Calmer的文章

  • 首页
  • 文章归档
  • 关于页面

  • 搜索
体验游戏 笔记 推荐 工具链 工具使用 小游戏 插件 UI 软件 教程

基于 World Partition 的随机系统设计(PCG方案)

发表于 2026-05-06 | 分类于 游戏开发 | 0 | 阅读次数 5

一、背景与问题

在大世界游戏中,随机系统是常见需求:怪物刷新、资源分布、事件触发等都依赖"从一组候选点中随机选取并生成 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 未解决的问题:实例数据的编辑便利性

以上改进方案的做法是:在点上标注信息数据,在事件表中说明如何选点、选好点后生成哪个蓝图。
引入新的问题:如果策划还需要编辑生成蓝图实例的参数,要么就需要建立非常多的蓝图,要么就用反射的方式来编辑。

  • 建立太多蓝图导致资源量膨胀
  • 使用反射
    1. 保存到表里的本质是一堆字符串,可读性差
    2. 反射编辑的体验不如原生 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,需要准备三样东西:

  1. Actor 的 Class
  2. Transform(从收集表中获取)
  3. 场景实例编辑数据(区别于 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 直接收集
PrePIEHookPIE 播放前自动触发跳过 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,策划在编辑器中即可直观看到"这个点会生成什么类型的物体",而非千篇一律的图标。

程序侧(所做)

程序在背后完成了以下工作:

  1. 收集随机 Actor:扫描场景中所有标记了随机标签的 Actor
  2. 解析 Package 路径:处理 Editor/Cook Package 差异和 LevelInstance 路径变化
  3. 自动化打上随机 DataLayer:确保这些 Actor 不会被默认加载进内存
  4. 流水线集成:支持 PIE Hook、手动触发、Commandlet 等多种运行方式

还有待完善的内容(已知局限与优化方向)

  1. 网络同步
    当前实现中,主体是通过 SpawnpointActor 套壳,本质上是随机启用了 SpawnpointActor,然后生成具有 Replicates 属性的 Actor,因此网络同步自然得到保证。

待扩展:若后续需要支持不 Replicates 的 Actor(如纯客户端表现),需要将 PreRandom 阶段的随机结果(也可以是随机种子,只要保证种子一样随机结果一致即可)通过 WorldDataLayers 同步到客户端,客户端再根据数据表在本地生成对应 Actor。

  1. 已知局限
  • 不受 StreamingSource 影响:由于使用 SpawnActor 方式生成 Actor,这些 Actor 本质上绕过了 WP 中 StreamingSource 的流式加载/卸载控制。即它们不会随玩家的 Streaming Source 移动而自动加载或卸载,需要由随机系统自行管理生命周期。解决思路是按 WP Cell 的划分规则,封装成一个 LevelStreamingDynamic 进行批量加载和卸载,或者额外实现流送源控制 Actor 加卸载的功能。
  1. 优化方向
  • 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 CookDataTable CookDataTable 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 系统下共存,按需选用。

  • 本文作者: Calmer
  • 本文链接: https://mytechplayer.com/archives/ji-yu-worldpartitionde-sui-ji-xi-tong-she-ji-pcg-fang-an-
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
# 推荐 # 笔记
"古法程序员"的AI Coding记录(4.18)
  • 文章目录
  • 站点概览
Calmer

Calmer

91 日志
7 分类
10 标签
RSS
Creative Commons
0%
© 2020 — 2026 Calmer
由 Halo 强力驱动
蜀ICP备20010026号-1川公网安备51019002006543
Copyright © 2020-2025 Calmer的文章