Shadow Map
全库目录221
本文目录18
一块表面是否受光,取决于光源与它之间有没有遮挡。Shadow Map 把这个问题转成两份深度的比较:先从光源看场景,记录最近表面,再检查当前接收点是否位于它后面。比较得到可见性,材质与光源随后决定这部分光有多亮。
Shadow Map 流程
生成
从光源视角渲染场景,把最近深度写入 Shadow Map。方向光使用正交投影,Spot Light 常使用透视投影,Point Light 可以使用 Cubemap 或其他多面投影。
查询
把当前表面点变换到 Light Clip/Shadow Texture Space,得到:
- Shadow UV;
- 当前接收点深度 。
读取 Shadow Map 的遮挡深度 。在近处深度较小的常规 Z 约定下,如果 明显大于 ,说明光源和接收点之间有更近表面,当前点在阴影中。
过滤离散可见性
深度比较与过滤顺序
假设一个采样落在遮挡物边缘,两侧深度分别属于近处遮挡物和远处背景。直接插值得到的是一个现实中不存在的中间深度。拿它与接收深度比较,会产生错误可见性。
PCF 的做法是:
- 对邻域每个深度分别做比较;
- 得到 0/1 或比较结果;
- 过滤这些可见性结果。
硬件 Comparison Sampler 可以对相邻 Texel 的比较结果做过滤,而不是先过滤原始深度。
PCF 的含义
Percentage-Closer Filtering 产生的是过滤后的可见比例。固定采样半径会得到近似固定宽度的柔边,但它主要是在抑制 Shadow Map 的离散锯齿,不是真正根据面积光源几何得到半影。
采样核越大:
- 边缘越软;
- 采样成本越高;
- 阴影细节更容易丢失;
- Bias 和漏光问题更明显。
Poisson Disk、旋转采样核和时域抖动可以减少规则图案,但会引入噪声或时间稳定性问题。
硬件比较采样器可以把深度比较和过滤组合起来。下面的 HLSL 代码执行一个 3x3 PCF 核,shadowCoord.xy 已在 ,shadowCoord.z 与 Shadow Map 使用同一深度约定:
float SampleShadowPCF(float3 shadowCoord, float bias)
{
uint width, height;
ShadowMap.GetDimensions(width, height);
float2 texel = 1.0 / float2(width, height);
float visibility = 0.0;
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x)
visibility += ShadowMap.SampleCmpLevelZero(
ShadowSampler,
shadowCoord.xy + float2(x, y) * texel,
shadowCoord.z - bias);
return visibility / 9.0;
}本段为HLSL机制函数,资源声明为 Texture2D<float> ShadowMap 与 SamplerComparisonState ShadowSampler。约定常规Z、清除深度1、比较 LESS_EQUAL,正bias从接收深度减去;反向Z需同步改变比较方向与偏置符号。线性比较采样的一次查询可比较相邻4个纹素,因此这里是9次查询,覆盖足迹会重叠,并非简单的9个独立点深度。光锥外、UV外和级联外的回退由调用端处理;边框颜色也要与受光约定一致。
PCSS
Percentage-Closer Soft Shadows 用三个步骤近似面积光半影。
1. Blocker Search
在接收点投影附近搜索深度小于 的样本。它们是可能挡住光源的 Blocker。
计算平均 Blocker 深度 。没有找到 Blocker 时,接收点通常视为完全受光。
2. 估计 Penumbra
平行平面近似下,半影大小取决于接收点与遮挡物的间距:
是光源尺寸。接收面离 Blocker 越远,半影越宽。
具体公式需要根据光源投影和深度空间转换。不能直接把非线性 Shadow Depth 塞进比例式。
相似三角形推导。
它来自相似三角形,理解推导才能判断它何时失效。
把光源看作一个宽度为 的面光源,遮挡物是一块平行于光源的不透明平板,接收面也与之平行。沿光源两端各引一条经过遮挡物边缘的直线,它们在接收面上圈出的区域就是半影。
设光源到遮挡物的距离为 、光源到接收面的距离为 ,由相似三角形:
即:
如果 、 已还原成同一光源空间中的正线性距离,便得到前面的形式。方向光的角半径模型还需要单独推导,不能直接套用有限距离面光源的分母。分母是光源到遮挡物的距离,不是遮挡物到接收面的距离——这是容易记反的地方。
两个直接推论:遮挡物贴近接收面时 ,半影趋近于零,这就是接触硬化;遮挡物贴近光源时 很小,半影急剧放大。
适用前提。
推导用到了三个假设,实际场景中都可能不成立:
平行平面。遮挡物与接收面都被当作平行于光源的平面。倾斜的接收面上,半影宽度沿表面变化,单一半径无法准确表达。
单一遮挡深度。Blocker Search 得到的是平均深度。当一个像素同时被远近两个物体遮挡时,平均值可能落在两者之间,对应的半影对谁都不正确。
深度线性可比。比例式中的 必须是线性距离。Shadow Map 存的通常是非线性深度,反向 Z 下方向还相反。必须先转换到线性空间,否则半影宽度会出现随距离系统性偏大或偏小的错误。
采样半径换算。
是世界空间的宽度,PCF 需要的是 Shadow Map 上的 UV 半径。转换依赖光源的投影方式:
正交投影(方向光)下,整张 Shadow Map 覆盖固定的世界范围 ,转换是常数比例:
这里的 都是完整宽度,采样半径取半宽;若实现把 lightSize 定义为半径,则必须整套改用半径约定。有限距离的透视光源在接收深度 处横向覆盖宽度为 ,所以 。方向光应先由角半径与遮挡间距估计世界半影,再使用正交覆盖换算。
级联阴影中每一级的 不同,同一个 在各级对应的 UV 半径也不同。这是级联过渡处软硬程度突变的常见原因——必须逐级换算,不能全局共用一个系数。
采样半径还需要设上限。半径过大时,固定的采样数会导致采样点过于稀疏,产生条带状噪声;此时应当同步增加采样数,或接受半影宽度被截断。
3. Variable-radius PCF
把估算半影转换为 Shadow Map UV 中的过滤半径,再执行 PCF。
PCSS 是 Contact-hardening Shadow 的近似。它仍会受到 Blocker Search 样本数、深度不连续、接收面角度和复杂遮挡关系影响。
Shadow Acne
由于深度精度、采样和表面斜率,接收面可能错误地与自己的 Shadow Depth 比较失败,出现条纹。
常见 Bias:
- Constant Depth Bias;
- Slope-scaled Bias;
- Normal Bias;
- Receiver Plane Depth Bias。
Bias 太小会 Acne,太大会 Peter Panning,让阴影与物体脚底分离。Normal Bias 还可能让薄物体漏光或改变阴影形状。
偏置改变的输入
普通Z下,真实接收深度0.5002与贴图0.5000比较会被判阴影;接收端减0.0003后成为0.4999,比较通过。若这份0.0002差值来自同一斜面的采样误差,偏置修复自阴影;若它来自真正贴近表面的遮挡物,相同偏置又会抹去接触阴影。
常量偏置给全部深度相同数值变化,投影深度的非线性使其世界距离效果随位置变化。斜率偏置根据深度相对屏幕的变化量增大偏移,照顾斜面跨纹素的误差。Normal Bias先沿法线移动世界接收点或投射几何,再投影;它也会移动UV,所以薄片轮廓和阴影形状可能改变。Receiver Plane方法利用接收面深度梯度,为每个PCF邻居估计该平面应有的深度,再比较残差,避免仅用中心深度解释整个核。实际API的深度偏置量化和符号需按后端核对。
阴影资源与投影方案
CSM
级联阴影贴图(Cascaded Shadow Maps,CSM)为方向光准备多张不同覆盖范围的 Shadow Map。近处 Cascade 覆盖小、世界空间 Texel 密度高;远处 Cascade 覆盖大,用较低精度维持可见距离。
单张方向光 Shadow Map 必须同时容纳相机附近和远处视锥。透视相机中,近处物体占屏幕面积大,却只得到和远处相同的光空间 Texel 密度,因此近景最容易出现锯齿和游动。
1. 分割相机视锥
设相机近远平面为 ,Cascade 数量为 ,第 个分割位置可以使用均匀分割:
对数分割会把更多范围留给近处:
实际常用 Practical Split 在两者之间插值:
越大,近处 Cascade 越密。分割距离通常按正的观察空间深度保存。右手观察空间常用 ,不能直接拿非线性的 Depth Buffer 值比较。
2. 计算每层光空间矩阵
每个 都形成一个截断视锥。计算步骤是:
- 用该层的近远距离建立相机投影矩阵。
- 反投影 NDC 的八个角点到世界空间。
- 计算角点中心,建立方向光 View Matrix。
- 把八个角点变换到光空间。
- 对光空间角点求包围范围,建立正交投影。
- 沿光照 Z 方向扩展范围,容纳可能投影进层内的 Caster。
NDC 深度范围取决于 API。OpenGL 默认使用 ,Direct3D 和常见 Vulkan 配置使用 。反投影时必须与实际投影矩阵约定一致。
下面的伪代码表达矩阵构造的数据流:
Matrix BuildCascadeMatrix(float splitNear, float splitFar)
{
Matrix sliceProj = Perspective(fov, aspect, splitNear, splitFar);
Vector4 corners[8] = InverseProjectNdcCorners(sliceProj * view);
Vector3 center = Average(corners);
Matrix lightView = LookAt(center - lightDir * casterRange,
center, stableUp);
Bounds bounds = FitLightSpaceBounds(corners, lightView);
bounds.ExpandDepth(casterRange);
Matrix lightProj = Orthographic(bounds.min, bounds.max);
return lightProj * lightView;
}casterRange 不能只按接收者视锥取值。视锥外的树或建筑仍可能把阴影投进视锥,过小会丢失阴影,过大则浪费深度精度。
3. 保存每层深度
各层可以使用独立纹理,也可以使用同尺寸、格式和 Mip 规则一致的 Texture Array。Array Layer 让 Shader 通过层索引采样,资源绑定更集中。
深度生成可以逐层提交 Draw,也可以使用 Layered Rendering。LearnOpenGL 的 CSM 示例用 Geometry Shader 的 gl_Layer 把图元写入不同 Array Layer。现代引擎也会通过多视图、实例化或逐层 Pass 完成,具体方式取决于 API、硬件和场景提交成本。
光空间矩阵数组适合放在 UBO 或 Constant Buffer。CPU 结构、Shader 布局和矩阵主次序必须一致。
4. 选择 Cascade
像素先计算正的观察空间深度,再找到包含它的分层区间。随后使用对应光空间矩阵和 Texture Array Layer:
int SelectCascade(vec3 positionWS)
{
float viewDepth = -(view * vec4(positionWS, 1.0)).z;
int layer = cascadeCount - 1;
for (int i = 0; i < cascadeCount - 1; ++i) {
if (viewDepth < cascadeSplits[i]) {
layer = i;
break;
}
}
return layer;
}这段代码假定右手观察空间中相机前方 Z 为负。引擎使用其他约定时,viewDepth 的符号需要同步调整。
5. 处理边界过渡
相邻 Cascade 的投影、Bias 和过滤结果不同。硬切换会在分割平面出现明显跳线。常见做法是在边界附近定义一段重叠区,同时采样相邻两层:
由观察空间深度在过渡区中的位置得到。构建两层矩阵前,需要把各自的接收视锥扩展到整个混合带,并重新确定投射物覆盖与过滤边界;只有两份深度图都包含该接收区域,双层查询才有效。过渡越宽,双层采样区域越大;过窄则难以隐藏差异。两层的世界空间过滤半径也应接近,否则混合仍会看到软硬变化。
6. Bias 和过滤尺度
同样的 Shadow UV 偏移在不同 Cascade 中代表不同世界距离。每层需要按正交投影范围和纹理分辨率计算世界空间 Texel 尺寸,再据此调整:
- Constant/Slope Bias;
- Normal Bias;
- PCF 或 PCSS 采样半径;
- Cascade 过渡区宽度。
直接复用同一数值会让近层 Peter Panning,或让远层 Acne 和锯齿加重。
7. 稳定化和 Texel Snapping
紧致包围盒会随着相机旋转和移动持续改变,导致世界点映射到不同 Shadow Texel,画面出现阴影游动。稳定化通常包含:
- 使用包围球或固定尺度确定 Cascade 的 XY 范围;
- 把光空间投影中心吸附到 Shadow Texel 网格;
- 固定方向光基底,避免 Up Vector 在临界方向翻转;
- 对分割距离和投影范围保持确定性。
若正交投影宽度为 、阴影分辨率为 ,一个 Texel 对应的光空间长度是:
应先在固定方向光基底下表达世界坐标,再将XY投影中心除以 、取整后乘回去。若先让光源View跟随中心平移、再对已经接近零的局部中心取整,就失去了固定的吸附基准。吸附还可能把覆盖范围推移半个纹素,包围范围应留出相应余量。包围球通常更稳定,紧致 AABB 的分辨率利用率更高。工程实现需要在稳定性和有效分辨率之间取舍。
8. 成本
CSM 的主要成本来自:
- 每个 Cascade 重复生成方向光深度;
- Caster 剔除、命令生成和状态提交;
- 多层深度纹理的显存和带宽;
- 边界混合的额外采样;
- PCF/PCSS 的采样核。
Cascade 数量、分辨率和更新频率应按平台与画面需求设置。远层可以降低更新频率或渐隐到烘焙阴影、距离场阴影等结果。
9. 调试
- 用不同纯色显示 Cascade Index,检查层选择和过渡。
- 单独显示 Texture Array 每一层的深度。
- 绘制每层世界空间视锥和方向光正交包围盒。
- 缓慢平移和旋转相机,观察边缘是否按整 Texel 移动。
- 在边界放置斜面、细杆和远处高 Caster,检查 Bias、漏投影和跳变。
- 记录每层 Draw、Caster 数量、分辨率和更新时间。
平面投影阴影
Shadow Map 能处理多种接收面和自阴影,但也要生成并查询深度资源。地面平整、阴影质量要求不高的场景中,平面投影阴影(Planar Projected Shadows)可以减少深度资源生成与查询,代价转为投影几何的额外绘制和覆盖率。
思路是:忽略自阴影,只求物体每个顶点沿光方向在地面上的投影位置,把网格压扁到平面上再用深色绘制一遍。
设指向光源的单位向量为 ,投影沿 ,顶点位置为 ,地面高度为 。沿光线走到 需要的参数是:
代入得到投影点:
这就是相似三角形的直接结果,也可以写成一个 投影矩阵在顶点着色器中统一应用。
它需要额外绘制投影几何,省去阴影深度图和相应查询。投影面与地面重合时仍需处理深度冲突,例如采用受控的几何偏移或深度偏置;实际成本由几何数量、覆盖率和模板操作决定。限制同样明确:只能投到一个平面上,地面起伏或有台阶时会穿帮;没有自阴影;多个投影重叠处颜色会叠加加深,需要用模板缓冲限制每像素只写一次。
接近零时 趋于无穷;还需拒绝 的反向投影。偏置、接收平面范围和模板位由宿主提供,额外绘制的实际成本仍取决于网格复杂度和覆盖面积。
方差阴影与虚拟阴影
方差阴影贴图(Variance Shadow Map,VSM)保存深度的一阶、二阶矩,用切比雪夫不等式估计受光概率。过滤后得到 与 ,取 。当 时返回1,否则受光比例的上界为 。这个上界可以超过真实可见比例,所以即使计算无误也可能漏光;压缩权重会减轻漏光,同时引入偏暗误差。矩数据可以线性过滤和预过滤,适合较软阴影,但数值精度仍需控制。EVSM 等变体通过指数变换增强分离度,同时更依赖精度和参数控制。
虚拟阴影贴图(Virtual Shadow Map,也简称 VSM)把高分辨率阴影空间切成 Page,只分配和更新当前视图需要的区域。它依赖虚拟地址映射、Page Cache、接收者驱动请求和失效规则,目标是让大世界和高密度几何保持细致阴影。
两个 VSM 指代完全不同:Variance 是基于深度矩的可过滤表示,Virtual 是稀疏分页与缓存方案。阅读资料或抓帧时要先确认上下文。
Point Light Shadow
点光源向所有方向发光,不能用单个透视投影覆盖。常见方案是以光源为中心建立六个 90 度视锥,把径向距离写入深度 Cubemap。
每个面使用相同近远平面,View Matrix 分别朝向正负 X、Y、Z。各面的上方向需符合所用 Cubemap 的面朝向约定,不能把同一个上方向原样用于所有面;尤其正负 Y 面必须选取与视线不平行的上方向。六面可以逐次渲染,也可以使用 Layered Rendering 写入 Cubemap Layer。
查询时用从光源到接收点的向量同时完成方向寻址和距离比较:
float PointShadow(vec3 positionWS)
{
vec3 lightToPoint = positionWS - lightPositionWS;
float receiverDistance = length(lightToPoint);
float blockerDistance = textureLod(pointShadowMap, lightToPoint, 0.0).r
* shadowFar;
return receiverDistance - shadowBias > blockerDistance
? 1.0 : 0.0;
}这段GLSL函数返回遮挡比例,1表示阴影,与前面PCF返回受光比例的含义相反。宿主提供 samplerCube pointShadowMap,采用点过滤和固定第0级;源深度线性过滤会重现PCF一节指出的错误。写入端把线性径向距离除以 shadowFar,读取后乘回同一值。光源位置的零方向、超出有效距离及近裁剪范围由调用端处理。若资源保存投影深度,必须先按投影公式还原,不能直接与欧氏距离比较。
软化点光阴影时,可以在 lightToPoint 周围按一组球面方向偏移采样。采样半径应转换到世界空间或随接收距离调整。直接使用固定方向偏移会让近远处软化尺度不一致。
主要工程问题包括:
- 六个面带来的 Caster 绘制、剔除和带宽;
- 面边界的过滤接缝;
- 近距离 Bias 和远距离精度;
- 动态物体引起的缓存失效;
- 大采样核的 Cubemap 纹理成本。
项目通常限制阴影点光数量、影响半径、分辨率和更新频率。静止灯光可以缓存静态 Caster,再单独更新动态层。Dual Paraboloid 等两面方案减少面数,同时带来投影畸变和接缝。
常见伪影定位
| 现象 | 优先检查 |
|---|---|
| 表面条纹 | Depth/Slope Bias、精度、法线 |
| 阴影悬空 | Bias 过大、Normal Bias |
| 边缘锯齿 | 分辨率、投影覆盖、PCF、Cascade |
| 相机移动抖动 | Cascade Stabilization、Texel Snapping |
| 薄墙漏光 | Shadow Caster 面、Bias、厚度 |
| PCSS 大块噪声 | Blocker Search、采样序列、深度转换 |
验证方法
- 直接显示 Shadow Map 和接收点 Shadow UV。
- 分别显示 、、比较结果和最终过滤值。
- PCSS 单独显示 Blocker Count、Average Blocker Depth 和 Filter Radius。
- 用平面、方块和可调面积光建立最小测试场景。
- 在斜面、薄片、远距离和 Cascade 边界做压力测试。
test_gi.py 检查半影完整宽度到采样半径的换算,并用二点深度分布展示方差上界的漏光。正文 PCF 完成 DXC 编译,级联选择与点光查询函数经 glslangValidator 补齐宿主声明后完成 GLSL 330 编译,入口见 compile_gi.py;级联矩阵宿主、实际深度生成与GPU图像尚未运行。
相关主题
参考资料
- Randima Fernando, Percentage-Closer Soft Shadows.
- NVIDIA, Integrating Realistic Soft Shadows into Your Game Engine.
- Microsoft Learn:Cascaded Shadow Maps,分层覆盖、固定尺度与纹素吸附。
- GPU Gems 3 第8章:Summed-Area Variance Shadow Maps,§8.4方差上界与§8.6 PCSS。
- LearnOpenGL,
src/8.guest/2021/2.csm. - LearnOpenGL,
src/5.advanced_lighting/3.2.1.point_shadowsand3.2.2.point_shadows_soft.