Files
OpenFrontIO/src/client/graphics/GameRenderer.ts
T
scamiv 7373a28c99 Feature/frame profiler (#2467)
## Description:

Adds a reusable FrameProfiler utility, and a way to export profiling
data for offline analysis.


### What this PR changes in the existing performance monitor

This PR enhances the performance monitor by:

- **Introducing a reusable `FrameProfiler` utility**
- New `FrameProfiler` singleton in
`src/client/graphics/FrameProfiler.ts`.
- Profiling is only active when the performance overlay is visible
(toggled via user settings), to avoid unnecessary overhead.

- **Per-layer and span-level timing integration**
  - `GameRenderer.renderGame` now:
    - Clears the profiler at the start of each frame.
- Wraps each `layer.renderLayer?.(this.context)` call with
`FrameProfiler.start()/end()`, keyed by the layer’s constructor name.
- Consumes the recorded timings at the end of the frame and passes them
into `PerformanceOverlay.updateFrameMetrics(frameDuration,
layerDurations)`.
  - `TerritoryLayer` instruments key operations:
    - `renderTerritory`
    - `putImageData`
    - Drawing the main canvas
    - Drawing the highlight canvas during spawn
- These show up in the performance overlay as additional entries (e.g.
`TerritoryLayer:renderTerritory`).

- **JSON export of performance snapshots**
- `PerformanceOverlay` can now build a full performance snapshot
(`buildPerformanceSnapshot`) containing:
    - FPS and frame time stats (current, 60s average, 60s history).
    - Tick metrics (avg/max execution and delay, plus raw samples).
- Layer breakdown (EMA-smoothed avg, max, total time per layer/span).
  - A new “Copy JSON” button:
- Uses `navigator.clipboard.writeText` when available and falls back to
a hidden `<textarea>` + `document.execCommand("copy")`.
- Provides user feedback via a transient status ("Copy JSON" → "Copied!"
or "Failed to copy").

- **Enable/disable functionality hooked into the UI**
  - `FrameProfiler.setEnabled(visible)` is invoked:
    - When the overlay visibility is toggled (`init` → `setVisible`).
- When the overlay re-checks visibility in `updateFrameMetrics`, so the
profiler state stays in sync with user settings.
- When disabled, `FrameProfiler` becomes a no-op (returns `0` from
`start`, ignores `record`/`end`, and `consume` returns an empty object),
ensuring minimal overhead when performance monitoring is off.

- **Performance overlay UX and i18n improvements**
  - New controls:
    - **Reset** button to clear all FPS/tick/layer stats.
    - **Copy JSON** button with a tooltip and transient status text.
  - Visual enhancements:
- Wider overlay (`min-width: 420px`) and extra padding for readability.
    - Layer breakdown section with:
      - A list that is now sorted by total accumulated time.
      - A horizontal bar per entry, scaled by average cost.
      - Avg / max time display per layer/span.
- All new text is routed through `translateText` and backed by
`en.json`:
    - `performance_overlay.reset`
    - `performance_overlay.copy_json_title`
    - `performance_overlay.copy_clipboard`
    - `performance_overlay.copied`
    - `performance_overlay.failed_copy`
    - `performance_overlay.fps`
    - `performance_overlay.avg_60s`
    - `performance_overlay.frame`
    - `performance_overlay.tick_exec`
    - `performance_overlay.tick_delay`
    - `performance_overlay.layers_header`

---

### How to set up profiling for new functions / code paths

For any function or code block you want to profile during a frame:

```ts
import { FrameProfiler } from "../FrameProfiler"; 

function heavyOperation() {
  const spanStart = FrameProfiler.start();
  // ... your existing work ...
  FrameProfiler.end("MyFeature:heavyOperation", spanStart);
}
```

Guidelines:

- Use descriptive, stable names:
  - Prefix with the component or layer name, e.g.:
    - `"TerritoryLayer:prepareTiles"`
    - `"GameRenderer:resolveVisibility"`
    - `"FooFeature:fetchData"`
- The same name can be called multiple times per frame; the profiler
accumulates the durations in that frame.
- The accumulated values will appear:
  - In `layerDurations` consumed at the end of the frame.
  - In the overlay “Layers (avg / max, sorted by total time)” section.
  - In the exported JSON under `layers` with `avg`, `max`, and `total`.

**3. Record pre-computed durations (optional)**

If you already have a measured duration and just want to attach it:

```ts
FrameProfiler.record("MyFeature:step1", someDurationInMs);
```

- This is equivalent to calling `start`/`end` but with your own timing
logic.
- Again, multiple calls with the same name in one frame will be summed.

---

<img width="466" height="823" alt="image"
src="https://github.com/user-attachments/assets/354b249a-25eb-4c3f-bd2e-9906372f761b"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates

- [x] 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

- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

---------

Co-authored-by: Evan <evanpelle@gmail.com>
2025-11-16 19:10:20 -08:00

410 lines
13 KiB
TypeScript

import { EventBus } from "../../core/EventBus";
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";
import { AlertFrame } from "./layers/AlertFrame";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
export function createRenderer(
canvas: HTMLCanvasElement,
game: GameView,
eventBus: EventBus,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas);
const userSettings = new UserSettings();
const uiState = { attackRatio: 20, ghostStructure: null } as UIState;
//hide when the game renders
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
startingModal.hide();
// TODO maybe append this to document instead of querying for them?
const emojiTable = document.querySelector("emoji-table") as EmojiTable;
if (!emojiTable || !(emojiTable instanceof EmojiTable)) {
console.error("EmojiTable element not found in the DOM");
}
emojiTable.transformHandler = transformHandler;
emojiTable.game = game;
emojiTable.initEventBus(eventBus);
const buildMenu = document.querySelector("build-menu") as BuildMenu;
if (!buildMenu || !(buildMenu instanceof BuildMenu)) {
console.error("BuildMenu element not found in the DOM");
}
buildMenu.game = game;
buildMenu.eventBus = eventBus;
buildMenu.transformHandler = transformHandler;
const leaderboard = document.querySelector("leader-board") as Leaderboard;
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
console.error("LeaderBoard element not found in the DOM");
}
leaderboard.eventBus = eventBus;
leaderboard.game = game;
const gameLeftSidebar = document.querySelector(
"game-left-sidebar",
) as GameLeftSidebar;
if (!gameLeftSidebar || !(gameLeftSidebar instanceof GameLeftSidebar)) {
console.error("GameLeftSidebar element not found in the DOM");
}
gameLeftSidebar.game = game;
const teamStats = document.querySelector("team-stats") as TeamStats;
if (!teamStats || !(teamStats instanceof TeamStats)) {
console.error("TeamStats element not found in the DOM");
}
teamStats.eventBus = eventBus;
teamStats.game = game;
const controlPanel = document.querySelector("control-panel") as ControlPanel;
if (!(controlPanel instanceof ControlPanel)) {
console.error("ControlPanel element not found in the DOM");
}
controlPanel.eventBus = eventBus;
controlPanel.uiState = uiState;
controlPanel.game = game;
const eventsDisplay = document.querySelector(
"events-display",
) as EventsDisplay;
if (!(eventsDisplay instanceof EventsDisplay)) {
console.error("events display not found");
}
eventsDisplay.eventBus = eventBus;
eventsDisplay.game = game;
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
if (!(chatDisplay instanceof ChatDisplay)) {
console.error("chat display not found");
}
chatDisplay.eventBus = eventBus;
chatDisplay.game = game;
const playerInfo = document.querySelector(
"player-info-overlay",
) as PlayerInfoOverlay;
if (!(playerInfo instanceof PlayerInfoOverlay)) {
console.error("player info overlay not found");
}
playerInfo.eventBus = eventBus;
playerInfo.transform = transformHandler;
playerInfo.game = game;
const winModal = document.querySelector("win-modal") as WinModal;
if (!(winModal instanceof WinModal)) {
console.error("win modal not found");
}
winModal.eventBus = eventBus;
winModal.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("replay panel not found");
}
replayPanel.eventBus = eventBus;
replayPanel.game = game;
const gameRightSidebar = document.querySelector(
"game-right-sidebar",
) as GameRightSidebar;
if (!(gameRightSidebar instanceof GameRightSidebar)) {
console.error("Game Right bar not found");
}
gameRightSidebar.game = game;
gameRightSidebar.eventBus = eventBus;
const settingsModal = document.querySelector(
"settings-modal",
) as SettingsModal;
if (!(settingsModal instanceof SettingsModal)) {
console.error("settings modal not found");
}
settingsModal.userSettings = userSettings;
settingsModal.eventBus = eventBus;
const unitDisplay = document.querySelector("unit-display") as UnitDisplay;
if (!(unitDisplay instanceof UnitDisplay)) {
console.error("unit display not found");
}
unitDisplay.game = game;
unitDisplay.eventBus = eventBus;
unitDisplay.uiState = uiState;
const playerPanel = document.querySelector("player-panel") as PlayerPanel;
if (!(playerPanel instanceof PlayerPanel)) {
console.error("player panel not found");
}
playerPanel.g = game;
playerPanel.initEventBus(eventBus);
playerPanel.emojiTable = emojiTable;
playerPanel.uiState = uiState;
const chatModal = document.querySelector("chat-modal") as ChatModal;
if (!(chatModal instanceof ChatModal)) {
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.initEventBus(eventBus);
const multiTabModal = document.querySelector(
"multi-tab-modal",
) as MultiTabModal;
if (!(multiTabModal instanceof MultiTabModal)) {
console.error("multi-tab modal not found");
}
multiTabModal.game = game;
const headsUpMessage = document.querySelector(
"heads-up-message",
) as HeadsUpMessage;
if (!(headsUpMessage instanceof HeadsUpMessage)) {
console.error("heads-up message not found");
}
headsUpMessage.game = game;
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const samRadiusLayer = new SAMRadiusLayer(
game,
eventBus,
transformHandler,
uiState,
);
const performanceOverlay = document.querySelector(
"performance-overlay",
) as PerformanceOverlay;
if (!(performanceOverlay instanceof PerformanceOverlay)) {
console.error("performance overlay not found");
}
performanceOverlay.eventBus = eventBus;
performanceOverlay.userSettings = userSettings;
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
}
alertFrame.game = game;
const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer;
if (!(spawnTimer instanceof SpawnTimer)) {
console.error("spawn timer not found");
}
spawnTimer.game = game;
spawnTimer.transformHandler = transformHandler;
// When updating these layers please be mindful of the order.
// 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, transformHandler),
structureLayer,
samRadiusLayer,
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
buildMenu,
new MainRadialMenu(
eventBus,
game,
transformHandler,
emojiTable as EmojiTable,
buildMenu,
uiState,
playerPanel,
),
spawnTimer,
leaderboard,
gameLeftSidebar,
unitDisplay,
gameRightSidebar,
controlPanel,
playerInfo,
winModal,
replayPanel,
settingsModal,
teamStats,
playerPanel,
headsUpMessage,
multiTabModal,
new AdTimer(game),
alertFrame,
performanceOverlay,
];
return new GameRenderer(
game,
eventBus,
canvas,
transformHandler,
uiState,
layers,
performanceOverlay,
);
}
export class GameRenderer {
private context: CanvasRenderingContext2D;
constructor(
private game: GameView,
private eventBus: EventBus,
private canvas: HTMLCanvasElement,
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[],
private performanceOverlay: PerformanceOverlay,
) {
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
}
initialize() {
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.layers.forEach((l) => l.init?.());
document.body.appendChild(this.canvas);
window.addEventListener("resize", () => this.resizeCanvas());
this.resizeCanvas();
//show whole map on startup
this.transformHandler.centerAll(0.9);
let rafId = requestAnimationFrame(() => this.renderGame());
this.canvas.addEventListener("contextlost", () => {
cancelAnimationFrame(rafId);
});
this.canvas.addEventListener("contextrestored", () => {
this.redraw();
rafId = requestAnimationFrame(() => this.renderGame());
});
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.transformHandler.updateCanvasBoundingRect();
//this.redraw()
}
redraw() {
this.layers.forEach((l) => {
if (l.redraw) {
l.redraw();
}
});
}
renderGame() {
FrameProfiler.clear();
const start = performance.now();
// Set background
this.context.fillStyle = this.game
.config()
.theme()
.backgroundColor()
.toHex();
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
const handleTransformState = (
needsTransform: boolean,
active: boolean,
): boolean => {
if (needsTransform && !active) {
this.context.save();
this.transformHandler.handleTransform(this.context);
return true;
} else if (!needsTransform && active) {
this.context.restore();
return false;
}
return active;
};
let isTransformActive = false;
for (const layer of this.layers) {
const needsTransform = layer.shouldTransform?.() ?? false;
isTransformActive = handleTransformState(
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();
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
const layerDurations = FrameProfiler.consume();
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
if (duration > 50) {
console.warn(
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
);
}
}
tick() {
this.layers.forEach((l) => l.tick?.());
}
resize(width: number, height: number): void {
this.canvas.width = Math.ceil(width / window.devicePixelRatio);
this.canvas.height = Math.ceil(height / window.devicePixelRatio);
}
}