UE5.8 PCG 性能与流送策略

📑 目录

UE5.8 PCG 性能与流送策略

一个典型的卡顿周末

去年秋天,我们项目组把一片 8×8 km8 \times 8\ \text{km} 的开放世界塞进 World Partition 后,策划按下生成键就去喝咖啡了。十分钟后,编辑器还没响应,主线程占用单核打满,内存飙到 38 GB38\ \text{GB},场景里 1.21.2 亿个实例把渲染线程拖垮。周一晨会上,美术同学把截图拍到桌上:帧率从 6060 掉到 33,GPU 因为 overdraw 和实例数爆炸直接罢工。

这就是 PCG 在大世界里最容易踩到的坑:不是生成逻辑写错了,而是生成策略没有和流送、预算、缓存对齐。UE5.8 在 Procedural Content Generation Framework 里加了不少新接口,例如 Partial Generation、更细粒度的 Volume 绑定、以及和 HLOD 的集成。本文把这些改动串成一条线,从缓存、流送、运行时三个层面讲清楚怎么让 PCG 在大世界里跑得稳。

PCG 生成缓存:别让重复计算吃掉帧

为什么缓存会决定生死

PCG 图表(Graph)每次执行都会重新采样噪点、遍历点云、做网格体替换。如果场景里有 200200 个 PCG Volume,每个 Volume 触发一次全量生成,同样的噪点函数就要算 200200 回。对于基于 Perlin 或 Worley 的多层地形变形,单次采样成本 O(nm)O(n \cdot m)nn 是点数量,mm 是图层数。没有缓存时,这些开销会线性叠加。

UE5.8 引入了 PCG Component 级别的生成缓存机制。执行结果可以按 Component / Seed / Settings Hash 三元组落盘。下一次执行时,只要输入参数没变,就直接读缓存而不是重跑图表。

缓存命中率可以用下式估算:

η=NhitNhit+Nmiss\eta = \frac{N_{\text{hit}}}{N_{\text{hit}} + N_{\text{miss}}}

这里 NhitN_{\text{hit}} 是命中缓存的生成次数,NmissN_{\text{miss}} 是失效次数。失效通常由三种情况触发:Volume 位置变化、Settings 参数修改、World Partition Cell 重新加载。设计图表时,要把稳定的部分和易变的部分拆开,让稳定部分长期命中缓存。

缓存落盘与版本控制

缓存文件默认放在 Saved/PCG/ 下,命名包含 Hash。UE5.8 允许通过 UPCGComponent::SetGeneratedResourcesCachePath 把缓存重定向到项目版本控制目录,这样美术换机器也能直接加载别人生成好的结果。

下面是一段在编辑器启动时预加载缓存的示例代码:

void APCGWorldManager::PreloadGeneratedResources()
{
    for (TActorIterator<APCGVolume> It(GetWorld()); It; ++It)
    {
        UPCGComponent* PCGComp = It->GetComponent();
        if (!PCGComp) continue;

        const FPCGCacheKey Key = PCGComp->BuildCacheKey();
        if (FPCGCacheStore::Get().Contains(Key))
        {
            PCGComp->LoadFromCache(Key);
        }
    }
}

代码很短,但效果很实在。我们在正式构建机器上把生成缓存和 Jenkins 产物打包在一起,CI 时间从 4747 分钟降到 1212 分钟。

Partial Generation:只更新改过的那一小块

全量生成的代价

Early Access 阶段,我们的 PCG 图表每次编辑参数都会触发整个 Volume 重新生成。一个 2 km22\ \text{km}^2 的森林 Volume,全量生成要 88 分钟。策划调一个树的密度,喝两杯咖啡回来还没好。

UE5.8 的 Partial Generation 解决了这个问题。它的核心思想是:把 Volume 划分成若干子区域(Subgraph / Spatial Hash Cell),只重算受影响的 Cell。每个 Cell 维护自己的缓存和依赖图。

flowchart TD
    subgraph pcg ["PCG Volume"]
        graph["PCG Graph"]
        grid["Spatial Hash Grid"]
        cell_a["Cell A Cache"]
        cell_b["Cell B Cache"]
        cell_c["Cell C Dirty"]
    end
    graph --> grid
    grid --> cell_a
    grid --> cell_b
    grid --> cell_c

上图是一个简化结构。PCG Graph 通过 Spatial Hash Grid 把任务分发到三个 Cell。参数改动只让 Cell C 变脏,A 和 B 继续用缓存。

依赖追踪

Partial Generation 能成立的前提是依赖图足够准。UE5.8 在 UPCGGraphExecutor 里维护了一张 Attribute / Spatial 依赖表。如果某个节点读取了 PositionDensity 这类全局属性,执行器会自动把相关 Cell 标脏。

实际使用中要注意两个细节:

  1. 自定义 C++ 节点必须正确声明依赖属性,否则会被当成全局依赖,导致大片 Cell 被标脏。
  2. 随机种子必须按 Cell 拆分。如果所有 Cell 共享一个全局 Seed,一个 Cell 的改动会通过随机序列污染相邻 Cell。

我们在项目里把全局 Seed 改成 Seed = BaseSeed + CellIndex,局部随机性没丢,Partial 命中率从 31%31\% 提升到 78%78\%

与 World Partition 配合:Cell 边界是隐形杀手

边界裂缝与重复生成

大世界用 World Partition 切分成 Grid Cell。PCG Volume 跨 Cell 时,每个 Cell 加载都会触发一次生成。如果生成逻辑依赖 Volume 的局部坐标,就会在 Cell 边界出现裂缝:树的高度、密度、甚至位置对不上。

问题根源是坐标空间不一致。PCG 节点内部常用 Actor Space 或 Local Space,而 World Partition 按 World Space 切 Cell。转换没对齐时,同一点在相邻 Cell 会被采样成不同值。

flowchart TD
    A["加载 Cell A"] --> B["以 Cell A 局部原点采样"]
    C["加载 Cell B"] --> D["以 Cell B 局部原点采样"]
    B --> E{坐标空间是否一致}
    D --> E
    E -->|"否"| F["边界裂缝"]
    E -->|"是"| G["无缝生成"]

推荐做法

我们的做法是:所有地形采样和实例化都在 World Space 完成,只在最后输出阶段做一次 World To Local 变换。这样 Cell 边界处的采样结果天然一致。

对于需要跨 Cell 连续的属性,比如河流侵蚀高度,我们在 World Partition Settings 里开启 Enable PCG Cross-Cell Interpolation。它会自动在相邻 Cell 之间做 1616 米宽的重叠采样带, erosion 结果在边界处做线性混合。混合权重可以写成:

w(x) = \operatorname{clamp}\left(\frac{d - |x - x_b|}{d}, 0, 1\right)

dd 是重叠带半宽,xbx_b 是 Cell 边界坐标。这个简单的权重把边界高度差从 1.81.8 米压到 0.10.1 米以下。

运行时加载策略

World Partition 默认按玩家位置流送 Cell。PCG 生成不要一开始就全量触发,而是绑定到 FWorldPartitionRuntimeCell::OnCellShown 事件。Cell 进入视野后再生成,离开时可以选择释放或保留缓存。

释放缓存能省内存,但切回来会再卡顿一次。我们的方案是分级保留:

  • 当前 Cell:完整实例 + 缓存
  • 相邻 3×33 \times 3 Cell:完整实例 + 缓存
  • 更远 Cell:只保留缓存,不生成实例

内存预算 BB 按 Cell 估算:

B=Ccurrent+8Cneighbor+NdistantCcacheB = C_{\text{current}} + 8 \cdot C_{\text{neighbor}} + N_{\text{distant}} \cdot C_{\text{cache}}

其中 CcurrentC_{\text{current}} 是当前 Cell 内存,CneighborC_{\text{neighbor}} 是相邻 Cell 内存,CcacheC_{\text{cache}} 是单 Cell 缓存大小,NdistantN_{\text{distant}} 是远处 Cell 数量。用这个公式可以快速估算不同视距下的内存峰值。

与 Level Streaming 配合:传统关卡仍有一席之地

World Partition 不是万能药

有些场景并不适合切进 World Partition,比如副本、剧情触发区域、或者由多个小关卡拼成的城镇。这些地方常用 Level Streaming。UE5.8 里 PCG 和 Level Streaming 的配合方式与 World Partition 不同,需要手动管理生命周期。

Level Streaming 加载的是整个 Level,PCG Actor 跟着 Level 一起加载。问题是 PCG 生成可能在 Level 加载完成后立刻开始,而此时玩家可能还在加载画面,或者正在高速移动。如果生成耗时 22 秒,玩家进入区域时就会看到一片空地慢慢长出树来。

sequenceDiagram
    participant P as Player
    participant LS as "Level Streaming"
    participant PCG as "PCG Component"
    participant Cache as "PCG Cache"

    P->>LS: 接近触发区域
    LS->>LS: 异步加载 SubLevel
    LS->>PCG: Level 加载完成
    PCG->>Cache: 查询是否有缓存
    alt 命中缓存
        Cache-->>PCG: 返回生成结果
        PCG-->>LS: 实例立即可见
    else 未命中
        PCG->>PCG: 异步执行 Graph
        Note over PCG: 玩家可能看到生成过程
    end
    LS-->>P: 场景就绪

预生成 + 异步加载

解决这个问题的办法是预生成并把结果序列化到 Level。UE5.8 支持 Generate in Editor 后把 ISM / HISM 组件直接保存到 Persistent Level 或 SubLevel。这样运行时就不需要再跑 PCG Graph,只要加载 Level 就能看到完整场景。

具体步骤:

  1. 在编辑器里对 SubLevel 跑一遍 PCG 生成。
  2. 把生成的 ISM/HISM Actor 转换为 Static Mesh Actor 或保留为 HISM。
  3. 烘焙时这些实例会进 pak 文件,运行时零生成开销。

代价是包体变大。我们用量化压缩实例变换矩阵,把每个实例从 6464 字节压到 2828 字节,包体增量控制在 12%12\% 以内。

性能预算:给 PCG 戴上紧箍咒

为什么要先定预算

没有预算的优化是玄学。我们项目定了三条硬指标:

  • 编辑器生成单块 World Partition Cell 不超过 800 ms800\ \text{ms}
  • 运行时 Cell 切换卡顿不超过 33 ms33\ \text{ms}
  • 单 Cell 实例数不超过 5050

PCG 生成时间可以拆成几个部分:

Ttotal=Tsampling+Tfiltering+Tmesh+TspawnT_{\text{total}} = T_{\text{sampling}} + T_{\text{filtering}} + T_{\text{mesh}} + T_{\text{spawn}}

分别对应采样、过滤筛选、网格体选择、实例生成。不同阶段有不同优化手段。

采样阶段

采样通常是最大头。降低采样成本的直接办法是减少采样密度。草地这类高频细节用 0.250.25 米间距,灌木用 11 米,树用 44 米。密度函数不要在高频区域堆太多噪声层。我们在噪声节点里把 Octave 从 66 降到 44,采样时间少了 40%40\%,视觉差别不大。

过滤阶段

过滤节点比如 DifferenceBounds ModifierDensity Filter 会遍历大量点。尽量把过滤放在采样之后、实例化之前。先用粗粒度过滤把 90%90\% 的点筛掉,再跑精细逻辑。这个顺序调整让我们的森林生成从 2.12.1 秒降到 0.70.7 秒。

实例化阶段

生成实例时不要一个点创建一个 StaticMeshComponent,那是自杀行为。必须用 ISM 或 HISM。HISM 支持 LOD 和遮挡剔除,是大世界首选。UE5.8 的 HISM 还加了 Nanite 实例支持,对于石头、建筑碎块这类高密度模型可以直接走 Nanite 管线。

下面这段蓝图无关的代码展示了如何查询当前 PCG Component 的实例数预算:

int32 APCGWorldManager::EstimateInstanceBudget(UPCGComponent* Comp)
{
    const FBox Bounds = Comp->GetGridBounds();
    const float Area = Bounds.GetSize().X * Bounds.GetSize().Y;
    const float Density = Comp->GetDensityPerSquareMeter();
    const float CullRatio = Comp->GetExpectedCullRatio();
    return FMath::FloorToInt(Area * Density * CullRatio);
}

CullRatio 是经验值,森林我们取 0.350.35,草地取 0.80.8。用这个估算可以在生成前拒绝明显超预算的参数。

实例化与 HLOD:视觉距离决定一切

实例不是越多越好

PCG 最容易制造海量实例。一平方公里草地按 0.250.25 米间距就是 16001600 万个点。全实例化不现实,必须按距离分层:

  • 00-2020 米:高密度实例
  • 2020-8080 米:中密度实例
  • 8080-200200 米:低密度实例或 Billboard
  • >> 200200 米:HLOD 代理

分层函数可以用距离衰减:

D(r)=D0max(0,1rRmax)pD(r) = D_0 \cdot \max\left(0, 1 - \frac{r}{R_{\text{max}}}\right)^p

D0D_0 是近处密度,RmaxR_{\text{max}} 是最大影响半径,pp 控制衰减陡峭程度。p=2p=2 时过渡柔和,适合草地;p=4p=4 时边界清晰,适合树群。

HLOD 集成

UE5.8 让 PCG 生成的实例可以直接参与 HLOD 生成。在 PCG Graph 里输出 HLOD Layer 属性,World Partition HLOD Builder 会把同一层实例合并成代理网格。

graph LR
    A["PCG Generated Instances"] --> B{Distance}
    B -->|Close| C["HISM LOD0-2"]
    B -->|Mid| D["HISM LOD3-4"]
    B -->|Far| E["HLOD Proxy Mesh"]
    E --> F["Impostor Billboard"]

上图展示了实例到 HLOD 的分流。近处用 HISM 的多级 LOD,远处合并成 HLOD 代理。HLOD 代理可以用更低的面数,甚至用烘焙好的材质图集,减少 draw call。

Nanite 的边界

Nanite 能处理高面数几何,但不是所有实例都适合。细小、半透明、或者需要顶点动画的物体不要走 Nanite。PCG 生成的草丛我们先用传统 LOD,远景才切到 Nanite 合并体。

运行时生成优化:别把编辑器逻辑搬到游戏里

异步与分帧

运行时生成最怕阻塞游戏线程。UE5.8 的 UPCGSubsystem 支持 ExecuteGraphAsync,可以把生成任务丢到后台线程。但要注意,最终创建 ISM/HISM 组件还是要回到游戏线程。分帧创建是常见做法:每帧只创建 NN 个实例,NN 由当前帧时间余量决定。

分帧数量公式:

Nframe=TbudgetTcurrentTinstanceN_{\text{frame}} = \left\lfloor \frac{T_{\text{budget}} - T_{\text{current}}}{T_{\text{instance}}} \right\rfloor

TbudgetT_{\text{budget}} 是每帧留给 PCG 的时间,TcurrentT_{\text{current}} 是当前帧已用时间,TinstanceT_{\text{instance}} 是创建一个实例的平均耗时。这个值可以动态调整,帧率低时少生成,帧率高时多生成。

运行时参数要克制

运行时 PCG 通常只用来做局部变化,比如玩家砍倒一棵树后重新分布周围植被,或者爆炸破坏后的碎石。不要在运行时重跑整个地形变形图表。

我们的做法是预生成静态层,运行时只替换动态层。静态层进 pak,动态层用轻量 PCG 图表实时算。动态层的数据量控制在 50005000 个实例以内,耗时 <16 ms<16\ \text{ms}

避免运行时 GC 峰值

PCG 生成过程中会产生大量临时点云和属性数组。如果这些对象是 UObject,频繁创建销毁会触发垃圾回收。UE5.8 的 PCG Settings 里可以开启 Use Native Point Data 复用内存池。另外,自定义节点里尽量用 TArrayFPCGPoint 的栈分配,避免 NewObject

动态加载与卸载节奏

运行时生成的另一个坑是加载和卸载节奏没对齐玩家移动速度。假设玩家骑马以 15 m/s15\ \text{m/s} 前进,而 PCG 生成一个 Cell 需要 200 ms200\ \text{ms}。如果 Cell 在玩家距离边界 3030 米时才触发生成,等生成完玩家已经冲到空地中央了。

我们设的触发距离要留足余量。触发距离 DtriggerD_{\text{trigger}} 可以按最坏情况估算:

Dtrigger=vmaxTgen+DsafeD_{\text{trigger}} = v_{\text{max}} \cdot T_{\text{gen}} + D_{\text{safe}}

vmaxv_{\text{max}} 是玩家最大移动速度,TgenT_{\text{gen}} 是生成耗时,DsafeD_{\text{safe}} 是安全余量,通常取 5050-100100 米。对于高速移动机制比如滑翔或载具,这个距离还要再放大。

卸载同样要小心。Cell 一离开视野就立刻销毁实例,玩家在边界来回移动时会造成反复生成。我们的做法是给每个 Cell 加一个冷却计时器,离开视野 55 秒后才真正释放。这段时间内玩家如果折返,直接复用已有实例。

分析工具与检查清单

内置分析器

UE5.8 在编辑器里加了一个 PCG Insights 窗口,可以逐节点看耗时和内存。关键指标:

  • Graph Execution Time:整图耗时
  • Node Execution Time:单个节点耗时
  • Point Count:每阶段点数
  • Memory Peak:生成过程峰值内存

配合 stat pcg 控制台命令,运行时也能看到当前生成任务的 CPU 和内存占用。

自定义 Profiling Scope

在 C++ 节点里加 Scope 可以精确定位热点:

void UMyPCGNode::ExecuteInternal(FPCGContext* Context)
{
    SCOPE_CYCLE_COUNTER(STAT_MyPCGNodeExecute);
    
    for (FPCGPoint& Point : Context->InputData.GetPoints())
    {
        Point.Density *= ComputeLocalDensity(Point.Transform.GetLocation());
    }
}
flowchart LR
    A["启动 stat pcg"] --> B["复现卡顿"]
    B --> C["打开 PCG Insights"]
    C --> D["定位耗时节点"]
    D --> E["优化或拆分节点"]
    E --> F["重新测量"]
    F -->|"未达标"| C
    F -->|"达标"| G["提交"]

检查清单

下面是我们在每次大世界内容更新前会过一遍的清单:

  • [ ] 每个 PCG Component 都启用了生成缓存,缓存路径纳入版本控制
  • [ ] 参数修改后只触发 Partial Generation,非全量重算
  • [ ] World Partition Cell 边界采样使用 World Space 或开启 Cross-Cell Interpolation
  • [ ] Level Streaming 场景预生成实例并序列化到 SubLevel
  • [ ] 单 Cell 生成时间 <800 ms<800\ \text{ms},运行时切换卡顿 <33 ms<33\ \text{ms}
  • [ ] 实例化使用 HISM/Nanite,避免单个 StaticMeshComponent
  • [ ] 按距离分层,远景进入 HLOD
  • [ ] 运行时生成走异步分帧,动态层实例数 <5000<5000
  • [ ] 自定义 C++ 节点声明了依赖属性,避免过度标脏
  • [ ] 分析器验证过热节点已优化

把流程焊进团队工作流

技术方案再好,如果只有一两个人知道,也会慢慢腐烂。我们在项目里定了几条规则,让 PCG 性能工作流变成日常习惯。

第一,美术调完参数必须本地跑一遍 npx hexo generate 等价的全量生成。这里的习惯是:任何改动先看生成时间。如果单块 Cell 生成时间从 600 ms600\ \text{ms} 涨到 1.21.2 秒,立刻回滚找原因。

第二,每两周做一次大世界基准测试。固定相机路径、固定画质、固定平台,记录帧率曲线和内存峰值。把数据存成 CSV,方便对比版本差异。

第三,策划新增 PCG 规则前要先过预算评审。我们会问他三个问题:这个规则影响的 Cell 数量是多少?最坏情况下实例数多少?运行时会不会触发重生成?回答不上来就不合入。

第四,代码合入前必须跑一遍 PCG Insights 热点扫描。任何人新增自定义节点,都要在节点里加 SCOPE_CYCLE_COUNTER,并附上测试截图。

这些规则看起来繁琐,但执行两个月后,因为 PCG 导致的卡顿工单从平均每周 4.54.5 张降到 0.80.8 张。

结语

PCG 在 UE5.8 里已经从玩具变成生产工具。但工具顺手与否,取决于是否尊重大世界的规律:缓存减少重复、Partial 减少范围、流送控制生命周期、预算守住底线。那次把内存从 38 GB38\ \text{GB} 压回 12 GB12\ \text{GB} 的周末之后,我们养成一个习惯:每次新增 PCG 图表,先跑一遍性能预算,再交给美术调参数。

这篇文章里的数字都来自我们项目的真实记录,不同项目的地形复杂度、资产规格和团队习惯会有差别,所以不要把 800 ms800\ \text{ms}5050 万实例当成绝对标准。重点是建立属于自己的预算基线,然后持续监控。

希望这些踩坑记录能帮到你的项目。如果你的 PCG 图表也会把编辑器卡死,不妨从缓存和 Partial Generation 开始查起。找到第一个热点,剩下的优化路径通常会自己浮现出来。祝你也能在下一个周末前把卡顿干掉,省下喝咖啡的时间去修更有意思的 bug,或者至少让策划不用再等八分钟才能看到效果,这比任何口头汇报都更有说服力,也更能让美术同学安心调参数。