GPU Gems 1 · Chapter 9. Efficient Shadow Volume Rendering
从 silhouette edge 构造 shadow volume,用 stencil buffer 计数阴影交点,再通过 multipass rendering 高效累加每盏灯。
学习目标
- 能解释 shadow volume 如何把“哪些像素被遮挡”转成 stencil buffer 中的计数
- 能实现从 silhouette edge 挤出体积,并比较 z-fail 与 z-pass 的适用条件
- 能修改实验中的光源类型、体积范围和裁剪参数,判断 fill rate 与几何成本的变化
- 能回答:为什么方向光可以省略 caps,而点光源常常需要有限体积或屏幕范围裁剪?
直觉引入:先问一个像素“有没有穿过阴影”
想象你从屏幕上的一个像素沿着视线向远处发射一条线。它如果穿过由遮挡物投出的封闭区域,就说明这条线上的可见点被挡住;如果进出次数相等,就说明它最后回到了区域外。我们不需要把阴影涂成黑色,只要把这个“穿过几次”的答案记下来。
本章解决的是实时点光源、聚光灯和方向光的硬阴影。核心做法是把遮挡物的轮廓挤出成一个体积,把每个像素的进入和离开次数写到 stencil,再按每盏灯分 pass 计算光照。难点不在黑色,而在可靠的分类和过大的填充范围。
1. shadow volume 适合什么场景
↡由遮挡物轮廓沿光线方向挤出、包住所有阴影区域的三维几何体 能产生锐利、逐像素的硬阴影,适用于点光源、聚光灯和方向光。阴影接收者不一定是普通网格,也可以是 billboard、粒子系统或带深度的预渲染场景;但遮挡者必须有与真实轮廓相符的网格,否则透明树叶的方形面就会投出错误的实心阴影。
它也不是所有场景的最佳解。少量向下的方向光、俯视摄像机和简单遮挡物会让体积很划算;多个点光源位于复杂室内场景中时,体积会覆盖大量像素,CPU 的轮廓处理与 GPU 的 stencil 填充都可能变成瓶颈。
2. multipass rendering 先写深度,再逐灯累加
↡把基础光照和每个灯的贡献拆成多个渲染 pass,再把结果相加的渲染组织方式 把每盏灯的 diffuse 与 specular 拆开。第一 pass 只计算 ambient 与 emissive,并写入深度;随后每个灯依次清空 stencil、标记阴影、计算非阴影区域的光照,最后用加法混合加入帧缓冲。
这样做有两个好处:基础 pass 预先建立深度,后续光照 pass 可以避免无效 overdraw;每个灯也能拥有自己的颜色、衰减和 shadow volume。原书建议基础 pass 使用替换式混合,灯光 pass 使用加法混合,并保持深度测试以避免后续 pass 的 z-fighting。
3. 从 silhouette edge 构造封闭体积
阴影投影的边界来自 ↡从光源看一面朝向光、另一面背向光的共享边。对每条网格边查看它的两个相邻面:若 light-facing 状态不同,这条边就是需要沿远离光源方向挤出的轮廓边。其他内部边不会改变阴影边界,挤出它们只会增加几何与填充。
点光源的边通常要挤到足够远的位置,并由两端的 ↡封闭 shadow volume、靠近光源一端的几何盖子 与远端的 dark cap 封闭。方向光的所有光线平行,远端在无穷远,很多情况下可以采用 uncapped 路径省去两个 cap;但前提是摄像机没有被该体积包住,并且 stencil 计数方向已经相应调整。
网格边还要维护一致的方向。构造侧面 quad 时,顶点 winding 必须让法线朝向体积外部;如果相邻面索引的方向不一致,debug 时会看到 front/back 面颜色交错,最终模板计数会在裂缝处漏掉。
4. stencil buffer 把交点变成阴影答案
↡保存每个像素整数计数,并在模板测试阶段按正反面更新的缓冲区 的关键是模拟射线交点计数。对每个可见像素 P,沿负视线方向发射射线;射线进入体积时增加一种计数,离开时抵消它。最终为零表示没有被包住,非零表示像素位于阴影体积内部。
设射线与 shadow volume 的面交点法线为 M,视线向量为 V。进入与离开的判断可以用 dot(M,V) 的正负区分,掠射时的零值则忽略。核心判定写成:
这个式子在说:只要进入次数多于离开次数,射线在体积里结束,像素就被遮挡;计数实现把复杂的几何关系交给硬件 stencil。
↡利用深度测试失败时的正反面操作完成体积计数的方法,适合摄像机可能处在体积内部的场景 先渲染体积的 back/front 面,只有当体积面深度测试失败时才更新 stencil。这样计算的是从可见表面向远处的交点,通常需要 caps 来保证体积封闭。
如果能保守确认摄像机不在体积内,可以用 ↡从可见表面向摄像机方向计数、常可省略 caps 的 stencil 路径,让通过深度测试的 front/back 面直接增减 stencil。它可以省掉 caps,但摄像机是否被体积包住必须单独检查;否则起始计数会偏移。
5. 用代码对照理解 stencil 操作
下面是把标记 pass 的意图压缩成 OpenGL 风格伪代码。颜色写入关闭、深度写入关闭,体积只留下 stencil 副作用。
glColorMask(false, false, false, false);
glDepthMask(false);
glEnable(GL_STENCIL_TEST);
glStencilMask(~0);
glEnable(GL_CULL_FACE);
// depth-fail:正面深度失败减少,背面深度失败增加
glCullFace(GL_FRONT);
glStencilOp(GL_KEEP, GL_DECR_WRAP, GL_KEEP);
drawShadowVolumeFrontFaces();
glCullFace(GL_BACK);
glStencilOp(GL_KEEP, GL_INCR_WRAP, GL_KEEP);
drawShadowVolumeBackFaces();这段代码把 volume 的两类交点写进同一个整数缓冲。项目支持 z-pass 时,可以把操作改成 depth-pass 的增减并省掉 caps;不要只改 stencilOp 而忘记开启 stencil test 和 stencil mask,否则写入会被硬件静默忽略。
在灯光 pass 中,模板测试反过来承担“只画亮处”的条件。基础光照已写深度,后续 pass 关闭深度写入、使用 GL_LEQUAL,并用加法混合把通过 stencil 的灯光加入画面。
glColorMask(true, true, true, true);
glDepthMask(false);
glDepthFunc(GL_LEQUAL);
glStencilFunc(GL_EQUAL, 0, ~0);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
glBlendFunc(GL_ONE, GL_ONE);
drawDiffuseAndSpecular(light);6. 优化几何和 fill rate
↡渲染管线每帧需要处理的屏幕片元数量;shadow volume 过度覆盖时常成为主要瓶颈 是阴影体积的 Achilles heel。一个点光源的体积会随着距离变大,远处的多边形即使不会照亮任何对象,也可能消耗 stencil 与光照片元。
第一类优化是 ↡把点光源的无限延伸体积截断到光照半径内的有限几何体:光源衰减到零的半径外不可能有有效光照,因此把 dark cap 延伸到这个球体边界即可。第二类是把光照范围投影到屏幕,用矩形 scissor 做 XY clipping;第三类是根据深度缓冲和光源范围设置 z-bounds,只处理位于前后深度界间的可见像素。
这些优化要从可证明的范围开始。裁剪太激进会产生阴影断裂或亮边;裁剪太松则无法节省填充。建议同时记录 stencil pass 的片元数量、光照 pass 的帧时间、轮廓边数和每盏灯的有效像素面积,再决定是否增加更复杂的几何处理。
7. 调试:先让看不见的体积显形
调试时可以暂时打开颜色写入,把 shadow volume 用加法透明色画出来;再用一种颜色画 front faces,另一种颜色画 back faces。这样可以直接观察轮廓侧面是否闭合、winding 是否正确、caps 是否覆盖到预期的位置。
模型裂缝也要在 CPU 侧断言:每条内部边应该正好有两个相邻面。若有未焊接的顶点,体积会在空中留下阴影 streak;这类错误不是 stencil 公式能修好的,必须回到网格拓扑或 welding 工具。
猜一猜:把实验切到 directional 后隐藏 shadow volume,为什么画面仍保留一个简化的无限投影?再把裁剪范围降到 0.25,先变化的是阴影形状、模板值,还是无效填充面积?
Shadow Volume Lab
先猜:把光源改成方向光,哪些 cap 可以消失?
观察:方向光可省 caps;缩小裁剪范围能减少体积覆盖的片元。
实验中的 front/back 调试开关与 volume 显示开关是故障注入:它们让你先看几何和计数,再回到最终的黑白阴影。真实引擎应在发布构建中关闭 debug color,并根据摄像机是否被体积包住选择 depth fail 或 z-pass。
小结:阴影体积是像素分类器,不是黑色贴片
- shadow volume 由 silhouette edge 挤出,封住所有可能的阴影区域。
- stencil buffer 用进入/离开计数分类每个可见像素。
- depth fail 更稳健,z-pass 在可证明的条件下可省 caps。
- multipass rendering 让每盏灯只累加通过模板测试的光照。
- finite volume、XY clipping 和 z-bounds 共同降低 fill rate。
练习
问题 1|改 Demo 代码 将实验在 z-fail 与 z-pass 之间切换,再分别显示和隐藏 shadow volume。说明两种计数模式的视觉结果与适用前提。
问题 2|推导题 某个像素沿视线遇到 front、back、front 三次交点。按“进入减少、离开增加”的示意操作计算 stencil 净值,并判断它是否位于体积内。
问题 3|独立实现题 为点光源增加 finite volume 和 XY clipping。除了截图,你会记录哪些指标来确认优化没有改变阴影正确性?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- shadow volume
- 从遮挡物轮廓沿光线方向挤出的三维区域,用来标记哪些像素被挡光。
- silhouette edge
- 从光源看一面朝向光、另一面背向光的共享边,它决定阴影体积的侧面。
- stencil buffer
- 每个像素的整数计数区,模板操作可以在不改颜色的情况下更新它。
- multipass rendering
- 把基础光照和每盏灯拆成多个 pass,再将光照贡献累加。
- depth fail
- 只有体积面深度测试失败时才更新 stencil 的计数方法。
- z-pass
- 体积面通过深度测试时直接更新 stencil 的路径,满足条件时可省 caps。
- light cap
- 封闭 shadow volume、靠近光源的一端几何盖子。
- finite volume
- 把无限延伸的点光源体积截断到有效光照半径内的有限几何体。
- fill rate
- 每帧要处理的屏幕片元数量;体积覆盖越大,填充压力通常越高。