前三章我们从 OpenGL 的顶点和状态讲到了 Blaze3D,又继续走过 HDR Bloom 与 MRT。到了这里,一个更容易踩坑的问题出现了:底层原理没有变,但 Minecraft 组织渲染代码的方式已经变了。

旧版本里,我们习惯拿到一个绘图对象,设置状态,然后立刻把矩形 文字或模型画出去。26.3 snapshot 2 则把提取游戏状态向 GPU 提交命令分成两个阶段,GUI 世界渲染 后处理和临时资源也分别有自己的组织方式。如果仍然按照“调用绘制方法就会立刻出现在屏幕上”去理解,代码可以写出来,执行顺序 资源生命周期和性能问题却很难说清。

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

本文对照 Minecraft 26.3 snapshot 2 的服务端与客户端源码。原文基于较早的 1.21.11 + NeoForge,其中以 GuiGraphics 为中心的部分已经发生变化;本文会保留原理,但使用 26.3 当前的类名和调用链重写。

4.1 心智模型变了:从立即绘制到提取与提交

早期 OpenGL 教程常按一条直线展开:绑定 Shader,绑定纹理,设置混合,上传顶点,然后调用 draw。这个模型适合解释 GPU 状态机,也能说明为什么错误的调用顺序会产生错误画面。

但大型游戏不能让每个界面和实体随意修改全局 GPU 状态。渲染器需要先知道这一帧要画什么,才能统一安排层级 排序 合批 资源分配和 Pass 顺序。因此现代 Minecraft 更接近提交描述:游戏逻辑先产生 RenderState,渲染阶段再把这些状态变成真正的 GPU 命令。

这张图先把两种心智模型放在一起。右侧多出来的阶段,不是把一次绘制故意拆复杂,而是把原本藏在全局状态里的约束变成显式数据。

这里还要区分立即模式和保留状态。所谓“保留”不是把 GUI 元素永久缓存起来,26.3 的 GuiRenderState 仍然会在帧间重置;它保留的是当前帧从提取到绘制之间的描述。这一小段生命周期已经足够让渲染器观察整批元素,再决定怎样准备。

于是一个 fill(...) 调用不再等于一个 GPU Draw Call。它可能只创建一个 ColoredRectangleRenderState,后来和其他相容矩形排在一起,最终合并进同一个 Mesh。API 表面仍像“画一个矩形”,真正语义已经变成“登记一个矩形”。

4.2 26.3 GUI 主线:Extractor、State 与 Renderer

原文用 GuiGraphics 解释 GUI,这在较早版本中没有问题。但对照当前客户端源码,26.3 已经没有这个类,职责被拆成了三个部分:

组件

职责

不负责什么

GuiGraphicsExtractor

读取 GUI 调用,创建矩形 文字 物品 贴图等 RenderState

不直接发出 GPU 绘制

GuiRenderState

保存元素 分层 边界 Glyph 物品和画中画状态

不创建 RenderPass

GuiRenderer

准备元素 排序 合并,最后向主目标提交绘制

不决定游戏语义

完整过程分成提取和绘制两段:

提取阶段

GameRenderer.extract() 先提取世界状态,随后调用 extractGui()。这里创建 GuiGraphicsExtractor,屏幕 HUD BossBar 当前界面和 Overlay 都通过它把内容写进同一个 GuiRenderState

简化后的结构如下:

public void extract(DeltaTracker deltaTracker, boolean advanceGameTime) {
    // 世界与 GUI 使用同一帧时序提取,但写入各自的 RenderState。
    extractLevel(...);
    extractGui(deltaTracker, shouldRenderLevel, resourcesLoaded);
}

private void extractGui(...) {
    gameRenderState.guiRenderState.reset();
    GuiGraphicsExtractor graphics = new GuiGraphicsExtractor(
        minecraft,
        gameRenderState.guiRenderState,
        xMouse,
        yMouse
    );

    // HUD、Screen 和 Overlay 在这里登记当前帧的 GUI 内容。
    minecraft.gui.extractRenderState(graphics, deltaTracker);
}

这段代码的重点不是具体方法名,而是 reset -> extract -> render 的边界。逻辑代码只能描述当前帧,不能把 GuiGraphicsExtractor 长期保存下来,更不能假设方法返回时像素已经进入主帧缓冲。

GuiGraphicsExtractor 内部也能验证这一点。比如 fill(...) 创建 ColoredRectangleRenderStatetext(...) 创建 GuiTextRenderStateitem(...) 则先建立可跟踪的物品状态,再写入 GuiItemRenderState。这些方法的共同结果是向状态容器增加元素,不是调用 RenderPass.draw()

准备与绘制阶段

完成世界渲染和后处理后,GameRenderer.render() 清理深度,再调用 GuiRenderer.render()。这个方法内部先 prepare(),然后才 draw(),最后清理本帧列表并重置 GUI 状态。

prepare() 做了几件容易被“立即绘制”模型忽略的事:

  • 准备 Picture-in-Picture 和物品模型。

  • 把文字展开成可提交的 Glyph 元素。

  • 按 Scissor、Pipeline sort key 和纹理设置排序。

  • 把相容元素合并成 Mesh,记录最终 Draw Command。

draw() 才会创建正交投影,绑定主 RenderTarget 的颜色和深度 View,打开 RenderPass 并执行准备好的绘制范围。GUI 模糊边界也在这里处理,因此模糊前后的层不能只靠业务代码的调用顺序猜测。

4.3 真实例子:BossBar 怎样出现在屏幕上

渲染架构最容易在客户端内部讲成一团,所以我们换成一个完整例子:服务端更新 BossBar 进度,客户端最终画出长度变化的血条。

这张图把 MineCore 提供的服务端和客户端源码连在一起:

服务端的 ServerBossEvent.setProgress() 只在数值变化时设置脏状态,并广播更新包:

@Override
public void setProgress(float progress) {
    if (progress != this.progress) {
        super.setProgress(progress);
        this.setDirty();

        // 包只携带新的进度语义,不携带顶点、贴图或 GPU 状态。
        this.broadcast(ClientboundBossEventPacket::createUpdateProgressPacket);
    }
}

名称 颜色 分段样式和暗化屏幕等属性使用各自的更新 Operation。首次添加时,数据也只是 UUID 名称 进度 颜色 样式以及音乐 雾效相关布尔值。

客户端收到 ClientboundBossEventPacket 后,由 ClientPacketListener.handleBossUpdate() 交给 BossHealthOverlay.update()。Overlay 保存一个可插值的 LerpingBossEvent,到了 GUI 提取阶段才决定贴图和位置:

public void extractRenderState(GuiGraphicsExtractor graphics) {
    if (!events.isEmpty()) {
        // BossBar 单独进入下一层,避免与前面的 HUD 元素交叉覆盖。
        graphics.nextStratum();

        for (LerpingBossEvent event : events.values()) {
            int x = graphics.guiWidth() / 2 - 91;
            drawBar(graphics, x, yOffset, event);

            Component name = event.getName();
            graphics.text(minecraft.font, name, centeredX(name), yOffset - 9, -1);
        }
    }
}

实际 drawBar() 使用客户端内置的 Sprite Identifier 和 RenderPipelines.GUI_TEXTURED。进度宽度按 Mth.lerpDiscrete(event.getProgress(), 0, 182) 计算,文字宽度和屏幕坐标也全部由客户端决定。

这就是服务端与渲染端之间最重要的边界:服务端发送是什么,客户端决定怎么画。服务端插件可以改变 BossBar 的名称 进度和样式,却不能通过这个包指定 Shader、上传纹理或要求客户端创建某个 RenderPass。想增加真正的新视觉效果,仍然需要客户端资源或模组配合。

4.4 二维变换、Scissor 与 GUI 层级

GUI 看起来是二维的,但它仍然有自己的坐标变换。26.3 的 GuiGraphicsExtractor 使用 Matrix3x2fStack 保存二维 pose,平移 缩放和旋转都作用在后续元素上。与过去常见的 4x4 矩阵相比,3x2 仿射矩阵只保留二维 GUI 真正需要的部分。

Scissor 必须跟着 Pose 变换

Scissor 用来限制滚动列表 输入框或局部面板的可见区域。容易犯的错误是先在局部坐标计算裁剪框,却把它当作最终屏幕坐标直接提交;一旦父控件有缩放或平移,内容和裁剪边界就会错开。

当前实现会先把 ScreenRectangle 通过 pose 变换,再交给 ScissorStack

因此控件的内容和 Scissor 必须在同一套变换约定下生成。旋转后的矩形裁剪最终仍需要一个轴对齐屏幕矩形,这也意味着 Scissor 适合矩形窗口,不适合任意形状遮罩;后者要使用 Stencil 或专门的遮罩 Pass。

Stratum 不是普通的 Z 坐标

GuiRenderState 内部保存 List<Node> strata,节点再组织 GUI 元素 Glyph 文字 物品和 Picture-in-Picture 状态。它会利用元素边界是否相交,建立能够维持覆盖关系的垂直节点结构。

nextStratum() 则是一个更强的边界,BossBar 和 Tooltip 这类内容可以明确进入后续层。它不是简单给每个顶点加一个越来越大的 Z 值,而是告诉准备阶段:这些元素的覆盖顺序不能被跨层排序或合批破坏。

这样做是为了同时满足正确性和批处理。完全按调用顺序提交最容易保持画面,但会产生大量 Pipeline 和纹理切换;完全按材质排序最省切换,却可能把后画的背景移到文字上面。Bounds 与 Stratum 给渲染器提供了一个中间条件:不相交的元素可以更自由地排序,相交或跨层的元素必须维持视觉约束。

4.5 RenderPipeline 是绘制契约,不只是 Shader

OpenGL 教程里经常把“管线”口语化成 Shader Program,但 26.3 的 RenderPipeline 描述的内容更多。它把 Shader 资源、顶点格式、图元模式、颜色目标、深度模板、剔除和排序键放在一个不可变对象中。

当前结构里可以看到这些核心字段:

  • 顶点和片段 Shader,以及编译时 defines

  • samplers 与 Uniform 描述。

  • ColorTargetState 和可选的 DepthStencilState

  • Polygon Mode、Culling、VertexFormat 与 VertexFormat.Mode。

  • 供准备阶段使用的 sortKey

这份契约解决的是“这批顶点必须在什么状态下解释”。如果两个元素使用不同混合方式 深度规则或顶点布局,即使 Shader 文件相同,也不能因为名字接近就合进同一个 Draw Command。

Minecraft 自己会从多个 Snippet 组合管线。例如 GUI_SNIPPET 放入 GUI 共用的混合和顶点约定,GUI_TEXTURED_SNIPPET 再加入纹理采样,最终形成 RenderPipelines.GUI_TEXTURED。这种组合比复制一整份配置更容易维持公共约束。

注册 API 要服从加载器版本

原文展示了 NeoForge 的 RegisterRenderPipelinesEvent,但提供的 26.3 vanilla 源码只能确认管线模型,不能证明某个加载器在当前快照仍使用相同事件。RenderPipelines.register(...) 在 vanilla 源码中也是私有方法。

因此这里不写一段看似能用、实际绑定旧版加载器的注册代码。模组真正接入时,应先确认目标 NeoForge Fabric 或其他加载器对 26.3 的扩展点,再用它登记 RenderPipeline管线定义可以迁移,注册入口不能跨版本猜测

4.6 Texture、View 与资源所有权

第二章已经讲过 RenderTarget 是离屏渲染目标。到了 26.3,还需要把目标、纹理和纹理 View 分清,否则 resize 和释放时很容易留下悬空引用。

RenderTarget 持有颜色与可选深度的 GpuTexture,同时为它们建立 GpuTextureViewTextureTarget 在渲染线程创建或 resize 这些资源,当前默认颜色格式仍然是 TextureFormat.RGBA8

GpuTexture 表示实际存储,并带有用途标记,例如 Copy Source、Copy Destination、Texture Binding 和 Render Attachment。用途不是注释,而是后端验证和资源创建的一部分:一张只允许采样的纹理,不能在没有 Render Attachment usage 的情况下突然作为颜色附件。

GpuTextureView 则是对纹理某个 mip 范围的访问方式。RenderPass 绑定的是 View,这样未来可以让不同 Pass 观察同一存储的不同层级;但 View 不会把所有权转移给 Pass,它自身也实现 AutoCloseable,资源所有者仍要按约定释放。

可以用三个问题检查资源边界:

  1. 谁创建并拥有 GpuTexture

  2. 窗口 resize 时谁重建 Texture 和 View?

  3. 最后一个使用者结束后,谁负责 close()

答案不能是“反正 RenderSystem 会处理”。临时资源交给 GraphicsResourceAllocator 或 FrameGraph 管理,长期目标交给明确的资源容器;两者混用时,最常见的结果是旧尺寸 View 被继续绑定,或者目标已释放但状态仍保存着引用。

4.7 FrameGraph:Pass、Handle、裁剪与生命周期

随着后处理增加,手工维护“先画天空,再画世界,再画天气,然后做后处理”会出现两个问题:顺序写在控制流里,资源读写却藏在每个 Runnable 内;临时纹理只能按经验创建和释放,很难判断最后一次使用发生在哪里。

FrameGraphBuilder 把这两件事都变成图。Pass 声明自己读取 创建或读写哪些 ResourceHandle,构建器据此推导依赖顺序和资源生命周期。

Pass 与版本化 Handle

当前 FramePass 提供的核心操作包括:

ResourceHandle<TextureTarget> temporary = pass.createsInternal("temporary", descriptor);
pass.reads(input);

// 写入会返回一个新版本的 Handle,后续读取者必须使用新版本。
ResourceHandle<RenderTarget> outputV2 = pass.readsAndWrites(outputV1);

pass.requires(previousPass);
pass.executes(() -> renderInto(temporary, outputV2));

readsAndWrites() 返回新句柄不是多余包装。它把同一资源在图中的写入版本显式化,避免后续 Pass 无意读取写入前的状态,也让构建器可以建立正确的先后关系。

添加 Pass 不代表一定执行

FrameGraph 会从可观察结果反向寻找需要保留的 Pass。一个 Pass 如果最终贡献到导入的外部资源,例如主 RenderTarget,就会因为依赖链被保留;如果它没有任何被消费的输出,也没有调用 disableCulling(),就可能被裁剪。

这一点和 GUI 的 RenderState 很像:描述工作不等于工作一定执行。调试捕获 计时器或只有副作用的 Pass 若确实必须运行,需要明确 disableCulling();普通渲染 Pass 则应该通过资源依赖自然进入可观察输出,不能全部禁用裁剪来掩盖缺失的句柄关系。

资源只活在需要的区间

构建器会计算内部虚拟资源的首次和最后一次使用。执行时在第一次使用前分配,最后一次使用后释放,再把底层对象交还给分配器复用。

这正是 FrameGraph 的性能价值之一:它不是让 GPU 自动变快,而是让互不重叠的临时资源能够共享底层分配,同时降低 resize 和异常路径中的生命周期错误。

26.3 的 LevelRenderer 已经按这个模型组织世界渲染。它导入主目标,按需要创建半透明 物品 粒子 天气和云层目标,增加 Clear、Sky、Main、Cloud、Weather 和 Debug 等 Pass,再把 Post Chain 放进同一张图,最后交给资源分配器执行。

这里要注意边界:世界渲染使用 FrameGraphBuilder,GUI 则在世界和后处理结束后,由 GuiRenderer 对主目标独立准备并绘制。两条分支最终落到同一画面,不代表 GUI 自动成为世界 FrameGraph 中的一个 Pass。

4.8 GpuDevice、CommandEncoder 与 RenderPass

FrameGraph 决定“哪些 Pass 按什么顺序执行”,真正落到底层 GPU 命令时还要经过 GpuDeviceCommandEncoderRenderPass。在 26.3 中它们是类,不是原文示例里的接口。

GpuDevice 负责创建纹理 Buffer Pipeline 和 CommandEncoder,也提供实现能力和调试信息。它是当前图形后端的入口,但业务渲染代码不应到处缓存设备并随意发命令,资源仍要服从渲染线程和生命周期约束。

CommandEncoder 用来组织复制 清除和 RenderPass。创建 RenderPass 时要给出一个颜色 GpuTextureView 和一个可选深度 View;当前公开抽象一次只描述一组颜色附件,这也解释了第三章所说的 MRT 不能只靠片段着色器增加 layout(location = 1)

进入 RenderPass 后,典型顺序如下:

try (RenderPass pass = encoder.createRenderPass(
        () -> "GUI",
        colorView,
        OptionalInt.empty(),
        depthView,
        OptionalDouble.empty())) {

    // Pipeline 决定顶点、Shader、混合和深度等完整绘制约定。
    pass.setPipeline(RenderPipelines.GUI_TEXTURED);
    pass.bindTexture("Sampler0", textureView, sampler);
    pass.setUniform("Projection", projectionBuffer);
    pass.setVertexBuffer(0, vertexBuffer);
    pass.setIndexBuffer(indexBuffer, indexType);
    pass.drawIndexed(0, 0, indexCount, 1);
}

这段是说明调用契约的简化代码,不是可以直接粘贴进模组的完整实现。实际参数与 Uniform 绑定会由版本内的辅助层组织,但约束不变:先声明附件,再绑定 Pipeline 和资源,最后 Draw。

RenderPass 活跃时,CommandEncoder 会拒绝 Pass 外命令。原因是图形后端必须知道当前附件和状态的作用域;如果在 Pass 中途复制同一张纹理或另开一个 Pass,资源状态转换和提交顺序都会变得不确定。try-with-resources 也不只是语法习惯,它确保异常路径仍然关闭 Pass。

4.9 迁移边界与落地顺序

从旧版 GUI 或裸 OpenGL 代码迁移到 26.3 时,最危险的做法是只替换类名。例如把 GuiGraphics 改成 GuiGraphicsExtractor,却继续缓存对象、在任意线程调用,或者假设一个 blitSprite() 就对应一次 Draw Call,代码即使通过编译,模型仍然是错的。

可以按下面的顺序收敛:

  1. 先确定数据归属。 世界对象 GUI 元素和服务端语义分别进入哪种 RenderState,不能把服务端字段直接解释成客户端 GPU 状态。

  2. 再确定帧阶段。 哪些代码发生在 extract,哪些发生在 prepare / draw,禁止跨帧保存 Extractor 或短生命周期 View。

  3. 然后写清 Pipeline 契约。 顶点格式 Shader 纹理 混合 深度和排序键必须一致,不能只看 Shader 名称。

  4. 把资源接入所有权体系。 长期目标由资源容器管理,临时目标由 FrameGraph 和 allocator 管理,resize 与 close 必须成对。

  5. 最后再做合批和后处理。 先验证层级 Scissor 和读写依赖正确,再根据 RenderDoc 或 GPU 计时决定是否减少 Pass、合并 Mesh 或复用目标。

常见问题可以这样定位:

现象

优先检查

常见原因

调用 GUI 方法却没有画面

当前帧阶段和状态 reset

在错误阶段登记,或状态在绘制前被清空

Tooltip 被其他元素盖住

Stratum 与 Bounds

为了合批破坏覆盖顺序

滚动面板裁剪错位

Pose 与 Scissor

裁剪框没有经过相同二维变换

Pass 完全没有执行

FrameGraph 输出依赖

无外部可观察结果且被裁剪

resize 后黑屏

Texture / View 所有权

仍绑定旧 View 或附件尺寸不一致

绘制批次异常增多

Pipeline 与纹理排序

状态契约不相容,无法合并 Mesh

最后把 26.3 的两条渲染分支放回一张图:

世界和 GUI 都先从游戏状态提取描述,但后续组织不同。世界由 LevelRenderer 建立 FrameGraph,管理多个目标和 Pass;GUI 由 GuiRenderer 准备 retained state,在世界后处理完成后写入主目标。它们最后都经过 CommandEncoder 和 RenderPass 接触 GPU,却不能因此混成同一种抽象。


现代渲染架构的关键不是多记几个类名,而是先把状态提取、资源依赖和命令提交三个边界分清。