第四章 阴影技术

3277 字
16 分钟
第四章 阴影技术

第四章 阴影技术#

一句话理解:实时阴影的本质是”从光源的视角看这个点是否可见”——Shadow Map 就是光源视角的深度图。所有的阴影技术都在解决同一个问题:如何用有限的分辨率、有限的计算量,生成没有锯齿、没有错位的软阴影。

📋 前置知识:Ch1(渲染管线——深度测试的底层机制)、Ch2(纹理采样——Shadow Map 本质上是一张深度纹理)


4.1 概念直觉 —— Shadow Map 的核心思想#

为什么阴影是图形学中最难的问题之一#

光照计算是局部的——片元着色器只关心"这个像素在光照下是什么颜色"
阴影是全局的——需要知道"这个像素有没有被其他物体挡住"
这就是为什么 Phong/PBR 的 Shader 里没有阴影——
片元着色器拿到一个像素时,不知道有没有别的东西挡住了它到光源的路径。

Shadow Map 的两步流程#

graph LR subgraph "Pass 1: 光源视角" LightCam["从光源位置\n渲染场景"] DepthMap["生成深度图\nShadow Map"] end subgraph "Pass 2: 摄像机视角" Cam["正常渲染场景\n从摄像机视角"] Compare["对每个片元\n比较深度"] Result["决定:\n在阴影中 or 被照亮"] end LightCam --> DepthMap Cam --> Compare DepthMap --> Compare Compare --> Result
Pass 1(光源视角):
- 把摄像机"放到"光源位置,朝向场景
- 渲染场景——但不输出颜色,只输出深度
- 结果是 Shadow Map:每个像素存的是"从光源看,这一步的最近物体的深度"
Pass 2(摄像机视角):
- 正常渲染场景
- 对每个片元,计算它在光源空间中的位置
- 查 Shadow Map:光源视野中,这个方向上的最近深度是多少?
- 如果片元深度 > Shadow Map 中的深度 → 被挡住了 → 在阴影中
- 如果片元深度 ≈ Shadow Map 中的深度 → 没被挡住 → 被照亮

4.2 Shadow Map 的实现#

Shader 代码#

// ============ Pass 1: 生成 Shadow Map ============
// 顶点着色器——只需要光源的 VP 矩阵
float4 ShadowMapVS(float3 position : POSITION) : SV_POSITION {
return mul(UNITY_MATRIX_LVP, float4(position, 1.0));
// LVP = Light View × Light Projection(如果是聚光灯/方向光)
}
// 片元着色器——只写深度,不输出颜色
// Unity 对 ShadowCaster Pass 有特殊处理——自动使用优化的深度-only 管线
// ============ Pass 2: 阴影采样 ============
float SampleShadowMap(float3 worldPos) {
// 1. 世界坐标 → 光源裁剪空间
float4 lightClipPos = mul(_LightVPMatrix, float4(worldPos, 1.0));
// 2. 透视除法 → NDC
float3 lightNDC = lightClipPos.xyz / lightClipPos.w;
// 3. NDC [-1,1] → 纹理坐标 [0,1]
float2 shadowUV = lightNDC.xy * 0.5 + 0.5;
// 4. 采样 Shadow Map
float shadowMapDepth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_ShadowMap, shadowUV).r;
// 5. 比较深度
float receiverDepth = lightNDC.z; // 当前片元在光源空间的深度
// receiverDepth > shadowMapDepth → 当前片元比最近的物体更远 → 被遮挡
float shadow = receiverDepth > shadowMapDepth ? 0.0 : 1.0;
// shadow = 0(在阴影中)/ 1(被照亮)
return shadow;
}
// 使用——在片元着色器的光照计算之后
float shadow = SampleShadowMap(input.worldPos);
float3 finalColor = (ambient + diffuse * shadow + specular * shadow) * texColor.rgb;
// 环境光不受阴影影响——完全被遮挡也应该有微弱的环境光

两种 Shadow Map 的投影方式#

方向光(太阳):
用正交投影——因为方向光没有位置,是平行光
问题:如果场景很大,一张 Shadow Map 覆盖不过来
→ 远处的阴影锯齿严重(一个 Shadow Map 像素覆盖了场景中一大块区域)
→ 解决方案:CSM(4.4 节)
点光源:
用 6 张透视投影——朝向上下左右前后(Cubemap)
开销巨大:每个点光源 = 6 次 Shadow Map 生成
聚光灯:
用 1 张透视投影——最划算
聚光灯本身范围有限 → Shadow Map 分辨率利用率高

4.3 阴影瑕疵与修复#

Shadow Acne(阴影痤疮)#

症状:被照亮的表面出现奇怪的暗色条纹/网格
成因:
Shadow Map 是离散的——每个像素存一个深度值
被照亮的表面上的片元深度 ≈ Shadow Map 中的深度(理论上相等)
但由于浮点精度 + Shadow Map 分辨率有限,
有些片元的深度"略大于"Shadow Map 的深度 → 被错误判断为被遮挡
┌─────────────────┐
│ Shadow Map 像素 │
│ 深度 = 0.512 │
└─────────────────┘
片元深度 = 0.5121(差 0.0001)
→ 0.5121 > 0.512 → "被遮挡"! → 黑色条纹

修复:Depth Bias

// 在比较深度之前,把 Shadow Map 的深度向远离光源的方向偏移一点
float receiverDepth = lightNDC.z - _ShadowBias;
float shadow = receiverDepth > shadowMapDepth ? 0.0 : 1.0;
// Bias 太小 → 痤疮仍然存在
// Bias 太大 → Peter Panning(见下)

更好的修复:Normal Bias

// 不是偏移深度值,而是沿法线方向偏移采样位置
// 法线越垂直于光线 → 偏移越大(因为误差越大)
float3 worldPosWithBias = input.worldPos + input.worldNormal * _NormalBias;
float4 lightClipPos = mul(_LightVPMatrix, float4(worldPosWithBias, 1.0));
// 用偏移后的位置计算深度

Peter Panning(彼得潘现象)#

症状:物体的阴影和物体本身"分开了"——看起来像彼得潘的影子脱离了身体
成因:Bias 太大 → 片元深度被偏移得太靠近摄像机 → 本该在阴影中的变成了被照亮
┌─────────┐ 物体
│ 阴影在这里 │ ← 本该紧贴物体
└─────────┘
↑ 间隙 ← Peter Panning
═══════ 地面
解决方案:减小 Bias + 使用 Normal Bias(让偏移不全是沿深度方向)

阴影锯齿#

硬阴影的阶梯状边缘——Shadow Map 分辨率太低
解决方案1:提高 Shadow Map 分辨率(简单粗暴——但显存带宽增加)
解决方案2:CSM——近处高分辨率,远处低分辨率(4.4 节)
解决方案3:PCF——对 Shadow Map 多次采样取平均(下文)

4.4 PCF —— 百分比渐近过滤#

原理#

硬阴影:Shadow Map 采样 1 次 → 非黑即白 → 锯齿边缘
软阴影:Shadow Map 采样 N 次 → 灰度过渡 → 模糊边缘
PCF = 在 Shadow Map 上取周围 N×N 个采样点
每个采样点独立判断是否在阴影中
取平均 = 阴影的"程度"(0.3 = 30% 在阴影中 = 半影)
float PCF_3x3(float2 shadowUV, float receiverDepth) {
float shadow = 0.0;
float2 texelSize = 1.0 / _ShadowMapSize; // Shadow Map 一个像素对应的 UV 范围
// 3×3 采样
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
float2 offset = float2(x, y) * texelSize;
float depth = SAMPLE_TEXTURE2D(_ShadowMap, sampler_ShadowMap,
shadowUV + offset).r;
shadow += receiverDepth > depth ? 0.0 : 1.0;
}
}
return shadow / 9.0; // 平均 = 半影程度
}
// 5×5 → 25 次采样 → 更软但更贵
// 7×7 → 49 次采样 → 很软但很贵

PCF 的采样核设计

规则网格(3×3 / 5×5):
简单但会产生规则的重复图案——不像真实阴影
泊松分布(Poisson Disk):
随机但确保采样点之间保持最小距离
结果看起来更自然——像噪声而非网格
硬件 PCF(移动 GPU 支持):
硬件内置的 2×2 PCF——性能接近单次采样
Unity 默认使用硬件 PCF

4.5 CSM —— 级联阴影贴图#

为什么需要 CSM#

一张 2048×2048 的 Shadow Map 覆盖整个场景:
- 近处的物体占 Shadow Map 的一小部分(比如 50×50 像素)
- 远处的物体也占一小部分
- 大部分分辨率浪费在了"中间距离"
CSM 的解决方案:
把摄像机视锥体切成 N 个区域(级联)
每个区域用一张独立的 Shadow Map
近处 = 小范围 = 高密度 = 清晰的阴影
远处 = 大范围 = 低密度 = 可以模糊
graph TD subgraph "摄像机视锥体" C1["Cascade 0\n近处 0-10m\n2048×2048"] C2["Cascade 1\n中距 10-30m\n2048×2048"] C3["Cascade 2\n远处 30-80m\n2048×2048"] end C1 -->|"高分辨率"| Shadow1["近处阴影清晰"] C2 -->|"中等分辨率"| Shadow2["中距阴影适中"] C3 -->|"低分辨率"| Shadow3["远处阴影模糊"]

CSM 的分级策略#

// Unity 中的 CSM 设置
// Quality Settings → Shadows → Cascade Count: 2 or 4
// 分级方法:
// 1. 等距分割:near→near+len/3→near+2*len/3→far(均匀分)
// 2. 对数分割:越靠近 near 越密集——因为近处阴影更重要
// 3. PSSM(Parallel-Split Shadow Maps):混合等距和对数
// Unity 的 Cascades 可视化:
// Scene 视图 → Draw Mode → Shadow Cascades
// 红/绿/蓝/黄分别代表 Cascade 0/1/2/3

CSM 的阴影采样#

// 片元着色器需要判断当前片元属于哪个 Cascade
float SampleCSM(float3 worldPos) {
// 1. 找出片元属于哪个 Cascade
int cascadeIndex = 0;
for (int i = 0; i < CASCADE_COUNT; i++) {
if (worldPos.z <= _CascadeSplitDistances[i]) {
cascadeIndex = i;
break;
}
}
// 2. 用对应 Cascade 的光源矩阵
float4x4 lightVP = _LightVPMatrices[cascadeIndex];
// 3. 采样对应 Cascade 的 Shadow Map(一般打包在 Shadow Map Atlas 中)
float shadow = SampleShadowMap(worldPos, lightVP, cascadeIndex);
// 4. Cascade 之间的过渡(近一层的边缘用下一层混合——避免边界突变)
if (cascadeIndex < CASCADE_COUNT - 1) {
float distToNext = _CascadeSplitDistances[cascadeIndex] - worldPos.z;
float blend = saturate(distToNext / _CascadeBlendDistance);
float nextShadow = SampleShadowMap(worldPos,
_LightVPMatrices[cascadeIndex + 1], cascadeIndex + 1);
shadow = lerp(nextShadow, shadow, blend);
}
return shadow;
}

移动端的阴影策略#

CSM 在移动端太贵——每级级联都是一个独立的 Shadow Map 渲染
移动端(中低端设备):
- 不使用 CSM——单级 Shadow Map
- 分辨率 512 或 1024(不是 2048)
- PCF 2×2(硬件支持的最低开销版本)
- 远距离物体不收阴影——Shadow Distance 设置短一些
移动端(高端设备):
- 2 级 CSM——近处清晰阴影 + 远处模糊
- 分辨率 1024
- PCF 3×3

4.6 🎮 游戏实战:阴影调优#

Unity Shadow 设置速查#

// Quality Settings → Shadows
// Shadowmask Mode: Distance Shadowmask(动态物体实时阴影 + 静态物体烘焙阴影)
// Shadows: Hard / Soft(硬阴影 / 软阴影——移动端用 Hard)
// Shadow Resolution: Low/Medium/High/Very High
// Shadow Distance: 50-150m(移动端 20-50m)
// Shadow Cascades: No/2/4
// Cascade Splits: 调节各 Cascade 的覆盖距离
// 常见调优:
// 1. 室内场景 → Shadow Distance 可以短(墙挡住了远处的阴影投射)
// 2. 室外开阔场景 → CSM 4 级 + 远的 Shadow Distance
// 3. 移动端 → 单级或不渲染实时阴影 + 烘焙静态阴影

阴影的常见问题排查#

问题:近处阴影有锯齿,远处阴影模糊
→ CSM 分级不合理——Cascade 0 太远了
→ 增加 Cascade 0 的占比(降低 Split 1 的距离)
问题:角色脚下一圈黑边
→ Shadow Acne——增大 Normal Bias 或减小 Depth Bias
问题:阴影和角色之间有间隙
→ Peter Panning——减小 Bias 值
问题:阴影在大面积水面上消失
→ Shadow Distance 不够远——水面通常在远处
→ 但增大 Shadow Distance 会增加性能开销
问题:阴影在陡坡上错误
→ Normal Bias 可以解决——沿法线偏移而非沿深度偏移

4.7 面试口述#

Q:“Shadow Map 的原理?Shadow Acne 怎么解决?“#

"Shadow Map 分两步。
第一步,从光源位置渲染场景,存深度图——不输出颜色,只输出深度。
第二步,正常渲染场景时,每个片元算出它在光源空间的坐标,采样 Shadow Map——
如果片元深度大于 Shadow Map 深度,说明有别的物体更靠近光源,当前片元被挡住了。
Shadow Acne 是浮点精度和离散采样导致的——本该被照亮的像素,
因为深度差了一丁点被误判为阴影。
解决方案是用 Bias——比较前把片元深度向光源方向偏移。
更好的方案是 Normal Bias——沿法线而非深度方向偏移,
避免了普通 Bias 调太大导致的 Peter Panning 现象。"

Q:“CSM 的分级策略是什么?为什么近处和远处用不同分辨率?“#

"CSM 把摄像机视锥体按距离切分成 N 个级联(通常 2 或 4 级)。
每个级联用一张独立的 Shadow Map,近处的级联覆盖范围小但分辨率密度高,
远处的级联覆盖范围大但分辨率密度低。
为什么这样设计?因为一张 Shadow Map 覆盖整个场景时,
大部分分辨率浪费在了中间距离——近处和远处的阴影都占很少像素。
把近处单独分一个级联 → 同样的 2048×2048 只覆盖 0-10 米 → 人物脚下的阴影非常清晰。
远处单独分一级 → 2048×2048 覆盖 30-80 米 → 像素密度低但可以接受(远处阴影模糊是自然的)。
分级策略有等距分割(均匀分)、对数分割(近处更密集)、
PSSM(混合等距和对数)——Unity 默认使用 PSSM。
级联之间需要做过渡混合(取相邻两级联的采样结果插值),避免级联边界出现阴影突变。"

4.8 本章回顾#

概念一句话
Shadow Map光源视角的深度图——比较深度判断遮挡
Shadow Acne精度误差导致的自遮挡条纹——加 Bias
Peter PanningBias 太大导致阴影脱离物体——减小 Bias 或用 Normal Bias
PCF多次采样取平均——消除阴影锯齿
CSM视锥体分层——近处清晰远处模糊
移动端阴影单级 Shadow Map + 低分辨率 + 近处阴影距离

📖 下一章:第五章 PBR 深入 —— Cook-Torrance BRDF 的 D/F/G 三项、IBL 环境光照。Ch3 讲了 PBR 是什么,Ch5 讲它的数学。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

第四章 阴影技术
https://firefly-7a0.pages.dev/posts/graphics/04_shadow_techniques/
作者
lonelystar
发布于
2026-04-29
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
第一章 渲染管线与 MVP 变换
图形学笔记 **图形学基础 · 渲染管线与 MVP 变换。** 从 DrawCall 提交到屏幕像素的完整旅程——应用阶段的剔除与合批、几何阶段的 MVP 变换与裁剪、光栅化阶段的片元着色与混合测试,每个 MVP 矩阵的几何直觉与数学推导。
2
图形学笔记:从渲染管线到移动端架构
图形学笔记 **游戏客户端开发 · 图形学全景导航。** 8 章覆盖渲染管线与 MVP 变换、纹理与采样、光照模型、阴影技术、PBR 深入、前向与延迟渲染、后处理与实时 GI、移动端渲染架构与调试——面向客户端开发岗,从原理到面试到工业实践。
3
第八章 移动端渲染架构与调试
图形学笔记 **图形学实践 · 移动端渲染与调试。** 从桌面 GPU 的 Immediate Mode 与移动 GPU 的 Tile-Based Rendering 的架构级对比,到带宽瓶颈与 Overdraw 的定量分析,到 MSAA/全屏后处理/Clip() 在移动端的真实代价,到 RenderDoc 的帧调试实战——覆盖从原理到优化到排错的完整链路。
4
第六章 前向渲染与延迟渲染
图形学笔记 **图形学进阶 · 渲染架构。** 前向渲染与延迟渲染的完整对比——从 DrawCall 与光源数的关系到 G-Buffer 的布局设计,从延迟渲染为什么不支持 MSAA 到 Forward+ 与 Clustered Forward 如何取两者之长,从桌面 GPU 的 Immediate Mode 到移动端 GPU 的 Tile-Based 架构。
5
第四章 HTTP、HTTPS 与应用层协议
计算机网络笔记 **面试突击 · 应用层协议。** 从 HTTP 报文结构到版本演进(1.1→2→3),从 TLS 握手到证书链,从 WebSocket 全双工到 Protobuf 序列化——一文覆盖应用层面试全部考点与游戏中的热更新、登录、协议设计。
随机文章 随机推荐

评论区

Profile Image of the Author
LonelyStar
Hello, I'm LonelyStar.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
119
分类
11
标签
346
总字数
226,548
运行时长
0
最后活动
0 天前

目录