MVP 四次变换
一个顶点从模型文件里的局部坐标,变成屏幕上的像素坐标,要连续经过四次空间变换。本渲染器把矩阵命名为 UNITY_MATRIX_M、UNITY_MATRIX_M_V、UNITY_MATRIX_M_V_P、UNITY_MATRIX_VP——和 Unity URP 完全对齐。
四次变换的链路
模型空间 ─M→ 世界空间 ─V→ 观察空间 ─P→ 裁剪空间 ─÷w→ NDC ─视口→ 屏幕
每一步的含义:
变换 矩阵 从 → 到 干什么 模型 M M M 模型 → 世界 把物体摆到场景里(位置、旋转、缩放) 观察 V V V 世界 → 观察 换到相机视角,相机在原点朝 -Z 投影 P P P 观察 → 裁剪 把视锥压成立方体,引入透视 透视除法 ÷w w w 裁剪 → NDC 把 w w w 拉平,得到归一化坐标
合并写成一次矩阵乘(列向量约定,与 Unity HLSL 一致):
v clip = P V M v model v_{\text{clip}} = P\,V\,M\,v_{\text{model}} v clip = P V M v model
注意乘的顺序:先 M 后 V 后 P ,离顶点最近的 M M M 在最右边。Unity HLSL 里是 mul(UNITY_MATRIX_MVP, v),本项目代码沿用同样的顺序。
为什么要拆成三次而不是一次
理论上可以预先把 P V M PVM P V M 乘成一个矩阵一次搞定。拆开是因为中间结果各有用途 :
世界坐标(M M M 之后)用来算光照——光源位置是世界空间的。
观察坐标(V V V 之后)用来做视锥裁剪判断。
裁剪坐标(P P P 之后)的 w w w 分量存了深度信息,光栅化时做透视校正要用。
URP 也是分步算这些矩阵,本渲染器照搬这个分工。
透视除法为什么单独一步
投影矩阵 P P P 的妙处在于把”远近”塞进 w w w 分量。观察空间里 z eye z_{\text{eye}} z eye 是深度,投影后:
w clip = − z eye ( > 0 ) w_{\text{clip}} = -z_{\text{eye}} \quad (>0) w clip = − z eye ( > 0 )
除以 w w w 之后,远处物体被压扁(坐标变小),这就是透视收缩(近大远小)的来源。但除法不能在裁剪之前做 ——裁剪(B05/B06)必须用裁剪空间坐标,因为裁剪面是线性的 w ≥ ∣ x ∣ , ∣ y ∣ , ∣ z ∣ w \ge |x|,|y|,|z| w ≥ ∣ x ∣ , ∣ y ∣ , ∣ z ∣ ,除完 w w w 这个性质就丢了。所以顺序固定是:投影 → 裁剪 → 透视除法 。这是管线里”先裁剪再除法”的根本原因,不是随意的安排。
易错点
乘法顺序取决于向量约定 。列向量(Unity/DirectX HLSL)是 P V M v PVMv P V M v ,从右往左作用;行向量(OpenGL 旧式)是 v M V P vMVP v M V P ,从左往右。本渲染器和 URP 一样用列向量,所以 P*V*M*v。
w w w 不一定是 1 。模型顶点齐次坐标 w = 1 w=1 w = 1 ,但投影之后 w = − z eye w=-z_{\text{eye}} w = − z eye ,是个随深度变化的值。后面所有透视校正(B07)都依赖这个 w w w 。
观察空间相机朝 -Z 。这是 OpenGL/Unity 约定(右手系,相机看向负 Z),所以 w = − z eye w=-z_{\text{eye}} w = − z eye 才是正数。DirectX 左手系朝 +Z,符号相反,别混。
参考
投影与裁剪盒子
视锥(金字塔视野)
相机能看见的区域是一个被六个平面切出的截头锥体(frustum):近、远、上、下、左、右六个面。透视投影下它是个金字塔形(近小远大),正交投影下是长方体。
投影矩阵把视锥压成立方体
投影矩阵 P P P 的作用,是把这个不规则的金字塔,线性变换成边长 2 的标准立方体 (各坐标范围 [ − 1 , 1 ] [-1, 1] [ − 1 , 1 ] )。变换后的空间叫裁剪空间,做完透视除法后归一化设备坐标(NDC)就落在 [ − 1 , 1 ] 3 [-1,1]^3 [ − 1 , 1 ] 3 这个盒子里。
视锥(金字塔) 裁剪空间 / NDC(标准立方体)
╱╲ P,÷w ┌─────┐
╱ ╲ ───────▶ │ │ x,y,z ∈ [-1,1]
╱ ╲ │ │
╱______╲ └─────┘
透视投影矩阵的形状
OpenGL/Unity 风格的透视投影矩阵(观察空间,相机朝 -Z):
P = [ 1 tan ( θ / 2 ) 0 0 0 0 1 \ar ⋅ 1 tan ( θ / 2 ) 0 0 0 0 − f + n f − n − 2 f n f − n 0 0 − 1 0 ] P = \begin{bmatrix}
\dfrac{1}{\tan(\theta/2)} & 0 & 0 & 0 \\
0 & \dfrac{1}{\ar} \cdot \dfrac{1}{\tan(\theta/2)} & 0 & 0 \\
0 & 0 & -\dfrac{f+n}{f-n} & -\dfrac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{bmatrix} P = tan ( θ /2 ) 1 0 0 0 0 \ar 1 ⋅ tan ( θ /2 ) 1 0 0 0 0 − f − n f + n − 1 0 0 − f − n 2 f n 0
其中 a a a 是宽高比、θ \theta θ 是垂直视场角、n n n /f f f 是近/远平面距离。关键在最后一行 [ 0 , 0 , − 1 , 0 ] [0,0,-1,0] [ 0 , 0 , − 1 , 0 ] :它把 z eye z_{\text{eye}} z eye 复制到 w w w ,所以
w clip = − z eye ( ≥ 0 ,因相机前方 z eye < 0 ) w_{\text{clip}} = -z_{\text{eye}} \quad(\ge 0\text{,因相机前方 }z_{\text{eye}}<0) w clip = − z eye ( ≥ 0 ,因相机前方 z eye < 0 )
这个 w w w 就是后面透视除法和透视校正(B07)的核心。
裁剪盒子:判断”在不在视野里”变得极简单
把视锥压成 [ − 1 , 1 ] 3 [-1,1]^3 [ − 1 , 1 ] 3 立方体后,“点在不在视野内”从六个平面方程退化成六个简单比较:
− w ≤ x ≤ w , − w ≤ y ≤ w , − w ≤ z ≤ w -w \le x \le w,\quad -w \le y \le w,\quad -w \le z \le w − w ≤ x ≤ w , − w ≤ y ≤ w , − w ≤ z ≤ w
注意这是裁剪空间的判定 (透视除法之前)。一个顶点只要六个不等式全满足就在视野内,任一违反就被裁掉。这就是把金字塔压成盒子的工程价值:复杂平面判定退化成坐标范围比较 。
易错点
裁剪判断用裁剪空间坐标(带 w w w ),不是 NDC 。NDC 是除以 w w w 之后 [ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 的结果,但除法会破坏裁剪面的线性,所以必须先在裁剪空间判定、再除。判定式是 ∣ x ∣ ≤ w |x|\le w ∣ x ∣ ≤ w 不是 ∣ x ∣ ≤ 1 |x|\le 1 ∣ x ∣ ≤ 1 。
近、远平面也在裁剪盒子的范围内 。z ∈ [ − w , w ] z\in[-w,w] z ∈ [ − w , w ] 同时约束了近平面(z ≥ − w z\ge -w z ≥ − w )和远平面(z ≤ w z\le w z ≤ w ),不需要单独判断。
投影矩阵不是把”远”缩小,是 w w w 让远处除完变小 。透视收缩发生在透视除法,不是投影矩阵本身。投影矩阵只负责压盒子。
参考
裁剪的真实切口
把视野拉窄、让模型冲出画面,屏幕边缘的三角形会被整整齐齐切断 ——那就是裁剪的产物。本渲染器用的算法和图形管线经典做法一致:逐裁剪面处理,留下界内、丢掉界外,在边界上插值出新顶点。
为什么必须裁剪
投影之后(B05),视野内顶点满足 ∣ x ∣ , ∣ y ∣ , ∣ z ∣ ≤ w |x|,|y|,|z| \le w ∣ x ∣ , ∣ y ∣ , ∣ z ∣ ≤ w 。如果一个三角形三个顶点全在视野外,光栅化时它要么画错位置(除以接近 0 的 w w w 数值爆炸),要么把不该看见的部分画上来。必须在透视除法之前、裁剪空间里把超出盒子的部分切掉。
逐面裁剪(Sutherland–Hodgman 思路)
裁剪不是一刀切,而是对每个裁剪面各做一次 。视锥有 6 个面(左/右/上/下/近/远),本渲染器总共处理 7 个面(标准 6 面 + 1 个额外平面)。每个面的处理逻辑相同:
对三角形的一条边 ( v 1 , v 2 ) (v_1, v_2) ( v 1 , v 2 ) ,用该面的判定函数 f ( v ) f(v) f ( v ) (裁剪空间里就是 x − w x-w x − w 、x + w x+w x + w 、y − w y-w y − w …… 这类线性式)分类:
v 1 v_1 v 1 v 2 v_2 v 2 输出 界内 界内 v 2 v_2 v 2 界内 界外 边与面的交点 v new v_{\text{new}} v new 界外 界内 交点 v new v_{\text{new}} v new ,再 v 2 v_2 v 2 界外 界外 无
遍历三角形三条边得到新顶点列表,可能从 3 个变成 4 个(四边形)或退化。下一个面再对这个新多边形做同样的处理,7 个面顺次做下来,最终多边形完全落在裁剪盒内 ,再拆回三角形送去光栅化。
交点怎么算:线性插值
判定函数 f f f 在裁剪空间是线性的,所以沿边 v 1 → v 2 v_1 \to v_2 v 1 → v 2 ,f f f 从 f 1 f_1 f 1 变到 f 2 f_2 f 2 。它与面相交(f = 0 f=0 f = 0 )处的参数:
t = f 1 f 1 − f 2 ( 0 ≤ t ≤ 1 ) t = \frac{f_1}{f_1 - f_2} \qquad (0 \le t \le 1) t = f 1 − f 2 f 1 ( 0 ≤ t ≤ 1 )
新顶点的所有属性(位置 x , y , z , w x,y,z,w x , y , z , w 、颜色、UV、法线、深度)都按同一个 t t t 线性插值:
v new = v 1 + t ( v 2 − v 1 ) v_{\text{new}} = v_1 + t\,(v_2 - v_1) v new = v 1 + t ( v 2 − v 1 )
因为这是在裁剪空间做的,连带 w w w 一起插值 ,所以插值结果是透视正确的——这正好绕开了光栅化阶段”线性插值会扭曲”的坑(那个坑用 B07 的透视校正解决)。裁剪插值天然正确,因为 w w w 还在。
易错点
裁剪必须在透视除法之前 。一旦除掉 w w w ,裁剪面就不再是线性的(x / w ≤ 1 x/w \le 1 x / w ≤ 1 涉及除法),交点参数 t t t 也算不准。所以管线顺序严格是:投影 → 裁剪 → 透视除法。
裁剪产生的不只是三角形 。一次裁剪可能把三角形切成四边形,要拆成两个三角形再光栅化。多面顺次裁剪可能产生更多顶点。
插值 t t t 用判定函数的比,不是距离的比 。t = f 1 / ( f 1 − f 2 ) t = f_1/(f_1-f_2) t = f 1 / ( f 1 − f 2 ) ,是因为 f f f 沿边线性;如果误用顶点距离比会得到错误的交点位置。
近平面裁剪最关键 。近处顶点的 w → 0 w \to 0 w → 0 ,透视除法会数值爆炸,所以近平面必须把这部分先切掉。
参考
光栅化 + 重心坐标
包围盒 + 逐像素判断
把一个三角形变成像素,做法很直接:算出三角形顶点的轴对齐包围盒(AABB),只遍历盒内的像素,每个像素判断在不在三角形内。在的就着色,不在的跳过。这比遍历整个屏幕快得多——包围盒外一定不在三角形里。
重心坐标:判断在内 + 给出三个权重
三角形顶点 A , B , C A,B,C A , B , C ,任意点 P P P 的重心坐标 ( α , β , γ ) (\alpha, \beta, \gamma) ( α , β , γ ) 定义为:
P = α A + β B + γ C α + β + γ = 1 P = \alpha A + \beta B + \gamma C \qquad \alpha+\beta+\gamma=1 P = α A + β B + γ C α + β + γ = 1
三个分量可用面积比算(△ \triangle △ 表示三角形面积):
α = area ( P B C ) area ( A B C ) , β = area ( P C A ) area ( A B C ) , γ = area ( P A B ) area ( A B C ) \alpha = \frac{\text{area}(PBC)}{\text{area}(ABC)},\quad \beta = \frac{\text{area}(PCA)}{\text{area}(ABC)},\quad \gamma = \frac{\text{area}(PAB)}{\text{area}(ABC)} α = area ( A B C ) area ( P B C ) , β = area ( A B C ) area ( P C A ) , γ = area ( A B C ) area ( P A B )
判定 :当且仅当 α , β , γ ≥ 0 \alpha,\beta,\gamma \ge 0 α , β , γ ≥ 0 时,P P P 在三角形内(含边界)。代码里通常用叉积/行列式算,避免显式求面积:
// 用边向量和点到顶点的向量叉积判定
Vec3 v0 = B - A, v1 = C - A, v2 = P - A;
double d00 = dot (v0,v0), d01 = dot (v0,v1), d11 = dot (v1,v1);
double d20 = dot (v2,v0), d21 = dot (v2,v1);
double denom = d00 * d11 - d01 * d01;
double v = (d11 * d20 - d01 * d21) / denom; // β
double w = (d00 * d21 - d01 * d20) / denom; // γ
double u = 1.0 - v - w; // α
if (u >= 0 && v >= 0 && w >= 0 ) { /* 在三角形内 */ }
重心坐标不只能判内外,它还顺手给出三个权重,把顶点的颜色、UV、法线、深度插值到每个像素 :
attr ( P ) = α attr A + β attr B + γ attr C \text{attr}(P) = \alpha\,\text{attr}_A + \beta\,\text{attr}_B + \gamma\,\text{attr}_C attr ( P ) = α attr A + β attr B + γ attr C
透视校正:不能直接线性插值
这是和 Unity 一样的关键细节。透视投影下直接用屏幕空间重心坐标插值属性会出错 ——屏幕上等距的点,在世界空间并不等距(透视收缩把远处压密了)。
设顶点 i i i 的属性 a i a_i a i 、裁剪空间 w i w_i w i ,正确做法是先除以 w w w 再插值,最后再乘回 w w w :
1 w P = α w A + β w B + γ w C \frac{1}{w_P} = \frac{\alpha}{w_A} + \frac{\beta}{w_B} + \frac{\gamma}{w_C} w P 1 = w A α + w B β + w C γ
a P = ( α a A w A + β a B w B + γ a C w C ) / 1 w P a_P = \left(\frac{\alpha\, a_A}{w_A} + \frac{\beta\, a_B}{w_B} + \frac{\gamma\, a_C}{w_C}\right) \Big/ \frac{1}{w_P} a P = ( w A α a A + w B β a B + w C γ a C ) / w P 1
直观理解:除以 w w w 把属性”拉回”线性空间,在这个空间里重心插值才正确,最后再乘 w w w 还原。不做这步,贴图在斜面上会扭曲 ——近处拉伸、远处压缩。
深度(z z z )也是属性,同样要透视校正,否则深度测试(B08)的近大远小关系会错。
易错点
重心坐标在屏幕空间和世界空间不同 。光栅化算出来的是屏幕空间重心坐标,直接用于世界空间属性就是错的——必须透视校正。
三个权重之和恒为 1 。α + β + γ = 1 \alpha+\beta+\gamma=1 α + β + γ = 1 是定义的一部分,算出三个后可用它做校验。
退化三角形要跳过 。三个顶点共线时面积 = 0 =0 = 0 ,分母 denom 为 0,会除零。要么跳过要么用更稳健的判定。
top-left rule 处理共享边 。相邻三角形共享的边若两边都判”在内”会画两次、产生缝;标准做法是只在 top edge / left edge 判在内,避免双重绘制。
参考
深度测试
Z 缓冲:每个像素记一个深度
光栅化(B07)把三角形变成像素后,一个像素可能被多个三角形覆盖——谁该留?答案是深度缓冲(Z-buffer / Depth buffer) :和颜色缓冲同等大小的二维数组,每个像素存一个深度值。新像素想画上来,先和缓冲里已有的深度比:更近就覆盖颜色并更新深度,更远就丢弃。
这就是”车头正确挡住后面座椅”的原理:座椅先画进像素,车头后来、深度更小、覆盖上去;或者反过来先画车头,座椅想画时深度更大、被拒绝。绘制顺序不影响结果 ——这是 Z 缓冲相对于”画家算法”(按深度排序后画)的最大优势。
深度可视化:越近越白
把深度缓冲的值映射成灰度,就是旁白里那张图:越近越白,越远越黑 。近处 z z z 小、映射后接近 1(白),远处 z z z 大、接近 0(黑)。这张图直接证明了深度缓冲确实在按距离存值。
默认比较函数:深度更小才通过
本渲染器的深度测试默认是 LessEqual(深度更小或相等才通过) ——即近的挡住远的。对应 OpenGL 的 GL_LEQUAL、Unity 的 ZTest LEqual(URP 默认)。判定式:
通过 ⟺ depth_新 ≤ depth_缓冲中
通过则写入颜色、更新深度;不通过则丢弃。“小 = 近”是约定 ,源于相机看向负 Z、近处 ∣ z eye ∣ |z_{\text{eye}}| ∣ z eye ∣ 小(B03)。
深度的非线性:透视校正后的 z z z
存进缓冲的不是观察空间的线性深度,而是透视除法后 NDC 的 z z z (范围 [ − 1 , 1 ] [-1,1] [ − 1 , 1 ] 或 [ 0 , 1 ] [0,1] [ 0 , 1 ] )。投影矩阵把近处压缩、远处拉伸,NDC 深度和真实距离是非线性 关系:
z NDC = 1 z eye ⋅ ( 常数 ) + 偏移 z_{\text{NDC}} = \frac{1}{z_{\text{eye}}}\cdot(\text{常数}) + \text{偏移} z NDC = z eye 1 ⋅ ( 常数 ) + 偏移
后果:深度缓冲里 0.5 对应的真实距离不是 近平面到远平面的中点,而是离近平面很近的位置。这意味着近处精度高、远处精度低 ——这是 z-fighting(两个很近的表面深度抖动闪烁)多发于远处的根本原因。24 位深度缓冲在远距离下区分不开相距很小的两个面。
易错点
要先清深度缓冲 。每帧开头必须 clear depth buffer(通常清成最远值 1.0),否则上一帧的深度会污染本帧,出现”看不到物体”或乱序遮挡。
“小=近”是约定,可改 。深度比较函数可设成 Greater(远挡近,用于画天空盒或半透明)。本渲染器默认 LessEqual,和 URP 一致。
半透明物体不能简单用 Z 测试 。玻璃半透明时不能直接覆盖近处像素,要先按从后到前画、用混合(blending)。Z-buffer 只解决不透明遮挡。
z 缓冲存的是 NDC 深度,非线性 。调试时把 z z z 直接当线性距离会误判远近关系,尤其在远处。
深度写入(ZWrite)和测试(ZTest)是两件事 。测试决定”能不能画”,写入决定”画完后要不要更新深度”。天空盒常设 ZWrite Off。
参考