渲染
多 Pass 渲染
直觉问题:为什么阴影需要两次渲染?
Q1: 为什么游戏中的阴影不直接画在模型上,而是要额外渲染一次?
Q2: 延迟渲染为什么可以支持几百个动态光源,而前向渲染却不行?
核心概念白话讲
渲染到纹理(RTT, Render-to-Texture)
传统渲染:直接渲染到屏幕帧缓冲 RTT 渲染:先渲染到离屏纹理,再用纹理进行二次处理
FBO(Framebuffer Object):帧缓冲对象,包含多个附件(颜色、深度、模板)
graph LR
A[几何渲染] --> B[默认帧缓冲<br/>直接显示]
A --> C[FBO离屏渲染<br/>Render-to-Texture]
C --> D[纹理复用<br/>Shadow Map/后处理]
TIP
FBO 在 WebGPU 中被拆分为 RenderPass + Texture,更灵活但更复杂。
Shadow Map(阴影映射)
核心思想:从光源视角渲染深度图,比较当前像素深度与阴影图深度
两步渲染:
- Light Pass:从光源位置渲染深度图
- Camera Pass:从相机位置渲染场景,采样阴影图
Shadow Acne(阴影痤疮):深度精度误差导致表面出现伪影
- 解决:添加偏移
bias
PCF(Percentage Closer Filtering):采样周围像素,软化阴影边缘
延迟渲染 vs 前向渲染
前向渲染(Forward Rendering):
- 每个像素立即计算光照
- 复杂度:O(几何数 × 光源数)
- 适合:少量光源
延迟渲染(Deferred Rendering):
- Geometry Pass:渲染到 G-Buffer(位置、法线、反照率)
- Lighting Pass:采样 G-Buffer 计算光照
- 复杂度:O(几何数 + 光源数)
- 适合:大量动态光源
WARNING
延迟渲染无法处理透明物体、MSAA 反走样。
G-Buffer(Geometry Buffer)
延迟渲染的中间缓冲,存储几何信息:
| 附件 | 格式 | 存储内容 |
|---|---|---|
| G0 | RGBA16F | 位置 (x, y, z, _) |
| G1 | RGBA8 | 法线 (nx, ny, nz, _) |
| G2 | RGBA8 | 反照率 (r, g, b, metallic) |
| Depth | DEPTH24_STENCIL8 | 深度/模板 |
抗锯齿技术
MSAA(Multisample Anti-Aliasing):
- 每像素采样多次,提高几何边缘质量
- 性能开销:4x MSAA = 4x 带宽
FXAA(Fast Approximate Anti-Aliasing):
- 后处理算法,检测边缘并模糊
- 性能开销低,但画面略糊
TAA(Temporal Anti-Aliasing):
- 结合历史帧重建,质量最高
- 需要运动向量,支持 Motion Blur
原理与数学机制
Shadow Map 深度比较
PCF 软阴影
4×4 PCF 采样:
VSM(Variance Shadow Map)
存储深度与深度的平方:
切比雪夫不等式估算阴影概率:
TIP
VSM 支持硬件过滤,但会产生光泄漏(Light Bleeding)。
延迟渲染光照计算
离散化:
FXAA 边缘检测
亮度计算:
边缘检测:
GLSL vs WGSL 代码对照
Shadow Map 实现
GLSL 版本
#version 300 es
precision highp float;
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec2 a_texcoord;
layout(location = 2) in vec3 a_normal;
uniform mat4 u_light_space_matrix;
uniform mat4 u_model;
out vec3 v_position;
out vec2 v_texcoord;
out vec3 v_normal;
out vec4 v_light_space_position;
void main() {
vec4 world_position = u_model * vec4(a_position, 1.0);
vec4 light_space_position = u_light_space_matrix * world_position;
v_position = world_position.xyz;
v_texcoord = a_texcoord;
v_normal = mat3(transpose(inverse(u_model))) * a_normal;
v_light_space_position = light_space_position;
gl_Position = u_light_space_matrix * world_position;
}
Fragment Shader:
#version 300 es
precision highp float;
in vec3 v_position;
in vec2 v_texcoord;
in vec3 v_normal;
in vec4 v_light_space_position;
uniform sampler2D u_shadow_map;
uniform vec3 u_light_position;
uniform vec3 u_view_position;
uniform vec3 u_light_color;
uniform sampler2D u_diffuse_map;
out vec4 frag_color;
float calculateShadow(vec4 light_space_position) {
vec3 proj_coords = light_space_position.xyz / light_space_position.w;
proj_coords = proj_coords * 0.5 + 0.5;
float closest_depth = texture(u_shadow_map, proj_coords.xy).r;
float current_depth = proj_coords.z;
float bias = 0.005;
float shadow = current_depth - bias > closest_depth ? 0.0 : 1.0;
if (proj_coords.z > 1.0) {
shadow = 1.0;
}
return shadow;
}
void main() {
vec3 albedo = texture(u_diffuse_map, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
vec3 light_dir = normalize(u_light_position - v_position);
vec3 view_dir = normalize(u_view_position - v_position);
float diffuse = max(dot(normal, light_dir), 0.0);
float shadow = calculateShadow(v_light_space_position);
vec3 ambient = 0.1 * albedo;
vec3 lighting = (ambient + diffuse * shadow) * u_light_color * albedo;
frag_color = vec4(lighting, 1.0);
}
WGSL 版本
Vertex Shader:
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) texcoord: vec2<f32>,
@location(2) normal: vec3<f32>,
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) world_position: vec3<f32>,
@location(1) texcoord: vec2<f32>,
@location(2) normal: vec3<f32>,
@location(3) light_space_position: vec4<f32>,
}
@group(0) @binding(0)
var<uniform> light_space_matrix: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> model: mat4x4<f32>;
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
let world_position = model * vec4<f32>(input.position, 1.0);
let light_space_position = light_space_matrix * world_position;
output.position = light_space_position;
output.world_position = world_position.xyz;
output.texcoord = input.texcoord;
let normal_matrix = transpose(inverse(mat3x3<f32>(model)));
output.normal = normal_matrix * input.normal;
output.light_space_position = light_space_position;
return output;
}
Fragment Shader:
struct FragmentInput {
@location(0) world_position: vec3<f32>,
@location(1) texcoord: vec2<f32>,
@location(2) normal: vec3<f32>,
@location(3) light_space_position: vec4<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var shadow_map: texture_depth_2d<f32>;
@group(0) @binding(1)
var shadow_sampler: sampler;
@group(0) @binding(2)
var<uniform> light_position: vec3<f32>;
@group(0) @binding(3)
var<uniform> view_position: vec3<f32>;
@group(0) @binding(4)
var<uniform> light_color: vec3<f32>;
@group(0) @binding(5)
var diffuse_map: texture_2d<f32>;
@group(0) @binding(6)
var diffuse_sampler: sampler;
fn calculateShadow(light_space_position: vec4<f32>) -> f32 {
var proj_coords = light_space_position.xyz / light_space_position.w;
proj_coords = proj_coords * 0.5 + 0.5;
let closest_depth = textureSample(shadow_map, shadow_sampler, proj_coords.xy);
let current_depth = proj_coords.z;
let bias: f32 = 0.005;
var shadow: f32 = 0.0;
if (current_depth - bias > closest_depth) {
shadow = 0.0;
} else {
shadow = 1.0;
}
if (proj_coords.z > 1.0) {
shadow = 1.0;
}
return shadow;
}
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let albedo = textureSample(diffuse_map, diffuse_sampler, input.texcoord).rgb;
let normal = normalize(input.normal);
let light_dir = normalize(light_position - input.world_position);
let view_dir = normalize(view_position - input.world_position);
let diffuse = max(dot(normal, light_dir), 0.0);
let shadow = calculateShadow(input.light_space_position);
let ambient = vec3<f32>(0.1) * albedo;
let lighting = (ambient + diffuse * shadow) * light_color * albedo;
output.color = vec4<f32>(lighting, 1.0);
return output;
}
差异点:
- WGSL 使用
texture_depth_2d<f32>存储深度图 textureSample()替代texture()- 条件表达式使用
if-else而非三元运算符
延迟渲染 G-Buffer 渲染
GLSL 版本
#version 300 es
precision highp float;
in vec3 v_position;
in vec2 v_texcoord;
in vec3 v_normal;
layout(location = 0) out vec4 g_position;
layout(location = 1) out vec4 g_normal;
layout(location = 2) out vec4 g_albedo;
uniform sampler2D u_diffuse_map;
void main() {
vec3 albedo = texture(u_diffuse_map, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
g_position = vec4(v_position, 1.0);
g_normal = vec4(normal, 1.0);
g_albedo = vec4(albedo, 1.0);
}
WGSL 版本
struct FragmentInput {
@location(0) world_position: vec3<f32>,
@location(1) texcoord: vec2<f32>,
@location(2) normal: vec3<f32>,
}
struct FragmentOutput {
@location(0) position: vec4<f32>,
@location(1) normal: vec4<f32>,
@location(2) albedo: vec4<f32>,
}
@group(0) @binding(0)
var diffuse_map: texture_2d<f32>;
@group(0) @binding(1)
var diffuse_sampler: sampler;
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let albedo = textureSample(diffuse_map, diffuse_sampler, input.texcoord).rgb;
let normal = normalize(input.normal);
output.position = vec4<f32>(input.world_position, 1.0);
output.normal = vec4<f32>(normal, 1.0);
output.albedo = vec4<f32>(albedo, 1.0);
return output;
}
差异点:
- WGSL 使用结构体统一管理输出
layout(location=...)被@location(...)替代
常见误区与陷阱
-
Shadow Map 偏移值过大
- 陷阱:
bias过大导致阴影悬浮 - 解决:根据光源距离和角度动态调整
bias
- 陷阱:
-
PCF 采样数过高
- 陷阱:4×4 或 8×8 PCF 导致性能暴跌
- 解决:使用分层采样或 Poisson Disk
-
延迟渲染深度精度丢失
- 陷阱:16 位浮点深度导致 Z-fighting
- 解决:使用 24/32 位深度或反向深度
-
透明物体无法延迟渲染
- 陷阱:透明物体不写入深度,延迟渲染失效
- 解决:前向渲染透明物体,最后混合
-
FXAA 过度模糊
- 陷阱:阈值过低导致全屏模糊
- 解决:调整
edgeThreshold和edgeThresholdMin
-
MSAA 与后处理冲突
- 陷阱:后处理需要 Resolve,延迟渲染无法使用 MSAA
- 解决:使用 SMAA/TAA 替代 MSAA
-
VSM 光泄漏
- 陷阱:深物体后方出现浅色伪影
- 解决:使用指数方差或 VSM-EVS
-
G-Buffer 带宽瓶颈
- 陷阱:多附件 G-Buffer 占用大量带宽
- 解决:使用 packed 格式或 MRT 压缩
延伸阅读与自测
权威资料
- Shadow Mapping - LearnOpenGL - 阴影映射基础教程
- Deferred Shading - LearnOpenGL - 延迟渲染实现
- NVIDIA GPU Gems - Chapter 8: Variance Shadow Maps - VSM 高级技术
- The Danger Zone - Shadow Techniques - 现代阴影算法对比
开源实现参考
- Unity URP Deferred Rendering - Unity 引擎延迟渲染
- Unreal Engine Deferred Renderer - UE5 延迟渲染管线
- WebGPU-Samples - WebGPU 官方示例
自测题
-
思考题: 为什么延迟渲染可以支持数百个动态光源,而前向渲染不行?
-
对比题: VSM 与 PCF 在软阴影质量上的区别是什么?为什么 VSM 支持硬件过滤?
-
实践题: 如何实现级联阴影映射(CSM)?如何处理级联边界?
-
扩展题: TAA 如何利用历史帧重建?如何处理运动模糊?
-
优化题: 如何优化延迟渲染的 G-Buffer 带宽?是否可以使用压缩纹理格式?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。