diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index d4cf4f3d8d..556c11eb04 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -116,8 +116,19 @@ animator.speed = 1; If you only want to pause a specific `AnimatorState` , you can do so by setting its speed to 0. ```typescript -const state = animator.findAnimatorState("xxx"); -state.speed = 0; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // State not found in any layer + return; +} + +// Pause only this Animator instance's playback of the state. +playData.speed = 0; + +// To resume later, just assign the desired speed again, e.g.: +// playData.speed = 1; +// or follow whatever the asset is currently configured to: +// playData.speed = playData.state.speed; ``` ### Transition to Specified Animation State @@ -130,26 +141,37 @@ animator.crossFade("OtherStateName", 0.3); ### Get Current Playing Animation State -You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once. +You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). The return type is `AnimatorState | null` — `null` is returned when the specified layer doesn't exist or no state is currently playing on it. Once you obtain a non-null state you can set its properties, such as changing the default loop playback to play once. ```typescript const currentState = animator.getCurrentAnimatorState(0); -// Play once -currentState.wrapMode = WrapMode.Once; -// Loop playback -currentState.wrapMode = WrapMode.Loop; +if (currentState) { + // Play once + currentState.wrapMode = WrapMode.Once; + // Loop playback + currentState.wrapMode = WrapMode.Loop; +} ``` ### Get Animation State -You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the specified `AnimatorState` . After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once. +You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the playback data for a specified `AnimatorState`. The return type is `AnimatorStatePlayData | null` (`null` when no layer contains a state with that name). + +The returned `AnimatorStatePlayData` exposes two distinct surfaces: + +- `playData.speed` — per-Animator playback speed. **Until written**, it live-binds to `state.speed`; once you write a value the instance owns its own speed, and later changes to `state.speed` no longer flow through. To follow the asset again, simply assign `playData.state.speed` back. Reading and writing only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`. +- `playData.state` — the underlying shared `AnimatorState` asset. Mutating fields like `wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. ```typescript -const state = animator.findAnimatorState("xxx"); -// Play once -state.wrapMode = WrapMode.Once; -// Loop playback -state.wrapMode = WrapMode.Loop; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // State not found in any layer + return; +} + +// Mutating the shared AnimatorState asset (affects all Animators using this controller). +playData.state.wrapMode = WrapMode.Once; +playData.state.wrapMode = WrapMode.Loop; ``` ### Animation Culling diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index 0f95eb4550..b3964fd146 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -120,8 +120,19 @@ animator.speed = 1; 如果你只想针对某一个 `动画状态` 进行暂停,可以通过将它的速度设置为 0 来实现。 ```typescript -const state = animator.findAnimatorState("xxx"); -state.speed = 0; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // 任何一个动画层都没有该状态 + return; +} + +// 仅暂停当前 Animator 实例的该状态播放。 +playData.speed = 0; + +// 想要恢复时再写一次目标速度即可,例如: +// playData.speed = 1; +// 或者跟随当前 asset 配置: +// playData.speed = playData.state.speed; ``` ### 过渡指定动画状态 @@ -135,26 +146,37 @@ animator.crossFade("OtherStateName", 0.3); ### 获取当前在播放的动画状态 -你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。获取之后可以设置 `动画状态` 的属性,比如将默认的循环播放改为一次。 +你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorState | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。 ```typescript const currentState = animator.getCurrentAnimatorState(0); -// 播放一次 -currentState.wrapMode = WrapMode.Once; -// 循环播放 -currentState.wrapMode = WrapMode.Loop; +if (currentState) { + // 播放一次 + currentState.wrapMode = WrapMode.Once; + // 循环播放 + currentState.wrapMode = WrapMode.Loop; +} ``` ### 获取动画状态 -你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState)  方法来获取指定名称的 `动画状态` 。获取之后可以设置动画状态的属性,比如将默认的循环播放改为一次。 +你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState) 方法来获取指定名称 `动画状态` 的播放数据。返回类型为 `AnimatorStatePlayData | null`(当任何一个动画层都没有该状态时返回 `null`)。 + +返回的 `AnimatorStatePlayData` 提供两套语义不同的访问入口: + +- `playData.speed`:每个 `Animator` 实例独立的播放速度。**未写入前**实时绑定到 `state.speed`,一旦写入即由该实例自行持有,之后修改 `state.speed` 不会再传递到此实例;想再次跟随 asset 时直接写回 `playData.state.speed` 即可。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。 +- `playData.state`:底层共享的 `AnimatorState` 资产。对 `wrapMode` 等字段的修改会改变共享的 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 ```typescript -const state = animator.findAnimatorState("xxx"); -// 播放一次 -state.wrapMode = WrapMode.Once; -// 循环播放 -state.wrapMode = WrapMode.Loop; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // 任何一个动画层都没有该状态 + return; +} + +// 修改共享的 AnimatorState 资产(影响所有使用该控制器的 Animator) +playData.state.wrapMode = WrapMode.Once; +playData.state.wrapMode = WrapMode.Loop; ``` ### 动画裁剪 diff --git a/e2e/case/animator-additive.ts b/e2e/case/animator-additive.ts index b30f2bf983..b9c4becee6 100644 --- a/e2e/case/animator-additive.ts +++ b/e2e/case/animator-additive.ts @@ -56,7 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name); additivePoseNames.forEach((name) => { - const clip = animator.findAnimatorState(name).clip; + const playData = animator.findAnimatorState(name); + if (!playData) { + throw new Error(`Animator state not found: ${name}`); + } + const clip = playData.state.clip; const newState = animatorStateMachine.addState(name); newState.clipStartTime = 1; newState.clip = clip; diff --git a/e2e/case/animator-event.ts b/e2e/case/animator-event.ts index 430b260dc2..193889eedf 100644 --- a/e2e/case/animator-event.ts +++ b/e2e/case/animator-event.ts @@ -53,7 +53,10 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); - const clip = state.clip; + if (!state) { + throw new Error("Animator state not found: walk"); + } + const clip = state.state.clip; const event0 = new AnimationEvent(); event0.functionName = "event0"; diff --git a/e2e/case/animator-play-backwards.ts b/e2e/case/animator-play-backwards.ts index 526d21b9ca..936a370048 100644 --- a/e2e/case/animator-play-backwards.ts +++ b/e2e/case/animator-play-backwards.ts @@ -33,7 +33,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const { defaultSceneRoot } = gltfResource; rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - animator.findAnimatorState("walk").speed = -1; + const walkState = animator.findAnimatorState("walk"); + if (!walkState) { + throw new Error("Animator state not found: walk"); + } + walkState.speed = -1; animator.play("walk"); updateForE2E(engine); diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 08fb9e6d39..34f1b7a899 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -57,6 +57,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const idleState = animator.findAnimatorState("idle"); const walkState = animator.findAnimatorState("walk"); const runState = animator.findAnimatorState("run"); + if (!idleState || !walkState || !runState) { + throw new Error("Required animator states not found: idle/walk/run"); + } let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -64,60 +67,62 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - toWalkTransition.exitTime * idleState._getDuration() + toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.exitTime * idleState.state._getDuration() + + //@ts-ignore + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toIdleTransition.duration * idleState._getDuration(); + toIdleTransition.duration * idleState.state._getDuration(); // to run state - const RunToWalkTransition = new AnimatorStateTransition(); - RunToWalkTransition.destinationState = walkState; - RunToWalkTransition.duration = 0.3; - RunToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(RunToWalkTransition); + const runToWalkTransition = new AnimatorStateTransition(); + runToWalkTransition.destinationState = walkState.state; + runToWalkTransition.duration = 0.3; + runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (RunToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - RunToWalkTransition.duration * walkState._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - anyTransition.duration * idleState._getDuration(); + anyTransition.duration * idleState.state._getDuration(); engine.time.maximumDeltaTime = 10000; updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 76e5247b3a..a6d4ebbb92 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -56,8 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); + if (!state) { + throw new Error("Animator state not found: walk"); + } - state.addStateMachineScript( + state.state.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { textRenderer.text = "0"; diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 9470d05817..7f025f321a 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -369,14 +369,13 @@ export class Entity extends EngineObject { /** * Find the entity by path. * @param path - The path of the entity eg: /entity - * @returns The component which be found + * @returns The entity that was found */ findByPath(path: string): Entity { const splits = path.split("/").filter(Boolean); if (!splits.length) { return this; } - return Entity._findChildByName(this, 0, splits, 0); } diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 180c2d1356..6640311825 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -23,7 +23,7 @@ import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; import { AnimatorStateData } from "./internal/AnimatorStateData"; -import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCurveOwner"; /** @@ -115,9 +115,7 @@ export class Animator extends Component { * @param normalizedTimeOffset - The normalized time offset (between 0 and 1, default 0) to start the state's animation from */ play(stateName: string, layerIndex: number = -1, normalizedTimeOffset: number = 0): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); const stateInfo = this._getAnimatorStateInfo(stateName, layerIndex); const { state } = stateInfo; @@ -192,9 +190,7 @@ export class Animator extends Component { return; } - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); this._updateMark++; @@ -206,20 +202,28 @@ export class Animator extends Component { } /** - * Get the playing state from the target layerIndex. + * Get the current playing state from the target layer. * @param layerIndex - The layer index + * @returns The currently playing AnimatorState, or null if the layer is missing or no state is playing */ - getCurrentAnimatorState(layerIndex: number): AnimatorState { - return this._animatorLayersData[layerIndex]?.srcPlayData?.state; + getCurrentAnimatorState(layerIndex: number): AnimatorState | null { + return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; } /** - * Get the state by name. + * Get the per-instance play data handle for a state by name. + * The returned handle persists for the layer's lifetime; modifications to + * `playData.speed` survive state transitions. + * * @param stateName - The state name - * @param layerIndex - The layer index(default -1). If layer is -1, find the first state with the given state name + * @param layerIndex - The layer index (default -1, searches all layers) + * @returns Per-instance AnimatorStatePlayData, or null if no state matches */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorState { - return this._getAnimatorStateInfo(stateName, layerIndex).state; + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null { + this._resetIfControllerUpdated(); + const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); + if (!state || foundLayer < 0) return null; + return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); } /** @@ -321,6 +325,18 @@ export class Animator extends Component { } } + // Detach clipChangedListeners before dropping stateData; otherwise each + // controller mutation would leave a dead listener attached to the + // surviving AnimatorState's UpdateFlagManager. + const layersData = this._animatorLayersData; + for (let i = 0, n = layersData.length; i < n; i++) { + const stateDataMap = layersData[i]?.animatorStateDataMap; + if (!stateDataMap) continue; + for (const stateName in stateDataMap) { + stateDataMap[stateName].dispose(); + } + } + this._animatorLayersData.length = 0; this._curveOwnerPool = Object.create(null); this._parametersValueMap = Object.create(null); @@ -331,6 +347,12 @@ export class Animator extends Component { } } + private _resetIfControllerUpdated(): void { + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } + } + /** * @internal */ @@ -343,6 +365,10 @@ export class Animator extends Component { } protected override _onDestroy(): void { + // Reuse _reset() to detach AnimatorStateData clipChangedListeners — without + // this the listener closures stay attached to surviving AnimatorState + // UpdateFlagManagers and keep referencing the destroyed entity / stateData. + this._reset(); super._onDestroy(); const controller = this._animatorController; if (controller) { @@ -358,11 +384,12 @@ export class Animator extends Component { normalizedTimeOffset: number, isFixedDuration: boolean ): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); + if (!state || playLayerIndex < 0) { + return; + } const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); manuallyTransition.duration = duration; @@ -388,8 +415,10 @@ export class Animator extends Component { break; } } - } else { + } else if (layerIndex >= 0 && layerIndex < layers.length) { state = layers[layerIndex].stateMachine.findStateByName(stateName); + } else { + layerIndex = -1; } } stateInfo.layerIndex = layerIndex; @@ -405,8 +434,14 @@ export class Animator extends Component { ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; let animatorStateData = animatorStateDataMap[stateName]; + if (animatorStateData && animatorStateData.state !== animatorState) { + // Same name but different state instance (e.g. removeState + addState same name): + // detach the old listener and rebuild stateData against the new state. + animatorStateData.dispose(); + animatorStateData = null; + } if (!animatorStateData) { - animatorStateData = new AnimatorStateData(); + animatorStateData = new AnimatorStateData(animatorState); animatorStateDataMap[stateName] = animatorStateData; this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); this._saveAnimatorEventHandlers(animatorState, animatorStateData); @@ -495,6 +530,7 @@ export class Animator extends Component { }; clipChangedListener(); state._updateFlagManager.addListener(clipChangedListener); + animatorStateData.clipChangedListener = clipChangedListener; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { @@ -521,8 +557,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // Standby have two sub state, one is never play, one is finished, never play srcPlayData.state is null - animatorLayerData.srcPlayData.state && this._prepareSrcCrossData(animatorLayerData, true); + // Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null) + animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -543,7 +579,7 @@ export class Animator extends Component { } private _prepareSrcCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.srcPlayData.stateData; + const { curveLayerOwner } = animatorLayerData.srcPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -554,7 +590,7 @@ export class Animator extends Component { } private _prepareDestCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.destPlayData.stateData; + const { curveLayerOwner } = animatorLayerData.destPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -616,17 +652,17 @@ export class Animator extends Component { const { srcPlayData } = layerData; const { state } = srcPlayData; - const playSpeed = state.speed * this.speed; + const playSpeed = srcPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastClipTime, playState: lastPlayState } = srcPlayData; + const { _clipTime: lastClipTime, _playState: lastPlayState } = srcPlayData; // Precalculate to get the transition srcPlayData.update(playDeltaTime); - const { clipTime, isForward } = srcPlayData; + const { _clipTime: clipTime, _isForward: isForward } = srcPlayData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -683,7 +719,7 @@ export class Animator extends Component { srcPlayData.update(playCostTime - playDeltaTime); } else { playCostTime = playDeltaTime; - if (srcPlayData.playState === AnimatorStatePlayState.Finished) { + if (srcPlayData._playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } } @@ -699,8 +735,9 @@ export class Animator extends Component { ); if (transition) { - // Remove speed factor, use actual cost time - const remainDeltaTime = deltaTime - playCostTime / playSpeed; + // Remove speed factor, use actual cost time. Per-instance speed=0 means the source + // state is paused, so it consumes no time — pass deltaTime through to the destination. + const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed; remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate); } } @@ -712,10 +749,10 @@ export class Animator extends Component { aniUpdate: boolean ): void { const curveBindings = playData.state.clip._curveBindings; - const finished = playData.playState === AnimatorStatePlayState.Finished; + const finished = playData._playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { - const curveLayerOwner = playData.stateData.curveLayerOwner; + const curveLayerOwner = playData._stateData.curveLayerOwner; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; const owner = layerOwner?.curveOwner; @@ -728,7 +765,7 @@ export class Animator extends Component { if (curve.keys.length) { this._checkRevertOwner(owner, additive); - const value = owner.evaluateValue(curve, playData.clipTime, additive); + const value = owner.evaluateValue(curve, playData._clipTime, additive); aniUpdate && owner.applyValue(value, weight, additive); finished && layerOwner.saveFinalValue(); } @@ -753,25 +790,25 @@ export class Animator extends Component { return; } - const srcPlaySpeed = srcState.speed * speed; - const dstPlaySpeed = destState.speed * speed; + const srcPlaySpeed = srcPlayData.speed * speed; + const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); - destPlayData && destPlayData.updateOrientation(dstPlayDeltaTime); + srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); + destPlayData.updateOrientation(dstPlayDeltaTime); - const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; - const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; + const { _clipTime: lastSrcClipTime, _playState: lastSrcPlayState } = srcPlayData; + const { _clipTime: lastDestClipTime, _playState: lastDstPlayState } = destPlayData; let dstPlayCostTime: number; - if (destPlayData.isForward) { + if (destPlayData._isForward) { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = playedTime + dstPlayDeltaTime > transitionDuration ? transitionDuration - playedTime : dstPlayDeltaTime; } else { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = // -dstPlayDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -788,13 +825,13 @@ export class Animator extends Component { srcPlayData.update(srcPlayCostTime); destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { - srcPlayData.playState = AnimatorStatePlayState.Finished; + srcPlayData._playState = AnimatorStatePlayState.Finished; this._preparePlayOwner(layerData, destState); this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); } else { @@ -840,7 +877,7 @@ export class Animator extends Component { const { state: destState } = destPlayData; const { _curveBindings: destCurves } = destState.clip; - const finished = destPlayData.playState === AnimatorStatePlayState.Finished; + const finished = destPlayData._playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { for (let i = crossLayerOwnerCollection.length - 1; i >= 0; i--) { @@ -857,8 +894,8 @@ export class Animator extends Component { const value = owner.evaluateCrossFadeValue( srcCurveIndex >= 0 ? srcCurves[srcCurveIndex].curve : null, destCurveIndex >= 0 ? destCurves[destCurveIndex].curve : null, - srcPlayData.clipTime, - destPlayData.clipTime, + srcPlayData._clipTime, + destPlayData._clipTime, crossWeight, additive ); @@ -883,22 +920,22 @@ export class Animator extends Component { return; } - const playSpeed = state.speed * this.speed; + const playSpeed = destPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastDestClipTime, playState: lastPlayState } = destPlayData; + const { _clipTime: lastDestClipTime, _playState: lastPlayState } = destPlayData; let dstPlayCostTime: number; - if (destPlayData.isForward) { + if (destPlayData._isForward) { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = playedTime + playDeltaTime > transitionDuration ? transitionDuration - playedTime : playDeltaTime; } else { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = // -playDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -913,7 +950,7 @@ export class Animator extends Component { destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; @@ -953,7 +990,7 @@ export class Animator extends Component { const { state } = destPlayData; const { _curveBindings: curveBindings } = state.clip; - const { clipTime: destClipTime, playState } = destPlayData; + const { _clipTime: destClipTime, _playState: playState } = destPlayData; const finished = playState === AnimatorStatePlayState.Finished; // When the animator is culled (aniUpdate=false), if the play state has finished, the final value needs to be calculated and saved to be applied directly @@ -989,12 +1026,12 @@ export class Animator extends Component { ): void { const playData = layerData.srcPlayData; const { state } = playData; - const actualSpeed = state.speed * this.speed; + const actualSpeed = playData.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; playData.updateOrientation(actualDeltaTime); - const { clipTime, isForward } = playData; + const { _clipTime: clipTime, _isForward: isForward } = playData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -1029,7 +1066,7 @@ export class Animator extends Component { return; } - const { curveLayerOwner } = playData.stateData; + const { curveLayerOwner } = playData._stateData; const { _curveBindings: curveBindings } = playData.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { @@ -1046,12 +1083,12 @@ export class Animator extends Component { private _updateCrossFadeData(layerData: AnimatorLayerData): void { const { destPlayData } = layerData; - if (destPlayData.playState === AnimatorStatePlayState.Finished) { + if (destPlayData._playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } else { layerData.layerState = LayerState.Playing; } - layerData.switchPlayData(); + layerData.promoteDest(); layerData.crossFadeTransition = null; } @@ -1059,7 +1096,7 @@ export class Animator extends Component { if (layerData.layerState === LayerState.Playing) { const srcPlayData = layerData.srcPlayData; if (srcPlayData.state !== playState) { - const { curveLayerOwner } = srcPlayData.stateData; + const { curveLayerOwner } = srcPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); } @@ -1311,7 +1348,13 @@ export class Animator extends Component { this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + const playData = animatorLayerData.getOrCreatePlayData(state); + playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + animatorLayerData.srcPlayData = playData; + // Clear any stale cross-fade slot from a previously-interrupted crossFade so + // subsequent crossFade() calls aren't no-op'd by the self-target alias guard. + animatorLayerData.destPlayData = null; + animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1411,13 +1454,20 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); + + // Self/active-dest cross-fade is intentionally a no-op because each state + // owns one persistent PlayData handle per layer (so per-instance overrides + // like speed survive transitions). Supporting self cross-fade would require + // a separate transient playback track per active fade. + if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { + return false; + } + const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - animatorLayerData.destPlayData.reset( - crossState, - animatorStateData, - transition.offset * crossState._getClipActualEndTime() - ); + const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); + destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1452,14 +1502,14 @@ export class Animator extends Component { lastClipTime: number, deltaTime: number ): void { - const { state, isForward, clipTime } = playData; + const { state, _isForward: isForward, _clipTime: clipTime } = playData; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); if (isForward) { if (lastClipTime + deltaTime >= endTime) { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData.currentEventIndex = 0; + playData._currentEventIndex = 0; this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); } else { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); @@ -1467,7 +1517,7 @@ export class Animator extends Component { } else { if (lastClipTime + deltaTime <= startTime) { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); - playData.currentEventIndex = eventHandlers.length - 1; + playData._currentEventIndex = eventHandlers.length - 1; this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); } else { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); @@ -1481,7 +1531,7 @@ export class Animator extends Component { lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState.currentEventIndex; + let eventIndex = playState._currentEventIndex; for (let n = eventHandlers.length; eventIndex < n; eventIndex++) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1495,7 +1545,7 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState.currentEventIndex = Math.min(eventIndex + 1, n - 1); + playState._currentEventIndex = Math.min(eventIndex + 1, n - 1); } } } @@ -1506,7 +1556,7 @@ export class Animator extends Component { lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState.currentEventIndex; + let eventIndex = playState._currentEventIndex; for (; eventIndex >= 0; eventIndex--) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1520,7 +1570,7 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState.currentEventIndex = Math.max(eventIndex - 1, 0); + playState._currentEventIndex = Math.max(eventIndex - 1, 0); } } } @@ -1564,13 +1614,13 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - const { eventHandlers } = playData.stateData; + const { eventHandlers } = playData._stateData; eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); } - if (lastPlayState !== AnimatorStatePlayState.Finished && playData.playState === AnimatorStatePlayState.Finished) { + if (lastPlayState !== AnimatorStatePlayState.Finished && playData._playState === AnimatorStatePlayState.Finished) { state._callOnExit(this, layerIndex); } else { state._callOnUpdate(this, layerIndex); diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts new file mode 100644 index 0000000000..41bea00a14 --- /dev/null +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -0,0 +1,126 @@ +import { AnimatorState } from "./AnimatorState"; +import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; +import { WrapMode } from "./enums/WrapMode"; +import { AnimatorStateData } from "./internal/AnimatorStateData"; + +/** + * Per-Animator per-state runtime handle. + * + * Lifecycle: created lazily by AnimatorLayerData.getOrCreatePlayData on first access + * (either via Animator.findAnimatorState or when the state begins playing). Persists + * for the layer's lifetime, so per-instance state (e.g. speed) survives transitions + * out of and back into the state. + * + * Public surface is intentionally narrow: + * - `state`: the shared AnimatorState asset (read-only). + * - `speed`: per-instance playback speed (see the `speed` getter for live-bind semantics). + * + * All other fields are engine-managed runtime state and are underscore-prefixed to + * mark them as implementation detail; mutating them from user code will corrupt + * Animator invariants. + */ +export class AnimatorStatePlayData { + /** The shared AnimatorState asset. Read-only reference. */ + readonly state: AnimatorState; + + /** @internal */ + _stateData: AnimatorStateData; + /** @internal */ + _playedTime: number = 0; + /** @internal */ + _playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; + /** @internal */ + _clipTime: number = 0; + /** @internal */ + _currentEventIndex: number = 0; + /** @internal */ + _isForward = true; + /** @internal */ + _offsetFrameTime: number = 0; + + private _speed: number | undefined; + private _changedOrientation = false; + + /** + * Per-instance playback speed for this state. + * + * - Read: live-reads `state.speed` until written; afterwards returns the per-instance value. + * - Write: claims per-instance ownership. Later changes to `state.speed` no longer flow through. + * + * Per-instance value persists across state transitions. + */ + get speed(): number { + return this._speed ?? this.state.speed; + } + + set speed(value: number) { + this._speed = value; + } + + /** @internal */ + constructor(state: AnimatorState) { + this.state = state; + } + + /** + * @internal + * Reset runtime fields when (re-)entering this state. Does NOT touch user-written per-instance values (e.g. speed). + */ + resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { + this._stateData = stateData; + this._offsetFrameTime = offsetFrameTime; + this._playedTime = 0; + this._playState = AnimatorStatePlayState.UnStarted; + this._clipTime = this.state.clipStartTime * this.state.clip.length; + this._currentEventIndex = 0; + this._isForward = true; + this._changedOrientation = false; + this.state._transitionCollection.needResetCurrentCheckIndex = true; + } + + /** @internal */ + updateOrientation(deltaTime: number): void { + if (deltaTime !== 0) { + const lastIsForward = this._isForward; + this._isForward = deltaTime > 0; + if (this._isForward !== lastIsForward) { + this._changedOrientation = true; + this._isForward || this._correctTime(); + } + } + } + + /** @internal */ + update(deltaTime: number): void { + this._playedTime += deltaTime; + const state = this.state; + let time = this._playedTime + this._offsetFrameTime; + const duration = state._getDuration(); + this._playState = AnimatorStatePlayState.Playing; + if (state.wrapMode === WrapMode.Loop) { + time = duration ? time % duration : 0; + } else { + if (Math.abs(time) >= duration) { + time = time < 0 ? -duration : duration; + this._playState = AnimatorStatePlayState.Finished; + } + } + + time < 0 && (time += duration); + this._clipTime = time + state.clipStartTime * state.clip.length; + + if (this._changedOrientation) { + !this._isForward && this._correctTime(); + this._changedOrientation = false; + } + } + + private _correctTime() { + const { state } = this; + // Reverse playback resumed at clipTime=0 would step into negatives; jump to + // clipEnd so the next sample continues seamlessly from the end of the clip. + if (this._clipTime === 0) { + this._clipTime = state.clipEndTime * state.clip.length; + } + } +} diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index b829ffde54..307ac5bdd0 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,6 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; +export { AnimatorStatePlayData } from "./AnimatorStatePlayData"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a7bd04f1a5..ceb9c89454 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,9 +1,10 @@ import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; +import { AnimatorState } from "../AnimatorState"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "../AnimatorStatePlayData"; /** * @internal @@ -12,20 +13,35 @@ export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: Record = {}; - srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); - destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); + animatorStateDataMap: Record = Object.create(null); + /** Per-state PlayData handles. Lazy populated. */ + statePlayDataMap: Record = Object.create(null); + /** Currently playing state's PlayData; null when standby. */ + srcPlayData: AnimatorStatePlayData | null = null; + /** Cross-fade target state's PlayData; null when not cross-fading. */ + destPlayData: AnimatorStatePlayData | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - switchPlayData(): void { - const srcPlayData = this.destPlayData; - const switchTemp = this.srcPlayData; - this.srcPlayData = srcPlayData; - this.destPlayData = switchTemp; + /** Get or lazily create the persistent PlayData for a state. */ + getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { + const statePlayDataMap = this.statePlayDataMap; + const stateName = state.name; + let playData = statePlayDataMap[stateName]; + if (playData?.state !== state) { + playData = new AnimatorStatePlayData(state); + statePlayDataMap[stateName] = playData; + } + return playData; + } + + /** After cross-fade completes, promote destPlayData to srcPlayData. */ + promoteDest(): void { + this.srcPlayData = this.destPlayData; + this.destPlayData = null; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 146c0bb52f..45e2099eb0 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -1,3 +1,4 @@ +import { AnimatorState } from "../AnimatorState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./AnimationEventHandler"; @@ -5,6 +6,19 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; * @internal */ export class AnimatorStateData { + /** Listener registered on `state._updateFlagManager`; kept so dispose() can detach it. */ + clipChangedListener: (() => void) | null = null; curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; + + constructor(readonly state: AnimatorState) {} + + /** Detach the clipChangedListener from state's UpdateFlagManager. No-op if not attached. */ + dispose(): void { + const { clipChangedListener } = this; + if (clipChangedListener) { + this.state._updateFlagManager.removeListener(clipChangedListener); + this.clipChangedListener = null; + } + } } diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts deleted file mode 100644 index 7d10fc2324..0000000000 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AnimatorState } from "../AnimatorState"; -import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; -import { WrapMode } from "../enums/WrapMode"; -import { AnimatorStateData } from "./AnimatorStateData"; - -/** - * @internal - */ -export class AnimatorStatePlayData { - state: AnimatorState; - stateData: AnimatorStateData; - playedTime: number; - playState: AnimatorStatePlayState; - clipTime: number; - currentEventIndex: number; - isForward = true; - offsetFrameTime: number; - - private _changedOrientation = false; - - reset(state: AnimatorState, stateData: AnimatorStateData, offsetFrameTime: number): void { - this.state = state; - this.playedTime = 0; - this.offsetFrameTime = offsetFrameTime; - this.stateData = stateData; - this.playState = AnimatorStatePlayState.UnStarted; - this.clipTime = state.clipStartTime * state.clip.length; - this.currentEventIndex = 0; - this.isForward = true; - this.state._transitionCollection.needResetCurrentCheckIndex = true; - } - - updateOrientation(deltaTime: number): void { - if (deltaTime !== 0) { - const lastIsForward = this.isForward; - this.isForward = deltaTime > 0; - if (this.isForward !== lastIsForward) { - this._changedOrientation = true; - this.isForward || this._correctTime(); - } - } - } - - update(deltaTime: number): void { - this.playedTime += deltaTime; - const state = this.state; - let time = this.playedTime + this.offsetFrameTime; - const duration = state._getDuration(); - this.playState = AnimatorStatePlayState.Playing; - if (state.wrapMode === WrapMode.Loop) { - time = duration ? time % duration : 0; - } else { - if (Math.abs(time) >= duration) { - time = time < 0 ? -duration : duration; - this.playState = AnimatorStatePlayState.Finished; - } - } - - time < 0 && (time += duration); - this.clipTime = time + state.clipStartTime * state.clip.length; - - if (this._changedOrientation) { - !this.isForward && this._correctTime(); - this._changedOrientation = false; - } - } - - private _correctTime() { - const { state } = this; - if (this.clipTime === 0) { - this.clipTime = state.clipEndTime * state.clip.length; - } - } -} diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index 83f9f65124..ef9f29bec8 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -114,15 +114,26 @@ export class GLTFParserContext { this.glTF = json; this.needAnimatorController = !!(json.skins || json.animations); + // Scene-before-Skin parse order + // + // Skin rootBone resolution walks joint parent chains and computes the + // joints' lowest common ancestor. Scene's parse head must run first + // because it synchronously attaches top-level scene nodes under the + // GLTF_ROOT wrapper; when joints span multiple top-level scene nodes + // that wrapper naturally becomes the LCA. + // + // Do not rewrite Skin to await full Scene: Scene's async tail can + // request Skin via _createRenderer for skinned renderers, which would + // deadlock on the cached promise. return AssetPromise.all([ this.get(GLTFParserType.Validator), + this.get(GLTFParserType.Scene), this.get(GLTFParserType.Texture), this.get(GLTFParserType.Material), this.get(GLTFParserType.Mesh), this.get(GLTFParserType.Skin), this.get(GLTFParserType.Animation), - this.get(GLTFParserType.AnimatorController), - this.get(GLTFParserType.Scene) + this.get(GLTFParserType.AnimatorController) ]).then(() => { const glTFResource = this.glTFResource; const animatorController = glTFResource.animatorController; diff --git a/packages/loader/src/gltf/parser/GLTFSceneParser.ts b/packages/loader/src/gltf/parser/GLTFSceneParser.ts index 5ee981b1d3..2187673d37 100644 --- a/packages/loader/src/gltf/parser/GLTFSceneParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSceneParser.ts @@ -38,6 +38,12 @@ export class GLTFSceneParser extends GLTFParser { sceneRoot.addChild(context.get(GLTFParserType.Entity, sceneNodes[i])); } + // Mirror _defaultSceneRoot's synchronous write so _sceneRoots[i] is visible + // in the same tick. _handleSubAsset would otherwise fill it asynchronously + // via the glTFResourceMap path after Scene's promise resolves — looks like + // a duplicate write, but it aligns the two wrapper-index fields so callers + // never observe one as set and the other still undefined. + (glTFResource._sceneRoots ||= [])[index] = sceneRoot; if (isDefaultScene) { glTFResource._defaultSceneRoot = sceneRoot; } diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 7c1580e4ca..31ee338df9 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -36,15 +36,19 @@ export class GLTFSkinParser extends GLTFParser { // Get skeleton if (skeleton !== undefined) { - const rootBone = entities[skeleton]; - skin.rootBone = rootBone; + skin.rootBone = entities[skeleton]; } else { - const rootBone = this._findSkeletonRootBone(joints, entities); - if (rootBone) { - skin.rootBone = rootBone; - } else { + // Resolve rootBone from the joints' lowest common ancestor. + // + // Multi-root scenes are not a special case: GLTFSceneParser unconditionally + // attaches every top-level node under a GLTF_ROOT wrapper, so when joints + // span multiple top-level scene nodes, their LCA is naturally the wrapper. + // When joints converge in one branch, the LCA is the actual skeleton root. + const rootBone = this._findSkeletonRootBoneByLCA(joints, entities); + if (!rootBone) { throw "Failed to find skeleton root bone."; } + skin.rootBone = rootBone; } return skin; @@ -53,7 +57,7 @@ export class GLTFSkinParser extends GLTFParser { return AssetPromise.resolve(skinPromise); } - private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity { + private _findSkeletonRootBoneByLCA(joints: number[], entities: Entity[]): Entity | null { const paths = >{}; for (const index of joints) { const path = new Array(); @@ -65,7 +69,7 @@ export class GLTFSkinParser extends GLTFParser { paths[index] = path; } - let rootNode = null; + let rootNode: Entity | null = null; for (let i = 0; ; i++) { let path = paths[joints[0]]; if (i >= path.length) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 044312c943..bd4e2c9fa0 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -20,7 +20,7 @@ import { } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; -import { Quaternion } from "@galacean/engine-math"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; @@ -70,11 +70,12 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 + // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const state = animator.findAnimatorState(name); - if (state) { + const playData = animator.findAnimatorState(name); + if (playData) { + const state = playData.state; state.clearTransitions(); state.speed = 1; state.clipStartTime = 0; @@ -106,24 +107,24 @@ describe("Animator test", function () { const speed = 1; let expectedSpeed = speed * 0.5; animator.speed = expectedSpeed; - let playedTime = srcPlayData.playedTime; + let playedTime = srcPlayData._playedTime; // @ts-ignore animator.engine.time._frameCount++; animator.update(5); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 5 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 5 * expectedSpeed); expectedSpeed = speed * 2; animator.speed = expectedSpeed; - playedTime = srcPlayData.playedTime; + playedTime = srcPlayData._playedTime; animator.update(10); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 10 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 10 * expectedSpeed); expectedSpeed = speed * 0; animator.speed = expectedSpeed; - playedTime = srcPlayData.playedTime; + playedTime = srcPlayData._playedTime; animator.update(15); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 15 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 15 * expectedSpeed); }); it("play animation", () => { @@ -160,7 +161,7 @@ describe("Animator test", function () { let animatorLayerData = animator["_animatorLayersData"]; const srcPlayData = animatorLayerData[0]?.srcPlayData; animator.update(5); - const curveOwner = srcPlayData.stateData.curveLayerOwner[0].curveOwner; + const curveOwner = srcPlayData._stateData.curveLayerOwner[0].curveOwner; const initValue = curveOwner.defaultValue; const currentValue = curveOwner.referenceTargetValue; expect(Quaternion.equals(initValue, currentValue)).to.eq(true); @@ -201,12 +202,12 @@ describe("Animator test", function () { animator.play(stateName); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect(animatorState).to.eq(currentAnimatorState); + expect(animatorState.state).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect(animatorState).not.to.eq(currentAnimatorState); - expect(animatorState.name).to.eq(expectedStateName); + expect(animatorState.state).not.to.eq(currentAnimatorState); + expect(animatorState.state.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -251,24 +252,24 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); const srcPlayData = layerData.srcPlayData; expect(srcPlayData.state.name).to.eq("Run"); - expect(srcPlayData.playedTime).to.eq(0.3); + expect(srcPlayData._playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState._getDuration()); + expect(srcPlayData._clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = runState; + transition.destinationState = runState.state; transition.duration = 1; transition.exitTime = 1; - walkState.addTransition(transition); + walkState.state.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length - 0.1); + animator.update(walkState.state.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -311,7 +312,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = animator.findAnimatorState("Run").clip; + const clip = animator.findAnimatorState("Run").state.clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -361,7 +362,7 @@ describe("Animator test", function () { event0.time = 0; const state = animator.findAnimatorState("Walk"); - state.clip.addEvent(event0); + state.state.clip.addEvent(event0); animator.update(10); expect(testScriptSpy).toHaveBeenCalledTimes(1); }); @@ -372,11 +373,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); + runState.state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -384,68 +385,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * idleState._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState.state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState; + runToWalkTransition.destinationState = walkState.state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(runToWalkTransition); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.7; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - (anyTransition.duration * idleState._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -497,11 +498,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); + runState.state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -509,68 +510,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * idleState._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState.state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState; + runToWalkTransition.destinationState = walkState.state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(runToWalkTransition); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.3; let anyToIdleTime = // @ts-ignore - (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - (anyTransition.duration * idleState._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -614,10 +615,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const toRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const toRunTransition = walkState.state.addTransition(runState.state); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -629,21 +630,21 @@ describe("Animator test", function () { const destPlayData = animator["_animatorLayersData"][0].destPlayData; const destState = destPlayData.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); - const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; + const crossWeight = animator["_animatorLayersData"][0].destPlayData._playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); }); it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.wrapMode = WrapMode.Once; - walkState.clipStartTime = 0.8; - walkState.clearTransitions(); + walkState.state.wrapMode = WrapMode.Once; + walkState.state.clipStartTime = 0.8; + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const toRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const toRunTransition = walkState.state.addTransition(runState.state); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - runState.clipStartTime = 0.5; + runState.state.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -657,9 +658,9 @@ describe("Animator test", function () { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - walkState.wrapMode = WrapMode.Once; - walkState.clearTransitions(); - walkState.addExitTransition(); + walkState.state.wrapMode = WrapMode.Once; + walkState.state.clearTransitions(); + walkState.state.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -826,15 +827,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - walkState.clipStartTime = 0.5; - walkState.addStateMachineScript( + walkState.state.clipStartTime = 0.5; + walkState.state.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run")); + const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run").state); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -846,8 +847,8 @@ describe("Animator test", function () { animator.update(0.5); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq(walkState.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData._playedTime).to.eq(0.5); + expect(layerData.srcPlayData._clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -860,13 +861,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clipStartTime = 0; - walkState.clearTransitions(); + walkState.state.clipStartTime = 0; + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -874,10 +875,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length * 0.5); + animator.update(walkState.state.clip.length * 0.5); expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState); + expect(layerData.destPlayData._playedTime).to.eq(0); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.state); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -886,12 +887,12 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(idleState.clip.length * 0.2 - 0.1); + animator.update(idleState.state.clip.length * 0.2 - 0.1); expect(layerData.srcPlayData.state.name).to.eq("Survey"); - expect(layerData.srcPlayData.clipTime).to.eq(idleState.clip.length * 0.2); + expect(layerData.srcPlayData._clipTime).to.eq(idleState.state.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -904,16 +905,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clipStartTime = 0; - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clipStartTime = 0; + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = runState.addTransition(walkState); + const runToWalkTransition = runState.state.addTransition(walkState.state); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -926,27 +927,27 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData.playedTime).to.eq(0.1); + expect(layerData.destPlayData._playedTime).to.eq(0.1); expect(animator.getParameterValue("triggerRun")).to.eq(false); expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.clip.length * 0.1 - 0.1); + animator.update(runState.state.clip.length * 0.1 - 0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(runState.clip.length * 0.1); + expect(layerData.srcPlayData._playedTime).to.eq(runState.state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.clip.length * 0.6); + animator.update(runState.state.clip.length * 0.6); expect(layerData.destPlayData.state.name).to.eq("Walk"); - expect(layerData.destPlayData.playedTime).to.eq(0); + expect(layerData.destPlayData._playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length * 0.3); + animator.update(walkState.state.clip.length * 0.3); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq(walkState.clip.length * 0.3); + expect(layerData.srcPlayData._playedTime).to.eq(walkState.state.clip.length * 0.3); }); it("fixedDuration", () => { @@ -956,11 +957,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clipStartTime = runState.clipEndTime = 0; - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clipStartTime = runState.state.clipEndTime = 0; + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -971,8 +972,8 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); - expect(layerData.srcPlayData.clipTime).to.eq(0); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); + expect(layerData.srcPlayData._clipTime).to.eq(0); }); it("transitionIndex", () => { @@ -1046,6 +1047,48 @@ describe("Animator test", function () { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); }); + it("samples self-name-prefixed curve paths on wrapped roots", () => { + const wrappedRoot = new Entity(engine, "GLTF_ROOT"); + const hips = new Entity(engine, "mixamorig:Hips"); + const spine = new Entity(engine, "mixamorig:Spine"); + hips.parent = wrappedRoot; + spine.parent = hips; + + const clip = new AnimationClip("idle"); + const hipsCurve = new AnimationFloatCurve(); + const spineCurve = new AnimationFloatCurve(); + const hipsStart = new Keyframe(); + const hipsEnd = new Keyframe(); + hipsStart.time = 0; + hipsStart.value = 0; + hipsEnd.time = 0.1; + hipsEnd.value = 1; + hipsCurve.addKey(hipsStart); + hipsCurve.addKey(hipsEnd); + + const spineStart = new Keyframe(); + const spineEnd = new Keyframe(); + spineStart.time = 0; + spineStart.value = 0; + spineEnd.time = 0.1; + spineEnd.value = 1; + spineCurve.addKey(spineStart); + spineCurve.addKey(spineEnd); + + clip.addCurveBinding("mixamorig:Hips", Transform, "position.x", hipsCurve); + clip.addCurveBinding("mixamorig:Hips/mixamorig:Spine", Transform, "position.y", spineCurve); + + expect(wrappedRoot.findByPath("mixamorig:Hips")).to.eq(hips); + expect(wrappedRoot.findByPath("mixamorig:Hips/mixamorig:Spine")).to.eq(spine); + + // @ts-ignore + clip._sampleAnimation(wrappedRoot, 0.1); + + expect(wrappedRoot.transform.position.x).to.eq(0); + expect(hips.transform.position.x).to.eq(1); + expect(spine.transform.position.y).to.eq(1); + }); + it("anyState transition interrupts crossFade", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", false); @@ -1053,7 +1096,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1091,12 +1134,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.clipStartTime = 0; - walkState.clipEndTime = 1; - walkState.clearTransitions(); + walkState.state.clipStartTime = 0; + walkState.state.clipEndTime = 1; + walkState.state.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = walkState.addTransition(idleState); + const noExitFailTransition = walkState.state.addTransition(idleState.state); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1105,26 +1148,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = runState; + exitTimeTransition.destinationState = runState.state; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - walkState.addTransition(exitTimeTransition); + walkState.state.addTransition(exitTimeTransition); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); animator.play("Walk"); // Update before exitTime, should still be in Walk and not start transitioning to Run. - const preExitDeltaTime = walkState.clip.length * 0.25; + const preExitDeltaTime = walkState.state.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.destPlayData.state).to.be.undefined; + expect(layerData.destPlayData).to.be.null; // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length * 0.5); + animator.update(walkState.state.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1136,17 +1179,17 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - walkState.wrapMode = WrapMode.Once; + walkState.state.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length + 0.1); + animator.update(walkState.state.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1179,7 +1222,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition(runState); + const anyToRun = stateMachine.addAnyStateTransition(runState.state); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1215,7 +1258,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1238,19 +1281,19 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); // Add a noExitTime transition - const t1 = walkState.addTransition(runState); + const t1 = walkState.state.addTransition(runState.state); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = walkState.addTransition(idleState); + const t2 = walkState.state.addTransition(idleState.state); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = walkState._transitionCollection; + const collection = walkState.state._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1269,4 +1312,487 @@ describe("Animator test", function () { expect(collection.get(0)).to.eq(t2); expect(collection.get(1)).to.eq(t1); }); + + it("findAnimatorState lazy-creates handle for unplayed state", () => { + // Clone yields a fresh animator with no PlayData populated + 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); // live-bound default + + // Same handle returned on subsequent calls (verifies caching) + expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); + }); + + it("per-instance speed set before play applies on first play", () => { + const handle = animator.findAnimatorState("Survey"); + handle.speed = 0.5; + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.001); + + // Same handle observed via getCurrentAnimatorState + expect(animator.getCurrentAnimatorState(0)).to.eq(handle.state); + expect(handle.speed).to.eq(0.5); + }); + + it("per-instance speed survives crossFade out and back", () => { + animator.findAnimatorState("Survey").speed = 0.5; + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.001); + + // crossFade out (fixed 0.05s duration) + animator.crossFadeInFixedDuration("Walk", 0.05, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); // complete crossfade + // crossFade back + animator.crossFadeInFixedDuration("Survey", 0.05, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const srcPlayData = animator._animatorLayersData[0].srcPlayData; + expect(srcPlayData.state.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey + expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); + expect(srcPlayData.speed).to.eq(0.5); + }); + + 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); // shared default + // shared asset not mutated + const sharedSurvey = animator.animatorController.layers[0].stateMachine.findStateByName("Survey"); + expect(sharedSurvey.speed).to.eq(1); + }); + + it("crossFade phase uses playData.speed for time progression", () => { + // Set high per-instance speed on src state + animator.findAnimatorState("Survey").speed = 4; + animator.play("Survey"); + // @ts-ignore — Animator.update short-circuits to dt=0 if _playFrameCount===frameCount + animator.engine.time._frameCount++; + animator.update(0.001); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + const srcPlayedBefore = layerData.srcPlayData._playedTime; + + // Start crossFade — during crossFade, src should still advance per playData.speed=4 + animator.crossFade("Walk", 0.5, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); // 50ms of crossfade + + const srcPlayedAfter = layerData.srcPlayData._playedTime; + const advanced = srcPlayedAfter - srcPlayedBefore; + // With playData.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. + expect(advanced).to.be.closeTo(0.2, 0.05); + }); + + it("findAnimatorState rebuilds handle when state identity changes (remove/re-add same name)", () => { + const sm = animator.animatorController.layers[0].stateMachine; + const oldSurvey = animator.findAnimatorState("Survey"); + expect(oldSurvey).not.to.eq(null); + const oldStateRef = oldSurvey.state; + const originalIndex = sm.states.indexOf(oldStateRef); + + // Simulate dynamic controller mutation: remove and re-add same-name state + sm.removeState(oldStateRef); + const newStateRef = sm.addState("Survey"); + expect(newStateRef).not.to.eq(oldStateRef); + + const newHandle = animator.findAnimatorState("Survey"); + expect(newHandle).not.to.eq(null); + expect(newHandle.state).to.eq(newStateRef); + expect(newHandle).not.to.eq(oldSurvey); + + // Restore original Survey state so subsequent tests still see the + // clip-bound state. Drop the barebones replacement and reinsert the + // original at its previous index in the states list/map. + sm.removeState(newStateRef); + sm.states.splice(originalIndex, 0, oldStateRef); + // @ts-ignore — _statesMap is private but rebuild requires direct access + sm._statesMap["Survey"] = oldStateRef; + // Reset cached layer data so the next findAnimatorState rebuilds against + // the restored state. + // @ts-ignore + animator._reset(); + }); + + it("rebuilds curve owners and event handlers when state identity changes via remove/re-add", () => { + const localEntity = new Entity(engine); + const localAnimator = localEntity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + // Old state binds rotation.x: 0 → 90 over 1s + const oldState = layer.stateMachine.addState("X"); + const oldClip = new AnimationClip("oldClip"); + const rotationCurve = new AnimationFloatCurve(); + const rk1 = new Keyframe(); + rk1.time = 0; + rk1.value = 0; + const rk2 = new Keyframe(); + rk2.time = 1; + rk2.value = 90; + rotationCurve.addKey(rk1); + rotationCurve.addKey(rk2); + oldClip.addCurveBinding("", Transform, "rotation.x", rotationCurve); + oldState.clip = oldClip; + oldState.wrapMode = WrapMode.Loop; + + localAnimator.animatorController = controller; + localAnimator.play("X"); + // @ts-ignore + localAnimator.engine.time._frameCount++; + localAnimator.update(0.5); + + // First play populates stateData cache keyed by name "X" pointing at oldState's owners. + expect(localEntity.transform.rotation.x).to.be.closeTo(45, 1); + + // Remove + re-add same-name state with a clip targeting a *different* property. + layer.stateMachine.removeState(oldState); + const newState = layer.stateMachine.addState("X"); + const newClip = new AnimationClip("newClip"); + const positionCurve = new AnimationFloatCurve(); + const pk1 = new Keyframe(); + pk1.time = 0; + pk1.value = 0; + const pk2 = new Keyframe(); + pk2.time = 1; + pk2.value = 5; + positionCurve.addKey(pk1); + positionCurve.addKey(pk2); + newClip.addCurveBinding("", Transform, "position.x", positionCurve); + newState.clip = newClip; + newState.wrapMode = WrapMode.Loop; + + // Reset transform so any stale binding shows up as a wrong-property mutation. + localEntity.transform.position = new Vector3(0, 0, 0); + localEntity.transform.rotation = new Vector3(0, 0, 0); + + // @ts-ignore — internal layer data, verify cached state BEFORE second play to confirm stale. + const layerDataBeforeSecondPlay = localAnimator._animatorLayersData[0]; + const stateDataBefore = layerDataBeforeSecondPlay.animatorStateDataMap["X"]; + expect(stateDataBefore, "stateData should exist after first play").to.not.eq(undefined); + expect(stateDataBefore.state, "first-play stateData.state must be oldState").to.eq(oldState); + + localAnimator.play("X"); + + // @ts-ignore — internal layer data + const layerData = localAnimator._animatorLayersData[0]; + const stateData = layerData.animatorStateDataMap["X"]; + // stateData must rebuild against newState identity, not stay aliased to oldState. + expect(stateData.state, "second-play stateData.state must be newState").to.eq(newState); + // The cached curveLayerOwner must point at position.x owner now, not the stale rotation.x owner. + const firstOwnerProp = (stateData.curveLayerOwner[0] as any)?.curveOwner?.property; + expect(firstOwnerProp).to.eq("position.x"); + + // @ts-ignore + localAnimator.engine.time._frameCount++; + localAnimator.update(0.5); + + // After rebuild: position.x ≈ 2.5, rotation.x stays at 0. + // Without rebuild (stale stateData): curveLayerOwner[0] still points at rotation.x owner, + // so position curve value would be applied to rotation.x and position.x would never change. + expect(localEntity.transform.position.x).to.be.closeTo(2.5, 0.5); + expect(localEntity.transform.rotation.x).to.eq(0); + + localEntity.destroy(); + }); + + it("findAnimatorState resets stale layer data after controller mutation", () => { + // Ensure layerData[0] is populated + const handle1 = animator.findAnimatorState("Survey"); + expect(handle1).not.to.eq(null); + + // Mutate the controller — this dispatches the update flag + const controller = animator.animatorController; + const dummyLayer = new AnimatorControllerLayer("__dummy__"); + controller.addLayer(dummyLayer); + + // findAnimatorState should reset stale layerData and rebuild + const handle2 = animator.findAnimatorState("Survey"); + expect(handle2).not.to.eq(null); + expect(handle2).not.to.eq(handle1); // fresh handle after reset + + // Cleanup + controller.removeLayer(controller.layers.indexOf(dummyLayer)); + }); + + it("destroy detaches stateData clipChangedListeners from surviving AnimatorState", () => { + // Build a controller whose AnimatorState we can keep alive after the animator is destroyed. + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + const sharedState = layer.stateMachine.addState("Y"); + const clip = new AnimationClip("yClip"); + const curve = new AnimationFloatCurve(); + const k1 = new Keyframe(); + k1.time = 0; + k1.value = 0; + const k2 = new Keyframe(); + k2.time = 1; + k2.value = 90; + curve.addKey(k1); + curve.addKey(k2); + clip.addCurveBinding("", Transform, "rotation.x", curve); + sharedState.clip = clip; + + // @ts-ignore — inspect listener attachment on the shared state directly. + const listenersBefore = sharedState._updateFlagManager._listeners.length; + + const localEntity = new Entity(engine); + const localAnimator = localEntity.addComponent(Animator); + localAnimator.animatorController = controller; + localAnimator.play("Y"); + // @ts-ignore + expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore + 1); + + // Destroying only the Animator (controller + state still alive) must + // detach the clipChangedListener it installed; otherwise the closure + // keeps a destroyed entity reachable through state.clip.dispatch(). + localAnimator.destroy(); + localEntity.destroy(); + + // @ts-ignore + expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore); + }); + + it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { + const survey = animator.findAnimatorState("Survey"); + expect(survey).not.to.eq(null); + const surveyState = survey.state; + // @ts-ignore — read internal listener list size + const listenersBefore = surveyState._updateFlagManager._listeners.length; + + // First play: registers one clipChangedListener for Survey on this layer. + animator.play("Survey"); + // @ts-ignore + const listenersAfterFirstPlay = surveyState._updateFlagManager._listeners.length; + expect(listenersAfterFirstPlay).to.eq(listenersBefore + 1); + + // Three controller mutations → three _reset() calls → without cleanup + // each reset would leave its prior listener attached and the next play + // would register a fresh one on top of it. + for (let i = 0; i < 3; i++) { + const dummy = new AnimatorControllerLayer(`__dummy_${i}__`); + animator.animatorController.addLayer(dummy); + animator.play("Survey"); + // @ts-ignore + const count = surveyState._updateFlagManager._listeners.length; + expect(count, `listener count after iteration ${i + 1}`).to.eq(listenersBefore + 1); + animator.animatorController.removeLayer(animator.animatorController.layers.indexOf(dummy)); + } + }); + + it("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + const srcBefore = layerData.srcPlayData; + const playedBefore = srcBefore._playedTime; + + // crossFade to the same state — should be ignored + animator.crossFade("Walk", 0.3, 0, 0); + + expect(layerData.srcPlayData).to.eq(srcBefore); + expect(layerData.srcPlayData._playedTime).to.eq(playedBefore); + expect(layerData.destPlayData).to.eq(null); + }); + + it("crossFade to currently-fading dest state is no-op", () => { + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + animator.crossFade("Run", 0.5, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + const destBefore = layerData.destPlayData; + const destPlayedBefore = destBefore._playedTime; + + // crossFade to the in-flight dest state — should be ignored + animator.crossFade("Run", 0.3, 0, 0); + + expect(layerData.destPlayData).to.eq(destBefore); + expect(layerData.destPlayData._playedTime).to.eq(destPlayedBefore); + }); + + it("state-machine self-transition is also a no-op (alias-guard policy)", () => { + const walk = animator.findAnimatorState("Walk"); + walk.state.clearTransitions(); + animator.animatorController.addParameter("restart", false); + + const selfTransition = walk.state.addTransition(walk.state); + selfTransition.hasExitTime = false; + selfTransition.duration = 0.1; + selfTransition.addCondition("restart", AnimatorConditionMode.If, true); + + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + const srcBefore = layerData.srcPlayData; + const playedBefore = srcBefore._playedTime; + + // Trigger the self-transition + animator.setParameterValue("restart", true); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); + + // Self-transition is intentionally a no-op (one persistent PlayData per state). + // src should keep advancing as if no transition happened, dest stays null. + expect(layerData.srcPlayData).to.eq(srcBefore); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.srcPlayData._playedTime).to.be.greaterThan(playedBefore); + expect(layerData.destPlayData).to.eq(null); + }); + + it("play during crossFade clears stale destPlayData", () => { + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + animator.crossFade("Run", 0.5, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); + + // Interrupt the in-flight crossFade with a play() + animator.play("Survey"); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + expect(layerData.destPlayData).to.eq(null); + expect(layerData.crossFadeTransition).to.eq(null); + + // A subsequent crossFade to the previously-fading state should now succeed — + // the stale dest slot must not block it via the alias guard. + animator.crossFade("Run", 0.3, 0, 0); + expect(layerData.destPlayData?.state.name).to.eq("Run"); + }); + + it("crossFade to nonexistent state is a safe no-op", () => { + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + const before = animator.getCurrentAnimatorState(0); + animator.crossFade("MissingState", 0.3, 0, 0); + const after = animator.getCurrentAnimatorState(0); + + expect(after).to.eq(before); + // @ts-ignore — verify no junk layerData was written at array index -1. + expect(animator._animatorLayersData[-1]).to.eq(undefined); + }); + + it("findAnimatorState with out-of-range layerIndex returns null", () => { + expect(animator.findAnimatorState("Survey", 99)).to.eq(null); + expect(animator.findAnimatorState("Survey", -2)).to.eq(null); + }); + + it("play / crossFade with out-of-range layerIndex are safe no-ops", () => { + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.001); + + const stateBefore = animator.getCurrentAnimatorState(0); + + expect(() => animator.play("Walk", 99)).not.to.throw(); + expect(() => animator.crossFade("Run", 0.3, 99, 0)).not.to.throw(); + + expect(animator.getCurrentAnimatorState(0)).to.eq(stateBefore); + // @ts-ignore — verify no junk layerData created at index -1 / 99 + expect(animator._animatorLayersData[-1]).to.eq(undefined); + // @ts-ignore + expect(animator._animatorLayersData[99]).to.eq(undefined); + }); + + it("transition out of a state with per-instance speed 0 does not produce NaN", () => { + const survey = animator.findAnimatorState("Survey"); + survey.speed = 0; // pause this state per-instance + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // crossFade out — destination state should still progress despite src speed=0 + animator.crossFade("Walk", 0.3, 0, 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + expect(Number.isNaN(layerData.srcPlayData._playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destPlayData?._playedTime ?? 0)).to.eq(false); + // Walk dest should have progressed + expect(layerData.destPlayData?._playedTime).to.be.greaterThan(0); + }); + + it("no-exit transition out of speed=0 source preserves remaining deltaTime and avoids NaN", () => { + const survey = animator.findAnimatorState("Survey"); + const walk = animator.findAnimatorState("Walk"); + survey.state.clearTransitions(); + walk.state.clearTransitions(); + animator.animatorController.addParameter("goWalk", false); + + survey.speed = 0; // pause source per-instance + + const transition = survey.state.addTransition(walk.state); + transition.hasExitTime = false; + transition.duration = 0.3; + transition.addCondition("goWalk", AnimatorConditionMode.If, true); + + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.05); + + animator.setParameterValue("goWalk", true); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const layerData = animator._animatorLayersData[0]; + expect(Number.isNaN(layerData.srcPlayData._playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destPlayData?._playedTime ?? 0)).to.eq(false); + expect(layerData.destPlayData?.state.name).to.eq("Walk"); + // dest should have advanced from the remaining deltaTime that was + // preserved by the playSpeed===0 guard + expect(layerData.destPlayData?._playedTime).to.be.greaterThan(0); + }); }); diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index 0a428f4b60..ac4795890e 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -871,4 +871,4 @@ describe("Entity", async () => { expect(order).toEqual(["C", "B", "A"]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 6ad52fc919..6f3eaf0b17 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -39,6 +39,117 @@ beforeAll(async function () { @registerGLTFParser(GLTFParserType.Schema) class GLTFCustomJSONParser extends GLTFParser { parse(context: GLTFParserContext) { + if (context.glTFResource.url.endsWith("testSkinRoot.gltf")) { + context.buffers = [new ArrayBuffer(192)]; + return Promise.resolve({ + asset: { + version: "2.0" + }, + scene: 0, + scenes: [ + { + nodes: [0, 1] + } + ], + nodes: [ + { + name: "Character_Man" + }, + { + name: "mixamorig:Hips", + children: [2] + }, + { + name: "mixamorig:Spine" + } + ], + skins: [ + { + inverseBindMatrices: 0, + // Joints span both top-level scene roots: Character_Man (0) and Hips (1)/Spine (2). + joints: [0, 1, 2] + } + ], + accessors: [ + { + bufferView: 0, + byteOffset: 0, + componentType: 5126, + count: 3, + type: "MAT4" + } + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 192 + } + ], + buffers: [ + { + byteLength: 192 + } + ] + }); + } + + if (context.glTFResource.url.endsWith("testSingleSkeleton.gltf")) { + context.buffers = [new ArrayBuffer(128)]; + return Promise.resolve({ + asset: { + version: "2.0" + }, + scene: 0, + scenes: [ + { + // Two top-level roots: a character skeleton and an unrelated sibling (e.g., a light). + nodes: [0, 2] + } + ], + nodes: [ + { + name: "Character_Root", + children: [1] + }, + { + name: "mixamorig:Hips" + }, + { + name: "Light" + } + ], + skins: [ + { + inverseBindMatrices: 0, + // All joints converge to a single top-level root (Character_Root). + joints: [0, 1] + } + ], + accessors: [ + { + bufferView: 0, + byteOffset: 0, + componentType: 5126, + count: 2, + type: "MAT4" + } + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 128 + } + ], + buffers: [ + { + byteLength: 128 + } + ] + }); + } + const glTF = { buffers: [ { @@ -481,6 +592,17 @@ describe("glTF Loader test", function () { expect(renderer).to.exist; expect(renderer.blendShapeWeights).to.deep.include([1, 1]); }); + + it("single-root animation root channel should bind to the root node path", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testA.gltf" + }); + + const clip = glTFResource.animations?.[0]; + expect(clip).to.exist; + expect(clip.curveBindings[0].relativePath).to.equal("entity1"); + }); }); describe("glTF instance test", function () { @@ -530,6 +652,33 @@ describe("glTF scene root structure", function () { expect(defaultSceneRoot.children.length).to.equal(1); expect(defaultSceneRoot.children[0].name).to.equal("entity1"); }); + + it("Multi-root skins without skeleton should use the scene wrapper as rootBone", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testSkinRoot.gltf" + }); + const { defaultSceneRoot, skins } = glTFResource; + + expect(defaultSceneRoot.name).to.equal("GLTF_ROOT"); + expect(defaultSceneRoot.children.length).to.equal(2); + expect(skins[0].rootBone).to.equal(defaultSceneRoot); + }); + + it("Multi-root scenes whose joints converge to a single top-level root should not use the scene wrapper", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testSingleSkeleton.gltf" + }); + const { defaultSceneRoot, skins } = glTFResource; + + expect(defaultSceneRoot.name).to.equal("GLTF_ROOT"); + // Scene has two top-level roots, but all joints converge to "Character_Root". + expect(defaultSceneRoot.children.length).to.equal(2); + expect(skins[0].rootBone).to.not.equal(defaultSceneRoot); + // rootBone should be inside the Character_Root subtree (LCA = Character_Root). + expect(skins[0].rootBone.name).to.equal("Character_Root"); + }); }); describe("glTF instance test", function () {