From abb5c3afd2e9e22742f1e562e3f2d0f61afb39b9 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Thu, 11 Jun 2026 15:41:16 +0800 Subject: [PATCH 01/28] fix(animation): split shaderlab animation fixes --- packages/core/src/animation/AnimationClip.ts | 9 + .../animation/AnimationClipCurveBinding.ts | 11 +- packages/core/src/animation/Animator.ts | 298 +++--- .../src/animation/AnimatorStateInstance.ts | 76 -- .../src/animation/AnimatorStateMachine.ts | 9 +- packages/core/src/animation/index.ts | 2 +- .../internal/AnimationEventHandler.ts | 6 +- .../animation/internal/AnimatorLayerData.ts | 38 +- .../animation/internal/AnimatorStateData.ts | 6 +- .../internal/AnimatorStatePlayData.ts | 81 +- tests/src/core/Animator.test.ts | 947 ++++++------------ tests/src/core/AnimatorHang.test.ts | 20 + 12 files changed, 591 insertions(+), 912 deletions(-) delete mode 100644 packages/core/src/animation/AnimatorStateInstance.ts create mode 100644 tests/src/core/AnimatorHang.test.ts diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts index adb0c07c91..9f7b6779fc 100644 --- a/packages/core/src/animation/AnimationClip.ts +++ b/packages/core/src/animation/AnimationClip.ts @@ -204,6 +204,15 @@ export class AnimationClip extends EngineObject { this._length = 0; } + /** + * Samples an animation at a given time. + * @param entity - The animated entity + * @param time - The time to sample an animation + */ + sampleAnimation(entity: Entity, time: number): void { + this._sampleAnimation(entity, time); + } + /** * @internal * Samples an animation at a given time. diff --git a/packages/core/src/animation/AnimationClipCurveBinding.ts b/packages/core/src/animation/AnimationClipCurveBinding.ts index 906c154087..e6e4066181 100644 --- a/packages/core/src/animation/AnimationClipCurveBinding.ts +++ b/packages/core/src/animation/AnimationClipCurveBinding.ts @@ -33,7 +33,7 @@ export class AnimationClipCurveBinding { /** The animation curve. */ curve: AnimationCurve; - private _tempCurveOwner: Record> = {}; + private _tempCurveOwner = new WeakMap>(); /** * @internal @@ -63,10 +63,11 @@ export class AnimationClipCurveBinding { * @internal */ _getTempCurveOwner(entity: Entity, component: Component): AnimationCurveOwner { - const { instanceId } = entity; - if (!this._tempCurveOwner[instanceId]) { - this._tempCurveOwner[instanceId] = this._createCurveOwner(entity, component); + let owner = this._tempCurveOwner.get(entity); + if (!owner) { + owner = this._createCurveOwner(entity, component); + this._tempCurveOwner.set(entity, owner); } - return this._tempCurveOwner[instanceId]; + return owner; } } diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 42fc2ee3fa..4413f393a6 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -6,11 +6,11 @@ import { Renderer } from "../Renderer"; import { Script } from "../Script"; import { Logger } from "../base/Logger"; import { assignmentClone, ignoreClone } from "../clone/CloneManager"; +import { ClearableObjectPool } from "../utils/ClearableObjectPool"; import { AnimatorController } from "./AnimatorController"; import { AnimatorControllerLayer } from "./AnimatorControllerLayer"; import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; import { AnimatorState } from "./AnimatorState"; -import { AnimatorStateInstance } from "./AnimatorStateInstance"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { KeyframeValueType } from "./Keyframe"; @@ -19,6 +19,7 @@ import { AnimatorCullingMode } from "./enums/AnimatorCullingMode"; import { AnimatorLayerBlendingMode } from "./enums/AnimatorLayerBlendingMode"; import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; import { LayerState } from "./enums/LayerState"; +import { WrapMode } from "./enums/WrapMode"; import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; @@ -31,13 +32,15 @@ import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCur */ export class Animator extends Component { private static _passedTriggerParameterNames = new Array(); - private static _tempScripts: Script[] = []; /** Culling mode of this Animator. */ cullingMode: AnimatorCullingMode = AnimatorCullingMode.None; /** The playback speed of the Animator, 1.0 is normal playback speed. */ @assignmentClone speed = 1.0; + /** Whether the Animator sends AnimationEvent callbacks. */ + @assignmentClone + fireEvents = true; /** @internal */ _playFrameCount = -1; @@ -54,7 +57,9 @@ export class Animator extends Component { @ignoreClone private _animatorLayersData = new Array(); @ignoreClone - private _curveOwnerPool: Record>> = Object.create(null); + private _curveOwnerPool: WeakMap>> = new WeakMap(); + @ignoreClone + private _animationEventHandlerPool = new ClearableObjectPool(AnimationEventHandler); @ignoreClone private _parametersValueMap = >Object.create(null); @@ -114,7 +119,9 @@ 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 { - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } const stateInfo = this._getAnimatorStateInfo(stateName, layerIndex); const { state } = stateInfo; @@ -189,7 +196,9 @@ export class Animator extends Component { return; } - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } this._updateMark++; @@ -201,34 +210,43 @@ export class Animator extends Component { } /** - * Get the state instance currently playing on the target layer. + * Get the playing state from the target layerIndex. * @param layerIndex - The layer index - * @returns The state instance, or null if nothing is playing - * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ - getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { - this._resetIfControllerUpdated(); - return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null; + getCurrentAnimatorState(layerIndex: number): AnimatorState { + return this._animatorLayersData[layerIndex]?.srcPlayData?.state; } /** - * Get the state instance for a named state on this Animator. - * Overrides on the returned instance only affect this Animator. + * Get the state by name. + * @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 + */ + /** + * Find the per-instance play data for a state by name. + * The returned object's `speed` and `wrapMode` are per-instance and safe to modify without affecting other Animator instances. * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns The state instance, or null if no state matches - * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. + * @returns Per-instance AnimatorStatePlayData, or null if not found */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { - this._resetIfControllerUpdated(); + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state); + if (!state || foundLayer < 0) return null; + const layerData = this._animatorLayersData[foundLayer]; + if (!layerData) return null; + // Check srcPlayData and destPlayData for the matching state + if (layerData.srcPlayData.state === state) return layerData.srcPlayData; + if (layerData.destPlayData.state === state) return layerData.destPlayData; + // State exists in controller but not currently playing — return srcPlayData initialized with the state + return layerData.srcPlayData; } /** * Get the layer by name. * @param name - The layer's name. + * @todo Return per-instance layer data (like AnimatorStatePlayData for states) instead of shared asset. + * Currently returns the shared AnimatorControllerLayer — modifying `weight` affects all instances. + * Should follow Unity's pattern: Animator.SetLayerWeight/GetLayerWeight (per-instance). */ findLayerByName(name: string): AnimatorControllerLayer { return this._animatorController?._layersMap[name]; @@ -316,30 +334,40 @@ export class Animator extends Component { * @internal */ _reset(): void { - const { _curveOwnerPool: animationCurveOwners } = this; - for (let instanceId in animationCurveOwners) { - const propertyOwners = animationCurveOwners[instanceId]; - for (let property in propertyOwners) { - const owner = propertyOwners[property]; - owner.revertDefaultValue(); + // revertDefaultValue 可能在死 target 上抛;listener 清理必须始终发生,否则 + // polyfill 的 clipChangedListener 会留在共享的 AnimatorState 上,捕获 this(Animator)→Entity 整树。 + // 用 try/catch 隔离 revert,listener 块紧跟其后无条件执行。 + const layersData = this._animatorLayersData; + for (let i = 0, n = layersData.length; i < n; i++) { + const stateDataMap = layersData[i].animatorStateDataMap; + for (const stateName in stateDataMap) { + const stateData = stateDataMap[stateName]; + const layerOwners = stateData.curveLayerOwner; + for (let j = 0, m = layerOwners.length; j < m; j++) { + try { + layerOwners[j]?.curveOwner?.revertDefaultValue(); + } catch (e) { + Logger.warn("Animator._reset: revertDefaultValue threw", e); + } + } + if (stateData.clipChangedListener && stateData.state) { + stateData.state._updateFlagManager.removeListener(stateData.clipChangedListener); + stateData.clipChangedListener = null; + stateData.state = null; + } } } this._animatorLayersData.length = 0; - this._curveOwnerPool = Object.create(null); + this._curveOwnerPool = new WeakMap(); this._parametersValueMap = Object.create(null); + this._animationEventHandlerPool.clear(); if (this._controllerUpdateFlag) { this._controllerUpdateFlag.flag = false; } } - private _resetIfControllerUpdated(): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } - } - /** * @internal */ @@ -353,6 +381,7 @@ export class Animator extends Component { protected override _onDestroy(): void { super._onDestroy(); + this._reset(); const controller = this._animatorController; if (controller) { this._addResourceReferCount(controller, -1); @@ -367,12 +396,12 @@ export class Animator extends Component { normalizedTimeOffset: number, isFixedDuration: boolean ): void { - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state) { - return; - } + if (!state) return; const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); manuallyTransition.duration = duration; @@ -398,10 +427,8 @@ export class Animator extends Component { break; } } - } else if (layerIndex >= 0 && layerIndex < layers.length) { - state = layers[layerIndex].stateMachine.findStateByName(stateName); } else { - layerIndex = -1; + state = layers[layerIndex].stateMachine.findStateByName(stateName); } } stateInfo.layerIndex = layerIndex; @@ -410,18 +437,19 @@ export class Animator extends Component { } private _getAnimatorStateData( + stateName: string, animatorState: AnimatorState, animatorLayerData: AnimatorLayerData, layerIndex: number ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; - let animatorStateData = animatorStateDataMap.get(animatorState); + let animatorStateData = animatorStateDataMap[stateName]; if (!animatorStateData) { - animatorStateData = new AnimatorStateData(animatorState); - animatorStateDataMap.set(animatorState, animatorStateData); + animatorStateData = new AnimatorStateData(); + animatorStateDataMap[stateName] = animatorStateData; this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); + this._saveAnimatorEventHandlers(animatorState, animatorStateData); } - this._ensureEventHandlers(animatorState, animatorStateData); return animatorStateData; } @@ -453,17 +481,20 @@ export class Animator extends Component { } const { property } = curve; - const { instanceId } = component; - // Get owner - const propertyOwners = (curveOwnerPool[instanceId] ||= >>( - Object.create(null) - )); + // Get owner — WeakMap keyed by Component so dead components let entries GC. + let propertyOwners = curveOwnerPool.get(component); + if (!propertyOwners) { + propertyOwners = >>Object.create(null); + curveOwnerPool.set(component, propertyOwners); + } const owner = (propertyOwners[property] ||= curve._createCurveOwner(targetEntity, component)); - // Get layer owner - const layerPropertyOwners = (layerCurveOwnerPool[instanceId] ||= >( - Object.create(null) - )); + // Get layer owner — same WeakMap-by-Component pattern (was Record which kept dead components pinned for the Animator's lifetime). + let layerPropertyOwners = layerCurveOwnerPool.get(component); + if (!layerPropertyOwners) { + layerPropertyOwners = >Object.create(null); + layerCurveOwnerPool.set(component, layerPropertyOwners); + } const layerOwner = (layerPropertyOwners[property] ||= curve._createCurveLayerOwner(owner)); if (mask && mask.pathMasks.length) { @@ -478,41 +509,36 @@ export class Animator extends Component { } } - private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { - // state._updateFlagManager dispatches on both clip-swap and clip-events-mutation, - // so its version covers every input that affects eventHandlers binding - const stateVersion = state._updateFlagManager.version; - const scriptsVersion = this._entity._scriptsVersion; - if ( - animatorStateData.eventsBuiltVersion === stateVersion && - animatorStateData.eventsBuiltScriptsVersion === scriptsVersion - ) { - return; - } - - const scripts = Animator._tempScripts; - this._entity.getComponents(Script, scripts); - const scriptCount = scripts.length; - const { events } = state.clip; + private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { + const eventHandlerPool = this._animationEventHandlerPool; + const scripts = []; const { eventHandlers } = animatorStateData; - eventHandlers.length = 0; - for (let i = 0, n = events.length; i < n; i++) { - const event = events[i]; - const eventHandler = new AnimationEventHandler(); - const funcName = event.functionName; - const { handlers } = eventHandler; - eventHandler.event = event; - for (let j = scriptCount - 1; j >= 0; j--) { - const script = scripts[j]; - const handler = script[funcName]?.bind(script); - handler && handlers.push(handler); + const clipChangedListener = () => { + this._entity.getComponents(Script, scripts); + const scriptCount = scripts.length; + const { events } = state.clip; + eventHandlers.length = 0; + for (let i = 0, n = events.length; i < n; i++) { + const event = events[i]; + const eventHandler = eventHandlerPool.get(); + const funcName = event.functionName; + const { handlers } = eventHandler; + + eventHandler.event = event; + handlers.length = 0; + for (let j = scriptCount - 1; j >= 0; j--) { + const script = scripts[j]; + const handler = script[funcName]?.bind(script); + handler && handlers.push(handler); + } + eventHandlers.push(eventHandler); } - eventHandlers.push(eventHandler); - } - scripts.length = 0; - animatorStateData.eventsBuiltVersion = stateVersion; - animatorStateData.eventsBuiltScriptsVersion = scriptsVersion; + }; + clipChangedListener(); + state._updateFlagManager.addListener(clipChangedListener); + animatorStateData.state = state; + animatorStateData.clipChangedListener = clipChangedListener; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { @@ -539,8 +565,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // 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); + // 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); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -632,9 +658,9 @@ export class Animator extends Component { aniUpdate: boolean ): void { const { srcPlayData } = layerData; - const state = srcPlayData.instance._state; + const { state } = srcPlayData; - const playSpeed = srcPlayData.instance.speed * this.speed; + const playSpeed = srcPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcPlayData.updateOrientation(playDeltaTime); @@ -717,8 +743,7 @@ export class Animator extends Component { ); if (transition) { - // 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. + // Remove speed factor, use actual cost time const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed; remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate); } @@ -730,7 +755,7 @@ export class Animator extends Component { additive: boolean, aniUpdate: boolean ): void { - const curveBindings = playData.instance.clip._curveBindings; + const curveBindings = playData.state.clip._curveBindings; const finished = playData.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { @@ -764,20 +789,20 @@ export class Animator extends Component { ) { const { srcPlayData, destPlayData, layerIndex } = layerData; const { speed } = this; - const srcState = srcPlayData.instance._state; - const destState = destPlayData.instance._state; + const { state: srcState } = srcPlayData; + const { state: destState } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcPlayData.instance.speed * speed; - const dstPlaySpeed = destPlayData.instance.speed * speed; + const srcPlaySpeed = srcPlayData.speed * speed; + const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); - destPlayData.updateOrientation(dstPlayDeltaTime); + srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); + destPlayData && destPlayData.updateOrientation(dstPlayDeltaTime); const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; @@ -855,8 +880,8 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcPlayData.instance.clip; - const destState = destPlayData.instance._state; + const { _curveBindings: srcCurves } = srcPlayData.state.clip; + const { state: destState } = destPlayData; const { _curveBindings: destCurves } = destState.clip; const finished = destPlayData.playState === AnimatorStatePlayState.Finished; @@ -895,14 +920,14 @@ export class Animator extends Component { aniUpdate: boolean ) { const { destPlayData } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destPlayData.instance.speed * this.speed; + const playSpeed = destPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destPlayData.updateOrientation(playDeltaTime); @@ -969,7 +994,7 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const { _curveBindings: curveBindings } = state.clip; const { clipTime: destClipTime, playState } = destPlayData; @@ -1007,8 +1032,8 @@ export class Animator extends Component { aniUpdate: boolean ): void { const playData = layerData.srcPlayData; - const state = playData.instance._state; - const actualSpeed = playData.instance.speed * this.speed; + const { state } = playData; + const actualSpeed = playData.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; playData.updateOrientation(actualDeltaTime); @@ -1049,7 +1074,7 @@ export class Animator extends Component { } const { curveLayerOwner } = playData.stateData; - const { _curveBindings: curveBindings } = playData.instance.clip; + const { _curveBindings: curveBindings } = playData.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1070,13 +1095,14 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.completeCrossFade(); + layerData.switchPlayData(); + layerData.crossFadeTransition = null; } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { if (layerData.layerState === LayerState.Playing) { const srcPlayData = layerData.srcPlayData; - if (srcPlayData.instance._state !== playState) { + if (srcPlayData.state !== playState) { const { curveLayerOwner } = srcPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); @@ -1100,7 +1126,7 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = playData.instance._state; + const { state } = playData; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1317,23 +1343,19 @@ export class Animator extends Component { } private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean { + const name = state.name; if (!state.clip) { - Logger.warn(`The state named ${state.name} has no AnimationClip data.`); + Logger.warn(`The state named ${name} has no AnimationClip data.`); return false; } const animatorLayerData = this._getAnimatorLayerData(layerIndex); - const animatorStateData = this._getAnimatorStateData(state, animatorLayerData, layerIndex); + const animatorStateData = this._getAnimatorStateData(name, state, animatorLayerData, layerIndex); this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - const playData = animatorLayerData.getOrCreateInstance(state)._playData; - playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); - animatorLayerData.srcPlayData = playData; - // Drop any dangling cross-fade slot from a previously-interrupted crossFade - // so a later crossFade(B) isn't wrongly no-op'd by the active-dest guard. - animatorLayerData.clearCrossFadeSlot(); + animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1433,21 +1455,13 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); + const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - // Self/active-dest cross-fade is a no-op: each state has one persistent - // instance per layer, so a second concurrent fade has nowhere to live. - if ( - animatorLayerData.srcPlayData?.instance._state === crossState || - animatorLayerData.destPlayData?.instance._state === crossState - ) { - return false; - } - - const animatorStateData = this._getAnimatorStateData(crossState, animatorLayerData, layerIndex); - - const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; - destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime()); - animatorLayerData.destPlayData = destPlayData; + animatorLayerData.destPlayData.reset( + crossState, + animatorStateData, + transition.offset * crossState._getClipActualEndTime() + ); animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1482,24 +1496,28 @@ export class Animator extends Component { lastClipTime: number, deltaTime: number ): void { - const { isForward, clipTime } = playData; - const state = playData.instance._state; + const { state, isForward, clipTime, wrapMode } = playData; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); + const canWrap = wrapMode === WrapMode.Loop; if (isForward) { if (lastClipTime + deltaTime >= endTime) { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData.currentEventIndex = 0; - this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + if (canWrap) { + playData.currentEventIndex = 0; + this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + } } else { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } } else { if (lastClipTime + deltaTime <= startTime) { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); - playData.currentEventIndex = eventHandlers.length - 1; - this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + if (canWrap) { + playData.currentEventIndex = eventHandlers.length - 1; + this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + } } else { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } @@ -1595,12 +1613,10 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - // Re-check whether the clip events/scripts changed since the last build — - // play()/crossFade() entry points already ensure on enter, but addEvent() - // or addComponent(Script) after play() must also flow through. - this._ensureEventHandlers(state, playData.stateData); const { eventHandlers } = playData.stateData; - eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); + this.fireEvents && + eventHandlers.length && + this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts deleted file mode 100644 index 9bcdfa0fb5..0000000000 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { AnimationClip } from "./AnimationClip"; -import { AnimatorState } from "./AnimatorState"; -import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; -import { WrapMode } from "./enums/WrapMode"; - -/** - * Per-Animator view of an `AnimatorState`. - * - * Override fields (speed, wrapMode) are scoped to this Animator; unset fields - * fall through to the underlying state asset. - */ -export class AnimatorStateInstance { - /** @internal */ - readonly _state: AnimatorState; - /** @internal */ - readonly _playData: AnimatorStatePlayData; - - private _speed: number | undefined; - private _wrapMode: WrapMode | undefined; - - /** - * The name of the underlying state. - */ - get name(): string { - return this._state.name; - } - - /** - * The animation clip of the underlying state. - */ - get clip(): AnimationClip { - return this._state.clip; - } - - /** - * The normalized clip start time of the underlying state. - */ - get clipStartTime(): number { - return this._state.clipStartTime; - } - - /** - * The normalized clip end time of the underlying state. - */ - get clipEndTime(): number { - return this._state.clipEndTime; - } - - /** - * Playback speed for this Animator; overrides the underlying state when set. - */ - get speed(): number { - return this._speed ?? this._state.speed; - } - - set speed(value: number) { - this._speed = value; - } - - /** - * Wrap mode for this Animator; overrides the underlying state when set. - */ - get wrapMode(): WrapMode { - return this._wrapMode ?? this._state.wrapMode; - } - - set wrapMode(value: WrapMode) { - this._wrapMode = value; - } - - /** @internal */ - constructor(state: AnimatorState) { - this._state = state; - this._playData = new AnimatorStatePlayData(this); - } -} diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 3200aab5df..7a2914280a 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -17,9 +17,9 @@ export class AnimatorStateMachine { /** * The state will be played automatically. - * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. Cleared to `null` if the state is removed via `removeState`. + * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. */ - defaultState: AnimatorState | null = null; + defaultState: AnimatorState; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -68,11 +68,8 @@ export class AnimatorStateMachine { const index = this.states.indexOf(state); if (index > -1) { this.states.splice(index, 1); - delete this._statesMap[name]; - if (this.defaultState === state) { - this.defaultState = null; - } } + delete this._statesMap[name]; } /** diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index 1bbffce883..bd201a871f 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStateInstance } from "./AnimatorStateInstance"; +export { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimationEventHandler.ts b/packages/core/src/animation/internal/AnimationEventHandler.ts index b3231e24bf..9124846f92 100644 --- a/packages/core/src/animation/internal/AnimationEventHandler.ts +++ b/packages/core/src/animation/internal/AnimationEventHandler.ts @@ -1,9 +1,11 @@ +import { IPoolElement } from "../../utils/ObjectPool"; import { AnimationEvent } from "../AnimationEvent"; - /** * @internal */ -export class AnimationEventHandler { +export class AnimationEventHandler implements IPoolElement { event: AnimationEvent; handlers: Function[] = []; + + dispose() {} } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a3b2677c0b..959aadf208 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,11 +1,10 @@ +import { Component } from "../../Component"; import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; -import { AnimatorState } from "../AnimatorState"; -import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; /** * @internal @@ -13,36 +12,21 @@ import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; - curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: WeakMap = new WeakMap(); - instanceMap: WeakMap = new WeakMap(); - srcPlayData: AnimatorStatePlayData | null = null; - destPlayData: AnimatorStatePlayData | null = null; + curveOwnerPool: WeakMap> = new WeakMap(); + animatorStateDataMap: Record = {}; + srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); + destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { - const map = this.instanceMap; - let instance = map.get(state); - if (!instance) { - instance = new AnimatorStateInstance(state); - map.set(state, instance); - } - return instance; - } - - completeCrossFade(): void { - this.srcPlayData = this.destPlayData; - this.destPlayData = null; - this.crossFadeTransition = null; - } - - clearCrossFadeSlot(): void { - this.destPlayData = null; - this.crossFadeTransition = null; + switchPlayData(): void { + const srcPlayData = this.destPlayData; + const switchTemp = this.srcPlayData; + this.srcPlayData = srcPlayData; + this.destPlayData = switchTemp; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 08d455ac38..42b50edd87 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -8,8 +8,6 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - eventsBuiltVersion = -1; - eventsBuiltScriptsVersion = -1; - - constructor(readonly state: AnimatorState) {} + state: AnimatorState = null; + clipChangedListener: () => void = null; } diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index a4f2c80142..7adc90c2f4 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,36 +1,75 @@ -import { AnimatorStateInstance } from "../AnimatorStateInstance"; +import { AnimationClip } from "../AnimationClip"; +import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; +import { StateMachineScript } from "../StateMachineScript"; import { AnimatorStateData } from "./AnimatorStateData"; /** - * @internal + * Per-instance runtime data for an AnimatorState. + * Proxies read-only properties from the shared AnimatorState asset, + * while providing per-instance mutable properties (e.g. speed, wrapMode). */ export class AnimatorStatePlayData { + /** @internal */ + state: AnimatorState; + /** @internal */ stateData: AnimatorStateData; + /** @internal */ + playedTime: number; + playState: AnimatorStatePlayState; + /** @internal */ + clipTime: number; + /** @internal */ + currentEventIndex: number; + /** @internal */ + isForward = true; + /** @internal */ + offsetFrameTime: number; + /** Per-instance speed. Initialized from AnimatorState.speed, safe to modify without affecting other instances. */ + speed: number = 1.0; + /** Per-instance wrap mode. Initialized from AnimatorState.wrapMode, safe to modify without affecting other instances. */ + wrapMode: WrapMode = WrapMode.Loop; - playedTime: number = 0; - playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; - clipTime: number = 0; - currentEventIndex: number = 0; - isForward: boolean = true; - offsetFrameTime: number = 0; + // ── Proxy properties from AnimatorState (read-only) ── - private _changedOrientation: boolean = false; + /** The name of the state. */ + get name(): string { + return this.state.name; + } - constructor(public readonly instance: AnimatorStateInstance) {} + /** The clip played by this state. */ + get clip(): AnimationClip { + return this.state.clip; + } - reset(stateData: AnimatorStateData, offsetFrameTime: number): void { - const state = this.instance._state; - this.stateData = stateData; - this.offsetFrameTime = offsetFrameTime; + /** The transitions going out of this state. */ + get transitions(): Readonly { + return this.state.transitions; + } + + /** + * Add a state machine script to the underlying AnimatorState. + */ + addStateMachineScript(scriptType: new () => T): T { + return this.state.addStateMachineScript(scriptType); + } + + 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._changedOrientation = false; - state._transitionCollection.needResetCurrentCheckIndex = true; + this.speed = state.speed; + this.wrapMode = state.wrapMode; + this.state._transitionCollection.needResetCurrentCheckIndex = true; } updateOrientation(deltaTime: number): void { @@ -46,12 +85,11 @@ export class AnimatorStatePlayData { update(deltaTime: number): void { this.playedTime += deltaTime; - const instance = this.instance; - const state = instance._state; + const state = this.state; let time = this.playedTime + this.offsetFrameTime; const duration = state._getDuration(); this.playState = AnimatorStatePlayState.Playing; - if (instance.wrapMode === WrapMode.Loop) { + if (this.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { @@ -69,9 +107,8 @@ export class AnimatorStatePlayData { } } - private _correctTime(): void { - const state = this.instance._state; - // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. + private _correctTime() { + const { state } = this; if (this.clipTime === 0) { this.clipTime = state.clipEndTime * state.clip.length; } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 9544a7591e..22fe8b110b 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -20,8 +20,8 @@ import { } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; -import { Quaternion, Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { Quaternion } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; const canvasDOM = document.createElement("canvas"); @@ -70,18 +70,16 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) + // 清理各状态的 transitions 并恢复默认属性 const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const view = animator.findAnimatorState(name); - if (view) { - const def = (view as any)._state; - def.clearTransitions(); - def.speed = 1; - def.clipStartTime = 0; - def.clipEndTime = 1; - def.wrapMode = WrapMode.Loop; - def.clip?.clearEvents(); + const state = animator.findAnimatorState(name); + if (state) { + state.clearTransitions(); + state.speed = 1; + state.clipStartTime = 0; + state.clipEndTime = 1; + state.wrapMode = WrapMode.Loop; } } }); @@ -198,9 +196,9 @@ describe("Animator test", function () { it("find animator state", () => { const stateName = "Survey"; const expectedStateName = "Run"; + const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; animator.play(stateName); - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); expect(animatorState).to.eq(currentAnimatorState); @@ -208,7 +206,7 @@ describe("Animator test", function () { animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); expect(animatorState).not.to.eq(currentAnimatorState); - expect(animatorState?.name).to.eq(expectedStateName); + expect(animatorState.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -240,6 +238,89 @@ describe("Animator test", function () { expect(layerState).to.eq(2); }); + it("crossFade advances with per-instance playData speed instead of shared AnimatorState speed", () => { + const sharedStates = animator.animatorController.layers[0].stateMachine.states; + const sharedWalkState = sharedStates.find((state) => state.name === "Walk"); + const sharedRunState = sharedStates.find((state) => state.name === "Run"); + const oldWalkSpeed = sharedWalkState.speed; + const oldRunSpeed = sharedRunState.speed; + + try { + animator.play("Walk"); + animator.crossFade("Run", 1.0, 0); + + const layerData = animator["_animatorLayersData"][0]; + layerData.srcPlayData.speed = 0.25; + layerData.destPlayData.speed = 0.25; + sharedWalkState.speed = 10; + sharedRunState.speed = 10; + + const srcPlayedTime = layerData.srcPlayData.playedTime; + const destPlayedTime = layerData.destPlayData.playedTime; + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.2); + + expect(layerData.srcPlayData.playedTime - srcPlayedTime).toBeCloseTo(0.05, 5); + expect(layerData.destPlayData.playedTime - destPlayedTime).toBeCloseTo(0.05, 5); + } finally { + sharedWalkState.speed = oldWalkSpeed; + sharedRunState.speed = oldRunSpeed; + } + }); + + it("playData wrapMode overrides shared AnimatorState wrapMode per instance", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + + const layerData = animator["_animatorLayersData"][0]; + const playData = layerData.srcPlayData; + playData.wrapMode = WrapMode.Once; + + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(playData.state.clip.length + 0.1); + + expect(layerData.layerState).to.eq(LayerState.Finished); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + } + }); + + it("playData wrapMode does not leak between animators sharing one controller", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + const otherEntity = new Entity(engine); + const otherAnimator = otherEntity.addComponent(Animator); + otherAnimator.animatorController = animator.animatorController; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + otherAnimator.play("Walk"); + + const playData = animator["_animatorLayersData"][0].srcPlayData; + const otherPlayData = otherAnimator["_animatorLayersData"][0].srcPlayData; + playData.wrapMode = WrapMode.Once; + + expect(otherPlayData.wrapMode).to.eq(WrapMode.Loop); + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + otherEntity.destroy(); + } + }); + it("cross fade in fixed time", () => { const runState = animator.findAnimatorState("Run"); animator.play("Walk"); @@ -252,25 +333,25 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const srcPlayData = layerData.srcPlayData; - expect(srcPlayData.instance.name).to.eq("Run"); + expect(srcPlayData.state.name).to.eq("Run"); expect(srcPlayData.playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); + expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = (runState as any)._state; + transition.destinationState = runState; transition.duration = 1; transition.exitTime = 1; - (walkState as any)._state.addTransition(transition); + walkState.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length - 0.1); + animator.update(walkState.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -313,7 +394,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = (animator.findAnimatorState("Run") as any)._state.clip; + const clip = animator.findAnimatorState("Run").clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -368,64 +449,82 @@ describe("Animator test", function () { expect(testScriptSpy).toHaveBeenCalledTimes(1); }); - it("eventHandlers rebuild when Script is added after play (no clip event mutation)", () => { - const event = new AnimationEvent(); - event.functionName = "event0"; - event.time = 0; - const state = animator.findAnimatorState("Walk")!; - state.clip.addEvent(event); // event exists before play - - animator.play("Walk"); // script not yet attached; first build sees zero scripts + it("fireEvents gates AnimationEvent dispatch without consuming the event", () => { + animator.play("Walk"); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); // does NOT bump clip _version - const spy = vi.spyOn(script, "event0"); - // @ts-ignore - animator.engine.time._frameCount++; + const testScript = animator.entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + + const state = animator.findAnimatorState("Walk"); + state.clip.addEvent(event0); + + animator.fireEvents = false; + animator.update(0); + expect(testScriptSpy).not.toHaveBeenCalled(); + + animator.fireEvents = true; animator.update(0.1); - expect(spy).toHaveBeenCalledTimes(1); + expect(testScriptSpy).toHaveBeenCalledTimes(1); }); - it("eventHandlers rebuild when state.clip is swapped (state version covers clip swap)", () => { - const walkState = animator.animatorController.layers[0].stateMachine.findStateByName("Walk"); - const runState = animator.animatorController.layers[0].stateMachine.findStateByName("Run"); - const originalClip = walkState.clip; + it("does not refire animation events when a once clip reaches the end", () => { + const entity = new Entity(engine); + const onceAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("Base Layer"); + controller.addLayer(layer); - // Old clip: add an event matching event0 - const oldEvent = new AnimationEvent(); - oldEvent.functionName = "event0"; - oldEvent.time = 0; - originalClip.addEvent(oldEvent); + const state = layer.stateMachine.addState("once"); + state.wrapMode = WrapMode.Once; + + const clip = new AnimationClip("once-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); - const spy0 = vi.spyOn(script, "event0"); - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(1); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.5; + clip.addEvent(event0); + state.clip = clip; + onceAnimator.animatorController = controller; - // Swap to a different real clip (Run's) that has no event0; even if the new clip's - // _version happens to match the prior snapshot, the swap itself must invalidate the - // cached eventHandlers via state._updateFlagManager dispatch - walkState.clip = runState.clip; + const testScript = entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); - spy0.mockClear(); - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(0); // swapped clip has no event0 + try { + onceAnimator.play("once"); + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.75); + expect(testScriptSpy).toHaveBeenCalledTimes(1); - // restore for other tests - walkState.clip = originalClip; + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.5); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } }); it("stateMachine", () => { @@ -434,11 +533,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -446,68 +545,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * (idleState as any)._state._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); 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 as any)._state._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -559,11 +658,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -571,68 +670,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * (idleState as any)._state._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); 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 as any)._state._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -676,10 +775,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -689,7 +788,7 @@ describe("Animator test", function () { animator.update(0.01); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - const destState = (destPlayData.instance as any)._state; + const destState = destPlayData.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); @@ -698,21 +797,21 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clipStartTime = 0.8; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0.8; + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - (runState as any)._state.clipStartTime = 0.5; + runState.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - expect(destPlayData.instance?.name).to.eq("Run"); + expect(destPlayData.state?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { @@ -720,8 +819,8 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clearTransitions(); - (walkState as any)._state.addExitTransition(); + walkState.clearTransitions(); + walkState.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -888,15 +987,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - (walkState as any)._state.clipStartTime = 0.5; - (walkState as any)._state.addStateMachineScript( + walkState.clipStartTime = 0.5; + walkState.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._state); + const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run")); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -907,9 +1006,9 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData.clipTime).to.eq(walkState.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -922,13 +1021,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0; + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -936,10 +1035,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.5); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + animator.update(walkState.clip.length * 0.5); + expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -947,13 +1046,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((idleState as any)._state.clip.length * 0.2 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Survey"); - expect(layerData.srcPlayData.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); + animator.update(idleState.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); }); it("setTriggerParameter", () => { @@ -966,16 +1065,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clipStartTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = (runState as any)._state.addTransition((walkState as any)._state); + const runToWalkTransition = runState.addTransition(walkState); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -987,28 +1086,28 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); 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 as any)._state.clip.length * 0.1 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq((runState as any)._state.clip.length * 0.1); + animator.update(runState.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); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._state.clip.length * 0.6); - expect(layerData.destPlayData.instance.name).to.eq("Walk"); + animator.update(runState.clip.length * 0.6); + expect(layerData.destPlayData.state.name).to.eq("Walk"); expect(layerData.destPlayData.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.3); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq((walkState as any)._state.clip.length * 0.3); + animator.update(walkState.clip.length * 0.3); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.srcPlayData.playedTime).to.eq(walkState.clip.length * 0.3); }); it("fixedDuration", () => { @@ -1018,11 +1117,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = (runState as any)._state.clipEndTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clipStartTime = runState.clipEndTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1032,7 +1131,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); expect(layerData.srcPlayData.clipTime).to.eq(0); }); @@ -1095,13 +1194,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state2"); }); it("Clone", () => { @@ -1150,6 +1249,40 @@ describe("Animator test", function () { expect(spine.transform.position.y).to.eq(1); }); + it("sampleAnimation samples clip curves without firing AnimationEvents", () => { + const entity = new Entity(engine, "sample-root"); + const clip = new AnimationClip("sample"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 3; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + + class TestScript extends Script { + event0(): void {} + } + + const script = entity.addComponent(TestScript); + const eventSpy = vi.spyOn(script, "event0"); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + clip.addEvent(event0); + + try { + clip.sampleAnimation(entity, 1); + expect(entity.transform.position.x).to.eq(3); + expect(eventSpy).not.toHaveBeenCalled(); + } finally { + entity.destroy(); + } + }); + it("anyState transition interrupts crossFade", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", false); @@ -1157,7 +1290,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1174,7 +1307,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1183,7 +1316,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1195,12 +1328,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clipEndTime = 1; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0; + walkState.clipEndTime = 1; + walkState.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = (walkState as any)._state.addTransition((idleState as any)._state); + const noExitFailTransition = walkState.addTransition(idleState); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1209,26 +1342,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = (runState as any)._state; + exitTimeTransition.destinationState = runState; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - (walkState as any)._state.addTransition(exitTimeTransition); + 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 as any)._state.clip.length * 0.25; + const preExitDeltaTime = walkState.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.destPlayData).to.be.null; + 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 as any)._state.clip.length * 0.5); + animator.update(walkState.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1240,7 +1373,7 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1250,7 +1383,7 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length + 0.1); + animator.update(walkState.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1264,7 +1397,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1273,7 +1406,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { @@ -1283,7 +1416,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition((runState as any)._state); + const anyToRun = stateMachine.addAnyStateTransition(runState); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1300,7 +1433,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1309,7 +1442,7 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { @@ -1319,7 +1452,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1335,26 +1468,26 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + 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 as any)._state.clearTransitions(); + walkState.clearTransitions(); // Add a noExitTime transition - const t1 = (walkState as any)._state.addTransition((runState as any)._state); + const t1 = walkState.addTransition(runState); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = (walkState as any)._state.addTransition((idleState as any)._state); + const t2 = walkState.addTransition(idleState); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = (walkState as any)._state._transitionCollection; + const collection = walkState._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1373,446 +1506,4 @@ 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.name).to.eq("Survey"); - expect(survey.speed).to.eq((survey as any)._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); - 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.instance.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey - expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(srcPlayData.instance.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 honors per-instance 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 per-instance 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 per-instance speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With shared 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 as any)._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 as any)._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.get(oldState); - 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.get(newState); - // 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("eventHandlers rebuild lazily when state.clip events change", () => { - const survey = animator.findAnimatorState("Survey"); - expect(survey).not.to.eq(null); - const surveyState = (survey as any)._state; - animator.play("Survey"); - - // @ts-ignore — internal layerData / stateData - const layerData = animator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap.get(surveyState); - expect(stateData, "stateData should exist after play").to.not.eq(undefined); - - const versionBefore = stateData.eventsBuiltVersion; - expect(versionBefore).to.be.greaterThanOrEqual(0); - - // Dispatching the clip flag bumps clip's version → next access invalidates eventsBuiltVersion. - surveyState.clip._updateFlagManager.dispatch(); - - // Next play / state-data access triggers _ensureEventHandlersUpToDate and rebuilds. - animator.play("Survey"); - // @ts-ignore - const layerDataAfter = animator._animatorLayersData[0]; - const stateDataAfter = layerDataAfter.animatorStateDataMap.get(surveyState); - expect(stateDataAfter.eventsBuiltVersion).to.be.greaterThan(versionBefore); - }); - - 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 as any)._state.clearTransitions(); - animator.animatorController.addParameter("restart", false); - - const selfTransition = (walk as any)._state.addTransition((walk as any)._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.instance.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?.instance.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 as any)._state.clearTransitions(); - (walk as any)._state.clearTransitions(); - animator.animatorController.addParameter("goWalk", false); - - survey.speed = 0; // pause source per-instance - - const transition = (survey as any)._state.addTransition((walk as any)._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?.instance.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/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts new file mode 100644 index 0000000000..99a8a5ba14 --- /dev/null +++ b/tests/src/core/AnimatorHang.test.ts @@ -0,0 +1,20 @@ +import { Animator, Camera } from "@galacean/engine-core"; +import "@galacean/engine-loader"; +import type { GLTFResource } from "@galacean/engine-loader"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { describe, expect, it } from "vitest"; +import { glbResource } from "./model/fox"; +const canvasDOM = document.createElement("canvas"); +canvasDOM.width = 1024; +canvasDOM.height = 1024; +describe("Canvas 1024 test", async function () { + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + rootEntity.addComponent(Camera); + const resource = await engine.resourceManager.load(glbResource); + const defaultSceneRoot = resource.defaultSceneRoot; + rootEntity.addChild(defaultSceneRoot); + const animator = defaultSceneRoot.getComponent(Animator); + it("loaded", () => { expect(animator).not.eq(null); }); +}); From aa541bdbea7008c6814bb9bc533886f6b4de28ed Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 19:31:03 +0800 Subject: [PATCH 02/28] fix(animation): restore animator state instance boundary --- ...-06-15-animator-state-instance-boundary.md | 20 ++ packages/core/src/animation/Animator.ts | 192 +++++++++--------- .../src/animation/AnimatorStateInstance.ts | 76 +++++++ .../src/animation/AnimatorStateMachine.ts | 9 +- packages/core/src/animation/index.ts | 2 +- .../internal/AnimationEventHandler.ts | 6 +- .../animation/internal/AnimatorLayerData.ts | 36 +++- .../animation/internal/AnimatorStateData.ts | 6 +- .../internal/AnimatorStatePlayData.ts | 83 +++----- tests/src/core/Animator.test.ts | 174 ++++++++++++---- tests/src/core/AnimatorHang.test.ts | 31 ++- 11 files changed, 414 insertions(+), 221 deletions(-) create mode 100644 notes/animation/2026-06-15-animator-state-instance-boundary.md create mode 100644 packages/core/src/animation/AnimatorStateInstance.ts diff --git a/notes/animation/2026-06-15-animator-state-instance-boundary.md b/notes/animation/2026-06-15-animator-state-instance-boundary.md new file mode 100644 index 0000000000..931e5a526f --- /dev/null +++ b/notes/animation/2026-06-15-animator-state-instance-boundary.md @@ -0,0 +1,20 @@ +# Animator State Instance Boundary + +## Context + +PR #3024 tried to split shared `AnimatorState` data from per-Animator runtime playback data, but the public API drifted to returning `AnimatorStatePlayData`. That exposed an internal runtime slot and made `findAnimatorState()` return the wrong object when the requested state existed but was not currently playing. + +## Decision + +Keep `AnimatorStateInstance` as the public per-Animator object. It owns the per-Animator overrides (`speed`, `wrapMode`) and holds the internal `AnimatorStatePlayData` slot. `AnimatorStatePlayData` stays internal and only tracks playback runtime state. + +Use `WeakMap` and `WeakMap` in `AnimatorLayerData` so renamed or removed shared states do not collide by name. Keep a layer-local `stateDataList` for reset-time default-value restoration while the lookup path remains WeakMap-based. + +Animation event handlers are rebuilt lazily from the state update version and entity scripts version. This covers clip event edits and scripts added after play without registering long-lived listeners on shared `AnimatorState` assets. + +## Verification + +- `pnpm -F @galacean/engine-core run b:types` passed. +- `pnpm run b:module` passed. +- `pnpm vitest tests/src/core/Animator.test.ts --run` is currently blocked before any test body runs by `WebGLEngine.create()` failing in the browser runner with `TypeError: Cannot read properties of undefined (reading 'name')`. +- Focused behavioral coverage was added for stable non-playing state instances, invalid layer lookup/play/crossFade, event binding after scripts are added post-play, and removing a default state. diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 4413f393a6..df4bf32298 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -6,11 +6,11 @@ import { Renderer } from "../Renderer"; import { Script } from "../Script"; import { Logger } from "../base/Logger"; import { assignmentClone, ignoreClone } from "../clone/CloneManager"; -import { ClearableObjectPool } from "../utils/ClearableObjectPool"; import { AnimatorController } from "./AnimatorController"; import { AnimatorControllerLayer } from "./AnimatorControllerLayer"; import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; import { AnimatorState } from "./AnimatorState"; +import { AnimatorStateInstance } from "./AnimatorStateInstance"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { KeyframeValueType } from "./Keyframe"; @@ -32,6 +32,7 @@ import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCur */ export class Animator extends Component { private static _passedTriggerParameterNames = new Array(); + private static _tempScripts: Script[] = []; /** Culling mode of this Animator. */ cullingMode: AnimatorCullingMode = AnimatorCullingMode.None; @@ -59,8 +60,6 @@ export class Animator extends Component { @ignoreClone private _curveOwnerPool: WeakMap>> = new WeakMap(); @ignoreClone - private _animationEventHandlerPool = new ClearableObjectPool(AnimationEventHandler); - @ignoreClone private _parametersValueMap = >Object.create(null); @ignoreClone @@ -119,9 +118,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; @@ -196,9 +193,7 @@ export class Animator extends Component { return; } - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); this._updateMark++; @@ -210,43 +205,34 @@ export class Animator extends Component { } /** - * Get the playing state from the target layerIndex. + * Get the state instance currently playing on the target layer. * @param layerIndex - The layer index + * @returns The state instance, or null if nothing is playing + * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ - getCurrentAnimatorState(layerIndex: number): AnimatorState { - return this._animatorLayersData[layerIndex]?.srcPlayData?.state; + getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { + this._resetIfControllerUpdated(); + return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null; } /** - * Get the state by name. - * @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 - */ - /** - * Find the per-instance play data for a state by name. - * The returned object's `speed` and `wrapMode` are per-instance and safe to modify without affecting other Animator instances. + * Get the state instance for a named state on this Animator. + * Overrides on the returned instance only affect this Animator. * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns Per-instance AnimatorStatePlayData, or null if not found + * @returns The state instance, or null if no state matches + * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { + this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state || foundLayer < 0) return null; - const layerData = this._animatorLayersData[foundLayer]; - if (!layerData) return null; - // Check srcPlayData and destPlayData for the matching state - if (layerData.srcPlayData.state === state) return layerData.srcPlayData; - if (layerData.destPlayData.state === state) return layerData.destPlayData; - // State exists in controller but not currently playing — return srcPlayData initialized with the state - return layerData.srcPlayData; + if (!state) return null; + return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state); } /** * Get the layer by name. * @param name - The layer's name. - * @todo Return per-instance layer data (like AnimatorStatePlayData for states) instead of shared asset. - * Currently returns the shared AnimatorControllerLayer — modifying `weight` affects all instances. - * Should follow Unity's pattern: Animator.SetLayerWeight/GetLayerWeight (per-instance). */ findLayerByName(name: string): AnimatorControllerLayer { return this._animatorController?._layersMap[name]; @@ -334,40 +320,36 @@ export class Animator extends Component { * @internal */ _reset(): void { - // revertDefaultValue 可能在死 target 上抛;listener 清理必须始终发生,否则 - // polyfill 的 clipChangedListener 会留在共享的 AnimatorState 上,捕获 this(Animator)→Entity 整树。 - // 用 try/catch 隔离 revert,listener 块紧跟其后无条件执行。 const layersData = this._animatorLayersData; for (let i = 0, n = layersData.length; i < n; i++) { - const stateDataMap = layersData[i].animatorStateDataMap; - for (const stateName in stateDataMap) { - const stateData = stateDataMap[stateName]; - const layerOwners = stateData.curveLayerOwner; - for (let j = 0, m = layerOwners.length; j < m; j++) { + const stateDataList = layersData[i].stateDataList; + for (let j = 0, m = stateDataList.length; j < m; j++) { + const layerOwners = stateDataList[j].curveLayerOwner; + for (let k = 0, l = layerOwners.length; k < l; k++) { try { - layerOwners[j]?.curveOwner?.revertDefaultValue(); + layerOwners[k]?.curveOwner?.revertDefaultValue(); } catch (e) { Logger.warn("Animator._reset: revertDefaultValue threw", e); } } - if (stateData.clipChangedListener && stateData.state) { - stateData.state._updateFlagManager.removeListener(stateData.clipChangedListener); - stateData.clipChangedListener = null; - stateData.state = null; - } } } this._animatorLayersData.length = 0; this._curveOwnerPool = new WeakMap(); this._parametersValueMap = Object.create(null); - this._animationEventHandlerPool.clear(); if (this._controllerUpdateFlag) { this._controllerUpdateFlag.flag = false; } } + private _resetIfControllerUpdated(): void { + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } + } + /** * @internal */ @@ -396,12 +378,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) return; + if (!state) { + return; + } const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); manuallyTransition.duration = duration; @@ -427,8 +409,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; @@ -437,19 +421,19 @@ export class Animator extends Component { } private _getAnimatorStateData( - stateName: string, animatorState: AnimatorState, animatorLayerData: AnimatorLayerData, layerIndex: number ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; - let animatorStateData = animatorStateDataMap[stateName]; + let animatorStateData = animatorStateDataMap.get(animatorState); if (!animatorStateData) { - animatorStateData = new AnimatorStateData(); - animatorStateDataMap[stateName] = animatorStateData; + animatorStateData = new AnimatorStateData(animatorState); + animatorStateDataMap.set(animatorState, animatorStateData); + animatorLayerData.stateDataList.push(animatorStateData); this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); - this._saveAnimatorEventHandlers(animatorState, animatorStateData); } + this._ensureEventHandlers(animatorState, animatorStateData); return animatorStateData; } @@ -487,7 +471,11 @@ export class Animator extends Component { propertyOwners = >>Object.create(null); curveOwnerPool.set(component, propertyOwners); } - const owner = (propertyOwners[property] ||= curve._createCurveOwner(targetEntity, component)); + let owner = propertyOwners[property]; + if (!owner) { + owner = curve._createCurveOwner(targetEntity, component); + propertyOwners[property] = owner; + } // Get layer owner — same WeakMap-by-Component pattern (was Record which kept dead components pinned for the Animator's lifetime). let layerPropertyOwners = layerCurveOwnerPool.get(component); @@ -509,36 +497,39 @@ export class Animator extends Component { } } - private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { - const eventHandlerPool = this._animationEventHandlerPool; - const scripts = []; - const { eventHandlers } = animatorStateData; + private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { + const stateVersion = state._updateFlagManager.version; + const scriptsVersion = this._entity._scriptsVersion; + if ( + animatorStateData.eventsBuiltVersion === stateVersion && + animatorStateData.eventsBuiltScriptsVersion === scriptsVersion + ) { + return; + } - const clipChangedListener = () => { - this._entity.getComponents(Script, scripts); - const scriptCount = scripts.length; - const { events } = state.clip; - eventHandlers.length = 0; - for (let i = 0, n = events.length; i < n; i++) { - const event = events[i]; - const eventHandler = eventHandlerPool.get(); - const funcName = event.functionName; - const { handlers } = eventHandler; + const scripts = Animator._tempScripts; + this._entity.getComponents(Script, scripts); + const scriptCount = scripts.length; + const { events } = state.clip; + const { eventHandlers } = animatorStateData; + eventHandlers.length = 0; + for (let i = 0, n = events.length; i < n; i++) { + const event = events[i]; + const eventHandler = new AnimationEventHandler(); + const funcName = event.functionName; + const { handlers } = eventHandler; - eventHandler.event = event; - handlers.length = 0; - for (let j = scriptCount - 1; j >= 0; j--) { - const script = scripts[j]; - const handler = script[funcName]?.bind(script); - handler && handlers.push(handler); - } - eventHandlers.push(eventHandler); + eventHandler.event = event; + for (let j = scriptCount - 1; j >= 0; j--) { + const script = scripts[j]; + const handler = script[funcName]?.bind(script); + handler && handlers.push(handler); } - }; - clipChangedListener(); - state._updateFlagManager.addListener(clipChangedListener); - animatorStateData.state = state; - animatorStateData.clipChangedListener = clipChangedListener; + eventHandlers.push(eventHandler); + } + scripts.length = 0; + animatorStateData.eventsBuiltVersion = stateVersion; + animatorStateData.eventsBuiltScriptsVersion = scriptsVersion; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { @@ -565,8 +556,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); } @@ -1095,8 +1086,7 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.switchPlayData(); - layerData.crossFadeTransition = null; + layerData.completeCrossFade(); } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { @@ -1343,19 +1333,21 @@ export class Animator extends Component { } private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean { - const name = state.name; if (!state.clip) { - Logger.warn(`The state named ${name} has no AnimationClip data.`); + Logger.warn(`The state named ${state.name} has no AnimationClip data.`); return false; } const animatorLayerData = this._getAnimatorLayerData(layerIndex); - const animatorStateData = this._getAnimatorStateData(name, state, animatorLayerData, layerIndex); + const animatorStateData = this._getAnimatorStateData(state, animatorLayerData, layerIndex); this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + const playData = animatorLayerData.getOrCreateInstance(state)._playData; + playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + animatorLayerData.srcPlayData = playData; + animatorLayerData.clearCrossFadeSlot(); animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1455,13 +1447,14 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); - const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); + if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { + return false; + } - animatorLayerData.destPlayData.reset( - crossState, - animatorStateData, - transition.offset * crossState._getClipActualEndTime() - ); + const animatorStateData = this._getAnimatorStateData(crossState, animatorLayerData, layerIndex); + const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; + destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1613,6 +1606,7 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { + this._ensureEventHandlers(state, playData.stateData); const { eventHandlers } = playData.stateData; this.fireEvents && eventHandlers.length && diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts new file mode 100644 index 0000000000..9bcdfa0fb5 --- /dev/null +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -0,0 +1,76 @@ +import { AnimationClip } from "./AnimationClip"; +import { AnimatorState } from "./AnimatorState"; +import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; +import { WrapMode } from "./enums/WrapMode"; + +/** + * Per-Animator view of an `AnimatorState`. + * + * Override fields (speed, wrapMode) are scoped to this Animator; unset fields + * fall through to the underlying state asset. + */ +export class AnimatorStateInstance { + /** @internal */ + readonly _state: AnimatorState; + /** @internal */ + readonly _playData: AnimatorStatePlayData; + + private _speed: number | undefined; + private _wrapMode: WrapMode | undefined; + + /** + * The name of the underlying state. + */ + get name(): string { + return this._state.name; + } + + /** + * The animation clip of the underlying state. + */ + get clip(): AnimationClip { + return this._state.clip; + } + + /** + * The normalized clip start time of the underlying state. + */ + get clipStartTime(): number { + return this._state.clipStartTime; + } + + /** + * The normalized clip end time of the underlying state. + */ + get clipEndTime(): number { + return this._state.clipEndTime; + } + + /** + * Playback speed for this Animator; overrides the underlying state when set. + */ + get speed(): number { + return this._speed ?? this._state.speed; + } + + set speed(value: number) { + this._speed = value; + } + + /** + * Wrap mode for this Animator; overrides the underlying state when set. + */ + get wrapMode(): WrapMode { + return this._wrapMode ?? this._state.wrapMode; + } + + set wrapMode(value: WrapMode) { + this._wrapMode = value; + } + + /** @internal */ + constructor(state: AnimatorState) { + this._state = state; + this._playData = new AnimatorStatePlayData(this); + } +} diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 7a2914280a..3200aab5df 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -17,9 +17,9 @@ export class AnimatorStateMachine { /** * The state will be played automatically. - * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. + * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. Cleared to `null` if the state is removed via `removeState`. */ - defaultState: AnimatorState; + defaultState: AnimatorState | null = null; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -68,8 +68,11 @@ export class AnimatorStateMachine { const index = this.states.indexOf(state); if (index > -1) { this.states.splice(index, 1); + delete this._statesMap[name]; + if (this.defaultState === state) { + this.defaultState = null; + } } - delete this._statesMap[name]; } /** diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index bd201a871f..1bbffce883 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; +export { AnimatorStateInstance } from "./AnimatorStateInstance"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimationEventHandler.ts b/packages/core/src/animation/internal/AnimationEventHandler.ts index 9124846f92..b3231e24bf 100644 --- a/packages/core/src/animation/internal/AnimationEventHandler.ts +++ b/packages/core/src/animation/internal/AnimationEventHandler.ts @@ -1,11 +1,9 @@ -import { IPoolElement } from "../../utils/ObjectPool"; import { AnimationEvent } from "../AnimationEvent"; + /** * @internal */ -export class AnimationEventHandler implements IPoolElement { +export class AnimationEventHandler { event: AnimationEvent; handlers: Function[] = []; - - dispose() {} } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 959aadf208..b0402e4069 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,10 +1,12 @@ import { Component } from "../../Component"; import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; +import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; /** * @internal @@ -13,20 +15,36 @@ export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; curveOwnerPool: WeakMap> = new WeakMap(); - animatorStateDataMap: Record = {}; - srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); - destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); + animatorStateDataMap: WeakMap = new WeakMap(); + stateDataList: AnimatorStateData[] = []; + instanceMap: WeakMap = new WeakMap(); + srcPlayData: AnimatorStatePlayData | null = null; + 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; + getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { + const map = this.instanceMap; + let instance = map.get(state); + if (!instance) { + instance = new AnimatorStateInstance(state); + map.set(state, instance); + } + return instance; + } + + completeCrossFade(): void { + this.srcPlayData = this.destPlayData; + this.destPlayData = null; + this.crossFadeTransition = null; + } + + clearCrossFadeSlot(): void { + this.destPlayData = null; + this.crossFadeTransition = null; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 42b50edd87..08d455ac38 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -8,6 +8,8 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - state: AnimatorState = null; - clipChangedListener: () => void = null; + eventsBuiltVersion = -1; + eventsBuiltScriptsVersion = -1; + + constructor(readonly state: AnimatorState) {} } diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index 7adc90c2f4..3ba4fd37b2 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,75 +1,57 @@ -import { AnimationClip } from "../AnimationClip"; +import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorState } from "../AnimatorState"; -import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; -import { StateMachineScript } from "../StateMachineScript"; import { AnimatorStateData } from "./AnimatorStateData"; /** - * Per-instance runtime data for an AnimatorState. - * Proxies read-only properties from the shared AnimatorState asset, - * while providing per-instance mutable properties (e.g. speed, wrapMode). + * @internal */ export class AnimatorStatePlayData { - /** @internal */ - state: AnimatorState; - /** @internal */ stateData: AnimatorStateData; - /** @internal */ - playedTime: number; - playState: AnimatorStatePlayState; - /** @internal */ - clipTime: number; - /** @internal */ - currentEventIndex: number; - /** @internal */ - isForward = true; - /** @internal */ - offsetFrameTime: number; - /** Per-instance speed. Initialized from AnimatorState.speed, safe to modify without affecting other instances. */ - speed: number = 1.0; - /** Per-instance wrap mode. Initialized from AnimatorState.wrapMode, safe to modify without affecting other instances. */ - wrapMode: WrapMode = WrapMode.Loop; - // ── Proxy properties from AnimatorState (read-only) ── + playedTime: number = 0; + playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; + clipTime: number = 0; + currentEventIndex: number = 0; + isForward: boolean = true; + offsetFrameTime: number = 0; - /** The name of the state. */ - get name(): string { - return this.state.name; + private _changedOrientation: boolean = false; + + constructor(public readonly instance: AnimatorStateInstance) {} + + get state(): AnimatorState { + return this.instance._state; } - /** The clip played by this state. */ - get clip(): AnimationClip { - return this.state.clip; + get speed(): number { + return this.instance.speed; } - /** The transitions going out of this state. */ - get transitions(): Readonly { - return this.state.transitions; + set speed(value: number) { + this.instance.speed = value; } - /** - * Add a state machine script to the underlying AnimatorState. - */ - addStateMachineScript(scriptType: new () => T): T { - return this.state.addStateMachineScript(scriptType); + get wrapMode(): WrapMode { + return this.instance.wrapMode; } - private _changedOrientation = false; + set wrapMode(value: WrapMode) { + this.instance.wrapMode = value; + } - reset(state: AnimatorState, stateData: AnimatorStateData, offsetFrameTime: number): void { - this.state = state; - this.playedTime = 0; - this.offsetFrameTime = offsetFrameTime; + reset(stateData: AnimatorStateData, offsetFrameTime: number): void { + const state = this.instance._state; this.stateData = stateData; + this.offsetFrameTime = offsetFrameTime; + this.playedTime = 0; this.playState = AnimatorStatePlayState.UnStarted; this.clipTime = state.clipStartTime * state.clip.length; this.currentEventIndex = 0; this.isForward = true; - this.speed = state.speed; - this.wrapMode = state.wrapMode; - this.state._transitionCollection.needResetCurrentCheckIndex = true; + this._changedOrientation = false; + state._transitionCollection.needResetCurrentCheckIndex = true; } updateOrientation(deltaTime: number): void { @@ -85,7 +67,7 @@ export class AnimatorStatePlayData { update(deltaTime: number): void { this.playedTime += deltaTime; - const state = this.state; + const state = this.instance._state; let time = this.playedTime + this.offsetFrameTime; const duration = state._getDuration(); this.playState = AnimatorStatePlayState.Playing; @@ -107,8 +89,9 @@ export class AnimatorStatePlayData { } } - private _correctTime() { - const { state } = this; + private _correctTime(): void { + const state = this.instance._state; + // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. if (this.clipTime === 0) { this.clipTime = state.clipEndTime * state.clip.length; } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 22fe8b110b..1f15c283f4 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -42,6 +42,9 @@ describe("Animator test", function () { let resource: GLTFResource; let engine: WebGLEngine; + const findSharedState = (stateName: string) => + animator.animatorController.layers[0].stateMachine.findStateByName(stateName); + beforeAll(async function () { engine = await WebGLEngine.create({ canvas: canvasDOM }); const scene = engine.sceneManager.activeScene; @@ -73,13 +76,14 @@ describe("Animator test", function () { // 清理各状态的 transitions 并恢复默认属性 const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const state = animator.findAnimatorState(name); + const state = findSharedState(name); if (state) { state.clearTransitions(); state.speed = 1; state.clipStartTime = 0; state.clipEndTime = 1; state.wrapMode = WrapMode.Loop; + state.clip?.clearEvents(); } } }); @@ -209,6 +213,37 @@ describe("Animator test", function () { expect(animatorState.name).to.eq(expectedStateName); }); + it("findAnimatorState returns a stable per-state instance for states that are not currently playing", () => { + animator.play("Walk"); + const walkInstance = animator.getCurrentAnimatorState(0); + const runInstance = animator.findAnimatorState("Run", 0); + const sharedRunState = findSharedState("Run"); + + expect(runInstance).not.to.eq(null); + expect(runInstance).not.to.eq(walkInstance); + expect(runInstance.name).to.eq("Run"); + + runInstance.speed = 0.5; + + expect(sharedRunState.speed).to.eq(1); + + animator.play("Run"); + expect(animator.getCurrentAnimatorState(0)).to.eq(runInstance); + expect(animator.getCurrentAnimatorState(0).speed).to.eq(0.5); + }); + + it("play, crossFade, and state lookup ignore out-of-range layers without throwing", () => { + animator.play("Walk"); + const before = animator.getCurrentAnimatorState(0); + + expect(() => animator.play("Run", 99)).not.to.throw(); + expect(() => animator.crossFade("Run", 0.1, 99)).not.to.throw(); + expect(animator.findAnimatorState("Run", 99)).to.eq(null); + expect(animator.findAnimatorState("Run", -2)).to.eq(null); + expect(animator.getCurrentAnimatorState(99)).to.eq(null); + expect(animator.getCurrentAnimatorState(0)).to.eq(before); + }); + it("animation getCurrentAnimatorState", () => { //get random animation element from gltf resource const min = 0; @@ -322,7 +357,7 @@ describe("Animator test", function () { }); it("cross fade in fixed time", () => { - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); animator.play("Walk"); animator.crossFadeInFixedDuration("Run", 0.3, 0, 0.1); // @ts-ignore @@ -340,8 +375,8 @@ describe("Animator test", function () { }); it("animation cross fade by transition", () => { - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); const transition = new AnimatorStateTransition(); transition.destinationState = runState; transition.duration = 1; @@ -394,7 +429,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = animator.findAnimatorState("Run").clip; + const clip = findSharedState("Run").clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -411,10 +446,10 @@ describe("Animator test", function () { ); let layerData = animator["_animatorLayersData"][1]; - const layerCurveOwner = layerData.curveOwnerPool[targetEntity.transform.instanceId]["rotationQuaternion"]; - const parentLayerCurveOwner = layerData.curveOwnerPool[parentEntity.transform.instanceId]["rotationQuaternion"]; + const layerCurveOwner = layerData.curveOwnerPool.get(targetEntity.transform)["rotationQuaternion"]; + const parentLayerCurveOwner = layerData.curveOwnerPool.get(parentEntity.transform)["rotationQuaternion"]; - let childLayerCurveOwner = layerData.curveOwnerPool[childEntity.transform.instanceId]["rotationQuaternion"]; + let childLayerCurveOwner = layerData.curveOwnerPool.get(childEntity.transform)["rotationQuaternion"]; expect(layerCurveOwner.isActive).to.eq(false); expect(parentLayerCurveOwner.isActive).to.eq(true); @@ -425,7 +460,7 @@ describe("Animator test", function () { animator.animatorController.addLayer(additiveLayer); animator.play("Run", 1); layerData = animator["_animatorLayersData"][1]; - childLayerCurveOwner = layerData.curveOwnerPool[childEntity.transform.instanceId]["rotationQuaternion"]; + childLayerCurveOwner = layerData.curveOwnerPool.get(childEntity.transform)["rotationQuaternion"]; expect(childLayerCurveOwner.isActive).to.eq(true); }); @@ -443,12 +478,35 @@ describe("Animator test", function () { event0.functionName = "event0"; event0.time = 0; - const state = animator.findAnimatorState("Walk"); + const state = findSharedState("Walk"); state.clip.addEvent(event0); animator.update(10); expect(testScriptSpy).toHaveBeenCalledTimes(1); }); + it("animation events bind scripts added after play", () => { + const state = findSharedState("Walk"); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + state.clip.addEvent(event0); + + animator.play("Walk"); + + class TestScript extends Script { + event0(): void {} + } + + const testScript = animator.entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.1); + + expect(testScriptSpy).toHaveBeenCalledTimes(1); + }); + it("fireEvents gates AnimationEvent dispatch without consuming the event", () => { animator.play("Walk"); @@ -463,7 +521,7 @@ describe("Animator test", function () { event0.functionName = "event0"; event0.time = 0; - const state = animator.findAnimatorState("Walk"); + const state = findSharedState("Walk"); state.clip.addEvent(event0); animator.fireEvents = false; @@ -530,13 +588,13 @@ describe("Animator test", function () { it("stateMachine", () => { animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; idleState.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; @@ -655,13 +713,13 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; idleState.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; @@ -774,9 +832,9 @@ describe("Animator test", function () { }); it("transitionOffset", () => { - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clearTransitions(); const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0; @@ -795,11 +853,11 @@ describe("Animator test", function () { }); it("clipStartTime crossFade", () => { - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.wrapMode = WrapMode.Once; walkState.clipStartTime = 0.8; walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clearTransitions(); const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0.5; @@ -817,7 +875,7 @@ describe("Animator test", function () { it("transition to exit but no entry", () => { const animatorLayerData = animator["_animatorLayersData"]; - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.wrapMode = WrapMode.Once; walkState.clearTransitions(); walkState.addExitTransition(); @@ -985,7 +1043,7 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const walkState = animator.findAnimatorState("Run"); + const walkState = findSharedState("Run"); // For test clipStartTime is not 0 and transition duration is 0 walkState.clipStartTime = 0.5; walkState.addStateMachineScript( @@ -995,7 +1053,7 @@ describe("Animator test", function () { } } ); - const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run")); + const transition = stateMachine.addAnyStateTransition(findSharedState("Run")); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -1019,13 +1077,13 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); idleState.speed = 1; idleState.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clipStartTime = 0; walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clearTransitions(); const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = true; @@ -1064,9 +1122,9 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clipStartTime = 0; runState.clearTransitions(); const walkToRunTransition = walkState.addTransition(runState); @@ -1116,9 +1174,9 @@ describe("Animator test", function () { animatorController.addTriggerParameter("triggerWalk"); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.clearTransitions(); - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); runState.clipStartTime = runState.clipEndTime = 0; runState.clearTransitions(); const walkToRunTransition = walkState.addTransition(runState); @@ -1203,6 +1261,38 @@ describe("Animator test", function () { expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state2"); }); + it("removing a default state prevents it from being auto-played", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const removedState = layer.stateMachine.addState("removed"); + const clip = new AnimationClip("removed-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + removedState.clip = clip; + layer.stateMachine.defaultState = removedState; + layer.stateMachine.removeState(removedState); + localAnimator.animatorController = controller; + + try { + localAnimator.update(0.1); + expect(localAnimator.getCurrentAnimatorState(0)).to.eq(null); + } finally { + entity.destroy(); + } + }); + it("Clone", () => { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); }); @@ -1287,7 +1377,7 @@ describe("Animator test", function () { const { animatorController } = animator; animatorController.addParameter("interrupt", false); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); // AnyState -> Idle (can interrupt) const anyToIdle = stateMachine.addAnyStateTransition(idleState); @@ -1324,9 +1414,9 @@ describe("Animator test", function () { animatorController.addParameter("goRun", true); animatorController.addParameter("never", false); - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); - const idleState = animator.findAnimatorState("Survey"); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + const idleState = findSharedState("Survey"); walkState.clipStartTime = 0; walkState.clipEndTime = 1; @@ -1369,8 +1459,8 @@ describe("Animator test", function () { const { animatorController } = animator; animatorController.addParameter("interrupt", false); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); - const walkState = animator.findAnimatorState("Walk"); + const idleState = findSharedState("Survey"); + const walkState = findSharedState("Walk"); // AnyState -> Idle (can interrupt) const anyToIdle = stateMachine.addAnyStateTransition(idleState); @@ -1413,7 +1503,7 @@ describe("Animator test", function () { const { animatorController } = animator; animatorController.addParameter("alwaysTrue", true); const stateMachine = animatorController.layers[0].stateMachine; - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); // AnyState -> Run (always true, noExitTime) const anyToRun = stateMachine.addAnyStateTransition(runState); @@ -1449,7 +1539,7 @@ describe("Animator test", function () { const { animatorController } = animator; animatorController.addParameter("interrupt", true); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); // AnyState -> Idle (always true, noExitTime) const anyToIdle = stateMachine.addAnyStateTransition(idleState); @@ -1472,9 +1562,9 @@ describe("Animator test", function () { }); it("toggle hasExitTime should maintain correct noExitTimeCount", () => { - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); - const idleState = animator.findAnimatorState("Survey"); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + const idleState = findSharedState("Survey"); walkState.clearTransitions(); // Add a noExitTime transition diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts index 99a8a5ba14..78609870dd 100644 --- a/tests/src/core/AnimatorHang.test.ts +++ b/tests/src/core/AnimatorHang.test.ts @@ -2,19 +2,28 @@ import { Animator, Camera } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { glbResource } from "./model/fox"; + const canvasDOM = document.createElement("canvas"); canvasDOM.width = 1024; canvasDOM.height = 1024; -describe("Canvas 1024 test", async function () { - const engine = await WebGLEngine.create({ canvas: canvasDOM }); - const scene = engine.sceneManager.activeScene; - const rootEntity = scene.createRootEntity(); - rootEntity.addComponent(Camera); - const resource = await engine.resourceManager.load(glbResource); - const defaultSceneRoot = resource.defaultSceneRoot; - rootEntity.addChild(defaultSceneRoot); - const animator = defaultSceneRoot.getComponent(Animator); - it("loaded", () => { expect(animator).not.eq(null); }); + +describe("Canvas 1024 test", function () { + let animator: Animator; + + beforeAll(async function () { + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + rootEntity.addComponent(Camera); + const resource = await engine.resourceManager.load(glbResource); + const defaultSceneRoot = resource.defaultSceneRoot; + rootEntity.addChild(defaultSceneRoot); + animator = defaultSceneRoot.getComponent(Animator); + }); + + it("loaded", () => { + expect(animator).not.eq(null); + }); }); From c975875dee5bb3f8340d44b99ba736b04ca36308 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 19:42:53 +0800 Subject: [PATCH 03/28] fix(animation): stabilize animator coverage path --- packages/core/src/animation/Animator.ts | 4 +++- tests/src/core/Animator.test.ts | 8 ++++---- tests/src/core/AnimatorHang.test.ts | 11 ++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index df4bf32298..44db7e320c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -322,7 +322,9 @@ export class Animator extends Component { _reset(): void { const layersData = this._animatorLayersData; for (let i = 0, n = layersData.length; i < n; i++) { - const stateDataList = layersData[i].stateDataList; + const layerData = layersData[i]; + if (!layerData) continue; + const stateDataList = layerData.stateDataList; for (let j = 0, m = stateDataList.length; j < m; j++) { const layerOwners = stateDataList[j].curveLayerOwner; for (let k = 0, l = layerOwners.length; k < l; k++) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 1f15c283f4..bbca69c29e 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -21,7 +21,7 @@ import { import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; import { Quaternion } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { WebGLEngine } from "@galacean/engine"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; const canvasDOM = document.createElement("canvas"); @@ -58,8 +58,8 @@ describe("Animator test", function () { }); afterAll(function () { - animator.destroy(); - engine.destroy(); + animator?.destroy(); + engine?.destroy(); }); afterEach(function () { @@ -1446,7 +1446,7 @@ describe("Animator test", function () { 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 diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts index 78609870dd..8aa299e910 100644 --- a/tests/src/core/AnimatorHang.test.ts +++ b/tests/src/core/AnimatorHang.test.ts @@ -1,8 +1,8 @@ import { Animator, Camera } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { beforeAll, describe, expect, it } from "vitest"; +import { WebGLEngine } from "@galacean/engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { glbResource } from "./model/fox"; const canvasDOM = document.createElement("canvas"); @@ -11,9 +11,10 @@ canvasDOM.height = 1024; describe("Canvas 1024 test", function () { let animator: Animator; + let engine: WebGLEngine; beforeAll(async function () { - const engine = await WebGLEngine.create({ canvas: canvasDOM }); + engine = await WebGLEngine.create({ canvas: canvasDOM }); const scene = engine.sceneManager.activeScene; const rootEntity = scene.createRootEntity(); rootEntity.addComponent(Camera); @@ -23,6 +24,10 @@ describe("Canvas 1024 test", function () { animator = defaultSceneRoot.getComponent(Animator); }); + afterAll(function () { + engine?.destroy(); + }); + it("loaded", () => { expect(animator).not.eq(null); }); From b5fdd4502e30a8190c96ead873aa33b337cf2917 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 19:47:09 +0800 Subject: [PATCH 04/28] docs(animation): update animator verification note --- .../animation/2026-06-15-animator-state-instance-boundary.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/notes/animation/2026-06-15-animator-state-instance-boundary.md b/notes/animation/2026-06-15-animator-state-instance-boundary.md index 931e5a526f..cc490f1dfd 100644 --- a/notes/animation/2026-06-15-animator-state-instance-boundary.md +++ b/notes/animation/2026-06-15-animator-state-instance-boundary.md @@ -16,5 +16,7 @@ Animation event handlers are rebuilt lazily from the state update version and en - `pnpm -F @galacean/engine-core run b:types` passed. - `pnpm run b:module` passed. -- `pnpm vitest tests/src/core/Animator.test.ts --run` is currently blocked before any test body runs by `WebGLEngine.create()` failing in the browser runner with `TypeError: Cannot read properties of undefined (reading 'name')`. +- `pnpm vitest tests/src/core/Animator.test.ts tests/src/core/AnimatorHang.test.ts --run` passed after using the same `@galacean/engine` WebGLEngine entrypoint as the rest of the test suite. +- `pnpm run coverage` passed locally with `111` files and `1423` tests. +- GitHub CI for PR #3024 passed build, lint, e2e, Codecov project, and Codecov patch on commit `c975875de`. - Focused behavioral coverage was added for stable non-playing state instances, invalid layer lookup/play/crossFade, event binding after scripts are added post-play, and removing a default state. From 1c25d4c0521cef7f498c136e35b15c5006c4834f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 19:59:18 +0800 Subject: [PATCH 05/28] fix(animation): simplify reset and cover loop events --- ...-06-15-animator-state-instance-boundary.md | 4 +- packages/core/src/animation/Animator.ts | 6 +- tests/src/core/Animator.test.ts | 120 ++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) diff --git a/notes/animation/2026-06-15-animator-state-instance-boundary.md b/notes/animation/2026-06-15-animator-state-instance-boundary.md index cc490f1dfd..eb7cf12a9b 100644 --- a/notes/animation/2026-06-15-animator-state-instance-boundary.md +++ b/notes/animation/2026-06-15-animator-state-instance-boundary.md @@ -17,6 +17,6 @@ Animation event handlers are rebuilt lazily from the state update version and en - `pnpm -F @galacean/engine-core run b:types` passed. - `pnpm run b:module` passed. - `pnpm vitest tests/src/core/Animator.test.ts tests/src/core/AnimatorHang.test.ts --run` passed after using the same `@galacean/engine` WebGLEngine entrypoint as the rest of the test suite. -- `pnpm run coverage` passed locally with `111` files and `1423` tests. -- GitHub CI for PR #3024 passed build, lint, e2e, Codecov project, and Codecov patch on commit `c975875de`. +- `CI=true pnpm run coverage` passed locally with `111` files and `1425` tests. - Focused behavioral coverage was added for stable non-playing state instances, invalid layer lookup/play/crossFade, event binding after scripts are added post-play, and removing a default state. +- Looping AnimationEvent coverage was added for both forward and backward wrap scans. diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 44db7e320c..432bffd9b3 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -328,11 +328,7 @@ export class Animator extends Component { for (let j = 0, m = stateDataList.length; j < m; j++) { const layerOwners = stateDataList[j].curveLayerOwner; for (let k = 0, l = layerOwners.length; k < l; k++) { - try { - layerOwners[k]?.curveOwner?.revertDefaultValue(); - } catch (e) { - Logger.warn("Animator._reset: revertDefaultValue threw", e); - } + layerOwners[k]?.curveOwner?.revertDefaultValue(); } } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index bbca69c29e..77eef67aa7 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -44,6 +44,37 @@ describe("Animator test", function () { const findSharedState = (stateName: string) => animator.animatorController.layers[0].stateMachine.findStateByName(stateName); + const createLoopAnimator = () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("Base Layer"); + controller.addLayer(layer); + + const state = layer.stateMachine.addState("loop"); + state.wrapMode = WrapMode.Loop; + + const clip = new AnimationClip("loop-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + state.clip = clip; + + localAnimator.animatorController = controller; + return { entity, animator: localAnimator, clip }; + }; + const updateAnimator = (target: Animator, deltaTime: number) => { + // @ts-ignore + target.engine.time._frameCount++; + target.update(deltaTime); + }; beforeAll(async function () { engine = await WebGLEngine.create({ canvas: canvasDOM }); @@ -533,6 +564,95 @@ describe("Animator test", function () { expect(testScriptSpy).toHaveBeenCalledTimes(1); }); + it("fires animation events across forward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 1.25); + + expect(event0Spy).toHaveBeenCalledTimes(2); + expect(event1Spy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + + it("fires animation events across backward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + event2(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + const event2Spy = vi.spyOn(testScript, "event2"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event2 = new AnimationEvent(); + event2.functionName = "event2"; + event2.time = 0.6; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event2); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.25); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).not.toHaveBeenCalled(); + expect(event2Spy).not.toHaveBeenCalled(); + + event0Spy.mockClear(); + event1Spy.mockClear(); + event2Spy.mockClear(); + loopAnimator.speed = -1; + updateAnimator(loopAnimator, 0.5); + + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).toHaveBeenCalledTimes(1); + expect(event2Spy).not.toHaveBeenCalled(); + + event0Spy.mockClear(); + event1Spy.mockClear(); + event2Spy.mockClear(); + updateAnimator(loopAnimator, 0.25); + + expect(event0Spy).not.toHaveBeenCalled(); + expect(event1Spy).not.toHaveBeenCalled(); + expect(event2Spy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + it("does not refire animation events when a once clip reaches the end", () => { const entity = new Entity(engine); const onceAnimator = entity.addComponent(Animator); From bb323cac20a1d22a7e56f6a12fd45ebcbdfec92d Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 14:54:49 +0800 Subject: [PATCH 06/28] test: cover animator state lookup rebuild edges --- tests/src/core/Animator.test.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 77eef67aa7..d180e652ad 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -263,6 +263,26 @@ describe("Animator test", function () { expect(animator.getCurrentAnimatorState(0).speed).to.eq(0.5); }); + it("findAnimatorState remains stable after crossFade play slots are reused", () => { + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + + animator.play("Walk"); + const walkInstance = animator.getCurrentAnimatorState(0); + const runInstance = animator.findAnimatorState("Run", 0); + walkInstance.speed = 2; + runInstance.speed = 0.5; + + animator.crossFade("Run", 0.1, 0); + updateAnimator(animator, runState._getDuration()); + + expect(animator.getCurrentAnimatorState(0)).to.eq(runInstance); + expect(animator.getCurrentAnimatorState(0).speed).to.eq(0.5); + expect(animator.findAnimatorState("Walk", 0)).to.eq(walkInstance); + expect(animator.findAnimatorState("Walk", 0).speed).to.eq(2); + expect(animator.findAnimatorState("Walk", 0)._state).to.eq(walkState); + }); + it("play, crossFade, and state lookup ignore out-of-range layers without throwing", () => { animator.play("Walk"); const before = animator.getCurrentAnimatorState(0); @@ -538,6 +558,33 @@ describe("Animator test", function () { expect(testScriptSpy).toHaveBeenCalledTimes(1); }); + it("animation events added after play rebuild handlers lazily", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + } + + const testScript = entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.05); + expect(testScriptSpy).not.toHaveBeenCalled(); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + clip.addEvent(event0); + + updateAnimator(loopAnimator, 0.1); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + it("fireEvents gates AnimationEvent dispatch without consuming the event", () => { animator.play("Walk"); From c5cd8cc30893e14e5b424d9c56f5f60a0ee61f2e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 15:40:12 +0800 Subject: [PATCH 07/28] chore(animation): keep notes out of pr --- ...-06-15-animator-state-instance-boundary.md | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 notes/animation/2026-06-15-animator-state-instance-boundary.md diff --git a/notes/animation/2026-06-15-animator-state-instance-boundary.md b/notes/animation/2026-06-15-animator-state-instance-boundary.md deleted file mode 100644 index eb7cf12a9b..0000000000 --- a/notes/animation/2026-06-15-animator-state-instance-boundary.md +++ /dev/null @@ -1,22 +0,0 @@ -# Animator State Instance Boundary - -## Context - -PR #3024 tried to split shared `AnimatorState` data from per-Animator runtime playback data, but the public API drifted to returning `AnimatorStatePlayData`. That exposed an internal runtime slot and made `findAnimatorState()` return the wrong object when the requested state existed but was not currently playing. - -## Decision - -Keep `AnimatorStateInstance` as the public per-Animator object. It owns the per-Animator overrides (`speed`, `wrapMode`) and holds the internal `AnimatorStatePlayData` slot. `AnimatorStatePlayData` stays internal and only tracks playback runtime state. - -Use `WeakMap` and `WeakMap` in `AnimatorLayerData` so renamed or removed shared states do not collide by name. Keep a layer-local `stateDataList` for reset-time default-value restoration while the lookup path remains WeakMap-based. - -Animation event handlers are rebuilt lazily from the state update version and entity scripts version. This covers clip event edits and scripts added after play without registering long-lived listeners on shared `AnimatorState` assets. - -## Verification - -- `pnpm -F @galacean/engine-core run b:types` passed. -- `pnpm run b:module` passed. -- `pnpm vitest tests/src/core/Animator.test.ts tests/src/core/AnimatorHang.test.ts --run` passed after using the same `@galacean/engine` WebGLEngine entrypoint as the rest of the test suite. -- `CI=true pnpm run coverage` passed locally with `111` files and `1425` tests. -- Focused behavioral coverage was added for stable non-playing state instances, invalid layer lookup/play/crossFade, event binding after scripts are added post-play, and removing a default state. -- Looping AnimationEvent coverage was added for both forward and backward wrap scans. From 3c1d0bfea949c61415d71228a9d7aa72c47c5173 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 18:27:54 +0800 Subject: [PATCH 08/28] test(animation): cover active-state crossFade no-op --- packages/core/src/animation/Animator.ts | 4 ++-- tests/src/core/Animator.test.ts | 16 ++++++++++++++++ tests/src/core/AnimatorHang.test.ts | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 432bffd9b3..c72c6246b4 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -463,7 +463,7 @@ export class Animator extends Component { } const { property } = curve; - // Get owner — WeakMap keyed by Component so dead components let entries GC. + // Key owner lookup by Component identity instead of instanceId. let propertyOwners = curveOwnerPool.get(component); if (!propertyOwners) { propertyOwners = >>Object.create(null); @@ -475,7 +475,7 @@ export class Animator extends Component { propertyOwners[property] = owner; } - // Get layer owner — same WeakMap-by-Component pattern (was Record which kept dead components pinned for the Animator's lifetime). + // Keep layer owner lookup on the same Component identity path. let layerPropertyOwners = layerCurveOwnerPool.get(component); if (!layerPropertyOwners) { layerPropertyOwners = >Object.create(null); diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index d180e652ad..59854ce08d 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -283,6 +283,22 @@ describe("Animator test", function () { expect(animator.findAnimatorState("Walk", 0)._state).to.eq(walkState); }); + it("crossFade to the active state is a no-op", () => { + animator.play("Walk"); + const currentInstance = animator.getCurrentAnimatorState(0); + updateAnimator(animator, 0.1); + + const layerData = animator["_animatorLayersData"][0]; + const playedBefore = layerData.srcPlayData.playedTime; + + animator.crossFade("Walk", 0.1, 0); + + expect(layerData.destPlayData).to.eq(null); + expect(layerData.crossFadeTransition).to.eq(null); + expect(animator.getCurrentAnimatorState(0)).to.eq(currentInstance); + expect(layerData.srcPlayData.playedTime).to.eq(playedBefore); + }); + it("play, crossFade, and state lookup ignore out-of-range layers without throwing", () => { animator.play("Walk"); const before = animator.getCurrentAnimatorState(0); diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts index 8aa299e910..dccb4c8ab5 100644 --- a/tests/src/core/AnimatorHang.test.ts +++ b/tests/src/core/AnimatorHang.test.ts @@ -9,7 +9,7 @@ const canvasDOM = document.createElement("canvas"); canvasDOM.width = 1024; canvasDOM.height = 1024; -describe("Canvas 1024 test", function () { +describe("Animator GLTF load hang regression on 1024x1024 canvas", function () { let animator: Animator; let engine: WebGLEngine; From 9f603f98859bc02080ce96b9ab693bc78e59a77f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:10:01 +0800 Subject: [PATCH 09/28] chore(animation): remove redundant crossfade guards --- packages/core/src/animation/Animator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index c72c6246b4..0577a80159 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -790,8 +790,8 @@ export class Animator extends Component { 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; From 18ec4b11d3c51ce4b5d9093a76826106b34f7c16 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:32:29 +0800 Subject: [PATCH 10/28] test(animation): tighten animator hang smoke assertion --- tests/src/core/AnimatorHang.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts index dccb4c8ab5..1d87711713 100644 --- a/tests/src/core/AnimatorHang.test.ts +++ b/tests/src/core/AnimatorHang.test.ts @@ -28,7 +28,7 @@ describe("Animator GLTF load hang regression on 1024x1024 canvas", function () { engine?.destroy(); }); - it("loaded", () => { - expect(animator).not.eq(null); + it("loads an Animator component without hanging", () => { + expect(animator).toBeInstanceOf(Animator); }); }); From 2138aa91ee968a4d9b4bb29b51217e818908f7ad Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:38:02 +0800 Subject: [PATCH 11/28] test(animation): make animator state tests deterministic --- tests/src/core/Animator.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 59854ce08d..51de038c8e 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -231,14 +231,14 @@ describe("Animator test", function () { it("find animator state", () => { const stateName = "Survey"; const expectedStateName = "Run"; - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; + const layerIndex = 0; - animator.play(stateName); + animator.play(stateName, layerIndex); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); expect(animatorState).to.eq(currentAnimatorState); - animator.play(expectedStateName); + animator.play(expectedStateName, layerIndex); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); expect(animatorState).not.to.eq(currentAnimatorState); expect(animatorState.name).to.eq(expectedStateName); @@ -312,13 +312,8 @@ describe("Animator test", function () { }); it("animation getCurrentAnimatorState", () => { - //get random animation element from gltf resource - const min = 0; - const max = resource.animations.length - 1; - const index = Math.floor(Math.random() * (max - min + 1)) + min; - //play animation and get current animator state - const expectedStateName = resource.animations[index].name; + const expectedStateName = resource.animations[0].name; animator.play(expectedStateName); const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); From c37fe79ee3bd42c7d7391939f924fc92b381ce56 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:49:13 +0800 Subject: [PATCH 12/28] perf(animation): reduce play data getter access --- .../animation/internal/AnimatorStatePlayData.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index 3ba4fd37b2..a137f18ed5 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -67,11 +67,16 @@ export class AnimatorStatePlayData { update(deltaTime: number): void { this.playedTime += deltaTime; - const state = this.instance._state; + const instance = this.instance; + const state = instance._state; + const clip = state.clip; + const clipLength = clip.length; + const clipStartTime = state.clipStartTime; let time = this.playedTime + this.offsetFrameTime; - const duration = state._getDuration(); + const duration = (state.clipEndTime - clipStartTime) * clipLength; + const wrapMode = instance.wrapMode; this.playState = AnimatorStatePlayState.Playing; - if (this.wrapMode === WrapMode.Loop) { + if (wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { @@ -81,7 +86,7 @@ export class AnimatorStatePlayData { } time < 0 && (time += duration); - this.clipTime = time + state.clipStartTime * state.clip.length; + this.clipTime = time + clipStartTime * clipLength; if (this._changedOrientation) { !this.isForward && this._correctTime(); @@ -91,9 +96,10 @@ export class AnimatorStatePlayData { private _correctTime(): void { const state = this.instance._state; + const clipLength = state.clip.length; // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. if (this.clipTime === 0) { - this.clipTime = state.clipEndTime * state.clip.length; + this.clipTime = state.clipEndTime * clipLength; } } } From 7d90f3cec04419643452b370c93be0e0d5789593 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:53:57 +0800 Subject: [PATCH 13/28] test(animation): avoid temp layer state in current-state test --- tests/src/core/Animator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 51de038c8e..9e617edba0 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -313,9 +313,9 @@ describe("Animator test", function () { it("animation getCurrentAnimatorState", () => { //play animation and get current animator state + const layerIndex = 0; const expectedStateName = resource.animations[0].name; - animator.play(expectedStateName); - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; + animator.play(expectedStateName, layerIndex); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); expect(currentAnimatorState.name).to.eq(expectedStateName); }); From 9483380c6aea6ccd0a58306bf1edb63c46c8f17c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 19:58:12 +0800 Subject: [PATCH 14/28] perf(animation): tighten play hot paths --- packages/core/src/animation/Animator.ts | 8 +++++++- .../src/animation/internal/AnimatorStatePlayData.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 0577a80159..68a9dda330 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -382,7 +382,13 @@ export class Animator extends Component { if (!state) { return; } - const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); + + const animatorLayerData = this._getAnimatorLayerData(playLayerIndex); + if (animatorLayerData.srcPlayData?.state === state || animatorLayerData.destPlayData?.state === state) { + return; + } + + const { manuallyTransition } = animatorLayerData; manuallyTransition.duration = duration; manuallyTransition.offset = normalizedTimeOffset; diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index a137f18ed5..bb86120578 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -78,14 +78,14 @@ export class AnimatorStatePlayData { this.playState = AnimatorStatePlayState.Playing; if (wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; - } else { - if (Math.abs(time) >= duration) { - time = time < 0 ? -duration : duration; - this.playState = AnimatorStatePlayState.Finished; - } + } else if (time >= duration || time <= -duration) { + time = time < 0 ? -duration : duration; + this.playState = AnimatorStatePlayState.Finished; } - time < 0 && (time += duration); + if (time < 0) { + time += duration; + } this.clipTime = time + clipStartTime * clipLength; if (this._changedOrientation) { From 39bf09df35d30ace4d03d2b8eb1a2cebfb0c7520 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 20:09:43 +0800 Subject: [PATCH 15/28] perf(animation): narrow crossfade no-op guard --- packages/core/src/animation/Animator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 68a9dda330..0a5bb16a33 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -384,7 +384,8 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(playLayerIndex); - if (animatorLayerData.srcPlayData?.state === state || animatorLayerData.destPlayData?.state === state) { + const { srcPlayData, destPlayData } = animatorLayerData; + if ((!destPlayData && srcPlayData?.state === state) || destPlayData?.state === state) { return; } From 063c352d4aaaa0527ff3ea410d5794b20d77e3c3 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 11:20:38 +0800 Subject: [PATCH 16/28] refactor(animation): simplify animator internals --- packages/core/src/animation/AnimationClip.ts | 10 ---------- packages/core/src/animation/Animator.ts | 13 +++++++------ .../src/animation/internal/AnimatorLayerData.ts | 14 +------------- tests/src/core/Animator.test.ts | 3 +-- 4 files changed, 9 insertions(+), 31 deletions(-) diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts index 9f7b6779fc..ef51647577 100644 --- a/packages/core/src/animation/AnimationClip.ts +++ b/packages/core/src/animation/AnimationClip.ts @@ -210,16 +210,6 @@ export class AnimationClip extends EngineObject { * @param time - The time to sample an animation */ sampleAnimation(entity: Entity, time: number): void { - this._sampleAnimation(entity, time); - } - - /** - * @internal - * Samples an animation at a given time. - * @param entity - The animated entity - * @param time - The time to sample an animation - */ - _sampleAnimation(entity: Entity, time: number): void { const { _curveBindings: curveBindings } = this; const components = AnimationCurveOwner._components; for (let i = curveBindings.length - 1; i >= 0; i--) { diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 0a5bb16a33..6fbfa1fd38 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -324,9 +324,8 @@ export class Animator extends Component { for (let i = 0, n = layersData.length; i < n; i++) { const layerData = layersData[i]; if (!layerData) continue; - const stateDataList = layerData.stateDataList; - for (let j = 0, m = stateDataList.length; j < m; j++) { - const layerOwners = stateDataList[j].curveLayerOwner; + for (const stateData of layerData.animatorStateDataMap.values()) { + const layerOwners = stateData.curveLayerOwner; for (let k = 0, l = layerOwners.length; k < l; k++) { layerOwners[k]?.curveOwner?.revertDefaultValue(); } @@ -435,7 +434,6 @@ export class Animator extends Component { if (!animatorStateData) { animatorStateData = new AnimatorStateData(animatorState); animatorStateDataMap.set(animatorState, animatorStateData); - animatorLayerData.stateDataList.push(animatorStateData); this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); } this._ensureEventHandlers(animatorState, animatorStateData); @@ -1091,7 +1089,9 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.completeCrossFade(); + layerData.srcPlayData = destPlayData; + layerData.destPlayData = null; + layerData.crossFadeTransition = null; } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { @@ -1352,7 +1352,8 @@ export class Animator extends Component { const playData = animatorLayerData.getOrCreateInstance(state)._playData; playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.srcPlayData = playData; - animatorLayerData.clearCrossFadeSlot(); + animatorLayerData.destPlayData = null; + animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); return true; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index b0402e4069..cf35d198fe 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -15,8 +15,7 @@ export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; curveOwnerPool: WeakMap> = new WeakMap(); - animatorStateDataMap: WeakMap = new WeakMap(); - stateDataList: AnimatorStateData[] = []; + animatorStateDataMap: Map = new Map(); instanceMap: WeakMap = new WeakMap(); srcPlayData: AnimatorStatePlayData | null = null; destPlayData: AnimatorStatePlayData | null = null; @@ -36,17 +35,6 @@ export class AnimatorLayerData { return instance; } - completeCrossFade(): void { - this.srcPlayData = this.destPlayData; - this.destPlayData = null; - this.crossFadeTransition = null; - } - - clearCrossFadeSlot(): void { - this.destPlayData = null; - this.crossFadeTransition = null; - } - resetCurrentCheckIndex(): void { this.layer.stateMachine._entryTransitionCollection.needResetCurrentCheckIndex = true; this.layer.stateMachine._anyStateTransitionCollection.needResetCurrentCheckIndex = true; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 9e617edba0..33eac76176 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1509,8 +1509,7 @@ describe("Animator test", function () { 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); + clip.sampleAnimation(wrappedRoot, 0.1); expect(wrappedRoot.transform.position.x).to.eq(0); expect(hips.transform.position.x).to.eq(1); From ae7ae016b1669d85af68c1894f60a60feb48b540 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 11:31:08 +0800 Subject: [PATCH 17/28] test(animation): cover destination crossfade no-op --- tests/src/core/Animator.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 33eac76176..4dc0333ddd 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -299,6 +299,22 @@ describe("Animator test", function () { expect(layerData.srcPlayData.playedTime).to.eq(playedBefore); }); + it("crossFade to the active destination is a no-op", () => { + animator.play("Walk"); + animator.crossFade("Run", 0.5, 0); + + const layerData = animator["_animatorLayersData"][0]; + const destBefore = layerData.destPlayData; + const transitionBefore = layerData.crossFadeTransition; + const playedBefore = destBefore.playedTime; + + animator.crossFade("Run", 0.1, 0); + + expect(layerData.destPlayData).to.eq(destBefore); + expect(layerData.crossFadeTransition).to.eq(transitionBefore); + expect(layerData.destPlayData.playedTime).to.eq(playedBefore); + }); + it("play, crossFade, and state lookup ignore out-of-range layers without throwing", () => { animator.play("Walk"); const before = animator.getCurrentAnimatorState(0); From 08ec9a1eb4cd447652813dcea20d50bb4f58d113 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 11:42:25 +0800 Subject: [PATCH 18/28] fix(animation): invalidate cache on state removal --- .../core/src/animation/AnimatorController.ts | 10 ++++ .../src/animation/AnimatorStateMachine.ts | 9 ++++ tests/src/core/Animator.test.ts | 53 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index 60bc439210..bc799feff4 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -19,6 +19,9 @@ export class AnimatorController extends ReferResource { _layersMap: Record = {}; private _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); + private _onStateMachineChanged = (): void => { + this._updateFlagManager.dispatch(); + }; /** * The layers in the controller. @@ -112,6 +115,7 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; + layer.stateMachine._setChangeCallback(this._onStateMachineChanged); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -123,6 +127,7 @@ export class AnimatorController extends ReferResource { removeLayer(layerIndex: number): void { const theLayer = this.layers[layerIndex]; this._layers.splice(layerIndex, 1); + theLayer.stateMachine._setChangeCallback(null); delete this._layersMap[theLayer.name]; this._updateFlagManager.dispatch(); } @@ -131,6 +136,10 @@ export class AnimatorController extends ReferResource { * Clear layers. */ clearLayers(): void { + const { _layers: layers } = this; + for (let i = 0, n = layers.length; i < n; i++) { + layers[i].stateMachine._setChangeCallback(null); + } this._layers.length = 0; for (let name in this._layersMap) { delete this._layersMap[name]; @@ -151,6 +160,7 @@ export class AnimatorController extends ReferResource { _setEngine(engine: Engine): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { + layers[i].stateMachine._setChangeCallback(this._onStateMachineChanged); layers[i]._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 3200aab5df..9c9990dd1e 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -14,6 +14,7 @@ export class AnimatorStateMachine { readonly states: AnimatorState[] = []; private _engine: Engine; + private _onChanged: (() => void) | null = null; /** * The state will be played automatically. @@ -72,6 +73,7 @@ export class AnimatorStateMachine { if (this.defaultState === state) { this.defaultState = null; } + this._onChanged?.(); } } @@ -170,4 +172,11 @@ export class AnimatorStateMachine { states[i]._setEngine(engine); } } + + /** + * @internal + */ + _setChangeCallback(onChanged: (() => void) | null): void { + this._onChanged = onChanged; + } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 4dc0333ddd..cb2534e2b3 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1487,6 +1487,59 @@ describe("Animator test", function () { } }); + it("removing a state invalidates cached layer data before the state name is reused", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const oldState = layer.stateMachine.addState("Temp"); + const oldClip = new AnimationClip("old-temp-clip"); + const oldCurve = new AnimationFloatCurve(); + const oldStart = new Keyframe(); + const oldEnd = new Keyframe(); + oldStart.time = 0; + oldStart.value = 0; + oldEnd.time = 1; + oldEnd.value = 1; + oldCurve.addKey(oldStart); + oldCurve.addKey(oldEnd); + oldClip.addCurveBinding("", Transform, "position.x", oldCurve); + oldState.clip = oldClip; + localAnimator.animatorController = controller; + + try { + localAnimator.play("Temp"); + let layerData = localAnimator["_animatorLayersData"][0]; + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(true); + + layer.stateMachine.removeState(oldState); + const newState = layer.stateMachine.addState("Temp"); + const newClip = new AnimationClip("new-temp-clip"); + const newCurve = new AnimationFloatCurve(); + const newStart = new Keyframe(); + const newEnd = new Keyframe(); + newStart.time = 0; + newStart.value = 0; + newEnd.time = 1; + newEnd.value = 2; + newCurve.addKey(newStart); + newCurve.addKey(newEnd); + newClip.addCurveBinding("", Transform, "position.x", newCurve); + newState.clip = newClip; + + localAnimator.play("Temp"); + layerData = localAnimator["_animatorLayersData"][0]; + + expect(localAnimator.getCurrentAnimatorState(0)._state).to.eq(newState); + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(false); + expect(layerData.animatorStateDataMap.has(newState)).to.eq(true); + } finally { + entity.destroy(); + } + }); + it("Clone", () => { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); }); From a92f56bb3e6a75716c51b4bd85f13f010cc4cac0 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 17:29:44 +0800 Subject: [PATCH 19/28] fix(animation): keep event cursor synced when events are muted --- packages/core/src/animation/Animator.ts | 16 +++++---- tests/src/core/Animator.test.ts | 43 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 6fbfa1fd38..45c041847e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1540,8 +1540,10 @@ export class Animator extends Component { const { handlers } = eventHandler; if (time >= lastClipTime) { - for (let j = handlers.length - 1; j >= 0; j--) { - handlers[j](parameter); + if (this.fireEvents) { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j](parameter); + } } playState.currentEventIndex = Math.min(eventIndex + 1, n - 1); } @@ -1565,8 +1567,10 @@ export class Animator extends Component { if (time <= lastClipTime) { const { handlers } = eventHandler; - for (let j = handlers.length - 1; j >= 0; j--) { - handlers[j](parameter); + if (this.fireEvents) { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j](parameter); + } } playState.currentEventIndex = Math.max(eventIndex - 1, 0); } @@ -1614,9 +1618,7 @@ export class Animator extends Component { ) { this._ensureEventHandlers(state, playData.stateData); const { eventHandlers } = playData.stateData; - this.fireEvents && - eventHandlers.length && - this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); + eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index cb2534e2b3..775c6f8673 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -670,6 +670,49 @@ describe("Animator test", function () { } }); + it("keeps AnimationEvent cursor in sync when fireEvents is disabled across forward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.8); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).toHaveBeenCalledTimes(1); + + event0Spy.mockClear(); + event1Spy.mockClear(); + loopAnimator.fireEvents = false; + updateAnimator(loopAnimator, 0.25); + expect(event0Spy).not.toHaveBeenCalled(); + expect(event1Spy).not.toHaveBeenCalled(); + + loopAnimator.fireEvents = true; + updateAnimator(loopAnimator, 0.45); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).not.toHaveBeenCalled(); + } finally { + entity.destroy(); + } + }); + it("fires animation events across backward loop wrap", () => { const { entity, animator: loopAnimator, clip } = createLoopAnimator(); From f2d788bd003ebc9e4d2980a33c7c484c3c3c338c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 17 Jun 2026 19:47:43 +0800 Subject: [PATCH 20/28] fix(animation): keep replaced state machines wired --- .../core/src/animation/AnimatorController.ts | 8 +-- .../src/animation/AnimatorControllerLayer.ts | 37 +++++++++++-- tests/src/core/Animator.test.ts | 55 +++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index bc799feff4..4d3d35025d 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -115,7 +115,7 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; - layer.stateMachine._setChangeCallback(this._onStateMachineChanged); + layer._setStateMachineChangeCallback(this._onStateMachineChanged); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -127,7 +127,7 @@ export class AnimatorController extends ReferResource { removeLayer(layerIndex: number): void { const theLayer = this.layers[layerIndex]; this._layers.splice(layerIndex, 1); - theLayer.stateMachine._setChangeCallback(null); + theLayer._setStateMachineChangeCallback(null); delete this._layersMap[theLayer.name]; this._updateFlagManager.dispatch(); } @@ -138,7 +138,7 @@ export class AnimatorController extends ReferResource { clearLayers(): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - layers[i].stateMachine._setChangeCallback(null); + layers[i]._setStateMachineChangeCallback(null); } this._layers.length = 0; for (let name in this._layersMap) { @@ -160,7 +160,7 @@ export class AnimatorController extends ReferResource { _setEngine(engine: Engine): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - layers[i].stateMachine._setChangeCallback(this._onStateMachineChanged); + layers[i]._setStateMachineChangeCallback(this._onStateMachineChanged); layers[i]._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index 135ac0204f..56421e7273 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -11,22 +11,51 @@ export class AnimatorControllerLayer { weight: number = 1.0; /** The blending mode used by the layer. It is not taken into account for the first layer. */ blendingMode: AnimatorLayerBlendingMode = AnimatorLayerBlendingMode.Override; - /** The state machine for the layer. */ - stateMachine: AnimatorStateMachine; /** The AnimatorLayerMask is used to mask out certain entities from being animated by an AnimatorLayer. */ mask: AnimatorLayerMask; + private _stateMachine: AnimatorStateMachine; + private _engine: Engine; + private _onStateMachineChanged: (() => void) | null = null; + + /** The state machine for the layer. */ + get stateMachine(): AnimatorStateMachine { + return this._stateMachine; + } + + set stateMachine(value: AnimatorStateMachine) { + const lastStateMachine = this._stateMachine; + if (lastStateMachine === value) { + return; + } + + lastStateMachine._setChangeCallback(null); + this._stateMachine = value; + value._setChangeCallback(this._onStateMachineChanged); + this._engine && value._setEngine(this._engine); + this._onStateMachineChanged?.(); + } + /** * @param name - The layer's name */ constructor(public readonly name: string) { - this.stateMachine = new AnimatorStateMachine(); + this._stateMachine = new AnimatorStateMachine(); } /** * @internal */ _setEngine(engine: Engine): void { - this.stateMachine._setEngine(engine); + this._engine = engine; + this._stateMachine._setEngine(engine); + } + + /** + * @internal + */ + _setStateMachineChangeCallback(onChanged: (() => void) | null): void { + this._onStateMachineChanged = onChanged; + this._stateMachine._setChangeCallback(onChanged); } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 775c6f8673..da924496fb 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1583,6 +1583,61 @@ describe("Animator test", function () { } }); + it("replacing a layer state machine keeps state removal invalidation", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const stateMachine = new AnimatorStateMachine(); + layer.stateMachine = stateMachine; + + const oldState = stateMachine.addState("Temp"); + const oldClip = new AnimationClip("old-temp-clip"); + const oldCurve = new AnimationFloatCurve(); + const oldStart = new Keyframe(); + const oldEnd = new Keyframe(); + oldStart.time = 0; + oldStart.value = 0; + oldEnd.time = 1; + oldEnd.value = 1; + oldCurve.addKey(oldStart); + oldCurve.addKey(oldEnd); + oldClip.addCurveBinding("", Transform, "position.x", oldCurve); + oldState.clip = oldClip; + localAnimator.animatorController = controller; + + try { + localAnimator.play("Temp"); + let layerData = localAnimator["_animatorLayersData"][0]; + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(true); + + stateMachine.removeState(oldState); + const newState = stateMachine.addState("Temp"); + const newClip = new AnimationClip("new-temp-clip"); + const newCurve = new AnimationFloatCurve(); + const newStart = new Keyframe(); + const newEnd = new Keyframe(); + newStart.time = 0; + newStart.value = 0; + newEnd.time = 1; + newEnd.value = 2; + newCurve.addKey(newStart); + newCurve.addKey(newEnd); + newClip.addCurveBinding("", Transform, "position.x", newCurve); + newState.clip = newClip; + + localAnimator.play("Temp"); + layerData = localAnimator["_animatorLayersData"][0]; + + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(false); + expect(layerData.animatorStateDataMap.has(newState)).to.eq(true); + } finally { + entity.destroy(); + } + }); + it("Clone", () => { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); }); From 1f0e0df41e280b13a7b1b12df50d76641a6b6b54 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 6 Jul 2026 16:13:34 +0800 Subject: [PATCH 21/28] refactor(animation): drop test-only playData setters AnimatorStatePlayData set speed / set wrapMode had zero production callers; tests now write through the public AnimatorStateInstance via playData.instance directly. --- .../core/src/animation/internal/AnimatorStatePlayData.ts | 8 -------- tests/src/core/Animator.test.ts | 8 ++++---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index bb86120578..b8a690e859 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -29,18 +29,10 @@ export class AnimatorStatePlayData { return this.instance.speed; } - set speed(value: number) { - this.instance.speed = value; - } - get wrapMode(): WrapMode { return this.instance.wrapMode; } - set wrapMode(value: WrapMode) { - this.instance.wrapMode = value; - } - reset(stateData: AnimatorStateData, offsetFrameTime: number): void { const state = this.instance._state; this.stateData = stateData; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index da924496fb..dd5dff2b98 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -363,8 +363,8 @@ describe("Animator test", function () { animator.crossFade("Run", 1.0, 0); const layerData = animator["_animatorLayersData"][0]; - layerData.srcPlayData.speed = 0.25; - layerData.destPlayData.speed = 0.25; + layerData.srcPlayData.instance.speed = 0.25; + layerData.destPlayData.instance.speed = 0.25; sharedWalkState.speed = 10; sharedRunState.speed = 10; @@ -394,7 +394,7 @@ describe("Animator test", function () { const layerData = animator["_animatorLayersData"][0]; const playData = layerData.srcPlayData; - playData.wrapMode = WrapMode.Once; + playData.instance.wrapMode = WrapMode.Once; expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); @@ -424,7 +424,7 @@ describe("Animator test", function () { const playData = animator["_animatorLayersData"][0].srcPlayData; const otherPlayData = otherAnimator["_animatorLayersData"][0].srcPlayData; - playData.wrapMode = WrapMode.Once; + playData.instance.wrapMode = WrapMode.Once; expect(otherPlayData.wrapMode).to.eq(WrapMode.Loop); expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); From 24840921f95f59f8944b3abd9a6f6f3931cdfa3c Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 6 Jul 2026 16:19:19 +0800 Subject: [PATCH 22/28] refactor(animation): drop single-use hot-path locals clip and wrapMode locals in update() and clipLength in _correctTime() were each read once, so hoisting bought no getter dedup; _correctTime additionally paid the clip.length getters even when the guard missed. --- .../core/src/animation/internal/AnimatorStatePlayData.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index b8a690e859..e303a711c8 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -61,14 +61,12 @@ export class AnimatorStatePlayData { this.playedTime += deltaTime; const instance = this.instance; const state = instance._state; - const clip = state.clip; - const clipLength = clip.length; + const clipLength = state.clip.length; const clipStartTime = state.clipStartTime; let time = this.playedTime + this.offsetFrameTime; const duration = (state.clipEndTime - clipStartTime) * clipLength; - const wrapMode = instance.wrapMode; this.playState = AnimatorStatePlayState.Playing; - if (wrapMode === WrapMode.Loop) { + if (instance.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else if (time >= duration || time <= -duration) { time = time < 0 ? -duration : duration; @@ -88,10 +86,9 @@ export class AnimatorStatePlayData { private _correctTime(): void { const state = this.instance._state; - const clipLength = state.clip.length; // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. if (this.clipTime === 0) { - this.clipTime = state.clipEndTime * clipLength; + this.clipTime = state.clipEndTime * state.clip.length; } } } From a9cadf35b61b3043b1e1f271d190543fff29c26b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 6 Jul 2026 16:33:30 +0800 Subject: [PATCH 23/28] perf(animation): defer transition clip bounds --- packages/core/src/animation/Animator.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 45c041847e..e7a8fa4ab6 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1122,10 +1122,7 @@ export class Animator extends Component { aniUpdate: boolean ): AnimatorStateTransition { const { state } = playData; - const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; - const startTime = state.clipStartTime * clipDuration; - const endTime = state.clipEndTime * clipDuration; if (transitionCollection.noExitTimeCount) { targetTransition = this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate); @@ -1134,6 +1131,10 @@ export class Animator extends Component { } } + const clipDuration = state.clip.length; + const startTime = state.clipStartTime * clipDuration; + const endTime = state.clipEndTime * clipDuration; + if (isForward) { if (lastClipTime + deltaTime >= endTime) { targetTransition = this._checkSubTransition( From cfed88cfc5953def572bb08c5f05ddcbc693895e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 6 Jul 2026 16:51:15 +0800 Subject: [PATCH 24/28] refactor(animation): tidy controller internals Expression-body arrow for _onStateMachineChanged, hoist layer local in _setEngine, and use the destructured alias in clearLayers. --- packages/core/src/animation/AnimatorController.ts | 12 ++++++------ .../core/src/animation/AnimatorControllerLayer.ts | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index 4d3d35025d..ddc3e95c40 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -19,9 +19,7 @@ export class AnimatorController extends ReferResource { _layersMap: Record = {}; private _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); - private _onStateMachineChanged = (): void => { - this._updateFlagManager.dispatch(); - }; + private _onStateMachineChanged = (): void => this._updateFlagManager.dispatch(); /** * The layers in the controller. @@ -140,7 +138,8 @@ export class AnimatorController extends ReferResource { for (let i = 0, n = layers.length; i < n; i++) { layers[i]._setStateMachineChangeCallback(null); } - this._layers.length = 0; + layers.length = 0; + for (let name in this._layersMap) { delete this._layersMap[name]; } @@ -160,8 +159,9 @@ export class AnimatorController extends ReferResource { _setEngine(engine: Engine): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - layers[i]._setStateMachineChangeCallback(this._onStateMachineChanged); - layers[i]._setEngine(engine); + const layer = layers[i]; + layer._setStateMachineChangeCallback(this._onStateMachineChanged); + layer._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index 56421e7273..da5963a0a9 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -18,7 +18,9 @@ export class AnimatorControllerLayer { private _engine: Engine; private _onStateMachineChanged: (() => void) | null = null; - /** The state machine for the layer. */ + /** + * The state machine for the layer. + */ get stateMachine(): AnimatorStateMachine { return this._stateMachine; } From 295251556304b5b4c9c60dc4330d3d5642ab16b5 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 6 Jul 2026 17:06:18 +0800 Subject: [PATCH 25/28] refactor(animation): use curve owner alias --- packages/core/src/animation/Animator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index e7a8fa4ab6..2d5d40aae9 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1006,7 +1006,7 @@ export class Animator extends Component { this._checkRevertOwner(owner, additive); - const value = layerOwner.curveOwner.crossFadeFromPoseAndApplyValue( + const value = owner.crossFadeFromPoseAndApplyValue( curveIndex >= 0 ? curveBindings[curveIndex].curve : null, destClipTime, crossWeight, From acf7648cf4f8fe935f0163ae02565e0dfa728de8 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 6 Jul 2026 17:20:32 +0800 Subject: [PATCH 26/28] refactor(animation): name state invalidation callbacks --- packages/core/src/animation/AnimatorController.ts | 10 +++++----- .../core/src/animation/AnimatorControllerLayer.ts | 14 +++++++------- .../core/src/animation/AnimatorStateMachine.ts | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index ddc3e95c40..1f8e91a01c 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -19,7 +19,7 @@ export class AnimatorController extends ReferResource { _layersMap: Record = {}; private _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); - private _onStateMachineChanged = (): void => this._updateFlagManager.dispatch(); + private _onStatesInvalidate = (): void => this._updateFlagManager.dispatch(); /** * The layers in the controller. @@ -113,7 +113,7 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; - layer._setStateMachineChangeCallback(this._onStateMachineChanged); + layer._setStatesInvalidateCallback(this._onStatesInvalidate); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -125,7 +125,7 @@ export class AnimatorController extends ReferResource { removeLayer(layerIndex: number): void { const theLayer = this.layers[layerIndex]; this._layers.splice(layerIndex, 1); - theLayer._setStateMachineChangeCallback(null); + theLayer._setStatesInvalidateCallback(null); delete this._layersMap[theLayer.name]; this._updateFlagManager.dispatch(); } @@ -136,7 +136,7 @@ export class AnimatorController extends ReferResource { clearLayers(): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - layers[i]._setStateMachineChangeCallback(null); + layers[i]._setStatesInvalidateCallback(null); } layers.length = 0; @@ -160,7 +160,7 @@ export class AnimatorController extends ReferResource { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { const layer = layers[i]; - layer._setStateMachineChangeCallback(this._onStateMachineChanged); + layer._setStatesInvalidateCallback(this._onStatesInvalidate); layer._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index da5963a0a9..5b6c094da4 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -16,7 +16,7 @@ export class AnimatorControllerLayer { private _stateMachine: AnimatorStateMachine; private _engine: Engine; - private _onStateMachineChanged: (() => void) | null = null; + private _onStatesInvalidate: (() => void) | null = null; /** * The state machine for the layer. @@ -31,11 +31,11 @@ export class AnimatorControllerLayer { return; } - lastStateMachine._setChangeCallback(null); + lastStateMachine._setStatesInvalidateCallback(null); this._stateMachine = value; - value._setChangeCallback(this._onStateMachineChanged); + value._setStatesInvalidateCallback(this._onStatesInvalidate); this._engine && value._setEngine(this._engine); - this._onStateMachineChanged?.(); + this._onStatesInvalidate?.(); } /** @@ -56,8 +56,8 @@ export class AnimatorControllerLayer { /** * @internal */ - _setStateMachineChangeCallback(onChanged: (() => void) | null): void { - this._onStateMachineChanged = onChanged; - this._stateMachine._setChangeCallback(onChanged); + _setStatesInvalidateCallback(onStatesInvalidate: (() => void) | null): void { + this._onStatesInvalidate = onStatesInvalidate; + this._stateMachine._setStatesInvalidateCallback(onStatesInvalidate); } } diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 9c9990dd1e..298e1c62c6 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -14,7 +14,7 @@ export class AnimatorStateMachine { readonly states: AnimatorState[] = []; private _engine: Engine; - private _onChanged: (() => void) | null = null; + private _onStatesInvalidate: (() => void) | null = null; /** * The state will be played automatically. @@ -73,7 +73,7 @@ export class AnimatorStateMachine { if (this.defaultState === state) { this.defaultState = null; } - this._onChanged?.(); + this._onStatesInvalidate?.(); } } @@ -176,7 +176,7 @@ export class AnimatorStateMachine { /** * @internal */ - _setChangeCallback(onChanged: (() => void) | null): void { - this._onChanged = onChanged; + _setStatesInvalidateCallback(onStatesInvalidate: (() => void) | null): void { + this._onStatesInvalidate = onStatesInvalidate; } } From 03f9f07f2871b2aaa551de564112d081269b228f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 6 Jul 2026 17:29:32 +0800 Subject: [PATCH 27/28] refactor(animation): drop redundant crossFade entry guard The _crossFade entry no-op guard was a strict subset of the guard inside _prepareCrossFadeByTransition, which also covers the automatic transition path (_applyTransition). Removing it leaves a single authoritative guard with one predicate to maintain; behavior is unchanged (both no-op regression tests still pass). --- packages/core/src/animation/Animator.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 2d5d40aae9..dba1323aff 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -383,11 +383,6 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(playLayerIndex); - const { srcPlayData, destPlayData } = animatorLayerData; - if ((!destPlayData && srcPlayData?.state === state) || destPlayData?.state === state) { - return; - } - const { manuallyTransition } = animatorLayerData; manuallyTransition.duration = duration; From a1eed4f7986c3cd41a9b625b247d6d5ab5293d55 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 6 Jul 2026 17:33:27 +0800 Subject: [PATCH 28/28] refactor(animation): drop code-restating comment The layer owner pool block visibly mirrors the owner pool block above it; the comment only restated that structure. --- packages/core/src/animation/Animator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index dba1323aff..f58589955c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -475,7 +475,6 @@ export class Animator extends Component { propertyOwners[property] = owner; } - // Keep layer owner lookup on the same Component identity path. let layerPropertyOwners = layerCurveOwnerPool.get(component); if (!layerPropertyOwners) { layerPropertyOwners = >Object.create(null);