解剖《Enshrouded》的体素世界:从创作工具到渲染与碰撞
结合 Keen Games 的世界设计资料和本机版本的资源反射、体素 tile 与机器码证据,追踪可编辑世界从创作到存储、Mesh、渲染和物理的路径。
《Enshrouded》的山体可以挖开,玩家可以建造,世界却仍然保有手工设计的道路、遗迹和地形构图。Keen Games 称它运行在自研的 Holistic Engine 上;制作地图时,团队先借助程序化工具,再由设计师手工加工。1 这给引擎提出了一个比“怎样绘制体素”更大的问题:
同一个可编辑世界,怎样同时成为设计师的创作材料、玩家的操作对象,以及渲染和物理系统的数据来源?
本文沿着“创作 → 资源 → 几何 → 渲染 → 碰撞”追查这个问题。公开资料解释世界如何被制作;对本机 Windows 版本的静态分析,则给出具体的类型、字段、tile 字节和函数入口。两类证据能够互相照亮,却不能自动拼成一份完整的引擎源码。
样本与阅读边界
本文的本机样本是 2026 年 6 月 29 日构建的 Windows 版 enshrouded.exe,SHA-256 为 AF2F5A1227911D8AA06B3908D6BD0211838211CAE14EA91099CB57D0DF990781。只读取安装文件,没有修改游戏或注入进程。资源字段偏移指序列化反射布局,不是运行时 C++ 对象的内存布局。游戏更新后,资源数据和文中的静态代码地址都可能变化。
1为什么可编辑世界不是一张最终 Mesh?
传统的手工场景可以把美术资产、碰撞体和光照结果提前准备好。玩家一旦能够挖穿山壁,至少有两件事需要同时成立:洞口必须显示出来,人物也必须能够穿过去。存档还要记住这次改动。若世界只是一张制作完成的 Mesh,这些变化便没有共同的数据来源。
体素提供的是一种离散的空间表示,不是必须画成方块的规定。概念上,我们可以把世界写成 ,让每个网格位置记录某些状态;这只是解释问题的模型,并非 Holistic Engine 已确认的底层数组格式。与只记录单一高度 的地表相比,三维表示能够容纳同一水平位置上的洞顶、洞底和更深处的空间。
Keen 的开发人员曾明确说,整个世界建立在方形体素网格上;同时提醒读者,外界对引擎内部算法的许多猜测并不准确。2 因此,看到平滑山坡,不能推出它使用 Marching Cubes;看到洞穴,也不能推出每个体素都保存 SDF。
2我们究竟掌握了哪些证据?
| 证据层次 | 本文如何使用 |
|---|---|
| 官方公开 | 说明世界制作方式、可见功能及更新中的变化 |
| 本机资源与机器码 | 说明该版本实际存在的类型、字段、数据片段和代码入口 |
| 工程推断 | 解释这些事实可能怎样协作,明确标出尚缺的调用链 |
| 未知 | 列编码、完整表面提取算法、碰撞代理生成方式等 |
这次本机分析分三步。首先记录 EXE 哈希、PE 节与诊断字符串,再用代码引用和虚表指针把部分文字线索定位到机器码。其次用社区的 kfc-parser 读取 KFC 索引、反射类型和资源描述符,从内容哈希提取三个原始 tile。最后让字段偏移、实例数据与代码入口互相核对:类型名称只能证明结构存在,实例和调用路径才能逐渐说明它怎样被使用。
反射表让我们重建序列化数据声明;机器码允许写经过验证的局部伪代码。本机发布版 EXE 没有 COFF 符号,调试目录虽留有 PDB 路径,却没有对应的 PDB 文件。优化后的二进制也无法完整还原原始变量名、注释和控制结构,因此本文不会把重建声明称作 Keen 的原始源码。
下文的流程图表示概念上的数据关系,不是已经恢复的函数调用图。
3世界如何被创作出来?
3.1从粗模到体素世界
Keen 的 World Design 团队公开过一条具体的制作路径:新区域先有粗略的 3D sculpt,用来判断距离和 POI 的位置;粗模随后进入体素世界,成为主场景中的 sub-biome scene。较小的 POI subscene 逐步占位,设计师再用手工放置的 voxel stamps 为地形增加细节。重要物件倾向于手工摆放,次要物件则可由 Whiteboxes 从候选资产中选择。3
这条流程解释了“手工世界”和“程序化工具”为何能够并存:工具承担重复劳动,设计师仍决定地标、路径和探索节奏。体素在这里既是运行时要读取的数据,也是创作过程中的材料。
3.2二进制透露了哪些创作输入?
本机 EXE 的反射表包含 SceneVoxelBrush(128 字节)。它有 model、color、material、disableDisplacement、resolveBiomePerVoxel、paintMaterialOnly、blitFunction、allowNonManifold、removableFogId 和 addDestructionEdge 等字段。VoxelBlitFunction 枚举可见 Default、KeepBuildings、Water、DestructionVoxels 四种值。
另一个类型 SceneVoxelContentPass(88 字节)按 passIndex 引用 blockOuts、voxelObjects、voxelBrushes、caves、tunnels、roads、instances、dungeonRoomInstances 和 proceduralLayers 等数组。这些字段与官方所述的分层创作流程相吻合,但字段列表不能还原编辑器界面,也不能证明各数组的执行顺序。
开发者工具和玩家建造工具面对同一个问题:不能要求操作者逐个修改最底层单元。Keen 谈到曲面建筑时,也强调希望新形状进入 snapping 与大蓝图工作流,而不只供熟练玩家逐体素拼接。3 底层网格提供空间规则;刷子、stamp 和蓝图把规则组织成可用的操作。
4世界如何存储?
4.1七层资源索引
从该版本 EXE 提取的反射数据含 14,398 个类型。其中,VoxelWorldResource 的序列化大小为 4,328 字节;它并非一整块体素数组,而是包含世界尺寸、材质 GUID 和内容引用的描述符。
| 字段 | 序列化偏移 | 读到的结构 |
|---|---|---|
type | 0x0000 | 资源类别;枚举中有 Solid、Fog、Temperature |
size、origin | 0x0004、0x0010 | 三维尺寸与原点 |
materialGuids | 0x001C | 256 个材质 GUID 槽 |
voxelLevels | 0x1030 | 7 个 VoxelWorldLevel |
voxelHashes | 0x10D8 | 内容哈希字段 |
每个 VoxelWorldLevel 有 tileSize、tileCount 和 tiles;最后一项是内容哈希数组。本机资源中,共提取 135 个体素世界描述符,其中 130 个标为 Solid、5 个标为 Fog。主 Solid 资源记录的 size 为 (10240,2648,10240),主 Fog 为 (10240,1956,10240)。主 Solid 的 256 个材质槽中有 198 个非空。
主 Solid 资源的七层索引如下:
| 层 | tileSize | tileCount | 内容哈希数 |
|---|---|---|---|
| 0 | 128 × 128 | 3 × 3 | 9 |
| 1 | 128 × 128 | 5 × 5 | 25 |
| 2 | 128 × 128 | 10 × 10 | 100 |
| 3 | 128 × 128 | 20 × 20 | 400 |
| 4 | 128 × 128 | 40 × 40 | 1,600 |
| 5 | 256 × 256 | 40 × 40 | 1,600 |
| 6 | 256 × 256 | 80 × 80 | 6,400 |
总数为 10,134 个 tile 引用,并不等于 10,134 份互不相同的内容。字段名提示分层组织,但数字本身还不能告诉我们各层的采样倍率、物理尺寸,或运行时如何选层。尤其不能把 size 或 tileSize 直接换算成米。
4.2一个 tile 里面是什么?
借助社区开源的 kfc-parser,可以按资源描述符中的 ContentHash 读出原始 tile 字节。本文检查了主 Solid 世界第 0 层、第 6 层以及主 Fog 世界第 0 层各一个样本。三个文件均符合下面的索引布局:
byte 0 = count_x
byte 1 = count_z
随后是 count_x × count_z 个小端 24 位偏移
payload_start = 2 + 3 × count_x × count_z
第 i 列 = payload[offset[i] : offset[i+1]]最后一列以 payload 末尾为边界。三个样本的偏移都从 0 开始,单调排列,并落在 payload 内。第 0 层 Solid/Fog 的两个样本各有 16 × 16 列,头部占 770 字节;第 6 层 Solid 样本有 32 × 32 列,头部占 3,074 字节。
这一步只定位了列在哪里。列长度不固定,开头四字节可以拆成两个 u16,但它们的含义和后续负载格式仍未查明。当前证据不足以宣称每列保存的是密度、距离、占据位或某种确定的压缩指令。
注意版本差异
社区旧版场景格式说明提到 highLODData 和 lowLODData。这两个字段不在本文样本的 VoxelWorldResource 反射定义中。研究格式时,应让当前版本的类型表与实际字节优先于旧字段名。4
5体素数据怎样成为可见几何?
有了世界数据,Renderer 仍需要得到可绘制的表面。本站旧稿在这里停留于“必然存在某种表面表示”;机器码和反射表现在让我们能够说得更具体一些,却仍不足以命名最终的 Mesher。
第一处函数片段引用错误信息 [mesh_builder] Couldn't allocate ... merged voxel rects。附近指令从输入读取三个 16 位尺寸,扫描字节样本,并在一条路径上连续写出六个 float。它说明存在一段把体素相关网格转换为几何记录的代码。六个 float 的确切语义,以及这条路径是否生成最终可见地形,仍需追踪消费者。
另一处体素 Mesh 代码在报出 Vertex index (16bit) overflow in voxel mesh! 之前与 0xFFFF 比较。这给出该路径的一个具体约束:顶点索引受 16 位范围限制。但相邻代码以 52 字节步长访问记录,不能据此把 52 字节说成所有 GPU 顶点的最终格式。
更清晰的渲染侧结构是 VoxelChunkShaderData,反射大小 192 字节:
| 字段组 | 已观察的字段 |
|---|---|
| 三角形 | triangleIndexOffset、triangleIndexCount |
| 四边形 | quadIndexOffset、quadIndexCount |
| 顶点与材质 | vertexOffset、bakedTriangleMaterialDataOffset |
| 小块与可见性 | chunkletOffset、chunkletCount、chunkletVisibilityOffset |
| 选择信息 | lodLevel、flags、pipelineIndices |
VoxelChunklet 本身的反射大小为 32 字节,含四浮点的 sphere 与 normal 字段。EXE 中还可找到 chunklet_culling、早期/后期剔除和前缀和绘制参数等名称。这些线索支持“几何被细分并参与剔除和绘制准备”的判断;各阶段 buffer 的布局与提交顺序尚未通过帧捕获验证。
此图是根据数据名称整理的待验证路径。四边形何时被拆成三角形、材质怎样烘焙、具体使用何种表面提取算法,都不是箭头能够证明的事。EXE 中存在 SDF 烘焙相关名称,也不等于地形必然通过 SDF 提取表面。
最终画面还取决于材质、光照和大气。Keen 的更新记录明确提到 terrain material blending 的修复、动态 GI probes 对光照变化的响应,以及新的体素压缩方法。5 这些属于公开的系统能力;本文没有把它们推成未经验证的 Mesh 算法。
6可编辑地形怎样参与物理?
旧稿把《Enshrouded》的物理后端列为完全未知。查看本机二进制后,这句话需要修订:EXE 中有 Jolt 的类型和诊断字符串,反射表也有 JoltObjectSetup 与 ColliderResourceData.joltObjects。这确认了 Jolt 集成;它本身还不能证明每一种物理对象都经过同一条路径。
更有价值的是体素专用的 VoxelWorldShape。EXE 中的 VoxelWorldShape::CollidePoint not implemented 文本可定位一段短函数,再由 .rdata 中指向它的虚表项找到邻近入口:
| 本机静态地址 | 依据 Jolt 接口顺序判断的入口 | 当前观察 |
|---|---|---|
0x14001E280 | 单结果 CastRay | 有实际函数体 |
0x14001E870 | 收集器版 CastRay | 有实际函数体 |
0x14001ED60 | CollidePoint | 带“未实现”诊断的短函数 |
0x14001EDA0 | CollideSoftBodyVertices | 带“未实现”诊断的短函数 |
槽位名称是把本机虚表与 Jolt 的 Shape 虚函数声明顺序对照后得到的高可信度判断,不是从游戏 PDB 读出的源码函数名。两个射线入口并非同样的报错短函数,说明体素 Shape 至少有专门的射线查询实现。CollidePoint 的短函数也不能推广为“整个游戏不支持点查询”。
反射表还出现 WorldCollision:它列有三角形批次、顶点位置、材质 ID 和 BVH 树数据。不过本轮没有从 KFC 提取到该类型的顶层资源描述符,不能把它直接认作 VoxelWorldShape 当前使用的碰撞代理。射线函数到底遍历原始列数据、BVH、三角形缓存还是其他结构,仍是未解问题。
从游戏行为看,地形修改后可见表面与角色碰撞必须保持一致;这是一项工程约束,并不说明引擎具体如何更新碰撞。Keen 的更新日志中,角色被地形碰撞卡住、隔着实体地面与梯子交互等修复,展示了动态世界在边界情况下的复杂性。6
7一次世界修改会牵动什么?
反射类型 VoxelWorldTerraAction 至少记录 position: float3、radius: float 和 mode。名称与字段适合描述一次局部地形操作;目前没有调用链证明玩家的每一种挖掘或建造行为都直接使用它。
如果局部世界状态变化,系统需要重新回答几组问题:
这些箭头表达一致性要求,并不表示本机已经恢复出统一事件总线或固定更新顺序。可编辑世界的难点正在这里:单独把一个体素写入数组很容易;让玩家接下来看到、撞到、保存到的都是同一处世界,则需要一整套协调机制。
公开更新记录可作为这种耦合的侧面例子:Keen 修复过建筑与地形体素交界的显示问题,也改进过动态 GI、室内外环境检测以及与地形碰撞有关的交互。56 这些修复不能反向证明具体代码架构,却说明世界几何变化并非 Renderer 独自处理的小功能。
8这次逆向究竟告诉了我们什么?
目前证据已经把讨论从“它也许用了某种体素技术”推进到更清晰的边界:
| 可以确认或直接观察 | 还不能确认 |
|---|---|
| 世界资源包含材质表与七层 tile 引用 | 七层与实际 LOD、采样倍率的准确映射 |
| 三个 tile 样本含 24 位列偏移索引 | 列负载的密度、材质或压缩编码 |
| 渲染数据区分三角形、四边形及 chunklet | 完整 Mesh 生成算法与 GPU 提交顺序 |
| 程序包含 Jolt 集成和体素 Shape 射线入口 | 射线使用的碰撞代理与更新策略 |
| 创作资源包含 brush 与 content pass | 编辑器 UI 和完整构建执行链 |
从发布版 EXE 可以恢复类型声明、资源数据和部分函数行为,却不能恢复原始 C++ 工程:本机没有对应 PDB,编译优化也不会保留所有源码结构。本文所有数字和地址都绑定到开头记录的样本版本。
如果继续研究,最有价值的三条线索是:解码 tile 列负载、追踪 Mesh 数据的写入者、追踪 VoxelWorldShape 的射线遍历。每前进一步,都应让新结论同时经得起资源样本和代码路径的检验。
对自己的沙盒项目而言,可以先问玩家与设计师究竟需要怎样改变世界:能否挖穿山体、修改的最小范围、是否永久保存、哪些系统需要立即响应。再选择体素结构、分块方式与表面算法。Keen 的案例最值得借鉴的,是把世界视为可持续编辑的数据和创作介质;渲染、物理与工具都围绕这件事工作。
注释
-
Keen Games,Enshrouded FAQ。官方确认 Holistic Engine,并说明地图制作结合程序化工具与设计师手工工作。 ↩
-
Keen Games 开发人员 Toast,关于 Holistic Engine 与方形体素网格的 Steam 回复,2026 年。开发人员确认世界依托方形体素网格,并指出外界对引擎内部的若干算法猜测不准确;该回复不是完整技术文档。 ↩
-
Keen Games,World Design Team Reddit AMA Recap,2024 年。团队解释了粗模、sub-biome、POI subscene、voxel stamp、Whiteboxes,以及建筑形状与 snapping 工作流。 ↩ ↩2
-
Brabb3l,kfc-parser 场景资源说明。这是社区格式文档;其中旧字段名需与本文样本的当前反射表对照。 ↩
-
Keen Games,Souls of the Frozen Frontier Update,2024 年。更新日志记录了材质混合、动态 GI probes、地形与建筑体素交界问题,以及体素压缩方法。 ↩ ↩2
-
Keen Games,Forging the Path Update,2026 年。更新日志记录了地形碰撞、角色或交互卡入几何体、环境检测等修复案例。 ↩ ↩2
CONTINUE EXPLORING