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 坐标空间变换链
图形学里最经典的一张图:
每一步的矩阵推导在 Part 1:数学基础 里有完整细节( 的构造分别见 Part 1 §2.2、§3.2、§3.1),这里只记住两个要点:
一、复合矩阵的顺序
矩阵乘法从右往左作用,所以写代码的时候也必须按 P * V * M 构造。
二、透视除法触发点
投影矩阵最后一行通常是 (OpenGL 约定),这会把 放进 。只有在裁剪完成之后,硬件才做 的除法得到 NDC——这个顺序不能反,否则会把视锥外的点除到视锥内,产生透视裁剪 bug。
视口变换将 NDC 映射到屏幕坐标:
其中 是屏幕宽高, 是深度缓冲区映射范围(OpenGL 默认 )。
5.3 可编程着色器
5.3.1 顶点着色器(Vertex Shader)
每个顶点运行一次,职责是把顶点从模型空间送进裁剪空间。
#version 330 corelayout (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 corein 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: UVglEnableVertexAttribArray(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(把平移项混进去会搞坏方向向量)
- ❌ 忘记最后归一化(缩放会改变法向量长度,后续光照计算失败)
- ✅ 纯旋转时,,法向量矩阵退化为原矩阵
6.3 变换矩阵的 TRS 分解
场景:从一个任意仿射矩阵反推原始的 Translation / Rotation / Scale,用于动画插值、编辑器 Gizmo、骨骼系统。
分解定理:任何可逆仿射矩阵可以唯一分解为
算法步骤
- 提取平移:
- 提取线性部分:(上 3×3)
- 计算缩放因子:(每列的模长)
- 检测反射:若 ,把 取负,避免把反射当成旋转
- 提取旋转:
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 表达。真要支持切变需要完整的极分解 ,其中 是正定对称矩阵。工程上通常约定导出器不产生切变。
七、图元装配与裁剪
7.1 图元类型
GPU 只认识三种基本图元:点、线段、三角形。其他几何体(四边形、圆、贝塞尔曲线)都要先转成这三种。
常用的三角形拓扑:
- GL_TRIANGLES:每 3 个顶点一个三角形
- GL_TRIANGLE_STRIP:相邻三角形共享两个顶点, 个顶点定义 个三角形
- GL_TRIANGLE_FAN:所有三角形共享第一个顶点,适合凸多边形
7.2 齐次空间裁剪
为什么要裁剪? 视锥外的三角形不能直接丢掉:横跨视锥边界的三角形必须被切开,否则透视除法会产生错误的屏幕坐标(尤其是经过相机后方的点, 会导致坐标翻转)。
齐次空间视锥(OpenGL 约定)的六个半空间:
直接在齐次坐标 下做裁剪,不需要先除 ——这是整个管线能正确处理跨视锥三角形的关键。
点的裁剪测试:
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;}点到某个裁剪平面的带符号距离(用来求三角形与平面的交点参数):
7.3 Sutherland-Hodgman 多边形裁剪
算法思想:对每一个裁剪平面,把输入多边形切一刀,结果多边形再作为下一个平面的输入。六个平面处理完就得到最终裁剪结果。
单平面裁剪的四种情况(遍历每条边 ):
| 输出 | ||
|---|---|---|
| 内 | 内 | 输出 |
| 内 | 外 | 输出交点 |
| 外 | 内 | 输出交点,再输出 |
| 外 | 外 | 什么都不输出 |
交点的参数计算:设前后两点到平面的带符号距离为 ,则交点参数
WARNING交点不仅位置要插值,所有顶点属性(UV、颜色、法向量)都要按同一个 线性插值——否则裁剪出来的片段会出现纹理错位。
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,得到裁剪后的凸多边形。若顶点数 ,再做三角形扇化(Triangle Fan)即可。
八、光栅化核心
8.1 Bresenham 画线算法(规范推导位置)
8.1.1 问题
给定两个整数端点 和 ,在像素网格上画出一条近似直线,要求:
- 每列(或每行)只点亮一个像素,避免断线
- 只用整数加减和比较,没有除法和浮点
8.1.2 从直线方程出发
设 ,,斜率 。直线方程写成隐式:
- :点在直线下方
- :点在直线上方
- :点在直线上
8.1.3 中点判据
假设已经点亮了 ,下一个像素只能在 或 二选一。取两者的中点 ,代入 :
- :中点在直线上方 → 直线更靠下 → 选
- :中点在直线下方 → 直线更靠上 → 选
8.1.4 增量化(关键一步)
直接算 还有浮点。相邻两步的判据差值是纯整数:
情况 A:,选
情况 B:,选
初值:。为了消去 ,两边同乘 2,得到全整数版本:
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; }}TIPBresenham 的思想——用整数增量替代浮点计算——在后面的圆/椭圆画法、DDA 光栅化、各向异性过滤里都会反复出现。
8.2 三角形光栅化
8.2.1 边方程(Edge Function)
三角形 的有向边 对应一条直线,其边函数
几何意义: 等于 2 × 三角形 的有向面积(逆时针为正)。
点 在三角形内的判据(三角形逆时针):
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 规则
相邻三角形共享边时,如果两边都严格用 ,会导致共享像素被画两次;用 又会漏画。DirectX/OpenGL 约定Top-Left Fill Rule:
- 只有左边(从下到上的边)和顶边(水平、从左到右的边)上的像素算作”内部”
- 其他边上的像素算作”外部”
这样任何一个像素只会被恰好一个三角形覆盖,避免闪烁与双重着色。
8.3 重心坐标系(规范推导位置)
NOTE本节是全系列重心坐标的规范位置。Part 1 §1.1 只做了简要引用,详细推导、透视校正都在这里。
8.3.1 定义
对三角形 内任意一点 ,存在唯一的一组非负数 满足:
这就是 的重心坐标。三个权重分别对应三个顶点的”影响力”。
8.3.2 面积比公式(完整推导)
核心引理: 等于 关于对顶边所切分出的子三角形面积比。
证明:固定 ,将 代入 :
用 叉乘两边:
两边取模:
同理:
8.3.3 二维情形的标准公式
二维下用 的叉积求面积:。记三角形总面积的两倍为 ,则
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 属性插值的通用公式
已知三角形三个顶点的属性 (深度、颜色、法向量、UV 等),三角形内部任一点的属性值:
但这只在屏幕空间线性的属性上成立——颜色这种无所谓,深度和 UV 需要透视校正(见 §8.4)。
8.4 透视校正插值
8.4.1 为什么屏幕空间线性插值是错的
透视投影后,世界空间里等距的三个点,在屏幕上不等距。直接在屏幕空间线性插值 UV,结果会在倾斜面上明显”挤”在一起(远处纹理被拉长)。
8.4.2 深度倒数线性
关键数学事实:
这是因为透视投影本质上是 形式,其中 对应于投影空间中的线性量。因此:
8.4.3 任意属性的透视校正
对任意顶点属性 (UV、世界空间坐标、法向量), 也在屏幕空间线性:
所以正确的插值是:
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深度值本身的插值比较特殊:在光栅化里,传进深度缓冲的 可以直接用重心坐标线性插值(因为投影矩阵已经让 在屏幕空间线性)。但 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 像素为最小单位执行。这是因为硬件需要相邻像素的属性差来估算 等导数,用于 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 深度与相机空间深度的关系:
求导得精度分布:
这个精度与 成反比——远处精度急剧下降。
9.2.2 Z-Fighting 的数学条件
24 位深度缓冲区的最小可分辨单位:
在相机空间距离 处,两个表面可区分的最小间距:
举例:, —— 3 cm 以内的两个面会冲突。
9.2.3 三个常用缓解方法
方法一:收紧近远平面比值
f/n 越大、精度越差。工程经验: 比较安全, 可用, 极易出问题。
方法二:Polygon Offset
glEnable(GL_POLYGON_OFFSET_FILL);glPolygonOffset(1.0f, 1.0f); // 把当前绘制的三角形"推远"一点点常用于阴影贴图——把遮挡物稍微推远避免自遮挡(shadow acne)。
方法三:反向 Z(Reverse-Z)
把近平面映射到 ,远平面映射到 ,配合浮点深度缓冲。利用 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 提出的渲染方程描述了任何表面点的出射辐射度:
- :从 点沿 方向的出射辐射度
- :自发光
- :BRDF,描述入射→出射的反射率
- :入射辐射度
- :入射方向与法向量夹角的余弦
10.1.2 光栅化管线的经验近似
光栅化管线没能力求解这个积分——每个片段只能访问自己的局部信息。于是Phong 模型把积分替换为对少量光源的求和:
这是一种经验模型,精度低但速度极快。物理正确的 PBR 模型(Cook-Torrance、Disney BRDF)见 Part 6。
10.2 Phong 反射模型
10.2.1 三分量结构
10.2.2 漫反射:Lambert 定律
物理假设:理想粗糙表面各方向反射均匀。能量守恒给出 BRDF:
推导:出射辐射度 在半球上积分应等于入射辐照度 乘以 :
其中半球积分 。由 BRDF 定义 ,得 。
10.2.3 镜面反射与反射向量
给定入射单位向量 和法向量 ,反射向量 应该:
- 与 夹角等于 与 的夹角
- 与 、 共面
推导:把 分解为平行 和垂直 两个分量:
反射只翻转切向分量 :
WARNINGGLSL 的
reflect(I, 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 的 在掠射角(光源几乎平行于表面)附近数值不稳定,同时每个像素都要算一次 。Blinn 1977 观察到一个等效替代:
是入射与视线方向的半角向量。用 替换 。
10.3.2 几何等价性
微表面理论视角:只有法向量恰好等于 的微面才把光线从 反射到 。所以 测量的是”当前宏观法向量”与”完美反射所需微表面法向量”的夹角,物理意义比 更直接。
10.3.3 指数修正
经验公式 ,得到视觉上近似大小的高光。
10.3.4 为什么大家都用 Blinn-Phong
- 便宜:省掉
reflect()的计算 - 稳定:掠射角下不会突变
- 可定向光源复用:平行光下 和 都是常数, 整帧可以预计算
- 贴近 PBR:微表面 GGX/Beckmann 模型里, 是天然的自变量
10.4 着色频率(Shading Frequency)
同一个光照模型,在”哪里求值”会大幅影响质量:
| 着色频率 | 求值位置 | 质量 | 成本 | 典型用途 |
|---|---|---|---|---|
| Flat | 三角形一次 | ★ | ★ | 低多边形风格化 |
| Gouraud | 顶点 | ★★ | ★★ | 老游戏、性能优先 |
| Phong | 片段 | ★★★ | ★★★ | 现代默认 |
Flat Shading:用面法向量,整个三角形一种颜色。
Gouraud Shading:顶点法向量(相邻面法向量加权平均),顶点着色后用重心坐标插值颜色。问题:镜面高光如果不落在顶点上就完全丢失。
Phong Shading:顶点存法向量,光栅化时插值法向量,在片段着色器里逐像素计算光照。现代管线默认方式。注意:Phong Shading 和 Phong Lighting Model 是两回事,前者是”在哪算”,后者是”怎么算”。
十一、纹理映射
11.1 UV 坐标与采样基础
11.1.1 UV 坐标系
纹理是一张 的图像。UV 坐标 是归一化的纹理坐标,与具体分辨率解耦。
连续纹理坐标到像素索引的映射(采样点在像素中心):
11.1.2 最近邻采样
优点:保真、可见像素细节;缺点:放大时出现明显锯齿 / 阶梯感。
11.1.3 双线性过滤(完整推导)
在 周围取四个整数邻居 ,其中 。权重 。
先沿 x 插值:
再沿 y 插值:
展开式(四个 texel 的双线性组合):
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:原图
- Level 1:,每个像素取 Level 0 的 2×2 平均
- …直到
总存储开销:。
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 在屏幕上变化多快:
11.2.4 三线性过滤
LOD 是连续值,分别在 和 层做双线性,再在两层结果间线性插值——总共 次样本计算。
11.3 纹理寻址模式
当 超出 时怎么办?
Repeat:,地板/墙壁等重复纹理
Mirror:镜像重复, 每加 1 就翻一次方向
Clamp to Edge:,天空盒接缝必备
Clamp to Border:超出范围用预设边界色
11.4 法线贴图(Normal Mapping)
11.4.1 思想
在低多边形模型上用纹理欺骗法向量,造出高频凹凸细节的错觉。一张 RGB 纹理里每个像素存一个切线空间下的法向量 。
从 的 RGB 映射到法向量:
11.4.2 切线空间(TBN)
每个顶点存 (tangent)、(bitangent)、(normal)三个正交单位向量。TBN 矩阵把切线空间向量变到世界空间:
11.4.3 从 UV 构造 T 与 B
给三角形三个顶点 及其 UV,两条边:
求解 线性方程组:
逆矩阵解法:
// 对每个三角形计算,再按顶点累加再归一化得到顶点 T/Bvoid 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
六张图片:分别对应 六个方向。采样时根据反射方向 分量的最大绝对值选择哪张面:
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 图像。采样公式:
优点:单图、适合 HDR 光照;缺点:两极有失真和缝合线问题。
11.5.3 典型应用
- Skybox:用视线方向采样,得到天空背景
- Reflection:用反射向量 采样,伪造镜面反射
- IBL(Image-Based Lighting):预过滤 cubemap 做漫反射/镜面反射环境光照——PBR 的基础,详见 Part 6
小结
本部分把光栅化管线从顶点输入到最终像素完整走了一遍:
- 几何阶段把顶点变换、法向量修正、TRS 分解做完
- 裁剪阶段在齐次空间解决视锥外三角形和 的陷阱
- 光栅化核心把 Bresenham、边方程、重心坐标、透视校正这四块拼成了经典的三角形填充
- 深度测试给出了 Z-Buffer、精度分析和 Reverse-Z
- 光照用 Phong / Blinn-Phong 给出了一套快速的经验近似,留出渲染方程的严格求解到 Part 4
- 纹理映射覆盖了采样、过滤、寻址、法线贴图、环境贴图
从 GAMES101 Assignment 1/2/3 到工业级管线,这一章提到的每一行代码都是绕不开的基建。Part 3 起我们进入几何建模,会看到曲线、曲面、网格这些在光栅化之前就准备好的数据是怎么来的。