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
    ),
  )
}

最后一帧始于 t=1.5t = 1.5;各帧时刻为 k/fpsk/\mathit{fps},其中 k<⌈D⋅fps⌉k < \lceil D \cdot \mathit{fps} \rceil,因此结束时刻 DD 本身不会被采样。

进阶

编写自己的后端

后端是从 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 的每个像素在 ±R\pm R 像素范围内寻找进入 previous 的位移,使其 (2P+1)2(2P+1)^2 块匹配得最好。然后 align_with_flow 把 previous 扭曲到 current 上,扭曲后的采样可以像上面那样累积,以减少重影。开销按每像素 (2R+1)2(2P+1)2(2R+1)^2 (2P+1)^2 增长,因此两个半径都要保持较小(TUI 演示使用 R=4R = 4、P=1P = 1)。

性能

build_draw_list 与场景大小成线性关系,但每次调用都会把整个场景再光栅化一次到 128 × 128 的阴影贴图中。光栅化开销与每个三角形的包围盒面积成正比,并且不会裁剪到缓冲范围内,因此请让几何体保持在视野之内。

常见陷阱

  • 眼睛后方的几何体。 没有任何裁剪。到达相机位置的物体会产生 z≤0z \le 0 的顶点,它们会投影到无穷远或在屏幕上镜像。
  • 光照方向。 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 返回 (−R,−R)(-R, -R),而不是 (0,0)(0, 0)。

下一步