From 3acb624ced4a8c31c954d5f1ad2f70f1794fb5a5 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:21:41 +0100 Subject: [PATCH] perf: improve performance overlay profiling --- resources/lang/en.json | 5 +- src/client/graphics/GameRenderer.ts | 151 +++++++---- src/client/graphics/layers/Layer.ts | 5 + .../graphics/layers/PerformanceOverlay.ts | 240 ++++++++++++++++-- 4 files changed, 330 insertions(+), 71 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 1e671caa2..6e8359b09 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -851,7 +851,10 @@ "frame": "Frame:", "tick_exec": "Tick Exec:", "tick_delay": "Tick Delay:", - "layers_header": "Layers (avg / max, sorted by total time):" + "layers_header": "Render Layers (avg / max | tick avg, sorted by total time):", + "render_layers_summary": "Last tick: {frames} frames, {ms}ms", + "tick_layers_header": "Tick Layers (avg / max, sorted by total time):", + "tick_layers_summary": "Last tick: {count} layers, {ms}ms" }, "heads_up_message": { "choose_spawn": "Choose a starting location", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index d1e191162..767b442bf 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -46,6 +46,20 @@ import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; +function namedLayer(layer: T, profileName: string): T { + layer.profileName = profileName; + return layer; +} + +function getProfileLabel(layer: Layer): string { + const base = layer.profileName ?? "UnknownLayer"; + if (layer instanceof HTMLElement) { + const tag = layer.tagName?.toLowerCase(); + if (tag) return `${base} (${tag})`; + } + return base; +} + export function createRenderer( canvas: HTMLCanvasElement, game: GameView, @@ -279,50 +293,68 @@ export function createRenderer( // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ - new TerrainLayer(game, transformHandler), - new TerritoryLayer(game, eventBus, transformHandler, userSettings), - new RailroadLayer(game, eventBus, transformHandler, uiState), - structureLayer, - samRadiusLayer, - new UnitLayer(game, eventBus, transformHandler), - new FxLayer(game, eventBus, transformHandler), - new UILayer(game, eventBus, transformHandler), - new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), - new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new DynamicUILayer(game, transformHandler, eventBus), - new NameLayer(game, transformHandler, eventBus), - eventsDisplay, - attacksDisplay, - chatDisplay, - buildMenu, - new MainRadialMenu( - eventBus, - game, - transformHandler, - emojiTable as EmojiTable, - buildMenu, - uiState, - playerPanel, + namedLayer(new TerrainLayer(game, transformHandler), "TerrainLayer"), + namedLayer( + new TerritoryLayer(game, eventBus, transformHandler, userSettings), + "TerritoryLayer", ), - spawnTimer, - immunityTimer, - leaderboard, - gameLeftSidebar, - unitDisplay, - gameRightSidebar, - controlPanel, - playerInfo, - winModal, - replayPanel, - settingsModal, - teamStats, - playerPanel, - headsUpMessage, - multiTabModal, - inGameHeaderAd, - spawnVideoAd, - alertFrame, - performanceOverlay, + namedLayer( + new RailroadLayer(game, eventBus, transformHandler, uiState), + "RailroadLayer", + ), + namedLayer(structureLayer, "StructureLayer"), + namedLayer(samRadiusLayer, "SAMRadiusLayer"), + namedLayer(new UnitLayer(game, eventBus, transformHandler), "UnitLayer"), + namedLayer(new FxLayer(game, eventBus, transformHandler), "FxLayer"), + namedLayer(new UILayer(game, eventBus, transformHandler), "UILayer"), + namedLayer( + new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), + "NukeTrajectoryPreviewLayer", + ), + namedLayer( + new StructureIconsLayer(game, eventBus, uiState, transformHandler), + "StructureIconsLayer", + ), + namedLayer( + new DynamicUILayer(game, transformHandler, eventBus), + "DynamicUILayer", + ), + namedLayer(new NameLayer(game, transformHandler, eventBus), "NameLayer"), + namedLayer(eventsDisplay, "EventsDisplay"), + namedLayer(attacksDisplay, "AttacksDisplay"), + namedLayer(chatDisplay, "ChatDisplay"), + namedLayer(buildMenu, "BuildMenu"), + namedLayer( + new MainRadialMenu( + eventBus, + game, + transformHandler, + emojiTable as EmojiTable, + buildMenu, + uiState, + playerPanel, + ), + "MainRadialMenu", + ), + namedLayer(spawnTimer, "SpawnTimer"), + namedLayer(immunityTimer, "ImmunityTimer"), + namedLayer(leaderboard, "Leaderboard"), + namedLayer(gameLeftSidebar, "GameLeftSidebar"), + namedLayer(unitDisplay, "UnitDisplay"), + namedLayer(gameRightSidebar, "GameRightSidebar"), + namedLayer(controlPanel, "ControlPanel"), + namedLayer(playerInfo, "PlayerInfoOverlay"), + namedLayer(winModal, "WinModal"), + namedLayer(replayPanel, "ReplayPanel"), + namedLayer(settingsModal, "SettingsModal"), + namedLayer(teamStats, "TeamStats"), + namedLayer(playerPanel, "PlayerPanel"), + namedLayer(headsUpMessage, "HeadsUpMessage"), + namedLayer(multiTabModal, "MultiTabModal"), + namedLayer(inGameHeaderAd, "InGameHeaderAd"), + namedLayer(spawnVideoAd, "SpawnVideoAd"), + namedLayer(alertFrame, "AlertFrame"), + namedLayer(performanceOverlay, "PerformanceOverlay"), ]; return new GameRenderer( @@ -339,6 +371,8 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; private layerTickState = new Map(); + private renderFramesSinceLastTick: number = 0; + private renderLayerDurationsSinceLastTick: Record = {}; constructor( private game: GameView, @@ -431,7 +465,7 @@ export class GameRenderer { const layerStart = FrameProfiler.start(); layer.renderLayer?.(this.context); - FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart); + FrameProfiler.end(getProfileLabel(layer), layerStart); } handleTransformState(false, isTransformActive); // Ensure context is clean after rendering this.transformHandler.resetChanged(); @@ -440,6 +474,13 @@ export class GameRenderer { const duration = performance.now() - start; const layerDurations = FrameProfiler.consume(); + if (FrameProfiler.isEnabled()) { + 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) { @@ -451,6 +492,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 = {}; for (const layer of this.layers) { if (!layer.tick) { @@ -470,7 +523,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 = getProfileLabel(layer); + tickLayerDurations[label] = (tickLayerDurations[label] ?? 0) + duration; + } + } + + if (shouldProfileTick) { + this.performanceOverlay.updateTickLayerMetrics(tickLayerDurations); } } diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 456648f79..43423089c 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -1,4 +1,9 @@ export interface Layer { + /** + * Stable display name for profiling/overlays. Avoid relying on + * `constructor.name` since production builds may minify it. + */ + profileName?: string; init?: () => void; tick?: () => void; // Optional hint to throttle expensive ticks by wall-clock. diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ad27aeaa5..90a6c271b 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -77,6 +77,44 @@ export class PerformanceOverlay extends LitElement implements Layer { total: number; }[] = []; + // Smoothed per-layer tick timings (EMA over recent ticks) + private tickLayerStats: Map< + string, + { avg: number; max: number; last: number; total: number } + > = new Map(); + + @state() + private tickLayerBreakdown: { + name: string; + avg: number; + max: number; + total: number; + }[] = []; + + @state() + private tickLayerLastCount: number = 0; + + @state() + private tickLayerLastTotalMs: number = 0; + + @state() + private tickLayerLastDurations: Record = {}; + + @state() + private renderLastTickFrameCount: number = 0; + + @state() + private renderLastTickLayerTotalMs: number = 0; + + @state() + private renderLastTickLayerDurations: Record = {}; + + // Smoothed per-layer render-per-tick timings (EMA over recent ticks) + private renderPerTickLayerStats: Map< + string, + { avg: number; max: number; last: number; total: number } + > = new Map(); + static styles = css` .performance-overlay { position: fixed; @@ -85,7 +123,7 @@ export class PerformanceOverlay extends LitElement implements Layer { transform: var(--transform, translateX(-50%)); background: rgba(0, 0, 0, 0.8); color: white; - padding: 8px 16px; + padding: 32px 16px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; @@ -190,6 +228,19 @@ export class PerformanceOverlay extends LitElement implements Layer { gap: 6px; font-size: 11px; margin-top: 2px; + padding: 2px 4px; + border-radius: 3px; + background: linear-gradient( + 90deg, + rgba(56, 189, 248, 0.35) 0%, + rgba(56, 189, 248, 0.35) var(--pct, 0%), + rgba(56, 189, 248, 0) var(--pct, 0%), + rgba(56, 189, 248, 0) 100% + ); + } + + .layer-row.inactive { + opacity: 0.5; } .layer-name { @@ -199,21 +250,6 @@ export class PerformanceOverlay extends LitElement implements Layer { 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%; - width: var(--width); - background: #38bdf8; - border-radius: 3px; - } - .layer-metrics { flex: 0 0 auto; white-space: nowrap; @@ -313,6 +349,17 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerStats.clear(); this.layerBreakdown = []; + // reset tick layer breakdown + this.tickLayerStats.clear(); + this.tickLayerBreakdown = []; + this.tickLayerLastCount = 0; + this.tickLayerLastTotalMs = 0; + this.tickLayerLastDurations = {}; + this.renderLastTickFrameCount = 0; + this.renderLastTickLayerTotalMs = 0; + this.renderLastTickLayerDurations = {}; + this.renderPerTickLayerStats.clear(); + this.requestUpdate(); }; @@ -419,6 +466,86 @@ export class PerformanceOverlay extends LitElement implements Layer { this.layerBreakdown = breakdown; } + updateRenderPerTickMetrics( + frameCount: number, + layerDurations: Record, + ) { + if (!this.isVisible || !this.userSettings.performanceOverlay()) return; + + const alpha = 0.2; // smoothing factor for EMA + + this.renderLastTickFrameCount = frameCount; + this.renderLastTickLayerDurations = { ...layerDurations }; + this.renderLastTickLayerTotalMs = Object.values(layerDurations).reduce( + (acc, ms) => acc + ms, + 0, + ); + + for (const [name, duration] of Object.entries(layerDurations)) { + const existing = this.renderPerTickLayerStats.get(name); + if (!existing) { + this.renderPerTickLayerStats.set(name, { + avg: duration, + max: duration, + last: duration, + total: duration, + }); + continue; + } + + const avg = existing.avg + alpha * (duration - existing.avg); + const max = Math.max(existing.max, duration); + const total = existing.total + duration; + this.renderPerTickLayerStats.set(name, { + avg, + max, + last: duration, + total, + }); + } + } + + updateTickLayerMetrics(tickLayerDurations: Record) { + if (!this.isVisible || !this.userSettings.performanceOverlay()) return; + + const alpha = 0.2; // smoothing factor for EMA + + const entries = Object.entries(tickLayerDurations); + this.tickLayerLastCount = entries.length; + this.tickLayerLastDurations = { ...tickLayerDurations }; + this.tickLayerLastTotalMs = entries.reduce((acc, [, duration]) => { + return acc + duration; + }, 0); + + entries.forEach(([name, duration]) => { + const existing = this.tickLayerStats.get(name); + if (!existing) { + this.tickLayerStats.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.tickLayerStats.set(name, { avg, max, last: duration, total }); + } + }); + + const breakdown = Array.from(this.tickLayerStats.entries()) + .map(([name, stats]) => ({ + name, + avg: stats.avg, + max: stats.max, + total: stats.total, + })) + .sort((a, b) => b.total - a.total); + + this.tickLayerBreakdown = breakdown; + } + updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) { if (!this.isVisible || !this.userSettings.performanceOverlay()) return; @@ -486,7 +613,13 @@ export class PerformanceOverlay extends LitElement implements Layer { executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, + renderPerTickLast: { + frames: this.renderLastTickFrameCount, + layerTotalMs: this.renderLastTickLayerTotalMs, + layers: { ...this.renderLastTickLayerDurations }, + }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), + tickLayers: this.tickLayerBreakdown.map((layer) => ({ ...layer })), }; } @@ -545,9 +678,17 @@ export class PerformanceOverlay extends LitElement implements Layer { ? translateText("performance_overlay.failed_copy") : translateText("performance_overlay.copy_clipboard"); + const renderLayersToShow = this.layerBreakdown.slice(0, 10); + const tickLayersToShow = this.tickLayerBreakdown.slice(0, 10); + const maxLayerAvg = - this.layerBreakdown.length > 0 - ? Math.max(...this.layerBreakdown.map((l) => l.avg)) + renderLayersToShow.length > 0 + ? Math.max(...renderLayersToShow.map((l) => l.avg)) + : 1; + + const maxTickLayerAvg = + tickLayersToShow.length > 0 + ? Math.max(...tickLayersToShow.map((l) => l.avg)) : 1; return html` @@ -601,21 +742,68 @@ export class PerformanceOverlay extends LitElement implements Layer {
${translateText("performance_overlay.layers_header")}
- ${this.layerBreakdown.map((layer) => { +
+ ${translateText("performance_overlay.render_layers_summary", { + frames: this.renderLastTickFrameCount, + ms: this.renderLastTickLayerTotalMs.toFixed(2), + })} +
+ ${renderLayersToShow.map((layer) => { const width = Math.min( 100, (layer.avg / maxLayerAvg) * 100 || 0, ); - return html`
+ const perTickRenderMs = + this.renderLastTickLayerDurations[layer.name] ?? 0; + const perTickRenderAvgMs = + this.renderPerTickLayerStats.get(layer.name)?.avg ?? 0; + const isInactive = perTickRenderMs <= 0.01; + const title = `${layer.name} | last tick render: ${perTickRenderMs.toFixed( + 2, + )}ms`; + return html`
${layer.name} -
-
-
+ + ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms | + ${perTickRenderAvgMs.toFixed(2)}ms + +
`; + })} +
` + : html``} + ${this.tickLayerBreakdown.length + ? html`
+
+ ${translateText("performance_overlay.tick_layers_header")} +
+
+ ${translateText("performance_overlay.tick_layers_summary", { + count: this.tickLayerLastCount, + ms: this.tickLayerLastTotalMs.toFixed(2), + })} +
+ ${tickLayersToShow.map((layer) => { + const width = Math.min( + 100, + (layer.avg / maxTickLayerAvg) * 100 || 0, + ); + const lastTickMs = this.tickLayerLastDurations[layer.name] ?? 0; + const isInactive = lastTickMs <= 0.01; + const title = `${layer.name} | last tick: ${lastTickMs.toFixed(2)}ms`; + return html`
+ ${layer.name} ${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms