Feature/frame profiler (#2467)

## Description:

Adds a reusable FrameProfiler utility, and a way to export profiling
data for offline analysis.


### What this PR changes in the existing performance monitor

This PR enhances the performance monitor by:

- **Introducing a reusable `FrameProfiler` utility**
- New `FrameProfiler` singleton in
`src/client/graphics/FrameProfiler.ts`.
- Profiling is only active when the performance overlay is visible
(toggled via user settings), to avoid unnecessary overhead.

- **Per-layer and span-level timing integration**
  - `GameRenderer.renderGame` now:
    - Clears the profiler at the start of each frame.
- Wraps each `layer.renderLayer?.(this.context)` call with
`FrameProfiler.start()/end()`, keyed by the layer’s constructor name.
- Consumes the recorded timings at the end of the frame and passes them
into `PerformanceOverlay.updateFrameMetrics(frameDuration,
layerDurations)`.
  - `TerritoryLayer` instruments key operations:
    - `renderTerritory`
    - `putImageData`
    - Drawing the main canvas
    - Drawing the highlight canvas during spawn
- These show up in the performance overlay as additional entries (e.g.
`TerritoryLayer:renderTerritory`).

- **JSON export of performance snapshots**
- `PerformanceOverlay` can now build a full performance snapshot
(`buildPerformanceSnapshot`) containing:
    - FPS and frame time stats (current, 60s average, 60s history).
    - Tick metrics (avg/max execution and delay, plus raw samples).
- Layer breakdown (EMA-smoothed avg, max, total time per layer/span).
  - A new “Copy JSON” button:
- Uses `navigator.clipboard.writeText` when available and falls back to
a hidden `<textarea>` + `document.execCommand("copy")`.
- Provides user feedback via a transient status ("Copy JSON" → "Copied!"
or "Failed to copy").

- **Enable/disable functionality hooked into the UI**
  - `FrameProfiler.setEnabled(visible)` is invoked:
    - When the overlay visibility is toggled (`init` → `setVisible`).
- When the overlay re-checks visibility in `updateFrameMetrics`, so the
profiler state stays in sync with user settings.
- When disabled, `FrameProfiler` becomes a no-op (returns `0` from
`start`, ignores `record`/`end`, and `consume` returns an empty object),
ensuring minimal overhead when performance monitoring is off.

- **Performance overlay UX and i18n improvements**
  - New controls:
    - **Reset** button to clear all FPS/tick/layer stats.
    - **Copy JSON** button with a tooltip and transient status text.
  - Visual enhancements:
- Wider overlay (`min-width: 420px`) and extra padding for readability.
    - Layer breakdown section with:
      - A list that is now sorted by total accumulated time.
      - A horizontal bar per entry, scaled by average cost.
      - Avg / max time display per layer/span.
- All new text is routed through `translateText` and backed by
`en.json`:
    - `performance_overlay.reset`
    - `performance_overlay.copy_json_title`
    - `performance_overlay.copy_clipboard`
    - `performance_overlay.copied`
    - `performance_overlay.failed_copy`
    - `performance_overlay.fps`
    - `performance_overlay.avg_60s`
    - `performance_overlay.frame`
    - `performance_overlay.tick_exec`
    - `performance_overlay.tick_delay`
    - `performance_overlay.layers_header`

---

### How to set up profiling for new functions / code paths

For any function or code block you want to profile during a frame:

```ts
import { FrameProfiler } from "../FrameProfiler"; 

function heavyOperation() {
  const spanStart = FrameProfiler.start();
  // ... your existing work ...
  FrameProfiler.end("MyFeature:heavyOperation", spanStart);
}
```

Guidelines:

- Use descriptive, stable names:
  - Prefix with the component or layer name, e.g.:
    - `"TerritoryLayer:prepareTiles"`
    - `"GameRenderer:resolveVisibility"`
    - `"FooFeature:fetchData"`
- The same name can be called multiple times per frame; the profiler
accumulates the durations in that frame.
- The accumulated values will appear:
  - In `layerDurations` consumed at the end of the frame.
  - In the overlay “Layers (avg / max, sorted by total time)” section.
  - In the exported JSON under `layers` with `avg`, `max`, and `total`.

**3. Record pre-computed durations (optional)**

If you already have a measured duration and just want to attach it:

```ts
FrameProfiler.record("MyFeature:step1", someDurationInMs);
```

- This is equivalent to calling `start`/`end` but with your own timing
logic.
- Again, multiple calls with the same name in one frame will be summed.

---

<img width="466" height="823" alt="image"
src="https://github.com/user-attachments/assets/354b249a-25eb-4c3f-bd2e-9906372f761b"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates

- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file

- [ ] I have added relevant tests to the test directory

- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
scamiv
2025-11-17 04:10:20 +01:00
committed by GitHub
parent a883d612e0
commit 7373a28c99
5 changed files with 394 additions and 9 deletions
+13
View File
@@ -671,6 +671,19 @@
},
"desync_notice": "You are desynced from other players. What you see might differ from other players."
},
"performance_overlay": {
"reset": "Reset",
"copy_json_title": "Copy current performance metrics as JSON",
"copy_clipboard": "Copy JSON",
"copied": "Copied!",
"failed_copy": "Failed to copy",
"fps": "FPS:",
"avg_60s": "Avg (60s):",
"frame": "Frame:",
"tick_exec": "Tick Exec:",
"tick_delay": "Tick Delay:",
"layers_header": "Layers (avg / max, sorted by total time):"
},
"heads_up_message": {
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
+62
View File
@@ -0,0 +1,62 @@
export class FrameProfiler {
private static timings: Record<string, number> = {};
private static enabled: boolean = false;
/**
* Enable or disable profiling.
*/
static setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
/**
* Check if profiling is enabled.
*/
static isEnabled(): boolean {
return this.enabled;
}
/**
* Clear all accumulated timings for the current frame.
*/
static clear(): void {
if (!this.enabled) return;
this.timings = {};
}
/**
* Record a duration (in ms) for a named span.
*/
static record(name: string, duration: number): void {
if (!this.enabled || !Number.isFinite(duration)) return;
this.timings[name] = (this.timings[name] ?? 0) + duration;
}
/**
* Convenience helper to start a span.
* Returns a high-resolution timestamp to be passed into end().
*/
static start(): number {
if (!this.enabled) return 0;
return performance.now();
}
/**
* Convenience helper to end a span started with start().
*/
static end(name: string, startTime: number): void {
if (!this.enabled || startTime === 0) return;
const duration = performance.now() - startTime;
this.record(name, duration);
}
/**
* Consume and reset all timings collected so far.
*/
static consume(): Record<string, number> {
if (!this.enabled) return {};
const copy = { ...this.timings };
this.timings = {};
return copy;
}
}
+7 -1
View File
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AdTimer } from "./layers/AdTimer";
@@ -343,6 +344,7 @@ export class GameRenderer {
}
renderGame() {
FrameProfiler.clear();
const start = performance.now();
// Set background
this.context.fillStyle = this.game
@@ -375,7 +377,10 @@ export class GameRenderer {
needsTransform,
isTransformActive,
);
const layerStart = FrameProfiler.start();
layer.renderLayer?.(this.context);
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
}
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
this.transformHandler.resetChanged();
@@ -383,7 +388,8 @@ export class GameRenderer {
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
this.performanceOverlay.updateFrameMetrics(duration);
const layerDurations = FrameProfiler.consume();
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
if (duration > 50) {
console.warn(
@@ -6,6 +6,8 @@ import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
} from "../../InputHandler";
import { translateText } from "../../Utils";
import { FrameProfiler } from "../FrameProfiler";
import { Layer } from "./Layer";
@customElement("performance-overlay")
@@ -46,6 +48,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
@state()
private copyStatus: "idle" | "success" | "error" = "idle";
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
@@ -56,6 +61,22 @@ export class PerformanceOverlay extends LitElement implements Layer {
private tickExecutionTimes: number[] = [];
private tickDelayTimes: number[] = [];
private copyStatusTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Smoothed per-layer render timings (EMA over recent frames)
private layerStats: Map<
string,
{ avg: number; max: number; last: number; total: number }
> = new Map();
@state()
private layerBreakdown: {
name: string;
avg: number;
max: number;
total: number;
}[] = [];
static styles = css`
.performance-overlay {
position: fixed;
@@ -64,7 +85,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
padding: 8px 16px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
@@ -72,6 +93,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
user-select: none;
cursor: move;
transition: none;
min-width: 420px;
}
.performance-overlay.dragging {
@@ -115,6 +137,86 @@ export class PerformanceOverlay extends LitElement implements Layer {
user-select: none;
pointer-events: auto;
}
.reset-button {
position: absolute;
top: 8px;
left: 8px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.copy-json-button {
position: absolute;
top: 8px;
left: 70px;
height: 20px;
padding: 0 6px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
.layers-section {
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 4px;
}
.layer-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: 2px;
}
.layer-name {
flex: 0 0 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-bar {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.25);
border-radius: 3px;
overflow: hidden;
}
.layer-bar-fill {
height: 100%;
background: #38bdf8;
border-radius: 3px;
}
.layer-metrics {
flex: 0 0 auto;
white-space: nowrap;
}
`;
constructor() {
@@ -124,6 +226,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
init() {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
this.setVisible(this.userSettings.performanceOverlay());
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
@@ -132,6 +235,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
setVisible(visible: boolean) {
this.isVisible = visible;
FrameProfiler.setEnabled(visible);
}
private handleClose() {
@@ -140,7 +244,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
private handleMouseDown = (e: MouseEvent) => {
// Don't start dragging if clicking on close button
if ((e.target as HTMLElement).classList.contains("close-button")) {
const target = e.target as HTMLElement;
if (
target.classList.contains("close-button") ||
target.classList.contains("reset-button") ||
target.classList.contains("copy-json-button")
) {
return;
}
@@ -179,9 +288,45 @@ export class PerformanceOverlay extends LitElement implements Layer {
document.removeEventListener("mouseup", this.handleMouseUp);
};
updateFrameMetrics(frameDuration: number) {
private handleReset = () => {
// reset FPS / frame stats
this.frameCount = 0;
this.lastTime = 0;
this.frameTimes = [];
this.fpsHistory = [];
this.lastSecondTime = 0;
this.framesThisSecond = 0;
this.currentFPS = 0;
this.averageFPS = 0;
this.frameTime = 0;
// reset tick metrics
this.tickExecutionTimes = [];
this.tickDelayTimes = [];
this.tickExecutionAvg = 0;
this.tickExecutionMax = 0;
this.tickDelayAvg = 0;
this.tickDelayMax = 0;
// reset layer breakdown
this.layerStats.clear();
this.layerBreakdown = [];
this.requestUpdate();
};
updateFrameMetrics(
frameDuration: number,
layerDurations?: Record<string, number>,
) {
const wasVisible = this.isVisible;
this.isVisible = this.userSettings.performanceOverlay();
// Update FrameProfiler enabled state when visibility changes
if (wasVisible !== this.isVisible) {
FrameProfiler.setEnabled(this.isVisible);
}
if (!this.isVisible) return;
const now = performance.now();
@@ -233,9 +378,46 @@ export class PerformanceOverlay extends LitElement implements Layer {
this.lastTime = now;
this.frameCount++;
if (layerDurations) {
this.updateLayerStats(layerDurations);
}
this.requestUpdate();
}
private updateLayerStats(layerDurations: Record<string, number>) {
const alpha = 0.2; // smoothing factor for EMA
Object.entries(layerDurations).forEach(([name, duration]) => {
const existing = this.layerStats.get(name);
if (!existing) {
this.layerStats.set(name, {
avg: duration,
max: duration,
last: duration,
total: duration,
});
} else {
const avg = existing.avg + alpha * (duration - existing.avg);
const max = Math.max(existing.max, duration);
const total = existing.total + duration;
this.layerStats.set(name, { avg, max, last: duration, total });
}
});
// Derive contributors sorted by total accumulated time spent
const breakdown = Array.from(this.layerStats.entries())
.map(([name, stats]) => ({
name,
avg: stats.avg,
max: stats.max,
total: stats.total,
}))
.sort((a, b) => b.total - a.total);
this.layerBreakdown = breakdown;
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
@@ -286,6 +468,70 @@ export class PerformanceOverlay extends LitElement implements Layer {
return "performance-bad";
}
private buildPerformanceSnapshot() {
return {
timestamp: new Date().toISOString(),
fps: {
current: this.currentFPS,
average60s: this.averageFPS,
frameTimeMs: this.frameTime,
history: [...this.fpsHistory],
},
ticks: {
executionAvgMs: this.tickExecutionAvg,
executionMaxMs: this.tickExecutionMax,
delayAvgMs: this.tickDelayAvg,
delayMaxMs: this.tickDelayMax,
executionSamples: [...this.tickExecutionTimes],
delaySamples: [...this.tickDelayTimes],
},
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
};
}
private clearCopyStatusTimeout() {
if (this.copyStatusTimeoutId !== null) {
clearTimeout(this.copyStatusTimeoutId);
this.copyStatusTimeoutId = null;
}
}
private scheduleCopyStatusReset() {
this.clearCopyStatusTimeout();
this.copyStatusTimeoutId = setTimeout(() => {
this.copyStatus = "idle";
this.copyStatusTimeoutId = null;
this.requestUpdate();
}, 2000);
}
private async handleCopyJson() {
const snapshot = this.buildPerformanceSnapshot();
const json = JSON.stringify(snapshot, null, 2);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(json);
} else {
const textarea = document.createElement("textarea");
textarea.value = json;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
this.copyStatus = "success";
} catch (err) {
console.warn("Failed to copy performance snapshot", err);
this.copyStatus = "error";
}
this.scheduleCopyStatusReset();
}
render() {
if (!this.isVisible) {
return html``;
@@ -297,41 +543,87 @@ export class PerformanceOverlay extends LitElement implements Layer {
transform: none;
`;
const copyLabel =
this.copyStatus === "success"
? translateText("performance_overlay.copied")
: this.copyStatus === "error"
? translateText("performance_overlay.failed_copy")
: translateText("performance_overlay.copy_clipboard");
const maxLayerAvg =
this.layerBreakdown.length > 0
? Math.max(...this.layerBreakdown.map((l) => l.avg))
: 1;
return html`
<div
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="reset-button" @click="${this.handleReset}">
${translateText("performance_overlay.reset")}
</button>
<button
class="copy-json-button"
@click="${this.handleCopyJson}"
title="${translateText("performance_overlay.copy_json_title")}"
>
${copyLabel}
</button>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="performance-line">
FPS:
${translateText("performance_overlay.fps")}
<span class="${this.getPerformanceColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="performance-line">
Avg (60s):
${translateText("performance_overlay.avg_60s")}
<span class="${this.getPerformanceColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="performance-line">
Frame:
${translateText("performance_overlay.frame")}
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
<div class="performance-line">
Tick Exec:
${translateText("performance_overlay.tick_exec")}
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
(max: <span>${this.tickExecutionMax}ms</span>)
</div>
<div class="performance-line">
Tick Delay:
${translateText("performance_overlay.tick_delay")}
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
${this.layerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
${translateText("performance_overlay.layers_header")}
</div>
${this.layerBreakdown.map((layer) => {
const width = Math.min(
100,
(layer.avg / maxLayerAvg) * 100 || 0,
);
return html`<div class="layer-row">
<span class="layer-name" title=${layer.name}
>${layer.name}</span
>
<div class="layer-bar">
<div class="layer-bar-fill" style="width: ${width}%;"></div>
</div>
<span class="layer-metrics">
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
</span>
</div>`;
})}
</div>`
: html``}
</div>
`;
}
@@ -19,6 +19,7 @@ import {
DragEvent,
MouseOverEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -399,7 +400,9 @@ export class TerritoryLayer implements Layer {
now > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = now;
const renderTerritoryStart = FrameProfiler.start();
this.renderTerritory();
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const vx0 = Math.max(0, topLeft.x);
@@ -411,6 +414,7 @@ export class TerritoryLayer implements Layer {
const h = vy1 - vy0 + 1;
if (w > 0 && h > 0) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
@@ -420,9 +424,11 @@ export class TerritoryLayer implements Layer {
w,
h,
);
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
}
}
const drawCanvasStart = FrameProfiler.start();
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -430,7 +436,9 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
@@ -438,6 +446,10 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
FrameProfiler.end(
"TerritoryLayer:drawHighlightCanvas",
highlightDrawStart,
);
}
}