501 lines
12 KiB
TypeScript
501 lines
12 KiB
TypeScript
import * as THREE from 'three';
|
|
import { Panel } from './panel';
|
|
|
|
interface StatsOptions {
|
|
trackGPU?: boolean;
|
|
logsPerSecond?: number;
|
|
samplesLog?: number;
|
|
samplesGraph?: number;
|
|
precision?: number;
|
|
minimal?: boolean;
|
|
horizontal?: boolean;
|
|
mode?: number;
|
|
}
|
|
|
|
interface QueryInfo {
|
|
query: WebGLQuery;
|
|
}
|
|
|
|
interface AverageData {
|
|
logs: number[];
|
|
graph: number[];
|
|
}
|
|
|
|
interface InfoData {
|
|
render: {
|
|
timestamp: number;
|
|
};
|
|
compute: {
|
|
timestamp: number;
|
|
};
|
|
}
|
|
|
|
class Stats {
|
|
private dom: HTMLDivElement;
|
|
private mode: number;
|
|
private horizontal: boolean;
|
|
private minimal: boolean;
|
|
private trackGPU: boolean;
|
|
private samplesLog: number;
|
|
private samplesGraph: number;
|
|
private precision: number;
|
|
private logsPerSecond: number;
|
|
|
|
private gl: WebGL2RenderingContext | null = null;
|
|
private ext: any | null = null;
|
|
private info?: InfoData;
|
|
private activeQuery: WebGLQuery | null = null;
|
|
private gpuQueries: QueryInfo[] = [];
|
|
private threeRendererPatched = false;
|
|
|
|
private beginTime: number;
|
|
private prevTime: number;
|
|
private prevCpuTime: number;
|
|
private frames = 0;
|
|
private renderCount = 0;
|
|
private isRunningCPUProfiling = false;
|
|
|
|
private totalCpuDuration = 0;
|
|
private totalGpuDuration = 0;
|
|
private totalGpuDurationCompute = 0;
|
|
private totalFps = 0;
|
|
|
|
private fpsPanel: Panel;
|
|
private msPanel: Panel;
|
|
private gpuPanel: Panel | null = null;
|
|
private gpuPanelCompute: Panel | null = null;
|
|
|
|
private averageFps: AverageData = { logs: [], graph: [] };
|
|
private averageCpu: AverageData = { logs: [], graph: [] };
|
|
private averageGpu: AverageData = { logs: [], graph: [] };
|
|
private averageGpuCompute: AverageData = { logs: [], graph: [] };
|
|
|
|
static Panel = Panel;
|
|
|
|
constructor({
|
|
trackGPU = false,
|
|
logsPerSecond = 30,
|
|
samplesLog = 60,
|
|
samplesGraph = 10,
|
|
precision = 2,
|
|
minimal = false,
|
|
horizontal = true,
|
|
mode = 0
|
|
}: StatsOptions = {}) {
|
|
this.mode = mode;
|
|
this.horizontal = horizontal;
|
|
this.minimal = minimal;
|
|
this.trackGPU = trackGPU;
|
|
this.samplesLog = samplesLog;
|
|
this.samplesGraph = samplesGraph;
|
|
this.precision = precision;
|
|
this.logsPerSecond = logsPerSecond;
|
|
|
|
// Initialize DOM
|
|
this.dom = document.createElement('div');
|
|
this.initializeDOM();
|
|
|
|
// Initialize timing
|
|
this.beginTime = performance.now();
|
|
this.prevTime = this.beginTime;
|
|
this.prevCpuTime = this.beginTime;
|
|
|
|
// Create panels
|
|
this.fpsPanel = this.addPanel(new Stats.Panel('FPS', '#0ff', '#002'), 0);
|
|
this.msPanel = this.addPanel(new Stats.Panel('CPU', '#0f0', '#020'), 1);
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
|
|
private initializeDOM(): void {
|
|
this.dom.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
opacity: 0.9;
|
|
z-index: 10000;
|
|
${this.minimal ? 'cursor: pointer;' : ''}
|
|
`;
|
|
}
|
|
|
|
private setupEventListeners(): void {
|
|
if (this.minimal) {
|
|
this.dom.addEventListener('click', this.handleClick);
|
|
this.showPanel(this.mode);
|
|
} else {
|
|
window.addEventListener('resize', this.handleResize);
|
|
}
|
|
}
|
|
|
|
private handleClick = (event: MouseEvent): void => {
|
|
event.preventDefault();
|
|
this.showPanel(++this.mode % this.dom.children.length);
|
|
};
|
|
|
|
private handleResize = (): void => {
|
|
this.resizePanel(this.fpsPanel, 0);
|
|
this.resizePanel(this.msPanel, 1);
|
|
if (this.gpuPanel) this.resizePanel(this.gpuPanel, 2);
|
|
if (this.gpuPanelCompute) this.resizePanel(this.gpuPanelCompute, 3);
|
|
};
|
|
|
|
public async init(
|
|
canvasOrGL: WebGL2RenderingContext | HTMLCanvasElement | OffscreenCanvas | any
|
|
): Promise<void> {
|
|
if (!canvasOrGL) {
|
|
console.error('Stats: The "canvas" parameter is undefined.');
|
|
return;
|
|
}
|
|
|
|
if (this.handleThreeRenderer(canvasOrGL)) return;
|
|
if (await this.handleWebGPURenderer(canvasOrGL)) return;
|
|
if (!this.initializeWebGL(canvasOrGL)) return;
|
|
|
|
}
|
|
|
|
private handleThreeRenderer(renderer: any): boolean {
|
|
if (renderer.isWebGLRenderer && !this.threeRendererPatched) {
|
|
this.patchThreeRenderer(renderer);
|
|
this.gl = renderer.getContext();
|
|
|
|
if (this.trackGPU) {
|
|
this.initializeGPUTracking();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async handleWebGPURenderer(renderer: any): Promise<boolean> {
|
|
if (renderer.isWebGPURenderer) {
|
|
if (this.trackGPU) {
|
|
renderer.backend.trackTimestamp = true;
|
|
if (await renderer.hasFeatureAsync('timestamp-query')) {
|
|
this.initializeWebGPUPanels();
|
|
}
|
|
}
|
|
this.info = renderer.info;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private initializeWebGPUPanels(): void {
|
|
this.gpuPanel = this.addPanel(new Stats.Panel('GPU', '#ff0', '#220'), 2);
|
|
this.gpuPanelCompute = this.addPanel(
|
|
new Stats.Panel('CPT', '#e1e1e1', '#212121'),
|
|
3
|
|
);
|
|
}
|
|
|
|
private initializeWebGL(
|
|
canvasOrGL: WebGL2RenderingContext | HTMLCanvasElement | OffscreenCanvas
|
|
): boolean {
|
|
if (canvasOrGL instanceof WebGL2RenderingContext) {
|
|
this.gl = canvasOrGL;
|
|
} else if (
|
|
canvasOrGL instanceof HTMLCanvasElement ||
|
|
canvasOrGL instanceof OffscreenCanvas
|
|
) {
|
|
this.gl = canvasOrGL.getContext('webgl2');
|
|
if (!this.gl) {
|
|
console.error('Stats: Unable to obtain WebGL2 context.');
|
|
return false;
|
|
}
|
|
} else {
|
|
console.error(
|
|
'Stats: Invalid input type. Expected WebGL2RenderingContext, HTMLCanvasElement, or OffscreenCanvas.'
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private initializeGPUTracking(): void {
|
|
if (this.gl) {
|
|
this.ext = this.gl.getExtension('EXT_disjoint_timer_query_webgl2');
|
|
if (this.ext) {
|
|
this.gpuPanel = this.addPanel(new Stats.Panel('GPU', '#ff0', '#220'), 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
public begin(): void {
|
|
if (!this.isRunningCPUProfiling) {
|
|
this.beginProfiling('cpu-started');
|
|
}
|
|
|
|
if (!this.gl || !this.ext) return;
|
|
|
|
if (this.activeQuery) {
|
|
this.gl.endQuery(this.ext.TIME_ELAPSED_EXT);
|
|
}
|
|
|
|
this.activeQuery = this.gl.createQuery();
|
|
if (this.activeQuery) {
|
|
this.gl.beginQuery(this.ext.TIME_ELAPSED_EXT, this.activeQuery);
|
|
}
|
|
}
|
|
|
|
public end(): void {
|
|
this.renderCount++;
|
|
if (this.gl && this.ext && this.activeQuery) {
|
|
this.gl.endQuery(this.ext.TIME_ELAPSED_EXT);
|
|
this.gpuQueries.push({ query: this.activeQuery });
|
|
this.activeQuery = null;
|
|
}
|
|
}
|
|
|
|
public update(): void {
|
|
if (!this.info) {
|
|
this.processGpuQueries();
|
|
} else {
|
|
this.processWebGPUTimestamps();
|
|
}
|
|
|
|
this.endProfiling('cpu-started', 'cpu-finished', 'cpu-duration');
|
|
this.updateAverages();
|
|
this.resetCounters();
|
|
}
|
|
|
|
private processWebGPUTimestamps(): void {
|
|
this.totalGpuDuration = this.info!.render.timestamp;
|
|
this.totalGpuDurationCompute = this.info!.compute.timestamp;
|
|
this.addToAverage(this.totalGpuDurationCompute, this.averageGpuCompute);
|
|
}
|
|
|
|
private updateAverages(): void {
|
|
this.addToAverage(this.totalCpuDuration, this.averageCpu);
|
|
this.addToAverage(this.totalGpuDuration, this.averageGpu);
|
|
}
|
|
|
|
private resetCounters(): void {
|
|
this.renderCount = 0;
|
|
if (this.totalCpuDuration === 0) {
|
|
this.beginProfiling('cpu-started');
|
|
}
|
|
this.totalCpuDuration = 0;
|
|
this.totalFps = 0;
|
|
this.beginTime = this.endInternal();
|
|
}
|
|
|
|
|
|
resizePanel(panel: Panel, offset: number) {
|
|
|
|
panel.canvas.style.position = 'absolute';
|
|
|
|
if (this.minimal) {
|
|
|
|
panel.canvas.style.display = 'none';
|
|
|
|
} else {
|
|
|
|
panel.canvas.style.display = 'block';
|
|
if (this.horizontal) {
|
|
panel.canvas.style.top = '0px';
|
|
panel.canvas.style.left = offset * panel.WIDTH / panel.PR + 'px';
|
|
} else {
|
|
panel.canvas.style.left = '0px';
|
|
panel.canvas.style.top = offset * panel.HEIGHT / panel.PR + 'px';
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
addPanel(panel: Panel, offset: number) {
|
|
|
|
if (panel.canvas) {
|
|
|
|
this.dom.appendChild(panel.canvas);
|
|
|
|
this.resizePanel(panel, offset);
|
|
|
|
}
|
|
|
|
return panel;
|
|
|
|
}
|
|
|
|
showPanel(id: number) {
|
|
|
|
for (let i = 0; i < this.dom.children.length; i++) {
|
|
const child = this.dom.children[i] as HTMLElement;
|
|
|
|
child.style.display = i === id ? 'block' : 'none';
|
|
|
|
}
|
|
|
|
this.mode = id;
|
|
|
|
}
|
|
|
|
processGpuQueries() {
|
|
|
|
|
|
if (!this.gl || !this.ext) return;
|
|
|
|
this.totalGpuDuration = 0;
|
|
|
|
this.gpuQueries.forEach((queryInfo, index) => {
|
|
if (this.gl) {
|
|
const available = this.gl.getQueryParameter(queryInfo.query, this.gl.QUERY_RESULT_AVAILABLE);
|
|
const disjoint = this.gl.getParameter(this.ext.GPU_DISJOINT_EXT);
|
|
|
|
if (available && !disjoint) {
|
|
const elapsed = this.gl.getQueryParameter(queryInfo.query, this.gl.QUERY_RESULT);
|
|
const duration = elapsed * 1e-6; // Convert nanoseconds to milliseconds
|
|
this.totalGpuDuration += duration;
|
|
this.gl.deleteQuery(queryInfo.query);
|
|
this.gpuQueries.splice(index, 1); // Remove the processed query
|
|
}
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
endInternal() {
|
|
|
|
this.frames++;
|
|
const time = (performance || Date).now();
|
|
const elapsed = time - this.prevTime;
|
|
|
|
// Calculate FPS more frequently based on logsPerSecond
|
|
if (time >= this.prevCpuTime + 1000 / this.logsPerSecond) {
|
|
// Calculate FPS and round to nearest integer
|
|
const fps = Math.round((this.frames * 1000) / elapsed);
|
|
|
|
// Add to FPS averages
|
|
this.addToAverage(fps, this.averageFps);
|
|
|
|
// Update all panels
|
|
this.updatePanel(this.fpsPanel, this.averageFps, 0);
|
|
this.updatePanel(this.msPanel, this.averageCpu, this.precision);
|
|
this.updatePanel(this.gpuPanel, this.averageGpu, this.precision);
|
|
|
|
if (this.gpuPanelCompute) {
|
|
this.updatePanel(this.gpuPanelCompute, this.averageGpuCompute);
|
|
}
|
|
|
|
// Reset frame counter for next interval
|
|
this.frames = 0;
|
|
this.prevCpuTime = time;
|
|
this.prevTime = time;
|
|
}
|
|
|
|
return time;
|
|
|
|
}
|
|
|
|
addToAverage(value: number, averageArray: { logs: any; graph: any; }) {
|
|
|
|
averageArray.logs.push(value);
|
|
if (averageArray.logs.length > this.samplesLog) {
|
|
|
|
averageArray.logs.shift();
|
|
|
|
}
|
|
|
|
averageArray.graph.push(value);
|
|
if (averageArray.graph.length > this.samplesGraph) {
|
|
|
|
averageArray.graph.shift();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
beginProfiling(marker: string) {
|
|
|
|
if (window.performance) {
|
|
|
|
window.performance.mark(marker);
|
|
this.isRunningCPUProfiling = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
endProfiling(startMarker: string | PerformanceMeasureOptions | undefined, endMarker: string | undefined, measureName: string) {
|
|
|
|
if (window.performance && endMarker && this.isRunningCPUProfiling) {
|
|
|
|
window.performance.mark(endMarker);
|
|
const cpuMeasure = performance.measure(measureName, startMarker, endMarker);
|
|
this.totalCpuDuration += cpuMeasure.duration;
|
|
this.isRunningCPUProfiling = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
updatePanel(panel: { update: any; } | null, averageArray: { logs: number[], graph: number[] }, precision = 2) {
|
|
|
|
if (averageArray.logs.length > 0) {
|
|
|
|
let sumLog = 0;
|
|
let max = 0.01;
|
|
|
|
for (let i = 0; i < averageArray.logs.length; i++) {
|
|
|
|
sumLog += averageArray.logs[i];
|
|
|
|
if (averageArray.logs[i] > max) {
|
|
max = averageArray.logs[i];
|
|
}
|
|
|
|
}
|
|
|
|
let sumGraph = 0;
|
|
let maxGraph = 0.01;
|
|
for (let i = 0; i < averageArray.graph.length; i++) {
|
|
|
|
sumGraph += averageArray.graph[i];
|
|
|
|
if (averageArray.graph[i] > maxGraph) {
|
|
maxGraph = averageArray.graph[i];
|
|
}
|
|
|
|
}
|
|
|
|
if (panel) {
|
|
panel.update(sumLog / Math.min(averageArray.logs.length, this.samplesLog), sumGraph / Math.min(averageArray.graph.length, this.samplesGraph), max, maxGraph, precision);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
get domElement() {
|
|
// patch for some use case in threejs
|
|
return this.dom;
|
|
|
|
}
|
|
|
|
patchThreeRenderer(renderer: any) {
|
|
|
|
// Store the original render method
|
|
const originalRenderMethod = renderer.render;
|
|
|
|
// Reference to the stats instance
|
|
const statsInstance = this;
|
|
|
|
// Override the render method on the prototype
|
|
renderer.render = function (scene: THREE.Scene, camera: THREE.Camera) {
|
|
|
|
|
|
statsInstance.begin(); // Start tracking for this render call
|
|
|
|
// Call the original render method
|
|
originalRenderMethod.call(this, scene, camera);
|
|
|
|
statsInstance.end(); // End tracking for this render call
|
|
};
|
|
|
|
|
|
this.threeRendererPatched = true;
|
|
|
|
}
|
|
}
|
|
|
|
|
|
export default Stats; |