VoxelDig体素地形项目
开发记录 · 地形

林间岩谷:从一块能挖的岩壁开始

山坡、洞穴和挖掘后的缺口,都是同一份地形数据生成的。这篇记录地图怎么搭起来、过程中修了什么,以及现在离 120 Hz 还差多少。

UE 5.7.4
地图已经能挖填,120 Hz 还没过关。

目前最需要改的是网格安装、大笔刷和 HUD。下面把做法和实测数据放在一起看。

先做一张能挖的山谷

这张图先解决一件事:让山坡、岩壁和洞穴连成一块可以编辑的地形。走到岩壁前挖掉一块,缺口要有表面,也要有碰撞;填回去、整平、撤销和存读档也要能接着用。

第一版取了约 64 × 64 m 的范围,中央约 41 m 可编辑,近处按 25 cm 采样。场景里放了缓坡、岩台、一条洞穴通道和洞室,再用简单树草看整体比例。

UE 中林间岩谷的山坡、岩台、洞口和占位树草
UE 实际运行画面。截图时设了 120 FPS 上限,性能测试则不限帧。

山体形状和材质都是在这个工程里写的,地图不依赖原作的地图文件或贴图。底层复用了前面逆向对齐后接入的部分数据、网格和调度逻辑;完整原作引擎的还原还远没有完成。

关卡入口是 /Game/Maps/TerrainStudy。进入游戏后,BeginPlay 才会生成地形、景物和灯光,所以只在编辑器里打开关卡,看不到完整场景。

代码放在哪里

地图的形状、地形数据和 UE 显示分别处理。改山谷轮廓时改生成器;改挖掘和存档时看数据层;卡在网格安装或碰撞时,再看调度器和 UE 组件。

文件 / 模块主要工作
TerrainStudy.cpp定义山体和洞穴,摆放景物,校正初始落点。
TerrainStudyWorld.cpp把生成结果写入多级数据,读写和检查基底缓存。
FField / FOriginalWorld画笔编辑、世界快照、父层更新、存档和变化通知。
FDigOriginalScheduler相机 LOD、重建任务、结果安装和碰撞完成后的切换。
TerrainWorkerInput / MeshNode读取邻域密度,过滤并生成平滑网格。
DigGame.cpp / DigTerrain.cpp角色、工具、HUD,以及地形系统与 UE 的连接。
bootstrap_study.py / study_terrain.hlsl生成关卡和材质资产,混合草、土、石的颜色。
TerrainStudyTests / TerrainStudyCapture检查数据和存档,验证挖掘、撤销并截图。
DigBenchmark / Run-TerrainStudyBudget.ps1按固定输入运行测试,记录耗时、内存和更新延迟。

山体和洞穴怎样生成

先用几组高斯形状拉出山脊、两侧坡地和谷地,叠加两档 Perlin 噪声,得到高度 H(x,y)。再用 D = z − H(x,y) 把高度变成三维标量场:负值是固体,正值是空气。

岩台用带轻微扰动的圆角盒并入山体。洞穴则分成胶囊形通道和椭球形洞室,从固体里减掉。最后处理地图四周和底部的边界。组合方式类似 SDF,但叠加高度和噪声后,D 并不处处等于到表面的真实距离。

// 构形示意,省略边界和噪声参数
D = z - Height(x, y);
D = min(D, RoundedRock(p));
Cave = min(CapsuleTunnel(p), Chamber(p));
D = max(D, -Cave);

这样生成的洞口、顶面和侧壁,跟后来用画笔挖出的表面一样,都由密度生成网格和碰撞。洞穴可以继续往里挖,周围的山体也可以填回去。

地形保存的是密度

网格只负责显示,真正保存下来的是密度和材料。本图最细一层有 256³ 个逻辑采样点,每点用 16 位:低 8 位存材料类别,高 8 位存占据量。生成器算出的 D 会被量化,不会长期为每个点保存一个浮点距离。

occupancy = clamp(round(127.5 - D * 255 / (2 * Cell)), 0, 255)
word = occupancy ? ((occupancy << 8) | materialId) : 0
Cell = 25 cm; materialId = 86

构建时先算好 256² 个高度,同一列向下采样时直接复用。数据按 8³ 样本块写入,跳过空块,再更新父层。总共有七级数据,当前显示用 Level 4~6;细级网格任务一次处理的核心边长是 32 个样本,约 8 m,和数据块的大小不同。

初始地形缓存到 Saved/Generated/Study/base-53545501.bin,压缩后约 1.03 MiB。文件头带版本和 CRC,损坏或版本不符就重新生成。改了地形公式,要同时更新 SourceVersion,否则会继续读到旧山体。运行时的快照、网格和碰撞还会另外占内存。

进程内保留一份不可变基底,每次建图取独立的世界副本。节点共享,修改时再复制;后台生成网格的任务读固定快照,因此玩家继续挖地也不会改掉任务正在读取的数据。

玩家修改另存为 Saved/Terrain/study.vdr。它有独立的存档 profile,读取时会检查,避免与原有对齐场景或 Legacy 场景混用。

挖一镐之后发生什么

  1. 输入与画笔相机射线命中地形,确定中心、半径和工具。
  2. 数据编辑新快照修改局部密度与材料,完成受影响父层更新。
  3. 失效通知携带变化范围与版本,使依赖节点重新排队。
  4. 后台生成Worker 读固定快照,生成平滑网格与发布数据。
  5. 分帧安装主线程建立 UE 网格资源,启动异步 Chaos 碰撞。
  6. 节点发布版本有效且碰撞就绪后切换可见资源,释放旧资源。

普通挖填只重建受影响的节点,不过范围要算上采样邻域。Level 6 的核心边长为 32,密度窗口从 P−2 开始取 36³,细级原始输入从 P−4 开始取 40³。多出来的样本用于过滤、法线和边界计算;漏掉它们,就可能留下没有更新的边缘。

网格生成可归为 Surface Nets 一类:先处理材料密度,做 3³ 过滤,在正负变化的边上求交点,再形成单元顶点、法线和四边形,最后处理边界标记并打包。这里用这个名字说明算法类型。

后台最多同时挂 16 个任务。完成后先核对版本,旧任务的结果直接丢弃。每个节点等新网格和碰撞都准备好,再换掉旧资源,因此连续挖掘时仍有旧地形可显示、可碰撞,整笔修改会分节点逐步出现。

UE 这一侧用 UProceduralMeshComponent,复杂网格碰撞交给 Chaos 异步烹饪。麻烦在于创建组件和网格截面仍要占主线程。现在按 2 ms 软限制安排上传,但已经开始的一次安装没法中途停下;这也是实测超过 0.50 ms 分配时间的主要待查环节。

撤销能直接取回历史快照,随后仍要完整重建显示和碰撞。这条路径还需要继续缩小重建范围。

挖掘前的岩壁
同一测试视点:挖掘前。
岩壁被实际挖出圆形凹洞
挖掘后:密度与显示网格发生变化;自动检查随后验证撤销恢复。

把白模变成现在的画面

地形材质按世界位置和法线取色:缓坡偏草,陡面露石,位置和高度控制土路、石层,粗糙度再做一点变化。挖出新表面后,材质能直接接上,不用重新铺 UV。目前这些层次主要由数学函数生成,离参考 Kena 的正式材质还有距离。

布景先用了 18 个树干、72 个球形树冠、558 丛交叉面草和 30 个散石,按四类 HISM 分组。草在约 38 m 外剔除,也不投影。景物暂时没有碰撞和导航影响,先用来观察构图和基础渲染开销。

直接按生成器的高度摆树,会和实际 LOD 网格有偏差,出现树草悬空。现在等初始碰撞完成,再把树根、草和石头向地表投射一次,树冠跟着树根移动。挖掉树下的土之后该倒树、掉落还是移除,这部分还没做。

出图时还碰到过画面发黑和实例退回默认材质的问题。前者关闭物理相机曝光换算、固定手动曝光;后者补上材质的 instanced static mesh 用途标记。背光岩壁太暗,又调整了太阳方向和天空光。最终用的是方向光、三层级联阴影、环境立方体天空光、渐变天空和高度雾。

当前没有开启 Lumen,树草也没有 Pivot Painter 风动。水、体积烟雾和天气云还没接入,后面的耗时表只覆盖眼前这张基础地图。

哪些地方省了工作

底层已经有压缩节点、七级数据和共享快照。普通编辑只使相关节点失效,后台任务按版本丢弃过期结果;配合 16 个在途任务上限和异步碰撞,避免主线程等整批地形一起算完。

搭这张图时,另外做了几处减少重复工作的处理:

处理省在哪里
预计算 256² 高度同一列只算一次山体高度,再沿 z 方向生成密度。
按块跳过空区域只写非空的 8³ 样本块。
缓存带版本的初始地形后续启动读取压缩基底,省去重复构形。
HISM、草的距离剔除和关闭投影减少占位景物的提交与阴影工作。
世界坐标材质编辑地形后直接使用新顶点的位置和法线,无需重新铺 UV。

这些处理还没有逐项做开关对照,因此暂时没有可靠的单项加速比例。

还有两处问题,只是先让它能看、能挖

混合 LOD 的交界目前有接缝。为了先检查这张图的整体效果,近场阈值从 32 / 128 / 256 放宽到 256 / 512 / 1024,让当前视点更多地使用细网格。接缝算法还没修好,这样做反而可能增加几何和提交开销;原有对齐场景保留原阈值。

另一处是岩壁几乎挖不动。最初材料工作系数设为 1,而 Remove 内核每笔预算也是 1,一次操作基本只够处理一个体素。确认系数表示挖掉完整体素所需的工作量后,本图改成了 0.0005,才得到可见的挖掘体积。这里只调整了本图材料参数,原有内核没改,也还没有核对原作材料值。

测试怎么跑

测试机是 i9-14900KF、RTX 5070 12 GB、64 GB 内存。使用 UE 5.7.4 Development Editor 的 -game 模式、D3D12 SM5,关闭 VSync 和帧率上限,不开帧生成。

这次按要求保留 Overwatch 运行,地形测试在后台离屏渲染,不占前台键鼠。因此记录的是引擎产帧耗时;后台游戏负载会变化,三轮数据也只能分别看,不能当作严格的分辨率对照。

每轮从初始地形开始,依次跑静止、小笔刷、大笔刷和绕场镜头。画笔固定在岩壁 (1150,100,650) cm:0.75 m 半径每 0.34 s 挖填一次,2.5 m 半径每 0.12 s 一次,挖和填交替,避免挖空之后工作量越来越小。同一轮的阶段之间不重置地形。

1080p 每阶段 30 s,宽屏每阶段 20 s,开始前都预热 10 s。统计只裁掉阶段两端各 0.5 s,中间的长帧保留。自动镜头没有覆盖步行查询、画笔预览圈和正式刚体压力。

记录包括帧间隔、Game / Render / RHI 区间、已标记的工作耗时、两种 GPU 总时间及各 pass、编辑活动帧、后台任务和内存。并行任务会重叠,各线程耗时和各自的 P95 不能直接相加。

采样器本身也出过一次问题:第一轮 67% 分辨率测试在镜头阶段扩容了记录数组,制造了额外尖峰。后来在预热前预留全部四阶段缓冲,重新跑了一轮,下表用的是复测结果。另一次原生宽屏预备采样为了避开分析脚本的 CPU 占用而中止,也没有计入。

实际花了多少时间

120 Hz 每帧只有 8.333 ms,还要给后续玩法和效果留时间。1080p 这轮最慢阶段的帧 P95 是 8.076 ms,P99 是 9.389 ms:多数帧接近目标,但长帧和部分子系统仍然超预算。

P95 表示 95% 的样本不超过这个耗时,P99 则看更靠后的长帧。挖掘和上传只在实际发生工作时统计,避免被空闲帧摊低。

项目实测预算结果
整帧 · 最差阶段 P95 / P998.076 / 9.389均 ≤ 8.333 msP99 超限
主线程总量 · 最差阶段 P952.562 ms6.00 ms低于预算
渲染线程已记录工作 P952.777 ms3.00 ms余量小;非完整执行周期
同步密度修改 · 小 / 大笔刷 P950.249 / 0.577 ms编辑与调度合计 0.45 ms大笔刷单这一项已超配
上传发布 · 活动帧 P951.061 / 1.892 ms0.50 ms超配
调试 UI / HUD · 最差阶段 P950.355 ms0.25 ms超配
GPU 总时间 · CSV 最差阶段 P954.636 ms7.00 ms低于预算
BasePass · 最差阶段 P952.924 ms地形 1.60 + 其他 0.70 ms合计存在超限,尚未独立隔离地形
更新组稳定 · 小 / 大笔刷 P9540.770 / 43.567 ms50.00 ms低于延迟目标

渲染线程的全局区间里有较长的 Visibility 等待,所以表里另列了已记录的工作耗时。它还不是完整执行周期,RHI 的纯工作也没有拆清,暂时无法判断 1.50 ms 的 RHI 预算。BasePass 则混着地形、天空网格和树草,需要分别关闭后再测,才能知道各自占多少。

三档设置的逐阶段结果

下表单位均为 ms。GPU 同时保留 RHI 和 CSV 记录,两者统计结果有差异,需要一起看。

1920 × 1080 · 100%

阶段帧 P95帧 P99最大帧GPU RHI P95GPU CSV P95
静止俯瞰6.0568.3739.3421.8682.330
0.75 m 连续挖填7.9828.88214.6641.9034.264
2.5 m 连续挖填7.9149.15210.6401.8604.410
绕场移动镜头8.0769.38916.4542.0144.636

3440 × 1440 · 100%

阶段帧 P95帧 P99最大帧GPU RHI P95GPU CSV P95
静止俯瞰10.34210.98312.21510.02210.036
0.75 m 连续挖填10.63711.18312.04710.42210.436
2.5 m 连续挖填9.93810.57615.7469.7309.747
绕场移动镜头7.0147.34310.3026.6836.683

3440 × 1440 · 67%

阶段帧 P95帧 P99最大帧GPU RHI P95GPU CSV P95
静止俯瞰5.5496.1446.9822.9993.318
0.75 m 连续挖填8.8479.77710.9253.4044.935
2.5 m 连续挖填9.20110.19517.5865.2495.415
绕场移动镜头8.5109.46510.6774.9385.063

内存与显存

这里记录的是地形进程的低频采样峰值,包含启动和预热。单位为 GiB。

设置RAM 工作集私有提交进程专用显存
1920 × 1080 · 100%4.6545.2020.490
3440 × 1440 · 100%3.7584.5500.821
3440 × 1440 · 67%3.7594.2960.689

三项预算分别是 16 / 20 / 9 GiB,目前都有余量。前面提到的 1.03 MiB 缓存只是一份压缩文件,运行时还要算上世界数据、快照、组件、网格和碰撞。

表里的“更新组稳定”从编辑开始,一直等到相关节点完成排队、生成、安装和碰撞准备。它是整段更新的延迟。首次看见缺口用了多久、碰撞何时生效,还需要单独加计时点。

下载测试数据 JSON ↓下载预算表 CSV ↓

目前验证到了哪里

地图接入后的 D3D12 自动化检查,结果是 54 项通过、0 失败、0 跳过。新增检查主要看洞内是否为空、岩壁是否为固体、挖掘是否改变密度,以及快照隔离、初始基底不被修改、存读档和父层更新是否正常。

运行时另外验证了射线命中岩壁、挖后密度变化、撤销后恢复,并留下对应截图。上面的挖掘截图直接用了 1.8 m 画笔,HUD 仍显示默认半径;正式性能测试用的是前面列出的 0.75 m 和 2.5 m。截图流程有等待和 PNG 写入,耗时没有混进性能结果。

当前范围仍是一张有限测试图。无限世界流送、联网、导航、混合 LOD 拼接、植被随地形变化和完整发行打包都还没完成。前面的 CPU 内核对齐也只覆盖部分逻辑,整款原作的算法、画面和性能还需要分别比较。

接下来先改哪几处

先处理已经超出分配时间的部分,再往场景里加效果:

  1. 网格安装与发布。 查组件和缓冲能否复用,能否把一次大安装拆小,再调整上传限制。目标是把更新活动帧 P95 压到 0.50 ms;单纯把 2 ms 限制调小,解决不了单次安装不可中断的问题。
  2. 大笔刷的同步部分。 分开记录快照、画笔和父层更新,找出主要耗时,让编辑与调度合计回到 0.45 ms 内。
  3. HUD。 缓存文字,降低统计信息刷新频率,把 UI 主线程 P95 压回 0.25 ms。
  4. LOD 接缝。 补齐混合层级拼接,收回放宽的近场阈值,用同一条相机路线比较画面、碰撞和几何开销。
  5. 正式场景效果。 逐项加入材质、风动植被、Lumen、水烟和玩法,每加一项保留前后测试,最后在独占机器上做组合与长时间测试。

水和烟准备复用地形的变化范围、版本及固体边界查询,各自维护水量、浓度和模拟状态。挖开洞口后排水、通风的联动还没开始实现。

在本地运行

在已有工程的工作区根目录执行,或双击 VoxelDig/PlayTerrainStudy.cmd:

.\VoxelDig\PlayTerrainStudy.ps1

默认 1920×1080、不限帧。宽屏入口:

.\VoxelDig\PlayTerrainStudy.ps1 -Width 3440 -Height 1440

采样使用一个新的输出目录。先保存并关闭其他 UE 实例;默认不允许并行游戏,只有明确需要带并行负载时才加 -AllowConcurrentGames:

.\tools\performance\Run-TerrainStudyBudget.ps1 -Width 1920 -Height 1080 -PhaseSeconds 30 -OutputDirectory analysis\new-study-run\1080p

生成资产的入口是 VoxelDig/Tools/bootstrap_study.py;数据检查在 TerrainStudyTests.cpp,画面检查开关为 -VoxelStudyCapture。更改场公式时更新版本,再运行构建与检查。

键位和其他原型入口见运行指南。这些命令用于已有本地工程,网站只提供文档和测试数据。