需要预先了解:


1. PostProcess相关逻辑

光照输出的是线性 HDR(High Dynamic Range,高动态范围)颜色——亮度可以超过 1.0,这是 Bloom 效果的前提。但显示器是 LDR 的 sRGB 设备,直接显示 HDR 数据会整体发灰发白。从"线性 HDR"到"可显示"之间隔着一整条后处理链:Bloom 提取辉光、ToneMapping 压缩动态范围、Gamma 校正转回 sRGB。这篇笔记梳理 IDGameEngine 的 PostProcessPass 如何组织这些效果。


1.1 HDR 渲染管线

先把整条链路铺开,后处理处于末端:

ForwardPass / LightingPass          PostProcessPass                窗口
┌──────────────┐   采样    ┌──────────────────┐   blit   ┌─────────┐
│ scene_fb     │ ────────▶ │ Bloom 金字塔      │ ───────▶ │ 默认    │
│ RGBA16F HDR  │           │ + ToneMapping    │          │ FBO     │
│ 线性空间     │           │ + Gamma → 输出    │          │         │
└──────────────┘           └──────────────────┘          └─────────┘
                                    │ 写入
                              ┌──────────────┐
                              │ viewport_fb  │ ──▶ ImGui Viewport 面板采样显示
                              │ RGBA8 无深度 │
                              └──────────────┘

两个关键目标:

  • scene_fb:RGBA16F 半精度浮点,能存超过 1.0 的亮度,整个光照阶段都在它上面完成;
  • viewport_fb:RGBA8,后处理的最终输出。它再 blit 到默认 framebuffer 供窗口显示,同时颜色纹理被 DevGUI 的 Viewport 面板直接采样(编辑器里看到的就是这张)。

两个 FBO 都由 Renderer 按窗口尺寸懒创建,尺寸变化时销毁重建。


1.2 全屏三角形

后处理的每个子步骤都是"读一张纹理、按像素算、写另一张纹理",绘制几何就是一个覆盖全屏的三角形。引擎里由 FullscreenQuad 工具类提供:

// 覆盖屏幕的大三角形(比视口大,保证边缘像素都被覆盖)
static const float s_vertices[] = {
    -1.0f, -1.0f,
     3.0f, -1.0f,
    -1.0f,  3.0f,
};

顶点数据只有 3 个 vec2,UV 在顶点着色器里从位置推导(aPos * 0.5 + 0.5)——覆盖范围是 [0,2]×[0,2][0,2] \times [0,2],屏幕外的部分被裁掉。用三角形而不用四边形的原因:

  • 四边形是两个三角形拼的,对角线上插值权重不一致,可能产生细微的采样不均;
  • 三角形不需要索引缓冲,一次 draw_arrays 完事。

全屏管线的状态统一是:深度测试/写入全关、剔除关、混合关——纯粹覆盖整屏。


1.3 PostProcessPass 的结构

PostProcessPass 在 RenderPass与RenderGraph 里声明 reads(SceneColor) + writes(ViewportTarget),是渲染图的最终输出节点。

一个有意思的设计取舍:所有子效果共用一个 shader,靠 uniform u_mode 分派:

u_mode 步骤 说明
0 EXTRACT 亮部提取(Bloom 第一步)
1 BLUR_H 水平 5-tap 高斯模糊
2 BLUR_V 垂直 5-tap 高斯模糊
4 DOWNSAMPLE 降采样(4-tap box,半像素偏移)
5 UPSAMPLE 升采样累加(tent 9-tap)
3 COMPOSITE 最终合成(+ 可选 ToneMapping / Gamma)

好处是整个后处理只创建一个 shader program、一个 pipeline,中间每步只是换绑定的输入纹理、目标 FBO 和 mode——省掉了多个 shader 的编译与切换。

效果开关不存在 Pass 里,唯一事实来源是 RendererSettings:

struct RendererSettings
{
    bool     post_bloom        = true;    // Bloom 效果位
    bool     post_tone_mapping = true;    // ACES ToneMapping 效果位
    bool     post_gamma        = true;    // Gamma 2.2 输出校正效果位
    float    bloom_threshold   = 1.0f;    // 亮部提取阈值(HDR 亮度)
    float    bloom_strength    = 0.8f;    // 合成叠加强度
    float    bloom_radius      = 1.0f;    // 模糊步长倍率
    uint32_t bloom_mips        = 5;       // 降采样级数(2~6)
};

Pass 每帧执行时实时读取 settings——DevGUI 里勾选框即改即生效,不触发管线重装配。


1.4 Bloom 金字塔

Bloom(泛光)让画面中超过阈值的高亮区域向外"晕开"。朴素的实现是"提取亮部 → 高斯模糊 → 叠加回去",但单一分辨率的模糊要么光晕太小、要么采样数爆炸。引擎用的是多级降采样金字塔(Call of Duty / Unity HDRP 的经典方案):

extract:   scene ──▶ A0(1/2 分辨率)
           首级补模糊: A0 ─▶ B0 ─▶ A0(blur_h + blur_v)
降采样链:  for i = 1..N-1:   A[i] = blur_v(blur_h(down(A[i-1])))
反向累加:  B[N-1] = A[N-1]
           for i = N-2..0:   B[i] = A[i] + tent(B[i+1])
合成:      scene + B0 × strength → ToneMapping → Gamma → viewport_fb

金字塔是两条 FBO 链(A 链存各级降采样结果、B 链存反向累加结果),每级都是 RGBA16F 无深度附件,从 1/2 窗口分辨率起逐级减半(保底 1×1)。级数 bloom_mips 默认 5,可调 2~6;级数或任一级尺寸变化时整链销毁重建。

亮部提取

// u_mode = 0
vec3 c = texture(u_input, v_uv).rgb;
float luma = dot(c, vec3(0.299, 0.587, 0.114));
// 阈值附近平滑过渡,避免硬切
float k = smoothstep(u_threshold, u_threshold * 1.5, luma);
FragColor = vec4(c * k, 1.0);

亮度低于阈值的不变(乘 0),高于 1.5 倍阈值的全部保留,中间平滑过渡——硬切的阈值会在亮区边缘留下肉眼可见的等值线。

高斯模糊:5-tap 线性优化

每级降采样后做水平 + 垂直两趟 5-tap 高斯:

vec3 gaussian_blur(sampler2D tex, vec2 uv, vec2 dir)
{
    vec3 result = texture(tex, uv).rgb * 0.227027;
    result += texture(tex, uv + dir * 1.384615).rgb * 0.316216;
    result += texture(tex, uv - dir * 1.384615).rgb * 0.316216;
    result += texture(tex, uv + dir * 3.230769).rgb * 0.070270;
    result += texture(tex, uv - dir * 3.230769).rgb * 0.070270;
    return result;
}

看似 5 个采样点,实际偏移量经过挑选(1.384615 / 3.230769),配合纹理双线性插值,每个 tap 覆盖两个 texel——等效 9-tap 高斯只用 5 次纹理访问。dir 由 u_texel_size(1/纹理尺寸)× u_bloom_radius(步长倍率)拼出,改半径就是改步长。

降采样与升采样

降采样用 4-tap box 滤波,采样点偏移半个目的像素:

// u_mode = 4,u_texel_size = 1/源纹理尺寸
vec3 c = texture(u_input, v_uv + ts * vec2( 0.5,  0.5)).rgb
       + texture(u_input, v_uv + ts * vec2(-0.5,  0.5)).rgb
       + texture(u_input, v_uv + ts * vec2( 0.5, -0.5)).rgb
       + texture(u_input, v_uv + ts * vec2(-0.5, -0.5)).rgb;
FragColor = vec4(c * 0.25, 1.0);

升采样累加用 3×3 tent 滤波(角 1/16、边 2/16、中心 4/16)放大上一级泛光,再与本级模糊结果混合:

// u_mode = 5,u_input = 本级 A[i],u_bloom = 上级 B[i+1]
vec3 up = /* tent 9-tap 采样上级 B[i+1] */;
// Unity HDRP 式混合累加:B[i] = lerp(tent(B[i+1]), A[i], 0.5)
FragColor = vec4(mix(up, texture(u_input, v_uv).rgb, 0.5), 1.0);

lerp 0.5 的累加让上级泛光逐级指数衰减(0.54≈6%0.5^4 \approx 6\%),光晕主体保留、外圈快速回落,不会把整个画面糊掉。

合成

// u_mode = 3
vec3 hdr = texture(u_input, v_uv).rgb;
if (u_has_bloom == 1)
    hdr += texture(u_bloom, v_uv).rgb * u_bloom_strength;

if (u_tone_mapping == 1)
    hdr = aces_filmic(hdr);

if (u_gamma == 1)
    hdr = pow(hdr, vec3(1.0 / 2.2));

FragColor = vec4(hdr, 1.0);

Bloom 叠加 → ToneMapping → Gamma,三步按固定顺序在最后一步完成。


1.5 ToneMapping:ACES Filmic

HDR 值直接 clamp 到 [0,1] 会把高亮区域压成一团死白、丢失色彩层次。ToneMapping(色调映射)用一个平滑曲线把任意大的亮度压缩进 [0,1],同时保住"越亮的地方过渡越缓"的视觉感受。引擎用的是 ACES Filmic 的多项式近似:

vec3 aces_filmic(vec3 x)
{
    return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}

f(x)=x (2.51x+0.03)x (2.43x+0.59)+0.14f(x) = \frac{x \, (2.51x + 0.03)}{x \, (2.43x + 0.59) + 0.14}

这条 S 形曲线的妙处:暗部近似线性通过(低亮度不做多余处理),亮部趋向 1 但永远到不了(高光细节被压缩而非截断)。相比朴素的 Reinhard 公式 x/(1+x)x / (1+x),ACES 的对比度保持更好,是当代引擎的主流选择。


1.6 Gamma 校正

最后一步是把线性空间转回 sRGB 编码。显示器对物理亮度近似做幂次响应,sRGB 标准用一个约 2.2 的 gamma 编码补偿——所以线性值需要在输出前做一次:

csRGB=clinear1/2.2c_{sRGB} = c_{linear}^{1/2.2}

if (u_gamma == 1)
    hdr = pow(hdr, vec3(1.0 / 2.2));

不做这一步的画面会整体偏暗(暗部被压缩);做了 ToneMapping 但不做 Gamma 则会偏灰。与输入端对应:光照输入时颜色 pow 2.2 线性化(见 光照相关逻辑),输出端 pow 1/2.2 转回去,一来一回保证中间的物理计算全部在线性空间。

之所以用 shader 里的 pow 而不是 GL_FRAMEBUFFER_SRGB 自动转换,是因为 ToneMapping 和 Gamma 都有开关——转换的时机和顺序(先 ACES 后 gamma)需要显式控制。


1.7 无后处理时的回退呈现

PostProcessPass 没装配时(set_visual_pipeline(post_process = false)),Renderer 会走一条回退呈现管线:复用同一个 postprocess shader 的 u_mode = 3(composite、无 bloom),对 scene_fb 做一次 ToneMapping + Gamma 后写进 viewport_fb:

// 替代裸 blit:保证 HDR → LDR 转换不丢失
IDRCmd::bind_framebuffer_color(scene_fb, 0, 0);       // HDR 场景 → slot 0
IDRCmd::set_param(present_pipeline(), "u_mode", 3);   // composite(无 bloom)
IDRCmd::set_param(present_pipeline(), "u_has_bloom", 0);
/* tone mapping / gamma 效果位同源读 RendererSettings */
IDRCmd::bind_framebuffer(viewport_fb);
IDRCmd::draw_arrays(present_pipeline(), FullscreenQuad::vertex_buffer());

直接把 RGBA16F blit 到 RGBA8 会让超过 1.0 的值全部截断——回退管线保证"关掉后处理"关掉的只是 Bloom,HDR → LDR 的转换不会丢。效果位同样读 RendererSettings,与 PostProcessPass 的行为完全同源。


1.8 一个真实的坑:sampler 残留

PostProcessPass 的 Bloom 流程开头有两行不起眼但重要的代码:

// ⚠️ 解绑残留 sampler:GL sampler object 状态优先于纹理自身 wrap 参数,且为进程级持久
IDRCmd::unbind_sampler(0);
IDRCmd::unbind_sampler(1);

起因:ShadowPass 把 ClampToBorder(白色边框)的阴影采样器绑到了 slot 1/2,而 sampler object 的状态优先于纹理自身参数且不会自动解除。Bloom 的 u_bloom 恰好在 slot 1——模糊采样越界 tap 时读到的不是 ClampToEdge 的边缘像素,而是采样器边框色(纯白),屏幕四边出现一圈亮线。

ShadowPass 与后处理各干各的、互不知情,这类"全局状态残留"是 OpenGL 状态机模型的经典陷阱,也是 IDRenderer的设计与实现 里命令层存在的意义之一——把解绑、复位这类约定收进固定位置,而不是指望每个 Pass 自己记得。


2. 小结

后处理这条链的完整图景:

  • 数据流:scene_fb(RGBA16F 线性 HDR)→ Bloom 金字塔(extract → 5 级降采样 + 模糊 → tent 反向累加)→ composite(叠 Bloom → ACES ToneMapping → Gamma 2.2)→ viewport_fb(RGBA8)→ 窗口 / ImGui;
  • 结构:单 shader 多 u_mode 分派,一个全屏三角形走天下;效果开关与参数的唯一事实来源是 RendererSettings,即改即生效;
  • 兜底:无后处理装配时走同源的回退呈现管线,HDR → LDR 转换永不丢失。

到这里,从 GPU 资源封装、渲染图调度、光照、阴影到后处理,IDGameEngine 渲染系统的主干就串完了。