From 421c6757ca9ac88a6c59ac0a0e070054975e8509 Mon Sep 17 00:00:00 2001 From: Frank Weindel <6070611+frank-weindel@users.noreply.github.com> Date: Mon, 18 Dec 2023 16:35:42 -0500 Subject: [PATCH] Context Spy When enabled by the `enableContextSpy` renderer option, the canvas context object (WebGL in the current case) will be instrumented with a spy mechanism that counts the number of calls to each context function. The number of calls to each function during the previous FPS interval will be included in the new `contextSpyData` key of the 'fpsUpdate' event. The Context Spy can be enabled in Example Tests by the `contextSpy` boolean URL param. When enabled, a statistical calculation of each context function call will be reported after every FPS statistics summary. Because of this, the option requires that the `fps` URL param is also set to true, otherwise only a degradation of performance will occur without any output of the Context Spy data. Example output: ``` --------------------------------- Average FPS: 4.4 Median FPS: 4 P01 FPS: 4 P05 FPS: 4 P25 FPS: 4 Std Dev FPS: 0.48989794855663565 Num samples: 5 --------------------------------- median(disable) / median(fps): 4 median(clear) / median(fps): 1 median(bindBuffer) / median(fps): 1 median(bufferData) / median(fps): 1 median(getParameter) / median(fps): 3 median(activeTexture) / median(fps): 3 median(bindTexture) / median(fps): 3 median(uniform2fv) / median(fps): 3 median(uniform1f) / median(fps): 3 median(enable) / median(fps): 3 median(blendFunc) / median(fps): 3 median(drawElements) / median(fps): 3 median(totalCalls) / median(fps): 28 --------------------------------- ```` BREAKING CHANGE This is a breaking change because the 'fpsUpdate' event previously only used a `number` as its payload type, whereas now it is an object with the keys `fps` and `contextSpyData`. Apps/frameworks that listen for the 'fpsUpdate' event will need to update their handlers. --- examples/README.md | 10 ++ examples/common/StatTracker.ts | 119 ++++++++++++++++ examples/index.ts | 128 +++++++++++------- src/common/CommonTypes.ts | 9 ++ src/core/Stage.ts | 30 +++- src/core/lib/ContextSpy.ts | 41 ++++++ src/core/renderers/webgl/WebGlCoreRenderer.ts | 8 +- src/main-api/ICoreDriver.ts | 3 +- src/main-api/RendererMain.ts | 20 ++- src/render-drivers/main/MainCoreDriver.ts | 12 +- .../threadx/ThreadXCoreDriver.ts | 6 +- .../threadx/ThreadXRendererMessage.ts | 5 +- src/render-drivers/threadx/worker/renderer.ts | 9 +- src/utils.ts | 28 +++- 14 files changed, 350 insertions(+), 78 deletions(-) create mode 100644 examples/common/StatTracker.ts create mode 100644 src/core/lib/ContextSpy.ts diff --git a/examples/README.md b/examples/README.md index cd159900..51a27361 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,6 +50,16 @@ pnpm watch - Whether or not to log the latest FPS sample to the console every 1 second. - After skipping the first 10 samples, every 100 samples after that will result in a statistics summary printed to the console. +- `contextSpy` (boolean, default: "false") + - Whether or not to turn on the context spy and reporting + - The context spy intercepts all calls to the (WebGL) context and reports + how many calls to each function occurred during the last FPS sampling period + (1 second for these tests). + - Statistical results of every context call will be reported along with the + FPS statistics summary. + - `fps` must be enabled in order to see any reporting. + - Enabling the context spy has a serious impact on performance so only use it + when you need to extract context call information. - `ppr` (number, default: 1) - Device physical pixel ratio. - `multiplier` (number, default: 1) diff --git a/examples/common/StatTracker.ts b/examples/common/StatTracker.ts new file mode 100644 index 00000000..2647acaf --- /dev/null +++ b/examples/common/StatTracker.ts @@ -0,0 +1,119 @@ +/* + * 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. + */ + +/** + * Simple utility class for capturing a set of sample groups and + * calculating statistics on them. + */ +export class StatTracker { + private data: Record = {}; + + /** + * Clear all sample groups + */ + reset() { + this.data = {}; + } + + /** + * Add a value to a sample group + * + * @param sampleGroup + * @param value + */ + add(sampleGroup: string, value: number) { + if (!this.data[sampleGroup]) { + this.data[sampleGroup] = []; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.data[sampleGroup]!.push(value); + } + + /** + * Get the percentile value for a sample group + * + * @param sampleGroup + * @param percentile + * @returns + */ + getPercentile(sampleGroup: string, percentile: number) { + const values = this.data[sampleGroup]; + if (!values) { + return 0; + } + values.sort((a, b) => a - b); + const index = Math.floor((percentile / 100) * values.length); + return values[index]!; + } + + /** + * Get the standard deviation for a sample group + * + * @param sampleGroup + * @returns + */ + getStdDev(sampleGroup: string) { + const values = this.data[sampleGroup]; + if (!values) { + return 0; + } + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const variance = + values.map((value) => Math.pow(value - mean, 2)).reduce((a, b) => a + b) / + values.length; + return Math.sqrt(variance); + } + + /** + * Get the average value for a sample group + * + * @param sampleGroup + * @returns + */ + getAverage(sampleGroup: string) { + const values = this.data[sampleGroup]; + if (!values) { + return 0; + } + return values.reduce((a, b) => a + b, 0) / values.length; + } + + /** + * Get the sample count for a sample group + * + * @param sampleGroup + * @returns + */ + getCount(sampleGroup: string) { + const values = this.data[sampleGroup]; + if (!values) { + return 0; + } + return values.length; + } + + /** + * Get the names of all the sample groups + * + * @returns + */ + getSampleGroups() { + return Object.keys(this.data); + } +} diff --git a/examples/index.ts b/examples/index.ts index a2dbeb6a..16b36936 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -24,11 +24,13 @@ import { type ICoreDriver, type NodeLoadedPayload, type RendererMainSettings, + type FpsUpdatePayload, } from '@lightningjs/renderer'; import { assertTruthy } from '@lightningjs/renderer/utils'; import coreWorkerUrl from './common/CoreWorker.js?importChunkUrl'; import coreExtensionModuleUrl from './common/AppCoreExtension.js?importChunkUrl'; import type { ExampleSettings } from './common/ExampleSettings.js'; +import { StatTracker } from './common/StatTracker.js'; interface TestModule { default: (settings: ExampleSettings) => Promise; @@ -60,29 +62,13 @@ const defaultResolution = 720; const defaultPhysicalPixelRatio = 1; (async () => { - // URL params - // - driver: main | threadx (default: threadx) - // - test: (default: test) - // - resolution: (default: 720) - // - Resolution (height) of to render the test at (in logical pixels) - // - ppr: (default: 1) - // - Device physical pixel ratio - // - showOverlay: true | false (default: true) - // - fps: true | false (default: false) - // - Log FPS to console every second - // - multiplier: (default: 1) - // - In tests that support it, multiply the number of objects created by - // this number. Useful for performance tests. - // - finalizationRegistry: true | false (default: false) - // - Use FinalizationRegistryTextureUsageTracker instead of - // ManualCountTextureUsageTracker - // - automation: true | false (default: false) - // - Run all tests in automation mode + // See README.md for details on the supported URL params const urlParams = new URLSearchParams(window.location.search); const automation = urlParams.get('automation') === 'true'; const test = urlParams.get('test') || (automation ? null : 'test'); const showOverlay = urlParams.get('overlay') !== 'false'; const logFps = urlParams.get('fps') === 'true'; + const enableContextSpy = urlParams.get('contextSpy') === 'true'; const perfMultiplier = Number(urlParams.get('multiplier')) || 1; const resolution = Number(urlParams.get('resolution')) || 720; const physicalPixelRatio = @@ -103,6 +89,7 @@ const defaultPhysicalPixelRatio = 1; logicalPixelRatio, physicalPixelRatio, logFps, + enableContextSpy, perfMultiplier, ); return; @@ -121,6 +108,7 @@ async function runTest( logicalPixelRatio: number, physicalPixelRatio: number, logFps: boolean, + enableContextSpy: boolean, perfMultiplier: number, ) { const testModule = testModules[getTestPath(test)]; @@ -139,6 +127,7 @@ async function runTest( const { renderer, appElement } = await initRenderer( driverName, logFps, + enableContextSpy, logicalPixelRatio, physicalPixelRatio, customSettings, @@ -182,6 +171,7 @@ async function runTest( async function initRenderer( driverName: string, logFps: boolean, + enableContextSpy: boolean, logicalPixelRatio: number, physicalPixelRatio: number, customSettings?: Partial, @@ -205,6 +195,7 @@ async function initRenderer( clearColor: 0x00000000, coreExtensionModule: coreExtensionModuleUrl, fpsUpdateInterval: logFps ? 1000 : 0, + enableContextSpy, ...customSettings, }, 'app', @@ -212,9 +203,9 @@ async function initRenderer( ); /** - * FPS sample captured + * Sample data captured */ - const fpsSamples: number[] = []; + const samples: StatTracker = new StatTracker(); /** * Number of samples to capture before calculating FPS stats */ @@ -228,39 +219,71 @@ async function initRenderer( */ let fpsSampleIndex = 0; let fpsSamplesLeft = fpsSampleCount; - renderer.on('fpsUpdate', (target: RendererMain, fps: number) => { - const captureSample = fpsSampleIndex >= fpsSampleSkipCount; - if (captureSample) { - fpsSamples.push(fps); - fpsSamplesLeft--; - if (fpsSamplesLeft === 0) { - const sortedSamples = fpsSamples.sort((a, b) => a - b); - const averageFps = - fpsSamples.reduce((a, b) => a + b, 0) / fpsSamples.length; - const p01Fps = sortedSamples[Math.floor(fpsSamples.length * 0.01)]!; - const p05Fps = sortedSamples[Math.floor(fpsSamples.length * 0.05)]!; - const p25Fps = sortedSamples[Math.floor(fpsSamples.length * 0.25)]!; - const medianFps = sortedSamples[Math.floor(fpsSamples.length * 0.5)]!; - const stdDevFps = Math.sqrt( - fpsSamples.reduce((a, b) => a + (b - averageFps) ** 2, 0) / - fpsSamples.length, - ); - console.log(`---------------------------------`); - console.log(`Average FPS: ${averageFps}`); - console.log(`Median FPS: ${medianFps}`); - console.log(`P01 FPS: ${p01Fps}`); - console.log(`P05 FPS: ${p05Fps}`); - console.log(`P25 FPS: ${p25Fps}`); - console.log(`Std Dev FPS: ${stdDevFps}`); - console.log(`Num samples: ${fpsSamples.length}`); - console.log(`---------------------------------`); - fpsSamples.length = 0; - fpsSamplesLeft = fpsSampleCount; + renderer.on( + 'fpsUpdate', + (target: RendererMain, fpsData: FpsUpdatePayload) => { + const captureSample = fpsSampleIndex >= fpsSampleSkipCount; + if (captureSample) { + samples.add('fps', fpsData.fps); + + if (fpsData.contextSpyData) { + let totalCalls = 0; + for (const key in fpsData.contextSpyData) { + const numCalls = fpsData.contextSpyData[key]!; + totalCalls += numCalls; + samples.add(key, numCalls); + } + samples.add('totalCalls', totalCalls); + } + + fpsSamplesLeft--; + if (fpsSamplesLeft === 0) { + const averageFps = samples.getAverage('fps'); + const p01Fps = samples.getPercentile('fps', 1); + const p05Fps = samples.getPercentile('fps', 5); + const p25Fps = samples.getPercentile('fps', 25); + const medianFps = samples.getPercentile('fps', 50); + const stdDevFps = samples.getStdDev('fps'); + console.log(`---------------------------------`); + console.log(`Average FPS: ${averageFps}`); + console.log(`Median FPS: ${medianFps}`); + console.log(`P01 FPS: ${p01Fps}`); + console.log(`P05 FPS: ${p05Fps}`); + console.log(`P25 FPS: ${p25Fps}`); + console.log(`Std Dev FPS: ${stdDevFps}`); + console.log(`Num samples: ${samples.getCount('fps')}`); + console.log(`---------------------------------`); + + // Print out median data for all context spy data + if (fpsData.contextSpyData) { + const contextKeys = samples + .getSampleGroups() + .filter((key) => key !== 'fps' && key !== 'totalCalls'); + // Print out median data for all context spy data + for (const key of contextKeys) { + const median = samples.getPercentile(key, 50); + console.log( + `median(${key}) / median(fps): ${Math.round( + median / medianFps, + )}`, + ); + } + const medianTotalCalls = samples.getPercentile('totalCalls', 50); + console.log( + `median(totalCalls) / median(fps): ${Math.round( + medianTotalCalls / medianFps, + )}`, + ); + console.log(`---------------------------------`); + } + samples.reset(); + fpsSamplesLeft = fpsSampleCount; + } } - } - console.log(`FPS: ${fps} (samples left: ${fpsSamplesLeft})`); - fpsSampleIndex++; - }); + console.log(`FPS: ${fpsData.fps} (samples left: ${fpsSamplesLeft})`); + fpsSampleIndex++; + }, + ); await renderer.init(); @@ -276,6 +299,7 @@ async function runAutomation(driverName: string, logFps: boolean) { const { renderer, appElement } = await initRenderer( driverName, logFps, + false, logicalPixelRatio, defaultPhysicalPixelRatio, ); diff --git a/src/common/CommonTypes.ts b/src/common/CommonTypes.ts index da5fd791..345db3af 100644 --- a/src/common/CommonTypes.ts +++ b/src/common/CommonTypes.ts @@ -94,3 +94,12 @@ export type NodeFailedEventHandler = ( target: any, payload: NodeFailedPayload, ) => void; + +/** + * Event payload for when an FpsUpdate event is emitted by either the Stage or + * MainRenderer + */ +export interface FpsUpdatePayload { + fps: number; + contextSpyData: Record | null; +} diff --git a/src/core/Stage.ts b/src/core/Stage.ts index d01b9899..f3556055 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -32,6 +32,8 @@ import type { import { SdfTextRenderer } from './text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.js'; import { CanvasTextRenderer } from './text-rendering/renderers/CanvasTextRenderer.js'; import { EventEmitter } from '../common/EventEmitter.js'; +import { ContextSpy } from './lib/ContextSpy.js'; +import type { FpsUpdatePayload } from '../common/CommonTypes.js'; export interface StageOptions { rootId: number; @@ -42,11 +44,18 @@ export interface StageOptions { canvas: HTMLCanvasElement | OffscreenCanvas; clearColor: number; fpsUpdateInterval: number; + enableContextSpy: boolean; + debug?: { monitorTextureCache?: boolean; }; } +export type StageFpsUpdateHandler = ( + stage: Stage, + fpsData: FpsUpdatePayload, +) => void; + const bufferMemory = 2e6; const autoStart = true; @@ -68,15 +77,27 @@ export class Stage extends EventEmitter { private fpsElapsedTime = 0; private renderRequested = false; + /// Debug data + contextSpy: ContextSpy | null = null; + /** * Stage constructor */ constructor(readonly options: StageOptions) { super(); - const { canvas, clearColor, rootId, debug, appWidth, appHeight } = options; + const { + canvas, + clearColor, + rootId, + debug, + appWidth, + appHeight, + enableContextSpy, + } = options; this.txManager = new CoreTextureManager(); this.shManager = new CoreShaderManager(); this.animationManager = new AnimationManager(); + this.contextSpy = enableContextSpy ? new ContextSpy() : null; if (debug?.monitorTextureCache) { setInterval(() => { @@ -96,6 +117,7 @@ export class Stage extends EventEmitter { bufferMemory, txManager: this.txManager, shManager: this.shManager, + contextSpy: this.contextSpy, }); // Must do this after renderer is created @@ -212,7 +234,11 @@ export class Stage extends EventEmitter { ); this.fpsNumFrames = 0; this.fpsElapsedTime = 0; - this.emit('fpsUpdate', fps); + this.emit('fpsUpdate', { + fps, + contextSpyData: this.contextSpy?.getData() ?? null, + } satisfies FpsUpdatePayload); + this.contextSpy?.reset(); } } } diff --git a/src/core/lib/ContextSpy.ts b/src/core/lib/ContextSpy.ts new file mode 100644 index 00000000..03638fe3 --- /dev/null +++ b/src/core/lib/ContextSpy.ts @@ -0,0 +1,41 @@ +/* + * 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 class ContextSpy { + private data: Record = {}; + + reset() { + this.data = {}; + } + + increment(name: string) { + if (!this.data[name]) { + this.data[name] = 0; + } + this.data[name]++; + } + + getData() { + return { ...this.data }; + } +} diff --git a/src/core/renderers/webgl/WebGlCoreRenderer.ts b/src/core/renderers/webgl/WebGlCoreRenderer.ts index 96d41497..c9848cec 100644 --- a/src/core/renderers/webgl/WebGlCoreRenderer.ts +++ b/src/core/renderers/webgl/WebGlCoreRenderer.ts @@ -54,6 +54,7 @@ import { import type { Dimensions } from '../../../common/CommonTypes.js'; import { WebGlCoreShader } from './WebGlCoreShader.js'; import { RoundedRectangle } from './shaders/RoundedRectangle.js'; +import { ContextSpy } from '../../lib/ContextSpy.js'; const WORDS_PER_QUAD = 24; const BYTES_PER_QUAD = WORDS_PER_QUAD * 4; @@ -66,6 +67,7 @@ export interface WebGlCoreRendererOptions { shManager: CoreShaderManager; clearColor: number; bufferMemory: number; + contextSpy: ContextSpy | null; } interface CoreWebGlSystem { @@ -113,11 +115,7 @@ export class WebGlCoreRenderer extends CoreRenderer { this.shManager = options.shManager; this.defaultTexture = new ColorTexture(this.txManager); - const gl = createWebGLContext(canvas); - if (!gl) { - throw new Error('Unable to create WebGL context'); - } - this.gl = gl; + const gl = (this.gl = createWebGLContext(canvas, options.contextSpy)); const color = getNormalizedRgbaComponents(clearColor); gl.viewport(0, 0, canvas.width, canvas.height); diff --git a/src/main-api/ICoreDriver.ts b/src/main-api/ICoreDriver.ts index b0dafcd3..3a192ef1 100644 --- a/src/main-api/ICoreDriver.ts +++ b/src/main-api/ICoreDriver.ts @@ -17,6 +17,7 @@ * limitations under the License. */ +import type { FpsUpdatePayload } from '../common/CommonTypes.js'; import type { INode, INodeWritableProps, @@ -56,5 +57,5 @@ export interface ICoreDriver { onBeforeDestroyNode(node: INode): void; - onFpsUpdate(fps: number): void; + onFpsUpdate(fpsData: FpsUpdatePayload): void; } diff --git a/src/main-api/RendererMain.ts b/src/main-api/RendererMain.ts index 7cde8f06..256cd86f 100644 --- a/src/main-api/RendererMain.ts +++ b/src/main-api/RendererMain.ts @@ -211,6 +211,21 @@ export interface RendererMainSettings { * @defaultValue `0` (disabled) */ fpsUpdateInterval?: number; + + /** + * Include context call (i.e. WebGL) information in FPS updates + * + * @remarks + * When enabled the number of calls to each context method over the + * `fpsUpdateInterval` will be included in the FPS update payload's + * `contextSpyData` property. + * + * Enabling the context spy has a serious impact on performance so only use it + * when you need to extract context call information. + * + * @defaultValue `false` (disabled) + */ + enableContextSpy?: boolean; } /** @@ -278,6 +293,7 @@ export class RendererMain extends EventEmitter { settings.experimental_FinalizationRegistryTextureUsageTracker ?? false, textureCleanupOptions: settings.textureCleanupOptions || {}, fpsUpdateInterval: settings.fpsUpdateInterval || 0, + enableContextSpy: settings.enableContextSpy ?? false, }; this.settings = resolvedSettings; @@ -335,8 +351,8 @@ export class RendererMain extends EventEmitter { this.nodes.delete(node.id); }; - driver.onFpsUpdate = (fps) => { - this.emit('fpsUpdate', fps); + driver.onFpsUpdate = (fpsData) => { + this.emit('fpsUpdate', fpsData); }; targetEl.appendChild(canvas); diff --git a/src/render-drivers/main/MainCoreDriver.ts b/src/render-drivers/main/MainCoreDriver.ts index d6441437..7bd1ebf7 100644 --- a/src/render-drivers/main/MainCoreDriver.ts +++ b/src/render-drivers/main/MainCoreDriver.ts @@ -25,13 +25,14 @@ import type { ITextNodeWritableProps, } from '../../main-api/INode.js'; import { MainOnlyNode, getNewId } from './MainOnlyNode.js'; -import { Stage } from '../../core/Stage.js'; +import { Stage, type StageFpsUpdateHandler } from '../../core/Stage.js'; import type { RendererMain, RendererMainSettings, } from '../../main-api/RendererMain.js'; import { MainOnlyTextNode } from './MainOnlyTextNode.js'; import { loadCoreExtension } from '../utils.js'; +import type { FpsUpdatePayload } from '../../common/CommonTypes.js'; export class MainCoreDriver implements ICoreDriver { private root: MainOnlyNode | null = null; @@ -52,6 +53,7 @@ export class MainCoreDriver implements ICoreDriver { clearColor: rendererSettings.clearColor, canvas, fpsUpdateInterval: rendererSettings.fpsUpdateInterval, + enableContextSpy: rendererSettings.enableContextSpy, debug: { monitorTextureCache: false, }, @@ -74,9 +76,9 @@ export class MainCoreDriver implements ICoreDriver { } // Forward fpsUpdate events from the stage to RendererMain - this.stage.on('fpsUpdate', (stage: Stage, fps: number) => { - this.onFpsUpdate(fps); - }); + this.stage.on('fpsUpdate', ((stage, fpsData) => { + this.onFpsUpdate(fpsData); + }) satisfies StageFpsUpdateHandler); } createNode(props: INodeWritableProps): INode { @@ -123,7 +125,7 @@ export class MainCoreDriver implements ICoreDriver { throw new Error('Method not implemented.'); } - onFpsUpdate(fps: number) { + onFpsUpdate(fpsData: FpsUpdatePayload) { throw new Error('Method not implemented.'); } //#endregion diff --git a/src/render-drivers/threadx/ThreadXCoreDriver.ts b/src/render-drivers/threadx/ThreadXCoreDriver.ts index ef54fd11..062332a6 100644 --- a/src/render-drivers/threadx/ThreadXCoreDriver.ts +++ b/src/render-drivers/threadx/ThreadXCoreDriver.ts @@ -42,6 +42,7 @@ import { type TextNodeStructWritableProps, } from './TextNodeStruct.js'; import { ThreadXMainTextNode } from './ThreadXMainTextNode.js'; +import type { FpsUpdatePayload } from '../../common/CommonTypes.js'; export interface ThreadXRendererSettings { coreWorkerUrl: string; @@ -79,7 +80,7 @@ export class ThreadXCoreDriver implements ICoreDriver { onMessage: async (message) => { // Forward fpsUpdate events from the renderer worker's Stage to RendererMain if (isThreadXRendererMessage('fpsUpdate', message)) { - this.onFpsUpdate(message.fps); + this.onFpsUpdate(message.fpsData); } }, }); @@ -108,6 +109,7 @@ export class ThreadXCoreDriver implements ICoreDriver { clearColor: rendererSettings.clearColor, coreExtensionModule: rendererSettings.coreExtensionModule, fpsUpdateInterval: rendererSettings.fpsUpdateInterval, + enableContextSpy: rendererSettings.enableContextSpy, } satisfies ThreadXRendererInitMessage, [offscreenCanvas], )) as number; @@ -258,7 +260,7 @@ export class ThreadXCoreDriver implements ICoreDriver { throw new Error('Method not implemented.'); } - onFpsUpdate(fps: number): void { + onFpsUpdate(fps: FpsUpdatePayload): void { throw new Error('Method not implemented.'); } //#endregion diff --git a/src/render-drivers/threadx/ThreadXRendererMessage.ts b/src/render-drivers/threadx/ThreadXRendererMessage.ts index 66e41f0e..9c52a028 100644 --- a/src/render-drivers/threadx/ThreadXRendererMessage.ts +++ b/src/render-drivers/threadx/ThreadXRendererMessage.ts @@ -17,6 +17,8 @@ * limitations under the License. */ +import type { FpsUpdatePayload } from '../../common/CommonTypes.js'; + /** * @module * @description @@ -43,6 +45,7 @@ export interface ThreadXRendererInitMessage extends ThreadXRendererMessage { devicePhysicalPixelRatio: number; clearColor: number; fpsUpdateInterval: number; + enableContextSpy: boolean; coreExtensionModule: string | null; } @@ -62,7 +65,7 @@ export interface ThreadXRendererReleaseTextureMessage export interface ThreadXRendererFpsUpdateMessage extends ThreadXRendererMessage { type: 'fpsUpdate'; - fps: number; + fpsData: FpsUpdatePayload; } /** diff --git a/src/render-drivers/threadx/worker/renderer.ts b/src/render-drivers/threadx/worker/renderer.ts index df57e3b2..913286e8 100644 --- a/src/render-drivers/threadx/worker/renderer.ts +++ b/src/render-drivers/threadx/worker/renderer.ts @@ -20,7 +20,7 @@ import { ThreadX, BufferStruct } from '@lightningjs/threadx'; import { NodeStruct, type NodeStructWritableProps } from '../NodeStruct.js'; import { ThreadXRendererNode } from './ThreadXRendererNode.js'; -import { Stage } from '../../../core/Stage.js'; +import { Stage, type StageFpsUpdateHandler } from '../../../core/Stage.js'; import { assertTruthy } from '../../../utils.js'; import { isThreadXRendererMessage, @@ -71,6 +71,7 @@ const threadx = ThreadX.init({ clearColor: message.clearColor, canvas, fpsUpdateInterval: message.fpsUpdateInterval, + enableContextSpy: message.enableContextSpy, debug: { monitorTextureCache: false, }, @@ -120,12 +121,12 @@ const threadx = ThreadX.init({ } // Forward FPS updates to the main worker. - stage.on('fpsUpdate', (stage: Stage, fps: number) => { + stage.on('fpsUpdate', ((stage, fpsData) => { threadx.sendMessage('parent', { type: 'fpsUpdate', - fps, + fpsData: fpsData, } satisfies ThreadXRendererFpsUpdateMessage); - }); + }) satisfies StageFpsUpdateHandler); // Return its ID so the main worker can retrieve it from the shared object // store. diff --git a/src/utils.ts b/src/utils.ts index 10758aab..1b277cf9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,9 +17,12 @@ * limitations under the License. */ +import type { ContextSpy } from './core/lib/ContextSpy.js'; + export function createWebGLContext( canvas: HTMLCanvasElement | OffscreenCanvas, -): WebGLRenderingContext | null { + contextSpy: ContextSpy | null, +): WebGLRenderingContext { const config: WebGLContextAttributes = { alpha: true, antialias: false, @@ -32,15 +35,32 @@ export function createWebGLContext( premultipliedAlpha: true, preserveDrawingBuffer: false, }; - return ( + const gl = // TODO: Remove this assertion once this issue is fixed in TypeScript // https://github.com/microsoft/TypeScript/issues/53614 (canvas.getContext('webgl', config) || canvas.getContext( 'experimental-webgl' as 'webgl', config, - )) as unknown as WebGLRenderingContext | null - ); + )) as unknown as WebGLRenderingContext | null; + if (!gl) { + throw new Error('Unable to create WebGL context'); + } + if (contextSpy) { + // Proxy the GL context to log all GL calls + return new Proxy(gl, { + get(target, prop) { + const value = target[prop as never] as unknown; + if (typeof value === 'function') { + contextSpy.increment(String(prop)); + return value.bind(target); + } + return value; + }, + }); + } + + return gl; } /**