diff --git a/index.html b/index.html index 800aaec144..7ccf8cb14d 100644 --- a/index.html +++ b/index.html @@ -274,6 +274,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index 6f06528dc1..e4ed2b701b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -526,6 +526,8 @@ "attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)", "territory_patterns_label": "🏳️ Territory Skins", "territory_patterns_desc": "Choose whether to display territory skin designs in game", + "territory_border_mode_label": "Territory Borders", + "territory_border_mode_desc": "Select border rendering style (visual only)", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ef0981bebf..2d1e06639b 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -9,6 +9,7 @@ import { PlayerCosmeticRefs, PlayerRecord, ServerMessage, + Turn, } from "../core/Schemas"; import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; @@ -35,7 +36,9 @@ import { InputHandler, MouseMoveEvent, MouseUpEvent, + SetWorkerDebugEvent, TickMetricsEvent, + WorkerMetricsEvent, } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; @@ -226,6 +229,12 @@ async function createClientGame( lobbyConfig.clientID, ); await worker.initialize(); + worker.onWorkerMetrics((metrics) => { + eventBus.emit(new WorkerMetricsEvent(metrics)); + }); + eventBus.on(SetWorkerDebugEvent, (event: SetWorkerDebugEvent) => { + worker.setWorkerDebug(event.config); + }); const gameView = new GameView( worker, config, @@ -386,15 +395,6 @@ export class ClientGameRunner { } }); - const worker = this.worker; - const keepWorkerAlive = () => { - if (this.isActive) { - worker.sendHeartbeat(); - requestAnimationFrame(keepWorkerAlive); - } - }; - requestAnimationFrame(keepWorkerAlive); - const onconnect = () => { console.log("Connected to game server!"); this.transport.rejoinGame(this.turnsSeen); @@ -436,20 +436,43 @@ export class ClientGameRunner { goToPlayer(); } - for (const turn of message.turns) { + const normalizeTurn = (turn: Turn): Turn => + this.gameView.config().isReplay() + ? { + ...turn, + intents: turn.intents.filter((i) => i.type !== "toggle_pause"), + } + : turn; + + // Firefox in particular suffers from a storm of thousands of tiny + // postMessage() calls on reconnect. Batch turns to keep the worker + // event loop responsive for render_frame and sim scheduling. + const batchSize = 256; + let batch: Turn[] = []; + const flush = () => { + if (batch.length === 0) return; + this.worker.sendTurnBatch(batch); + batch = []; + }; + + for (const rawTurn of message.turns as Turn[]) { + const turn = normalizeTurn(rawTurn); if (turn.turnNumber < this.turnsSeen) { continue; } while (turn.turnNumber - 1 > this.turnsSeen) { - this.worker.sendTurn({ + batch.push({ turnNumber: this.turnsSeen, intents: [], }); this.turnsSeen++; + if (batch.length >= batchSize) flush(); } - this.worker.sendTurn(turn); + batch.push(turn); this.turnsSeen++; + if (batch.length >= batchSize) flush(); } + flush(); } if (message.type === "desync") { if (this.lobby.gameStartInfo === undefined) { @@ -543,11 +566,19 @@ export class ClientGameRunner { const tile = this.gameView.ref(cell.x, cell.y); if ( this.gameView.isLand(tile) && - !this.gameView.hasOwner(tile) && this.gameView.inSpawnPhase() && !this.gameView.config().isRandomSpawn() ) { - this.eventBus.emit(new SendSpawnIntentEvent(tile)); + // Main thread no longer maintains authoritative tile ownership. Query the + // worker for spawn validation. + this.worker + .tileContext(tile) + .then((ctx) => { + if (!ctx.hasOwner) { + this.eventBus.emit(new SendSpawnIntentEvent(tile)); + } + }) + .catch((err) => console.warn("tileContext spawn lookup failed:", err)); return; } if (this.gameView.inSpawnPhase()) { @@ -561,12 +592,22 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + this.worker + .tileContext(tile) + .then((ctx) => { + if (!this.myPlayer) { + return; + } + this.eventBus.emit( + new SendAttackIntentEvent( + ctx.ownerId ?? null, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); + }) + .catch((err) => + console.warn("tileContext attack lookup failed:", err), + ); } else if (this.canAutoBoat(actions, tile)) { this.sendBoatAttackIntent(tile); } @@ -677,12 +718,22 @@ export class ClientGameRunner { this.myPlayer.actions(tile).then((actions) => { if (this.myPlayer === null) return; if (actions.canAttack) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.gameView.owner(tile).id(), - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + this.worker + .tileContext(tile) + .then((ctx) => { + if (!this.myPlayer) { + return; + } + this.eventBus.emit( + new SendAttackIntentEvent( + ctx.ownerId ?? null, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); + }) + .catch((err) => + console.warn("tileContext attack lookup failed:", err), + ); } }); } diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 45d1188a3b..be6b9c14f0 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -2,6 +2,7 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { UnitType } from "../core/game/Game"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; +import type { WorkerMetricsMessage } from "../core/worker/WorkerMessages"; import { UIState } from "./graphics/UIState"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; @@ -81,6 +82,20 @@ export class RefreshGraphicsEvent implements GameEvent {} export class TogglePerformanceOverlayEvent implements GameEvent {} +export class SetWorkerDebugEvent implements GameEvent { + constructor( + public readonly config: { + enabled: boolean; + intervalMs?: number; + includeTrace?: boolean; + }, + ) {} +} + +export class WorkerMetricsEvent implements GameEvent { + constructor(public readonly metrics: WorkerMetricsMessage) {} +} + export class ToggleStructureEvent implements GameEvent { constructor(public readonly structureTypes: UnitType[] | null) {} } @@ -136,6 +151,10 @@ export class TickMetricsEvent implements GameEvent { ) {} } +export class WebGPUComputeMetricsEvent implements GameEvent { + constructor(public readonly computeMs: number) {} +} + export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 70f4251f80..4e27a42c37 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -5,6 +5,7 @@ import { UserSettings } from "../core/game/UserSettings"; import "./components/baseComponents/setting/SettingKeybind"; import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind"; import "./components/baseComponents/setting/SettingNumber"; +import "./components/baseComponents/setting/SettingSelect"; import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; import { BaseModal } from "./components/BaseModal"; diff --git a/src/client/components/baseComponents/setting/SettingSelect.ts b/src/client/components/baseComponents/setting/SettingSelect.ts new file mode 100644 index 0000000000..0dbbe41f6f --- /dev/null +++ b/src/client/components/baseComponents/setting/SettingSelect.ts @@ -0,0 +1,62 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export type SettingSelectOption = { value: string; label: string }; + +@customElement("setting-select") +export class SettingSelect extends LitElement { + @property() label = "Setting"; + @property() description = ""; + @property() id = ""; + @property() value = ""; + @property({ attribute: false }) options: SettingSelectOption[] = []; + @property({ type: Boolean }) easter = false; + + createRenderRoot() { + return this; + } + + private handleChange(e: Event) { + const select = e.target as HTMLSelectElement; + this.value = select.value; + this.dispatchEvent( + new CustomEvent("change", { + detail: { value: this.value }, + bubbles: true, + composed: true, + }), + ); + } + + render() { + const rainbowClass = this.easter + ? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]" + : ""; + + return html` +
+
+
+ ${this.label} +
+
+ ${this.description} +
+
+ + +
+ `; + } +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 0a0e8b5cf8..2fc66d3ec3 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -38,11 +38,11 @@ import { SpawnVideoAd } from "./layers/SpawnVideoReward"; import { StructureIconsLayer } from "./layers/StructureIconsLayer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; -import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; +import { WebGPUDebugOverlay } from "./layers/WebGPUDebugOverlay"; import { WinModal } from "./layers/WinModal"; export function createRenderer( @@ -224,6 +224,16 @@ export function createRenderer( performanceOverlay.eventBus = eventBus; performanceOverlay.userSettings = userSettings; + const webgpuDebugOverlay = document.querySelector( + "webgpu-debug-overlay", + ) as WebGPUDebugOverlay; + if (!(webgpuDebugOverlay instanceof WebGPUDebugOverlay)) { + console.error("webgpu debug overlay not found"); + } + webgpuDebugOverlay.eventBus = eventBus; + webgpuDebugOverlay.userSettings = userSettings; + webgpuDebugOverlay.requestUpdate(); + const alertFrame = document.querySelector("alert-frame") as AlertFrame; if (!(alertFrame instanceof AlertFrame)) { console.error("alert frame not found"); @@ -263,7 +273,6 @@ export function createRenderer( // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ - new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, eventBus, transformHandler), structureLayer, @@ -306,6 +315,7 @@ export function createRenderer( spawnVideoAd, alertFrame, performanceOverlay, + webgpuDebugOverlay, ]; return new GameRenderer( @@ -316,6 +326,7 @@ export function createRenderer( uiState, layers, performanceOverlay, + webgpuDebugOverlay, ); } @@ -331,8 +342,10 @@ export class GameRenderer { public uiState: UIState, private layers: Layer[], private performanceOverlay: PerformanceOverlay, + private webgpuDebugOverlay: WebGPUDebugOverlay, ) { - const context = canvas.getContext("2d", { alpha: false }); + // Keep the main canvas transparent; the WebGPU territory canvas renders the background. + const context = canvas.getContext("2d", { alpha: true }); if (context === null) throw new Error("2d context not supported"); this.context = context; } @@ -380,13 +393,8 @@ export class GameRenderer { renderGame() { FrameProfiler.clear(); const start = performance.now(); - // Set background - this.context.fillStyle = this.game - .config() - .theme() - .backgroundColor() - .toHex(); - this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + // Clear overlay canvas to transparent; the territory WebGPU canvas draws the base. + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); const handleTransformState = ( needsTransform: boolean, @@ -424,6 +432,7 @@ export class GameRenderer { const layerDurations = FrameProfiler.consume(); this.performanceOverlay.updateFrameMetrics(duration, layerDurations); + this.webgpuDebugOverlay.updateFrameMetrics(duration); if (duration > 50) { console.warn( diff --git a/src/client/graphics/HoverInfo.ts b/src/client/graphics/HoverInfo.ts new file mode 100644 index 0000000000..c99f25777f --- /dev/null +++ b/src/client/graphics/HoverInfo.ts @@ -0,0 +1,73 @@ +import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { GameView, PlayerView, UnitView } from "../../core/game/GameView"; + +export type HoverInfo = { + player: PlayerView | null; + unit: UnitView | null; + isWilderness: boolean; + isIrradiatedWilderness: boolean; +}; + +function euclideanDistWorld( + coord: { x: number; y: number }, + tileRef: TileRef, + game: GameView, +): number { + const x = game.x(tileRef); + const y = game.y(tileRef); + const dx = coord.x - x; + const dy = coord.y - y; + return Math.sqrt(dx * dx + dy * dy); +} + +function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { + return (a: UnitView, b: UnitView) => { + const distA = euclideanDistWorld(coord, a.tile(), game); + const distB = euclideanDistWorld(coord, b.tile(), game); + return distA - distB; + }; +} + +export function getHoverInfo( + game: GameView, + worldCoord: { x: number; y: number }, +): HoverInfo { + const info: HoverInfo = { + player: null, + unit: null, + isWilderness: false, + isIrradiatedWilderness: false, + }; + + if (!game.isValidCoord(worldCoord.x, worldCoord.y)) { + return info; + } + + const tile = game.ref(worldCoord.x, worldCoord.y); + const owner = game.owner(tile); + + if (owner && owner.isPlayer()) { + info.player = owner as PlayerView; + return info; + } + + if (owner && !owner.isPlayer() && game.isLand(tile)) { + info.isIrradiatedWilderness = game.hasFallout(tile); + info.isWilderness = !info.isIrradiatedWilderness; + return info; + } + + if (!game.isLand(tile)) { + const units = game + .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) + .filter((u) => euclideanDistWorld(worldCoord, u.tile(), game) < 50) + .sort(distSortUnitWorld(worldCoord, game)); + + if (units.length > 0) { + info.unit = units[0]; + } + } + + return info; +} diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index d5e86315a5..8962724801 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -140,12 +140,9 @@ export function getPlayerIcons( return isSendingNuke && notMyPlayer && unit.isActive(); }); - const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => { - const detonationDst = unit.targetTile(); - if (!detonationDst || !myPlayer) return false; - const targetId = game.owner(detonationDst).id(); - return targetId === myPlayer.id(); - }); + // Main thread does not maintain authoritative tile ownership; treat this icon + // as informational only (no "targeted at me" specialization here). + const isMyPlayerTarget = false; if (nukesSentByOtherPlayer.length > 0) { const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon; diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 6cf43cd774..a472c691d9 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -45,6 +45,14 @@ export class TransformHandler { return this._boundingRect; } + getOffsetX(): number { + return this.offsetX; + } + + getOffsetY(): number { + return this.offsetY; + } + width(): number { return this.boundingRect().width; } diff --git a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts new file mode 100644 index 0000000000..474627fb9c --- /dev/null +++ b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts @@ -0,0 +1,434 @@ +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, + SetPaletteMessage, + SetPatternsEnabledMessage, + SetShaderSettingsMessage, + ViewSize, + ViewTransform, +} from "../../../core/worker/WorkerMessages"; + +export interface Canvas2DCreateResult { + renderer: Canvas2DRendererProxy | null; + reason?: string; +} + +export class Canvas2DRendererProxy { + public readonly canvas: HTMLCanvasElement; + private offscreenCanvas: OffscreenCanvas | null = null; + private worker: WorkerClient | null = null; + private ready = false; + private failed = false; + private initPromise: Promise | null = null; + private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; + + private viewSize: ViewSize = { width: 1, height: 1 }; + private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + private lastSentViewSize: ViewSize | null = null; + private lastSentViewTransform: ViewTransform | null = null; + private renderInFlight = false; + private renderSeq = 0; + private renderCooldownUntilMs = 0; + + 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, + ): Canvas2DCreateResult { + if (typeof OffscreenCanvas === "undefined") { + return { + renderer: null, + reason: + "OffscreenCanvas not supported; Canvas2D worker renderer disabled.", + }; + } + if ( + typeof HTMLCanvasElement.prototype.transferControlToOffscreen !== + "function" + ) { + return { + renderer: null, + reason: + "transferControlToOffscreen not supported; Canvas2D worker renderer disabled.", + }; + } + + const renderer = new Canvas2DRendererProxy(game, theme); + renderer.worker = worker; + renderer.startInit(); + return { renderer }; + } + + private startInit(): void { + if (this.initPromise) return; + this.initPromise = this.init().catch((err) => { + this.failed = true; + this.renderInFlight = false; + this.pendingMessages = []; + console.error("Worker canvas2d renderer init failed:", err); + throw err; + }); + } + + private async init(): Promise { + if (!this.worker) { + throw new Error("Worker not set"); + } + + this.offscreenCanvas = this.canvas.transferControlToOffscreen(); + + const themeAny = this.theme as any; + const darkMode = themeAny.darkShore !== undefined; + + const messageId = `init_renderer_canvas2d_${Date.now()}`; + const initMessage: InitRendererMessage = { + type: "init_renderer", + id: messageId, + offscreenCanvas: this.offscreenCanvas, + darkMode: darkMode, + backend: "canvas2d", + }; + + this.worker.postMessage(initMessage, [this.offscreenCanvas]); + + 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); + if (message.ok === false) { + reject( + new Error(message.error ?? "Renderer initialization failed"), + ); + return; + } + + this.ready = true; + for (const pending of this.pendingMessages) { + if (pending.transferables) { + this.worker?.postMessage(pending.message, pending.transferables); + } else { + this.sendToWorker(pending.message); + } + } + this.pendingMessages = []; + resolve(); + } + }; + + this.worker?.addMessageHandler(messageId, handler); + }); + } + + private sendToWorker(message: any): void { + if (!this.worker) return; + if (this.failed) return; + if (!this.ready) { + this.pendingMessages.push({ message }); + return; + } + this.worker.postMessage(message); + } + + private sendToWorkerWithTransfer(message: any, transferables: any[]): void { + if (!this.worker) return; + if (this.failed) return; + if (!this.ready) { + this.pendingMessages.push({ message, transferables }); + return; + } + this.worker.postMessage(message, transferables); + } + + setViewSize(width: number, height: number): void { + this.viewSize = { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), + }; + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + this.viewTransform = { scale, offsetX, offsetY }; + } + + setAlternativeView(enabled: boolean): void { + const message: SetAlternativeViewMessage = { + type: "set_alternative_view", + enabled, + }; + this.sendToWorker(message); + } + + setPatternsEnabled(enabled: boolean): void { + const message: SetPatternsEnabledMessage = { + type: "set_patterns_enabled", + enabled, + }; + this.sendToWorker(message); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + const message: SetHighlightedOwnerMessage = { + type: "set_highlighted_owner", + ownerSmallId, + }; + this.sendToWorker(message); + } + + // Shader controls are ignored by the Canvas2D backend but kept for API parity. + setTerritoryShader(_shaderPath: string): void {} + setTerrainShader(_shaderPath: string): void {} + setTerritoryShaderParams( + _params0: Float32Array | number[], + _params1: Float32Array | number[], + ): void {} + setTerrainShaderParams( + _params0: Float32Array | number[], + _params1: Float32Array | number[], + ): void {} + setPreSmoothing( + _enabled: boolean, + _shaderPath: string, + _params0: Float32Array | number[], + ): void {} + setPostSmoothing( + _enabled: boolean, + _shaderPath: string, + _params0: Float32Array | number[], + ): void {} + setShaderSettings(_settings: SetShaderSettingsMessage): void {} + + 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); + } + + markDefensePostsDirty(): void { + this.markAllDirty(); + } + + refreshPalette(): void { + if (!this.worker) return; + + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + + const RESERVED = 10; + const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + // Fallout slot (index 0) + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tc = player.territoryColor().toRgb(); + row0[idx] = toByte(tc.r); + row0[idx + 1] = toByte(tc.g); + row0[idx + 2] = toByte(tc.b); + row0[idx + 3] = 255; + + const bc = player.borderColor().toRgb(); + row1[idx] = toByte(bc.r); + row1[idx + 1] = toByte(bc.g); + row1[idx + 2] = toByte(bc.b); + row1[idx + 3] = 255; + } + + const message: SetPaletteMessage = { + type: "set_palette", + paletteWidth, + maxSmallId, + row0, + row1, + }; + this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]); + + const fallback: RefreshPaletteMessage = { type: "refresh_palette" }; + this.sendToWorker(fallback); + } + + refreshTerrain(): void { + const message: RefreshTerrainMessage = { type: "refresh_terrain" }; + this.sendToWorker(message); + } + + tick(): void { + // No-op: worker renderer ticks from worker-side game_update. + } + + render(): void { + if (this.failed) { + return; + } + if (performance.now() < this.renderCooldownUntilMs) { + return; + } + if (this.renderInFlight) { + return; + } + + this.renderInFlight = true; + const renderId = `render_${++this.renderSeq}`; + const sentAtWallMs = Date.now(); + + const message: RenderFrameMessage = { type: "render_frame" }; + message.id = renderId; + message.sentAtWallMs = sentAtWallMs; + + if ( + !this.lastSentViewSize || + this.lastSentViewSize.width !== this.viewSize.width || + this.lastSentViewSize.height !== this.viewSize.height + ) { + message.viewSize = this.viewSize; + this.lastSentViewSize = this.viewSize; + } + + if ( + !this.lastSentViewTransform || + this.lastSentViewTransform.scale !== this.viewTransform.scale || + this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX || + this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY + ) { + message.viewTransform = this.viewTransform; + this.lastSentViewTransform = this.viewTransform; + } + + const worker = this.worker; + if (worker) { + const timeout = setTimeout(() => { + if (!this.renderInFlight) { + worker.removeMessageHandler(renderId); + return; + } + + console.warn(`render_done timeout (${renderId})`); + worker.removeMessageHandler(renderId); + + this.renderInFlight = false; + this.renderCooldownUntilMs = performance.now() + 250; + this.lastSentViewSize = null; + this.lastSentViewTransform = null; + }, 15000); + + worker.addMessageHandler(renderId, (m: any) => { + if (m?.type !== "render_done") { + return; + } + clearTimeout(timeout); + const startedAt = typeof m.startedAt === "number" ? m.startedAt : NaN; + const endedAt = typeof m.endedAt === "number" ? m.endedAt : NaN; + const startedAtWallMs = + typeof m.startedAtWallMs === "number" ? m.startedAtWallMs : NaN; + const endedAtWallMs = + typeof m.endedAtWallMs === "number" ? m.endedAtWallMs : NaN; + const echoedSentAtWallMs = + typeof m.sentAtWallMs === "number" ? m.sentAtWallMs : sentAtWallMs; + if ( + Number.isFinite(startedAt) && + Number.isFinite(endedAt) && + Number.isFinite(startedAtWallMs) && + Number.isFinite(endedAtWallMs) && + Number.isFinite(echoedSentAtWallMs) + ) { + const queueMs = startedAtWallMs - echoedSentAtWallMs; + const renderMs = endedAt - startedAt; + const totalMs = endedAtWallMs - echoedSentAtWallMs; + const breakdown = + typeof m.renderCpuMs === "number" || + typeof m.renderGpuWaitMs === "number" || + typeof m.renderWaitPrevGpuMs === "number" || + typeof m.renderGetTextureMs === "number" + ? { + waitPrevGpuMs: + typeof m.renderWaitPrevGpuMs === "number" + ? Math.round(m.renderWaitPrevGpuMs) + : undefined, + waitPrevGpuTimedOut: + typeof m.renderWaitPrevGpuTimedOut === "boolean" + ? m.renderWaitPrevGpuTimedOut + : undefined, + cpuMs: + typeof m.renderCpuMs === "number" + ? Math.round(m.renderCpuMs) + : undefined, + getTextureMs: + typeof m.renderGetTextureMs === "number" + ? Math.round(m.renderGetTextureMs) + : undefined, + gpuWaitMs: + typeof m.renderGpuWaitMs === "number" + ? Math.round(m.renderGpuWaitMs) + : undefined, + gpuWaitTimedOut: + typeof m.renderGpuWaitTimedOut === "boolean" + ? m.renderGpuWaitTimedOut + : undefined, + } + : undefined; + if (totalMs > 1000 || queueMs > 1000 || renderMs > 1000) { + console.warn("worker render timing", { + id: renderId, + queueMs: Math.round(queueMs), + renderMs: Math.round(renderMs), + totalMs: Math.round(totalMs), + breakdown, + }); + } + } + this.renderInFlight = false; + }); + } else { + this.renderInFlight = false; + return; + } + + this.sendToWorker(message); + } +} diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index ef3547e41b..17ed8a5fba 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; import { SendEmojiIntentEvent } from "../../Transport"; @@ -24,28 +23,36 @@ export class EmojiTable extends LitElement { } const tile = this.game.ref(cell.x, cell.y); - if (!this.game.hasOwner(tile)) { - return; - } + this.game.worker.tileContext(tile).then((ctx) => { + if (!ctx.ownerId) { + return; + } - const targetPlayer = this.game.owner(tile); - // maybe redundant due to owner check but better safe than sorry - if (targetPlayer instanceof TerraNulliusImpl) { - return; - } + let targetPlayer: PlayerView | null = null; + try { + const maybe = this.game.player(ctx.ownerId); + targetPlayer = + maybe && maybe.isPlayer() ? (maybe as PlayerView) : null; + } catch { + targetPlayer = null; + } + if (!targetPlayer) { + return; + } - this.showTable((emoji) => { - const recipient = - targetPlayer === this.game.myPlayer() - ? AllPlayers - : (targetPlayer as PlayerView); - eventBus.emit( - new SendEmojiIntentEvent( - recipient, - flattenedEmojiTable.indexOf(emoji as Emoji), - ), - ); - this.hideTable(); + this.showTable((emoji) => { + const recipient = + targetPlayer === this.game.myPlayer() + ? AllPlayers + : (targetPlayer as PlayerView); + eventBus.emit( + new SendEmojiIntentEvent( + recipient, + flattenedEmojiTable.indexOf(emoji as Emoji), + ), + ); + this.hideTable(); + }); }); }); eventBus.on(CloseViewEvent, (e) => { diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 989b5aa797..8d81054377 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -114,8 +114,17 @@ export class MainRadialMenu extends LitElement implements Layer { ) { this.buildMenu.playerActions = actions; - const tileOwner = this.game.owner(tile); - const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + let recipient: PlayerView | null = null; + try { + const ctx = await this.game.worker.tileContext(tile); + if (ctx.ownerId) { + const maybe = this.game.player(ctx.ownerId); + recipient = maybe && maybe.isPlayer() ? (maybe as PlayerView) : null; + } + } catch { + // Best-effort; the menu can still operate from PlayerActions alone. + recipient = null; + } if (myPlayer && recipient) { this.chatIntegration.setupChatModal(myPlayer, recipient); diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index ad27aeaa53..4f8150e452 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -2,9 +2,12 @@ import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; +import type { WorkerMetricsMessage } from "../../../core/worker/WorkerMessages"; import { + SetWorkerDebugEvent, TickMetricsEvent, TogglePerformanceOverlayEvent, + WorkerMetricsEvent, } from "../../InputHandler"; import { translateText } from "../../Utils"; import { FrameProfiler } from "../FrameProfiler"; @@ -42,6 +45,18 @@ export class PerformanceOverlay extends LitElement implements Layer { @state() private isVisible: boolean = false; + @state() + private workerMetrics: WorkerMetricsMessage | null = null; + + @state() + private workerMetricsAgeMs: number = 0; + + @state() + private workerIncludeTrace: boolean = false; + + @state() + private workerIntervalMs: number = 1000; + @state() private isDragging: boolean = false; @@ -60,6 +75,7 @@ export class PerformanceOverlay extends LitElement implements Layer { private dragStart: { x: number; y: number } = { x: 0, y: 0 }; private tickExecutionTimes: number[] = []; private tickDelayTimes: number[] = []; + private lastWorkerMetricsWallMs: number = 0; private copyStatusTimeoutId: ReturnType | null = null; @@ -232,11 +248,24 @@ export class PerformanceOverlay extends LitElement implements Layer { this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => { this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay); }); + this.eventBus.on(WorkerMetricsEvent, (event: WorkerMetricsEvent) => { + this.workerMetrics = event.metrics; + this.lastWorkerMetricsWallMs = Date.now(); + this.workerMetricsAgeMs = 0; + this.requestUpdate(); + }); } setVisible(visible: boolean) { this.isVisible = visible; FrameProfiler.setEnabled(visible); + this.eventBus.emit( + new SetWorkerDebugEvent({ + enabled: visible, + intervalMs: this.workerIntervalMs, + includeTrace: this.workerIncludeTrace, + }), + ); } private handleClose() { @@ -326,10 +355,21 @@ export class PerformanceOverlay extends LitElement implements Layer { // Update FrameProfiler enabled state when visibility changes if (wasVisible !== this.isVisible) { FrameProfiler.setEnabled(this.isVisible); + this.eventBus.emit( + new SetWorkerDebugEvent({ + enabled: this.isVisible, + intervalMs: this.workerIntervalMs, + includeTrace: this.workerIncludeTrace, + }), + ); } if (!this.isVisible) return; + if (this.lastWorkerMetricsWallMs > 0) { + this.workerMetricsAgeMs = Date.now() - this.lastWorkerMetricsWallMs; + } + const now = performance.now(); // Initialize timing on first call @@ -486,10 +526,220 @@ export class PerformanceOverlay extends LitElement implements Layer { executionSamples: [...this.tickExecutionTimes], delaySamples: [...this.tickDelayTimes], }, + worker: { + enabled: this.isVisible, + includeTrace: this.workerIncludeTrace, + intervalMs: this.workerIntervalMs, + lastMetricsAgeMs: this.workerMetricsAgeMs, + metrics: this.workerMetrics, + }, layers: this.layerBreakdown.map((layer) => ({ ...layer })), }; } + private getWorkerKeyStats(metrics: WorkerMetricsMessage | null): { + intervalMs: number; + loopLagAvg: number; + loopLagMax: number; + simDelayAvg: number; + simDelayMax: number; + simExecAvg: number; + simExecMax: number; + rfQueueAvg: number | null; + rfQueueMax: number | null; + rfHandlerAvg: number | null; + rfHandlerMax: number | null; + renderSubmittedCount: number | null; + renderNoopCount: number | null; + renderCpuTotalAvg: number | null; + renderCpuTotalMax: number | null; + renderGetTextureAvg: number | null; + renderGetTextureMax: number | null; + renderFrameComputeAvg: number | null; + renderFrameComputeMax: number | null; + renderTerritoryPassAvg: number | null; + renderTerritoryPassMax: number | null; + renderTemporalResolveAvg: number | null; + renderTemporalResolveMax: number | null; + renderSubmitAvg: number | null; + renderSubmitMax: number | null; + traceLines: string[]; + topMsgs: Array<{ + type: string; + count: number; + queueAvg: number | null; + queueMax: number | null; + handlerAvg: number | null; + handlerMax: number | null; + }>; + } { + if (!metrics) { + return { + intervalMs: 0, + loopLagAvg: 0, + loopLagMax: 0, + simDelayAvg: 0, + simDelayMax: 0, + simExecAvg: 0, + simExecMax: 0, + rfQueueAvg: null, + rfQueueMax: null, + rfHandlerAvg: null, + rfHandlerMax: null, + renderSubmittedCount: null, + renderNoopCount: null, + renderCpuTotalAvg: null, + renderCpuTotalMax: null, + renderGetTextureAvg: null, + renderGetTextureMax: null, + renderFrameComputeAvg: null, + renderFrameComputeMax: null, + renderTerritoryPassAvg: null, + renderTerritoryPassMax: null, + renderTemporalResolveAvg: null, + renderTemporalResolveMax: null, + renderSubmitAvg: null, + renderSubmitMax: null, + traceLines: [], + topMsgs: [], + }; + } + + const rfQueueAvg = metrics.msgQueueMsAvg?.["render_frame"]; + const rfQueueMax = metrics.msgQueueMsMax?.["render_frame"]; + const rfHandlerAvg = metrics.msgHandlerMsAvg?.["render_frame"]; + const rfHandlerMax = metrics.msgHandlerMsMax?.["render_frame"]; + const traceLines = + metrics.trace && metrics.trace.length > 0 ? metrics.trace.slice(-5) : []; + + const topMsgs = Object.entries(metrics.msgCounts ?? {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([type, count]) => ({ + type, + count, + queueAvg: + typeof metrics.msgQueueMsAvg?.[type] === "number" + ? metrics.msgQueueMsAvg[type] + : null, + queueMax: + typeof metrics.msgQueueMsMax?.[type] === "number" + ? metrics.msgQueueMsMax[type] + : null, + handlerAvg: + typeof metrics.msgHandlerMsAvg?.[type] === "number" + ? metrics.msgHandlerMsAvg[type] + : null, + handlerMax: + typeof metrics.msgHandlerMsMax?.[type] === "number" + ? metrics.msgHandlerMsMax[type] + : null, + })); + + return { + intervalMs: metrics.intervalMs, + loopLagAvg: metrics.eventLoopLagMsAvg, + loopLagMax: metrics.eventLoopLagMsMax, + simDelayAvg: metrics.simPumpDelayMsAvg, + simDelayMax: metrics.simPumpDelayMsMax, + simExecAvg: metrics.simPumpExecMsAvg, + simExecMax: metrics.simPumpExecMsMax, + rfQueueAvg: typeof rfQueueAvg === "number" ? rfQueueAvg : null, + rfQueueMax: typeof rfQueueMax === "number" ? rfQueueMax : null, + rfHandlerAvg: typeof rfHandlerAvg === "number" ? rfHandlerAvg : null, + rfHandlerMax: typeof rfHandlerMax === "number" ? rfHandlerMax : null, + renderSubmittedCount: + typeof metrics.renderSubmittedCount === "number" + ? metrics.renderSubmittedCount + : null, + renderNoopCount: + typeof metrics.renderNoopCount === "number" + ? metrics.renderNoopCount + : null, + renderCpuTotalAvg: + typeof metrics.renderCpuTotalMsAvg === "number" + ? metrics.renderCpuTotalMsAvg + : null, + renderCpuTotalMax: + typeof metrics.renderCpuTotalMsMax === "number" + ? metrics.renderCpuTotalMsMax + : null, + renderGetTextureAvg: + typeof metrics.renderGetTextureMsAvg === "number" + ? metrics.renderGetTextureMsAvg + : null, + renderGetTextureMax: + typeof metrics.renderGetTextureMsMax === "number" + ? metrics.renderGetTextureMsMax + : null, + renderFrameComputeAvg: + typeof metrics.renderFrameComputeMsAvg === "number" + ? metrics.renderFrameComputeMsAvg + : null, + renderFrameComputeMax: + typeof metrics.renderFrameComputeMsMax === "number" + ? metrics.renderFrameComputeMsMax + : null, + renderTerritoryPassAvg: + typeof metrics.renderTerritoryPassMsAvg === "number" + ? metrics.renderTerritoryPassMsAvg + : null, + renderTerritoryPassMax: + typeof metrics.renderTerritoryPassMsMax === "number" + ? metrics.renderTerritoryPassMsMax + : null, + renderTemporalResolveAvg: + typeof metrics.renderTemporalResolveMsAvg === "number" + ? metrics.renderTemporalResolveMsAvg + : null, + renderTemporalResolveMax: + typeof metrics.renderTemporalResolveMsMax === "number" + ? metrics.renderTemporalResolveMsMax + : null, + renderSubmitAvg: + typeof metrics.renderSubmitMsAvg === "number" + ? metrics.renderSubmitMsAvg + : null, + renderSubmitMax: + typeof metrics.renderSubmitMsMax === "number" + ? metrics.renderSubmitMsMax + : null, + traceLines, + topMsgs, + }; + } + + private formatMs(v: number | null | undefined, digits: number = 1): string { + if (v === null || v === undefined || !Number.isFinite(v)) return "—"; + return `${v.toFixed(digits)}ms`; + } + + private onWorkerTraceToggle(e: Event) { + const target = e.target as HTMLInputElement; + this.workerIncludeTrace = !!target.checked; + this.eventBus.emit( + new SetWorkerDebugEvent({ + enabled: this.isVisible, + intervalMs: this.workerIntervalMs, + includeTrace: this.workerIncludeTrace, + }), + ); + } + + private onWorkerIntervalChange(e: Event) { + const target = e.target as HTMLSelectElement; + const ms = Number.parseInt(target.value, 10); + if (!Number.isFinite(ms) || ms <= 0) return; + this.workerIntervalMs = ms; + this.eventBus.emit( + new SetWorkerDebugEvent({ + enabled: this.isVisible, + intervalMs: this.workerIntervalMs, + includeTrace: this.workerIncludeTrace, + }), + ); + } + private clearCopyStatusTimeout() { if (this.copyStatusTimeoutId !== null) { clearTimeout(this.copyStatusTimeoutId); @@ -550,6 +800,8 @@ export class PerformanceOverlay extends LitElement implements Layer { ? Math.max(...this.layerBreakdown.map((l) => l.avg)) : 1; + const worker = this.getWorkerKeyStats(this.workerMetrics); + return html`
${this.tickDelayAvg.toFixed(2)}ms (max: ${this.tickDelayMax}ms)
+
+
Worker
+
+ metrics age + ${this.formatMs(this.workerMetricsAgeMs, 0)} +
+
+ metrics interval (worker) + ${this.formatMs(worker.intervalMs, 0)} +
+
+ event loop lag (avg / max) + ${this.formatMs(worker.loopLagAvg)} / + ${this.formatMs(worker.loopLagMax, 0)} +
+
+ sim pump delay (avg / max) + ${this.formatMs(worker.simDelayAvg)} / + ${this.formatMs(worker.simDelayMax, 0)} +
+
+ sim pump exec (avg / max) + ${this.formatMs(worker.simExecAvg)} / + ${this.formatMs(worker.simExecMax, 0)} +
+
+ render_frame queue (avg / max) + ${this.formatMs(worker.rfQueueAvg, 0)} / + ${this.formatMs(worker.rfQueueMax, 0)} +
+
+ render_frame handler (avg / max) + ${this.formatMs(worker.rfHandlerAvg, 0)} / + ${this.formatMs(worker.rfHandlerMax, 0)} +
+ ${worker.renderSubmittedCount !== null || + worker.renderNoopCount !== null + ? html`
+ render submits / noops + ${worker.renderSubmittedCount ?? 0} / + ${worker.renderNoopCount ?? 0} +
` + : html``} + ${worker.renderCpuTotalAvg !== null + ? html`
+ render CPU breakdown (avg/max, submitted frames) +
+
+ cpu total + ${this.formatMs(worker.renderCpuTotalAvg)} / + ${this.formatMs(worker.renderCpuTotalMax, 0)} +
+
+ getCurrentTexture + ${this.formatMs(worker.renderGetTextureAvg)} / + ${this.formatMs(worker.renderGetTextureMax, 0)} +
+
+ frame compute + ${this.formatMs(worker.renderFrameComputeAvg)} / + ${this.formatMs(worker.renderFrameComputeMax, 0)} +
+
+ territory pass + ${this.formatMs(worker.renderTerritoryPassAvg)} / + ${this.formatMs(worker.renderTerritoryPassMax, 0)} +
+
+ temporal resolve + ${this.formatMs(worker.renderTemporalResolveAvg)} / + ${this.formatMs(worker.renderTemporalResolveMax, 0)} +
+
+ submit + ${this.formatMs(worker.renderSubmitAvg)} / + ${this.formatMs(worker.renderSubmitMax, 0)} +
` + : html``} + ${worker.topMsgs.length + ? html`
+ top msgs (count | queue avg/max | handler avg/max) +
+ ${worker.topMsgs.map( + (m) => + html`
+ ${m.type} (${m.count}) + + ${this.formatMs(m.queueAvg, 0)}/${this.formatMs( + m.queueMax, + 0, + )} + | + ${this.formatMs(m.handlerAvg, 0)}/${this.formatMs( + m.handlerMax, + 0, + )} + +
`, + )}` + : html``} +
+ trace + + + + +
+ ${worker.traceLines.length + ? html`
+
+ ${worker.traceLines.join("\n")} +
+
` + : html``} +
${this.layerBreakdown.length ? html`
diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index b8e153a70a..d94c5cfb16 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -7,10 +7,8 @@ import { PlayerProfile, PlayerType, Relation, - Unit, UnitType, } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; import { AllianceView } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler"; @@ -34,26 +32,6 @@ import portIcon from "/images/PortIcon.svg?url"; import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url"; import soldierIcon from "/images/SoldierIcon.svg?url"; -function euclideanDistWorld( - coord: { x: number; y: number }, - tileRef: TileRef, - game: GameView, -): number { - const x = game.x(tileRef); - const y = game.y(tileRef); - const dx = coord.x - x; - const dy = coord.y - y; - return Math.sqrt(dx * dx + dy * dy); -} - -function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { - return (a: Unit | UnitView, b: Unit | UnitView) => { - const distA = euclideanDistWorld(coord, a.tile(), game); - const distB = euclideanDistWorld(coord, b.tile(), game); - return distA - distB; - }; -} - @customElement("player-info-overlay") export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) @@ -74,6 +52,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @state() private unit: UnitView | null = null; + @state() + private isWilderness: boolean = false; + + @state() + private isIrradiatedWilderness: boolean = false; + @state() private _isInfoVisible: boolean = false; @@ -81,6 +65,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private lastMouseUpdate = 0; + private showDetails = true; + private hoverSeq = 0; init() { this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => this.onMouseEvent(e), @@ -105,6 +91,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.setVisible(false); this.unit = null; this.player = null; + this.isWilderness = false; + this.isIrradiatedWilderness = false; } public maybeShow(x: number, y: number) { @@ -115,26 +103,65 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } const tile = this.game.ref(worldCoord.x, worldCoord.y); - if (!tile) return; - const owner = this.game.owner(tile); + // Land hover info requires tile ownership/fallout, which is authoritative in + // the worker (main thread does not maintain a full tile mirror). + if (this.game.isLand(tile)) { + const seq = ++this.hoverSeq; + this.game.worker + .tileContext(tile) + .then((ctx) => { + if (!this._isActive || seq !== this.hoverSeq) { + return; + } + + if (ctx.ownerId) { + try { + const owner = this.game.player(ctx.ownerId); + if (owner && owner.isPlayer()) { + this.player = owner; + this.player.profile().then((p) => { + if (this._isActive && seq === this.hoverSeq) { + this.playerProfile = p; + } + }); + this.setVisible(true); + } + } catch { + // ignore + } + return; + } + + this.isIrradiatedWilderness = ctx.hasFallout; + this.isWilderness = !ctx.hasFallout; + this.setVisible(true); + }) + .catch(() => { + // ignore hover failures + }); + return; + } - if (owner && owner.isPlayer()) { - this.player = owner as PlayerView; - this.player.profile().then((p) => { - this.playerProfile = p; + // Water hover info can be derived from unit view data (already on main). + const units = this.game + .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) + .filter((u) => { + const dx = worldCoord.x - this.game.x(u.tile()); + const dy = worldCoord.y - this.game.y(u.tile()); + return Math.sqrt(dx * dx + dy * dy) < 50; + }) + .sort((a, b) => { + const dxA = worldCoord.x - this.game.x(a.tile()); + const dyA = worldCoord.y - this.game.y(a.tile()); + const dxB = worldCoord.x - this.game.x(b.tile()); + const dyB = worldCoord.y - this.game.y(b.tile()); + return dxA * dxA + dyA * dyA - (dxB * dxB + dyB * dyB); }); + + if (units.length > 0) { + this.unit = units[0]; this.setVisible(true); - } else if (!this.game.isLand(tile)) { - const units = this.game - .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) - .filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50) - .sort(distSortUnitWorld(worldCoord, this.game)); - - if (units.length > 0) { - this.unit = units[0]; - this.setVisible(true); - } } } @@ -458,6 +485,15 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
+ ${this.isWilderness || this.isIrradiatedWilderness + ? html`
+ ${translateText( + this.isIrradiatedWilderness + ? "player_info_overlay.irradiated_wilderness_title" + : "player_info_overlay.wilderness_title", + )} +
` + : ""} ${this.player !== null ? this.renderPlayerInfo(this.player) : ""} ${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 674b83e155..0000a49f07 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -62,6 +62,7 @@ export class PlayerPanel extends LitElement implements Layer { private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; private kickedPlayerIDs = new Set(); + @state() private otherPlayer: PlayerView | null = null; @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -107,15 +108,29 @@ export class PlayerPanel extends LitElement implements Layer { async tick() { if (this.isVisible && this.tile) { - const owner = this.g.owner(this.tile); - if (owner && owner.isPlayer()) { - const pv = owner as PlayerView; - const id = pv.id(); + const tile = this.tile; + try { + const ctx = await this.g.worker.tileContext(tile); + if (!ctx.ownerId) { + this.hide(); + return; + } + const owner = this.g.player(ctx.ownerId); + if (!owner || !owner.isPlayer()) { + this.hide(); + return; + } + + this.otherPlayer = owner as PlayerView; + + const id = this.otherPlayer.id(); // fetch only if we don't have it or the player changed if (this._profileForPlayerId !== Number(id)) { - this.otherProfile = await pv.profile(); + this.otherProfile = await this.otherPlayer.profile(); this._profileForPlayerId = Number(id); } + } catch { + // If tile context fails (rare), keep the panel as-is. } // Refresh actions & alliance expiry @@ -146,6 +161,9 @@ export class PlayerPanel extends LitElement implements Layer { public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; + this.otherPlayer = null; + this.otherProfile = null; + this._profileForPlayerId = null; this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); @@ -159,6 +177,7 @@ export class PlayerPanel extends LitElement implements Layer { this.suppressNextHide = true; this.actions = actions; this.tile = tile; + this.otherPlayer = target; this.sendTarget = target; this.sendMode = "gold"; this.moderationTarget = null; @@ -170,6 +189,11 @@ export class PlayerPanel extends LitElement implements Layer { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; + this.tile = null; + this.actions = null; + this.otherPlayer = null; + this.otherProfile = null; + this._profileForPlayerId = null; this.moderationTarget = null; this.requestUpdate(); } @@ -859,13 +883,21 @@ export class PlayerPanel extends LitElement implements Layer { if (!my) return html``; if (!this.tile) return html``; - const owner = this.g.owner(this.tile); - if (!owner || !owner.isPlayer()) { - this.hide(); - console.warn("Tile is not owned by a player"); - return html``; + const other = this.otherPlayer; + if (!other) { + return html` +
+
+ ${translateText("loading")}… +
+
+ `; } - const other = owner as PlayerView; const myGoldNum = my.gold(); const myTroopsNum = Number(my.troops()); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index aeafdf8a10..3af390ccde 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -464,10 +464,9 @@ export const deleteUnitElement: MenuElement = { name: "delete", cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(), disabled: (params: MenuElementParams) => { - const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); - if (!tileOwner.isPlayer() || tileOwner.id() !== params.myPlayer.id()) { + if (!params.selected || params.selected.id() !== params.myPlayer.id()) { return true; } @@ -564,7 +563,6 @@ export const boatMenuElement: MenuElement = { export const centerButtonElement: CenterButtonElement = { disabled: (params: MenuElementParams): boolean => { - const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); if (!isLand) { return true; @@ -573,7 +571,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.config().isRandomSpawn()) { return true; } - if (tileOwner.isPlayer()) { + if (params.selected) { return true; } return false; @@ -619,10 +617,8 @@ export const rootMenuElement: MenuElement = { subMenu: (params: MenuElementParams) => { const isAllied = params.selected?.isAlliedWith(params.myPlayer); - const tileOwner = params.game.owner(params.tile); const isOwnTerritory = - tileOwner.isPlayer() && - (tileOwner as PlayerView).id() === params.myPlayer.id(); + params.selected !== null && params.selected.id() === params.myPlayer.id(); const menuItems: (MenuElement | null)[] = [ infoMenuElement, diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 0a5b184bf2..83143978cc 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -168,6 +168,11 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } + private onToggleWebgpuDebugOverlayButtonClick() { + this.userSettings.toggleWebgpuDebug(); + this.requestUpdate(); + } + private onExitButtonClick() { // redirect to the home page window.location.href = "/"; @@ -498,6 +503,29 @@ export class SettingsModal extends LitElement implements Layer {
+ +
+
+ +
Renderer
+ +
+
Background
+ +
+ +
+
+
tick ms compute
+
${this.tickComputeMs.toFixed(2)}
+
+
+
render fps
+
${this.renderFps}
+
+
+ +
Terrain
+ +
+
Terrain Shader
+ +
+ + ${terrainShader.options.map((opt) => this.renderOptionControl(opt))} + +
Territory
+ +
+
Territory Shader
+ +
+ + ${shader.options.map((opt) => this.renderOptionControl(opt))} + +
Temporal
+ +
+
Post Compute
+ +
+ + ${pre.options.map((opt) => this.renderOptionControl(opt))} + +
+
Post Render
+ +
+ + ${post.options.map((opt) => this.renderOptionControl(opt))} +
+ `; + } +} diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts new file mode 100644 index 0000000000..25dad2017c --- /dev/null +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -0,0 +1,634 @@ +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 { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass"; +import { DefendedStrengthPass } from "./compute/DefendedStrengthPass"; +import { StateUpdatePass } from "./compute/StateUpdatePass"; +import { TerrainComputePass } from "./compute/TerrainComputePass"; +import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass"; +import { GroundTruthData } from "./core/GroundTruthData"; +import { WebGPUDevice } from "./core/WebGPUDevice"; +import { RenderPass } from "./render/RenderPass"; +import { TemporalResolvePass } from "./render/TemporalResolvePass"; +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; + 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); + + // 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 readonly defensePostRange: number; + + private preSmoothingEnabled = false; + private postSmoothingEnabled = false; + + 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; + this.defensePostRange = game.config().defensePostRange(); + } + + 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, + this.defensePostRange, + state, + ); + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); + this.resources.setTerrainShaderParams( + this.terrainShaderParams0, + this.terrainShaderParams1, + ); + + // 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(); + 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); + } + + this.applyPreSmoothingConfig(); + this.applyPostSmoothingConfig(); + + // 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(); + + 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); + } + + // Worker renderer needs this; on main thread this is currently a no-op beyond + // forcing a palette refresh (PlayerView colors are not recomputed dynamically). + setPatternsEnabled(_enabled: boolean): void { + this.refreshPalette(); + } + + 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); + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + this.device.canvasFormat, + ); + } else { + this.temporalResolvePass.setEnabled(false); + this.resources.releasePostSmoothingTextures(); + } + + this.resources.invalidateHistory(); + } + + markTile(tile: TileRef): void { + if (this.stateUpdatePass) { + this.stateUpdatePass.markTile(tile); + } + } + + 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(); + // 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; + } + + this.resources.updateTickTiming(performance.now() / 1000); + + if (this.game.config().defensePostRange() !== this.defensePostRange) { + throw new Error("defensePostRange changed at runtime; unsupported."); + } + + // Upload palette if needed + this.resources.uploadPalette(); + + // Upload diplomacy relations (used by retro shader / debug modes) + this.resources.uploadRelations(); + + // Upload defense posts if needed (also produces defended dirty tiles on changes) + 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 (clear will run before update if needed) + 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 (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 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()) { + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + 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()]); + } +} diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts new file mode 100644 index 0000000000..cd08b14af7 --- /dev/null +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -0,0 +1,526 @@ +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, + SetPaletteMessage, + SetPatternsEnabledMessage, + SetShaderSettingsMessage, + ViewSize, + ViewTransform, +} 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 failed = false; + private initPromise: Promise | null = null; + private pendingMessages: Array<{ message: any; transferables?: any[] }> = []; + + private viewSize: ViewSize = { width: 1, height: 1 }; + private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + private lastSentViewSize: ViewSize | null = null; + private lastSentViewTransform: ViewTransform | null = null; + private renderInFlight = false; + private renderSeq = 0; + private renderCooldownUntilMs = 0; + + 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().catch((err) => { + this.failed = true; + this.renderInFlight = false; + this.pendingMessages = []; + console.error("Worker territory renderer init failed:", err); + throw err; + }); + } + + 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, + backend: "webgpu", + }; + + // 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); + if (message.ok === false) { + reject( + new Error(message.error ?? "Renderer initialization failed"), + ); + return; + } + + this.ready = true; + // Send any pending messages + for (const pending of this.pendingMessages) { + if (pending.transferables) { + this.worker?.postMessage(pending.message, pending.transferables); + } else { + this.sendToWorker(pending.message); + } + } + this.pendingMessages = []; + resolve(); + } + }; + + this.worker?.addMessageHandler(messageId, handler); + }); + } + + private sendToWorker(message: any): void { + if (!this.worker) { + return; + } + if (this.failed) { + return; + } + if (!this.ready) { + this.pendingMessages.push({ message }); + return; + } + this.worker.postMessage(message); + } + + private sendToWorkerWithTransfer(message: any, transferables: any[]): void { + if (!this.worker) { + return; + } + if (this.failed) { + return; + } + if (!this.ready) { + this.pendingMessages.push({ message, transferables }); + return; + } + this.worker.postMessage(message, transferables); + } + + setViewSize(width: number, height: number): void { + this.viewSize = { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), + }; + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + this.viewTransform = { scale, offsetX, offsetY }; + } + + setAlternativeView(enabled: boolean): void { + const message: SetAlternativeViewMessage = { + type: "set_alternative_view", + enabled, + }; + this.sendToWorker(message); + } + + setPatternsEnabled(enabled: boolean): void { + const message: SetPatternsEnabledMessage = { + type: "set_patterns_enabled", + 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 { + if (!this.worker) { + return; + } + + // Build palette on the main thread to avoid order-dependent color allocator + // divergence between main and worker. + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + + const RESERVED = 10; + const paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + // Fallout slot (index 0) + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tc = player.territoryColor().toRgb(); + row0[idx] = toByte(tc.r); + row0[idx + 1] = toByte(tc.g); + row0[idx + 2] = toByte(tc.b); + row0[idx + 3] = 255; + + const bc = player.borderColor().toRgb(); + row1[idx] = toByte(bc.r); + row1[idx + 1] = toByte(bc.g); + row1[idx + 2] = toByte(bc.b); + row1[idx + 3] = 255; + } + + const message: SetPaletteMessage = { + type: "set_palette", + paletteWidth, + maxSmallId, + row0, + row1, + }; + + // Transfer buffers to avoid copies; arrays are rebuilt when needed. + this.sendToWorkerWithTransfer(message, [row0.buffer, row1.buffer]); + + // Back-compat: also mark palette dirty in worker for older code paths. + const fallback: RefreshPaletteMessage = { type: "refresh_palette" }; + this.sendToWorker(fallback); + } + + markDefensePostsDirty(): void { + this.markAllDirty(); + } + + refreshTerrain(): void { + const message: RefreshTerrainMessage = { + type: "refresh_terrain", + }; + this.sendToWorker(message); + } + + tick(): void { + // No-op: worker renderer ticks from worker-side game_update. + // Sending tick messages from the main thread duplicates GPU work and + // can stall Firefox badly under load. + } + + render(): void { + if (this.failed) { + return; + } + if (performance.now() < this.renderCooldownUntilMs) { + return; + } + if (this.renderInFlight) { + return; + } + + this.renderInFlight = true; + const renderId = `render_${++this.renderSeq}`; + const sentAtWallMs = Date.now(); + + const message: RenderFrameMessage = { type: "render_frame" }; + message.id = renderId; + message.sentAtWallMs = sentAtWallMs; + + if ( + !this.lastSentViewSize || + this.lastSentViewSize.width !== this.viewSize.width || + this.lastSentViewSize.height !== this.viewSize.height + ) { + message.viewSize = this.viewSize; + this.lastSentViewSize = this.viewSize; + } + + if ( + !this.lastSentViewTransform || + this.lastSentViewTransform.scale !== this.viewTransform.scale || + this.lastSentViewTransform.offsetX !== this.viewTransform.offsetX || + this.lastSentViewTransform.offsetY !== this.viewTransform.offsetY + ) { + message.viewTransform = this.viewTransform; + this.lastSentViewTransform = this.viewTransform; + } + + const worker = this.worker; + if (worker) { + const timeout = setTimeout(() => { + if (!this.renderInFlight) { + worker.removeMessageHandler(renderId); + return; + } + + console.warn(`render_done timeout (${renderId})`); + worker.removeMessageHandler(renderId); + + // Recover from lost/blocked frames without flooding the worker. + this.renderInFlight = false; + this.renderCooldownUntilMs = performance.now() + 250; + + // Force a view resync on the next successful render. + this.lastSentViewSize = null; + this.lastSentViewTransform = null; + }, 15000); + + worker.addMessageHandler(renderId, (m: any) => { + if (m?.type !== "render_done") { + return; + } + clearTimeout(timeout); + const startedAt = typeof m.startedAt === "number" ? m.startedAt : NaN; + const endedAt = typeof m.endedAt === "number" ? m.endedAt : NaN; + const startedAtWallMs = + typeof m.startedAtWallMs === "number" ? m.startedAtWallMs : NaN; + const endedAtWallMs = + typeof m.endedAtWallMs === "number" ? m.endedAtWallMs : NaN; + const echoedSentAtWallMs = + typeof m.sentAtWallMs === "number" ? m.sentAtWallMs : sentAtWallMs; + if ( + Number.isFinite(startedAt) && + Number.isFinite(endedAt) && + Number.isFinite(startedAtWallMs) && + Number.isFinite(endedAtWallMs) && + Number.isFinite(echoedSentAtWallMs) + ) { + const queueMs = startedAtWallMs - echoedSentAtWallMs; + const renderMs = endedAt - startedAt; + const totalMs = endedAtWallMs - echoedSentAtWallMs; + const breakdown = + typeof m.renderCpuMs === "number" || + typeof m.renderGpuWaitMs === "number" || + typeof m.renderWaitPrevGpuMs === "number" || + typeof m.renderGetTextureMs === "number" + ? { + waitPrevGpuMs: + typeof m.renderWaitPrevGpuMs === "number" + ? Math.round(m.renderWaitPrevGpuMs) + : undefined, + waitPrevGpuTimedOut: + typeof m.renderWaitPrevGpuTimedOut === "boolean" + ? m.renderWaitPrevGpuTimedOut + : undefined, + cpuMs: + typeof m.renderCpuMs === "number" + ? Math.round(m.renderCpuMs) + : undefined, + getTextureMs: + typeof m.renderGetTextureMs === "number" + ? Math.round(m.renderGetTextureMs) + : undefined, + gpuWaitMs: + typeof m.renderGpuWaitMs === "number" + ? Math.round(m.renderGpuWaitMs) + : undefined, + gpuWaitTimedOut: + typeof m.renderGpuWaitTimedOut === "boolean" + ? m.renderGpuWaitTimedOut + : undefined, + } + : undefined; + if (totalMs > 1000 || queueMs > 1000 || renderMs > 1000) { + console.warn("worker render timing", { + id: renderId, + queueMs: Math.round(queueMs), + renderMs: Math.round(renderMs), + totalMs: Math.round(totalMs), + breakdown, + }); + } + } + this.renderInFlight = false; + }); + } else { + this.renderInFlight = false; + return; + } + + this.sendToWorker(message); + } +} diff --git a/src/client/graphics/webgpu/compute/ComputePass.ts b/src/client/graphics/webgpu/compute/ComputePass.ts new file mode 100644 index 0000000000..0be77e64e7 --- /dev/null +++ b/src/client/graphics/webgpu/compute/ComputePass.ts @@ -0,0 +1,37 @@ +import { GroundTruthData } from "../core/GroundTruthData"; + +/** + * Base interface for compute passes. + * Compute passes run during tick() (simulation rate) to update ground truth data. + */ +export interface ComputePass { + /** Unique name of this pass (used for dependency resolution) */ + name: string; + + /** Names of passes that must run before this one */ + dependencies: string[]; + + /** + * Initialize the pass with device and resources. + * Called once during renderer initialization. + */ + init(device: GPUDevice, resources: GroundTruthData): Promise; + + /** + * Check if this pass needs to run this tick. + * Performance optimization: return false to skip execution. + */ + needsUpdate(): boolean; + + /** + * Execute the compute pass. + * @param encoder Command encoder for recording GPU commands + * @param resources Ground truth data (read/write access) + */ + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void; + + /** + * Clean up resources when the pass is no longer needed. + */ + dispose(): void; +} diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts new file mode 100644 index 0000000000..3d747803bf --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts @@ -0,0 +1,159 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Full defended strength recompute across the entire map. + * Used on initial upload or when post diffs are too large for a tile list. + */ +export class DefendedStrengthFullPass implements ComputePass { + name = "defended-strength-full"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength-full.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return this.resources?.needsDefendedFullRecompute() ?? false; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + if (!resources.needsDefendedFullRecompute()) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(0); + + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + if ( + !this.bindGroup || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer + ) { + this.rebuildBindGroup(); + } + if (!this.bindGroup) { + return; + } + + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + pass.end(); + + resources.clearDefendedFullRecompute(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 3, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 4, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts new file mode 100644 index 0000000000..ae0034ce61 --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts @@ -0,0 +1,172 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Recomputes defended strength for a list of dirty tiles. + * Dirty tiles are produced when defense posts are added/removed/moved. + */ +export class DefendedStrengthPass implements ComputePass { + name = "defended-strength"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundDirtyTilesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const dirtyCount = resources.getDefendedDirtyTilesCount(); + if (dirtyCount === 0) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(dirtyCount); + + const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer; + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundDirtyTilesBuffer !== dirtyTilesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(dirtyCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + resources.clearDefendedDirtyTiles(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.defendedDirtyTilesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: { buffer: this.resources.defendedDirtyTilesBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts new file mode 100644 index 0000000000..f874305e20 --- /dev/null +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -0,0 +1,197 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that scatters tile state updates into the state texture. + */ +export class StateUpdatePass implements ComputePass { + name = "state-update"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private readonly pendingTiles: Set = new Set(); + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/state-update.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + needsUpdate(): boolean { + return this.pendingTiles.size > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const numUpdates = this.pendingTiles.size; + if (numUpdates === 0) { + return; + } + + resources.setLastStateUpdateCount(numUpdates); + + const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); + resources.writeStateUpdateParamsBuffer(numUpdates); + + const staging = resources.getUpdatesStaging(); + const state = resources.getState(); + + // Prepare staging data + let idx = 0; + for (const tile of this.pendingTiles) { + const stateValue = state[tile]; + staging[idx * 2] = tile; + staging[idx * 2 + 1] = stateValue; + idx++; + } + + // Upload to GPU + this.device.queue.writeBuffer( + updatesBuffer, + 0, + staging.subarray(0, numUpdates * 2), + ); + + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(numUpdates / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + this.pendingTiles.clear(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.stateUpdateParamsBuffer || + !this.resources.updatesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.stateUpdateParamsBuffer }, + }, + { binding: 1, resource: { buffer: this.resources.updatesBuffer } }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + markTile(tile: number): void { + this.pendingTiles.add(tile); + } + + dispose(): void { + // Resources are managed by GroundTruthData + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/TerrainComputePass.ts b/src/client/graphics/webgpu/compute/TerrainComputePass.ts new file mode 100644 index 0000000000..45f2a6e8f4 --- /dev/null +++ b/src/client/graphics/webgpu/compute/TerrainComputePass.ts @@ -0,0 +1,145 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that generates terrain colors from terrain data. + * Runs once at initialization or when theme changes. + */ +export class TerrainComputePass implements ComputePass { + name = "terrain-compute"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private needsCompute = true; + private shaderPath = "compute/terrain-compute.wgsl"; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + this.ensureBindGroupLayout(); + await this.setShader(this.shaderPath); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + if (!this.device || !this.bindGroupLayout) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.needsCompute = true; + } + + needsUpdate(): boolean { + return this.needsCompute; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.bindGroup) { + return; + } + + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + pass.end(); + + this.needsCompute = false; + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.terrainParamsBuffer || + !this.resources.terrainDataTexture || + !this.resources.terrainTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.terrainParamsBuffer }, + }, + { + binding: 1, + resource: this.resources.terrainDataTexture.createView(), + }, + { + binding: 2, + resource: this.resources.terrainTexture.createView(), + }, + ], + }); + } + + private ensureBindGroupLayout(): void { + if (!this.device || this.bindGroupLayout) { + return; + } + + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + ], + }); + } + + markDirty(): void { + this.needsCompute = true; + // Rebuild bind group in case terrain params buffer was recreated + this.rebuildBindGroup(); + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts new file mode 100644 index 0000000000..488c3c0787 --- /dev/null +++ b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts @@ -0,0 +1,203 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Per-frame compute pass that updates the visual state texture. + * Supports dissolve and budgeted reveal modes. + */ +export class VisualStateSmoothingPass implements ComputePass { + name = "visual-state-smoothing"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(8); + private enabled = false; + private shaderPath = "compute/visual-state-smoothing.wgsl"; + private mode = 0; + private curveExp = 1; + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundVisualStateTexture: GPUTexture | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.paramsBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST, + }); + + await this.setShader(this.shaderPath); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + if (!this.device) { + return; + } + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + this.pipeline = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + setParams(params0: Float32Array | number[]): void { + this.mode = Number(params0[0] ?? 0); + this.curveExp = Number(params0[1] ?? 1); + this.enabled = this.mode > 0; + } + + needsUpdate(): boolean { + if (!this.enabled || !this.resources) { + return false; + } + return this.resources.getLastStateUpdateCount() > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + + const updateCount = resources.getLastStateUpdateCount(); + if (updateCount <= 0) { + return; + } + + const updatesBuffer = resources.updatesBuffer; + const visualStateTexture = resources.getVisualStateTexture(); + if (!updatesBuffer || !visualStateTexture) { + return; + } + + this.paramsData[0] = this.mode; + this.paramsData[1] = this.curveExp; + this.paramsData[2] = 0; + this.paramsData[3] = 0; + this.paramsData[4] = updateCount; + this.paramsData[5] = 0; + this.paramsData[6] = 0; + this.paramsData[7] = 0; + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundVisualStateTexture !== visualStateTexture; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(updateCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer || + !this.resources.updatesBuffer || + !this.resources.getVisualStateTexture() + ) { + this.bindGroup = null; + return; + } + + const visualStateTexture = this.resources.getVisualStateTexture(); + if (!visualStateTexture) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: { buffer: this.resources.updatesBuffer }, + }, + { + binding: 3, + resource: visualStateTexture.createView(), + }, + ], + }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundVisualStateTexture = visualStateTexture; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts new file mode 100644 index 0000000000..e5f9eafb0c --- /dev/null +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -0,0 +1,1445 @@ +import { Theme } from "../../../../core/configuration/Config"; +import { UnitType } from "../../../../core/game/Game"; +import { GameView } from "../../../../core/game/GameView"; + +/** + * Alignment helper for texture uploads. + */ +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +/** + * Manages authoritative GPU textures and buffers (ground truth data). + * All compute and render passes read from this data. + */ +export class GroundTruthData { + public static readonly PALETTE_RESERVED_SLOTS = 10; + public static readonly PALETTE_FALLOUT_INDEX = 0; + private static readonly MAX_OWNER_SLOTS = 0x1000; // ownerId is 12 bits + + // Textures + public readonly stateTexture: GPUTexture; + public readonly terrainTexture: GPUTexture; + public readonly terrainDataTexture: GPUTexture; + public readonly paletteTexture: GPUTexture; + public readonly ownerIndexTexture: GPUTexture; + public readonly relationsTexture: GPUTexture; + public readonly defendedStrengthTexture: GPUTexture; + public visualStateTexture: GPUTexture | null = null; + public currentColorTexture: GPUTexture | null = null; + public historyColorTextures: [GPUTexture, GPUTexture] | null = null; + + // Buffers + public readonly uniformBuffer: GPUBuffer; + public readonly temporalUniformBuffer: GPUBuffer; + public readonly terrainParamsBuffer: GPUBuffer; + public readonly stateUpdateParamsBuffer: GPUBuffer; + public readonly defendedStrengthParamsBuffer: GPUBuffer; + public updatesBuffer: GPUBuffer | null = null; + public readonly defenseOwnerOffsetsBuffer: GPUBuffer; + public defensePostsByOwnerBuffer: GPUBuffer; + public defendedDirtyTilesBuffer: GPUBuffer; + + // Staging arrays for buffer uploads + private updatesStaging: Uint32Array | null = null; + private defenseOwnerOffsetsStaging: Uint32Array; + private defensePostsByOwnerStaging: Uint32Array | null = null; + private defendedDirtyTilesStaging: Uint32Array | null = null; + + // Buffer capacities + private updatesCapacity = 0; + private defensePostsByOwnerCapacity = 0; + private defendedDirtyTilesCapacity = 0; + + // State tracking + private readonly mapWidth: number; + private readonly mapHeight: number; + private readonly state: Uint16Array; + private readonly terrainData: Uint8Array; + private needsStateUpload = true; + private stateUploadScratch: Uint32Array | null = null; + private stateUploadScratchStrideU32 = 0; + private needsPaletteUpload = true; + private needsTerrainDataUpload = true; + private terrainDataUploadScratch: Uint8Array | null = null; + private terrainDataUploadScratchBytesPerRow = 0; + private needsTerrainParamsUpload = true; + private useVisualStateTexture = false; + private visualStateNeedsSync = false; + private lastStateUpdateCount = 0; + private historyIndex = 0; + private historyValid = false; + private postSmoothingWidth = 0; + private postSmoothingHeight = 0; + private postSmoothingFormat: GPUTextureFormat | null = null; + private lastTickSec = 0; + private tickDtSec = 0.1; + private tickDtEmaSec = 0.1; + private tickCount = 0; + private readonly tickEmaAlpha = 0.2; + private paletteWidth = 1; + private paletteOverride: { + paletteWidth: number; + maxSmallId: number; + row0: Uint8Array; + row1: Uint8Array; + } | null = null; + private needsDefensePostsUpload = true; + private defensePostsTotalCount = 0; + private defendedDirtyTilesCount = 0; + private needsFullDefendedStrengthRecompute = false; + private lastDefensePostKeys = new Set(); + private defensePostRange = 0; + private defenseCircleRange = -1; + private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] + + // Uniform data arrays + private readonly uniformData = new Float32Array(20); + private readonly temporalData = new Float32Array(8); + private readonly terrainParamsData = new Float32Array(32); // 8 vec4f: base colors + tuning + private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad + private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad + + // View state (updated by renderer) + private viewWidth = 1; + private viewHeight = 1; + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + private alternativeView = false; + private highlightedOwnerId = -1; + + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); + private terrainShaderParams0 = new Float32Array([0.0, 2.5, 1.0, 0.0]); + private terrainShaderParams1 = new Float32Array([0.6, 0.0, 0.0, 0.0]); + + private paletteMaxSmallId = 0; + private ownerIndexWidth = 1; + private relationsSize = 1; + private needsRelationsUpload = true; + private relationsDenseBySmallId: Uint32Array | null = null; + private pendingRelationsPairs: Set = new Set(); + private readonly relationWriteScratch = new Uint8Array(256); + + private constructor( + private readonly device: GPUDevice, + private readonly game: GameView, + private readonly theme: Theme, + defensePostRange: number, + state: Uint16Array, + terrainData: Uint8Array, + mapWidth: number, + mapHeight: number, + ) { + this.state = state; + this.terrainData = terrainData; + this.mapWidth = mapWidth; + this.mapHeight = mapHeight; + this.defensePostRange = Math.max(0, defensePostRange | 0); + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const COPY_SRC_TEX = GPUTextureUsage?.COPY_SRC ?? 0x1; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + + // Render uniforms: 5x vec4f = 80 bytes + this.uniformBuffer = device.createBuffer({ + size: 80, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Temporal uniforms: 2x vec4f = 32 bytes + this.temporalUniformBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST_BUF, + }); + + // State update params: 4x u32 = 16 bytes + this.stateUpdateParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Defended strength params: 4x u32 = 16 bytes + this.defendedStrengthParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Terrain params: 8x vec4f = 128 bytes (base colors + tuning) + this.terrainParamsBuffer = device.createBuffer({ + size: 128, + usage: UNIFORM | COPY_DST_BUF, + }); + + // State texture (r32uint) + this.stateTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | COPY_SRC_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Defended strength texture (rgba8unorm, r channel used) + this.defendedStrengthTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "rgba8unorm", + usage: TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Palette texture (rgba8unorm): row 0 territory colors, row 1 border colors + this.paletteTexture = device.createTexture({ + size: { width: 1, height: 2 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // SmallID -> dense index lookup texture (r32uint) + this.ownerIndexTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Dense relation matrix texture (r8uint) + this.relationsTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Terrain texture (rgba8unorm) - output of terrain compute shader + this.terrainTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Terrain data texture (r8uint) - input terrain data (read-only in compute shader) + this.terrainDataTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + + // Defense posts data: ownerOffsets[ownerId] = {start, count}, postsByOwner[start..] = {x,y} + this.defenseOwnerOffsetsBuffer = device.createBuffer({ + size: GroundTruthData.MAX_OWNER_SLOTS * 8, + usage: STORAGE | COPY_DST_BUF, + }); + this.defenseOwnerOffsetsStaging = new Uint32Array( + GroundTruthData.MAX_OWNER_SLOTS * 2, + ); + + this.defensePostsByOwnerBuffer = device.createBuffer({ + size: 8, + usage: STORAGE | COPY_DST_BUF, + }); + + // Dirty tile indices to recompute defended strength when posts change + this.defendedDirtyTilesBuffer = device.createBuffer({ + size: 4 * 8, + usage: STORAGE | COPY_DST_BUF, + }); + } + + static create( + device: GPUDevice, + game: GameView, + theme: Theme, + defensePostRange: number, + state: Uint16Array, + ): GroundTruthData { + return new GroundTruthData( + device, + game, + theme, + defensePostRange, + state, + game.terrainDataView(), + game.width(), + game.height(), + ); + } + + // ===================== + // View state setters + // ===================== + + setViewSize(width: number, height: number): void { + this.viewWidth = Math.max(1, Math.floor(width)); + this.viewHeight = Math.max(1, Math.floor(height)); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + const eps = 1e-4; + const changed = + Math.abs(scale - this.viewScale) > eps || + Math.abs(offsetX - this.viewOffsetX) > eps || + Math.abs(offsetY - this.viewOffsetY) > eps; + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + if (changed) { + this.invalidateHistory(); + } + } + + setAlternativeView(enabled: boolean): void { + if (this.alternativeView !== enabled) { + this.alternativeView = enabled; + this.invalidateHistory(); + } + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + this.highlightedOwnerId = ownerSmallId ?? -1; + } + + 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); + } + } + + 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); + } + this.needsTerrainParamsUpload = true; + } + + setUseVisualStateTexture(enabled: boolean): void { + this.useVisualStateTexture = enabled; + if (enabled) { + this.visualStateNeedsSync = true; + } + } + + consumeVisualStateSyncNeeded(): boolean { + if (!this.visualStateNeedsSync) { + return false; + } + this.visualStateNeedsSync = false; + return true; + } + + ensureVisualStateTexture(): void { + if (this.visualStateTexture) { + return; + } + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + this.visualStateTexture = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + } + + releaseVisualStateTexture(): void { + if (this.visualStateTexture) { + (this.visualStateTexture as any).destroy?.(); + this.visualStateTexture = null; + } + } + + getVisualStateTexture(): GPUTexture | null { + return this.visualStateTexture; + } + + getRenderStateTexture(): GPUTexture { + if (this.useVisualStateTexture && this.visualStateTexture) { + return this.visualStateTexture; + } + return this.stateTexture; + } + + setLastStateUpdateCount(count: number): void { + this.lastStateUpdateCount = Math.max(0, Math.floor(count)); + } + + getLastStateUpdateCount(): number { + return this.lastStateUpdateCount; + } + + updateTickTiming(nowSec: number): void { + if (this.lastTickSec > 0) { + const dt = Math.max(1e-3, nowSec - this.lastTickSec); + this.tickDtSec = dt; + this.tickDtEmaSec = + this.tickDtEmaSec * (1 - this.tickEmaAlpha) + dt * this.tickEmaAlpha; + } + this.lastTickSec = nowSec; + this.tickCount += 1; + } + + writeTemporalUniformBuffer(nowSec: number): void { + const denom = Math.max(1e-3, this.tickDtEmaSec); + const alpha = Math.max(0, Math.min(1, (nowSec - this.lastTickSec) / denom)); + + this.temporalData[0] = nowSec; + this.temporalData[1] = this.lastTickSec; + this.temporalData[2] = this.tickDtSec; + this.temporalData[3] = this.tickDtEmaSec; + this.temporalData[4] = alpha; + this.temporalData[5] = this.tickCount; + this.temporalData[6] = this.historyValid ? 1 : 0; + this.temporalData[7] = 0; + + this.device.queue.writeBuffer( + this.temporalUniformBuffer, + 0, + this.temporalData, + ); + } + + invalidateHistory(): void { + this.historyValid = false; + } + + markHistoryValid(): void { + this.historyValid = true; + } + + swapHistoryTextures(): void { + if (!this.historyColorTextures) { + return; + } + this.historyIndex = this.historyIndex === 0 ? 1 : 0; + } + + ensurePostSmoothingTextures( + width: number, + height: number, + format: GPUTextureFormat, + ): void { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const needsRebuild = + !this.currentColorTexture || + !this.historyColorTextures || + this.postSmoothingWidth !== w || + this.postSmoothingHeight !== h || + this.postSmoothingFormat !== format; + + if (!needsRebuild) { + return; + } + + this.releasePostSmoothingTextures(); + + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const RENDER_ATTACHMENT = GPUTextureUsage?.RENDER_ATTACHMENT ?? 0x10; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + + this.currentColorTexture = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyA = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyB = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + + this.historyColorTextures = [historyA, historyB]; + this.historyIndex = 0; + this.historyValid = false; + this.postSmoothingWidth = w; + this.postSmoothingHeight = h; + this.postSmoothingFormat = format; + } + + releasePostSmoothingTextures(): void { + if (this.currentColorTexture) { + (this.currentColorTexture as any).destroy?.(); + this.currentColorTexture = null; + } + if (this.historyColorTextures) { + (this.historyColorTextures[0] as any).destroy?.(); + (this.historyColorTextures[1] as any).destroy?.(); + this.historyColorTextures = null; + } + this.historyValid = false; + this.postSmoothingWidth = 0; + this.postSmoothingHeight = 0; + this.postSmoothingFormat = null; + } + + getCurrentColorTexture(): GPUTexture | null { + return this.currentColorTexture; + } + + getHistoryReadTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex]; + } + + getHistoryWriteTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex === 0 ? 1 : 0]; + } + + // ===================== + // Upload methods + // ===================== + + uploadState(): void { + if (!this.needsStateUpload) { + return; + } + this.needsStateUpload = false; + + const bytesPerTexel = Uint32Array.BYTES_PER_ELEMENT; + const fullBytesPerRow = this.mapWidth * bytesPerTexel; + const bytesPerRow = align(fullBytesPerRow, 256); + const strideU32 = bytesPerRow / 4; + const required = strideU32 * this.mapHeight; + + if ( + !this.stateUploadScratch || + this.stateUploadScratchStrideU32 !== strideU32 || + this.stateUploadScratch.length < required + ) { + this.stateUploadScratch = new Uint32Array(required); + this.stateUploadScratchStrideU32 = strideU32; + } + + const dst = this.stateUploadScratch; + const src = this.state; + const w = this.mapWidth; + for (let y = 0; y < this.mapHeight; y++) { + const srcBase = y * w; + const dstBase = y * strideU32; + for (let x = 0; x < w; x++) { + dst[dstBase + x] = src[srcBase + x]; + } + } + + this.device.queue.writeTexture( + { texture: this.stateTexture }, + dst, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } + + /** + * @deprecated Use terrain compute shader instead. This method is kept for fallback. + */ + uploadTerrain(): void { + const bytesPerRow = this.mapWidth * 4; + const paddedBytesPerRow = align(bytesPerRow, 256); + const row = new Uint8Array(paddedBytesPerRow); + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (let y = 0; y < this.mapHeight; y++) { + row.fill(0); + for (let x = 0; x < this.mapWidth; x++) { + const tile = y * this.mapWidth + x; + const rgba = this.theme.terrainColor(this.game, tile).rgba; + const idx = x * 4; + row[idx] = toByte(rgba.r); + row[idx + 1] = toByte(rgba.g); + row[idx + 2] = toByte(rgba.b); + row[idx + 3] = 255; + } + + this.device.queue.writeTexture( + { texture: this.terrainTexture, origin: { x: 0, y } }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + + uploadTerrainData(): void { + if (!this.needsTerrainDataUpload) { + return; + } + this.needsTerrainDataUpload = false; + + const bytesPerRow = align(this.mapWidth, 256); + const required = bytesPerRow * this.mapHeight; + if ( + !this.terrainDataUploadScratch || + this.terrainDataUploadScratchBytesPerRow !== bytesPerRow || + this.terrainDataUploadScratch.length < required + ) { + this.terrainDataUploadScratch = new Uint8Array(required); + this.terrainDataUploadScratchBytesPerRow = bytesPerRow; + } + + const dst = this.terrainDataUploadScratch; + const src = this.terrainData; + const w = this.mapWidth; + for (let y = 0; y < this.mapHeight; y++) { + const srcStart = y * w; + const dstStart = y * bytesPerRow; + dst.set(src.subarray(srcStart, srcStart + w), dstStart); + } + + this.device.queue.writeTexture( + { texture: this.terrainDataTexture }, + dst, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } + + uploadTerrainParams(): void { + if (!this.needsTerrainParamsUpload) { + return; + } + this.needsTerrainParamsUpload = false; + + // Extract theme colors directly from theme object (much faster than sampling tiles) + const themeAny = this.theme as any; + const isDark = themeAny.darkShore !== undefined; + + // Get shore color + const shore = isDark ? themeAny.darkShore : themeAny.shore; + const shoreColor = shore?.rgba ?? { r: 204, g: 203, b: 158, a: 255 }; + + // Get water colors + const water = isDark ? themeAny.darkWater : themeAny.water; + const waterColor = water?.rgba ?? { r: 70, g: 132, b: 180, a: 255 }; + + const shorelineWater = isDark + ? themeAny.darkShorelineWater + : themeAny.shorelineWater; + const shorelineWaterColor = shorelineWater?.rgba ?? { + r: 100, + g: 143, + b: 255, + a: 255, + }; + + // Compute terrain base colors from formulas (no tile sampling needed) + // Plains at mag 0: rgb(190, 220, 138) for pastel, rgb(140, 170, 88) for dark + const plainsColor = isDark + ? { r: 140, g: 170, b: 88, a: 255 } + : { r: 190, g: 220, b: 138, a: 255 }; + + // Highland at mag 10: rgb(220, 203, 158) for pastel, rgb(170, 153, 108) for dark + const highlandColor = isDark + ? { r: 170, g: 153, b: 108, a: 255 } + : { r: 220, g: 203, b: 158, a: 255 }; + + // Mountain at mag 20: rgb(240, 240, 240) for pastel, rgb(190, 190, 190) for dark + const mountainColor = isDark + ? { r: 190, g: 190, b: 190, a: 255 } + : { r: 240, g: 240, b: 240, a: 255 }; + + // Store colors as vec4f (RGBA normalized to 0-1) + // Index 0-3: shore color + this.terrainParamsData[0] = shoreColor.r / 255; + this.terrainParamsData[1] = shoreColor.g / 255; + this.terrainParamsData[2] = shoreColor.b / 255; + this.terrainParamsData[3] = 1.0; + + // Index 4-7: water base color + this.terrainParamsData[4] = waterColor.r / 255; + this.terrainParamsData[5] = waterColor.g / 255; + this.terrainParamsData[6] = waterColor.b / 255; + this.terrainParamsData[7] = 1.0; + + // Index 8-11: shoreline water color + this.terrainParamsData[8] = shorelineWaterColor.r / 255; + this.terrainParamsData[9] = shorelineWaterColor.g / 255; + this.terrainParamsData[10] = shorelineWaterColor.b / 255; + this.terrainParamsData[11] = 1.0; + + // Index 12-15: plains base color (magnitude 0) + this.terrainParamsData[12] = plainsColor.r / 255; + this.terrainParamsData[13] = plainsColor.g / 255; + this.terrainParamsData[14] = plainsColor.b / 255; + this.terrainParamsData[15] = 1.0; + + // Index 16-19: highland base color (magnitude 10) + this.terrainParamsData[16] = highlandColor.r / 255; + this.terrainParamsData[17] = highlandColor.g / 255; + this.terrainParamsData[18] = highlandColor.b / 255; + this.terrainParamsData[19] = 1.0; + + // Index 20-23: mountain base color (magnitude 20) + this.terrainParamsData[20] = mountainColor.r / 255; + this.terrainParamsData[21] = mountainColor.g / 255; + this.terrainParamsData[22] = mountainColor.b / 255; + this.terrainParamsData[23] = 1.0; + + // Index 24-31: tuning params (shader-dependent) + this.terrainParamsData[24] = this.terrainShaderParams0[0]; + this.terrainParamsData[25] = this.terrainShaderParams0[1]; + this.terrainParamsData[26] = this.terrainShaderParams0[2]; + this.terrainParamsData[27] = this.terrainShaderParams0[3]; + this.terrainParamsData[28] = this.terrainShaderParams1[0]; + this.terrainParamsData[29] = this.terrainShaderParams1[1]; + this.terrainParamsData[30] = this.terrainShaderParams1[2]; + this.terrainParamsData[31] = this.terrainShaderParams1[3]; + + this.device.queue.writeBuffer( + this.terrainParamsBuffer, + 0, + this.terrainParamsData, + ); + } + + markTerrainParamsDirty(): void { + this.needsTerrainParamsUpload = true; + } + + uploadPalette(): boolean { + if (!this.needsPaletteUpload) { + return false; + } + this.needsPaletteUpload = false; + + const prevMaxSmallId = this.paletteMaxSmallId; + + let maxSmallId = 0; + let nextPaletteWidth = 0; + let row0: Uint8Array | null = null; + let row1: Uint8Array | null = null; + + if (this.paletteOverride) { + maxSmallId = this.paletteOverride.maxSmallId; + nextPaletteWidth = this.paletteOverride.paletteWidth; + + const expectedRowStride = nextPaletteWidth * 4; + if ( + this.paletteOverride.row0.length === expectedRowStride && + this.paletteOverride.row1.length === expectedRowStride + ) { + row0 = this.paletteOverride.row0; + row1 = this.paletteOverride.row1; + } else { + // Malformed; fall back to local generation. + this.paletteOverride = null; + } + } + + if (!row0 || !row1) { + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + nextPaletteWidth = + GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); + + const rowStride = nextPaletteWidth * 4; + row0 = new Uint8Array(rowStride); + row1 = new Uint8Array(rowStride); + + // Store special colors in reserved slots (0-9) + const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4; + row0[falloutIdx] = 120; + row0[falloutIdx + 1] = 255; + row0[falloutIdx + 2] = 71; + row0[falloutIdx + 3] = 255; + + // Store player colors starting at index 10 + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const rgba = player.territoryColor().rgba; + const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4; + row0[idx] = rgba.r; + row0[idx + 1] = rgba.g; + row0[idx + 2] = rgba.b; + row0[idx + 3] = 255; + + const borderRgba = player.borderColor().rgba; + row1[idx] = borderRgba.r; + row1[idx + 1] = borderRgba.g; + row1[idx + 2] = borderRgba.b; + row1[idx + 3] = 255; + } + } + + this.paletteMaxSmallId = maxSmallId; + if (this.paletteMaxSmallId !== prevMaxSmallId) { + // Relations/owner-index textures depend on maxSmallId. + this.needsRelationsUpload = true; + } + + let textureRecreated = false; + if (nextPaletteWidth !== this.paletteWidth) { + this.paletteWidth = nextPaletteWidth; + (this.paletteTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).paletteTexture = this.device.createTexture({ + size: { width: this.paletteWidth, height: 2 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const rowStride = this.paletteWidth * 4; + if (row0.length !== rowStride || row1.length !== rowStride) { + throw new Error( + `Palette row size mismatch: expected ${rowStride} bytes, got ${row0.length}/${row1.length}`, + ); + } + + const bytesPerRow = align(rowStride, 256); + const padded = new Uint8Array(bytesPerRow * 2); + padded.set(row0, 0); + padded.set(row1, bytesPerRow); + + this.device.queue.writeTexture( + { texture: this.paletteTexture }, + padded, + { bytesPerRow, rowsPerImage: 2 }, + { width: this.paletteWidth, height: 2, depthOrArrayLayers: 1 }, + ); + + return textureRecreated; + } + + uploadRelations(): boolean { + if (!this.needsRelationsUpload && this.pendingRelationsPairs.size > 0) { + return this.uploadRelationsPartial(); + } + if (!this.needsRelationsUpload) { + return false; + } + + this.needsRelationsUpload = false; + this.pendingRelationsPairs.clear(); + + const players = this.game + .playerViews() + .filter((p) => p.smallID() > 0) + .slice() + .sort((a, b) => a.smallID() - b.smallID()); + + const maxSmallId = this.paletteMaxSmallId; + const nextOwnerIndexWidth = Math.max(1, maxSmallId + 1); + + let textureRecreated = false; + + if (nextOwnerIndexWidth !== this.ownerIndexWidth) { + this.ownerIndexWidth = nextOwnerIndexWidth; + (this.ownerIndexTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).ownerIndexTexture = this.device.createTexture({ + size: { width: this.ownerIndexWidth, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const denseBySmallId = new Uint32Array(this.ownerIndexWidth); + let dense = 0; + for (const p of players) { + const id = p.smallID(); + if (id <= 0 || id >= this.ownerIndexWidth) continue; + dense++; + denseBySmallId[id] = dense; + } + this.relationsDenseBySmallId = denseBySmallId; + + const ownerIndexBytesPerRow = align(this.ownerIndexWidth * 4, 256); + const ownerIndexPaddedU32 = new Uint32Array(ownerIndexBytesPerRow / 4); + ownerIndexPaddedU32.set(denseBySmallId); + this.device.queue.writeTexture( + { texture: this.ownerIndexTexture }, + ownerIndexPaddedU32, + { bytesPerRow: ownerIndexBytesPerRow, rowsPerImage: 1 }, + { width: this.ownerIndexWidth, height: 1, depthOrArrayLayers: 1 }, + ); + + const nextRelationsSize = Math.max(1, dense + 1); + if (nextRelationsSize !== this.relationsSize) { + this.relationsSize = nextRelationsSize; + (this.relationsTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).relationsTexture = this.device.createTexture({ + size: { width: this.relationsSize, height: this.relationsSize }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const relBytesPerRow = align(this.relationsSize, 256); + const relPadded = new Uint8Array(relBytesPerRow * this.relationsSize); + + // 0 = neutral, 1 = friendly, 2 = embargo + for (let i = 0; i < players.length; i++) { + for (let j = i + 1; j < players.length; j++) { + const a = players[i]; + const b = players[j]; + const aDense = denseBySmallId[a.smallID()]; + const bDense = denseBySmallId[b.smallID()]; + if (aDense === 0 || bDense === 0) continue; + + let code = 0; + if (a.hasEmbargo(b)) { + code = 2; + } else if (a.isFriendly(b) || b.isFriendly(a)) { + code = 1; + } + + relPadded[aDense + bDense * relBytesPerRow] = code; + relPadded[bDense + aDense * relBytesPerRow] = code; + } + } + + this.device.queue.writeTexture( + { texture: this.relationsTexture }, + relPadded, + { bytesPerRow: relBytesPerRow, rowsPerImage: this.relationsSize }, + { + width: this.relationsSize, + height: this.relationsSize, + depthOrArrayLayers: 1, + }, + ); + + return textureRecreated; + } + + private uploadRelationsPartial(): boolean { + if (!this.relationsDenseBySmallId || !this.relationsTexture) { + // No stable mapping/texture yet: fall back to a full rebuild. + this.needsRelationsUpload = true; + this.pendingRelationsPairs.clear(); + return false; + } + + const denseBySmallId = this.relationsDenseBySmallId; + const size = this.relationsSize; + const scratch = this.relationWriteScratch; + const bytesPerRow = 256; + + const writeTexel = (x: number, y: number, value: number) => { + if (x <= 0 || y <= 0 || x >= size || y >= size) { + return; + } + scratch.fill(0); + scratch[0] = value & 0xff; + this.device.queue.writeTexture( + { texture: this.relationsTexture, origin: { x, y } }, + scratch, + { bytesPerRow, rowsPerImage: 1 }, + { width: 1, height: 1, depthOrArrayLayers: 1 }, + ); + }; + + const computeCode = (aSmall: number, bSmall: number): number => { + if (aSmall === bSmall) return 0; + const aAny: any = (this.game as any).playerBySmallID?.(aSmall); + const bAny: any = (this.game as any).playerBySmallID?.(bSmall); + if (!aAny || !bAny || !aAny.isPlayer?.() || !bAny.isPlayer?.()) { + return 0; + } + if (aAny.hasEmbargo?.(bAny)) { + return 2; + } + if (aAny.isFriendly?.(bAny) || bAny.isFriendly?.(aAny)) { + return 1; + } + return 0; + }; + + for (const key of this.pendingRelationsPairs) { + const aSmall = Number(key >> 32n); + const bSmall = Number(key & 0xffffffffn); + const aDense = denseBySmallId[aSmall] ?? 0; + const bDense = denseBySmallId[bSmall] ?? 0; + if (aDense === 0 || bDense === 0) { + continue; + } + + const code = computeCode(aSmall, bSmall); + writeTexel(aDense, bDense, code); + if (aDense !== bDense) { + writeTexel(bDense, aDense, code); + } + } + + this.pendingRelationsPairs.clear(); + return false; + } + + uploadDefensePosts(): void { + if (!this.needsDefensePostsUpload) { + return; + } + this.needsDefensePostsUpload = false; + + const range = this.defensePostRange; + const posts = this.collectDefensePosts(); + this.defensePostsTotalCount = posts.length; + + // Diff posts to produce dirty tiles for recompute (include removed + added). + const nextKeys = new Set(); + for (const p of posts) { + nextKeys.add(`${p.ownerId},${p.x},${p.y}`); + } + + const changedPosts: Array<{ x: number; y: number }> = []; + for (const key of this.lastDefensePostKeys) { + if (!nextKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + for (const key of nextKeys) { + if (!this.lastDefensePostKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + this.lastDefensePostKeys = nextKeys; + + // Pack posts by owner into GPU buffers. + this.packDefensePostsByOwner(posts); + + // Build dirty tiles around changed posts (so removals clear too). + this.buildDefendedDirtyTiles(changedPosts, range); + } + + getDefensePostsTotalCount(): number { + return this.defensePostsTotalCount; + } + + getDefendedDirtyTilesCount(): number { + return this.defendedDirtyTilesCount; + } + + needsDefendedFullRecompute(): boolean { + return this.needsFullDefendedStrengthRecompute; + } + + clearDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = false; + } + + clearDefendedDirtyTiles(): void { + this.defendedDirtyTilesCount = 0; + } + + writeStateUpdateParamsBuffer(updateCount: number): void { + this.stateUpdateParamsData[0] = updateCount >>> 0; + this.stateUpdateParamsData[1] = this.defensePostRange >>> 0; + this.stateUpdateParamsData[2] = 0; + this.stateUpdateParamsData[3] = 0; + this.device.queue.writeBuffer( + this.stateUpdateParamsBuffer, + 0, + this.stateUpdateParamsData, + ); + } + + writeDefendedStrengthParamsBuffer(dirtyCount: number): void { + this.defendedStrengthParamsData[0] = dirtyCount >>> 0; + this.defendedStrengthParamsData[1] = this.defensePostRange >>> 0; + this.defendedStrengthParamsData[2] = 0; + this.defendedStrengthParamsData[3] = 0; + this.device.queue.writeBuffer( + this.defendedStrengthParamsBuffer, + 0, + this.defendedStrengthParamsData, + ); + } + + private collectDefensePosts(): Array<{ + x: number; + y: number; + ownerId: number; + }> { + const posts: Array<{ x: number; y: number; ownerId: number }> = []; + const units = this.game.units(UnitType.DefensePost) as any[]; + for (const u of units) { + if (!u.isActive() || u.isUnderConstruction()) { + continue; + } + const tile = u.tile(); + posts.push({ + x: this.game.x(tile), + y: this.game.y(tile), + ownerId: u.owner().smallID(), + }); + } + return posts; + } + + private ensureDefensePostsByOwnerBuffer(capacityPosts: number): void { + const requested = Math.max(1, capacityPosts); + if ( + this.defensePostsByOwnerBuffer && + requested <= this.defensePostsByOwnerCapacity && + this.defensePostsByOwnerStaging + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defensePostsByOwnerCapacity = Math.max( + 8, + Math.pow(2, Math.ceil(Math.log2(requested))), + ); + + const bytesPerPost = 8; // 2 * u32 (x,y) + const bufferSize = this.defensePostsByOwnerCapacity * bytesPerPost; + + (this.defensePostsByOwnerBuffer as any).destroy?.(); + this.defensePostsByOwnerBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defensePostsByOwnerStaging = new Uint32Array( + this.defensePostsByOwnerCapacity * 2, + ); + } + + private ensureDefendedDirtyTilesBuffer(capacityTiles: number): void { + if ( + this.defendedDirtyTilesBuffer && + capacityTiles <= this.defendedDirtyTilesCapacity + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defendedDirtyTilesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityTiles)))), + ); + + const bufferSize = this.defendedDirtyTilesCapacity * 4; // u32 per tile + + (this.defendedDirtyTilesBuffer as any).destroy?.(); + this.defendedDirtyTilesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defendedDirtyTilesStaging = new Uint32Array( + this.defendedDirtyTilesCapacity, + ); + } + + private packDefensePostsByOwner( + posts: Array<{ x: number; y: number; ownerId: number }>, + ): void { + // Reset counts + this.defenseOwnerOffsetsStaging.fill(0); + const counts = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + counts[owner]++; + } + + // Prefix sums into offsets (start,count) pairs. + let running = 0; + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + const count = counts[owner]; + this.defenseOwnerOffsetsStaging[owner * 2] = running; + this.defenseOwnerOffsetsStaging[owner * 2 + 1] = count; + running += count; + } + + this.ensureDefensePostsByOwnerBuffer(running); + if (!this.defensePostsByOwnerStaging) { + throw new Error("defensePostsByOwnerStaging not allocated"); + } + + const writeCursor = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + writeCursor[owner] = this.defenseOwnerOffsetsStaging[owner * 2]; + } + + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + const idx = writeCursor[owner]++; + this.defensePostsByOwnerStaging[idx * 2] = p.x >>> 0; + this.defensePostsByOwnerStaging[idx * 2 + 1] = p.y >>> 0; + } + + this.device.queue.writeBuffer( + this.defenseOwnerOffsetsBuffer, + 0, + this.defenseOwnerOffsetsStaging, + ); + if (running > 0) { + this.device.queue.writeBuffer( + this.defensePostsByOwnerBuffer, + 0, + this.defensePostsByOwnerStaging.subarray(0, running * 2), + ); + } + } + + private ensureDefenseCircleOffsets(range: number): void { + if (range === this.defenseCircleRange) { + return; + } + this.defenseCircleRange = range; + if (range <= 0) { + this.defenseCircleOffsets = new Int16Array(0); + return; + } + + const offsets: number[] = []; + const r2 = range * range; + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { + if (dx * dx + dy * dy <= r2) { + offsets.push(dx, dy); + } + } + } + this.defenseCircleOffsets = new Int16Array(offsets); + } + + private buildDefendedDirtyTiles( + changedPosts: Array<{ x: number; y: number }>, + range: number, + ): void { + if (changedPosts.length === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + this.ensureDefenseCircleOffsets(range); + const offsets = this.defenseCircleOffsets; + const offsetsCount = offsets.length / 2; + if (offsetsCount === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + const worstCase = changedPosts.length * offsetsCount; + const mapTiles = this.mapWidth * this.mapHeight; + if (worstCase > mapTiles) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = true; + return; + } + + this.needsFullDefendedStrengthRecompute = false; + this.ensureDefendedDirtyTilesBuffer(worstCase); + if (!this.defendedDirtyTilesStaging) { + throw new Error("defendedDirtyTilesStaging not allocated"); + } + + let cursor = 0; + for (const post of changedPosts) { + for (let i = 0; i < offsets.length; i += 2) { + const x = post.x + offsets[i]; + const y = post.y + offsets[i + 1]; + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + continue; + } + this.defendedDirtyTilesStaging[cursor++] = + (y * this.mapWidth + x) >>> 0; + } + } + + this.defendedDirtyTilesCount = cursor; + this.device.queue.writeBuffer( + this.defendedDirtyTilesBuffer, + 0, + this.defendedDirtyTilesStaging.subarray(0, cursor), + ); + } + + ensureUpdatesBuffer(capacity: number): GPUBuffer { + if (this.updatesBuffer && capacity <= this.updatesCapacity) { + return this.updatesBuffer; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.updatesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(capacity))), + ); + const bufferSize = this.updatesCapacity * 8; // Each update is 8 bytes + + if (this.updatesBuffer) { + (this.updatesBuffer as any).destroy?.(); + } + + const buffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + (this as any).updatesBuffer = buffer; + + this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); + return buffer; + } + + getUpdatesStaging(): Uint32Array { + this.updatesStaging ??= new Uint32Array(this.updatesCapacity * 2); + return this.updatesStaging; + } + + // ===================== + // Uniform buffer updates + // ===================== + + writeUniformBuffer(timeSec: number): void { + this.uniformData[0] = this.mapWidth; + this.uniformData[1] = this.mapHeight; + this.uniformData[2] = this.viewScale; + this.uniformData[3] = timeSec; + this.uniformData[4] = this.viewOffsetX; + this.uniformData[5] = this.viewOffsetY; + this.uniformData[6] = this.alternativeView ? 1 : 0; + this.uniformData[7] = this.highlightedOwnerId; + this.uniformData[8] = this.viewWidth; + this.uniformData[9] = this.viewHeight; + this.uniformData[10] = this.game.myPlayer()?.smallID() ?? 0; + this.uniformData[11] = 0; + + this.uniformData[12] = this.territoryShaderParams0[0]; + this.uniformData[13] = this.territoryShaderParams0[1]; + this.uniformData[14] = this.territoryShaderParams0[2]; + this.uniformData[15] = this.territoryShaderParams0[3]; + + this.uniformData[16] = this.territoryShaderParams1[0]; + this.uniformData[17] = this.territoryShaderParams1[1]; + this.uniformData[18] = this.territoryShaderParams1[2]; + this.uniformData[19] = this.territoryShaderParams1[3]; + + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); + } + + // ===================== + // State getters/setters + // ===================== + + markPaletteDirty(): void { + this.needsPaletteUpload = true; + } + + markRelationsDirty(): void { + this.needsRelationsUpload = true; + this.pendingRelationsPairs.clear(); + } + + markRelationsPairDirty(aSmallId: number, bSmallId: number): void { + if (aSmallId <= 0 || bSmallId <= 0) { + return; + } + if (!this.relationsDenseBySmallId) { + // No mapping yet: ensure a full rebuild occurs. + this.needsRelationsUpload = true; + return; + } + const a = Math.min(aSmallId, bSmallId); + const b = Math.max(aSmallId, bSmallId); + const key = (BigInt(a) << 32n) | BigInt(b); + this.pendingRelationsPairs.add(key); + } + + setPaletteOverride( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + this.paletteOverride = { paletteWidth, maxSmallId, row0, row1 }; + this.needsPaletteUpload = true; + } + + markDefensePostsDirty(): void { + this.needsDefensePostsUpload = true; + } + + markStateDirty(): void { + this.needsStateUpload = true; + } + + markDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = true; + this.defendedDirtyTilesCount = 0; + } + + getState(): Uint16Array { + return this.state; + } + + getMapWidth(): number { + return this.mapWidth; + } + + getMapHeight(): number { + return this.mapHeight; + } + + getGame(): GameView { + return this.game; + } + + getTheme(): Theme { + return this.theme; + } +} diff --git a/src/client/graphics/webgpu/core/Pipeline.ts b/src/client/graphics/webgpu/core/Pipeline.ts new file mode 100644 index 0000000000..ec2b4dcc77 --- /dev/null +++ b/src/client/graphics/webgpu/core/Pipeline.ts @@ -0,0 +1,90 @@ +type AnyFn = (...args: any[]) => any; +type SlowPipelineKind = "compute" | "render"; +type SlowPipelineEvent = { + kind: SlowPipelineKind; + ms: number; + label?: string; +}; + +function getOptionalMethod( + obj: unknown, + name: string, +): T | undefined { + const anyObj = obj as any; + const value = anyObj?.[name]; + return typeof value === "function" ? (value as T) : undefined; +} + +function emitSlowPipelineEvent(event: SlowPipelineEvent): void { + const hook = (globalThis as any).__webgpuSlowPipelineHook; + if (typeof hook !== "function") { + return; + } + try { + hook(event); + } catch { + // ignore + } +} + +export async function createComputePipeline( + device: GPUDevice, + descriptor: GPUComputePipelineDescriptor, + debugLabel?: string, +): Promise { + const start = performance.now(); + + const maybeAsync = getOptionalMethod< + (desc: GPUComputePipelineDescriptor) => Promise + >(device, "createComputePipelineAsync"); + + const pipeline = maybeAsync + ? await maybeAsync.call(device, descriptor) + : device.createComputePipeline(descriptor); + + const ms = performance.now() - start; + if (ms > 250) { + console.warn("WebGPU slow compute pipeline compile", { + ms: Math.round(ms), + label: debugLabel, + }); + emitSlowPipelineEvent({ + kind: "compute", + ms, + label: debugLabel, + }); + } + + return pipeline; +} + +export async function createRenderPipeline( + device: GPUDevice, + descriptor: GPURenderPipelineDescriptor, + debugLabel?: string, +): Promise { + const start = performance.now(); + + const maybeAsync = getOptionalMethod< + (desc: GPURenderPipelineDescriptor) => Promise + >(device, "createRenderPipelineAsync"); + + const pipeline = maybeAsync + ? await maybeAsync.call(device, descriptor) + : device.createRenderPipeline(descriptor); + + const ms = performance.now() - start; + if (ms > 250) { + console.warn("WebGPU slow render pipeline compile", { + ms: Math.round(ms), + label: debugLabel, + }); + emitSlowPipelineEvent({ + kind: "render", + ms, + label: debugLabel, + }); + } + + return pipeline; +} diff --git a/src/client/graphics/webgpu/core/ShaderLoader.ts b/src/client/graphics/webgpu/core/ShaderLoader.ts new file mode 100644 index 0000000000..19ad380ecd --- /dev/null +++ b/src/client/graphics/webgpu/core/ShaderLoader.ts @@ -0,0 +1,18 @@ +/** + * Utility for loading WGSL shader sources bundled by Vite. + * Uses a static glob so production builds reliably include all shaders. + */ + +const shaderSources = import.meta.glob("../shaders/**/*.wgsl", { + as: "raw", + eager: true, +}) as Record; + +export async function loadShader(path: string): Promise { + const key = `../shaders/${path}`; + const src = shaderSources[key]; + if (!src) { + throw new Error(`Missing WGSL shader source: ${key}`); + } + return src; +} diff --git a/src/client/graphics/webgpu/core/WebGPUDevice.ts b/src/client/graphics/webgpu/core/WebGPUDevice.ts new file mode 100644 index 0000000000..07df9cba7c --- /dev/null +++ b/src/client/graphics/webgpu/core/WebGPUDevice.ts @@ -0,0 +1,68 @@ +/** + * Manages WebGPU device initialization and canvas context configuration. + */ + +export class WebGPUDevice { + public readonly device: GPUDevice; + public readonly context: GPUCanvasContext; + public readonly canvasFormat: GPUTextureFormat; + + private constructor( + device: GPUDevice, + context: GPUCanvasContext, + canvasFormat: GPUTextureFormat, + ) { + this.device = device; + this.context = context; + this.canvasFormat = canvasFormat; + } + + /** + * Initialize WebGPU device and canvas context. + * @param canvas Canvas element to configure (HTMLCanvasElement or OffscreenCanvas) + * @returns WebGPUDevice instance or null if WebGPU is not available + */ + static async create( + canvas: HTMLCanvasElement | OffscreenCanvas, + ): Promise { + const nav = globalThis.navigator as any; + if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { + return null; + } + + const adapter = await nav.gpu.requestAdapter(); + if (!adapter) { + return null; + } + + const device = await adapter.requestDevice(); + const context = canvas.getContext("webgpu"); + if (!context) { + return null; + } + + const canvasFormat = + typeof nav.gpu.getPreferredCanvasFormat === "function" + ? nav.gpu.getPreferredCanvasFormat() + : "bgra8unorm"; + + context.configure({ + device, + format: canvasFormat, + alphaMode: "opaque", + }); + + return new WebGPUDevice(device, context, canvasFormat); + } + + /** + * Reconfigure the canvas context (e.g., when canvas size changes). + */ + reconfigure(): void { + this.context.configure({ + device: this.device, + format: this.canvasFormat, + alphaMode: "opaque", + }); + } +} diff --git a/src/client/graphics/webgpu/render/RenderPass.ts b/src/client/graphics/webgpu/render/RenderPass.ts new file mode 100644 index 0000000000..3140d0026b --- /dev/null +++ b/src/client/graphics/webgpu/render/RenderPass.ts @@ -0,0 +1,46 @@ +import { GroundTruthData } from "../core/GroundTruthData"; + +/** + * Base interface for render passes. + * Render passes run during render() (frame rate) to draw to the canvas. + */ +export interface RenderPass { + /** Unique name of this pass (used for dependency resolution) */ + name: string; + + /** Names of render passes that must run before this one */ + dependencies: string[]; + + /** + * Initialize the pass with device, resources, and canvas format. + * Called once during renderer initialization. + */ + init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise; + + /** + * Check if this pass needs to run this frame. + * Performance optimization: return false to skip execution. + */ + needsUpdate(): boolean; + + /** + * Execute the render pass. + * @param encoder Command encoder for recording GPU commands + * @param resources Ground truth data (read-only access) + * @param target Target texture view to render to + */ + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void; + + /** + * Clean up resources when the pass is no longer needed. + */ + dispose(): void; +} diff --git a/src/client/graphics/webgpu/render/TemporalResolvePass.ts b/src/client/graphics/webgpu/render/TemporalResolvePass.ts new file mode 100644 index 0000000000..1d9a4b1628 --- /dev/null +++ b/src/client/graphics/webgpu/render/TemporalResolvePass.ts @@ -0,0 +1,218 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { RenderPass } from "./RenderPass"; + +/** + * Post-render temporal resolve pass. Blends current and history frames. + */ +export class TemporalResolvePass implements RenderPass { + name = "temporal-resolve"; + dependencies: string[] = ["territory"]; + + private pipeline: GPURenderPipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private canvasFormat: GPUTextureFormat | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(4); + private enabled = false; + private boundCurrentTexture: GPUTexture | null = null; + private boundHistoryTexture: GPUTexture | null = null; + + async init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise { + this.device = device; + this.resources = resources; + this.canvasFormat = canvasFormat; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + this.paramsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST, + }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + ], + }); + + await this.setShader("render/temporal-resolve.wgsl"); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + setParams(params0: Float32Array | number[]): void { + this.paramsData[0] = Number(params0[0] ?? 0); + this.paramsData[1] = Number(params0[1] ?? 1); + this.paramsData[2] = Number(params0[2] ?? 0.08); + this.paramsData[3] = 0; + this.enabled = this.paramsData[0] > 0; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + needsUpdate(): boolean { + return this.enabled; + } + + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + if (!this.enabled) { + return; + } + + const currentTexture = resources.getCurrentColorTexture(); + const historyRead = resources.getHistoryReadTexture(); + const historyWrite = resources.getHistoryWriteTexture(); + if (!currentTexture || !historyRead || !historyWrite) { + return; + } + + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundCurrentTexture !== currentTexture || + this.boundHistoryTexture !== historyRead; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + { + view: historyWrite.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], + }); + + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.draw(3); + pass.end(); + + resources.swapHistoryTextures(); + resources.markHistoryValid(); + } + + rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer + ) { + return; + } + + const currentTexture = this.resources.getCurrentColorTexture(); + const historyRead = this.resources.getHistoryReadTexture(); + if (!currentTexture || !historyRead) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: currentTexture.createView(), + }, + { + binding: 3, + resource: historyRead.createView(), + }, + ], + }); + + this.boundCurrentTexture = currentTexture; + this.boundHistoryTexture = historyRead; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts new file mode 100644 index 0000000000..9d61cc2cd5 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts @@ -0,0 +1,284 @@ +export type TerrainShaderId = "classic" | "improved-lite" | "improved-heavy"; + +export type TerrainShaderOption = + | { + kind: "boolean"; + key: string; + label: string; + defaultValue: boolean; + } + | { + kind: "range"; + key: string; + label: string; + defaultValue: number; + min: number; + max: number; + step: number; + } + | { + kind: "enum"; + key: string; + label: string; + defaultValue: number; + options: Array<{ value: number; label: string }>; + }; + +export interface TerrainShaderDefinition { + id: TerrainShaderId; + label: string; + wgslPath: string; + options: TerrainShaderOption[]; +} + +export const TERRAIN_SHADER_KEY = "settings.webgpu.terrain.shader"; + +export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [ + { + id: "classic", + label: "Classic", + wgslPath: "compute/terrain-compute.wgsl", + options: [], + }, + { + id: "improved-lite", + label: "Improved (Lite)", + wgslPath: "compute/terrain-compute-improved-lite.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.noiseStrength", + label: "Noise Strength", + defaultValue: 0.005, + min: 0, + max: 0.08, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.blendWidth", + label: "Biome Blend Width", + defaultValue: 5, + min: 0.5, + max: 5, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.waterBlurStrength", + label: "Water Blur Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.05, + }, + ], + }, + { + id: "improved-heavy", + label: "Improved (Heavy)", + wgslPath: "compute/terrain-compute-improved-heavy.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.noiseStrength", + label: "Noise Strength", + defaultValue: 0.01, + min: 0, + max: 0.1, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength", + label: "Detail Noise Strength", + defaultValue: 0.01, + min: 0, + max: 0.08, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.blendWidth", + label: "Biome Blend Width", + defaultValue: 4.5, + min: 0.5, + max: 6, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthStrength", + label: "Water Depth Strength", + defaultValue: 0.35, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthCurve", + label: "Water Depth Curve", + defaultValue: 2, + min: 0.5, + max: 4, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthBlur", + label: "Water Depth Blur", + defaultValue: 0.6, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.lightingStrength", + label: "Lighting Strength", + defaultValue: 0.3, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.cavityStrength", + label: "Cavity Strength", + defaultValue: 0.15, + min: 0, + max: 1, + step: 0.05, + }, + ], + }, +]; + +export function getTerrainShaderById( + id: TerrainShaderId, +): TerrainShaderDefinition { + const found = TERRAIN_SHADERS.find((s) => s.id === id); + if (!found) { + throw new Error(`Unknown terrain shader: ${id}`); + } + return found; +} + +export function terrainShaderIdFromInt(value: number): TerrainShaderId { + if (value === 1) return "improved-lite"; + if (value === 2) return "improved-heavy"; + return "classic"; +} + +export function terrainShaderIntFromId(id: TerrainShaderId): number { + if (id === "improved-lite") return 1; + if (id === "improved-heavy") return 2; + return 0; +} + +export function readTerrainShaderId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerrainShaderId { + return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0)); +} + +export function buildTerrainShaderParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + shaderId: TerrainShaderId, +): { shaderPath: string; params0: Float32Array; params1: Float32Array } { + const waterDepthStrengthDefault = 0.4; + const waterDepthCurveDefault = 2; + const waterDepthBlurDefault = 0.6; + + if (shaderId === "improved-lite") { + const noiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.noiseStrength", + 0.005, + ); + const blendWidth = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.blendWidth", + 5, + ); + const waterBlurStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.waterBlurStrength", + 1, + ); + const params0 = new Float32Array([ + noiseStrength, + blendWidth, + waterBlurStrength, + 0, + ]); + const params1 = new Float32Array([0, 0, 0, 0]); + return { + shaderPath: "compute/terrain-compute-improved-lite.wgsl", + params0, + params1, + }; + } + + if (shaderId === "improved-heavy") { + const noiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.noiseStrength", + 0.01, + ); + const detailNoiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength", + 0.01, + ); + const blendWidth = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.blendWidth", + 4.5, + ); + const waterDepthStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthStrength", + 0.35, + ); + const waterDepthCurve = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthCurve", + 2, + ); + const waterDepthBlur = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthBlur", + 0.6, + ); + const lightingStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.lightingStrength", + 0.3, + ); + const cavityStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.cavityStrength", + 0.15, + ); + + const params0 = new Float32Array([ + noiseStrength, + blendWidth, + waterDepthStrength, + waterDepthCurve, + ]); + const params1 = new Float32Array([ + detailNoiseStrength, + lightingStrength, + cavityStrength, + waterDepthBlur, + ]); + return { + shaderPath: "compute/terrain-compute-improved-heavy.wgsl", + params0, + params1, + }; + } + + const params0 = new Float32Array([ + 0, + 2.5, + waterDepthStrengthDefault, + waterDepthCurveDefault, + ]); + const params1 = new Float32Array([waterDepthBlurDefault, 0, 0, 0]); + return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts new file mode 100644 index 0000000000..be5a76a8e5 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts @@ -0,0 +1,128 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve"; + +export interface TerritoryPostSmoothingDefinition { + id: TerritoryPostSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_POST_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.post"; + +export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "fade", + label: "Fade", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 0.2, + min: 0.01, + max: 1, + step: 0.01, + }, + ], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.dissolveWidth", + label: "Dissolve Width", + defaultValue: 0.08, + min: 0.01, + max: 0.4, + step: 0.01, + }, + ], + }, +]; + +export function territoryPostSmoothingIdFromInt( + value: number, +): TerritoryPostSmoothingId { + if (value === 1) return "fade"; + if (value === 2) return "dissolve"; + return "off"; +} + +export function territoryPostSmoothingIntFromId( + id: TerritoryPostSmoothingId, +): number { + if (id === "fade") return 1; + if (id === "dissolve") return 2; + return 0; +} + +export function readTerritoryPostSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPostSmoothingId { + return territoryPostSmoothingIdFromInt( + userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0), + ); +} + +export function buildTerritoryPostSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPostSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const blendStrength = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.blendStrength", + 0.2, + ); + const dissolveWidth = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.dissolveWidth", + 0.08, + ); + + const mode = smoothingId === "fade" ? 1 : 2; + const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + + return { + enabled: true, + shaderPath: "render/temporal-resolve.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts new file mode 100644 index 0000000000..e04ee0a6da --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts @@ -0,0 +1,114 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget"; + +export interface TerritoryPreSmoothingDefinition { + id: TerritoryPreSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_PRE_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.pre"; + +export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, + { + id: "budget", + label: "Budgeted Reveal", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, +]; + +export function territoryPreSmoothingIdFromInt( + value: number, +): TerritoryPreSmoothingId { + if (value === 1) return "dissolve"; + if (value === 2) return "budget"; + return "off"; +} + +export function territoryPreSmoothingIntFromId( + id: TerritoryPreSmoothingId, +): number { + if (id === "dissolve") return 1; + if (id === "budget") return 2; + return 0; +} + +export function readTerritoryPreSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPreSmoothingId { + return territoryPreSmoothingIdFromInt( + userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0), + ); +} + +export function buildTerritoryPreSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPreSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const curveExp = userSettings.getFloat( + "settings.webgpu.territory.preSmoothing.curveExp", + 1, + ); + const mode = smoothingId === "dissolve" ? 1 : 2; + + const params0 = new Float32Array([mode, curveExp, 0, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + return { + enabled: true, + shaderPath: "compute/visual-state-smoothing.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts new file mode 100644 index 0000000000..b9f875a5b9 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -0,0 +1,210 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { RenderPass } from "./RenderPass"; + +/** + * Main territory rendering pass. + * Renders territory colors, defended tiles, fallout, and hover highlights. + */ +export class TerritoryRenderPass implements RenderPass { + name = "territory"; + dependencies: string[] = []; + + private pipeline: GPURenderPipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private canvasFormat: GPUTextureFormat | null = null; + private shaderPath = "render/territory.wgsl"; + private clearR = 0; + private clearG = 0; + private clearB = 0; + + async init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise { + this.device = device; + this.resources = resources; + this.canvasFormat = canvasFormat; + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 4, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 5, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 6, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + ], + }); + + await this.setShader(this.shaderPath); + + this.rebuildBindGroup(); + + // Extract clear color from theme + const bg = resources.getTheme().backgroundColor().rgba; + this.clearR = bg.r / 255; + this.clearG = bg.g / 255; + this.clearB = bg.b / 255; + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + needsUpdate(): boolean { + // Always run every frame (can be optimized later if needed) + return true; + } + + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void { + if (!this.device || !this.pipeline) { + return; + } + + // Rebuild bind group if needed (e.g., after texture recreation) + this.rebuildBindGroup(); + + if (!this.bindGroup) { + return; + } + + // Update uniforms + resources.writeUniformBuffer(performance.now() / 1000); + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + loadOp: "clear", + storeOp: "store", + clearValue: { + r: this.clearR, + g: this.clearG, + b: this.clearB, + a: 1, + }, + }, + ], + }); + + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.draw(3); + pass.end(); + } + + rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.uniformBuffer || + !this.resources.defendedStrengthTexture || + !this.resources.paletteTexture || + !this.resources.terrainTexture || + !this.resources.ownerIndexTexture || + !this.resources.relationsTexture + ) { + return; + } + + const stateTexture = this.resources.getRenderStateTexture(); + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.resources.uniformBuffer } }, + { + binding: 1, + resource: stateTexture.createView(), + }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 3, + resource: this.resources.paletteTexture.createView(), + }, + { + binding: 4, + resource: this.resources.terrainTexture.createView(), + }, + { + binding: 5, + resource: this.resources.ownerIndexTexture.createView(), + }, + { + binding: 6, + resource: this.resources.relationsTexture.createView(), + }, + ], + }); + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts new file mode 100644 index 0000000000..ee78cdf1c7 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts @@ -0,0 +1,353 @@ +export type TerritoryShaderId = "classic" | "retro"; + +export type TerritoryShaderOption = + | { + kind: "boolean"; + key: string; + label: string; + defaultValue: boolean; + } + | { + kind: "range"; + key: string; + label: string; + defaultValue: number; + min: number; + max: number; + step: number; + } + | { + kind: "enum"; + key: string; + label: string; + defaultValue: number; + options: Array<{ value: number; label: string }>; + }; + +export interface TerritoryShaderDefinition { + id: TerritoryShaderId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_SHADER_KEY = "settings.webgpu.territory.shader"; + +export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [ + { + id: "classic", + label: "Simple", + wgslPath: "render/territory.wgsl", + options: [ + { + kind: "enum", + key: "settings.webgpu.territory.classic.borderMode", + label: "Border Mode", + defaultValue: 1, + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "Simple" }, + { value: 2, label: "Glow" }, + ], + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.thicknessPx", + label: "Thickness (px)", + defaultValue: 1, + min: 0.5, + max: 8, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.borderStrength", + label: "Border Strength", + defaultValue: 0.64, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowStrength", + label: "Glow Strength", + defaultValue: 0.42, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 12, + step: 0.25, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: false, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: false, + }, + ], + }, + { + id: "retro", + label: "Retro", + wgslPath: "render/retro.wgsl", + options: [ + { + kind: "boolean", + key: "settings.webgpu.territory.retro.colorByRelations", + label: "Color By Player Relations", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.patternWhenDefended", + label: "Pattern When In Defended Range", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.splitBorder", + label: "Split Border", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: true, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.thicknessPx", + label: "Thickness (px)", + defaultValue: 6, + min: 0.5, + max: 12, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.borderStrength", + label: "Border Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowStrength", + label: "Glow Strength", + defaultValue: 0, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 16, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.relationTintStrength", + label: "Relation Tint Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.defendedPatternStrength", + label: "Defended Pattern Strength", + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.defendedThreshold", + label: "Defended Threshold", + defaultValue: 0.01, + min: 0.01, + max: 1, + step: 0.01, + }, + ], + }, +]; + +export function getTerritoryShaderById( + id: TerritoryShaderId, +): TerritoryShaderDefinition { + const found = TERRITORY_SHADERS.find((s) => s.id === id); + if (!found) { + throw new Error(`Unknown territory shader: ${id}`); + } + return found; +} + +export function territoryShaderIdFromInt(value: number): TerritoryShaderId { + return value === 1 ? "retro" : "classic"; +} + +export function territoryShaderIntFromId(id: TerritoryShaderId): number { + return id === "retro" ? 1 : 0; +} + +export function readTerritoryShaderId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryShaderId { + return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0)); +} + +export function buildTerritoryShaderParams( + userSettings: { + get: (key: string, defaultValue: boolean) => boolean; + getFloat: (key: string, defaultValue: number) => number; + getInt: (key: string, defaultValue: number) => number; + }, + shaderId: TerritoryShaderId, +): { shaderPath: string; params0: Float32Array; params1: Float32Array } { + if (shaderId === "retro") { + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.retro.thicknessPx", + 6, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.borderStrength", + 1, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.glowStrength", + 0, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.retro.glowRadiusMul", + 1, + ); + + const colorByRelations = userSettings.get( + "settings.webgpu.territory.retro.colorByRelations", + true, + ); + const patternWhenDefended = userSettings.get( + "settings.webgpu.territory.retro.patternWhenDefended", + true, + ); + const splitBorder = userSettings.get( + "settings.webgpu.territory.retro.splitBorder", + true, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.retro.drawDefendedRadius", + true, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.retro.disableDefendedTint", + true, + ); + const relationTintStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.relationTintStrength", + 1, + ); + const defendedPatternStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.defendedPatternStrength", + 0.5, + ); + const defendedThreshold = userSettings.getFloat( + "settings.webgpu.territory.retro.defendedThreshold", + 0.01, + ); + + let flags = 0; + if (colorByRelations) flags |= 1 << 0; + if (patternWhenDefended) flags |= 1 << 1; + if (splitBorder) flags |= 1 << 2; + if (drawDefendedRadius) flags |= 1 << 3; + if (disableDefendedTint) flags |= 1 << 4; + + const params0 = new Float32Array([ + thicknessPx, + borderStrength, + glowStrength, + glowRadiusMul, + ]); + const params1 = new Float32Array([ + flags, + relationTintStrength, + defendedPatternStrength, + defendedThreshold, + ]); + + return { shaderPath: "render/retro.wgsl", params0, params1 }; + } + + const borderMode = userSettings.getInt( + "settings.webgpu.territory.classic.borderMode", + 1, + ); + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.classic.thicknessPx", + 1, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.borderStrength", + 0.64, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.glowStrength", + 0.42, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.classic.glowRadiusMul", + 1, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.classic.drawDefendedRadius", + false, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.classic.disableDefendedTint", + false, + ); + + const params0 = new Float32Array([ + borderMode, + thicknessPx, + borderStrength, + glowStrength, + ]); + const params1 = new Float32Array([ + glowRadiusMul, + drawDefendedRadius ? 1 : 0, + disableDefendedTint ? 1 : 0, + 0, + ]); + return { shaderPath: "render/territory.wgsl", params0, params1 }; +} diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl new file mode 100644 index 0000000000..0cea311a24 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl @@ -0,0 +1,65 @@ +struct Params { + _dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(3) var ownerOffsets: array; +@group(0) @binding(4) var postsByOwner: array; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let dims = textureDimensions(stateTex); + if (globalId.x >= dims.x || globalId.y >= dims.y) { + return; + } + + let x = i32(globalId.x); + let y = i32(globalId.y); + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl new file mode 100644 index 0000000000..828392ce05 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl @@ -0,0 +1,69 @@ +struct Params { + dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var dirtyTiles: array; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.dirtyCount) { + return; + } + + let tileIndex = dirtyTiles[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(tileIndex % mapWidth); + let y = i32(tileIndex / mapWidth); + + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl new file mode 100644 index 0000000000..dec940fc9d --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -0,0 +1,73 @@ +struct Update { + tileIndex: u32, + newState: u32, +}; + +struct Params { + updateCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var updates: array; +@group(0) @binding(2) var stateTex: texture_storage_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.updateCount) { + return; + } + let update = updates[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(update.tileIndex % mapWidth); + let y = i32(update.tileIndex / mapWidth); + textureStore(stateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); + + // Update defended strength for this tile based on the new owner. + let owner = update.newState & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl new file mode 100644 index 0000000000..611a2c1a0d --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl @@ -0,0 +1,201 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterDepthStrength, w=waterDepthCurve + tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=waterDepthBlur +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +fn hash21(p: vec2u) -> f32 { + var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u; + n ^= n >> 16u; + n *= 0x85ebca6bu; + n ^= n >> 13u; + n *= 0xc2b2ae35u; + n ^= n >> 16u; + return f32(n & 0x00ffffffu) / 16777215.0; +} + +fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i { + let maxX = i32(dims.x) - 1; + let maxY = i32(dims.y) - 1; + return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY)); +} + +fn sampleTerrainData(coord: vec2i, dims: vec2u) -> u32 { + let c = clampCoord(coord, dims); + return textureLoad(terrainDataTex, c, 0).x; +} + +fn computeLandColor( + mag: f32, + noise: f32, + noiseStrength: f32, + blendWidth: f32, +) -> vec3f { + let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0); + let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b); + + let highlandMag = clamp(mag - 10.0, 0.0, 9.0); + let highland = vec3f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + ); + + let mountainMag = max(mag - 20.0, 0.0); + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + let mountain = vec3f(gray, gray, gray); + + let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag); + let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag); + var land = mix(plains, highland, tHigh); + land = mix(land, mountain, tMount); + + let noiseBias = (noise - 0.5) * noiseStrength; + return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + let noise = hash21(vec2u(texCoord)); + let noiseFine = hash21(vec2u(texCoord) * 3u + vec2u(17u, 29u)); + let noiseStrength = max(params.tuning0.x, 0.0); + let blendWidth = max(params.tuning0.y, 0.1); + let waterDepthStrength = clamp(params.tuning0.z, 0.0, 1.0); + let waterDepthCurve = max(params.tuning0.w, 0.1); + let detailNoiseStrength = max(params.tuning1.x, 0.0); + let lightingStrength = clamp(params.tuning1.y, 0.0, 1.0); + let cavityStrength = clamp(params.tuning1.z, 0.0, 1.0); + let waterDepthBlur = clamp(params.tuning1.w, 0.0, 1.0); + let shoreMixLand = 0.6; + let shoreMixWater = 0.55; + let specularStrength = 0.05; + + let hC = mag / 31.0; + let dataL = sampleTerrainData(texCoord + vec2i(-1, 0), dims); + let dataR = sampleTerrainData(texCoord + vec2i(1, 0), dims); + let dataD = sampleTerrainData(texCoord + vec2i(0, -1), dims); + let dataU = sampleTerrainData(texCoord + vec2i(0, 1), dims); + + let magL = f32(dataL & MAGNITUDE_MASK); + let magR = f32(dataR & MAGNITUDE_MASK); + let magD = f32(dataD & MAGNITUDE_MASK); + let magU = f32(dataU & MAGNITUDE_MASK); + + let hL = magL / 31.0; + let hR = magR / 31.0; + let hD = magD / 31.0; + let hU = magU / 31.0; + + let dx = hR - hL; + let dy = hU - hD; + let normal = normalize(vec3f(-dx * 2.2, -dy * 2.2, 1.0)); + let lightDir = normalize(vec3f(0.55, 0.45, 1.0)); + let diffuse = clamp(dot(normal, lightDir), 0.0, 1.0); + let baseLighting = 0.55 + 0.45 * diffuse; + let lighting = mix(1.0, baseLighting, lightingStrength); + + let slope = length(vec2f(dx, dy)); + let rockiness = smoothstep(0.08, 0.28, slope); + + let cavity = clamp(((hL + hR + hD + hU) * 0.25 - hC) * 2.0, 0.0, 0.25); + + var color: vec4f; + + if (isLand) { + var land = computeLandColor(mag, noise, noiseStrength, blendWidth); + + if (isShoreline) { + land = mix(land, params.shoreColor.rgb, shoreMixLand); + } + + land = mix(land, params.mountainBaseColor.rgb, rockiness * 0.6); + + land = clamp(land * lighting, vec3f(0.0), vec3f(1.0)); + land = clamp(land * (1.0 - cavity * cavityStrength), vec3f(0.0), vec3f(1.0)); + land = clamp( + land + vec3f((noiseFine - 0.5) * detailNoiseStrength), + vec3f(0.0), + vec3f(1.0), + ); + + color = vec4f(land, 1.0); + } else { + var sum = mag; + var count = 1.0; + if ((dataL & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magL; + count = count + 1.0; + } + if ((dataR & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magR; + count = count + 1.0; + } + if ((dataD & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magD; + count = count + 1.0; + } + if ((dataU & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magU; + count = count + 1.0; + } + + let avgMag = sum / count; + let smoothMag = mix(mag, avgMag, waterDepthBlur); + let depth01 = clamp(smoothMag / 10.0, 0.0, 1.0); + let depth = clamp(pow(depth01, waterDepthCurve), 0.0, 1.0); + let depthColor = mix( + params.shorelineWaterColor.rgb, + params.waterColor.rgb, + depth, + ); + var water = mix(params.waterColor.rgb, depthColor, waterDepthStrength); + let noiseBias = (noise - 0.5) * (noiseStrength * 0.6); + water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); + + if (isShoreline) { + water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater); + } + + let viewDir = vec3f(0.0, 0.0, 1.0); + let spec = pow(max(dot(reflect(-lightDir, normal), viewDir), 0.0), 24.0); + water = clamp( + water + vec3f(spec * specularStrength), + vec3f(0.0), + vec3f(1.0), + ); + + color = vec4f(water, 1.0); + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl new file mode 100644 index 0000000000..95e4dfa5aa --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl @@ -0,0 +1,134 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterBlurStrength, w=unused +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +fn hash21(p: vec2u) -> f32 { + var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u; + n ^= n >> 16u; + n *= 0x85ebca6bu; + n ^= n >> 13u; + n *= 0xc2b2ae35u; + n ^= n >> 16u; + return f32(n & 0x00ffffffu) / 16777215.0; +} + +fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i { + let maxX = i32(dims.x) - 1; + let maxY = i32(dims.y) - 1; + return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY)); +} + +fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -> vec3f { + let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0); + let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b); + + let highlandMag = clamp(mag - 10.0, 0.0, 9.0); + let highland = vec3f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + ); + + let mountainMag = max(mag - 20.0, 0.0); + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + let mountain = vec3f(gray, gray, gray); + + let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag); + let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag); + var land = mix(plains, highland, tHigh); + land = mix(land, mountain, tMount); + + let noiseBias = (noise - 0.5) * noiseStrength; + return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + let noise = hash21(vec2u(texCoord)); + let noiseStrength = max(params.tuning0.x, 0.0); + let blendWidth = max(params.tuning0.y, 0.1); + let waterDepthBlur = clamp(params.tuning0.z, 0.0, 1.0); + let shoreMixLand = 0.6; + var color: vec4f; + + if (isLand) { + var land = computeLandColor(mag, noise, noiseStrength, blendWidth); + if (isShoreline) { + land = mix(land, params.shoreColor.rgb, shoreMixLand); + } + color = vec4f(land, 1.0); + } else { + if (isShoreline) { + color = vec4f(params.shorelineWaterColor.rgb, 1.0); + textureStore(terrainTex, texCoord, color); + return; + } + + var sum = mag; + var count = 1.0; + let dataL = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(-1, 0), dims), 0).x; + if ((dataL & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataL & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataR = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(1, 0), dims), 0).x; + if ((dataR & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataR & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataD = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, -1), dims), 0).x; + if ((dataD & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataD & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataU = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, 1), dims), 0).x; + if ((dataU & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataU & MAGNITUDE_MASK); + count = count + 1.0; + } + + let avgMag = sum / count; + let smoothMag = mix(mag, avgMag, waterDepthBlur); + let magClamped = min(smoothMag, 10.0); + let adjustment = (1.0 - magClamped) / 255.0; + let water = vec3f( + max(params.waterColor.r + adjustment, 0.0), + max(params.waterColor.g + adjustment, 0.0), + max(params.waterColor.b + adjustment, 0.0), + ); + color = vec4f(water, 1.0); + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl new file mode 100644 index 0000000000..b37ac68de4 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl @@ -0,0 +1,104 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // Shader tuning params (unused in classic) + tuning1: vec4f, // Shader tuning params (unused in classic) +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const OCEAN_BIT: u32 = 5u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + // Extract terrain bits + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + var color: vec4f; + + // Check if shore (land adjacent to water) + if (isLand && isShoreline) { + color = params.shoreColor; + } else if (!isLand) { + // Water tile + if (isShoreline) { + color = params.shorelineWaterColor; + } else { + // Deep water - color varies by magnitude + // CPU formula: waterColor - 10 + (11 - min(mag, 10)) + // In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0 + // Simplified: waterColor + (1 - min(mag, 10)) / 255.0 + let magClamped = min(mag, 10.0); + let adjustment = (1.0 - magClamped) / 255.0; + color = vec4f( + max(params.waterColor.r + adjustment, 0.0), + max(params.waterColor.g + adjustment, 0.0), + max(params.waterColor.b + adjustment, 0.0), + 1.0 + ); + } + } else { + // Land tile - determine terrain type from magnitude + // CPU formulas: + // Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9 + // Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19 + // Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20 + // + // We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255 + // We sampled highland at some mag 10-19, need to compute from mag 10 + if (magnitude < 10u) { + // Plains: rgb(190, 220 - 2*mag, 138) + color = vec4f( + params.plainsBaseColor.r, // 190/255 + max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255 + params.plainsBaseColor.b, // 138/255 + 1.0 + ); + } else if (magnitude < 20u) { + // Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) + // We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255 + // For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255 + let highlandMag = mag - 10.0; + color = vec4f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + 1.0 + ); + } else { + // Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) + // We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel + // For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255 + let mountainMag = mag - 20.0; + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + color = vec4f(gray, gray, gray, 1.0); + } + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl new file mode 100644 index 0000000000..3be34cec06 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl @@ -0,0 +1,76 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=curveExp + params1: vec4f, // x=updateCount +}; + +struct Update { + tileIndex: u32, + newState: u32, +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var updates: array; +@group(0) @binding(3) var visualStateTex: texture_storage_2d; + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + let updateCount = u32(max(0.0, p.params1.x) + 0.5); + if (idx >= updateCount) { + return; + } + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let curveExp = max(0.001, p.params0.y); + let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0); + + let update = updates[idx]; + + if (mode == 1u) { + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u)); + let r = hashToUnitFloat(h); + if (r > alpha) { + return; + } + } else if (mode == 2u) { + let targetCount = u32(floor(f32(updateCount) * alpha)); + if (idx >= targetCount) { + return; + } + } else { + return; + } + + let dims = textureDimensions(visualStateTex); + let mapWidth = dims.x; + let x = i32(update.tileIndex % mapWidth); + let y = i32(update.tileIndex / mapWidth); + textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); +} diff --git a/src/client/graphics/webgpu/shaders/render/retro.wgsl b/src/client/graphics/webgpu/shaders/render/retro.wgsl new file mode 100644 index 0000000000..ad257d504e --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/retro.wgsl @@ -0,0 +1,303 @@ +struct Uniforms { + mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, // x=thicknessPx, y=borderStrength, z=glowStrength, w=glowRadiusMul + shaderParams1: vec4f, // x=flags, y=relationTintStrength, z=defendedPatternStrength, w=defendedThreshold +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hasFlag(flags: u32, bit: u32) -> bool { + return (flags & (1u << bit)) != 0u; +} + +fn relationCode(ownerA: u32, ownerB: u32) -> u32 { + if (ownerA == 0u || ownerB == 0u) { + return 0u; + } + let aDense = textureLoad(ownerIndexTex, vec2i(i32(ownerA), 0), 0).x; + let bDense = textureLoad(ownerIndexTex, vec2i(i32(ownerB), 0), 0).x; + if (aDense == 0u || bDense == 0u) { + return 0u; + } + return textureLoad(relationsTex, vec2i(i32(aDense), i32(bDense)), 0).x; +} + +fn applyDefendedPattern( + baseRgb: vec3f, + strength: f32, + texCoord: vec2i, +) -> vec3f { + let parity = (u32(texCoord.x) ^ u32(texCoord.y)) & 1u; + let factor = select(0.75, 1.25, parity == 1u); + let patterned = clamp(baseRgb * factor, vec3f(0.0), vec3f(1.0)); + return mix(baseRgb, patterned, clamp(strength, 0.0, 1.0)); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale_time.xy; + let viewScale = u.mapResolution_viewScale_time.z; + let timeSec = u.mapResolution_viewScale_time.w; + let viewOffset = u.viewOffset_alt_highlight.xy; + let altView = u.viewOffset_alt_highlight.z; + let highlightId = u.viewOffset_alt_highlight.w; + let myPlayerSmallId = u.viewSize_pad.z; + + let thicknessPx = u.shaderParams0.x; + let borderStrength = u.shaderParams0.y; + let glowStrength = u.shaderParams0.z; + let glowRadiusMul = u.shaderParams0.w; + + let flags = u32(max(0.0, u.shaderParams1.x) + 0.5); + let relationTintStrength = u.shaderParams1.y; + let defendedPatternStrength = u.shaderParams1.z; + let defendedThreshold = u.shaderParams1.w; + + let enableRelations = hasFlag(flags, 0u); + let enableDefendedPattern = hasFlag(flags, 1u); + let enableSplit = hasFlag(flags, 2u); + let drawDefendedRadius = hasFlag(flags, 3u); + let disableDefendedTint = hasFlag(flags, 4u); + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if ( + mapCoord.x < 0.0 || + mapCoord.y < 0.0 || + mapCoord.x >= mapRes.x || + mapCoord.y >= mapRes.y + ) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + let hasFallout = (state & 0x2000u) != 0u; + + let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; + + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + var territoryRgb = c.rgb; + if (!disableDefendedTint) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } + if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + territoryRgb = mix(territoryRgb, falloutColor, 0.5); + } + outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); + } else if (hasFallout) { + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0); + } + + // In alt view we show only borders on top of terrain. + if (altView > 0.5) { + outColor = terrain; + } + + if (owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var bestDist = 1e9; + var otherOwner = 0u; + var otherCoord = texCoord; + + // Only border against other non-zero owners. + if (texCoord.x > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(-1, 0); + } + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(1, 0); + } + } + } + if (texCoord.y > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, -1); + } + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, 1); + } + } + } + + if (otherOwner != 0u) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = max(0.1, thicknessPx) / pxPerTile; + + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, bestDist); + let glowTiles = (max(0.1, thicknessPx) * max(0.1, glowRadiusMul)) / pxPerTile; + let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, bestDist); + + var baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + + if (!enableSplit) { + let otherBorderRgb = textureLoad(paletteTex, vec2i(i32(otherOwner) + 10, 1), 0).rgb; + baseBorderRgb = 0.5 * (baseBorderRgb + otherBorderRgb); + } + + var edgeDefendedStrength = defendedStrength; + if (!enableSplit) { + let otherDef = textureLoad(defendedStrengthTex, otherCoord, 0).x; + edgeDefendedStrength = max(edgeDefendedStrength, otherDef); + } + + // Determine relation color (normal: between owners, altView: relation to viewer). + var rel = 0u; + if (enableRelations) { + if (altView > 0.5) { + rel = relationCode(owner, u32(max(0.0, myPlayerSmallId) + 0.5)); + } else { + rel = relationCode(owner, otherOwner); + } + } + + var borderRgb = baseBorderRgb; + if (rel != 0u) { + let tintTarget = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), rel == 2u); + let tint = clamp(0.35 * relationTintStrength, 0.0, 1.0); + borderRgb = mix(borderRgb, tintTarget, tint); + } + + if (enableDefendedPattern && edgeDefendedStrength >= defendedThreshold) { + borderRgb = applyDefendedPattern(borderRgb, defendedPatternStrength, texCoord); + } + + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(line * borderStrength, 0.0, 1.0)), + outColor.a, + ); + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(glow * glowStrength, 0.0, 1.0)), + outColor.a, + ); + } + } + + if (drawDefendedRadius && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(baseBorderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + + // Apply hover highlight if needed + if (highlightId > 0.5) { + let alpha = select(0.65, 0.0, altView > 0.5); + + if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) { + let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853); + let strength = 0.15 + 0.15 * pulse; + let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength); + outColor = vec4f(highlightedRgb, outColor.a); + } + } + + return outColor; +} diff --git a/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl new file mode 100644 index 0000000000..e4cd48dbee --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl @@ -0,0 +1,81 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var currentTex: texture_2d; +@group(0) @binding(3) var historyTex: texture_2d; + +struct FragOutput { + @location(0) color: vec4f, + @location(1) history: vec4f, +}; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> FragOutput { + let texCoord = vec2i(pos.xy); + let curr = textureLoad(currentTex, texCoord, 0); + let hist = textureLoad(historyTex, texCoord, 0); + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let strength = clamp(p.params0.y, 0.0, 1.0); + let width = max(0.001, p.params0.z); + + var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0); + if (t.historyValid < 0.5) { + alpha = 1.0; + } + + if (mode == 1u) { + let outColor = mix(hist, curr, alpha); + return FragOutput(outColor, outColor); + } + + if (mode == 2u) { + let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u); + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u))); + let mask = smoothstep(alpha - width, alpha + width, r); + let outColor = mix(hist, curr, mask); + return FragOutput(outColor, outColor); + } + + return FragOutput(curr, curr); +} diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl new file mode 100644 index 0000000000..a234c5a631 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -0,0 +1,218 @@ +struct Uniforms { + mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, + shaderParams1: vec4f, +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale_time.xy; + let viewScale = u.mapResolution_viewScale_time.z; + let timeSec = u.mapResolution_viewScale_time.w; + let viewOffset = u.viewOffset_alt_highlight.xy; + let altView = u.viewOffset_alt_highlight.z; + let highlightId = u.viewOffset_alt_highlight.w; + let viewSize = u.viewSize_pad.xy; + let borderMode = u.shaderParams0.x; + let thicknessPx = u.shaderParams0.y; + let borderStrength = u.shaderParams0.z; + let glowStrength = u.shaderParams0.w; + let glowRadiusMul = u.shaderParams1.x; + let drawDefendedRadius = u.shaderParams1.y; + let disableDefendedTint = u.shaderParams1.z; + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + // Match TransformHandler.screenToWorldCoordinates formula: + // gameX = (canvasX - game.width() / 2) / scale + offsetX + game.width() / 2 + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if (mapCoord.x < 0.0 || mapCoord.y < 0.0 || mapCoord.x >= mapRes.x || mapCoord.y >= mapRes.y) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + let hasFallout = (state & 0x2000u) != 0u; + + let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + var territoryRgb = c.rgb; + if (disableDefendedTint <= 0.5) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } + if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + territoryRgb = mix(territoryRgb, falloutColor, 0.5); + } + outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); + } else if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0); + } + + // Apply alternative view (hide territory by showing terrain only) + if (altView > 0.5 && owner != 0u) { + outColor = terrain; + } + + // Borders (purely visual): render a stable-pixel-width line at ownership edges. + if (borderMode > 0.5 && altView <= 0.5 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + // Only border against other non-zero owners. + if (texCoord.x > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + + // Mode 1: thin black border. + // Mode 2: thicker black border + obvious tinted glow. + let isGlow = borderMode > 1.5; + let thicknessTiles = thicknessPx / pxPerTile; + + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + outColor = vec4f( + mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)), + outColor.a, + ); + + if (isGlow) { + let glowTiles = (thicknessPx * glowRadiusMul) / pxPerTile; + let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, dist); + let ownerRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0).rgb; + let glowColor = mix(vec3f(1.0, 1.0, 1.0), ownerRgb, 0.85); + outColor = vec4f( + mix(outColor.rgb, glowColor, clamp(glow * glowStrength, 0.0, 1.0)), + outColor.a, + ); + } + } + } + + // Debug: defended radius boundary (based on defendedStrengthTex coverage). + if (drawDefendedRadius > 0.5 && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let borderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(borderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + + // Apply hover highlight if needed + if (highlightId > 0.5) { + let alpha = select(0.65, 0.0, altView > 0.5); + + if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) { + let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853); + let strength = 0.15 + 0.15 * pulse; + let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength); + outColor = vec4f(highlightedRgb, outColor.a); + } + } + + return outColor; +} diff --git a/src/client/vite-env.d.ts b/src/client/vite-env.d.ts index 83679d71c2..cd2c5fb6e1 100644 --- a/src/client/vite-env.d.ts +++ b/src/client/vite-env.d.ts @@ -44,3 +44,8 @@ declare module "*.svg?url" { const svgUrl: string; export default svgUrl; } + +declare module "*.wgsl?raw" { + const content: string; + export default content; +} diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 0f93a94f68..24bc4cdcb0 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -86,6 +86,14 @@ export class GameRunner { private isExecuting = false; private playerViewData: Record = {}; + /** + * Optional sink for tile state updates. When set, the runner avoids sending + * packed tile updates to the callback (to reduce transfer overhead) and + * instead forwards packed updates to the sink. + * + * Packed encoding: [tileRef << 16 | state] as bigint. + */ + public tileUpdateSink?: (packedTileUpdate: bigint) => void; constructor( public game: Game, @@ -112,6 +120,10 @@ export class GameRunner { this.turns.push(turn); } + public hasPendingTurns(): boolean { + return this.currTurn < this.turns.length; + } + public executeNextTick(): boolean { if (this.isExecuting) { return false; @@ -167,12 +179,21 @@ export class GameRunner { } // Many tiles are updated to pack it into an array - const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update); + const tileUpdates = updates[GameUpdateType.Tile]; + let packedTileUpdates: BigUint64Array; + if (this.tileUpdateSink) { + for (const u of tileUpdates) { + this.tileUpdateSink(u.update); + } + packedTileUpdates = new BigUint64Array(0); + } else { + packedTileUpdates = new BigUint64Array(tileUpdates.map((u) => u.update)); + } updates[GameUpdateType.Tile] = []; this.callBack({ tick: this.game.ticks(), - packedTileUpdates: new BigUint64Array(packedTileUpdates), + packedTileUpdates, updates: updates, playerNameViewData: this.playerViewData, tickExecutionDuration: tickExecutionDuration, diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index f291e11214..1e0069742a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -733,6 +733,12 @@ export interface Game extends GameMap { callback: (neighbor: TileRef) => void, ): void; + /** + * Optional hook for tile state changes. When set, tile mutations should call + * this instead of emitting GameUpdateType.Tile updates. + */ + onTileStateChanged?: (tile: TileRef) => void; + // Player Management player(id: PlayerID): Player; players(): Player[]; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6dd76beff8..01c745744e 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -84,6 +84,7 @@ export class GameImpl implements Game { private updates: GameUpdates = createGameUpdatesMap(); private unitGrid: UnitGrid; + public onTileStateChanged?: (tile: TileRef) => void; private playerTeams: Team[]; private botTeam: Team = ColoredTeams.Bot; @@ -234,6 +235,17 @@ export class GameImpl implements Game { (this.updates[update.type] as GameUpdate[]).push(update); } + private reportTileStateChanged(tile: TileRef): void { + if (this.onTileStateChanged) { + this.onTileStateChanged(tile); + return; + } + this.addUpdate({ + type: GameUpdateType.Tile, + update: this.toTileUpdate(tile), + }); + } + nextUnitID(): number { const old = this._nextUnitID; this._nextUnitID++; @@ -248,10 +260,7 @@ export class GameImpl implements Game { return; } this._map.setFallout(tile, value); - this.addUpdate({ - type: GameUpdateType.Tile, - update: this.toTileUpdate(tile), - }); + this.reportTileStateChanged(tile); } units(...types: UnitType[]): Unit[] { @@ -594,10 +603,7 @@ export class GameImpl implements Game { owner._lastTileChange = this._ticks; this.updateBorders(tile); this._map.setFallout(tile, false); - this.addUpdate({ - type: GameUpdateType.Tile, - update: this.toTileUpdate(tile), - }); + this.reportTileStateChanged(tile); } relinquish(tile: TileRef) { @@ -615,10 +621,7 @@ export class GameImpl implements Game { this._map.setOwnerID(tile, 0); this.updateBorders(tile); - this.addUpdate({ - type: GameUpdateType.Tile, - update: this.toTileUpdate(tile), - }); + this.reportTileStateChanged(tile); } private updateBorders(tile: TileRef) { @@ -836,7 +839,6 @@ export class GameImpl implements Game { playerID: id, }); } - addUnit(u: Unit) { this.unitGrid.addUnit(u); } @@ -952,11 +954,19 @@ export class GameImpl implements Game { return this._map.hasOwner(ref); } setOwnerID(ref: TileRef, playerId: number): void { - return this._map.setOwnerID(ref, playerId); + this._map.setOwnerID(ref, playerId); + this.reportTileStateChanged(ref); } hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + this._map.setDefended(ref, value); + this.reportTileStateChanged(ref); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } @@ -1015,6 +1025,12 @@ export class GameImpl implements Game { updateTile(tu: TileUpdate): TileRef { return this._map.updateTile(tu); } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 136fdf1d92..e83f6edb2b 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -27,6 +27,10 @@ export interface GameMap { setOwnerID(ref: TileRef, playerId: number): void; hasFallout(ref: TileRef): boolean; setFallout(ref: TileRef, value: boolean): void; + isDefended(ref: TileRef): boolean; + setDefended(ref: TileRef, value: boolean): void; + tileStateView(): Uint16Array; + terrainDataView(): Uint8Array; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -76,6 +80,7 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; + private static readonly DEFENDED_BIT = 12; private static readonly FALLOUT_BIT = 13; private static readonly DEFENSE_BONUS_BIT = 14; // Bit 15 still reserved @@ -211,6 +216,26 @@ export class GameMapImpl implements GameMap { } } + isDefended(ref: TileRef): boolean { + return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT)); + } + + setDefended(ref: TileRef, value: boolean): void { + if (value) { + this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT; + } else { + this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT); + } + } + + tileStateView(): Uint16Array { + return this.state; + } + + terrainDataView(): Uint8Array { + return this.terrain; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 15ce0d5648..2950fb8b17 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -880,6 +880,18 @@ export class GameView implements GameMap { setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index ba74b9ae8c..8536345d0e 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -4,6 +4,16 @@ import { PlayerPattern } from "../Schemas"; const PATTERN_KEY = "territoryPattern"; export class UserSettings { + getString(key: string, defaultValue: string): string { + const value = localStorage.getItem(key); + if (value === null) return defaultValue; + return value; + } + + setString(key: string, value: string): void { + localStorage.setItem(key, value); + } + get(key: string, defaultValue: boolean): boolean { const value = localStorage.getItem(key); if (!value) return defaultValue; @@ -33,6 +43,20 @@ export class UserSettings { localStorage.setItem(key, value.toString()); } + getInt(key: string, defaultValue: number): number { + const value = localStorage.getItem(key); + if (!value) return defaultValue; + + const intValue = parseInt(value, 10); + if (!Number.isFinite(intValue)) return defaultValue; + + return intValue; + } + + setInt(key: string, value: number): void { + localStorage.setItem(key, Math.trunc(value).toString()); + } + emojis() { return this.get("settings.emojis", true); } @@ -41,6 +65,19 @@ export class UserSettings { return this.get("settings.performanceOverlay", false); } + webgpuDebug(): boolean { + return this.get("settings.webgpuDebug", true); + } + + backgroundRenderer(): "webgpu" | "canvas2d" { + const raw = this.getString("settings.backgroundRenderer", "webgpu"); + return raw === "canvas2d" ? "canvas2d" : "webgpu"; + } + + setBackgroundRenderer(renderer: "webgpu" | "canvas2d"): void { + this.setString("settings.backgroundRenderer", renderer); + } + alertFrame() { return this.get("settings.alertFrame", true); } @@ -100,6 +137,10 @@ export class UserSettings { this.set("settings.performanceOverlay", !this.performanceOverlay()); } + toggleWebgpuDebug() { + this.set("settings.webgpuDebug", !this.webgpuDebug()); + } + toggleAlertFrame() { this.set("settings.alertFrame", !this.alertFrame()); } diff --git a/src/core/worker/DirtyTileQueue.ts b/src/core/worker/DirtyTileQueue.ts new file mode 100644 index 0000000000..c9bb886ca1 --- /dev/null +++ b/src/core/worker/DirtyTileQueue.ts @@ -0,0 +1,80 @@ +import { TileRef } from "../game/GameMap"; + +/** + * Worker-local deduping dirty-tile queue. + * + * Mirrors the SAB branch "dirtyFlags + ring buffer" idea, but without Atomics + * (single-threaded within the worker). + */ +export class DirtyTileQueue { + private readonly dirtyFlags: Uint8Array; + private readonly queue: Uint32Array; + private head = 0; + private tail = 0; + private size = 0; + + constructor( + numTiles: number, + private readonly capacity: number, + ) { + this.dirtyFlags = new Uint8Array(numTiles); + this.queue = new Uint32Array(capacity); + } + + /** + * Mark a tile dirty (idempotent until drained). + * + * Returns `false` if the queue overflows. + */ + mark(tile: TileRef): boolean { + const idx = tile as unknown as number; + if (idx < 0 || idx >= this.dirtyFlags.length) { + return true; + } + if (this.dirtyFlags[idx] === 1) { + return true; + } + if (this.size >= this.capacity) { + return false; + } + this.dirtyFlags[idx] = 1; + this.queue[this.tail] = idx >>> 0; + this.tail = (this.tail + 1) % this.capacity; + this.size++; + return true; + } + + /** + * Drain up to `maxCount` dirty tiles. + * + * Clears the dirty flag for each returned tile. + */ + drain(maxCount: number): TileRef[] { + const count = Math.min(maxCount, this.size); + if (count === 0) { + return []; + } + const out: TileRef[] = new Array(count); + for (let i = 0; i < count; i++) { + const idx = this.queue[this.head]; + this.head = (this.head + 1) % this.capacity; + this.size--; + this.dirtyFlags[idx] = 0; + out[i] = idx as unknown as TileRef; + } + return out; + } + + clear(): void { + if (this.size > 0) { + this.dirtyFlags.fill(0); + } + this.head = 0; + this.tail = 0; + this.size = 0; + } + + pendingCount(): number { + return this.size; + } +} diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts new file mode 100644 index 0000000000..8b5884b96c --- /dev/null +++ b/src/core/worker/GameViewAdapter.ts @@ -0,0 +1,548 @@ +import { Colord, colord } from "colord"; +import { Theme } from "../configuration/Config"; +import { UnitType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { + AllianceExpiredUpdate, + AllianceRequestReplyUpdate, + BrokeAllianceUpdate, + EmbargoUpdate, + GameUpdateType, + GameUpdateViewData, + PlayerUpdate, + UnitUpdate, +} from "../game/GameUpdates"; +import { GameView } from "../game/GameView"; +import { ClientID, PlayerCosmetics } from "../Schemas"; + +class DefensePostUnit { + public index = -1; + private readonly ownerView = { smallID: () => this.ownerSmallId }; + + constructor( + public readonly id: number, + private tileRef: TileRef, + private ownerSmallId: number, + ) {} + + isActive(): boolean { + return true; + } + + isUnderConstruction(): boolean { + return false; + } + + tile(): TileRef { + return this.tileRef; + } + + owner(): { smallID: () => number } { + return this.ownerView; + } + + set(tileRef: TileRef, ownerSmallId: number): void { + this.tileRef = tileRef; + this.ownerSmallId = ownerSmallId; + } +} + +class PlayerLiteView { + private readonly territoryRgba = { r: 0, g: 0, b: 0, a: 255 }; + private readonly borderRgba = { r: 0, g: 0, b: 0, a: 255 }; + private readonly territoryObj = { rgba: this.territoryRgba }; + private readonly borderObj = { rgba: this.borderRgba }; + + constructor( + private readonly adapter: GameViewAdapter, + public data: PlayerUpdate, + ) {} + + id(): string { + return this.data.id; + } + + smallID(): number { + return this.data.smallID; + } + + clientID(): ClientID | null { + return this.data.clientID; + } + + team(): any | null { + return this.data.team ?? null; + } + + type(): any { + return this.data.playerType; + } + + isPlayer(): boolean { + return true; + } + + territoryColor(): { rgba: { r: number; g: number; b: number; a: number } } { + this.ensureColors(); + return this.territoryObj; + } + + borderColor(): { rgba: { r: number; g: number; b: number; a: number } } { + this.ensureColors(); + return this.borderObj; + } + + hasEmbargoAgainst(other: PlayerLiteView): boolean { + return this.adapter.hasEmbargoPair(this.smallID(), other.smallID()); + } + + hasEmbargo(other: PlayerLiteView): boolean { + return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); + } + + isFriendly(other: PlayerLiteView): boolean { + const team = this.team(); + return ( + (team !== null && team === other.team()) || + this.adapter.hasFriendlyPair(this.smallID(), other.smallID()) + ); + } + + markColorsDirty(): void { + this.adapter.markPlayerColorsDirty(this.smallID()); + } + + private ensureColors(): void { + if (!this.adapter.consumePlayerColorsDirty(this.smallID())) { + return; + } + + const theme = this.adapter.getTheme(); + const defaultTerritoryColor = theme.territoryColor(this as any); + const defaultBorderColor = theme.borderColor(defaultTerritoryColor); + + const cosmetics = this.adapter.getCosmetics(this.clientID()); + const pattern = this.adapter.getPatternsEnabled() + ? cosmetics.pattern + : undefined; + if (pattern) { + (pattern as any).colorPalette ??= { + name: "", + primaryColor: defaultTerritoryColor.toHex(), + secondaryColor: defaultBorderColor.toHex(), + }; + } + + const territoryColor: Colord = + this.team() === null + ? colord( + cosmetics.color?.color ?? + (pattern as any)?.colorPalette?.primaryColor ?? + defaultTerritoryColor.toHex(), + ) + : defaultTerritoryColor; + + const maybeFocusedBorderColor = + this.adapter.getMyClientId() !== null && + this.clientID() === this.adapter.getMyClientId() + ? theme.focusedBorderColor() + : defaultBorderColor; + + const borderColor: Colord = colord( + (pattern as any)?.colorPalette?.secondaryColor ?? + cosmetics.color?.color ?? + maybeFocusedBorderColor.toHex(), + ); + + const tc = territoryColor.toRgb(); + this.territoryRgba.r = Math.round(tc.r); + this.territoryRgba.g = Math.round(tc.g); + this.territoryRgba.b = Math.round(tc.b); + this.territoryRgba.a = 255; + + const bc = borderColor.toRgb(); + this.borderRgba.r = Math.round(bc.r); + this.borderRgba.g = Math.round(bc.g); + this.borderRgba.b = Math.round(bc.b); + this.borderRgba.a = 255; + } +} + +/** + * 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 lastUpdate: GameUpdateViewData | null = null; + private patternsEnabled = false; + + private defensePostsDirty = true; + private readonly defensePostsById = new Map(); + private readonly defensePosts: DefensePostUnit[] = []; + + // "Dirty" here means "palette/relations roster may have changed" (not "any player field updated"). + private playersDirty = true; + private rosterDirty = true; + private readonly playersBySmallId = new Map(); + private playerViewsCache: PlayerLiteView[] = []; + private rosterEpoch = 1; + private playerViewsCacheEpoch = 0; + private playerColorsEpoch = 1; + private readonly playerColorsDirtyEpochBySmallId = new Map(); + private readonly embargoPairs = new Set(); + private readonly friendlyPairs = new Set(); + private relationsInitialized = false; + private readonly emptyCosmetics = {} as PlayerCosmetics; + + constructor( + private tileState: Uint16Array, + private terrainData: Uint8Array, + private readonly mapWidth: number, + private readonly mapHeight: number, + private theme: Theme, + private readonly myClientId: ClientID | null, + private readonly cosmeticsByClientID: Map, + ) { + void 0; + } + + getMyClientId(): ClientID | null { + return this.myClientId; + } + + getTheme(): Theme { + return this.theme; + } + + getPatternsEnabled(): boolean { + return this.patternsEnabled; + } + + getCosmetics(clientId: ClientID | null): PlayerCosmetics { + if (!clientId) { + return this.emptyCosmetics; + } + return this.cosmeticsByClientID.get(clientId) ?? this.emptyCosmetics; + } + + private static pairKey(a: number, b: number): bigint { + const lo = Math.min(a, b) >>> 0; + const hi = Math.max(a, b) >>> 0; + return (BigInt(lo) << 32n) | BigInt(hi); + } + + hasEmbargoPair(aSmallId: number, bSmallId: number): boolean { + return this.embargoPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId)); + } + + hasFriendlyPair(aSmallId: number, bSmallId: number): boolean { + return this.friendlyPairs.has(GameViewAdapter.pairKey(aSmallId, bSmallId)); + } + + markPlayerColorsDirty(smallId: number): void { + this.playerColorsDirtyEpochBySmallId.delete(smallId); + } + + consumePlayerColorsDirty(smallId: number): boolean { + const last = this.playerColorsDirtyEpochBySmallId.get(smallId) ?? 0; + if (last === this.playerColorsEpoch) { + return false; + } + this.playerColorsDirtyEpochBySmallId.set(smallId, this.playerColorsEpoch); + return true; + } + + private upsertDefensePost( + id: number, + tile: TileRef, + ownerSmallId: number, + ): void { + const existing = this.defensePostsById.get(id); + if (existing) { + if ( + existing.tile() !== tile || + existing.owner().smallID() !== ownerSmallId + ) { + existing.set(tile, ownerSmallId); + this.defensePostsDirty = true; + } + return; + } + + const unit = new DefensePostUnit(id, tile, ownerSmallId); + unit.index = this.defensePosts.length; + this.defensePosts.push(unit); + this.defensePostsById.set(id, unit); + this.defensePostsDirty = true; + } + + private removeDefensePost(id: number): void { + const existing = this.defensePostsById.get(id); + if (!existing) { + return; + } + + const idx = existing.index; + const last = this.defensePosts.pop(); + if (last && last !== existing) { + this.defensePosts[idx] = last; + last.index = idx; + } + this.defensePostsById.delete(id); + this.defensePostsDirty = true; + } + + consumeDefensePostsDirty(): boolean { + const dirty = this.defensePostsDirty; + this.defensePostsDirty = false; + return dirty; + } + + consumePlayersDirty(): boolean { + const dirty = this.playersDirty; + this.playersDirty = false; + return dirty; + } + + consumeRosterDirty(): boolean { + const dirty = this.rosterDirty; + this.rosterDirty = false; + return dirty; + } + + setPatternsEnabled(enabled: boolean): void { + if (this.patternsEnabled === enabled) { + return; + } + this.patternsEnabled = enabled; + this.playersDirty = true; + this.playerColorsEpoch++; + } + + /** + * Update adapter with latest game update data. + * Invalidates caches so they're recomputed on next access. + */ + update(gu: GameUpdateViewData): void { + this.lastUpdate = gu; + + const playerUpdates = (gu.updates?.[GameUpdateType.Player] ?? + []) as PlayerUpdate[]; + let rosterChanged = false; + let paletteRelevantChanged = false; + for (const p of playerUpdates) { + const small = p.smallID; + if (small <= 0) { + continue; + } + const existing = this.playersBySmallId.get(small); + if (existing) { + const prev = existing.data; + existing.data = p; + const teamChanged = (prev.team ?? null) !== (p.team ?? null); + const colorRelevantChanged = + teamChanged || + prev.clientID !== p.clientID || + prev.playerType !== p.playerType || + prev.isAlive !== p.isAlive || + prev.isDisconnected !== p.isDisconnected; + if (colorRelevantChanged) { + existing.markColorsDirty(); + paletteRelevantChanged = true; + } + if (teamChanged) { + // Team changes affect "friendly" relations matrix across many pairs. + // Treat it like a roster change to force a full relations rebuild. + rosterChanged = true; + } + } else { + this.playersBySmallId.set(small, new PlayerLiteView(this, p)); + rosterChanged = true; + paletteRelevantChanged = true; + } + } + + if (rosterChanged) { + this.rosterDirty = true; + this.rosterEpoch++; + } + if (rosterChanged || paletteRelevantChanged) { + this.playersDirty = true; + } + + const shouldRebuildRelationsSnapshot = + rosterChanged || (!this.relationsInitialized && playerUpdates.length > 0); + if (shouldRebuildRelationsSnapshot) { + // Rebuild relations snapshot from authoritative PlayerUpdate state. + // This ensures correct initial relations without relying on event history. + this.embargoPairs.clear(); + this.friendlyPairs.clear(); + + const idToSmall = new Map(); + for (const v of this.playersBySmallId.values()) { + idToSmall.set(v.data.id, v.data.smallID); + } + for (const v of this.playersBySmallId.values()) { + const a = v.data.smallID; + if (a <= 0) continue; + + for (const b of v.data.allies ?? []) { + if (typeof b === "number" && b > 0) { + this.friendlyPairs.add(GameViewAdapter.pairKey(a, b)); + } + } + + for (const otherId of v.data.embargoes ?? []) { + if (typeof otherId !== "string") continue; + const b = idToSmall.get(otherId) ?? 0; + if (b > 0) { + this.embargoPairs.add(GameViewAdapter.pairKey(a, b)); + } + } + } + + this.relationsInitialized = true; + } + + const embargoUpdates = (gu.updates?.[GameUpdateType.EmbargoEvent] ?? + []) as EmbargoUpdate[]; + for (const e of embargoUpdates) { + const key = GameViewAdapter.pairKey(e.playerID, e.embargoedID); + if (e.event === "start") { + this.embargoPairs.add(key); + } else { + this.embargoPairs.delete(key); + } + } + + const allianceReplies = (gu.updates?.[ + GameUpdateType.AllianceRequestReply + ] ?? []) as AllianceRequestReplyUpdate[]; + for (const e of allianceReplies) { + if (!e.accepted) { + continue; + } + this.friendlyPairs.add( + GameViewAdapter.pairKey(e.request.requestorID, e.request.recipientID), + ); + } + + const brokeAllianceUpdates = (gu.updates?.[GameUpdateType.BrokeAlliance] ?? + []) as BrokeAllianceUpdate[]; + for (const e of brokeAllianceUpdates) { + this.friendlyPairs.delete( + GameViewAdapter.pairKey(e.traitorID, e.betrayedID), + ); + } + + const expiredUpdates = (gu.updates?.[GameUpdateType.AllianceExpired] ?? + []) as AllianceExpiredUpdate[]; + for (const e of expiredUpdates) { + this.friendlyPairs.delete( + GameViewAdapter.pairKey(e.player1ID, e.player2ID), + ); + } + + const unitUpdates = (gu.updates?.[GameUpdateType.Unit] ?? + []) as UnitUpdate[]; + for (const u of unitUpdates) { + if (u.unitType !== UnitType.DefensePost) { + continue; + } + + const removed = + u.markedForDeletion !== false || + !u.isActive || + u.underConstruction === true; + if (removed) { + this.removeDefensePost(u.id); + } else { + this.upsertDefensePost(u.id, u.pos, u.ownerID); + } + } + } + + width(): number { + return this.mapWidth; + } + + height(): number { + return this.mapHeight; + } + + x(tile: TileRef): number { + return tile % this.mapWidth; + } + + y(tile: TileRef): number { + return (tile / this.mapWidth) | 0; + } + + playerBySmallID(smallId: number): any | null { + return this.playersBySmallId.get(smallId) ?? null; + } + + units(...types: UnitType[]): any[] { + if (types.length === 1 && types[0] === UnitType.DefensePost) { + return this.defensePosts; + } + return []; + } + + /** + * Return the authoritative tile state view. + * + * Important: this must be the live backing buffer, because GPU update passes + * read from it when individual tiles are marked dirty. + */ + tileStateView(): Uint16Array { + return this.tileState; + } + + /** + * Return the immutable terrain data view. + */ + terrainDataView(): Uint8Array { + return this.terrainData; + } + + /** + * Convert Game players to PlayerView-like objects for rendering. + * + * Important: this must match the *main-thread* PlayerView color selection, + * otherwise the worker-rendered territory will disagree with UI. + */ + playerViews(): any[] { + if (this.playerViewsCacheEpoch !== this.rosterEpoch) { + this.playerViewsCache = [...this.playersBySmallId.values()]; + this.playerViewsCacheEpoch = this.rosterEpoch; + } + return this.playerViewsCache; + } + + /** + * 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(): TileRef[] { + if (!this.lastUpdate) { + return []; + } + // packedTileUpdates encode [tileRef << 16 | state] as bigint. + const packed = this.lastUpdate.packedTileUpdates; + const out: TileRef[] = new Array(packed.length); + for (let i = 0; i < packed.length; i++) { + out[i] = Number(packed[i] >> 16n); + } + return out; + } +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 31fd3f1362..fad19fa85a 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,7 +1,23 @@ 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 { PlayerID } from "../game/Game"; +import { + AllianceExpiredUpdate, + AllianceRequestReplyUpdate, + BrokeAllianceUpdate, + EmbargoUpdate, + ErrorUpdate, + GameUpdateType, + GameUpdateViewData, +} from "../game/GameUpdates"; + +import { createGameRunner, GameRunner } from "../GameRunner"; +import { ClientID, GameStartInfo, PlayerCosmetics, Turn } from "../Schemas"; +import { DirtyTileQueue } from "./DirtyTileQueue"; +import { WorkerCanvas2DRenderer } from "./WorkerCanvas2DRenderer"; import { AttackAveragePositionResultMessage, InitializedMessage, @@ -9,20 +25,149 @@ import { PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + RenderDoneMessage, + RendererReadyMessage, + TileContextResultMessage, TransportShipSpawnResultMessage, WorkerMessage, } from "./WorkerMessages"; +import { WorkerProfiler } from "./WorkerProfiler"; +import { WorkerTerritoryRenderer } from "./WorkerTerritoryRenderer"; const ctx: Worker = self as any; let gameRunner: Promise | null = null; +let gameStartInfo: GameStartInfo | null = null; +let myClientID: ClientID | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); -const MAX_TICKS_PER_HEARTBEAT = 4; +let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null; +let dirtyTiles: DirtyTileQueue | null = null; +let dirtyTilesOverflow = false; +let renderTileState: Uint16Array | null = null; +const pendingTurns: Turn[] = []; + +const profiler = new WorkerProfiler(sendMessage); + +let simPumpScheduled = false; + +function scheduleSimPump(): void { + if (simPumpScheduled) { + return; + } + simPumpScheduled = true; + const scheduledAtWallMs = Date.now(); + setTimeout(async () => { + simPumpScheduled = false; + if (!gameRunner) { + return; + } + + const gr = await gameRunner; + profiler.recordSimDelay(Date.now() - scheduledAtWallMs); + const execStart = performance.now(); + if (pendingTurns.length > 0) { + // Drain turns into GameRunner's queue in chunks so we don't block + // the worker event loop for too long (important for Firefox). + const maxDrain = 256; + for (let i = 0; i < maxDrain && pendingTurns.length > 0; i++) { + const t = pendingTurns.shift(); + if (t) { + gr.addTurn(t); + } + } + } + gr.executeNextTick(); + profiler.recordSimExec(performance.now() - execStart); + if (pendingTurns.length > 0 || gr.hasPendingTurns()) { + scheduleSimPump(); + } + }, 0); +} function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate if (!("updates" in gu)) { return; } + + // Keep renderer-side adapter in sync (palette/relations/etc). + const viewUpdateDidWork = (renderer as any)?.updateGameView?.(gu) === true; + + // Uploading relations is expensive; only refresh when diplomacy changes, + // and only for the affected player pairs. + const updates = gu.updates; + let relationsChanged = false; + if (renderer) { + const markPair = (aSmallId: number, bSmallId: number) => { + const r: any = renderer as any; + if (r?.markRelationsPairDirty) { + r.markRelationsPairDirty(aSmallId, bSmallId); + relationsChanged = true; + } else if (r?.markRelationsDirty) { + // Fallback for older/other renderers. + r.markRelationsDirty(); + relationsChanged = true; + } + }; + + for (const e of updates[GameUpdateType.EmbargoEvent] as EmbargoUpdate[]) { + markPair(e.playerID, e.embargoedID); + } + for (const e of updates[ + GameUpdateType.AllianceRequestReply + ] as AllianceRequestReplyUpdate[]) { + if (e.accepted) { + markPair(e.request.requestorID, e.request.recipientID); + } + } + for (const e of updates[ + GameUpdateType.BrokeAlliance + ] as BrokeAllianceUpdate[]) { + markPair(e.traitorID, e.betrayedID); + } + for (const e of updates[ + GameUpdateType.AllianceExpired + ] as AllianceExpiredUpdate[]) { + markPair(e.player1ID, e.player2ID); + } + } + + // Flush simulation-derived dirty tiles into the renderer before running + // compute passes for this tick. + if (renderer && dirtyTiles) { + let didWork = false; + if (viewUpdateDidWork) { + didWork = true; + } + if (relationsChanged) { + didWork = true; + } + if (dirtyTilesOverflow) { + dirtyTilesOverflow = false; + dirtyTiles.clear(); + renderer.markAllDirty(); + didWork = true; + } else { + const pending = dirtyTiles.pendingCount(); + if (pending > 0) { + const tiles = dirtyTiles.drain(pending); + for (const tile of tiles) { + renderer.markTile(tile); + } + didWork = true; + } + } + + // Run compute passes at simulation tick cadence (not at render FPS). + if (didWork) { + const r: any = renderer as any; + if (typeof r.requestTick === "function") { + r.requestTick(); + } else { + renderer.tick(); + } + } + } + sendMessage({ type: "game_update", gameUpdate: gu, @@ -35,154 +180,537 @@ function sendMessage(message: WorkerMessage) { ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; + const queueMs = + typeof (message as any).sentAtWallMs === "number" + ? Date.now() - (message as any).sentAtWallMs + : null; + const handlerStart = performance.now(); + + try { + switch (message.type) { + case "set_worker_debug": + profiler.configure({ + enabled: message.enabled, + intervalMs: message.intervalMs, + includeTrace: message.includeTrace, + }); + break; + case "heartbeat": + // Heartbeat is a high-frequency "wake up" signal from the main thread. + // Coalesce it and run simulation work in small slices to avoid backlog. + scheduleSimPump(); + break; + case "init": + try { + gameStartInfo = message.gameStartInfo; + myClientID = message.clientID; + gameRunner = createGameRunner( + message.gameStartInfo, + message.clientID, + mapLoader, + gameUpdate, + ).then((gr) => { + const numTiles = gr.game.width() * gr.game.height(); + // Capacity is bounded; on overflow we fall back to markAllDirty(). + dirtyTiles = new DirtyTileQueue(numTiles, Math.max(4096, numTiles)); + dirtyTilesOverflow = false; + renderTileState = gr.game.tileStateView(); + + gr.game.onTileStateChanged = (tile) => { + if (!dirtyTiles) { + return; + } + if (dirtyTilesOverflow) { + return; + } + + const mark = (t: any) => { + if (!dirtyTiles!.mark(t)) { + dirtyTilesOverflow = true; + } + }; + mark(tile); + gr.game.forEachNeighbor(tile, (n) => mark(n)); + }; - switch (message.type) { - case "heartbeat": { - const gr = await gameRunner; - if (!gr) { + sendMessage({ + type: "initialized", + id: message.id, + } as InitializedMessage); + return gr; + }); + } catch (error) { + console.error("Failed to initialize game runner:", error); + throw error; + } break; - } - const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT); - for (let i = 0; i < ticksToRun; i++) { - if (!gr.executeNextTick()) { - break; + + case "turn": + if (!gameRunner) { + throw new Error("Game runner not initialized"); } - } - break; - } - case "init": - try { - gameRunner = createGameRunner( - message.gameStartInfo, - message.clientID, - mapLoader, - gameUpdate, - ).then((gr) => { + + try { + pendingTurns.push(message.turn); + scheduleSimPump(); + } catch (error) { + console.error("Failed to process turn:", error); + throw error; + } + break; + + case "turn_batch": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + pendingTurns.push(...message.turns); + scheduleSimPump(); + } catch (error) { + console.error("Failed to process turn batch:", error); + throw error; + } + break; + + case "tile_context": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + try { + const gr = await gameRunner; + const tile = message.tile; + const hasOwner = gr.game.hasOwner(tile); + const ownerSmallId = hasOwner ? gr.game.ownerID(tile) : null; + let ownerId: PlayerID | null = null; + if (hasOwner) { + const owner = gr.game.owner(tile); + ownerId = owner && owner.isPlayer() ? owner.id() : null; + } sendMessage({ - type: "initialized", + type: "tile_context_result", id: message.id, - } as InitializedMessage); - return gr; - }); - } catch (error) { - console.error("Failed to initialize game runner:", error); - throw error; - } - break; + result: { + hasOwner, + ownerSmallId, + ownerId, + hasFallout: gr.game.hasFallout(tile), + isDefended: gr.game.isDefended(tile), + }, + } as TileContextResultMessage); + } catch (error) { + console.error("Failed to fetch tile context:", error); + throw error; + } + break; - case "turn": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + case "player_actions": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } - try { - const gr = await gameRunner; - await gr.addTurn(message.turn); - } catch (error) { - console.error("Failed to process turn:", error); - throw error; - } - break; + try { + const actions = (await gameRunner).playerActions( + message.playerID, + message.x, + message.y, + ); + sendMessage({ + type: "player_actions_result", + id: message.id, + result: actions, + } as PlayerActionsResultMessage); + } catch (error) { + console.error("Failed to check borders:", error); + throw error; + } + break; + case "player_profile": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } - case "player_actions": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + try { + const profile = (await gameRunner).playerProfile(message.playerID); + sendMessage({ + type: "player_profile_result", + id: message.id, + result: profile, + } as PlayerProfileResultMessage); + } catch (error) { + console.error("Failed to check borders:", error); + throw error; + } + break; + case "player_border_tiles": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } - try { - const actions = (await gameRunner).playerActions( - message.playerID, - message.x, - message.y, - ); - sendMessage({ - type: "player_actions_result", - id: message.id, - result: actions, - } as PlayerActionsResultMessage); - } catch (error) { - console.error("Failed to check borders:", error); - throw error; - } - break; - case "player_profile": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + try { + const borderTiles = (await gameRunner).playerBorderTiles( + message.playerID, + ); + sendMessage({ + type: "player_border_tiles_result", + id: message.id, + result: borderTiles, + } as PlayerBorderTilesResultMessage); + } catch (error) { + console.error("Failed to get border tiles:", error); + throw error; + } + break; + case "attack_average_position": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } - try { - const profile = (await gameRunner).playerProfile(message.playerID); - sendMessage({ - type: "player_profile_result", - id: message.id, - result: profile, - } as PlayerProfileResultMessage); - } catch (error) { - console.error("Failed to check borders:", error); - throw error; - } - break; - case "player_border_tiles": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + try { + const averagePosition = (await gameRunner).attackAveragePosition( + message.playerID, + message.attackID, + ); + sendMessage({ + type: "attack_average_position_result", + id: message.id, + x: averagePosition ? averagePosition.x : null, + y: averagePosition ? averagePosition.y : null, + } as AttackAveragePositionResultMessage); + } catch (error) { + console.error("Failed to get attack average position:", error); + throw error; + } + break; + case "transport_ship_spawn": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } - try { - const borderTiles = (await gameRunner).playerBorderTiles( - message.playerID, - ); - sendMessage({ - type: "player_border_tiles_result", - id: message.id, - result: borderTiles, - } as PlayerBorderTilesResultMessage); - } catch (error) { - console.error("Failed to get border tiles:", error); - throw error; - } - break; - case "attack_average_position": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + try { + const spawnTile = (await gameRunner).bestTransportShipSpawn( + message.playerID, + message.targetTile, + ); + sendMessage({ + type: "transport_ship_spawn_result", + id: message.id, + result: spawnTile, + } as TransportShipSpawnResultMessage); + } catch (error) { + console.error("Failed to spawn transport ship:", error); + } + break; - try { - const averagePosition = (await gameRunner).attackAveragePosition( - message.playerID, - message.attackID, - ); - sendMessage({ - type: "attack_average_position_result", - id: message.id, - x: averagePosition ? averagePosition.x : null, - y: averagePosition ? averagePosition.y : null, - } as AttackAveragePositionResultMessage); - } catch (error) { - console.error("Failed to get attack average position:", error); - throw error; - } - break; - case "transport_ship_spawn": - if (!gameRunner) { - throw new Error("Game runner not initialized"); - } + case "init_renderer": + try { + if (!gameRunner || !gameStartInfo) { + throw new Error("Game runner not initialized"); + } + const gr = await gameRunner; - try { - const spawnTile = (await gameRunner).bestTransportShipSpawn( - message.playerID, - message.targetTile, - ); - sendMessage({ - type: "transport_ship_spawn_result", - id: message.id, - result: spawnTile, - } as TransportShipSpawnResultMessage); - } catch (error) { - console.error("Failed to spawn transport ship:", error); - } - break; - default: - console.warn("Unknown message :", message); + (renderer as any)?.dispose?.(); + renderer = null; + + // 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(); + + const cosmeticsByClientID = new Map(); + for (const p of gameStartInfo.players) { + cosmeticsByClientID.set( + p.clientID, + (p.cosmetics ?? {}) as PlayerCosmetics, + ); + } + + const backend = message.backend ?? "webgpu"; + renderer = + backend === "canvas2d" + ? new WorkerCanvas2DRenderer() + : new WorkerTerritoryRenderer(); + + renderTileState ??= gr.game.tileStateView(); + await renderer.init( + message.offscreenCanvas, + gr, + theme, + myClientID, + cosmeticsByClientID, + renderTileState, + ); + + sendMessage({ + type: "renderer_ready", + id: message.id, + ok: true, + } as RendererReadyMessage); + } catch (error) { + console.error("Failed to initialize renderer:", error); + sendMessage({ + type: "renderer_ready", + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + } as RendererReadyMessage); + renderer = null; + } + break; + + case "set_patterns_enabled": + if (renderer) { + renderer.setPatternsEnabled(message.enabled); + const r: any = renderer as any; + if (typeof r.requestTick === "function") { + r.requestTick(); + } else { + renderer.tick(); + } + } + break; + + case "set_palette": + if (renderer) { + renderer.setPaletteFromBytes( + message.paletteWidth, + message.maxSmallId, + message.row0, + message.row1, + ); + const r: any = renderer as any; + if (typeof r.requestTick === "function") { + r.requestTick(); + } else { + renderer.tick(); + } + } + 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) { + const r: any = renderer as any; + if (message.territoryShader) { + r.setTerritoryShader?.(message.territoryShader); + } + if ( + message.territoryShaderParams0 && + message.territoryShaderParams1 + ) { + r.setTerritoryShaderParams?.( + message.territoryShaderParams0, + message.territoryShaderParams1, + ); + } + if (message.terrainShader) { + r.setTerrainShader?.(message.terrainShader); + } + if (message.terrainShaderParams0 && message.terrainShaderParams1) { + r.setTerrainShaderParams?.( + message.terrainShaderParams0, + message.terrainShaderParams1, + ); + } + if (message.preSmoothing) { + r.setPreSmoothing?.( + message.preSmoothing.enabled, + message.preSmoothing.shaderPath, + message.preSmoothing.params0, + ); + } + if (message.postSmoothing) { + r.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(); + const r: any = renderer as any; + if (typeof r.requestTick === "function") { + r.requestTick(); + } else { + renderer.tick(); + } + } + break; + + case "refresh_palette": + if (renderer) { + renderer.refreshPalette(); + const r: any = renderer as any; + if (typeof r.requestTick === "function") { + r.requestTick(); + } else { + renderer.tick(); + } + } + 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) { + const id = message.id; + const startedAt = performance.now(); + const startedAtWallMs = Date.now(); + let renderWaitPrevGpuMs: number | undefined; + let renderCpuMs: number | undefined; + let renderGetTextureMs: number | undefined; + let renderGpuWaitMs: number | undefined; + let renderWaitPrevGpuTimedOut: boolean | undefined; + let renderGpuWaitTimedOut: boolean | undefined; + let renderSubmitted: boolean | undefined; + let renderFrameComputeMs: number | undefined; + let renderTerritoryPassMs: number | undefined; + let renderTemporalResolveMs: number | undefined; + let renderSubmitMs: number | undefined; + let renderCpuTotalMs: number | undefined; + try { + if ("viewSize" in message && message.viewSize) { + renderer.setViewSize( + message.viewSize.width, + message.viewSize.height, + ); + } + if ("viewTransform" in message && message.viewTransform) { + renderer.setViewTransform( + message.viewTransform.scale, + message.viewTransform.offsetX, + message.viewTransform.offsetY, + ); + } + const r: any = renderer as any; + if (typeof r.renderAsync === "function") { + const breakdown = await r.renderAsync(!!profiler.config.enabled); + if (breakdown) { + renderWaitPrevGpuMs = breakdown.waitPrevGpuMs; + renderCpuMs = breakdown.cpuMs; + renderGetTextureMs = breakdown.getTextureMs; + renderGpuWaitMs = breakdown.gpuWaitMs; + renderWaitPrevGpuTimedOut = breakdown.waitPrevGpuTimedOut; + renderGpuWaitTimedOut = breakdown.gpuWaitTimedOut; + renderSubmitted = breakdown.submitted; + renderFrameComputeMs = breakdown.frameComputeMs; + renderTerritoryPassMs = breakdown.territoryPassMs; + renderTemporalResolveMs = breakdown.temporalResolveMs; + renderSubmitMs = breakdown.submitMs; + renderCpuTotalMs = breakdown.cpuTotalMs; + } + } else { + renderer.render(); + } + } catch (error) { + console.error("render_frame failed:", error); + } finally { + const endedAt = performance.now(); + const endedAtWallMs = Date.now(); + if (id) { + if (typeof renderSubmitted === "boolean") { + profiler.recordRenderBreakdown({ + submitted: renderSubmitted, + getTextureMs: renderGetTextureMs, + frameComputeMs: renderFrameComputeMs, + territoryPassMs: renderTerritoryPassMs, + temporalResolveMs: renderTemporalResolveMs, + submitMs: renderSubmitMs, + cpuTotalMs: renderCpuTotalMs, + }); + } + sendMessage({ + type: "render_done", + id, + sentAtWallMs: + typeof (message as any).sentAtWallMs === "number" + ? (message as any).sentAtWallMs + : undefined, + startedAtWallMs, + endedAtWallMs, + startedAt, + endedAt, + renderWaitPrevGpuMs, + renderCpuMs, + renderGetTextureMs, + renderGpuWaitMs, + renderWaitPrevGpuTimedOut, + renderGpuWaitTimedOut, + renderSubmitted, + renderFrameComputeMs, + renderTerritoryPassMs, + renderTemporalResolveMs, + renderSubmitMs, + renderCpuTotalMs, + } as RenderDoneMessage); + } + } + } + break; + + default: + console.warn("Unknown message :", message); + } + } finally { + profiler.recordMessage( + (message as any).type ?? "unknown", + queueMs, + performance.now() - handlerStart, + ); } }); diff --git a/src/core/worker/WorkerCanvas2DRenderer.ts b/src/core/worker/WorkerCanvas2DRenderer.ts new file mode 100644 index 0000000000..b8a8f029ef --- /dev/null +++ b/src/core/worker/WorkerCanvas2DRenderer.ts @@ -0,0 +1,554 @@ +import { Theme } from "../configuration/Config"; +import { PastelTheme } from "../configuration/PastelTheme"; +import { PastelThemeDark } from "../configuration/PastelThemeDark"; +import { TileRef } from "../game/GameMap"; +import { GameUpdateViewData } from "../game/GameUpdates"; +import { GameRunner } from "../GameRunner"; +import { ClientID, PlayerCosmetics } from "../Schemas"; +import { GameViewAdapter } from "./GameViewAdapter"; + +type Offscreen2D = OffscreenCanvasRenderingContext2D; + +export class WorkerCanvas2DRenderer { + private canvas: OffscreenCanvas | null = null; + private ctx: Offscreen2D | null = null; + + private rasterCanvas: OffscreenCanvas | null = null; + private rasterCtx: Offscreen2D | null = null; + private rasterImage: ImageData | null = null; + private terrainBaseRgba: Uint8Array | null = null; + private tileState: Uint16Array | null = null; + + private gameViewAdapter: GameViewAdapter | null = null; + private gameRunner: GameRunner | null = null; + private theme: Theme | null = null; + + private ready = false; + private mapWidth = 1; + private mapHeight = 1; + + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + + private readonly chunkSize = 64; + private chunksX = 1; + private chunksY = 1; + + private dirtyChunkFlags: Uint8Array = new Uint8Array(0); + private dirtyChunkQueue: Uint32Array = new Uint32Array(0); + private dirtyHead = 0; + private dirtyTail = 0; + private dirtyCapacity = 0; + + private paletteWidth = 1; + private paletteMaxSmallId = 0; + private paletteRow0: Uint8Array = new Uint8Array(4); + private paletteRow1: Uint8Array = new Uint8Array(4); + private hasExternalPalette = false; + + async init( + offscreenCanvas: OffscreenCanvas, + gameRunner: GameRunner, + theme: Theme, + myClientID: ClientID | null, + cosmeticsByClientID: Map, + tileState: Uint16Array, + ): Promise { + this.canvas = offscreenCanvas; + this.ctx = offscreenCanvas.getContext("2d", { alpha: true }) as Offscreen2D; + if (!this.ctx) { + throw new Error("Failed to get 2D context for OffscreenCanvas"); + } + + this.gameRunner = gameRunner; + this.theme = theme; + this.tileState = tileState; + + const mapW = gameRunner.game.width(); + const mapH = gameRunner.game.height(); + this.mapWidth = mapW; + this.mapHeight = mapH; + + this.gameViewAdapter = new GameViewAdapter( + tileState, + gameRunner.game.terrainDataView(), + gameRunner.game.width(), + gameRunner.game.height(), + theme, + myClientID, + cosmeticsByClientID, + ); + + this.rasterCanvas = new OffscreenCanvas(mapW, mapH); + this.rasterCtx = this.rasterCanvas.getContext("2d", { + alpha: true, + willReadFrequently: true, + }) as Offscreen2D; + if (!this.rasterCtx) { + throw new Error("Failed to get 2D context for raster canvas"); + } + + this.rasterImage = new ImageData(mapW, mapH); + + this.chunksX = Math.ceil(mapW / this.chunkSize); + this.chunksY = Math.ceil(mapH / this.chunkSize); + const numChunks = this.chunksX * this.chunksY; + + this.dirtyChunkFlags = new Uint8Array(numChunks); + // Chunk queue sized so markAllDirty() can enqueue every chunk. + this.dirtyCapacity = Math.max(1024, numChunks + 1); + this.dirtyChunkQueue = new Uint32Array(this.dirtyCapacity); + this.dirtyHead = 0; + this.dirtyTail = 0; + + this.ready = true; + + // First paint. + this.rebuildPaletteFromGame(); + this.rebuildTerrainBase(); + this.markAllDirty(); + this.tick(); + } + + updateGameView(gu: GameUpdateViewData): boolean { + if (!this.gameViewAdapter) { + return false; + } + this.gameViewAdapter.update(gu); + const playersDirty = this.gameViewAdapter.consumePlayersDirty(); + if (playersDirty && !this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + this.markAllDirty(); + return true; + } + return false; + } + + dispose(): void { + this.ready = false; + this.canvas = null; + this.ctx = null; + this.rasterCanvas = null; + this.rasterCtx = null; + this.rasterImage = null; + this.terrainBaseRgba = null; + this.tileState = null; + this.gameViewAdapter = null; + this.gameRunner = null; + this.theme = null; + this.mapWidth = 1; + this.mapHeight = 1; + this.dirtyChunkFlags = new Uint8Array(0); + this.dirtyChunkQueue = new Uint32Array(0); + this.dirtyHead = 0; + this.dirtyTail = 0; + this.dirtyCapacity = 0; + } + + setViewSize(width: number, height: number): void { + if (!this.canvas) return; + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + if (this.canvas.width === nextWidth && this.canvas.height === nextHeight) { + return; + } + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + } + + setAlternativeView(_enabled: boolean): void {} + setHighlightedOwnerId(_ownerSmallId: number | null): void {} + setPatternsEnabled(enabled: boolean): void { + this.gameViewAdapter?.setPatternsEnabled(enabled); + // Patterns affect colours; simplest is a full repaint. + if (!this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + } + this.markAllDirty(); + } + + setPaletteFromBytes( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + this.paletteWidth = paletteWidth; + this.paletteMaxSmallId = maxSmallId; + this.paletteRow0 = row0; + this.paletteRow1 = row1; + this.hasExternalPalette = true; + this.markAllDirty(); + } + + refreshPalette(): void { + if (!this.hasExternalPalette) { + this.rebuildPaletteFromGame(); + } + this.markAllDirty(); + } + + refreshTerrain(): void { + this.rebuildTerrainBase(); + this.markAllDirty(); + } + + markTile(tile: TileRef): void { + if (!this.ready) return; + // TileRef is a linear index (y * width + x). + const x = tile % this.mapWidth; + const y = (tile / this.mapWidth) | 0; + this.markChunkAt(x, y); + } + + markAllDirty(): void { + if (!this.ready) return; + this.dirtyChunkFlags.fill(0); + this.dirtyHead = 0; + this.dirtyTail = 0; + const numChunks = this.dirtyChunkFlags.length; + for (let i = 0; i < numChunks; i++) { + this.enqueueChunk(i); + } + } + + tick(): void { + if ( + !this.ready || + !this.gameRunner || + !this.theme || + !this.gameViewAdapter || + !this.rasterCtx || + !this.rasterImage || + !this.terrainBaseRgba + ) { + return; + } + + const mapW = this.mapWidth; + const mapH = this.mapHeight; + const out = this.rasterImage.data; + const base = this.terrainBaseRgba; + const state = this.tileState; + if (!state) { + return; + } + const row0 = this.paletteRow0; + const maxSmallId = this.paletteMaxSmallId; + + const falloutR = row0[0] ?? 120; + const falloutG = row0[1] ?? 255; + const falloutB = row0[2] ?? 71; + const ownerMask = 0xfff; + const falloutBit = 0x2000; + + const mix65 = (a: number, b: number): number => + ((a * 35 + b * 65 + 50) / 100) | 0; + const mix50 = (a: number, b: number): number => (a + b + 1) >> 1; + + const budgetMs = 6; + const start = performance.now(); + + while (this.dirtyHead !== this.dirtyTail) { + if (performance.now() - start > budgetMs) { + break; + } + + const chunkId = this.dirtyChunkQueue[this.dirtyHead]; + this.dirtyHead = (this.dirtyHead + 1) % this.dirtyCapacity; + this.dirtyChunkFlags[chunkId] = 0; + + const cx = chunkId % this.chunksX; + const cy = Math.floor(chunkId / this.chunksX); + const sx = cx * this.chunkSize; + const sy = cy * this.chunkSize; + const ex = Math.min(mapW, sx + this.chunkSize); + const ey = Math.min(mapH, sy + this.chunkSize); + + for (let y = sy; y < ey; y++) { + const row = y * mapW; + for (let x = sx; x < ex; x++) { + const tile = row + x; + const s = state[tile]; + const owner = s & ownerMask; + const hasFallout = (s & falloutBit) !== 0; + + const p = tile * 4; + const tr = base[p]; + const tg = base[p + 1]; + const tb = base[p + 2]; + + // Fast path: terrain only. + if (owner === 0 && !hasFallout) { + out[p] = tr; + out[p + 1] = tg; + out[p + 2] = tb; + out[p + 3] = 255; + continue; + } + + let r = tr; + let g = tg; + let b = tb; + + if (owner !== 0) { + // Player colors start at slot 10. + if (owner <= maxSmallId) { + const idx = (10 + owner) * 4; + if (idx + 2 < row0.length) { + let pr = row0[idx]; + let pg = row0[idx + 1]; + let pb = row0[idx + 2]; + + if (hasFallout) { + pr = mix50(pr, falloutR); + pg = mix50(pg, falloutG); + pb = mix50(pb, falloutB); + } + + r = mix65(tr, pr); + g = mix65(tg, pg); + b = mix65(tb, pb); + } + } + } else if (hasFallout) { + r = mix50(tr, falloutR); + g = mix50(tg, falloutG); + b = mix50(tb, falloutB); + } + + out[p] = r; + out[p + 1] = g; + out[p + 2] = b; + out[p + 3] = 255; + } + } + + this.rasterCtx.putImageData( + this.rasterImage, + 0, + 0, + sx, + sy, + ex - sx, + ey - sy, + ); + } + } + + render(): void { + if (!this.ready || !this.ctx || !this.gameRunner || !this.rasterCanvas) { + return; + } + + const w = (this.canvas?.width ?? 1) as number; + const h = (this.canvas?.height ?? 1) as number; + + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, w, h); + this.ctx.imageSmoothingEnabled = false; + + const scale = this.viewScale; + this.ctx.setTransform( + scale, + 0, + 0, + scale, + this.gameRunner.game.width() / 2 - this.viewOffsetX * scale, + this.gameRunner.game.height() / 2 - this.viewOffsetY * scale, + ); + + this.ctx.drawImage( + this.rasterCanvas, + -this.gameRunner.game.width() / 2, + -this.gameRunner.game.height() / 2, + ); + } + + private markChunkAt(x: number, y: number): void { + const cx = Math.floor(x / this.chunkSize); + const cy = Math.floor(y / this.chunkSize); + if (cx < 0 || cy < 0 || cx >= this.chunksX || cy >= this.chunksY) { + return; + } + const chunkId = cx + cy * this.chunksX; + this.enqueueChunk(chunkId); + } + + private enqueueChunk(chunkId: number): void { + if (this.dirtyChunkFlags[chunkId] === 1) { + return; + } + this.dirtyChunkFlags[chunkId] = 1; + this.dirtyChunkQueue[this.dirtyTail] = chunkId; + this.dirtyTail = (this.dirtyTail + 1) % this.dirtyCapacity; + if (this.dirtyTail === this.dirtyHead) { + // Overflow: fall back to repaint everything next tick. + this.markAllDirty(); + } + } + + private rebuildPaletteFromGame(): void { + if (!this.gameViewAdapter) { + return; + } + + let maxSmallId = 0; + const players = this.gameViewAdapter.playerViews(); + for (const p of players) { + maxSmallId = Math.max(maxSmallId, p.smallID()); + } + + const RESERVED = 10; + this.paletteMaxSmallId = maxSmallId; + this.paletteWidth = RESERVED + Math.max(1, maxSmallId + 1); + const rowStride = this.paletteWidth * 4; + + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + row0[0] = 120; + row0[1] = 255; + row0[2] = 71; + row0[3] = 255; + + for (const p of players) { + const id = p.smallID(); + if (id <= 0) continue; + const idx = (RESERVED + id) * 4; + + const tr = p.territoryColor().rgba; + row0[idx] = tr.r; + row0[idx + 1] = tr.g; + row0[idx + 2] = tr.b; + row0[idx + 3] = 255; + + const br = p.borderColor().rgba; + row1[idx] = br.r; + row1[idx + 1] = br.g; + row1[idx + 2] = br.b; + row1[idx + 3] = 255; + } + + this.paletteRow0 = row0; + this.paletteRow1 = row1; + this.hasExternalPalette = false; + } + + private rebuildTerrainBase(): void { + if (!this.gameRunner || !this.theme || !this.rasterImage) { + return; + } + + const mapW = this.mapWidth; + const mapH = this.mapHeight; + const numTiles = mapW * mapH; + const terrain = this.gameRunner.game.terrainDataView(); + const base = new Uint8Array(numTiles * 4); + + const isDark = this.theme instanceof PastelThemeDark; + const isPastel = + this.theme instanceof PastelTheme || + this.theme instanceof PastelThemeDark; + + if (isPastel) { + // Decode terrain directly from packed terrain bytes (fast, no allocations). + const shoreR = isDark ? 134 : 204; + const shoreG = isDark ? 133 : 203; + const shoreB = isDark ? 88 : 158; + + const shorelineWaterR = isDark ? 50 : 100; + const shorelineWaterG = isDark ? 50 : 143; + const shorelineWaterB = isDark ? 50 : 255; + + const waterBaseR = isDark ? 14 : 70; + const waterBaseG = isDark ? 11 : 132; + const waterBaseB = isDark ? 30 : 180; + + for (let t = 0; t < numTiles; t++) { + const b = terrain[t]; + const isLand = (b & 0x80) !== 0; + const isShoreline = (b & 0x40) !== 0; + const mag = b & 0x1f; + + let r = 0, + g = 0, + bb = 0; + + if (isLand && isShoreline) { + r = shoreR; + g = shoreG; + bb = shoreB; + } else if (!isLand) { + // Water (ocean + lake share the same formula here). + if (isShoreline) { + r = shorelineWaterR; + g = shorelineWaterG; + bb = shorelineWaterB; + } else if (isDark) { + if (mag < 10) { + const adj = 9 - mag; + r = Math.max(waterBaseR + adj, 0); + g = Math.max(waterBaseG + adj, 0); + bb = Math.max(waterBaseB + adj, 0); + } else { + r = waterBaseR; + g = waterBaseG; + bb = waterBaseB; + } + } else { + const m = mag < 10 ? mag : 10; + const adj = 1 - m; + r = Math.max(waterBaseR + adj, 0); + g = Math.max(waterBaseG + adj, 0); + bb = Math.max(waterBaseB + adj, 0); + } + } else { + // Land (non-shore) + if (mag < 10) { + r = isDark ? 140 : 190; + g = (isDark ? 170 : 220) - 2 * mag; + bb = isDark ? 88 : 138; + } else if (mag < 20) { + r = (isDark ? 150 : 200) + 2 * mag; + g = (isDark ? 133 : 183) + 2 * mag; + bb = (isDark ? 88 : 138) + 2 * mag; + } else { + const half = mag >> 1; + r = (isDark ? 180 : 230) + half; + g = (isDark ? 180 : 230) + half; + bb = (isDark ? 180 : 230) + half; + } + } + + const p = t * 4; + base[p] = r; + base[p + 1] = g; + base[p + 2] = bb; + base[p + 3] = 255; + } + } else { + // Fallback for other themes: call the theme once per tile (slow but only on init/theme change). + for (let t = 0; t < numTiles; t++) { + const rgba = this.theme.terrainColor( + this.gameRunner.game, + t as TileRef, + ).rgba; + const p = t * 4; + base[p] = rgba.r; + base[p + 1] = rgba.g; + base[p + 2] = rgba.b; + base[p + 3] = rgba.a ?? 255; + } + } + + this.terrainBaseRgba = base; + } +} diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index fe0ac38fca..efc641dfdc 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -9,7 +9,12 @@ import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; -import { WorkerMessage } from "./WorkerMessages"; +import { + SetWorkerDebugMessage, + TileContext, + WorkerMessage, + WorkerMetricsMessage, +} from "./WorkerMessages"; export class WorkerClient { private worker: Worker; @@ -18,6 +23,11 @@ export class WorkerClient { private gameUpdateCallback?: ( update: GameUpdateViewData | ErrorUpdate, ) => void; + private workerMetricsCallback?: (metrics: WorkerMetricsMessage) => void; + + private pendingTurns: Turn[] = []; + private turnFlushScheduled = false; + private readonly maxTurnsPerBatch = 256; constructor( private gameStartInfo: GameStartInfo, @@ -45,7 +55,12 @@ export class WorkerClient { } break; + case "worker_metrics": + this.workerMetricsCallback?.(message); + break; + case "initialized": + case "renderer_ready": default: if (message.id && this.messageHandlers.has(message.id)) { const handler = this.messageHandlers.get(message.id)!; @@ -56,6 +71,58 @@ 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 { + if ( + message && + typeof message === "object" && + typeof message.sentAtWallMs !== "number" + ) { + message.sentAtWallMs = Date.now(); + } + if (transfer && transfer.length > 0) { + this.worker.postMessage(message, transfer); + return; + } + this.worker.postMessage(message); + } + + onWorkerMetrics(callback?: (metrics: WorkerMetricsMessage) => void): void { + this.workerMetricsCallback = callback; + } + + setWorkerDebug(config: { + enabled: boolean; + intervalMs?: number; + includeTrace?: boolean; + }): void { + this.postMessage({ + type: "set_worker_debug", + enabled: config.enabled, + intervalMs: config.intervalMs, + includeTrace: config.includeTrace, + } satisfies SetWorkerDebugMessage); + } + initialize(): Promise { return new Promise((resolve, reject) => { const messageId = generateID(); @@ -67,7 +134,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "init", id: messageId, gameStartInfo: this.gameStartInfo, @@ -91,19 +158,47 @@ export class WorkerClient { this.gameUpdateCallback = gameUpdate; } + private scheduleTurnFlush(): void { + if (this.turnFlushScheduled) return; + this.turnFlushScheduled = true; + setTimeout(() => { + this.turnFlushScheduled = false; + this.flushTurns(); + }, 0); + } + + private flushTurns(): void { + while (this.pendingTurns.length > 0) { + const batch = this.pendingTurns.splice(0, this.maxTurnsPerBatch); + this.postMessage({ + type: "turn_batch", + turns: batch, + }); + } + } + sendTurn(turn: Turn) { if (!this.isInitialized) { throw new Error("Worker not initialized"); } - this.worker.postMessage({ - type: "turn", - turn, - }); + this.pendingTurns.push(turn); + this.scheduleTurnFlush(); + } + + sendTurnBatch(turns: Turn[]) { + if (!this.isInitialized) { + throw new Error("Worker not initialized"); + } + if (turns.length === 0) return; + + // Preserve order with any already queued turns. + this.pendingTurns.push(...turns); + this.scheduleTurnFlush(); } sendHeartbeat() { - this.worker.postMessage({ + this.postMessage({ type: "heartbeat", }); } @@ -126,7 +221,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "player_profile", id: messageId, playerID: playerID, @@ -152,7 +247,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "player_border_tiles", id: messageId, playerID: playerID, @@ -182,7 +277,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "player_actions", id: messageId, playerID: playerID, @@ -218,7 +313,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "attack_average_position", id: messageId, playerID: playerID, @@ -248,7 +343,7 @@ export class WorkerClient { } }); - this.worker.postMessage({ + this.postMessage({ type: "transport_ship_spawn", id: messageId, playerID: playerID, @@ -257,6 +352,29 @@ export class WorkerClient { }); } + tileContext(tile: TileRef): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if (message.type === "tile_context_result" && message.result) { + resolve(message.result); + } + }); + + this.postMessage({ + type: "tile_context", + id: messageId, + tile, + }); + }); + } + cleanup() { this.worker.terminate(); this.messageHandlers.clear(); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1f..8a63eefd1d 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -13,7 +13,10 @@ export type WorkerMessageType = | "init" | "initialized" | "turn" + | "turn_batch" | "game_update" + | "tile_context" + | "tile_context_result" | "player_actions" | "player_actions_result" | "player_profile" @@ -23,12 +26,36 @@ 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_patterns_enabled" + | "set_palette" + | "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" + | "render_done" + | "set_worker_debug" + | "worker_metrics" + | "renderer_metrics"; // Base interface for all messages interface BaseWorkerMessage { type: WorkerMessageType; id?: string; + /** + * Cross-thread timestamp (Date.now()) set by the sender when enqueuing the + * message. Used for queue latency debugging. + */ + sentAtWallMs?: number; } export interface HeartbeatMessage extends BaseWorkerMessage { @@ -47,6 +74,11 @@ export interface TurnMessage extends BaseWorkerMessage { turn: Turn; } +export interface TurnBatchMessage extends BaseWorkerMessage { + type: "turn_batch"; + turns: Turn[]; +} + // Messages from worker to main thread export interface InitializedMessage extends BaseWorkerMessage { type: "initialized"; @@ -57,6 +89,24 @@ export interface GameUpdateMessage extends BaseWorkerMessage { gameUpdate: GameUpdateViewData; } +export interface TileContext { + hasOwner: boolean; + ownerSmallId: number | null; + ownerId: PlayerID | null; + hasFallout: boolean; + isDefended: boolean; +} + +export interface TileContextMessage extends BaseWorkerMessage { + type: "tile_context"; + tile: TileRef; +} + +export interface TileContextResultMessage extends BaseWorkerMessage { + type: "tile_context_result"; + result: TileContext; +} + export interface PlayerActionsMessage extends BaseWorkerMessage { type: "player_actions"; playerID: PlayerID; @@ -112,23 +162,251 @@ 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 + backend?: "webgpu" | "canvas2d"; +} + +export interface SetPatternsEnabledMessage extends BaseWorkerMessage { + type: "set_patterns_enabled"; + enabled: boolean; +} + +export interface SetPaletteMessage extends BaseWorkerMessage { + type: "set_palette"; + paletteWidth: number; + maxSmallId: number; + row0: Uint8Array; + row1: Uint8Array; +} + +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 ViewSize { + width: number; + height: number; +} + +export interface ViewTransform { + scale: number; + offsetX: number; + offsetY: number; +} + +export interface RenderFrameMessage extends BaseWorkerMessage { + type: "render_frame"; + /** + * Optional per-frame view state. This allows the main thread to coalesce + * high-frequency camera updates into the existing render message. + */ + viewSize?: ViewSize; + viewTransform?: ViewTransform; +} + +// Renderer messages from worker to main thread +export interface RenderDoneMessage extends BaseWorkerMessage { + type: "render_done"; + /** + * Timestamp (performance.now()) in the worker right before starting work. + */ + startedAt?: number; + /** + * Timestamp (performance.now()) in the worker right after finishing work. + */ + endedAt?: number; + /** + * Echo of RenderFrameMessage.sentAtWallMs (if provided) so callers can + * compute queue/processing latency without storing state. + */ + sentAtWallMs?: number; + /** + * Timestamps (Date.now()) in the worker. Use these for cross-thread latency + * (Firefox may use a different time origin for performance.now()). + */ + startedAtWallMs?: number; + endedAtWallMs?: number; + + /** + * Optional breakdown from the worker's renderAsync implementation. + * All values are milliseconds. + */ + renderWaitPrevGpuMs?: number; + renderCpuMs?: number; + renderGetTextureMs?: number; + renderGpuWaitMs?: number; + renderWaitPrevGpuTimedOut?: boolean; + renderGpuWaitTimedOut?: boolean; + + /** + * Additional optional breakdown for CPU-side render encoding. + */ + renderSubmitted?: boolean; + renderFrameComputeMs?: number; + renderTerritoryPassMs?: number; + renderTemporalResolveMs?: number; + renderSubmitMs?: number; + renderCpuTotalMs?: number; +} + +export interface RendererReadyMessage extends BaseWorkerMessage { + type: "renderer_ready"; + ok: boolean; + error?: string; +} + +export interface RendererMetricsMessage extends BaseWorkerMessage { + type: "renderer_metrics"; + computeMs: number; +} + +export interface SetWorkerDebugMessage extends BaseWorkerMessage { + type: "set_worker_debug"; + enabled: boolean; + intervalMs?: number; + includeTrace?: boolean; +} + +export interface WorkerMetricsMessage extends BaseWorkerMessage { + type: "worker_metrics"; + intervalMs: number; + eventLoopLagMsAvg: number; + eventLoopLagMsMax: number; + simPumpDelayMsAvg: number; + simPumpDelayMsMax: number; + simPumpExecMsAvg: number; + simPumpExecMsMax: number; + + /** + * Optional render_frame breakdown collected inside the worker renderer. + * Values are based on the last metrics interval. + */ + renderSubmittedCount?: number; + renderNoopCount?: number; + renderGetTextureMsAvg?: number; + renderGetTextureMsMax?: number; + renderFrameComputeMsAvg?: number; + renderFrameComputeMsMax?: number; + renderTerritoryPassMsAvg?: number; + renderTerritoryPassMsMax?: number; + renderTemporalResolveMsAvg?: number; + renderTemporalResolveMsMax?: number; + renderSubmitMsAvg?: number; + renderSubmitMsMax?: number; + renderCpuTotalMsAvg?: number; + renderCpuTotalMsMax?: number; + + msgCounts: Record; + msgHandlerMsAvg: Record; + msgHandlerMsMax: Record; + msgQueueMsAvg: Record; + msgQueueMsMax: Record; + trace?: string[]; +} + // Union types for type safety export type MainThreadMessage = | HeartbeatMessage | InitMessage | TurnMessage + | TurnBatchMessage + | TileContextMessage | PlayerActionsMessage | PlayerProfileMessage | PlayerBorderTilesMessage | AttackAveragePositionMessage - | TransportShipSpawnMessage; + | TransportShipSpawnMessage + | InitRendererMessage + | SetPatternsEnabledMessage + | SetPaletteMessage + | SetViewSizeMessage + | SetViewTransformMessage + | SetAlternativeViewMessage + | SetHighlightedOwnerMessage + | SetShaderSettingsMessage + | MarkTileMessage + | MarkAllDirtyMessage + | RefreshPaletteMessage + | RefreshTerrainMessage + | TickRendererMessage + | SetWorkerDebugMessage + | RenderFrameMessage; // Message send from worker export type WorkerMessage = | InitializedMessage | GameUpdateMessage + | TileContextResultMessage | PlayerActionsResultMessage | PlayerProfileResultMessage | PlayerBorderTilesResultMessage | AttackAveragePositionResultMessage - | TransportShipSpawnResultMessage; + | TransportShipSpawnResultMessage + | RenderDoneMessage + | RendererReadyMessage + | RendererMetricsMessage + | WorkerMetricsMessage; diff --git a/src/core/worker/WorkerProfiler.ts b/src/core/worker/WorkerProfiler.ts new file mode 100644 index 0000000000..f812db4af3 --- /dev/null +++ b/src/core/worker/WorkerProfiler.ts @@ -0,0 +1,362 @@ +import { WorkerMetricsMessage } from "./WorkerMessages"; + +export type WorkerDebugConfig = { + enabled: boolean; + intervalMs: number; + includeTrace: boolean; +}; + +export class WorkerProfiler { + public config: WorkerDebugConfig = { + enabled: false, + intervalMs: 1000, + includeTrace: false, + }; + + private reportTimer: any = null; + private lastReportWallMs = 0; + + private eventLoopLagSum = 0; + private eventLoopLagCount = 0; + private eventLoopLagMax = 0; + + private simDelaySum = 0; + private simDelayCount = 0; + private simDelayMax = 0; + + private simExecSum = 0; + private simExecCount = 0; + private simExecMax = 0; + + private readonly msgCounts = new Map(); + private readonly msgHandlerSum = new Map(); + private readonly msgQueueSum = new Map(); + private readonly msgHandlerMax = new Map(); + private readonly msgQueueMax = new Map(); + + private traceRing: string[] = []; + private traceHead = 0; + private readonly traceCap = 160; + + private renderSubmittedCount = 0; + private renderNoopCount = 0; + private renderGetTextureSum = 0; + private renderGetTextureMax = 0; + private renderFrameComputeSum = 0; + private renderFrameComputeMax = 0; + private renderTerritoryPassSum = 0; + private renderTerritoryPassMax = 0; + private renderTemporalResolveSum = 0; + private renderTemporalResolveMax = 0; + private renderSubmitSum = 0; + private renderSubmitMax = 0; + private renderCpuTotalSum = 0; + private renderCpuTotalMax = 0; + + constructor(private send: (message: WorkerMetricsMessage) => void) {} + + start(): void { + if (this.reportTimer) return; + this.lastReportWallMs = Date.now(); + + // Event-loop lag sampler (low overhead). + let expected = Date.now() + 100; + setInterval(() => { + if (!this.config.enabled) return; + const now = Date.now(); + const lag = Math.max(0, now - expected); + expected = now + 100; + this.eventLoopLagSum += lag; + this.eventLoopLagCount++; + this.eventLoopLagMax = Math.max(this.eventLoopLagMax, lag); + }, 100); + + this.reportTimer = setInterval(() => this.report(), this.config.intervalMs); + } + + configure(next: Partial): void { + const prevInterval = this.config.intervalMs; + this.config = { + enabled: next.enabled ?? this.config.enabled, + intervalMs: Math.max( + 100, + (next.intervalMs ?? this.config.intervalMs) | 0, + ), + includeTrace: next.includeTrace ?? this.config.includeTrace, + }; + + if (this.config.enabled && !this.reportTimer) { + this.start(); + } + + if (this.reportTimer && this.config.intervalMs !== prevInterval) { + clearInterval(this.reportTimer); + this.reportTimer = setInterval( + () => this.report(), + this.config.intervalMs, + ); + } + } + + recordMessage(type: string, queueMs: number | null, handlerMs: number): void { + if (!this.config.enabled) return; + this.msgCounts.set(type, (this.msgCounts.get(type) ?? 0) + 1); + this.msgHandlerSum.set( + type, + (this.msgHandlerSum.get(type) ?? 0) + handlerMs, + ); + this.msgHandlerMax.set( + type, + Math.max(this.msgHandlerMax.get(type) ?? 0, handlerMs), + ); + if (queueMs !== null) { + this.msgQueueSum.set(type, (this.msgQueueSum.get(type) ?? 0) + queueMs); + this.msgQueueMax.set( + type, + Math.max(this.msgQueueMax.get(type) ?? 0, queueMs), + ); + } + + if (handlerMs > 25 || (queueMs !== null && queueMs > 250)) { + this.trace( + `${new Date().toISOString()} msg ${type} queue=${queueMs ?? "?"}ms handler=${Math.round(handlerMs)}ms`, + ); + } + } + + recordSimExec(execMs: number): void { + if (!this.config.enabled) return; + this.simExecSum += execMs; + this.simExecCount++; + this.simExecMax = Math.max(this.simExecMax, execMs); + if (execMs > 25) { + this.trace( + `${new Date().toISOString()} sim executeNextTick ${Math.round(execMs)}ms`, + ); + } + } + + recordSimDelay(delayMs: number): void { + if (!this.config.enabled) return; + this.simDelaySum += delayMs; + this.simDelayCount++; + this.simDelayMax = Math.max(this.simDelayMax, delayMs); + if (delayMs > 25) { + this.trace( + `${new Date().toISOString()} sim scheduleDelay ${Math.round(delayMs)}ms`, + ); + } + } + + recordRenderBreakdown(b: { + submitted: boolean; + getTextureMs?: number; + frameComputeMs?: number; + territoryPassMs?: number; + temporalResolveMs?: number; + submitMs?: number; + cpuTotalMs?: number; + }): void { + if (!this.config.enabled) return; + if (!b.submitted) { + this.renderNoopCount++; + return; + } + this.renderSubmittedCount++; + + if (typeof b.getTextureMs === "number") { + this.renderGetTextureSum += b.getTextureMs; + this.renderGetTextureMax = Math.max( + this.renderGetTextureMax, + b.getTextureMs, + ); + } + if (typeof b.frameComputeMs === "number") { + this.renderFrameComputeSum += b.frameComputeMs; + this.renderFrameComputeMax = Math.max( + this.renderFrameComputeMax, + b.frameComputeMs, + ); + } + if (typeof b.territoryPassMs === "number") { + this.renderTerritoryPassSum += b.territoryPassMs; + this.renderTerritoryPassMax = Math.max( + this.renderTerritoryPassMax, + b.territoryPassMs, + ); + } + if (typeof b.temporalResolveMs === "number") { + this.renderTemporalResolveSum += b.temporalResolveMs; + this.renderTemporalResolveMax = Math.max( + this.renderTemporalResolveMax, + b.temporalResolveMs, + ); + } + if (typeof b.submitMs === "number") { + this.renderSubmitSum += b.submitMs; + this.renderSubmitMax = Math.max(this.renderSubmitMax, b.submitMs); + } + if (typeof b.cpuTotalMs === "number") { + this.renderCpuTotalSum += b.cpuTotalMs; + this.renderCpuTotalMax = Math.max(this.renderCpuTotalMax, b.cpuTotalMs); + } + } + + trace(line: string): void { + if (!this.config.enabled || !this.config.includeTrace) return; + if (this.traceRing.length < this.traceCap) { + this.traceRing.push(line); + return; + } + this.traceRing[this.traceHead] = line; + this.traceHead = (this.traceHead + 1) % this.traceCap; + } + + private flushTrace(): string[] { + if (!this.config.includeTrace || this.traceRing.length === 0) { + return []; + } + if (this.traceRing.length < this.traceCap) { + return [...this.traceRing]; + } + return [ + ...this.traceRing.slice(this.traceHead), + ...this.traceRing.slice(0, this.traceHead), + ]; + } + + private report(): void { + if (!this.config.enabled) return; + const now = Date.now(); + const intervalMs = Math.max(1, now - this.lastReportWallMs); + this.lastReportWallMs = now; + + const toAvgRecord = ( + sumMap: Map, + countMap: Map, + ) => { + const out: Record = {}; + for (const [k, sum] of sumMap) { + const c = countMap.get(k) ?? 0; + if (c > 0) { + out[k] = sum / c; + } + } + return out; + }; + + const toMaxRecord = (maxMap: Map) => { + const out: Record = {}; + for (const [k, v] of maxMap) { + out[k] = v; + } + return out; + }; + + const msgCountsObj: Record = {}; + for (const [k, c] of this.msgCounts) { + msgCountsObj[k] = c; + } + + const renderTotal = this.renderSubmittedCount + this.renderNoopCount; + const rAvg = (sum: number): number => + this.renderSubmittedCount > 0 ? sum / this.renderSubmittedCount : 0; + + const metrics: WorkerMetricsMessage = { + type: "worker_metrics", + intervalMs, + eventLoopLagMsAvg: + this.eventLoopLagCount > 0 + ? this.eventLoopLagSum / this.eventLoopLagCount + : 0, + eventLoopLagMsMax: this.eventLoopLagMax, + simPumpDelayMsAvg: + this.simDelayCount > 0 ? this.simDelaySum / this.simDelayCount : 0, + simPumpDelayMsMax: this.simDelayMax, + simPumpExecMsAvg: + this.simExecCount > 0 ? this.simExecSum / this.simExecCount : 0, + simPumpExecMsMax: this.simExecMax, + renderSubmittedCount: + renderTotal > 0 ? this.renderSubmittedCount : undefined, + renderNoopCount: renderTotal > 0 ? this.renderNoopCount : undefined, + renderGetTextureMsAvg: + this.renderSubmittedCount > 0 + ? rAvg(this.renderGetTextureSum) + : undefined, + renderGetTextureMsMax: + this.renderSubmittedCount > 0 ? this.renderGetTextureMax : undefined, + renderFrameComputeMsAvg: + this.renderSubmittedCount > 0 + ? rAvg(this.renderFrameComputeSum) + : undefined, + renderFrameComputeMsMax: + this.renderSubmittedCount > 0 ? this.renderFrameComputeMax : undefined, + renderTerritoryPassMsAvg: + this.renderSubmittedCount > 0 + ? rAvg(this.renderTerritoryPassSum) + : undefined, + renderTerritoryPassMsMax: + this.renderSubmittedCount > 0 ? this.renderTerritoryPassMax : undefined, + renderTemporalResolveMsAvg: + this.renderSubmittedCount > 0 + ? rAvg(this.renderTemporalResolveSum) + : undefined, + renderTemporalResolveMsMax: + this.renderSubmittedCount > 0 + ? this.renderTemporalResolveMax + : undefined, + renderSubmitMsAvg: + this.renderSubmittedCount > 0 ? rAvg(this.renderSubmitSum) : undefined, + renderSubmitMsMax: + this.renderSubmittedCount > 0 ? this.renderSubmitMax : undefined, + renderCpuTotalMsAvg: + this.renderSubmittedCount > 0 + ? rAvg(this.renderCpuTotalSum) + : undefined, + renderCpuTotalMsMax: + this.renderSubmittedCount > 0 ? this.renderCpuTotalMax : undefined, + msgCounts: msgCountsObj, + msgHandlerMsAvg: toAvgRecord(this.msgHandlerSum, this.msgCounts), + msgHandlerMsMax: toMaxRecord(this.msgHandlerMax), + msgQueueMsAvg: toAvgRecord(this.msgQueueSum, this.msgCounts), + msgQueueMsMax: toMaxRecord(this.msgQueueMax), + trace: this.config.includeTrace ? this.flushTrace() : undefined, + }; + + this.send(metrics); + + // Reset per-interval counters. + this.eventLoopLagSum = 0; + this.eventLoopLagCount = 0; + this.eventLoopLagMax = 0; + this.simDelaySum = 0; + this.simDelayCount = 0; + this.simDelayMax = 0; + this.simExecSum = 0; + this.simExecCount = 0; + this.simExecMax = 0; + this.renderSubmittedCount = 0; + this.renderNoopCount = 0; + this.renderGetTextureSum = 0; + this.renderGetTextureMax = 0; + this.renderFrameComputeSum = 0; + this.renderFrameComputeMax = 0; + this.renderTerritoryPassSum = 0; + this.renderTerritoryPassMax = 0; + this.renderTemporalResolveSum = 0; + this.renderTemporalResolveMax = 0; + this.renderSubmitSum = 0; + this.renderSubmitMax = 0; + this.renderCpuTotalSum = 0; + this.renderCpuTotalMax = 0; + this.msgCounts.clear(); + this.msgHandlerSum.clear(); + this.msgHandlerMax.clear(); + this.msgQueueSum.clear(); + this.msgQueueMax.clear(); + if (this.config.includeTrace) { + this.traceRing = []; + this.traceHead = 0; + } + } +} diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts new file mode 100644 index 0000000000..594745d5cd --- /dev/null +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -0,0 +1,1009 @@ +import { Theme } from "../configuration/Config"; +import { TileRef } from "../game/GameMap"; +import { GameUpdateViewData } from "../game/GameUpdates"; +import { GameRunner } from "../GameRunner"; +import { ClientID, PlayerCosmetics } from "../Schemas"; +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 canvas: OffscreenCanvas | null = null; + private resources: GroundTruthData | null = null; + private gameViewAdapter: GameViewAdapter | null = null; + private ready = false; + private lastGpuWork: Promise | null = null; + private frameDirty = true; + + private lastViewWidth = 0; + private lastViewHeight = 0; + private lastViewScale = NaN; + private lastViewOffsetX = NaN; + private lastViewOffsetY = NaN; + private lastAlternativeViewEnabled: boolean | null = null; + private lastHighlightedOwnerSmallId: number | null = null; + + // 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; + private patternsEnabled = false; + private tickPending = false; + private tickRunning = false; + private gpuWaitEnabled = true; + private readonly gpuWaitTimeoutMs = 250; + + /** + * Initialize renderer with offscreen canvas and game data. + */ + async init( + offscreenCanvas: OffscreenCanvas, + gameRunner: GameRunner, + theme: Theme, + myClientID: ClientID | null, + cosmeticsByClientID: Map, + tileState: Uint16Array, + ): Promise { + this.canvas = offscreenCanvas; + const game = gameRunner.game; + this.defensePostRange = game.config().defensePostRange(); + + // Create adapter + this.gameViewAdapter = new GameViewAdapter( + tileState, + game.terrainDataView(), + game.width(), + game.height(), + theme, + myClientID, + cosmeticsByClientID, + ); + this.gameViewAdapter.setPatternsEnabled(this.patternsEnabled); + + // 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 = tileState; + this.resources = GroundTruthData.create( + webgpuDevice.device, + this.gameViewAdapter as any, + theme, + this.defensePostRange, + 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) + .then(() => { + this.computeTerrainImmediate(); + }) + .catch(() => {}); + 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): boolean { + if (!this.gameViewAdapter) { + return false; + } + + this.gameViewAdapter.update(gu); + const defensePostsDirty = this.gameViewAdapter.consumeDefensePostsDirty(); + const rosterDirty = this.gameViewAdapter.consumeRosterDirty(); + const playersDirty = this.gameViewAdapter.consumePlayersDirty(); + if (defensePostsDirty) { + this.resources?.markDefensePostsDirty(); + } + if (rosterDirty) { + this.resources?.markRelationsDirty(); + this.resources?.markPaletteDirty(); + this.resources?.invalidateHistory(); + } else if (playersDirty) { + this.resources?.markPaletteDirty(); + this.resources?.invalidateHistory(); + } + const didWork = defensePostsDirty || rosterDirty || playersDirty; + if (didWork) { + this.frameDirty = true; + } + return didWork; + } + + /** + * 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)); + + if ( + nextWidth === this.lastViewWidth && + nextHeight === this.lastViewHeight + ) { + return; + } + this.lastViewWidth = nextWidth; + this.lastViewHeight = nextHeight; + this.frameDirty = true; + + let sizeChanged = true; + if (this.canvas) { + sizeChanged = + nextWidth !== this.canvas.width || nextHeight !== this.canvas.height; + if (sizeChanged) { + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + } + } + + this.resources.setViewSize(nextWidth, nextHeight); + if (sizeChanged) { + this.device.reconfigure(); + } + + if (this.postSmoothingEnabled && this.resources) { + this.resources.ensurePostSmoothingTextures( + nextWidth, + nextHeight, + this.device.canvasFormat, + ); + } + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + if (!this.resources) { + return; + } + if ( + scale === this.lastViewScale && + offsetX === this.lastViewOffsetX && + offsetY === this.lastViewOffsetY + ) { + return; + } + this.lastViewScale = scale; + this.lastViewOffsetX = offsetX; + this.lastViewOffsetY = offsetY; + this.frameDirty = true; + this.resources.setViewTransform(scale, offsetX, offsetY); + } + + setAlternativeView(enabled: boolean): void { + if (!this.resources) { + return; + } + if (enabled === this.lastAlternativeViewEnabled) { + return; + } + this.lastAlternativeViewEnabled = enabled; + this.frameDirty = true; + this.resources.setAlternativeView(enabled); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + if (!this.resources) { + return; + } + if (ownerSmallId === this.lastHighlightedOwnerSmallId) { + return; + } + this.lastHighlightedOwnerSmallId = ownerSmallId; + this.frameDirty = true; + this.resources.setHighlightedOwnerId(ownerSmallId); + } + + setPatternsEnabled(enabled: boolean): void { + this.patternsEnabled = enabled; + this.gameViewAdapter?.setPatternsEnabled(enabled); + this.resources?.markPaletteDirty(); + this.resources?.invalidateHistory(); + this.frameDirty = true; + } + + setTerritoryShader(shaderPath: string): void { + this.territoryShaderPath = shaderPath; + if (this.territoryRenderPass) { + void this.territoryRenderPass.setShader(shaderPath); + } + this.resources?.invalidateHistory(); + this.frameDirty = true; + } + + setTerrainShader(shaderPath: string): void { + this.terrainShaderPath = shaderPath; + if (!this.terrainComputePass) { + return; + } + void this.terrainComputePass.setShader(shaderPath).then(() => { + this.refreshTerrain(); + }); + this.frameDirty = true; + } + + 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(); + this.frameDirty = true; + } + + 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(); + this.frameDirty = true; + } + + 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(); + this.frameDirty = true; + } + + 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(); + this.frameDirty = true; + } + + 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, + ); + const w = + (this.canvas?.width ?? this.lastViewWidth ?? 0) > 0 + ? (this.canvas?.width ?? this.lastViewWidth) + : 1; + const h = + (this.canvas?.height ?? this.lastViewHeight ?? 0) > 0 + ? (this.canvas?.height ?? this.lastViewHeight) + : 1; + this.resources.ensurePostSmoothingTextures( + w, + h, + 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 { + if (!this.resources) { + return; + } + // Full sync points used when the dirty-tile pipeline overflows or when + // global settings require a complete rebuild. + this.resources.markStateDirty(); + this.resources.markDefensePostsDirty(); + this.resources.markDefendedFullRecompute(); + this.resources.markPaletteDirty(); + this.resources.invalidateHistory(); + + this.terrainComputePass?.markDirty(); + this.frameDirty = true; + } + + refreshPalette(): void { + if (!this.resources) { + return; + } + this.resources.markPaletteDirty(); + this.frameDirty = true; + } + + markRelationsDirty(): void { + this.resources?.markRelationsDirty(); + this.frameDirty = true; + } + + markRelationsPairDirty(aSmallId: number, bSmallId: number): void { + this.resources?.markRelationsPairDirty(aSmallId, bSmallId); + this.frameDirty = true; + } + + setPaletteFromBytes( + paletteWidth: number, + maxSmallId: number, + row0: Uint8Array, + row1: Uint8Array, + ): void { + if (!this.resources) { + return; + } + this.resources.setPaletteOverride(paletteWidth, maxSmallId, row0, row1); + this.resources.invalidateHistory(); + this.frameDirty = true; + } + + markDefensePostsDirty(): void { + if (!this.resources) { + return; + } + this.resources.markDefensePostsDirty(); + this.frameDirty = true; + } + + refreshTerrain(): void { + if (!this.resources || !this.device) { + return; + } + this.resources.markTerrainParamsDirty(); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + this.computeTerrainImmediate(); + } + this.frameDirty = true; + } + + dispose(): void { + this.ready = false; + this.computePasses = []; + this.computePassOrder = []; + this.frameComputePasses = []; + this.renderPasses = []; + this.renderPassOrder = []; + this.terrainComputePass = null; + this.stateUpdatePass = null; + this.defendedStrengthFullPass = null; + this.defendedStrengthPass = null; + this.visualStateSmoothingPass = null; + this.territoryRenderPass = null; + this.temporalResolvePass = null; + this.resources = null; + this.gameViewAdapter = null; + this.device = null; + this.canvas = null; + } + + 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(): boolean { + if (!this.ready || !this.device || !this.resources) { + return false; + } + + this.resources.updateTickTiming(performance.now() / 1000); + + // 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 false; + } + + 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()]); + this.frameDirty = true; + return true; + } + + requestTick(): void { + this.tickPending = true; + if (this.tickRunning) { + return; + } + this.tickRunning = true; + void this.runTickLoop(); + } + + private async runTickLoop(): Promise { + try { + while (this.tickPending) { + this.tickPending = false; + + if (!this.ready || !this.device) { + return; + } + + if (this.gpuWaitEnabled && this.lastGpuWork) { + const r = await this.awaitGpuWork(this.lastGpuWork); + if (r.timedOut) { + this.gpuWaitEnabled = false; + } + this.lastGpuWork = null; + } + + const submitted = this.tick(); + const q: any = this.device.device.queue as any; + if (submitted && typeof q?.onSubmittedWorkDone === "function") { + const p = q.onSubmittedWorkDone() as Promise; + this.lastGpuWork = p.catch(() => {}); + if (this.gpuWaitEnabled) { + const r = await this.awaitGpuWork(this.lastGpuWork); + if (r.timedOut) { + this.gpuWaitEnabled = false; + this.lastGpuWork = null; + } else { + this.lastGpuWork = null; + } + } + } + } + } finally { + this.tickRunning = false; + } + } + + /** + * Render one frame. + * Runs render passes to draw to the canvas. + */ + render( + onGetTextureMs?: (ms: number) => void, + profile?: { + cpuTotalMs: number; + frameComputeMs: number; + territoryPassMs: number; + temporalResolveMs: number; + submitMs: number; + }, + ): boolean { + if ( + !this.ready || + !this.device || + !this.resources || + !this.territoryRenderPass + ) { + return false; + } + + // Without post-smoothing, stable frames can simply be skipped. + if (!this.frameDirty && !this.postSmoothingEnabled) { + return false; + } + + const cpuStart = profile ? performance.now() : 0; + + const nowSec = performance.now() / 1000; + this.resources.writeTemporalUniformBuffer(nowSec); + + const encoder = this.device.device.createCommandEncoder(); + const getTexStart = performance.now(); + const swapchainView = this.device.context.getCurrentTexture().createView(); + if (onGetTextureMs) { + onGetTextureMs(performance.now() - getTexStart); + } + + let frameComputeMs = 0; + if ( + this.preSmoothingEnabled && + this.resources.consumeVisualStateSyncNeeded() + ) { + const start = profile ? performance.now() : 0; + 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, + }, + ); + } + if (profile) { + frameComputeMs += performance.now() - start; + } + } + + for (const pass of this.frameComputePasses) { + if (!pass.needsUpdate()) { + continue; + } + const start = profile ? performance.now() : 0; + pass.execute(encoder, this.resources); + if (profile) { + frameComputeMs += performance.now() - start; + } + } + if (profile) { + profile.frameComputeMs = frameComputeMs; + } + + let territoryPassMs = 0; + let temporalResolveMs = 0; + + // Execute render passes in dependency order. + for (const pass of this.renderPassOrder) { + if (!pass.needsUpdate()) { + continue; + } + + const passStart = profile ? performance.now() : 0; + + if (pass === this.territoryRenderPass && this.postSmoothingEnabled) { + if (!this.resources.getCurrentColorTexture()) { + const viewWidth = this.canvas?.width ?? 1; + const viewHeight = this.canvas?.height ?? 1; + this.resources.ensurePostSmoothingTextures( + viewWidth, + viewHeight, + this.device.canvasFormat, + ); + } + + const currentTexture = this.resources.getCurrentColorTexture(); + if (currentTexture) { + pass.execute(encoder, this.resources, currentTexture.createView()); + } + + if (profile) { + territoryPassMs += performance.now() - passStart; + } + continue; + } + + pass.execute(encoder, this.resources, swapchainView); + + if (profile) { + const dt = performance.now() - passStart; + if (pass === this.territoryRenderPass) { + territoryPassMs += dt; + } else if (pass === this.temporalResolvePass) { + temporalResolveMs += dt; + } + } + } + + const submitStart = profile ? performance.now() : 0; + this.device.device.queue.submit([encoder.finish()]); + + if (profile) { + profile.territoryPassMs = territoryPassMs; + profile.temporalResolveMs = temporalResolveMs; + profile.submitMs = performance.now() - submitStart; + profile.cpuTotalMs = performance.now() - cpuStart; + } + + if (!this.postSmoothingEnabled) { + this.frameDirty = false; + } + return true; + } + + async renderAsync(profilePhases: boolean = false): Promise<{ + waitPrevGpuMs: number; + cpuMs: number; + getTextureMs: number; + submitted: boolean; + frameComputeMs?: number; + territoryPassMs?: number; + temporalResolveMs?: number; + submitMs?: number; + cpuTotalMs?: number; + gpuWaitMs: number; + waitPrevGpuTimedOut: boolean; + gpuWaitTimedOut: boolean; + } | null> { + if (!this.ready || !this.device) { + return null; + } + + const waitPrevGpuMs = 0; + let cpuMs = 0; + let getTextureMs = 0; + let submitted = false; + let frameComputeMs: number | undefined; + let territoryPassMs: number | undefined; + let temporalResolveMs: number | undefined; + let submitMs: number | undefined; + let cpuTotalMs: number | undefined; + const gpuWaitMs = 0; + const waitPrevGpuTimedOut = false; + const gpuWaitTimedOut = false; + + // Keep render_frame handlers cheap: do not await GPU progress here. + // Backpressure is handled on the main thread (one in-flight render). + this.lastGpuWork = null; + + const cpuStart = performance.now(); + const profile = profilePhases + ? { + cpuTotalMs: 0, + frameComputeMs: 0, + territoryPassMs: 0, + temporalResolveMs: 0, + submitMs: 0, + } + : undefined; + + submitted = this.render((ms) => { + getTextureMs = ms; + }, profile); + cpuMs = performance.now() - cpuStart; + + if (!submitted) { + this.lastGpuWork = null; + return { + waitPrevGpuMs, + cpuMs, + getTextureMs, + submitted, + gpuWaitMs, + waitPrevGpuTimedOut, + gpuWaitTimedOut, + }; + } + + const q: any = this.device.device.queue as any; + if (typeof q?.onSubmittedWorkDone !== "function") { + this.lastGpuWork = null; + return { + waitPrevGpuMs, + cpuMs, + getTextureMs, + submitted, + frameComputeMs, + territoryPassMs, + temporalResolveMs, + submitMs, + cpuTotalMs, + gpuWaitMs, + waitPrevGpuTimedOut, + gpuWaitTimedOut, + }; + } + + if (profile) { + frameComputeMs = profile.frameComputeMs; + territoryPassMs = profile.territoryPassMs; + temporalResolveMs = profile.temporalResolveMs; + submitMs = profile.submitMs; + cpuTotalMs = profile.cpuTotalMs; + } + + const p = q.onSubmittedWorkDone() as Promise; + this.lastGpuWork = p.catch(() => {}); + + return { + waitPrevGpuMs, + cpuMs, + getTextureMs, + submitted, + frameComputeMs, + territoryPassMs, + temporalResolveMs, + submitMs, + cpuTotalMs, + gpuWaitMs, + waitPrevGpuTimedOut, + gpuWaitTimedOut, + }; + } + + private async awaitGpuWork( + work: Promise, + ): Promise<{ timedOut: boolean }> { + let timeoutId: any = null; + const timeout = new Promise<"timeout">((resolve) => { + timeoutId = setTimeout(() => resolve("timeout"), this.gpuWaitTimeoutMs); + }); + const result = await Promise.race([ + work.then(() => "done" as const), + timeout, + ]); + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + return { timedOut: result === "timeout" }; + } +}