8571 字
43 分钟
计算机图形学笔记(二):光栅化渲染管线
NOTE

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

目录#

  1. 渲染管线总览 — 三大阶段、MVP 变换链、可编程着色器
  2. 几何阶段 — 顶点数据组织、法向量变换代码、TRS 分解
  3. 图元装配与裁剪 — 齐次空间裁剪、Sutherland-Hodgman 算法
  4. 光栅化核心 — Bresenham 画线、三角形光栅化、重心坐标、透视校正
  5. 深度测试 — Z-Buffer、深度精度、Z-Fighting 与反向 Z
  6. 光照模型 — Phong、Blinn-Phong、着色频率
  7. 纹理映射 — 采样、过滤、Mipmap、法线贴图、环境映射

五、渲染管线总览#

5.1 管线三大阶段#

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

应用阶段(Application Stage, CPU)

  • 场景管理、视锥剔除(粗粒度)、LOD 选择
  • 动画更新(骨骼、顶点变形)
  • 最终提交给 GPU 的是顶点缓冲 + 索引 + 绘制调用

几何阶段(Geometry Stage, GPU)

  • 顶点着色器:顶点变换(MVP)、法向量变换、属性传递
  • 可选曲面细分 / 几何着色器
  • 投影、裁剪、屏幕映射

光栅化阶段(Rasterization Stage, GPU)

  • 三角形设置:边方程与重心坐标系数
  • 三角形遍历:决定哪些像素被覆盖
  • 片段着色器:按像素计算颜色
  • 输出合并:深度测试、模板测试、混合
TIP

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

5.2 坐标空间变换链#

图形学里最经典的一张图:

模型坐标LocalM世界坐标WorldV观察坐标ViewP裁剪坐标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}}

每一步的矩阵推导在 Part 1:数学基础 里有完整细节(mathbfM,V,Pmathbf{M,V,P} 的构造分别见 Part 1 §2.2、§3.2、§3.1),这里只记住两个要点:

一、复合矩阵的顺序

vclip=PVMvlocal\vec{v}_{clip} = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot \vec{v}_{local}

矩阵乘法从右往左作用,所以写代码的时候也必须按 P * V * M 构造。

二、透视除法触发点

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

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

S=(w/200w/20h/20h/200(f2f1)/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}

其中 w,hw, h 是屏幕宽高,[f1,f2][f_1, f_2] 是深度缓冲区映射范围(OpenGL 默认 [0,1][0,1])。

5.3 可编程着色器#

5.3.1 顶点着色器(Vertex Shader)#

每个顶点运行一次,职责是把顶点从模型空间送进裁剪空间。

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat3 uNormalMatrix; // inverse-transpose of upper-left 3x3 of uModel
out vec3 vWorldPos;
out vec3 vNormal;
out vec2 vTexCoord;
void main() {
vec4 worldPos = uModel * vec4(aPos, 1.0);
vWorldPos = worldPos.xyz;
vNormal = normalize(uNormalMatrix * aNormal);
vTexCoord = aTexCoord;
gl_Position = uProjection * uView * worldPos;
}
WARNING

法向量必须用 (M⁻¹)ᵀ 的左上 3×3 去变换,不能直接乘 uModel。完整推导见 Part 1 §2.3,本部分 §6.2 只放工程实现。

5.3.2 片段着色器(Fragment Shader)#

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

#version 330 core
in vec3 vWorldPos;
in vec3 vNormal;
in vec2 vTexCoord;
uniform sampler2D uAlbedo;
uniform vec3 uLightPos;
uniform vec3 uViewPos;
out vec4 FragColor;
void main() {
vec3 N = normalize(vNormal);
vec3 L = normalize(uLightPos - vWorldPos);
vec3 V = normalize(uViewPos - vWorldPos);
vec3 H = normalize(L + V); // 半角向量(Blinn-Phong)
vec3 albedo = texture(uAlbedo, vTexCoord).rgb;
float diff = max(dot(N, L), 0.0);
float spec = pow(max(dot(N, H), 0.0), 64.0);
vec3 ambient = 0.1 * albedo;
vec3 diffuse = diff * albedo;
vec3 specular = vec3(0.3) * spec;
FragColor = vec4(ambient + diffuse + specular, 1.0);
}

六、几何阶段细节#

6.1 顶点数据组织#

一个几何体在 GPU 上的常见表示:

struct Vertex {
Eigen::Vector3f position; // 模型空间坐标
Eigen::Vector3f normal; // 顶点法向量
Eigen::Vector2f texCoord; // UV
Eigen::Vector3f tangent; // 切向量(法线贴图需要)
};

VBO(Vertex Buffer Object):连续存放所有顶点属性的 GPU 缓冲区。

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

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

unsigned int VAO, VBO, EBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
vertices.data(), GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
indices.data(), GL_STATIC_DRAW);
// location 0: 位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void*)offsetof(Vertex, position));
// location 1: 法向量
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void*)offsetof(Vertex, normal));
// location 2: UV
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(void*)offsetof(Vertex, texCoord));

6.2 法向量变换(仅工程实现)#

NOTE

完整推导见 Part 1 §2.3(切平面垂直条件 → 逆转置),本节只保留工程代码和易错点。

// 从 4x4 模型矩阵计算 3x3 法向量矩阵
Eigen::Matrix3f compute_normal_matrix(const Eigen::Matrix4f& model) {
return model.block<3, 3>(0, 0).inverse().transpose();
}
// 把模型空间法向量变到世界空间
Eigen::Vector3f transform_normal(const Eigen::Vector3f& n,
const Eigen::Matrix4f& model) {
return (compute_normal_matrix(model) * n).normalized();
}

易错点清单

  • ❌ 直接 model * n(非均匀缩放下不垂直切平面)
  • ❌ 忘记取左上 3×3(把平移项混进去会搞坏方向向量)
  • ❌ 忘记最后归一化(缩放会改变法向量长度,后续光照计算失败)
  • ✅ 纯旋转时,(mathbfM1)T=mathbfM(mathbf{M}^{-1})^T = mathbf{M},法向量矩阵退化为原矩阵

6.3 变换矩阵的 TRS 分解#

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

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

M=TRS\mathbf{M} = \mathbf{T} \cdot \mathbf{R} \cdot \mathbf{S}

算法步骤

  1. 提取平移vect=mathbfM[0:3,3]vec{t} = mathbf{M}[0{:}3, 3]
  2. 提取线性部分mathbfA=mathbfM[0:3,0:3]mathbf{A} = mathbf{M}[0{:}3, 0{:}3](上 3×3)
  3. 计算缩放因子sk=mathbfA[:,k]s_k = |mathbf{A}_{[:,k]}|(每列的模长)
  4. 检测反射:若 det(mathbfA)<0det(mathbf{A}) < 0,把 szs_z 取负,避免把反射当成旋转
  5. 提取旋转mathbfR[:,k]=mathbfA[:,k]/skmathbf{R}*{[:,k]} = mathbf{A}*{[:,k]} / s_k
struct Transform {
Eigen::Vector3f translation;
Eigen::Quaternionf rotation;
Eigen::Vector3f scale;
};
Transform decompose(const Eigen::Matrix4f& M) {
Transform out;
out.translation = M.block<3, 1>(0, 3);
Eigen::Matrix3f A = M.block<3, 3>(0, 0);
out.scale.x() = A.col(0).norm();
out.scale.y() = A.col(1).norm();
out.scale.z() = A.col(2).norm();
// 处理反射:把负号归到 Z
if (A.determinant() < 0.0f) out.scale.z() = -out.scale.z();
Eigen::Matrix3f R;
R.col(0) = A.col(0) / out.scale.x();
R.col(1) = A.col(1) / out.scale.y();
R.col(2) = A.col(2) / out.scale.z();
out.rotation = Eigen::Quaternionf(R);
return out;
}
WARNING

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


七、图元装配与裁剪#

7.1 图元类型#

GPU 只认识三种基本图元:点、线段、三角形。其他几何体(四边形、圆、贝塞尔曲线)都要先转成这三种。

常用的三角形拓扑:

  • GL_TRIANGLES:每 3 个顶点一个三角形
  • GL_TRIANGLE_STRIP:相邻三角形共享两个顶点,nn 个顶点定义 n2n-2 个三角形
  • GL_TRIANGLE_FAN:所有三角形共享第一个顶点,适合凸多边形

7.2 齐次空间裁剪#

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

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

wxw(左、右)wyw(下、上)wzw(近、远)\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}

直接在齐次坐标 (x,y,z,w)(x, y, z, w) 下做裁剪,不需要先除 ww——这是整个管线能正确处理跨视锥三角形的关键。

点的裁剪测试

bool point_inside_frustum(const Eigen::Vector4f& p) {
float w = p.w();
return p.x() >= -w && p.x() <= w
&& p.y() >= -w && p.y() <= w
&& p.z() >= -w && p.z() <= w;
}

点到某个裁剪平面的带符号距离(用来求三角形与平面的交点参数):

: d=w+x,: d=wx: d=w+y,: d=wy: d=w+z,: d=wz\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}

7.3 Sutherland-Hodgman 多边形裁剪#

算法思想:对每一个裁剪平面,把输入多边形切一刀,结果多边形再作为下一个平面的输入。六个平面处理完就得到最终裁剪结果。

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

PprevP_{prev}PcurrP_{curr}输出
输出 PcurrP_{curr}
输出交点
输出交点,再输出 PcurrP_{curr}
什么都不输出

交点的参数计算:设前后两点到平面的带符号距离为 d1,d2d_1, d_2,则交点参数

t=d1d1d2,Pcross=Pprev+t(PcurrPprev)t = \frac{d_1}{d_1 - d_2}, \quad P_{\text{cross}} = P_{prev} + t(P_{curr} - P_{prev})
WARNING

交点不仅位置要插值,所有顶点属性(UV、颜色、法向量)都要按同一个 tt 线性插值——否则裁剪出来的片段会出现纹理错位。

struct ClipVertex {
Eigen::Vector4f pos; // 齐次裁剪坐标
Eigen::Vector3f normal;
Eigen::Vector2f uv;
};
// 对某个裁剪平面计算带符号距离
using DistFn = std::function<float(const Eigen::Vector4f&)>;
std::vector<ClipVertex> clip_against_plane(const std::vector<ClipVertex>& in,
DistFn dist) {
std::vector<ClipVertex> out;
if (in.empty()) return out;
for (size_t i = 0; i < in.size(); ++i) {
const ClipVertex& curr = in[i];
const ClipVertex& prev = in[(i + in.size() - 1) % in.size()];
float d_curr = dist(curr.pos);
float d_prev = dist(prev.pos);
bool curr_inside = d_curr >= 0.0f;
bool prev_inside = d_prev >= 0.0f;
if (curr_inside != prev_inside) {
float t = d_prev / (d_prev - d_curr);
ClipVertex cross;
cross.pos = prev.pos + t * (curr.pos - prev.pos);
cross.normal = prev.normal + t * (curr.normal - prev.normal);
cross.uv = prev.uv + t * (curr.uv - prev.uv);
out.push_back(cross);
}
if (curr_inside) out.push_back(curr);
}
return out;
}

依次对六个平面调用 clip_against_plane,得到裁剪后的凸多边形。若顶点数 >3> 3,再做三角形扇化(Triangle Fan)即可。


八、光栅化核心#

8.1 Bresenham 画线算法(规范推导位置)#

8.1.1 问题#

给定两个整数端点 (x0,y0)(x_0, y_0)(x1,y1)(x_1, y_1),在像素网格上画出一条近似直线,要求:

  • 每列(或每行)只点亮一个像素,避免断线
  • 只用整数加减比较,没有除法和浮点

8.1.2 从直线方程出发#

Deltax=x1x0>0Delta x = x_1 - x_0 > 0Deltay=y1y0>0Delta y = y_1 - y_0 > 0,斜率 m=Deltay/Deltaxin(0,1]m = Delta y / Delta x in (0, 1]。直线方程写成隐式:

F(x,y)=ΔyxΔxy+(Δxy0Δyx0)=0F(x, y) = \Delta y \cdot x - \Delta x \cdot y + (\Delta x \cdot y_0 - \Delta y \cdot x_0) = 0
  • F>0F > 0:点在直线下方
  • F<0F < 0:点在直线上方
  • F=0F = 0:点在直线上

8.1.3 中点判据#

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

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) + C
  • dk<0d_k < 0:中点在直线上方 → 直线更靠下 → 选 (xk+1,yk)(x_k+1, y_k)
  • dkgeq0d_k geq 0:中点在直线下方 → 直线更靠上 → 选 (xk+1,yk+1)(x_k+1, y_k+1)

8.1.4 增量化(关键一步)#

直接算 dkd_k 还有浮点。相邻两步的判据差值是纯整数:

情况 Adk<0d_k < 0,选 (xk+1,yk)(x_k+1, y_k)

dk+1dk=Δ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 y

情况 Bdkgeq0d_k geq 0,选 (xk+1,yk+1)(x_k+1, y_k+1)

dk+1dk=Δ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 x

初值d0=F(x0+1,y0+0.5)=Deltay0.5Deltaxd_0 = F(x_0+1, y_0+0.5) = Delta y - 0.5Delta x。为了消去 0.50.5,两边同乘 2,得到全整数版本

d0=2ΔyΔxd_0 = 2\Delta y - \Delta xdk+1={dk+2Δy若 dk<0dk+2(ΔyΔx)若 dk0d_{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}

8.1.5 工程实现#

void bresenham_line(int x0, int y0, int x1, int y1,
std::function<void(int, int)> set_pixel) {
// 保证 x 方向递增;对 |m|>1 的情况交换 x/y 扫描
bool steep = std::abs(y1 - y0) > std::abs(x1 - x0);
if (steep) { std::swap(x0, y0); std::swap(x1, y1); }
if (x0 > x1) { std::swap(x0, x1); std::swap(y0, y1); }
int dx = x1 - x0;
int dy = std::abs(y1 - y0);
int d = 2 * dy - dx; // 初值 d_0
int ystep = (y0 < y1) ? 1 : -1;
int y = y0;
for (int x = x0; x <= x1; ++x) {
if (steep) set_pixel(y, x); // 还原坐标轴交换
else set_pixel(x, y);
if (d > 0) { y += ystep; d -= 2 * dx; }
d += 2 * dy;
}
}
TIP

Bresenham 的思想——用整数增量替代浮点计算——在后面的圆/椭圆画法、DDA 光栅化、各向异性过滤里都会反复出现。

8.2 三角形光栅化#

8.2.1 边方程(Edge Function)#

三角形 ABC\triangle ABC 的有向边 AB\overrightarrow{AB} 对应一条直线,其边函数

EAB(P)=(yAyB)(xPxA)+(xBxA)(yPyA)E_{AB}(P) = (y_A - y_B)(x_P - x_A) + (x_B - x_A)(y_P - y_A)

几何意义:EAB(P)E_{AB}(P) 等于 2 × 三角形 ABP\triangle ABP 的有向面积(逆时针为正)。

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

EAB(P)0  EBC(P)0  ECA(P)0E_{AB}(P) \geq 0 \ \land\ E_{BC}(P) \geq 0 \ \land\ E_{CA}(P) \geq 0

8.2.2 包围盒遍历#

现代光栅化不再用扫描线,而是轴对齐包围盒 + 边方程测试

void rasterize_triangle(const Vector3f v[3],
std::function<void(int, int, float, float, float)> shade) {
// 计算整数包围盒(注意屏幕边界裁剪)
int x_min = std::max(0, (int)std::floor(std::min({v[0].x(), v[1].x(), v[2].x()})));
int x_max = std::min(width -1, (int)std::ceil (std::max({v[0].x(), v[1].x(), v[2].x()})));
int y_min = std::max(0, (int)std::floor(std::min({v[0].y(), v[1].y(), v[2].y()})));
int y_max = std::min(height-1, (int)std::ceil (std::max({v[0].y(), v[1].y(), v[2].y()})));
for (int y = y_min; y <= y_max; ++y) {
for (int x = x_min; x <= x_max; ++x) {
// 以像素中心 (x+0.5, y+0.5) 做测试
auto [a, b, g] = barycentric_2d(x + 0.5f, y + 0.5f, v);
if (a >= 0 && b >= 0 && g >= 0) {
shade(x, y, a, b, g);
}
}
}
}

为什么用包围盒而不是扫描线?

  • 扫描线需要对三角形顶点排序 + 边增量计算,分支多
  • 包围盒 + 边方程对 SIMD/GPU 天然友好,可以 2×2 像素块并行
  • 支持任意三角形朝向(顺时针/逆时针只需切换符号)

8.2.3 Top-Left 规则#

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

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

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

8.3 重心坐标系(规范推导位置)#

NOTE

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

8.3.1 定义#

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

P=αA+βB+γC,α+β+γ=1P = \alpha A + \beta B + \gamma C, \quad \alpha + \beta + \gamma = 1

这就是 PP重心坐标。三个权重分别对应三个顶点的”影响力”。

8.3.2 面积比公式(完整推导)#

核心引理alpha,beta,gammaalpha, beta, gamma 等于 PP 关于对顶边所切分出的子三角形面积比

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

CP=α(AC)+β(BC)=αCA+βCB\overrightarrow{CP} = \alpha(A - C) + \beta(B - C) = \alpha \overrightarrow{CA} + \beta \overrightarrow{CB}

CB\overrightarrow{CB} 叉乘两边:

CP×CB=α(CA×CB)\overrightarrow{CP} \times \overrightarrow{CB} = \alpha (\overrightarrow{CA} \times \overrightarrow{CB})

两边取模:

α=CP×CBCA×CB=2Area(PBC)2Area(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)}

同理:

β=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)}

8.3.3 二维情形的标准公式#

二维下用 z=0z=0 的叉积求面积:textArea(triangleABC)=frac12left(xBxA)(yCyA)(xCxA)(yByA)righttext{Area}(triangle ABC) = frac{1}{2}left|(x_B-x_A)(y_C-y_A) - (x_C-x_A)(y_B-y_A)right|。记三角形总面积的两倍为 DD,则

α=(xBxP)(yCyP)(xCxP)(yByP)D\alpha = \frac{(x_B - x_P)(y_C - y_P) - (x_C - x_P)(y_B - y_P)}{D}β=(xCxP)(yAyP)(xAxP)(yCyP)D\beta = \frac{(x_C - x_P)(y_A - y_P) - (x_A - x_P)(y_C - y_P)}{D}γ=1αβ\gamma = 1 - \alpha - \beta

8.3.4 工程实现#

// GAMES101 Assignment 2 风格,已整理
std::tuple<float, float, float>
barycentric_2d(float x, float y, const Eigen::Vector3f v[3]) {
float x0 = v[0].x(), y0 = v[0].y();
float x1 = v[1].x(), y1 = v[1].y();
float x2 = v[2].x(), y2 = v[2].y();
float denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2);
float a = ((y1 - y2) * (x - x2) + (x2 - x1) * (y - y2)) / denom;
float b = ((y2 - y0) * (x - x2) + (x0 - x2) * (y - y2)) / denom;
float c = 1.0f - a - b;
return {a, b, c};
}

8.3.5 属性插值的通用公式#

已知三角形三个顶点的属性 fA,fB,fCf_A, f_B, f_C(深度、颜色、法向量、UV 等),三角形内部任一点的属性值:

f(P)=αfA+βfB+γfCf(P) = \alpha f_A + \beta f_B + \gamma f_C

但这只在屏幕空间线性的属性上成立——颜色这种无所谓,深度和 UV 需要透视校正(见 §8.4)。

8.4 透视校正插值#

8.4.1 为什么屏幕空间线性插值是错的#

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

8.4.2 深度倒数线性#

关键数学事实:

1z 在屏幕空间是线性的(即 α,β,γ 的线性组合)\frac{1}{z} \text{ 在屏幕空间是线性的(即 } \alpha, \beta, \gamma \text{ 的线性组合)}

这是因为透视投影本质上是 z=az+b/zz' = az + b / z 形式,其中 1/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}

8.4.3 任意属性的透视校正#

对任意顶点属性 ff(UV、世界空间坐标、法向量),f/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}

所以正确的插值是:

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}}

8.4.4 工程实现#

// alpha, beta, gamma: 屏幕空间重心坐标
// z_a/b/c: 三个顶点在相机空间的深度(正值)
// f_a/b/c: 三个顶点上待插值的属性
template <typename T>
T perspective_correct(float alpha, float beta, float gamma,
float z_a, float z_b, float z_c,
const T& f_a, const T& f_b, const T& f_c) {
float inv_z = alpha / z_a + beta / z_b + gamma / z_c;
T num = alpha * f_a / z_a + beta * f_b / z_b + gamma * f_c / z_c;
return num / inv_z;
}
WARNING

深度值本身的插值比较特殊:在光栅化里,传进深度缓冲的 zNDCz_{NDC} 可以直接用重心坐标线性插值(因为投影矩阵已经让 zNDCz_{NDC} 在屏幕空间线性)。但 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,能批量剔除整块被遮挡的像素。

bool hiz_reject(int tile_x, int tile_y, int level,
float tri_z_near) {
float tile_z_max = hi_z[level][tile_y * tile_stride[level] + tile_x];
return tri_z_near > tile_z_max; // 整个 tile 都在三角形前面 → 剔除
}

8.5.3 2×2 像素 Quad#

片段着色器实际上以 2×2 像素为最小单位执行。这是因为硬件需要相邻像素的属性差来估算 u/x,u/y\partial u/\partial x, \partial u/\partial y 等导数,用于 Mipmap 选级和各向异性过滤。

WARNING

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


九、深度测试与隐藏面消除#

9.1 Z-Buffer 算法#

核心思想:为每个像素维护一个深度值 depth_buffer[x][y],只有新片段的深度更近才写入。

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

优点

  • 与顺序无关:三角形可以任意顺序提交
  • 简单,硬件友好
  • 支持复杂遮挡关系(循环遮挡都没问题)

缺点

  • 需要额外内存(每像素 24/32 位)
  • 半透明物体失效:必须从远到近手动排序
  • 有精度问题(见 §9.2)

9.2 深度精度与 Z-Fighting#

9.2.1 非线性的深度分布#

透视投影后,NDC 深度与相机空间深度的关系:

zNDC=f+nfn2fn(fn)zviewz_{NDC} = \frac{f + n}{f - n} - \frac{2fn}{(f-n) z_{view}}

求导得精度分布:

dzNDCdzview=2fn(fn)zview2\frac{dz_{NDC}}{dz_{view}} = \frac{2fn}{(f-n) z_{view}^2}

这个精度与 zview2z_{view}^2 成反比——远处精度急剧下降。

9.2.2 Z-Fighting 的数学条件#

24 位深度缓冲区的最小可分辨单位:

ΔzNDCmin=122415.96×108\Delta z_{NDC}^{min} = \frac{1}{2^{24} - 1} \approx 5.96 \times 10^{-8}

在相机空间距离 zz 处,两个表面可区分的最小间距:

Δzviewmin=z2(fn)2fnΔzNDCmin\Delta z_{view}^{min} = \frac{z^2(f - n)}{2fn} \cdot \Delta z_{NDC}^{min}

举例:n=0.1,f=1000,z=100n = 0.1, f = 1000, z = 100Deltazviewminapprox0.030Delta z_{view}^{min} approx 0.030 —— 3 cm 以内的两个面会冲突

9.2.3 三个常用缓解方法#

方法一:收紧近远平面比值

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

方法二:Polygon Offset

glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(1.0f, 1.0f); // 把当前绘制的三角形"推远"一点点

常用于阴影贴图——把遮挡物稍微推远避免自遮挡(shadow acne)。

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

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

// 反向 Z 投影矩阵(OpenGL 约定需要 glClipControl 切换裁剪空间)
Eigen::Matrix4f reverse_z_projection(float fov, float aspect,
float n, float f) {
float t = std::tan(fov * 0.5f);
Eigen::Matrix4f P = Eigen::Matrix4f::Zero();
P(0, 0) = 1.0f / (aspect * t);
P(1, 1) = 1.0f / t;
P(2, 2) = n / (f - n); // 近→1, 远→0
P(2, 3) = (f * n) / (f - n);
P(3, 2) = -1.0f;
return P;
}
// 配合:
glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE);
glDepthFunc(GL_GREATER); // 比较方向反过来
glClearDepth(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θidω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_i
  • LoL_o:从 p\mathbf{p} 点沿 ωo\omega_o 方向的出射辐射度
  • LeL_e:自发光
  • frf_r:BRDF,描述入射→出射的反射率
  • LiL_i:入射辐射度
  • costhetai=vecncdotomegaicostheta_i = vec{n} cdot omega_i:入射方向与法向量夹角的余弦

10.1.2 光栅化管线的经验近似#

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

LoLambient+k=1Nlights[Ldiffuse(k)+Lspecular(k)]L_o \approx L_{ambient} + \sum_{k=1}^{N_{lights}} \left[ L_{diffuse}^{(k)} + L_{specular}^{(k)} \right]

这是一种经验模型,精度低但速度极快。物理正确的 PBR 模型(Cook-Torrance、Disney BRDF)见 Part 6。

10.2 Phong 反射模型#

10.2.1 三分量结构#

Itotal=kaIa环境+kdIl(nl)漫反射+ksIl(rv)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{镜面}}

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)}

推导:出射辐射度 LrL_r 在半球上积分应等于入射辐照度 EE 乘以 rhodrho_d

ρ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

其中半球积分 intOmegacostheta,domega=piint_{Omega} costheta, domega = pi。由 BRDF 定义 fr=Lr/Ef_r = L_r / E,得 fr=rhod/pif_r = rho_d / pi

10.2.3 镜面反射与反射向量#

给定入射单位向量 l\mathbf{l} 和法向量 mathbfnmathbf{n},反射向量 r\mathbf{r} 应该:

  1. n\mathbf{n} 夹角等于 l\mathbf{l}n\mathbf{n} 的夹角
  2. mathbflmathbf{l}mathbfnmathbf{n} 共面

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

l=(nl)nl+l(nl)nl\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}

反射只翻转切向分量 mathbflperpmathbf{l}_perp

r=ll=2(nl)nl\mathbf{r} = \mathbf{l}_\parallel - \mathbf{l}_\perp = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l}
WARNING

GLSL 的 reflect(I, N) 约定 I\mathbf{I}从光源射入的方向(即 mathbfl-mathbf{l}),返回 mathbfI2(mathbfNcdotmathbfI)mathbfNmathbf{I} - 2(mathbf{N} cdot mathbf{I})mathbf{N}。符号容易搞错,自己实现时务必验证。

10.2.4 工程实现#

Eigen::Vector3f phong_shade(const Eigen::Vector3f& p, // 世界空间点
const Eigen::Vector3f& n, // 单位法向量
const Eigen::Vector3f& eye_pos,
const Light& light,
const Material& mat) {
Eigen::Vector3f l = (light.position - p).normalized();
Eigen::Vector3f v = (eye_pos - p).normalized();
Eigen::Vector3f r = 2.0f * n.dot(l) * n - l;
// 距离衰减(平方反比定律)
float r2 = (light.position - p).squaredNorm();
Eigen::Vector3f I_eff = light.intensity / r2;
Eigen::Vector3f ambient = mat.ka.cwiseProduct(light.ambient);
Eigen::Vector3f diffuse = mat.kd.cwiseProduct(I_eff)
* std::max(0.0f, n.dot(l));
Eigen::Vector3f specular = mat.ks.cwiseProduct(I_eff)
* std::pow(std::max(0.0f, r.dot(v)), mat.shininess);
return ambient + diffuse + specular;
}

10.3 Blinn-Phong:半角向量#

10.3.1 动机#

Phong 的 rv\mathbf{r} \cdot \mathbf{v}掠射角(光源几乎平行于表面)附近数值不稳定,同时每个像素都要算一次 mathbfrmathbf{r}。Blinn 1977 观察到一个等效替代:

h=l+vl+v\mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\|\mathbf{l} + \mathbf{v}\|}

h\mathbf{h} 是入射与视线方向的半角向量。用 (nh)n(\mathbf{n} \cdot \mathbf{h})^{n'} 替换 (mathbfrcdotmathbfv)n(mathbf{r} cdot mathbf{v})^n

10.3.2 几何等价性#

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

10.3.3 指数修正#

经验公式 nBlinnapprox4nPhongn_{Blinn} approx 4 n_{Phong},得到视觉上近似大小的高光。

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

  • 便宜:省掉 reflect() 的计算
  • 稳定:掠射角下不会突变
  • 可定向光源复用:平行光下 l\mathbf{l}v\mathbf{v} 都是常数,mathbfhmathbf{h} 整帧可以预计算
  • 贴近 PBR:微表面 GGX/Beckmann 模型里,mathbfncdotmathbfhmathbf{n} cdot mathbf{h} 是天然的自变量

10.4 着色频率(Shading Frequency)#

同一个光照模型,在”哪里求值”会大幅影响质量:

着色频率求值位置质量成本典型用途
Flat三角形一次低多边形风格化
Gouraud顶点★★★★老游戏、性能优先
Phong片段★★★★★★现代默认

Flat Shading:用面法向量,整个三角形一种颜色。mathbfnface=frac(vecv1vecv0)times(vecv2vecv0)cdotsmathbf{n}_{face} = frac{(vec{v}_1-vec{v}_0) times (vec{v}_2-vec{v}_0)}{|cdots|}

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

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


十一、纹理映射#

11.1 UV 坐标与采样基础#

11.1.1 UV 坐标系#

纹理是一张 W×HW \times H 的图像。UV 坐标 (u,v)[0,1]2(u, v) \in [0, 1]^2归一化的纹理坐标,与具体分辨率解耦。

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

x=uW0.5,y=vH0.5x = u \cdot W - 0.5, \quad y = v \cdot H - 0.5

11.1.2 最近邻采样#

T(u,v)=texel[round(x),round(y)]T(u, v) = \text{texel}[\text{round}(x), \text{round}(y)]

优点:保真、可见像素细节;缺点:放大时出现明显锯齿 / 阶梯感。

11.1.3 双线性过滤(完整推导)#

(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=lfloorxrfloor,x1=x0+1x_0 = lfloor x rfloor, x_1 = x_0 + 1。权重 fx=xx0,fy=yy0f_x = x - x_0, f_y = y - y_0

先沿 x 插值

C0=(1fx)T00+fxT10C_0 = (1 - f_x) T_{00} + f_x T_{10}C1=(1fx)T01+fxT11C_1 = (1 - f_x) T_{01} + f_x T_{11}

再沿 y 插值

Tbi(u,v)=(1fy)C0+fyC1T_{bi}(u, v) = (1 - f_y) C_0 + f_y C_1

展开式(四个 texel 的双线性组合):

Tbi=(1fx)(1fy)T00+fx(1fy)T10+(1fx)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}
Eigen::Vector3f sample_bilinear(const Texture& tex, float u, float v) {
float x = u * tex.width - 0.5f;
float y = v * tex.height - 0.5f;
int x0 = (int)std::floor(x), y0 = (int)std::floor(y);
int x1 = x0 + 1, y1 = y0 + 1;
float fx = x - x0, fy = y - y0;
auto T00 = tex.fetch_wrap(x0, y0);
auto T10 = tex.fetch_wrap(x1, y0);
auto T01 = tex.fetch_wrap(x0, y1);
auto T11 = tex.fetch_wrap(x1, y1);
auto C0 = (1 - fx) * T00 + fx * T10;
auto C1 = (1 - fx) * T01 + fx * T11;
return (1 - fy) * C0 + fy * C1;
}

11.2 Mipmap 与三线性过滤#

11.2.1 走样问题的来源#

一个屏幕像素覆盖纹理上一大块区域时(远处地面),若只采一个点,就发生欠采样——纹理高频部分出现 Moiré 条纹、闪烁、锯齿。这是奈奎斯特采样定理的直接后果。

11.2.2 Mipmap:预滤波金字塔#

预先构建一系列降采样的纹理:

  • Level 0:原图 W×HW \times H
  • Level 1:W/2timesH/2W/2 times H/2,每个像素取 Level 0 的 2×2 平均
  • …直到 1×11 \times 1

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

void build_mipmaps(Texture& tex) {
int w = tex.width, h = tex.height;
while (w > 1 || h > 1) {
int nw = std::max(1, w / 2);
int nh = std::max(1, h / 2);
Texture next(nw, nh);
for (int y = 0; y < nh; ++y) {
for (int x = 0; x < nw; ++x) {
auto sum = (tex.fetch(2*x, 2*y ) + tex.fetch(2*x+1, 2*y)
+ tex.fetch(2*x, 2*y+1) + tex.fetch(2*x+1, 2*y+1));
next.set(x, y, sum * 0.25f);
}
}
tex.mips.push_back(std::move(next));
w = nw; h = nh;
}
}

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 WLOD=log2L\text{LOD} = \log_2 L

11.2.4 三线性过滤#

LOD 是连续值,分别在 LOD\lfloor \text{LOD} \rfloorLOD\lceil \text{LOD} \rceil 层做双线性,再在两层结果间线性插值——总共 2×4+1=92 \times 4 + 1 = 9 次样本计算。

11.3 纹理寻址模式#

(u,v)(u, v) 超出 [0,1]2[0, 1]^2 时怎么办?

Repeatu=ulfloorurflooru' = u - lfloor u rfloor,地板/墙壁等重复纹理

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

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

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

11.4 法线贴图(Normal Mapping)#

11.4.1 思想#

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

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

11.4.2 切线空间(TBN)#

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

nworld=TBNn~,TBN=(TBN)\mathbf{n}_{world} = \text{TBN} \cdot \tilde{\mathbf{n}}, \quad \text{TBN} = \begin{pmatrix} \mathbf{T} & \mathbf{B} & \mathbf{N} \end{pmatrix}

11.4.3 从 UV 构造 T 与 B#

给三角形三个顶点 v0,v1,v2\vec{v}_0, \vec{v}_1, \vec{v}_2 及其 UV,两条边:

Δv1=v1v0,Δuv1=uv1uv0\Delta \vec{v}_1 = \vec{v}_1 - \vec{v}_0, \quad \Delta \mathbf{uv}_1 = \mathbf{uv}_1 - \mathbf{uv}_0Δv2=v2v0,Δuv2=uv2uv0\Delta \vec{v}_2 = \vec{v}_2 - \vec{v}_0, \quad \Delta \mathbf{uv}_2 = \mathbf{uv}_2 - \mathbf{uv}_0

求解 2×22 \times 2 线性方程组:

(Δu1Δv1Δu2Δv2)(TB)=(Δv1Δv2)\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}

逆矩阵解法:

(TB)=1Δu1Δv2Δu2Δv1(Δv2Δv1Δu2Δu1)(Δv1Δv2)\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}
// 对每个三角形计算,再按顶点累加再归一化得到顶点 T/B
void compute_tbn(const Vertex& v0, const Vertex& v1, const Vertex& v2,
Eigen::Vector3f& T, Eigen::Vector3f& B) {
auto dv1 = v1.pos - v0.pos, dv2 = v2.pos - v0.pos;
auto du1 = v1.uv - v0.uv, du2 = v2.uv - v0.uv;
float det = du1.x() * du2.y() - du2.x() * du1.y();
float inv = (std::abs(det) < 1e-8f) ? 1.0f : 1.0f / det;
T = inv * ( du2.y() * dv1 - du1.y() * dv2);
B = inv * (-du2.x() * dv1 + du1.x() * dv2);
}

Fragment Shader 里:

vec3 tangent_normal = texture(uNormalMap, vTexCoord).rgb * 2.0 - 1.0;
mat3 TBN = mat3(normalize(vTangent),
normalize(vBitangent),
normalize(vNormal));
vec3 N = normalize(TBN * tangent_normal);
// 后续用 N 做光照计算

11.5 环境映射(Environment Mapping)#

让物体”反射”周围场景的技术。核心:把周围环境烘焙成一张纹理,按反射方向采样。

11.5.1 Cubemap#

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

int face; Eigen::Vector2f uv;
Eigen::Vector3f a = r.cwiseAbs();
if (a.x() >= a.y() && a.x() >= a.z()) {
face = (r.x() > 0) ? 0 : 1;
uv = Eigen::Vector2f(-r.z(), -r.y()) / a.x();
if (face == 1) uv.x() = -uv.x();
} else if (a.y() >= a.z()) {
face = (r.y() > 0) ? 2 : 3;
uv = Eigen::Vector2f(r.x(), (r.y() > 0) ? r.z() : -r.z()) / a.y();
} else {
face = (r.z() > 0) ? 4 : 5;
uv = Eigen::Vector2f((r.z() > 0) ? r.x() : -r.x(), -r.y()) / a.z();
}
uv = (uv + Eigen::Vector2f(1, 1)) * 0.5f;

11.5.2 Spherical Mapping#

用单张经纬度投影的 HDR 图像。采样公式:

u=0.5+arctan2(rz,rx)2π,v=0.5arcsin(ry)πu = 0.5 + \frac{\arctan2(r_z, r_x)}{2\pi}, \quad v = 0.5 - \frac{\arcsin(r_y)}{\pi}

优点:单图、适合 HDR 光照;缺点:两极有失真和缝合线问题。

11.5.3 典型应用#

  • Skybox:用视线方向采样,得到天空背景
  • Reflection:用反射向量 r=2(nv)nv\mathbf{r} = 2(\mathbf{n}\cdot\mathbf{v})\mathbf{n} - \mathbf{v} 采样,伪造镜面反射
  • IBL(Image-Based Lighting):预过滤 cubemap 做漫反射/镜面反射环境光照——PBR 的基础,详见 Part 6

小结#

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

  • 几何阶段把顶点变换、法向量修正、TRS 分解做完
  • 裁剪阶段在齐次空间解决视锥外三角形和 w<0w < 0 的陷阱
  • 光栅化核心把 Bresenham、边方程、重心坐标、透视校正这四块拼成了经典的三角形填充
  • 深度测试给出了 Z-Buffer、精度分析和 Reverse-Z
  • 光照用 Phong / Blinn-Phong 给出了一套快速的经验近似,留出渲染方程的严格求解到 Part 4
  • 纹理映射覆盖了采样、过滤、寻址、法线贴图、环境贴图

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

计算机图形学笔记(二):光栅化渲染管线
https://kyc001.github.io/posts/计算机图形学笔记二/
作者
kyc001
发布于
2025-07-22
许可协议
CC BY-NC-SA 4.0