开放世界的人口编排:身份先于实体,人群按距离分层

系列《会动的城市》· 行人篇 · 上层调度

上一篇(系列总览):在 UE5 上重建一座会自己运转的城市。本篇承接它,把其中的人群 / 人口这一块展开深挖。

交通篇讲了车流怎么被调度。这一篇转向街上的人群——他们从何而来、去向何方、何时出现、何时被回收。这是行人的决策层:人口编排、存根、三层表示、生成预算、坐落在车道图上的人群流动。他们怎么动(运动学、动画、群体表现),属于行人篇《效果·物理·表现》的范畴。

导言:成群的人是被编排的,不是一堆各自的 AI

和车流一样,先纠正一个直觉:以为城市里的人群,是"很多个各自有 AI 的 NPC"。若让每个路人都运行一套完整的感知-决策-行为,数百人同屏,CPU 将不堪负荷。

原版(一款成熟的大型开放世界 RPG)的做法是一套人口编排层:它在"关卡里预先摆放的社区数据"和"运行时真正跑起来的 NPC"之间,架了一整套编排——存根、生成服务、三层表示、预算、与车道图/存档/跨系统事件的桥接。街上的人并非各自独立生成,而是这套编排按玩家周围的需要逐层调度而来。

这一篇顺着"存根怎么先于实体存在 → 三层表示怎么按距离退化 → 生成怎么是一个请求队列而非即时 → 预算怎么限制完整实体的数量 → 人群怎么坐落在车道图上 → 卡住/超范围怎么清理 → 这一切怎么存档"这条线,把行人的决策层剖开,再讲怎么在 UE5.8 上参考原版自建面向数据的人口运行时(可选 Mass,但不必须)。

一条纪律先点明,它是整个人口系统的灵魂:"生成一个 NPC"从来不是一个瞬间完成的操作,而是一条跨越多个阶段、多个 token、可能失败或超时的异步链。 把它当成"调用即出现",是人口系统失败的首要原因。这就是那句贯穿全系列的话——受理≠完成——在人群这里最集中的体现。


一、存根优先:身份先于实体表示

① 原版设计

整个人口系统的基石,是存根优先(stub-first)。一个"人"在系统里的第一存在形式,不是一个完整的 NPC actor,而是一个轻量存根(stub)——它有一个稳定的实体 ID、一个位置/朝向、一份持久状态,但不含完整 actor 的整套组件(AI、动画、物理、感知……)。

关键在于:存根先于实体存在,而且实体的挂载/卸载不改变存根的身份引用。 一个人可能先是一个存根(远处、成本低),玩家靠近时才为它"attach"一个完整实体(获得 AI、动画、可交互能力),玩家远离又"dispose"掉实体、退回存根。这个过程可以反复发生,而存根提供了一个稳定的身份层,不随表示的 attach/dispose 变化——于是任务、存档、关系这类系统,可以基于这个稳定身份建立关联,不必绑定易失的完整 actor。(严格说,一个大世界里往往有多套 id——人口 id、持久化 id、运行时实体句柄;这里说的"身份"指的是那个稳定的、供上层建立关联的身份层,不是断言所有系统都用同一个 id。)

存根由一个专门的存根控制器管理。这里有一个源码级的细节:控制器存的是裸的存根指针,依赖删除回调(在锁的保护下)来清除——这是一处性能取舍(避免智能指针的开销),但要求删除路径极其小心,不得留下悬空指针。

深挖:为何采用裸指针——一处危险但值得的取舍

在一个有几千个存根、每帧都在生灭的系统里,用智能指针(引用计数)的开销不小:每次拷贝、传递都要原子地增减引用计数,跨线程时还有缓存行争用。所以原版这里选了裸指针 + 删除回调——存根被销毁时,通过回调(在一把锁的保护下)通知所有持有它裸指针的地方"该存根已失效,请清除对应指针"。这免除了引用计数的全部开销,但把安全责任压到了删除路径的纪律上:删除必须走那个回调、必须在锁下、必须清除所有引用,遗漏其一即导致悬空指针与崩溃。

这是个典型的"性能 vs 安全"的工程权衡,也是移植时最该小心的地方之一。UE 里有 TWeakObjectPtr(弱引用,对象没了自动变空)——它比裸指针安全得多,代价是每次解引用要查一次对象是否还有效。对存根这种量大、高频的对象,可先用 TWeakObjectPtr 确保安全,待实测确认解引用开销确为瓶颈,再考虑采用原版的裸指针 + 回调方案。不宜一开始就为节省少量开销而采用裸指针——先确保安全,再按实测优化。 这条取舍值得写进移植笔记。

深挖:读存根的异步创建——令牌、待定表、和一个"迟到就作废"的守卫

存根不是同步 new 出来的,读代码是一套异步令牌机制。请求建存根时,创建器拿到一个创建令牌、立刻把它压进一张"待定创建"表——此刻还没有任何存根对象,只有一张"我请求了、等回调"的凭据。存根真正建好时触发回调,回调里先查令牌状态是否成功,成功才把存根"接管"过来、加进容器(容器永不拒绝——接管是无条件的,拒绝逻辑在更早)。

最讲究的是失败与竞态的处理。待定数据里存了一个指向交通槽的弱指针——因为建存根这段时间里,那个它本来要绑的交通槽可能已经被交通系统删了。所以回调里有一道守卫:即使存根创建成功,只要发现它要绑的槽已经过期,就把这个存根一并作废(连同回收槽、清工作点令牌)——宁可创建后作废,也不留下一个"绑着不存在的槽"的孤儿。还有一个节流常量:每帧最多重试固定次数的槽请求,避免大量创建失败的存根拖垮当前帧。

这套"令牌 + 待定表 + 迟到作废"是异步生成的标准骨架,比同步 new 复杂,但在流式世界里几乎绕不开——生成跨帧、依赖的资源(槽、区域)随时可能在生成途中消失,所以"完成时再核验一遍前提还在不在"这道守卫基本是必需的。移植时存根创建走异步句柄 + 完成回调,回调里重新核验依赖(槽/车道还在吗),别假设"我发起时它在、完成时它还在"。

② 相对 UE5.8 原生的优势

UE5.8 的 actor 生命周期是"spawn 出来就是完整 actor"。要做"存根先于实体、实体可反复挂卸、身份不变",得自己在 actor 之上架一层。MassEntity 有类似的"轻量实体"思路(fragment 组合),但它是特性插件、且要迁就它的 ECS 范式。原版这套 stub-first 的优势是身份和表示彻底解耦:身份(实体 ID + 持久态)是稳定的、低成本的、常驻的;表示(完整 actor)是按需挂卸的、昂贵的、可有可无的。这是"一座城市记录每个个体、却不同时模拟每个个体"的根本。

③ 移植到 UE5.8

// 重建工程:存根优先(存根=稳定身份层,表示可挂可卸)
struct FCyberEntityStub                    // 轻量、低成本、常驻
{
    FCyberEntityId Id;                     // 稳定身份,attach/dispose 都不变
    FTransform Transform;
    FCyberPersistentState State;           // 持久态(恩怨、任务标记…)
    FCyberRepresentationHandle CurrentRepresentation;  // 当前表示句柄(内部再映射到 actor,不直接暴露 ACyberNpc)
};
// UCyberPopulationSubsystem 管存根池;走近升表示、走远降表示,身份引用不动。
// 刻意不把 TWeakObjectPtr<ACyberNpc> 摆在存根上——那会把"表示"漏进"身份",违背 identity ≠ representation。

要点:身份(存根 ID + 持久态)和表示(完整 actor)解耦、实体可反复挂卸、ID 不变、存根池自己管生命周期。别用"spawn 即完整 actor"的裸 UE 模型。


二、三层退化:完整 NPC / 存根 / 远景 dot

① 原版设计

人群按距离/重要性分三层表示,成本逐级下降、数量逐级上升:

  • 完整 NPC:近处、可交互。完整 actor,不剥组件——有 AI、动画、感知、可对话可战斗。成本最高,数量最少。
  • 轻量存根 / 精简 actor:中距离。有 ID、变换、持久态,但按 LOD 剥掉一批组件(状态效果、小队、视觉感知、肢解等),碰撞简化为查询形状,而且静默不广播(不发"我生成了"这类事件——性能优化)。存根独立于实体:实体可反复 attach/dispose,存根不动。
  • 远景 dot:远处。就一个纯数据点——不拥有完整实体语义、不参与交互逻辑,沿车道样条插值,整批 GPU 实例化画出来。它主要是视觉表现(无论背后是否有轻量代理/池化条目,逻辑上都不能将其视为一个可交互的人)。成本最低,数量最多(上千)。

三层之间双向 handoff:走近则 dot→存根→完整逐级升格(attach),走远则反向降级(dispose)。有一条重要纪律:远景 dot 的"渲染数据有效",不等于"那里有一个能交互的人"——dot 只是看起来有人,真要交互,得先升格成存根/实体。

这套三层和交通篇的车流三层是同一套思路的两个实例——都是运动学、都分三层、都自存根升格而来。人和车在"成本即距离"这条曲线上,是一样的。

图 · 身份先于表示:三层表示按成本 / 距离升降,底部身份层稳定不变
图 · 身份先于表示:三层表示按成本 / 距离升降,底部身份层稳定不变

时序走查:一个路人的一生(远景点 → 完整 NPC → 又回远景点)

把三层、handoff、预算、身份串起来,看一个路人随玩家接近经历了什么:① 最远处他是广场上一片 dot 中的一个——纯位置 + 朝向,ISM 批量绘制,没有存根、没有 ID、不做任何逻辑;② 玩家靠近到某个距离,人口编排决定为这片区域"落地"存根——为他创建一个存根(有了稳定实体 ID、位置、一份持久态),但仍是轻量、静默、按 LOD 剥离了组件;③ 玩家更近,进入"可交互距离",且此刻完整实体预算尚有余量——存根 attach 一个完整 NPC(获得 AI、动画、感知,可对话可战斗),实体 ID 不变;④ 玩家与他发生争执乃至冲突——这些写入他存根的持久态(他记住了玩家);⑤ 玩家离开,他移出可交互距离,完整实体 dispose(把预算释放给玩家面前新的人),退回存根,但存根和持久态仍在;⑥ 玩家走得更远,存根也被回收,他退回广场上一个 dot。全程实体 ID 不变——所以玩家下次返回、或存档读档,遇到的仍是那个"记得与他有过冲突"的他,无论此刻是 dot、存根还是完整 NPC。这条链把本篇每个机制串了一遍,也是"一座城市记录每个个体、却从不同时模拟每个个体"的完整图景。

深挖:读升级逻辑——不是"走近就升",是一场重要性的静默竞争

"走近则升格"是直觉,但读代码会发现升级不由距离或时间直接触发,而是一套多因素的重要性评分在所有候选之间做静默竞争——谁的分高,谁占用有限的完整实体名额。从代码结构看,这个评分综合了几类因素(具体权重和组合方式以实现为准,这里只讲维度):

  • 身份优先级:剧情硬要求"总在场"的权重最高,任务相关的次之,社区居民再次,纯背景人群最低——身份越重要,基础分越高。
  • 类型:车辆的权重明显高于普通角色(车更显眼、玩家更可能和它交互)。
  • 距离衰减:近的高、远的低(大致是"剩余半径比例"那种线性衰减)。
  • 高度差:和玩家的垂直高差拉大时权重往下掉、差得多了急降——楼上楼下的人不值得升(多半看不到、也够不着)。
  • 视野内 / 兴趣点(POI)内的加成:处于玩家视野内、或位于剧情热点中的人,额外加分最多。

关键是没有广播、没有状态机:不是"某个人到了某距离就发事件请求升级",而是每帧算出所有候选的评分、按名额从高到低 attach。这套"纯数值静默竞争"避开了状态机的复杂度,也天然实现了"名额恒定、内容随玩家重要性流动"。移植时升降级不应写成"距离阈值触发",而应写成"重要性排序 + 名额",才能在预算约束下始终把名额分配给最应升格的对象。

深挖:升格成完整实体时,到底剥/留了哪些组件

"存根按 LOD 剥组件"具体剥什么,源码里是几张明确的组件黑名单。背景人群(crowd 档)升成的实体,会剥掉这几类组件:状态效果、小队成员、视觉感知、肢解、投射物生成、物体携带——因为一个背景路人不需要中状态效果、不属于任何小队、不用"看见"东西做感知、不会被肢解、不发射子弹、不携带可交互物。剥掉它们,既节省内存也节省每帧逻辑。还有更精简的档("简单"/"简单乘客"),剥离得更彻底(例如车内乘客连触发器激活组件都去除——他只是坐在那里,无需响应任何事件)。

一个性能细节:这些要剥的组件类,用一次性初始化 + 缓存类型指针的方式查(不是每次按名字字符串比对),保证运行时快。移植时"存根/精简 actor 剥哪些组件"要做成显式的、分档的黑名单(crowd / simple / occupant 各一份),而非用一个模糊的"轻量模式"含混带过——剥错了要么留下无用的开销、要么剥掉了它实际需要的能力。

② 相对 UE5.8 原生的优势

UE5.8 没有"人群三层表示 + 双向 handoff"的原生系统(MassCrowd 有类似的 LOD 思路,但是特性插件、绑死 ECS)。原系统这套的优势是三层的每一层都是刻意设计的成本点:完整层不剥组件(要交互)、存根层按 LOD 剥组件且静默、dot 层纯数据。剥哪些组件、什么距离升降、handoff 怎么保持 ID——全是可控的自建逻辑。

平心而论,UE5.8 在表示层和批处理层其实给了不少成熟设施:ISM/HISM 画海量远景实例、Mass 的 fragment+processor 跑无状态批量、SmartObject 挂交互点、动画预算分配器控人群动画开销——这些底层能力是引擎的强项,自建时该借就借。引擎真正没有的,是把这些串成"stub-first 三层 + 双向 handoff + 预算化 attach"的那套编排语义——底层设施引擎给,编排逻辑得自建。所以本篇讲"自建",指的是编排层,并非要重造 ISM 或动画预算器。

③ 移植到 UE5.8

// 重建工程:人群三层表示(成本即距离,双向 handoff)
enum class ECyberCrowdTier : uint8 { FullNpc, Stub, DistantDot };
// FullNpc   : 完整 actor,不剥组件(可交互)
// Stub      : 有 ID/变换/持久态,按 LOD 剥组件,静默不广播
// DistantDot: 纯数据点 + ISM 批量画,不拥有完整实体语义、不参与交互
// 升格 attach / 降级 dispose,身份引用不变;dot 只是视觉,不能作为可交互对象。

要点:三层各是刻意的成本点、存根层按 LOD 剥组件且静默、dot 无实体。和车流三层同构。


三、人口编排:从社区数据到街上的人

① 原版设计

三层表示的"内容从哪来"?来自关卡里预先摆放的社区数据(community)——设计师标注的"这片区域该有什么样的人、多密、什么作息"。人口编排层的活,是把这份静态的社区数据,在运行时编排成动态的人群:按玩家位置、时间、密度参数,决定此刻该在哪些位置、生成哪些人、挂到哪一层。

社区数据的核心是一张作息时间表,值得看清它的粒度。社区不是"随时都刷一样的人",而是按时段(一个时段枚举,白天是默认,还有夜晚等)切成若干生成阶段(spawn phase):每个阶段绑一个时段,记着这一阶段要生成多少个(quantity)、生成在哪些标记点 / spot 节点上(markings / spot 节点引用)、以及是否按序生成(sequence,例如一群人依次入场而非同时出现)。于是"早高峰上班族、深夜零星醉汉、正午商业区人潮"这种作息,不是硬编码的 if-else,而是同一片社区在不同时段查不同的生成阶段——时间到了,编排层就按当前时段那一组阶段去铺人。这就是"城市有作息、街道白日拥挤入夜冷清"的数据来源。

这个编排层是个桥接中枢——它连着:社区数据(内容/作息)、实体存根(身份)、运行时实体生成服务(造 actor)、交通车道(人群坐落其上)、远景人群渲染、通缉响应生成(见下)、存档(人群状态存取)、跨系统事件(生成/回收广播给关心的系统)。它不自行生成人口,而是协调这一组子系统,按需把人编排出来。要划清一点:编排层只提出需求("这片区域此刻该有这么些人"),真正把人造出来还得走下一章那条异步链路——所以这里说的"生成哪些人",是"决定该生成谁",不是"当场就有了"。

图 · 人口编排层:桥接中枢,六方解耦,由作息时段表驱动
图 · 人口编排层:桥接中枢,六方解耦,由作息时段表驱动

深挖:prevention 系统——不是"抑制生成",是"通缉响应生成"

有一个叫 prevention 的子系统,名字极容易望文生义成"阻止/抑制生成路人"——但从它的接口和调用关系看,方向恰好相反。它更像一个**"玩家行为响应生成"系统**:接口是"按某个等级请求生成一个单位""按等级整批撤销""查询当前生成了多少个",还带一个响应等级的概念。把这些串起来看,它做的事更接近——玩家违规(袭击、开枪、引来注意),按响应等级生成对应的执法/敌对单位来"制止"玩家,等级越高派出的单位越强。也就是说,它是往城市里增派单位(追捕玩家的),而非减少单位。(这是从接口/数据结构反推的定性,非逐行证明;但"prevention = 抑制生成"这个望文生义几乎肯定是错的。)

它还负责一项相关工作——尸体清理:城市里被击杀的人不能无限堆积,由一个尸体计数器监控,按距离、是否在视野内、以及尸体总数是否超阈来决定何时让尸体消失(数量超限即立即清理、玩家转身远离后清理)。这套 spawn/despawn 也是请求队列 + 锁 + 每帧处理(尸体更新甚至隔几帧才运行一次以节省开销)——又一次"受理≠完成"。

之所以单独说明它,一是纠正这个名字的直觉陷阱(prevention = 通缉响应,不是抑制),二是它是"城市对玩家行为有反应"的关键一环:玩家违规,街上便增派追捕的单位;玩家击杀,尸体会按规则被回收而非无限堆积。移植时这套要单独做成一个"通缉响应生成子系统",别与普通人口生成混同,也别被名字误导成抑制逻辑。

图 · prevention 实为通缉响应生成器:按玩家行为决定生成何种响应
图 · prevention 实为通缉响应生成器:按玩家行为决定生成何种响应

深挖:读尸体清理——三层圈、绝望删除、和"最近释放的 ID 别急着重用"

尸体清理这段代码,把"内存压力下如何优雅地清理尸体"做得很讲究,值得逐层看。它每隔几帧才运行一次(尸体清理不急,节省开销)。运行时先把所有死体收集出来,然后按到玩家的距离平方分三层处理:

  • 最外圈(超过"视野外清除距离"):立即强制删除——玩家根本看不到那么远,直接回收。
  • 中圈(在中间距离带):软删除(走正常的可删检查,不强求)。
  • 近圈(就在玩家附近):先不删,但记下"离得最远的、在视野内的死体"和"离得最远的、视野外的死体",留作备用。

最值得注意的是绝望删除:如果死体数量已经超过"立即清除阈值"、且总实体数已达上限(内存确实告急),而这一轮又一个都没删成——系统会强制删除那个"离得最远的视野外死体";万一连视野外的都没有,退而删除视野内离得最远的那具(哪怕玩家正看着它消失)。这是"常态温和、极端果断"的分级——平时绝不在玩家眼前清理尸体,只有内存确实告急时才越过视野约束。

还有一个易忽略的 ID 纪律:删一个实体时,它的 ID 被插回 ID 池的前端(而不是尾部)。为什么?这样最近释放的 ID 不会被立刻重用——否则脚本层可能刚记下"3 号是那个死掉的敌人",下一帧 3 号又被分给一个新生成的路人,脚本就张冠李戴了。"最近释放的别急着重用"是对象池的一条经典保命纪律。移植这套通缉/尸体清理时,这三层圈 + 绝望删除 + ID 前端回收,都要照搬。

深挖:读作息调度器——多时段怎么切、为什么只有多时段才注册回调

社区的"作息"落到代码,是一个时段调度器在管,它切换时段的逻辑读起来很紧凑。设置一组时段时,它先注销旧的回调、更新时段表、立即按当前游戏小时应用一次、再注册新回调。而"按当前小时选哪个时段"这一步有个讲究:如果只有一个时段,直接用它;如果有多个,就从后往前遍历时段数组,找第一个"起始小时 ≤ 当前小时"的用上;如果一个都没找到(当前小时比所有时段的起始都早),就回绕到最后一个时段。这个"从后往前找 + 回绕"正确处理了跨午夜的时段(比如"22 点起的夜间时段"要一直管到次日凌晨)。

一个小而精的优化:只有当时段多于一个时才注册时间回调——单时段的社区(一天到晚一个样)压根不需要监听时间变化,省掉这个订阅。回调触发时还会按帧号去重(同一帧里时间系统可能多次通知,只处理一次),防抖动。移植时"社区作息"不宜做成每帧查询一次当前该生成什么,而应做成"时段边界触发切换 + 单时段免订阅 + 帧内去重"——这是把"一座城有作息"做得既正确(跨午夜正确)又高效(不做不必要的订阅)的关键。

深挖:生成/回收是事件,不是让别人来问

人口编排的一个关键设计,是它把人群的生成/回收广播成跨系统事件,而不是让关心的系统各自来轮询"现在有哪些人"。为什么这很重要?因为一个 NPC 的出现/消失,牵动一大堆别的系统:任务系统要知道"目标 NPC 生成了没"、战斗系统要知道"这个敌人还在不在"、对话系统要知道"能不能触发"、音频要知道"该不该加一个人声源"……如果这些系统各自每帧去问人口系统"那个人在吗",就是 N 个系统 × 每帧一次查询,既浪费又容易读到中间态(那个人正 attach 到一半)。

改成事件广播:人口系统在一个 NPC 真正 attach 完成、或真正回收时,广播一条事件,订阅的系统各自响应。这样每个变化只处理一次、且是在"完成"的时刻(attach 全部就绪 / 回收干净)广播,订阅者拿到的是一致状态,不会遇到半初始化的中间态。这又是"受理≠完成"的配套:只在完成时广播,别在受理时广播——否则任务系统收到"NPC 生成了"却发现它还没 attach 完,就会出诡异的空指针或半初始化 bug。移植时人口子系统要有一条清晰的事件总线,且严格在完成时刻发事件。

② 相对 UE5.8 原生的优势

UE5.8 没有"社区数据→运行时人群"的编排层。原版这套的优势是它把"一座城市该有多少人、在哪、什么样"从硬编码变成数据驱动的编排:社区数据是内容作者写的,编排层按玩家和规则实时把它铺成人群。移植时这层编排要自建——它是"城市人口感觉对"的调度大脑。

③ 移植到 UE5.8

// 重建工程:人口编排层(桥接社区数据 / 存根 / 生成 / 车道 / 存档 / 事件)
// 不用一个大 Tick 管所有人口——拆成显式的相位,接入自定义的 World 相位/调度,而非普通 actor Tick。
class UCyberPopulationSubsystem : public UWorldSubsystem
{
    void TickPopulationPhase(float Dt);   // 编排:按作息表算该有谁、升降哪些(重要性竞争)
    void TickSpawnPhase(float Dt);        // 生成相位:消化异步请求队列、节流放行
    void TickDespawnPhase(float Dt);      // 回收相位:延迟删除器 + 孤儿容器 + 批量清
    // 输入:社区作息表(时段→生成阶段:数量/标记/spot/是否按序) + 玩家位置 + 时间
    // 协调:存根池 / 实体生成服务 / 交通车道 / 远景渲染 / 存档 / 事件广播
    // 通缉响应(prevention)是独立子系统:按响应等级生成执法单位 + 尸体清理,别与普通生成混
};

要点:编排层是协调中枢(不自己造人,协调子系统)、社区作息表驱动(时段→生成阶段:数量/标记/spot/是否按序)、通缉响应(prevention)是独立的"加人"子系统(按通缉等级生成执法单位 + 尸体清理),别被名字误导成抑制。


四、生成是一个请求队列,不是即时调用

① 原版设计

这是整个人口系统最重要、也最反直觉的一点——"生成一个人"是一个异步请求,不是一次即时调用。 源码里那些接口(添加实体、请求生成、启用/禁用人群)返回的都是**"请求已受理/已入队"的信号,而不是"实体已可见"的完成**。

一次生成的"完成",要跨越若干阶段:存根创建 token → 人口注册队列 → 实体生成 token → attach 调度。每一步都是异步的、可能延迟的。所以"我请求生成一个 NPC"到"这个 NPC 真的站在街上可交互",中间隔着一条链,链上任何一环都可能还在进行、或失败。

为何要如此迂回?因为大世界是流送的、预算是有限的:你请求的位置可能还没流送进来(存根建不了)、这一帧的生成预算可能用完了(得排队到下一帧)、attach 需要等实体生成服务空闲。把生成做成同步即时调用,一是会造成卡顿(构造完整 actor 是重量级操作),二是无法在流送和预算约束下优雅排队。

所以整个系统必须建立在"受理≠完成"之上:请求生成 ≠ 人已出现;启用人群 ≠ 人群已在跑。任何依赖"人已经在那了"的逻辑,都得等真正的完成信号(实体 attach 完成的回调),而不是请求返回。

深挖:从源码状态看,生成可抽象成四棒接力,每一棒都可能中断

源码里没有一个显式写着"四个 token"的东西,但把生成路径上那些异步状态/凭据串起来,可以抽象成一条四棒的接力——这么看能看清为什么它必须异步、以及每一棒可能怎么断(下面的"棒"是我的抽象概括,不是原版某个具名结构):

  1. 存根创建 token:请求先要一个存根。但存根要建在某个位置,那个位置可能还没流送进来——这一棒就得等流送,或者失败(位置无效)。
  2. 人口注册队列:存根建好了,要注册进人口系统的有序表。但注册是入队的,到人口系统的处理节拍才真正登记——这一棒是排队等节拍。
  3. 实体生成 token:注册好的存根要 attach 一个完整实体(造 actor)。但构造完整 actor 是重量级操作、且受完整实体预算约束——预算已满时,这一棒排队等别人 dispose。
  4. attach 调度:实体造好了,要 attach 到存根上(把组件、AI、动画挂上,恢复持久态)。这一棒可能跨帧完成,attach 完才真正"这个人站在街上可交互"。

四棒接力,任何一棒都可能延迟(等流送/等节拍/等预算/跨帧)或失败(位置无效/预算耗尽后撤销)。所以"我请求了一个人"到"他真的在那",是一条可能在任意一棒中断的链。这就是为什么系统里到处是分级状态(排队中 / 存根已建 / 实体生成中 / 已挂载 / 失败 / 超时),为什么依赖"人在场"的逻辑必须等最后那个 attach 回调——而不是任何一棒中间的返回。把这四棒压成一个"生成成功/失败"的布尔值,等于假设这条接力不会中途中断——而它经常中断。

图 · 生成的四棒接力:受理 ≠ 完成,每一棒都可能中断
图 · 生成的四棒接力:受理 ≠ 完成,每一棒都可能中断

② 相对 UE5.8 原生的优势

UE5.8 的 SpawnActor 是同步的、即时的——调用返回你就有一个 actor。这在小场景没问题,但对"大世界流送 + 预算化 + 三层 handoff"的人口系统是不够的。原版这套异步请求队列的优势是它天然适配流送和预算:请求入队,系统在流送就绪、预算允许时才真正生成,全程分级状态可查。移植时得在 SpawnActor 之上架这层异步编排,别直接同步 spawn。

值得强调的是,UE 的 SpawnActor 同步语义会主动诱导开发者写出错误的人口系统——因为它返回即有 actor,很自然会写成"需要即 SpawnActor、返回即使用",然后在数百人同屏时出现每帧 spawn 卡顿、位置未流送好时 spawn 失败未被处理、缺预算导致内存溢出。这些坑原版都用异步队列绕过了,但 UE 的即时 API 把它们隐藏了起来、等规模上来才暴露。所以移植这层不是"锦上添花",而是必须在 SpawnActor 之上封装一层异步请求队列——把即时 spawn 降级成"最后一步的执行细节",前面套上"入队 → 节流 → 流送就绪校验 → 预算检查 → 分级状态"。这也是为什么本篇反复强调"生成是请求不是调用":它直接对着 UE 那个最容易用错的 API。

③ 移植到 UE5.8

// 重建工程:生成 = 异步请求队列(受理≠完成,跨多阶段)
FCyberSpawnHandle UCyberPopulationSubsystem::RequestSpawn(const FCyberSpawnDesc& Desc);
// 返回 handle(受理),不是 actor(完成)。内部阶段:
//   存根创建 token → 人口注册队列 → 实体生成 token → attach 调度
enum class ECyberSpawnStatus : uint8 { Queued, StubCreated, EntitySpawning, Attached, Failed, TimedOut };
// 依赖"人已在场"的逻辑,等 Attached 回调,不应依赖 RequestSpawn 的返回值。

要点:生成是异步请求、返回 handle 不是 actor、完成跨多个 token/阶段、依赖"人在场"的逻辑等 attach 回调。这是全系统的地基纪律。

深挖:读生成的节流与"生成优先于删除"

生成队列有两个读代码才注意到的调度纪律。其一,每帧生成有硬节流——一帧最多真正生成固定的少量存根。为什么?因为造存根、attach 实体开销大,一帧涌入上百个生成请求也只能逐步处理;不节流的话,玩家一进入新区域、大批请求同时落地,会瞬间卡顿。所以生成是"细水长流"的:请求可以爆发式到来,落地被匀速地节流。

其二,"生成优先于删除"是显式编码的。 处理删除队列时,先查这个待删的实体是否还在生成队列里尚未生成——如果是,直接从生成队列移除、把 ID 还池,根本不生成它(既然马上要删,何必先生成再删,多做一次重开销操作)。更进一步:每次有新生成请求时,会清空整个待删队列——其含义是"既然又要生成了,之前那些待删的暂缓"。这是一种"避免无用功"的调度策略:生成和删除是一对昂贵的操作,能抵消就抵消,能推迟就推迟。移植时生成要有每帧节流,且"生成 vs 删除"要能互相抵消(待删的尚未生成就直接撤销、新生成到来就暂缓删除),而非机械地"请求了就生成、要删了就删"。

顺带一个 ID 池的对称设计:分配 ID 从池的后端弹出、回收 ID 插到前端——一出一进两头走,保证一个刚被回收的 ID 要经过整池一轮才会被再分出去。这和上一章通缉系统的 ID 前端回收是同一条纪律:最近用过的 ID,隔得越久再重用越安全。


五、生成预算:什么时候能挂完整实体

① 原版设计

三层中成本最高的是"完整实体"(attach 一个完整 NPC)。所以有一个预算约束着:同时能有多少个完整实体挂载。存根可以很多(成本低),但 attach 成完整实体的数量有上限——超出就得排队,等别的实体 dispose 释放预算。

这个预算是"一城人群不卡顿"的闸门:远处成千的 dot 不占预算(纯数据),中距离的存根占很小的预算(轻量、独立的池),只有近处真正 attach 的完整 NPC 占用主预算。玩家转身,视野里该 attach 的人变了,系统就在预算内 dispose 背后的、attach 面前的——预算恒定,内容随玩家流动。

存根池本身也有独立预算,和"完整实体"预算分开。这让"存根很多但完整实体很少"成为自然状态——大部分个体以存根形式低成本地存在,只有玩家正注视、可能交互的那些才升格。

图 · 重要性竞争 + 分层预算闸门:升格与否由排序 + 名额决定
图 · 重要性竞争 + 分层预算闸门:升格与否由排序 + 名额决定

② 相对 UE5.8 原生的优势

UE5.8 没有"完整实体 attach 预算 + 存根池独立预算"这套。参考实现的优势是它用分层预算把"一城人"的开销钉死在一个恒定值上:不管理论上这片区域该有多少人,同时 attach 的完整实体数量有硬上限,超了排队。这是帧率不随人口规模爆炸的根本闸门。

深挖:读预算——两条预算线、按画质从表里读、和"挂不上就退而暂存"

读代码,预算其实是两条线:一条 已挂载实体预算、一条 未挂载实体预算,各有当前用量和上限。这两个上限不是硬编码的,而是按画质档从一张配置表(CSV)里读——低画质档有单独的、更小的已挂载上限。所以"一座城同时有多少完整 NPC"是可配、随画质缩放的:低配机器上自动少 attach 几个,帧率优先。

生成算法的处理很讲究"不浪费":它按重要性把生成队列排好序,依次尝试把每个 attach 进"已挂载预算";挂不上(预算已满)不是直接丢弃,而是转成"未挂载实体"暂存——实体已经造好了,只是还没 attach 上完整表示,放进未挂载预算里排队,等已挂载预算释放位置再 attach。这样构造实体的开销不致浪费。而真正生成失败(位置无效之类)才立即取消、从队列移除、不重试。

这三层——已挂载预算(贵,看画质)+ 未挂载暂存(造好了等 attach)+ 失败即弃(不重试)——是"预算约束下尽量不浪费、又不无限重试"的平衡。移植时预算别写死一个数,做成"按画质档从配置读 + 已挂载/未挂载两级 + 挂不上先暂存不丢"。

③ 移植到 UE5.8

// 重建工程:分层预算(完整实体预算 + 存根池独立预算)
// 完整实体 attach 数量有硬上限 → 超了排队,等 dispose 腾预算
// 存根池独立预算(轻量),dot 不占预算(纯数据)
// 玩家移动 → 预算内 dispose 背后 / attach 面前,预算恒定、内容流动。

要点:完整实体 attach 有硬预算(帧率闸门)、存根池预算独立、dot 不占预算。预算恒定、内容随玩家流动。


六、人群坐落在车道图上:人也在"车道"上流动

① 原版设计

一个把交通篇和这篇衔接起来的关键事实:人群也坐落在车道图上。 交通篇讲的那套车道图,不只给车用——人行道也是"车道",人在上面流动的方式和车在机动车道上流动同构:都拿车道上的槽、都沿车道积分弧长、都受占用约束。

人口编排通过一个人车协调器和交通系统共享车道事件与槽机制。街上走的人,很多是"交通工作点(work-point)"——车道上标注的"人该在这儿做什么"(等公交、逛街、过马路)的锚点,人群生成时被分配到这些工作点上。

这里有个源码级的细节,体现"受理≠完成"和"分级处理":人群对车道事件的响应里,忽略"车道封锁"这个事件(除非是监视器状态),而把死端/堵塞的清理推迟到删除阶段统一做——不是一收到封锁就立即仓促地删人,而是累积到删除阶段批处理。这又是"事件驱动 + 批处理"的调度范式。

深挖:读车道密度——"这条道该有几个人"怎么落地成生成/删除

人群怎么知道一条车道该有多少人、多了删少了补?读代码,是靠一个按车道段的密度账。每条车道被切成若干段(fragment),每段记一个"缺口密度"——目标人数减去实际人数。这个数是正的,就说明这段人不够、该补(生成请求往这儿投);是负的,就说明人过多、该删(前面清理章讲的"某车道人太多"正是读这个负值触发的)。删的时候还按"缺口密度"排序,最"过剩"的车道段优先减人。

这套"按段记缺口密度、正补负删"的好处是生成和删除共用同一本账——不是两套独立的逻辑各自臆断,而是同一个密度目标,多退少补。密度目标本身来自社区数据(那条道白天该多拥挤、夜里该多空旷),于是"街道的疏密"从数据一路贯到"每段该补该删几个人"。移植时人群的生成/删除不宜各写各的触发条件,应共用一本"按车道段的目标 vs 实际"的密度账:正数投生成、负数触发删、按过剩程度排优先级——这样疏密可控、且生成删除不冲突。

图 · 人车共用一张车道图:行人坐落于
图 · 人车共用一张车道图:行人坐落于”边”上,按段密度补充或删除

深挖:远景人群点怎么找到自己该待的车道

有一个专门的远景人群车道查找器,负责给远处的人群点分配"该沿哪条车道流动"。远景人群是一片沿路线移动的点(下一章讲),但"哪条路线"不是随便给的——查找器要在人群点周围找到合适的人行道车道,把点绑上去,让它们看起来像在人行道上行走,而不是穿墙、或浮在机动车道中间。

这和交通车的"生成查询"(交通篇的子段化空位查找)是同一类空间问题的两个版本:交通车要在机动车道上找足够长的空位放车,远景人群点要在人行道车道上找归属。它们共享"车道图 + 空间索引(BVH)"这套基础设施——又一次印证了人车共用一张图的优势。移植时远景人群的车道查找,直接复用交通篇那套车道图 + BVH,不必单独为人再建一套空间结构。

工作点(work-point)的分配也在这里:一个人群点被分配到车道上后,可能被指到附近的一个工作点(等公交、看橱窗、过马路),于是它不只是"沿车道走",而是"走到工作点、做一段该做的事、再走"。这让人群看起来有目的,而不是永远匀速地沿人行道游走。

② 相对 UE5.8 原生的优势

UE5.8 里人(MassCrowd)和车(MassTraffic)是两套插件、两套表示,共享车道要自己搭桥。原版这套"人车共用一张车道图和一套槽 + 工作点"的优势是统一——人和车在同一坐标系里流动、交互(车在斑马线让行人)有共同的基础。移植时如果自建(不用 Mass),从一开始就让人车共用这套车道图,避免双插件搭桥的割裂。

③ 移植到 UE5.8

// 重建工程:人群坐落车道图(人车共用,工作点锚定)
// 人行道 = 车道图的一类车道;人拿槽、沿弧长走,同交通篇同构。
// UCyberCrowdTrafficCoordinator:共享车道事件 + 槽 + 工作点(work-point)。
// 车道封锁事件:忽略(除监视器状态),死端清理推迟到删除阶段批处理。

要点:人群坐落在车道图上(人车共用)、工作点锚定人群行为、车道封锁清理推迟到删除阶段批处理(不必一收到就仓促处理)。


七、清理:卡住、超范围、生成失败

① 原版设计

人群不断生成,也就不断需要回收。原版的删除路径要处理好几类情况,都在删除阶段统一批处理,而不是随时零散地删:

  • 超出范围:人走出了玩家周围的活跃区域,该回收。
  • 卡住(stuck):人被几何卡住、走不动了,该清理掉换一个。
  • 生成错误:生成过程中出了问题(位置无效、预算耗尽后的半成品),该干净地撤销。
  • 车道死端/堵塞:所在车道成了死路,上面的人该疏散或回收。

这些删除都走删除回调,而且存根控制器存的是裸指针——所以删除必须在锁的保护下、按顺序清除,别留悬空。把删除集中到"删除阶段"批处理,而不是随时删,是为了避免在遍历人群时并发地改动人群集合(那是一类经典的迭代器失效 / 并发 bug)。

深挖:读延迟删除器——短暂淡出、FIFO、和"删前先降级"

读删除路径会发现,一个存根被判定该删,多数时候不是立刻删,而是走一个延迟删除器。流程很讲究:先 设为非常驻、再 把优先级降到人群档、再 触发淡出,最后把它连同一个时间戳压进一个 FIFO 队列。每帧检查队头:当前时间 − 入队时间 ≥ 一个短暂的淡出时长 才真正删。

三个细节值得记:其一,删前先"降级"——设非常驻、降优先级,是为了防止它在淡出这段时间里又被"重要性竞争"重新升回来(正在消失的人不应再被 attach)。其二,FIFO 保证删除顺序 = 请求顺序,内存释放可预测,便于性能分析。其三,只有"能立即删"的(例如已经在视野外、彻底无用)才走同步删,其余都走这条淡出——所以几乎不会看到人无端消失,都是溶解着淡出(这也和交通效果篇的溶解显隐对应上了)。

删除的入口是多路、出口是一个:不管是"到达终点""卡在交通里""死了""超出范围""某车道人太多"哪个原因,最后都汇进同一个删除函数——它统一把存根转交给孤儿容器、从交通系统移除槽、递减范围内计数。其中几条路径有精确的门槛:卡在交通里的,要"不在视野内、且离玩家足够远"才删(近处卡着的先保留,以免在玩家眼前消失);超出范围的,删之前先往远景系统请求一个 dot 替身(实体已移除、但远处那个点还在,视觉不断);某车道人过多的,随机挑几个删、且要越过"最后可见时间"门槛(刚被玩家看到的不删)。移植时这套"多入口 → 单出口删除函数 + 各路径的视野/距离/时间门槛"要照搬——它是"回收从不在玩家眼前突兀发生"的保证。

深挖:读孤儿容器——被摘下的存根去哪了

删除路径里反复出现一个"孤儿容器"——存根从各处被摘下来,不是直接销毁,而是先"转交给孤儿容器"。这是个中转站,读它的实现有两个讲究。其一,它按实体 ID 排序存放,插入用二分查找(lower_bound)定位——因为孤儿可能很多,排序让"按 ID 查一个孤儿在不在"是对数级的,而不是线性扫。其二,它和"事件桥接对象"同生共死:存根进孤儿容器时,给它建一个事件桥接对象;删掉时,一并删掉那个激活器。事件桥接对象是让脚本层能捕获存根事件(生成完成、进视野……)的钩子——所以孤儿存根虽然逻辑上"退役"了,脚本层还能通过它的触发器感知到它。

为什么要这么一个中转站,而不是摘下就删?因为"摘下"和"真正销毁"之间还有事要做——淡出、通知、等安全时机。孤儿容器就是这段"退役但尚未销毁"的缓冲期的归属。这和前面延迟删除器是配套的两层:延迟删除器管"淡出计时再删",孤儿容器管"排序存放 + 触发器钩子",一起构成"存根优雅退场"的完整流程。移植时"存根退役"要有这么一个中转缓冲,不宜摘下即 delete——那样淡出、脚本通知、安全时机都无处执行。

图 · 优雅退场:延迟删除器 + 孤儿容器 + 尸体三层清理圈
图 · 优雅退场:延迟删除器 + 孤儿容器 + 尸体三层清理圈

② 相对 UE5.8 原生的优势

UE 的 actor 销毁是即时的,但"多类回收原因(超范围/卡住/生成失败/死端)集中到删除阶段批处理 + 裸指针在锁下清除"是自建的调度纪律。原版这套的优势是它把"人群随时在生灭"这件危险的事,收拢到一个安全的批处理点——避免了并发改集合、悬空指针这类最难查的 bug。

③ 移植到 UE5.8

// 重建工程:人群清理(多原因集中到删除阶段批处理)
enum class ECyberDespawnReason : uint8 { OutOfRange, Stuck, SpawnError, LaneDeadEnd };
// 攒待删存根 → 删除阶段统一批处理(锁保护下清裸指针,按序,别悬空)
// 别在遍历人群时并发删(迭代器失效/并发 bug)。

要点:多类回收原因集中到删除阶段批处理、锁保护下清除、别在遍历时并发改集合。


八、远景人群:看得见 ≠ 那里有人

① 原版设计

三层的最远一层——远景人群(distant crowd)——单独说明它的认知纪律。远景人群是一片沿车道插值、GPU 实例化渲染的点。它提供的是视觉数据的可用性,而不是"一群活着的人群实体"。

也就是说:远处广场上那一片攒动的人群,渲染上是有效的(可见有人),但逻辑上那里没有实体——没有一个个 NPC 在做决策,只有一片点在沿路线移动、被批量绘制。玩家靠近,需要的那些才升格成存根/实体。

深挖:读远景点的更新——什么时候一个 dot 要"重生"

远景点不是静止的,每帧要更新,而更新逻辑里最有意思的是什么时候把一个 dot 重生到别处。读代码,触发重生的条件是一组"或":它的车道索引失效了、离玩家太远了、它卡住不动超过若干帧、这条车道该减点了、或者它进了常规人群的距离该升级了。还有一个反直觉的守卫:如果一个 dot 在玩家视锥内、但离得又太近(近到快该升级成真实体了),也强制重生——避免"一个本应变成真人的点还在那里作为纯点漂浮"。重生时最多试固定次数,每次挑一条按概率加权的随机车道,重置它的速度、位置、卡住计数。

那个"卡住几帧就重生"的计数很关键:远景是靠"点沿车道匀速流动"造出车流/人流感的,一旦某个点因为前方拥堵卡住不动,它就破坏了"在流动"的错觉——所以直接让它重生到别处,而不是随之堵塞。远景层不解决拥堵,它绕过拥堵:卡住就换处重来。这和近处真实体"卡住了走清理删除"是两套逻辑——近处要真处理,远处只要"看起来在动"。

支撑这套的是一个常量车道网络:初始化时把所有车道预处理成一批"入度、出度都 ≤ 1"的直路链(一条能一路走下去、不分叉不汇入的路),远景点就沿这些直路链插值移动。构建时用了环检测——如果追踪一条链时绕回了自己,就把它标成无效(而不是删掉,留着调试)。为什么要预处理成常量直路链?因为远景点不需要真寻路、不需要在路口决策,只要"沿一条路一直走",直路链正好提供这种成本最低的"路"。移植时远景人群/车流走"预处理直路链 + 点沿链插值 + 卡住就重生 + ISM 批量绘制",别给远景点任何真寻路或真 AI。这条纪律和交通篇的 dot 完全一致:渲染数据有效 ≠ 那里有可交互对象。 把这两者混淆——以为远景人群里每个点都是个能对话的人——是流式人口最容易犯的概念错误,会导致"明明看见那里有人,走过去却没有"或反过来的各种异常。

② 相对 UE5.8 原生的优势

UE 的 ISM/HISM 能画海量实例——底层白送。这套设计的优势不在渲染,而在**明确远景人群是"视觉数据"而非"实体完成"**这个概念划分。移植时用 ISM 画远景人群,逻辑上坚持"它们不是实体"。

更深一层的优势是那套"常量直路链 + 卡住就重生"的运动模型:它让远景人群"看起来在有目的地流动",却几乎不花 CPU——没有寻路、没有决策、没有避让,只有"沿一条预处理好的直路匀速插值,卡住几帧就换一条路重来"。UE 侧如果贸然给远景点也运行一点 AI(哪怕很轻),几千个点也会累积成可观开销;原版这套的价值正是把远景点的"智能"压到零、只留"看起来在动"。这条边界要守死:远景层解决的是"视觉上有一片在流动的人/车",绝不解决"他们各自要去哪、怎么绕障"——那些是升格成存根/实体之后的事。移植时给远景点任何真逻辑,都是在这条最该低成本的层上浪费开销。

③ 移植到 UE5.8

// 重建工程:远景人群 = 视觉数据(ISM 批量画),不是实体
// 沿车道插值的点 → UInstancedStaticMeshComponent 批量渲染
// 纪律:视觉数据有效 ≠ 那里有可交互的人;需要才升格成存根/实体。

要点:远景人群是视觉数据、不是实体;用 ISM 画,逻辑上坚持"看得见≠有人"。


九、持久化:城市记得每个人

① 原版设计

人口系统要能存档、能随流送恢复。因为身份挂在存根上(实体 ID + 持久态),存档存的主要是存根层的状态:谁在哪、谁的持久标记(任务进度、和玩家的关系、存活状态)是什么。完整实体是易失的表示,随时可从存根重建;存根才是要存的身份。

这套的效果是:玩家在一片区域击杀过的人、帮助过的人、触发过的事,离开再回来、存档再读档,都仍被记得。 因为这些不挂在易失的完整 actor 上,而挂在持久的存根 ID + 状态上。

深挖:读"新建 vs 恢复"——两条路必须严格对称

读存根构造的代码,会看到它分两种模式:新建和恢复(读档/流式载入)。这两条路必须严格对称,否则持久状态就会重复或丢失。新建走的是"启用持久化 → 创建基础数据持久态 → 创建各组件持久态";恢复走的是"跳过初始持久化设置、直接访问已存在的持久态 → 对每个组件持久态回调一个'已恢复'通知"。两条路都有一个前置校验:新建前必须确认组件持久态不存在(不然重复创建)、恢复前必须确认它存在(不然无从恢复)——校验不过直接断言。

最能说明"对称有多重要"的是析构:存根析构时强制断言它的基础数据持久态已经被置空——也就是说,一个存根只能通过正规的删除流程销毁(那个流程会先妥善处理持久态),绝不能被随手 delete。这道断言是"防止有人绕过删除流程、留下半清理的持久态"的最后一道岗。

为什么揪着"对称"不放?因为持久化 bug 是最难查的一类——存了没恢复、或恢复了又存一遍,症状往往是"读档后某个 NPC 状态微妙地不对",隔着一次存读档才复现。原版用"新建/恢复两条对称的路 + 前置存在性校验 + 析构断言"把这类 bug 堵在编译期/断言期。移植时存根(以及任何持久对象)的"新建"和"恢复"务必写成严格镜像的两条路,且各带存在性校验——别让"恢复"复用"新建"的路径再打补丁,那是持久化 bug 的温床。这和车辆篇"逐车持久化"是同一个哲学:存身份和持久态(存根 / 逐车状态),不存易失的表示(完整 actor / 物理帧态)。

深挖:可存档 ID 与不可存档 ID——不是每个人都值得进存档

一个容易忽略但重要的区分:不是所有存根都要写进存档。原版对实体 ID 有"可存档 / 不可存档"的区分——重要的、剧情相关的、玩家有过交互的 NPC,用可存档 ID,进存档、跨读档保留;而纯背景的路人(很快就会被回收、玩家永远不会记得的那种),用不可存档 ID,不进存档——他们回收了就是回收了,读档后重新随机生成一批即可,没必要为"某个无名路人此刻在哪"耗费存档空间。

这个区分是存档不膨胀的关键。一座城市同时有几千个存根,如果每个都写进存档,存档会大到离谱、读写会慢。所以系统只持久化"值得记住的少数"——玩家交互过的、剧情标记的、任务相关的——其余的背景人群是可再生的、不持久的。判断一个人该用哪种 ID,本身是编排层的一个决策(这个 NPC 重要吗?玩家可能记得他吗?)。移植时这个"可存档/不可存档"的二分要尽早纳入存根设计——否则要么存档膨胀,要么该记住的人没记住。

② 相对 UE5.8 原生的优势

UE 有存档框架,但"存根层持久化 + 完整实体从存根重建"这套身份/表示分离的存档设计是自建的。它的优势是存档只需存低成本、稳定的存根状态,不用序列化大量易失的完整 actor——既节省空间,又避免"读档后 NPC 状态错乱"的 bug。

③ 移植到 UE5.8

// 重建工程:人口持久化(存存根身份+持久态,不存易失实体)
// 存档 = 存根层(EntityId + FCyberPersistentState)
// 完整实体易失,随流送/读档从存根重建
// 城市"记得每个人"靠稳定的存根 ID + 持久态,不靠完整 actor。

要点:存存根身份 + 持久态、完整实体从存根重建。和逐车持久化同哲学。


附:代码视角走查——一个路人从生成到回收的一生

把读过的代码路径串成一条线,看一个路人在代码里实际经历什么(每步对应前面某个深挖):

  1. 社区决定该有他:作息调度器按当前时段选中一个生成阶段,阶段说"这片区域该有这么多这样的人"。
  2. 发异步生成请求:请求进队列、拿一个 ID(从池后端弹出)、返回一个 handle——还没有人。每帧节流只放行几个。
  3. 异步建存根:创建令牌进待定表,回调里核验"要绑的槽还在吗",在则接管成存根、加进容器;ID 全程锁定。
  4. 重要性竞争决定升不升:他和周围所有候选一起,每帧按一套多因素重要性评分(身份优先级 + 类型 + 距离 + 高度差 + 视野/POI 加成)竞争有限的完整实体名额。
  5. 升成完整 NPC:名额有余、他排到了 → 存根 attach 完整实体,按黑名单剥掉状态效果/小队/感知等他用不上的组件;从持久态恢复他的记忆(和你的恩怨还在)。
  6. 活着、被玩家影响:玩家与他的交互写入他存根的持久态。
  7. 玩家离开、他降级:完整实体 dispose 释放名额,退回存根——但走延迟删除器:先降级、短暂淡出、进 FIFO,删前进孤儿容器中转(排序 + 触发器钩子)。
  8. 更远、退成 dot:连存根都回收,他变成远景一个点,沿常量直路链插值流动;卡住几帧就重生到别处。
  9. 他若死亡:尸体计数器按三层圈清理,ID 插回池前端(不急着重用,防止脚本里"那个死掉的人"被张冠李戴)。

全程他的身份(ID + 持久态)稳定,表示(dot / 存根 / 完整 NPC)反复变——这正是"一座城市记录每个个体、却从不同时模拟每个个体"在代码层真正的样子:一串在令牌、队列、重要性公式、淡出计时、孤儿容器之间小心编排的状态转换。

图 · 一个角色的生命周期:表示动态升降,身份始终在场
图 · 一个角色的生命周期:表示动态升降,身份始终在场

十、读代码读出的人口铁律:一堆常量背后的纪律

把前面各章"读代码才知道"的细节提取出来集中审视,会发现人口系统的稳,靠的是一批看似琐碎、实则每条都堵着一类 bug 的纪律。移植时这些比架构图更有价值:

关于"不轻信当下"(流式生灭下的防御)

  1. 存根用裸指针换性能,但删除必须走回调、在锁下、清干净。 量大高频才值得,UE 侧先用弱引用保安全、实测再优化。
  2. 异步创建完成时要重新核验依赖。 建存根期间它要绑的槽可能已被删——完成回调里发现槽过期就作废这个存根,别留孤儿。
  3. "更新"和"删除"可能同帧、跨系统发生。 关键写入前加"它是不是即将被删/有没有父级"的守卫。

关于"回收要优雅"(从不在玩家眼前突兀发生) 4. 删除延迟一个短暂淡出、走 FIFO,删前先降级(设非常驻 + 降优先级,防淡出期间又被升回来)。 5. 多入口删除汇进单出口函数,各路径带视野/距离/最后可见时间门槛。 6. 退役存根进"孤儿容器"中转(按 ID 排序 + 触发器钩子),别摘下就 delete。

关于"名额与 ID"(预算约束下不浪费、不串号) 7. 升级是多因素重要性评分的静默竞争(身份优先级 + 类型 + 距离 + 高度差 + 视野/POI 加成),不是距离阈值触发、不发事件。 8. 完整实体预算按画质从表里读,挂不上先转"未挂载暂存"不丢,失败即弃不重试。 9. 生成每帧节流(固定的少量),且"生成 vs 删除"能抵消就抵消。 10. 回收的 ID 插回池前端——最近释放的别急着重用,防脚本张冠李戴。

关于"内存告急时的果断" 11. 尸体清理三层圈(外圈立即删/中圈软删/近圈留),只有总数超上限、又一个没删成的"绝望"时刻,才越过视野约束删玩家眼前的。

关于"持久化对称" 12. 新建 / 恢复必须严格镜像,各带存在性校验,析构断言持久态已清——堵住"存了没恢复/恢复又存一遍"这类最难查的读档 bug。

这些纪律的共同底色和车辆调度篇那十条一样:在流式、并行、随时失效的环境里,不轻信任何"现在拿到的下一刻还在、还有效"。 一座城市能"记录每个个体却不同时模拟每个个体",不是靠某个聪明的架构,是靠这十几条纪律一条不落地照做。补一句锁的纪律:人口系统里每处临界区都极短(只护队列/表的增删,不在锁内做 I/O 或复杂计算)——生成/删除请求常从别的线程(脚本、输入)发来,短临界区 + 分系统的锁,是"既线程安全又不抖帧"的前提。


十一、迁移取舍:参考原版自建面向数据的人口运行时

最后总结移植口径:优先参考原版,自建面向数据的人口运行时;Mass 是可选升格,不是必须。

机制 用什么 不用什么 为什么
身份 存根优先(ID + 持久态,独立于实体) ❌ spawn 即完整 actor 身份/表示解耦
三层 完整 NPC / 存根 / dot,双向 handoff ❌ 单一表示 成本即距离
编排 社区数据 → 编排层 → 人群 — 数据驱动;prevention=通缉响应生成(独立子系统)
生成 异步请求队列(受理≠完成,多 token) ❌ 同步 SpawnActor 适配流送 + 预算
预算 完整实体 attach 硬预算 + 存根池独立预算 — 帧率闸门
坐落 人群坐落车道图(人车共用 + 工作点) ❌ Mass 双插件搭桥 统一坐标系
清理 多原因集中删除阶段批处理 ❌ 随时零散删 避免并发改集合
远景 视觉数据(ISM),非实体 ❌ 给远景点实体 看得见≠有人
持久化 存根身份 + 持久态 ❌ 存易失完整 actor 城市记得每个人
承载 面向数据自建(SOA 存根 + 预算化 subsystem + BVH + ISM) ❌ 必须用 Mass 更高语义还原 + 控制力
大脑 规则(默认)→ LLM(人群导演/个性,可选) — LLM 换决策,执行不变

为什么优先自建而非直接采用 Mass:Mass(MassEntity + MassCrowd)能省掉 ECS 管道、能接 MassCrowd/MassTraffic 的现成能力,但代价是迁就它的范式,且这套 stub-first / 单线程消化 / 可见性优先级 / 预算化的语义,用 Mass 也得自行补齐大半。参考原版自建一套面向数据的人口运行时(SOA 存根数组 + 预算化的 UWorldSubsystem + BVH 空间查询 + ISM 远景点),能拿到更高程度的语义还原和控制力。但"自建"不等于"全盘拒绝 Mass"——某些层局部采用 Mass 反而合适:比如远景 crowd、或那些无状态的轻量代理,用 MassEntity 的 fragment + processor 来跑批量更新,是它的强项;而"身份/表示解耦、预算化 attach、stub-first 生命周期"这套核心语义留在自建层掌控。所以更准确的口径是:核心自建、边缘按需借 Mass,而不是二选一。

最后补一句 LLM 的定位,以免突兀:本篇讲的是人口"运行时"——身份、表示、预算、异步生命周期,这套是地基,和 LLM 无关,也不该被 LLM 替代。 如果未来引入 LLM,它更适合待在内容层和决策层(人群的个性、街区的氛围、少数 NPC 此刻的反应),而不是去接管"谁该生成、谁该升级、谁该回收"这套运行时调度。

那 LLM 具体负责什么? 和交通导演同构:慢层离线用 LLM 生成人群的"个性与剧本"(不同街区的人什么作息、什么反应倾向),固化成社区数据参数;中层只给玩家附近、剧情相关的少数 NPC 升格成 LLM agent 做运行时决策。三层表示的"成本即距离"直接沿用到 LLM——远处的人连规则 AI 都不运行,更不会调用 LLM。而这一切的前提,是运行时地基已经稳固地把"人的来源、去向与回收时机"处理妥当;LLM 只是构建于这层地基之上的"大脑",不是地基本身。


AI 协作复盘

  • AI 帮了什么:本篇基于对原版人口模块的源码级深读(存根优先与实体 ID 稳定、三层表示与双向 handoff、人口编排层桥接社区数据/存根/生成/车道/存档/事件、生成的请求队列语义与多 token 完成链、完整实体 attach 预算与存根池独立预算、人群坐落车道图与工作点、多原因回收集中删除阶段批处理、存根控制器的裸指针与删除回调、远景人群的视觉数据而非实体、存根层持久化),逐条带回真实机制并映射到 UE5。
  • 人怎么补位:①口径是人定的——沿用"优先参考原版自建、Mass 可选不必须"(用户此前在主篇人群章明确过);②匿名化——原版内部类名/行号只留研究笔记,正文用通用工程术语。
  • 怎么核验:每条结论可回溯到人口模块深读(如"生成接口返回受理信号而非实体完成""存根控制器存裸指针依赖删除回调""远景人群是视觉数据可用性"均来自深读档案)。
  • 一条自律(据一轮 AI review 收敛过):这篇分清了三层——源码明确存在的机制(接口/数据结构/断言直接读到的)、从结构反推的模型(如"四棒接力""重要性评分的维度",用"可抽象为……""从接口看更接近……"标注)、我的 UE5 迁移设计(UCyber* 自建那部分)。精巧的模型不写成"已证明的运行机制";具体常量(次数/时长/距离)在正文只讲"有这么一道阀/门槛",不写死数字(避免组合起来指纹到具体作品)。
  • 这篇的边界:它是开发计划 / 重建蓝图,讲透"成群的人怎么被编排调度、UE5 上怎么复刻";这些人怎么动(运动学、动画、群体表现)是行人篇《效果·物理·表现》那篇的事。

发表评论

了解 AI Native Game Development 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读