前三章我们从 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(...) 创建 ColoredRectangleRenderState,text(...) 创建 GuiTextRenderState,item(...) 则先建立可跟踪的物品状态,再写入 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,同时为它们建立 GpuTextureView。TextureTarget 在渲染线程创建或 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 命令时还要经过 GpuDevice、CommandEncoder 和 RenderPass。在 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,却不能因此混成同一种抽象。


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