mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
refactor: restructure WebGPU territory renderer into extensible pass-based architecture
Refactor the monolithic TerritoryWebGLRenderer into a modular, extensible architecture that separates ground truth computation from rendering passes. This change also includes related improvements to game state management and hover information handling. WebGPU Architecture Refactor: - Extract all shaders to external .wgsl files (no inlined shaders) - Separate ground truth data management (GroundTruthData) from rendering - Create pass-based architecture with ComputePass and RenderPass interfaces - Implement compute passes: StateUpdatePass, DefendedClearPass, DefendedUpdatePass - Implement render pass: TerritoryRenderPass - Add TerritoryRenderer orchestrator with dependency-based execution ordering - Add WebGPUDevice for device initialization and management - Add ShaderLoader utility for loading .wgsl files via Vite ?raw imports Performance Optimizations: - Dependency order computed once at init (topological sort) - Early exit checks at orchestrator and pass levels - Bind groups rebuilt when textures/buffers are recreated - Zero per-frame allocations (reuse command encoders and staging buffers) Architecture Benefits: - Easy to extend with new compute/render passes (borders, temporal smoothing, etc.) - Clear separation between tick-based compute and frame-based rendering - All shaders in external files for better maintainability - Ground truth data computed once and reused by all passes Related Changes: - Add defended tile state support to GameMap (isDefended/setDefended) - Expose tileStateView() for direct GPU state access - Extract hover info logic to HoverInfo utility - Remove TerrainLayer (terrain now rendered by WebGPU territory pass) - Update GameRenderer to use transparent overlay canvas - Add viewOffset() method to TransformHandler Files: - Deleted: TerritoryWebGLRenderer.ts (1217 lines), TerrainLayer.ts (77 lines) - Added: 17 new files in webgpu/ directory structure - Updated: TerritoryLayer.ts, GameRenderer.ts, PlayerInfoOverlay.ts, GameMap.ts, GameView.ts, GameImpl.ts, TransformHandler.ts, vite-env.d.ts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
<div
|
||||
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
|
||||
>
|
||||
${this.isWilderness || this.isIrradiatedWilderness
|
||||
? html`<div class="p-2 font-bold">
|
||||
${translateText(
|
||||
this.isIrradiatedWilderness
|
||||
? "player_info_overlay.irradiated_wilderness_title"
|
||||
: "player_info_overlay.wilderness_title",
|
||||
)}
|
||||
</div>`
|
||||
: ""}
|
||||
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
||||
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
||||
</div>
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> | 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<void> {
|
||||
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<T extends { name: string; dependencies: string[] }>(
|
||||
passes: T[],
|
||||
): T[] {
|
||||
const passMap = new Map<string, T>();
|
||||
for (const pass of passes) {
|
||||
passMap.set(pass.name, pass);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<number> = new Set();
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, Promise<string>>();
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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<WebGPUDevice | null> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
@group(0) @binding(0) var defendedTex: texture_storage_2d<r32uint, write>;
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
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));
|
||||
}
|
||||
@@ -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<uniform> d: DefenseParams;
|
||||
@group(0) @binding(1) var<storage, read> posts: array<DefensePost>;
|
||||
@group(0) @binding(2) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(3) var defendedTex: texture_storage_2d<r32uint, write>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
struct Update {
|
||||
tileIndex: u32,
|
||||
newState: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<storage, read> updates: array<Update>;
|
||||
@group(0) @binding(1) var stateTex: texture_storage_2d<r32uint, write>;
|
||||
|
||||
@compute @workgroup_size(1)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
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));
|
||||
}
|
||||
@@ -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<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var<uniform> d: DefenseParams;
|
||||
@group(0) @binding(2) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(3) var defendedTex: texture_2d<u32>;
|
||||
@group(0) @binding(4) var paletteTex: texture_2d<f32>;
|
||||
@group(0) @binding(5) var terrainTex: texture_2d<f32>;
|
||||
|
||||
@vertex
|
||||
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||||
var pos = array<vec2f, 3>(
|
||||
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;
|
||||
}
|
||||
Vendored
+10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user