view 教程

本教程介绍如何在场景中放置相机并找出点落在屏幕上的位置:瞄准观察相机,用透视或正交投影进行投影,根据真实的镜头和传感器推导投影,制作滑动变焦动画,以及计算投影三角形内部的正确深度。

快速开始

按 core 教程中的方式安装模块,并在 core 旁边导入 view:

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

把立方体的两个对角投影到 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 是近处的左下角,距离两个单位;顶点 6 是远处的右上角,距离四个单位,因此更靠近屏幕中心 (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 后端按 terminal_y_scale 压缩 yy,因此垂直视场只覆盖中间一半的行;见 TUI 后端设计。

使用物理单位

WorldUnit 让你说明一个场景单位的含义。它不会改变画面(投影只取决于焦距与传感器高度之比),但 focal_length_world_units 和 sensor_height_world_units 以场景单位表示光学参数,例如用于在焦距处放置你自己的近平面。

常见陷阱

  • 相机后方的点。 没有任何裁剪。相机空间中 z≤0z \le 0 的顶点会投影到无穷远或镜像位置。让眼睛位于所有物体之外。
  • 向上方向与视线平行。 当 up 平行于 target - eye 时(例如以向上 =+y= +y 竖直向下看),look_at 会得到零标架和空白画面。请换一个向上向量。
  • 手性。 相机遵循 Direct3D 的左手约定:使用默认相机时,+x+x 在右,+y+y 向上,+z+z 远离你。为右手系编写的场景会显示为镜像。
  • 屏幕 yy 向下增大。 ProjectedVertex 中的 y 越大,在屏幕上的位置越低。
  • Camera3::default 接受一个距离。 它是普通的构造函数,而不是 Default trait,并把眼睛放在负 zz 轴上。
  • 重复构造标架。 world_to_camera_point 每次调用都会重建视图矩阵。对大量点,请调用一次 view_transform() 并使用 apply_point。

下一步

  • view API 列出了每个类型和函数。
  • view 设计推导了相机标架、投影、透视校正深度规则以及物理相机模型。
  • frontend 教程把网格和相机变成绘制列表。