import { Theme } from "../../../core/configuration/Config"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { createCanvas } from "../../Utils"; import { ComputePass } from "./compute/ComputePass"; import { DefendedClearPass } from "./compute/DefendedClearPass"; import { DefendedUpdatePass } from "./compute/DefendedUpdatePass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; import { TerrainComputePass } from "./compute/TerrainComputePass"; import { GroundTruthData } from "./core/GroundTruthData"; import { WebGPUDevice } from "./core/WebGPUDevice"; import { RenderPass } from "./render/RenderPass"; import { TerritoryRenderPass } from "./render/TerritoryRenderPass"; export interface TerritoryWebGLCreateResult { renderer: TerritoryRenderer | null; reason?: string; } /** * Main orchestrator for WebGPU territory rendering. * Manages compute passes (tick-based) and render passes (frame-based). */ export class TerritoryRenderer { public readonly canvas: HTMLCanvasElement; private device: WebGPUDevice | null = null; private resources: GroundTruthData | null = null; private ready = false; private initPromise: Promise | null = null; // Compute passes private computePasses: ComputePass[] = []; private computePassOrder: ComputePass[] = []; // Render passes private renderPasses: RenderPass[] = []; private renderPassOrder: RenderPass[] = []; // Pass instances private terrainComputePass: TerrainComputePass | null = null; private stateUpdatePass: StateUpdatePass | null = null; private defendedClearPass: DefendedClearPass | null = null; private defendedUpdatePass: DefendedUpdatePass | null = null; private territoryRenderPass: TerritoryRenderPass | null = null; // State tracking private needsDefendedRebuild = true; private needsDefendedHardClear = true; 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): TerritoryWebGLCreateResult { 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 nav = globalThis.navigator as any; if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { return { renderer: null, reason: "WebGPU not available; GPU renderer disabled.", }; } const renderer = new TerritoryRenderer(game, theme); renderer.startInit(); return { renderer }; } private startInit(): void { if (this.initPromise) return; this.initPromise = this.init(); } private async init(): Promise { const webgpuDevice = await WebGPUDevice.create(this.canvas); if (!webgpuDevice) { return; } this.device = webgpuDevice; const state = this.game.tileStateView(); this.resources = GroundTruthData.create( webgpuDevice.device, this.game, this.theme, state, ); // Upload terrain data and params (terrain colors will be computed on GPU) this.resources.uploadTerrainData(); this.resources.uploadTerrainParams(); // Create compute passes (terrain compute should run first) this.terrainComputePass = new TerrainComputePass(); this.stateUpdatePass = new StateUpdatePass(); this.defendedClearPass = new DefendedClearPass(); this.defendedUpdatePass = new DefendedUpdatePass(); this.computePasses = [ this.terrainComputePass, this.stateUpdatePass, this.defendedClearPass, this.defendedUpdatePass, ]; // Create render passes this.territoryRenderPass = new TerritoryRenderPass(); this.renderPasses = [this.territoryRenderPass]; // Initialize all passes for (const pass of this.computePasses) { await pass.init(webgpuDevice.device, this.resources); } for (const pass of this.renderPasses) { await pass.init( webgpuDevice.device, this.resources, webgpuDevice.canvasFormat, ); } // Compute dependency order (topological sort) this.computePassOrder = this.topologicalSort(this.computePasses); this.renderPassOrder = this.topologicalSort(this.renderPasses); this.ready = true; } /** * Topological sort of passes based on dependencies. * Ensures passes run in the correct order. */ 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)); if (nextWidth === this.canvas.width && nextHeight === this.canvas.height) { return; } this.canvas.width = nextWidth; this.canvas.height = nextHeight; this.resources.setViewSize(nextWidth, nextHeight); this.device.reconfigure(); } 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); } markTile(tile: TileRef): void { if (this.stateUpdatePass) { this.stateUpdatePass.markTile(tile); } } markAllDirty(): void { this.needsDefendedRebuild = true; if (this.defendedUpdatePass) { this.defendedUpdatePass.markDirty(); } } refreshPalette(): void { if (!this.resources) { return; } this.resources.markPaletteDirty(); } markDefensePostsDirty(): void { if (!this.resources) { return; } this.resources.markDefensePostsDirty(); this.needsDefendedRebuild = true; if (this.defendedUpdatePass) { this.defendedUpdatePass.markDirty(); } } refreshTerrain(): void { if (!this.resources || !this.device) { return; } this.resources.markTerrainParamsDirty(); if (this.terrainComputePass) { this.terrainComputePass.markDirty(); // Immediately compute terrain to avoid blank rendering this.computeTerrainImmediate(); } } /** * Immediately execute terrain compute pass (for theme changes). * This ensures terrain is recomputed before the next render. */ private computeTerrainImmediate(): void { if ( !this.ready || !this.device || !this.resources || !this.terrainComputePass ) { return; } // Upload terrain params if needed 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()]); // Rebuild render pass bind group to ensure it uses the updated terrain texture // This will be called again in render(), but doing it here ensures it's ready 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; } // Upload palette if needed this.resources.uploadPalette(); // Upload defense posts if needed (tracks if it was dirty before upload) const wasDefensePostsDirty = (this.resources as any) .needsDefensePostsUpload; this.resources.uploadDefensePosts(); // Initial state upload this.resources.uploadState(); // Check if we need to run compute passes const numUpdates = this.stateUpdatePass ? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0) : 0; const needsTerrainCompute = this.terrainComputePass ? this.terrainComputePass.needsUpdate() : false; const range = this.game.config().defensePostRange(); const rangeChanged = range !== this.resources.getLastDefenseRange(); const countChanged = this.resources.getDefensePostsCount() !== this.resources.getLastDefensePostsCount(); const hasPosts = this.resources.getDefensePostsCount() > 0; // Use explicit boolean checks to satisfy linter (|| is correct for boolean OR) const shouldRebuildDefended = this.needsDefendedRebuild === true || wasDefensePostsDirty === true || rangeChanged === true || countChanged === true || (hasPosts && numUpdates > 0); const needsCompute = needsTerrainCompute === true || numUpdates > 0 || shouldRebuildDefended === true || this.needsDefendedHardClear === true; // Update defense params even if we early-out if (!needsCompute) { this.resources.writeDefenseParamsBuffer(); this.resources.setLastDefenseRange(range); this.resources.setLastDefensePostsCount( this.resources.getDefensePostsCount(), ); return; } const encoder = this.device.device.createCommandEncoder(); // Handle defended rebuild (before executing passes) if (shouldRebuildDefended) { // Increment epoch for this rebuild const epochBefore = this.resources.getDefendedEpoch(); this.resources.incrementDefendedEpoch(); const epochAfter = this.resources.getDefendedEpoch(); // If epoch wrapped, we need a hard clear if (epochAfter === 0 || epochAfter < epochBefore) { this.needsDefendedHardClear = true; this.resources.incrementDefendedEpoch(); } this.needsDefendedRebuild = false; } // Update hard clear flag for DefendedClearPass if (this.defendedClearPass) { this.defendedClearPass.setNeedsHardClear(this.needsDefendedHardClear); } // Execute compute passes in dependency order (clear will run before update if needed) for (const pass of this.computePassOrder) { if (!pass.needsUpdate()) { continue; } pass.execute(encoder, this.resources); } // After all passes, update defense params and clear flags this.resources.writeDefenseParamsBuffer(); if (this.needsDefendedHardClear && this.defendedClearPass) { this.needsDefendedHardClear = false; this.defendedClearPass.setNeedsHardClear(false); } this.resources.setLastDefenseRange(range); this.resources.setLastDefensePostsCount( this.resources.getDefensePostsCount(), ); 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; } // If terrain needs recomputation, trigger it asynchronously (no blocking) // It will be ready for the next frame, acceptable trade-off for performance 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()]); // Continue with render - may show stale terrain for one frame, but better performance } const encoder = this.device.device.createCommandEncoder(); const textureView = this.device.context.getCurrentTexture().createView(); // Execute render passes in dependency order for (const pass of this.renderPassOrder) { if (!pass.needsUpdate()) { continue; } pass.execute(encoder, this.resources, textureView); } this.device.device.queue.submit([encoder.finish()]); } }