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 の輝度バッファに描画し、好きな 3 文字で出力します。

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 バックエンドは同じ仕事を、アスペクト補正とシェードランプを使ってきちんと行います。

よくある作業

シーンを組み立てる

シーンは 1 つの平行光源に照らされたオブジェクトのリストで、各オブジェクトはモデル変換付きのメッシュです。メッシュはオブジェクト間で共有できます。

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% 失います。2 つのシーンで板の最も明るい三角形を比べてみましょう。

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<⌈D⋅fps⌉k < \lceil D \cdot \mathit{fps} \rceil について k/fpsk/\mathit{fps} なので、終了時刻 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 の各ピクセルについて、previous への ±R\pm R ピクセル以内の変位のうち (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) です。
  • 2 つのサイズ。 投影のビューポートと draw_list_to_luma(またはバックエンドの設定)に渡すサイズは独立しています。同じサイズを使わないと、画像が切れたり中央からずれたりします。
  • 値と被覆。 覆われたピクセルの輝度が 0.0 のこともあります(光源に背を向けた面)。そこに面があるかどうかは depth_at(x, y) < LUMA_FAR_DEPTH で判定してください。
  • ExposureSettings::auto は 1 サンプルを返す。 先にシャッターをフレーム間隔にクランプするからです。目に見える長時間露光にするには、サンプル数を自分で選んでください。
  • 並べ替えられていないキーフレーム。 ScalarTrack は時刻が昇順であることを前提とし、並べ替えはしません。
  • 平坦な領域でのフロー。 どの変位も同じくらい合うところでは、estimate_optical_flow は (0,0)(0, 0) ではなく (−R,−R)(-R, -R) を返します。

次のステップ

  • frontend API にはすべての型と関数が記述されています。
  • 影の判定、ラスタライズの規則、深度バッファの不変条件は frontend の設計で導出しています。
  • バックエンドを選びましょう:TUI、Canvas、GSAP SVG。