From 7cdf1b8160f618425bb04463bfc997b80740318a Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:02:59 +0100 Subject: [PATCH] refactor: restructure WebGPU territory renderer into extensible pass-based architecture Refactor the monolithic TerritoryWebGLRenderer into a modular, extensible architecture that separates ground truth computation from rendering passes. This change also includes related improvements to game state management and hover information handling. WebGPU Architecture Refactor: - Extract all shaders to external .wgsl files (no inlined shaders) - Separate ground truth data management (GroundTruthData) from rendering - Create pass-based architecture with ComputePass and RenderPass interfaces - Implement compute passes: StateUpdatePass, DefendedClearPass, DefendedUpdatePass - Implement render pass: TerritoryRenderPass - Add TerritoryRenderer orchestrator with dependency-based execution ordering - Add WebGPUDevice for device initialization and management - Add ShaderLoader utility for loading .wgsl files via Vite ?raw imports Performance Optimizations: - Dependency order computed once at init (topological sort) - Early exit checks at orchestrator and pass levels - Bind groups rebuilt when textures/buffers are recreated - Zero per-frame allocations (reuse command encoders and staging buffers) Architecture Benefits: - Easy to extend with new compute/render passes (borders, temporal smoothing, etc.) - Clear separation between tick-based compute and frame-based rendering - All shaders in external files for better maintainability - Ground truth data computed once and reused by all passes Related Changes: - Add defended tile state support to GameMap (isDefended/setDefended) - Expose tileStateView() for direct GPU state access - Extract hover info logic to HoverInfo utility - Remove TerrainLayer (terrain now rendered by WebGPU territory pass) - Update GameRenderer to use transparent overlay canvas - Add viewOffset() method to TransformHandler Files: - Deleted: TerritoryWebGLRenderer.ts (1217 lines), TerrainLayer.ts (77 lines) - Added: 17 new files in webgpu/ directory structure - Updated: TerritoryLayer.ts, GameRenderer.ts, PlayerInfoOverlay.ts, GameMap.ts, GameView.ts, GameImpl.ts, TransformHandler.ts, vite-env.d.ts --- src/client/graphics/GameRenderer.ts | 16 +- src/client/graphics/HoverInfo.ts | 73 ++ src/client/graphics/TransformHandler.ts | 4 + .../graphics/layers/PlayerInfoOverlay.ts | 70 +- src/client/graphics/layers/TerrainLayer.ts | 107 --- src/client/graphics/layers/TerritoryLayer.ts | 859 +++++------------- .../graphics/webgpu/TerritoryRenderer.ts | 385 ++++++++ .../graphics/webgpu/compute/ComputePass.ts | 37 + .../webgpu/compute/DefendedClearPass.ts | 105 +++ .../webgpu/compute/DefendedUpdatePass.ts | 159 ++++ .../webgpu/compute/StateUpdatePass.ts | 146 +++ .../graphics/webgpu/core/GroundTruthData.ts | 524 +++++++++++ .../graphics/webgpu/core/ShaderLoader.ts | 28 + .../graphics/webgpu/core/WebGPUDevice.ts | 66 ++ .../graphics/webgpu/render/RenderPass.ts | 46 + .../webgpu/render/TerritoryRenderPass.ts | 189 ++++ .../webgpu/shaders/common/uniforms.wgsl | 12 + .../shaders/compute/defended-clear.wgsl | 12 + .../shaders/compute/defended-update.wgsl | 53 ++ .../webgpu/shaders/compute/state-update.wgsl | 21 + .../webgpu/shaders/render/territory.wgsl | 98 ++ src/client/vite-env.d.ts | 10 + src/core/game/GameImpl.ts | 10 +- src/core/game/GameMap.ts | 20 + src/core/game/GameView.ts | 9 + 25 files changed, 2264 insertions(+), 795 deletions(-) create mode 100644 src/client/graphics/HoverInfo.ts delete mode 100644 src/client/graphics/layers/TerrainLayer.ts create mode 100644 src/client/graphics/webgpu/TerritoryRenderer.ts create mode 100644 src/client/graphics/webgpu/compute/ComputePass.ts create mode 100644 src/client/graphics/webgpu/compute/DefendedClearPass.ts create mode 100644 src/client/graphics/webgpu/compute/DefendedUpdatePass.ts create mode 100644 src/client/graphics/webgpu/compute/StateUpdatePass.ts create mode 100644 src/client/graphics/webgpu/core/GroundTruthData.ts create mode 100644 src/client/graphics/webgpu/core/ShaderLoader.ts create mode 100644 src/client/graphics/webgpu/core/WebGPUDevice.ts create mode 100644 src/client/graphics/webgpu/render/RenderPass.ts create mode 100644 src/client/graphics/webgpu/render/TerritoryRenderPass.ts create mode 100644 src/client/graphics/webgpu/shaders/common/uniforms.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-update.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/state-update.wgsl create mode 100644 src/client/graphics/webgpu/shaders/render/territory.wgsl diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 4df65facc..28bc6789d 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -40,7 +40,6 @@ import { SpawnTimer } from "./layers/SpawnTimer"; 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"; @@ -275,8 +274,7 @@ 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), + new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, eventBus, transformHandler, uiState), new CoordinateGridLayer(game, eventBus, transformHandler), structureLayer, @@ -348,7 +346,8 @@ export class GameRenderer { private layers: Layer[], private performanceOverlay: PerformanceOverlay, ) { - 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; } @@ -399,13 +398,8 @@ export class GameRenderer { 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, diff --git a/src/client/graphics/HoverInfo.ts b/src/client/graphics/HoverInfo.ts new file mode 100644 index 000000000..c99f25777 --- /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/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 90966525c..5d9a73c7d 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -59,6 +59,10 @@ export class TransformHandler { return this._boundingRect; } + viewOffset(): { x: number; y: number } { + return { x: this.offsetX, y: this.offsetY }; + } + width(): number { return this.boundingRect().width; } diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index e720738f4..df7d485e6 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -6,10 +6,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 { @@ -24,6 +22,7 @@ import { renderTroops, translateText, } from "../../Utils"; +import { getHoverInfo } from "../HoverInfo"; import { EMOJI_ICON_KIND, getFirstPlacePlayer, @@ -47,26 +46,6 @@ const portIcon = assetUrl("images/PortIcon.svg"); const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg"); const soldierIcon = assetUrl("images/SoldierIcon.svg"); -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 }) @@ -87,6 +66,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; @@ -134,36 +119,28 @@ 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) { this.hide(); const worldCoord = this.transform.screenToWorldCoordinates(x, y); - if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) { - return; - } + const info = getHoverInfo(this.game, worldCoord); - const tile = this.game.ref(worldCoord.x, worldCoord.y); - if (!tile) return; - - const owner = this.game.owner(tile); - - if (owner && owner.isPlayer()) { - this.player = owner as PlayerView; + if (info.player) { + this.player = info.player; this.player.profile().then((p) => { this.playerProfile = p; }); 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); - } + } else if (info.isWilderness || info.isIrradiatedWilderness) { + this.isWilderness = info.isWilderness; + this.isIrradiatedWilderness = info.isIrradiatedWilderness; + this.setVisible(true); + } else if (info.unit) { + this.unit = info.unit; + this.setVisible(true); } } @@ -506,6 +483,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/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts deleted file mode 100644 index 353555912..000000000 --- a/src/client/graphics/layers/TerrainLayer.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Config, Theme } from "../../../core/configuration/Config"; -import { GameView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class TerrainLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private theme: Theme; - private config: Config; - - constructor( - private game: GameView, - private transformHandler: TransformHandler, - ) { - this.config = this.game.config(); - } - shouldTransform(): boolean { - return true; - } - tick() { - if (this.config.theme() !== this.theme) { - this.redraw(); - return; - } - // Repaint terrain for tiles whose terrain changed (e.g. nuke - // turning land to water). - const updatedTiles = this.game.recentlyUpdatedTerrainTiles(); - if (updatedTiles.length > 0) { - let dirty = false; - for (const tile of updatedTiles) { - const terrainColor = this.theme.terrainColor(this.game, tile); - const offset = tile * 4; - const r = terrainColor.rgba.r; - const g = terrainColor.rgba.g; - const b = terrainColor.rgba.b; - if ( - this.imageData.data[offset] !== r || - this.imageData.data[offset + 1] !== g || - this.imageData.data[offset + 2] !== b - ) { - this.imageData.data[offset] = r; - this.imageData.data[offset + 1] = g; - this.imageData.data[offset + 2] = b; - dirty = true; - } - } - if (dirty) { - this.context.putImageData(this.imageData, 0, 0); - } - } - } - - init() { - console.log("redrew terrain layer"); - this.redraw(); - } - - redraw(): void { - this.canvas = document.createElement("canvas"); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - const context = this.canvas.getContext("2d", { alpha: false }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - this.imageData = this.context.createImageData( - this.canvas.width, - this.canvas.height, - ); - - this.initImageData(); - this.context.putImageData(this.imageData, 0, 0); - } - - initImageData() { - this.theme = this.config.theme(); - this.game.forEachTile((tile) => { - const terrainColor = this.theme.terrainColor(this.game, tile); - // TODO: isn't tileref and index the same? - const index = this.game.y(tile) * this.game.width() + this.game.x(tile); - const offset = index * 4; - this.imageData.data[offset] = terrainColor.rgba.r; - this.imageData.data[offset + 1] = terrainColor.rgba.g; - this.imageData.data[offset + 2] = terrainColor.rgba.b; - this.imageData.data[offset + 3] = 255; - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - if (this.transformHandler.scale < 1) { - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "low"; - } else { - context.imageSmoothingEnabled = false; - } - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } -} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 5eaedca87..7ef437b4d 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,709 +1,300 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { - Cell, - ColoredTeams, - PlayerType, - Team, - UnitType, -} from "../../../core/game/Game"; -import { euclDistFN, TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { - AlternateViewEvent, - DragEvent, - MouseOverEvent, -} from "../../InputHandler"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler"; import { FrameProfiler } from "../FrameProfiler"; import { TransformHandler } from "../TransformHandler"; +import { TerritoryRenderer } from "../webgpu/TerritoryRenderer"; import { Layer } from "./Layer"; export class TerritoryLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; - private borderAnimTime = 0; + profileName(): string { + return "TerritoryLayer:renderLayer"; + } - private cachedTerritoryPatternsEnabled: boolean | undefined; + private attachedTerritoryCanvas: HTMLCanvasElement | null = null; + + private overlayWrapper: HTMLElement | null = null; + private overlayResizeObserver: ResizeObserver | null = null; - private tileToRenderQueue: PriorityQueue<{ - tile: TileRef; - lastUpdate: number; - }> = new PriorityQueue((a, b) => { - return a.lastUpdate - b.lastUpdate; - }); - private random = new PseudoRandom(123); private theme: Theme; - // Used for spawn highlighting - private highlightCanvas: HTMLCanvasElement; - private highlightContext: CanvasRenderingContext2D; - - private highlightedTerritory: PlayerView | null = null; - + private territoryRenderer: TerritoryRenderer | null = null; private alternativeView = false; - private lastDragTime = 0; - private nodrawDragDuration = 200; + + private lastPaletteSignature: string | null = null; + private lastDefensePostsSignature: string | null = null; + private lastMousePosition: { x: number; y: number } | null = null; - - private refreshRate = 10; //refresh every 10ms - private lastRefresh = 0; - - private lastFocusedPlayer: PlayerView | null = null; + private hoveredOwnerSmallId: number | null = null; + private lastHoverUpdateMs = 0; constructor( private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, + private userSettings: UserSettings, ) { this.theme = game.config().theme(); - this.cachedTerritoryPatternsEnabled = undefined; } shouldTransform(): boolean { return true; } - async paintPlayerBorder(player: PlayerView) { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); - } - - tick() { - if (this.game.inSpawnPhase()) { - this.spawnHighlight(); - } - - this.game.recentlyUpdatedTiles().forEach((t) => { - this.enqueueTile(t); - // Immediately clear territory overlay for water tiles so old - // borders/territory don't persist visually (e.g. after nuke turns land to water) - if (this.game.isWater(t)) { - this.clearTile(t); - } - }); - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - unitUpdates.forEach((update) => { - if (update.unitType === UnitType.DefensePost) { - // Only update borders if the defense post is not under construction - if (update.underConstruction) { - return; // Skip barrier creation while under construction - } - - const tile = update.pos; - this.game - .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange())) - .forEach((t) => { - if ( - this.game.isBorder(t) && - (this.game.ownerID(t) === update.ownerID || - this.game.ownerID(t) === update.lastOwnerID) - ) { - this.enqueueTile(t); - } - }); - } - }); - - // Detect alliance mutations - const myPlayer = this.game.myPlayer(); - if (myPlayer) { - updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { - const territory = this.game.playerBySmallID(update.betrayedID); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - }); - - updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { - if ( - update.accepted && - (update.request.requestorID === myPlayer.smallID() || - update.request.recipientID === myPlayer.smallID()) - ) { - const territoryId = - update.request.requestorID === myPlayer.smallID() - ? update.request.recipientID - : update.request.requestorID; - const territory = this.game.playerBySmallID(territoryId); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - } - }); - updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => { - const player = this.game.playerBySmallID(update.playerID) as PlayerView; - const embargoed = this.game.playerBySmallID( - update.embargoedID, - ) as PlayerView; - - if ( - player.id() === myPlayer?.id() || - embargoed.id() === myPlayer?.id() - ) { - this.redrawBorder(player, embargoed); - } - }); - } - - const focusedPlayer = this.game.focusedPlayer(); - if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); - } - this.lastFocusedPlayer = focusedPlayer; - } - } - - private spawnHighlight() { - this.highlightContext.clearRect( - 0, - 0, - this.game.width(), - this.game.height(), - ); - - this.drawFocusedPlayerHighlight(); - - const humans = this.game - .playerViews() - .filter((p) => p.type() === PlayerType.Human); - - const focusedPlayer = this.game.focusedPlayer(); - const teamColors = Object.values(ColoredTeams); - for (const human of humans) { - if (human === focusedPlayer) { - continue; - } - const center = human.nameLocation(); - if (!center) { - continue; - } - const centerTile = this.game.ref(center.x, center.y); - if (!centerTile) { - continue; - } - let color = this.theme.spawnHighlightColor(); - const myPlayer = this.game.myPlayer(); - if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) { - // In FFA games (when team === null), use default yellow spawn highlight color - color = this.theme.spawnHighlightColor(); - } else if (myPlayer !== null && myPlayer !== human) { - // In Team games, the spawn highlight color becomes that player's team color - // Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively - const team = human.team(); - if (team !== null && teamColors.includes(team)) { - color = this.theme.teamColor(team); - } else { - if (myPlayer.isFriendly(human)) { - color = this.theme.spawnHighlightTeamColor(); - } else { - color = this.theme.spawnHighlightColor(); - } - } - } - - for (const tile of this.game.bfs( - centerTile, - euclDistFN(centerTile, 9, true), - )) { - if (!this.game.hasOwner(tile)) { - this.paintHighlightTile(tile, color, 255); - } - } - } - } - - private drawFocusedPlayerHighlight() { - const focusedPlayer = this.game.focusedPlayer(); - - if (!focusedPlayer) { - return; - } - const center = focusedPlayer.nameLocation(); - if (!center) { - return; - } - // Breathing border animation - this.borderAnimTime += 0.5; - const minRad = 8; - const maxRad = 24; - // Range: [minPadding..maxPadding] - const radius = - minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime)); - - const baseColor = this.theme.spawnHighlightSelfColor(); //white - let teamColor: Colord | null = null; - - const team: Team | null = focusedPlayer.team(); - if (team !== null && Object.values(ColoredTeams).includes(team)) { - teamColor = this.theme.teamColor(team).alpha(0.5); - } else { - teamColor = baseColor; - } - - this.drawBreathingRing( - center.x, - center.y, - minRad, - maxRad, - radius, - baseColor, // Always draw white static semi-transparent ring - teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games. - ); - - // Draw breathing rings for teammates in team games (helps colorblind players identify teammates) - this.drawTeammateHighlights(minRad, maxRad, radius); - } - - private drawTeammateHighlights( - minRad: number, - maxRad: number, - radius: number, - ) { - const myPlayer = this.game.myPlayer(); - if (myPlayer === null || myPlayer.team() === null) { - return; - } - - const teammates = this.game - .playerViews() - .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p)); - - // Smaller radius for teammates (more subtle than self highlight) - const teammateMinRad = 5; - const teammateMaxRad = 14; - const teammateRadius = - teammateMinRad + - (teammateMaxRad - teammateMinRad) * - ((radius - minRad) / (maxRad - minRad)); - - const teamColors = Object.values(ColoredTeams); - for (const teammate of teammates) { - const center = teammate.nameLocation(); - if (!center) { - continue; - } - - const team = teammate.team(); - let baseColor: Colord; - let breathingColor: Colord; - - if (team !== null && teamColors.includes(team)) { - baseColor = this.theme.teamColor(team).alpha(0.5); - breathingColor = this.theme.teamColor(team).alpha(0.5); - } else { - baseColor = this.theme.spawnHighlightTeamColor(); - breathingColor = this.theme.spawnHighlightTeamColor(); - } - - this.drawBreathingRing( - center.x, - center.y, - teammateMinRad, - teammateMaxRad, - teammateRadius, - baseColor, - breathingColor, - ); - } - } - init() { - this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); }); - this.eventBus.on(DragEvent, (e) => { - // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. - // this.lastDragTime = Date.now(); + this.eventBus.on(MouseOverEvent, (e) => { + this.lastMousePosition = { x: e.x, y: e.y }; }); this.redraw(); } - onMouseOver(event: MouseOverEvent) { - this.lastMousePosition = { x: event.x, y: event.y }; - this.updateHighlightedTerritory(); - } + tick() { + const tickProfile = FrameProfiler.start(); - private updateHighlightedTerritory() { - if (!this.alternativeView) { - return; + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.redraw(); } - if (!this.lastMousePosition) { - return; + this.refreshPaletteIfNeeded(); + this.refreshDefensePostsIfNeeded(); + + const updatedTiles = this.game.recentlyUpdatedTiles(); + for (let i = 0; i < updatedTiles.length; i++) { + this.markTile(updatedTiles[i]); } - const cell = this.transformHandler.screenToWorldCoordinates( - this.lastMousePosition.x, - this.lastMousePosition.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } + // After collecting pending updates and handling palette/theme changes, + // invoke the renderer's tick() to process compute passes. This ensures + // compute shaders run at the simulation rate rather than every frame. + this.territoryRenderer?.tick(); - const previousTerritory = this.highlightedTerritory; - const territory = this.getTerritoryAtCell(cell); - - if (territory) { - this.highlightedTerritory = territory; - } else { - this.highlightedTerritory = null; - } - - if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); - } - } - - private getTerritoryAtCell(cell: { x: number; y: number }) { - const tile = this.game.ref(cell.x, cell.y); - if (!tile) { - return null; - } - // If the tile has no owner, it is either a fallout tile or a terra nullius tile. - if (!this.game.hasOwner(tile)) { - return null; - } - const owner = this.game.owner(tile); - return owner instanceof PlayerView ? owner : null; + FrameProfiler.end("TerritoryLayer:tick", tickProfile); } redraw() { - console.log("redrew territory layer"); - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - this.imageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.alternativeImageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.initImageData(); - - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); - - // Add a second canvas for highlights - this.highlightCanvas = document.createElement("canvas"); - const highlightContext = this.highlightCanvas.getContext("2d", { - alpha: true, - }); - if (highlightContext === null) throw new Error("2d context not supported"); - this.highlightContext = highlightContext; - this.highlightCanvas.width = this.game.width(); - this.highlightCanvas.height = this.game.height(); - - this.game.forEachTile((t) => { - this.paintTerritory(t); - }); + this.configureRenderer(); } - redrawBorder(...players: PlayerView[]) { - return Promise.all( - players.map(async (player) => { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); - }); - }), + private configureRenderer() { + const { renderer, reason } = TerritoryRenderer.create( + this.game, + this.theme, ); - } + if (!renderer) { + throw new Error(reason ?? "WebGPU is required for territory rendering."); + } - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); + this.territoryRenderer = renderer; + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); + this.lastPaletteSignature = this.computePaletteSignature(); + + this.lastDefensePostsSignature = this.computeDefensePostsSignature(); + // Ensure defense posts buffer is uploaded on first tick. + this.territoryRenderer.markDefensePostsDirty(); + + // Run an initial tick to upload state and build the colour texture. Without + // this, the first render call may occur before the initial compute pass + // has been executed, resulting in undefined colours. + this.territoryRenderer.tick(); } renderLayer(context: CanvasRenderingContext2D) { - const now = Date.now(); - if ( - now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { - this.lastRefresh = now; - const renderTerritoryStart = FrameProfiler.start(); - this.renderTerritory(); - FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const vx0 = Math.max(0, topLeft.x); - const vy0 = Math.max(0, topLeft.y); - const vx1 = Math.min(this.game.width() - 1, bottomRight.x); - const vy1 = Math.min(this.game.height() - 1, bottomRight.y); - - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; - - if (w > 0 && h > 0) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); - } + if (!this.territoryRenderer) { + return; } - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), + this.ensureTerritoryCanvasAttached(context.canvas); + this.updateHoverHighlight(); + + const renderTerritoryStart = FrameProfiler.start(); + this.territoryRenderer.setViewSize( + context.canvas.width, + context.canvas.height, ); - FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); - if (this.game.inSpawnPhase()) { - const highlightDrawStart = FrameProfiler.start(); - context.drawImage( - this.highlightCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end( - "TerritoryLayer:drawHighlightCanvas", - highlightDrawStart, - ); - } + const viewOffset = this.transformHandler.viewOffset(); + this.territoryRenderer.setViewTransform( + this.transformHandler.scale, + viewOffset.x, + viewOffset.y, + ); + this.territoryRenderer.render(); + FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); } - renderTerritory() { - let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if (numToRender === 0 || this.game.inSpawnPhase()) { - numToRender = this.tileToRenderQueue.size(); - } - - while (numToRender > 0) { - numToRender--; - - const entry = this.tileToRenderQueue.pop(); - if (!entry) { - break; - } - - const tile = entry.tile; - this.paintTerritory(tile); - for (const neighbor of this.game.neighbors(tile)) { - this.paintTerritory(neighbor, true); - } - } - } - - paintTerritory(tile: TileRef, isBorder: boolean = false) { - if (isBorder && !this.game.hasOwner(tile)) { + private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) { + if (!this.territoryRenderer) { return; } - if (!this.game.hasOwner(tile)) { - if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); - return; + const canvas = this.territoryRenderer.canvas; + + // If the renderer recreated its canvas, detach the old one. + if (this.attachedTerritoryCanvas !== canvas) { + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = canvas; + + // Configure overlay canvas styles once. Avoid per-frame style reads/writes. + canvas.style.pointerEvents = "none"; + canvas.style.position = "absolute"; + canvas.style.inset = "0"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + } + + const parent = mainCanvas.parentElement; + if (!parent) { + // Fallback: if the canvas isn't in the DOM yet, append to body. + if (!canvas.isConnected) { + document.body.appendChild(canvas); } - this.clearTile(tile); return; } - const owner = this.game.owner(tile) as PlayerView; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); - if (this.game.isBorder(tile)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const playerIsFocused = owner && this.game.focusedPlayer() === owner; - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); - } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, - ); + // Ensure the main canvas is wrapped in a positioned container so the + // territory canvas can overlay it without mirroring computed styles. + let wrapper: HTMLElement; + const currentParent = mainCanvas.parentElement; + if (currentParent && currentParent.dataset.territoryOverlay === "1") { + wrapper = currentParent; } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); + wrapper = document.createElement("div"); + wrapper.dataset.territoryOverlay = "1"; + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + wrapper.style.lineHeight = "0"; - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); + // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper. + parent.replaceChild(wrapper, mainCanvas); + wrapper.appendChild(mainCanvas); + } + + if (this.overlayWrapper !== wrapper) { + this.overlayWrapper = wrapper; + this.overlayResizeObserver?.disconnect(); + this.overlayResizeObserver = new ResizeObserver(() => { + this.syncOverlayWrapperSize(mainCanvas, wrapper); + }); + this.overlayResizeObserver.observe(mainCanvas); + // Kick an initial size update; further updates are handled by ResizeObserver. + this.syncOverlayWrapperSize(mainCanvas, wrapper); + } + + // Ensure territory canvas is the first child so it's the lowest layer. + if (canvas.parentElement !== wrapper) { + canvas.remove(); + wrapper.insertBefore(canvas, mainCanvas); + } else if (canvas !== wrapper.firstElementChild) { + wrapper.insertBefore(canvas, mainCanvas); } } - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); - } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); - } - if (other.isFriendly(myPlayer)) { - return this.theme.allyColor(); - } - if (!other.hasEmbargo(myPlayer)) { - return this.theme.neutralColor(); - } - return this.theme.enemyColor(); - } - - paintAlternateViewTile(tile: TileRef, other: PlayerView) { - const color = this.alternateViewColor(other); - this.paintTile(this.alternativeImageData, tile, color, 255); - } - - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; - } - - clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - clearAlternativeTile(tile: TileRef) { - const offset = tile * 4; - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.push({ - tile: tile, - lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5), - }); - } - - async enqueuePlayerBorder(player: PlayerView) { - const playerBorderTiles = await player.borderTiles(); - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.enqueueTile(tile); - }); - } - - paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { - this.clearTile(tile); - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.highlightContext.fillRect(x, y, 1, 1); - } - - clearHighlightTile(tile: TileRef) { - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.clearRect(x, y, 1, 1); - } - - private drawBreathingRing( - cx: number, - cy: number, - minRad: number, - maxRad: number, - radius: number, - transparentColor: Colord, - breathingColor: Colord, + private syncOverlayWrapperSize( + mainCanvas: HTMLCanvasElement, + wrapper: HTMLElement, ) { - const ctx = this.highlightContext; - if (!ctx) return; + // Ensure the wrapper has real layout size so the absolutely-positioned + // territory canvas (100% width/height) is non-zero even if the main canvas + // is positioned absolutely. + const rect = mainCanvas.getBoundingClientRect(); + const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth; + const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight; + if (w > 0) wrapper.style.width = `${w}px`; + if (h > 0) wrapper.style.height = `${h}px`; + } - // Draw a semi-transparent ring around the starting location - ctx.beginPath(); - // Transparency matches the highlight color provided - const transparent = transparentColor.alpha(0); - const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad); + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); + } - // Pixels with radius < minRad are transparent - radGrad.addColorStop(0, transparent.toRgbString()); - // The ring then starts with solid highlight color - radGrad.addColorStop(0.01, transparentColor.toRgbString()); - radGrad.addColorStop(0.1, transparentColor.toRgbString()); - // The outer edge of the ring is transparent - radGrad.addColorStop(1, transparent.toRgbString()); + private updateHoverHighlight() { + if (!this.territoryRenderer) { + return; + } - // Draw an arc at the max radius and fill with the created radial gradient - ctx.arc(cx, cy, maxRad, 0, Math.PI * 2); - ctx.fillStyle = radGrad; - ctx.closePath(); - ctx.fill(); + const now = performance.now(); + if (now - this.lastHoverUpdateMs < 100) { + return; + } + this.lastHoverUpdateMs = now; - const breatheInner = breathingColor.alpha(0); - // Draw a solid ring around the starting location with outer radius = the breathing radius - ctx.beginPath(); - const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius); - // Pixels with radius < minRad are transparent - radGrad2.addColorStop(0, breatheInner.toRgbString()); - // The ring then starts with solid highlight color - radGrad2.addColorStop(0.01, breathingColor.toRgbString()); - // The ring is solid throughout - radGrad2.addColorStop(1, breathingColor.toRgbString()); + let nextOwnerSmallId: number | null = null; + if (this.lastMousePosition) { + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (this.game.isValidCoord(cell.x, cell.y)) { + const tile = this.game.ref(cell.x, cell.y); + const owner = this.game.owner(tile); + if (owner && owner.isPlayer()) { + nextOwnerSmallId = owner.smallID(); + } + } + } - // Draw an arc at the current breathing radius and fill with the created "gradient" - ctx.arc(cx, cy, radius, 0, Math.PI * 2); - ctx.fillStyle = radGrad2; - ctx.fill(); + if (nextOwnerSmallId === this.hoveredOwnerSmallId) { + return; + } + this.hoveredOwnerSmallId = nextOwnerSmallId; + this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId); + } + + private computePaletteSignature(): string { + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const patternsEnabled = this.userSettings.territoryPatterns(); + return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; + } + + private refreshPaletteIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computePaletteSignature(); + if (signature !== this.lastPaletteSignature) { + this.lastPaletteSignature = signature; + this.territoryRenderer.refreshPalette(); + } + } + + private computeDefensePostsSignature(): string { + // Active + completed posts only. + const parts: string[] = []; + for (const u of this.game.units(UnitType.DefensePost)) { + if (!u.isActive() || u.isUnderConstruction()) continue; + const tile = u.tile(); + parts.push( + `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`, + ); + } + parts.sort(); + return parts.join("|"); + } + + private refreshDefensePostsIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computeDefensePostsSignature(); + if (signature !== this.lastDefensePostsSignature) { + this.lastDefensePostsSignature = signature; + this.territoryRenderer.markDefensePostsDirty(); + } } } diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts new file mode 100644 index 000000000..171510e6f --- /dev/null +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -0,0 +1,385 @@ +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { createCanvas } from "../../Utils"; +import { ComputePass } from "./compute/ComputePass"; +import { DefendedClearPass } from "./compute/DefendedClearPass"; +import { DefendedUpdatePass } from "./compute/DefendedUpdatePass"; +import { StateUpdatePass } from "./compute/StateUpdatePass"; +import { GroundTruthData } from "./core/GroundTruthData"; +import { WebGPUDevice } from "./core/WebGPUDevice"; +import { RenderPass } from "./render/RenderPass"; +import { TerritoryRenderPass } from "./render/TerritoryRenderPass"; + +export interface TerritoryWebGLCreateResult { + renderer: TerritoryRenderer | null; + reason?: string; +} + +/** + * Main orchestrator for WebGPU territory rendering. + * Manages compute passes (tick-based) and render passes (frame-based). + */ +export class TerritoryRenderer { + public readonly canvas: HTMLCanvasElement; + + private device: WebGPUDevice | null = null; + private resources: GroundTruthData | null = null; + private ready = false; + private initPromise: Promise | null = null; + + // Compute passes + private computePasses: ComputePass[] = []; + private computePassOrder: ComputePass[] = []; + + // Render passes + private renderPasses: RenderPass[] = []; + private renderPassOrder: RenderPass[] = []; + + // Pass instances + private stateUpdatePass: StateUpdatePass | null = null; + private defendedClearPass: DefendedClearPass | null = null; + private defendedUpdatePass: DefendedUpdatePass | null = null; + private territoryRenderPass: TerritoryRenderPass | null = null; + + // State tracking + private needsDefendedRebuild = true; + private needsDefendedHardClear = true; + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = createCanvas(); + this.canvas.style.pointerEvents = "none"; + this.canvas.width = 1; + this.canvas.height = 1; + } + + static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { + const state = game.tileStateView(); + const expected = game.width() * game.height(); + if (state.length !== expected) { + return { + renderer: null, + reason: "Tile state buffer size mismatch; GPU renderer disabled.", + }; + } + + const nav = globalThis.navigator as any; + if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { + return { + renderer: null, + reason: "WebGPU not available; GPU renderer disabled.", + }; + } + + const renderer = new TerritoryRenderer(game, theme); + renderer.startInit(); + return { renderer }; + } + + private startInit(): void { + if (this.initPromise) return; + this.initPromise = this.init(); + } + + private async init(): Promise { + const webgpuDevice = await WebGPUDevice.create(this.canvas); + if (!webgpuDevice) { + return; + } + this.device = webgpuDevice; + + const state = this.game.tileStateView(); + this.resources = GroundTruthData.create( + webgpuDevice.device, + this.game, + this.theme, + state, + ); + + // Upload initial terrain texture + this.resources.uploadTerrain(); + + // Create compute passes + this.stateUpdatePass = new StateUpdatePass(); + this.defendedClearPass = new DefendedClearPass(); + this.defendedUpdatePass = new DefendedUpdatePass(); + + this.computePasses = [ + this.stateUpdatePass, + this.defendedClearPass, + this.defendedUpdatePass, + ]; + + // Create render passes + this.territoryRenderPass = new TerritoryRenderPass(); + this.renderPasses = [this.territoryRenderPass]; + + // Initialize all passes + for (const pass of this.computePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + + for (const pass of this.renderPasses) { + await pass.init( + webgpuDevice.device, + this.resources, + webgpuDevice.canvasFormat, + ); + } + + // Compute dependency order (topological sort) + this.computePassOrder = this.topologicalSort(this.computePasses); + this.renderPassOrder = this.topologicalSort(this.renderPasses); + + this.ready = true; + } + + /** + * Topological sort of passes based on dependencies. + * Ensures passes run in the correct order. + */ + private topologicalSort( + passes: T[], + ): T[] { + const passMap = new Map(); + for (const pass of passes) { + passMap.set(pass.name, pass); + } + + const visited = new Set(); + const visiting = new Set(); + const result: T[] = []; + + const visit = (pass: T): void => { + if (visiting.has(pass.name)) { + console.warn( + `Circular dependency detected involving pass: ${pass.name}`, + ); + return; + } + if (visited.has(pass.name)) { + return; + } + + visiting.add(pass.name); + for (const depName of pass.dependencies) { + const dep = passMap.get(depName); + if (dep) { + visit(dep); + } + } + visiting.delete(pass.name); + visited.add(pass.name); + result.push(pass); + }; + + for (const pass of passes) { + if (!visited.has(pass.name)) { + visit(pass); + } + } + + return result; + } + + setViewSize(width: number, height: number): void { + if (!this.resources || !this.device) { + return; + } + + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + + if (nextWidth === this.canvas.width && nextHeight === this.canvas.height) { + return; + } + + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + this.resources.setViewSize(nextWidth, nextHeight); + this.device.reconfigure(); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + if (!this.resources) { + return; + } + this.resources.setViewTransform(scale, offsetX, offsetY); + } + + setAlternativeView(enabled: boolean): void { + if (!this.resources) { + return; + } + this.resources.setAlternativeView(enabled); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + if (!this.resources) { + return; + } + this.resources.setHighlightedOwnerId(ownerSmallId); + } + + markTile(tile: TileRef): void { + if (this.stateUpdatePass) { + this.stateUpdatePass.markTile(tile); + } + } + + markAllDirty(): void { + this.needsDefendedRebuild = true; + if (this.defendedUpdatePass) { + this.defendedUpdatePass.markDirty(); + } + } + + refreshPalette(): void { + if (!this.resources) { + return; + } + this.resources.markPaletteDirty(); + } + + markDefensePostsDirty(): void { + if (!this.resources) { + return; + } + this.resources.markDefensePostsDirty(); + this.needsDefendedRebuild = true; + if (this.defendedUpdatePass) { + this.defendedUpdatePass.markDirty(); + } + } + + /** + * Perform one simulation tick. + * Runs compute passes to update ground truth data. + */ + tick(): void { + if (!this.ready || !this.device || !this.resources) { + return; + } + + // Upload palette if needed + this.resources.uploadPalette(); + + // Upload defense posts if needed (tracks if it was dirty before upload) + const wasDefensePostsDirty = (this.resources as any) + .needsDefensePostsUpload; + this.resources.uploadDefensePosts(); + + // Initial state upload + this.resources.uploadState(); + + // Check if we need to run compute passes + const numUpdates = this.stateUpdatePass + ? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0) + : 0; + const range = this.game.config().defensePostRange(); + const rangeChanged = range !== this.resources.getLastDefenseRange(); + const countChanged = + this.resources.getDefensePostsCount() !== + this.resources.getLastDefensePostsCount(); + const hasPosts = this.resources.getDefensePostsCount() > 0; + + // Use explicit boolean checks to satisfy linter (|| is correct for boolean OR) + const shouldRebuildDefended = + this.needsDefendedRebuild === true || + wasDefensePostsDirty === true || + rangeChanged === true || + countChanged === true || + (hasPosts && numUpdates > 0); + + const needsCompute = + numUpdates > 0 || + shouldRebuildDefended === true || + this.needsDefendedHardClear === true; + + // Update defense params even if we early-out + if (!needsCompute) { + this.resources.writeDefenseParamsBuffer(); + this.resources.setLastDefenseRange(range); + this.resources.setLastDefensePostsCount( + this.resources.getDefensePostsCount(), + ); + return; + } + + const encoder = this.device.device.createCommandEncoder(); + + // Handle defended rebuild (before executing passes) + if (shouldRebuildDefended) { + // Increment epoch for this rebuild + const epochBefore = this.resources.getDefendedEpoch(); + this.resources.incrementDefendedEpoch(); + const epochAfter = this.resources.getDefendedEpoch(); + + // If epoch wrapped, we need a hard clear + if (epochAfter === 0 || epochAfter < epochBefore) { + this.needsDefendedHardClear = true; + this.resources.incrementDefendedEpoch(); + } + + this.needsDefendedRebuild = false; + } + + // Update hard clear flag for DefendedClearPass + if (this.defendedClearPass) { + this.defendedClearPass.setNeedsHardClear(this.needsDefendedHardClear); + } + + // Execute compute passes in dependency order (clear will run before update if needed) + for (const pass of this.computePassOrder) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } + + // After all passes, update defense params and clear flags + this.resources.writeDefenseParamsBuffer(); + if (this.needsDefendedHardClear && this.defendedClearPass) { + this.needsDefendedHardClear = false; + this.defendedClearPass.setNeedsHardClear(false); + } + + this.resources.setLastDefenseRange(range); + this.resources.setLastDefensePostsCount( + this.resources.getDefensePostsCount(), + ); + + this.device.device.queue.submit([encoder.finish()]); + } + + /** + * Render one frame. + * Runs render passes to draw to the canvas. + */ + render(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.territoryRenderPass + ) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + const textureView = this.device.context.getCurrentTexture().createView(); + + // Execute render passes in dependency order + for (const pass of this.renderPassOrder) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources, textureView); + } + + this.device.device.queue.submit([encoder.finish()]); + } +} diff --git a/src/client/graphics/webgpu/compute/ComputePass.ts b/src/client/graphics/webgpu/compute/ComputePass.ts new file mode 100644 index 000000000..0be77e64e --- /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/DefendedClearPass.ts b/src/client/graphics/webgpu/compute/DefendedClearPass.ts new file mode 100644 index 000000000..d6aa8642f --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedClearPass.ts @@ -0,0 +1,105 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that clears the defended texture (sets all texels to 0). + * Used for initial clear and epoch wrap scenarios. + */ +export class DefendedClearPass implements ComputePass { + name = "defended-clear"; + 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 needsHardClear = true; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-clear.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + needsUpdate(): boolean { + return this.needsHardClear; + } + + 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.needsHardClear = false; + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: this.resources.defendedTexture.createView(), + }, + ], + }); + } + + setNeedsHardClear(value: boolean): void { + this.needsHardClear = value; + } + + 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/DefendedUpdatePass.ts b/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts new file mode 100644 index 000000000..c68d1358b --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts @@ -0,0 +1,159 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that updates the defended texture from defense posts. + */ +export class DefendedUpdatePass implements ComputePass { + name = "defended-update"; + 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 needsRebuild = true; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-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 */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + if (!this.resources || !this.needsRebuild) { + return false; + } + + // Only run if we have defense posts + return this.resources.getDefensePostsCount() > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const range = resources.getGame().config().defensePostRange(); + const postsCount = resources.getDefensePostsCount(); + + if (postsCount === 0) { + this.needsRebuild = false; + return; + } + + // Epoch is incremented by orchestrator before this pass runs + resources.writeDefenseParamsBuffer(); + + const oldBuffer = this.resources?.defensePostsBuffer; + const bufferChanged = oldBuffer !== resources.defensePostsBuffer; + + if (bufferChanged) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const gridSize = 2 * range + 1; + const workgroupCount = Math.ceil(gridSize / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCount, workgroupCount, postsCount); + pass.end(); + + this.needsRebuild = false; + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defenseParamsBuffer || + !this.resources.defensePostsBuffer || + !this.resources.stateTexture || + !this.resources.defendedTexture || + this.resources.getDefensePostsCount() <= 0 + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defenseParamsBuffer }, + }, + { + binding: 1, + resource: { buffer: this.resources.defensePostsBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedTexture.createView(), + }, + ], + }); + } + + markDirty(): void { + this.needsRebuild = true; + } + + 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 000000000..a61a04789 --- /dev/null +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -0,0 +1,146 @@ +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(); + + 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: "read-only-storage" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + 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; + } + + const oldBuffer = this.resources?.updatesBuffer; + const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); + const bufferChanged = oldBuffer !== updatesBuffer; + + 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), + ); + + // Rebuild bind group if buffer changed + if (bufferChanged) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + if (this.bindGroup) { + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(numUpdates); + pass.end(); + } + + this.pendingTiles.clear(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.updatesBuffer || + !this.resources.stateTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.resources.updatesBuffer } }, + { + binding: 1, + resource: this.resources.stateTexture.createView(), + }, + ], + }); + } + + 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/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts new file mode 100644 index 000000000..8ea4c2624 --- /dev/null +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -0,0 +1,524 @@ +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; + + // Textures + public readonly stateTexture: GPUTexture; + public readonly terrainTexture: GPUTexture; + public readonly paletteTexture: GPUTexture; + public readonly defendedTexture: GPUTexture; + + // Buffers + public readonly uniformBuffer: GPUBuffer; + public readonly defenseParamsBuffer: GPUBuffer; + public updatesBuffer: GPUBuffer | null = null; + public defensePostsBuffer: GPUBuffer | null = null; + + // Staging arrays for buffer uploads + private updatesStaging: Uint32Array | null = null; + private defensePostsStaging: Uint32Array | null = null; + + // Buffer capacities + private updatesCapacity = 0; + private defensePostsCapacity = 0; + + // State tracking + private readonly mapWidth: number; + private readonly mapHeight: number; + private readonly state: Uint16Array; + private needsStateUpload = true; + private needsPaletteUpload = true; + private paletteWidth = 1; + private defensePostsCount = 0; + private needsDefensePostsUpload = true; + + // Uniform data arrays + private readonly uniformData = new Float32Array(12); + private readonly defenseParamsData = new Uint32Array(4); + + // 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; + + // Defense state + private defendedEpoch = 1; + private lastDefenseRange = -1; + private lastDefensePostsCount = -1; + + private constructor( + private readonly device: GPUDevice, + private readonly game: GameView, + private readonly theme: Theme, + state: Uint16Array, + mapWidth: number, + mapHeight: number, + ) { + this.state = state; + this.mapWidth = mapWidth; + this.mapHeight = mapHeight; + + 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 TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + + // Render uniforms: 3x vec4f = 48 bytes + this.uniformBuffer = device.createBuffer({ + size: 48, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Defense params: 4x u32 = 16 bytes + this.defenseParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // State texture (r32uint) + this.stateTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Defended texture (r32uint) + this.defendedTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r32uint", + usage: TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Palette texture (rgba8unorm) + this.paletteTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Terrain texture (rgba8unorm) + this.terrainTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + } + + static create( + device: GPUDevice, + game: GameView, + theme: Theme, + state: Uint16Array, + ): GroundTruthData { + return new GroundTruthData( + device, + game, + theme, + state, + 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 { + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + } + + setAlternativeView(enabled: boolean): void { + this.alternativeView = enabled; + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + this.highlightedOwnerId = ownerSmallId ?? -1; + } + + // ===================== + // Upload methods + // ===================== + + uploadState(): void { + if (!this.needsStateUpload) { + return; + } + this.needsStateUpload = false; + + // Convert 16-bit CPU state to 32-bit array + const u32State = new Uint32Array(this.state.length); + for (let i = 0; i < this.state.length; i++) { + u32State[i] = this.state[i]; + } + + const bytesPerTexel = Uint32Array.BYTES_PER_ELEMENT; + const fullBytesPerRow = this.mapWidth * bytesPerTexel; + + if (fullBytesPerRow % 256 === 0) { + this.device.queue.writeTexture( + { texture: this.stateTexture }, + u32State, + { bytesPerRow: fullBytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } else { + // Fallback: upload row-by-row with padding + const paddedBytesPerRow = align(fullBytesPerRow, 256); + const scratch = new Uint32Array(paddedBytesPerRow / 4); + for (let y = 0; y < this.mapHeight; y++) { + const start = y * this.mapWidth; + scratch.set(u32State.subarray(start, start + this.mapWidth), 0); + this.device.queue.writeTexture( + { texture: this.stateTexture, origin: { x: 0, y } }, + scratch, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + } + + 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 }, + ); + } + } + + uploadPalette(): boolean { + if (!this.needsPaletteUpload) { + return false; + } + this.needsPaletteUpload = false; + + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const nextPaletteWidth = + GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); + + 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: 1 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const bytes = new Uint8Array(this.paletteWidth * 4); + + // Store special colors in reserved slots (0-9) + const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4; + bytes[falloutIdx] = 120; + bytes[falloutIdx + 1] = 255; + bytes[falloutIdx + 2] = 71; + bytes[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; + bytes[idx] = rgba.r; + bytes[idx + 1] = rgba.g; + bytes[idx + 2] = rgba.b; + bytes[idx + 3] = 255; + } + + const bytesPerRow = align(this.paletteWidth * 4, 256); + const padded = + bytesPerRow === this.paletteWidth * 4 + ? bytes + : (() => { + const tmp = new Uint8Array(bytesPerRow); + tmp.set(bytes); + return tmp; + })(); + + this.device.queue.writeTexture( + { texture: this.paletteTexture }, + padded, + { bytesPerRow, rowsPerImage: 1 }, + { width: this.paletteWidth, height: 1, depthOrArrayLayers: 1 }, + ); + + return textureRecreated; + } + + uploadDefensePosts(): void { + if (!this.needsDefensePostsUpload) { + return; + } + this.needsDefensePostsUpload = false; + + const posts = this.collectDefensePosts(); + this.defensePostsCount = posts.length; + + if (this.defensePostsCount > 0) { + this.ensureDefensePostsBuffer(this.defensePostsCount); + } + + if ( + this.defensePostsCount > 0 && + this.defensePostsStaging && + this.defensePostsBuffer + ) { + for (let i = 0; i < this.defensePostsCount; i++) { + const p = posts[i]; + this.defensePostsStaging[i * 3] = p.x >>> 0; + this.defensePostsStaging[i * 3 + 1] = p.y >>> 0; + this.defensePostsStaging[i * 3 + 2] = p.ownerId >>> 0; + } + this.device.queue.writeBuffer( + this.defensePostsBuffer, + 0, + this.defensePostsStaging.subarray(0, this.defensePostsCount * 3), + ); + } + } + + 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 ensureDefensePostsBuffer(capacity: number): void { + if (this.defensePostsBuffer && capacity <= this.defensePostsCapacity) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defensePostsCapacity = Math.max( + 8, + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacity)))), + ); + + const bytesPerPost = 12; // 3 * u32 + const bufferSize = this.defensePostsCapacity * bytesPerPost; + + if (this.defensePostsBuffer) { + (this.defensePostsBuffer as any).destroy?.(); + } + + (this as any).defensePostsBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defensePostsStaging = new Uint32Array(this.defensePostsCapacity * 3); + } + + 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?.(); + } + + (this as any).updatesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); + return this.updatesBuffer; + } + + 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] = 0; + this.uniformData[11] = 0; + + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); + } + + writeDefenseParamsBuffer(): void { + const range = this.game.config().defensePostRange() >>> 0; + this.defenseParamsData[0] = range; + this.defenseParamsData[1] = this.defensePostsCount >>> 0; + this.defenseParamsData[2] = this.defendedEpoch >>> 0; + this.defenseParamsData[3] = 0; + this.device.queue.writeBuffer( + this.defenseParamsBuffer, + 0, + this.defenseParamsData, + ); + } + + // ===================== + // State getters/setters + // ===================== + + getDefendedEpoch(): number { + return this.defendedEpoch; + } + + incrementDefendedEpoch(): void { + this.defendedEpoch = (this.defendedEpoch + 1) >>> 0; + if (this.defendedEpoch === 0) { + this.defendedEpoch = 1; + } + } + + getDefensePostsCount(): number { + return this.defensePostsCount; + } + + getLastDefenseRange(): number { + return this.lastDefenseRange; + } + + setLastDefenseRange(range: number): void { + this.lastDefenseRange = range; + } + + getLastDefensePostsCount(): number { + return this.lastDefensePostsCount; + } + + setLastDefensePostsCount(count: number): void { + this.lastDefensePostsCount = count; + } + + markPaletteDirty(): void { + this.needsPaletteUpload = true; + } + + markDefensePostsDirty(): void { + this.needsDefensePostsUpload = true; + } + + 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/ShaderLoader.ts b/src/client/graphics/webgpu/core/ShaderLoader.ts new file mode 100644 index 000000000..c5b818d50 --- /dev/null +++ b/src/client/graphics/webgpu/core/ShaderLoader.ts @@ -0,0 +1,28 @@ +/** + * Utility for loading WGSL shader files via Vite ?raw imports. + * Caches loaded shaders to avoid re-importing. + */ + +const shaderCache = new Map>(); + +/** + * Load a shader file from the shaders directory. + * @param path Relative path from shaders/ directory (e.g., "compute/state-update.wgsl") + * @returns Promise resolving to the shader code as a string + */ +export async function loadShader(path: string): Promise { + // Check cache first + if (shaderCache.has(path)) { + return shaderCache.get(path)!; + } + + // Import shader using Vite ?raw import + const shaderPromise = import(`../shaders/${path}?raw`).then( + (module) => module.default as string, + ); + + // Cache the promise + shaderCache.set(path, shaderPromise); + + return shaderPromise; +} diff --git a/src/client/graphics/webgpu/core/WebGPUDevice.ts b/src/client/graphics/webgpu/core/WebGPUDevice.ts new file mode 100644 index 000000000..27b587a7c --- /dev/null +++ b/src/client/graphics/webgpu/core/WebGPUDevice.ts @@ -0,0 +1,66 @@ +/** + * 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 + * @returns WebGPUDevice instance or null if WebGPU is not available + */ + static async create(canvas: HTMLCanvasElement): 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 000000000..3140d0026 --- /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/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts new file mode 100644 index 000000000..41249ba49 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -0,0 +1,189 @@ +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 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; + + const shaderCode = await loadShader("render/territory.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + 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: "uint" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 4, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 5, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + ], + }); + + this.pipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + + 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; + } + + 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); + resources.writeDefenseParamsBuffer(); + + 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.defenseParamsBuffer || + !this.resources.stateTexture || + !this.resources.defendedTexture || + !this.resources.paletteTexture || + !this.resources.terrainTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.resources.uniformBuffer } }, + { + binding: 1, + resource: { buffer: this.resources.defenseParamsBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedTexture.createView(), + }, + { + binding: 4, + resource: this.resources.paletteTexture.createView(), + }, + { + binding: 5, + resource: this.resources.terrainTexture.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/shaders/common/uniforms.wgsl b/src/client/graphics/webgpu/shaders/common/uniforms.wgsl new file mode 100644 index 000000000..d60f9986f --- /dev/null +++ b/src/client/graphics/webgpu/shaders/common/uniforms.wgsl @@ -0,0 +1,12 @@ +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/w unused +}; + +struct DefenseParams { + range: u32, + postCount: u32, + epoch: u32, + _pad: u32, +}; diff --git a/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl new file mode 100644 index 000000000..682cc4786 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl @@ -0,0 +1,12 @@ +@group(0) @binding(0) var defendedTex: texture_storage_2d; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let dims = textureDimensions(defendedTex); + let x = i32(globalId.x); + let y = i32(globalId.y); + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + textureStore(defendedTex, vec2i(x, y), vec4u(0u, 0u, 0u, 0u)); +} diff --git a/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl new file mode 100644 index 000000000..0d3780661 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl @@ -0,0 +1,53 @@ +struct DefenseParams { + range: u32, + postCount: u32, + epoch: u32, + _pad: u32, +}; + +struct DefensePost { + x: u32, + y: u32, + ownerId: u32, +}; + +@group(0) @binding(0) var d: DefenseParams; +@group(0) @binding(1) var posts: array; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedTex: texture_storage_2d; + +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let postIdx = globalId.z; + let postCount = d.postCount; + if (postIdx >= postCount) { + return; + } + + let range = i32(d.range); + if (range < 0) { + return; + } + + let dx = i32(globalId.x) - range; + let dy = i32(globalId.y) - range; + if (dx * dx + dy * dy > range * range) { + return; + } + + let post = posts[postIdx]; + let x = i32(post.x) + dx; + let y = i32(post.y) + dy; + + let dims = textureDimensions(stateTex); + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + if (owner == post.ownerId) { + textureStore(defendedTex, texCoord, vec4u(d.epoch, 0u, 0u, 0u)); + } +} 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 000000000..9532b8af0 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -0,0 +1,21 @@ +struct Update { + tileIndex: u32, + newState: u32, +}; + +@group(0) @binding(0) var updates: array; +@group(0) @binding(1) var stateTex: texture_storage_2d; + +@compute @workgroup_size(1) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= arrayLength(&updates)) { + 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)); +} 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 000000000..591b59941 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -0,0 +1,98 @@ +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/w unused +}; + +struct DefenseParams { + range: u32, + postCount: u32, + epoch: u32, + _pad: u32, +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var d: DefenseParams; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedTex: texture_2d; +@group(0) @binding(4) var paletteTex: texture_2d; +@group(0) @binding(5) var terrainTex: 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; + + // 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); + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + let defended = textureLoad(defendedTex, texCoord, 0).x == d.epoch; + var territoryRgb = c.rgb; + if (defended) { + territoryRgb = mix(territoryRgb, vec3f(1.0, 0.0, 1.0), 0.35); + } + 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; + } + + // 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 d00e00e09..5f9a79e8a 100644 --- a/src/client/vite-env.d.ts +++ b/src/client/vite-env.d.ts @@ -34,3 +34,13 @@ declare module "*.webp" { const webpContent: string; export default webpContent; } + +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/game/GameImpl.ts b/src/core/game/GameImpl.ts index a9e914e77..33390337b 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -958,7 +958,6 @@ export class GameImpl implements Game { playerID: id, }); } - addUnit(u: Unit) { this.unitGrid.addUnit(u); this._unitMap.set(u.id(), u); @@ -1097,6 +1096,12 @@ export class GameImpl implements Game { 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); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } @@ -1155,6 +1160,9 @@ export class GameImpl implements Game { updateTile(tile: TileRef, state: number): boolean { return this._map.updateTile(tile, state); } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 592d02ca4..77f794779 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -33,6 +33,9 @@ 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; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -96,6 +99,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 @@ -266,6 +270,22 @@ 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; + } + 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 a924abd97..7973d039e 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1323,6 +1323,15 @@ 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(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); }