From e0c17c3032151fc79a1a83e1edbbcde0eab56e6b Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 12:31:08 +0200 Subject: [PATCH 01/10] fix: snapshot activeAnimations in update() to prevent stalls from mid-iteration splice AnimationManager.update() iterated activeAnimations with a cached length, but animations that finish during iteration call unregisterAnimation() which splices the array. With the cached length, subsequent indices read undefined or skip animations, causing animation-heavy scenarios like confetti to stall. Fix: snapshot the array with slice() before iterating, matching the same pattern used in EventEmitter.emit(). --- src/core/animations/AnimationManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index fbf20d91..79c17d15 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -51,8 +51,11 @@ export class AnimationManager { update(dt: number) { const animations = this.activeAnimations; - for (let i = 0, len = animations.length; i < len; i++) { - animations[i]!.update(dt); + // Snapshot to handle animations that unregister during iteration + // (e.g., when an animation finishes and calls unregisterAnimation/splice) + const snapshot = animations.slice(); + for (let i = 0, len = snapshot.length; i < len; i++) { + snapshot[i]!.update(dt); } } From 624ddc1a94a7921ccb9373669ca40f29191bd8af Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 14:50:19 +0200 Subject: [PATCH 02/10] feat: add perfMultiplier support to confetti test Scale POOL_SIZE and BURST_COUNT by perfMultiplier so the test can be stressed further on high-end hardware via ?multiplier=N in the URL. Default (multiplier=1) is unchanged: 160 pool + 30 burst particles. --- examples/tests/confetti.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/tests/confetti.ts b/examples/tests/confetti.ts index f131dcdf..a3b581e7 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, From 5a9b80b38f12aa59b20d6899c085ca75dd13b9e1 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:00:12 +0200 Subject: [PATCH 03/10] fix: prevent pool corruption and remove per-frame allocations Three fixes bundled together: 1. Pool corruption (the activeAnimations infinite growth bug) CoreAnimationController.onFinished/stop/onDestroy now capture local refs to animation and manager before emitting 'stopped'. Previously, the user's stopped callback could synchronously call createAnimation() which pops the just-finished animation from the pool and re-inits it, then releaseToPool() would immediately clear its listeners, corrupting the newly-started animation. Result was activeAnimations growing unboundedly (starts at 14fps, bottoms out to 1fps). 2. EventEmitter.emit() - backwards for loop replaces slice() snapshot listeners.slice() allocated a new array on every emit() call. With 380 concurrent animations each emitting 'tick' every frame, this created ~22,800 short-lived arrays per second. A backwards for loop is equally safe against once()/off() splice() mutations (removals shift elements at higher indices, already visited) with zero allocations. 3. AnimationManager.update() + unregisterAnimation() Backwards for loop replaces the animations.slice() snapshot (one 380-element copy per frame). Swap-remove restored in unregisterAnimation() for O(1) removal. A swap-remove at index i only affects elements at index >= i, which a backwards loop has already visited, so no element is ever skipped. --- src/common/EventEmitter.ts | 9 ++-- src/core/animations/AnimationManager.ts | 14 ++++--- .../animations/CoreAnimationController.ts | 41 +++++++++++++------ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index e489d813..b1a63b9d 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -64,10 +64,11 @@ export class EventEmitter implements IEventEmitter { if (!listeners) { 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); } } diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index 79c17d15..37ad3eef 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -45,17 +45,19 @@ export class AnimationManager { const animations = this.activeAnimations; const index = animations.indexOf(animation); if (index >= 0) { - animations.splice(index, 1); + // Swap-remove: O(1), order doesn't matter for the update loop + animations[index] = animations[animations.length - 1]!; + animations.pop(); } } update(dt: number) { const animations = this.activeAnimations; - // Snapshot to handle animations that unregister during iteration - // (e.g., when an animation finishes and calls unregisterAnimation/splice) - const snapshot = animations.slice(); - for (let i = 0, len = snapshot.length; i < len; i++) { - snapshot[i]!.update(dt); + // Iterate backwards: safe when animation.update() finishes and triggers + // unregisterAnimation() (swap-remove). A swap-remove at index i only + // affects elements at index >= i, which a backwards loop has already visited. + for (let i = animations.length - 1; i >= 0; i--) { + animations[i]!.update(dt); } } diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index e5669e40..8ff13ad2 100644 --- a/src/core/animations/CoreAnimationController.ts +++ b/src/core/animations/CoreAnimationController.ts @@ -70,18 +70,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 +144,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 +171,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 => { From 467a667bcfdde82212c664ee75dcdf84b64f7595 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:11:41 +0200 Subject: [PATCH 04/10] perf: eliminate per-frame and per-recycle heap allocations Six targeted fixes to eliminate the allocation pressure identified in heap profiling (85k arrays, 85k Arrays, 104k numbers created/deleted): 1. CoreAnimation: PropGroup arrays reused across pool recycles Instead of allocating 4 new arrays + 1 PropGroup object on every animation recycle, we now keep persistent PropGroup instances on the CoreAnimation and clear/refill them via a length counter. Eliminates ~5 object allocations per animation lifecycle. 2. CoreAnimation: emit() called without data payload All emit('finished',{}), emit('animating',{}), emit('destroyed',{}) calls now pass no data arg (undefined). The {} argument was a fresh object allocation on every state transition that listeners never used. 3. EventEmitter: removeAllListeners() clears in place Instead of this.eventListeners = {} (new object every call), we now delete keys from the existing object. Eliminates one {} per removeAllListeners() call, which fires twice per animation lifecycle (in init() and releaseToPool()). 4. utils.ts: Fix broken Newton-Raphson guard cbxd > 1e-10 && cbxd < 1e-10 is an impossible condition (always false) causing the near-zero derivative fallback to never trigger. Fixed to cbxd > -1e-10 && cbxd < 1e-10. Previously the solver always ran all 20 Newton iterations before the binary search, wasting ~190k FP ops/frame. 5. CoreAnimation: linear easing takes O(1) fast path easing && easing !== 'linear' guard prevents 'linear' (a truthy string) from calling the timingFunction closure when the inlined calculation startValue + (endValue - startValue) * progress is identical and cheaper. 6. Stage: pre-allocated frameTick payload Reuse a single { time, delta } object instead of allocating a new one every frame for the 'frameTick' event. --- src/common/EventEmitter.ts | 6 +- src/core/Stage.ts | 12 ++-- src/core/animations/CoreAnimation.ts | 89 ++++++++++++++++------------ src/core/utils.ts | 2 +- 4 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index b1a63b9d..557c058c 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -73,6 +73,10 @@ export class EventEmitter implements IEventEmitter { } removeAllListeners() { - this.eventListeners = {}; + // Clear in place to avoid allocating a new {} object. + const listeners = this.eventListeners; + for (const key in listeners) { + delete listeners[key]; + } } } 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/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index d24d8929..d2b7687c 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 = { @@ -58,6 +59,23 @@ export class CoreAnimation extends EventEmitter { private timingFunction!: TimingFunction; private node!: CoreNode; + // 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, + }; + propValuesMap: PropValuesMap = { props: null, shaderProps: null }; constructor() { @@ -80,44 +98,37 @@ export class CoreAnimation extends EventEmitter { this.propValuesMap.shaderProps = null; this.removeAllListeners(); + // 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; } } } @@ -144,7 +155,7 @@ export class CoreAnimation extends EventEmitter { 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]!; } @@ -169,7 +180,7 @@ export class CoreAnimation extends EventEmitter { 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]!; @@ -216,7 +227,7 @@ export class CoreAnimation extends EventEmitter { return startValue; } - if (easing) { + if (easing && easing !== 'linear') { const easingProgressValue = this.timingFunction(this.progress) || this.progress; return mergeColorProgress(startValue, endValue, easingProgressValue); @@ -224,7 +235,7 @@ export class CoreAnimation extends EventEmitter { return mergeColorProgress(startValue, endValue, this.progress); } - if (easing) { + if (easing && easing !== 'linear') { return this.applyEasing(this.progress, startValue, endValue); } return startValue + (endValue - startValue) * this.progress; @@ -239,7 +250,7 @@ export class CoreAnimation extends EventEmitter { 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]!, @@ -256,12 +267,12 @@ export class CoreAnimation extends EventEmitter { if (this.node.destroyed) { // cleanup - this.emit('destroyed', {}); + this.emit('destroyed'); return; } if (duration === 0 && delayFor === 0) { - this.emit('finished', {}); + this.emit('finished'); return; } @@ -280,13 +291,13 @@ 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', {}); + this.emit('animating'); } this.progress += dt / duration; @@ -298,7 +309,7 @@ export class CoreAnimation extends EventEmitter { // 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', {}); + this.emit('finished'); return; } } @@ -323,7 +334,7 @@ export class CoreAnimation extends EventEmitter { } if (this.progress === 1) { - this.emit('finished', {}); + this.emit('finished'); } } } 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; } From 98d561bfd5d85c1a7b71c14a421e1ea9b3586430 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:21:53 +0200 Subject: [PATCH 05/10] perf: zero-alloc hot path - pre-allocated listener arrays, isLinear flag, no Math.trunc Implements all remaining bottlenecks identified in heap profiling: 1. EventEmitter: add clearListeners(events) method Zeros known listener arrays in-place (arr.length=0) without deleting keys, keeping V8 in fast-properties mode and preserving the arrays for reuse. Prevents hidden-class demotion caused by 'delete'. 2. CoreAnimation + CoreAnimationController: pre-allocate listener arrays Constructor now pre-allocates [] for each known event name so that registerAnimation()'s on() calls never need to allocate new arrays. releaseToPool() now calls clearListeners() instead of removeAllListeners() to preserve those arrays across pool recycles. Eliminates 4 array allocations per animation start. 3. CoreAnimation: isLinear boolean flag Replaces the per-frame 'easing !== linear' string comparison in updateValue() with a boolean flag set once in init(). Also removes the now-unused easing parameter from updateValue/updateValues since all branching is via this.isLinear. 4. CoreAnimationController: pre-allocated tickPayload Reuses { progress: 0 } object across frames. Also switches onTick from forEach to a reverse for loop. 5. CoreAnimation.update(): extract propValuesMap to locals Avoids repeated bracket-string property lookups in the hot path. Also removes 'easing' from the update() destructure since it is no longer needed. 6. utils.ts mergeColorProgress: remove Math.trunc() >>> and & 0xff already produce unsigned integers. Math.trunc() was a no-op wrapping 8 redundant function calls per color blend. 7. confetti.ts: reuse animations array on relaunch this.animations.length=0 + push() instead of allocating new [] on every particle relaunch. --- examples/tests/confetti.ts | 5 +- src/common/EventEmitter.ts | 19 +++++- src/core/animations/AnimationManager.ts | 6 +- src/core/animations/CoreAnimation.ts | 61 +++++++++++-------- .../animations/CoreAnimationController.ts | 25 +++++--- src/utils.ts | 19 +++--- 6 files changed, 87 insertions(+), 48 deletions(-) diff --git a/examples/tests/confetti.ts b/examples/tests/confetti.ts index a3b581e7..b5d9d0ab 100644 --- a/examples/tests/confetti.ts +++ b/examples/tests/confetti.ts @@ -115,12 +115,13 @@ export default async function ({ 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/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index 557c058c..433cf93b 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 { @@ -79,4 +79,21 @@ export class EventEmitter implements IEventEmitter { 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). + */ + 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; + } + } + } } diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index 37ad3eef..61c553aa 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -101,9 +101,11 @@ export class AnimationManager { animation: CoreAnimation, controller: CoreAnimationController, ): void { - animation.removeAllListeners(); + // Clear in-place using known event names -- preserves pre-allocated listener + // arrays so the next registerAnimation() doesn't need to allocate new []. + animation.clearListeners(CoreAnimation.EVENTS); this.animationPool.push(animation); - controller.removeAllListeners(); + controller.clearListeners(CoreAnimationController.EVENTS); this.controllerPool.push(controller); } } diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index d2b7687c..6287c157 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -53,6 +53,8 @@ export class CoreAnimation extends EventEmitter { public loop!: boolean; public repeat!: number; public stopMethod!: 'reverse' | 'reset' | false; + // Cached at init() time -- avoids string comparison in the per-frame hot path + private isLinear = true; private progress = 0; private delayFor = 0; private delay = 0; @@ -76,10 +78,24 @@ export class CoreAnimation extends EventEmitter { 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'] = []; } /** @@ -96,7 +112,9 @@ export class CoreAnimation extends EventEmitter { this.progress = 0; this.propValuesMap.props = null; this.propValuesMap.shaderProps = null; - this.removeAllListeners(); + // Clear listener arrays in-place (zero alloc) -- keeps arrays alive so + // the next registerAnimation() on() calls don't need to allocate new []. + this.clearListeners(CoreAnimation.EVENTS); // Reset persistent group lengths (reuse existing arrays, no new allocations) this.propsGroup.length = 0; @@ -143,6 +161,7 @@ export class CoreAnimation extends EventEmitter { this.stopMethod = settings.stopMethod ?? false; this.timingFunction = typeof easing === 'string' ? getTimingFunction(easing) : easing; + this.isLinear = easing === 'linear'; this.delayFor = delay; } @@ -208,12 +227,7 @@ export class CoreAnimation extends EventEmitter { return this.timingFunction(p) * (e - s) + s; } - updateValue( - isColor: boolean, - propValue: number, - startValue: number, - easing: string | TimingFunction | undefined, - ): number { + updateValue(isColor: boolean, propValue: number, startValue: number): number { if (this.progress === 1) { return propValue; } @@ -227,7 +241,7 @@ export class CoreAnimation extends EventEmitter { return startValue; } - if (easing && easing !== 'linear') { + if (!this.isLinear) { const easingProgressValue = this.timingFunction(this.progress) || this.progress; return mergeColorProgress(startValue, endValue, easingProgressValue); @@ -235,34 +249,25 @@ export class CoreAnimation extends EventEmitter { return mergeColorProgress(startValue, endValue, this.progress); } - if (easing && easing !== 'linear') { + if (!this.isLinear) { return this.applyEasing(this.progress, startValue, endValue); } return startValue + (endValue - startValue) * this.progress; } - private updateValues( - target: Record, - group: PropGroup, - easing: string | TimingFunction | undefined, - ) { + private updateValues(target: Record, group: PropGroup) { const keys = group.keys; const starts = group.starts; const targets = group.targets; const isColor = group.isColor; const length = group.length; for (let i = 0; i < length; i++) { - target[keys[i]!] = this.updateValue( - isColor[i]!, - targets[i]!, - starts[i]!, - easing, - ); + target[keys[i]!] = this.updateValue(isColor[i]!, targets[i]!, starts[i]!); } } update(dt: number) { - const { duration, loop, easing, stopMethod } = this; + const { duration, loop, stopMethod } = this; const { delayFor } = this; if (this.node.destroyed) { @@ -314,18 +319,20 @@ export class CoreAnimation extends EventEmitter { } } - if (this.propValuesMap['props'] !== null) { + // Extract to locals to avoid repeated bracket-string 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, ); } - if (this.propValuesMap['shaderProps'] !== null) { + if (shaderGroup !== null) { this.updateValues( this.node.shader!.props as Record, - this.propValuesMap['shaderProps'], - easing, + shaderGroup, ); } diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index 8ff13ad2..5ecdf484 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,8 @@ export class CoreAnimationController this.state = 'stopped'; this.stoppedPromise = null; this.stoppedResolve = null; - this.removeAllListeners(); + // Clear in-place to preserve pre-allocated arrays (zero alloc) + this.clearListeners(CoreAnimationController.EVENTS); } start(): IAnimationController { @@ -200,13 +211,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/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)); From 9af0ac48afd1e67924465c0185b5836c30339fec Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:26:42 +0200 Subject: [PATCH 06/10] perf: eliminate float boxing, invDuration multiply, hasEasing direct checks 1. isLinear -> hasEasing with explicit === true / === false checks Renames the cached easing flag to 'hasEasing' (positive branch, no negation) and uses strict boolean comparison on all checks per the requirement of direct comparisons, no truthy/falsy. 2. invDuration: replace per-frame division with multiplication Store 1/duration in init() so update() does: progress += dt * this.invDuration instead of: progress += dt / duration Float division is ~5-20x slower than multiplication on modern CPUs. 3. Cache progress in local variable in update() Read this.progress once into a local 'progress' at the start of update(), do all arithmetic on the local, write back once at the end. Avoids repeated this.progress reads which cause V8 to unbox the HeapNumber on every property access -- the source of the -0.x float allocations visible in the heap profiler. 4. Pass progress as parameter to updateValue() / updateValues() The float stays on the stack (unboxed register) as it crosses the function boundary, rather than being re-read from the object heap. 5. Inline applyEasing Removes the private applyEasing() method. The one-liner timingFunction(p) * (e - s) + s is inlined directly in updateValue(), eliminating a function call per non-linear property per frame. 6. Explicit === comparisons throughout update() and updateValue() loop === true, stopMethod !== false, hasEasing === true, isColor === true -- all direct type-safe comparisons. --- src/core/animations/CoreAnimation.ts | 104 +++++++++++++++++---------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index 6287c157..a5f05921 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -53,8 +53,10 @@ export class CoreAnimation extends EventEmitter { public loop!: boolean; public repeat!: number; public stopMethod!: 'reverse' | 'reset' | false; - // Cached at init() time -- avoids string comparison in the per-frame hot path - private isLinear = true; + // 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; @@ -153,7 +155,10 @@ export class CoreAnimation extends EventEmitter { 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; @@ -161,7 +166,8 @@ export class CoreAnimation extends EventEmitter { this.stopMethod = settings.stopMethod ?? false; this.timingFunction = typeof easing === 'string' ? getTimingFunction(easing) : easing; - this.isLinear = easing === 'linear'; + // Explicit bool -- avoids string comparison on every updateValue() call + this.hasEasing = easing !== 'linear'; this.delayFor = delay; } @@ -218,51 +224,66 @@ 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; - } - - updateValue(isColor: boolean, propValue: number, startValue: number): number { - if (this.progress === 1) { + /** + * 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, + progress: number, + ): number { + 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 (!this.isLinear) { - 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 (!this.isLinear) { - 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) { + private updateValues( + target: Record, + group: PropGroup, + progress: number, + ) { const keys = group.keys; const starts = group.starts; const targets = group.targets; const isColor = group.isColor; const length = group.length; for (let i = 0; i < length; i++) { - target[keys[i]!] = this.updateValue(isColor[i]!, targets[i]!, starts[i]!); + target[keys[i]!] = this.updateValue( + isColor[i]!, + targets[i]!, + starts[i]!, + progress, + ); } } @@ -271,7 +292,6 @@ export class CoreAnimation extends EventEmitter { const { delayFor } = this; if (this.node.destroyed) { - // cleanup this.emit('destroyed'); return; } @@ -295,31 +315,35 @@ export class CoreAnimation extends EventEmitter { } if (duration === 0) { - // No duration, we are done. this.emit('finished'); return; } - if (this.progress === 0) { - // Progress is 0, we are starting the post-delay part of the animation. + // 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. + if (stopMethod !== false) { + this.progress = progress; this.emit('finished'); return; } } - // Extract to locals to avoid repeated bracket-string lookups in the hot path + // 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; @@ -327,20 +351,22 @@ export class CoreAnimation extends EventEmitter { this.updateValues( this.node as unknown as Record, propsGroup, + progress, ); } if (shaderGroup !== null) { this.updateValues( this.node.shader!.props as Record, shaderGroup, + progress, ); } - if (this.progress < 1) { + if (progress < 1) { this.emit('tick'); } - if (this.progress === 1) { + if (progress === 1) { this.emit('finished'); } } From 52edf55422e137b8ea1eb2f9e19508067e67f8d2 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:46:59 +0200 Subject: [PATCH 07/10] feat: add confetti2 continuous spawn stress test confetti2 differs from confetti in one key way: nodes are ephemeral. Each particle is a brand-new node that gets created, animated, and destroyed on completion -- nothing is reused between particles. This stresses the full animation lifecycle continuously: - Node create/destroy every cycle (no node pooling) - Animation controller + animation init on every spawn - 3 concurrent animations per particle (Y/X/rotation) - Constant pool churn even at steady state A new wave of WAVE_SIZE (50 * perfMultiplier) particles fires every 500ms, overlapping with the previous wave still in flight. Use ?multiplier=N to scale the load. Use this alongside confetti (which recycles nodes) to separately benchmark: - confetti: animation pool recycling throughput - confetti2: full spawn/destroy churn throughput --- examples/tests/confetti2.ts | 134 ++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 examples/tests/confetti2.ts diff --git a/examples/tests/confetti2.ts b/examples/tests/confetti2.ts new file mode 100644 index 00000000..4ea598f1 --- /dev/null +++ b/examples/tests/confetti2.ts @@ -0,0 +1,134 @@ +/* + * 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 spawn stress test + * + * Unlike confetti.ts which recycles a fixed set of nodes, this test + * continuously creates brand-new nodes, animates them, and destroys them + * on completion. This stresses the full animation lifecycle: + * + * - Node creation + destruction every cycle + * - Animation controller + animation init on every spawn + * - Pool warm-up and steady-state recycling under constant churn + * - No long-lived node state — every particle is ephemeral + * + * A new wave of WAVE_SIZE particles is fired every WAVE_INTERVAL_MS, + * overlapping with the previous wave still in flight. Use ?multiplier=N + * to scale the wave size. + */ + +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; +// How many new particles to spawn per wave +const WAVE_SIZE = 50; +// How often to fire a new wave (ms) -- overlaps with previous wave in flight +const WAVE_INTERVAL_MS = 500; +// Duration range for each particle (ms) +const DURATION_MIN_MS = 2000; +const DURATION_MAX_MS = 4000; + +export default async function ({ + renderer, + testRoot, + perfMultiplier, +}: ExampleSettings) { + const waveSize = WAVE_SIZE * perfMultiplier; + + /** + * Spawn a single ephemeral particle: create node, animate it falling, + * then destroy both the node and the controller on completion. + */ + function spawnParticle() { + const size = 11 + Math.random() * 25; + const startX = Math.random() * 1920; + const endX = startX + (Math.random() - 0.5) * 80; + const durationMs = + DURATION_MIN_MS + + Math.floor(Math.random() * (DURATION_MAX_MS - DURATION_MIN_MS)); + + const node = renderer.createNode({ + x: startX, + y: Y_START, + w: size, + h: size, + rotation: Math.random() * Math.PI * 2, + color: COLORS[Math.floor(Math.random() * COLORS.length)]!, + src: SHAPES[Math.floor(Math.random() * SHAPES.length)]!, + parent: testRoot, + }); + + // Animate Y -- linear fall + const animateY = node.animate( + { y: Y_END }, + { duration: durationMs, easing: 'linear' }, + ); + + // Animate X -- cubic-bezier drift + const animateX = node.animate( + { x: endX }, + { duration: durationMs, easing: 'cubic-bezier(0,0,0.4,1)' }, + ); + + // Animate rotation + const animateRot = node.animate( + { rotation: Math.random() * Math.PI * 4 }, + { duration: durationMs, easing: 'ease-out' }, + ); + + animateY.start(); + animateX.start(); + animateRot.start(); + + // When Y finishes: stop siblings and destroy node -- tests the full + // create-animate-destroy path continuously with no node reuse + animateY.on('stopped', () => { + animateX.stop(); + animateRot.stop(); + node.parent = null; + node.destroy(); + }); + } + + /** + * Fire a wave of particles, staggered by 1 frame each to avoid + * creating all nodes in the same frame. + */ + function fireWave() { + for (let i = 0; i < waveSize; i++) { + setTimeout(spawnParticle, i * (WAVE_INTERVAL_MS / waveSize)); + } + } + + // Fire the first wave immediately, then repeat on interval + fireWave(); + setInterval(fireWave, WAVE_INTERVAL_MS); +} From 19afe5ff6a712a5bbffe27ee38a02e79ea9a0d60 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 15:57:05 +0200 Subject: [PATCH 08/10] feat: add confetti2 - continuous animation spawn stress test on persistent nodes Nodes are created once and kept alive. Each cycle creates 3 brand-new animation controllers (Y/X/rotation), starts them, and immediately re-creates fresh ones on completion. This isolates the createAnimation() churn cost from node overhead. Key differences vs confetti: - Nodes: persistent (created once, never destroyed) - Animations: 3 per node (Y linear, X cubic-bezier, rotation ease-out) - Controllers: fresh every cycle via node.animate() -- stresses pool - Relaunch: immediate on Y stop, no rest period Scale: 50 * perfMultiplier nodes via ?multiplier=N --- examples/tests/confetti2.ts | 156 ++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 69 deletions(-) diff --git a/examples/tests/confetti2.ts b/examples/tests/confetti2.ts index 4ea598f1..a348adb0 100644 --- a/examples/tests/confetti2.ts +++ b/examples/tests/confetti2.ts @@ -18,22 +18,24 @@ */ /** - * Confetti 2 — continuous spawn stress test + * Confetti 2 — continuous animation spawn stress test * - * Unlike confetti.ts which recycles a fixed set of nodes, this test - * continuously creates brand-new nodes, animates them, and destroys them - * on completion. This stresses the full animation lifecycle: + * 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. * - * - Node creation + destruction every cycle - * - Animation controller + animation init on every spawn - * - Pool warm-up and steady-state recycling under constant churn - * - No long-lived node state — every particle is ephemeral + * 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 * - * A new wave of WAVE_SIZE particles is fired every WAVE_INTERVAL_MS, - * overlapping with the previous wave still in flight. Use ?multiplier=N - * to scale the wave size. + * 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 = @@ -48,87 +50,103 @@ const COLORS = [0x3c91efff, 0x847effff, 0x00a75eff, 0xf1604bff, 0xc4defaff]; const Y_START = -40; const Y_END = 1600; -// How many new particles to spawn per wave -const WAVE_SIZE = 50; -// How often to fire a new wave (ms) -- overlaps with previous wave in flight -const WAVE_INTERVAL_MS = 500; -// Duration range for each particle (ms) +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 waveSize = WAVE_SIZE * perfMultiplier; + const nodeCount = NODES * perfMultiplier; /** - * Spawn a single ephemeral particle: create node, animate it falling, - * then destroy both the node and the controller on completion. + * 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 spawnParticle() { + function createParticle(): Particle { const size = 11 + Math.random() * 25; - const startX = Math.random() * 1920; - const endX = startX + (Math.random() - 0.5) * 80; - const durationMs = - DURATION_MIN_MS + - Math.floor(Math.random() * (DURATION_MAX_MS - DURATION_MIN_MS)); const node = renderer.createNode({ - x: startX, + x: Math.random() * 1920, y: Y_START, w: size, h: size, - rotation: Math.random() * Math.PI * 2, color: COLORS[Math.floor(Math.random() * COLORS.length)]!, src: SHAPES[Math.floor(Math.random() * SHAPES.length)]!, parent: testRoot, }); - // Animate Y -- linear fall - const animateY = node.animate( - { y: Y_END }, - { duration: durationMs, easing: 'linear' }, - ); - - // Animate X -- cubic-bezier drift - const animateX = node.animate( - { x: endX }, - { duration: durationMs, easing: 'cubic-bezier(0,0,0.4,1)' }, - ); - - // Animate rotation - const animateRot = node.animate( - { rotation: Math.random() * Math.PI * 4 }, - { duration: durationMs, easing: 'ease-out' }, - ); - - animateY.start(); - animateX.start(); - animateRot.start(); - - // When Y finishes: stop siblings and destroy node -- tests the full - // create-animate-destroy path continuously with no node reuse - animateY.on('stopped', () => { - animateX.stop(); - animateRot.stop(); - node.parent = null; - node.destroy(); - }); - } + const particle: Particle = { + node, + animateY: null, + animateX: null, + animateRot: null, - /** - * Fire a wave of particles, staggered by 1 frame each to avoid - * creating all nodes in the same frame. - */ - function fireWave() { - for (let i = 0; i < waveSize; i++) { - setTimeout(spawnParticle, i * (WAVE_INTERVAL_MS / waveSize)); - } + 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; } - // Fire the first wave immediately, then repeat on interval - fireWave(); - setInterval(fireWave, WAVE_INTERVAL_MS); + // 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); + } } From daacd80aeb6622c05b41bb02f7e32169ae0c2ace Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 16:43:00 +0200 Subject: [PATCH 09/10] perf: O(1) unregisterAnimation via activeIndex, remove double clearListeners Two fixes targeting the confetti2 FPS regression (18 vs 23 FPS vs 3.1.1): 1. O(1) unregisterAnimation via activeIndex Add activeIndex: number to CoreAnimation, kept in sync on every registerAnimation() and swap-remove. unregisterAnimation() now reads animation.activeIndex directly instead of indexOf() scan. Restores O(1) removal matching v3.1.1's Map.delete(). registerAnimation(): sets animation.activeIndex = array.length before push unregisterAnimation(): uses activeIndex directly, updates swapped element's index, sets activeIndex = -1 on removal -- O(1) total update() checks anim.activeIndex >= 0 before calling update(dt), preventing double-processing when a sibling stop() during a completion callback swap-removes an already-visited element into the current slot. 2. Remove double clearListeners from init() releaseToPool() already calls clearListeners() on both animation and controller before returning them to the pool. init() was redundantly calling clearListeners() again on already-empty arrays. Removed from CoreAnimation.init() and CoreAnimationController.init(). Contract: objects arrive at init() already cleared by releaseToPool(). --- src/core/animations/AnimationManager.ts | 29 +++++++++++++------ src/core/animations/CoreAnimation.ts | 9 ++++-- .../animations/CoreAnimationController.ts | 4 +-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index 61c553aa..7bfcd800 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -38,26 +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) { - // Swap-remove: O(1), order doesn't matter for the update loop - animations[index] = animations[animations.length - 1]!; - animations.pop(); + 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; - // Iterate backwards: safe when animation.update() finishes and triggers - // unregisterAnimation() (swap-remove). A swap-remove at index i only - // affects elements at index >= i, which a backwards loop has already visited. + // 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--) { - animations[i]!.update(dt); + const anim = animations[i]!; + if (anim.activeIndex >= 0) { + anim.update(dt); + } } } diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index a5f05921..b18270eb 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -62,6 +62,9 @@ export class CoreAnimation extends EventEmitter { 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. @@ -112,11 +115,11 @@ 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; - // Clear listener arrays in-place (zero alloc) -- keeps arrays alive so - // the next registerAnimation() on() calls don't need to allocate new []. - this.clearListeners(CoreAnimation.EVENTS); + // NOTE: listener arrays are already cleared by releaseToPool() before + // this is called. No need to clearListeners() here again. // Reset persistent group lengths (reuse existing arrays, no new allocations) this.propsGroup.length = 0; diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index 5ecdf484..8f2f4133 100644 --- a/src/core/animations/CoreAnimationController.ts +++ b/src/core/animations/CoreAnimationController.ts @@ -63,8 +63,8 @@ export class CoreAnimationController this.state = 'stopped'; this.stoppedPromise = null; this.stoppedResolve = null; - // Clear in-place to preserve pre-allocated arrays (zero alloc) - this.clearListeners(CoreAnimationController.EVENTS); + // NOTE: listener arrays are already cleared by releaseToPool() before + // this is called. No need to clearListeners() here again. } start(): IAnimationController { From 542266b1ae509fa618aeb2e61816c7f68b81827d Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Fri, 10 Jul 2026 17:32:49 +0200 Subject: [PATCH 10/10] perf: reduce rAF completion path overhead - swap clearListeners, fix reset, emit guard Four changes to reduce synchronous work inside the rAF handler during animation completion, improving stability (P01/P05 FPS) under continuous animation churn (confetti2 pattern): 1. EventEmitter.emit(): early exit on listeners.length === 0 Pre-allocated empty arrays (stopped/animating/tick/destroyed) now short-circuit immediately without entering the loop -- single integer comparison instead of a full loop iteration with no-op. 2. EventEmitter.clearListeners(): arr.length > 0 guard Only writes arr.length = 0 when there is actually something to clear. In steady-state (after unregisterAnimation() empties arrays via off()), this is 4-6 integer reads and zero writes -- avoids write barriers on old-gen pooled objects. 3. CoreAnimation.reset(): remove update(0), inline restoreValues directly update(0) ran the full update pipeline (dirty marking, event emissions, updateValues) just to write start values back to node properties. reset() now writes start values directly via restoreValues(). restore() simplified to just call reset(). Identical visible behaviour. 4. Swap: clearListeners moved from releaseToPool() to init() releaseToPool() is now just 2 array pushes -- the minimum possible work in the hot rAF completion path. clearListeners() runs lazily in init() on the next reuse, where the arr.length > 0 guard makes it nearly free (arrays already empty after unregisterAnimation). --- src/common/EventEmitter.ts | 8 +++- src/core/animations/AnimationManager.ts | 8 ++-- src/core/animations/CoreAnimation.ts | 37 +++++++++++-------- .../animations/CoreAnimationController.ts | 5 ++- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index 433cf93b..528c0418 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -61,7 +61,7 @@ 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; } // Iterate backwards: safe when once()/off() splice() during emission since @@ -86,12 +86,16 @@ export class EventEmitter implements IEventEmitter { * 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) { + if (arr !== undefined && arr.length > 0) { arr.length = 0; } } diff --git a/src/core/animations/AnimationManager.ts b/src/core/animations/AnimationManager.ts index 7bfcd800..a843ac12 100644 --- a/src/core/animations/AnimationManager.ts +++ b/src/core/animations/AnimationManager.ts @@ -112,11 +112,11 @@ export class AnimationManager { animation: CoreAnimation, controller: CoreAnimationController, ): void { - // Clear in-place using known event names -- preserves pre-allocated listener - // arrays so the next registerAnimation() doesn't need to allocate new []. - animation.clearListeners(CoreAnimation.EVENTS); + // 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.clearListeners(CoreAnimationController.EVENTS); this.controllerPool.push(controller); } } diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index b18270eb..8c91bedb 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -118,8 +118,10 @@ export class CoreAnimation extends EventEmitter { this.activeIndex = -1; this.propValuesMap.props = null; this.propValuesMap.shaderProps = null; - // NOTE: listener arrays are already cleared by releaseToPool() before - // this is called. No need to clearListeners() here again. + // 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; @@ -177,7 +179,23 @@ export class CoreAnimation extends EventEmitter { 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) { @@ -190,19 +208,8 @@ export class CoreAnimation extends EventEmitter { } 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) { diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index 8f2f4133..2c09607e 100644 --- a/src/core/animations/CoreAnimationController.ts +++ b/src/core/animations/CoreAnimationController.ts @@ -63,8 +63,9 @@ export class CoreAnimationController this.state = 'stopped'; this.stoppedPromise = null; this.stoppedResolve = null; - // NOTE: listener arrays are already cleared by releaseToPool() before - // this is called. No need to clearListeners() here again. + // 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 {