GPU Gems 1 · Chapter 12. Omnidirectional Shadow Mapping
用 cubemap 为点光源覆盖六个方向,写入 squared distance,再在接收阶段比较投影距离,构成全方向动态阴影管线。
学习目标
- 能解释点光源为什么需要 cubemap 覆盖六个方向,并把六个 face 与采样方向对应起来
- 能实现创建阶段写入光源到遮挡物的 squared distance,以及投影阶段的方向采样、bias 和深度比较
- 能比较浮点 cubemap 与 RGBA 深度打包的精度、兼容性、显存和 shader 成本
- 能修改实验中的硬/软阴影、cubemap 分辨率、bias 与过滤半径,判断 acne、漏光和性能变化
直觉引入:让点光源在黑暗房间里转一圈
想象一盏裸灯放在房间中央。它照向天花板、地板和四面墙,任何方向都可能产生遮挡。平面 shadow map 像一台只朝前拍摄的相机;要让它回答光从哪个方向到达这个点,需要把相机围着光源转六次,并把六张深度照片装进一个 cubemap。
创建 cubemap 时记录的是每个方向遇到的最近遮挡距离。渲染接收物体时,计算接收点到光源的方向与距离,沿该方向取出最近遮挡距离并比较。如果接收点更远,它被挡住;如果更近,它仍然可见。
1. 从普通 shadow mapping 到 point light
↡通过投影深度纹理比较光源与接收点距离来判断遮挡的阴影算法 的基本流程是:先从光源视角渲染深度纹理,再在正常相机的片元 shader 中把当前点送进 light space,最后比较当前深度和纹理深度。
↡从一个位置向所有方向发光、因此需要在光源周围覆盖六个视锥的光源类型 与方向光不同:方向光可以用一张正交投影图,点光源必须根据接收点的 light direction 选择 cubemap face。
↡以光源为中心、包含正负 x、y、z 六个 90 度方向面的立方体纹理 把全方向采样交给硬件。每个 face 都应使用 90 度视场和正确的 up 向量;面矩阵错位会表现为接缝、跳面或某些方向没有阴影。
章节将 cubemap 作为六个 shadow map 的容器。采样方向既决定选择哪一面,也决定该面上的纹理坐标;因此 face 的朝向和 light vector 的坐标空间必须统一。
2. 创建与投影:两阶段距离比较
↡以光源为视点向六个 cubemap 面渲染遮挡物,写入每个方向上的最近距离信息的阶段 只关注 cast shadow 的对象。动态场景每帧重渲染,静态场景可以预计算;点光源移动时,cubemap 仍需跟着光源更新。
↡正常相机渲染接收物体时,根据接收点到光源的方向采样 cubemap 并比较距离的阶段 要完成四件事:算 light direction、采样 cubemap、算当前点距离、用 bias 比较两者。
↡用点积表示距离平方、避免平方根并在创建和投影阶段复用的度量 让两个阶段使用同一度量。令 v 为光源到当前点的向量,则 d2 = dot(v, v)。这是一次点积,不需要 square root;代价是远距离数值范围更大,需要关注纹理精度。
vec3 vLight = worldPosition - lightPosition;
float fDistSquared = dot(vLight, vLight);
return fDistSquared;创建阶段把 fDistSquared 写入当前 cubemap face;投影阶段用接收点的 fDistSquared 减去 depth bias,再与 cubemap sample 比较。两边必须使用同一坐标空间和尺度。
3. 六面渲染与深度格式
每个 cubemap face 都是一次 90 度的光源视角渲染,方向为 +x、-x、+y、-y、+z、-z。实现可以用六次普通 pass,也可以用硬件的 layered rendering 减少状态切换;算法语义不变。
↡把高精度深度值拆到 RGBA 颜色通道,采样后按相同权重恢复的兼容性存储方案 可以在缺少浮点纹理时保存 32 位深度。浮点 cubemap 代码直观、精度高,但硬件支持和访问速度有限;RGBA pack 更普遍,却增加编码、解码和量化误差。
选择格式时还要计算六面显存。一个 1024×1024、32 位、六面 cubemap 还需要额外深度缓冲;分辨率提升会改善阴影质量,也会增加创建渲染、纹理带宽和显存成本。
vec4 packDistance(float d2) {
vec4 scale = vec4(1.0, 256.0, 65536.0, 16777216.0);
return fract(d2 * scale);
}
float unpackDistance(vec4 packed) {
vec4 weight = vec4(1.0, 1.0 / 256.0,
1.0 / 65536.0, 1.0 / 16777216.0);
return dot(packed, weight);
}这里的 pack 是示意,真实实现还要处理通道精度、范围归一化和边界溢出。浮点与 RGBA 两条路径都必须使用同一 bias 和距离尺度。
4. z-only pass 与逐灯累加
↡先只把不透明可见物体写入深度缓冲,再让后续光照与阴影 pass 只处理可见像素的预处理阶段 是多光源场景的重要优化。完成一次深度预处理后,每盏灯的 shadow-writing 与 shading pass 都可以用深度测试跳过隐藏片元;透明对象不应写入这个 pass。
一盏灯的逻辑可以压缩成:
for (int light = 0; light < lightCount; light++) {
for (int face = 0; face < 6; face++) {
renderCastersToCubeFace(light, face);
}
for (Object object : visibleObjects) {
shadeWithOmniShadow(object, light);
}
}第一层循环填充 cubemap,第二层循环投影并累加 lighting × shadow。点光源数量增加时,六面创建成本和逐灯 shading 成本都会增加;静态 cubemap 缓存可以把创建阶段移出每帧热路径。
5. projection 与 bias:先判断遮挡,再决定颜色
当前点的平方距离减去 bias 后,与对应方向的 cubemap 距离比较。如果当前值更大,点在遮挡物后方;如果 bias 太小,自身深度误差会产生 acne;如果 bias 太大,接触阴影会被推开并漏光。
float current = dot(vLight, vLight) - depthBias;
float mapDistance = sampleCubeDistance(-vLight);
float shadow = current > mapDistance ? 1.0 : 0.0;
vec3 litColor = lightingColor * (1.0 - shadow);这个片元段只展示 hard shadow。真实项目还要考虑 cube face 接缝、过滤、半精度与深度范围;排错时应分别可视化方向、当前 d2、map d2 和最终 shadow coefficient。
6. 软阴影只是多方向比较的近似
↡在 cubemap 方向周围取多个偏移方向,分别做遮挡比较并平均结果来近似软边的过滤方法 不是物理面积光源。它把采样方向在 cubemap 周围散开,再平均比较结果,从视觉上减轻硬边和 aliasing;它没有显式建模遮挡者、接收者与光源面积。
float shadow = 0.0;
for (int i = 0; i < 4; i++) {
vec3 direction = -vLight + filterOffsets[i];
float mapDistance = sampleCubeDistance(direction);
shadow += current < mapDistance ? 0.0 : 0.25;
}过滤半径越大,边缘越软,但需要更多 fetch 才能避免 banding;把方向计算移到顶点阶段可以节省片元指令。多次采样要与 cubemap 分辨率、bias 和缓存命中一起评估。
7. 实验:让光源绕着遮挡物走一圈
猜一猜:先切换到 RGBA pack,再把 bias 拉到最小;你应该看到精度和自遮挡伪影变得更敏感。切到 soft 后增大 filter radius,边界会变软,但 sample 数和纹理 fetch 也会增加。最后隐藏六面 cubemap,观察画面仍保留接收面,但无法直观看见方向覆盖。
Omnidirectional Shadow Lab
先猜:只把光源绕物体转一圈,为什么一张平面 shadow map 不够?
观察:降低 bias 可能出现 acne;增大 filter radius 会变软但增加 fetch,RGBA pack 需要额外解码。
实验将四类工程选择分开:storage 决定深度格式,shadow mode 决定单次还是多次比较,resolution 表示六面纹理预算,bias 与 filter radius 分别影响接触稳定性和软边宽度。这样排查时可以先判断是 face 方向错、距离格式错,还是过滤参数错。
小结:全方向阴影是 cubemap 加距离比较
- 点光源向所有方向发光,必须用六个 cubemap face 覆盖正负 x、y、z。
- 创建阶段从光源视角写入遮挡物的 squared distance,投影阶段用接收点距离与对应方向的 map distance 比较。
- 浮点 cubemap 直观且精度高;RGBA depth packing 兼容性好,但需要一致的 pack/unpack。
- z-only pass 先建立可见深度,后续逐灯执行 shadow map 与 lighting 的 multipass。
- 多方向采样可以近似软阴影,但样本、过滤半径和 bias 都会影响质量与性能。
练习
问题 1|改 Demo 代码 把实验从 float d2 切换为 RGBA pack,再把 cubemap resolution 调低。记录阴影边界、显存预算和可能出现的量化误差。
问题 2|推导题 创建阶段记录到的遮挡距离平方是 9.0,接收点距离平方是 9.2,depth bias 为 0.1。按当前距离平方减 bias 后的比较,接收点是否在阴影中?
问题 3|独立实现题 为一个动态点光源实现六面 shadow-map creation 和 projection pass。除了平均帧时间,你会记录哪些指标来定位性能瓶颈?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- shadow mapping
- 通过投影深度纹理并比较光源到接收点距离来判断遮挡的算法。
- point light
- 从一个位置向所有方向发光、需要全方向阴影覆盖的光源。
- omnidirectional cubemap
- 以光源为中心、覆盖正负 x、y、z 六个方向的立方体纹理。
- shadow-map creation
- 从光源视角向六面 cubemap 写入遮挡距离的阶段。
- shadow projection
- 接收阶段按 light direction 采样 cubemap 并比较距离的阶段。
- squared distance
- 用点积表示距离平方、避免平方根并在创建和投影阶段复用的度量。
- RGBA depth packing
- 把深度拆到 RGBA 颜色通道并在采样后恢复的存储方案。
- z-only pass
- 先只更新可见物体深度、供后续逐灯 pass 跳过隐藏片元的预处理。
- omnidirectional PCF
- 在 cubemap 方向周围多次比较并平均以近似软阴影的过滤方式。