From 0070b31a8a708e9dee37540398f6ec84993c4e3f Mon Sep 17 00:00:00 2001 From: shrinktofit Date: Thu, 15 Apr 2021 14:37:52 +0800 Subject: [PATCH] AnimationCurve --- .vscode/cSpell.json | 3 + .../skeletal-animation-blending.ts | 10 +- .../skeletal-animation-data-hub.ts | 128 +- .../skeletal-animation-state.ts | 21 +- .../skeletal-animation-utils.ts | 8 +- .../skeletal-animation/skeletal-animation.ts | 2 +- cocos/core/algorithm/binary-search.ts | 8 +- cocos/core/animation/animation-clip.ts | 1590 +++++++++++++++-- cocos/core/animation/animation-curve.ts | 76 +- cocos/core/animation/animation-state.ts | 342 +--- cocos/core/animation/animation.ts | 9 + cocos/core/animation/cubic-spline-value.ts | 4 + cocos/core/animation/index.ts | 2 +- cocos/core/animation/internal-symbols.ts | 2 + cocos/core/animation/legacy-clip-data.ts | 219 +++ cocos/core/animation/pose-output.ts | 48 + cocos/core/curves/curve-base.ts | 4 + cocos/core/curves/curve.ts | 173 ++ cocos/core/curves/eval-float-curve.ts | 108 ++ cocos/core/curves/index.ts | 13 + cocos/core/curves/integer-curve.ts | 41 + cocos/core/curves/keyframe-curve.ts | 178 ++ cocos/core/curves/keys-shared-curves.ts | 44 + cocos/core/curves/object-curve.ts | 17 + cocos/core/curves/quat-curve.ts | 178 ++ cocos/core/data/utils/asserts.ts | 2 +- cocos/core/index.ts | 1 + .../animaion-clip-migration-3.x.test.ts | 190 ++ tests/curves/curve.test.ts | 8 + 29 files changed, 2731 insertions(+), 698 deletions(-) create mode 100644 cocos/core/animation/internal-symbols.ts create mode 100644 cocos/core/animation/legacy-clip-data.ts create mode 100644 cocos/core/animation/pose-output.ts create mode 100644 cocos/core/curves/curve-base.ts create mode 100644 cocos/core/curves/curve.ts create mode 100644 cocos/core/curves/eval-float-curve.ts create mode 100644 cocos/core/curves/index.ts create mode 100644 cocos/core/curves/integer-curve.ts create mode 100644 cocos/core/curves/keyframe-curve.ts create mode 100644 cocos/core/curves/keys-shared-curves.ts create mode 100644 cocos/core/curves/object-curve.ts create mode 100644 cocos/core/curves/quat-curve.ts create mode 100644 tests/animation/animaion-clip-migration-3.x.test.ts create mode 100644 tests/curves/curve.test.ts diff --git a/.vscode/cSpell.json b/.vscode/cSpell.json index ebe7c74111e..376dabcb909 100644 --- a/.vscode/cSpell.json +++ b/.vscode/cSpell.json @@ -9,15 +9,18 @@ "Chukong", "clampf", "COCOSPLAY", + "coeff", "deinterleave", "deserialize", "earcut", "emscripten", + "endregion", "eventify", "eventified", "forin", "glsl", "grayscale", + "interp", "IGFX", "lerp", "lerpable", diff --git a/cocos/3d/skeletal-animation/skeletal-animation-blending.ts b/cocos/3d/skeletal-animation/skeletal-animation-blending.ts index 3d3c41235bd..29a2c48d18c 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation-blending.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation-blending.ts @@ -32,11 +32,11 @@ import { Vec3, Quat } from '../../core/math'; import { Node } from '../../core/scene-graph'; import { IValueProxyFactory } from '../../core/animation/value-proxy'; import { assertIsNonNullable } from '../../core/data/utils/asserts'; -import { AnimationState } from '../../core/animation/animation-state'; +import { PoseOutput } from '../../core/animation/pose-output'; export class BlendStateBuffer { private _nodeBlendStates: Map = new Map(); - private _states = new Set(); + private _states = new Set(); public ref (node: Node, property: BlendingProperty) { let nodeBlendState = this._nodeBlendStates.get(node); @@ -118,11 +118,11 @@ export class BlendStateBuffer { }); } - public bindState (state: AnimationState) { + public bindState (state: PoseOutput) { this._states.add(state); } - public unbindState (state: AnimationState) { + public unbindState (state: PoseOutput) { this._states.delete(state); } } @@ -197,7 +197,7 @@ function isVec3Property (property: BlendingProperty) { return !isQuatProperty(property); } -type BlendingProperty = keyof NodeBlendState['properties']; +export type BlendingProperty = keyof NodeBlendState['properties']; type BlendingPropertyValue

= NonNullable['value']; diff --git a/cocos/3d/skeletal-animation/skeletal-animation-data-hub.ts b/cocos/3d/skeletal-animation/skeletal-animation-data-hub.ts index a2dd5c178a7..be34c58924c 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation-data-hub.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation-data-hub.ts @@ -27,41 +27,25 @@ * @packageDocumentation * @module animation */ - -import { - clamp01, Mat4, Quat, Vec3, -} from '../../core/math'; import { DataPoolManager } from './data-pool-manager'; -import { AnimationClip, IObjectCurveData } from '../../core/animation/animation-clip'; -import { HierarchyPath, isCustomPath, isPropertyPath } from '../../core/animation/target-path'; +import type { AnimationClip } from '../../core/animation/animation-clip'; import { legacyCC } from '../../core/global-exports'; +import { BAKE_SKELETON_CURVE_SYMBOL } from '../../core/animation/internal-symbols'; -type CurveData = Vec3[] | Quat[] | Mat4[]; -type ConvertedProps = Record; - -interface IPropertyCurve { - keys: number; - values: CurveData; -} -interface ISkeletalCurveInfo { - frames: number; - sample: number; -} -interface IConvertedData { - info: ISkeletalCurveInfo; - data: Record; -} +type BakeData = ReturnType; /** * 骨骼动画数据转换中心。 */ export class SkelAnimDataHub { - public static getOrExtract (clip: AnimationClip) { + public static getOrExtract (clip: AnimationClip): BakeData { let data = SkelAnimDataHub.pool.get(clip); - if (!data || data.info.sample !== clip.sample) { + if (!data || data.samples !== clip.sample) { // release outdated render data if (data) { (legacyCC.director.root.dataPoolManager as DataPoolManager).releaseAnimationClip(clip); } - data = convertToSkeletalCurves(clip); + const frames = Math.ceil(clip.sample * clip.duration) + 1; + const step = clip.sample; + data = clip[BAKE_SKELETON_CURVE_SYMBOL](0, step, frames); SkelAnimDataHub.pool.set(clip, data); } return data; @@ -71,99 +55,5 @@ export class SkelAnimDataHub { SkelAnimDataHub.pool.delete(clip); } - protected static pool = new Map(); -} - -function convertToSkeletalCurves (clip: AnimationClip): IConvertedData { - const data: Record = {}; - clip.curves.forEach((curve) => { - if (!curve.valueAdapter - && isCustomPath(curve.modifiers[0], HierarchyPath) - && isPropertyPath(curve.modifiers[1])) { - const { path } = curve.modifiers[0]; - let cs = data[path]; - if (!cs) { cs = data[path] = {}; } - const property = curve.modifiers[1] as string; - cs[property] = { values: curve.data.values, keys: curve.data.keys }; // don't use curve.data directly - } - }); - const frames = Math.ceil(clip.sample * clip.duration) + 1; - // lazy eval the conversion due to memory-heavy ops - // many animation paths may not be actually in-use - for (const path of Object.keys(data)) { - const props = data[path]; - if (!props) { continue; } - Object.defineProperty(props, 'worldMatrix', { - get: () => { - if (!props._worldMatrix) { - const { position, rotation, scale } = props; - // fixed step pre-sample - convertToUniformSample(clip, position, frames); - convertToUniformSample(clip, rotation, frames); - convertToUniformSample(clip, scale, frames); - // transform to world space - convertToWorldSpace(data, path, props); - } - return props._worldMatrix; - }, - }); - } - const info: ISkeletalCurveInfo = { - frames, - sample: clip.sample, - }; - return { info, data }; -} - -function convertToUniformSample (clip: AnimationClip, curve: IPropertyCurve, frames: number) { - const keys = clip.keys[curve.keys]; - const values: CurveData = []; - if (!keys || keys.length === 1) { - for (let i = 0; i < frames; i++) { - values[i] = curve.values[0].clone(); // never forget to clone - } - } else { - const isQuat = curve.values[0] instanceof Quat; - for (let i = 0, idx = 0; i < frames; i++) { - let time = i / clip.sample; - while (keys[idx] <= time) { idx++; } - if (idx > keys.length - 1) { idx = keys.length - 1; time = keys[idx]; } else if (idx === 0) { idx = 1; } - const from = curve.values[idx - 1].clone(); - const denom = keys[idx] - keys[idx - 1]; - const ratio = denom ? clamp01((time - keys[idx - 1]) / denom) : 1; - if (isQuat) { - (from as Quat).slerp(curve.values[idx] as Quat, ratio); - } else { - (from as Vec3).lerp(curve.values[idx] as Vec3, ratio); - } - values[i] = from; - } - } - curve.values = values; -} - -function convertToWorldSpace (convertedProps: Record, path: string, props: IObjectCurveData) { - const oPos = props.position.values; - const oRot = props.rotation.values; - const oScale = props.scale.values; - const matrix = oPos.map(() => new Mat4()); - const idx = path.lastIndexOf('/'); - let pMatrix: Mat4[] | null = null; - if (idx > 0) { - const name = path.substring(0, idx); - const data = convertedProps[name]; - if (!data) { console.warn('no data for parent bone?'); return; } - pMatrix = data.worldMatrix.values as Mat4[]; - } - // all props should have the same length now - for (let i = 0; i < oPos.length; i++) { - const oT = oPos[i]; - const oR = oRot[i]; - const oS = oScale[i]; - const m = matrix[i]; - Mat4.fromRTS(m, oR, oT, oS); - if (pMatrix) { Mat4.multiply(m, pMatrix[i], m); } - } - Object.keys(props).forEach((k) => delete props[k]); - props._worldMatrix = { keys: 0, interpolate: false, values: matrix }; + private static pool = new Map(); } diff --git a/cocos/3d/skeletal-animation/skeletal-animation-state.ts b/cocos/3d/skeletal-animation/skeletal-animation-state.ts index 5092542732c..e195cef0815 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation-state.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation-state.ts @@ -32,7 +32,7 @@ import { SkinnedMeshRenderer } from '../skinned-mesh-renderer'; import { Mat4, Quat, Vec3 } from '../../core/math'; import { IAnimInfo, JointAnimationInfo } from './skeletal-animation-utils'; import { Node } from '../../core/scene-graph/node'; -import { AnimationClip, IRuntimeCurve } from '../../core/animation/animation-clip'; +import { AnimationClip } from '../../core/animation/animation-clip'; import { AnimationState } from '../../core/animation/animation-state'; import { SkeletalAnimation, Socket } from './skeletal-animation'; import { SkelAnimDataHub } from './skeletal-animation-data-hub'; @@ -52,8 +52,6 @@ interface ISocketData { frames: ITransform[]; } -const noCurves: IRuntimeCurve[] = []; - export class SkeletalAnimationState extends AnimationState { protected _frames = 1; @@ -88,12 +86,13 @@ export class SkeletalAnimationState extends AnimationState { } this._parent = root.getComponent('cc.SkeletalAnimation') as SkeletalAnimation; const baked = this._parent.useBakedAnimation; - super.initialize(root, baked ? noCurves : undefined); + this._doNotCreateEval = baked; + super.initialize(root); this._curvesInited = !baked; - const { info } = SkelAnimDataHub.getOrExtract(this.clip); - this._frames = info.frames - 1; + const { frames, samples } = SkelAnimDataHub.getOrExtract(this.clip); + this._frames = frames - 1; this._animInfo = this._animInfoMgr.getData(root.uuid); - this._bakedDuration = this._frames / info.sample; // last key + this._bakedDuration = this._frames / samples; // last key } public onPlay () { @@ -127,13 +126,13 @@ export class SkeletalAnimationState extends AnimationState { if (!socket.target) { continue; } const clipData = SkelAnimDataHub.getOrExtract(this.clip); let animPath = socket.path; - let source = clipData.data[animPath]; + let source = clipData.joints[animPath]; let animNode = targetNode; let downstream: Mat4 | undefined; while (!source) { const idx = animPath.lastIndexOf('/'); animPath = animPath.substring(0, idx); - source = clipData.data[animPath]; + source = clipData.joints[animPath]; if (animNode) { if (!downstream) { downstream = Mat4.identity(m4_2); } Mat4.fromRTS(m4_1, animNode.rotation, animNode.position, animNode.scale); @@ -142,8 +141,8 @@ export class SkeletalAnimationState extends AnimationState { } if (idx < 0) { break; } } - const curveData: Mat4[] | undefined = source && source.worldMatrix.values as Mat4[]; - const { frames } = clipData.info; + const curveData: Mat4[] | undefined = source && source.transforms; + const { frames } = clipData; const transforms: ITransform[] = []; for (let f = 0; f < frames; f++) { let mat: Mat4; diff --git a/cocos/3d/skeletal-animation/skeletal-animation-utils.ts b/cocos/3d/skeletal-animation/skeletal-animation-utils.ts index 29a600fd719..fa9dbfd9dba 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation-utils.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation-utils.ts @@ -272,7 +272,7 @@ export class JointTexturePool { if (texture && texture.bounds.has(mesh.hash)) { texture.refCount++; return texture; } const { joints, bindposes } = skeleton; const clipData = SkelAnimDataHub.getOrExtract(clip); - const { frames } = clipData.info; + const { frames } = clipData; let textureBuffer: Float32Array = null!; let buildTexture = false; const jointCount = joints.length; if (!texture) { @@ -394,14 +394,14 @@ export class JointTexturePool { const clipData = SkelAnimDataHub.getOrExtract(clip); for (let j = 0; j < jointCount; j++) { let animPath = joints[j]; - let source = clipData.data[animPath]; + let source = clipData.joints[animPath]; let animNode = skinningRoot.getChildByPath(animPath); let downstream: Mat4 | undefined; let correctionPath: string | undefined; while (!source) { const idx = animPath.lastIndexOf('/'); animPath = animPath.substring(0, idx); - source = clipData.data[animPath]; + source = clipData.joints[animPath]; if (animNode) { if (!downstream) { downstream = new Mat4(); } Mat4.fromRTS(m4_1, animNode.rotation, animNode.position, animNode.scale); @@ -447,7 +447,7 @@ export class JointTexturePool { } } animInfos.push({ - curveData: source && source.worldMatrix.values as Mat4[], downstream, bindposeIdx, bindposeCorrection, + curveData: source && source.transforms, downstream, bindposeIdx, bindposeCorrection, }); } return animInfos; diff --git a/cocos/3d/skeletal-animation/skeletal-animation.ts b/cocos/3d/skeletal-animation/skeletal-animation.ts index 9d1d8b1fce5..938eec21879 100644 --- a/cocos/3d/skeletal-animation/skeletal-animation.ts +++ b/cocos/3d/skeletal-animation/skeletal-animation.ts @@ -174,7 +174,7 @@ export class SkeletalAnimation extends Animation { } public querySockets () { - const animPaths = (this._defaultClip && Object.keys(SkelAnimDataHub.getOrExtract(this._defaultClip).data).sort() + const animPaths = (this._defaultClip && Object.keys(SkelAnimDataHub.getOrExtract(this._defaultClip).joints).sort() .reduce((acc, cur) => (cur.startsWith(acc[acc.length - 1]) ? acc : (acc.push(cur), acc)), [] as string[])) || []; if (!animPaths.length) { return ['please specify a valid default animation clip first']; } const out: string[] = []; diff --git a/cocos/core/algorithm/binary-search.ts b/cocos/core/algorithm/binary-search.ts index db6f1fca0c9..7879cafe7ca 100644 --- a/cocos/core/algorithm/binary-search.ts +++ b/cocos/core/algorithm/binary-search.ts @@ -45,7 +45,7 @@ export function binarySearch (array: number[], value: number) { * otherwise, a negative number that is the bitwise complement of the index of the next element that is large than the searched value or, * if there is no larger element(include the case that the array is empty), the bitwise complement of array's length. */ -export function binarySearchEpsilon (array: number[], value: number, EPSILON = 1e-6) { +export function binarySearchEpsilon (array: readonly number[], value: number, EPSILON = 1e-6) { let low = 0; let high = array.length - 1; let middle = high >>> 1; @@ -71,15 +71,15 @@ export function binarySearchEpsilon (array: number[], value: number, EPSILON = 1 * otherwise, a negative number that is the bitwise complement of the index of the next element that is large than the searched value or, * if there is no larger element(include the case that the array is empty), the bitwise complement of array's length. */ -export function binarySearchBy (array: T[], value: T, lessThan: (lhs: T, rhs: T) => boolean) { +export function binarySearchBy (array: T[], value: U, lessThan: (lhs: T, rhs: U) => number) { let low = 0; let high = array.length - 1; let middle = high >>> 1; for (; low <= high; middle = (low + high) >>> 1) { const test = array[middle]; - if (lessThan(value, test)) { + if (lessThan(test, value) < 0) { high = middle - 1; - } else if (lessThan(test, value)) { + } else if (lessThan(test, value) > 0) { low = middle + 1; } else { return middle; diff --git a/cocos/core/animation/animation-clip.ts b/cocos/core/animation/animation-clip.ts index eafd78b064a..820da405648 100644 --- a/cocos/core/animation/animation-clip.ts +++ b/cocos/core/animation/animation-clip.ts @@ -32,84 +32,465 @@ import { EDITOR } from 'internal:constants'; import { ccclass, serializable } from 'cc.decorator'; import { Asset } from '../assets/asset'; import { SpriteFrame } from '../../2d/assets/sprite-frame'; -import { CompactValueTypeArray } from '../data/utils/compact-value-type-array'; -import { errorID } from '../platform/debug'; +import { errorID, warn } from '../platform/debug'; import { DataPoolManager } from '../../3d/skeletal-animation/data-pool-manager'; import { binarySearchEpsilon } from '../algorithm/binary-search'; import { murmurhash2_32_gc } from '../utils/murmurhash2_gc'; -import { AnimCurve, IPropertyCurveData, RatioSampler } from './animation-curve'; import { SkelAnimDataHub } from '../../3d/skeletal-animation/skeletal-animation-data-hub'; -import { ComponentPath, HierarchyPath, TargetPath } from './target-path'; +import { ComponentPath, HierarchyPath, TargetPath, evaluatePath } from './target-path'; import { WrapMode as AnimationWrapMode } from './types'; import { IValueProxyFactory } from './value-proxy'; import { legacyCC } from '../global-exports'; +import { RealCurve, RealInterpMethod } from '../curves'; +import { ObjectCurve } from '../curves/object-curve'; +import { approx, Color, Mat4, Quat, Size, Vec2, Vec3, Vec4 } from '../math'; +import { createBoundTarget, IBoundTarget } from './bound-target'; +import { Node } from '../scene-graph/node'; +import { IntegerCurve } from '../curves/integer-curve'; +import { QuaternionCurve, QuaternionInterpMethod } from '../curves/quat-curve'; +import { KeySharedRealCurves } from '../curves/keys-shared-curves'; +import { assertIsTrue } from '../data/utils/asserts'; +import type { PoseOutput } from './pose-output'; +import * as legacy from './legacy-clip-data'; +import { BAKE_SKELETON_CURVE_SYMBOL } from './internal-symbols'; +import { RealKeyframeValue } from '../curves/curve'; +import { CubicSplineNumberValue, CubicSplineVec2Value, CubicSplineVec3Value, CubicSplineVec4Value } from './cubic-spline-value'; -export interface IObjectCurveData { - [propertyName: string]: IPropertyCurveData; +export interface IAnimationEvent { + functionName: string; + parameters: string[]; } -export interface IComponentsCurveData { - [componentName: string]: IObjectCurveData; +export interface IAnimationEventGroup { + events: IAnimationEvent[]; } -export interface INodeCurveData { - props?: IObjectCurveData; - comps?: IComponentsCurveData; +export declare namespace AnimationClip { + export interface IEvent { + frame: number; + func: string; + params: string[]; + } } -export type IRuntimeCurve = Pick & { - /** - * 属性曲线。 - */ - curve: AnimCurve; +// #region Tracks + +type TrackPath = TargetPath[]; + +interface Range { + min: number; + max: number; +} + +const createEvalSymbol = Symbol('CreateEval'); + +const CLASS_NAME_PREFIX_ANIM = 'cc.animation.'; + +/** + * A track describes the path of animate a target. + * It's the basic unit of animation clip. + */ +@ccclass(`${CLASS_NAME_PREFIX_ANIM}Track`) +export class Track { + @serializable + public path!: TrackPath; + + @serializable + public setter!: IValueProxyFactory | undefined; + + public getChannels (): Channel[] { + return []; + } + + public getRange (): Range { + const range: Range = { min: Infinity, max: -Infinity }; + for (const channel of this.getChannels()) { + range.min = Math.min(range.min, channel.curve.rangeMin); + range.max = Math.max(range.max, channel.curve.rangeMax); + } + return range; + } + + public [createEvalSymbol] (runtimeBinding: RuntimeBinding): TrackEval { + throw new Error(`No Impl`); + } +} +interface TrackEval { /** - * 曲线采样器。 + * Evaluates the track. + * @param time The time. */ - sampler: RatioSampler | null; -}; + evaluate(time: number, runtimeBinding: RuntimeBinding): unknown; +} -export interface IAnimationEvent { - functionName: string; - parameters: string[]; +type Curve = RealCurve | IntegerCurve | QuaternionCurve | ObjectCurve; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}Channel`) +export class Channel { + @serializable + public name = ''; + + @serializable + public curve!: T; } -export interface IAnimationEventGroup { - events: IAnimationEvent[]; +type RealChannel = Channel; + +type IntegerChannel = Channel; + +type QuaternionChannel = Channel; + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}SingleChannelTrack`) +export abstract class SingleChannelTrack extends Track { + constructor () { + super(); + this._channel = new Channel(); + this._channel.curve = this.createCurve(); + } + + get channel () { + return this._channel; + } + + public getChannels () { + return [this._channel]; + } + + protected createCurve (): TCurve { + throw new Error(`Not impl`); + } + + @serializable + private _channel: Channel; } -export declare namespace AnimationClip { - export type PropertyCurveData = IPropertyCurveData; +@ccclass(`${CLASS_NAME_PREFIX_ANIM}RealTrack`) +export class RealTrack extends SingleChannelTrack { + protected createCurve () { + return new RealCurve(); + } +} - export interface ICurve { - commonTarget?: number; - modifiers: TargetPath[]; - valueAdapter?: IValueProxyFactory; - data: PropertyCurveData; +@ccclass(`${CLASS_NAME_PREFIX_ANIM}IntegerTrack`) +export class IntegerTrack extends SingleChannelTrack { + protected createCurve () { + return new IntegerCurve(); } +} - export interface ICommonTarget { - modifiers: TargetPath[]; - valueAdapter?: IValueProxyFactory; +@ccclass(`${CLASS_NAME_PREFIX_ANIM}QuaternionTrack`) +export class QuaternionTrack extends SingleChannelTrack { + protected createCurve () { + return new QuaternionCurve(); } - export interface IEvent { - frame: number; - func: string; - params: string[]; + public [createEvalSymbol] () { + return new QuatTrackEval(this.getChannels()[0].curve); } +} - export namespace _impl { - type MaybeCompactCurve = Omit & { - data: Omit & { - values: any[] | CompactValueTypeArray; - }; - }; +class QuatTrackEval { + constructor (private _curve: QuaternionCurve) { + + } + + public evaluate (time: number) { + this._curve.evaluate(time, this._result); + return this._result; + } + + private _result: Quat = new Quat(); +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}ObjectTrack`) +export class ObjectTrack extends SingleChannelTrack> { + protected createCurve () { + return new ObjectCurve(); + } +} + +function maskIfEmpty (curve: T) { + return curve.empty ? undefined : curve; +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}VectorTrack`) +export class VectorTrack extends Track { + constructor () { + super(); + this._channels = new Array(4) as VectorTrack['_channels']; + for (let i = 0; i < this._channels.length; ++i) { + const channel = new Channel(); + channel.name = 'X'; + channel.curve = new RealCurve(); + this._channels[i] = channel; + } + } + + get componentsCount () { + return this._nComponents; + } + + set componentsCount (value) { + this._nComponents = value; + } + + public getChannels () { + return this._channels; + } + + public [createEvalSymbol] () { + switch (this._nComponents) { + default: + case 2: + return new Vec2TrackEval( + maskIfEmpty(this._channels[0].curve), + maskIfEmpty(this._channels[1].curve), + ); + case 3: + return new Vec3TrackEval( + maskIfEmpty(this._channels[0].curve), + maskIfEmpty(this._channels[1].curve), + maskIfEmpty(this._channels[2].curve), + ); + case 4: + return new Vec4TrackEval( + maskIfEmpty(this._channels[0].curve), + maskIfEmpty(this._channels[1].curve), + maskIfEmpty(this._channels[2].curve), + maskIfEmpty(this._channels[3].curve), + ); + } + } + + @serializable + private _channels: [RealChannel, RealChannel, RealChannel, RealChannel]; + + @serializable + private _nComponents: 2 | 3 | 4 = 4; +} + +class Vec2TrackEval { + constructor (private _x: RealCurve | undefined, private _y: RealCurve | undefined) { + + } + + public evaluate (time: number, runtimeBinding: RuntimeBinding) { + if ((!this._x || !this._y) && runtimeBinding.getValue) { + Vec2.copy(this._result, runtimeBinding.getValue() as Vec2); + } + + if (this._x) { + this._result.x = this._x.evaluate(time); + } + if (this._y) { + this._result.y = this._y.evaluate(time); + } + + return this._result; + } + + private _result: Vec2 = new Vec2(); +} + +class Vec3TrackEval { + constructor (private _x: RealCurve | undefined, private _y: RealCurve | undefined, private _z: RealCurve | undefined) { + + } + + public evaluate (time: number, runtimeBinding: RuntimeBinding) { + if ((!this._x || !this._y || !this._z) && runtimeBinding.getValue) { + Vec3.copy(this._result, runtimeBinding.getValue() as Vec3); + } + + if (this._x) { + this._result.x = this._x.evaluate(time); + } + if (this._y) { + this._result.y = this._y.evaluate(time); + } + if (this._z) { + this._result.z = this._z.evaluate(time); + } + + return this._result; + } + + private _result: Vec3 = new Vec3(); +} + +class Vec4TrackEval { + constructor ( + private _x: RealCurve | undefined, + private _y: RealCurve | undefined, + private _z: RealCurve | undefined, + private _w: RealCurve | undefined, + ) { + + } + + public evaluate (time: number, runtimeBinding: RuntimeBinding) { + if ((!this._x || !this._y || !this._z || !this._w) && runtimeBinding.getValue) { + Vec4.copy(this._result, runtimeBinding.getValue() as Vec4); + } - type MaybeCompactKeys = Array; + if (this._x) { + this._result.x = this._x.evaluate(time); + } + if (this._y) { + this._result.y = this._y.evaluate(time); + } + if (this._z) { + this._result.z = this._z.evaluate(time); + } + if (this._w) { + this._result.w = this._w.evaluate(time); + } + + return this._result; + } + + private _result: Vec4 = new Vec4(); +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}ColorTrack`) +export class ColorTrack extends Track { + constructor () { + super(); + this._channels = new Array(4) as ColorTrack['_channels']; + for (let i = 0; i < this._channels.length; ++i) { + const channel = new Channel(); + channel.name = 'R'; + channel.curve = new IntegerCurve(); + this._channels[i] = channel; + } + } + + public getChannels () { + return this._channels; + } + + public [createEvalSymbol] () { + return new ColorTrackEval( + maskIfEmpty(this._channels[0].curve), + maskIfEmpty(this._channels[1].curve), + maskIfEmpty(this._channels[2].curve), + maskIfEmpty(this._channels[3].curve), + ); + } + + @serializable + private _channels: [IntegerChannel, IntegerChannel, IntegerChannel, IntegerChannel]; +} + +class ColorTrackEval { + constructor ( + private _x: TCurve | undefined, + private _y: TCurve | undefined, + private _z: TCurve | undefined, + private _w: TCurve | undefined, + ) { + + } + + public evaluate (time: number, runtimeBinding: RuntimeBinding) { + if ((!this._x || !this._y || !this._z || !this._w) && runtimeBinding.getValue) { + Color.copy(this._result, runtimeBinding.getValue() as Color); + } + + if (this._x) { + this._result.r = this._x.evaluate(time); + } + if (this._y) { + this._result.g = this._y.evaluate(time); + } + if (this._z) { + this._result.b = this._z.evaluate(time); + } + if (this._w) { + this._result.a = this._w.evaluate(time); + } + + return this._result; + } + + private _result: Color = new Color(); +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}UntypedTrackChannel`) +class UntypedTrackChannel extends Channel { + @serializable + public property!: string; +} + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}UntypedTrack`) +class UntypedTrack extends Track { + @serializable + private _channels: UntypedTrackChannel[] = []; + + public getChannels () { + return this._channels; + } + + public [createEvalSymbol] (runtimeBinding: RuntimeBinding) { + if (!runtimeBinding.getValue) { + throw new Error(`Can not decide type for untyped track: runtime binding does not provide a getter.`); + } + const trySearchCurve = (property: string) => this._channels.find((channel) => channel.property === property)?.curve; + const value = runtimeBinding.getValue(); + switch (true) { + case value instanceof Size: + default: + throw new Error(`Can not decide type for untyped track: got a unsupported value from runtime binding.`); + case value instanceof Vec2: + return new Vec2TrackEval( + trySearchCurve('x'), + trySearchCurve('y'), + ); + case value instanceof Vec3: + return new Vec3TrackEval( + trySearchCurve('x'), + trySearchCurve('y'), + trySearchCurve('z'), + ); + case value instanceof Vec4: + return new Vec4TrackEval( + trySearchCurve('x'), + trySearchCurve('y'), + trySearchCurve('z'), + trySearchCurve('w'), + ); + case value instanceof Color: + // TODO: what if x, y, z, w? + return new ColorTrackEval( + trySearchCurve('r'), + trySearchCurve('g'), + trySearchCurve('b'), + trySearchCurve('a'), + ); + } + } + + public addChannel (property: string): UntypedTrackChannel { + const channel = new UntypedTrackChannel(); + channel.property = property; + this._channels.push(channel); + return channel; } } +// #endregion + +interface SkeletonAnimationBakeInfo { + samples: number; + + frames: number; + + joints: Record; +} + /** * @zh 动画剪辑表示一段使用动画编辑器编辑的关键帧动画或是外部美术工具生产的骨骼动画。 * 它的数据主要被分为几层:轨道、关键帧和曲线。 @@ -130,34 +511,14 @@ export class AnimationClip extends Asset { * ``` */ public static createWithSpriteFrames (spriteFrames: SpriteFrame[], sample: number) { - if (!Array.isArray(spriteFrames)) { - errorID(3905); - return null; - } - const clip = new AnimationClip(); clip.sample = sample || clip.sample; - clip.duration = spriteFrames.length / clip.sample; const step = 1 / clip.sample; - const keys = new Array(spriteFrames.length); - const values = new Array(keys.length); - for (let i = 0; i < spriteFrames.length; i++) { - keys[i] = i * step; - values[i] = spriteFrames[i]; - } - clip.keys = [keys]; - clip.curves = [{ - modifiers: [ - new ComponentPath('cc.Sprite'), - 'spriteFrame', - ], - data: { - keys: 0, - values, - }, - }]; - + const track = new ObjectTrack(); + track.path = [new ComponentPath('cc.Sprite'), 'spriteFrame']; + const curve = track.getChannels()[0].curve; + curve.assignSorted(spriteFrames.map((spriteFrame, index) => [step * index, spriteFrame])); return clip; } @@ -190,34 +551,6 @@ export class AnimationClip extends Asset { @serializable public events: AnimationClip.IEvent[] = []; - @serializable - private _duration = 0; - - @serializable - private _keys: number[][] = []; - - @serializable - private _stepness = 0; - - @serializable - private _curves: AnimationClip.ICurve[] = []; - - @serializable - private _commonTargets: AnimationClip.ICommonTarget[] = []; - - @serializable - private _hash = 0; - - private frameRate = 0; - private _ratioSamplers: RatioSampler[] = []; - private _runtimeCurves?: IRuntimeCurve[]; - private _runtimeEvents?: { - ratios: number[]; - eventGroups: IAnimationEventGroup[]; - }; - - private _data: Uint8Array | null = null; - /** * @zh 动画的周期。 * @en Animation duration. @@ -231,40 +564,31 @@ export class AnimationClip extends Asset { } /** - * @zh 曲线可引用的所有时间轴。 - * @en Frame keys referenced by curves. + * Gets the count of tracks this animation owns. */ - get keys () { - return this._keys; - } - - set keys (value) { - this._keys = value; + get tracksCount () { + return this._tracks.length; } /** - * @protected + * Gets an iterable to tracks. */ - get eventGroups (): readonly IAnimationEventGroup[] { - if (!this._runtimeEvents) { - this._createRuntimeEvents(); - } - return this._runtimeEvents!.eventGroups; + get tracks (): Iterable { + return this._tracks; } /** - * @protected + * Gets or sets if compression is enabled for this animation. + * When compression is enabled, + * both space and performance may be optimized at production phase. + * The price is that you can not flexible edit the animation at run time. */ - get stepness () { - return this._stepness; + get compressionEnabled () { + return this._compressionEnabled; } - /** - * @protected - */ - set stepness (value) { - this._stepness = value; - this._applyStepness(); + set compressionEnabled (value) { + this._compressionEnabled = value; } get hash () { @@ -275,40 +599,17 @@ export class AnimationClip extends Asset { return this._hash = murmurhash2_32_gc(buffer, 666); } - get curves () { - return this._curves; - } - - set curves (value) { - this._curves = value; - delete this._runtimeCurves; - } + // #region Events. /** - * 此动画的数据。 + * @protected + * @deprecated Since V3.1. */ - get data () { - return this._data; - } - - get commonTargets () { - return this._commonTargets; - } - - set commonTargets (value) { - this._commonTargets = value; - } - - public onLoaded () { - this.frameRate = this.sample; - this._decodeCVTAs(); - } - - public getPropertyCurves (): readonly IRuntimeCurve[] { - if (!this._runtimeCurves) { - this._createPropertyCurves(); + get eventGroups (): readonly IAnimationEventGroup[] { + if (!this._runtimeEvents) { + this._createRuntimeEvents(); } - return this._runtimeCurves!; + return this._runtimeEvents!.eventGroups; } /** @@ -346,6 +647,93 @@ export class AnimationClip extends Asset { return this.events.length !== 0; } + // #endregion + + public onLoaded () { + this.frameRate = this.sample; + } + + /** + * Gets the time range this animation spans. + * @returns The time range. + */ + public getRange () { + const range: Range = { min: Infinity, max: -Infinity }; + for (const track of this._tracks) { + const trackRange = track.getRange(); + range.min = Math.min(range.min, trackRange.min); + range.max = Math.max(range.max, trackRange.max); + } + return range; + } + + /** + * Gets the specified track. + * @param index Index to the track. + * @returns The track. + */ + public getTrack (index: number) { + return this._tracks[index]; + } + + /** + * Adds a track into this animation. + * @param track The track. + * @returns Index to the track. + */ + public addTrack (track: Track) { + const index = this._tracks.length; + this._tracks.push(track); + return index; + } + + /** + * Removes a track from this animation. + * @param index Index to the track. + */ + public removeTrack (index: number) { + this._tracks.splice(index, 1); + } + + /** + * Creates an evaluator for this animation. + * @param context The context. + * @returns The evaluator. + * @internal Do not use this in your code. + */ + public createEvaluator (context: AnimationEvalContext) { + const target = context.target; + + const binder: Binder = (trackPath: TrackPath, setter: IValueProxyFactory | undefined) => { + const trackTarget = bindRuntimeTarget( + target, + trackPath, + setter ?? undefined, + context.pose, + false, + ); + // TODO: warning + return trackTarget ?? undefined; + }; + + return this._createEvalWithBinder(binder); + } + + /** + * Compresses this animation. + */ + public compress () { + const compressedData = new CompressedData(); + for (const track of this.tracks) { + if (track instanceof RealTrack) { + compressedData.compressRealTrack(track); + } else if (track instanceof VectorTrack) { + compressedData.compressVectorTrack(track); + } + } + this._compressedData = compressedData; + } + public destroy () { if (legacyCC.director.root.dataPoolManager) { (legacyCC.director.root.dataPoolManager as DataPoolManager).releaseAnimationClip(this); @@ -354,27 +742,321 @@ export class AnimationClip extends Asset { return super.destroy(); } - protected _createPropertyCurves () { - this._ratioSamplers = this._keys.map( - (keys) => new RatioSampler( - keys.map( - (key) => key / this._duration, - ), - ), - ); + public [BAKE_SKELETON_CURVE_SYMBOL] (start: number, samples: number, frames: number): SkeletonAnimationBakeInfo { + const step = 1.0 / samples; + + const animatedJoints = this._collectAnimatedJoints(); + + const jointsBakeInfo: Record = {}; + for (const joint of animatedJoints) { + jointsBakeInfo[joint] = { + transforms: Array.from({ length: frames }, () => new Mat4()), + }; + } + + class SkeletonFrame { + public position = new Vec3(); + public scale = new Vec3(); + public rotation = new Quat(); + public eulerAngles = new Vec3(); + public parent: SkeletonFrame | null = null; + + public get transform (): Readonly { + const transform = this._transform; + if (this._dirty) { + this._dirty = false; + Mat4.fromRTS(transform, this.rotation, this.position, this.scale); + if (this.parent) { + Mat4.multiply(transform, this.parent.transform, transform); + } + } + return this._transform; + } + + public invalidate () { + this._dirty = true; + } + + private _dirty = true; + private _transform = new Mat4(); + } + + const skeletonFrames = animatedJoints.reduce((result, joint) => { + result[joint] = new SkeletonFrame(); + return result; + }, {} as Record); + for (const joint of Object.keys(skeletonFrames)) { + const skeletonFrame = skeletonFrames[joint]; + const parentJoint = joint.lastIndexOf('/'); + if (parentJoint >= 0) { + const parentJointName = joint.substring(0, parentJoint); + const parentJointFrame = skeletonFrames[parentJointName]; + if (!parentJointFrame) { + warn(`Seems like we have animation for ${joint} but are missing its parent joint ${parentJointName} in animation?`); + } else { + skeletonFrame.parent = parentJointFrame; + } + } + } + + const binder: Binder = (trackPath: TrackPath, setter: IValueProxyFactory | undefined) => { + if (setter || !isTargetingTRS(trackPath)) { + return undefined; + } + + const { path } = trackPath[0] as HierarchyPath; + const jointFrame = skeletonFrames[path]; + if (!jointFrame) { + return undefined; + } + + switch (trackPath[1]) { + default: + return undefined; + case 'position': + return { + setValue (value: Vec3) { + Vec3.copy(jointFrame.position, value); + }, + }; + case 'rotation': + return { + setValue (value: Quat) { + Quat.copy(jointFrame.rotation, value); + }, + }; + case 'scale': + return { + setValue (value: Vec3) { + Vec3.copy(jointFrame.scale, value); + }, + }; + case 'eulerAngles': + return { + setValue (value: Vec3) { + Vec3.copy(jointFrame.eulerAngles, value); + }, + }; + } + }; + + const evaluator = this._createEvalWithBinder(binder); + + for (let iFrame = 0; iFrame < frames; ++iFrame) { + const time = start + step * iFrame; + evaluator.evaluate(time); + for (const joint of animatedJoints) { + Mat4.copy( + jointsBakeInfo[joint].transforms[iFrame], + skeletonFrames[joint].transform, + ); + } + for (const joint of animatedJoints) { + skeletonFrames[joint].invalidate(); + } + } + + return { + samples, + + frames, + + joints: jointsBakeInfo, + }; + } + + /** + * Convert all untyped tracks into typed ones and delete the original. + * @param refine How to decide the type on specified path. + * @internal DO NOT USE THIS IN YOUR CODE. + */ + public upgradeUntypedTracks (refine: (path: TrackPath, setter?: IValueProxyFactory) => 'vec2' | 'vec3' | 'vec4' | 'color' | 'size') { + const newTracks: Track[] = []; + for (const track of this._tracks) { + if (!(track instanceof UntypedTrack)) { + continue; + } + const untypedTrack = track; + + const trySearchChannel = (property: string, outChannel: RealChannel) => { + const untypedChannel = untypedTrack.getChannels().find((channel) => channel.property === property); + if (untypedChannel) { + outChannel.name = untypedChannel.name; + outChannel.curve = untypedChannel.curve; + } + }; + const kind = refine(track.path, track.setter); + switch (kind) { + default: + continue; + case 'vec2': case 'vec3': case 'vec4': { + const track = new VectorTrack(); + newTracks.push(track); + track.componentsCount = kind === 'vec2' ? 2 : kind === 'vec3' ? 3 : 4; + const [x, y, z, w] = track.getChannels(); + switch (kind) { + case 'vec4': + trySearchChannel('w', w); + // fall through + case 'vec3': + trySearchChannel('z', z); + // fall through + default: + case 'vec2': + trySearchChannel('x', x); + trySearchChannel('y', y); + } + break; + } + case 'color': { + const track = new ColorTrack(); + newTracks.push(track); + const [r, g, b, a] = track.getChannels(); + trySearchChannel('r', r); + trySearchChannel('g', g); + trySearchChannel('b', b); + trySearchChannel('a', a); + // TODO: we need float-int conversion if xyzw + trySearchChannel('x', r); + trySearchChannel('y', g); + trySearchChannel('z', b); + trySearchChannel('w', a); + break; + } + case 'size': + break; + } + } + } + + // #region Legacy area + // The following are significantly refactored and deprecated since 3.1. + // We deprecates the direct exposure of keys, values, events. + // Instead, we use track to organize them together. + + /** + * @zh 曲线可引用的所有时间轴。 + * @en Frame keys referenced by curves. + * @deprecated Since V3.1. + */ + get keys () { + return this._getLegacyData().keys; + } + + set keys (value) { + this._getLegacyData().keys = value; + } + + /** + * @zh 此动画包含的所有曲线。 + * @en Curves this animation contains. + * @deprecated Since V3.1. + */ + get curves () { + return this._getLegacyData().curves; + } + + set curves (value) { + this._getLegacyData().curves = value; + } + + /** + * @deprecated Since V3.1. + */ + get commonTargets () { + return this._getLegacyData().commonTargets; + } + + set commonTargets (value) { + this._getLegacyData().commonTargets = value; + } + + /** + * 此动画的数据。 + * @deprecated Since V3.1. + */ + get data () { + return this._getLegacyData().data; + } + + /** + * @internal + * @deprecated Since V3.1. + */ + public getPropertyCurves () { + return this._getLegacyData().getPropertyCurves(); + } + + /** + * Migrates legacy data into tracks. + * @internal This method tend to be used as internal purpose or patch. + * DO NOT use it in your code since it might be removed for the future at any time. + * @deprecated Since V3.1. + */ + public syncLegacyData () { + if (this._legacyData) { + this._fromLegacy(this._legacyData); + this._legacyData = undefined; + } + } + + // #endregion + + @serializable + private _duration = 0; + + @serializable + private _hash = 0; + + private frameRate = 0; + + private _runtimeEvents?: { + ratios: number[]; + eventGroups: IAnimationEventGroup[]; + }; + + @serializable + private _tracks: Track[] = []; + + @serializable + private _compressionEnabled = false; + + @serializable + private _compressedData: CompressedData | undefined = undefined; + + private _legacyData: legacy.AnimationClipLegacyData | undefined = undefined; + + private _createEvalWithBinder (binder: Binder) { + const trackEvalStatues: TrackEvalStatus[] = []; + let compressedDataEvaluator: CompressedDataEvaluator | undefined; + + for (const track of this._tracks) { + const trackTarget = binder(track.path, track.setter); + if (!trackTarget) { + continue; + } + const trackEval = track[createEvalSymbol](trackTarget); + trackEvalStatues.push({ + binding: trackTarget, + trackEval, + }); + } + + if (this._compressedData) { + compressedDataEvaluator = this._compressedData.createEval(binder); + } - this._runtimeCurves = this._curves.map((targetCurve): IRuntimeCurve => ({ - curve: new AnimCurve(targetCurve.data, this._duration), - modifiers: targetCurve.modifiers, - valueAdapter: targetCurve.valueAdapter, - sampler: this._ratioSamplers[targetCurve.data.keys], - commonTarget: targetCurve.commonTarget, - })); + const evaluator = new AnimationClipEvaluator( + trackEvalStatues, + compressedDataEvaluator, + ); - this._applyStepness(); + return evaluator; } - protected _createRuntimeEvents () { + private _createRuntimeEvents () { if (EDITOR && !legacyCC.GAME_VIEW) { return; } @@ -404,37 +1086,573 @@ export class AnimationClip extends Asset { }; } - protected _applyStepness () { - // for (const propertyCurve of this._propertyCurves) { - // propertyCurve.curve.stepfy(this._stepness); - // } + private _getLegacyData () { + if (!this._legacyData) { + this._legacyData = this._toLegacy(); + } + return this._legacyData; } - private _decodeCVTAs () { - const binaryBuffer: ArrayBuffer = ArrayBuffer.isView(this._nativeAsset) ? this._nativeAsset.buffer : this._nativeAsset; - if (!binaryBuffer) { - return; + private _toLegacy (): legacy.AnimationClipLegacyData { + const keys: number[][] = []; + const legacyCurves: legacy.LegacyClipCurve[] = []; + const commonTargets: legacy.LegacyCommonTarget[] = []; + + const legacyClipData = new legacy.AnimationClipLegacyData(this._duration); + legacyClipData.keys = keys; + legacyClipData.curves = legacyCurves; + legacyClipData.commonTargets = commonTargets; + return legacyClipData; + } + + private _fromLegacy (legacyData: legacy.AnimationClipLegacyData) { + const newTracks: Track[] = []; + + const { + keys: legacyKeys, + curves: legacyCurves, + commonTargets: legacyCommonTargets, + } = legacyData; + + const untypedTracks = legacyCommonTargets.map((legacyCommonTarget) => { + const track = new UntypedTrack(); + newTracks.push(track); + return track; + }); + + for (const legacyCurve of legacyCurves) { + const legacyCurveData = legacyCurve.data; + const legacyValues = legacyCurveData.values; + if (legacyValues.length === 0) { + // Legacy clip did not record type info. + continue; + } + const legacyKeysIndex = legacyCurveData.keys; + // Rule: negative index means single frame. + const times = legacyKeysIndex < 0 ? [0.0] : legacyKeys[legacyCurveData.keys]; + const firstValue = legacyValues[0]; + // Rule: default to true. + const interpolate = legacyCurveData ?? true; + // Rule: _arrayLength only used for morph target, internally. + assertIsTrue(typeof legacyCurveData._arrayLength !== 'number' || typeof firstValue === 'number'); + + let legacyCommonTargetCurve: RealCurve | undefined; + if (typeof legacyCurve.commonTarget === 'number') { + // Rule: common targets should only target Vectors/`Size`/`Color`. + if (!legacyCurves.every((value) => typeof value === 'number')) { + warn(`Incorrect curve.`); + continue; + } + // Rule: Each curve that has common target should be numeric curve and targets string property. + if (legacyCurve.valueAdapter || legacyCurve.modifiers.length !== 1 || typeof legacyCurve.modifiers[0] !== 'string') { + warn(`Incorrect curve.`); + continue; + } + const propertyName = legacyCurve.modifiers[0]; + const untypedTrack = untypedTracks[legacyCurve.commonTarget]; + untypedTrack.addChannel(propertyName); + } + + const convertCurve = () => { + if (typeof firstValue === 'number') { + if (!legacyValues.every((value) => typeof value === 'number')) { + warn(`Misconfigured curve.`); + return; + } + let realCurve: RealCurve; + if (legacyCommonTargetCurve) { + realCurve = legacyCommonTargetCurve; + } else { + const track = new RealTrack(); + newTracks.push(track); + realCurve = track.channel.curve; + } + const interpMethod = interpolate ? RealInterpMethod.LINEAR : RealInterpMethod.CONSTANT; + realCurve.assignSorted(times, (legacyValues as number[]).map((value) => ({ + value, + interpMethod, + startTangent: 0, + endTangent: 0, + }))); + } else if (typeof firstValue === 'object') { + switch (true) { + default: + break; + case legacyValues.every((value) => value instanceof Vec2): + case legacyValues.every((value) => value instanceof Vec3): + case legacyValues.every((value) => value instanceof Vec4): { + type Vec4plus = Vec4[]; + type Vec3plus = (Vec3 | Vec4)[]; + type Vec2plus = (Vec2 | Vec3 | Vec4)[]; + const components = firstValue instanceof Vec2 ? 2 : firstValue instanceof Vec3 ? 3 : 4; + const track = new VectorTrack(); + track.componentsCount = components; + const [x, y, z, w] = track.getChannels(); + const interpMethod = interpolate ? RealInterpMethod.LINEAR : RealInterpMethod.CONSTANT; + const valueToFrame = (value: number): RealKeyframeValue => ({ + value, + interpMethod, + startTangent: 0, + endTangent: 0, + }); + switch (components) { + case 4: + w.curve.assignSorted(times, (legacyValues as Vec4plus).map((value) => valueToFrame(value.w))); + // falls through + case 3: + z.curve.assignSorted(times, (legacyValues as Vec3plus).map((value) => valueToFrame(value.z))); + // falls through + default: + x.curve.assignSorted(times, (legacyValues as Vec2plus).map((value) => valueToFrame(value.x))); + y.curve.assignSorted(times, (legacyValues as Vec2plus).map((value) => valueToFrame(value.y))); + break; + } + newTracks.push(track); + return; + } + case legacyValues.every((value) => value instanceof Quat): { + const track = new QuaternionTrack(); + const interpMethod = interpolate ? QuaternionInterpMethod.SLERP : QuaternionInterpMethod.CONSTANT; + track.channel.curve.assignSorted(times, (legacyValues as Quat[]).map((value) => ({ + value: Quat.clone(value), + interpMethod, + }))); + newTracks.push(track); + return; + } + case legacyValues.every((value) => value instanceof Color): { + const track = new ColorTrack(); + const [r, g, b, a] = track.getChannels(); + const interpMethod = interpolate ? RealInterpMethod.LINEAR : RealInterpMethod.CONSTANT; + const valueToFrame = (value: number): RealKeyframeValue => ({ + value, + interpMethod, + startTangent: 0, + endTangent: 0, + }); + r.curve.assignSorted(times, (legacyValues as Color[]).map((value) => valueToFrame(value.r))); + g.curve.assignSorted(times, (legacyValues as Color[]).map((value) => valueToFrame(value.g))); + b.curve.assignSorted(times, (legacyValues as Color[]).map((value) => valueToFrame(value.b))); + a.curve.assignSorted(times, (legacyValues as Color[]).map((value) => valueToFrame(value.a))); + newTracks.push(track); + return; + } + case legacyValues.every((value) => value instanceof CubicSplineNumberValue): { + const track = new RealTrack(); + const interpMethod = interpolate ? RealInterpMethod.CUBIC : RealInterpMethod.CONSTANT; + track.channel.curve.assignSorted(times, (legacyValues as CubicSplineNumberValue[]).map((value) => ({ + value: value.dataPoint, + interpMethod, + startTangent: value.inTangent, + endTangent: value.outTangent, + }))); + newTracks.push(track); + return; + } + case legacyValues.every((value) => value instanceof CubicSplineVec2Value): + case legacyValues.every((value) => value instanceof CubicSplineVec3Value): + case legacyValues.every((value) => value instanceof CubicSplineVec4Value): { + type Vec4plus = CubicSplineVec4Value[]; + type Vec3plus = (CubicSplineVec3Value | CubicSplineVec4Value)[]; + type Vec2plus = (CubicSplineVec2Value | CubicSplineVec3Value | CubicSplineVec4Value)[]; + const components = firstValue instanceof CubicSplineVec2Value ? 2 : firstValue instanceof CubicSplineVec3Value ? 3 : 4; + const track = new VectorTrack(); + track.componentsCount = components; + const [x, y, z, w] = track.getChannels(); + const interpMethod = interpolate ? RealInterpMethod.LINEAR : RealInterpMethod.CONSTANT; + const valueToFrame = (value: number, startTangent: number, endTangent: number): RealKeyframeValue => ({ + value, + interpMethod, + startTangent: 0, + endTangent: 0, + }); + switch (components) { + case 4: + w.curve.assignSorted(times, (legacyValues as Vec4plus).map( + (value) => valueToFrame(value.dataPoint.w, value.inTangent.w, value.outTangent.w), + )); + // falls through + case 3: + z.curve.assignSorted(times, (legacyValues as Vec3plus).map( + (value) => valueToFrame(value.dataPoint.z, value.inTangent.z, value.outTangent.z), + )); + // falls through + default: + x.curve.assignSorted(times, (legacyValues as Vec2plus).map( + (value) => valueToFrame(value.dataPoint.y, value.inTangent.y, value.outTangent.y), + )); + y.curve.assignSorted(times, (legacyValues as Vec2plus).map( + (value) => valueToFrame(value.dataPoint.x, value.inTangent.x, value.outTangent.x), + )); + break; + } + newTracks.push(track); + return; + } + } // End switch + } + + if (interpolate) { + warn(``); + return; + } + + const objectTrack = new ObjectTrack(); + objectTrack.channel.curve.assignSorted(times, legacyValues); + newTracks.push(objectTrack); + }; + + convertCurve(); + } + + for (const track of newTracks) { + this.addTrack(track); + } + } + + private _collectAnimatedJoints () { + const joints = new Set(); + + for (const track of this._tracks) { + if (!track.setter && isTargetingTRS(track.path)) { + const { path } = track.path[0] as HierarchyPath; + joints.add(path); + } } - const maybeCompressedKeys = this._keys as AnimationClip._impl.MaybeCompactKeys; - for (let iKey = 0; iKey < maybeCompressedKeys.length; ++iKey) { - const keys = maybeCompressedKeys[iKey]; - if (keys instanceof CompactValueTypeArray) { - maybeCompressedKeys[iKey] = keys.decompress(binaryBuffer); + if (this._compressedData) { + for (const joint of this._compressedData.collectAnimatedJoints()) { + joints.add(joint); } } - for (let iCurve = 0; iCurve < this._curves.length; ++iCurve) { - const curve = this._curves[iCurve] as AnimationClip._impl.MaybeCompactCurve; - if (curve.data.values instanceof CompactValueTypeArray) { - curve.data.values = curve.data.values.decompress(binaryBuffer); + return Array.from(joints); + } +} + +legacyCC.AnimationClip = AnimationClip; + +type Binder = (path: TrackPath, setter: IValueProxyFactory | undefined) => undefined | RuntimeBinding; + +type RuntimeBinding = { + setValue(value: unknown): void; + + getValue?(): unknown; +}; + +// #region Data compression + +@ccclass(`${CLASS_NAME_PREFIX_ANIM}CompressedData`) +class CompressedData { + public compressRealTrack (track: RealTrack) { + this._tracks.push({ + type: CompressedDataTrackType.FLOAT, + path: track.path, + setter: track.setter, + components: [this._addRealCurve(track.getChannels()[0].curve)], + }); + } + + public compressVectorTrack (vectorTrack: VectorTrack) { + const nComponents = vectorTrack.componentsCount; + const channels = vectorTrack.getChannels(); + const components = new Array(nComponents); + for (let i = 0; i < nComponents; ++i) { + const channel = channels[i]; + components[i] = this._addRealCurve(channel.curve); + } + this._tracks.push({ + type: + nComponents === 2 + ? CompressedDataTrackType.VEC2 + : nComponents === 3 + ? CompressedDataTrackType.VEC3 + : CompressedDataTrackType.VEC4, + path: vectorTrack.path, + setter: vectorTrack.setter, + components, + }); + } + + public createEval (binder: Binder) { + const compressedDataEvalStatus: CompressedDataEvalStatus = { + keySharedCurvesEvalStatuses: [], + trackEvalStatuses: [], + }; + + const { + keySharedCurvesEvalStatuses, + trackEvalStatuses, + } = compressedDataEvalStatus; + + for (const curves of this._curves) { + keySharedCurvesEvalStatuses.push({ + curves, + result: new Array(curves.curveCount).fill(0.0), + }); + } + + for (const track of this._tracks) { + const trackTarget = binder(track.path, track.setter); + if (!trackTarget) { + continue; } + let immediate: CompressedTrackImmediate | undefined; + switch (track.type) { + default: + case CompressedDataTrackType.FLOAT: + break; + case CompressedDataTrackType.VEC2: + immediate = new Vec2(); + break; + case CompressedDataTrackType.VEC3: + immediate = new Vec3(); + break; + case CompressedDataTrackType.VEC4: + immediate = new Vec4(); + break; + } + trackEvalStatuses.push({ + type: track.type, + target: trackTarget, + curves: track.components, + immediate, + }); + } + + return new CompressedDataEvaluator(compressedDataEvalStatus); + } + + public collectAnimatedJoints () { + const joints: string[] = []; + + for (const track of this._tracks) { + if (!track.setter && isTargetingTRS(track.path)) { + const { path } = track.path[0] as HierarchyPath; + joints.push(path); + } + } + + return joints; + } + + @serializable + private _curves: KeySharedRealCurves[] = []; + + @serializable + private _tracks: CompressedTrack[] = []; + + private _addRealCurve (curve: RealCurve): CompressedCurvePointer { + const times = [...curve.times()]; + const values = [...curve.values()]; + let iKeySharedCurves = this._curves.findIndex((shared) => shared.curveCount === times.length + && shared.times.every((k, i) => approx(k, times[i], 1e-6))); + if (iKeySharedCurves < 0) { + iKeySharedCurves = this._curves.length; + const keySharedCurves = new KeySharedRealCurves(times); + this._curves.push(keySharedCurves); } + const iCurve = this._curves[iKeySharedCurves].curveCount; + this._curves[iKeySharedCurves].addCurve( + values, + curve.underflowOperation, + curve.overflowOperation, + ); + return { + shared: iKeySharedCurves, + component: iCurve, + }; } public validate () { - return this.keys.length > 0 && this.curves.length > 0; + return this._tracks.length > 0; } } -legacyCC.AnimationClip = AnimationClip; +class CompressedDataEvaluator { + constructor (compressedDataEvalStatus: CompressedDataEvalStatus) { + this._compressedDataEvalStatus = compressedDataEvalStatus; + } + + public evaluate (time: number) { + const { + keySharedCurvesEvalStatuses, + trackEvalStatuses: compressedTrackEvalStatuses, + } = this._compressedDataEvalStatus; + + const getPreEvaluated = (pointer: CompressedCurvePointer) => keySharedCurvesEvalStatuses[pointer.shared].result[pointer.component]; + + for (const keySharedCurvesEvalStatus of keySharedCurvesEvalStatuses) { + keySharedCurvesEvalStatus.curves.evaluate(time, keySharedCurvesEvalStatus.result); + } + + for (const compressedTrackEvalStatus of compressedTrackEvalStatuses) { + let value: unknown = compressedTrackEvalStatus.immediate; + switch (compressedTrackEvalStatus.type) { + default: + break; + case CompressedDataTrackType.FLOAT: + value = getPreEvaluated(compressedTrackEvalStatus.curves[0]); + break; + case CompressedDataTrackType.VEC2: + Vec2.set( + value as Vec2, + getPreEvaluated(compressedTrackEvalStatus.curves[0]), + getPreEvaluated(compressedTrackEvalStatus.curves[1]), + ); + break; + case CompressedDataTrackType.VEC3: + Vec3.set( + value as Vec3, + getPreEvaluated(compressedTrackEvalStatus.curves[0]), + getPreEvaluated(compressedTrackEvalStatus.curves[1]), + getPreEvaluated(compressedTrackEvalStatus.curves[2]), + ); + break; + case CompressedDataTrackType.VEC4: + Vec4.set( + value as Vec4, + getPreEvaluated(compressedTrackEvalStatus.curves[0]), + getPreEvaluated(compressedTrackEvalStatus.curves[1]), + getPreEvaluated(compressedTrackEvalStatus.curves[2]), + getPreEvaluated(compressedTrackEvalStatus.curves[4]), + ); + break; + } + compressedTrackEvalStatus.target.setValue(value); + } + } + + private _compressedDataEvalStatus: CompressedDataEvalStatus; +} + +interface CompressedTrack { + path: TrackPath; + setter: IValueProxyFactory | undefined; + type: CompressedDataTrackType; + components: CompressedCurvePointer[]; +} + +enum CompressedDataTrackType { + FLOAT, + VEC2, + VEC3, + VEC4, +} + +interface AnimationEvalContext { + /** + * The output pose. + */ + pose?: PoseOutput; + + /** + * The root animating target(should be scene node now). + */ + target: Node; +} + +interface TrackEvalStatus { + binding: RuntimeBinding; + trackEval: TrackEval; +} + +type CompressedTrackImmediate = Vec2 | Vec3 | Vec4; + +interface CompressedDataEvalStatus { + keySharedCurvesEvalStatuses: KeySharedCurvesEvalStatus[]; + + trackEvalStatuses: Array<{ + type: CompressedDataTrackType; + target: RuntimeBinding; + immediate: CompressedTrackImmediate | undefined; + curves: CompressedCurvePointer[]; + }>; +} + +interface CompressedCurvePointer { + shared: number; + component: number; +} + +interface KeySharedCurvesEvalStatus { + curves: KeySharedRealCurves; + result: number[]; +} + +// #endregion + +class AnimationClipEvaluator { + constructor ( + trackEvalStatuses: TrackEvalStatus[], + compressedDataEvaluator: CompressedDataEvaluator | undefined, + ) { + this._trackEvalStatues = trackEvalStatuses; + this._compressedDataEvaluator = compressedDataEvaluator; + } + + public evaluate (time: number) { + const { + _trackEvalStatues: trackEvalStatuses, + _compressedDataEvaluator: compressedDataEvaluator, + } = this; + + for (const trackEvalStatus of trackEvalStatuses) { + const value = trackEvalStatus.trackEval.evaluate(time, trackEvalStatus.binding); + trackEvalStatus.binding.setValue(value); + } + + if (compressedDataEvaluator) { + compressedDataEvaluator.evaluate(time); + } + } + + private _compressedDataEvaluator: CompressedDataEvaluator | undefined; + private _trackEvalStatues:TrackEvalStatus[] = []; +} + +/** + * Bind runtime target. Especially optimized for skeletal case. + */ +function bindRuntimeTarget ( + rootTarget: any, + path: TrackPath, + setter: IValueProxyFactory | undefined, + poseOutput: PoseOutput | undefined, + isConstant: boolean, +): IBoundTarget | null { + if (!isTargetingTRS(path) || !poseOutput) { + return createBoundTarget(rootTarget, path, setter); + } else { + const targetNode = evaluatePath(rootTarget, ...path.slice(0, path.length - 1)); + if (targetNode !== null && targetNode instanceof Node) { + const propertyName = path[path.length - 1] as 'position' | 'rotation' | 'scale' | 'eulerAngles'; + const blendStateWriter = poseOutput.createPoseWriter(targetNode, propertyName, isConstant); + return createBoundTarget(rootTarget, [], blendStateWriter); + } + } + return null; +} + +function isTargetingTRS (path: TargetPath[]) { + let prs: string | undefined; + if (path.length === 1 && typeof path[0] === 'string') { + prs = path[0]; + } else if (path.length > 1) { + for (let i = 0; i < path.length - 1; ++i) { + if (!(path[i] instanceof HierarchyPath)) { + return false; + } + } + prs = path[path.length - 1] as string; + } + switch (prs) { + case 'position': + case 'scale': + case 'rotation': + case 'eulerAngles': + return true; + default: + return false; + } +} diff --git a/cocos/core/animation/animation-curve.ts b/cocos/core/animation/animation-curve.ts index b3ddf63b78b..2ab3926e5a7 100644 --- a/cocos/core/animation/animation-curve.ts +++ b/cocos/core/animation/animation-curve.ts @@ -36,67 +36,7 @@ import { bezierByTime, BezierControlPoints } from './bezier'; import * as easing from './easing'; import { ILerpable, isLerpable } from './types'; import { legacyCC } from '../global-exports'; - -/** - * 表示曲线值,曲线值可以是任意类型,但必须符合插值方式的要求。 - */ -export type CurveValue = any; - -/** - * 表示曲线的目标对象。 - */ -export type CurveTarget = Record; - -/** - * 内置帧时间渐变方式名称。 - */ -export type EasingMethodName = keyof (typeof easing); - -/** - * 帧时间渐变方式。可能为内置帧时间渐变方式的名称或贝塞尔控制点。 - */ -export type EasingMethod = EasingMethodName | BezierControlPoints; - -type LerpFunction = (from: T, to: T, t: number, dt: number) => T; - -type CompressedEasingMethods = Record; - -/** - * 曲线数据。 - */ -export interface IPropertyCurveData { - /** - * 曲线使用的时间轴。 - * @see {AnimationClip.keys} - */ - keys: number; - - /** - * 曲线值。曲线值的数量应和 `keys` 所引用时间轴的帧数相同。 - */ - values: CurveValue[]; - - /** - * 曲线任意两帧时间的渐变方式。仅当 `easingMethods === undefined` 时本字段才生效。 - */ - easingMethod?: EasingMethod; - - /** - * 描述了每一帧时间到下一帧时间之间的渐变方式。 - */ - easingMethods?: EasingMethod[] | CompressedEasingMethods; - - /** - * 是否进行插值。 - * @default true - */ - interpolate?: boolean; - - /** - * For internal usage only. - */ - _arrayLength?: number; -} +import type * as legacy from './legacy-clip-data'; export class RatioSampler { public ratios: number[]; @@ -138,14 +78,14 @@ export class AnimCurve { return controlPoints as BezierControlPoints; } - public types?: Array<(EasingMethod | null)> = undefined; + public types?: Array<(legacy.LegacyEasingMethod | null)> = undefined; - public type?: EasingMethod | null = null; + public type?: legacy.LegacyEasingMethod | null = null; /** * The values of the keyframes. (y) */ - private _values: CurveValue[] = []; + private _values: legacy.LegacyCurveValue[] = []; /** * Lerp function used. If undefined, no lerp is performed. @@ -156,13 +96,13 @@ export class AnimCurve { private _array?: any[]; - constructor (propertyCurveData: Omit, duration: number) { + constructor (propertyCurveData: Omit, duration: number) { this._duration = duration; // Install values. this._values = propertyCurveData.values; - const getCurveType = (easingMethod: EasingMethod) => { + const getCurveType = (easingMethod: legacy.LegacyEasingMethod) => { if (typeof easingMethod === 'string') { return easingMethod; } else if (Array.isArray(easingMethod)) { @@ -323,7 +263,7 @@ legacyCC.sampleAnimationCurve = sampleAnimationCurve; * @param type - If it's Array, then ratio will be computed with bezierByTime. * If it's string, then ratio will be computed with cc.easing function */ -export function computeRatioByType (ratio: number, type: EasingMethod) { +export function computeRatioByType (ratio: number, type: legacy.LegacyEasingMethod) { if (typeof type === 'string') { const func = easing[type]; if (func) { @@ -389,7 +329,7 @@ const selectLerpFx = (() => { return (from: Quat, to: Quat, t: number, dt: number) => Quat.slerp(tempValue, from, to, t); } - return (value: any): LerpFunction | undefined => { + return (value: any): legacy.LegacyLerpFunction | undefined => { if (value === null) { return undefined; } diff --git a/cocos/core/animation/animation-state.ts b/cocos/core/animation/animation-state.ts index 4e0e264b867..c44e6c826fa 100644 --- a/cocos/core/animation/animation-state.ts +++ b/cocos/core/animation/animation-state.ts @@ -30,17 +30,13 @@ import { EDITOR } from 'internal:constants'; import { Node } from '../scene-graph/node'; -import { AnimationClip, IRuntimeCurve } from './animation-clip'; -import { AnimCurve, RatioSampler } from './animation-curve'; -import { createBoundTarget, createBufferedTarget, IBufferedTarget, IBoundTarget } from './bound-target'; +import { AnimationClip } from './animation-clip'; import { Playable } from './playable'; import { WrapMode, WrapModeMask, WrappedInfo } from './types'; -import { HierarchyPath, evaluatePath, TargetPath } from './target-path'; -import { BlendStateBuffer, createBlendStateWriter, IBlendStateWriter } from '../../3d/skeletal-animation/skeletal-animation-blending'; import { legacyCC } from '../global-exports'; import { ccenum } from '../value-types/enum'; -import { IValueProxyFactory } from './value-proxy'; import { assertIsTrue } from '../data/utils/asserts'; +import { PoseOutput } from './pose-output'; /** * @en The event type supported by Animation @@ -81,81 +77,6 @@ export enum EventType { FINISHED = 'finished', } ccenum(EventType); -export class ICurveInstance { - public commonTargetIndex?: number; - - private _curve: AnimCurve; - private _boundTarget: IBoundTarget; - private _rootTargetProperty?: string; - private _curveDetail: Omit; - - constructor ( - runtimeCurve: Omit, - target: any, - boundTarget: IBoundTarget, - ) { - this._curve = runtimeCurve.curve; - this._curveDetail = runtimeCurve; - - this._boundTarget = boundTarget; - } - - public applySample (ratio: number, index: number, lerpRequired: boolean, samplerResultCache, weight: number) { - if (this._curve.empty()) { - return; - } - let value: any; - if (!this._curve.hasLerp() || !lerpRequired) { - value = this._curve.valueAt(index); - } else { - value = this._curve.valueBetween( - ratio, - samplerResultCache.from, - samplerResultCache.fromRatio, - samplerResultCache.to, - samplerResultCache.toRatio, - ); - } - this._setValue(value, weight); - } - - private _setValue (value: any, weight: number) { - this._boundTarget.setValue(value); - } - - get propertyName () { return this._rootTargetProperty || ''; } - - get curveDetail () { - return this._curveDetail; - } -} - -/** - * The curves in ISamplerSharedGroup share a same keys. - */ -interface ISamplerSharedGroup { - sampler: RatioSampler | null; - curves: ICurveInstance[]; - samplerResultCache: { - from: number; - fromRatio: number; - to: number; - toRatio: number; - }; -} - -function makeSamplerSharedGroup (sampler: RatioSampler | null): ISamplerSharedGroup { - return { - sampler, - curves: [], - samplerResultCache: { - from: 0, - fromRatio: 0, - to: 0, - toRatio: 0, - }, - }; -} const InvalidIndex = -1; @@ -325,7 +246,16 @@ export class AnimationState extends Playable { /** * The weight. */ - public weight = 0; + get weight () { + return this._weight; + } + + set weight (value) { + this._weight = value; + if (this._poseOutput) { + this._poseOutput.weight = value; + } + } public frameRate = 0; @@ -341,16 +271,8 @@ export class AnimationState extends Playable { private _clip: AnimationClip; private _process = this.process; - private _samplerSharedGroups: ISamplerSharedGroup[] = []; private _target: Node | null = null; private _ignoreIndex = InvalidIndex; - /** - * May be `null` due to failed to initialize. - */ - private _commonTargetStatuses: (null | { - target: IBufferedTarget; - changed: boolean; - })[] = []; private _wrapMode = WrapMode.Normal; private _repeatCount = 1; private _delay = 0; @@ -365,14 +287,15 @@ export class AnimationState extends Playable { private _lastWrapInfo: WrappedInfo | null = null; private _lastWrapInfoEvent: WrappedInfo | null = null; private _wrappedInfo = new WrappedInfo(); - private _blendStateBuffer: BlendStateBuffer | null = null; - private _blendStateWriters: IBlendStateWriter[] = []; private _allowLastFrame = false; - private _blendStateWriterHost = { - weight: 0, - enabled: false, - }; private _playbackRange: { min: number; max: number; }; + private _poseOutput: PoseOutput | null = null; + private _weight = 0.0; + private _clipEval: ReturnType | undefined; + /** + * For internal usage. Really hack... + */ + protected _doNotCreateEval = false; constructor (clip: AnimationClip, name = '') { super(); @@ -391,14 +314,16 @@ export class AnimationState extends Playable { return this._curveLoaded; } - public initialize (root: Node, propertyCurves?: readonly IRuntimeCurve[]) { + public initialize (root: Node) { if (this._curveLoaded) { return; } this._curveLoaded = true; - this._destroyBlendStateWriters(); - this._samplerSharedGroups.length = 0; - this._blendStateBuffer = legacyCC.director.getAnimationManager()?.blendState ?? null; - if (this._blendStateBuffer) { - this._blendStateBuffer.bindState(this); + if (this._poseOutput) { + this._poseOutput.destroy(); + this._poseOutput = null; + } + if (this._clipEval) { + // TODO: destroy? + this._clipEval = undefined; } this._targetNode = root; const clip = this._clip; @@ -418,107 +343,23 @@ export class AnimationState extends Playable { this.repeatCount = 1; } - /** - * Create the bound target. Especially optimized for skeletal case. - */ - const createBoundTargetOptimized = ( - createFn: (...args: Parameters) => BoundTargetT | null, - rootTarget: any, - path: TargetPath[], - valueAdapter: IValueProxyFactory | undefined, - isConstant: boolean, - ): BoundTargetT | null => { - if (!isTargetingTRS(path) || !this._blendStateBuffer) { - return createFn(rootTarget, path, valueAdapter); - } else { - const targetNode = evaluatePath(rootTarget, ...path.slice(0, path.length - 1)); - if (targetNode !== null && targetNode instanceof Node) { - const propertyName = path[path.length - 1] as 'position' | 'rotation' | 'scale' | 'eulerAngles'; - const blendStateWriter = createBlendStateWriter( - this._blendStateBuffer, - targetNode, - propertyName, - this._blendStateWriterHost, - isConstant, - ); - this._blendStateWriters.push(blendStateWriter); - return createFn(rootTarget, [], blendStateWriter); - } - } - return null; - }; - - this._commonTargetStatuses = clip.commonTargets.map((commonTarget, index) => { - const target = createBoundTargetOptimized( - createBufferedTarget, - root, - commonTarget.modifiers, - commonTarget.valueAdapter, - false, - ); - if (target === null) { - return null; - } else { - return { - target, - changed: false, - }; - } - }); - - if (!propertyCurves) { - propertyCurves = clip.getPropertyCurves(); - } - for (let iPropertyCurve = 0; iPropertyCurve < propertyCurves.length; ++iPropertyCurve) { - const propertyCurve = propertyCurves[iPropertyCurve]; - let samplerSharedGroup = this._samplerSharedGroups.find((value) => value.sampler === propertyCurve.sampler); - if (!samplerSharedGroup) { - samplerSharedGroup = makeSamplerSharedGroup(propertyCurve.sampler); - this._samplerSharedGroups.push(samplerSharedGroup); - } - - let rootTarget: any; - if (typeof propertyCurve.commonTarget === 'undefined') { - rootTarget = root; - } else { - const commonTargetStatus = this._commonTargetStatuses[propertyCurve.commonTarget]; - if (!commonTargetStatus) { - continue; - } - rootTarget = commonTargetStatus.target.peek(); - } - - const boundTarget = createBoundTargetOptimized( - createBoundTarget, - rootTarget, - propertyCurve.modifiers, - propertyCurve.valueAdapter, - propertyCurve.curve.constant(), - ); - - if (boundTarget === null) { - // warn(`Failed to bind "${root.name}" to curve in clip ${clip.name}: ${err}`); - } else { - const curveInstance = new ICurveInstance( - propertyCurve, - rootTarget, - boundTarget, - ); - curveInstance.commonTargetIndex = propertyCurve.commonTarget; - samplerSharedGroup.curves.push(curveInstance); + if (!this._doNotCreateEval) { + const pose = legacyCC.director.getAnimationManager()?.blendState ?? null; + if (pose) { + this._poseOutput = new PoseOutput(pose); } + this._clipEval = clip.createEvaluator({ + target: root, + pose: this._poseOutput ?? undefined, + }); } } public destroy () { - this._destroyBlendStateWriters(); - } - - /** - * @private - */ - public onBlendFinished () { - this._blendStateWriterHost.enabled = false; + if (this._poseOutput) { + this._poseOutput.destroy(); + this._poseOutput = null; + } } /** @@ -667,69 +508,12 @@ export class AnimationState extends Playable { } protected _sampleCurves (ratio: number) { - this._blendStateWriterHost.weight = this.weight; - this._blendStateWriterHost.enabled = true; - - // Before we sample, we pull values of common targets. - for (let iCommonTarget = 0; iCommonTarget < this._commonTargetStatuses.length; ++iCommonTarget) { - const commonTargetStatus = this._commonTargetStatuses[iCommonTarget]; - if (!commonTargetStatus) { - continue; - } - commonTargetStatus.target.pull(); - commonTargetStatus.changed = false; - } - - for (let iSamplerSharedGroup = 0, szSamplerSharedGroup = this._samplerSharedGroups.length; - iSamplerSharedGroup < szSamplerSharedGroup; ++iSamplerSharedGroup) { - const samplerSharedGroup = this._samplerSharedGroups[iSamplerSharedGroup]; - const sampler = samplerSharedGroup.sampler; - const { samplerResultCache } = samplerSharedGroup; - let index = 0; - let lerpRequired = false; - if (!sampler) { - index = 0; - } else { - index = sampler.sample(ratio); - if (index < 0) { - index = ~index; - if (index <= 0) { - index = 0; - } else if (index >= sampler.ratios.length) { - index = sampler.ratios.length - 1; - } else { - lerpRequired = true; - samplerResultCache.from = index - 1; - samplerResultCache.fromRatio = sampler.ratios[samplerResultCache.from]; - samplerResultCache.to = index; - samplerResultCache.toRatio = sampler.ratios[samplerResultCache.to]; - index = samplerResultCache.from; - } - } - } - - for (let iCurveInstance = 0, szCurves = samplerSharedGroup.curves.length; - iCurveInstance < szCurves; ++iCurveInstance) { - const curveInstance = samplerSharedGroup.curves[iCurveInstance]; - curveInstance.applySample(ratio, index, lerpRequired, samplerResultCache, this.weight); - if (curveInstance.commonTargetIndex !== undefined) { - const commonTargetStatus = this._commonTargetStatuses[curveInstance.commonTargetIndex]; - if (commonTargetStatus) { - commonTargetStatus.changed = true; - } - } - } + if (this._poseOutput) { + this._poseOutput.enabled = true; + this._poseOutput.weight = this.weight; } - - // After sample, we push values of common targets. - for (let iCommonTarget = 0; iCommonTarget < this._commonTargetStatuses.length; ++iCommonTarget) { - const commonTargetStatus = this._commonTargetStatuses[iCommonTarget]; - if (!commonTargetStatus) { - continue; - } - if (commonTargetStatus.changed) { - commonTargetStatus.target.push(); - } + if (this._clipEval) { + this._clipEval.evaluate(this.current); } } @@ -787,9 +571,6 @@ export class AnimationState extends Playable { } } - private cache (frames: number) { - } - private _needReverse (currentIterations: number) { const wrapMode = this.wrapMode; let needReverse = false; @@ -984,41 +765,6 @@ export class AnimationState extends Playable { private _onPauseOrStop () { legacyCC.director.getAnimationManager().removeAnimation(this); } - - private _destroyBlendStateWriters () { - for (let iBlendStateWriter = 0; iBlendStateWriter < this._blendStateWriters.length; ++iBlendStateWriter) { - this._blendStateWriters[iBlendStateWriter].destroy(); - } - this._blendStateWriters.length = 0; - if (this._blendStateBuffer) { - this._blendStateBuffer.unbindState(this); - this._blendStateBuffer = null; - } - this._blendStateWriterHost.enabled = false; - } -} - -function isTargetingTRS (path: TargetPath[]) { - let prs: string | undefined; - if (path.length === 1 && typeof path[0] === 'string') { - prs = path[0]; - } else if (path.length > 1) { - for (let i = 0; i < path.length - 1; ++i) { - if (!(path[i] instanceof HierarchyPath)) { - return false; - } - } - prs = path[path.length - 1] as string; - } - switch (prs) { - case 'position': - case 'scale': - case 'rotation': - case 'eulerAngles': - return true; - default: - return false; - } } function wrapIterations (iterations: number) { diff --git a/cocos/core/animation/animation.ts b/cocos/core/animation/animation.ts index 1f5ef6df239..2787cf14931 100644 --- a/cocos/core/animation/animation.ts +++ b/cocos/core/animation/animation.ts @@ -33,3 +33,12 @@ export * from './value-proxy'; export { UniformProxyFactory } from './value-proxy-factories/uniform'; export { MorphWeightsValueProxy, MorphWeightsAllValueProxy } from './value-proxy-factories/morph-weights'; export * from './cubic-spline-value'; +export { + Track, + RealTrack, + IntegerTrack, + VectorTrack, + QuaternionTrack, + ColorTrack, + ObjectTrack, +} from './animation-clip'; diff --git a/cocos/core/animation/cubic-spline-value.ts b/cocos/core/animation/cubic-spline-value.ts index bb1fac42dba..6733e436cfd 100644 --- a/cocos/core/animation/cubic-spline-value.ts +++ b/cocos/core/animation/cubic-spline-value.ts @@ -112,16 +112,20 @@ function makeCubicSplineValueConstructor ( export const CubicSplineVec2Value = makeCubicSplineValueConstructor( 'cc.CubicSplineVec2Value', Vec2, Vec2.multiplyScalar, Vec2.scaleAndAdd, ); + +export type CubicSplineVec2Value = ICubicSplineValue; legacyCC.CubicSplineVec2Value = CubicSplineVec2Value; export const CubicSplineVec3Value = makeCubicSplineValueConstructor( 'cc.CubicSplineVec3Value', Vec3, Vec3.multiplyScalar, Vec3.scaleAndAdd, ); +export type CubicSplineVec3Value = ICubicSplineValue; legacyCC.CubicSplineVec3Value = CubicSplineVec3Value; export const CubicSplineVec4Value = makeCubicSplineValueConstructor( 'cc.CubicSplineVec4Value', Vec4, Vec4.multiplyScalar, Vec4.scaleAndAdd, ); +export type CubicSplineVec4Value = ICubicSplineValue; legacyCC.CubicSplineVec4Value = CubicSplineVec4Value; export const CubicSplineQuatValue = makeCubicSplineValueConstructor( diff --git a/cocos/core/animation/index.ts b/cocos/core/animation/index.ts index 8549766fd4d..fc83b6b815f 100644 --- a/cocos/core/animation/index.ts +++ b/cocos/core/animation/index.ts @@ -41,7 +41,7 @@ legacyCC.easing = easing; export * from './bezier'; export { easing }; export * from './animation-curve'; -export * from './animation-clip'; +export { AnimationClip } from './animation-clip'; export * from './animation-manager'; export { AnimationState, diff --git a/cocos/core/animation/internal-symbols.ts b/cocos/core/animation/internal-symbols.ts new file mode 100644 index 00000000000..37aa2847753 --- /dev/null +++ b/cocos/core/animation/internal-symbols.ts @@ -0,0 +1,2 @@ + +export const BAKE_SKELETON_CURVE_SYMBOL = Symbol('BakeNodeCurves'); diff --git a/cocos/core/animation/legacy-clip-data.ts b/cocos/core/animation/legacy-clip-data.ts new file mode 100644 index 00000000000..6b13d77bbd2 --- /dev/null +++ b/cocos/core/animation/legacy-clip-data.ts @@ -0,0 +1,219 @@ +import { TargetPath } from './target-path'; +import { IValueProxyFactory } from './value-proxy'; +import * as easing from './easing'; +import { BezierControlPoints } from './bezier'; +import { CompactValueTypeArray } from '../data/utils/compact-value-type-array'; +import { serializable } from '../data/decorators'; +import { AnimCurve, RatioSampler } from './animation-curve'; + +/** + * 表示曲线值,曲线值可以是任意类型,但必须符合插值方式的要求。 + */ +export type LegacyCurveValue = any; + +/** + * 表示曲线的目标对象。 + */ +export type LegacyCurveTarget = Record; + +/** + * 内置帧时间渐变方式名称。 + */ +export type LegacyEasingMethodName = keyof (typeof easing); + +/** + * 帧时间渐变方式。可能为内置帧时间渐变方式的名称或贝塞尔控制点。 + */ +export type LegacyEasingMethod = LegacyEasingMethodName | BezierControlPoints; + +export type LegacyCompressedEasingMethods = Record; + +export type LegacyLerpFunction = (from: T, to: T, t: number, dt: number) => T; + +export interface LegacyClipCurveData { + /** + * 曲线使用的时间轴。 + * @see {AnimationClip.keys} + */ + keys: number; + + /** + * 曲线值。曲线值的数量应和 `keys` 所引用时间轴的帧数相同。 + */ + values: LegacyCurveValue[]; + + /** + * 曲线任意两帧时间的渐变方式。仅当 `easingMethods === undefined` 时本字段才生效。 + */ + easingMethod?: LegacyEasingMethod; + + /** + * 描述了每一帧时间到下一帧时间之间的渐变方式。 + */ + easingMethods?: LegacyEasingMethod[] | LegacyCompressedEasingMethods; + + /** + * 是否进行插值。 + * @default true + */ + interpolate?: boolean; + + /** + * For internal usage only. + */ + _arrayLength?: number; +} + +export interface LegacyClipCurve { + commonTarget?: number; + + modifiers: TargetPath[]; + + valueAdapter?: IValueProxyFactory; + + data: LegacyClipCurveData; +} + +export interface LegacyCommonTarget { + modifiers: TargetPath[]; + valueAdapter?: IValueProxyFactory; +} + +export type LegacyMaybeCompactCurve = Omit & { + data: Omit & { + values: any[] | CompactValueTypeArray; + }; +}; + +export type LegacyMaybeCompactKeys = Array; + +export type LegacyRuntimeCurve = Pick & { + /** + * 属性曲线。 + */ + curve: AnimCurve; + + /** + * 曲线采样器。 + */ + sampler: RatioSampler | null; +} + +export class AnimationClipLegacyData { + constructor (duration: number) { + this._duration = duration; + } + + get keys () { + return this._keys; + } + + set keys (value) { + this._keys = value; + } + + get curves () { + return this._curves; + } + + set curves (value) { + this._curves = value; + delete this._runtimeCurves; + } + + get commonTargets () { + return this._commonTargets; + } + + set commonTargets (value) { + this._commonTargets = value; + } + + /** + * 此动画的数据。 + */ + get data () { + return this._data; + } + + public getPropertyCurves (): readonly LegacyRuntimeCurve[] { + if (!this._runtimeCurves) { + this._createPropertyCurves(); + } + return this._runtimeCurves!; + } + + @serializable + private _keys: number[][] = []; + + @serializable + private _curves: LegacyClipCurve[] = []; + + @serializable + private _commonTargets: LegacyCommonTarget[] = []; + + private _ratioSamplers: RatioSampler[] = []; + + private _runtimeCurves?: LegacyRuntimeCurve[]; + + private _data: Uint8Array | null = null; + + private _duration: number; + + protected _createPropertyCurves () { + this._ratioSamplers = this._keys.map( + (keys) => new RatioSampler( + keys.map( + (key) => key / this._duration, + ), + ), + ); + + this._runtimeCurves = this._curves.map((targetCurve): LegacyRuntimeCurve => ({ + curve: new AnimCurve(targetCurve.data, this._duration), + modifiers: targetCurve.modifiers, + valueAdapter: targetCurve.valueAdapter, + sampler: this._ratioSamplers[targetCurve.data.keys], + commonTarget: targetCurve.commonTarget, + })); + } + + // private _decodeCVTAs () { + // const binaryBuffer: ArrayBuffer = ArrayBuffer.isView(this._nativeAsset) ? this._nativeAsset.buffer : this._nativeAsset; + // if (!binaryBuffer) { + // return; + // } + + // const maybeCompressedKeys = this._keys; + // for (let iKey = 0; iKey < maybeCompressedKeys.length; ++iKey) { + // const keys = maybeCompressedKeys[iKey]; + // if (keys instanceof CompactValueTypeArray) { + // maybeCompressedKeys[iKey] = keys.decompress(binaryBuffer); + // } + // } + + // for (let iCurve = 0; iCurve < this._curves.length; ++iCurve) { + // const curve = this._curves[iCurve]; + // if (curve.data.values instanceof CompactValueTypeArray) { + // curve.data.values = curve.data.values.decompress(binaryBuffer); + // } + // } + // } +} + +// #region Legacy data structures prior to 1.2 + +export interface LegacyObjectCurveData { + [propertyName: string]: LegacyClipCurveData; +} + +export interface LegacyComponentsCurveData { + [componentName: string]: LegacyObjectCurveData; +} + +export interface LegacyNodeCurveData { + props?: LegacyObjectCurveData; + comps?: LegacyComponentsCurveData; +} + +// #endregion diff --git a/cocos/core/animation/pose-output.ts b/cocos/core/animation/pose-output.ts new file mode 100644 index 00000000000..c96aad58d54 --- /dev/null +++ b/cocos/core/animation/pose-output.ts @@ -0,0 +1,48 @@ +import { BlendStateBuffer, BlendingProperty, createBlendStateWriter, IBlendStateWriter } from '../../3d/skeletal-animation/skeletal-animation-blending'; +import type { Node } from '../scene-graph'; + +export type Pose = BlendStateBuffer; + +export class PoseOutput { + public weight = 0.0; + + public enabled = false; + + constructor (pose: Pose) { + this._pose = pose; + this._pose.bindState(this); + } + + public destroy () { + for (let iBlendStateWriter = 0; iBlendStateWriter < this._blendStateWriters.length; ++iBlendStateWriter) { + this._blendStateWriters[iBlendStateWriter].destroy(); + } + this._blendStateWriters.length = 0; + if (this._pose) { + this._pose.unbindState(this); + } + this.enabled = false; + } + + public createPoseWriter (node: Node, property: BlendingProperty, constants: boolean) { + const writer = createBlendStateWriter( + this._pose, + node, + property, + this, + constants, + ); + + this._blendStateWriters.push(writer); + + return writer; + } + + public onBlendFinished () { + this.enabled = false; + } + + private _pose: Pose; + + private _blendStateWriters: IBlendStateWriter[] = []; +} diff --git a/cocos/core/curves/curve-base.ts b/cocos/core/curves/curve-base.ts new file mode 100644 index 00000000000..841b4fefb4d --- /dev/null +++ b/cocos/core/curves/curve-base.ts @@ -0,0 +1,4 @@ +export interface CurveBase { + readonly rangeMin: number; + readonly rangeMax: number; +} diff --git a/cocos/core/curves/curve.ts b/cocos/core/curves/curve.ts new file mode 100644 index 00000000000..3b6359e525d --- /dev/null +++ b/cocos/core/curves/curve.ts @@ -0,0 +1,173 @@ +import { assertIsTrue } from '../data/utils/asserts'; +import { approx, lerp, pingPong, repeat } from '../math'; +import { KeyframeCurve } from './keyframe-curve'; +import { evalFloatCurve } from './eval-float-curve'; +import { ccclass, serializable } from '../data/decorators'; + +/** + * The method used for interpolation between values of a keyframe and its next keyframe. + */ +export enum RealInterpMethod { + /** + * Perform linear interpolation. + */ + LINEAR, + + /** + * Always use the value from this keyframe. + */ + CONSTANT, + + /** + * Perform cubic(hermite) interpolation. + */ + CUBIC, +} + +/** + * Specifies the operation should be taken + * if input time is underflow(less than the the first frame time) or + * overflow(greater than the last frame time) when evaluating an animation curve. + */ +export enum OverflowOperation { + /** + * Compute the result + * according to the first two frame's linear trend in the case of underflow and + * according to the last two frame's linear trend in the case of overflow. + * If there are less than two frames, fallback to `CLAMP`. + */ + LINEAR_TREND, + + /** + * Use first frame's value in the case of underflow, + * use last frame's value in the case of overflow. + */ + CLAMP, + + /** + * Before evaluation, repeatedly mapping the input time into the allowed range. + */ + REPEAT, + + /** + * Before evaluation, mapping the input time into the allowed range like ping pong. + */ + PING_PONG, +} + +export interface RealKeyframeValue { + /** + * Interpolation method used for this keyframe. + */ + interpMethod: RealInterpMethod; + + /** + * Value of the keyframe. + */ + value: number; + + /** + * The tangent of this keyframe + * when it's used as starting point during cubic interpolation. + * Meaningless otherwise. + */ + startTangent: number; + + /** + * The tangent of this keyframe + * when it's used as ending point during cubic interpolation. + * Meaningless otherwise. + */ + endTangent: number; +} + +/** + * Curve. + */ +@ccclass('cc.RealCurve') +export class RealCurve extends KeyframeCurve { + /** + * Gets or sets the operation should be taken + * if input time is less than the time of first keyframe when evaluating this curve. + * Defaults to `OverflowOperation.CLAMP`. + */ + get underflowOperation () { + return this._underflowOp; + } + + set underflowOperation (value) { + this._underflowOp = value; + } + + /** + * Gets or sets the operation should be taken + * if input time is greater than the time of last keyframe when evaluating this curve. + * Defaults to `OverflowOperation.CLAMP`. + */ + get overflowOperation () { + return this._overflowOp; + } + + set overflowOperation (value) { + this._overflowOp = value; + } + + /** + * Evaluates this curve at specified time. + * @param time Input time. + * @returns Result value. + */ + public evaluate (time: number): number { + return evalFloatCurve( + time, + this._times, + this._values, + this._underflowOp, + this._overflowOp, + ); + } + + /** + * Adds a keyframe into this curve. + * @param time Time of the keyframe. + * @param value Value of the keyframe. + * @returns The index to the new keyframe. + */ + public addKeyFrame (time: number, value: number): number { + return this.insertNewKeyframe(time, { + interpMethod: RealInterpMethod.LINEAR, + value, + startTangent: 0, + endTangent: 0, + }); + } + + /** + * Returns if this curve is constant. + * @param tolerance The tolerance. + * @returns Whether it is constant. + */ + public isConstant (tolerance: number) { + if (this._values.length <= 1) { + return true; + } + const firstVal = this._values[0].value; + return this._values.every((frame) => approx(frame.value, firstVal, tolerance)); + } + + protected copyValue (value: RealKeyframeValue): RealKeyframeValue { + return { + interpMethod: value.interpMethod, + value: value.value, + startTangent: value.startTangent, + endTangent: value.endTangent, + }; + } + + // Always sorted by time + @serializable + private _underflowOp: OverflowOperation = OverflowOperation.CLAMP; + + @serializable + private _overflowOp: OverflowOperation = OverflowOperation.CLAMP; +} diff --git a/cocos/core/curves/eval-float-curve.ts b/cocos/core/curves/eval-float-curve.ts new file mode 100644 index 00000000000..14e80038b35 --- /dev/null +++ b/cocos/core/curves/eval-float-curve.ts @@ -0,0 +1,108 @@ +import { RealInterpMethod, RealKeyframeValue, OverflowOperation } from './curve'; +import { assertIsTrue } from '../data/utils/asserts'; +import { approx, lerp, pingPong, repeat } from '../math'; +import { binarySearchEpsilon } from '../algorithm/binary-search'; + +export function evalFloatCurve ( + time: number, + times: readonly number[], + values: readonly RealKeyframeValue[], + underflowOperation: OverflowOperation, + overflowOperation: OverflowOperation, +) { + const nFrames = times.length; + + if (nFrames === 0) { + return 0.0; + } + + const firstTime = times[0]; + const lastTime = times[nFrames - 1]; + if (time < firstTime) { + // Underflow + const preTime = times[0]; + const preValue = values[0]; + if (underflowOperation === OverflowOperation.LINEAR_TREND && nFrames > 1) { + const nextTime = times[1]; + const nextFrame = values[1]; + const slope = (nextFrame.value - preValue.value) / (nextTime - preTime); + return preValue.value + (time - preTime) * slope; + } else { + switch (underflowOperation) { + case OverflowOperation.REPEAT: + time = firstTime + repeat(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.PING_PONG: + time = firstTime + pingPong(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.CLAMP: + default: + return preValue.value; + } + } + } else if (time > lastTime) { + // Overflow + const preTime = lastTime; + const preFrame = values[nFrames - 1]; + if (overflowOperation === OverflowOperation.LINEAR_TREND && nFrames > 1) { + const nextTime = times[nFrames - 2]; + const nextValue = values[nFrames - 2]; + const slope = (nextValue.value - preFrame.value) / (nextTime - preTime); + return preFrame.value + (time - preTime) * slope; + } else { + switch (overflowOperation) { + case OverflowOperation.REPEAT: + time = firstTime + repeat(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.PING_PONG: + time = firstTime + pingPong(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.CLAMP: + default: + return preFrame.value; + } + } + } + + const index = binarySearchEpsilon(times, time); + if (index >= 0) { + return values[index].value; + } + + const iNext = ~index; + assertIsTrue(iNext !== 0 && iNext !== nFrames && nFrames > 1); + + const iPre = iNext - 1; + const preTime = times[iPre]; + const preValue = values[iPre]; + const nextTime = times[iNext]; + const nextValue = values[iNext]; + assertIsTrue(nextTime > time && time > preTime); + const dt = nextTime - preTime; + + const ratio = (time - preTime) / dt; + switch (preValue.interpMethod) { + default: + case RealInterpMethod.CONSTANT: + return preValue.value; + case RealInterpMethod.LINEAR: + return lerp(preValue.value, nextValue.value, ratio); + case RealInterpMethod.CUBIC: { + const t0 = preValue.endTangent; + const t1 = nextValue.startTangent; + const oneThird = 1.0 / 3.0; + const p1 = preValue.value + oneThird * t0 * dt; + const p2 = nextValue.value - oneThird * t1 * dt; + return bezierInterp(preValue.value, p1, p2, nextValue.value, ratio); + } + } +} + +function bezierInterp (p0: number, p1: number, p2: number, p3: number, t: number) { + const u = 1 - t; + const coeff0 = u * u * u; + const coeff1 = 3 * u * u * t; + const coeff2 = 3 * u * t * t; + const coeff3 = t * t * t; + return coeff0 * p0 + coeff1 * p1 + coeff2 * p2 + coeff3 * p3; +} diff --git a/cocos/core/curves/index.ts b/cocos/core/curves/index.ts new file mode 100644 index 00000000000..8bb8a10b71c --- /dev/null +++ b/cocos/core/curves/index.ts @@ -0,0 +1,13 @@ +export { + RealCurve, + RealInterpMethod, +} from './curve'; + +export { + IntegerCurve, +} from './integer-curve'; + +export { + QuaternionCurve, + QuaternionInterpMethod, +} from './quat-curve'; diff --git a/cocos/core/curves/integer-curve.ts b/cocos/core/curves/integer-curve.ts new file mode 100644 index 00000000000..7ada30351c2 --- /dev/null +++ b/cocos/core/curves/integer-curve.ts @@ -0,0 +1,41 @@ +import { ccclass, serializable } from '../data/decorators'; +import { RealCurve } from './curve'; + +export enum RoundType { + /** + * Returns the integer part of the result by removing any fractional digits. + */ + TRUNC, + + /** + * Returns the largest integer less than or equal to the result. + */ + FLOOR, + + /** + * Rounds the result up to the next largest integer. + */ + CEIL, + + /** + * Returns the result rounded to the nearest integer. + */ + ROUND, +} + +@ccclass('cc.IntegerCurve') +export class IntegerCurve extends RealCurve { + @serializable + public truncType: RoundType = RoundType.TRUNC; + + public evaluate (time: number) { + const value = super.evaluate(time); + switch (this.truncType) { + default: + case RoundType.TRUNC: return Math.trunc(value); + case RoundType.FLOOR: return Math.floor(value); + case RoundType.CEIL: return Math.ceil(value); + case RoundType.ROUND: return Math.round(value); + } + } +} diff --git a/cocos/core/curves/keyframe-curve.ts b/cocos/core/curves/keyframe-curve.ts new file mode 100644 index 00000000000..ee00ad98bdf --- /dev/null +++ b/cocos/core/curves/keyframe-curve.ts @@ -0,0 +1,178 @@ +import { binarySearchEpsilon } from '../algorithm/binary-search'; +import { ccclass, serializable } from '../data/decorators'; +import { assertIsTrue } from '../data/utils/asserts'; +import { approx } from '../math'; +import type { CurveBase } from './curve-base'; + +type KeyFrame = [number, TKeyframeValue]; + +/** + * Curve. + */ +@ccclass('cc.KeyframeCurve') +export class KeyframeCurve implements CurveBase { + /** + * Gets the count of keyframes. + */ + get keyFramesCount () { + return this._times.length; + } + + /** + * Indicates if this curve has no any key frame. + */ + get empty () { + return this._times.length === 0; + } + + /** + * Gets the minimal time. + */ + get rangeMin () { + return this._times[0]; + } + + /** + * Gets the maximum time. + */ + get rangeMax () { + return this._times[this._values.length - 1]; + } + + /** + * Returns an iterator to keyframe pairs. + */ + public* keyframes (): Iterable> { + for (let i = 0; i < this._times.length; ++i) { + yield [this._times[i], this._values[i]]; + } + } + + public times (): Iterable { + return this._times; + } + + public values (): Iterable { + return this._values; + } + + /** + * Gets the time of specified keyframe. + * @param index Index to the keyframe. + * @returns The keyframe 's time. + */ + public getKeyframeTime (index: number): number { + return this._times[index]; + } + + /** + * Gets the value of specified keyframe. + * @param index Index to the keyframe. + * @returns The keyframe 's value. + */ + public getKeyframeValue (index: number): TKeyframeValue { + return this._values[index]; + } + + /** + * Removes a keyframe from this curve. + * @param index Index to the keyframe. + */ + public removeKeyframe (index: number) { + this._times.splice(index, 1); + this._values.splice(index, 1); + } + + /** + * Searches for the keyframe at specified time. + * @param time Time to search. + * @returns Index to the keyframe or negative number if not found. + */ + public indexOfKeyframe (time: number) { + return binarySearchEpsilon(this._times, time); + } + + /** + * Updates the time of a keyframe. + * @param index Index to the keyframe. + * @param time New time. + */ + public updateTime (index: number, time: number) { + const value = this._values[index]; + this.removeKeyframe(index); + this.insertNewKeyframe(time, value); + } + + /** + * Assigns all keyframes. + * @param keyframes An iterable to keyframes. The keyframes should be sorted by their time. + */ + public assignSorted (keyframes: Iterable<[number, TKeyframeValue]>): void; + + /** + * Assigns all keyframes. + * @param times Times array. Should be sorted. + * @param values Values array. Corresponding to each time in `times`. + */ + public assignSorted (times: readonly number[], values: TKeyframeValue[]): void; + + public assignSorted (times: Iterable<[number, TKeyframeValue]> | readonly number[], values?: readonly TKeyframeValue[]) { + if (values !== undefined) { + assertIsTrue(Array.isArray(times)); + assertIsTrue(isSorted(times)); + assertIsTrue(times.length === values.length); + this._times = times.slice(); + this._values = values.map((value) => this.copyValue(value)); + } else { + const keyframes = Array.from(times as Iterable<[number, TKeyframeValue]>); + this._times = keyframes.map(([time]) => time); + this._values = keyframes.map(([, value]) => this.copyValue(value)); + } + } + + protected searchKeyframe (time: number) { + return binarySearchEpsilon(this._times, time); + } + + protected insertNewKeyframe (time: number, value: TKeyframeValue) { + const times = this._times; + const values = this._values; + const nFrames = times.length; + + const index = binarySearchEpsilon(times, time); + if (index >= 0) { + return index; + } + const iNext = ~index; + if (iNext === 0) { + times.unshift(time); + values.unshift(value); + } else if (iNext === nFrames) { + times.push(time); + values.push(value); + } else { + assertIsTrue(nFrames > 1); + times.splice(iNext - 1, 0, time); + values.splice(iNext - 1, 0, value); + } + return iNext; + } + + protected copyValue (value: TKeyframeValue) { + return value; + } + + // Times are always sorted and 1-1 correspond to values. + @serializable + protected _times: number[] = []; + + @serializable + protected _values: TKeyframeValue[] = []; +} + +function isSorted (values: number[]) { + return values.every( + (value, index, array) => index === 0 + || value > array[index - 1] || approx(value, array[index - 1], 1e-6), + ); +} diff --git a/cocos/core/curves/keys-shared-curves.ts b/cocos/core/curves/keys-shared-curves.ts new file mode 100644 index 00000000000..570fbf6ab77 --- /dev/null +++ b/cocos/core/curves/keys-shared-curves.ts @@ -0,0 +1,44 @@ +import { assertIsTrue } from '../data/utils/asserts'; +import { RealKeyframeValue, OverflowOperation } from './curve'; + +/** + * Considering most of model animations are baked and most of its curves share same times, + * we do not have to do time searching for many times. + */ +export class KeySharedRealCurves { + constructor (times: number[]) { + this._times = times; + } + + get keyframesCount () { + return this._times.length; + } + + get curveCount () { + return this._curves.length; + } + + get times (): readonly number[] { + return this._times; + } + + public addCurve (values: RealKeyframeValue[], underflowOperation: OverflowOperation, overflowOperation: OverflowOperation) { + assertIsTrue(values.length === this._times.length); + this._curves.push({ + values, + underflowOperation, + overflowOperation, + }); + } + + public evaluate (time: number, values: number[]) { + } + + private _times: number[]; + + private _curves: { + underflowOperation: OverflowOperation; + overflowOperation: OverflowOperation; + values: RealKeyframeValue[]; + }[] = []; +} diff --git a/cocos/core/curves/object-curve.ts b/cocos/core/curves/object-curve.ts new file mode 100644 index 00000000000..d2126073fc6 --- /dev/null +++ b/cocos/core/curves/object-curve.ts @@ -0,0 +1,17 @@ +import { ccclass } from '../data/decorators'; +import { clamp } from '../math'; +import { KeyframeCurve } from './keyframe-curve'; + +export type ObjectCurveKeyframe = T; + +@ccclass('cc.ObjectCurve') +export class ObjectCurve extends KeyframeCurve> { + public evaluate (time: number) { + const iSearch = this.searchKeyframe(time); + if (iSearch >= 0) { + return this._values[iSearch]; + } + const iPrev = clamp((~iSearch) - 1, 0, this._values.length - 1); + return this._values[iPrev]; + } +} diff --git a/cocos/core/curves/quat-curve.ts b/cocos/core/curves/quat-curve.ts new file mode 100644 index 00000000000..890a6af2412 --- /dev/null +++ b/cocos/core/curves/quat-curve.ts @@ -0,0 +1,178 @@ +import { assertIsTrue } from '../data/utils/asserts'; +import { approx, lerp, pingPong, Quat, repeat } from '../math'; +import { KeyframeCurve } from './keyframe-curve'; +import { evalFloatCurve } from './eval-float-curve'; +import { OverflowOperation } from './curve'; +import { binarySearchEpsilon } from '../algorithm/binary-search'; +import { ccclass, serializable } from '../data/decorators'; + +export interface QuaternionKeyframeValue { + /** + * Interpolation method used for this keyframe. + */ + interpMethod: QuaternionInterpMethod; + + /** + * Value of the keyframe. + */ + value: Quat; +} + +/** + * The method used for interpolation between values of a keyframe and its next keyframe. + */ +export enum QuaternionInterpMethod { + /** + * Perform spherical linear interpolation. + */ + SLERP, + + /** + * Always use the value from this keyframe. + */ + CONSTANT, + + // #region TODO: Spherical Quadrangle Interpolation + /** + * TODO: Spherical Quadrangle Interpolation + * - https://theory.org/software/qfa/writeup/node12.html + * - http://digitalrune.github.io/DigitalRune-Documentation/html/58f74cca-83a3-5e9e-6d5d-63b09a723f90.htm + */ + // SQUAD, + // #endregion +} + +/** + * Quaternion curve. + */ +@ccclass('cc.QuaternionCurve') +export class QuaternionCurve extends KeyframeCurve { + /** + * Gets or sets the operation should be taken + * if input time is less than the time of first keyframe when evaluating this curve. + * Defaults to `OverflowOperation.CLAMP`. + */ + get underflowOperation () { + return this._underflowOp; + } + + set underflowOperation (value) { + this._underflowOp = value; + } + + /** + * Gets or sets the operation should be taken + * if input time is greater than the time of last keyframe when evaluating this curve. + * Defaults to `OverflowOperation.CLAMP`. + */ + get overflowOperation () { + return this._overflowOp; + } + + set overflowOperation (value) { + this._overflowOp = value; + } + + /** + * Evaluates this curve at specified time. + * @param time Input time. + * @returns Result value. + */ + public evaluate (time: number, quat?: Quat): Quat { + quat ??= new Quat(); + + const { + _times: times, + _values: values, + _overflowOp: overflowOperation, + _underflowOp: underflowOperation, + } = this; + const nFrames = times.length; + + if (nFrames === 0) { + return quat; + } + + const firstTime = times[0]; + const lastTime = times[nFrames - 1]; + if (time < firstTime) { + // Underflow + const preValue = values[0]; + switch (underflowOperation) { + case OverflowOperation.REPEAT: + time = firstTime + repeat(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.PING_PONG: + time = firstTime + pingPong(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.CLAMP: + default: + return Quat.copy(quat, preValue.value); + } + } else if (time > lastTime) { + // Overflow + const preValue = values[nFrames - 1]; + switch (overflowOperation) { + case OverflowOperation.REPEAT: + time = firstTime + repeat(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.PING_PONG: + time = firstTime + pingPong(time - firstTime, lastTime - firstTime); + break; + case OverflowOperation.CLAMP: + default: + return Quat.copy(quat, preValue.value); + } + } + + const index = binarySearchEpsilon(times, time); + if (index >= 0) { + return Quat.copy(quat, values[index].value); + } + + const iNext = ~index; + assertIsTrue(iNext !== 0 && iNext !== nFrames && nFrames > 1); + + const iPre = iNext - 1; + const preTime = times[iPre]; + const preValue = values[iPre]; + const nextTime = times[iNext]; + const nextValue = values[iNext]; + assertIsTrue(nextTime > time && time > preTime); + const dt = nextTime - preTime; + + const ratio = (time - preTime) / dt; + switch (preValue.interpMethod) { + default: + case QuaternionInterpMethod.CONSTANT: + return Quat.copy(quat, preValue.value); + case QuaternionInterpMethod.SLERP: + return Quat.slerp(quat, preValue.value, nextValue.value, ratio); + } + } + + /** + * Adds a keyframe into this curve. + * @param time Time of the keyframe. + * @param value Value of the keyframe. + * @returns The index to the new keyframe. + */ + public addKeyFrame (time: number, value: QuaternionKeyframeValue): number { + // TODO: shall we normalize it? + return this.insertNewKeyframe(time, value); + } + + protected copyValue (value: QuaternionKeyframeValue): QuaternionKeyframeValue { + return { + interpMethod: value.interpMethod, + value: Quat.copy(new Quat(), value.value), + }; + } + + // Always sorted by time + @serializable + private _underflowOp: OverflowOperation = OverflowOperation.CLAMP; + + @serializable + private _overflowOp: OverflowOperation = OverflowOperation.CLAMP; +} diff --git a/cocos/core/data/utils/asserts.ts b/cocos/core/data/utils/asserts.ts index f89ef8ff009..badab9a7838 100644 --- a/cocos/core/data/utils/asserts.ts +++ b/cocos/core/data/utils/asserts.ts @@ -39,7 +39,7 @@ export function assertIsNonNullable (expr: T, message?: string): asserts expr * @param expr Testing expression. * @param message Optional message. */ -export function assertIsTrue (expr: boolean, message?: string) { +export function assertIsTrue (expr: unknown, message?: string): asserts expr { if (DEBUG && !expr) { throw new Error(`Assertion failed: ${message ?? ''}`); } diff --git a/cocos/core/index.ts b/cocos/core/index.ts index 5dcf81c9495..1637201e7aa 100644 --- a/cocos/core/index.ts +++ b/cocos/core/index.ts @@ -61,3 +61,4 @@ export * from './scene-graph'; export * from './components'; export * from './builtin'; export * from './animation'; +export * from './curves'; diff --git a/tests/animation/animaion-clip-migration-3.x.test.ts b/tests/animation/animaion-clip-migration-3.x.test.ts new file mode 100644 index 00000000000..a116799a3c2 --- /dev/null +++ b/tests/animation/animaion-clip-migration-3.x.test.ts @@ -0,0 +1,190 @@ +import { math, RealInterpMethod } from "../../cocos/core"; +import { AnimationClip, animation } from "../../cocos/core/animation"; +import { RealKeyframeValue } from "../../cocos/core/curves/curve"; + + +describe('Animation Clip Migration 3.x', () => { + test('Numeric curves', () => { + const clip = new AnimationClip(); + clip.keys = [ + [0.0, 0.2, 0.8], + ]; + clip.curves = [ + { + modifiers: ['p'], + data: { + keys: 0, + values: [3.14, 6.18, 8.9], + }, + }, + ] + clip.syncLegacyData(); + expect(clip.tracksCount).toBe(1); + const track = clip.getTrack(0) as animation.RealTrack; + expect(track).toBeInstanceOf(animation.RealTrack); + const curve = track.channel.curve; + expect(Array.from(curve.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(curve.values())).toStrictEqual( + createRealKeyframesWithoutTangent([3.14, 6.18, 8.9], RealInterpMethod.LINEAR), + ); + }); + + test('Vec2 curves', () => { + const clip = new AnimationClip(); + clip.keys = [[0.0, 0.2, 0.8]]; + clip.curves = [{ + modifiers: ['p'], + data: { + keys: 0, + values: [ + new math.Vec2(1.0, 4.0), + new math.Vec2(2.0, 5.0), + new math.Vec2(3.0, 6.0), + ], + }, + }]; + clip.syncLegacyData(); + expect(clip.tracksCount).toBe(1); + const track = clip.getTrack(0) as animation.VectorTrack; + expect(track).toBeInstanceOf(animation.VectorTrack); + expect(track.componentsCount).toBe(2); + const [{ curve: x }, { curve: y }] = track.getChannels(); + expect(Array.from(x.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(x.values())).toStrictEqual( + createRealKeyframesWithoutTangent([1.0, 2.0, 3.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(y.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(y.values())).toStrictEqual( + createRealKeyframesWithoutTangent([4.0, 5.0, 6.0], RealInterpMethod.LINEAR), + ); + + }); + + test('Vec3 curves', () => { + const clip = new AnimationClip(); + clip.keys = [[0.0, 0.2, 0.8]]; + clip.curves = [{ + modifiers: ['p'], + data: { + keys: 0, + values: [ + new math.Vec3(1.0, 4.0, 7.0), + new math.Vec3(2.0, 5.0, 8.0), + new math.Vec3(3.0, 6.0, 9.0), + ], + }, + }]; + clip.syncLegacyData(); + expect(clip.tracksCount).toBe(1); + const track = clip.getTrack(0) as animation.VectorTrack; + expect(track).toBeInstanceOf(animation.VectorTrack); + expect(track.componentsCount).toBe(3); + const [{ curve: x }, { curve: y }, { curve: z }] = track.getChannels(); + expect(Array.from(x.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(x.values())).toStrictEqual( + createRealKeyframesWithoutTangent([1.0, 2.0, 3.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(y.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(y.values())).toStrictEqual( + createRealKeyframesWithoutTangent([4.0, 5.0, 6.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(z.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(z.values())).toStrictEqual( + createRealKeyframesWithoutTangent([7.0, 8.0, 9.0], RealInterpMethod.LINEAR), + ); + }); + + test('Vec4 curves', () => { + const clip = new AnimationClip(); + clip.keys = [[0.0, 0.2, 0.8]]; + clip.curves = [{ + modifiers: ['p'], + data: { + keys: 0, + values: [ + new math.Vec4(1.0, 4.0, 7.0, 10.0), + new math.Vec4(2.0, 5.0, 8.0, 11.0), + new math.Vec4(3.0, 6.0, 9.0, 12.0), + ], + }, + }]; + clip.syncLegacyData(); + expect(clip.tracksCount).toBe(1); + const track = clip.getTrack(0) as animation.VectorTrack; + expect(track).toBeInstanceOf(animation.VectorTrack); + expect(track.componentsCount).toBe(4); + const [{ curve: x }, { curve: y }, { curve: z }, { curve: w }] = track.getChannels(); + expect(Array.from(x.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(x.values())).toStrictEqual( + createRealKeyframesWithoutTangent([1.0, 2.0, 3.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(y.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(y.values())).toStrictEqual( + createRealKeyframesWithoutTangent([4.0, 5.0, 6.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(z.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(z.values())).toStrictEqual( + createRealKeyframesWithoutTangent([7.0, 8.0, 9.0], RealInterpMethod.LINEAR), + ); + expect(Array.from(w.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(w.values())).toStrictEqual( + createRealKeyframesWithoutTangent([10.0, 11.0, 12.0], RealInterpMethod.LINEAR), + ); + }); + + test('Color curves', () => { + const clip = new AnimationClip(); + clip.keys = [[0.0, 0.2, 0.8]]; + clip.curves = [{ + modifiers: ['p'], + data: { + keys: 0, + values: [ + new math.Color(10, 40, 70, 100), + new math.Color(20, 50, 80, 110), + new math.Color(30, 60, 90, 120), + ], + }, + }]; + clip.syncLegacyData(); + expect(clip.tracksCount).toBe(1); + const track = clip.getTrack(0) as animation.ColorTrack; + expect(track).toBeInstanceOf(animation.ColorTrack); + const [{ curve: r }, { curve: g }, { curve: b }, { curve: a }] = track.getChannels(); + expect(Array.from(r.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(r.values())).toStrictEqual( + createRealKeyframesWithoutTangent([10, 20, 30], RealInterpMethod.LINEAR), + ); + expect(Array.from(g.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(g.values())).toStrictEqual( + createRealKeyframesWithoutTangent([40, 50, 60], RealInterpMethod.LINEAR), + ); + expect(Array.from(b.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(b.values())).toStrictEqual( + createRealKeyframesWithoutTangent([70, 80, 90], RealInterpMethod.LINEAR), + ); + expect(Array.from(a.times())).toStrictEqual([0.0, 0.2, 0.8]); + expect(Array.from(a.values())).toStrictEqual( + createRealKeyframesWithoutTangent([100, 110, 120], RealInterpMethod.LINEAR), + ); + }); + + test('Common target: Color', () => { + + }); + + test('Common target: Color with components as floats', () => { + + }); +}); + +function createRealKeyframesWithoutTangent (values: number[], interpMethod: RealInterpMethod): RealKeyframeValue[] { + return values.map((value) => { + return { + value, + interpMethod, + startTangent: 0, + endTangent: 0, + }; + }); +} \ No newline at end of file diff --git a/tests/curves/curve.test.ts b/tests/curves/curve.test.ts new file mode 100644 index 00000000000..0b7b2f1d1ab --- /dev/null +++ b/tests/curves/curve.test.ts @@ -0,0 +1,8 @@ +import { AnimationCurve } from '../../cocos/core/curves'; + +describe('Curve', () => { + test('Evaluate an empty curve', () => { + const curve = new AnimationCurve(); + expect(curve.evaluate(12.34)).toBe(0.0); + }); +}); \ No newline at end of file