diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 812b05fef..422767e7b 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -18,6 +18,7 @@ */ import type { CoreNodeRenderState } from '../core/CoreNode.js'; +import type { FrameCount } from '../core/lib/fps.js'; import type { TextureError } from '../core/TextureError.js'; /** @@ -138,6 +139,7 @@ export type NodeRenderStateEventHandler = ( export interface FpsUpdatePayload { fps: number; contextSpyData: Record | null; + frameCount: FrameCount; } /** diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 958d8f37a..d946dbfdb 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -57,6 +57,12 @@ import { ColorTexture } from './textures/ColorTexture.js'; import type { Platform } from './platforms/Platform.js'; import type { WebPlatform } from './platforms/web/WebPlatform.js'; import type { RendererMainSettings } from '../main-api/Renderer.js'; +import { + createFrameCounter, + setFpsInterval, + setFpsBoundaries, + type FrameCounter, +} from './lib/fps.js'; export type StageOptions = Omit< RendererMainSettings, @@ -65,6 +71,7 @@ export type StageOptions = Omit< textureMemory: TextureMemoryManagerSettings; canvas: HTMLCanvasElement | OffscreenCanvas; fpsUpdateInterval: number; + fpsBoundaries?: number[]; eventBus: EventEmitter; platform: Platform | WebPlatform; inspector: boolean; @@ -136,10 +143,10 @@ export class Stage { lastFrameTime = 0; currentFrameTime = 0; elapsedTime = 0; + currentFrameCounter: FrameCounter | null = null; + private timedNodes: CoreNode[] = []; private clrColor = 0x00000000; - private fpsNumFrames = 0; - private fpsElapsedTime = 0; private numQuadsRendered = 0; private renderRequested = false; private frameEventQueue: [name: string, payload: unknown][] = []; @@ -198,6 +205,12 @@ export class Stage { this.animationManager = new AnimationManager(); this.contextSpy = enableContextSpy ? new ContextSpy() : null; + // Set initial frame buckets and FPS update interval for FPS tracking + if (options.fpsBoundaries !== undefined) { + setFpsBoundaries(options.fpsBoundaries); + } + setFpsInterval(options.fpsUpdateInterval); + let bm = [0, 0, 0, 0] as [number, number, number, number]; if (boundsMargin) { bm = Array.isArray(boundsMargin) @@ -382,9 +395,8 @@ export class Stage { this.lastFrameTime = this.currentFrameTime; this.currentFrameTime = newFrameTime; this.elapsedTime = newFrameTime - this.startTime; - this.deltaTime = !this.lastFrameTime - ? 100 / 6 - : newFrameTime - this.lastFrameTime; + this.deltaTime = + this.lastFrameTime === 0 ? 100 / 6 : newFrameTime - this.lastFrameTime; this.txManager.frameTime = newFrameTime; this.txMemManager.frameTime = newFrameTime; @@ -542,24 +554,50 @@ export class Stage { // If there's an FPS update interval, emit the FPS update event // when the specified interval has elapsed. const { fpsUpdateInterval } = this.options; - if (fpsUpdateInterval) { - this.fpsNumFrames++; - this.fpsElapsedTime += this.deltaTime; - if (this.fpsElapsedTime >= fpsUpdateInterval) { - const fps = Math.round( - (this.fpsNumFrames * 1000) / this.fpsElapsedTime, - ); - this.fpsNumFrames = 0; - this.fpsElapsedTime = 0; + if (fpsUpdateInterval > 0) { + let frameCounter = this.currentFrameCounter; + const elapsed = this.elapsedTime; + + if (frameCounter === null) { + frameCounter = this.currentFrameCounter = createFrameCounter(elapsed); + } + + frameCounter.increment(this.deltaTime); + + if (frameCounter.end <= elapsed) { this.queueFrameEvent('fpsUpdate', { - fps, + fps: Math.round( + (frameCounter.total / (elapsed - frameCounter.start)) * 1000, + ), contextSpyData: this.contextSpy?.getData() ?? null, + frameCount: { + //only make a copy of the boundaries array since it's read-only and we want to prevent mutation from outside + boundaries: ([] as number[]).concat(frameCounter.boundaries), + //count and total are not mutated inside the renderer anymore so no copy is necessary + count: frameCounter.count, + total: frameCounter.total, + }, } satisfies FpsUpdatePayload); this.contextSpy?.reset(); + this.currentFrameCounter = null; } } } + updateFpsUpdateInterval(newInterval: number) { + this.options.fpsUpdateInterval = newInterval; + setFpsInterval(newInterval); + // Reset current frame counter to start new interval + this.currentFrameCounter = null; + } + + updateFpsBoundaries(newBoundaries: number[]) { + this.options.fpsBoundaries = newBoundaries; + setFpsBoundaries(newBoundaries); + // Reset current frame counter to start new interval with new boundaries + this.currentFrameCounter = null; + } + calculateQuads() { const quads = this.renderer.getQuadCount(); if (quads && quads !== this.numQuadsRendered) { diff --git a/src/core/lib/fps.test.ts b/src/core/lib/fps.test.ts new file mode 100644 index 000000000..9086ee6fa --- /dev/null +++ b/src/core/lib/fps.test.ts @@ -0,0 +1,75 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2026 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 { beforeEach, describe, expect, it } from 'vitest'; +import { createFrameCounter, setFpsBoundaries, setFpsInterval } from './fps.js'; + +describe('fps', () => { + beforeEach(() => { + setFpsBoundaries([20, 40, 60, 80, 100]); + setFpsInterval(1000); + }); + + it('assigns frames to matching buckets and overflow', () => { + setFpsBoundaries([20, 40]); + const counter = createFrameCounter(0); + + counter.increment(10); + counter.increment(20); + counter.increment(21); + counter.increment(40); + counter.increment(41); + + expect(counter.count[20]).toBe(2); + expect(counter.count[40]).toBe(2); + expect(counter.count.overflow).toBe(1); + expect(counter.total).toBe(5); + }); + + it('calculates averageFps from total frames and elapsed interval', () => { + setFpsInterval(2000); + const counter = createFrameCounter(100); + + for (let i = 0; i < 10; i++) { + counter.increment(16); + } + + expect(counter.averageFps).toBe(5); + }); + + it('keeps counter boundaries and interval when global settings change', () => { + setFpsBoundaries([10, 20]); + setFpsInterval(1000); + const firstCounter = createFrameCounter(0); + + setFpsBoundaries([30, 60]); + setFpsInterval(2000); + const secondCounter = createFrameCounter(100); + + firstCounter.increment(15); + secondCounter.increment(15); + + expect(firstCounter.count[20]).toBe(1); + expect(firstCounter.count.overflow).toBe(0); + expect(firstCounter.end).toBe(1000); + expect(secondCounter.count[30]).toBe(1); + expect(secondCounter.count.overflow).toBe(0); + expect(secondCounter.end).toBe(2100); + }); +}); diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts new file mode 100644 index 000000000..e09cfe5c9 --- /dev/null +++ b/src/core/lib/fps.ts @@ -0,0 +1,106 @@ +/* + * 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. + */ + +export interface FrameCount { + /** + * The boundaries for the frame count buckets. + */ + boundaries: number[]; + /** + * The count of frames in each bucket. + */ + count: Record; + /** + * The total number of frames counted. + */ + total: number; +} + +export type FrameCounter = FrameCount & { + /** + * The start time of the frame counting interval. + */ + start: number; + /** + * The end time of the frame counting interval. + */ + end: number; + /** + * Increments the frame count based on the provided frame delta time. + * @param frameDelta - The time in milliseconds it took to render the last frame. + */ + increment: (frameDelta: number) => void; + /** + * Calculates and returns the average frames per second (FPS) based on the total frames counted and the elapsed time. + * @returns The average FPS. + */ + get averageFps(): number; +}; + +let fpsBoundaries = [20, 40, 60, 80, 100]; +let fpsInterval = 1000; // 1 second + +const frameCounter: FrameCounter = { + start: 0, + end: 0, + total: 0, + boundaries: [], + count: {}, + increment(frameDelta: number) { + this.total++; + const boundaries = this.boundaries; + for (let i = 0; i < boundaries.length; i++) { + const bucket = boundaries[i] as number; + if (frameDelta <= bucket) { + this.count[bucket]!++; + return; + } + } + this.count['overflow']!++; + }, + get averageFps() { + //calculate fps based on frame count and elapsed time + return (this.total / (this.end - this.start)) * 1000; + }, +}; + +export function setFpsBoundaries(newBoundaries: number[]) { + // sort buckets in ascending order just in case + fpsBoundaries = newBoundaries.slice().sort((a, b) => a - b); +} + +export function setFpsInterval(newInterval: number) { + fpsInterval = newInterval; +} + +export function createFrameCounter(frameTime: number): FrameCounter { + const counter = Object.create(frameCounter) as FrameCounter; + counter.boundaries = fpsBoundaries; + counter.start = frameTime; + counter.end = frameTime + fpsInterval; + counter.total = 0; + counter.count = Object.create(null) as Record; + //fill frames with 0 for each bucket + for (let i = 0; i < fpsBoundaries.length; i++) { + const bucket = fpsBoundaries[i] as number; + counter.count[bucket] = 0; + } + counter.count['overflow'] = 0; + return counter; +} diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index 5facc967e..ba6815e14 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -238,6 +238,10 @@ export interface RendererRuntimeSettings { */ fpsUpdateInterval: number; + /** + * Frame time bucket boundaries (in milliseconds) included in the `fpsUpdate` event payload. + */ + fpsBoundaries?: number[]; /** * Clears the render buffer on reset * @@ -536,6 +540,7 @@ export class RendererMain extends EventEmitter { settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1, clearColor: settings.clearColor ?? 0x00000000, fpsUpdateInterval: settings.fpsUpdateInterval || 0, + fpsBoundaries: settings.fpsBoundaries, enableClear: settings.enableClear ?? true, targetFPS: settings.targetFPS || 0, numImageWorkers: @@ -599,6 +604,7 @@ export class RendererMain extends EventEmitter { enableContextSpy: settings.enableContextSpy!, forceWebGL2: settings.forceWebGL2!, fpsUpdateInterval: settings.fpsUpdateInterval!, + fpsBoundaries: settings.fpsBoundaries, enableClear: settings.enableClear!, numImageWorkers: settings.numImageWorkers!, renderEngine: settings.renderEngine!, @@ -951,6 +957,14 @@ export class RendererMain extends EventEmitter { needDimensionsUpdate = true; } + if (options.fpsUpdateInterval !== undefined) { + this.stage.updateFpsUpdateInterval(options.fpsUpdateInterval); + } + + if (options.fpsBoundaries !== undefined) { + this.stage.updateFpsBoundaries(options.fpsBoundaries); + } + if (options.boundsMargin !== undefined) { this.stage.setBoundsMargin(options.boundsMargin); }