Skip to content

在浏览器里造开放世界,第 29 部分:一个 controller,任何身体

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 28 部分覆盖了草和遮挡。二十八个部分造了一个能站进去的世界:你能读懂的地形、你能游泳的水、立得住直到地平线的植被、一个记得你改了什么的服务器。这一部分讲在这一切里移动的那个东西,它是整个引擎的回报,因为目标不是一个玩家 controller。它是一个不在乎自己驱动的是什么身体、动画从哪来、方向盘后面是人还是 AI 的 controller。Spike 58 把移动造成物理引擎之上的一摞可插拔行为,那个物理引擎对 locomotion 一无所知,然后通过把三种不同的身体跑过同一份拷贝来证明这个架构。Spike 59 在那个 controller 上挂一个真正 retarget 过的 avatar 而一行都不改它。Spike 60 挂一个完全不需要 retargeting 的不同动画包,并把 clip 选择逻辑拉出来变成你真能测试的东西。

一个对走路一无所知的物理引擎

在新标签页打开 Spike 58 ↗ · 看源码

这个设计规则很严苛,而它就是全部要点:capsule 引擎不拥有任何 locomotion。没有走、没有跑、没有跳、没有摩擦、没有顶速上限,连重力都没有。它只把一个 kinematic capsule 对地形做积分,仅此而已。每个 locomotion 行为,走、滑、滑翔、攀爬、游泳、蹲、stamina,都活在一个注册到引擎的自包含 controller 里。每帧引擎 tick 每一个 controller,问每一个它想不想要控制权,并让优先级最高的索求者写 velocity。走没有特殊地位。它只是那个总是说 yes 的最低优先级 controller,所以没别的触发时它就是默认。一个 controller 是六个小函数:一个 tick 每帧更新它自己的内部状态即便它没在激活、一个纯 wantsControl 谓词来索求这一帧、一个只有赢家才跑的 applyForces 来写 velocity 并施加它想要的自己那份重力,外加可选的 onEnteronExitstateName。游泳从 applyForces 返回 ownsCollision: true 来接管地形处理,因为它的浮力弹簧否则会跟引擎的吸脚打架。

连那些不是 locomotion 的东西也仍然漏过那个契约,这逼出了下一个想法。让这个架构赚回本钱的是 channel。一个早先的单 channel 设计把所有东西都跑过一次仲裁,这意味着一个 stamina 追踪器或一个蹲姿势得假装成 locomotion,然后用一个 wantsControl 返回 false 的 hack 拒绝控制权,只为了跑它自己的记账。修法把 controller 拆成命名的 channel,它们独立仲裁并按一个固定顺序施加:resource,然后 stance,然后 locomotion。Resource 先跑,因为它的写入,比如 stamina 衰减,会被别的读到。Stance 第二跑,因为蹲缩小 capsule 高度这事得在走读它来限顶速之前落地。Locomotion 最后跑并拥有每帧的 velocity 写入。所以一个 stamina 观察者和一个蹲修改器和一个激活的游泳 controller 全都干净地共存,各在自己的 channel 里,没有哪个 controller 得撒谎说自己是什么。Demo 用一个会按激活 controller 变色的裸 capsule 把这一切可视化,所以你能看着仲裁发生:绿色的走在陡坡上翻成橙色,因为滑接管了,在湖里翻成青色,因为游泳赢了,你点滑翔时在半空中翻成奶油色。

一个引擎,三种身体

"不拥有 locomotion"真正的测试不是玩家。是同一个引擎,原封不动,能不能驱动一个根本不是玩家的东西。createCapsuleEngine 是一个纯 factory,没有模块级状态、没有 singleton、没有每实例的副作用,所以这个 spike 把它实例化三次。玩家是一个实例,带完整的 controller 集合。一匹可骑的马是第二个实例,只注册一个走 controller,这就是把那个想法变成字面意思:一个身体的移动词汇就是你注册的那些 controller,所以这匹马在平地上更快,而且物理上没法攀爬、游泳或滑翔,因为那些 controller 从没被加进来。一个自主的 NPC 是第三个实例,每帧跟玩家一起被 tick,由一个 wander controller 驱动,它合成自己的输入,所以这个身体自己掌舵,键盘上没有手。引擎从不知道它的某个身体是匹马或者另一个是 AI 驱动的。它们全都是同一个 capsule 积分器,配不同的 controller 列表。

上骑是故意活在引擎之外的那一块。在玩家和马之间切换控制权意味着协调两个引擎,而一个 controller 在一个引擎内部跑,看不过那个边界,所以上骑逻辑坐在 host 层:它是一个小状态机,决定这一帧步进哪个引擎、冻结另一个、用一个四分之三秒的 smoothstep 把骑手缓到马鞍上,并告诉相机跟踪哪个身体。引擎 factory 从不听说这其中任何事。这就是架构划下并守住的那条线。属于一个身体的行为是 controller;身体之间的协调是 host 的活,而把那些分开就是为什么第四个或第四十个身体不会有任何新的开销。

不用浏览器就能测

因为引擎不去够任何 window、任何 document、任何 Three.js,整个东西 headless 运行。这个 spike 带一个 Node harness,它 mock 出地形接口,用脚本化的输入逐帧驱动引擎,并对得到的状态做断言,所以那些靠试玩很惨才能抓到的回归改在一个脚本里被抓到:跳和落地的过渡、游泳进出阈值、攀爬在表面变平时自动清除、stamina 耗尽速率。把同一个输入和时间步序列喂两遍并检查逐字节相同的输出状态,把引擎钉成确定性的,那是一个联网构建最终会倚靠的属性。harness 抓到的一个回归值得留着:从可走的地面踏上一个比走更陡的斜坡,过去会接通滑 controller 把玩家弹回坡上。修法是一个下降门。只有当 capsule 确实在朝斜坡落下时滑才开始,所以现在水平走进一个陡面会干净地挡住而不是滑,那个断言"走进一个不可走的斜坡挡住玩家"的测试把它锁定住。

不碰 controller 就插进一个真正的 avatar

在新标签页打开 Spike 59 ↗ · 看源码

Spike 59 测试的是 controller 层是不是真的跟身体解耦了。它把那个彩色 capsule 换成一个真正的蒙皮角色,那个 3MIKE FBX rig 配上 retarget 到它身上的 Quaternius Universal Animation Library clip,而 controller 一点都不变。接缝是一个单独的字符串。每个 controller 已经通过 stateName 报告一个状态名,idle、walk、run、jump、fall、land、slide、glide、swim、swimIdle,而 avatar 层通过一张别名表把那个名字映射到一个 retarget 过的 clip,切换时做 crossfade。走从着地加垂直 velocity 加水平速度动态地挑它自己的子状态,所以单个走 controller 驱动 idle、walk、run、jump、fall 和 land,而 avatar 只是跟着报告的名字。因为这个 spike 是单人的,avatar 丢掉了早先联网 spike 里那套整数线缆的间接层和多角色抽象,把状态名直接映射到一个 clip。证明在于从 capsule 到带 rig 的人这整个视觉升级碰了零行 locomotion 代码,这正是一个可插拔的 controller 应该买来的东西。

一个不需要 retargeting 的包,以及一个你能测试的选择器

在新标签页打开 Spike 60 ↗ · 看源码

Spike 60 插进第三个身体,Synty 的 POLYGON Base Locomotion 包,而 loader 几乎什么都没有。每个 clip 作为一个独立 FBX 出货,带着同一副 Synty 骨架的一份内嵌拷贝外加一个烘焙好的动画,而因为角色 rig 和每个 clip 都用相同的骨骼名,你就从 fbx.animations[0] 抓下那个 clip,直接在角色的 mixer 上播放它,回路里没有任何 retargeting 库。Three.js 按骨骼名而不是对象身份来解析动画 track 的目标,所以一个针对匹配 rig 制作的 Synty 或 Mixamo 风格的包就直接能用。这是跟上一个 spike 的 UAL 路径的有意对比,那条路需要重量级的 retargeting,因为源 clip 和目标 rig 是针对不同骨架制作的。同一个 controller、同一个状态名接缝,背后两条完全不同的动画管线。

Spike 60 的另一半是让 clip 选择逻辑可测试。选哪个 clip 来播充满了判断阈值,而那套逻辑一直埋在 avatar 层里,紧挨着 FBXLoader 和 DOM,在那里没法被演练。这个 spike 把选择器抽成纯函数,既不碰 Three.js 也不碰 window:它们拿一个朴素的玩家记录(velocity、水平速度、朝向、着地、地面法线、撞击 velocity)和一个 clip-action 替身,并返回一个 clip 别名字符串。这让那些阈值能作为命名的、被断言的常量存在。跳按速度分桶拆成走、跑和冲刺变体,那些桶跟走 controller 实际的冲刺阈值对齐;落地按撞击 velocity 拆成软、中、硬;上坡和下坡 clip 变体从一个坡度投影点积触发,在大约七度以下有一个平 clip 死区;一个 idle 到 locomotion 的桥总是向前解析,所以一个站定起步永远不会倒着播一个 clip;一个停止桥被一个最小循环内时间 gate,因为 Synty 的脚相位停止 clip 包含大约一秒制作好的减速,硬贴到一个玩家几乎没迈出的步子上看起来很可笑。一个小的状态去抖器把 fall 状态推迟几帧,所以跨一个接缝时一帧的地面接触丢失不会让动画闪烁。把所有这些从渲染外壳里拉出来意味着规则能在没有 GPU 时被单元测试,跟引擎自己在 spike 58 里得到的那套 headless 纪律一样,这就是靠猜来调的 locomotion 手感和你能钉死的 locomotion 手感之间的区别。

本章涉及的技术

一个无 locomotion 的物理引擎。 capsule 引擎把一个 kinematic 身体对地形做积分,不拥有走、跑、跳、摩擦、速度上限或重力。每个行为都是一个注册的 controller,暴露 tickwantsControlapplyForces 和可选的 onEnter/onExit/stateName。走只是那个最低优先级、总是 yes 的默认,而一个 controller 能返回 ownsCollision: true 来接管地形处理(游泳就这么做,这样它的浮力弹簧不跟吸脚打架)。

独立的仲裁 channel。 Controller 注册进命名的 channel(resource、stance、locomotion),它们分开仲裁并按固定顺序施加,所以像 stamina 这样的观察者和像蹲这样的修改器跟激活的 locomotion 共存,而不用假装控制并拒绝它。Resource 的写入(stamina)被 stance 和 locomotion 读到;stance 的写入(蹲的 capsule 高度)被 locomotion 的速度上限读到;locomotion 最后跑并拥有 velocity 写入。

一个 factory,许多身体。 createCapsuleEngine 是一个没有 singleton 的纯 factory,所以同一个引擎驱动玩家、一匹只能走的可骑马和一个吃合成输入的自掌舵 NPC。一个身体的移动词汇恰恰是它注册的 controller 集合,所以这匹马没法攀爬或游泳,因为那些 controller 从没被加进来。上骑活在 host 层而不在一个 controller 里,因为它协调两个看不过彼此的引擎。见 GPU 驱动的 LOD

Headless、确定性的测试。 引擎不碰任何 windowdocument 或 Three.js,所以一个 Node harness 逐帧驱动它,并对跳和落地、游泳阈值、攀爬自动清除和 stamina 耗尽做断言。重放同一个输入两遍并检查相同的输出证明确定性,而一个回归测试钉住那个下降门,它阻止一次水平走进陡面把玩家向后滑。

身体无关的 avatar 绑定配一个可测试的选择器。 Controller 报告一个状态名字符串,视觉层把它映射到一个 clip,所以把一个 capsule 换成一个 retarget 过的 3MIKE + UAL avatar 碰零行 locomotion 代码。Synty POLYGON clip 跟 rig 共用骨骼名,不用 retargeting 就能播放(Three.js 按骨骼名绑定 track),不像 UAL 路径那套重量级 retargeting。clip 选择器被抽成纯函数,带命名常量用于跳的速度分桶、落地严重度、坡度投影变体、一个总是向前的 idle 桥、一个停止桥的最小时间守卫,以及一个 fall 状态去抖器,所以 locomotion 手感是可单元测试的而不是靠猜的。


第 29 部分,共 29 部分。这是本系列目前的最后一部分。 上一篇:第 28 部分 - 一直铺到地平线的草,以及把自己藏起来的地面 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide