view 设计

设计目标

view 回答一个问题:世界空间中的点出现在屏幕上的什么位置,它有多远?答案必须足够精确,使每个后端(终端字符单元、画布像素、SVG 多边形)显示同一幅画面;足够简单,能用三个公式表述;并且按摄影师的思维方式参数化:一个传感器、一个镜头和一个距离,而不是一个抽象的视场角数值。

数学背景

管线分为三个阶段,每个阶段都是坐标系之间的映射:

pworld⏟core→ V pcamera⏟x right, y up, z forward→ project (xs,ys)⏟viewport units, depth=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 .

相机标架

Camera3 由眼睛 ee、目标 tt 和近似的向上向量 aa 给出。它推导出

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 .

这三个向量构成一个正交标架。由叉积的构造,rr 垂直于 ff,而 u=f×ru = f \times r 同时垂直于二者;∥u∥=∥f∥∥r∥sin⁡90°=1\lVert u \rVert = \lVert f \rVert \lVert r \rVert \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 .

uu 是 aa 垂直于 ff 的分量再归一化:由同一恒等式 f×(a×f)=a−(a⋅f) ff \times (a \times f) = a - (a \cdot f)\, f,因此屏幕上的“上”在观察方向允许的范围内尽量接近 aa。若 a∥fa \parallel f,叉积为零,normalize_vec 返回零,标架退化;这是 look_at 的前提条件,而不是被检查的错误。

视图变换

设 R=[ r∣u∣f ]R = [\,r \mid u \mid f\,] 是以标架为列的矩阵。它是正交的,因此 R−1=RTR^{-1} = R^\mathsf{T}。相机到世界的映射把相机坐标 cc 送到 e+Rce + R c;取其逆得到世界到相机的映射

c=RT(p−e)=RTp−RTe,V=(RT−RTe0T1)=(rT−r⋅euT−u⋅efT−f⋅e0T1),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},

这正是 Camera3::view_transform 构造的矩阵。两项检查:Ve=0V e = 0(眼睛移到原点)以及 Vt=(0,0,∥t−e∥)V t = (0, 0, \lVert t - e \rVert)(目标位于正 zz 轴的正前方)。由于 VV 是刚体运动(det⁡RT=+1\det R^\mathsf{T} = +1),它保持长度、角度和点积。特别地,朗伯项 n^⋅ℓ\hat n \cdot \ell 在世界空间和相机空间中取值相同,这就是前端可以在视图变换之后计算光照的原因。

手性

屏幕把 rr 显示为向右、uu 显示为向上,而 f=r×uf = r \times u 指向屏幕内部。按右手系理解坐标轴时,右 ×\times 上应指向观察者;而这里它背离观察者。因此该约定是 Direct3D 的左手约定:使用 Camera3::default 时,世界 +x+x 出现在右侧,+y+y 向上,+z+z 远离观察者。生成网格的正面在屏幕上呈顺时针,与 Direct3D 一样。geometry3d 中的一切都与这种理解一致;为右手系建模的场景会左右镜像。

透视投影

位于原点、沿 +z+z 观察的针孔相机,把点 (x,y,z)(x, y, z) 成像在针孔前方距离 φ\varphi 处的平面上。由相似三角形,像位于

(φxz, φyz).\left( \varphi \frac{x}{z},\ \varphi \frac{y}{z} \right).

PerspectiveProjection::project_point 按每单位像素数对其缩放,把原点移到 W×HW \times H 视口的中心,并翻转 yy,使其像屏幕行号那样向下增大。把 φ\varphi 和像素密度合并为一个比例 ss:

xs=W2+s xz,ys=H2−s yz.x_s = \frac{W}{2} + s\,\frac{x}{z},\qquad y_s = \frac{H}{2} - s\,\frac{y}{z} .

在齐次坐标中,这就是内参矩阵 KK 后接透视除法:

K=(s0W/20−sH/2001),K(xyz)=(sx+W2z−sy+H2zz) → ÷z  (xsys1).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}.

通常的图形管线把 KK 拆成到规范化设备坐标 [−1,1]2[-1, 1]^2 的投影和单独的视口变换。view 把二者合并,因为中间没有任何阶段需要规范化坐标。它还保留相机空间的 zz 作为深度,而不是规范化深度。形如 a+b/za + b/z 的规范化深度是 zz 的单调函数,因此二者给出相同的深度测试结果。保留 zz 使深度值能以世界单位读出,并让光栅化器可以直接插值 1/z1/z(见下文)。

正交投影

去掉除法就得到平行投影 xs=W/2+sxx_s = W/2 + s x、ys=H/2−syy_s = H/2 - s y,此时 ss 以每个世界单位的像素数计。尺寸不再随距离缩小。OrthographicProjection 用于点和示意图;前端不用它进行光栅化。

透视校正深度

光栅化器知道投影后的顶点 p0,p1,p2p_0, p_1, p_2,以及每个像素的屏幕空间重心坐标 λi\lambda_i,满足 ∑λi=1\sum \lambda_i = 1 和 p=∑λipip = \sum \lambda_i p_i。它需要投影到该像素的 3D 点的深度 zz。用 λ\lambda 对 zz 做线性插值是错误的,因为投影不保持直线上的比例。正确的规则是:

在投影后的三角形上,1/z1/z 是屏幕位置的仿射函数,因此 1z=∑iλizi\dfrac{1}{z} = \displaystyle\sum_i \frac{\lambda_i}{z_i}。

推导。设 P=∑μiPiP = \sum \mu_i P_i 为 3D 点,其 3D 重心坐标为 μi\mu_i(∑μi=1\sum \mu_i = 1),于是其深度为 z=∑μiziz = \sum \mu_i z_i。从中心度量屏幕位置,x~s=xs−W/2\tilde x_s = x_s - W/2。则

x~s(P)=s xz=sz∑iμixi=∑iμiziz⋅s xizi=∑iμiziz x~s(Pi),\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}

对 yy 也有同样的计算。权重 μizi/z\mu_i z_i / z 之和为 (∑μizi)/z=1\big(\sum \mu_i z_i\big)/z = 1,因此它们就是 pp 的屏幕空间重心坐标,而对非退化三角形,重心坐标是唯一的:

λi=μiziz.\lambda_i = \frac{\mu_i z_i}{z} .

除以 ziz_i 并求和,

∑iλizi=∑iμiz=1z.■\sum_i \frac{\lambda_i}{z_i} = \sum_i \frac{\mu_i}{z} = \frac{1}{z}. \qquad\blacksquare

interpolate_perspective_depth 计算的正是这个式子。线性插值的误差可能很大:在深度从 11 到 33 的边上,屏幕中点的真实深度为 (12⋅1+12⋅13)−1=1.5(\tfrac12 \cdot 1 + \tfrac12 \cdot \tfrac13)^{-1} = 1.5,而线性平均给出 22。在两个相交或几乎接触的表面之间,这一差异决定了哪个可见;仓库中有一个测试检查深度缓冲使用的是正确的值。

由推导可得三点:

  • 屏幕坐标的任何仿射变换都不改变 λi\lambda_i,因为重心坐标是仿射不变量。TUI 后端的终端 yy 缩放就是这样的映射,因此在它之后该规则依然精确。
  • 对正交投影,zz 本身就是屏幕位置的仿射函数,1/z1/z 规则反而不精确;这就是前端在其(正交的)阴影贴图中使用线性深度,并且从不把正交投影的三角形交给这些光栅化器的原因。
  • 该规则要求 zi>0z_i > 0。当某个顶点位于眼睛处或其后方时,代码回退为线性插值,至少结果是有限的;不过由于没有任何裁剪,这种情况下图像本来就是错的。

物理相机

焦距为 ff(mm)、传感器高为 hh(mm)的真实相机,按 φ=f\varphi = f 的针孔公式,把 (x,y,z)(x, y, z) 成像在传感器上高 f y/zf\,y/z mm 处。若传感器高度显示在视口的 HH 行上,则每毫米有 H/hH/h 个像素,因此

ys−H2=−Hh⋅f yz⟹s=Hfh,y_s - \frac{H}{2} = -\frac{H}{h} \cdot f\,\frac{y}{z} \quad\Longrightarrow\quad s = \frac{H f}{h},

这就是 ScientificCamera::projection_scale。沿传感器某一尺寸 dd 的视场角,可由针孔、传感器中心和传感器边缘构成的直角三角形得到:

tan⁡θd2=d/2f⟹θd=2arctan⁡d2f,\tan\frac{\theta_d}{2} = \frac{d/2}{f} \quad\Longrightarrow\quad \theta_d = 2 \arctan\frac{d}{2 f},

这就是 LensSpec::horizontal_fov、vertical_fov 和 diagonal_fov 的公式。使用该比例时,位于垂直视场边缘的点(y/z=tan⁡(θh/2)=h/2fy/z = \tan(\theta_h/2) = h/2f)落在 ys=H/2−s h/2f=0y_s = H/2 - s\,h/2f = 0 处,即最上面一行:垂直视场角恰好覆盖视口高度。水平方向上,视口覆盖 2arctan⁡ ⁣(WH⋅h2f)2\arctan\!\big(\tfrac{W}{H} \cdot \tfrac{h}{2f}\big),仅当 W/H=w/hW/H = w/h 时才等于传感器的水平视场。因此 640 × 480(4:3)的画布配全画幅传感器(3:2)时,显示的水平视场比镜头的略窄。

单位在 ss 中相互抵消:ff 和 hh 都以毫米计,而 x/zx/z 是两个世界长度之比。把整个场景和相机距离按同一因子缩放,图像不变,因此 WorldUnit 不进入投影。它被 focal_length_world_units 和 sensor_height_world_units 使用,供需要的调用者以场景单位表示光学参数。

滑动变焦

距离为 dd、高度为 YY 的物体看上去有 s Y/d=HfY/(hd)s\,Y/d = H f Y / (h d) 像素高。要在相机移动时保持其大小不变,需要 f/df/d 为常数,即 f(d)=f0 d/d0f(d) = f_0\, d / d_0。此时位于其他距离 d+Δd + \Delta 的物体按系数 dd+Δ⋅d0+Δd0\frac{d}{d + \Delta} \cdot \frac{d_0 + \Delta}{d_0} 改变大小,这就是 demo 包用 ScientificCamera::with_lens 制作动画的“眩晕”效果。

设计决策

带推导标架的观察相机

问题:调用者应能在不计算正交标架的情况下瞄准相机。观察描述 (e,t,a)(e, t, a) 符合人们的思维方式,而上面类似 Gram–Schmidt 的构造把它转换为刚体变换。存储这三个向量(而不是矩阵)使 Camera3 便于做动画:移动 eye,保持 target。代价是每次调用 world_to_camera_* 都会重建标架;前端逐顶点调用它,在演示规模下这是可以接受的。

合并投影与视口,保留相机深度

规范化设备坐标的存在是为了让 GPU 能够裁剪并映射到任意帧缓冲。geometry3d 没有裁剪器,每个后端都以视口单位绘制,因此单独的 NDC 阶段只会多一步而不增加能力。保留 zz 作为深度,使深度缓冲保存的是沿观察轴的距离,并把透视校正规则变成上面那个简短的公式。

用物理参数描述透视

一个视场角数值隐藏了摄影师分别控制的两样东西:镜头和传感器。ScientificCamera 同时接受二者,这使滑动变焦只需改变一个焦距,并使比例由第一性原理推出。比例与视口高度绑定,因此当视口宽度变化时,垂直视场角——大多数相机和图形 API 的约定——保持不变。

宽松的构造函数

传感器、镜头和世界单位的构造函数把非正值替换为 1.0,而不是返回错误。这些值来自代码而非用户,而在渲染器中,一幅可见但错误的画面比每帧都走一条错误路径更容易调试。Viewport::new 和 Camera3::look_at 完全不做校验。

正确性与不变量

  • 只要 up 不平行于观察方向,(r,u,f)(r, u, f) 就是满足 r×u=fr \times u = f 的正交标架。
  • view_transform 是刚体运动:它把眼睛映射到原点、把目标映射到 (0,0,∥t−e∥)(0, 0, \lVert t - e \rVert),并保持距离和点积。
  • 当 z>0z > 0 时,PerspectiveProjection::project_point 与 KK 后接透视除法一致;depth 是相机空间的 zz,因此深度比较就是沿观察轴的距离比较。
  • 如上推导,当三个顶点的深度都超过 DEPTH_EPSILON 时,interpolate_perspective_depth 返回像素下方 3D 点的精确深度(在舍入意义下)。
  • projection_scale 把垂直视场角映射到视口高度:stan⁡(θh/2)=H/2s \tan(\theta_h/2) = H/2。
  • 所有函数都是常数时间;批量投影函数与点数成线性关系。

被否决的方案

  • 用 Transform3 应用 4×4 投影矩阵。 这可行(apply_point 会除以 ww),但会把深度替换为规范化值,并且之后还需要一个视口步骤;三个显式公式更清晰。
  • 针对近平面裁剪。 这是处理穿过眼睛平面的几何体的正确方法,但它会把三角形拆成多边形,并且每条路径都需要裁剪器。演示程序转而让所有几何体都位于相机前方。
  • 视场角参数。 它可由 LensSpec 和 SensorSpec 推出,但无法同样直接地表达传感器的更换或滑动变焦。

边界

view 不会:

  • 裁剪几何体、定义近或远平面,或处理位于眼睛处或其后方的点;
  • 把屏幕点反投影为射线;
  • 模拟景深、镜头畸变、曝光或不居中的主点;
  • 校正非正方形的像素或终端字符单元(这由 TUI 后端负责);
  • 光栅化、着色或拥有任何输出格式。