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);
}