先做一张能挖的山谷
这张图先解决一件事:让山坡、岩壁和洞穴连成一块可以编辑的地形。走到岩壁前挖掉一块,缺口要有表面,也要有碰撞;填回去、整平、撤销和存读档也要能接着用。
第一版取了约 64 × 64 m 的范围,中央约 41 m 可编辑,近处按 25 cm 采样。场景里放了缓坡、岩台、一条洞穴通道和洞室,再用简单树草看整体比例。

山体形状和材质都是在这个工程里写的,地图不依赖原作的地图文件或贴图。底层复用了前面逆向对齐后接入的部分数据、网格和调度逻辑;完整原作引擎的还原还远没有完成。
关卡入口是 /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 场景混用。
挖一镐之后发生什么
- 输入与画笔相机射线命中地形,确定中心、半径和工具。
- 数据编辑新快照修改局部密度与材料,完成受影响父层更新。
- 失效通知携带变化范围与版本,使依赖节点重新排队。
- 后台生成Worker 读固定快照,生成平滑网格与发布数据。
- 分帧安装主线程建立 UE 网格资源,启动异步 Chaos 碰撞。
- 节点发布版本有效且碰撞就绪后切换可见资源,释放旧资源。
普通挖填只重建受影响的节点,不过范围要算上采样邻域。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 / P99 | 8.076 / 9.389 | 均 ≤ 8.333 ms | P99 超限 |
| 主线程总量 · 最差阶段 P95 | 2.562 ms | 6.00 ms | 低于预算 |
| 渲染线程已记录工作 P95 | 2.777 ms | 3.00 ms | 余量小;非完整执行周期 |
| 同步密度修改 · 小 / 大笔刷 P95 | 0.249 / 0.577 ms | 编辑与调度合计 0.45 ms | 大笔刷单这一项已超配 |
| 上传发布 · 活动帧 P95 | 1.061 / 1.892 ms | 0.50 ms | 超配 |
| 调试 UI / HUD · 最差阶段 P95 | 0.355 ms | 0.25 ms | 超配 |
| GPU 总时间 · CSV 最差阶段 P95 | 4.636 ms | 7.00 ms | 低于预算 |
| BasePass · 最差阶段 P95 | 2.924 ms | 地形 1.60 + 其他 0.70 ms | 合计存在超限,尚未独立隔离地形 |
| 更新组稳定 · 小 / 大笔刷 P95 | 40.770 / 43.567 ms | 50.00 ms | 低于延迟目标 |
渲染线程的全局区间里有较长的 Visibility 等待,所以表里另列了已记录的工作耗时。它还不是完整执行周期,RHI 的纯工作也没有拆清,暂时无法判断 1.50 ms 的 RHI 预算。BasePass 则混着地形、天空网格和树草,需要分别关闭后再测,才能知道各自占多少。
三档设置的逐阶段结果
下表单位均为 ms。GPU 同时保留 RHI 和 CSV 记录,两者统计结果有差异,需要一起看。
1920 × 1080 · 100%
| 阶段 | 帧 P95 | 帧 P99 | 最大帧 | GPU RHI P95 | GPU CSV P95 |
|---|---|---|---|---|---|
| 静止俯瞰 | 6.056 | 8.373 | 9.342 | 1.868 | 2.330 |
| 0.75 m 连续挖填 | 7.982 | 8.882 | 14.664 | 1.903 | 4.264 |
| 2.5 m 连续挖填 | 7.914 | 9.152 | 10.640 | 1.860 | 4.410 |
| 绕场移动镜头 | 8.076 | 9.389 | 16.454 | 2.014 | 4.636 |
3440 × 1440 · 100%
| 阶段 | 帧 P95 | 帧 P99 | 最大帧 | GPU RHI P95 | GPU CSV P95 |
|---|---|---|---|---|---|
| 静止俯瞰 | 10.342 | 10.983 | 12.215 | 10.022 | 10.036 |
| 0.75 m 连续挖填 | 10.637 | 11.183 | 12.047 | 10.422 | 10.436 |
| 2.5 m 连续挖填 | 9.938 | 10.576 | 15.746 | 9.730 | 9.747 |
| 绕场移动镜头 | 7.014 | 7.343 | 10.302 | 6.683 | 6.683 |
3440 × 1440 · 67%
| 阶段 | 帧 P95 | 帧 P99 | 最大帧 | GPU RHI P95 | GPU CSV P95 |
|---|---|---|---|---|---|
| 静止俯瞰 | 5.549 | 6.144 | 6.982 | 2.999 | 3.318 |
| 0.75 m 连续挖填 | 8.847 | 9.777 | 10.925 | 3.404 | 4.935 |
| 2.5 m 连续挖填 | 9.201 | 10.195 | 17.586 | 5.249 | 5.415 |
| 绕场移动镜头 | 8.510 | 9.465 | 10.677 | 4.938 | 5.063 |
内存与显存
这里记录的是地形进程的低频采样峰值,包含启动和预热。单位为 GiB。
| 设置 | RAM 工作集 | 私有提交 | 进程专用显存 |
|---|---|---|---|
| 1920 × 1080 · 100% | 4.654 | 5.202 | 0.490 |
| 3440 × 1440 · 100% | 3.758 | 4.550 | 0.821 |
| 3440 × 1440 · 67% | 3.759 | 4.296 | 0.689 |
三项预算分别是 16 / 20 / 9 GiB,目前都有余量。前面提到的 1.03 MiB 缓存只是一份压缩文件,运行时还要算上世界数据、快照、组件、网格和碰撞。
表里的“更新组稳定”从编辑开始,一直等到相关节点完成排队、生成、安装和碰撞准备。它是整段更新的延迟。首次看见缺口用了多久、碰撞何时生效,还需要单独加计时点。
目前验证到了哪里
地图接入后的 D3D12 自动化检查,结果是 54 项通过、0 失败、0 跳过。新增检查主要看洞内是否为空、岩壁是否为固体、挖掘是否改变密度,以及快照隔离、初始基底不被修改、存读档和父层更新是否正常。
运行时另外验证了射线命中岩壁、挖后密度变化、撤销后恢复,并留下对应截图。上面的挖掘截图直接用了 1.8 m 画笔,HUD 仍显示默认半径;正式性能测试用的是前面列出的 0.75 m 和 2.5 m。截图流程有等待和 PNG 写入,耗时没有混进性能结果。
当前范围仍是一张有限测试图。无限世界流送、联网、导航、混合 LOD 拼接、植被随地形变化和完整发行打包都还没完成。前面的 CPU 内核对齐也只覆盖部分逻辑,整款原作的算法、画面和性能还需要分别比较。
接下来先改哪几处
先处理已经超出分配时间的部分,再往场景里加效果:
- 网格安装与发布。 查组件和缓冲能否复用,能否把一次大安装拆小,再调整上传限制。目标是把更新活动帧 P95 压到 0.50 ms;单纯把 2 ms 限制调小,解决不了单次安装不可中断的问题。
- 大笔刷的同步部分。 分开记录快照、画笔和父层更新,找出主要耗时,让编辑与调度合计回到 0.45 ms 内。
- HUD。 缓存文字,降低统计信息刷新频率,把 UI 主线程 P95 压回 0.25 ms。
- LOD 接缝。 补齐混合层级拼接,收回放宽的近场阈值,用同一条相机路线比较画面、碰撞和几何开销。
- 正式场景效果。 逐项加入材质、风动植被、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。更改场公式时更新版本,再运行构建与检查。
键位和其他原型入口见运行指南。这些命令用于已有本地工程,网站只提供文档和测试数据。