MTR 中面向 OBJ 渲染的 GPU Instancing 管线设计与实现


MTR 中面向 OBJ 渲染的 GPU Instancing 管线设计与实现
如果你学过一点计算机图形学,GPU instancing 这个概念应该不陌生。
它的核心想法很简单:如果场景里有很多个相同的模型,不要把同一份顶点数据一遍又一遍提交给 GPU。我们只上传一份静态模型,然后给每个实例提供不同的位置、旋转、缩放、颜色和光照,让 GPU 一次性画出很多个对象。
也就是:
一份 mesh + 多份 instance 数据 = 很多个对象
这里的 mesh 是“模型本身”,比如一段轨道、一节车体、一个 bogie 的几何体。instance 数据则是“这个模型在这一帧应该怎么被画出来”,比如它在世界中的位置、朝向、颜色、光照等。如果有一百段轨道都使用同一个 OBJ 模型,那么理想情况下,我们不应该把这份 OBJ 的顶点数据重复提交一百遍,而是提交一次几何体,再提交一百份变换数据。
其实如果只是写一个最小 demo,instancing的实现并不复杂。准备一份顶点缓冲区,再准备一份 instance buffer,把每个实例的 model matrix 放进去,shader 里读取这个矩阵,最后调用 drawElementsInstanced。只要 OpenGL 状态和 vertex attribute 配置正确,就能很快看到一排相同模型被画在不同位置,这个过程的耗时可能就十几分钟。
但这次我们要做的不是一个图形学 demo,而是把 GPU instancing 接进 Minecraft Transit Railway 已经存在的渲染系统里。
所以真正的难点在于新的 GPU instancing 如何与 MTR 原来的管线共存。MTR 已经有自己的 optimized renderer,有轨道资源、车辆资源、OBJ group、part condition、interior/exterior 渲染阶段、半透明模型、资源重载,还有跨 Minecraft 版本的 mapping 层。不能因为我加了一条更快的 GPU path,就把原来的系统全部推翻。
这也是很多工程优化和个人项目最大的区别。个人项目里,我们可以从零开始设计输入、输出和渲染流程;但真实的工程项目里,输入格式、资源结构、旧代码行为、用户资源包、不同版本兼容性都已经存在了。后面新接入的优化必须和原有系统一起工作。
所以这次的目标不是重写 MTR 的渲染器,而是在现有管线旁边接上一条新的支路:
Code
MTR 原有渲染管线│├── 适合实例化的 opaque/cutout OBJ → GPU instancing path│└── 不适合实例化的部分 → optimized renderer / fallback

特别声明,这篇文章没有严格按照真实的实现顺序来写。为了便于阅读,也为了让整体实现流程更有系统性,行为顺序上会对部分顺序做一些调整。我们会先从 MTR 自己的问题说起,再从最小的 instancing 模型开始,一步一步搭出 Mapping 层、静态 mesh 缓存、batch key、rail 接入、vehicle 接入、fallback、GL state 保护和 resource reload。
从 NTE 和 IR 看铁路 Mod 的模型渲染组织
在最开始,我先看了一下其他能够加载 mesh 模型的高版本铁路类 mod。其实我发现,即使一个项目没有显式使用 GPU instancing,它的渲染逻辑里也经常会出现类似的结构:
Code
同一份模型资源+ 多个世界位置+ 多组变换矩阵+ 多次渲染提交
其实原因很明了。铁路场景里有大量重复对象:轨道、车辆、车厢,甚至同一列车里的某些局部结构,本质上都可能是同一份 OBJ 或 mesh 模型在不同位置、不同矩阵、不同光照条件下反复绘制。
这里我主要分析的是 Nemo's Transit Expansion 和 Immersive Railroading 。前者想必只要接触过 MTR 的同学就不会陌生,应该也是不少人 3.x 版本里的必装扩展;它也是 MTR 社区里最早把 mesh 模型渲染这件事做成体系的项目。后者则是 Forge 生态里很经典的铁路 mod,更偏“全尺寸真实铁路驾驶”的车辆和轨道系统。
NTE:一套比较完整的模型提交框架
NTE 的渲染系统不是把 OBJ 加载、材质处理和渲染提交全部散落在业务代码里,而是自己组织了一套模型管理和 draw scheduling 结构。
粗略看,可以分成三层:
Code
RawModel 还没有上传到 GPU 的原始模型Model 已经上传到 GPU 的模型数据ModelCluster 按 opaque / translucent 拆分后的可绘制模型集合
ModelManager 负责加载和缓存模型。ModelCluster 会把一个模型里的 opaque 部分和 translucent 部分拆开。这样渲染时,不透明和半透明对象可以走不同的队列。这个设计很重要,因为在图形学里,opaque 和 translucent 的处理方式本来就不一样:opaque 通常比较容易合批,translucent 则更依赖绘制顺序。
如果把 opaque 和 translucent 太早混在一起,后面就会很难处理。比如 opaque 可以先写深度,再让 GPU 利用深度测试减少片元开销;但 translucent 往往需要考虑从远到近的绘制顺序,否则透明叠加结果会不对。所以 NTE 很早就把这两类对象拆开,这是一个非常实际的工程选择。
再往外一层是 DrawScheduler。业务代码不是直接 draw,而是先把要画的 ModelCluster、矩阵和光照放进队列。到了统一提交阶段,scheduler 再根据当前 DrawContext 决定走 Blaze buffer 还是 GL batch manager。
不过,NTE 的 batch 和真正的 GPU instancing 还不是一回事。
先看 NTE 的普通模型路径。这里的核心类是 BatchManager。它会把使用相同材质和相同 shader 配置的模型放到同一组里。这样做的目的,是减少渲染时频繁切换材质和 shader 的开销。
不过,这里的“batch”还不是 GPU instancing。普通路径里,每次 enqueue 进去的模型最后仍然会变成一个 RenderCall。到了绘制阶段,BatchManager 只是在同一个材质 / shader 组合下,按顺序逐个执行这些 RenderCall。
可以把它理解成:
Code
把状态相同的 draw call 放在一起画
这已经是一种优化,因为它减少了状态切换。但它并没有把多个对象合成一次 instanced draw。每个对象仍然有自己的一次绘制提交,只是这些提交被排得更整齐。
真正使用 instance buffer 的,是 NTE 的 rail path,也就是 InstancedRailChunk。
InstancedRailChunk 会为一个 rail chunk 创建 InstanceBuf。这里的 InstanceBuf 可以看做“一块专门存每个轨道实例差异数据的 GPU buffer”。轨道模型本身只需要上传一次,而每一小段轨道不同的地方,例如颜色、光照和矩阵,会被连续写进这块 instance buffer。
它的 vertex attribute mapping 里也明确写了:MATRIX_MODEL 来自 instance buffer。也就是说,shader 在画同一个 rail model 时,不是所有轨道共用一个 model matrix,而是每个轨道实例从 InstanceBuf 里读取自己的矩阵。
所以 NTE 的 rail instancing 大致可以理解成:
Code
同一个 rail model-> 上传成一份 VertArrays-> 准备一个 InstanceBuf-> 写入多段轨道的 color / light / matrix-> 用一次 instanced draw 画出多段轨道
这条路径非常适合轨道。轨道 chunk 里的对象比较规整:同一种 rail model 会在一个区域里重复出现很多次,每段轨道主要差在位置矩阵、颜色和光照。几何体本身不需要变,所以把这些“每段轨道不同的数据”放进 instance buffer,是很自然的做法。
车辆路径就不一样了。
NTE 的车辆不是把整辆车或多个车厢直接收集成 instance buffer 来画,而是走它自己的 sowcerext.multipart 模型组织方式。以 MultipartContainer.updateAndEnqueueAll(...) 为例,它的流程大概是:
Code
遍历每个 part-> 更新这个 part 当前的状态-> 取出当前应该显示的 ModelCluster-> 计算这个 part 的局部变换矩阵-> 和车厢整体矩阵相乘-> 把结果交给 DrawScheduler
这里的 part 是指车辆模型里的一个可单独更新的组成部分,比如车体上的某个组件。NTE 里这些 part 可以来自它自己的 .animated 或 MI 资源体系。
.animated part 可以根据状态函数选择当前使用哪一个模型状态,并计算平移、旋转等变换;MI part 则可以根据 keyframe 时间读取平移 / 旋转曲线,同时处理隐藏部件。它们都是 NTE 自己的 multipart 更新机制,不等同于 MTR 原生车辆里的 PartCondition 或 render stage。
所以,NTE 的车辆路径更像这样:
Code
车辆 / 车厢状态-> 更新 multipart part-> 计算每个 part 当前是否显示、用哪个模型、用什么矩阵-> 逐个 part enqueue 到 DrawScheduler-> BatchManager 再按材质 / shader 分组绘制
这条路径有模型缓存,也有材质层面的 batch,但它没有把多辆车、多个车厢,或者多个相同 part 重新收集成一块跨车辆的 instance buffer。
如果要在 NTE 车辆上继续做真正的 vehicle instancing,单靠现有 DrawScheduler 还不够。因为 renderer 需要额外判断:哪些 part 使用的是同一个 ModelCluster,哪些 part 的材质状态一致,哪些 part 当前可见,哪些 part 的动画帧相同或可以共享同一套静态 mesh。然后还要把它们跨车厢、跨车辆收集起来,把每个实例自己的矩阵、光照和颜色写进 instance buffer。
这比 rail instancing 复杂得多。轨道基本上是“同一模型 + 多个矩阵”;车辆则是“多个 part + 多种状态 + 多种矩阵 + 可见性变化 + 动画状态”。所以 NTE 当前的边界很清楚:rail chunk 做了真正的 GPU instancing;车辆模型则仍然是 multipart 更新、逐 part enqueue,再由 BatchManager 做材质 / shader 层面的批处理。
这个区别也说明了为什么 vehicle instancing 不能简单照搬 rail instancing。两者看起来都是“重复模型优化”,但 rail 的重复更规整,车辆的重复则被部件状态和动画逻辑打散了。
再对比 MTR4,这个问题会更明显。MTR4 已经有自己的资源系统、车辆模型描述、optimized renderer 和跨版本 mapping。它不能简单复制 NTE 的 DrawScheduler,也不能只把 NTE 的 rail instancing 搬过来就结束。真正要做的是在 MTR 已有管线旁边,加一条能够识别哪些 OBJ 可以实例化、哪些部分必须 fallback,并且还能和旧 renderer 共存的新支路。
IR:更像 rolling stock 专用渲染体系
Immersive Railroading 是另一种思路。它的车辆模型建立在 OBJModel / OBJRender 体系上。以 StockModel 为例,它继承自 OBJModel,模型位置、贴图、texture cache、LOD 尺寸等都由这一套模型系统管理。
它非常重视车辆本身的语义。比如一辆车不是一个简单的 OBJ,而是由 frame、bogey、wheel、door、control、seat、headlight、animation 等组件组成。渲染时,它会通过 ModelState 把这些组件状态、动画矩阵和光照状态叠加进去。
这和普通游戏里的“渲染一个静态模型”差别很大。铁路车辆不是一个单独的 mesh,它往往有车体、转向架、轮子、门、灯、仪表、座位等多个部分。不同部分有不同的局部坐标和动画规则。比如 bogie 会跟随轨道方向旋转,轮子会根据行驶距离旋转,车门会根据开关状态移动。这些语义都必须在渲染前被整理成最终的矩阵和状态。
IR 里还有一个很朴素但实用的优化:贴图 LOD。它会根据车辆距离玩家的远近选择不同尺寸的贴图。例如:
Code
近处:使用最大贴图尺寸中距离:使用 1024远距离:使用 512
这就是一个很典型的纹理侧 LOD:模型几何不变,但贴图分辨率会根据观察距离降级。近处车辆保留高分辨率贴图,保证细节;远处车辆切到较小贴图,减少显存占用和纹理采样压力。
这类优化很工程,因为它抓住的是铁路类 mod 里另一个很现实的瓶颈:车辆模型通常贴图面积很大,一些追求高精细度和高分辨率的资源包作者往往会使用 4k ~ 8k 分辨率的贴图。即使顶点数量暂时不是主要问题,高分辨率贴图仍然可能带来明显的资源压力。
这一点其实也是目前 MTR 渲染系统里相对缺少的能力。MTR 现在更关注模型提交、批处理和渲染路径本身,但在贴图纹理侧,还没有类似 IR 这种明确的纹理 LOD 策略。后续如果继续优化车辆渲染,除了 GPU instancing 这类几何提交优化,基于距离的贴图降级也很值得做。
MTR 的 Instancing 切入点:Rail 与 Vehicle 的重复绘制
通过分析这两个项目,我们得到了一个结论,铁路 mod 的渲染往往与模型结构、组件语义、材质状态、透明排序、资源生命周期等深度绑定。
MTR 这次的问题也是类似的:
我们要做的是把这些重复出现的 OBJ 绘制整理出来,让它们进入 MTR 已经存在的渲染管线,并在合适的位置接上 GPU instancing。
换句话说,GPU instancing 是一个局部技术点,但它必须穿过资源加载、模型组织、渲染状态、车辆逻辑、版本兼容这些层次,最后才能变成一个真正可用的功能。
而在 MTR 里最适合实例化的对象主要有两类:轨道和车辆。
轨道的重复性很强。同一种 rail style 会在世界里出现很多段。每一段轨道的位置、方向和光照不同,但它们使用的模型几何体可能是相同的。
比如一个车站附近可能铺了很多段同样样式的轨道。它们在世界里的坐标不同,有的转弯,有的直行,但最终使用的 OBJ mesh 可能来自同一份资源。传统方式下,如果每一段都重新提交一遍 mesh,就会产生大量重复工作。
车辆也类似。同一车型、同一节车体、同一个 bogie,在同一帧中可能出现多次。它们的位置和姿态不同,但很多 OBJ group 对应的 mesh 是可以复用的。
例如一条线路上同时出现多辆相同车型的列车,每辆车又有多个相同结构的车厢。车厢之间的差异主要是位置和姿态,而不是几何体本身。这个场景天然适合“静态几何复用 + 实例数据变化”的绘制模型。
所以从图形学角度看,这正好符合 GPU instancing 的使用场景:
Code
Static Mesh + Instance Matrix + Instance Light + Instance Color
其中:
Static Mesh是不变的模型几何;Instance Matrix决定每个实例的位置、旋转和缩放;Instance Light决定每个实例的 lightmap;Instance Color决定每个实例的颜色或材质颜色修正。
如果只看这层,问题很简单。但 MTR 的复杂度在下面这些问题:
- 这个 OBJ group 是不透明的,还是半透明的?
- 这个 mesh 使用的 shader 能不能被当前 GPU path 支持?
- 它属于
EXTERIOR、INTERIOR,还是别的 render stage? - 车辆 part 的
PartCondition当前是否满足? - part 有没有自己的局部变换?
- 当前 Minecraft 版本下,position matrix 应该怎么获取?
- GPU draw 改了 OpenGL 状态后,会不会影响原来的 renderer?
- 资源重载后,旧的 GPU mesh 和 handle 怎么失效?
- 如果一辆车只有一个小 group 不支持 GPU,是整车 fallback,还是只 fallback 那一块?
这些问题才是这次实现的主体。
我最后给这条路径定了一个原则:
GPU path 只处理自己能正确处理的东西;处理不了的部分必须明确 fallback;fallback 不是异常,而是设计的一部分。
这个原则决定了后面的架构。
如果一个 mesh 是半透明的,而当前 GPU path 还不能正确处理透明排序,那它就应该走 fallback。如果一个 shader type 暂时不支持,也应该走 fallback。如果一个资源包里的 OBJ group 不符合预期,也应该给出明确原因,而不是默默画坏。
因此,fallback 在这条管线里是一种明确的安全边界。GPU path 负责处理已经确认可实例化的对象,fallback 负责承接暂时不适合实例化的对象,让整条渲染流程保持可用。
第一步:把 OBJ 拆成可复用 Mesh 与实例数据
GPU instancing 的第一步,是把数据分成两类:哪些东西所有实例共用,哪些东西每个实例不同。
对 MTR 的 OBJ 来说,共用的是:
- 顶点位置;
- UV;
- normal;
- index buffer;
- mesh 的基础材质信息;
- OBJ group 和 mesh 的静态关系。
每个实例不同的是:
- model matrix;
- lightmap;
- color;
- 当前帧 offset 相关的矩阵修正。
于是目标数据流可以写成这样:
Code
OBJ RawMesh↓ 资源构建阶段上传StaticObjMesh↓ 运行时按实例排队InstancePayload(matrix, color, light)↓ 帧末按 batch 连续上传InstanceBuffer↓ drawElementsInstancedGPU draw

这里有两个关键词。
第一个是 StaticObjMesh。它表示一份已经上传到 GPU、可以被很多实例复用的静态模型。
第二个是 InstancePayload。它表示每个实例自己的小数据包,里面主要是矩阵、颜色和光照。
这个拆分很基础,但它也是后面所有设计的前提。如果静态 mesh 没有缓存好,运行时就会重复处理模型;如果 instance payload 不规整,上传和 shader 读取就会变得很容易出错。我们可以直接用面向对象编程里的 Class 与 Instance 来理解。Mesh 就是定义好的“类”,它包含了所有的顶点数据和基础结构,这个类在内存里只需要存在一份。而场景里渲染的每一个具体物体,都是这个类派生出来的“实例”。
在渲染时,GPU 不需要去重复复制这个“类”,它只需要通过 InstancePayload 给每个“实例”传递不同的初始化实参。
另外,这个拆分还有一个好处,它让资源阶段和运行阶段的职责更清楚。
资源阶段负责处理重的、不需要每帧重复做的事情,比如解析 OBJ、构建 mesh、上传 VBO/IBO、记录 group 映射。运行阶段只负责处理每帧变化的事情,比如当前矩阵、光照、颜色和可见对象队列。
这也正是实时渲染里常见的优化思路:能提前做的事情提前做,能缓存的东西缓存起来,每帧只做必要的工作。
第二步:用 Mapping 层封装跨版本 Instancing 能力
如果只支持一个 Minecraft 版本,我可以直接在 MTR 主仓库里写 VAO、VBO、attribute pointer、shader patch。这样短期最快,但长期会很难维护。
MTR 是跨版本项目。不同 Minecraft 版本的渲染封装、矩阵对象和 shader 资源结构都有差异。GPU instancing 又刚好需要操作这些底层内容:
- 它要新增 vertex attribute;
- 它要修改 shader,让 shader 读取 instance matrix;
- 它要拿当前帧的矩阵;
- 它要保护 OpenGL 状态。
所以第一步不是直接改业务代码,而是在 Minecraft-Mappings 里补一套公共能力。
这一步是在给工程打地基,地基不打好,上面盖的所有楼都是危楼。它本身不直接产生业务效果,但如果不做这一步,上层的 rail 和 vehicle 接入就会到处重复写底层代码。一旦不同 Minecraft 版本出现差异,维护成本会迅速上升。
1. 描述 vertex attribute
Mapping 层需要知道每个 attribute 来自哪里。
例如:
Code
POSITION 来自 vertex bufferUV_TEXTURE 来自 vertex bufferNORMAL 来自 vertex bufferCOLOR 可以来自 instance bufferUV_LIGHTMAP 可以来自 instance bufferMATRIX_MODEL 可以来自 instance buffer
这次最关键的是 MATRIX_MODEL。普通 draw 里,model matrix 可以是一个全局状态;但 instanced draw 里,每个实例都有自己的 model matrix,所以它必须来自 instance buffer。
这里的变化很重要。传统绘制可以理解成“当前 draw call 有一个 model matrix”,而 instanced draw 则变成“同一个 draw call 里,每个 instance 都有一个 model matrix”。这意味着 model matrix 从一个全局 uniform 状态,变成了 per-instance attribute。
2. 提供 InstancedDrawHelper
实例化绘制需要一些固定操作,例如:
Code
setupInstanceAttributessetupInstanceAttributePointersuploadInstancesdrawElementsInstanced
这些操作不应该散落在 MTR 业务代码里。MTR 应该表达“我要上传这些实例”“我要画这个 mesh 的 N 个实例”,而不是到处手写 attribute divisor 和 pointer offset。
所以 Mapping 层提供了 InstancedDrawHelper,把这类底层操作收起来。
OpenGL 的 vertex attribute 配置非常容易写错,尤其是 instancing 里还要处理 divisor、stride、offset、mat4 拆分等问题。如果这些细节散落在多个类里,后期调试会很痛苦。
3. 给 shader 注入 ModelMat
shader 必须知道每个实例的模型矩阵。概念上就是在 vertex shader 里加入:
Code
in mat4 ModelMat;
然后顶点位置和法线计算要乘上这个矩阵。
这里容易低估难度。mat4 在 vertex attribute 里不是一个普通字段,它会展开成四个 vec4 attribute。CPU 侧写 buffer 的顺序、stride、offset,必须和 shader 侧读取的 layout 完全一致。只要错一点,画面就可能出现模型错位、颜色错误或光照错误。
我实现的过程中就在这里被折磨了很久。
这类问题一般不会给出清晰错误信息。OpenGL 不会告诉你“你的 matrix 第三列 offset 错了”。它只会把错误数据送进 shader,然后模型就以奇怪的方式出现在屏幕上。比如位置偏到天上,模型被压扁,颜色不对,或者光照神秘的在闪。
所以 shader patch 和 attribute layout 必须一起设计,不能各写各的。
4. 保护 GL state
GPU path 会绑定 VAO/VBO、切换 shader、绑定 texture、修改 depth/blend/cull 状态。如果画完不恢复,后面的 optimized renderer 可能会被污染。
所以 Mapping 层提供 GlStateTracker 和 protected state scope。简单理解就是:
Code
进入 GPU draw 前:保存当前 OpenGL 状态执行 GPU draw退出 GPU draw 后:恢复原来的 OpenGL 状态
这一步非常重要。没有状态保护,很多 bug 会表现得像“随机出现”:换个资源包、换个视角、换个绘制顺序就出问题。
比如某一次 GPU draw 关闭了 cull face 但没有恢复,后面的模型可能出现背面也被绘制的情况;某一次绑定了错误的 texture 没恢复,后面的模型就可能贴图错乱;某一次 shader 状态没切回来,旧 renderer 的输出就可能完全不对。
所以只靠“我记得恢复了某几个状态”是不够的。更可靠的方式是建立明确的 protected scope,让 GPU path 的状态修改被限制在一个范围内。
5. 处理跨版本矩阵
实例化最终要把 model matrix 写进 instance buffer。这个矩阵必须和当前帧、当前 offset、当前 Minecraft 版本的渲染语义一致。
所以 Mapping 层提供了类似 GraphicsHolder.copyPositionMatrix() 的 helper。这样 MTR 主仓库不用在每个版本里自己判断 position matrix 应该怎么拿。
矩阵问题比它看起来更麻烦。不同版本的渲染封装可能对 matrix stack、camera offset、position matrix 的暴露方式不一样。如果业务代码到处自己拿矩阵,就很容易出现某个版本正常、另一个版本错位的问题。
把矩阵捕获能力收进 Mapping 层以后,MTR 主仓库可以只关心“我需要当前帧的矩阵副本”,而不需要关心每个版本背后具体怎么实现。
到这里,GPU instancing 的底层能力才算准备好。注意,此时还没有真正接 rail 和 vehicle。
第三步:把 OBJ 上传成可复用的 StaticObjMesh
有了 Mapping 层以后,MTR 主仓库要做的第一件事,是把 OBJ 的 raw mesh 上传成可复用的静态 GPU mesh。
这就是 StaticObjMesh 的作用。
它大致保存:
Code
StaticObjMesh- VertexArray / Mesh / IndexBuffer- material properties- shader type- bounds- group/source metadata
运行时如果要绘制一个实例,只需要引用这个 StaticObjMesh,再提供一份 instance payload。
这里的 StaticObjMesh 不只是性能缓存,也是一种边界。它把“模型资源阶段”和“运行时绘制阶段”分开。资源阶段负责把原始 OBJ 转成 GPU 能直接使用的数据;运行时不再关心 OBJ 文件怎么解析,也不再关心 RawMesh 怎么上传,只关心“我要画这个已经准备好的 mesh”。
然后是 GpuObjModelRegistry 和 GpuObjModelWrapper。
GpuObjModelRegistry 负责从 OBJ 资源构建 GPU wrapper,并做缓存。GpuObjModelWrapper 维护两类信息:
Code
OBJ group name -> StaticObjMesh 列表translucent group 集合
为什么要保留 group 维度?因为 vehicle 的 part 通常是按 group name 组织的。后面判断一个 part 能不能走 GPU path,必须知道它引用的 group 是否存在、是否半透明、里面有哪些 mesh。
如果一开始就把整个 OBJ 展平成一堆 mesh,vehicle 接入时就会丢失重要的业务信息。
举个例子,一辆车的 OBJ 里可能有 body、door_left、door_right、bogie_front、window 这些 group。对于 GPU 来说,它们最终可能都是 mesh;但对于车辆逻辑来说,这些 group 的名字非常重要。车门可能有开关动画,窗户可能是半透明,bogie 可能有局部旋转。如果 flatten 之后丢掉 group 信息,后面就很难知道哪个 mesh 应该跟哪个 part 关联。
GpuObjModelWrapper 在这里承担的是 GPU 模型索引表的角色:它记录每个 OBJ group 对应哪些 StaticObjMesh,同时记录哪些 group 属于 translucent。后续系统拿到 group name 后,就能直接找到对应的 GPU mesh,并知道它是否适合进入 opaque instancing path
第四步:用 Batch Key 定义安全的合批边界
有了静态 mesh 后,下一步是合批。
最直觉的做法是:同一张 texture 的 mesh 放进同一个 batch。但放在这里不完全可行。
两个 mesh 使用同一张贴图,不代表它们可以在同一个渲染状态下绘制。影响 draw 是否正确的因素还包括:
- shader type;
- render stage;
- material color;
- lightmap / overlay 语义;
- opaque / translucent;
- vertex attribute state;
- shader override 规则。
如果 batch key 太宽松,就会导致一些情况,比如某个 mesh 使用了错误的 shader,或者继承了另一个 mesh 的材质状态。这类 bug 不一定会崩溃,但画面会不对,而且很难定位。
所以 ObjBatchKey 必须表示:
这些 mesh 在同一套渲染状态下绘制是正确的。
最后队列结构变成两层:
Code
ObjBatchKey└── StaticObjMesh└── InstancePayload[]
第一层保证渲染状态一致,第二层保证同一个 mesh 的实例数据连续。
这个设计的原则是:先画对,再谈画得更快。

这里可以把 batch key 想象成数据库里的 group by key。只有 key 完全相同的数据,才能被放到同一组里。如果 key 少考虑了一个字段,看起来分组数变少了,但结果可能就是错的。
渲染里的 batch key 也是一样。我们当然希望 batch 越少越好,因为 batch 少通常意味着 draw call 少、状态切换少。但如果为了减少 batch 而忽略 shader type 或 material state,就可能把本来不该一起画的东西放到一起。
第五步:让 GpuObjRenderer 聚合实例并统一提交
GpuObjRenderer 是 GPU path 的执行器,但它不应该理解太多业务。
它不需要知道这是 rail 还是 vehicle,也不需要知道这是门、车体还是 bogie。它只需要知道:
Code
给我一个 batch key给我一个 static mesh给我 matrix / color / light我把它放进当前帧队列
一帧开始时,MainRenderer 会通知 GpuObjRenderer 开始新的一帧:
Code
GpuObjRenderer.beginFrame(offset, graphicsHolder)
业务渲染过程中,如果 rail 或 vehicle 发现某个对象可以走 GPU instancing,就把它 queue 进来。
到了统一绘制阶段,GpuObjRenderer.renderOpaque(offset) 会做这些事:
- 遍历所有 batch;
- 把 batch 内所有 mesh 的 instance payload 拼成连续 buffer;
- 一次性上传整个 batch 的 instance data;
- 对每个 mesh 设置正确的 instance attribute pointer;
- 调用
drawElementsInstanced; - 清理当前帧队列。
伪代码大概是:
Code
for (BatchEntry batch : batches) {ByteBuffer payload = pack(batch); // batch 内连续uploadInstances(payload); // 一次上传for (MeshEntry mesh : batch.meshes) {bind(mesh.staticMesh);setupInstancePointers(mesh.offset);drawElementsInstanced(mesh.count);}}
这里真正重要的是前面的数据组织。
如果每个实例都单独上传,CPU 侧仍然会很碎;如果同一个 batch 能合成一块连续 buffer,再一次上传,就更符合 instancing 的意义。
所以这条路径的优化方向是:
Code
每个实例单独上传 → 每个 mesh 聚合 → 每个 batch 一次上传
从实现角度看,这意味着 GpuObjRenderer 不能简单用一个全局 list 存所有 instance。它需要知道 instance 属于哪个 batch、哪个 mesh,还要保证同一个 mesh 的 instance payload 在 buffer 里是连续的。否则到了 draw 的时候,就很难用一个 offset 和 count 描述“这个 mesh 对应的实例范围”。
这也是为什么队列结构一开始就要设计好。如果数据结构过于随意,后期再想优化上传粒度会很麻烦。
另外,GpuObjRenderer 不理解业务是一件好事。它只处理渲染层通用概念:batch、mesh、instance payload。Rail 和 vehicle 的业务差异留在各自资源类里处理。这样后面如果要给别的 OBJ 对象接入 GPU instancing,也不需要改 GpuObjRenderer 的核心逻辑。
第六步:用 Rail 路径验证 Instancing 基础链路
Rail 是最适合先接入 GPU path 的对象。它结构比较简单,重复性强,业务条件也少。
Rail 的资源构建阶段主要检查这些条件:
- rail style 必须是 OBJ;
- 当前环境必须支持 optimized rendering;
GpuObjModelRegistry能成功构建 wrapper;- wrapper 里不能有 translucent mesh。
第四点是有些许保守的。Rail 这里暂时没有做 group 级 translucent fallback,而是只要包含半透明 mesh,就整体回退到旧路径。
原因是第一阶段更重视正确性。Opaque/cutout 已经能覆盖主要收益,而半透明对象涉及绘制顺序、深度和排序问题,风险更高。
通过检查后,RailResource 会把 wrapper 里的 mesh 变成 GPU cache entry:
Code
RailResource-> GpuObjModelWrapper-> StaticObjMesh-> ObjBatchKey-> RailGpuCache.Entry
运行时,rail 不再重复提交 OBJ mesh,而是把每段 rail 的矩阵、颜色和光照 queue 到 GpuObjRenderer。最后由 renderOpaque() 统一绘制。
这条路径跑通后,可以确认三件事:
- Mapping 层的 instance attribute 能工作;
- StaticObjMesh 缓存和 batch 上传能工作;
- GPU path 能和原 optimized renderer 在同一帧里共存。
然后才适合接更复杂的 vehicle。
Rail 路径相对单纯,我们在这里用他来验证从 OBJ wrapper 到 StaticObjMesh,从 instance queue 到 batch upload,从 shader ModelMat 到最终绘制,整条链路是否能正确闭环。如果它都不稳定,就说明底层 Mapping、shader、payload layout、GL state 保护里一定还有问题。先用 rail 把基础链路跑通,再去接 vehicle,可以减少很多变量。
第七步:vehicle 不能简单整车 GPU 或整车 fallback
Vehicle 是这次实现里最复杂的部分。
Rail 更像“同一个模型重复很多次”,而 vehicle 更像“一个模型内部有很多条件、部件和状态”。它包含:
PartCondition;- interior / exterior render stage;
- OBJ group name;
- part local transform;
- door / bogie / body 等不同语义;
- translucent group;
- shader override;
- fallback model。
最简单的做法是:一辆车要么全部 GPU,要么全部 fallback。
但这样会损失很多性能收益。因为一辆车里可能只有一个小 group 不适合 GPU,比如一个半透明窗户、一个特殊 shader group,或者一个缺失 group。如果因为这一小块问题就让整辆车 fallback,GPU path 的覆盖率会很低。
所以 vehicle 必须做局部判断:
Code
一辆 vehicle├── 可实例化 part/group → GPU queue└── 不可实例化 part/group → fallback queue
这一步是 vehicle 接入的核心。

更具体地说,vehicle 的难点在于它同时有“资源结构”和“运行时状态”。资源结构告诉我们这个模型有哪些 group、哪些 part、哪些材质;运行时状态告诉我们当前这辆车处于什么 condition,门开没开,part 应该怎么变换,interior 是否需要绘制。GPU cache 必须在资源阶段尽量整理好结构,但运行时又必须根据当前状态选择实际要 queue 的实例。
1. DynamicVehicleModel:先整理模型结构
DynamicVehicleModel 不直接绘制车辆。它更像一个预处理层,负责把车辆模型按条件和 render stage 整理好。
它会把数据分到类似这些结构里:
Code
materialGroupsForPartConditionAndRenderStageobjModelsForPartConditionAndRenderStagefallbackObjModelsForPartConditionAndRenderStage
这样后面构建 GPU cache 时,不需要再从原始模型描述里重新理解“这个 part 在什么条件下出现”“它属于 interior 还是 exterior”。
简单说,它把复杂的 vehicle 业务信息先整理成更容易处理的中间结构。
这一步很像编译器里的中间表示。原始模型描述可能适合资源作者理解,但不一定适合 renderer 直接处理。DynamicVehicleModel 做的事情,就是把资源描述转换成更接近渲染需求的数据结构。
2. ModelPropertiesPart:判断一个 part 能不能 GPU
一个 part 能不能走 GPU path,不应该等到 render time 才临时判断,而应该在 cache 构建阶段尽量确定。
ModelPropertiesPart.writeGpuCache(...) 会检查:
Code
根据 names 找 OBJ group├── group 不存在 → fallback├── group translucent → fallback├── shader 不支持 → fallback└── 都满足 → VehicleGpuCache.Part
cache 的正式组成部分也是一个重点,目前 vehicle cache 里同时存在两类对象:
Code
VehicleGpuCache.Part 可 GPU 的 partVehicleGpuCache.FallbackPart 需要旧路径绘制的 part
运行时,可 GPU 的 part queue 到 GpuObjRenderer;不可 GPU 的 part schedule 到 optimized renderer。
虽然粒度变细以后,实现复杂了,但收益也保住了。
如果不这样做,一个资源包里只要有少量半透明 group 或特殊材质,就可能让整车退回旧路径。这样 GPU instancing 在 vehicle 上的覆盖率会很难看。
3. VehicleGpuCache:让 Vehicle 支持 Part 级 GPU 与 Fallback 混合路径
如果每个 part 都单独提交,CPU 侧还是会很碎。
所以 VehicleGpuCache 会再做一次聚合:相同 ObjBatchKey、相同 StaticObjMesh 的 part 可以放进同一个 PartGroup。
结构大概是:
Code
VehicleGpuCache.PartGroup-> ObjBatchKey-> StaticObjMesh-> 多个 placement / local transform
这样运行时可以按 group 写入 instance 数据。
这个结构和 GpuObjRenderer 的 batch / mesh / instance 队列是对齐的。
这一步本质上是在业务层提前减少碎片。Vehicle 里 part 很多,如果每个 part 到 renderer 那里才开始合并,renderer 的压力会更大。先在 VehicleGpuCache 里把能合的 part 聚起来,后面 GpuObjRenderer 再按 batch/mesh 做最终聚合,整个 pipeline 会更顺。
第八步:在 Vehicle Queue 中组合车体矩阵与 Part 局部变换
真正运行时,vehicle 要把两个矩阵组合起来。
第一个是整节车的矩阵,表示这节车当前在世界中的位置和姿态。第二个是 part 的局部矩阵,表示这个 part 在车体内部的位置和姿态。
最终写进 instance buffer 的矩阵是:
Code
drawMatrix = 当前车体的矩阵localTransform = part 在车体内的局部变换partDrawMatrix = drawMatrix * localTransform
这个 partDrawMatrix 就是 GPU shader 最终使用的 model matrix。
这里要注意矩阵乘法的语义。localTransform 描述的是 part 相对于车体的位置,而 drawMatrix 描述的是车体相对于世界或当前渲染坐标系的位置。只有把两者组合起来,才能得到 part 在最终画面中的位置。
如果顺序写错,结果会完全不一样。比如你可能会看到车门绕世界原点旋转,而不是绕车体局部坐标移动;或者 bogie 出现在车体外面很远的位置。这类 bug 通常不是 shader 错,而是矩阵语义错。
我刻意让 vehicle 业务层完成这个组合,而不是让 GpuObjRenderer 理解 vehicle part。GPU renderer 只消费最终矩阵,不关心它来自车门、bogie 还是车体外壳。
这样可以避免 GPU renderer 慢慢变成第二套 vehicle renderer。
运行时逻辑可以简化成:
Code
for (PartGroup group : activeConditionBucket.groups) {beginQueue(group.batchKey);for (PartPlacement placement : group.placements) {Matrix4f partDrawMatrix = drawMatrix * placement.localTransform;queuedMesh.queue(partDrawMatrix, light, color);}}for (FallbackPart fallback : activeConditionBucket.fallbacks) {optimizedRenderer.schedule(fallback.model, fallback.transform);}
同一辆车里,GPU 和 fallback 可以同时存在。这是 vehicle path 能落地的关键。
这个混合模式还有一个好处:它让 GPU path 可以渐进式扩展。今天只能支持 opaque/cutout,那就先把 opaque/cutout 接进来;明天如果 translucent 策略成熟,再把部分 translucent group 接进来。每一步都可以独立推进,而不是必须一次性解决所有问题。
第九步:让 MainRenderer 调度 GPU 与原有 Renderer 双通路
当 rail 和 vehicle 都能 queue GPU instance 后,MainRenderer 的角色也发生了变化。
它不再只是调用各类 renderer,然后让 optimized renderer 统一画掉。它现在更像一个双通路调度器:
Code
begin frame↓业务渲染:RenderVehicles / RenderRails / RenderLifts↓可 GPU 的对象进入 GpuObjRenderer queue不可 GPU 的对象进入 optimized renderer / fallback queue↓GpuObjRenderer.renderOpaque(offset)↓OptimizedRenderer.render(...)↓clear frame queues
这个顺序很重要。GPU opaque instancing 先集中处理,原 optimized renderer 后面继续处理自己的队列。中间必须有 GL state 保护,避免 GPU path 改过的状态影响旧 renderer。
这里还要区分两种“保护”:
Code
资源 reload 保护:用于资源和 shader 的生命周期GL state 保护:用于一次 GPU draw 前后的 OpenGL 状态恢复
这两者不能混用。reload 是资源生命周期问题,GL state 是绘制状态问题。把它们混在一起,会让 GPU draw 和 renderer reload 的边界变得很脆弱。
从更高层看,MainRenderer 的工作变成了“组织一帧里的各条渲染路径”。业务 renderer 负责发现要画什么,GPU renderer 负责画可实例化的 opaque OBJ,optimized renderer 负责原有路径和 fallback。这样每个模块都有自己的职责。
这种调度结构也方便调试。如果某个对象没走 GPU path,可以检查它是在资源阶段 fallback 了,还是运行时 condition 不满足;如果 GPU path 画错,可以单独看 batch、mesh、instance payload;如果 fallback 画错,则说明问题可能在旧路径或 fallback model 构建上。
第十步:用 Resource Reload 管理 GPU 对象生命周期
GPU cache 有一个容易忽略的问题:它看起来是普通 Java 对象,但背后绑定的是当前资源和当前 OpenGL 对象。
资源重载以后,旧的 StaticObjMesh、旧的 wrapper、旧的 public handle 都不能继续使用。否则可能出现这些问题:
- 资源已经换了,旧 handle 还在 queue;
- 旧 VAO/VBO 还被引用;
- 旧材质状态和新资源不一致;
- 某些对象看起来还存在,但实际已经不属于当前 renderer 生命周期。
所以 reload 时必须统一清理:
Code
ExternalInstancedModelRegistry.handleRendererReload()GpuObjModelRegistry.clear()GpuObjRenderer.INSTANCE.reload()
也就是说,resource reload 对 GPU path 来说,不只是“重新读 OBJ 文件”,而是整条 GPU 缓存链路都要失效。
这也是 public API 里需要 InstancedModelHandle.isValid() 和 getFallbackReason() 的原因。外部调用方不能假设一个 handle 永远有效,它需要知道当前 handle 是否还能 queue,如果不能,原因是什么。
资源重载在 mod 开发里非常常见。玩家切换资源包、开发者热重载资源、shader 或模型重新加载,都可能触发相关流程。如果 GPU 对象生命周期没有处理好,bug 可能不会立刻出现,而是在下一次绘制时才表现出来。
所以我把 reload 看成这条管线必须支持的一等场景,而不是边角情况。只有 reload 后旧对象明确失效,新对象重新构建,GPU path 才能长期稳定。
这到底是不是一个算法问题?
如果只从算法角度看,GPU instancing 可以写得很短:
Code
for each static mesh:collect instance transformsupload instance bufferdrawElementsInstanced(mesh, instanceCount)
正如一开始说的:
真正的难点并不在 GPU instancing 怎么写,而在“它怎么和 MTR 原来的管线共存。
在我把这套 Pipeline 实现出来之后简单宣传了一下,有部分同学说“MTR4的渲染优化也来了”,其实并不是很准确。
在 MTR 这次实现里,更重要的是一个“分流 + 聚合”的过程。
资源阶段做:
Code
OBJ group-> 判断 opaque / translucent-> RawMesh 上传成 StaticObjMesh-> 建立 group -> mesh 索引-> 构建 rail / vehicle GPU cache-> 给不能 GPU 的部分构建 fallback cache
运行阶段做:
Code
业务对象-> 判断当前 condition / stage-> 计算 instance matrix / light / color-> 按 ObjBatchKey 分流-> 按 StaticObjMesh 聚合-> batch 内连续打包 instance payload-> 一次上传,多次 instanced draw
这些步骤每一步都在避免后面的混乱。
如果没有 group 索引,vehicle part 找不到自己的 mesh。
如果没有 fallback cache,render time 会临时构建模型,资源生命周期会变乱。
如果 batch key 太松,材质状态会串。
如果 instance payload layout 不固定,CPU 和 shader 会读写错位。
如果 GL state 不保护,旧 renderer 会被污染。
如果 reload 不清 cache,旧 GPU 对象会继续被使用。
这些问题都不是 drawElementsInstanced 能解决的。
所以我更愿意把这次实现看成一个“数据管线设计”问题,而不是单纯的“图形 API 调用”问题。图形 API 调用只是最后一步,前面那些数据如何组织、何时构建、何时失效、如何 fallback,才决定这条路径能不能在真实项目中使用。
实现过程中几个比较典型的坑
附上一些 debug 过程中的截图。


1. ModelMat 不是简单加一个 mat4
给 shader 加一个 in mat4 ModelMat 看起来很简单,但它背后是四个 attribute location,而且要和 CPU 侧 instance buffer 的 stride、offset 完全一致。
一旦 padding 或顺序错了,画面可能会出现模型错位、颜色异常或 lightmap 异常。
最后只能把 instance payload 的约定固定下来:Mapping 层负责描述 attribute,MTR 侧严格按这个描述写 buffer。
这个坑说明了一件事:CPU 和 GPU 之间的数据布局必须被当成协议来维护。只要协议两边理解不一致,结果就不可控。
2. color 和 light 的顺序也是协议
color、light、matrix 不是普通字段,而是 CPU 和 shader 之间的二进制协议。
Java 侧按 RGBA 写,shader 侧就必须按 RGBA 读。lightmap 的 bit 顺序也必须和 shader attribute 对齐。编译器不会帮你检查这种错误,只能靠明确的 layout 和测试。
很多图形 bug 难查,就是因为类型系统帮不上忙。Java 里看起来都是往 buffer 写数字,shader 里看起来也只是读 attribute,但它们之间没有高级语言那种结构体类型检查。只要 offset 错了,读出来的数据仍然是“合法数字”,只是语义完全错了。
3. batch key 不能过度贪心
合批越多,draw call 越少,性能越好。但只要把不该合的 mesh 合到一起,就会出现视觉错误。
所以第一版 batch key 宁愿保守一点,也不能错合。等 diagnostics 更完整以后,再考虑更激进的合批策略。
这也是性能优化里常见的 trade-off:不要为了指标好看而破坏正确性。尤其是渲染系统,错误有时不会立刻暴露,而是在某个资源包、某个视角、某种光照条件下才出现。
4. vehicle fallback 不能运行时临时构建
如果 fallback model 到 render time 才构建,它会和 GPU draw、optimized renderer、resource reload 混在一起,生命周期很难控制。
所以 fallback 应该在 cache/build 阶段准备好,render time 只消费已经存在的 fallback model。
这相当于把“不确定性”提前处理掉。资源阶段可以慢一点,但它只在加载或重载时发生;render time 每帧都会发生,越简单越好。
5. vehicle 不能因为一个 group 失败就整车 fallback
Rail 整体 fallback 可以接受,因为 rail 结构简单。但 vehicle 如果因为一个半透明窗户或特殊 group 就整车 fallback,GPU path 的收益会大幅下降。
所以 vehicle 必须支持 group / part 级 fallback。这会让实现更复杂,但也让这条路径真正有实际价值。
这个坑本质上是 fallback 粒度的问题。粒度太粗,系统简单但收益差;粒度太细,系统复杂但覆盖率高。Vehicle 这里我选择了更细的粒度,因为车辆模型本来就复杂,整车 fallback 的损失太大。
6. GL state 保护不能靠记忆
GPU path 和 optimized renderer 共存时,任何 OpenGL 状态污染都可能变成随机 bug。这里不能靠“我记得恢复了几个状态”,而必须有明确的 protected scope。
这类 bug 的特点是复现困难。你可能在开发环境里看不到,但玩家的资源包、shader、显卡驱动或绘制顺序稍微不同,就会出现问题。所以 GL state 保护要尽量系统化,而不是靠人工自觉。
7. diagnostics 不是可有可无
GPU instancing 出问题时,单看画面往往不够。你需要知道:这个对象有没有进入 GPU path?如果没有,fallback reason 是什么?如果进入了,它属于哪个 batch?使用哪个 StaticObjMesh?instance payload 里的 matrix、light、color 是否正常?
所以 diagnostics 对这类系统很重要。它是开发阶段让系统可维护的基础。没有 diagnostics,后面一旦资源包复杂起来,调试成本会非常高。
再看 NTE 和 IR:MTR 的难点在哪里
现在回头看,MTR 这次实现和 NTE、IR 有相似点,也有明显差异。
NTE 的 ModelManager + ModelCluster + DrawScheduler 更像一套完整的模型提交框架。模型加载、上传、opaque/translucent 拆分、draw queue 都在自己的体系里闭环。
IR 的 OBJModel + OBJRender + ModelState 更像 rolling stock 专用的模型语义系统。车辆组件、动画、状态、贴图 LOD 都被包在自己的模型系统里。
MTR 这次 GPU instancing 的定位不同:
Code
NTE:自带批处理模型框架IR:rolling stock 专用 OBJ 渲染体系MTR GPU instancing:在既有 MTR 管线旁边插入可回退的实例化支路
如果 MTR 是从零开始写,可能可以设计成更统一的模型框架。但现实是,MTR 已经有自己的渲染系统、资源系统、Mapping 层和资源包兼容性。
所以这次实现必须更克制:
Code
能 GPU 的接进 GPU path不能 GPU 的明确 fallback底层跨版本能力放在 Mapping业务判断留在 MTR 主仓库
这就是为什么我认为这次最难的不是 GPU instancing,而是集成。
从架构角度看,NTE 更像“我自己管理整条模型渲染链路”,IR 更像“我有一套围绕 rolling stock 设计的模型语义系统”,而 MTR 这次更像“我在已有铁路渲染系统里插入一条新管线”。
这三种方式没有绝对好坏,取决于项目历史和目标。如果项目从一开始就有统一模型框架,那么很多优化可以自然放进去;如果项目已经有大量旧路径,就必须考虑兼容、fallback 和渐进迁移。
最终管线:准备、排队与提交
把所有部分串起来,最后结构大概是这样:
再换一种更直观的方式,可以把这条管线分成三个阶段:
Code
准备阶段:解析资源,上传静态 mesh,构建 cache排队阶段:根据当前帧状态,把可 GPU 的对象写入 instance queue提交阶段:按 batch 上传 instance buffer,执行 instanced draw
准备阶段尽量多做结构化工作,排队阶段尽量轻量,提交阶段尽量减少上传次数和 draw call。这个分层是整个实现能跑得比较清楚的原因。
本文中所提到的对 MTR 本体以及 Minecraft-Mapping做的改动已于 2026/5/21 晚提交 Pull Request。


