姆姆极客分享MU·GEEK·SHARE
// Real-time Shadow Rendering Light Object shadow (projected) techniques: PCF · VSM · CSM · RT trade-off: quality × perf × complexity light → occluder → receiver → shadow
游戏开发·

实时阴影渲染技术深度对比:从 Shadow Map 到 Ray Tracing

阴影是真实感的灵魂。本文系统对比 Shadow Map、PCF、VSM、CSM、SDSM、Ray Traced Shadows 的原理、性能与适用场景,给出选型决策框架。


阴影是 3D 场景中“真实性”的最大杠杆——一个无阴影的角色像漂浮在场景里,加上阴影立刻“落地”。但实时阴影算法的选型一直是图形程序的痛点:从最基础的 Shadow Map 到基于硬件 RT 的阴影,每个方案都有明确的代价与适用边界。本文把主流方案系统对比,帮你为自己的项目选对方案。

一、Shadow Map:所有方案的起点

Shadow Map 是实时阴影的事实标准,几乎所有变体都基于它。原理简单:从光源视角渲染一张深度图,然后从相机视角检查每个像素的深度是否大于光源深度——是则在阴影中。

// 顶点着色器:把世界坐标转到光源裁剪空间
struct VSOut {
    float4 pos : SV_POSITION;
    float4 lightSpacePos : TEXCOORD0;
};

VSOut VS(float3 worldPos : POSITION) {
    VSOut o;
    o.pos = mul(float4(worldPos, 1), viewProj);
    o.lightSpacePos = mul(float4(worldPos, 1), lightViewProj);
    return o;
}

// 像素着色器:比较深度
float4 PS(VSOut i) : SV_Target {
    float3 projCoords = i.lightSpacePos.xyz / i.lightSpacePos.w;
    projCoords = projCoords * 0.5 + 0.5;  // [-1,1] → [0,1]

    float closestDepth = shadowMap.Sample(sampler, projCoords.xy).r;
    float currentDepth = projCoords.z;

    float shadow = currentDepth > closestDepth + 0.005 ? 0.3 : 1.0;
    return float4(shadow, shadow, shadow, 1);
}

优点:实现简单,GPU 友好,所有平台都支持。 缺点

  • 硬边:阴影边缘是锯齿状的
  • Acne:精度问题导致自阴影伪影
  • Peter panning:bias 过大让阴影脱离物体
  • 分辨率瓶颈:远离光源的区域阴影质量差

所有后续方案都是在解决这些问题。

二、PCF:最简单的软阴影

Percentage-Closer Filtering 不是新算法,而是对 Shadow Map 采样的多次比较 + 平均:

float PCF(float2 uv, float compareDepth) {
    float shadow = 0;
    float2 texelSize = 1.0 / shadowMapSize;

    [unroll]
    for (int x = -1; x <= 1; x++) {
        [unroll]
        for (int y = -1; y <= 1; y++) {
            float pcfDepth = shadowMap.Sample(sampler, uv + float2(x, y) * texelSize).r;
            shadow += compareDepth > pcfDepth + 0.005 ? 0.3 : 1.0;
        }
    }
    return shadow / 9.0;
}

3x3 PCF 让边缘从锯齿变成“颗粒状”,5x5 更柔但更慢。PCF 是最常用的软阴影方案,性能/质量平衡好。

PCSS:物理正确的软阴影

PCSS(Percentage-Closer Soft Shadows)在 PCF 基础上动态调整采样范围:

// 根据"遮挡物到光源距离"调整 penumbra 大小
float penumbra = (receiverDepth - blockerDepth) * lightSize / blockerDepth;
float filterRadius = penumbra * shadowMapSize / receiverDepth;

// 用 filterRadius 做 PCF

近处阴影锐、远处阴影柔,更接近物理真实。但代价是两次纹理采样(blocker search + PCF),开销是普通 PCF 的 2-3 倍。

三、VSM:可过滤的阴影图

Variance Shadow Map 的核心思路:存储 depth 和 depth²,用切比雪夫不等式估算遮挡比例。

// 渲染时: RG 通道存 depth 和 depth²
float2 moments = float2(depth, depth * depth);
// 用硬件 bilinear filter 采样 moments

// 查询时
float2 m = shadowMap.Sample(sampler, uv).rg;
float p = (currentDepth <= m.x) ? 1.0 : 0.0;
float variance = m.y - m.x * m.x;
variance = max(variance, 0.00001);

float d = currentDepth - m.x;
float pMax = variance / (variance + d * d);

float shadow = max(p, pMax);

VSM 的最大优势:可以用硬件 bilinear / mipmap 过滤,远比 PCF 的多次采样便宜。但精度问题让 VSM 在“半遮挡”场景出现 light leaking(不该亮的地方亮了)。

四、CSM:远距离阴影的工程答案

Cascade Shadow Maps 把相机的视锥切成多个 cascade,每个 cascade 用独立的 shadow map:

// 通常 3-4 个 cascade
struct CascadeInfo {
    Matrix viewProj;
    float resolution;  // 比如 2048
    float farPlane;    // 比如 10, 30, 100, 500
};

// 像素着色器:根据 view space Z 选 cascade
float3 viewPos = mul(worldPos, view);
float depth = -viewPos.z;

int cascadeIdx = 0;
[unroll]
for (int i = 0; i < CASCADE_COUNT; i++) {
    if (depth > cascades[i].farPlane) cascadeIdx = i + 1;
}

// 用对应 cascade 的 shadow map
float shadow = SampleShadow(cascades[cascadeIdx], worldPos);

近处用高分辨率 shadow map(锐利),远处用低分辨率(覆盖范围大)。这是几乎所有 3A 游戏的标配。

SDSM:自动算 cascade 范围

Sample Distribution Shadow Maps 用上一帧的深度图反推“光源视锥应该包多大”,避免手动调参:

// GPU reduce pass:找出 view space 最小/最大 Z
// CPU readback:拿到这两个值
// 计算紧凑的光源视锥,重渲染 shadow map

效果是“无论镜头怎么动,shadow map 分辨率都用得最满”,比固定 cascade 划分质量高 30%+。

五、Ray Traced Shadows:终极方案

实时光线追踪在 2018 年 RTX 发布后逐步普及,2026 年的中高端 PC 几乎都支持。

// DXR / Vulkan RT
[shader("raygeneration")]
void RayGen() {
    uint2 pixel = DispatchRaysIndex().xy;
    float3 origin = cameraPos;
    float3 dir = computeRayDir(pixel);

    RayDesc ray;
    ray.Origin = origin;
    ray.Direction = dir;
    ray.TMin = 0;
    ray.TMax = 1000;

    ShadowPayload payload;
    payload.inShadow = false;

    TraceRay(
        topLevelAS,
        RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH,
        0xFF, 0, 0, 0, ray, payload
    );

    gOutput[pixel] = payload.inShadow ? 0.3 : 1.0;
}

优点

  • 完美的硬阴影(点光源)
  • 完美的软阴影(面光源,多 ray 平均)
  • 没有 shadow map 的 acne / peter panning / resolution 问题

缺点

  • 性能开销大(每像素至少一次 RT traversal)
  • 需要 BVH 加速结构
  • 移动端几乎不支持

六、选型决策框架

场景 推荐方案 理由
移动端 / Web 1 cascade + PCF 性能预算紧张
中端 PC 游戏 CSM (3-4) + PCF 平衡好
高端 PC 游戏 CSM + PCSS 或 RT shadows 软阴影更真实
室内场景 单张 shadow map + VSM 紧凑范围用 VSM 不漏光
大型开放世界 CSM + SDSM + 远距离 RT 远景用 RT 替代极低分辨率 CSM
体素/体积阴影 Ray marched shadows Shadow Map 不适合

七、性能预算参考

以 1080p / 60fps 为基准(总预算 16ms):

方案 阴影耗时 占总帧
单 cascade + PCF 3x3 0.3ms 2%
4 cascade + PCF 5x5 1.8ms 11%
4 cascade + PCSS 4.2ms 26%
RT shadows (1 ray/px) 2.5ms 16%
RT soft shadows (8 ray/px) 8.0ms 50%

经验法则:阴影预算不要超过总帧的 20%,否则其他系统会爆。

八、常见伪影与对策

Shadow Acne(条纹状自阴影)

  • 原因:深度精度不够
  • 对策:增加 bias;用 front-face culling 渲染 shadow map

Peter Panning(阴影脱离物体)

  • 原因:bias 过大
  • 对策:减小 bias,或用 slope-scaled bias

Light Leaking(不该亮的亮了)

  • 原因:VSM/ESM 高频信号丢失
  • 对策:用 EVSM 4 moments,或回退 PCF

Perspective Aliasing(近处锯齿)

  • 原因:单 cascade 分辨率不够
  • 对策:CSM,近处用更高分辨率

结语

实时阴影没有“银弹”,每个方案都是在“质量 vs 性能 vs 复杂度”三角中做取舍。对一个新项目,建议从 CSM + PCF 起步,这是最稳妥的工程基线;有 RT 硬件再加 RT shadows 作为高端选项;移动端/Web 老老实实单 cascade + PCF。

阴影算法的演进史,本质上是 GPU 能力增长的镜像——从早期的 stencil shadow volume,到 shadow map 的各种变体,再到 RT shadows。每一次跳跃都让“真实”更近一步。但记住:用户感知的是“有没有阴影”远大于“阴影质量多高”,所以先把基础做好,再追求极致。