From 597e43d195bf12ef40cfebb273572122820bef8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 05:02:49 +0000 Subject: [PATCH] fix: Wire performance panel to worker metrics with zero-cost when closed The performance panel was completely broken because game logic runs exclusively in a Web Worker, which has its own PerformanceMonitor singleton. System timings and tick times were recorded in the worker but never sent to the main thread where the panel displays them. Changes: - Add WorkerPerformanceMetrics type and setPerformanceCollection message - Add PerformanceMonitor.applyWorkerMetrics() to receive worker data - Implement toggle-based collection in GameWorker (10Hz when enabled) - Wire WorkerBridge to forward metrics to main thread PerformanceMonitor - Enable collection on PerformanceDashboard mount, disable on unmount Performance characteristics: - Zero overhead when panel is closed (no timing, no messages) - 10Hz throttled metrics transfer when open (~100ms interval) - Efficient tuple serialization for system timings https://claude.ai/code/session_0129n4vJK6gWdvXSBUEfMwsv --- src/components/game/PerformanceDashboard.tsx | 15 +++- src/engine/core/PerformanceMonitor.ts | 38 +++++++++ src/engine/workers/GameWorker.ts | 83 ++++++++++++++++++++ src/engine/workers/WorkerBridge.ts | 23 ++++++ src/engine/workers/types.ts | 19 ++++- 5 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/components/game/PerformanceDashboard.tsx b/src/components/game/PerformanceDashboard.tsx index b7f7610c..c8426bfe 100644 --- a/src/components/game/PerformanceDashboard.tsx +++ b/src/components/game/PerformanceDashboard.tsx @@ -6,6 +6,7 @@ import { PerformanceSnapshot, SystemTiming, } from '@/engine/core/PerformanceMonitor'; +import { getWorkerBridge } from '@/engine/workers/WorkerBridge'; // Mini sparkline graph component const Sparkline = memo(function Sparkline({ @@ -142,6 +143,13 @@ export const PerformanceDashboard = memo(function PerformanceDashboard({ }, []); useEffect(() => { + // Enable worker performance collection when dashboard mounts + const bridge = getWorkerBridge(); + bridge?.setPerformanceCollection(true); + + // Start main thread performance monitor + PerformanceMonitor.start(); + // Subscribe to performance updates const unsubscribe = PerformanceMonitor.subscribe(handleSnapshot); @@ -151,7 +159,12 @@ export const PerformanceDashboard = memo(function PerformanceDashboard({ setFpsHistory(PerformanceMonitor.getFPSHistory().slice(-60)); setTickHistory(PerformanceMonitor.getTickTimeHistory().slice(-60)); - return unsubscribe; + return () => { + // Disable worker performance collection when dashboard unmounts + bridge?.setPerformanceCollection(false); + PerformanceMonitor.stop(); + unsubscribe(); + }; }, [handleSnapshot]); if (!snapshot) { diff --git a/src/engine/core/PerformanceMonitor.ts b/src/engine/core/PerformanceMonitor.ts index c8eb61b2..1d758a23 100644 --- a/src/engine/core/PerformanceMonitor.ts +++ b/src/engine/core/PerformanceMonitor.ts @@ -321,6 +321,44 @@ class PerformanceMonitorClass { this.networkMetrics = { ...this.networkMetrics, ...metrics }; } + /** + * Apply performance metrics received from the game worker. + * This bridges the worker's performance data to the main thread's PerformanceMonitor. + * Called by WorkerBridge when receiving 'performanceMetrics' messages. + */ + public applyWorkerMetrics( + tickTime: number, + systemTimings: Array<[string, number]>, + entityCounts: [number, number, number, number] + ): void { + // Record tick time + this.lastTickTime = tickTime; + this.tickTimeHistory.push(tickTime); + + // Apply system timings - clear first, then record each + this.currentSystemTimings.clear(); + for (const [name, duration] of systemTimings) { + this.currentSystemTimings.set(name, duration); + + // Update history ring buffer + if (!this.systemTimingHistory.has(name)) { + this.systemTimingHistory.set(name, new RingBuffer(SYSTEM_TIMING_HISTORY_SIZE)); + } + this.systemTimingHistory.get(name)!.push(duration); + } + + // Apply entity counts + const [units, buildings, resources, projectiles] = entityCounts; + this.entityCounts = { + total: units + buildings + resources + projectiles, + units, + buildings, + projectiles, + resources, + effects: 0, + }; + } + /** * Update render metrics (called by WebGPUGameCanvas each frame) * @param drawCalls - Draw calls for this frame (reset after each frame) diff --git a/src/engine/workers/GameWorker.ts b/src/engine/workers/GameWorker.ts index 8786a410..993f1952 100644 --- a/src/engine/workers/GameWorker.ts +++ b/src/engine/workers/GameWorker.ts @@ -15,6 +15,7 @@ */ import { GameCore, GameConfig } from '../core/GameCore'; +import { PerformanceMonitor } from '../core/PerformanceMonitor'; import { debugInitialization, debugPerformance } from '@/utils/debugLogger'; // Components @@ -54,6 +55,7 @@ import type { ResourceRenderState, ProjectileRenderState, SpawnMapData, + WorkerPerformanceMetrics, } from './types'; import type { GameState, TerrainCell } from '../core/GameCore'; import type { GameCommand } from '../core/GameCommand'; @@ -106,6 +108,12 @@ export class WorkerGame extends GameCore { private hasLoggedFirstRenderState = false; private renderStatesSent = 0; + // Performance collection (zero-cost when disabled) + private performanceCollectionEnabled = false; + private perfMetricsInterval: ReturnType | null = null; + private lastTickDuration = 0; + private lastSystemTimings: Array<[string, number]> = []; + constructor(config: GameConfig) { super(config); @@ -478,9 +486,20 @@ export class WorkerGame extends GameCore { // Process queued commands for this tick this.processQueuedCommands(); + // Track tick time only when performance collection is enabled (zero-cost when disabled) + let tickStart = 0; + if (this.performanceCollectionEnabled) { + tickStart = performance.now(); + } + // Update all systems this.world.update(deltaTime); + // Record tick duration for performance metrics + if (this.performanceCollectionEnabled) { + this.lastTickDuration = performance.now() - tickStart; + } + // Update player resources this.updatePlayerResources(); } @@ -803,6 +822,65 @@ export class WorkerGame extends GameCore { } } + // ============================================================================ + // PERFORMANCE COLLECTION + // ============================================================================ + + /** + * Enable or disable performance metrics collection. + * When enabled, starts a 10Hz interval to send metrics to main thread. + * When disabled, all timing overhead is eliminated. + */ + public setPerformanceCollection(enabled: boolean): void { + if (this.performanceCollectionEnabled === enabled) return; + + this.performanceCollectionEnabled = enabled; + PerformanceMonitor.setCollecting(enabled); + + if (enabled) { + // Start 10Hz metrics reporting (100ms interval) + this.perfMetricsInterval = setInterval(() => this.sendPerformanceMetrics(), 100); + } else { + // Stop metrics reporting + if (this.perfMetricsInterval !== null) { + clearInterval(this.perfMetricsInterval); + this.perfMetricsInterval = null; + } + // Clear cached data + this.lastTickDuration = 0; + this.lastSystemTimings = []; + } + } + + /** + * Collect and send performance metrics to main thread. + * Called at 10Hz when collection is enabled. + */ + private sendPerformanceMetrics(): void { + if (!this.performanceCollectionEnabled) return; + + // Get system timings from PerformanceMonitor + const systemTimings = PerformanceMonitor.getSystemTimings(); + const timingTuples: Array<[string, number]> = systemTimings.map(t => [t.name, t.duration]); + + // Cache for next call (in case update hasn't run) + this.lastSystemTimings = timingTuples; + + // Get entity counts (O(1) - just reading lengths) + const units = this.world.getEntitiesWith('Unit').length; + const buildings = this.world.getEntitiesWith('Building').length; + const resources = this.world.getEntitiesWith('Resource').length; + const projectiles = this.world.getEntitiesWith('Projectile').length; + + const metrics: WorkerPerformanceMetrics = { + tickTime: this.lastTickDuration, + systemTimings: timingTuples, + entityCounts: [units, buildings, resources, projectiles], + }; + + postMessage({ type: 'performanceMetrics', metrics } satisfies WorkerToMainMessage); + } + // ============================================================================ // ENTITY SPAWNING // ============================================================================ @@ -1108,6 +1186,11 @@ if (typeof self !== 'undefined') { } break; } + + case 'setPerformanceCollection': { + game?.setPerformanceCollection(message.enabled); + break; + } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/engine/workers/WorkerBridge.ts b/src/engine/workers/WorkerBridge.ts index 866252f9..83bb3c67 100644 --- a/src/engine/workers/WorkerBridge.ts +++ b/src/engine/workers/WorkerBridge.ts @@ -37,6 +37,7 @@ import { removeMultiplayerMessageHandler, } from '@/store/multiplayerStore'; import { debugInitialization } from '@/utils/debugLogger'; +import { PerformanceMonitor } from '../core/PerformanceMonitor'; // ============================================================================ // TYPES @@ -280,6 +281,15 @@ export class WorkerBridge { remoteChecksum: message.remoteChecksum, }); break; + + case 'performanceMetrics': + // Forward worker performance metrics to main thread's PerformanceMonitor + PerformanceMonitor.applyWorkerMetrics( + message.metrics.tickTime, + message.metrics.systemTimings, + message.metrics.entityCounts + ); + break; } } @@ -420,6 +430,19 @@ export class WorkerBridge { this.worker?.postMessage({ type: 'setDebugSettings', settings } satisfies MainToWorkerMessage); } + // ============================================================================ + // PERFORMANCE COLLECTION + // ============================================================================ + + /** + * Enable or disable performance metrics collection in the worker. + * When enabled, the worker sends metrics at 10Hz for the performance dashboard. + * When disabled, zero overhead - no timing, no messages. + */ + public setPerformanceCollection(enabled: boolean): void { + this.worker?.postMessage({ type: 'setPerformanceCollection', enabled } satisfies MainToWorkerMessage); + } + // ============================================================================ // ENTITY SPAWNING // ============================================================================ diff --git a/src/engine/workers/types.ts b/src/engine/workers/types.ts index 79e67d4e..8c143789 100644 --- a/src/engine/workers/types.ts +++ b/src/engine/workers/types.ts @@ -387,7 +387,8 @@ export type MainToWorkerMessage = | { type: 'requestChecksum' } | { type: 'setSelection'; entityIds: number[]; playerId: string } | { type: 'setControlGroup'; groupNumber: number; entityIds: number[] } - | { type: 'spawnEntities'; mapData: SpawnMapData }; + | { type: 'spawnEntities'; mapData: SpawnMapData } + | { type: 'setPerformanceCollection'; enabled: boolean }; /** * Player slot info for spawning @@ -426,6 +427,19 @@ export interface SpawnMapData { playerSlots?: PlayerSlotInfo[]; } +/** + * Performance metrics sent from worker to main thread. + * Only sent when performance collection is enabled (panel is open). + * Uses flat arrays for efficient serialization. + */ +export interface WorkerPerformanceMetrics { + tickTime: number; + // Tuple array [systemName, durationMs] for efficient serialization + systemTimings: Array<[string, number]>; + // [units, buildings, resources, projectiles] + entityCounts: [number, number, number, number]; +} + /** * Messages sent FROM game worker TO main thread */ @@ -437,7 +451,8 @@ export type WorkerToMainMessage = | { type: 'gameOver'; winnerId: string | null; reason: string } | { type: 'error'; message: string; stack?: string } | { type: 'multiplayerCommand'; command: GameCommand } - | { type: 'desync'; tick: number; localChecksum: string; remoteChecksum: string }; + | { type: 'desync'; tick: number; localChecksum: string; remoteChecksum: string } + | { type: 'performanceMetrics'; metrics: WorkerPerformanceMetrics }; // ============================================================================ // GAME COMMAND - Re-export from shared module for consistency