From e933af7d5badff7b3064ad2df258acba32a9a5b0 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Tue, 19 May 2026 14:03:50 +0200 Subject: [PATCH 01/13] added framecounter --- src/common/CommonTypes.ts | 2 + src/core/Stage.ts | 57 +++++++++++++++++++++------- src/core/lib/fps.ts | 80 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 src/core/lib/fps.ts diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 812b05fef..1015f4766 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -18,6 +18,7 @@ */ import type { CoreNodeRenderState } from '../core/CoreNode.js'; +import type { FrameCounter } 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; + frameCounter: FrameCounter; } /** diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 958d8f37a..8cc5fa79a 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, + setFrameBuckets, + 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; + fpsBuckets?: number[]; eventBus: EventEmitter; platform: Platform | WebPlatform; inspector: boolean; @@ -136,6 +143,8 @@ export class Stage { lastFrameTime = 0; currentFrameTime = 0; elapsedTime = 0; + currentFrameCounter: FrameCounter | null = null; + private timedNodes: CoreNode[] = []; private clrColor = 0x00000000; private fpsNumFrames = 0; @@ -198,6 +207,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.fpsBuckets) { + setFrameBuckets(options.fpsBuckets); + } + setFpsInterval(options.fpsUpdateInterval); + let bm = [0, 0, 0, 0] as [number, number, number, number]; if (boundsMargin) { bm = Array.isArray(boundsMargin) @@ -382,9 +397,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,21 +556,38 @@ 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 eleapsed = this.elapsedTime; + if (frameCounter !== null && frameCounter.end < eleapsed) { this.queueFrameEvent('fpsUpdate', { - fps, + fps: frameCounter.averageFps, contextSpyData: this.contextSpy?.getData() ?? null, + frameCounter: frameCounter, } satisfies FpsUpdatePayload); this.contextSpy?.reset(); + frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); + } + + if (frameCounter === null) { + frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); } + + frameCounter.increment(this.deltaTime); + // 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; + // this.queueFrameEvent('fpsUpdate', { + // fps, + // contextSpyData: this.contextSpy?.getData() ?? null, + // } satisfies FpsUpdatePayload); + // this.contextSpy?.reset(); + // } } } diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts new file mode 100644 index 000000000..0509fe08a --- /dev/null +++ b/src/core/lib/fps.ts @@ -0,0 +1,80 @@ +/* + * 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. + */ + +/** + * Class that keeps track of the invocations of Context methods when + * the `enableContextSpy` renderer option is enabled. + */ + +export interface FrameCounter { + start: number; + end: number; + frameCount: number; + frames: Record; + increment: (frameDelta: number) => void; + get averageFps(): number; +} + +let buckets = [20, 40, 60, 80, 100]; +let overflowBucketLabel = '>' + buckets[buckets.length - 1]; +let fpsInterval = 1000; // 1 second + +const frameCounter: FrameCounter = { + start: 0, + end: 0, + frameCount: 0, + frames: {}, + increment(frameDelta: number) { + this.frameCount++; + for (let i = 0; i < buckets.length; i++) { + const bucket = buckets[i] as number; + if (frameDelta <= bucket) { + this.frames[bucket]!++; + return; + } + } + this.frames[overflowBucketLabel]!++; + }, + get averageFps() { + //calculate fps based on frame count and elapsed time + return (this.frameCount / (this.end - this.start)) * 1000; + }, +}; + +export function setFrameBuckets(newBuckets: number[]) { + buckets = newBuckets; + overflowBucketLabel = '>' + buckets[buckets.length - 1]; +} + +export function setFpsInterval(newInterval: number) { + fpsInterval = newInterval; +} + +export function createFrameCounter(frameTime: number): FrameCounter { + const counter = Object.create(frameCounter) as FrameCounter; + counter.start = frameTime; + counter.end = frameTime + fpsInterval; + //fill frames with 0 for each bucket + for (let i = 0; i < buckets.length; i++) { + const bucket = buckets[i] as number; + counter.frames[bucket] = 0; + } + counter.frames[overflowBucketLabel] = 0; + return counter; +} From 1e2779b231024b72378d8d9e465c01b8cc8a7598 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Thu, 4 Jun 2026 11:24:00 +0200 Subject: [PATCH 02/13] tweaking property names, change framecount payload --- src/common/CommonTypes.ts | 4 +-- src/core/Stage.ts | 30 ++++++----------- src/core/lib/fps.ts | 70 +++++++++++++++++++++++++-------------- 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 1015f4766..422767e7b 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -18,7 +18,7 @@ */ import type { CoreNodeRenderState } from '../core/CoreNode.js'; -import type { FrameCounter } from '../core/lib/fps.js'; +import type { FrameCount } from '../core/lib/fps.js'; import type { TextureError } from '../core/TextureError.js'; /** @@ -139,7 +139,7 @@ export type NodeRenderStateEventHandler = ( export interface FpsUpdatePayload { fps: number; contextSpyData: Record | null; - frameCounter: FrameCounter; + frameCount: FrameCount; } /** diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 8cc5fa79a..b826c89c6 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -71,7 +71,7 @@ export type StageOptions = Omit< textureMemory: TextureMemoryManagerSettings; canvas: HTMLCanvasElement | OffscreenCanvas; fpsUpdateInterval: number; - fpsBuckets?: number[]; + fpsBoundaries?: number[]; eventBus: EventEmitter; platform: Platform | WebPlatform; inspector: boolean; @@ -147,8 +147,6 @@ export class Stage { private timedNodes: CoreNode[] = []; private clrColor = 0x00000000; - private fpsNumFrames = 0; - private fpsElapsedTime = 0; private numQuadsRendered = 0; private renderRequested = false; private frameEventQueue: [name: string, payload: unknown][] = []; @@ -208,8 +206,8 @@ export class Stage { this.contextSpy = enableContextSpy ? new ContextSpy() : null; // Set initial frame buckets and FPS update interval for FPS tracking - if (options.fpsBuckets) { - setFrameBuckets(options.fpsBuckets); + if (options.fpsBoundaries) { + setFrameBuckets(options.fpsBoundaries); } setFpsInterval(options.fpsUpdateInterval); @@ -563,7 +561,13 @@ export class Stage { this.queueFrameEvent('fpsUpdate', { fps: frameCounter.averageFps, contextSpyData: this.contextSpy?.getData() ?? null, - frameCounter: frameCounter, + 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(); frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); @@ -574,20 +578,6 @@ export class Stage { } frameCounter.increment(this.deltaTime); - // 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; - // this.queueFrameEvent('fpsUpdate', { - // fps, - // contextSpyData: this.contextSpy?.getData() ?? null, - // } satisfies FpsUpdatePayload); - // this.contextSpy?.reset(); - // } } } diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts index 0509fe08a..b5d224949 100644 --- a/src/core/lib/fps.ts +++ b/src/core/lib/fps.ts @@ -17,49 +17,70 @@ * limitations under the License. */ -/** - * Class that keeps track of the invocations of Context methods when - * the `enableContextSpy` renderer option is enabled. - */ +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 interface FrameCounter { +export type FrameCounter = FrameCount & { + /** + * The start time of the frame counting interval. + */ start: number; + /** + * The end time of the frame counting interval. + */ end: number; - frameCount: number; - frames: Record; + /** + * 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 buckets = [20, 40, 60, 80, 100]; -let overflowBucketLabel = '>' + buckets[buckets.length - 1]; +let boundaries = [20, 40, 60, 80, 100]; let fpsInterval = 1000; // 1 second const frameCounter: FrameCounter = { start: 0, end: 0, - frameCount: 0, - frames: {}, + total: 0, + boundaries: [], + count: {}, increment(frameDelta: number) { - this.frameCount++; - for (let i = 0; i < buckets.length; i++) { - const bucket = buckets[i] as number; + this.total++; + for (let i = 0; i < boundaries.length; i++) { + const bucket = boundaries[i] as number; if (frameDelta <= bucket) { - this.frames[bucket]!++; + this.count[bucket]!++; return; } } - this.frames[overflowBucketLabel]!++; + this.count['overflow']!++; }, get averageFps() { //calculate fps based on frame count and elapsed time - return (this.frameCount / (this.end - this.start)) * 1000; + return (this.total / (this.end - this.start)) * 1000; }, }; export function setFrameBuckets(newBuckets: number[]) { - buckets = newBuckets; - overflowBucketLabel = '>' + buckets[buckets.length - 1]; + boundaries = newBuckets; } export function setFpsInterval(newInterval: number) { @@ -68,13 +89,14 @@ export function setFpsInterval(newInterval: number) { export function createFrameCounter(frameTime: number): FrameCounter { const counter = Object.create(frameCounter) as FrameCounter; + counter.boundaries = boundaries; counter.start = frameTime; counter.end = frameTime + fpsInterval; //fill frames with 0 for each bucket - for (let i = 0; i < buckets.length; i++) { - const bucket = buckets[i] as number; - counter.frames[bucket] = 0; + for (let i = 0; i < boundaries.length; i++) { + const bucket = boundaries[i] as number; + counter.count[bucket] = 0; } - counter.frames[overflowBucketLabel] = 0; + counter.count['overflow'] = 0; return counter; } From 9ac1f0f831d161be6888791ec7b42968ba0ddcf8 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Thu, 4 Jun 2026 11:32:20 +0200 Subject: [PATCH 03/13] make boundaries and fps interval setable during renderer creation / runtime --- src/core/Stage.ts | 12 ++++++++++++ src/main-api/Renderer.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index b826c89c6..25b06ad94 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -581,6 +581,18 @@ export class Stage { } } + updateFpsUpdateInterval(newInterval: number) { + setFpsInterval(newInterval); + // Reset current frame counter to start new interval + this.currentFrameCounter = null; + } + + updateFpsBoundaries(newBoundaries: number[]) { + setFrameBuckets(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/main-api/Renderer.ts b/src/main-api/Renderer.ts index 5facc967e..e123cf638 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -238,6 +238,11 @@ export interface RendererRuntimeSettings { */ fpsUpdateInterval: number; + /** + * Boundaries for FPS buckets in the FPS update event payload + */ + fpsBoundaries?: number[]; + /** * Clears the render buffer on reset * @@ -536,6 +541,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 +605,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 +958,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); } From 07692f52629c729db4ee0d58035149eb9709da2e Mon Sep 17 00:00:00 2001 From: jfboeve Date: Thu, 4 Jun 2026 12:23:22 +0200 Subject: [PATCH 04/13] sort boundaries in ascending order --- src/core/Stage.ts | 6 +++--- src/core/lib/fps.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 25b06ad94..40e3ec3f6 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -60,7 +60,7 @@ import type { RendererMainSettings } from '../main-api/Renderer.js'; import { createFrameCounter, setFpsInterval, - setFrameBuckets, + setFpsBoundaries, type FrameCounter, } from './lib/fps.js'; @@ -207,7 +207,7 @@ export class Stage { // Set initial frame buckets and FPS update interval for FPS tracking if (options.fpsBoundaries) { - setFrameBuckets(options.fpsBoundaries); + setFpsBoundaries(options.fpsBoundaries); } setFpsInterval(options.fpsUpdateInterval); @@ -588,7 +588,7 @@ export class Stage { } updateFpsBoundaries(newBoundaries: number[]) { - setFrameBuckets(newBoundaries); + setFpsBoundaries(newBoundaries); // Reset current frame counter to start new interval with new boundaries this.currentFrameCounter = null; } diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts index b5d224949..1c5fb1bf0 100644 --- a/src/core/lib/fps.ts +++ b/src/core/lib/fps.ts @@ -79,8 +79,9 @@ const frameCounter: FrameCounter = { }, }; -export function setFrameBuckets(newBuckets: number[]) { - boundaries = newBuckets; +export function setFpsBoundaries(newBoundaries: number[]) { + //sort buckets in ascending order just in case + boundaries = newBoundaries.sort((a, b) => a - b); } export function setFpsInterval(newInterval: number) { From 0d6514bf5eb73c166426e7a5e22df9a975c2df9a Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:11:48 +0200 Subject: [PATCH 05/13] fixed co-pilot comments, cleaned up calculateFPS function --- src/common/CommonTypes.ts | 2 +- src/core/Stage.ts | 19 ++++++++++--------- src/core/lib/fps.ts | 13 ++++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 422767e7b..65a07298a 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -139,7 +139,7 @@ export type NodeRenderStateEventHandler = ( export interface FpsUpdatePayload { fps: number; contextSpyData: Record | null; - frameCount: FrameCount; + frameCount?: FrameCount; } /** diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 40e3ec3f6..242f1e663 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -556,8 +556,15 @@ export class Stage { const { fpsUpdateInterval } = this.options; if (fpsUpdateInterval > 0) { let frameCounter = this.currentFrameCounter; - const eleapsed = this.elapsedTime; - if (frameCounter !== null && frameCounter.end < eleapsed) { + const elapsed = this.elapsedTime; + + if (frameCounter === null) { + frameCounter = this.currentFrameCounter = createFrameCounter(elapsed); + } + + frameCounter.increment(this.deltaTime); + + if (frameCounter.end <= elapsed) { this.queueFrameEvent('fpsUpdate', { fps: frameCounter.averageFps, contextSpyData: this.contextSpy?.getData() ?? null, @@ -570,14 +577,8 @@ export class Stage { }, } satisfies FpsUpdatePayload); this.contextSpy?.reset(); - frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); + this.currentFrameCounter = null; } - - if (frameCounter === null) { - frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); - } - - frameCounter.increment(this.deltaTime); } } diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts index 1c5fb1bf0..a1112f43e 100644 --- a/src/core/lib/fps.ts +++ b/src/core/lib/fps.ts @@ -53,7 +53,7 @@ export type FrameCounter = FrameCount & { get averageFps(): number; }; -let boundaries = [20, 40, 60, 80, 100]; +let fpsBoundaries = [20, 40, 60, 80, 100]; let fpsInterval = 1000; // 1 second const frameCounter: FrameCounter = { @@ -64,6 +64,7 @@ const frameCounter: FrameCounter = { 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) { @@ -81,7 +82,7 @@ const frameCounter: FrameCounter = { export function setFpsBoundaries(newBoundaries: number[]) { //sort buckets in ascending order just in case - boundaries = newBoundaries.sort((a, b) => a - b); + fpsBoundaries = newBoundaries.sort((a, b) => a - b); } export function setFpsInterval(newInterval: number) { @@ -90,12 +91,14 @@ export function setFpsInterval(newInterval: number) { export function createFrameCounter(frameTime: number): FrameCounter { const counter = Object.create(frameCounter) as FrameCounter; - counter.boundaries = boundaries; + 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 < boundaries.length; i++) { - const bucket = boundaries[i] as number; + for (let i = 0; i < fpsBoundaries.length; i++) { + const bucket = fpsBoundaries[i] as number; counter.count[bucket] = 0; } counter.count['overflow'] = 0; From 5b19bf1f9efce54b620dbfaaa0e56dd2b0e3f0a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:17:18 +0000 Subject: [PATCH 06/13] test: add fps unit coverage requested in review --- src/core/lib/fps.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/core/lib/fps.test.ts 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); + }); +}); From 0b7fe63636eba8ff13a2faea1ce658940f1eb717 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:38:48 +0200 Subject: [PATCH 07/13] use explicit !== undefined Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/Stage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 242f1e663..566c00f19 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -206,7 +206,7 @@ export class Stage { this.contextSpy = enableContextSpy ? new ContextSpy() : null; // Set initial frame buckets and FPS update interval for FPS tracking - if (options.fpsBoundaries) { + if (options.fpsBoundaries !== undefined) { setFpsBoundaries(options.fpsBoundaries); } setFpsInterval(options.fpsUpdateInterval); From 6f20901a117ae5ff11dcdc1081a40700af5ffcba Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:39:27 +0200 Subject: [PATCH 08/13] use boundaries in docs, instead of buckets Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/main-api/Renderer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index e123cf638..ba6815e14 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -239,10 +239,9 @@ export interface RendererRuntimeSettings { fpsUpdateInterval: number; /** - * Boundaries for FPS buckets in the FPS update event payload + * Frame time bucket boundaries (in milliseconds) included in the `fpsUpdate` event payload. */ fpsBoundaries?: number[]; - /** * Clears the render buffer on reset * From 2ba3331061491ad0430caef3e85f039e1254e747 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:41:23 +0200 Subject: [PATCH 09/13] clone newBounderies before sorting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/lib/fps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/lib/fps.ts b/src/core/lib/fps.ts index a1112f43e..e09cfe5c9 100644 --- a/src/core/lib/fps.ts +++ b/src/core/lib/fps.ts @@ -81,8 +81,8 @@ const frameCounter: FrameCounter = { }; export function setFpsBoundaries(newBoundaries: number[]) { - //sort buckets in ascending order just in case - fpsBoundaries = newBoundaries.sort((a, b) => a - b); + // sort buckets in ascending order just in case + fpsBoundaries = newBoundaries.slice().sort((a, b) => a - b); } export function setFpsInterval(newInterval: number) { From 37d379a59b32a5a70a8d6503def4dce00d2d873a Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:42:07 +0200 Subject: [PATCH 10/13] set this.options.fpsBoundaries on updateFpsBounderies Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/Stage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 566c00f19..96bd51dd5 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -589,6 +589,7 @@ export class Stage { } updateFpsBoundaries(newBoundaries: number[]) { + this.options.fpsBoundaries = newBoundaries; setFpsBoundaries(newBoundaries); // Reset current frame counter to start new interval with new boundaries this.currentFrameCounter = null; From 0caf5fce3096a6c116500f76b4db6da5e9a7d3cb Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 1 Jul 2026 15:42:49 +0200 Subject: [PATCH 11/13] Set this.options.fpsUpdateInterval on updateFpsUpdateInterval Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/Stage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 96bd51dd5..7424cf4c5 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -583,6 +583,7 @@ export class Stage { } updateFpsUpdateInterval(newInterval: number) { + this.options.fpsUpdateInterval = newInterval; setFpsInterval(newInterval); // Reset current frame counter to start new interval this.currentFrameCounter = null; From deea92f02fd9abd135d373ebb16f35476ab98863 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:45:17 +0000 Subject: [PATCH 12/13] Fix FPS calculation to use actual elapsed time with Math.round --- src/core/Stage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 7424cf4c5..d946dbfdb 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -566,7 +566,9 @@ export class Stage { if (frameCounter.end <= elapsed) { this.queueFrameEvent('fpsUpdate', { - fps: frameCounter.averageFps, + 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 From 6989e2238975ea2fb8b8b2c2a1143d028f1095e1 Mon Sep 17 00:00:00 2001 From: jfboeve Date: Wed, 15 Jul 2026 13:36:37 +0200 Subject: [PATCH 13/13] updated FpsUpdatePayload type --- src/common/CommonTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 65a07298a..422767e7b 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -139,7 +139,7 @@ export type NodeRenderStateEventHandler = ( export interface FpsUpdatePayload { fps: number; contextSpyData: Record | null; - frameCount?: FrameCount; + frameCount: FrameCount; } /**