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更改颜色或明暗级数;请像上面那样自己绘制亮度缓冲。
下一步
- Canvas API 列出了配置和渲染函数。
- Canvas 设计解释了着色和游程绘制。
- 与保留矢量多边形而非像素的 GSAP SVG 后端对比一下。