现代Minecraft渲染导论 第四章:MC1.21.11/26.1 新渲染架构和后现代Blaze3D

现代Minecraft渲染导论 第四章:后现代Minecraft渲染架构(26.1+)

从 RenderSystem + ShaderInstance + BufferBuilder.end() 时代,走到 GuiGraphics + GuiRenderState + RenderPipeline + FrameGraph 时代

版本:

  1. Minecraft 1.21.11(截至目前26.1与其相比渲染架构变更不大)
  2. NeoForge 平台(Fabric暂不考虑,但本章大部分内容不涉及Mod加载器平台)
  3. Java 21

迁移警告

Mojang正在积极地将Blaze3D迁移至VK,故本章讲述的内容仅适用于26年夏季VK渲染后端更新前的版本(1.21.11/26.1,相关接口和代码不保证之后不发生语义漂移

4.0 前言

如果你在 1.20.1 甚至更老的版本里写过渲染代码,那么你大概率熟悉这样一套路径:

kotlin 复制代码
RenderSystem.setShader(...)
RenderSystem.setShaderTexture(0, texture)

val builder = Tesselator.getInstance().builder
builder.begin(...)
// vertex(...)
val mesh = builder.end()
BufferUploader.drawWithShader(mesh)

这套思维在旧版 Minecraft 里当然成立,而且在很多地方确实是主路径。
但到了 1.21.11,你如果还把它当作整个客户端渲染系统的中心,那就会产生严重误判。

为什么?

因为高版本变化的不是某个函数,而是整套渲染架构的组织方式:

  1. GUI 渲染已经明显转向 GuiGraphics + GuiRenderState
  2. GUI 2D 变换已经不再默认建立在 PoseStack 上,而是JOML提供的 Matrix3x2fStack
  3. pipeline 已经被提升为显式对象,RenderPipeline 不再只是“底层内部细节”。
  4. GPU 资源的表达已经不再只是一个 texture id,而是更靠近现代图形 API 的资源对象与视图对象。
  5. FrameGraphBuilder 这种现代引擎的资源生命周期与 pass 依赖管理工具已经进入 Mojang 的正式实现。
  6. NeoForge 也明确提供了 RegisterRenderPipelinesEventRegisterParticleGroupsEvent 这类高版本扩展点。

所以这一章不是给高版本 API 速查表,而是一次渲染心智模型的重建。

4.0.1 这一章到底要解决什么问题

本章要解决的是下面这几个问题:

  1. 1.21.11 的 GUI 渲染到底是如何从“调用 helper”变成“提交 render state”的。
  2. GuiGraphics 为什么不是 PoseStack 的新名字。
  3. GuiRenderState 为什么说明 Mojang 自己也在做“先收集,再提交”。
  4. RenderPipeline 为什么比旧版 ShaderInstance 更接近高版本的真实中心。
  5. TextureTargetGpuTextureViewFrameGraphBuilder 为什么说明离屏、后处理、资源生命周期都必须重新理解。
  6. 为什么粒子、自定义 GUI、高级特效、offscreen composition、world supplemental render 不能再沿用旧版统一抽象。

4.0.2 本章的阅读方式

  1. 先接受一个事实:高版本渲染 API 不是旧 API 的别名。
  2. 先看 Mojang 自己的源码组织,再看我们自己的封装应该怎么落。
  3. 先分清 GUI path 和 World path,再谈统一抽象。
  4. 先理解资源与提交模型,再谈性能与优化。

如果你跳过前两步,直接去看自己的 mod 代码,通常会产生一种错觉:

“为什么新版写起来这么别扭?我只想像以前那样直接画一个矩形。”

问题不在于 Mojang 故意把事情复杂化,而在于旧版那套“直接设置状态、直接推顶点、直接 draw”的上层暴露方式,本来就不适合继续承载现在的渲染系统,而且不利于后期MC向Vulkan迁移

4.0.3 本章与前几章的关系

前几章讲的图形学内容仍然是基础,但它们不再足以直接指导 1.21.11 开发。

这一章会把旧内容“吸收并升级”:

  1. 第一章里关于状态机、纹理、FBO、混合、矩阵的知识,仍然需要。
  2. 第二章里关于 Blaze3D 的讨论,仍然有用,但语义必须更新。
  3. 第三章里关于 MRT、HDR、后处理、离屏渲染的内容,在这里会和 TextureTargetGpuTextureViewFrameGraph 重新结合。

也就是说,本章不是附录,而是整个系列在高版本语境下的核心章节。

4.1 从旧世界进入新世界:渲染心智模型先更新

4.1.1 旧模型:状态驱动、立即感很强

旧版 Minecraft 渲染的常见心智模型一直是“立即味”很重,因为在MC 1.14正式Introduce Blaze3D前MC一直在使用GL立即模式绘图,即使在后期迁移到了核心模式,Mojang依然保留了类立即模式的绘图接口,在以前的核心模式(再早的立即模式就不再描述了,纯GlStateManager+Tesselator)写渲染,流程大致是这样的:

  1. 先切 shader。
  2. 先切 texture。
  3. 先切 blend/depth/cull/scissor 等状态。
  4. 拿到 BufferBuilder
  5. 开始塞顶点。
  6. end()
  7. 上传并绘制。

这套模型最大的特点,是“渲染动作”和“状态修改”耦合得很紧。

你可以把它画成这样:

flowchart TD A[设置 RenderSystem 状态] --> B[拿到 BufferBuilder] B --> C[写入顶点] C --> D[end] D --> E[BufferUploader.draw] E --> F[立即看到绘制结果]

这个模型在旧版本里之所以有效,是因为上层 API 暴露出来的抽象,本来就非常接近立即绘制。

4.1.2 新模型:提交驱动、资源驱动、管线驱动

1.21.11,至少在 GUI 与新式 pipeline 语义上,更合理的心智模型应该是:

  1. 我不是直接“画”,而是在提交一个渲染描述。
  2. 这个渲染描述会进入某个 render state 容器。
  3. 它最终会在合适的阶段按正确的顺序、正确的资源绑定、正确的 pipeline 被统一提交。
  4. 离屏与后处理不再只是“手动 bind 一个 FBO 再画”,而是越来越偏向 pass + resource lifetime 的组织方式。

简化后可以画成这样:

flowchart TD A[上层调用 GuiGraphics / Render API] --> B[生成 Render State / Pass 描述] B --> C[进入 GuiRenderState / Pipeline / FrameGraph] C --> D[统一排序与资源准备] D --> E[在合适阶段提交] E --> F[GPU 执行]

可以看到高版本的绘制流程正在逐渐变得规范。

4.1.3 为什么“状态机思维”不再够用

OpenGL 状态机知识当然没有过时,但它在高版本里已经不是上层最重要的组织原则了。

原因很简单:

  1. GuiGraphics 上层接口已经在主动替你构造 GUI render state。
  2. RenderPipeline 已经把大批“渲染状态组合”收敛为显式对象。
  3. TextureSetup 里已经不再只是 texture slot,而是 GpuTextureView + GpuSampler
  4. FrameGraphBuilder 开始显式管理 pass 顺序与资源生命周期。

所以现在更准确的说法是:
::: align-center
旧版强调“当前 GPU 状态是什么”,新版越来越强调“我要提交什么描述,以及这个描述依赖哪些资源和管线”。
:::

4.1.4 为什么 GUI 和 World 不能继续混在一起

旧版很多项目有一个经典问题:

  1. GUI 画法和世界画法混用同一套 helper。
  2. 上层统一拿着 PoseStack 到处传。
  3. 只要能跑,就把二维、三维、离屏、粒子、世界额外渲染都塞进一套抽象里。

到了 1.21.11,这条路会越来越危险。

因为 Mojang 在MC源码里给出了明显信号:

  1. GUI 2D 主要围绕 GuiGraphicsMatrix3x2fStack
  2. World / 3D 仍然主要围绕 PoseStack、世界渲染通路、对应的 pipeline 和 buffer 体系。
  3. 粒子路径也有自己的 extract / submit 方向。

如果还强行维持“统一渲染上下文”,最终结果通常是:

  1. GUI 2D 变换语义被三维抽象污染。
  2. 世界渲染被 GUI helper 牵着走。
  3. 粒子和离屏逻辑都开始借道错误路径。

4.1.5 我们应该如何更新认知

可以先把高版本渲染理解成三层:

层级 关注点 典型对象
上层语义层 我想画什么、我要表达什么效果 GuiGraphics、项目自己的 retained UI、特效语义对象
中层提交层 以什么 render state / pipeline / pass 提交 GuiRenderStateRenderPipelineTextureSetup
底层资源层 GPU 资源、视图、命令编码、生命周期 GpuTextureViewTextureTargetFrameGraphBuilder

一旦这样分层,很多迁移决策就会清晰得多:

  1. 上层 UI 语义该保留。
  2. 中层旧 replay backend 该重写。
  3. 底层 OpenGL 细节该下沉到 backend boundary 后面。

4.2 Mojang 自己在做什么:1.21.11 渲染栈总览

如果你想真正理解新版渲染系统,第一步不是搜教程(准确地说,也搜不到,MC的渲染教程多年来一直严重匮乏),而是先看源码包结构。

4.2.1 关键包结构

1.21.11 里,至少要先关注下面这些位置:

包 / 类 关注重点
net.minecraft.client.gui.GuiGraphics GUI 绘制入口
net.minecraft.client.gui.render.state.* GUI render state 的数据结构
net.minecraft.client.renderer.RenderPipelines Mojang 预注册 pipeline
com.mojang.blaze3d.pipeline.RenderPipeline pipeline 本体
com.mojang.blaze3d.pipeline.TextureTarget 离屏 render target
com.mojang.blaze3d.textures.GpuTextureView 纹理视图
com.mojang.blaze3d.framegraph.FrameGraphBuilder pass / resource graph
net.neoforged.neoforge.client.event.RegisterRenderPipelinesEvent NeoForge 的 pipeline 注册入口
net.neoforged.neoforge.client.event.RegisterParticleGroupsEvent NeoForge 的粒子组注册入口

这已经足够说明Mojang 的新渲染系统,不再只是“RenderSystem 下面一堆 OpenGL 函数封装”。

4.2.2 com.mojang.blaze3d 现在分别负责什么

现在可以粗略把 Blaze3D 的相关子模块理解成:

模块 作用
pipeline 描述渲染管线、render target 等对象
textures 纹理对象、view、sampler
framegraph pass 依赖、资源生命周期、执行图
systems 一些仍然存在的系统级渲染入口,例如 RenderSystem

注意,这里的关键词已经不是“立即模式式 helper”,而是:

  1. Pipeline
  2. TextureView
  3. FrameGraph
  4. Resource

这几个词本身就已经比旧版更接近现代图形 API 的语义了。

4.2.3 net.minecraft.client.gui 的新 GUI 渲染路径

GUI 路径现在的主角不是 AbstractGui,也不是自己手搓 BufferBuilder

而是 GuiGraphics,当然这个GuiGraphics也和1.20.X不一样了

它内部直接持有:

  1. Matrix3x2fStack pose
  2. GuiRenderState guiRenderState
  3. ScissorStack
  4. GUI atlas / material 相关对象

这已经清楚地表明:

Mojang 现在把 GUI 绘制理解成“收集 GUI 元素状态,再统一处理”的流程,而不是简单的 helper 调用集合。

4.2.4 net.minecraft.client.renderer 的新管线组织方式

旧版很多人说“shader”,新版必须开始谈“pipeline”。

RenderPipelines 中预先注册了大量 pipeline / snippet,这些对象不再只是 shader 程序名,而是:

  1. 顶点格式
  2. 顶点模式
  3. blend
  4. depth test
  5. cull
  6. sampler
  7. uniform
  8. shader define

也就是说,新版真正重要的不只是“用哪个 shader”,而是“这个绘制属于哪个 pipeline”。

4.2.5 NeoForge 在高版本里提供了什么扩展点

NeoForge 也在跟着高版本渲染语义走,典型例子就是:

  1. RegisterRenderPipelinesEvent
  2. RegisterParticleGroupsEvent

这说明高版本扩展的正确方向是:

  1. 在 pipeline 层接入。
  2. 在 particle group / render order 层接入。

而不是继续到处 patch 一些旧时代的 begin/end 或 render-time mixin。

4.3 GUI 渲染入口已经换了:GuiGraphics

如果你只记住本章一个类,那大概率应该先记住 GuiGraphics

因为它是 GUI 新路径里最显眼、也是最容易被误解的入口。

很多人第一次看到它,会下意识觉得:

“这不就是新版 AbstractGui 吗?”

或者:

“这不还是1.20.X那个GuiGraphics吗?”

这只说对了一半。

更准确地说,现在的GuiGraphics 已经是:

  1. GUI 绘制入口。
  2. GUI 2D 变换入口。
  3. GUI render state 提交入口。
  4. GUI 分层与 scissor 语义的承载入口。

4.3.1 构造和成员

Mojang 源码:

java 复制代码
public class GuiGraphics implements net.neoforged.neoforge.client.extensions.IGuiGraphicsExtension {
    final Minecraft minecraft;
    private final Matrix3x2fStack pose;
    private final GuiGraphics.ScissorStack scissorStack = new GuiGraphics.ScissorStack();
    private final MaterialSet materials;
    private final TextureAtlas guiSprites;
    final GuiRenderState guiRenderState;

    private GuiGraphics(Minecraft minecraft, Matrix3x2fStack pose, GuiRenderState guiRenderState, int mouseX, int mouseY) {
        this.minecraft = minecraft;
        this.pose = pose;
        this.guiRenderState = guiRenderState;
    }

    public GuiGraphics(Minecraft minecraft, GuiRenderState guiRenderState, int mouseX, int mouseY) {
        this(minecraft, new Matrix3x2fStack(16), guiRenderState, mouseX, mouseY);
    }
}

这段代码已经足够说明三个关键变化:

  1. GUI 变换栈已经是 Matrix3x2fStack,不是 PoseStack
  2. GuiGraphics 从一开始就绑定着 GuiRenderState
  3. scissor 也不再只是“顺手调一个 GL scissor”,而是 GUI 语义的一部分。

4.3.2 pose() 为什么是 Matrix3x2fStack

源码非常直接:

java 复制代码
public Matrix3x2fStack pose() {
    return this.pose;
}

Mojang 已经明确把 GUI 2D 变换从旧式三维 PoseStack 语义中拆出来了。
因为 GUI 绝大多数绘制,本质上只需要二维仿射变换:

  1. 平移
  2. 旋转
  3. 缩放
  4. 剪裁后的轴对齐边界计算

对于这类工作,Matrix3x2fStack 更轻,也更接近真实需求。

所以如果你的项目在 GUI 层还到处默认传 PoseStack,那必须立即迁移。

4.3.3 fill(...) 不是“立刻画矩形”,而是“提交矩形状态”

fill 的实现:

java 复制代码
public void fill(int minX, int minY, int maxX, int maxY, int color) {
    this.fill(RenderPipelines.GUI, minX, minY, maxX, maxY, color);
}

public void fill(RenderPipeline pipeline, int minX, int minY, int maxX, int maxY, int color) {
    this.submitColoredRectangle(pipeline, TextureSetup.noTexture(), minX, minY, maxX, maxY, color, null);
}

private void submitColoredRectangle(
    RenderPipeline pipeline,
    TextureSetup textureSetup,
    int minX,
    int minY,
    int maxX,
    int maxY,
    int colorFrom,
    @Nullable Integer colorTo
) {
    this.guiRenderState.submitGuiElement(
        new ColoredRectangleRenderState(
            pipeline,
            textureSetup,
            new Matrix3x2f(this.pose),
            minX,
            minY,
            maxX,
            maxY,
            colorFrom,
            colorTo != null ? colorTo : colorFrom,
            this.scissorStack.peek()
        )
    );
}

这段代码它表达的是:

“把这个矩形的 pipeline、texture setup、pose、几何范围、颜色、scissor,一次性打包成 ColoredRectangleRenderState,然后提交给 GuiRenderState。”

这就是新版 GUI 主路径的核心语义。

4.3.4 drawString(...) 也不是直接画,而是提交文本状态

源码同样很直白:

java 复制代码
public void drawString(Font font, FormattedCharSequence text, int x, int y, int color, boolean drawShadow) {
    if (ARGB.alpha(color) != 0) {
        this.guiRenderState.submitText(
            new GuiTextRenderState(
                font, text, new Matrix3x2f(this.pose), x, y, color, 0, drawShadow, false, this.scissorStack.peek()
            )
        );
    }
}

注意这里依然没有直接“绘制文本”,而是在提交 GuiTextRenderState

GuiTextRenderState 自己还会延迟准备:

java 复制代码
public Font.PreparedText ensurePrepared() {
    if (this.preparedText == null) {
        this.preparedText = this.font.prepareText(this.text, this.x, this.y, this.color, this.dropShadow, this.includeEmpty, this.backgroundColor);
        ScreenRectangle screenrectangle = this.preparedText.bounds();
        if (screenrectangle != null) {
            screenrectangle = screenrectangle.transformMaxBounds(this.pose);
            this.bounds = this.scissor != null ? this.scissor.intersection(screenrectangle) : screenrectangle;
        }
    }

    return this.preparedText;
}

这说明新版 GUI 文本路径也更偏向“状态对象 + 延迟准备 + 统一调度”。

4.3.5 renderItem(...) 走的是 item render state 路径

看物品渲染:

java 复制代码
private void renderItem(@Nullable LivingEntity entity, @Nullable Level level, ItemStack stack, int x, int y, int seed) {
    if (!stack.isEmpty()) {
        TrackingItemStackRenderState trackingitemstackrenderstate = new TrackingItemStackRenderState();
        this.minecraft
            .getItemModelResolver()
            .updateForTopItem(trackingitemstackrenderstate, stack, ItemDisplayContext.GUI, level, entity, seed);

        this.guiRenderState.submitItem(
            new GuiItemRenderState(
                stack.getItem().getName().toString(),
                new Matrix3x2f(this.pose),
                trackingitemstackrenderstate,
                x,
                y,
                this.scissorStack.peek()
            )
        );
    }
}

这同样不是“这里直接把 item 画出来”。

而是:

  1. 先解析 item model state。
  2. 再打包成 GuiItemRenderState
  3. 再交给 GuiRenderState

所以读者现在应该很清楚了:

GuiGraphics 的主要职责,不是自己完成所有绘制,而是把 GUI 调用翻译成统一的 GUI render state。

4.3.6 scissor 在新模型里也带着变换语义

源码:

java 复制代码
public void enableScissor(int minX, int minY, int maxX, int maxY) {
    ScreenRectangle screenrectangle = new ScreenRectangle(minX, minY, maxX - minX, maxY - minY)
        .transformAxisAligned(this.pose);
    this.scissorStack.push(screenrectangle);
}

注意这里不是直接把参数塞给底层 scissor。

它先做了:

  1. 根据当前 pose 进行轴对齐变换。
  2. 再把结果压入 scissorStack

这意味着在高版本 GUI 路径里,scissor 已经是和当前 2D 变换绑定的语义对象,而不是单纯的“底层状态开关”。

4.3.7 blitSprite(...) 说明 GUI atlas 与 sprite metadata 也被正式纳入 GUI 渲染模型

再看一段很关键的逻辑:

java 复制代码
public void blitSprite(RenderPipeline pipeline, Identifier sprite, int x, int y, int width, int height, int color) {
    TextureAtlasSprite textureatlassprite = this.guiSprites.getSprite(sprite);
    GuiSpriteScaling guispritescaling = getSpriteScaling(textureatlassprite);
    switch (guispritescaling) {
        case GuiSpriteScaling.Stretch guispritescaling$stretch:
            this.blitSprite(pipeline, textureatlassprite, x, y, width, height, color);
            break;
        case GuiSpriteScaling.Tile guispritescaling$tile:
            this.blitTiledSprite(...);
            break;
        case GuiSpriteScaling.NineSlice guispritescaling$nineslice:
            this.blitNineSlicedSprite(...);
            break;
        default:
    }
}

这说明新版 GUI 渲染不仅仅是“贴一张纹理”,而是已经把:

  1. atlas sprite
  2. GUI metadata
  3. nine-slice / tile / stretch 这些 GUI 特化语义

都作为正式路径的一部分。

所以如果你的 GUI 框架还在上层自己重复造一套九宫格、tile、atlas 逻辑,就要重新评估是否和 Mojang 已有语义冲突了。

4.4 GUI 不是立刻画上去的:GuiRenderState

理解了 GuiGraphics,下一步就必须理解 GuiRenderState

因为如果你不理解它,就会一直误以为:

GuiGraphics.fill(...) 只是内部偷偷帮我调了一个更复杂的 draw call。”

实际上不是。

更接近真实情况的说法是:

GuiGraphics 负责生产 GUI render state,GuiRenderState 负责组织这些 state。

4.4.1 先看它最关键的字段

Mojang 源码:

java 复制代码
public class GuiRenderState {
    private final List<GuiRenderState.Node> strata = new ArrayList<>();
    private int firstStratumAfterBlur = Integer.MAX_VALUE;
    private GuiRenderState.Node current;
    private final Set<Object> itemModelIdentities = new HashSet<>();
    private @Nullable ScreenRectangle lastElementBounds;

    public GuiRenderState() {
        this.nextStratum();
    }
}

这里最重要的不是字段名,而是它暗示出来的结构:

  1. 有 strata,也就是分层。
  2. 有 current,也就是当前节点。
  3. 有 blur 的分界点。
  4. 有上一个元素的 bounds。

这说明 GuiRenderState 关心的不是“有没有 draw call”,而是:

  1. 元素如何分层。
  2. 元素如何根据屏幕边界关系组织。
  3. 哪些内容在 blur 前,哪些内容在 blur 后。

4.4.2 nextStratum()up():Mojang 在显式组织 GUI 层级

看两个核心方法:

java 复制代码
public void nextStratum() {
    this.current = new GuiRenderState.Node(null);
    this.strata.add(this.current);
}

public void up() {
    if (this.current.up == null) {
        this.current.up = new GuiRenderState.Node(this.current);
    }

    this.current = this.current.up;
}

这已经说明 GUI 渲染在内部并不是一个扁平列表。

它至少有两层结构:

  1. 横向的 strata
  2. 纵向的 up 节点链

可以先把它理解成:

  1. stratum 解决大层级问题。
  2. up 解决局部覆盖与嵌套层级问题。

4.4.3 为什么要根据 bounds 自动调整节点位置

看最重要的一段逻辑:

java 复制代码
private boolean findAppropriateNode(ScreenArea screenArea) {
    ScreenRectangle screenrectangle = screenArea.bounds();
    if (screenrectangle == null) {
        return false;
    } else {
        if (this.lastElementBounds != null && this.lastElementBounds.encompasses(screenrectangle)) {
            this.up();
        } else {
            this.navigateToAboveHighestElementWithIntersectingBounds(screenrectangle);
        }

        this.lastElementBounds = screenrectangle;
        return true;
    }
}

它说明 Mojang 在提交 GUI 元素时,并不是简单 append 到列表尾部,而是在根据元素边界关系决定应该放到哪个 node。

这背后的意思是:

::: align-center
GUI layering 在高版本里,不只是调用顺序问题,而是屏幕区域关系也参与了渲染组织。
:::

再看 navigateToAboveHighestElementWithIntersectingBounds(...)

java 复制代码
private void navigateToAboveHighestElementWithIntersectingBounds(ScreenRectangle rectangle) {
    GuiRenderState.Node node = this.strata.getLast();

    while (node.up != null) {
        node = node.up;
    }

    boolean flag = false;

    while (!flag) {
        flag = this.hasIntersection(rectangle, node.elementStates)
            || this.hasIntersection(rectangle, node.itemStates)
            || this.hasIntersection(rectangle, node.textStates)
            || this.hasIntersection(rectangle, node.picturesInPictureStates);
        if (node.parent == null) {
            break;
        }

        if (!flag) {
            node = node.parent;
        }
    }

    this.current = node;
    if (flag) {
        this.up();
    }
}

这段逻辑等价于:

  1. 先找到当前最高的 node。
  2. 向下检查有没有和当前元素 bounds 相交的内容。
  3. 如果有,就上升一层放置。
  4. 如果没有,就回退到父节点继续判断。

也就是说,Mojang 在用一套“基于 bounds 相交关系的层级组织逻辑”来帮助 GUI 元素正确分层。

4.4.4 Node 里存的不是一种东西,而是多类 GUI state

Node 的结构也很能说明问题:

java 复制代码
static class Node {
    public @Nullable List<GuiElementRenderState> elementStates;
    public @Nullable List<GuiElementRenderState> glyphStates;
    public @Nullable List<GuiItemRenderState> itemStates;
    public @Nullable List<GuiTextRenderState> textStates;
    public @Nullable List<PictureInPictureRenderState> picturesInPictureStates;
}

这说明 GUI 渲染在 Mojang 内部已经明确拆分成不同类别:

  1. 普通 GUI 元素
  2. glyph
  3. item
  4. text
  5. 画中画类状态

这和旧版“都转成顶点然后一股脑儿画掉”的上层观感差异非常大。
因为现在不同类别的 GUI 内容,在提交阶段完全可以走不同的处理路径。

4.4.5 它是怎么遍历这些状态的

看遍历逻辑:

java 复制代码
public void forEachElement(Consumer<GuiElementRenderState> action, GuiRenderState.TraverseRange traverseRange) { ... }
public void forEachItem(Consumer<GuiItemRenderState> action) { ... }
public void forEachText(Consumer<GuiTextRenderState> action) { ... }
public void forEachPictureInPicture(Consumer<PictureInPictureRenderState> action) { ... }

这说明后续提交阶段可以按类别遍历,也可以按 blur 前后遍历。

于是我们就能看出 GuiRenderState 的真正角色:

它不是某个“中间变量”。
它实际上是 GUI 帧内渲染调度的核心数据结构之一。

4.4.6 用图看 GuiRenderState=

flowchart TD A[GuiGraphics.fill / drawString / renderItem] --> B[构造对应 RenderState] B --> C[GuiRenderState.current] C --> D{bounds 是否与已有内容相交} D -- 是 --> E[up 到更高节点] D -- 否 --> F[放入当前或父节点] E --> G[进入对应 state 列表] F --> G G --> H[后续按 element/text/item/pip 分类遍历]

再把 strata + up 看成层级图,大致像这样:

graph TD S1[Stratum 0] S2[Stratum 1] N1[Node] N2[Node.up] N3[Node.up.up] S1 --> N1 N1 --> N2 N2 --> N3 S2 --> M1[另一条 Node 链]

这当然不是完整内部结构图,但已经足够建立正确印象:

  1. GuiRenderState 不是扁平列表。
  2. 它在主动组织 GUI layering。
  3. 它是状态容器,不是立即绘制工具。

4.5 GUI 2D 变换系统重做:Matrix3x2fStack

前面我们已经看到,GuiGraphics 直接持有的是 Matrix3x2fStack,而不是 PoseStack

这一节要解决的问题是:

为什么 Mojang 要这样改,以及这对我们自己的 GUI 框架意味着什么。

核心结论

在1.21.11里,GUI 2D 已经不该继续默认建立在PoseStack心智上。
这不是代码风格问题,而是语义边界已经被 Mojang 明确拆开了。

4.5.1 为什么 GUI 不再继续沿用 PoseStack

旧时代把 PoseStack 到处传,有一个很大的便利:

  1. GUI 也能用。
  2. 世界渲染也能用。
  3. 粒子也能用。
  4. 看起来所有变换都统一了。

但这个“统一”其实很虚。

因为 GUI 2D 真正关心的是:

  1. 二维平移
  2. 二维缩放
  3. 二维旋转
  4. 变换后的屏幕边界
  5. 裁剪区域的传播

PoseStack 的语义中心是三维。

当你把 GUI 也绑在三维栈上时,长期后果通常是:

  1. 上层 API 总觉得 mulPoseMatrix(Matrix4f) 是理所当然的。
  2. 很多 GUI helper 会偷偷依赖旧时代 model-view 的残留习惯。
  3. scissor、bounds、命中测试在二维坐标语义上会越来越混乱。

4.5.2 ScreenRectangle :Mojang 的真实意图

ScreenRectangle1.21.11 中有两个关键方法:

java 复制代码
public ScreenRectangle transformAxisAligned(Matrix3x2fc pose) {
    Vector2f v0 = pose.transformPosition(this.left(), this.top(), new Vector2f());
    Vector2f v1 = pose.transformPosition(this.right(), this.bottom(), new Vector2f());
    return new ScreenRectangle(Mth.floor(v0.x), Mth.floor(v0.y), Mth.floor(v1.x - v0.x), Mth.floor(v1.y - v0.y));
}

public ScreenRectangle transformMaxBounds(Matrix3x2fc pose) {
    Vector2f v0 = pose.transformPosition(this.left(), this.top(), new Vector2f());
    Vector2f v1 = pose.transformPosition(this.right(), this.top(), new Vector2f());
    Vector2f v2 = pose.transformPosition(this.left(), this.bottom(), new Vector2f());
    Vector2f v3 = pose.transformPosition(this.right(), this.bottom(), new Vector2f());
    float minX = Math.min(Math.min(v0.x(), v2.x()), Math.min(v1.x(), v3.x()));
    float maxX = Math.max(Math.max(v0.x(), v2.x()), Math.max(v1.x(), v3.x()));
    float minY = Math.min(Math.min(v0.y(), v2.y()), Math.min(v1.y(), v3.y()));
    float maxY = Math.max(Math.max(v0.y(), v2.y()), Math.max(v1.y(), v3.y()));
    return new ScreenRectangle(Mth.floor(minX), Mth.floor(minY), Mth.ceil(maxX - minX), Mth.ceil(maxY - minY));
}

这说明 Mojang 已经把 GUI 的边界计算明确建立在二维仿射变换上。

其中:

  1. transformAxisAligned(...) 更适合本身保持轴对齐的语义,例如部分 scissor 处理。
  2. transformMaxBounds(...) 则更适合算“经过 2D 变换后的最大包围盒”。

这正是 GUI 最常见的需求。

4.5.3 GuiGraphics.enableScissor(...)

前面我们已经看过:

java 复制代码
public void enableScissor(int minX, int minY, int maxX, int maxY) {
    ScreenRectangle screenrectangle = new ScreenRectangle(minX, minY, maxX - minX, maxY - minY)
        .transformAxisAligned(this.pose);
    this.scissorStack.push(screenrectangle);
}

也就是说:

  1. GUI 当前变换栈是二维的。
  2. scissor 也跟着二维变换一起走。
  3. bounds 计算同样跟着二维变换一起走。

如果你的 GUI 抽象还在假设“只要传一个 PoseStack,之后再慢慢约定哪些操作只在二维使用”,那就已经落后于 Mojang 当前设计了。

4.5.4 对项目迁移意味着什么

如果你在迁移自己的 GUI 系统,那么至少应该做这几件事:

  1. GUIContext 级别引入 GUI 专用 2D 栈,而不是继续默认持有 PoseStack
  2. GUI DSL 与 World DSL 拆开,不要再试图做一个“全场通用”的变换 DSL。
  3. 任何依赖 Matrix4fmulPoseMatrix(...)、旧 model-view 假设的 GUI 代码,都应该被视为迁移风险点。
  4. GUI replay / retained flush 时,应该优先输出到 GuiGraphics 能理解的二维语义对象。

4.5.5 GUI 2D 与 World 3D 的边界

可以先把边界总结成一句话:

::: align-center
GUI 2D 用 Matrix3x2fStack,World / 3D 继续主要用 PoseStack
:::

4.6 渲染状态不再靠手搓:RenderPipeline

如果说 GuiGraphicsGuiRenderState 是高版本 GUI 路径的入口,那么 RenderPipeline 就是高版本渲染架构里最关键的中层对象之一。

旧时代很多人做渲染设计时,脑子里的中心对象是:

  1. ShaderInstance
  2. RenderSystem 当前状态
  3. 顶点格式
  4. 当前纹理槽

到了 1.21.11,这个中心必须往 RenderPipeline 迁移。

4.6.1 RenderPipeline 到底在描述什么

先看 Mojang 的字段:

java 复制代码
public class RenderPipeline {
    private final Identifier location;
    private final Identifier vertexShader;
    private final Identifier fragmentShader;
    private final ShaderDefines shaderDefines;
    private final List<String> samplers;
    private final List<RenderPipeline.UniformDescription> uniforms;
    private final DepthTestFunction depthTestFunction;
    private final PolygonMode polygonMode;
    private final boolean cull;
    private final Optional<BlendFunction> blendFunction;
    private final boolean writeColor;
    private final boolean writeAlpha;
    private final boolean writeDepth;
    private final VertexFormat vertexFormat;
    private final VertexFormat.Mode vertexFormatMode;
    private final float depthBiasScaleFactor;
    private final float depthBiasConstant;
    private final int sortKey;
}

这已经足够说明:

RenderPipeline 不是“shader 句柄”。

它至少同时描述了:

  1. shader
  2. define
  3. sampler
  4. uniform
  5. depth test
  6. blend
  7. cull
  8. color/depth write
  9. vertex format
  10. vertex topology

也就是说,Mojang 正在把“一个绘制到底属于哪种状态组合”显式对象化。

4.6.2 Builder 说明 pipeline 是正式的组合单位

再看 Builder 的接口:

java 复制代码
public static class Builder {
    public RenderPipeline.Builder withLocation(String location) { ... }
    public RenderPipeline.Builder withFragmentShader(String fragmentShader) { ... }
    public RenderPipeline.Builder withVertexShader(String vertexShader) { ... }
    public RenderPipeline.Builder withShaderDefine(String flag) { ... }
    public RenderPipeline.Builder withShaderDefine(String key, float value) { ... }
    public RenderPipeline.Builder withSampler(String sampler) { ... }
    public RenderPipeline.Builder withUniform(String uniform, UniformType type) { ... }
    public RenderPipeline.Builder withDepthTestFunction(DepthTestFunction depthTestFunction) { ... }
    public RenderPipeline.Builder withCull(boolean cull) { ... }
    public RenderPipeline.Builder withBlend(BlendFunction blendFunction) { ... }
    public RenderPipeline.Builder withDepthWrite(boolean writeDepth) { ... }
    public RenderPipeline.Builder withVertexFormat(VertexFormat vertexFormat, VertexFormat.Mode vertexFormatMode) { ... }
}

读者会发现,高版本里真正稳定的组合单位已经变成:

“某一类绘制所需的整组渲染状态”

而不是:

“先拿一个 shader,然后在调用点临时拼剩下的状态。”

这就是为什么高版本不应该再以 ShaderInstance 为架构中心。

4.6.3 Snippet 在解决什么问题

Mojang 没有让每条 pipeline 从零开始声明,而是引入了 Snippet

源码:

java 复制代码
public static RenderPipeline.Builder builder(RenderPipeline.Snippet... snippets) {
    RenderPipeline.Builder builder = new RenderPipeline.Builder();

    for (RenderPipeline.Snippet snippet : snippets) {
        builder.withSnippet(snippet);
    }

    return builder;
}

Snippet 的价值在于:

  1. 把公共状态片段复用出来。
  2. 让不同 pipeline 可以基于同一组基础约束拼装。
  3. 避免每个 pipeline 手动抄一遍所有状态。

这是一种非常典型的高版本设计信号:

Mojang 正在把 pipeline 当成正式架构层,而不是零散配置。

4.6.4 先看 Mojang 预定义的 GUI / 粒子 / Outline 片段

RenderPipelines

java 复制代码
public static final RenderPipeline.Snippet PARTICLE_SNIPPET = RenderPipeline.builder(MATRICES_FOG_SNIPPET)
    .withVertexShader("core/particle")
    .withFragmentShader("core/particle")
    .withSampler("Sampler0")
    .withSampler("Sampler2")
    .withVertexFormat(DefaultVertexFormat.PARTICLE, VertexFormat.Mode.QUADS)
    .buildSnippet();

public static final RenderPipeline.Snippet GUI_SNIPPET = RenderPipeline.builder(MATRICES_PROJECTION_SNIPPET)
    .withVertexShader("core/gui")
    .withFragmentShader("core/gui")
    .withBlend(BlendFunction.TRANSLUCENT)
    .withVertexFormat(DefaultVertexFormat.POSITION_COLOR, VertexFormat.Mode.QUADS)
    .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
    .buildSnippet();

public static final RenderPipeline.Snippet GUI_TEXTURED_SNIPPET = RenderPipeline.builder(MATRICES_PROJECTION_SNIPPET)
    .withVertexShader("core/position_tex_color")
    .withFragmentShader("core/position_tex_color")
    .withSampler("Sampler0")
    .withBlend(BlendFunction.TRANSLUCENT)
    .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS)
    .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
    .buildSnippet();

public static final RenderPipeline.Snippet GUI_TEXT_SNIPPET = RenderPipeline.builder(TEXT_SNIPPET)
    .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
    .buildSnippet();

public static final RenderPipeline.Snippet OUTLINE_SNIPPET = RenderPipeline.builder(MATRICES_PROJECTION_SNIPPET)
    .withVertexShader("core/rendertype_outline")
    .withFragmentShader("core/rendertype_outline")
    .withSampler("Sampler0")
    .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
    .withDepthWrite(false)
    .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS)
    .buildSnippet();

这几段非常有代表性。

它说明 Mojang 已经在显式区分:

  1. GUI 纯色
  2. GUI 纹理
  3. GUI 文本
  4. 粒子
  5. Outline

这些东西不再只是“同一个 shader 改几个参数”。

4.6.5 再看真正注册出的 pipeline

再看下面这些:

java 复制代码
public static final RenderPipeline GUI = register(RenderPipeline.builder(GUI_SNIPPET).withLocation("pipeline/gui").build());
public static final RenderPipeline GUI_TEXTURED = register(RenderPipeline.builder(GUI_TEXTURED_SNIPPET).withLocation("pipeline/gui_textured").build());
public static final RenderPipeline GUI_INVERT = register(
    RenderPipeline.builder(GUI_SNIPPET).withLocation("pipeline/gui_invert").withBlend(BlendFunction.INVERT).build()
);
public static final RenderPipeline GUI_TEXT_HIGHLIGHT = register(
    RenderPipeline.builder(GUI_SNIPPET).withLocation("pipeline/gui_text_highlight").withBlend(BlendFunction.ADDITIVE).build()
);

这再次证明:

  1. pipeline 是正式注册对象。
  2. Mojang 通过 snippet 派生出具体 pipeline。
  3. 同一类基础语义上,可以再做少量状态变体。

4.6.6 这对我们自己的架构意味着什么

如果你自己还在设计一个“Shader管理器 统治一切”的体系,那么高版本里它的地位应该下降。

更合适的设计应该是:

  1. UiPipelineRegistry
  2. WorldRenderPipelineRegistry
  3. 必要时保留很小的 shader handle 作为过渡桥

因为真正稳定的上层边界已经变成:

  1. 这个绘制属于哪条路径
  2. 它应该落在哪个 pipeline
  3. 它需要哪些 sampler / uniform / vertex format

不要再把 RenderPipeline 当成 ShaderInstance 的皮肤

如果只是把旧设计里所有 ShaderInstance 字段机械替换成 RenderPipeline,
你会得到一个“看起来高版本、实际上还是旧架构”的系统。

4.6.7 高版本应该如何理解 pipeline

可以先用一句话总结:

::: align-center
高版本里,pipeline 是一类绘制的正式渲染契约
:::

这个契约不仅决定 shader,还决定这类绘制的资源绑定约束与状态边界。

4.7 NeoForge 的新注册入口:RegisterRenderPipelinesEvent

既然 pipeline 已经是正式架构对象,那么 mod 侧自然也需要一个正式注册入口。

NeoForge 给出了RegisterRenderPipelinesEvent

4.7.1 先看事件本身

源码很短,但很重要:

java 复制代码
/**
 * Fired to allow mods to register custom {@linkplain RenderPipeline pipelines}.
 * This event is fired after the default Minecraft pipelines have been registered.
 */
public class RegisterRenderPipelinesEvent extends Event implements IModBusEvent {
    private final Consumer<RenderPipeline> registrar;

    public void registerPipeline(RenderPipeline pipeline) {
        registrar.accept(pipeline);
    }
}

这段话至少明确了三件事:

  1. 这是给 mod 注册自定义 RenderPipeline 用的正式入口。
  2. 它发生在 Mojang 默认 pipeline 注册完成之后。
  3. NeoForge 没有让你去 patch 内部列表,而是给了显式 registrar。

4.7.2 这意味着什么

这意味着高版本的正确扩展方式是:

  1. 在 mod bus 上注册你自己的 pipeline。
  2. 让上层渲染语义显式依赖这些 pipeline。
  3. 不要再靠一堆 runtime hack 去模拟旧版 shader registration。

如果放到项目设计上,这就是为什么我们需要:

  1. UiPipelineRegistry
  2. WorldRenderPipelineRegistry

而不是一个“大一统 ShaderRegistry”。

4.7.3 什么应该进入 UiPipelineRegistry

GUI pipeline 的典型候选包括:

  1. 标准 2D 纯色 / 纹理路径
  2. 自定义 GUI 贴图效果
  3. SDF / distance field 类矩形与边框
  4. GUI blur / mask / composite 需要的 pipeline
  5. ShaderTexture 一类 GUI 特化效果

这些 pipeline 有几个共同点:

  1. 它们属于 GUI path。
  2. 默认应建立在 GUI 2D 坐标与 GUI 资源绑定约束上。
  3. 不应该混入 world supplemental render 的假设。

4.7.4 什么应该进入 WorldRenderPipelineRegistry

World pipeline 的典型候选包括:

  1. Outliner
  2. world supplemental render
  3. 特定世界特效的补充渲染
  4. 未来需要接入世界通路的特殊 mesh / line / overlay

这些 pipeline 的约束和 GUI 完全不同:

  1. 它们通常需要 world / camera / fog / depth 的语义。
  2. 它们的 transform 主体不是 GUI 2D。
  3. 它们不能借用 GUI pipeline “先跑通再说”。

4.8 离屏渲染与资源生命周期:TextureTarget

如果说前面几节主要在重建 GUI 与 pipeline 语义,从这里开始,我们进入高版本离屏与资源生命周期的核心变化。

旧时代很多人一说离屏渲染,脑子里的第一反应是:

  1. 创建 FBO
  2. 绑颜色纹理
  3. 绑深度纹理
  4. bindWrite()
  5. unbindWrite()
  6. colorTextureId
  7. 再手动画一个全屏 quad 合成

这套流程并没有“错”,但它已经不是 1.21.11 最值得依赖的上层心智了。

4.8.1 TextureTarget 是什么

源码很短:

java 复制代码
public class TextureTarget extends RenderTarget {
    public TextureTarget(@Nullable String name, int width, int height, boolean useDepth) {
        this(name, width, height, useDepth, false);
    }

    public TextureTarget(@Nullable String name, int width, int height, boolean useDepth, boolean enableStencil) {
        super(name, useDepth, enableStencil);
        RenderSystem.assertOnRenderThread();
        this.resize(width, height);
    }
}

它本身不复杂,重点在于它继承的 RenderTarget

4.8.2 RenderTarget 的内部已经不是旧 FBO 心智

RenderTarget 的核心字段:

java 复制代码
public abstract class RenderTarget {
    public int width;
    public int height;
    public final boolean useDepth;
    public final boolean useStencil;
    protected @Nullable GpuTexture colorTexture;
    protected @Nullable GpuTextureView colorTextureView;
    protected @Nullable GpuTexture depthTexture;
    protected @Nullable GpuTextureView depthTextureView;
}

注意这里的关键词:

  1. GpuTexture
  2. GpuTextureView

而不是:

  1. color attachment id
  2. depth attachment id
  3. framebuffer handle

这说明高版本在离屏资源表达上,已经明显转向现代 GPU 资源对象模型。

4.8.3 看看它是怎么创建资源的

java 复制代码
public void createBuffers(int width, int height) {
    RenderSystem.assertOnRenderThread();
    GpuDevice device = RenderSystem.getDevice();

    if (this.useDepth) {
        this.depthTexture = device.createTexture(() -> this.label + " / Depth", 15, format, width, height, 1, 1);
        this.depthTextureView = device.createTextureView(this.depthTexture);
    }

    this.colorTexture = device.createTexture(() -> this.label + " / Color", 15, TextureFormat.RGBA8, width, height, 1, 1);
    this.colorTextureView = device.createTextureView(this.colorTexture);
}

这里的重点不是参数细节,而是资源创建的结构:

  1. 先通过 GpuDevice 创建 texture
  2. 再创建 texture view
  3. RenderTarget 持有的是资源对象与 view

这已经和旧时代“拿一堆 GL id 自己管理”完全不是一个抽象层次。

4.8.4 销毁语义也已经是资源对象生命周期

再看销毁:

java 复制代码
public void destroyBuffers() {
    RenderSystem.assertOnRenderThread();
    if (this.depthTexture != null) {
        this.depthTexture.close();
        this.depthTexture = null;
    }
    if (this.depthTextureView != null) {
        this.depthTextureView.close();
        this.depthTextureView = null;
    }
    if (this.colorTexture != null) {
        this.colorTexture.close();
        this.colorTexture = null;
    }
    if (this.colorTextureView != null) {
        this.colorTextureView.close();
        this.colorTextureView = null;
    }
}

这再次说明:

高版本在离屏层的核心语义已经变成资源生命周期管理,而不是手动记住几个 id。

4.8.5 copyDepthFrom(...)blitAndBlendToTexture(...) 说明什么

看两段方法:

java 复制代码
public void copyDepthFrom(RenderTarget otherTarget) {
    RenderSystem.getDevice()
        .createCommandEncoder()
        .copyTextureToTexture(otherTarget.depthTexture, this.depthTexture, 0, 0, 0, 0, 0, this.width, this.height);
}
java 复制代码
public void blitAndBlendToTexture(GpuTextureView textureView) {
    try (RenderPass renderpass = RenderSystem.getDevice()
            .createCommandEncoder()
            .createRenderPass(() -> "Blit render target", textureView, OptionalInt.empty())) {
        renderpass.setPipeline(RenderPipelines.ENTITY_OUTLINE_BLIT);
        RenderSystem.bindDefaultUniforms(renderpass);
        renderpass.bindTexture("InSampler", this.colorTextureView, RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));
        renderpass.draw(0, 3);
    }
}

这两段非常关键。
它说明:

  1. 离屏 target 之间的资源复制已经通过 command encoder 表达。
  2. 合成也开始显式走 render pass + pipeline,而不是默认“绑回主 FBO 画个 quad”。

4.9 GPU资源不再只是一个texture id:GpuTextureGpuTextureView

前一节里我们已经看到,RenderTarget内部保存的不是旧式colorTextureId,而是:

1.GpuTexture
2.GpuTextureView

这不是命名变化,而是资源模型变化。

4.9.1先看GpuTextureView本体

源码非常短:

java 复制代码
public abstract class GpuTextureView implements AutoCloseable {
    private final GpuTexture texture;
    private final int baseMipLevel;
    private final int mipLevels;

    public GpuTexture texture() {
        return this.texture;
    }

    public int baseMipLevel() {
        return this.baseMipLevel;
    }

    public int mipLevels() {
        return this.mipLevels;
    }

    public int getWidth(int mipLevel) {
        return this.texture.getWidth(mipLevel + this.baseMipLevel);
    }

    public int getHeight(int mipLevel) {
        return this.texture.getHeight(mipLevel + this.baseMipLevel);
    }

    public abstract boolean isClosed();
}

这段代码有几个非常关键的信号:

1.view不是纹理本身。
2.view引用一个底层GpuTexture
3.view可以只看这个纹理的一部分mip层级。
4.view自己也有生命周期,需要close()

4.9.2为什么高版本开始强调view

旧时代很多上层代码的默认想法是:

“我有一张纹理,所以我就有一个纹理句柄。”

这在现代GPU资源模型里太粗糙了。

因为你真正需要表达的经常不是:

“我拥有整个资源。”

而是:

“我现在要以某种方式访问这个资源的某一部分。”

GpuTextureView正是在表达这个语义。

它允许高层把“资源本体”和“当前访问视图”分开。

4.9.3这对离屏与合成为什么很重要

如果你继续沿用旧式colorTextureId心智,那么你会天然假设:

1.离屏目标暴露给上层的就是一张完整纹理。
2.采样、复制、合成都默认对整张纹理进行。
3.mip、子区域、后续视图变化都不是上层关心的问题。

但现在高版本已经把这些语义显式对象化了。

这意味着:

1.离屏输出更适合以GpuTextureView向后续pass暴露。
2.上层不该再直接依赖“某个整数纹理id”。
3.UIVisualLayer、视频上传、外部纹理接入等系统,都应该围绕view而不是裸资源句柄设计。

4.9.4GpuTextureView为什么说明资源所有权必须更严谨

再看RenderTarget.destroyBuffers()时对view的处理:

java 复制代码
if (this.colorTextureView != null) {
    this.colorTextureView.close();
    this.colorTextureView = null;
}

这件事很值得强调。

因为它表明:

1.view不是随便借一下就完了的临时数据。
2.view和texture一样属于需要明确释放的GPU资源对象。
3.如果你的上层代码把view到处缓存却不管理生命周期,就会进入错误状态。

一个重要迁移结论

高版本里上层特效代码不应该继续把“纹理资源”抽象成一个整数id。
更稳定的边界应该是“能力导向接口+GpuTextureView或更高层资源句柄”。

4.10FrameGraph:真正的“新渲染系统骨架”

到了这里,我们终于可以进入高版本渲染系统里最像“架构中枢”的对象之一:FrameGraphBuilder
如果前面的GuiGraphicsGuiRenderStateRenderPipelineTextureTarget是在告诉你“渲染对象和资源表达变了”,那么FrameGraphBuilder是在告诉你:

“pass调度与资源生命周期管理本身,也已经正式进入架构层。”

4.10.1什么是FrameGraph

可以先用最简单的话来定义:

FrameGraph就是一张描述“这一帧有哪些pass、这些pass依赖哪些资源、这些资源什么时候分配和释放”的图。

它要解决的核心问题不是“怎么画一个东西”,而是:

1.这一帧到底需要执行哪些pass。
2.哪些pass其实可以裁掉。
3.哪些资源在哪些pass之间共享。
4.这些资源应该何时获取、何时释放。
5.pass顺序如何根据依赖自动决定。

这和旧式“我先bind这个FBO,再手动调用下一步”是完全不同的思路。

4.10.2先看最关键的入口

源码:

java 复制代码
public class FrameGraphBuilder {
    private final List<FrameGraphBuilder.InternalVirtualResource<?>> internalResources = new ArrayList<>();
    private final List<FrameGraphBuilder.ExternalResource<?>> externalResources = new ArrayList<>();
    private final List<FrameGraphBuilder.Pass> passes = new ArrayList<>();

    public FramePass addPass(String name) {
        FrameGraphBuilder.Pass pass = new FrameGraphBuilder.Pass(this.passes.size(), name);
        this.passes.add(pass);
        return pass;
    }

    public <T> ResourceHandle<T> importExternal(String name, T resource) {
        ...
    }

    public <T> ResourceHandle<T> createInternal(String name, ResourceDescriptor<T> descriptor) {
        ...
    }
}

这里已经能看出FrameGraph的三个基础元素:

1.pass
2.external resource
3.internal resource

4.10.3importExternal(...)createInternal(...)分别表达什么

这两个入口非常关键。

importExternal(...)表达的是:

“这个资源不是由当前frame graph创建的,但本帧图会引用它。”

例如:

1.主屏幕目标
2.已有的离屏目标
3.从别处传进来的纹理资源

createInternal(...)表达的是:

“这个资源是当前frame graph内部产生并管理生命周期的资源。”

例如:

1.某个临时后处理target
2.某个中间mask纹理
3.某个只在数个pass之间存在的缓冲

这条边界非常重要,因为它决定资源生命周期是否由frame graph接管。

4.10.4ResourceHandle为什么比“直接传对象”更重要

FrameGraph没有让你把对象直接塞进pass,而是引入了ResourceHandle

源码里Handle大致是这样:

java 复制代码
static class Handle<T> implements ResourceHandle<T> {
    final FrameGraphBuilder.VirtualResource<T> holder;
    private final int version;
    final FrameGraphBuilder.@Nullable Pass createdBy;
    final BitSet readBy = new BitSet();
    private FrameGraphBuilder.@Nullable Handle<T> aliasedBy;

    @Override
    public T get() {
        return this.holder.get();
    }
}

为什么这很重要?

因为handle表达的不只是“拿到对象”,而是:

1.这个资源是哪个虚拟资源的哪个版本。
2.它是谁创建的。
3.哪些pass读了它。
4.它是否已经被后续写入alias成新版。

也就是说,handle是资源数据流的一部分,不是普通引用。

4.10.5FrameGraph如何决定执行哪些pass

execute(...)里的主流程:

java 复制代码
public void execute(GraphicsResourceAllocator allocator, FrameGraphBuilder.Inspector inspector) {
    BitSet keep = this.identifyPassesToKeep();
    List<FrameGraphBuilder.Pass> ordered = new ArrayList<>(keep.cardinality());
    BitSet visiting = new BitSet(this.passes.size());

    for (FrameGraphBuilder.Pass pass : this.passes) {
        this.resolvePassOrder(pass, keep, visiting, ordered);
    }

    this.assignResourceLifetimes(ordered);

    for (FrameGraphBuilder.Pass pass : ordered) {
        ...
        pass.task.run();
        ...
    }
}

这里至少做了三件事:

1.识别哪些pass真的需要保留。
2.解析pass顺序。
3.根据最终顺序分配资源生命周期。

这就是为什么FrameGraph不是“高级任务列表”。它是在真正管理一帧渲染图。

4.10.6pass裁剪意味着什么

先看identifyPassesToKeep()

java 复制代码
private BitSet identifyPassesToKeep() {
    for (FrameGraphBuilder.VirtualResource<?> virtualresource : this.externalResources) {
        FrameGraphBuilder.Pass pass = virtualresource.handle.createdBy;
        if (pass != null) {
            this.discoverAllRequiredPasses(pass, bitset, deque);
        }
    }

    for (FrameGraphBuilder.Pass pass : this.passes) {
        if (pass.disableCulling) {
            this.discoverAllRequiredPasses(pass, bitset, deque);
        }
    }
}

这说明不是所有你“加进来”的pass最后都会执行。

只有那些:

  1. 会影响外部资源产出
  2. 或者被明确标记不能裁剪的pass

才会被保留下来。这是一种很现代的图式优化思维。

4.10.7资源生命周期是怎么被自动安排的

再看assignResourceLifetimes(...)

java 复制代码
private void assignResourceLifetimes(Collection<FrameGraphBuilder.Pass> passes) {
    FrameGraphBuilder.Pass[] lastUsers = new FrameGraphBuilder.Pass[this.internalResources.size()];

    for (FrameGraphBuilder.Pass pass : passes) {
        for (int i = pass.requiredResourceIds.nextSetBit(0); i >= 0; i = pass.requiredResourceIds.nextSetBit(i + 1)) {
            FrameGraphBuilder.InternalVirtualResource<?> resource = this.internalResources.get(i);
            FrameGraphBuilder.Pass previous = lastUsers[i];
            lastUsers[i] = pass;
            if (previous == null) {
                pass.resourcesToAcquire.add(resource);
            } else {
                previous.resourcesToRelease.clear(i);
            }

            pass.resourcesToRelease.set(i);
        }
    }
}

这段代码表达的语义非常清楚:

1.资源第一次被用到时获取。
2.资源最后一次被用到的pass负责释放。

这就是资源生命周期自动化。

所以以后再谈高版本离屏、后处理、多pass合成时,正确方向应该是:

1.把pass关系表达清楚。
2.把资源流表达清楚。
3.让图自己决定分配和释放。

而不是继续手动写一串bind/unbind流程。

4.10.8用图看FrameGraph

flowchart TD A[importExternal主屏幕资源] B[createInternal中间离屏资源] P1[PassA:提取或第一次绘制] P2[PassB:处理中间结果] P3[PassC:合成到外部资源] A --> P3 B --> P2 P1 --> B P2 --> P3

这个图当然简化了很多,但它足够帮助你建立正确印象:

1.pass不是孤立函数。
2.资源不是任意全局对象。
3.输出关系决定pass是否需要被保留。

不要把FrameGraph当成“更好看的后处理链表”

后处理链表只是在描述顺序。而FrameGraph描述的是顺序、依赖、资源版本和生命周期。

4.11CommandEncoder/RenderPass/GpuDevice:更底层的新GPU语义

如果说RenderPipeline在定义“我要以什么渲染契约绘制”,FrameGraphBuilder在定义“这一帧有哪些pass和资源关系”,那么再往下一层,你就会看到真正的GPU操作接口:

1.GpuDevice
2.CommandEncoder
3.RenderPass

这几个对象非常重要,因为它们说明Mojang正在继续把底层后端抽象往现代图形API方向推进。

4.11.1先看GpuDevice

源码:

java 复制代码
public interface GpuDevice {
    CommandEncoder createCommandEncoder();

    GpuSampler createSampler(...);

    GpuTexture createTexture(...);

    GpuTextureView createTextureView(GpuTexture texture);

    GpuTextureView createTextureView(GpuTexture texture, int baseMipLevel, int mipLevels);

    GpuBuffer createBuffer(...);

    CompiledRenderPipeline precompilePipeline(RenderPipeline pipeline, @Nullable ShaderSource shaderSource);
}

从这个接口你就能看出,GpuDevice已经是一个非常典型的设备抽象了。

它负责:

1.创建命令编码器
2.创建纹理
3.创建视图
4.创建buffer
5.预编译pipeline

所以高版本里真正的资源创建中心,不应该再是你自己散落在各处的OpenGL调用。

4.11.2CommandEncoder在表达什么

再看CommandEncoder

java 复制代码
public interface CommandEncoder {
    RenderPass createRenderPass(Supplier<String> debugGroup, GpuTextureView colorTexture, OptionalInt clearColor);

    void clearColorTexture(GpuTexture texture, int color);

    void writeToBuffer(GpuBufferSlice slice, ByteBuffer buffer);

    void writeToTexture(GpuTexture texture, NativeImage image);

    void copyTextureToTexture(GpuTexture source, GpuTexture destination, int mipLevel, int x, int y, int sourceX, int sourceY, int width, int height);

    void presentTexture(GpuTextureView texture);
}

这个接口非常像现代GPUAPI里的命令录制层。

它关心的不是“当前全局状态是什么”,而是:

1.我现在要创建一个render pass。
2.我要清理哪个资源。
3.我要把哪段数据写入哪个buffer或texture。
4.我要把哪个纹理复制到另一个纹理。
5.我要把哪个最终view呈现到屏幕。

这已经和旧式RenderSystem状态机思维有本质区别了。

4.11.3RenderPass在表达什么

再看RenderPass

java 复制代码
public interface RenderPass extends AutoCloseable {
    void setPipeline(RenderPipeline pipeline);

    void bindTexture(String name, @Nullable GpuTextureView textureView, @Nullable GpuSampler sampler);

    void setUniform(String name, GpuBuffer buffer);

    void setViewport(int x, int y, int width, int height);

    void enableScissor(int x, int y, int width, int height);

    void setVertexBuffer(int index, GpuBuffer buffer);

    void setIndexBuffer(GpuBuffer indexBuffer, VertexFormat.IndexType indexType);

    void drawIndexed(int firstIndex, int index, int indexCount, int primCount);

    void draw(int firstIndex, int indexCount);
}

这说明真正的绘制提交通常发生在pass内部。

而且pass内部的关键动作已经被明确成:

  1. 选择pipeline
  2. 绑定texture与sampler
  3. 绑定uniform buffer
  4. 设置viewport/scissor
  5. 绑定vertex/index buffer
  6. 发起draw

这套语义非常接近现代图形API的正式提交流程。

4.11.4为什么这说明上层不该继续直接依赖OpenGL

因为如果Mojang已经在提供:

  1. 设备抽象
  2. 资源抽象
  3. 视图抽象
  4. pass抽象
  5. pipeline抽象

那么上层UI/特效/世界补充渲染继续直接依赖原始OpenGL调用,就等于在主动绕过高版本正式架构。而且在之后迁移到VK时会造成巨大的迁移灾难

4.11.5把这三层关系串起来

现在我们可以把高版本渲染提交主线画成这样:

flowchart TD A[上层语义:GUI/粒子/世界补充渲染] --> B[RenderPipeline与资源句柄] B --> C[FrameGraph或直接Pass组织] C --> D[GpuDevice.createCommandEncoder] D --> E[RenderPass] E --> F[绑定Texture/Uniform/Buffer] F --> G[draw]

本章的介绍到此结束

游客

全部评论 (0)

暂无评论,快来抢沙发吧~