mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 14:26:10 +00:00
perf: improve performance overlay profiling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user