UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

766 lines (763 loc) 31.1 kB
import { math } from '../../core/math/math.js'; import { Texture } from '../../platform/graphics/texture.js'; import { ADDRESS_REPEAT, FILTER_NEAREST } from '../../platform/graphics/constants.js'; import { LAYERID_UI } from '../../scene/constants.js'; import { CpuTimer } from './cpu-timer.js'; import { GpuTimer } from './gpu-timer.js'; import { StatsTimer } from './stats-timer.js'; import { Graph } from './graph.js'; import { WordAtlas } from './word-atlas.js'; import { Render2d } from './render2d.js'; /** * @import { AppBase } from '../../framework/app-base.js' * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' */ // CPU stat name mappings: full property name -> shortened display name const cpuStatDisplayNames = { animUpdate: 'anim', physicsTime: 'physics', renderTime: 'render', gsplatSort: 'gsplatSort' }; // CPU stats with delayed creation (only shown once non-zero, but never removed) const delayedStartStats = new Set([ 'physicsTime', 'animUpdate', 'gsplatSort' ]); /** * @typedef {object} MiniStatsSizeOptions * @property {number} width - Width of the graph area. * @property {number} height - Height of the graph area. * @property {number} spacing - Spacing between graphs. * @property {boolean} graphs - Whether to show graphs. */ /** * @typedef {object} MiniStatsProcessorOptions * @property {boolean} enabled - Whether to show the graph. * @property {number} watermark - Watermark - shown as a line on the graph, useful for displaying a * budget. */ /** * @typedef {object} MiniStatsGraphOptions * @property {string} name - Display name. * @property {string[]} stats - Path to data inside Application.stats. * @property {number} [decimalPlaces] - Number of decimal places (defaults to none). * @property {string} [unitsName] - Units (defaults to ""). * @property {number} [watermark] - Watermark - shown as a line on the graph, useful for displaying * a budget. */ /** * @typedef {object} MiniStatsOptions * @property {MiniStatsSizeOptions[]} sizes - Sizes of area to render individual graphs in and * spacing between individual graphs. * @property {number} startSizeIndex - Index into sizes array for initial setting. * @property {number} textRefreshRate - Refresh rate of text stats in ms. * @property {MiniStatsProcessorOptions} cpu - CPU graph options. * @property {MiniStatsProcessorOptions} gpu - GPU graph options. * @property {MiniStatsGraphOptions[]} stats - Array of options to render additional graphs based * on stats collected into Application.stats. * @property {number} [gpuTimingMinSize] - Minimum size index at which to show GPU pass timing * graphs. Defaults to 1. * @property {number} [cpuTimingMinSize] - Minimum size index at which to show CPU sub-timing * graphs (script, anim, physics, render). Defaults to 1. * @property {number} [vramTimingMinSize] - Minimum size index at which to show VRAM subcategory * graphs. Defaults to 1. */ /** * MiniStats is a small graphical overlay that displays realtime performance metrics. By default, * it shows CPU and GPU utilization, frame timings and draw call count. It can also be configured * to display additional graphs based on data collected into {@link AppBase#stats}. */ class MiniStats { /** * Destroy the MiniStats instance. * * @example * miniStats.destroy(); */ destroy() { this.device.off('resizecanvas', this.updateDiv, this); this.device.off('losecontext', this.loseContext, this); this.app.off('postrender', this.postRender, this); this.graphs.forEach((graph)=>graph.destroy()); this.gpuPassGraphs.clear(); this.cpuGraphs.clear(); this.vramGraphs.clear(); this.wordAtlas.destroy(); this.texture.destroy(); this.div.remove(); } /** * Returns the default options for MiniStats. The default options configure the overlay to * show the following graphs: * * - CPU utilization * - GPU utilization * - Overall frame time * - Draw call count * - Total VRAM usage * * @param {string[]} [extraStats] - Optional array of preset names from * {@link MiniStats.statPresets} to include. The preset stats are inserted after the 'Frame' * entry. Can be: 'gsplats', 'gsplatsCopy'. * @returns {object} The default options for MiniStats. * @example * // default options without extra stats * const options = pc.MiniStats.getDefaultOptions(); * @example * // include gsplat stats * const options = pc.MiniStats.getDefaultOptions(['gsplats', 'gsplatsCopy']); */ static getDefaultOptions(extraStats = []) { const options = { // sizes of area to render individual graphs in and spacing between individual graphs sizes: [ { width: 100, height: 16, spacing: 0, graphs: false }, { width: 128, height: 32, spacing: 2, graphs: true }, { width: 256, height: 64, spacing: 2, graphs: true } ], // index into sizes array for initial setting startSizeIndex: 0, // refresh rate of text stats in ms textRefreshRate: 500, // cpu graph options cpu: { enabled: true, watermark: 33 }, // gpu graph options gpu: { enabled: true, watermark: 33 }, // array of options to render additional graphs based on stats collected into Application.stats stats: [ { // display name name: 'Frame', // path to data inside Application.stats stats: [ 'frame.ms' ], // number of decimal places (defaults to none) decimalPlaces: 1, // units (defaults to "") unitsName: 'ms', // watermark - shown as a line on the graph, useful for displaying a budget watermark: 33 }, // total number of draw calls { name: 'DrawCalls', stats: [ 'drawCalls.total' ], watermark: 1000 }, // used VRAM in MB { name: 'VRAM', stats: [ 'vram.totalUsed' ], decimalPlaces: 1, multiplier: 1 / (1024 * 1024), unitsName: 'MB', watermark: 1024 } ], // minimum size index to show GPU pass timing graphs gpuTimingMinSize: 1, // minimum size index to show CPU sub-timing graphs cpuTimingMinSize: 1, // minimum size index to show VRAM subcategory graphs vramTimingMinSize: 1 }; if (extraStats.length > 0) { const frameIndex = options.stats.findIndex((s)=>s.name === 'Frame'); const insertIndex = frameIndex !== -1 ? frameIndex + 1 : options.stats.length; // reverse so user-specified order matches visual top-to-bottom order const extra = extraStats.flatMap((name)=>MiniStats.statPresets[name] ?? []).reverse(); options.stats.splice(insertIndex, 0, ...extra); } return options; } /** * Sets the active size index. Setting the active size index will resize the overlay to the * size specified by the corresponding entry in the sizes array. * * @type {number} * @ignore */ set activeSizeIndex(value) { this._activeSizeIndex = value; this.gspacing = this.sizes[value].spacing; this.resize(this.sizes[value].width, this.sizes[value].height, this.sizes[value].graphs); // update opacity based on size (larger sizes have higher default opacity) this.opacity = value > 0 ? 0.85 : 0.7; // delete sub-stat graphs when switching below their thresholds if (value < this.gpuTimingMinSize && this.gpuPassGraphs) { this.clearSubGraphs(this.gpuPassGraphs, 'GPU', 0.33); } if (value < this.cpuTimingMinSize && this.cpuGraphs) { this.clearSubGraphs(this.cpuGraphs, 'CPU', 0.66); } if (value < this.vramTimingMinSize && this.vramGraphs) { this.clearSubGraphs(this.vramGraphs); } } /** * Gets the active size index. * * @type {number} * @ignore */ get activeSizeIndex() { return this._activeSizeIndex; } /** * Sets the opacity of the MiniStats overlay. * * @type {number} * @ignore */ set opacity(value) { this.clr[3] = value; } /** * Gets the opacity of the MiniStats overlay. * * @type {number} * @ignore */ get opacity() { return this.clr[3]; } /** * Gets the overall height of the MiniStats overlay. * * @type {number} * @ignore */ get overallHeight() { const graphs = this.graphs; const spacing = this.gspacing; return this.height * graphs.length + spacing * (graphs.length - 1); } /** * Sets the enabled state of the MiniStats overlay. * * @type {boolean} */ set enabled(value) { if (value !== this._enabled) { this._enabled = value; for(let i = 0; i < this.graphs.length; ++i){ this.graphs[i].enabled = value; this.graphs[i].timer.enabled = value; } } } /** * Gets the enabled state of the MiniStats overlay. * * @type {boolean} */ get enabled() { return this._enabled; } /** * Create the graphs requested by the user and add them to the MiniStats instance. * * @param {AppBase} app - The application. * @param {GraphicsDevice} device - The graphics device. * @param {object} options - Options for the MiniStats instance. * @private */ initGraphs(app, device, options) { this.graphs = []; // Add VRAM first so it appears at the bottom in the compact stacked view. // Graphs are rendered bottom-to-top. if (options.stats) { options.stats.forEach((entry)=>{ if (entry.name === 'VRAM') { const timer = new StatsTimer(app, entry.stats, entry.decimalPlaces, entry.unitsName, entry.multiplier); const graph = new Graph(entry.name, app, entry.watermark, options.textRefreshRate, timer); this.graphs.push(graph); } }); } if (options.cpu.enabled) { const timer = new CpuTimer(app); const graph = new Graph('CPU', app, options.cpu.watermark, options.textRefreshRate, timer); graph.graphType = 0.66; this.graphs.push(graph); } if (options.gpu.enabled) { const timer = new GpuTimer(device); const graph = new Graph('GPU', app, options.gpu.watermark, options.textRefreshRate, timer); graph.graphType = 0.33; this.graphs.push(graph); } if (options.stats) { options.stats.forEach((entry)=>{ if (entry.name === 'VRAM') { return; } const timer = new StatsTimer(app, entry.stats, entry.decimalPlaces, entry.unitsName, entry.multiplier); const graph = new Graph(entry.name, app, entry.watermark, options.textRefreshRate, timer); this.graphs.push(graph); }); } this.texture = new Texture(device, { name: 'mini-stats-graph-texture', width: 1, height: 1, mipmaps: false, minFilter: FILTER_NEAREST, magFilter: FILTER_NEAREST, addressU: ADDRESS_REPEAT, addressV: ADDRESS_REPEAT }); this.graphs.forEach((graph)=>{ graph.texture = this.texture; this.allocateRow(graph); }); } /** * Render the MiniStats overlay. This is called automatically when the `postrender` event is * fired by the application. * * @private */ render() { const graphs = this.graphs; const wordAtlas = this.wordAtlas; const render2d = this.render2d; const width = this.width; const height = this.height; const gspacing = this.gspacing; render2d.startFrame(); for(let i = 0; i < graphs.length; ++i){ const graph = graphs[i]; let y = i * (height + gspacing); // render the graph graph.render(render2d, 0, y, width, height); // render the text let x = 1; y += height - 13; // name + space x += wordAtlas.render(render2d, graph.name, x, y) + 10; // timing (average value) const timingText = graph.timingText; for(let j = 0; j < timingText.length; ++j){ x += wordAtlas.render(render2d, timingText[j], x, y); } // max value (only on larger sizes) if (graph.maxText && this._activeSizeIndex > 0) { x += 5; x += wordAtlas.render(render2d, 'max', x, y); x += 5; const maxText = graph.maxText; for(let j = 0; j < maxText.length; ++j){ x += wordAtlas.render(render2d, maxText[j], x, y); } } // units (at the end, after both average and max) if (graph.timer.unitsName) { x += wordAtlas.render(render2d, graph.timer.unitsName, x, y); } } render2d.render(this.app, this.drawLayer, this.texture, this.wordAtlas.texture, this.clr, height); } /** * Resize the MiniStats overlay. * * @param {number} width - The new width. * @param {number} height - The new height. * @param {boolean} showGraphs - Whether to show the graphs. * @private */ resize(width, height, showGraphs) { const graphs = this.graphs; for(let i = 0; i < graphs.length; ++i){ graphs[i].enabled = showGraphs; } this.width = width; this.height = height; this.updateDiv(); } /** * Update the size and position of the MiniStats overlay. This is called automatically when the * `resizecanvas` event is fired by the graphics device. * * @private */ updateDiv() { const rect = this.device.canvas.getBoundingClientRect(); this.div.style.left = `${rect.left}px`; this.div.style.bottom = `${window.innerHeight - rect.bottom}px`; this.div.style.width = `${this.width}px`; this.div.style.height = `${this.overallHeight}px`; } /** * Called when the graphics device is lost. * * @private */ loseContext() { this.graphs.forEach((graph)=>graph.loseContext()); } /** * Update sub-stat graphs (GPU passes or CPU timings). * @param {Map} subGraphs - Map to store graph data (gpuPassGraphs or cpuGraphs) * @param {string} mainGraphName - Name of main graph ('GPU' or 'CPU') * @param {Map<string,number>|Object} stats - Stats data (Map for GPU, object for CPU) * @param {string} statPathPrefix - Prefix for stat path ('gpu' for GPU, 'frame' for CPU) * @param {number} removeAfterFrames - Frames of zero before removal * @private */ updateSubStats(subGraphs, mainGraphName, stats, statPathPrefix, removeAfterFrames) { const passesToRemove = []; // check existing sub-stats for removal for (const [statName, statData] of subGraphs){ const timing = stats instanceof Map ? stats.get(statName) || 0 : stats[statName] || 0; if (timing > 0) { // update last non-zero frame statData.lastNonZeroFrame = this.frameIndex; } else if (removeAfterFrames > 0) { // Only GPU passes auto-hide; CPU stats are never removed const shouldAutoHide = statPathPrefix === 'gpu'; if (shouldAutoHide && this.frameIndex - statData.lastNonZeroFrame > removeAfterFrames) { passesToRemove.push(statName); } } } // remove stats that have been zero for too long for (const statName of passesToRemove){ const statData = subGraphs.get(statName); if (statData) { // remove from graphs array const index = this.graphs.indexOf(statData.graph); if (index !== -1) { this.graphs.splice(index, 1); } this.freeRow(statData.graph); statData.graph.destroy(); subGraphs.delete(statName); } } // scan for new sub-stats const statsEntries = stats instanceof Map ? stats : Object.entries(stats); const mainGraph = this.graphs.find((g)=>g.name === mainGraphName); for (const [statName, timing] of statsEntries){ if (!subGraphs.has(statName)) { // Skip creating graph for auto-hide stats with zero timing // Skip creating graph for GPU passes or delayed-start CPU stats with zero timing const isDelayedStart = statPathPrefix === 'gpu' || delayedStartStats.has(statName); if (isDelayedStart && timing === 0) { continue; } // create new graph for this stat // shorten display name for CPU stats let displayName = statName; if (statPathPrefix === 'frame') { displayName = cpuStatDisplayNames[statName] || statName; } const graphName = ` ${displayName}`; // indent with 2 spaces // use main graph watermark when available const watermark = mainGraph?.watermark ?? 10.0; const decimalPlaces = 1; const unitsName = statPathPrefix === 'vram' ? 'MB' : 'ms'; const multiplier = statPathPrefix === 'vram' ? 1 / (1024 * 1024) : 1; const statPath = `${statPathPrefix}.${statName}`; const timer = new StatsTimer(this.app, [ statPath ], decimalPlaces, unitsName, multiplier); const graph = new Graph(graphName, this.app, watermark, this.textRefreshRate, timer); // Set graph type for background tinting if (statPathPrefix === 'gpu') { graph.graphType = 0.33; // GPU sub-graphs } else if (statPathPrefix === 'frame') { graph.graphType = 0.66; // CPU sub-graphs } graph.texture = this.texture; this.allocateRow(graph); // match the current display mode const currentSize = this.sizes[this._activeSizeIndex]; graph.enabled = currentSize.graphs; // find the main graph index and insert before it (graphs render bottom to top) let mainGraphIndex = this.graphs.findIndex((g)=>g.name === mainGraphName); if (mainGraphIndex === -1) { mainGraphIndex = 0; // fallback to start if main graph not found } // find where to insert - right before the main graph, after any existing sub-stats let insertIndex = mainGraphIndex; for(let i = mainGraphIndex - 1; i >= 0; i--){ // check if this is an indented sub-stat (starts with spaces) if (this.graphs[i].name.startsWith(' ')) { insertIndex = i; } else { break; } } // insert the new graph at the correct position this.graphs.splice(insertIndex, 0, graph); subGraphs.set(statName, { graph: graph, lastNonZeroFrame: timing > 0 ? this.frameIndex : this.frameIndex - removeAfterFrames - 1 }); } } // sync all sub-stat watermarks to match main graph if (mainGraph) { for (const statData of subGraphs.values()){ statData.graph.watermark = mainGraph.watermark; } } } /** * Allocates a texture row for a graph. Reuses free rows when available. * * @param {Graph} graph - The graph to allocate a row for. * @returns {number} The allocated row index. * @private */ allocateRow(graph) { let row; if (this.freeRows.length > 0) { row = this.freeRows.pop(); } else { row = this.nextRowIndex++; this.ensureTextureHeight(this.nextRowIndex); } this.graphRows.set(graph, row); graph.yOffset = row; graph.needsClear = true; // Will clear on first update() return row; } /** * Frees a texture row when a graph is destroyed. * * @param {Graph} graph - The graph whose row to free. * @private */ freeRow(graph) { const row = this.graphRows.get(graph); if (row !== undefined) { this.freeRows.push(row); this.graphRows.delete(graph); } } /** * Remove all sub-stat graphs from a tracking map when collapsing below a size threshold. * * @param {Map} subGraphs - The sub-graph map to clear. * @param {string} [mainGraphName] - If provided, reset the main graph's graphType. * @param {number} [graphType] - The graphType value to restore on the main graph. * @private */ clearSubGraphs(subGraphs, mainGraphName, graphType) { for (const statData of subGraphs.values()){ const index = this.graphs.indexOf(statData.graph); if (index !== -1) { this.graphs.splice(index, 1); } this.freeRow(statData.graph); statData.graph.destroy(); } subGraphs.clear(); if (mainGraphName) { const mainGraph = this.graphs.find((g)=>g.name === mainGraphName); if (mainGraph) mainGraph.graphType = graphType; } } /** * Ensures the texture has enough rows. Only grows, never shrinks. * * @param {number} requiredRows - The minimum number of rows needed. * @private */ ensureTextureHeight(requiredRows) { const maxWidth = this.sizes[this.sizes.length - 1].width; const requiredWidth = math.nextPowerOfTwo(maxWidth); const requiredHeight = math.nextPowerOfTwo(requiredRows); // Only grow, never shrink if (requiredHeight > this.texture.height) { this.texture.resize(requiredWidth, requiredHeight); } } /** * Called when the `postrender` event is fired by the application. * * @private */ postRender() { if (this._enabled) { this.render(); // Update GPU pass graphs when size index meets threshold if (this._activeSizeIndex >= this.gpuTimingMinSize) { const gpuStats = this.app.stats.gpu; if (gpuStats) { this.updateSubStats(this.gpuPassGraphs, 'GPU', gpuStats, 'gpu', 240); } } // Update CPU sub-timing graphs when size index meets threshold if (this._activeSizeIndex >= this.cpuTimingMinSize) { const cpuStats = { scriptUpdate: this.app.stats.frame.scriptUpdate, scriptPostUpdate: this.app.stats.frame.scriptPostUpdate, animUpdate: this.app.stats.frame.animUpdate, physicsTime: this.app.stats.frame.physicsTime, renderTime: this.app.stats.frame.renderTime, gsplatSort: this.app.stats.frame.gsplatSort }; this.updateSubStats(this.cpuGraphs, 'CPU', cpuStats, 'frame', 240); } // Update VRAM subcategory graphs when size index meets threshold if (this._activeSizeIndex >= this.vramTimingMinSize) { const vram = this.app.stats.vram; const vramStats = { tex: vram.tex, geom: vram.geom }; if (this.device.isWebGPU) { vramStats.buffers = vram.buffers; } this.updateSubStats(this.vramGraphs, 'VRAM', vramStats, 'vram', 0); } } this.frameIndex++; } /** * Create a new MiniStats instance. * * @param {AppBase} app - The application. * @param {MiniStatsOptions} [options] - Options for the MiniStats instance. * @example * // create a new MiniStats instance using default options * const miniStats = new pc.MiniStats(app); */ constructor(app, options = MiniStats.getDefaultOptions()){ const device = app.graphicsDevice; // Persistent texture row allocation (must be initialized before initGraphs) this.graphRows = new Map(); // Map<Graph, rowIndex> this.freeRows = []; // Available rows for reuse this.nextRowIndex = 0; // Next new row to allocate // sizes must be set before initGraphs (needed by ensureTextureHeight) this.sizes = options.sizes; // create graphs this.initGraphs(app, device, options); // extract list of words const words = new Set([ '', 'ms', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '-', ' ' ].concat(this.graphs.map((graph)=>graph.name)).concat(options.stats ? options.stats.map((stat)=>stat.unitsName) : []).filter((item)=>!!item)); // always add lowercase and uppercase letters (needed for "max" display and GPU pass names) for(let i = 97; i <= 122; i++){ words.add(String.fromCharCode(i)); } for(let i = 65; i <= 90; i++){ words.add(String.fromCharCode(i)); } this.wordAtlas = new WordAtlas(device, words); this._activeSizeIndex = options.startSizeIndex; // if GPU pass tracking, CPU timing or VRAM detail is enabled, use the last width for medium/large sizes const gpuTimingMinSize = options.gpuTimingMinSize ?? 1; const cpuTimingMinSize = options.cpuTimingMinSize ?? 1; const vramTimingMinSize = options.vramTimingMinSize ?? 1; if (gpuTimingMinSize < this.sizes.length || cpuTimingMinSize < this.sizes.length || vramTimingMinSize < this.sizes.length) { const lastWidth = this.sizes[this.sizes.length - 1].width; for(let i = 1; i < this.sizes.length - 1; i++){ this.sizes[i].width = lastWidth; } } // create click region so we can resize const div = document.createElement('div'); div.setAttribute('id', 'mini-stats'); div.style.cssText = 'position:fixed;bottom:0;left:0;background:transparent;'; document.body.appendChild(div); div.addEventListener('mouseenter', (event)=>{ this.opacity = 1.0; }); div.addEventListener('mouseleave', (event)=>{ // larger sizes have higher default opacity this.opacity = this._activeSizeIndex > 0 ? 0.85 : 0.7; }); div.addEventListener('click', (event)=>{ event.preventDefault(); if (this._enabled) { this.activeSizeIndex = (this.activeSizeIndex + 1) % this.sizes.length; this.resize(this.sizes[this.activeSizeIndex].width, this.sizes[this.activeSizeIndex].height, this.sizes[this.activeSizeIndex].graphs); } }); device.on('resizecanvas', this.updateDiv, this); device.on('losecontext', this.loseContext, this); app.on('postrender', this.postRender, this); this.app = app; this.drawLayer = app.scene.layers.getLayerById(LAYERID_UI); this.device = device; this.render2d = new Render2d(device); this.div = div; this.width = 0; this.height = 0; this.gspacing = 2; // initial opacity depends on starting size this.clr = [ 1, 1, 1, options.startSizeIndex > 0 ? 0.85 : 0.7 ]; this._enabled = true; // GPU pass tracking this.gpuTimingMinSize = gpuTimingMinSize; this.gpuPassGraphs = new Map(); // Map<passName, { graph, lastNonZeroFrame }> // CPU sub-timing tracking this.cpuTimingMinSize = cpuTimingMinSize; this.cpuGraphs = new Map(); // Map<statName, { graph, lastNonZeroFrame }> // VRAM subcategory tracking this.vramTimingMinSize = vramTimingMinSize; this.vramGraphs = new Map(); // Map<statName, { graph, lastNonZeroFrame }> this.frameIndex = 0; this.textRefreshRate = options.textRefreshRate; // initial resize this.activeSizeIndex = this._activeSizeIndex; } } /** * Predefined stat groups that can be included via {@link MiniStats.getDefaultOptions}. Each * key maps to an array of {@link MiniStatsGraphOptions} entries that are inserted after the * 'Frame' stat in the default options. * * @type {Object<string, MiniStatsGraphOptions[]>} * @ignore */ MiniStats.statPresets = { gsplats: [ { name: 'GSplats', stats: [ 'frame.gsplats' ], decimalPlaces: 3, multiplier: 1 / 1000000, unitsName: 'M', watermark: 10 } ], gsplatsCopy: [ { name: 'GsplatsCopy', stats: [ 'frame.gsplatBufferCopy' ], decimalPlaces: 1, multiplier: 1, unitsName: '%', watermark: 100 } ] }; export { MiniStats };