From 202f59a4107194dded6a8419fbd1beca0ec02017 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:50:00 +0100 Subject: [PATCH] Refactor territory rendering to use WebGPU Replace Canvas 2D-based territory rendering with a WebGPU implementation for improved performance and scalability. Major changes: - Remove TerrainLayer: territory and terrain now rendered together in WebGPU - Add TerritoryWebGLRenderer: new WebGPU-based renderer (1162 lines) - GPU-authoritative state management with compute shaders - Separate uniform buffers for compute and render passes - Efficient incremental updates via scatter/gather compute passes - Full rebuild compute pass for palette/theme changes - Add defense post management: - Track defended state in GameMap (bit 12 in tile state) - Update defended tiles when defense posts are added/removed/moved - Update defended state when tiles change ownership - Extract hover detection into HoverInfo utility: - Centralized logic for player/unit/wilderness detection - Used by PlayerInfoOverlay for cleaner separation of concerns - Canvas architecture changes: - Main canvas now transparent (alpha: true) - WebGPU canvas renders background and territory - Overlay canvas renders UI elements on top - Performance optimizations: - Compute shaders run at simulation rate (tick), not frame rate - Incremental tile updates via pending tiles set - Palette signature tracking to avoid unnecessary rebuilds - Defense posts signature tracking for efficient updates Files changed: - 10 files changed, 1447 insertions(+), 752 deletions(-) - New: TerritoryWebGLRenderer.ts, HoverInfo.ts - Removed: TerrainLayer.ts - Modified: TerritoryLayer.ts (simplified from 710 to 250 lines) --- src/client/graphics/GameRenderer.ts | 14 +- src/client/graphics/HoverInfo.ts | 73 ++ src/client/graphics/TransformHandler.ts | 4 + .../graphics/layers/PlayerInfoOverlay.ts | 58 +- src/client/graphics/layers/TerrainLayer.ts | 77 -- src/client/graphics/layers/TerritoryLayer.ts | 816 +++--------- .../graphics/layers/TerritoryWebGLRenderer.ts | 1162 +++++++++++++++++ src/core/game/GameImpl.ts | 10 +- src/core/game/GameMap.ts | 20 + src/core/game/GameView.ts | 9 + 10 files changed, 1469 insertions(+), 774 deletions(-) create mode 100644 src/client/graphics/HoverInfo.ts delete mode 100644 src/client/graphics/layers/TerrainLayer.ts create mode 100644 src/client/graphics/layers/TerritoryWebGLRenderer.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb38..5c9227ebd 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -37,7 +37,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"; @@ -248,7 +247,6 @@ export function createRenderer( // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ - new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, eventBus, transformHandler), structureLayer, @@ -315,7 +313,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; } @@ -363,13 +362,8 @@ export class GameRenderer { renderGame() { FrameProfiler.clear(); const start = performance.now(); - // Set background - this.context.fillStyle = this.game - .config() - .theme() - .backgroundColor() - .toHex(); - this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + // Clear overlay canvas to transparent; the territory WebGPU canvas draws the base. + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); const handleTransformState = ( needsTransform: boolean, 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 6cf43cd77..2a6f0d7fb 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -45,6 +45,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 0bc367ce7..2dbf7f28d 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -7,10 +7,8 @@ import { PlayerProfile, PlayerType, Relation, - Unit, UnitType, } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; import { AllianceView } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler"; @@ -20,6 +18,7 @@ import { renderTroops, translateText, } from "../../Utils"; +import { getHoverInfo } from "../HoverInfo"; import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -33,26 +32,6 @@ import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url"; import portIcon from "/images/PortIcon.svg?url"; import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url"; -function euclideanDistWorld( - coord: { x: number; y: number }, - tileRef: TileRef, - game: GameView, -): number { - const x = game.x(tileRef); - const y = game.y(tileRef); - const dx = coord.x - x; - const dy = coord.y - y; - return Math.sqrt(dx * dx + dy * dy); -} - -function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { - return (a: Unit | UnitView, b: Unit | UnitView) => { - const distA = euclideanDistWorld(coord, a.tile(), game); - const distB = euclideanDistWorld(coord, b.tile(), game); - return distA - distB; - }; -} - @customElement("player-info-overlay") export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) @@ -119,38 +98,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer { 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 (owner && !owner.isPlayer() && this.game.isLand(tile)) { - if (this.game.hasFallout(tile)) { - this.isIrradiatedWilderness = true; - } else { - this.isWilderness = 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); - } 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); - } } } diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts deleted file mode 100644 index 57efc759f..000000000 --- a/src/client/graphics/layers/TerrainLayer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { 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; - - constructor( - private game: GameView, - private transformHandler: TransformHandler, - ) {} - shouldTransform(): boolean { - return true; - } - tick() { - if (this.game.config().theme() !== this.theme) { - this.redraw(); - } - } - - 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.game.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 08d3f5a9c..b7c5b991d 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,710 +1,250 @@ -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 { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { - AlternateViewEvent, - DragEvent, - MouseOverEvent, -} from "../../InputHandler"; +import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler"; import { FrameProfiler } from "../FrameProfiler"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer"; export class TerritoryLayer implements Layer { - private userSettings: UserSettings; - 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 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: TerritoryWebGLRenderer | 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, - userSettings: UserSettings, + private userSettings: UserSettings, ) { - this.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)); - 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() { - if (this.game.ticks() % 5 === 0) { - return; - } - - 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 } = TerritoryWebGLRenderer.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 (this.attachedTerritoryCanvas !== canvas) { + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = canvas; + } + + const parent = mainCanvas.parentNode; + if (!parent) { + 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); + if (!canvas.isConnected) { + parent.insertBefore(canvas, mainCanvas); + return; + } + + if (canvas.parentNode !== parent) { + parent.insertBefore(canvas, mainCanvas); + return; + } + + if (canvas.nextSibling !== mainCanvas) { + parent.insertBefore(canvas, mainCanvas); + } + } + + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); + } + + private updateHoverHighlight() { + if (!this.territoryRenderer) { + return; + } + + const now = performance.now(); + if (now - this.lastHoverUpdateMs < 100) { + return; + } + this.lastHoverUpdateMs = now; + + 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(); + } } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), + } + + 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)}`, ); - - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, - ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); } + parts.sort(); + return parts.join("|"); } - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); + private refreshDefensePostsIfNeeded() { + if (!this.territoryRenderer) { + return; } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); + const signature = this.computeDefensePostsSignature(); + if (signature !== this.lastDefensePostsSignature) { + this.lastDefensePostsSignature = signature; + this.territoryRenderer.markDefensePostsDirty(); } - 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, - ) { - const ctx = this.highlightContext; - if (!ctx) return; - - // 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); - - // 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()); - - // 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 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()); - - // 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(); } } diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts new file mode 100644 index 000000000..91706b6d6 --- /dev/null +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -0,0 +1,1162 @@ +import { Theme } from "../../../core/configuration/Config"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { createCanvas } from "../../Utils"; + +export interface TerritoryWebGLCreateResult { + renderer: TerritoryWebGLRenderer | null; + reason?: string; +} + +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +// Minimal territory renderer backed by WebGPU. +// Note: Name kept to minimize diff against the previous WebGL implementation. +export class TerritoryWebGLRenderer { + public readonly canvas: HTMLCanvasElement; + + private readonly mapWidth: number; + private readonly mapHeight: number; + private readonly clearR: number; + private readonly clearG: number; + private readonly clearB: number; + private viewWidth = 1; + private viewHeight = 1; + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + private alternativeView = false; + private highlightedOwnerId = -1; + + private readonly state: Uint16Array; + // Track tiles that need to be updated on the GPU. Use a Set to avoid duplicates. + private readonly pendingTiles: Set = new Set(); + private needsFullRebuild = true; + private needsPaletteUpload = true; + // When using a GPU-authoritative state, the CPU does not upload state + // textures after initialization. Keep this flag for initial upload only. + private needsStateUpload = true; + private paletteWidth = 1; + + // Render uniform layout (48 bytes): + // [0..3] mapResolution_viewScale + // [4..7] viewOffset + // [8..11] defenseParams (x=defensePostRange, y=defensePostsCount) + private readonly uniformData = new Float32Array(12); + + // Compute uniform layout (16 bytes): + // [0..3] defenseParams (x=defensePostRange, y=defensePostsCount, z/w unused) + private readonly computeUniformData = new Float32Array(4); + + private readonly computeParams = new Uint32Array(4); + + private stateBuffer: any | null = null; + private computeParamsBuffer: any | null = null; + private updatesBuffer: any | null = null; + private updatesCapacity = 0; + private updatesStaging: Uint32Array | null = null; + + // Defended tiles resources + private defendedTex: any | null = null; + private defensePostsBuffer: any | null = null; + private defensePostsStaging: Uint32Array | null = null; + private defensePostsCount = 0; + private needsDefensePostsUpload = true; + private defensePostsCapacity = 0; + + // Bind group layout and bind group for scatter (state update) compute pass + private computeBindGroupLayoutScatter: any | null = null; + private scatterBindGroup: any | null = null; + // Compute pipelines for scatter/state update + private computePipelineScatterState: any | null = null; + + // Clear defended texture pass + private clearDefendedBindGroupLayout: any | null = null; + private clearDefendedBindGroup: any | null = null; + private computePipelineClearDefended: any | null = null; + + // Update defended texture pass + private updateDefendedBindGroupLayout: any | null = null; + private updateDefendedBindGroup: any | null = null; + private computePipelineUpdateDefended: any | null = null; + + // WebGPU objects are intentionally typed as `any` to avoid requiring WebGPU + // TypeScript libs in this repo. + private device: any | null = null; + private context: any | null = null; + private canvasFormat: any | null = null; + private pipeline: any | null = null; + private bindGroupLayout: any | null = null; + private bindGroup: any | null = null; + private uniformBuffer: any | null = null; // For render shader (48 bytes) + private computeUniformBuffer: any | null = null; // For compute shader (16 bytes, only defenseParams) + private stateTexture: any | null = null; + private terrainTexture: any | null = null; + private paletteTexture: any | null = null; + + private initPromise: Promise | null = null; + private ready = false; + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + state: Uint16Array, + ) { + this.canvas = createCanvas(); + this.canvas.style.pointerEvents = "none"; + this.mapWidth = game.width(); + this.mapHeight = game.height(); + this.canvas.width = 1; + this.canvas.height = 1; + this.state = state; + + const bg = this.theme.backgroundColor().rgba; + this.clearR = bg.r / 255; + this.clearG = bg.g / 255; + this.clearB = bg.b / 255; + } + + 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 TerritoryWebGLRenderer(game, theme, state); + renderer.startInit(); + return { renderer }; + } + + private startInit() { + if (this.initPromise) return; + this.initPromise = this.init(); + } + + private async init() { + const nav = globalThis.navigator as any; + const adapter = await nav.gpu.requestAdapter(); + if (!adapter) { + return; + } + + const device = await adapter.requestDevice(); + this.device = device; + + const context = this.canvas.getContext("webgpu"); + if (!context) { + return; + } + this.context = context; + + this.canvasFormat = + typeof nav.gpu.getPreferredCanvasFormat === "function" + ? nav.gpu.getPreferredCanvasFormat() + : "bgra8unorm"; + + this.configureContext(); + this.createGpuResources(); + this.ready = true; + } + + private configureContext() { + if (!this.context || !this.device || !this.canvasFormat) return; + this.context.configure({ + device: this.device, + format: this.canvasFormat, + alphaMode: "opaque", + }); + } + + private createGpuResources() { + if (!this.device) return; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + // const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + 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 uniform buffer: 3x vec4f = 48 bytes + this.uniformBuffer = this.device.createBuffer({ + size: 48, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Compute uniform buffer: 1x vec4f = 16 bytes (only defenseParams) + this.computeUniformBuffer = this.device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Create the state texture as a 32-bit unsigned integer texture. It + // includes STORAGE_BINDING so it can be written in a compute shader and + // TEXTURE_BINDING so it can be read in the fragment and compute shaders. + this.stateTexture = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Defended tiles texture (uint 0/1). Using r32uint for broad WebGPU support. + this.defendedTex = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "r32uint", + usage: TEXTURE_BINDING | STORAGE_BINDING, + }); + + this.paletteTexture = this.device.createTexture({ + size: { width: 1, height: 1 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + this.terrainTexture = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + this.uploadTerrainTexture(); + + const shader = this.device.createShaderModule({ + code: ` +struct Uniforms { + mapResolution_viewScale: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + defenseParams: vec4f, // x=defensePostRange, y=defensePostsCount, z/w unused +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) 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); +} + +fn composeBaseColor(texCoord: vec2i, owner: u32) -> vec4f { + let terrain = textureLoad(terrainTex, texCoord, 0); + if (owner == 0u) { + return terrain; + } + + let c = textureLoad(paletteTex, vec2i(i32(owner), 0), 0); + let defended = textureLoad(defendedTex, texCoord, 0).x != 0u; + var territoryRgb = c.rgb; + if (defended) { + territoryRgb = mix(territoryRgb, vec3f(1.0, 0.0, 1.0), 0.35); + } + + return vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale.xy; + let viewScale = u.mapResolution_viewScale.z; + let timeSec = u.mapResolution_viewScale.w; + let viewOffset = u.viewOffset.xy; + let altView = u.viewOffset.z; + let highlightId = u.viewOffset.w; + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if (mapCoord.x < 0.0 || mapCoord.y < 0.0 || mapCoord.x >= mapRes.x || mapCoord.y >= mapRes.y) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + + var outColor = composeBaseColor(texCoord, owner); + + // Apply alternative view (hide territory by showing terrain only) + if (altView > 0.5 && owner != 0u) { + outColor = textureLoad(terrainTex, texCoord, 0); + } + + // 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; +} +`, + }); + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 4, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + ], + }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shader, entryPoint: "vsMain" }, + fragment: { + module: shader, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + + // ===================== + // Compute shaders + // ===================== + + // Compute pass 1: Scatter state updates into the state texture. Writes the + // newState value into the state texture at the specified tile index. + const computeShaderScatter = this.device.createShaderModule({ + code: ` +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)); +} +`, + }); + + // Compute pass 2: Clear defended texture (set all texels to 0). + const computeShaderClearDefended = this.device.createShaderModule({ + code: ` +@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)); +} +`, + }); + + // Compute pass 3: Update defended texture from defense posts. + const computeShaderUpdateDefended = this.device.createShaderModule({ + code: ` +struct ComputeUniforms { + defenseParams: vec4f, // x=range, y=postCount, z/w unused +}; + +struct DefensePost { + x: u32, + y: u32, + ownerId: u32, +}; + +@group(0) @binding(0) var u: ComputeUniforms; +@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 = u32(u.defenseParams.y); + if (postIdx >= postCount) { + return; + } + + let range = i32(u.defenseParams.x); + 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(1u, 0u, 0u, 0u)); + } +} +`, + }); + + // ===================== + // Bind group layouts + // ===================== + + // Bind group layout for scatter pass: updates buffer and state texture (write-only) + this.computeBindGroupLayoutScatter = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + // Bind group layout for clear defended pass + this.clearDefendedBindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + // Bind group layout for update defended pass + this.updateDefendedBindGroupLayout = this.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" }, + }, + ], + }); + + // ===================== + // Compute pipelines + // ===================== + + this.computePipelineScatterState = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.computeBindGroupLayoutScatter], + }), + compute: { + module: computeShaderScatter, + entryPoint: "main", + }, + }); + + this.computePipelineClearDefended = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.clearDefendedBindGroupLayout], + }), + compute: { + module: computeShaderClearDefended, + entryPoint: "main", + }, + }); + + this.computePipelineUpdateDefended = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.updateDefendedBindGroupLayout], + }), + compute: { + module: computeShaderUpdateDefended, + entryPoint: "main", + }, + }); + + // Create the bind groups for fragment rendering and compute passes + this.rebuildBindGroup(); + this.rebuildScatterBindGroup(); + this.rebuildClearDefendedBindGroup(); + // updateDefendedBindGroup is created after the defense posts buffer exists. + } + + private rebuildBindGroup() { + if ( + !this.device || + !this.bindGroupLayout || + !this.uniformBuffer || + !this.stateTexture || + !this.defendedTex || + !this.paletteTexture || + !this.terrainTexture + ) { + return; + } + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniformBuffer } }, + { binding: 1, resource: this.stateTexture.createView() }, + { binding: 2, resource: this.defendedTex.createView() }, + { binding: 3, resource: this.paletteTexture.createView() }, + { binding: 4, resource: this.terrainTexture.createView() }, + ], + }); + } + + private rebuildScatterBindGroup() { + // Create the bind group for the scatter compute pass. It binds the + // updates buffer and the state texture as a storage texture. + if ( + !this.device || + !this.computeBindGroupLayoutScatter || + !this.updatesBuffer || + !this.stateTexture + ) { + return; + } + this.scatterBindGroup = this.device.createBindGroup({ + layout: this.computeBindGroupLayoutScatter, + entries: [ + { binding: 0, resource: { buffer: this.updatesBuffer } }, + { binding: 1, resource: this.stateTexture.createView() }, + ], + }); + } + + private rebuildClearDefendedBindGroup() { + if ( + !this.device || + !this.clearDefendedBindGroupLayout || + !this.defendedTex + ) { + return; + } + this.clearDefendedBindGroup = this.device.createBindGroup({ + layout: this.clearDefendedBindGroupLayout, + entries: [{ binding: 0, resource: this.defendedTex.createView() }], + }); + } + + private rebuildUpdateDefendedBindGroup() { + if ( + !this.device || + !this.updateDefendedBindGroupLayout || + !this.computeUniformBuffer || + !this.defensePostsBuffer || + !this.stateTexture || + !this.defendedTex || + this.defensePostsCount <= 0 + ) { + this.updateDefendedBindGroup = null; + return; + } + + this.updateDefendedBindGroup = this.device.createBindGroup({ + layout: this.updateDefendedBindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.computeUniformBuffer } }, + { binding: 1, resource: { buffer: this.defensePostsBuffer } }, + { binding: 2, resource: this.stateTexture.createView() }, + { binding: 3, resource: this.defendedTex.createView() }, + ], + }); + } + + public markDefensePostsDirty() { + this.needsDefensePostsUpload = true; + this.needsFullRebuild = true; + } + + setAlternativeView(enabled: boolean) { + this.alternativeView = enabled; + } + + setHighlightedOwnerId(ownerSmallId: number | null) { + this.highlightedOwnerId = ownerSmallId ?? -1; + } + + setViewSize(width: number, height: number) { + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + if (nextWidth === this.viewWidth && nextHeight === this.viewHeight) { + return; + } + this.viewWidth = nextWidth; + this.viewHeight = nextHeight; + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + this.configureContext(); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number) { + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + } + + markTile(tile: TileRef) { + // Always add the tile to the set of pending updates. Even if a full + // rebuild is pending we still need to update the GPU state texture for + // this tile so any derived textures (e.g. defended tiles) use the correct state. + this.pendingTiles.add(tile); + // No need to mark stateTexture for upload; the GPU owns the state + // texture. Updates will be scattered via compute in tick(). + } + + markAllDirty() { + this.needsFullRebuild = true; + // Do not clear pending updates. A full rebuild will still require any + // outstanding state updates to be applied first so that any derived + // textures (e.g. defended tiles) are computed from the latest state. + } + + refreshPalette() { + this.needsPaletteUpload = true; + } + + private ensureUpdatesBuffer(capacity: number) { + if (this.updatesBuffer && capacity <= this.updatesCapacity) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + // Round up to next power of 2 for capacity + this.updatesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(capacity))), + ); + const bufferSize = this.updatesCapacity * 8; // Each update is 8 bytes (u32 tileIndex + u32 newState) + + if (this.updatesBuffer) { + this.updatesBuffer.destroy?.(); + } + + this.updatesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); + // Rebuild the scatter bind group because the buffer has changed + this.rebuildScatterBindGroup(); + } + + private ensureDefensePostsBuffer(capacity: number) { + 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.destroy?.(); + } + + this.defensePostsBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defensePostsStaging = new Uint32Array(this.defensePostsCapacity * 3); + + // Buffer changed -> rebuild bind group + this.rebuildUpdateDefendedBindGroup(); + } + + 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 UnitView[]; + 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 uploadDefensePostsIfNeeded() { + if (!this.device || !this.needsDefensePostsUpload) { + return; + } + + const posts = this.collectDefensePosts(); + this.defensePostsCount = posts.length; + + // Reallocate buffer if needed + 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), + ); + } + + // Rebuild bind group because defensePostsCount may have changed. + this.rebuildUpdateDefendedBindGroup(); + + this.needsDefensePostsUpload = false; + } + + private uploadStateTextureIfNeeded() { + if (!this.device || !this.stateTexture || !this.needsStateUpload) { + return; + } + this.needsStateUpload = false; + + // When the state texture is 32-bit, convert the 16-bit CPU state + // to a 32-bit array before uploading. Store the 16-bit value in the + // lower 16 bits and zero the upper 16 bits. This provides enough + // space for additional flags. + 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 }, + ); + } + } + } + + private uploadPaletteIfNeeded() { + if (!this.device || !this.paletteTexture || !this.needsPaletteUpload) { + return; + } + this.needsPaletteUpload = false; + + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const nextPaletteWidth = Math.max(1, maxSmallId + 1); + + if (nextPaletteWidth !== this.paletteWidth) { + this.paletteWidth = nextPaletteWidth; + this.paletteTexture.destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + this.paletteTexture = this.device.createTexture({ + size: { width: this.paletteWidth, height: 1 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + this.rebuildBindGroup(); + } + + const bytes = new Uint8Array(this.paletteWidth * 4); + // ownerId 0 stays transparent. + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0 || id >= this.paletteWidth) continue; + const rgba = player.territoryColor().rgba; + const idx = 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 }, + ); + } + + private uploadTerrainTexture() { + if (!this.device || !this.terrainTexture) { + return; + } + + 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 }, + ); + } + } + + /** + * Write compute uniform buffer. Only contains defenseParams which is + * the only uniform data the compute shader needs. + */ + private writeComputeUniformBuffer() { + if (!this.computeUniformBuffer || !this.device) { + return; + } + + const range = this.game.config().defensePostRange(); + this.computeUniformData[0] = range; + this.computeUniformData[1] = this.defensePostsCount; + this.computeUniformData[2] = 0; + this.computeUniformData[3] = 0; + + this.device.queue.writeBuffer( + this.computeUniformBuffer, + 0, + this.computeUniformData, + ); + } + + /** + * Update the common uniform fields for render (map dimensions and defense params). + */ + private updateRenderUniformFields() { + const range = this.game.config().defensePostRange(); + this.uniformData[0] = this.mapWidth; + this.uniformData[1] = this.mapHeight; + this.uniformData[8] = range; + this.uniformData[9] = this.defensePostsCount; + this.uniformData[10] = 0; + this.uniformData[11] = 0; + } + + /** + * Update the view transform fields used only by rendering. + */ + private updateViewTransformFields(timeSec: number) { + 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; + } + + /** + * Write uniform buffer for rendering. Updates all values including + * view transform which is used by the fragment shader. + */ + private writeUniformBuffer(timeSec: number) { + if (!this.uniformBuffer || !this.device) { + return; + } + + this.updateRenderUniformFields(); + this.updateViewTransformFields(timeSec); + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); + } + + /** + * Perform one simulation tick. This uploads any staged palette changes, any + * pending tile updates, and dispatches compute passes to update the state + * texture and defended texture. Compute work + * only runs when necessary. + */ + public tick() { + if (!this.ready || !this.device) { + return; + } + + // Upload palette if needed. Rendering uses the palette texture directly. + this.uploadPaletteIfNeeded(); + + // Upload defense posts buffer if needed. + this.uploadDefensePostsIfNeeded(); + + // If the state texture has not yet been uploaded (initial upload), do so now. + // This will convert the CPU's 16-bit state array into a 32-bit texture. + this.uploadStateTextureIfNeeded(); + + // Determine how many updates need to be processed + const numUpdates = this.pendingTiles.size; + + // Early-out if no compute work is needed + if (numUpdates === 0 && !this.needsFullRebuild) { + return; + } + + // Update compute uniform buffer (only defense params needed). + this.writeComputeUniformBuffer(); + + const encoder = this.device.createCommandEncoder(); + + // 1) Clear defended texture + if ( + this.computePipelineClearDefended && + this.clearDefendedBindGroup && + this.defendedTex + ) { + const clearPass = encoder.beginComputePass(); + clearPass.setPipeline(this.computePipelineClearDefended); + clearPass.setBindGroup(0, this.clearDefendedBindGroup); + const workgroupCountX = Math.ceil(this.mapWidth / 8); + const workgroupCountY = Math.ceil(this.mapHeight / 8); + clearPass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + clearPass.end(); + } + + // 2) Scatter state updates + if (numUpdates > 0) { + this.ensureUpdatesBuffer(numUpdates); + if (this.updatesStaging && this.updatesBuffer) { + let idx = 0; + for (const tile of this.pendingTiles) { + const stateValue = this.state[tile]; + this.updatesStaging[idx * 2] = tile; + this.updatesStaging[idx * 2 + 1] = stateValue; + idx++; + } + this.device.queue.writeBuffer( + this.updatesBuffer, + 0, + this.updatesStaging.subarray(0, numUpdates * 2), + ); + // Rebuild scatter bind group in case the buffer changed + this.rebuildScatterBindGroup(); + if (this.scatterBindGroup && this.computePipelineScatterState) { + const scatterPass = encoder.beginComputePass(); + scatterPass.setPipeline(this.computePipelineScatterState); + scatterPass.setBindGroup(0, this.scatterBindGroup); + scatterPass.dispatchWorkgroups(numUpdates); + scatterPass.end(); + } + // Clear pending tiles on CPU side + this.pendingTiles.clear(); + } + } + + // 3) Update defended texture + if ( + this.defensePostsCount > 0 && + this.computePipelineUpdateDefended && + this.updateDefendedBindGroup + ) { + const range = this.game.config().defensePostRange(); + const gridSize = 2 * range + 1; + const workgroupCount = Math.ceil(gridSize / 8); + + const defendedPass = encoder.beginComputePass(); + defendedPass.setPipeline(this.computePipelineUpdateDefended); + defendedPass.setBindGroup(0, this.updateDefendedBindGroup); + defendedPass.dispatchWorkgroups( + workgroupCount, + workgroupCount, + this.defensePostsCount, + ); + defendedPass.end(); + } + // No full-map colour rebuild here: rendering composes colours at view resolution. + + this.needsFullRebuild = false; + + this.device.queue.submit([encoder.finish()]); + } + + render() { + if ( + !this.ready || + !this.device || + !this.context || + !this.pipeline || + !this.bindGroup + ) { + return; + } + + // Update uniforms + this.writeUniformBuffer(performance.now() / 1000); + + // Encode render pass. No compute work is scheduled here; all compute happens in tick(). + const encoder = this.device.createCommandEncoder(); + const textureView = this.context.getCurrentTexture().createView(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: textureView, + 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(); + this.device.queue.submit([encoder.finish()]); + } +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a2bd1c902..9ba2c832e 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -808,7 +808,6 @@ export class GameImpl implements Game { playerID: id, }); } - addUnit(u: Unit) { this.unitGrid.addUnit(u); } @@ -929,6 +928,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); } @@ -987,6 +992,9 @@ export class GameImpl implements Game { updateTile(tu: TileUpdate): TileRef { return this._map.updateTile(tu); } + 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 136fdf1d9..dd00f3b22 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -27,6 +27,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[]; @@ -76,6 +79,7 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; + private static readonly DEFENDED_BIT = 12; private static readonly FALLOUT_BIT = 13; private static readonly DEFENSE_BONUS_BIT = 14; // Bit 15 still reserved @@ -211,6 +215,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 15ce0d564..72d00d8af 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -880,6 +880,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); }