第16章 · 大世界技术:World Partition 与流送

📑 目录

导语:2004 年 WOW 开服,玩家从艾尔文森林一路跑进暴风城,全程没有一个加载画面。二十年过去,这件事依然是关卡技术的分水岭:一张 8km×8km 的地图全量装进内存是 10GB 级别,32 位浮点在 8km 外连 1 毫米都保证不了,一次传送能让主线程卡掉两秒。无缝世界的另一半在服务器——Zone 拆分与跨区交接留给第 25 章;客户端这一半,就是本章要拆的 World Partition 与流送。

本章地图

mindmap
  root((大世界流送))
    三难
      内存装不下
      浮点精度漂移
      加载尖峰卡帧
    World Partition
      网格化世界
      OFPA 一Actor一文件
      流送源与半径
      格子生命周期
    数据层
      白天 夜晚 灾变后
      运行时整层切换
    流送策略
      内存预算公式
      HLOD 兜底
      速度自适应
    大世界坐标
      LWC 双精度
      原点重定位
    远港方案
      8km 切 256m 格
      主城 野外 副本

16.1 为什么无缝世界难:内存、精度与加载尖峰

小关卡时代,一关 200–500MB,进关一次加载完,所有系统都假设"世界是完整的"。大世界打破这个假设:渲染、物理、AI、音频都必须接受一个事实——此刻只有一小片世界在内存里,其余的只是磁盘上的包和远处的一张低模皮。

从"关卡思维"换到"世界思维",要翻三座山。每一座都对应一类真实事故。

先把一个词说死:无缝不等于"没有加载"。WOW 同样在加载,它只是把加载藏进了跑动的时间里——你在艾尔文森林跑向暴风城的两分钟里,客户端一直在后台搬下一格的资产。无缝的本质是"加载与游玩并行"——"加载消失"从来都不在目标清单上。理解这一点,后面的所有设计都顺理成章:网格是搬运的单位,半径是搬运的提前量,HLOD 是搬运没赶上时的遮羞布。

有人问过另一条路:既然装不下,靠操作系统的虚拟内存换页顶着行不行?不行。换页是以 4KB 页为单位、被缺页中断驱动的随机 IO,游戏资产需要的是成块、可预测、可排优先级的加载。把流送交给换页,等于把帧率交给磁盘的寻道时间——机械硬盘时代这就是"走三步卡一步"的直接原因。流送必须是引擎主动做的事。

难一:内存装不下。

「远港 Online」的主大陆 8km×8km = 64km²。按 256m×256m 一格切,全图约 977 格(31.25×31.25)。野外一格(地形块 + 静态网格实例 + 植被 + 贴图驻留 + 物理)6–10MB,主城一格 30–50MB。全图平均按 10MB 估,全加载就是 10GB 级别——还没算虚拟纹理池和 HLOD 代理。

16GB 内存的机器,Windows 占 3–4GB,游戏进程预算 10GB 上下,全图一塞就爆。内存这道墙的结论很粗暴:世界永远只能加载局部,问题只剩下"局部画多大"。

单格 10MB 这个数怎么来的,拆开看:一块 256m 的地形高度场与材质权重约 1MB;格内静态网格实例(房屋、岩石、栅栏)的变换与渲染状态 2–3MB;贴图经虚拟纹理(Virtual Texture)按需驻留,一格视野内约 2–4MB;碰撞体与物理对象 1MB 上下;剩下的给导航网格与音频衰减数据。哪一项都砍不掉太多,合在一起就是这个量级。主城一格翻到 30–50MB,是因为建筑密度与独立网格数量是野外的五到八倍。

难二:浮点会漂移。

UE 内部单位是厘米,世界坐标传统上用 32 位 float。float 只有 24 位有效尾数,数值越大,相邻两个可表示数的间隔越大:

Δx=x223\Delta x = x \cdot 2^{-23}

8km = 8×10⁵ cm,代入得 Δx ≈ 0.095 cm ≈ 1mm。听起来小,后果不小:顶点坐标每帧在 1mm 的格点上量化,建筑边缘出现果冻状抖动(vertex wobble);根骨骼位置被量化,脚步开始滑移;物理射线在毫米级误差下误判。拉到 80km,Δx 涨到约 1cm,角色直接穿墙。

UE4 时代靠原点重定位(Origin Rebasing)续命;UE5 起用 Large World Coordinates 把世界坐标升为 double。这笔账在 16.5 节细算。

难三:加载会卡帧。

传送或高速移动会让几百 MB 资产"集中到期"。走同步加载,主线程 stall 1–3 秒;走异步加载,读包在后台线程,但反序列化与注册要回主线程,不做预算控制时单帧 40–80ms 持续数秒。

玩家看到的是:传送落地,帧率从 90 掉到 15,人冻在半空,建筑一格一格"长"出来。事故报告里这叫 loading spike,玩家嘴里这叫"卡了"。

为什么反序列化与注册绕不开主线程?UObject 的创建、组件的注册进场景、物理体的生成,都要改引擎的全局对象表与场景图,这些结构没有为多线程写设计。后台线程能做的是读磁盘、解压、解析包体这些纯数据活;一旦要"把对象放进世界",就得回主线程排队。那么加载优化的本质到底是什么?一句话:主线程每帧肯为加载花多少毫秒的预算问题。从来就没有"异步就完事"这条捷径。

三难对照表,后面五节就是逐条拆这三行:

典型事故量级病根本章对策
内存全图加载 OOM 闪退64km² ≈ 10GB世界只能装局部网格化流送(16.2/16.4)
精度远离原点建筑抖动8km 处 ≈1mmfloat 24 位尾数LWC / 原点重定位(16.5)
尖峰传送后 PPT 三秒数百 MB 集中到期加载没有预算异步 + 分帧 + HLOD 兜底(16.4)

16.2 World Partition:网格化世界的运行时

一句话:把世界切成固定边长的格子,玩家是圆心,半径内的格子进内存,半径外的出去,引擎替你维护这个圆的进出。

UE4 的 World Composition 靠策划手动切子关卡,接缝处全是人肉维护。UE5 的 World Partition 把这件事自动化:开启之后,摆进关卡的每个 Actor 按 XY 坐标自动落入流送格子,编辑器的 World Partition 面板能看到整张网格的占用情况。5.8 里 World Partition 是新关卡的默认方案,World Composition 已标记废弃(以官方文档 World Partition 词条为准)。

它还顺手解决了一个版本控制的老大难:OFPA(One File Per Actor)。World Partition 关卡里每个 Actor 在磁盘上是独立文件(__ExternalActors__ 目录),.umap 本身只剩一个壳。.umap 是二进制没法合并,以前二十个人改同一张图互相锁文件;OFPA 把冲突粒度从"整张图"降到"单个 Actor"。

编辑器里的工作方式也变了。World Partition 面板把整张图显示成网格,关卡策划只加载自己要改的那几格——8km 的图,编辑器里同时驻留的通常不到 5%,否则 32GB 内存的开发机也扛不住全图展开。老关卡有转换入口(Tools 菜单下的 Convert Level,以 5.8 文档为准),引擎按 Actor 位置自动分格。转换是单程的:转之前先把关卡打进版本库的标签,转换过程中的分格结果要逐区人工验收,尤其是跨格子摆放的长条物体(围墙、河道、道路样条),它们按原点落格,视觉上横跨边界,流送时会出现"半堵墙"。

运行时有三个核心概念:

  • Runtime Spatial Hash:格子的哈希索引。核心参数是 CellSize(格边长)与 LoadingRange(加载半径),在 World Settings 的 World Partition 面板里配置,不同版本入口略有差异,以 5.8 文档为准。
  • Streaming Source(流送源):圆心提供者。默认每个玩家 Pawn 自带一个隐式流送源;显式做法是挂 UWorldPartitionStreamingSourceComponent。典型场景:过场动画里相机飞到 2km 外,玩家还站在原地——这时要以相机为圆心加一个流送源,玩家才不会看着镜头里的楼当面消失。
  • Spatially Loaded 开关:Actor 细节面板上的选项,勾了才进格子参与流送。GameMode、全局管理器、跨格子的任务触发器这类"世界大脑"不勾,永远驻留内存。

引擎按固定周期(亚秒级,具体 CVar 以 5.8 文档为准)做集合差分:算出"所有流送源半径覆盖的格子集合",与"当前已加载集合"对比,新进的排队加载,离开的排队卸载。不是每帧——每帧算一次近千格的集合运算是纯浪费。

这里有个工程细节叫滞后(hysteresis):加载半径与卸载半径不是同一个数。玩家在半径边界上来回走,如果进出用同一条线,边界上的格子就会反复加载卸载,队列空转、内存抖动。实际策略是卸载半径比加载半径大半格到一格,格子一旦进来就多留一会儿,玩家在边界来回踱步不会触发任何流送事件。这个思路和数据库缓存的过期策略、磁滞回线是一个道理:边界上的抖动,用不对称的阈值消化掉。

下面这张示意图是本章的核心图景:4×4 网格、内外两圈半径、玩家位置三者的空间关系。

流送网格示意:4×4 格子,每格 256m×256m(比例 140px = 256m)256 m玩家激活半径 ≈ 220m(7 格在内存)HLOD 显示半径 ≈ 366m

超出外圈
完全不加载

已激活(Actor 在内存)仅 HLOD 代理可见未加载

一个格子的生命周期比"加载 / 卸载"两个状态复杂得多。资产先在后台线程读包(Loading),读完后对象已在内存但尚未注册进场景(Loaded)——不可见、不 Tick、无物理;主线程在帧预算内分批注册后才真正激活(Activated)。离开半径后逆向走一遍。

分级的意义在主线程预算:把"激活 5 个格子"摊到好几帧做,每帧只做 1–2ms 的注册工作,玩家就感觉不到加载。这是 16.1 里"加载尖峰"的正面答案。

stateDiagram-v2
    [*] --> Unloaded: 初始
    Unloaded --> Loading: 进入加载半径\n异步请求资产
    Loading --> Loaded: 反序列化完成\n已在内存, 未注册
    Loaded --> Activated: 主线程分帧注册\n可见+可物理+可Tick
    Activated --> Unloading: 离开所有流送源半径
    Unloading --> Unloaded: 引用清零, GC 回收
    Loaded --> Unloading: 加载完成前玩家已走远
    note right of Unloaded
        此状态下若有 HLOD,
        画面由代理网格兜底
    end note
    note right of Activated
        唯一消耗完整
        内存与 CPU 的状态
    end note

格子进出的决策循环,引擎每周期跑一遍:

flowchart TD
    A[每周期收集所有流送源位置] --> B[计算应加载格子集合 S]
    B --> C{S 与已在内存集合做差分}
    C -->|新进入半径| D[加入异步加载队列
按距圆心远近排序] C -->|超出半径| E{该格有 HLOD 层?} E -->|有| F[切换显示 HLOD 代理
卸载原始 Actor] E -->|无| G[直接卸载
标记待 GC] D --> H[后台线程读包] H --> I[主线程分帧反序列化与注册] I --> J["格子激活: 可见+可物理"]

各模块的归属关系,一张图理清:

classDiagram
    class UWorldPartition {
        +RuntimeHash
        +StreamingSources
        +周期驱动流送
    }
    class UWorldPartitionRuntimeHash {
        +CellSize
        +LoadingRange
        +格子索引
    }
    class UWorldPartitionRuntimeCell {
        +状态: Unloaded..Activated
        +Actor 列表
    }
    class UWorldPartitionStreamingSourceComponent {
        +位置
        +bStreamingSourceEnabled
    }
    class UDataLayerManager {
        +SetDataLayerRuntimeState()
    }
    class UDataLayerInstance {
        +RuntimeState
    }
    class AWorldPartitionHLOD {
        +合并代理网格
    }
    UWorldPartition "1" --> "1" UWorldPartitionRuntimeHash
    UWorldPartitionRuntimeHash "1" --> "*" UWorldPartitionRuntimeCell
    UWorldPartition "1" --> "*" UWorldPartitionStreamingSourceComponent
    UWorldPartitionRuntimeCell "*" --> "0..1" AWorldPartitionHLOD : 超范围时兜底
    UDataLayerManager "1" --> "*" UDataLayerInstance
    UWorldPartitionRuntimeCell "*" ..> UDataLayerInstance : 按层过滤

显式流送源的标准写法——挂在角色上,过场时也能改挂到相机上:

// 自定义流送源:半径等参数在 World Settings 的 World Partition 面板配置
UCLASS()
class AFarportCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AFarportCharacter()
    {
        // 属性名以 5.8 文档 World Partition 词条核对
        StreamingSource = CreateDefaultSubobject<
            UWorldPartitionStreamingSourceComponent>(TEXT("StreamingSource"));
    }

    UPROPERTY(VisibleAnywhere, Category = "Streaming")
    TObjectPtr<UWorldPartitionStreamingSourceComponent> StreamingSource;
};

还有一类全局流送源:不挂在任何 Pawn 上,由系统手动指定位置。传送大厅用它预热目的地——玩家读传送条的两秒里,目标区域的流送源已经开工,落地时近景格子多半已 Activated。WOW 传送门落地即见人,靠的就是这类预热——带宽更大从来不是答案。

调试期把流送状态画出来是刚需:World Partition 自带的编辑器可视化能显示每格状态,运行期配合 wp.* 系列调试 CVar 在屏幕上标出格子边界与加载状态(具体词条以 5.8 文档为准)。排查"为什么这栋楼没加载"时,第一件事永远是开可视化看格子状态,而不是怀疑资产坏了。

补课:本节补关卡设计基础——动线与视觉锚点。程序出身必修,关卡策划可跳过。

格子边界是"系统缝",别把它压在玩家的高频动线上。主城城门、十字路口这种每秒都有人经过的地方,如果恰好是四个格子的交界,玩家每过一次城门就触发一轮进出加载,尖峰全挤在最不该卡的地方。切格子时把主路完整包进单格,把边界藏进地形遮挡(山脊、密林、城墙拐角)——玩家看不见远处,卸载就不可感知。

视觉锚点是另一个方向的问题:无缝世界没有加载画面,玩家靠什么定向?靠跨格子可见的地标。WOW 的暴风城英雄谷尖塔、奥格瑞玛的高塔,都是在 2km 外就能看见的"世界坐标系"。工程上这类锚点要么进 HLOD 顶层(永远以低模可见),要么干脆设成不随格子卸载。远港的灯塔就按这个思路做。

16.3 数据层:同一个世界的白天、夜晚与灾变后

Data Layer 与格子是正交的两个维度:格子回答"空间上该不该在",数据层回答"逻辑上该不该在"。一个 Actor 同时属于某个格子和某一层;层被卸载,层内所有格子连带卸载;层被激活,层内格子再按流送半径走正常流程。

资产组织上,DataLayerAsset 是内容浏览器里的资产,关卡里实际生效的是它的实例(DataLayerInstance),编辑器有专门的 Data Layers 面板管理。给 Actor 分层就是细节面板里勾选归属层,一个 Actor 可以属于多个层。它和子关卡(Sub-Level)的区别值得说清:子关卡是"手动切块",块内所有东西同生共死;数据层是"标签化的 Actor 集合",同一空间里的 Actor 可以分属不同层,再叠加格子做空间过滤。主城广场白天的人群和夜晚的灯火站在同一块地砖上,各占一层,互不干扰——子关卡做不到这种同空间复用。

「远港 Online」主城的实操三套层:

数据层内容激活时机可逆
DL_Farport_Day市集 NPC、常规巡逻、开门店铺、白日氛围音06:00–18:00可逆
DL_Farport_Night街灯、夜市摊贩、宵禁巡逻、酒馆人声18:00–06:00可逆
DL_Farport_Cataclysm烧毁建筑变体、撤离点、灰烬天气、幸存者营地第三章"远港之焚"后单程

层还分静态与运行时两类用法。静态层在烘焙期决定归属,包体里就分好块,适合"永远一起出现"的内容;运行时层靠状态切换,才是日夜与灾变这种玩法。远港的规矩是:能静态的别上运行时——运行时切换有加载延迟和管理成本,静态分层零开销。三套剧情层是必须运行时的那部分,节日活动装饰这种一年用两周的,静态层挂活动标签就够。

运行时切换走 UDataLayerManager,核心调用是 SetDataLayerRuntimeState(..., EDataLayerRuntimeState::Activated / Unloaded),5.8 的具体签名以官方文档 Data Layers 词条为准。

// 世界状态切换:白天 -> 夜晚。异步过程,UI 层要配转场
void AFarportGameMode::SetWorldState(EFarportWorldState NewState)
{
    UDataLayerManager* Mgr = UDataLayerManager::GetDataLayerManager(GetWorld());
    if (!Mgr) return;

    const UDataLayerAsset* Target = (NewState == EFarportWorldState::Night)
        ? NightLayer.LoadSynchronous()
        : DayLayer.LoadSynchronous();

    // 先卸旧层再激活新层;加载在后台进行,激活完成有委托回调
    Mgr->SetDataLayerRuntimeState(CurrentLayer.Get(), EDataLayerRuntimeState::Unloaded);
    Mgr->SetDataLayerRuntimeState(Target, EDataLayerRuntimeState::Activated);
    CurrentLayer = Target;
}

两条纪律,都是踩过坑的教训。

切换不是瞬时的。层内格子同样走异步加载,主城夜晚层 200–400MB,激活要几秒。硬切会看到整座城市"闪烁换皮"。正确做法是 UI 播转场(日落渐变遮罩 + 环境音交叉淡入),后台等层的激活回调再放玩家出来。

可逆层与不可逆层要分开对待。白天 / 夜晚会反复切,两套资产的预算都得留;灾变后是剧情单程票,老层 Unloaded 之后引用清零、GC 回收,内存真正释放。这也是控内存的手段:不再回来的世界状态,让它真的走,别留在原地占预算。

MMO 比单机多一条纪律:层状态由服务器权威驱动。同一张主城地图,服务器说现在是夜晚,所有在线客户端必须切到 DL_Farport_Night,否则同一条街你看见灯火、队友看见白昼,剧情与世界事件就各演各的了。工程上这是把层状态做成一个复制的世界变量(放 GameState,复制机制见第 21 章),客户端收到变更再走上面的切换流程。灾变后这种剧情层同理:全服玩家在同一时刻见证远港之焚,靠的是服务器把世界状态版本号推给所有人,而不是各客户端自己看任务进度猜。

16.4 流送策略:半径、HLOD 与内存预算的博弈

设格边长 S,加载半径 R,圆内格子数约为面积之比:

N \approx \pi \left(\frac{R}{S}\right)^2,\qquad M_{\text{流送}} = N \times \bar{C}_{\text{格}} + M_{\text{HLOD}}

其中 C̄格 是单格平均内存,M_HLOD 是全图 HLOD 代理的常驻开销。这个公式里最要命的是平方律:半径翻倍,驻留格子数变四倍。流送半径不是"看得越远越爽",每加一个格边长的半径,内存按平方交税。

把远港的数字代进去,先算劝退账。全图加载:

M_{\text{全图}} = \left(\frac{8000\,\text{m}}{256\,\text{m}}\right)^2 \times 10\,\text{MB} \approx 977 \times 10\,\text{MB} \approx 10\,\text{GB}

16GB 内存的机器直接出局,这就是 16.1 那张表的来源。再算流送账:R = 512m、S = 256m,N = π×(2)² ≈ 13 格,驻留约 130MB;全图 HLOD 代理合并后按 0.8–1.5GB 常驻估,总盘子压进 2GB 以内。剩下的内存预算留给角色、特效和 UI——这才是能上线的数字。

HLOD(Hierarchical Level of Detail)是这套方案的另一半:构建期把一格(或一组格)里的静态网格合并成一个低模代理。800 个建筑 Actor?在 500m 外,玩家看到的是 1 个合并网格;格子卸载后,画面由 HLOD 兜底——"看得见但摸不着"的那层世界就是它。

代价要写清楚:8km 图的全量 HLOD 构建以小时计;代理数据是额外内存;动态 Actor(门、可破坏物、NPC)进不了 HLOD;切换距离要留缓冲带,否则玩家会看到代理与真身"闪换"。

HLOD 是分层的,不是一个开关。HLOD0 服务中距离,把一格内的几十个小物件合成几个网格;HLOD1 服务远距离,把 2×2 格的建筑群合成一张皮;层数与合并比例在 HLOD Layer 资产里定义。植被和树要特别处理:它们量大、远看又最显眼,通常进专门的植被 HLOD 层或用 Billboard(面片)替代,处理不好远处山脊会"秃"——WOW 老玩家对远处山坡的光秃感一定不陌生,那就是植被代理没做或做少了。

半径与格边长的配比也有经验区间。R/S 比值常见在 1.5 到 3 之间:比值小于 1.5,玩家对角线方向上会出现"看得见的洞"——半径圆盖不住斜前方的格子角;比值大于 3,为远处格子交的平方税开始不划算。远港野外 512m/256m 正好是 2,主城 384m/128m 是 3,都在区间内。加载队列按距圆心排序,近的先来;玩家急转弯时队列要重排,否则身后的格子还在占加载带宽,眼前的路却是空的。

一次玩家移动触发的完整流送时序:

sequenceDiagram
    participant P as 玩家 Pawn
    participant S as StreamingSource
    participant W as WorldPartition
    participant A as 后台加载线程
    participant G as Game 线程

    P->>S: 每帧移动, 位置变更
    S->>W: 周期上报位置(亚秒级)
    W->>W: 集合差分: 新进 3 格 / 离开 2 格
    W->>A: 为新格发起异步读包(按距离排序)
    A-->>W: 读包完成(数帧至数百 ms)
    W->>G: 主线程分帧反序列化+注册
    G-->>P: 新格 Activate, 建筑进入视野
    W->>G: 老格 Unload: 注销并标记待回收
    G->>G: GC 择机回收内存
    Note over P,G: 不卡帧的前提: 单帧注册时间低于预算

走路半径够用,骑飞行坐骑就不够。坐骑 40m/s,一格 256m,6.4 秒过一格,固定半径会冲进未加载格——玩家看到脚下是空的。加载半径要跟着速度走:

# 速度自适应流送:跑得快就看得远,防止冲进未加载区
def update_loading_range(player, world_partition):
    v = player.velocity.length()          # cm/s
    base = 51200.0                        # 基础半径 512m
    lead_time = 6.0                       # 加载一格平均 4-6s, 提前量
    dynamic = v * lead_time               # 未来 lead_time 秒到达的位置
    range_cm = clamp(base + dynamic, base, 2 * base)
    world_partition.set_loading_range(range_cm)
    # 落地后 v 归零, 半径自动收回基础值, 内存随之释放

每帧加载预算也要守住:异步加载的每帧时间片由 s.* 系列 CVar 控制(如 s.AsyncLoadingTimeLimit,以 5.8 文档 Streaming 词条为准)。原则只有一句:宁可让远处格子晚到 200ms,不让当前帧超时。远处建筑晚长出来一点没人骂,帧率掉到 15 人人骂。

预算最后要落成制度,否则每个系统都会偷偷超支。远港的流送预算表是这样的:野外流送驻留 200MB 封顶,HLOD 常驻 1.2GB 封顶,单帧加载注册时间 2ms 封顶,单格异步加载 400ms 超时告警。每周跑一次自动化跑图(第 18 章的性能工作流),四项任何一项飘红就回去查资产——多半是新上的建筑没进 HLOD,或者某格的贴图没走虚拟纹理。流送会跟着内容生产一起长大。把它当成配一次参数就结束的系统,迟早要出事。

16.5 大世界坐标:原点重定位与精度

16.1 的公式 Δx = x·2⁻²³ 是 UE4 时代的达摩克利斯之剑。那时世界半径还有约 21km(2²¹ cm)的硬上限,WOW 级别的地图都得绕着走,绕的手段就是原点重定位:玩家跑远了,把整个世界平移,让原点重新贴到玩家脚下,所有 Actor 的世界坐标跟着减一个偏移。

UE5 的答案是 Large World Coordinates(LWC):世界坐标在引擎层升为 64 位 double,渲染提交前转成相机相对(camera-relative)的 float。绝对坐标可以大到天文数字,但送进 GPU 的永远是"相机附近的小数"——精度损失被锁死在相机身边,而不是世界尽头。5.8 默认开启,8km 地图不需要任何手工干预。

值得理解这层转换的原理,因为排查渲染诡异问题时绕不开它。每个渲染资源的变换矩阵在提交前会减去相机的世界位置,GPU 看到的顶点坐标全部是"相对相机"的值;相机动,所有提交数据跟着平移。对 shader 来说世界原点就是相机,double 精度的绝对坐标从未进过显存。坐标本身并没有变准——真相是,"不准"被挪到了看不见的地方。这就是为什么 8km 外的建筑在 5.8 里不抖。

三件事仍然要自己算账。其一,网络复制仍是量化 float:FVector_NetQuantize 系列按厘米或毫米档压缩传输,自定义位置同步协议时精度自己选(以文档 Replication 词条为准)。其二,老代码里的 FVector 运算假设了 float 范围,升级 LWC 后留意溢出与隐式转换警告。其三,物理等子系统内部仍有 float 路径,极端距离下的行为以 5.8 文档 LWC 词条为准。

LWC 之后单机基本不用手动重定位,但读老代码、排查"传送后粒子残影、布料爆炸"这类 bug 时仍要懂它:重定位瞬间所有 Actor 的世界坐标变了,没处理原点变更通知的系统,就会拿旧坐标算出新位置的怪东西。机制本身不长这样:

// 原点重定位触发(UE4 遗产;5.8 默认 LWC 下通常不需要, 仅演示机制)
// API 签名以官方文档 World Origin Rebasing 词条为准
void AFarportPlayerController::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);
    APawn* P = GetPawn();
    if (!P) return;

    const FVector Loc = P->GetActorLocation();
    if (Loc.Size() > 200000.0)  // 跑出 2km 安全半径
    {
        // 以玩家脚下为新原点, 世界整体平移
        GetWorld()->SetNewWorldOrigin(FIntVector(Loc));
    }
}

16.6 「远港 Online」的地图切割方案

把前面所有结论落到 8km×8km 的主大陆上。第一个问题:格子边长选多少?候选是 128m、256m、1km。

1km 一格:全图 64 格,管理省心,但每格 160MB 往上,单格异步加载 1–3 秒——加载尖峰从"很多小石头"变成"几块大砖头",帧预算摊不平。粒度粗还意味着浪费:半径 1.5km 内大量视野外角落被加载,内存反而更高。

128m 一格:4096 格,每格的调度、状态机、哈希项本身有开销,流送事件碎到每帧都在进出,小格子的灵活被调度成本吃掉。它适合密度极高的主城局部,不适合全图。

256m 一格:977 格,单格 10MB 级、加载 100–400ms,后台线程摊得平;半径 512m 只挂 13 格,预算可控。这是甜点,也是远港野外的选择。

为什么不干脆搞动态格子——密度高的地方自动切细?引擎的 Runtime Spatial Hash 是固定格,动态细分要自己写一层映射,调度复杂度上一个台阶;而实测里"主城加密、野外标准"的双规格已经覆盖 90% 的收益。工程上够用就不加复杂度,这是 50 人团队能维护得动的方案。

三类区域三种策略:

区域格子加载半径HLOD数据层备注
主城 2km×2km128m 加密384m手工分层白天/夜晚/灾变后建筑密度是野外 5–8 倍
野外 约 60km²256m512m自动构建季节层植被走 PCG(见第 17 章)
副本不进大世界流送独立关卡实例传送加载 ≤3s

副本单独说。它不进大世界流送,理由有三:副本空间通常不在主大陆坐标系内(塞在世界的角落或独立关卡里),进大世界网格纯属浪费索引;副本是强节奏内容,进本那一刻本来就有传送读条的合理预期,3 秒内的一次性加载比常驻流送更省内存;副本要支持多实例——同一个副本模板,一百个团队各开一份,这在服务器侧是实例化问题(见第 25 章),客户端侧就是"卸载大世界相关格,加载副本关卡"的一次切换。

边界处理按 16.2 的补课执行:主城与野外交界的城门是玩家最高频路径,格子边界不压城门正下方,主路包进单格;主城外围留一圈缓冲格,玩家距城 512m 就开始预热,进城那一刻不该有任何加载感。

高速移动单独处理:飞行坐骑 40m/s,过一格 6.4 秒,用 16.4 的速度自适应把半径顶到 1km,落地收回。WOW 的飞行路线走固定航路是同一思路——航路两侧的格子可以提前排队,加载从"追赶玩家"变成"恭候玩家"。

最后把客户端这套网格跟服务器对表,为卷三埋伏笔。256m 网格不是客户端的孤立设计:第 25 章会把同一张网格映射到 Zone Server 进程边界——一个 Zone 进程管 2×2 到 4×4 个格子,客户端流送半径与服务器 AOI(Area of Interest,兴趣区域)半径取同一数量级。两边用同一张图说话,跨区交接时客户端提前加载的格子,正好对应服务器提前迁移的实体。无缝世界的客户端与服务器,到此才算合上缝。

容量规划也在这张网格上做。按一个 Zone 进程管 4×4 格(1km×1km)粗算,64km² 的主大陆约 64 个 Zone 格子域,主城单独拆成多个域分担密度——WOW 的东部王国与卡利姆多就是按大陆切进程组的同一思路。分线(Shard)则是把同一套格子在另一组进程上整个复制一份,人口超载时开新线,这部分留给第 25 章展开。

方案定了,验收标准先写在前面,免得到时候扯皮:坐骑全速(40m/s)沿任意直线穿越全图,视野内不出现未加载空洞;传送落地后 1 秒内角色可交互、3 秒内近景格子全部激活;主城日夜切换全程帧率不低于 30;连续跑图 30 分钟无内存单调上涨(排查流送泄漏)。四条全绿,远港的客户端地基才算打完。

小结

无缝世界的本质是承认"世界只能装局部",渲染、物理、AI、音频全部围绕这个假设设计。内存按平方律交税:流送半径翻倍,驻留格子数变四倍;远港 64km² 全加载约 10GB,流送方案压进 2GB 以内。加载完成不等于可见——Unloaded→Loading→Loaded→Activated 的分级,是为了让主线程把注册摊进帧预算,这是 loading spike 的正面答案。数据层与格子正交:一个管空间,一个管剧情与时间状态;不再回来的世界状态要真的卸载,别留它占预算。LWC 之后浮点精度不再是 8km 地图的瓶颈,但网络量化与物理内部路径仍要自己算账。

上手任务

  1. World Partition 沙盒:建 2km×2km 灰盒,随机摆 200 个静态网格,用默认流送参数跑动。用 World Partition 面板和 stat 命令观察格子进出。验收:驻留内存增量低于全加载的 15%,全程无大于 33ms 的单帧尖峰。
  2. 双数据层切换:给沙盒加白天/夜晚两层,做按键切换并配转场遮罩。验收:切换全程帧率不低于 30,无建筑"闪换"。
  3. 内存账实操:对测试图跑 memreport,实测单格平均开销,代入 16.4 的公式,算出全加载内存与三个候选半径(384/512/768m)的驻留内存。验收:交一张表,含推荐的格边长与半径,并写清理由。

下一章

格子解决了"装不下",没解决"填不满"——64km² 的世界靠手铺树要铺到项目解散。第 17 章 PCG 接管内容生产线:写一条规则,让森林、乱石滩和废墟自己长出来。

延伸阅读

  • 官方文档:Unreal Engine → Building Virtual Worlds → World Partition
  • 官方文档:World Partition — Hierarchical Level of Detail;Data Layers;Large World Coordinates
  • 源码:Engine/Source/Runtime/Engine/Private/WorldPartition/(WorldPartitionRuntimeHash.cpp 看集合差分,WorldPartitionStreamingPolicy.cpp 看加载调度)
  • GDC 方向:历年开放世界流送主题分享(巫师、地平线系列关于 streaming 与 LOD 的演讲)