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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughRefactors Animator to use per-instance ChangesAnimator playback model, API, and tests
Loader and GLTF-related tests
Sequence Diagram(s)sequenceDiagram
participant Dev as Developer
participant Animator
participant Layer as AnimatorLayerData
participant Play as AnimatorStatePlayData
participant StateData as AnimatorStateData
Dev->>Animator: findAnimatorState("walk")
Animator->>Animator: _resetIfControllerUpdated()
Animator->>Layer: getOrCreatePlayData(state)
Layer->>Play: new / return cached PlayData(state)
Play-->>Layer: playData
Layer-->>Animator: playData
Animator-->>Dev: AnimatorStatePlayData | null
Dev->>Play: playData.speed = 0
Play-->>Play: set per-instance speed
Dev->>Animator: play(state)
Animator->>Play: resetForPlay(...)
Play-->>Animator: initialized timing
Animator->>Animator: update(deltaTime)
Animator->>Play: update(deltaTime)
Play-->>Animator: updated _playedTime, _clipTime, _playState
Dev->>Animator: destroy()
Animator->>Animator: _reset()
Animator->>StateData: dispose()
StateData-->>Animator: listener detached
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/loader/src/gltf/parser/GLTFSkinParser.ts (1)
60-89:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winGuard against empty joints array.
If
jointsis empty andskeletonis undefined, calling_findSkeletonRootBoneByLCA([])will throwTypeError: Cannot read properties of undefined (reading 'length')at line 75 when accessingpaths[joints[0]](wherejoints[0]isundefined).While GLTF skins with zero joints are likely invalid, the loader should handle this gracefully rather than crash.
🛡️ Proposed fix to add guard
private _findSkeletonRootBoneByLCA(joints: number[], entities: Entity[]): Entity | null { + if (joints.length === 0) { + return null; + } const paths = <Record<number, Entity[]>>{}; for (const index of joints) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts` around lines 60 - 89, The _findSkeletonRootBoneByLCA function can throw when called with an empty joints array; add an early guard in _findSkeletonRootBoneByLCA to return null if joints is empty (e.g., check if joints.length === 0) before building paths, so the method exits gracefully instead of accessing joints[0]; ensure this short-circuit is applied in the existing function (referenced by name) to prevent the undefined index access.
🧹 Nitpick comments (2)
tests/src/core/Animator.test.ts (2)
255-257: ⚡ Quick winUse tolerance assertions for timeline floats.
Several new checks use exact
to.eq(...)on floating-point timeline values (_playedTime/_clipTime). These are brittle across runtimes and can cause flaky tests; preferto.be.closeTo(..., epsilon).Suggested change pattern
- expect(srcPlayData._playedTime).to.eq(0.3); + expect(srcPlayData._playedTime).to.be.closeTo(0.3, 1e-6); - expect(layerData.srcPlayData._clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData._clipTime).to.be.closeTo(walkState.state.clip.length * 0.5 + 0.5, 1e-6);Also applies to: 850-852, 880-896, 930-950, 975-977
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/core/Animator.test.ts` around lines 255 - 257, Replace brittle exact equality assertions on floating timeline values with tolerance-based checks: change expect(srcPlayData._playedTime).to.eq(0.3) and expect(srcPlayData._clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()) to use Chai’s to.be.closeTo(expected, epsilon) (e.g., epsilon = 1e-6 or 1e-3 depending on precision needs). Update all similar assertions referencing srcPlayData._playedTime, srcPlayData._clipTime, and any uses of runState.state._getDuration() in this test (also at the other ranges noted: 850-852, 880-896, 930-950, 975-977) to use closeTo with a consistent epsilon to avoid flaky float comparisons.
1316-1328: ⚡ Quick winDestroy cloned entities to keep test isolation clean.
These tests create cloned entities but do not destroy them. Add cleanup to avoid state/listener accumulation across cases.
Suggested cleanup
it("findAnimatorState lazy-creates handle for unplayed state", () => { const cloneEntity = animator.entity.clone(); const cloneAnimator = cloneEntity.getComponent(Animator); const survey = cloneAnimator.findAnimatorState("Survey"); expect(survey).to.not.eq(null); expect(survey.state.name).to.eq("Survey"); expect(survey.speed).to.eq(survey.state.speed); expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); + cloneEntity.destroy(); }); it("per-instance speed is per-Animator (clone isolation)", () => { const cloneEntity = animator.entity.clone(); const cloneAnimator = cloneEntity.getComponent(Animator); expect(cloneAnimator.animatorController).to.eq(animator.animatorController); animator.findAnimatorState("Survey").speed = 0.5; expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); expect(cloneAnimator.findAnimatorState("Survey").speed).to.eq(1); const sharedSurvey = animator.animatorController.layers[0].stateMachine.findStateByName("Survey"); expect(sharedSurvey.speed).to.eq(1); + cloneEntity.destroy(); });Also applies to: 1368-1380
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/core/Animator.test.ts` around lines 1316 - 1328, The test creates cloned entities but never cleans them up; after creating cloneEntity and using cloneAnimator (from Animator and findAnimatorState) ensure the cloned entity is destroyed to avoid lingering state/listeners — call the appropriate destructor on cloneEntity (same fix for the similar test around the other case that uses cloneEntity/cloneAnimator at lines 1368-1380) so each test leaves no residual entities.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/animation/Animator.ts`:
- Around line 209-210: getCurrentAnimatorState may return stale data because it
reads this._animatorLayersData directly without invoking
_resetIfControllerUpdated(); modify getCurrentAnimatorState to call
this._resetIfControllerUpdated() at the start so controller-change cache is
cleared before reading this._animatorLayersData?.srcPlayData?.state and
returning the value (preserve returning null when absent).
---
Outside diff comments:
In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts`:
- Around line 60-89: The _findSkeletonRootBoneByLCA function can throw when
called with an empty joints array; add an early guard in
_findSkeletonRootBoneByLCA to return null if joints is empty (e.g., check if
joints.length === 0) before building paths, so the method exits gracefully
instead of accessing joints[0]; ensure this short-circuit is applied in the
existing function (referenced by name) to prevent the undefined index access.
---
Nitpick comments:
In `@tests/src/core/Animator.test.ts`:
- Around line 255-257: Replace brittle exact equality assertions on floating
timeline values with tolerance-based checks: change
expect(srcPlayData._playedTime).to.eq(0.3) and
expect(srcPlayData._clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration())
to use Chai’s to.be.closeTo(expected, epsilon) (e.g., epsilon = 1e-6 or 1e-3
depending on precision needs). Update all similar assertions referencing
srcPlayData._playedTime, srcPlayData._clipTime, and any uses of
runState.state._getDuration() in this test (also at the other ranges noted:
850-852, 880-896, 930-950, 975-977) to use closeTo with a consistent epsilon to
avoid flaky float comparisons.
- Around line 1316-1328: The test creates cloned entities but never cleans them
up; after creating cloneEntity and using cloneAnimator (from Animator and
findAnimatorState) ensure the cloned entity is destroyed to avoid lingering
state/listeners — call the appropriate destructor on cloneEntity (same fix for
the similar test around the other case that uses cloneEntity/cloneAnimator at
lines 1368-1380) so each test leaves no residual entities.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 735a15df-3ec6-465a-bcde-2ae3e27028fb
📒 Files selected for processing (20)
docs/en/animation/animator.mdxdocs/zh/animation/animator.mdxe2e/case/animator-additive.tse2e/case/animator-event.tse2e/case/animator-play-backwards.tse2e/case/animator-stateMachine.tse2e/case/animator-stateMachineScript.tspackages/core/src/Entity.tspackages/core/src/animation/Animator.tspackages/core/src/animation/AnimatorStatePlayData.tspackages/core/src/animation/index.tspackages/core/src/animation/internal/AnimatorLayerData.tspackages/core/src/animation/internal/AnimatorStateData.tspackages/core/src/animation/internal/AnimatorStatePlayData.tspackages/loader/src/gltf/parser/GLTFParserContext.tspackages/loader/src/gltf/parser/GLTFSceneParser.tspackages/loader/src/gltf/parser/GLTFSkinParser.tstests/src/core/Animator.test.tstests/src/core/Entity.test.tstests/src/loader/GLTFLoader.test.ts
💤 Files with no reviewable changes (1)
- packages/core/src/animation/internal/AnimatorStatePlayData.ts
| getCurrentAnimatorState(layerIndex: number): AnimatorState | null { | ||
| return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; |
There was a problem hiding this comment.
Reset controller-change cache before returning current state
getCurrentAnimatorState() can return stale state data after controller mutations because it reads _animatorLayersData without running _resetIfControllerUpdated() (unlike play, update, crossFade, and findAnimatorState). This is observable if callers query current state before the next update tick.
Proposed fix
getCurrentAnimatorState(layerIndex: number): AnimatorState | null {
+ this._resetIfControllerUpdated();
return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| getCurrentAnimatorState(layerIndex: number): AnimatorState | null { | |
| return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; | |
| getCurrentAnimatorState(layerIndex: number): AnimatorState | null { | |
| this._resetIfControllerUpdated(); | |
| return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/animation/Animator.ts` around lines 209 - 210,
getCurrentAnimatorState may return stale data because it reads
this._animatorLayersData directly without invoking _resetIfControllerUpdated();
modify getCurrentAnimatorState to call this._resetIfControllerUpdated() at the
start so controller-change cache is cleared before reading
this._animatorLayersData?.srcPlayData?.state and returning the value (preserve
returning null when absent).
- Compress the caller comment to two lines that state the trigger and the precise condition under which LCA falls back to the GLTF_ROOT wrapper (only when joints span multiple top-level scene nodes; joints spanning sibling armatures under the same top-level still resolve to a non-wrapper node). - Add a brief JSDoc on _findSkeletonRootBoneByLCA covering algorithm and null-return semantics; the cross-parser wrapper invariant stays in the caller comment where it is consumed.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev/2.0 #2999 +/- ##
===========================================
- Coverage 78.14% 78.04% -0.10%
===========================================
Files 900 900
Lines 99255 99602 +347
Branches 10213 10287 +74
===========================================
+ Hits 77563 77735 +172
- Misses 21521 21692 +171
- Partials 171 175 +4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts`:
- Around line 39-41: The code assigns skin.rootBone = entities[skeleton] without
validating the lookup; update GLTFSkinParser so that before assigning
skin.rootBone (when skeleton is defined) you verify that entities[skeleton]
exists and is a valid bone, and if not throw a clear error (or return a parsing
error) mentioning the invalid skin.skeleton index and the skin id; modify the
logic around the skeleton variable and skin.rootBone assignment to perform this
check and fail fast with a descriptive message rather than allowing rootBone to
become undefined.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 91f4fa93-5ebd-4b1f-b70a-0420bdcf83a9
📒 Files selected for processing (1)
packages/loader/src/gltf/parser/GLTFSkinParser.ts
| if (skeleton !== undefined) { | ||
| const rootBone = entities[skeleton]; | ||
| skin.rootBone = rootBone; | ||
| skin.rootBone = entities[skeleton]; | ||
| } else { |
There was a problem hiding this comment.
Validate skin.skeleton lookup before assigning rootBone.
Line 40 assigns entities[skeleton] directly. If the skeleton index is invalid/missing, rootBone becomes undefined and the parser fails later with a less actionable error. Fail fast here.
Suggested fix
if (skeleton !== undefined) {
- skin.rootBone = entities[skeleton];
+ const rootBone = entities[skeleton];
+ if (!rootBone) {
+ throw new Error(`Failed to find skeleton root bone from skin.skeleton index: ${skeleton}.`);
+ }
+ skin.rootBone = rootBone;
} else {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (skeleton !== undefined) { | |
| const rootBone = entities[skeleton]; | |
| skin.rootBone = rootBone; | |
| skin.rootBone = entities[skeleton]; | |
| } else { | |
| if (skeleton !== undefined) { | |
| const rootBone = entities[skeleton]; | |
| if (!rootBone) { | |
| throw new Error(`Failed to find skeleton root bone from skin.skeleton index: ${skeleton}.`); | |
| } | |
| skin.rootBone = rootBone; | |
| } else { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts` around lines 39 - 41, The
code assigns skin.rootBone = entities[skeleton] without validating the lookup;
update GLTFSkinParser so that before assigning skin.rootBone (when skeleton is
defined) you verify that entities[skeleton] exists and is a valid bone, and if
not throw a clear error (or return a parsing error) mentioning the invalid
skin.skeleton index and the skin id; modify the logic around the skeleton
variable and skin.rootBone assignment to perform this check and fail fast with a
descriptive message rather than allowing rootBone to become undefined.
The synchronous write was introduced together with _findSceneRootBone, the only same-tick consumer that needed _sceneRoots[i] visible during Skin parse. Once _findSceneRootBone was deleted in favor of the LCA-only resolution, this write lost its consumer. _findSkeletonRootBoneByLCA reads entity.parent, not _sceneRoots, so the same-tick alignment with _defaultSceneRoot is no longer load-bearing. _handleSubAsset still writes _sceneRoots[i] asynchronously after Scene resolves, which is what every post-load reader (instantiateSceneRoot, sceneRoots getter) actually consumes.
Drop the 13-line Scene-before-Skin justification. The array position already encodes the data-flow direction (Scene precedes Skin), and at runtime the ordering is enforced by JS microtask semantics regardless of array index — Scene.parse attaches GLTF_ROOT synchronously inside AssetPromise.all, and Skin's LCA runs inside getAccessorBuffer().then, so sync always precedes async within the same batch. Keep the reorder for readability and for safety margin against future synchronous LCA refactors; remove the prose that over-stated the current invariant as load-bearing.
726b194 to
b109a42
Compare
Drop the ByLCA suffix added earlier. Same-module private helpers (_computeLocalBounds, _createRenderer, _parseEntityComponent) all use intent-only naming with no algorithm in the identifier; ByLCA is the sole exception. The algorithm name lives in the JSDoc, which is the right place — it documents the current implementation choice without making the algorithm part of the function's contract. Callers care about "find rootBone", not "find rootBone by which algorithm".
There was a problem hiding this comment.
♻️ Duplicate comments (1)
packages/loader/src/gltf/parser/GLTFSkinParser.ts (1)
43-45:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winThrow an
Errorobject, not a string literal.Throwing a string weakens stack traces and type safety, making error handling upstream more brittle. The
indexparameter is available in scope to provide additional context.Suggested fix
const rootBone = this._findSkeletonRootBone(joints, entities); if (!rootBone) { - throw "Failed to find skeleton root bone."; + throw new Error(`Failed to find skeleton root bone for skin index ${index}.`); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts` around lines 43 - 45, Replace the string throw with an Error object that includes context: where the code currently does `if (!rootBone) { throw "Failed to find skeleton root bone."; }`, change it to throw a new Error that includes the `index` (or other identifying info) so stack traces and type checking work (e.g., in GLTFSkinParser where `rootBone` is checked, throw new Error(`Failed to find skeleton root bone for skin ${index}`)). Ensure you use `throw new Error(...)` rather than throwing a string.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts`:
- Around line 43-45: Replace the string throw with an Error object that includes
context: where the code currently does `if (!rootBone) { throw "Failed to find
skeleton root bone."; }`, change it to throw a new Error that includes the
`index` (or other identifying info) so stack traces and type checking work
(e.g., in GLTFSkinParser where `rootBone` is checked, throw new Error(`Failed to
find skeleton root bone for skin ${index}`)). Ensure you use `throw new
Error(...)` rather than throwing a string.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 34d1c77b-bc67-4fdc-90a8-4994ec9f4497
📒 Files selected for processing (2)
packages/loader/src/gltf/parser/GLTFParserContext.tspackages/loader/src/gltf/parser/GLTFSkinParser.ts
Same template error as the findByPath JSDoc fix in this PR — three sibling methods originally carried "@returns The component which be found", which is wrong on both axes: the return type is Entity not Component, and the phrasing is ungrammatical. Bring all three into sync. Also fix the findByName @param ("The name of the entity which want to be found") and the stray tab character on getChild's @returns line.
GuoLei1990
left a comment
There was a problem hiding this comment.
已关闭问题清单
所有历史 P0/P1 问题均已修复。持续跟踪的 P2 问题状态(本轮无新进展):
findAnimatorStateJSDoc 未说明 controller mutation 后 handle 失效:仍未修复getCurrentAnimatorState未调用_resetIfControllerUpdated():仍未修复GLTFSkinParserentities[skeleton]无越界 guard:仍未修复
总结
本轮新增唯一 commit(efa4795e)是纯 JSDoc 修正:修复 Entity.ts 中 getChild 和 findByName 两处沿用错误的 @returns The component which be found(返回类型是 Entity 而非 Component,且语法不通),同步修正 findByName 的 @param 措辞和 getChild 的 stray tab。改动干净,无需额外审查。
三条持续跟踪的 P2 本轮无新进展,重列于下。
问题
[P2] findAnimatorState JSDoc 仍未说明 controller mutation 后 handle 失效
当前 JSDoc 仅写 @returns Per-instance AnimatorStatePlayData, or null if no state matches,AnimatorStatePlayData 类注释写道 "Persists for the layer's lifetime",但均未说明 addLayer/removeLayer 触发 _resetIfControllerUpdated → _reset() 时整个 _animatorLayersData(含所有 statePlayDataMap)被丢弃,旧 handle 静默孤立。建议在 findAnimatorState 的 JSDoc 补一句:
Note: handles become stale after a controller structure change (layers added or removed). Re-call
findAnimatorStateto get a fresh handle.
P2,不阻塞合并。
[P2] getCurrentAnimatorState 仍未调用 _resetIfControllerUpdated()
play/update/crossFade/findAnimatorState 均已调用,唯独 getCurrentAnimatorState 直接读 _animatorLayersData[layerIndex]?.srcPlayData?.state ?? null,可能在 controller mutation 后返回旧 state。建议对齐:
getCurrentAnimatorState(layerIndex: number): AnimatorState | null {
this._resetIfControllerUpdated();
return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null;
}P2,不阻塞合并。
[P2] GLTFSkinParser: entities[skeleton] 无越界 guard
skin.skeleton 指向不存在节点索引时,entities[skeleton] 为 undefined,skin.rootBone 静默变成 undefined,运行时报错离根因很远。建议:
if (skeleton !== undefined) {
const rootBone = entities[skeleton];
if (!rootBone) {
throw `Skin skeleton index ${skeleton} is out of range.`;
}
skin.rootBone = rootBone;
}P2,不阻塞合并。
整体 LGTM,以上三条 P2 均不阻塞合入。
Summary
从 #2983 抽离动画 + GLTF 加载器修复,独立 PR 便于 review 与合入
dev/2.0。物理 raycast/sweep 修复同步抽离至 #2998。
动画 — Per-state PlayData handle
把
AnimatorStatePlayData升级为"per-Animator per-state 持久 handle",作为公开 API 暴露。findAnimatorState(name, layerIdx?): AnimatorStatePlayData | null— 返回稳定 handle(lazy create on first access),即使 state 从未播放也能拿到;controller mutation 后通过 update flag guard 先 reset 再返回,避免拿到 stale layerDataAnimatorLayerData.statePlayDataMap: Record<string, AnimatorStatePlayData>— 按state.name索引,Object.create(null)初始化,跟同 class 内animatorStateDataMap/curveOwnerPool风格一致;getOrCreatePlayData内做 identity 校验,同名 state 替换时重建 handleplayData.speed— getter/setter,内部_speed: number | undefined;未写入前 live-bindstate.speed(编辑器调 asset 仍能传递到未声明 override 的 instance),写入后该 instance 拥有自己的 speed(之后 asset.speed 变化不再传递);想再次跟随 asset 直接写playData.state.speedplayData.state.xxx— 显式访问 shared 资产(删除全部 proxy properties /addStateMachineScript)_前缀(_stateData/_playedTime/_clipTime/_playState/_currentEventIndex/_isForward/_offsetFrameTime),stripInternal把@internal字段从 d.ts 剥离,npm 用户公开面只剩state/speedAnimatorStateData—state改 readonly 构造参数 +dispose()集中 detachclipChangedListener;三处生命周期边界(identity miss /_reset/_onDestroy)都调dispose()释放 listener,避免 controller mutation 或 destroy 后 listener 残留在 surviving state 上_updateCrossFadeState— cross fade 阶段使用playData.speed(修复原来用state.speed导致 cross fade 阶段忽略 per-instance override 的 bug)findAnimatorState不再读state.clip.length,避免 state 没有 clip 时崩溃_getAnimatorStateInfo加 bounds check,findAnimatorState返回 null,play/crossFadeno-op,避免layers[99].stateMachine抛错playData.speed=0时_updatePlayingState中playCostTime / playSpeed不再产生 NaN_preparePlay清理 stale 状态 — 中断 cross fade 时清空destPlayData和crossFadeTransitiongetCurrentAnimatorState— 返回类型AnimatorState | null(layer 不存在 / 无 state 播放时返回 null)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)GLTFSceneParser同步写_sceneRoots[i]— 与_defaultSceneRoot在同 tick 可见,避免_handleSubAsset异步路径下两个 wrapper-index 字段出现"一个 set 一个 undefined"的窗口GLTFParserContextScene-before-Skin parse order — 把Scene从并行 parse 列表的尾部前置到Skin之前;显式注释 LCA 依赖 wrapper 已挂 parent chain 的不变量,并警告 Skin parser 不能 await full Scene(避免_createRenderer反向请求 Skin 造成循环依赖)用户文档 + 示例
docs/{en,zh}/animation/animator.mdx— 同步新 API:findAnimatorState返回AnimatorStatePlayData | null加 null guard、playData.speed暂停/恢复示例、getCurrentAnimatorState返回AnimatorState | null加 null guarde2e/case/animator-*.ts(5 个文件) — 适配findAnimatorState新返回类型,调用点加 null check 和playData.state.xxx访问抽离说明
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)
Animator.findAnimatorState()返回AnimatorStatePlayData | null(旧返回AnimatorState)Animator.getCurrentAnimatorState()返回AnimatorState | null(旧实现 out-of-range layer 会抛错)AnimatorStatePlayData不再代理AnimatorState字段(name/clip/wrapMode/transitions/addStateMachineScript)—— 用playData.state.xxxAnimatorStatePlayData移出internal/,从@galacean/engine-core公开导出;stripInternal把 engine-managed runtime 字段从 d.ts 剥离,公开面只剩state/speedAnimator.findAnimatorState/play/crossFade对 out-of-range layerIndex 改为 safe no-op(旧实现会抛错)Test plan
tests/src/core/Animator.test.ts— 通过 53 个(baseline + 大量新增),含 per-state handle / per-instance speed / alias guard / out-of-range layer / zero-speed transition / state-machine self-transition / play interrupt crossFade clears dest / state identity 替换 / listener 不累积 / destroy detach / self-name-prefixed curve path on wrapped rootstests/src/loader/GLTFLoader.test.ts— 通过 7 个,含 multi-root span LCA / single-skeleton converge / explicit skeleton index抽离历史
续自 #2984(branch 重命名
feat/animation-physics→fix/animation-loader,跨仓库 PR 的 head_ref 不能跟随 fork branch rename,故关闭原 PR 重开)。原 PR 的 review 讨论保留在 #2984 评论区。Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests