From 333555fc0e803f5f7a62f64240779731cd2a37c0 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Mon, 30 Jun 2025 19:12:30 +0200 Subject: [PATCH 1/2] feat: add global target FPS control for animations and update related settings --- examples/index.ts | 13 +- examples/tests/animation.ts | 38 ++++- examples/tests/default-fps-animation.ts | 176 ++++++++++++++++++++++++ src/core/Stage.ts | 1 + src/core/animations/CoreAnimation.ts | 49 ++++++- src/main-api/Renderer.ts | 87 +++++++++++- 6 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 examples/tests/default-fps-animation.ts diff --git a/examples/index.ts b/examples/index.ts index 2d850478..49a46234 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -92,6 +92,7 @@ const defaultPhysicalPixelRatio = 1; const forceWebGL2 = urlParams.get('webgl2') === 'true'; const textureProcessingLimit = Number(urlParams.get('textureProcessingLimit')) || 0; + const defaultAnimationFps = Number(urlParams.get('targetFPS')) || undefined; const physicalPixelRatio = Number(urlParams.get('ppr')) || defaultPhysicalPixelRatio; @@ -117,6 +118,7 @@ const defaultPhysicalPixelRatio = 1; enableInspector, forceWebGL2, textureProcessingLimit, + defaultAnimationFps, ); return; } @@ -140,6 +142,7 @@ async function runTest( enableInspector: boolean, forceWebGL2: boolean, textureProcessingLimit: number, + defaultAnimationFps?: number, ) { const testModule = testModules[getTestPath(test)]; if (!testModule) { @@ -148,10 +151,14 @@ async function runTest( const module = await testModule(); - const customSettings: Partial = - typeof module.customSettings === 'function' + const customSettings: Partial = { + ...(typeof module.customSettings === 'function' ? module.customSettings(urlParams) - : {}; + : {}), + ...(defaultAnimationFps !== undefined && { + targetFPS: defaultAnimationFps, + }), + }; const { renderer, appElement } = await initRenderer( renderMode, diff --git a/examples/tests/animation.ts b/examples/tests/animation.ts index 5ca4bbdd..4bedecef 100644 --- a/examples/tests/animation.ts +++ b/examples/tests/animation.ts @@ -28,6 +28,8 @@ interface AnimationExampleSettings { stopMethod: 'reverse' | 'reset' | false; } +const fpsBrackets = [0, 5, 12, 24, 30, 60]; + export default async function ({ renderer, testRoot }: ExampleSettings) { const node = renderer.createNode({ x: 0, @@ -62,7 +64,18 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { y: 90, fontFamily: 'Ubuntu', fontSize: 20, - text: 'press left or right arrow key to change easing', + text: + 'press left or right arrow key to change easing\n' + + 'press up or down arrow key to change global target FPS', + }); + + const fpsLabel = renderer.createTextNode({ + parent: node, + x: 40, + y: 135, + fontFamily: 'Ubuntu', + fontSize: 20, + text: `target FPS: ${renderer.targetFPS}`, }); /** @@ -140,6 +153,8 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { currentAnimation.start(); }; + let currentFpsIndex = 0; + window.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') { animationIndex++; @@ -148,6 +163,27 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { animationIndex--; } + if (e.key === 'ArrowUp') { + //increase global target FPS + currentFpsIndex = (currentFpsIndex + 1) % fpsBrackets.length; + const newFps = fpsBrackets[currentFpsIndex]; + renderer.targetFPS = newFps; + console.log(`Global target FPS set to: ${newFps}`); + + fpsLabel.text = `target FPS: ${newFps}`; + } + + if (e.key === 'ArrowDown') { + //decrease global target FPS + currentFpsIndex = + (currentFpsIndex - 1 + fpsBrackets.length) % fpsBrackets.length; + const newFps = fpsBrackets[currentFpsIndex]; + renderer.targetFPS = newFps; + console.log(`Global target FPS set to: ${newFps}`); + + fpsLabel.text = `target FPS: ${newFps}`; + } + // wrap around animationIndex = ((animationIndex % easings.length) + easings.length) % easings.length; diff --git a/examples/tests/default-fps-animation.ts b/examples/tests/default-fps-animation.ts new file mode 100644 index 00000000..bd6aaa92 --- /dev/null +++ b/examples/tests/default-fps-animation.ts @@ -0,0 +1,176 @@ +/* + * 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. + */ + +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export default async function ({ renderer, testRoot }: ExampleSettings) { + console.log('Target Animation FPS Test'); + console.log('Global targetFPS:', renderer.targetFPS); + + // Demonstrate runtime setting of target FPS + console.log('Setting global target to 24fps...'); + renderer.targetFPS = 24; + + const node = renderer.createNode({ + x: 0, + y: 0, + width: 1920, + height: 1080, + color: 0x000000ff, + parent: testRoot, + }); + + // Test node 1: Uses global default FPS (no targetFps specified) + const globalFpsNode = renderer.createNode({ + x: 100, + y: 100, + width: 200, + height: 200, + color: 0xff0000ff, // Red + parent: node, + }); + + // Test node 2: Overrides with specific FPS + const specificFpsNode = renderer.createNode({ + x: 400, + y: 100, + width: 200, + height: 200, + color: 0x00ff00ff, // Green + parent: node, + }); + + // Test node 3: No FPS throttling (should run at display refresh rate) + const noThrottleNode = renderer.createNode({ + x: 700, + y: 100, + width: 200, + height: 200, + color: 0x0000ffff, // Blue + parent: node, + }); + + // Labels + const globalLabel = renderer.createTextNode({ + x: 100, + y: 320, + text: 'Global Target FPS', + fontFamily: 'Ubuntu', + fontSize: 32, + color: 0xffffffff, + parent: node, + }); + + const specificLabel = renderer.createTextNode({ + x: 400, + y: 320, + text: 'Specific 12 FPS', + fontFamily: 'Ubuntu', + fontSize: 32, + color: 0xffffffff, + parent: node, + }); + + const noThrottleLabel = renderer.createTextNode({ + x: 700, + y: 320, + text: 'No Throttling', + fontFamily: 'Ubuntu', + fontSize: 32, + color: 0xffffffff, + parent: node, + }); + + // Animation 1: Should use global default FPS setting + const globalFpsAnimation = globalFpsNode.animate( + { + x: globalFpsNode.x + 300, + rotation: Math.PI * 2, + }, + { + duration: 3000, + loop: true, + easing: 'ease-in-out', + // No targetFps specified - should use global default + }, + ); + + // Animation 2: Specific 12 FPS override + const specificFpsAnimation = specificFpsNode.animate( + { + x: specificFpsNode.x + 300, + rotation: Math.PI * 2, + }, + { + duration: 3000, + loop: true, + easing: 'ease-in-out', + targetFps: 12, // Override global default + }, + ); + + // Animation 3: No throttling (should run at display refresh rate) + const noThrottleAnimation = noThrottleNode.animate( + { + x: noThrottleNode.x + 300, + rotation: Math.PI * 2, + }, + { + duration: 3000, + loop: true, + easing: 'ease-in-out', + targetFps: 0, // Explicitly disable throttling + }, + ); + + globalFpsAnimation.start(); + specificFpsAnimation.start(); + noThrottleAnimation.start(); + + // Demonstrate dynamic FPS changes + let fpsChangeCounter = 0; + const fpsValues = [24, 15, 60, undefined]; // undefined = no throttling + + setInterval(() => { + fpsChangeCounter = (fpsChangeCounter + 1) % fpsValues.length; + const newFps = fpsValues[fpsChangeCounter]; + + console.log(`Changing global target FPS to: ${newFps ?? 'no throttling'}`); + renderer.targetFPS = newFps; + + // Update the label to show current setting + globalLabel.text = `Global Target: ${newFps ?? 'No Throttling'}`; + }, 4000); // Change every 4 seconds + + // Instructions + const instructions = renderer.createTextNode({ + x: 100, + y: 500, + text: + 'Watch how the RED box changes smoothness as global FPS changes every 4 seconds:\n' + + '• Red box: Uses dynamic global target FPS (changes automatically)\n' + + '• Green box: Always 12 FPS (never changes)\n' + + '• Blue box: Always no throttling (never changes)\n' + + '\nAlso try: renderer.targetFPS = 30;', + fontFamily: 'Ubuntu', + fontSize: 24, + color: 0xffffffff, + parent: node, + }); +} diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 4c710be9..4400e88f 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -79,6 +79,7 @@ export interface StageOptions { strictBounds: boolean; textureProcessingTimeLimit: number; createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full'; + defaultAnimationTargetFps?: number; } export type StageFpsUpdateHandler = ( diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index be8e0817..836108da 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -30,6 +30,15 @@ export interface AnimationSettings { repeat: number; repeatDelay: number; stopMethod: 'reverse' | 'reset' | false; + /** + * Target FPS for this animation (optional) + * When set, animation updates will be throttled to this frame rate. + * If not set, falls back to the global defaultAnimationTargetFps from renderer settings. + * If both are undefined/0, no throttling occurs (uses display refresh rate). + * @example 24 for 24fps animations on low-end devices + * @default undefined (uses global default or no throttling) + */ + targetFps?: number; } type PropValues = { @@ -45,6 +54,10 @@ export class CoreAnimation extends EventEmitter { private delay = 0; private timingFunction: (t: number) => number | undefined; + // Frame throttling properties + private accumulatedDt = 0; + private readonly targetFrameTime: number | undefined; + propValuesMap: PropValuesMap = {}; dynPropValuesMap: PropValuesMap | undefined = undefined; @@ -107,15 +120,25 @@ export class CoreAnimation extends EventEmitter { repeat: settings.repeat ?? 0, repeatDelay: settings.repeatDelay ?? 0, stopMethod: settings.stopMethod ?? false, + targetFps: settings.targetFps, }; this.timingFunction = getTimingFunction(easing); this.delayFor = delay; this.delay = delay; + + // Initialize frame throttling + // Use per-animation targetFps if set, otherwise fall back to global default + const effectiveTargetFps = + settings.targetFps ?? node.stage.options.defaultAnimationTargetFps; + if (effectiveTargetFps && effectiveTargetFps > 0) { + this.targetFrameTime = 1000 / effectiveTargetFps; // Convert FPS to milliseconds + } } reset() { this.progress = 0; this.delayFor = this.settings.delay || 0; + this.accumulatedDt = 0; // Reset frame throttling accumulator this.update(0); } @@ -257,8 +280,8 @@ export class CoreAnimation extends EventEmitter { const { duration, loop, easing, stopMethod } = this.settings; const { delayFor } = this; + // Early exit checks if (this.node.destroyed) { - // cleanup this.emit('destroyed', {}); return; } @@ -268,27 +291,39 @@ export class CoreAnimation extends EventEmitter { return; } + // Frame throttling for performance optimization + if (this.targetFrameTime !== undefined) { + this.accumulatedDt += dt; + + // Skip update if we haven't accumulated enough time for the target frame rate + if (this.accumulatedDt < this.targetFrameTime) { + return; // Skip this frame + } + + // Use accumulated time but cap it to prevent huge jumps (max 2 frames worth) + dt = Math.min(this.accumulatedDt, this.targetFrameTime * 2); + this.accumulatedDt = 0; // Reset accumulator + } + + // Handle delay phase if (this.delayFor > 0) { this.delayFor -= dt; if (this.delayFor >= 0) { - // Either no or more delay left. Exit. - return; + return; // Still in delay phase } else { - // We went beyond the delay time, add it back to dt so we can continue - // with the animation. + // Delay finished, use remaining time for animation dt = -this.delayFor; this.delayFor = 0; } } if (duration === 0) { - // No duration, we are done. this.emit('finished', {}); return; } + // Emit animating event on first update after delay if (this.progress === 0) { - // Progress is 0, we are starting the post-delay part of the animation. this.emit('animating', {}); } diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 97ad65c8..e309ec1f 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -280,6 +280,23 @@ export interface RendererMainSettings { */ textureProcessingTimeLimit?: number; + /** + * Default target FPS for animations + * + * @remarks + * Controls the target frame rate that animations will be throttled to if no + * per-animation targetFps is specified. This helps manage performance + * on lower-end devices by reducing animation update frequency. + * Set to 0 or undefined to disable global FPS throttling (animations + * will run at display refresh rate unless individually throttled). + * + * This setting can also be changed at runtime using the `renderer.targetFPS` + * getter/setter property. + * + * @defaultValue `undefined` (no global throttling) + */ + targetFPS?: number; + /** * Canvas object to use for rendering * @@ -332,13 +349,30 @@ export interface RendererMainSettings { * const renderer = new RendererMain( * { * appWidth: 1920, - * appHeight: 1080 + * appHeight: 1080, + * targetFPS: 30, // Global target FPS for animations * }, * 'app', * new MainCoreDriver(), * ); + * + * // Control animation performance at runtime + * renderer.targetFPS = 24; // Set global target to 24fps + * + * // Create animations that inherit the global target + * node.animate({ x: 100 }, { duration: 1000 }); // Uses global 24fps + * + * // Override with specific FPS + * node.animate({ x: 200 }, { duration: 1000, targetFps: 60 }); // Uses 60fps * ``` * + * ## Animation Performance Control + * The renderer provides global control over animation frame rates for performance management: + * - Use `targetFPS` setting to set initial global target FPS + * - Use `renderer.targetFPS` getter/setter to change at runtime + * - Individual animations can override with their own `targetFps` setting + * - Set to `undefined` or `0` for no throttling (display refresh rate) + * * ## Events * - `fpsUpdate` * - Emitted every `fpsUpdateInterval` milliseconds with the current FPS @@ -412,7 +446,8 @@ export class RendererMain extends EventEmitter { quadBufferSize: settings.quadBufferSize ?? 4 * 1024 * 1024, fontEngines: settings.fontEngines, strictBounds: settings.strictBounds ?? true, - textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 10, + textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 42, + targetFPS: settings.targetFPS ?? 0, canvas: settings.canvas || document.createElement('canvas'), createImageBitmapSupport: settings.createImageBitmapSupport || 'full', }; @@ -459,6 +494,7 @@ export class RendererMain extends EventEmitter { strictBounds: this.settings.strictBounds, textureProcessingTimeLimit: this.settings.textureProcessingTimeLimit, createImageBitmapSupport: this.settings.createImageBitmapSupport, + defaultAnimationTargetFps: this.settings.targetFPS, }); // Extract the root node @@ -748,4 +784,51 @@ export class RendererMain extends EventEmitter { setClearColor(color: number) { this.stage.setClearColor(color); } + + /** + * Gets the target FPS for animations + * + * @returns The current target animation FPS, or undefined if not set + * + * @remarks + * This is the global target FPS that animations will use if they don't + * specify their own targetFps. When undefined or 0, animations run at + * display refresh rate unless individually throttled. + */ + get targetFPS(): number | undefined { + return this.stage.options.defaultAnimationTargetFps; + } + + /** + * Sets the target FPS for animations + * + * @param fps - The target FPS to set as default for all animations. + * Set to undefined, 0, or a negative value to disable global throttling. + * + * @remarks + * This setting affects all new animations created after this call. + * Existing animations will continue to use their original settings. + * Individual animations can still override this global default by + * specifying their own targetFps. + * + * @example + * ```typescript + * // Set global target to 30fps for better performance on low-end devices + * renderer.targetFPS = 30; + * + * // This animation will use the 30fps default + * node.animate({ x: 100 }, { duration: 1000 }); + * + * // This animation overrides to 60fps + * node.animate({ x: 200 }, { duration: 1000, targetFps: 60 }); + * + * // Disable global throttling + * renderer.targetFPS = undefined; + * ``` + */ + set targetFPS(fps: number | undefined) { + // Update the stage options directly + this.stage.options.defaultAnimationTargetFps = + fps && fps > 0 ? fps : undefined; + } } From 8a9675ceca978f9b13ec336b34759dee22df0056 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Mon, 30 Jun 2025 21:28:23 +0200 Subject: [PATCH 2/2] fix: optimize frame throttling logic to separate animation timing from visual updates --- src/core/animations/CoreAnimation.ts | 70 ++++++++++++++++------------ 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index 836108da..32fcd941 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -292,17 +292,21 @@ export class CoreAnimation extends EventEmitter { } // Frame throttling for performance optimization + // Key insight: We separate animation timing from visual updates + // - Animation progress always advances based on actual elapsed time + // - Visual property updates are throttled to the target FPS + // This ensures animations complete in the correct time duration + let shouldRender = true; if (this.targetFrameTime !== undefined) { this.accumulatedDt += dt; - // Skip update if we haven't accumulated enough time for the target frame rate + // Check if we should skip rendering this frame if (this.accumulatedDt < this.targetFrameTime) { - return; // Skip this frame + shouldRender = false; // Skip rendering but continue with animation progress + } else { + // Reset accumulator when we do render + this.accumulatedDt = 0; } - - // Use accumulated time but cap it to prevent huge jumps (max 2 frames worth) - dt = Math.min(this.accumulatedDt, this.targetFrameTime * 2); - this.accumulatedDt = 0; // Reset accumulator } // Handle delay phase @@ -341,32 +345,36 @@ export class CoreAnimation extends EventEmitter { } } - if (this.propValuesMap['props'] !== undefined) { - this.updateValues( - this.node as unknown as Record, - this.propValuesMap['props'], - easing, - ); - } - if (this.propValuesMap['shaderProps'] !== undefined) { - this.updateValues( - this.node.shader.props as Record, - this.propValuesMap['shaderProps'], - easing, - ); - } + // Only update visual properties if we should render this frame + // This allows animation timing to stay correct while throttling visual updates + if (shouldRender) { + if (this.propValuesMap['props'] !== undefined) { + this.updateValues( + this.node as unknown as Record, + this.propValuesMap['props'], + easing, + ); + } + if (this.propValuesMap['shaderProps'] !== undefined) { + this.updateValues( + this.node.shader.props as Record, + this.propValuesMap['shaderProps'], + easing, + ); + } - if (this.dynPropValuesMap !== undefined) { - const dynEntries = Object.keys(this.dynPropValuesMap); - const dynEntriesL = dynEntries.length; - if (dynEntriesL > 0) { - for (let i = 0; i < dynEntriesL; i++) { - const key = dynEntries[i]!; - this.updateValues( - this.node.shader.props[key], - this.dynPropValuesMap[key]!, - easing, - ); + if (this.dynPropValuesMap !== undefined) { + const dynEntries = Object.keys(this.dynPropValuesMap); + const dynEntriesL = dynEntries.length; + if (dynEntriesL > 0) { + for (let i = 0; i < dynEntriesL; i++) { + const key = dynEntries[i]!; + this.updateValues( + this.node.shader.props[key], + this.dynPropValuesMap[key]!, + easing, + ); + } } } }