backend/canvas 教程

本教程介绍如何把 geometry3d 场景放到网页上:建立一个 JavaScript 目标的包,在 <canvas> 上绘制一帧,用 requestAnimationFrame 让它动起来,并编写一个使用自定义颜色的变体。

快速开始

该后端需要 js 目标和 rabbita 的 DOM 绑定:

moon add Luna-Flow/geometry3d@0.5.1
moon add Luna-Flow/linear-algebra@0.4.2
moon add moonbit-community/rabbita@0.12.4

在 moon.pkg 中为浏览器声明一个可执行包:

import {
  "Luna-Flow/geometry3d/core",
  "Luna-Flow/geometry3d/view",
  "Luna-Flow/geometry3d/frontend",
  "Luna-Flow/geometry3d/backend/canvas",
  "moonbit-community/rabbita/dom",
}

supported_targets = "js"

pkgtype(kind: "executable")

在 id 为 scene 的元素中绘制一帧:

fn main {
  let canvas = @dom.document()
    .get_element_by_id("scene")
    .to_option()
    .unwrap()
    .to_html_canvas_element()
    .unwrap()
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(480, 360), 300.0),
  )
  let scene = @frontend.Scene::single(
    @core.torus_mesh(1.6, 0.55, 32, 18),
    @core.Transform3::rotation(1.0, 0.4, 0.0),
    @frontend.Light::default(),
  )
  let list = @frontend.build_draw_list(scene, view)
  @canvas.render_canvas(canvas, list, @canvas.CanvasRenderConfig::sized(480, 360))
}

用 moon build --target js 构建,并在包含 <canvas id="scene"></canvas> 的页面中加载生成的 JavaScript 文件:

<!doctype html>
<canvas id="scene"></canvas>
<script type="module" src="./main.js"></script>

页面会在深蓝色背景上显示一个青色的平面着色圆环。仓库中的 just canvas-build 配方展示了 moon 把输出放在哪里(_build/js/debug/build/<package>/<package>.js),并把它复制到 index.html 旁边。

常见任务

用 requestAnimationFrame 制作动画

请求浏览器在每次重绘前回调,按给定的时间戳(毫秒)渲染场景,然后再次请求:

fn start_animation(context : @dom.CanvasRenderingContext2D) -> Unit {
  let config = @canvas.CanvasRenderConfig::sized(480, 360)
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(480, 360), 300.0),
  )
  let window = @dom.window()
  fn frame(timestamp : Double) -> Unit {
    let angle = timestamp * 0.001
    let scene = @frontend.Scene::single(
      @core.cube_mesh(1.0),
      @core.Transform3::rotation(0.7 * angle, angle, 0.25 * angle),
      @frontend.Light::default(),
    )
    @canvas.render_scene(context, scene, view, config)
    ignore(window.request_animation_frame(frame))
  }
  ignore(window.request_animation_frame(frame))
}

先把画布的 width 和 height 属性设为配置尺寸,再用 canvas.get_context("2d").to0().unwrap() 获取一次上下文;render_canvas 在单帧时会把这两件事都做好。

用镜头构图

使用 ScientificCamera,让构图由焦距决定,而不是手动调节比例。在 640 × 480 的画布和全画幅传感器下,35 mm 镜头的垂直视场约为 38°:

fn lens_view(focal_mm : Double) -> @frontend.RenderView {
  let camera = @view.ScientificCamera::new(
    @view.Camera3::default(6.2),
    @view.SensorSpec::full_frame(),
    @view.LensSpec::new(focal_mm),
    @view.WorldUnit::unitless(),
  )
  @frontend.RenderView::scientific(camera, @view.Viewport::new(640, 480))
}

test "lens view" {
  let degrees = @view.LensSpec::new(35.0).vertical_fov(@view.SensorSpec::full_frame()) * 180.0 / @math.PI
  inspect(degrees.round(), content="38")
  inspect(lens_view(35.0).projection.scale, content="700")
}

用自定义颜色绘制

CanvasRenderConfig 固定了颜色,其字段无法在你的包中设置。需要其他调色板时,请自己用前端光栅化并绘制像素:

fn paint_amber(context : @dom.CanvasRenderingContext2D, list : @frontend.DrawList, w : Int, h : Int) -> Unit {
  context.set_fill_style(@js.Union3::from0("rgb(20, 10, 0)"))
  context.fill_rect(0.0, 0.0, w.to_double(), h.to_double())
  let luma = @frontend.draw_list_to_luma(list, w, h)
  for y in 0..<h {
    for x in 0..<w {
      if luma.depth_at(x, y) < @frontend.LUMA_FAR_DEPTH * 0.5 {
        let v = luma.get(x, y)
        let r = (255.0 * v).round().to_int()
        let g = (176.0 * v).round().to_int()
        context.set_fill_style(@js.Union3::from0("rgb(\{r}, \{g}, 0)"))
        context.fill_rect(x.to_double(), y.to_double(), 1.0, 1.0)
      }
    }
  }
}

这个示意版本每个像素调用一次;后端会把相同的相邻像素合并成像素段,从而调用次数少得多,正如其设计所解释的。moonbit-community/rabbita/js 的导入提供了 @js.Union3。

进阶

从页面切换场景

仓库中的 demo_canvas 包把所选场景保存在一个 Ref 中,根据 <select> 元素的 change 事件更新它,并在动画回调中读取它。demo_canvas 教程对此有详细讲解。

性能

每帧都在 MoonBit 中光栅化整个画布,然后对每段明暗相同的像素发出一次 fillRect。平面着色的场景像素段很长,因此 640 × 480 在当前浏览器中能流畅地播放动画。渲染开销随画布面积增长;为节省时间,可以渲染较小的画布并用 CSS 放大(image-rendering: pixelated 能保持像素清晰)。

常见陷阱

  • 目标不对。 该包只能为 js 构建。请在每个导入它的包中添加 supported_targets = "js",否则在其他目标上构建会失败。
  • 两个尺寸。 投影的视口、CanvasRenderConfig 以及画布元素的 width 和 height 属性要使用相同的尺寸。仅设置 CSS 尺寸只会拉伸位图。
  • 没有 2D 上下文。 当 getContext("2d") 失败时(例如画布已被用于 WebGL),render_canvas 会中止。
  • 黑色不是背景。 背向光源的面会被画成黑色,与背景色不同。
  • 只读配置。 你无法通过 CanvasRenderConfig 更改颜色或明暗级数;请像上面那样自己绘制亮度缓冲。

下一步