diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 94b27d886..eb354aa66 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -28,6 +28,7 @@ import { readTerritoryShaderId, } from "../webgpu/render/TerritoryShaderRegistry"; import { TerritoryRenderer } from "../webgpu/TerritoryRenderer"; +import { TerritoryRendererProxy } from "../webgpu/TerritoryRendererProxy"; import { Layer } from "./Layer"; export class TerritoryLayer implements Layer { @@ -42,7 +43,8 @@ export class TerritoryLayer implements Layer { private theme: Theme; - private territoryRenderer: TerritoryRenderer | null = null; + private territoryRenderer: TerritoryRenderer | TerritoryRendererProxy | null = + null; private alternativeView = false; private lastPaletteSignature: string | null = null; @@ -119,9 +121,11 @@ export class TerritoryLayer implements Layer { } private configureRenderer() { - const { renderer, reason } = TerritoryRenderer.create( + // Use proxy to render in worker thread + const { renderer, reason } = TerritoryRendererProxy.create( this.game, this.theme, + this.game.worker, ); if (!renderer) { throw new Error(reason ?? "WebGPU is required for territory rendering."); @@ -189,6 +193,11 @@ export class TerritoryLayer implements Layer { const canvas = this.territoryRenderer.canvas; + // Canvas must be HTMLCanvasElement for DOM operations (proxy always provides this) + if (!(canvas instanceof HTMLCanvasElement)) { + return; + } + // If the renderer recreated its canvas, detach the old one. if (this.attachedTerritoryCanvas !== canvas) { this.attachedTerritoryCanvas?.remove(); diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts new file mode 100644 index 000000000..b2fac5880 --- /dev/null +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -0,0 +1,301 @@ +import { createCanvas } from "src/client/Utils"; +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { WorkerClient } from "../../../core/worker/WorkerClient"; +import { + InitRendererMessage, + MarkAllDirtyMessage, + MarkTileMessage, + RefreshPaletteMessage, + RefreshTerrainMessage, + RenderFrameMessage, + SetAlternativeViewMessage, + SetHighlightedOwnerMessage, + SetShaderSettingsMessage, + SetViewSizeMessage, + SetViewTransformMessage, + TickRendererMessage, +} from "../../../core/worker/WorkerMessages"; + +export interface TerritoryWebGLCreateResult { + renderer: TerritoryRendererProxy | null; + reason?: string; +} + +/** + * Proxy for TerritoryRenderer that forwards calls to worker thread. + * Manages canvas transfer and message routing. + */ +export class TerritoryRendererProxy { + public readonly canvas: HTMLCanvasElement; + private offscreenCanvas: OffscreenCanvas | null = null; + private worker: WorkerClient | null = null; + private ready = false; + private initPromise: Promise | null = null; + private pendingMessages: any[] = []; + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = createCanvas(); + this.canvas.style.pointerEvents = "none"; + this.canvas.width = 1; + this.canvas.height = 1; + } + + static create( + game: GameView, + theme: Theme, + worker: WorkerClient, + ): TerritoryWebGLCreateResult { + const nav = globalThis.navigator as any; + if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { + return { + renderer: null, + reason: "WebGPU not available; GPU renderer disabled.", + }; + } + + if (typeof OffscreenCanvas === "undefined") { + return { + renderer: null, + reason: "OffscreenCanvas not supported; GPU renderer disabled.", + }; + } + + const state = game.tileStateView(); + const expected = game.width() * game.height(); + if (state.length !== expected) { + return { + renderer: null, + reason: "Tile state buffer size mismatch; GPU renderer disabled.", + }; + } + + const renderer = new TerritoryRendererProxy(game, theme); + renderer.worker = worker; + renderer.startInit(); + return { renderer }; + } + + private startInit(): void { + if (this.initPromise) return; + this.initPromise = this.init(); + } + + private async init(): Promise { + if (!this.worker) { + throw new Error("Worker not set"); + } + + // Transfer canvas control to offscreen + this.offscreenCanvas = this.canvas.transferControlToOffscreen(); + + // Send init message to worker + // Determine dark mode from theme (check if it has darkShore property, same as GroundTruthData) + const themeAny = this.theme as any; + const darkMode = themeAny.darkShore !== undefined; + + const messageId = `init_renderer_${Date.now()}`; + const initMessage: InitRendererMessage = { + type: "init_renderer", + id: messageId, + offscreenCanvas: this.offscreenCanvas, + darkMode: darkMode, + }; + + // Transfer the offscreen canvas + this.worker.postMessage(initMessage, [this.offscreenCanvas]); + + // Wait for renderer ready + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.worker?.removeMessageHandler(messageId); + reject(new Error("Renderer initialization timeout")); + }, 10000); + + const handler = (message: any) => { + if (message.type === "renderer_ready" && message.id === messageId) { + clearTimeout(timeout); + this.worker?.removeMessageHandler(messageId); + this.ready = true; + // Send any pending messages + for (const msg of this.pendingMessages) { + this.sendToWorker(msg); + } + this.pendingMessages = []; + resolve(); + } + }; + + this.worker?.addMessageHandler(messageId, handler); + }); + } + + private sendToWorker(message: any): void { + if (!this.worker) { + return; + } + if (!this.ready) { + this.pendingMessages.push(message); + return; + } + this.worker.postMessage(message); + } + + setViewSize(width: number, height: number): void { + const message: SetViewSizeMessage = { + type: "set_view_size", + width, + height, + }; + this.sendToWorker(message); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + const message: SetViewTransformMessage = { + type: "set_view_transform", + scale, + offsetX, + offsetY, + }; + this.sendToWorker(message); + } + + setAlternativeView(enabled: boolean): void { + const message: SetAlternativeViewMessage = { + type: "set_alternative_view", + enabled, + }; + this.sendToWorker(message); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + const message: SetHighlightedOwnerMessage = { + type: "set_highlighted_owner", + ownerSmallId, + }; + this.sendToWorker(message); + } + + setTerritoryShader(shaderPath: string): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + territoryShader: shaderPath, + }; + this.sendToWorker(message); + } + + setTerrainShader(shaderPath: string): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + terrainShader: shaderPath, + }; + this.sendToWorker(message); + } + + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + territoryShaderParams0: Array.from(params0), + territoryShaderParams1: Array.from(params1), + }; + this.sendToWorker(message); + } + + setTerrainShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + terrainShaderParams0: Array.from(params0), + terrainShaderParams1: Array.from(params1), + }; + this.sendToWorker(message); + } + + setPreSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + preSmoothing: { + enabled, + shaderPath, + params0: Array.from(params0), + }, + }; + this.sendToWorker(message); + } + + setPostSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + const message: SetShaderSettingsMessage = { + type: "set_shader_settings", + postSmoothing: { + enabled, + shaderPath, + params0: Array.from(params0), + }, + }; + this.sendToWorker(message); + } + + markTile(tile: TileRef): void { + const message: MarkTileMessage = { + type: "mark_tile", + tile, + }; + this.sendToWorker(message); + } + + markAllDirty(): void { + const message: MarkAllDirtyMessage = { + type: "mark_all_dirty", + }; + this.sendToWorker(message); + } + + refreshPalette(): void { + const message: RefreshPaletteMessage = { + type: "refresh_palette", + }; + this.sendToWorker(message); + } + + markDefensePostsDirty(): void { + this.markAllDirty(); + } + + refreshTerrain(): void { + const message: RefreshTerrainMessage = { + type: "refresh_terrain", + }; + this.sendToWorker(message); + } + + tick(): void { + const message: TickRendererMessage = { + type: "tick_renderer", + }; + this.sendToWorker(message); + } + + render(): void { + const message: RenderFrameMessage = { + type: "render_frame", + }; + this.sendToWorker(message); + } +} diff --git a/src/client/graphics/webgpu/core/WebGPUDevice.ts b/src/client/graphics/webgpu/core/WebGPUDevice.ts index 27b587a7c..07df9cba7 100644 --- a/src/client/graphics/webgpu/core/WebGPUDevice.ts +++ b/src/client/graphics/webgpu/core/WebGPUDevice.ts @@ -19,10 +19,12 @@ export class WebGPUDevice { /** * Initialize WebGPU device and canvas context. - * @param canvas Canvas element to configure + * @param canvas Canvas element to configure (HTMLCanvasElement or OffscreenCanvas) * @returns WebGPUDevice instance or null if WebGPU is not available */ - static async create(canvas: HTMLCanvasElement): Promise { + static async create( + canvas: HTMLCanvasElement | OffscreenCanvas, + ): Promise { const nav = globalThis.navigator as any; if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { return null; diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts new file mode 100644 index 000000000..1c8fa0875 --- /dev/null +++ b/src/core/worker/GameViewAdapter.ts @@ -0,0 +1,165 @@ +import { Theme } from "../configuration/Config"; +import { Game, UnitType } from "../game/Game"; +import { GameUpdateViewData } from "../game/GameUpdates"; +import { GameView } from "../game/GameView"; +import { TerrainMapData } from "../game/TerrainMapLoader"; + +/** + * Adapter that makes Game work as GameView for rendering purposes. + * Provides the interface that GroundTruthData and rendering passes need, + * without requiring the full GameView infrastructure. + */ +export class GameViewAdapter implements Partial { + private tileStateCache: Uint16Array | null = null; + private terrainDataCache: Uint8Array | null = null; + private lastUpdate: GameUpdateViewData | null = null; + + constructor( + private game: Game, + private mapData: TerrainMapData, + private theme: Theme, + ) {} + + /** + * Update adapter with latest game update data. + * Invalidates caches so they're recomputed on next access. + */ + update(gu: GameUpdateViewData): void { + this.lastUpdate = gu; + // Invalidate caches when updated + this.tileStateCache = null; + this.terrainDataCache = null; + } + + config() { + return this.game.config(); + } + + width(): number { + return this.game.width(); + } + + height(): number { + return this.game.height(); + } + + x(tile: bigint): number { + return this.game.x(tile); + } + + y(tile: bigint): number { + return this.game.y(tile); + } + + units(...types: UnitType[]): any[] { + return this.game.units(...types); + } + + /** + * Build tile state view from game. + * Cached until next update. + */ + tileStateView(): Uint16Array { + if (this.tileStateCache) { + return this.tileStateCache; + } + // Build tile state from game + const width = this.game.width(); + const height = this.game.height(); + const state = new Uint16Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const tile = this.game.ref(x, y); + const owner = this.game.owner(tile); + const ownerId = owner ? owner.smallID() : 0; + const terrain = this.game.terrain(tile); + const terrainType = terrain.type(); + const terrainMag = terrain.magnitude(); + // Pack state: ownerId (12 bits) | terrainType (2 bits) | terrainMag (2 bits) + state[y * width + x] = + (ownerId & 0xfff) | + ((terrainType & 0x3) << 12) | + ((terrainMag & 0x3) << 14); + } + } + this.tileStateCache = state; + return state; + } + + /** + * Build terrain data view from game. + * Cached until next update. + */ + terrainDataView(): Uint8Array { + if (this.terrainDataCache) { + return this.terrainDataCache; + } + // Build terrain data from game + const width = this.game.width(); + const height = this.game.height(); + const terrainData = new Uint8Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const tile = this.game.ref(x, y); + const terrain = this.game.terrain(tile); + terrainData[y * width + x] = terrain.type(); + } + } + this.terrainDataCache = terrainData; + return terrainData; + } + + /** + * Convert Game players to PlayerView-like objects for rendering. + * Computes colors from theme directly (no PlayerView needed). + */ + playerViews(): any[] { + const theme = this.game.config().theme(); + return this.game.players().map((p) => { + // Get default colors from theme + const defaultTerritoryColor = theme.territoryColor(p as any); + const defaultBorderColor = theme.borderColor(defaultTerritoryColor); + const territoryRgb = defaultTerritoryColor.toRgb(); + const borderRgb = defaultBorderColor.toRgb(); + + return { + smallID: () => p.smallID(), + territoryColor: () => ({ + rgba: { + r: Math.round(territoryRgb.r), + g: Math.round(territoryRgb.g), + b: Math.round(territoryRgb.b), + a: Math.round((territoryRgb.a ?? 1) * 255), + }, + }), + borderColor: () => ({ + rgba: { + r: Math.round(borderRgb.r), + g: Math.round(borderRgb.g), + b: Math.round(borderRgb.b), + a: Math.round((borderRgb.a ?? 1) * 255), + }, + }), + }; + }); + } + + /** + * Get my player for highlighting (returns null in worker context). + */ + myPlayer(): any | null { + // Return null for now - this is used for highlighting + // Could be implemented if we track clientID in worker + return null; + } + + /** + * Get recently updated tiles from last game update. + */ + recentlyUpdatedTiles(): bigint[] { + if (!this.lastUpdate) { + return []; + } + return Array.from(this.lastUpdate.packedTileUpdates); + } +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index a60e63e4b..f1eb5e755 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,7 +1,12 @@ import version from "resources/version.txt?raw"; -import { createGameRunner, GameRunner } from "../GameRunner"; +import { Theme } from "../configuration/Config"; +import { PastelTheme } from "../configuration/PastelTheme"; +import { PastelThemeDark } from "../configuration/PastelThemeDark"; import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; +import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader"; +import { createGameRunner, GameRunner } from "../GameRunner"; +import { GameStartInfo } from "../Schemas"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -9,19 +14,28 @@ import { PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + RendererReadyMessage, TransportShipSpawnResultMessage, WorkerMessage, } from "./WorkerMessages"; +import { WorkerTerritoryRenderer } from "./WorkerTerritoryRenderer"; const ctx: Worker = self as any; let gameRunner: Promise | null = null; +let gameStartInfo: GameStartInfo | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +let renderer: WorkerTerritoryRenderer | null = null; +let mapData: TerrainMapData | null = null; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate if (!("updates" in gu)) { return; } + // Update renderer with game update + if (renderer) { + renderer.updateGameView(gu); + } sendMessage({ type: "game_update", gameUpdate: gu, @@ -41,6 +55,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { break; case "init": try { + gameStartInfo = message.gameStartInfo; gameRunner = createGameRunner( message.gameStartInfo, message.clientID, @@ -170,6 +185,153 @@ ctx.addEventListener("message", async (e: MessageEvent) => { console.error("Failed to spawn transport ship:", error); } break; + + case "init_renderer": + try { + if (!gameRunner || !gameStartInfo) { + throw new Error("Game runner not initialized"); + } + const gr = await gameRunner; + + // Load map data if not already loaded + // Use gameStartInfo.config which has the original game map info + mapData ??= await loadTerrainMap( + gameStartInfo.config.gameMap, + gameStartInfo.config.gameMapSize, + mapLoader, + ); + + // Create theme based on darkMode flag from main thread + // (can't access userSettings in worker, so it's passed from main thread) + const theme: Theme = message.darkMode + ? new PastelThemeDark() + : new PastelTheme(); + + renderer = new WorkerTerritoryRenderer(); + + await renderer.init(message.offscreenCanvas, gr, mapData, theme); + + sendMessage({ + type: "renderer_ready", + id: message.id, + } as RendererReadyMessage); + } catch (error) { + console.error("Failed to initialize renderer:", error); + sendMessage({ + type: "renderer_ready", + id: message.id, + } as RendererReadyMessage); + throw error; + } + break; + + case "set_view_size": + if (renderer) { + renderer.setViewSize(message.width, message.height); + } + break; + + case "set_view_transform": + if (renderer) { + renderer.setViewTransform( + message.scale, + message.offsetX, + message.offsetY, + ); + } + break; + + case "set_alternative_view": + if (renderer) { + renderer.setAlternativeView(message.enabled); + } + break; + + case "set_highlighted_owner": + if (renderer) { + renderer.setHighlightedOwnerId(message.ownerSmallId); + } + break; + + case "set_shader_settings": + if (renderer) { + if (message.territoryShader) { + renderer.setTerritoryShader(message.territoryShader); + } + if (message.territoryShaderParams0 && message.territoryShaderParams1) { + renderer.setTerritoryShaderParams( + message.territoryShaderParams0, + message.territoryShaderParams1, + ); + } + if (message.terrainShader) { + renderer.setTerrainShader(message.terrainShader); + } + if (message.terrainShaderParams0 && message.terrainShaderParams1) { + renderer.setTerrainShaderParams( + message.terrainShaderParams0, + message.terrainShaderParams1, + ); + } + if (message.preSmoothing) { + renderer.setPreSmoothing( + message.preSmoothing.enabled, + message.preSmoothing.shaderPath, + message.preSmoothing.params0, + ); + } + if (message.postSmoothing) { + renderer.setPostSmoothing( + message.postSmoothing.enabled, + message.postSmoothing.shaderPath, + message.postSmoothing.params0, + ); + } + } + break; + + case "mark_tile": + if (renderer) { + renderer.markTile(message.tile); + } + break; + + case "mark_all_dirty": + if (renderer) { + renderer.markAllDirty(); + } + break; + + case "refresh_palette": + if (renderer) { + renderer.refreshPalette(); + } + break; + + case "refresh_terrain": + if (renderer) { + renderer.refreshTerrain(); + } + break; + + case "tick_renderer": + if (renderer) { + const start = performance.now(); + renderer.tick(); + const computeMs = performance.now() - start; + sendMessage({ + type: "renderer_metrics", + computeMs, + }); + } + break; + + case "render_frame": + if (renderer) { + renderer.render(); + } + break; + default: console.warn("Unknown message :", message); } diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index e6e80b82d..a5ea358dd 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -46,6 +46,7 @@ export class WorkerClient { break; case "initialized": + case "renderer_ready": default: if (message.id && this.messageHandlers.has(message.id)) { const handler = this.messageHandlers.get(message.id)!; @@ -56,6 +57,30 @@ export class WorkerClient { } } + /** + * Add a message handler for a specific message ID. + */ + addMessageHandler( + id: string, + handler: (message: WorkerMessage) => void, + ): void { + this.messageHandlers.set(id, handler); + } + + /** + * Remove a message handler. + */ + removeMessageHandler(id: string): void { + this.messageHandlers.delete(id); + } + + /** + * Post a message to the worker with optional transferables. + */ + postMessage(message: any, transfer?: Transferable[]): void { + this.worker.postMessage(message, transfer); + } + initialize(): Promise { return new Promise((resolve, reject) => { const messageId = generateID(); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1..158bb171d 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -23,7 +23,21 @@ export type WorkerMessageType = | "attack_average_position" | "attack_average_position_result" | "transport_ship_spawn" - | "transport_ship_spawn_result"; + | "transport_ship_spawn_result" + | "init_renderer" + | "renderer_ready" + | "set_view_size" + | "set_view_transform" + | "set_alternative_view" + | "set_highlighted_owner" + | "set_shader_settings" + | "mark_tile" + | "mark_all_dirty" + | "refresh_palette" + | "refresh_terrain" + | "tick_renderer" + | "render_frame" + | "renderer_metrics"; // Base interface for all messages interface BaseWorkerMessage { @@ -112,6 +126,91 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage { result: TileRef | false; } +// Renderer messages from main thread to worker +export interface InitRendererMessage extends BaseWorkerMessage { + type: "init_renderer"; + offscreenCanvas: OffscreenCanvas; + darkMode: boolean; // Whether to use dark theme +} + +export interface SetViewSizeMessage extends BaseWorkerMessage { + type: "set_view_size"; + width: number; + height: number; +} + +export interface SetViewTransformMessage extends BaseWorkerMessage { + type: "set_view_transform"; + scale: number; + offsetX: number; + offsetY: number; +} + +export interface SetAlternativeViewMessage extends BaseWorkerMessage { + type: "set_alternative_view"; + enabled: boolean; +} + +export interface SetHighlightedOwnerMessage extends BaseWorkerMessage { + type: "set_highlighted_owner"; + ownerSmallId: number | null; +} + +export interface SetShaderSettingsMessage extends BaseWorkerMessage { + type: "set_shader_settings"; + territoryShader?: string; + territoryShaderParams0?: number[]; + territoryShaderParams1?: number[]; + terrainShader?: string; + terrainShaderParams0?: number[]; + terrainShaderParams1?: number[]; + preSmoothing?: { + enabled: boolean; + shaderPath: string; + params0: number[]; + }; + postSmoothing?: { + enabled: boolean; + shaderPath: string; + params0: number[]; + }; +} + +export interface MarkTileMessage extends BaseWorkerMessage { + type: "mark_tile"; + tile: TileRef; +} + +export interface MarkAllDirtyMessage extends BaseWorkerMessage { + type: "mark_all_dirty"; +} + +export interface RefreshPaletteMessage extends BaseWorkerMessage { + type: "refresh_palette"; +} + +export interface RefreshTerrainMessage extends BaseWorkerMessage { + type: "refresh_terrain"; +} + +export interface TickRendererMessage extends BaseWorkerMessage { + type: "tick_renderer"; +} + +export interface RenderFrameMessage extends BaseWorkerMessage { + type: "render_frame"; +} + +// Renderer messages from worker to main thread +export interface RendererReadyMessage extends BaseWorkerMessage { + type: "renderer_ready"; +} + +export interface RendererMetricsMessage extends BaseWorkerMessage { + type: "renderer_metrics"; + computeMs: number; +} + // Union types for type safety export type MainThreadMessage = | HeartbeatMessage @@ -121,7 +220,19 @@ export type MainThreadMessage = | PlayerProfileMessage | PlayerBorderTilesMessage | AttackAveragePositionMessage - | TransportShipSpawnMessage; + | TransportShipSpawnMessage + | InitRendererMessage + | SetViewSizeMessage + | SetViewTransformMessage + | SetAlternativeViewMessage + | SetHighlightedOwnerMessage + | SetShaderSettingsMessage + | MarkTileMessage + | MarkAllDirtyMessage + | RefreshPaletteMessage + | RefreshTerrainMessage + | TickRendererMessage + | RenderFrameMessage; // Message send from worker export type WorkerMessage = @@ -131,4 +242,6 @@ export type WorkerMessage = | PlayerProfileResultMessage | PlayerBorderTilesResultMessage | AttackAveragePositionResultMessage - | TransportShipSpawnResultMessage; + | TransportShipSpawnResultMessage + | RendererReadyMessage + | RendererMetricsMessage; diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts new file mode 100644 index 000000000..cf137b3bc --- /dev/null +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -0,0 +1,603 @@ +import { Theme } from "../configuration/Config"; +import { TileRef } from "../game/GameMap"; +import { GameUpdateViewData } from "../game/GameUpdates"; +import { TerrainMapData } from "../game/TerrainMapLoader"; +import { GameRunner } from "../GameRunner"; +import { GameViewAdapter } from "./GameViewAdapter"; + +// Import rendering components from client (they should work with adapter) +import { ComputePass } from "../../client/graphics/webgpu/compute/ComputePass"; +import { DefendedStrengthFullPass } from "../../client/graphics/webgpu/compute/DefendedStrengthFullPass"; +import { DefendedStrengthPass } from "../../client/graphics/webgpu/compute/DefendedStrengthPass"; +import { StateUpdatePass } from "../../client/graphics/webgpu/compute/StateUpdatePass"; +import { TerrainComputePass } from "../../client/graphics/webgpu/compute/TerrainComputePass"; +import { VisualStateSmoothingPass } from "../../client/graphics/webgpu/compute/VisualStateSmoothingPass"; +import { GroundTruthData } from "../../client/graphics/webgpu/core/GroundTruthData"; +import { WebGPUDevice } from "../../client/graphics/webgpu/core/WebGPUDevice"; +import { RenderPass } from "../../client/graphics/webgpu/render/RenderPass"; +import { TemporalResolvePass } from "../../client/graphics/webgpu/render/TemporalResolvePass"; +import { TerritoryRenderPass } from "../../client/graphics/webgpu/render/TerritoryRenderPass"; + +/** + * Worker-compatible WebGPU territory renderer. + * Works with Game directly (not GameView) and uses OffscreenCanvas. + */ +export class WorkerTerritoryRenderer { + private device: WebGPUDevice | null = null; + private resources: GroundTruthData | null = null; + private gameViewAdapter: GameViewAdapter | null = null; + private ready = false; + + // Compute passes + private computePasses: ComputePass[] = []; + private computePassOrder: ComputePass[] = []; + private frameComputePasses: ComputePass[] = []; + + // Render passes + private renderPasses: RenderPass[] = []; + private renderPassOrder: RenderPass[] = []; + + // Pass instances + private terrainComputePass: TerrainComputePass | null = null; + private stateUpdatePass: StateUpdatePass | null = null; + private defendedStrengthFullPass: DefendedStrengthFullPass | null = null; + private defendedStrengthPass: DefendedStrengthPass | null = null; + private visualStateSmoothingPass: VisualStateSmoothingPass | null = null; + private territoryRenderPass: TerritoryRenderPass | null = null; + private temporalResolvePass: TemporalResolvePass | null = null; + + private territoryShaderPath = "render/territory.wgsl"; + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); + private terrainShaderPath = "compute/terrain-compute.wgsl"; + private terrainShaderParams0 = new Float32Array(4); + private terrainShaderParams1 = new Float32Array(4); + private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl"; + private preSmoothingParams0 = new Float32Array(4); + private postSmoothingShaderPath = "render/temporal-resolve.wgsl"; + private postSmoothingParams0 = new Float32Array(4); + + private preSmoothingEnabled = false; + private postSmoothingEnabled = false; + private defensePostRange: number; + + /** + * Initialize renderer with offscreen canvas and game data. + */ + async init( + offscreenCanvas: OffscreenCanvas, + gameRunner: GameRunner, + mapData: TerrainMapData, + theme: Theme, + ): Promise { + const game = gameRunner.game; + this.defensePostRange = game.config().defensePostRange(); + + // Create adapter + this.gameViewAdapter = new GameViewAdapter(game, mapData, theme); + + // Initialize WebGPU device with offscreen canvas + const webgpuDevice = await WebGPUDevice.create(offscreenCanvas); + if (!webgpuDevice) { + throw new Error("Failed to create WebGPU device in worker"); + } + this.device = webgpuDevice; + + // Create ground truth data using adapter + const state = this.gameViewAdapter.tileStateView(); + this.resources = GroundTruthData.create( + webgpuDevice.device, + this.gameViewAdapter as any, + theme, + state, + ); + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); + this.resources.setTerrainShaderParams( + this.terrainShaderParams0, + this.terrainShaderParams1, + ); + + // Upload terrain data and params + this.resources.uploadTerrainData(); + this.resources.uploadTerrainParams(); + + // Create compute passes + this.terrainComputePass = new TerrainComputePass(); + void this.terrainComputePass.setShader(this.terrainShaderPath); + this.stateUpdatePass = new StateUpdatePass(); + this.defendedStrengthFullPass = new DefendedStrengthFullPass(); + this.defendedStrengthPass = new DefendedStrengthPass(); + this.visualStateSmoothingPass = new VisualStateSmoothingPass(); + + this.computePasses = [ + this.terrainComputePass, + this.stateUpdatePass, + this.defendedStrengthFullPass, + this.defendedStrengthPass, + ]; + + this.frameComputePasses = [this.visualStateSmoothingPass]; + + // Create render passes + this.territoryRenderPass = new TerritoryRenderPass(); + this.temporalResolvePass = new TemporalResolvePass(); + this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass]; + + // Initialize all passes + for (const pass of this.computePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + + for (const pass of this.frameComputePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + + for (const pass of this.renderPasses) { + await pass.init( + webgpuDevice.device, + this.resources, + webgpuDevice.canvasFormat, + ); + } + + if (this.territoryRenderPass) { + await this.territoryRenderPass.setShader(this.territoryShaderPath); + } + + // Compute dependency order + this.computePassOrder = this.topologicalSort(this.computePasses); + this.renderPassOrder = this.topologicalSort(this.renderPasses); + + this.ready = true; + } + + /** + * Update game view adapter with latest game update. + */ + updateGameView(gu: GameUpdateViewData): void { + if (this.gameViewAdapter) { + this.gameViewAdapter.update(gu); + } + } + + /** + * Topological sort of passes based on dependencies. + */ + private topologicalSort( + passes: T[], + ): T[] { + const passMap = new Map(); + for (const pass of passes) { + passMap.set(pass.name, pass); + } + + const visited = new Set(); + const visiting = new Set(); + const result: T[] = []; + + const visit = (pass: T): void => { + if (visiting.has(pass.name)) { + console.warn( + `Circular dependency detected involving pass: ${pass.name}`, + ); + return; + } + if (visited.has(pass.name)) { + return; + } + + visiting.add(pass.name); + for (const depName of pass.dependencies) { + const dep = passMap.get(depName); + if (dep) { + visit(dep); + } + } + visiting.delete(pass.name); + visited.add(pass.name); + result.push(pass); + }; + + for (const pass of passes) { + if (!visited.has(pass.name)) { + visit(pass); + } + } + + return result; + } + + setViewSize(width: number, height: number): void { + if (!this.resources || !this.device) { + return; + } + + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + + // OffscreenCanvas doesn't have width/height properties we can set + // The size is managed by the main thread canvas + this.resources.setViewSize(nextWidth, nextHeight); + this.device.reconfigure(); + + if (this.postSmoothingEnabled && this.resources) { + this.resources.ensurePostSmoothingTextures( + nextWidth, + nextHeight, + this.device.canvasFormat, + ); + this.resources.invalidateHistory(); + } + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + if (!this.resources) { + return; + } + this.resources.setViewTransform(scale, offsetX, offsetY); + } + + setAlternativeView(enabled: boolean): void { + if (!this.resources) { + return; + } + this.resources.setAlternativeView(enabled); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + if (!this.resources) { + return; + } + this.resources.setHighlightedOwnerId(ownerSmallId); + } + + setTerritoryShader(shaderPath: string): void { + this.territoryShaderPath = shaderPath; + if (this.territoryRenderPass) { + void this.territoryRenderPass.setShader(shaderPath); + } + this.resources?.invalidateHistory(); + } + + setTerrainShader(shaderPath: string): void { + this.terrainShaderPath = shaderPath; + if (!this.terrainComputePass) { + return; + } + void this.terrainComputePass.setShader(shaderPath).then(() => { + this.refreshTerrain(); + }); + } + + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.territoryShaderParams0[i] = Number(params0[i] ?? 0); + this.territoryShaderParams1[i] = Number(params1[i] ?? 0); + } + + if (!this.resources) { + return; + } + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); + this.resources.invalidateHistory(); + } + + setTerrainShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.terrainShaderParams0[i] = Number(params0[i] ?? 0); + this.terrainShaderParams1[i] = Number(params1[i] ?? 0); + } + + if (!this.resources) { + return; + } + this.resources.setTerrainShaderParams( + this.terrainShaderParams0, + this.terrainShaderParams1, + ); + this.refreshTerrain(); + } + + setPreSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.preSmoothingEnabled = enabled; + if (shaderPath) { + this.preSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.preSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPreSmoothingConfig(); + } + + setPostSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.postSmoothingEnabled = enabled; + if (shaderPath) { + this.postSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.postSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPostSmoothingConfig(); + } + + private applyPreSmoothingConfig(): void { + if (!this.resources || !this.visualStateSmoothingPass) { + return; + } + + this.resources.setUseVisualStateTexture(this.preSmoothingEnabled); + if (this.preSmoothingEnabled) { + this.resources.ensureVisualStateTexture(); + void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath); + this.visualStateSmoothingPass.setParams(this.preSmoothingParams0); + } else { + this.visualStateSmoothingPass.setParams(new Float32Array(4)); + this.resources.releaseVisualStateTexture(); + } + + this.resources.invalidateHistory(); + } + + private applyPostSmoothingConfig(): void { + if (!this.resources || !this.temporalResolvePass || !this.device) { + return; + } + + if (this.postSmoothingEnabled) { + void this.temporalResolvePass.setShader(this.postSmoothingShaderPath); + this.temporalResolvePass.setParams(this.postSmoothingParams0); + this.temporalResolvePass.setEnabled(true); + // Note: canvas size not available here, will be set on first setViewSize + if (this.resources) { + this.resources.ensurePostSmoothingTextures( + 1, + 1, + this.device.canvasFormat, + ); + } + } else { + this.temporalResolvePass.setEnabled(false); + this.resources.releasePostSmoothingTextures(); + } + + this.resources.invalidateHistory(); + } + + markTile(tile: TileRef): void { + if (this.stateUpdatePass) { + // TileRef is number, StateUpdatePass.markTile expects number + this.stateUpdatePass.markTile(tile as number); + } + } + + markAllDirty(): void { + this.resources?.markDefensePostsDirty(); + } + + refreshPalette(): void { + if (!this.resources) { + return; + } + this.resources.markPaletteDirty(); + } + + markDefensePostsDirty(): void { + if (!this.resources) { + return; + } + this.resources.markDefensePostsDirty(); + } + + refreshTerrain(): void { + if (!this.resources || !this.device) { + return; + } + this.resources.markTerrainParamsDirty(); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + this.computeTerrainImmediate(); + } + } + + private computeTerrainImmediate(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.terrainComputePass + ) { + return; + } + + this.resources.uploadTerrainParams(); + + if (!this.terrainComputePass.needsUpdate()) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(encoder, this.resources); + this.device.device.queue.submit([encoder.finish()]); + + if (this.territoryRenderPass) { + (this.territoryRenderPass as any).rebuildBindGroup?.(); + } + } + + /** + * Perform one simulation tick. + * Runs compute passes to update ground truth data. + */ + tick(): void { + if (!this.ready || !this.device || !this.resources) { + return; + } + + this.resources.updateTickTiming(performance.now() / 1000); + + if ( + this.gameViewAdapter?.config().defensePostRange() !== + this.defensePostRange + ) { + throw new Error("defensePostRange changed at runtime; unsupported."); + } + + // Upload palette if needed + this.resources.uploadPalette(); + + // Upload diplomacy relations + this.resources.uploadRelations(); + + // Upload defense posts if needed + this.resources.uploadDefensePosts(); + + // Initial state upload + this.resources.uploadState(); + + const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false; + if (!stateUpdatesPending) { + this.resources.setLastStateUpdateCount(0); + } + + const needsCompute = + (this.terrainComputePass?.needsUpdate() ?? false) || + stateUpdatesPending || + (this.defendedStrengthFullPass?.needsUpdate() ?? false) || + (this.defendedStrengthPass?.needsUpdate() ?? false); + + if (!needsCompute) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + + if (this.preSmoothingEnabled && stateUpdatesPending) { + this.resources.ensureVisualStateTexture(); + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + this.resources.consumeVisualStateSyncNeeded(); + } + } + + // Execute compute passes in dependency order + for (const pass of this.computePassOrder) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } + + this.device.device.queue.submit([encoder.finish()]); + } + + /** + * Render one frame. + * Runs render passes to draw to the canvas. + */ + render(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.territoryRenderPass + ) { + return; + } + + const nowSec = performance.now() / 1000; + this.resources.writeTemporalUniformBuffer(nowSec); + + // If terrain needs recomputation, trigger it asynchronously + if (this.terrainComputePass?.needsUpdate()) { + this.resources.uploadTerrainParams(); + const computeEncoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(computeEncoder, this.resources); + this.device.device.queue.submit([computeEncoder.finish()]); + } + + const encoder = this.device.device.createCommandEncoder(); + const swapchainView = this.device.context.getCurrentTexture().createView(); + + if ( + this.preSmoothingEnabled && + this.resources.consumeVisualStateSyncNeeded() + ) { + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + } + } + + for (const pass of this.frameComputePasses) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } + + // Execute render passes in dependency order + for (const pass of this.renderPassOrder) { + if (!pass.needsUpdate()) { + continue; + } + if (pass === this.territoryRenderPass && this.postSmoothingEnabled) { + if (!this.resources.getCurrentColorTexture()) { + // Use view size from resources (stored via setViewSize) + const viewWidth = (this.resources as any).viewWidth ?? 1; + const viewHeight = (this.resources as any).viewHeight ?? 1; + this.resources.ensurePostSmoothingTextures( + viewWidth, + viewHeight, + this.device.canvasFormat, + ); + } + const currentTexture = this.resources.getCurrentColorTexture(); + if (currentTexture) { + pass.execute(encoder, this.resources, currentTexture.createView()); + } + continue; + } + + pass.execute(encoder, this.resources, swapchainView); + } + + this.device.device.queue.submit([encoder.finish()]); + } +}