diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index 812b05fe..422767e7 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 958d8f37..40e3ec3f 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) { + 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,45 @@ 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, + 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); + } + + if (frameCounter === null) { + frameCounter = this.currentFrameCounter = createFrameCounter(eleapsed); } + + frameCounter.increment(this.deltaTime); } } + updateFpsUpdateInterval(newInterval: number) { + setFpsInterval(newInterval); + // Reset current frame counter to start new interval + this.currentFrameCounter = null; + } + + updateFpsBoundaries(newBoundaries: number[]) { + 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.ts b/src/core/lib/fps.ts new file mode 100644 index 00000000..1c5fb1bf --- /dev/null +++ b/src/core/lib/fps.ts @@ -0,0 +1,103 @@ +/* + * 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 boundaries = [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++; + 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 + boundaries = newBoundaries.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 = boundaries; + counter.start = frameTime; + counter.end = frameTime + fpsInterval; + //fill frames with 0 for each bucket + for (let i = 0; i < boundaries.length; i++) { + const bucket = boundaries[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 5facc967..e123cf63 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); }