在 UE5 上重建一座会自己运转的城市:从驱动核、人群 AI 到 LLM 驱动的工程实录

这是开放世界系列的延伸篇。上一篇《如何制作开放世界游戏:一座可叙事的城市》拆的是一款成熟商业开放世界 RPG「在做什么」——它怎么讲故事、怎么让城市看起来活着。这一篇换一个视角:假设我们要在 Unreal Engine 5 上把这套「会自己运转的城市」重新实现一遍,到底要复刻哪些机制、哪些能直接复用引擎内置系统、哪些需自行实现。

全文基于对原作引擎运行时的源码级阅读,以及一套正在进行中的 UE5.8 重建工程的契约与已落地代码。沿用系列的匿名化处理:只谈工程,不点名原作。文中的「原版」一律指被分析的那套原始 C++ 运行时,「重建工程」指 UE5 侧的复刻。

补充说明:本篇更偏向一份开发计划 / 重建设计蓝图——基于源码实证,梳理”要复刻什么、怎么在 UE5 上落地”。具体的实现成果与可运行 Demo,会在后续随工程推进逐步补充。

导言:迁移真正的难点不是「翻译」,是「不把分级状态压成一个布尔值」

受理 ≠ 完成:分级状态脊柱与五个「假完成」信号
图 1 · 受理 ≠ 完成:分级状态脊柱与五个「假完成」信号

把一套自研引擎的城市模拟搬到 UE5,直觉上像是一桩翻译工作:找到对应的 UE 子系统,把数据格式转过去,接口一对一接上。但真正读进原版的源码,会发现这条路上埋着一个反复出现、足以毁掉整次迁移的陷阱——把”受理”当成”完成”

原版里几乎每一个对外 API 都是异步的、分阶段的:

  • 往城里加一个人,调用返回成功,只代表请求进了队列,那个人这一帧根本还不存在;
  • 一次寻路返回 isReady = true不代表找到了路,它可能同时带着”起点还没流送进来””节点池耗尽””只搜出半条”三个状态位;
  • 一个工作点(workspot)发出”播放”命令返回真,只代表命令入了队,动画一帧都还没采样;
  • 交通系统的全局寻路即使失败,返回的也是一个非空的空路径——指针不为 null,但里面什么都没有。

这些不是 bug,是固定帧预算下异步引擎的必然形态。一座城市同屏可能有上千个体、几百辆车,没有任何一个系统能在一帧里把”请求→加载→初始化→挂载→注册→可玩”全部走完。于是每个系统都把工作切成阶段,每个阶段只承诺”我这一层到哪了”,从不替下游打包票。

迁移的头号纪律,因此不是”找到对应的 UE 系统”,而是:在 UE5 侧把这套分级状态原样保留下来,绝不为了接口好看而把它们压成一个布尔值(bool)。这一点在重建工程里被写成了一条不可谈判的合同——所有运行时 API 必须返回结构化的状态,而不是裸的 bool

// 重建工程的共享状态词汇(已落地)
UENUM(BlueprintType)
enum class ECyberRuntimeStatus : uint8
{
    Unknown,        // 没有可信状态
    Requested,      // 请求已提交,但没跨越任何完成边界
    Queued,         // 在某个 tick/job/帧队列上等待
    Loading,        // 资源/数据加载中
    Loaded,         // 原始资源加载完,但未必初始化、未必可玩
    Initialized,    // 运行时对象本地初始化完成
    Attached,       // 已挂到所属 world/scene
    Registered,     // 可被注册表/ID 查到
    Ready,          // 本模块的就绪边界达成
    GameplayReady,  // 跨模块的玩法消费者可以安全依赖了
    Completed,      // 声明范围内的操作完成
    Failed, Cancelled, Aborted, Defaulted, Skipped, Assumed
};

这条枚举不是装饰。它是整篇文章的脊柱:下面每一个系统——人口、NPC 行为、智能对象、寻路、载具——在原版里都是分阶段的,在 UE5 侧也都必须用这套或类似的分级状态承接。我们会反复看到同一个动作:找到一个看起来是”完成”的回调,证明它其实只是某一层完成,然后在 UE5 侧给它单独建一个状态位。

文章先讲驱动核(什么在让整座城市运转),再按”地基(实体)→人群→NPC→驻留→寻路→载具与交通→跨系统就绪门”逐系统拆,最后落到两件更前瞻的事——用 LLM 驱动这座城市,以及迁移取舍。每一个系统章固定三段:

  • ① 原版设计——原版引擎里这套机制到底怎么运转(基于源码级阅读);
  • ② 相对 UE5.8 原生的优势——同样一件事,UE5.8 开箱即用提供什么、原版这套设计的长处在哪、又有哪些地方其实 UE5.8 更优(这一段不予回避,该承认 UE5.8 更优处即承认);
  • ③ 移植到 UE5.8 的方法——具体怎么落地、复用哪个内置系统、自建什么、陷阱在哪。

之所以单独把”②优势对比”列为一段,是因为迁移决策的核心就是这一问:这个机制值不值得照搬?还是 UE5.8 原生的做法已经更好?答案逐系统不同,得一个个掂量。读这篇时可以带两个贯穿性的问题:这座城市的运转到底由什么驱动?(第一章回答)以及如果把这个”驱动”换成 LLM,会怎样?(第九章回答)


一、城市的驱动核:什么在让这座城市运转

城市的驱动核:相位调度 + 数据驱动 + 事件总线 + 预算
图 2 · 城市的驱动核:相位调度 + 数据驱动 + 事件总线 + 预算

前面说”每个系统都分阶段、靠信号”,但还没回答一个更底层的问题:这些系统是被什么驱动的? 一座城市同屏上千个体、几百辆车、几十条任务,它们不是各自起一个线程独立运行,而是挂在一套统一的驱动底座上。这套底座有四个部件:帧相位调度、数据驱动配置、事件总线、预算系统。理解它,才理解整座城市”运转的逻辑”从何而来——也才理解为什么后面每个系统都长成”分阶段、靠信号”的样子。

① 原版设计

帧相位调度(frame-phase scheduling)。 原版不是”每个对象自己 tick”,而是把一帧切成有序的若干相位(phase),每个系统注册到特定相位的特定 job。一个任务调度器用优先级分层的若干通道、依赖计数器、等待列表来编排:job 之间用”依赖计数归零才触发”串起来,跨对象的更新约束(比如人口必须在物理之前刷、插值必须在物理之后)由相位顺序保证,而不是由各对象自己判断。核心纪律是“发起即返回、完成靠信号”——一个 job 派发出去立刻返回,它做完了靠计数器/回调通知下游,没有谁阻塞等谁。

具体到前面那些系统,它们”挂在哪个相位”是被精心编排过的:人口在动画更新相派出池更新 job、在后物理相等它完成;载具分前物理/后物理两相;寻路在相机更新相;交通在帧首派一串并行 job;AI 在桶更新前相分配 LOD。这套编排保证了数据依赖的正确顺序——这就是为什么前面每个系统都是异步分阶段的:它们都活在这套相位调度里,一帧的预算被相位切片分配,谁也没有”独占一帧从头跑到尾”的权力。

数据驱动配置(data-driven config)。 城市的”内容”——每种 NPC 的属性、每种车的参数、每个任务的结构、每种交互的行为——不写死在代码里,而是放在一个百万级的扁平配置库里,支持热重载。运行时系统从库里按 ID 取记录,记录可以在不重编译的情况下改。这套是”内容离开代码、进入可编辑数据”的载体——策划改一个数值、加一个 NPC 类型,不必动程序员。它的纪律前面提过:分级成功(CRC 只保证字节完整、不保证语义有效)、Val<T> 可能绑到默认值、热重载通知不等于消费者重建完成。记住这个库——它是后面 LLM 驱动最重要的接口:LLM 生成的内容,最终落地形式就是往这个库里写记录。

事件总线(event / fact bus)。 系统之间不直接调用,而是通过事实库(FactsDB)生成器事件总线广播状态变化。一个事实(fact)被写入时,同步触发所有监听者的回调;NPC 的生成/死亡/存根创建被广播给订阅者(任务、预防刷怪系统)。事实库就是整座城市的共享黑板——任务系统读它判断条件、写它推进剧情;AI 读它感知世界;交互改变设备状态时写它。这套发布-订阅,是城市各系统”互相影响”却不”互相耦合”的关键:谁也不持有谁,大家都读写同一块事实。

预算系统(budget)。 每一帧的工作量是有限的。智能对象解析有亚毫秒预算、人口 attach 有数量预算、交通全局寻路有 2ms 预算、navmesh tile 插入有 0.5ms 预算。预算化 + 结转 + 分段 time-slice,是这套底座在固定帧时间里优雅退化而非崩溃的根本——预算用完了,剩下的工作排到下一帧,而不是这一帧卡死。

把这四件拼起来,就是”城市运转的驱动逻辑”:相位调度决定谁在何时跑、数据驱动决定跑什么内容、事件总线让它们互相影响、预算系统让一切在固定帧时间里收敛。 每个系统只是这套底座上的一个挂载点。这也是回答标题那个问题的地方——是什么让城市”自己运转”?不是某一个聪明的系统,而是这套底座:它像一台时钟,把每一帧切成格子分给各系统,各系统在格子里读数据、写事实、花预算。

② 相对 UE5.8 原生的优势

UE5.8 的对应物是分散的、且默认更”各自为政”

  • tick: UE 有 tick groups(PrePhysics/DuringPhysics/PostPhysics)和 tick 依赖(AddTickPrerequisite),Mass 有 processor 的执行顺序/相位。这给了相位编排的骨架。但 UE 默认是”每个 actor/component 自己 tick”,原版那套”统一相位调度器 + 依赖计数 + 一帧预算切片”要靠 Mass 的 processor 图 + 自建调度器才能逼近。
  • 数据: UE 有 DataTableDataRegistryGameplayTags。但”百万级扁平值 + 热重载 + 分级成功(found / defaulted / missing / schema-mismatch)”这套语义要在 DataRegistry 之上自建——这正是重建工程 CyberDataRuntime 已落地的部分。
  • 事件: UE 有 delegate、GameplayMessageSubsystem、用 GameplayTag 路由的 message。这接近事实总线,但”同步回调 vs 下一帧投递”的边界、以及事实的持久化(存档),要自己定。
  • 预算: UE 没有统一的”帧预算分配器”。各系统的 time-slice 要自己写。

一句话:UE5.8 给了相位/数据/事件的零件,但”统一驱动底座”这个整体——一帧被一个调度器按预算切给各相位、内容全数据驱动可热重载、系统只通过事实总线互相影响——是要自己组装的架构。

③ 移植到 UE5.8 的方法

驱动底座是地基中的地基,重建工程里它对应几个共享插件:

  • 相位调度: 用 Mass 的 processor 图表达跨系统相位(UMassProcessor::ExecutionOrder),非 Mass 的系统用 UTickableWorldSubsystem + 显式 tick 依赖。可再加一个 UCyberFrameScheduler 统一管”哪个系统在哪个相位、领多少预算”。
  • 数据: CyberDataRuntime(已落地)在 DataRegistry 之上提供 ECyberDataStatus(Found / Defaulted / Missing / TypeMismatch / SchemaMismatch / ReloadPending)的结构化结果,这正是原版”分级成功”的 UE 化。
  • 事件:UGameplayMessageSubsystem 或自建 UCyberFactsSubsystem 做事实总线,并为每条 API 明确”同步回调还是下一帧投递”(原版有同步重入,UE 重建必须显式选其一);事实要可持久化进存档。
  • 预算: 每个 time-sliced 系统(人口解析、智能对象解析、交通寻路)从一个共享的 FCyberFrameBudget 领预算,而非各自估算。
// 重建工程:一帧的预算从共享分配器领取(设计)
struct FCyberFrameBudget
{
    double SmartObjectResolveSeconds = 0.0004;  // 0.4ms
    double TrafficPathfindSeconds    = 0.002;   // 2ms
    double NavTileInsertSeconds      = 0.0005;  // 0.5ms
    double LlmDecisionSeconds        = 0.0;     // 见第九章:LLM 也从这里领预算
    double Acquire(double& Pool, double Want);  // 领预算 + 结转
};

这一章的意义:它是后面所有系统的”为什么能这样写”的答案——分阶段、靠信号、预算化,都是因为它们活在这套驱动底座上。它也是第九章 LLM 驱动的接入点:LLM 不会凭空驱动城市,它驱动的恰恰是这套底座——往数据库写内容、往事实总线写事实、在某个相位里被预算化地调用。 把这一章读懂,第九章就只是”再挂一个生产者上去”。


二、地基:实体生命周期,从来不是一个 Spawned 布尔

实体生命周期的七个独立状态位 + 未挂载池
图 3 · 实体生命周期的七个独立状态位 + 未挂载池

在动人口和 AI 之前,必须先把”一个实体从无到有”这件事拆清楚,因为后面所有系统都依赖它。

① 原版设计

原版里没有”spawn 了没有”这种二元问题。一个实体的诞生跨越至少七个互不等价的阶段:请求提交、进入队列、生成令牌(spawn token)报告成功、对象构造、组件初始化、挂载进世界、注册进可查表,最后才是”玩法系统可以安全依赖它”。

关键在于生成令牌就绪不等于玩法就绪。原版的人口系统拿到 spawn token 的 IsSpawned() 为真之后,实体只是”构造出来了”——它可能还没 attach 到世界、渲染代理还没建、物理还没就位、AI 还没接管、注册表里还查不到。任何一个下游系统如果在 token 就绪的那一刻就去使用这个实体,就会拿到一个半成品。

更微妙的是,原版允许实体先 spawn、后 attach:生成出来但还没挂载的实体先进一个”未挂载池”,等预算允许再 attach。所以”对象存在”和”对象在世界里”是两个独立的状态。这个”未挂载池”本身还有独立的预算——它防止一帧内生成过多实体而耗尽内存预算,即使生成令牌都已成功,attach 仍需排队。于是一个实体可能停在”已构造、已初始化、但还在未挂载池里等 attach 预算”这个中间态好几帧,对外完全不可见、不可交互、注册表里也查不到。

这套分级的代价是调用方必须明确自己依赖的是哪一层。原版里下游系统不会笼统地查询”这个实体就绪了吗”,而是查询”它是否已达 gameplay-ready”或某个明确子集。这看似繁琐,但正是它让”任务在 NPC 尚未就绪时即向其分派对话”这类时序缺陷在编译期/契约层即被拦截,而非延至运行期随机崩溃。

② 相对 UE5.8 原生的优势

UE5.8 开箱提供的实体生命周期,是粗粒度的:AActorBeginPlayHasActorBegunPlay()IsActorInitialized() 这么几个布尔,World Partition 的就绪是 cell 级的”是否加载完成”。这套对”放置若干关卡物件”足够,但对”一座城同屏上千个体、每个都可能正卡在某个中间态”就过于粗糙——无法区分”已构造但尚未挂载””已挂载但注册表尚查不到””已注册但 AI 尚未接管”。

原版这套设计的优势正在于此:它把”对象存在”和”对象可被依赖”彻底拆成了可独立观测的多个状态,还多了一个”未挂载池”的预算层。下游因此能精确地等到”可玩”而非”已 spawn”,从源头杜绝”向一个尚未就绪的 NPC 分派任务”这类时序缺陷——而 UE5.8 的粗粒度布尔无法做到这种精确等待。

但这里要说句公道话:UE5.8 的 Actor/Component + Subsystem 模型本身完全够用作载体——这也正是这一层能在重建工程里直接落地、且作为全篇模板的原因。原版的优势不是某个 UE5.8 没有的”机制”,而是”把状态分级、绝不把分级状态压成一个布尔值”这条纪律。机制是借来的,纪律是自己立的。

③ 移植到 UE5.8 的方法

这一层在重建工程里已经落地了,可以直接当所有后续系统的模板。它没有用任何高级系统,就是最朴素的 Actor/Component + WorldSubsystem——这正是 UE5 适配的第一原则:能用标准特性就不上高级系统

生命周期被建模成一组并列的、各自独立的状态字段,而不是一个枚举里的单一进度:

// 重建工程已落地:实体生命周期,每个阶段一个独立状态位
USTRUCT(BlueprintType)
struct FCyberEntityLifecycleState
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly) FCyberEntityId EntityId;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Spawn          = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Construction   = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Initialization = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Attachment     = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Registry       = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Gameplay       = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) ECyberRuntimeStatus Persistence    = ECyberRuntimeStatus::Unknown;
    UPROPERTY(BlueprintReadOnly) TArray<FString> Warnings;
};

注意这里没有一个叫 bSpawned 的字段。生成、构造、初始化、挂载、注册、可玩、持久化是七个能被独立观测的状态。任务系统、交互系统要依赖一个实体时,合同规定它们只能Gameplay == GameplayReady 或一个明确声明的子集,绝不能拿 Spawn 阶段的成功当玩法就绪。

注册表本身是一个 UWorldSubsystem,提供结构化查询——返回的不是裸指针,而是带 bFound 的结果结构:

UCLASS()
class UCyberEntityRuntimeSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable)
    bool RegisterInstance(const FCyberEntityInstance& Instance);

    UFUNCTION(BlueprintPure)
    FCyberEntityQueryResult GetInstance(FName InstanceId) const;   // bFound + Instance

    UFUNCTION(BlueprintPure)
    TArray<FCyberEntityInstance> GetInstancesForZone(FName ZoneId) const;
private:
    UPROPERTY(Transient) TMap<FName, FCyberEntityInstance> Instances;
};

这一章的意义:它确立了之后每个系统的写法范式——分阶段状态、结构化结果、子系统注册表。后面人口/AI/载具的重建,本质上都是把这个范式套到各自的领域里。


三、人群:成本即距离,三层退化是同一条流水线上的三种表示

人群三层成本模型与三态双向 handoff
图 4 · 人群三层成本模型与三态双向 handoff

这是整座城市开销控制的核心,也是 UE5.8 侧能复用最多引擎能力的部分。

① 原版设计

原版的人口系统不是一个”生成 NPC”的 API,而是一整层编排。它在编辑器配置的社区数据、轻量存根、运行时生成服务、交通车道、远景渲染、临时刷怪、存读档之间做协调。它的核心是 stub-first(存根优先)

存根(stub)是先于实体存在的轻量占位。 它持有记录 ID、世界变换、外观、生成器 ID、缓存的组件持久化状态——但没有渲染实体。系统只在存根进入”生成形状”(玩家周围一定范围)时才把真正的引擎实体 attach 上去;实体被回收后,存根仍在原地。人口系统加实体的入口甚至硬断言”实体必须已经有一个 stub”。

在 stub 之上,同一个”路人”按距离付三档代价

  • 完整 NPC:近处、可交互、完整行为与动画、不剥组件、正常广播生成事件。最贵。
  • 轻量人群存根:中距离。生成时按 LOD 套一个组件过滤器,剥掉状态效果、小队、视觉感知、肢解等若干类组件,碰撞简化为查询形状,且静默不广播(性能优化)。
  • 远景人群点(dot):远处。根本不创建存根或实体,而是上千个纯数据的”点”,沿交通车道样条插值位置,整批送进渲染器做一次 GPU instancing。它的渲染数据有效,不等于那里有一个能交互的人。

三层之间是连续可降级的双向转换:远景点接近时,发出生成请求升格为存根;存根超范围被删时,回退成一个远景点;存根 attach 上实体就成了完整 NPC,实体被 dispose 又退回光存根。它们不是三套独立对象,是同一条 lane 上的三种表示,靠生成请求和 ID 保持互相 handoff。

这套系统的工程纪律体现在两条边界上。其一,“受理”与”落地”严格分帧分锁:所有对外 API(加实体、减实体、更新槽位)只把一个请求压进加锁队列;真正的注册、生成、attach、despawn 全部收敛到单个持写锁的 job 内串行执行。其二,可见性是异步双通道:系统同时维护”在视野内”和”快进视野”两套独立的可见性查询结果——在视野内的实体永远不会被 despawn 抢走预算。

退一步看,整个生成管线是这样分阶段的:外部调用加实体 →(加锁队列受理)→ 单线程 tick 内注册落地 → 按距离/优先级/可见性算重要度分桶排序 → 按重要度尝试 attach 或生成 → 拿回 spawn token 压队列 → 轮询 token 完成 → attach + 广播。一头一尾都是异步边界,中间那段才是真正的”落地”。

时序走查:一个 NPC 从请求到就绪,需经过六个阶段

把这条管线摊开看,能更清楚地理解”为什么不能把任何一步当完成”。原版里这六个阶段分布在不同的线程上下文和不同的帧:

  • 阶段 A — 受理(任意线程,加锁入队)。 外部调用”加实体”,先硬断言这个实体已经有存根,然后解析记录(人还是车)、算模板哈希、选外观、定优先级,最后只把一条注册请求压进一个带自旋锁的请求集合。这一步不落地任何东西,返回的成功只是”队列收下了”。
  • 阶段 B — 注册落地(单线程 tick,持写锁)。 一个统一的池更新 job 把请求 swap 出来,真正建一条注册记录,缓存存根指针和世界坐标;该进活跃集的进活跃集,”永远生成”的直接进立即生成列表。从这里开始才有”被系统管理”的实体记录,但仍然没有引擎实体。
  • 阶段 C — 评分分桶。 遍历活跃集,对每个候选跑”在视野内/快进视野/在兴趣点”判定,用一个重要度公式(距离 × 优先级权重 × 类型权重 × 高度系数,再叠加视野内/兴趣点加成)算分,分到”视野内/将进视野/视野外/超范围”四个数组,各自按重要度降序排序。
  • 阶段 D — 真正落地。 对每个桶按重要度依次尝试:能传送已挂载的就传送、能 attach 未挂载的就 attach、否则才真正生成。生成时才构造生成上下文(模板路径、位置取自交通槽或存根、跳过 attach、初始隐藏溶解、按 LOD 过滤组件、按优先级定生成优先级),调生成服务拿回一个 spawn token 压进生成队列,回调只置一个”需要更新已生成”的原子标志。生成还可能因为生成策略、零浪费策略、取消、队列限流、未挂载预算不足而正常地返回失败。
  • 阶段 E — 令牌完成轮询。 那个原子标志触发一个”更新刚生成实体”的步骤,轮询生成队列:token 报告已生成,就提取实体;要立即 attach 的走调度 attach,否则进”未挂载池”(消耗未挂载预算);token 报告失败,就走生成失败路径。
  • 阶段 F — 挂载 + 广播。 调度 attach 把实体设进注册记录、挂进世界(可带淡入),回调人群系统”人口系统生成了实体”,只有非人群 LOD 才广播生成事件(人群静默,是性能优化),写黑板标记、更新预算。

六道关里,外部能看到的”成功”出现在阶段 A,而”这个 NPC 真的存在于世界中、能被任务引用”要到阶段 F 之后。中间隔着两个异步边界(请求队列、生成令牌)和至少一次跨帧。任何下游系统如果在阶段 A~E 的任意一步就去使用这个实体,拿到的都是半成品。

despawn 是同样的镜像:一个”能否立即删除”的闸门判断——未挂载的可立删,正在淡出的不可删,否则取决于它是否还在全视锥内(可见就不能立删)。要删的实体先标记淡出、移出活跃集、向实体投递一个”溶解隐藏”事件、压入淡出队列,过了淡出时间才真正 dispose、归还预算、非人群才广播 despawn。处于视野内的实体,不会被立即移除。

存根从哪来,又被谁消费

存根不是凭空冒出来的。它有两条产线:一条是社区系统——编辑器里配置的”社区”描述了一片区域该有哪些人、按什么时间作息出现,区域流送进来时,社区的”条目生成器”先去预约一个工作点(稀缺资源,预约失败要回收 ID 定时重试、有重试上限),拿到工作点的世界变换后才造存根;另一条是人群系统——它在交通车道的片段上先请求一个交通槽,再在槽上造存根,行人优先落在工作点上。

存根造好之后,被多个系统反向消费:

  • 载具系统通过人口系统加减”车存根”,并在车存根 attach 后,把交通槽提供者交给载具对象、为车辆填充乘客;
  • AI 的驾驶/移动动作通过社区系统拿到人群系统的句柄,做交通的加入/离开/完成;
  • 任务的刷怪/预防系统自带一套存根集合和预生成的 ID 池,订阅生成器的”生成/死亡/存根创建”事件总线来做任务相关的动态刷人;
  • 场景流送驱动社区的”区域流入/流出”,决定何时开始造存根、何时回收。

还有一个值得记住的技术债标记:为了让剧情需要的某个 NPC 永远不被回收,源码里把”永久抽取存根”做成了”普通抽取 + 一个永久计数器”——因为任务还要访问被抽取存根的 ID,没法真正一抽了之。这类”用计数器抑制重生”的 hack 说明:存根的状态恢复(存档/卸载/再激活)边界极易出错,UE 重建时应该用更干净的”owned / extracted”状态机替代,别照搬计数器 hack。

这一节的意义:它说明人口系统是整座城市的枢纽,下游一票系统都挂在它的事件总线和存根主键上。UE 重建时,这个枢纽地位要保留——人口的存根 ID 应该是 AI、载具、任务共享的实体主键,而不是各搞各的。

② 相对 UE5.8 原生的优势

UE5.8 在这件事上其实给了不少:MassEntity(在 MassGameplay 插件里)是一套成熟的面向数据 ECS,MassCrowd 提供按距离在 ISM/静态 actor/动态 actor 之间切换的人群表示框架——这正是官方城市演示用来撑起”一座会动的城市”的底子。所以”三层 LOD 表示”这个最难的骨架,UE5.8 是给了的。

但原版这套设计仍有三处 UE5.8 开箱不给、必须自补的优势:

  • 存根独立于实体的生命周期。 MassEntity 默认”entity 即数据”,没有”实体被销毁但占位还在”的概念。原版的 stub-first 让实体可反复 attach/dispose 而存根不动——背景社区因此不会每次都重新去找 AI 工位,预算(attached/unattached)针对的是实体而非存根。这是大规模人口”省”的根。
  • 请求与落地分帧分锁。 所有对外 API 只入加锁队列,真正的 spawn/attach/despawn 收敛到单个持写锁的 job 串行执行。UE5.8 的 Mass 是并行 processor 模型,需自行建立这条”请求队列→单线程消化”的边界,否则异步生成完成回调会与状态修改产生竞争。
  • 可见性双通道 + in-view 永不被抢预算。 原版的”在视野内”与”快进视野”是两套独立结果,且在视野内的实体绝不被 despawn 抢预算。Mass 的 LOD 只按距离,这条优先级规则要自己写。

一句话:UE5.8 提供了”结构骨架”(三层表示),原版提供的是”核心语义”(stub-first 的独立生命周期、单线程消化、可见性优先级)。

③ 移植到 UE5.8 的方法:优先参考原版,自建面向数据的人口运行时

这里先有一个底座层面的选择要做:人口的数据底座,是照原版那样自建一套面向数据(data-oriented)的运行时,还是用 UE 的 MassEntity

本工程的取向是优先参考原版自建——因为原版这套人口系统本身就是一套被验证过、完整的面向数据设计,直接 1:1 重建能拿到最高的语义保真,也最省”和引擎假设较劲”的功夫。具体到 UE5,就是用朴素 C++ 把它照搬:一组 SOA 的存根数组、一个带预算的 UWorldSubsystem 做”请求队列→单线程消化”、BVH/Octree 做空间查询、UInstancedStaticMeshComponent 画远景点、按需 attach AActor 做完整 NPC——自己掌控存储与调度,跳过 Mass 的 ECS。

Mass 是可选的另一条路(升格,不是默认):引擎里 MassEntity(在 MassGameplay 插件)、MassAIMassCrowd 都是现成的,MassCrowd 还带按距离切 ISM/actor 的表示框架。它的价值是省掉 ECS 底层管道、并能对接 MassCrowd/MassTraffic/ZoneGraph 生态;代价是得把下面这套原版语义”塞进”Mass(它未必天然支持),且 entity handle 不是稳定持久 ID。一个连带影响:人口若走自建,通常也就放弃了 MassCrowd,而第七章的交通本就要自建——两者叠加,等于城市模拟层几乎全自建。这是”标准优先→升格”路线里直接跳到 ④/⑤(自建)的一次刻意选择,值不值得取决于你多看重保真与控制。

无论走哪条底座,下面这套语义都要原样搬过来——它们才是原版真正值钱的部分。先把层级与存根定下来(”分级状态”和”stub-first”):

// 重建工程:人群层级与存根(设计)
UENUM(BlueprintType)
enum class ECyberCrowdTier : uint8
{
    DistantDot,   // 纯数据点,ISM 渲染,无 actor(走 Mass 时是 fragment)
    Stub,         // 轻量存根,有 ID/变换/持久态,无渲染实体
    LightActor,   // attach 了精简组件的 actor
    FullNpc       // 完整可交互 NPC
};

USTRUCT()
struct FCyberPopulationStub   // 先于实体存在,可反复 attach/dispose 而自身不动
{
    GENERATED_BODY()
    FCyberEntityId            EntityId;
    FCyberEntityDefinitionRef Definition;
    FTransform                Transform;
    ECyberCrowdTier           Tier = ECyberCrowdTier::Stub;
    TWeakObjectPtr<AActor>    AttachedActor;   // 仅 LightActor/FullNpc 阶段非空
    ECyberRuntimeStatus       Attach = ECyberRuntimeStatus::Unknown;
};

下面四条边界对两条底座都成立,必须照搬,否则就丢了原版的设计意图:

  1. 不能把 AActor 当 stub。 UE 开发者的本能是”一个 NPC 一个 Actor”,但原版的预算(比如同时 attach 的实体数有硬上限)针对的是实体,不是存根。存根必须是一个独立的轻量容器(自建走 SOA 数组里的一项,走 Mass 则是 fragment),AActor/UInstancedStaticMeshComponent 只在进入生成形状时按需创建、淡出后销毁。一个区域可以有几千个存根,但同时只有几百个 actor。
  1. “受理”和”落地”分帧分锁。 对应一个带预算的 UWorldSubsystem:对外 API 只把请求 Enqueue 进一个线程安全队列,真正的 spawn/attach/despawn 全部在 Tick 里单线程消化。否则异步生成完成回调会和状态修改竞争(走 Mass 也是同一条边界)。
  1. 三态 handoff 要实现双向。 dot↔stub↔npc 的双向转换必须实现且保持 ID。自建时它就是人口子系统里的一个方法(走 Mass 则对应一个 representation processor,但”存根独立于实体存在”要自己补——Mass 默认 entity 即数据,没有”实体被销毁但占位还在”的概念):
// 重建工程:三态 handoff(自建子系统的一个方法)。存根永远在,actor 按 tier 来去。
void UCyberPopulationRuntimeSubsystem::UpdateTier(FCyberPopulationStub& Stub, float DistToCenter)
{
    const ECyberCrowdTier Desired = ChooseTier(DistToCenter);   // 距离→目标层
    if (Desired == Stub.Tier) return;

    switch (Desired)
    {
    case ECyberCrowdTier::DistantDot:
        ReleaseActor(Stub);                 // 销毁 actor,保留存根 + ISM 实例
        break;
    case ECyberCrowdTier::Stub:
        ReleaseActor(Stub);                 // 仅数据,连 ISM 都可省
        break;
    case ECyberCrowdTier::LightActor:
        Stub.AttachedActor = AcquireActor(Stub, /*组件过滤=*/ELOD::Light);
        Stub.Attach = ECyberRuntimeStatus::Attached;
        break;
    case ECyberCrowdTier::FullNpc:
        Stub.AttachedActor = AcquireActor(Stub, ELOD::Full);     // 不过滤组件
        break;
    }
    Stub.Tier = Desired;   // EntityId 全程不变——这是 handoff 不丢身份的关键
}

关键是 EntityId 在所有转换里保持不变:远景点升格成完整 NPC 再降回存根,对任务系统而言始终是同一个人。自建时用一个稳定的 FCyberEntityId(带 FGuid)作为跨层主键即可;走 Mass 则要当心——Mass 的 FMassEntityHandle 在 entity 销毁重建后会变,不能拿它当持久身份。

  1. 可见性双通道 + 异步遮挡。 用异步遮挡/可见性查询(避免每帧同步射线),且”在视野内”与”快进视野”必须是两套独立结果。自建时这是人口子系统自己的一个异步查询(走 Mass 则对应 FMassVisualizationLODProcessor);无论哪条,”in-view 实体永不被 despawn 抢预算”这条优先级规则都要写进自己的预算分配逻辑。

这一章的取舍:人口的”魂”——stub-first 的独立生命周期、请求队列单线程消化、预算化解析、可见性双通道优先级——无论自建还是 Mass 都得自己写。区别只在底座:参考原版自建 = 1:1 保真 + 完全掌控,代价是 SOA 存储/分块迭代/并行调度这套 ECS 管道全自己写、且用不上 MassCrowd/MassTraffic 生态;用 Mass = 省掉这套管道 + 能接生态,代价是迁就它、并照样自建那套”魂”。 本工程取前者——参考原版自建,把交通也纳入同一套自建的、面向数据的城市模拟层。


四、NPC 的大脑:两套行为模型、一条 15 格的意志、与同步重入的完成链

命令队列定长 16 / 溢出阈值 15 / 留一格哨兵
图 6 · 命令队列定长 16 / 溢出阈值 15 / 留一格哨兵
NPC 同步重入完成链:一个调用栈内连锁停用整棵子树
图 5 · NPC 同步重入完成链:一个调用栈内连锁停用整棵子树

人会动之后,得让它”想”。这一章 UE5 有强力的现成系统(AIController + BehaviorTree/StateTree),但原版的几个核心语义和 UE 的默认假设正面冲突,照搬会出微妙的时序 bug。

① 原版设计

原版里同时存在两套独立的行为执行框架,命名空间分得很清。

其一是传统行为树:绑定一棵编译好的树,激活根节点,每帧自上而下递归 tick,根节点不再返回”进行中”就停用。经典 BT,服务于旧资产。

其二是主力的新行为框架,架构与传统 BT 完全不同——它是编译期内存布局 + 事件驱动 + 回调完成的混合体:

  • 行为被编译成全局共享的只读对象:按资源哈希去重缓存,全进程共享一份,引用计数归零才释放。节点树和一份”实例数据布局”(一张编译期定死的偏移表)在构造时一次性编译出来。每个 NPC 只持有一个实例 buffer,节点本身无状态。这等价于一份 BT 资产被多个实例共享,但连实例数据的内存布局都是编译期定死的 offset,不是对象字段。
  • 更新不是递归遍历整棵树,而是遍历一个”更新队列”——只有主动注册为可更新的节点才被 tick。
  • 最关键的特性:完成传播是同步重入,不是入队。 一个节点完成时,同步地:若有父节点就立刻回调父节点的”子完成”(父在同一调用栈里反应);若是根节点就同步调实例的”已执行”,而”已执行”又同步调”停用”。于是”子完成→父回调→可能触发兄弟激活或整树停用”全部在一个同步调用栈内发生,不经过任何队列。正因为如此,更新循环里要不断递增一个”更新 ID”——因为回调路径上节点可能已经被重新求值,必须防止重复 tick 已失活的节点。

行为之外,NPC 和世界打交道靠三个配套结构:

命令队列——一个定长状态机。容量是 16,但写到第 15 格就判溢出,把新命令直接取消。为什么是 15 不是 16?因为有一个”全部恢复”操作会把暂存的命令一次性压回队列,留一格空位防止那一刻数组越界。这种”留一格哨兵”是手写定长容器的经典手法。命令的每次状态切换都会同步地向实体投递一个状态事件、并同步回调所有监听者。

信号系统——固定 4 个监听槽。信号从”干净”翻到”脏”的那一刻同步通知所有监听者,但只在翻转的那一次通知(边沿触发),后续重复置脏不再广播。命令是”我要你做什么”,信号是”世界发生了什么”,二者成对。

数据驱动的动作系统——从配置库编译出条件/动作,缓存,按实体实例化(从 free-list 池复用),tick 期按 TTL 惰性停用。这是”无堆分配、可预测内存”设计意图的集中体现。它的闭环是四段:从配置库取记录、编译成可执行的条件/动作对象并缓存(带双检防并发重复编译)、给每个用到它的实体从 free-list 池里实例化一份、每次求值更新”最后访问时间”;然后一个注册在帧前段的步骤把超过存活时间(TTL)没被访问的实例惰性回收、归还池子。它还做了一件聪明事:把内容上等价(哈希相同)的记录归并,让多个 ID 共享一份编译产物。这套”编译→缓存→池化实例→TTL 惰性回收”的形态,UE 里没有直接对应物,但它的意图——避免每帧重复解析数据、避免堆分配抖动——在 Mass 里可以用 fragment + shared fragment 表达。

这里值得插一个对比,因为它澄清了”假异步”不是因为这套引擎不会写异步:寻路是假异步(内核同步),但 AI 驾驶的避障却是真异步。原版给每辆 AI 车在车身周围布了六个碰撞探测器(前、前左、前右、后、后左、后右),每个持一个异步物理查询令牌,走标准的”提交-轮询-消费”:令牌为空时提交一次异步球扫,下一帧检查”处理好了没”,好了读命中结果再把令牌清空消费掉,一组六个全空了才允许提交下一轮。同一套代码里,寻路选择了假异步(因为同步寻路足够快、且能简化调用点),避障选择了真异步(因为物理查询代价高、必须摊到多帧)——这说明”假异步”是一个深思熟虑的工程选择,并非取巧。迁移时也应这样按成本逐项判断,而非一概而论。

最后,原版给 AI 做了 LOD 分级 tick:四个 LOD 桶,tick 周期默认每 1 / 4 / 8 / 16 帧一次;按可见性、距离、是否战斗排序后填桶,溢出的降到更低 LOD;被任务或过场强拉的无视分桶直接最高频。还带低帧率降级——帧率掉到阈值以下时通知订阅者进一步压低 AI 开销,并用滞回避免在阈值边缘抖动。值得一提的是源码里有句注释明确承认:这套参数虽然叫”tick rate”,其实是 tick 周期(多少帧 tick 一次),命名是 misnomer——这种”名字和语义对不上”的历史包袱,迁移时最容易踩,照着名字理解就会把频率和周期搞反。

时序走查:一次”子节点失败”如何在单个调用栈内连锁停用整棵子树

新行为框架最反 UE 直觉的就是它的同步重入。设想一个 NPC 正在执行”走到掩体后开火”,开火子节点判定目标已死、返回失败。在原版里,接下来发生的一切不跨帧、不入队,全在这一个调用栈里:

  1. 开火节点调”完成(失败)”,同步触发父节点(序列节点)的”子完成”回调;
  2. 序列节点发现子失败,自己也”完成(失败)”,同步触发它的父节点;
  3. 一路上溯到根节点,根节点的”已执行”被同步调用;
  4. “已执行”同步调”停用”,把整个行为实例停掉,取消所有注册的回调;
  5. 与此同时,更高层可能挂着一个”行为失败就切到逃跑”的事件处理器,它在停用过程中被同步激活,于是一个新的行为实例即时启动。

这一整串从”目标已死”到”开始逃跑”,发生在同一帧、同一调用栈、原始那次 tick 还没退栈的时候。正因为如此,更新循环每次进出都要递增一个”更新 ID”,并用它判断”我正在 tick 的这个节点,是不是已经在刚才的回调链里被重新求值或失活了”——否则就会重复 tick 一个已经死掉的节点。

这套机制的好处是零延迟反应:NPC 对世界变化的响应没有一帧的滞后。代价是任何回调代码都必须假设自己是在中间态被触发的,状态可能正处于中间态。UE 的 BehaviorTree 用延迟完成把这套复杂度换成了”慢一帧但更易于推理”,这正是迁移时要正面权衡的取舍点。

② 相对 UE5.8 原生的优势

这一章 UE5.8 的”现成度”是双向的,得分开说。

UE5.8 给的:AIController + BehaviorTree(成熟的行为树)、StateTree(数据驱动、紧凑 instance data 的状态机框架)。值得强调的是,StateTree 的设计哲学和原版那套”全局共享只读编译产物 + 每 NPC 一个紧凑 instance buffer”高度一致,甚至更干净(StateTree 的 instance data 不依赖 per-NPC 的 UObject 树)。这一点上 UE5.8 不落下风,重建 NPC 大脑应优先 StateTree,不必复刻原版那套手写的 instance-data-layout。

原版真正强过 UE5.8 开箱的,是三处:

  • 同步重入的完成链 = 零延迟反应。 原版”子失败→父回调→整树停用→兄弟激活”全在一个调用栈、同一帧跑完,对世界变化无一帧滞后。UE5.8 的 BehaviorTree 用 FinishLatentTask 把完成推迟到下一次 tick——但这要诚实地两面看:延迟完成换来的是”慢一帧但好推理”,对多数行为反而是更安全的工程取舍。所以这条”优势”是有代价的,不是纯赚。
  • 命令队列与信号系统:UE5.8 压根没有。 “NPC 命令队列””4 槽信号 + 边沿触发”是 gameplay 层,引擎不提供。原版用定长无堆分配 + 哨兵保护实现了可预测内存,这是它实打实的优势。
  • AI LOD 分桶 tick。 原版 1/4/8/16 帧分桶 + 溢出降级 + 低帧滞回;UE5.8 的 AIController 默认每帧 tick,大规模人群必须自建可变频率(或借 Mass 的 LOD 批处理)。

③ 移植到 UE5.8 的方法:基于 BehaviorTree/StateTree,但要重建四条语义

UE5 标配 AIController + BehaviorTree,外加 StateTree(在引擎里,一套更数据驱动、更适合表达状态机的框架)。原版那两套行为模型,对应 UE 的 BT(传统树)和 StateTree(数据驱动、紧凑、事件友好)正合适。但有四条原版语义和 UE 默认假设冲突:

  1. 完成传播:UE 是延迟的,原版是同步重入的。 UE 的 BehaviorTree 用 FinishLatentTask 把任务完成推迟到下一次 tick,StateTree 的转换也是声明式的。直接照搬原版的”子失败立刻在同一栈里触发兄弟/整树停用”会改变时序。如果某段逻辑依赖”完成的瞬间副作用”(比如完成回调里立刻改了一个共享黑板值,下一个节点当帧就读),UE 的延迟完成会让它差一帧。重建时要么接受这一帧延迟并审计所有依赖、要么自建一个同步完成通道,并复刻”更新 ID”式的”回调路径上重新求值”保护。
  1. 共享只读 + per-NPC 实例 buffer:用 StateTree 而非 per-actor UObject 树。 原版的”一份编译产物 + 每 NPC 一个紧凑 instance blob”正是 StateTree 的设计哲学(StateTree asset 共享,instance data 是紧凑的属性 blob)。这比”每个 NPC 一棵 UObject 行为树”内存和 cache 特性好得多。重建 NPC 大脑应优先 StateTree,把原版的 instance-data-layout 映射到 StateTree 的 instance data。
  1. 命令队列和信号系统:引擎没有,要自建,且常量是硬契约。 UE 没有”NPC 命令队列”这种东西——这是 gameplay 层。重建时用一个组件承载,但容量 16、溢出阈值 15、留一格哨兵、信号 4 槽、边沿触发通知这些不是随便填的数字,它们承载着”无堆分配、可预测内存”的意图。用 TArray 动态扩容重写时,溢出保护和边沿触发语义要原样保留:
// 重建工程:NPC 命令队列(设计),保留定长 + 哨兵语义
UCLASS()
class UCyberNpcCommandQueue : public UActorComponent
{
    GENERATED_BODY()
public:
    static constexpr int32 MaxQueueSize = 16;        // 硬契约
    // 溢出阈值 = MaxQueueSize - 1,留一格给"全部恢复"操作
    bool Enqueue(const FCyberNpcCommand& Cmd);       // 满则置 Cancelled,不阻塞
    void SetState(FCyberNpcCommandHandle H, ECyberCmdState New);  // 同步发事件 + 同步回调
private:
    TArray<FCyberNpcCommand> Queue;      // 逻辑上限 MaxQueueSize
    TArray<FCyberNpcCommand> Stashed;
};
  1. AI LOD:用 Mass LOD 或自建分桶 tick。 UE 的 AIController 默认每帧 tick,没有原版那套”1/4/8/16 帧分桶 + 溢出降级 + 低帧滞回”。大规模人群的 AI 必须自建可变 tick 频率——要么把 NPC AI 放进 Mass(Mass 天然按 LOD 批处理),要么给 AIController 加一个分桶调度器。被任务/过场强拉到最高频这条特例也要留:
// 重建工程:AI LOD 分桶调度(设计),保留"周期"而非"频率"语义
UENUM()
enum class ECyberAiLod : uint8 { L0_EveryFrame, L1_Every4, L2_Every8, L3_Every16 };

USTRUCT()
struct FCyberAiLodConfig
{
    GENERATED_BODY()
    // 注意:这是 tick 周期(每 N 帧一次),不是频率。原版源码自己都标了 misnomer。
    int32 TickPeriodFrames[4] = { 1, 4, 8, 16 };
    int32 BucketCapacity[4]   = { -1, 16, 16, -1 };   // -1 = 无上限;溢出降到更低 LOD
};

// 每秒重排一次:按可见性/距离/是否战斗算排序键,降序填桶,溢出降级;
// 被 quest/过场强拉的无视分桶直接 L0。低帧率时用滞回阈值整体再降一档。

这套调度若直接放进 Mass,可以省掉手写分桶——Mass 的 LOD processor 天然把 entity 按距离分级批处理。但”溢出降到更低 LOD””被任务强拉到最高频””低帧滞回降级”这三条优先级规则仍要写进自定义 LOD processor,引擎默认的 LOD 只按距离,不懂”这个 NPC 正在演任务,必须满帧”。

持锁回调的陷阱要单独说明。 原版大量使用”持锁期间同步回调跨系统”——移动策略组件整个更新持一把锁,并在锁内查询警戒区管理器,而后者又在自己的回调锁里同步回调进移动策略组件。命令队列的状态切换也是持锁同步 fire 事件。UE 通常假设游戏线程单线程、回调即时执行——如果重建时把这些逻辑放进多线程 Mass processor,这套分锁/双缓冲结构要么保留、要么彻底改成无锁的数据流,绝不能主观假定”反正都在游戏线程”。

把这个坑说透一点,因为它是迁移里最隐蔽的一类。移动策略组件的更新函数整个函数体持一把锁,而在这把锁里它会去查询警戒区管理器(NPC 不能走出警戒区的逻辑);与此同时,警戒区管理器在自己的回调锁里,会同步回调进移动策略组件注册的那个 lambda(用来把缓存的受限目标置无效)。于是存在两把锁的跨系统交互——一旦顺序错了,就是经典的死锁;一旦在错误的时机回调,就读到半更新的状态。这种”A 持锁调 B,B 持自己的锁回调 A”的结构,在原作的多线程 bucket 更新模型里是精心设计过的,但搬到 UE 时极危险:如果工程师默认”UE 都在游戏线程、回调即时、不用锁”,把这套逻辑直接铺平,要么在单线程下侥幸跑通但丢了并行收益,要么一旦放进 Mass 的并行 processor 就随机死锁。正确的做法是:迁移前先把这些跨系统回调的锁依赖图画出来,决定是整体保留分锁结构、还是改写成”收集-屏障-应用”的无锁数据流——但这个决定必须显式做出,不能默认。


五、驻留行为:需求、预约、工作点,与一串可能缺失的动画

工作点八层完成信号与缺动画三级降级链
图 7 · 工作点八层完成信号与缺动画三级降级链

光会走、会决策还不够。城市要”活”,得有人就座、有人作业、有人倚靠掩体。这一章 UE5 有专门的 SmartObjects 插件,但原版的几个语义需要纠偏。

① 原版设计

两个系统合奏:智能对象(椅子、售货机、掩体——世界里”可以对它做点什么”的点)和工作点(一段绑定动画的”作业”流程)。

智能对象这里有个常被误传的细节需要纠正:它的占用不是”独占预约”,而是一个可叠加的引用计数锁。这个锁保护的是”别把这个对象卸载/销毁”,不是”我独占这个工位”——多个用户可以同时叠加这个锁。真正的工位独占性在更上层(工作点/掩体的占用逻辑)。这是两层,重建时不能混。

智能对象的”解析”(把一个登记的占位真正变成”你可以用了”)是预算化、空间局部化、带滞回的:

  • 每帧总预算极小(亚毫秒级),用完即止,未用完结转不超过半帧;
  • 以玩家和关键 AI 作为”解析中心”,按到相机的距离排序;对象位置存在一棵 BVH 里;
  • 内圈解析、外圈卸载的滞回设计防止边界抖动;每个中心每帧最多解析很少几个,每帧最多反解析的数量也有上限;
  • 远离所有中心的对象永远不解析,只占一个表项加一个 BVH 叶子,零运行时开销;
  • 销毁从不立即删,而是标记 + 切断回调 + 入销毁队列;而且锁优先于一切——被锁住的对象即使已标记销毁也会被强行放回以保活。

掩体是另一种取舍:可用射击姿态的查询是缓存 + 同步射线检测。每个掩体每种暴露方式都可能发起一条阻塞主线程的同步射线——这就是缓存存在的全部理由。缓存的 key 把射线起点量化到 0.1 米栅格、再异或目标 ID;有效期按离威胁远近在 0.5~2 秒间插值(距离越近刷新越频繁);对”已分离/未初始化的威胁”直接返回空。命中是 O(1) 哈希查找、零物理,未命中是一串同步射线——开销差一个数量级。

掩体的占用和智能对象的引用计数锁不同,它是严格的 1:1 硬互斥:靠两张反向哈希表(NPC→掩体、掩体→NPC)维护,注册时四道闸(掩体有效、已注册、该 NPC 没占别的掩体、该掩体没被别人占)。这印证了前面那条”保活锁和独占预约是两层”——同一个城市里,”别卸载这把椅子”用的是可叠加的引用计数,”这个掩体只能我一个人用”用的是 1:1 互斥表,两套机制各司其职。重建时若把它们混成一套,要么掩体被多人抢占穿模,要么椅子被一个永远到不了的 NPC 锁死。还有个并发细节值得记:注册的检查用读锁、写入升写锁,中间存在一个 check-then-act 的窗口——UE 重建若用 SmartObjects 的 Claim 机制,要确认它的占用是原子的,别把这个窗口也继承过来。

工作点把”局部完成不是业务完成”演到极致。配置一个工作点只是配置,发一条命令只是入队;真正的播放、动画就绪、位移、物品操作、完成,全发生在之后。它的动画推进尤其能体现”优雅地降级”:

  • 选下一段动画时若该动画缺失,不报错:缺的是 idle 就给一个几秒的驻留(假装播了,避免空转风暴),缺普通动画就给零时长(立即跳到下一条,不卡死);
  • 如果记录推进反复返回零,会触发一个”最多跳过 N 条”的兜底,超过就退化成循环待机,并返回一个节流时长防止当帧死循环。

也就是说,一个配置错误的工作点不会导致游戏崩溃,而是让那个 NPC 在原地反复空转——这正是开放世界里那些动作异常穿帮的工程来源。

而所谓”完成”,原版把它拆成了一串各自只代表一层的信号。把它列成表,是这一章最该牢记的内容——因为”工作点”几乎是整个城市里”完成语义”层数最多的系统:

信号触发时机只代表哪一层
配置工作点返回调用即返回仅”实例已注册/命令已入队”,动画一帧没播
发命令返回真调用即返回仅”实例存在且命令已入队”,未执行
工作点已开始实例挂图后已挂载并开始播放(进入播放态)
动画已开始/已结束单条记录某一条 record 的动画起止,非整个工作点
播放同步动画返回真主从对齐匹配到入口、跳转命令已下发——非播放、非对齐、非完成
完成进入退出态工作点自然播完的逻辑通知,但资源/物品还没卸
停止外部强拆才是卸图、卸 animset、卸物品、清 LOD
移动控制器”是否完成”智能对象侧“动作真正播完没”的最终裁决

理解这张表,就理解了为什么”NPC 就座休憩”在工程上是”一次预约 + 一串命令 + 一个可能缺失的动画 + 八种各管一层的完成信号”。重建时若把任意两层合并成一个 bool,就会出现”动画仍在播放 NPC 即被传送””工作点逻辑完成但道具仍挂在手上”这类穿帮。

② 相对 UE5.8 原生的优势

UE5.8 给的:SmartObjects 插件(ClaimRelease 独占 slot + 实例集合按需激活)、GameplayBehaviorSmartObjectsStateTreeMotion Warping(位移对齐)。”交互点 + 独占占用 + 状态机 + 位移对齐”这套零件,UE5.8 是齐的。

原版强过 UE5.8 开箱的,是它的调度纪律,而这恰恰是 SmartObjects 插件不管的:

  • 两层占用语义分明。 原版的引用计数保活锁(防卸载,可叠加)和独占预约(1:1 互斥,掩体用)是两层不同的东西。UE5.8 的 SmartObjects 只给了独占 Claim 这一层;”保活锁”那层要自己用子系统的 pin-count 实现。混成一层就会出”椅子被永远到不了的 NPC 锁死”或”明明有人用却被卸载”。
  • 预算化解析 + 空间局部化 + 滞回。 亚毫秒预算分段、BVH 内圈解析外圈卸载的滞回带、远处永不解析=零开销——这套是大世界控制 CPU 开销的关键。SmartObjects 支持”按需激活”,但这套调度需自行实现。
  • 同步射线的量化栅格缓存。 掩体 LOS 用 0.1m 体素 + 目标 ID 做 key、按距离自适应有效期。UE5.8 没有这层缓存,照搬同步 trace 会显著拖慢帧率。
  • 缺动画的三级降级链。 配置错误的工作点不会崩溃、仅使 NPC 原地空转。UE5.8 的 Montage 缺失没有这套兜底,需自行建立。

简言之:UE5.8 给了交互点和占用机制,原版给的是让这些机制在大世界里不致开销失控的调度纪律。

③ 移植到 UE5.8 的方法:基于 SmartObjects 插件 + StateTree + Motion Warping

UE5 的 SmartObjects 插件(外加 GameplayBehaviorSmartObjects)和 StateTree 都在引擎里,正好对应。但有五条边界要小心:

  1. “可用/已下发”≠”已完成”必须贯穿设计。 原版里”加需求””发命令””播同步动画”的布尔返回值都只表达”匹配成功/命令入队”,完成永远走独立回调。UE 里 SmartObject 的 Claim、StateTree 任务的进入、Montage 的播放请求,都要保留这一分层——别把”请求成功”当”动作完成”。
  1. 保活锁(refcount/pin)和独占预约是两层,分开实现。 原版的引用计数锁只防卸载,可多人叠加;工位独占在更上层。UE 里对应:用一个 world subsystem 的 TMap<FGuid,int32> PinCount 做流式保活,而 SmartObjects 插件的 Claim/Release(独占 slot)是另一层。混在一起会出”一把椅子被一个永远到不了的 NPC 锁死”或”明明没人用却被卸载”的 bug。
  1. 解析必须预算化 + 空间局部化 + 滞回。 这是大开放世界控制 CPU/内存开销的关键。UE 里对应一个带 budget 的 UTickableWorldSubsystem + Mass/Octree 空间查询。绝不能一次性把所有 SmartObject 都 spawn 成 actor——SmartObjects 插件本身支持”实例集合 + 按需激活”,但”远处永远不解析、亚毫秒预算分段、内外圈滞回”这套调度要自己写在解析 processor 里:
// 重建工程:预算化智能对象解析(设计)
void UCyberSmartObjectResolver::Tick(float Dt)
{
    double Budget = ResolveBudgetSeconds;          // 亚毫秒级,未用完结转 ≤ 半帧
    Budget += FMath::Min(CarriedBudget, Dt * 0.5);
    // 解析中心 = 玩家 + 关键 AI,按到相机距离平方排序
    for (const FResolveCenter& C : SortedResolveCenters)
    {
        int32 ResolvedThisCenter = 0;
        // BVH 查询:内圈半径内"待解析"的对象
        for (FSmartObjectHandle H : Octree.QueryInner(C.Location, InnerRadius))
        {
            if (Budget <= 0 || ResolvedThisCenter >= MaxResolvePerCenter) break;
            const double T0 = FPlatformTime::Seconds();
            Resolve(H);                              // 占位 → 真正注册
            Budget -= (FPlatformTime::Seconds() - T0);
            ++ResolvedThisCenter;
        }
    }
    // 外圈半径外的已解析对象反解析(InnerRadius < OuterRadius 形成滞回带)
    UnresolveBeyond(OuterRadius, MaxUnresolvePerFrame);
    // 远离所有中心的对象:永远不进上面的循环 → 零开销
}

这段的核心是三个数字关系:内圈半径 < 外圈半径(滞回带,防边界抖动)、每中心每帧解析上限(防尖峰)、每帧反解析上限(防卸载尖峰)。SmartObjects 插件给了”实例 + Claim/Release”,但这套调度不在它的职责范围内。

  1. 同步射线必须有量化栅格缓存兜底。 掩体的 LOS 用主线程同步射线,靠”起点量化到 0.1 米体素 + 目标 ID”做 key、按距离自适应有效期。UE 重写掩体暴露点时若用同步 line trace,必须照搬这套量化缓存,否则每 NPC 每帧多条同步 trace 会显著拖慢帧率;已分离/未 attach 的威胁要短路返回空。更好的做法是把这类查询挪到异步 trace 或 Mass 的批量 trace 上。
  1. 降级链与”完成/清理”分离是健壮性底线。 三层降级(缺普通动画→立即跳 / 缺 idle→驻留几秒 / 跳过超限→循环兜底待机并节流)防数据病态死循环;”逻辑完成通知”与”资源卸载清理”必须是两条路径。UE 里 StateTree/Montage 的完成委托和实例 teardown 应同样拆开,并为缺失的 Montage 设一个硬编码 idle 兜底。位移对齐用 Motion Warping 替代原版的”工作点位移请求”。

最后补一个常被低估的复杂度来源:工作点的物品/道具副作用本身也是异步的。NPC 执行任务时手里要持物(端盘子、握工具),这分两类——真实物品走库存系统的给/收,全局道具则走一套异步生成:先异步加载道具的绑定资源、再派 job 生成道具实体、由生成令牌回调回填实体句柄。在道具还没生成完时,取道具会拿到一个无效索引——这又是一个”请求成功 ≠ 资源就绪”的实例。而且这些物品动作是延迟执行 + 去重的(同一实体同一物品只保留最后一个动作),顺序敏感的库存序列会被压平。UE 重建时,工作点手里的道具应该用异步生成 + 句柄回调表达,绝不能假设”开始执行时道具就在手上”;卸载时还要区分”解绑(道具回原位)”和”销毁”——原版里卸下道具默认只是解绑、不销毁实体,照搬错了会出”道具凭空消失”或”道具泄漏”。

这一章的取舍:SmartObjects + StateTree 给了”交互点 + 状态机”的骨架,但原版真正值钱的是那套调度纪律(预算化解析、量化缓存、降级链、两层锁、完成语义分层)——这些引擎一概不给,是重建的主要工作量。


六、寻路:异步的外衣下是同步的内核,而 UE 恰好同源

寻路「假异步外壳 / 同步内核」剖面与状态位组合判据
图 8 · 寻路「假异步外壳 / 同步内核」剖面与状态位组合判据

NPC 和车都要寻路。这一章有个好消息:UE 的导航底层就是 Recast/Detour,和原版同源——参考价值极强。但原版在 Detour 之上包的那层”假异步 + 多图缝合”,UE 不提供。

① 原版设计

如果只看脚本层接口,很容易认定寻路是异步的:发起寻路拿到一个令牌,之后每帧查询”是否完成”,完成后再取走。但顺着实现读下去会撞见一句注释直白承认:异步寻路其实没实现,那个”更新查询”的函数内核直接调同步寻路。整个”异步”是上层一个多帧合成层用令牌轮询模拟出来的。

这层假异步的设计其实很值得借鉴——它把”是否真异步”做成了实现细节,对外只暴露异步形状的契约。哪天真要换成多帧异步,调用方一行都不用改。

但这一层真正的陷阱,是分级状态不能压成一个布尔值。底层寻路返回的是一组位掩码状态:起点没流送进来、终点没流送进来、最近可走多边形无效、结果是部分路径、节点池耗尽、拉直失败……它们会组合出现。其中两个位的区分至关重要:

  • “部分路径”且”节点池耗尽” = 没搜完,值得重试;
  • “部分路径”但”节点池没耗尽” = 整张可达图都搜完了也没到,重试无意义,是真无路。

如果调用方只看一个 isReady 就判定成功,就会让 NPC 沿一条不完整路径行进并最终撞上障碍。多帧合成层正是靠这两个位的组合来决定”等流送重试”还是”判定无路”。它还有一套”等流送”策略:起点/终点没流送进来时,每隔几秒重发同一查询,同时把已有的部分结果立即交付用户(因为等流送可能无限久)。

寻路之上还有一层多图缝合。原版里步行/室内走一张 navmesh,车道/人行道走另一套交通车道图,一个查询构建器负责把”navmesh→交通→navmesh”拼起来:按目标点离最近车道的距离决定走 navmesh 还是交通、反向查找可达车道、用基于直线估距的”路径好坏度”近似挑路、处理电梯和入口标记的 off-mesh 连接。这层缝合里埋了一堆作者自己标注的 FIXME 和 HACK(电梯 off-mesh 被强制过滤掉所以不工作、用几何猜哪段是电梯、为了内容里拼错的 tag 把错拼也加进白名单)——这是整个迁移工作量最大、最易出 bug 的部分

交通系统这边还有一个反直觉的细节:全局寻路失败时,它存的是一条空的占位路径而不是空指针——所以”结果指针非空”根本不等于”找到了路”,上层必须靠”路径是否为空”来判失败。源码里作者自己也标了 TODO 要删掉这个 dummy path。

时序走查:一次跨 navmesh 和车道的寻路,是怎么”假装异步”的

把多帧合成层摊开看,”假异步”到底假在哪就清楚了。这一层用三把分域的锁(活跃查询、已取消查询、结果)维护一组进行中的查询,每个查询是一台小状态机:

  • 发起:调用方提交一次寻路,立刻发出第一段(可能是 navmesh,也可能是车道),分配一个递增的令牌返回。这一帧同步内核已经把第一段算完了,但调用方拿到的只是令牌。
  • 每帧推进:合成层每帧把已取消的查询清掉,对活跃查询逐个推进——读上一段的结果,决定是切下一段、切下一个 fallback 方案,还是重试。一条”navmesh→车道→navmesh”的路,是分多段、跨多帧拼出来的。
  • 判读:这里是精华。车道段返回 null 是”还在算”,返回的路”为空”才是失败(那个 dummy path);navmesh 段”未就绪”是”还在算”。如果带着”起点/终点没流送进来”且该段允许等流送,就每隔几秒重发同一段,同时把已有的部分结果立即标记可用交给调用方——因为等流送可能无限久,不能干等。而”部分结果但节点池没耗尽”会被判成”真无路”直接失败。
  • 拉取:调用方每帧来问”好了没”,只有标记可用才返回,且一次只取走一段——多段路要多次拉取。

所以”异步”全是这层用令牌轮询 + 多段拼接 + 等流送重试模拟出来的,底下每一次实际计算都是同步阻塞的。这个设计的精妙在于:它把”现在是不是真异步”完全藏进了实现,对外永远是异步形状的契约。

而支撑车流的交通系统本身,是一套独立的、帧前段高度 job 化的机器,值得单独看一眼,因为它是 UE 侧最需要自建的部分。它在每帧开头并行派发一串 job:处理待注册的车道、更新车道(清失效槽、灯变更、回收待删节点)、绿波灯推进、更新交叉口、检测死路、更新碰撞、处理全局寻路请求、清理超时的生成请求、维护评估器与生成查询缓存。这串 job 里有三处机制特别能说明”大世界交通”的工程难点:

其一,车道注册是延迟且可抵消的。车道随区域流送进出,但注册和反注册不立即生效——它们先进队列,如果一个节点同时出现在注册和反注册队列里就互相抵消(流式抖动时常见)。真正的删除更是推迟到”所有引用归零且无占用槽位”之后才做,避免删掉正被车占用的车道。注册时还会把该节点的静态碰撞从一个持久池里抽取注入到运行时碰撞图,反注册时再还回去——碰撞数据跟着车道的生命周期走。

其二,死路检测是事件驱动 + 帧首批处理。碰撞更新(比如一辆车抛锚堵了路)会触发把受影响的车道收进待检集合,帧首的死路检测 job 逐条检查、命中就标记死路并向上游传播(一条路堵了,通往它的路也成了死路),最后一次性广播”车道封锁”事件,让生成和寻路避开。这条”碰撞→待检→批处理→传播→再广播”的链路,是城市车流遇到障碍能自然绕行的来源。

其三,全局寻路是有状态、可切片续算的。交通的全局寻路不是一次算完,而是有 2 毫秒的帧预算:起步一段、续算一段、超时就下帧接着算,当前进度由一个”进行中请求”持有。这和 navmesh 那层的”假异步”形成有趣对照——交通寻路是真的跨帧切片的,因为车道图的全局寻路确实贵到必须摊开。

这三处在 UE 里都没有现成实现:ZoneGraph 给了车道和交叉口的图结构,但车道占用、延迟注册抵消、死路传播、全局寻路切片,全要自己写。这也是为什么交通是全文唯一标注”需自建/移植”的系统。

而它上面还压着一个”查询构建器”,负责把高层意图翻成多段 + 多个 fallback 方案:纯 navmesh(不允许部分)→ navmesh-车道-navmesh → 从最远已流送点再来一遍 → 纯车道 →(入口衔接)→ 最后才是允许部分结果 + 直线兜底的方案。一层层降级,越往后越妥协。这套 fallback 链,是开放世界里”导航箭头总能指出一个方向、哪怕只是部分路径”的工程来源。

② 相对 UE5.8 原生的优势(也包括 UE5.8 更强的地方)

这一章必须把话反过来说:很多地方 UE5.8 比原版更好,照搬原版反而是退步。不予回避,逐条说明:

  • 真异步:UE5.8 赢。 原版的”异步”是上层用令牌轮询模拟的,内核 FindPathSync 是同步阻塞的(源码自己都标了 todo)。UE5.8 的 UNavigationSystemV1::FindPathAsync真正的异步寻路。这里别移植原版的假异步。
  • 失败语义:UE5.8 赢。 原版交通寻路用”非空的空路径”表失败,是个反模式(作者自己 TODO 要删)。UE5.8 的 FNavPathSharedPtrIsValid()bPartial 干净得多。直接用 UE5.8 语义。
  • 底层算法:平手(同源)。 UE5.8 的 ARecastNavMesh 底层就是 Recast/Detour,和原版同源,tile 增量流送、off-mesh links 都现成。

那原版还剩什么值得借鉴的优势?只有两处,但很关键:

  • partial 的细分判据。 原版区分”部分+节点池耗尽=没搜完,值得重试”与”部分+没耗尽=真无路”。UE5.8 的 bPartial 没有这个”值得重试”信号——这条语义要自己补,否则分不清该重试还是该放弃。
  • 多图缝合策略 + 等流送策略。 “navmesh→交通图”的拼接、”整块 sector 就绪才标记”的 gate、”等流送时立即交付 partial + 每几秒重发”——这些 UE5.8 既不给、ZoneGraph 也不给,是 gameplay 策略层。

所以这一章的诚实结论是:底层采用 UE5.8(更强),失败语义采用 UE5.8(更干净),只把”partial 细分”和”多图缝合策略”这两点语义从原版借鉴过来。

③ 移植到 UE5.8 的方法:基于 Recast/Detour,自建分级结果与缝合层

UE 的 ARecastNavMesh 底层就是 Recast/Detour,tile 流送、增量 add/remove tile、FindPathAsync(真异步)、off-mesh links 都现成。这是全文 UE 现成度最高的一章。但有四条要自己补:

  1. 保留 partial 的细分语义。 UE 的 FPathFindingResultbPartial,但没有”节点池耗尽”这种”值得重试”的信号。原版那条”部分+无耗尽=真无路;部分+耗尽=没搜完”的判据,UE 不给——要自己在 query filter 或路径元数据上补一个”是否因节点预算中断”的标记,否则无法区分”该重试”与”该放弃”。
  1. 流送门禁 + 就绪查询要自建。 UE 的导航 tile 流送(配合 World Partition 的 Navigation Data Chunk)会做增量加载,但没有暴露”这块导航是否已就绪”给上层在寻路前 gate。原版那套”整块区域全 tile 成功才标记就绪””数据真插入前 listener 就先收到通知(让交通先登记)”的语义,UE 没有等价物——要用 World Partition 的 streaming 事件自己搭一个”已就绪扇区”集合,并暴露查询。
  1. navmesh↔交通的缝合层全自建——这是重建的硬骨头。 UE 里 navmesh = Recast,交通车道 = ZoneGraph(引擎里现成,提供车道/交叉口)。但两张图之间的缝合——入口衔接、裁剪到车道、子图连通性匹配、行人路好坏度近似——UE 完全不提供。这正是原版里 FIXME/HACK 最密集的地方。重建时建议:电梯/楼梯过渡直接用 UE 的 smart link + 自定义 area,别复刻那个坏掉的 off-mesh HACK;好坏度近似可以先照搬”估距/直距”的廉价版,但要留一个”用真实路长复算”的改进点。
  1. 失败语义用 UE 的干净约定,别移植 dummy path。 原版用”非空空路径”表失败是个反模式(作者自己都想删)。UE 的 FNavPathSharedPtrIsValid()/bPartial 表达更干净,直接用 UE 语义。但”等流送时立即交付已有 partial””每隔几秒重发”这类策略是 gameplay 层,UE 无原生等价,要自己写——可以挂在交通路径请求的处理器里。
  1. off-mesh 连接的运行时状态机要自建。 原版维护着”off-mesh 连接 ID ↔ 多边形引用”的多张表、每种 agent 尺寸的启用/禁用/覆盖位、”对玩家是否可用”的游戏态计数,以及 tile 流送进来时重放已设状态。UE 的 off-mesh links(UNavLinkComponent/smart links)有启用/禁用,但没有基于 tag 的过滤、没有”流送进来时重放已设状态”、没有”对玩家是否可用”这类游戏态位——这些要在自定义 query filter 的 area flags 上重建。这里有个明确的教训:原版里电梯走 off-mesh 本身就是坏的(被强制过滤掉,作者标了 FIXME),还有一处为了内容里拼错的 tag 把错拼也加进白名单的 hack。UE 重做时应直接用 smart link + 自定义 area 表达电梯/楼梯过渡,不要复刻那条已失效的 off-mesh 路径,也不要继承那些 hack——迁移是难得的”一次性偿清历史技术债”的机会。

这一章的取舍:底层算法 UE 近乎原生提供(同源),但”分级结果的细分””流送就绪 gate””多图缝合””等流送策略”四件事是 gameplay 策略层,需自建。缝合层尤其要预留充足工期。


七、车与交通:物理相位中的并行,与被”合成”出来的轮胎接触

载具「玩家=物理 / AI=运动学」分流
图 9 · 载具「玩家=物理 / AI=运动学」分流

载具是另一套逻辑,但纪律一脉相承。这一章要分两半看:载具物理 UE 有现成(Chaos),车流/交通调度则是全文唯一没有统一内置方案、需基于 ZoneGraph / MassTraffic 或自建的部分。

① 原版设计

先纠正一个容易产生误解的点:原版的载具不用 PhysX/Chaos 那种车辆 SDK,而是一套自定义刚体 + 射线悬挂模型——底层物理引擎只负责碰撞查询和接触回调,车的运动是自己积分的。

载具对象有个清晰的层级,但有几个反直觉的结构性事实,迁移时若凭直觉照搬就会建错继承树:

  • 通用载具基类之下,只有汽车和摩托是”轮式车”——它们共享一套传动系(刹车/变速箱/引擎)。
  • 坦克和飞行器直接继承通用基类,不属于轮式。坦克没有传动系,但有重武器和机翼/推进器的特效参数;飞行器更特殊——它根本不注入悬挂(悬挂指针为空),运动靠一套独立的空中控制系统。
  • 正因为飞行器没有悬挂,通用基类每次访问悬挂都得判空回退到底盘组件。悬挂是以抽象基类指针注入的,基类对具体是汽车悬挂、摩托悬挂还是坦克悬挂完全无感,由子类构造时注入。

这套层级的设计意图是”通用基类持有一切子系统的可选指针,子类只注入自己需要的”。它对应到 UE 不该是一棵深继承树(汽车 is-a 轮式 is-a 载具 is-a……),而更适合组合:一个载具 actor 挂若干可选组件(传动、悬挂、空中控制、武器、autopilot、毁坏),按类型决定挂哪些。深继承在 UE 里会与 Chaos 的 AWheeledVehiclePawn 体系冲突,组合则能让”飞行器=无悬挂+空中控制””坦克=无传动+重武器”自然表达。

它的 tick 按物理相位分桶,是能同时跑一城车流的根本:

  • 前物理桶:处理车库、刷入延迟注册队列、收集本帧要 tick 的车,然后对所有车做一次并行的预移动更新,再做限速、定步进,最后做插值。
  • 定步进是核心:固定 60Hz(每步 1/60 秒),用一个累加器预算出本帧要走几步,先更新流式卸载车的 autopilot(直接把目标变换写到存根上),再对车辆数组做 parallel-for,每车在内层循环里走预算出的步数。每一步内部是”预解算 job → 悬挂预解算 → 自定义求解器 Solve → 自定义积分器 IntegrateMotion → 后解算”。
  • 后物理桶:并行做移动后更新,收尾时统一刷回调。

确定性靠两个”更新标志”撑着:一个让 tick 和配置库热重载互斥(tick 期间禁止 reload),一个让注册容器和读取互斥。注册本身走无锁队列在前物理桶统一刷入一个有序数组,容量有静态上限、溢出直接 fatal。

车的”感知”里藏着一个特别能说明问题的机制:平滑接触合成。轮胎和地面的接触不是每帧直接拿物理射线的命中,而是经过一层临界阻尼平滑——维护一个 0 到 1 的”接触可信度”。命中时可信度往 1 走,没命中时往 0 衰减;而只要可信度还大于一个小阈值,即使射线这一帧什么都没打到,它也会在轮子完全下垂处合成一个接触点、法线强制朝车体上方。这个可信度直接乘进轮载,参与所有轮力计算。它的目的是:短暂腾空或路面有缝时不让悬挂力瞬断,避免抖动和弹跳。代价是——轮胎”听见”的地面,未必是它这一帧真”踩到”的地面:下游的音频和特效看到的可能是这个合成接触,而不是真实命中。绝大多数时候这让手感更顺滑,但它是一类经典的”表现与物理不同源”。还有个相关细节:AI 车用射线、玩家车用扫掠(sweep)——因为玩家车要更精确的接触感,AI 车要更便宜。悬挂查询本身还为摩托做了侧壁修正(倾斜的摩托轮用半圆柱扫掠会”显得”抬起来,要把轮子按修正量压回去)、为路缘做了”极限压缩事件”的钳制(60Hz 下每帧一次扫掠想支持上路缘,必须区分路缘撞击和跳跃落地、夹住病态的压缩突变)。这些都是”手感”藏在物理细节里的地方,重建时低估了就会丢质感。

碰撞接触是另一处要照搬语义的地方。系统在一把共享锁下遍历物理接触,把物理 actor 映射回底盘组件。两车相撞时,对在交通里的车触发”交通撞击”反应,再转发接触给对象层。对象层处理接触时有个关键操作:按质量归一化冲量——用物理冲量除以车的物理质量,防止那些以运动学方式驱动的大质量车(autopilot 车质量可能被设得很大)一撞就把其他车辆弹飞。交通 NPC 的撞击反应是一个”眩晕”状态:超过按车记录算出的阈值就按力度插值眩晕时长、排队一个眩晕事件让车停下当前动作,低于上阈且非玩家驾驶则触发延迟鸣笛。这些数字化的阈值和反应,是”城市车流被撞后像真的”的来源。

载具的另一条关键边界是 autopilot(AI 驾驶)是运动学的,不是物理的。玩家开车走物理(刚体 + 悬挂 + 求解器),AI 开车则完全没有任何力、扭矩、轮模拟:进入 autopilot 会显式关掉物理,然后每步直接把姿态”设”上去。AI 沿样条行驶时,本质上只是积分一个”弧长标量 + 速度”再转成变换。写进刚体的速度只是装饰性的,喂给音频和特效,不参与积分。

autopilot 由一个门面委托给三选一的策略,理解这三种策略就理解了一座城里所有 AI 车的运动来源:

  • 交通槽:crowd 交通用。车被吸附到交通槽给出的位置和朝向上,跟着车道流走。
  • 样条槽:脚本和任务行驶的主力。它就是”沿一条限速样条移动的一个点”,用一个独立的 60Hz 固定子步累加器积分一维弧长,目标速度来自样条上当前位置的限速;朝向用前后轴两个采样点算(轴距感知)。它还有一个”保持距离”模式做车队/橡皮筋跟随:投影伴随车的位置、近距离阻尼、前方一定距离探测转弯提前减速。激活时会把整条路径离散成一串碰撞盘注册给交通系统,让别的车避让。
  • 跟随物体:最简单,将一辆车固定在另一物体的局部坐标偏移处,用指数平滑追赶。护航/僚机用。

而真正把目标变换”设”到车上时,分两种宿主:完整载具走一条”算目标变换 → 可选地做玩家↔AI 控制权插值 → 贴合路面 → 关物理 → 强制设变换”的路;流式卸载、只剩存根的车,则走另一条把同样的数学结果写到存根上的路。同一套运动数学,两个宿主——这是后面 UE 重建最该照搬的结构。

物理的开关本身不是一个 bool,而是一个原因位掩码:交通、调试、爆炸、autopilot、休眠、”流式初始即睡”、”周围流式卸载”、任务过场、调试 gizmo、”等待流送”……十来个原因,任意一个置位就关物理,全部清零才开。车出生即”流式初始即睡”。这个设计的意义在于:关物理的原因可以叠加——一辆车可能同时因为”在交通里”和”任务接管”而关物理,必须两个原因都清掉才恢复。用单个 bool 表达会丢掉这种叠加,导致”任务结束了车却不动(因为还在交通态)”这类 bug。

// 重建工程:物理禁用原因位掩码(设计),而非单 bool
UENUM(meta=(Bitflags))
enum class ECyberVehiclePhysicsDisableReason : uint32
{
    None             = 0,
    Traffic          = 1 << 0,
    Autopilot        = 1 << 1,
    Asleep           = 1 << 2,
    StreamInitAsleep = 1 << 3,   // 出生即睡
    StuffStreamedOut = 1 << 4,
    QuestOrScene     = 1 << 5,
    Debug            = 1 << 6,
    // ...
};
// IsPhysicsEnabled() == (DisabledReasons == 0)
// 进 autopilot:Set(Autopilot);任务接管:Set(QuestOrScene);
// 必须所有相关原因都 Clear 后才会重新跑物理。

持久化也分层:系统级存得极少,真正的逐车状态(autopilot 进度、轮子视觉姿态、毁坏栅格、电台、任务强制变换)下放到每辆车的持久化组件,由反射框架序列化。轮子的持久化状态用一个”上一帧”哨兵值(极小负数)来判断”有没有有效的上一帧”,让阻尼计算和中途存档都能工作。这个细节很关键:每帧的轮子运行时状态(接触材质、视觉位移、弹簧压缩、防倾杆位移、阻尼力)都会被实时写回持久化,所以”持久化里的上一帧”既服务于阻尼器的差分计算,也服务于”玩家在车飞起来那一刻存档、读档后车的姿态完全一致”。

时序走查:一帧之内,一城车怎么并行地走完固定步

载具 tick 是全系统并行度最高的地方,把它摊开能看清”确定性”和”并行”是怎么同时拿到的:

  1. 前物理桶开头先抢一个”禁止热重载”的独占标志——整个 tick 期间配置库不能 reload,保证每辆车看到的参数一致。
  2. 串行做几件准备:处理车库(召唤/回收)、把无锁注册队列里的请求刷进有序车辆数组、收集本帧要 tick 的车。
  3. 对所有车并行做一次”预移动”更新(parallel-for)。
  4. 算固定步预算:把本帧 dt 累加进一个累加器,按固定步长(1/60 秒)算出这一帧要走几个整步——可能 0 步(帧太快)、可能 2~3 步(帧太慢)。
  5. 先更新流式卸载车的 autopilot:这些车没有完整实体,只有存根,直接把 autopilot 算出的目标变换写到存根上——这是运动学路径,不碰物理。
  6. 对所有车并行做固定步(parallel-for):每辆车在内层循环里走预算出的步数,每一步是”预解算 → 悬挂预解算 → 收集轮接触喂自定义求解器 Solve → 自定义积分器 IntegrateMotion → 后解算”。
  7. 再对所有车并行做插值:把 60Hz 固定步的结果,按累加器余量插值到当前渲染帧——这样物理跑 60Hz、画面跑任意帧率,车看起来依然顺滑。
  8. 后物理桶并行做”移动后”更新,收尾时统一刷回调、释放热重载标志、处理人群碰撞。

整条链里,”确定性”来自固定步长 + 累加器 + 热重载互斥,”并行”来自三处 parallel-for over 车辆数组 + 无锁注册队列。两者不矛盾,是因为所有跨车的共享状态修改都被收进了串行的准备段,并行段里每辆车只碰自己。这正是 UE 重建最容易丢的东西——一旦把车的更新散进各 actor 的 Tick(),可变 dt 会毁掉固定步的确定性,actor 间的隐式依赖会毁掉并行安全。

② 相对 UE5.8 原生的优势

载具这章的优势对比,也得拆成”物理”和”交通”两半,成色差很大。

物理这半,UE5.8 给得很足:ChaosVehiclesPluginChaosModularVehicle 是完整的车辆物理 SDK,玩家车的悬挂、传动、轮胎模拟都现成。原版用的是自定义刚体 + 射线悬挂(不走 PhysX/Chaos 车辆 SDK),它强过 UE5.8 开箱的地方在四点:

  • 玩家=物理 / AI=运动学的同车互斥。 这是最大的优势——上千辆交通车不运行物理,只积分”弧长+速度”。如果照 UE5.8 的直觉给每辆车都挂 Chaos Vehicle,会直接压垮帧率。原版这套分流是规模化的前提。
  • 平滑接触合成。 没命中也合成接触点、按时间平滑接触可信度。Chaos 默认”轮子离地即零力”过路缘会抖,这层是手感天花板,UE5.8 不给。
  • 物理开关是可叠加的原因位掩码。 UE5.8 至多只能用一个 bool 或 SetSimulatePhysics,无法表达”同时因交通和任务而关物理,需两个原因都清除才恢复”。
  • 固定 60Hz 子步 + 并行车列表 + 热重载互斥的确定性。 UE5.8 的 actor Tick 是可变 dt,散在各 actor 上会丢确定性和并行安全。

交通这半,前面已经讲过:UE5.8 没有统一内置的城市交通调度系统(ZoneGraph 只给车道图结构、MassTraffic 在 City Sample/实验示例里,标准引擎不随发,需自建或移植)。所以这半的”优势对比”是”有没有统一方案”的问题,而不是”谁更好”的问题。

③ 移植到 UE5.8 的方法:载具物理基于 Chaos,交通调度需移植/自建

这一章必须拆成两半,因为两半的引擎现成度天差地别。

载具物理:基于 Chaos,但仅用于玩家车。 引擎里有 ChaosVehiclesPluginChaosModularVehicle(Experimental)。但有三条边界:

  1. 玩家=物理、AI=运动学,二者在同一类车上互斥。 这是最重要的方向性判断:不要用 Chaos Vehicle 去跑 AI 车。AI 车应该是运动学组件(Mass 或 UMoverComponent),沿 ZoneGraph 车道或样条积分弧长+速度再设姿态;Chaos 只用于玩家车。原版里 autopilot 显式关物理再每步设姿态,就是这个意思。让上千辆交通车都运行 Chaos 物理会带来严重的性能问题。
  1. 物理开关是位掩码,不是 bool。 必须照搬那十来个”禁用原因”的语义,尤其”流式初始即睡””交通””任务过场”的叠加。UE 里对应”何时让车变 kinematic、何时退出物理模拟”的状态机——单 bool 会丢掉流式/交通/任务的叠加语义。
  1. “平滑接触合成”是手感天花板,必须复刻。 Chaos 轮子默认”离地即零力”会抖。要在自定义悬挂或 Chaos 轮力之上叠一层时间平滑的”接触可信度”——没命中时按衰减时间维持一个递减的可信度,在完全下垂处合成接触点,乘进轮载。这层不复刻,AI 车和玩家车过路缘、过坑时都会跳。

车流/交通调度:没有统一内置方案,这是全文唯一需要移植或自建的部分。

经实测,标准引擎里没有 MassTraffic——它是官方城市演示项目(City Sample)专属的插件,不随引擎分发。所以”车流/人流的路网调度”在 UE5 标准引擎里没有统一内置的调度系统:ZoneGraph 给了车道图结构,但调度层(占用/红绿灯/死路/全局寻路切片)缺位。重建只有两条路:

  • 移植:从官方城市演示项目把 MassTraffic 整套搬过来。它确实用 Mass + ZoneGraph 解决了”一城车流”的问题,架构上和原版的”交通车道 + 交通槽 + 全局寻路”高度对应。但它是一个庞大、耦合演示内容的插件,移植要剥离演示资产、对齐到本工程的 Mass schema 和 ZoneGraph 数据,工作量不小。
  • 自建:基于引擎现成的 ZoneGraph(提供车道、交叉口的图结构)自己写交通调度——车道占用、交通槽、红绿灯、死路检测、全局路径切片续算。原版那套机制(车道注册跨 job、静态碰撞抽取注入、交叉口监听、死路事件驱动批处理、全局寻路 2 毫秒预算切片)可以作为自建的蓝图。

无论哪条路,下面这层映射是确定的,可以先写出来:

// 重建工程:交通槽 ≈ ZoneGraph 车道点;autopilot 样条槽 ≈ 脚本 spline
// 二者最终都归结为"产出目标 Transform 后运动学设置"
USTRUCT()
struct FCyberTrafficSlot
{
    GENERATED_BODY()
    FZoneGraphLaneHandle Lane;          // ZoneGraph 车道句柄
    float                DistanceAlongLane = 0.f;
    FVector              Position = FVector::ZeroVector;
    FVector              Forward  = FVector::ForwardVector;
    // 小 POD 音频数据盖进交通槽,由轻量交通路径播放,
    // 完整音频组件在 LOD 提升时接管并翻 bCanPlaySounds=false 防双播
    bool                 bCanPlaySounds = true;
};

还有一条贯穿载具全系统的二元性必须照搬:存根与完整 actor 的双重性。同一个 autopilot 既能驱动完整载具,也能只驱动一个轻量存根(流式卸载时把目标变换写到存根上)。交通音频靠把一个小 POD(交通元数据名、实体 ID、能否播声、鸣笛请求)盖进交通槽,由轻量交通路径播放;完整音频组件在 LOD 提升时接管,并把”能否播声”翻成假防止双播。UE 里对应 Mass fragment(卸载态)↔ 完整 Pawn(载入态),运动和音频的数学要放进一个共享组件,让两端都能跑。这正是 ZoneGraph 交通重建的核心。

还有一个容易被忽略、但对手感至关重要的边界:玩家与 AI 之间的控制权移交,是要插值的,不是瞬切的。当一辆交通车被玩家上车接管、或玩家下车交还给 AI 时,如果直接将控制权切换过去,车会产生跳变。原版为此专门做了一套控制权移交插值——一组缓动曲线,把姿态从 AI 算出的目标平滑过渡到玩家物理、或反向。它还带一个安全阀:如果两端差距超过某个瞬移幅度阈值(例如流式传送导致车瞬间移动很远),就直接切断插值、硬切过去,避免插值出异常的长轨迹。UE 重建时,这套移交插值要落在”运动学组件 ↔ Chaos 物理”的切换点上,是玩家上下车手感的关键一环,遗漏就会产生”上车卡顿、下车跳变”的粗糙观感。

最后是确定性的三根支柱:延迟注册(无锁队列在前物理统一刷入有序数组、静态预算溢出 fatal)、固定 60Hz 子步(累加器预算步数 + parallel-for)、tick 内禁热重载(两个更新标志保证容器与配置库热重载互斥)。UE 重建若把这些散进各 actor 的可变 dt Tick,会丢确定性和并行安全——应集中在一个 world subsystem 里做固定步 + 并行车列表。

// 重建工程:载具运行时集中固定步(设计骨架)
void UCyberVehicleRuntimeSubsystem::TickFixedStep(float Dt)
{
    FScopedReloadGuard NoReload(ReloadFlag);        // tick 期禁热重载
    DrainRegistrationQueue();                        // 无锁队列 → 有序车列表
    GatherVehiclesToTick();
    ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->PreMove(Dt); });

    Accumulator += Dt;
    int32 Steps = 0;
    while (Accumulator > FixedStep) { Accumulator -= FixedStep; ++Steps; }   // 1/60
    UpdateStreamedOutAutopilots(Steps);              // 只剩存根的车走运动学
    if (Steps > 0)
        ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->FixedSteps(Steps, FixedStep); });

    ParallelFor(Vehicles.Num(), [&](int32 i){ Vehicles[i]->Interpolate(Accumulator / FixedStep); });
}

八、缝进同一帧:跨系统的就绪门

跨系统就绪门:生产者→门→消费者单向网络
图 10 · 跨系统就绪门:生产者→门→消费者单向网络

人、车、人群、智能对象——这些不是孤岛,它们在一帧里靠接口互相借力,而且方向很清晰。

① 原版设计

原版里这些系统的协作方向是单向、清晰的:NPC 的”开车/移动”动作通过社区系统拿到人群系统的句柄,去做交通的加入/离开/完成;载具系统通过人口系统加减”车存根”,并借人群系统拿交通槽;场景、任务、交互系统则统一把这些当”可解析的实体”来等——等的是一个就绪子集,而不是裸的”生成成功”。

三大系统不互相拥有,靠存根和实体 ID 这个共同主键协作。人与车之所以不冲突,靠的不是某个全局调度器,而是各系统都遵循”在正确的门后等待”的约定——任务系统等”可玩就绪”,而非在”生成受理”的瞬间就去引用一个尚未就绪的 NPC。

② 相对 UE5.8 原生的优势

UE5.8 给的”就绪”信号是粗的:World Partition 的 streaming 完成是 cell 级的布尔,Subsystem 机制有,但没有”组合就绪门”这种约定——它不会表明”这片区域的资源就绪、prefab 就绪、预取也已确认,因此可玩”,它只表明”cell 加载完成”。

原版这套设计的优势,是把”就绪”做成了组合状态 + 单向依赖的契约

  • 组合就绪门。 区域可玩不是一个 bool,而是列出”我满足了哪些子状态(资源/挂载/扇区/节点/预取)、跳过了哪些”。消费者据此能精确地等到真正可玩。
  • 消费者只依赖门,不读彼此内部。 人口等”区域就绪门”、交通等”车道就绪门”、AI 等”实体可玩门”——单向依赖,谁也不碰谁的内部实现。

这正好堵住 UE5.8 最容易踩的陷阱:把”World Partition cell 加载完”直接当”区域可玩”。这个陷阱正是导言那句”让还没注册的 NPC 开一辆没就位的车撞上没流送的墙”的根源。UE5.8 给了 Subsystem 这个载体,但”组合 gate + 单向契约”这套架构纪律要自己立。

③ 移植到 UE5.8 的方法:把就绪门做成接口

重建工程把这套协作固化成了一组等待门(wait gate)接口,声明在共享的核心插件里,是各系统之间唯一的依赖通道:

// 跨模块等待门(设计):消费者只依赖门,不依赖彼此的内部实现
| 门                         | 生产者                | 消费者                          |
| DataRecordReady           | CyberDataRuntime      | world/entity/quest/interaction  |
| GameplayAreaReady         | CyberWorldRuntime     | entity/quest/interaction/AI/人口 |
| EntityGameplayReady       | CyberEntityRuntime    | quest/interaction/AI/save       |
| TrafficLaneReady          | CyberVehicleRuntime   | population/AI                    |

这背后是迁移合同里一条不可谈判的规则:一个模块只能依赖更早的契约状态或显式接口,不能依赖另一个模块的内部实现细节。世界区域就绪不是 World Partition 那个”加载完了”的 bool,而是一个组合状态——它必须列出自己满足了哪些子状态:

// 重建工程已落地:组合的世界区域就绪门
USTRUCT(BlueprintType)
struct FCyberGameplayAreaGate
{
    GENERATED_BODY()
    FCyberWorldAreaId       AreaId;
    FCyberWorldReadinessState Readiness;          // 资源/挂载/扇区/节点/预取各一个状态位
    ECyberRuntimeStatus     OverallStatus = ECyberRuntimeStatus::Unknown;
    TArray<FName>           SatisfiedSubStates;   // 明确列出满足了哪些
    TArray<FName>           SkippedSubStates;     // 以及跳过了哪些
    TArray<FString>         Warnings;
};

消费方的代码因此长这样——它等门,而不是直接读别人的内部状态

// 重建工程:人口系统等"区域就绪门"才开始造存根(设计)
void UCyberPopulationRuntimeSubsystem::OnAreaGateChanged(const FCyberGameplayAreaGate& Gate)
{
    if (Gate.OverallStatus != ECyberRuntimeStatus::GameplayReady)
        return;   // 门没开,绝不抢跑——哪怕 World Partition 说"加载完了"

    // 门开了,且明确知道满足了哪些子状态,才开始在这个区域编排人口
    BeginPopulatingArea(Gate.AreaId, Gate.SatisfiedSubStates);
}

注意它没有去问 World Partition”这个 cell 加载完了吗”。区域就绪是一个组合状态,可能 World Partition 的 cell 加载完了,但任务要求的 prefab 还没就位、预取快照还没确认——这时 OverallStatus 就不该是 GameplayReady。把”WP 加载完”直接当”区域可玩”,正是导言里那个”撞上还没流送进来的墙”的根源。

人口系统在区域就绪门后面才开始造存根,交通系统在车道就绪后才注册车流,AI 在实体可玩就绪后才接管——每一道门都是可观察的独立信号。这一章不长,但它是把前面几个系统章缝成一个能跑的整体的关键:系统之间的契约,就是这一串老老实实分级的就绪状态。


九、用 LLM 驱动这座城市:从数据驱动到语言驱动

用 LLM 驱动城市:三时间层 + 导演架构 + 决策分级状态
图 11 · 用 LLM 驱动城市:三时间层 + 导演架构 + 决策分级状态

到这里,城市能自己运转了,但它的内容与决策仍是预先写好的:NPC 的日程在配置库里、行为在行为树里、对话在任务图里、突发事件由刷怪导演按规则触发。这是数据驱动的城市——丰富,但有限,且每一处都要人去填。本系列叫”AI 原生游戏开发”,这一章就回答那个真正前瞻的问题:如果让 LLM 来驱动这座城市,该怎么做?

先给结论,因为它决定了整章的架构:LLM 是导演(planner / director),不是控制器(controller)。 它产出”高层意图”,由前面那套传统系统去执行。把 LLM 塞进每帧的移动/物理/避障是方向性的错误(延迟、成本、不确定性三重不可接受);把 LLM 放在”决定 NPC 想做什么、城市该发生什么”这一层,才是它的位置。

① 把驱动力换成 LLM:三个时间层

第一章说过,城市由”相位调度 + 数据 + 事件 + 预算”驱动。LLM 驱动不是推翻这套底座,也不是把 LLM 变成一个跑在运行时的 AI 系统,而是让它参与”内容层”的生成与”决策层”的高层编排——它是规划与内容创作层(planning + authoring),不接管每帧的执行。按时间尺度分三层,LLM 在每层的角色完全不同:

时间尺度谁来做LLM 的角色
快层每帧(约 16ms)传统 code绝不。移动、物理、避障、动画、寻路执行
中层亚秒~秒,运行时异步传统 + LLMLLM 产决策/反应/对话,异步,结构化输出
慢层秒~分钟,离线/预生成LLMLLM 产内容:背景、日程、任务大纲、对话池
  • 慢层(离线生成内容)——最稳妥、最该先上。 用 LLM 离线生成 NPC 的背景故事、一天的日程、性格标签、任务大纲、对话候选池,然后落地成第一章那个配置库的记录。注意:这一层本质上还是数据驱动——只不过数据的作者从策划变成了 LLM。运行时一行 LLM 都不调,零延迟、零运行时成本、完全可控。一座城市的”密度”和”多样性”可以靠它放大一个量级,而工程上几乎没有新风险。
  • 中层(运行时异步决策)——LLM 驱动的核心,也最难。 NPC 遇到一个预设之外的情境(玩家做出预期之外的举动、两个 NPC 的日程冲突、突发事件发生),把当前世界事实 + 这个 NPC 的人设 + 一组合法的可选动作提交给 LLM,LLM 返回一个结构化的决策(选哪个动作、对谁说什么)。动态对话也在这层。它必须异步——一次调用几百毫秒到几秒,绝不能阻塞游戏线程。
  • 快层(每帧执行)——LLM 永远不碰。 NPC 决定”去酒吧找某人”,这是 LLM 产的意图;但”怎么走过去、避开车流、播放走路动画”,全是前面那套寻路(第六章)/移动/动画系统干的。LLM 给目标,引擎给过程。

② 关键洞察:LLM 调用就是又一个”受理≠完成”的异步边界

这是把前八章和 LLM 缝起来的关键。回看导言那条贯穿全文的纪律——受理≠完成、状态分级、绝不把分级状态压成一个布尔值——它原封不动地适用于 LLM 调用。一次 LLM 请求,和一次 spawn token、一次寻路、一次工作点命令,是同一种东西:一个有延迟的、可能失败的、分阶段的异步操作。

所以 LLM 决策的状态,也该是分级的:

UENUM()
enum class ECyberLlmDecisionStatus : uint8
{
    Requested,     // 请求已组装并提交,但模型还没开始
    Streaming,     // 模型在产出 token
    Parsed,        // 拿到结构化输出,但还没校验
    Validated,     // 校验通过(动作合法、目标存在)
    Applied,       // 决策已写回命令队列/事实总线
    Rejected,      // 校验失败(幻觉/非法动作)→ 走降级
    TimedOut       // 超时 → 走降级
};

“LLM 返回了”不等于”决策可用”(可能是幻觉),更不等于”决策已执行”。中间隔着校验和映射两道关。这套状态机,和第二章的实体生命周期、第六章的寻路状态位是同构的——LLM 没有打破这座城市的工程纪律,它只是又一个挂在驱动底座上的异步生产者。 这也是为什么把城市先做成”分级状态 + 事件总线 + 预算化”的样子,本身就是在为 LLM 驱动铺路。

③ 架构:LLM 作导演,工具作执行端,事实总线作感知输入

把 LLM 接进前面那套底座,有三个接口已经现成了,这也是前八章的设计对 LLM 友好的原因:

1. 工具化(tool use):把城市的能力暴露成 LLM 的工具。 前面每个系统的”动作”——寻路到某处、播放一个工作点、加入交通、占用一个智能对象、写一条事实、推进一段任务——都包装成 LLM 可调用的 tool,带结构化参数。LLM 不直接操纵世界,它调用工具,引擎执行。关键在于:工具的参数被约束到合法集合(目标只能从”附近已知地点”里选、动作只能从”这个 NPC 当前可做的”里选)——这是治幻觉的根本手段,LLM 没法”走到一个不存在的酒吧”,因为工具的枚举里压根没有它。

// LLM 导演的工具(设计):每个都映射到前面某个系统的现成能力
// MoveTo(targetId)       → 寻路系统(第六章)
// UseSmartObject(soId)   → 智能对象/工作点(第五章)
// JoinTraffic(laneId)    → 交通(第七章)
// SetFact(factId, value) → 事实总线(第一章)
// Say(targetId, line)    → 对话/UI
// targetId / soId / laneId 全部来自"当前合法集合",LLM 选不出集合外的值

2. 事实总线 ↔ LLM 上下文:FactsDB 就是 LLM 的世界输入。 LLM 做决策要”看见世界”,而它看见的,正是第一章那个事实库——当前时间、天气、这个 NPC 知道的事、附近发生了什么、它和玩家的关系。把相关事实序列化成 LLM 的上下文(prompt),LLM 的决策再写回事实/命令队列。事实总线天然就是 LLM 的”感知输入 + 记忆”。NPC 的记忆一致性(它记得玩家昨天攻击过它)靠事实的持久化(存档)保证——这又复用了前面的存档分级,不必为 LLM 另造一套记忆。

3. 预算化 + 缓存 + 降级:LLM 调用要像智能对象解析一样被调度。 LLM 调用贵(token 成本 + 延迟),所以它必须预算化,逻辑和第五章的智能对象解析一模一样:

  • 空间局部化: 只有玩家附近、剧情相关的 NPC 才调 LLM 做决策;远处的 NPC 用便宜的规则/行为树(就像第三章远景人群点不跑完整 AI)。这是把”成本即距离”直接搬到 LLM 上。
  • 预算: 每帧/每秒的 LLM 调用数有上限(从第一章那个 FCyberFrameBudget 领),超了排队,就像 spawn queue 限流。
  • 缓存: 相同情境的决策缓存复用(同一类 NPC 遇到同一类事,不必每次都问模型);prompt 里不变的部分(世界规则、人设)用 prompt 缓存,省 token 也省延迟。
  • 降级链: LLM 超时/失败/产出非法 → 回退到传统行为树或默认日程。这就是第五章”缺动画降级链”的翻版——LLM 不可用时,城市退化成数据驱动的城市,而不是卡死。 而且 NPC 在 LLM “思考”的那几百毫秒里,先用一个占位行为(继续步行、播放 idle),决策返回后再切换——延迟被行为掩盖,正如工作点缺动画时的驻留。

4. 一个全局导演(Director)。 除了每个 NPC 的局部决策,再设一个全局 LLM 导演,类比传统的刷怪导演——它按当前节奏(玩家正在做什么、上一次戏剧高潮过去多久)决定”何时为城市增添新事件”:触发一桩街头事件、让某个帮派行动、在玩家附近安排一次偶遇。导演不操纵细节,它往事实总线写一条”该发生某事了”,由具体系统去落地。这是”让城市有叙事呼吸感”的 AI 原生做法。

④ 用 Claude 具体怎么接

落到实现,中层决策的一次调用长这样(异步、结构化输出、prompt 缓存):

// 重建工程:LLM 导演子系统(设计骨架)
void UCyberLlmDirectorSubsystem::RequestDecision(FCyberEntityId Npc)
{
    FCyberLlmRequest Req;
    Req.System   = BuildPersonaPrompt(Npc);          // 人设 + 世界规则,可 prompt-cache
    Req.Context  = SerializeRelevantFacts(Npc);      // 事实总线 → 世界状态
    Req.Tools    = BuildLegalToolset(Npc);           // 合法动作集合(治幻觉)
    Req.Budget   = FrameBudget.Acquire(FrameBudget.LlmDecisionSeconds, /*want*/0.0);
    Decisions.Add(Npc, ECyberLlmDecisionStatus::Requested);

    AsyncLlmCall(Req, [this, Npc](FCyberLlmResult R)  // 异步,不阻塞游戏线程
    {
        // 回到游戏线程:Parsed → Validated → Applied,逐级推进
        if (!Validate(R)) { Fallback(Npc); return; }  // 非法/幻觉 → 降级
        ApplyToCommandQueue(Npc, R.ToolCalls);        // 映射到命令队列/任务图
    });
}

几个 Claude 侧的要点:

  • 结构化输出走 tool use:让模型必须调用预定义的 tool,而不是自由文本——输出天然结构化、天然被约束到合法集合,校验在 tool-call 层就完成大半。
  • prompt caching:世界规则、NPC 人设这些每次都一样的长前缀,用 prompt 缓存。这对”一城 NPC 共享同一套世界设定”是巨大的节省——缓存命中后,延迟和成本都大幅下降。
  • 模型分级:局部 NPC 的小决策用快模型(如 Haiku 级),全局导演的大编排用强模型(如 Opus 级)——又是一次”成本分层”,和人群三层、AI LOD 同一种思路。
  • 延迟预算:中层决策的延迟必须小于玩家能感知的反应窗口;掩盖不了的,就移到慢层去预生成。

⑤ 边界与坑(都和前面同构)

  • 幻觉 → 用 tool use + 合法集合约束,模型选不出不存在的东西;关键决策再加一道校验(第八章的就绪门思路:决策要过”合法性门”才 Applied)。
  • 一致性/记忆 → 事实总线持久化,NPC 的记忆是事实,不是 prompt 里的临时文本。
  • 成本/延迟 → 预算化 + 缓存 + 空间局部化 + 模型分级,全是前面调度纪律的复用。
  • 不确定性 → LLM 产意图、传统系统产执行,执行层确定;LLM 失败有降级链兜底。
  • 受理≠完成 → LLM 调用是异步边界,状态分级,绝不把”模型返回了”当”决策生效了”。

一句话收束这一章:LLM 不会替你重写这座城市的工程,它接在城市已经建好的驱动底座上——往数据库写内容、往事实总线写事实、通过工具调用现成系统、在预算和降级的纪律里被调度。 把城市做成”数据驱动 + 分级状态 + 事件总线 + 预算化”的样子,本身就是为 LLM 驱动铺好了路。AI 原生不是推倒重来,而是让这套底座的内容创作与高层决策编排从人写,变成模型写——而每帧的执行层、纪律层,一行都不用改。换句话说,LLM 上移到规划与创作层,不下沉到运行时控制层。


十、迁移的取舍:标准优先,高级系统是规模化的升格

迁移升格路线表与三档引擎成色
图 12 · 迁移升格路线表与三档引擎成色

最后回到方法论。重建这样一座城市,最容易犯的战略错误是一开始就动用高级系统——还没把玩法语义跑通,就先纠结 MassEntity 的 archetype 怎么划分、ZoneGraph 的数据怎么烘焙。重建工程的适配策略把顺序固定了下来:

每个系统按这个优先级选实现:① UE 标准特性 → ② UE 高级系统 → ③ 项目插件适配层 → ④ 编辑器/commandlet 工具 → ⑤ 引擎源码改动(仅最后阶段)。

落到本文这几个系统,就是一张”先标准、后升格”的路线表,每一格都标着”什么时候才该升级武器”:

系统先用(标准/已有)后升格(高级)升格触发条件
驱动核tick groups + DataTable + 消息子系统Mass 相位图 + DataRegistry + 自建调度/预算系统数与规模上升
实体生命周期Actor/Component + WorldSubsystem自定义 ECS / Mass实体规模逼近 Actor 上限
人口生成区 + 轻量 actor自建 DOD 运行时(参考原版,本工程取此)/ 或 MassEntity/MassCrowd规模上来后升格
NPC 行为AIController + BehaviorTreeStateTree / 自定义 plannerBT 无法表达所需状态
智能对象组件 + trace + SmartObjectsStateTree 深度集成交互复杂度上升
寻路Recast/Detour(引擎原生)自建分级结果 + 缝合层多图缝合成为瓶颈
载具物理Chaos Vehicles(玩家车)自定义运动学(AI 车)一开始就要分流
交通调度ZoneGraph 自建 / 移植MassTraffic 全量车流规模要求 Mass
内容/决策驱动数据驱动(配置库 + 行为树 + 任务图)LLM 驱动(离线生成 → 运行时导演)要”活的””会即兴”的城市

这张表里藏着本文最诚实的一条结论:这些系统的引擎现成度,差异很大

  • 近乎原生提供的:实体生命周期(标准 Actor/Subsystem,且重建工程已落地可当模板)、寻路底层(UE 和原版同源,都是 Recast/Detour)。
  • 有强力现成骨架、但要补 gameplay 语义的:人群(MassEntity/MassCrowd 给三层 LOD——但本工程取”参考原版自建面向数据运行时”为主,Mass 为可选)、NPC 行为(BehaviorTree/StateTree 给行为框架)、智能对象(SmartObjects 插件给交互点)、载具物理(Chaos 给玩家车)。这些 UE 给了”形”,但原版真正值钱的”调度纪律”——预算化、分级状态、降级链、确定性子步——引擎不给,是主要工作量。

需要澄清一句,避免把全文读成”原版处处更强”:②优势对比谈的是”语义贴合度”,不是”优劣排名”。 恰恰有两处 UE5 是更强、而非更弱的不同范式——Chaos Vehicles 提供完整的车辆物理 SDK(原版是自定义刚体,各有取舍),StateTree 的共享资产 + 紧凑 instance data 比原版那套手写的实例布局更干净、更工程化。这两处迁移时应当顺势采用 UE 范式,而不是为了”照搬原版”去复刻一套更原始的实现。

  • 没有统一内置方案、需移植或自建的:车流/交通调度。标准引擎不随发 MassTraffic(它在 City Sample),ZoneGraph 只给车道图结构、不给调度层。这是全文唯一一处需要移植或从零自建的部分,工期要单独预留。

这张表的两端还各加了一格:最上面的驱动核(第一章)——它不是某个玩法系统,而是承载其余所有系统的相位/数据/事件/预算底座,决定了它们”为什么长成分阶段的样子”;最下面的内容/决策驱动(第九章)——它是这套架构的最终升格方向:当数据驱动的城市不够”活”时,把内容层和决策层从人写换成 LLM 写。两者一首一尾,正好框住中间七个系统:底座决定它们怎么运转,LLM 决定它们由谁来编写。

而无论哪一格,那条贯穿全文的脊柱都不变:把原版里每个系统的分级状态,在 UE5 侧原样保留,绝不把分级状态压成一个布尔值。聪明的行为、漂亮的车流可以慢慢长;但只要哪个系统偷偷把”我收到了”当成”我办好了”,这座重建的城市迟早会在某个路口,让一个还没注册进表的 NPC,开一辆物理还没就位的车,撞上一面导航还没流送进来的墙。

把全文的判断收成一句可执行的迁移顺序:先用标准 Actor/Subsystem 把分级状态的地基打牢(这是已验证的模板),再用 BehaviorTree/StateTree、SmartObjects、Recast/Detour、Chaos 这些引擎内置系统承接”有骨架”的那几个领域,将节省的工程投入集中到引擎不提供的两件事上——一是各系统的调度纪律(预算化、降级链、确定性子步、可见性优先级),二是交通这个唯一需从零自建或移植的系统。 顺序错了——例如一开始就着手 MassTraffic 移植、或先纠结 ZoneGraph 数据烘焙——就会在玩法语义尚未跑通时陷入引擎细节,这正是大型迁移最常见的失败方式。

再往上抽一层,这套方法论其实不止适用于这一座城市。任何把成熟自研引擎的运行时搬到通用引擎的迁移,都会面对同一组问题:哪些是引擎原生提供的算法(照用)、哪些是引擎给了框架但需自行补全的语义(承接再补)、哪些是引擎完全没有的 gameplay 策略(自建)。能把这三档分清楚、并且在每一档里都守住”不把分级状态压成一个布尔值”这条纪律,迁移就完成了一半。剩下的一半,是耐心——耐心地为每一个”看似已完成”的回调,在新引擎里单独建一个状态位。

迁移一座会自己运转的城市,要照搬的从来不是某个精巧的算法,而是这套毫不取巧的纪律


AI 协作复盘

  • AI 帮了什么:派出多个并行代理对原版的人口、NPC 行为(含传统/新两套行为框架、命令队列、信号、寻路、移动策略、AI tweak action、AI LOD)、智能对象与工作点、导航与交通、载具与悬挂五大系统做了源码级深读,逐条带回真实机制与边界(命令队列”留一格哨兵”、寻路”假异步+状态位组合判据”、平滑接触合成、人群三层 handoff、工作点缺动画降级链等);并把这些机制逐一映射到 UE5 的对应系统,区分”原生提供/有骨架需补语义/需自建”三档成色。
  • 哪需要人补位:①成色核实是人逼出来的——用户一句”你确定这些都有实现的参考?”挡下了”每章都有现成轮子”的乐观叙事,促成了对引擎插件的实测(确认 MassTraffic/独立 ChaosVehicles 命名等的真实状态),文章定位也因此从”实现讲解”诚实地改成”源码实证 + 重建设计蓝图”;②匿名化边界——原版的内部类名/文件行号只留在研究笔记里,正文一律用通用工程术语,不指纹化原作;③图、术语表、英文版按”中文先行→审→英文→发布”留作后续批次。
  • 怎么核验:每条机制结论都可回溯到对应系统的源码深读(带文件:行号),UE5 侧的”有/无现成系统”结论来自对引擎插件目录的实测(哪些 .uplugin 在、哪些不在),不靠记忆下结论。

发表评论

了解 AI Native Game Development 的更多信息

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

继续阅读