/ Ch09 图形管线

第9章:图形管线 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第9章(p.177-195)。
本章是实时渲染的"施工图"——它告诉你现代 GPU 到底是怎么一步步把 3D 物体画到屏幕上的。
学完本章,你将真正理解光栅化(rasterization)这个图形学的核心技术。
本版插画采用 Guizang 材质插画风格重新绘制。

学习目标

  1. 理解图形管线的 4 个阶段——顶点处理、光栅化、片元处理、混合
  2. 掌握中点算法(Midpoint Algorithm)——画线的核心算法
  3. 理解三角形的栅格化——重心坐标 + Gouraud 插值
  4. 掌握透视正确的插值——为什么直接插值 (u, v) 是错的
  5. 理解裁剪(Clipping)——为什么要在投影变换前后都做
  6. 掌握z-Buffer 算法——隐藏面消除的工业标准

9.1 光栅化(Rasterization)

9.1.1 图形管线的 4 个阶段

核心洞察:所有实时渲染(游戏、UI、AR/VR)都遵循同一个"流水线":

GPU四阶段流水线
图9-1:GPU 四阶段渲染流水线——顶点处理 → 光栅化 → 片元着色 → 输出混合

4 个阶段的核心职责

阶段输入输出主要操作
顶点处理顶点坐标屏幕空间顶点矩阵变换(MVP)
光栅化屏幕空间三角形片元(像素)"哪些像素被三角形覆盖"
片元处理片元处理后片元着色、纹理采样
混合多个片元最终颜色深度测试、Alpha 混合

核心洞察光栅化是管线的"心脏"——它把"连续的三角形"转换为"离散的像素"。这一步是实时渲染和光线追踪的核心区别:光线追踪每个像素独立处理(图像顺序),光栅化每个三角形独立处理(物体顺序)。

9.1.2 画线算法(Line Drawing)

画线 = "在像素网格上画一条尽可能直的直线"

直线的隐式方程

f(x, y) ≡ (y₀ - y₁)x + (x₁ - x₀)y + x₀y₁ - x₁y₀ = 0

核心思想

  • 直线的"一侧" f(x, y) > 0
  • 直线的"另一侧" f(x, y) < 0
  • 直线上的点 f(x, y) = 0

9.1.3 中点算法(Midpoint Algorithm)

中点算法 = "决定下一个像素在 y+1 还是 y"的高效算法

核心思想

当前像素 (x, y)
候选像素 (x+1, y) 和 (x+1, y+1)
中点 = (x+1, y+0.5)

如果 f(中点) < 0 → 直线在 x+1, y 之上 → 选 (x+1, y+1)
否则 → 选 (x+1, y)

算法伪代码

function midpoint_line(x₀, y₀, x₁, y₁):
  y = y₀
  for x in range(x₀, x₁):
    drawpixel(x, y)
    if f(x+1, y+0.5) < 0:
      y = y + 1

核心洞察增量算法只用了"加法"——避免了乘法。这让 Bresenham 算法(1965年!)至今仍在使用。

9.1.4 三角形的栅格化(Triangle Rasterization)

三角形的栅格化 = "哪些像素被三角形覆盖"

三角形光栅化
图9-2:三角形光栅化——三角形覆盖像素网格,通过重心坐标判断像素归属

重心坐标插值

对三角形顶点 p₀, p₁, p₂ 和颜色 c₀, c₁, c₂
重心坐标 (α, β, γ) = 内部点的三角形面积比例

像素颜色 c = α·c₀ + β·c₁ + γ·c₂

Gouraud 插值 = 在三角形内部线性插值颜色(在屏幕空间)。

算法步骤

# 计算包围盒
xmin = floor(x₀); xmax = ceil(x₁)
ymin = floor(y₀); ymax = ceil(y₁)

for y in range(ymin, ymax+1):
    for x in range(xmin, xmax+1):
        # 计算重心坐标
        α = f₁₂(x, y) / f₁₂(x₀, y₀)
        β = f₂₀(x, y) / f₂₀(x₁, y₁)
        γ = f₀₁(x, y) / f₀₁(x₂, y₂)
        if α > 0 and β > 0 and γ > 0:
            c = α·c₀ + β·c₁ + γ·c₂
            drawpixel(x, y, c)

9.1.5 透视正确的插值(Perspective Correct Interpolation)

核心陷阱:直接用屏幕空间的 (α, β, γ) 插值纹理坐标 (u, v) 是错误的

为什么?

3D 空间中等间距的网格 → 屏幕空间中等间距的网格(近大远小)→ 但屏幕空间的"等间距"在 3D 空间不等间距

实际做法:把所有插值量当作"齐次坐标"——插值 u, v, w 也插值,最后再做透视除法。

# 三个顶点的齐次插值
u_s = α(u₀/w₀) + β(u₁/w₁) + γ(u₂/w₂)
v_s = α(v₀/w₀) + β(v₁/w₁) + γ(v₂/w₂)
1_s = α(1/w₀) + β(1/w₁) + γ(1/w₂)

# 透视除法
u = u_s / 1_s
v = v_s / 1_s

核心洞察插值 w 然后再除 = 透视正确。这让远处的纹理保持"看起来对"——这是 3D 渲染不会变形的关键。

9.1.6 裁剪(Clipping)

裁剪 = "把视体外的部分切掉"

透视投影会把 "z = 0"(视点平面)映射到 z = ±∞。如果三角形的一部分在视点之后(z > 0),透视除法会出现除以 0 的情况!

方式优点缺点
视体前裁剪(用 6 个平面)三角形完全在视体中,透视除法安全需要 6 次平面对每个三角形
齐次坐标裁剪(用 6 个 4D 平面)一次齐次除法,4D 平面简单透视除法后才真正安全

9.2 光栅化前后的操作

9.2.1 最简单的 2D 渲染管线

2D 渲染 = 顶点处理(不做事)+ 光栅化 + 片元处理(用顶点颜色)+ 混合(覆盖)

9.2.2 最小 3D 渲染管线

3D 渲染 = 顶点处理(MVP 变换)+ 光栅化 + 片元处理 + 混合

问题:3D 物体的"深度顺序"怎么办?

  • 画家算法:先画远的,再画近的。但有循环遮挡时无法处理!
  • z-Buffer:每个像素存一个深度值,只保留最近的像素

9.2.3 z-Buffer 算法(工业标准)

z-Buffer = "每个像素存一个深度值 + 深度测试"

Z-Buffer深度测试
图9-3:Z-Buffer 深度测试——比较模型A和模型B的深度值,保留最近的可见结果
# 初始化
zbuffer[每个像素] = ∞

# 对每个三角形
for each pixel (x, y) in triangle:
    z = 插值深度(x, y)
    if z < zbuffer[x, y]:
        zbuffer[x, y] = z
        color_buffer[x, y] = 像素颜色

核心洞察z-Buffer 是无敌的——它不依赖任何排序,对任何几何形状都有效。所有现代 GPU 都用 z-Buffer(或它的变种)。

z-Buffer 的精度问题

  • 整数存储比 float 更快,但精度有限
  • 靠近 near plane 的精度高,靠近 far plane 的精度低
  • z-fighting:当两个三角形几乎在同一深度时,可能出现"闪烁"

9.3 完整渲染管线

9.3.1 着色频率对比

面着色(Flat Shading)、顶点插值(Gouraud Shading)和像素着色(Phong Shading)是三种不同粒度的着色方式,效果和性能依次递进。

着色频率对比
图9-4:着色频率对比——面着色(左)、顶点插值(中)、像素着色(右)
着色方式粒度性能效果
面着色每个三角形一个颜色最快有明显棱角
顶点插值每个顶点一个颜色,内部线性插值中等平滑渐变
像素着色每个像素独立计算最慢最逼真

9.3.2 完整流程代码

// 顶点着色器(Vertex Shader)
vec4 p_world = M_model * vec4(position, 1.0);
vec4 p_camera = M_view * p_world;
vec4 p_clip = M_proj * p_camera;

// 裁剪(Clipping)—— 4 个齐次平面
if (!is_inside_view_volume(p_clip)) discard;

// 光栅化(Rasterization)—— 找覆盖的像素
for each pixel (x_s, y_s) in triangle:
    // 透视除法(Perspective Division)
    vec3 p_ndc = p_clip.xyz / p_clip.w;
    
    // 视口变换(Viewport Transform)
    vec2 p_screen = (p_ndc.xy + 1.0) * 0.5 * viewport_size;
    
    // 片元着色器(Fragment Shader)
    vec3 color = compute_lighting(p_world, normal, ...);
    
    // 深度测试(Depth Test)
    if (p_ndc.z < z_buffer[x_s, y_s]):
        z_buffer[x_s, y_s] = p_ndc.z;
        color_buffer[x_s, y_s] = color;

9.3.3 GPU 硬件化

阶段GPU 硬件是否可编程
顶点处理Vertex Shader✅ GLSL/HLSL
光栅化Rasterizer❌ 固定功能
片元处理Fragment Shader✅ GLSL/HLSL
混合Output Merger部分可编程

关键洞察光栅化是固定功能——硬件直接实现。但顶点/片元是可编程的——你可以写自己的着色器。这就是为什么现代游戏能实现各种视觉效果。


9.4 实用考虑

9.4.1 性能优化

1. 早期 z 测试(Early Z):在片元着色器之前做深度测试,节省大量 GPU 计算。

if (z > z_buffer[x, y]) discard;  // 节省片元着色器调用

2. 三角形排序:虽然 z-Buffer 不需要排序,但透明物体需要从后往前画。

3. 视锥体剔除(Frustum Culling):在 CPU 端剔除整个不在视体内的物体,节省 GPU 时间。

9.4.2 抗锯齿(Anti-Aliasing)

问题:三角形边缘的"锯齿"是光栅化的固有问题(像素是离散的)。

技术原理性能
SSAA渲染 4×4 像素,最后平均最慢
MSAA每个像素有多个子样本,只着色一次中等
FXAA / TAA后处理抗锯齿最快

9.4.3 为什么是三角形?

  • 永远平面:3 个点确定一个平面
  • 简单求交:光线-三角形求交是图形学的核心算法
  • 插值简单:重心坐标线性插值
  • GPU 优化:硬件直接支持三角形栅格化

全章总结

讲义核心洞察:图形管线是实时渲染的"施工图"——

  • 4 个阶段:顶点处理 → 光栅化 → 片元处理 → 混合
  • 光栅化的核心:把"连续的三角形"变成"离散的像素"
  • 透视正确的插值:插值齐次坐标后做透视除法
  • 裁剪:在齐次坐标中(4D 平面),而不是世界坐标
  • z-Buffer:工业标准的隐藏面消除算法

关键公式速查

中点算法: f(x+1, y+0.5) 的符号决定下一像素
重心插值: c = αc₀ + βc₁ + γc₂ (α+β+γ=1)
透视正确插值: u = u_s / 1_s, 其中 u_s = α(u₀/w₀) + β(u₁/w₁) + γ(u₂/w₂)
z-Buffer: if (z < z_buffer[x, y]) 写入

下一步:第 10 章将讲解信号处理基础——理解"采样、滤波、混叠"等图形学基础概念。

目录 X
章节 X