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 填充都可能变成瓶颈。

先分类像素,再累加每盏灯1 基础光照ambient + emissive2 标记阴影volume → stencil3 单灯光照diffuse + specular4 累加画面additive blend深度先写好,后续光照 pass 才能少 overdraw
shadow volume 不直接画黑色几何,而是先给像素分类,再让每盏灯只给未被遮挡的像素贡献光照。

2. multipass rendering 先写深度,再逐灯累加

把每盏灯的 diffuse 与 specular 拆开。第一 pass 只计算 ambient 与 emissive,并写入深度;随后每个灯依次清空 stencil、标记阴影、计算非阴影区域的光照,最后用加法混合加入帧缓冲。

这样做有两个好处:基础 pass 预先建立深度,后续光照 pass 可以避免无效 overdraw;每个灯也能拥有自己的颜色、衰减和 shadow volume。原书建议基础 pass 使用替换式混合,灯光 pass 使用加法混合,并保持深度测试以避免后续 pass 的 z-fighting。

3. 从 silhouette edge 构造封闭体积

阴影投影的边界来自 。对每条网格边查看它的两个相邻面:若 light-facing 状态不同,这条边就是需要沿远离光源方向挤出的轮廓边。其他内部边不会改变阴影边界,挤出它们只会增加几何与填充。

点光源的边通常要挤到足够远的位置,并由两端的 与远端的 dark cap 封闭。方向光的所有光线平行,远端在无穷远,很多情况下可以采用 uncapped 路径省去两个 cap;但前提是摄像机没有被该体积包住,并且 stencil 计数方向已经相应调整。

从 silhouette edge 挤出 shadow volumepoint lightcastershadow volume side挤出到远处或有限光照范围light cap靠近光源的一端dark cap背面向无穷远
阴影体积只需要挤出轮廓边;光源看到的正背面交界,就是决定阴影侧面的 silhouette edge。

网格边还要维护一致的方向。构造侧面 quad 时,顶点 winding 必须让法线朝向体积外部;如果相邻面索引的方向不一致,debug 时会看到 front/back 面颜色交错,最终模板计数会在裂缝处漏掉。

4. stencil buffer 把交点变成阴影答案

的关键是模拟射线交点计数。对每个可见像素 P,沿负视线方向发射射线;射线进入体积时增加一种计数,离开时抵消它。最终为零表示没有被包住,非零表示像素位于阴影体积内部。

设射线与 shadow volume 的面交点法线为 M,视线向量为 V。进入与离开的判断可以用 dot(M,V) 的正负区分,掠射时的零值则忽略。核心判定写成:

Ptext在阴影中iffNenter>NexitP\\text{ 在阴影中}\\iff N_{enter}>N_{exit}

这个式子在说:只要进入次数多于离开次数,射线在体积里结束,像素就被遮挡;计数实现把复杂的几何关系交给硬件 stencil。

用进入数 − 离开数判断像素是否在体积内亮像素:计数归零front −1back +1−1 + 1 = 0射线穿过一进一出暗像素:计数非零front −1back +1front −1−1 + 1 − 1 = −1射线在体积内结束
stencil buffer 把每个像素的空间交点计数变成硬件计数:进入与离开相等就亮,不相等就暗。

先渲染体积的 back/front 面,只有当体积面深度测试失败时才更新 stencil。这样计算的是从可见表面向远处的交点,通常需要 caps 来保证体积封闭。

如果能保守确认摄像机不在体积内,可以用 ,让通过深度测试的 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

是阴影体积的 Achilles heel。一个点光源的体积会随着距离变大,远处的多边形即使不会照亮任何对象,也可能消耗 stencil 与光照片元。

第一类优化是 :光源衰减到零的半径外不可能有有效光照,因此把 dark cap 延伸到这个球体边界即可。第二类是把光照范围投影到屏幕,用矩形 scissor 做 XY clipping;第三类是根据深度缓冲和光源范围设置 z-bounds,只处理位于前后深度界间的可见像素。

把无效的体积像素挡在门外方向光uncapped省 light / dark cap点光源finite volume限制到光照半径屏幕范围XY + Z bounds少画无效片元优化目标:降低 fill rate,而不是改变阴影判定
shadow volume 的主要瓶颈是填充率;先利用光源类型和范围缩小需要标记的像素,再考虑更复杂的几何优化。

这些优化要从可证明的范围开始。裁剪太激进会产生阴影断裂或亮边;裁剪太松则无法节省填充。建议同时记录 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 可以消失?

可交互
模板阴影视口暗:stencil 2Lfront/back stencil 操作 · shadow side 填充范围

观察:方向光可省 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
每帧要处理的屏幕片元数量;体积覆盖越大,填充压力通常越高。

资料与写作方式声明

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

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

讨论

评论区加载中…