perf: improve performance overlay profiling

This commit is contained in:
scamiv
2026-02-22 16:21:41 +01:00
parent 7c6c2b1fd8
commit 3acb624ced
4 changed files with 330 additions and 71 deletions
+4 -1
View File
@@ -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",
+107 -44
View File
@@ -46,6 +46,20 @@ import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
function namedLayer<T extends Layer>(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<Layer, { lastTickAtMs: number }>();
private renderFramesSinceLastTick: number = 0;
private renderLayerDurationsSinceLastTick: Record<string, number> = {};
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<string, number> = {};
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);
}
}
+5
View File
@@ -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.
+214 -26
View File
@@ -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<string, number> = {};
@state()
private renderLastTickFrameCount: number = 0;
@state()
private renderLastTickLayerTotalMs: number = 0;
@state()
private renderLastTickLayerDurations: Record<string, number> = {};
// 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<string, number>,
) {
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<string, number>) {
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 {
<div class="performance-line">
${translateText("performance_overlay.layers_header")}
</div>
${this.layerBreakdown.map((layer) => {
<div class="performance-line">
${translateText("performance_overlay.render_layers_summary", {
frames: this.renderLastTickFrameCount,
ms: this.renderLastTickLayerTotalMs.toFixed(2),
})}
</div>
${renderLayersToShow.map((layer) => {
const width = Math.min(
100,
(layer.avg / maxLayerAvg) * 100 || 0,
);
return html`<div class="layer-row">
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`<div
class="layer-row ${isInactive ? "inactive" : ""}"
style="--pct: ${width}%;"
title=${title}
>
<span class="layer-name" title=${layer.name}
>${layer.name}
</span>
<div class="layer-bar">
<div
class="layer-bar-fill"
style="--width: ${width}%;"
></div>
</div>
<span class="layer-metrics">
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms |
${perTickRenderAvgMs.toFixed(2)}ms
</span>
</div>`;
})}
</div>`
: html``}
${this.tickLayerBreakdown.length
? html`<div class="layers-section">
<div class="performance-line">
${translateText("performance_overlay.tick_layers_header")}
</div>
<div class="performance-line">
${translateText("performance_overlay.tick_layers_summary", {
count: this.tickLayerLastCount,
ms: this.tickLayerLastTotalMs.toFixed(2),
})}
</div>
${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`<div
class="layer-row ${isInactive ? "inactive" : ""}"
style="--pct: ${width}%;"
title=${title}
>
<span class="layer-name" title=${layer.name}
>${layer.name}</span
>
<span class="layer-metrics">
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
</span>