From f3e57529ebd7959e0ce2ac274e0cc60d4f4eea97 Mon Sep 17 00:00:00 2001 From: ChenMo Date: Wed, 25 Feb 2026 10:32:55 +0800 Subject: [PATCH] Allow anyState transitions to interrupt crossFade & fix transition bugs (#2893) * fix(animation): allow anyState transitions to interrupt crossFade & fix transition bugs --- packages/core/src/animation/Animator.ts | 41 ++- .../AnimatorStateTransitionCollection.ts | 11 +- tests/src/core/Animator.test.ts | 253 +++++++++++++++++- 3 files changed, 296 insertions(+), 9 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 0ad1df8b0..ac4cbe875 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -748,6 +748,10 @@ export class Animator extends Component { const { state: destState } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); + if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { + return; + } + const srcPlaySpeed = srcState.speed * speed; const dstPlaySpeed = destState.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; @@ -872,9 +876,12 @@ export class Animator extends Component { ) { const { destPlayData } = layerData; const { state } = destPlayData; - const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); + if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { + return; + } + const playSpeed = state.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; @@ -1081,7 +1088,7 @@ export class Animator extends Component { const endTime = state.clipEndTime * clipDuration; if (transitionCollection.noExitTimeCount) { - targetTransition = this._checkNoExitTimeTransition(layerData, transitionCollection, aniUpdate); + targetTransition = this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate); if (targetTransition) { return targetTransition; } @@ -1155,13 +1162,37 @@ export class Animator extends Component { return targetTransition; } - private _checkNoExitTimeTransition( + private _tryCrossFadeInterrupt( + layerData: AnimatorLayerData, + transitionDuration: number, + currentDestState: AnimatorState, + deltaTime: number, + aniUpdate: boolean + ): boolean { + if (transitionDuration > 0) { + const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; + if ( + anyStateTransitions.noExitTimeCount && + this._checkNoExitTimeTransitions(layerData, anyStateTransitions, aniUpdate, currentDestState) + ) { + this._updateState(layerData, deltaTime, aniUpdate); + return true; + } + } + return false; + } + + private _checkNoExitTimeTransitions( layerData: AnimatorLayerData, transitionCollection: AnimatorStateTransitionCollection, - aniUpdate: boolean + aniUpdate: boolean, + excludeDestState?: AnimatorState ): AnimatorStateTransition { - for (let i = 0, n = transitionCollection.count; i < n; ++i) { + for (let i = 0, n = transitionCollection.noExitTimeCount; i < n; ++i) { const transition = transitionCollection.get(i); + // Skip if destination is same as current state (equivalent to Unity's canTransitionToSelf=false) + // TODO: Support canTransitionToSelf option on AnimatorStateTransition + if (excludeDestState && transition.destinationState === excludeDestState) continue; if ( transition.mute || (transitionCollection.isSoloMode && !transition.solo) || diff --git a/packages/core/src/animation/AnimatorStateTransitionCollection.ts b/packages/core/src/animation/AnimatorStateTransitionCollection.ts index e902ca128..6ea486b50 100644 --- a/packages/core/src/animation/AnimatorStateTransitionCollection.ts +++ b/packages/core/src/animation/AnimatorStateTransitionCollection.ts @@ -74,9 +74,13 @@ export class AnimatorStateTransitionCollection { this._soloCount += isModifiedSolo ? 1 : -1; } - updateTransitionsIndex(transition: AnimatorStateTransition, hasExitTime: boolean): void { + updateTransitionsIndex(transition: AnimatorStateTransition, newHasExitTime: boolean): void { const transitions = this.transitions; transitions.splice(transitions.indexOf(transition), 1); + // newHasExitTime=true means transition was noExitTime before, so decrement + if (newHasExitTime) { + this.noExitTimeCount--; + } this._addTransition(transition); } @@ -101,13 +105,14 @@ export class AnimatorStateTransitionCollection { } const { exitTime } = transition; + const { noExitTimeCount } = this; const count = transitions.length; - const maxExitTime = count ? transitions[count - 1].exitTime : 0; + const maxExitTime = count > noExitTimeCount ? transitions[count - 1].exitTime : 0; if (exitTime >= maxExitTime) { transitions.push(transition); } else { let index = count; - while (--index >= 0 && exitTime < transitions[index].exitTime); + while (--index >= noExitTimeCount && exitTime < transitions[index].exitTime); transitions.splice(index + 1, 0, transition); } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 8649f4800..c37d476a9 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -28,6 +28,15 @@ const canvasDOM = document.createElement("canvas"); canvasDOM.width = 1024; canvasDOM.height = 1024; +// Mirror of internal LayerState enum (not exported from @galacean/engine-core) +const LayerState = { + Standby: 0, + Playing: 1, + CrossFading: 2, + FixedCrossFading: 3, + Finished: 4 +} as const; + describe("Animator test", function () { let animator: Animator; let resource: GLTFResource; @@ -55,6 +64,24 @@ describe("Animator test", function () { // @ts-ignore animator._reset(); animator.animatorController.clearParameters(); + + // 清理 state machine transitions + const stateMachine = animator.animatorController.layers[0].stateMachine; + stateMachine.clearAnyStateTransitions(); + stateMachine.clearEntryStateTransitions(); + + // 清理各状态的 transitions 并恢复默认属性 + const stateNames = ["Survey", "Walk", "Run"]; + for (const name of stateNames) { + const state = animator.findAnimatorState(name); + if (state) { + state.clearTransitions(); + state.speed = 1; + state.clipStartTime = 0; + state.clipEndTime = 1; + state.wrapMode = WrapMode.Loop; + } + } }); it("constructor", () => { // Test default values @@ -1017,5 +1044,229 @@ describe("Animator test", function () { it("Clone", () => { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); - }) + }); + + it("anyState transition interrupts crossFade", () => { + const { animatorController } = animator; + animatorController.addParameter("interrupt", false); + const stateMachine = animatorController.layers[0].stateMachine; + const idleState = animator.findAnimatorState("Survey"); + + // AnyState -> Idle (can interrupt) + const anyToIdle = stateMachine.addAnyStateTransition(idleState); + anyToIdle.hasExitTime = false; + anyToIdle.duration = 0.2; + anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); + + // Start crossFade using crossFade method + animator.play("Walk"); + animator.crossFade("Run", 1.0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // Get layerData after update (layerData is recreated after _reset in afterEach) + // @ts-ignore + const layerData = animator._getAnimatorLayerData(0); + + expect(layerData.layerState).to.eq(LayerState.CrossFading); + expect(layerData.destPlayData.state.name).to.eq("Run"); + + // Trigger interrupt during crossFade + animator.setParameterValue("interrupt", true); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // Should have interrupted to Idle + expect(layerData.destPlayData.state.name).to.eq("Survey"); + }); + + it("noExitTime transition scan should ignore exitTime transitions", () => { + const { animatorController } = animator; + animatorController.addParameter("goRun", true); + animatorController.addParameter("never", false); + + const walkState = animator.findAnimatorState("Walk"); + const runState = animator.findAnimatorState("Run"); + const idleState = animator.findAnimatorState("Survey"); + + walkState.clipStartTime = 0; + walkState.clipEndTime = 1; + walkState.clearTransitions(); + + // A noExitTime transition that fails (ensures noExitTimeCount > 0). + const noExitFailTransition = walkState.addTransition(idleState); + noExitFailTransition.hasExitTime = false; + noExitFailTransition.duration = 0; + noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); + + // A hasExitTime transition whose conditions are true, but should not fire until exitTime. + const exitTimeTransition = new AnimatorStateTransition(); + exitTimeTransition.exitTime = 0.5; + exitTimeTransition.duration = 0; + exitTimeTransition.destinationState = runState; + exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); + walkState.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; + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(preExitDeltaTime); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.destPlayData.state).to.be.undefined; + + // Update past exitTime, should transition to Run. + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(walkState.clip.length * 0.5); + expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); + }); + + it("anyState transition interrupts FixedCrossFading", () => { + const { animatorController } = animator; + animatorController.addParameter("interrupt", false); + const stateMachine = animatorController.layers[0].stateMachine; + const idleState = animator.findAnimatorState("Survey"); + const walkState = animator.findAnimatorState("Walk"); + + // AnyState -> Idle (can interrupt) + const anyToIdle = stateMachine.addAnyStateTransition(idleState); + 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; + animator.play("Walk"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(walkState.clip.length + 0.1); + + // @ts-ignore + const layerData = animator._getAnimatorLayerData(0); + + expect(layerData.layerState).to.eq(LayerState.Finished); + + // CrossFade from Finished state → FixedCrossFading + animator.crossFade("Run", 1.0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); + expect(layerData.destPlayData.state.name).to.eq("Run"); + + // Trigger interrupt during FixedCrossFading + animator.setParameterValue("interrupt", true); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // Should have interrupted to Idle + expect(layerData.destPlayData.state.name).to.eq("Survey"); + }); + + it("anyState interrupt should skip transition to same destination state", () => { + const { animatorController } = animator; + animatorController.addParameter("alwaysTrue", true); + const stateMachine = animatorController.layers[0].stateMachine; + const runState = animator.findAnimatorState("Run"); + + // AnyState -> Run (always true, noExitTime) + const anyToRun = stateMachine.addAnyStateTransition(runState); + anyToRun.hasExitTime = false; + anyToRun.duration = 0.2; + anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); + + // Start crossFade Walk -> Run + animator.play("Walk"); + animator.crossFade("Run", 1.0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const layerData = animator._getAnimatorLayerData(0); + + // Should be in CrossFading state, dest = Run + expect(layerData.layerState).to.eq(LayerState.CrossFading); + expect(layerData.destPlayData.state.name).to.eq("Run"); + + // Update again - anyState -> Run should be skipped because dest is already Run + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // Should still be CrossFading to Run (not interrupted/reset) + expect(layerData.layerState).to.eq(LayerState.CrossFading); + expect(layerData.destPlayData.state.name).to.eq("Run"); + }); + + it("zero-duration crossFade should not be interrupted by anyState transition", () => { + const { animatorController } = animator; + animatorController.addParameter("interrupt", true); + const stateMachine = animatorController.layers[0].stateMachine; + const idleState = animator.findAnimatorState("Survey"); + + // AnyState -> Idle (always true, noExitTime) + const anyToIdle = stateMachine.addAnyStateTransition(idleState); + anyToIdle.hasExitTime = false; + anyToIdle.duration = 0.2; + anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); + + // Start crossFade with duration = 0 (instant transition) + animator.play("Walk"); + animator.crossFade("Run", 0); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + // @ts-ignore + const layerData = animator._getAnimatorLayerData(0); + + // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) + expect(layerData.srcPlayData.state.name).to.eq("Run"); + }); + + it("toggle hasExitTime should maintain correct noExitTimeCount", () => { + const walkState = animator.findAnimatorState("Walk"); + const runState = animator.findAnimatorState("Run"); + const idleState = animator.findAnimatorState("Survey"); + walkState.clearTransitions(); + + // Add a noExitTime transition + const t1 = walkState.addTransition(runState); + t1.hasExitTime = false; + + // Add a hasExitTime transition + const t2 = walkState.addTransition(idleState); + t2.hasExitTime = true; + t2.exitTime = 0.5; + + // @ts-ignore + const collection = walkState._transitionCollection; + expect(collection.noExitTimeCount).to.eq(1); + expect(collection.count).to.eq(2); + + // Toggle noExitTime -> hasExitTime + t1.hasExitTime = true; + t1.exitTime = 0.8; + expect(collection.noExitTimeCount).to.eq(0); + expect(collection.count).to.eq(2); + + // Toggle hasExitTime -> noExitTime + t2.hasExitTime = false; + expect(collection.noExitTimeCount).to.eq(1); + expect(collection.count).to.eq(2); + + // Verify array order: [t2(noExitTime), t1(exitTime=0.8)] + expect(collection.get(0)).to.eq(t2); + expect(collection.get(1)).to.eq(t1); + }); });