Performance Overlay rework/redesign (#3274)

## Description:
updates the Performance Overlay to be more usable
(draggable/resizable/scrollable), adds tick-level metrics (TPS +
per-layer tick timings), and reduces overhead when the overlay is
hidden.

### UI/UX
- Overlay layout updated to a fixed, pixel-positioned panel (default
near top-left) with a dedicated drag handle.
- Overlay is touch-draggable (pointer events) and remains usable on
small viewports via internal scrolling.
- Overlay width is resizable with a right-edge handle; width is clamped
to viewport bounds.
- Render/tick layer breakdown sections are collapsible, with headers and
“last tick” summaries.

### New metrics
- Adds TPS reporting:
  - Current TPS (ticks in the last 1s).
- Average TPS over the last ~60s, computed using elapsed time so it’s
accurate before a full 60s passes.
- Adds per-layer tick profiling (“Tick Layers”) alongside render
profiling (“Render Layers”).
- Adds “render-per-tick” metrics so render-layer costs can be understood
per simulation tick (frames + per-layer totals).

### Performance / overhead
- Avoids profiling overhead when the overlay is hidden:
- `GameRenderer` only calls `FrameProfiler.clear()/consume()` and
per-layer `start/end` when profiling is enabled.
- Tick-layer duration tracking is only collected when profiling is
enabled.

### Settings plumbing
- `UserSettings` now dispatches a `user-settings-changed` `CustomEvent`
on `set()` / `setFloat()`.
- The overlay listens for `settings.performanceOverlay` changes so
visibility stays in sync even when toggled outside the overlay.

## Implementation notes (by file)

- `src/client/graphics/layers/PerformanceOverlay.ts`
  - Adds TPS tracking using a timestamp ring + moving heads (1s / 60s).
- Adds UI state for collapsibles, drag + resize pointer tracking, and
new breakdown models:
    - Render layers: EMA avg/max + per-tick render aggregation.
    - Tick layers: EMA avg/max + last-tick durations.
- Copy-to-clipboard snapshot now includes TPS, tick layers, and
render-per-tick last-tick details.

- `src/client/graphics/GameRenderer.ts`
  - Gates render-layer profiling behind `FrameProfiler.isEnabled()`.
- Accumulates per-render-layer timings across frames and publishes them
once per tick via `updateRenderPerTickMetrics(...)`.
- Measures tick-layer durations (per layer `tick()` call) and publishes
them via `updateTickLayerMetrics(...)`.

- `src/core/game/UserSettings.ts`
- Adds `emitChange(key, value)` to dispatch `user-settings-changed` to
`globalThis` (best-effort).

- `resources/lang/en.json`
- Adds/updates `performance_overlay.*` strings for TPS and the new
render/tick layer sections.

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] 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
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
scamiv
2026-02-23 21:22:56 +01:00
committed by GitHub
parent e5ce278cb1
commit 4b917c4153
4 changed files with 798 additions and 135 deletions
+10 -1
View File
@@ -849,9 +849,18 @@
"fps": "FPS:",
"avg_60s": "Avg (60s):",
"frame": "Frame:",
"tps": "TPS:",
"tps_avg_60s": "Avg:",
"tick_exec": "Tick Exec:",
"tick_delay": "Tick Delay:",
"layers_header": "Layers (avg / max, sorted by total time):"
"layers_header": "Render Layers",
"render_layers_table_header": "avg / max | tick avg",
"render_layers_summary": "Last tick: {frames} frames, {ms}ms",
"tick_layers_header": "Tick Layers",
"tick_layers_table_header": "avg / max",
"tick_layers_summary": "Last tick: {count} layers, {ms}ms",
"expand": "Expand",
"collapse": "Collapse"
},
"heads_up_message": {
"choose_spawn": "Choose a starting location",
+47 -6
View File
@@ -339,6 +339,8 @@ export function createRenderer(
export class GameRenderer {
private context: CanvasRenderingContext2D;
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
private renderFramesSinceLastTick: number = 0;
private renderLayerDurationsSinceLastTick: Record<string, number> = {};
constructor(
private game: GameView,
@@ -395,7 +397,10 @@ export class GameRenderer {
}
renderGame() {
FrameProfiler.clear();
const shouldProfileFrame = FrameProfiler.isEnabled();
if (shouldProfileFrame) {
FrameProfiler.clear();
}
const start = performance.now();
// Set background
this.context.fillStyle = this.game
@@ -429,9 +434,16 @@ export class GameRenderer {
isTransformActive,
);
const layerStart = FrameProfiler.start();
layer.renderLayer?.(this.context);
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
if (shouldProfileFrame) {
const layerStart = FrameProfiler.start();
layer.renderLayer?.(this.context);
FrameProfiler.end(
layer.constructor?.name ?? "UnknownLayer",
layerStart,
);
} else {
layer.renderLayer?.(this.context);
}
}
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
this.transformHandler.resetChanged();
@@ -439,8 +451,15 @@ export class GameRenderer {
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
const layerDurations = FrameProfiler.consume();
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
if (shouldProfileFrame) {
const layerDurations = FrameProfiler.consume();
this.renderFramesSinceLastTick++;
for (const [name, ms] of Object.entries(layerDurations)) {
this.renderLayerDurationsSinceLastTick[name] =
(this.renderLayerDurationsSinceLastTick[name] ?? 0) + ms;
}
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
}
if (duration > 50) {
console.warn(
@@ -451,6 +470,18 @@ export class GameRenderer {
tick() {
const nowMs = performance.now();
const shouldProfileTick = FrameProfiler.isEnabled();
if (shouldProfileTick) {
this.performanceOverlay.updateRenderPerTickMetrics(
this.renderFramesSinceLastTick,
this.renderLayerDurationsSinceLastTick,
);
this.renderFramesSinceLastTick = 0;
this.renderLayerDurationsSinceLastTick = {};
}
const tickLayerDurations: Record<string, number> = {};
for (const layer of this.layers) {
if (!layer.tick) {
@@ -470,7 +501,17 @@ export class GameRenderer {
state.lastTickAtMs = nowMs;
this.layerTickState.set(layer, state);
const tickStart = shouldProfileTick ? performance.now() : 0;
layer.tick();
if (shouldProfileTick && tickStart !== 0) {
const duration = performance.now() - tickStart;
const label = layer.constructor?.name ?? "UnknownLayer";
tickLayerDurations[label] = (tickLayerDurations[label] ?? 0) + duration;
}
}
if (shouldProfileTick) {
this.performanceOverlay.updateTickLayerMetrics(tickLayerDurations);
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -4,6 +4,20 @@ import { PlayerPattern } from "../Schemas";
const PATTERN_KEY = "territoryPattern";
export class UserSettings {
private emitChange(key: string, value: boolean | number): void {
try {
const maybeDispatch = (globalThis as any)?.dispatchEvent;
if (typeof maybeDispatch !== "function") return;
(globalThis as any).dispatchEvent(
new CustomEvent("user-settings-changed", {
detail: { key, value },
}),
);
} catch {
// Ignore - settings should still be applied even if event dispatch fails.
}
}
get(key: string, defaultValue: boolean): boolean {
const value = localStorage.getItem(key);
if (!value) return defaultValue;
@@ -17,6 +31,7 @@ export class UserSettings {
set(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
this.emitChange(key, value);
}
getFloat(key: string, defaultValue: number): number {
@@ -31,6 +46,7 @@ export class UserSettings {
setFloat(key: string, value: number) {
localStorage.setItem(key, value.toString());
this.emitChange(key, value);
}
emojis() {