fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复 - #2999
Conversation
AnimatorState.speed is part of the shared AnimatorController asset. Modifying it at runtime pollutes all Animator instances sharing the same controller, causing animation speed corruption after cloning. - Add speed field to AnimatorStatePlayData, initialized from AnimatorState.speed on reset - Add proxy properties (name/clip/wrapMode/transitions/addStateMachineScript) - Change speed calculation to playData.speed * animator.speed - findAnimatorState now returns per-instance AnimatorStatePlayData - Export AnimatorStatePlayData for consumer code
Promote AnimatorStatePlayData from a play-slot object to a per-Animator per-state persistent handle. Each AnimatorLayerData holds a state→PlayData map; srcPlayData/destPlayData become nullable references into the map. API: - findAnimatorState(name, layerIdx?) returns AnimatorStatePlayData|null, lazy-creating the handle on first access (works even when the state has never played) - playData.speed is a getter/setter backed by _speedOverride; reads fall back to state.speed (live binding); clearSpeedOverride() resumes tracking the shared default - playData.state.xxx for shared asset access (no proxy properties) - resetForPlay() resets runtime fields only; user overrides survive transitions Bugs fixed: - _updateCrossFadeState now multiplies by playData.speed (was state.speed), so per-instance speed applies during cross-fade - findAnimatorState no longer returns the wrong state's playData when the queried state isn't currently playing (was: fell back to srcPlayData) Lifecycle changes: - AnimatorLayerData.statePlayDataMap caches per-state handles - switchPlayData() replaced by promoteDest() (src ← dest, dest = null) - _preparePlay/_prepareCrossFade get-or-create from the map and assign references rather than reset slot objects Cleanup: - Remove AnimatorStatePlayData proxy properties (name/clip/wrapMode/ transitions/addStateMachineScript) — use playData.state.xxx instead - Drop @todo on findLayerByName and duplicate JSDoc on findAnimatorState
…ernal/ Address code quality review on 57da59a: - AnimatorStatePlayData constructor no longer reads state.clip; clipTime defers to resetForPlay so findAnimatorState doesn't crash for states with no clip yet - Move AnimatorStatePlayData from internal/ to animation/ root since it is now public API returned by findAnimatorState; update imports - Annotate findAnimatorState and getCurrentAnimatorState return types as | null to match runtime behavior - Remove dead && guards in _updateCrossFadeState (layerState guarantees non-null entry) - Tighten AnimatorLayerData field comments
Add 6 regression tests covering the new findAnimatorState handle: - lazy create on first access (state never played) - speed override set before play applies on first play - override survives crossFade out and back - override is per-Animator (clone isolation, shared asset unmutated) - crossFade phase uses playData.speed (was state.speed before fix) - clearSpeedOverride resumes live tracking of state.speed Fix existing call sites broken by proxy removal: tests that accessed state.clip / state.clearTransitions / state.clipStartTime etc. now go through state.state.xxx (the shared AnimatorState). state.speed reads and writes remain on the per-instance handle.
Address code quality review: - Test #1 now uses a cloned animator (no afterEach pre-population) so it actually verifies lazy PlayData creation; rename to match intent - Test #2 drops @ts-ignore on _animatorLayersData by reading the override through the same handle returned by findAnimatorState - Test #5 tightens >0.1 threshold to closeTo(0.2, 0.05) so a regression reducing the multiplier wouldn't slip past - Align .eq/.greaterThan calls with the file's .to.eq/.to.be convention
Previously walk-up went all the way to GLTF_ROOT (the wrapper, no parent), but sceneRootChildren contains GLTF_ROOT's direct children — never GLTF_ROOT itself. Result: function always returned null, making multi-root skin wrapper detection a no-op. Stop the walk as soon as the entity is a direct child of the scene root. The final check then succeeds for joints under any sceneNode, returning the wrapper sceneRoot as rootBone. Verified via standalone reproduction matching the test fixture.
When entity X had a child also named X, findByPath("X") short-circuited to
return self due to the GLTF self-name prefix branch — making the same-name
child unreachable.
Try direct child lookup first; fall back to the self-name prefix only when
the child path doesn't match. Both the GLTF normalized-prefix case and the
same-name child case work correctly.
PR galacean#2984 changed Animator.findAnimatorState() to return AnimatorStatePlayData instead of AnimatorState. Unit tests were already updated to access shared-asset members via `.state.xxx`; e2e cases were missed and would TypeError at runtime when playwright loaded them. Convert each shared-asset access on findAnimatorState() results: - .clip -> .state.clip (animator-event, animator-additive) - .addTransition / .addExitTransition / ._getDuration -> .state.xxx (animator-stateMachine) - .addStateMachineScript -> .state.addStateMachineScript (animator-stateMachineScript) .speed reads/writes are intentionally preserved on the per-instance handle (the whole point of the API change).
…n flag - _prepareCrossFadeByTransition guards against crossFade to current src or dest state, since statePlayDataMap holds a single PlayData per AnimatorState; without the guard, dest aliases to src, resetForPlay clobbers the active runtime, and _updateCrossFadeState updates the same object twice - AnimatorStatePlayData.resetForPlay also resets _changedOrientation so re-entering a state doesn't carry the previous track's orientation flag into the new playback window True self-crossfade support requires splitting persistent override fields from transient src/dest runtime tracks; out of scope for this PR.
When the entity has a child with the same name as splits[0], findByPath must not fallback to the self-prefix interpretation: the user clearly intends to descend into the child, and a deeper-path miss should return null rather than silently re-resolve the path against the entity itself.
PR galacean#2984 changed findAnimatorState to return AnimatorStatePlayData | null. Update both EN and ZH docs to reflect: - Per-instance speed override (playData.speed) - Shared asset access (playData.state.xxx) - Nullable return guard - clearSpeedOverride() to resume live binding to state.speed
findAnimatorState now returns AnimatorStatePlayData | null. e2e cases were dereferencing without a guard, which would surface as "Cannot read properties of null" if a state name doesn't match the asset. Add fail-fast guards naming the missing state for actionable errors.
… multiple roots Previously: if all joints were under any sceneNodes' subtrees, _findSceneRootBone returned GLTF_ROOT, even when joints converged to a single top-level child. That over-promoted the rootBone to include unrelated sibling nodes (lights, cameras, props), affecting bounds. Now: track which top-level child each joint resolves to. Only return sceneRoot when joints span >1 different top-level children. Otherwise fall through to _findSkeletonRootBone for the LCA.
When play() interrupts a cross-fade, destPlayData and crossFadeTransition were left dangling. With persistent statePlayDataMap, this caused the self-crossFade alias guard to wrongly no-op subsequent crossFade calls to the previously-fading state. Clear destPlayData and crossFadeTransition on play() entry so the layer state matches reality.
If the requested state name doesn't match any layer, _getAnimatorLayerData was being called with playLayerIndex = -1, which would write a junk AnimatorLayerData entry at array index -1 (JS array negative indexing creates a property). Guard the lookup at the entry point.
Bring the JSDoc tag in line with the other engine-managed runtime fields on AnimatorStatePlayData (playedTime/clipTime/etc.) so docs/IDE filtering treats them uniformly.
Self-prefix fallback called _findChildByName with pathIndex=1, whose not-found backtrack path recursed into entity.parent — for detached or root entities, that's null and crashes on null._children. Use splits.slice(1) with pathIndex=0 so the recursion stays within the entity's subtree and returns null cleanly when the deeper path misses. Also retitle the fallback comment to a generic path-semantics description, since core/Entity should not carry GLTF-specific framing.
When called with an out-of-range layerIndex, _getAnimatorStateInfo accessed layers[idx].stateMachine and threw. This propagated to findAnimatorState (which is supposed to return null) and to play / crossFade entry points. Bound-check the index and return a stateInfo with layerIndex = -1 / state = null so all three callers see safe behavior.
When per-instance state speed is 0 (paused) and a transition fires, playCostTime / playSpeed produced NaN, which made remainDeltaTime > 0 evaluate false and the destination state silently dropped the remaining delta on that frame. Treat speed=0 as "no time consumed by this state" and pass deltaTime through to the destination instead.
GLTFSkinParser._findSceneRootBone reads glTFResource._sceneRoots which GLTFSceneParser populates synchronously. The current AssetPromise.all ordering preserves this; document the invariant so a future array reorder doesn't silently break skin root resolution.
…hine Bring local AnimatorStateTransition declarations into line with the project's camelCase convention.
…te(null) idiom AnimatorLayerData already used Record-style maps for animatorStateDataMap and curveOwnerPool; statePlayDataMap was the only Map in the animation module. Layer-internal stateName is canonical (AnimatorStateMachine deduplicates by name). Switch to the project's standard pattern for intra-class consistency and v8 hidden-class friendliness on small caches. Also normalize animatorStateDataMap initialization to Object.create(null) for the same null-prototype safety as curveOwnerPool.
The example showed `playData.speed = 0` immediately followed by `playData.clearSpeedOverride()`, which silently cancels the override. Comment out the resume call and label it as a later-stage operation so copy-pasting actually pauses the state.
The previous comment phrased the guard as a temporary workaround. The behavior is in fact deliberate: per-state persistent PlayData makes self-cross-fade structurally inexpressible without a separate transient track. Phrase the comment so future readers understand it as policy.
Replace per-scene Set<Entity> creation with parent-walk identity checks. Tracks first-encountered top-level joint root and compares subsequent joints by reference, returning sceneRoot the moment a divergent root is found.
Replace splits.slice(1) + _findChildByName(pathIndex=1) with a dedicated subtree-only path-search helper. Two improvements: no array allocation on every fallback, and the "fallback never backtracks to siblings" semantic is now expressed in the helper's contract instead of relying on the caller to neutralize backtracking via slicing.
…t + skin index guard - tests: "find animator state" was comparing `(instance as any)._state` (AnimatorState) against `getCurrentAnimatorState` (AnimatorStateInstance), which can never be equal. Also fix the layerIndex source — it was read from `_tempAnimatorStateInfo` *before* play() populated it, so it was -1 instead of 0. - Animator.getCurrentAnimatorState now calls _resetIfControllerUpdated() so it doesn't return a stale instance after a controller mutation, consistent with play/crossFade/findAnimatorState/update. - Add @remarks on both getCurrentAnimatorState and findAnimatorState spelling out that the returned instance is invalidated by controller structure changes (layers added/removed) and must be re-fetched. - GLTFSkinParser: when `skin.skeleton` is an out-of-range index, throw a precise error instead of silently assigning `undefined` to `skin.rootBone` (which surfaces as a confusing error later).
…field refs
Two issues surfaced when running the full Animator suite:
1. animation event test fails — the lazy `_ensureEventHandlers` was only
invoked at play()/crossFade() entry, so a clip.addEvent() after
play() didn't rebuild handlers on the next update. Move the ensure
call into `_fireAnimationEventsAndCallScripts` so update-path
re-checks the clip version too.
2. Three tests still reference fields that moved during the refactor:
- `srcPlayData.speed` — speed is now on the instance, change to
`srcPlayData.instance.speed`
- `layerData.destPlayData?.state.name` (two sites) — PlayData no
longer has `state`, change to `.instance.name`
All 52 Animator tests pass after this commit.
`getCurrentAnimatorState` now returns the per-Animator view (`AnimatorStateInstance`), which only exposes getters for these clip-framing fields. Writing on the view was a silent no-op, so the blendShape e2e never actually clamped the clip range — page timed out waiting for the expected frame. The intent is to freeze playback at frame 1, which is shared-state behavior (every Animator using this controller would freeze the same way). Write directly on the AnimatorState asset.
|
@GuoLei1990 整体方向都正确( 1.
|
… eventHandlers cache Address two corner-case rebuild misses in `_ensureEventHandlers`. 1. Script-add after play() Lazy cache only compared clip._updateFlagManager._version. addComponent(Script) on the Animator's entity does not bump any clip flag, so a script attached after play() never received existing clip events. Fix: Entity owns a `_scriptsVersion` counter bumped on _addScript/_removeScript; AnimatorStateData snapshots `eventsBuiltScriptsVersion`; rebuild on mismatch. 2. Clip swap with coincidental matching version `state.clip = newClip` already dispatches `state._updateFlagManager` (via _onClipChanged) and clip-internal addEvent/clearEvents bubbles through the same path. Switch the cache key from `clip._updateFlagManager.version` to `state._updateFlagManager.version` — state is the funnel for both clip-swap and clip-events-mutation, so a single version covers every input that affects eventHandlers binding. Also: - Promote `UpdateFlagManager._version` to a documented public `version` field (the class is @internal, so this stays engine-internal). - afterEach cleans clip events to keep test isolation. - Two red tests: scripts-added-after-play, and clip-swap-rebuilds-handlers.
|
@luzhuang 三条都处理了,逐条确认: 1. ✅
|
…teData After WeakMap<AnimatorState, T> identity caching landed, the stateName string parameter was no longer referenced inside _getAnimatorStateData (map.get uses the AnimatorState object identity directly). Drop the dead parameter and inline the now-stale local `name` variable in _preparePlay's only remaining use.
When `skin.skeleton` is absent and joints contain only bones, the previous joint-only LCA returned the bone root (e.g. mixamorig:Hips) rather than the GLTF_ROOT wrapper that also dominates the skinned mesh node's local frame. Real-world glTFs put the mesh node outside `skin.joints` and reference the skin via `node.skin`, so the mesh node has to be folded into the LCA inputs. `_findSkinRootBoneByLCA` prepends mesh nodes whose `node.skin === skinIndex` to the joint list and reuses the path-walk LCA. The explicit `skin.skeleton` out-of-range guard is preserved. The existing `testSkinRoot` fixture was masking the gap by placing Character_Man inside `joints`; the regression test now uses joints=[1,2] and `Character_Man.skin = 0`, exercising the LCA-via-mesh-node path. Ported from luzhuang/fix/shaderlab@4a8342b7d.
This reverts commit 755305e.
| return; | ||
| } | ||
|
|
||
| const scripts = []; |
There was a problem hiding this comment.
这个数组可以缓存一下,不需要每次都新生成。
There was a problem hiding this comment.
已修复 (commit 4699ea5)。
Animator._tempScripts 静态字段复用,参照引擎里 PostProcessManager._tempColliders 的同模式。使用前后都 scripts.length = 0 清空,避免持有 stale script 引用造成弱 leak。
private static _tempScripts: Script[] = [];
private _ensureEventHandlers(...) {
// ...
const scripts = Animator._tempScripts;
this._entity.getComponents(Script, scripts);
// ... build handlers ...
scripts.length = 0;
}顺带一起处理了同段代码的另一个用户感知 bug:disabled Script 仍收事件 — handlers 数组以前存 Function[] (bind 出来的 closure),丢失 script 引用所以 fire 时无法查 script.enabled。改成 { script, fn }[],fire 时 script.enabled && fn.call(script, parameter),对齐 Unity 行为基线。
…euse temp scripts array
Two issues addressed:
1. Disabled scripts still received animation event callbacks.
The handlers array previously stored bind-out closures (`Function[]`),
which discarded the originating Script reference — fire path had no
way to check `script.enabled`. Change AnimationEventHandler.handlers
to `{ script, fn }[]` so the fire path pulls `script.enabled` directly
from the source of truth at fire time. Unity parity: disabled
MonoBehaviours do not receive OnAnimationEvent.
2. `_ensureEventHandlers` allocated a new `[]` every rebuild as the
getComponents out array. Switch to a static `Animator._tempScripts`
reused across rebuilds (clear-on-exit to drop script references),
matching the engine convention (e.g. `PostProcessManager._tempColliders`).
Drop `IPoolElement` + empty `dispose()` from AnimationEventHandler — the
ClearableObjectPool was removed earlier in this PR and these had become
dead code.
Red test added: "disabled Script does not receive animation events"
verifies enable→fire, disable→silent, re-enable→fire cycle. 55/55 pass.
…Scripts and dead-code cleanup
Previous commit introduced `script.enabled && fn.call(...)` filtering in
the fire path and changed handlers to `{ script, fn }[]` to support it.
After a closer look at Unity / Unreal / Godot, all three intentionally do
NOT filter AnimationEvent dispatch by component-level enabled — the
event belongs to the clip's timeline, not to the script's lifecycle, so
`script.enabled` (which scopes Update/OnEnable) has no jurisdiction over
it. Galacean should follow the same boundary.
Revert:
- handler invocation back to direct `handlers[j](parameter)` (base shape)
- AnimationEventHandler.handlers back to `Function[]`
- drop the disabled-script red test
Keep (still valid improvements):
- `Animator._tempScripts` static array reuse — addresses
@cptbtptpbcptdtptp's review point, mirrors `PostProcessManager._tempColliders`
- Drop `IPoolElement` + empty `dispose()` from AnimationEventHandler —
dead since the ClearableObjectPool removal earlier in this PR
54/54 Animator tests pass.
Class doc condensed from 4-line prose to 2-line summary (core model + override semantics). The four pass-through getters (name/clip/clipStartTime/clipEndTime) have no JSDoc — their name + 1-line body is self-describing. speed/wrapMode JSDoc reduced to a single sentence each, kept in multi-line form per project comment-style convention.
Previous commit removed JSDoc from name/clip/clipStartTime/clipEndTime arguing "name is self-describing". That was wrong — they are public API on a class exported from @galacean/engine-core, so users need hover docs in VSCode and TypeDoc output. Pass-through wrappers still need a one-sentence doc; not writing one is only acceptable for internal/private fields.
GuoLei1990
left a comment
There was a problem hiding this comment.
增量审查(2026-05-18)
已关闭问题清单
所有历史跟踪问题均已修复,无遗留。上轮(2026-05-18T08:09:57Z HEAD c303355)结论 LGTM。
本轮新增 commits(fca884515 和 1851c1e8f)
均为纯文档变更,仅涉及 AnimatorStateInstance.ts,无实现代码改动。
fca884515 docs(animation): trim AnimatorStateInstance JSDoc
- 将 class 级注释从 4 行精简为 2 行,保留核心语义(override 字段作用域 + 未设置时透传)。
- 移除了
name / clip / clipStartTime / clipEndTime四个 pass-through getter 的 JSDoc,认为其自说明;将speed / wrapMode的注释各缩为单行。
1851c1e8f docs(animation): restore JSDoc on pass-through public getters
- 立即撤回了上一 commit 对 4 个透传 getter 移除 JSDoc 的决策。理由正确:这四个 getter 是
@galacean/engine-core对外导出的公开 API,用户依赖 VSCode hover 和 TypeDoc 产物,不能无注释。 - 每个 getter 补回了一行说明(
The name/clip/... of the underlying state)。 - 最终状态:所有公开 getter 均有 JSDoc,类级注释简洁且准确,
speed / wrapMode的单行描述也正确传达了 override 语义。
检查结论:
- 两个 commit 组合后语义自洽,最终文件状态正确。
- 注释内容与实现一致:
speed/wrapMode确实是_speed ?? _state.speed透传逻辑;类级 JSDoc 与实际 API 契约匹配。 - 无新增实现风险点。
总结
本轮变更纯属文档调整,最终 AnimatorStateInstance 的 JSDoc 状态规范、准确、完整。LGTM,无新问题。
Summary
从 #2983 抽离动画 + GLTF 加载器修复,独立 PR 便于 review 与合入
dev/2.0。物理 raycast/sweep 修复同步抽离至 #2998。
动画 — Shared asset + per-Animator view
把
AnimatorState一分为二:共享资产(AnimatorState,继续是 controller 持有的资源)和 per-Animator 视图(AnimatorStateInstance,新增类型)。视图按(Animator, state)对惰性创建,放在AnimatorLayerData的WeakMap里;视图上的 override 只影响所属 Animator,其它共享同一 controller 的 Animator 不受影响。公开 API
findAnimatorState(name, layerIdx?): AnimatorStateInstance | null— 返回该 Animator 上的视图(lazy create on first access);controller mutation 后失效,需要重新调用获取新视图getCurrentAnimatorState(layerIndex): AnimatorStateInstance | null— 返回当前正在播的视图;同样在 controller mutation 后失效AnimatorStateInstance.speed/wrapMode— per-instance overrideable;未覆盖时 fall-through 到底层AnimatorState资产(编辑器调 asset 仍能传递到未声明 override 的 instance),覆盖后该 instance 独立持有自己的值AnimatorStateInstance.name/clip/clipStartTime/clipEndTime— 透传访问底层资产字段真 Bug 修复(用户视角)
每条均以"用户场景 → 现象 → 修复后"描述。
越界
layerIndex调用 API 时崩溃animator.play("walk", 99)/crossFade("walk", 0.3, 99)/findAnimatorState("walk", 99)/getCurrentAnimatorState(99)layers[99].stateMachine抛TypeError: Cannot read property 'stateMachine' of undefinedstate 没绑 clip 时调
findAnimatorState崩溃state.clip.length抛 NPE暂停状态(
instance.speed = 0)transition 时下一帧目标 state 卡住0 / 0 = NaN,目标 state 这一帧 deltaTime 完全丢失,看起来卡了一帧play("A")中断 fade 后,再crossFade到原 fade 目标失效play("C"),之后又想crossFade("B")同名
removeState + addState后新 state 用旧 state 的曲线播放stateMachine.removeState(oldWalk); stateMachine.addState("walk"); animator.play("walk")删了
defaultState之后自动播放仍指向已删 statestateMachine.removeState(stateMachine.defaultState)defaultState字段仍持有被删 state 引用,下次 controller 重启/onEnable 尝试播放已删 stateremoveState自动把defaultState置 nullplay()后再addComponent(Script),clip 里已存在的事件不触发state.clip.addEvent(...)→animator.play(...)→entity.addComponent(MyScript)Entity._scriptsVersion)runtime 替换
state.clip后,旧 clip 的事件仍触发state.clip = newClipclip._updateFlagManager.version,新旧 clip version 巧合相等时 cache 误命中,eventHandlers 仍用旧 clipstate._updateFlagManager.version(state 自己 funnel 了 clip swap + events 变两路)instance.speedoverride 在 crossFade 阶段被忽略findAnimatorState("X").speed = 0.5; animator.crossFade("X", 0.3)_updateCrossFadeState用state.speed而非instance.speed,fade 阶段不遵守 per-instance overrideinstance.speedcrossFade 到当前正在播放/正在 fade 的 state 导致动画错乱
crossFade("B")或crossFade("A")多 root 骨骼 GLTF 加载 rootBone 解析错误或抛错
skin.skeleton缺失、且 joints 跨多个 scene root 的 GLTF_findSkeletonRootBonefallback 抛错或挂错 root entityGLTF
skin.skeleton索引越界静默失败skin.skeleton指向不存在的节点索引(外部数据错误或工具导出 bug)entities[skeleton]静默undefined→skin.rootBone = undefined→ 后续蒙皮渲染报远离根因的错Skin skeleton index N is out of range.controller 增删 layer 后,
getCurrentAnimatorState/findAnimatorState返回失效引用animator.animatorController.addLayer/removeLayer/clearLayers之后查这两个 API_resetIfControllerUpdated(),结构变化后正确返回 null 或新 instance架构简化
UpdateFlagManager加 monotonic_version计数器;AnimatorStateData.eventsBuiltVersion快照式记录上次构建版本。clip events 变化触发_version++,Animator 在 update/play 路径上 pull-check version 决定是否重建。消除了原来 listener push 路径上的反向引用 leak 风险,以及clipChangedListener注册/注销/dispose 一整套基础设施AnimatorLayerData上animatorStateDataMap/instanceMap都用WeakMap<AnimatorState, T>;state 被 GC 时 entry 自动消失,无需手动清理ClearableObjectPool<AnimationEventHandler>— 在我们的多 stateData 共享 + 各自独立失效语义下,pool 永远单调增长,从未起到复用作用;直接new更简单且无差异stateMachine → controllerdispatch 转发链 — WeakMap 改造后变得不必要,一并删除AnimatorLayerData.completeCrossFade()/clearCrossFadeSlot()把散落在 Animator 的 slot 字段管理收回 LayerData 内部Lifecycle 不变量
addLayer/removeLayer/clearLayers触发 controller updateFlag → Animator 下次访问入口走_reset(),丢弃所有_animatorLayersData(包含 instanceMap)。Public API JSDoc 明示这条契约,用户必须重新调用findAnimatorState/getCurrentAnimatorState获取新视图speed/wrapModeoverride 不会被PlayData.reset()清掉(它们物理上在AnimatorStateInstance上,PlayData.reset只重置运行时游标)GLTF 加载器
fix(loader): resolve skin rootBone by joint LCA—GLTFSkinParser._findSkeletonRootBone重写为_findSkeletonRootBoneByLCA:没有显式skin.skeleton时,rootBone 一律通过 joints 的最近公共祖先算出来。删除之前"无 skeleton 时 fallback 抛错"分支。GLTF_ROOT wrapping 由 dev/2.0 已合入的 GLTFSceneParser: Always create container root node for consistent animation bone paths #2942/fix(loader): always create GLTF_ROOT container for consistent animation paths #2943 保证,multi-root spanning joints 自然解析为 wrapper,converged joints 解析为真实 skeleton root(如Character_Root)— 已随 LCA-only rootBone 方案移除(LCA 不依赖GLTFSceneParser同步写_sceneRoots[i]_sceneRoots),post-load 仍由_handleSubAsset异步填充GLTFParserContextScene-before-Skin parse order — 把Scene从并行 parse 列表的尾部前置到Skin之前;显式注释 LCA 依赖 wrapper 已挂 parent chain 的不变量,并警告 Skin parser 不能 await full Scene(避免_createRenderer反向请求 Skin 造成循环依赖)GLTFSkinParserskin.skeleton 越界 guard — 显式 throwSkin skeleton index N is out of range.,不再静默undefined用户文档 + 示例
docs/{en,zh}/animation/animator.mdx— 同步新 API:findAnimatorState返回AnimatorStateInstance | null加 null guard、getCurrentAnimatorState返回AnimatorStateInstance | null加 null guard、instance.speed暂停/恢复示例e2e/case/animator-*.ts(5 个文件) — 适配findAnimatorState新返回类型,调用点加 null check;视图字段命名idleState/walkState/runState(变量名指代视图本身,而不是 def 资产)tests/src/core/Animator.test.ts,+775/-256 行) — 新增 51 个测试覆盖 per-instance override / fall-through / crossFade 保留语义 / lazy version 重建 / WeakMap 等行为抽离说明
dev/2.0的 commit(GLTF_ROOT 容器 GLTFSceneParser: Always create container root node for consistent animation bone paths #2942/fix(loader): always create GLTF_ROOT container for consistent animation paths #2943、blendShape e2e 适配)Breaking changes (2.0)
AnimatorStateInstance类型,从@galacean/engine-core公开导出Animator.findAnimatorState()返回AnimatorStateInstance | null(旧返回AnimatorState)Animator.getCurrentAnimatorState()返回AnimatorStateInstance | null(旧返回AnimatorState,且 out-of-range layer 会抛错)AnimatorStateInstance上的 override:speed/wrapMode是 per-Animator 字段,fall-through 到底层AnimatorState;视图上的 override 不影响其它 AnimatorAnimatorStateInstance在 controller 结构变化(addLayer/removeLayer/clearLayers)后失效;用户持有的旧引用变成 orphan,必须重新调用findAnimatorState/getCurrentAnimatorState获取AnimatorStateMachine.defaultState类型从AnimatorState改为AnimatorState | null;removeState删除当前 default state 时自动置 nullAnimatorStateMachine.removeState行为更精确:state 不在 array 中时整体跳过(包括_statesMap清理),避免误删同名 entryAnimator.findAnimatorState/play/crossFade对 out-of-range layerIndex 改为 safe no-op(旧实现会抛错)量化
b:types通过