这篇笔记主要写一下阴影贴图(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);
级联的两个典型问题:边界可见(在两个级联之间做一小段混合可以解决)和阴影游泳(相机移动时阴影边缘抖动,因为光源的正交投影范围在变)。后者的标准解法是把光源投影的中心对齐到阴影贴图的纹素网格上,让阴影贴图的采样点在世界空间中保持稳定。
下一篇讲延迟渲染。