在 Metal 引擎中实现透视阴影贴图
前言
有光的地方就有影子。但如果你写过渲染引擎,就会知道——光照模型(比如 Blinn-Phong)默认是不会产生阴影的。它只告诉你"这个点被光照了多亮",但不会告诉你"这个点有没有被别的东西挡住"。
KisekiEngine 之前已经实现了 Blinn-Phong 点光源光照,场景里有立方体、有地面、有光照明暗,但就是没有阴影。一个物体浮在地面上,下面却没有影子,看起来就像悬浮在空中一样不真实。
要加阴影,最经典的方案就是 Shadow Mapping(阴影贴图)。它的核心思想只需要一句话:
站在光源的位置看一眼,记下每个方向上最近的深度;然后在正常渲染时,把每个片元投影回光源视角,比较一下深度——如果片元比记录的深度更远,说明它被什么东西挡住了,那它就在阴影里。
接下来,我们一步一步把这个想法变成 Metal 代码。
整体架构:双 Pass 渲染
整个实现的核心是在同一个 Command Buffer 里做两个 Render Pass:
┌─────────────────────────────────────────────────┐
│ Command Buffer │
│ │
│ ┌─────────────────────┐ ┌──────────────────┐ │
│ │ Shadow Pass │ │ Main Pass │ │
│ │ │ │ │ │
│ │ 光源视角渲染深度 │──▶│ 正常渲染 + │ │
│ │ → Shadow Map │ │ 采样 Shadow Map │ │
│ │ (1024×1024) │ │ → 阴影判定 │ │
│ └─────────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────┘
- Pass 1(Shadow Pass):把光源当成一台摄像机,用透视投影渲染整个场景,但只写深度,不输出颜色。结果存到一张 1024×1024 的深度纹理里,这就是 shadow map。
- Pass 2(Main Pass):用正常的摄像机渲染场景。在片段着色器里,把每个片元的世界坐标投影到光源空间,去 shadow map 里查一下深度,判断它是不是在阴影里。
下面按实现顺序逐步讲解。
第一步:创建 Shadow Map 纹理
MTLTextureDescriptor* desc = [MTLTextureDescriptor
texture2DDescriptorWithPixelFormat:MTLPixelFormatDepth32Float
width:1024 height:1024 mipmapped:NO];
desc.storageMode = MTLStorageModePrivate;
desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead;
这段代码创建了一张 1024×1024 的深度纹理,格式是 Depth32Float(每像素 32 位浮点深度值)。
关键在 usage 这一行——它必须同时包含两个标志:
MTLTextureUsageRenderTarget:Shadow Pass 要把深度写进去MTLTextureUsageShaderRead:Main Pass 的片段着色器要读出来
如果你之前写过普通的 depth buffer,可能习惯了只设 RenderTarget,因为深度测试是硬件自动做的,不需要在 shader 里读。但 shadow map 不一样,它本质上是一个"要被 shader 采样的纹理",所以 ShaderRead 不能少。
第二步:Depth-Only Pipeline
Shadow Pass 只需要深度,不需要计算颜色。Metal 允许我们创建一个没有片段着色器的渲染管线:
pipelineDesc.vertexFunction = shadowVertexFunc;
pipelineDesc.fragmentFunction = nil; // 没有片段着色器
pipelineDesc.depthAttachmentPixelFormat = MTLPixelFormatDepth32Float;
// 没有 color attachment
当 fragmentFunction 为 nil 时,Metal 只执行顶点着色器和光栅化,然后直接把插值后的 position.z 写入 depth texture。没有颜色计算,没有纹理采样,GPU 开销非常小。
这也是 shadow mapping 效率还可以的原因之一——Shadow Pass 的渲染开销比正常渲染低很多。
第三步:Shadow Vertex Shader
vertex ShadowVertexOut shadow_vertex(VertexIn in [[stage_in]],
constant Uniforms& uniforms [[buffer(1)]]) {
ShadowVertexOut out;
float4 worldPos = uniforms.modelMatrix * float4(in.position, 1.0);
out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos;
return out;
}
这个顶点着色器和主渲染的顶点着色器几乎一模一样——模型坐标乘 Model 矩阵得到世界坐标,再乘 View 和 Projection 矩阵得到裁剪坐标。
唯一的区别:这里的 viewMatrix 和 projectionMatrix 不是摄像机的,而是光源的。
输出也极简——只需要 position,不需要法线、UV 之类的东西,因为我们只关心深度。
第四步:构建光源矩阵
simd_float4x4 lightView = kmath::lookAt(lightPos, sceneCenter, up);
simd_float4x4 lightProj = kmath::perspective(kmath::radians(90.0f), 1.0f, 0.1f, 50.0f);
simd_float4x4 lightVP = simd_mul(lightProj, lightView);
这里把光源当成一台虚拟摄像机来构建矩阵:
lookAt:光源在lightPos,看向场景中心sceneCenter。这定义了光源的"视图矩阵"。perspective:用透视投影,因为我们模拟的是点光源,它发出的光是发散的。FOV 设为 90° 是一个比较大的视角,能覆盖较大范围的场景。- aspect = 1.0:因为 shadow map 是正方形(1024×1024)。
- near = 0.1, far = 50.0:近远裁剪面,根据场景尺度调整。
如果是平行光(如太阳光),这里应该用正交投影(orthographic),因为太阳光的光线是平行的。但点光源是透视的,所以用 perspective。这也是"透视阴影贴图"这个名字的由来。
第五步:配置 Shadow Pass
passDesc.depthAttachment.storeAction = MTLStoreActionStore; // 必须 Store!
[encoder setDepthBias:0.01 slopeScale:1.0 clamp:0.01]; // 硬件深度偏移
这两行容易被忽略,但都很关键:
storeAction = Store:普通渲染的 depth attachment 通常设为 DontCare,因为深度测试结束后就不需要了。但 shadow map 的深度信息在 Pass 2 还要用,所以必须存下来。如果漏了这个,Pass 2 采样到的 shadow map 就是一堆垃圾数据。
setDepthBias:这是硬件级别的深度偏移,用来防止 shadow acne(后面会详细讲)。它直接在光栅化阶段对深度值做偏移,比在 shader 里手动加 bias 更精确。
第六步:片段着色器中的阴影计算
这是整个实现最核心的部分——在 Main Pass 的片段着色器里判断一个点是否在阴影中。
// 世界坐标 → 光源裁剪空间
float4 lightClip = shadowUniforms.lightViewProjectionMatrix * float4(in.worldPosition, 1.0);
float3 lightNDC = lightClip.xyz / lightClip.w;
// NDC → 纹理 UV(注意 y 翻转)
float2 shadowUV = lightNDC.xy * float2(0.5, -0.5) + 0.5;
float currentDepth = lightNDC.z;
// 采样比较
float bias = 0.005;
float shadowDepth = shadowMap.sample(shadowSampler, shadowUV);
shadow = (currentDepth - bias > shadowDepth) ? 0.0 : 1.0;
这段代码做了四件事,一步步来看:
6.1 世界坐标投影到光源空间
float4 lightClip = shadowUniforms.lightViewProjectionMatrix * float4(in.worldPosition, 1.0);
float3 lightNDC = lightClip.xyz / lightClip.w;
把当前片元的世界坐标乘以光源的 View-Projection 矩阵,得到裁剪坐标,再做透视除法(除以 w)得到 NDC(Normalized Device Coordinates)。
这一步本质上是在问:“如果从光源的角度看,这个点在屏幕上的哪个位置?深度是多少?”
6.2 NDC 转纹理 UV
float2 shadowUV = lightNDC.xy * float2(0.5, -0.5) + 0.5;
NDC 的 xy 范围是 [-1, 1],但纹理 UV 的范围是 [0, 1]。所以需要做一次映射。
注意 y 分量乘的是 -0.5,因为 NDC 坐标系里 y 向上为正,而纹理坐标系里 v 向下为正。如果不翻转 y,阴影会上下颠倒。
6.3 深度比较
float shadowDepth = shadowMap.sample(shadowSampler, shadowUV);
shadow = (currentDepth - bias > shadowDepth) ? 0.0 : 1.0;
从 shadow map 采样出来的 shadowDepth 是 Shadow Pass 记录的"从光源看过去,这个方向上最近的物体的深度"。
currentDepth 是当前片元到光源的深度。
比较逻辑:
- 如果
currentDepth > shadowDepth(加了 bias 之后),说明当前片元比最近物体更远——它被挡住了,在阴影中,shadow = 0.0。 - 否则,说明当前片元就是(或者在)最近物体上,受到光照,
shadow = 1.0。
6.4 关于 Metal 的 NDC z 范围
一个值得注意的细节:Metal 的 NDC z 范围是 [0, 1](近裁剪面为 0,远裁剪面为 1),而 OpenGL 是 [-1, 1]。所以在 Metal 里做 shadow mapping 时,不需要像 OpenGL 那样对 z 做额外的 * 0.5 + 0.5 映射,直接用就行。
第七步:最终颜色合成
float3 litColor = ambient * baseColor + shadow * (diffuse * baseColor + specular);
最终的光照颜色由三个部分组成:
- Ambient(环境光):不受 shadow 影响。即使在阴影里,物体也不会完全漆黑——现实中总有间接光照。
- Diffuse(漫反射):乘以 shadow。在阴影中时 shadow = 0,漫反射被完全遮挡。
- Specular(高光):同样乘以 shadow。如果被挡住了,高光也不应该出现。
这个公式很简洁,也符合直觉——阴影中的物体只保留环境光,亮处的物体有完整的漫反射和高光。
Shadow Acne 与 Depth Bias
如果你实现了上面的代码,跑起来后很可能看到一个经典问题:地面上出现了密密麻麻的条纹伪影,像摩尔纹一样。这就是 Shadow Acne。
为什么会出现 Shadow Acne?
Shadow map 的分辨率是有限的(1024×1024),而场景中的表面是连续的。一个 shadow map 像素覆盖了场景中一小块区域,这块区域内所有片元共享同一个深度值。
但实际上这些片元的深度各不相同(尤其是倾斜的表面)。有些片元的真实深度恰好比 shadow map 记录的深度大一点点(因为精度不够),就被错误地判定为"在阴影中"。这些错误的阴影和正确的光照交替出现,就形成了条纹。
两层防御
在 KisekiEngine 里,我们用了两层 bias 来对抗 shadow acne:
第一层:硬件 Depth Bias(Shadow Pass)
[encoder setDepthBias:0.01 slopeScale:1.0 clamp:0.01];
这是在光栅化阶段直接对深度值做偏移。slopeScale 参数会根据表面倾斜程度自动调整偏移量——表面越倾斜,偏移越大。这比固定偏移更智能。
第二层:Shader Bias(Main Pass)
float bias = 0.005;
shadow = (currentDepth - bias > shadowDepth) ? 0.0 : 1.0;
在比较深度时,给 currentDepth 减去一个小偏移。等价于"把判定阈值稍微放宽一点"。
两层叠加使用,基本可以消除大部分 shadow acne。但 bias 也不能设太大,否则阴影会和投射物体之间出现可见的间隙(叫做 Peter Panning——阴影和物体脱离,像彼得潘的影子一样)。
总结与后续
回顾一下,完整的 Shadow Mapping 实现涉及这些组件:
- 一张可读可写的深度纹理(shadow map)
- 一个 depth-only 的渲染管线(Shadow Pass)
- 光源的 View-Projection 矩阵
- 片段着色器中的坐标变换与深度比较
- Depth bias 防止 shadow acne
核心概念就是那句话:“从光源看一眼,记住深度,然后在渲染时比较”。所有的代码都是围绕这个思路展开的。
当前实现的局限
这个实现是最基础的 shadow mapping,有几个明显的不足:
- 硬阴影:阴影边缘非常锐利,现实中的阴影通常有半影区(penumbra)。可以通过 PCF(Percentage Closer Filtering) 采样周围多个像素取平均来产生软阴影。
- 单方向:当前的 shadow map 只覆盖光源的一个方向。点光源理论上向所有方向发光,完整实现需要 Cube Shadow Map(6 个方向各一张)。
- 分辨率固定:1024×1024 在近处可能不够细腻。可以用 Cascaded Shadow Maps(CSM) 根据距离使用不同分辨率。
- 没有透明物体处理:半透明物体不会投射半透明阴影。
但作为第一步,一个能工作的硬阴影已经让场景的真实感有了质的提升——物体终于"踩在地上"了。后续可以在这个基础上逐步改进。