diff --git a/EngineErrorMap.md b/EngineErrorMap.md index 4a7d42772bc..ebfe59171df 100644 --- a/EngineErrorMap.md +++ b/EngineErrorMap.md @@ -3141,3 +3141,7 @@ Can not encode CCON binary: lack of text encoder. ### 13104 Can not decode CCON binary: lack of text decoder. + +### 14000 + +Graph update has been interrupted since too many transitions(greater than %s) occurred during one frame. diff --git a/cocos/3d/skeletal-animation/skeletal-animation-blending.ts b/cocos/3d/skeletal-animation/skeletal-animation-blending.ts index 80eab119360..11b864be739 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation-blending.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation-blending.ts @@ -28,9 +28,11 @@ * @hidden */ -import { Vec3, Quat } from '../../core/math'; +import { DEBUG } from 'internal:constants'; +import { Vec3, Quat, approx } from '../../core/math'; import { Node } from '../../core/scene-graph'; import { RuntimeBinding } from '../../core/animation/tracks/track'; +import { assertIsTrue } from '../../core/data/utils/asserts'; export class BlendStateBuffer { private _nodeBlendStates: Map = new Map(); @@ -161,6 +163,9 @@ class Vec3PropertyBlendState extends PropertyBlendState { Vec3.scaleAndAdd(blendedValue, blendedValue, value, weight); } this.blendedWeight += weight; + if (DEBUG && this.blendedWeight > 1.0) { + assertIsTrue(approx(this.blendedWeight, 1.0, 1e-6)); + } } public reset () { @@ -186,6 +191,9 @@ class QuatPropertyBlendState extends PropertyBlendState { Quat.slerp(blendedValue, blendedValue, value, t); } this.blendedWeight += weight; + if (DEBUG && this.blendedWeight > 1.0) { + assertIsTrue(approx(this.blendedWeight, 1.0, 1e-6)); + } } public reset () { diff --git a/cocos/core/animation/animation-state.ts b/cocos/core/animation/animation-state.ts index d7569ee0942..9e0ca9b802d 100644 --- a/cocos/core/animation/animation-state.ts +++ b/cocos/core/animation/animation-state.ts @@ -37,7 +37,9 @@ import { legacyCC } from '../global-exports'; import { ccenum } from '../value-types/enum'; import { assertIsNonNullable, assertIsTrue } from '../data/utils/asserts'; import { debug } from '../platform/debug'; +import { SkeletonMask } from './skeleton-mask'; import { PoseOutput } from './pose-output'; +import { BlendStateBuffer } from '../../3d/skeletal-animation/skeletal-animation-blending'; /** * @en The event type supported by Animation @@ -322,7 +324,7 @@ export class AnimationState extends Playable { return this._curveLoaded; } - public initialize (root: Node) { + public initialize (root: Node, blendStateBuffer?: BlendStateBuffer, mask?: SkeletonMask) { if (this._curveLoaded) { return; } this._curveLoaded = true; if (this._poseOutput) { @@ -352,7 +354,7 @@ export class AnimationState extends Playable { } if (!this._doNotCreateEval) { - const pose = legacyCC.director.getAnimationManager()?.blendState ?? null; + const pose = blendStateBuffer ?? legacyCC.director.getAnimationManager()?.blendState ?? null; if (pose) { this._poseOutput = new PoseOutput(pose); } diff --git a/cocos/core/animation/animation.ts b/cocos/core/animation/animation.ts index 951edea938f..626de606856 100644 --- a/cocos/core/animation/animation.ts +++ b/cocos/core/animation/animation.ts @@ -40,3 +40,4 @@ export { QuatTrack } from './tracks/quat-track'; export { ColorTrack } from './tracks/color-track'; export { SizeTrack } from './tracks/size-track'; export { ObjectTrack } from './tracks/object-track'; +export * from './marionette'; diff --git a/cocos/core/animation/kinematics/ccd.ts b/cocos/core/animation/kinematics/ccd.ts new file mode 100644 index 00000000000..335ead5177d --- /dev/null +++ b/cocos/core/animation/kinematics/ccd.ts @@ -0,0 +1,110 @@ + +import { assertIsTrue } from '../../data/utils/asserts'; +import { Quat } from '../../math/quat'; +import { clamp } from '../../math/utils'; +import { Vec3 } from '../../math/vec3'; +import { Node } from '../../scene-graph/node'; + +const THETA_ERROR = 0.001; +const DUMP_BIAS = 1.0; + +enum IterationResult { + UNFINISHED, + DONE, + INTERRUPTED, +} + +/** + * The Cyclic Coordinate Descent algorithm. + * @param links The links(limbs). + * @param target Target position. + * @param maxIterations Max iterations. + * @param forward True if use forward iteration(base to leaf), otherwise use backward iteration(leaf to base). + */ +export function ccdIK( + links: Node[], + target: Vec3, + epsilon: number, + maxIterations: number, + forward: boolean, +) { + const nLinks = links.length; + if (nLinks < 2) { + return; + } + + const u = new Vec3(); // Vector from end factor to current link + const v = new Vec3(); // Vector from target to current link + const axis = new Vec3(); // Intermediate var + const correctiveRot = new Quat(); + const currentPos = new Vec3(); + const currentRot = new Quat(); + const endFactorPos = new Vec3(); + + const iEndFactor = links.length - 1; + const endFactor = links[iEndFactor]; + if (forward) { + for (let iteration = 0; iteration < maxIterations; ++iteration) { + // Won't run in infinite loop since we have `nLinks >= 2` + for (let iLink = 0; iLink < iEndFactor; ++iLink) { + const result = correct(iLink); + if (result === IterationResult.INTERRUPTED) { + break; + } else if (result === IterationResult.DONE) { + return; + } + } + } + } else { + for (let iteration = 0; iteration < maxIterations; ++iteration) { + // Won't run in infinite loop since we have `nLinks >= 2` + for (let iLink = iEndFactor - 1; iLink >= 0; --iLink) { + const result = correct(iLink); + if (result === IterationResult.INTERRUPTED) { + break; + } else if (result === IterationResult.DONE) { + return; + } + } + } + } + + function correct (linkIndex: number): IterationResult { + const current = links[linkIndex]; + + current.getWorldPosition(currentPos); + endFactor.getWorldPosition(endFactorPos); + + Vec3.subtract(u, endFactorPos, currentPos); + Vec3.normalize(u, u); + Vec3.subtract(v, target, currentPos); + Vec3.normalize(v, v); + + // TODO: what if axis is zero? + Vec3.cross(axis, u, v); + Vec3.normalize(axis, axis); + + const cosTheta = Vec3.dot(u, v); + const theta = Math.acos(cosTheta) * DUMP_BIAS; + + // Refresh hierarchy + Quat.fromAxisAngle(correctiveRot, axis, theta); + current.getWorldRotation(currentRot); + Quat.multiply(currentRot, correctiveRot, currentRot); + current.setWorldRotation(currentRot); + endFactor.getWorldPosition(endFactorPos); + + // Try + const distance = Vec3.distance(endFactorPos, target); + if (distance < epsilon) { + return IterationResult.DONE; + } + + // If the link’s corrective rotations exceeds the tolerance-redo other links. + if (theta > THETA_ERROR) { + return IterationResult.INTERRUPTED; + } + + return IterationResult.UNFINISHED; + } +} diff --git a/cocos/core/animation/marionette/__tmp__/get-demo-graphs.ts b/cocos/core/animation/marionette/__tmp__/get-demo-graphs.ts new file mode 100644 index 00000000000..71b724f81e7 --- /dev/null +++ b/cocos/core/animation/marionette/__tmp__/get-demo-graphs.ts @@ -0,0 +1,168 @@ +import { GraphDescription } from './graph-description'; +import { AnimationGraph } from '../animation-graph'; +import { createGraphFromDescription } from './graph-from-description'; + +export function __getDemoGraphs () { + return Object.entries(graphDescMap).reduce((result, [name, graphDesc]) => { + result[name] = createGraphFromDescription(graphDesc); + return result; + }, {} as Record); +} + +const graphDescMap: Record = { + 'any-transition': { + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'Node1', + type: 'animation', + }], + anyTransitions: [{ + to: 0, + }], + }, + }], + }, + + vars: { + vars: [ + { name: 'foo', value: 1.0 }, + { name: 'bar', value: false }, + ], + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'animation', + }], + anyTransitions: [{ + to: 0, + }], + transitions: [{ + from: 0, + to: 1, + conditions: [{ + type: 'binary', + lhs: 'foo', + operator: 'EQUAL', + rhs: 2.0, + }], + }], + }, + }], + }, + + 'pose-blend-requires-numbers': { + vars: [{ + name: 'v', + value: false, + }], + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'Node1', + type: 'animation', + motion: { + type: 'blend', + children: [{ type: 'clip' }, { type: 'clip' }], + blender: { + type: '1d', + thresholds: [0.0, 1.0], + value: { + name: 'v', + value: 0, + }, + }, + }, + }], + }, + }], + }, + + 'successive-satisfaction': { + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + transitions: [{ + from: 0, + to: 1, + }], + }, + }], + }, + + 'unspecified-condition': { + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'asd', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + }, + }], + }, + + 'variable-not-found-in-condition': { + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + type: 'animation', + name: 'Node1', + }], + entryTransitions: [{ + to: 0, + conditions: [{ + type: 'unary', + operator: 'TRUTHY', + operand: { name: 'asd', value: 0.0 }, + }], + }], + }, + }], + }, + + 'variable-not-found-in-pose-blend': { + layers: [{ + graph: { + type: 'state-machine', + nodes: [{ + name: 'Node1', + type: 'animation', + motion: { + type: 'blend', + children: [{ type: 'clip' }, { type: 'clip' }], + blender: { + type: '1d', + thresholds: [0.0, 1.0], + value: { + name: 'asd', + value: 0, + }, + }, + }, + }], + }, + }], + }, +}; diff --git a/cocos/core/animation/marionette/__tmp__/graph-description.ts b/cocos/core/animation/marionette/__tmp__/graph-description.ts new file mode 100644 index 00000000000..1873f852fa2 --- /dev/null +++ b/cocos/core/animation/marionette/__tmp__/graph-description.ts @@ -0,0 +1,93 @@ +export interface GraphDescription { + vars?: Array<{ + name: string; + value: string | boolean | number; + }>; + + layers: Array; +} + +export interface LayerDescription { + graph: StateMachineDescription; +} + +export interface StateDesc { + name?: string; +} + +export interface MotionStateDesc extends StateDesc { + type: 'animation'; + motion?: MotionDescription; +} + +export interface StateMachineDescription extends StateDesc { + type: 'state-machine'; + + nodes?: Array; + + entryTransitions?: Array<{ + to: number; + } & TransitionDescriptionBase>; + + exitTransitions?: Array<{ + from: number; + } & AnimationTransitionDescription>; + + anyTransitions?: Array<{ + to: number; + } & TransitionDescriptionBase>; + + transitions?: Array; +} + +export interface AnimationTransitionDescription extends TransitionDescriptionBase { + from: number; + to: number; + duration?: number; + exitCondition?: number; +} + +export interface TransitionDescriptionBase { + conditions?: ConditionDescription[]; +} + +export type ConditionDescription = { + type: 'unary'; + operator: 'TRUTHY' | 'FALSY'; + operand: ParametricDescription; +} | { + type: 'binary'; + operator: 'EQUAL' | 'NOT_EQUAL' | 'LESS_THAN' | 'LESS_THAN_OR_EQUAL_TO' | 'GREATER_THAN' | 'GREATER_THAN_OR_EQUAL_TO'; + lhs: ParametricDescription; + rhs: ParametricDescription; +} | { + type: 'trigger'; +}; + +export type ValueDescription = string | number | boolean; + +export type MotionDescription = ClipMotionDescription | AnimationBlendDescription; + +export interface ClipMotionDescription { + type: 'clip'; +} + +export interface AnimationBlendDescription { + type: 'blend'; + children: MotionDescription[]; + blender: { + type: '1d'; + thresholds: number[]; + value: ParametricDescription; + } | { + type: '2d'; + algorithm: 'simpleDirectional' | 'freeformCartesian' | 'freeformDirectional'; + thresholds: Array<{ x: number; y: number; }>; + values: [ParametricDescription, ParametricDescription]; + }; +} + +export type ParametricDescription = T | { + name: string; + value: T; +} diff --git a/cocos/core/animation/marionette/__tmp__/graph-from-description.ts b/cocos/core/animation/marionette/__tmp__/graph-from-description.ts new file mode 100644 index 00000000000..5ca67694d02 --- /dev/null +++ b/cocos/core/animation/marionette/__tmp__/graph-from-description.ts @@ -0,0 +1,173 @@ +import { Vec2 } from '../../../math/vec2'; +import { AnimationGraph, State, StateMachine, AnimationTransition } from '../animation-graph'; +import { Condition, BinaryCondition, TriggerCondition, UnaryCondition } from '../condition'; +import { ClipMotion } from '../clip-motion'; +import { + GraphDescription, + MotionDescription, + TransitionDescriptionBase, + StateMachineDescription, + ParametricDescription, + AnimationTransitionDescription, +} from './graph-description'; +import { AnimationBlend1D } from '../animation-blend-1d'; +import { AnimationBlend2D } from '../animation-blend-2d'; + +import { Motion } from '../motion'; +import { Bindable, VariableType } from '../parametric'; +import { Value } from '../variable'; +import { MotionState } from '../motion-state'; + +export function createGraphFromDescription (graphDescription: GraphDescription) { + const graph = new AnimationGraph(); + + if (graphDescription.vars) { + for (const varDesc of graphDescription.vars) { + graph.addVariable(varDesc.name, getVariableTypeFromValue(varDesc.value), varDesc.value); + } + } + + for (const layerDesc of graphDescription.layers) { + const layer = graph.addLayer(); + createSubgraph(layer.stateMachine, layerDesc.graph); + } + return graph; +} + +function createSubgraph (subgraph: StateMachine, subgraphDesc: StateMachineDescription) { + const nodes = subgraphDesc.nodes?.map((nodeDesc) => { + let node: State; + if (nodeDesc.type === 'animation') { + const animationState = subgraph.addMotion(); + if (nodeDesc.motion) { + animationState.motion = createMotion(nodeDesc.motion); + } + node = animationState; + } else { + const subSubgraph = subgraph.addSubStateMachine(); + createSubgraph(subgraph, nodeDesc); + node = subSubgraph; + } + if (nodeDesc.name) { + node.name = nodeDesc.name; + } + return node; + }) ?? []; + + if (subgraphDesc.entryTransitions) { + for (const transitionDesc of subgraphDesc.entryTransitions) { + createTransition(subgraph, subgraph.entryState, nodes[transitionDesc.to], transitionDesc); + } + } + if (subgraphDesc.exitTransitions) { + for (const transitionDesc of subgraphDesc.exitTransitions) { + createAnimationTransition(subgraph, nodes[transitionDesc.from] as MotionState, subgraph.exitState, transitionDesc); + } + } + if (subgraphDesc.anyTransitions) { + for (const transitionDesc of subgraphDesc.anyTransitions) { + createTransition(subgraph, subgraph.entryState, nodes[transitionDesc.to], transitionDesc); + } + } + if (subgraphDesc.transitions) { + for (const transitionDesc of subgraphDesc.transitions) { + createAnimationTransition(subgraph, nodes[transitionDesc.from] as MotionState, nodes[transitionDesc.to], transitionDesc); + } + } +} + +function createTransition (graph: StateMachine, from: State, to: State, transitionDesc: TransitionDescriptionBase) { + let condition: Condition | undefined; + const conditions = transitionDesc.conditions?.map((conditionDesc) => { + switch (conditionDesc.type) { + default: + throw new Error(`Unknown condition type.`); + case 'unary': { + const condition = new UnaryCondition(); + condition.operator = UnaryCondition.Operator[conditionDesc.type]; + createParametric(conditionDesc.operand, condition.operand); + return condition; + } + case 'binary': { + const condition = new BinaryCondition(); + condition.operator = BinaryCondition.Operator[conditionDesc.type]; + createParametric(conditionDesc.lhs, condition.lhs); + createParametric(conditionDesc.rhs, condition.rhs); + return condition; + } + case 'trigger': { + const condition = new TriggerCondition(); + return condition; + } + } + }); + const transition = graph.connect(from, to, conditions); + return transition; +} + +function createAnimationTransition (graph: StateMachine, from: MotionState, to: State, descriptor: AnimationTransitionDescription) { + const transition = createTransition(graph, from, to, descriptor) as unknown as AnimationTransition; + + const { + duration, + exitCondition, + } = descriptor; + + transition.duration = duration ?? 0.0; + + transition.exitConditionEnabled = false; + if (typeof exitCondition !== 'undefined') { + transition.exitConditionEnabled = true; + transition.exitCondition = exitCondition; + } + return transition; +} + +function createMotion (motionDesc: MotionDescription): Motion { + if (motionDesc.type === 'clip') { + const motion = new ClipMotion(); + return motion; + } else if (motionDesc.blender.type === '1d') { + const motion = new AnimationBlend1D(); + const thresholds = motionDesc.blender.thresholds; + motion.items = motionDesc.children.map((childMotionDesc, iMotion) => { + const item = new AnimationBlend1D.Item(); + item.motion = createMotion(childMotionDesc); + item.threshold = thresholds[iMotion]; + return item; + }); + createParametric(motionDesc.blender.value, motion.param); + return motion; + } else { + const algorithm = AnimationBlend2D.Algorithm[motionDesc.blender.algorithm]; + const motion = new AnimationBlend2D(); + motion.algorithm = algorithm; + const thresholds = motionDesc.blender.thresholds; + motion.items = motionDesc.children.map((childMotionDesc, iMotion) => { + const item = new AnimationBlend2D.Item(); + item.motion = createMotion(childMotionDesc); + item.threshold = new Vec2(thresholds[iMotion].x, thresholds[iMotion].y); + return item; + }); + createParametric(motionDesc.blender.values[0], motion.paramX); + createParametric(motionDesc.blender.values[1], motion.paramY); + return motion; + } +} + +function createParametric (paramDesc: ParametricDescription, bindable: Bindable) { + if (typeof paramDesc === 'object') { + bindable.variable = paramDesc.name; + bindable.value = paramDesc.value; + } else { + bindable.value = paramDesc; + } +} + +function getVariableTypeFromValue (value: Value) { + switch (true) { + case typeof value === 'boolean': return VariableType.BOOLEAN; + case typeof value === 'number': return VariableType.NUMBER; + default: throw new Error(`Unknown variable type.`); + } +} diff --git a/cocos/core/animation/marionette/animation-blend-1d.ts b/cocos/core/animation/marionette/animation-blend-1d.ts new file mode 100644 index 00000000000..fe47cc8875c --- /dev/null +++ b/cocos/core/animation/marionette/animation-blend-1d.ts @@ -0,0 +1,86 @@ +import { serializable } from 'cc.decorator'; +import { ccclass } from '../../data/class-decorator'; +import { createEval } from './create-eval'; +import { BindableNumber, bindOr, VariableType } from './parametric'; +import { Motion, MotionEval, MotionEvalContext } from './motion'; +import { AnimationBlend, AnimationBlendEval, AnimationBlendItem, validateBlendParam } from './animation-blend'; +import { blend1D } from './blend-1d'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationBlend1DItem`) +class AnimationBlend1DItem extends AnimationBlendItem { + @serializable + public threshold = 0.0; + + public clone () { + const that = new AnimationBlend1DItem(); + this._assign(that); + return that; + } + + protected _assign (that: AnimationBlend1DItem) { + super._assign(that); + that.threshold = this.threshold; + return that; + } +} + +@ccclass('cc.animation.AnimationBlend1D') +export class AnimationBlend1D extends AnimationBlend { + public static Item = AnimationBlend1DItem; + + @serializable + private _items: AnimationBlend1DItem[] = []; + + @serializable + public param = new BindableNumber(); + + get items (): Iterable { + return this._items; + } + + set items (value) { + this._items = Array.from(value) + .sort(({ threshold: lhs }, { threshold: rhs }) => lhs - rhs); + } + + public clone () { + const that = new AnimationBlend1D(); + that._items = this._items.map((item) => item.clone()); + that.param = this.param.clone(); + return that; + } + + public [createEval] (context: MotionEvalContext) { + const evaluation = new AnimationBlend1DEval(context, this._items, this._items.map(({ threshold }) => threshold), 0.0); + const initialValue = bindOr( + context, + this.param, + VariableType.NUMBER, + evaluation.setInput, + evaluation, + 0, + ); + evaluation.setInput(initialValue, 0); + return evaluation; + } +} + +export declare namespace AnimationBlend1D { + export type Item = AnimationBlend1DItem; +} + +class AnimationBlend1DEval extends AnimationBlendEval { + private declare _thresholds: readonly number[]; + + constructor (context: MotionEvalContext, items: AnimationBlendItem[], thresholds: readonly number[], input: number) { + super(context, items, [input]); + this._thresholds = thresholds; + this.doEval(); + } + + protected eval (weights: number[], [value]: readonly [number]) { + blend1D(weights, this._thresholds, value); + } +} diff --git a/cocos/core/animation/marionette/animation-blend-2d.ts b/cocos/core/animation/marionette/animation-blend-2d.ts new file mode 100644 index 00000000000..a9f2d1a4c29 --- /dev/null +++ b/cocos/core/animation/marionette/animation-blend-2d.ts @@ -0,0 +1,138 @@ +import { Vec2 } from '../../math'; +import { property, ccclass } from '../../data/class-decorator'; +import { ccenum } from '../../value-types/enum'; +import { createEval } from './create-eval'; +import { AnimationBlend, AnimationBlendEval, AnimationBlendItem, validateBlendParam } from './animation-blend'; +import { Motion, MotionEvalContext } from './motion'; +import { serializable, type } from '../../data/decorators'; +import { BindableNumber, bindOr, VariableType } from './parametric'; +import { sampleFreeformCartesian, sampleFreeformDirectional, blendSimpleDirectional } from './blend-2d'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; + +enum Algorithm { + SIMPLE_DIRECTIONAL, + FREEFORM_CARTESIAN, + FREEFORM_DIRECTIONAL, +} + +ccenum(Algorithm); + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationBlend2DItem`) +class AnimationBlend2DItem extends AnimationBlendItem { + @serializable + public threshold = new Vec2(); + + public clone () { + const that = new AnimationBlend2DItem(); + this._assign(that); + return that; + } + + protected _assign (that: AnimationBlend2DItem) { + super._assign(that); + Vec2.copy(that.threshold, this.threshold); + return that; + } +} + +@ccclass('cc.animation.AnimationBlend2D') +export class AnimationBlend2D extends EditorExtendable implements AnimationBlend { + public static Algorithm = Algorithm; + + public static Item = AnimationBlend2DItem; + + @serializable + public algorithm = Algorithm.SIMPLE_DIRECTIONAL; + + @serializable + private _items: AnimationBlend2DItem[] = []; + + @serializable + public paramX = new BindableNumber(); + + @serializable + public paramY = new BindableNumber(); + + get items (): Iterable { + return this._items; + } + + set items (items) { + this._items = Array.from(items); + } + + public clone () { + const that = new AnimationBlend2D(); + that._items = this._items.map((item) => item?.clone() ?? null); + that.paramX = this.paramX.clone(); + that.paramY = this.paramY.clone(); + return that; + } + + public [createEval] (context: MotionEvalContext) { + const evaluation = new AnimationBlend2DEval(context, this._items, this._items.map(({ threshold }) => threshold), this.algorithm, [0.0, 0.0]); + const initialValueX = bindOr( + context, + this.paramX, + VariableType.NUMBER, + evaluation.setInput, + evaluation, + 0, + ); + const initialValueY = bindOr( + context, + this.paramY, + VariableType.NUMBER, + evaluation.setInput, + evaluation, + 1, + ); + evaluation.setInput(initialValueX, 0); + evaluation.setInput(initialValueY, 1); + return evaluation; + } +} + +export declare namespace AnimationBlend2D { + export type Algorithm = typeof Algorithm; + + export type Item = AnimationBlend2DItem; +} + +class AnimationBlend2DEval extends AnimationBlendEval { + private _thresholds: readonly Vec2[]; + private _algorithm: Algorithm; + private _value = new Vec2(); + + constructor ( + context: MotionEvalContext, + items: AnimationBlendItem[], + thresholds: readonly Vec2[], + algorithm: Algorithm, + inputs: [number, number], + ) { + super(context, items, inputs); + this._thresholds = thresholds; + this._algorithm = algorithm; + this.doEval(); + } + + protected eval (weights: number[], [x, y]: [number, number]) { + Vec2.set(this._value, x, y); + weights.fill(0); + switch (this._algorithm) { + case Algorithm.SIMPLE_DIRECTIONAL: + blendSimpleDirectional(weights, this._thresholds, this._value); + break; + case Algorithm.FREEFORM_CARTESIAN: + sampleFreeformCartesian(weights, this._thresholds, this._value); + break; + case Algorithm.FREEFORM_DIRECTIONAL: + sampleFreeformDirectional(weights, this._thresholds, this._value); + break; + default: + break; + } + } +} diff --git a/cocos/core/animation/marionette/animation-blend-direct.ts b/cocos/core/animation/marionette/animation-blend-direct.ts new file mode 100644 index 00000000000..71d5522dce7 --- /dev/null +++ b/cocos/core/animation/marionette/animation-blend-direct.ts @@ -0,0 +1,74 @@ +import { serializable } from 'cc.decorator'; +import { ccclass } from '../../data/class-decorator'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { createEval } from './create-eval'; +import { Motion, MotionEval, MotionEvalContext } from './motion'; +import { AnimationBlend, AnimationBlendEval, AnimationBlendItem } from './animation-blend'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationBlendDirectItem`) +class AnimationBlendDirectItem extends AnimationBlendItem { + @serializable + public weight = 0.0; + + public clone () { + const that = new AnimationBlendDirectItem(); + this._assign(that); + return that; + } + + protected _assign (that: AnimationBlendDirectItem) { + super._assign(that); + that.weight = this.weight; + return that; + } +} + +@ccclass('cc.animation.AnimationBlendDirect') +export class AnimationBlendDirect extends EditorExtendable implements AnimationBlend { + public static Item = AnimationBlendDirectItem; + + @serializable + private _items: AnimationBlendDirectItem[] = []; + + get items () { + return this._items; + } + + set items (value) { + this._items = Array.from(value); + } + + public clone () { + const that = new AnimationBlendDirect(); + that._items = this._items.map((item) => item?.clone() ?? null); + return that; + } + + public [createEval] (context: MotionEvalContext) { + const myEval = new AnimationBlendDirectEval( + context, + this._items, + this._items.map(({ weight }) => weight), + ); + return myEval; + } +} + +export declare namespace AnimationBlendDirect { + export type Item = AnimationBlendDirectItem; +} + +class AnimationBlendDirectEval extends AnimationBlendEval { + constructor (...args: ConstructorParameters) { + super(...args); + this.doEval(); + } + + protected eval (weights: number[], inputs: readonly number[]) { + const nChildren = weights.length; + for (let iChild = 0; iChild < nChildren; ++iChild) { + weights[iChild] = inputs[iChild]; + } + } +} diff --git a/cocos/core/animation/marionette/animation-blend.ts b/cocos/core/animation/marionette/animation-blend.ts new file mode 100644 index 00000000000..1fc29c9620d --- /dev/null +++ b/cocos/core/animation/marionette/animation-blend.ts @@ -0,0 +1,111 @@ +import { ccclass } from '../../data/class-decorator'; +import { MotionEvalContext, Motion, MotionEval } from './motion'; +import { Value } from './variable'; +import { createEval } from './create-eval'; +import { VariableTypeMismatchedError } from './errors'; +import { serializable } from '../../data/decorators'; +import { ClipStatus } from './graph-eval'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; + +export interface AnimationBlend extends Motion, EditorExtendable { + [createEval] (_context: MotionEvalContext): MotionEval | null; +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationBlendItem`) +export class AnimationBlendItem { + @serializable + public motion: Motion | null = null; + + public clone () { + const that = new AnimationBlendItem(); + this._assign(that); + return that; + } + + protected _assign (that: AnimationBlendItem) { + that.motion = this.motion?.clone() ?? null; + return that; + } +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationBlend`) +export class AnimationBlend extends EditorExtendable implements Motion { +} + +export class AnimationBlendEval implements MotionEval { + private declare _childEvaluators: (MotionEval | null)[]; + private declare _weights: number[]; + private declare _inputs: number[]; + + constructor ( + context: MotionEvalContext, + children: AnimationBlendItem[], + inputs: number[], + ) { + this._childEvaluators = children.map((child) => child.motion?.[createEval](context) ?? null); + this._weights = new Array(this._childEvaluators.length).fill(0); + this._inputs = [...inputs]; + } + + get duration () { + let uniformDuration = 0.0; + for (let iChild = 0; iChild < this._childEvaluators.length; ++iChild) { + uniformDuration += (this._childEvaluators[iChild]?.duration ?? 0.0) * this._weights[iChild]; + } + return uniformDuration; + } + + public getClipStatuses (baseWeight: number): Iterator { + const { _childEvaluators: children, _weights: weights } = this; + const nChildren = children.length; + let iChild = 0; + let currentChildIterator: Iterator | undefined; + return { + next () { + // eslint-disable-next-line no-constant-condition + while (true) { + if (currentChildIterator) { + const result = currentChildIterator.next(); + if (!result.done) { + return result; + } + } + if (iChild >= nChildren) { + return { done: true, value: undefined }; + } else { + const child = children[iChild]; + currentChildIterator = child?.getClipStatuses(baseWeight * weights[iChild]); + ++iChild; + } + } + }, + }; + } + + public sample (progress: number, weight: number) { + for (let iChild = 0; iChild < this._childEvaluators.length; ++iChild) { + this._childEvaluators[iChild]?.sample(progress, weight * this._weights[iChild]); + } + } + + public setInput (value: number, index: number) { + this._inputs[index] = value; + this.doEval(); + } + + protected doEval () { + this.eval(this._weights, this._inputs); + } + + protected eval (_weights: number[], _inputs: readonly number[]) { + + } +} + +export function validateBlendParam (val: unknown, name: string): asserts val is number { + if (typeof val !== 'number') { + // TODO var name? + throw new VariableTypeMismatchedError(name, 'number'); + } +} diff --git a/cocos/core/animation/marionette/animation-controller.ts b/cocos/core/animation/marionette/animation-controller.ts new file mode 100644 index 00000000000..dd00aca411b --- /dev/null +++ b/cocos/core/animation/marionette/animation-controller.ts @@ -0,0 +1,67 @@ +import { Component } from '../../components'; +import { AnimationGraph } from './animation-graph'; +import { property, ccclass, menu } from '../../data/class-decorator'; +import { AnimationGraphEval, StateStatus, TransitionStatus, ClipStatus } from './graph-eval'; +import { Value } from './variable'; +import { assertIsNonNullable } from '../../data/utils/asserts'; + +export type { + StateStatus, + ClipStatus, + TransitionStatus, +}; + +@ccclass('cc.animation.AnimationController') +@menu('Components/Animation/Animation Controller') +export class AnimationController extends Component { + @property(AnimationGraph) + public graph: AnimationGraph | null = null; + + private _graphEval: AnimationGraphEval | null = null; + + public start () { + if (this.graph) { + this._graphEval = new AnimationGraphEval(this.graph, this.node, this); + } + } + + public update (deltaTime: number) { + this._graphEval?.update(deltaTime); + } + + public setValue (name: string, value: Value) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + graphEval.setValue(name, value); + } + + public getCurrentStateStatus (layer: number) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + return graphEval.getCurrentStateStatus(layer); + } + + public getCurrentClipStatuses (layer: number) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + return graphEval.getCurrentClipStatuses(layer); + } + + public getCurrentTransition (layer: number) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + return graphEval.getCurrentTransition(layer); + } + + public getNextStateStatus (layer: number) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + return graphEval.getNextStateStatus(layer); + } + + public getNextClipStatuses (layer: number) { + const { _graphEval: graphEval } = this; + assertIsNonNullable(graphEval); + return graphEval.getNextClipStatuses(layer); + } +} diff --git a/cocos/core/animation/marionette/animation-graph.ts b/cocos/core/animation/marionette/animation-graph.ts new file mode 100644 index 00000000000..bcff720d8a5 --- /dev/null +++ b/cocos/core/animation/marionette/animation-graph.ts @@ -0,0 +1,590 @@ +import { ccclass, serializable } from 'cc.decorator'; +import { DEBUG } from 'internal:constants'; +import { remove, removeAt, removeIf } from '../../utils/array'; +import { assertIsNonNullable, assertIsTrue } from '../../data/utils/asserts'; +import { Motion, MotionEval, MotionEvalContext } from './motion'; +import type { Condition } from './condition'; +import { Asset } from '../../assets'; +import { OwnedBy, assertsOwnedBy, own, markAsDangling, ownerSymbol } from './ownership'; +import { Value } from './variable'; +import { InvalidTransitionError } from './errors'; +import { createEval } from './create-eval'; +import { MotionState } from './motion-state'; +import { State, outgoingsSymbol, incomingsSymbol, InteractiveState } from './state'; +import { SkeletonMask } from '../skeleton-mask'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { array } from '../../utils/js'; +import { move } from '../../algorithm/move'; +import { onAfterDeserializedTag } from '../../data/deserialize-symbols'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; +import { StateMachineComponent } from './state-machine-component'; +import { VariableType } from './parametric'; + +export { State }; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}Transition`) +class Transition extends EditorExtendable implements OwnedBy, Transition { + declare [ownerSymbol]: StateMachine | undefined; + + /** + * The transition source. + */ + @serializable + public from: State; + + /** + * The transition target. + */ + @serializable + public to: State; + + /** + * The transition condition. + */ + @serializable + public conditions: Condition[] = []; + + /** + * @internal + */ + constructor (from: State, to: State, conditions?: Condition[]) { + super(); + this.from = from; + this.to = to; + if (conditions) { + this.conditions = conditions; + } + } + + [ownerSymbol]: StateMachine | undefined; +} + +type TransitionView = Omit & { + readonly from: Transition['from']; + readonly to: Transition['to']; +}; + +export type { TransitionView as Transition }; + +export type TransitionInternal = Transition; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}AnimationTransition`) +class AnimationTransition extends Transition { + /** + * The transition duration. + * The unit of the duration is the real duration of transition source + * if `relativeDuration` is `true` or seconds otherwise. + */ + @serializable + public duration = 0.3; + + /** + * Determines the unit of transition duration. See `duration`. + */ + @serializable + public relativeDuration = false; + + @serializable + public exitConditionEnabled = true; + + get exitCondition () { + return this._exitCondition; + } + + set exitCondition (value) { + assertIsTrue(value >= 0.0); + this._exitCondition = value; + } + + @serializable + private _exitCondition = 1.0; +} + +type AnimationTransitionView = Omit & { + readonly from: AnimationTransition['from']; + readonly to: AnimationTransition['to']; +}; + +export type { AnimationTransitionView as AnimationTransition }; + +export function isAnimationTransition (transition: TransitionView): transition is AnimationTransitionView { + return transition instanceof AnimationTransition; +} + +@ccclass('cc.animation.StateMachine') +export class StateMachine extends EditorExtendable { + @serializable + private _states: State[] = []; + + @serializable + private _transitions: Transition[] = []; + + @serializable + private _entryState: State; + + @serializable + private _exitState: State; + + @serializable + private _anyState: State; + + /** + * @internal + */ + constructor () { + super(); + this._entryState = this._addState(new State()); + this._entryState.name = 'Entry'; + this._exitState = this._addState(new State()); + this._exitState.name = 'Exit'; + this._anyState = this._addState(new State()); + this._anyState.name = 'Any'; + } + + public [onAfterDeserializedTag] () { + this._states.forEach((state) => own(state, this)); + this._transitions.forEach((transition) => { + transition.from[outgoingsSymbol].push(transition); + transition.to[incomingsSymbol].push(transition); + }); + } + + [createEval] (context: MotionEvalContext): MotionEval | null { + throw new Error('Method not implemented.'); + } + + /** + * The entry state. + */ + get entryState () { + return this._entryState; + } + + /** + * The exit state. + */ + get exitState () { + return this._exitState; + } + + /** + * The any state. + */ + get anyState () { + return this._anyState; + } + + /** + * Gets an iterator to all states within this graph. + * @returns The iterator. + */ + public states (): Iterable { + return this._states; + } + + /** + * Gets an iterator to all transitions within this graph. + * @returns The iterator. + */ + public transitions (): Iterable { + return this._transitions; + } + + /** + * Gets the transition between specified states. + * @param from Transition source. + * @param to Transition target. + * @returns The transition, if one existed. + */ + public getTransition (from: State, to: State): Iterable { + assertsOwnedBy(from, this); + assertsOwnedBy(to, this); + return from[outgoingsSymbol].filter((transition) => transition.to === to); + } + + /** + * Gets all outgoing transitions of specified state. + * @param to The state. + * @returns Result transitions. + */ + public getOutgoings (from: State): Iterable { + assertsOwnedBy(from, this); + return from[outgoingsSymbol]; + } + + /** + * Gets all incoming transitions of specified state. + * @param to The state. + * @returns Result transitions. + */ + public getIncomings (to: State): Iterable { + assertsOwnedBy(to, this); + return to[incomingsSymbol]; + } + + /** + * Adds a motion state into this state machine. + * @returns The newly created motion. + */ + public addMotion (): MotionState { + return this._addState(new MotionState()); + } + + /** + * Adds a sub state machine into this state machine. + * @returns The newly created state machine. + */ + public addSubStateMachine (): SubStateMachine { + return this._addState(new SubStateMachine()); + } + + /** + * Removes specified state from this state machine. + * @param state The state to remove. + */ + public remove (state: State) { + assertsOwnedBy(state, this); + + if (state === this.entryState + || state === this.exitState + || state === this.anyState) { + return; + } + + this.eraseTransitionsIncludes(state); + remove(this._states, state); + + markAsDangling(state); + } + + /** + * Connect two states. + * @param from Source state. + * @param to Target state. + * @param condition The transition condition. + */ + public connect (from: MotionState, to: State, conditions?: Condition[]): AnimationTransitionView; + + /** + * Connect two states. + * @param from Source state. + * @param to Target state. + * @param condition The transition condition. + * @throws `InvalidTransitionError` if: + * - the target state is entry or any, or + * - the source state is exit. + */ + public connect (from: State, to: State, conditions?: Condition[]): TransitionView; + + public connect (from: State, to: State, conditions?: Condition[]): TransitionView { + assertsOwnedBy(from, this); + assertsOwnedBy(to, this); + + if (to === this.entryState) { + throw new InvalidTransitionError('to-entry'); + } + if (to === this.anyState) { + throw new InvalidTransitionError('to-any'); + } + if (from === this.exitState) { + throw new InvalidTransitionError('from-exit'); + } + + const transition = from instanceof MotionState || from === this._anyState + ? new AnimationTransition(from, to, conditions) + : new Transition(from, to, conditions); + + own(transition, this); + this._transitions.push(transition); + from[outgoingsSymbol].push(transition); + to[incomingsSymbol].push(transition); + + return transition; + } + + public disconnect (from: State, to: State) { + assertsOwnedBy(from, this); + assertsOwnedBy(to, this); + + const oTransitions = from[outgoingsSymbol]; + for (let iOTransition = 0; iOTransition < oTransitions.length; ++iOTransition) { + const oTransition = oTransitions[iOTransition]; + if (oTransition.to === to) { + assertIsTrue( + remove(this._transitions, oTransition), + ); + removeAt(oTransitions, iOTransition); + assertIsNonNullable( + removeIf(to[incomingsSymbol], (transition) => transition === oTransition), + ); + markAsDangling(oTransition); + break; + } + } + } + + public eraseOutgoings (from: State) { + assertsOwnedBy(from, this); + + const oTransitions = from[outgoingsSymbol]; + for (let iOTransition = 0; iOTransition < oTransitions.length; ++iOTransition) { + const oTransition = oTransitions[iOTransition]; + const to = oTransition.to; + assertIsTrue( + remove(this._transitions, oTransition), + ); + assertIsNonNullable( + removeIf(to[incomingsSymbol], (transition) => transition === oTransition), + ); + markAsDangling(oTransition); + } + oTransitions.length = 0; + } + + public eraseIncomings (to: State) { + assertsOwnedBy(to, this); + + const iTransitions = to[incomingsSymbol]; + for (let iITransition = 0; iITransition < iTransitions.length; ++iITransition) { + const iTransition = iTransitions[iITransition]; + const from = iTransition.from; + assertIsTrue( + remove(this._transitions, iTransition), + ); + assertIsNonNullable( + removeIf(from[outgoingsSymbol], (transition) => transition === iTransition), + ); + markAsDangling(iTransition); + } + iTransitions.length = 0; + } + + public eraseTransitionsIncludes (state: State) { + this.eraseIncomings(state); + this.eraseOutgoings(state); + } + + public clone () { + const that = new StateMachine(); + const stateMap = new Map(); + for (const state of this._states) { + switch (state) { + case this._entryState: + stateMap.set(state, that._entryState); + break; + case this._exitState: + stateMap.set(state, that._exitState); + break; + case this._anyState: + stateMap.set(state, that._anyState); + break; + default: + if (state instanceof MotionState || state instanceof SubStateMachine) { + const thatState = state.clone(); + that._addState(thatState); + stateMap.set(state, thatState); + } else { + assertIsTrue(false); + } + break; + } + } + for (const transition of this._transitions) { + const thatFrom = stateMap.get(transition.from); + const thatTo = stateMap.get(transition.to); + assertIsTrue(thatFrom && thatTo); + const thatTransition = that.connect(thatFrom, thatTo) as Transition; + thatTransition.conditions = transition.conditions.map((condition) => condition.clone()); + if (thatTransition instanceof AnimationTransition) { + assertIsTrue(transition instanceof AnimationTransition); + thatTransition.duration = transition.duration; + thatTransition.exitConditionEnabled = transition.exitConditionEnabled; + thatTransition.exitCondition = transition.exitCondition; + } + } + return that; + } + + private _addState (state: T) { + own(state, this); + this._states.push(state); + return state; + } +} + +@ccclass('cc.animation.SubStateMachine') +export class SubStateMachine extends InteractiveState { + get stateMachine () { + return this._stateMachine; + } + + public clone () { + const that = new SubStateMachine(); + that._stateMachine = this._stateMachine.clone(); + return that; + } + + @serializable + private _stateMachine: StateMachine = new StateMachine(); +} + +@ccclass('cc.animation.Layer') +export class Layer implements OwnedBy { + [ownerSymbol]: AnimationGraph | undefined; + + @serializable + private _stateMachine: StateMachine; + + @serializable + public name = ''; + + @serializable + public weight = 1.0; + + @serializable + public mask: SkeletonMask | null = null; + + @serializable + public blending: LayerBlending = LayerBlending.additive; + + /** + * @internal + */ + constructor () { + this._stateMachine = new StateMachine(); + } + + get stateMachine () { + return this._stateMachine; + } +} + +export enum LayerBlending { + override, + additive, +} + +@ccclass('cc.animation.Variable') +export class Variable { + // TODO: we should not specify type here but due to de-serialization limitation + // See: https://github.com/cocos-creator/3d-tasks/issues/7909 + @serializable + private _type: VariableType = VariableType.NUMBER; + + // Same as `_type` + @serializable + private _value: Value = 0.0; + + constructor (type?: VariableType) { + if (typeof type === 'undefined') { + return; + } + + this._type = type; + switch (type) { + default: + break; + case VariableType.NUMBER: + this._value = 0; + break; + case VariableType.INTEGER: + this._value = 0.0; + break; + case VariableType.BOOLEAN: + case VariableType.TRIGGER: + this._value = false; + break; + } + } + + get type () { + return this._type; + } + + get value () { + return this._value; + } + + set value (value) { + if (DEBUG) { + switch (this._type) { + default: + break; + case VariableType.NUMBER: + assertIsTrue(typeof value === 'number'); + break; + case VariableType.INTEGER: + assertIsTrue(Number.isInteger(value)); + break; + case VariableType.BOOLEAN: + assertIsTrue(typeof value === 'boolean'); + break; + } + } + this._value = value; + } +} + +@ccclass('cc.animation.AnimationGraph') +export class AnimationGraph extends Asset { + @serializable + private _layers: Layer[] = []; + + @serializable + private _variables: Record = {}; + + constructor () { + super(); + } + + get layers (): readonly Layer[] { + return this._layers; + } + + get variables (): Iterable<[string, { type: VariableType, value: Value }]> { + return Object.entries(this._variables); + } + + /** + * Adds a layer. + * @returns The new layer. + */ + public addLayer () { + const layer = new Layer(); + this._layers.push(layer); + return layer; + } + + /** + * Removes a layer. + * @param index Index to the layer to remove. + */ + public removeLayer (index: number) { + array.removeAt(this._layers, index); + } + + /** + * Adjusts the layer's order. + * @param index + * @param newIndex + */ + public moveLayer (index: number, newIndex: number) { + move(this._layers, index, newIndex); + } + + public addVariable (name: string, type: VariableType, value?: Value) { + const variable = new Variable(type); + if (typeof value !== 'undefined') { + variable.value = value; + } + this._variables[name] = variable; + } + + public removeVariable (name: string) { + delete this._variables[name]; + } + + public getVariable (name: string) { + return this._variables[name]; + } +} diff --git a/cocos/core/animation/marionette/blend-1d.ts b/cocos/core/animation/marionette/blend-1d.ts new file mode 100644 index 00000000000..89f051b867f --- /dev/null +++ b/cocos/core/animation/marionette/blend-1d.ts @@ -0,0 +1,23 @@ +export function blend1D (weights: number[], thresholds: readonly number[], value: number) { + weights.fill(0.0); + if (thresholds.length === 0) { + // Do nothing + } else if (value <= thresholds[0]) { + weights[0] = 1; + } else if (value >= thresholds[thresholds.length - 1]) { + weights[weights.length - 1] = 1; + } else { + let iUpper = 0; + for (let iThresholds = 1; iThresholds < thresholds.length; ++iThresholds) { + if (thresholds[iThresholds] > value) { + iUpper = iThresholds; + break; + } + } + const lower = thresholds[iUpper - 1]; + const upper = thresholds[iUpper]; + const dVal = upper - lower; + weights[iUpper - 1] = (upper - value) / dVal; + weights[iUpper] = (value - lower) / dVal; + } +} diff --git a/cocos/core/animation/marionette/blend-2d.ts b/cocos/core/animation/marionette/blend-2d.ts new file mode 100644 index 00000000000..153e3886eaa --- /dev/null +++ b/cocos/core/animation/marionette/blend-2d.ts @@ -0,0 +1,282 @@ +import { assertIsTrue } from '../../data/utils/asserts'; +import { Vec2, Vec3, clamp } from '../../math'; + +/** + * Blends given samples using simple directional algorithm. + * @param weights Result weights of each sample. + * @param samples Every samples' parameter. + * @param input Input parameter. + */ +export const blendSimpleDirectional = (() => { + const CACHE_NORMALIZED_SAMPLE = new Vec2(); + + const CACHE_BARYCENTRIC_SOLUTIONS: EquationResolutions = { wA: 0, wB: 0 }; + + return function blendSimpleDirectional (weights: number[], samples: readonly Vec2[], input: Readonly) { + assertIsTrue(weights.length === samples.length); + + if (samples.length === 0) { + return; + } + + if (samples.length === 1) { + weights[0] = 1.0; + return; + } + + if (Vec2.strictEquals(input, Vec2.ZERO)) { + const iCenter = samples.findIndex((sample) => Vec2.strictEquals(sample, Vec2.ZERO)); + if (iCenter >= 0) { + weights[iCenter] = 1.0; + } else { + weights.fill(1.0 / samples.length); + } + return; + } + + // Finds out the sector the input point locates + let iSectorStart = -1; + let iSectorEnd = -1; + let iCenter = -1; + let lhsCosAngle = Number.NEGATIVE_INFINITY; + let rhsCosAngle = Number.NEGATIVE_INFINITY; + const { x: inputX, y: inputY } = input; + for (let iSample = 0; iSample < samples.length; ++iSample) { + const sample = samples[iSample]; + if (Vec2.equals(sample, Vec2.ZERO)) { + iCenter = iSample; + continue; + } + + const sampleNormalized = Vec2.normalize(CACHE_NORMALIZED_SAMPLE, sample); + const cosAngle = Vec2.dot(sampleNormalized, input); + const sign = sampleNormalized.x * inputY - sampleNormalized.y * inputX; + if (sign > 0) { + if (cosAngle >= rhsCosAngle) { + rhsCosAngle = cosAngle; + iSectorStart = iSample; + } + } else if (cosAngle >= lhsCosAngle) { + lhsCosAngle = cosAngle; + iSectorEnd = iSample; + } + } + + let centerWeight = 0.0; + if (iSectorStart < 0 || iSectorEnd < 0) { + // Input fall at vertex. + centerWeight = 1.0; + } else { + const { wA, wB } = solveBarycentric(samples[iSectorStart], samples[iSectorEnd], input, CACHE_BARYCENTRIC_SOLUTIONS); + let w1 = 0.0; + let w2 = 0.0; + const sum = wA + wB; + if (sum > 1) { + // Input fall at line C-A or C-B but not beyond C + w1 = wA / sum; + w2 = wB / sum; + } else if (sum < 0) { + // Input fall at line C-A or C-B but beyond A or B + w1 = 0.0; + w2 = 0.0; + centerWeight = 1.0; + } else { + // Inside triangle + w1 = wA; + w2 = wB; + centerWeight = 1.0 - sum; + } + weights[iSectorStart] = w1; + weights[iSectorEnd] = w2; + } + + // Center influence part + if (centerWeight > 0.0) { + if (iCenter >= 0) { + weights[iCenter] = centerWeight; + } else { + const average = centerWeight / weights.length; + for (let i = 0; i < weights.length; ++i) { + weights[i] += average; + } + } + } + }; +})(); + +/** + * Validates the samples if they satisfied the requirements of simple directional algorithm. + * @param samples Samples to validate. + * @returns Issues the samples containing. + */ +export function validateSimpleDirectionalSamples (samples: ReadonlyArray): SimpleDirectionalSampleIssue[] { + const nSamples = samples.length; + const issues: SimpleDirectionalSampleIssue[] = []; + const sameDirectionValidationFlag = new Array(samples.length).fill(false); + samples.forEach((sample, iSample) => { + if (sameDirectionValidationFlag[iSample]) { + return; + } + let sameDirectionSamples: number[] | undefined; + for (let iCheckSample = 0; iCheckSample < nSamples; ++iCheckSample) { + const checkSample = samples[iCheckSample]; + if (Vec2.equals(sample, checkSample, 1e-5)) { + (sameDirectionSamples ??= []).push(iCheckSample); + sameDirectionValidationFlag[iCheckSample] = true; + } + } + if (sameDirectionSamples) { + sameDirectionSamples.unshift(iSample); + issues.push(new SimpleDirectionalIssueSameDirection(sameDirectionSamples)); + } + }); + return issues; +} + +export type SimpleDirectionalSampleIssue = SimpleDirectionalIssueSameDirection; + +/** + * Simple directional issue representing some samples have same(or very similar) direction. + */ +export class SimpleDirectionalIssueSameDirection { + public constructor (public samples: readonly number[]) { } +} + +/** + * Cartesian Gradient Band Interpolation. + * @param weights + * @param thresholds + * @param value + */ +export function sampleFreeformCartesian (weights: number[], thresholds: readonly Vec2[], value: Readonly) { + sampleFreeform(weights, thresholds, value, getGradientBandCartesianCoords); +} + +/** + * Polar Gradient Band Interpolation. + * @param weights + * @param thresholds + * @param value + */ +export function sampleFreeformDirectional (weights: number[], thresholds: readonly Vec2[], value: Readonly) { + sampleFreeform(weights, thresholds, value, getGradientBandPolarCoords); +} + +function sampleFreeform (weights: number[], samples: readonly Vec2[], value: Readonly, getGradientBandCoords: GetGradientBandCoords) { + weights.fill(0.0); + const pIpInput = new Vec2(0, 0); + const pIJ = new Vec2(0, 0); + let sumInfluence = 0.0; + const nSamples = samples.length; + for (let iSample = 0; iSample < nSamples; ++iSample) { + let influence = Number.MAX_VALUE; + let outsideHull = false; + for (let jSample = 0; jSample < nSamples; ++jSample) { + if (iSample === jSample) { + continue; + } + getGradientBandCoords(samples[iSample], samples[jSample], value, pIpInput, pIJ); + const t = 1 - Vec2.dot(pIpInput, pIJ) / Vec2.lengthSqr(pIJ); + if (t < 0) { + outsideHull = true; + break; + } + influence = Math.min(influence, t); + } + if (!outsideHull) { + weights[iSample] = influence; + sumInfluence += influence; + } + } + if (sumInfluence > 0) { + weights.forEach((influence, index) => weights[index] = influence / sumInfluence); + } +} + +interface EquationResolutions { + wA: number; + wB: number; +} + +/** + * Solves the barycentric coordinates of `p` within triangle (0, `a`, `b`). + * @param a Triangle vertex. + * @param b Triangle vertex. + * @param p Input vector. + * @param resolutions The barycentric coordinates of `a` and `b`. + * @returns + */ +function solveBarycentric ( + a: Readonly, + b: Readonly, + p: Readonly, + resolutions: EquationResolutions, +) { + // Let P = p - 0, A = a - 0, B = b - 0, + // wA = (P x B) / (A x B) + // wB = (P x A) / (B x A) + const det = Vec2.cross(a, b); + if (!det) { + resolutions.wA = 0.0; + resolutions.wB = 0.0; + } else { + resolutions.wA = Vec2.cross(p, b) / det; + resolutions.wB = Vec2.cross(p, a) / -det; + } + return resolutions; +} + +type GetGradientBandCoords = (point: Readonly, pI: Readonly, pJ: Readonly, pIpInput: Vec2, pIpJ: Vec2) => void; + +const getGradientBandCartesianCoords: GetGradientBandCoords = (pI, pJ, input, pIpInput, pIpJ) => { + Vec2.subtract(pIpInput, input, pI); + Vec2.subtract(pIpJ, pJ, pI); +}; + +const getGradientBandPolarCoords = ((): GetGradientBandCoords => { + const axis = new Vec3(0, 0, 0); // buffer for axis + const tmpV3 = new Vec3(0, 0, 0); // buffer for temp vec3 + const pQueriedProjected = new Vec3(0, 0, 0); // buffer for pQueriedProjected + const pi3 = new Vec3(0, 0, 0); // buffer for pi3 + const pj3 = new Vec3(0, 0, 0); // buffer for pj3 + const pQueried3 = new Vec3(0, 0, 0); // buffer for pQueried3 + return (pI, pJ, input, pIpInput, pIpJ) => { + let aIJ = 0.0; + let aIQ = 0.0; + let angleMultiplier = 2.0; + Vec3.set(pQueriedProjected, input.x, input.y, 0.0); + if (Vec2.equals(pI, Vec2.ZERO)) { + aIJ = Vec2.angle(input, pJ); + aIQ = 0.0; + angleMultiplier = 1.0; + } else if (Vec2.equals(pJ, Vec2.ZERO)) { + aIJ = Vec2.angle(input, pI); + aIQ = aIJ; + angleMultiplier = 1.0; + } else { + aIJ = Vec2.angle(pI, pJ); + if (aIJ <= 0.0) { + aIQ = 0.0; + } else if (Vec2.equals(input, Vec2.ZERO)) { + aIQ = aIJ; + } else { + Vec3.set(pi3, pI.x, pI.y, 0); + Vec3.set(pj3, pJ.x, pJ.y, 0); + Vec3.set(pQueried3, input.x, input.y, 0); + Vec3.cross(axis, pi3, pj3); + Vec3.projectOnPlane(pQueriedProjected, pQueried3, axis); + aIQ = Vec3.angle(pi3, pQueriedProjected); + if (aIJ < Math.PI * 0.99) { + if (Vec3.dot(Vec3.cross(tmpV3, pi3, pQueriedProjected), axis) < 0) { + aIQ = -aIQ; + } + } + } + } + const lenPI = Vec2.len(pI); + const lenPJ = Vec2.len(pJ); + const deno = (lenPJ + lenPI) / 2; + Vec2.set(pIpJ, (lenPJ - lenPI) / deno, aIJ * angleMultiplier); + Vec2.set(pIpInput, (Vec3.len(pQueriedProjected) - lenPI) / deno, aIQ * angleMultiplier); + }; +})(); diff --git a/cocos/core/animation/marionette/clip-motion.ts b/cocos/core/animation/marionette/clip-motion.ts new file mode 100644 index 00000000000..affa67c59e9 --- /dev/null +++ b/cocos/core/animation/marionette/clip-motion.ts @@ -0,0 +1,78 @@ +import { ccclass, type } from '../../data/class-decorator'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { AnimationClip } from '../animation-clip'; +import { AnimationState } from '../animation-state'; +import { createEval } from './create-eval'; +import { graphDebug, GRAPH_DEBUG_ENABLED, pushWeight } from './graph-debug'; +import { ClipStatus } from './graph-eval'; +import { MotionEvalContext, Motion, MotionEval } from './motion'; + +@ccclass('cc.animation.ClipMotion') +export class ClipMotion extends EditorExtendable implements Motion { + @type(AnimationClip) + public clip: AnimationClip | null = null; + + public [createEval] (context: MotionEvalContext) { + return !this.clip ? null : new ClipMotionEval(context, this.clip); + } + + public clone () { + const that = new ClipMotion(); + that.clip = this.clip; + return that; + } +} + +class ClipMotionEval implements MotionEval { + public declare __DEBUG__ID__?: string; + + private declare _state: AnimationState; + + public declare readonly duration: number; + + constructor (context: MotionEvalContext, clip: AnimationClip) { + this.duration = clip.duration; + this._state = new AnimationState(clip); + this._state.initialize(context.node, context.blendBuffer); + } + + public getClipStatuses (baseWeight: number): Iterator { + let got = false; + return { + next: () => { + if (got) { + return { + done: true, + value: undefined, + }; + } else { + got = true; + return { + done: false, + value: { + __DEBUG_ID__: this.__DEBUG__ID__, + clip: this._state.clip, + weight: baseWeight, + }, + }; + } + }, + }; + } + + get progress () { + return this._state.time / this.duration; + } + + public sample (progress: number, weight: number) { + if (weight === 0.0) { + return; + } + pushWeight(this._state.name, weight); + const time = this._state.duration * progress; + this._state.time = time; + this._state.weight = weight; + this._state.sample(); + this._state.weight = 0.0; + } +} diff --git a/cocos/core/animation/marionette/condition.ts b/cocos/core/animation/marionette/condition.ts new file mode 100644 index 00000000000..8a3b6ed055a --- /dev/null +++ b/cocos/core/animation/marionette/condition.ts @@ -0,0 +1,292 @@ +import { + VariableType, + BindableBoolean, BindableNumber, BindContext, bindOr, validateVariableExistence, validateVariableType, + bindNumericOr, +} from './parametric'; +import { ccclass, serializable } from '../../data/decorators'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; +import { createEval } from './create-eval'; +import { VariableTypeMismatchedError } from './errors'; +import type { Value } from './variable'; + +export type ConditionEvalContext = BindContext; + +export interface Condition { + clone (): Condition; + [createEval] (context: BindContext): ConditionEval; +} + +export interface ConditionEval { + /** + * Evaluates this condition. + */ + eval(): boolean; +} + +enum BinaryOperator { + EQUAL_TO, + NOT_EQUAL_TO, + LESS_THAN, + LESS_THAN_OR_EQUAL_TO, + GREATER_THAN, + GREATER_THAN_OR_EQUAL_TO, +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}BinaryCondition`) +export class BinaryCondition implements Condition { + public static readonly Operator = BinaryOperator; + + @serializable + public operator: BinaryOperator = BinaryOperator.EQUAL_TO; + + @serializable + public lhs: BindableNumber = new BindableNumber(); + + @serializable + public rhs: BindableNumber = new BindableNumber(); + + public clone () { + const that = new BinaryCondition(); + that.operator = this.operator; + that.lhs = this.lhs.clone(); + that.rhs = this.rhs.clone(); + return that; + } + + public [createEval] (context: BindContext) { + const { operator, lhs, rhs } = this; + const evaluation = new BinaryConditionEval(operator, 0.0, 0.0); + const lhsValue = bindNumericOr( + context, + lhs, + VariableType.NUMBER, + evaluation.setLhs, + evaluation, + ); + const rhsValue = bindNumericOr( + context, + rhs, + VariableType.NUMBER, + evaluation.setRhs, + evaluation, + ); + evaluation.reset(lhsValue, rhsValue); + return evaluation; + } +} + +export declare namespace BinaryCondition { + export type Operator = BinaryOperator; +} + +class BinaryConditionEval implements ConditionEval { + private declare _operator: BinaryOperator; + private declare _lhs: number; + private declare _rhs: number; + private declare _result: boolean; + + constructor (operator: BinaryOperator, lhs: number, rhs: number) { + this._operator = operator; + this._lhs = lhs; + this._rhs = rhs; + this._eval(); + } + + public reset (lhs: number, rhs: number) { + this._lhs = lhs; + this._rhs = rhs; + this._eval(); + } + + public setLhs (value: number) { + this._lhs = value; + this._eval(); + } + + public setRhs (value: number) { + this._rhs = value; + this._eval(); + } + + /** + * Evaluates this condition. + */ + public eval () { + return this._result; + } + + private _eval () { + const { + _lhs: lhs, + _rhs: rhs, + } = this; + switch (this._operator) { + default: + case BinaryOperator.EQUAL_TO: + this._result = lhs === rhs; + break; + case BinaryOperator.NOT_EQUAL_TO: + this._result = lhs !== rhs; + break; + case BinaryOperator.LESS_THAN: + this._result = lhs < rhs; + break; + case BinaryOperator.LESS_THAN_OR_EQUAL_TO: + this._result = lhs <= rhs; + break; + case BinaryOperator.GREATER_THAN: + this._result = lhs > rhs; + break; + case BinaryOperator.GREATER_THAN_OR_EQUAL_TO: + this._result = lhs >= rhs; + break; + } + } +} + +enum UnaryOperator { + TRUTHY, + FALSY, +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}UnaryCondition`) +export class UnaryCondition implements Condition { + public static readonly Operator = UnaryOperator; + + @serializable + public operator: UnaryOperator = UnaryOperator.TRUTHY; + + @serializable + public operand = new BindableBoolean(); + + public clone () { + const that = new UnaryCondition(); + that.operator = this.operator; + that.operand = this.operand.clone(); + return that; + } + + public [createEval] (context: ConditionEvalContext) { + const { operator, operand } = this; + const evaluation = new UnaryConditionEval(operator, false); + const value = bindOr( + context, + operand, + VariableType.BOOLEAN, + evaluation.setOperand, + evaluation, + ); + evaluation.reset(value); + return evaluation; + } +} + +export declare namespace UnaryCondition { + export type Operator = UnaryOperator; +} + +class UnaryConditionEval implements ConditionEval { + private declare _operator: UnaryOperator; + private declare _operand: boolean; + private declare _result: boolean; + + constructor (operator: UnaryOperator, operand: boolean) { + this._operator = operator; + this._operand = operand; + this._eval(); + } + + public reset (value: boolean) { + this.setOperand(value); + } + + public setOperand (value: boolean) { + this._operand = value; + this._eval(); + } + + /** + * Evaluates this condition. + */ + public eval () { + return this._result; + } + + private _eval () { + const { _operand: operand } = this; + switch (this._operator) { + default: + case UnaryOperator.TRUTHY: + this._result = !!operand; + break; + case UnaryOperator.FALSY: + this._result = !operand; + break; + } + } +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}TriggerCondition`) +export class TriggerCondition implements Condition { + @serializable + public trigger = ''; + + public clone () { + const that = new TriggerCondition(); + that.trigger = this.trigger; + return that; + } + + [createEval] (context: BindContext): ConditionEval { + const evaluation = new TriggerConditionEval(false); + const triggerInstance = context.getVar(this.trigger); + if (validateVariableExistence(triggerInstance, this.trigger)) { + validateVariableType(triggerInstance.type, VariableType.TRIGGER, this.trigger); + evaluation.setTrigger(triggerInstance.bind( + evaluation.setTrigger, + evaluation, + ) as boolean); + } + return evaluation; + } +} + +class TriggerConditionEval implements ConditionEval { + constructor (triggered: boolean) { + this._triggered = triggered; + } + + public setTrigger (trigger: boolean) { + this._triggered = trigger; + } + + public eval (): boolean { + return this._triggered; + } + + private _triggered = false; +} + +export function validateConditionParamNumber (val: unknown, name: string): asserts val is number { + if (typeof val !== 'number') { + throw new VariableTypeMismatchedError(name, 'float'); + } +} + +export function validateConditionParamInteger (val: unknown, name: string): asserts val is number { + if (!Number.isInteger(val)) { + throw new VariableTypeMismatchedError(name, 'integer'); + } +} + +export function validateConditionParamBoolean (val: unknown, name: string): asserts val is boolean { + if (typeof val !== 'boolean') { + throw new VariableTypeMismatchedError(name, 'boolean'); + } +} + +export function validateConditionParamTrigger (val: unknown, name: string): asserts val is boolean { + if (typeof val !== 'object') { + throw new VariableTypeMismatchedError(name, 'trigger'); + } +} diff --git a/cocos/core/animation/marionette/create-eval.ts b/cocos/core/animation/marionette/create-eval.ts new file mode 100644 index 00000000000..e2355aaeafa --- /dev/null +++ b/cocos/core/animation/marionette/create-eval.ts @@ -0,0 +1 @@ +export const createEval = Symbol('[[createEval]]'); diff --git a/cocos/core/animation/marionette/errors.ts b/cocos/core/animation/marionette/errors.ts new file mode 100644 index 00000000000..3ff36a1605c --- /dev/null +++ b/cocos/core/animation/marionette/errors.ts @@ -0,0 +1,18 @@ +export class InvalidTransitionError extends Error { + constructor (type: 'to-entry' | 'to-any' | 'from-exit') { + super(`${type} transition is invalid`); + this.name = 'TransitionRejectError'; + } +} + +export class VariableNotDefinedError extends Error { + constructor (name: string) { + super(`Graph variable ${name} is not defined`); + } +} + +export class VariableTypeMismatchedError extends Error { + constructor (name: string, expected: string, received?: string) { + super(`Expect graph variable ${name} to have type '${expected}' instead of received '${received ?? typeof received}'`); + } +} diff --git a/cocos/core/animation/marionette/graph-debug.ts b/cocos/core/animation/marionette/graph-debug.ts new file mode 100644 index 00000000000..86a4c1926ef --- /dev/null +++ b/cocos/core/animation/marionette/graph-debug.ts @@ -0,0 +1,31 @@ +import { debug } from '../../platform/debug'; + +export const GRAPH_DEBUG_ENABLED = false; + +export const graphDebug = GRAPH_DEBUG_ENABLED + ? debug + : EMPTY as typeof debug; + +export const graphDebugGroup = GRAPH_DEBUG_ENABLED + ? console.group + : EMPTY as typeof debug; + +export const graphDebugGroupEnd = GRAPH_DEBUG_ENABLED + ? console.groupEnd + : EMPTY as typeof debug; + +function EMPTY (...args: unknown[]) { } + +const weightsStats: [string, number][] = []; + +export function pushWeight (name: string, weight: number) { + weightsStats.push([name, weight]); +} + +export function getWeightsStats () { + return `[${weightsStats.map(([name, weight]) => `[${name}: ${weight}]`).join(' ')}]`; +} + +export function clearWeightsStats () { + weightsStats.length = 0; +} diff --git a/cocos/core/animation/marionette/graph-eval.ts b/cocos/core/animation/marionette/graph-eval.ts new file mode 100644 index 00000000000..599661ae3dc --- /dev/null +++ b/cocos/core/animation/marionette/graph-eval.ts @@ -0,0 +1,1106 @@ +import { DEBUG } from 'internal:constants'; +import { AnimationGraph, Layer, StateMachine, State, Transition, isAnimationTransition, SubStateMachine } from './animation-graph'; +import { assertIsTrue, assertIsNonNullable } from '../../data/utils/asserts'; +import { MotionEval, MotionEvalContext } from './motion'; +import type { Node } from '../../scene-graph/node'; +import { createEval } from './create-eval'; +import { Value } from './variable'; +import { BindContext, bindOr, VariableType } from './parametric'; +import { ConditionEval, TriggerCondition } from './condition'; +import { VariableNotDefinedError, VariableTypeMismatchedError } from './errors'; +import { MotionState } from './motion-state'; +import { SkeletonMask } from '../skeleton-mask'; +import { debug, warnID } from '../../platform/debug'; +import { BlendStateBuffer } from '../../../3d/skeletal-animation/skeletal-animation-blending'; +import { clearWeightsStats, getWeightsStats, graphDebug, graphDebugGroup, graphDebugGroupEnd, GRAPH_DEBUG_ENABLED } from './graph-debug'; +import { AnimationClip } from '../animation-clip'; +import type { AnimationController } from './animation-controller'; +import { StateMachineComponent } from './state-machine-component'; +import { InteractiveState } from './state'; + +export class AnimationGraphEval { + private declare _layerEvaluations: LayerEval[]; + private _blendBuffer = new BlendStateBuffer(); + private _currentTransitionCache: TransitionStatus = { + duration: 0.0, + time: 0.0, + }; + + constructor (graph: AnimationGraph, root: Node, newGenAnim: AnimationController) { + for (const [name, { type, value }] of graph.variables) { + this._varInstances[name] = new VarInstance(type, value); + } + + const context: LayerContext = { + newGenAnim, + blendBuffer: this._blendBuffer, + node: root, + getVar: (id: string): VarInstance | undefined => this._varInstances[id], + triggerResetFn: (name: string) => { + this.setValue(name, false); + }, + }; + + this._layerEvaluations = Array.from(graph.layers).map((layer) => { + const layerEval = new LayerEval(layer, { + ...context, + mask: layer.mask ?? undefined, + }); + return layerEval; + }); + } + + public update (deltaTime: number) { + graphDebugGroup(`New frame started.`); + if (GRAPH_DEBUG_ENABLED) { + clearWeightsStats(); + } + for (const layerEval of this._layerEvaluations) { + layerEval.update(deltaTime); + } + if (GRAPH_DEBUG_ENABLED) { + graphDebug(`Weights: ${getWeightsStats()}`); + } + this._blendBuffer.apply(); + graphDebugGroupEnd(); + } + + public getCurrentStateStatus (layer: number): Readonly | null { + // TODO optimize object + const stats: StateStatus = { __DEBUG_ID__: '' }; + if (this._layerEvaluations[layer].getCurrentStateStatus(stats)) { + return stats; + } else { + return null; + } + } + + public getCurrentClipStatuses (layer: number): Iterable> { + return this._layerEvaluations[layer].getCurrentClipStatuses(); + } + + public getCurrentTransition (layer: number): Readonly | null { + const { + _layerEvaluations: layers, + _currentTransitionCache: currentTransition, + } = this; + const isInTransition = layers[layer].getCurrentTransition(currentTransition); + return isInTransition ? currentTransition : null; + } + + public getNextStateStatus (layer: number): Readonly | null { + // TODO optimize object + const stats: StateStatus = { __DEBUG_ID__: '' }; + if (this._layerEvaluations[layer].getNextStateStatus(stats)) { + return stats; + } else { + return null; + } + } + + public getNextClipStatuses (layer: number): Iterable> { + assertIsNonNullable(this.getCurrentTransition(layer), '!!this.getCurrentTransition(layer)'); + return this._layerEvaluations[layer].getNextClipStatuses(); + } + + public getValue (name: string) { + const varInstance = this._varInstances[name]; + if (!varInstance) { + return undefined; + } else { + return varInstance.value; + } + } + + public setValue (name: string, value: Value) { + const varInstance = this._varInstances[name]; + if (!varInstance) { + return; + } + varInstance.value = value; + } + + private _varInstances: Record = {}; +} + +export interface TransitionStatus { + duration: number; + time: number; +} + +export interface ClipStatus { + clip: AnimationClip; + weight: number; +} + +export interface StateStatus { + /** + * For testing. + * TODO: remove it. + */ + __DEBUG_ID__: string | undefined; +} + +type TriggerResetFn = (name: string) => void; + +interface LayerContext extends BindContext { + newGenAnim: AnimationController; + + /** + * The root node bind to the graph. + */ + node: Node; + + /** + * The blend buffer. + */ + blendBuffer: BlendStateBuffer; + + /** + * The mask applied to this layer. + */ + mask?: SkeletonMask; + + /** + * TODO: A little hacky. + * A function which resets specified trigger. This function can be stored. + */ + triggerResetFn: TriggerResetFn; +} + +class LayerEval { + public declare name: string; + + constructor (layer: Layer, context: LayerContext) { + this.name = layer.name; + this._newGenAnim = context.newGenAnim; + this._weight = layer.weight; + const { entry, exit } = this._addStateMachine(layer.stateMachine, null, { + ...context, + }, layer.name); + this._topLevelEntry = entry; + this._topLevelExit = exit; + this._currentNode = entry; + this._resetTrigger = context.triggerResetFn; + } + + /** + * Indicates if this layer's top level graph reached its exit. + */ + get exited () { + return this._currentNode === this._topLevelExit; + } + + public update (deltaTime: number) { + if (!this.exited) { + this._fromWeight = 1.0; + this._toWeight = 0.0; + this._eval(deltaTime); + this._sample(); + } + } + + public getCurrentStateStatus (status: StateStatus): boolean { + const { _currentNode: currentNode } = this; + if (currentNode.kind === NodeKind.animation) { + status.__DEBUG_ID__ = currentNode.name; + return true; + } else { + return false; + } + } + + public getCurrentClipStatuses (): Iterable { + const { _currentNode: currentNode } = this; + if (currentNode.kind === NodeKind.animation) { + return currentNode.getClipStatuses(this._fromWeight); + } else { + return emptyClipStatusesIterable; + } + } + + public getCurrentTransition (transitionStatus: TransitionStatus): boolean { + const { _currentTransitionPath: currentTransitionPath } = this; + if (currentTransitionPath.length !== 0) { + const { + duration, + normalizedDuration, + } = currentTransitionPath[0]; + const durationInSeconds = transitionStatus.duration = normalizedDuration + ? duration * (this._currentNode.kind === NodeKind.animation ? this._currentNode.duration : 0.0) + : duration; + transitionStatus.time = this._transitionProgress * durationInSeconds; + return true; + } else { + return false; + } + } + + public getNextStateStatus (status: StateStatus): boolean { + assertIsTrue(this._currentTransitionToNode, 'There is no transition currently in layer.'); + status.__DEBUG_ID__ = this._currentTransitionToNode.name; + return true; + } + + public getNextClipStatuses (): Iterable { + const { _currentTransitionPath: currentTransitionPath } = this; + const nCurrentTransitionPath = currentTransitionPath.length; + assertIsTrue(nCurrentTransitionPath > 0, 'There is no transition currently in layer.'); + const to = currentTransitionPath[nCurrentTransitionPath - 1].to; + assertIsTrue(to.kind === NodeKind.animation); + return to.getClipStatuses(this._toWeight) ?? emptyClipStatusesIterable; + } + + private declare _newGenAnim: AnimationController; + private _weight: number; + private _nodes: NodeEval[] = []; + private _topLevelEntry: NodeEval; + private _topLevelExit: NodeEval; + private _currentNode: NodeEval; + private _currentTransitionToNode: MotionStateEval | null = null; + private _currentTransitionPath: TransitionEval[] = []; + private _transitionProgress = 0; + private declare _triggerReset: TriggerResetFn; + private _fromWeight = 0.0; + private _toWeight = 0.0; + + private _addStateMachine ( + graph: StateMachine, parentStateMachineInfo: StateMachineInfo | null, context: LayerContext, __DEBUG_ID__: string, + ): StateMachineInfo { + const nodes = Array.from(graph.states()); + + let entryEval: SpecialStateEval | undefined; + let anyNode: SpecialStateEval | undefined; + let exitEval: SpecialStateEval | undefined; + + const nodeEvaluations = nodes.map((node): NodeEval | null => { + if (node instanceof MotionState) { + return new MotionStateEval(node, context); + } else if (node === graph.entryState) { + return entryEval = new SpecialStateEval(node, NodeKind.entry, node.name); + } else if (node === graph.exitState) { + return exitEval = new SpecialStateEval(node, NodeKind.exit, node.name); + } else if (node === graph.anyState) { + return anyNode = new SpecialStateEval(node, NodeKind.any, node.name); + } else { + assertIsTrue(node instanceof SubStateMachine); + return null; + } + }); + + assertIsNonNullable(entryEval, 'Entry node is missing'); + assertIsNonNullable(exitEval, 'Exit node is missing'); + assertIsNonNullable(anyNode, 'Any node is missing'); + + const stateMachineInfo: StateMachineInfo = { + components: null, + parent: parentStateMachineInfo, + entry: entryEval, + exit: exitEval, + any: anyNode, + }; + + for (let iNode = 0; iNode < nodes.length; ++iNode) { + const nodeEval = nodeEvaluations[iNode]; + if (nodeEval) { + nodeEval.stateMachine = stateMachineInfo; + } + } + + const subStateMachineInfos = nodes.map((node) => { + if (node instanceof SubStateMachine) { + const subStateMachineInfo = this._addStateMachine(node.stateMachine, stateMachineInfo, context, `${__DEBUG_ID__}/${node.name}`); + subStateMachineInfo.components = new InstantiatedComponents(node); + return subStateMachineInfo; + } else { + return null; + } + }); + + if (DEBUG) { + for (const nodeEval of nodeEvaluations) { + if (nodeEval) { + nodeEval.__DEBUG_ID__ = `${nodeEval.name}(from ${__DEBUG_ID__})`; + } + } + } + + for (let iNode = 0; iNode < nodes.length; ++iNode) { + const node = nodes[iNode]; + const outgoingTemplates = graph.getOutgoings(node); + const outgoingTransitions: TransitionEval[] = []; + + let fromNode: NodeEval; + if (node instanceof SubStateMachine) { + const subStateMachineInfo = subStateMachineInfos[iNode]; + assertIsNonNullable(subStateMachineInfo); + fromNode = subStateMachineInfo.exit; + } else { + const nodeEval = nodeEvaluations[iNode]; + assertIsNonNullable(nodeEval); + fromNode = nodeEval; + } + + for (const outgoing of outgoingTemplates) { + const outgoingNode = outgoing.to; + const iOutgoingNode = nodes.findIndex((nodeTemplate) => nodeTemplate === outgoing.to); + if (iOutgoingNode < 0) { + assertIsTrue(false, 'Bad animation data'); + } + + let toNode: NodeEval; + if (outgoingNode instanceof SubStateMachine) { + const subStateMachineInfo = subStateMachineInfos[iOutgoingNode]; + assertIsNonNullable(subStateMachineInfo); + toNode = subStateMachineInfo.entry; + } else { + const nodeEval = nodeEvaluations[iOutgoingNode]; + assertIsNonNullable(nodeEval); + toNode = nodeEval; + } + + const transitionEval: TransitionEval = { + to: toNode, + conditions: outgoing.conditions.map((condition) => condition[createEval](context)), + duration: isAnimationTransition(outgoing) ? outgoing.duration : 0.0, + normalizedDuration: isAnimationTransition(outgoing) ? outgoing.relativeDuration : false, + exitConditionEnabled: isAnimationTransition(outgoing) ? outgoing.exitConditionEnabled : false, + exitCondition: isAnimationTransition(outgoing) ? outgoing.exitCondition : 0.0, + triggers: undefined, + }; + transitionEval.conditions.forEach((conditionEval, iCondition) => { + const condition = outgoing.conditions[iCondition]; + if (condition instanceof TriggerCondition && condition.trigger) { + // TODO: validates the existence of trigger? + (transitionEval.triggers ??= []).push(condition.trigger); + } + }); + outgoingTransitions.push(transitionEval); + } + + fromNode.outgoingTransitions = outgoingTransitions; + } + + return stateMachineInfo; + } + + /** + * Updates this layer, return when the time piece exhausted or the graph reached exit state. + * @param deltaTime The time piece to update. + * @returns Remain time piece. + */ + private _eval (deltaTime: Readonly) { + assertIsTrue(!this.exited); + graphDebugGroup(`[Layer ${this.name}]: UpdateStart ${deltaTime}s`); + + const MAX_ITERATIONS = 100; + let passConsumed = 0.0; + + let remainTimePiece = deltaTime; + for (let continueNextIterationForce = true, // Force next iteration even remain time piece is zero + iterations = 0; + continueNextIterationForce || remainTimePiece > 0.0; + ) { + continueNextIterationForce = false; + + if (iterations !== 0) { + graphDebug(`Pass end. Consumed ${passConsumed}s, remain: ${remainTimePiece}s`); + } + + if (iterations === MAX_ITERATIONS) { + warnID(14000, MAX_ITERATIONS); + break; + } + + graphDebug(`Pass ${iterations} started.`); + + if (GRAPH_DEBUG_ENABLED) { + passConsumed = 0.0; + } + + ++iterations; + + // Update current transition if we're in transition. + // If currently no transition, we simple fallthrough. + if (this._currentTransitionPath.length > 0) { + const currentUpdatingConsume = this._updateCurrentTransition(remainTimePiece); + if (GRAPH_DEBUG_ENABLED) { + passConsumed = currentUpdatingConsume; + } + remainTimePiece -= currentUpdatingConsume; + if (this._currentNode.kind === NodeKind.exit) { + break; + } + if (this._currentTransitionPath.length === 0) { + // If the update invocation finished the transition, + // We force restart the iteration + continueNextIterationForce = true; + } + continue; + } + + const { _currentNode: currentNode } = this; + + const transitionMatch = this._matchCurrentNodeTransition(remainTimePiece); + + if (transitionMatch) { + const { + transition, + requires: updateRequires, + } = transitionMatch; + + graphDebug(`[SubStateMachine ${this.name}]: CurrentNodeUpdate: ${currentNode.name}`); + if (currentNode.kind === NodeKind.animation) { + currentNode.updateFromPort(updateRequires); + } + if (GRAPH_DEBUG_ENABLED) { + passConsumed = remainTimePiece; + } + + remainTimePiece -= updateRequires; + this._switchTo(transition); + + continueNextIterationForce = true; + } else { // If no transition matched, we update current node. + graphDebug(`[SubStateMachine ${this.name}]: CurrentNodeUpdate: ${currentNode.name}`); + if (currentNode.kind === NodeKind.animation) { + currentNode.updateFromPort(remainTimePiece); + // Animation play eat all times. + remainTimePiece = 0.0; + } + if (GRAPH_DEBUG_ENABLED) { + passConsumed = remainTimePiece; + } + continue; + } + } + + graphDebug(`[SubStateMachine ${this.name}]: UpdateEnd`); + graphDebugGroupEnd(); + + return remainTimePiece; + } + + private _sample () { + const { + _currentNode: currentNode, + _currentTransitionToNode: currentTransitionToNode, + _fromWeight: fromWeight, + } = this; + if (currentNode.kind === NodeKind.animation) { + currentNode.sampleFromPort(fromWeight); + } + if (currentTransitionToNode) { + if (currentTransitionToNode.kind === NodeKind.animation) { + currentTransitionToNode.sampleToPort(this._toWeight); + } + } + } + + /** + * Searches for a transition which should be performed + * if current node update for no more than `deltaTime`. + * @param deltaTime + * @returns + */ + private _matchCurrentNodeTransition (deltaTime: Readonly) { + const currentNode = this._currentNode; + + let minDeltaTimeRequired = Infinity; + let transitionRequiringMinDeltaTime: TransitionEval | null = null; + + const match0 = this._matchTransition( + currentNode, + currentNode, + deltaTime, + transitionMatchCacheRegular, + ); + if (match0) { + ({ + requires: minDeltaTimeRequired, + transition: transitionRequiringMinDeltaTime, + } = match0); + } + + if (currentNode.kind === NodeKind.animation) { + for (let ancestor: StateMachineInfo | null = currentNode.stateMachine; + ancestor !== null; + ancestor = ancestor.parent) { + const anyMatch = this._matchTransition( + ancestor.any, + currentNode, + deltaTime, + transitionMatchCacheAny, + ); + if (anyMatch && anyMatch.requires < minDeltaTimeRequired) { + ({ + requires: minDeltaTimeRequired, + transition: transitionRequiringMinDeltaTime, + } = anyMatch); + } + } + } + + const result = transitionMatchCache; + + if (transitionRequiringMinDeltaTime) { + return result.set(transitionRequiringMinDeltaTime, minDeltaTimeRequired); + } + + return null; + } + + /** + * Searches for a transition which should be performed + * if specified node update for no more than `deltaTime`. + * @param node + * @param realNode + * @param deltaTime + * @returns + */ + private _matchTransition ( + node: NodeEval, realNode: NodeEval, deltaTime: Readonly, result: TransitionMatchCache, + ): TransitionMatch | null { + assertIsTrue(node === realNode || node.kind === NodeKind.any); + const { outgoingTransitions } = node; + const nTransitions = outgoingTransitions.length; + let minDeltaTimeRequired = Infinity; + let transitionRequiringMinDeltaTime: TransitionEval | null = null; + for (let iTransition = 0; iTransition < nTransitions; ++iTransition) { + const transition = outgoingTransitions[iTransition]; + const { conditions } = transition; + const nConditions = conditions.length; + + // Handle empty condition case. + if (nConditions === 0) { + if (node.kind === NodeKind.entry || node.kind === NodeKind.exit) { + // These kinds of transition is definitely chosen. + return result.set(transition, 0.0); + } + if (!transition.exitConditionEnabled) { + // Invalid transition, ignored. + continue; + } + } + + let deltaTimeRequired = 0.0; + + if (realNode.kind === NodeKind.animation && transition.exitConditionEnabled) { + const exitTime = realNode.duration * transition.exitCondition; + deltaTimeRequired = Math.max(exitTime - realNode.fromPortTime, 0.0); + if (deltaTimeRequired > deltaTime) { + continue; + } + } + + let satisfied = true; + for (let iCondition = 0; iCondition < nConditions; ++iCondition) { + const condition = conditions[iCondition]; + if (!condition.eval()) { + satisfied = false; + break; + } + } + if (!satisfied) { + continue; + } + + if (deltaTimeRequired === 0.0) { + // Exit condition is disabled or the exit condition is just 0.0. + return result.set(transition, 0.0); + } + + if (deltaTimeRequired < minDeltaTimeRequired) { + minDeltaTimeRequired = deltaTimeRequired; + transitionRequiringMinDeltaTime = transition; + } + } + if (transitionRequiringMinDeltaTime) { + return result.set(transitionRequiringMinDeltaTime, minDeltaTimeRequired); + } + return null; + } + + private _switchTo (transition: TransitionEval) { + const { _currentNode: currentNode } = this; + + graphDebugGroup(`[SubStateMachine ${this.name}]: STARTED ${currentNode.name} -> ${transition.to.name}.`); + + // TODO: what if the first is entry(ie. not animation play)? + // TODO: what if two of the path use same trigger? + let currentTransition = transition; + const { _currentTransitionPath: currentTransitionPath } = this; + for (; ;) { + // Reset triggers + this._resetTriggersOnTransition(currentTransition); + + currentTransitionPath.push(currentTransition); + const { to } = currentTransition; + if (to.kind === NodeKind.animation) { + break; + } + + if (to.kind === NodeKind.entry) { + // We're entering a state machine + this._callEnterMethods(to); + } + + const transitionMatch = this._matchTransition( + to, + to, + 0.0, + transitionMatchCache, + ); + if (!transitionMatch) { + break; + } + currentTransition = transitionMatch.transition; + } + + const realTargetNode = currentTransition.to; + if (realTargetNode.kind !== NodeKind.animation) { + // We ran into a exit/entry node. + // Current: ignore the transition, but triggers are consumed + // TODO: what should we done here? + currentTransitionPath.length = 0; + return; + } + + // Apply transitions + this._transitionProgress = 0.0; + this._currentTransitionToNode = realTargetNode; + + realTargetNode.resetToPort(); + this._callEnterMethods(realTargetNode); + + graphDebugGroupEnd(); + } + + /** + * Update current transition. + * Asserts: `!!this._currentTransition`. + * @param deltaTime Time piece. + * @returns + */ + private _updateCurrentTransition (deltaTime: number) { + const { + _currentTransitionPath: currentTransitionPath, + _currentTransitionToNode: currentTransitionToNode, + } = this; + + assertIsNonNullable(currentTransitionPath.length > 0); + assertIsNonNullable(currentTransitionToNode); + + const currentTransition = currentTransitionPath[0]; + + const { + duration: transitionDuration, + normalizedDuration, + } = currentTransition; + + const fromNode = this._currentNode; + const toNode = currentTransitionToNode; + + let contrib = 0.0; + let ratio = 0.0; + if (transitionDuration <= 0) { + contrib = 0.0; + ratio = 1.0; + } else { + assertIsTrue(fromNode.kind === NodeKind.animation); + const { _transitionProgress: transitionProgress } = this; + const durationSeconds = normalizedDuration ? transitionDuration * fromNode.duration : transitionDuration; + const progressSeconds = transitionProgress * durationSeconds; + const remain = durationSeconds - progressSeconds; + assertIsTrue(remain >= 0.0); + contrib = Math.min(remain, deltaTime); + ratio = this._transitionProgress = (progressSeconds + contrib) / durationSeconds; + assertIsTrue(ratio >= 0.0 && ratio <= 1.0); + } + + const toNodeName = toNode?.name ?? ''; + + const weight = this._weight; + graphDebugGroup( + `[SubStateMachine ${this.name}]: TransitionUpdate: ${fromNode.name} -> ${toNodeName}` + + `with ratio ${ratio} in base weight ${this._weight}.`, + ); + + this._fromWeight = weight * (1.0 - ratio); + this._toWeight = weight * ratio; + + if (fromNode.kind === NodeKind.animation) { + graphDebugGroup(`Update ${fromNode.name}`); + fromNode.updateFromPort(contrib); + graphDebugGroupEnd(); + } + + if (toNode) { + graphDebugGroup(`Update ${toNode.name}`); + toNode.updateToPort(contrib); + graphDebugGroupEnd(); + } + + graphDebugGroupEnd(); + + if (ratio === 1.0) { + // Transition done. + graphDebug(`[SubStateMachine ${this.name}]: Transition finished: ${fromNode.name} -> ${toNodeName}.`); + + this._callExitMethods(fromNode); + const { _currentTransitionPath: transitions } = this; + const nTransition = transitions.length; + for (let iTransition = 0; iTransition < nTransition; ++iTransition) { + const { to } = transitions[iTransition]; + if (to.kind === NodeKind.exit) { + this._callExitMethods(to); + } + } + toNode.finishTransition(); + this._currentNode = toNode; + this._currentTransitionToNode = null; + this._currentTransitionPath.length = 0; + this._fromWeight = 1.0; + this._toWeight = 0.0; + } + + return contrib; + } + + private _resetTriggersOnTransition (transition: TransitionEval) { + const { triggers } = transition; + if (triggers) { + const nTriggers = triggers.length; + for (let iTrigger = 0; iTrigger < nTriggers; ++iTrigger) { + const trigger = triggers[iTrigger]; + this._resetTrigger(trigger); + } + } + } + + private _resetTrigger (name: string) { + const { _triggerReset: triggerResetFn } = this; + triggerResetFn(name); + } + + private _callEnterMethods (node: NodeEval) { + const { _newGenAnim: newGenAnim } = this; + switch (node.kind) { + default: + break; + case NodeKind.animation: + node.components.callEnterMethods(newGenAnim); + break; + case NodeKind.entry: + node.stateMachine.components?.callEnterMethods(newGenAnim); + break; + } + } + + private _callExitMethods (node: NodeEval) { + const { _newGenAnim: newGenAnim } = this; + switch (node.kind) { + default: + break; + case NodeKind.animation: + node.components.callExitMethods(newGenAnim); + break; + case NodeKind.exit: + node.stateMachine.components?.callExitMethods(newGenAnim); + break; + } + } +} + +const emptyClipStatusesIterator: Iterator = Object.freeze({ + next () { + return { + done: true, + value: undefined, + }; + }, +}); + +const emptyClipStatusesIterable: Iterable = Object.freeze({ + [Symbol.iterator] () { + return emptyClipStatusesIterator; + }, +}); + +interface TransitionMatch { + /** + * The matched result. + */ + transition: TransitionEval; + + /** + * The after after which the transition can happen. + */ + requires: number; +} + +class TransitionMatchCache { + public transition: TransitionMatch['transition'] | null = null; + + public requires = 0.0; + + public set (transition: TransitionMatch['transition'], requires: number) { + this.transition = transition; + this.requires = requires; + return this as TransitionMatch; + } +} + +const transitionMatchCache = new TransitionMatchCache(); + +const transitionMatchCacheRegular = new TransitionMatchCache(); + +const transitionMatchCacheAny = new TransitionMatchCache(); + +enum NodeKind { + entry, exit, any, animation, +} + +export class StateEval { + public declare __DEBUG_ID__?: string; + + public declare stateMachine: StateMachineInfo; + + constructor (node: State) { + this.name = node.name; + } + + public readonly name: string; + + public outgoingTransitions: readonly TransitionEval[] = []; +} + +const DEFAULT_ENTER_METHOD = StateMachineComponent.prototype.onEnter; + +const DEFAULT_EXIT_METHOD = StateMachineComponent.prototype.onExit; + +class InstantiatedComponents { + constructor (node: InteractiveState) { + this._components = node.instantiateComponents(); + } + + public callEnterMethods (newGenAnim: AnimationController) { + const { _components: components } = this; + const nComponents = components.length; + for (let iComponent = 0; iComponent < nComponents; ++iComponent) { + const component = components[iComponent]; + if (component.onEnter !== DEFAULT_ENTER_METHOD) { + component.onEnter(newGenAnim); + } + } + } + + public callExitMethods (newGenAnim: AnimationController) { + const { _components: components } = this; + const nComponents = components.length; + for (let iComponent = 0; iComponent < nComponents; ++iComponent) { + const component = components[iComponent]; + if (component.onExit !== DEFAULT_EXIT_METHOD) { + component.onExit(newGenAnim); + } + } + } + + private declare _components: StateMachineComponent[]; +} + +interface StateMachineInfo { + parent: StateMachineInfo | null; + entry: NodeEval; + exit: NodeEval; + any: NodeEval; + components: InstantiatedComponents | null; +} + +export class MotionStateEval extends StateEval { + constructor (node: MotionState, context: LayerContext) { + super(node); + const speed = bindOr( + context, + node.speed, + VariableType.NUMBER, + this._setSpeed, + this, + ); + this._speed = speed; + const sourceEvalContext: MotionEvalContext = { + ...context, + }; + const sourceEval = node.motion?.[createEval](sourceEvalContext) ?? null; + if (sourceEval) { + Object.defineProperty(sourceEval, '__DEBUG_ID__', { value: this.name }); + } + this._source = sourceEval; + this.components = new InstantiatedComponents(node); + } + + public readonly kind = NodeKind.animation; + + public declare components: InstantiatedComponents; + + get duration () { + return this._source?.duration ?? 0.0; + } + + get fromPortTime () { + return this._fromPort.progress * this.duration; + } + + public updateFromPort (deltaTime: number) { + this._fromPort.progress = calcProgressUpdate( + this._fromPort.progress, + this.duration, + deltaTime * this._speed, + ); + } + + public updateToPort (deltaTime: number) { + this._toPort.progress = calcProgressUpdate( + this._toPort.progress, + this.duration, + deltaTime * this._speed, + ); + } + + public resetToPort () { + this._toPort.progress = 0.0; + } + + public finishTransition () { + this._fromPort.progress = this._toPort.progress; + } + + public sampleFromPort (weight: number) { + const normalized = normalizeProgress(this._fromPort.progress); + this._source?.sample(normalized, weight); + } + + public sampleToPort (weight: number) { + const normalized = normalizeProgress(this._toPort.progress); + this._source?.sample(normalized, weight); + } + + public getClipStatuses (baseWeight: number): Iterable { + const { _source: source } = this; + if (!source) { + return emptyClipStatusesIterable; + } else { + return { + [Symbol.iterator]: () => source.getClipStatuses(baseWeight), + }; + } + } + + private _source: MotionEval | null = null; + private _speed = 1.0; + private _fromPort: MotionEvalPort = { + progress: 0.0, + }; + private _toPort: MotionEvalPort = { + progress: 0.0, + }; + + private _setSpeed (value: number) { + this._speed = value; + } +} + +function calcProgressUpdate (currentProgress: number, duration: number, deltaTime: number) { + if (duration === 0.0) { + // TODO? + return 0.0; + } + const progress = currentProgress + deltaTime / duration; + return progress; +} + +function normalizeProgress (progress: number) { + return progress - Math.trunc(progress); +} + +interface MotionEvalPort { + progress: number; +} + +export class SpecialStateEval extends StateEval { + constructor (node: State, kind: SpecialStateEval['kind'], name: string) { + super(node); + this.kind = kind; + } + + public readonly kind: NodeKind.entry | NodeKind.exit | NodeKind.any; +} + +export type NodeEval = MotionStateEval | SpecialStateEval; + +interface TransitionEval { + to: NodeEval; + duration: number; + normalizedDuration: boolean; + conditions: ConditionEval[]; + exitConditionEnabled: boolean; + exitCondition: number; + /** + * Bound triggers, once this transition satisfied. All triggers would be reset. + */ + triggers: string[] | undefined; +} + +class VarInstance { + public type: VariableType; + + constructor (type: VariableType, value: Value) { + this.type = type; + this._value = value; + } + + get value () { + return this._value; + } + + set value (value) { + this._value = value; + for (const { fn, thisArg, args } of this._refs) { + fn.call(thisArg, value, ...args); + } + } + + public bind ( + fn: (this: TThis, value: T, ...args: ExtraArgs) => void, + thisArg: TThis, + ...args: ExtraArgs + ) { + this._refs.push({ + fn: fn as (this: unknown, value: unknown, ...args: unknown[]) => void, + thisArg, + args, + }); + return this._value; + } + + private _value: Value; + private _refs: VarRef[] = []; +} + +export type { VarInstance }; + +interface VarRefs { + type: VariableType; + + value: Value; + + refs: VarRef[]; +} + +interface VarRef { + fn: (this: unknown, value: unknown, ...args: unknown[]) => void; + + thisArg: unknown; + + args: unknown[]; +} diff --git a/cocos/core/animation/marionette/index.ts b/cocos/core/animation/marionette/index.ts new file mode 100644 index 00000000000..83e7f1389ca --- /dev/null +++ b/cocos/core/animation/marionette/index.ts @@ -0,0 +1,20 @@ +export { InvalidTransitionError, VariableNotDefinedError } from './errors'; +export { AnimationGraph, LayerBlending, isAnimationTransition, StateMachine, SubStateMachine } from './animation-graph'; +export type { Transition, AnimationTransition, Layer, State, Variable } from './animation-graph'; +export { BinaryCondition, UnaryCondition, TriggerCondition } from './condition'; +export type { Condition } from './condition'; +export type { Value } from './variable'; +export { MotionState } from './motion-state'; +export { ClipMotion } from './clip-motion'; +export type { AnimationBlend } from './animation-blend'; +export { AnimationBlendDirect } from './animation-blend-direct'; +export { AnimationBlend1D } from './animation-blend-1d'; +export { AnimationBlend2D } from './animation-blend-2d'; +export { AnimationController } from './animation-controller'; +export type { ClipStatus, TransitionStatus, StateStatus } from './animation-controller'; +export { VariableType } from './parametric'; +export type { BindableNumber, BindableBoolean } from './parametric'; +export { SkeletonMask } from '../skeleton-mask'; +export { StateMachineComponent } from './state-machine-component'; + +export { __getDemoGraphs } from './__tmp__/get-demo-graphs'; diff --git a/cocos/core/animation/marionette/motion-state.ts b/cocos/core/animation/marionette/motion-state.ts new file mode 100644 index 00000000000..9916ea43815 --- /dev/null +++ b/cocos/core/animation/marionette/motion-state.ts @@ -0,0 +1,21 @@ +import { ccclass, serializable } from 'cc.decorator'; +import { Motion } from './motion'; +import { State, InteractiveState } from './state'; +import { BindableNumber } from './parametric'; +import { MotionStateEval } from './graph-eval'; + +@ccclass('cc.animation.Motion') +export class MotionState extends InteractiveState { + @serializable + public motion: Motion | null = null; + + @serializable + public speed = new BindableNumber(1.0); + + public clone () { + const that = new MotionState(); + that.motion = this.motion?.clone() ?? null; + that.speed = this.speed.clone(); + return that; + } +} diff --git a/cocos/core/animation/marionette/motion.ts b/cocos/core/animation/marionette/motion.ts new file mode 100644 index 00000000000..6f1433af597 --- /dev/null +++ b/cocos/core/animation/marionette/motion.ts @@ -0,0 +1,26 @@ +import { Node } from '../../scene-graph'; +import { SkeletonMask } from '../skeleton-mask'; +import { createEval } from './create-eval'; +import type { BindContext } from './parametric'; +import type { BlendStateBuffer } from '../../../3d/skeletal-animation/skeletal-animation-blending'; +import type { ClipStatus } from './graph-eval'; + +export interface MotionEvalContext extends BindContext { + node: Node; + + blendBuffer: BlendStateBuffer; + + mask?: SkeletonMask; +} + +export interface MotionEval { + readonly duration: number; + sample(progress: number, baseWeight: number): void; + getClipStatuses(baseWeight: number): Iterator; +} + +export interface Motion { + [createEval] (context: MotionEvalContext): MotionEval | null; + + clone(): Motion; +} diff --git a/cocos/core/animation/marionette/ownership.ts b/cocos/core/animation/marionette/ownership.ts new file mode 100644 index 00000000000..4afeae5eae2 --- /dev/null +++ b/cocos/core/animation/marionette/ownership.ts @@ -0,0 +1,24 @@ +import { DEBUG } from 'internal:constants'; +import { assertIsTrue } from '../../data/utils/asserts'; + +export const ownerSymbol = Symbol('[[Owner]]'); + +export interface OwnedBy { + [ownerSymbol]: T | undefined; +} + +export function assertsOwnedBy (mastered: OwnedBy, owner: T) { + assertIsTrue(mastered[ownerSymbol] === owner); +} + +export function own (mastered: OwnedBy, owner: T) { + if (DEBUG) { + mastered[ownerSymbol] = owner; + } +} + +export function markAsDangling (mastered: OwnedBy) { + if (DEBUG) { + mastered[ownerSymbol] = undefined; + } +} diff --git a/cocos/core/animation/marionette/parametric.ts b/cocos/core/animation/marionette/parametric.ts new file mode 100644 index 00000000000..b938c5f05a7 --- /dev/null +++ b/cocos/core/animation/marionette/parametric.ts @@ -0,0 +1,164 @@ +import { ccclass, serializable } from '../../data/decorators'; +import { assertIsTrue } from '../../data/utils/asserts'; +import { warn } from '../../platform/debug'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; +import { VariableNotDefinedError, VariableTypeMismatchedError } from './errors'; +import type { VarInstance } from './graph-eval'; + +export enum VariableType { + NUMBER, + + BOOLEAN, + + TRIGGER, + + INTEGER, +} + +export interface Bindable { + value: TValue; + + variable: string; + + clone(): Bindable; +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}BindableNumber`) +export class BindableNumber implements Bindable { + @serializable + public variable = ''; + + @serializable + public value = 0.0; + + constructor (value = 0.0) { + this.value = value; + } + + public clone (): Bindable { + const that = new BindableNumber(); + that.value = this.value; + that.variable = this.variable; + return that; + } +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}BindableBoolean`) +export class BindableBoolean implements Bindable { + @serializable + public variable = ''; + + @serializable + public value = false; + + constructor (value = false) { + this.value = value; + } + + public clone (): Bindable { + const that = new BindableBoolean(); + that.value = this.value; + that.variable = this.variable; + return that; + } +} + +export type BindCallback = + (this: TThis, value: TValue, ...args: TArgs) => void; + +export type VariableTypeValidator = () => void; + +export interface BindContext { + getVar(id: string): VarInstance | undefined; +} + +export function bindOr ( + context: BindContext, + bindable: Bindable, + type: VariableType, + callback: BindCallback, + thisArg: TThis, + ...args: TArgs +): TValue { + const { + variable, + value, + } = bindable; + + if (!variable) { + return value; + } + + const varInstance = context.getVar(variable); + if (!validateVariableExistence(varInstance, variable)) { + return value; + } + + if (varInstance.type !== type) { + throw new VariableTypeMismatchedError(variable, 'number'); + } + + const initialValue = varInstance.bind( + callback, + thisArg, + ...args, + ); + + return initialValue as unknown as TValue; +} + +export function bindNumericOr ( + context: BindContext, + bindable: Bindable, + type: VariableType, + callback: BindCallback, + thisArg: TThis, + ...args: TArgs +) { + const { + variable, + value, + } = bindable; + + if (!variable) { + return value; + } + + const varInstance = context.getVar(variable); + if (!validateVariableExistence(varInstance, variable)) { + return value; + } + + if (type !== VariableType.NUMBER && type !== VariableType.INTEGER) { + throw new VariableTypeMismatchedError(variable, 'number or integer'); + } + + const initialValue = varInstance.bind( + callback, + thisArg, + ...args, + ); + + return initialValue as unknown as number; +} + +export function validateVariableExistence (varInstance: VarInstance | undefined, name: string): varInstance is VarInstance { + if (!varInstance) { + // TODO, warn only? + throw new VariableNotDefinedError(name); + } else { + return true; + } +} + +export function validateVariableType (type: VariableType, expected: VariableType, name: string) { + if (type !== expected) { + throw new VariableTypeMismatchedError(name, 'number'); + } +} + +export function validateVariableTypeNumeric (type: VariableType, name: string) { + if (type !== VariableType.NUMBER && type !== VariableType.INTEGER) { + throw new VariableTypeMismatchedError(name, 'number or integer'); + } +} diff --git a/cocos/core/animation/marionette/state-machine-component.ts b/cocos/core/animation/marionette/state-machine-component.ts new file mode 100644 index 00000000000..3130df4a541 --- /dev/null +++ b/cocos/core/animation/marionette/state-machine-component.ts @@ -0,0 +1,10 @@ +import { ccclass } from 'cc.decorator'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; +import type { AnimationController } from './animation-controller'; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}StateMachineComponent`) +export class StateMachineComponent { + public onEnter (_newGenAnim: AnimationController): void { } + + public onExit (_newGenAnim: AnimationController): void { } +} diff --git a/cocos/core/animation/marionette/state.ts b/cocos/core/animation/marionette/state.ts new file mode 100644 index 00000000000..095d76ecc10 --- /dev/null +++ b/cocos/core/animation/marionette/state.ts @@ -0,0 +1,62 @@ +import { ccclass, serializable } from 'cc.decorator'; +import { OwnedBy, ownerSymbol } from './ownership'; +import type { Layer, StateMachine, TransitionInternal } from './animation-graph'; +import { EditorExtendable } from '../../data/editor-extendable'; +import { CLASS_NAME_PREFIX_ANIM } from '../define'; +import { StateMachineComponent } from './state-machine-component'; +import { remove } from '../../utils/array'; +import { instantiate } from '../../data/instantiate'; + +export const outgoingsSymbol = Symbol('[[Outgoing transitions]]'); + +export const incomingsSymbol = Symbol('[[Incoming transitions]]'); + +@ccclass('cc.animation.State') +export class State extends EditorExtendable implements OwnedBy { + declare [ownerSymbol]: StateMachine | undefined; + + @serializable + public name = ''; + + public [outgoingsSymbol]: TransitionInternal[] = []; + + public [incomingsSymbol]: TransitionInternal[] = []; + + /** + * @internal + */ + constructor () { + super(); + } +} + +type StateMachineComponentConstructor = Constructor; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}InteractiveState`) +export class InteractiveState extends State { + get components (): Iterable { + return this._components; + } + + public addComponent (constructor: StateMachineComponentConstructor) { + const component = new constructor(); + this._components.push(component); + return component; + } + + public removeComponent (component: StateMachineComponent) { + remove(this._components, component); + } + + public instantiateComponents (): StateMachineComponent[] { + const instantiatedComponents = this._components.map((component) => { + // @ts-expect-error Typing + const instantiated = instantiate(component, true) as unknown as StateMachineComponent; + return instantiated; + }); + return instantiatedComponents; + } + + @serializable + private _components: StateMachineComponent[] = []; +} diff --git a/cocos/core/animation/marionette/variable.ts b/cocos/core/animation/marionette/variable.ts new file mode 100644 index 00000000000..8d3ad2f9ffa --- /dev/null +++ b/cocos/core/animation/marionette/variable.ts @@ -0,0 +1 @@ +export type Value = number | string | boolean; diff --git a/cocos/core/animation/skeleton-mask.ts b/cocos/core/animation/skeleton-mask.ts new file mode 100644 index 00000000000..2dccd0d8c83 --- /dev/null +++ b/cocos/core/animation/skeleton-mask.ts @@ -0,0 +1,42 @@ +import { ccclass, serializable, type } from 'cc.decorator'; +import { Asset } from '../assets/asset'; + +@ccclass('cc.SkeletonMask') +export class SkeletonMask extends Asset { + @serializable + private _jointMasks: JointMask[] = []; + + get joints (): Iterable { + return this._jointMasks; + } + + set joints (value: Iterable) { + this._jointMasks = Array.from(value, ({ path, enabled }) => { + const jointMask = new JointMask(); + jointMask.path = path; + jointMask.enabled = enabled; + return jointMask; + }); + } +} + +export declare namespace SkeletonMask { + export type JointMaskInfo = JointMaskInfo_; +} + +interface JointMaskInfo { + readonly path: string; + + enabled: boolean; +} + +type JointMaskInfo_ = JointMaskInfo; + +@ccclass('cc.JointMask') +class JointMask { + @serializable + public path!: string; + + @serializable + public enabled!: boolean; +} diff --git a/cocos/core/components/component-event-handler.ts b/cocos/core/components/component-event-handler.ts index 05a02f75934..f8e3c75a278 100644 --- a/cocos/core/components/component-event-handler.ts +++ b/cocos/core/components/component-event-handler.ts @@ -99,6 +99,7 @@ export class EventHandler { */ @serializable @type(legacyCC.Node) + @serializable @tooltip('i18n:button.click_event.target') public target: Node | null = null; /** diff --git a/cocos/core/data/deserialize-dynamic.ts b/cocos/core/data/deserialize-dynamic.ts index 2349606e4c0..3355a03be7b 100644 --- a/cocos/core/data/deserialize-dynamic.ts +++ b/cocos/core/data/deserialize-dynamic.ts @@ -44,6 +44,7 @@ import type { deserialize, CCClassConstructor } from './deserialize'; import { CCON } from './ccon'; import { assertIsTrue } from './utils/asserts'; import { reportMissingClass as defaultReportMissingClass } from './report-missing-class'; +import { onAfterDeserializedTag } from './deserialize-symbols'; function compileObjectTypeJit ( sources: string[], @@ -568,11 +569,17 @@ class _Deserializer { if (!(EDITOR && legacyCC.js.isChildClassOf(klass, legacyCC.Component))) { const obj = createObject(klass); this._deserializeInto(value, obj, klass); + if ((obj as { [onAfterDeserializedTag]?(): void; })[onAfterDeserializedTag]) { + (obj as { [onAfterDeserializedTag]?(): void; })[onAfterDeserializedTag]!(); + } return obj; } else { try { const obj = createObject(klass); this._deserializeInto(value, obj, klass); + if ((obj as { [onAfterDeserializedTag]?(): void; })[onAfterDeserializedTag]) { + (obj as { [onAfterDeserializedTag]?(): void; })[onAfterDeserializedTag]!(); + } return obj; } catch (e: unknown) { if (DEBUG) { diff --git a/cocos/core/data/deserialize-symbols.ts b/cocos/core/data/deserialize-symbols.ts new file mode 100644 index 00000000000..6e91702b709 --- /dev/null +++ b/cocos/core/data/deserialize-symbols.ts @@ -0,0 +1 @@ +export const onAfterDeserializedTag = Symbol('[[OnAfterDeserialized]]'); diff --git a/cocos/core/data/utils/asserts.ts b/cocos/core/data/utils/asserts.ts index 5a6eee58787..57b5293a845 100644 --- a/cocos/core/data/utils/asserts.ts +++ b/cocos/core/data/utils/asserts.ts @@ -42,7 +42,7 @@ export function assertIsNonNullable (expr: T, message?: string): asserts expr export function assertIsTrue (expr: unknown, message?: string): asserts expr { if (DEBUG && !expr) { // eslint-disable-next-line no-debugger - debugger; + // debugger; throw new Error(`Assertion failed: ${message ?? ''}`); } } diff --git a/editor/assets/default_file_content/animgraph b/editor/assets/default_file_content/animgraph new file mode 100644 index 00000000000..72bac0cc9e4 --- /dev/null +++ b/editor/assets/default_file_content/animgraph @@ -0,0 +1,60 @@ +[ + { + "__type__": "cc.animation.AnimationGraph", + "_name": "", + "_objFlags": 0, + "_native": "", + "_layers": [ + { + "__id__": 1 + } + ], + "_variables": {} + }, + { + "__type__": "cc.animation.Layer", + "_stateMachine": { + "__id__": 2 + }, + "name": "", + "weight": 1, + "mask": null, + "blending": 1 + }, + { + "__type__": "cc.animation.StateMachine", + "_states": [ + { + "__id__": 3 + }, + { + "__id__": 4 + }, + { + "__id__": 5 + } + ], + "_transitions": [], + "_entryState": { + "__id__": 3 + }, + "_exitState": { + "__id__": 4 + }, + "_anyState": { + "__id__": 5 + } + }, + { + "__type__": "cc.animation.State", + "name": "Entry" + }, + { + "__type__": "cc.animation.State", + "name": "Exit" + }, + { + "__type__": "cc.animation.State", + "name": "Any" + } +] \ No newline at end of file diff --git a/editor/assets/default_file_content/animgraph.meta b/editor/assets/default_file_content/animgraph.meta new file mode 100644 index 00000000000..dce50cc080c --- /dev/null +++ b/editor/assets/default_file_content/animgraph.meta @@ -0,0 +1,12 @@ +{ + "ver": "1.0.0", + "importer": "*", + "imported": true, + "uuid": "e0a8e34d-9242-4f0f-833b-f57e1ffbf285", + "files": [ + "", + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/editor/assets/default_file_content/ts-animation-graph b/editor/assets/default_file_content/ts-animation-graph new file mode 100644 index 00000000000..9d3c880040f --- /dev/null +++ b/editor/assets/default_file_content/ts-animation-graph @@ -0,0 +1,26 @@ +import { _decorator, animation } from "cc"; +const { ccclass, property } = _decorator; + +/** + * Predefined variables + * Name = <%Name%> + * DateTime = <%DateTime%> + * Author = <%Author%> + * FileBasename = <%FileBasename%> + * FileBasenameNoExtension = <%FileBasenameNoExtension%> + * URL = <%URL%> + * ManualUrl = <%ManualUrl%> + * + */ + +@ccclass("<%Name%>") +export class <%Name%> extends animation.StateMachineComponent { + + onEnter (controller: animation.AnimationController) { + + } + + onExit (controller: animation.AnimationController) { + + } +} diff --git a/editor/assets/default_file_content/ts-animation-graph.meta b/editor/assets/default_file_content/ts-animation-graph.meta new file mode 100644 index 00000000000..230e1051761 --- /dev/null +++ b/editor/assets/default_file_content/ts-animation-graph.meta @@ -0,0 +1,12 @@ +{ + "ver": "1.0.0", + "importer": "*", + "imported": true, + "uuid": "f102759d-fcb3-4103-82e5-9a955401466a", + "files": [ + "", + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/editor/assets/default_hdr_skybox/cloudy/kloppenheim.hdr b/editor/assets/default_hdr_skybox/cloudy/kloppenheim.hdr new file mode 100644 index 00000000000..0738cf0ad82 Binary files /dev/null and b/editor/assets/default_hdr_skybox/cloudy/kloppenheim.hdr differ diff --git a/editor/assets/default_hdr_skybox/cloudy/sunflowers.hdr b/editor/assets/default_hdr_skybox/cloudy/sunflowers.hdr new file mode 100644 index 00000000000..70c83f2cafe Binary files /dev/null and b/editor/assets/default_hdr_skybox/cloudy/sunflowers.hdr differ diff --git a/editor/assets/default_hdr_skybox/cloudy/winter_lake.hdr b/editor/assets/default_hdr_skybox/cloudy/winter_lake.hdr new file mode 100644 index 00000000000..07c85f41360 Binary files /dev/null and b/editor/assets/default_hdr_skybox/cloudy/winter_lake.hdr differ diff --git a/editor/assets/default_hdr_skybox/dawn/kiara_dawn.hdr b/editor/assets/default_hdr_skybox/dawn/kiara_dawn.hdr new file mode 100644 index 00000000000..7a48cd06890 Binary files /dev/null and b/editor/assets/default_hdr_skybox/dawn/kiara_dawn.hdr differ diff --git a/editor/assets/default_hdr_skybox/dawn/spruit_sunrise.hdr b/editor/assets/default_hdr_skybox/dawn/spruit_sunrise.hdr new file mode 100644 index 00000000000..b1da6c49908 Binary files /dev/null and b/editor/assets/default_hdr_skybox/dawn/spruit_sunrise.hdr differ diff --git a/editor/assets/default_hdr_skybox/dawn/sunset_fairway.hdr b/editor/assets/default_hdr_skybox/dawn/sunset_fairway.hdr new file mode 100644 index 00000000000..e5235758212 Binary files /dev/null and b/editor/assets/default_hdr_skybox/dawn/sunset_fairway.hdr differ diff --git a/editor/assets/default_hdr_skybox/dawn/table_mountain.hdr b/editor/assets/default_hdr_skybox/dawn/table_mountain.hdr new file mode 100644 index 00000000000..6ef94990e91 Binary files /dev/null and b/editor/assets/default_hdr_skybox/dawn/table_mountain.hdr differ diff --git a/editor/assets/default_hdr_skybox/day_light/hilly_terrain.hdr b/editor/assets/default_hdr_skybox/day_light/hilly_terrain.hdr new file mode 100644 index 00000000000..057d51df253 Binary files /dev/null and b/editor/assets/default_hdr_skybox/day_light/hilly_terrain.hdr differ diff --git a/editor/assets/default_hdr_skybox/day_light/kiara_afternoon.hdr b/editor/assets/default_hdr_skybox/day_light/kiara_afternoon.hdr new file mode 100644 index 00000000000..ee7c105c936 Binary files /dev/null and b/editor/assets/default_hdr_skybox/day_light/kiara_afternoon.hdr differ diff --git a/editor/assets/default_hdr_skybox/day_light/qwantani.hdr b/editor/assets/default_hdr_skybox/day_light/qwantani.hdr new file mode 100644 index 00000000000..766e41388b4 Binary files /dev/null and b/editor/assets/default_hdr_skybox/day_light/qwantani.hdr differ diff --git a/editor/assets/default_hdr_skybox/day_light/rooitou_park.hdr b/editor/assets/default_hdr_skybox/day_light/rooitou_park.hdr new file mode 100644 index 00000000000..4cdc5bd84f7 Binary files /dev/null and b/editor/assets/default_hdr_skybox/day_light/rooitou_park.hdr differ diff --git a/editor/assets/default_hdr_skybox/night/kloppenheim.hdr b/editor/assets/default_hdr_skybox/night/kloppenheim.hdr new file mode 100644 index 00000000000..760aad3f34f Binary files /dev/null and b/editor/assets/default_hdr_skybox/night/kloppenheim.hdr differ diff --git a/editor/assets/default_hdr_skybox/night/moonless_golf.hdr b/editor/assets/default_hdr_skybox/night/moonless_golf.hdr new file mode 100644 index 00000000000..7da5c8952ba Binary files /dev/null and b/editor/assets/default_hdr_skybox/night/moonless_golf.hdr differ diff --git a/editor/assets/default_hdr_skybox/night/moonlit_golf.hdr b/editor/assets/default_hdr_skybox/night/moonlit_golf.hdr new file mode 100644 index 00000000000..ed62bab948d Binary files /dev/null and b/editor/assets/default_hdr_skybox/night/moonlit_golf.hdr differ diff --git a/editor/exports/new-gen-anim.ts b/editor/exports/new-gen-anim.ts new file mode 100644 index 00000000000..03b73e42796 --- /dev/null +++ b/editor/exports/new-gen-anim.ts @@ -0,0 +1,14 @@ + +export { + blend1D, +} from '../../cocos/core/animation/marionette/blend-1d'; + +export { + blendSimpleDirectional, + validateSimpleDirectionalSamples, + SimpleDirectionalIssueSameDirection, +} from '../../cocos/core/animation/marionette/blend-2d'; + +export type { + SimpleDirectionalSampleIssue, +} from '../../cocos/core/animation/marionette/blend-2d'; diff --git a/editor/inspector/assets.js b/editor/inspector/assets.js index 713fa9fc6de..7aea447edf2 100644 --- a/editor/inspector/assets.js +++ b/editor/inspector/assets.js @@ -1,6 +1,7 @@ const { join } = require('path'); module.exports = { + 'animation-graph': join(__dirname, './assets/animation-graph.js'), 'audio-clip': join(__dirname, './assets/audio-clip.js'), 'auto-atlas': join(__dirname, './assets/texture/auto-atlas.js'), // reuse 'dragonbones-atlas': join(__dirname, './assets/json.js'), // reuse diff --git a/editor/inspector/assets/animation-graph.js b/editor/inspector/assets/animation-graph.js new file mode 100644 index 00000000000..cd1b9a45430 --- /dev/null +++ b/editor/inspector/assets/animation-graph.js @@ -0,0 +1,51 @@ +exports.template = ` +
+ Open Animation Graph Editor Panel + +
+`; + +exports.style = ` +.asset-animation-graph { + padding-top: 10px; + text-align: center; +} + +.asset-animation-graph .tip { + color: var(--color-focus-contrast-weakest); +} +`; + +exports.$ = { + constainer: '.asset-animation-graph', + button: '.open', + tip: '.tip', +}; + +exports.ready = function() { + this.$.button.addEventListener('click', async () => { + await Editor.Message.request('scene', 'execute-scene-script', { + name: 'animation-graph', + method: 'edit', + args: [this.asset.uuid], + }); + + Editor.Panel.open('animation-graph'); + }); +}; + +exports.update = function(assetList, metaList) { + this.assetList = assetList; + this.metaList = metaList; + this.meta = this.metaList[0]; + this.asset = this.assetList[0]; + + if (assetList.length !== 1) { + this.$.button.disabled = true; + this.$.tip.style.display = 'block'; + } else { + this.$.button.disabled = false; + this.$.tip.style.display = 'none'; + } +}; + diff --git a/tests/animation/graphs/any-transition.ts b/tests/animation/graphs/any-transition.ts new file mode 100644 index 00000000000..5f71d83def3 --- /dev/null +++ b/tests/animation/graphs/any-transition.ts @@ -0,0 +1,13 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + }], + anyTransitions: [{ + to: 0, + }], + }, + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/infinity-loop.ts b/tests/animation/graphs/infinity-loop.ts new file mode 100644 index 00000000000..f6bfd58d099 --- /dev/null +++ b/tests/animation/graphs/infinity-loop.ts @@ -0,0 +1,25 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + transitions: [{ + from: 0, + to: 1, + exitCondition: 0.0, + }, { + from: 1, + to: 0, + exitCondition: 0.0, + }], + }, + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/pose-blend-requires-numbers.ts b/tests/animation/graphs/pose-blend-requires-numbers.ts new file mode 100644 index 00000000000..379a878a45a --- /dev/null +++ b/tests/animation/graphs/pose-blend-requires-numbers.ts @@ -0,0 +1,26 @@ +export default { + vars: [{ + name: 'v', + value: false, + }], + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + motion: { + type: 'blend', + children: [{ type: 'clip' }, { type: 'clip' }], + blender: { + type: '1d', + thresholds: [0.0, 1.0], + value: { + name: 'v', + value: 0, + }, + }, + }, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/successive-satisfaction.ts b/tests/animation/graphs/successive-satisfaction.ts new file mode 100644 index 00000000000..d3d9d743bd8 --- /dev/null +++ b/tests/animation/graphs/successive-satisfaction.ts @@ -0,0 +1,21 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + transitions: [{ + from: 0, + to: 1, + exitCondition: 0.0, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/unspecified-condition-for-non-entry-node.ts b/tests/animation/graphs/unspecified-condition-for-non-entry-node.ts new file mode 100644 index 00000000000..9f067f133ed --- /dev/null +++ b/tests/animation/graphs/unspecified-condition-for-non-entry-node.ts @@ -0,0 +1,22 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + transitions: [{ + from: 0, + to: 1, + duration: 0.3, + exitCondition: 0.0, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/unspecified-condition.ts b/tests/animation/graphs/unspecified-condition.ts new file mode 100644 index 00000000000..7a222701d98 --- /dev/null +++ b/tests/animation/graphs/unspecified-condition.ts @@ -0,0 +1,13 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'asd', + type: 'animation', + }], + entryTransitions: [{ + to: 0, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/variable-not-found-in-condition.ts b/tests/animation/graphs/variable-not-found-in-condition.ts new file mode 100644 index 00000000000..c5a6678e60a --- /dev/null +++ b/tests/animation/graphs/variable-not-found-in-condition.ts @@ -0,0 +1,17 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + }], + entryTransitions: [{ + to: 0, + conditions: [{ + type: 'unary', + operator: 'TRUTHY', + operand: { name: 'asd', value: 0.0 }, + }], + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/variable-not-found-in-pose-blend.ts b/tests/animation/graphs/variable-not-found-in-pose-blend.ts new file mode 100644 index 00000000000..5e7facf6b16 --- /dev/null +++ b/tests/animation/graphs/variable-not-found-in-pose-blend.ts @@ -0,0 +1,22 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + motion: { + type: 'blend', + children: [{ type: 'clip' }, { type: 'clip' }], + blender: { + type: '1d', + thresholds: [0.0, 1.0], + value: { + name: 'asd', + value: 0, + }, + }, + }, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/graphs/zero-time-piece.ts b/tests/animation/graphs/zero-time-piece.ts new file mode 100644 index 00000000000..349f66601b0 --- /dev/null +++ b/tests/animation/graphs/zero-time-piece.ts @@ -0,0 +1,34 @@ +export default { + layers: [{ + graph: { + nodes: [{ + name: 'Node1', + type: 'animation', + }, { + name: 'Node2', + type: 'subgraph', + nodes: [{ + type: 'animation', + name: 'SubStateMachineNode1', + }], + entryTransitions: [{ + to: 0, + }], + exitTransitions: [{ + from: 0, + exitCondition: 0.0, + }], + }], + entryTransitions: [{ + to: 0, + }], + exitTransitions: [{ + from: 1, + }], + transitions: [{ + from: 0, + to: 1, + }], + } + }], +} as import('../../../cocos/core/animation/marionette/__tmp__/graph-description').GraphDescription; \ No newline at end of file diff --git a/tests/animation/mask.test.ts b/tests/animation/mask.test.ts new file mode 100644 index 00000000000..26cfc7641f4 --- /dev/null +++ b/tests/animation/mask.test.ts @@ -0,0 +1,115 @@ +import { AnimationState } from '../../cocos/core/animation/animation-state'; +import { AnimationClip } from '../../cocos/core/animation/animation-clip'; +import { SkeletonMask } from '../../cocos/core/animation/skeleton-mask'; +import { Node } from '../../cocos/core/scene-graph/node'; +import { HierarchyPath } from '../../cocos/core/animation/target-path'; + +describe('Skeleton Mask', () => { + test('Apply mask', () => { + const mask = createMaskFromJson({ + name: 'root', + enabled: true, + children: [ + { + name: 'spine', + enabled: true, + children: [ + { + name: 'LeftShoulder', + enabled: true, + children: [ + { name: 'LeftHand', enabled: true }, + ], + }, + { name: 'RightShoulder', enabled: true }, + ], + }, + { + name: 'LeftLeg', + enabled: false, + children: [ + { + name: 'LeftKnee', + enabled: false, + }, + ], + }, + { + name: 'RightLeg', + enabled: false, + }, + ], + }); + + const clip = new AnimationClip(); + clip.curves = [ + { + // Filter out the root + modifiers: [ + new HierarchyPath('LeftLeg'), + ], + data: { + keys: 0, + values: [], + }, + }, + { + // Filter out the subpath + modifiers: [ + new HierarchyPath('LeftLeg/LeftKnee'), + ], + data: { + keys: 0, + values: [], + }, + }, + { + // Disabled + modifiers: [ + new HierarchyPath('spine/LeftShoulder/LeftHand'), + ], + data: { + keys: 0, + values: [], + }, + }, + { + // Incomplete path + modifiers: [ + new HierarchyPath('LeftShoulder/LeftHand'), + ], + data: { + keys: 0, + values: [], + }, + }, + ]; + + const state = new AnimationState(clip); + state.initialize(new Node(), undefined, mask); + }); +}); + +interface MaskJson { + name: string, + enabled: boolean; + children?: MaskJson[]; +} + +function createMaskFromJson (maskJson: MaskJson) { + const jointMaskInfos: SkeletonMask.JointMaskInfo[] = []; + visit(maskJson, ''); + const mask = new SkeletonMask(); + mask.joints = jointMaskInfos; + return mask; + + function visit (maskJson: MaskJson, parentPath: string) { + const path = parentPath ? `${parentPath}/${maskJson.name}` : maskJson.name; + jointMaskInfos.push({ path, enabled: maskJson.enabled }); + if (maskJson.children) { + for (const child of maskJson.children) { + visit(child, path); + } + } + } +} \ No newline at end of file diff --git a/tests/animation/new-gen-anim/blend-algorithms/blend-2d.test.ts b/tests/animation/new-gen-anim/blend-algorithms/blend-2d.test.ts new file mode 100644 index 00000000000..3b03dda001c --- /dev/null +++ b/tests/animation/new-gen-anim/blend-algorithms/blend-2d.test.ts @@ -0,0 +1,193 @@ +import { Vec2 } from "../../../../cocos/core"; +import { sampleFreeformDirectional, blendSimpleDirectional } from "../../../../cocos/core/animation/marionette/blend-2d"; +import '../../../utils/matcher-deep-close-to'; + +const EXPECT_NUM_DIGITS = 5; + +describe('Simple directional 2D', () => { + function calcSimpleDirectional (samples: readonly Vec2[], input: Vec2) { + const weights = new Array(samples.length).fill(0); + blendSimpleDirectional(weights, samples, input); + return weights; + } + + test('Zero or one sample', () => { + expect(() => calcSimpleDirectional([], new Vec2(3.14, 6.18))).not.toThrow(); + + expect(calcSimpleDirectional([new Vec2(-12.3, 45.6)], new Vec2(78.9, 3.14))).toBeDeepCloseTo([1.0], EXPECT_NUM_DIGITS); + }); + + test('Input direction is zero', () => { + // If there is also zero sample, that sample owns all weights. + expect(calcSimpleDirectional([ + new Vec2(-0.3, 0.4), + new Vec2(+1.0, 0.2), + new Vec2(0.0, 0.0), + new Vec2(0.0, -1.0), + new Vec2(0.0, +1.0), + ], Vec2.ZERO)).toBeDeepCloseTo([ + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + ], EXPECT_NUM_DIGITS); + + // Otherwise, weights are averaged for all samples. + expect(calcSimpleDirectional([ + new Vec2(-0.3, 0.4), + new Vec2(+1.0, 0.2), + new Vec2(0.0, -1.0), + new Vec2(0.0, +1.0), + ], Vec2.ZERO)).toBeDeepCloseTo([ + 0.25, + 0.25, + 0.25, + 0.25, + ], EXPECT_NUM_DIGITS); + }); + + test('Input within a sector', () => { + // Points can be visualized at: https://www.desmos.com/calculator/rapuusa3g1 + + const calcEdgeLinePoint = (from: Vec2, to: Vec2, ratio: number) => { + return Vec2.scaleAndAdd( + new Vec2(), + from, + Vec2.subtract(new Vec2(), to, from), + ratio, + ); + }; + + const sampleTriangleVertexA = new Vec2(0.2, 0.7); + const sampleTriangleVertexB = new Vec2(0.3, 0.4); + const sampleOutOfSector1 = new Vec2(0.0576,0.583); + const sampleOutOfSector2 = new Vec2(0.243,-0.22); + const inputInsideTriangle = new Vec2(0.15, 0.4); + const inputOnEdgeABRatio = 0.34; + const inputOnEdgeAB = calcEdgeLinePoint(sampleTriangleVertexA, sampleTriangleVertexB, inputOnEdgeABRatio); + const inputOnEdgeCARatio = 0.711; + const inputOnEdgeCA = calcEdgeLinePoint(Vec2.ZERO, sampleTriangleVertexA, inputOnEdgeCARatio); + const inputOnEdgeCAExtended = calcEdgeLinePoint(Vec2.ZERO, sampleTriangleVertexA, 1.3); + const inputOnEdgeCBExtended = calcEdgeLinePoint(Vec2.ZERO, sampleTriangleVertexB, -0.4); + const inputInsideSectorOutSideTriangle = new Vec2(0.2765,0.682); + + const samples = [ + sampleTriangleVertexA, + sampleTriangleVertexB, + sampleOutOfSector1, + sampleOutOfSector2, + ]; + + const samplesWithCentralSample = [ + ...samples, + Vec2.ZERO, + ]; + + const weightsOfSample = ({ + sampleTriangleVertexA = 0.0, + sampleTriangleVertexB = 0.0, + sampleOutOfSector1 = 0.0, + sampleOutOfSector2 = 0.0, + }: Partial>) => { + return [ + sampleTriangleVertexA, + sampleTriangleVertexB, + sampleOutOfSector1, + sampleOutOfSector2, + ]; + }; + + const weightsOfSampleWithCentralSample = ({ sampleCentral = 0.0, ...other }: Parameters[0] & { + sampleCentral?: number; + }) => { + return [ + ...weightsOfSample(other), + sampleCentral, + ]; + }; + + // Outside any sector. + expect(calcSimpleDirectional([ + sampleTriangleVertexA, + sampleTriangleVertexB, + ], new Vec2(0.4, 0.4))).toBeDeepCloseTo([ + 0.5, + 0.5, + ], EXPECT_NUM_DIGITS); + + // Inside triangle, without central sample + // central weight are averaged to all + expect(calcSimpleDirectional(samples, inputInsideTriangle)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: 0.46154 + 0.34615 / samples.length, + sampleTriangleVertexB: 0.19231 + 0.34615 / samples.length, + sampleOutOfSector1: 0.34615 / samples.length, + sampleOutOfSector2: 0.34615 / samples.length, + }), EXPECT_NUM_DIGITS); + + // Inside triangle, with central sample + expect(calcSimpleDirectional(samplesWithCentralSample, inputInsideTriangle)).toBeDeepCloseTo(weightsOfSampleWithCentralSample({ + sampleTriangleVertexA: 0.46154, + sampleTriangleVertexB: 0.19231, + sampleCentral: 0.34615, + }), EXPECT_NUM_DIGITS); + + // Fall at vertex + expect(calcSimpleDirectional(samples, sampleTriangleVertexA)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: 1.0, + }), EXPECT_NUM_DIGITS); + + // Fall at edge(between center vertex and vertex A), without central sample + expect(calcSimpleDirectional(samples, inputOnEdgeCA)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: inputOnEdgeCARatio + (1.0 - inputOnEdgeCARatio) / samples.length, + sampleTriangleVertexB: (1.0 - inputOnEdgeCARatio) / samples.length, + sampleOutOfSector1: (1.0 - inputOnEdgeCARatio) / samples.length, + sampleOutOfSector2: (1.0 - inputOnEdgeCARatio) / samples.length, + }), EXPECT_NUM_DIGITS); + + // Fall at edge(between center vertex and vertex A), with central sample + expect(calcSimpleDirectional(samplesWithCentralSample, inputOnEdgeCA)).toBeDeepCloseTo(weightsOfSampleWithCentralSample({ + sampleCentral: 1.0 - inputOnEdgeCARatio, + sampleTriangleVertexA: inputOnEdgeCARatio, + }), EXPECT_NUM_DIGITS); + + // Fall on edge(between vertex A, B) + expect(calcSimpleDirectional(samples, inputOnEdgeAB)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: (1.0 - inputOnEdgeABRatio), + sampleTriangleVertexB: inputOnEdgeABRatio, + }), EXPECT_NUM_DIGITS); + + // Fall on line(C -> A) but not edge(C -> A), beyond A. + // CentBarycentric coordinates are normalized. + expect(calcSimpleDirectional(samples, inputOnEdgeCAExtended)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: 1.0, + }), EXPECT_NUM_DIGITS); + + // Fall on line(C -> B) but not edge(C -> B), not beyond C, without central sample. + // Only central point got weight, but there is no central sample. So averaged to every sample. + expect(calcSimpleDirectional(samples, inputOnEdgeCBExtended)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: 1.0 / samples.length, + sampleTriangleVertexB: 1.0 / samples.length, + sampleOutOfSector1: 1.0 / samples.length, + sampleOutOfSector2: 1.0 / samples.length, + }), EXPECT_NUM_DIGITS); + + // Fall on line(C -> B) but not edge(C -> B), not beyond C, with central sample. + // Only central point got weight, + expect(calcSimpleDirectional(samplesWithCentralSample, inputOnEdgeCBExtended)).toBeDeepCloseTo(weightsOfSampleWithCentralSample({ + sampleCentral: 1.0, + }), EXPECT_NUM_DIGITS); + + // Inside sector, but outside triangle. + // Similar with "fall on line" case: centBarycentric coordinates are normalized. + expect(calcSimpleDirectional(samples, inputInsideSectorOutSideTriangle)).toBeDeepCloseTo(weightsOfSample({ + sampleTriangleVertexA: 0.6219, + sampleTriangleVertexB: 0.3781, + }), EXPECT_NUM_DIGITS); + }); +}); \ No newline at end of file diff --git a/tests/animation/new-gen-anim/blend-algorithms/inverse-distance-weighting.ts b/tests/animation/new-gen-anim/blend-algorithms/inverse-distance-weighting.ts new file mode 100644 index 00000000000..fb980570638 --- /dev/null +++ b/tests/animation/new-gen-anim/blend-algorithms/inverse-distance-weighting.ts @@ -0,0 +1,24 @@ +import { Vec2 } from "../../../../cocos/core"; + +export function inverseDistanceWeighting2D (weights: number[], samples: readonly Vec2[], value: Readonly, power: number) { + const invDistances = new Array(samples.length).fill(0.0); + + let sumInvDistance = 0.0; + for (let iSample = 0; iSample < samples.length; ++iSample) { + const sample = samples[iSample]; + const distanceSqr = Vec2.squaredDistance(sample, value); + if (distanceSqr === 0.0) { + weights.fill(0.0); + weights[iSample] = 1.0; + return; + } + // 1 / (d^p) + const invDistance = distanceSqr ** -(0.5 * power); + invDistances[iSample] = invDistance; + sumInvDistance += invDistance; + } + + for (let iSample = 0; iSample < samples.length; ++iSample) { + weights[iSample] = invDistances[iSample] / sumInvDistance; + } +} \ No newline at end of file diff --git a/tests/animation/newgenanim.test.ts b/tests/animation/newgenanim.test.ts new file mode 100644 index 00000000000..a71341cda4b --- /dev/null +++ b/tests/animation/newgenanim.test.ts @@ -0,0 +1,1462 @@ + +import { AnimationClip, Component, Node, Vec2, Vec3, warnID } from '../../cocos/core'; +import { AnimationBlend1D, AnimationBlend2D, Condition, InvalidTransitionError, VariableNotDefinedError, __getDemoGraphs, ClipMotion, AnimationBlendDirect, VectorTrack, VariableType } from '../../cocos/core/animation/animation'; +import { LayerBlending, AnimationGraph, StateMachine, Transition, isAnimationTransition, AnimationTransition } from '../../cocos/core/animation/marionette/animation-graph'; +import { createEval } from '../../cocos/core/animation/marionette/create-eval'; +import { VariableTypeMismatchedError } from '../../cocos/core/animation/marionette/errors'; +import { AnimationGraphEval, StateStatus, ClipStatus } from '../../cocos/core/animation/marionette/graph-eval'; +import { createGraphFromDescription } from '../../cocos/core/animation/marionette/__tmp__/graph-from-description'; +import gAnyTransition from './graphs/any-transition'; +import gUnspecifiedCondition from './graphs/unspecified-condition'; +import glUnspecifiedConditionOnEntryNode from './graphs/unspecified-condition-for-non-entry-node'; +import gSuccessiveSatisfaction from './graphs/successive-satisfaction'; +import gVariableNotFoundInCondition from './graphs/variable-not-found-in-condition'; +import gVariableNotFoundInAnimationBlend from './graphs/variable-not-found-in-pose-blend'; +import gAnimationBlendRequiresNumbers from './graphs/pose-blend-requires-numbers'; +import gInfinityLoop from './graphs/infinity-loop'; +import gZeroTimePiece from './graphs/zero-time-piece'; +import { blend1D } from '../../cocos/core/animation/marionette/blend-1d'; +import '../utils/matcher-deep-close-to'; +import { BinaryCondition, UnaryCondition, TriggerCondition } from '../../cocos/core/animation/marionette/condition'; +import { AnimationController } from '../../cocos/core/animation/marionette/animation-controller'; +import { StateMachineComponent } from '../../cocos/core/animation/marionette/state-machine-component'; + +describe('NewGen Anim', () => { + const demoGraphs = __getDemoGraphs(); + + test('Defaults', () => { + const graph = new AnimationGraph(); + expect(graph.layers).toHaveLength(0); + const layer = graph.addLayer(); + expect(layer.blending).toBe(LayerBlending.additive); + expect(layer.mask).toBeNull(); + expect(layer.weight).toBe(1.0); + const layerGraph = layer.stateMachine; + testGraphDefaults(layerGraph); + + const animState = layerGraph.addMotion(); + expect(animState.name).toBe(''); + expect(animState.speed.variable).toBe(''); + expect(animState.speed.value).toBe(1.0); + expect(animState.motion).toBeNull(); + + testGraphDefaults(layerGraph.addSubStateMachine().stateMachine); + + const clipMotion = new ClipMotion(); + expect(clipMotion.clip).toBeNull(); + + const animationBlend1D = new AnimationBlend1D(); + expect(Array.from(animationBlend1D.items)).toHaveLength(0); + expect(animationBlend1D.param.variable).toBe(''); + expect(animationBlend1D.param.value).toBe(0.0); + + const animationBlend2D = new AnimationBlend2D(); + expect(animationBlend2D.algorithm).toBe(AnimationBlend2D.Algorithm.SIMPLE_DIRECTIONAL); + expect(Array.from(animationBlend2D.items)).toHaveLength(0); + expect(animationBlend2D.paramX.variable).toBe(''); + expect(animationBlend2D.paramX.value).toBe(0.0); + expect(animationBlend2D.paramY.variable).toBe(''); + expect(animationBlend2D.paramY.value).toBe(0.0); + + const animationBlendDirect = new AnimationBlendDirect(); + expect(Array.from(animationBlendDirect.items)).toHaveLength(0); + + const transition = layerGraph.connect(layerGraph.entryState, animState); + testTransitionDefaults(transition); + + const animTransition = layerGraph.connect(animState, animState); + testTransitionDefaults(animTransition); + expect(animTransition.duration).toBe(0.3); + expect(animTransition.exitConditionEnabled).toBe(true); + expect(animTransition.exitCondition).toBe(1.0); + + function testGraphDefaults(graph: StateMachine) { + expect(Array.from(graph.states())).toStrictEqual(expect.arrayContaining([ + graph.entryState, + graph.exitState, + graph.anyState, + ])); + expect(graph.entryState.name).toBe('Entry'); + expect(graph.exitState.name).toBe('Exit'); + expect(graph.anyState.name).toBe('Any'); + expect(Array.from(graph.transitions())).toHaveLength(0); + } + + function testTransitionDefaults (transition: Transition) { + expect(transition.conditions).toHaveLength(0); + } + }); + + describe('Asset transition API', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const n1 = layerGraph.addMotion(); + const n2 = layerGraph.addMotion(); + const trans1 = layerGraph.connect(n1, n2); + expect([...layerGraph.getOutgoings(n1)].map((t) => t.to)).toContain(n2); + expect([...layerGraph.getIncomings(n2)].map((t) => t.from)).toContain(n1); + + // There may be multiple transitions between two nodes. + const trans2 = layerGraph.connect(n1, n2); + expect(trans2).not.toBe(trans1); + expect([...layerGraph.getTransition(n1, n2)]).toEqual(expect.arrayContaining([trans1, trans2])); + + // Self transitions are also allowed. + const n3 = layerGraph.addMotion(); + const selfTransition = layerGraph.connect(n3, n3); + expect([...layerGraph.getTransition(n3, n3)]).toMatchObject([selfTransition]); + }); + + describe('Transitions', () => { + test('Could not transition to entry node', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + expect(() => layerGraph.connect(layerGraph.addMotion(), layerGraph.entryState)).toThrowError(InvalidTransitionError); + }); + + test('Could not transition from exit node', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + expect(() => layerGraph.connect(layerGraph.exitState, layerGraph.addMotion())).toThrowError(InvalidTransitionError); + }); + + test('Zero time piece', () => { + // SPEC: Whenever zero time piece is encountered, + // no matter the time piece is generated since originally passed to `update()`, + // or was exhausted and left zero. + // The following updates at that time would still steadily proceed: + // - The graph is in transition state and the transition specified 0 duration, then the switch will happened; + // - The graph is in node state and a transition is judged to be happened, then the graph will run in transition state. + const graphEval = createAnimationGraphEval(createGraphFromDescription(gZeroTimePiece), new Node()); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'SubStateMachineNode1' }, + }); + }); + + test(`Transition: anim -> anim`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const node1 = graph.addMotion(); + node1.motion = createClipMotionPositionX(1.0, 2.0); + const node2 = graph.addMotion(); + node2.motion = createClipMotionPositionX(1.0, 3.0); + graph.connect(graph.entryState, node1); + const transition = graph.connect(node1, node2); + transition.duration = 0.3; + transition.exitConditionEnabled = true; + transition.exitCondition = 0.0; + + const rootNode = new Node(); + const graphEval = createAnimationGraphEval(animationGraph, rootNode); + graphEval.update(0.15); + expect(rootNode.position).toBeDeepCloseTo(new Vec3(2.5)); + }); + + test('Condition not specified', () => { + const graphEval = createAnimationGraphEval(createGraphFromDescription(gUnspecifiedCondition), new Node()); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'asd' }, + }); + }); + + test('Condition not specified for non-entry node', () => { + const graphEval = createAnimationGraphEval(createGraphFromDescription(glUnspecifiedConditionOnEntryNode), new Node()); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node1' }, + transition: { + nextNode: { __DEBUG_ID__: 'Node2' }, + }, + }); + graphEval.update(0.32); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node2' }, + }); + }); + + test('Successive transitions', () => { + const graphEval = createAnimationGraphEval(createGraphFromDescription(gSuccessiveSatisfaction), new Node()); + graphEval.update(0.0); + + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node2' }, + }); + }); + + test('Infinity loop', () => { + const warnMockInstance = warnID as unknown as jest.MockInstance, Parameters>; + warnMockInstance.mockClear(); + + const graphEval = createAnimationGraphEval(createGraphFromDescription(gInfinityLoop), new Node()); + graphEval.update(0.0); + + expect(warnMockInstance).toBeCalledTimes(1); + expect(warnMockInstance.mock.calls[0]).toHaveLength(2); + expect(warnMockInstance.mock.calls[0][0]).toStrictEqual(14000); + expect(warnMockInstance.mock.calls[0][1]).toStrictEqual(100); + }); + + test('Self transition', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const animState = graph.addMotion(); + animState.name = 'Node'; + const anim = animState.motion = createClipMotionPositionXLinear(1.0, 0.3, 1.4); + const clip = anim.clip!; + + graph.connect(graph.entryState, animState); + + const selfTransition = graph.connect(animState, animState); + selfTransition.exitConditionEnabled = true; + selfTransition.exitCondition = 0.9; + selfTransition.duration = 0.3; + + const node = new Node(); + const graphEval = createAnimationGraphEval(animationGraph, node); + + graphEval.update(0.7); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip, + weight: 1.0, + }, + }); + expect(node.position.x).toBeCloseTo(0.3 + (1.4 - 0.3) * 0.7); + + graphEval.update(0.25); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip, + weight: 0.83333, + }, + transition: { + time: 0.05, + next: { + clip, + weight: 0.16667, + }, + }, + }); + expect(node.position.x).toBeCloseTo( + (0.3 + (1.4 - 0.3) * 0.95) * 0.83333 + + (0.3 + (1.4 - 0.3) * 0.05) * 0.16667 + ); + }); + + test('Subgraph transitions are selected only when subgraph exited', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const subStateMachine = graph.addSubStateMachine(); + subStateMachine.name = 'Subgraph'; + const subgraphEntryToExit = subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachine.stateMachine.exitState); + const [subgraphEntryToExitCondition] = subgraphEntryToExit.conditions = [new TriggerCondition()]; + animationGraph.addVariable('subgraphExitTrigger', VariableType.TRIGGER, false); + subgraphEntryToExitCondition.trigger = 'subgraphExitTrigger'; + + graph.connect(graph.entryState, subStateMachine); + const node = graph.addMotion(); + node.name = 'Node'; + const subgraphToNode = graph.connect(subStateMachine, node); + const [triggerCondition] = subgraphToNode.conditions = [new TriggerCondition()]; + + animationGraph.addVariable('trigger', VariableType.TRIGGER); + triggerCondition.trigger = 'trigger'; + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: null, + }); + + graphEval.setValue('trigger', true); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: null, + }); + + graphEval.setValue('subgraphExitTrigger', true); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { + __DEBUG_ID__: 'Node', + }, + }); + }); + + test(`In single frame: exit condition just satisfied or satisfied and remain time`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const animState1 = graph.addMotion(); + animState1.name = 'AnimState'; + const animState1Clip = animState1.motion = createClipMotionPositionX(1.0, 2.0, 'AnimState1Clip'); + + const animState2 = graph.addMotion(); + animState2.name = 'AnimState'; + const animState2Clip = animState2.motion = createClipMotionPositionX(1.0, 2.0, 'AnimState2Clip'); + + graph.connect(graph.entryState, animState1); + const node1To2 = graph.connect(animState1, animState2); + node1To2.duration = 0.0; + node1To2.exitConditionEnabled = true; + node1To2.exitCondition = 1.0; + + { + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.update(animState1Clip.clip!.duration); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState2Clip.clip!, + weight: 1.0, + }, + }); + } + + { + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.update(animState1Clip.clip!.duration + 0.1); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState2Clip.clip!, + weight: 1.0, + }, + }); + } + }); + + test(`Exit time > 1`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const animState1 = graph.addMotion(); + animState1.name = 'AnimState'; + const animState1Clip = animState1.motion = createClipMotionPositionX(1.0, 2.0, 'AnimState1Clip'); + + const animState2 = graph.addMotion(); + animState2.name = 'AnimState'; + const animState2Clip = animState2.motion = createClipMotionPositionX(1.0, 2.0, 'AnimState2Clip'); + + graph.connect(graph.entryState, animState1); + const node1To2 = graph.connect(animState1, animState2); + node1To2.duration = 0.3; + node1To2.exitConditionEnabled = true; + node1To2.exitCondition = 2.7; + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.update(animState1Clip.clip!.duration * 1.1); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState1Clip.clip!, + weight: 1.0, + }, + }); + + graphEval.update(animState1Clip.clip!.duration * 1.8); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState1Clip.clip!, + weight: 0.333333, + }, + transition: { + time: 0.2, + next: { + clip: animState2Clip.clip!, + weight: 0.666667, + }, + }, + }); + }); + + test(`Transition into subgraph`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const animState = graph.addMotion(); + animState.name = 'AnimState'; + const animClip = animState.motion = createClipMotionPositionX(1.0, 2.0, 'AnimStateClip'); + + const subStateMachine = graph.addSubStateMachine(); + subStateMachine.name = 'Subgraph'; + + const subStateMachineAnimState = subStateMachine.stateMachine.addMotion(); + subStateMachineAnimState.name = 'SubgraphAnimState'; + const subStateMachineAnimStateClip = subStateMachineAnimState.motion = createClipMotionPositionX(1.0, 3.0, 'SubgraphAnimStateClip'); + subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachineAnimState); + + const subStateMachineAnimState2 = subStateMachine.stateMachine.addMotion(); + subStateMachineAnimState2.name = 'SubgraphAnimState2'; + const subgraphAnimState2Clip = subStateMachineAnimState2.motion = createClipMotionPositionX(0.1, 3.0, 'SubgraphAnimState2Clip'); + const animToStateMachineAnim1ToAnim2 = subStateMachine.stateMachine.connect(subStateMachineAnimState, subStateMachineAnimState2); + animToStateMachineAnim1ToAnim2.duration = 0.3; + animToStateMachineAnim1ToAnim2.exitConditionEnabled = true; + animToStateMachineAnim1ToAnim2.exitCondition = 1.0; + + graph.connect(graph.entryState, animState); + const animToStateMachine = graph.connect(animState, subStateMachine); + animToStateMachine.duration = 0.3; + animToStateMachine.exitConditionEnabled = true; + animToStateMachine.exitCondition = 0.0; + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update(0.2); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animClip.clip!, + weight: 0.33333, + }, + transition: { + time: 0.2, + next: { + clip: subStateMachineAnimStateClip.clip!, + weight: 0.66667, + }, + }, + }); + + graphEval.update(0.1); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subStateMachineAnimStateClip.clip!, + weight: 1.0, + }, + }); + + graphEval.update(subStateMachineAnimStateClip.clip!.duration - 0.3 + 0.1); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subStateMachineAnimStateClip.clip!, + weight: 0.66667, + }, + transition: { + time: 0.1, + next: { + clip: subgraphAnimState2Clip.clip!, + weight: 0.33333, + }, + }, + }); + }); + + test('Transition from sub-state machine', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const animState = graph.addMotion(); + animState.name = 'AnimState'; + const animClip = animState.motion = createClipMotionPositionX(1.0, 2.0, 'AnimStateClip'); + + const { + subgraph, + subStateMachineAnimStateClip, + } = (() => { + const subStateMachine = graph.addSubStateMachine(); + subStateMachine.name = 'Subgraph'; + + const subStateMachineAnimState = subStateMachine.stateMachine.addMotion(); + subStateMachineAnimState.name = 'SubgraphAnimState'; + + const subStateMachineAnimStateClip = subStateMachineAnimState.motion = createClipMotionPositionX(1.0, 3.0, 'SubgraphAnimStateClip'); + subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachineAnimState); + + const subStateMachineAnimStateToExit = subStateMachine.stateMachine.connect(subStateMachineAnimState, subStateMachine.stateMachine.exitState); + subStateMachineAnimStateToExit.duration = 0.3; + subStateMachineAnimStateToExit.exitConditionEnabled = true; + subStateMachineAnimStateToExit.exitCondition = 1.0; + + return { + subgraph: subStateMachine, + subStateMachineAnimStateClip: subStateMachineAnimStateClip, + }; + })(); + + graph.connect(graph.entryState, subgraph); + graph.connect(subgraph, animState); + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update( + // exit condition + duration + subStateMachineAnimStateClip.clip!.duration + 0.2, + ); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subStateMachineAnimStateClip.clip!, + weight: 0.33333, + }, + transition: { + time: 0.2, + next: { + clip: animClip.clip!, + weight: 0.66667, + }, + }, + }); + + graphEval.update( + 0.10001, + ); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animClip.clip!, + }, + }); + }); + + test('Transition from sub-state machine to sub-state machine', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const createSubgraph = (name: string) => { + const subStateMachine = graph.addSubStateMachine(); + subStateMachine.name = name; + + const subStateMachineAnimState = subStateMachine.stateMachine.addMotion(); + subStateMachineAnimState.name = `${name}AnimState`; + + const subStateMachineAnimStateClip = subStateMachineAnimState.motion = createClipMotionPositionX(1.0, 3.0, `${name}AnimStateClip`); + subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachineAnimState); + + const subgraphAnimStateToExit = subStateMachine.stateMachine.connect(subStateMachineAnimState, subStateMachine.stateMachine.exitState); + subgraphAnimStateToExit.duration = 0.3; + subgraphAnimStateToExit.exitConditionEnabled = true; + subgraphAnimStateToExit.exitCondition = 1.0; + + return { + subgraph: subStateMachine, + subStateMachineAnimStateClip, + }; + }; + + const { + subgraph: subgraph1, + subStateMachineAnimStateClip: subgraph1AnimStateClip, + } = createSubgraph('Subgraph1'); + + const { + subgraph: subgraph2, + subStateMachineAnimStateClip: subgraph2AnimStateClip, + } = createSubgraph('Subgraph2'); + + graph.connect(graph.entryState, subgraph1); + graph.connect(subgraph1, subgraph2); + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update( + // exit condition + duration + subgraph1AnimStateClip.clip!.duration + 0.2, + ); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subgraph1AnimStateClip.clip!, + weight: 0.33333, + }, + transition: { + time: 0.2, + next: { + clip: subgraph2AnimStateClip.clip!, + weight: 0.66667, + }, + }, + }); + + graphEval.update( + 0.10001, + ); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subgraph2AnimStateClip.clip!, + }, + }); + }); + + describe('Condition', () => { + function createAnimationGraphForConditionTest(conditions: Condition[]) { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const node1 = graph.addMotion(); + node1.name = 'FalsyBranchNode'; + const node2 = graph.addMotion(); + node2.name = 'TruthyBranchNode'; + graph.connect(graph.entryState, node1); + const transition = graph.connect(node1, node2, conditions); + transition.duration = 0.0; + transition.exitConditionEnabled = false; + return animationGraph; + } + + test.each([ + ['Be truthy', UnaryCondition.Operator.TRUTHY, [ + [true, true], + [false, false] + ]], + ['Be falsy', UnaryCondition.Operator.FALSY, [ + [true, false], + [false, true], + ]], + ] as [ + title: string, + op: UnaryCondition.Operator, + samples: [input: boolean, output: boolean][] + ][])(`Unary: %s`, (_title, op, samples) => { + for (const [input, output] of samples) { + const condition = new UnaryCondition(); + condition.operator = op; + condition.operand.value = input; + const graph = createAnimationGraphForConditionTest([condition]); + const graphEval = createAnimationGraphEval(graph, new Node()); + graphEval.update(0.0); + if (output) { + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'TruthyBranchNode' }, + }); + } else { + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'FalsyBranchNode' }, + }); + } + } + }); + + test.each([ + ['Equal to', BinaryCondition.Operator.EQUAL_TO, [ + [0.2, 0.2, true], + [0.2, 0.3, false], + ]], + ['Not equal to', BinaryCondition.Operator.NOT_EQUAL_TO, [ + [0.2, 0.2, false], + [0.2, 0.3, true], + ]], + ['Greater than', BinaryCondition.Operator.GREATER_THAN, [ + [0.2, 0.2, false], + [0.2, 0.3, false], + [0.3, 0.2, true], + ]], + ['Less than', BinaryCondition.Operator.LESS_THAN, [ + [0.2, 0.2, false], + [0.2, 0.3, true], + [0.3, 0.2, false], + ]], + ['Greater than or equal to', BinaryCondition.Operator.GREATER_THAN_OR_EQUAL_TO, [ + [0.2, 0.2, true], + [0.2, 0.3, false], + [0.3, 0.2, true], + ]], + ['Less than or equal to', BinaryCondition.Operator.LESS_THAN_OR_EQUAL_TO, [ + [0.2, 0.2, true], + [0.2, 0.3, true], + [0.3, 0.2, false], + ]], + ] as [ + title: string, + op: BinaryCondition.Operator, + samples: [lhs: number, rhs: number, output: boolean][] + ][])(`Binary: %s`, (_title, op, samples) => { + for (const [lhs, rhs, output] of samples) { + const condition = new BinaryCondition(); + condition.operator = op; + condition.lhs.value = lhs; + condition.rhs.value = rhs; + const graph = createAnimationGraphForConditionTest([condition]); + const graphEval = createAnimationGraphEval(graph, new Node()); + graphEval.update(0.0); + if (output) { + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'TruthyBranchNode' }, + }); + } else { + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'FalsyBranchNode' }, + }); + } + } + }); + + test(`Trigger condition`, () => { + const condition = new TriggerCondition(); + condition.trigger = 'theTrigger'; + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const node1 = graph.addMotion(); + node1.name = 'FalsyBranchNode'; + const node2 = graph.addMotion(); + node2.name = 'TruthyBranchNode'; + const node3 = graph.addMotion(); + node3.name = 'ExtraNode'; + graph.connect(graph.entryState, node1); + const transition = graph.connect(node1, node2, [condition]); + transition.duration = 0.0; + transition.exitConditionEnabled = false; + const transition2 = graph.connect(node2, node3, [condition]); + transition2.duration = 0.0; + transition2.exitConditionEnabled = false; + + animationGraph.addVariable('theTrigger', VariableType.TRIGGER); + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'FalsyBranchNode' }, + }); + graphEval.setValue('theTrigger', true); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'TruthyBranchNode' }, + }); + expect(graphEval.getValue('theTrigger')).toBe(false); + }); + }); + + test('All triggers along the transition path should be reset', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + + const subStateMachine1 = graph.addSubStateMachine(); + subStateMachine1.name = 'Subgraph1'; + + const subStateMachine1_1 = subStateMachine1.stateMachine.addSubStateMachine(); + subStateMachine1_1.name = 'Subgraph1_1'; + + const subStateMachine1_2 = subStateMachine1.stateMachine.addSubStateMachine(); + subStateMachine1_2.name = 'Subgraph1_2'; + + const subgraph1_2AnimState = subStateMachine1_2.stateMachine.addMotion(); + subgraph1_2AnimState.name = 'Subgraph1_2AnimState'; + + let nTriggers = 0; + + const addTriggerCondition = (transition: Transition) => { + const [condition] = transition.conditions = [new TriggerCondition()]; + condition.trigger = `trigger${nTriggers}`; + animationGraph.addVariable(`trigger${nTriggers}`, VariableType.TRIGGER); + ++nTriggers; + }; + + addTriggerCondition( + subStateMachine1_2.stateMachine.connect(subStateMachine1_2.stateMachine.entryState, subgraph1_2AnimState), + ); + + addTriggerCondition( + subStateMachine1.stateMachine.connect(subStateMachine1.stateMachine.entryState, subStateMachine1_1), + ); + + subStateMachine1_1.stateMachine.connect(subStateMachine1_1.stateMachine.entryState, subStateMachine1_1.stateMachine.exitState); + + addTriggerCondition( + subStateMachine1.stateMachine.connect(subStateMachine1_1, subStateMachine1_2), + ); + + addTriggerCondition( + graph.connect(graph.entryState, subStateMachine1), + ); + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { currentNode: null }); + + for (let i = 0; i < nTriggers; ++i) { + graphEval.setValue(`trigger${i}`, true); + } + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { + __DEBUG_ID__: 'Subgraph1_2AnimState', + }, + }); + const triggerStates = Array.from({ length: nTriggers }, (_, iTrigger) => graphEval.getValue(`trigger${iTrigger}`)); + expect(triggerStates).toStrictEqual(new Array(nTriggers).fill(false)); + }); + + describe(`Transition priority`, () => { + test('Transitions to different nodes, use the first-connected and first-matched transition', () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const animState1 = graph.addMotion(); + animState1.name = 'Node1'; + animState1.motion = createEmptyClipMotion(1.0); + const animState2 = graph.addMotion(); + animState2.name = 'Node2'; + animState2.motion = createEmptyClipMotion(1.0); + const animState3 = graph.addMotion(); + animState3.name = 'Node3'; + animState3.motion = createEmptyClipMotion(1.0); + const transition1 = graph.connect(animState1, animState2); + transition1.exitConditionEnabled = true; + transition1.exitCondition = 0.8; + const [ transition1Condition ] = transition1.conditions = [ new UnaryCondition() ]; + transition1Condition.operator = UnaryCondition.Operator.TRUTHY; + transition1Condition.operand.variable = 'switch1'; + const transition2 = graph.connect(animState1, animState3); + transition2.exitConditionEnabled = true; + transition2.exitCondition = 0.8; + const [ transition2Condition ] = transition2.conditions = [ new UnaryCondition() ]; + transition2Condition.operator = UnaryCondition.Operator.TRUTHY; + transition2Condition.operand.variable = 'switch2'; + graph.connect(graph.entryState, animState1); + animationGraph.addVariable('switch1', VariableType.BOOLEAN, false); + animationGraph.addVariable('switch2', VariableType.BOOLEAN, false); + + // #region Both satisfied + { + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.setValue('switch1', true); + graphEval.setValue('switch2', true); + graphEval.update(0.9); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node1' }, + transition: { + time: 0.1, + nextNode: { __DEBUG_ID__: 'Node2' }, + }, + }); + } + // #endregion + + // #region The later satisfied + { + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + graphEval.setValue('switch1', false); + graphEval.setValue('switch2', true); + graphEval.update(0.9); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node1' }, + transition: { + time: 0.1, + nextNode: { __DEBUG_ID__: 'Node3' }, + }, + }); + } + // #endregion + }); + }); + + test(`Transition duration: in seconds`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const animState1 = graph.addMotion(); + animState1.name = 'Node1'; + const { clip: animState1Clip } = animState1.motion = createEmptyClipMotion(4.0); + const animState2 = graph.addMotion(); + animState2.name = 'Node2'; + const { clip: animState2Clip } = animState2.motion = createEmptyClipMotion(1.0); + graph.connect(graph.entryState, animState1); + const transition = graph.connect(animState1, animState2); + transition.exitConditionEnabled = true; + transition.exitCondition = 0.1; + transition.duration = 0.3; + transition.relativeDuration = false; + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + graphEval.update(0.1 * animState1Clip.duration + 0.1); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState1Clip!, + weight: 0.666667, + }, + transition: { + duration: 0.3, + time: 0.1, + next: { + clip: animState2Clip!, + weight: 0.33333, + }, + }, + }); + }); + + test(`Transition duration: normalized`, () => { + const animationGraph = new AnimationGraph(); + const layer = animationGraph.addLayer(); + const graph = layer.stateMachine; + const animState1 = graph.addMotion(); + animState1.name = 'Node1'; + const { clip: animState1Clip } = animState1.motion = createEmptyClipMotion(4.0); + const animState2 = graph.addMotion(); + animState2.name = 'Node2'; + const { clip: animState2Clip } = animState2.motion = createEmptyClipMotion(1.0); + graph.connect(graph.entryState, animState1); + const transition = graph.connect(animState1, animState2); + transition.exitConditionEnabled = true; + transition.exitCondition = 0.1; + transition.duration = 0.3; + transition.relativeDuration = true; + + const graphEval = createAnimationGraphEval(animationGraph, new Node()); + + const animState1Duration = animState1Clip.duration; + graphEval.update(0.1 * animState1Duration + 0.1 * animState1Duration); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animState1Clip!, + weight: 0.666667, + }, + transition: { + duration: 0.3 * animState1Duration, + time: 0.1 * animState1Duration, + next: { + clip: animState2Clip!, + weight: 0.33333, + }, + }, + }); + }); + }); + + describe(`Any state`, () => { + test('Could not transition to any node', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + expect(() => layerGraph.connect(layerGraph.addMotion(), layerGraph.anyState)).toThrowError(InvalidTransitionError); + }); + + test('Transition from any state node is a kind of anim transition', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const animState = layerGraph.addMotion(); + const anyTransition = layerGraph.connect(layerGraph.anyState, animState); + expect(isAnimationTransition(anyTransition)).toBe(true); + }); + + test('Any transition', () => { + const graphEval = createAnimationGraphEval(createGraphFromDescription(gAnyTransition), new Node()); + graphEval.update(0.0); + expectAnimationGraphEvalStatusLayer0(graphEval, { + currentNode: { __DEBUG_ID__: 'Node1' }, + }); + }); + + test('Inheritance', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const animState = layerGraph.addMotion(); + const animClip = animState.motion = createClipMotionPositionX(1.0, 0.5, 'AnimStateClip'); + + const subStateMachine = layerGraph.addSubStateMachine(); + subStateMachine.name = 'Subgraph'; + const subStateMachineAnimState = subStateMachine.stateMachine.addMotion(); + const subStateMachineAnimStateClip = subStateMachineAnimState.motion = createClipMotionPositionX(1.0, 0.7, 'SubgraphAnimStateClip'); + subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachineAnimState); + + layerGraph.connect(layerGraph.entryState, subStateMachine); + const anyTransition = layerGraph.connect(layerGraph.anyState, animState) as AnimationTransition; + anyTransition.duration = 0.3; + anyTransition.exitConditionEnabled = true; + anyTransition.exitCondition = 0.1; + const [ triggerCondition ] = anyTransition.conditions = [new TriggerCondition()]; + triggerCondition.trigger = 'trigger'; + graph.addVariable('trigger', VariableType.TRIGGER, true); + + const graphEval = createAnimationGraphEval(graph, new Node()); + graphEval.update(0.2); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: subStateMachineAnimStateClip.clip!, + weight: 0.666667, + }, + transition: { + time: 0.1, + next: { + clip: animClip.clip!, + weight: 0.333333, + }, + }, + }); + + graphEval.update(0.2); + expectAnimationGraphEvalStatusLayer0(graphEval, { + current: { + clip: animClip.clip!, + weight: 1.0, + }, + }); + }); + }); + + test('State events', () => { + type Invocation = { + kind: 'onEnter', + id: string, + args: Parameters; + } | { + kind: 'onExit', + id: string, + args: Parameters; + }; + + class Recorder extends Component { + public record = jest.fn(); + + public clear () { + this.record.mockClear(); + } + } + + class StatsComponent extends StateMachineComponent { + public id: string = ''; + + onEnter (...args: Parameters) { + this._getRecorder(args[0]).record({ + kind: 'onEnter', + id: this.id, + args, + }); + } + + onExit (...args: Parameters) { + this._getRecorder(args[0]).record({ + kind: 'onExit', + id: this.id, + args, + }); + } + + private _getRecorder(newGenAnim: AnimationController): Recorder { + const receiver = newGenAnim.node.getComponent(Recorder) as Recorder | null; + expect(receiver).not.toBeNull(); + return receiver!; + } + } + + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + + const animState = layerGraph.addMotion(); + const animStateStats = animState.addComponent(StatsComponent); + animStateStats.id = 'AnimState'; + animState.motion = createClipMotionPositionX(1.0, 0.5, 'AnimStateClip'); + + const animState2 = layerGraph.addMotion(); + const animState2Stats = animState2.addComponent(StatsComponent); + animState2Stats.id = 'AnimState2'; + animState2.motion = createClipMotionPositionX(1.0, 0.5, 'AnimState2Clip'); + + const animState3 = layerGraph.addMotion(); + const animState3Stats = animState3.addComponent(StatsComponent); + animState3Stats.id = 'AnimState3'; + animState3.motion = createClipMotionPositionX(1.0, 0.5, 'AnimState3Clip'); + + const subStateMachine = layerGraph.addSubStateMachine(); + const subgraphStats = subStateMachine.addComponent(StatsComponent); + subgraphStats.id = 'Subgraph'; + const subStateMachineAnimState = subStateMachine.stateMachine.addMotion(); + const subgraphAnimStateStats = subStateMachineAnimState.addComponent(StatsComponent); + subgraphAnimStateStats.id = 'SubgraphAnimState'; + subStateMachineAnimState.motion = createClipMotionPositionX(1.0, 0.5, 'SubgraphAnimStateClip'); + subStateMachine.stateMachine.connect(subStateMachine.stateMachine.entryState, subStateMachineAnimState); + const subgraphTransition = subStateMachine.stateMachine.connect(subStateMachineAnimState, subStateMachine.stateMachine.exitState); + subgraphTransition.duration = 0.3; + subgraphTransition.exitConditionEnabled = true; + subgraphTransition.exitCondition = 0.7; + + layerGraph.connect(layerGraph.entryState, animState); + const transition = layerGraph.connect(animState, animState2); + transition.duration = 0.3; + transition.exitConditionEnabled = true; + transition.exitCondition = 0.7; + layerGraph.connect(animState2, subStateMachine); + layerGraph.connect(subStateMachine, animState3); + + const node = new Node(); + const recorder = node.addComponent(Recorder) as Recorder; + const { graphEval, newGenAnim } = createAnimationGraphEval2(graph, node); + + graphEval.update(0.1); + expect(recorder.record).toHaveBeenCalledTimes(1); + expect(recorder.record).toHaveBeenNthCalledWith(1, { + kind: 'onEnter', + id: 'AnimState', + args: [ + newGenAnim, + ], + }); + recorder.clear(); + + graphEval.update(1.1); + expect(recorder.record).toHaveBeenCalledTimes(2); + expect(recorder.record).toHaveBeenNthCalledWith(1, { + kind: 'onEnter', + id: 'AnimState2', + args: [ + newGenAnim, + ], + }); + expect(recorder.record).toHaveBeenNthCalledWith(2, { + kind: 'onExit', + id: 'AnimState', + args: [ + newGenAnim, + ], + }); + recorder.clear(); + + graphEval.update(1.0); + expect(recorder.record).toHaveBeenCalledTimes(3); + expect(recorder.record).toHaveBeenNthCalledWith(1, { + kind: 'onEnter', + id: 'Subgraph', + args: [ + newGenAnim, + ], + }); + expect(recorder.record).toHaveBeenNthCalledWith(2, { + kind: 'onEnter', + id: 'SubgraphAnimState', + args: [ + newGenAnim, + ], + }); + expect(recorder.record).toHaveBeenNthCalledWith(3, { + kind: 'onExit', + id: 'AnimState2', + args: [ + newGenAnim, + ], + }); + recorder.clear(); + + graphEval.update(1.0); + expect(recorder.record).toHaveBeenCalledTimes(3); + expect(recorder.record).toHaveBeenNthCalledWith(1, { + kind: 'onEnter', + id: 'AnimState3', + args: [ + newGenAnim, + ], + }); + expect(recorder.record).toHaveBeenNthCalledWith(2, { + kind: 'onExit', + id: 'SubgraphAnimState', + args: [ + newGenAnim, + ], + }); + expect(recorder.record).toHaveBeenNthCalledWith(3, { + kind: 'onExit', + id: 'Subgraph', + args: [ + newGenAnim, + ], + }); + recorder.clear(); + }); + + describe('Animation properties', () => { + describe('Speed', () => { + test(`Constant`, () => { + const graph = new AnimationGraph(); + expect(graph.layers).toHaveLength(0); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const animState = layerGraph.addMotion(); + animState.motion = createClipMotionPositionXLinear(1.0, 0.3, 1.7); + animState.speed.value = 1.2; + layerGraph.connect(layerGraph.entryState, animState); + + const node = new Node(); + const animationGraphEval = createAnimationGraphEval(graph, node); + animationGraphEval.update(0.2); + expect(node.position.x).toBeCloseTo( + 0.3 + (1.7 - 0.3) * (0.2 * 1.2 / 1.0), + ); + }); + + test(`Variable`, () => { + const graph = new AnimationGraph(); + expect(graph.layers).toHaveLength(0); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const animState = layerGraph.addMotion(); + animState.motion = createClipMotionPositionXLinear(1.0, 0.3, 1.7); + animState.speed.variable = 'speed'; + animState.speed.value = 1.2; + graph.addVariable('speed', VariableType.NUMBER, 0.5); + layerGraph.connect(layerGraph.entryState, animState); + + const node = new Node(); + const animationGraphEval = createAnimationGraphEval(graph, node); + animationGraphEval.update(0.2); + expect(node.position.x).toBeCloseTo( + 0.3 + (1.7 - 0.3) * (0.2 * 0.5 / 1.0), + ); + + animationGraphEval.setValue('speed', 1.2); + animationGraphEval.update(0.2); + expect(node.position.x).toBeCloseTo( + 0.3 + (1.7 - 0.3) * ((0.2 * 0.5 + 0.2 * 1.2) / 1.0), + ); + }); + }); + }); + + describe('Removing nodes', () => { + test('Could not remove special nodes', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + layerGraph.remove(layerGraph.entryState); + layerGraph.remove(layerGraph.exitState); + layerGraph.remove(layerGraph.anyState); + expect([...layerGraph.states()]).toEqual(expect.arrayContaining([ + layerGraph.entryState, + layerGraph.exitState, + layerGraph.anyState, + ])); + }); + + test('Also erase referred transitions', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const node1 = layerGraph.addMotion(); + const node2 = layerGraph.addMotion(); + const node3 = layerGraph.addMotion(); + layerGraph.connect(node1, node2); + layerGraph.connect(node3, node1); + + layerGraph.remove(node2); + expect([...layerGraph.getOutgoings(node1)]).toHaveLength(0); + layerGraph.remove(node3); + expect([...layerGraph.getIncomings(node1)]).toHaveLength(0); + }); + }); + + describe('Exotic nodes', () => { + test('Removed nodes are dangling', () => { + const graph = new AnimationGraph(); + const layer = graph.addLayer(); + const layerGraph = layer.stateMachine; + const node = layerGraph.addMotion(); + layerGraph.remove(node); + expect(() => layerGraph.remove(node)).toThrow(); + expect(() => layerGraph.getIncomings(node)).toThrow(); + expect(() => layerGraph.connect(layerGraph.entryState, node)).toThrow(); + }); + + test('Nodes in different layers are isolated', () => { + const graph = new AnimationGraph(); + const layer1 = graph.addLayer(); + const layerGraph1 = layer1.stateMachine; + const layer2 = graph.addLayer(); + const layerGraph2 = layer2.stateMachine; + const node1 = layerGraph1.addMotion(); + const node2 = layerGraph2.addMotion(); + expect(() => layerGraph2.connect(node2, node1)).toThrow(); + }); + }); + + describe('Blender 1D', () => { + test('Thresholds should have been sorted', () => { + const createBlend1DItemWithWeight = (threshold: number) => { + const item = new AnimationBlend1D.Item(); + item.threshold = threshold; + return item; + }; + const blender1D = new AnimationBlend1D(); + blender1D.items = [createBlend1DItemWithWeight(0.3), createBlend1DItemWithWeight(0.2)]; + expect([...blender1D.items].map(({ threshold }) => threshold)).toStrictEqual([0.2, 0.3]); + + blender1D.items = [createBlend1DItemWithWeight(0.9), createBlend1DItemWithWeight(-0.2)]; + expect([...blender1D.items].map(({ threshold }) => threshold)).toStrictEqual([-0.2, 0.9]); + }); + + test('1D Blending', () => { + const thresholds = [0.1, 0.3] as const; + const weights = new Array(thresholds.length).fill(0); + + blend1D(weights, thresholds, 0.4); + expect(weights).toBeDeepCloseTo([0.0, 1.0]); + + blend1D(weights, thresholds, 0.1); + expect(weights).toBeDeepCloseTo([1.0, 0.0]); + + blend1D(weights, thresholds, 0.3); + expect(weights).toBeDeepCloseTo([0.0, 1.0]); + + blend1D(weights, thresholds, 0.2); + expect(weights).toBeDeepCloseTo([0.5, 0.5]); + }); + }); + + describe('Variable not found error', () => { + test('Missed in conditions', () => { + expect(() => createAnimationGraphEval(createGraphFromDescription(gVariableNotFoundInCondition), new Node())).toThrowError(VariableNotDefinedError); + }); + + test('Missed in animation blend', () => { + expect(() => createAnimationGraphEval(createGraphFromDescription(gVariableNotFoundInAnimationBlend), new Node())).toThrowError(VariableNotDefinedError); + }); + }); + + describe('Variable type mismatch error', () => { + test('animation blend requires numbers', () => { + expect(() => createAnimationGraphEval(createGraphFromDescription(gAnimationBlendRequiresNumbers), new Node())).toThrowError(VariableTypeMismatchedError); + }); + }); + + describe('Property binding', () => { + }); +}); + +function createEmptyClipMotion (duration: number, name = '') { + const clip = new AnimationClip(); + clip.name = name; + clip.enableTrsBlending = true; + clip.duration = duration; + const clipMotion = new ClipMotion(); + clipMotion.clip = clip; + return clipMotion; +} + +function createClipMotionPositionX(duration: number, value: number, name = '') { + const clip = new AnimationClip(); + clip.name = name; + clip.enableTrsBlending = true; + clip.duration = duration; + const track = new VectorTrack(); + track.componentsCount = 3; + track.path.toProperty('position'); + track.channels()[0].curve.assignSorted([[0.0, value]]); + clip.addTrack(track); + const clipMotion = new ClipMotion(); + clipMotion.clip = clip; + return clipMotion; +} + +function createClipMotionPositionXLinear(duration: number, from: number, to: number, name = '') { + const clip = new AnimationClip(); + clip.name = name; + clip.enableTrsBlending = true; + clip.duration = duration; + const track = new VectorTrack(); + track.componentsCount = 3; + track.path.toProperty('position'); + track.channels()[0].curve.assignSorted([ + [0.0, from], + [duration, to], + ]); + clip.addTrack(track); + const clipMotion = new ClipMotion(); + clipMotion.clip = clip; + return clipMotion; +} + +type MayBeArray = T | T[]; + +function expectAnimationGraphEvalStatusLayer0 (graphEval: AnimationGraphEval, status: { + currentNode?: Parameters[1]; + current?: Parameters[1]; + transition?: { + time?: number; + duration?: number; + nextNode?: Parameters[1]; + next?: Parameters[1]; + }; +}) { + if (status.currentNode) { + expectStateStatus(graphEval.getCurrentStateStatus(0), status.currentNode); + } + if (status.current) { + const currentClipStatuses = Array.from(graphEval.getCurrentClipStatuses(0)); + expectClipStatuses(currentClipStatuses, status.current); + } + + const currentTransition = graphEval.getCurrentTransition(0); + if (!status.transition) { + expect(currentTransition).toBeNull(); + } else { + expect(currentTransition).not.toBeNull(); + if (typeof status.transition.time === 'number') { + expect(currentTransition.time).toBeCloseTo(status.transition.time, 5); + } + if (typeof status.transition.duration === 'number') { + expect(currentTransition.duration).toBeCloseTo(status.transition.duration, 5); + } + if (status.transition.nextNode) { + expectStateStatus(graphEval.getNextStateStatus(0), status.transition.nextNode); + } + if (status.transition.next) { + expectClipStatuses(Array.from(graphEval.getNextClipStatuses(0)), status.transition.next); + } + } +} + +function expectStateStatus (stateStatus: Readonly | null, expected: null | { + __DEBUG_ID__?: string; +}) { + if (!expected) { + expect(stateStatus).toBeNull(); + } else { + expect(stateStatus).not.toBeNull(); + expect(stateStatus.__DEBUG_ID__).toBe(expected.__DEBUG_ID__); + } +} + +function expectClipStatuses (clipStatuses: ClipStatus[], expected: MayBeArray<{ + clip?: AnimationClip; + weight?: number; +}>) { + const expects = Array.isArray(expected) ? expected : [expected]; + expect(clipStatuses).toHaveLength(expects.length); + for (let i = 0; i < expects.length; ++i) { + const { clip, weight = 1.0 } = expects[i]; + if (clip) { + expect(clipStatuses[i].clip).toBe(clip); + } + expect(clipStatuses[i].weight).toBeCloseTo(weight, 5); + } +} + +function createAnimationGraphEval (animationGraph: AnimationGraph, node: Node): AnimationGraphEval { + const newGenAnim = node.addComponent(AnimationController) as AnimationController; + const graphEval = new AnimationGraphEval( + animationGraph, + node, + newGenAnim, + ); + // @ts-expect-error HACK + newGenAnim._graphEval = graphEval; + return graphEval; +} + +function createAnimationGraphEval2 (animationGraph: AnimationGraph, node: Node) { + const newGenAnim = node.addComponent(AnimationController) as AnimationController; + const graphEval = new AnimationGraphEval( + animationGraph, + node, + newGenAnim, + ); + // @ts-expect-error HACK + newGenAnim._graphEval = graphEval; + return { + graphEval, + newGenAnim, + }; +} diff --git a/tests/animation/skeletal-animation-blending.test.ts b/tests/animation/skeletal-animation-blending.test.ts index 92e23910143..003dbf25f4a 100644 --- a/tests/animation/skeletal-animation-blending.test.ts +++ b/tests/animation/skeletal-animation-blending.test.ts @@ -134,7 +134,7 @@ describe('Skeletal animation blending', () => { expect(Vec3.equals(nodeScale_all.scale, new Vec3(0.2, 0.4, 0.6))).toBe(true); }); - test('If sum less than 1, current pose with be blended, with remain weight', () => { + test('If sum less than 1, current animation with be blended, with remain weight', () => { host1.weight = 0.3; host2.weight = 0.5; host3.weight = 0.2; diff --git a/tests/core/deserialize.test.ts b/tests/core/deserialize.test.ts new file mode 100644 index 00000000000..d3f57da9ac5 --- /dev/null +++ b/tests/core/deserialize.test.ts @@ -0,0 +1,35 @@ + +import { property } from '../../cocos/core/data/class-decorator'; +import { ccclass } from '../../cocos/core/data/decorators'; +import { deserialize } from '../../cocos/core/data/deserialize'; + +describe('Deserialize', () => { + test('Object array element', () => { + @ccclass('Foo') class Foo { @property name: string = ''; } + + @ccclass('Bar') class Bar { @property array: Foo[] = []; @property mainFoo: Foo; } + + const classMap = { + 'Foo': Foo, + 'Bar': Bar, + }; + + const serialized = [ + { __type__: 'Bar', array: [{ __id__: 1 }, {__id__: 2 }], mainFoo: { __id__: 1 } }, + { __type__: 'Foo', name: 'foo1' }, + { __type__: 'Foo', name: 'foo2'}, + ]; + + const deserialized: Bar = deserialize(serialized, undefined, { + classFinder: (id: string) => { + return classMap[id]; + }, + }); + + expect(deserialized.array).toHaveLength(2); + expect(deserialized.array[0]).toBeInstanceOf(Foo); + expect(deserialized.array[0].name).toBe('foo1'); + expect(deserialized.array[1]).toBeInstanceOf(Foo); + expect(deserialized.array[1].name).toBe('foo2'); + }); +}); diff --git a/tests/utils/matcher-deep-close-to.ts b/tests/utils/matcher-deep-close-to.ts index c4f71d7a751..0d45219955b 100644 --- a/tests/utils/matcher-deep-close-to.ts +++ b/tests/utils/matcher-deep-close-to.ts @@ -5,7 +5,7 @@ expect.extend({ toBeDeepCloseTo }); declare global { namespace jest { - interface Matchers extends Matchers { + interface Matchers extends jest.Matchers { toBeDeepCloseTo: (expected: unknown, numDigits?: number) => MatcherResult; } } diff --git a/tests/utils/matchers/to-be-close-to-array.ts b/tests/utils/matchers/to-be-close-to-array.ts new file mode 100644 index 00000000000..18257d00115 --- /dev/null +++ b/tests/utils/matchers/to-be-close-to-array.ts @@ -0,0 +1,33 @@ +declare global { + namespace jest { + interface Matchers { + toBeCloseToArray(expected: T[]): CustomMatcherResult; + } + } +} + +expect.extend({ + toBeCloseToArray(received: T, expected: T[], epsilon = 1e-5) { + if (!Array.isArray(received)) { + return { + pass: false, + message: () => `Expected ${received} to be Array`, + }; + } + if (received.length !== expected.length) { + return { + pass: false, + message: () => `Expected ${received} to have length ${expected.length}`, + }; + } + return received.every((v: number, i: number) => Math.abs(v - expected[i]) < epsilon) ? ({ + pass: true, + message: () => `Expected ${received} not to be ${expected}`, + }) : ({ + pass: false, + message: () => `Expected ${received} to be ${expected}`, + }); + }, +}); + +export {};