From f9e7b1a95c269a64f37c522fefed3dbb81080307 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 21:53:36 -0400 Subject: [PATCH] Add performance HUD panel --- apps/viewer/src/GameViewer.ts | 93 ++++++++++++++++++++++++++++++++--- apps/viewer/src/style.css | 43 +++++++++++----- 2 files changed, 115 insertions(+), 21 deletions(-) diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 593976f..82e7295 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -82,6 +82,17 @@ interface NetworkStats { throughputSamples: NetworkSample[]; } +interface PerformanceSample { + atMs: number; + frameMs: number; +} + +interface PerformanceStats { + frameSamples: PerformanceSample[]; + lastFrameMs: number; + lastPanelUpdateAtMs: number; +} + interface PresentationEntry { detail: THREE.Object3D; icon: THREE.Sprite; @@ -137,6 +148,7 @@ export class GameViewer { private readonly detailBodyEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement; private readonly networkPanelEl: HTMLDivElement; + private readonly performancePanelEl: HTMLDivElement; private readonly errorEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement; @@ -153,6 +165,11 @@ export class GameViewer { streamConnected: false, throughputSamples: [], }; + private readonly performanceStats: PerformanceStats = { + frameSamples: [], + lastFrameMs: 0, + lastPanelUpdateAtMs: 0, + }; private selectedItems: Selectable[] = []; private worldSignature = ""; @@ -184,20 +201,26 @@ export class GameViewer { const hud = document.createElement("div"); hud.className = "viewer-shell"; hud.innerHTML = ` -
-

Game

-
Bootstrapping
-
+
+
+

Game

+
Bootstrapping
+
+ + +
-
`; @@ -207,6 +230,7 @@ export class GameViewer { this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement; this.factionStripEl = hud.querySelector(".faction-strip") 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.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement; @@ -637,6 +661,7 @@ export class GameViewer { } private render() { + const frameStartedAtMs = performance.now(); const delta = Math.min(this.clock.getDelta(), 0.033); this.updateCamera(delta); this.updatePlanetPresentation(); @@ -644,6 +669,8 @@ export class GameViewer { this.updateNetworkPanel(); this.applyZoomPresentation(); this.renderer.render(this.scene, this.camera); + this.recordPerformanceStats(performance.now() - frameStartedAtMs); + this.updatePerformancePanel(); } private updateCamera(delta: number) { @@ -762,6 +789,56 @@ export class GameViewer { ].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() { const now = performance.now(); for (const visual of this.shipVisuals.values()) { diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index 44e8d3f..a188fd9 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -37,6 +37,16 @@ canvas { 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 { position: absolute; display: none; @@ -48,8 +58,8 @@ canvas { .topbar, .details-panel, .network-panel, +.performance-panel, .faction-strip { - position: absolute; backdrop-filter: blur(18px); background: var(--panel); border: 1px solid var(--panel-border); @@ -57,9 +67,6 @@ canvas { } .topbar { - top: 20px; - left: 20px; - width: min(360px, calc(100vw - 40px)); border-radius: 22px; padding: 18px 20px; pointer-events: auto; @@ -103,6 +110,7 @@ canvas { } .details-panel { + position: absolute; top: 110px; right: 20px; width: min(380px, calc(100vw - 40px)); @@ -115,8 +123,13 @@ canvas { } .network-panel { - top: 172px; - left: 20px; + border-radius: 24px; + padding: 18px; + color: var(--text); + pointer-events: auto; +} + +.performance-panel { width: min(360px, calc(100vw - 40px)); border-radius: 24px; padding: 18px; @@ -131,7 +144,8 @@ canvas { text-transform: uppercase; } -.network-panel h2 { +.network-panel h2, +.performance-panel h2 { margin: 0; color: var(--accent); letter-spacing: 0.16em; @@ -139,7 +153,8 @@ canvas { text-transform: uppercase; } -.network-body { +.network-body, +.performance-body { margin-top: 14px; color: var(--muted); font-family: "IBM Plex Mono", "SFMono-Regular", monospace; @@ -178,6 +193,7 @@ canvas { } .faction-strip { + position: absolute; left: 20px; bottom: 20px; width: min(920px, calc(100vw - 440px)); @@ -222,9 +238,9 @@ canvas { } @media (max-width: 760px) { - .topbar { - width: auto; + .left-panel-stack { right: 20px; + width: auto; } .details-panel { @@ -238,9 +254,10 @@ canvas { } .network-panel { - top: 172px; - right: 20px; - left: 20px; + width: auto; + } + + .performance-panel { width: auto; }