playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
479 lines (478 loc) • 15.9 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 = /* @__PURE__ */ new Set([
"physicsTime",
"animUpdate",
"gsplatSort"
]);
class MiniStats {
constructor(app, options = MiniStats.getDefaultOptions()) {
const device = app.graphicsDevice;
this.graphRows = /* @__PURE__ */ 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;
});
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 = /* @__PURE__ */ new Map();
this.cpuTimingMinSize = cpuTimingMinSize;
this.cpuGraphs = /* @__PURE__ */ new Map();
this.vramTimingMinSize = vramTimingMinSize;
this.vramGraphs = /* @__PURE__ */ new Map();
this.frameIndex = 0;
this.textRefreshRate = options.textRefreshRate;
this.activeSizeIndex = this._activeSizeIndex;
}
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 statPresets = {
gsplats: [
{ name: "GSplats", stats: ["frame.gsplats"], decimalPlaces: 3, multiplier: 1 / 1e6, unitsName: "M", watermark: 10 }
],
gsplatsCopy: [
{ name: "GsplatsCopy", stats: ["frame.gsplatBufferCopy"], decimalPlaces: 1, multiplier: 1, unitsName: "%", watermark: 100 }
]
};
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: 1e3
},
// 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;
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;
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,
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 !== void 0) {
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++;
}
}
export {
MiniStats
};