mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
+10
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user