diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index aa9e30d4..c31a6bf6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -19,7 +19,7 @@ import { ControlPanel2 } from "./layers/ControlPanel2"; import { DevHud } from "./layers/DevHud"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; -import { FxLayer } from "./layers/FxLayer"; +import { FxLayerV2 as FxLayer } from "./layers/FxLayerV2"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; diff --git a/src/client/graphics/layers/FxLayerV2.ts b/src/client/graphics/layers/FxLayerV2.ts new file mode 100644 index 00000000..5f46732e --- /dev/null +++ b/src/client/graphics/layers/FxLayerV2.ts @@ -0,0 +1,444 @@ +/** + * FxLayerV2 — Optimised FX rendering layer + * ========================================== + * + * Drop-in replacement for FxLayer with the following performance + * improvements (preserves identical visual output): + * + * 1. Swap-and-pop removal — O(1) per dead FX vs O(n) splice + * 2. Cached displayObject in FxInfo — removes per-frame virtual dispatch + * 3. Reusable tmp Cell — avoids `new Cell()` per FX on camera change + * 4. Direct for-loops in tick() — no .map().forEach() intermediate arrays + * 5. Separate addFxSingle / addFxMultiple — no Array.isArray + wrapper + * 6. ReadonlySet for FX-eligible unit types — O(1) lookup + * 7. Cached scale per position-update batch — one read, not per-FX + * 8. Inline worldToScreen math — avoids return-object allocation per FX + * 9. Active count tracking — avoid repeated .length access + * 10. Lazy renderer.render() — skip PIXI pass when scene is idle + */ + +import * as PIXI from "pixi.js"; +import { Theme } from "../../../core/configuration/Config"; +import { Cell, UnitType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "../fx/Fx"; +import { doomsdayFxFactory, nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; +import { SpriteFx } from "../fx/SpriteFx"; +import { UnitExplosionFx } from "../fx/UnitExplosionFx"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +// ── Opt 2: Cache the PIXI display object alongside the Fx ────────────── +interface FxInfo { + fx: Fx; + displayObject: PIXI.Container; // cached once at addFx time + worldX: number; + worldY: number; +} + +// ── Opt 6: Unit types that trigger FX events ──────────────────────────── +const NUKE_SMALL_TYPES: ReadonlySet = new Set([ + UnitType.AtomBomb, + UnitType.MIRVWarhead, +]); + +export class FxLayerV2 implements Layer { + layerName = "FxLayer"; + private renderer: PIXI.Renderer; + private stage: PIXI.Container; + private pixicanvas: HTMLCanvasElement; + + private lastRefresh: number = 0; + private refreshRate: number = 10; + private adaptiveRefresh: boolean = true; + private theme: Theme; + private animatedSpriteLoader: AnimatedSpriteLoader = + new AnimatedSpriteLoader(); + + private allFx: FxInfo[] = []; + + // ── Opt 9: Track count separately to avoid repeated .length reads ─ + private activeCount: number = 0; + + // ── Opt 10: Track whether the scene is dirty (FX added / removed) ─ + private sceneDirty: boolean = false; + + // ── Opt 3: Reusable point for worldToScreenCoordinates ──────────── + private readonly _tmpCell: { x: number; y: number } = { x: 0, y: 0 }; + + // ── Opt 8: Cache transform intermediates ────────────────────────── + // Filled once per position-update batch in _cacheTransform() + private _txScale: number = 1; + private _txOffsetX: number = 0; + private _txOffsetY: number = 0; + private _txHalfW: number = 0; + private _txHalfH: number = 0; + private _txRectLeft: number = 0; + private _txRectTop: number = 0; + + constructor( + private game: GameView, + private transformHandler: TransformHandler, + ) { + this.theme = this.game.config().theme(); + } + + shouldTransform(): boolean { + return false; + } + + async init() { + this.renderer = new PIXI.WebGLRenderer(); + this.pixicanvas = document.createElement("canvas"); + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + + this.pixicanvas.style.position = "fixed"; + this.pixicanvas.style.left = "0"; + this.pixicanvas.style.top = "0"; + this.pixicanvas.style.width = "100%"; + this.pixicanvas.style.height = "100%"; + this.pixicanvas.style.pointerEvents = "none"; + this.pixicanvas.style.zIndex = "35"; + document.body.appendChild(this.pixicanvas); + + this.stage = new PIXI.Container(); + + await this.renderer.init({ + canvas: this.pixicanvas, + width: this.pixicanvas.width, + height: this.pixicanvas.height, + backgroundAlpha: 0, + clearBeforeRender: true, + }); + + window.addEventListener("resize", () => this.resizeCanvas()); + + try { + await this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); + console.log("FX sprites loaded successfully"); + } catch (err) { + console.error("Failed to load FX sprites:", err); + } + } + + resizeCanvas() { + if (this.renderer) { + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + this.renderer.resize(window.innerWidth, window.innerHeight); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // tick() — Opt 4: direct for-loops, no intermediate arrays + // ═══════════════════════════════════════════════════════════════════════ + + tick() { + const updates = this.game.updatesSinceLastTick(); + if (!updates) return; + + // ── Unit updates ── + const unitUpdates = updates[GameUpdateType.Unit]; + if (unitUpdates) { + for (let i = 0, len = unitUpdates.length; i < len; i++) { + const unitView = this.game.unit(unitUpdates[i].id); + if (unitView !== undefined) { + this.onUnitEvent(unitView); + } + } + } + + // ── Bomber explosions ── + const bomberUpdates = updates[GameUpdateType.BomberExplosion]; + if (bomberUpdates) { + for (let i = 0, len = bomberUpdates.length; i < len; i++) { + const update = bomberUpdates[i]; + const bomberFx = nukeFxFactory( + this.animatedSpriteLoader, + 0, + 0, + update.radius, + this.game, + 0.2, + ); + this.addFxMultiple(bomberFx, update.x, update.y); + } + } + + // ── Doomsday explosions ── + const doomUpdates = updates[GameUpdateType.DoomsdayExplosion]; + if (doomUpdates) { + for (let i = 0, len = doomUpdates.length; i < len; i++) { + const update = doomUpdates[i]; + const doomFx = doomsdayFxFactory( + this.animatedSpriteLoader, + 0, + 0, + update.radius, + this.game, + ); + this.addFxMultiple(doomFx, update.x, update.y); + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // addFx — Opt 5: separate single/multiple paths, no Array.isArray + // ═══════════════════════════════════════════════════════════════════════ + + private addFxSingle(fx: Fx, worldX: number, worldY: number) { + const displayObject = fx.getDisplayObject(); + const info: FxInfo = { fx, displayObject, worldX, worldY }; + this.allFx.push(info); + this.activeCount++; + this.stage.addChild(displayObject); + this.updateFxPositionInline(info); + this.sceneDirty = true; + } + + private addFxMultiple(fxList: Fx[], worldX: number, worldY: number) { + for (let i = 0, len = fxList.length; i < len; i++) { + this.addFxSingle(fxList[i], worldX, worldY); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Unit event routing — Opt 6: ReadonlySet lookup + // ═══════════════════════════════════════════════════════════════════════ + + private onUnitEvent(unit: UnitView) { + const type = unit.type(); + + if (NUKE_SMALL_TYPES.has(type)) { + this.onNukeEvent(unit, 70); + } else if (type === UnitType.HydrogenBomb) { + this.onNukeEvent(unit, 160); + } else if (type === UnitType.Warship) { + this.onWarshipEvent(unit); + } else if (type === UnitType.Shell) { + this.onShellEvent(unit); + } else if (type === UnitType.AABullet) { + this.onAABulletEvent(unit); + } + } + + private onAABulletEvent(unit: UnitView) { + if (!unit.isActive() && unit.reachedTarget()) { + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); + this.addFxSingle( + new SpriteFx(this.animatedSpriteLoader, 0, 0, FxType.MiniExplosion), + worldX, + worldY, + ); + } + } + + private onShellEvent(unit: UnitView) { + if (!unit.isActive() && unit.reachedTarget()) { + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); + this.addFxSingle( + new SpriteFx(this.animatedSpriteLoader, 0, 0, FxType.MiniExplosion), + worldX, + worldY, + ); + } + } + + private onWarshipEvent(unit: UnitView) { + if (!unit.isActive()) { + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); + this.addFxSingle( + new UnitExplosionFx(this.animatedSpriteLoader, 0, 0, this.game), + worldX, + worldY, + ); + this.addFxSingle( + new SpriteFx( + this.animatedSpriteLoader, + 0, + 0, + FxType.SinkingShip, + undefined, + unit.owner(), + this.theme, + ), + worldX, + worldY, + ); + } + } + + private onNukeEvent(unit: UnitView, radius: number) { + if (!unit.isActive()) { + if (!unit.reachedTarget()) { + this.handleSAMInterception(unit); + } else { + this.handleNukeExplosion(unit, radius); + } + } + } + + private handleNukeExplosion(unit: UnitView, radius: number) { + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); + this.addFxMultiple( + nukeFxFactory(this.animatedSpriteLoader, 0, 0, radius, this.game), + worldX, + worldY, + ); + } + + private handleSAMInterception(unit: UnitView) { + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); + this.addFxSingle( + new SpriteFx(this.animatedSpriteLoader, 0, 0, FxType.SAMExplosion), + worldX, + worldY, + ); + this.addFxSingle(new ShockwaveFx(0, 0, 800, 40), worldX, worldY); + } + + redraw(): void { + // No-op + } + + // ═══════════════════════════════════════════════════════════════════════ + // Position update — Opt 3 + 7 + 8: reuse tmp cell, cache scale, + // inline worldToScreen math + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Snapshot the transform handler's constants once before a batch of + * position updates. Avoids repeated property access per FX. + */ + private _cacheTransform() { + this._txScale = this.transformHandler.scale; + // Access the internal offset/width via worldToScreenCoordinates on a + // known point, then derive the linear transform coefficients. + // We call once with (0,0) and once with (1,0) to get the two + // unknowns: + // screenX = scale * worldX + bx + // screenY = scale * worldY + by + + const a = this.transformHandler.worldToScreenCoordinates( + new Cell(0, 0) as Cell, + ); + const b = this.transformHandler.worldToScreenCoordinates( + new Cell(1, 0) as Cell, + ); + const sx = b.x - a.x; // effective scale-x + // For efficiency we pre-compute bx/by from a. + this._txOffsetX = a.x; // bx = screen(0,0).x + this._txOffsetY = a.y; // by = screen(0,0).y + this._txHalfW = sx; // ~= scale * dpr + canvasRect corrections + // We assume uniform scale (sx === sy) + } + + /** + * Inline position update — avoids Cell allocation + return-object. + * Uses cached transform from `_cacheTransform()`. + */ + private updateFxPositionFast(fxInfo: FxInfo) { + const dObj = fxInfo.displayObject; + dObj.x = this._txOffsetX + fxInfo.worldX * this._txHalfW; + dObj.y = this._txOffsetY + fxInfo.worldY * this._txHalfW; + dObj.scale.set(this._txScale); + } + + /** + * Fallback for single-FX position update (e.g. in addFxSingle). + * Still avoids allocation via _tmpCell. (Opt 3) + */ + private updateFxPositionInline(fxInfo: FxInfo) { + this._tmpCell.x = fxInfo.worldX; + this._tmpCell.y = fxInfo.worldY; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpCell as Cell, + ); + const dObj = fxInfo.displayObject; + dObj.x = screenPos.x; + dObj.y = screenPos.y; + dObj.scale.set(this.transformHandler.scale); + } + + // ═══════════════════════════════════════════════════════════════════════ + // renderLayer — Opt 1 + 9 + 10 + // ═══════════════════════════════════════════════════════════════════════ + + renderLayer(context: CanvasRenderingContext2D) { + if (!this.renderer) return; + + const now = Date.now(); + const fxEnabled = this.game.config().userSettings()?.fxLayer(); + + if (fxEnabled) { + if (now > this.lastRefresh + this.refreshRate) { + const delta = now - this.lastRefresh; + this.updateFx(delta); + this.lastRefresh = now; + } + + // ── Opt 7 + 8: Batch position update with cached transform ── + if (this.transformHandler.hasChanged()) { + this._cacheTransform(); + const arr = this.allFx; + for (let i = 0, len = this.activeCount; i < len; i++) { + this.updateFxPositionFast(arr[i]); + } + } + + // ── Opt 10: Only call renderer.render when scene has content ── + if (this.activeCount > 0 || this.sceneDirty) { + this.renderer.render(this.stage); + this.sceneDirty = false; + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // updateFx — Opt 1: Swap-and-pop removal + // ═══════════════════════════════════════════════════════════════════════ + + updateFx(delta: number) { + const count = this.activeCount; + if (count === 0) return; + + const t0 = performance.now(); + const arr = this.allFx; + + let i = 0; + let len = count; + + while (i < len) { + const fxInfo = arr[i]; + if (!fxInfo.fx.update(delta)) { + // ── Opt 1: Swap dead FX with last element, pop ── + this.stage.removeChild(fxInfo.displayObject); // Opt 2: cached ref + len--; + if (i < len) { + arr[i] = arr[len]; // swap + } + arr.length = len; // pop (truncate) + this.sceneDirty = true; + // Don't increment i — re-check swapped element + } else { + i++; + } + } + + this.activeCount = len; + + if (this.adaptiveRefresh) { + const elapsed = performance.now() - t0; + this.refreshRate = + elapsed > 12 ? Math.min(33, Math.ceil(elapsed * 2)) : 16; + } + } +} diff --git a/tests/client/layers/FxLayer.perf.test.ts b/tests/client/layers/FxLayer.perf.test.ts new file mode 100644 index 00000000..8dcb0eea --- /dev/null +++ b/tests/client/layers/FxLayer.perf.test.ts @@ -0,0 +1,103 @@ +/** + * FxLayer Performance Benchmark + * ============================== + * Baseline benchmark for the Canvas2D+PIXI FxLayer. + * Uses the fx-layer-bench-harness factory pattern so results can be + * compared against alternative implementations. + * + * @jest-environment jsdom + */ + +import { + installCanvasMock, + mockAnimatedSpriteLoaderModule, + PIXI_MOCK_MODULE, + runFxBenchSuite, +} from "./fx-layer-bench-harness"; + +// ── Must mock pixi.js and AnimatedSpriteLoader BEFORE importing FxLayer ── +jest.mock("pixi.js", () => PIXI_MOCK_MODULE); +mockAnimatedSpriteLoaderModule(); + +// Mock the sprite image imports (webpack URLs → empty strings) +jest.mock("../../../resources/sprites/bigsmoke.png", () => "bigsmoke.png"); +jest.mock( + "../../../resources/sprites/miniExplosion.png", + () => "miniExplosion.png", +); +jest.mock("../../../resources/sprites/minifire.png", () => "minifire.png"); +jest.mock( + "../../../resources/sprites/nukeExplosion.png", + () => "nukeExplosion.png", +); +jest.mock( + "../../../resources/sprites/samExplosion.png", + () => "samExplosion.png", +); +jest.mock( + "../../../resources/sprites/sinkingShip.png", + () => "sinkingShip.png", +); +jest.mock("../../../resources/sprites/smoke.png", () => "smoke.png"); +jest.mock( + "../../../resources/sprites/smokeAndFire.png", + () => "smokeAndFire.png", +); +jest.mock( + "../../../resources/sprites/unitExplosion.png", + () => "unitExplosion.png", +); + +// Mock SpriteLoader.colorizeCanvas (used by AnimatedSpriteLoader) +jest.mock("../../../src/client/graphics/SpriteLoader", () => ({ + colorizeCanvas: () => { + const c = { + width: 64, + height: 16, + getContext: () => ({ + drawImage: () => {}, + getImageData: () => ({ + data: new Uint8ClampedArray(64 * 16 * 4), + width: 64, + height: 16, + }), + putImageData: () => {}, + clearRect: () => {}, + }), + }; + return c; + }, + getColoredSprite: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { FxLayer } = require("../../../src/client/graphics/layers/FxLayer"); + +// ── Install mocks ── +beforeAll(() => { + // Stub window.addEventListener for resize handler + jest.spyOn(window, "addEventListener").mockImplementation(() => {}); + + // Stub createImageBitmap (used by AnimatedSpriteLoader.loadAllAnimatedSpriteImages) + (globalThis as any).createImageBitmap = async () => ({ + width: 64, + height: 16, + close: () => {}, + }); +}); + +beforeEach(() => { + installCanvasMock(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ── Run the benchmark suite ── +describe("FxLayer (baseline)", () => { + runFxBenchSuite( + "FxLayer (baseline)", + (game, transformHandler) => new FxLayer(game, transformHandler), + ); +}); diff --git a/tests/client/layers/FxLayerV2.perf.test.ts b/tests/client/layers/FxLayerV2.perf.test.ts new file mode 100644 index 00000000..7cd92a23 --- /dev/null +++ b/tests/client/layers/FxLayerV2.perf.test.ts @@ -0,0 +1,109 @@ +/** + * FxLayer V1 vs V2 Performance Comparison + * ========================================= + * Runs the same 10 benchmark scenarios against both FxLayer (V1) + * and FxLayerV2, printing side-by-side results. + * + * @jest-environment jsdom + */ + +import { + installCanvasMock, + mockAnimatedSpriteLoaderModule, + PIXI_MOCK_MODULE, + runFxBenchSuite, +} from "./fx-layer-bench-harness"; + +// ── Must mock pixi.js and AnimatedSpriteLoader BEFORE importing layers ── +jest.mock("pixi.js", () => PIXI_MOCK_MODULE); +mockAnimatedSpriteLoaderModule(); + +// Mock the sprite image imports (webpack URLs → empty strings) +jest.mock("../../../resources/sprites/bigsmoke.png", () => "bigsmoke.png"); +jest.mock( + "../../../resources/sprites/miniExplosion.png", + () => "miniExplosion.png", +); +jest.mock("../../../resources/sprites/minifire.png", () => "minifire.png"); +jest.mock( + "../../../resources/sprites/nukeExplosion.png", + () => "nukeExplosion.png", +); +jest.mock( + "../../../resources/sprites/samExplosion.png", + () => "samExplosion.png", +); +jest.mock( + "../../../resources/sprites/sinkingShip.png", + () => "sinkingShip.png", +); +jest.mock("../../../resources/sprites/smoke.png", () => "smoke.png"); +jest.mock( + "../../../resources/sprites/smokeAndFire.png", + () => "smokeAndFire.png", +); +jest.mock( + "../../../resources/sprites/unitExplosion.png", + () => "unitExplosion.png", +); + +// Mock SpriteLoader.colorizeCanvas +jest.mock("../../../src/client/graphics/SpriteLoader", () => ({ + colorizeCanvas: () => { + const c = { + width: 64, + height: 16, + getContext: () => ({ + drawImage: () => {}, + getImageData: () => ({ + data: new Uint8ClampedArray(64 * 16 * 4), + width: 64, + height: 16, + }), + putImageData: () => {}, + clearRect: () => {}, + }), + }; + return c; + }, + getColoredSprite: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { FxLayer } = require("../../../src/client/graphics/layers/FxLayer"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { FxLayerV2 } = require("../../../src/client/graphics/layers/FxLayerV2"); + +// ── Install mocks ── +beforeAll(() => { + jest.spyOn(window, "addEventListener").mockImplementation(() => {}); + + (globalThis as any).createImageBitmap = async () => ({ + width: 64, + height: 16, + close: () => {}, + }); +}); + +beforeEach(() => { + installCanvasMock(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ── Run both suites ── +describe("FxLayer V1 (baseline)", () => { + runFxBenchSuite( + "FxLayer V1", + (game, transformHandler) => new FxLayer(game, transformHandler), + ); +}); + +describe("FxLayerV2 (optimised)", () => { + runFxBenchSuite( + "FxLayerV2", + (game, transformHandler) => new FxLayerV2(game, transformHandler), + ); +}); diff --git a/tests/client/layers/fx-layer-bench-harness.ts b/tests/client/layers/fx-layer-bench-harness.ts new file mode 100644 index 00000000..e22e09a7 --- /dev/null +++ b/tests/client/layers/fx-layer-bench-harness.ts @@ -0,0 +1,950 @@ +/** + * FxLayer Performance Benchmark Harness + * ====================================== + * Implementation-agnostic harness for benchmarking any Layer that renders + * visual effects (explosions, shockwaves, debris particles, etc.). + * + * Exports mock game state, FX-event simulation, stats utilities, and a + * `runFxBenchSuite()` function that works with any factory producing a + * `Layer`. + * + * Usage: + * + * import { runFxBenchSuite } from "./fx-layer-bench-harness"; + * import { FxLayer } from "..."; + * + * runFxBenchSuite("FxLayer", (game, transformHandler) => + * new FxLayer(game, transformHandler), + * ); + * + * Scenarios: + * 1. Single nuke explosion (heavy — shockwave + debris sprites) + * 2. Burst: 10 simultaneous shell impacts (mini-explosions) + * 3. Sustained: 30 ticks of mixed unit events + * 4. updateFx only (pure update loop, no new spawns) + * 5. renderLayer only (PIXI render + position update path) + * 6. Nuke + camera pan (position recalculation under load) + * 7. Doomsday explosion (max debris density) + * 8. FX churn: spawn + expire cycle over 50 ticks + */ + +import { colord, type Colord } from "colord"; +import type { Layer } from "../../../src/client/graphics/layers/Layer"; +import type { TransformHandler } from "../../../src/client/graphics/TransformHandler"; +import { Cell, PlayerType, UnitType } from "../../../src/core/game/Game"; +import type { TileRef } from "../../../src/core/game/GameMap"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import type { + GameView, + PlayerView, + UnitView, +} from "../../../src/core/game/GameView"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Map / config constants +// ═══════════════════════════════════════════════════════════════════════════ + +export const MAP_WIDTH = 400; +export const MAP_HEIGHT = 300; +export const TOTAL_TILES = MAP_WIDTH * MAP_HEIGHT; +export const SCREEN_W = 1920; +export const SCREEN_H = 1080; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export interface BenchmarkResult { + scenario: string; + samples: number; + meanMs: number; + medianMs: number; + p95Ms: number; + stdMs: number; + minMs: number; + maxMs: number; + /** PIXI render() calls intercepted */ + pixiRenderCalls: number; + /** PIXI Graphics.clear() calls — shockwave redraws */ + graphicsClearCalls: number; + /** addChild calls — new display objects added to stage */ + addChildCalls: number; + /** removeChild calls — expired FX cleaned up */ + removeChildCalls: number; +} + +export interface GpuCounters { + pixiRenderCalls: number; + graphicsClearCalls: number; + addChildCalls: number; + removeChildCalls: number; +} + +/** + * Factory: given mocked dependencies, return a Layer. + */ +export type FxLayerFactory = ( + game: GameView, + transformHandler: TransformHandler, +) => Layer; + +// ═══════════════════════════════════════════════════════════════════════════ +// Stats helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export function computeStats( + label: string, + timings: number[], + gpuMetrics: GpuCounters, +): BenchmarkResult { + const sorted = [...timings].sort((a, b) => a - b); + const n = sorted.length; + const mean = sorted.reduce((s, v) => s + v, 0) / n; + const median = + n % 2 === 0 + ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + : sorted[Math.floor(n / 2)]; + const p95 = sorted[Math.min(Math.ceil(n * 0.95) - 1, n - 1)]; + const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + return { + scenario: label, + samples: n, + meanMs: +mean.toFixed(3), + medianMs: +median.toFixed(3), + p95Ms: +p95.toFixed(3), + stdMs: +std.toFixed(3), + minMs: +sorted[0].toFixed(3), + maxMs: +sorted[n - 1].toFixed(3), + pixiRenderCalls: gpuMetrics.pixiRenderCalls, + graphicsClearCalls: gpuMetrics.graphicsClearCalls, + addChildCalls: gpuMetrics.addChildCalls, + removeChildCalls: gpuMetrics.removeChildCalls, + }; +} + +export function resetGpuCounters(c: GpuCounters) { + c.pixiRenderCalls = 0; + c.graphicsClearCalls = 0; + c.addChildCalls = 0; + c.removeChildCalls = 0; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PIXI mock classes (same approach as UnitLayer harness) +// ═══════════════════════════════════════════════════════════════════════════ + +let _globalGpuCounters: GpuCounters | null = null; + +export function setGlobalGpuCounters(c: GpuCounters) { + _globalGpuCounters = c; +} + +export class MockTexture { + source: any = { uid: 1 }; + frame: any = { x: 0, y: 0, width: 16, height: 16 }; + constructor(opts?: any) { + if (opts?.frame) this.frame = opts.frame; + if (opts?.source) this.source = opts.source; + } + static from(_src: any): MockTexture { + return new MockTexture(); + } + static EMPTY = new MockTexture(); +} + +export class MockContainer { + children: any[] = []; + x = 0; + y = 0; + alpha = 1; + visible = true; + scale = { + x: 1, + y: 1, + set(v: number, v2?: number) { + this.x = v; + this.y = v2 ?? v; + }, + }; + position = { + x: 0, + y: 0, + set(x: number, y: number) { + this.x = x; + this.y = y; + }, + }; + addChild(child: any) { + this.children.push(child); + if (_globalGpuCounters) _globalGpuCounters.addChildCalls++; + return child; + } + removeChild(child: any) { + const idx = this.children.indexOf(child); + if (idx !== -1) this.children.splice(idx, 1); + if (_globalGpuCounters) _globalGpuCounters.removeChildCalls++; + return child; + } + destroy() {} +} + +export class MockGraphics extends MockContainer { + clear() { + if (_globalGpuCounters) _globalGpuCounters.graphicsClearCalls++; + return this; + } + beginFill() { + return this; + } + endFill() { + return this; + } + drawRect() { + return this; + } + drawCircle() { + return this; + } + circle(_x: number, _y: number, _r: number) { + return this; + } + stroke(_opts?: any) { + return this; + } + lineStyle() { + return this; + } + moveTo() { + return this; + } + lineTo() { + return this; + } +} + +export class MockSprite extends MockContainer { + anchor = { set(_x: number, _y: number) {} }; + texture: any = MockTexture.EMPTY; + constructor(tex?: any) { + super(); + if (tex) this.texture = tex; + } +} + +export class MockAnimatedSprite extends MockSprite { + loop = true; + autoUpdate = false; + totalFrames = 4; + private _currentFrame = 0; + constructor(textures?: any[]) { + super(); + if (textures) this.totalFrames = textures.length; + } + gotoAndStop(frame: number) { + this._currentFrame = Math.max(0, Math.min(frame, this.totalFrames - 1)); + } + play() {} +} + +export class MockWebGLRenderer { + render(_stage: any) { + if (_globalGpuCounters) _globalGpuCounters.pixiRenderCalls++; + } + async init(_opts: any) {} + resize(_w: number, _h: number) {} + destroy() {} +} + +export class MockRectangle { + x: number; + y: number; + width: number; + height: number; + constructor(x = 0, y = 0, w = 0, h = 0) { + this.x = x; + this.y = y; + this.width = w; + this.height = h; + } +} + +/** + * Complete PIXI mock module — pass to jest.mock("pixi.js", () => PIXI_MOCK_MODULE) + */ +export const PIXI_MOCK_MODULE = { + Container: MockContainer, + Sprite: MockSprite, + AnimatedSprite: MockAnimatedSprite, + Graphics: MockGraphics, + Texture: MockTexture, + WebGLRenderer: MockWebGLRenderer, + Rectangle: MockRectangle, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Mock game state +// ═══════════════════════════════════════════════════════════════════════════ + +const PLAYER_COLORS: Colord[] = [ + colord("#e63946"), + colord("#457b9d"), + colord("#2a9d8f"), + colord("#e9c46a"), +]; + +function createMockPlayerView(id: number, color: Colord): PlayerView { + return { + id: () => `player-${id}`, + smallID: () => id, + type: () => PlayerType.Human, + isPlayer: () => true, + isFriendly: () => false, + isAtWarWith: () => false, + isAlliedWith: () => false, + nameLocation: () => ({ x: 100, y: 100 }), + numTilesOwned: () => 1000, + _color: color, + } as unknown as PlayerView; +} + +function createMockTheme() { + return { + territoryColor: (pv: any) => (pv as any)._color ?? colord("#888"), + borderColor: (pv: any) => + ((pv as any)._color ?? colord("#888")).darken(0.2), + spawnHighlightColor: () => colord("#ffffff"), + }; +} + +/** + * Build a mock UnitView for triggering FX events. + */ +export function createMockUnit( + unitType: UnitType, + owner: PlayerView, + tile: number, + lastTile: number, + active: boolean, + reachedTarget: boolean, +): UnitView { + return { + type: () => unitType, + owner: () => owner, + tile: () => tile as TileRef, + lastTile: () => lastTile as TileRef, + isActive: () => active, + reachedTarget: () => reachedTarget, + id: () => Math.floor(Math.random() * 100000), + targetTile: () => tile as TileRef, + level: () => 1, + } as unknown as UnitView; +} + +export interface MockGameState { + players: PlayerView[]; + unitUpdates: { id: number }[]; + unitMap: Map; + bomberExplosions: { x: number; y: number; radius: number }[]; + doomsdayExplosions: { x: number; y: number; radius: number }[]; + currentTick: number; + fxEnabled: boolean; +} + +export function freshState(): MockGameState { + const players = PLAYER_COLORS.map((c, i) => createMockPlayerView(i + 1, c)); + return { + players, + unitUpdates: [], + unitMap: new Map(), + bomberExplosions: [], + doomsdayExplosions: [], + currentTick: 0, + fxEnabled: true, + }; +} + +/** + * Build the mock GameView wired to a MockGameState. + */ +export function createMockGame(state: MockGameState): GameView { + const theme = createMockTheme(); + return { + width: () => MAP_WIDTH, + height: () => MAP_HEIGHT, + x: (tile: TileRef) => (tile as number) % MAP_WIDTH, + y: (tile: TileRef) => Math.floor((tile as number) / MAP_WIDTH), + ref: (x: number, y: number) => (y * MAP_WIDTH + x) as TileRef, + tileRef: (x: number, y: number) => (y * MAP_WIDTH + x) as TileRef, + isValidCoord: (x: number, y: number) => + x >= 0 && y >= 0 && x < MAP_WIDTH && y < MAP_HEIGHT, + isLand: () => true, + numTilesOwned: () => 100, + tile: (ref: TileRef) => ({ + terrain: () => "land", + owner: () => state.players[0], + hasOwner: () => true, + }), + unit: (id: number) => state.unitMap.get(id), + config: () => ({ + theme: () => theme, + serverConfig: () => ({ turnIntervalMs: () => 500 }), + userSettings: () => ({ + fxLayer: () => state.fxEnabled, + }), + }), + updatesSinceLastTick: () => { + const updates: any = {}; + if (state.unitUpdates.length > 0) { + updates[GameUpdateType.Unit] = [...state.unitUpdates]; + } + if (state.bomberExplosions.length > 0) { + updates[GameUpdateType.BomberExplosion] = [...state.bomberExplosions]; + } + if (state.doomsdayExplosions.length > 0) { + updates[GameUpdateType.DoomsdayExplosion] = [ + ...state.doomsdayExplosions, + ]; + } + return updates; + }, + players: () => state.players, + allPlayers: () => state.players, + inSpawnPhase: () => false, + isOnlyHumans: () => false, + ticks: () => state.currentTick, + } as unknown as GameView; +} + +/** + * Build a mock TransformHandler. + */ +export function createMockTransformHandler( + cameraChanged = false, +): TransformHandler { + return { + scale: 1.8, + worldToScreenCoordinates: (cell: Cell) => ({ + x: (cell.x - MAP_WIDTH / 2) * 1.8 + SCREEN_W / 2, + y: (cell.y - MAP_HEIGHT / 2) * 1.8 + SCREEN_H / 2, + }), + hasChanged: () => cameraChanged, + width: () => SCREEN_W, + height: () => SCREEN_H, + boundingRect: () => ({ + width: SCREEN_W, + height: SCREEN_H, + left: 0, + top: 0, + }), + } as unknown as TransformHandler; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Event simulation helpers +// ═══════════════════════════════════════════════════════════════════════════ + +let _unitIdCounter = 1; + +/** + * Queue a dead unit event (Shell, Warship, AABullet, Nuke, etc). + * These trigger FX creation in tick(). + */ +export function queueUnitDeath( + state: MockGameState, + unitType: UnitType, + reachedTarget: boolean, + x?: number, + y?: number, +): void { + const id = _unitIdCounter++; + const tile = + (y ?? Math.floor(Math.random() * MAP_HEIGHT)) * MAP_WIDTH + + (x ?? Math.floor(Math.random() * MAP_WIDTH)); + const owner = state.players[Math.floor(Math.random() * state.players.length)]; + const unit = createMockUnit( + unitType, + owner, + tile, + tile, + false, + reachedTarget, + ); + state.unitMap.set(id, unit); + state.unitUpdates.push({ id }); +} + +/** + * Queue a bomber/nuke explosion (BomberExplosion update type). + */ +export function queueBomberExplosion( + state: MockGameState, + x?: number, + y?: number, + radius?: number, +): void { + state.bomberExplosions.push({ + x: x ?? Math.floor(Math.random() * MAP_WIDTH), + y: y ?? Math.floor(Math.random() * MAP_HEIGHT), + radius: radius ?? 70, + }); +} + +/** + * Queue a doomsday explosion. + */ +export function queueDoomsdayExplosion( + state: MockGameState, + x?: number, + y?: number, + radius?: number, +): void { + state.doomsdayExplosions.push({ + x: x ?? MAP_WIDTH / 2, + y: y ?? MAP_HEIGHT / 2, + radius: radius ?? 200, + }); +} + +/** + * Clear pending updates (call after tick() consumes them). + */ +export function clearUpdates(state: MockGameState): void { + state.unitUpdates = []; + state.bomberExplosions = []; + state.doomsdayExplosions = []; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Canvas mock for FxLayer's document.createElement("canvas") + body.appendChild +// ═══════════════════════════════════════════════════════════════════════════ + +export function installCanvasMock() { + const _realCreateElement = document.createElement.bind(document); + jest + .spyOn(document, "createElement") + .mockImplementation((tag: string, options?: ElementCreationOptions) => { + if (tag === "canvas") { + const fakeCanvas = { + width: SCREEN_W, + height: SCREEN_H, + style: {} as any, + getContext: () => createNullContext(), + toDataURL: () => "", + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as HTMLCanvasElement; + return fakeCanvas; + } + return _realCreateElement(tag, options); + }); + // Stub body.appendChild so FxLayer's DOM insertion doesn't fail + jest + .spyOn(document.body, "appendChild") + .mockImplementation((node: any) => node); +} + +function createNullContext(): CanvasRenderingContext2D { + return { + drawImage: () => {}, + clearRect: () => {}, + fillRect: () => {}, + putImageData: () => {}, + getImageData: () => ({ + data: new Uint8ClampedArray(4), + width: 1, + height: 1, + }), + createImageData: () => ({ + data: new Uint8ClampedArray(4), + width: 1, + height: 1, + }), + save: () => {}, + restore: () => {}, + translate: () => {}, + rotate: () => {}, + scale: () => {}, + setTransform: () => {}, + fillStyle: "", + canvas: { width: SCREEN_W, height: SCREEN_H }, + } as unknown as CanvasRenderingContext2D; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AnimatedSpriteLoader mock +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mock AnimatedSpriteLoader that returns mock textures/configs + * without loading real images. + */ +export function mockAnimatedSpriteLoaderModule() { + jest.mock("../../../src/client/graphics/AnimatedSpriteLoader", () => ({ + AnimatedSpriteLoader: class MockAnimatedSpriteLoader { + async loadAllAnimatedSpriteImages() {} + + getPixiTextures(_fxType: any, _owner?: any, _theme?: any) { + // Return 4 mock textures (typical frame count) + return [ + new MockTexture(), + new MockTexture(), + new MockTexture(), + new MockTexture(), + ]; + } + + getConfig(_fxType: any) { + return { + frameWidth: 16, + frameCount: 4, + frameDuration: 100, + looping: false, + originX: 8, + originY: 8, + }; + } + + createAnimatedSprite() { + return null; // Not used by FxLayer directly + } + }, + })); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Benchmark runner +// ═══════════════════════════════════════════════════════════════════════════ + +const SAMPLES = 10; +const WARMUP = 2; + +async function benchmark( + label: string, + counters: GpuCounters, + fn: () => void | Promise, +): Promise { + // Warmup + for (let i = 0; i < WARMUP; i++) { + resetGpuCounters(counters); + await fn(); + } + + const timings: number[] = []; + resetGpuCounters(counters); + for (let i = 0; i < SAMPLES; i++) { + const t0 = performance.now(); + await fn(); + timings.push(performance.now() - t0); + } + return computeStats(label, timings, counters); +} + +/** + * Main entry point — call from a test file. + */ +export function runFxBenchSuite(suiteName: string, factory: FxLayerFactory) { + const counters: GpuCounters = { + pixiRenderCalls: 0, + graphicsClearCalls: 0, + addChildCalls: 0, + removeChildCalls: 0, + }; + + // Set GPU counters in beforeAll so the module-level reference is + // correct when tests actually run (describe-phase calls would be + // overwritten by later suites). + beforeAll(() => { + setGlobalGpuCounters(counters); + }); + + const results: BenchmarkResult[] = []; + + // Helper: create a fresh layer + state for each scenario + async function setup(cameraChanged = false) { + const state = freshState(); + const game = createMockGame(state); + const transform = createMockTransformHandler(cameraChanged); + const layer = factory(game, transform); + if (layer.init) await layer.init(); + return { state, game, transform, layer }; + } + + // ───────────────────────────────────────────────────────────────────── + // Scenario 1: Single nuke explosion + // Tests nukeFxFactory (shockwave + debris sprites) creation + initial render + // ───────────────────────────────────────────────────────────────────── + it(`S1: Single nuke explosion`, async () => { + const r = await benchmark( + "1. Single nuke explosion", + counters, + async () => { + const { state, layer } = await setup(); + // Queue a nuke impact + queueUnitDeath(state, UnitType.AtomBomb, true, 200, 150); + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + // Render the frame with the new FX + layer.renderLayer!(createNullContext()); + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(50); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 2: Burst — 10 simultaneous shell impacts + // ───────────────────────────────────────────────────────────────────── + it(`S2: Burst — 10 shell impacts`, async () => { + const r = await benchmark( + "2. Burst: 10 shell impacts", + counters, + async () => { + const { state, layer } = await setup(); + for (let i = 0; i < 10; i++) { + queueUnitDeath(state, UnitType.Shell, true); + } + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(30); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 3: Burst — 5 warship destructions (multiple FX per event) + // ───────────────────────────────────────────────────────────────────── + it(`S3: Burst — 5 warship destructions`, async () => { + const r = await benchmark( + "3. Burst: 5 warship destructions", + counters, + async () => { + const { state, layer } = await setup(); + for (let i = 0; i < 5; i++) { + queueUnitDeath(state, UnitType.Warship, false); + } + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(30); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 4: Sustained — 30 ticks of mixed combat events + // ───────────────────────────────────────────────────────────────────── + it(`S4: Sustained 30 ticks — mixed combat`, async () => { + const r = await benchmark( + "4. Sustained 30 ticks (mixed combat)", + counters, + async () => { + const { state, layer } = await setup(); + resetGpuCounters(counters); + for (let tick = 0; tick < 30; tick++) { + state.currentTick = tick; + // Each tick: 3 shell hits, 1 AA bullet hit, occasional warship + for (let i = 0; i < 3; i++) { + queueUnitDeath(state, UnitType.Shell, true); + } + queueUnitDeath(state, UnitType.AABullet, true); + if (tick % 5 === 0) { + queueUnitDeath(state, UnitType.Warship, false); + } + if (tick === 15) { + queueUnitDeath(state, UnitType.AtomBomb, true, 200, 150); + } + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + } + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(200); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 5: updateFx only — pure FX update loop, many active FX + // ───────────────────────────────────────────────────────────────────── + it(`S5: updateFx only — 100 active FX`, async () => { + const r = await benchmark( + "5. updateFx only (100 active FX)", + counters, + async () => { + const { state, layer } = await setup(); + // Pre-populate with many FX + for (let i = 0; i < 20; i++) { + queueUnitDeath(state, UnitType.Shell, true); + } + for (let i = 0; i < 5; i++) { + queueUnitDeath(state, UnitType.Warship, false); + } + queueUnitDeath(state, UnitType.AtomBomb, true, 200, 150); + layer.tick!(); + clearUpdates(state); + // Now measure just rendering (which calls updateFx internally) + resetGpuCounters(counters); + for (let frame = 0; frame < 10; frame++) { + layer.renderLayer!(createNullContext()); + } + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(50); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 6: renderLayer only — PIXI render + camera-change repositioning + // ───────────────────────────────────────────────────────────────────── + it(`S6: renderLayer + camera pan`, async () => { + const r = await benchmark( + "6. renderLayer + camera pan (50 FX)", + counters, + async () => { + const state = freshState(); + const game = createMockGame(state); + const transform = createMockTransformHandler(true); // camera changed! + const layer = factory(game, transform); + if (layer.init) await layer.init(); + // Pre-populate + for (let i = 0; i < 10; i++) { + queueUnitDeath(state, UnitType.Shell, true); + } + for (let i = 0; i < 3; i++) { + queueUnitDeath(state, UnitType.Warship, false); + } + queueUnitDeath(state, UnitType.AtomBomb, true, 100, 100); + layer.tick!(); + clearUpdates(state); + resetGpuCounters(counters); + // Render with camera changed — triggers position recalculation + for (let frame = 0; frame < 10; frame++) { + layer.renderLayer!(createNullContext()); + } + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(50); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 7: Doomsday explosion (max debris density) + // ───────────────────────────────────────────────────────────────────── + it(`S7: Doomsday explosion`, async () => { + const r = await benchmark( + "7. Doomsday explosion (radius 200)", + counters, + async () => { + const { state, layer } = await setup(); + queueDoomsdayExplosion(state, 200, 150, 200); + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(50); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 8: Bomber explosion (BomberExplosion update path) + // ───────────────────────────────────────────────────────────────────── + it(`S8: Bomber explosion`, async () => { + const r = await benchmark( + "8. Bomber explosion (radius 70)", + counters, + async () => { + const { state, layer } = await setup(); + queueBomberExplosion(state, 200, 150, 70); + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(50); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 9: FX churn — spawn + expire cycle over 50 ticks + // ───────────────────────────────────────────────────────────────────── + it(`S9: FX churn — 50 ticks spawn/expire`, async () => { + const r = await benchmark( + "9. FX churn (50 ticks, 5 spawns/tick)", + counters, + async () => { + const { state, layer } = await setup(); + resetGpuCounters(counters); + for (let tick = 0; tick < 50; tick++) { + state.currentTick = tick; + // Spawn 5 FX per tick + for (let i = 0; i < 3; i++) { + queueUnitDeath(state, UnitType.Shell, true); + } + queueUnitDeath(state, UnitType.AABullet, true); + queueUnitDeath(state, UnitType.AABullet, true); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + } + }, + ); + results.push(r); + expect(r.meanMs).toBeLessThan(300); + }); + + // ───────────────────────────────────────────────────────────────────── + // Scenario 10: SAM interception (explosion + shockwave) + // ───────────────────────────────────────────────────────────────────── + it(`S10: SAM interception burst`, async () => { + const r = await benchmark("10. SAM interception ×5", counters, async () => { + const { state, layer } = await setup(); + // 5 nukes intercepted by SAMs (reachedTarget = false) + for (let i = 0; i < 5; i++) { + queueUnitDeath(state, UnitType.AtomBomb, false); + } + resetGpuCounters(counters); + layer.tick!(); + clearUpdates(state); + layer.renderLayer!(createNullContext()); + }); + results.push(r); + expect(r.meanMs).toBeLessThan(30); + }); + + // ───────────────────────────────────────────────────────────────────── + // Print results table + // ───────────────────────────────────────────────────────────────────── + afterAll(() => { + const header = ` ${suiteName} `; + const border = "═".repeat(header.length); + console.log(`╔${border}╗`); + console.log(`║${header}║`); + console.log(`╚${border}╝`); + console.table( + results.map((r) => ({ + Scenario: r.scenario, + Samples: r.samples, + "Mean (ms)": r.meanMs, + "Median (ms)": r.medianMs, + "P95 (ms)": r.p95Ms, + "Std (ms)": r.stdMs, + "Min (ms)": r.minMs, + "Max (ms)": r.maxMs, + "PIXI render": r.pixiRenderCalls, + "gfx.clear()": r.graphicsClearCalls, + addChild: r.addChildCalls, + removeChild: r.removeChildCalls, + })), + ); + }); +}