UE5.8 PCG 性能与流送策略
一个典型的卡顿周末
去年秋天,我们项目组把一片 的开放世界塞进 World Partition 后,策划按下生成键就去喝咖啡了。十分钟后,编辑器还没响应,主线程占用单核打满,内存飙到 ,场景里 亿个实例把渲染线程拖垮。周一晨会上,美术同学把截图拍到桌上:帧率从 掉到 ,GPU 因为 overdraw 和实例数爆炸直接罢工。
这就是 PCG 在大世界里最容易踩到的坑:不是生成逻辑写错了,而是生成策略没有和流送、预算、缓存对齐。UE5.8 在 Procedural Content Generation Framework 里加了不少新接口,例如 Partial Generation、更细粒度的 Volume 绑定、以及和 HLOD 的集成。本文把这些改动串成一条线,从缓存、流送、运行时三个层面讲清楚怎么让 PCG 在大世界里跑得稳。
PCG 生成缓存:别让重复计算吃掉帧
为什么缓存会决定生死
PCG 图表(Graph)每次执行都会重新采样噪点、遍历点云、做网格体替换。如果场景里有 个 PCG Volume,每个 Volume 触发一次全量生成,同样的噪点函数就要算 回。对于基于 Perlin 或 Worley 的多层地形变形,单次采样成本 , 是点数量, 是图层数。没有缓存时,这些开销会线性叠加。
UE5.8 引入了 PCG Component 级别的生成缓存机制。执行结果可以按 Component / Seed / Settings Hash 三元组落盘。下一次执行时,只要输入参数没变,就直接读缓存而不是重跑图表。
缓存命中率可以用下式估算:
这里 是命中缓存的生成次数, 是失效次数。失效通常由三种情况触发: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 时间从 分钟降到 分钟。
Partial Generation:只更新改过的那一小块
全量生成的代价
Early Access 阶段,我们的 PCG 图表每次编辑参数都会触发整个 Volume 重新生成。一个 的森林 Volume,全量生成要 分钟。策划调一个树的密度,喝两杯咖啡回来还没好。
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 依赖表。如果某个节点读取了 Position 或 Density 这类全局属性,执行器会自动把相关 Cell 标脏。
实际使用中要注意两个细节:
- 自定义 C++ 节点必须正确声明依赖属性,否则会被当成全局依赖,导致大片 Cell 被标脏。
- 随机种子必须按 Cell 拆分。如果所有 Cell 共享一个全局 Seed,一个 Cell 的改动会通过随机序列污染相邻 Cell。
我们在项目里把全局 Seed 改成 Seed = BaseSeed + CellIndex,局部随机性没丢,Partial 命中率从 提升到 。
与 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 之间做 米宽的重叠采样带, erosion 结果在边界处做线性混合。混合权重可以写成:
是重叠带半宽, 是 Cell 边界坐标。这个简单的权重把边界高度差从 米压到 米以下。
运行时加载策略
World Partition 默认按玩家位置流送 Cell。PCG 生成不要一开始就全量触发,而是绑定到 FWorldPartitionRuntimeCell::OnCellShown 事件。Cell 进入视野后再生成,离开时可以选择释放或保留缓存。
释放缓存能省内存,但切回来会再卡顿一次。我们的方案是分级保留:
- 当前 Cell:完整实例 + 缓存
- 相邻 Cell:完整实例 + 缓存
- 更远 Cell:只保留缓存,不生成实例
内存预算 按 Cell 估算:
其中 是当前 Cell 内存, 是相邻 Cell 内存, 是单 Cell 缓存大小, 是远处 Cell 数量。用这个公式可以快速估算不同视距下的内存峰值。
与 Level Streaming 配合:传统关卡仍有一席之地
World Partition 不是万能药
有些场景并不适合切进 World Partition,比如副本、剧情触发区域、或者由多个小关卡拼成的城镇。这些地方常用 Level Streaming。UE5.8 里 PCG 和 Level Streaming 的配合方式与 World Partition 不同,需要手动管理生命周期。
Level Streaming 加载的是整个 Level,PCG Actor 跟着 Level 一起加载。问题是 PCG 生成可能在 Level 加载完成后立刻开始,而此时玩家可能还在加载画面,或者正在高速移动。如果生成耗时 秒,玩家进入区域时就会看到一片空地慢慢长出树来。
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 就能看到完整场景。
具体步骤:
- 在编辑器里对 SubLevel 跑一遍 PCG 生成。
- 把生成的 ISM/HISM Actor 转换为 Static Mesh Actor 或保留为 HISM。
- 烘焙时这些实例会进 pak 文件,运行时零生成开销。
代价是包体变大。我们用量化压缩实例变换矩阵,把每个实例从 字节压到 字节,包体增量控制在 以内。
性能预算:给 PCG 戴上紧箍咒
为什么要先定预算
没有预算的优化是玄学。我们项目定了三条硬指标:
- 编辑器生成单块 World Partition Cell 不超过
- 运行时 Cell 切换卡顿不超过
- 单 Cell 实例数不超过 万
PCG 生成时间可以拆成几个部分:
分别对应采样、过滤筛选、网格体选择、实例生成。不同阶段有不同优化手段。
采样阶段
采样通常是最大头。降低采样成本的直接办法是减少采样密度。草地这类高频细节用 米间距,灌木用 米,树用 米。密度函数不要在高频区域堆太多噪声层。我们在噪声节点里把 Octave 从 降到 ,采样时间少了 ,视觉差别不大。
过滤阶段
过滤节点比如 Difference、Bounds Modifier、Density Filter 会遍历大量点。尽量把过滤放在采样之后、实例化之前。先用粗粒度过滤把 的点筛掉,再跑精细逻辑。这个顺序调整让我们的森林生成从 秒降到 秒。
实例化阶段
生成实例时不要一个点创建一个 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 是经验值,森林我们取 ,草地取 。用这个估算可以在生成前拒绝明显超预算的参数。
实例化与 HLOD:视觉距离决定一切
实例不是越多越好
PCG 最容易制造海量实例。一平方公里草地按 米间距就是 万个点。全实例化不现实,必须按距离分层:
- - 米:高密度实例
- - 米:中密度实例
- - 米:低密度实例或 Billboard
- 米:HLOD 代理
分层函数可以用距离衰减:
是近处密度, 是最大影响半径, 控制衰减陡峭程度。 时过渡柔和,适合草地; 时边界清晰,适合树群。
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 组件还是要回到游戏线程。分帧创建是常见做法:每帧只创建 个实例, 由当前帧时间余量决定。
分帧数量公式:
是每帧留给 PCG 的时间, 是当前帧已用时间, 是创建一个实例的平均耗时。这个值可以动态调整,帧率低时少生成,帧率高时多生成。
运行时参数要克制
运行时 PCG 通常只用来做局部变化,比如玩家砍倒一棵树后重新分布周围植被,或者爆炸破坏后的碎石。不要在运行时重跑整个地形变形图表。
我们的做法是预生成静态层,运行时只替换动态层。静态层进 pak,动态层用轻量 PCG 图表实时算。动态层的数据量控制在 个实例以内,耗时 。
避免运行时 GC 峰值
PCG 生成过程中会产生大量临时点云和属性数组。如果这些对象是 UObject,频繁创建销毁会触发垃圾回收。UE5.8 的 PCG Settings 里可以开启 Use Native Point Data 复用内存池。另外,自定义节点里尽量用 TArray 和 FPCGPoint 的栈分配,避免 NewObject。
动态加载与卸载节奏
运行时生成的另一个坑是加载和卸载节奏没对齐玩家移动速度。假设玩家骑马以 前进,而 PCG 生成一个 Cell 需要 。如果 Cell 在玩家距离边界 米时才触发生成,等生成完玩家已经冲到空地中央了。
我们设的触发距离要留足余量。触发距离 可以按最坏情况估算:
是玩家最大移动速度, 是生成耗时, 是安全余量,通常取 - 米。对于高速移动机制比如滑翔或载具,这个距离还要再放大。
卸载同样要小心。Cell 一离开视野就立刻销毁实例,玩家在边界来回移动时会造成反复生成。我们的做法是给每个 Cell 加一个冷却计时器,离开视野 秒后才真正释放。这段时间内玩家如果折返,直接复用已有实例。
分析工具与检查清单
内置分析器
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 生成时间 ,运行时切换卡顿
- [ ] 实例化使用 HISM/Nanite,避免单个 StaticMeshComponent
- [ ] 按距离分层,远景进入 HLOD
- [ ] 运行时生成走异步分帧,动态层实例数
- [ ] 自定义 C++ 节点声明了依赖属性,避免过度标脏
- [ ] 分析器验证过热节点已优化
把流程焊进团队工作流
技术方案再好,如果只有一两个人知道,也会慢慢腐烂。我们在项目里定了几条规则,让 PCG 性能工作流变成日常习惯。
第一,美术调完参数必须本地跑一遍 npx hexo generate 等价的全量生成。这里的习惯是:任何改动先看生成时间。如果单块 Cell 生成时间从 涨到 秒,立刻回滚找原因。
第二,每两周做一次大世界基准测试。固定相机路径、固定画质、固定平台,记录帧率曲线和内存峰值。把数据存成 CSV,方便对比版本差异。
第三,策划新增 PCG 规则前要先过预算评审。我们会问他三个问题:这个规则影响的 Cell 数量是多少?最坏情况下实例数多少?运行时会不会触发重生成?回答不上来就不合入。
第四,代码合入前必须跑一遍 PCG Insights 热点扫描。任何人新增自定义节点,都要在节点里加 SCOPE_CYCLE_COUNTER,并附上测试截图。
这些规则看起来繁琐,但执行两个月后,因为 PCG 导致的卡顿工单从平均每周 张降到 张。
结语
PCG 在 UE5.8 里已经从玩具变成生产工具。但工具顺手与否,取决于是否尊重大世界的规律:缓存减少重复、Partial 减少范围、流送控制生命周期、预算守住底线。那次把内存从 压回 的周末之后,我们养成一个习惯:每次新增 PCG 图表,先跑一遍性能预算,再交给美术调参数。
这篇文章里的数字都来自我们项目的真实记录,不同项目的地形复杂度、资产规格和团队习惯会有差别,所以不要把 或 万实例当成绝对标准。重点是建立属于自己的预算基线,然后持续监控。
希望这些踩坑记录能帮到你的项目。如果你的 PCG 图表也会把编辑器卡死,不妨从缓存和 Partial Generation 开始查起。找到第一个热点,剩下的优化路径通常会自己浮现出来。祝你也能在下一个周末前把卡顿干掉,省下喝咖啡的时间去修更有意思的 bug,或者至少让策划不用再等八分钟才能看到效果,这比任何口头汇报都更有说服力,也更能让美术同学安心调参数。