Add performance HUD panel

This commit is contained in:
2026-03-12 21:53:36 -04:00
parent b57b04d90a
commit f9e7b1a95c
2 changed files with 115 additions and 21 deletions

View File

@@ -82,6 +82,17 @@ interface NetworkStats {
throughputSamples: NetworkSample[]; throughputSamples: NetworkSample[];
} }
interface PerformanceSample {
atMs: number;
frameMs: number;
}
interface PerformanceStats {
frameSamples: PerformanceSample[];
lastFrameMs: number;
lastPanelUpdateAtMs: number;
}
interface PresentationEntry { interface PresentationEntry {
detail: THREE.Object3D; detail: THREE.Object3D;
icon: THREE.Sprite; icon: THREE.Sprite;
@@ -137,6 +148,7 @@ export class GameViewer {
private readonly detailBodyEl: HTMLDivElement; private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement;
private readonly networkPanelEl: HTMLDivElement; private readonly networkPanelEl: HTMLDivElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement; private readonly errorEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement;
@@ -153,6 +165,11 @@ export class GameViewer {
streamConnected: false, streamConnected: false,
throughputSamples: [], throughputSamples: [],
}; };
private readonly performanceStats: PerformanceStats = {
frameSamples: [],
lastFrameMs: 0,
lastPanelUpdateAtMs: 0,
};
private selectedItems: Selectable[] = []; private selectedItems: Selectable[] = [];
private worldSignature = ""; private worldSignature = "";
@@ -184,20 +201,26 @@ export class GameViewer {
const hud = document.createElement("div"); const hud = document.createElement("div");
hud.className = "viewer-shell"; hud.className = "viewer-shell";
hud.innerHTML = ` hud.innerHTML = `
<div class="left-panel-stack">
<header class="topbar"> <header class="topbar">
<h2>Game</h2> <h2>Game</h2>
<div class="topbar-body">Bootstrapping</div> <div class="topbar-body">Bootstrapping</div>
</header> </header>
<aside class="network-panel">
<h2>Network</h2>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<aside class="performance-panel">
<h2>Performance</h2>
<div class="performance-body">Waiting for frame samples.</div>
</aside>
</div>
<aside class="details-panel"> <aside class="details-panel">
<h2>Selection</h2> <h2>Selection</h2>
<h3 class="detail-title">Nothing selected</h3> <h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Waiting for the authoritative snapshot.</div> <div class="detail-body">Waiting for the authoritative snapshot.</div>
<div class="error-strip" hidden></div> <div class="error-strip" hidden></div>
</aside> </aside>
<aside class="network-panel">
<h2>Network</h2>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<section class="faction-strip"></section> <section class="faction-strip"></section>
<div class="marquee-box"></div> <div class="marquee-box"></div>
`; `;
@@ -207,6 +230,7 @@ export class GameViewer {
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement; this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement;
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement; this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement; this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement;
@@ -637,6 +661,7 @@ export class GameViewer {
} }
private render() { private render() {
const frameStartedAtMs = performance.now();
const delta = Math.min(this.clock.getDelta(), 0.033); const delta = Math.min(this.clock.getDelta(), 0.033);
this.updateCamera(delta); this.updateCamera(delta);
this.updatePlanetPresentation(); this.updatePlanetPresentation();
@@ -644,6 +669,8 @@ export class GameViewer {
this.updateNetworkPanel(); this.updateNetworkPanel();
this.applyZoomPresentation(); this.applyZoomPresentation();
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
this.recordPerformanceStats(performance.now() - frameStartedAtMs);
this.updatePerformancePanel();
} }
private updateCamera(delta: number) { private updateCamera(delta: number) {
@@ -762,6 +789,56 @@ export class GameViewer {
].join("\n"); ].join("\n");
} }
private recordPerformanceStats(frameMs: number) {
const now = performance.now();
this.performanceStats.lastFrameMs = frameMs;
this.performanceStats.frameSamples.push({ atMs: now, frameMs });
const cutoff = now - 4000;
this.performanceStats.frameSamples = this.performanceStats.frameSamples.filter((sample) => sample.atMs >= cutoff);
}
private updatePerformancePanel() {
const now = performance.now();
if (
this.performanceStats.lastPanelUpdateAtMs > 0 &&
now - this.performanceStats.lastPanelUpdateAtMs < 250
) {
return;
}
const samples = this.performanceStats.frameSamples;
const elapsedWindowSeconds = samples.length > 1
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
: 1;
const averageFrameMs = samples.length > 0
? samples.reduce((sum, sample) => sum + sample.frameMs, 0) / samples.length
: 0;
const worstFrameMs = samples.length > 0
? samples.reduce((max, sample) => Math.max(max, sample.frameMs), 0)
: 0;
const fps = samples.length > 1
? (samples.length - 1) / elapsedWindowSeconds
: 0;
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
const renderInfo = this.renderer.info;
this.performancePanelEl.textContent = [
`fps: ${fps.toFixed(1)}`,
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
`frame last: ${this.performanceStats.lastFrameMs.toFixed(2)} ms`,
`frame worst: ${worstFrameMs.toFixed(2)} ms`,
`recent low: ${recentLowFps.toFixed(1)}`,
`draw calls: ${renderInfo.render.calls}`,
`triangles: ${renderInfo.render.triangles}`,
`points: ${renderInfo.render.points}`,
`lines: ${renderInfo.render.lines}`,
`geometries: ${renderInfo.memory.geometries}`,
`textures: ${renderInfo.memory.textures}`,
`pixel ratio: ${this.renderer.getPixelRatio().toFixed(2)}`,
].join("\n");
this.performanceStats.lastPanelUpdateAtMs = now;
}
private updateShipPresentation() { private updateShipPresentation() {
const now = performance.now(); const now = performance.now();
for (const visual of this.shipVisuals.values()) { for (const visual of this.shipVisuals.values()) {

View File

@@ -37,6 +37,16 @@ canvas {
pointer-events: none; pointer-events: none;
} }
.left-panel-stack {
position: absolute;
top: 20px;
left: 20px;
width: min(360px, calc(100vw - 40px));
display: flex;
flex-direction: column;
gap: 16px;
}
.marquee-box { .marquee-box {
position: absolute; position: absolute;
display: none; display: none;
@@ -48,8 +58,8 @@ canvas {
.topbar, .topbar,
.details-panel, .details-panel,
.network-panel, .network-panel,
.performance-panel,
.faction-strip { .faction-strip {
position: absolute;
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
@@ -57,9 +67,6 @@ canvas {
} }
.topbar { .topbar {
top: 20px;
left: 20px;
width: min(360px, calc(100vw - 40px));
border-radius: 22px; border-radius: 22px;
padding: 18px 20px; padding: 18px 20px;
pointer-events: auto; pointer-events: auto;
@@ -103,6 +110,7 @@ canvas {
} }
.details-panel { .details-panel {
position: absolute;
top: 110px; top: 110px;
right: 20px; right: 20px;
width: min(380px, calc(100vw - 40px)); width: min(380px, calc(100vw - 40px));
@@ -115,8 +123,13 @@ canvas {
} }
.network-panel { .network-panel {
top: 172px; border-radius: 24px;
left: 20px; padding: 18px;
color: var(--text);
pointer-events: auto;
}
.performance-panel {
width: min(360px, calc(100vw - 40px)); width: min(360px, calc(100vw - 40px));
border-radius: 24px; border-radius: 24px;
padding: 18px; padding: 18px;
@@ -131,7 +144,8 @@ canvas {
text-transform: uppercase; text-transform: uppercase;
} }
.network-panel h2 { .network-panel h2,
.performance-panel h2 {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
letter-spacing: 0.16em; letter-spacing: 0.16em;
@@ -139,7 +153,8 @@ canvas {
text-transform: uppercase; text-transform: uppercase;
} }
.network-body { .network-body,
.performance-body {
margin-top: 14px; margin-top: 14px;
color: var(--muted); color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
@@ -178,6 +193,7 @@ canvas {
} }
.faction-strip { .faction-strip {
position: absolute;
left: 20px; left: 20px;
bottom: 20px; bottom: 20px;
width: min(920px, calc(100vw - 440px)); width: min(920px, calc(100vw - 440px));
@@ -222,9 +238,9 @@ canvas {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.topbar { .left-panel-stack {
width: auto;
right: 20px; right: 20px;
width: auto;
} }
.details-panel { .details-panel {
@@ -238,9 +254,10 @@ canvas {
} }
.network-panel { .network-panel {
top: 172px; width: auto;
right: 20px; }
left: 20px;
.performance-panel {
width: auto; width: auto;
} }