前两章我们从 OpenGL 一路讲到了 Blaze3D,知道顶点怎么进入 GPU,也知道 RenderTarget 怎么把一帧画面留在纹理里。但是把场景画进纹理只是第一步,怎样保住强光的亮度层次,怎样让光源产生自然的光晕,又怎样在一次绘制里留下颜色 法线 高光这些不同的数据呢?

这一章继续往后处理走,讲三个经常一起出现的技术:HDR 负责保存超出屏幕范围的亮度,Bloom 利用这些亮度生成光晕,MRT 则让同一个片段同时写入多张纹理。它们不是三个孤立的特效,而是一条从场景数据最终画面的渲染链。

原始资料:https://blog.fxmarkbrown.top/post/181

本文以 OpenGL 3.2+ 和 Minecraft 的 Blaze3D 概念为基础,并对照 Minecraft 26.3 snapshot 2 的服务端与客户端源码。示例中的裸 OpenGL 调用用于说明底层原理;26.3 的实际接入方式和当前 API 限制会单独说明。

3.1 HDR:先保存亮度,再决定怎么显示

LDR 丢掉了什么

普通颜色纹理最常见的格式是 RGBA8,每个通道只有 8 位,着色器最终能保留下来的值落在 [0, 1]。这对显示器来说正合适,但对中间计算来说太早了。

假设一个方块表面的亮度是 0.8,火把中心是 3.0,太阳是 20.0。如果场景直接写入 RGBA8,后两者都会被截断成 1.0。到了后处理阶段,程序只知道它们都是白色,已经无法判断谁应该更亮,也无法让太阳产生比火把更宽的 Bloom。

HDR(High Dynamic Range)解决的不是“让屏幕超过最大亮度”,而是推迟亮度信息的丢失。场景先写进浮点纹理,允许颜色大于 1.0;等光照 Bloom 曝光都算完之后,再把结果压缩到显示器能接受的范围。

这张图把原文中的房间 窗户 太阳字符画换成实际对比图,重点是 LDR 截断后丢失了哪些亮度差异。

场景数据

LDR 纹理中的结果

HDR 纹理中的结果

暗处

0.05

0.05

普通表面

0.8

0.8

火把中心

1.0,已截断

3.0

太阳

1.0,已截断

20.0

这张图回答一个问题:HDR 数据经过哪些阶段才会变成屏幕上的 LDR 像素。

HDR 帧缓冲格式

HDR 的关键不在 FBO 本身,而在颜色附件使用什么内部格式。FBO 只是容器,真正决定动态范围和带宽的是挂在上面的纹理。

格式

每像素大小

Alpha

适用场景

GL_RGBA8

4 字节

最终 LDR 输出 普通颜色

GL_RGBA16F

8 字节

通用 HDR 中间结果

GL_RGBA32F

16 字节

需要高精度的计算纹理

GL_R11F_G11F_B10F

4 字节

不需要 Alpha 的 HDR 颜色

大部分实时渲染使用 RGBA16F 就够了。它的带宽是 RGBA8 的两倍,但只有 RGBA32F 的一半;半精度浮点也足以保存颜色和光照。只有确实需要更高数值精度时,才值得换成 RGBA32F

下面是创建 HDR 颜色附件时最关键的部分:

int colorTexture = GL11.glGenTextures();
GL11.glBindTexture(GL11.GL_TEXTURE_2D, colorTexture);

// 内部格式决定 GPU 如何保存数据;RGBA16F 保留大于 1.0 的亮度。
GL11.glTexImage2D(
    GL11.GL_TEXTURE_2D,
    0,
    GL30.GL_RGBA16F,
    width,
    height,
    0,
    GL11.GL_RGBA,
    GL11.GL_FLOAT,
    (ByteBuffer) null
);

// 后处理会跨像素采样,线性过滤可以避免缩放时出现明显块状边界。
GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);

// 模糊核会采到纹理边缘以外,CLAMP_TO_EDGE 用来阻止画面从另一侧卷回来。
GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE);
GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE);

GL30.glFramebufferTexture2D(
    GL30.GL_FRAMEBUFFER,
    GL30.GL_COLOR_ATTACHMENT0,
    GL11.GL_TEXTURE_2D,
    colorTexture,
    0
);

这里容易混淆两个格式参数:GL_RGBA16F 是纹理在 GPU 内部的存储格式,GL_RGBA + GL_FLOAT 是上传数据时的通道和数据类型。因为这里只分配空间而不上传初始像素,最后一个参数传 null

创建完成后必须检查 FBO 完整性。附件尺寸不一致 格式不被支持 或 attachment 配置错误时,OpenGL 不一定在创建那一行报错,真正绘制时只会得到黑屏。

int status = GL30.glCheckFramebufferStatus(GL30.GL_FRAMEBUFFER);
if (status != GL30.GL_FRAMEBUFFER_COMPLETE) {
    // 状态码必须进入异常信息,否则不同驱动上的附件问题很难定位。
    throw new IllegalStateException("HDR framebuffer is incomplete: 0x" + Integer.toHexString(status));
}

线性空间与 Gamma

HDR 纹理保存的是线性光照结果。线性空间的意思是:2.0 的能量就是 1.0 的两倍,颜色相加和光照乘法都符合物理意义。屏幕显示使用的 sRGB 则经过 Gamma 编码,同样的数值差并不代表同样的光能差。

因此顺序不能颠倒,这张图把线性计算和最终编码的边界画了出来。

如果先做 Gamma 再 Bloom,模糊和叠加会发生在非线性颜色上,光晕通常会发灰;如果一帧做了两次 Gamma,画面会明显变亮。遇到“颜色怎么调都不对”的问题,先检查每张中间纹理到底是线性数据还是 sRGB 数据。

色调映射

HDR 值最终还是要进入 [0, 1]。直接 clamp 会把所有强光压成一片白色,色调映射(Tone Mapping)则用一条曲线逐渐压缩高亮,让暗部和中间调尽量保持不变。

最简单的是 Reinhard:

vec3 toneMapReinhard(vec3 hdr) {
    // 曲线在低亮度处接近线性,在高亮度处逐渐趋近 1.0。
    return hdr / (hdr + vec3(1.0));
}

它稳定而且便宜,但容易把高亮压得偏灰。需要更有对比度的画面时,可以使用常见的 ACES 近似曲线:

vec3 toneMapAces(vec3 hdr) {
    const float a = 2.51;
    const float b = 0.03;
    const float c = 2.43;
    const float d = 0.59;
    const float e = 0.14;

    // 这是实时渲染常用的 ACES 拟合曲线,不等同于完整的 ACES 色彩管理流程。
    return clamp((hdr * (a * hdr + b)) / (hdr * (c * hdr + d) + e), 0.0, 1.0);
}

完整的色调映射 Pass 只需要读取一张 HDR 纹理,再输出一个全屏四边形:

#version 150

uniform sampler2D HdrSampler;
uniform float Exposure;

in vec2 texCoord;
out vec4 fragColor;

vec3 toneMapAces(vec3 hdr) {
    const float a = 2.51;
    const float b = 0.03;
    const float c = 2.43;
    const float d = 0.59;
    const float e = 0.14;
    return clamp((hdr * (a * hdr + b)) / (hdr * (c * hdr + d) + e), 0.0, 1.0);
}

void main() {
    vec3 hdr = texture(HdrSampler, texCoord).rgb;

    // 曝光在线性空间中缩放光能,必须发生在色调映射之前。
    vec3 mapped = toneMapAces(hdr * Exposure);

    // 只有目标不是自动执行 sRGB 编码时,才在着色器里手动做 Gamma。
    mapped = pow(mapped, vec3(1.0 / 2.2));
    fragColor = vec4(mapped, 1.0);
}

代码里那句 Gamma 并不是永远需要。如果最终帧缓冲启用了 GL_FRAMEBUFFER_SRGB,硬件会自动把线性输出编码成 sRGB,此时着色器再 pow 一次就是重复校正。到底由谁负责转换必须在整个管线里只规定一次。

曝光控制

曝光是在色调映射前对 HDR 值做统一缩放。手动曝光最直接,适合固定风格的界面 特效或调试工具:

vec3 exposed = hdrColor * exposure;

自动曝光则需要先估计画面亮度。常见做法是把亮度纹理连续降采样到 1x1,或者用 Compute Shader 做并行归约,再用中灰值计算目标曝光:

float targetExposure = 0.18 / max(averageLuminance, 0.0001);

不能把目标曝光直接用于下一帧,否则从洞穴走到阳光下时画面会瞬间跳变。人眼适应亮暗需要时间,渲染也应该做指数平滑:

float adaptedExposure = mix(
    previousExposure,
    targetExposure,
    1.0 - exp(-deltaTime * adaptationSpeed)
);

这里的 deltaTime 必须参与计算。按帧固定插值会让 30 FPS 和 144 FPS 的适应速度不同,最后变成和机器性能绑定的视觉效果。

接入 Minecraft 时的边界

第二章讲过,Minecraft 的 RenderTarget 管理颜色纹理和深度纹理,也负责 resize 与释放。但对照 26.3 snapshot 2 客户端源码可以看到,它的 createBuffers() 把颜色格式固定成了 TextureFormat.RGBA8

// RenderTarget#createBuffers 的关键逻辑:默认目标只能得到 RGBA8 颜色纹理。
this.colorTexture = device.createTexture(
    () -> this.label + " / Color",
    15,
    TextureFormat.RGBA8,
    width,
    height,
    1,
    1
);
this.colorTextureView = device.createTextureView(this.colorTexture);

同一版本的 TextureFormat 只有 RGBA8 RED8 RED8I DEPTH32,没有 RGBA16F。这意味着上面的 HDR FBO 代码能解释 OpenGL 原理,却不能直接改写成 26.3 的 GpuDevice.createTexture() 调用。把普通 RenderTarget 命名成 HDR,也不会让它自动获得高动态范围。

真正接入时需要确认三件事:

  • 颜色附件的内部格式确实是浮点格式,而不是默认的 RGBA8

  • 场景着色器没有在写入 HDR 目标前提前 clamp 颜色。

  • 色调映射 Pass 位于最终输出之前,而且 Gamma 只处理一次。

在 26.3 上实现真正的 HDR,需要先扩展 TextureFormat 和对应 OpenGL backend 的格式映射,再让目标描述符、资源分配器和 RenderPass 都认识这种格式。这不是在业务渲染类里补几行 LWJGL 调用就能完成的改动,因为 RenderTarget 已经通过 GpuDevice 接管资源所有权。

还有一个明确约束:RenderTarget.resize()destroyBuffers() 都会调用 RenderSystem.assertOnRenderThread()。因此创建 重建 释放纹理必须留在渲染线程,并进入资源重载和窗口 resize 生命周期,不能从服务端线程或普通游戏逻辑线程直接操作 GPU 对象。

HDR 的成本

HDR 最大的成本不是色调映射那一次全屏绘制,而是显存和带宽。1920×1080 的一张 RGBA16F 纹理大约占 15.8 MiB;如果同时保留场景 Ping-Pong Bloom 多级纹理,内存会继续叠加。

因此优化顺序通常是:

  1. 场景颜色优先用 RGBA16F,不要默认上 RGBA32F

  2. 不需要 Alpha 时评估 R11F_G11F_B10F,但先确认混合和目标硬件支持。

  3. Bloom 等低频效果放到半分辨率或更低分辨率处理。

  4. 中间目标按尺寸和格式复用,避免每帧创建 删除纹理。

这一步的关键是保留后处理真正需要的信息,而不是让整条管线无差别地使用最高精度。

3.2 Bloom:让强光扩散,而不是把画面涂白

Bloom 到底在模拟什么

现实中的强光进入镜头或眼睛后会发生散射,光能从原来的像素扩散到周围,于是亮源边缘出现柔和光晕。Bloom 模拟的就是这种现象。

这张图对比同一个亮源在没有 Bloom 和加入 Bloom 后的边缘变化。

它不是在光源外面画一个半透明圆,也不是简单提高饱和度。标准 Bloom 至少包含三步:提取高亮、模糊高亮、把模糊结果加回原场景。

这张图把完整 Bloom 流程拆成高亮提取、模糊、升采样和合成四个明确阶段。

注意色调映射在 Bloom 之后。若先把画面压到 [0, 1],太阳和普通白色方块又会失去亮度差,Bright Pass 只能靠颜色猜谁是光源。

高亮提取

最直白的办法是计算亮度,超过阈值的像素保留,其余变成黑色:

float luminance(vec3 color) {
    // 权重来自人眼对不同颜色的感知差异,绿色对亮度的贡献最高。
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

vec3 extractHard(vec3 color, float threshold) {
    return luminance(color) > threshold ? color : vec3(0.0);
}

硬阈值会在临界亮度附近产生突变。一个像素从 0.99 变到 1.01,Bloom 贡献会突然从零跳到完整颜色,移动的光源因此容易闪烁。

更常用的是软阈值,也叫 soft knee:

vec3 extractBloom(vec3 color, float threshold, float knee) {
    float brightness = max(max(color.r, color.g), color.b);

    // knee 定义阈值附近的过渡宽度,避免像素跨过阈值时突然闪烁。
    float soft = brightness - threshold + knee;
    soft = clamp(soft / max(2.0 * knee, 0.0001), 0.0, 1.0);
    soft = soft * soft * knee;

    float contribution = max(soft, brightness - threshold);
    contribution /= max(brightness, 0.0001);
    return color * contribution;
}

这里使用最大颜色通道判断强度,能保住高饱和度的蓝色或红色光源;使用感知亮度则更接近人眼明暗。没有唯一正确的选择,关键是让阈值定义和美术目标一致。

为什么高斯模糊要拆成两遍

假设模糊核宽度是 9,直接做二维卷积,每个像素要采样 9 x 9 = 81 次。高斯核可以分离成水平和垂直两个一维核,先横向采样 9 次,再纵向采样 9 次,只需要 18 次。

这张图直接对比二维卷积和分离高斯的采样次数。

横向 Pass 的输出必须写进临时纹理,纵向 Pass 再读取它。不能在同一张纹理上边采样边写入,这正是第二章 Ping-Pong 缓冲要解决的问题。

#version 150

uniform sampler2D SourceSampler;
uniform vec2 Direction;
uniform vec2 TexelSize;

in vec2 texCoord;
out vec4 fragColor;

const float WEIGHT[5] = float[](
    0.227027,
    0.194595,
    0.121622,
    0.054054,
    0.016216
);

void main() {
    vec3 result = texture(SourceSampler, texCoord).rgb * WEIGHT[0];

    for (int i = 1; i < 5; i++) {
        vec2 offset = Direction * TexelSize * float(i);
        // 对称采样保证核的中心不偏移,移动时不会把光晕拖向一侧。
        result += texture(SourceSampler, texCoord + offset).rgb * WEIGHT[i];
        result += texture(SourceSampler, texCoord - offset).rgb * WEIGHT[i];
    }

    fragColor = vec4(result, 1.0);
}

水平方向传入 Direction = vec2(1, 0),垂直方向传入 vec2(0, 1)TexelSize1.0 / textureSize,不能直接用像素数量当 UV 偏移。

多尺度 Bloom

只在原始分辨率反复做小核模糊,既贵又很难得到宽光晕。多尺度 Bloom 会建立一条逐级缩小的纹理链,在低分辨率上让同一个采样半径覆盖更大的屏幕范围,再从最小一级逐层放大并合并。

这张图回答一个问题:不同尺度的高亮数据如何下采样,再回到最终 Bloom 纹理。

以 1920×1080 为例:

层级

分辨率

主要贡献

Mip 0

960×540

光源附近的清晰光晕

Mip 1

480×270

中等范围扩散

Mip 2

240×135

大范围柔光

Mip 3

120×68

最宽的低频光晕

这里第一层就从半分辨率开始。Bloom 本来就是低频模糊效果,没有必要为每个屏幕像素保留完整分辨率;这一步通常能换来最直接的性能收益。

降采样不能只取一个像素,否则细小光源会在分辨率变化时忽隐忽现。至少要用 Box Tent 或其他低通滤波器汇总邻域,再写入下一级。

vec3 downsample13Tap(sampler2D source, vec2 uv, vec2 texel) {
    vec3 result = texture(source, uv).rgb * 4.0;

    // 边和角共同参与,先滤掉高频信息再缩小,避免细光源闪烁。
    result += texture(source, uv + texel * vec2(-1.0, -1.0)).rgb;
    result += texture(source, uv + texel * vec2( 1.0, -1.0)).rgb;
    result += texture(source, uv + texel * vec2(-1.0,  1.0)).rgb;
    result += texture(source, uv + texel * vec2( 1.0,  1.0)).rgb;
    result += texture(source, uv + texel * vec2(-1.0,  0.0)).rgb * 2.0;
    result += texture(source, uv + texel * vec2( 1.0,  0.0)).rgb * 2.0;
    result += texture(source, uv + texel * vec2( 0.0, -1.0)).rgb * 2.0;
    result += texture(source, uv + texel * vec2( 0.0,  1.0)).rgb * 2.0;

    return result / 16.0;
}

升采样时从最小一级开始,把低分辨率 Bloom 放大并加到上一级。越低的层级覆盖范围越广,越高的层级保留光源附近的轮廓,最后得到既有核心又有外圈的光晕。

合成顺序

Bloom 最终通过加法回到 HDR 场景:

vec3 scene = texture(SceneSampler, texCoord).rgb;
vec3 bloom = texture(BloomSampler, texCoord).rgb;

// Bloom 仍是线性 HDR 数据,先合成再统一色调映射。
vec3 combined = scene + bloom * bloomStrength;
vec3 mapped = toneMapAces(combined * exposure);

不要对场景纹理使用 mix(scene, bloom, strength)mix 会在增加光晕的同时压低原画面,最后像盖了一层雾;Bloom 表示散射出来的额外光能,通常应该做加法。

如果想避免画面整体发灰,应先提高提取阈值或减小软阈值范围,再调 bloomStrength。一上来只降低强度,往往会让真正的光源也失去光晕。

用 MRT 省掉 Bright Pass

传统流程先渲染完整场景,再用一个全屏 Pass 提取高亮。若场景着色器本来就知道当前片段的发光强度,可以用 MRT 同时输出场景颜色和 Bloom 颜色:

layout(location = 0) out vec4 sceneColor;
layout(location = 1) out vec4 bloomColor;

void main() {
    vec4 color = calculateSurfaceColor();
    sceneColor = color;

    // 发光材质由材质数据决定,不必从最终颜色反推哪些像素应该发光。
    bloomColor = vec4(color.rgb * emissiveStrength, color.a);
}

这种方式少了一次全屏读取,也能让不够亮但明确标记为发光的材质参与 Bloom。但代价是所有相关场景管线都要支持第二个输出,而且额外附件会增加写带宽。后面会看到,26.3 当前公开的 Blaze3D RenderPass 还不能直接绑定第二个颜色输出,所以这段 GLSL 是 MRT 原理示例,不是能直接装进现有粒子管线的代码。

实例:服务端粒子怎样走到客户端 Bloom

服务端本身不渲染。以 26.3 snapshot 2 的粒子链为例,ServerLevel.sendParticles() 做的是构造 ClientboundLevelParticlesPacket,然后按距离把数据发给玩家。默认只发给 32 格内的玩家,overrideLimiter=true 时服务端发送半径才会扩大到 512 格。

一个服务端调用可以这样写:

serverLevel.sendParticles(
    ParticleTypes.END_ROD,
    false, // 不绕过距离和客户端粒子限制,避免远处特效制造无效网络与渲染负担。
    true,  // 在最低粒子设置下提高保留概率,但不保证每个粒子都一定出现。
    x,
    y,
    z,
    24,
    0.25,
    0.25,
    0.25,
    0.02
);

这个包只携带 ParticleOptions、位置、三个方向的分布范围、最大速度、数量和两个限制标志。它没有 HDR 值、Bloom 强度、Shader 名称,更没有任何 GPU 指令。服务端决定发生什么,客户端决定怎么画。

客户端收到包后,ClientPacketListener.handleParticleEvent() 根据 count 走两种语义。count == 0 时,xDist/yDist/zDist * maxSpeed 被当作单个粒子的确定速度;count > 0 时,位置偏移和速度都使用高斯随机数生成。因此不要把 count=0 理解成“不生成粒子”。

接着 ClientLevel.doAddParticle() 再做一次本地限制:普通粒子超过相机 32 格会被丢弃,粒子设置为 MINIMAL 时也可能不创建;只有 overrideLimiter 才直接跳过这两项检查。通过检查后,ParticleEngine 根据粒子类型找到 ParticleProvider,创建具体 Particle 并放进对应的 ParticleGroup

这张图把网络语义、客户端粒子实例和最终渲染目标连在一起。

进入渲染帧后,ParticleEngine.extract() 先把可见粒子提取到 ParticlesRenderStateLevelRenderer 在主 Pass 中调用 particlesRenderState.submit(...),并在启用透明度后处理时创建独立的 particles target;绘制半透明内容前,它还会执行 particleTarget.copyDepthFrom(mainTarget),让粒子继续遵守主场景的遮挡关系。

如果要让 END_ROD 产生 Bloom,改服务端包没有用。客户端需要让对应粒子材质输出可识别的高亮,或者把 particles target 交给高亮提取 模糊 合成链。服务端最多通过粒子类型 数量 颜色等语义影响输入,不能直接指定客户端采用哪条后处理管线。

26.3 中的后处理方式

26.3 的 LevelRenderer 已经使用 FrameGraphBuilder 组织一帧:主目标通过 importExternal() 进入图,translucent particles weather clouds 等目标按需通过 createInternal() 创建,每个 FramePass 再用 readsAndWrites() 声明依赖。PostChain.addToFrame() 会把每个 PostPass 继续加入同一张帧图,这正适合表达“输入纹理 -> 高亮提取 -> 模糊 -> 合成”。

PostPass 的实际执行也不是旧版的绑定 FBO 再画四边形,而是通过 CommandEncoder.createRenderPass() 创建 Pass、绑定输入纹理和 Uniform,最后 draw(0, 3) 画一个全屏三角形。旧的 PostChain.process() 在当前源码中已经标记为 @Deprecated,新代码应优先接入现有 FrameGraph,而不是另起一张临时帧图。

接入时仍要处理几个边界:

  • 窗口 resize 后,整条 Bloom 纹理链都要按新尺寸重建或调整。

  • 每一级纹理必须使用线性过滤和边缘钳制,否则升采样会出现块状边缘或画面回卷。

  • 资源包重载时,着色器 Uniform 和目标引用都可能失效,不能长期缓存旧句柄。

  • 每个 Pass 都要明确输入和输出,禁止对同一 attachment 同时读写。

  • 最终合成后要恢复 Minecraft 后续渲染所需的目标 视口和管线状态。

如果效果只覆盖少量粒子或方块实体,可以先把它们画进独立目标,再对这个目标做 Bloom,没必要处理整张主场景。反过来,如果要让太阳 熔岩 粒子和实体统一参与,就要在场景级别规划数据来源,避免每个模块各做一套模糊链。

调参先看什么

现象

优先检查

常见原因

所有白色方块都发光

Threshold

在 LDR 上提取或阈值过低

光晕边缘闪烁

降采样滤波

直接点采样导致小亮源丢失

光晕像方块

Mip 数量和过滤

分辨率太低或使用最近邻

画面整体发灰

Soft Knee 与合成

参与 Bloom 的像素太多

GPU 时间过高

分辨率和 Pass 数

全分辨率反复模糊

resize 后画面错位

目标尺寸与 TexelSize

Uniform 仍是旧分辨率

Bloom 的质量不取决于模糊次数越多越好,而取决于高亮来源 多尺度分布和合成顺序是否正确。

3.3 MRT:一次片段计算,写入多份结果

为什么需要多个输出

普通片段着色器只有一个颜色输出,写到 COLOR_ATTACHMENT0。但后处理不只需要最终颜色:Bloom 可能要发光颜色,SSAO 要法线和深度,运动模糊要速度向量,延迟渲染还要材质参数。

最直接的办法是把场景画很多次:第一次写颜色,第二次写法线,第三次写材质。问题是每个 Pass 都要重新提交几何、执行顶点着色器、光栅化并运行一部分重复逻辑。

MRT(Multiple Render Targets)让一个片段着色器同时写入多个颜色附件。几何只处理一次,片段计算得到的不同结果分别进入不同纹理。

这张图对比传统单目标和 MRT 的输出关系。

原文还分别用两段字符画对比了传统多 Pass 与 MRT 单 Pass。把两条路径拆开后,重复几何处理发生在哪里会更清楚。

FBO 和着色器如何对应

MRT 有两边必须同时配对:CPU 侧用 glDrawBuffers 声明哪些 attachment 可以写,片段着色器用 layout(location = n) 声明每个输出写到哪里。

创建三个颜色附件之后,CPU 侧要明确启用它们:

int[] drawBuffers = {
    GL30.GL_COLOR_ATTACHMENT0,
    GL30.GL_COLOR_ATTACHMENT1,
    GL30.GL_COLOR_ATTACHMENT2
};

// attachment 已挂载不代表会被写入,glDrawBuffers 才定义片段输出的路由。
GL20.glDrawBuffers(drawBuffers);

着色器的 location 与数组位置一一对应:

#version 150

in vec3 viewPosition;
in vec3 viewNormal;
in vec2 texCoord;

uniform sampler2D DiffuseSampler;
uniform float Roughness;

layout(location = 0) out vec4 outAlbedo;
layout(location = 1) out vec4 outNormal;
layout(location = 2) out vec4 outMaterial;

void main() {
    vec4 albedo = texture(DiffuseSampler, texCoord);
    if (albedo.a < 0.01) {
        // 所有 MRT 输出都属于同一个片段;discard 会同时取消全部附件写入。
        discard;
    }

    outAlbedo = albedo;

    // 法线编码到 [0, 1],便于写入归一化格式;读取时要再还原到 [-1, 1]。
    outNormal = vec4(normalize(viewNormal) * 0.5 + 0.5, 1.0);

    // 小型材质参数可以打包进同一附件,减少目标数量和带宽。
    outMaterial = vec4(Roughness, 0.0, 0.0, 1.0);
}

如果忘了 glDrawBuffers,多数情况下只有 attachment 0 有内容,其他纹理保持清除色。若 location 和附件顺序错位,程序不会理解“这是法线纹理”,它只会忠实地把数据写进错误目标。

G-Buffer 与延迟渲染

MRT 最典型的用途是 G-Buffer。传统前向渲染在绘制每个物体时立刻计算所有光源;延迟渲染先把表面属性写入多张纹理,等几何阶段结束后,再用全屏或光体积 Pass 统一计算光照。

这张图把原文 G-Buffer 字符框里的通道用途重新整理成附件布局。

这张图继续说明这些附件如何从几何 Pass 流向光照 Pass。

这套结构的收益来自“几何复杂度”和“光源数量”解耦。一个像素最终只针对可见表面计算光照,不会给后面被遮住的片段反复算很多灯。

但 G-Buffer 不是附件越多越好。1920×1080 下,每多写一张 RGBA16F 就多出约 15.8 MiB 的存储,并在几何 Pass 增加整屏写带宽。位置通常可以从深度和逆投影矩阵重建,没有必要再存一张 RGB16F

vec3 reconstructViewPosition(float depth, vec2 uv, mat4 inverseProjection) {
    vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
    vec4 view = inverseProjection * clip;

    // 透视除法恢复视图空间坐标,不能直接使用未除以 w 的齐次坐标。
    return view.xyz / view.w;
}

法线也可以使用两个通道的八面体编码,粗糙度 金属度 自发光强度可以按精度要求打包。优化 MRT 的重点通常不是少做一次算术,而是少写一张大纹理。

MRT 不等于一定更快

MRT 省掉的是重复几何处理,增加的是显存写入。如果场景几何很重、输出数据确实会被后续多个 Pass 使用,MRT 通常划算;如果只为了一个很小的局部效果额外写四张全屏纹理,带宽成本可能比单独补一个 Pass 更高。

可以用这个判断顺序:

  1. 这些数据是否来自同一批可见片段?

  2. 后续 Pass 是否真的会读取每个输出?

  3. 能否从深度或已有附件重建,而不是新增纹理?

  4. 能否降低通道数 精度或分辨率?

  5. 目标硬件的最大 attachment 数和带宽是否足够?

OpenGL 可以查询限制:

int maxAttachments = GL11.glGetInteger(GL30.GL_MAX_COLOR_ATTACHMENTS);
int maxDrawBuffers = GL11.glGetInteger(GL20.GL_MAX_DRAW_BUFFERS);

// 实际可用输出数同时受 attachment 和 draw buffer 两个上限约束。
int usableTargets = Math.min(maxAttachments, maxDrawBuffers);

不要把桌面显卡常见的 8 个输出当作规范保证。创建管线时应根据需要声明最少目标,并在能力不足时给出明确降级路径。

混合 清除与深度

MRT 中所有附件共享同一批片段,但每张纹理未必需要相同的混合策略。场景颜色可能使用 Alpha 混合,Bloom 目标可能使用加法,而法线或材质数据通常应该直接覆盖。

基础 OpenGL 3.2 状态往往只能统一控制混合;按 attachment 设置 glBlendFunci 需要更高版本或扩展支持。面向 Minecraft 的实现不能只看开发机的 OpenGL 版本,还要服从游戏声明的图形 API 下限。

清除同样要逐目标思考。glClear(GL_COLOR_BUFFER_BIT) 会清除当前启用的 draw buffers,但不同附件可能需要不同默认值。比如法线纹理用 (0.5, 0.5, 1.0) 表示朝向相机的默认法线,材质纹理可能要用完全不同的初值。

// 单独清除能让每个 attachment 使用符合其编码约定的默认值。
GL30.glClearBufferfv(GL11.GL_COLOR, 0, new float[] {0f, 0f, 0f, 0f});
GL30.glClearBufferfv(GL11.GL_COLOR, 1, new float[] {0.5f, 0.5f, 1f, 0f});
GL30.glClearBufferfv(GL11.GL_COLOR, 2, new float[] {1f, 0f, 0f, 0f});

深度附件通常由这些颜色目标共享,因为它们描述的是同一次几何 Pass。不要为了每张颜色纹理重复创建深度;但后续使用 G-Buffer 时要保证深度纹理仍然存活,且没有在不该清除的时候被下一阶段覆盖。

在 Blaze3D 中落地

Blaze3D 的高级管线能力随 Minecraft 版本变化较快。26.3 snapshot 2 的 CommandEncoder.createRenderPass() 只接收一个颜色 GpuTextureView 和一个可选深度 GpuTextureViewRenderTarget 本身也只有一组 colorTexture/colorTextureView。所以当前公开抽象不只是“不方便使用 MRT”,而是根本没有声明多个颜色附件的位置

这意味着片段着色器即使写了 layout(location = 1),CPU 侧也没有第二个 attachment 可以绑定。要做真正 MRT,必须同时扩展 CommandEncoderRenderPassBackend、OpenGL framebuffer 组装、目标描述符和资源生命周期。只修改 GLSL 不会得到第二张纹理。

实现 MRT 时至少需要一个独立的资源所有者,负责:

  • 通过 Blaze3D backend 在渲染线程创建 framebuffer 与全部 attachment。

  • 记录每个 attachment 的格式 尺寸和用途,避免 location 靠约定猜测。

  • 在窗口尺寸变化时原子地重建全部附件,不能只 resize 其中一张。

  • 在资源释放或客户端关闭时通过 GraphicsResourceAllocator 释放目标。

  • 把读写关系接入 FrameGraphBuilder,不能绕过资源句柄偷偷读写同一张纹理。

固定槽位协议也要写清楚。例如 location 0 永远是场景颜色,location 1 永远是 Bloom,不能让不同着色器各自解释。只要一个管线把第二个输出当法线、另一个把它当高亮,后处理就会读取到语义错误的数据,而且这种错误通常只表现为颜色异常。

如果只是给特定粒子做 Bloom,更简单的方案往往是单独渲染这些粒子到一个目标,再做模糊合成。只有当大量材质都需要额外输出,或者后续已经要建立 G-Buffer 时,MRT 的复杂度才真正值得。

3.4 把三者连成一条管线

HDR、Bloom、MRT 放在一起之后,完整顺序如下图。

顺序比某一段着色器代码更重要。HDR 在色调映射前保存亮度,Bloom 在亮度还没有被截断时提取强光,MRT 则负责减少重复几何工作。只要其中一个阶段提前把数据压平,后面的阶段就没有办法把信息猜回来。

实际开发时可以从最小闭环开始:在通用 OpenGL 环境中,先做一张 RGBA16F 场景纹理和一个色调映射 Pass;如果目标是 26.3 客户端,就要先补齐 Blaze3D 的浮点纹理格式和 backend 映射,再建立同样的闭环。确认颜色空间正确后加入单级 Bloom,最后再根据性能数据决定是否做多尺度和 MRT,这样每增加一个阶段,都能明确知道是哪一步改变了画面。


高级渲染不是把更多 Pass 接在一起,而是让每个阶段都保留下一阶段真正需要的数据。