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
相机位于 ,朝向原点。顶点 0 是近处的左下角,距离两个单位;顶点 6 是远处的右上角,距离四个单位,因此更靠近屏幕中心 。屏幕 向下增大。
常见任务
瞄准相机
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")
}
根据镜头推导投影
不要去猜比例,而是用物理方式描述相机。投影比例为 ,因此垂直视场角填满视口高度:
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
制作滑动变焦动画
把相机后移并按比例加长焦距,。位于目标处的物体在屏幕上保持大小,其后的所有物体都会变大:
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 表面的深度。它插值的是 ,这是投影所保持的量:
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 压缩 ,因此垂直视场只覆盖中间一半的行;见 TUI 后端设计。
使用物理单位
WorldUnit 让你说明一个场景单位的含义。它不会改变画面(投影只取决于焦距与传感器高度之比),但 focal_length_world_units 和 sensor_height_world_units 以场景单位表示光学参数,例如用于在焦距处放置你自己的近平面。
常见陷阱
- 相机后方的点。 没有任何裁剪。相机空间中 的顶点会投影到无穷远或镜像位置。让眼睛位于所有物体之外。
- 向上方向与视线平行。 当
up平行于target - eye时(例如以向上 竖直向下看),look_at会得到零标架和空白画面。请换一个向上向量。 - 手性。 相机遵循 Direct3D 的左手约定:使用默认相机时, 在右, 向上, 远离你。为右手系编写的场景会显示为镜像。
- 屏幕 向下增大。
ProjectedVertex中的y越大,在屏幕上的位置越低。 Camera3::default接受一个距离。 它是普通的构造函数,而不是Defaulttrait,并把眼睛放在负 轴上。- 重复构造标架。
world_to_camera_point每次调用都会重建视图矩阵。对大量点,请调用一次view_transform()并使用apply_point。
下一步
- view API 列出了每个类型和函数。
- view 设计推导了相机标架、投影、透视校正深度规则以及物理相机模型。
- frontend 教程把网格和相机变成绘制列表。