view 设计
设计目标
view 回答一个问题:世界空间中的点出现在屏幕上的什么位置,它有多远?答案必须足够精确,使每个后端(终端字符单元、画布像素、SVG 多边形)显示同一幅画面;足够简单,能用三个公式表述;并且按摄影师的思维方式参数化:一个传感器、一个镜头和一个距离,而不是一个抽象的视场角数值。
数学背景
管线分为三个阶段,每个阶段都是坐标系之间的映射:
p world ⏟ core → V p camera ⏟ x right , y up , z forward → project ( x s , y s ) ⏟ viewport units , d e p t h = z . \underbrace{p_{\text{world}}}_{\texttt{core}}
\xrightarrow{\ V\ }
\underbrace{p_{\text{camera}}}_{x \text{ right},\ y \text{ up},\ z \text{ forward}}
\xrightarrow{\ \text{project}\ }
\underbrace{(x_s, y_s)}_{\text{viewport units}},\ \mathit{depth} = z . core p world V x right , y up , z forward p camera project viewport units ( x s , y s ) , depth = z .
相机标架
Camera3 由眼睛 e e e 、目标 t t t 和近似的向上向量 a a a 给出。它推导出
f = t − e ∥ t − e ∥ , r = a × f ∥ a × f ∥ , u = f × r . f = \frac{t - e}{\lVert t - e \rVert},\qquad
r = \frac{a \times f}{\lVert a \times f \rVert},\qquad
u = f \times r . f = ∥ t − e ∥ t − e , r = ∥ a × f ∥ a × f , u = f × r .
这三个向量构成一个正交标架。由叉积的构造,r r r 垂直于 f f f ,而 u = f × r u = f \times r u = f × r 同时垂直于二者;∥ u ∥ = ∥ f ∥ ∥ r ∥ sin 90 ° = 1 \lVert u \rVert = \lVert f \rVert \lVert r \rVert \sin 90° = 1 ∥ u ∥ = ∥ f ∥ ∥ r ∥ sin 90° = 1 ,因此代码中最后的归一化除舍入外不改变任何东西。三重积给出定向:
r × u = r × ( f × r ) = f ( r ⋅ r ) − r ( r ⋅ f ) = f . r \times u = r \times (f \times r) = f\,(r \cdot r) - r\,(r \cdot f) = f . r × u = r × ( f × r ) = f ( r ⋅ r ) − r ( r ⋅ f ) = f .
u u u 是 a a a 垂直于 f f f 的分量再归一化:由同一恒等式 f × ( a × f ) = a − ( a ⋅ f ) f f \times (a \times f) = a - (a \cdot f)\, f f × ( a × f ) = a − ( a ⋅ f ) f ,因此屏幕上的“上”在观察方向允许的范围内尽量接近 a a a 。若 a ∥ f a \parallel f a ∥ f ,叉积为零,normalize_vec 返回零,标架退化;这是 look_at 的前提条件,而不是被检查的错误。
设 R = [ r ∣ u ∣ f ] R = [\,r \mid u \mid f\,] R = [ r ∣ u ∣ f ] 是以标架为列的矩阵。它是正交的,因此 R − 1 = R T R^{-1} = R^\mathsf{T} R − 1 = R T 。相机到世界的映射把相机坐标 c c c 送到 e + R c e + R c e + R c ;取其逆得到世界到相机的映射
c = R T ( p − e ) = R T p − R T e , V = ( R T − R T e 0 T 1 ) = ( r T − r ⋅ e u T − u ⋅ e f T − f ⋅ e 0 T 1 ) , c = R^\mathsf{T} (p - e) = R^\mathsf{T} p - R^\mathsf{T} e,
\qquad
V = \begin{pmatrix} R^\mathsf{T} & -R^\mathsf{T} e \\ 0^\mathsf{T} & 1 \end{pmatrix}
= \begin{pmatrix}
r^\mathsf{T} & -r \cdot e \\
u^\mathsf{T} & -u \cdot e \\
f^\mathsf{T} & -f \cdot e \\
0^\mathsf{T} & 1
\end{pmatrix}, c = R T ( p − e ) = R T p − R T e , V = ( R T 0 T − R T e 1 ) = r T u T f T 0 T − r ⋅ e − u ⋅ e − f ⋅ e 1 ,
这正是 Camera3::view_transform 构造的矩阵。两项检查:V e = 0 V e = 0 V e = 0 (眼睛移到原点)以及 V t = ( 0 , 0 , ∥ t − e ∥ ) V t = (0, 0, \lVert t - e \rVert) V t = ( 0 , 0 , ∥ t − e ∥) (目标位于正 z z z 轴的正前方)。由于 V V V 是刚体运动(det R T = + 1 \det R^\mathsf{T} = +1 det R T = + 1 ),它保持长度、角度和点积。特别地,朗伯项 n ^ ⋅ ℓ \hat n \cdot \ell n ^ ⋅ ℓ 在世界空间和相机空间中取值相同,这就是前端可以在视图变换之后计算光照的原因。
手性
屏幕把 r r r 显示为向右、u u u 显示为向上,而 f = r × u f = r \times u f = r × u 指向屏幕内部。按右手系理解坐标轴时,右 × \times × 上应指向 观察者;而这里它背离观察者。因此该约定是 Direct3D 的左手约定:使用 Camera3::default 时,世界 + x +x + x 出现在右侧,+ y +y + y 向上,+ z +z + z 远离观察者。生成网格的正面在屏幕上呈顺时针,与 Direct3D 一样。geometry3d 中的一切都与这种理解一致;为右手系建模的场景会左右镜像。
透视投影
位于原点、沿 + z +z + z 观察的针孔相机,把点 ( x , y , z ) (x, y, z) ( x , y , z ) 成像在针孔前方距离 φ \varphi φ 处的平面上。由相似三角形,像位于
( φ x z , φ y z ) . \left( \varphi \frac{x}{z},\ \varphi \frac{y}{z} \right). ( φ z x , φ z y ) .
PerspectiveProjection::project_point 按每单位像素数对其缩放,把原点移到 W × H W \times H W × H 视口的中心,并翻转 y y y ,使其像屏幕行号那样向下增大。把 φ \varphi φ 和像素密度合并为一个比例 s s s :
x s = W 2 + s x z , y s = H 2 − s y z . x_s = \frac{W}{2} + s\,\frac{x}{z},\qquad
y_s = \frac{H}{2} - s\,\frac{y}{z} . x s = 2 W + s z x , y s = 2 H − s z y .
在齐次坐标中,这就是内参矩阵 K K K 后接透视除法:
K = ( s 0 W / 2 0 − s H / 2 0 0 1 ) , K ( x y z ) = ( s x + W 2 z − s y + H 2 z z ) → ÷ z ( x s y s 1 ) . K = \begin{pmatrix} s & 0 & W/2 \\ 0 & -s & H/2 \\ 0 & 0 & 1 \end{pmatrix},\qquad
K \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} s x + \tfrac{W}{2} z \\ -s y + \tfrac{H}{2} z \\ z \end{pmatrix}
\ \xrightarrow{\ \div z\ }\
\begin{pmatrix} x_s \\ y_s \\ 1 \end{pmatrix}. K = s 0 0 0 − s 0 W /2 H /2 1 , K x y z = s x + 2 W z − sy + 2 H z z ÷ z x s y s 1 .
通常的图形管线把 K K K 拆成到规范化设备坐标 [ − 1 , 1 ] 2 [-1, 1]^2 [ − 1 , 1 ] 2 的投影和单独的视口变换。view 把二者合并,因为中间没有任何阶段需要规范化坐标。它还保留相机空间的 z z z 作为深度,而不是规范化深度。形如 a + b / z a + b/z a + b / z 的规范化深度是 z z z 的单调函数,因此二者给出相同的深度测试结果。保留 z z z 使深度值能以世界单位读出,并让光栅化器可以直接插值 1 / z 1/z 1/ z (见下文)。
正交投影
去掉除法就得到平行投影 x s = W / 2 + s x x_s = W/2 + s x x s = W /2 + s x 、y s = H / 2 − s y y_s = H/2 - s y y s = H /2 − sy ,此时 s s s 以每个世界单位的像素数计。尺寸不再随距离缩小。OrthographicProjection 用于点和示意图;前端不用它进行光栅化。
透视校正深度
光栅化器知道投影后的顶点 p 0 , p 1 , p 2 p_0, p_1, p_2 p 0 , p 1 , p 2 ,以及每个像素的屏幕空间重心坐标 λ i \lambda_i λ i ,满足 ∑ λ i = 1 \sum \lambda_i = 1 ∑ λ i = 1 和 p = ∑ λ i p i p = \sum \lambda_i p_i p = ∑ λ i p i 。它需要投影到该像素的 3D 点的深度 z z z 。用 λ \lambda λ 对 z z z 做线性插值是错误的,因为投影不保持直线上的比例。正确的规则是:
在投影后的三角形上,1 / z 1/z 1/ z 是屏幕位置的仿射函数,因此 1 z = ∑ i λ i z i \dfrac{1}{z} = \displaystyle\sum_i \frac{\lambda_i}{z_i} z 1 = i ∑ z i λ i 。
推导。设 P = ∑ μ i P i P = \sum \mu_i P_i P = ∑ μ i P i 为 3D 点,其 3D 重心坐标为 μ i \mu_i μ i (∑ μ i = 1 \sum \mu_i = 1 ∑ μ i = 1 ),于是其深度为 z = ∑ μ i z i z = \sum \mu_i z_i z = ∑ μ i z i 。从中心度量屏幕位置,x ~ s = x s − W / 2 \tilde x_s = x_s - W/2 x ~ s = x s − W /2 。则
x ~ s ( P ) = s x z = s z ∑ i μ i x i = ∑ i μ i z i z ⋅ s x i z i = ∑ i μ i z i z x ~ s ( P i ) , \begin{aligned}
\tilde x_s(P) = s\,\frac{x}{z} = \frac{s}{z} \sum_i \mu_i x_i
= \sum_i \frac{\mu_i z_i}{z} \cdot s\,\frac{x_i}{z_i}
= \sum_i \frac{\mu_i z_i}{z}\, \tilde x_s(P_i),
\end{aligned} x ~ s ( P ) = s z x = z s i ∑ μ i x i = i ∑ z μ i z i ⋅ s z i x i = i ∑ z μ i z i x ~ s ( P i ) ,
对 y y y 也有同样的计算。权重 μ i z i / z \mu_i z_i / z μ i z i / z 之和为 ( ∑ μ i z i ) / z = 1 \big(\sum \mu_i z_i\big)/z = 1 ( ∑ μ i z i ) / z = 1 ,因此它们就是 p p p 的屏幕空间重心坐标,而对非退化三角形,重心坐标是唯一的:
λ i = μ i z i z . \lambda_i = \frac{\mu_i z_i}{z} . λ i = z μ i z i .
除以 z i z_i z i 并求和,
∑ i λ i z i = ∑ i μ i z = 1 z . ■ \sum_i \frac{\lambda_i}{z_i} = \sum_i \frac{\mu_i}{z} = \frac{1}{z}. \qquad\blacksquare i ∑ z i λ i = i ∑ z μ i = z 1 . ■
interpolate_perspective_depth 计算的正是这个式子。线性插值的误差可能很大:在深度从 1 1 1 到 3 3 3 的边上,屏幕中点的真实深度为 ( 1 2 ⋅ 1 + 1 2 ⋅ 1 3 ) − 1 = 1.5 (\tfrac12 \cdot 1 + \tfrac12 \cdot \tfrac13)^{-1} = 1.5 ( 2 1 ⋅ 1 + 2 1 ⋅ 3 1 ) − 1 = 1.5 ,而线性平均给出 2 2 2 。在两个相交或几乎接触的表面之间,这一差异决定了哪个可见;仓库中有一个测试检查深度缓冲使用的是正确的值。
由推导可得三点:
屏幕坐标的任何仿射变换都不改变 λ i \lambda_i λ i ,因为重心坐标是仿射不变量。TUI 后端的终端 y y y 缩放就是这样的映射,因此在它之后该规则依然精确。
对正交投影,z z z 本身就是屏幕位置的仿射函数,1 / z 1/z 1/ z 规则反而不精确;这就是前端在其(正交的)阴影贴图中使用线性深度,并且从不把正交投影的三角形交给这些光栅化器的原因。
该规则要求 z i > 0 z_i > 0 z i > 0 。当某个顶点位于眼睛处或其后方时,代码回退为线性插值,至少结果是有限的;不过由于没有任何裁剪,这种情况下图像本来就是错的。
物理相机
焦距为 f f f (mm)、传感器高为 h h h (mm)的真实相机,按 φ = f \varphi = f φ = f 的针孔公式,把 ( x , y , z ) (x, y, z) ( x , y , z ) 成像在传感器上高 f y / z f\,y/z f y / z mm 处。若传感器高度显示在视口的 H H H 行上,则每毫米有 H / h H/h H / h 个像素,因此
y s − H 2 = − H h ⋅ f y z ⟹ s = H f h , y_s - \frac{H}{2} = -\frac{H}{h} \cdot f\,\frac{y}{z}
\quad\Longrightarrow\quad
s = \frac{H f}{h}, y s − 2 H = − h H ⋅ f z y ⟹ s = h H f ,
这就是 ScientificCamera::projection_scale。沿传感器某一尺寸 d d d 的视场角,可由针孔、传感器中心和传感器边缘构成的直角三角形得到:
tan θ d 2 = d / 2 f ⟹ θ d = 2 arctan d 2 f , \tan\frac{\theta_d}{2} = \frac{d/2}{f}
\quad\Longrightarrow\quad
\theta_d = 2 \arctan\frac{d}{2 f}, tan 2 θ d = f d /2 ⟹ θ d = 2 arctan 2 f d ,
这就是 LensSpec::horizontal_fov、vertical_fov 和 diagonal_fov 的公式。使用该比例时,位于垂直视场边缘的点(y / z = tan ( θ h / 2 ) = h / 2 f y/z = \tan(\theta_h/2) = h/2f y / z = tan ( θ h /2 ) = h /2 f )落在 y s = H / 2 − s h / 2 f = 0 y_s = H/2 - s\,h/2f = 0 y s = H /2 − s h /2 f = 0 处,即最上面一行:垂直视场角恰好覆盖视口高度。水平方向上,视口覆盖 2 arctan ( W H ⋅ h 2 f ) 2\arctan\!\big(\tfrac{W}{H} \cdot \tfrac{h}{2f}\big) 2 arctan ( H W ⋅ 2 f h ) ,仅当 W / H = w / h W/H = w/h W / H = w / h 时才等于传感器的水平视场。因此 640 × 480(4:3)的画布配全画幅传感器(3:2)时,显示的水平视场比镜头的略窄。
单位在 s s s 中相互抵消:f f f 和 h h h 都以毫米计,而 x / z x/z x / z 是两个世界长度之比。把整个场景和相机距离按同一因子缩放,图像不变,因此 WorldUnit 不进入投影。它被 focal_length_world_units 和 sensor_height_world_units 使用,供需要的调用者以场景单位表示光学参数。
滑动变焦
距离为 d d d 、高度为 Y Y Y 的物体看上去有 s Y / d = H f Y / ( h d ) s\,Y/d = H f Y / (h d) s Y / d = H f Y / ( h d ) 像素高。要在相机移动时保持其大小不变,需要 f / d f/d f / d 为常数,即 f ( d ) = f 0 d / d 0 f(d) = f_0\, d / d_0 f ( d ) = f 0 d / d 0 。此时位于其他距离 d + Δ d + \Delta d + Δ 的物体按系数 d d + Δ ⋅ d 0 + Δ d 0 \frac{d}{d + \Delta} \cdot \frac{d_0 + \Delta}{d_0} d + Δ d ⋅ d 0 d 0 + Δ 改变大小,这就是 demo 包用 ScientificCamera::with_lens 制作动画的“眩晕”效果。
设计决策
带推导标架的观察相机
问题:调用者应能在不计算正交标架的情况下瞄准相机。观察描述 ( e , t , a ) (e, t, a) ( e , t , a ) 符合人们的思维方式,而上面类似 Gram–Schmidt 的构造把它转换为刚体变换。存储这三个向量(而不是矩阵)使 Camera3 便于做动画:移动 eye,保持 target。代价是每次调用 world_to_camera_* 都会重建标架;前端逐顶点调用它,在演示规模下这是可以接受的。
合并投影与视口,保留相机深度
规范化设备坐标的存在是为了让 GPU 能够裁剪并映射到任意帧缓冲。geometry3d 没有裁剪器,每个后端都以视口单位绘制,因此单独的 NDC 阶段只会多一步而不增加能力。保留 z z z 作为深度,使深度缓冲保存的是沿观察轴的距离,并把透视校正规则变成上面那个简短的公式。
用物理参数描述透视
一个视场角数值隐藏了摄影师分别控制的两样东西:镜头和传感器。ScientificCamera 同时接受二者,这使滑动变焦只需改变一个焦距,并使比例由第一性原理推出。比例与视口高度 绑定,因此当视口宽度变化时,垂直视场角——大多数相机和图形 API 的约定——保持不变。
宽松的构造函数
传感器、镜头和世界单位的构造函数把非正值替换为 1.0,而不是返回错误。这些值来自代码而非用户,而在渲染器中,一幅可见但错误的画面比每帧都走一条错误路径更容易调试。Viewport::new 和 Camera3::look_at 完全不做校验。
正确性与不变量
只要 up 不平行于观察方向,( r , u , f ) (r, u, f) ( r , u , f ) 就是满足 r × u = f r \times u = f r × u = f 的正交标架。
view_transform 是刚体运动:它把眼睛映射到原点、把目标映射到 ( 0 , 0 , ∥ t − e ∥ ) (0, 0, \lVert t - e \rVert) ( 0 , 0 , ∥ t − e ∥) ,并保持距离和点积。
当 z > 0 z > 0 z > 0 时,PerspectiveProjection::project_point 与 K K K 后接透视除法一致;depth 是相机空间的 z z z ,因此深度比较就是沿观察轴的距离比较。
如上推导,当三个顶点的深度都超过 DEPTH_EPSILON 时,interpolate_perspective_depth 返回像素下方 3D 点的精确深度(在舍入意义下)。
projection_scale 把垂直视场角映射到视口高度:s tan ( θ h / 2 ) = H / 2 s \tan(\theta_h/2) = H/2 s tan ( θ h /2 ) = H /2 。
所有函数都是常数时间;批量投影函数与点数成线性关系。
被否决的方案
用 Transform3 应用 4×4 投影矩阵。 这可行(apply_point 会除以 w w w ),但会把深度替换为规范化值,并且之后还需要一个视口步骤;三个显式公式更清晰。
针对近平面裁剪。 这是处理穿过眼睛平面的几何体的正确方法,但它会把三角形拆成多边形,并且每条路径都需要裁剪器。演示程序转而让所有几何体都位于相机前方。
视场角参数。 它可由 LensSpec 和 SensorSpec 推出,但无法同样直接地表达传感器的更换或滑动变焦。
边界
view 不会:
裁剪几何体、定义近或远平面,或处理位于眼睛处或其后方的点;
把屏幕点反投影为射线;
模拟景深、镜头畸变、曝光或不居中的主点;
校正非正方形的像素或终端字符单元(这由 TUI 后端负责);
光栅化、着色或拥有任何输出格式。