Skip to content

Commit 7373a28

Browse files
scamivevanpelle
andauthored
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>
1 parent a883d61 commit 7373a28

File tree

5 files changed

+394
-9
lines changed

5 files changed

+394
-9
lines changed

resources/lang/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,19 @@
671671
},
672672
"desync_notice": "You are desynced from other players. What you see might differ from other players."
673673
},
674+
"performance_overlay": {
675+
"reset": "Reset",
676+
"copy_json_title": "Copy current performance metrics as JSON",
677+
"copy_clipboard": "Copy JSON",
678+
"copied": "Copied!",
679+
"failed_copy": "Failed to copy",
680+
"fps": "FPS:",
681+
"avg_60s": "Avg (60s):",
682+
"frame": "Frame:",
683+
"tick_exec": "Tick Exec:",
684+
"tick_delay": "Tick Delay:",
685+
"layers_header": "Layers (avg / max, sorted by total time):"
686+
},
674687
"heads_up_message": {
675688
"choose_spawn": "Choose a starting location",
676689
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export class FrameProfiler {
2+
private static timings: Record<string, number> = {};
3+
private static enabled: boolean = false;
4+
5+
/**
6+
* Enable or disable profiling.
7+
*/
8+
static setEnabled(enabled: boolean): void {
9+
this.enabled = enabled;
10+
}
11+
12+
/**
13+
* Check if profiling is enabled.
14+
*/
15+
static isEnabled(): boolean {
16+
return this.enabled;
17+
}
18+
19+
/**
20+
* Clear all accumulated timings for the current frame.
21+
*/
22+
static clear(): void {
23+
if (!this.enabled) return;
24+
this.timings = {};
25+
}
26+
27+
/**
28+
* Record a duration (in ms) for a named span.
29+
*/
30+
static record(name: string, duration: number): void {
31+
if (!this.enabled || !Number.isFinite(duration)) return;
32+
this.timings[name] = (this.timings[name] ?? 0) + duration;
33+
}
34+
35+
/**
36+
* Convenience helper to start a span.
37+
* Returns a high-resolution timestamp to be passed into end().
38+
*/
39+
static start(): number {
40+
if (!this.enabled) return 0;
41+
return performance.now();
42+
}
43+
44+
/**
45+
* Convenience helper to end a span started with start().
46+
*/
47+
static end(name: string, startTime: number): void {
48+
if (!this.enabled || startTime === 0) return;
49+
const duration = performance.now() - startTime;
50+
this.record(name, duration);
51+
}
52+
53+
/**
54+
* Consume and reset all timings collected so far.
55+
*/
56+
static consume(): Record<string, number> {
57+
if (!this.enabled) return {};
58+
const copy = { ...this.timings };
59+
this.timings = {};
60+
return copy;
61+
}
62+
}

src/client/graphics/GameRenderer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
33
import { UserSettings } from "../../core/game/UserSettings";
44
import { GameStartingModal } from "../GameStartingModal";
55
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
6+
import { FrameProfiler } from "./FrameProfiler";
67
import { TransformHandler } from "./TransformHandler";
78
import { UIState } from "./UIState";
89
import { AdTimer } from "./layers/AdTimer";
@@ -343,6 +344,7 @@ export class GameRenderer {
343344
}
344345

345346
renderGame() {
347+
FrameProfiler.clear();
346348
const start = performance.now();
347349
// Set background
348350
this.context.fillStyle = this.game
@@ -375,15 +377,19 @@ export class GameRenderer {
375377
needsTransform,
376378
isTransformActive,
377379
);
380+
381+
const layerStart = FrameProfiler.start();
378382
layer.renderLayer?.(this.context);
383+
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
379384
}
380385
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
381386
this.transformHandler.resetChanged();
382387

383388
requestAnimationFrame(() => this.renderGame());
384389
const duration = performance.now() - start;
385390

386-
this.performanceOverlay.updateFrameMetrics(duration);
391+
const layerDurations = FrameProfiler.consume();
392+
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
387393

388394
if (duration > 50) {
389395
console.warn(

0 commit comments

Comments
 (0)