playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
523 lines (520 loc) • 16.7 kB
JavaScript
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';
const cpuStatDisplayNames = {
animUpdate: 'anim',
physicsTime: 'physics',
renderTime: 'render',
gsplatSort: 'gsplatSort'
};
const delayedStartStats = new Set([
'physicsTime',
'animUpdate',
'gsplatSort'
]);
class MiniStats {
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();
}
static getDefaultOptions(extraStats = []) {
const options = {
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
}
],
startSizeIndex: 0,
textRefreshRate: 500,
cpu: {
enabled: true,
watermark: 33
},
gpu: {
enabled: true,
watermark: 33
},
stats: [
{
name: 'Frame',
stats: [
'frame.ms'
],
decimalPlaces: 1,
unitsName: 'ms',
watermark: 33
},
{
name: 'DrawCalls',
stats: [
'drawCalls.total'
],
watermark: 1000
},
{
name: 'VRAM',
stats: [
'vram.totalUsed'
],
decimalPlaces: 1,
multiplier: 1 / (1024 * 1024),
unitsName: 'MB',
watermark: 1024
}
],
gpuTimingMinSize: 1,
cpuTimingMinSize: 1,
vramTimingMinSize: 1
};
if (extraStats.length > 0) {
const frameIndex = options.stats.findIndex((s)=>s.name === 'Frame');
const insertIndex = frameIndex !== -1 ? frameIndex + 1 : options.stats.length;
const extra = extraStats.flatMap((name)=>MiniStats.statPresets[name] ?? []).reverse();
options.stats.splice(insertIndex, 0, ...extra);
}
return options;
}
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);
this.opacity = value > 0 ? 0.85 : 0.7;
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);
}
}
get activeSizeIndex() {
return this._activeSizeIndex;
}
set opacity(value) {
this.clr[3] = value;
}
get opacity() {
return this.clr[3];
}
get overallHeight() {
const graphs = this.graphs;
const spacing = this.gspacing;
return this.height * graphs.length + spacing * (graphs.length - 1);
}
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;
}
}
}
get enabled() {
return this._enabled;
}
initGraphs(app, device, options) {
this.graphs = [];
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() {
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);
graph.render(render2d, 0, y, width, height);
let x = 1;
y += height - 13;
x += wordAtlas.render(render2d, graph.name, x, y) + 10;
const timingText = graph.timingText;
for(let j = 0; j < timingText.length; ++j){
x += wordAtlas.render(render2d, timingText[j], x, y);
}
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);
}
}
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(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();
}
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`;
}
loseContext() {
this.graphs.forEach((graph)=>graph.loseContext());
}
updateSubStats(subGraphs, mainGraphName, stats, statPathPrefix, removeAfterFrames) {
const passesToRemove = [];
for (const [statName, statData] of subGraphs){
const timing = stats instanceof Map ? stats.get(statName) || 0 : stats[statName] || 0;
if (timing > 0) {
statData.lastNonZeroFrame = this.frameIndex;
} else if (removeAfterFrames > 0) {
const shouldAutoHide = statPathPrefix === 'gpu';
if (shouldAutoHide && this.frameIndex - statData.lastNonZeroFrame > removeAfterFrames) {
passesToRemove.push(statName);
}
}
}
for (const statName of passesToRemove){
const statData = subGraphs.get(statName);
if (statData) {
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);
}
}
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)) {
const isDelayedStart = statPathPrefix === 'gpu' || delayedStartStats.has(statName);
if (isDelayedStart && timing === 0) {
continue;
}
let displayName = statName;
if (statPathPrefix === 'frame') {
displayName = cpuStatDisplayNames[statName] || statName;
}
const graphName = ` ${displayName}`;
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);
if (statPathPrefix === 'gpu') {
graph.graphType = 0.33;
} else if (statPathPrefix === 'frame') {
graph.graphType = 0.66;
}
graph.texture = this.texture;
this.allocateRow(graph);
const currentSize = this.sizes[this._activeSizeIndex];
graph.enabled = currentSize.graphs;
let mainGraphIndex = this.graphs.findIndex((g)=>g.name === mainGraphName);
if (mainGraphIndex === -1) {
mainGraphIndex = 0;
}
let insertIndex = mainGraphIndex;
for(let i = mainGraphIndex - 1; i >= 0; i--){
if (this.graphs[i].name.startsWith(' ')) {
insertIndex = i;
} else {
break;
}
}
this.graphs.splice(insertIndex, 0, graph);
subGraphs.set(statName, {
graph: graph,
lastNonZeroFrame: timing > 0 ? this.frameIndex : this.frameIndex - removeAfterFrames - 1
});
}
}
if (mainGraph) {
for (const statData of subGraphs.values()){
statData.graph.watermark = mainGraph.watermark;
}
}
}
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;
return row;
}
freeRow(graph) {
const row = this.graphRows.get(graph);
if (row !== undefined) {
this.freeRows.push(row);
this.graphRows.delete(graph);
}
}
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;
}
}
ensureTextureHeight(requiredRows) {
const maxWidth = this.sizes[this.sizes.length - 1].width;
const requiredWidth = math.nextPowerOfTwo(maxWidth);
const requiredHeight = math.nextPowerOfTwo(requiredRows);
if (requiredHeight > this.texture.height) {
this.texture.resize(requiredWidth, requiredHeight);
}
}
postRender() {
if (this._enabled) {
this.render();
if (this._activeSizeIndex >= this.gpuTimingMinSize) {
const gpuStats = this.app.stats.gpu;
if (gpuStats) {
this.updateSubStats(this.gpuPassGraphs, 'GPU', gpuStats, 'gpu', 240);
}
}
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);
}
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++;
}
constructor(app, options = MiniStats.getDefaultOptions()){
const device = app.graphicsDevice;
this.graphRows = new Map();
this.freeRows = [];
this.nextRowIndex = 0;
this.sizes = options.sizes;
this.initGraphs(app, device, options);
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));
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;
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;
}
}
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)=>{
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;
this.clr = [
1,
1,
1,
options.startSizeIndex > 0 ? 0.85 : 0.7
];
this._enabled = true;
this.gpuTimingMinSize = gpuTimingMinSize;
this.gpuPassGraphs = new Map();
this.cpuTimingMinSize = cpuTimingMinSize;
this.cpuGraphs = new Map();
this.vramTimingMinSize = vramTimingMinSize;
this.vramGraphs = new Map();
this.frameIndex = 0;
this.textRefreshRate = options.textRefreshRate;
this.activeSizeIndex = this._activeSizeIndex;
}
}
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 };