一、背景与问题
在大世界游戏中,随机系统是常见需求:怪物刷新、资源分布、事件触发等都依赖"从一组候选点中随机选取并生成 Actor"的能力。随着项目规模增长,场景中需要管理的随机出生点从数百扩展到数千甚至上万,传统方案逐渐暴露出内存、时序和宏观管控方面的瓶颈。
一个好的随机系统需要同时满足以下目标:
- 可总览:策划能宏观看到所有参与随机的内容,而非"来一个才知道一个"
- 可预览:编辑器中直观看到候选 Actor 的位置和表现
- 实例数据可编辑:策划可以在场景 Details 面板上直接编辑每个 Actor 的实例参数(区别于 CDO)
- 按需加载:未命中的 Actor 不应占用运行时内存
- 正确打包:所有候选 Actor 的实例数据都必须被 Cook 进包体
本文从常规方案出发,逐步演进到基于 World Partition(WP)+ DataLayer + 全自动化收集 的方案,梳理各方案在逻辑设计、性能表现、配置管理和打包流程上的差异,以及演进过程中的思考。
本质是实现了Actor静默系统,UE本身没有Actor Active机制。
二、方案一:SpawnpointManager 直接场景收集
2.1 基本思路
最直观的实现方式:策划在场景编辑器中摆放 SpawnpointActor(点Actor),每个 Actor 挂载 SpawnFactoryComponent 来配置生成类型、模式和参数。运行时,SpawnFactoryComponent在BeginPlay将自身注册到SpawnFactoryManager(GameMode 组件),然后Manager通过遍历注册列表统一调度生成。
运行流程:
┌─────────────────────────────────────────────────────────────────┐
│ Editor(编辑器) │
│ 策划手动摆放 SpawnPointActor → 配置 SpawnFactoryComponent │
└───────────────────────────┬─────────────────────────────────────┘
│ 场景加载
▼
┌─────────────────────────────────────────────────────────────────┐
│ Runtime(运行时) │
│ 1. 所有 SpawnPointActor 随关卡加载进内存 │
│ 2. BeginPlay → RegisterSpawnFactory() 注册到 Manager │
│ 3. Manager 每帧 OnTick → 遍历 Spawners → 按模式生成 │
│ - OneTime:自动生成一次 │
│ - Periodic:按 SpawnPeriod 周期生成 │
│ - Manual:外部手动调用 │
│ - Director:规则随机生成(联动导演层) │
└─────────────────────────────────────────────────────────────────┘
优点:
- 实现简单,学习成本低,只需 SpawnFactoryComponent + SpawnFactoryManager 即可完成
- 所见即所得,编辑器中直接看到出生点位置和配置
- 灵活调整,运行时可动态增减 Spawner,支持多种生成模式
缺点:
- 内存浪费:所有候选 Actor 都会以"空壳"形式加载进内存,即使最终未被随机选中
- 时序不确定:Actor 的注册先后顺序不可控,Manager 不清楚何时收集完备
- 难以总览:无法宏观知道参与随机的 Actor 到底有多少,只能逐个注册时才知道
三、方案二:基于事件的选点生成
3.1 编辑器离线收集
在常规方案的基础上做了一版改进:依旧在场景中摆放 SpawnpointActor 并编辑 SpawnFactoryComponent中Config,但新增一个配置标记表示"该 Actor 由导演层统一处理随机"。
导演层是项目自研的上层关卡/事件管理系统,负责统筹管理关卡中的事件触发、怪物刷新、资源分配等逻辑。
编辑器中提前收集所有由导演层控制的 SpawnpointActor,将这些点的数据统一存入一张收集表。上层逻辑读取该表,按标签分类后执行随机。
改进效果:
- 解决了总览问题:所有参与随机的点一目了然
- 解决了内存浪费问题:Actor 可设置为 EditorOnly,只在编辑器中存在用于收集,不占用运行时内存
- 解决了时序问题:因为有总览表,所以可以轻松控制时序
3.2 选点生成
因为将场景中点的数据都收集到了表里,我们可以直接在表里配置好各个点位要生成什么数据,生成的时序,生成互斥关系等。
运行流程:
┌─────────────────────────────────────────────────────────────────┐
│ Editor(编辑器) │
│ 1. 策划摆放 SpawnPointActor + 配置点组名/标签 │
│ 2. 配置 DataTable:事件表(时间→事件ID→点组→生成蓝图) │
└───────────────────────────┬─────────────────────────────────────┘
│ 关卡加载
▼
┌─────────────────────────────────────────────────────────────────┐
│ Runtime(运行时) │
│ 1. SpawnRule.Play() → 从 MapEventTable 加载事件定义 │
│ 2. 按 DelayTime 构建 Time2Rules 时间→事件映射 │
│ 3. 定时器 OnTimer() → 到时间 → 执行对应事件 │
│ 4. 事件执行 → 选可用点位 │
│ 5. 互斥检查 → 冲突事件互斥 │
│ 6. 占用点位 → 生成 Actor │
└─────────────────────────────────────────────────────────────────┘
常规方案的视角: 事件驱动的视角:
┌──────────────────────┐ ┌──────────────────────────┐
│ 场景中分散的出生点 │ │ 时间轴事件表 │
│ │ │ │
│ ● 点A (怪物) │ │ T=0s → 事件1(点组A,怪) │
│ ● 点B (宝箱) │ │ T=10s → 事件2(点组B,箱) │
│ ● 点C (怪物) │ │ T=10s → 事件3(点组C,怪) │
│ ● 点D (采集) │ │ ↑ 互斥事件2 │
│ ...看不到全貌 │ │ T=30s → 事件4(点组D,采) │
└──────────────────────┘ └──────────────────────────┘
优点:
- 事件编排能力:通过 DataTable 事件表定义时间轴,策划可以总览"第N秒刷什么"的全局节奏
- 互斥与优先级:支持事件间互斥、优先级控制同一时刻的执行顺序
- 数据与场景分离:事件定义在 DataTable 中,独立于场景文件,版本管理更友好
- 点组复用:通过点组名/标签分组,同一组点位可被多个事件复用(虽然可以这么做,但是不建议,保持单一性原则最好)
缺点:
- 需要额外写收集工具:事件表在 DataTable 中,点位在场景中,需要通过收集工具来收集需要的场景数据
- 策划理解成本高:选点逻辑较为抽象,策划理解较为困难
- 蓝图实例数据不易编辑:策划需要根据蓝图基类创建多份子类资源,导致资源量碰撞;使用反射方式来编辑较为抽象
3.3 未解决的问题:实例数据的编辑便利性
以上改进方案的做法是:在点上标注信息数据,在事件表中说明如何选点、选好点后生成哪个蓝图。
引入新的问题:如果策划还需要编辑生成蓝图实例的参数,要么就需要建立非常多的蓝图,要么就用反射的方式来编辑。
- 建立太多蓝图导致资源量膨胀
- 使用反射
- 保存到表里的本质是一堆字符串,可读性差
- 反射编辑的体验不如原生 Details 面板直观
策划真正想要的是:在场景中直接编辑好 Actor 的实例数据,使用原生 Details 面板,所见即所得。正常情况不需要Active这个Actor,在被真正随机到后直接Active这个Actor,然后还原在场景中配置的所有实例数据。
这本质上也是 UE 原生 Actor Active 机制的局限;只要 Actor 被放在场景中并随关卡加载,它就会进入内存。我们只能在 BeginPlay 中于业务层屏蔽后续逻辑,让 Actor"看起来"没有激活,需要时再手动启动相关业务。这种做法侵入了业务层代码,不够干净。
四、方案三:基于 World Partition 的方案
4.1 设计思路
我们回到最初的问题:能否在场景中直接编辑好 Actor 的实例数据,将同一个 Actor 复制多份放到可能出现的位置,打上随机标签,在随机命中后才启用对应的 Actor?
这个思路的好处是:
- 可视化预览:编辑器中直接看到每个候选位置会生成什么样的 Actor
- 实例编辑便利:使用原生 Details 面板编辑,所见即所得
- 选点直观:候选点就是场景中的 Actor 本身
代价是:预览 Actor 多了,场景可能看起来比较杂乱。但只要涉及预览,场景中的"膨胀"就不可避免,带来的直观性收益远大于视觉噪音。
核心挑战在于:1. 未命中的 Actor 不能加载进内存,但又要保证它们能被正确 Cook 进包体;2. 怎么从Package数据中复现Actor实例数据到场景中;3. Actor CookPackage怎么离线获得。
4.2 DataLayer:Cook 但不加载
基于 WP 的 DataLayer 机制可以优雅地解决这个问题:
当一个 Actor 归属于某个 DataLayer 时,若该 DataLayer 未被 Activated 或 Loaded,则 Actor 不会被加载到内存,但仍会被 Cook 进包体。这正是我们需要的——实例数据存在于包中供按需使用,却不会在运行时直接占用内存。
传统方式: WP + DataLayer 方式:
┌─────────────────────┐ ┌─────────────────────┐
│ 加载Actor到内存 │ │ Actor归属随机DataLayer│
│ 获取Component配置 │ │ DataLayer不激活 │
│ 注册到Manager │ │ Actor不加载进内存 │
│ │ │ 但Cook进包体 │
│ 内存占用:高 │ │ 按需SpawnActor │
│ 时机:Runtime │ │ 内存占用:低 │
└─────────────────────┘ └─────────────────────┘
这里需要自动化的操作:策划配置了随机标签的actor,需要给他自动分配随机的datalayer。
为什么不直接用 DataLayer 的动态加载?
DataLayer 也支持卸载/动态加载 Actor,但它管理的粒度是 Actor 集合。在随机系统中,我们需要控制的是单个 Actor 级别的加载。如果为每个需要独立控制的 Actor 都分配一个专属 DataLayer,会导致 DataLayer 数量急剧膨胀,管理成本不可接受。
4.3 按需 Spawn:细粒度的 Actor 实例化
数据通过 DataLayer 保存在包中后,下一步是如何按需将命中的 Actor 实例化到场景。
WP 中 Actor 通常随 LevelStreamingDynamic 整体加载,但我们需要的是从一个未加载的 DataLayer 中单独取出某个 Actor 并 Spawn,粒度远比整个 DataLayer 激活要细。
方案是使用 SpawnActor,需要准备三样东西:
- Actor 的 Class
- Transform(从收集表中获取)
- 场景实例编辑数据(区别于 CDO 的数据)
恢复实例数据的坑
直觉上,通过 FActorSpawnParameters::Template 参数,将从 Package 中加载出来的 Actor 对象赋值给 Template 就行了。但实际上这还不够——Template 只会在 Spawn 时赋予 C++ 定义的组件相关实例数据,蓝图的实例编辑数据会丢失。
正确的做法是结合以下三者:
| 要素 | 作用 |
|---|---|
FActorSpawnParameters::Template | 提供 C++ 层的组件实例数据模板 |
FActorSpawnParameters::bDeferConstruction | 延迟构造,在 Spawn 和 FinishSpawning 之间留出设置窗口 |
Actor::FinishSpawning + ComponentInstanceDataCache | 在完成 Spawn 时传入蓝图实例编辑数据,完整还原场景中配置的参数 |
三者配合,才能完整复现 Actor 在场景中编辑的全部实例数据。
Package 路径的获取
在解决了"怎么 Spawn"之后,还有一个更基础的问题:Actor 的 Package 路径从哪来? 不知道资源路径,就无从加载。
这涉及 WP 本身的 Cell 划分机制。Actor 的 Package 路径分为 Editor Package 和 Cook Package 两种形态,且如果 Actor 归属于某个 LevelInstance,其路径还会发生变化。
关于 WP 的 Cell 划分和 GenerateStreaming 原理,此处不展开,感兴趣的读者可以查阅相关源码。这里直接说结论
一个Actor的CookPackage为:当前所在地图的Path/Generated/所在的CellName
资产名为:场景中actorname,但是如果这个actor被包在LevelInstance,还会有LevelInstance的前缀
例如:
ActorName:BP_LD_SpawnPoint_Treasure_C_UAID_D843AE965F021CAC02_1085277183
编辑器下(OFPA):/MyMap/ExternalActors/Maps/TR_LevelInstance/TR_LevelInstance_Room/TR_LevelInstance_Room_E/LI_TR_EL01_4207/0/VB/BFV2GH0R194EBL4PWNCXKM
CookPackage:/MyMap/Maps/TestMap/Generated/9FE4UFVQHJ2VXCZGLY0GEFRSZ
4.4 编辑器离线收集管线
核心思路的转变:将"收集"从运行时前移到编辑器离线阶段。利用 World Partition 的 ActorDesc 系统,在编辑器中遍历整个世界的 Actor 描述信息(无需加载 Actor 实体),提取 LevelInstance(房间容器)和 SpawnPoint 的元数据,离线生成数据表;运行时仅根据数据表按需 Spawn,不再依赖场景中的空壳 Actor。
UE5 的 World Partition 为每个 Actor 维护一份 ActorDesc 描述数据,记录 Actor 的类型、GUID、位置、所属 DataLayer 等元信息。这份描述信息在编辑器环境下可以直接枚举,不需要加载 Actor 实体到内存。
收集管线(8阶段):
┌──────────────────────────────────────────────────────────────────┐
│ Editor 离线收集管线 │
│ │
│ Phase 1: BuildStreamingInfoMap │
│ └── WorldPartition ActorDesc 枚举,构建 GUID→Info 映射 │
│ │
│ Phase 2: EnumerateActorDescs │
│ └── 分类:LI实例 vs RandomActor │
│ │
│ Phase 3: CollectContainerData │
│ └── 遍历 LI,收集房间模板 + SpawnPoint + RoomInfo │
│ │
│ Phase 4: MergeOldContainerTable │
│ └── 保留旧表的随机配置,只更新实例数据 │
│ │
│ Phase 5: SaveContainerTable │
│ └── 持久化到 DataTable 资产 │
│ │
│ Phase 6: CollectRandomData_FromDescs │
│ └── 枚举参与随机的 Actor 描述信息 │
│ │
│ Phase 7: MergeOldRandomTable │
│ └── 合并旧有随机配置 │
│ │
│ Phase 8: SaveRandomTableAndReport │
│ └── 保存随机表 + 输出收集报告 │
└──────────────────────────────────────────────────────────────────┘
收集管线支持三种触发环境,适配不同工作流:
| 环境 | 触发方式 | 特性 |
|---|---|---|
| ManualEditor | 编辑器手动按钮 | 支持 DataLayer 缺失检查、Actor 预加载、从 World 直接收集 |
| PrePIEHook | PIE 播放前自动触发 | 跳过 GenerateStreaming,从 ActorDesc 直接收集 |
| Commandlet | 命令行批处理 | 完整流程,适合 CI/CD 和自动化打包 |
还有一个最重要的问题,我们所有的数据都需要实时的去收集就,策划修改后,需要更新离线数据怎么办?
每次改完都手动点收集一次,有点麻烦,并且容易忘记,我们最容易想到的就是PIE运行前自动收集一次,对于策划来说无感知;
但是因为这份数据还需要打包后使用,PIE处收集了,也仅限于编辑器使用,因此还需要额外集成流水线,在策划对地图中某个Actor做了修改提交时,触发一次流水线收集,并提交变更的离线数据表。
有了上述我们整个系统才更完备了。
合并策略:保留配置、更新实例
收集管线的 Phase 4(MergeOldContainerTable)和 Phase 7(MergeOldRandomTable)实现了"只更新实例数据,保留策划配置"的合并策略。当策划在场景中新增/删除了出生点后重新收集,旧表中已经配置好的 RandomNum、RandomType、SubRandomPool 等参数会被保留,避免策划重复配置。
4.5 运行时随机流程
运行时随机由 PMGameplayRandomRule(导演层 Action)驱动,整体流程分为四个阶段:启动触发、数据加载与分组、逐行随机执行、Actor 实例化生成。
┌──────────────────────────────────────────────────────────────────────┐
│ Runtime 随机流程 │
│ │
│ 阶段1:启动触发(Play) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ PIE 模式 → 直接调用 ExecuteRandom() │ │
│ │ DS 模式 → 监听 Event_DsReady_AllowRandom │ │
│ │ + 保底定时器(默认 3 秒) │ │
│ │ 任一触发 → ExecuteRandom() │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 阶段2:数据加载与分组(ExecuteRandom) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 从 DirectorDataAsset 加载 RandomTable + ContainerTemplate │ │
│ │ 遍历 RandomTable 所有行: │ │
│ │ → 按 RandomPoolTag 分类 → GameplayTag2RowNames │ │
│ │ → 按 RandomPoolGroup 分组 → RandomGroup2RowNames │ │
│ │ (组内按 RandomPriority 插入排序,值越小优先级越高) │ │
│ │ → Group=0 的行归入 DefaultTags(无组散行) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 阶段3:逐行随机执行 │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 3a. 先执行 DefaultTags 中 bDefaultRunning=true 的散行 │ │
│ │ 3b. 再执行各 Group(带组代理检查 + 优先级排序) │ │
│ │ 3c. 处理 ContainerTemplateTable 中的 NoRandom 容器实例 │ │
│ │ │ │
│ │ 每行执行流程(ExecuteRandomByRow): │ │
│ │ → CheckRandomDependCondition(前置条件检查) │ │
│ │ → DoCommonRandomProxy(按 RandomType 执行随机算法) │ │
│ │ → 命中的 ActorDesc → EnableActorInstanceDesc │ │
│ │ → 命中的 ContainerDesc → EnableActorContainerDesc │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 阶段4:Actor 实例化生成(EnableActorInstanceDesc) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 异步加载 Package(Editor/Cook 路径分别处理) │ │
│ │ → 从 Package 中找到模板 Actor │ │
│ │ → SpawnActorByTemplate: │ │
│ │ Template + bDeferConstruction │ │
│ │ → SpawnActor(延迟构造) │ │
│ │ → FComponentInstanceDataCache 缓存蓝图实例数据 │ │
│ │ → FinishSpawning(还原实例数据 + 设置 Transform) │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
启动触发策略
PMGameplayRandomRule 作为导演层的一个 Action,在 Play() 时根据运行环境选择不同的触发方式:
- PIE 模式(编辑器内播放):直接同步调用
ExecuteRandom(),用于快速测试 - DS 模式(线上 Dedicated Server):注册
Event_DsReady_AllowRandom消息监听,等待局外系统通知 DS 就绪后再执行随机;同时设置保底定时器(默认 3 秒),防止消息未到达导致随机永远不执行
两条路径最终都汇入 ExecuteRandom(),且通过 HasExecute 标记保证只执行一次。
数据分组策略
ExecuteRandom() 从 DirectorDataAsset 加载两张表:
| 数据表 | 内容 |
|---|---|
RandomTable | 随机定义表,每行描述一个随机池(Tag、类型、数量、候选实例等) |
ContainerTemplateTable | 容器模板表,描述房间/LI 内部的 Actor 布局和子随机池 |
加载后遍历 RandomTable,将行按两个维度分类:
RandomTable 行
┌───────────────────────────────────────────────────────┐
│ Row: RandomPoolTag=Monster.Cave Group=0 Priority=0 │──→ DefaultTags(散行)
│ Row: RandomPoolTag=Chest.Rare Group=1 Priority=0 │──→ Group 1, 位置 0
│ Row: RandomPoolTag=Chest.Common Group=1 Priority=1 │──→ Group 1, 位置 1
│ Row: RandomPoolTag=Gather.Herb Group=2 Priority=0 │──→ Group 2, 位置 0
└───────────────────────────────────────────────────────┘
执行顺序:
1. DefaultTags 中 bDefaultRunning=true 的行 → ExecuteRandomByGameplayTag
2. Group 1(按 Priority 排序)→ ExecuteRandomByGroupId
3. Group 2(按 Priority 排序)→ ExecuteRandomByGroupId
Group 代理机制:执行 Group 前会先调用 RandomGroupProxy(),检查该组是否有外部代理(如宝箱怪代理)接管随机逻辑。如果代理处理了,则跳过默认流程。
三种随机算法
每行通过 RandomType 指定使用哪种算法,RandomNum 指定目标数量:
┌─────────────────────────────────────────────────────────────────────┐
│ 三种随机算法 │
│ │
│ Probability(概率模式) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 每个候选实例独立掷骰: │ │
│ │ rand(0,1) ≤ Weight → 命中 │ │
│ │ RandomNum 无实际意义,命中数量由各实例概率独立决定 │ │
│ │ 适用场景:每个点位有独立的出现概率 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Weight(权重模式) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 从候选池中按权重抽取 RandomNum 个,无放回: │ │
│ │ 1. 构建权重区间表 [0, w1), [w1, w1+w2), ... │ │
│ │ 2. rand(0, TotalWeight) 落入哪个区间 → 命中该实例 │ │
│ │ 3. 移除已命中实例,重建权重表,继续抽取 │ │
│ │ 若 RandomNum ≥ 候选总数 → 全部命中(跳过随机) │ │
│ │ Weight=0 的实例被排除在外 │ │
│ │ 适用场景:N 选 M 的经典加权随机 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Select(选择模式 / 位置交换) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 将实例与位置解耦后重新配对: │ │
│ │ 1. 收集所有候选实例的 Transform → 位置池 │ │
│ │ 2. 分别 Shuffle 实例池和位置池 │ │
│ │ 3. 取前 RandomNum 对,将位置赋给实例 │ │
│ │ 效果:随机选出 M 个实例,并随机分配到 M 个位置 │ │
│ │ 适用场景:实例和位置都需要随机打乱的场景 │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
前置条件检查
每行执行随机前会调用 CheckRandomDependCondition(),遍历该行配置的 DependConditionData 数组。每个条件由一个 UPMRandomDependConditionBase 子类实现,通过 StaticCheckCondition() 判断是否满足。任一条件不满足则跳过该行的随机。标记为 bProxy 或 bUsingGroup 的条件不在此处检查(它们在 Group 代理阶段处理)。
容器随机:房间内的子随机池
当随机命中一个 ContainerDesc(容器实例)时,通过 EnableActorContainerDesc() 进一步处理容器内部的随机逻辑:
ContainerTemplate(房间容器模板)
┌─────────────────────────────────────────────────────────┐
│ ContainerPath: /Game/Maps/Room_A │
│ │
│ NoRandomActorDescArray:(必定生成的 Actor) │
│ [灯光Actor, 装饰Actor, ...] │
│ → 直接偏移基准坐标后 EnableActorInstanceDesc │
│ │
│ SubRandomPool2RandomDescArray:(子随机池,按 Tag 分组) │
│ Tag=Monster → {Type=Weight, Num=2, Instances=[...]} │
│ Tag=Chest → {Type=Probability, Num=1, Instances=[]} │
│ → 每个子池独立执行随机算法 │
│ → 命中结果偏移基准坐标后生成 │
│ │
│ 容器可嵌套:子随机池命中的 ContainerDesc │
│ → 递归调用 EnableActorContainerDesc │
└─────────────────────────────────────────────────────────┘
容器模板内的所有 Actor 坐标都是相对于容器基准位置的局部坐标,生成时通过 AddToTranslation(BaseLocation) 转换为世界坐标。
NoRandom 容器实例
ExecuteRandom() 的最后一步处理 ContainerTemplateTable 中标记为"不参与随机"的容器实例(ContainerActorInstancesNoRandom)。这些是确定性生成的房间,不经过随机选择,直接通过 EnableNoRandomContainerInstanceDesc() 将每个实例转为 ContainerDesc 后走 EnableActorContainerDesc() 流程生成。
五、最终效果
解决上述底层问题后,系统呈现出对策划和程序两端截然不同的面貌:
策划侧(所见)
策划只需在场景中的 Actor 上配置以下数据:
| 数据类型 | 内容 |
|---|---|
| 随机数据 | 随机标签、随机权重等 |
| 点数据 | 生成配置 |
| 实例数据 | Details 面板上配置的所有场景参数(区别于 CDO) |
配置完成后,自动化工具会收集所有随机数据,策划可以在总览表中直观查看和管理。
通过 SpawnPoint 预览数据表,编辑器中的 SpawnPoint 可以根据其 RandomFilterTags 自动显示对应的白模预览 Mesh,策划在编辑器中即可直观看到"这个点会生成什么类型的物体",而非千篇一律的图标。
程序侧(所做)
程序在背后完成了以下工作:
- 收集随机 Actor:扫描场景中所有标记了随机标签的 Actor
- 解析 Package 路径:处理 Editor/Cook Package 差异和 LevelInstance 路径变化
- 自动化打上随机 DataLayer:确保这些 Actor 不会被默认加载进内存
- 流水线集成:支持 PIE Hook、手动触发、Commandlet 等多种运行方式
还有待完善的内容(已知局限与优化方向)
- 网络同步
当前实现中,主体是通过 SpawnpointActor 套壳,本质上是随机启用了 SpawnpointActor,然后生成具有 Replicates 属性的 Actor,因此网络同步自然得到保证。
待扩展:若后续需要支持不 Replicates 的 Actor(如纯客户端表现),需要将 PreRandom 阶段的随机结果(也可以是随机种子,只要保证种子一样随机结果一致即可)通过 WorldDataLayers 同步到客户端,客户端再根据数据表在本地生成对应 Actor。
- 已知局限
- 不受 StreamingSource 影响:由于使用 SpawnActor 方式生成 Actor,这些 Actor 本质上绕过了 WP 中 StreamingSource 的流式加载/卸载控制。即它们不会随玩家的 Streaming Source 移动而自动加载或卸载,需要由随机系统自行管理生命周期。解决思路是按 WP Cell 的划分规则,封装成一个 LevelStreamingDynamic 进行批量加载和卸载,或者额外实现流送源控制 Actor 加卸载的功能。
- 优化方向
- Package 分散策略:当前所有随机 Actor 统一分配到同一个 DataLayer,导致实例化单个 Actor 时可能会额外加载同 Package 中的其他数据。需要根据业务粒度对 DataLayer 进行细分,减少不必要的内存加载。
六、三种方案横向对比
7.1 逻辑设计
| 维度 | 方案一:直接场景收集 | 方案二:事件选点生成 | 方案三:WP 方案 |
|---|---|---|---|
| 数据来源 | 场景 Actor 实体 | DataTable 事件表 | WP ActorDesc 离线采集 |
| 收集时机 | Runtime(BeginPlay) | Editor 离线 / Runtime(Play + Timer) | Editor 离线 / PIE Hook / Commandlet |
| 时序保证 | 弱(Actor 注册顺序不确定) | 强(Timer + Priority 编排) | 强(数据预构建,执行确定) |
| 宏观管控 | 弱(只能逐个查看) | 强(事件表可总览) | 强(DataTable + 收集报告) |
| 随机粒度 | 单点概率/权重 | 事件级互斥 + 点组选择 | 房间级方案选择 + 维度/品质/数量 |
7.2 性能表现
| 维度 | 方案一:直接场景收集 | 方案二:事件选点生成 | 方案三:WP 方案 |
|---|---|---|---|
| 内存占用 | 高:所有出生点 Actor 加载为空壳 | 低:读表按需选点位生成 | 低:仅 DataTable 行数据,按需动态创建 |
| 加载时间 | 与场景点位数量线性相关 | 与事件数量相关(较少) | 与点位数量相关(较少) |
| Tick 开销 | Manager 每帧遍历 Spawners 数组 | Timer 驱动,非每帧 | 一次性执行,无持续 Tick |
| 规模上限 | 数百(空壳 Actor 内存压力) | 数十到数百事件 | 百以上LI中近千实例(已验证) |
| WP 流送兼容 | 差(Actor 必须加载才能注册) | 好(离线采集不依赖 Actor 加载) | 好(离线采集不依赖 Actor 加载) |
7.3 配置管理
| 维度 | 方案一:直接场景收集 | 方案二:事件选点生成 | 方案三:WP 方案 |
|---|---|---|---|
| 策划配置方式 | 场景内选中 Actor → Details 面板 | DataTable 编辑 + 场景摆点 | 场景摆点 + 一键收集 + DataTable 微调 |
| 配置可读性 | 低(分散在场景各处) | 高(事件表可总览,点位分散) | 高(模板表 + 方案表集中管理) |
| 版本管理 | 高(OFPA) | 中(DataTable容易产出冲突) | 高(DataTable + 自动生成 + Merge 策略) |
| 配置复用 | 无(每个点独立配置) | 低(事件可复用蓝图类) | 高(模板级复用,LI 实例共享) |
| 错误检测 | 运行时才能发现 | 部分编辑期检测 | 收集阶段报告 + DataLayer 检查 |
7.4 打包与部署
| 维度 | 方案一:直接场景收集 | 方案二:事件选点生成 | 方案三:WP 方案 |
|---|---|---|---|
| Cook 流程 | 标准 Actor Cook | DataTable Cook | DataTable Cook + Actor实例数据 |
| PAK 大小影响 | 中(所有空壳 Actor 序列化) | 中(DataTable + 蓝图类) | 中(DataTable + Actor 实例数据) |
| 热更新友好度 | 一般(引用变动不大时,可以只该Actor热更) | 好(DataTable 可独立更新) | 一般(DataTable热更+Actor 热更) |
七、选型建议
| 场景特征 | 推荐方案 |
|---|---|
| 小型固定关卡(< 50 个出生点) | 常规方案:简单直观,维护成本低 |
| 中型事件驱动关卡(副本、Raid 波次) | 改进方案:事件编排 + 互斥控制 |
| 大世界 / 大量 LI 房间 / 程序化产出 | WP 方案:离线收集 + 模板复用 + 方案随机 |
| 混合场景 | 三种方案可共存,由 Director 统一调度 |
值得注意的是,三种方案在项目中并非互相替代,而是层级递进、协同工作。GameModeDirectorComponent 作为统一的 Director 系统,通过 Action 队列调度所有行为——无论是常规方案的直接生成、改进方案的事件规则、还是 WP 方案的产出随机,都是这个队列中的一个 Action 节点。
八、总结
| 对比项 | 方案一:直接场景收集 | 方案二:事件选点生成 | 方案三:WP 方案 |
|---|---|---|---|
| 实现难度 | 低 | 中 | 高 |
| 内存效率 | 低 | 高 | 高 |
| 时序可控 | 弱 | 强 | 强 |
| 宏观总览 | 差 | 好 | 好 |
| 配置复用 | 无 | 中 | 高(模板级) |
| 实例编辑 | 好 | 差(需反射序列化) | 好(原生 Details 面板) |
| 大世界适配 | 差 | 中 | 好 |
| 热更新 | 中 | 好 | 中 |
| 典型规模 | 数十~数百点 | 数十~数百事件 | 近千点 |
从"场景摆放、Runtime 全量加载"到"事件驱动、按时序编排",再到"编辑器离线采集、Runtime 按需动态生成",三种方案的演进反映了一个核心趋势:将尽可能多的工作前移到编辑器阶段,Runtime 只做最少的必要工作,这是标准预计算优化。
基于 WP 的PCG方案,本质上是利用 UE5 World Partition 的 ActorDesc 与 DataLayer 能力,在编辑器阶段完成"全世界的信息采集和预处理",运行时仅消费预处理结果。这不仅解决了大世界场景下空壳 Actor 的内存问题,更通过 DataLayer 的"Cook 但不加载"特性,实现了策划在场景中直接编辑实例数据的需求——既保留了原生 Details 面板的编辑便利性,又避免了运行时的内存浪费。同时,模板化和方案化的设计将随机系统从"逐点配置"提升到"房间级方案配置"的抽象层次,大幅提升了策划的配置效率和系统的可维护性。
方案选择的核心原则:没有最好的方案,只有最匹配当前规模和需求的方案。小规模用简单方案,避免过度设计;大规模用 WP 方案,避免性能和管理瓶颈。三种方案可以在同一个 Director 系统下共存,按需选用。