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 01/23] 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); } From d149ff48ddefadc44bd08ab6ebf2a3484e64fec0 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:55:50 +0100 Subject: [PATCH 02/23] move terrain color computation to GPU compute shader --- src/client/graphics/layers/TerritoryLayer.ts | 9 + .../graphics/webgpu/TerritoryRenderer.ts | 89 ++++++- .../webgpu/compute/TerrainComputePass.ts | 127 ++++++++++ .../graphics/webgpu/core/GroundTruthData.ts | 225 +++++++++++++++++- .../shaders/compute/terrain-compute.wgsl | 102 ++++++++ src/core/game/GameImpl.ts | 3 + src/core/game/GameMap.ts | 5 + src/core/game/GameView.ts | 3 + 8 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 src/client/graphics/webgpu/compute/TerrainComputePass.ts create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 7ef437b4d..632e96432 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -62,6 +62,7 @@ export class TerritoryLayer implements Layer { const currentTheme = this.game.config().theme(); if (currentTheme !== this.theme) { this.theme = currentTheme; + this.territoryRenderer?.refreshTerrain(); this.redraw(); } @@ -116,6 +117,14 @@ export class TerritoryLayer implements Layer { return; } + // Check for theme changes in renderLayer too (for when game is paused) + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.territoryRenderer.refreshTerrain(); + this.redraw(); + } + this.ensureTerritoryCanvasAttached(context.canvas); this.updateHoverHighlight(); diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 171510e6f..7256f8765 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -6,6 +6,7 @@ import { ComputePass } from "./compute/ComputePass"; import { DefendedClearPass } from "./compute/DefendedClearPass"; import { DefendedUpdatePass } from "./compute/DefendedUpdatePass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; +import { TerrainComputePass } from "./compute/TerrainComputePass"; import { GroundTruthData } from "./core/GroundTruthData"; import { WebGPUDevice } from "./core/WebGPUDevice"; import { RenderPass } from "./render/RenderPass"; @@ -37,6 +38,7 @@ export class TerritoryRenderer { private renderPassOrder: RenderPass[] = []; // Pass instances + private terrainComputePass: TerrainComputePass | null = null; private stateUpdatePass: StateUpdatePass | null = null; private defendedClearPass: DefendedClearPass | null = null; private defendedUpdatePass: DefendedUpdatePass | null = null; @@ -99,15 +101,18 @@ export class TerritoryRenderer { state, ); - // Upload initial terrain texture - this.resources.uploadTerrain(); + // Upload terrain data and params (terrain colors will be computed on GPU) + this.resources.uploadTerrainData(); + this.resources.uploadTerrainParams(); - // Create compute passes + // Create compute passes (terrain compute should run first) + this.terrainComputePass = new TerrainComputePass(); this.stateUpdatePass = new StateUpdatePass(); this.defendedClearPass = new DefendedClearPass(); this.defendedUpdatePass = new DefendedUpdatePass(); this.computePasses = [ + this.terrainComputePass, this.stateUpdatePass, this.defendedClearPass, this.defendedUpdatePass, @@ -255,6 +260,50 @@ export class TerritoryRenderer { } } + refreshTerrain(): void { + if (!this.resources || !this.device) { + return; + } + this.resources.markTerrainParamsDirty(); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + // Immediately compute terrain to avoid blank rendering + this.computeTerrainImmediate(); + } + } + + /** + * Immediately execute terrain compute pass (for theme changes). + * This ensures terrain is recomputed before the next render. + */ + private computeTerrainImmediate(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.terrainComputePass + ) { + return; + } + + // Upload terrain params if needed + this.resources.uploadTerrainParams(); + + if (!this.terrainComputePass.needsUpdate()) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(encoder, this.resources); + this.device.device.queue.submit([encoder.finish()]); + + // Rebuild render pass bind group to ensure it uses the updated terrain texture + // This will be called again in render(), but doing it here ensures it's ready + if (this.territoryRenderPass) { + (this.territoryRenderPass as any).rebuildBindGroup?.(); + } + } + /** * Perform one simulation tick. * Runs compute passes to update ground truth data. @@ -267,6 +316,9 @@ export class TerritoryRenderer { // Upload palette if needed this.resources.uploadPalette(); + // Upload terrain params if needed (theme changed) + this.resources.uploadTerrainParams(); + // Upload defense posts if needed (tracks if it was dirty before upload) const wasDefensePostsDirty = (this.resources as any) .needsDefensePostsUpload; @@ -279,6 +331,9 @@ export class TerritoryRenderer { const numUpdates = this.stateUpdatePass ? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0) : 0; + const needsTerrainCompute = this.terrainComputePass + ? this.terrainComputePass.needsUpdate() + : false; const range = this.game.config().defensePostRange(); const rangeChanged = range !== this.resources.getLastDefenseRange(); const countChanged = @@ -295,6 +350,7 @@ export class TerritoryRenderer { (hasPosts && numUpdates > 0); const needsCompute = + needsTerrainCompute === true || numUpdates > 0 || shouldRebuildDefended === true || this.needsDefendedHardClear === true; @@ -369,6 +425,33 @@ export class TerritoryRenderer { return; } + // Check if terrain needs recomputation (e.g., theme changed) + // If so, compute it in the same command buffer before rendering + if (this.terrainComputePass?.needsUpdate()) { + this.resources.uploadTerrainParams(); + + // Use a single encoder to ensure compute completes before render + const encoder = this.device.device.createCommandEncoder(); + + // Execute terrain compute first + this.terrainComputePass.execute(encoder, this.resources); + + // Then execute render passes in the same command buffer + // The render pass will rebuild its bind group, which will now use the updated terrain texture + const textureView = this.device.context.getCurrentTexture().createView(); + for (const pass of this.renderPassOrder) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources, textureView); + } + + // Submit single command buffer with both compute and render + // This ensures compute completes before render reads the terrain texture + this.device.device.queue.submit([encoder.finish()]); + return; + } + const encoder = this.device.device.createCommandEncoder(); const textureView = this.device.context.getCurrentTexture().createView(); diff --git a/src/client/graphics/webgpu/compute/TerrainComputePass.ts b/src/client/graphics/webgpu/compute/TerrainComputePass.ts new file mode 100644 index 000000000..16f53a21a --- /dev/null +++ b/src/client/graphics/webgpu/compute/TerrainComputePass.ts @@ -0,0 +1,127 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that generates terrain colors from terrain data. + * Runs once at initialization or when theme changes. + */ +export class TerrainComputePass implements ComputePass { + name = "terrain-compute"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private needsCompute = true; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/terrain-compute.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + needsUpdate(): boolean { + return this.needsCompute; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.bindGroup) { + return; + } + + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + pass.end(); + + this.needsCompute = false; + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.terrainParamsBuffer || + !this.resources.terrainDataTexture || + !this.resources.terrainTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.terrainParamsBuffer }, + }, + { + binding: 1, + resource: this.resources.terrainDataTexture.createView(), + }, + { + binding: 2, + resource: this.resources.terrainTexture.createView(), + }, + ], + }); + } + + markDirty(): void { + this.needsCompute = true; + // Rebuild bind group in case terrain params buffer was recreated + this.rebuildBindGroup(); + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 8ea4c2624..6444791c0 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -20,12 +20,14 @@ export class GroundTruthData { // Textures public readonly stateTexture: GPUTexture; public readonly terrainTexture: GPUTexture; + public readonly terrainDataTexture: GPUTexture; public readonly paletteTexture: GPUTexture; public readonly defendedTexture: GPUTexture; // Buffers public readonly uniformBuffer: GPUBuffer; public readonly defenseParamsBuffer: GPUBuffer; + public readonly terrainParamsBuffer: GPUBuffer; public updatesBuffer: GPUBuffer | null = null; public defensePostsBuffer: GPUBuffer | null = null; @@ -41,8 +43,11 @@ export class GroundTruthData { private readonly mapWidth: number; private readonly mapHeight: number; private readonly state: Uint16Array; + private readonly terrainData: Uint8Array; private needsStateUpload = true; private needsPaletteUpload = true; + private needsTerrainDataUpload = true; + private needsTerrainParamsUpload = true; private paletteWidth = 1; private defensePostsCount = 0; private needsDefensePostsUpload = true; @@ -50,6 +55,7 @@ export class GroundTruthData { // Uniform data arrays private readonly uniformData = new Float32Array(12); private readonly defenseParamsData = new Uint32Array(4); + private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase // View state (updated by renderer) private viewWidth = 1; @@ -70,10 +76,12 @@ export class GroundTruthData { private readonly game: GameView, private readonly theme: Theme, state: Uint16Array, + terrainData: Uint8Array, mapWidth: number, mapHeight: number, ) { this.state = state; + this.terrainData = terrainData; this.mapWidth = mapWidth; this.mapHeight = mapHeight; @@ -97,6 +105,12 @@ export class GroundTruthData { usage: UNIFORM | COPY_DST_BUF, }); + // Terrain params: 6x vec4f = 96 bytes (shore, water, shorelineWater, plainsBase, highlandBase, mountainBase) + this.terrainParamsBuffer = device.createBuffer({ + size: 96, + usage: UNIFORM | COPY_DST_BUF, + }); + // State texture (r32uint) this.stateTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, @@ -118,10 +132,17 @@ export class GroundTruthData { usage: COPY_DST_TEX | TEXTURE_BINDING, }); - // Terrain texture (rgba8unorm) + // Terrain texture (rgba8unorm) - output of terrain compute shader this.terrainTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Terrain data texture (r8uint) - input terrain data (read-only in compute shader) + this.terrainDataTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r8uint", usage: COPY_DST_TEX | TEXTURE_BINDING, }); } @@ -137,6 +158,7 @@ export class GroundTruthData { game, theme, state, + game.terrainDataView(), game.width(), game.height(), ); @@ -212,6 +234,9 @@ export class GroundTruthData { } } + /** + * @deprecated Use terrain compute shader instead. This method is kept for fallback. + */ uploadTerrain(): void { const bytesPerRow = this.mapWidth * 4; const paddedBytesPerRow = align(bytesPerRow, 256); @@ -241,6 +266,204 @@ export class GroundTruthData { } } + uploadTerrainData(): void { + if (!this.needsTerrainDataUpload) { + return; + } + this.needsTerrainDataUpload = false; + + const bytesPerRow = this.mapWidth; + const paddedBytesPerRow = align(bytesPerRow, 256); + + if (paddedBytesPerRow === bytesPerRow) { + // Direct upload if already aligned + this.device.queue.writeTexture( + { texture: this.terrainDataTexture }, + this.terrainData, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } else { + // Row-by-row upload with padding + const row = new Uint8Array(paddedBytesPerRow); + for (let y = 0; y < this.mapHeight; y++) { + row.fill(0); + const start = y * this.mapWidth; + row.set(this.terrainData.subarray(start, start + this.mapWidth), 0); + this.device.queue.writeTexture( + { texture: this.terrainDataTexture, origin: { x: 0, y } }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + } + + uploadTerrainParams(): void { + if (!this.needsTerrainParamsUpload) { + return; + } + this.needsTerrainParamsUpload = false; + + // Sample theme colors by finding representative tiles + // We'll search for a shore tile, water tile, and compute base terrain colors + let shoreColor = { r: 204, g: 203, b: 158, a: 255 }; // Default pastel + let waterColor = { r: 70, g: 132, b: 180, a: 255 }; // Default pastel + let shorelineWaterColor = { r: 100, g: 143, b: 255, a: 255 }; // Default pastel + + // Find a shore tile (land adjacent to water) + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if (this.game.isShore(i)) { + const color = this.theme.terrainColor(this.game, i); + shoreColor = color.rgba; + break; + } + } + + // Find a deep water tile (magnitude > 5) and shoreline water + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if (this.game.isWater(i)) { + if (this.game.isShoreline(i)) { + const color = this.theme.terrainColor(this.game, i); + shorelineWaterColor = color.rgba; + } else if (this.game.magnitude(i) > 5) { + const color = this.theme.terrainColor(this.game, i); + waterColor = color.rgba; + } + if (waterColor.r !== 70 || shorelineWaterColor.r !== 100) { + // Found both, can break + if (this.game.isShoreline(i) && this.game.magnitude(i) > 5) { + break; + } + } + } + } + + // Compute terrain base colors by sampling at magnitude 0, 10, 20 + // Find a plains tile (magnitude < 10, land, not shore) + let plainsColor = { r: 190, g: 220, b: 138, a: 255 }; + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if ( + this.game.isLand(i) && + !this.game.isShore(i) && + this.game.magnitude(i) < 10 + ) { + const color = this.theme.terrainColor(this.game, i); + plainsColor = color.rgba; + break; + } + } + + // Find a highland tile at magnitude 10 (for accurate formula computation) + let highlandColor = { r: 200, g: 183, b: 138, a: 255 }; + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if ( + this.game.isLand(i) && + !this.game.isShore(i) && + this.game.magnitude(i) === 10 + ) { + const color = this.theme.terrainColor(this.game, i); + highlandColor = color.rgba; + break; + } + } + // If no mag 10 found, try any highland tile + if (highlandColor.r === 200 && highlandColor.g === 183) { + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if ( + this.game.isLand(i) && + !this.game.isShore(i) && + this.game.magnitude(i) >= 10 && + this.game.magnitude(i) < 20 + ) { + const color = this.theme.terrainColor(this.game, i); + highlandColor = color.rgba; + break; + } + } + } + + // Store colors as vec4f (RGBA normalized to 0-1) + // Index 0-3: shore color + this.terrainParamsData[0] = shoreColor.r / 255; + this.terrainParamsData[1] = shoreColor.g / 255; + this.terrainParamsData[2] = shoreColor.b / 255; + this.terrainParamsData[3] = 1.0; + + // Index 4-7: water base color + this.terrainParamsData[4] = waterColor.r / 255; + this.terrainParamsData[5] = waterColor.g / 255; + this.terrainParamsData[6] = waterColor.b / 255; + this.terrainParamsData[7] = 1.0; + + // Index 8-11: shoreline water color + this.terrainParamsData[8] = shorelineWaterColor.r / 255; + this.terrainParamsData[9] = shorelineWaterColor.g / 255; + this.terrainParamsData[10] = shorelineWaterColor.b / 255; + this.terrainParamsData[11] = 1.0; + + // Find a mountain tile at magnitude 20 (for accurate formula computation) + let mountainColor = { r: 230, g: 230, b: 230, a: 255 }; + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if ( + this.game.isLand(i) && + !this.game.isShore(i) && + this.game.magnitude(i) === 20 + ) { + const color = this.theme.terrainColor(this.game, i); + mountainColor = color.rgba; + break; + } + } + // If no mag 20 found, try any mountain tile + if (mountainColor.r === 230 && mountainColor.g === 230) { + for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { + if ( + this.game.isLand(i) && + !this.game.isShore(i) && + this.game.magnitude(i) >= 20 + ) { + const color = this.theme.terrainColor(this.game, i); + mountainColor = color.rgba; + break; + } + } + } + + // Index 12-15: plains base color (magnitude 0) + this.terrainParamsData[12] = plainsColor.r / 255; + this.terrainParamsData[13] = plainsColor.g / 255; + this.terrainParamsData[14] = plainsColor.b / 255; + this.terrainParamsData[15] = 1.0; + + // Index 16-19: highland base color (magnitude 10) + this.terrainParamsData[16] = highlandColor.r / 255; + this.terrainParamsData[17] = highlandColor.g / 255; + this.terrainParamsData[18] = highlandColor.b / 255; + this.terrainParamsData[19] = 1.0; + + // Index 20-23: mountain base color (magnitude 20) + this.terrainParamsData[20] = mountainColor.r / 255; + this.terrainParamsData[21] = mountainColor.g / 255; + this.terrainParamsData[22] = mountainColor.b / 255; + this.terrainParamsData[23] = 1.0; + + this.device.queue.writeBuffer( + this.terrainParamsBuffer, + 0, + this.terrainParamsData, + ); + } + + markTerrainParamsDirty(): void { + this.needsTerrainParamsUpload = true; + } + uploadPalette(): boolean { if (!this.needsPaletteUpload) { return false; diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl new file mode 100644 index 000000000..3cfaadeef --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl @@ -0,0 +1,102 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const OCEAN_BIT: u32 = 5u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + // Extract terrain bits + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + var color: vec4f; + + // Check if shore (land adjacent to water) + if (isLand && isShoreline) { + color = params.shoreColor; + } else if (!isLand) { + // Water tile + if (isShoreline) { + color = params.shorelineWaterColor; + } else { + // Deep water - color varies by magnitude + // CPU formula: waterColor - 10 + (11 - min(mag, 10)) + // In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0 + // Simplified: waterColor + (1 - min(mag, 10)) / 255.0 + let magClamped = min(mag, 10.0); + let adjustment = (1.0 - magClamped) / 255.0; + color = vec4f( + max(params.waterColor.r + adjustment, 0.0), + max(params.waterColor.g + adjustment, 0.0), + max(params.waterColor.b + adjustment, 0.0), + 1.0 + ); + } + } else { + // Land tile - determine terrain type from magnitude + // CPU formulas: + // Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9 + // Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19 + // Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20 + // + // We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255 + // We sampled highland at some mag 10-19, need to compute from mag 10 + if (magnitude < 10u) { + // Plains: rgb(190, 220 - 2*mag, 138) + color = vec4f( + params.plainsBaseColor.r, // 190/255 + max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255 + params.plainsBaseColor.b, // 138/255 + 1.0 + ); + } else if (magnitude < 20u) { + // Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) + // We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255 + // For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255 + let highlandMag = mag - 10.0; + color = vec4f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + 1.0 + ); + } else { + // Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) + // We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel + // For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255 + let mountainMag = mag - 20.0; + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + color = vec4f(gray, gray, gray, 1.0); + } + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 33390337b..1d2c87763 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1163,6 +1163,9 @@ export class GameImpl implements Game { tileStateView(): Uint16Array { return this._map.tileStateView(); } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 77f794779..6185c3892 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -36,6 +36,7 @@ export interface GameMap { isDefended(ref: TileRef): boolean; setDefended(ref: TileRef, value: boolean): void; tileStateView(): Uint16Array; + terrainDataView(): Uint8Array; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -286,6 +287,10 @@ export class GameMapImpl implements GameMap { return this.state; } + terrainDataView(): Uint8Array { + return this.terrain; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 7973d039e..59a4b49e4 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1332,6 +1332,9 @@ export class GameView implements GameMap { tileStateView(): Uint16Array { return this._map.tileStateView(); } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } From c26acbda3ab8edff3e25f10df51958d6bab370aa Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:00:21 +0100 Subject: [PATCH 03/23] refactor: optimize terrain recomputation in TerritoryRenderer Updated the terrain recomputation logic to trigger asynchronously, improving performance by allowing rendering to continue without blocking. This change ensures that the terrain will be ready for the next frame, which may result in displaying stale terrain for one frame but enhances overall rendering efficiency. --- .../graphics/webgpu/TerritoryRenderer.ts | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 7256f8765..6d2dd608b 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -425,31 +425,14 @@ export class TerritoryRenderer { return; } - // Check if terrain needs recomputation (e.g., theme changed) - // If so, compute it in the same command buffer before rendering + // If terrain needs recomputation, trigger it asynchronously (no blocking) + // It will be ready for the next frame, acceptable trade-off for performance if (this.terrainComputePass?.needsUpdate()) { this.resources.uploadTerrainParams(); - - // Use a single encoder to ensure compute completes before render - const encoder = this.device.device.createCommandEncoder(); - - // Execute terrain compute first - this.terrainComputePass.execute(encoder, this.resources); - - // Then execute render passes in the same command buffer - // The render pass will rebuild its bind group, which will now use the updated terrain texture - const textureView = this.device.context.getCurrentTexture().createView(); - for (const pass of this.renderPassOrder) { - if (!pass.needsUpdate()) { - continue; - } - pass.execute(encoder, this.resources, textureView); - } - - // Submit single command buffer with both compute and render - // This ensures compute completes before render reads the terrain texture - this.device.device.queue.submit([encoder.finish()]); - return; + const computeEncoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(computeEncoder, this.resources); + this.device.device.queue.submit([computeEncoder.finish()]); + // Continue with render - may show stale terrain for one frame, but better performance } const encoder = this.device.device.createCommandEncoder(); From 92ba5754d636e7d0a5741383a094c8f74f7480b5 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:03:48 +0100 Subject: [PATCH 04/23] refactor: update workgroup size in compute shader and dispatch logic Modified the workgroup size in the state-update compute shader from 1 to 64 for improved parallel processing. Adjusted the dispatch logic in StateUpdatePass to calculate the correct number of workgroups based on the new size, enhancing performance during state updates. Removed unnecessary terrain parameter upload in TerritoryRenderer to streamline resource management. --- src/client/graphics/webgpu/TerritoryRenderer.ts | 3 --- src/client/graphics/webgpu/compute/StateUpdatePass.ts | 4 +++- src/client/graphics/webgpu/shaders/compute/state-update.wgsl | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 6d2dd608b..12861303c 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -316,9 +316,6 @@ export class TerritoryRenderer { // Upload palette if needed this.resources.uploadPalette(); - // Upload terrain params if needed (theme changed) - this.resources.uploadTerrainParams(); - // Upload defense posts if needed (tracks if it was dirty before upload) const wasDefensePostsDirty = (this.resources as any) .needsDefensePostsUpload; diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts index a61a04789..f2d6e47a4 100644 --- a/src/client/graphics/webgpu/compute/StateUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -101,7 +101,9 @@ export class StateUpdatePass implements ComputePass { const pass = encoder.beginComputePass(); pass.setPipeline(this.pipeline); pass.setBindGroup(0, this.bindGroup); - pass.dispatchWorkgroups(numUpdates); + // Dispatch with workgroup_size(64), so divide by 64 and round up + const workgroupCount = Math.ceil(numUpdates / 64); + pass.dispatchWorkgroups(workgroupCount); pass.end(); } diff --git a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl index 9532b8af0..8fb0821a5 100644 --- a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -6,7 +6,7 @@ struct Update { @group(0) @binding(0) var updates: array; @group(0) @binding(1) var stateTex: texture_storage_2d; -@compute @workgroup_size(1) +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) globalId: vec3) { let idx = globalId.x; if (idx >= arrayLength(&updates)) { From 44ffa96b64f46684569a7e4cc0b4a188df22c538 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:09:31 +0100 Subject: [PATCH 05/23] refactor: optimize terrain color extraction in GroundTruthData Replaced tile sampling for terrain colors with direct extraction from the theme object, significantly improving performance. Updated shore, water, shoreline water, plains, highland, and mountain color computations to utilize theme properties, eliminating the need for tile searches. This change enhances efficiency in terrain color management while maintaining visual fidelity. --- .../graphics/webgpu/core/GroundTruthData.ts | 134 +++++------------- 1 file changed, 33 insertions(+), 101 deletions(-) diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 6444791c0..6485fa1ed 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -310,83 +310,43 @@ export class GroundTruthData { } this.needsTerrainParamsUpload = false; - // Sample theme colors by finding representative tiles - // We'll search for a shore tile, water tile, and compute base terrain colors - let shoreColor = { r: 204, g: 203, b: 158, a: 255 }; // Default pastel - let waterColor = { r: 70, g: 132, b: 180, a: 255 }; // Default pastel - let shorelineWaterColor = { r: 100, g: 143, b: 255, a: 255 }; // Default pastel + // Extract theme colors directly from theme object (much faster than sampling tiles) + const themeAny = this.theme as any; + const isDark = themeAny.darkShore !== undefined; - // Find a shore tile (land adjacent to water) - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if (this.game.isShore(i)) { - const color = this.theme.terrainColor(this.game, i); - shoreColor = color.rgba; - break; - } - } + // Get shore color + const shore = isDark ? themeAny.darkShore : themeAny.shore; + const shoreColor = shore?.rgba ?? { r: 204, g: 203, b: 158, a: 255 }; - // Find a deep water tile (magnitude > 5) and shoreline water - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if (this.game.isWater(i)) { - if (this.game.isShoreline(i)) { - const color = this.theme.terrainColor(this.game, i); - shorelineWaterColor = color.rgba; - } else if (this.game.magnitude(i) > 5) { - const color = this.theme.terrainColor(this.game, i); - waterColor = color.rgba; - } - if (waterColor.r !== 70 || shorelineWaterColor.r !== 100) { - // Found both, can break - if (this.game.isShoreline(i) && this.game.magnitude(i) > 5) { - break; - } - } - } - } + // Get water colors + const water = isDark ? themeAny.darkWater : themeAny.water; + const waterColor = water?.rgba ?? { r: 70, g: 132, b: 180, a: 255 }; - // Compute terrain base colors by sampling at magnitude 0, 10, 20 - // Find a plains tile (magnitude < 10, land, not shore) - let plainsColor = { r: 190, g: 220, b: 138, a: 255 }; - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if ( - this.game.isLand(i) && - !this.game.isShore(i) && - this.game.magnitude(i) < 10 - ) { - const color = this.theme.terrainColor(this.game, i); - plainsColor = color.rgba; - break; - } - } + const shorelineWater = isDark + ? themeAny.darkShorelineWater + : themeAny.shorelineWater; + const shorelineWaterColor = shorelineWater?.rgba ?? { + r: 100, + g: 143, + b: 255, + a: 255, + }; - // Find a highland tile at magnitude 10 (for accurate formula computation) - let highlandColor = { r: 200, g: 183, b: 138, a: 255 }; - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if ( - this.game.isLand(i) && - !this.game.isShore(i) && - this.game.magnitude(i) === 10 - ) { - const color = this.theme.terrainColor(this.game, i); - highlandColor = color.rgba; - break; - } - } - // If no mag 10 found, try any highland tile - if (highlandColor.r === 200 && highlandColor.g === 183) { - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if ( - this.game.isLand(i) && - !this.game.isShore(i) && - this.game.magnitude(i) >= 10 && - this.game.magnitude(i) < 20 - ) { - const color = this.theme.terrainColor(this.game, i); - highlandColor = color.rgba; - break; - } - } - } + // Compute terrain base colors from formulas (no tile sampling needed) + // Plains at mag 0: rgb(190, 220, 138) for pastel, rgb(140, 170, 88) for dark + const plainsColor = isDark + ? { r: 140, g: 170, b: 88, a: 255 } + : { r: 190, g: 220, b: 138, a: 255 }; + + // Highland at mag 10: rgb(220, 203, 158) for pastel, rgb(170, 153, 108) for dark + const highlandColor = isDark + ? { r: 170, g: 153, b: 108, a: 255 } + : { r: 220, g: 203, b: 158, a: 255 }; + + // Mountain at mag 20: rgb(240, 240, 240) for pastel, rgb(190, 190, 190) for dark + const mountainColor = isDark + ? { r: 190, g: 190, b: 190, a: 255 } + : { r: 240, g: 240, b: 240, a: 255 }; // Store colors as vec4f (RGBA normalized to 0-1) // Index 0-3: shore color @@ -407,34 +367,6 @@ export class GroundTruthData { this.terrainParamsData[10] = shorelineWaterColor.b / 255; this.terrainParamsData[11] = 1.0; - // Find a mountain tile at magnitude 20 (for accurate formula computation) - let mountainColor = { r: 230, g: 230, b: 230, a: 255 }; - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if ( - this.game.isLand(i) && - !this.game.isShore(i) && - this.game.magnitude(i) === 20 - ) { - const color = this.theme.terrainColor(this.game, i); - mountainColor = color.rgba; - break; - } - } - // If no mag 20 found, try any mountain tile - if (mountainColor.r === 230 && mountainColor.g === 230) { - for (let i = 0; i < Math.min(1000, this.mapWidth * this.mapHeight); i++) { - if ( - this.game.isLand(i) && - !this.game.isShore(i) && - this.game.magnitude(i) >= 20 - ) { - const color = this.theme.terrainColor(this.game, i); - mountainColor = color.rgba; - break; - } - } - } - // Index 12-15: plains base color (magnitude 0) this.terrainParamsData[12] = plainsColor.r / 255; this.terrainParamsData[13] = plainsColor.g / 255; From 655bb211e1a4f300086165eedf395bed4351bb7b Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:49:20 +0100 Subject: [PATCH 06/23] simplify defended territory rendering logic Replace epoch-based defended texture tracking with hard-clear approach for improved reliability and performance. Remove complex dirty state tracking and epoch incrementing logic in favor of direct hard clears before rebuilds. Changes: - Remove wasDefensePostsDirty tracking from TerritoryRenderer - Replace numUpdates > 0 checks with hasStateUpdates boolean - Hard-clear defended texture before restamping instead of epoch management - Mark DefendedUpdatePass as dirty when rebuilding defended state - Rebuild bind group in DefendedUpdatePass when missing, not just on buffer change This eliminates potential transient mismatches where defended rendering disappeared between rebuilds and simplifies the update pipeline. --- .../graphics/webgpu/TerritoryRenderer.ts | 29 ++++++++----------- .../webgpu/compute/DefendedUpdatePass.ts | 2 +- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 12861303c..f5910727c 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -316,18 +316,16 @@ export class TerritoryRenderer { // 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; + // Upload defense posts if needed 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 hasStateUpdates = this.stateUpdatePass + ? this.stateUpdatePass.needsUpdate() + : false; const needsTerrainCompute = this.terrainComputePass ? this.terrainComputePass.needsUpdate() : false; @@ -341,14 +339,13 @@ export class TerritoryRenderer { // 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); + (hasPosts && hasStateUpdates === true); const needsCompute = needsTerrainCompute === true || - numUpdates > 0 || + hasStateUpdates === true || shouldRebuildDefended === true || this.needsDefendedHardClear === true; @@ -366,15 +363,13 @@ export class TerritoryRenderer { // 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(); + // Hard-clear defended texture before restamping. This avoids relying on + // epoch-stamping for correctness and prevents transient mismatches where + // defended rendering disappears between rebuilds. + this.needsDefendedHardClear = true; - // If epoch wrapped, we need a hard clear - if (epochAfter === 0 || epochAfter < epochBefore) { - this.needsDefendedHardClear = true; - this.resources.incrementDefendedEpoch(); + if (this.defendedUpdatePass) { + this.defendedUpdatePass.markDirty(); } this.needsDefendedRebuild = false; diff --git a/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts b/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts index c68d1358b..abac77c7f 100644 --- a/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts @@ -87,7 +87,7 @@ export class DefendedUpdatePass implements ComputePass { const oldBuffer = this.resources?.defensePostsBuffer; const bufferChanged = oldBuffer !== resources.defensePostsBuffer; - if (bufferChanged) { + if (bufferChanged || !this.bindGroup) { this.rebuildBindGroup(); } From 97603f7a1a8d2db5a91e388f55906f2af288b82e Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:50:39 +0100 Subject: [PATCH 07/23] replace defended epoch stamping with defended-strength field Store defended influence in defendedStrengthTexture and sample it in territory render shader Recompute defended strength on tick for state-updated tiles and for post-change dirty tiles, with full-map fallback when diffs are large Pack defense posts by owner on GPU (owner offsets + posts buffer) Remove old defended clear/update passes and epoch-based params --- .../graphics/webgpu/TerritoryRenderer.ts | 103 +---- .../webgpu/compute/DefendedClearPass.ts | 105 ----- ...atePass.ts => DefendedStrengthFullPass.ts} | 86 ++-- .../webgpu/compute/DefendedStrengthPass.ts | 172 ++++++++ .../webgpu/compute/StateUpdatePass.ts | 81 +++- .../graphics/webgpu/core/GroundTruthData.ts | 393 +++++++++++++----- .../webgpu/render/TerritoryRenderPass.ts | 27 +- .../webgpu/shaders/common/uniforms.wgsl | 12 - .../shaders/compute/defended-clear.wgsl | 12 - .../compute/defended-strength-full.wgsl | 65 +++ .../shaders/compute/defended-strength.wgsl | 69 +++ .../shaders/compute/defended-update.wgsl | 53 --- .../webgpu/shaders/compute/state-update.wgsl | 58 ++- .../webgpu/shaders/render/territory.wgsl | 26 +- 14 files changed, 806 insertions(+), 456 deletions(-) delete mode 100644 src/client/graphics/webgpu/compute/DefendedClearPass.ts rename src/client/graphics/webgpu/compute/{DefendedUpdatePass.ts => DefendedStrengthFullPass.ts} (59%) create mode 100644 src/client/graphics/webgpu/compute/DefendedStrengthPass.ts delete mode 100644 src/client/graphics/webgpu/shaders/common/uniforms.wgsl delete mode 100644 src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl delete mode 100644 src/client/graphics/webgpu/shaders/compute/defended-update.wgsl diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index f5910727c..6b3116b99 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -3,8 +3,8 @@ 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 { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass"; +import { DefendedStrengthPass } from "./compute/DefendedStrengthPass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; import { TerrainComputePass } from "./compute/TerrainComputePass"; import { GroundTruthData } from "./core/GroundTruthData"; @@ -40,13 +40,10 @@ export class TerritoryRenderer { // Pass instances private terrainComputePass: TerrainComputePass | null = null; private stateUpdatePass: StateUpdatePass | null = null; - private defendedClearPass: DefendedClearPass | null = null; - private defendedUpdatePass: DefendedUpdatePass | null = null; + private defendedStrengthFullPass: DefendedStrengthFullPass | null = null; + private defendedStrengthPass: DefendedStrengthPass | null = null; private territoryRenderPass: TerritoryRenderPass | null = null; - - // State tracking - private needsDefendedRebuild = true; - private needsDefendedHardClear = true; + private readonly defensePostRange: number; private constructor( private readonly game: GameView, @@ -56,6 +53,7 @@ export class TerritoryRenderer { this.canvas.style.pointerEvents = "none"; this.canvas.width = 1; this.canvas.height = 1; + this.defensePostRange = game.config().defensePostRange(); } static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { @@ -108,14 +106,14 @@ export class TerritoryRenderer { // Create compute passes (terrain compute should run first) this.terrainComputePass = new TerrainComputePass(); this.stateUpdatePass = new StateUpdatePass(); - this.defendedClearPass = new DefendedClearPass(); - this.defendedUpdatePass = new DefendedUpdatePass(); + this.defendedStrengthFullPass = new DefendedStrengthFullPass(); + this.defendedStrengthPass = new DefendedStrengthPass(); this.computePasses = [ this.terrainComputePass, this.stateUpdatePass, - this.defendedClearPass, - this.defendedUpdatePass, + this.defendedStrengthFullPass, + this.defendedStrengthPass, ]; // Create render passes @@ -236,10 +234,7 @@ export class TerritoryRenderer { } markAllDirty(): void { - this.needsDefendedRebuild = true; - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } + this.resources?.markDefensePostsDirty(); } refreshPalette(): void { @@ -254,10 +249,6 @@ export class TerritoryRenderer { return; } this.resources.markDefensePostsDirty(); - this.needsDefendedRebuild = true; - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } } refreshTerrain(): void { @@ -313,73 +304,31 @@ export class TerritoryRenderer { return; } + if (this.game.config().defensePostRange() !== this.defensePostRange) { + throw new Error("defensePostRange changed at runtime; unsupported."); + } + // Upload palette if needed this.resources.uploadPalette(); - // Upload defense posts if needed + // Upload defense posts if needed (also produces defended dirty tiles on changes) this.resources.uploadDefensePosts(); // Initial state upload this.resources.uploadState(); - // Check if we need to run compute passes - const hasStateUpdates = this.stateUpdatePass - ? this.stateUpdatePass.needsUpdate() - : false; - const needsTerrainCompute = this.terrainComputePass - ? this.terrainComputePass.needsUpdate() - : false; - const range = this.game.config().defensePostRange(); - const rangeChanged = range !== this.resources.getLastDefenseRange(); - const countChanged = - this.resources.getDefensePostsCount() !== - this.resources.getLastDefensePostsCount(); - const hasPosts = this.resources.getDefensePostsCount() > 0; - - // Use explicit boolean checks to satisfy linter (|| is correct for boolean OR) - const shouldRebuildDefended = - this.needsDefendedRebuild === true || - rangeChanged === true || - countChanged === true || - (hasPosts && hasStateUpdates === true); - const needsCompute = - needsTerrainCompute === true || - hasStateUpdates === true || - shouldRebuildDefended === true || - this.needsDefendedHardClear === true; + (this.terrainComputePass?.needsUpdate() ?? false) || + (this.stateUpdatePass?.needsUpdate() ?? false) || + (this.defendedStrengthFullPass?.needsUpdate() ?? false) || + (this.defendedStrengthPass?.needsUpdate() ?? false); - // 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) { - // Hard-clear defended texture before restamping. This avoids relying on - // epoch-stamping for correctness and prevents transient mismatches where - // defended rendering disappears between rebuilds. - this.needsDefendedHardClear = true; - - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } - - 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()) { @@ -388,18 +337,6 @@ export class TerritoryRenderer { 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()]); } diff --git a/src/client/graphics/webgpu/compute/DefendedClearPass.ts b/src/client/graphics/webgpu/compute/DefendedClearPass.ts deleted file mode 100644 index d6aa8642f..000000000 --- a/src/client/graphics/webgpu/compute/DefendedClearPass.ts +++ /dev/null @@ -1,105 +0,0 @@ -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/DefendedStrengthFullPass.ts similarity index 59% rename from src/client/graphics/webgpu/compute/DefendedUpdatePass.ts rename to src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts index abac77c7f..3d747803b 100644 --- a/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts @@ -3,10 +3,11 @@ import { loadShader } from "../core/ShaderLoader"; import { ComputePass } from "./ComputePass"; /** - * Compute pass that updates the defended texture from defense posts. + * Full defended strength recompute across the entire map. + * Used on initial upload or when post diffs are too large for a tile list. */ -export class DefendedUpdatePass implements ComputePass { - name = "defended-update"; +export class DefendedStrengthFullPass implements ComputePass { + name = "defended-strength-full"; dependencies: string[] = ["state-update"]; private pipeline: GPUComputePipeline | null = null; @@ -14,13 +15,13 @@ export class DefendedUpdatePass implements ComputePass { private bindGroup: GPUBindGroup | null = null; private device: GPUDevice | null = null; private resources: GroundTruthData | null = null; - private needsRebuild = true; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; async init(device: GPUDevice, resources: GroundTruthData): Promise { this.device = device; this.resources = resources; - const shaderCode = await loadShader("compute/defended-update.wgsl"); + const shaderCode = await loadShader("compute/defended-strength-full.wgsl"); const shaderModule = device.createShaderModule({ code: shaderCode }); this.bindGroupLayout = device.createBindGroupLayout({ @@ -33,17 +34,22 @@ export class DefendedUpdatePass implements ComputePass { { binding: 1, visibility: 4 /* COMPUTE */, - buffer: { type: "read-only-storage" }, + texture: { sampleType: "uint" }, }, { binding: 2, visibility: 4 /* COMPUTE */, - texture: { sampleType: "uint" }, + storageTexture: { format: "rgba8unorm" }, }, { binding: 3, visibility: 4 /* COMPUTE */, - storageTexture: { format: "r32uint" }, + buffer: { type: "read-only-storage" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, }, ], }); @@ -60,12 +66,7 @@ export class DefendedUpdatePass implements ComputePass { } needsUpdate(): boolean { - if (!this.resources || !this.needsRebuild) { - return false; - } - - // Only run if we have defense posts - return this.resources.getDefensePostsCount() > 0; + return this.resources?.needsDefendedFullRecompute() ?? false; } execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { @@ -73,38 +74,35 @@ export class DefendedUpdatePass implements ComputePass { return; } - const range = resources.getGame().config().defensePostRange(); - const postsCount = resources.getDefensePostsCount(); - - if (postsCount === 0) { - this.needsRebuild = false; + if (!resources.needsDefendedFullRecompute()) { return; } - // Epoch is incremented by orchestrator before this pass runs - resources.writeDefenseParamsBuffer(); + resources.writeDefendedStrengthParamsBuffer(0); - const oldBuffer = this.resources?.defensePostsBuffer; - const bufferChanged = oldBuffer !== resources.defensePostsBuffer; - - if (bufferChanged || !this.bindGroup) { + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + if ( + !this.bindGroup || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer + ) { this.rebuildBindGroup(); } - if (!this.bindGroup) { return; } - const gridSize = 2 * range + 1; - const workgroupCount = Math.ceil(gridSize / 8); + 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(workgroupCount, workgroupCount, postsCount); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); pass.end(); - this.needsRebuild = false; + resources.clearDefendedFullRecompute(); } private rebuildBindGroup(): void { @@ -112,11 +110,11 @@ export class DefendedUpdatePass implements ComputePass { !this.device || !this.bindGroupLayout || !this.resources || - !this.resources.defenseParamsBuffer || - !this.resources.defensePostsBuffer || + !this.resources.defendedStrengthParamsBuffer || !this.resources.stateTexture || - !this.resources.defendedTexture || - this.resources.getDefensePostsCount() <= 0 + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer ) { this.bindGroup = null; return; @@ -127,26 +125,28 @@ export class DefendedUpdatePass implements ComputePass { entries: [ { binding: 0, - resource: { buffer: this.resources.defenseParamsBuffer }, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, }, { binding: 1, - resource: { buffer: this.resources.defensePostsBuffer }, - }, - { - binding: 2, resource: this.resources.stateTexture.createView(), }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, { binding: 3, - resource: this.resources.defendedTexture.createView(), + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 4, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, }, ], }); - } - markDirty(): void { - this.needsRebuild = true; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; } dispose(): void { diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts new file mode 100644 index 000000000..ae0034ce6 --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts @@ -0,0 +1,172 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Recomputes defended strength for a list of dirty tiles. + * Dirty tiles are produced when defense posts are added/removed/moved. + */ +export class DefendedStrengthPass implements ComputePass { + name = "defended-strength"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundDirtyTilesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const dirtyCount = resources.getDefendedDirtyTilesCount(); + if (dirtyCount === 0) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(dirtyCount); + + const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer; + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundDirtyTilesBuffer !== dirtyTilesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(dirtyCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + resources.clearDefendedDirtyTiles(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.defendedDirtyTilesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: { buffer: this.resources.defendedDirtyTilesBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts index f2d6e47a4..05dee89ff 100644 --- a/src/client/graphics/webgpu/compute/StateUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -15,6 +15,8 @@ export class StateUpdatePass implements ComputePass { private device: GPUDevice | null = null; private resources: GroundTruthData | null = null; private readonly pendingTiles: Set = new Set(); + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; async init(device: GPUDevice, resources: GroundTruthData): Promise { this.device = device; @@ -28,13 +30,33 @@ export class StateUpdatePass implements ComputePass { { binding: 0, visibility: 4 /* COMPUTE */, - buffer: { type: "read-only-storage" }, + buffer: { type: "uniform" }, }, { binding: 1, visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, storageTexture: { format: "r32uint" }, }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, ], }); @@ -65,9 +87,8 @@ export class StateUpdatePass implements ComputePass { return; } - const oldBuffer = this.resources?.updatesBuffer; const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); - const bufferChanged = oldBuffer !== updatesBuffer; + resources.writeStateUpdateParamsBuffer(numUpdates); const staging = resources.getUpdatesStaging(); const state = resources.getState(); @@ -88,8 +109,13 @@ export class StateUpdatePass implements ComputePass { staging.subarray(0, numUpdates * 2), ); - // Rebuild bind group if buffer changed - if (bufferChanged) { + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { this.rebuildBindGroup(); } @@ -97,15 +123,12 @@ export class StateUpdatePass implements ComputePass { return; } - if (this.bindGroup) { - const pass = encoder.beginComputePass(); - pass.setPipeline(this.pipeline); - pass.setBindGroup(0, this.bindGroup); - // Dispatch with workgroup_size(64), so divide by 64 and round up - const workgroupCount = Math.ceil(numUpdates / 64); - pass.dispatchWorkgroups(workgroupCount); - pass.end(); - } + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(numUpdates / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); this.pendingTiles.clear(); } @@ -115,22 +138,46 @@ export class StateUpdatePass implements ComputePass { !this.device || !this.bindGroupLayout || !this.resources || + !this.resources.stateUpdateParamsBuffer || !this.resources.updatesBuffer || - !this.resources.stateTexture + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer ) { + this.bindGroup = null; return; } this.bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ - { binding: 0, resource: { buffer: this.resources.updatesBuffer } }, { - binding: 1, + binding: 0, + resource: { buffer: this.resources.stateUpdateParamsBuffer }, + }, + { binding: 1, resource: { buffer: this.resources.updatesBuffer } }, + { + binding: 2, resource: this.resources.stateTexture.createView(), }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, ], }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; } markTile(tile: number): void { diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 6485fa1ed..d27a05bee 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -16,28 +16,35 @@ function align(value: number, alignment: number): number { export class GroundTruthData { public static readonly PALETTE_RESERVED_SLOTS = 10; public static readonly PALETTE_FALLOUT_INDEX = 0; + private static readonly MAX_OWNER_SLOTS = 0x1000; // ownerId is 12 bits // Textures public readonly stateTexture: GPUTexture; public readonly terrainTexture: GPUTexture; public readonly terrainDataTexture: GPUTexture; public readonly paletteTexture: GPUTexture; - public readonly defendedTexture: GPUTexture; + public readonly defendedStrengthTexture: GPUTexture; // Buffers public readonly uniformBuffer: GPUBuffer; - public readonly defenseParamsBuffer: GPUBuffer; public readonly terrainParamsBuffer: GPUBuffer; + public readonly stateUpdateParamsBuffer: GPUBuffer; + public readonly defendedStrengthParamsBuffer: GPUBuffer; public updatesBuffer: GPUBuffer | null = null; - public defensePostsBuffer: GPUBuffer | null = null; + public readonly defenseOwnerOffsetsBuffer: GPUBuffer; + public defensePostsByOwnerBuffer: GPUBuffer; + public defendedDirtyTilesBuffer: GPUBuffer; // Staging arrays for buffer uploads private updatesStaging: Uint32Array | null = null; - private defensePostsStaging: Uint32Array | null = null; + private defenseOwnerOffsetsStaging: Uint32Array; + private defensePostsByOwnerStaging: Uint32Array | null = null; + private defendedDirtyTilesStaging: Uint32Array | null = null; // Buffer capacities private updatesCapacity = 0; - private defensePostsCapacity = 0; + private defensePostsByOwnerCapacity = 0; + private defendedDirtyTilesCapacity = 0; // State tracking private readonly mapWidth: number; @@ -49,13 +56,19 @@ export class GroundTruthData { private needsTerrainDataUpload = true; private needsTerrainParamsUpload = true; private paletteWidth = 1; - private defensePostsCount = 0; private needsDefensePostsUpload = true; + private defensePostsTotalCount = 0; + private defendedDirtyTilesCount = 0; + private needsFullDefendedStrengthRecompute = false; + private lastDefensePostKeys = new Set(); + private defenseCircleRange = -1; + private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] // Uniform data arrays private readonly uniformData = new Float32Array(12); - private readonly defenseParamsData = new Uint32Array(4); private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase + private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad + private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad // View state (updated by renderer) private viewWidth = 1; @@ -66,11 +79,6 @@ export class GroundTruthData { 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, @@ -99,8 +107,14 @@ export class GroundTruthData { usage: UNIFORM | COPY_DST_BUF, }); - // Defense params: 4x u32 = 16 bytes - this.defenseParamsBuffer = device.createBuffer({ + // State update params: 4x u32 = 16 bytes + this.stateUpdateParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Defended strength params: 4x u32 = 16 bytes + this.defendedStrengthParamsBuffer = device.createBuffer({ size: 16, usage: UNIFORM | COPY_DST_BUF, }); @@ -118,10 +132,10 @@ export class GroundTruthData { usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, }); - // Defended texture (r32uint) - this.defendedTexture = device.createTexture({ + // Defended strength texture (rgba8unorm, r channel used) + this.defendedStrengthTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, - format: "r32uint", + format: "rgba8unorm", usage: TEXTURE_BINDING | STORAGE_BINDING, }); @@ -145,6 +159,28 @@ export class GroundTruthData { format: "r8uint", usage: COPY_DST_TEX | TEXTURE_BINDING, }); + + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + + // Defense posts data: ownerOffsets[ownerId] = {start, count}, postsByOwner[start..] = {x,y} + this.defenseOwnerOffsetsBuffer = device.createBuffer({ + size: GroundTruthData.MAX_OWNER_SLOTS * 8, + usage: STORAGE | COPY_DST_BUF, + }); + this.defenseOwnerOffsetsStaging = new Uint32Array( + GroundTruthData.MAX_OWNER_SLOTS * 2, + ); + + this.defensePostsByOwnerBuffer = device.createBuffer({ + size: 8, + usage: STORAGE | COPY_DST_BUF, + }); + + // Dirty tile indices to recompute defended strength when posts change + this.defendedDirtyTilesBuffer = device.createBuffer({ + size: 4 * 8, + usage: STORAGE | COPY_DST_BUF, + }); } static create( @@ -471,30 +507,83 @@ export class GroundTruthData { } this.needsDefensePostsUpload = false; + const range = this.game.config().defensePostRange(); const posts = this.collectDefensePosts(); - this.defensePostsCount = posts.length; + this.defensePostsTotalCount = posts.length; - if (this.defensePostsCount > 0) { - this.ensureDefensePostsBuffer(this.defensePostsCount); + // Diff posts to produce dirty tiles for recompute (include removed + added). + const nextKeys = new Set(); + for (const p of posts) { + nextKeys.add(`${p.ownerId},${p.x},${p.y}`); } - 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; + const changedPosts: Array<{ x: number; y: number }> = []; + for (const key of this.lastDefensePostKeys) { + if (!nextKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); } - this.device.queue.writeBuffer( - this.defensePostsBuffer, - 0, - this.defensePostsStaging.subarray(0, this.defensePostsCount * 3), - ); } + for (const key of nextKeys) { + if (!this.lastDefensePostKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + this.lastDefensePostKeys = nextKeys; + + // Pack posts by owner into GPU buffers. + this.packDefensePostsByOwner(posts); + + // Build dirty tiles around changed posts (so removals clear too). + this.buildDefendedDirtyTiles(changedPosts, range); + } + + getDefensePostsTotalCount(): number { + return this.defensePostsTotalCount; + } + + getDefendedDirtyTilesCount(): number { + return this.defendedDirtyTilesCount; + } + + needsDefendedFullRecompute(): boolean { + return this.needsFullDefendedStrengthRecompute; + } + + clearDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = false; + } + + clearDefendedDirtyTiles(): void { + this.defendedDirtyTilesCount = 0; + } + + writeStateUpdateParamsBuffer(updateCount: number): void { + this.stateUpdateParamsData[0] = updateCount >>> 0; + this.stateUpdateParamsData[1] = this.game.config().defensePostRange() >>> 0; + this.stateUpdateParamsData[2] = 0; + this.stateUpdateParamsData[3] = 0; + this.device.queue.writeBuffer( + this.stateUpdateParamsBuffer, + 0, + this.stateUpdateParamsData, + ); + } + + writeDefendedStrengthParamsBuffer(dirtyCount: number): void { + this.defendedStrengthParamsData[0] = dirtyCount >>> 0; + this.defendedStrengthParamsData[1] = + this.game.config().defensePostRange() >>> 0; + this.defendedStrengthParamsData[2] = 0; + this.defendedStrengthParamsData[3] = 0; + this.device.queue.writeBuffer( + this.defendedStrengthParamsBuffer, + 0, + this.defendedStrengthParamsData, + ); } private collectDefensePosts(): Array<{ @@ -518,8 +607,11 @@ export class GroundTruthData { return posts; } - private ensureDefensePostsBuffer(capacity: number): void { - if (this.defensePostsBuffer && capacity <= this.defensePostsCapacity) { + private ensureDefensePostsByOwnerBuffer(capacityPosts: number): void { + if ( + this.defensePostsByOwnerBuffer && + capacityPosts <= this.defensePostsByOwnerCapacity + ) { return; } @@ -527,24 +619,182 @@ export class GroundTruthData { const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; - this.defensePostsCapacity = Math.max( + this.defensePostsByOwnerCapacity = Math.max( 8, - Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacity)))), + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityPosts)))), ); - const bytesPerPost = 12; // 3 * u32 - const bufferSize = this.defensePostsCapacity * bytesPerPost; + const bytesPerPost = 8; // 2 * u32 (x,y) + const bufferSize = this.defensePostsByOwnerCapacity * bytesPerPost; - if (this.defensePostsBuffer) { - (this.defensePostsBuffer as any).destroy?.(); - } - - (this as any).defensePostsBuffer = this.device.createBuffer({ + (this.defensePostsByOwnerBuffer as any).destroy?.(); + this.defensePostsByOwnerBuffer = this.device.createBuffer({ size: bufferSize, usage: STORAGE | COPY_DST_BUF, }); - this.defensePostsStaging = new Uint32Array(this.defensePostsCapacity * 3); + this.defensePostsByOwnerStaging = new Uint32Array( + this.defensePostsByOwnerCapacity * 2, + ); + } + + private ensureDefendedDirtyTilesBuffer(capacityTiles: number): void { + if ( + this.defendedDirtyTilesBuffer && + capacityTiles <= this.defendedDirtyTilesCapacity + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defendedDirtyTilesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityTiles)))), + ); + + const bufferSize = this.defendedDirtyTilesCapacity * 4; // u32 per tile + + (this.defendedDirtyTilesBuffer as any).destroy?.(); + this.defendedDirtyTilesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defendedDirtyTilesStaging = new Uint32Array( + this.defendedDirtyTilesCapacity, + ); + } + + private packDefensePostsByOwner( + posts: Array<{ x: number; y: number; ownerId: number }>, + ): void { + // Reset counts + this.defenseOwnerOffsetsStaging.fill(0); + const counts = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + counts[owner]++; + } + + // Prefix sums into offsets (start,count) pairs. + let running = 0; + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + const count = counts[owner]; + this.defenseOwnerOffsetsStaging[owner * 2] = running; + this.defenseOwnerOffsetsStaging[owner * 2 + 1] = count; + running += count; + } + + this.ensureDefensePostsByOwnerBuffer(running); + if (!this.defensePostsByOwnerStaging) { + throw new Error("defensePostsByOwnerStaging not allocated"); + } + + const writeCursor = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + writeCursor[owner] = this.defenseOwnerOffsetsStaging[owner * 2]; + } + + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + const idx = writeCursor[owner]++; + this.defensePostsByOwnerStaging[idx * 2] = p.x >>> 0; + this.defensePostsByOwnerStaging[idx * 2 + 1] = p.y >>> 0; + } + + this.device.queue.writeBuffer( + this.defenseOwnerOffsetsBuffer, + 0, + this.defenseOwnerOffsetsStaging, + ); + if (running > 0) { + this.device.queue.writeBuffer( + this.defensePostsByOwnerBuffer, + 0, + this.defensePostsByOwnerStaging.subarray(0, running * 2), + ); + } + } + + private ensureDefenseCircleOffsets(range: number): void { + if (range === this.defenseCircleRange) { + return; + } + this.defenseCircleRange = range; + if (range <= 0) { + this.defenseCircleOffsets = new Int16Array(0); + return; + } + + const offsets: number[] = []; + const r2 = range * range; + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { + if (dx * dx + dy * dy <= r2) { + offsets.push(dx, dy); + } + } + } + this.defenseCircleOffsets = new Int16Array(offsets); + } + + private buildDefendedDirtyTiles( + changedPosts: Array<{ x: number; y: number }>, + range: number, + ): void { + if (changedPosts.length === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + this.ensureDefenseCircleOffsets(range); + const offsets = this.defenseCircleOffsets; + const offsetsCount = offsets.length / 2; + if (offsetsCount === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + const worstCase = changedPosts.length * offsetsCount; + const mapTiles = this.mapWidth * this.mapHeight; + if (worstCase > mapTiles) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = true; + return; + } + + this.needsFullDefendedStrengthRecompute = false; + this.ensureDefendedDirtyTilesBuffer(worstCase); + if (!this.defendedDirtyTilesStaging) { + throw new Error("defendedDirtyTilesStaging not allocated"); + } + + let cursor = 0; + for (const post of changedPosts) { + for (let i = 0; i < offsets.length; i += 2) { + const x = post.x + offsets[i]; + const y = post.y + offsets[i + 1]; + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + continue; + } + this.defendedDirtyTilesStaging[cursor++] = + (y * this.mapWidth + x) >>> 0; + } + } + + this.defendedDirtyTilesCount = cursor; + this.device.queue.writeBuffer( + this.defendedDirtyTilesBuffer, + 0, + this.defendedDirtyTilesStaging.subarray(0, cursor), + ); } ensureUpdatesBuffer(capacity: number): GPUBuffer { @@ -566,13 +816,14 @@ export class GroundTruthData { (this.updatesBuffer as any).destroy?.(); } - (this as any).updatesBuffer = this.device.createBuffer({ + const buffer = this.device.createBuffer({ size: bufferSize, usage: STORAGE | COPY_DST_BUF, }); + (this as any).updatesBuffer = buffer; this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); - return this.updatesBuffer; + return buffer; } getUpdatesStaging(): Uint32Array { @@ -601,54 +852,10 @@ export class GroundTruthData { 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; } diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts index 41249ba49..b5d30bc86 100644 --- a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -42,25 +42,20 @@ export class TerritoryRenderPass implements RenderPass { { binding: 1, visibility: 2 /* FRAGMENT */, - buffer: { type: "uniform" }, + texture: { sampleType: "uint" }, }, { binding: 2, visibility: 2 /* FRAGMENT */, - texture: { sampleType: "uint" }, + texture: { sampleType: "float" }, }, { binding: 3, visibility: 2 /* FRAGMENT */, - texture: { sampleType: "uint" }, - }, - { - binding: 4, - visibility: 2 /* FRAGMENT */, texture: { sampleType: "float" }, }, { - binding: 5, + binding: 4, visibility: 2 /* FRAGMENT */, texture: { sampleType: "float" }, }, @@ -112,7 +107,6 @@ export class TerritoryRenderPass implements RenderPass { // Update uniforms resources.writeUniformBuffer(performance.now() / 1000); - resources.writeDefenseParamsBuffer(); const pass = encoder.beginRenderPass({ colorAttachments: [ @@ -142,9 +136,8 @@ export class TerritoryRenderPass implements RenderPass { !this.bindGroupLayout || !this.resources || !this.resources.uniformBuffer || - !this.resources.defenseParamsBuffer || !this.resources.stateTexture || - !this.resources.defendedTexture || + !this.resources.defendedStrengthTexture || !this.resources.paletteTexture || !this.resources.terrainTexture ) { @@ -157,22 +150,18 @@ export class TerritoryRenderPass implements RenderPass { { 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: 2, + resource: this.resources.defendedStrengthTexture.createView(), }, { - binding: 4, + binding: 3, resource: this.resources.paletteTexture.createView(), }, { - binding: 5, + binding: 4, resource: this.resources.terrainTexture.createView(), }, ], diff --git a/src/client/graphics/webgpu/shaders/common/uniforms.wgsl b/src/client/graphics/webgpu/shaders/common/uniforms.wgsl deleted file mode 100644 index d60f9986f..000000000 --- a/src/client/graphics/webgpu/shaders/common/uniforms.wgsl +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 682cc4786..000000000 --- a/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl +++ /dev/null @@ -1,12 +0,0 @@ -@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-strength-full.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl new file mode 100644 index 000000000..0cea311a2 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl @@ -0,0 +1,65 @@ +struct Params { + _dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(3) var ownerOffsets: array; +@group(0) @binding(4) var postsByOwner: array; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let dims = textureDimensions(stateTex); + if (globalId.x >= dims.x || globalId.y >= dims.y) { + return; + } + + let x = i32(globalId.x); + let y = i32(globalId.y); + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl new file mode 100644 index 000000000..828392ce0 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl @@ -0,0 +1,69 @@ +struct Params { + dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var dirtyTiles: array; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.dirtyCount) { + return; + } + + let tileIndex = dirtyTiles[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(tileIndex % mapWidth); + let y = i32(tileIndex / mapWidth); + + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl deleted file mode 100644 index 0d3780661..000000000 --- a/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl +++ /dev/null @@ -1,53 +0,0 @@ -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 index 8fb0821a5..dec940fc9 100644 --- a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -3,13 +3,24 @@ struct Update { newState: u32, }; -@group(0) @binding(0) var updates: array; -@group(0) @binding(1) var stateTex: texture_storage_2d; +struct Params { + updateCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var updates: array; +@group(0) @binding(2) var stateTex: texture_storage_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) globalId: vec3) { let idx = globalId.x; - if (idx >= arrayLength(&updates)) { + if (idx >= p.updateCount) { return; } let update = updates[idx]; @@ -18,4 +29,45 @@ fn main(@builtin(global_invocation_id) globalId: vec3) { let x = i32(update.tileIndex % mapWidth); let y = i32(update.tileIndex / mapWidth); textureStore(stateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); + + // Update defended strength for this tile based on the new owner. + let owner = update.newState & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); } diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl index 591b59941..5f1d7c334 100644 --- a/src/client/graphics/webgpu/shaders/render/territory.wgsl +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -4,19 +4,11 @@ struct Uniforms { 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; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; @vertex fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { @@ -56,15 +48,17 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { let hasFallout = (state & 0x2000u) != 0u; let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; var outColor = terrain; if (owner != 0u) { // Player colors start at index 10 let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); - 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); - } + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + clamp(0.35 * defendedStrength, 0.0, 0.35), + ); if (hasFallout) { // Fallout color is at index 0 let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; From 99e7d36464d5dc11de54c2b65137ce51333892e7 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:33:45 +0100 Subject: [PATCH 08/23] Switched loadShader() to a Vite-bundled static shader map using import.meta.glob(..., { as: "raw", eager: true }) --- .../graphics/webgpu/core/ShaderLoader.ts | 32 +++++++------------ .../webgpu/shaders/render/territory.wgsl | 2 +- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/client/graphics/webgpu/core/ShaderLoader.ts b/src/client/graphics/webgpu/core/ShaderLoader.ts index c5b818d50..19ad380ec 100644 --- a/src/client/graphics/webgpu/core/ShaderLoader.ts +++ b/src/client/graphics/webgpu/core/ShaderLoader.ts @@ -1,28 +1,18 @@ /** - * Utility for loading WGSL shader files via Vite ?raw imports. - * Caches loaded shaders to avoid re-importing. + * Utility for loading WGSL shader sources bundled by Vite. + * Uses a static glob so production builds reliably include all shaders. */ -const shaderCache = new Map>(); +const shaderSources = import.meta.glob("../shaders/**/*.wgsl", { + as: "raw", + eager: true, +}) as Record; -/** - * 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)!; + const key = `../shaders/${path}`; + const src = shaderSources[key]; + if (!src) { + throw new Error(`Missing WGSL shader source: ${key}`); } - - // 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; + return src; } diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl index 5f1d7c334..565dc00aa 100644 --- a/src/client/graphics/webgpu/shaders/render/territory.wgsl +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -57,7 +57,7 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { territoryRgb = mix( territoryRgb, vec3f(1.0, 0.0, 1.0), - clamp(0.35 * defendedStrength, 0.0, 0.35), + clamp(0.8 * defendedStrength, 0.1, 0.35), ); if (hasFallout) { // Fallout color is at index 0 From 06eb82b1cf1750ef09c614242a636ad501f25cf7 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:10:36 +0100 Subject: [PATCH 09/23] border test 9000 --- resources/lang/en.json | 2 + src/client/UserSettingModal.ts | 25 ++++++++ .../baseComponents/setting/SettingSelect.ts | 18 ++++-- src/client/graphics/layers/SettingsModal.ts | 39 ++++++++++++ src/client/graphics/layers/TerritoryLayer.ts | 15 +++++ .../graphics/webgpu/TerritoryRenderer.ts | 7 +++ .../graphics/webgpu/core/GroundTruthData.ts | 13 +++- .../webgpu/shaders/render/territory.wgsl | 59 ++++++++++++++++++- src/core/game/UserSettings.ts | 18 ++++++ 9 files changed, 186 insertions(+), 10 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 5135cc399..e119dae09 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -703,6 +703,8 @@ "coordinate_grid_desc": "Toggle the alphanumeric grid overlay", "attacking_troops_overlay_label": "Attacking Troops Overlay", "attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.", + "territory_border_mode_label": "Territory Borders", + "territory_border_mode_desc": "Select border rendering style (visual only)", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 60d434dbf..6f6ddb698 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -300,6 +300,16 @@ export class UserSettingModal extends BaseModal { this.requestUpdate(); } + private changeTerritoryBorderMode(e: CustomEvent<{ value: number | string }>) { + const rawValue = e.detail?.value; + const value = + typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10); + if (!Number.isFinite(value)) return; + + this.userSettings.setInt("settings.territoryBorderMode", Math.round(value)); + this.requestUpdate(); + } + private toggleTerritoryPatterns() { this.userSettings.toggleTerritoryPatterns(); @@ -752,6 +762,21 @@ export class UserSettingModal extends BaseModal { > + +
@@ -51,7 +57,7 @@ export class SettingSelect extends LitElement {
+ + + + +
+
-
- territoryBorderMode -
-
- ${translateText("user_setting.territory_border_mode_label")} -
-
- ${translateText("user_setting.territory_border_mode_desc")} -
-
- -
- + +
`; } diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 3cebb41c1..e202dc38f 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -7,9 +7,11 @@ import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass"; import { DefendedStrengthPass } from "./compute/DefendedStrengthPass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; import { TerrainComputePass } from "./compute/TerrainComputePass"; +import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass"; import { GroundTruthData } from "./core/GroundTruthData"; import { WebGPUDevice } from "./core/WebGPUDevice"; import { RenderPass } from "./render/RenderPass"; +import { TemporalResolvePass } from "./render/TemporalResolvePass"; import { TerritoryRenderPass } from "./render/TerritoryRenderPass"; export interface TerritoryWebGLCreateResult { @@ -31,10 +33,15 @@ export class TerritoryRenderer { private territoryShaderPath = "render/territory.wgsl"; private territoryShaderParams0 = new Float32Array(4); private territoryShaderParams1 = new Float32Array(4); + private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl"; + private preSmoothingParams0 = new Float32Array(4); + private postSmoothingShaderPath = "render/temporal-resolve.wgsl"; + private postSmoothingParams0 = new Float32Array(4); // Compute passes private computePasses: ComputePass[] = []; private computePassOrder: ComputePass[] = []; + private frameComputePasses: ComputePass[] = []; // Render passes private renderPasses: RenderPass[] = []; @@ -45,9 +52,14 @@ export class TerritoryRenderer { private stateUpdatePass: StateUpdatePass | null = null; private defendedStrengthFullPass: DefendedStrengthFullPass | null = null; private defendedStrengthPass: DefendedStrengthPass | null = null; + private visualStateSmoothingPass: VisualStateSmoothingPass | null = null; private territoryRenderPass: TerritoryRenderPass | null = null; + private temporalResolvePass: TemporalResolvePass | null = null; private readonly defensePostRange: number; + private preSmoothingEnabled = false; + private postSmoothingEnabled = false; + private constructor( private readonly game: GameView, private readonly theme: Theme, @@ -115,6 +127,7 @@ export class TerritoryRenderer { this.stateUpdatePass = new StateUpdatePass(); this.defendedStrengthFullPass = new DefendedStrengthFullPass(); this.defendedStrengthPass = new DefendedStrengthPass(); + this.visualStateSmoothingPass = new VisualStateSmoothingPass(); this.computePasses = [ this.terrainComputePass, @@ -123,15 +136,22 @@ export class TerritoryRenderer { this.defendedStrengthPass, ]; + this.frameComputePasses = [this.visualStateSmoothingPass]; + // Create render passes this.territoryRenderPass = new TerritoryRenderPass(); - this.renderPasses = [this.territoryRenderPass]; + this.temporalResolvePass = new TemporalResolvePass(); + this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass]; // Initialize all passes for (const pass of this.computePasses) { await pass.init(webgpuDevice.device, this.resources); } + for (const pass of this.frameComputePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + for (const pass of this.renderPasses) { await pass.init( webgpuDevice.device, @@ -144,6 +164,9 @@ export class TerritoryRenderer { await this.territoryRenderPass.setShader(this.territoryShaderPath); } + this.applyPreSmoothingConfig(); + this.applyPostSmoothingConfig(); + // Compute dependency order (topological sort) this.computePassOrder = this.topologicalSort(this.computePasses); this.renderPassOrder = this.topologicalSort(this.renderPasses); @@ -215,6 +238,15 @@ export class TerritoryRenderer { this.canvas.height = nextHeight; this.resources.setViewSize(nextWidth, nextHeight); this.device.reconfigure(); + + if (this.postSmoothingEnabled && this.resources) { + this.resources.ensurePostSmoothingTextures( + nextWidth, + nextHeight, + this.device.canvasFormat, + ); + this.resources.invalidateHistory(); + } } setViewTransform(scale: number, offsetX: number, offsetY: number): void { @@ -243,6 +275,7 @@ export class TerritoryRenderer { if (this.territoryRenderPass) { void this.territoryRenderPass.setShader(shaderPath); } + this.resources?.invalidateHistory(); } setTerritoryShaderParams( @@ -261,6 +294,77 @@ export class TerritoryRenderer { this.territoryShaderParams0, this.territoryShaderParams1, ); + this.resources.invalidateHistory(); + } + + setPreSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.preSmoothingEnabled = enabled; + if (shaderPath) { + this.preSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.preSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPreSmoothingConfig(); + } + + setPostSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.postSmoothingEnabled = enabled; + if (shaderPath) { + this.postSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.postSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPostSmoothingConfig(); + } + + private applyPreSmoothingConfig(): void { + if (!this.resources || !this.visualStateSmoothingPass) { + return; + } + + this.resources.setUseVisualStateTexture(this.preSmoothingEnabled); + if (this.preSmoothingEnabled) { + this.resources.ensureVisualStateTexture(); + void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath); + this.visualStateSmoothingPass.setParams(this.preSmoothingParams0); + } else { + this.visualStateSmoothingPass.setParams(new Float32Array(4)); + this.resources.releaseVisualStateTexture(); + } + + this.resources.invalidateHistory(); + } + + private applyPostSmoothingConfig(): void { + if (!this.resources || !this.temporalResolvePass || !this.device) { + return; + } + + if (this.postSmoothingEnabled) { + void this.temporalResolvePass.setShader(this.postSmoothingShaderPath); + this.temporalResolvePass.setParams(this.postSmoothingParams0); + this.temporalResolvePass.setEnabled(true); + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + this.device.canvasFormat, + ); + } else { + this.temporalResolvePass.setEnabled(false); + this.resources.releasePostSmoothingTextures(); + } + + this.resources.invalidateHistory(); } markTile(tile: TileRef): void { @@ -340,6 +444,8 @@ export class TerritoryRenderer { return; } + this.resources.updateTickTiming(performance.now() / 1000); + if (this.game.config().defensePostRange() !== this.defensePostRange) { throw new Error("defensePostRange changed at runtime; unsupported."); } @@ -356,9 +462,14 @@ export class TerritoryRenderer { // Initial state upload this.resources.uploadState(); + const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false; + if (!stateUpdatesPending) { + this.resources.setLastStateUpdateCount(0); + } + const needsCompute = (this.terrainComputePass?.needsUpdate() ?? false) || - (this.stateUpdatePass?.needsUpdate() ?? false) || + stateUpdatesPending || (this.defendedStrengthFullPass?.needsUpdate() ?? false) || (this.defendedStrengthPass?.needsUpdate() ?? false); @@ -368,6 +479,23 @@ export class TerritoryRenderer { const encoder = this.device.device.createCommandEncoder(); + if (this.preSmoothingEnabled && stateUpdatesPending) { + this.resources.ensureVisualStateTexture(); + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + this.resources.consumeVisualStateSyncNeeded(); + } + } + // Execute compute passes in dependency order (clear will run before update if needed) for (const pass of this.computePassOrder) { if (!pass.needsUpdate()) { @@ -393,6 +521,9 @@ export class TerritoryRenderer { return; } + const nowSec = performance.now() / 1000; + this.resources.writeTemporalUniformBuffer(nowSec); + // If terrain needs recomputation, trigger it asynchronously (no blocking) // It will be ready for the next frame, acceptable trade-off for performance if (this.terrainComputePass?.needsUpdate()) { @@ -404,14 +535,54 @@ export class TerritoryRenderer { } const encoder = this.device.device.createCommandEncoder(); - const textureView = this.device.context.getCurrentTexture().createView(); + const swapchainView = this.device.context.getCurrentTexture().createView(); + + if ( + this.preSmoothingEnabled && + this.resources.consumeVisualStateSyncNeeded() + ) { + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + } + } + + for (const pass of this.frameComputePasses) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } // Execute render passes in dependency order for (const pass of this.renderPassOrder) { if (!pass.needsUpdate()) { continue; } - pass.execute(encoder, this.resources, textureView); + if (pass === this.territoryRenderPass && this.postSmoothingEnabled) { + if (!this.resources.getCurrentColorTexture()) { + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + this.device.canvasFormat, + ); + } + const currentTexture = this.resources.getCurrentColorTexture(); + if (currentTexture) { + pass.execute(encoder, this.resources, currentTexture.createView()); + } + continue; + } + + pass.execute(encoder, this.resources, swapchainView); } this.device.device.queue.submit([encoder.finish()]); diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts index 05dee89ff..f874305e2 100644 --- a/src/client/graphics/webgpu/compute/StateUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -87,6 +87,8 @@ export class StateUpdatePass implements ComputePass { return; } + resources.setLastStateUpdateCount(numUpdates); + const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); resources.writeStateUpdateParamsBuffer(numUpdates); diff --git a/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts new file mode 100644 index 000000000..488c3c078 --- /dev/null +++ b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts @@ -0,0 +1,203 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Per-frame compute pass that updates the visual state texture. + * Supports dissolve and budgeted reveal modes. + */ +export class VisualStateSmoothingPass implements ComputePass { + name = "visual-state-smoothing"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(8); + private enabled = false; + private shaderPath = "compute/visual-state-smoothing.wgsl"; + private mode = 0; + private curveExp = 1; + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundVisualStateTexture: GPUTexture | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.paramsBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST, + }); + + await this.setShader(this.shaderPath); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + if (!this.device) { + return; + } + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + this.pipeline = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + setParams(params0: Float32Array | number[]): void { + this.mode = Number(params0[0] ?? 0); + this.curveExp = Number(params0[1] ?? 1); + this.enabled = this.mode > 0; + } + + needsUpdate(): boolean { + if (!this.enabled || !this.resources) { + return false; + } + return this.resources.getLastStateUpdateCount() > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + + const updateCount = resources.getLastStateUpdateCount(); + if (updateCount <= 0) { + return; + } + + const updatesBuffer = resources.updatesBuffer; + const visualStateTexture = resources.getVisualStateTexture(); + if (!updatesBuffer || !visualStateTexture) { + return; + } + + this.paramsData[0] = this.mode; + this.paramsData[1] = this.curveExp; + this.paramsData[2] = 0; + this.paramsData[3] = 0; + this.paramsData[4] = updateCount; + this.paramsData[5] = 0; + this.paramsData[6] = 0; + this.paramsData[7] = 0; + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundVisualStateTexture !== visualStateTexture; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(updateCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer || + !this.resources.updatesBuffer || + !this.resources.getVisualStateTexture() + ) { + this.bindGroup = null; + return; + } + + const visualStateTexture = this.resources.getVisualStateTexture(); + if (!visualStateTexture) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: { buffer: this.resources.updatesBuffer }, + }, + { + binding: 3, + resource: visualStateTexture.createView(), + }, + ], + }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundVisualStateTexture = visualStateTexture; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index bef95e294..e0a2923ee 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -26,9 +26,13 @@ export class GroundTruthData { public readonly ownerIndexTexture: GPUTexture; public readonly relationsTexture: GPUTexture; public readonly defendedStrengthTexture: GPUTexture; + public visualStateTexture: GPUTexture | null = null; + public currentColorTexture: GPUTexture | null = null; + public historyColorTextures: [GPUTexture, GPUTexture] | null = null; // Buffers public readonly uniformBuffer: GPUBuffer; + public readonly temporalUniformBuffer: GPUBuffer; public readonly terrainParamsBuffer: GPUBuffer; public readonly stateUpdateParamsBuffer: GPUBuffer; public readonly defendedStrengthParamsBuffer: GPUBuffer; @@ -57,6 +61,19 @@ export class GroundTruthData { private needsPaletteUpload = true; private needsTerrainDataUpload = true; private needsTerrainParamsUpload = true; + private useVisualStateTexture = false; + private visualStateNeedsSync = false; + private lastStateUpdateCount = 0; + private historyIndex = 0; + private historyValid = false; + private postSmoothingWidth = 0; + private postSmoothingHeight = 0; + private postSmoothingFormat: GPUTextureFormat | null = null; + private lastTickSec = 0; + private tickDtSec = 0.1; + private tickDtEmaSec = 0.1; + private tickCount = 0; + private readonly tickEmaAlpha = 0.2; private paletteWidth = 1; private needsDefensePostsUpload = true; private defensePostsTotalCount = 0; @@ -68,6 +85,7 @@ export class GroundTruthData { // Uniform data arrays private readonly uniformData = new Float32Array(20); + private readonly temporalData = new Float32Array(8); private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad @@ -107,6 +125,7 @@ export class GroundTruthData { const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const COPY_SRC_TEX = GPUTextureUsage?.COPY_SRC ?? 0x1; const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; @@ -116,6 +135,12 @@ export class GroundTruthData { usage: UNIFORM | COPY_DST_BUF, }); + // Temporal uniforms: 2x vec4f = 32 bytes + this.temporalUniformBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST_BUF, + }); + // State update params: 4x u32 = 16 bytes this.stateUpdateParamsBuffer = device.createBuffer({ size: 16, @@ -138,7 +163,7 @@ export class GroundTruthData { this.stateTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, format: "r32uint", - usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + usage: COPY_DST_TEX | COPY_SRC_TEX | TEXTURE_BINDING | STORAGE_BINDING, }); // Defended strength texture (rgba8unorm, r channel used) @@ -233,13 +258,24 @@ export class GroundTruthData { } setViewTransform(scale: number, offsetX: number, offsetY: number): void { + const eps = 1e-4; + const changed = + Math.abs(scale - this.viewScale) > eps || + Math.abs(offsetX - this.viewOffsetX) > eps || + Math.abs(offsetY - this.viewOffsetY) > eps; this.viewScale = scale; this.viewOffsetX = offsetX; this.viewOffsetY = offsetY; + if (changed) { + this.invalidateHistory(); + } } setAlternativeView(enabled: boolean): void { - this.alternativeView = enabled; + if (this.alternativeView !== enabled) { + this.alternativeView = enabled; + this.invalidateHistory(); + } } setHighlightedOwnerId(ownerSmallId: number | null): void { @@ -256,6 +292,190 @@ export class GroundTruthData { } } + setUseVisualStateTexture(enabled: boolean): void { + this.useVisualStateTexture = enabled; + if (enabled) { + this.visualStateNeedsSync = true; + } + } + + consumeVisualStateSyncNeeded(): boolean { + if (!this.visualStateNeedsSync) { + return false; + } + this.visualStateNeedsSync = false; + return true; + } + + ensureVisualStateTexture(): void { + if (this.visualStateTexture) { + return; + } + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + this.visualStateTexture = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + } + + releaseVisualStateTexture(): void { + if (this.visualStateTexture) { + (this.visualStateTexture as any).destroy?.(); + this.visualStateTexture = null; + } + } + + getVisualStateTexture(): GPUTexture | null { + return this.visualStateTexture; + } + + getRenderStateTexture(): GPUTexture { + if (this.useVisualStateTexture && this.visualStateTexture) { + return this.visualStateTexture; + } + return this.stateTexture; + } + + setLastStateUpdateCount(count: number): void { + this.lastStateUpdateCount = Math.max(0, Math.floor(count)); + } + + getLastStateUpdateCount(): number { + return this.lastStateUpdateCount; + } + + updateTickTiming(nowSec: number): void { + if (this.lastTickSec > 0) { + const dt = Math.max(1e-3, nowSec - this.lastTickSec); + this.tickDtSec = dt; + this.tickDtEmaSec = + this.tickDtEmaSec * (1 - this.tickEmaAlpha) + dt * this.tickEmaAlpha; + } + this.lastTickSec = nowSec; + this.tickCount += 1; + } + + writeTemporalUniformBuffer(nowSec: number): void { + const denom = Math.max(1e-3, this.tickDtEmaSec); + const alpha = Math.max(0, Math.min(1, (nowSec - this.lastTickSec) / denom)); + + this.temporalData[0] = nowSec; + this.temporalData[1] = this.lastTickSec; + this.temporalData[2] = this.tickDtSec; + this.temporalData[3] = this.tickDtEmaSec; + this.temporalData[4] = alpha; + this.temporalData[5] = this.tickCount; + this.temporalData[6] = this.historyValid ? 1 : 0; + this.temporalData[7] = 0; + + this.device.queue.writeBuffer( + this.temporalUniformBuffer, + 0, + this.temporalData, + ); + } + + invalidateHistory(): void { + this.historyValid = false; + } + + markHistoryValid(): void { + this.historyValid = true; + } + + swapHistoryTextures(): void { + if (!this.historyColorTextures) { + return; + } + this.historyIndex = this.historyIndex === 0 ? 1 : 0; + } + + ensurePostSmoothingTextures( + width: number, + height: number, + format: GPUTextureFormat, + ): void { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const needsRebuild = + !this.currentColorTexture || + !this.historyColorTextures || + this.postSmoothingWidth !== w || + this.postSmoothingHeight !== h || + this.postSmoothingFormat !== format; + + if (!needsRebuild) { + return; + } + + this.releasePostSmoothingTextures(); + + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const RENDER_ATTACHMENT = GPUTextureUsage?.RENDER_ATTACHMENT ?? 0x10; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + + this.currentColorTexture = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyA = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyB = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + + this.historyColorTextures = [historyA, historyB]; + this.historyIndex = 0; + this.historyValid = false; + this.postSmoothingWidth = w; + this.postSmoothingHeight = h; + this.postSmoothingFormat = format; + } + + releasePostSmoothingTextures(): void { + if (this.currentColorTexture) { + (this.currentColorTexture as any).destroy?.(); + this.currentColorTexture = null; + } + if (this.historyColorTextures) { + (this.historyColorTextures[0] as any).destroy?.(); + (this.historyColorTextures[1] as any).destroy?.(); + this.historyColorTextures = null; + } + this.historyValid = false; + this.postSmoothingWidth = 0; + this.postSmoothingHeight = 0; + this.postSmoothingFormat = null; + } + + getCurrentColorTexture(): GPUTexture | null { + return this.currentColorTexture; + } + + getHistoryReadTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex]; + } + + getHistoryWriteTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex === 0 ? 1 : 0]; + } + // ===================== // Upload methods // ===================== diff --git a/src/client/graphics/webgpu/render/TemporalResolvePass.ts b/src/client/graphics/webgpu/render/TemporalResolvePass.ts new file mode 100644 index 000000000..1d9a4b162 --- /dev/null +++ b/src/client/graphics/webgpu/render/TemporalResolvePass.ts @@ -0,0 +1,218 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { RenderPass } from "./RenderPass"; + +/** + * Post-render temporal resolve pass. Blends current and history frames. + */ +export class TemporalResolvePass implements RenderPass { + name = "temporal-resolve"; + dependencies: string[] = ["territory"]; + + private pipeline: GPURenderPipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private canvasFormat: GPUTextureFormat | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(4); + private enabled = false; + private boundCurrentTexture: GPUTexture | null = null; + private boundHistoryTexture: GPUTexture | null = null; + + async init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise { + this.device = device; + this.resources = resources; + this.canvasFormat = canvasFormat; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + this.paramsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST, + }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + ], + }); + + await this.setShader("render/temporal-resolve.wgsl"); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + setParams(params0: Float32Array | number[]): void { + this.paramsData[0] = Number(params0[0] ?? 0); + this.paramsData[1] = Number(params0[1] ?? 1); + this.paramsData[2] = Number(params0[2] ?? 0.08); + this.paramsData[3] = 0; + this.enabled = this.paramsData[0] > 0; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + needsUpdate(): boolean { + return this.enabled; + } + + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + if (!this.enabled) { + return; + } + + const currentTexture = resources.getCurrentColorTexture(); + const historyRead = resources.getHistoryReadTexture(); + const historyWrite = resources.getHistoryWriteTexture(); + if (!currentTexture || !historyRead || !historyWrite) { + return; + } + + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundCurrentTexture !== currentTexture || + this.boundHistoryTexture !== historyRead; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + { + view: historyWrite.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], + }); + + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.draw(3); + pass.end(); + + resources.swapHistoryTextures(); + resources.markHistoryValid(); + } + + rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer + ) { + return; + } + + const currentTexture = this.resources.getCurrentColorTexture(); + const historyRead = this.resources.getHistoryReadTexture(); + if (!currentTexture || !historyRead) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: currentTexture.createView(), + }, + { + binding: 3, + resource: historyRead.createView(), + }, + ], + }); + + this.boundCurrentTexture = currentTexture; + this.boundHistoryTexture = historyRead; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts new file mode 100644 index 000000000..e0fcb073d --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts @@ -0,0 +1,128 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve"; + +export interface TerritoryPostSmoothingDefinition { + id: TerritoryPostSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_POST_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.post"; + +export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "fade", + label: "Fade", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + ], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.dissolveWidth", + label: "Dissolve Width", + defaultValue: 0.08, + min: 0.01, + max: 0.4, + step: 0.01, + }, + ], + }, +]; + +export function territoryPostSmoothingIdFromInt( + value: number, +): TerritoryPostSmoothingId { + if (value === 1) return "fade"; + if (value === 2) return "dissolve"; + return "off"; +} + +export function territoryPostSmoothingIntFromId( + id: TerritoryPostSmoothingId, +): number { + if (id === "fade") return 1; + if (id === "dissolve") return 2; + return 0; +} + +export function readTerritoryPostSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPostSmoothingId { + return territoryPostSmoothingIdFromInt( + userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0), + ); +} + +export function buildTerritoryPostSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPostSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const blendStrength = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.blendStrength", + 1, + ); + const dissolveWidth = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.dissolveWidth", + 0.08, + ); + + const mode = smoothingId === "fade" ? 1 : 2; + const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + + return { + enabled: true, + shaderPath: "render/temporal-resolve.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts new file mode 100644 index 000000000..e04ee0a6d --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts @@ -0,0 +1,114 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget"; + +export interface TerritoryPreSmoothingDefinition { + id: TerritoryPreSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_PRE_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.pre"; + +export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, + { + id: "budget", + label: "Budgeted Reveal", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, +]; + +export function territoryPreSmoothingIdFromInt( + value: number, +): TerritoryPreSmoothingId { + if (value === 1) return "dissolve"; + if (value === 2) return "budget"; + return "off"; +} + +export function territoryPreSmoothingIntFromId( + id: TerritoryPreSmoothingId, +): number { + if (id === "dissolve") return 1; + if (id === "budget") return 2; + return 0; +} + +export function readTerritoryPreSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPreSmoothingId { + return territoryPreSmoothingIdFromInt( + userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0), + ); +} + +export function buildTerritoryPreSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPreSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const curveExp = userSettings.getFloat( + "settings.webgpu.territory.preSmoothing.curveExp", + 1, + ); + const mode = smoothingId === "dissolve" ? 1 : 2; + + const params0 = new Float32Array([mode, curveExp, 0, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + return { + enabled: true, + shaderPath: "compute/visual-state-smoothing.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts index 34f5f278a..b9f875a5b 100644 --- a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -157,7 +157,6 @@ export class TerritoryRenderPass implements RenderPass { !this.bindGroupLayout || !this.resources || !this.resources.uniformBuffer || - !this.resources.stateTexture || !this.resources.defendedStrengthTexture || !this.resources.paletteTexture || !this.resources.terrainTexture || @@ -167,13 +166,15 @@ export class TerritoryRenderPass implements RenderPass { return; } + const stateTexture = this.resources.getRenderStateTexture(); + this.bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { binding: 0, resource: { buffer: this.resources.uniformBuffer } }, { binding: 1, - resource: this.resources.stateTexture.createView(), + resource: stateTexture.createView(), }, { binding: 2, diff --git a/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl new file mode 100644 index 000000000..3be34cec0 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl @@ -0,0 +1,76 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=curveExp + params1: vec4f, // x=updateCount +}; + +struct Update { + tileIndex: u32, + newState: u32, +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var updates: array; +@group(0) @binding(3) var visualStateTex: texture_storage_2d; + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + let updateCount = u32(max(0.0, p.params1.x) + 0.5); + if (idx >= updateCount) { + return; + } + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let curveExp = max(0.001, p.params0.y); + let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0); + + let update = updates[idx]; + + if (mode == 1u) { + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u)); + let r = hashToUnitFloat(h); + if (r > alpha) { + return; + } + } else if (mode == 2u) { + let targetCount = u32(floor(f32(updateCount) * alpha)); + if (idx >= targetCount) { + return; + } + } else { + return; + } + + let dims = textureDimensions(visualStateTex); + let mapWidth = dims.x; + let x = i32(update.tileIndex % mapWidth); + let y = i32(update.tileIndex / mapWidth); + textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); +} diff --git a/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl new file mode 100644 index 000000000..e4cd48dbe --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl @@ -0,0 +1,81 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var currentTex: texture_2d; +@group(0) @binding(3) var historyTex: texture_2d; + +struct FragOutput { + @location(0) color: vec4f, + @location(1) history: vec4f, +}; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> FragOutput { + let texCoord = vec2i(pos.xy); + let curr = textureLoad(currentTex, texCoord, 0); + let hist = textureLoad(historyTex, texCoord, 0); + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let strength = clamp(p.params0.y, 0.0, 1.0); + let width = max(0.001, p.params0.z); + + var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0); + if (t.historyValid < 0.5) { + alpha = 1.0; + } + + if (mode == 1u) { + let outColor = mix(hist, curr, alpha); + return FragOutput(outColor, outColor); + } + + if (mode == 2u) { + let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u); + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u))); + let mask = smoothstep(alpha - width, alpha + width, r); + let outColor = mix(hist, curr, mask); + return FragOutput(outColor, outColor); + } + + return FragOutput(curr, curr); +} From fd87b0e3f8de2ec7015111a81b205880f56ab7cf Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:55:19 +0100 Subject: [PATCH 14/23] adjusted defaults --- .../webgpu/render/TerritoryPostSmoothingRegistry.ts | 6 +++--- .../graphics/webgpu/render/TerritoryShaderRegistry.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts index e0fcb073d..be5a76a8e 100644 --- a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts +++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts @@ -28,8 +28,8 @@ export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [ kind: "range", key: "settings.webgpu.territory.postSmoothing.blendStrength", label: "Blend Strength", - defaultValue: 1, - min: 0, + defaultValue: 0.2, + min: 0.01, max: 1, step: 0.01, }, @@ -108,7 +108,7 @@ export function buildTerritoryPostSmoothingParams( const blendStrength = userSettings.getFloat( "settings.webgpu.territory.postSmoothing.blendStrength", - 1, + 0.2, ); const dissolveWidth = userSettings.getFloat( "settings.webgpu.territory.postSmoothing.dissolveWidth", diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts index 183993fa2..ee78cdf1c 100644 --- a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts +++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts @@ -194,7 +194,7 @@ export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [ key: "settings.webgpu.territory.retro.defendedThreshold", label: "Defended Threshold", defaultValue: 0.01, - min: 0, + min: 0.01, max: 1, step: 0.01, }, From c9ea04abacc6b8629c0bdf78488d4f1e9f1c3ef6 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:43:25 +0100 Subject: [PATCH 15/23] Add improved terrain compute shaders with lite and heavy variants - Add terrain-compute-improved-lite.wgsl and terrain-compute-improved-heavy.wgsl - Create TerrainShaderRegistry.ts for shader management - Refactor TerrainComputePass to support dynamic shader switching - Update TerritoryRenderer, TerritoryLayer, and GroundTruthData for new shader integration - Enhance WebGPUDebugOverlay with additional debugging capabilities --- src/client/graphics/layers/TerritoryLayer.ts | 26 ++ .../graphics/layers/WebGPUDebugOverlay.ts | 55 ++++- .../graphics/webgpu/TerritoryRenderer.ts | 37 +++ .../webgpu/compute/TerrainComputePass.ts | 66 +++-- .../graphics/webgpu/core/GroundTruthData.ts | 29 ++- .../webgpu/render/TerrainShaderRegistry.ts | 233 ++++++++++++++++++ .../terrain-compute-improved-heavy.wgsl | 167 +++++++++++++ .../terrain-compute-improved-lite.wgsl | 103 ++++++++ .../shaders/compute/terrain-compute.wgsl | 2 + 9 files changed, 688 insertions(+), 30 deletions(-) create mode 100644 src/client/graphics/webgpu/render/TerrainShaderRegistry.ts create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index f2a2c93b5..94b27d886 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -11,6 +11,10 @@ import { } from "../../InputHandler"; import { FrameProfiler } from "../FrameProfiler"; import { TransformHandler } from "../TransformHandler"; +import { + buildTerrainShaderParams, + readTerrainShaderId, +} from "../webgpu/render/TerrainShaderRegistry"; import { buildTerritoryPostSmoothingParams, readTerritoryPostSmoothingId, @@ -43,6 +47,7 @@ export class TerritoryLayer implements Layer { private lastPaletteSignature: string | null = null; private lastDefensePostsSignature: string | null = null; + private lastTerrainShaderSignature: string | null = null; private lastTerritoryShaderSignature: string | null = null; private lastPreSmoothingSignature: string | null = null; private lastPostSmoothingSignature: string | null = null; @@ -87,6 +92,7 @@ export class TerritoryLayer implements Layer { this.refreshPaletteIfNeeded(); this.refreshDefensePostsIfNeeded(); + this.applyTerrainShaderSettings(); this.applyTerritoryShaderSettings(); this.applyTerritorySmoothingSettings(); @@ -124,6 +130,7 @@ export class TerritoryLayer implements Layer { this.territoryRenderer = renderer; this.territoryRenderer.setAlternativeView(this.alternativeView); this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); + this.applyTerrainShaderSettings(true); this.applyTerritoryShaderSettings(true); this.applyTerritorySmoothingSettings(true); this.territoryRenderer.markAllDirty(); @@ -335,6 +342,25 @@ export class TerritoryLayer implements Layer { this.territoryRenderer.setTerritoryShaderParams(params0, params1); } + private applyTerrainShaderSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const terrainId = readTerrainShaderId(this.userSettings); + const { shaderPath, params0, params1 } = buildTerrainShaderParams( + this.userSettings, + terrainId, + ); + const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; + if (!force && signature === this.lastTerrainShaderSignature) { + return; + } + this.lastTerrainShaderSignature = signature; + this.territoryRenderer.setTerrainShader(shaderPath); + this.territoryRenderer.setTerrainShaderParams(params0, params1); + } + private applyTerritorySmoothingSettings(force: boolean = false) { if (!this.territoryRenderer) { return; diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index cf60eee5a..908d31ce4 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -4,6 +4,13 @@ import { live } from "lit/directives/live.js"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { WebGPUComputeMetricsEvent } from "../../InputHandler"; +import { + TERRAIN_SHADER_KEY, + TERRAIN_SHADERS, + terrainShaderIdFromInt, + terrainShaderIntFromId, + TerrainShaderOption, +} from "../webgpu/render/TerrainShaderRegistry"; import { TERRITORY_POST_SMOOTHING, TERRITORY_POST_SMOOTHING_KEY, @@ -21,9 +28,12 @@ import { TERRITORY_SHADERS, territoryShaderIdFromInt, territoryShaderIntFromId, + TerritoryShaderOption, } from "../webgpu/render/TerritoryShaderRegistry"; import { Layer } from "./Layer"; +type ShaderOption = TerrainShaderOption | TerritoryShaderOption; + @customElement("webgpu-debug-overlay") export class WebGPUDebugOverlay extends LitElement implements Layer { @property({ type: Object }) @@ -186,6 +196,18 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { this.requestUpdate(); } + private selectedTerrainShaderId() { + const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0); + return terrainShaderIdFromInt(selected); + } + + private setSelectedTerrainShaderId( + id: "classic" | "improved-lite" | "improved-heavy", + ) { + this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id)); + this.requestUpdate(); + } + private selectedPreSmoothingId() { const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0); return territoryPreSmoothingIdFromInt(selected); @@ -212,9 +234,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { this.requestUpdate(); } - private renderOptionControl( - option: (typeof TERRITORY_SHADERS)[number]["options"][number], - ) { + private renderOptionControl(option: ShaderOption) { if (option.kind === "boolean") { const enabled = this.userSettings.get(option.key, option.defaultValue); return html` @@ -289,6 +309,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { const shaderId = this.selectedShaderId(); const shader = TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0]; + const terrainShaderId = this.selectedTerrainShaderId(); + const terrainShader = + TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ?? + TERRAIN_SHADERS[0]; const preId = this.selectedPreSmoothingId(); const pre = TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ?? @@ -315,6 +339,31 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { +
Shaders
+ +
+
Terrain Shader
+ +
+ + ${terrainShader.options.map((opt) => this.renderOptionControl(opt))} + +
Territory
+
Territory Shader
+ ${TERRITORY_RENDERER_OPTIONS.map( + (option) => + html``, + )} + +
+ ${note ? html`
${note}
` : null} + + + `; + } +} diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts index 7b02694ca..1b466d269 100644 --- a/src/client/graphics/layers/TerritoryBackend.ts +++ b/src/client/graphics/layers/TerritoryBackend.ts @@ -2,6 +2,8 @@ import { Layer } from "./Layer"; export type TerritoryRendererId = "classic" | "webgl" | "webgpu"; export type TerritoryRendererPreference = "auto" | TerritoryRendererId; +export const TERRITORY_RENDERER_STATUS_EVENT = + "event:territory-renderer-status"; export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [ "auto", @@ -17,6 +19,13 @@ export interface TerritoryBackend extends Layer { whenReady?: () => Promise; } +export interface TerritoryRendererStatus { + active: TerritoryRendererId | null; + preference: TerritoryRendererPreference; + failedBackends: TerritoryRendererId[]; + message: string | null; +} + export interface TerritoryBackendCandidate { readonly id: TerritoryRendererId; init?: () => void | Promise; diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 60833078b..1f2bf25e9 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -8,8 +8,10 @@ import { import { TransformHandler } from "../TransformHandler"; import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend"; import { + TERRITORY_RENDERER_STATUS_EVENT, TerritoryBackend, TerritoryRendererId, + TerritoryRendererStatus, selectTerritoryBackend, territoryRendererOrder, } from "./TerritoryBackend"; @@ -25,6 +27,7 @@ export class TerritoryLayer implements TerritoryBackend { private initialized = false; private readonly settingsChanged = () => { this.failedBackends.clear(); + this.publishStatus("Retrying renderer selection"); void this.selectConfiguredBackend(); }; @@ -51,7 +54,10 @@ export class TerritoryLayer implements TerritoryBackend { ); // Keep the map visible while accelerated renderers initialize. - this.activateBackend(this.createBackend("classic")); + this.activateBackend( + this.createBackend("classic"), + "Using Classic while accelerated renderer initializes", + ); void this.selectConfiguredBackend(); } @@ -122,6 +128,8 @@ export class TerritoryLayer implements TerritoryBackend { if (selection.backend !== null) { this.activateBackend(selection.backend); + } else { + this.publishStatus("No territory renderer is currently available"); } } @@ -161,7 +169,10 @@ export class TerritoryLayer implements TerritoryBackend { } } - private activateBackend(backend: TerritoryBackend) { + private activateBackend( + backend: TerritoryBackend, + message: string | null = null, + ) { if (this.activeBackend === backend) { return; } @@ -169,6 +180,7 @@ export class TerritoryLayer implements TerritoryBackend { this.activeBackend = backend; previous?.dispose?.(); console.info(`[TerritoryLayer] active renderer: ${backend.id}`); + this.publishStatus(message); } private runActive( @@ -196,6 +208,7 @@ export class TerritoryLayer implements TerritoryBackend { if (backend.id !== "classic") { this.failedBackends.add(backend.id); } + this.publishStatus(`${backend.id} failed: ${reason}`); if (this.activeBackend === backend) { this.activeBackend = null; backend.dispose?.(); @@ -241,4 +254,17 @@ export class TerritoryLayer implements TerritoryBackend { context.fillRect(0, 0, context.canvas.width, context.canvas.height); context.restore(); } + + private publishStatus(message: string | null = null) { + const detail: TerritoryRendererStatus = { + active: this.activeBackend?.id ?? null, + preference: this.userSettings.territoryRenderer(), + failedBackends: Array.from(this.failedBackends), + message, + }; + + globalThis.dispatchEvent?.( + new CustomEvent(TERRITORY_RENDERER_STATUS_EVENT, { detail }), + ); + } } diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index 9aae56bf0..6c9275d57 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -48,7 +48,19 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { @state() private tickComputeMs: number = 0; + @state() + private position: { x: number; y: number } | null = null; + + @state() + private isDragging = false; + private frameTimes: number[] = []; + private dragState: { + pointerId: number; + offsetX: number; + offsetY: number; + } | null = null; + private readonly positionStorageKey = "webgpuDebugOverlay.position.v1"; static styles = css` .overlay { @@ -71,6 +83,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { user-select: none; } + .overlay.dragging { + opacity: 0.72; + } + .title { font-weight: 700; margin-bottom: 8px; @@ -78,6 +94,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { align-items: center; justify-content: space-between; gap: 8px; + cursor: grab; + touch-action: none; + } + + .overlay.dragging .title { + cursor: grabbing; } .metrics { @@ -154,6 +176,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { `; init() { + this.restorePosition(); this.eventBus.on(WebGPUComputeMetricsEvent, (e) => { if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) { this.tickComputeMs = e.computeMs; @@ -163,6 +186,11 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { this.requestUpdate(); } + disconnectedCallback(): void { + super.disconnectedCallback(); + this.endDrag(); + } + updateFrameMetrics(frameDurationMs: number): void { if (!this.userSettings || !this.userSettings.webgpuDebug()) { return; @@ -301,6 +329,118 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { `; } + private restorePosition() { + try { + const raw = localStorage.getItem(this.positionStorageKey); + if (!raw) { + return; + } + const parsed = JSON.parse(raw) as { x: unknown; y: unknown }; + if ( + typeof parsed.x === "number" && + typeof parsed.y === "number" && + Number.isFinite(parsed.x) && + Number.isFinite(parsed.y) + ) { + this.position = this.clampPosition(parsed.x, parsed.y); + } + } catch { + // Keep the default position. + } + } + + private savePosition() { + if (!this.position) { + return; + } + try { + localStorage.setItem( + this.positionStorageKey, + JSON.stringify(this.position), + ); + } catch { + // Position persistence is best-effort. + } + } + + private clampPosition(x: number, y: number) { + const overlay = this.renderRoot.querySelector( + ".overlay", + ) as HTMLElement | null; + const width = overlay?.offsetWidth ?? 340; + const height = overlay?.offsetHeight ?? 420; + const margin = 8; + return { + x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)), + y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)), + }; + } + + private overlayStyle() { + if (!this.position) { + return ""; + } + return `left: ${this.position.x}px; top: ${this.position.y}px;`; + } + + private stopPointerEvent(event: PointerEvent) { + event.stopPropagation(); + } + + private handleDragPointerDown(event: PointerEvent) { + event.preventDefault(); + event.stopPropagation(); + + const overlay = this.renderRoot.querySelector( + ".overlay", + ) as HTMLElement | null; + if (!overlay) { + return; + } + const rect = overlay.getBoundingClientRect(); + this.position = { x: rect.left, y: rect.top }; + this.isDragging = true; + this.dragState = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + + globalThis.addEventListener("pointermove", this.handleDragPointerMove); + globalThis.addEventListener("pointerup", this.handleDragPointerUp); + globalThis.addEventListener("pointercancel", this.handleDragPointerUp); + } + + private readonly handleDragPointerMove = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.position = this.clampPosition( + event.clientX - this.dragState.offsetX, + event.clientY - this.dragState.offsetY, + ); + }; + + private readonly handleDragPointerUp = (event: PointerEvent) => { + if (!this.dragState || event.pointerId !== this.dragState.pointerId) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.savePosition(); + this.endDrag(); + }; + + private endDrag() { + globalThis.removeEventListener("pointermove", this.handleDragPointerMove); + globalThis.removeEventListener("pointerup", this.handleDragPointerUp); + globalThis.removeEventListener("pointercancel", this.handleDragPointerUp); + this.dragState = null; + this.isDragging = false; + } + render() { if (!this.userSettings || !this.userSettings.webgpuDebug()) { return null; @@ -323,8 +463,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { TERRITORY_POST_SMOOTHING[0]; return html` -
-
+
+
WebGPU Debug
From 93378846c8ca46a7aa750bd76471518122a0e0a9 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 26 May 2026 23:11:59 +0200 Subject: [PATCH 22/23] Show matching renderer debug panel --- .../graphics/layers/WebGLTerritoryBackend.ts | 31 ++++++++++++++++--- .../graphics/layers/WebGPUDebugOverlay.ts | 18 ++++++++++- src/core/game/UserSettings.ts | 20 ++++++++++-- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts index e683b2d61..d714566d6 100644 --- a/src/client/graphics/layers/WebGLTerritoryBackend.ts +++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts @@ -5,7 +5,11 @@ import { ColoredTeams, PlayerType, Team } 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 { UserSettings } from "../../../core/game/UserSettings"; +import { + USER_SETTINGS_CHANGED_EVENT, + UserSettings, + WEBGL_DEBUG_KEY, +} from "../../../core/game/UserSettings"; import { AlternateViewEvent, ContextMenuEvent, @@ -25,7 +29,6 @@ const ENABLE_CONTEST_TRACKING = false; const CONTEST_STRENGTH_EMA_ALPHA = 0.8; const CONTEST_STRENGTH_MIN = 0.01; const CONTEST_STRENGTH_MAX = 0.95; -const DEBUG_TERRITORY_OVERLAY = false; type ContestComponent = { id: number; @@ -104,6 +107,7 @@ export class WebGLTerritoryBackend implements TerritoryBackend { event.preventDefault(); this.failureReason = "WebGL context lost."; }; + private readonly debugSettingChanged = () => this.syncSmoothingDebugUi(); constructor( private game: GameView, @@ -409,8 +413,12 @@ export class WebGLTerritoryBackend implements TerritoryBackend { this.hoverHighlightOptions(), ); }); + globalThis.addEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`, + this.debugSettingChanged, + ); this.redraw(); - this.ensureSmoothingDebugUi(); + this.syncSmoothingDebugUi(); } getFailureReason(): string | null { @@ -418,6 +426,10 @@ export class WebGLTerritoryBackend implements TerritoryBackend { } dispose() { + globalThis.removeEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`, + this.debugSettingChanged, + ); this.smoothingDebugUi?.remove(); this.smoothingDebugUi = null; this.territoryRenderer?.canvas.removeEventListener( @@ -428,8 +440,17 @@ export class WebGLTerritoryBackend implements TerritoryBackend { this.territoryRenderer = null; } + private syncSmoothingDebugUi() { + if (!this.userSettings.webglDebug()) { + this.smoothingDebugUi?.remove(); + this.smoothingDebugUi = null; + return; + } + this.ensureSmoothingDebugUi(); + } + private ensureSmoothingDebugUi() { - if (!DEBUG_TERRITORY_OVERLAY) return; + if (!this.userSettings.webglDebug()) return; if (this.smoothingDebugUi) return; const root = document.createElement("div"); @@ -1001,7 +1022,7 @@ export class WebGLTerritoryBackend implements TerritoryBackend { ); } - if (DEBUG_TERRITORY_OVERLAY) { + if (this.userSettings.webglDebug()) { const overlayStart = FrameProfiler.start(); this.drawDebugOverlay(context); FrameProfiler.end("TerritoryLayer:debugOverlay", overlayStart); diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index 6c9275d57..961cd61ce 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -2,7 +2,11 @@ import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { live } from "lit/directives/live.js"; import { EventBus } from "../../../core/EventBus"; -import { UserSettings } from "../../../core/game/UserSettings"; +import { + USER_SETTINGS_CHANGED_EVENT, + UserSettings, + WEBGPU_DEBUG_KEY, +} from "../../../core/game/UserSettings"; import { WebGPUComputeMetricsEvent } from "../../InputHandler"; import { TERRAIN_SHADER_KEY, @@ -177,6 +181,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { init() { this.restorePosition(); + globalThis.addEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`, + this.handleDebugSettingChanged, + ); this.eventBus.on(WebGPUComputeMetricsEvent, (e) => { if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) { this.tickComputeMs = e.computeMs; @@ -189,8 +197,16 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { disconnectedCallback(): void { super.disconnectedCallback(); this.endDrag(); + globalThis.removeEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`, + this.handleDebugSettingChanged, + ); } + private readonly handleDebugSettingChanged = () => { + this.requestUpdate(); + }; + updateFrameMetrics(frameDurationMs: number): void { if (!this.userSettings || !this.userSettings.webgpuDebug()) { return; diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 2454176fc..35b7b3abf 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -48,6 +48,8 @@ export const DARK_MODE_KEY = "settings.darkMode"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; export const KEYBINDS_KEY = "settings.keybinds"; export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer"; +export const WEBGL_DEBUG_KEY = "settings.webglDebug"; +export const WEBGPU_DEBUG_KEY = "settings.webgpuDebug"; export type TerritoryRendererPreference = | "auto" | "classic" @@ -160,7 +162,19 @@ export class UserSettings { } webgpuDebug(): boolean { - return this.get("settings.webgpuDebug", false); + return this.get(WEBGPU_DEBUG_KEY, false); + } + + webglDebug(): boolean { + return this.get(WEBGL_DEBUG_KEY, false); + } + + setWebgpuDebug(value: boolean): void { + this.set(WEBGPU_DEBUG_KEY, value); + } + + setWebglDebug(value: boolean): void { + this.set(WEBGL_DEBUG_KEY, value); } alertFrame() { @@ -221,6 +235,8 @@ export class UserSettings { value === "classic" || value === "webgl" || value === "webgpu" ? value : "auto"; + this.setWebglDebug(renderer === "webgl"); + this.setWebgpuDebug(renderer === "webgpu"); this.setString(TERRITORY_RENDERER_KEY, renderer); } @@ -254,7 +270,7 @@ export class UserSettings { } toggleWebgpuDebug() { - this.set("settings.webgpuDebug", !this.webgpuDebug()); + this.setWebgpuDebug(!this.webgpuDebug()); } toggleAlertFrame() { From 6ac8d4d1017fe58c0a8ff3da5efdabcb620755a5 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 27 May 2026 02:15:59 +0200 Subject: [PATCH 23/23] Show fallout in WebGL alt view --- src/client/graphics/layers/TerritoryWebGLRenderer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index f506f45aa..002772b30 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -3357,6 +3357,9 @@ export class TerritoryWebGLRenderer { if (u_alternativeView) { // Alt view: terrain + borders only, no territory fill vec3 color = baseTerrainColor; + if (owner == 0u && hasFallout) { + color = mix(baseTerrainColor, u_fallout.rgb, u_alpha); + } if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) { // Only draw borders, not territory fill uint relationAlt = relationCode(owner, uint(u_viewerId));