需要预先了解:


1. 阴影相关逻辑

阴影是光照的"这道光被挡住了没"那一半。IDGameEngine 的阴影基于经典 Shadow Map(阴影贴图),在这之上实现了 Hard / PCF / PCSS 三档滤波和最多 4 层的 CSM 级联。这篇笔记按数据流梳理:CPU 侧的封装 → ShadowPass 的渲染流程 → GPU 侧 shader 的采样与滤波。


1.1 阴影贴图原理

Shadow Map 是两遍渲染:

  1. 深度遍:把相机换成光源,从光源位置看向场景,只把深度写进一张贴图——记录"沿这道光方向,最先碰到的东西有多远";
  2. 着色遍:正常渲染时,把每个像素变换到光源视角,比较它的深度和贴图里的深度。比贴图近说明它就是挡光者(照亮),比贴图远说明它前面有东西(阴影)。

平行光的光源视角用正交投影(平行光没有透视衰减),这是后面 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),每层四步:

  1. PSSM 分割——把主相机视锥沿深度切成 count 段。第 i 层远边界:

spliti=lerp(n+(f−n)i+1c,  n (f/n)i+1c,  λ)split_i = \text{lerp}\big( n + (f - n) \tfrac{i+1}{c},\; n \, (f/n)^{\tfrac{i+1}{c}},\; \lambda \big)

均匀分割(λ=0\lambda = 0)近处浪费、对数分割(λ=1\lambda = 1)远处不够,0.75 是常用折中;

  1. 取视锥段 8 角点——近平面 4 个 + 远平面 4 个,视空间直接算出,再经逆 view 矩阵转到世界空间;

  2. 光源视角 AABB——8 个角点投影到光源 view 空间,取 XYZ 三轴的包围盒。XY 范围即正交投影的 left/right/bottom/top,Z 范围外扩 1 个世界单位做近远平面(防边缘裁剪)。紧贴包围盒意味着每一层的 texel 都花在有效区域上;

  3. 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 的流程:

  1. 复位 ctx:阴影相关字段全部清空(shadow_enabled = false、矩阵归单位阵)。复位是必须的——后续 Pass 靠这些字段判断"这帧有没有阴影";
  2. 找主方向光:遍历光源列表取第一个启用的 Directional。找不到就上传一个 enabled = 0 的阴影块直接返回——无阴影帧也要上传,否则 UBO 里残留上一帧的 enabled = 1 数据,关阴影后画面上会留着旧阴影;
  3. 同步配置:光源方向写入 ShadowCamera,级联层数从 RendererSettings::cascade_count 读取(clamp 到 [1, MAX_CASCADES]);
  4. 懒创建资源:depth shader、ShadowMap(分辨率或层数变化时重建)、raw/cmp 两个采样器;
  5. 逐层渲染:compute_view(layer) → attach 该层 → 清深度 → 遍历不透明批次画深度;
  6. 写回 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 是面光源尺寸,越大影子越软):

wpenumbra=(zreceiver−zblocker)⋅wlightzblockerw_{penumbra} = \frac{(z_{receiver} - z_{blocker}) \cdot w_{light}}{z_{blocker}}

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 的"近锐远软"叠加起来,就是一套接近现代引擎观感的方向光阴影。