计算机图形学笔记(二):光栅化渲染管线

7982 admin
国风企划

NOTE光栅化是整个图形学流水线里最”接地气”的一环——从一堆三角形到屏幕上的每一个像素,中间的每一步都既涉及几何与代数,也关心工程上怎么做得快。本部分按管线→几何阶段→裁剪→光栅化核心→深度→光照→纹理的顺序展开,所有关键推导都保留完整过程。

目录#

渲染管线总览 — 三大阶段、MVP 变换链、可编程着色器

几何阶段 — 顶点数据组织、法向量变换代码、TRS 分解

图元装配与裁剪 — 齐次空间裁剪、Sutherland-Hodgman 算法

光栅化核心 — Bresenham 画线、三角形光栅化、重心坐标、透视校正

深度测试 — Z-Buffer、深度精度、Z-Fighting 与反向 Z

光照模型 — Phong、Blinn-Phong、着色频率

纹理映射 — 采样、过滤、Mipmap、法线贴图、环境映射

五、渲染管线总览#5.1 管线三大阶段#现代 GPU 的光栅化管线概念上可以拆成三个大阶段,它们之间通过固定的数据格式交接。

应用阶段(Application Stage, CPU)

场景管理、视锥剔除(粗粒度)、LOD 选择

动画更新(骨骼、顶点变形)

最终提交给 GPU 的是顶点缓冲 + 索引 + 绘制调用

几何阶段(Geometry Stage, GPU)

顶点着色器:顶点变换(MVP)、法向量变换、属性传递

可选曲面细分 / 几何着色器

投影、裁剪、屏幕映射

光栅化阶段(Rasterization Stage, GPU)

三角形设置:边方程与重心坐标系数

三角形遍历:决定哪些像素被覆盖

片段着色器:按像素计算颜色

输出合并:深度测试、模板测试、混合

TIP把”谁做什么”记清楚对写 Shader 非常关键:Vertex Shader 一个顶点跑一次、Fragment Shader 一个像素跑一次,两者之间的属性通过光栅化阶段的插值衔接。

5.2 坐标空间变换链#图形学里最经典的一张图:

模型坐标⏟Local→M世界坐标⏟World→V观察坐标⏟View→P裁剪坐标⏟Clip→÷wNDC⏟[−1,1]3→视口屏幕坐标⏟Screen\underbrace{\text{模型坐标}}_{\text{Local}} \xrightarrow{\mathbf{M}} \underbrace{\text{世界坐标}}_{\text{World}} \xrightarrow{\mathbf{V}} \underbrace{\text{观察坐标}}_{\text{View}} \xrightarrow{\mathbf{P}} \underbrace{\text{裁剪坐标}}_{\text{Clip}} \xrightarrow{\div w} \underbrace{\text{NDC}}_{[-1,1]^3} \xrightarrow{\text{视口}} \underbrace{\text{屏幕坐标}}_{\text{Screen}}Local模型坐标​​M​World世界坐标​​V​View观察坐标​​P​Clip裁剪坐标​​÷w​[−1,1]3NDC​​视口​Screen屏幕坐标​​每一步的矩阵推导在 Part 1:数学基础 里有完整细节(mathbfM,V,Pmathbf{M,V,P}mathbfM,V,P 的构造分别见 Part 1 §2.2、§3.2、§3.1),这里只记住两个要点:

一、复合矩阵的顺序

v⃗clip=P⋅V⋅M⋅v⃗local\vec{v}_{clip} = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot \vec{v}_{local}vclip​=P⋅V⋅M⋅vlocal​矩阵乘法从右往左作用,所以写代码的时候也必须按 P * V * M 构造。

二、透视除法触发点

投影矩阵最后一行通常是 (0,0,−1,0)(0, 0, -1, 0)(0,0,−1,0)(OpenGL 约定),这会把 −zview-z_{view}−zview​ 放进 wclipw_{clip}wclip​。只有在裁剪完成之后,硬件才做 (x,y,z)/w(x, y, z) / w(x,y,z)/w 的除法得到 NDC——这个顺序不能反,否则会把视锥外的点除到视锥内,产生透视裁剪 bug。

视口变换将 NDC 映射到屏幕坐标:

S=(w/200w/20h/20h/200(f2−f1)/2(f2+f1)/20001)\mathbf{S} = \begin{pmatrix}

w/2 & 0 & 0 & w/2 \\

0 & h/2 & 0 & h/2 \\

0 & 0 & (f_2-f_1)/2 & (f_2+f_1)/2 \\

0 & 0 & 0 & 1

\end{pmatrix}S=​w/2000​0h/200​00(f2​−f1​)/20​w/2h/2(f2​+f1​)/21​​其中 w,hw, hw,h 是屏幕宽高,[f1,f2][f_1, f_2][f1​,f2​] 是深度缓冲区映射范围(OpenGL 默认 [0,1][0,1][0,1])。

5.3 可编程着色器#5.3.1 顶点着色器(Vertex Shader)#每个顶点运行一次,职责是把顶点从模型空间送进裁剪空间。

1#version 330 core2layout (location = 0) in vec3 aPos;3layout (location = 1) in vec3 aNormal;4layout (location = 2) in vec2 aTexCoord;5

6uniform mat4 uModel;7uniform mat4 uView;8uniform mat4 uProjection;9uniform mat3 uNormalMatrix; // inverse-transpose of upper-left 3x3 of uModel10

11out vec3 vWorldPos;12out vec3 vNormal;13out vec2 vTexCoord;14

15void main() {16 vec4 worldPos = uModel * vec4(aPos, 1.0);17 vWorldPos = worldPos.xyz;18 vNormal = normalize(uNormalMatrix * aNormal);19 vTexCoord = aTexCoord;20 gl_Position = uProjection * uView * worldPos;21}WARNING法向量必须用 (M⁻¹)ᵀ 的左上 3×3 去变换,不能直接乘 uModel。完整推导见 Part 1 §2.3,本部分 §6.2 只放工程实现。

5.3.2 片段着色器(Fragment Shader)#光栅化器每覆盖一个像素就调一次。Vertex Shader 的 out 变量到这里已经是透视校正插值过的结果。

1#version 330 core2in vec3 vWorldPos;3in vec3 vNormal;4in vec2 vTexCoord;5

6uniform sampler2D uAlbedo;7uniform vec3 uLightPos;8uniform vec3 uViewPos;9out vec4 FragColor;10

11void main() {12 vec3 N = normalize(vNormal);13 vec3 L = normalize(uLightPos - vWorldPos);14 vec3 V = normalize(uViewPos - vWorldPos);15 vec3 H = normalize(L + V); // 半角向量(Blinn-Phong)16

17 vec3 albedo = texture(uAlbedo, vTexCoord).rgb;18 float diff = max(dot(N, L), 0.0);19 float spec = pow(max(dot(N, H), 0.0), 64.0);20 vec3 ambient = 0.1 * albedo;21 vec3 diffuse = diff * albedo;22 vec3 specular = vec3(0.3) * spec;23

24 FragColor = vec4(ambient + diffuse + specular, 1.0);25}

六、几何阶段细节#6.1 顶点数据组织#一个几何体在 GPU 上的常见表示:

1struct Vertex {2 Eigen::Vector3f position; // 模型空间坐标3 Eigen::Vector3f normal; // 顶点法向量4 Eigen::Vector2f texCoord; // UV5 Eigen::Vector3f tangent; // 切向量(法线贴图需要)6};VBO(Vertex Buffer Object):连续存放所有顶点属性的 GPU 缓冲区。

EBO(Element Buffer Object):索引数组,让多个三角形共享同一个顶点,避免重复。

VAO(Vertex Array Object):记录 VBO/EBO 的绑定关系和顶点属性的内存布局,绘制时只用绑 VAO 就能恢复全部状态。

1unsigned int VAO, VBO, EBO;2glGenVertexArrays(1, &VAO);3glGenBuffers(1, &VBO);4glGenBuffers(1, &EBO);5

6glBindVertexArray(VAO);7

8glBindBuffer(GL_ARRAY_BUFFER, VBO);9glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),10 vertices.data(), GL_STATIC_DRAW);11

12glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);13glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),14 indices.data(), GL_STATIC_DRAW);15

16// location 0: 位置17glEnableVertexAttribArray(0);18glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),19 (void*)offsetof(Vertex, position));20// location 1: 法向量21glEnableVertexAttribArray(1);22glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),23 (void*)offsetof(Vertex, normal));24// location 2: UV25glEnableVertexAttribArray(2);26glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),27 (void*)offsetof(Vertex, texCoord));6.2 法向量变换(仅工程实现)#NOTE完整推导见 Part 1 §2.3(切平面垂直条件 → 逆转置),本节只保留工程代码和易错点。

1// 从 4x4 模型矩阵计算 3x3 法向量矩阵2Eigen::Matrix3f compute_normal_matrix(const Eigen::Matrix4f& model) {3 return model.block<3, 3>(0, 0).inverse().transpose();4}5

6// 把模型空间法向量变到世界空间7Eigen::Vector3f transform_normal(const Eigen::Vector3f& n,8 const Eigen::Matrix4f& model) {9 return (compute_normal_matrix(model) * n).normalized();10}易错点清单

❌ 直接 model * n(非均匀缩放下不垂直切平面)

❌ 忘记取左上 3×3(把平移项混进去会搞坏方向向量)

❌ 忘记最后归一化(缩放会改变法向量长度,后续光照计算失败)

✅ 纯旋转时,(mathbfM−1)T=mathbfM(mathbf{M}^{-1})^T = mathbf{M}(mathbfM−1)T=mathbfM,法向量矩阵退化为原矩阵

6.3 变换矩阵的 TRS 分解#场景:从一个任意仿射矩阵反推原始的 Translation / Rotation / Scale,用于动画插值、编辑器 Gizmo、骨骼系统。

分解定理:任何可逆仿射矩阵可以唯一分解为

M=T⋅R⋅S\mathbf{M} = \mathbf{T} \cdot \mathbf{R} \cdot \mathbf{S}M=T⋅R⋅S算法步骤

提取平移:vect=mathbfM[0:3,3]vec{t} = mathbf{M}[0{:}3, 3]vect=mathbfM[0:3,3]

提取线性部分:mathbfA=mathbfM[0:3,0:3]mathbf{A} = mathbf{M}[0{:}3, 0{:}3]mathbfA=mathbfM[0:3,0:3](上 3×3)

计算缩放因子:sk=∣mathbfA[:,k]∣s_k = |mathbf{A}_{[:,k]}|sk​=∣mathbfA[:,k]​∣(每列的模长)

检测反射:若 det(mathbfA)<0det(mathbf{A}) < 0det(mathbfA)<0,把 szs_zsz​ 取负,避免把反射当成旋转

提取旋转:mathbfR∗[:,k]=mathbfA∗[:,k]/skmathbf{R}*{[:,k]} = mathbf{A}*{[:,k]} / s_kmathbfR∗[:,k]=mathbfA∗[:,k]/sk​

1struct Transform {2 Eigen::Vector3f translation;3 Eigen::Quaternionf rotation;4 Eigen::Vector3f scale;5};6

7Transform decompose(const Eigen::Matrix4f& M) {8 Transform out;9 out.translation = M.block<3, 1>(0, 3);10

11 Eigen::Matrix3f A = M.block<3, 3>(0, 0);12 out.scale.x() = A.col(0).norm();13 out.scale.y() = A.col(1).norm();14 out.scale.z() = A.col(2).norm();15

16 // 处理反射:把负号归到 Z17 if (A.determinant() < 0.0f) out.scale.z() = -out.scale.z();18

19 Eigen::Matrix3f R;20 R.col(0) = A.col(0) / out.scale.x();21 R.col(1) = A.col(1) / out.scale.y();22 R.col(2) = A.col(2) / out.scale.z();23 out.rotation = Eigen::Quaternionf(R);24

25 return out;26}WARNING这个”极分解”在存在切变(shear) 时会失败——切变无法用 TRS 表达。真要支持切变需要完整的极分解 mathbfA=mathbfRcdotmathbfUmathbf{A} = mathbf{R} cdot mathbf{U}mathbfA=mathbfRcdotmathbfU,其中 U\mathbf{U}U 是正定对称矩阵。工程上通常约定导出器不产生切变。

七、图元装配与裁剪#7.1 图元类型#GPU 只认识三种基本图元:点、线段、三角形。其他几何体(四边形、圆、贝塞尔曲线)都要先转成这三种。

常用的三角形拓扑:

GL_TRIANGLES:每 3 个顶点一个三角形

GL_TRIANGLE_STRIP:相邻三角形共享两个顶点,nnn 个顶点定义 n−2n-2n−2 个三角形

GL_TRIANGLE_FAN:所有三角形共享第一个顶点,适合凸多边形

7.2 齐次空间裁剪#为什么要裁剪? 视锥外的三角形不能直接丢掉:横跨视锥边界的三角形必须被切开,否则透视除法会产生错误的屏幕坐标(尤其是经过相机后方的点,w<0w < 0w<0 会导致坐标翻转)。

齐次空间视锥(OpenGL 约定)的六个半空间:

−w≤x≤w(左、右)−w≤y≤w(下、上)−w≤z≤w(近、远)\begin{aligned}

-w \leq x \leq w \quad (\text{左、右}) \\

-w \leq y \leq w \quad (\text{下、上}) \\

-w \leq z \leq w \quad (\text{近、远})

\end{aligned}−w≤x≤w(左、右)−w≤y≤w(下、上)−w≤z≤w(近、远)​直接在齐次坐标 (x,y,z,w)(x, y, z, w)(x,y,z,w) 下做裁剪,不需要先除 www——这是整个管线能正确处理跨视锥三角形的关键。

点的裁剪测试:

1bool point_inside_frustum(const Eigen::Vector4f& p) {2 float w = p.w();3 return p.x() >= -w && p.x() <= w4 && p.y() >= -w && p.y() <= w5 && p.z() >= -w && p.z() <= w;6}点到某个裁剪平面的带符号距离(用来求三角形与平面的交点参数):

左: d=w+x,右: d=w−x下: d=w+y,上: d=w−y近: d=w+z,远: d=w−z\begin{array}{ll}

\text{左}:\ d = w + x, & \text{右}:\ d = w - x \\

\text{下}:\ d = w + y, & \text{上}:\ d = w - y \\

\text{近}:\ d = w + z, & \text{远}:\ d = w - z \\

\end{array}左: d=w+x,下: d=w+y,近: d=w+z,​右: d=w−x上: d=w−y远: d=w−z​7.3 Sutherland-Hodgman 多边形裁剪#算法思想:对每一个裁剪平面,把输入多边形切一刀,结果多边形再作为下一个平面的输入。六个平面处理完就得到最终裁剪结果。

单平面裁剪的四种情况(遍历每条边 PprevtoPcurrP_{prev} to P_{curr}Pprev​toPcurr​):

PprevP_{prev}Pprev​PcurrP_{curr}Pcurr​输出内内输出 PcurrP_{curr}Pcurr​内外输出交点外内输出交点,再输出 PcurrP_{curr}Pcurr​外外什么都不输出交点的参数计算:设前后两点到平面的带符号距离为 d1,d2d_1, d_2d1​,d2​,则交点参数

t=d1d1−d2,Pcross=Pprev+t(Pcurr−Pprev)t = \frac{d_1}{d_1 - d_2}, \quad P_{\text{cross}} = P_{prev} + t(P_{curr} - P_{prev})t=d1​−d2​d1​​,Pcross​=Pprev​+t(Pcurr​−Pprev​)WARNING交点不仅位置要插值,所有顶点属性(UV、颜色、法向量)都要按同一个 ttt 线性插值——否则裁剪出来的片段会出现纹理错位。

1struct ClipVertex {2 Eigen::Vector4f pos; // 齐次裁剪坐标3 Eigen::Vector3f normal;4 Eigen::Vector2f uv;5};6

7// 对某个裁剪平面计算带符号距离8using DistFn = std::function;9

10std::vector clip_against_plane(const std::vector& in,11 DistFn dist) {12 std::vector out;13 if (in.empty()) return out;14

15 for (size_t i = 0; i < in.size(); ++i) {16 const ClipVertex& curr = in[i];17 const ClipVertex& prev = in[(i + in.size() - 1) % in.size()];18

19 float d_curr = dist(curr.pos);20 float d_prev = dist(prev.pos);21

22 bool curr_inside = d_curr >= 0.0f;23 bool prev_inside = d_prev >= 0.0f;24

25 if (curr_inside != prev_inside) {26 float t = d_prev / (d_prev - d_curr);27 ClipVertex cross;28 cross.pos = prev.pos + t * (curr.pos - prev.pos);29 cross.normal = prev.normal + t * (curr.normal - prev.normal);30 cross.uv = prev.uv + t * (curr.uv - prev.uv);31 out.push_back(cross);32 }33 if (curr_inside) out.push_back(curr);34 }35 return out;36}依次对六个平面调用 clip_against_plane,得到裁剪后的凸多边形。若顶点数 >3> 3>3,再做三角形扇化(Triangle Fan)即可。

八、光栅化核心#8.1 Bresenham 画线算法(规范推导位置)#8.1.1 问题#给定两个整数端点 (x0,y0)(x_0, y_0)(x0​,y0​) 和 (x1,y1)(x_1, y_1)(x1​,y1​),在像素网格上画出一条近似直线,要求:

每列(或每行)只点亮一个像素,避免断线

只用整数加减和比较,没有除法和浮点

8.1.2 从直线方程出发#设 Deltax=x1−x0>0Delta x = x_1 - x_0 > 0Deltax=x1​−x0​>0,Deltay=y1−y0>0Delta y = y_1 - y_0 > 0Deltay=y1​−y0​>0,斜率 m=Deltay/Deltaxin(0,1]m = Delta y / Delta x in (0, 1]m=Deltay/Deltaxin(0,1]。直线方程写成隐式:

F(x,y)=Δy⋅x−Δx⋅y+(Δx⋅y0−Δy⋅x0)=0F(x, y) = \Delta y \cdot x - \Delta x \cdot y + (\Delta x \cdot y_0 - \Delta y \cdot x_0) = 0F(x,y)=Δy⋅x−Δx⋅y+(Δx⋅y0​−Δy⋅x0​)=0

F>0F > 0F>0:点在直线下方

F<0F < 0F<0:点在直线上方

F=0F = 0F=0:点在直线上

8.1.3 中点判据#假设已经点亮了 (xk,yk)(x_k, y_k)(xk​,yk​),下一个像素只能在 (xk+1,yk)(x_k+1, y_k)(xk​+1,yk​) 或 (xk+1,yk+1)(x_k+1, y_k+1)(xk​+1,yk​+1) 二选一。取两者的中点 M=(xk+1,yk+0.5)M = (x_k+1, y_k+0.5)M=(xk​+1,yk​+0.5),代入 FFF:

dk=F(xk+1, yk+0.5)=Δy(xk+1)−Δx(yk+0.5)+Cd_k = F(x_k+1,\ y_k+0.5) = \Delta y (x_k+1) - \Delta x (y_k+0.5) + Cdk​=F(xk​+1, yk​+0.5)=Δy(xk​+1)−Δx(yk​+0.5)+C

dk<0d_k < 0dk​<0:中点在直线上方 → 直线更靠下 → 选 (xk+1,yk)(x_k+1, y_k)(xk​+1,yk​)

dkgeq0d_k geq 0dk​geq0:中点在直线下方 → 直线更靠上 → 选 (xk+1,yk+1)(x_k+1, y_k+1)(xk​+1,yk​+1)

8.1.4 增量化(关键一步)#直接算 dkd_kdk​ 还有浮点。相邻两步的判据差值是纯整数:

情况 A:dk<0d_k < 0dk​<0,选 (xk+1,yk)(x_k+1, y_k)(xk​+1,yk​)

dk+1−dk=Δy(xk+2)−Δx(yk+0.5)−[Δy(xk+1)−Δx(yk+0.5)]=Δyd_{k+1} - d_k = \Delta y(x_k+2) - \Delta x(y_k+0.5) - [\Delta y(x_k+1) - \Delta x(y_k+0.5)] = \Delta ydk+1​−dk​=Δy(xk​+2)−Δx(yk​+0.5)−[Δy(xk​+1)−Δx(yk​+0.5)]=Δy情况 B:dkgeq0d_k geq 0dk​geq0,选 (xk+1,yk+1)(x_k+1, y_k+1)(xk​+1,yk​+1)

dk+1−dk=Δy(xk+2)−Δx(yk+1.5)−[Δy(xk+1)−Δx(yk+0.5)]=Δy−Δxd_{k+1} - d_k = \Delta y(x_k+2) - \Delta x(y_k+1.5) - [\Delta y(x_k+1) - \Delta x(y_k+0.5)] = \Delta y - \Delta xdk+1​−dk​=Δy(xk​+2)−Δx(yk​+1.5)−[Δy(xk​+1)−Δx(yk​+0.5)]=Δy−Δx初值:d0=F(x0+1,y0+0.5)=Deltay−0.5Deltaxd_0 = F(x_0+1, y_0+0.5) = Delta y - 0.5Delta xd0​=F(x0​+1,y0​+0.5)=Deltay−0.5Deltax。为了消去 0.50.50.5,两边同乘 2,得到全整数版本:

d0=2Δy−Δxd_0 = 2\Delta y - \Delta xd0​=2Δy−Δxdk+1={dk+2Δy若 dk<0dk+2(Δy−Δx)若 dk≥0d_{k+1} = \begin{cases} d_k + 2\Delta y & \text{若 } d_k < 0 \\ d_k + 2(\Delta y - \Delta x) & \text{若 } d_k \geq 0 \end{cases}dk+1​={dk​+2Δydk​+2(Δy−Δx)​若 dk​<0若 dk​≥0​8.1.5 工程实现#1void bresenham_line(int x0, int y0, int x1, int y1,2 std::function set_pixel) {3 // 保证 x 方向递增;对 |m|>1 的情况交换 x/y 扫描4 bool steep = std::abs(y1 - y0) > std::abs(x1 - x0);5 if (steep) { std::swap(x0, y0); std::swap(x1, y1); }6 if (x0 > x1) { std::swap(x0, x1); std::swap(y0, y1); }7

8 int dx = x1 - x0;9 int dy = std::abs(y1 - y0);10 int d = 2 * dy - dx; // 初值 d_011 int ystep = (y0 < y1) ? 1 : -1;12 int y = y0;13

14 for (int x = x0; x <= x1; ++x) {15 if (steep) set_pixel(y, x); // 还原坐标轴交换16 else set_pixel(x, y);17

18 if (d > 0) { y += ystep; d -= 2 * dx; }19 d += 2 * dy;20 }21}TIPBresenham 的思想——用整数增量替代浮点计算——在后面的圆/椭圆画法、DDA 光栅化、各向异性过滤里都会反复出现。

8.2 三角形光栅化#8.2.1 边方程(Edge Function)#三角形 △ABC\triangle ABC△ABC 的有向边 AB→\overrightarrow{AB}AB 对应一条直线,其边函数

EAB(P)=(yA−yB)(xP−xA)+(xB−xA)(yP−yA)E_{AB}(P) = (y_A - y_B)(x_P - x_A) + (x_B - x_A)(y_P - y_A)EAB​(P)=(yA​−yB​)(xP​−xA​)+(xB​−xA​)(yP​−yA​)几何意义:EAB(P)E_{AB}(P)EAB​(P) 等于 2 × 三角形 △ABP\triangle ABP△ABP 的有向面积(逆时针为正)。

点 PPP 在三角形内的判据(三角形逆时针):

EAB(P)≥0 ∧ EBC(P)≥0 ∧ ECA(P)≥0E_{AB}(P) \geq 0 \ \land\ E_{BC}(P) \geq 0 \ \land\ E_{CA}(P) \geq 0EAB​(P)≥0 ∧ EBC​(P)≥0 ∧ ECA​(P)≥08.2.2 包围盒遍历#现代光栅化不再用扫描线,而是轴对齐包围盒 + 边方程测试:

1void rasterize_triangle(const Vector3f v[3],2 std::function shade) {3 // 计算整数包围盒(注意屏幕边界裁剪)4 int x_min = std::max(0, (int)std::floor(std::min({v[0].x(), v[1].x(), v[2].x()})));5 int x_max = std::min(width -1, (int)std::ceil (std::max({v[0].x(), v[1].x(), v[2].x()})));6 int y_min = std::max(0, (int)std::floor(std::min({v[0].y(), v[1].y(), v[2].y()})));7 int y_max = std::min(height-1, (int)std::ceil (std::max({v[0].y(), v[1].y(), v[2].y()})));8

9 for (int y = y_min; y <= y_max; ++y) {10 for (int x = x_min; x <= x_max; ++x) {11 // 以像素中心 (x+0.5, y+0.5) 做测试12 auto [a, b, g] = barycentric_2d(x + 0.5f, y + 0.5f, v);13 if (a >= 0 && b >= 0 && g >= 0) {14 shade(x, y, a, b, g);15 }16 }17 }18}为什么用包围盒而不是扫描线?

扫描线需要对三角形顶点排序 + 边增量计算,分支多

包围盒 + 边方程对 SIMD/GPU 天然友好,可以 2×2 像素块并行

支持任意三角形朝向(顺时针/逆时针只需切换符号)

8.2.3 Top-Left 规则#相邻三角形共享边时,如果两边都严格用 geqgeqgeq,会导致共享像素被画两次;用 >>> 又会漏画。DirectX/OpenGL 约定Top-Left Fill Rule:

只有左边(从下到上的边)和顶边(水平、从左到右的边)上的像素算作”内部”

其他边上的像素算作”外部”

这样任何一个像素只会被恰好一个三角形覆盖,避免闪烁与双重着色。

8.3 重心坐标系(规范推导位置)#NOTE本节是全系列重心坐标的规范位置。Part 1 §1.1 只做了简要引用,详细推导、透视校正都在这里。

8.3.1 定义#对三角形 △ABC\triangle ABC△ABC 内任意一点 PPP,存在唯一的一组非负数 (α,β,γ)(\alpha, \beta, \gamma)(α,β,γ) 满足:

P=αA+βB+γC,α+β+γ=1P = \alpha A + \beta B + \gamma C, \quad \alpha + \beta + \gamma = 1P=αA+βB+γC,α+β+γ=1这就是 PPP 的重心坐标。三个权重分别对应三个顶点的”影响力”。

8.3.2 面积比公式(完整推导)#核心引理:alpha,beta,gammaalpha, beta, gammaalpha,beta,gamma 等于 PPP 关于对顶边所切分出的子三角形面积比。

证明:固定 alpha+beta+gamma=1alpha + beta + gamma = 1alpha+beta+gamma=1,将 P=αA+βB+γCP = \alpha A + \beta B + \gamma CP=αA+βB+γC 代入 overrightarrowCP=P−Coverrightarrow{CP} = P - CoverrightarrowCP=P−C:

CP→=α(A−C)+β(B−C)=αCA→+βCB→\overrightarrow{CP} = \alpha(A - C) + \beta(B - C) = \alpha \overrightarrow{CA} + \beta \overrightarrow{CB}CP=α(A−C)+β(B−C)=αCA+βCB用 CB→\overrightarrow{CB}CB 叉乘两边:

CP→×CB→=α(CA→×CB→)\overrightarrow{CP} \times \overrightarrow{CB} = \alpha (\overrightarrow{CA} \times \overrightarrow{CB})CP×CB=α(CA×CB)两边取模:

α=∥CP→×CB→∥∥CA→×CB→∥=2⋅Area(△PBC)2⋅Area(△ABC)=Area(△PBC)Area(△ABC)\alpha = \frac{\|\overrightarrow{CP} \times \overrightarrow{CB}\|}{\|\overrightarrow{CA} \times \overrightarrow{CB}\|} = \frac{2 \cdot \text{Area}(\triangle PBC)}{2 \cdot \text{Area}(\triangle ABC)} = \frac{\text{Area}(\triangle PBC)}{\text{Area}(\triangle ABC)}α=∥CA×CB∥∥CP×CB∥​=2⋅Area(△ABC)2⋅Area(△PBC)​=Area(△ABC)Area(△PBC)​同理:

β=Area(△APC)Area(△ABC),γ=Area(△ABP)Area(△ABC)\beta = \frac{\text{Area}(\triangle APC)}{\text{Area}(\triangle ABC)}, \quad \gamma = \frac{\text{Area}(\triangle ABP)}{\text{Area}(\triangle ABC)}β=Area(△ABC)Area(△APC)​,γ=Area(△ABC)Area(△ABP)​8.3.3 二维情形的标准公式#二维下用 z=0z=0z=0 的叉积求面积:textArea(triangleABC)=frac12left∣(xB−xA)(yC−yA)−(xC−xA)(yB−yA)right∣text{Area}(triangle ABC) = frac{1}{2}left|(x_B-x_A)(y_C-y_A) - (x_C-x_A)(y_B-y_A)right|textArea(triangleABC)=frac12left∣(xB​−xA​)(yC​−yA​)−(xC​−xA​)(yB​−yA​)right∣。记三角形总面积的两倍为 DDD,则

α=(xB−xP)(yC−yP)−(xC−xP)(yB−yP)D\alpha = \frac{(x_B - x_P)(y_C - y_P) - (x_C - x_P)(y_B - y_P)}{D}α=D(xB​−xP​)(yC​−yP​)−(xC​−xP​)(yB​−yP​)​β=(xC−xP)(yA−yP)−(xA−xP)(yC−yP)D\beta = \frac{(x_C - x_P)(y_A - y_P) - (x_A - x_P)(y_C - y_P)}{D}β=D(xC​−xP​)(yA​−yP​)−(xA​−xP​)(yC​−yP​)​γ=1−α−β\gamma = 1 - \alpha - \betaγ=1−α−β8.3.4 工程实现#1// GAMES101 Assignment 2 风格,已整理2std::tuple3barycentric_2d(float x, float y, const Eigen::Vector3f v[3]) {4 float x0 = v[0].x(), y0 = v[0].y();5 float x1 = v[1].x(), y1 = v[1].y();6 float x2 = v[2].x(), y2 = v[2].y();7

8 float denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);9 float a = ((y1 - y2) * (x - x2) + (x2 - x1) * (y - y2)) / denom;10 float b = ((y2 - y0) * (x - x2) + (x0 - x2) * (y - y2)) / denom;11 float c = 1.0f - a - b;12 return {a, b, c};13}8.3.5 属性插值的通用公式#已知三角形三个顶点的属性 fA,fB,fCf_A, f_B, f_CfA​,fB​,fC​(深度、颜色、法向量、UV 等),三角形内部任一点的属性值:

f(P)=αfA+βfB+γfCf(P) = \alpha f_A + \beta f_B + \gamma f_Cf(P)=αfA​+βfB​+γfC​但这只在屏幕空间线性的属性上成立——颜色这种无所谓,深度和 UV 需要透视校正(见 §8.4)。

8.4 透视校正插值#8.4.1 为什么屏幕空间线性插值是错的#透视投影后,世界空间里等距的三个点,在屏幕上不等距。直接在屏幕空间线性插值 UV,结果会在倾斜面上明显”挤”在一起(远处纹理被拉长)。

8.4.2 深度倒数线性#关键数学事实:

1z 在屏幕空间是线性的(即 α,β,γ 的线性组合)\frac{1}{z} \text{ 在屏幕空间是线性的(即 } \alpha, \beta, \gamma \text{ 的线性组合)}z1​ 在屏幕空间是线性的(即 α,β,γ 的线性组合)这是因为透视投影本质上是 z′=az+b/zz' = az + b / zz′=az+b/z 形式,其中 1/z1/z1/z 对应于投影空间中的线性量。因此:

1zP=α1zA+β1zB+γ1zC\frac{1}{z_P} = \alpha \frac{1}{z_A} + \beta \frac{1}{z_B} + \gamma \frac{1}{z_C}zP​1​=αzA​1​+βzB​1​+γzC​1​8.4.3 任意属性的透视校正#对任意顶点属性 fff(UV、世界空间坐标、法向量),f/zf/zf/z 也在屏幕空间线性:

f(P)zP=αfAzA+βfBzB+γfCzC\frac{f(P)}{z_P} = \alpha \frac{f_A}{z_A} + \beta \frac{f_B}{z_B} + \gamma \frac{f_C}{z_C}zP​f(P)​=αzA​fA​​+βzB​fB​​+γzC​fC​​所以正确的插值是:

f(P)=αfA/zA+βfB/zB+γfC/zCα/zA+β/zB+γ/zC\boxed{f(P) = \frac{\alpha f_A / z_A + \beta f_B / z_B + \gamma f_C / z_C}{\alpha / z_A + \beta / z_B + \gamma / z_C}}f(P)=α/zA​+β/zB​+γ/zC​αfA​/zA​+βfB​/zB​+γfC​/zC​​​8.4.4 工程实现#1// alpha, beta, gamma: 屏幕空间重心坐标2// z_a/b/c: 三个顶点在相机空间的深度(正值)3// f_a/b/c: 三个顶点上待插值的属性4template 5T perspective_correct(float alpha, float beta, float gamma,6 float z_a, float z_b, float z_c,7 const T& f_a, const T& f_b, const T& f_c) {8 float inv_z = alpha / z_a + beta / z_b + gamma / z_c;9 T num = alpha * f_a / z_a + beta * f_b / z_b + gamma * f_c / z_c;10 return num / inv_z;11}WARNING深度值本身的插值比较特殊:在光栅化里,传进深度缓冲的 zNDCz_{NDC}zNDC​ 可以直接用重心坐标线性插值(因为投影矩阵已经让 zNDCz_{NDC}zNDC​ 在屏幕空间线性)。但 UV / 世界坐标 / 法向量必须走透视校正公式。

8.5 现代 GPU 的并行光栅化#8.5.1 Tile-Based 光栅化#移动 GPU(Mali, Adreno, PowerVR, Apple GPU)和部分桌面 GPU 把屏幕切成 16×16 或 32×32 的 tile,每个 tile 独立光栅化。好处:

Tile 内的 Framebuffer 能装进片上 SRAM,带宽消耗大幅降低

同一个 tile 的像素天然 SIMD

适合大规模并行

8.5.2 Early-Z 与 Hi-Z#Early-Z:如果片段着色器不修改深度(没有 discard、没有 gl_FragDepth),硬件会在 FS 之前就做深度测试,被遮挡的片段直接跳过 FS,节省大量计算。

Hi-Z(Hierarchical Z):对深度缓冲建 Mipmap,每个 tile 记录最大/最小深度。三角形进入前先查 Hi-Z,能批量剔除整块被遮挡的像素。

1bool hiz_reject(int tile_x, int tile_y, int level,2 float tri_z_near) {3 float tile_z_max = hi_z[level][tile_y * tile_stride[level] + tile_x];4 return tri_z_near > tile_z_max; // 整个 tile 都在三角形前面 → 剔除5}8.5.3 2×2 像素 Quad#片段着色器实际上以 2×2 像素为最小单位执行。这是因为硬件需要相邻像素的属性差来估算 ∂u/∂x,∂u/∂y\partial u/\partial x, \partial u/\partial y∂u/∂x,∂u/∂y 等导数,用于 Mipmap 选级和各向异性过滤。

WARNING正因如此,分支内调用 texture() 是危险的——如果同 Quad 中有的像素走 if 分支、有的走 else 分支,导数就不对了。常见症状是树叶、栅栏的 Alpha Test 边缘出现 Mipmap 错误。

九、深度测试与隐藏面消除#9.1 Z-Buffer 算法#核心思想:为每个像素维护一个深度值 depth_buffer[x][y],只有新片段的深度更近才写入。

1void draw_triangle(const Triangle& t) {2 rasterize_triangle(t.v, [&](int x, int y, float a, float b, float g) {3 // 深度插值(NDC 空间可直接线性插值)4 float z = a * t.v[0].z() + b * t.v[1].z() + g * t.v[2].z();5 int idx = y * width + x;6 if (z < depth_buffer[idx]) {7 depth_buffer[idx] = z;8 color_buffer[idx] = shade(t, a, b, g);9 }10 });11}优点

与顺序无关:三角形可以任意顺序提交

简单,硬件友好

支持复杂遮挡关系(循环遮挡都没问题)

缺点

需要额外内存(每像素 24/32 位)

对半透明物体失效:必须从远到近手动排序

有精度问题(见 §9.2)

9.2 深度精度与 Z-Fighting#9.2.1 非线性的深度分布#透视投影后,NDC 深度与相机空间深度的关系:

zNDC=f+nf−n−2fn(f−n)zviewz_{NDC} = \frac{f + n}{f - n} - \frac{2fn}{(f-n) z_{view}}zNDC​=f−nf+n​−(f−n)zview​2fn​求导得精度分布:

dzNDCdzview=2fn(f−n)zview2\frac{dz_{NDC}}{dz_{view}} = \frac{2fn}{(f-n) z_{view}^2}dzview​dzNDC​​=(f−n)zview2​2fn​这个精度与 zview2z_{view}^2zview2​ 成反比——远处精度急剧下降。

9.2.2 Z-Fighting 的数学条件#24 位深度缓冲区的最小可分辨单位:

ΔzNDCmin=1224−1≈5.96×10−8\Delta z_{NDC}^{min} = \frac{1}{2^{24} - 1} \approx 5.96 \times 10^{-8}ΔzNDCmin​=224−11​≈5.96×10−8在相机空间距离 zzz 处,两个表面可区分的最小间距:

Δzviewmin=z2(f−n)2fn⋅ΔzNDCmin\Delta z_{view}^{min} = \frac{z^2(f - n)}{2fn} \cdot \Delta z_{NDC}^{min}Δzviewmin​=2fnz2(f−n)​⋅ΔzNDCmin​举例:n=0.1,f=1000,z=100n = 0.1, f = 1000, z = 100n=0.1,f=1000,z=100,Deltazviewminapprox0.030Delta z_{view}^{min} approx 0.030Deltazviewmin​approx0.030 —— 3 cm 以内的两个面会冲突。

9.2.3 三个常用缓解方法#方法一:收紧近远平面比值

f/n 越大、精度越差。工程经验:f/n<1000f/n < 1000f/n<1000 比较安全,f/n<10000f/n < 10000f/n<10000 可用,f/n>10000f/n > 10000f/n>10000 极易出问题。

方法二:Polygon Offset

1glEnable(GL_POLYGON_OFFSET_FILL);2glPolygonOffset(1.0f, 1.0f); // 把当前绘制的三角形"推远"一点点常用于阴影贴图——把遮挡物稍微推远避免自遮挡(shadow acne)。

方法三:反向 Z(Reverse-Z)

把近平面映射到 zNDC=1z_{NDC} = 1zNDC​=1,远平面映射到 zNDC=0z_{NDC} = 0zNDC​=0,配合浮点深度缓冲。利用 IEEE 754 浮点在接近 0 处精度更高的特性,精度分布从”远处差”变成”远处好”,大大缓解远场 Z-Fighting。

1// 反向 Z 投影矩阵(OpenGL 约定需要 glClipControl 切换裁剪空间)2Eigen::Matrix4f reverse_z_projection(float fov, float aspect,3 float n, float f) {4 float t = std::tan(fov * 0.5f);5 Eigen::Matrix4f P = Eigen::Matrix4f::Zero();6 P(0, 0) = 1.0f / (aspect * t);7 P(1, 1) = 1.0f / t;8 P(2, 2) = n / (f - n); // 近→1, 远→09 P(2, 3) = (f * n) / (f - n);10 P(3, 2) = -1.0f;11 return P;12}13

14// 配合:15glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE);16glDepthFunc(GL_GREATER); // 比较方向反过来17glClearDepth(0.0f); // 清成 0(代表"最远")9.3 其他隐藏面消除#9.3.1 画家算法#按深度从远到近绘制。致命弱点:

循环遮挡处理不了(三角形 A 挡 B、B 挡 C、C 挡 A)

穿插的长条三角形无法用单个深度值代表

今天的用法:只用在透明物体的 Sort-Then-Draw 流程里,因为 Z-Buffer 无法正确混合半透明。

9.3.2 BSP 树#用一系列分割平面把场景递归切成前后两部分,建一棵二叉空间分割树。遍历时按相机和分割平面的位置关系决定先画哪边——能对静态几何得到绝对正确的深度顺序,不需要 Z-Buffer。

经典用途:Doom / Quake 时代的室内渲染。现代 GPU 有 Z-Buffer 后基本被淘汰,但在CSG 布尔运算、碰撞检测中仍有身影。

十、光照模型与着色#10.1 渲染方程引子#NOTE本节只给光照模型的物理引子。完整的辐射度量学(Radiance/Irradiance/Radiant Flux 的严格定义)、BRDF 的三条性质、渲染方程的积分形式与路径追踪求解,全部见 Part 4:光线追踪与全局光照 §20.1。

10.1.1 渲染方程的形式#Kajiya 1986 提出的渲染方程描述了任何表面点的出射辐射度:

Lo(p,ωo)=Le(p,ωo)+∫Ω+fr(p,ωi,ωo)Li(p,ωi)cos⁡θi dωiL_o(\mathbf{p}, \omega_o) = L_e(\mathbf{p}, \omega_o) + \int_{\Omega^+} f_r(\mathbf{p}, \omega_i, \omega_o) L_i(\mathbf{p}, \omega_i) \cos\theta_i \, d\omega_iLo​(p,ωo​)=Le​(p,ωo​)+∫Ω+​fr​(p,ωi​,ωo​)Li​(p,ωi​)cosθi​dωi​

LoL_oLo​:从 p\mathbf{p}p 点沿 ωo\omega_oωo​ 方向的出射辐射度

LeL_eLe​:自发光

frf_rfr​:BRDF,描述入射→出射的反射率

LiL_iLi​:入射辐射度

costhetai=vecncdotomegaicostheta_i = vec{n} cdot omega_icosthetai​=vecncdotomegai​:入射方向与法向量夹角的余弦

10.1.2 光栅化管线的经验近似#光栅化管线没能力求解这个积分——每个片段只能访问自己的局部信息。于是Phong 模型把积分替换为对少量光源的求和:

Lo≈Lambient+∑k=1Nlights[Ldiffuse(k)+Lspecular(k)]L_o \approx L_{ambient} + \sum_{k=1}^{N_{lights}} \left[ L_{diffuse}^{(k)} + L_{specular}^{(k)} \right]Lo​≈Lambient​+k=1∑Nlights​​[Ldiffuse(k)​+Lspecular(k)​]这是一种经验模型,精度低但速度极快。物理正确的 PBR 模型(Cook-Torrance、Disney BRDF)见 Part 6。

10.2 Phong 反射模型#10.2.1 三分量结构#Itotal=kaIa⏟环境+kdIl(n⋅l)⏟漫反射+ksIl(r⋅v)n⏟镜面I_{total} = \underbrace{k_a I_a}_{\text{环境}} + \underbrace{k_d I_l (\mathbf{n} \cdot \mathbf{l})}_{\text{漫反射}} + \underbrace{k_s I_l (\mathbf{r} \cdot \mathbf{v})^n}_{\text{镜面}}Itotal​=环境ka​Ia​​​+漫反射kd​Il​(n⋅l)​​+镜面ks​Il​(r⋅v)n​​10.2.2 漫反射:Lambert 定律#物理假设:理想粗糙表面各方向反射均匀。能量守恒给出 BRDF:

frLambert=ρdπ,ρd∈[0,1] 是反照率(albedo)f_r^{Lambert} = \frac{\rho_d}{\pi}, \quad \rho_d \in [0, 1] \text{ 是反照率(albedo)}frLambert​=πρd​​,ρd​∈[0,1] 是反照率(albedo)推导:出射辐射度 LrL_rLr​ 在半球上积分应等于入射辐照度 EEE 乘以 rhodrho_drhod​:

ρdE=∫ΩLrcos⁡θrdωr=Lr∫Ωcos⁡θrdωr=Lr⋅π\rho_d E = \int_{\Omega} L_r \cos\theta_r d\omega_r = L_r \int_{\Omega} \cos\theta_r d\omega_r = L_r \cdot \piρd​E=∫Ω​Lr​cosθr​dωr​=Lr​∫Ω​cosθr​dωr​=Lr​⋅π其中半球积分 intOmegacostheta,domega=piint_{Omega} costheta, domega = piintOmega​costheta,domega=pi。由 BRDF 定义 fr=Lr/Ef_r = L_r / Efr​=Lr​/E,得 fr=rhod/pif_r = rho_d / pifr​=rhod​/pi。

10.2.3 镜面反射与反射向量#给定入射单位向量 l\mathbf{l}l 和法向量 mathbfnmathbf{n}mathbfn,反射向量 r\mathbf{r}r 应该:

与 n\mathbf{n}n 夹角等于 l\mathbf{l}l 与 n\mathbf{n}n 的夹角

与 mathbflmathbf{l}mathbfl、mathbfnmathbf{n}mathbfn 共面

推导:把 l\mathbf{l}l 分解为平行 n\mathbf{n}n 和垂直 n\mathbf{n}n 两个分量:

l=(n⋅l)n⏟l∥+l−(n⋅l)n⏟l⊥\mathbf{l} = \underbrace{(\mathbf{n} \cdot \mathbf{l})\mathbf{n}}_{\mathbf{l}_\parallel} + \underbrace{\mathbf{l} - (\mathbf{n} \cdot \mathbf{l})\mathbf{n}}_{\mathbf{l}_\perp}l=l∥​(n⋅l)n​​+l⊥​l−(n⋅l)n​​反射只翻转切向分量 mathbflperpmathbf{l}_perpmathbflp​erp:

r=l∥−l⊥=2(n⋅l)n−l\mathbf{r} = \mathbf{l}_\parallel - \mathbf{l}_\perp = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l}r=l∥​−l⊥​=2(n⋅l)n−lWARNINGGLSL 的 reflect(I, N) 约定 I\mathbf{I}I 是从光源射入的方向(即 −mathbfl-mathbf{l}−mathbfl),返回 mathbfI−2(mathbfNcdotmathbfI)mathbfNmathbf{I} - 2(mathbf{N} cdot mathbf{I})mathbf{N}mathbfI−2(mathbfNcdotmathbfI)mathbfN。符号容易搞错,自己实现时务必验证。

10.2.4 工程实现#1Eigen::Vector3f phong_shade(const Eigen::Vector3f& p, // 世界空间点2 const Eigen::Vector3f& n, // 单位法向量3 const Eigen::Vector3f& eye_pos,4 const Light& light,5 const Material& mat) {6 Eigen::Vector3f l = (light.position - p).normalized();7 Eigen::Vector3f v = (eye_pos - p).normalized();8 Eigen::Vector3f r = 2.0f * n.dot(l) * n - l;9

10 // 距离衰减(平方反比定律)11 float r2 = (light.position - p).squaredNorm();12 Eigen::Vector3f I_eff = light.intensity / r2;13

14 Eigen::Vector3f ambient = mat.ka.cwiseProduct(light.ambient);15 Eigen::Vector3f diffuse = mat.kd.cwiseProduct(I_eff)16 * std::max(0.0f, n.dot(l));17 Eigen::Vector3f specular = mat.ks.cwiseProduct(I_eff)18 * std::pow(std::max(0.0f, r.dot(v)), mat.shininess);19

20 return ambient + diffuse + specular;21}10.3 Blinn-Phong:半角向量#10.3.1 动机#Phong 的 r⋅v\mathbf{r} \cdot \mathbf{v}r⋅v 在掠射角(光源几乎平行于表面)附近数值不稳定,同时每个像素都要算一次 mathbfrmathbf{r}mathbfr。Blinn 1977 观察到一个等效替代:

h=l+v∥l+v∥\mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\|\mathbf{l} + \mathbf{v}\|}h=∥l+v∥l+v​h\mathbf{h}h 是入射与视线方向的半角向量。用 (n⋅h)n′(\mathbf{n} \cdot \mathbf{h})^{n'}(n⋅h)n′ 替换 (mathbfrcdotmathbfv)n(mathbf{r} cdot mathbf{v})^n(mathbfrcdotmathbfv)n。

10.3.2 几何等价性#微表面理论视角:只有法向量恰好等于 h\mathbf{h}h 的微面才把光线从 l\mathbf{l}l 反射到 mathbfvmathbf{v}mathbfv。所以 n⋅h\mathbf{n} \cdot \mathbf{h}n⋅h 测量的是”当前宏观法向量”与”完美反射所需微表面法向量”的夹角,物理意义比 r⋅v\mathbf{r} \cdot \mathbf{v}r⋅v 更直接。

10.3.3 指数修正#经验公式 nBlinnapprox4nPhongn_{Blinn} approx 4 n_{Phong}nBlinn​approx4nPhong​,得到视觉上近似大小的高光。

10.3.4 为什么大家都用 Blinn-Phong#

便宜:省掉 reflect() 的计算

稳定:掠射角下不会突变

可定向光源复用:平行光下 l\mathbf{l}l 和 v\mathbf{v}v 都是常数,mathbfhmathbf{h}mathbfh 整帧可以预计算

贴近 PBR:微表面 GGX/Beckmann 模型里,mathbfncdotmathbfhmathbf{n} cdot mathbf{h}mathbfncdotmathbfh 是天然的自变量

10.4 着色频率(Shading Frequency)#同一个光照模型,在”哪里求值”会大幅影响质量:

着色频率求值位置质量成本典型用途Flat三角形一次★★低多边形风格化Gouraud顶点★★★★老游戏、性能优先Phong片段★★★★★★现代默认Flat Shading:用面法向量,整个三角形一种颜色。mathbfnface=frac(vecv1−vecv0)times(vecv2−vecv0)∣cdots∣mathbf{n}_{face} = frac{(vec{v}_1-vec{v}_0) times (vec{v}_2-vec{v}_0)}{|cdots|}mathbfnface​=frac(vecv1​−vecv0​)times(vecv2​−vecv0​)∣cdots∣

Gouraud Shading:顶点法向量(相邻面法向量加权平均),顶点着色后用重心坐标插值颜色。问题:镜面高光如果不落在顶点上就完全丢失。

Phong Shading:顶点存法向量,光栅化时插值法向量,在片段着色器里逐像素计算光照。现代管线默认方式。注意:Phong Shading 和 Phong Lighting Model 是两回事,前者是”在哪算”,后者是”怎么算”。

十一、纹理映射#11.1 UV 坐标与采样基础#11.1.1 UV 坐标系#纹理是一张 W×HW \times HW×H 的图像。UV 坐标 (u,v)∈[0,1]2(u, v) \in [0, 1]^2(u,v)∈[0,1]2 是归一化的纹理坐标,与具体分辨率解耦。

连续纹理坐标到像素索引的映射(采样点在像素中心):

x=u⋅W−0.5,y=v⋅H−0.5x = u \cdot W - 0.5, \quad y = v \cdot H - 0.5x=u⋅W−0.5,y=v⋅H−0.511.1.2 最近邻采样#T(u,v)=texel[round(x),round(y)]T(u, v) = \text{texel}[\text{round}(x), \text{round}(y)]T(u,v)=texel[round(x),round(y)]优点:保真、可见像素细节;缺点:放大时出现明显锯齿 / 阶梯感。

11.1.3 双线性过滤(完整推导)#在 (x,y)(x, y)(x,y) 周围取四个整数邻居 (x0,y0),(x1,y0),(x0,y1),(x1,y1)(x_0, y_0), (x_1, y_0), (x_0, y_1), (x_1, y_1)(x0​,y0​),(x1​,y0​),(x0​,y1​),(x1​,y1​),其中 x0=lfloorxrfloor,x1=x0+1x_0 = lfloor x rfloor, x_1 = x_0 + 1x0​=lfloorxrfloor,x1​=x0​+1。权重 fx=x−x0,fy=y−y0f_x = x - x_0, f_y = y - y_0fx​=x−x0​,fy​=y−y0​。

先沿 x 插值:

C0=(1−fx)T00+fxT10C_0 = (1 - f_x) T_{00} + f_x T_{10}C0​=(1−fx​)T00​+fx​T10​C1=(1−fx)T01+fxT11C_1 = (1 - f_x) T_{01} + f_x T_{11}C1​=(1−fx​)T01​+fx​T11​再沿 y 插值:

Tbi(u,v)=(1−fy)C0+fyC1T_{bi}(u, v) = (1 - f_y) C_0 + f_y C_1Tbi​(u,v)=(1−fy​)C0​+fy​C1​展开式(四个 texel 的双线性组合):

Tbi=(1−fx)(1−fy)T00+fx(1−fy)T10+(1−fx)fyT01+fxfyT11T_{bi} = (1-f_x)(1-f_y) T_{00} + f_x(1-f_y) T_{10} + (1-f_x)f_y T_{01} + f_x f_y T_{11}Tbi​=(1−fx​)(1−fy​)T00​+fx​(1−fy​)T10​+(1−fx​)fy​T01​+fx​fy​T11​1Eigen::Vector3f sample_bilinear(const Texture& tex, float u, float v) {2 float x = u * tex.width - 0.5f;3 float y = v * tex.height - 0.5f;4 int x0 = (int)std::floor(x), y0 = (int)std::floor(y);5 int x1 = x0 + 1, y1 = y0 + 1;6 float fx = x - x0, fy = y - y0;7

8 auto T00 = tex.fetch_wrap(x0, y0);9 auto T10 = tex.fetch_wrap(x1, y0);10 auto T01 = tex.fetch_wrap(x0, y1);11 auto T11 = tex.fetch_wrap(x1, y1);12

13 auto C0 = (1 - fx) * T00 + fx * T10;14 auto C1 = (1 - fx) * T01 + fx * T11;15 return (1 - fy) * C0 + fy * C1;16}11.2 Mipmap 与三线性过滤#11.2.1 走样问题的来源#一个屏幕像素覆盖纹理上一大块区域时(远处地面),若只采一个点,就发生欠采样——纹理高频部分出现 Moiré 条纹、闪烁、锯齿。这是奈奎斯特采样定理的直接后果。

11.2.2 Mipmap:预滤波金字塔#预先构建一系列降采样的纹理:

Level 0:原图 W×HW \times HW×H

Level 1:W/2timesH/2W/2 times H/2W/2timesH/2,每个像素取 Level 0 的 2×2 平均

…直到 1×11 \times 11×1

总存储开销:WcdotHcdot(1+1/4+1/16+ldots)=frac43WHW cdot H cdot (1 + 1/4 + 1/16 + ldots) = frac{4}{3} W HWcdotHcdot(1+1/4+1/16+ldots)=frac43WH。

1void build_mipmaps(Texture& tex) {2 int w = tex.width, h = tex.height;3 while (w > 1 || h > 1) {4 int nw = std::max(1, w / 2);5 int nh = std::max(1, h / 2);6 Texture next(nw, nh);7 for (int y = 0; y < nh; ++y) {8 for (int x = 0; x < nw; ++x) {9 auto sum = (tex.fetch(2*x, 2*y ) + tex.fetch(2*x+1, 2*y)10 + tex.fetch(2*x, 2*y+1) + tex.fetch(2*x+1, 2*y+1));11 next.set(x, y, sum * 0.25f);12 }13 }14 tex.mips.push_back(std::move(next));15 w = nw; h = nh;16 }17}11.2.3 选级 LOD 计算#GPU 利用 2×2 Quad 的相邻像素差分估算 UV 在屏幕上变化多快:

L=max⁡((∂u/∂x)2+(∂v/∂x)2,(∂u/∂y)2+(∂v/∂y)2)⋅WL = \max\left(\sqrt{(\partial u / \partial x)^2 + (\partial v / \partial x)^2}, \sqrt{(\partial u / \partial y)^2 + (\partial v / \partial y)^2}\right) \cdot WL=max((∂u/∂x)2+(∂v/∂x)2​,(∂u/∂y)2+(∂v/∂y)2​)⋅WLOD=log⁡2L\text{LOD} = \log_2 LLOD=log2​L11.2.4 三线性过滤#LOD 是连续值,分别在 ⌊LOD⌋\lfloor \text{LOD} \rfloor⌊LOD⌋ 和 ⌈LOD⌉\lceil \text{LOD} \rceil⌈LOD⌉ 层做双线性,再在两层结果间线性插值——总共 2×4+1=92 \times 4 + 1 = 92×4+1=9 次样本计算。

11.3 纹理寻址模式#当 (u,v)(u, v)(u,v) 超出 [0,1]2[0, 1]^2[0,1]2 时怎么办?

Repeat:u′=u−lfloorurflooru' = u - lfloor u rflooru′=u−lfloorurfloor,地板/墙壁等重复纹理

Mirror:镜像重复,uuu 每加 1 就翻一次方向

Clamp to Edge:u′=textclamp(u,0,1)u' = text{clamp}(u, 0, 1)u′=textclamp(u,0,1),天空盒接缝必备

Clamp to Border:超出范围用预设边界色

11.4 法线贴图(Normal Mapping)#11.4.1 思想#在低多边形模型上用纹理欺骗法向量,造出高频凹凸细节的错觉。一张 RGB 纹理里每个像素存一个切线空间下的法向量 tildemathbfnin[−1,1]3tilde{mathbf{n}} in [-1, 1]^3tildemathbfnin[−1,1]3。

从 [0,1]3[0,1]^3[0,1]3 的 RGB 映射到法向量:tildemathbfn=2cdottextrgb−1tilde{mathbf{n}} = 2cdottext{rgb} - 1tildemathbfn=2cdottextrgb−1

11.4.2 切线空间(TBN)#每个顶点存 mathbfTmathbf{T}mathbfT(tangent)、mathbfBmathbf{B}mathbfB(bitangent)、mathbfNmathbf{N}mathbfN(normal)三个正交单位向量。TBN 矩阵把切线空间向量变到世界空间:

nworld=TBN⋅n~,TBN=(TBN)\mathbf{n}_{world} = \text{TBN} \cdot \tilde{\mathbf{n}}, \quad \text{TBN} = \begin{pmatrix} \mathbf{T} & \mathbf{B} & \mathbf{N} \end{pmatrix}nworld​=TBN⋅n~,TBN=(T​B​N​)11.4.3 从 UV 构造 T 与 B#给三角形三个顶点 v⃗0,v⃗1,v⃗2\vec{v}_0, \vec{v}_1, \vec{v}_2v0​,v1​,v2​ 及其 UV,两条边:

Δv⃗1=v⃗1−v⃗0,Δuv1=uv1−uv0\Delta \vec{v}_1 = \vec{v}_1 - \vec{v}_0, \quad \Delta \mathbf{uv}_1 = \mathbf{uv}_1 - \mathbf{uv}_0Δv1​=v1​−v0​,Δuv1​=uv1​−uv0​Δv⃗2=v⃗2−v⃗0,Δuv2=uv2−uv0\Delta \vec{v}_2 = \vec{v}_2 - \vec{v}_0, \quad \Delta \mathbf{uv}_2 = \mathbf{uv}_2 - \mathbf{uv}_0Δv2​=v2​−v0​,Δuv2​=uv2​−uv0​求解 2×22 \times 22×2 线性方程组:

(Δu1Δv1Δu2Δv2)(TB)=(Δv⃗1Δv⃗2)\begin{pmatrix} \Delta u_1 & \Delta v_1 \\ \Delta u_2 & \Delta v_2 \end{pmatrix} \begin{pmatrix} \mathbf{T} \\ \mathbf{B} \end{pmatrix} = \begin{pmatrix} \Delta \vec{v}_1 \\ \Delta \vec{v}_2 \end{pmatrix}(Δu1​Δu2​​Δv1​Δv2​​)(TB​)=(Δv1​Δv2​​)逆矩阵解法:

(TB)=1Δu1Δv2−Δu2Δv1(Δv2−Δv1−Δu2Δu1)(Δv⃗1Δv⃗2)\begin{pmatrix} \mathbf{T} \\ \mathbf{B} \end{pmatrix} = \frac{1}{\Delta u_1 \Delta v_2 - \Delta u_2 \Delta v_1} \begin{pmatrix} \Delta v_2 & -\Delta v_1 \\ -\Delta u_2 & \Delta u_1 \end{pmatrix} \begin{pmatrix} \Delta \vec{v}_1 \\ \Delta \vec{v}_2 \end{pmatrix}(TB​)=Δu1​Δv2​−Δu2​Δv1​1​(Δv2​−Δu2​​−Δv1​Δu1​​)(Δv1​Δv2​​)1// 对每个三角形计算,再按顶点累加再归一化得到顶点 T/B2void compute_tbn(const Vertex& v0, const Vertex& v1, const Vertex& v2,3 Eigen::Vector3f& T, Eigen::Vector3f& B) {4 auto dv1 = v1.pos - v0.pos, dv2 = v2.pos - v0.pos;5 auto du1 = v1.uv - v0.uv, du2 = v2.uv - v0.uv;6

7 float det = du1.x() * du2.y() - du2.x() * du1.y();8 float inv = (std::abs(det) < 1e-8f) ? 1.0f : 1.0f / det;9

10 T = inv * ( du2.y() * dv1 - du1.y() * dv2);11 B = inv * (-du2.x() * dv1 + du1.x() * dv2);12}Fragment Shader 里:

1vec3 tangent_normal = texture(uNormalMap, vTexCoord).rgb * 2.0 - 1.0;2mat3 TBN = mat3(normalize(vTangent),3 normalize(vBitangent),4 normalize(vNormal));5vec3 N = normalize(TBN * tangent_normal);6// 后续用 N 做光照计算11.5 环境映射(Environment Mapping)#让物体”反射”周围场景的技术。核心:把周围环境烘焙成一张纹理,按反射方向采样。

11.5.1 Cubemap#六张图片:分别对应 ±x,±y,±z\pm x, \pm y, \pm z±x,±y,±z 六个方向。采样时根据反射方向 r\mathbf{r}r 分量的最大绝对值选择哪张面:

1int face; Eigen::Vector2f uv;2Eigen::Vector3f a = r.cwiseAbs();3if (a.x() >= a.y() && a.x() >= a.z()) {4 face = (r.x() > 0) ? 0 : 1;5 uv = Eigen::Vector2f(-r.z(), -r.y()) / a.x();6 if (face == 1) uv.x() = -uv.x();7} else if (a.y() >= a.z()) {8 face = (r.y() > 0) ? 2 : 3;9 uv = Eigen::Vector2f(r.x(), (r.y() > 0) ? r.z() : -r.z()) / a.y();10} else {11 face = (r.z() > 0) ? 4 : 5;12 uv = Eigen::Vector2f((r.z() > 0) ? r.x() : -r.x(), -r.y()) / a.z();13}14uv = (uv + Eigen::Vector2f(1, 1)) * 0.5f;11.5.2 Spherical Mapping#用单张经纬度投影的 HDR 图像。采样公式:

u=0.5+arctan⁡2(rz,rx)2π,v=0.5−arcsin⁡(ry)πu = 0.5 + \frac{\arctan2(r_z, r_x)}{2\pi}, \quad v = 0.5 - \frac{\arcsin(r_y)}{\pi}u=0.5+2πarctan2(rz​,rx​)​,v=0.5−πarcsin(ry​)​优点:单图、适合 HDR 光照;缺点:两极有失真和缝合线问题。

11.5.3 典型应用#

Skybox:用视线方向采样,得到天空背景

Reflection:用反射向量 r=2(n⋅v)n−v\mathbf{r} = 2(\mathbf{n}\cdot\mathbf{v})\mathbf{n} - \mathbf{v}r=2(n⋅v)n−v 采样,伪造镜面反射

IBL(Image-Based Lighting):预过滤 cubemap 做漫反射/镜面反射环境光照——PBR 的基础,详见 Part 6

小结#本部分把光栅化管线从顶点输入到最终像素完整走了一遍:

几何阶段把顶点变换、法向量修正、TRS 分解做完

裁剪阶段在齐次空间解决视锥外三角形和 w<0w < 0w<0 的陷阱

光栅化核心把 Bresenham、边方程、重心坐标、透视校正这四块拼成了经典的三角形填充

深度测试给出了 Z-Buffer、精度分析和 Reverse-Z

光照用 Phong / Blinn-Phong 给出了一套快速的经验近似,留出渲染方程的严格求解到 Part 4

纹理映射覆盖了采样、过滤、寻址、法线贴图、环境贴图

从 GAMES101 Assignment 1/2/3 到工业级管线,这一章提到的每一行代码都是绕不开的基建。Part 3 起我们进入几何建模,会看到曲线、曲面、网格这些在光栅化之前就准备好的数据是怎么来的。

青云诀之伏魔转生条件是什么 全等级转生条件一览 2026世界杯新秀:谁将异军突起,搅动绿茵风云?