第一章我们搞清楚了 OpenGL 的底层原理——GPU 管线 矩阵变换 纹理采样这些对吧。但是 Minecraft 是怎么把那些 OpenGL 概念组装成一个能跑的渲染系统的呢?本文就带大家了解一下 Blaze3D。
我把朋友的笔记结合最新的 MC 客户端源码重新整理了一遍,补充了必要的前置知识,尽量让没上过图形学课的同学也能跟上。
本文基于 Minecraft 最新客户端源码(Blaze3D 渲染引擎)。穿插对比 Arc3D 的设计帮助理解。
1.1 从立即模式到现代渲染
什么是立即模式
前置知识:GPU(显卡)是专门用来画图的硬件,CPU 把"画什么"告诉 GPU,GPU 负责算像素颜色输出到屏幕。CPU 和 GPU 之间通过驱动程序通信,每次通信都有固定开销——就像发快递,不管包裹大小,揽件本身就要花时间。
OpenGL 1.x/2.x 时代,向 GPU 传递几何数据的方式极其直白——每调用一次 glVertex,就往驱动塞一个顶点。这种方式叫立即模式(Immediate Mode)。代码写起来像在画板上逐点描线,直觉且容易上手,但每个顶点的传输都是一次 CPU 到驱动的函数调用。
// 立即模式绘制一个彩色三角形
glBegin(GL_TRIANGLES); // 告诉 GPU:"接下来我要画三角形"
glColor3f(1.0f, 0.0f, 0.0f); // 设置当前颜色为红色(RGB 各 0~1)
glVertex3f(-0.5f, -0.5f, 0.0f); // 发送第一个顶点坐标(左下角)
glColor3f(0.0f, 1.0f, 0.0f); // 切换颜色为绿色
glVertex3f( 0.5f, -0.5f, 0.0f); // 发送第二个顶点(右下角)
glColor3f(0.0f, 0.0f, 1.0f); // 切换颜色为蓝色
glVertex3f( 0.0f, 0.5f, 0.0f); // 发送第三个顶点(顶部)
glEnd(); // 告诉 GPU:"三角形数据发完了,可以画了"
每一帧都要把这些调用重新执行一遍。三角形少的时候无所谓,三角形数量上万之后,CPU 就成了瓶颈——不是 GPU 画不动,而是 CPU 来不及喂数据。
MC 早期的 Tessellator
Minecraft 1.12 及更早版本使用 Tessellator 来封装绘制流程。Tessellator 做的事情是把顶点先攒到一个 Java 侧的 ByteBuffer 里,攒完一批再一次性提交。看上去像是缓冲模式,但底层仍然是每帧重新填充 重新上传 重新绘制。
// MC 1.12 Tessellator 的典型用法
Tessellator tessellator = Tessellator.getInstance(); // 获取全局唯一的绘制器实例
BufferBuilder buffer = tessellator.getBuffer(); // 取出内部的数据构建器
// begin() 告诉构建器:我要画四边形(GL_QUADS),每个顶点带位置+纹理坐标
buffer.begin(GL11.GL_QUADS, DefaultVertexFormats.POSITION_TEX);
// 添加四个顶点构成一个矩形面
// pos(x,y,z) 设置 3D 坐标,tex(u,v) 设置纹理采样坐标(0~1 范围)
buffer.pos(x, y, z).tex(u0, v0).endVertex(); // 左下角
buffer.pos(x, y + h, z).tex(u0, v1).endVertex(); // 左上角
buffer.pos(x + w, y + h, z).tex(u1, v1).endVertex(); // 右上角
buffer.pos(x + w, y, z).tex(u1, v0).endVertex(); // 右下角
tessellator.draw(); // 把攒好的数据一次性上传 GPU 并绘制——每帧都要重新来一遍
这里的关键问题不是 Tessellator 本身设计得差,而是它没办法让数据常驻显存。每一帧都要经历"Java 填充 → 上传 GPU → 绘制 → 丢弃"的循环,对于一个有几十万个方块面的世界来说,这个循环每帧都在重复做无用功。
固定管线 vs 可编程管线
前置知识:渲染管线(Pipeline)是 GPU 内部处理图形数据的流水线——顶点进去,像素出来。中间经过坐标变换 光照计算 颜色混合等步骤。可以类比工厂流水线:原料进入,成品出来,中间每个工位做一道工序。
OpenGL 的渲染流水线经历了两个时代。固定管线时代,顶点变换 光照计算 雾效混合这些步骤都是写死的——你能调参数(比如灯光颜色 雾的浓度),但不能改算法本身。可编程管线把这些步骤拆开,交给开发者用着色器(Shader)自己写——着色器就是一段跑在 GPU 上的小程序。
两者的核心区别不是功能多少,而是控制权归属。固定管线下你只能选"开灯"或"关灯",可编程管线下你能自己写光照公式。对 Minecraft 来说,这意味着可以实现自定义的方块光照衰减 动态天空颜色 水面折射等效果——这些在固定管线上不可能做到。
GlStateManager 的诞生
前置知识:OpenGL 是一个状态机(State Machine)。状态机的意思是:你设了一个开关,它就一直保持那个状态直到你再次修改。好比你把灯打开了,它不会自己关——你必须手动关掉。渲染时的"混合模式 深度测试 纹理绑定"这些都是状态,设了就生效直到被改。
裸 OpenGL 调用有一个实际问题:状态机的开销。调用 glEnable(GL_BLEND) 之后混合一直开着,直到你手动关掉。多个模块各自调 OpenGL,很容易互相踩状态——A 模块开了混合忘了关,B 模块的不透明渲染就全透明了。
Mojang 在 MC 内部引入了 GlStateManager,动机有三:
状态缓存:如果当前已经是
GL_BLEND开启状态,再调一次 enable 就跳过,减少冗余的驱动调用。统一接口:所有渲染代码通过同一层包装访问 OpenGL,避免散落在各处的裸调用互相干扰。
调试支持:在这一层可以插入状态检查 日志输出 错误捕获,排查渲染 bug 时不用逐行翻驱动日志。
GlStateManager 不是性能优化——它是工程管理手段。让一个有几十万行渲染代码的项目保持可维护。
立即模式的性能问题
立即模式的瓶颈集中在三个层面:
CPU-GPU 数据传输。每个顶点都要从 CPU 侧穿越驱动到达 GPU。一个 chunk 有上万个面,每个面 4 个顶点,每个顶点一次调用。16×16 的可视范围意味着每帧几十万次 glVertex。
驱动调用开销。每次 glVertex glColor glTexCoord 都是一次函数调用,要经过参数校验 状态同步 命令缓冲区写入。3 万次调用的驱动开销本身就吃掉几毫秒。
无法利用现代 GPU 特性。立即模式下数据不常驻显存,GPU 的并行计算单元大部分时间在等数据。没有 VBO 就不能做实例化绘制,没有着色器就不能做 GPU 端的顶点变换和剔除。

从图上可以看到,VBO 模式下帧时间几乎不随 draw call 数量增长,而立即模式是线性恶化。这不是几倍的差距,是数量级的差距。
为什么必须迁移
MC 1.17 正式将最低 OpenGL 版本要求从 2.1 提升到 3.2。这不是一个渐进式升级,而是一刀切——不支持 OpenGL 3.2 的显卡直接无法启动游戏。
迁移的动因和收益总结如下:
这次迁移的本质不是"用新 API 重写旧代码",而是改变了数据和 GPU 之间的关系。旧模式下 CPU 是搬运工,每帧把数据送过去再拿回来。新模式下数据上传一次就留在显存里,CPU 只负责发指令——画哪些、怎么画、用什么着色器。
从工程角度看,这也是 Mojang 能够引入 Blaze3D 渲染引擎的前提条件。没有可编程管线和常驻显存数据,后续的 section buffer chunk 缓存 多线程渲染优化全都无从谈起。
1.2 Blaze3D 架构全景
什么是 Blaze3D
Minecraft 1.17 开始要求 OpenGL 3.2 Core Profile,旧的渲染代码不能再用了。Mojang 对整个渲染层做了一次现代化重构,社区把这套新系统叫做 Blaze3D——名字来自游戏里的烈焰人(Blaze)。
不是新写的引擎,而是在既有代码上做系统性升级。核心变化是把立即模式 固定管线 手动状态管理这三件事分别替换成了缓冲对象 可编程着色器 类型化渲染状态。
主要改进对比
老版本渲染和 Blaze3D 的对应关系:
这不是渐进式修补。1.17 以后如果你还用 GL11.glBegin 写 Mod 渲染代码,直接就是黑屏——Core Profile 把那些老 API 全删了。
核心组件与分层架构
Blaze3D 的设计是一个从上到下的五层结构,每层只依赖下一层的接口,不跨层调用。
第一层:应用层
WorldRenderer EntityRenderer BlockRenderer GUI 这些游戏逻辑代码住在最顶上。它们不直接碰 OpenGL,只通过下面几层的 API 来提交渲染请求。
第二层:RenderType 系统
定义渲染状态的组合——一个 RenderType 把着色器 纹理 混合模式 深度测试 背面剔除这些状态打包在一起。应用层只需要说"我要用 translucent 类型画",不需要自己一个个设状态。
第三层:顶点构建系统
BufferBuilder 负责在 CPU 侧按 VertexFormat 的规格填充顶点数据。Tesselator 是它的单例封装,简化调用。填好的数据通过 BufferUploader 提交给 GPU。
第四层:渲染状态管理
RenderSystem 是整个渲染系统的调度核心,负责线程安全的状态切换。ShaderInstance 管理着色器程序的加载和 uniform 绑定。RenderTarget 封装帧缓冲对象,支持离屏渲染和后处理。
第五层:底层 OpenGL 封装
GlStateManager 直接包装 LWJGL 3 提供的 OpenGL 调用,做状态缓存和冗余调用过滤。这一层对上层透明——你不需要知道底下到底是 OpenGL 还是别的什么。
核心类一览
注意包路径的规律:com.mojang.blaze3d 是渲染引擎层,net.minecraft.client.renderer 是游戏逻辑层。两者边界清晰。
与 Arc3D 的对比
Arc3D 是一个独立的现代图形引擎项目,设计目标和 Blaze3D 不同。把它们放在一起看能帮助理解 Blaze3D 的设计取舍。
Blaze3D 选择 OpenGL 3.2 而不是 4.5 或 Vulkan,不是因为 Mojang 不懂更现代的方案。而是 Minecraft 的玩家群体覆盖了大量低端硬件——集成显卡 老笔记本 教育机构的公用电脑。把最低要求定高了,玩家就丢了。
Arc3D 没有这个包袱,所以可以激进地用 DSA(Direct State Access)减少绑定开销,用 Vulkan 后端榨取多线程性能,用自研着色器编译器做跨平台 SPIR-V 输出。
Blaze3D 的局限与取舍
Blaze3D 不是最现代的渲染架构,这是有意为之的设计决策。
兼容性优先。OpenGL 3.2 是 2009 年的规范,几乎所有还在服务的 GPU 都支持。如果用 4.5 或 Vulkan,大约 15% 的现有玩家会被排除在外。
稳定性优先。单线程渲染模型虽然性能天花板低,但状态管理简单,bug 容易定位。多线程渲染引入的同步问题在一个月更新一次的游戏里得不偿失。
Mod 生态优先。Minecraft 的 Mod 生态是核心竞争力。渲染 API 改动太激进,现有 Mod 全部失效,社区就散了。Blaze3D 保留了 GlStateManager 作为兼容层,让老 Mod 有渐进迁移的余地。
这三点加在一起就解释了为什么 Blaze3D 看起来"不够现代"——它的目标从来不是技术前沿,而是在约束条件下做到够用且稳。
1.3 一帧的生命周期
前面我们知道了 Blaze3D 的组件分层和各类的职责。但一帧渲染到底是怎么走完的?在拆解 RenderSystem BufferBuilder RenderType 这些组件之前,先把整条流水线跑一遍,建立全景认知。
入口:GameRenderer.render()
GameRenderer.render() 是每一帧渲染的起点。主循环每 tick 调用一次,它负责把整个画面从零推到屏幕上。按时间顺序,它做了这些事:
检查窗口 resize,必要时重新分配帧缓冲
清除主帧缓冲(颜色缓冲 + 深度缓冲)
更新全局 uniform(分辨率 时间戳 相机位置 游戏时间等)
渲染光照贴图(lightmap),供后续方块和实体采样
调用
renderLevel()渲染 3D 世界执行实体描边
doEntityOutline(如果光标指向实体)执行后处理链
PostChain(着色器后效,如 Creeper 视角扭曲)清除深度缓冲(为 GUI 准备干净的深度空间)
渲染 GUI(
guiRenderer.render,包括血条 物品栏 调试信息)帧结束清理(释放临时资源 提交 GL 命令)
注意第 8 步的深度清除——3D 世界的深度值已经用完了,GUI 需要独立的深度层级,否则文字会被方块遮挡。
renderLevel():3D 世界的入口
renderLevel() 是 3D 场景渲染的入口,它在主帧缓冲上绘制世界内容。这一层做的事不多,主要是准备投影矩阵和屏幕效果:
计算投影矩阵——将 FOV 视角晃动 传送门扭曲 反胃效果叠加进透视投影
投影矩阵是一个 4×4 的数学矩阵,作用是把 3D 世界坐标"压扁"成 2D 屏幕坐标。透视投影矩阵会制造"近大远小"的效果——远处的方块在屏幕上更小。FOV(Field of View)决定了视角的广度,FOV 越大看到的范围越广但物体变形越明显。
设置投影矩阵和雾效——写入 RenderSystem 全局状态
调用
LevelRenderer.renderLevel()——真正的世界绘制在这里渲染手持物品——单独的投影矩阵(近裁面 0.05 远裁面 100),保证手不会穿进方块
渲染屏幕效果——传送门叠加 水下滤镜 着火效果
手持物品用独立投影矩阵是一个经典技巧。如果用世界相机的投影,近裁面太远会裁掉手指,远裁面太远会让手在深度缓冲里和远处方块产生 z-fighting。所以单独开一组参数,保证手始终在画面最前面且不穿帮。
LevelRenderer.renderLevel():世界渲染的顺序
这是最关键的一层。世界里所有可见物体在这里按严格顺序绘制。顺序不是随便排的,每一步都有深度测试和混合的正确性依赖:
天空盒——最远的背景,先画不影响任何东西
地形 solid 层——不透明方块(石头 泥土 木板),构成大部分画面
地形 cutout 层——镂空方块(玻璃板 树叶 花 铁栏杆),alpha 测试丢弃透明像素
实体——玩家 怪物 掉落物 矿车
方块实体 BlockEntity——箱子 告示牌 信标 附魔台,有独立渲染逻辑
粒子——破坏粒子 火焰 药水效果
天气效果——雨 雪,半透明的全屏覆盖
地形 translucent 层——半透明方块(水 冰 染色玻璃),必须最后渲染

渲染顺序的原因
为什么要这个顺序?核心是深度测试和颜色混合的正确性。
前置知识:
深度缓冲(Z-buffer):屏幕上每个像素除了颜色外,还存着一个"深度值"——代表这个像素对应的物体离相机多远。画新像素时,GPU 比较新旧深度值,只保留更近的那个。这就是为什么远处的方块会被近处的方块遮挡。
颜色混合(Alpha Blending):半透明物体不能简单替换底下的颜色,要按透明度"混"在一起。公式是
最终颜色 = 新颜色 × alpha + 旧颜色 × (1-alpha)。alpha=0 完全透明,alpha=1 完全不透明。正交投影 vs 透视投影:透视投影有近大远小的效果(3D 世界用),正交投影没有变形(2D 界面用)。
不透明物体先画,顺序无所谓。 因为有深度缓冲(z-buffer),无论哪个像素先写入,最终深度测试只保留最近的那个。所以 solid 和 cutout 内部的绘制顺序可以是任意的,GPU 会用深度值自动裁掉被遮挡的部分。
半透明物体必须从远到近画。 颜色混合(alpha blending)是顺序相关的操作:最终颜色 = src * alpha + dst * (1 - alpha)。如果先画近处的水面,远处的方块颜色还没写入 dst,混合结果就是错的。所以 translucent 层不仅排在最后,内部的 chunk 还要按离相机的距离从远到近排序。
GUI 最后画,正交投影覆盖。 GUI 使用正交投影(没有透视变形),覆盖在整个 3D 场景之上。清除深度缓冲后再画,保证血条和文字不会被任何 3D 物体遮挡。
这三条规则是所有实时 3D 引擎的共识,不是 Minecraft 独有的设计。
简化伪代码
下面是 GameRenderer.render() 的简化版,用来建立整体印象。实际代码更复杂(有大量条件分支和状态恢复),但骨架是这样的:
// GameRenderer.render() 简化版
public void render(float partialTick) {
// 检查窗口尺寸变化
if (window.isResized()) {
mainTarget.resize(window.getWidth(), window.getHeight());
}
// 清除主帧缓冲
mainTarget.clear();
mainTarget.bindWrite(true);
// 更新全局 uniform
RenderSystem.setShaderGameTime(gameTime);
RenderSystem.setShaderScreenSize(width, height);
// 渲染光照贴图(供方块和实体采样)
lightTexture.turnOnLightLayer();
lightTexture.updateLightmap(partialTick);
// 渲染 3D 世界
renderLevel(partialTick);
// 实体描边(光标指向时的高亮轮廓)
doEntityOutline();
// 后处理链(着色器效果)
if (postEffect != null) {
postEffect.process(partialTick);
}
// 清除深度缓冲(GUI 需要独立深度空间)
mainTarget.bindWrite(true);
RenderSystem.clear(GL_DEPTH_BUFFER_BIT);
// 渲染 GUI(血条 物品栏 聊天 调试信息)
guiRenderer.render(partialTick);
// 帧结束
mainTarget.unbindWrite();
window.swapBuffers();
}
// LevelRenderer.renderLevel() 渲染顺序
public void renderLevel(PoseStack poseStack, float partialTick, Camera camera) {
// 天空盒(最远背景)
renderSky(poseStack);
// 地形 solid(不透明方块 深度测试开启 不需要排序)
renderChunkLayer(RenderType.solid(), poseStack, camera);
// 地形 cutout(镂空方块 alpha 测试丢弃透明像素)
renderChunkLayer(RenderType.cutout(), poseStack, camera);
// 实体(玩家 怪物 掉落物)
renderEntities(poseStack, camera, partialTick);
// 方块实体(箱子 告示牌 信标)
renderBlockEntities(poseStack, camera, partialTick);
// 粒子
particleEngine.render(poseStack, camera, partialTick);
// 天气效果(雨 雪)
renderWeather(poseStack, camera);
// 地形 translucent(半透明方块 从远到近排序 最后绘制)
renderChunkLayer(RenderType.translucent(), poseStack, camera);
}
小结
一帧从 GameRenderer.render() 进入,经过清除 光照 3D世界 后处理 GUI 五个大阶段。3D 世界内部又按不透明→镂空→实体→半透明的严格顺序推进。这个顺序不是工程偏好,是正确性的硬约束。
后面几节我们逐一拆解这条流水线中涉及的组件——RenderSystem 管状态 BufferBuilder 管数据 RenderType 管配置 RenderTarget 管离屏——每个组件在这条时间线上都有自己的位置。知道了全景,再看局部才不会迷路。
1.4 RenderSystem:状态管理核心
RenderSystem 是 Blaze3D 中被调用频率最高的类——几乎所有渲染代码都要和它打交道。它封装了 OpenGL 状态机的所有操作,并加了线程安全保障。
RenderSystem 概述
com.mojang.blaze3d.systems.RenderSystem 是一个纯静态工具类,所有方法都是 static。它不是一个对象,没有实例——直接通过类名调用。
// RenderSystem 的典型使用方式
RenderSystem.enableBlend();
RenderSystem.setShader(GameRenderer::getPositionTexShader);
RenderSystem.setShaderTexture(0, myTexture);
它的定位不是 OpenGL 的简单包装(那是 GlStateManager 的事),而是渲染调度中心——负责决定什么时候 什么线程上 以什么方式修改 GL 状态。
线程安全模型
前置知识:MC 有多个线程同时运行——主线程处理游戏逻辑(方块更新 实体AI 网络),渲染线程负责调 OpenGL 画画面。OpenGL 的设计要求所有 GL 调用必须从同一个线程发出(创建 GL 上下文的那个线程)。如果从别的线程直接调 GL 函数,结果未定义——轻则黑屏重则崩溃。
MC 的渲染必须在渲染线程上执行。RenderSystem 提供了三个机制来保证这一点:
// 1. 检查:当前是否在渲染线程
if (RenderSystem.isOnRenderThread()) {
RenderSystem.enableBlend(); // 安全,直接执行
}
// 2. 断言:不在渲染线程就崩溃(调试用)
RenderSystem.assertOnRenderThread();
// 3. 延迟:不在渲染线程时,把操作排队到渲染线程执行
RenderSystem.recordRenderCall(() -> {
RenderSystem.enableBlend();
RenderSystem.setShaderColor(1.0f, 0.0f, 0.0f, 1.0f);
});
实际开发中,Mod 代码如果在事件回调里触发渲染操作,就需要用 recordRenderCall 延迟执行。直接在网络线程或逻辑线程上调 RenderSystem 会触发断言崩溃。
Arc3D 有类似机制但更灵活——它的 executeRenderCall 会自动判断当前线程并选择直接执行还是排队,不需要调用方手动区分。

着色器相关 API
// 设置当前着色器(Supplier 延迟获取,避免初始化顺序问题)
RenderSystem.setShader(GameRenderer::getPositionTexShader);
RenderSystem.setShader(GameRenderer::getParticleShader);
RenderSystem.setShader(() -> myCustomShader); // 自定义着色器
// 设置纹理到采样器单元
RenderSystem.setShaderTexture(0, new ResourceLocation("minecraft", "textures/block/dirt.png"));
RenderSystem.setShaderTexture(0, diffuseTexture); // 单元 0:主纹理
RenderSystem.setShaderTexture(1, overlayTexture); // 单元 1:覆盖层
RenderSystem.setShaderTexture(2, lightmapTexture); // 单元 2:光照贴图
// 颜色调制器(乘以顶点颜色,影响最终输出)
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); // 白色,无调制
RenderSystem.setShaderColor(1.0f, 0.0f, 0.0f, 1.0f); // 红色叠加
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 0.5f); // 半透明
// 雾效参数
RenderSystem.setShaderFogStart(10.0f); // 雾起始距离
RenderSystem.setShaderFogEnd(100.0f); // 雾结束距离
RenderSystem.setShaderFogColor(0.5f, 0.5f, 0.5f, 1.0f); // 灰雾
RenderSystem.setShaderFogShape(FogShape.SPHERE); // 球形雾(MC 默认)
setShader 用 Supplier 而不是直接传实例,是因为着色器可能在资源重载时被替换。延迟获取确保每次绘制时拿到的是最新实例。
混合模式 API
前置知识:颜色混合(Blending)解决的问题是——当一个半透明像素要画到已有内容上面时,最终颜色怎么算?答案是用一个公式把"新颜色(source)"和"已有颜色(destination)"按比例混在一起。不同的混合公式产生不同的视觉效果:标准透明 发光 阴影等。
// 开关
RenderSystem.enableBlend(); // 启用颜色混合(画半透明物体前必须开)
RenderSystem.disableBlend(); // 禁用(画不透明物体时关掉,提升性能)
// 标准透明混合公式:最终颜色 = 新颜色 × alpha + 旧颜色 × (1 - alpha)
// 这是最直觉的透明效果:alpha=0.5 时新旧各占一半
RenderSystem.defaultBlendFunc();
// 上面这一行等价于下面这个更明确的写法:
RenderSystem.blendFuncSeparate(
GlStateManager.SourceFactor.SRC_ALPHA, // srcRGB: 新颜色的 RGB 乘以自身 alpha
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA, // dstRGB: 旧颜色的 RGB 乘以 (1-alpha)
GlStateManager.SourceFactor.ONE, // srcAlpha: 新 alpha 直接取
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA // dstAlpha: 旧 alpha 也做混合
);
// 加法混合公式:最终颜色 = 新颜色 × alpha + 旧颜色 × 1
// 旧颜色不衰减,新颜色叠上去只会让画面更亮——适合发光效果
RenderSystem.blendFunc(
GlStateManager.SourceFactor.SRC_ALPHA, // 新颜色按 alpha 缩放
GlStateManager.DestFactor.ONE // 旧颜色保持不变(1=不衰减)
);
// 预乘 Alpha 公式:最终颜色 = 新颜色 × 1 + 旧颜色 × (1-alpha)
// 前提是纹理在制作时已经把 RGB 乘过 alpha 了,避免边缘出现黑线
RenderSystem.blendFunc(
GlStateManager.SourceFactor.ONE, // 新颜色直接取(已经预乘过了)
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA // 旧颜色按 (1-alpha) 衰减
);
// 乘法混合公式:最终颜色 = 旧颜色 × 新颜色
// 效果:新颜色越暗的地方,旧颜色也变暗——适合阴影贴花
RenderSystem.blendFunc(
GlStateManager.SourceFactor.DST_COLOR, // 新颜色的贡献 = 旧颜色值(相当于做乘法)
GlStateManager.DestFactor.ZERO // 旧颜色贡献 = 0(完全被替换为乘法结果)
);

这四种配方覆盖了 MC 里 90% 的混合需求。标准透明用于水面 冰块 UI 浮层;加法混合用于经验球 闪电 魔法粒子这类发光效果;预乘 Alpha 用于字体和 UI 图标(避免边缘黑线);乘法混合用于阴影贴花(让地面颜色变暗但不遮挡纹理细节)。
深度测试 API
前置知识:深度测试(Depth Test)是 GPU 判断"谁在前面"的机制。屏幕上每个像素都有一个深度值(0~1),代表该位置最近物体的距离。画新像素时,GPU 比较新深度和已有深度:如果新的更近就画上去并更新深度值,否则丢弃。这就是为什么近处的方块会自动遮挡远处的方块,而不需要我们手动排序。
// 开关
RenderSystem.enableDepthTest(); // 开启深度测试
RenderSystem.disableDepthTest(); // 关闭(GUI 不需要)
// 深度比较函数
RenderSystem.depthFunc(GL11.GL_LEQUAL); // 小于等于通过(MC 默认)
RenderSystem.depthFunc(GL11.GL_LESS); // 严格小于
RenderSystem.depthFunc(GL11.GL_ALWAYS); // 总是通过(忽略深度)
// 深度写入控制
RenderSystem.depthMask(true); // 允许写入深度缓冲
RenderSystem.depthMask(false); // 禁止写入(半透明物体必须关)
三阶段典型配置:
// 阶段一:不透明物体(写入深度 + 测试深度)
RenderSystem.enableDepthTest();
RenderSystem.depthMask(true);
renderOpaqueObjects();
// 阶段二:半透明物体(测试深度但不写入)
RenderSystem.enableDepthTest(); // 仍然测试——被遮挡的部分不画
RenderSystem.depthMask(false); // 不写入——否则后面的半透明会被挡
renderTranslucentObjects();
// 阶段三:GUI(不需要深度)
RenderSystem.disableDepthTest();
renderGUI();
面剔除与多边形偏移
// 面剔除
RenderSystem.enableCull(); // 剔除背面(普通方块 实体)
RenderSystem.disableCull(); // 双面渲染(树叶 草 粒子)
// 多边形偏移(解决 Z-fighting:两个面距离太近闪烁)
RenderSystem.enablePolygonOffset();
RenderSystem.polygonOffset(-1.0f, -1.0f); // 向相机方向偏移(贴花在方块表面)
// 渲染完后关闭
RenderSystem.disablePolygonOffset();
颜色遮罩 视口 裁剪
// 颜色写入遮罩(控制哪些通道写入帧缓冲)
RenderSystem.colorMask(true, true, true, true); // RGBA 全写
RenderSystem.colorMask(true, true, true, false); // 只写 RGB 不写 Alpha
RenderSystem.colorMask(false, false, false, false); // 什么都不写(只写深度/模板)
// 视口
RenderSystem.viewport(0, 0, width, height);
// 裁剪测试(只渲染指定矩形内的像素)
RenderSystem.enableScissor(x, y, width, height);
RenderSystem.disableScissor();
// 清除
RenderSystem.clearColor(0.0f, 0.0f, 0.0f, 1.0f); // 设置清除色
RenderSystem.clear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT, Minecraft.ON_OSX);
完整渲染示例
一个典型的自定义特效渲染函数,展示完整的状态设置→绘制→恢复流程:
public void renderCustomEffect(PoseStack poseStack, ResourceLocation texture, float alpha) {
// === 设置渲染状态 ===
RenderSystem.enableBlend(); // 开启混合
RenderSystem.blendFunc( // 加法混合(发光)
GlStateManager.SourceFactor.SRC_ALPHA,
GlStateManager.DestFactor.ONE
);
RenderSystem.enableDepthTest(); // 深度测试开
RenderSystem.depthMask(false); // 不写入深度
RenderSystem.disableCull(); // 双面渲染
// === 设置着色器和纹理 ===
RenderSystem.setShader(GameRenderer::getPositionTexColorShader);
RenderSystem.setShaderTexture(0, texture); // 绑定纹理
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, alpha); // 透明度
// === 构建顶点数据 ===
Matrix4f matrix = poseStack.last().pose(); // 获取变换矩阵
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX_COLOR);
buffer.vertex(matrix, -1, -1, 0).uv(0, 1).color(255, 255, 255, 255).endVertex();
buffer.vertex(matrix, 1, -1, 0).uv(1, 1).color(255, 255, 255, 255).endVertex();
buffer.vertex(matrix, 1, 1, 0).uv(1, 0).color(255, 255, 255, 255).endVertex();
buffer.vertex(matrix, -1, 1, 0).uv(0, 0).color(255, 255, 255, 255).endVertex();
// === 上传并绘制 ===
BufferUploader.drawWithShader(tesselator.end());
// === 恢复状态 ===
RenderSystem.depthMask(true); // 恢复深度写入
RenderSystem.enableCull(); // 恢复面剔除
RenderSystem.defaultBlendFunc(); // 恢复默认混合
RenderSystem.disableBlend(); // 关闭混合
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); // 恢复颜色
}
注意最后的状态恢复——RenderSystem 是全局状态机,你改了什么就要还什么。否则后续渲染会继承你留下的状态,产生难以排查的 bug。
API 速查表
1.5 BufferBuilder:顶点数据构建
前置知识:
顶点(Vertex):3D 图形的基本构成单元。一个三角形有 3 个顶点,一个方块面有 4 个顶点。每个顶点除了位置坐标(x,y,z)外,还可以带颜色 纹理坐标 法线等附加信息。
VBO(Vertex Buffer Object):显存里的一块内存,专门用来存顶点数据。数据一旦上传到 VBO 就常驻显存,GPU 可以反复读取而不需要 CPU 每帧重传。
VAO(Vertex Array Object):记录"VBO 里的数据怎么解释"的配置对象——比如前 12 字节是位置(3个float),接下来 4 字节是颜色(4个byte) 等等。
顶点构建系统概述
Blaze3D 的顶点数据不是直接写进 VBO 的——中间有一层 CPU 端的构建系统负责组装 拼接 校验。整体数据流是这样的:
Tesselator(单例入口) → BufferBuilder(构建器) → VertexFormat(格式校验) → BufferUploader(上传绘制)

四个角色各管一件事:
Tesselator —— 全局单例,持有一个预分配的大 ByteBuffer,对外暴露
getBuilder()和end()两个入口。BufferBuilder —— 真正干活的构建器,负责把每个顶点的属性按 VertexFormat 定义的顺序写入底层 byte 数组。
VertexFormat —— 描述一个顶点由哪些属性组成 每个属性的类型 偏移量 总步长。构建器靠它来校验写入顺序是否正确。
BufferUploader —— 拿到构建完成的 buffer 数据后,创建 VAO/VBO 并发起
glDrawArrays或glDrawElements调用。
Tesselator 单例
// 获取全局唯一的 Tesselator 实例
Tesselator tesselator = Tesselator.getInstance();
// 从中取出 BufferBuilder
BufferBuilder buffer = tesselator.getBuilder();
为什么是单例?内部预分配了一块较大的 ByteBuffer(默认 2MB),避免每帧反复 malloc/free 产生内存碎片。全局共用这块内存,绝大多数渲染路径顺序执行,不需要并发构建。
需要注意的是 Tesselator 不支持嵌套调用。如果上一次 begin() 还没 end() 就再调 begin(),会直接抛 IllegalStateException。遇到确实需要同时构建多份数据的场景(比如在渲染回调里插入额外几何体),要自己 new BufferBuilder(bufferSize) 创建独立实例。
BufferBuilder 使用流程
完整的五步流程如下:
// 第一步:获取 builder
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
// 第二步:begin —— 指定图元模式和顶点格式
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR_TEX_LIGHTMAP);
// 第三步:逐顶点写入属性(重复 N 次)
// 这里以一个四边形的四个顶点为例
buffer.vertex(matrix, x0, y0, z0)
.color(255, 255, 255, 255)
.uv(0.0f, 0.0f)
.uv2(packedLight)
.endVertex();
buffer.vertex(matrix, x1, y1, z1)
.color(255, 255, 255, 255)
.uv(1.0f, 0.0f)
.uv2(packedLight)
.endVertex();
buffer.vertex(matrix, x2, y2, z2)
.color(255, 255, 255, 255)
.uv(1.0f, 1.0f)
.uv2(packedLight)
.endVertex();
buffer.vertex(matrix, x3, y3, z3)
.color(255, 255, 255, 255)
.uv(0.0f, 1.0f)
.uv2(packedLight)
.endVertex();
// 第四步:结束构建,得到打包好的数据
BufferBuilder.RenderedBuffer renderedBuffer = buffer.end();
// 第五步:上传到 GPU 并绘制
BufferUploader.drawWithShader(renderedBuffer);
几个关键点:begin() 的第一个参数决定图元拓扑(QUADS TRIANGLES TRIANGLE_STRIP 等),第二个参数决定每个顶点要填哪些属性。end() 返回的 RenderedBuffer 是一个轻量包装,持有底层 ByteBuffer 的一个切片引用。drawWithShader() 会在绘制完成后自动释放这块 buffer 供下次复用。
VertexConsumer 链式接口
BufferBuilder 实现了 VertexConsumer 接口,所有属性通过链式调用按顺序填入:
术语解释:
matrix(变换矩阵):一个 4×4 的数字方阵,作用是把顶点从"模型自身的坐标系"变换到"世界坐标系"或"相机坐标系"。比如一个箱子模型的顶点是相对于箱子中心定义的,乘以变换矩阵后就变成了世界中的实际位置。
UV 坐标:纹理贴图的采样坐标。把一张纹理图想象成一张平铺的贴纸,U 是横向(0=左边 1=右边),V 是纵向(0=顶部 1=底部)。GPU 根据每个顶点的 UV 值从纹理图上取对应的颜色。
法线(Normal):垂直于表面的单位向量,指向"外面"。GPU 用法线计算光照——法线朝向光源的面更亮,背对光源的面更暗。
buffer.vertex(matrix, x, y, z) // 位置——将 (x,y,z) 乘以变换矩阵后写入缓冲区
.color(255, 255, 255, 255) // RGBA 颜色,每个分量 0-255(255=最大强度)
.uv(u, v) // UV0 主纹理坐标——GPU 从纹理的 (u,v) 位置取颜色
.overlayCoords(u, v) // UV1 覆盖层坐标(实体受伤红闪 TNT 白闪用这个)
.uv2(packedLight) // UV2 光照贴图坐标——决定该顶点接收多少环境光
.normal(nx, ny, nz) // 法线方向——垂直于表面指向外侧的单位向量
.endVertex(); // 标记当前顶点结束,内部写入指针前进到下一个顶点的位置
每个方法的要点:
vertex —— 有两个重载。
vertex(Matrix4f, float, float, float)会把局部坐标乘以传入的模型视图矩阵再写入;vertex(double, double, double)直接写世界坐标,不做变换。绝大多数场景用带 Matrix4f 的版本,因为渲染管线中 PoseStack 已经帮你累积好了变换。color —— 四个 int 参数分别是 R G B A。也有接受单个 int(ARGB packed)的重载。颜色会和纹理采样结果相乘,传 255 表示不做额外着色。
uv —— 写入 UV0,对应主纹理的采样坐标。精灵图(sprite)的 UV 通常从 TextureAtlasSprite 获取,不是简单的 0~1。
overlayCoords —— 写入 UV1 覆盖层。大多数方块渲染不需要这个,传
OverlayTexture.NO_OVERLAY即可。实体渲染时用它驱动受伤闪烁效果。uv2 —— 写入 UV2 光照坐标。这是一个打包的 int,低 16 位 block light 高 16 位 sky light,用
LightTexture.pack()生成。normal —— 法线方向,归一化的三分量浮点数。影响 diffuse 光照强度。方块面朝哪个方向,法线就指向那个方向。
endVertex —— 不写任何数据,只是把内部写入指针前进一个 stride 的长度,表示这个顶点的所有属性已填完。
调用顺序必须和 VertexFormat 中声明的属性顺序一致。如果跳过某个属性或者顺序写反,BufferBuilder 在 debug 模式下会抛异常,release 模式下则会产生错位的顶点数据——画面表现为三角形乱飞或者整块消失。
VertexFormat 预定义格式

MC 预定义了一组 VertexFormat 覆盖绝大多数渲染场景。选错格式不会编译报错 但运行时着色器拿到的属性全是错位的垃圾数据。
字节数直接决定了内存占用和带宽消耗。渲染 GUI 用 POSITION_TEX_COLOR 就够了,没必要上 NEW_ENTITY 白白多传 12 字节。
顶点属性顺序
必须严格按 VertexFormat 定义的顺序填写属性。顺序错误不会抛运行时异常 但渲染结果完全错误——颜色字节被当位置解释 UV 被当法线读取 画面直接乱掉。
// ✓ 正确:POSITION_TEX_COLOR 的顺序是 pos → uv → color
buffer.vertex(x, y, z).uv(u, v).color(r, g, b, a).endVertex();
// ✗ 错误:color 和 uv 顺序反了
buffer.vertex(x, y, z).color(r, g, b, a).uv(u, v).endVertex();
// 不会报错!但 GPU 会把 color 的字节当 UV 解释,画面完全乱掉
这是新手最常踩的坑之一。调试时如果看到模型变成彩色噪点或者纹理拉伸成锯齿状 先检查属性顺序。
VertexFormat.Mode 图元类型
Mode 枚举定义了顶点如何组装成图元:
LINES — 每 2 个顶点构成一条线段
TRIANGLES — 每 3 个顶点构成一个三角形
TRIANGLE_STRIP — 相邻三角形共享边 顶点复用率高
QUADS — 每 4 个顶点构成一个四边形
QUADS 比较特殊。OpenGL Core Profile 早已删除了 GL_QUADS,MC 内部做了自动转换:
输入 4 个顶点 v0 v1 v2 v3
拆成两个三角形 (v0, v1, v2) 和 (v0, v2, v3)
保持逆时针绕序确保面朝向正确
因此用 QUADS 写代码更直觉(四边形思维)但实际提交给 GPU 的仍然是三角形。索引缓冲由引擎自动生成 开发者不需要手动处理。
BufferUploader
BufferUploader.drawWithShader() 是最终把顶点数据推到 GPU 并触发绘制的入口。内部简化流程如下:
从 RenderSystem 获取当前绑定的着色器
设置着色器 uniform(模型视图矩阵 投影矩阵 颜色 雾效参数)
将顶点数据上传到临时 VBO
配置 VAO 按 VertexFormat 设置各属性指针(偏移 步长 类型)
执行
glDrawArrays或glDrawElements清理临时资源 解绑 VAO
每次调用 drawWithShader 都是一次完整的 draw call。这意味着频繁调用会产生大量 GPU 状态切换开销 后面性能优化一节会展开讲。
完整示例
示例 1:渲染带纹理的四边形
GUI 中最常见的操作——贴一张纹理到屏幕指定区域。
public void renderTexturedQuad(PoseStack poseStack, ResourceLocation texture,
float x, float y, float width, float height) {
// 绑定着色器和纹理
RenderSystem.setShader(GameRenderer::getPositionTexShader);
RenderSystem.setShaderTexture(0, texture);
Matrix4f matrix = poseStack.last().pose();
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
// 开始构建四边形 使用 POSITION_TEX 格式
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX);
// 逆时针绕序:左下 → 右下 → 右上 → 左上
buffer.vertex(matrix, x, y + height, 0).uv(0, 1).endVertex();
buffer.vertex(matrix, x + width, y + height, 0).uv(1, 1).endVertex();
buffer.vertex(matrix, x + width, y, 0).uv(1, 0).endVertex();
buffer.vertex(matrix, x, y, 0).uv(0, 0).endVertex();
// 一次上传并绘制
BufferUploader.drawWithShader(tesselator.end());
}
示例 2:渲染彩色三角形(调试用)
调试渲染管线时经常需要画一个纯色三角形确认坐标系和矩阵是否正确。
public void renderDebugTriangle(PoseStack poseStack,
float x1, float y1,
float x2, float y2,
float x3, float y3) {
RenderSystem.setShader(GameRenderer::getPositionColorShader);
// 关闭纹理 启用混合以支持半透明
RenderSystem.enableBlend();
Matrix4f matrix = poseStack.last().pose();
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
buffer.begin(VertexFormat.Mode.TRIANGLES, DefaultVertexFormat.POSITION_COLOR);
// 三个顶点分别用红 绿 蓝 GPU 会自动插值产生渐变
buffer.vertex(matrix, x1, y1, 0).color(255, 0, 0, 128).endVertex();
buffer.vertex(matrix, x2, y2, 0).color(0, 255, 0, 128).endVertex();
buffer.vertex(matrix, x3, y3, 0).color(0, 0, 255, 128).endVertex();
BufferUploader.drawWithShader(tesselator.end());
RenderSystem.disableBlend();
}
示例 3:渲染粒子 Billboard(面向相机的四边形)
粒子系统的核心——让一个四边形始终朝向摄像机。关键是用摄像机的 right 和 up 向量计算顶点位置。
public void renderBillboard(PoseStack poseStack, Camera camera,
Vec3 center, float size,
float u0, float v0, float u1, float v1,
int light) {
RenderSystem.setShader(GameRenderer::getParticleShader);
// 从相机获取朝向向量
Vec3 right = new Vec3(camera.getLeftVector()).scale(-1);
Vec3 up = new Vec3(camera.getUpVector());
// 四个角的偏移
float hs = size * 0.5f;
Matrix4f matrix = poseStack.last().pose();
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE);
// 左下
float x0 = (float)(center.x - right.x * hs - up.x * hs);
float y0 = (float)(center.y - right.y * hs - up.y * hs);
float z0 = (float)(center.z - right.z * hs - up.z * hs);
buffer.vertex(matrix, x0, y0, z0).uv(u0, v1).color(255, 255, 255, 255)
.uv2(light).endVertex();
// 右下
float x1 = (float)(center.x + right.x * hs - up.x * hs);
float y1f = (float)(center.y + right.y * hs - up.y * hs);
float z1 = (float)(center.z + right.z * hs - up.z * hs);
buffer.vertex(matrix, x1, y1f, z1).uv(u1, v1).color(255, 255, 255, 255)
.uv2(light).endVertex();
// 右上
float x2 = (float)(center.x + right.x * hs + up.x * hs);
float y2f = (float)(center.y + right.y * hs + up.y * hs);
float z2 = (float)(center.z + right.z * hs + up.z * hs);
buffer.vertex(matrix, x2, y2f, z2).uv(u1, v0).color(255, 255, 255, 255)
.uv2(light).endVertex();
// 左上
float x3 = (float)(center.x - right.x * hs + up.x * hs);
float y3f = (float)(center.y - right.y * hs + up.y * hs);
float z3 = (float)(center.z - right.z * hs + up.z * hs);
buffer.vertex(matrix, x3, y3f, z3).uv(u0, v0).color(255, 255, 255, 255)
.uv2(light).endVertex();
BufferUploader.drawWithShader(tesselator.end());
}
性能优化
渲染性能的第一杀手是 draw call 数量。每次 drawWithShader 调用都意味着一次 CPU→GPU 的状态同步 这个代价远大于多画几个三角形。
批量渲染 vs 逐个渲染
// ✗ 低效:每个物体单独 draw call
for (var obj : objects) {
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX_COLOR);
addVertices(buffer, obj);
BufferUploader.drawWithShader(tesselator.end()); // 每次都上传+绘制
}
// 100 个物体 = 100 次 draw call = 100 次状态同步
// ✓ 高效:合并成一次 draw call
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX_COLOR);
for (var obj : objects) {
addVertices(buffer, obj); // 只往 buffer 里追加顶点
}
BufferUploader.drawWithShader(tesselator.end()); // 一次上传 一次绘制
// 100 个物体 = 1 次 draw call
按纹理分组减少状态切换
合批有个前提:同一次 draw call 里所有顶点必须用同一张纹理和同一个着色器。如果物体用了不同纹理 就需要按纹理分组。
// 按纹理对物体分组
Map<ResourceLocation, List<RenderObject>> grouped = objects.stream()
.collect(Collectors.groupingBy(RenderObject::getTexture));
// 每组一次 draw call
for (var entry : grouped.entrySet()) {
RenderSystem.setShaderTexture(0, entry.getKey());
buffer.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX_COLOR);
for (var obj : entry.getValue()) {
addVertices(buffer, obj);
}
BufferUploader.drawWithShader(tesselator.end());
}
// N 种纹理 = N 次 draw call(而不是 M 个物体 = M 次)
更进一步可以使用纹理图集(Texture Atlas)把多张小纹理合并到一张大图上 通过 UV 偏移选择子区域。MC 的方块纹理和物品纹理都是这么做的——整个方块渲染只需要一次纹理绑定。
1.6 RenderType:渲染状态封装
什么是 RenderType
渲染一个物体需要配置一堆状态——着色器 纹理 混合模式 深度测试 面剔除。手动管理这些状态极其容易出错:忘记恢复一个开关就会污染后续所有渲染。
RenderType 的解决方案是把一组状态打包成一个对象。

// ✗ 没有 RenderType:手动设置每个状态,还要手动恢复
RenderSystem.enableBlend();
RenderSystem.blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA);
RenderSystem.enableDepthTest();
RenderSystem.depthMask(false);
RenderSystem.disableCull();
RenderSystem.setShader(GameRenderer::getEntityTranslucentShader);
RenderSystem.setShaderTexture(0, texture);
// 渲染...
// 还要恢复所有 7 个状态!漏一个就出 bug
// ✓ 有 RenderType:三行搞定
RenderType type = RenderType.entityTranslucent(texture);
type.setupRenderState(); // 自动设置所有状态
// 渲染...
type.clearRenderState(); // 自动恢复所有状态
预定义 RenderType 一览
方块渲染类型
实体渲染类型
其他渲染类型
使用 RenderType
基本方式(直接操作 Tesselator):
RenderType renderType = RenderType.entityTranslucent(myTexture);
renderType.setupRenderState(); // 设置所有状态
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
buffer.begin(renderType.mode(), renderType.format());
// 添加顶点...
BufferUploader.drawWithShader(tesselator.end());
renderType.clearRenderState(); // 恢复所有状态
MultiBufferSource 方式(更常用 更高效):
public void render(PoseStack poseStack, MultiBufferSource bufferSource,
int packedLight, int packedOverlay) {
// 获取指定 RenderType 的 VertexConsumer
VertexConsumer consumer = bufferSource.getBuffer(
RenderType.entityTranslucent(myTexture)
);
// 直接往里添加顶点
Matrix4f matrix = poseStack.last().pose();
consumer.vertex(matrix, x, y, z)
.color(255, 255, 255, 255)
.uv(u, v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(poseStack.last().normal(), 0, 1, 0)
.endVertex();
// MultiBufferSource 自动管理绘制时机
}
MultiBufferSource 的三个优势:
自动按 RenderType 分组——相同类型的顶点攒一起批量绘制
自动排序——不透明先画 半透明后画
状态切换最小化——同类型只 setup 一次

在实际引擎中,一帧里会有上百次 getBuffer() 调用请求不同的 RenderType。MultiBufferSource 把这些请求收集起来,按类型归并,最终只做少量 draw call。这是 Blaze3D 批量绘制的核心机制。
RenderType 内部结构
RenderType 本质是一个 CompositeState——由多个 RenderStateShard 组合而成:
// RenderType 内部的状态组合
RenderType.CompositeState state = RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_TRANSLUCENT_SHADER) // 着色器
.setTextureState(new TextureStateShard(texture, false, false)) // 纹理
.setTransparencyState(TRANSLUCENT_TRANSPARENCY) // 混合模式
.setLightmapState(LIGHTMAP) // 光照贴图
.setOverlayState(OVERLAY) // 覆盖层
.setCullState(NO_CULL) // 不剔除
.setWriteMaskState(COLOR_DEPTH_WRITE) // 写入遮罩
.createCompositeState(true); // true = 半透明需要排序
每个 Shard 负责一种状态的 setup 和 clear。RenderStateShard 类型列表:
预定义 Shard 常量(直接引用 不需要 new):
// 透明度
RenderStateShard.NO_TRANSPARENCY // 不混合
RenderStateShard.TRANSLUCENT_TRANSPARENCY // 标准透明
RenderStateShard.ADDITIVE_TRANSPARENCY // 加法(发光)
RenderStateShard.GLINT_TRANSPARENCY // 附魔光泽
// 深度
RenderStateShard.NO_DEPTH_TEST // 无深度测试
RenderStateShard.LEQUAL_DEPTH_TEST // ≤ 通过(默认)
// 面剔除
RenderStateShard.CULL // 剔除背面
RenderStateShard.NO_CULL // 双面
// 写入
RenderStateShard.COLOR_WRITE // 只写颜色
RenderStateShard.DEPTH_WRITE // 只写深度
RenderStateShard.COLOR_DEPTH_WRITE // 都写
创建自定义 RenderType
方式一:RenderType.create() 直接创建
public static final RenderType MY_GLOW = RenderType.create(
"my_mod:glow", // 唯一名称(调试用)
DefaultVertexFormat.POSITION_TEX_COLOR, // 顶点格式
VertexFormat.Mode.QUADS, // 图元模式
256, // 缓冲区大小
false, // affectsCrumbling(参与方块破坏?)
true, // sortOnUpload(半透明排序?)
RenderType.CompositeState.builder()
.setShaderState(POSITION_TEX_COLOR_SHADER)
.setTextureState(new TextureStateShard(
new ResourceLocation("my_mod", "textures/glow.png"), false, false))
.setTransparencyState(ADDITIVE_TRANSPARENCY) // 加法混合
.setCullState(NO_CULL) // 双面
.setDepthTestState(LEQUAL_DEPTH_TEST)
.setWriteMaskState(COLOR_WRITE) // 不写深度
.createCompositeState(false)
);
方式二:带参数的 RenderType(每个纹理一个实例)
// Util.memoize 缓存——同一个纹理只创建一次 RenderType 实例
private static final Function<ResourceLocation, RenderType> CUSTOM_ENTITY =
Util.memoize(texture -> RenderType.create(
"my_mod:custom_entity",
DefaultVertexFormat.NEW_ENTITY,
VertexFormat.Mode.QUADS,
256, true, true,
RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_TRANSLUCENT_SHADER)
.setTextureState(new TextureStateShard(texture, false, false))
.setTransparencyState(TRANSLUCENT_TRANSPARENCY)
.setLightmapState(LIGHTMAP)
.setOverlayState(OVERLAY)
.setCullState(NO_CULL)
.createCompositeState(true)
));
public static RenderType customEntity(ResourceLocation texture) {
return CUSTOM_ENTITY.apply(texture);
}
Util.memoize 是关键——没有它每次调用都会 new 一个 RenderType 对象,在渲染循环里造成 GC 压力。
1.21.4+ 的变化
核心渲染管线(方块 实体 粒子 GUI)的状态配置从 json 迁移到了 Java 代码中硬编码(RenderPipelines.java 的 Builder 模式)。这意味着资源包不能再通过修改 json 来切换核心着色器。
但 PostChain(后处理管线)仍然用 json 配置。着色器源文件(.vsh .fsh)也仍在 shaders/ 目录,只是"谁用哪个着色器"这件事不再写在 json 里。
调试与常见问题
1.7 RenderTarget:离屏渲染与后处理
前置知识:
帧缓冲(Framebuffer):GPU 画画的目标"画布"。默认的帧缓冲就是屏幕——GPU 画完直接显示。但你也可以创建额外的帧缓冲,让 GPU 画到一张纹理上而不是屏幕上,这就叫离屏渲染。
后处理(Post-Processing):先把整个场景画到一张纹理上,然后对这张纹理做图像处理(模糊 调色 辉光 扭曲等),最后把处理结果贴到屏幕上。就像先拍一张照片,再用滤镜修图。
FBO(Framebuffer Object):OpenGL 中创建自定义帧缓冲的对象。一个 FBO 可以挂载颜色纹理(存画面)和深度纹理(存深度值)。
前面几节我们一直在主帧缓冲上画东西。但很多效果需要先画到一个"临时画布"上,处理完再贴回屏幕。这就是 RenderTarget 的职责。
RenderTarget 概述
com.mojang.blaze3d.pipeline.RenderTarget 是 Minecraft 对 OpenGL FBO(Framebuffer Object)的封装。它把帧缓冲的创建 绑定 纹理读取 深度管理统一成一套高层 API,使用者不需要直接碰 glGenFramebuffers 这类底层调用。
主要用途有三个:
离屏渲染——把某一类物体画到独立缓冲里,不影响主画面(比如实体描边 发光效果)
后处理——对已经画好的画面做全屏效果(模糊 色调映射 扭曲)
多 Pass 渲染——一帧拆成多个阶段,每个阶段写入不同的缓冲,最后合成
GameRenderer 里的 itemEntityTarget particlesTarget weatherTarget cloudsTarget 都是 RenderTarget 实例。Minecraft 用它们把不同图层分开渲染,再通过 PostChain 合成最终画面。
创建与配置
创建一个新的 RenderTarget:
// 参数:宽度 高度 是否带深度缓冲 macOS兼容标志
RenderTarget target = new RenderTarget(width, height, true, Minecraft.ON_OSX);
获取主帧缓冲(也就是最终显示到屏幕的那个):
RenderTarget mainTarget = Minecraft.getInstance().getMainRenderTarget();
useDepth 参数控制是否分配深度纹理。如果只是做 2D 后处理(不需要深度测试),可以传 false 省一张纹理。但大多数场景都需要深度,因为 3D 物体的遮挡关系依赖它。
ON_OSX 是一个历史遗留标志。macOS 上某些 GL 驱动对 FBO 的兼容行为不同,这个标志用来处理平台差异。
RenderTarget API
RenderTarget 提供的操作可以分成四组:
绑定与解绑
// 绑定为写入目标(参数控制是否同时设置 viewport)
target.bindWrite(true);
// 参数 false 时只绑定 FBO 不改 viewport,适合部分区域渲染
target.bindWrite(false);
// 解绑写入(恢复到默认帧缓冲)
target.unbindWrite();
// 绑定为读取源(把颜色纹理绑到纹理单元)
target.bindRead();
target.unbindRead();
清除
// 设置清除颜色(RGBA 范围 0-1)
target.setClearColor(0.0f, 0.0f, 0.0f, 0.0f);
// 执行清除(颜色缓冲 + 深度缓冲一起清)
target.clear(Minecraft.ON_OSX);
尺寸管理
// 窗口 resize 时调用,重新分配纹理
target.resize(newWidth, newHeight, Minecraft.ON_OSX);
纹理访问
// 获取颜色纹理的 GL ID(用于在着色器里采样)
int colorTex = target.getColorTextureId();
// 获取深度纹理的 GL ID
int depthTex = target.getDepthTextureId();
复制与显示
// 把内容 blit 到屏幕主缓冲
target.blitToScreen(screenWidth, screenHeight);
// 从另一个 RenderTarget 复制深度缓冲
target.copyDepthFrom(sourceTarget);
渲染到 RenderTarget
一个完整的离屏渲染流程包含五步:保存当前状态 → 绑定目标 → 清除 → 渲染内容 → 恢复状态。
public void renderToTarget(RenderTarget target) {
// 保存当前绑定的帧缓冲
RenderTarget previous = Minecraft.getInstance().getMainRenderTarget();
// 绑定离屏目标
target.setClearColor(0.0f, 0.0f, 0.0f, 0.0f);
target.clear(Minecraft.ON_OSX);
target.bindWrite(true);
// 在这里执行渲染操作
// 所有 draw call 都会写入 target 而非屏幕
renderSomeContent();
// 恢复到之前的帧缓冲
target.unbindWrite();
previous.bindWrite(true);
}
这个模式在 Minecraft 里随处可见。LevelRenderer 在渲染实体描边时就是先画到 entityTarget,再通过后处理着色器提取轮廓,最后合成回主画面。
后处理效果实现
后处理的核心思路是:把整个画面当作一张纹理,用一个全屏四边形把它画出来,期间通过片段着色器对每个像素做变换。
全屏四边形渲染的基本结构:
public void renderFullscreenQuad(RenderTarget source) {
// 绑定源纹理
source.bindRead();
// 设置着色器
RenderSystem.setShader(GameRenderer::getPositionTexShader);
// 构建覆盖整个屏幕的四边形
BufferBuilder builder = Tesselator.getInstance().begin(
VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX);
builder.addVertex(-1.0f, -1.0f, 0.0f).setUv(0.0f, 0.0f);
builder.addVertex( 1.0f, -1.0f, 0.0f).setUv(1.0f, 0.0f);
builder.addVertex( 1.0f, 1.0f, 0.0f).setUv(1.0f, 1.0f);
builder.addVertex(-1.0f, 1.0f, 0.0f).setUv(0.0f, 1.0f);
BufferUploader.drawWithShader(builder.buildOrThrow());
source.unbindRead();
}
坐标范围 -1 到 1 是 NDC(标准化设备坐标),刚好覆盖整个视口。UV 从 0 到 1 采样整张纹理。
一个简单的高斯模糊需要两个 Pass——水平方向一次 垂直方向一次。分开做的原因是把 O(n²) 的 2D 卷积降成两次 O(n) 的 1D 卷积:
// blur.fsh — 单方向高斯模糊
#version 150
uniform sampler2D DiffuseSampler; // 输入纹理
uniform vec2 BlurDir; // 模糊方向 (1,0) 或 (0,1)
uniform vec2 InSize; // 纹理尺寸
in vec2 texCoord;
out vec4 fragColor;
void main() {
vec2 texelSize = 1.0 / InSize;
// 5-tap 高斯权重
float weights[5] = float[](0.227, 0.194, 0.122, 0.054, 0.016);
vec3 result = texture(DiffuseSampler, texCoord).rgb * weights[0];
for (int i = 1; i < 5; i++) {
vec2 offset = BlurDir * texelSize * float(i);
result += texture(DiffuseSampler, texCoord + offset).rgb * weights[i];
result += texture(DiffuseSampler, texCoord - offset).rgb * weights[i];
}
fragColor = vec4(result, 1.0);
}
水平 Pass 设 BlurDir = (1, 0),把结果写到临时缓冲;垂直 Pass 设 BlurDir = (0, 1),从临时缓冲读入再写到最终目标。
Ping-Pong 缓冲
多 Pass 后处理有一个经典问题:一个着色器的输出是下一个着色器的输入。不能对同一张纹理同时读写(未定义行为),所以需要两个缓冲交替使用——这就是 Ping-Pong 技术。
public class PingPongBuffer {
private final RenderTarget bufferA;
private final RenderTarget bufferB;
private boolean writeToA = true;
public PingPongBuffer(int width, int height) {
this.bufferA = new RenderTarget(width, height, false, Minecraft.ON_OSX);
this.bufferB = new RenderTarget(width, height, false, Minecraft.ON_OSX);
}
// 获取当前要写入的目标
public RenderTarget getWriteTarget() {
return writeToA ? bufferA : bufferB;
}
// 获取当前要读取的源(上一个 Pass 的输出)
public RenderTarget getReadTarget() {
return writeToA ? bufferB : bufferA;
}
// 一个 Pass 结束后翻转
public void swap() {
writeToA = !writeToA;
}
// 调整尺寸
public void resize(int width, int height) {
bufferA.resize(width, height, Minecraft.ON_OSX);
bufferB.resize(width, height, Minecraft.ON_OSX);
}
// 获取最终结果(最后写入的那个)
public RenderTarget getResult() {
// swap 后 writeToA 已翻转,所以结果在"读取目标"里
return getReadTarget();
}
}
使用流程:
PingPongBuffer pingPong = new PingPongBuffer(width, height);
// 初始内容写入
pingPong.getWriteTarget().bindWrite(true);
renderInitialContent();
pingPong.swap();
// 多次后处理
for (PostEffect effect : effects) {
RenderTarget src = pingPong.getReadTarget();
RenderTarget dst = pingPong.getWriteTarget();
dst.clear(Minecraft.ON_OSX);
dst.bindWrite(true);
effect.apply(src); // 从 src 采样,写入 dst
dst.unbindWrite();
pingPong.swap();
}
// 最终结果
RenderTarget finalResult = pingPong.getResult();
finalResult.blitToScreen(screenWidth, screenHeight);
Minecraft 的 PostChain 内部就是用类似的机制串联多个后处理 Pass。
与 Arc3D 对比
Arc3D(Minecraft 底层图形抽象层的现代实验分支)用不同的方式管理帧缓冲:
GLFramebuffer:更薄的 FBO 封装,只管理 GL 对象生命周期,不绑定具体纹理格式
FramebufferCache:池化管理,按尺寸和格式缓存 FBO,避免频繁创建销毁
RenderPassDesc:声明式描述一个渲染 Pass 需要哪些 attachment,框架自动分配缓冲
这种设计更接近 Vulkan 的 RenderPass 概念——把"我需要什么"和"怎么分配资源"分离。RenderTarget 则是更传统的命令式风格:创建 绑定 使用 销毁,每一步都显式控制。
常见问题
深度缓冲共享。 多个 RenderTarget 之间不能直接共享深度缓冲。如果需要在离屏渲染时保持主场景的深度关系(比如粒子不穿墙),要用 copyDepthFrom() 把主缓冲的深度复制过来。这是一次 GPU 拷贝,有性能开销,但比重新渲染深度便宜得多。
窗口 resize 处理。 所有 RenderTarget 都要在窗口尺寸变化时调用 resize()。漏掉任何一个会导致画面拉伸或崩溃。Minecraft 在 GameRenderer.resize() 里统一处理所有已注册的目标。
纹理过滤模式。 默认创建的 RenderTarget 使用 GL_NEAREST(最近邻采样)。后处理时如果需要平滑采样(比如缩放 模糊),要手动切换到 GL_LINEAR:
RenderSystem.bindTexture(target.getColorTextureId());
GlStateManager._texParameter(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
GlStateManager._texParameter(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
用完记得恢复,否则其他地方使用这个纹理时会得到意外的模糊效果。
1.8 ShaderInstance:着色器管理
前置知识:
着色器(Shader):运行在 GPU 上的小程序。GPU 对每个顶点执行"顶点着色器"(决定顶点最终在屏幕哪个位置),对每个像素执行"片段着色器"(决定这个像素最终是什么颜色)。着色器用 GLSL 语言编写,语法类似 C。
Uniform 变量:从 CPU 传给着色器的"全局参数"。比如变换矩阵 时间 颜色调制这些每帧可能变化的值,CPU 设好后着色器直接读取。Uniform 对所有顶点/像素统一生效(所以叫 uniform——统一)。
采样器(Sampler):着色器里用来读取纹理的特殊变量。
texture(Sampler0, uv)就是从绑定在 0 号单元的纹理上、用 uv 坐标取一个颜色值。
ShaderInstance 概述
net.minecraft.client.renderer.ShaderInstance 封装了一个完整的 GL 着色器程序。它持有顶点着色器 片段着色器 Uniform 变量 采样器绑定这四类资源,对外提供统一的加载 绑定 参数设置接口。每个 ShaderInstance 在构造时完成 GLSL 源码编译和链接,运行时只需绑定和上传 Uniform 即可驱动渲染。
ShaderInstance 的生命周期由 GameRenderer 管理。资源包重载时所有着色器实例会被销毁并重新编译,这意味着 mod 替换 .vsh .fsh 文件后热重载即可生效,不需要重启游戏。
1.21.4+ 管线变化
1.21.4 之前,核心渲染管线的着色器通过 shaders/core/*.json 描述文件绑定顶点格式 混合模式 采样器等元数据。1.21.4 起这套 json 机制被移除,改为在 RenderPipelines.java 中用 Builder 硬编码管线配置。
PostChain(后处理链)仍然保留 json 描述。PostChainConfig 通过 Codec 反序列化 shaders/post/*.json,每个 pass 指定输入纹理 输出目标 着色器引用。这一层没有变动,mod 仍然可以通过资源包注入自定义后处理效果。
着色器源文件(.vsh .fsh)的存放位置不变,仍在 assets/minecraft/shaders/ 目录下。变化的只是"谁来声明如何使用这些源文件"——从 json 声明式变成了 Java 命令式。
着色器文件结构
着色器源码分布在两个子目录:
shaders/core/— 核心渲染着色器,每对.vsh+.fsh对应一个渲染管线shaders/include/— 可复用的工具函数片段,通过 import 机制引入
include 机制使用 Mojang 自定义的预处理指令:
#moj_import <fog.glsl> // 从 shaders/include/ 目录引入
#moj_import "my_utils.glsl" // 从当前文件同目录引入
预处理在 Java 侧完成,递归展开后拼接成完整源码再提交给 GL 编译。不支持条件编译,不支持 #define 宏传参。
顶点着色器示例
以 particle.vsh 为例,这是粒子渲染的顶点着色器:
#version 150
#moj_import <fog.glsl>
// 顶点属性输入
in vec3 Position; // 粒子顶点世界坐标
in vec2 UV0; // 纹理坐标
in vec4 Color; // 顶点颜色(粒子染色)
in ivec2 UV2; // 光照贴图坐标(整数,需除以 16 归一化)
// 采样器
uniform sampler2D Sampler2; // 光照贴图(lightmap)
// 变换矩阵
uniform mat4 ModelViewMat; // 模型视图矩阵
uniform mat4 ProjMat; // 投影矩阵
// 雾效参数
uniform int FogShape; // 雾的形状:0 球形 1 圆柱形
// 传递给片段着色器的插值变量
out float vertexDistance; // 顶点到相机的距离,用于雾效计算
out vec2 texCoord0; // 纹理坐标透传
out vec4 vertexColor; // 最终顶点颜色(粒子色 * 光照)
void main() {
// 标准 MVP 变换:把 3D 世界坐标变成屏幕上的 2D 坐标
// Position 是世界坐标 → 乘 ModelViewMat 得到相机视角下的坐标 → 乘 ProjMat 压成屏幕坐标
// vec4(Position, 1.0) 中的 1.0 是齐次坐标的 w 分量,用于透视除法(近大远小)
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
// 计算该顶点到相机的距离,fog_distance 是 fog.glsl 里的工具函数
// 这个距离值会传给片段着色器,用来决定雾效浓度(越远越浓)
vertexDistance = fog_distance(ModelViewMat, Position, FogShape);
// 纹理坐标直接透传
texCoord0 = UV0;
// 顶点颜色 = 粒子自身颜色 × 光照贴图采样值
// UV2 / 16 将整数坐标映射到 16×16 光照贴图的纹素位置
vertexColor = Color * texelFetch(Sampler2, UV2 / 16, 0);
}
这里的关键设计是光照贴图的处理方式。UV2 是整数坐标(ivec2),范围 0-240,除以 16 得到 0-15 的纹素索引,用 texelFetch 精确采样而非插值。这保证了 Minecraft 特有的"方块光照"离散感。
片段着色器示例
对应的 particle.fsh:
#version 150
#moj_import <fog.glsl>
// 从顶点着色器接收的插值变量
in float vertexDistance;
in vec2 texCoord0;
in vec4 vertexColor;
// 采样器
uniform sampler2D Sampler0; // 粒子纹理图集
// Uniform
uniform vec4 ColorModulator; // 全局颜色调制(用于淡入淡出等效果)
uniform vec4 FogColor; // 雾的颜色
uniform float FogStart; // 雾起始距离
uniform float FogEnd; // 雾结束距离
// 输出
out vec4 fragColor;
void main() {
// 采样粒子纹理
vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator;
// alpha 测试:完全透明的像素直接丢弃
if (color.a < 0.01) {
discard;
}
// 应用线性雾效混合
fragColor = linear_fog(color, vertexDistance, FogStart, FogEnd, FogColor);
}
片段着色器的逻辑很直白:纹理采样 颜色相乘 alpha 测试 雾效混合。discard 用于剔除透明像素,避免它们写入深度缓冲影响后续渲染排序。
Uniform 系统
ShaderInstance 通过名称索引 Uniform 变量。获取和设置的典型流程:
// 获取 Uniform 句柄
Uniform uniform = shader.getUniform("ColorModulator");
// 设置值(多种重载)
uniform.set(1.0f, 1.0f, 1.0f, 0.5f); // vec4
uniform.set(matrix); // mat4
uniform.set(0.8f); // float
// 上传到 GPU(在 draw call 前调用)
uniform.upload();
核心渲染管线中常用的 Uniform 变量:
Uniform 的上传时机很重要。Minecraft 在每次 draw call 前批量上传当前着色器的所有 dirty uniform,而不是逐个 set 后立即 glUniform。这减少了 GL 状态切换次数。
RenderPipelines Builder 模式(新版)
1.21.4 起,核心管线的配置集中在 RenderPipelines 类中。以下是简化的构建示例:
// 地形渲染的通用片段(被多个管线复用)
private static final SnippetKey TERRAIN_SNIPPET = RenderPipeline.builder()
.withVertexShader("core/rendertype_solid")
.withFragmentShader("core/rendertype_solid")
.withSampler("Sampler0") // 方块纹理图集
.withSampler("Sampler2") // 光照贴图
.withUniform("ModelViewMat")
.withUniform("ProjMat")
.withUniform("ChunkOffset") // 区块偏移量,用于相对坐标渲染
.withUniform("ColorModulator")
.withUniform("FogStart")
.withUniform("FogEnd")
.withUniform("FogColor")
.withUniform("FogShape")
.withVertexFormat(DefaultVertexFormat.BLOCK)
.buildSnippet();
// 实体渲染的管线配置
private static final SnippetKey ENTITY_SNIPPET = RenderPipeline.builder()
.withVertexShader("core/rendertype_entity_solid")
.withFragmentShader("core/rendertype_entity_solid")
.withSampler("Sampler0") // 实体纹理
.withSampler("Sampler1") // 覆盖层纹理(受伤闪红 死亡变白)
.withSampler("Sampler2") // 光照贴图
.withUniform("ModelViewMat")
.withUniform("ProjMat")
.withUniform("ColorModulator")
.withUniform("Light0_Direction")
.withUniform("Light1_Direction")
.withVertexFormat(DefaultVertexFormat.NEW_ENTITY)
.buildSnippet();
Builder 模式的优势在于编译期可检查,IDE 能直接跳转到管线定义。缺点是 mod 不再能通过资源包覆盖 json 来修改管线绑定,需要用 mixin 或事件拦截 Builder 调用。
buildSnippet() 返回 SnippetKey,多个最终管线可以引用同一个 Snippet 来复用配置。这类似于着色器中 include 的思路——把公共部分抽出来,避免重复声明。
与 Arc3D 对比
Blaze3D 的着色器系统只支持运行时 GLSL。着色器以文本形式加载,由 GL 驱动在目标机器上编译。不同 GPU 厂商的 GLSL 编译器行为差异(尤其是精度 优化 错误容忍度)会导致"在我机器上能跑"的问题。
Arc3D 引入了 SPIR-V 中间表示。着色器先用 GLSL 或自定义语言编写,离线编译为 SPIR-V 字节码,运行时再由后端(Vulkan OpenGL Metal)翻译为目标 API 的着色器格式。这带来三个收益:
编译错误在构建期暴露,不会延迟到玩家机器上
跨平台行为一致,SPIR-V 语义精确定义
可以做离线优化 死代码消除 常量折叠,减少运行时编译开销
对于 Minecraft 这种需要支持大量硬件组合的游戏来说,SPIR-V 路径能显著减少"着色器编译失败"类 bug report。但这也意味着更重的工具链依赖和更长的构建流程。
从立即模式的历史包袱到 Blaze3D 的现代架构,从一帧的完整生命周期到各组件的逐层拆解——理解这些设计意图和实际 API,是写 MC 渲染代码的基础。

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