@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
1,209 lines (1,066 loc) • 53 kB
text/typescript
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): AnimationActio