From 5c832ee5df5d27265d87768a33924fdfe5009016 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Mon, 30 Jun 2025 22:23:50 +0200 Subject: [PATCH 1/2] feat: implement global targetFPS setting --- examples/index.ts | 11 +- examples/tests/default-fps-animation.ts | 347 ++++++++++++++++++++++++ src/core/Stage.ts | 29 ++ src/core/platform.ts | 43 ++- src/main-api/Renderer.ts | 56 ++++ 5 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 examples/tests/default-fps-animation.ts diff --git a/examples/index.ts b/examples/index.ts index 2d850478..da753ed2 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 globalTargetFPS = Number(urlParams.get('targetFPS')) || undefined; const physicalPixelRatio = Number(urlParams.get('ppr')) || defaultPhysicalPixelRatio; @@ -117,6 +118,7 @@ const defaultPhysicalPixelRatio = 1; enableInspector, forceWebGL2, textureProcessingLimit, + globalTargetFPS, ); return; } @@ -140,6 +142,7 @@ async function runTest( enableInspector: boolean, forceWebGL2: boolean, textureProcessingLimit: number, + globalTargetFPS?: number, ) { const testModule = testModules[getTestPath(test)]; if (!testModule) { @@ -148,10 +151,12 @@ async function runTest( const module = await testModule(); - const customSettings: Partial = - typeof module.customSettings === 'function' + const customSettings: Partial = { + ...(typeof module.customSettings === 'function' ? module.customSettings(urlParams) - : {}; + : {}), + ...(globalTargetFPS !== undefined && { targetFPS: globalTargetFPS }), + }; const { renderer, appElement } = await initRenderer( renderMode, diff --git a/examples/tests/default-fps-animation.ts b/examples/tests/default-fps-animation.ts new file mode 100644 index 00000000..3a97ecc3 --- /dev/null +++ b/examples/tests/default-fps-animation.ts @@ -0,0 +1,347 @@ +/* + * 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 { IAnimationController, INode } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +/** + * Global FPS Throttling Demo + * + * This example demonstrates the global targetFPS setting that throttles the entire + * render loop. It shows multiple animations running simultaneously, all affected + * by the global FPS limit. + * + * Controls: + * - Number keys 1-9: Set FPS limits (10, 15, 20, 24, 25, 30, 45, 60, 120) + * - 0: Remove FPS limit (unlimited) + * - Space: Toggle all animations on/off + */ +export default async function ({ renderer, testRoot }: ExampleSettings) { + const backgroundNode = renderer.createNode({ + x: 0, + y: 0, + width: 1920, + height: 1080, + color: 0x1a1a1aff, + parent: testRoot, + }); + + // Title + renderer.createTextNode({ + parent: backgroundNode, + x: 50, + y: 50, + fontFamily: 'Ubuntu', + fontSize: 48, + text: 'Global FPS Throttling Demo', + color: 0xffffffff, + }); + + // FPS display + const fpsDisplayNode = renderer.createTextNode({ + parent: backgroundNode, + x: 50, + y: 120, + fontFamily: 'Ubuntu', + fontSize: 32, + text: `Current FPS Limit: ${ + renderer.targetFPS === 0 ? 'Unlimited' : renderer.targetFPS + }`, + color: 0x00ff00ff, + }); + + // Instructions + renderer.createTextNode({ + parent: backgroundNode, + x: 50, + y: 180, + fontFamily: 'Ubuntu', + fontSize: 20, + text: 'Press 1-9 for FPS limits (10-120), 0 for unlimited, Space to toggle animations', + color: 0xccccccff, + }); + + // Performance stats + const statsNode = renderer.createTextNode({ + parent: backgroundNode, + x: 50, + y: 220, + fontFamily: 'Ubuntu', + fontSize: 18, + text: 'Actual FPS: Calculating...', + color: 0xffff00ff, + }); + + // Create multiple animated objects to demonstrate the global effect + const animatedObjects: Array<{ + node: INode; + animation: IAnimationController | null; + baseX: number; + baseY: number; + }> = []; + + // Create a grid of animated objects + const colors = [ + 0xff0000ff, 0x00ff00ff, 0x0000ffff, 0xffff00ff, 0xff00ffff, 0x00ffffff, + ]; + const gridSize = 6; + const spacing = 120; + const startX = 300; + const startY = 300; + + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + const x = startX + col * spacing; + const y = startY + row * spacing; + const colorIndex = (row * gridSize + col) % colors.length; + const color = colors[colorIndex]; + + if (color !== undefined) { + const animatedNode = renderer.createNode({ + x, + y, + width: 60, + height: 60, + color, + parent: backgroundNode, + }); + + animatedObjects.push({ + node: animatedNode, + animation: null, + baseX: x, + baseY: y, + }); + } + } + } + + // Create a rotating object + const rotatingNode = renderer.createNode({ + x: 1500, + y: 400, + width: 100, + height: 100, + color: 0xffa500ff, + parent: backgroundNode, + pivot: 0.5, + }); + + // Create a scaling object + const scalingNode = renderer.createNode({ + x: 1650, + y: 400, + width: 80, + height: 80, + color: 0x9400d3ff, + parent: backgroundNode, + pivot: 0.5, + }); + + // Animation controllers + let animationsRunning = true; + let rotationAnimation: IAnimationController | null = null; + let scalingAnimation: IAnimationController | null = null; + + // FPS tracking + let frameCount = 0; + let lastTime = performance.now(); + let actualFPS = 0; + + // Function to start all animations + const startAnimations = () => { + // Start grid animations with simple back-and-forth motion + animatedObjects.forEach((obj, index) => { + const delay = index * 50; // Stagger the animations + const duration = 2000; // Standard duration + + // Create simple horizontal motion + const targetX = obj.baseX + 80; // Move 80 pixels to the right + + obj.animation = obj.node.animate( + { x: targetX }, + { + duration, + delay, + loop: true, + easing: 'ease-in-out', + }, + ); + obj.animation.start(); + }); + + // Start rotation animation + rotationAnimation = rotatingNode.animate( + { rotation: Math.PI * 2 }, + { + duration: 2000, + loop: true, + easing: 'linear', + }, + ); + rotationAnimation.start(); + + // Start scaling animation + scalingAnimation = scalingNode.animate( + { scaleX: 1.5, scaleY: 1.5 }, + { + duration: 2000, + loop: true, + easing: 'ease-in-out', + }, + ); + scalingAnimation.start(); + }; + + // Function to stop all animations + const stopAnimations = () => { + animatedObjects.forEach((obj) => { + if (obj.animation) { + obj.animation.stop(); + obj.animation = null; + } + }); + + if (rotationAnimation) { + rotationAnimation.stop(); + rotationAnimation = null; + } + + if (scalingAnimation) { + scalingAnimation.stop(); + scalingAnimation = null; + } + }; + + // FPS options mapping + const fpsOptions = [ + 0, // 0 = unlimited + 10, // 1 + 15, // 2 + 20, // 3 + 24, // 4 + 25, // 5 + 30, // 6 + 45, // 7 + 60, // 8 + 120, // 9 + ]; + + // Function to set FPS limit + const setFPSLimit = (fps: number) => { + renderer.targetFPS = fps; + fpsDisplayNode.text = `Current FPS Limit: ${fps === 0 ? 'Unlimited' : fps}`; + console.log(`Global FPS limit set to: ${fps === 0 ? 'Unlimited' : fps}`); + }; + + // Function to update performance stats + const updateStats = () => { + frameCount++; + const currentTime = performance.now(); + const deltaTime = currentTime - lastTime; + + if (deltaTime >= 1000) { + // Update every second + actualFPS = Math.round((frameCount * 1000) / deltaTime); + statsNode.text = `Actual FPS: ${actualFPS} | Target: ${ + renderer.targetFPS === 0 ? 'Unlimited' : renderer.targetFPS + }`; + frameCount = 0; + lastTime = currentTime; + } + + requestAnimationFrame(updateStats); + }; + + // Start performance monitoring + updateStats(); + + // Event handlers + window.addEventListener('keydown', (event) => { + const key = event.key; + + if (key >= '0' && key <= '9') { + const index = parseInt(key); + const targetFPS = fpsOptions[index]; + if (targetFPS !== undefined) { + setFPSLimit(targetFPS); + } + } else if (key === ' ') { + event.preventDefault(); + animationsRunning = !animationsRunning; + + if (animationsRunning) { + startAnimations(); + console.log('Animations started'); + } else { + stopAnimations(); + console.log('Animations stopped'); + } + } + }); + + // Start with animations running + startAnimations(); + + // Add visual feedback labels + renderer.createTextNode({ + parent: backgroundNode, + x: startX, + y: startY - 40, + fontFamily: 'Ubuntu', + fontSize: 24, + text: 'Animated Grid', + color: 0xffffffff, + }); + + renderer.createTextNode({ + parent: backgroundNode, + x: 1450, + y: 350, + fontFamily: 'Ubuntu', + fontSize: 20, + text: 'Rotation', + color: 0xffffffff, + }); + + renderer.createTextNode({ + parent: backgroundNode, + x: 1600, + y: 350, + fontFamily: 'Ubuntu', + fontSize: 20, + text: 'Scaling', + color: 0xffffffff, + }); + + // Add performance comparison info + renderer.createTextNode({ + parent: backgroundNode, + x: 50, + y: 950, + fontFamily: 'Ubuntu', + fontSize: 16, + text: 'Lower FPS = choppier animation, Higher FPS = smoother animation\nGlobal throttling affects ALL animations simultaneously', + color: 0x888888ff, + }); + + console.log('Global FPS Throttling Demo loaded'); + console.log('Use number keys 0-9 to change FPS limits'); + console.log('Use space bar to toggle animations'); +} diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 59e9f4e1..fcb0374e 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -68,6 +68,7 @@ export interface StageOptions { canvas: HTMLCanvasElement | OffscreenCanvas; clearColor: number; fpsUpdateInterval: number; + targetFPS: number; enableContextSpy: boolean; forceWebGL2: boolean; numImageWorkers: number; @@ -111,6 +112,16 @@ export class Stage { public readonly strictBounds: boolean; public readonly defaultTexture: Texture | null = null; + /** + * Target frame time in milliseconds (calculated from targetFPS) + * + * @remarks + * This is pre-calculated to avoid recalculating on every frame. + * - 0 means no throttling (use display refresh rate) + * - >0 means throttle to this frame time (1000 / targetFPS) + */ + public targetFrameTime: number = 0; + /** * Renderer Event Bus for the Stage to emit events onto * @@ -156,6 +167,10 @@ export class Stage { } = options; this.eventBus = options.eventBus; + + // Calculate target frame time from targetFPS option + this.targetFrameTime = options.targetFPS > 0 ? 1000 / options.targetFPS : 0; + this.txManager = new CoreTextureManager(this, { numImageWorkers, createImageBitmapSupport, @@ -292,6 +307,20 @@ export class Stage { this.renderRequested = true; } + /** + * Update the target frame time based on the current targetFPS setting + * + * @remarks + * This should be called whenever the targetFPS option is changed + * to ensure targetFrameTime stays in sync. + * targetFPS of 0 means no throttling (targetFrameTime = 0) + * targetFPS > 0 means throttle to 1000/targetFPS milliseconds + */ + updateTargetFrameTime() { + this.targetFrameTime = + this.options.targetFPS > 0 ? 1000 / this.options.targetFPS : 0; + } + updateFrameTime() { const newFrameTime = getTimeStamp(); this.lastFrameTime = this.currentFrameTime; diff --git a/src/core/platform.ts b/src/core/platform.ts index 0b59fb24..1dced682 100644 --- a/src/core/platform.ts +++ b/src/core/platform.ts @@ -24,14 +24,38 @@ import type { Stage } from './Stage.js'; */ export const startLoop = (stage: Stage) => { let isIdle = false; - const runLoop = () => { + let lastFrameTime = 0; + + const runLoop = (currentTime: number = 0) => { + const targetFrameTime = stage.targetFrameTime; + + // Check if we should throttle this frame + if (targetFrameTime > 0 && currentTime - lastFrameTime < targetFrameTime) { + // Too early for next frame, schedule with setTimeout for precise timing + const delay = targetFrameTime - (currentTime - lastFrameTime); + setTimeout(() => requestAnimationFrame(runLoop), delay); + return; + } + + lastFrameTime = currentTime; + stage.updateFrameTime(); stage.updateAnimations(); if (!stage.hasSceneUpdates()) { // We still need to calculate the fps else it looks like the app is frozen stage.calculateFps(); - setTimeout(runLoop, 16.666666666666668); + + if (targetFrameTime > 0) { + // Use setTimeout for throttled idle frames + setTimeout( + () => requestAnimationFrame(runLoop), + Math.max(targetFrameTime, 16.666666666666668), + ); + } else { + // Use standard idle timeout when not throttling + setTimeout(() => requestAnimationFrame(runLoop), 16.666666666666668); + } if (!isIdle) { stage.eventBus.emit('idle'); @@ -49,8 +73,21 @@ export const startLoop = (stage: Stage) => { isIdle = false; stage.drawFrame(); stage.flushFrameEvents(); - requestAnimationFrame(runLoop); + + // Schedule next frame + if (targetFrameTime > 0) { + // Use setTimeout + rAF combination for precise FPS control + const nextFrameDelay = Math.max( + 0, + targetFrameTime - (performance.now() - currentTime), + ); + setTimeout(() => requestAnimationFrame(runLoop), nextFrameDelay); + } else { + // Use standard rAF when not throttling + requestAnimationFrame(runLoop); + } }; + requestAnimationFrame(runLoop); }; diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index bdd57b59..4407934b 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -157,6 +157,22 @@ export interface RendererMainSettings { */ fpsUpdateInterval?: number; + /** + * Target FPS for the global render loop + * + * @remarks + * Controls the maximum frame rate of the entire rendering system. + * When set to 0, no throttling is applied (use display refresh rate). + * When set to a positive number, the global requestAnimationFrame loop + * will be throttled to this target FPS, affecting all animations and rendering. + * + * This provides global performance control for the entire application, + * useful for managing performance on lower-end devices. + * + * @defaultValue `0` (no throttling, use display refresh rate) + */ + targetFPS?: number; + /** * Include context call (i.e. WebGL) information in FPS updates * @@ -403,6 +419,7 @@ export class RendererMain extends EventEmitter { settings.devicePhysicalPixelRatio || window.devicePixelRatio, clearColor: settings.clearColor ?? 0x00000000, fpsUpdateInterval: settings.fpsUpdateInterval || 0, + targetFPS: settings.targetFPS || 0, numImageWorkers: settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2, enableContextSpy: settings.enableContextSpy ?? false, @@ -449,6 +466,7 @@ export class RendererMain extends EventEmitter { enableContextSpy: this.settings.enableContextSpy, forceWebGL2: this.settings.forceWebGL2, fpsUpdateInterval: this.settings.fpsUpdateInterval, + targetFPS: this.settings.targetFPS, numImageWorkers: this.settings.numImageWorkers, renderEngine: this.settings.renderEngine, textureMemory: resolvedTxSettings, @@ -748,4 +766,42 @@ export class RendererMain extends EventEmitter { setClearColor(color: number) { this.stage.setClearColor(color); } + + /** + * Gets the target FPS for the global render loop + * + * @returns The current target FPS (0 means no throttling) + * + * @remarks + * This controls the maximum frame rate of the entire rendering system. + * When 0, the system runs at display refresh rate. + */ + get targetFPS(): number { + return this.stage.options.targetFPS; + } + + /** + * Sets the target FPS for the global render loop + * + * @param fps - The target FPS to set for the global render loop. + * Set to 0 or a negative value to disable throttling. + * + * @remarks + * This setting affects the entire rendering system immediately. + * All animations, rendering, and frame updates will be throttled + * to this target FPS. Provides global performance control. + * + * @example + * ```typescript + * // Set global target to 30fps for better performance + * renderer.targetFPS = 30; + * + * // Disable global throttling (use display refresh rate) + * renderer.targetFPS = 0; + * ``` + */ + set targetFPS(fps: number) { + this.stage.options.targetFPS = fps > 0 ? fps : 0; + this.stage.updateTargetFrameTime(); + } } From 74b093312737e523a51bf70db2e9a8e942053278 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Mon, 30 Jun 2025 22:27:25 +0200 Subject: [PATCH 2/2] fix: adjust FPS options for better range in global FPS throttling demo --- examples/tests/default-fps-animation.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/tests/default-fps-animation.ts b/examples/tests/default-fps-animation.ts index 3a97ecc3..20473de4 100644 --- a/examples/tests/default-fps-animation.ts +++ b/examples/tests/default-fps-animation.ts @@ -232,15 +232,15 @@ export default async function ({ renderer, testRoot }: ExampleSettings) { // FPS options mapping const fpsOptions = [ 0, // 0 = unlimited - 10, // 1 - 15, // 2 - 20, // 3 - 24, // 4 - 25, // 5 + 5, // 1 + 10, // 2 + 15, // 3 + 20, // 4 + 24, // 5 30, // 6 - 45, // 7 - 60, // 8 - 120, // 9 + 40, // 7 + 45, // 8 + 60, // 9 ]; // Function to set FPS limit