返回知识库

渲染

多 Pass 渲染

多 Pass 渲染 封面
WebGPU/WebGLShaderGLSL/WGSL多Pass渲染Shadow Map延迟渲染

直觉问题:为什么阴影需要两次渲染?

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(阴影映射)

核心思想:从光源视角渲染深度图,比较当前像素深度与阴影图深度

两步渲染

  1. Light Pass:从光源位置渲染深度图
  2. 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)

延迟渲染的中间缓冲,存储几何信息:

附件格式存储内容
G0RGBA16F位置 (x, y, z, _)
G1RGBA8法线 (nx, ny, nz, _)
G2RGBA8反照率 (r, g, b, metallic)
DepthDEPTH24_STENCIL8深度/模板

抗锯齿技术

MSAA(Multisample Anti-Aliasing)

  • 每像素采样多次,提高几何边缘质量
  • 性能开销:4x MSAA = 4x 带宽

FXAA(Fast Approximate Anti-Aliasing)

  • 后处理算法,检测边缘并模糊
  • 性能开销低,但画面略糊

TAA(Temporal Anti-Aliasing)

  • 结合历史帧重建,质量最高
  • 需要运动向量,支持 Motion Blur

原理与数学机制

Shadow Map 深度比较

shadow={0,depthfragment>depthshadowMap+bias1,depthfragmentdepthshadowMap+bias\text{shadow} = \begin{cases} 0, & \text{depth}_{\text{fragment}} > \text{depth}_{\text{shadowMap}} + \text{bias} \\ 1, & \text{depth}_{\text{fragment}} \leq \text{depth}_{\text{shadowMap}} + \text{bias} \end{cases}

PCF 软阴影

4×4 PCF 采样

shadow=116i=11j=11compare(depthfrag,depthshadowMap(uv+(i,j)texelSize))\text{shadow} = \frac{1}{16} \sum_{i=-1}^{1} \sum_{j=-1}^{1} \text{compare}(\text{depth}_{\text{frag}}, \text{depth}_{\text{shadowMap}}(uv + (i, j) \cdot \text{texelSize}))

VSM(Variance Shadow Map)

存储深度与深度的平方

E[X]=depthE[X] = \sum depth E[X2]=depth2E[X^2] = \sum depth^2 Var(X)=E[X2](E[X])2\text{Var}(X) = E[X^2] - (E[X])^2

切比雪夫不等式估算阴影概率

P(t<μ)=σ2σ2+(tμ)2P(t < \mu) = \frac{\sigma^2}{\sigma^2 + (t - \mu)^2}

TIP

VSM 支持硬件过滤,但会产生光泄漏(Light Bleeding)。

延迟渲染光照计算

Lout=Ωfr(p,ωi,ωo)Li(p,ωi)(nωi)dωiL_{out} = \int_{\Omega} f_r(p, \omega_i, \omega_o) \cdot L_i(p, \omega_i) \cdot (n \cdot \omega_i) \, d\omega_i

离散化

Lout=k=1NLkBRDF(albedo,normal,roughness,metallic)max(0,nlk)L_{out} = \sum_{k=1}^{N} L_k \cdot \text{BRDF}(\text{albedo}, \text{normal}, \text{roughness}, \text{metallic}) \cdot \max(0, n \cdot l_k)

FXAA 边缘检测

亮度计算

l=0.299r+0.587g+0.114bl = 0.299 \cdot r + 0.587 \cdot g + 0.114 \cdot b

边缘检测

edge=lnorthlsouth+leastlwest\text{edge} = |l_{\text{north}} - l_{\text{south}}| + |l_{\text{east}} - l_{\text{west}}|

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(...) 替代

常见误区与陷阱

  1. Shadow Map 偏移值过大

    • 陷阱:bias 过大导致阴影悬浮
    • 解决:根据光源距离和角度动态调整 bias
  2. PCF 采样数过高

    • 陷阱:4×4 或 8×8 PCF 导致性能暴跌
    • 解决:使用分层采样或 Poisson Disk
  3. 延迟渲染深度精度丢失

    • 陷阱:16 位浮点深度导致 Z-fighting
    • 解决:使用 24/32 位深度或反向深度
  4. 透明物体无法延迟渲染

    • 陷阱:透明物体不写入深度,延迟渲染失效
    • 解决:前向渲染透明物体,最后混合
  5. FXAA 过度模糊

    • 陷阱:阈值过低导致全屏模糊
    • 解决:调整 edgeThresholdedgeThresholdMin
  6. MSAA 与后处理冲突

    • 陷阱:后处理需要 Resolve,延迟渲染无法使用 MSAA
    • 解决:使用 SMAA/TAA 替代 MSAA
  7. VSM 光泄漏

    • 陷阱:深物体后方出现浅色伪影
    • 解决:使用指数方差或 VSM-EVS
  8. G-Buffer 带宽瓶颈

    • 陷阱:多附件 G-Buffer 占用大量带宽
    • 解决:使用 packed 格式或 MRT 压缩

延伸阅读与自测

权威资料

开源实现参考

自测题

  1. 思考题: 为什么延迟渲染可以支持数百个动态光源,而前向渲染不行?

  2. 对比题: VSM 与 PCF 在软阴影质量上的区别是什么?为什么 VSM 支持硬件过滤?

  3. 实践题: 如何实现级联阴影映射(CSM)?如何处理级联边界?

  4. 扩展题: TAA 如何利用历史帧重建?如何处理运动模糊?

  5. 优化题: 如何优化延迟渲染的 G-Buffer 带宽?是否可以使用压缩纹理格式?


参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。