这篇文章是「如何制作一款开放世界游戏」系列的实战篇之一(系列总纲《技术全面解析》在此,建议先读那篇建立全景),聚焦一个足以单独成篇的深点:当街上要同时活着几百个 NPC、上百辆有司机的车,这套系统到底是如何支撑的,又该怎么把它搬到 UE5 上而不解体。 材料来自对一款成熟商业开放世界游戏引擎与客户端代码的长期源码级逆向阅读(约 700+ 篇源码级笔记),文中的类名、函数名、阶段划分都在源码里逐处核对过,只是发布时按惯例做了匿名化处理——讲成一套通用的「开放世界人口系统」,不点具体游戏和中间件的名字。
行人主题按「层」切成上下两篇:本篇(上)讲调度与决策——到「产生任务槽」为止:谁持有所有对象、一帧按什么顺序推进、人口怎么生成剔除、谁决定一个对象算多细、一个 NPC 怎么从感知走到「决定做什么」。下篇讲行为、表现与物理——执行任务:任务槽怎么变成走/逃/环境行为、场景点驱动的「在对的地点做对的事」、以及 locomotion 动画与 ragdoll 物理的定调。
和纯讲原版设计不同,这一篇的 UE5 落法不是纸上推演:我们有一个正在开发的 UE5 原型工程(下称 VirtualWorld / VW),它刻意不走 Mass 这类现成人群/实体框架,而是从零构建一套「世界托管实体、按相位推进」的运行时,尽量保留原版的更新时机、函数粒度和所有权边界。为什么不用 Mass?这不是「没想到」,而是权衡之后放弃的结论——正文每章会具体讲清楚:要保住原版那套「所有权分层、行为可打断、成本编码进状态」的语义,套用一个为「同质、可并行批处理」设计的数据导向 ECS 框架,是方向相悖。
写法上每个子系统走同一条三段线索:先讲原版是怎么设计的(源码级,落到函数),再讲为什么现成方案(尤其 Mass)不适合搬这套语义,最后落到这个原型里是怎么实现/规划的——哪些做完了、哪些还是骨架、哪些是已知缺口,都如实说,绝不把「立了骨架」说成「做完了」。
诚实边界:我读透的是「人口这一层运行时怎么被组织起来」——生成、剔除、LOD、任务分发的调度骨架和所有权边界;没读透的是它底层依赖的算法黑盒(导航网格构建、寻路内核、动画混合树内部、任务树调度算法)。凡涉及这些,我都会明确标注「盲区」。读到哪、讲到哪,这条界线全篇一以贯之。
UE5 实现实操在后续落地篇:本篇(与本系列所有方案篇)的定位是设计与迁移决策——原版为什么这么设计、哪些自建哪些复用、接缝画在哪。每篇方案篇都对应一篇落地篇,讲 UE5 工程里实际怎么跑——代码走读、接引擎子系统的具体做法、踩坑与验证——后续陆续补上。
引言:多实体人口运行时的系统成本
先做个思想实验。你手上有一个渲染、动画、物理都现成的引擎,美术也给了你一个做工精良的路人角色。把一个这样的 NPC 放进世界,让它走动、查看手机、受惊逃跑——不难,UE5 里搭建 Character + 行为树,很快就能出效果。
难的是把这个数字乘以几百。
一旦街上同时要有几百个路人、上百辆有司机的车,所有在「一个 NPC」尺度下无害的假设会同时失效:
- 谁决定这一帧生成谁、删掉谁? 你不能让每个 NPC 自己决定「我该不该存在」——那样就无人管理全局预算,数量一多便会失控。
- 镜头一公里外那两百个人,凭什么会自动「算得少一点」而不是耗尽帧预算? 你不能指望「优化编译器」替你降低远处对象的开销,这件事必须被显式地设计进来。
- 一个刚被创建出来的路人,它的第一个行为是谁给它装上的? 是生成它的工厂,还是别的什么人?这个边界划错,后面所有「NPC 出生即卡死」的难以定位的 bug 都源于此。
- 一辆 NPC 开的车,它的驾驶 AI 到底归谁? 归车,还是归司机?这个所有权问题想不清,联网和 LOD 都会随之失序。
这些问题,在单个 Actor 的 Tick() 里一个都回答不了——因为它们全都不是「某个对象自己的事」,而是世界对所有对象的统一调度。
这就是本文的主线。海量角色系统的真正难点,不在「让一个 NPC 像活的」,而在用一份固定的每帧预算,去供养一群数量随时在变、离玩家远近各异、所有权还各不相同的异构对象。把这句话拆开,就是本篇要讲的几件事:
- 世界基础层:谁持有所有对象、一帧按什么顺序推进它们、增删怎么才安全。
- 实体表示:一个对象「是完整实例还是轻量代理」怎么编码进它自己的状态,怎么在两者间无缝切换。
- 人口的生成:世界一侧决定「此刻该生成多少人、生成在哪」的策略。
- 人口的剔除与预算:谁能被删、谁进复用池、这一帧还允许产出多少。
- 行为级 LOD 与代理切换:同一个对象凭它离玩家多远,决定它这一帧「算全套、算一部分、还是几乎不算」。
- 感知—决策—任务流水线:单个 NPC 从「知道周围发生了什么」到「决定做什么」的真机理。
而「决定做什么」之后——任务怎么被执行成走/逃/环境行为、街边抽烟坐长椅的人从哪来、动画和 ragdoll 怎么定调——是下篇的内容。
有一条纪律会贯穿全文,值得先点明:这套系统从不把「一个对象要不要更新」压成一个孤立的布尔值。 它始终是分级的、分相位的、分所有权的——离玩家近的算全套,远的按时间片轮流算,再远的降成一个几乎不占 CPU 的代理;而且「什么时候算」是被一个世界级调度器按固定相位驱动的,不是每个对象自己 Tick 出来的;而且「谁拥有这个状态」和「谁只是请求/观察」被划分得十分清楚。这条「成本即距离、调度即相位、职责即所有权」的纪律,是海量角色系统得以成立的基础层。

基础层与 Mass 的职责边界
先说明本节的定位,以免误读全文主题:这不是一篇「Mass 对比文」。 Mass 只是本文迁移决策中的一个代表性例子,真正讨论的是一个更普遍的问题——一个框架是否匹配目标系统的语义形状。之所以处处以 Mass 为参照,是因为它是当下 UE 社区做人群的第一直觉,把「为什么直觉在这里不成立」讲透,恰好能把这套系统的语义形状衬托出来。
在展开之前,需要先把这整篇的基础层决策说透:VirtualWorld 原型不让实体继承 AActor、不靠 Tick(),也不套 Mass 这类数据导向 ECS 框架,而是由一个世界运行时(FVWWorldRuntime)持有所有实体、按相位推进。
这个「不用 Mass」的决定,值得单独解释,因为它和当下 UE 社区「做人群就用 Mass」的直觉正好相反。
Mass 擅长的是「几万个同质、轻状态、可并行批处理的实体」——一片会飘的草、一群没有个体差异的背景人、一场粒子风暴。它的威力来自把同类实体的数据排布成连续内存(Fragment),用 Processor 批量、缓存友好地扫描。只要你的问题符合这个形状,Mass 性能极高。
但海量角色系统要的恰恰是相反的形状:
- 每个 NPC 有分层的所有权——谁拥有它的任务、谁只是请求生成它、谁只是在旁边观察它。这是「关系」,不是「数据行」。
- 每个 NPC 有可被打断的日常态——它在坐着,但随时可能被一声枪响打断。这是「带优先级的状态机」,不是「单遍扫描的纯函数」。
- 每个对象有「这一帧算多细」的成本档——而且这个档位本身是个带滞后的状态机。
- 每个 NPC 有从事件到任务的因果链——「因为听到枪声,所以产生逃跑任务」。这条因果要能溯源、能裁决、能合法地中止。
把这些放进 ECS 的 Fragment + Processor,你会立刻陷入两难:要么把 Processor 写成一批互相依赖、带大量 per-entity 分支的逻辑——那 ECS 赖以见长的连续内存批处理随之消失,你付出了架构代价却得不到性能红利;要么为了迁就框架,把原版的所有权和因果关系压平成扁平数据——而那套所有权和因果,正是「像一座活城市」和「像一片背景板」的全部区别。
因此原型的选择相当明确:基础层自行构建,把原版的语义原封不动搬进来,而不是迁就一个为别的问题设计的框架。 这不是「不信任引擎」,而是「认清这个问题的形状」——它是异质的、有状态的、讲所有权和因果的,更适合手写运行时来承载。下面逐章展开时会反复看到:正因为基础层是自建的,每一层语义才都有它本来的形状可以安放。
需要讲平的是:这是一次工程权衡,不是对 Mass 的否定,更不是否定 ECS 这个范式本身。 这里真正否定的只有一件事:让一个形状不匹配的框架来定义系统的语义。Mass 在同质实体的批处理场景(成片植被、无个体差异的远景人群、粒子级 agent)依然是很有效、很值得用的方案——数据导向批处理该归 ECS 的地方,本原型也不会强行写成面向对象;只是在「海量角色」这个问题里,语义约束(所有权、可打断、因果、成本档)的优先级高于批处理吞吐,权衡的天平才倒向自建。换一个问题形状,结论完全可能反过来。本文后面每章的「为什么不套 Mass」,讲的都是在这个特定问题下的不匹配,而不是一个放之四海的判断。

(顺带说清楚一个边界:不用 Mass ≠ 什么现成设施都不用。下篇会看到,像「世界预置交互点」这种纯数据/查询层的设施,原型是打算直接复用的;像车辆刚体物理这种与人口语义无关的现成组件,也该复用引擎自带的。「自建」针对的是运行时调度与所有权这层基础,不是一切。)
最后交代一个贯穿全文的口径:讲原型落法时,会把当前状态明确分成三类——已落地(有实现、有函数体或有测试覆盖)、已建骨架(类型、状态、迁移都建好,具体行为还空)、未实现(路线图有条目,代码还没写)。每一章的第三段都会标清楚它落在哪一类,避免把「立了骨架」读成「做完了」。
下面逐章展开。
一、基础层:世界托管实体与相位调度
这一章要解决的问题:谁持有所有对象?一帧按什么顺序推进它们?在遍历的中途生成或删除一个对象,怎样才能不崩溃?
这一章讲的是最底层的那一块。如果把这座城市看成一个运行中的系统,本章解决的是「操作系统级」的问题:谁拥有对象、什么时候调度、什么时候允许改变世界状态。它看起来和「海量角色」离得最远,其实是全篇所有上层策略能不能站稳的基础层——基础层如果做成「每对象 Tick」,后面的 LOD、任务、人口往往都会受牵制;基础层如果交给 Mass,后面的所有权和因果就得迁就框架。 所以先把它讲透。
① 原版机制
原版的实体不是「Actor」。它是一条由世界层托管的记录。这个区别是理解一切的起点。
在原版里,一个实体(Entity)被创建出来后,它的生命周期、它在各套系统里的注册(空间划分、世界、渲染、室内、物理)、它每帧被不被更新,都不归它自己管,归一个世界层(下称 GameWorld)管。实体自己只提供一组「相位钩子」——「到了控制相位请调我的 ProcessControl」「到了物理前相位请调我的 ProcessPrePhysics」——但什么时候调、要不要调、调完之后要不要把它从某个列表里移出,是世界层的决定。
为什么非要这样?因为开放世界里的更新存在大量时序约束,而这些约束单个对象的 Tick 根本表达不了:
- AI 要在拿到完整事件上下文之后才能决策——如果一个 NPC 在自己的 Tick 里决策,它依赖的「周围发生了什么」可能这一帧还没收集齐。
- 物理求解必须在一个集中的、靠近模拟时钟的阶段做,不能散落在各对象的 Tick 里被随意打断。
- 「这一帧是否发生了碰撞」这类物理后标志,要在物理跑完后由一个统一阶段来清;散在各对象里清,必然先后错乱。
- 反向动力学(脚贴地、手扶墙)要拿到最终骨骼矩阵才能做,必须排在动画和物理都定稿之后。
这些约束的共同点是:它们关心的是「在整帧的哪个阶段做」,而不是「在哪个对象里做」。 对象的 Tick 是「按对象切」的,而这些约束是「按阶段切」的——两套切分方式正交,用前者表达后者必然生硬。原版的解法是把一帧切成若干有序相位,每个相位扫一遍相关对象、只做该相位该做的事:控制 → 物理前 → 物理 → 物理后 → 预渲染 → 预渲染后。
还有一个极其关键、也极容易做错的点:增删的安全性。在开放世界里,一个对象可能在「控制相位」里被 AI 决定要删除(比如它刚死、刚出视野),也可能在某相位里被生成(比如一次爆炸产生了碎片)。如果你在遍历更新列表的中途真的把对象销毁了,遍历就会访问到失效指针。原版的做法是:删除请求先入队、真正的销毁推迟到本帧相位全部跑完之后;相位遍历中途只做「标记」,不做「销毁」。
一个非常精细、也非常能说明原版克制的细节:一个实体的 ProcessControl 返回 false,不代表要销毁它——它只代表「把这个实体从控制相位的更新列表里移出」(它可能只是不需要每帧控制更新了,但作为世界实体依然存在)。把「不再更新」和「要销毁」这两件事分清楚,是这套调度的一条隐形纪律。
这一章的意义:开放世界的基础层不是「一个更快的 Tick」,而是「把一帧按相位切开、把增删的安全点集中到帧边界、把实体的生命周期从实体自己手里收归世界层」。这三件事做对了,上面所有系统才有稳固的基础可以立足。

② Mass 方案的适配边界
到这里,「为什么不用 Mass」已经能给出第一个、也是最根本的理由了:Mass 会替你决定这层基础的形状,而这层基础恰恰是最需要贴着原版语义自行设计的。
如果你用 Mass,那么「谁持有实体、一帧怎么推进、增删怎么调度」这些问题的答案,就变成了「Mass 的 Processor 执行图 + Fragment 生命周期」。Mass 的调度是为「批量、并行、缓存友好」设计的,它当然能跑,但它的相位模型和原版那套「控制→物理前→物理→物理后→预渲染」的语义相位不是一回事——你要么接受 Mass 的模型(那就丢了原版精心设计的时序语义),要么在 Mass 上面再叠一层自己的相位(那 Mass 在这层就只剩一个累赘)。
而且,Mass 的实体是「扁平数据行」,它天然不喜欢「一个实体返回 false 表示把自己移出某个列表但不销毁」这种带语义的、逐个对象的生命周期决策——这类逻辑在 ECS 里要么变成一个额外的 tag Fragment + 一个专门的 Processor,要么就与框架方向相抗。原版那种「删除先入队、帧尾统一 flush」的安全点,在自己搭的运行时里是一个自然的设计;在 Mass 里则要小心翼翼地和它的 defer 命令系统对齐。
一句话:基础层这一层,Mass 给的「便利」是有前提的——前提是你的实体就该是扁平数据行、你的更新就该是批量 Processor。海量角色的实体不满足这个前提,所以在这层用 Mass,等于用短期便利换来长期的适配负担。
③ 原型状态与规划
已实现的部分是:这层基础在原型里已完成基础实现、进入稳定状态,并有 30 条以「世界运行时」命名的自动化测试覆盖。 它是全篇落地程度最高的一层。
FVWWorldRuntime 用 TUniquePtr 持有所有实体,每个实体拿一个稳定的句柄 FVWEntityHandle。注意这个选择:它不用裸指针在系统间传实体,而是用句柄——因为将来脚本、网络、调试都要引用实体,句柄能统一「这个引用还有效吗」的判断,避免野指针。这正对应原版「实体是世界托管的记录、不是任意系统都能持有的裸对象」的思路。
它不靠 AActor::Tick,而是每帧按固定相位推进,相位名和调用时机刻意对齐原版:
RunControlPhase -> 每个登记实体的 ProcessControl
RunPrePhysicsPhase -> ProcessPrePhysics
RunPhysicsPhase -> ProcessPhysics
RunPostPhysicsPhase -> ProcessPostPhysics (UE 侧扩展点,见下)
RunPreRenderPhase -> PreRender
RunPostPreRenderPhase -> PostPreRender
其中 PostPhysics 是相对原版新增的一个 UE 侧扩展相位——留着将来和 Chaos 或 Actor 的 transform 做对账用,不希望把这部分逻辑强行并入原版风格的物理钩子。这是「保留原版语义、但在该扩展的地方诚实地扩展」的一个例子。
三个关键安全语义已经用测试固定下来,逐条说:
第一,实体不自己管全局注册,增删都走世界一侧、延迟销毁。 删除走 RequestRemoveEntity 入队,真正销毁推迟到本帧相位跑完之后——实现上是 PendingRemoval 标记 + ProcessingDepth(记录当前在第几层相位遍历里)+ FlushPendingRemovals(在安全点统一清)。在某相位遍历中途请求删除,只打标记,实体要到 flush 点才真正消失。这正对应原版「删除由世界侧协调、不在遍历中途销毁」。对应的测试里有一条专门验证「在物理相位里请求自删,会跳过它后续的相位」——说明「标记了就不再往下走,但对象还在,帧尾才回收」这条语义是真的成立、被测过的。
第二,ProcessControl 返回 false 只把实体移出 control 相位、不等于删除它。 这条语义和原版一字对齐,而且有一条测试的名字就直接叫「ProcessControlFalseDisablesControl」。原型甚至为此设计了一个更细的区分:一条测试验证「移出控制相位并继续」会照常计数,另一条验证「移出控制相位的同时请求删除」时删除优先——把「不再更新」和「要销毁」这两件事的组合都覆盖到了。
第三,按相位选择性登记。 EVWEntityUpdateFlags 是一组位标志(Control / PrePhysics / Physics / PostPhysics / PreRender / PostPreRender),一个实体只登记进它真正需要的相位桶(ControlUpdateBucket / PrePhysicsUpdateBucket / …)。远处的、纯装饰的对象可以只留在很少的桶里,甚至一个都不留。这是「按相位切、而不是每对象全套 Tick」的直接体现,也是后面接 LOD 的物理挂点——第五章讲的「降档」,落到实处很大一部分就是「把这个实体从某些相位桶里移出」。原型还特意做了防御:相位标志在请求边界严格校验,未知的位进不了命令缓冲;已经进入待删除的实体,拒绝再接受相位启用/禁用请求。
还有一个容易被忽略、但很能体现「基础层要稳」的设计——在相位遍历中途生成实体的处理。 如果一个实体在控制相位里创建了另一个实体,新实体不能立刻被放进正在遍历的桶里(会打乱遍历),也不能立刻就跑后续相位(它错过了本帧的控制相位)。原型的做法是 PendingAddEntities:新实体立即被绑定(能被 FindEntity 找到、能请求相位变更、能请求自删),但只在一个安全点才真正登记进相位桶,且不会在同一帧里补跑它已经错过的相位。对应测试专门验证了「控制相位里新增的实体,其相位桶登记推迟到下一帧」。这一整套「pending add / pending remove / 命令缓冲 / 安全点 flush」,就是原版「世界侧列表变更边界」在 UE C++ 里的忠实落地。
诚实边界:这层目前的命令执行还是单线程的——它是一个安全边界,不是 TaskGraph 并行系统。原型明确把它标为「Layer 1 的观测性调度统计」,NeedsAnotherStep / Skipped 这些物理步结果目前只是记进统计,还没真的去跑额外物理子步或跳过队列。也就是说,基础层的「正确性和安全性」已经立住,「并行性能」是后面的事——这个先后顺序本身也是刻意的。
这层的验收方式,顺带给全系列立了一个方法论样板:测试名就是规格清单。 用测试注册宏把原型的测试文件扫一遍,今天能数出 769 条自动化测试;其中以「世界运行时」为前缀的恰好 30 条,名字逐条对应上面讲过的安全语义——ProcessControlFalse(返回 false 只退出控制相位)、DeferredAdd 与 PendingAddPhaseCommand(遍历中途新增走待处理队列,新实体的相位命令同样延迟生效)、PhaseMutationDuringControl 与 DuplicatePhaseMutation(遍历中改相位、重复提交相位变更)、ShutdownHandleReuse 与 DestructorShutdown(关停后句柄不得复用、析构路径兜底关停)、ImmediateRemovalRetiresEntity 与 FlushRetiredEntities(立即移除也只是退役、退役实体在安全点统一回收)。这份名单几乎可以当本章的目录读:想审计基础层做没做某件事,不用翻实现,先翻测试名。另一个值得注意的比例是:基础层只占全部测试的约二十五分之一,其余七百多条都压在后来的车辆与交通子系统上。这个分布本身就是「基础层早早稳定、之后极少回头改」的证据——底层立稳之后,上层每加一块能力,测试都加在上层,这 30 条从头到尾几乎没有再动过。
二、实体表示:完整实例与代理状态
这一章要解决的问题:为什么远处的对象要变成一个「轻量代理」?这个「完整实例⇄代理」的切换,怎么才能不是一个裸开关,而是一次带门控的、可追溯的裁决?
上一章立了「谁持有对象、怎么调度」的基础层。这一章往上一层:同一个概念对象,怎么用两种不同「重量」的表示来承载它,以及在两种表示之间怎么安全切换。 这是海量对象不爆的核心机制之一,也是最容易被做成「随意设置一个 bool」的地方——原版在这里的克制,特别值得学。
① 原版机制
原版对「可交互物件」这类对象,有一套完整实例(real)与占位代理(dummy)分离的机制。远处那些你根本不会与之交互的物件——路边的垃圾桶、长椅、栏杆——没必要以完整实例存在(占内存、要注册进物理和渲染)。它们平时是一个极轻的 dummy 占位;当玩家靠近、或脚本要用它、或它进了潜在可见集,才被「实例化」成完整对象。玩家走远,再转回 dummy。
关键在于:这个转换不是一个动作,是一次要过一长串门控的裁决。 原版的对象人口系统(ObjectPopulation)在决定「把这个 dummy 实例化」或「把这个 real 降回 dummy」时,要考虑众多拒绝或推迟的理由:
- 这个对象被脚本占用着吗?被过场占用着吗?被程序化生成系统占用着吗?——占用着就不能随意转换。
- 它正被玩家拿在手里吗?对某个远端玩家可见吗?——是就不能降。
- 它在潜在可见集里吗?被狙击镜或第一人称需要吗?——是就得保持完整。
- 它的模型资产就绪了吗?转换会不会触发一次流式加载、或一次实体生命周期变更?——会的话可能得推迟。
- 它是一个可见的、已损坏的对象吗?——损坏状态要保留,不能在转换中丢失损坏痕迹。
每一条「不转」的理由,都是一个明确的、可区分的状态,而不是一个含糊的「转换失败」。 这一点极重要:当你在调试「为什么这个对象没被回收」时,「因为它被脚本占用」和「因为它对远端玩家可见」是完全不同的排查方向。把拒绝理由压成一个 bool,等于丢弃了调试信息。
而且原版把「转换的裁决」和「转换的调度」分开了:裁决是对象本地的(这个对象此刻能不能转),调度是世界侧的(这一帧的预算允许转多少个、优先转哪些)。这个拆分在第五章讲 LOD 预算时还会回来。
这一章的意义:「一个对象是完整实例还是代理」是海量对象规模化的核心开关,但这个开关绝不能是裸的——它必须是一次带一长串门控的裁决,每个拒绝理由都留痕,而且裁决(能不能转)要和调度(这帧转不转、转哪些)分家。
② Mass 方案的适配边界
这一层是 Mass「扁平数据行」模型最不适配的地方。
「完整实例⇄代理」说到底是同一个概念对象的两种重量差异极大的表示之间的切换,切换时还要过一长串带业务语义的门控。在 ECS 里,你会立刻面临一个尴尬的建模问题:real 和 dummy 是同一个 entity 的两组 Fragment,还是两个不同的 entity?
- 如果是同一个 entity 换 Fragment——那「实例化」就是一次结构变更(archetype 迁移),在 Mass 里是相对昂贵、且要小心的操作,而你可能每帧要处理成百上千次这种转换的候选。
- 如果是两个 entity——那你就得自己维护「这个 dummy 对应哪个 real」的映射,还要处理转换时的身份转移,ECS 本身提供不了任何帮助。
更不必说那一长串门控(脚本占用、玩家持有、潜在可见集、损坏状态……)——它们是逐个对象、带大量分支的裁决,正是 Processor 批处理最不擅长的形状。把这套裁决写进 Processor,它就退化成一个带二十个 if 的循环,ECS 的意义随之丧失。
所以结论和上一章一致:这层的核心是「带语义的表示切换 + 逐个裁决的门控」,不是「批量处理同质数据」——用 Mass 是选错了工具。
③ 原型状态与规划
这一层原型已经立好了骨架,而且骨架的质量很高——它把原版「转换是一次带门控的裁决」这条纪律,做成了一套显式的「请求—策略—决议」协议。 但要说清楚:目前落地的是单个对象的转换协议和裁决门控,还没落地的是世界侧的批量扫描与预算调度。分开讲。
已经落地的(单对象的转换协议):原型有 FVWObject,并把「这个对象现在是完整实例还是代理」做成了一等状态:EVWObjectRepresentationState { None, Dummy, Real }。转换不是一个裸 setter,而是三步:
- 请求:
RequestRealizeFromDummy(...)/RequestConvertToDummy(...)提交一个转换请求,请求里带请求方句柄、原型名、模型哈希、是否脚本请求、以及一个序列号(用于后续「这个请求还是我当初提的那个吗」的校验)。 - 策略裁决:
FVWObjectConversionPolicy::EvaluateAndApply(...)是策略闸门。它的头文件注释写得极其克制——「explicit Object-local request policy only; it does not scan, resolve, stream, create, destroy, or auto-run from phases」。也就是说,它只回答「该不该转」,绝不自己去扫描世界、去流式、去创建销毁。这条边界和原版「裁决与调度分家」严丝合缝。 - 决议:请求被接受后记为一次决议(resolution),带前后表示状态、序列号,供后续消费。
真正精彩的是策略闸门内部的门控顺序。读它的实现,你会看到一条严格排序的裁决链:先校验动作合法(不是 realize 也不是 convert-to-dummy 就直接拒)→ 检查对象表示不能是 None(否则是无效对象)→ 检查是否已有未决请求(有就保留原请求、不覆盖)→ 检查当前表示和目标动作兼容不兼容(已经是目标表示 / 表示不匹配,是两个不同的返回)→ 然后跑一长串「输入阻断」检查。
那串「输入阻断」检查,几乎是原版那套拒绝理由的逐条搬运,而且是按优先级顺序逐个判的:缺原型或模型元数据 → 会触发实体生命周期 → 会触发流式 → 模型资产没就绪 → 对 real 对象不安全 → 没有关联的 dummy → IPL/室内无效 → 被「永不 dummy」钉住 → 在潜在可见集里 → 被狙击镜/第一人称需要 → 是可见的损坏对象 → 被脚本需要 → 被脚本/程序化/过场占用而拒绝转 dummy → 网络控制阻断 → 回放占用推迟 → 被玩家持有 → 对远端玩家可见 → 临时/碎片生命周期推迟 → 被策略推迟。每一条命中都返回一个专属的状态枚举值——NeverDummyPinned、FoundInPvs、RequiredBySniperOrFirstPerson、HeldByPlayer、NearOrVisibleToRemotePlayer……一共二十多个。
这就是原版纪律的忠实落地:转换要过一长串门控,每个「不转」的理由都是一个可区分的、可单测的状态,而不是一个含糊的 false。而且整个策略是纯函数式的、Object-local 的、不碰世界的——你可以对它写大量单元测试,喂不同的输入组合,断言它返回哪个精确的拒绝理由。

还没落地的(世界侧的扫描与预算调度):迁移路线图把「Object 与 Dummy/Real Population」列为一个独立层,目前未开始。也就是说,那个「每帧扫描一批候选对象、按预算(这帧允许转多少个 real、多少个 dummy)、按 focus/摄像机/玩家距离排优先级、然后对每个候选调一次上面那个策略闸门」的世界侧调度器,目前只有设计条目,没有实现。原型已经把最难、最容易出错的「裁决门控」做得充分,把相对机械的「批量调度」留在后面——这个先后顺序,又是一次「先立正确性、再补吞吐」。
诚实边界:EVWObjectRepresentationState 的注释也交代得很清楚——它是「Object-local semantic representation only; it does not prove model residency, drawable existence, world membership」。意思是:这个状态说的是「语义上这个对象现在算 real 还是 dummy」,它不保证背后的模型已经驻留、可绘制对象已经存在、它已经进入世界。那些「真的把模型加载进来、真的注册进渲染」的工作,是将来接 UE 表现层(Actor/Component/Chaos bridge)时才做的——现在这层只负责把「语义状态机」确定下来。
最后划清一条容易混淆的边界:dummy 不是 LOD。 本章解决的是「对象以什么形式存在」(完整实例还是轻量代理,是表示层的问题);第五章解决的是「这一帧给它多少计算预算」(算全套、按时间片算、还是几乎不算,是成本调度层的问题)。二者相关——降低预算的手段之一正是切换表示——但不是同一个问题:一个 real 对象也可以被降频更新,一个 dummy 也依然占着自己的相位登记。读到第五章时请带着这条区分。
三、人口的生成:控制点、钥匙孔、有界周期
这一章要解决的问题:世界一侧,谁来决定此刻街上该生成多少人、新的人生成在哪、怎么保证他们不在玩家近处凭空出现?
前两章立了「对象怎么被托管调度」和「对象怎么在两种重量间切换」。从这一章起,进入真正的「人口」——街上到底该有多少人、他们从何处出现。
① 原版机制
原版把这件事交给一个世界级的人口管理器(下称 PedPopulation),它每帧跑一次 Process(),但它自己完全不执行任何一个 NPC 的 AI。它是一个「围绕玩家当前位置的空间人口策略器」。它的 Process() 被切成四个大阶段,顺序很讲究。
阶段一:帧门控与控制点建立。 函数一开头是若干早退条件:玩家还没定位就返回、游戏暂停就返回、人口被禁用就返回、回放模式下只更新复用池就返回。它还维护一个「连续淡出帧数」的计数,用来处理开场/切换时的特殊逻辑。这批门控在真正开始生成之前,把各种不该生成的情况全部拦截——这是「先想清楚什么时候不该做,再做」的典型。
门控过了,它算出「人口控制点」(下称 PopCtrl):这是一个纯数据载体,装着当前的人口中心、转换中心、朝向、视场角(含正切半视场角)、室内类别(室外/浅室内/深室内)、中心速度、是否在车里。这个控制点从哪来?中心和速度来自「焦点实体管理器」(通常是玩家),朝向和视场来自当前主导的渲染摄像机或 gameplay 摄像机。注意它区分了「人口中心」和「转换中心」——脚本可以用一个独立的转换中心(比如过场里摄像机飞到别处,但人口还该围着玩家生成)。
阶段二:范围与密度计算。 有了控制点,它推导这一帧的生成/删除策略。输入因素众多:开场模式还是正常模式、场景扩展范围策略、玩家/控制点速度、全局 LOD 缩放、过场钳制、室内深度、玩家载具类型(尤其飞机要拉大范围)、脚本每帧下发的乘数。从这些算出:视野内生成范围、视野外生成范围、额外视锥生成范围、删除范围缩放、删除速率缩放、是否要跑视锥内测试、控制点是否在室内。结果存进一份「当帧计算参数」,并构造一个生成用的钥匙孔形状(keyhole shape)。
这个钥匙孔形状是原版最值得单独讲、也最少人知道的一块。新手会以为「在玩家周围随机撒点生成」即可,但那样会在玩家近处凭空生成 NPC,穿帮。钥匙孔的做法是把生成候选区做成一个带朝向、镂空的复杂形状:它有中心、归一化的 2D 朝向、余弦半角、内外环半径、侧壁厚度。它提供两个核心判定——CategorisePoint(一个点属于哪个类别)和 CategoriseLink(一段路网连线属于哪个类别)。类别包括:视野内可用、内环外(近处,视野外可近生成)、外环、以及一个特别的「仅当被遮挡时可用」类别。语义就是:正前方视野内只在中远处生成(近处玩家看得清,不能凭空出现)、视野外可以离得近生成(反正看不见)、被遮挡的点降级为「除非它被挡住,否则不用」。
极其精细的一点:路人和车辆各自持有一份自己的钥匙孔。路人的钥匙孔从当前路人生成范围 + 摄像机视场初始化,然后把一份拷贝交给后台寻路线程去枚举导航网格候选多边形——寻路线程用钥匙孔的包围盒界定迭代范围、用 CategorisePoint 判定每个候选多边形能不能生成。车辆的钥匙孔从车辆生成范围 + 摄像机朝向 + 前瞻速度 + 侧壁设置初始化,缓存路网连线的类别,用于选择激活哪些生成连线。寻路后台线程只是这份几何的消费者,不是另一个所有者——这条所有权边界很干净。

阶段三:剔除与复用,先于生成。 注意顺序——先删后加。在生成任何新人之前,先按可见性重要度和距离标记/删除、按删除优先级删除、(如果开启)按警察偏好数量删除、清理多余死尸、刷新复用池。删除压力先建立、回收簿记先刷新,然后才轮到决定要不要生成。这个顺序不是随意的:先释放空间与预算,生成才有意义。
阶段四:有界的、按周期的生成。 生成不是「填满为止」的循环。它把参数推给后台寻路的路人生成任务、消费已经算好的候选坐标、处理野生动物、播种每周期的创建/销毁计数,然后进入一个由时间片驱动的人口周期循环。每个周期里按固定顺序做:处理删除队列(如果这周期还没销毁过谁)→ 在场景点生成 → 生成环境路人 → 一轮轮询式的排期生成(车内排期人、排期场景人、排期环境人)。两条关键控制策略在这里可见:生成量在正常路径下是有上界的,以及创建溢出会跨周期携带——防止某一帧的过量生成击穿帧预算。
还有一个专门的「即时填充」快路径(ProcessInstantFill),用于开场或强制铺满:它反复强推寻路做生成、刷新候选坐标、处理野生动物、在每帧尝试上限内反复添加环境路人。它比正常的有界周期循环激进得多——原版把「开场铺满」当成一个独立的人口模式,而不是「正常生成但更快」。
即使到函数尾部,它仍在执行世界侧的维护工作:更新性能计数、重算「是否超过环境路人上限」、更新车内环境例外计数、更新亲兵生成、重置临时人口控制覆盖、衰减即时填充状态、更新多人可见性失败计时、后台重初始化路人动画目录、把脚本人口球体内的对象强制变 real。从头到尾,它都是人口系统的所有者,而不是某个 NPC 的 AI 执行器。
还有一条容易被忽略的设计:生成预算和删除预算是成对的。 「还该再生成多少个场景点 NPC / 环境 NPC」这类函数不是生成器,而是预算闸门——它考虑当前人数、目标人数、内存预算倍率、人口区域倍率、室内与开场状态,算出「剩余允许产出多少」。这解释了为什么生成和剔除必须放在一起看:产出被同一套世界侧预算模型限定,而这套模型稍后也决定谁被删除、谁进复用池(下一章展开剔除侧)。

这一章的意义:人口密度不是一个「每个 NPC 自己决定要不要在」的属性,它是世界一侧对全局预算的持续调节。而「在哪生成才不穿帮」这件看似琐碎的事,被原版编码成了一段可复用、可被后台线程消费的钥匙孔几何——这是把「体验约束(别在玩家近处凭空生成)」变成「可计算的几何谓词」的巧妙做法。一句话总结这一章:生成从来不是「随机创建对象」,而是在空间约束(钥匙孔)、导航候选(后台线程枚举的合法落点)与预算闸门三重限定下的选择。
② Mass 方案的适配边界
Mass 的 MassSpawner 会让人产生第一反应:「这不正是海量实体生成吗,正好适用」。但把原版这套人口逻辑往上一放,就会发现方向不对:
- 带朝向的「钥匙孔」防穿帮生成——Mass 提供的是区域体 / 密度体 / 环境查询,但「正前方中远、视野外可近、遮挡点降级、路人和车各一份钥匙孔、把拷贝交给寻路线程」这套语义,框架并不负责,全部需要自行实现。而这还不是最关键的问题。
- 分层的剔除与复用不是「批处理」形状——原版那套「硬池压力 / 可见性重要度 / 删除优先级 / 非删除区 / 场景点归属 / 室内特例」是逐个对象、带大量分支的裁决。ECS 的 Processor 一旦写满这种 per-entity 分支,它赖以见长的连续内存批处理就形同虚设了——你付出了 ECS 的架构代价,却得不到它的性能红利。
- 成对的、跨帧携带的生成/删除预算是有状态的世界侧策略——它天然属于一个「世界一侧的所有者」,而不是「对每个实体跑一遍的 Processor」。强行放进 Mass,要么用一个全局 Subsystem 绕过 ECS(那 Mass 在这里就只剩一个生成器外壳),要么把预算状态散进 Fragment(那更难维护)。
- 「有界周期 + 溢出跨帧携带」这种节流本身,也是一个有状态的、跨帧的世界侧循环,和 Processor 的「无状态、每帧扫全量」模型不是一路。
一句话:Mass 是为「多、同质、可并行」优化的;人口系统的核心难点是「异质、有所有权、逐个裁决、有状态节流」——两者的形状不匹配。 用 Mass 你会一直在「迁就框架」和「绕过框架」之间反复权衡。这不是不必要的重复建设,而是为了不因省下一个生成器、却承担一整套错配的架构。
③ 原型状态与规划
先说清楚状态:人口的空间策略在原型里目前基本是缺口——它排在实体基础层、物理、Ped/Vehicle/Object 骨架之后。 但基础层已经为它准备好了几处关键的接入点,而且规划是清楚的。
已经就绪的基础层支撑:上一章讲的「延迟删除 + 相位安全点」,天然适合把人口的「先删后加」执行序落地——一个「剔除 Pass」和一个「生成 Pass」,靠世界运行时的相位顺序排定先后,中途的增删都走 pending 队列、帧尾统一 flush,不会落入遍历中途销毁的陷阱。这意味着人口层不用自己发明一套增删安全机制,直接建立在基础层之上。
规划中的落法:
- 人口控制点会做成世界运行时里的一块(对应 PopCtrl):每帧算一次玩家/摄像机的位置、朝向、速度、室内状态,产出当帧参数,供所有生成/剔除逻辑消费。它是一个纯数据载体 + 一个每帧刷新点,正好落在世界运行时的某个相位里。
- 钥匙孔几何会作为一个独立的值类型自建,直接照搬原版语义:中心、归一化朝向、余弦半角、内外环半径、侧壁厚度,提供
CategorisePoint/CategoriseLink。这是原版最值得原样复刻的一块——它是纯几何、无外部依赖、极易单测。路人和车辆各持一份的拆分也照搬。 - 有界周期生成 + 成对预算闸门会做成人口层的策略函数,跑在世界运行时的生成相位里,预算状态作为世界侧的一块共享状态(不散进各实体)。
诚实边界(两处):其一,上面这些目前都还没写,是路线图条目 + 设计方向,不是已实现代码——这一章的③是本篇里「已实现」占比最低的之一。其二是盲区:钥匙孔生成依赖后台线程枚举导航网格候选(「这个点站得下人吗」)。原型侧对应 UE 的 Navigation System 查询,但导航网格构建算法本身没有逐行读过,这里只讲到「候选点要过可站立性检查」这个接入点,构建内部当盲区处理。
四、人口的剔除、复用与预算
这一章要解决的问题:谁能被删?删了是直接销毁还是留着复用?这一帧到底还允许产出多少新人?
上一章讲了「先删后加」里的「加」。这一章补上「删」——它和「加」是同一个人口所有者的两半,不是附带的清理工作。之所以单独成章,是因为剔除的复杂度一点不比生成低,而且它是「预算经济学」真正落地的地方。
① 原版机制
原版把大规模的路人剔除、剔除后的复用、删除队列、以及生成预算,全都放在同一个人口所有者里——和生成路径并列。这不是偶然:删除是生成的「平衡的另一半」,同一套预算模型既决定产出、也决定回收。
剔除是分层的,不是一个距离测试。 它至少有这么几层,逐层判:
- 硬池压力清理:当路人池容量耗尽,强制清理——这是最后一道保护性兜底。
- 可见性重要度 + 距离剔除:按「这个路人对当前画面有多重要」和「离得多远」综合判,标记或删除。
- 删除优先级剔除:当删除需求高时,按优先级删。
- 非删除区与面向玩家的约束:某些区域里的路人不能删,正对着玩家的不能随意删。
- 室内与场景点用户的特例:室内的、正在用某个场景点的路人,有特殊待遇。
而剔除范围本身是算出来的,不是常数。原版有专门的函数算「当前的剔除几何」,输入包括:视野内剔除范围、视野外剔除范围、室内感知、玩家可见性和视锥规则、非删除门控、路人状态、场景点参与情况。这说明剔除层是一套持久的世界运行时策略,而不是散落各处的临时清理。
删除不等于销毁——有复用池。 路人可以通过「排队删除」或「进复用池」离开活跃模拟,而不只是被立刻销毁。有专门的函数处理删除队列、更新复用池、把路人加进复用池。这对所有权有明确含义:
- 任务树不拥有最终的世界删除。
- 世界层拥有通用实体的增删注册。
- 人口所有者拥有「一个路人是否符合删除/复用条件、何时执行」这个人口专属的决定。
也就是说,一个 NPC 的任务可以把它标记成「值得保留、勿删」或「可删」,但广义的人口预算强制权始终在人口所有者手里——actor 侧只能提供输入,最终裁量在世界侧。
预算是成对的、有内存意识的。 上一章提到的「还该再生成多少」预算闸门,和这里的剔除是一体两面。它们考虑当前人数、场景/环境目标、内存预算倍率、人口区域倍率、室内与开场状态。内存预算倍率这个输入特别关键——它意味着人口目标不是一个固定数字,而是随可用内存动态缩放的。海量对象系统的「多少算多」,本身就是个变量。
一句话收束:剔除不是「生成的收尾」,它是人口经济学里和生成对等的另一半。把「谁能删、删还是复用、这帧还能产出多少」全部收归同一个世界侧所有者,并让它随内存动态缩放——这才是「对象随时在变,但帧预算和内存预算始终守得住」的真正原因。

② Mass 方案的适配边界
这一章其实把第三章②的论点又加强了一层。Mass LOD 确实能做「远处实体去激活」,看似剔除。但原版的剔除根本不是「去激活」那么简单:
- 它是分层的、带优先级的裁决(硬池压力 / 可见性重要度 / 删除优先级 / 非删除区 / 室内特例),每层都是逐个对象的分支逻辑——又一次落在 Processor 批处理的短板上。
- 它区分删除 vs 复用,这需要一个有状态的复用池,和「谁拥有最终删除权」的清晰所有权——ECS 里实体的销毁/回收是框架管的,你要在它之上再叠一层「先不真正删除、先进入复用池」,是与框架的设计方向相逆。
- 它的预算随内存动态缩放,是一个跨帧的、世界侧的有状态策略——不是「每帧对每个实体跑一遍」能表达的。
- 最根本的:从原版的代码结构可以直接读出「任务可以提意见(勿删/可删),但最终裁量在世界侧人口所有者」——删除意愿是任务侧设置的标志位,执行删除的调用点全在人口管理器一侧。这条「输入与裁量分离」的所有权契约,在 ECS 的扁平模型里没有自然的落点——谁是那个「世界侧裁量者」?一个全局 Subsystem?那它又绕过了 ECS。
结论还是那句:核心难点是异质、有所有权、逐个裁决、有状态节流——Mass 的强项在这儿全用不上,弱点全部命中。
③ 原型状态与规划
状态和上一章类似:剔除策略本身还没写(属于人口层,未开始),但基础层和所有权契约的落点已经清楚。
已经就绪的基础层支撑:
- 「删除先入队、帧尾统一销毁」是基础层已实现的能力(第一章)——剔除层不用自己造删除安全机制。
- 复用池在概念上和基础层的「retired entity(退役实体保留到下一帧或关机)」机制同源:原型已经有「实体被移除后先退役、句柄不复用、必要时显式 flush」的语义。把它扩展成「删除的路人先进复用池、下次生成优先从池取」,是顺着基础层自然延伸出来的,而非另起一套。
规划中的落法与所有权契约:这一层最该从原版搬的不是某段代码,而是那条「输入与裁量分离」的所有权契约——NPC 的任务可以往它的状态里写「我现在有意思/可删」,但是否真的删,由世界侧人口所有者按预算裁量。原型的实体已经能承载这类「actor 侧输入」的状态字段,人口所有者将来作为世界侧的一块,读这些输入 + 全局预算,做最终裁量。这条契约一旦确定,「任务与人口的裁量权归属」就不会冲突。
诚实边界:分层剔除的具体判定、复用池的具体策略、随内存缩放的预算闸门,原型里都还没实现,是路线图的人口层条目。这一章③给的是「基础层已经为剔除预留了删除安全点和退役/复用语义,所有权契约的落点也清楚」这个事实,加上明确的规划方向——不是已完成代码。
五、行为级 LOD 与代理切换
这一章要解决的问题:同样是「一辆有司机的车」或「一个路人」,凭什么离玩家 20 米的要算全套物理和 AI,离玩家 300 米的却几乎不占 CPU?这个降级是谁决定的、怎么做到无缝、怎么不抖?
第二章讲了「一个对象怎么在完整实例和代理两种表示间切换」,那是表示层。这一章讲成本调度层:谁来每帧决定「这一大群对象里,哪些该升档、哪些该降档」,以及这个决策怎么才能稳。两者是配合的——表示层提供「怎么变」,调度层提供「谁该变、这帧变几个」。
① 原版机制
原版这里有一个极其精妙、也极其反直觉的拆分,最典型的体现在车辆一侧(人一侧同理),值得完整讲。它把「一辆车」拆成三个所有权层次:
- 人口所有者(VehiclePopulation)——只管这辆车的生命周期:生成、删除、复用、紧急车辆(警车救护车)生成。它不管这辆车这一帧算多细。
- LOD 管理器(VehicleAILodManager)——全局唯一,是「这辆车这一帧算多细」的真正决策者。
- 每车 LOD 状态(VehicleAILod)——只是存储:当前 LOD 标志、被阻挡的标志、被强制的标志、这一帧有没有被时间片更新到。头文件甚至明确写了:只有管理器能改这些标志,管理器是它的友元。
LOD 管理器的每帧 Update() 最能说明这套设计的分工。它:选人口控制中心 → 重算「最坏时间片时间步」→ 更新 LOD 距离和范围缩放 → 按有效距离给所有车重排优先级 → 分批遍历(不是一帧全处理完)→ 用滞后(hysteresis)和 N-LOD 预算决定每辆车的目标细节档 → 执行代理化转换(TryToMakeIntoDummy / TryToMakeFromDummy)→ 开关时间片更新 → 管理「假乘客」的真假转换 → 刷新全局的时间片车辆计数和下次更新窗口。
三个档位:
- real(完整实例)——完整物理、完整 AI、真实驾驶循环。
- dummy(代理)——不跑完整刚体物理,沿路网做简化运动。
- superdummy(更轻的代理)——连简化运动都进一步压缩。
(一辆 dummy 车具体怎么「沿路网做简化运动」——运动学预览的推进公式——在车辆篇的下篇有真实代码可对照,这里只讲调度语义。)
几个特别值得学的点:
滞后(hysteresis)。 升档和降档用的不是同一个阈值——中间留一段死区。为什么?因为一个对象若在单一阈值附近往复移动,会在两档之间频繁切换,视觉上抖动、性能上更糟(每次切换本身有开销)。滞后是把「切档」这件事做稳的必需品,不是可选优化。
分批 + 时间片。 LOD 管理器不在一帧里处理完所有车,而是分批、并维护「最坏时间片时间步」。远处的车并不需要每帧更新,按时间片轮流更新——「时间片」就是「这一帧轮到哪批远处对象更新」。这是「按有效距离排优先级、近的多更新、远的少更新」的落地。
假乘客(pretend occupant)。 远处的车里不需要真的有一个跑着完整 AI 的司机——成本过高。LOD 管理器维护一个预算:「这一帧允许多少辆车的乘客是『真人』」,超预算的车,乘客是「假的」(一个不占 AI 的占位);玩家靠近、进了预算,再把假乘客转成真人 NPC。这个转换预算的所有者是 LOD 管理器,而不是人口所有者——因为它管的是「这些乘客被允许有多贵」这件运行时的事,不是「这辆车什么时候生成」这件生命周期的事。这个所有权切分特别干净:population 拥有生成与生命周期,LOD 管理器拥有「这些占用物被允许有多贵」的运行时预算。
人一侧是同构的:每个 NPC 每帧先由 AI-LOD 管理器算出一个 fullUpdate 布尔——这一帧要不要跑完整更新。有个极不易察觉的细节:外层维护的 fullUpdate 和喂给智能/任务步的 fullUpdate 可以不是同一个值——一个可调项能让外层按完整帧处理,而智能步仍拿到原始的 AI-LOD 决策。也就是说,「这个对象整体算多细」和「它的 AI 算多细」是两个可以分别调节的参数。这个细节在第六章会再出现(ProcessIntelligence 的 bFullUpdate 参数正是它)。
这一章的意义:海量对象之所以不爆,不是因为「优化得好」,而是因为「算多少」被显式地编码进了对象自己的状态,并由一个持有全局预算、按距离排序、带滞后切换的管理器统一裁决。分级、滞后、预算、时间片,缺一个都会抖动或预算失守。

② Mass 方案的适配边界
这一层最容易被 Mass LOD 迷惑:它现成提供「距离分桶 + 时间片降频」,看似正是原版所需。但它提供的只是「多久算一次」这一个维度,而原版的代理切换是「算成什么」——一个带状态机的语义转换,这两件事相去甚远:
- 三档语义(real/dummy/superdummy)各自跑什么、切换怎么无缝——Mass LOD 只告诉你「这个 LOD 桶该降频到多少」,但「real 跑完整物理、dummy 沿路网简化运动、superdummy 再压」这套每档不同的行为、以及档间无缝切换时的状态迁移,框架完全不管。
- 滞后(hysteresis)——不加滞后,对象会在阈值附近频繁往复切档,视觉抖动、性能更差。这是转换状态机的一部分,不是「分桶」能表达的。你可以在 Mass LOD 的桶边界上手动加迟滞,但那已经是在框架外面补状态机了。
- 「假乘客」这种按预算把子对象在真/假之间转换的机制——它是「一个实体的子对象(乘客)按全局预算在两种表示间转换」,这在 ECS 的「实体是扁平数据行」模型里尤其棘手:乘客到底是父实体的 Fragment,还是独立实体?两种做法都会与 Mass 的批处理模型冲突(前者结构变更昂贵,后者身份映射框架不管)。
- 两个可分别调节的旋钮(对象整体 LOD ≠ AI LOD)——Mass LOD 一个桶往往驱动一切,要拆成两级得绕开它自己设计。
Mass LOD 解决的是「降频」,代理切换要的是「变形」——用降频框架去做变形,等于一直在框架外面补状态机。 所以 VirtualWorld 原型把「档位、切换、预算」直接做成实体自己的状态和世界侧策略,正好落在它「世界托管 + 相位调度」的基础层上,转换协议是一等公民而不是补丁。
③ 原型状态与规划
这一层的「表示切换」骨架已经在第二章讲的 FVWObject 转换协议里落地了——EVWObjectRepresentationState + FVWObjectConversionPolicy 那套「请求—策略—决议」,正是「怎么变形」的一等实现。这一章③要补的是「谁来调度这些变形」,而那部分还未开始。分开说。
已经落地的(变形的机制):一个对象怎么从 dummy 变 real、变的时候要过哪些门控、每个「不能变」的理由是什么——这些第二章已经讲透,是真实、可单测的代码。它对应原版的 TryToMakeFromDummy / TryToMakeIntoDummy 背后那套「转换裁决」,而且原型把裁决门控做得比一个裸转换函数完善得多(二十多个精确的拒绝理由)。
已经预留的挂点(降档的物理落点):第一章讲的 EVWEntityUpdateFlags + 相位桶,是「行为级 LOD」落到实处的物理挂点。所谓「把一个对象降档」,很大一部分就是「把它从某些相位桶里移出」——降到 dummy 的车不再登记进完整物理相位、降档的 NPC 不再每帧跑控制相位。这个「按相位选择性登记」的能力基础层已经具备,LOD 调度器将来只需要去操作这些相位标志。
还未开始的(LOD 调度器与预算):那个「每帧选控制中心、按有效距离重排、分批遍历、用滞后和 N-LOD 预算决定每辆车目标档、管理时间片窗口」的全局 LOD 管理器,以及假乘客预算,目前是路线图 Vehicle / Object Population 层的条目,未实现。也就是说,原型现在有「怎么变形」和「怎么把降档落到相位桶」,但还没有那个「每帧决定谁该升谁该降、这帧预算几个」的决策核心。
一个明确取舍:车辆物理这一档,原型规划是不自己造刚体代理,用引擎的车辆物理 + 物理 LOD。原版自建简化运动,是因为它那个年代没有现成的车辆物理 LOD;今天已有,重造并不经济。原型这块要搬的是「三档 + 滞后 + 预算 + 时间片」的调度语义,不是原版的物理实现——搬语义,不搬实现。这也再次说明「不用 Mass」不等于「什么都自己写」:与人口语义无关的现成组件,该复用就复用。
六、感知—决策—任务流水线:到「产生任务槽」为止
这一章要解决的问题:单个 NPC 怎么做到「像活的」——它怎么知道周围发生了什么、怎么决定该不该反应、这个决定又怎么落成一个待执行的任务?而这一切,怎么在「合法地不反应」时也能被表达?
前面五章都在讲「一大群对象怎么被托管、切换、生成、剔除、调度」——那是群体视角。这一章切到个体视角:一个具体的 NPC,它内部那条流水线长什么样。这是「像活的」的真机理,也是原型里结构骨架最完整的一层。本章讲到「事件被裁决、变成一个挂在任务槽上的待执行任务」为止——任务被执行成走/逃/环境行为,是下篇的主题。
① 原版机制
当世界的场景更新扫到一个活着的 NPC,它的 ProcessControl() 会跑一层层的每帧包装:动画状态同步 → 外层完整更新维护 → 数据/重置/AI 前的库存 → 智能(intelligence) → AI 后库存 → 动画/图形/物理收尾。核心那一步是智能,它才是「像活的」的发生地。
智能这一步是一条清晰的流水线,顺序是:
- 维护附近实体列表——「我周围有谁」。
- 变量与状态更新。
- 扫描事件(scan for events)——一个专门的扫描器在这里跑,它是纯生产者:扫描附近/世界/其它 NPC 的状况,产出并入队「事件」对象(听到枪声、看到尸体、车要撞过来了……)。它只生产事件,不决定怎么反应。
- 决策器、目标选择、掩体、封闭区侧更新。
- 处理事件(handle events)——事件处理器消费队列,做裁决。
- 执行任务(process tasks)——任务树在同一帧的这一步真正推进。
- 任务后状态更新。
这条链的关键在于职责被切得非常干净:扫描器只负责「发现并产出事件」(生产层);智能流水线是真正的网关(消费扫描、把事件交给处理器、然后跑任务);响应工厂在更下游,只负责「把某种事件响应类型映射成一个具体的任务实例」(构造层)。完整的因果链是:扫描器产出事件 → 智能流水线处理该帧、把事件交给处理器 → 响应工厂把选中的响应路由成具体任务 → 任务树在同一次智能流水线里执行。
任务本身也不是一棵树,而是几棵并行的树:主任务、次任务、移动、动作——一个 NPC 可以同时「走路(移动)+ 打电话(次任务)+ 被更高优先级事件打断主任务」。这几棵并行的树,是「一个人能同时做几件不冲突的事、又能被高优先级事件打断」的结构基础。
这里有一个非常反直觉、但对「像活的」至关重要的设计:一个合法的事件响应,可以故意什么都不做(no-op)。 决策不是「收到事件必须反应」,而是经过决策器加权、按 NPC 的性格/关系组/警觉度裁决出的结果——它完全可以判定「这个路人对这声枪响的合理反应就是继续走」。正是这种「合法地不反应」,让人群看起来有差异、有性格,而不是所有人对同一刺激整齐划一地做同一个动作。
一句话收束:「像活的」不来自更复杂的单个行为,而来自一条把「感知」「裁决」「执行」彻底分层、并允许裁决层合法地压制反应的流水线。谁生产、谁裁决、谁执行分清楚,人群的「个性」才有生长的空间。

② Mass 方案的适配边界
这一层尤其能说明「为什么 Mass 不适合」。Mass 的定位就是同质、可并行的批处理——而「像活的」的全部来源恰恰是异质和因果:每个 NPC 有自己的性格权重、自己那几棵并行任务树、自己「这个事件我合法地不反应」的裁决。这种「逐个对象、带分支、有状态因果链」的逻辑,正是 ECS Processor 最不擅长的形状。
那用 UE 的感知组件 + 状态树/行为树呢?它们把「感知」和「执行」两端覆盖得不错,但中间那层——「事件先入队、再由一个加权决策器统一裁决、且允许合法 no-op」——现成栈里没有。感知组件通常直接把刺激传给行为逻辑,缺一个「按性格/关系/警觉度加权、可以裁决为不反应」的仲裁层;少了它,人群很容易变成「同一刺激、同一反应」的整齐划一。此外,「先维护感知、再裁决、再执行任务」的固定帧序、以及「几棵并行任务树」的结构,现成方案也不会替你确立规则。这条流水线的价值全在它的结构和所有权边界上,而不在某个可复用的算法块上——所以它天生适合自建,不适合套框架。
③ 原型状态与规划
这条流水线是原型里结构骨架最完整、且已有真实函数体的一层。 FVWPed 把整条链的状态和步骤都建出来了,且 VWPed.cpp 里有真实的函数体(不是纯声明)。逐段对应,并落到真代码:
几棵并行任务树。 EVWPedTaskTree { Primary, Secondary, Movement, Motion },直接对应原版的主/次/移动/动作。每个任务槽 FVWPedTaskSlotState 带树、优先级、任务类型、生命周期状态(Pending / Active / Completed / Aborted),并且记着它是被哪个事件触发的——槽里有 SourceEventType / SourceEventPriority / SourceEventHandle / SourceEventTag。这条「任务知道自己从哪个事件来」的溯源,正是原版那条因果链的落地。
事件是候选、不是命令。 FVWPedEventState 是「事件候选」列表(扫描器的产出物),AddIntelligenceEvent(...) 往里加,FindHighestPriorityEvent() 挑最高优先级——对应「扫描器生产、按优先级裁决」。事件进来是「候选」,不是「命令」,这个措辞差异就是原版「事件不等于必须反应」的落地。
事件→任务的路由是显式一层。 FVWPedEventTaskResponseState 是「某类事件应该映射成哪棵树、什么优先级、什么任务」的响应表,ProcessEventTaskBridge() 是那座桥——对应原版的响应工厂。读这座桥的真实实现,能看到它做得很细:它先按当前事件类型查响应表;查到后,先做去重——如果目标槽已经是完全一样的任务(同类型、同来源、同脚本参数),就跳过不重复应用;如果目标槽已经是 Active 且在跑,也跳过(不打断正在跑的同位任务);只有真正需要时,才把响应写成一个 Pending 的任务槽,并把当前事件的来源信息(类型/优先级/句柄/标签)写入这个新槽——这就是上面说的「任务知道自己从哪个事件来」的落地写入点。它还把「这帧桥处理了几次、实际应用了几次」记进计数器(EventTaskBridgeProcessCount / EventTaskBridgeAppliedCount),方便验证「事件真的变成了任务、且没重复变」。
整条流水线的入口是 ProcessIntelligence(Context, bFullUpdate)。 注意那个 bFullUpdate 参数——正是第五章讲的 AI-LOD 旋钮:它接受一个「这帧要不要完整更新」的外部决策。读它的实现:它累加「距上次 AI 更新的时间」、递增智能处理计数;只有在 bFullUpdate 时才刷新「当前事件」(从候选里挑)、才跑事件→任务桥;然后推进任务处理。也就是说,非完整更新的帧,这个 NPC 的智能被有意地「跑得浅」——这正是行为级 LOD 在个体流水线上的体现。
最能体现原版纪律的一点——合法 no-op——原型做进去了,而且是双重的。 FVWPedState(准确说是它的 Intelligence)里有 bTasksSuppressed 和 SetTasksSuppressed(...)。读 ProcessIntelligence 的实现,这个开关同时门控两处:事件→任务桥(bFullUpdate && !bTasksSuppressed 才跑桥)和任务处理计数(!bTasksSuppressed 才推进)。也就是说,抑制状态下,事件照收、候选照排(感知不停),但「把事件变成任务」和「推进任务」都被显式跳过。这就是「裁决层可以合法地压制反应」的那个开关——它不是一个「出错了就跳过」的补丁,而是一等的、可查询的状态,且它的门控点在代码里明确可查。
诚实边界:这些目前都是骨架级语义控制——头文件里反复标注「skeleton-level semantic controls only; they do not execute subtasks, movement, animation, or vehicle entry」。也就是说,「任务槽」「事件候选」「响应路由」「抑制开关」这些状态和迁移都真实存在、可单测、有函数体,但底下真正的行为(走路、逃跑、播动画)还没接——ProcessIntelligence 目前推进的是计数器和状态,不是动画和移动。这恰恰是刻意的顺序:先把「谁生产、谁裁决、谁执行、谁能合法不做」的骨架和所有权边界先确定,再往每个槽里填行为——避免行为逻辑过早膨胀而侵蚀架构边界。这也正是原型的一贯节奏:正确性和边界先确定,具体行为后填。「往槽里填行为」那一半,正是下篇的主题。
盲区标注:任务树内部的优先级竞争与并发调度算法(多棵树之间怎么仲裁、同一棵树里高低优先级怎么抢占),原版我只读到结构层,没逐行读它的调度内核;原型这一层也还没实现具体的仲裁规则(目前是「按树+优先级定位一个槽」的简单模型)。这里只讲到「响应被路由成任务槽、槽有优先级和生命周期」,树内核当盲区。
七、迁移的取舍:行人篇(上)的落地清单
把六章合起来,给一张对照表。三档判据是对这个原型诚实的口径:①已落地(原型里实现且有测试/有函数体)、②已立骨架(类型/状态/迁移建好,行为还空)、③规划中/未开始(路线图有条目,代码还没写)。
| 层 | 关键机制 | 原型状态 | 落点 |
|---|---|---|---|
| 世界基础层 | 世界托管实体、句柄 | ① 已落地 | FVWWorldRuntime + FVWEntityHandle |
| 世界基础层 | 相位调度(非每对象 Tick) | ① 已落地 | RunControlPhase / RunPrePhysicsPhase / … |
| 世界基础层 | 延迟删除、相位安全点 | ① 已落地 | PendingRemoval + ProcessingDepth + FlushPendingRemovals |
| 世界基础层 | 相位中途增删的安全处理 | ① 已落地 | PendingAddEntities + 命令缓冲 |
| 世界基础层 | 按相位选择性登记 | ① 已落地 | EVWEntityUpdateFlags + 相位桶 |
| 世界基础层 | 返回 false = 移出相位≠销毁 | ① 已落地 | ProcessControl 语义(有专门测试) |
| 实体表示 | real/dummy 表示状态 | ② 已立骨架 | EVWObjectRepresentationState |
| 实体表示 | 转换的「请求—策略—决议」协议 | ② 已立骨架 | FVWObjectConversionPolicy(拒绝理由逐条枚举) |
| 实体表示 | 裁决与调度分离 | ② 已立骨架 | 策略「不扫描/不流式/不创建」 |
| 人口生成 | 人口控制点、当帧参数 | ③ 未开始 | 规划为世界运行时的一块 |
| 人口生成 | 钥匙孔防穿帮生成几何 | ③ 未开始 | 规划自建值类型,照搬原版语义 |
| 人口生成 | 有界周期 + 溢出跨帧携带 | ③ 未开始 | 规划为人口层生成相位 |
| 人口剔除 | 分层删除优先级 | ③ 未开始 | 基础层(延迟删除)已就绪 |
| 人口剔除 | 复用池 | ③ 未开始 | 基础层(退役实体语义)同源 |
| 人口剔除 | 输入与裁量分离的所有权契约 | ③ 未开始 | 落点已清楚,未实现 |
| 生成/删除预算 | 成对预算、随内存缩放 | ③ 未开始 | 规划为世界侧共享状态 |
| 代理切换 | 变形机制(怎么变) | ② 已立骨架 | 复用实体表示层的转换协议 |
| 代理切换 | 降档落到相位桶(挂点) | ① 已落地 | EVWEntityUpdateFlags(挂点就绪) |
| 代理切换 | 三档 + 滞后 + 全局预算调度 | ③ 未开始 | 路线图 Vehicle/Object Population 层 |
| 代理切换 | 假乘客真/假转换预算 | ③ 未开始 | 规划为 LOD 调度的一部分 |
| 车辆物理 LOD | 简化运动 | 取舍 | 不自造,规划用引擎车辆物理(详见车辆篇) |
| 感知流水线 | 几棵并行任务树 | ② 已立骨架 | EVWPedTaskTree + 任务槽 |
| 感知流水线 | 任务溯源到触发事件 | ② 已立骨架 | 槽的 SourceEvent* 字段(桥里写入) |
| 感知流水线 | 事件候选 → 响应路由(含去重) | ② 已立骨架 | ProcessEventTaskBridge(有函数体) |
| 感知流水线 | 合法 no-op(抑制反应) | ② 已立骨架 | bTasksSuppressed(双重门控) |
| 感知流水线 | full-update 旋钮 | ② 已立骨架 | ProcessIntelligence(Context, bFullUpdate) |
读这张表的一句话:这个原型目前几乎把全部投入集中在「基础层」上——世界托管、相位调度、延迟删除、相位中途增删安全、按相位登记,这些已进入稳定状态、有 30 条以「世界运行时」命名的测试覆盖;再往上,实体表示和感知流水线立好了骨架(表示状态、转换协议的二十多个拒绝理由、任务树、事件路由的去重与溯源、合法 no-op 的双重门控,都是真实、可单测、有函数体的代码);而人口的空间策略(钥匙孔、预算、剔除)尚未动工。任务的执行侧(走/逃/环境行为、场景系统、动画与 ragdoll)是下篇的主题。
建造顺序与 Mass 的适配边界
这张表其实画出了一条清晰的建造顺序,而这条顺序正是从原版逆向里学到的:
先把基础层确定下来——谁拥有对象、什么时候算、怎么安全增删。再把「表示」和「个体流水线」的骨架搭起来——一个对象怎么在两种重量间切换、一个 NPC 怎么感知—裁决—产生任务。最后才填「一座城市该有多少人、生成在哪、街边站着谁」这些最上层的策略。反过来先做上层策略,基础层一旦动摇,上层就得全部重做。
而这条建造顺序,也解释了为什么在这类语义驱动系统里,基础层不适合直接交给 Mass 这类批处理运行时——注意这里说的是「适用范围」,不是「Mass 不行」。
Mass 是一个面向批处理的「实体运行时」框架。一旦把基础层交给它,就等于把最底下那层——「谁拥有什么、什么时候算、怎么安全增删」——的话语权交给了框架的 Fragment/Processor 模型。而通读这几章会发现,那层正是全篇反复强调、最需要贴着原版语义自行设计的部分:世界托管而非对象自持、语义相位而非批量 Processor、返回 false 移出相位而非销毁、删除先入队帧尾 flush、按相位选择性登记(这还是 LOD 的挂点)……这些都不是「同质数据的批处理」,而是「带语义的、逐个对象的、讲所有权和时序的」调度。
基础层一旦贴合了框架的形状,上面的所有权分层、可打断行为、成本档、因果链就都得迁就它——你会一路在「迁就框架」和「绕过框架」之间反复摇摆,最终写出一个「名义上用了 Mass、实际处处对抗 Mass」的折中产物。在这个问题里,用 Mass 与其说是省下了基础层,不如说是把这层基础外包给了一个为另一类问题设计的框架。 再强调一次:换成同质、可并行的问题(成片植被、无差异远景人群),这个天平会倒向另一边。
心法一句话:这类语义驱动的基础层更适合自建(而不是套用 Mass、或直接用现成 Tick 作为主调度模型),语义骨架从原版搬(「大致相似」不等于等价),只有真正与语义无关的现成组件——车辆刚体物理、世界预置交互点这类纯数据/查询层——才该复用引擎自带的。 分得清哪层该自建、哪层该复用,比一刀切「全自研」或「全用引擎」都重要。
AI 协作复盘
这篇文章有两条材料线:一套成熟开放世界引擎的源码级逆向笔记,和一个正在开发的 UE5 原型工程。把两者对起来讲,本身就是一次人机协作,值得如实记一笔。
AI 帮了什么。 逆向侧,六章的机制散在几百篇笔记里,AI 的价值在于并行地把所有权边界归位——「人口所有者只做世界一侧、不执行 AI」「LOD 管理器是决策者、每车 LOD 只是存储」「扫描器是生产者、智能流水线才是网关」「population 拥有生命周期、LOD 管理器拥有假乘客预算」这些边界,是从大量逐文件笔记里提炼、再对齐成一条「成本即距离、调度即相位、职责即所有权」主线的。原型侧,AI 协助把「头文件里那些枚举和状态字段」对回「原版的哪个机制」——认出 bTasksSuppressed 就是原版「合法 no-op」的落地、FVWObjectConversionPolicy 那串拒绝枚举就是原版转换门控的逐条搬运、任务槽的 SourceEvent* 字段就是那条因果链的落地写入点,并进一步去读 VWPed.cpp / VWObjectConversionPolicy.cpp 确认这些是真有函数体的实现、而不是空声明。
哪里差点出错。 第一版初稿里,AI 把 UE5 落法默认写成了「用 Mass 搭人群」(数据导向实体 + MassSpawner + Mass LOD),还把这些现成设施说成对原版机制的「等价替换」。这条路线被原型的实际选择直接否定了——原型权衡后明确放弃了 Mass,理由就是正文每章讲的那套「形状不匹配」:Mass 为同质可并行优化,人口系统要的是异质、有所有权、逐个裁决、有状态节流。逐条一追(Mass LOD 给了分桶降频,那滞后呢?三档变形呢?假乘客呢?合法 no-op 呢?转换的二十多个拒绝理由呢?),就暴露出「有一个相似设施」和「能承载那套完整语义」根本是两回事。教训是:讲 UE5 落法之前,先去读那个真实的原型工程和它的技术决策,别凭「UE 现在流行用 Mass 做人群」臆断——流行做法和这个项目真实的、有理由的选择,可能正好相反。
盲区怎么处理。 凡是源码笔记没读透的算法黑盒(导航网格构建、寻路内核、任务树调度内部),以及原型里还没写的部分(人口空间策略、剔除),一律显式标注「盲区」或「未开始」,不用「听起来对」的通用描述去填,也绝不把「立了骨架」说成「做完了」。这一篇尤其要守这条——因为它的③段横跨「已落地/已立骨架/未开始」三档,一旦含糊,读者就分不清哪些是真跑过的代码、哪些是设计意图。读到哪讲到哪、做到哪说到哪——这条纪律是这类「逆向 + 在建原型」对照文章可信度的底线。
*这是「如何制作一款开放世界游戏」系列的实战篇。行人主题按层切成上下两篇:本篇(上)讲调度与决策——世界基础层、实体表示、人口生成与剔除、行为级 LOD、感知—决策—任务流水线(到产生任务槽为止);下篇讲行为、表现与物理——任务执行、场景点驱动的环境行为、locomotion 动画与 ragdoll 的定调。原型当前把基础层(世界运行时 + 相位调度 + 增删安全)做到稳定、把实体表示与感知流水线立好骨架,人口空间策略与剔除尚未动工。文中所有类名与源码路径均为长期逆向阅读与在建工程的实地核对结果,发布时按惯例做匿名化处理。*