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 = `
-
+
+
+
+
+
-
`;
@@ -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;
}