Skip to content

ADR-202: fork 自治改动批次 — 一次回灌批量根治可改 fork 的上游缺口

状态: ✅ P0 已落地(vendored + postinstall);P2/P3 搭车项全部完成(audio 条目9 ✅ / IK 重解 ✅ / WASM 迁移 ✅);🟢 仅余 MODEL_WIND_FORCE_SCALE 真机标定与 🟡 vendor/fork 漂移防护待探明 日期: 2026-07-31 分发方案演进(2026-07-31): 初版否决的 git 依赖方案已由 -dist 分支跑通并采纳——fork 新增 build-dist-branch.mjs 产出只含编译产物(esm/ 包根 + 全 wasm + audio getter + KTX2)的 feat/p2-native-rigidbody-bundle-dist 分支,消费侧改为 "babylon-mmd": "github:eghrhegpe/babylon-mmd#feat/p2-native-rigidbody-bundle-dist"。解决了初版否决的三层错位(main 字段 / 94 处深路径 / postinstall 崩):分支包根即 esm/ 、无 devDeps、无 postinstall。取代 vendored postinstallapply-vendored-wasm.mjs 的 wasm 注入 + audio getter patch 均已由 -dist 自带,--ignore-scripts 重装验证产物仍在),frontend/package.json 已移除 postinstall。代价:每次 fork 重建产物需 node build-dist-branch.mjs && git push -f -dist。迁移踩坦见 docs/buglog/2026-07-31-babylon-mmd-dist-esm-resolution.md(三轮 ESM 解析缺陷,已全修,消费侧 tsc0/build/2704 全绿)。 P0 实现: 采纳 vendored 方案(非初版推荐的 git 依赖)——fork 重编译的 spr/mpr wasm 产物提交进 frontend/vendor/babylon-mmd-wasm/postinstallscripts/apply-vendored-wasm.mjs)在 npm ci 后注入 node_modules/babylon-mmd。生产仅加载 spr/mpr(InstanceType/*.js../wasm/{spr,mpr} import),mpd 为 debug 变体不分发。真机实测:模型原生发丝/裙摆随风摆动。 关联: ADR-201(原生刚体施力导出,本批次 P1 受益项)、ADR-200(路径1 已采纳)、ADR-192(永久自治下游)、docs/upstream/babylon-mmd-compatibility.md(23 处应对台账,本 ADR 在「自治路径解锁」新前提下重评) 背景: ADR-192 时代「fork 改动只能走 PR、PR 被上游拒」→ 全部本地应对。ADR-201 证明 fork 本地可编译回灌,分类前提翻转:对运行时功能类缺口,「改 fork」首次成为比「JS 反射/绕路」更根治的选项。本 ADR 把值得改 fork 的收敛为一个批次,避免零散决策。


一、P0 承重项 — fork 产物如何进入 CI(未解决则全批次阻塞)

这是本 ADR 的头号阻塞项,必须先解决,否则下面所有改动在发版产物里不存在。

1.1 现状:CI 拿的是官方版,fork 改动会「蒸发」

环节事实后果
frontend/package.json:72"babylon-mmd": "^1.2.0"声明 registry 版本
package-lock.json:2534-2537resolved = registry.npmmirror.com 官方 1.2.0 tarball + integrity 哈希锁死官方版
CI 全线(release.yml / ci.yml / web-pages.yml / webgl-weekly.yml / cache-warm.yml全部 npm ci严格按 lockfile 拉官方 1.2.0,integrity 校验拒绝本地篡改

结论:本地 node_modules/babylon-mmd 是手动覆盖的 fork 产物(能跑);CI 干净机器 npm ci 只拿官方版 → fork 新导出在发版产物里不存在 → 运行时 undefined is not a function

1.2 分发方案对比

方案CI 可复现代价采纳
file: 本地路径❌ CI 无 C:\Users\...\babylon-mmd,发版直接挂
git 依赖(github:eghrhegpe/babylon-mmd#<tag>,dist 提交进 fork)npm ci 可复现拉取fork 需 commit 编译产物、打 tag;切版会连带 1.2→1.3 API 跳变❌ 已探明不可行(见 §1.4)
vendored 进 app 仓库frontend/vendor/babylon-mmd-wasm/ + postinstall 注入)app 仓库体积涨、wasm 二进制入 git已采纳

1.3 已落地:vendored + postinstall(方案 3)

未选 git 依赖的原因:fork 是 1.3.0 源、app 装 1.2.0,切 git 依赖等于强制版本跳变(1.2→1.3),需先扫 API 破坏面;vendored 仅注入 wasm 产物,不动 JS 版本,风险更小。

  1. fork 重编译 build-wasm-spr + build-wasm-mpr(生产变体)。
  2. app 将 spr/+mpr/ 产物拷进 frontend/vendor/babylon-mmd-wasm/(整目录,删 .gitignore),commit 入 git*.wasm 已由 .gitattributes 标 binary)。
  3. postinstallscripts/apply-vendored-wasm.mjs)在 npm i/npm ci 后把 vendor 产物 cpSync 覆盖进 node_modules/babylon-mmd/esm/Runtime/Optimized/wasm/{spr,mpr}
  4. npm ci 先清空 node_modules 再装,postinstall 在装完后执行 → 注入不会被清掉,可复现。
  5. 同时解决「原仓拒 AI PR」——不发 PR,fork 产物随 app 仓走。

1.4 修订:路线 A(git 依赖)实跑探明 — 包结构错位,不可行

2026-07-28 增补。fork 侧 build-esm 已修通(工具链三因修复),但 git 依赖方案因 fork 包结构天生为 npm publish 设计而落不了地。

起因:P0 vendored 落地后,为消除 vendor 目录维护负担,重启路线 A 评估。先扫 1.2→1.3 API 破坏面(结论:安全,CHANGELOG 仅附加 + bugfix,mmd-adapter.ts 的私有字段访问均带守卫降级),再修通 fork 的 build-esm,打 tag v1.3.0-mmar1,准备 app 侧切版本。

build-esm 修通的三因修复(fork commit 64a94ce):

  1. Cargo.tomlwasm-opt = false — wasm-pack 从 GitHub releases 下载 binaryen 失败(CN 网络),在生成 package.json 前退出,导致 spr/mpr 缺 package.json → typeof import("./wasm/spr")moduleResolution: "bundler" 下解析失败 → TS2312。
  2. tsconfig.lib.jsonexperimentalDecorators + emitDecoratorMetadata — babylon.js 9.18 的 @serialize 已迁 TC39 标准装饰器签名,旧版冲突 → TS1240(.pure.ts 全挂)。
  3. .gitignore 开例外让 dist/esm/ + wasm/ 入 git,忽略 wasm-pack 每次重建生成的子目录 .gitignore

为何仍不可行 — 三层结构性错位

  1. main 字段错位:fork package.jsonmain: "esm/index.js" 指仓库根,publish-lib./dist(产物提升为包根)→ registry 包有 esm/,git clone 没有(只有 dist/esm/)→ import 'babylon-mmd' 404。
  2. 深路径 import 错位(致命):app 94 处(38 个文件)import "babylon-mmd/esm/Runtime/..." 走深路径解析,不经过 main。git 依赖下 node_modules/babylon-mmd/esm/Runtime/... 不存在(产物在 dist/esm/),94 条全挂。exports map 重映射在 vite/webpack 下行为参差,不稳。
  3. postinstall 会崩:fork package.jsonpostinstall: "ts-node postInstallScript.ts && patch-package" 依赖 devDeps,git 依赖不装 devDeps → ts-node: command not found

唯一干净解(-dist 分支)的代价:建只放 dist 内容的分支,包根即 dist,esm/ 和深路径都对。但每次 fork 重建产物需 force push -dist,双轨维护,代价高于 vendored(vendor 目录 ~200KB binary + 一个 postinstall 脚本)。

结论:路线 A 暂不采用。build-esm 修通 + tag v1.3.0-mmar1 保留在 fork 作为技术储备(证明 fork TS 可编译,为未来 upstream 修复后切 registry 1.3.0 扫清障碍)。P0 维持 vendored。


二、批次清单 — 23 处应对在「自治路径解锁」新前提下重评

前提翻转:ADR-192 的「全部本地应对」建立在「只能提 PR」上。现可本地回灌,重新分类为「改 fork 是否比现状更划算」。

A 类 — 纯类型/测试缺口(改 fork 零收益,维持现状)

条目 1/2/4/5/6/8/10/11/16/19/20/23(compatibility.md 编号):运行时对象本就有成员,仅 .d.ts 未声明;或 Babylon.js 侧类型 / 测试 mock。上游「接口最小化」是设计立场,本地交集类型是官方推荐消费方模式。共 13 条,不动。

B 类 — 运行时能力缺口(改 fork 可根治)

条目缺口编译路径划算度批次优先级
3(含 ADR-201)原生刚体无 JS 句柄 / _rigidBodyBundleMap 反射build-wasm-mpr + build-esm✅ 最划算P1
7physics / impl 反射访问TS build-esm✅ 提公开 getter,连带清 3 的反射P1(搭车)
9StreamAudioPlayer._audio 私有反射TS build-esm(纯 JS 类,非 wasm)⚠️ 加 get audio() 即可P2(搭车)
14setRuntimeAnimation 不重置时钟TS build-esm🟡 reset 时钟根治,但本地 seekAnimation(0) 已稳P3(可选)
17onFinish,用 onPause 代替TS build-esm🟡 加 onFinishObservable,但 onPause 兜底已稳P3(可选)
13WASM 模式 ikSolver = null,骨骼覆盖后无法重解 IKbuild-wasm-spr/mpr + build-esm✅ 已落地(A-class):fork 暴露 mmdModelSolveIk 导出,app 经 ikSolverIndex 回退重解,比自建 2-bone IK 更根治已完成(见 §五)
15VmdLoaderdispose()🟢 无状态需释放,非缺陷忽略

C 类 — 构建/副作用/知识(与源码改动无关)

条目 12/18/21/22:时序文档、移除 monkey-patch、side-effect 导入、MPR 动态导入。改 fork 无意义。

D 类 — 上游健壮性缺失(ADR-192 审核补遗发现,不在原 23 处台账)

2026-07-28 增补。来源:用户预感"抛开上游处理模型物理的情况,巨容易失效+不可控"→ 审核物理子系统发现。原 23 处台账是"接口缺口",D 类是"运行时健壮性缺失",属新类别。

缺口现状(本地兜底)fork 根治编译路径划算度批次优先级
MmdWasmPhysicsRuntimeImpl 无 disposed 守卫virtual-skirt/ground-collision 的 dispose() 靠 try/catch 兜底 impl 已销毁时的调用impl 加 isDisposed 标志,方法调用自动 no-opbuild-wasm-mpr + build-esm🟡 中(HMR/场景切换触发,非高频)P2 搭车
addRigidBody/addConstraint 返回 boolean 不抛异常virtual-skirt 调用方手动检查返回值 + logWarn(ADR-192 审核补遗刚补)失败抛异常或返回详细原因,消除"静默失效"根源build-wasm-mpr + build-esm🟡 中(已是适配层补丁,fork 根治可删调用方检查)P2 搭车

三、批次执行计划

阶段内容编译路径依赖
P0✅ 已落地:vendored 方案——fork 重编 spr/mpr → 拷进 frontend/vendor/ commit → postinstall 注入 → spr/mpr 含导出、真机风力起效无(已完成)
P1原生刚体施力导出(ADR-201,mmdModelRigidBodyApplyCentralForce 等,注:fork 源码里私有字段访问需改用 physics_model_context() 访问器,否则 E0616 编译失败)+ physics.impl/_rigidBodyBundleMap 提公开 getterbuild-wasm-mpr + build-esmP0
P2StreamAudioPlayerget audio()(条目 9)+ D 类两项:MmdWasmPhysicsRuntimeImplisDisposed 守卫 / addRigidBody/addConstraint 失败抛异常(均纯 TS build-esm,ADR-192 审核补遗发现)build-esmP0
P3(可选)setRuntimeAnimation reset 时钟 / onFinishObservablebuild-esmP0

关键顺序:P0 是所有 fork 改动能进发版的共同前提。建议 P0 单独先跑通(哪怕先只带 P1 一项),验证「改 fork → 编译 → tag → CI 可复现」整条链,再一次性把 P2/P3 搭上,避免为未验证的链路提前铺摊子。


四、对 CI / 发版的影响(本 ADR 核心结论)

  • P0 已解决(vendored):CI npm ci 后 postinstall 自动注入 vendor 的 spr/mpr → 产物含 P2 导出,无本地机器依赖,可复现;babylon-mmd 仍锁 1.2.0,无版本跳变风险。
  • 升级路径:fork 再改 → 重编 spr/mpr → 重拷 vendor → commit;postinstall 不变。
  • 软风险:vendor 的 wasm 二进制与 fork 源可能逐渐漂移(手工拷贝),需在 fork 改动后纪律重拷;app 仓体积因 wasm 二进制增长(可接受)。

五、待办

#待办状态备注
1P0 拍板vendored 方案落地,真机风力起效
2CI 干净验证完整 npm ci(清残留 node/esbuild 僵尸后重跑)已坐实:postinstall 自动注入 spr/mpr,vendored-patch.test.ts 3 断言钉结实 patch 生效(含负向验证)
3P2 搭车(条目9 audio)fork 0b54302get audio())+ app 4 文件(postinstall patch / mmd-adapter / 两个测试 mock)+ 守护测试;2400 单测全绿
4P2 搭车(D 类,ADR-192 审核补遗)🟡 fork 已储备 / app 暂缓isDisposed 守卫:fork 侧 getter 已加(技术储备);app 暂不 patch——virtual-skirt/ground-collision 已通过返回 false 降级,无反射 isDisposed 的代码,patch 进去是死代码,等将来 setup 前需主动探测 disposed 时再扩展 postinstall。"失败抛异常":经评估不适用——app 已降级,fork 改抛异常反而破坏 app 的降级契约,不做。
4bA-class:WASM 骨骼覆盖后 IK 重解(条目 13)全链路:fork 7edf759MmdModel::solve_ik → wasm-bindgen mmdModelSolveIk)+ 重编 spr/mpr(导出已核实四处 True)+ vendor 同步注入 + app 6 处补丁(solveIkNative / bone-override 4 处 / scene.ts resolver 注入)。与本地 TwoBoneIKSolver 共存:后者仍服务 feet-adjustment;本项补的是 bone-override 覆盖后重解原生 IK 链这块缺口。tsc 0 错、2417 单测全绿。
5MODEL_WIND_FORCE_SCALE 标定🟢 待真机风力已起效,按实测摆幅调系数
6vendor/fork 漂移防护🟡 待探明fork 每次改 wasm 后必须重拷 vendor,否则两者静默不一致;vendored-patch.test.ts 已部分缓解(锚点漂移会报红)
7feet-adjustment WASM 路径迁移(方案C→方案A)见 §六。两处方案C 全部迁移完成(2026-07-29):①feet-adjustment 的 _solveWasmLegIK/_findKnee/_propagateChildrenWasmSimple/BONE_KNEE_L/R 已移除,WASM 分支走 getWasmIkResolver() → mmdModelSolveIk;②bone-override 的 _solveManualLegIK/_resolveLegChains/_legChainCache/LEG_IK_CHAIN_CONFIG/invalidateLegChainCache/_ResolvedLegChain 已移除,替换为 _solvePosSlotIkWasm(经 _wasmIkResolver 重解原生 IK 链)。motion-algos/two-bone-ik.ts + 测试已删除。§6.3 两项关键验证均通过。2441 单测全绿。

六、feet-adjustment WASM 路径迁移(方案C→方案A)

背景: ADR-085 原始设计为「JS 模式走 ikSolver.solve(false),WASM 模式走方案C 纯 JS 余弦定理两骨骼 IK」。ADR-202 待办 #4b 为 bone-override 路径补齐了方案A(mmdModelSolveIk 导出),但 WASM 模式下仍有两处方案C:feet-adjustment 的 _solveWasmLegIK(脚部贴地层 order=5)和 bone-override 的 _solveManualLegIK(POS 偏移覆盖层 ~order=8)。导致同一帧、同一模型的 IK 链被两套不同算法先后修改。本 § 将两处方案C 全部迁到方案A,统一后完整删除 two-bone-ik.ts

6.1 四套 IK 重解路径现状对比

路径代码位置机制能力
JS 原生(feet-adjustment JS)feet-adjustment.ts:425-430ik.setWorldTranslation(groundY) → solver.solve(false)✅ 原生 IK 求解器,处理角度约束/趾链
JS 原生(bone-override IK 覆写)bone-override.ts:310-320solver.solve(false)_wasmIkResolver → solveIkNative → mmdModelSolveIk✅ JS/WASM 双模式统一走原生求解器(ADR-202 待办 #4b 已落地)
方案C(feet-adjustment WASM)feet-adjustment.ts:254-302 + motion-algos/two-bone-ik.tssolveTwoBoneIK(余弦定理) → applyRotationToWorldMatrix → _propagateChildrenWasmSimple❌ 单次求解、无角度约束、不处理趾链、大偏移失真
方案C(bone-override POS slot WASM)bone-override.ts:668-737 + motion-algos/two-bone-ik.ts_resolveLegChains → solveTwoBoneIK(余弦定理) → _propagateChildrenWasm❌ 同上,且与 feet-adjustment 方案C 重复

6.2 迁移目标

feet-adjustment.tsbone-override.ts 中的两处方案C 全部替换为 mmdModelSolveIk 路径,实现 WASM 模式下所有 IK 重解统一走原生 IK 求解器,删除 motion-algos/two-bone-ik.ts 及相关测试、缓存、骨骼查找代码。

6.3 关键验证(两项)

6.3a setWorldTranslation → WASM bone buffer 同步

方案A 的正确执行依赖一个前提:ik.setWorldTranslation(_vTarget) 写入后,WASM 侧 solve_ik 能读到新位置

  • 验证结果(2026-07-29 真机实测):✅ 同步是自动的,无需桥接
    • setWorldTranslation 后读 worldMatrix buffer 确认:match=true,写入值与 buffer 值一致
    • mmdModelSolveIk 后读同一 buffer:buf Y=0.000 不变(符合预期——solve_ik 修改的是链骨骼的 rotation,不是 IK 目标骨的 translation)
  • 机制说明:ik.setWorldTranslation 在 WASM 模式下写入 MmdWasmRuntimeBone.worldMatrix(WASM 内存的 Float32Array 视图),mmdModelSolveIkMmdModel::solve_ik 通过 target bone 索引读同一 buffer,数据一致。

6.3b ikSolverIndex 在 IK 目标骨上可用性

bone-override 路径验证的是被覆盖骨(如膝 左ひざikSolverIndex。但 feet-adjustment 操作的是 IK 目标骨(左足IK/右足IK

  • 验证结果(2026-07-29 真机实测):✅ ikSolverIndex=0,有效
    • (ik as { ikSolverIndex?: number }).ikSolverIndex 输出 0(左右脚分别为不同 solver,索引值因模型而异)
    • 大于等于 0,solveIkNative 不会因 ikSolverIndex < 0 提前返回
  • 结合 6.3a 和 6.3b:方案A 迁移的两个关键前提均已满足,无需额外桥接或 fork 改动

6.4 实施步骤

步骤内容文件
1导出 resolveWasmIk(modelId, ikSolverIndex, usePhysics):bone-override 新增导出函数,内部调 _wasmIkResolver(已由 scene.ts 注入),feet-adjustment 导入使用,无需关心 wasmInstance/model.ptr 细节。不新建文件,不重复 mmd-adapter.tssolveIkNativebone-override.ts 新增导出
2迁移 _adjustFoot() WASM 分支:_solveWasmLegIK() → 读 IK 目标骨的 ikSolverIndexresolveWasmIk(modelId, ikSolverIndex, false)feet-adjustment.ts
3迁移 _solveManualLegIK() WASM 分支:读 IK 目标骨 ikSolverIndexresolveWasmIk(modelId, ikSolverIndex, false),跳过方案C 的 solveTwoBoneIK/applyRotationToWorldMatrix/_propagateChildrenWasmbone-override.ts
4移除方案C 废弃代码:_solveWasmLegIK_propagateChildrenWasmSimple_findKneeBONE_KNEE_L/R@/motion-algos/two-bone-ik 导入;一并移除 _solveManualLegIK_resolveLegChains_legChainCacheinvalidateLegChainCacheLEG_IK_CHAIN_CONFIGfeet-adjustment.ts + bone-override.ts
5修复 feet-adjustment debug 日志(第 390 行):迁移后 WASM 模式下 ikSolver 仍为 null(WASM 运行时不暴露此字段),debug 日志会输出 solver=null 造成「IK 未生效」误判。改为检查 ikSolverIndex 存在性feet-adjustment.ts
6删除 motion-algos/two-bone-ik.ts + __tests__/two-bone-ik.test.ts两文件整删
7验证:WASM 模式脚部贴地功能正常(真机),对比 JS 模式行为一致性真机调试
8同步 ADR-085 §五 方案A 描述,标注「已实施」adr-085-feet-adjustment.md

6.5 依赖与风险

风险影响缓解
setWorldTranslation 未写入 WASM bone buffer迁移后 solve_ik 读到旧位置,脚部不贴地翻 fork 源码确认 solve_ik 的 target position 来源(§6.3a);如需桥接,fork 侧补充 syncBonesToWasm 导出(P0 已有 vendored 路径)
ikSolverIndex 在 IK 目标骨上为 -1/nullsolveIkNative 返回 false,静默失效真机调试输出 ikSolverIndex 值确认(§6.3b);若为 -1 需改用踝骨 ikSolverIndex(方案C 已有 _findKnee 思路可复用)
modelIdmodel.ptr 映射feet-adjustment 无 modelRegistry步骤 1 通过 resolveWasmIk 封装,feet-adjustment 不直接依赖
方案C 的 _findHip 逻辑迁移后不直接需要,但 solveFootTarget 仍需 legLength_findHip 保留(供 reachAngle/maxAngle 计算),只删 _findKnee_solveWasmLegIK
mmdModelSolveIk 对含 toe 链模型的处理方案A 会传播到趾骨,方案C 不会正向收益(更精确),但需确认 toe 链旋转方向正确(实测验证)
debug 日志 solver=null 假阴性WASM 迁移后 ikSolver 仍为 null(WASM 运行时不暴露此字段),调试者可能误以为 IK 未生效步骤 5 修复:改为输出 ikSolverIndex 存在性
two-bone-ik.ts 有多处 import漏删一处则编译失败步骤 6 前全局 grep 确认 two-bone-ik 所有引用点:grep -r "two-bone-ik" frontend/src frontend/src/__tests__
mmdModelSolveIk 回写 IK 目标骨 worldMatrix(含旋转),与 motion module 覆盖冲突bone-override order=0 写入用户旋转/位置覆盖 → feet-adjustment order=5 调 setWorldTranslation(groundY) 踩掉位置覆盖 + mmdModelSolveIk 踩掉旋转覆盖 → motion module 覆盖失效。另 foot-modules ikBone 硬编码半角 左足IK,部分 PMX 模型实际骨名为全角 左足IK,导致 _overrideMaps 中 key 不匹配feet-adjustment 在 setWorldTranslation 前检查 getModuleState(modelId, 'left-foot'/'right-foot').enabledparams 是否有非零值(feet-adjustment.ts line 276-291)。有激活的模块参数时跳过自动贴地,避免与 mmdModelSolveIk 冲突。同时修复 foot-modules.ts 的硬编码骨名问题需另案处理

6.6 验收标准

  • WASM 模式脚部贴地正常,与 JS 模式行为一致
  • npm run check 0 错误、npm run test 全绿
  • 方案C 全部代码已移除(_solveWasmLegIK / _propagateChildrenWasmSimple / _findKnee / _solveManualLegIK / _resolveLegChains / _legChainCache / invalidateLegChainCache / LEG_IK_CHAIN_CONFIG / BONE_KNEE_L/R / two-bone-ik 导入)
  • motion-algos/two-bone-ik.ts + __tests__/two-bone-ik.test.ts 已删除,全局 grep 无残留引用
  • 真机验证 ikSolverIndex 在 IK 目标骨上 >= 0(左右脚不同),且 setWorldTranslationmmdModelSolveIk 正确读到新位置
  • debug 日志输出 ikSolverIndex 而非 ikSolver,无假阴性误导
  • ADR-085 方案A 状态更新为「已实施」

6.7 时序分析:双调用路径的幂等性

方案A 迁移后,mmdModelSolveIk 在一帧内可能被调用两次:

调用点管线位置时机IK 目标骨位置效果
bone-override _solvePosSlotIkWasmstage=bone-override, order=0覆盖循环 + IK 保护恢复后动画位置 + foot-modules slot.pos重解 IK 链(髋/膝/踝 rotation)
feet-adjustmentstage=bone-override, order=5bone-override 之后setWorldTranslation(groundY)再次重解 IK 链(覆盖前一次结果)

执行顺序全景

bone-override callback (order=0):
  ① _runFrameHooks()           — FEET:0 帧钩子写 slot.pos 到 overrideMap
  ② _snapshotProtectedPositions — 快照 IK 目标骨 worldMatrix
  ③ 覆盖循环                   — _applyWasmOverride + _propagateChildrenWasm
  ④ _restoreProtectedPositions — 恢复 IK 目标骨到「动画位置 + slot.pos」
  ⑤ _solvePosSlotIkWasm        — 第一次 mmdModelSolveIk(读 ④ 的位置)
  ⑥ _protectedIkBoneNames.clear()

feet-adjustment callback (order=5):
  ⑦ solveFootTarget            — 计算是否需要贴地(jumpThreshold 判断)
  ⑧ setWorldTranslation        — 写 groundY 到 IK 目标骨 worldMatrix
  ⑨ mmdModelSolveIk            — 第二次重解 IK 链(读 ⑧ 的位置)

幂等性保证

mmdModelSolveIk幂等的——每次调用都是「读 IK 目标骨当前 worldMatrix → 反解整条 IK 链 → 回写链骨骼 rotation」,不是增量修改。第二次调用会用新的目标位置重新解链,完全覆盖第一次的链骨骼 rotation。

冲突分析(编排层互斥)

实际实现中,两条调用路径互斥,不会对同一 IK 链同帧双调用:

  • IK 目标骨有 POS slot 覆盖:步骤 ⑤ _solvePosSlotIkWasmmmdModelSolveIk(读 slot.pos 偏移后的位置)。feet-adjustment 在步骤 ⑦ 前检查 getOverride(cand) 候选骨名,发现 foundOverride=true → 直接 return,步骤 ⑧⑨ 不执行。
  • IK 目标骨无 POS slot 覆盖、脚需贴地(skip=false:步骤 ⑤ slot?.enabled || !slot.pos 为 false → 不调 mmdModelSolveIk。feet-adjustment 步骤 ⑧⑨ 执行(读 groundY)。
  • 脚在空中(skip=true:步骤 ⑤ 不调(无 slot),步骤 ⑧⑨ 也不调(res.skip → return)。两者都 no-op。

互斥由 feet-adjustment._adjustFootfoundOverride 检查保证,而非依赖 mmdModelSolveIk 的幂等性兜底。

运行时守护(feetDebug 模式)

互斥是编排层不变量,但万一被破坏(重构、新模块插入、边界条件遗漏),需要在运行时能捕获。bone-override.setWasmIkResolver 注入时包装一层 _guardedResolve

  • 检测_ikResolveFrameLog 记录本帧每个 (modelId, ikSolverIndex) 的调用次数。第二次同 key 调用即触发 warn。
  • 节流(不刷爆日志):双重节流——帧内 _ikResolveWarnSuppressed(每帧最多 1 条)+ 时间窗口 _ikResolveLastWarnTime(每 2 秒最多 1 条)。60fps 下持续异常每秒最多 0.5 条 warn。
  • 开关:仅 feetDebug.value=true 时启用检测逻辑;关闭时守护 no-op(仅多一次 Map.get),无开销。
  • 不阻止调用:守护只 warn,不阻止 mmdModelSolveIk 执行——避免误报阻断正常渲染。帧首 _resetIkResolveGuard 由 bone-override callback(order=0)调用,覆盖整个 bone-override stage(含 feet-adjustment order=5)。

冲突点:feet-adjustment 的 setWorldTranslation 踩掉 foot-modules 的位置覆盖

步骤 ⑧ 的 setWorldTranslation(groundY) 直接写 IK 目标骨的 worldMatrix buffer,会覆盖步骤 ④ 恢复的「动画位置 + slot.pos」。这是方案C 时代就存在的问题(方案C 的 _solveWasmLegIK 也读同一 buffer),方案A 迁移不改变此行为。

feet-adjustment 已有保护逻辑:在 _adjustFoot 中检查脚部模块(foot-modules)是否激活,有激活的非零参数时跳过自动贴地,避免冲突。

结论

方案A 迁移不引入新的时序问题。编排层互斥保证同一 IK 链一帧内最多被 mmdModelSolveIk 重解一次;运行时守护(feetDebug 模式)作为断言兜底,双重节流避免日志刷爆。与方案C 时代行为一致,唯一差异是方案A 回写全链 rotation(含趾链),比方案C 更精确。