T.TAO
返回博客
/9 min read/Graphics Engine

Metal #10 阴影

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下阴影贴图(shadow mapping)在 Metal 里的实现,以及那几个几乎所有人都会踩的坑。

基本思想

阴影贴图的想法只有一句话:从光源的位置渲染一遍场景的深度。 然后在主渲染时,把每个片元变换到光源空间,比较它的深度和阴影贴图里记录的深度——如果更远,说明它被别的东西挡住了,处于阴影中。

所以这是一个两 pass 的算法。

Pass 1:深度 pass

只需要深度附件,不需要颜色:

Swiftlet shadowDescriptor = MTLRenderPassDescriptor()
shadowDescriptor.depthAttachment.texture     = shadowMap
shadowDescriptor.depthAttachment.loadAction  = .clear
shadowDescriptor.depthAttachment.storeAction = .store      // 主 pass 要读它
shadowDescriptor.depthAttachment.clearDepth  = 1.0

let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: shadowDescriptor)!
encoder.setRenderPipelineState(shadowPipelineState)        // 只有顶点函数
encoder.setDepthStencilState(depthStencilState)

注意这里 storeAction 必须是 .store——这是上一篇里说的"除非后续 pass 要读"的那个例外。

阴影 pipeline 的片元函数可以完全省略(fragmentFunction = nil),因为我们只要深度:

Swiftlet pd = MTLRenderPipelineDescriptor()
pd.vertexFunction   = library.makeFunction(name: "shadow_vertex")
pd.fragmentFunction = nil
pd.depthAttachmentPixelFormat = .depth32Float

这能让深度 pass 快很多,也是它比主 pass 便宜得多的原因。

顶点函数只做一件事:

MSLvertex float4 shadow_vertex(VertexIn in [[stage_in]],
                            constant float4x4 &lightMVP [[buffer(11)]])
{
    return lightMVP * in.position;
}

lightMVP 是从光源视角的 view-projection 乘上模型矩阵。方向光用正交投影(光线平行),点光源用透视投影,而且需要渲染立方体的六个面。

Pass 2:比较

主 pass 里,顶点函数额外输出一个光源空间的位置:

MSLout.shadowPosition = uniforms.lightViewProjection * posWS;

片元函数里做除法、映射到 [0,1] 的 UV,然后采样比较:

MSLfloat3 proj = in.shadowPosition.xyz / in.shadowPosition.w;   // 正交投影时 w = 1,但保留除法更通用
float2 uv   = proj.xy * 0.5 + 0.5;
uv.y        = 1.0 - uv.y;                                    // Metal 纹理原点在左上

float closest = shadowMap.sample(shadowSampler, uv).r;
float current = proj.z;                                      // Metal 深度已经是 [0,1]
float shadow  = current > closest ? 0.0 : 1.0;

两个 Metal 特有的细节:纹理的 y 轴需要翻转(NDC 原点在中心、y 向上,纹理原点在左上、y 向下),以及深度不需要从 [-1,1] 映射到 [0,1]——Metal 的 NDC 深度本来就是 [0,1],照抄 OpenGL 教程的 proj.z * 0.5 + 0.5 在这里是错的。

阴影贴图的采样器必须用 .clampToEdge,并且阴影贴图之外的区域应该视为不在阴影中。否则光锥外的整个世界都会是黑的。

阴影痤疮与 Peter Panning

直接用上面的代码,会看到表面上布满条纹状的自阴影——这叫阴影痤疮(shadow acne)。

原因是阴影贴图的分辨率有限:一个阴影贴图纹素覆盖了表面上的一小片区域,这片区域内所有点共用同一个深度值,但它们的实际深度是连续变化的。于是有一半的点会判定为"比记录的深度更远",被错判为阴影。

最直接的解法是加一个偏移:

MSLfloat bias = 0.005;
float shadow = current - bias > closest ? 0.0 : 1.0;

但常数偏移治标不治本。表面越倾斜,一个纹素覆盖的深度范围越大,需要的偏移也越大。所以应该用斜率相关偏移

MSLfloat bias = max(0.05 * (1.0 - dot(n, l)), 0.005);

偏移开大了会产生另一个问题:Peter Panning——物体和它的影子分离,看起来像悬浮在地面上。名字来自彼得潘那个和影子分家的故事。

更彻底的解法是在深度 pass 里只渲染背面

Swiftencoder.setCullMode(.front)

这样记录的深度是物体背面的深度,比正面深一个物体的厚度,自阴影的问题自然消失,而且不需要偏移。代价是这个技巧只对封闭的实心物体有效——一片单面的墙或者树叶会完全失去阴影。

实践中通常两者结合:封闭物体用背面渲染,薄片物体用斜率偏移。

硬件阴影比较

上面那个 current > closest 的比较,其实可以让纹理采样单元来做,而且是免费的硬件 PCF。Metal 通过比较采样器支持这个:

Swiftlet sd = MTLSamplerDescriptor()
sd.compareFunction = .lessEqual
sd.minFilter = .linear
sd.magFilter = .linear
shadowSampler = device.makeSamplerState(descriptor: sd)
MSLdepth2d<float> shadowMap [[texture(1)]];
sampler_comparison shadowSampler [[sampler(1)]];

float shadow = shadowMap.sample_compare(shadowSampler, uv, current - bias);

sample_compare 会做四次比较然后双线性插值比较结果(而不是深度值),直接返回 0 到 1 的柔和边缘。注意顺序很重要:插值深度再比较是错的,插值比较结果才对。

PCF:柔化边缘

单次 sample_compare 只软化一个像素。要更柔的阴影,需要在一个邻域内多次采样求平均——这就是 PCF(Percentage-Closer Filtering):

MSLfloat pcf(depth2d<float> map, sampler_comparison s, float2 uv, float ref) {
    float sum = 0.0;
    float texel = 1.0 / 2048.0;
    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            sum += map.sample_compare(s, uv + float2(x, y) * texel, ref);
        }
    }
    return sum / 9.0;
}

3×3 是质量和开销的常见折中。要更柔的话,与其加大核,不如用泊松盘采样加上按像素旋转的抖动——用更少的采样得到噪声而非条带,而噪声在 TAA 下会被时序平滑掉。

级联阴影贴图

一张阴影贴图要覆盖整个视锥体,就意味着近处的分辨率极低——远处几百米的地方和脚下的草用同样的纹素密度,这显然不合理。

级联阴影贴图(CSM)把视锥体按深度切成几段,每段用一张自己的阴影贴图。近处的那张覆盖很小的范围,分辨率因此很高。

Plain Text|--cascade 0--|-----cascade 1-----|----------cascade 2----------|
0            10m                 40m                          200m

分割距离通常用对数和均匀分布的混合(practical split scheme),因为纯对数分割在近处切得太密。

片元里按视空间深度选择级联:

MSLuint cascade = 0;
for (uint i = 0; i < cascadeCount - 1; i++) {
    if (viewDepth > cascadeSplits[i]) cascade = i + 1;
}
float shadow = pcf(shadowMaps, s, projectToCascade(posWS, cascade), ref);

级联的两个典型问题:边界可见(在两个级联之间做一小段混合可以解决)和阴影游泳(相机移动时阴影边缘抖动,因为光源的正交投影范围在变)。后者的标准解法是把光源投影的中心对齐到阴影贴图的纹素网格上,让阴影贴图的采样点在世界空间中保持稳定。

下一篇讲延迟渲染。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal