渲染
片元与像素处理
直觉问题:GPU 如何”画”出每个像素?
想象你画水彩画:先用铅笔勾勒轮廓,然后在轮廓内填充颜色。GPU 渲染也类似:先确定”轮廓”(三角形占据哪些像素),然后”填充颜色”(计算每个像素的颜色值)。
但 GPU 面临更复杂的问题:
- 三角形可能只覆盖像素的一部分,边缘该画什么颜色?
- 多个三角形重叠,哪个应该显示在最前面?
- 如何防止”锯齿”,让边缘看起来平滑?
这就涉及 片元与像素处理——GPU 渲染管线的”光栅化与测试阶段”,负责将抽象的图元转换为屏幕上的最终像素。
NOTE
本篇聚焦于 光栅化到帧缓冲 的过程,包括 Fragment Shader、深度测试、模板测试、多重采样等关键环节。
核心概念:从图元到像素的转换
光栅化(Rasterization)
光栅化是 GPU 将连续的几何图元(三角形、线段、点)转换为离散的像素(片元)的过程。每个片元包含:
- 屏幕坐标(Screen Space,)
- 深度值(Depth,)
- 插值属性(Interpolated Attributes,如纹理坐标、颜色、法线)
光栅化流程:
- 三角形设置(Triangle Setup):计算三角形边方程、平面方程
- 三角形遍历(Triangle Traversal):扫描三角形覆盖的像素
- 片元生成(Fragment Generation):为每个覆盖的像素生成片元
- 属性插值(Attribute Interpolation):插值计算逐片元属性
flowchart LR
A[顶点变换<br>Vertex Shader] --> B[图元装配<br>Primitive Assembly]
B --> C[光栅化<br>Rasterization]
C --> D[片元生成<br>Fragment Generation]
D --> E[属性插值<br>Interpolation]
E --> F[片元着色<br>Fragment Shader]
F --> G[深度测试<br>Depth Test]
G --> H[模板测试<br>Stencil Test]
H --> I[混合<br>Blending]
I --> J[写入帧缓冲<br>Frame Buffer]
style C fill:#e3f2fd,stroke:#2196f3,stroke-width:3px
style F fill:#bbdefb,stroke:#2196f3
style G fill:#90caf9,stroke:#2196f3
style H fill:#64b5f6,stroke:#2196f3
style I fill:#42a5f5,stroke:#2196f3
TIP
光栅化是 不可编程 的阶段,GPU 硬件自动完成。开发者只能控制其输入(顶点数据)和输出(片元属性)。
片元着色器(Fragment Shader)
Fragment Shader 是管线中第二个可编程阶段,它对每个片元执行一次,主要职责是:
- 逐片元计算:计算片元的颜色、深度、透明度等
- 纹理采样:读取纹理数据,计算最终颜色
- 光照计算:计算逐片元光照(Phong/Blinn-Phong/PBR)
- 深度修改:可选地修改片元深度值(
gl_FragDepth/@builtin(frag_depth))
WARNING
Fragment Shader 的执行次数 = 三角形覆盖的片元数量。一个 100×100 像素的三角形可能产生 10,000 个片元,每个片元都会调用 Fragment Shader 一次。
深度测试(Depth Test)
深度测试用于解决 遮挡关系问题,确保离相机近的物体遮挡远处的物体。GPU 使用 深度缓冲区(Depth Buffer,也叫 Z-Buffer)存储每个像素的深度值( 值)。
深度测试流程:
- 片元生成后,GPU 查询深度缓冲区中对应位置的当前深度值
- 比较 和 (通常是”小于”或”小于等于”)
- 如果通过测试,更新深度缓冲区;否则,丢弃片元
graph TD
A[片元生成] --> B{深度测试}
B -->|通过| C[更新深度缓冲区]
B -->|失败| D[丢弃片元]
C --> E[继续后续流程]
D --> E
style B fill:#e3f2fd,stroke:#2196f3,stroke-width:3px
style C fill:#bbdefb,stroke:#2196f3
style D fill:#ffcdd2,stroke:#f44336
NOTE
- WebGL/GLSL 的深度测试通过
gl.enable(gl.DEPTH_TEST)启用,通过gl.depthFunc(gl.LEQUAL)设置比较函数。 - WebGPU/WGSL 的深度测试通过
depthStencil状态配置。
模板测试(Stencil Test)
模板测试用于实现 复杂的渲染效果,如镜子反射、阴影体积、UI 遮罩等。GPU 使用 模板缓冲区(Stencil Buffer)存储每个像素的模板值(通常为 8 位整数)。
模板测试流程:
- 片元生成后,GPU 查询模板缓冲区中对应位置的当前模板值
- 比较 和 (由开发者指定比较函数)
- 如果通过测试,根据模板操作更新模板缓冲区
stateDiagram-v2
[*] --> 模板测试
模板测试 --> 通过: 条件满足
模板测试 --> 失败: 条件不满足
通过 --> 深度测试
失败 --> 深度测试: 若配置继续
失败 --> [*]: 丢弃
深度测试 --> 通过: 深度合格
深度测试 --> 失败: 深度不合格
通过 --> 更新模板
失败 --> 更新模板: 若配置继续
失败 --> [*]: 丢弃
更新模板 --> 混合
混合 --> 写入帧缓冲
写入帧缓冲 --> [*]
graph TD
A[片元生成] --> B{模板测试}
B -->|通过| C{深度测试}
B -->|失败| D[根据失败操作更新模板]
C -->|通过| E[根据通过操作更新模板]
C -->|失败| F[根据深度失败操作更新模板]
D --> G[后续流程]
E --> G
F --> G
style B fill:#e3f2fd,stroke:#2196f3,stroke-width:3px
style D fill:#ffcdd2,stroke:#f44336
style E fill:#c8e6c9,stroke:#4caf50
style F fill:#ffe0b2,stroke:#ff9800
TIP
- 模板测试在深度测试 之前 执行,可以提前丢弃不符合条件的片元。
- 常用场景:渲染平面镜的反射(只在镜面区域渲染反射物体)、渲染阴影体积、实现圆形/圆角 UI 控件。
混合(Blending)
混合用于实现 透明效果,将片元的颜色与帧缓冲区中已有的颜色进行混合。混合公式:
其中:
- :片元的源颜色(来自 Fragment Shader)
- :帧缓冲区中的目标颜色(已渲染的像素)
- :源混合因子(如
SRC_ALPHA) - :目标混合因子(如
ONE_MINUS_SRC_ALPHA)
常用混合模式:
- 标准透明混合:
SRC_ALPHA+ONE_MINUS_SRC_ALPHA(glBlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)) - 加法混合:
SRC_ALPHA+ONE(glBlendFunc(gl.SRC_ALPHA, gl.ONE),用于发光效果) - 乘法混合:
ZERO+SRC_COLOR(glBlendFunc(gl.ZERO, gl.SRC_COLOR),用于遮罩)
WARNING
- 混合会 禁用深度写入(
gl.depthMask(gl.FALSE)),否则透明物体的深度值会遮挡后续的不透明物体。 - 渲染透明物体时,通常需要 从后往前渲染(先渲染离相机远的透明物体,再渲染近的)。
多重采样(Multisample Anti-Aliasing,MSAA)
多重采样是一种 抗锯齿技术,通过在每个像素内采样多个子像素来消除锯齿。MSAA 的工作原理:
- 子像素采样:每个像素被划分为多个子像素(如 4× MSAA = 16 个子像素)
- 覆盖测试:每个子像素独立进行覆盖测试
- 颜色计算:片元颜色只计算一次,然后根据子像素覆盖比例混合
- Resolve:最终渲染时,将子像素合并为一个像素颜色
graph TD
A[普通渲染<br>单像素] --> B[4× MSAA<br>16 子像素]
B --> C[每个子像素独立覆盖测试]
C --> D[片元颜色计算一次]
D --> E[根据覆盖比例混合]
E --> F[Resolve 为单像素]
style B fill:#e3f2fd,stroke:#2196f3,stroke-width:3px
style F fill:#bbdefb,stroke:#2196f3
TIP
- MSAA 只解决 几何边缘锯齿,不解决纹理锯齿(需要 Mipmapping)。
- MSAA 的性能开销与采样数成正比(4× MSAA ≈ 4× 性能开销)。
- WebGPU 支持 MSAA,但需要额外的 Render Pass 配置。
Early-Z 和 Hi-Z
Early-Z 是一种优化技术,在 Fragment Shader 执行 之前 进行深度测试,提前丢弃被遮挡的片元。
graph TD
A[片元生成] --> B[Early-Z 测试]
B -->|通过| C[Fragment Shader]
B -->|失败| D[丢弃片元]
C --> E[深度测试]
E -->|通过| F[更新深度缓冲区]
E -->|失败| G[丢弃片元]
style B fill:#e3f2fd,stroke:#2196f3,stroke-width:3px
style D fill:#ffcdd2,stroke:#f44336
Hi-Z(Hierarchical Z)是 Early-Z 的改进版本,使用 层次化的深度缓冲区来加速深度测试:
- 将屏幕划分为多个层级,每个层级存储对应区域的最小/最大深度值
- 深度测试时先检查粗层级,如果整个区域都遮挡,则直接丢弃
- 递归检查细层级,直到确定最终的深度结果
WARNING
- Early-Z 假设 Fragment Shader 不会修改深度值(
gl_FragDepth/@builtin(frag_depth))。 - 如果 Fragment Shader 修改了深度值,Early-Z 可能会失效,导致性能下降。
- 透明物体(禁用深度写入)会禁用 Early-Z。
数学原理:光栅化与插值
三角形光栅化
GPU 使用 边方程(Edge Function)进行三角形光栅化。给定三角形的三个顶点 (屏幕坐标),边方程定义为:
点 在三角形内部的充要条件是:
GPU 在硬件上高效计算边方程,遍历三角形覆盖的像素。
属性插值
片元的属性(如纹理坐标、颜色、法线)是通过对顶点属性进行 插值 得到的。GPU 支持 三种插值模式:
-
透视插值(Perspective Interpolation,默认):考虑透视除法的影响 其中 是顶点深度的倒数。
-
线性插值(Linear Interpolation):不考虑透视,适用于 2D 渲染 其中 是重心坐标。
-
重心插值(Centroid Interpolation):只在片元覆盖的子像素内插值,用于 MSAA
NOTE
- GLSL 使用
smooth(透视插值,默认)、flat(无插值,直接使用顶点属性)、noperspective(线性插值)修饰符。 - WGSL 使用
@interpolate(perspective)(默认)、@interpolate(linear)、@interpolate(flat)修饰符。
重心坐标(Barycentric Coordinates)
重心坐标是描述点在三角形内位置的参数。给定三角形 和点 ,重心坐标 满足:
重心坐标可用于:
- 属性插值:
- 点在三角形内判断:
- 重心着色:根据重心坐标计算颜色
TIP
- GPU 硬件自动计算重心坐标,开发者无需手动计算。
- 如果需要手动计算,可以使用边方程的倒数作为重心坐标。
GLSL vs WGSL 代码对照
基础 Fragment Shader 对比
GLSL (WebGL 2.0):
#version 300 es
precision mediump float;
in vec3 v_normal;
in vec2 v_texcoord;
uniform sampler2D u_texture;
uniform vec3 u_lightDirection;
uniform vec3 u_lightColor;
out vec4 fragColor;
void main() {
// 纹理采样
vec4 texColor = texture(u_texture, v_texcoord);
// 漫反射光照(Lambert)
float diffuse = max(dot(normalize(v_normal), normalize(u_lightDirection)), 0.0);
vec3 lighting = texColor.rgb * u_lightColor * diffuse;
// 输出最终颜色
fragColor = vec4(lighting, texColor.a);
}
WGSL (WebGPU):
struct Uniforms {
lightDirection: vec3<f32>,
lightColor: vec3<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var texSampler: sampler;
@group(0) @binding(2) var texture: texture_2d<f32>;
struct FragmentInput {
@location(0) normal: vec3<f32>,
@location(1) texcoord: vec2<f32>,
}
@fragment
fn fragment_shader(input: FragmentInput) -> @location(0) vec4<f32> {
// 纹理采样
let texColor = textureSample(texture, texSampler, input.texcoord);
// 漫反射光照(Lambert)
let normal = normalize(input.normal);
let lightDir = normalize(uniforms.lightDirection);
let diffuse = max(dot(normal, lightDir), 0.0);
let lighting = texColor.rgb * uniforms.lightColor * diffuse;
// 输出最终颜色
return vec4<f32>(lighting, texColor.a);
}
语法差异对比表
| 概念 | GLSL | WGSL | 差异说明 |
|---|---|---|---|
| 输出变量 | out vec4 fragColor | @location(0) vec4<f32> | WGSL 使用返回值 + Location |
| 纹理采样 | texture(sampler2D, uv) | textureSample(texture, sampler, uv) | WGSL 分离纹理和采样器 |
| Uniform 绑定 | uniform sampler2D u_texture | @group(0) @binding(N) | WGSL 使用 Binding Group |
| 精度修饰符 | precision mediump float | 无 | WGSL 默认 32 位浮点 |
| 插值修饰符 | smooth in vec3 v_normal | @location(0) normal: vec3<f32> | WGSL 默认透视插值 |
| 深度修改 | gl_FragDepth = 0.5 | @builtin(frag_depth) | WGSL 需要显式声明 |
| 丢弃片元 | discard | discard | 语法相同 |
实例:深度测试 + Early-Z
GLSL 启用深度测试(JavaScript):
// 启用深度测试
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL); // 深度小于等于时通过测试
// 清除深度缓冲区
gl.clearDepth(1.0);
gl.clear(gl.DEPTH_BUFFER_BIT);
// 渲染场景
// ...
WGSL 配置深度测试(Rust/WebGPU):
// 创建深度纹理
let depth_texture = device.create_texture(&wgpu::TextureDescriptor {
size: wgpu::Extent3d {
width: 1024,
height: 768,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth24PlusStencil8,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
label: Some("Depth Texture"),
});
// 配置深度测试
let depth_stencil = wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual, // 深度小于等于时通过测试
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
};
// 渲染通道
let render_pass_descriptor = wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_texture.create_view(&wgpu::TextureViewDescriptor::default()),
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0), // 清除深度为 1.0
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
};
实例:透明渲染 + 混合
GLSL 透明渲染(JavaScript + Shader):
// 启用混合
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 禁用深度写入(透明物体)
gl.depthMask(gl.FALSE);
// 渲染透明物体
// ...
// 恢复深度写入
gl.depthMask(gl.TRUE);
// Fragment Shader(计算透明度)
precision mediump float;
in vec2 v_texcoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
vec4 texColor = texture(u_texture, v_texcoord);
// 如果完全透明,丢弃片元(优化混合)
if (texColor.a < 0.01) {
discard;
}
fragColor = texColor;
}
WGSL 透明渲染(Rust/WebGPU):
// 配置混合
let blend = Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
});
// 禁用深度写入
let depth_write_enabled = false;
// Fragment Shader(计算透明度)
@fragment
fn fragment_shader(input: FragmentInput) -> @location(0) vec4<f32> {
let texColor = textureSample(texture, texSampler, input.texcoord);
// 如果完全透明,丢弃片元(优化混合)
if (texColor.a < 0.01) {
discard;
}
return texColor;
}
常见误区与陷阱
1. 混合与深度测试冲突
误区:透明物体可以和不透明物体一样启用深度写入和深度测试。
正确理解:
- 透明物体必须 禁用深度写入(
gl.depthMask(gl.FALSE)/depth_write_enabled = false),否则会遮挡后续的透明物体。 - 透明物体通常需要 从后往前渲染(先渲染离相机远的透明物体,再渲染近的),否则混合顺序错误。
正确流程:
- 渲染所有不透明物体(启用深度写入和测试)
- 按深度排序透明物体(从后往前)
- 渲染透明物体(禁用深度写入,启用测试和混合)
错误示例:
// 错误:透明物体启用深度写入
gl.depthMask(gl.TRUE); // 错误!应该是 gl.FALSE
gl.enable(gl.BLEND);
// 渲染透明物体...
2. Early-Z 失效
误区:Early-Z 总是有效,可以优化所有场景。
正确理解:
- Early-Z 假设 Fragment Shader 不会修改深度值(
gl_FragDepth/@builtin(frag_depth))。 - 如果 Fragment Shader 修改了深度值,Early-Z 会 失效,导致性能下降。
- 透明物体(禁用深度写入)会 禁用 Early-Z。
- Alpha Test(
discard)可能会影响 Early-Z 的准确性。
优化建议:
- 尽量避免在 Fragment Shader 中写入
gl_FragDepth/@builtin(frag_depth)。 - 如果必须修改深度值,考虑使用 Z-Prepass(先渲染场景到深度缓冲区,再渲染到颜色缓冲区)。
3. 模板测试与深度测试顺序错误
误区:深度测试在模板测试之前执行。
正确理解:
- 模板测试在深度测试 之前 执行,可以提前丢弃不符合条件的片元。
- 深度测试的通过/失败会影响模板测试的操作(模板操作分为”通过”、“深度测试失败”、“全部失败”三种)。
正确顺序:
- 模板测试
- 深度测试
- 混合
错误示例:
// 错误:假设深度测试在模板测试之前
gl.enable(gl.STENCIL_TEST);
gl.enable(gl.DEPTH_TEST);
// 模板测试会在深度测试之前执行
4. MSAA 性能陷阱
误区:MSAA 是”免费”的抗锯齿技术,可以随意开启。
正确理解:
- MSAA 的性能开销与采样数成正比(4× MSAA ≈ 4× 性能开销)。
- MSAA 只解决 几何边缘锯齿,不解决纹理锯齿(需要 Mipmapping)。
- MSAA 会增加内存占用(深度缓冲区、模板缓冲区都需要 MSAA)。
优化建议:
- 对于移动设备,考虑使用 FXAA(后处理抗锯齿)代替 MSAA。
- 如果必须使用 MSAA,选择 2× 或 4× MSAA,避免 8× 或更高。
- 对于 UI 渲染,考虑使用 SDF(Signed Distance Field) 字体代替 MSAA。
5. 丢弃片元与深度测试冲突
误区:discard 可以替代深度测试,手动实现遮挡剔除。
正确理解:
discard会在 Fragment Shader 中 终止 片元处理,不会更新深度缓冲区。discard会 禁用 Early-Z,导致性能下降(GPU 无法提前进行深度测试)。discard应该用于 Alpha Test(透明度阈值裁剪),而不是替代深度测试。
正确用法:
// 正确:Alpha Test(透明度小于阈值时丢弃)
vec4 texColor = texture(u_texture, v_texcoord);
if (texColor.a < 0.5) {
discard; // 正确:Alpha Test
}
错误用法:
// 错误:用 discard 替代深度测试
if (depth_value > current_depth) {
discard; // 错误!应该使用深度测试
}
延伸阅读与自测
权威资料索引
- 光栅化原理:Rasterization: A Practical Implementation (Pharr & Humphreys)
- 深度测试:Depth Testing (LearnOpenGL)
- 模板测试:Stencil Testing (LearnOpenGL)
- MSAA 原理:Multisample Anti-Aliasing (NVIDIA)
- Early-Z:Early Z-Test (GPUOpen)
NOTE
以上资料通过 WebSearch 于 2026-01-18 检索。
自测题
-
基础概念:光栅化和 Fragment Shader 的区别是什么?GPU 在光栅化阶段和 Fragment Shader 阶段分别做什么?
-
数学推导:给定三角形的三个顶点 ,请计算点 的重心坐标 。
-
实践应用:在 Fragment Shader 中实现一个”距离雾”效果:根据片元与相机的距离,计算雾的颜色和密度,并与片元颜色混合。请写出 GLSL 和 WGSL 的实现代码。
-
性能优化:假设你需要渲染 10,000 个半透明的粒子,如何优化混合和深度测试?提示:考虑粒子排序、Early-Z、Alpha Test 等。
-
调试技巧:你的透明物体渲染结果不正确(顺序错误或混合错误),请列出至少 3 个可能的调试步骤。提示:检查深度写入、混合函数、渲染顺序。
参考答案(简要)
-
光栅化 vs Fragment Shader:
- 光栅化:不可编程,GPU 硬件自动将三角形转换为片元,计算片元的屏幕坐标和深度值。
- Fragment Shader:可编程,对每个片元计算颜色、纹理采样、光照等。
-
重心坐标计算:
- 边方程:
- 重心坐标:(点 在 边上)
-
距离雾效果:
- GLSL:
float fogFactor = 1.0 - exp(-distance * fogDensity); vec3 finalColor = mix(fogColor, fragColor.rgb, fogFactor); - WGSL:
let fogFactor = 1.0 - exp(-distance * fogDensity); let finalColor = mix(fogColor, fragColor.rgb, fogFactor);
- GLSL:
-
粒子优化:
- 从后往前排序粒子(按深度降序)
- 使用
discard丢弃完全透明的粒子(Alpha Test) - 考虑使用 加法混合(
ONE+ONE)代替标准透明混合 - 如果粒子很小,考虑使用 点精灵(Point Sprite)代替三角形
-
调试步骤:
- 检查是否禁用了深度写入(
gl.depthMask(gl.FALSE)/depth_write_enabled = false) - 检查混合函数是否正确(
SRC_ALPHA+ONE_MINUS_SRC_ALPHA) - 使用调试工具(RenderDoc/Spector.js)截取渲染过程,检查深度缓冲区和颜色缓冲区
- 尝试禁用混合,查看深度排序是否正确
- 检查是否禁用了深度写入(
下一篇:05 · 2D 渲染基础 —— 2D 变换矩阵、Sprite 渲染、字体渲染(SDF/MSDF)、瓦片地图渲染、Canvas2D vs WebGL 2D。