渲染
后处理技术
直觉问题:为什么游戏中的光晕如此真实?
Q1: 为什么电影中强烈的阳光会产生光晕效果?GPU 如何模拟?
Q2: 为什么相机快速移动时会有拖影?游戏如何模拟这种效果?
核心概念白话讲
后处理流程
后处理是在场景渲染完成后对最终图像进行的处理。
graph LR
A[场景渲染<br/>HDR 纹理] --> B[提取亮部<br/>Bloom阈值]
B --> C[模糊处理<br/>高斯模糊]
C --> D[混合叠加<br/>Bloom混合]
D --> E[色调映射<br/>HDR→LDR]
E --> F[最终输出<br/>显示]
高斯模糊
高斯分布:
分离卷积:将 2D 卷积拆分为两次 1D 卷积
- 水平 Pass:沿 X 轴模糊
- 垂直 Pass:沿 Y 轴模糊
- 性能提升:O(N²) → O(2N)
Bloom(光晕)
原理:提取场景亮部,模糊后叠加到原始图像
步骤:
- 阈值提取:
brightness > threshold - 多级模糊:降采样 + 高斯模糊
- 叠加混合:
final = original + blurred
TIP
- Unreal Engine 使用 Kawase 模糊替代高斯模糊,性能更高
Vignette(暗角)
原理:模拟镜头边缘光照衰减
Chromatic Aberration(色差)
原理:不同波长的光折射率不同,导致 RGB 分离
SSAO(Screen-Space Ambient Occlusion)
原理:在屏幕空间采样周围像素,计算遮蔽比例
步骤:
- 采样当前像素周围 N 个点
- 比较深度,计算遮挡比例
- 将 AO 值应用到环境光
HBAO(Horizon-Based AO):考虑地平线遮挡,效果更准确
DOF(Depth of Field,景深)
原理:模拟相机焦距,焦距范围内清晰,范围外模糊
参数:
- Focus Distance:焦点距离
- Focus Range:清晰范围
- Blur Strength:模糊强度
Motion Blur(运动模糊)
原理:根据运动向量混合历史帧
TAA(Temporal Anti-Aliasing):结合运动向量和历史帧重建,同时实现抗锯齿和运动模糊
原理与数学机制
高斯模糊卷积核
3×3 高斯核(σ=1):
5×5 高斯核(σ=1.4):
Bloom 亮度计算
亮度公式:
阈值过滤:
SSAO 遮蔽计算
DOF 模糊半径
圆盘采样:
Motion Blur 速度向量
相机速度:
世界空间速度:
屏幕空间速度:
GLSL vs WGSL 代码对照
高斯模糊实现
GLSL 版本
水平模糊:
#version 300 es
precision highp float;
in vec2 v_texcoord;
out vec4 frag_color;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform vec2 u_direction;
void main() {
vec2 texelSize = 1.0 / u_resolution;
vec3 result = vec3(0.0);
float weights[5] = float[](0.227027, 0.1945946, 0.120641, 0.0546702, 0.016216);
result += texture(u_texture, v_texcoord).rgb * weights[0];
for (int i = 1; i < 5; i++) {
result += texture(u_texture, v_texcoord + u_direction * texelSize * float(i)).rgb * weights[i];
result += texture(u_texture, v_texcoord - u_direction * texelSize * float(i)).rgb * weights[i];
}
frag_color = vec4(result, 1.0);
}
WGSL 版本
struct FragmentInput {
@location(0) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var texture_input: texture_2d<f32>;
@group(0) @binding(1)
var texture_sampler: sampler;
@group(0) @binding(2)
var<uniform> resolution: vec2<f32>;
@group(0) @binding(3)
var<uniform> direction: vec2<f32>;
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let texelSize = 1.0 / resolution;
var result = vec3<f32>(0.0);
let weights = array<f32, 5>(0.227027, 0.1945946, 0.120641, 0.0546702, 0.016216);
result = result + textureSample(texture_input, texture_sampler, input.texcoord).rgb * weights[0];
for (var i: i32 = 1; i < 5; i++) {
result = result + textureSample(texture_input, texture_sampler, input.texcoord + direction * texelSize * f32(i)).rgb * weights[i];
result = result + textureSample(texture_input, texture_sampler, input.texcoord - direction * texelSize * f32(i)).rgb * weights[i];
}
output.color = vec4<f32>(result, 1.0);
return output;
}
差异点:
- WGSL 使用
array<f32, N>定义数组 float(i)需要显式转换为f32(i)- WGSL 的
for循环需要显式声明变量类型
Bloom 实现
GLSL 版本
亮度提取:
#version 300 es
precision highp float;
in vec2 v_texcoord;
out vec4 frag_color;
uniform sampler2D u_texture;
uniform float u_threshold;
void main() {
vec3 color = texture(u_texture, v_texcoord).rgb;
float brightness = dot(color, vec3(0.2126, 0.7152, 0.0722));
if (brightness > u_threshold) {
frag_color = vec4(color, 1.0);
} else {
frag_color = vec4(0.0, 0.0, 0.0, 1.0);
}
}
Bloom 混合:
#version 300 es
precision highp float;
in vec2 v_texcoord;
out vec4 frag_color;
uniform sampler2D u_scene_texture;
uniform sampler2D u_bloom_texture;
uniform float u_bloom_intensity;
void main() {
vec3 scene = texture(u_scene_texture, v_texcoord).rgb;
vec3 bloom = texture(u_bloom_texture, v_texcoord).rgb;
vec3 final = scene + bloom * u_bloom_intensity;
frag_color = vec4(final, 1.0);
}
WGSL 版本
亮度提取:
struct FragmentInput {
@location(0) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var texture_input: texture_2d<f32>;
@group(0) @binding(1)
var texture_sampler: sampler;
@group(0) @binding(2)
var<uniform> threshold: f32;
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let color = textureSample(texture_input, texture_sampler, input.texcoord).rgb;
let brightness = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
if (brightness > threshold) {
output.color = vec4<f32>(color, 1.0);
} else {
output.color = vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
return output;
}
Bloom 混合:
struct FragmentInput {
@location(0) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var scene_texture: texture_2d<f32>;
@group(0) @binding(1)
var scene_sampler: sampler;
@group(0) @binding(2)
var bloom_texture: texture_2d<f32>;
@group(0) @binding(3)
var bloom_sampler: sampler;
@group(0) @binding(4)
var<uniform> bloom_intensity: f32;
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let scene = textureSample(scene_texture, scene_sampler, input.texcoord).rgb;
let bloom = textureSample(bloom_texture, bloom_sampler, input.texcoord).rgb;
let final = scene + bloom * bloom_intensity;
output.color = vec4<f32>(final, 1.0);
return output;
}
差异点:
- WGSL 的
if-else语法与 GLSL 相同 vec3<f32>(...)需要显式类型标注
常见误区与陷阱
-
高斯模糊采样数过高
- 陷阱:使用 9×9 或更大卷积核导致性能暴跌
- 解决:使用分离卷积或 Kawase 模糊
-
Bloom 阈值过低
- 陷阱:阈值 0.0 导致全屏 Bloom,性能消耗大
- 解决:根据场景动态调整阈值(1.0-2.0)
-
SSAO 采样模式重复
- 陷阱:使用规则网格采样导致明显的条纹伪影
- 解决:使用旋转噪声或哈希函数
-
DOF 景深范围过大
- 陷阱:焦距范围过小导致全屏模糊
- 解决:根据相机距离动态调整焦距
-
Motion Blur 速度向量未归一化
- 陷阱:相机运动速度过快导致过度模糊
- 解决:限制最大模糊半径
-
Vignette 强度过高
- 陷阱:暗角强度过大导致画面过暗
- 解决:限制强度在 0.3-0.5 范围
-
Chromatic Aberration 采样距离过大
- 陷阱:RGB 分离距离过大导致画面失真
- 解决:根据像素到中心的距离动态调整偏移
-
后处理顺序错误
- 陷阱:在 Bloom 前进行色调映射,导致高光丢失
- 解决:确保 Tone Mapping 是最后一步
延伸阅读与自测
权威资料
- WebGL2 Bloom - WebGL2 Bloom 教程
- NVIDIA GPU Gems - Chapter 27: Motion Blur - Motion Blur 高级技术
- Unity Post Processing - Unity 后处理栈文档
- Kawase Blur - Kawase 模糊原理
开源实现参考
- Three.js Post Processing - Three.js 后处理库
- Unreal Engine Post Processing - UE5 后处理系统
- WebGPU Image Processing - WebGPU 图像处理示例
自测题
-
思考题: 为什么高斯模糊可以分离为两次 1D 卷积?为什么性能提升如此显著?
-
对比题: Bloom 与 HDR 渲染的关系是什么?为什么 Tone Mapping 必须在 Bloom 之后?
-
实践题: 如何实现自适应 Bloom 阈值,根据场景平均亮度动态调整?
-
扩展题: SSAO 与 HBAO 在算法原理上的区别是什么?为什么 HBAO 效果更准确?
-
优化题: 如何优化后处理管线,减少纹理采样次数?是否可以使用 Compute Shader 加速?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。