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。

把全方向采样交给硬件。每个 face 都应使用 90 度视场和正确的 up 向量;面矩阵错位会表现为接缝、跳面或某些方向没有阴影。

一个点光源,六个方向的 shadow mapL+x90° face-x90° face+y90° face-y90° face+z90° face-z90° facesample direction → 选择 face → 比较 squared distance
cubemap 不是只存一张正面深度图,而是以点光源为中心覆盖六个方向;采样方向就是从接收点指向光源的向量。

章节将 cubemap 作为六个 shadow map 的容器。采样方向既决定选择哪一面,也决定该面上的纹理坐标;因此 face 的朝向和 light vector 的坐标空间必须统一。

2. 创建与投影:两阶段距离比较

只关注 cast shadow 的对象。动态场景每帧重渲染,静态场景可以预计算;点光源移动时,cubemap 仍需跟着光源更新。

要完成四件事:算 light direction、采样 cubemap、算当前点距离、用 bias 比较两者。

create shadow map → project and compare1 创建六个 cubemap face2 写入distance² from light3 投影receiver → direction4 比较distance² vs mapdepth-only pass 先写可见表面后续每盏灯只给 visible pixels 做 lighting × shadow
创建阶段只关心光源看到的最近距离;投影阶段把接收点方向送入同一 cubemap,再比较两个 squared distance。

让两个阶段使用同一度量。令 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 减少状态切换;算法语义不变。

可以在缺少浮点纹理时保存 32 位深度。浮点 cubemap 代码直观、精度高,但硬件支持和访问速度有限;RGBA pack 更普遍,却增加编码、解码和量化误差。

distance²:一次点积,两个阶段复用floating-point cube直接存 d²,采样后比较高精度格式支持有限 / 访问可能更慢RGBA8 cubepack → sample → unpackRGBA兼容性高需要 pack / unpack 与精度管理单张 cubemap 仍要考虑 6 面显存
写入 squared distance 是一次点积,避免平方根;存储格式则在精度、硬件支持、显存和 shader 指令之间取舍。

选择格式时还要计算六面显存。一个 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 与逐灯累加

是多光源场景的重要优化。完成一次深度预处理后,每盏灯的 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 周围散开,再平均比较结果,从视觉上减轻硬边和 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 不够?

可交互
point light → dynamic receiverd² float cubemapreceiver surfacepoint lightcaster4 samples / soft edge+x-x+y-y+z-zcompare: current distance² − bias vs cubemap sample

观察:降低 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 方向周围多次比较并平均以近似软阴影的过滤方式。

资料与写作方式声明

本章以GPU Gems 1 · Chapter 12. Omnidirectional Shadow Mapping公开完整正文核定章节范围、事实坐标与时代语境,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…