阴影相关逻辑
需要预先了解:
1. 阴影相关逻辑
阴影是光照的"这道光被挡住了没"那一半。IDGameEngine 的阴影基于经典 Shadow Map(阴影贴图),在这之上实现了 Hard / PCF / PCSS 三档滤波和最多 4 层的 CSM 级联。这篇笔记按数据流梳理:CPU 侧的封装 → ShadowPass 的渲染流程 → GPU 侧 shader 的采样与滤波。
1.1 阴影贴图原理
Shadow Map 是两遍渲染:
- 深度遍:把相机换成光源,从光源位置看向场景,只把深度写进一张贴图——记录"沿这道光方向,最先碰到的东西有多远";
- 着色遍:正常渲染时,把每个像素变换到光源视角,比较它的深度和贴图里的深度。比贴图近说明它就是挡光者(照亮),比贴图远说明它前面有东西(阴影)。
平行光的光源视角用正交投影(平行光没有透视衰减),这是后面 ShadowCamera 的核心工作。
1.2 CPU 侧的封装
ShadowConfig:参数配置
inline constexpr uint32_t MAX_CASCADES = 4; // 级联层数上限
enum class ShadowQuality : uint8_t
{
Low = 0, // 512², 无 PCF
Medium = 1, // 1024², 3×3 PCF
High = 2, // 2048², 5×5 PCF
Ultra = 3 // 4096², 7×7 PCF
};
struct ShadowParam
{
ShadowQuality quality = ShadowQuality::High;
float bias = 0.0015f; // 深度偏移(防自阴影)
float normal_bias = 0.02f; // 法线偏移(防自阴影)
};
template<>
struct ShadowConfig<LightType::Directional>
{
ShadowParam param;
float ortho_extent = 20.0f; // 正交半范围(单级联模式用)
float cascade_splits_lambda = 0.75f; // PSSM 分割系数
float near_plane = 0.5f;
float far_plane = 300.0f;
};
质量档位把"贴图分辨率"和"PCF 采样半径"绑定在一起:档位越高贴图越大、可支撑的滤波核越宽。
ShadowMap:array 深度纹理 + 单 FBO
ShadowMap 封装了一张 GL_TEXTURE_2D_ARRAY 深度纹理和一个 FBO:
class ShadowMap
{
TextureID m_depth_array; // array 深度纹理(层数 = 级联数)
FrameBufferID m_fb; // 渲染用 FBO(无颜色、无自带深度附件)
uint32_t m_layer_count;
uint32_t m_size;
};
即使只开一层(关闭 CSM),也走 array 路径——统一形态,shader 侧的采样代码不用为单层/多层分叉。渲染某一层时,用 attach_framebuffer_depth_layer 把 array 纹理的第 layer 层挂到 FBO 的深度附件上,然后清屏、绘制——glClear 只清当前 attach 的那一层。
析构顺序有讲究:先销毁 FBO,再销毁纹理。FBO 的深度附件引用的是外部 array 纹理,反过来销毁会在 GL 名字回收机制下产生悬空附件。
DirectionalShadowCamera:光源视角的矩阵
compute_view(index, main_camera) 是整个 CPU 侧阴影的心脏——计算第 index 层级联的光源 view/proj 矩阵。分两种模式:
单级联(cascade_count = 1):以主相机位置为锚点,光源往后退 ortho_extent + near_plane 作为眼睛位置,正交范围固定 [-extent, extent]。这是旧行为的保留,级联关闭时画面与基线逐像素一致。
级联模式(count > 1),每层四步:
- PSSM 分割——把主相机视锥沿深度切成 count 段。第 i 层远边界:
均匀分割()近处浪费、对数分割()远处不够,0.75 是常用折中;
-
取视锥段 8 角点——近平面 4 个 + 远平面 4 个,视空间直接算出,再经逆 view 矩阵转到世界空间;
-
光源视角 AABB——8 个角点投影到光源 view 空间,取 XYZ 三轴的包围盒。XY 范围即正交投影的 left/right/bottom/top,Z 范围外扩 1 个世界单位做近远平面(防边缘裁剪)。紧贴包围盒意味着每一层的 texel 都花在有效区域上;
-
bias_scale——记录本层 AABB 宽度与层 0 宽度的比值。远层的正交范围更大、同样分辨率下一个 texel 对应的世界尺寸更大,抗自阴影的偏移量需要按这个比例放大。这个比值会一路传到 shader(后面的
u_cascade_bias_scales)。
// bias_scale:本层 AABB 宽度 / 层 0 宽度(texel 尺寸维度)
const float aabb_width = max_x - min_x;
float bias_scale = 1.0f;
if (index == 0)
m_layer0_extent = aabb_width; // 层 0 宽度由首次调用缓存
else if (m_layer0_extent > 0.0f)
bias_scale = aabb_width / m_layer0_extent;
级联的意义:把同一张贴图的分辨率按"近处密、远处疏"重新分配。近处的一层用整张贴图的 2048² 覆盖十几个世界单位,近景阴影清晰;远处的层覆盖几百单位,糊一点也看不出来。
1.3 ShadowPass:渲染流程
ShadowPass 在 RenderPass与RenderGraph 里声明 writes(ShadowMap),是整张图的第一道工序。execute 的流程:
- 复位 ctx:阴影相关字段全部清空(
shadow_enabled = false、矩阵归单位阵)。复位是必须的——后续 Pass 靠这些字段判断"这帧有没有阴影"; - 找主方向光:遍历光源列表取第一个启用的 Directional。找不到就上传一个
enabled = 0的阴影块直接返回——无阴影帧也要上传,否则 UBO 里残留上一帧的enabled = 1数据,关阴影后画面上会留着旧阴影; - 同步配置:光源方向写入 ShadowCamera,级联层数从
RendererSettings::cascade_count读取(clamp 到[1, MAX_CASCADES]); - 懒创建资源:depth shader、ShadowMap(分辨率或层数变化时重建)、raw/cmp 两个采样器;
- 逐层渲染:
compute_view(layer)→ attach 该层 → 清深度 → 遍历不透明批次画深度; - 写回 ctx + 上传 ShadowBlock UBO。
逐层渲染的核心循环:
for (uint32_t layer = 0; layer < count; ++layer)
{
ShadowView sv = m_shadow_camera.compute_view(layer, ctx.camera);
IDRCmd::bind_framebuffer(map->get_fb());
IDRCmd::attach_framebuffer_depth_layer(map->get_fb(), map->get_depth_array(), layer);
IDRCmd::set_viewport(0, 0, map_size, map_size);
IDRCmd::clear(false, true); // 只清深度
m_light_view_proj = sv.view_proj; // 供 draw_depth_batch 取用
for (const ModelSE& entry : ctx.opaque_batches)
draw_depth_batch(ctx, entry);
ctx.light_view_projs[layer] = sv.view_proj; // 写回 ctx
ctx.cascade_splits[layer] = sv.far_bound;
ctx.cascade_bias_scales[layer] = sv.bias_scale;
}
深度遍的管线状态:cull_mode = Back(背面剔除——只渲染朝向光源的正面,被照面自己不写深度,从根上减少自阴影)。深度 shader 非常短:
// depth.vsl
uniform mat4 u_light_view_proj;
uniform mat4 u_model;
uniform float u_normal_bias; // 法线偏移
void main()
{
vec3 world_pos = (u_model * vec4(aPos, 1.0)).xyz;
vec3 world_normal = normalize(mat3(u_model) * aNormal);
world_pos += world_normal * u_normal_bias; // 沿法线偏移,消除自阴影空缺
gl_Position = u_light_view_proj * vec4(world_pos, 1.0);
}
normal_bias 把顶点沿法线往光源方向推离表面再投影——数值精度不够时,"表面自己在自己投的深度上"会出现条状自阴影,把位置抬高一点就错开了。这和后面着色遍的深度 bias 是互补的两手。
ShadowBlock UBO
阴影数据不走逐个 set_param,而是打包成一个 std140 UBO 一次性上传:
// C++ 侧,与 shader 端 ShadowBlock 一一对应
struct ShadowBlockGPU
{
Array<float, 16> light_space_mvps[MAX_CASCADES]; // 各层光源 VP(列优先)
Vec4 cascade_splits; // xyzw = 层 0~3 远边界(视空间距离)
Vec4 cascade_bias_scales; // xyzw = 层 0~3 bias 缩放
Vec4 shadow_params; // x = count, y = pcf_radius, z = filter, w = light_size
Vec4 misc; // x = bias, y = light_index, z = enabled, w = pad
};
static_assert(sizeof(ShadowBlockGPU) == 320, "std140 布局");
// shader 侧
layout(std140, binding = 0) uniform ShadowBlock
{
mat4 u_light_space_mvps[4];
vec4 u_cascade_splits;
vec4 u_cascade_bias_scales;
vec4 u_shadow_params;
vec4 u_shadow_misc;
};
一次 glBufferSubData 上传 320 字节,替代以前消费点 10+ 次的逐元素 set_param——尤其光源 VP 数组那种 u_light_space_mvps[0]、u_light_space_mvps[1] 逐个设的写法。std140 的对齐规则(vec4 十六字节对齐、mat4 数组紧凑排布)用 static_assert + offsetof 在编译期锁死,两端的布局永远不会漂移。
1.4 GPU 侧:采样与滤波
着色阶段(前向的 geometry.fsl 和延迟的 deferred_lighting.fsl 共用同一套逻辑)里,阴影计算只挂在主方向光上:
if (i == shadow_light_index && shadow_enabled == 1)
{
// slope bias:入射角越斜,深度差放大越多
float ndotl = max(dot(N, L), 0.0);
float slope_scale = clamp(1.0 / max(ndotl, 0.1), 1.0, 10.0);
float slope_bias = shadow_bias * slope_scale;
if (shadow_filter == 0) shadow = compute_shadow_hard (v_world_pos, slope_bias);
else if (shadow_filter == 1) shadow = compute_shadow_pcf (v_world_pos, slope_bias);
else shadow = compute_shadow_pcss (v_world_pos, slope_bias);
}
选层与双采样状态
三个滤波函数的开头都是一样的:选层 → 变换到光源空间 → NDC 归一化 → bias → 越界保护。
选层(CSM 开启时):按像素的视空间深度,和 u_cascade_splits 从后往前比:
int pick_cascade(vec3 world_pos)
{
int cascade_count = int(u_shadow_params.x);
if (cascade_count <= 1) return 0;
float dist = -(u_view * vec4(world_pos, 1.0)).z; // 视空间距离(正数)
for (int i = cascade_count - 1; i >= 1; --i)
if (dist > u_cascade_splits[i - 1]) return i;
return 0;
}
双采样状态是这套实现的一个特色:同一张深度 array 纹理绑到两个纹理槽,配两个不同的 Sampler Object——
| 槽位 | 采样器 | 用途 |
|---|---|---|
| raw | NEAREST + 无比较 |
读原始深度值,PCSS 的 blocker search 用 |
| cmp | LINEAR + REF_TO_TEXTURE |
硬件比较采样:一次访问 = 2×2 texel 的深度比较 + 双线性插值,即免费硬件 PCF |
sampler object 的状态优先于纹理对象自身的参数,同一纹理得以在两种采样模式下复用。
Hard
float compute_shadow_hard(vec3 world_pos, float slope_bias)
{
int layer = pick_cascade(world_pos);
float layer_bias = slope_bias * u_cascade_bias_scales[layer]; // bias 双维缩放
vec4 sc = u_light_space_mvps[layer] * vec4(world_pos, 1.0);
vec3 proj = sc.xyz * 0.5 + 0.5; // NDC [-1,1] → UV [0,1]
proj.z -= layer_bias;
if (越界) return 1.0;
return sample_cmp(layer, proj.xy, proj.z); // 单次硬件比较采样
}
返回值是阴影系数:1 全亮、0 全阴影。单次 sample_cmp 借 LINEAR 过滤拿到 2×2 texel 的比较插值结果,所以"Hard"其实也有一点硬件自带的柔化。
PCF
PCF(Percentage Closer Filtering,百分比渐近滤波)把"单点比较"扩展成"邻域多点比较取平均":
int radius = int(u_shadow_params.y); // 0→1×1, 1→3×3, 2→5×5, 3→7×7
float shadow = 0.0;
for (int x = -radius; x <= radius; ++x)
for (int y = -radius; y <= radius; ++y)
{
vec2 offset = vec2(float(x), float(y)) * texel_size;
shadow += sample_cmp(layer, proj.xy + offset, proj.z);
}
return shadow / float((2 * radius + 1) * (2 * radius + 1));
每个 tap 都是硬件比较采样(自带 2×2 双线性),(2r+1)² 个 tap 平均出渐变的半影。核越大越柔和也越贵,质量档位直接控制 radius。
PCSS
PCSS(Percentage Closer Soft Shadows)的核心洞察:半影宽度和"遮挡物到 receiver 的距离"成正比——遮挡物越靠近投下阴影的表面,影子边缘越锐利;悬在半空的遮挡物投出软边。三步走:
1. blocker search:在周围一圈采样原始深度(raw 槽位),统计真正挡住当前像素的 texel,求平均遮挡深度:
float blocker_depth_sum = 0.0;
int blocker_count = 0;
for (int i = 0; i < 16; ++i)
{
vec2 offset = rotate2d(POISSON_DISK[i], angle) * search_radius * texel_size;
float depth = sample_raw(layer, proj.xy + offset);
if (depth < proj.z) // 比当前像素浅 → 是遮挡者
{
blocker_depth_sum += depth;
blocker_count++;
}
}
if (blocker_count == 0) return 1.0; // 无遮挡 → 全亮
float avg_blocker = blocker_depth_sum / float(blocker_count);
2. 半影宽度:相似三角形关系推出(u_shadow_params.w 是面光源尺寸,越大影子越软):
float penumbra = (proj.z - avg_blocker) * u_shadow_params.w
/ max(avg_blocker, 1e-4)
/ u_cascade_bias_scales[layer]; // 归一化到层 0 的 texel 尺度
注意那个 / bias_scale:半影是物理量(世界尺度),远层一个 texel 对应的世界尺寸更大,不归一化的话 16 texel × 粗 texel 会算出巨大的世界半径,采样盘甩出阴影边界,阴影散成点雾。
3. Poisson PCF:以 penumbra 为半径,16 点泊松圆盘采样取平均:
float pcf_radius = clamp(penumbra, 1.0, 16.0);
float shadow = 0.0;
for (int i = 0; i < 16; ++i)
{
vec2 offset = rotate2d(POISSON_DISK[i], angle) * pcf_radius * texel_size;
shadow += sample_cmp(layer, proj.xy + offset, proj.z);
}
return shadow / 16.0;
两个采样细节:
- Poisson 圆盘(16 点)代替方形网格——同样的采样数,分布更均匀,格子感更弱;
- IGN 旋转:每 8×8 像素一个 tile,tile 内共享一个由交错梯度噪声(Interleaved Gradient Noise)生成的旋转角。旋转打散了泊松盘的固定图案,配合时间抖动就是 TAA 素材(当前未做时间累积,静态画面下旋转角固定,不闪)。
1.5 消费点:apply_shadow
着色前的阴影装配收在 RenderPass::apply_shadow(前向)和 LightingPass::execute(延迟,槽位后移)里,两处逻辑一致:
// 同一张 array 纹理绑两个槽:raw(blocker search)+ cmp(硬件比较采样)
IDRCmd::bind_texture(ctx.shadow_depth_array, 1);
IDRCmd::bind_sampler(ctx.shadow_sampler_raw, 1);
IDRCmd::bind_texture(ctx.shadow_depth_array, 2);
IDRCmd::bind_sampler(ctx.shadow_sampler_cmp, 2);
IDRCmd::set_param(shader, "u_shadow_map", 1);
IDRCmd::set_param(shader, "u_shadow_map_cmp", 2);
IDRCmd::bind_uniform_buffer(ctx.shadow_ubo, 0); // ShadowBlock → binding 0
| 路径 | raw 槽 | cmp 槽 | 说明 |
|---|---|---|---|
| 前向(Forward/Transparent) | 1 | 2 | 材质纹理占 slot 0 |
| 延迟(Lighting) | 3 | 4 | G-Buffer 三附件占 slot 0/1/2 |
槽位错开是因为两条路径占用的纹理槽不同,shader 里声明的 sampler 编号必须与实际绑定的槽位一一对应,两处注释都标了"必须同步维护"。
无阴影帧的处理也在消费点:即使 shadow_enabled = false,也照样 bind 那个 UBO(内容是 ShadowPass 或 Renderer 注入的全零块,enabled = 0),shader 读到 enabled = 0 直接跳过阴影计算——这就是前面"无阴影帧也要上传"在消费端的另一半。
2. 小结
阴影系统的分层:
- CPU 侧:ShadowConfig(质量/偏移参数)→ DirectionalShadowCamera(单级联 / PSSM + 8 角点 AABB)→ ShadowMap(array 深度纹理,逐层 attach)→ ShadowManager(池管理);
- ShadowPass:复位 → 找主方向光 → 逐层渲染深度 → 写回 ctx + 上传 320B 的 ShadowBlock UBO;
- GPU 侧:pick_cascade 选层 → slope bias × cascade bias scale 双维偏移 → raw/cmp 双采样状态 → Hard(单 tap)/ PCF(方形核)/ PCSS(blocker search → 物理半影 → Poisson 16 点)三档滤波。
CSM 的"近密远疏"和 PCSS 的"近锐远软"叠加起来,就是一套接近现代引擎观感的方向光阴影。



