PostProcess相关逻辑
需要预先了解:
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)——覆盖范围是 ,屏幕外的部分被裁掉。用三角形而不用四边形的原因:
- 四边形是两个三角形拼的,对角线上插值权重不一致,可能产生细微的采样不均;
- 三角形不需要索引缓冲,一次
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 的累加让上级泛光逐级指数衰减(),光晕主体保留、外圈快速回落,不会把整个画面糊掉。
合成
// 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);
}
这条 S 形曲线的妙处:暗部近似线性通过(低亮度不做多余处理),亮部趋向 1 但永远到不了(高光细节被压缩而非截断)。相比朴素的 Reinhard 公式 ,ACES 的对比度保持更好,是当代引擎的主流选择。
1.6 Gamma 校正
最后一步是把线性空间转回 sRGB 编码。显示器对物理亮度近似做幂次响应,sRGB 标准用一个约 2.2 的 gamma 编码补偿——所以线性值需要在输出前做一次:
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 渲染系统的主干就串完了。



