老周第一次把 PCG 搬进 Roguelike 运行时生成的时候,信心满满。编辑器里点一下生成,房间、走廊、宝箱、精英怪点位整整齐齐。他想着,把这些 Graph 在游戏启动时跑一遍不就完了。结果打包后第一次进游戏,加载屏卡了十二秒,然后帧率直接掉到个位数。玩家还没看见第一个房间,后台已经在第三十八次 GC 崩溃了。
问题很直接:PCG 在编辑器里是离线工具,运行时却要为每一帧负责。UE5.8 把这件事拆成了几条清晰的线,核心就是 UPCGSubsystem。这篇文章聊的,是我顺着这几条线踩过的坑,以及怎么把 Graph 的执行从阻塞调用改成可以呼吸的异步任务。
1. UPCGSubsystem 到底管什么
UPCGSubsystem 是 UE5.8 运行时 PCG 的入口。每个 UWorld 都会挂一个实例,生命周期跟着世界走。它手里攥着三样东西:当前世界所有 UPCGComponent 的注册表、Graph 执行的调度器、以及和 World Partition / Level Streaming 打交道的接口。
编辑器里我们习惯右键点击 Generate,这个动作在运行时不会凭空出现。运行时想要 PCG 干活,必须通过 Subsystem 发起请求。请求的内容包括:目标 Component、要执行的 Graph Asset、输入数据集合、以及一个可选的委托,用来在 Graph 跑完之后拿到结果。
flowchart TD
subgraph world ["UWorld"]
wp["World Partition Runtime"]
sub["UPCGSubsystem"]
comp["UPCGComponent"]
actor["Generated Actor"]
end
subgraph data ["Generated Data"]
inst["Procedural Instances"]
cell["Cell Metadata"]
end
sub --> comp
comp --> actor
actor --> inst
wp --> cell
sub --> cellSubsystem 不直接算点、摆实例。它更像一个包工头,把 Graph 交给执行器,再把执行器吐出来的 FPCGContext 翻译回场景里的 Actor 和 Component。这样做的好处是:Graph 的逻辑和世界的变化解耦。换房间、换种子、换难度,只需要换输入参数,Graph 本身不需要重写。
老周项目里,每个 Roguelike 房间是一个 Actor,身上挂一个 UPCGComponent。进入新楼层时,关卡管理器把房间的种子、尺寸、主题标签塞进 Component,然后调用 Subsystem 的异步执行接口。Subsystem 会根据当前帧负载决定立即跑还是排队等下一帧。
2. 运行时执行 Graph
编辑器里 Generate 大致是同步的:点下去,等进度条走完,场景就更新了。运行时如果也这么干,主线程会被 Graph 的节点链堵住。UE5.8 提供了 ExecuteGraphAsync 这一组 API,返回一个 FPCGGraphTaskHandle。
拿到 Handle 之后,主线程该干嘛干嘛。Graph 的节点会被拆成可重入的执行单元,每个单元执行完检查时间片。如果还有剩余时间,继续下一个节点;如果时间到了,把状态封存,下一帧再恢复。
下面这段代码是老周项目里房间生成器的简化版。它演示了怎么在运行时发起一次 Graph 执行,并监听完成回调。
void ARoomGenerator::RequestGenerate(int32 Seed, FGameplayTag Theme)
{
UWorld* World = GetWorld();
if (!World) return;
UPCGSubsystem* PCGSub = World->GetSubsystem<UPCGSubsystem>();
if (!PCGSub) return;
UPCGComponent* PCGComp = FindComponentByClass<UPCGComponent>();
if (!PCGComp) return;
FPCGGraphParameters Params;
Params.Seed = Seed;
Params.SetTagValue(TEXT("Theme"), Theme.ToString());
FPCGGraphTaskHandle Handle = PCGSub->ExecuteGraphAsync(
PCGComp,
RuntimeGraph,
Params,
EPCGExecutionType::Generate
);
PCGSub->RegisterCompletionCallback(Handle,
FPCGGraphCompleted::CreateUObject(this, &ARoomGenerator::OnRoomReady));
}代码只有二十多行,但背后有几个细节要注意。
第一,RuntimeGraph 必须把所有节点都设置成运行时可用。编辑器里很多节点依赖 UWorld 的 Editor 接口,比如 Editor Mesh Selector、Landscape Spline Reader,这些在打包版本里会失效或被忽略。老周最开始的 Graph 用了 Get Actor Data 节点的 Editor 路径,打包后怎么也读不到场景里的碰撞体,就是因为这个。
第二,EPCGExecutionType::Generate 和 Construct 的行为不一样。Generate 只负责产出数据,Construct 才会真正把实例写到世界里。运行时为了控制节奏,通常先 Generate 拿到数据,再决定什么时候 Construct。
3. 分帧处理与异步
Graph 节点链的复杂度不是线性可控的。一个 Point Filter 可能只跑几微秒,一个 Mesh Spawner 面对十万实例就可能吃掉好几毫秒。UE5.8 的调度器会在每个节点执行前检查帧预算,公式可以写成:
如果当前节点预估耗时 大于 ,调度器就把上下文序列化,挂到下一帧。这个预估不是万能的,它基于节点历史执行时间的滑动平均。对于第一次跑的节点,默认给一个保守值。
flowchart TD
A["Subsystem 收到 Graph 请求"] --> B["创建 FPCGGraphTask"]
B --> C["加入分帧调度队列"]
C --> D["本帧开始执行"]
D --> E{节点执行完毕?}
E -->|"是"| F{是否还有后续节点?}
F -->|"是"| G["检查剩余时间片"]
G -->|"足够"| D
G -->|"不足"| H["保存 Context 状态"]
H --> I["下一帧恢复"]
I --> D
F -->|"否"| J["触发完成回调"]
E -->|"否"| H老周的项目把单帧预算设成 。为什么是 ?因为他锁 帧,单帧总预算是 ,渲染和逻辑各占一部分,留给 PCG 的只剩这么多。预算不是越小越好,切得太碎会导致 Context 序列化的开销反噬。实测下来 是大多数中低端设备的甜点。
异步执行还会带来一个老问题:回调地狱。一个房间生成完,可能要接着生成下一个房间,然后连接走廊,最后刷怪。UE5.8 允许把多个 Graph 任务串成链,前一个任务的输出作为后一个任务的输入参数。链里的每个任务仍然独立分帧,但逻辑顺序由 Subsystem 保证。
4. 加载屏与渐进式生成
Roguelike 每一局开局,总有一大堆房间要准备。老周最早的做法是在加载屏背后一次性跑完全部 Graph。结果低端机上加载屏能放半首歌,玩家以为游戏死了。
UE5.8 里更务实的做法是:加载屏只负责必要资源,真正耗时的 PCG 放到进入关卡后渐进完成。玩家先看到起始房间,四周的门锁着,后台慢慢把相邻房间算出来。门打开的瞬间,房间已经准备好了。
sequenceDiagram
participant P as "玩家"
participant L as LoadingScreen
participant S as UPCGSubsystem
participant G as GraphExecutor
participant W as World
P->>L: 打开加载界面
L->>S: 生成起始房间
S->>G: 执行必要节点
G-->>S: 完成
S->>W: 提交起始房间
L->>P: 隐藏加载界面
loop 游戏进行中
P->>S: 靠近新门
S->>G: 渐进生成相邻房间
G-->>S: 返回进度
S->>W: 提交实例
end渐进生成有个前提:玩家不会在一帧之内穿过两扇门。所以相邻房间的生成可以提前好几秒开始。老周用了一个简单的触发体积,玩家在房间里走动时,半径 米内的门会被标记为准备中。Subsystem 给这些门的 Graph 任务更高的优先级,但仍然是分帧执行。
加载屏也不是完全没用。如果一局游戏需要预计算全局关卡图,比如 Roguelike 的地图拓扑,这个可以在加载屏里同步或准同步完成。拓扑数据很小,不直接产生实例,耗时可控。把它和实例生成分开,是加载时间优化的关键。
5. 与 World Partition 的集成
大世界分块生成是另一个战场。老周后来接了一个开放世界项目,地图按 划分 Cell,每个 Cell 用 PCG 生成植被、岩石和废墟。这里不能把 PCG 当成普通 Actor 随意生成实例,必须尊重 World Partition 的流送边界。
UE5.8 的集成点是 WorldPartitionRuntimeHash 和 PCG 的 Runtime Grid。PCG Subsystem 在注册 Component 时会检查它所在的 Cell,Graph 执行完成后,生成的 Actor 和数据会被打上对应的 Cell ID。World Partition 在加载或卸载 Cell 时,会通知 Subsystem 哪些 PCG 数据需要保留、哪些可以释放。
老周遇到的第一个坑是实例跨 Cell。一棵树的树冠可能伸到隔壁 Cell,如果这棵树只被写入原 Cell,隔壁 Cell 卸载时树会凭空消失。UE5.8 提供的做法是 Cell Boundary Sampler,在 Graph 里先按边界把点切到相邻 Cell,再分别输出。代价是同样的输入数据会被执行多次,所以只在边界区域用高密度采样,内部区域用低密度。
另一个坑是 HLOD。PCG 生成的静态网格实例默认不会自动进入 HLOD 烘焙流程,因为它们是运行时产物。UE5.8 允许把 PCG 输出缓存到 PCGGeneratedDocument 一样的资源里,然后在编辑器阶段预烘焙 HLOD。运行时加载的是预烘焙结果,PCG 只负责按规则把它摆到正确位置。这样远处的 Cell 可以用 HLOD 代理,近处才用完整实例。
真正开工时,老周把大世界拆成三层:远景用 HLOD 烘焙好的代理,中景用 PCG 运行时生成的低密度实例,近景用完整 Graph。这样玩家在移动时,GPU 的压力变化是平滑的,而不是到某个 Cell 边界突然爆增。World Partition 会按距离切换这三层,PCG Subsystem 只需要保证当前层的数据在正确的时间点准备好。
还有一个细节是编辑器预览和运行时不一致。老周在编辑器里调整 Graph 时,所有 Cell 都是可见的,采样范围可以拉得很远。打包后 Cell 没加载就采不到点,结果植被密度比编辑器低很多。后来他在 Graph 开头加了一个 Is Cell Loaded 判断,未加载区域直接跳过,等 World Partition 加载完成后再触发一次 PCG 生成。
6. 运行时生成与编辑器的差异
很多人第一次把 PCG 从编辑器搬到运行时,都会有一种错觉:同一张 Graph,结果应该一模一样。实际上,同一张 Graph 在两种环境下跑出来的差异能写满一页纸。
编辑器有 Undo/Redo,有 PIE 和 Standalone 的边界信息,有大量只在 Editor 模式才有效的节点。运行时没有这些。最明显的区别是输入数据。编辑器里 Get Surface Data 可以读到整个 Persisten Level 的地形和静态网格;运行时你只能读到当前已加载的 Cell 和已经存在的 Actor。
graph LR
A["编辑器 Graph"] --> B["Undo/Redo 支持"]
A --> C["Editor-only 节点"]
A --> D["全关卡输入数据"]
E["运行时 Graph"] --> F["无撤销"]
E --> G["仅 Runtime-safe 节点"]
E --> H["仅已加载数据"]
C -.->|"打包后失效"| G
D -.->|"Cell 流送限制"| H种子行为也需要确认。编辑器里每次 Generate 可能重新播种;运行时为了保证联机一致,必须显式指定种子,并且所有随机节点都要走同一个 FRandomStream。UE5.8 的 Random 节点默认和 Component 的 Seed 绑定,但如果你在蓝图里手动改了 Seed,记得同步改回 Graph 的输入参数,否则同一局游戏里两个客户端看到的房间会不一样。
还有一件小事:运行时没有 Seamless Travel 之外的世界重载。也就是说,如果 Graph 执行到一半,目标 Actor 被卸载了,Subsystem 会取消这次任务并调用失败回调。老周因为这个漏掉失败处理,后台报错刷屏。后来他在回调里加了一层兜底:如果任务被取消,就把房间标记为脏,等玩家再次靠近时重新请求。
7. 性能预算与流送
性能预算不是拍脑袋定的。老周的流程是先跑一轮 Profiler,把单帧时间拆成固定开销和 PCG 可变开销。固定开销包括角色动画、物理、UI、音频,这部分不能动。剩下的才是 PCG 能花的钱。
对于 帧目标, 测出来 ,那留给 PCG 的就是 。但这个数不能全花掉,要留 的缓冲,否则帧率会抖动。最终老周把单帧 PCG 上限锁在 。
预算要拆成两部分:CPU 计算预算和 IO 预算。CPU 预算管 Graph 节点执行;IO 预算管实例构造、材质加载、纹理流送。UE5.8 的调度器目前主要管 CPU,IO 这边需要你自己用 AsyncLoading 和 Streaming 预算来控。老周的做法是在 Graph 输出阶段把实例按材质分组,同一材质的实例集中提交,减少 RHI 状态切换。
流送方面,不要让所有 Cell 的 PCG 同时开工。Subsystem 支持优先级队列,距离玩家近的 Cell 优先级高,远的排队。每个 Cell 还可以设置 LODBias,远处的 Cell 用简化版 Graph,只生成少量大物体。近处用完整 Graph,铺满细节。
内存也要算进去。PCG 输出的大量 UInstancedStaticMeshComponent 会吃掉可观的显存和系统内存。老周给每个 Cell 设了实例数量上限,公式是:
其中 包含变换、颜色、自定义数据。如果 Graph 输出超过上限,后面的实例会被丢弃或者合并成 HierarchicalInstancedStaticMesh。合并能减少 Draw Call,但会增加构建时间,所以只在必要时触发。
预算还要考虑峰值。Roguelike 进入新楼层时,可能同时触发十几个房间的生成。如果 Subsystem 不对并发任务数做限制,这些任务会抢满所有时间片,帧率直接崩掉。UE5.8 允许设置 Max Concurrent Graph Tasks,老周设成 ,保证后台最多只有三个 Graph 同时跑,剩下的在队列里等。这个值和设备档位绑定,高端机可以放宽到 ,低端机降到 。
内存方面,老周给每个 Cell 设了实例数量上限,公式是:
其中 包含变换、颜色、自定义数据。如果 Graph 输出超过上限,后面的实例会被丢弃或者合并成 HierarchicalInstancedStaticMesh。合并能减少 Draw Call,但会增加构建时间,所以只在必要时触发。
8. 网络同步
Roguelike 做成多人,PCG 生成的内容怎么同步?最简单的办法是服务端跑完 Graph,把生成的 Actor 复制到客户端。但这不适合大规模实例,复制十万棵树的坐标能把带宽撑爆。
更合理的做法是:只同步种子和规则,客户端自己跑 Graph。UE5.8 的 UPCGSubsystem 在服务器和客户端都有实例,但服务器拥有权威。服务器决定每个房间的 Seed、难度参数、主题标签,然后通过 RPC 或者 Replicated 的 Component 属性把这些轻量数据下发。客户端收到后,在本地 Subsystem 里执行同样的 Graph,得到同样的结果。
sequenceDiagram
participant SV as Server
participant CL as Client
participant SS as "Server PCGSubsystem"
participant SC as "Client PCGSubsystem"
participant GW as GameWorld
SV->>SS: 生成房间 Seed/参数
SS->>GW: 输出实例
SV->>CL: Replicate Seed + Params
CL->>SC: 发起本地 Graph 执行
SC->>GW: 输出实例
Note over SV,CL: 客户端不等待实例复制前提是 Graph 必须是确定性的。老周在这条路上摔过一次:Graph 里用了一个 Get Random Point In Bounds 节点,内部随机源在编辑器和服务端一致,但客户端和服务器用的 FRandomStream 初始状态不一样。查了半天才发现是这个节点没有正确绑定 Component Seed。后来他给所有随机节点统一加了 Seed 输入,并且禁用了依赖系统时间的任何节点。
如果 Graph 输出的是交互对象,比如宝箱、敌人、陷阱,这些不能靠确定性复现。服务器生成这些 Actor 后,要按普通网络同步走。可以把它们和静态实例分开处理:静态实例由 PCG 本地生成,动态交互对象由服务器生成并复制。Subsystem 支持把 Graph 输出拆成 Static 和 Dynamic 两个通道,Static 不走网络,Dynamic 走网络。
还要处理 late join。新玩家加入时,可能已经错过了某些房间的生成 RPC。老周的方案是在 GameState 里维护一个 Room Generation Journal,记录每个房间的 Seed 和生成状态。新玩家加入后,从 Journal 里按顺序重放,本地的 PCG Subsystem 重新执行一遍。Journal 很小,几百字节就能存一局的地图状态。
专用服务器上还要考虑一点:服务器不需要渲染,所以 PCG 的 Construct 阶段可以跳过视觉实例,只生成碰撞体和交互对象。UE5.8 的 Subsystem 支持按执行端过滤节点,WithEditor 和 WithGame 之外还能指定 DedicatedServer。老周在服务器上只跑 Collision 和 Gameplay 相关的输出通道,把 Mesh Spawner 留给客户端本地生成。这样服务器内存和 CPU 都省了一大截。
写在最后
把 PCG 从编辑器搬进运行时,本质上是在和时间做交易。你不能再像离线工具那样无限制地燃烧 CPU,而是要把每一毫秒都花在玩家看得见、摸得着的地方。
老周现在的 Roguelike 项目在低端机上开局加载 秒,进入关卡后前 秒渐进完成剩余房间的生成,帧率稳定在 帧以上。大世界那边, 的地图按 Cell 流送,玩家骑马跑图时几乎感觉不到后台在生成。
这些数字背后是几个原则的反复敲打:Subsystem 是入口,Graph 要分帧执行,加载屏别贪多,World Partition 的边界必须尊重,运行时和编辑器是两回事,性能预算要量化,网络同步只传种子不传实例。把这些原则落到代码里,PCG 才能真正从开发工具变成游戏体验的一部分。