import { AnimationAction, AnimationClip, AnimationMixer, AxesHelper, Euler, KeyframeTrack, LoopOnce, Object3D, Quaternion, Vector3 } from "three"; import { isDevEnvironment } from "../engine/debug/index.js"; import { Mathf } from "../engine/engine_math.js"; import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js"; import { assign, SerializationContext, TypeSerializer } from "../engine/engine_serialization_core.js"; import { Context } from "../engine/engine_setup.js"; import { isAnimationAction } from "../engine/engine_three_utils.js"; import { TypeStore } from "../engine/engine_typestore.js"; import { deepClone, getParam } from "../engine/engine_utils.js"; import type { AnimatorControllerModel, Condition, State, Transition } from "../engine/extensions/NEEDLE_animator_controller_model.js"; import { AnimatorConditionMode, AnimatorControllerParameterType, AnimatorStateInfo, createMotion, StateMachineBehaviour } from "../engine/extensions/NEEDLE_animator_controller_model.js"; import { Animator } from "./Animator.js"; const debug = getParam("debuganimatorcontroller"); const debugRootMotion = getParam("debugrootmotion"); /** * Generates a hash code for a string * @param str - The string to hash * @returns A numeric hash value */ function stringToHash(str): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash; } /** * Configuration options for creating an AnimatorController */ declare type CreateAnimatorControllerOptions = { /** Should each animation state loop */ looping?: boolean, /** Set to false to disable generating transitions between animation clips */ autoTransition?: boolean, /** Duration in seconds for transitions between states */ transitionDuration?: number, } /** * Controls the playback of animations using a state machine architecture. * * The AnimatorController manages animation states, transitions between states, * and parameters that affect those transitions. It is used by the {@link Animator} * component to control animation behavior on 3D models. * * Use the static method {@link AnimatorController.createFromClips} to create * an animator controller from a set of animation clips. */ export class AnimatorController { /** * Creates an AnimatorController from a set of animation clips. * Each clip becomes a state in the controller's state machine. * * @param clips - The animation clips to use for creating states * @param options - Configuration options for the controller including looping behavior and transitions * @returns A new AnimatorController instance */ static createFromClips(clips: AnimationClip[], options: CreateAnimatorControllerOptions = { looping: false, autoTransition: true, transitionDuration: 0 }): AnimatorController { const states: State[] = []; for (let i = 0; i < clips.length; i++) { const clip = clips[i]; const transitions: Transition[] = []; if (options.autoTransition !== false) { const dur = options.transitionDuration ?? 0; const normalizedDuration = dur / clip.duration; // automatically transition to self by default let nextState = i; if (options.autoTransition === undefined || options.autoTransition === true) { nextState = (i + 1) % clips.length; } transitions.push({ exitTime: 1 - normalizedDuration, offset: 0, duration: dur, hasExitTime: true, destinationState: nextState, conditions: [], }) } const state: State = { name: clip.name, hash: i, // by using the index it's easy for users to call play(2) to play the clip at index 2 motion: { name: clip.name, clip: clip, isLooping: options?.looping ?? false, }, transitions: transitions, behaviours: [] } states.push(state); } const model: AnimatorControllerModel = { name: "AnimatorController", guid: new InstantiateIdProvider(Date.now()).generateUUID(), parameters: [], layers: [{ name: "Base Layer", stateMachine: { defaultState: 0, states: states } }] } const controller = new AnimatorController(model); return controller; } /** * Plays an animation state by name or hash. * * @param name - The name or hash identifier of the state to play * @param layerIndex - The layer index (defaults to 0) * @param normalizedTime - The normalized time to start the animation from (0-1) * @param durationInSec - Transition duration in seconds */ play(name: string | number, layerIndex: number = -1, normalizedTime: number = Number.NEGATIVE_INFINITY, durationInSec: number = 0) { if (layerIndex < 0) layerIndex = 0; else if (layerIndex >= this.model.layers.length) { console.warn("invalid layer"); return; } const layer = this.model.layers[layerIndex]; const sm = layer.stateMachine; for (const state of sm.states) { if (state.name === name || state.hash === name) { if (debug) console.log("transition to ", state); this.transitionTo(state, durationInSec, normalizedTime); return; } } console.warn("Could not find " + name + " to play"); } /** * Resets the controller to its initial state. */ reset() { this.setStartTransition(); } /** * Sets a boolean parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @param value - The boolean value to set */ setBool(name: string | number, value: boolean) { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = value); } /** * Gets a boolean parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @returns The boolean value of the parameter, or false if not found */ getBool(name: string | number): boolean { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false; } /** * Sets a float parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @param val - The float value to set * @returns True if the parameter was found and set, false otherwise */ setFloat(name: string | number, val: number) { const key = typeof name === "string" ? "name" : "hash"; const filtered = this.model?.parameters?.filter(p => p[key] === name); filtered.forEach(p => p.value = val); return filtered?.length > 0; } /** * Gets a float parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @returns The float value of the parameter, or 0 if not found */ getFloat(name: string | number): number { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0; } /** * Sets an integer parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @param val - The integer value to set */ setInteger(name: string | number, val: number) { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = val); } /** * Gets an integer parameter value by name or hash. * * @param name - The name or hash identifier of the parameter * @returns The integer value of the parameter, or 0 if not found */ getInteger(name: string | number): number { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0; } /** * Sets a trigger parameter to active (true). * Trigger parameters are automatically reset after they are consumed by a transition. * * @param name - The name or hash identifier of the trigger parameter */ setTrigger(name: string | number) { if (debug) console.log("SET TRIGGER", name); const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = true); } /** * Resets a trigger parameter to inactive (false). * * @param name - The name or hash identifier of the trigger parameter */ resetTrigger(name: string | number) { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = false); } /** * Gets the current state of a trigger parameter. * * @param name - The name or hash identifier of the trigger parameter * @returns The boolean state of the trigger, or false if not found */ getTrigger(name: string | number): boolean { const key = typeof name === "string" ? "name" : "hash"; return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false; } /** * Checks if the controller is currently in a transition between states. * * @returns True if a transition is in progress, false otherwise */ isInTransition(): boolean { return this._activeStates.length > 1; } /** Set the speed of the animator controller. Larger values will make the animation play faster. */ setSpeed(speed: number) { this._speed = speed; } private _speed: number = 1; /** * Finds an animation state by name or hash. * @deprecated Use findState instead * * @param name - The name or hash identifier of the state to find * @returns The found state or null if not found */ FindState(name: string | number | undefined | null): State | null { return this.findState(name); } /** * Finds an animation state by name or hash. * * @param name - The name or hash identifier of the state to find * @returns The found state or null if not found */ findState(name: string | number | undefined | null): State | null { if (!name) return null; if (Array.isArray(this.model.layers)) { for (const layer of this.model.layers) { for (const state of layer.stateMachine.states) { if (state.name === name || state.hash == name) return state; } } } return null; } /** * Gets information about the current playing animation state. * * @returns An AnimatorStateInfo object with data about the current state, or null if no state is active */ getCurrentStateInfo() { if (!this._activeState) return null; const action = this._activeState.motion.action; if (!action) return null; const dur = this._activeState.motion.clip!.duration; const normalizedTime = dur <= 0 ? 0 : Math.abs(action.time / dur); return new AnimatorStateInfo(this._activeState, normalizedTime, dur, this._speed); } /** * Gets the animation action currently playing. * * @returns The current animation action, or null if no action is playing */ get currentAction(): AnimationAction | null { if (!this._activeState) return null; const action = this._activeState.motion.action; if (!action) return null; return action; } /** * The normalized time (0-1) to start playing the first state at. * This affects the initial state when the animator is first enabled. */ normalizedStartOffset: number = 0; /** * The Animator component this controller is bound to. */ animator?: Animator; /** * The data model describing the animation states and transitions. */ model: AnimatorControllerModel; /** * Gets the engine context from the bound animator. */ get context(): Context | undefined | null { return this.animator?.context; } /** * Gets the animation mixer used by this controller. */ get mixer() { return this._mixer; } /** * Cleans up resources used by this controller. * Stops all animations and unregisters the mixer from the animation system. */ dispose() { this._mixer.stopAllAction(); if (this.animator) { this._mixer.uncacheRoot(this.animator.gameObject); for (const action of this._activeStates) { if (action.motion.clip) this.mixer.uncacheAction(action.motion.clip, this.animator.gameObject); } } this.context?.animations.unregisterAnimationMixer(this._mixer); } // applyRootMotion(obj: Object3D) { // // this.internalApplyRootMotion(obj); // } /** * Binds this controller to an animator component. * Creates a new animation mixer and sets up animation actions. * * @param animator - The animator to bind this controller to */ bind(animator: Animator) { if (!animator) console.error("AnimatorController.bind: animator is null"); else if (this.animator !== animator) { if (this._mixer) { this._mixer.stopAllAction(); this.context?.animations.unregisterAnimationMixer(this._mixer); } this.animator = animator; this._mixer = new AnimationMixer(this.animator.gameObject); this.context?.animations.registerAnimationMixer(this._mixer); this.createActions(this.animator); } } /** * Creates a deep copy of this controller. * Clones the model data but does not copy runtime state. * * @returns A new AnimatorController instance with the same configuration */ clone() { if (typeof this.model === "string") { console.warn("AnimatorController has not been resolved, can not create model from string", this.model); return null; } if (debug) console.warn("AnimatorController clone()", this.model); // clone runtime controller but dont clone clip or action const clonedModel = deepClone(this.model, (_owner, _key, _value) => { if (_value === null || _value === undefined) return true; // dont clone three Objects if (_value.type === "Object3D" || _value.isObject3D === true) return false; // dont clone AnimationAction if (isAnimationAction(_value)) { //.constructor.name === "AnimationAction") { // console.log(_value); return false; } // dont clone AnimationClip if (_value["tracks"] !== undefined) return false; // when assigned __concreteInstance during serialization if (_value instanceof AnimatorController) return false; return true; }) as AnimatorControllerModel; console.assert(clonedModel !== this.model); const controller = new AnimatorController(clonedModel); return controller; } /** * Updates the controller's state machine and animations. * Called each frame by the animator component. * * @param weight - The weight to apply to the animations (for blending) */ update(weight: number) { if (!this.animator) return; this.evaluateTransitions(); this.updateActiveStates(weight); // We want to update the animation mixer even if there is no active state (e.g. in cases where an empty animator controller is assigned and the timeline runs) // if (!this._activeState) return; const dt = this.animator.context.time.deltaTime; if (this.animator.applyRootMotion) { this.rootMotionHandler?.onBeforeUpdate(weight); } this._mixer.update(dt); if (this.animator.applyRootMotion) { this.rootMotionHandler?.onAfterUpdate(weight); } } private _mixer!: AnimationMixer; private _activeState?: State; /** * Gets the currently active animation state. * * @returns The active state or undefined if no state is active */ get activeState(): State | undefined { return this._activeState; } constructor(model: AnimatorControllerModel) { this.model = model; if (debug) console.log(this); } private _activeStates: State[] = []; private updateActiveStates(weight: number) { for (let i = 0; i < this._activeStates.length; i++) { const state = this._activeStates[i]; const motion = state.motion; if (!motion.action) { this._activeStates.splice(i, 1); i--; } else { const action = motion.action; action.weight = weight; // console.log(action.getClip().name, action.getEffectiveWeight(), action.isScheduled()); if ((action.getEffectiveWeight() <= 0 && !action.isRunning())) { if (debug) console.debug("REMOVE", state.name, action.getEffectiveWeight(), action.isRunning(), action.isScheduled()) this._activeStates.splice(i, 1); i--; } } } } private setStartTransition() { if (this.model.layers.length > 1 && (debug || isDevEnvironment())) { console.warn("Multiple layers are not supported yet " + this.animator?.name); } for (const layer of this.model.layers) { const sm = layer.stateMachine; if (sm.defaultState === undefined) { if (debug) console.warn("AnimatorController default state is undefined, will assign state 0 as default", layer); sm.defaultState = 0; } const start = sm.states[sm.defaultState]; this.transitionTo(start, 0, this.normalizedStartOffset); break; } } private evaluateTransitions() { let didEnterStateThisFrame = false; if (!this._activeState) { this.setStartTransition(); if (!this._activeState) return; didEnterStateThisFrame = true; } const state = this._activeState; const action = state.motion.action; let index = 0; for (const transition of state.transitions) { ++index; // transition without exit time and without condition that transition to itself are ignored if (!transition.hasExitTime && transition.conditions.length <= 0) { // if (this._activeState && this.getState(transition.destinationState, currentLayer)?.hash === this._activeState.hash) continue; } let allConditionsAreMet = true; for (const cond of transition.conditions) { if (!this.evaluateCondition(cond)) { allConditionsAreMet = false; break; } } if (!allConditionsAreMet) continue; if (debug && allConditionsAreMet) { // console.log("All conditions are met", transition); } if (action) { const dur = state.motion.clip!.duration; const normalizedTime = dur <= 0 ? 1 : Math.abs(action.time / dur); let exitTime = transition.exitTime; // When the animation is playing backwards we need to check exit time inverted if (action.timeScale < 0) { exitTime = 1 - exitTime; } let makeTransition = false; if (transition.hasExitTime) { if (action.timeScale > 0) makeTransition = normalizedTime >= transition.exitTime; // When the animation is playing backwards we need to check exit time inverted else if (action.timeScale < 0) makeTransition = 1 - normalizedTime >= transition.exitTime; } else { makeTransition = true; } if (makeTransition) { // disable triggers for this transition for (const cond of transition.conditions) { const param = this.model.parameters.find(p => p.name === cond.parameter); if (param?.type === AnimatorControllerParameterType.Trigger && param.value) { param.value = false; } } // if (transition.hasExitTime && transition.exitTime >= .9999) action.clampWhenFinished = true; // else action.clampWhenFinished = false; if (debug) { const targetState = this.getState(transition.destinationState, 0); console.log(`Transition to ${transition.destinationState} / ${targetState?.name}`, transition, "\nTimescale: " + action.timeScale, "\nNormalized time: " + normalizedTime.toFixed(3), "\nExit Time: " + exitTime, transition.hasExitTime); // console.log(action.time, transition.exitTime); } this.transitionTo(transition.destinationState, transition.duration, transition.offset); // use the first transition that matches all conditions and make the transition as soon as in range return; } } else { this.transitionTo(transition.destinationState, transition.duration, transition.offset); return; } // if none of the transitions can be made continue searching for another transition meeting the conditions } // action.time += this.context.time.deltaTime // console.log(action?.time, action?.getEffectiveWeight()) // update timescale if (action) { this.setTimescale(action, state); } let didTriggerLooping = false; if (state.motion.isLooping && action) { // we dont use the three loop state here because it prevents the transition check above // it is easier if we re-trigger loop here. // We also can easily add the cycle offset settings from unity later if (action.time >= action.getClip().duration) { didTriggerLooping = true; action.reset(); action.time = 0; action.play(); } else if (action.time <= 0 && action.timeScale < 0) { didTriggerLooping = true; action.reset(); action.time = action.getClip().duration; action.play(); } } // call update state behaviours: if (!didTriggerLooping && state && !didEnterStateThisFrame && action && this.animator) { if (state.behaviours) { const duration = action?.getClip().duration; const normalizedTime = action.time / duration; const info = new AnimatorStateInfo(this._activeState, normalizedTime, duration, this._speed) for (const beh of state.behaviours) { if (beh.instance) { beh.instance.onStateUpdate?.call(beh.instance, this.animator, info, 0); } } } } } private setTimescale(action: AnimationAction, state: State) { let speedFactor = state.speed ?? 1; if (state.speedParameter) speedFactor *= this.getFloat(state.speedParameter); if (speedFactor !== undefined) { action.timeScale = speedFactor * this._speed; } } private getState(state: State | number, layerIndex: number): State | null { if (typeof state === "number") { if (state == -1) { state = this.model.layers[layerIndex].stateMachine.defaultState; // exit state -> entry state if (state === undefined) { if (debug) console.warn("AnimatorController default state is undefined: ", this.model, "Layer: " + layerIndex); state = 0; } } state = this.model.layers[layerIndex].stateMachine.states[state]; } return state; } /** * These actions have been active previously but not faded out because we entered a state that has no real animation - no duration. In which case we hold the previously active actions until they are faded out. */ private readonly _heldActions: AnimationAction[] = []; private releaseHeldActions(duration: number) { for (const prev of this._heldActions) { prev.fadeOut(duration); } this._heldActions.length = 0; } private transitionTo(state: State | number, durationInSec: number, offsetNormalized: number) { if (!this.animator) return; const layerIndex = 0; state = this.getState(state, layerIndex) as State; if (!state?.motion || !state.motion.clip || !(state.motion.clip instanceof AnimationClip)) { // if(debug) console.warn("State has no clip or motion", state); return; } const isSelf = this._activeState === state; if (isSelf) { const motion = state.motion; if (!motion.action_loopback && motion.clip) { // uncache action immediately resets the applied animation which breaks the root motion // this happens if we have a transition to self and the clip is not cached yet const previousMatrix = this.rootMotionHandler ? this.animator.gameObject.matrix.clone() : null; this._mixer.uncacheAction(motion.clip, this.animator.gameObject); if (previousMatrix) previousMatrix.decompose(this.animator.gameObject.position, this.animator.gameObject.quaternion, this.animator.gameObject.scale); motion.action_loopback = this.createAction(motion.clip); } } // call exit state behaviours if (this._activeState?.behaviours && this._activeState.motion.action) { const duration = this._activeState?.motion.clip!.duration; const normalizedTime = this._activeState.motion.action.time / duration; const info = new AnimatorStateInfo(this._activeState, normalizedTime, duration, this._speed); for (const beh of this._activeState.behaviours) { beh.instance?.onStateExit?.call(beh.instance, this.animator, info, layerIndex); } } const prevAction = this._activeState?.motion.action; if (isSelf) { state.motion.action = state.motion.action_loopback; state.motion.action_loopback = prevAction; } const prev = this._activeState; this._activeState = state; const action = state.motion?.action; const clip = state.motion.clip; if (clip?.duration <= 0 && clip.tracks.length <= 0) { // if the new state doesn't have a valid clip / no tracks we don't fadeout the previous action and instead hold the previous action. if (prevAction) { this._heldActions.push(prevAction); } } else if (prevAction) { prevAction!.fadeOut(durationInSec); this.releaseHeldActions(durationInSec); } if (action) { offsetNormalized = Math.max(0, Math.min(1, offsetNormalized)); if (state.cycleOffsetParameter) { let val = this.getFloat(state.cycleOffsetParameter); if (typeof val === "number") { if (val < 0) val += 1; offsetNormalized += val; offsetNormalized %= 1; } else if (debug) console.warn("AnimatorController cycle offset parameter is not a number", state.cycleOffsetParameter); } else if (typeof state.cycleOffset === "number") { offsetNormalized += state.cycleOffset offsetNormalized %= 1; } if (action.isRunning()) action.stop(); action.reset(); action.enabled = true; this.setTimescale(action, state); const duration = state.motion.clip!.duration; // if we are looping to the same state we don't want to offset the current start time action.time = isSelf ? 0 : offsetNormalized * duration; if (action.timeScale < 0) action.time = duration - action.time; action.clampWhenFinished = true; action.setLoop(LoopOnce, 0); if (durationInSec > 0) action.fadeIn(durationInSec); else action.weight = 1; action.play(); if (this.rootMotionHandler) { this.rootMotionHandler.onStart(action); } if (!this._activeStates.includes(state)) this._activeStates.push(state); // call enter state behaviours if (this._activeState.behaviours) { const info = new AnimatorStateInfo(state, offsetNormalized, duration, this._speed); for (const beh of this._activeState.behaviours) { beh.instance?.onStateEnter?.call(beh.instance, this.animator, info, layerIndex); } } } else if (debug) { if (!state["__warned_no_motion"]) { state["__warned_no_motion"] = true; console.warn("No action", state.motion, this); } } if (debug) console.log("TRANSITION FROM " + prev?.name + " TO " + state.name, durationInSec, prevAction, action, action?.getEffectiveTimeScale(), action?.getEffectiveWeight(), action?.isRunning(), action?.isScheduled(), action?.paused); } private createAction(clip: AnimationClip) { // uncache clip causes issues when multiple states use the same clip // this._mixer.uncacheClip(clip); // instead only uncache the action when one already exists to make sure // we get unique actions per state const existing = this._mixer.existingAction(clip); if (existing) this._mixer.uncacheAction(clip, this.animator?.gameObject); if (this.animator?.applyRootMotion) { if (!this.rootMotionHandler) { this.rootMotionHandler = new RootMotionHandler(this); } // TODO: find root bone properly const root = this.animator.gameObject; return this.rootMotionHandler.createClip(this._mixer, root, clip); } else { const action = this._mixer.clipAction(clip); return action; } } private evaluateCondition(cond: Condition): boolean { const param = this.model.parameters.find(p => p.name === cond.parameter); if (!param) return false; // console.log(param.name, param.value); switch (cond.mode) { case AnimatorConditionMode.If: return param.value === true; case AnimatorConditionMode.IfNot: return param.value === false; case AnimatorConditionMode.Greater: return param.value as number > cond.threshold; case AnimatorConditionMode.Less: return param.value as number < cond.threshold; case AnimatorConditionMode.Equals: return param.value === cond.threshold; case AnimatorConditionMode.NotEqual: return param.value !== cond.threshold; } return false; } private createActions(_animator: Animator) { if (debug) console.log("AnimatorController createActions", this.model); for (const layer of this.model.layers) { const sm = layer.stateMachine; for (let index = 0; index < sm.states.length; index++) { const state = sm.states[index]; // ensure we have a transitions array if (!state.transitions) { state.transitions = []; } for (const t of state.transitions) { // can happen if conditions are empty in blender - the exporter seems to skip empty arrays if (!t.conditions) t.conditions = []; } // ensure we have a motion even if none was exported if (!state.motion) { if (debug) console.warn("No motion", state); state.motion = createMotion(state.name); // console.warn("Missing motion", "AnimatorController: " + this.model.name, state); // sm.states.splice(index, 1); // index -= 1; // continue; } // the clips array contains which animator has which animationclip if (this.animator && state.motion.clips) { // TODO: we have to compare by name because on instantiate we clone objects but not the node object const mapping = state.motion.clips?.find(e => e.node.name === this.animator?.gameObject?.name); if (!mapping) { if (debug || isDevEnvironment()) { console.warn("Could not find clip for animator \"" + this.animator?.gameObject?.name + "\"", state.motion.clips.map(c => c.node.name)); } } else state.motion.clip = mapping.clip; } // ensure we have a clip to blend to if (!state.motion.clip) { if (debug) console.warn("No clip assigned to state", state); const clip = new AnimationClip(undefined, undefined, []); state.motion.clip = clip; } if (state.motion?.clip) { const clip = state.motion.clip; if (clip instanceof AnimationClip) { const action = this.createAction(clip); state.motion.action = action; } else { if (debug || isDevEnvironment()) console.warn("No valid animationclip assigned", state); } } // create state machine behaviours if (state.behaviours && Array.isArray(state.behaviours)) { for (const behaviour of state.behaviours) { if (!behaviour?.typeName) continue; const type = TypeStore.get(behaviour.typeName); if (type) { const instance: StateMachineBehaviour = new type() as StateMachineBehaviour; if (instance.isStateMachineBehaviour) { instance._context = this.context ?? undefined; assign(instance, behaviour.properties); behaviour.instance = instance; } if (debug) console.log("Created animator controller behaviour", state.name, behaviour.typeName, behaviour.properties, instance); } else { if (debug || isDevEnvironment()) console.warn("Could not find AnimatorBehaviour type: " + behaviour.typeName); } } } } } } /** * Yields all animation actions managed by this controller. * Iterates through all states in all layers and returns their actions. */ *enumerateActions() { if (!this.model.layers) return; for (const layer of this.model.layers) { const sm = layer.stateMachine; for (let index = 0; index < sm.states.length; index++) { const state = sm.states[index]; if (state?.motion) { if (state.motion.action) yield state.motion.action; if (state.motion.action_loopback) yield state.motion.action_loopback; } } } } // https://docs.unity3d.com/Manual/RootMotion.html private rootMotionHandler?: RootMotionHandler; // private findRootBone(obj: Object3D): Object3D | null { // if (this.animationRoot) return this.animationRoot; // if (obj.type === "Bone") { // this.animationRoot = obj as Bone; // return this.animationRoot; // } // if (obj.children) { // for (const ch of obj.children) { // const res = this.findRootBone(ch); // if (res) return res; // } // } // return null; // } } /** * Wraps a KeyframeTrack to allow custom evaluation of animation values. * Used internally to modify animation behavior without changing the original data. */ class TrackEvaluationWrapper { track?: KeyframeTrack; createdInterpolant?: any; originalEvaluate?: Function; private customEvaluate?: (time: number) => any; constructor(track: KeyframeTrack, evaluate: (time: number, value: any) => any) { this.track = track; const t = track as any; const createOriginalInterpolator = t.createInterpolant.bind(track); t.createInterpolant = () => { t.createInterpolant = createOriginalInterpolator; this.createdInterpolant = createOriginalInterpolator(); this.originalEvaluate = this.createdInterpolant.evaluate.bind(this.createdInterpolant); this.customEvaluate = time => { if (!this.originalEvaluate) return; const res = this.originalEvaluate(time); return evaluate(time, res); }; this.createdInterpolant.evaluate = this.customEvaluate; return this.createdInterpolant; } }; dispose() { if (this.createdInterpolant && this.originalEvaluate) { this.createdInterpolant.evaluate = this.originalEvaluate; } this.track = undefined; this.createdInterpolant = null; this.originalEvaluate = undefined; this.customEvaluate = undefined; } } /** * Handles root motion extraction from animation tracks. * Captures movement from animations and applies it to the root object. */ class RootMotionAction { private static lastObjPosition: { [key: string]: Vector3 } = {}; private static lastObjRotation: { [key: string]: Quaternion } = {}; // we remove the first keyframe rotation from the space rotation when updating private static firstKeyframeRotation: { [key: string]: Quaternion } = {}; // this is used to rotate the space on clip end / start (so the transform direction is correct) private static spaceRotation: { [key: string]: Quaternion } = {}; private static effectiveSpaceRotation: { [key: string]: Quaternion } = {}; private static clipOffsetRotation: { [key: string]: Quaternion } = {}; set action(val: AnimationAction) { this._action = val; } get action() { return this._action; } get cacheId() { return this.root.uuid; } private _action!: AnimationAction; private root: Object3D; private clip: AnimationClip; private positionWrapper: TrackEvaluationWrapper | null = null; private rotationWrapper: TrackEvaluationWrapper | null = null; private context: Context; positionChange: Vector3 = new Vector3(); rotationChange: Quaternion = new Quaternion(); constructor(context: Context, root: Object3D, clip: AnimationClip, positionTrack: KeyframeTrack | null, rotationTrack: KeyframeTrack | null) { // console.log(this, positionTrack, rotationTrack); this.context = context; this.root = root; this.clip = clip; if (!RootMotionAction.firstKeyframeRotation[this.cacheId]) RootMotionAction.firstKeyframeRotation[this.cacheId] = new Quaternion(); if (rotationTrack) { const values = rotationTrack.values; RootMotionAction.firstKeyframeRotation[this.cacheId] .set(values[0], values[1], values[2], values[3]) } if (!RootMotionAction.spaceRotation[this.cacheId]) RootMotionAction.spaceRotation[this.cacheId] = new Quaternion(); if (!RootMotionAction.effectiveSpaceRotation[this.cacheId]) RootMotionAction.effectiveSpaceRotation[this.cacheId] = new Quaternion(); RootMotionAction.clipOffsetRotation[this.cacheId] = new Quaternion(); if (rotationTrack) { RootMotionAction.clipOffsetRotation[this.cacheId] .set(rotationTrack.values[0], rotationTrack.values[1], rotationTrack.values[2], rotationTrack.values[3]) .invert(); } this.handlePosition(clip, positionTrack); this.handleRotation(clip, rotationTrack); } onStart(action: AnimationAction) { if (action.getClip() !== this.clip) return; if (!RootMotionAction.lastObjRotation[this.cacheId]) { RootMotionAction.lastObjRotation[this.cacheId] = this.root.quaternion.clone() } const lastRotation = RootMotionAction.lastObjRotation[this.cacheId]; // const firstKeyframe = RootMotionAction.firstKeyframeRotation[this.this.cacheId]; // lastRotation.invert().premultiply(firstKeyframe).invert(); RootMotionAction.spaceRotation[this.cacheId].copy(lastRotation); if (debugRootMotion) { const euler = new Euler().setFromQuaternion(lastRotation); console.log("START", this.clip.name, Mathf.toDegrees(euler.y), this.root.position.z); } } private getClipRotationOffset() { return RootMotionAction.clipOffsetRotation[this.cacheId]; } private _prevTime = 0; private handlePosition(_clip: AnimationClip, track: KeyframeTrack | null) { if (track) { const root = this.root; if (debugRootMotion) root.add(new AxesHelper()); if (!RootMotionAction.lastObjPosition[this.cacheId]) RootMotionAction.lastObjPosition[this.cacheId] = this.root.position.clone(); const valuesDiff = new Vector3(); const valuesPrev = new Vector3(); // const rotation = new Quaternion(); this.positionWrapper = new TrackEvaluationWrapper(track, (time, value: Float64Array) => { const weight = this.action.getEffectiveWeight(); // reset for testing if (debugRootMotion) { if (root.position.length() > 8) root.position.set(0, root.position.y, 0); } if (time > this._prevTime) { valuesDiff.set(value[0], value[1], value[2]); valuesDiff.sub(valuesPrev); valuesDiff.multiplyScalar(weight); valuesDiff.applyQuaternion(this.getClipRotationOffset()); // RootMotionAction.effectiveSpaceRotation[id].slerp(RootMotionAction.spaceRotation[id], weight); valuesDiff.applyQuaternion(root.quaternion); this.positionChange.copy(valuesDiff); // this.root.position.add(valuesDiff); } valuesPrev.fromArray(value); this._prevTime = time; value[0] = 0; value[1] = 0; value[2] = 0; return value; }); } } private static identityQuaternion = new Quaternion(); private handleRotation(clip: AnimationClip, track: KeyframeTrack | null) { if (track) { if (debugRootMotion) { const arr = track.values; const firstKeyframe = new Euler().setFromQuaternion(new Quaternion(arr[0], arr[1], arr[2], arr[3])); console.log(clip.name, track.name, "FIRST ROTATION IN TRACK", Mathf.toDegrees(firstKeyframe.y)); const i = track.values.length - 4; const lastKeyframe = new Quaternion().set(arr[i], arr[i + 1], arr[i + 2], arr[i + 3]); const euler = new Euler().setFromQuaternion(lastKeyframe); console.log(clip.name, track.name, "LAST ROTATION IN TRACK", Mathf.toDegrees(euler.y)); } // if (!RootMotionAction.lastObjRotation[root.uuid]) RootMotionAction.lastObjRotation[root.uuid] = new Quaternion(); // const temp = new Quaternion(); let prevTime: number = 0; const valuesPrev = new Quaternion(); const valuesDiff = new Quaternion(); // const summedRot = new Quaternion(); this.rotationWrapper = new TrackEvaluationWrapper(track, (time, value: Float64Array) => { // root.quaternion.copy(RootMotionAction.lastObjRotation[root.uuid]); if (time > prevTime) { valuesDiff.set(value[0], value[1], value[2], value[3]); valuesPrev.invert(); valuesDiff.multiply(valuesPrev); // if(weight < .99) valuesDiff.slerp(RootMotionAction.identityQuaternion, 1 - weight); this.rotationChange.copy(valuesDiff); // root.quaternion.multiply(valuesDiff); } // else // root.quaternion.multiply(this.getClipRotationOffset()); // RootMotionAction.lastObjRotation[root.uuid].copy(root.quaternion); valuesPrev.fromArray(value); prevTime = time; value[0] = 0; value[1] = 0; value[2] = 0; value[3] = 1; return value; }); } } // private lastPos: Vector3 = new Vector3(); onBeforeUpdate(_weight: number) { this.positionChange.set(0, 0, 0); this.rotationChange.set(0, 0, 0, 1); } onAfterUpdate(weight: number): boolean { if (!this.action) return false; weight *= this.action.getEffectiveWeight(); if (weight <= 0) return false; this.positionChange.multiplyScalar(weight); this.rotationChange.slerp(RootMotionAction.identityQuaternion, 1 - weight); return true; } } /** * Manages root motion for a character. * Extracts motion from animation tracks and applies it to the character's transform. */ class RootMotionHandler { private controller: AnimatorController; private handler: RootMotionAction[] = []; private root!: Object3D; private basePosition: Vector3 = new Vector3(); private baseQuaternion: Quaternion = new Quaternion(); private baseRotation: Euler = new Euler(); constructor(controller: AnimatorController) { this.controller = controller; } createClip(mixer: AnimationMixer, root: Object3D, clip: AnimationClip): AnimationAction { this.root = root; let rootName = ""; if (root && "name" in root) { rootName = root.name; } const positionTrack = this.findRootTrack(clip, ".position"); const rotationTrack = this.findRootTrack(clip, ".quaternion"); const handler = new RootMotionAction(this.controller.context!, root, clip, positionTrack, rotationTrack); this.handler.push(handler); // it is important we do this after the handler is created // otherwise we can not hook into threejs interpolators const action = mixer.clipAction(clip); handler.action = action; return action; } onStart(action: AnimationAction) { for (const handler of this.handler) { handler.onStart(action); } } onBeforeUpdate(weight: number) { // capture the position of the object this.basePosition.copy(this.root.position); this.baseQuaternion.copy(this.root.quaternion); for (const hand of this.handler) hand.onBeforeUpdate(weight); } private summedPosition: Vector3 = new Vector3(); private summedRotation: Quaternion = new Quaternion(); onAfterUpdate(weight: number) { if (weight <= 0) return; // TODO: blend weight properly with root motion (when using timeline blending with animator) // apply the accumulated changes this.root.position.copy(this.basePosition); this.root.quaternion.copy(this.baseQuaternion); this.summedPosition.set(0, 0, 0); this.summedRotation.set(0, 0, 0, 1); for (const entry of this.handler) { if (entry.onAfterUpdate(weight)) { this.summedPosition.add(entry.positionChange); this.summedRotation.multiply(entry.rotationChange); } } this.root.position.add(this.summedPosition); this.root.quaternion.multiply(this.summedRotation); // RootMotionAction.lastObjRotation[this.root.uuid].copy(this.root.quaternion); } private findRootTrack(clip: AnimationClip, name: string) { const tracks = clip.tracks; if (!tracks) return null; for (const track of tracks) { if (track.name.endsWith(name)) { return track; } } return null; } } /** * Serialization handler for AnimatorController instances. * Handles conversion between serialized data and runtime objects. */ class AnimatorControllerSerializator extends TypeSerializer { onSerialize(_: any, _context: SerializationContext) { } onDeserialize(data: AnimatorControllerModel & { __type?: string }, context: SerializationContext) { if (context.type === AnimatorController && data?.__type === "AnimatorController") return new AnimatorController(data); return undefined; } } new AnimatorControllerSerializator(AnimatorController);