这篇是「如何制作一款开放世界游戏」系列的车辆主题下篇。上篇讨论调度与决策:车辆持有驾驶循环,ped 只提交意图,座位关系具有权威方,驾驶权支持仲裁与一致性校验,LOD 具有明确档位。本篇继续讨论意图如何转换为执行控制量(油门、刹车、转向)。车辆获得目标路口意图后,控制量经过哪些运行时层级生成?NPC 获得进入车辆的意图后,开门、入座、关门动作链如何组织并支持失败回退?降到 dummy 档的远距离车辆如何维持运动表现?驾驶意图又如何进入物理系统?
材料来自对一款成熟商业开放世界游戏引擎与客户端代码的源码级逆向阅读(约 700+ 篇笔记),并对照同一个 UE5 原型工程(VirtualWorld / VW)。每个子系统均按“原版机制 → 批处理边界 → 原型实现与规划状态”展开。
原型状态在各章节中分别标注:进出车的关系级编排、运动学预览和物理接缝已有代码与测试;驾驶执行栈,以及进出车的动画与物理叶子,仍属于原版设计和迁移规划。每章的第三段会明确说明实现状态。
诚实边界:各驾驶算法的内部几何(避让的转向评分、追击的漂移探测、限速塑形公式)、寻路内核、导航网格构建,都是逆向读不到的盲区,碰到一律标注。
UE5 实现实操在后续落地篇:本篇及本系列其他方案篇聚焦设计与迁移决策——原版机制、复用边界和接缝位置。对应的落地篇再讨论 UE5 工程中的代码走读、引擎子系统接入、问题定位与验证。
引言:从驾驶意图到执行控制量
上篇结束于驾驶权归属的一致性校验;本篇继续讨论指令产生与执行。AI 驾驶任务如何计算本帧控制量,是上篇标记为“尚未接入”的部分。本篇关注这条产生与执行指令的通路:原版侧呈现为四层执行栈及三类支援分支;原型侧则由关系编排、运动学预览和物理接缝组成的已实现部分,以及行为层预留接口构成。
本篇依次讨论四个问题。第一章说明原版如何将路径选择、局部接近与控制量生成拆分为四层,并由巡航、追击和警察围堵任务共享;第二章说明进出车动作链如何组织,以及失败时如何回退;第三章说明降档后,dummy 车辆如何通过低成本运动学推进替代完整物理求解;第四章说明驾驶意图与 Chaos 物理之间的适配边界。末尾给出迁移取舍清单。读者应能据此判断:驾驶控制量由哪些层级逐级生成;NPC 进入车辆失败时关系状态如何保持一致;远距离车辆每帧计算哪些状态;以及尚未接入物理的原型如何验证驾驶层边界。
贯穿本篇的主线只有一条:策略与执行分离。 上层重塑目标、下层生成控制量;关系归权威方、动作归执行链;意图归运行时、物理归引擎。每一章都是这条线的一次落地。
给「有已实现代码的那部分」先交代体量,建立预期:进出车编排三件套(进车 343 行 / 出车 226 行 / 换座 737 行)+ 驱动器(212 行)+ 槽执行器(1450 行),合计约三千行、198 条自动化测试;运动学预览和物理接缝合计再占数百行实现与 18 条测试。本篇讲「地图」的部分(执行栈)和讲「已实现代码」的部分(编排/预览/接缝)大约各占一半——这也是执行侧的真实现状:边界与编排部分已建立,具体行为实现尚未补齐。
一、驾驶执行栈:从路线规划到控制量生成的四层职责

这一章要解决的问题:车辆获得目标路口的驾驶意图后,到“转向角与纵向控制量生成”,需要经过哪些运行时层级?为什么巡航、追击、警察围堵表现为不同的行为,底层却可以共享同一套执行栈?

上篇讲了“车辆持有驾驶循环、ped 下发意图”这一所有权入口,但该循环具体执行哪些驾驶行为,留到了本章。本章讨论车辆如何在路网上行驶。它是本篇原版证据最充分的一章:结构清晰地分为四层,每层职责单一,上层重塑目标,下层生成控制量。
① 原版机制
原版的自动车驾驶不是单一的大函数,而是一个四层执行栈。每层承担单一职责,上层持续向下层提交“局部目标点 + 限速”。
第一层:路线所有者(route owner)。 一个名为「巡航」的任务(下称 CruiseNew)其实远不止「无目标巡航」——它是整个自动车的路线所有者。它拥有:寻路辅助的生命周期、路网节点列表与跟随路线的构建、路线重规划策略、变道请求与车道预约、指示灯/junction/限速区策略、以及在「路网 / 直线 / 导航网格」三种执行模式之间切换。它的状态机把这些完整列出:FindRoute → Cruise → StopForJunction → WaitBeforeMovingAgain → GoToStraightLine → GoToNavmesh → GetToRoadUsingNavmesh → Burnout → RevEngine → PauseForUnloadedNodes → Stop。单看这个状态列表就能读出很多信息:找路、巡航、为路口停、停后再走、直线回退、导航网格回退、烧胎、轰油门、等节点流式加载、彻底停下——一辆车城市道路行驶中的主要宏观状态都在这里。连「等节点流式加载」都是一个显式状态——路网是流式的这个事实,直接刻在了驾驶任务的状态机上,而不是用一次阻塞等待掩盖过去。
这一层里最关键的一个桥函数,是「找目标点并限速」——它把「完整路径 / 车道 / junction 上下文」这一大批信息,归并成一个立即目标点 + 一个封顶的巡航速度提交给下层。它综合了:路网节点元数据和限速区给的速度乘子、由当前速度 vs 期望速度算的前瞻距离、针对 junction 转弯/岔道/大车/挂车/窄路的特殊前瞻塑形、以及「如果子任务已经认为该停就复用停止态」。这就是为什么路线所有者即使生出一个下层子任务,它仍是真正的所有者——下层不选路网目标,是上层不断把重算出的局部目标提交给下层。 “持续提交动态重算的局部目标”这一模式,是整个四层栈的黏合层。
路线这一侧还有一个容易被忽略的第三方所有者:路线所有者拥有「生成和扩展路线」,但「在已建好的路线上做插值」归一个专门的跟随路线助手(FollowRouteHelper)——下层的 goto 叶子只消费被选出的局部目标点,既不搜路也不插值。于是路线的生命周期被切成三段:生成归所有者、插值归助手、消费归叶子。重规划的触发条件也在所有者层写得很明确:目标移动了、路线快耗尽了,才触发重搜;上一次的车道选择会作为下一次路线搜索的输入,使重规划继承当前车道上下文,避免产生蛇形变道。goto 特化还带自己的寻路旗标:按当前行驶方向汇入道路、等待流式节点——细到「搜出来的路线不能让车原地掉头去接」。
「去某点」是路线所有者的一个目标导向特化:它复用同一套路线/搜索/状态切换机器,只是从「开放式巡航」变成「奔着一个目标去」——目标无效就立刻退出、按到达距离决定切直线还是到达、可选用游荡回退代替直线回退。所以类层次是有意义的:巡航 = 通用路线所有者,去某点 = 目标导向的路线所有者。交通篇讲派遣时会看到,警车追捕用的就是「去某点」这一支。
第二层:局部执行器(带避让的去点)。 这是 AI 视角下真正的「自动车驾驶叶子」。它拥有短程行为:局部转向方向、局部障碍扫描、对交通/行人/门/物件的限速、三点掉头升级、短暂等待/刹车/迎面急打方向的分支。它的状态机是最有力的证据:GoToPoint → ThreePointTurn → WaitForTraffic → WaitForPed → TemporarilyBrake → Swerve。它把避让工作推进延迟处理,然后在延迟步里:缓存路网/跟随路线、算当前行驶方向 vs 目标朝向、严重时交给三点掉头、执行交通检查/行人/物件/路沿的遮挡测试、选一个转向方向、算一个安全限速、更新内层子任务。它还负责一批细节:junction 内的左转让行、异常车辆拒绝与对静止/玩家/警车的特殊处理、贴近跟车的跟车政策、对玩家行人从刹车到鸣笛到缓慢推挤的升级、迎面警告/急打方向、警笛驱动的环境交通反应、以及从上层变道逻辑提交下来的超车请求。但它不拥有完整路线搜索和长程目的地选择——那始终在上层。 这一层是「交通呈现复杂行为」的所在——车辆可以等待行人、绕行前方障碍物、会在死路上三点掉头,由该层负责。
第三层:最终控制写入者(去点自动车)。 这一层最深,它不理解交通或路线拓扑,它只做一件事:把「一个目标点 + 一个期望速度」变成裸控制——转向角、油门、刹车、手刹。它算出到当前局部目标的期望朝向、把期望速度 vs 当前前进速度映射成油门/刹车、为转向角/坡度/牵引损失/打滑而减油门、激进转弯可选用手刹、为保守司机或低速运动做「拟人化/平滑」、最后把值写回车。注意「拟人化/平滑」这一步——它是让 AI 开的车不像机器人的关键(权威实例不会瞬间打满方向、不会油门刹车骤变)。
于是整个控制栈的职责可以直接审计:路线所有者确定目标 → 避让叶子计算局部接近路径 → 去点任务生成控制量。
旁支:导航网格执行分支。 当路网不合适或无法到达,还有一个「走导航网格」的执行分支——它执行一次导航网格路线搜索、从结果构建跟随路线、然后仍旧通过同一套自动车避让/去点叶子来执行运动。所以自动车家族在一个所有者下有三种导航模式:路网路线、直线回退、导航网格回退/越野恢复。为什么要三种?因为城里绝大多数时候走路网,但车可能被开进没有路网的地方(草地、工地),这时得靠导航网格找回路。这个配方并不陌生:上篇讲门接近择路时也是三模式(直达/点路线/导航网格)——「主路径 + 直线回退 + 导航网格回退」是这套引擎处理「移动目标可达性」的标准配方,从接近车门这几米到开过半座城,同一配方换尺度复用。
旁支之二:支援机动任务族。 在核心三层旁边,还站着一族「窄而深」的支援任务,值得单独列出,因为它们展示了这套栈的组合能力:
- 三点掉头是唯一「越过 goto 直生成控制量」的本地恢复叶:从朝向误差/掉头语境/卡住状态判定要不要开始,前进相位和倒车相位交替(探测、超时、无移动量决定何时换向),多次失败后逐步放宽「不上人行道」的约束,朝向恢复够了就退出把控制还给上层。它自带一小层直生成控制量(转向钳制平滑、前进倒车的油门刹车选择、牵引与坡度感知)——恢复机动承受不起「提交目标点」的间接延迟,必须直接写入控制量,这是整个家族里对「间接控制原则」唯一的例外,而例外被限制在一个叶子里。
- 通用停车叶(Stop)是被所有停靠流程复用的「停住并保持停住」:写油门/刹车/手刹/回正,顺带更新车侧智能的「停着的理由」——连「为什么停着」都是状态,不是含糊的静止。
- 靠边停(PullOver)是四态编排(巡航找合适路段 → 朝着路缘点开 → 减速 → 停),选边逻辑综合车道拓扑/单行道/出租车与玩家启发,转向灯随选定的路缘侧持续更新——表现细节由拥有决策的层顺带负责。
- 泊车编排(Park)是十态的「机动程序」:导航到位 → 前进入位/倒车入位/退出车位/向前推进,每相位之间用停车叶做「结算检查点」,位置朝向达标就完成、不达标就转入下一个矫正相位、车位持续被堵就走
Failed。泊车不是一个算法,是一个会自我矫正的状态机程序。 - 可达接近(Approach)专为派遣服务:目标点不适合作为道路驾驶终点时(目标在越野处/楼里),持续追踪目标附近「最近的可达路网节点」,把接近目标重定向为「开到那个节点附近的随机可达位置」——「开到警报现场」在道路语义下的正确翻译是「开到现场旁最近的路上」,这层翻译有专门的任务负责。
这一族的共同点:除了三点掉头,全部通过组合下层(goto/避让/停车)完成移动,自己只拥有「何时、何地、按什么顺序」的编排——同一套三层核心,靠不同的窄编排组合出靠边停、泊车、下客、接近四种完全不同的产品行为。
旁支之三:临时动作族。 栈的最底部旁边还躺着一排限时冲量叶(等待/急刹/倒车/打轮/前冲/手刹……):共享一个极小的基类——存一个结束时间戳、默认不进网络同步、动作激活期间禁止这辆车降到最深的代理档(一辆正在急打方向躲避的车不能突然变成简化运动)——然后每个叶子就是「限时直写一小段控制策略,到时退出,顺带刷新『最近不卡』时间戳」。它们是继三点掉头之后第二处被允许直生成控制量的地方,同样被限制在「短暂、有限时、纠正性」的窄框里;整族只有急刹一个叶子被纳入脚本网络同步(它是最通用的可脚本化停车冲量)。这一排冲量叶回答的是“下一秒的运动响应”,和上面“分钟级目标”的路线层隔着整整一个栈——时间尺度也是分层依据:秒级反应直生成控制量,分钟级意图走目标提交。
把三条旁支放回主图,这套执行栈的间接控制原则就完整了:正常路径上,只有最底层的控制写入者碰踏板和方向盘;两个被明确圈起来的例外——三点掉头(恢复机动)和临时动作族(限时冲量)——都以「窄、短、有出口」为代价换取直写权。例外不可怕,没有边界的例外才可怕。
第四层:战术包装层。 警察行为、追击、并排——这些表现为差异极大的行为,其实都只是战术包装器,坐在上面不断重塑目标,真正的移动全下放给前三层:
- 警察行为看似文件很大,其实所有权很简单:它主要是选择执行哪个子任务(巡航/撞击/跟随/封堵),从目标有效性、通缉等级、车型、特勤特殊处理、警笛作弊策略里挑一个,然后委托给对应的专门任务。它自己不实现底层战术机动。它委托的那三个子任务,各自的「窄」也值得点一笔:跟随是个车型分派器(汽车/直升机/潜艇各一个状态),汽车分支只拥有「跟多远、何时算到」的尾随策略,目标一动就重写 goto 的目标点、同步更新避让缓存里的尾随偏移;撞击的状态机只有三态(开始/撞/结束),它做的是把 goto 的驾驶旗标调成「不避让目标」、追踪接触(记最近接触时间与次数)、撞上之后按调参间隔主动后撤而不是把目标明确约束——碰撞行为的攻击性,全在策略参数里,不在控制代码里;封堵最厚,是一个八态的战术选择器:进入 FSM 之前先在预处理里算好四件事(双车几何、异步地图探测、道路宽度与同路语境、该不该禁用急刹截停),再由一组「策略分支判定」的判定器选分支——其中「绕到前方」「回退追踪」两个分支仍是 goto 包装,只有三个直接执行叶(前方巡航压速:带前向碰撞探测、撞到地图几何就把目标点反射开;两相切断:先就位再急刹;横向往复堵截)才有自己的机动逻辑。即使是“警车逼停玩家”这类定制化程度较高的行为,拆到最后也是「选择器 + 三个窄叶 + 共用的 goto 底盘」。
- 追击不重新实现自动车运动,它坐在「去某点」之上,为追逐几何不断重写那个 goto:把主目标归一化(目标在开车就追车、骑乘就追坐骑、否则追人)、维护追击漂移/探测/视线辅助、在「绕到后方 / 追击 / 搜索」之间选(追得上就贴、追不上先占它的后方位、丢了视线转搜索——三态覆盖了追逐的全部宏观情形)、更新避让缓存让相关车理解共享的追击目标、用目标位置/直线距离/追击速度重写 goto 子任务。
- 并排比追击更窄:校验目标车/挂车替换/目标司机,然后建一个 goto 任务、不断重写到就位——算侧向偏移或前方偏移目标位、从与目标的重叠算速度调整、处理「从前方切入再侧移」的特殊分支。
这一章的意义:一辆车从驾驶意图到控制量生成,被拆成路线所有者 → 局部避让 → 控制写入者三层核心 + 战术包装层,旁边站着导航网格分支和支援机动族。四层分别回答:去哪里、如何接近、如何控制、为何采用当前驾驶策略。该分层的作用在于:巡航、追击、警察围堵、并排护送、靠边停、泊车、下客这些表面差异明显的行为,底下共用同一套执行栈,差别仅在于上层包装如何重塑目标并编排行为顺序。上层重塑目标、下层生成控制量——「策略与执行分离」在这一章达到极致;而唯一被允许「直接写入控制量」的例外(三点掉头),被严格限制在一个恢复叶里。这也解释了交通为什么能「低成本」:几百辆车共享同一套三层核心,仅为需要战术或泊车的车辆增加窄职责包装。
② 关系模型与批处理边界
这一章其实无需重复展开 Mass 的比较——它是一个逐对象运行、带多层任务嵌套的状态机栈,天然就不是批处理的形状。四层里每一层都是一个有状态的 FSM(路线所有者有约十个状态、避让叶子有六个状态),层与层之间是「上层持有下层并持续更新下层目标」的父子任务关系。这种「多层任务树 + 每层各自的状态机 + 父子间持续传递参数」,因此不适合直接映射为 ECS 的 Fragment/Processor 批处理模型——它是一棵会 push/pop 的任务树,不是一张能被扫过的数据表。所以驾驶执行栈天然属于自建的对象化运行时。
(一个诚实的补充:远景纯装饰的车流是另一回事——那些车没有战术、不追捕、不需要三点掉头,只是沿路网匀速走,那种确实可以用批处理高效执行。因此 Mass 的适用范围也应按 LOD 档位区分:近处有完整驾驶行为的车自建执行栈,远景纯装饰的车流可以走批处理路径。这与角色篇 LOD 章关于近处 Actor 与远处轻量实体的分层原则一致。)
原型侧与「任务树」对应的容器,是角色篇立的任务槽(按树+优先级定位、带生命周期和溯源)——将来执行栈的每一层落进原型时,「上层持有下层」就表达为「上层任务槽持有子任务引用、按帧重写子任务参数」,树内核的调度算法(原版盲区)则要自行设计成可测的仲裁规则。这是把「多层任务嵌套」从原版搬到原型时,唯一需要自己发明的部分。
③ 原型实现与规划状态
诚实说清楚:这一整套四层驾驶执行栈,原型里目前还没有实现。 原型有的是上篇讲过的「车拥有驾驶循环的边界」和本篇第三章的「运动学预览」,但路线搜索、避让叶子、控制写入、导航网格分支、战术包装器,一个都还尚未实现。
那为什么还要花一整章讲原版?因为它是迁移规划的地图,而且它给出了几条清晰的迁移决策:
- 路线所有者层(选路、重规划、变道、模式切换)——纯策略逻辑,规划为车侧智能里的一棵驾驶任务树,自建。它依赖的路网数据挂在 UE 数据层上。
- 控制写入者层(把目标点+速度变成油门/刹车/转向)——它的产出正好是上篇讲过的
FVWVehicleControlCommand(油门/刹车/转向/手刹)。也就是说,原型已有的驾驶指令结构,就是这个四层栈的最底层输出口——出口已就位,缺的是上面三层的逻辑。这是本章唯一「已落地」的一环。 - 避让叶子层(局部障碍/限速/三点掉头)——自建,局部扫描复用世界运行时的空间查询。
- 导航网格分支——依赖 UE Navigation System 的 navmesh 查询(构建算法是盲区)。
再具体一层,把「四层栈将来接到原型哪里」的接线图画出来——「迁移地图」和「空想」的区别,就在每条线的两端是不是都能指到真实代码(全部是规划,但每个接点今天都可以实地指认):
- 控制写入者的落点不只是那个指令结构体——它写指令的正确方式已经存在:上篇讲的
AcceptDriverCommandCandidateForTaskSource,带着任务树/优先级/会话标签把指令连同出处一起交给车。执行栈的最底层将来不是「设一个结构体」,是「走一遍带授权的接受协议」——仲裁、归属、五重清除这些纪律自动继承。 - 路线所有者的宿主是车侧智能的那个接口骨架:上篇讲过
ProcessIntelligence今天只有四行计数,它就是给「车侧任务管理器 + 路线所有者」留的插槽——调用时机与调用频率已有测试约束,填进去的逻辑天然继承正确的更新节奏。 - 避让叶子的输入依赖世界运行时的空间查询(角色篇的附近实体扫描,ped 侧已有骨架,车侧未建)。
- 战术包装层可以直接复用本篇第二章那套「意图桥 + 驱动器」模式——「追击不断重写 goto 目标」和「脚本命令不断重写进出车意图」是同一个形状:上层持有意图、反复改写、下层执行。
- 支援机动族与临时动作族排在最后:它们是纯组合(靠 goto/停车拼装)或纯冲量(限时直写),等核心三层立住后按需逐个加;唯一要提前设计的是「临时动作激活期间约束 LOD 档」的机制——它牵扯 LOD 调度器的接口,而后者本身尚未实现(上篇的规划项),两者要一起定。
建造顺序因此也清楚:先填控制写入者(出口协议已在,风险最低)→ 再避让叶子(依赖空间查询)→ 再路线所有者(依赖路网数据,交通篇上篇的规划)→ 战术包装最后。每一步都往已被测试覆盖的接点上装,而不是悬空新建。
诚实边界:除了「控制写入者的输出口已就位」,其余全是原版设计 + 迁移规划,原型无对应代码。各驾驶算法内部(避让的转向评分几何、追击的漂移探测、限速塑形的具体公式)是盲区。
二、进出车关系编排:支持失败回退的多步运行时链


这一章要解决的问题:一个 NPC 上车,要经过接近车门、开门、入座、放人、关门一长串步骤。这条链里任何一步失败——门被挡、座位被占、动画被打断、任务半路被顶替——如何保证状态回退完整?以及:在动画一帧都没有的情况下,这条链的「链」本身能先建到什么程度?
上篇第二章讲了该动作链依赖的基础:座位关系有唯一权威操作、失败必须原子回退、ped 侧只是非权威快照。这一章讲这层基础之上的动作链本身——那串「开门、入座、放人、关门」在原版中的组织方式,以及为什么该组织方式(而不是动画本身)才是值得搬的部分。
① 原版机制
原版把「进车」和「出车」各做成一个外层通用状态机,下面挂一串窄而具体的叶子。
进车的外层状态机(下称 EnterVehicle)覆盖完整的进车链:Start → 走向车 → 接近车门 → 开门 → 入座 → 等待座位上的人离开 → 把人放进座位 → 关门 → shuffle 换座 → 尝试抓门。它拥有的是策略:入口点选择与重试、从通用接近切换到车门局部行为、座位被占/被挡时的中断处理、普通入座 vs 强制等座 vs 换座 vs 抓门恢复之间的分流、以及最终交接进「已就座」态。
而底下的叶子都是窄执行器,各只负责一件事,逐个看:
- 开门(从外侧)——打开选中的那扇外侧门。为什么开门要单独成一个叶子、而不是并入入座?因为开门本身可能失败或被打断(门被其他车辆挡住、门被锁),它需要自己的成功/失败出口,好让外层状态机决定是重试还是换一扇门。
- 入座——播放真正的「进入座位」运行时。这是玩家看到的那段进车动画的核心。
- 把 ped 放进座位——执行最终的「ped 归位」交接。注意它和「入座」是分开的两个叶子:入座是「动作过程」,放进座位是「状态归位」(真正把 ped 和座位的关系锁定)。分开,是因为「动画播到哪」和「关系何时正式建立」是两件事,混在一起会在动画被打断时留下关系脏状态——上篇第二章讲的那对权威操作,正是被这个叶子调用的。
- 从座内关门——人进去坐好之后,从车里侧把门带上。
- 换座 shuffle——车里从一个座位挪到另一个座位(比如从后座挪到驾驶座)。它是进/出两条链共享的叶子。
「抓门」是一个更窄的中断叶子,只在开门阶段附近出现:它检查抓取点是否可及、装上平衡辅助、然后交给自然运动接管。它不是主流程的一环,而是「开门时出了意外(比如车动了)」的恢复分支。把这种意外恢复也做成一个显式叶子、而不是散落在主流程里的 if 分支,是这套设计能保持清晰的关键。
出车结构对称:外层状态机 Start → 选门 → 换座 → 出座 → 把人放到车外 → 关门 → 完成,底下同样是窄职责叶(出座、把人放车外、从外侧关门、以及和进车共享的换座 shuffle)。进出对称、且共享 shuffle 叶子,说明这套家族是围绕「座位关系变更」统一设计的,不是进车一套、出车另写一套。
这个「外层 FSM 管策略、窄职责叶管执行」的分层,关键作用是让复杂链条的每一步都是可中断、可重试、可回退的——外层状态机知道现在处于哪个阶段、这一步失败了该退回哪、该不该重选门。如果把整条链写成一个大函数,任何一步的失败处理都会变得极其棘手:门开启过程中受到阻挡、入座动作中断、座位被其他实体占用或释放操作失败——这些情况在“外层 FSM + 窄职责叶”的结构中均具有明确的责任归属与回退路径。
而门接近这一步本身还有一层拆分:上层只是个包装(它把移动控制打包起来,必要时还叠加战斗侧的接近移动),真正的择路在更下层的门接近任务里。那个下层任务拥有:直达 / 点路线 / 导航网格三种路径模式的选择、门是否可及的判定、以及为不同模式创建对应的移动子任务。为什么接近车门这么一小段路还要三种模式?因为车可能停在开阔地(直达)、停在需要绕行的窄巷(点路线)、或停在没有路网覆盖的越野处(导航网格)。上层包装、下层择路,又是一次「策略与执行分离」——和第一章驾驶执行栈的「路线所有者 vs 局部执行」是同一个模式在小尺度上的复现。
这一章的意义:进出车不是「播一段动画」,而是一条多步、可失败、必须支持完整状态回退的关系变更链。把它拆成「外层 FSM 管策略、窄职责叶管执行」,每一步的失败与重试才有明确归属;连「意外恢复」(抓门)都是显式叶子而不是散落的 if。这套组织方式,比任何一段具体动画都更值得搬——而且③段会看到,原型已经在任何动画接入前完成了这套组织方式:五终态编排、脚本命令合同、任务链驱动器,相关编排代码均已有测试覆盖。
② 关系模型与批处理边界
进出车执行链是「一个多步状态机 + 每步可失败回退」——多步可回退状态机是「带优先级、有中断、有重试」的逐个对象逻辑,不是无状态批处理。外层 FSM 要知道「现在处于哪个阶段、失败了退回哪」,叶子要有自己的成功/失败出口——这是一棵有状态的任务树,不是能被 Processor 扫过的数据行。(它脚下那条「跨实体关系原子性」的论证,上篇第二章已经讲过,不重复。)
③ 原型实现与规划状态
先把边界明确约束:这条链的「动作叶子」——开门/入座动画/放人/关门的真实运行时——原型尚未接入(Layer5 文档明写 deferred,连同 attachment、武器相机副作用)。但「叶子尚未接入」不等于「这一章原型是空的」——恰恰相反,叶子之上的整条编排协议,原型已经是真实、带测试的代码,而且它的结构和原版「外层 FSM 管策略」惊人地同构。读 FVWPedVehicleTaskEnterStep::Execute(343 行)的真实实现,一次「进车步」按固定顺序走完这些事:
- 先记住既有意图。如果这个 ped 身上还挂着一条未完结的进车意图(比如上一个任务让它上另一辆车),新意图建立后,既有意图在旧车上的座位预约会被主动释放、既有意图的源任务如果已被顶替中止也会被登记——目标切换时清理旧预约,这对应原版在重选车门或更换车辆时的关系状态回退。
- 意图桥请求(上篇第一章讲的五道门)。桥拒绝就终止,状态
BridgeRejected,诊断标Blocked。 - 预约门。真正解析落座之前,先走一遍「订座」:目标座已是自己 → 跳过;否则
ReserveTaskSeat→ 座位已被订就ConfirmTaskSeatReservation(同身份幂等确认)→ 确认因身份不匹配失败、但旧预约确实是同一个请求方的 →ReplaceStaleSameSourceTaskSeatReservation(上篇讲的同源顶替)。三级递进全失败,进车步直接以ResolverRejected终止,连解析器都不会见到——预约协议不是可选装饰,它是进车的硬性前置。 - 边界模式。输入里有一面
bAllowSeatResolver旗:置假时,进车步做到「意图已建、预约已订」就停(状态WouldRequireResolver),不真正落座——整条链可以被外部推演到任意一步,这是把「多步链每步可停」做成了显式能力,测试和调试都靠它。 - 异步等待识别。如果这个意图已经被接受、仍处于执行中(序列号相同的
Accepted态),不重复解析,返回IntentRequested——重入安全。 - 解析、回写、释放。调用上篇讲的座位解析器真正落座;把解析结果通过生命周期桥回写成源任务槽的完成/中止;无论成败,都释放这次进车用的座位预约——成功了预约完成使命,失败了不留悬挂。终态
EnteredVehicle或ResolverRejected。
一次进车步返回的结果对象,聚合了四份子结果(意图桥结果、解析结果、任务生命周期结果、诊断门面结果)加五面 mutation 标志——上篇讲的「结果即审计记录」在编排层的再现。五种终态(EnteredVehicle / IntentRequested / WouldRequireResolver / BridgeRejected / ResolverRejected)把「进了、等着、到达边界条件、桥拒了、解析拒了」区分得十分明确。
编排层之上还有一层脚本命令协议(FVWPedVehicleScriptTaskCommandBridge):把「脚本让这个 ped 上车/下车/换座」做成了 Request / Cancel / Complete / Reject 四个动作的显式协议,返回状态枚举有十八种——其中一半是各种精确的「不许」:任务出处不匹配(TaskSourceMismatch)、意图出处不匹配(IntentSourceMismatch)、意图当前状态不可取消(IntentNotCancelable)、任务生命周期不允许(DisallowedTaskLifecycle)……它的头注释同样把边界写死:「writes Ped-local command state and intent metadata, but never executes seats or movement」(只写 ped 本地的命令状态和意图元数据,绝不执行座位或移动)。按测试名单,这层协议覆盖了取消时释放预约、同源意图复用、异主意图拒绝、终态收尾不重开等几十种情形——「脚本控制 NPC 上下车」在这个原型里不是一个函数调用,是一份有拒绝语义的合同。
一个容易错过的细节:驱动器识别「车辆脚本任务」用的是成对身份——任务类型和脚本命令必须同时匹配(如任务类型 TASK_ENTER_VEHICLE 配命令 SCRIPT_TASK_ENTER_VEHICLE),缺一即不认。单看似乎冗余,但它防的是「一个碰巧同名的任务被脚本命令系统误接管」——身份用两个独立来源交叉确认,伪造或碰撞的成本翻倍,和上篇「车侧+ped 侧都认才算司机」是同一个双签思路。
最上面还有一层驱动器(FVWPedVehicleActiveScriptTaskStep),它回答「这一步该对这个 ped 的车辆任务做什么」。读它的实现(212 行),分派逻辑是:ped 身上没有车辆意图 → 从任务槽里选一个打开的车辆脚本任务执行(通过一个 1450 行的槽执行器落到前面讲的进/出/换座步);意图处于执行中 → 继续执行;意图到了终态(完成/拒绝/中止)→ 先看有没有下一个同主、无意图的打开任务在排队——有就直接链去执行它(任务链:脚本连续提交「上车→换座→下车」,一个终态衔接下一个任务,避免无意义的空帧);没有才做终态收尾(按终态种类分别走完成/拒绝/取消的收尾路径,清理任务槽与预约状态)。两个细节值得点名:其一,「打开的命令任务」的资格判定,要求任务槽的事件来源字段全部为空——角色篇讲的事件→任务桥写进来的槽(带 SourceEvent* 溯源)不在脚本命令的管辖范围,事件驱动的任务和脚本命令的任务互不越权,对应测试名 RequestRejectsEventTaskOwnership。其二,槽和意图的配对是九项全匹配(任务树、优先级、任务类型、任务拥有者、是否脚本、脚本命令、脚本阶段、会话标签、目标车)——“该意图是否由该任务槽发出”被判到字段级,任何一项对不上都不算同源。上篇关于迟到操作的状态管理原则,在这层的形态是:终态处理只清理自身状态,任务链只连接同一所有者。
迁移规划上,这一章给出的决策是:
- 外层 FSM 与叶子结构——自建,贴着原版的「策略/执行分离」组织;叶子的成功/失败出口对齐上篇的精确枚举纪律。
- 动画本身——复用引擎(Animation Blueprint / Montage),不自建动画系统;叶子只管「何时播放、是否完成以及如何处理中断」,不管「如何播放」。
- 门接近择路——三种模式里,导航网格一支依赖 UE Navigation System(构建算法是盲区);直达/点路线自建。
把贯穿这一切的意图生命周期单独摆一下:EVWPedVehicleIntentState 共六态——None / Requested(已请求)/ Accepted(已接受、飞行中)/ Completed(完成)/ Rejected(被拒)/ Aborted(被中止)。三个终态的语义分工:Rejected 是「系统说不行」(座位被占、句柄无效),Aborted 是「上游说不要了」(任务被更高优先级顶替),Completed 是「做成了」——终态收尾时三者走三条不同的路径(上一段驱动器里的 Complete/Reject/Cancel 三个分支)。为什么「被拒」和「被中止」必须分开?因为它们的下游动作不同:被拒的任务可能要换目标重试,被中止的任务不该再碰这辆车——混成一个「失败」,重试逻辑就会在不该重试的地方重试。
出车与换座是同一族的对称实现。 TaskExitStep::Execute(226 行)和进车步共享同一个五终态骨架(桥→边界模式→执行中状态识别→解析→回写与释放),但有两处语义差异写得很清楚:下车没有预约门(离开不需要订座),下车也没有乘客座回退(意图桥调用里这两个参数被硬编码关掉)。它保留了「换意图清旧状态」:一个 ped 上车进行到一半改变主意要下车,发出下车意图时,之前那个未完成的上车意图挂在目标座上的预约会被主动释放——中途变更不保留悬挂预约。换座步(TaskSeatMoveStep,737 行)是三类操作中实现最复杂的一项——「从这个座挪到那个座」要同时处理旧座释放、新座预约、驾驶座标志变化、以及换座对驾驶指令归属的连带影响。它的测试名单本身就说清了语义边界:MovesDriverToPassenger(司机挪去乘客座——这正是驾驶指令归属被连带清掉的场景)、ShuffleRejectsFullVehicle(车满了拒绝换座)、ShuffleRejectsOutsideVehicle(人不在车里不许「换座」)、ClearsStaleDriverCommandWhenNotSeated / ClearsStalePedOccupancyWhenNotSeated(换座时顺带清理两侧的过期账)、CancelShuffleReleasesReservation(取消换座释放预约)、ChainsLeaveAfterCompletedShuffle(换座完成后链去执行下车——任务链跨任务类型也成立)。读换座步的主流程(第 594 行起),还有四个值得点名的语义:其一,换座在意图层就是「同车 Enter」——它发出的意图类型是 Enter(关掉驾驶座意图和乘客回退),由解析器的「同车自动走 MovePedToSeat」规则落成换座;「换座」不是第三种意图,是 Enter 在特定前置下的形态,意图类型的种类被刻意压到最少。其二,目标座可以不指定(INDEX_NONE = 「换到下一个合适的座」),这时由一条专门的自动选座路径解析;指定与不指定走两条明确分开的解析函数。其三,终态区分「实际发生移动」和「已处于目标座位」——MovedSeat 与 AlreadyInSeat 是两个成功终态,幂等重入不会被误报成一次移动。其四,“实体不在车内却提交换座请求”触发的不只是拒绝:这条失败路径顺带清理 ped 侧过期占用、修复驾驶指令归属、中止源任务——把一个“基于过期状态认知的请求”当成「过期状态的报警器」,顺带完成状态修复(对应测试 ClearsStale…WhenNotSeated 两条)。
三件套的解析结果最终都通过反向生命周期桥(FVWPedVehicleIntentTaskLifecycleBridge::ApplyResolverResultToSourceTask)回写任务槽,这座桥自己也是一条门链——回写同样要过验证:当前没有意图、解析结果对应的不是当前意图(RequestSerialMismatch / StaleResolverResult)、找不到源任务、源任务出处不匹配、任务已经在终态(TaskAlreadyTerminal)——任何一条命中都拒绝回写,合法路径才落成 CompletedTask 或 AbortedTask,并带上任务生命周期变更前后的两个状态。头注释照例锁边界:「Explicit semantic feedback bridge only」。去程(任务→意图)有五道门,回程(解析结果→任务)有六道门——双向都设防,一条过期的解析结果永远改不到一个已经易主的任务槽。
三兄弟加上驱动器和槽执行器(1450 行),Layer4 进出车这一层合计约三千行实现、198 条自动化测试——这不是「骨架」,这是一套完成度相当高的关系级进出车运行时。
顺带把每一步的诊断出口交代了:三兄弟的每次执行都返回一份诊断门面结果(DiagnosticFacade),把底层预约门/预约流的状态归并成七种结局(Ready / Blocked / Mismatch / Boundary / Incomplete / MutationObserved …)加一个来源标记(问题出在预约门还是预约流)和一面「需要调用方做边界工作」的旗。上层不用理解底层几十种状态的组合,看结局分类就能决定下一步——这是「精确枚举向上聚合成可消费的粗粒度」的样本:底层留痕要细,上层消费要粗,两头都对。
原版叶子和原型编排的对应关系,用一张小表收拢(也是「关系级实现」到底覆盖了原版哪些步的精确答案):
| 原版进出车链的一步 | 原型对应物 | 状态 |
|---|---|---|
| 走向车/接近车门(门接近择路) | 无——需要移动系统 | ③ 未开始 |
| 开门(从外侧) | 无——需要动画/物理 | ③ 未开始 |
| 入座(动作过程) | 无——需要动画 | ③ 未开始 |
| 把 ped 放进座位(状态归位) | 解析器 → AddPedInSeat(七步门链) | ① 已落地 |
| 等待座位上的人离开 / 座位被占分流 | 预约门三级递进 + 乘客座回退 | ① 已落地(关系级) |
| 换座 shuffle | TaskSeatMoveStep + MovePedToSeat | ① 已落地(关系级) |
| 抓门(意外恢复) | 无——需要物理 | ③ 未开始 |
| 外层 FSM 的策略编排(重试/分流/交接) | TaskEnterStep / TaskExitStep 五终态编排 | ① 已落地(关系级) |
可以明确看到:原版链条里所有「关系变更」的步,原型均已实现;所有“身体动作”步骤,原型均尚未实现——切分线精确地落在关系状态与表现层之间。为什么应先处理状态、再接入表现?因为关系状态错误将直接导致表现错误(动画播得再好,坐进已被占的座位就是灾难),而关系状态正确后,表现层可以独立迭代(占位状态迁移升级为行走动画,编排层一行不用改)。表现层可以替换,关系状态是不可妥协的事实——先明确并验证不可妥协的状态约束,是这套原型反复出现的建造哲学。
把这一章③段讲的所有部件串成一条完整的意图处理通道,作为收束:任务槽(角色篇的产物,带事件溯源或脚本身份)→ 意图桥(五道门,只翻译)→ 意图状态(六态生命周期,序列号防伪)→ 预约门(三级递进,不产生不一致状态)→ 座位解析器(可拒绝、可回退、同时修复状态)→ 权威操作(七步门链,原子回退)→ 反向生命周期桥(六道门,回写任务槽)→ 驱动器(终态收尾或链去下一个任务)。八段,每段一个职责,每段的拒绝都有名字,每段的变更都有版本——这就是「一个 NPC 上车」在关系层的全部真相。将来动画叶子接进来,它们插在「解析器」前后各步之间,使每一步在关系状态确认后再等待表现层完成——通道本身不变。
诚实边界,重新分级:这一章的原型状态需要比初稿更细的划分——「关系如何建立与回退」是已实现的部分(上篇);「链条如何编排」也是实的(本章③:意图桥、预约门、解析、生命周期回写、脚本命令协议,全部有函数体和测试——它相当于原版外层 FSM 的关系级实现);真正未开始的是「每一步里的动作」——开门/入座动画/放人/关门的真实运行时,以及门接近择路。换句话说:这条链当前可以直接完成 NPC 的车辆占用状态迁移并保持关系记录一致,尚缺少将该状态迁移替换为接近车辆、开门、入座等真实动作的表现层实现。 动作叶子接入时,编排层理论上不需要动——每个叶子占据链条的一步,成功/失败出口对齐既有的精确枚举。
三、运动学预览:行为降级后的低成本运动


这一章要解决的问题:远处三百米外的车,不执行完整物理模拟和驾驶 AI,如何维持沿路运动表现?
上篇讲了 LOD 的决策侧——三档、谁决定降档。这一章讲执行侧:一辆车降到 dummy 档之后,靠什么继续动。
① 原版机制
车辆特有的关键是 dummy 档「沿路网做简化运动」:一辆 dummy 车不执行轮胎、悬挂与碰撞的完整刚体模拟,而是使用低成本运动学推进——给定当前速度和转向,沿着它应该走的路线向前推进。「它应该走的路线」不是 dummy 档另算的:第一章讲的跟随路线助手在 real 档给避让叶子供插值,在 dummy 档同一份路线直接供简化运动消费——降档换掉的是推进方式,路线数据一份不换,这也是升降档能无缝的一半原因(另一半是速度衔接,见②)。玩家看到的是一辆在正常沿路开的车,但它这一帧的 CPU 开销只是「速度乘时间向前推进一点」,而不是一次完整的物理求解。这就是「远处的车维持运动表现,同时显著降低 CPU 成本」的秘密。
降档不是暂停车辆,而是切换到低成本运动方式继续执行——这个区别是「远处世界依然鲜活」和「远处世界一片静止」的分水岭。
这一章的意义:远处的车变低成本,靠的不是「停下来」或「不画」,而是切换到运动学近似以维持沿路运动。「降档 = 换更低成本的推进方式,而非暂停」,是开放世界远景依然鲜活的关键。原型把这套近似做成了「一维推进 + 参数校验 + 行为测试」的独立件——不依赖路线和物理实现,当前即可进行单元测试。
② 关系模型与批处理边界
这一层是角色篇「代理切换 vs Mass LOD」论点的车辆版。Mass LOD 给的是「多久算一次」(降频/分桶),而车辆 dummy 要的是「算成什么」——一套和 real 档行为不同的、沿路网的运动学近似,以及档间无缝切换(从简化运动切回真实物理时,速度朝向要接得上,不得出现跳变)。Mass LOD 不提供「各档位采用不同的推进逻辑」和「档间状态迁移」,这些需要自行在框架外补齐。所以和角色篇结论一致:降频框架做不了变形,代理切换要的是变形。
值得提前指出,原型的接缝设计已经为「档间无缝」备好了数据通道:预览维护的速度会随物理集成请求打包给下游——将来一辆 dummy 车升回 real 档接上 Chaos 时,预览速度就是刚体的初速度,升档瞬间车不会从静止突然加速或凭空减速。「无缝」不是切换那天才想的事,是预览的字段设计里预埋的事。
③ 原型实现与规划状态
「运动学预览」在原型里有真实代码。 Preview 表示行为语义与确定性推进,不等同于物理解算;它描述车辆应处于何种运动状态,Physics 再负责处理刚体、碰撞与引擎侧约束。
- 运动学预览:
UpdateKinematicPreview(Context)是 dummy 档「沿路简化运动」的落地雏形。读它的真实实现,逻辑很清楚:先把驾驶指令的油门/刹车/转向都钳到合法区间(油门刹车 0~1、转向 -1~1)、把操控数据的牵引/刹车力/转向锁取非负;然后算这一帧的净加速度 = 油门带来的加速度(基准 5 m/s² × 牵引倍率 × 油门)减去刹车带来的减速度(基准 8 m/s² × 刹车力倍率 × 刹车)——注意刹车基准强于油门基准,「制动能力优先于加速能力」;把净加速度 × delta 累加进当前速度,并夹到非负(车不会倒着加速);若手刹输入有效,速度直接归零;再按「有没有倒车意图」定一个方向符号(±1),把方向 × 速度 × delta累加进「前进距离」;最后转向 × 转向锁角度得到转向角。全程确定性、纯运动学、不碰任何物理——它产出的是「这辆车语义上应该在哪、速度多少、转向多少」,供将来 dummy 档用来推进,或供预渲染做插值。这正是「dummy 车靠运动学预览向前推进、不跑刚体」的雏形。这套简化推进需要单独实现,原因在于它是「远距离车辆保持连续行驶表现」的性价比核心:每辆车每帧只是几次乘加,而不是一次完整的轮胎/悬挂/碰撞求解——两者的开销相差数量级。 - 预览的挂点与观测:预览不是偶发的按需调用——它挂在
DoProcessControl里,每次控制更新先跑预览、再跑智能处理,顺序固定(上篇第一章的调用链)。预览状态自带两个观测字段:LastUpdateTimeSlice(上次在哪个时间片更新)和UpdateCount(累计更新次数)。后者还有一个接缝上的角色:下一章会看到,「预览至少更新过一次」(UpdateCount > 0)是物理集成请求就绪的四个条件之一——一辆从没算过预览的车,不会向物理层提交任何意图。配套还有ResetKinematicPreview(整体归零,供换挡/传送后重置)。 - 预览的四条行为测试,名字直接就是规格:
ThrottleTest(给油加速)、BrakeTest(刹车减速)、NeutralCommandTest(零输入不动)、以及最重要的DoesNotRunPhysicsTest——「预览不跑物理」不是文档里的一句承诺,是一条会失败的自动化测试。把「不做什么」也写成测试,这个边界才真正被明确约束。 - 操控数据本身有校验:
FVWVehicleHandlingData+TrySetHandlingData会校验质量/牵引/刹车/转向锁在合理范围,返回 ready/incomplete/invalid——保证传入运动学预览的参数满足约束。这个校验也是「分级状态、不压成布尔」纪律的又一次体现:操控数据不是「有效/无效」两态,而是「就绪 / 不完整 / 各字段分别无效」的精确枚举,调试时能立刻知道是质量未配置、还是转向锁越界。
顺带记一条从原版临时动作族读到的 LOD 交叉细节:正在执行限时纠正动作的车,会被禁止降到最深的代理档——一辆正在急打方向的车如果此刻被降成简化运动,那个「急打方向」就凭空消失了。也就是说,LOD 决策不只看距离,还要尊重「谁正在借用这辆车的控制权」;原型将来的 LOD 管理器要为这类「临时钉档」留接口。
一个车辆特有的 LOD 细节,值得从角色篇的通用讲法里单独提出强调:运动学预览和「假乘客」是配套的。 角色篇讲过「假乘客」——远处车辆不保留完整 AI 驾驶员,而是使用不参与 AI 决策的占位实体。现在把两者拼起来看:一辆降到 dummy 档的远处车,它的司机是假乘客(不算 AI)、它的运动是运动学预览(不算物理)——两种计算降级同时生效,这辆车才真正做到「维持行驶表现并显著降低 CPU 成本」。少了任何一个都不行:仅降低 AI 成本而保留完整物理,物理求解还是贵;只省物理成本不降而保留完整 AI,几百个司机的完整决策照样耗尽帧预算。「假乘客 + 运动学预览」是同一件事的两半——把一辆车的『驾驶成本』和『运动成本』同时降下来。 而这两半将来由同一个决策者(上篇的全局 LOD 管理器)统一升降:降档同时换上假乘客和预览,升档同时换回真司机和刚体——成本档位是一个原子决定,不是两个独立开关,否则会出现「司机是假的、物理却是真的」这类不一致的中间状态。
还有一个从代码里读出来的诚实局限,值得先于任何人发问就写明:预览的「前进距离」是一个标量——沿行进方向累计了多少米——而转向角只是被记录,并不参与位置积分。也就是说,预览今天回答的是「这辆车沿它该走的路线推进了多远、此刻方向盘打了多少度」,而不是「这辆车在世界坐标里的二维轨迹」。这不是缺陷,是分工:曲线是路线的事,推进是预览的事——dummy 车的位置将来 = 路线几何(交通篇的路网/路线数据)+ 预览的沿线距离,两者相乘才是世界坐标。预览把自己限制在「一维推进 + 状态记录」,恰好让它对路线数据零依赖、今天就能独立成立和单测。
诚实边界:运动学预览的状态和确定性推进公式是真实代码,但它目前是语义预览,尚未接入到真正的 transform 变更或渲染插值上(Layer5 文档明写「不 mutate transform、不碰 Chaos movement」)。加上上篇讲的「全局 LOD 管理器未实现」,完整的说法是:「一辆 dummy 车如何通过运动学推进」已有初步实现,「谁来决定这辆车该降到 dummy」和「挪的结果如何实际反映到画面」都尚未实现。
四、物理集成接缝:复用 Chaos 并隔离物理实现
这一章要解决的问题:车最终要真的动起来、要碰撞、要有轮胎悬挂——这些该自己写,还是交给引擎?如何将「生成驾驶意图」和「真正驱动物理」隔开,让两者能独立演进?
① 原版机制
原版是十几年前的工程,它那年代没有现成的、够用的车辆物理 LOD 和刚体车辆,所以它自造了整套车辆物理(轮胎、悬挂、传动、以及 dummy 档的简化运动)。这在当年是必需的。
但对今天在 UE5 上重建的人来说,这恰恰是一个不该照搬的地方——原版自造车辆物理的价值,在于它的调度语义(三档、滞后、预算、dummy 沿路运动),而不在它具体的物理实现。UE5 有 Chaos Vehicles + 车辆物理 LOD,重造一套刚体并不经济。
所以这一章的原版侧,重点不是“原版如何实现物理系统”,而是「应迁移哪些语义、应复用哪些实现」的判断:搬调度语义(三档/滞后/预算/运动学预览),复用物理实现(Chaos)。而要做到这一点,前提是把「生成驾驶意图」和「驱动物理」用一道接缝隔开——上层只管产出「这辆车本帧需要采用的速度、转向与行驶方向」,下层(无论是 Chaos 还是别的)负责把这个意图变成真实的 transform 和碰撞。
这一章的意义:与人口/驾驶语义无关的现成组件(车辆刚体物理)该复用引擎现成的,不该自造。而复用的前提,是用一道接缝把「意图」和「物理驱动」隔开——这样上层的驾驶/LOD 语义和下层的 Chaos 实现可以各自独立演进。③段会看到,原型把这道接缝做到了「连 no-op 都语义精确、连状态消失都有版本号」的程度——接缝不是一个接口声明,是一整套交接纪律。
② 物理实现的引擎复用边界
这一章的②和前面几章相反:这里恰恰是该复用引擎、不该自建的地方。 原因很简单——车辆刚体物理是一个「与人口所有权、驾驶权、座位一致性都无关的纯物理问题」,它没有前面那些「异质、有所有权、逐个裁决」的语义特征,它就是「给定力和约束,解算 transform 和碰撞」的同质计算。这类问题引擎已经做得很好(Chaos Vehicles),自造既费力又难追上。
这也再次印证整个系列的心法:「不套 Mass、自建运行时」针对的是「运行时调度与所有权这层语义」,不是一切。 分得清哪层该自建(调度/所有权/一致性)、哪层该复用(刚体物理、纯数据查询层),比一刀切「全自研」或「全用引擎」都重要。判据可以压成一个问题:这块计算需不需要知道「谁拥有什么、谁被授权做什么」? 需要,自建;不需要——它只是「给定输入求输出」的同质计算——复用。刚体物理求解不关心司机是谁,所以它是现成组件;驾驶权关心,所以它是语义。
③ 原型实现与规划状态
原型已经建立该集成接缝。 它是一个适配器接口:IVWVehiclePhysicalIntegrationAdapter,只有一个方法——把一个「物理集成请求」评估成一个「物理集成结果」。
- 请求(
FVWVehiclePhysicalIntegrationRequest)由车侧把驾驶指令、操控摘要、运动学预览打包而成:来源车、指令来源、操控校验状态、有没有就绪操控、有没有运动学预览、以及预览的速度/前进距离/转向角/质量。请求自带就绪判定,四个条件缺一不可:来源车句柄有效、指令来源标签非空、操控数据就绪、运动学预览至少更新过一次——全齐才是Ready,否则Incomplete。也就是说,上层产出的是描述车辆本帧执行意图的完整数据包,不直接碰物理,而且不完整的意图根本不会声称自己就绪。 - 写入请求的操控数据,上游有一道合法范围校验(
TrySetHandlingData):质量为负非法、牵引/刹车倍率必须在 0 到 10 之间、转向锁必须在 0 到 90 度之间——超出即返回具体是哪个字段非法的枚举,数据不落盘;而「就绪」还要求配了操控标识和正质量。「合法」和「配齐」是两道分开的检查:一份全零的操控数据是合法的(没有字段越界),但不就绪(还未配置)。非法参数在进入预览公式之前就被挡下,这是「预览的输出永远可信」的前提。 - 默认适配器是
FVWNullVehiclePhysicalIntegrationAdapter——它返回确定性的「不可用/no-op」,并把请求原样保留供诊断。值得停一下:Null Adapter 的存在,是为了让上层开发、测试与诊断独立于物理实现。 连“不执行任何动作”都分两种。 读它的实现:收到就绪的请求,返回Unavailable+ 诊断理由NullAdapterUnavailable(「你的意图没问题,是我这个适配器不执行」);收到不完整的请求,返回Skipped+ 理由IncompleteRequest(「你的意图本身就不合格」)。将来排查「车为什么不动」,这两种 no-op 指向完全不同的方向——前者说明该接真适配器了,后者说明上游状态未配置齐。这是一个空实现,但它是一个语义精确的空实现:接缝在、协议在、请求打包在,将来接 Chaos,就是换一个真实适配器进来,上层一行不用改。
接缝的两头之间,还有一个「待处理意图」缓冲(FVWVehiclePendingPhysicalIntentState),它带一个修订号(Revision)。这个设计解决一个时序问题:上层产出意图的节奏、和下层物理消费意图的节奏不一定同步(物理可能降频、可能单帧执行多次物理步、可能跨线程)。所以上层不是「直接命令物理」,而是「把最新意图连同一个递增的修订号放进缓冲」,下层物理在它方便的时候取走——修订号让下层知道「这是不是一个比我上次处理的更新的意图」,避免重复处理既有意图或漏掉新意图。这再次体现了“带版本的状态 + 生产者消费者解耦”模式,和上篇驾驶权归属的序列号、座位预约的序列号是同一套思路:凡是「两个节奏不同的参与方要交接状态」,就用一个版本号来一致性校验。
这个缓冲的写入口同样设了门。SetPendingPhysicalIntent 做三重校验:意图必须真的成立(bHasIntent)、意图的来源车必须是这辆车自己、意图对应的请求状态必须是 Ready——三条任一不满足,写入被拒绝。整条接缝的可观测性最终汇入上篇提过的单车自检门面:17 份子快照里,集成侧独占四份(请求边界失败、集成诊断摘要、待处理意图、预渲染诊断),一行 BuildInspectionDiagnosticLine 就能把「这辆车的物理交接处于哪个阶段」打进日志——将来接 Chaos 联调时,排障所需的基础工具已具备。第二条尤其值得注意:意图快照的 bHasIntent 在构建时就要求「请求就绪 且 来源车等于本车」——「将其他车辆物理意图写入本车」这种来源错配,在两道关口都过不去。而且不只写入递增修订号,ClearPendingPhysicalIntent 清除也递增修订号——「意图没了」本身是一次需要被下游感知的变更:一辆车刹停后撤销了物理意图,消费端靠修订号变化知道「该停手了」,而不是空等一个永远不来的新意图。这和上篇「清空归属也递增序列号」是同一条纪律:状态的消失和状态的出现,同样是版本历史的一部分。
还有一整套集成诊断(FVWVehicleIntegrationDebugSnapshot 等一批快照)——它把「这辆车此刻的物理集成处于哪个阶段、请求就绪没、有没有待处理意图、上次预渲染的结果如何」全部快照出来。尚未接入 Chaos 的接缝仍需要完整诊断,原因在于因为接缝正是将来最容易出问题的地方(上下层节奏不匹配、意图丢失、请求不完整),提前把诊断做足,等真接入 Chaos 后出现问题能快速定位。这和交通就绪门那套诊断视图是同一种工程习惯:在系统边界上重点投入,因为边界是 bug 最容易藏身的地方。
诊断的写入点在预渲染相位。读 PreRender 的实现:每次预渲染时,车辆将当前集成调试快照完整写入 FVWVehiclePreRenderState——二十多个 LastIntegration* / LastKinematicPreview* / LastPendingIntent* 字段。其中藏着一个一致性校验设计:同一组「速度/前进距离/转向角」被记了三份——预览状态里的当前值、集成请求里带的值、待处理意图里存的值。三份数值本该一致;哪一份掉队了,就说明哪一段交接出了问题(预览更新了但请求没重建?请求重建了但意图没提交?)。把「按契约应保持一致的数值」分开记录,不一致本身就是直接的诊断信号。 集成状态还有一个四态摘要码(EVWVehicleIntegrationDiagnosticSummaryCode):请求不完整 / 就绪但无待处理意图 / 就绪且有待处理意图 / 有待处理意图但请求已不就绪——最后一种是典型的病态(意图挂着,可它的前提已经塌了),被单独编码出来,而不是混在「不就绪」里。配套的边界失败快照(BuildInspectionBoundaryFailureSnapshot)则把每个缺口拆成独立布尔,其中「缺操控」(bMissingHandling,未配置)和「操控非法」(bInvalidHandling,已配置但超出范围)是两面不同的旗——修数据和补数据是两种工作,诊断从源头就分开。这一块的自动化测试有 13 条(名字含 PhysicalIntegration)。
这个设计的价值:「生成驾驶意图」(上层,已实现)和「驱动真实物理」(下层,先用 null 适配器占位)被一道接口彻底隔开。 上层的驾驶指令、仲裁、LOD、运动学预览可以先完整跑起来、可以单测,完全不依赖任何物理实现;待 Chaos 适配器就绪,从 null 适配器切到 Chaos 适配器即可。这正是「搬语义、不搬实现」在工程上的明确落地。而且这道接缝还带来一个测试收益:因为上层完全不依赖真实物理,可以注入一个测试适配器(返回预设结果),来测试上层在「物理拒绝了这个意图」「物理层返回车辆受阻状态」等各种情况下的反应——不需要真的跑一套物理就能覆盖这些边界。接口隔离换来的是可测性,这是「搬语义、不搬实现」之外的一层额外收益。
顺着接口的形状,把「从 null 换成 Chaos」那天要做的事列成清单(规划,但每一项都由现有接口精确规定):真实适配器要实现的仍是那一个方法——收一份请求(里面有意图速度/前进距离/转向角/质量和操控校验状态),返回一份结果(接受/拒绝/跳过 + 诊断理由 + 自己的适配器标签);消费侧则从待处理意图缓冲取意图、按修订号一致性校验、把结果反映到 Chaos Vehicles 的目标速度/转向输入上。上层的一切——预览、请求打包、缓冲、诊断、预渲染快照——一行不改;要新写的只有「如何将意图翻译成 Chaos 的输入」这一段翻译逻辑,以及一份新的诊断理由枚举(替换 NullAdapterUnavailable)。接缝的价值在换的那天兑现。
诚实边界:接缝、请求打包、null 适配器、以及一整套集成诊断(FVWVehicleIntegrationDebugSnapshot 等)都是真实代码;但真正的 Chaos 适配器尚未接入——目前下游是 null,车辆不会产生真实运动或碰撞。Layer6 文档明写「不碰 Chaos 集成、不碰 UE movement 组件、不碰 transform/碰撞状态」。所以:「意图如何打包、接缝如何隔离」是实的,「意图如何驱动真实车辆运动」尚未实现。
五、迁移取舍:车辆篇(下)的实现清单
把本篇四章合起来,给一张对照表。判据沿用系列的诚实口径:① 已落地(有函数体+测试)、② 已立骨架(类型状态齐、行为空)、③ 未开始(路线图条目,或原版 deep / 原型未写)、复用(该复用引擎)。
| 层 | 关键机制 | 原型状态 | 落点 |
|---|---|---|---|
| 驾驶执行栈 | 路线所有者(选路/重规划/变道/模式切换) | ③ 未开始 | 原版 deep;规划自建为驾驶任务树 |
| 驾驶执行栈 | 局部避让叶子(障碍/限速/三点掉头) | ③ 未开始 | 规划自建,扫描复用空间查询 |
| 驾驶执行栈 | 控制写入者的输出口 | ① 已落地 | FVWVehicleControlCommand(栈的最底层出口) |
| 驾驶执行栈 | 导航网格分支(越野回退) | ③ 未开始 | 依赖 UE NavSystem(构建算法盲区) |
| 驾驶执行栈 | 战术包装层(警察/追击/并排) | ③ 未开始 | 原版 deep;原型未写 |
| 驾驶执行栈 | 支援机动族(三点掉头/靠边停/泊车/下客/接近) | ③ 未开始 | 原版 deep;组合式编排,原型未写 |
| 驾驶执行栈 | 跟随路线助手(路线插值第三方所有者) | ③ 未开始 | 原版 deep;归路线数据层规划 |
| 进出车执行 | 关系级编排(意图桥→预约门→解析→生命周期回写) | ① 已落地 | TaskEnterStep::Execute(五种终态,带测试) |
| 进出车执行 | 脚本命令协议(Request/Cancel/Complete/Reject) | ① 已落地 | ScriptTaskCommandBridge(十八种状态) |
| 进出车执行 | 驱动器(终态收尾/任务链)+ 槽执行器 | ① 已落地 | ActiveScriptTaskStep + ScriptTaskSlotExecutor |
| 进出车执行 | 反向生命周期桥(解析结果→任务槽,六道门) | ① 已落地 | IntentTaskLifecycleBridge |
| 进出车执行 | 进出车动画/开门/放人/关门(动作叶子) | ③ 未开始 | Layer5 deferred;动画规划复用引擎 |
| 进出车执行 | 门接近择路(直达/点路线/navmesh) | ③ 未开始 | navmesh 构建算法盲区 |
| 行为 LOD 执行侧 | 运动学预览(dummy 沿路推进雏形) | ① 已落地 | UpdateKinematicPreview(确定性、不碰物理) |
| 行为 LOD 执行侧 | 操控数据校验 | ① 已落地 | TrySetHandlingData + 校验状态 |
| 物理集成 | 意图与物理驱动的接缝 | ① 已落地 | IVWVehiclePhysicalIntegrationAdapter |
| 物理集成 | 请求打包 + 诊断 | ① 已落地 | BuildPhysicalIntegrationRequest + debug 快照 |
| 物理集成 | 待处理意图缓冲(带修订号) | ① 已落地 | FVWVehiclePendingPhysicalIntentState |
| 物理集成 | 真实 Chaos 适配器 | ③ 未开始 | 目前是 null 适配器(两种精确 no-op) |
| 车辆刚体物理 | 轮胎/悬挂/传动/碰撞 | 复用 | 不自造,规划用 Chaos Vehicles |
| 测试覆盖 | 本篇涉及子系统 | ① 已落地 | Kinematic 5 / PhysicalIntegration 13 / Layer4 进出车协议数十条 |
读这张表的一句话:本篇是「执行侧」,它的落地分布正好和上篇互补——接缝、近似与编排(运动学预览、物理接缝、进出车的关系级编排与脚本命令协议)是真实代码,它们构成执行侧的基础边界;而壳里的行为(四层执行栈、动作叶子、战术包装)几乎全是「原版设计 + 迁移地图」。表里「① 已落地」的行数比初稿翻了一倍——不是这周写了新代码,是这轮深挖把原本被低估的编排层重新归位;取舍表本身也要随证据更新,它是测量结果,不是一次性的宣言。这个分布不是偶然,它就是原型的建造顺序:先把「意图生成、物理接入、低成本近似计算和动作链编排」这些边界立成可单测的代码,再往边界里填真正的驾驶行为和动作表演。上篇立所有权,本篇立接缝与编排,行为最后填——若顺序倒置,底层边界变化将导致大范围返工。
三条可迁移的接缝经验
和上篇的「三条实现经验」对仗,本篇的执行侧也沉淀三条——它们都出自「上下两层节奏不同、信任不同」的交接处,这正是执行侧和调度侧最大的结构差异:
其一,「不执行任何动作」也要分档。 null 适配器把 no-op 分成「意图合格但我不执行」和「意图本身不合格」两种;预约诊断门面把失败分成「被前置条件拒绝」「不匹配」「到达边界条件」。占位实现的价值不在「先具备运行能力」,在「先把语义分对」——将来换真实现时,所有调用方的错误处理已经是对的。
其二,消失也是变更。 意图清除递增修订号、归属清空递增序列号——凡是有消费者的状态,它的消失必须和它的出现一样可感知。反例是「悄悄清掉,让消费者自己发现不对」——那是竞态和悬挂引用的温床。
其三,同一份数值,记三处,不一致即报警。 预览值、请求值、待处理意图值按契约应保持一致,分开记录后,「哪一交接环节发生数据丢失」不需要复现就能定位。冗余不是浪费,一致性校验用的冗余是最低成本的监控。
这三条加上上篇那三条(结果即审计、写入口封死、迟到是常态),就是这个原型六篇方案篇里反复出现的全部实现纪律——它们彼此不独立:写入口少,审计才记得全;审计记得全,迟到的操作才辨认得出;辨认得出,接缝两侧才敢各自异步。一套纪律,六个战场。
AI 协作复盘
这一篇的材料线和上篇相同:逆向笔记 + 原型真实代码。但两条线的配比正好反过来——上篇多数章节能对照原型函数体,本篇的主体(驾驶执行栈)是原版证据最厚、原型完全未写的一章;而本篇的第二主角(进出车编排)又反过来是原型厚到超出预期的一章。两头都要求同一件事:完成证据核对后再写作。
AI 帮了什么。 逆向侧,AI 把四层执行栈从相关笔记里对齐成「路线所有者 → 局部避让 → 控制写入 → 战术包装」这条线,核对了两级状态机的完整状态列表(路线所有者约十个状态、避让叶子六个状态),并为这次扩写回读了两篇函数级笔记——核心驾驶任务族与支援机动族——把跟随路线助手的三分所有权、重规划的车道作为后续计算输入、三点掉头「直生成控制量的唯一例外」、泊车的十态自矫正程序这些细节补进①段。原型侧,AI 逐行读了 UpdateKinematicPreview 的推进公式(含 5/8 m/s² 两个基准常量)、物理接缝的请求打包/两种 no-op/修订号缓冲、以及 TaskEnterStep::Execute 全部 343 行,确认「预览是确定性纯运动学、接缝下游是 null 适配器、进出车编排是带测试的已实现代码」这三个分级事实。
一次向上的修正。 初稿把进出车执行章的原型状态写成「只有顶层骨架(②),其余未开始」——深挖后发现编排层整个是①已落地:意图桥、预约门、解析、生命周期回写、脚本命令协议全有函数体和测试,真正未开始的只有动作叶子。诚实分级的修正不总是向下的(把言过其实的收回来),也会向上(把实际做到的补认)——两个方向都靠读代码,不靠印象。这次的取舍表按新证据重新分级。
本篇的主要写作风险是「将迁移规划误写为已实现功能」。 执行栈一章原版讲得越细,越容易让读者以为原型也做到了。本篇的处理是把「原型未实现」放在③段第一句、把唯一已落地的一环(控制写入者的输出口 FVWVehicleControlCommand)明确标出——宁可让实现边界清晰,避免读者误判。同样,运动学预览「有真实代码」和「尚未接入到 transform」两句话必须一起说,只说前半句就是失实。
数字同样要现场普查,不引旧状态。 本篇引用的每一个体量数字——343/226/737/1450/212 行、198 条 Layer4 测试、4 条预览行为测试、13 条集成测试——都是动笔当天用行数统计和测试名分类当场量出来的,不是从旧笔记里抄的。上篇复盘讲过「31 条」变「769 条」的教训,本篇直接把普查做成写作流程的第一步。
原版细节要为迁移决策服务。 这轮母库回读让原版侧素材变得非常厚(执行栈四层、支援机动族、临时动作族、警察战术三件套),厚到足以把①段写成独立赏析——克制点在于每一段原版细节都要回答「这对迁移意味着什么」:三点掉头的直生成控制量对应「例外要圈起来」、临时动作的钉档对应「LOD 管理器要留接口」、泊车的十态程序对应「组合优先于新建」。写不出迁移含义的细节,再精彩也删。
分级诚实在本篇的形态:①真实代码(进出车关系级编排全家、预览、接缝、操控校验)/ ③地图(执行栈四层与支援机动族、动作叶子、战术包装)/ 复用(动画、Chaos、navmesh 查询)/ 盲区(避让几何、追击漂移、寻路内核、navmesh 构建)。值得注意本篇没有「② 骨架」——深挖之后,原先以为的骨架(进出车编排)被证实为①,而执行侧其余部分是③:执行侧的真实现状是两极的,中间态反而少。全篇没有把任何一种状态伪装成另一种。
*这是「如何制作一款开放世界游戏」系列实战篇。车辆主题按层切成上下两篇:上篇讲调度与决策(所有权、座位、驾驶权、LOD 决策),本篇(下)讲行为、表现与物理(驾驶执行栈、进出车执行、运动学预览、物理接缝)。原型当前已实现进出车的关系级编排(约三千行、198 条测试)、运动学预览与物理接缝;四层驾驶执行栈已有充分的原版证据,原型尚未实现,本篇诚实地把它讲成迁移地图;「多车辆如何协同形成交通」(路网、路口、红绿灯、派遣)在交通篇上下两篇展开。文中所有类名与源码路径均为长期逆向阅读与在建工程的实地核对结果,发布时按惯例做匿名化处理。*