前三章我们从 OpenGL 的顶点和状态讲到了 Blaze3D,又继续走过 HDR Bloom 与 MRT。到了这里,一个更容易踩坑的问题出现了:底层原理没有变,但 Minecraft 组织渲染代码的方式已经变了。
旧版本里,我们习惯拿到一个绘图对象,设置状态,然后立刻把矩形 文字或模型画出去。26.3 snapshot 2 则把提取游戏状态和向 GPU 提交命令分成两个阶段,GUI 世界渲染 后处理和临时资源也分别有自己的组织方式。如果仍然按照“调用绘制方法就会立刻出现在屏幕上”去理解,代码可以写出来,执行顺序 资源生命周期和性能问题却很难说清。
本文对照 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 已经没有这个类,职责被拆成了三个部分:
完整过程分成提取和绘制两段:

提取阶段
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,资源所有者仍要按约定释放。
可以用三个问题检查资源边界:
谁创建并拥有
GpuTexture?窗口 resize 时谁重建 Texture 和 View?
最后一个使用者结束后,谁负责
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,代码即使通过编译,模型仍然是错的。
可以按下面的顺序收敛:
先确定数据归属。 世界对象 GUI 元素和服务端语义分别进入哪种 RenderState,不能把服务端字段直接解释成客户端 GPU 状态。
再确定帧阶段。 哪些代码发生在 extract,哪些发生在 prepare / draw,禁止跨帧保存 Extractor 或短生命周期 View。
然后写清 Pipeline 契约。 顶点格式 Shader 纹理 混合 深度和排序键必须一致,不能只看 Shader 名称。
把资源接入所有权体系。 长期目标由资源容器管理,临时目标由 FrameGraph 和 allocator 管理,resize 与 close 必须成对。
最后再做合批和后处理。 先验证层级 Scissor 和读写依赖正确,再根据 RenderDoc 或 GPU 计时决定是否减少 Pass、合并 Mesh 或复用目标。
常见问题可以这样定位:
最后把 26.3 的两条渲染分支放回一张图:

世界和 GUI 都先从游戏状态提取描述,但后续组织不同。世界由 LevelRenderer 建立 FrameGraph,管理多个目标和 Pass;GUI 由 GuiRenderer 准备 retained state,在世界后处理完成后写入主目标。它们最后都经过 CommandEncoder 和 RenderPass 接触 GPU,却不能因此混成同一种抽象。
现代渲染架构的关键不是多记几个类名,而是先把状态提取、资源依赖和命令提交三个边界分清。

参与讨论
(Participate in the discussion)
参与讨论