UE5.8 World Partition 与 Grid 划分:把大世界切成可加载的方块

📑 目录

UE5.8 World Partition 与 Grid 划分:把大世界切成可加载的方块

想象一个场景:团队打算在 UE5.8 里做一片 20 km × 20 km 的开放世界。山脉、河流、村落和地下城全部塞进同一张持久关卡。打开编辑器时,所有模型、光照、地形和导航数据一次性涌入内存,加载界面漫长到足够泡一杯咖啡。美术摆完一座桥,保存时经常提示磁盘 I/O 忙;策划想看某个角落,却要在庞大的关卡里拖动视角很久。这种状态持续下去,制作进度会慢得让人想放弃。

测试组也遇到了麻烦。一名 QA 骑着摩托从南岸冲到北岸,刚到河中央,桥面突然闪了一下,随后才重新出现。策划在旁边看到了,直接站起来说这不是能接受的表现。问题很快被定位到关卡流送的边界:那座桥正好横跨两个子关卡,加载顺序没对上,导致玩家先看到空洞,再看到完整桥面。团队心知肚明,如果继续用老办法拆分子关卡,这种边界问题只会越来越多。

World Partition 出现就是为了处理这种局面。它不再要求开发者手动拆分子关卡,而是按空间把大世界切成规则的 Grid Cell,运行时按距离动态加载和卸载。每块 Cell 只包含自己区域内的 Actor,编辑器默认只处理可见的部分,磁盘、内存和协作都因此轻松不少。

1. World Partition 解决了什么问题

在老的 Level Streaming 流程里,地图被拆成 Persistent Level 加若干 Sub-Level。关卡设计师需要手动画边界、写 Streaming Volume、维护加载逻辑。地图越大,子关卡越多,边界越难对齐。玩家跑到两个子关卡交界时,经常出现加载缝、光照不一致或者碰撞丢失。

UE5.8 的 World Partition 把这张大表交给引擎自动维护。编辑器里所有 Actor 仍然放在同一张逻辑关卡中,但磁盘存储和运行时内存都被 Grid 切成小块。运行时根据玩家的位置,把附近的 Cell 加载进来,远离的 Cell 卸载掉。加载粒度由 Cell Size 决定,而不是人工划分的子关卡。

下面这张图展示了 World Partition 在运行时的工作层级。World 逻辑上是一份完整的关卡描述,Runtime HLOD 与 Streaming Source 共同决定哪些 Cell 进入内存,最后通过 Renderer 提交到屏幕。

flowchart TD
    subgraph runtime ["运行时"]
        world["World"]
        wp["World Partition"]
        source["Streaming Source"]
        hlod["Runtime HLOD"]
        renderer["Renderer"]
    end
    world --> wp
    wp --> source
    wp --> hlod
    source --> hlod
    hlod --> renderer

这种架构带来几个直接的好处。开发者不必再为每一块地图写加载脚本;多人在同一张地图里,不同玩家附近的数据可以独立流送;关卡光照和物理边界跟着 Cell 走,交界处的拼接由引擎处理。对于 20 km 级别的地图,这种自动化是项目能推进下去的前提。

协作方式也变了。过去两位美术不能同时编辑同一个子关卡,因为版本控制会把整个关卡文件锁住。World Partition 把数据拆到 Cell 级别后,只要两个人不在同一个 Cell 里工作,提交时产生的冲突就会小很多。策划调整任务触发器、美术修改建筑模型、程序调整光照,大家可以在同一张大地图里各忙各的,不再需要频繁互相等待。

2. Cell Size 选择

Cell Size 是 World Partition 最核心的参数之一。它决定了地图被切成多大的方块,也决定了运行时一次加载多少数据。UE5.8 默认常见值有 128 m、256 m、512 m、1024 m 等,项目需要根据地图尺寸和玩法选择。

团队内部曾经为这个数值开过几次会。策划希望 Cell 越小越好,这样玩家只会加载眼前一点点内容,内存峰值低。程序则担心 Cell 过小会导致文件数量爆炸,运行时查询 Cell 的开销增加,磁盘寻址也频繁。美术的意见介于两者之间:建筑密集的城市区域需要更小的 Cell,以便单独卸载远处的楼群;开阔的平原用大 Cell 更合适,避免把一片草地拆成几十份。

选择 Cell Size 的一个实用思路是先定加载半径 RloadR_{load} 和内存预算,再反推合适的 Cell 边长 CC。假设加载半径是 2 km,那么玩家周围同时存在的 Cell 数量可以估算为:

Nactive=2RloadC2N_{active} = \left\lceil \frac{2 R_{load}}{C} \right\rceil^2

比如 Rload=2048R_{load}=2048 m,C=512C=512 m,则 Nactive=8×8=64N_{active} = 8 \times 8 = 64 个 Cell。如果把 Cell 缩到 256 m,NactiveN_{active} 会涨到 256 个。Cell 数量增加并不直接等价于内存翻倍,因为小 Cell 里内容更少,但文件头、Actor 列表和 HLOD 元数据的开销会线性上升。

还有一个常被忽略的因素:Actor 的边界。一座横跨两个 Cell 的桥梁,或者一个 800 m 长的城墙,World Partition 会把它登记到所有重叠的 Cell 里。Cell 越小,这种跨 Cell 的 Actor 就越多,加载时容易出现重复引用。遇到这种情况,可以在 Actor 上调整 Grid 参与方式,或者把巨型对象拆成更小的模块。

对这片 20 km 的开放世界,团队最终把城市区域设为 256 m Cell,郊野区域设为 512 m Cell,通过不同 World Partition 设置或者 Layer 来区分。这样加载半径 1.5 km 时,城市里同时加载的 Cell 数量在 144 左右,郊野只有 36,磁盘压力和内存峰值都落在了可接受区间。

为了进一步量化,程序还跑了一个脚本,统计每个 Cell 内 Static Mesh 的三角形数量和 Actor 数量。他们发现某些市中心 Cell 的三角形数超过平均值三倍,立刻把几栋高层建筑拆到了相邻 Cell。这个步骤没法靠拍脑袋完成,必须落到具体数字上。

不同 Cell Size 的取舍可以用一张简单的关系图表示。

graph LR
    A["小 Cell"] --> B["内存峰值低"]
    A --> C["文件数量多"]
    A --> D["跨 Cell Actor 多"]
    E["大 Cell"] --> F["加载抖动少"]
    E --> G["单次加载数据多"]
    E --> H["元数据开销小"]

3. Streaming Source

Cell 切好之后,谁来决定加载哪些 Cell?答案是 Streaming Source。每一个需要触发加载的游戏对象都可以挂一个 Streaming Source,最常见的是玩家角色的 Player Controller。无人机、载具、过场动画相机、观战点也能成为 Source。

UE5.8 的 Streaming Source 不是简单的一个点。它带有 Loading Range、Unload Range、Priority 和 Label 等属性。Loading Range 表示多远以内的 Cell 会被加载;Unload Range 比 Loading Range 更大,当 Cell 离开所有 Source 的 Unload Range 后才会被释放。Priority 用来处理多个 Source 同时请求同一个 Cell 的情况。Label 则可以把 Source 和特定 Data Layer 绑定,例如只允许某个 Source 加载任务相关的 Layer。

多个 Source 同时存在时,World Partition 会把它们的加载请求做并集。假设玩家骑着摩托车,后方还有一位队友骑马跟随,两个 Streaming Source 叠加后,车辆前方的 Cell 会预加载,队友所在区域的 Cell 也不会提前卸载。这种机制对载具高速移动尤其重要;没有它,玩家以 80 km/h 冲下山坡时,前方地形可能还没进内存。

实际设置 Loading Range 时要注意一个经验数值。加载速度必须大于 Source 的移动速度,否则玩家会在 Cell 边界看到空洞。假设 Cell 边长 C=512C=512 m,Loading Range Rload=1536R_{load}=1536 m,则 Source 到未加载 Cell 的距离还有 3 个 Cell。若摩托车的最大速度是 25 m/s,加载一个 Cell 平均需要 100 ms,那么每秒能向前推进 2.5 个 Cell 的预加载深度,通常足够安全。

过场动画相机是一个容易踩坑的地方。动画导演希望镜头能俯瞰整个山谷,但如果给相机一个超大的 Loading Range,切换回玩家视角时,那些远景 Cell 会因为 Source 消失而被立刻标记卸载,造成顿卡。处理办法是为相机单独绑定一个 Label,只加载 Cinematics Data Layer,同时把 Priority 设低,避免它抢走玩家 Source 的资源保留权。

4. 自动网格化加载/卸载

World Partition 的自动流送可以用一张流程图概括。运行时每一帧都会收集所有 Streaming Source,计算它们覆盖的 Cell 集合,然后与当前已加载集合做差集。新增的 Cell 进入加载队列,离开的 Cell 进入卸载队列。加载队列会按优先级和距离排序,分批异步读取。

flowchart TD
    A["收集所有 Streaming Source"] --> B["计算 Source 覆盖的 Cell 集合"]
    B --> C{与当前已加载集合比较}
    C -->|"新增"| D["加入加载队列"]
    C -->|"离开"| E["加入卸载队列"]
    D --> F["按距离和优先级排序"]
    F --> G["异步读取 Cell 数据"]
    G --> H["实例化 Actor 与 HLOD"]
    E --> I["延迟卸载并回收资源"]

异步读取是重点。World Partition 不会卡住游戏线程去等硬盘,而是把 I/O 放到后台任务。Cell 数据被读入后,Actor 的注册、组件的初始化、物理场景的同步仍然在主线程分帧完成。这也意味着玩家偶尔会看到远处的物体比近处晚几帧出现,属于正常现象。如果加载线程压力过大,可以在项目设置里限制每帧同时处理的 Cell 数量。

卸载过程则相对保守。一个 Cell 只有在所有 Streaming Source 都超出其 Unload Range 后才会被标记。即使被标记,引擎也会保留一小段缓冲时间,避免玩家在原地震动时反复加载同一块 Cell。这个机制虽然增加了少量内存占用,却显著减少了加载抖动。

传送是最容易暴露加载瓶颈的操作。玩家从地图一端传送到另一端,原来的 Cell 会快速离开 Unload Range,新位置附近的 Cell 会大量进入加载队列。如果一次性加载太多,后台 I/O 会挤爆,主线程也可能被拖慢。常见做法是在传送前先黑屏或播放过场,同时临时降低加载并发数,让关键 Cell 优先完成。

5. Grid 命名与导航

World Partition 的 Grid 坐标非常直观。以关卡原点为 (0,0)(0,0),每个 Cell 用整数索引 (X,Y)(X,Y) 表示。Cell 文件名通常形如 WP_X0_Y0,其中 XXYY 分别代表沿世界坐标轴的格子编号。负坐标用 X-1_Y0 这样的写法,读取时注意不要和连字符混淆。

编辑器里可以通过 World Partition 窗口直接跳转到某个 Cell。输入 X=12,Y=-5,视角会飞到对应格子上空,同时只加载该 Cell 及其相邻 Cell。这个功能在排查局部问题非常方便。比如策划报告某座塔的碰撞异常,打开对应 Cell 后,场景里只剩下塔和它周围一小片地面,干扰信息少了很多。

导航时还要留意 Grid Origin 和 Cell Size 的关系。如果关卡原点不在地图中心,Cell 编号会向一侧偏移。团队在制作 20 km 地图时,把原点放在西南角,这样所有 Cell 索引都是非负数,归档和脚本处理更简单。代价是地图中心可能位于 X=19,Y=19 附近,需要一份坐标对照表。

这份对照表很快被用到了自动化脚本里。程序写了一个 Editor Utility,输入世界坐标后自动返回 Cell 索引和文件名。美术报告某个位置出错时,只需要把坐标贴到工具里,就能立刻打开对应 Cell。这个过程省掉了在世界大纲里手动搜索的麻烦,也减少了误改其他 Cell 的概率。

Data Layer 也会出现在 Grid 导航中。同一 Cell 可以分属多个 Data Layer,例如 GameplayAudioCinematics。在 World Partition 窗口里可以按 Layer 过滤,只显示当前关心的对象。加载时 Source 的 Label 会决定哪些 Layer 被激活,哪些只是占位。

6. HLOD 与 World Partition

HLOD 和 World Partition 是天然搭档。没有 HLOD,远处山脉的每一块石头都会以完整精度进入显存;有了 HLOD,每个 Cell 会按距离生成若干级简化代理。玩家在几公里外看到山脉的轮廓,走近后才替换为高精度模型。

HLOD 层级的数量通常和 Cell Size 挂钩。假设最远观察距离是 DmaxD_{max},Cell 边长为 CC,那么合理的层级数可以估算为:

Lmax=log2DmaxC+1L_{max} = \left\lfloor \log_2 \frac{D_{max}}{C} \right\rfloor + 1

例如 Dmax=4096D_{max}=4096 m,C=512C=512 m,则 Lmax=4L_{max}=4。也就是说,每个 Cell 最多生成 4 级 HLOD,最外层在 4 km 外呈现,最近一层在 512 m 左右切换为完整模型。层数过多会增加构建时间和磁盘占用;层数过少则会在中距离出现明显的 pop。

下面这张序列图展示了一个 Cell 被加载后如何与 HLOD 系统协作。

sequenceDiagram
    participant S as "Streaming Source"
    participant WP as "World Partition"
    participant H as "HLOD System"
    participant R as Renderer
    S->>WP: 位置更新
    WP->>WP: 计算新增 Cell
    WP->>H: 请求对应 HLOD
    H->>H: 按距离选择 LOD 层级
    H->>R: 提交代理网格
    R->>R: 渲染到屏幕

UE5.8 的 HLOD 构建会在编辑器里为每个 Cell 生成 Hierarchical LODActors。构建时间大致和 Cell 内 Actor 数量成正比,因此把密集城市拆成 256 m Cell 也能缩短单次 HLOD 构建时长。构建完成后,代理网格会存放在 HLOD 目录下,和 Cell 数据一起流送。

HLOD 构建通常放在夜间跑。团队把城市区域和郊野区域分成两个构建任务,城市任务因为 Actor 密集,耗时是郊野的五倍。构建完成后,美术第二天会抽查几个 Cell 的代理质量,确认没有丢失重要 silhouette。有一次发现远处城堡的塔尖被简化成了平顶,原因是 HLOD 体积设置得太激进,调整后重新构建才解决。

7. 调试与可视化

调试 World Partition 最直接的方法是打开编辑器视口的可视化覆盖层。在 Viewport 菜单里找到 Show > World Partition,可以看到彩色网格线把地图划分成 Cell。不同颜色代表加载状态:绿色表示已加载,黄色表示加载中,红色表示等待卸载,灰色表示未加载。

除了网格线,控制台变量也提供了大量运行时信息。常用命令包括:

  • wp.Runtime.ShowCells 1:在屏幕上绘制当前激活的 Cell 边界。
  • wp.Runtime.ShowStreamingSources 1:显示所有 Streaming Source 的位置和半径。
  • wp.Runtime.ShowCellLoading 1:打印每个 Cell 的加载耗时。

当玩家报告远处地形闪烁或者加载缝时,可以先打开 ShowCells 确认边界位置,再用 ShowStreamingSources 检查 Loading Range 是否覆盖了问题区域。如果 Cell 边界正好穿过一座建筑,那很可能是跨 Cell Actor 导致的异常。

编辑器里的 World Partition 窗口还能导出 CSV 报告,列出每个 Cell 的 Actor 数量、内存占用、HLOD 层级和构建状态。团队每周会跑一遍这个报告,找出占用特别大的 Cell 进行拆分或优化。

有一次测试发现某个 Cell 长时间卡在红色,明明玩家已经走远,它却迟迟不卸载。查了半天,原来是一个过场动画相机在播完后没有正确注销 Streaming Source,导致那个 Source 的 Unload Range 一直覆盖着这块 Cell。修复注销逻辑后,红色立刻消失,内存峰值也降了下来。这类问题不靠可视化很难快速定位。

8. 性能优化

World Partition 把大世界的加载问题从手工拆关变成了自动调度,但这不意味着性能会自动变好。以下是团队在实践中总结的几条方向。

控制同时加载的 Cell 数量。Loading Range 不要设得太大。前面公式里已经看到,NactiveN_{active}RloadR_{load} 的平方成正比。把加载半径从 2 km 提到 3 km,Cell 数量可能翻倍,而画面收益并不明显。

避免跨 Cell 的巨型 Actor。城墙、长桥、大型地形雕刻品最好按 Cell 边界拆分,或者使用 World Partition 提供的 Spatially Loaded 选项控制它们参与哪些 Cell。巨型 Actor 会导致多个 Cell 同时持有同一份资源引用,增加内存和加载依赖。

合理使用 Data Layer。把 Gameplay、光照、音频、过场动画分层后,不同玩法阶段只加载需要的 Layer。例如战斗中不需要播片用的轨道摄像机,探索时也不需要全部任务触发器。Data Layer 配合 Streaming Source Label 能显著降低单帧实例化压力。

优化 HLOD 构建策略。层级数按前面公式估算即可,不要盲目加到 6、7 层。每层 HLOD 都会增加构建时间和磁盘占用。对于平原、沙漠这类低细节区域,1 到 2 层 HLOD 已经足够。

预留 I/O 带宽。World Partition 的加载瓶颈通常不在 CPU,而在磁盘。把项目放在 SSD 上是最直接的提升。主机平台还可以开启基于内存虚拟文件的缓存,减少重复读取。

监控上线前要在目标设备上跑一遍完整地图漫游,记录加载队列长度、Cell 切换耗时和内存峰值。UE5.8 的 Insights 和 Stat 命令都能捕获这些数据。找到加载抖动点后,再回到 Cell Size、Loading Range 和 HLOD 策略上做微调。

下面这段代码展示了一个简单的自定义 Streaming Source 组件示例,可以用来给无人机或观战点增加加载能力。

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "WorldPartition/RuntimeHashSet/RuntimeHashSet.h"
#include "StreamingSourceComponent.generated.h"

UCLASS(ClassGroup = Custom, meta = (BlueprintSpawnableComponent))
class MYGAME_API UMyStreamingSourceComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UMyStreamingSourceComponent();

protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float LoadingRange = 2048.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float UnloadRange = 3072.0f;
};

组件创建后,把它挂到无人机 Actor 上。BeginPlay 中调用 UWorldPartitionSubsystem::RegisterStreamingSource 并传入自身 Transform 与这两个半径,World Partition 就会把它当作有效 Source。无人机的 Loading Range 可以比玩家小一些,避免它飞得太高时把整张地图都拉进内存。

World Partition 加上合理的 Grid 划分,把 20 km 的开放世界从一张无法打开的大表,变成了一套可预测、可调试、可优化的自动流送系统。Cell Size 的选择、Streaming Source 的布置、HLOD 层级的配合,每一个环节都能在编辑器里反复验证。团队不再需要靠猜来决定地图该怎么拆,而是可以用数据回答加载压力和内存峰值问题。