在基于物理的大型开放世界渲染中,级联阴影贴图(Cascaded Shadow Maps, CSM)是处理大范围日光阴影的工业标准。然而,受限于有限的阴影贴图分辨率(通常为 2048x2048 或 4096x4096)以及为根除阴影粉刺(Acne)而不得不引入的法线与深度偏置(Normal Offset & Depth Bias),CSM 在处理微观尺度细节时存在天然的几何短板。最典型的现象便是角色脚底与地面交界处的“漂浮悬空(Peter Panning)”、下颌与衣领处的无阴影漏光,以及细小植被、碎石缝隙处阴影的严重缺失,极大削弱了画面的物理接地感(Grounding)。
屏幕空间接触阴影(Screen Space Contact Shadows)是弥补这一缺陷成本最低且效果最显著的技术方案。它不占用额外的阴影贴图显存,直接利用 G-Buffer 阶段已经生成的高分辨率屏幕深度缓冲,在片元着色器中沿着指向光源的方向进行短距离屏幕空间光线步进(Ray Marching),以极高的几何保真度精准补全厘米级的微观缝隙接触阴影。
算法拓扑与屏幕空间光线步进几何
接触阴影只负责填补从物体表面向外延伸的微小距离(通常在世界空间 0.1 米至 0.5 米以内),更远处的阴影则平滑交由 CSM 接管。
其核心计算流程如下:
- 起点反求:从屏幕 UV 提取深度值,通过逆变换还原当前着色像素的视空间位置 $\mathbf{P}_{view}$。
- 光线方向构建:将世界空间阳光方向向量变换至视空间,得到步进向量 $\mathbf{L}_{view}$。
- 蓝噪声扰动(Blue Noise Jitter):为了避免固定步长采样引发的阶梯形采样带伪影(Banding Artifacts),使用交错网格或蓝噪声纹理对光线起点施加微小的随机相位偏移。
- 步进与厚度深度测试(Depth & Thickness Test):在视空间或 NDC 投影空间沿光线迭代步进,在每一步将光线采样点投影到屏幕 UV 坐标,采集该处的实际表面深度,判断光线是否被其他几何体遮挡。
Light Direction L ▲ / / Ray Marching Steps (8~16 步) / * * * * (投影回屏幕采样深度缓冲) / P (当前像素视空间坐标) ────┴────────────── 地面微小缝隙接触阴影核心片元着色器实现
以下是在 Vulkan/GLSL 环境下实现高保真接触阴影的片元着色器核心函数:
// ContactShadows.glsl - 屏幕空间接触阴影步进与厚度判定 #version 450 layout(binding = 0) uniform sampler2D uDepthTexture; layout(binding = 1) uniform sampler2D uBlueNoise; layout(push_constant) uniform ContactShadowUBO { mat4 projMatrix; // 透视投影矩阵 mat4 invProjMatrix; // 逆投影矩阵 vec3 lightDirView; // 视空间中的光线方向 (归一化) float maxRayLength; // 最大世界/视空间步进长度 (如 0.25米) int maxSteps; // 最大步进步数 (推荐 12 ~ 16 步) float thickness; // 判定遮挡的物体厚度阈值 (如 0.05米) } ubo; // 从屏幕 UV 与深度缓冲还原视空间坐标 vec3 GetViewPosition(vec2 uv, float depth) { vec4 clipPos = vec4(uv * 2.0 - 1.0, depth, 1.0); vec4 viewPos = ubo.invProjMatrix * clipPos; return viewPos.xyz / viewPos.w; } // 屏幕空间接触阴影核心步进 float CalculateContactShadow(vec2 screenUV, vec3 viewPos, vec2 screenResolution) { // 1. 采集蓝噪声抖动因子 (0.0 ~ 1.0) vec2 noiseUV = screenResolution / vec2(textureSize(uBlueNoise, 0)) * screenUV; float jitter = texture(uBlueNoise, noiseUV).r; // 2. 初始化光线步进参数 vec3 rayDir = ubo.lightDirView; float stepSize = ubo.maxRayLength / float(ubo.maxSteps); // 应用起点抖动,打破规则条带 vec3 currentPos = viewPos + rayDir * (stepSize * jitter); float shadowFactor = 1.0; // 1.0 为完全照亮,0.0 为完全阴影 for (int i = 0; i < ubo.maxSteps; ++i) { currentPos += rayDir * stepSize; // 3. 将视空间点投影回 NDC 并映射至屏幕 UV vec4 projPos = ubo.projMatrix * vec4(currentPos, 1.0); projPos.xyz /= projPos.w; vec2 sampleUV = projPos.xy * 0.5 + 0.5; // 边界保护:若光线步出屏幕视口,立即终止判定 if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { break; } // 4. 读取采样点处的实际几何体深度 float sampledDepth = texture(uDepthTexture, sampleUV).r; if (sampledDepth >= 1.0) continue; // 击中天空盒 vec3 sampledViewPos = GetViewPosition(sampleUV, sampledDepth); // 5. 深度对比:当前光线深度是否在物体表面之后且未穿透厚度阈值 // 在视空间中,Z 轴通常为负,距离摄像机越远 Z 越小 float depthDiff = sampledViewPos.z - currentPos.z; if (depthDiff > 0.0 && depthDiff < ubo.thickness) { // 发生微观遮挡,计算基于步进距离的平滑淡出 float fade = 1.0 - (float(i) / float(ubo.maxSteps)); shadowFactor = 1.0 - fade; break; } } return shadowFactor; }屏幕空间局限性与 CSM 的平滑融合
由于屏幕空间技术只能采集当前视口已经光栅化出的几何信息,当遮挡体位于摄像机视锥体之外、或者被其他前景物体完全遮挡时,接触阴影便无法检出,容易出现阴影突然消失的瑕疵。
必须设计严密的融合与兜底逻辑:
- 视口边缘衰减(Screen Edge Fade):当光线采样的
sampleUV逼近屏幕四周边缘(如距离边缘小于 5% 像素区)时,根据边缘距离线性衰减接触阴影强度,杜绝摄像机旋转时光线突然被截断引起的突变闪烁。 - 乘法复合与极值选取:在计算最终直接光照可见性时,将接触阴影因子 $S_{contact}$ 与大尺度级联阴影因子 $S_{CSM}$ 取最小值($\min(S_{CSM}, S_{contact})$)进行压制:
在近距离缝隙处,接触阴影能瞬间“拉回”因 CSM 偏置而悬浮的阴影;在远距离处,接触阴影平滑衰减为 1.0,完全由 CSM 接管大范围树影和山影。float finalShadow = min(csmShadow, contactShadow);
性能实测与开销控制
屏幕空间接触阴影的性能完全由分辨率与采样步数决定。
- 半分辨率执行(Half-Resolution Pass):在高端桌面平台,可以在全分辨率全屏 Quad 中直接执行 12 步步进,耗时约 0.4ms(1440p)。
- 空间降采样与双边滤波(Bilateral Upsample):在移动端或主机平台上,将接触阴影单独提取为一个半分辨率 Compute Pass(步数降为 8 步),利用深度感知的双边联合上采样(Joint Bilateral Upsample)贴回全分辨率光照缓冲,整体开销可直接压降至 0.12ms 左右。
微小缝隙阴影的回归,为角色脚底与细密场景赋予了坚实可靠的物理真实感。