diff --git a/examples/tests/confetti.ts b/examples/tests/confetti.ts index f131dcdf..b5d9d0ab 100644 --- a/examples/tests/confetti.ts +++ b/examples/tests/confetti.ts @@ -12,8 +12,6 @@ const SHAPE_3 = const SHAPES = [SHAPE_1, SHAPE_2, SHAPE_3]; const COLORS = [0x3c91efff, 0x847effff, 0x00a75eff, 0xf1604bff, 0xc4defaff]; -const POOL_SIZE = 160; -const BURST_COUNT = 30; const Y_START = -40; const Y_END = 1600; @@ -23,7 +21,13 @@ type Particle = { animations: IAnimationController[]; launch(isBurst: boolean, side: 'left' | 'right' | null, startY: number): void; }; -export default async function ({ renderer, testRoot }: ExampleSettings) { +export default async function ({ + renderer, + testRoot, + perfMultiplier, +}: ExampleSettings) { + const POOL_SIZE = 160 * perfMultiplier; + const BURST_COUNT = 30 * perfMultiplier; function createParticle( isBurst: boolean, side: 'left' | 'right' | null, @@ -111,12 +115,13 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { const onStopped = () => { animateY.off('stopped', onStopped); this.launch(false, null, Y_START); - this.animations = []; + this.animations.length = 0; }; animateY.on('stopped', onStopped); - this.animations = [animateY, animateX /*, animateRot*/]; + this.animations.length = 0; + this.animations.push(animateY, animateX /*, animateRot*/); }, }; setTimeout(() => { diff --git a/examples/tests/confetti2.ts b/examples/tests/confetti2.ts new file mode 100644 index 00000000..a348adb0 --- /dev/null +++ b/examples/tests/confetti2.ts @@ -0,0 +1,152 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Confetti 2 — continuous animation spawn stress test + * + * Unlike confetti.ts which recycles a fixed pool of nodes with the same + * animations, this test keeps nodes alive but continuously creates fresh + * animations on them. Each cycle: stop existing animations, spawn 3 new + * ones on the same node, repeat when done. + * + * This isolates the animation spawn/recycle cost specifically: + * - No node create/destroy overhead + * - Constant createAnimation() churn even with a warm pool + * - 3 concurrent animations per node (Y/X/rotation) + * - Each animation cycle is independent: fresh props, fresh controllers + * + * Use ?multiplier=N to scale the number of nodes (50 * N). + */ + +import type { IAnimationController } from '../../dist/exports/index.js'; +import type { INode } from '../../dist/src/main-api/INode.js'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +const SHAPE_1 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEjSURBVHgBnZO9TgJBFIXvbEllQqsJNrTaSmVhqdHKViyplCewsTXhDcAnkBcwUkm52tqwJrYmVLTDOXAWJsPfhJN8e/fuzD2TmblrFsl7fw66YOSXyvWtZpuEwQPw5nfrKaxzZTFCDrjCBLyDIfjXvDo4Aw3lPefcfWjQRWiq4CUojEWjFqiANkw6jntG8qGVn7cUlzqVyRgcZ3jcaWCYUEx9gR/AbTczOVKflq5c8SQ0+LN0lXNrWfCxYnuIBoXeDy1d5dxvGrwqaVi6LhQHNOgpYaPUE4qvQBUU6IN+hkeBpK3Blow26RZc6n3ZiRQaqoPwoJT3zKviafNwj2Rc1fisC1fsYfIY/YWxRurchdwaE3bYDbi2ebexZX9BH6sO4vlTUbSnqsTgwTgAAAAASUVORK5CYII='; +const SHAPE_2 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAABOSURBVHgB7ZKhDQAgDAQfwiA4loEhUKzFZoxSqKtAtFUILmnST/7cByJaADJs9OiQmJxEGAqhnmv8RDj54lOiHEBV9MtNbDDAYod9r3MDaHkHotN0PbEAAAAASUVORK5CYII='; +const SHAPE_3 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAATCAYAAACQjC21AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAFcSURBVHgBrVQtTwNBEJ1tMKCa8AOoqj6LK6BJjl8AFleCwsE/AEGQdx5RgYXUYTnbmjsSJKQ1xW7f5N4m28u2d/14ybuZ2515O9PerEgNrLVtMAf7sgtA6MqWmMguwOocerINVMAuYijbAAIJhb7AGf22bAIkdrzK7sB3+ver8gwCYtgIPOJah3R+Br6AXfCW6wXp/G/66R4eCbiqjQ/aMdmtHOqj0Ap1Y+gFpExU/AWSDsB98BA8k7I7xZMxpt/Co8DLidfCORNCYop/7kWe2I2KqWNcFP89bT9m0iv4uaTCaylbn1IsdZst52BxCl7AfWDSpYRxSrFCO/PFFgQ9ZLRjCcOtawFZdTMk2KMd0eqPry0e8/2HNmr0ket48QN+Bt+8CVH88iMfNZptXlUOvlBSuSRmTaYmdBnkrgoe9mjXuSz0RAZOlp3OGc9dXJ1gDA44PXWxevEOqutzNZRW6qN+HjoAAAAASUVORK5CYII='; + +const SHAPES = [SHAPE_1, SHAPE_2, SHAPE_3]; +const COLORS = [0x3c91efff, 0x847effff, 0x00a75eff, 0xf1604bff, 0xc4defaff]; + +const Y_START = -40; +const Y_END = 1600; +const NODES = 50; +const DURATION_MIN_MS = 2000; +const DURATION_MAX_MS = 4000; + +type Particle = { + node: INode; + animateY: IAnimationController | null; + animateX: IAnimationController | null; + animateRot: IAnimationController | null; + launch(): void; +}; + +export default async function ({ + renderer, + testRoot, + perfMultiplier, +}: ExampleSettings) { + const nodeCount = NODES * perfMultiplier; + + /** + * Create a persistent node and continuously re-spawn fresh animations on it. + * The node itself never changes parent or gets destroyed -- only the + * animations are created fresh each cycle, stressing createAnimation(). + */ + function createParticle(): Particle { + const size = 11 + Math.random() * 25; + + const node = renderer.createNode({ + x: Math.random() * 1920, + y: Y_START, + w: size, + h: size, + color: COLORS[Math.floor(Math.random() * COLORS.length)]!, + src: SHAPES[Math.floor(Math.random() * SHAPES.length)]!, + parent: testRoot, + }); + + const particle: Particle = { + node, + animateY: null, + animateX: null, + animateRot: null, + + launch() { + const n = this.node; + const durationMs = + DURATION_MIN_MS + + Math.floor(Math.random() * (DURATION_MAX_MS - DURATION_MIN_MS)); + + const startX = Math.random() * 1920; + const endX = startX + (Math.random() - 0.5) * 80; + const startRot = Math.random() * Math.PI * 2; + + // Reset node to start position for new cycle + n.x = startX; + n.y = Y_START; + n.rotation = startRot; + n.color = COLORS[Math.floor(Math.random() * COLORS.length)]!; + n.src = SHAPES[Math.floor(Math.random() * SHAPES.length)]!; + + // Create 3 brand-new animation controllers each cycle -- + // this is the key difference from confetti.ts which reuses + // the same controllers indefinitely via the relaunch pattern. + this.animateY = n.animate( + { y: Y_END }, + { duration: durationMs, easing: 'linear' }, + ); + this.animateX = n.animate( + { x: endX }, + { duration: durationMs, easing: 'cubic-bezier(0,0,0.4,1)' }, + ); + this.animateRot = n.animate( + { rotation: startRot + Math.random() * Math.PI * 4 }, + { duration: durationMs, easing: 'ease-out' }, + ); + + this.animateY.start(); + this.animateX.start(); + this.animateRot.start(); + + // When Y finishes, stop the siblings and immediately re-launch -- + // keeps the node fully animated at all times with fresh controllers + this.animateY.on('stopped', () => { + this.animateX!.stop(); + this.animateRot!.stop(); + this.launch(); + }); + }, + }; + + return particle; + } + + // Create all nodes upfront with a small stagger to spread the initial + // wave of animation starts across frames + for (let i = 0; i < nodeCount; i++) { + const particle = createParticle(); + setTimeout(() => particle.launch(), i * 10); + } +} diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index e489d813..528c0418 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -31,9 +31,9 @@ export class EventEmitter implements IEventEmitter { let listeners = this.eventListeners[event]; if (!listeners) { listeners = []; + this.eventListeners[event] = listeners; } listeners.push(listener); - this.eventListeners[event] = listeners; } off(event: string, listener?: EventListener): void { @@ -61,17 +61,43 @@ export class EventEmitter implements IEventEmitter { emit(event: string, data?: any): void { const listeners = this.eventListeners[event]; - if (!listeners) { + if (listeners === undefined || listeners.length === 0) { return; } - // Snapshot to handle listeners that remove themselves via once()/off() - const snapshot = listeners.slice(); - for (let i = 0, len = snapshot.length; i < len; i++) { - snapshot[i]!(this, data); + // Iterate backwards: safe when once()/off() splice() during emission since + // removals only shift elements at higher indices (already visited). + // Zero allocations vs the previous listeners.slice() snapshot approach. + for (let i = listeners.length - 1; i >= 0; i--) { + listeners[i]!(this, data); } } removeAllListeners() { - this.eventListeners = {}; + // Clear in place to avoid allocating a new {} object. + const listeners = this.eventListeners; + for (const key in listeners) { + delete listeners[key]; + } + } + + /** + * Clear all listeners for the given event names by setting their array + * length to 0, WITHOUT deleting the keys. This keeps the eventListeners + * object in V8's fast-properties mode and avoids re-allocating the arrays + * on the next on() call. Use this for objects with a known fixed set of + * event names (e.g. CoreAnimation, CoreAnimationController). + * + * Only writes arr.length = 0 when the array is non-empty -- skips the write + * (and the write barrier on old-gen objects) when already empty, which is + * the common case after unregisterAnimation() has removed all listeners. + */ + clearListeners(events: readonly string[]): void { + const map = this.eventListeners; + for (let i = 0; i < events.length; i++) { + const arr = map[events[i]!]; + if (arr !== undefined && arr.length > 0) { + arr.length = 0; + } + } } } diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 687154fd..525408bd 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -105,6 +105,11 @@ export class Stage { public preloadBound: Bound; public readonly defaultTexture: Texture | null = null; public pixelRatio: number; + // Pre-allocated frameTick payload -- reused every frame to avoid per-frame {} allocation + private readonly frameTickPayload: { time: number; delta: number } = { + time: 0, + delta: 0, + }; public readonly bufferMemory: number = 2e6; public readonly platform: Platform | WebPlatform; public readonly calculateTextureCoord: boolean; @@ -399,10 +404,9 @@ export class Stage { // This event is emitted at the beginning of the frame (before any updates // or rendering), so no need to to use `stage.queueFrameEvent` here. - this.eventBus.emit('frameTick', { - time: this.currentFrameTime, - delta: this.deltaTime, - }); + this.frameTickPayload.time = this.currentFrameTime; + this.frameTickPayload.delta = this.deltaTime; + this.eventBus.emit('frameTick', this.frameTickPayload); } /** diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index fbf20d91..a843ac12 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -38,21 +38,37 @@ export class AnimationManager { private controllerPool: CoreAnimationController[] = []; registerAnimation(animation: CoreAnimation) { + animation.activeIndex = this.activeAnimations.length; this.activeAnimations.push(animation); } unregisterAnimation(animation: CoreAnimation) { + const index = animation.activeIndex; + if (index === -1) { + return; + } const animations = this.activeAnimations; - const index = animations.indexOf(animation); - if (index >= 0) { - animations.splice(index, 1); + const last = animations.length - 1; + if (index !== last) { + // Swap with the last element and update its index + const swap = animations[last]!; + animations[index] = swap; + swap.activeIndex = index; } + animations.pop(); + animation.activeIndex = -1; } update(dt: number) { const animations = this.activeAnimations; - for (let i = 0, len = animations.length; i < len; i++) { - animations[i]!.update(dt); + // Iterate backwards. With activeIndex tracking, if a sibling stop() during + // a completion event swap-removes an already-visited element into index i, + // we check activeIndex >= 0 before updating to avoid double-processing. + for (let i = animations.length - 1; i >= 0; i--) { + const anim = animations[i]!; + if (anim.activeIndex >= 0) { + anim.update(dt); + } } } @@ -96,9 +112,11 @@ export class AnimationManager { animation: CoreAnimation, controller: CoreAnimationController, ): void { - animation.removeAllListeners(); + // Do NOT clearListeners here -- init() clears lazily on next reuse. + // By the time releaseToPool() fires, unregisterAnimation() has already + // emptied all listener arrays via off(). Moving clearListeners to init() + // keeps this hot path (inside the rAF completion chain) as cheap as possible. this.animationPool.push(animation); - controller.removeAllListeners(); this.controllerPool.push(controller); } } diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index d24d8929..8c91bedb 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -36,6 +36,7 @@ type PropGroup = { starts: number[]; targets: number[]; isColor: boolean[]; + length: number; }; type PropValuesMap = { @@ -52,16 +53,54 @@ export class CoreAnimation extends EventEmitter { public loop!: boolean; public repeat!: number; public stopMethod!: 'reverse' | 'reset' | false; + // Cached at init() time -- avoids re-computation in the per-frame hot path + private hasEasing = false; + // Reciprocal of duration -- stored so update() multiplies instead of divides + private invDuration = 0; private progress = 0; private delayFor = 0; private delay = 0; private timingFunction!: TimingFunction; private node!: CoreNode; + // Index into AnimationManager.activeAnimations -- kept in sync on every + // register/swap-remove so unregisterAnimation() is O(1) with no indexOf scan. + public activeIndex = -1; + + // Persistent PropGroup instances -- reused across pool recycles to avoid + // allocating new arrays each time. length tracks how many entries are active. + private propsGroup: PropGroup = { + keys: [], + starts: [], + targets: [], + isColor: [], + length: 0, + }; + private shaderPropsGroup: PropGroup = { + keys: [], + starts: [], + targets: [], + isColor: [], + length: 0, + }; + + // Fixed set of event names this animation emits -- used for zero-alloc clearListeners() + static readonly EVENTS = [ + 'finished', + 'animating', + 'tick', + 'destroyed', + ] as const; propValuesMap: PropValuesMap = { props: null, shaderProps: null }; constructor() { super(); + // Pre-allocate listener arrays for the known events so on() never needs to + // allocate a new [] when the animation is registered after a pool recycle. + this.eventListeners['finished'] = []; + this.eventListeners['animating'] = []; + this.eventListeners['tick'] = []; + this.eventListeners['destroyed'] = []; } /** @@ -76,55 +115,55 @@ export class CoreAnimation extends EventEmitter { this.id = ++animationIdCounter; this.node = node; this.progress = 0; + this.activeIndex = -1; this.propValuesMap.props = null; this.propValuesMap.shaderProps = null; - this.removeAllListeners(); + // Clear any stale listeners from the previous use. With the arr.length > 0 + // guard in clearListeners(), this is a near-zero cost read of 4 array lengths + // when unregisterAnimation() has already emptied them (the common case). + this.clearListeners(CoreAnimation.EVENTS); + + // Reset persistent group lengths (reuse existing arrays, no new allocations) + this.propsGroup.length = 0; + this.shaderPropsGroup.length = 0; for (const key in props) { if (key !== 'shaderProps') { if (this.propValuesMap['props'] === null) { - this.propValuesMap['props'] = { - keys: [], - starts: [], - targets: [], - isColor: [], - }; + this.propValuesMap['props'] = this.propsGroup; } - const group = this.propValuesMap['props']!; - group.keys.push(key); - group.starts.push( - node[key as keyof Omit] || 0, - ); - group.targets.push( - props[ - key as keyof Omit - ] as number, - ); - group.isColor.push(key.indexOf('color') !== -1); + const group = this.propsGroup; + const i = group.length++; + group.keys[i] = key; + group.starts[i] = + node[key as keyof Omit] || 0; + group.targets[i] = props[ + key as keyof Omit + ] as number; + group.isColor[i] = key.indexOf('color') !== -1; } else if (key === 'shaderProps' && node.shader !== null) { - this.propValuesMap['shaderProps'] = { - keys: [], - starts: [], - targets: [], - isColor: [], - }; - const group = this.propValuesMap['shaderProps']!; + this.propValuesMap['shaderProps'] = this.shaderPropsGroup; + const group = this.shaderPropsGroup; for (const key in props.shaderProps) { let start = node.shader.props![key]; if (Array.isArray(start) === true) { start = start[0]; } - group.keys.push(key); - group.starts.push(start); - group.targets.push(props.shaderProps[key] as number); - group.isColor.push(key.indexOf('color') !== -1); + const i = group.length++; + group.keys[i] = key; + group.starts[i] = start; + group.targets[i] = props.shaderProps[key] as number; + group.isColor[i] = key.indexOf('color') !== -1; } } } const easing = settings.easing || 'linear'; const delay = settings.delay ?? 0; - this.duration = settings.duration ?? 0; + const duration = settings.duration ?? 0; + this.duration = duration; + // Pre-compute reciprocal to replace per-frame division with multiplication + this.invDuration = duration > 0 ? 1 / duration : 0; this.delay = delay; this.easing = easing; this.loop = settings.loop ?? false; @@ -132,44 +171,51 @@ export class CoreAnimation extends EventEmitter { this.stopMethod = settings.stopMethod ?? false; this.timingFunction = typeof easing === 'string' ? getTimingFunction(easing) : easing; + // Explicit bool -- avoids string comparison on every updateValue() call + this.hasEasing = easing !== 'linear'; this.delayFor = delay; } reset() { this.progress = 0; this.delayFor = this.delay || 0; - this.update(0); + // Write start values directly rather than calling update(0), which would + // run the full update pipeline (dirty marking, event emissions) for no gain. + // Identical visible behaviour -- node properties snap to start values. + const propsGroup = this.propValuesMap.props; + const shaderGroup = this.propValuesMap.shaderProps; + if (propsGroup !== null) { + this.restoreValues( + this.node as unknown as Record, + propsGroup, + ); + } + if (shaderGroup !== null) { + this.restoreValues( + this.node.shader!.props as Record, + shaderGroup, + ); + } } private restoreValues(target: Record, group: PropGroup) { const keys = group.keys; const starts = group.starts; - const length = keys.length; + const length = group.length; for (let i = 0; i < length; i++) { target[keys[i]!] = starts[i]!; } } restore() { + // reset() already writes start values back to node properties this.reset(); - if (this.propValuesMap['props'] !== null) { - this.restoreValues( - this.node as unknown as Record, - this.propValuesMap['props'], - ); - } - if (this.propValuesMap['shaderProps'] !== null) { - this.restoreValues( - this.node.shader!.props as Record, - this.propValuesMap['shaderProps'], - ); - } } private reverseValues(group: PropGroup) { const starts = group.starts; const targets = group.targets; - const length = starts.length; + const length = group.length; for (let i = 0; i < length; i++) { const tmp = starts[i]!; starts[i] = targets[i]!; @@ -188,80 +234,80 @@ export class CoreAnimation extends EventEmitter { } // restore stop method if we are not looping - if (!this.loop) { + if (this.loop === false) { this.stopMethod = false; } } - private applyEasing(p: number, s: number, e: number): number { - return this.timingFunction(p) * (e - s) + s; - } - + /** + * Interpolate a single property value given the current progress. + * progress is passed as a parameter so callers can cache it in a local, + * avoiding repeated this.progress reads (which box floats in V8). + */ updateValue( isColor: boolean, propValue: number, startValue: number, - easing: string | TimingFunction | undefined, + progress: number, ): number { - if (this.progress === 1) { + if (progress === 1) { return propValue; } - if (this.progress === 0) { + if (progress === 0) { return startValue; } - const endValue = propValue; if (isColor === true) { - if (startValue === endValue) { + if (startValue === propValue) { return startValue; } - - if (easing) { - const easingProgressValue = - this.timingFunction(this.progress) || this.progress; - return mergeColorProgress(startValue, endValue, easingProgressValue); + if (this.hasEasing === true) { + const p = this.timingFunction(progress) || progress; + return mergeColorProgress(startValue, propValue, p); } - return mergeColorProgress(startValue, endValue, this.progress); + return mergeColorProgress(startValue, propValue, progress); } - if (easing) { - return this.applyEasing(this.progress, startValue, endValue); + if (this.hasEasing === true) { + // Inlined applyEasing: this.timingFunction(p) * (e - s) + s + return ( + this.timingFunction(progress) * (propValue - startValue) + startValue + ); } - return startValue + (endValue - startValue) * this.progress; + return startValue + (propValue - startValue) * progress; } private updateValues( target: Record, group: PropGroup, - easing: string | TimingFunction | undefined, + progress: number, ) { const keys = group.keys; const starts = group.starts; const targets = group.targets; const isColor = group.isColor; - const length = keys.length; + const length = group.length; for (let i = 0; i < length; i++) { target[keys[i]!] = this.updateValue( isColor[i]!, targets[i]!, starts[i]!, - easing, + progress, ); } } update(dt: number) { - const { duration, loop, easing, stopMethod } = this; + const { duration, loop, stopMethod } = this; const { delayFor } = this; if (this.node.destroyed) { - // cleanup - this.emit('destroyed', {}); + this.emit('destroyed'); return; } if (duration === 0 && delayFor === 0) { - this.emit('finished', {}); + this.emit('finished'); return; } @@ -279,51 +325,59 @@ export class CoreAnimation extends EventEmitter { } if (duration === 0) { - // No duration, we are done. - this.emit('finished', {}); + this.emit('finished'); return; } - if (this.progress === 0) { - // Progress is 0, we are starting the post-delay part of the animation. - this.emit('animating', {}); + // Read progress once into a local -- avoids repeated this.progress reads + // which cause V8 to box the float value on each access to the object property. + let progress = this.progress; + + if (progress === 0) { + this.emit('animating'); } - this.progress += dt / duration; + // Multiply by pre-computed reciprocal -- avoids per-frame float division + progress += dt * this.invDuration; - if (this.progress > 1) { - this.progress = loop ? 0 : 1; + if (progress > 1) { + progress = loop === true ? 0 : 1; this.delayFor = this.delay; - if (stopMethod) { - // If there's a stop method emit finished so the stop method can be applied. - // TODO: We should probably reevaluate how stopMethod is implemented as currently - // stop method 'reset' does not work when looping. - this.emit('finished', {}); + if (stopMethod !== false) { + this.progress = progress; + this.emit('finished'); return; } } - if (this.propValuesMap['props'] !== null) { + // Write back once + this.progress = progress; + + // Extract to locals to avoid repeated property lookups in the hot path + const propsGroup = this.propValuesMap.props; + const shaderGroup = this.propValuesMap.shaderProps; + + if (propsGroup !== null) { this.updateValues( this.node as unknown as Record, - this.propValuesMap['props'], - easing, + propsGroup, + progress, ); } - if (this.propValuesMap['shaderProps'] !== null) { + if (shaderGroup !== null) { this.updateValues( this.node.shader!.props as Record, - this.propValuesMap['shaderProps'], - easing, + shaderGroup, + progress, ); } - if (this.progress < 1) { + if (progress < 1) { this.emit('tick'); } - if (this.progress === 1) { - this.emit('finished', {}); + if (progress === 1) { + this.emit('finished'); } } } diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index e5669e40..2c09607e 100644 --- a/src/core/animations/CoreAnimationController.ts +++ b/src/core/animations/CoreAnimationController.ts @@ -38,9 +38,19 @@ export class CoreAnimationController private manager!: AnimationManager; private animation!: CoreAnimation; + // Pre-allocated tick payload -- reused every frame to avoid per-frame {} allocation + private readonly tickPayload: { progress: number } = { progress: 0 }; + + // Fixed set of event names this controller emits -- used for zero-alloc clearListeners() + static readonly EVENTS = ['stopped', 'animating'] as const; + constructor() { super(); this.state = 'stopped'; + // Pre-allocate listener arrays for the known events so on() never needs to + // allocate a new [] when the controller is reused after a pool recycle. + this.eventListeners['stopped'] = []; + this.eventListeners['animating'] = []; } /** @@ -53,7 +63,9 @@ export class CoreAnimationController this.state = 'stopped'; this.stoppedPromise = null; this.stoppedResolve = null; - this.removeAllListeners(); + // Clear any stale user listeners from the previous use. Near-zero cost + // when arrays are already empty (the common case after releaseToPool). + this.clearListeners(CoreAnimationController.EVENTS); } start(): IAnimationController { @@ -70,18 +82,26 @@ export class CoreAnimationController return this; } this.unregisterAnimation(); + + // Capture refs before emit -- the user's stopped callback may synchronously + // call createAnimation() which recycles these objects from the pool. + // Releasing AFTER emit with captured refs prevents corrupting the recycled objects. + const animation = this.animation; + const manager = this.manager; + if (this.stoppedResolve !== null) { this.stoppedResolve(); this.stoppedResolve = null; } + + this.state = 'stopped'; this.emit('stopped', this); + if (reset === true) { - this.animation.reset(); + animation.reset(); } - this.state = 'stopped'; - // Release to pool after all user listeners have been notified - this.manager.releaseToPool(this.animation, this); + manager.releaseToPool(animation, this); return this; } @@ -136,18 +156,22 @@ export class CoreAnimationController private onDestroy = (): void => { this.unregisterAnimation(); + + // Capture refs before emit -- same race condition guard as stop()/onFinished() + const animation = this.animation; + const manager = this.manager; + if (this.stoppedResolve !== null) { this.stoppedResolve(); this.stoppedResolve = null; } - this.emit('stopped', this); + this.state = 'stopped'; - // Release to pool after all user listeners have been notified - this.manager.releaseToPool(this.animation, this); + this.emit('stopped', this); + manager.releaseToPool(animation, this); }; private onFinished = (): void => { - // If the animation is looping, then we need to restart it. const { loop, stopMethod } = this.animation; if (stopMethod === 'reverse') { @@ -159,19 +183,22 @@ export class CoreAnimationController return; } - // unregister animation this.unregisterAnimation(); - // resolve promise + // Capture refs before emit -- the user's stopped callback may synchronously + // call createAnimation() which recycles these objects from the pool. + // Releasing AFTER emit with captured refs prevents corrupting the recycled objects. + const animation = this.animation; + const manager = this.manager; + if (this.stoppedResolve !== null) { this.stoppedResolve(); this.stoppedResolve = null; } - this.emit('stopped', this); this.state = 'stopped'; - // Release to pool after all user listeners have been notified - this.manager.releaseToPool(this.animation, this); + this.emit('stopped', this); + manager.releaseToPool(animation, this); }; private onAnimating = (): void => { @@ -185,13 +212,13 @@ export class CoreAnimationController */ private onTick = (): void => { const listeners = this.eventListeners['tick']; - if (listeners === undefined) { + if (listeners === undefined || listeners.length === 0) { return; } - listeners.forEach((listener) => { - listener(this, { - progress: this.animation['progress'], - }); - }); + // Mutate pre-allocated payload to avoid per-frame {} allocation + this.tickPayload.progress = this.animation['progress']; + for (let i = listeners.length - 1; i >= 0; i--) { + listeners[i]!(this, this.tickPayload); + } }; } diff --git a/src/core/utils.ts b/src/core/utils.ts index 246eb5a5..7b1e71b8 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -90,7 +90,7 @@ const getTimingBezier = ( // Cubic bezier derivative. cbxd = t * (t * (3 * xa) + 2 * xb) + xc; - if (cbxd > 1e-10 && cbxd < 1e-10) { + if (cbxd > -1e-10 && cbxd < 1e-10) { // Problematic. Fall back to binary search method. break; } diff --git a/src/utils.ts b/src/utils.ts index 2d4a9ac7..c85c0a40 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -66,15 +66,16 @@ export function mergeColorProgress( rgba2: number, p: number, ): number { - const r1 = Math.trunc(rgba1 >>> 24); - const g1 = Math.trunc((rgba1 >>> 16) & 0xff); - const b1 = Math.trunc((rgba1 >>> 8) & 0xff); - const a1 = Math.trunc(rgba1 & 0xff); - - const r2 = Math.trunc(rgba2 >>> 24); - const g2 = Math.trunc((rgba2 >>> 16) & 0xff); - const b2 = Math.trunc((rgba2 >>> 8) & 0xff); - const a2 = Math.trunc(rgba2 & 0xff); + // >>> and & 0xff already produce unsigned integers -- Math.trunc is redundant + const r1 = rgba1 >>> 24; + const g1 = (rgba1 >>> 16) & 0xff; + const b1 = (rgba1 >>> 8) & 0xff; + const a1 = rgba1 & 0xff; + + const r2 = rgba2 >>> 24; + const g2 = (rgba2 >>> 16) & 0xff; + const b2 = (rgba2 >>> 8) & 0xff; + const a2 = rgba2 & 0xff; const r = Math.round(r2 * p + r1 * (1 - p)); const g = Math.round(g2 * p + g1 * (1 - p));