view チュートリアル

このチュートリアルでは、シーンにカメラを置き、点が画面のどこに来るかを求める方法を説明します。ルックアットカメラを向ける、透視投影や正射影で投影する、実際のレンズとセンサーから投影を導く、ドリーズームをアニメーションさせる、投影された三角形の内部の正しい深度を計算する、です。

クイックスタート

core チュートリアルと同じようにモジュールをインストールし、core と一緒に view をインポートします。

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

立方体の向かい合う 2 つの角を 80 × 40 のビューポートに投影します。

fn main {
  let camera = @view.Camera3::default(3.0)
  let viewport = @view.Viewport::new(80, 40)
  let projection = @view.PerspectiveProjection::new(viewport, 40.0)
  let cube = @core.cube_mesh(1.0)
  for i in [0, 6] {
    let p = projection.project_point(
      camera.world_to_camera_point(cube.vertices[i]),
    )
    println("vertex \{i}: x=\{p.x} y=\{p.y} depth=\{p.depth}")
  }
}

出力:

vertex 0: x=20 y=40 depth=2
vertex 6: x=50 y=10 depth=4

カメラは z=−3z = -3 にあって原点を向いています。手前左下の角である頂点 0 は 2 単位、奥右上の角である頂点 6 は 4 単位離れているので、画面の中心 (40,20)(40, 20) により近くなります。画面の yy は下に向かって増えます。

よくある作業

カメラを向ける

Camera3::look_at は視点、見る点、おおよその上方向を取ります。up が視線に直交していなくても、導かれる座標系は正規直交です。

test "aim the camera" {
  let camera = @view.Camera3::look_at(
    @core.vec3(5.0, 5.0, 0.0),
    @core.vec3(0.0, 0.0, 0.0),
    @core.vec3(0.0, 1.0, 0.0),
  )
  let f = camera.forward()
  let u = camera.true_up()
  inspect(f.dot(u).abs() < 1.0e-12, content="true")
  // the target lies straight ahead, sqrt(50) units away
  let t = camera.world_to_camera_point(@core.vec3(0.0, 0.0, 0.0))
  inspect(t[0].abs() < 1.0e-12 && t[1].abs() < 1.0e-12, content="true")
  inspect((t[2] - 50.0.sqrt()).abs() < 1.0e-12, content="true")
}

透視投影と正射影を比べる

透視投影では同じ大きさでも距離とともに小さくなりますが、正射影ではそうなりません。

test "perspective versus orthographic" {
  let viewport = @view.Viewport::new(100, 100)
  let persp = @view.PerspectiveProjection::new(viewport, 100.0)
  let ortho = @view.OrthographicProjection::new(viewport, 10.0)
  let width_at = fn(z : Double, project : (@la.Vector[Double]) -> @view.ProjectedVertex) {
    project(@core.vec3(1.0, 0.0, z)).x - project(@core.vec3(-1.0, 0.0, z)).x
  }
  inspect(width_at(2.0, fn(p) { persp.project_point(p) }), content="100")
  inspect(width_at(4.0, fn(p) { persp.project_point(p) }), content="50")
  inspect(width_at(2.0, fn(p) { ortho.project_point(p) }), content="20")
  inspect(width_at(4.0, fn(p) { ortho.project_point(p) }), content="20")
}

レンズから投影を導く

スケールを推測する代わりに、カメラを物理的に記述します。投影スケールは Hf/hH f / h なので、垂直方向の画角がビューポートの高さを満たします。

fn degrees(radians : Double) -> Double {
  (radians * 180.0 / @math.PI * 10.0).round() / 10.0
}

test "lens and sensor" {
  let sensor = @view.SensorSpec::full_frame()
  for mm in [24.0, 50.0, 200.0] {
    let lens = @view.LensSpec::new(mm)
    println("\{mm} mm: \{degrees(lens.horizontal_fov(sensor))} x \{degrees(lens.vertical_fov(sensor))} degrees")
  }
  let camera = @view.ScientificCamera::new(
    @view.Camera3::default(5.0),
    sensor,
    @view.LensSpec::new(24.0),
    @view.WorldUnit::unitless(),
  )
  let viewport = @view.Viewport::new(640, 480)
  let projection = camera.to_perspective_projection(viewport)
  inspect(projection.scale, content="480")
}

出力:

24 mm: 73.7 x 53.1 degrees
50 mm: 39.6 x 27 degrees
200 mm: 10.3 x 6.9 degrees

ドリーズームをアニメーションさせる

カメラを後ろへ下げ、それに比例して焦点距離を伸ばします(f=f0 d/d0f = f_0\,d/d_0)。注視点にある物体は画面上の大きさを保ち、その後ろにあるものはすべて大きくなります。

fn projected_height(distance : Double, focal_mm : Double, object_z : Double) -> Double {
  let camera = @view.ScientificCamera::new(
    @view.Camera3::default(distance),
    @view.SensorSpec::full_frame(),
    @view.LensSpec::new(focal_mm),
    @view.WorldUnit::unitless(),
  )
  let viewport = @view.Viewport::new(640, 480)
  let projection = camera.to_perspective_projection(viewport)
  let top = projection.project_point(
    camera.camera.world_to_camera_point(@core.vec3(0.0, 1.0, object_z)),
  )
  let bottom = projection.project_point(
    camera.camera.world_to_camera_point(@core.vec3(0.0, -1.0, object_z)),
  )
  bottom.y - top.y
}

test "dolly zoom" {
  let (d0, f0) = (4.0, 24.0)
  for d in [4.0, 8.0] {
    let f = f0 * d / d0
    let subject = projected_height(d, f, 0.0).round()
    let background = projected_height(d, f, 6.0).round()
    println("d=\{d} f=\{f}: subject \{subject} px, background \{background} px")
  }
}

出力:

d=4 f=24: subject 240 px, background 96 px
d=8 f=48: subject 240 px, background 137 px

ピクセルの下の深度を求める

投影された三角形の中でピクセルの重心座標がわかっているラスタライザは、interpolate_perspective_depth でそこにある 3D の面の深度を得られます。補間するのは投影が保つ量である 1/z1/z です。

test "depth under a pixel" {
  let viewport = @view.Viewport::new(100, 100)
  let projection = @view.PerspectiveProjection::new(viewport, 50.0)
  // a floor triangle receding from depth 2 to depth 6
  let a = projection.project_point(@core.vec3(-1.0, -1.0, 2.0))
  let b = projection.project_point(@core.vec3(1.0, -1.0, 2.0))
  let c = projection.project_point(@core.vec3(0.0, -1.0, 6.0))
  let z = @view.interpolate_perspective_depth(a, b, c, 0.25, 0.25, 0.5)
  inspect(z, content="3")
  // linear interpolation would claim 4
  inspect(0.25 * a.depth + 0.25 * b.depth + 0.5 * c.depth, content="4")
}

さらに進んで

カメラをフロントエンドに渡す

frontend パッケージは Camera3 と PerspectiveProjection を RenderView にまとめます。RenderView::scientific(camera, viewport) は ScientificCamera からそれを作り、デモもこの方法を使っています。frontend チュートリアルを見てください。

縦横比に注意する

スケールはセンサーの高さをビューポートの高さに結び付けます。ビューポートの縦横比がセンサーと違うと、表示される水平方向の視野は horizontal_fov と異なります。フルサイズセンサー(3:2)なら、720 × 480 のような 3:2 のビューポートで水平方向の視野がちょうど一致します。ターミナルでは TUI バックエンドが yy を terminal_y_scale で圧縮するので、垂直方向の視野は中央の半分の行を占めます。TUI バックエンドの設計を見てください。

物理単位で扱う

WorldUnit でシーンの 1 単位が何を意味するかを示せます。画は変わりません(投影は焦点距離とセンサーの高さの比だけで決まります)が、focal_length_world_units と sensor_height_world_units が光学系をシーンの単位で表すので、たとえば焦点距離の位置に自分でニア平面を置くのに使えます。

よくある落とし穴

  • カメラの後ろの点。 何もクリッピングされません。カメラ空間で z≤0z \le 0 の頂点は無限遠か鏡像の位置に投影されます。視点はすべての物体の外に置いてください。
  • 上方向が視線と平行。 up が target - eye と平行な look_at(たとえば上方向 =+y= +y で真下を見る)は零の座標系と空の画になります。別の上向きベクトルを選んでください。
  • 左右の系。 カメラは Direct3D の左手系の規約に従います。既定のカメラでは +x+x が右、+y+y が上、+z+z があなたから遠ざかる向きです。右手系向けに作ったシーンは鏡像で表示されます。
  • 画面の yy は下に向かって増える。 ProjectedVertex の y が大きいほど画面の下になります。
  • Camera3::default は距離を取る。 これは Default トレイトではなく通常のコンストラクタで、視点を負の zz 軸上に置きます。
  • 座標系の繰り返しの構築。 world_to_camera_point は呼び出しのたびにビュー行列を作り直します。多数の点には view_transform() を一度呼び、apply_point を使ってください。

次のステップ

  • view API にはすべての型と関数が載っています。
  • カメラの座標系、投影、透視補正された深度の規則、物理カメラのモデルは view の設計で導出しています。
  • frontend チュートリアルでは、メッシュとカメラを描画リストに変えます。