拆解 Voxy:Minecraft 远景渲染背后的数据系统
从一块方块的变化出发,读懂 Voxy 如何用多级体素、增量更新、8 字节四边形、GPU 层级遍历和任务反压维持远景。
很多人第一次关注 Voxy,是因为它能让 Minecraft 展示很远的地形。对开发者来说,更有意思的场景是:远处一棵树刚刚长出来,而相机还在移动。系统需要更新远景数据、决定是否重新生成网格、判断这一帧该画哪一级细节,同时不能让后台任务把帧时间拖垮。
这篇文章要回答的就是:Voxy 怎样把持续变化的方块世界,转换成有限内存、显存和帧时间能够处理的表示?
Voxy 的答案分散在世界数据、模型烘焙、网格生成、GPU 遍历和持久化存储之间。单独看某段位运算,容易只看到技巧;沿着数据流读,才能看清这些技巧为什么需要一起出现。
本文依据本地 C:\dev\Projects\MinecraftMods\voxy-dev 源码快照撰写;其 gradle.properties 声明 Voxy 0.2.20-beta、Minecraft 26.2。Voxy 上游仓库可供在线阅读,但 dev 分支会继续变化,具体实现以本文分析的本地快照为准。以下是静态源码分析,没有启动游戏或测量帧率;涉及收益的判断均是对设计的推论。
1一张图看懂 Voxy 在做什么
近处的方块需要保留丰富细节,远处山坡上的每个方块却可能连一个像素都占不到。继续以同样的精度处理它们,投入的计算和最终画面贡献就不成比例。
LOD,即细节层次,就是让远处区域使用更粗的表示。Voxy 将这个思路落实到体素数据本身:先从 Minecraft 的区块截面中提取方块、群系和光照,再生成多级体素,持久保存,并按当前视角生成、选择和绘制相应几何。
可以沿着两条相连的数据流理解它。第一条把世界数据变成可复用的远景资产;第二条在每帧选择可见资产,并把缺失内容作为请求送回后台。
这份远景表示保存的是渲染所需信息。看见远处地形,并不意味着那些区域同时执行完整的实体、红石或其他游戏模拟。已有数据的范围也仍受摄取与导入来源约束:渲染系统不能凭空知道从未获得的地形。
这套流水线最值得学习的前提是表示按用途变化:Minecraft 的游戏逻辑、远景层级、GPU 绘制命令和磁盘格式共享世界含义,却不必共享同一种数据布局。
| 要解决的压力 | Voxy 的做法 | 随之而来的代价 |
|---|---|---|
| 世界数据太大 | 多级体素与局部值表 | 粗层会丢失孔洞、材质比例等细节 |
| 网格与提交太多 | 可见面剔除、合面、8 字节 quad、间接绘制 | 编码与着色器必须严格配套 |
| 视角持续变化 | GPU 层级遍历与缺失请求 | CPU/GPU 之间需要回传和同步 |
| 后台任务可能积压 | 随任务数收紧请求额度 | 细节提升速度会随负载变化 |
2第一层:用固定数组搭出多级世界
这里有两个容易混淆的尺寸。
VoxelizedSection 对应输入端的 16 × 16 × 16 方块截面。它在同一个数组中容纳五级数据:
16³ → 8³ → 4³ → 2³ → 1³WorldSection 则固定容纳 32³ 个体素。随着 LOD 等级提高,每个体素代表的世界范围扩大,整个 Section 的覆盖范围也随之扩大。
| LOD 等级 | 一个体素覆盖的方块边长 | 一个 WorldSection 覆盖的方块边长 |
|---|---|---|
| 0 | 1 | 32 |
| 1 | 2 | 64 |
| 2 | 4 | 128 |
| 3 | 8 | 256 |
| 4 | 16 | 512 |
因此,等级 4 的一个体素代表一个 16³ 的原始方块区域;等级 4 的整个 WorldSection 则覆盖 512³ 的空间。把这两个概念混为一谈,会误读后面的坐标与层级关系。
这种组织方式有一个工程优势:每一级都能使用相似的数组和处理逻辑,变化主要体现在坐标尺度。WorldUpdater 用输入截面坐标右移 lvl + 1 位确定写入哪个 WorldSection,再计算该级在 Section 内的局部偏移。层级关系类似八叉树,存储基本单元却是固定大小的数组块,不必把每个原始方块都包装成树节点对象。
额外层级的体素数量也可以直接算出来:一个输入截面原有 4096 个体素,附加的四级共 512 + 64 + 8 + 1 = 585 个,增加约 14.3%。这只是该体素金字塔的元素数量开销,不包括索引、缓存、网格和 GPU 资源。
这个数字解释了为什么预先保存多级表示值得考虑:用相对有限的附加数据,换取远景渲染时直接选择合适精度的能力。
3第二层:降采样决定远处还剩下什么
Mipper.mip() 接收八个体素,输出一个粗体素。它没有把方块 ID 求平均,也没有采用多数表决。
当前实现会忽略空气候选,对非空气方块计算类似这样的优先值:
优先值 = opacity × 16 + 固定位置编号然后选中优先值最大的候选,保留它的整个体素值,包括该候选的方块、群系与光照。这里的 opacity 来自 Mapper 中的方块属性;树叶被特别设置为 15。相同 opacity 下,固定编号决定结果,因此选择是确定的,也带有空间方向偏好。
只有八个候选全为空气时,代码才汇总光照:天空光取平均后向上取整,方块光使用当前整数编码下的平均与掩码处理。
为什么不能简单平均?因为“石头”“草”和“树叶”是类别,ID 的数值中点没有视觉意义。LOD 首先需要决定留下哪类表面,然后才有颜色、光照如何近似的问题。
从实现可以推断,这种选择倾向于保留较强的实体存在感,树叶特判也有利于保留树冠。但代价同样清楚:某个优先级高的方块可能代表整个区域,少量非空气内容也可能在粗层级中被放大,细小孔洞和混合材质比例则可能丢失。这些是算法取舍的推论,并不是本文已经复现的画面缺陷。
学习这个模块时,可以提出一个很有价值的实验:在森林、栅栏、悬空结构和洞穴口上,对比当前规则、多数表决以及覆盖率规则。衡量标准应同时包含轮廓稳定性、材质保留和运算成本。
这里传递出的经验是:LOD 算法表达了对画面重要性的判断。减少数量只是结果,选择保留什么才是设计。 Voxy 在这里选择“最不透明的一个样本”,而没有保存八个样本的材质比例;这是一个可以用对照实验评估的明确取舍。
4第三层:一块方块改变后,哪些工作真的需要重做
VoxelIngestService 的处理链很清晰:转换输入数据,计算输入截面的各级 mip,再调用 WorldUpdater.insertUpdate() 写入世界表示。
WorldUpdater 逐级把这次输入对应的局部区域写入 WorldSection。它会比较新旧体素,维护非空气计数和子区域存在状态,再根据变化类型通知后续系统。
这里值得注意的是,几何内容变化和子区域存在性变化被分别记录。前者关系到网格重建,后者关系到层级结构是否需要调整。边界变化还会产生相邻方向掩码,因为一个区域的表面是否可见,可能取决于旁边区域是否有方块。
假设树叶出现在一个已经有石头的 2 × 2 × 2 区域。最细层的体素必然变化;如果降采样仍选中那块石头,上层的值可能完全不变。WorldUpdater 在某级比较后发现没有内容变化,也没有需要继续上报的存在状态变化,就能停止传播。局部输入变了,不等于每一级输出都变了。
这个优化有边界:调用更新器之前,输入截面的 mip 已经生成;提前退出主要节省更高层 WorldSection 的写入、通知和后续网格工作。若变化位于 Section 边缘,相邻 Section 的可见面也可能受影响,因此还要带上邻居方向掩码。
这一点很适合迁移到其他系统:把“发生了更新”细分为“哪些可观察结果改变了”。地形、导航、缩略图和缓存依赖图,都可以借此减少无效重算。
5第四层:先理解方块外观,再压缩几何
Minecraft 的方块外观并不都等于完整立方体。远景系统还需要处理透明裁剪、流体以及各种模型形状。
SoftwareModelTextureBakery 提供了一条很值得学习的路线:收集方块模型的几何,用软件光栅器从六个方向处理,输出各面的数据。普通模型与流体分别走相应分支,过程中还汇总阴影、透明和 discard 等标记。
这样,后面的远景网格生成可以使用烘焙结果与模型元数据,而不必在每次生成区域网格时重新解释完整模型。ModelQueries 中关于面存在、面遮挡和光照方式的查询,进一步把视觉信息转换成网格生成阶段可以快速判断的条件。
可以把这理解为一次针对远景用途的模型预处理:把丰富输入转成受限、紧凑、便于重复使用的表示。
这并不意味着任意模型都能被无损保留。例如,当前软件烘焙路径中关于方块实体模型的处理仍有 TODO 和注释掉的调用。读源码时必须分清“存在一个相关类”和“当前流程已经启用这项能力”。
5.1合面与 8 字节四边形
RenderDataFactory 的工作可以分成两层。
第一层是尽量不生成不需要的面。代码维护不透明、非不透明和流体掩码,读取相邻区域边界,并结合模型遮挡属性决定哪些面需要出现。对于规则体素网格,位掩码可以把一组占用情况交给整数运算处理,减少逐对象判断。
第二层是合并可以合并的面。ScanMesher2D 沿扫描线处理数据,再把匹配的相邻行合并成矩形。合并要求传入的编码数据一致,因此不能只看“材质一样”;参与编码的光照等差异也可能阻止合并。
它的矩形长宽上限是 16,正好对应 quad 格式中用四位保存“尺寸减一”的约定。这是一个具体的跨层设计:网格算法的限制与 GPU 数据编码互相配合。
生成结果以一个 long 保存一个四边形描述,也就是 8 字节。其中编码位置、面方向、尺寸、模型索引、群系和光照等信息。顶点着色器通过 gl_VertexID 找到四边形及角点,再计算顶点位置和 UV。
这里的 8 字节指四边形描述本身,并不包括模型纹理、区域元数据、索引和绘制命令的全部开销。举个具体例子:相邻的 16 个顶面若编码数据完全相同,扫描线可以把它们合成一个长 16、宽 1 的四边形;若光照编码不同,就要分开。所谓“贪心合面”最终受 GPU 格式和视觉属性约束。
这种做法适用于结构高度规则的几何:许多顶点属性可以从少量参数恢复。相较于直接搬运完整顶点属性,它把一部分成本转为 GPU 上的解码计算,从而争取减少存储和传输量。收益取决于实际瓶颈,不能脱离硬件直接给出帧率倍数。
还有一个阅读细节:世界体素的 Mapper 编码与渲染 quad 编码是两种格式,不能因为它们都放进 long 就混用。quad_format.glsl 的两种解码分支对模型索引位宽的写法也有差别,深入改动时需要结合实际模型容量和编译分支核对。
6第五层:GPU 遍历既画当前画面,也提出下一步需求
traversal_dev.comp 把三个问题组合在同一次层级遍历中:这个节点是否可见?它是否值得继续细化?需要的细节是否已经准备好?
首先,视锥测试排除视野外区域。随后,Hi-Z 测试利用分层深度纹理判断节点是否被遮挡。深度被组织成不同分辨率,可以针对投影范围选择合适层级,避免把所有遮挡判断都落到全分辨率像素上。
接着,screenspace.glsl 根据节点包围盒的投影计算屏幕空间面积指标,shouldDecend() 用它与阈值 minSSS 比较。这里的依据是屏幕上的投影大小,不只是世界空间距离;也不要直接把这个面积指标说成已经测量过的几何误差。
当需要细化时,已有子节点就继续遍历;缺少子节点就提交请求,并在满足渲染距离等条件时使用当前粗节点网格。反过来,如果当前节点正好适合绘制却没有网格,也会请求数据,并可能尝试子节点。
最值得学习的是“请求更好的结果”与“本帧立即使用的结果”分开。需要细化却没有子节点时,着色器提交请求,并在距离条件允许时尝试画当前粗节点;当前节点本应绘制却没有网格时,它提交请求并尝试下降到子节点。两条退路的条件不同,因此不能把它概括成任何缺失都一定有粗层兜底。
粗节点可用
↓ 当前投影值得细化
提交细化请求,同时尽可能继续画粗节点
↓ 后台准备数据和网格
后续遍历能够使用子节点这是一种渐进提供细节的设计。请求标记还用于抑制重复提交,防止同一缺失内容反复进入队列。
7第六层:缺失请求必须受后台吞吐量约束
GPU 可能每帧发现大量细化需求,但 CPU 的网格生成、磁盘读取和 GPU 上传吞吐量都是有限的。
HierarchicalOcclusionTraverser.uploadUniform() 会根据网格任务数量调整请求额度。当前源码中,参考任务数量是 4000,请求队列最大额度是 50;其关系是:
可用比例 = max(0, (4000 - 当前网格任务数) / 4000)
请求额度 = clamp(ceil(可用比例² × 50), 0, 50)例如排队任务为 2000 时,可用比例为 0.5,额度是 ceil(0.25 × 50) = 13,并非线性计算所得的 25;达到 4000 时额度为 0。这里的平方使积压尚未填满时就更积极地收紧请求。这两个数字是本地实现参数,不是通用最优值。GPU 的请求队列另有硬上限;整套机制相当于让需求速率服从后台供给能力。
GPU 请求通过 DownloadStream 回传 CPU。该工具用 fence 判断已提交工作是否完成,再处理对应回调和内存回收;上传工具也以 fence 管理在途数据的生命周期。
这揭示了异步系统里两个不同问题:一是任务何时执行完,二是关联内存何时可以重用。提交成功不代表 GPU 已经消费完数据,过早覆盖缓冲区会破坏正确性。
遍历本身仍由 CPU 提交多轮 compute 调度,源码把 persistent GPU threads 写在 TODO 中。因此,准确的描述是 GPU 执行可见性和细化决策,CPU 继续承担调度和后台供给。
最终,MDIC 渲染后端调用 glMultiDrawElementsIndirectCountARB,从缓冲区使用绘制命令和数量。它减少了 CPU 为每个可见区域逐一提交绘制调用的需要,但整个流程仍包含命令生成、同步屏障和资源绑定。
8第七层:把远景数据存成可重复使用的资产
当前默认配置由 StorageConfigUtil.createDefaultSerializer() 组装:
SectionSerializationStorage
→ CompressionStorageAdaptor(ZSTD,等级 1)
→ RocksDBStorageBackend这里值得学的是职责分离:序列化决定数据格式,压缩器决定如何缩小字节流,存储后端决定如何保存和读取。
SaveLoadSystem3 会为一个 WorldSection 建立局部值表,把每个 64 位体素替换成 16 位索引,再保存实际出现的体素值。
原始 32³ 个 long 占 256 KiB。若这个 Section 有 K 种不同的完整体素值,那么压缩前序列化体积大致为:
16 字节头部 + 64 KiB 索引 + 8K 字节值表这里的“不同值”包含编码中的方块、群系与光照差异,不只是方块种类。如果重复度很低,值表方案也可能比原始数组更大;后续压缩效果仍需看数据分布。
例如 K = 256 时,这一层格式约占 16 + 65536 + 2048 = 67600 字节,约 66 KiB;原始数组为 256 KiB。这个数字还没算 ZSTD,不能当作最终磁盘占用或通用压缩率。这种格式未追求把每个索引压到理论最少位数,而是保留固定 16 位索引,给编码与解码提供简单路径。是否值得进一步位打包,应结合读写频率、CPU 成本和最终压缩效果比较。
另一个容易误讲的细节是:文件里虽然存在 lin2z() 和 z2lin(),当前 serialize() 循环直接遍历 section.data,没有调用它们。因此不能据函数存在就宣称这条存储路径使用了 Morton 顺序。
9值得带着批判眼光阅读的边界
Voxy 包含显式 acquire/release、线程局部缓存、原生内存和 GPU 资源管理。它们让资源使用更可控,同时增加了必须维护的不变量。
VoxelIngestService 入队时持有世界引用,并在处理后的 finally 中释放,保证后台任务执行期间世界不会因为无人引用而被回收。
但引用计数只回答“对象还活着吗”,不能回答“读取时内容会不会变化”。同一个摄取路径复制了光照数据,却仍把 LevelChunkSection 引用交给后台任务,源码明确留有需要复制 Section 数据以避免并发修改的 TODO。
这是特别值得认真分析的地方:资源生命周期安全、数据快照一致性和内存可见性,是三个需要分别证明的问题。添加一个原子计数器,不能自动解决后两个问题。这里的 TODO 提供了审查线索,但本文没有进行并发复现,不能据此断言每次摄取都会产生错误。
类似地,渲染系统在完成工作后恢复或清理大量 OpenGL 状态,体现了嵌入 Minecraft、Sodium 和 Iris 渲染环境时的现实成本。一个独立演示程序可以独占图形状态,一个 Mod 则需要与其他渲染代码共同工作。算法之外,接口契约和状态管理同样决定系统能否可靠运行。
此外,SoftwareModelTextureBakery 的方块实体模型路径留有 TODO;网格构建中也有透明面与区块边界相关的 TODO。它们提示读者:这种紧凑表示是针对远景的近似,不等于完整复现近处渲染。源码中的 TODO 是审查线索,不是本文已经复现的缺陷报告。
9.1建议怎样读源码
如果只挑三条路线深入学习,我会建议先读 Mipper → WorldUpdater,理解表示和变化传播;再读 RenderDataFactory → ScanMesher2D → quad_format.glsl,理解数据布局如何贯穿 CPU/GPU;最后读 traversal_dev.comp → HierarchicalOcclusionTraverser → DownloadStream,理解实时系统如何在资源不足时仍然持续给出结果。
真正可以从 Voxy 带走的,是一种设计顺序:先决定什么信息值得保留,再让数据格式、生成任务、传输和绘制围绕这个决定配合。它最精彩的部分,就藏在这些模块的连接处。
10源码阅读索引
以下路径均相对于 C:\dev\Projects\MinecraftMods\voxy-dev。Java 简写前缀 J/ 代表 src/main/java/me/cortex/voxy/;着色器前缀 S/ 代表 src/main/resources/assets/voxy/shaders/。函数名用于定位,避免后续编辑导致行号失效。在线可从上游 dev 分支查找同名文件;上游代码可能已有变化。
| 主题 | 文件与定位 |
|---|---|
| 输入体素金字塔 | J/common/voxelization/VoxelizedSection.java:createEmpty、getBaseIndexForLevel |
| 摄取流程与并发边界 | J/common/world/service/VoxelIngestService.java:processJob、enqueueIngest |
| 体素编码和树叶特判 | J/common/world/other/Mapper.java:composeMappingId、StateEntry |
| 降采样规则 | J/common/world/other/Mipper.java:mip |
| 世界组织 | J/common/world/WorldEngine.java:MAX_LOD_LAYER、getWorldSectionId;WorldSection.java:SECTION_VOLUME |
| 增量传播 | J/common/world/WorldUpdater.java:insertUpdate |
| 六方向模型烘焙 | J/client/core/model/bakery/SoftwareModelTextureBakery.java:renderToOutput |
| 网格构建与编码 | J/client/core/rendering/building/RenderDataFactory.java:emitQuad、packPartialQuadData、generateMesh |
| 扫描线合面 | J/client/core/util/ScanMesher2D.java:putNext0、finish |
| GPU 解码与顶点恢复 | S/lod/quad_format.glsl;S/lod/gl46/quads3.vert:main |
| 层级决策 | S/lod/hierarchical/traversal_dev.comp:traverse、addRequest |
| 屏幕尺度与 Hi-Z | S/lod/hierarchical/screenspace.glsl:setupScreenspace、isCulledByHiz、shouldDecend |
| 调度与反压 | J/client/core/rendering/hierachical/HierarchicalOcclusionTraverser.java:uploadUniform、traverseInternal、forwardDownloadResult(目录名原本就拼作 hierachical) |
| GPU 数据生命周期 | J/client/core/rendering/util/UploadStream.java、DownloadStream.java |
| 间接绘制 | J/client/core/rendering/section/backend/mdic/MDICSectionRenderer.java:搜索 glMultiDrawElementsIndirectCountARB |
| 默认存储链 | J/common/StorageConfigUtil.java:createDefaultSerializer |
| 局部值表格式 | J/common/world/SaveLoadSystem3.java:serialize、deserialize |
| 系统装配和状态管理 | J/client/core/VoxyRenderSystem.java:构造函数、renderOpaque、shutdown |
分析日期:2026-09-26。本文未修改项目源码。
CONTINUE EXPLORING