diff --git a/resources/lang/en.json b/resources/lang/en.json index 189d54202..05039b365 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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..." diff --git a/src/client/graphics/FrameProfiler.ts b/src/client/graphics/FrameProfiler.ts new file mode 100644 index 000000000..a697b4343 --- /dev/null +++ b/src/client/graphics/FrameProfiler.ts @@ -0,0 +1,62 @@ +export class FrameProfiler { + private static timings: Record = {}; + 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 { + if (!this.enabled) return {}; + const copy = { ...this.timings }; + this.timings = {}; + return copy; + } +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index f398dccba..1410cdbbd 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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( diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ab6e6cfef..fb744d4a0 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -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 | 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, + ) { + 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) { + 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`
+ +
- FPS: + ${translateText("performance_overlay.fps")} ${this.currentFPS}
- Avg (60s): + ${translateText("performance_overlay.avg_60s")} ${this.averageFPS}
- Frame: + ${translateText("performance_overlay.frame")} ${this.frameTime}ms
- Tick Exec: + ${translateText("performance_overlay.tick_exec")} ${this.tickExecutionAvg.toFixed(2)}ms (max: ${this.tickExecutionMax}ms)
- Tick Delay: + ${translateText("performance_overlay.tick_delay")} ${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms)
+ ${this.layerBreakdown.length + ? html`
+
+ ${translateText("performance_overlay.layers_header")} +
+ ${this.layerBreakdown.map((layer) => { + const width = Math.min( + 100, + (layer.avg / maxLayerAvg) * 100 || 0, + ); + return html`
+ ${layer.name} +
+
+
+ + ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms + +
`; + })} +
` + : html``}
`; } diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 84a523ecf..b7e302ade 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -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, + ); } }