frontend API

包 Luna-Flow/geometry3d/frontend 把场景和相机转换为与后端无关的、已着色并已投影的三角形列表(DrawList)。它还拥有 Canvas 后端和曝光效果使用的标量图像软件深度缓冲(LumaBuffer)、块匹配光流估计器、曝光设置和动画时间轴。它对字符、颜色和 DOM 一无所知。

import {
  "Luna-Flow/geometry3d/core",
  "Luna-Flow/geometry3d/view",
  "Luna-Flow/geometry3d/frontend",
  "Luna-Flow/linear-algebra/mutable" @la,
}

frontend 设计解释了管线、阴影贴图和光栅化规则;frontend 教程展示了它们的用法。

场景

SceneObject

SceneObject 是一个网格及其模型变换。

pub struct SceneObject {
  mesh : @core.Mesh
  transform : @core.Transform3
}

SceneObject::new

SceneObject::new(mesh, transform) 构造一个对象;同一个网格可以被多个对象共享。

pub fn SceneObject::new(@core.Mesh, @core.Transform3) -> Self

Light

Light 是方向光:direction 是世界空间中从场景指向光源的单位向量。

pub struct Light {
  direction : @mutable.Vector[Double]
}

Light::directional

Light::directional(direction) 构造一个光源,并对 direction 归一化。

pub fn Light::directional(@mutable.Vector[Double]) -> Self

零向量会得到一个什么也照不亮的光源。

Light::default

Light::default() 即 Light::directional((0.6, 0.7, -1.0)):来自右上方,与默认相机位于同一侧。

pub fn Light::default() -> Self

它是普通的构造函数,而不是 Default trait 的实现。

Scene

Scene 是由一个方向光照亮的对象列表。

pub struct Scene {
  objects : Array[SceneObject]
  light : Light
}

Scene::new, Scene::single

Scene::new(light) 构造空场景;Scene::single(mesh, transform, light) 构造只含一个对象的场景。

pub fn Scene::new(Light) -> Self
pub fn Scene::single(@core.Mesh, @core.Transform3, Light) -> Self

Scene::add_object

scene.add_object(object) 原地把对象追加到场景中。

pub fn Scene::add_object(Self, SceneObject) -> Unit
test "scene" {
  let scene = @frontend.Scene::new(@frontend.Light::default())
  scene.add_object(
    @frontend.SceneObject::new(@core.cube_mesh(1.0), @core.Transform3::identity()),
  )
  scene.add_object(
    @frontend.SceneObject::new(
      @core.sphere_mesh(0.5, 8, 12),
      @core.Transform3::translation(2.0, 0.0, 0.0),
    ),
  )
  inspect(scene.objects.length(), content="2")
  inspect(@core.vec_length(scene.light.direction), content="1")
}

渲染视图与绘制列表

RenderView

RenderView 是渲染场景时使用的相机和透视投影。

pub struct RenderView {
  camera : @view.Camera3
  projection : @view.PerspectiveProjection
}

RenderView::perspective, RenderView::scientific

RenderView::perspective(camera, projection) 把相机与投影配对;RenderView::scientific(camera, viewport) 取 ScientificCamera 的相机,并根据其镜头和传感器推导投影。

pub fn RenderView::perspective(@view.Camera3, @view.PerspectiveProjection) -> Self
pub fn RenderView::scientific(@view.ScientificCamera, @view.Viewport) -> Self

DrawTriangle

DrawTriangle 是一个带平面亮度的已投影三角形,亮度名义上位于 [0,1][0, 1];舍入可能使其超过 1 几个 ulp,后端会对其截断。

pub struct DrawTriangle {
  p0 : @view.ProjectedVertex
  p1 : @view.ProjectedVertex
  p2 : @view.ProjectedVertex
  intensity : Double
}

顶点采用视口单位,深度为相机空间深度,与 PerspectiveProjection 的输出一致。屏幕上的环绕方向未经规范化;光栅化器接受两种方向。

DrawTriangle::new

DrawTriangle::new(p0, p1, p2, intensity) 构造一个三角形,例如用于在没有场景的情况下驱动后端。

pub fn DrawTriangle::new(@view.ProjectedVertex, @view.ProjectedVertex, @view.ProjectedVertex, Double) -> Self

DrawList

DrawList 是每个后端都会消费的有序三角形列表。

pub struct DrawList {
  triangles : Array[DrawTriangle]
}

DrawList::new, DrawList::push_triangle

DrawList::new() 构造空列表,list.push_triangle(t) 原地追加一个三角形。

pub fn DrawList::new() -> Self
pub fn DrawList::push_triangle(Self, DrawTriangle) -> Unit

build_draw_list

build_draw_list(scene, view) 运行几何管线:模型变换、阴影贴图、视图变换、投影、背面剔除、带阴影的朗伯着色以及三角化。

pub fn build_draw_list(Scene, RenderView) -> DrawList

对于每个朝向相机的面,它推入两个三角形(对于退化四边形,第二个面积为零),亮度为

I=max⁡(0,n^⋅ℓ) (0.35+0.65 v),v∈{0,0.2,0.4,0.6,0.8,1},I = \max(0, \hat n \cdot \ell)\,\big(0.35 + 0.65\,v\big),\qquad v \in \{0, 0.2, 0.4, 0.6, 0.8, 1\},

其中 vv 是根据 128 × 128 阴影贴图,面的中心与四个顶点中光线能照到的比例。背向光源的面得到 I=0I = 0。三角形按场景顺序逐个对象、在对象内逐个面排列;列表不按深度排序。开销与顶点数和面数成线性关系,另加阴影贴图的光栅化。

test "draw list" {
  let scene = @frontend.Scene::single(
    @core.cube_mesh(1.0),
    @core.Transform3::rotation(0.4, 0.6, 0.0),
    @frontend.Light::default(),
  )
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(80, 40), 30.0),
  )
  let list = @frontend.build_draw_list(scene, view)
  // three faces of a turned cube face the camera, two triangles each
  inspect(list.triangles.length(), content="6")
  inspect(list.triangles.all(fn(t) { t.intensity >= 0.0 && t.intensity <= 1.0 }), content="true")
}

亮度缓冲

LUMA_FAR_DEPTH

LUMA_FAR_DEPTH 是空像素的深度,103010^{30}。

pub const LUMA_FAR_DEPTH : Double = 1.0e30

深度至少为 LUMA_FAR_DEPTH * 0.5 的像素会被后端视为背景。

LumaBuffer

LumaBuffer 是按行主序存储的标量亮度图像,每个像素带一个深度。

pub struct LumaBuffer {
  width : Int
  height : Int
  values : Array[Double]
  depths : Array[Double]
}

LumaBuffer::new

LumaBuffer::new(width, height) 构造一个缓冲,所有值为 0.0、所有深度为 LUMA_FAR_DEPTH。

pub fn LumaBuffer::new(Int, Int) -> Self

LumaBuffer::index

buffer.index(x, y) 返回数组下标 y * width + x,不做边界检查。

pub fn LumaBuffer::index(Self, Int, Int) -> Int

LumaBuffer::get, LumaBuffer::depth_at

buffer.get(x, y) 返回某个像素的值,buffer.depth_at(x, y) 返回其深度;在缓冲之外分别返回 0.0 和 LUMA_FAR_DEPTH。

pub fn LumaBuffer::get(Self, Int, Int) -> Double
pub fn LumaBuffer::depth_at(Self, Int, Int) -> Double

LumaBuffer::set_if_closer

buffer.set_if_closer(x, y, depth, value) 即深度测试:仅当 depth + DEPTH_EPSILON 小于已存深度时才写入该像素。

pub fn LumaBuffer::set_if_closer(Self, Int, Int, Double, Double) -> Unit

缓冲之外的写入会被忽略。深度相等时保留先写入的值。

LumaBuffer::draw_triangle

buffer.draw_triangle(t) 光栅化一个三角形:中心 (x+12,y+12)(x + \tfrac12, y + \tfrac12) 位于三角形内部或边上的每个像素,若用透视校正深度通过深度测试,就得到 t.intensity。

pub fn LumaBuffer::draw_triangle(Self, DrawTriangle) -> Unit

面积不超过 DEPTH_EPSILON 的三角形会被跳过。循环遍历三角形的包围盒,该包围盒不会被裁剪到缓冲范围内。

draw_list_to_luma

draw_list_to_luma(list, width, height) 创建一个缓冲,并按顺序把列表中的每个三角形绘制进去。

pub fn draw_list_to_luma(DrawList, Int, Int) -> LumaBuffer

LumaBuffer::add_weighted_sample

buffer.add_weighted_sample(sample, w) 原地把 w * sample.values[i] 加到每个值上,并保留两者中较小的深度。

pub fn LumaBuffer::add_weighted_sample(Self, Self, Double) -> Unit

只处理两个长度中较小者(min⁡\min)对应的前缀;请使用相同尺寸的缓冲。

average_luma

average_luma(a, b) 对两个相同尺寸的缓冲返回一个新缓冲,其值为两者的平均,深度为两者的最小值。

pub fn average_luma(LumaBuffer, LumaBuffer) -> LumaBuffer
test "luma buffer" {
  let near = @frontend.DrawTriangle::new(
    @view.ProjectedVertex::new(0.0, 0.0, 2.0),
    @view.ProjectedVertex::new(8.0, 0.0, 2.0),
    @view.ProjectedVertex::new(0.0, 8.0, 2.0),
    0.8,
  )
  let far = @frontend.DrawTriangle::new(
    @view.ProjectedVertex::new(0.0, 0.0, 5.0),
    @view.ProjectedVertex::new(8.0, 0.0, 5.0),
    @view.ProjectedVertex::new(0.0, 8.0, 5.0),
    0.3,
  )
  let list = @frontend.DrawList::new()
  list.push_triangle(far)
  list.push_triangle(near)
  let buffer = @frontend.draw_list_to_luma(list, 8, 8)
  inspect(buffer.get(1, 1), content="0.8")
  inspect(buffer.depth_at(1, 1), content="2")
  inspect(buffer.depth_at(7, 7) == @frontend.LUMA_FAR_DEPTH, content="true")
  let dark = @frontend.LumaBuffer::new(8, 8)
  inspect(@frontend.average_luma(buffer, dark).get(1, 1), content="0.4")
  dark.add_weighted_sample(buffer, 0.5)
  inspect(dark.get(1, 1), content="0.4")
}

曝光

ShutterSpeed

ShutterSpeed 是以秒为单位的曝光时间。

pub struct ShutterSpeed {
  seconds : Double
}

ShutterSpeed::seconds, ShutterSpeed::reciprocal

ShutterSpeed::seconds(t) 构造 t 秒的快门,ShutterSpeed::reciprocal(n) 构造 1/n 秒的快门;非正的参数得到 1/60 秒。

pub fn ShutterSpeed::seconds(Double) -> Self
pub fn ShutterSpeed::reciprocal(Double) -> Self

ExposureSettings

ExposureSettings 由快门、它所属的帧间隔以及需要平均的采样数组成。

pub struct ExposureSettings {
  shutter : ShutterSpeed
  frame_dt : Double
  samples : Int
}

ExposureSettings::new

ExposureSettings::new(shutter, frame_dt, samples) 构造设置:非正的 frame_dt 变为 1/60 秒,快门被截断为不超过 frame_dt,samples 被提升到至少 1。

pub fn ExposureSettings::new(ShutterSpeed, Double, Int) -> Self

ExposureSettings::auto

ExposureSettings::auto(shutter, frame_dt) 构造 samples = ceil(shutter / frame_dt) 的设置,该值在快门被截断之后计算。

pub fn ExposureSettings::auto(ShutterSpeed, Double) -> Self

由于截断后快门永远不会超过 frame_dt,auto 总是只得到一个采样。需要长曝光的调用者应自行选择更大的采样数,TUI 演示就是这样做的。

test "exposure" {
  let shutter = @frontend.ShutterSpeed::reciprocal(30.0)
  let settings = @frontend.ExposureSettings::auto(shutter, 1.0 / 60.0)
  inspect(settings.shutter.seconds == 1.0 / 60.0, content="true")
  inspect(settings.samples, content="1")
  let manual = @frontend.ExposureSettings::new(shutter, 1.0 / 24.0, 12)
  inspect(manual.samples, content="12")
}

光流

FlowVector

FlowVector 是整数像素位移。

pub struct FlowVector {
  dx : Int
  dy : Int
}

FlowVector::new

FlowVector::new(dx, dy) 构造一个位移。

pub fn FlowVector::new(Int, Int) -> Self

FlowField

FlowField 是按行主序存储的位移场,每个像素一个位移。

pub struct FlowField {
  width : Int
  height : Int
  vectors : Array[FlowVector]
}

FlowField::new, FlowField::index, FlowField::get, FlowField::set

FlowField::new(w, h) 构造零场;index 为 y * width + x;get 在场外返回 (0, 0),set 忽略场外的写入。

pub fn FlowField::new(Int, Int) -> Self
pub fn FlowField::index(Self, Int, Int) -> Int
pub fn FlowField::get(Self, Int, Int) -> FlowVector
pub fn FlowField::set(Self, Int, Int, FlowVector) -> Unit

estimate_optical_flow

estimate_optical_flow(previous, current, search_radius, patch_radius) 为 current 的每个像素估计其邻域来自 previous 中的何处。

pub fn estimate_optical_flow(LumaBuffer, LumaBuffer, Int, Int) -> FlowField

对每个像素 (x,y)(x, y),它返回满足 ∣dx∣,∣dy∣≤|d_x|, |d_y| \le search_radius 的位移 (dx,dy)(d_x, d_y),使 previous 中以 (x+dx,y+dy)(x + d_x, y + d_y) 为中心的 (2P+1)2(2P + 1)^2 块与 current 中以 (x,y)(x, y) 为中心的块之间的差平方和最小,其中 PP = patch_radius。缓冲之外的像素读作 0.0。并列时保留扫描顺序(先 dyd_y 后 dxd_x,从 −R-R 开始递增)中的第一个位移,因此在每个位移都同样吻合的均匀区域中,结果是 (−R,−R)(-R, -R) 而不是 (0,0)(0, 0)。负半径按 0 处理。开销为 O(WH(2R+1)2(2P+1)2)O\big(W H (2R + 1)^2 (2P + 1)^2\big)。

align_with_flow

align_with_flow(previous, current, flow) 把 previous 扭曲到 current 的像素网格上:结果的像素 (x,y)(x, y) 取 previous 中 (x+dx,y+dy)(x + d_x, y + d_y) 处的值和深度。

pub fn align_with_flow(LumaBuffer, LumaBuffer, FlowField) -> LumaBuffer

accumulate_with_flow

accumulate_with_flow(previous, current, flow) 返回扭曲后的 previous 与 current 的平均,深度取自 current。

pub fn accumulate_with_flow(LumaBuffer, LumaBuffer, FlowField) -> LumaBuffer
test "optical flow" {
  let previous = @frontend.LumaBuffer::new(6, 6)
  let current = @frontend.LumaBuffer::new(6, 6)
  previous.set_if_closer(2, 2, 1.0, 1.0) // a bright pixel at (2, 2)
  current.set_if_closer(3, 2, 1.0, 1.0) // has moved one pixel right
  let flow = @frontend.estimate_optical_flow(previous, current, 2, 1)
  let v = flow.get(3, 2)
  debug_inspect((v.dx, v.dy), content="(-1, 0)")
  let aligned = @frontend.align_with_flow(previous, current, flow)
  inspect(aligned.get(3, 2), content="1")
  inspect(@frontend.accumulate_with_flow(previous, current, flow).get(3, 2), content="1")
  // far from the moving pixel every displacement fits: the first one wins
  let flat = flow.get(0, 5)
  debug_inspect((flat.dx, flat.dy), content="(-2, -2)")
}

时间轴

Timeline

Timeline 是固定帧率的片段:以秒为单位的时长和帧率。

pub struct Timeline {
  duration_seconds : Double
  fps : Int
}

Timeline::new

Timeline::new(duration, fps) 构造时间轴;非正的时长变为 1 秒,小于 1 的 fps 变为 1。

pub fn Timeline::new(Double, Int) -> Self

Timeline::frame_count, Timeline::frame_dt

frame_count 返回 max⁡(1,⌈D⋅fps⌉)\max(1, \lceil D \cdot \mathit{fps} \rceil),frame_dt 返回 1/fps1/\mathit{fps}。

pub fn Timeline::frame_count(Self) -> Int
pub fn Timeline::frame_dt(Self) -> Double

TimelineSample

TimelineSample 是时间轴中的一帧:它的下标、时间 t=k/fpst = k/\mathit{fps} 和进度 min⁡(1,t/D)\min(1, t/D)。

pub struct TimelineSample {
  frame_index : Int
  time_seconds : Double
  progress : Double
}

Timeline::sample

timeline.sample(k) 返回第 k 帧的采样;负下标按 0 处理。允许超出末尾的下标,其进度为 1。

pub fn Timeline::sample(Self, Int) -> TimelineSample

ScalarKeyframe

ScalarKeyframe 是某一时刻的一个值。

pub struct ScalarKeyframe {
  time_seconds : Double
  value : Double
}

ScalarKeyframe::new

ScalarKeyframe::new(time, value) 构造一个关键帧。

pub fn ScalarKeyframe::new(Double, Double) -> Self

ScalarTrack

ScalarTrack 是一条穿过按时间排序的关键帧的分段线性动画曲线。

pub struct ScalarTrack {
  keyframes : Array[ScalarKeyframe]
}

ScalarTrack::new

ScalarTrack::new(keyframes) 构造一条轨道;关键帧必须按时间递增排序。

pub fn ScalarTrack::new(Array[ScalarKeyframe]) -> Self

ScalarTrack::sample

track.sample(t) 返回时刻 t 的值:在相邻关键帧之间线性插值,第一个关键帧之前取第一个值,最后一个之后取最后一个值,空轨道返回 0.0。

pub fn ScalarTrack::sample(Self, Double) -> Double

当两个关键帧时间相同(相差在 DEPTH_EPSILON 以内)时,该时刻取后一个值。

test "timeline" {
  let timeline = @frontend.Timeline::new(1.0, 4)
  inspect(timeline.frame_count(), content="4")
  let s = timeline.sample(2)
  debug_inspect((s.time_seconds, s.progress), content="(0.5, 0.5)")
  let track = @frontend.ScalarTrack::new([
    @frontend.ScalarKeyframe::new(0.0, 0.0),
    @frontend.ScalarKeyframe::new(1.0, 10.0),
  ])
  inspect(track.sample(0.25), content="2.5")
  inspect(track.sample(5.0), content="10")
}