KisekiEngine 点光源阴影排查:从单张透视 Shadow Map 到 6 面 Shadow Map Array
开源项目地址:https://github.com/sznswjr/kiseki_engine
相关提交:6b97d64 Refactor point light shadow mapping
背景
KisekiEngine 的 garden scene 里有房子、树、围栏、长椅、邮箱和地面。前一轮已经实现了基础 Blinn-Phong 光照、纹理、OBJ 加载和一张 2D shadow map。最初看起来阴影已经出现了,但继续观察就会发现两个问题:
- 围栏、立柱这类细长物体在地面上的阴影缺失或不完整。
- 改成点光源 cubemap 思路后,地面阴影又出现过位置错乱。
这两个现象很容易被误判成 bias、PCF 或 shadow map 分辨率问题。实际根因更底层:光源类型、shadow map 投影模型和 cubemap face 坐标约定没有匹配好。
这篇文章记录这次排查和重构过程。
问题一:围栏阴影为什么会缺失
最早的实现方式是:把 shadow camera 放在点光源位置,然后使用一张 2D 透视 shadow map。
这听起来合理,因为点光源确实有一个位置。但透视 shadow map 表达的是一个视锥,它天然适合 Spot Light,不适合完整的 Point Light。
点光源向所有方向发光。单张 2D shadow map 最多只能覆盖一个方向的锥形区域。为了让它“尽量覆盖当前场景所有物体”,只能不断扩大 FOV。
在这个场景里,点光源位于:
scene.light.position = {3.0f, 8.0f, 4.0f};
围栏位于地面南侧:
addOBJObject(scene, fenceMesh, modelTexture,
(simd_float3){i * 2.0f, 0, 6.5f});
地面本身又是 20x20 的大平面。把地面、房子、树、围栏全部纳入一个透视 shadow frustum 后,计算出来的 FOV 接近 180°。这就是危险信号。
FOV 接近 180° 会带来几个问题:
- 物体非常容易贴到 shadow frustum 边缘,稍微越界就没有阴影。
- shadow map 的有效像素被摊得很开,围栏这种薄物体只占很少像素。
- bias 和 PCF 会更容易把细长阴影过滤掉。
- 如果物体分布在光源周围,单个视锥不可能覆盖光源背后的区域。
所以围栏阴影缺失不是“调大分辨率”就能解决的参数问题,而是用错了 shadow map 类型。
主流引擎怎么处理
主流游戏引擎通常按光源类型选择阴影方案:
| 光源类型 | 常见阴影方案 |
|---|---|
| Directional Light | 正交 shadow map,户外常用 CSM |
| Spot Light | 单张透视 shadow map |
| Point Light | cubemap shadow,六个方向各渲一次 |
也就是说,单张透视 shadow map 是 spotlight 的方案,不是完整 point light 的方案。
KisekiEngine 最初的问题,正是把 point light 的照明模型和 spotlight 风格的 shadow map 混在了一起。
第一次重构:引入光源和阴影对象标记
为了避免继续在 lightPosition 上打补丁,我先把场景光源抽象出来:
enum class LightType {
Directional = 0,
Point = 1,
Spot = 2
};
struct SceneLight {
LightType type = LightType::Point;
simd_float3 position = {1.5f, 3.0f, 1.5f};
simd_float3 direction = {0.0f, -1.0f, 0.0f};
simd_float3 color = {1.0f, 1.0f, 1.0f};
float intensity = 1.0f;
float range = 30.0f;
float ambientIntensity = 0.15f;
float spotAngleRadians = 0.0f;
bool castsShadow = true;
};
同时给 SceneObject 加上阴影相关标记:
bool castsShadow = true;
bool receivesShadow = true;
bool visibleInMainPass = true;
bool visibleInShadowPass = true;
这样可以明确区分:
- 地面只接收阴影,不作为 caster。
- 光源可视化球体不参与 shadow pass。
- 调试物体可以只在主 pass 可见。
这一步很重要。否则光源球体这种“可视化对象”会污染 shadow pass,地面这种 receiver 也可能被误纳入 shadow caster 的计算。
第二次重构:点光源改为六面阴影
点光源阴影应该从光源位置向六个方向渲染:
+X-X+Y-Y+Z-Z
每个方向都是一个 90° 透视投影。这样才能覆盖点光源周围完整空间。
最初我尝试使用 Metal 的 texturecube<float> 做采样。但这又引出了第二个问题。
问题二:为什么地面阴影位置会错乱
改成 cubemap 思路后,阴影不再受单个视锥裁剪,但地面阴影的位置一度是错乱的。
原因是 cubemap 有两套坐标约定必须一致:
- shadow pass 渲染每个 face 时使用的 view matrix。
- main pass 采样 cubemap 时,API 根据方向向量选择 face 和 face 内 UV 的规则。
如果渲染 face 的朝向、up vector、面内 UV 方向,和 texturecube.sample(direction) 的隐式规则不完全一致,就会出现“方向看起来对,但读到的是旋转或翻转后的深度”的情况。
地面阴影错乱就是这个原因。
与其继续猜 Metal 的隐式 cubemap face 布局,不如把 face 选择和 UV 计算显式化。
最终方案:6 层 texture2d_array
最终实现没有继续依赖 texturecube.sample(),而是改成 6 层 texture2d_array。
shadow map 创建方式变为:
MTLTextureDescriptor* desc = [MTLTextureDescriptor
texture2DDescriptorWithPixelFormat:MTLPixelFormatR32Float
width:kShadowMapSize
height:kShadowMapSize
mipmapped:NO];
desc.textureType = MTLTextureType2DArray;
desc.arrayLength = 6;
desc.storageMode = MTLStorageModePrivate;
desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead;
每一层对应一个 face。shadow pass 渲染时显式选择 slice:
id<MTLTexture> faceTex = [shadowMap newTextureViewWithPixelFormat:MTLPixelFormatR32Float
textureType:MTLTextureType2D
levels:NSMakeRange(0, 1)
slices:NSMakeRange(face, 1)];
这样主 pass 采样时也可以手动选择 face 和 UV,完全避免隐式 cubemap 规则不一致的问题。
Shadow Pass:写入归一化距离
每个 face 渲染时,仍然使用 90° 透视投影:
simd_float4x4 lightProj = kmath::perspective(
kmath::radians(90.0f), 1.0f, 0.05f, scene.light.range
);
shadow fragment 不写颜色,而是把“片元到光源的距离 / 光源范围”写入 R32Float:
fragment float4 point_shadow_fragment(ShadowVertexOut in [[stage_in]],
constant ShadowUniforms& shadowUniforms [[buffer(0)]]) {
float distanceToLight = length(in.worldPosition - shadowUniforms.lightPositionAndRange.xyz);
float normalizedDepth = clamp(distanceToLight / shadowUniforms.lightPositionAndRange.w, 0.0, 1.0);
return float4(normalizedDepth, 0.0, 0.0, 1.0);
}
这里仍然使用 depth attachment 做硬件深度测试,保证每个 face 最终留下最近的 caster。
Main Pass:手动选择 Face 和 UV
主 pass 中先计算光源到当前片元的方向:
float3 lightToFragment = in.worldPosition - shadowUniforms.lightPositionAndRange.xyz;
float currentDepth = length(lightToFragment) / shadowUniforms.lightPositionAndRange.w;
然后根据 dominant axis 手动选择 face:
float3 absDir = abs(lightToFragment);
if (absDir.x >= absDir.y && absDir.x >= absDir.z) {
face = lightToFragment.x >= 0.0 ? 0 : 1;
} else if (absDir.y >= absDir.z) {
face = lightToFragment.y >= 0.0 ? 2 : 3;
} else {
face = lightToFragment.z >= 0.0 ? 4 : 5;
}
每个 face 同时定义 forward、right、up,再计算 UV:
float faceDepth = max(dot(forward, lightToFragment), 0.0001);
float2 shadowUV = float2(dot(right, lightToFragment), -dot(up, lightToFragment)) / faceDepth;
shadowUV = shadowUV * 0.5 + 0.5;
最后从 array 的对应 layer 采样:
float closestDepth = pointShadowMap.sample(shadowSampler, shadowUV, face).r;
这一步修复了地面阴影错乱,因为渲染和采样使用了同一套 face/UV 约定。
PCF:在 Face 的 UV 空间里过滤
为了改善围栏、立柱这类薄物体的阴影边缘,保留了 3x3 PCF。不过这次 PCF 不再扰动 cubemap 方向向量,而是在当前 face 的 UV 空间里采样邻域:
float2 texelSize = 2.5 / float2(pointShadowMap.get_width(), pointShadowMap.get_height());
float visibility = 0.0;
for (int y = -1; y <= 1; ++y) {
for (int x = -1; x <= 1; ++x) {
float2 sampleUV = shadowUV + float2(x, y) * texelSize;
float sampleDepth = pointShadowMap.sample(shadowSampler, sampleUV, face).r;
visibility += (currentDepth - bias > sampleDepth) ? 0.0 : 1.0;
}
}
shadow = visibility / 9.0;
这样过滤逻辑也和 face/UV 约定绑定,不会再次引入 cubemap 面旋转问题。
验证
每次修改后都运行构建和离屏测试:
cmake --build build
./build/KisekiEngine --test
最终结果:
Result: 7/7 passed
同时查看离屏 dump 的正常渲染帧,确认:
- 围栏阴影不再受单个透影视锥裁剪。
- 地面阴影位置和光源/物体关系一致。
- 地面只接收阴影,不再作为 caster 污染 shadow map。
- 光源可视化球体不参与 shadow pass。
经验总结
这次问题最有价值的地方不在于某个 bias 数值,而在于确认了几条工程原则。
1. 先确认光源类型和阴影类型是否匹配
单张透视 shadow map 适合 spot light,不适合完整 point light。点光源需要 cubemap shadow 或等价的全向阴影方案。
2. FOV 接近 180° 是方案不稳定的信号
如果一个 shadow camera 需要接近 180° 才能覆盖场景,说明它已经不是一个合理的 shadow camera。即使勉强覆盖,分辨率、深度精度和 PCF 都会变差。
3. Shadow caster 和 receiver 必须显式区分
地面、调试模型、光源可视化模型不应该无条件参与 shadow pass。否则它们会污染 shadow map 或干扰排查。
4. Cubemap face 约定不要靠猜
如果渲染 face 的 view matrix 和采样 API 的内部 face/UV 约定不一致,阴影会错位。使用 6 层 texture2d_array 并手动计算 face/UV,虽然代码多一点,但行为更可控。
5. 图形问题要靠观测工具闭环
这轮排查依赖了离屏测试、debug mode、帧 dump 和几何 bounds/FOV 分析。相比盲目调参数,这些工具更快指向结构性问题。
最终,KisekiEngine 的点光源阴影从“单张透视 shadow map 的临时方案”重构为“按光源类型组织、点光源使用 6 面 shadow map array”的方案。这个结构也为后续补完整的 directional light、spot light、CSM 和 shadow atlas 留出了更合理的空间。