Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/common/CommonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -138,6 +139,7 @@ export type NodeRenderStateEventHandler = (
export interface FpsUpdatePayload {
fps: number;
contextSpyData: Record<string, number> | null;
frameCount: FrameCount;
}
Comment thread
michielvandergeest marked this conversation as resolved.

/**
Expand Down
68 changes: 53 additions & 15 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,6 +71,7 @@ export type StageOptions = Omit<
textureMemory: TextureMemoryManagerSettings;
canvas: HTMLCanvasElement | OffscreenCanvas;
fpsUpdateInterval: number;
fpsBoundaries?: number[];
eventBus: EventEmitter;
platform: Platform | WebPlatform;
inspector: boolean;
Expand Down Expand Up @@ -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][] = [];
Expand Down Expand Up @@ -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);
Comment thread
jfboeve marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.

let bm = [0, 0, 0, 0] as [number, number, number, number];
if (boundsMargin) {
bm = Array.isArray(boundsMargin)
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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', {
Comment thread
jfboeve marked this conversation as resolved.
fps,
fps: Math.round(
(frameCounter.total / (elapsed - frameCounter.start)) * 1000,
),
contextSpyData: this.contextSpy?.getData() ?? null,
frameCount: {
Comment thread
jfboeve marked this conversation as resolved.
//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;
}
Comment thread
Copilot marked this conversation as resolved.

updateFpsBoundaries(newBoundaries: number[]) {
this.options.fpsBoundaries = newBoundaries;
setFpsBoundaries(newBoundaries);
// Reset current frame counter to start new interval with new boundaries
this.currentFrameCounter = null;
}
Comment thread
Copilot marked this conversation as resolved.

calculateQuads() {
const quads = this.renderer.getQuadCount();
if (quads && quads !== this.numQuadsRendered) {
Expand Down
75 changes: 75 additions & 0 deletions src/core/lib/fps.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions src/core/lib/fps.ts
Original file line number Diff line number Diff line change
@@ -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<number | string, number>;
/**
* The total number of frames counted.
*/
total: number;
}
Comment thread
jfboeve marked this conversation as resolved.

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

Comment thread
jfboeve marked this conversation as resolved.
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);
}
Comment thread
Copilot marked this conversation as resolved.

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<number | string, number>;
//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;
}
14 changes: 14 additions & 0 deletions src/main-api/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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!,
Expand Down Expand Up @@ -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);
}
Expand Down
Loading