解剖《Enshrouded》的体素世界:从创作工具到渲染与碰撞
结合 Keen Games 的世界设计资料和本机版本的资源反射、体素 tile 与机器码证据,追踪可编辑世界从创作到存储、Mesh、渲染和物理的路径。
《Enshrouded》的山体可以挖开,玩家可以建造,世界却仍然保有手工设计的道路、遗迹和地形构图。Keen Games 称它运行在自研的 Holistic Engine 上;制作地图时,团队先借助程序化工具,再由设计师手工加工。1 这给引擎提出了一个比“怎样绘制体素”更大的问题:
同一个可编辑世界,怎样同时成为设计师的创作材料、玩家的操作对象,以及渲染和物理系统的数据来源?
本文沿着“创作 → 资源 → 几何 → 渲染 → 碰撞”追查这个问题。公开资料解释世界如何被制作;对本机 Windows 版本的静态分析,则给出具体的类型、字段、tile 字节和函数入口。两类证据能够互相照亮,却不能自动拼成一份完整的引擎源码。
样本与阅读边界
本文于 2026 年 10 月 21 日继续调查的本机样本,内部版本串标记为 2026 年 6 月 29 日,Steam Build 为 23966345,Windows 版 enshrouded.exe 的 SHA-256 为 AF2F5A1227911D8AA06B3908D6BD0211838211CAE14EA91099CB57D0DF990781。只读取安装文件及其副本,没有修改游戏或注入进程。资源字段偏移指序列化反射布局,不是运行时 C++ 对象的内存布局;GPU 反射声明和 CPU 工作记录也分别说明。游戏更新后,资源数据和文中的静态代码地址都可能变化。新增结论的地址、证据编号与复核范围见本轮调查摘要。
1为什么可编辑世界不是一张最终 Mesh?
传统的手工场景可以把美术资产、碰撞体和光照结果提前准备好。玩家一旦能够挖穿山壁,至少有两件事需要同时成立:洞口必须显示出来,人物也必须能够穿过去。存档还要记住这次改动。若世界只是一张制作完成的 Mesh,这些变化便没有共同的数据来源。
体素提供的是一种离散的空间表示,不是必须画成方块的规定。概念上,我们可以把世界写成 ,让每个网格位置记录某些状态;这只是解释问题的模型,并非 Holistic Engine 已确认的底层数组格式。与只记录单一高度 的地表相比,三维表示能够容纳同一水平位置上的洞顶、洞底和更深处的空间。
Keen 的开发人员曾明确说,整个世界建立在方形体素网格上;同时提醒读者,外界对引擎内部算法的许多猜测并不准确。2 因此,看到平滑山坡,不能推出它使用 Marching Cubes;看到洞穴,也不能推出每个体素都保存 SDF。
2我们究竟掌握了哪些证据?
| 证据层次 | 本文如何使用 |
|---|---|
| 官方公开 | 说明世界制作方式、可见功能及更新中的变化 |
| 本机资源与机器码 | 说明该版本实际存在的类型、字段、数据片段和代码入口 |
| 工程推断 | 解释这些事实可能怎样协作,明确标出尚缺的调用链 |
| 未知 | 全局材料映射、跨 LOD 衔接、编辑失效与多系统同步、实际 GPU 帧行为等 |
这次本机分析分三步。首先记录 EXE 哈希、PE 节与诊断字符串,再用代码引用和虚表指针把部分文字线索定位到机器码。其次用社区的 kfc-parser 读取 KFC 索引、反射类型和资源描述符,并扩大原始 tile 的抽样。最后通过 REA 与 Ghidra 追踪顶点生成、连接及射线查询,让字段偏移、实例数据与代码入口互相核对:类型名称只能证明结构存在,实例和调用路径才能逐渐说明它怎样被使用。
反射表让我们重建序列化数据声明;机器码允许写经过验证的局部伪代码。本机发布版 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 个非空。
反射中还有一个较小的 VoxelWorldChunkResource:112 字节,唯一字段是七个 ContentHash 组成的 voxelColumnData。它提供了另一条追踪列数据的资源入口,但不能仅凭 Chunk 这个名字认定其空间边长或运行时分配粒度。复核这些字段时,还要区分 keen:: 与 keen::ds:: 两套声明;例如同名的 VoxelWorldResource 在后者为 4,552 字节,不能混用偏移。本文采用的资源声明摘录给出了完整限定名。
主 Solid 资源的七层索引如下:
| 层 | tileSize | tileCount | 内容哈希数 | 相对 size 单位的采样间距 |
|---|---|---|---|---|
| 0 | 128 × 128 | 3 × 3 | 9 | 32 |
| 1 | 128 × 128 | 5 × 5 | 25 | 16 |
| 2 | 128 × 128 | 10 × 10 | 100 | 8 |
| 3 | 128 × 128 | 20 × 20 | 400 | 4 |
| 4 | 128 × 128 | 40 × 40 | 1,600 | 2 |
| 5 | 256 × 256 | 40 × 40 | 1,600 | 1 |
| 6 | 256 × 256 | 80 × 80 | 6,400 | 0.5 |
总数为 10,134 个 tile 引用,并不等于 10,134 份互不相同的内容。扩大到小型世界资源后,层级关系开始有可核对的数值约束:size.x=256 的资源,七层 tileSize.x 依次为 8,16,32,64,128,256,512,列数则为 1,2,4,8,16,32,64。它们一致支持每列横向覆盖八个该层采样、层间采样数逐层翻倍,上表最后一列由此得到。4
主世界还有边缘裁剪:第 0 层最后一块 tile 只有 8×8 列,也就是 64×64 个该层采样。于是一个方向上的覆盖范围为 ,恰好等于世界描述符的宽度。这比从字段名猜 LOD 更具体;不过采样间距仍是相对描述符 size 的单位,不能直接称为米,也没有恢复运行时的选层距离或过渡算法。
4.2一个 tile 里面是什么?
借助社区开源的 kfc-parser,可以按资源描述符中的 ContentHash 读出原始 tile 字节。初始调查取了主 Solid 第 0、6 层和主 Fog 第 0 层三个样本;本轮复用 KFC3 索引布局,编写只读 reader,扩大到 1,035 个 tile、764,841 列。三个旧样本的 SHA-256 均与重新读取的内容一致;全部受检样本符合下面的索引布局。4
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 字节。新增小世界样本还包括 1×1 与 64×64 列,所以 reader 不应把列数写死成 16 或 32。
继续追到列加载、列内选择与区域读取函数,这一步已经不只定位“列在哪里”。列开头两个小端 u16 分别是纵向逻辑 brick 数与控制流字节数;后者不是第二个高度或 brick 数。控制流位于四字节头之后,余下部分是负载。每个逻辑 brick 解出 个 u16,区域读取器按 X 最快的顺序复制到目标三维数组。5
brick 内的线性下标为 x+8*y+64*z。区域读取路径对 Y<0 填 0xFF01,对超过该列逻辑 brick 数的区域填零;修改覆盖层存在时还会优先查询覆盖层。这些边界规则同样是解码接口的一部分,不能只解出中间负载便认为世界读取完成。
控制流按低位优先读取,先给连续 brick 的 run,再区分两种模式:uniform 模式复用或读取常量体素字;压缩模式为每个 brick 提供 10 位负载长度。压缩负载由 FUN_140e99830 解码成低、高两个字节平面,控制位从头部向前读,literal 字节则从负载尾部向后读,最后核对两端消费边界。低字节具有历史值复用,高字节有依赖低字节类别的默认值和 run 规则,不能当成通用的简单 (value,count) 数组。
十几字节的最小 tile 因而成为很好的真实测试:一个列头为 01 00 02 00,表示一个逻辑 brick、两字节控制流;六字节压缩负载最终还原出 511 个零字和一个 0x00A8。这也说明“非零材料字”不能自动解释为“高密度实体”,两个字节要交给相应消费者。
原创列/brick 解码器已经在 149 个 tile、6,853 列上验证控制流与负载消费闭合:涉及 575,330 个逻辑 brick,其中 551,522 个为 uniform,23,808 次为压缩负载引用。对 7,734 份不同压缩负载实际还原了 3,959,808 个 u16 采样,零失败;uniform 只计数,没有为了统计而展开成巨型数组。受检范围包括全部小世界第 0 层,以及主 Solid/Fog 的七层样本;最大第 6 层 tile 采用有界列抽样。这是独立格式解码验证,尚非与游戏内存逐字对照。完整验证统计列出了样本哈希与范围。
注意版本差异
社区旧版场景格式说明提到 highLODData 和 lowLODData。这两个字段不在本文样本的 VoxelWorldResource 反射定义中。研究格式时,应让当前版本的类型表与实际字节优先于旧字段名。6
4.3CPU 位移数据是另一份输入
主 Solid 描述符还引用了一块 cpuDisplacement 内容。它的结构已经能逐条核验:前 1,024 字节是 256 个小端 u32 偏移槽;非零槽指向记录,相同偏移可以重复使用。本例有 214 个非零槽、123 条唯一记录,每条记录都精确满足:
float32 parameter[3]
uint32 width, height
uint8 samples[width * height]全部 123 条记录的尺寸闭合。一个例子是三个 float 约为 (0.04,0.5,0.5),宽高均为 50,记录长 20+50×50=2520 字节。继续追到碰撞顶点函数 FUN_140e87e50,这些参数终于有了消费者:三者分别用于 UV 频率、位移幅度和样本偏置,位移量为 (byte/255-bias)×amplitude,沿局部法线加到位置上。45
这条路径以 2×materialId 选顶面槽,以 2×materialId+1 选侧面槽;槽必须小于 256,且偏移非零。法线 Y 分量不超过约 -0.707 时跳过;大于等于约 0.707 使用顶面投影,其余使用按主方向选择与翻转的侧面投影。寻址是重复 UV 下的最近点字节采样。由此可以确认这个消费者中的 128 对顶面/侧面槽与三参数用途,但仍不能把局部材料 ID 直接映射为全局 GUID 槽,也不能宣称所有渲染分支都使用相同的位移公式。独立位移参考代码将这些边界单独列出。
5体素数据怎样成为可见几何?
这次沿 Vertex index (16bit) overflow in voxel mesh! 的代码引用,定位到 FUN_1401a8540。它把一个三维单元的标量采样转换为一个顶点,并附上材料和其他属性。下面讨论的是本机样本的这条路径,尚不能覆盖地形、建筑、屋顶及全部 LOD 的生成方式。5
5.1输入标量场也经过了塑形
上游 FUN_140e88d80 将打包的 u16 采样转换为 float 场。它使用高字节的数值,并非简单读取一个“占据/空”布尔值;低字节则在中心样本是否小于 128 的分支中参与塑形。完整邻域路径同时读取 个高字节的总和,以及六个轴向邻居的最小、最大值。5
设中心高字节归一化为 ,27 个归一化高字节的均值为 ,六个轴向邻居的最小、最大值为 。忽略 float 的逐步舍入,可将规则重述为:
先取 ;若中心低字节小于 128,再令 。随后按 混合出 ,最后在 时取 ,否则取 。
这是一套受邻域与材料类别影响的标量塑形,不能等价替换成 0.5-d 或严格距离值。两个已查调用者分别提供材料维度比输出多 4、中心偏移 2,以及材料维度比输出多 4、中心偏移 3 的布局;后者的输出又比原请求尺寸多 3。维度必须连同 origin 一起处理,不能只按“多几格”分配数组。
而且进入转换器之前仍可能有预处理:已查的 FUN_1401a9470 路径将低字节为零的高字节清零;对低字节不小于 128 的样本,将高字节低四位扩展为 (d&15)×17,并可能与正 Y 邻居混合。不能把资源里所有高字节统一当成未经转换的 8 位密度。完整规则及分支边界见标量场调查明细。
边界 fallback 还有实际差异:它只遍历可用交集,均值仍固定除以 27,min/max 的奇偶选择也与完整邻域分支不同。两条已查调用链的 halo 足以走完整邻域,因此原创标量场模型明确只实现完整 halo 路径,并拒绝越界输入。其 838 项断言包括预处理类别边界,以及打包高字节阶跃经过转换器、再进入局部连接模型的端到端平面检查。
5.2单元里的顶点,放在哪里?
函数先读取立方体八角的 float。角编号的三个位分别对应 ,再遍历一个包含十二对端点的边表。只有一端小于零、另一端不小于零的边参与计算。设端点位置为 ,采样为 ,零交点为
把所有交点平均,便得到单元内位置 。写入输出时,再加单元坐标 ,整体减去半格:
这里的单位是采样网格单位,不能直接解释为米。函数自身没有处理“十二条边都不跨零”的情况;调用者需要保证进入的是有效表面单元。零恰好属于非负一侧,因此也不能把符号测试改成两端乘积严格小于零。
这条局部顶点放置规则与 Mikola Lysenko 在 2012 年展示的 naive surface nets 做法相符:取立方体各边零交点的平均位置。7 下文继续核对相邻单元的连接;分块、跨 LOD 交界和上游场生成仍有缺口,不能仅凭局部规则给整个引擎定名。
同样,能找零交点,只证明这里使用了有符号标量场。严格 SDF 还要求数值表达到表面的距离;本函数没有证明这个性质,不能把 float 场直接称作距离场。
5.3方向向量与边界位置修正
函数继续从八个角点计算
代数上,它是三线性插值场在单元中心梯度的两倍。随后 FUN_1400b8200 将它归一化为法线 :若平方长度不超过约 ,直接返回零向量,否则除以长度。结果既写入顶点记录,也参与材料加权。即使单元内存在零交叉,中心梯度仍可能抵消为零,因此这个回退分支值得在重实现时保留。5
若单元落在采样区域最外一层,且边界开关打开,函数还执行 。这是沿向量整体移动,不是只压低某一个坐标轴。它可能服务于分块衔接,但目前不能直接认作 LOD 裙边。
5.4四个单元怎样连接成一个面?
继续追到 FUN_1401af120,连接规则也可以具体写出来:扫描采样点的三条正向格边;遇到符号改变,就取围绕该格边的四个单元,通过前面的缓存取得四个顶点。下表列出以 为起点时的单元坐标偏移。5
| 格边方向 | ||||
|---|---|---|---|---|
| X | (0,0,0) | (0,0,-1) | (0,-1,0) | (0,-1,-1) |
| Y | (0,0,0) | (-1,0,0) | (0,0,-1) | (-1,0,-1) |
| Z | (-1,0,0) | (0,0,0) | (-1,-1,0) | (0,-1,0) |
输出器 FUN_1401a8480 每次追加四个 u16。格边起点标量为负时,顺序是 [v0,v1,v3,v2];非负时则是 [v2,v3,v1,v0]。这证明了符号决定绕序的四索引面,但没有在这里发生三角化,也没有选择四边形的对角线。渲染器如何展开它,仍需后续消费者。
扫描还有明确的边界条件:沿格边方向必须满足 coordinate+2 < dimension,另外两个坐标必须大于零。这为四个相邻单元留下空间。宽材料邻域则通过独立的整数偏移与更大的材料场对齐;几何边界、材料 halo 和资源 tile 的边界不能混为一谈。
下面是已核对的局部直接调用关系,地址均绑定本文样本:
驱动函数可以只扫描指定分区。分区坐标 在每轴映射为半开区间 。这里的 16 是扫描范围的划分规则,没有证明 chunk 固定为 个体素。重复传入分区也不见去重或事务回滚,重实现应由调度层负责这些约束。
5.5材料:8 或 64 个样本,最后保留三个 ID
几何与方向始终使用八个标量角点;另一个开关改变的是材料邻域:八样本模式取每轴 ,六十四样本模式取每轴 。后者要求调用者提供邻域边界数据,不能仅扩大循环便读取数组外的内容。5
每个材料样本在这条路径上读取为两字节:低字节在 1..127 且高字节非零,才参与候选统计;高字节的大小不直接缩放权重。对邻域位置 ,贡献为
因此权重来自沿方向向量的投影,而非单纯的欧氏距离。即使钳制结果为零,仍贡献 1。边界位置后移没有改变这里使用的原始单元内位置 。
这里还有一个需要继续追的映射问题:资源里有 256 个 GUID 槽,主世界其中 198 个非空;这条局部路径却只把低字节 1..127 当作普通材料候选。因此不能把材料样本的低字节直接当成全局 GUID 槽号,尚需确认上游是否重映射、分类或使用局部表。
统计器只有十六个槽。已有 ID 累加,新 ID 只有在单次贡献超过末槽累计值时才能替换它;每次更新后按累计权重排序,平手保持已有次序。八邻域不会填满十六种材料,六十四邻域则可能丢弃候选。因此它是容量受限的流式统计,不能等价改写成完整直方图再取前三。
最终输出前三个材料 ID,但只显式存第二、第三名的量化权重:。分母 包含所有有效样本的贡献,包括未进入前三、甚至已被十六槽淘汰的候选。它没有对前三重新归一化;主材质是否由剩余权重恢复,还要检查消费者。
52 字节记录与缓存的实际边界
本函数追加的 CPU 记录步长为 0x34,可以恢复出以下写入布局;它不等于所有 GPU 顶点的最终格式。5
| 偏移 | 写入内容 |
|---|---|
0x00–0x08 | 位置 float3 |
0x0C–0x14 | 处理后的方向 float3 |
0x18–0x1B | 第二、第三材料 ID 及各自的 8 位权重 |
0x1C–0x28 | 四个 float 辅助属性,部分语义仍未知 |
0x2C–0x33 | 第一材料 ID 零扩展为 64 位 |
辅助属性中,一项是输入字节除以 255;另一项由邻域里低字节不小于 128 的样本数计算:。没有消费者证据时,不应直接把它们命名为 AO、光照或湿度。
顶点缓存按单元坐标寻址,0xFFFF 表示尚未创建。合法索引为 0..65534,因此这个输出上下文最多容纳 65,535 条记录。它不是整款游戏的顶点总上限。缓存还意味着同一单元再次请求时直接复用第一次的结果;重实现时,需要把缓存生命周期与采样、材料及边界参数保持一致。
5.7从 CPU 工作记录到 GPU 声明
继续沿 FUN_1401b4de0,已经能把前面的 52 字节 CPU 记录接到一条实际压缩路径:其 mode 0 输出每顶点 12 字节、三个 u32。位置按 round(p×128+2048) 量化并钳制到 14 位;法线先按 L1 长度归一、折叠下半球,再量化为两个 6 位分量。两个辅助 float 的正值测试变成标志位。
第三个 u32 存三个 7 位材料 ID、第二/第三材料的各 3 位权重,以及一个 3 位辅助值。权重只保留前述 8 位量化值的高三位;辅助值来自 round(clamp(record[0x28],0,1)×4)。这里的材质损失与前面的十六槽淘汰是两次不同的压缩,重实现不能只复刻其中一次。
同一消费者同时保留四边形索引,并确定拆出 (a,b,c)、(a,c,d),所以这条路径的对角线为 a–c。下游 chunklet 按最多 32 个三角形分组,对应最多 16 个四边形;它计算点均值中心、最大点距半径,以及面法线的聚合值。具体位布局与去重位图的边界放在消费者调查明细,不把 mode 0 推广为所有网格模式。5
另一条先前定位的路径 FUN_140989460 引用 [mesh_builder] Couldn't allocate ... merged voxel rects。它先扫描字节网格的 X 向连续区间,再合并 X 范围相同的相邻 Y 记录,随后合并 XY 范围相同的相邻 Z 记录;六个 float 实际按 xmin,xmax,ymin,ymax,zmin,zmax 组织,最后连同总边界交给 FUN_1409882f0。这已经比诊断字符串更具体,是一条轴对齐盒合并路径。其用途仍要追消费者,不能将它直接认作前面可见表面的网格化器。5
VoxelChunkShaderData 的元数据明确带有 IS_GPU_STORAGE 和 gpu_storage 标记,反射声明大小为 192 字节。这比“名字里有 Shader”更具体,说明它是一份 GPU storage 数据合同;实际帧中的绑定、索引单位与缓冲格式仍需消费者核对:
| 字段组 | 已观察的字段 |
|---|---|
| 三角形 | triangleIndexOffset、triangleIndexCount |
| 四边形 | quadIndexOffset、quadIndexCount |
| 顶点与材质 | vertexOffset、bakedTriangleMaterialDataOffset |
| 小块与可见性 | chunkletOffset、chunkletCount、chunkletVisibilityOffset |
| 选择信息 | lodLevel、flags、pipelineIndices |
VoxelChunklet 本身的反射大小为 32 字节,含四浮点的 sphere 与 normal 字段。EXE 中还可找到 chunklet_culling、早期/后期剔除和前缀和绘制参数等名称。这些线索支持“几何被细分并参与剔除和绘制准备”的判断;各阶段 buffer 的布局与提交顺序尚未通过帧捕获验证。
扩大 GPU 类型核对后,还有几项值得用于自己的架构草图。BrickData 是 16 字节 uint4,BrickUploadInstanceData 是 4 字节索引加一条 BrickData,总计 20 字节;BrickJob 则用 12 字节记录 atlas 索引、staging 偏移与 level。BrickCullingShaderParameters 分别声明建筑和地形的 subdivision 参数、occupation grid 偏移及 chunklet bounds。它们确认了 GPU 声明中的职责拆分,不能据此把磁盘列、渲染 brick 和 CPU 网格区块当成同一种空间单元。完整的 33 个反射类型摘录保留了 flags、attributes、大小、对齐与字段偏移。
材质烘焙也有可以继续追查的连接点:vertex 与 triangle 两组烘焙参数都包含 perVertexBakingDataOffset,而 bakedTriangleMaterialDataOffset 同时出现在 triangle 参数和 VoxelChunkShaderData 中。这支持把局部表面构建与渲染材质准备分开研究;尚未恢复实际调度顺序或最终位格式。
此图仍是职责概念图。前面的局部 CPU 链已经恢复四边形拆分、顶点打包与 chunklet 分组;图中的 GPU 剔除、材质烘焙和最终提交顺序尚需进一步验证。EXE 中存在 SDF 烘焙相关名称,也不等于原始体素或用于表面生成的 float 场就是严格 SDF。
最终画面还取决于材质、光照和大气。Keen 的更新记录明确提到 terrain material blending 的修复、动态 GI probes 对光照变化的响应,以及新的体素压缩方法。8 这些属于公开的系统能力;本文没有把它们推成未经验证的 Mesh 算法。
6可编辑地形怎样参与物理?
本机 EXE 中有 Jolt 的类型和诊断字符串,反射表也有 JoltObjectSetup 与 ColliderResourceData.joltObjects。这确认了 Jolt 集成;它本身还不能证明每一种物理对象都经过同一条路径。
更有价值的是体素专用的 VoxelWorldShape。EXE 中的 VoxelWorldShape::CollidePoint not implemented 文本可定位一段短函数,再由 .rdata 中指向它的虚表项找到邻近入口:
| 本机静态地址 | 依据 Jolt 接口顺序判断的入口 | 当前观察 |
|---|---|---|
0x14001E280 | collector 式射线入口 | 函数体通过回调提交命中 |
0x14001E870 | 最近命中式射线入口 | 更新 fraction 与子形状标识,返回布尔结果 |
0x14001ED60 | CollidePoint | 带“未实现”诊断的短函数 |
0x14001EDA0 | CollideSoftBodyVertices | 带“未实现”诊断的短函数 |
射线入口的身份由函数体中的回调、返回值和命中更新核对;其余槽位结合诊断文本与 Jolt 的 Shape 虚函数声明顺序判断。这些不是从游戏 PDB 读出的源码函数名;游戏使用的 Jolt 具体版本与编译选项仍待核实。CollidePoint 的短函数也不能推广为“整个游戏不支持点查询”。
进一步反编译 0x14001E280,可以越过“有函数体”这一层:它把射线划成长度不超过 4.0 的局部段,为各段构造 AABB,再调用 FUN_140e85ef0 获取候选。候选列表的记录步长为 92 字节,前三个 float3 是三角形顶点;入口逐条执行射线与三角形求交,并通过收集器回调提交命中。记录中的一个 u16 与一个字节还被拼成 24 位子形状标识。这里的长度属于 Shape 局部坐标,不能直接改写成米。5
沿候选提供者继续追到 FUN_140e82b30,代理的来源也能回答一部分:有锁保护的局部碰撞网格缓存,命中时复制缓存中的顶点和四边形,未命中时现场生成。构建路径请求 12×12×12 个 u16 采样,再构造 10×10×10 的浮点场;碰撞顶点使用与可见局部路径相同的八角/十二边交点均值和中心梯度,并增加上述 CPU 位移采样。
缓存的碰撞顶点步长为 28 字节,即位置、法线与一个位移标量;四边形记录为 10 字节,含四个 u16 索引及标签。提供者明确将 (a,b,c,d) 拆为 (a,b,c)、(a,c,d),生成前述 92 字节三角形代理。于是这里已经能确认按需生成、缓存四边形网格,再拆三角形供查询;缓存何时因编辑失效、是否与渲染任务同步,仍需继续追踪。
反射表还出现 WorldCollision:它列有三角形批次、顶点位置、材质 ID 和 BVH 树数据。不过本轮没有从 KFC 提取到该类型的顶层资源描述符,也没有数据流将它接到上述候选列表,不能把它直接认作当前的体素碰撞代理。
从游戏行为看,地形修改后可见表面与角色碰撞必须保持一致;这是一项工程约束,并不说明引擎具体如何更新碰撞。Keen 的更新日志中,角色被地形碰撞卡住、隔着实体地面与梯子交互等修复,展示了动态世界在边界情况下的复杂性。9
7一次世界修改会牵动什么?
反射类型 VoxelWorldTerraAction 至少记录 position: float3、radius: float 和 mode。名称与字段适合描述一次局部地形操作;目前没有调用链证明玩家的每一种挖掘或建造行为都直接使用它。
如果局部世界状态变化,系统需要重新回答几组问题:
这些箭头表达一致性要求,并不表示本机已经恢复出统一事件总线或固定更新顺序。可编辑世界的难点正在这里:单独把一个体素写入数组很容易;让玩家接下来看到、撞到、保存到的都是同一处世界,则需要一整套协调机制。
公开更新记录可作为这种耦合的侧面例子:Keen 修复过建筑与地形体素交界的显示问题,也改进过动态 GI、室内外环境检测以及与地形碰撞有关的交互。89 这些修复不能反向证明具体代码架构,却说明世界几何变化并非 Renderer 独自处理的小功能。
8这次逆向究竟告诉了我们什么?
目前证据已经把讨论从“它也许用了某种体素技术”推进到更清晰的边界:
| 可以确认或直接观察 | 还不能确认 |
|---|---|
| 七层 tile 与逐层翻倍的采样关系、边缘裁剪 | 运行时选层距离、过渡与世界单位 |
| 1,035 个 tile 的索引;149 个 tile 中列/brick 的解码验证 | 全部版本与异常输入的兼容、全局材料映射 |
| 标量塑形、交点均值、法线、材质、四索引连接与三角化 | 全部网格模式、跨 LOD 接缝与误差控制 |
| mode 0 的 52→12 字节打包及 chunklet 分组 | shader 解码、GPU 剔除与实际帧提交 |
| Jolt 集成、按需生成并缓存碰撞网格、三角形射线测试 | 编辑后的缓存失效、碰撞与渲染的更新同步 |
| 创作资源包含 brush 与 content pass | 编辑器 UI 和完整构建执行链 |
从发布版 EXE 可以恢复类型声明、资源数据和部分函数行为,却不能恢复原始 C++ 工程:本机没有对应 PDB,编译优化也不会保留所有源码结构。本文所有数字和地址都绑定到开头记录的样本版本。
8.1怎样为独立重实现做铺垫?
本轮已经提供单元顶点参考实现与局部连接参考实现。这是根据静态行为独立写出的数学模型,不是恢复的 Keen 源码。548 项单元检查与 72 项拓扑检查覆盖了二值符号组合、零梯度、材料淘汰、缓存容量、三轴平面、符号反转及 4,096 个扫描分区的覆盖一致性。球面与带内腔的壳体还检查了闭合边关联和 Euler 特征。模型主要采用 JavaScript 双精度,不能据此宣称与 SSE 路径逐位相同,也尚未与游戏运行结果对照。
可按下面的依赖顺序推进自己的原型,每一步都留下可替换的接口:
| 模块 | 现在能使用的约束 | 下一步验证目标 |
|---|---|---|
| 世界读取 | u24 索引、列控制流、uniform 与双平面压缩、纵向边界 | 更广泛版本与损坏输入;局部材料表映射 |
| 采样适配 | 类别预处理、27 样本塑形、材料 halo、碰撞位移采样 | 所有模式与地形/建筑交界,渲染位移的消费者 |
| 局部表面 | 八角零交点均值、中心梯度、缓存、四索引连接 | 与游戏运行输出对照、退化单元及跨 LOD 接缝 |
| 渲染准备 | 52→12 字节顶点、固定对角线拆面、32 三角形 chunklet | shader 解码、材质烘焙与 GPU 调度 |
| 碰撞与修改 | 射线入口消费三角形代理 | 代理生成、脏区传播、渲染/碰撞/存档的一致更新 |
第一版可以先用自己构造的标量场验证局部表面模块,再接已恢复的列解码与塑形规则;随后加入位移、压缩顶点和碰撞缓存。这样每一步都有可以独立检查的输入输出,也不会把未知部分硬塞进一个未经验证的 SDF 假设。后续最值得沿着追的地址、证据范围和复现方式集中在调查附件说明。
对自己的沙盒项目而言,可以先问玩家与设计师究竟需要怎样改变世界:能否挖穿山体、修改的最小范围、是否永久保存、哪些系统需要立即响应。再选择体素结构、分块方式与表面算法。Keen 的案例最值得借鉴的,是把世界视为可持续编辑的数据和创作介质;渲染、物理与工具都围绕这件事工作。
注释
-
Keen Games,Enshrouded FAQ。官方确认 Holistic Engine,并说明地图制作结合程序化工具与设计师手工工作。 ↩
-
Keen Games 开发人员 Toast,关于 Holistic Engine 与方形体素网格的 Steam 回复,2026-10-21 核对。开发人员确认世界依托方形体素网格,并指出外界对引擎内部的若干算法猜测不准确;该回复不是完整技术文档。 ↩
-
Keen Games,World Design Team Reddit AMA Recap,2024 年。团队解释了粗模、sub-biome、POI subscene、voxel stamp、Whiteboxes,以及建筑形状与 snapping 工作流。 ↩ ↩2
-
GPT-6,2026-10-21 资源样本统计与格式边界。记录 135 个描述符、1,035 个 tile、764,841 列的索引检查、层级尺度、旧样本哈希复核、CPU 位移容器,以及后续 149 个 tile 的独立列/brick 解码验证范围。 ↩ ↩2 ↩3
-
GPT-6,2026-10-21 本机静态调查摘要。记录样本哈希、函数地址、REA 证据编号与局部 Mesh 行为;数学重述和合成验证来自本轮调查,不是 Keen 的原始源码或运行时测量。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
Brabb3l,kfc-parser 场景资源说明。这是社区格式文档;其中旧字段名需与本文样本的当前反射表对照。 ↩
-
Mikola Lysenko,Smooth Voxel Terrain (Part 2),2012。作者说明 naive surface nets 的单元顶点取各边零交点均值;此处仅用于比较局部规则,不作为《Enshrouded》采用整套公开算法的来源。 ↩
-
Keen Games,Souls of the Frozen Frontier Update,2024 年。更新日志记录了材质混合、动态 GI probes、地形与建筑体素交界问题,以及体素压缩方法。 ↩ ↩2
-
Keen Games,Forging the Path Update,2026 年。更新日志记录了地形碰撞、角色或交互卡入几何体、环境检测等修复案例。 ↩ ↩2
CONTINUE EXPLORING
评论与交流
在 GitHub 查看讨论欢迎补充、提问,或分享不同的看法。使用 GitHub 账号登录后即可评论。