Add performance HUD panel
This commit is contained in:
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user