Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 3 additions & 47 deletions packages/core/src/animation/Animator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,21 +744,9 @@ export class Animator extends Component {
) {
const { srcPlayData, destPlayData, layerIndex } = layerData;
const { speed } = this;
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();
const { state: srcState } = srcPlayData;
const { state: destState } = destPlayData;

// Check anyState noExitTime transitions, allow interrupting crossFade
// Must check before any update() calls to preserve events and let new state consume deltaTime
if (transitionDuration > 0) {
const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine;
if (anyStateTransitions.noExitTimeCount) {
if (this._checkCrossFadeInterrupt(layerData, anyStateTransitions, destState, aniUpdate)) {
this._updateState(layerData, deltaTime, aniUpdate);
return;
}
}
}
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();

const srcPlaySpeed = srcState.speed * speed;
const dstPlaySpeed = destState.speed * speed;
Expand Down Expand Up @@ -884,19 +872,8 @@ export class Animator extends Component {
) {
const { destPlayData } = layerData;
const { state } = destPlayData;
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();

// Check anyState noExitTime transitions, allow interrupting crossFade
// Must check before any update() calls to preserve events and let new state consume deltaTime
if (transitionDuration > 0) {
const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine;
if (anyStateTransitions.noExitTimeCount) {
if (this._checkCrossFadeInterrupt(layerData, anyStateTransitions, state, aniUpdate)) {
this._updateState(layerData, deltaTime, aniUpdate);
return;
}
}
}
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();

const playSpeed = state.speed * this.speed;
const playDeltaTime = playSpeed * deltaTime;
Expand Down Expand Up @@ -1183,29 +1160,8 @@ export class Animator extends Component {
transitionCollection: AnimatorStateTransitionCollection,
aniUpdate: boolean
): AnimatorStateTransition {
return this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate);
}

private _checkCrossFadeInterrupt(
layerData: AnimatorLayerData,
transitionCollection: AnimatorStateTransitionCollection,
currentDestState: AnimatorState,
aniUpdate: boolean
): AnimatorStateTransition {
return this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate, currentDestState);
}

private _checkNoExitTimeTransitions(
layerData: AnimatorLayerData,
transitionCollection: AnimatorStateTransitionCollection,
aniUpdate: boolean,
excludeDestState?: AnimatorState
): AnimatorStateTransition {
for (let i = 0, n = transitionCollection.noExitTimeCount; i < n; ++i) {
for (let i = 0, n = transitionCollection.count; i < n; ++i) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_checkNoExitTimeTransition() now iterates over transitionCollection.count (all transitions) even though it’s only called when noExitTimeCount > 0. This can allow hasExitTime transitions to be applied immediately (bypassing exit-time gating) if their conditions pass after the no-exit-time transitions fail.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

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) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,20 @@ export class AnimatorStateTransitionCollection {
private _addTransition(transition: AnimatorStateTransition): void {
const transitions = this.transitions;

// NoExitTime transitions are stored at the front of the array [0, noExitTimeCount)
if (!transition.hasExitTime) {
transitions.unshift(transition);
this.noExitTimeCount++;
return;
}

// HasExitTime transitions are sorted by exitTime in range [noExitTimeCount, count)
const { exitTime } = transition;
const { noExitTimeCount } = this;
const count = transitions.length;
// Only compare with hasExitTime transitions (after noExitTimeCount)
const maxExitTime = count > noExitTimeCount ? transitions[count - 1].exitTime : 0;
const maxExitTime = count ? transitions[count - 1].exitTime : 0;
if (exitTime >= maxExitTime) {
transitions.push(transition);
} else {
let index = count;
// Stop at noExitTimeCount boundary to avoid comparing with noExitTime transitions
while (--index >= noExitTimeCount && exitTime < transitions[index].exitTime);
while (--index >= 0 && exitTime < transitions[index].exitTime);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The insertion loop for hasExitTime transitions now compares against the entire transitions array, including hasExitTime=false transitions whose exitTime still defaults to 1.0. That can insert exit-time transitions into the [0, noExitTimeCount) segment and break the noExitTimeCount ordering invariant relied on by the forward/backward exit-time scans.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

transitions.splice(index + 1, 0, transition);
}
}
Expand Down
188 changes: 16 additions & 172 deletions tests/src/core/Animator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,6 @@ 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
Expand Down Expand Up @@ -949,28 +931,21 @@ describe("Animator test", function () {
const walkState = animator.findAnimatorState("Walk");
walkState.clearTransitions();
const runState = animator.findAnimatorState("Run");
const originClipStartTime = runState.clipStartTime;
const originClipEndTime = runState.clipEndTime;
try {
runState.clipStartTime = runState.clipEndTime = 0;
runState.clearTransitions();
const walkToRunTransition = walkState.addTransition(runState);
walkToRunTransition.hasExitTime = false;
walkToRunTransition.isFixedDuration = true;
walkToRunTransition.duration = 0.1;
walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true);
animator.play("Walk");
animator.activateTriggerParameter("triggerRun");
// @ts-ignore
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);
} finally {
runState.clipStartTime = originClipStartTime;
runState.clipEndTime = originClipEndTime;
}
runState.clipStartTime = runState.clipEndTime = 0;
runState.clearTransitions();
const walkToRunTransition = walkState.addTransition(runState);
walkToRunTransition.hasExitTime = false;
walkToRunTransition.isFixedDuration = true;
walkToRunTransition.duration = 0.1;
walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true);
animator.play("Walk");
animator.activateTriggerParameter("triggerRun");
// @ts-ignore
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);
});

it("transitionIndex", () => {
Expand Down Expand Up @@ -1042,136 +1017,5 @@ 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 originAnyStateTransitions = stateMachine.anyStateTransitions.slice();
const originNoExitTimeTransitions = originAnyStateTransitions.filter((t) => !t.hasExitTime);
const originExitTimeTransitions = originAnyStateTransitions.filter((t) => t.hasExitTime);
stateMachine.clearAnyStateTransitions();
try {
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);

// LayerState.CrossFading = 2
expect(layerData.layerState).to.eq(2);
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");
} finally {
stateMachine.clearAnyStateTransitions();
for (let i = 0; i < originExitTimeTransitions.length; i++) {
stateMachine.addAnyStateTransition(originExitTimeTransitions[i]);
}
for (let i = originNoExitTimeTransitions.length - 1; i >= 0; i--) {
stateMachine.addAnyStateTransition(originNoExitTimeTransitions[i]);
}
}
});

it("noExitTime transition scan should ignore exitTime transitions", () => {
const { animatorController } = animator;
animatorController.addParameter("goRun", true);
animatorController.addParameter("never", false);

const stateMachine = animatorController.layers[0].stateMachine;
const originAnyStateTransitions = stateMachine.anyStateTransitions.slice();
const originNoExitTimeAnyTransitions = originAnyStateTransitions.filter((t) => !t.hasExitTime);
const originExitTimeAnyTransitions = originAnyStateTransitions.filter((t) => t.hasExitTime);
stateMachine.clearAnyStateTransitions();

const walkState = animator.findAnimatorState("Walk");
const runState = animator.findAnimatorState("Run");
const idleState = animator.findAnimatorState("Survey");

const originWalkTransitions = walkState.transitions.slice();
const originNoExitTimeWalkTransitions = originWalkTransitions.filter((t) => !t.hasExitTime);
const originExitTimeWalkTransitions = originWalkTransitions.filter((t) => t.hasExitTime);

const originClipStartTime = walkState.clipStartTime;
const originClipEndTime = walkState.clipEndTime;

try {
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.
// Use pre-configured transition to avoid dynamic hasExitTime switching
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");
} finally {
walkState.clipStartTime = originClipStartTime;
walkState.clipEndTime = originClipEndTime;
walkState.clearTransitions();
for (let i = 0; i < originExitTimeWalkTransitions.length; i++) {
walkState.addTransition(originExitTimeWalkTransitions[i]);
}
for (let i = originNoExitTimeWalkTransitions.length - 1; i >= 0; i--) {
walkState.addTransition(originNoExitTimeWalkTransitions[i]);
}

stateMachine.clearAnyStateTransitions();
for (let i = 0; i < originExitTimeAnyTransitions.length; i++) {
stateMachine.addAnyStateTransition(originExitTimeAnyTransitions[i]);
}
for (let i = originNoExitTimeAnyTransitions.length - 1; i >= 0; i--) {
stateMachine.addAnyStateTransition(originNoExitTimeAnyTransitions[i]);
}
}
});
})
});
3 changes: 1 addition & 2 deletions tests/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ export default defineProject({
"@galacean/engine-loader",
"@galacean/engine-rhi-webgl",
"@galacean/engine-math",
"@galacean/engine-core",
"fsevents"
"@galacean/engine-core"
]
},
test: {
Expand Down
Loading