frontend 教程
本教程介绍如何描述一个场景,把它渲染成与后端无关的 DrawList,并使用前端的图像工具:亮度缓冲、阴影、长曝光和时间轴。最后你会自己写一个小小的后端。
快速开始
在 core 和 view 旁边导入 frontend:
import {
"moonbitlang/core/math",
"Luna-Flow/geometry3d/core",
"Luna-Flow/geometry3d/view",
"Luna-Flow/geometry3d/frontend",
"Luna-Flow/linear-algebra/mutable" @la,
}
把一个旋转过的立方体渲染到 24 × 10 的亮度缓冲中,并用你选择的三个字符打印出来:
fn main {
let scene = @frontend.Scene::single(
@core.cube_mesh(1.0),
@core.Transform3::rotation(0.5, 0.7, 0.0),
@frontend.Light::default(),
)
let viewport = @view.Viewport::new(24, 10)
let projection = @view.PerspectiveProjection::new(viewport, 9.0)
let view = @frontend.RenderView::perspective(@view.Camera3::default(4.0), projection)
let luma = @frontend.draw_list_to_luma(@frontend.build_draw_list(scene, view), 24, 10)
for y in 0..<10 {
let row = StringBuilder()
for x in 0..<24 {
let v = luma.get(x, y)
row.write_char(if luma.depth_at(x, y) >= @frontend.LUMA_FAR_DEPTH { '.' } else if v > 0.5 { '#' } else { '+' })
}
println(row.to_string())
}
}
输出:
........................
............+#..........
..........+++#..........
.........++++##.........
.........+++###.........
........++++###.........
.........+++###.........
...........++##.........
........................
........................
立方体占据图像的中部。# 标记明亮的受光面,+ 标记较暗的面。前端只计算了三角形和经过深度测试的标量图像;字符是这个程序自己的后端。TUI 后端以宽高比校正和明暗字符表正式地完成同样的工作。
常见任务
组合场景
场景是由一个方向光照亮的对象列表,每个对象是一个带模型变换的网格。网格可以在对象之间共享:
fn two_cubes() -> @frontend.Scene {
let scene = @frontend.Scene::new(
@frontend.Light::directional(@core.vec3(0.3, 1.0, -0.5)),
)
let cube = @core.cube_mesh(0.5)
scene.add_object(
@frontend.SceneObject::new(cube, @core.Transform3::translation(-1.0, 0.0, 0.0)),
)
scene.add_object(
@frontend.SceneObject::new(
cube,
@core.Transform3::rotation(0.0, 0.8, 0.0).compose(
@core.Transform3::translation(1.0, 0.0, 1.0),
),
),
)
scene
}
test "compose a scene" {
let scene = two_cubes()
inspect(scene.objects.length(), content="2")
}
通过物理相机渲染
RenderView::scientific 根据传感器和镜头推导投影,因此当视口尺寸改变时图像保持构图不变:
test "physical camera" {
let camera = @view.ScientificCamera::new(
@view.Camera3::default(6.0),
@view.SensorSpec::full_frame(),
@view.LensSpec::new(35.0),
@view.WorldUnit::unitless(),
)
let small = @frontend.RenderView::scientific(camera, @view.Viewport::new(80, 40))
let large = @frontend.RenderView::scientific(camera, @view.Viewport::new(160, 80))
inspect(large.projection.scale / small.projection.scale, content="2")
let list = @frontend.build_draw_list(two_cubes(), small)
inspect(list.triangles.length() > 0, content="true")
}
在数字中看到阴影
一块从正上方照亮的薄板单独存在时是完全受光的;在它上方放一个立方体,下方的面会损失最多 65% 的亮度。比较两个场景中薄板最亮的三角形:
fn slab_scene(with_cube : Bool) -> @frontend.Scene {
let scene = @frontend.Scene::new(
@frontend.Light::directional(@core.vec3(0.0, 1.0, 0.0)),
)
scene.add_object(
@frontend.SceneObject::new(
@core.cube_mesh(1.0),
@core.Transform3::scale(0.6, 0.05, 0.6).compose(
@core.Transform3::translation(0.0, -1.0, 0.0),
),
),
)
if with_cube {
scene.add_object(
@frontend.SceneObject::new(
@core.cube_mesh(1.0),
@core.Transform3::translation(0.0, 1.5, 0.0),
),
)
}
scene
}
fn top_intensity(scene : @frontend.Scene) -> Double {
let view = @frontend.RenderView::perspective(
@view.Camera3::look_at(
@core.vec3(0.0, 4.0, -4.0),
@core.vec3(0.0, -1.0, 0.0),
@core.vec3(0.0, 1.0, 0.0),
),
@view.PerspectiveProjection::new(@view.Viewport::new(80, 40), 40.0),
)
let list = @frontend.build_draw_list(scene, view)
// the slab is the first object, so its triangles come first
let mut best = 0.0
for i in 0..<4 {
if list.triangles[i].intensity > best {
best = list.triangles[i].intensity
}
}
best
}
test "shadow" {
let round2 = fn(x : Double) { (x * 100.0).round() / 100.0 }
inspect(round2(top_intensity(slab_scene(false))), content="1")
inspect(round2(top_intensity(slab_scene(true))), content="0.35")
}
0.35 是环境光份额:完全处于阴影中的面保留其朗伯亮度的 35%。
从亮度缓冲读取深度
draw_list_to_luma 把绘制列表光栅化到一个 LumaBuffer 中。每个像素保存最近表面的亮度和相机空间深度;空像素的深度为 LUMA_FAR_DEPTH:
test "depth buffer" {
let scene = @frontend.Scene::single(
@core.cube_mesh(1.0),
@core.Transform3::identity(),
@frontend.Light::default(),
)
let view = @frontend.RenderView::perspective(
@view.Camera3::default(4.0),
@view.PerspectiveProjection::new(@view.Viewport::new(40, 40), 20.0),
)
let luma = @frontend.draw_list_to_luma(@frontend.build_draw_list(scene, view), 40, 40)
// the front face of the cube is 3 units from the eye
inspect(luma.depth_at(20, 20), content="3")
inspect(luma.depth_at(0, 0) == @frontend.LUMA_FAR_DEPTH, content="true")
}
制作长曝光
对在连续时刻得到的多次渲染求平均。运动的边缘变模糊,静止的部分保持清晰:
fn spinning_cube_luma(angle : Double) -> @frontend.LumaBuffer {
let scene = @frontend.Scene::single(
@core.cube_mesh(1.0),
@core.Transform3::rotation(0.3, angle, 0.0),
@frontend.Light::default(),
)
let view = @frontend.RenderView::perspective(
@view.Camera3::default(4.0),
@view.PerspectiveProjection::new(@view.Viewport::new(40, 20), 16.0),
)
@frontend.draw_list_to_luma(@frontend.build_draw_list(scene, view), 40, 20)
}
test "long exposure" {
let samples = 8
let exposure = @frontend.LumaBuffer::new(40, 20)
for k in 0..<samples {
exposure.add_weighted_sample(
spinning_cube_luma(k.to_double() * 0.05),
1.0 / samples.to_double(),
)
}
let sharp = spinning_cube_luma(0.35)
let mut changed = 0
for i in 0..<sharp.values.length() {
if (sharp.values[i] - exposure.values[i]).abs() > 0.05 {
changed += 1
}
}
inspect(changed > 0, content="true")
}
用时间轴制作动画
Timeline 把时长和帧率转换为各帧的时刻;ScalarTrack 在这些时刻插值关键帧的值:
test "timeline" {
let timeline = @frontend.Timeline::new(2.0, 2)
let distance = @frontend.ScalarTrack::new([
@frontend.ScalarKeyframe::new(0.0, 4.0),
@frontend.ScalarKeyframe::new(2.0, 8.0),
])
let lines = []
for k in 0..<timeline.frame_count() {
let s = timeline.sample(k)
lines.push("frame \{k}: t=\{s.time_seconds} distance=\{distance.sample(s.time_seconds)}")
}
inspect(
lines.join("\n"),
content=(
#|frame 0: t=0 distance=4
#|frame 1: t=0.5 distance=5
#|frame 2: t=1 distance=6
#|frame 3: t=1.5 distance=7
),
)
}
最后一帧始于 ;各帧时刻为 ,其中 ,因此结束时刻 本身不会被采样。
进阶
编写自己的后端
后端是从 DrawList 到输出的函数。这个后端输出 SVG 多边形,并按由远到近排序,使后面的多边形覆盖前面的:
fn to_svg_polygons(list : @frontend.DrawList) -> Array[String] {
let sorted = list.triangles.copy()
sorted.sort_by(fn(a, b) {
let da = a.p0.depth + a.p1.depth + a.p2.depth
let db = b.p0.depth + b.p1.depth + b.p2.depth
db.compare(da)
})
sorted.map(fn(t) {
let grey = (t.intensity * 255.0).round().to_int()
"<polygon points=\"\{t.p0.x},\{t.p0.y} \{t.p1.x},\{t.p1.y} \{t.p2.x},\{t.p2.y}\" fill=\"rgb(\{grey},\{grey},\{grey})\"/>"
})
}
test "own backend" {
let list = @frontend.DrawList::new()
list.push_triangle(
@frontend.DrawTriangle::new(
@view.ProjectedVertex::new(0.0, 0.0, 1.0),
@view.ProjectedVertex::new(4.0, 0.0, 1.0),
@view.ProjectedVertex::new(0.0, 4.0, 1.0),
1.0,
),
)
inspect(
to_svg_polygons(list)[0],
content="<polygon points=\"0,0 4,0 0,4\" fill=\"rgb(255,255,255)\"/>",
)
}
本质上,这就是 GSAP SVG 后端。当三角形相交时,画家排序只是近似的;像 LumaBuffer 这样的深度缓冲才是精确的。
用光流对齐曝光采样
estimate_optical_flow(previous, current, R, P) 为 current 的每个像素在 像素范围内寻找进入 previous 的位移,使其 块匹配得最好。然后 align_with_flow 把 previous 扭曲到 current 上,扭曲后的采样可以像上面那样累积,以减少重影。开销按每像素 增长,因此两个半径都要保持较小(TUI 演示使用 、)。
性能
build_draw_list 与场景大小成线性关系,但每次调用都会把整个场景再光栅化一次到 128 × 128 的阴影贴图中。光栅化开销与每个三角形的包围盒面积成正比,并且不会裁剪到缓冲范围内,因此请让几何体保持在视野之内。
常见陷阱
- 眼睛后方的几何体。 没有任何裁剪。到达相机位置的物体会产生 的顶点,它们会投影到无穷远或在屏幕上镜像。
- 光照方向。
Light::directional接受指向光源的方向。“向下照射”的光是vec3(0.0, 1.0, 0.0)。 - 两个尺寸。 投影的视口与传给
draw_list_to_luma(或后端配置)的尺寸彼此独立。请使用相同的尺寸,否则图像会被裁切或偏离中心。 - 值与覆盖。 被覆盖的像素的亮度可能是
0.0(背向光源的面)。要知道某处是否有表面,请测试depth_at(x, y) < LUMA_FAR_DEPTH。 ExposureSettings::auto只返回一个采样。 它会先把快门截断到帧间隔。要得到可见的长曝光,请自己选择采样数。- 未排序的关键帧。
ScalarTrack假定时间递增,并且不会排序。 - 平坦区域中的光流。 在每个位移都同样吻合的地方,
estimate_optical_flow返回 ,而不是 。
下一步
- frontend API 记录了每个类型和函数。
- frontend 设计推导了阴影测试、光栅化规则和深度缓冲不变量。
- 选择一个后端:TUI、Canvas 或 GSAP SVG。