mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:20:43 +00:00
Merge renderer fallback branch
This commit is contained in:
+1
-1
@@ -77,7 +77,7 @@ ENV GIT_COMMIT="$GIT_COMMIT"
|
||||
RUN <<'EOF' tee /usr/local/bin/start.sh
|
||||
#!/bin/sh
|
||||
if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then
|
||||
exec timeout 25h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
exec timeout 720h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
else
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
fi
|
||||
|
||||
@@ -343,7 +343,9 @@
|
||||
<chat-modal></chat-modal>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<renderer-status-panel></renderer-status-panel>
|
||||
<performance-overlay></performance-overlay>
|
||||
<webgpu-debug-overlay></webgpu-debug-overlay>
|
||||
<player-info-overlay></player-info-overlay>
|
||||
<leader-board></leader-board>
|
||||
<team-stats></team-stats>
|
||||
|
||||
@@ -703,6 +703,10 @@
|
||||
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
|
||||
"attacking_troops_overlay_label": "Attacking Troops Overlay",
|
||||
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
|
||||
"territory_border_mode_label": "Territory Borders",
|
||||
"territory_border_mode_desc": "Select border rendering style (visual only)",
|
||||
"renderer_label": "Renderer",
|
||||
"renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.",
|
||||
"performance_overlay_label": "Performance Overlay",
|
||||
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
|
||||
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||
|
||||
@@ -193,6 +193,10 @@ export class TickMetricsEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class WebGPUComputeMetricsEvent implements GameEvent {
|
||||
constructor(public readonly computeMs: number) {}
|
||||
}
|
||||
|
||||
export class InputHandler {
|
||||
private lastPointerX: number = 0;
|
||||
private lastPointerY: number = 0;
|
||||
|
||||
@@ -300,6 +300,24 @@ export class UserSettingModal extends BaseModal {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private changeTerritoryBorderMode(
|
||||
e: CustomEvent<{ value: number | string }>,
|
||||
) {
|
||||
const rawValue = e.detail?.value;
|
||||
const value =
|
||||
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
|
||||
if (!Number.isFinite(value)) return;
|
||||
|
||||
this.userSettings.setInt("settings.territoryBorderMode", Math.round(value));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) {
|
||||
const value = String(e.detail?.value ?? "auto");
|
||||
this.userSettings.setTerritoryRenderer(value);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private toggleTerritoryPatterns() {
|
||||
this.userSettings.toggleTerritoryPatterns();
|
||||
|
||||
@@ -752,6 +770,35 @@ export class UserSettingModal extends BaseModal {
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 😊 Emojis -->
|
||||
<setting-select
|
||||
label="${translateText("user_setting.territory_border_mode_label")}"
|
||||
description="${translateText(
|
||||
"user_setting.territory_border_mode_desc",
|
||||
)}"
|
||||
id="territory-border-mode-select"
|
||||
.value=${String(this.userSettings.territoryBorderMode())}
|
||||
.options=${[
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 1, label: "Simple" },
|
||||
{ value: 2, label: "Glow" },
|
||||
]}
|
||||
@change=${this.changeTerritoryBorderMode}
|
||||
></setting-select>
|
||||
|
||||
<setting-select
|
||||
label="${translateText("user_setting.renderer_label")}"
|
||||
description="${translateText("user_setting.renderer_desc")}"
|
||||
id="territory-renderer-select"
|
||||
.value=${this.userSettings.territoryRenderer()}
|
||||
.options=${[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "classic", label: "Classic" },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "webgpu", label: "WebGPU" },
|
||||
]}
|
||||
@change=${this.changeTerritoryRenderer}
|
||||
></setting-select>
|
||||
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.emojis_label")}"
|
||||
description="${translateText("user_setting.emojis_desc")}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
type SelectOption = {
|
||||
export type SettingSelectOption = {
|
||||
value: number | string;
|
||||
label: string;
|
||||
};
|
||||
@@ -10,8 +10,10 @@ type SelectOption = {
|
||||
export class SettingSelect extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Array }) options: SelectOption[] = [];
|
||||
@property() id = "setting-select-input";
|
||||
@property({ type: Array }) options: SettingSelectOption[] = [];
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -35,14 +37,18 @@ export class SettingSelect extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const rainbowClass = this.easter
|
||||
? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]"
|
||||
: "";
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3"
|
||||
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3 ${rainbowClass}"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<label
|
||||
class="text-white font-bold text-base block mb-1"
|
||||
for="setting-select-input"
|
||||
for=${this.id}
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="text-white/50 text-sm leading-snug">
|
||||
@@ -51,7 +57,7 @@ export class SettingSelect extends LitElement {
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="setting-select-input"
|
||||
id=${this.id}
|
||||
class="w-full appearance-none py-2 pl-3 pr-9 border border-white/20 rounded-lg bg-black/40 text-white font-mono text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
.value=${String(this.value)}
|
||||
@change=${this.handleChange}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PerformanceOverlay } from "./layers/PerformanceOverlay";
|
||||
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { RendererStatusPanel } from "./layers/RendererStatusPanel";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
@@ -40,11 +41,11 @@ 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";
|
||||
import { UnitLayer } from "./layers/UnitLayer";
|
||||
import { WebGPUDebugOverlay } from "./layers/WebGPUDebugOverlay";
|
||||
import { WinModal } from "./layers/WinModal";
|
||||
|
||||
export function createRenderer(
|
||||
@@ -242,6 +243,25 @@ export function createRenderer(
|
||||
performanceOverlay.eventBus = eventBus;
|
||||
performanceOverlay.userSettings = userSettings;
|
||||
|
||||
const webgpuDebugOverlay = document.querySelector(
|
||||
"webgpu-debug-overlay",
|
||||
) as WebGPUDebugOverlay;
|
||||
if (!(webgpuDebugOverlay instanceof WebGPUDebugOverlay)) {
|
||||
console.error("webgpu debug overlay not found");
|
||||
}
|
||||
webgpuDebugOverlay.eventBus = eventBus;
|
||||
webgpuDebugOverlay.userSettings = userSettings;
|
||||
webgpuDebugOverlay.requestUpdate();
|
||||
|
||||
const rendererStatusPanel = document.querySelector(
|
||||
"renderer-status-panel",
|
||||
) as RendererStatusPanel;
|
||||
if (!(rendererStatusPanel instanceof RendererStatusPanel)) {
|
||||
console.error("renderer status panel not found");
|
||||
}
|
||||
rendererStatusPanel.userSettings = userSettings;
|
||||
rendererStatusPanel.requestUpdate();
|
||||
|
||||
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
|
||||
if (!(alertFrame instanceof AlertFrame)) {
|
||||
console.error("alert frame not found");
|
||||
@@ -275,8 +295,8 @@ 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),
|
||||
rendererStatusPanel,
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, eventBus, transformHandler, uiState),
|
||||
new CoordinateGridLayer(game, eventBus, transformHandler),
|
||||
structureLayer,
|
||||
@@ -320,6 +340,7 @@ export function createRenderer(
|
||||
inGamePromo,
|
||||
alertFrame,
|
||||
performanceOverlay,
|
||||
webgpuDebugOverlay,
|
||||
];
|
||||
|
||||
return new GameRenderer(
|
||||
@@ -330,6 +351,7 @@ export function createRenderer(
|
||||
uiState,
|
||||
layers,
|
||||
performanceOverlay,
|
||||
webgpuDebugOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,8 +369,10 @@ export class GameRenderer {
|
||||
public uiState: UIState,
|
||||
private layers: Layer[],
|
||||
private performanceOverlay: PerformanceOverlay,
|
||||
private webgpuDebugOverlay: WebGPUDebugOverlay,
|
||||
) {
|
||||
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 +423,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,
|
||||
@@ -457,6 +476,7 @@ export class GameRenderer {
|
||||
}
|
||||
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
||||
}
|
||||
this.webgpuDebugOverlay.updateFrameMetrics(duration);
|
||||
|
||||
if (duration > 50) {
|
||||
console.warn(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
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 { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class ClassicCanvasTerritoryLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private imageData: ImageData;
|
||||
private alternativeImageData: ImageData;
|
||||
private borderAnimTime = 0;
|
||||
|
||||
private cachedTerritoryPatternsEnabled: boolean | undefined;
|
||||
|
||||
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 alternativeView = false;
|
||||
private lastDragTime = 0;
|
||||
private nodrawDragDuration = 200;
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
|
||||
private refreshRate = 10; //refresh every 10ms
|
||||
private lastRefresh = 0;
|
||||
|
||||
private lastFocusedPlayer: PlayerView | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
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.eventBus.on(DragEvent, (e) => {
|
||||
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
|
||||
// this.lastDragTime = Date.now();
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
onMouseOver(event: MouseOverEvent) {
|
||||
this.lastMousePosition = { x: event.x, y: event.y };
|
||||
this.updateHighlightedTerritory();
|
||||
}
|
||||
|
||||
private updateHighlightedTerritory() {
|
||||
if (!this.alternativeView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.lastMousePosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
redrawBorder(...players: PlayerView[]) {
|
||||
return Promise.all(
|
||||
players.map(async (player) => {
|
||||
const tiles = await player.borderTiles();
|
||||
tiles.borderTiles.forEach((tile: TileRef) => {
|
||||
this.paintTerritory(tile, true);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
|
||||
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const ctx = this.highlightContext;
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw a semi-transparent ring around the starting location
|
||||
ctx.beginPath();
|
||||
// Transparency matches the highlight color provided
|
||||
const transparent = transparentColor.alpha(0);
|
||||
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
|
||||
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad.addColorStop(0, transparent.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad.addColorStop(0.01, transparentColor.toRgbString());
|
||||
radGrad.addColorStop(0.1, transparentColor.toRgbString());
|
||||
// The outer edge of the ring is transparent
|
||||
radGrad.addColorStop(1, transparent.toRgbString());
|
||||
|
||||
// Draw an arc at the max radius and fill with the created radial gradient
|
||||
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = radGrad;
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const breatheInner = breathingColor.alpha(0);
|
||||
// Draw a solid ring around the starting location with outer radius = the breathing radius
|
||||
ctx.beginPath();
|
||||
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad2.addColorStop(0, breatheInner.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad2.addColorStop(0.01, breathingColor.toRgbString());
|
||||
// The ring is solid throughout
|
||||
radGrad2.addColorStop(1, breathingColor.toRgbString());
|
||||
|
||||
// Draw an arc at the current breathing radius and fill with the created "gradient"
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = radGrad2;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { ClassicCanvasTerritoryLayer } from "./ClassicCanvasTerritoryLayer";
|
||||
import { TerrainLayer } from "./TerrainLayer";
|
||||
import { TerritoryBackend } from "./TerritoryBackend";
|
||||
|
||||
export class ClassicTerritoryBackend implements TerritoryBackend {
|
||||
readonly id = "classic";
|
||||
|
||||
private readonly terrainLayer: TerrainLayer;
|
||||
private readonly territoryLayer: ClassicCanvasTerritoryLayer;
|
||||
|
||||
constructor(
|
||||
game: GameView,
|
||||
eventBus: EventBus,
|
||||
transformHandler: TransformHandler,
|
||||
) {
|
||||
this.terrainLayer = new TerrainLayer(game, transformHandler);
|
||||
this.territoryLayer = new ClassicCanvasTerritoryLayer(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
);
|
||||
}
|
||||
|
||||
profileName(): string {
|
||||
return "ClassicTerritoryBackend:renderLayer";
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.terrainLayer.init?.();
|
||||
this.territoryLayer.init?.();
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.terrainLayer.tick?.();
|
||||
this.territoryLayer.tick?.();
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.terrainLayer.redraw?.();
|
||||
this.territoryLayer.redraw?.();
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
this.terrainLayer.renderLayer?.(context);
|
||||
this.territoryLayer.renderLayer?.(context);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Classic layers own only offscreen canvases and event-bus listeners.
|
||||
// The event bus does not currently expose unsubscribe hooks.
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
TERRITORY_RENDERER_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
} from "../../../core/game/UserSettings";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
TERRITORY_RENDERER_OPTIONS,
|
||||
TERRITORY_RENDERER_STATUS_EVENT,
|
||||
TerritoryRendererId,
|
||||
TerritoryRendererPreference,
|
||||
TerritoryRendererStatus,
|
||||
} from "./TerritoryBackend";
|
||||
|
||||
@customElement("renderer-status-panel")
|
||||
export class RendererStatusPanel extends LitElement implements Layer {
|
||||
@property({ type: Object })
|
||||
public userSettings!: UserSettings;
|
||||
|
||||
@state()
|
||||
private activeRenderer: TerritoryRendererId | null = null;
|
||||
|
||||
@state()
|
||||
private preference: TerritoryRendererPreference = "auto";
|
||||
|
||||
@state()
|
||||
private failedBackends: TerritoryRendererId[] = [];
|
||||
|
||||
@state()
|
||||
private message: string | null = null;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } | null = null;
|
||||
|
||||
@state()
|
||||
private isDragging = false;
|
||||
|
||||
private dragState: {
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} | null = null;
|
||||
|
||||
private readonly positionStorageKey = "rendererStatusPanel.position.v1";
|
||||
|
||||
static styles = css`
|
||||
.panel {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9998;
|
||||
width: min(280px, calc(100vw - 32px));
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 16, 20, 0.86);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.panel.dragging {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 6px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.panel.dragging .title {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgb(67, 214, 142);
|
||||
box-shadow: 0 0 0 3px rgba(67, 214, 142, 0.16);
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
padding: 5px 7px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`;
|
||||
|
||||
init() {
|
||||
this.preference = this.userSettings.territoryRenderer();
|
||||
this.restorePosition();
|
||||
globalThis.addEventListener(
|
||||
TERRITORY_RENDERER_STATUS_EVENT,
|
||||
this.handleRendererStatus,
|
||||
);
|
||||
globalThis.addEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.handlePreferenceChanged,
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.endDrag();
|
||||
globalThis.removeEventListener(
|
||||
TERRITORY_RENDERER_STATUS_EVENT,
|
||||
this.handleRendererStatus,
|
||||
);
|
||||
globalThis.removeEventListener(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.handlePreferenceChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleRendererStatus = (event: Event) => {
|
||||
const detail = (event as CustomEvent<TerritoryRendererStatus>).detail;
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRenderer = detail.active;
|
||||
this.preference = detail.preference;
|
||||
this.failedBackends = detail.failedBackends;
|
||||
this.message = detail.message;
|
||||
};
|
||||
|
||||
private readonly handlePreferenceChanged = () => {
|
||||
if (!this.userSettings) {
|
||||
return;
|
||||
}
|
||||
this.preference = this.userSettings.territoryRenderer();
|
||||
this.message = null;
|
||||
};
|
||||
|
||||
private changeRenderer(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.userSettings.setTerritoryRenderer(value);
|
||||
this.preference = this.userSettings.territoryRenderer();
|
||||
}
|
||||
|
||||
private rendererLabel(id: TerritoryRendererId | TerritoryRendererPreference) {
|
||||
if (id === "webgpu") return "WebGPU";
|
||||
if (id === "webgl") return "WebGL";
|
||||
if (id === "classic") return "Classic";
|
||||
return "Auto";
|
||||
}
|
||||
|
||||
private statusNote() {
|
||||
if (this.failedBackends.length > 0) {
|
||||
return `Skipped this cycle: ${this.failedBackends
|
||||
.map((id) => this.rendererLabel(id))
|
||||
.join(", ")}`;
|
||||
}
|
||||
if (
|
||||
this.activeRenderer &&
|
||||
this.preference !== "auto" &&
|
||||
this.activeRenderer !== this.preference
|
||||
) {
|
||||
return `Fallback from ${this.rendererLabel(this.preference)}`;
|
||||
}
|
||||
return this.message;
|
||||
}
|
||||
|
||||
private restorePosition() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.positionStorageKey);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { x: unknown; y: unknown };
|
||||
if (
|
||||
typeof parsed.x === "number" &&
|
||||
typeof parsed.y === "number" &&
|
||||
Number.isFinite(parsed.x) &&
|
||||
Number.isFinite(parsed.y)
|
||||
) {
|
||||
this.position = this.clampPosition(parsed.x, parsed.y);
|
||||
}
|
||||
} catch {
|
||||
// Keep the default docked position.
|
||||
}
|
||||
}
|
||||
|
||||
private savePosition() {
|
||||
if (!this.position) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
this.positionStorageKey,
|
||||
JSON.stringify(this.position),
|
||||
);
|
||||
} catch {
|
||||
// Position persistence is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
private clampPosition(x: number, y: number) {
|
||||
const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null;
|
||||
const width = panel?.offsetWidth ?? 280;
|
||||
const height = panel?.offsetHeight ?? 120;
|
||||
const margin = 8;
|
||||
return {
|
||||
x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)),
|
||||
y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)),
|
||||
};
|
||||
}
|
||||
|
||||
private panelStyle() {
|
||||
if (!this.position) {
|
||||
return "";
|
||||
}
|
||||
return `left: ${this.position.x}px; top: ${this.position.y}px; bottom: auto;`;
|
||||
}
|
||||
|
||||
private stopPointerEvent(event: PointerEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleDragPointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const panel = this.renderRoot.querySelector(".panel") as HTMLElement | null;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const rect = panel.getBoundingClientRect();
|
||||
this.position = { x: rect.left, y: rect.top };
|
||||
this.isDragging = true;
|
||||
this.dragState = {
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - rect.left,
|
||||
offsetY: event.clientY - rect.top,
|
||||
};
|
||||
|
||||
globalThis.addEventListener("pointermove", this.handleDragPointerMove);
|
||||
globalThis.addEventListener("pointerup", this.handleDragPointerUp);
|
||||
globalThis.addEventListener("pointercancel", this.handleDragPointerUp);
|
||||
}
|
||||
|
||||
private readonly handleDragPointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.position = this.clampPosition(
|
||||
event.clientX - this.dragState.offsetX,
|
||||
event.clientY - this.dragState.offsetY,
|
||||
);
|
||||
};
|
||||
|
||||
private readonly handleDragPointerUp = (event: PointerEvent) => {
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.savePosition();
|
||||
this.endDrag();
|
||||
};
|
||||
|
||||
private endDrag() {
|
||||
globalThis.removeEventListener("pointermove", this.handleDragPointerMove);
|
||||
globalThis.removeEventListener("pointerup", this.handleDragPointerUp);
|
||||
globalThis.removeEventListener("pointercancel", this.handleDragPointerUp);
|
||||
this.dragState = null;
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.userSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const note = this.statusNote();
|
||||
return html`
|
||||
<div
|
||||
class="panel ${this.isDragging ? "dragging" : ""}"
|
||||
style=${this.panelStyle()}
|
||||
@pointerdown=${this.stopPointerEvent}
|
||||
>
|
||||
<div class="title" @pointerdown=${this.handleDragPointerDown}>
|
||||
<span>Renderer</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="row">
|
||||
<div class="label">Active</div>
|
||||
<div class="value active">
|
||||
<span class="dot"></span>
|
||||
${this.activeRenderer
|
||||
? this.rendererLabel(this.activeRenderer)
|
||||
: "Pending"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="label" for="renderer-select">Saved</label>
|
||||
<select
|
||||
id="renderer-select"
|
||||
.value=${this.preference}
|
||||
@change=${this.changeRenderer}
|
||||
>
|
||||
${TERRITORY_RENDERER_OPTIONS.map(
|
||||
(option) =>
|
||||
html`<option value=${option}>
|
||||
${this.rendererLabel(option)}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
${note ? html`<div class="note">${note}</div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleWebgpuDebugOverlayButtonClick() {
|
||||
this.userSettings.toggleWebgpuDebug();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onExitButtonClick() {
|
||||
// redirect to the home page
|
||||
window.location.href = "/";
|
||||
@@ -526,6 +531,29 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||
@click="${this.onToggleWebgpuDebugOverlayButtonClick}"
|
||||
>
|
||||
<img
|
||||
src=${settingsIcon}
|
||||
alt="webgpuDebugIcon"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">WebGPU Debug</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
Territory shader selection + options
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.webgpuDebug()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="border-t border-slate-600 pt-3 mt-4">
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded-sm text-red-400 transition-colors"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
|
||||
export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
|
||||
export const TERRITORY_RENDERER_STATUS_EVENT =
|
||||
"event:territory-renderer-status";
|
||||
|
||||
export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [
|
||||
"auto",
|
||||
"classic",
|
||||
"webgl",
|
||||
"webgpu",
|
||||
];
|
||||
|
||||
export interface TerritoryBackend extends Layer {
|
||||
readonly id: TerritoryRendererId;
|
||||
dispose?: () => void;
|
||||
getFailureReason?: () => string | null;
|
||||
whenReady?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TerritoryRendererStatus {
|
||||
active: TerritoryRendererId | null;
|
||||
preference: TerritoryRendererPreference;
|
||||
failedBackends: TerritoryRendererId[];
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface TerritoryBackendCandidate {
|
||||
readonly id: TerritoryRendererId;
|
||||
init?: () => void | Promise<void>;
|
||||
dispose?: () => void;
|
||||
getFailureReason?: () => string | null;
|
||||
whenReady?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TerritoryBackendSelectionFailure {
|
||||
id: TerritoryRendererId;
|
||||
reason: string;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface TerritoryBackendSelection<
|
||||
T extends TerritoryBackendCandidate,
|
||||
> {
|
||||
backend: T | null;
|
||||
failures: TerritoryBackendSelectionFailure[];
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export function normalizeTerritoryRendererPreference(
|
||||
value: string | null | undefined,
|
||||
): TerritoryRendererPreference {
|
||||
if (
|
||||
value === "classic" ||
|
||||
value === "webgl" ||
|
||||
value === "webgpu" ||
|
||||
value === "auto"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function territoryRendererOrder(
|
||||
preference: TerritoryRendererPreference,
|
||||
failedBackends: ReadonlySet<TerritoryRendererId> = new Set(),
|
||||
): TerritoryRendererId[] {
|
||||
const preferredOrder: TerritoryRendererId[] =
|
||||
preference === "classic"
|
||||
? ["classic"]
|
||||
: preference === "webgl"
|
||||
? ["webgl", "classic"]
|
||||
: ["webgpu", "webgl", "classic"];
|
||||
|
||||
return preferredOrder.filter(
|
||||
(id) => id === "classic" || !failedBackends.has(id),
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectTerritoryBackend<
|
||||
T extends TerritoryBackendCandidate,
|
||||
>(
|
||||
preference: TerritoryRendererPreference,
|
||||
failedBackends: ReadonlySet<TerritoryRendererId>,
|
||||
createBackend: (id: TerritoryRendererId) => T,
|
||||
shouldContinue: () => boolean = () => true,
|
||||
): Promise<TerritoryBackendSelection<T>> {
|
||||
const failures: TerritoryBackendSelectionFailure[] = [];
|
||||
|
||||
for (const id of territoryRendererOrder(preference, failedBackends)) {
|
||||
if (!shouldContinue()) {
|
||||
return { backend: null, failures, cancelled: true };
|
||||
}
|
||||
|
||||
const backend = createBackend(id);
|
||||
try {
|
||||
await backend.init?.();
|
||||
|
||||
if (!shouldContinue()) {
|
||||
backend.dispose?.();
|
||||
return { backend: null, failures, cancelled: true };
|
||||
}
|
||||
|
||||
let reason = backend.getFailureReason?.() ?? null;
|
||||
if (reason !== null) {
|
||||
backend.dispose?.();
|
||||
failures.push({ id, reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (backend.whenReady) {
|
||||
const ready = await backend.whenReady();
|
||||
|
||||
if (!shouldContinue()) {
|
||||
backend.dispose?.();
|
||||
return { backend: null, failures, cancelled: true };
|
||||
}
|
||||
|
||||
reason = backend.getFailureReason?.() ?? null;
|
||||
if (!ready || reason !== null) {
|
||||
backend.dispose?.();
|
||||
failures.push({
|
||||
id,
|
||||
reason: reason ?? "initialization failed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { backend, failures, cancelled: false };
|
||||
} catch (error) {
|
||||
backend.dispose?.();
|
||||
failures.push({
|
||||
id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { backend: null, failures, cancelled: false };
|
||||
}
|
||||
@@ -1,709 +1,270 @@
|
||||
import { PriorityQueue } from "@datastructures-js/priority-queue";
|
||||
import { Colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
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 { FrameProfiler } from "../FrameProfiler";
|
||||
TERRITORY_RENDERER_KEY,
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
} from "../../../core/game/UserSettings";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
|
||||
import {
|
||||
TERRITORY_RENDERER_STATUS_EVENT,
|
||||
TerritoryBackend,
|
||||
TerritoryRendererId,
|
||||
TerritoryRendererStatus,
|
||||
selectTerritoryBackend,
|
||||
territoryRendererOrder,
|
||||
} from "./TerritoryBackend";
|
||||
import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend";
|
||||
import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend";
|
||||
|
||||
export class TerritoryLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private imageData: ImageData;
|
||||
private alternativeImageData: ImageData;
|
||||
private borderAnimTime = 0;
|
||||
export class TerritoryLayer implements TerritoryBackend {
|
||||
readonly id = "classic";
|
||||
|
||||
private cachedTerritoryPatternsEnabled: boolean | undefined;
|
||||
|
||||
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 alternativeView = false;
|
||||
private lastDragTime = 0;
|
||||
private nodrawDragDuration = 200;
|
||||
private lastMousePosition: { x: number; y: number } | null = null;
|
||||
|
||||
private refreshRate = 10; //refresh every 10ms
|
||||
private lastRefresh = 0;
|
||||
|
||||
private lastFocusedPlayer: PlayerView | null = null;
|
||||
private activeBackend: TerritoryBackend | null = null;
|
||||
private failedBackends = new Set<TerritoryRendererId>();
|
||||
private selectionToken = 0;
|
||||
private initialized = false;
|
||||
private readonly settingsChanged = () => {
|
||||
this.failedBackends.clear();
|
||||
this.publishStatus("Retrying renderer selection");
|
||||
void this.selectConfiguredBackend();
|
||||
};
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
this.cachedTerritoryPatternsEnabled = undefined;
|
||||
private userSettings: UserSettings,
|
||||
) {}
|
||||
|
||||
profileName(): string {
|
||||
return "TerritoryLayer:renderLayer";
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
init() {
|
||||
this.initialized = true;
|
||||
globalThis.addEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.settingsChanged,
|
||||
);
|
||||
|
||||
// Keep the map visible while accelerated renderers initialize.
|
||||
this.activateBackend(
|
||||
this.createBackend("classic"),
|
||||
"Using Classic while accelerated renderer initializes",
|
||||
);
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
|
||||
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.eventBus.on(DragEvent, (e) => {
|
||||
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
|
||||
// this.lastDragTime = Date.now();
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
onMouseOver(event: MouseOverEvent) {
|
||||
this.lastMousePosition = { x: event.x, y: event.y };
|
||||
this.updateHighlightedTerritory();
|
||||
}
|
||||
|
||||
private updateHighlightedTerritory() {
|
||||
if (!this.alternativeView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.lastMousePosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
);
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
this.runActive("tick", (backend) => backend.tick?.());
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
redrawBorder(...players: PlayerView[]) {
|
||||
return Promise.all(
|
||||
players.map(async (player) => {
|
||||
const tiles = await player.borderTiles();
|
||||
tiles.borderTiles.forEach((tile: TileRef) => {
|
||||
this.paintTerritory(tile, true);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.runActive("redraw", (backend) => backend.redraw?.());
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
|
||||
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.activeBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
if (this.activeBackend.id !== "webgpu") {
|
||||
this.fillBackground(context);
|
||||
}
|
||||
|
||||
this.runActive("renderLayer", (backend) => backend.renderLayer?.(context));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
globalThis.removeEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
|
||||
this.settingsChanged,
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
this.activeBackend?.dispose?.();
|
||||
this.activeBackend = null;
|
||||
}
|
||||
|
||||
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 async selectConfiguredBackend() {
|
||||
const token = ++this.selectionToken;
|
||||
const preference = this.userSettings.territoryRenderer();
|
||||
const order = territoryRendererOrder(preference, this.failedBackends);
|
||||
if (
|
||||
this.activeBackend?.id === order[0] &&
|
||||
!this.activeBackend.getFailureReason?.()
|
||||
) {
|
||||
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;
|
||||
}
|
||||
this.clearTile(tile);
|
||||
const selection = await selectTerritoryBackend(
|
||||
preference,
|
||||
this.failedBackends,
|
||||
(id) => this.createBackend(id),
|
||||
() => token === this.selectionToken,
|
||||
);
|
||||
|
||||
if (selection.cancelled) {
|
||||
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);
|
||||
for (const failure of selection.failures) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`,
|
||||
failure.error ?? "",
|
||||
);
|
||||
if (failure.id !== "classic") {
|
||||
this.failedBackends.add(failure.id);
|
||||
}
|
||||
const isDefended = this.game.hasUnitNearby(
|
||||
tile,
|
||||
this.game.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
}
|
||||
|
||||
this.paintTile(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.borderColor(tile, isDefended),
|
||||
255,
|
||||
);
|
||||
if (selection.backend !== null) {
|
||||
this.activateBackend(selection.backend);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
|
||||
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
|
||||
this.publishStatus("No territory renderer is currently available");
|
||||
}
|
||||
}
|
||||
|
||||
alternateViewColor(other: PlayerView): Colord {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) {
|
||||
return this.theme.neutralColor();
|
||||
private async initializeCandidate(
|
||||
backend: TerritoryBackend,
|
||||
token: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await backend.init?.();
|
||||
if (token !== this.selectionToken) {
|
||||
return false;
|
||||
}
|
||||
if (backend.getFailureReason?.()) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${backend.id} renderer unavailable: ${backend.getFailureReason()}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (backend.whenReady) {
|
||||
const ready = await backend.whenReady();
|
||||
if (!ready || backend.getFailureReason?.()) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${backend.id} renderer unavailable: ${
|
||||
backend.getFailureReason?.() ?? "initialization failed"
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[TerritoryLayer] ${backend.id} renderer failed init`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
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 activateBackend(
|
||||
backend: TerritoryBackend,
|
||||
message: string | null = null,
|
||||
) {
|
||||
const ctx = this.highlightContext;
|
||||
if (!ctx) return;
|
||||
if (this.activeBackend === backend) {
|
||||
return;
|
||||
}
|
||||
const previous = this.activeBackend;
|
||||
this.activeBackend = backend;
|
||||
previous?.dispose?.();
|
||||
console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
|
||||
this.publishStatus(message);
|
||||
}
|
||||
|
||||
// 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 runActive(
|
||||
operation: "tick" | "redraw" | "renderLayer",
|
||||
run: (backend: TerritoryBackend) => void,
|
||||
) {
|
||||
const backend = this.activeBackend;
|
||||
if (!backend) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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());
|
||||
try {
|
||||
run(backend);
|
||||
const reason = backend.getFailureReason?.();
|
||||
if (reason) {
|
||||
this.handleBackendFailure(backend, `${operation}: ${reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleBackendFailure(backend, `${operation}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
private handleBackendFailure(backend: TerritoryBackend, reason: string) {
|
||||
console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`);
|
||||
if (backend.id !== "classic") {
|
||||
this.failedBackends.add(backend.id);
|
||||
}
|
||||
this.publishStatus(`${backend.id} failed: ${reason}`);
|
||||
if (this.activeBackend === backend) {
|
||||
this.activeBackend = null;
|
||||
backend.dispose?.();
|
||||
const classic = this.createBackend("classic");
|
||||
void this.initializeCandidate(classic, ++this.selectionToken).then(
|
||||
(ready) => {
|
||||
if (ready) {
|
||||
this.activateBackend(classic);
|
||||
void this.selectConfiguredBackend();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
private createBackend(id: TerritoryRendererId): TerritoryBackend {
|
||||
if (id === "webgpu") {
|
||||
return new WebGPUTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
this.userSettings,
|
||||
);
|
||||
}
|
||||
if (id === "webgl") {
|
||||
return new WebGLTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
);
|
||||
}
|
||||
return new ClassicTerritoryBackend(
|
||||
this.game,
|
||||
this.eventBus,
|
||||
this.transformHandler,
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
private fillBackground(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.fillStyle = this.game.config().theme().backgroundColor().toHex();
|
||||
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
private publishStatus(message: string | null = null) {
|
||||
const detail: TerritoryRendererStatus = {
|
||||
active: this.activeBackend?.id ?? null,
|
||||
preference: this.userSettings.territoryRenderer(),
|
||||
failedBackends: Array.from(this.failedBackends),
|
||||
message,
|
||||
};
|
||||
|
||||
globalThis.dispatchEvent?.(
|
||||
new CustomEvent(TERRITORY_RENDERER_STATUS_EVENT, { detail }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
USER_SETTINGS_CHANGED_EVENT,
|
||||
UserSettings,
|
||||
WEBGPU_DEBUG_KEY,
|
||||
} from "../../../core/game/UserSettings";
|
||||
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
|
||||
import {
|
||||
TERRAIN_SHADER_KEY,
|
||||
TERRAIN_SHADERS,
|
||||
terrainShaderIdFromInt,
|
||||
terrainShaderIntFromId,
|
||||
TerrainShaderOption,
|
||||
} from "../webgpu/render/TerrainShaderRegistry";
|
||||
import {
|
||||
TERRITORY_POST_SMOOTHING,
|
||||
TERRITORY_POST_SMOOTHING_KEY,
|
||||
territoryPostSmoothingIdFromInt,
|
||||
territoryPostSmoothingIntFromId,
|
||||
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
|
||||
import {
|
||||
TERRITORY_PRE_SMOOTHING,
|
||||
TERRITORY_PRE_SMOOTHING_KEY,
|
||||
territoryPreSmoothingIdFromInt,
|
||||
territoryPreSmoothingIntFromId,
|
||||
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
|
||||
import {
|
||||
TERRITORY_SHADER_KEY,
|
||||
TERRITORY_SHADERS,
|
||||
territoryShaderIdFromInt,
|
||||
territoryShaderIntFromId,
|
||||
TerritoryShaderOption,
|
||||
} from "../webgpu/render/TerritoryShaderRegistry";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
type ShaderOption = TerrainShaderOption | TerritoryShaderOption;
|
||||
|
||||
@customElement("webgpu-debug-overlay")
|
||||
export class WebGPUDebugOverlay extends LitElement implements Layer {
|
||||
@property({ type: Object })
|
||||
public eventBus!: EventBus;
|
||||
|
||||
@property({ type: Object })
|
||||
public userSettings!: UserSettings;
|
||||
|
||||
@state()
|
||||
private renderFps: number = 0;
|
||||
|
||||
@state()
|
||||
private tickComputeMs: number = 0;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } | null = null;
|
||||
|
||||
@state()
|
||||
private isDragging = false;
|
||||
|
||||
private frameTimes: number[] = [];
|
||||
private dragState: {
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} | null = null;
|
||||
private readonly positionStorageKey = "webgpuDebugOverlay.position.v1";
|
||||
|
||||
static styles = css`
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 9999;
|
||||
min-width: 340px;
|
||||
max-width: 420px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overlay.dragging {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.overlay.dragging .title {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 6px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-top: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.range {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rangeValue {
|
||||
min-width: 54px;
|
||||
text-align: right;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
`;
|
||||
|
||||
init() {
|
||||
this.restorePosition();
|
||||
globalThis.addEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
|
||||
this.handleDebugSettingChanged,
|
||||
);
|
||||
this.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
|
||||
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
|
||||
this.tickComputeMs = e.computeMs;
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.endDrag();
|
||||
globalThis.removeEventListener?.(
|
||||
`${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
|
||||
this.handleDebugSettingChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleDebugSettingChanged = () => {
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
updateFrameMetrics(frameDurationMs: number): void {
|
||||
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(frameDurationMs) || frameDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.frameTimes.push(frameDurationMs);
|
||||
if (this.frameTimes.length > 60) {
|
||||
this.frameTimes.shift();
|
||||
}
|
||||
|
||||
const avgMs =
|
||||
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
|
||||
this.renderFps = Math.round(1000 / Math.max(1e-6, avgMs));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedShaderId() {
|
||||
const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 0);
|
||||
return territoryShaderIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedShaderId(id: "classic" | "retro") {
|
||||
this.userSettings.setInt(
|
||||
TERRITORY_SHADER_KEY,
|
||||
territoryShaderIntFromId(id),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedTerrainShaderId() {
|
||||
const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
|
||||
return terrainShaderIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedTerrainShaderId(
|
||||
id: "classic" | "improved-lite" | "improved-heavy",
|
||||
) {
|
||||
this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedPreSmoothingId() {
|
||||
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
|
||||
return territoryPreSmoothingIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedPreSmoothingId(id: "off" | "dissolve" | "budget") {
|
||||
this.userSettings.setInt(
|
||||
TERRITORY_PRE_SMOOTHING_KEY,
|
||||
territoryPreSmoothingIntFromId(id),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private selectedPostSmoothingId() {
|
||||
const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
|
||||
return territoryPostSmoothingIdFromInt(selected);
|
||||
}
|
||||
|
||||
private setSelectedPostSmoothingId(id: "off" | "fade" | "dissolve") {
|
||||
this.userSettings.setInt(
|
||||
TERRITORY_POST_SMOOTHING_KEY,
|
||||
territoryPostSmoothingIntFromId(id),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderOptionControl(option: ShaderOption) {
|
||||
if (option.kind === "boolean") {
|
||||
const enabled = this.userSettings.get(option.key, option.defaultValue);
|
||||
return html`
|
||||
<div class="row">
|
||||
<div class="label">${option.label}</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${live(enabled)}
|
||||
@change=${(e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
this.userSettings.set(option.key, checked);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (option.kind === "enum") {
|
||||
const value = this.userSettings.getInt(option.key, option.defaultValue);
|
||||
return html`
|
||||
<div class="row">
|
||||
<div class="label">${option.label}</div>
|
||||
<select
|
||||
.value=${live(String(value))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(next)) return;
|
||||
this.userSettings.setInt(option.key, next);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${option.options.map(
|
||||
(o) => html`<option value=${String(o.value)}>${o.label}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const value = this.userSettings.getFloat(option.key, option.defaultValue);
|
||||
return html`
|
||||
<div class="row">
|
||||
<div class="label">${option.label}</div>
|
||||
<div class="range">
|
||||
<input
|
||||
type="range"
|
||||
min=${String(option.min)}
|
||||
max=${String(option.max)}
|
||||
step=${String(option.step)}
|
||||
.value=${live(String(value))}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
const next = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(next)) return;
|
||||
this.userSettings.setFloat(option.key, next);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
/>
|
||||
<div class="rangeValue">${value.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private restorePosition() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.positionStorageKey);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { x: unknown; y: unknown };
|
||||
if (
|
||||
typeof parsed.x === "number" &&
|
||||
typeof parsed.y === "number" &&
|
||||
Number.isFinite(parsed.x) &&
|
||||
Number.isFinite(parsed.y)
|
||||
) {
|
||||
this.position = this.clampPosition(parsed.x, parsed.y);
|
||||
}
|
||||
} catch {
|
||||
// Keep the default position.
|
||||
}
|
||||
}
|
||||
|
||||
private savePosition() {
|
||||
if (!this.position) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
this.positionStorageKey,
|
||||
JSON.stringify(this.position),
|
||||
);
|
||||
} catch {
|
||||
// Position persistence is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
private clampPosition(x: number, y: number) {
|
||||
const overlay = this.renderRoot.querySelector(
|
||||
".overlay",
|
||||
) as HTMLElement | null;
|
||||
const width = overlay?.offsetWidth ?? 340;
|
||||
const height = overlay?.offsetHeight ?? 420;
|
||||
const margin = 8;
|
||||
return {
|
||||
x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)),
|
||||
y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)),
|
||||
};
|
||||
}
|
||||
|
||||
private overlayStyle() {
|
||||
if (!this.position) {
|
||||
return "";
|
||||
}
|
||||
return `left: ${this.position.x}px; top: ${this.position.y}px;`;
|
||||
}
|
||||
|
||||
private stopPointerEvent(event: PointerEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleDragPointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const overlay = this.renderRoot.querySelector(
|
||||
".overlay",
|
||||
) as HTMLElement | null;
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
this.position = { x: rect.left, y: rect.top };
|
||||
this.isDragging = true;
|
||||
this.dragState = {
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - rect.left,
|
||||
offsetY: event.clientY - rect.top,
|
||||
};
|
||||
|
||||
globalThis.addEventListener("pointermove", this.handleDragPointerMove);
|
||||
globalThis.addEventListener("pointerup", this.handleDragPointerUp);
|
||||
globalThis.addEventListener("pointercancel", this.handleDragPointerUp);
|
||||
}
|
||||
|
||||
private readonly handleDragPointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.position = this.clampPosition(
|
||||
event.clientX - this.dragState.offsetX,
|
||||
event.clientY - this.dragState.offsetY,
|
||||
);
|
||||
};
|
||||
|
||||
private readonly handleDragPointerUp = (event: PointerEvent) => {
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.savePosition();
|
||||
this.endDrag();
|
||||
};
|
||||
|
||||
private endDrag() {
|
||||
globalThis.removeEventListener("pointermove", this.handleDragPointerMove);
|
||||
globalThis.removeEventListener("pointerup", this.handleDragPointerUp);
|
||||
globalThis.removeEventListener("pointercancel", this.handleDragPointerUp);
|
||||
this.dragState = null;
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shaderId = this.selectedShaderId();
|
||||
const shader =
|
||||
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
|
||||
const terrainShaderId = this.selectedTerrainShaderId();
|
||||
const terrainShader =
|
||||
TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ??
|
||||
TERRAIN_SHADERS[0];
|
||||
const preId = this.selectedPreSmoothingId();
|
||||
const pre =
|
||||
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
|
||||
TERRITORY_PRE_SMOOTHING[0];
|
||||
const postId = this.selectedPostSmoothingId();
|
||||
const post =
|
||||
TERRITORY_POST_SMOOTHING.find((s) => s.id === postId) ??
|
||||
TERRITORY_POST_SMOOTHING[0];
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="overlay ${this.isDragging ? "dragging" : ""}"
|
||||
style=${this.overlayStyle()}
|
||||
@pointerdown=${this.stopPointerEvent}
|
||||
>
|
||||
<div class="title" @pointerdown=${this.handleDragPointerDown}>
|
||||
<div>WebGPU Debug</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="label">tick ms compute</div>
|
||||
<div class="value">${this.tickComputeMs.toFixed(2)}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">render fps</div>
|
||||
<div class="value">${this.renderFps}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sectionTitle">Terrain</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Terrain Shader</div>
|
||||
<select
|
||||
.value=${live(String(terrainShaderIntFromId(terrainShaderId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = terrainShaderIdFromInt(Number.parseInt(raw, 10));
|
||||
this.setSelectedTerrainShaderId(next);
|
||||
}}
|
||||
>
|
||||
${TERRAIN_SHADERS.map(
|
||||
(s) =>
|
||||
html`<option value=${String(terrainShaderIntFromId(s.id))}>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${terrainShader.options.map((opt) => this.renderOptionControl(opt))}
|
||||
|
||||
<div class="sectionTitle">Territory</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Territory Shader</div>
|
||||
<select
|
||||
.value=${live(String(territoryShaderIntFromId(shaderId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = territoryShaderIdFromInt(Number.parseInt(raw, 10));
|
||||
this.setSelectedShaderId(next);
|
||||
}}
|
||||
>
|
||||
${TERRITORY_SHADERS.map(
|
||||
(s) =>
|
||||
html`<option value=${String(territoryShaderIntFromId(s.id))}>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${shader.options.map((opt) => this.renderOptionControl(opt))}
|
||||
|
||||
<div class="sectionTitle">Temporal</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Post Compute</div>
|
||||
<select
|
||||
.value=${live(String(territoryPreSmoothingIntFromId(preId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = territoryPreSmoothingIdFromInt(
|
||||
Number.parseInt(raw, 10),
|
||||
);
|
||||
this.setSelectedPreSmoothingId(next);
|
||||
}}
|
||||
>
|
||||
${TERRITORY_PRE_SMOOTHING.map(
|
||||
(s) =>
|
||||
html`<option
|
||||
value=${String(territoryPreSmoothingIntFromId(s.id))}
|
||||
>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${pre.options.map((opt) => this.renderOptionControl(opt))}
|
||||
|
||||
<div class="row">
|
||||
<div class="label">Post Render</div>
|
||||
<select
|
||||
.value=${live(String(territoryPostSmoothingIntFromId(postId)))}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLSelectElement).value;
|
||||
const next = territoryPostSmoothingIdFromInt(
|
||||
Number.parseInt(raw, 10),
|
||||
);
|
||||
this.setSelectedPostSmoothingId(next);
|
||||
}}
|
||||
>
|
||||
${TERRITORY_POST_SMOOTHING.map(
|
||||
(s) =>
|
||||
html`<option
|
||||
value=${String(territoryPostSmoothingIntFromId(s.id))}
|
||||
>
|
||||
${s.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${post.options.map((opt) => this.renderOptionControl(opt))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
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,
|
||||
WebGPUComputeMetricsEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import {
|
||||
buildTerrainShaderParams,
|
||||
readTerrainShaderId,
|
||||
} from "../webgpu/render/TerrainShaderRegistry";
|
||||
import {
|
||||
buildTerritoryPostSmoothingParams,
|
||||
readTerritoryPostSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPostSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryPreSmoothingParams,
|
||||
readTerritoryPreSmoothingId,
|
||||
} from "../webgpu/render/TerritoryPreSmoothingRegistry";
|
||||
import {
|
||||
buildTerritoryShaderParams,
|
||||
readTerritoryShaderId,
|
||||
} from "../webgpu/render/TerritoryShaderRegistry";
|
||||
import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
|
||||
import { TerritoryBackend } from "./TerritoryBackend";
|
||||
|
||||
export class WebGPUTerritoryBackend implements TerritoryBackend {
|
||||
readonly id = "webgpu";
|
||||
|
||||
profileName(): string {
|
||||
return "WebGPUTerritoryBackend:renderLayer";
|
||||
}
|
||||
|
||||
private attachedTerritoryCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
private overlayWrapper: HTMLElement | null = null;
|
||||
private overlayResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private theme: Theme;
|
||||
|
||||
private territoryRenderer: TerritoryRenderer | null = null;
|
||||
private alternativeView = false;
|
||||
|
||||
private lastPaletteSignature: string | null = null;
|
||||
private lastDefensePostsSignature: string | null = null;
|
||||
private lastTerrainShaderSignature: string | null = null;
|
||||
private lastTerritoryShaderSignature: string | null = null;
|
||||
private lastPreSmoothingSignature: string | null = null;
|
||||
private lastPostSmoothingSignature: string | null = null;
|
||||
|
||||
private lastMousePosition: { x: number; y: number } | 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();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(AlternateViewEvent, (e) => {
|
||||
this.alternativeView = e.alternateView;
|
||||
this.territoryRenderer?.setAlternativeView(this.alternativeView);
|
||||
});
|
||||
this.eventBus.on(MouseOverEvent, (e) => {
|
||||
this.lastMousePosition = { x: e.x, y: e.y };
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
whenReady(): Promise<boolean> {
|
||||
return this.territoryRenderer?.whenReady() ?? Promise.resolve(false);
|
||||
}
|
||||
|
||||
getFailureReason(): string | null {
|
||||
return this.territoryRenderer?.getFailureReason() ?? null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.overlayResizeObserver?.disconnect();
|
||||
this.overlayResizeObserver = null;
|
||||
this.attachedTerritoryCanvas?.remove();
|
||||
this.attachedTerritoryCanvas = null;
|
||||
this.overlayWrapper = null;
|
||||
this.territoryRenderer?.dispose();
|
||||
this.territoryRenderer = null;
|
||||
}
|
||||
|
||||
tick() {
|
||||
const tickProfile = FrameProfiler.start();
|
||||
|
||||
const currentTheme = this.game.config().theme();
|
||||
if (currentTheme !== this.theme) {
|
||||
this.theme = currentTheme;
|
||||
this.territoryRenderer?.refreshTerrain();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
this.refreshPaletteIfNeeded();
|
||||
this.refreshDefensePostsIfNeeded();
|
||||
this.applyTerrainShaderSettings();
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
const updatedTiles = this.game.recentlyUpdatedTiles();
|
||||
for (let i = 0; i < updatedTiles.length; i++) {
|
||||
this.markTile(updatedTiles[i]);
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (this.territoryRenderer) {
|
||||
const start = performance.now();
|
||||
this.territoryRenderer.tick();
|
||||
const computeMs = performance.now() - start;
|
||||
this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs));
|
||||
}
|
||||
|
||||
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.configureRenderer();
|
||||
}
|
||||
|
||||
private configureRenderer() {
|
||||
this.territoryRenderer?.dispose();
|
||||
this.territoryRenderer = null;
|
||||
|
||||
const { renderer, reason } = TerritoryRenderer.create(
|
||||
this.game,
|
||||
this.theme,
|
||||
);
|
||||
if (!renderer) {
|
||||
throw new Error(reason ?? "WebGPU is required for territory rendering.");
|
||||
}
|
||||
|
||||
this.territoryRenderer = renderer;
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
|
||||
this.applyTerrainShaderSettings(true);
|
||||
this.applyTerritoryShaderSettings(true);
|
||||
this.applyTerritorySmoothingSettings(true);
|
||||
this.territoryRenderer.markAllDirty();
|
||||
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) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for theme changes in renderLayer too (for when game is paused)
|
||||
const currentTheme = this.game.config().theme();
|
||||
if (currentTheme !== this.theme) {
|
||||
this.theme = currentTheme;
|
||||
this.territoryRenderer.refreshTerrain();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
// Apply user settings even while the game is paused (settings modal).
|
||||
this.applyTerritoryShaderSettings();
|
||||
this.applyTerritorySmoothingSettings();
|
||||
|
||||
this.ensureTerritoryCanvasAttached(context.canvas);
|
||||
this.updateHoverHighlight();
|
||||
|
||||
const renderTerritoryStart = FrameProfiler.start();
|
||||
this.territoryRenderer.setViewSize(
|
||||
context.canvas.width,
|
||||
context.canvas.height,
|
||||
);
|
||||
const viewOffset = this.transformHandler.viewOffset();
|
||||
this.territoryRenderer.setViewTransform(
|
||||
this.transformHandler.scale,
|
||||
viewOffset.x,
|
||||
viewOffset.y,
|
||||
);
|
||||
this.territoryRenderer.render();
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
|
||||
}
|
||||
|
||||
private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
|
||||
if (!this.territoryRenderer) {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.dataset.territoryOverlay = "1";
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.style.lineHeight = "0";
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
private syncOverlayWrapperSize(
|
||||
mainCanvas: HTMLCanvasElement,
|
||||
wrapper: HTMLElement,
|
||||
) {
|
||||
// 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`;
|
||||
}
|
||||
|
||||
private markTile(tile: TileRef) {
|
||||
this.territoryRenderer?.markTile(tile);
|
||||
}
|
||||
|
||||
private updateHoverHighlight() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastHoverUpdateMs < 100) {
|
||||
return;
|
||||
}
|
||||
this.lastHoverUpdateMs = now;
|
||||
|
||||
let nextOwnerSmallId: number | null = null;
|
||||
if (this.lastMousePosition) {
|
||||
const cell = this.transformHandler.screenToWorldCoordinates(
|
||||
this.lastMousePosition.x,
|
||||
this.lastMousePosition.y,
|
||||
);
|
||||
if (this.game.isValidCoord(cell.x, cell.y)) {
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
const owner = this.game.owner(tile);
|
||||
if (owner && owner.isPlayer()) {
|
||||
nextOwnerSmallId = owner.smallID();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 applyTerritoryShaderSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderId = readTerritoryShaderId(this.userSettings);
|
||||
const { shaderPath, params0, params1 } = buildTerritoryShaderParams(
|
||||
this.userSettings,
|
||||
shaderId,
|
||||
);
|
||||
|
||||
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
|
||||
if (!force && signature === this.lastTerritoryShaderSignature) {
|
||||
return;
|
||||
}
|
||||
this.lastTerritoryShaderSignature = signature;
|
||||
|
||||
this.territoryRenderer.setTerritoryShader(shaderPath);
|
||||
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
|
||||
}
|
||||
|
||||
private applyTerrainShaderSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terrainId = readTerrainShaderId(this.userSettings);
|
||||
const { shaderPath, params0, params1 } = buildTerrainShaderParams(
|
||||
this.userSettings,
|
||||
terrainId,
|
||||
);
|
||||
const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
|
||||
if (!force && signature === this.lastTerrainShaderSignature) {
|
||||
return;
|
||||
}
|
||||
this.lastTerrainShaderSignature = signature;
|
||||
this.territoryRenderer.setTerrainShader(shaderPath);
|
||||
this.territoryRenderer.setTerrainShaderParams(params0, params1);
|
||||
}
|
||||
|
||||
private applyTerritorySmoothingSettings(force: boolean = false) {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preId = readTerritoryPreSmoothingId(this.userSettings);
|
||||
const preParams = buildTerritoryPreSmoothingParams(
|
||||
this.userSettings,
|
||||
preId,
|
||||
);
|
||||
const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`;
|
||||
if (force || preSignature !== this.lastPreSmoothingSignature) {
|
||||
this.lastPreSmoothingSignature = preSignature;
|
||||
this.territoryRenderer.setPreSmoothing(
|
||||
preParams.enabled,
|
||||
preParams.shaderPath,
|
||||
preParams.params0,
|
||||
);
|
||||
}
|
||||
|
||||
const postId = readTerritoryPostSmoothingId(this.userSettings);
|
||||
const postParams = buildTerritoryPostSmoothingParams(
|
||||
this.userSettings,
|
||||
postId,
|
||||
);
|
||||
const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`;
|
||||
if (force || postSignature !== this.lastPostSmoothingSignature) {
|
||||
this.lastPostSmoothingSignature = postSignature;
|
||||
this.territoryRenderer.setPostSmoothing(
|
||||
postParams.enabled,
|
||||
postParams.shaderPath,
|
||||
postParams.params0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,657 @@
|
||||
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 { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass";
|
||||
import { DefendedStrengthPass } from "./compute/DefendedStrengthPass";
|
||||
import { StateUpdatePass } from "./compute/StateUpdatePass";
|
||||
import { TerrainComputePass } from "./compute/TerrainComputePass";
|
||||
import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass";
|
||||
import { GroundTruthData } from "./core/GroundTruthData";
|
||||
import { WebGPUDevice } from "./core/WebGPUDevice";
|
||||
import { RenderPass } from "./render/RenderPass";
|
||||
import { TemporalResolvePass } from "./render/TemporalResolvePass";
|
||||
import { TerritoryRenderPass } from "./render/TerritoryRenderPass";
|
||||
|
||||
export interface TerritoryWebGLCreateResult {
|
||||
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;
|
||||
private failureReason: string | null = null;
|
||||
private territoryShaderPath = "render/territory.wgsl";
|
||||
private territoryShaderParams0 = new Float32Array(4);
|
||||
private territoryShaderParams1 = new Float32Array(4);
|
||||
private terrainShaderPath = "compute/terrain-compute.wgsl";
|
||||
private terrainShaderParams0 = new Float32Array(4);
|
||||
private terrainShaderParams1 = new Float32Array(4);
|
||||
private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
|
||||
private preSmoothingParams0 = new Float32Array(4);
|
||||
private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
|
||||
private postSmoothingParams0 = new Float32Array(4);
|
||||
|
||||
// Compute passes
|
||||
private computePasses: ComputePass[] = [];
|
||||
private computePassOrder: ComputePass[] = [];
|
||||
private frameComputePasses: ComputePass[] = [];
|
||||
|
||||
// Render passes
|
||||
private renderPasses: RenderPass[] = [];
|
||||
private renderPassOrder: RenderPass[] = [];
|
||||
|
||||
// Pass instances
|
||||
private terrainComputePass: TerrainComputePass | null = null;
|
||||
private stateUpdatePass: StateUpdatePass | null = null;
|
||||
private defendedStrengthFullPass: DefendedStrengthFullPass | null = null;
|
||||
private defendedStrengthPass: DefendedStrengthPass | null = null;
|
||||
private visualStateSmoothingPass: VisualStateSmoothingPass | null = null;
|
||||
private territoryRenderPass: TerritoryRenderPass | null = null;
|
||||
private temporalResolvePass: TemporalResolvePass | null = null;
|
||||
private readonly defensePostRange: number;
|
||||
|
||||
private preSmoothingEnabled = false;
|
||||
private postSmoothingEnabled = false;
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
) {
|
||||
this.canvas = createCanvas();
|
||||
this.canvas.style.pointerEvents = "none";
|
||||
this.canvas.width = 1;
|
||||
this.canvas.height = 1;
|
||||
this.defensePostRange = game.config().defensePostRange();
|
||||
}
|
||||
|
||||
static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult {
|
||||
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().catch((error) => {
|
||||
this.ready = false;
|
||||
this.failureReason =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.warn("[TerritoryRenderer] WebGPU init failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
const webgpuDevice = await WebGPUDevice.create(this.canvas);
|
||||
if (!webgpuDevice) {
|
||||
this.failureReason = "WebGPU device initialization failed.";
|
||||
return;
|
||||
}
|
||||
this.device = webgpuDevice;
|
||||
void webgpuDevice.device.lost.then((info) => {
|
||||
this.ready = false;
|
||||
this.failureReason = `WebGPU device lost: ${info.reason}`;
|
||||
});
|
||||
|
||||
const state = this.game.tileStateView();
|
||||
this.resources = GroundTruthData.create(
|
||||
webgpuDevice.device,
|
||||
this.game,
|
||||
this.theme,
|
||||
state,
|
||||
);
|
||||
this.resources.setTerritoryShaderParams(
|
||||
this.territoryShaderParams0,
|
||||
this.territoryShaderParams1,
|
||||
);
|
||||
this.resources.setTerrainShaderParams(
|
||||
this.terrainShaderParams0,
|
||||
this.terrainShaderParams1,
|
||||
);
|
||||
|
||||
// Upload terrain data and params (terrain colors will be computed on GPU)
|
||||
this.resources.uploadTerrainData();
|
||||
this.resources.uploadTerrainParams();
|
||||
|
||||
// Create compute passes (terrain compute should run first)
|
||||
this.terrainComputePass = new TerrainComputePass();
|
||||
void this.terrainComputePass.setShader(this.terrainShaderPath);
|
||||
this.stateUpdatePass = new StateUpdatePass();
|
||||
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
|
||||
this.defendedStrengthPass = new DefendedStrengthPass();
|
||||
this.visualStateSmoothingPass = new VisualStateSmoothingPass();
|
||||
|
||||
this.computePasses = [
|
||||
this.terrainComputePass,
|
||||
this.stateUpdatePass,
|
||||
this.defendedStrengthFullPass,
|
||||
this.defendedStrengthPass,
|
||||
];
|
||||
|
||||
this.frameComputePasses = [this.visualStateSmoothingPass];
|
||||
|
||||
// Create render passes
|
||||
this.territoryRenderPass = new TerritoryRenderPass();
|
||||
this.temporalResolvePass = new TemporalResolvePass();
|
||||
this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass];
|
||||
|
||||
// Initialize all passes
|
||||
for (const pass of this.computePasses) {
|
||||
await pass.init(webgpuDevice.device, this.resources);
|
||||
}
|
||||
|
||||
for (const pass of this.frameComputePasses) {
|
||||
await pass.init(webgpuDevice.device, this.resources);
|
||||
}
|
||||
|
||||
for (const pass of this.renderPasses) {
|
||||
await pass.init(
|
||||
webgpuDevice.device,
|
||||
this.resources,
|
||||
webgpuDevice.canvasFormat,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.territoryRenderPass) {
|
||||
await this.territoryRenderPass.setShader(this.territoryShaderPath);
|
||||
}
|
||||
|
||||
this.applyPreSmoothingConfig();
|
||||
this.applyPostSmoothingConfig();
|
||||
|
||||
// Compute dependency order (topological sort)
|
||||
this.computePassOrder = this.topologicalSort(this.computePasses);
|
||||
this.renderPassOrder = this.topologicalSort(this.renderPasses);
|
||||
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
async whenReady(): Promise<boolean> {
|
||||
await this.initPromise;
|
||||
return this.ready && this.failureReason === null;
|
||||
}
|
||||
|
||||
getFailureReason(): string | null {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.ready = false;
|
||||
try {
|
||||
this.device?.device.destroy();
|
||||
} catch {
|
||||
// Ignore device cleanup failures during renderer fallback.
|
||||
}
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (this.postSmoothingEnabled && this.resources) {
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setTerritoryShader(shaderPath: string): void {
|
||||
this.territoryShaderPath = shaderPath;
|
||||
if (this.territoryRenderPass) {
|
||||
void this.territoryRenderPass.setShader(shaderPath);
|
||||
}
|
||||
this.resources?.invalidateHistory();
|
||||
}
|
||||
|
||||
setTerrainShader(shaderPath: string): void {
|
||||
this.terrainShaderPath = shaderPath;
|
||||
if (!this.terrainComputePass) {
|
||||
return;
|
||||
}
|
||||
void this.terrainComputePass.setShader(shaderPath).then(() => {
|
||||
this.refreshTerrain();
|
||||
});
|
||||
}
|
||||
|
||||
setTerritoryShaderParams(
|
||||
params0: Float32Array | number[],
|
||||
params1: Float32Array | number[],
|
||||
): void {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.territoryShaderParams0[i] = Number(params0[i] ?? 0);
|
||||
this.territoryShaderParams1[i] = Number(params1[i] ?? 0);
|
||||
}
|
||||
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
this.resources.setTerritoryShaderParams(
|
||||
this.territoryShaderParams0,
|
||||
this.territoryShaderParams1,
|
||||
);
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
setTerrainShaderParams(
|
||||
params0: Float32Array | number[],
|
||||
params1: Float32Array | number[],
|
||||
): void {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.terrainShaderParams0[i] = Number(params0[i] ?? 0);
|
||||
this.terrainShaderParams1[i] = Number(params1[i] ?? 0);
|
||||
}
|
||||
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
this.resources.setTerrainShaderParams(
|
||||
this.terrainShaderParams0,
|
||||
this.terrainShaderParams1,
|
||||
);
|
||||
this.refreshTerrain();
|
||||
}
|
||||
|
||||
setPreSmoothing(
|
||||
enabled: boolean,
|
||||
shaderPath: string,
|
||||
params0: Float32Array | number[],
|
||||
): void {
|
||||
this.preSmoothingEnabled = enabled;
|
||||
if (shaderPath) {
|
||||
this.preSmoothingShaderPath = shaderPath;
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.preSmoothingParams0[i] = Number(params0[i] ?? 0);
|
||||
}
|
||||
this.applyPreSmoothingConfig();
|
||||
}
|
||||
|
||||
setPostSmoothing(
|
||||
enabled: boolean,
|
||||
shaderPath: string,
|
||||
params0: Float32Array | number[],
|
||||
): void {
|
||||
this.postSmoothingEnabled = enabled;
|
||||
if (shaderPath) {
|
||||
this.postSmoothingShaderPath = shaderPath;
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.postSmoothingParams0[i] = Number(params0[i] ?? 0);
|
||||
}
|
||||
this.applyPostSmoothingConfig();
|
||||
}
|
||||
|
||||
private applyPreSmoothingConfig(): void {
|
||||
if (!this.resources || !this.visualStateSmoothingPass) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resources.setUseVisualStateTexture(this.preSmoothingEnabled);
|
||||
if (this.preSmoothingEnabled) {
|
||||
this.resources.ensureVisualStateTexture();
|
||||
void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath);
|
||||
this.visualStateSmoothingPass.setParams(this.preSmoothingParams0);
|
||||
} else {
|
||||
this.visualStateSmoothingPass.setParams(new Float32Array(4));
|
||||
this.resources.releaseVisualStateTexture();
|
||||
}
|
||||
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
private applyPostSmoothingConfig(): void {
|
||||
if (!this.resources || !this.temporalResolvePass || !this.device) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.postSmoothingEnabled) {
|
||||
void this.temporalResolvePass.setShader(this.postSmoothingShaderPath);
|
||||
this.temporalResolvePass.setParams(this.postSmoothingParams0);
|
||||
this.temporalResolvePass.setEnabled(true);
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
} else {
|
||||
this.temporalResolvePass.setEnabled(false);
|
||||
this.resources.releasePostSmoothingTextures();
|
||||
}
|
||||
|
||||
this.resources.invalidateHistory();
|
||||
}
|
||||
|
||||
markTile(tile: TileRef): void {
|
||||
if (this.stateUpdatePass) {
|
||||
this.stateUpdatePass.markTile(tile);
|
||||
}
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
this.resources?.markDefensePostsDirty();
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
this.resources.markPaletteDirty();
|
||||
}
|
||||
|
||||
markDefensePostsDirty(): void {
|
||||
if (!this.resources) {
|
||||
return;
|
||||
}
|
||||
this.resources.markDefensePostsDirty();
|
||||
}
|
||||
|
||||
refreshTerrain(): void {
|
||||
if (!this.resources || !this.device) {
|
||||
return;
|
||||
}
|
||||
this.resources.markTerrainParamsDirty();
|
||||
if (this.terrainComputePass) {
|
||||
this.terrainComputePass.markDirty();
|
||||
// Immediately compute terrain to avoid blank rendering
|
||||
this.computeTerrainImmediate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately execute terrain compute pass (for theme changes).
|
||||
* This ensures terrain is recomputed before the next render.
|
||||
*/
|
||||
private computeTerrainImmediate(): void {
|
||||
if (
|
||||
!this.ready ||
|
||||
!this.device ||
|
||||
!this.resources ||
|
||||
!this.terrainComputePass
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload terrain params if needed
|
||||
this.resources.uploadTerrainParams();
|
||||
|
||||
if (!this.terrainComputePass.needsUpdate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encoder = this.device.device.createCommandEncoder();
|
||||
this.terrainComputePass.execute(encoder, this.resources);
|
||||
this.device.device.queue.submit([encoder.finish()]);
|
||||
|
||||
// Rebuild render pass bind group to ensure it uses the updated terrain texture
|
||||
// This will be called again in render(), but doing it here ensures it's ready
|
||||
if (this.territoryRenderPass) {
|
||||
(this.territoryRenderPass as any).rebuildBindGroup?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one simulation tick.
|
||||
* Runs compute passes to update ground truth data.
|
||||
*/
|
||||
tick(): void {
|
||||
if (!this.ready || !this.device || !this.resources) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resources.updateTickTiming(performance.now() / 1000);
|
||||
|
||||
if (this.game.config().defensePostRange() !== this.defensePostRange) {
|
||||
throw new Error("defensePostRange changed at runtime; unsupported.");
|
||||
}
|
||||
|
||||
// Upload palette if needed
|
||||
this.resources.uploadPalette();
|
||||
|
||||
// Upload diplomacy relations (used by retro shader / debug modes)
|
||||
this.resources.uploadRelations();
|
||||
|
||||
// Upload defense posts if needed (also produces defended dirty tiles on changes)
|
||||
this.resources.uploadDefensePosts();
|
||||
|
||||
// Initial state upload
|
||||
this.resources.uploadState();
|
||||
|
||||
const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false;
|
||||
if (!stateUpdatesPending) {
|
||||
this.resources.setLastStateUpdateCount(0);
|
||||
}
|
||||
|
||||
const needsCompute =
|
||||
(this.terrainComputePass?.needsUpdate() ?? false) ||
|
||||
stateUpdatesPending ||
|
||||
(this.defendedStrengthFullPass?.needsUpdate() ?? false) ||
|
||||
(this.defendedStrengthPass?.needsUpdate() ?? false);
|
||||
|
||||
if (!needsCompute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encoder = this.device.device.createCommandEncoder();
|
||||
|
||||
if (this.preSmoothingEnabled && stateUpdatesPending) {
|
||||
this.resources.ensureVisualStateTexture();
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (visualStateTexture) {
|
||||
encoder.copyTextureToTexture(
|
||||
{ texture: this.resources.stateTexture },
|
||||
{ texture: visualStateTexture },
|
||||
{
|
||||
width: this.resources.getMapWidth(),
|
||||
height: this.resources.getMapHeight(),
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
);
|
||||
this.resources.consumeVisualStateSyncNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
// Execute compute passes in dependency order (clear will run before update if needed)
|
||||
for (const pass of this.computePassOrder) {
|
||||
if (!pass.needsUpdate()) {
|
||||
continue;
|
||||
}
|
||||
pass.execute(encoder, this.resources);
|
||||
}
|
||||
|
||||
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 nowSec = performance.now() / 1000;
|
||||
this.resources.writeTemporalUniformBuffer(nowSec);
|
||||
|
||||
// If terrain needs recomputation, trigger it asynchronously (no blocking)
|
||||
// It will be ready for the next frame, acceptable trade-off for performance
|
||||
if (this.terrainComputePass?.needsUpdate()) {
|
||||
this.resources.uploadTerrainParams();
|
||||
const computeEncoder = this.device.device.createCommandEncoder();
|
||||
this.terrainComputePass.execute(computeEncoder, this.resources);
|
||||
this.device.device.queue.submit([computeEncoder.finish()]);
|
||||
// Continue with render - may show stale terrain for one frame, but better performance
|
||||
}
|
||||
|
||||
const encoder = this.device.device.createCommandEncoder();
|
||||
const swapchainView = this.device.context.getCurrentTexture().createView();
|
||||
|
||||
if (
|
||||
this.preSmoothingEnabled &&
|
||||
this.resources.consumeVisualStateSyncNeeded()
|
||||
) {
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (visualStateTexture) {
|
||||
encoder.copyTextureToTexture(
|
||||
{ texture: this.resources.stateTexture },
|
||||
{ texture: visualStateTexture },
|
||||
{
|
||||
width: this.resources.getMapWidth(),
|
||||
height: this.resources.getMapHeight(),
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pass of this.frameComputePasses) {
|
||||
if (!pass.needsUpdate()) {
|
||||
continue;
|
||||
}
|
||||
pass.execute(encoder, this.resources);
|
||||
}
|
||||
|
||||
// Execute render passes in dependency order
|
||||
for (const pass of this.renderPassOrder) {
|
||||
if (!pass.needsUpdate()) {
|
||||
continue;
|
||||
}
|
||||
if (pass === this.territoryRenderPass && this.postSmoothingEnabled) {
|
||||
if (!this.resources.getCurrentColorTexture()) {
|
||||
this.resources.ensurePostSmoothingTextures(
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
this.device.canvasFormat,
|
||||
);
|
||||
}
|
||||
const currentTexture = this.resources.getCurrentColorTexture();
|
||||
if (currentTexture) {
|
||||
pass.execute(encoder, this.resources, currentTexture.createView());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
pass.execute(encoder, this.resources, swapchainView);
|
||||
}
|
||||
|
||||
this.device.device.queue.submit([encoder.finish()]);
|
||||
}
|
||||
}
|
||||
@@ -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,159 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { ComputePass } from "./ComputePass";
|
||||
|
||||
/**
|
||||
* Full defended strength recompute across the entire map.
|
||||
* Used on initial upload or when post diffs are too large for a tile list.
|
||||
*/
|
||||
export class DefendedStrengthFullPass implements ComputePass {
|
||||
name = "defended-strength-full";
|
||||
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 boundPostsByOwnerBuffer: GPUBuffer | null = null;
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
|
||||
const shaderCode = await loadShader("compute/defended-strength-full.wgsl");
|
||||
const shaderModule = device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "rgba8unorm" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.pipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
return this.resources?.needsDefendedFullRecompute() ?? false;
|
||||
}
|
||||
|
||||
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||
if (!this.device || !this.pipeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resources.needsDefendedFullRecompute()) {
|
||||
return;
|
||||
}
|
||||
|
||||
resources.writeDefendedStrengthParamsBuffer(0);
|
||||
|
||||
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
|
||||
if (
|
||||
!this.bindGroup ||
|
||||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer
|
||||
) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
if (!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();
|
||||
|
||||
resources.clearDefendedFullRecompute();
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.defendedStrengthParamsBuffer ||
|
||||
!this.resources.stateTexture ||
|
||||
!this.resources.defendedStrengthTexture ||
|
||||
!this.resources.defenseOwnerOffsetsBuffer ||
|
||||
!this.resources.defensePostsByOwnerBuffer
|
||||
) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.defendedStrengthParamsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: this.resources.stateTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: this.resources.defendedStrengthTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { ComputePass } from "./ComputePass";
|
||||
|
||||
/**
|
||||
* Recomputes defended strength for a list of dirty tiles.
|
||||
* Dirty tiles are produced when defense posts are added/removed/moved.
|
||||
*/
|
||||
export class DefendedStrengthPass implements ComputePass {
|
||||
name = "defended-strength";
|
||||
dependencies: string[] = ["state-update"];
|
||||
|
||||
private pipeline: GPUComputePipeline | null = null;
|
||||
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private device: GPUDevice | null = null;
|
||||
private resources: GroundTruthData | null = null;
|
||||
private boundDirtyTilesBuffer: GPUBuffer | null = null;
|
||||
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
|
||||
const shaderCode = await loadShader("compute/defended-strength.wgsl");
|
||||
const shaderModule = device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "rgba8unorm" },
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.pipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0;
|
||||
}
|
||||
|
||||
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||
if (!this.device || !this.pipeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dirtyCount = resources.getDefendedDirtyTilesCount();
|
||||
if (dirtyCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
resources.writeDefendedStrengthParamsBuffer(dirtyCount);
|
||||
|
||||
const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer;
|
||||
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
|
||||
const shouldRebuildBindGroup =
|
||||
!this.bindGroup ||
|
||||
this.boundDirtyTilesBuffer !== dirtyTilesBuffer ||
|
||||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer;
|
||||
|
||||
if (shouldRebuildBindGroup) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
const workgroupCount = Math.ceil(dirtyCount / 64);
|
||||
pass.dispatchWorkgroups(workgroupCount);
|
||||
pass.end();
|
||||
|
||||
resources.clearDefendedDirtyTiles();
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.defendedStrengthParamsBuffer ||
|
||||
!this.resources.defendedDirtyTilesBuffer ||
|
||||
!this.resources.stateTexture ||
|
||||
!this.resources.defendedStrengthTexture ||
|
||||
!this.resources.defenseOwnerOffsetsBuffer ||
|
||||
!this.resources.defensePostsByOwnerBuffer
|
||||
) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.defendedStrengthParamsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: { buffer: this.resources.defendedDirtyTilesBuffer },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: this.resources.stateTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: this.resources.defendedStrengthTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer;
|
||||
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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();
|
||||
private boundUpdatesBuffer: GPUBuffer | null = null;
|
||||
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
|
||||
|
||||
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: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "r32uint" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "rgba8unorm" },
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
resources.setLastStateUpdateCount(numUpdates);
|
||||
|
||||
const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates);
|
||||
resources.writeStateUpdateParamsBuffer(numUpdates);
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer;
|
||||
const shouldRebuildBindGroup =
|
||||
!this.bindGroup ||
|
||||
this.boundUpdatesBuffer !== updatesBuffer ||
|
||||
this.boundPostsByOwnerBuffer !== postsByOwnerBuffer;
|
||||
|
||||
if (shouldRebuildBindGroup) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
const workgroupCount = Math.ceil(numUpdates / 64);
|
||||
pass.dispatchWorkgroups(workgroupCount);
|
||||
pass.end();
|
||||
|
||||
this.pendingTiles.clear();
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.stateUpdateParamsBuffer ||
|
||||
!this.resources.updatesBuffer ||
|
||||
!this.resources.stateTexture ||
|
||||
!this.resources.defendedStrengthTexture ||
|
||||
!this.resources.defenseOwnerOffsetsBuffer ||
|
||||
!this.resources.defensePostsByOwnerBuffer
|
||||
) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.stateUpdateParamsBuffer },
|
||||
},
|
||||
{ binding: 1, resource: { buffer: this.resources.updatesBuffer } },
|
||||
{
|
||||
binding: 2,
|
||||
resource: this.resources.stateTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: this.resources.defendedStrengthTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
resource: { buffer: this.resources.defenseOwnerOffsetsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
resource: { buffer: this.resources.defensePostsByOwnerBuffer },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundUpdatesBuffer = this.resources.updatesBuffer;
|
||||
this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer;
|
||||
}
|
||||
|
||||
markTile(tile: number): void {
|
||||
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,145 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { ComputePass } from "./ComputePass";
|
||||
|
||||
/**
|
||||
* Compute pass that generates terrain colors from terrain data.
|
||||
* Runs once at initialization or when theme changes.
|
||||
*/
|
||||
export class TerrainComputePass implements ComputePass {
|
||||
name = "terrain-compute";
|
||||
dependencies: string[] = [];
|
||||
|
||||
private pipeline: GPUComputePipeline | null = null;
|
||||
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private device: GPUDevice | null = null;
|
||||
private resources: GroundTruthData | null = null;
|
||||
private needsCompute = true;
|
||||
private shaderPath = "compute/terrain-compute.wgsl";
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
|
||||
this.ensureBindGroupLayout();
|
||||
await this.setShader(this.shaderPath);
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
this.shaderPath = shaderPath;
|
||||
if (!this.device || !this.bindGroupLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.pipeline = this.device.createComputePipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
});
|
||||
|
||||
this.needsCompute = true;
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
return this.needsCompute;
|
||||
}
|
||||
|
||||
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||
if (!this.device || !this.pipeline || !this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapWidth = resources.getMapWidth();
|
||||
const mapHeight = resources.getMapHeight();
|
||||
const workgroupCountX = Math.ceil(mapWidth / 8);
|
||||
const workgroupCountY = Math.ceil(mapHeight / 8);
|
||||
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.dispatchWorkgroups(workgroupCountX, workgroupCountY);
|
||||
pass.end();
|
||||
|
||||
this.needsCompute = false;
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.terrainParamsBuffer ||
|
||||
!this.resources.terrainDataTexture ||
|
||||
!this.resources.terrainTexture
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.terrainParamsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: this.resources.terrainDataTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: this.resources.terrainTexture.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private ensureBindGroupLayout(): void {
|
||||
if (!this.device || this.bindGroupLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroupLayout = this.device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "rgba8unorm" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
this.needsCompute = true;
|
||||
// Rebuild bind group in case terrain params buffer was recreated
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { ComputePass } from "./ComputePass";
|
||||
|
||||
/**
|
||||
* Per-frame compute pass that updates the visual state texture.
|
||||
* Supports dissolve and budgeted reveal modes.
|
||||
*/
|
||||
export class VisualStateSmoothingPass implements ComputePass {
|
||||
name = "visual-state-smoothing";
|
||||
dependencies: string[] = [];
|
||||
|
||||
private pipeline: GPUComputePipeline | null = null;
|
||||
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private device: GPUDevice | null = null;
|
||||
private resources: GroundTruthData | null = null;
|
||||
private paramsBuffer: GPUBuffer | null = null;
|
||||
private paramsData = new Float32Array(8);
|
||||
private enabled = false;
|
||||
private shaderPath = "compute/visual-state-smoothing.wgsl";
|
||||
private mode = 0;
|
||||
private curveExp = 1;
|
||||
private boundUpdatesBuffer: GPUBuffer | null = null;
|
||||
private boundVisualStateTexture: GPUTexture | null = null;
|
||||
|
||||
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
|
||||
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
||||
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
|
||||
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
|
||||
|
||||
this.paramsBuffer = device.createBuffer({
|
||||
size: 32,
|
||||
usage: UNIFORM | COPY_DST,
|
||||
});
|
||||
|
||||
await this.setShader(this.shaderPath);
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
this.shaderPath = shaderPath;
|
||||
if (!this.device) {
|
||||
return;
|
||||
}
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.bindGroupLayout = this.device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
buffer: { type: "read-only-storage" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 4 /* COMPUTE */,
|
||||
storageTexture: { format: "r32uint" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.pipeline = this.device.createComputePipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
});
|
||||
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
setParams(params0: Float32Array | number[]): void {
|
||||
this.mode = Number(params0[0] ?? 0);
|
||||
this.curveExp = Number(params0[1] ?? 1);
|
||||
this.enabled = this.mode > 0;
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
if (!this.enabled || !this.resources) {
|
||||
return false;
|
||||
}
|
||||
return this.resources.getLastStateUpdateCount() > 0;
|
||||
}
|
||||
|
||||
execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
|
||||
if (!this.device || !this.pipeline || !this.paramsBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCount = resources.getLastStateUpdateCount();
|
||||
if (updateCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatesBuffer = resources.updatesBuffer;
|
||||
const visualStateTexture = resources.getVisualStateTexture();
|
||||
if (!updatesBuffer || !visualStateTexture) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paramsData[0] = this.mode;
|
||||
this.paramsData[1] = this.curveExp;
|
||||
this.paramsData[2] = 0;
|
||||
this.paramsData[3] = 0;
|
||||
this.paramsData[4] = updateCount;
|
||||
this.paramsData[5] = 0;
|
||||
this.paramsData[6] = 0;
|
||||
this.paramsData[7] = 0;
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
|
||||
|
||||
const shouldRebuild =
|
||||
!this.bindGroup ||
|
||||
this.boundUpdatesBuffer !== updatesBuffer ||
|
||||
this.boundVisualStateTexture !== visualStateTexture;
|
||||
if (shouldRebuild) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginComputePass();
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
const workgroupCount = Math.ceil(updateCount / 64);
|
||||
pass.dispatchWorkgroups(workgroupCount);
|
||||
pass.end();
|
||||
}
|
||||
|
||||
private rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.temporalUniformBuffer ||
|
||||
!this.paramsBuffer ||
|
||||
!this.resources.updatesBuffer ||
|
||||
!this.resources.getVisualStateTexture()
|
||||
) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const visualStateTexture = this.resources.getVisualStateTexture();
|
||||
if (!visualStateTexture) {
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.temporalUniformBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: { buffer: this.paramsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: { buffer: this.resources.updatesBuffer },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: visualStateTexture.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundUpdatesBuffer = this.resources.updatesBuffer;
|
||||
this.boundVisualStateTexture = visualStateTexture;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
this.paramsBuffer = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Utility for loading WGSL shader sources bundled by Vite.
|
||||
* Uses a static glob so production builds reliably include all shaders.
|
||||
*/
|
||||
|
||||
const shaderSources = import.meta.glob("../shaders/**/*.wgsl", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
export async function loadShader(path: string): Promise<string> {
|
||||
const key = `../shaders/${path}`;
|
||||
const src = shaderSources[key];
|
||||
if (!src) {
|
||||
throw new Error(`Missing WGSL shader source: ${key}`);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
@@ -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,218 @@
|
||||
import { GroundTruthData } from "../core/GroundTruthData";
|
||||
import { loadShader } from "../core/ShaderLoader";
|
||||
import { RenderPass } from "./RenderPass";
|
||||
|
||||
/**
|
||||
* Post-render temporal resolve pass. Blends current and history frames.
|
||||
*/
|
||||
export class TemporalResolvePass implements RenderPass {
|
||||
name = "temporal-resolve";
|
||||
dependencies: string[] = ["territory"];
|
||||
|
||||
private pipeline: GPURenderPipeline | null = null;
|
||||
private bindGroupLayout: GPUBindGroupLayout | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private device: GPUDevice | null = null;
|
||||
private resources: GroundTruthData | null = null;
|
||||
private canvasFormat: GPUTextureFormat | null = null;
|
||||
private paramsBuffer: GPUBuffer | null = null;
|
||||
private paramsData = new Float32Array(4);
|
||||
private enabled = false;
|
||||
private boundCurrentTexture: GPUTexture | null = null;
|
||||
private boundHistoryTexture: GPUTexture | null = null;
|
||||
|
||||
async init(
|
||||
device: GPUDevice,
|
||||
resources: GroundTruthData,
|
||||
canvasFormat: GPUTextureFormat,
|
||||
): Promise<void> {
|
||||
this.device = device;
|
||||
this.resources = resources;
|
||||
this.canvasFormat = canvasFormat;
|
||||
|
||||
const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
|
||||
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
|
||||
const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
|
||||
this.paramsBuffer = device.createBuffer({
|
||||
size: 16,
|
||||
usage: UNIFORM | COPY_DST,
|
||||
});
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.setShader("render/temporal-resolve.wgsl");
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.pipeline = this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
vertex: { module: shaderModule, entryPoint: "vsMain" },
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: "fsMain",
|
||||
targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }],
|
||||
},
|
||||
primitive: { topology: "triangle-list" },
|
||||
});
|
||||
}
|
||||
|
||||
setParams(params0: Float32Array | number[]): void {
|
||||
this.paramsData[0] = Number(params0[0] ?? 0);
|
||||
this.paramsData[1] = Number(params0[1] ?? 1);
|
||||
this.paramsData[2] = Number(params0[2] ?? 0.08);
|
||||
this.paramsData[3] = 0;
|
||||
this.enabled = this.paramsData[0] > 0;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
needsUpdate(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
execute(
|
||||
encoder: GPUCommandEncoder,
|
||||
resources: GroundTruthData,
|
||||
target: GPUTextureView,
|
||||
): void {
|
||||
if (!this.device || !this.pipeline || !this.paramsBuffer) {
|
||||
return;
|
||||
}
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTexture = resources.getCurrentColorTexture();
|
||||
const historyRead = resources.getHistoryReadTexture();
|
||||
const historyWrite = resources.getHistoryWriteTexture();
|
||||
if (!currentTexture || !historyRead || !historyWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
|
||||
|
||||
const shouldRebuild =
|
||||
!this.bindGroup ||
|
||||
this.boundCurrentTexture !== currentTexture ||
|
||||
this.boundHistoryTexture !== historyRead;
|
||||
if (shouldRebuild) {
|
||||
this.rebuildBindGroup();
|
||||
}
|
||||
|
||||
if (!this.bindGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = encoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: target,
|
||||
loadOp: "clear",
|
||||
storeOp: "store",
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
},
|
||||
{
|
||||
view: historyWrite.createView(),
|
||||
loadOp: "clear",
|
||||
storeOp: "store",
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.draw(3);
|
||||
pass.end();
|
||||
|
||||
resources.swapHistoryTextures();
|
||||
resources.markHistoryValid();
|
||||
}
|
||||
|
||||
rebuildBindGroup(): void {
|
||||
if (
|
||||
!this.device ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.resources ||
|
||||
!this.resources.temporalUniformBuffer ||
|
||||
!this.paramsBuffer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTexture = this.resources.getCurrentColorTexture();
|
||||
const historyRead = this.resources.getHistoryReadTexture();
|
||||
if (!currentTexture || !historyRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
resource: { buffer: this.resources.temporalUniformBuffer },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: { buffer: this.paramsBuffer },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: currentTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: historyRead.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.boundCurrentTexture = currentTexture;
|
||||
this.boundHistoryTexture = historyRead;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
this.paramsBuffer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
export type TerrainShaderId = "classic" | "improved-lite" | "improved-heavy";
|
||||
|
||||
export type TerrainShaderOption =
|
||||
| {
|
||||
kind: "boolean";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "range";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
| {
|
||||
kind: "enum";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
options: Array<{ value: number; label: string }>;
|
||||
};
|
||||
|
||||
export interface TerrainShaderDefinition {
|
||||
id: TerrainShaderId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerrainShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRAIN_SHADER_KEY = "settings.webgpu.terrain.shader";
|
||||
|
||||
export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
|
||||
{
|
||||
id: "classic",
|
||||
label: "Classic",
|
||||
wgslPath: "compute/terrain-compute.wgsl",
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "improved-lite",
|
||||
label: "Improved (Lite)",
|
||||
wgslPath: "compute/terrain-compute-improved-lite.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedLite.noiseStrength",
|
||||
label: "Noise Strength",
|
||||
defaultValue: 0.005,
|
||||
min: 0,
|
||||
max: 0.08,
|
||||
step: 0.005,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedLite.blendWidth",
|
||||
label: "Biome Blend Width",
|
||||
defaultValue: 5,
|
||||
min: 0.5,
|
||||
max: 5,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedLite.waterBlurStrength",
|
||||
label: "Water Blur Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "improved-heavy",
|
||||
label: "Improved (Heavy)",
|
||||
wgslPath: "compute/terrain-compute-improved-heavy.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.noiseStrength",
|
||||
label: "Noise Strength",
|
||||
defaultValue: 0.01,
|
||||
min: 0,
|
||||
max: 0.1,
|
||||
step: 0.005,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
|
||||
label: "Detail Noise Strength",
|
||||
defaultValue: 0.01,
|
||||
min: 0,
|
||||
max: 0.08,
|
||||
step: 0.005,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.blendWidth",
|
||||
label: "Biome Blend Width",
|
||||
defaultValue: 4.5,
|
||||
min: 0.5,
|
||||
max: 6,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.waterDepthStrength",
|
||||
label: "Water Depth Strength",
|
||||
defaultValue: 0.35,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.waterDepthCurve",
|
||||
label: "Water Depth Curve",
|
||||
defaultValue: 2,
|
||||
min: 0.5,
|
||||
max: 4,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.waterDepthBlur",
|
||||
label: "Water Depth Blur",
|
||||
defaultValue: 0.6,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.lightingStrength",
|
||||
label: "Lighting Strength",
|
||||
defaultValue: 0.3,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.terrain.improvedHeavy.cavityStrength",
|
||||
label: "Cavity Strength",
|
||||
defaultValue: 0.15,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getTerrainShaderById(
|
||||
id: TerrainShaderId,
|
||||
): TerrainShaderDefinition {
|
||||
const found = TERRAIN_SHADERS.find((s) => s.id === id);
|
||||
if (!found) {
|
||||
throw new Error(`Unknown terrain shader: ${id}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function terrainShaderIdFromInt(value: number): TerrainShaderId {
|
||||
if (value === 1) return "improved-lite";
|
||||
if (value === 2) return "improved-heavy";
|
||||
return "classic";
|
||||
}
|
||||
|
||||
export function terrainShaderIntFromId(id: TerrainShaderId): number {
|
||||
if (id === "improved-lite") return 1;
|
||||
if (id === "improved-heavy") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readTerrainShaderId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerrainShaderId {
|
||||
return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0));
|
||||
}
|
||||
|
||||
export function buildTerrainShaderParams(
|
||||
userSettings: {
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
shaderId: TerrainShaderId,
|
||||
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
|
||||
const waterDepthStrengthDefault = 0.4;
|
||||
const waterDepthCurveDefault = 2;
|
||||
const waterDepthBlurDefault = 0.6;
|
||||
|
||||
if (shaderId === "improved-lite") {
|
||||
const noiseStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedLite.noiseStrength",
|
||||
0.005,
|
||||
);
|
||||
const blendWidth = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedLite.blendWidth",
|
||||
5,
|
||||
);
|
||||
const waterBlurStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedLite.waterBlurStrength",
|
||||
1,
|
||||
);
|
||||
const params0 = new Float32Array([
|
||||
noiseStrength,
|
||||
blendWidth,
|
||||
waterBlurStrength,
|
||||
0,
|
||||
]);
|
||||
const params1 = new Float32Array([0, 0, 0, 0]);
|
||||
return {
|
||||
shaderPath: "compute/terrain-compute-improved-lite.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
|
||||
if (shaderId === "improved-heavy") {
|
||||
const noiseStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.noiseStrength",
|
||||
0.01,
|
||||
);
|
||||
const detailNoiseStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
|
||||
0.01,
|
||||
);
|
||||
const blendWidth = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.blendWidth",
|
||||
4.5,
|
||||
);
|
||||
const waterDepthStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.waterDepthStrength",
|
||||
0.35,
|
||||
);
|
||||
const waterDepthCurve = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.waterDepthCurve",
|
||||
2,
|
||||
);
|
||||
const waterDepthBlur = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.waterDepthBlur",
|
||||
0.6,
|
||||
);
|
||||
const lightingStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.lightingStrength",
|
||||
0.3,
|
||||
);
|
||||
const cavityStrength = userSettings.getFloat(
|
||||
"settings.webgpu.terrain.improvedHeavy.cavityStrength",
|
||||
0.15,
|
||||
);
|
||||
|
||||
const params0 = new Float32Array([
|
||||
noiseStrength,
|
||||
blendWidth,
|
||||
waterDepthStrength,
|
||||
waterDepthCurve,
|
||||
]);
|
||||
const params1 = new Float32Array([
|
||||
detailNoiseStrength,
|
||||
lightingStrength,
|
||||
cavityStrength,
|
||||
waterDepthBlur,
|
||||
]);
|
||||
return {
|
||||
shaderPath: "compute/terrain-compute-improved-heavy.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
|
||||
const params0 = new Float32Array([
|
||||
0,
|
||||
2.5,
|
||||
waterDepthStrengthDefault,
|
||||
waterDepthCurveDefault,
|
||||
]);
|
||||
const params1 = new Float32Array([waterDepthBlurDefault, 0, 0, 0]);
|
||||
return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
|
||||
|
||||
export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve";
|
||||
|
||||
export interface TerritoryPostSmoothingDefinition {
|
||||
id: TerritoryPostSmoothingId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerritoryShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRITORY_POST_SMOOTHING_KEY =
|
||||
"settings.webgpu.territory.smoothing.post";
|
||||
|
||||
export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
wgslPath: "",
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "fade",
|
||||
label: "Fade",
|
||||
wgslPath: "render/temporal-resolve.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
label: "Blend Strength",
|
||||
defaultValue: 0.2,
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dissolve",
|
||||
label: "Dissolve",
|
||||
wgslPath: "render/temporal-resolve.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
label: "Blend Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.postSmoothing.dissolveWidth",
|
||||
label: "Dissolve Width",
|
||||
defaultValue: 0.08,
|
||||
min: 0.01,
|
||||
max: 0.4,
|
||||
step: 0.01,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function territoryPostSmoothingIdFromInt(
|
||||
value: number,
|
||||
): TerritoryPostSmoothingId {
|
||||
if (value === 1) return "fade";
|
||||
if (value === 2) return "dissolve";
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function territoryPostSmoothingIntFromId(
|
||||
id: TerritoryPostSmoothingId,
|
||||
): number {
|
||||
if (id === "fade") return 1;
|
||||
if (id === "dissolve") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readTerritoryPostSmoothingId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerritoryPostSmoothingId {
|
||||
return territoryPostSmoothingIdFromInt(
|
||||
userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTerritoryPostSmoothingParams(
|
||||
userSettings: {
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
smoothingId: TerritoryPostSmoothingId,
|
||||
): {
|
||||
enabled: boolean;
|
||||
shaderPath: string;
|
||||
params0: Float32Array;
|
||||
params1: Float32Array;
|
||||
} {
|
||||
if (smoothingId === "off") {
|
||||
return {
|
||||
enabled: false,
|
||||
shaderPath: "",
|
||||
params0: new Float32Array(4),
|
||||
params1: new Float32Array(4),
|
||||
};
|
||||
}
|
||||
|
||||
const blendStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.postSmoothing.blendStrength",
|
||||
0.2,
|
||||
);
|
||||
const dissolveWidth = userSettings.getFloat(
|
||||
"settings.webgpu.territory.postSmoothing.dissolveWidth",
|
||||
0.08,
|
||||
);
|
||||
|
||||
const mode = smoothingId === "fade" ? 1 : 2;
|
||||
const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]);
|
||||
const params1 = new Float32Array([0, 0, 0, 0]);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
shaderPath: "render/temporal-resolve.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
|
||||
|
||||
export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget";
|
||||
|
||||
export interface TerritoryPreSmoothingDefinition {
|
||||
id: TerritoryPreSmoothingId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerritoryShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRITORY_PRE_SMOOTHING_KEY =
|
||||
"settings.webgpu.territory.smoothing.pre";
|
||||
|
||||
export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
wgslPath: "",
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: "dissolve",
|
||||
label: "Dissolve",
|
||||
wgslPath: "compute/visual-state-smoothing.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.preSmoothing.curveExp",
|
||||
label: "Reveal Curve",
|
||||
defaultValue: 1,
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
label: "Budgeted Reveal",
|
||||
wgslPath: "compute/visual-state-smoothing.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.preSmoothing.curveExp",
|
||||
label: "Reveal Curve",
|
||||
defaultValue: 1,
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function territoryPreSmoothingIdFromInt(
|
||||
value: number,
|
||||
): TerritoryPreSmoothingId {
|
||||
if (value === 1) return "dissolve";
|
||||
if (value === 2) return "budget";
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function territoryPreSmoothingIntFromId(
|
||||
id: TerritoryPreSmoothingId,
|
||||
): number {
|
||||
if (id === "dissolve") return 1;
|
||||
if (id === "budget") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function readTerritoryPreSmoothingId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerritoryPreSmoothingId {
|
||||
return territoryPreSmoothingIdFromInt(
|
||||
userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTerritoryPreSmoothingParams(
|
||||
userSettings: {
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
smoothingId: TerritoryPreSmoothingId,
|
||||
): {
|
||||
enabled: boolean;
|
||||
shaderPath: string;
|
||||
params0: Float32Array;
|
||||
params1: Float32Array;
|
||||
} {
|
||||
if (smoothingId === "off") {
|
||||
return {
|
||||
enabled: false,
|
||||
shaderPath: "",
|
||||
params0: new Float32Array(4),
|
||||
params1: new Float32Array(4),
|
||||
};
|
||||
}
|
||||
|
||||
const curveExp = userSettings.getFloat(
|
||||
"settings.webgpu.territory.preSmoothing.curveExp",
|
||||
1,
|
||||
);
|
||||
const mode = smoothingId === "dissolve" ? 1 : 2;
|
||||
|
||||
const params0 = new Float32Array([mode, curveExp, 0, 0]);
|
||||
const params1 = new Float32Array([0, 0, 0, 0]);
|
||||
return {
|
||||
enabled: true,
|
||||
shaderPath: "compute/visual-state-smoothing.wgsl",
|
||||
params0,
|
||||
params1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 shaderPath = "render/territory.wgsl";
|
||||
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;
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
{
|
||||
binding: 6,
|
||||
visibility: 2 /* FRAGMENT */,
|
||||
texture: { sampleType: "uint" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.setShader(this.shaderPath);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async setShader(shaderPath: string): Promise<void> {
|
||||
this.shaderPath = shaderPath;
|
||||
|
||||
if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderCode = await loadShader(shaderPath);
|
||||
const shaderModule = this.device.createShaderModule({ code: shaderCode });
|
||||
|
||||
this.pipeline = this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
}),
|
||||
vertex: { module: shaderModule, entryPoint: "vsMain" },
|
||||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: "fsMain",
|
||||
targets: [{ format: this.canvasFormat }],
|
||||
},
|
||||
primitive: { topology: "triangle-list" },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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.defendedStrengthTexture ||
|
||||
!this.resources.paletteTexture ||
|
||||
!this.resources.terrainTexture ||
|
||||
!this.resources.ownerIndexTexture ||
|
||||
!this.resources.relationsTexture
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateTexture = this.resources.getRenderStateTexture();
|
||||
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.resources.uniformBuffer } },
|
||||
{
|
||||
binding: 1,
|
||||
resource: stateTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: this.resources.defendedStrengthTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
resource: this.resources.paletteTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
resource: this.resources.terrainTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 5,
|
||||
resource: this.resources.ownerIndexTexture.createView(),
|
||||
},
|
||||
{
|
||||
binding: 6,
|
||||
resource: this.resources.relationsTexture.createView(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
this.bindGroup = null;
|
||||
this.device = null;
|
||||
this.resources = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
export type TerritoryShaderId = "classic" | "retro";
|
||||
|
||||
export type TerritoryShaderOption =
|
||||
| {
|
||||
kind: "boolean";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "range";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
| {
|
||||
kind: "enum";
|
||||
key: string;
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
options: Array<{ value: number; label: string }>;
|
||||
};
|
||||
|
||||
export interface TerritoryShaderDefinition {
|
||||
id: TerritoryShaderId;
|
||||
label: string;
|
||||
wgslPath: string;
|
||||
options: TerritoryShaderOption[];
|
||||
}
|
||||
|
||||
export const TERRITORY_SHADER_KEY = "settings.webgpu.territory.shader";
|
||||
|
||||
export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [
|
||||
{
|
||||
id: "classic",
|
||||
label: "Simple",
|
||||
wgslPath: "render/territory.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "enum",
|
||||
key: "settings.webgpu.territory.classic.borderMode",
|
||||
label: "Border Mode",
|
||||
defaultValue: 1,
|
||||
options: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 1, label: "Simple" },
|
||||
{ value: 2, label: "Glow" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.classic.thicknessPx",
|
||||
label: "Thickness (px)",
|
||||
defaultValue: 1,
|
||||
min: 0.5,
|
||||
max: 8,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.classic.borderStrength",
|
||||
label: "Border Strength",
|
||||
defaultValue: 0.64,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.classic.glowStrength",
|
||||
label: "Glow Strength",
|
||||
defaultValue: 0.42,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.classic.glowRadiusMul",
|
||||
label: "Glow Radius",
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.classic.drawDefendedRadius",
|
||||
label: "Draw Defended Radius",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.classic.disableDefendedTint",
|
||||
label: "Disable Defended Tint",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "retro",
|
||||
label: "Retro",
|
||||
wgslPath: "render/retro.wgsl",
|
||||
options: [
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.retro.colorByRelations",
|
||||
label: "Color By Player Relations",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.retro.patternWhenDefended",
|
||||
label: "Pattern When In Defended Range",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.retro.splitBorder",
|
||||
label: "Split Border",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.retro.drawDefendedRadius",
|
||||
label: "Draw Defended Radius",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
kind: "boolean",
|
||||
key: "settings.webgpu.territory.retro.disableDefendedTint",
|
||||
label: "Disable Defended Tint",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.thicknessPx",
|
||||
label: "Thickness (px)",
|
||||
defaultValue: 6,
|
||||
min: 0.5,
|
||||
max: 12,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.borderStrength",
|
||||
label: "Border Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.glowStrength",
|
||||
label: "Glow Strength",
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.glowRadiusMul",
|
||||
label: "Glow Radius",
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
max: 16,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.relationTintStrength",
|
||||
label: "Relation Tint Strength",
|
||||
defaultValue: 1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.defendedPatternStrength",
|
||||
label: "Defended Pattern Strength",
|
||||
defaultValue: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
kind: "range",
|
||||
key: "settings.webgpu.territory.retro.defendedThreshold",
|
||||
label: "Defended Threshold",
|
||||
defaultValue: 0.01,
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getTerritoryShaderById(
|
||||
id: TerritoryShaderId,
|
||||
): TerritoryShaderDefinition {
|
||||
const found = TERRITORY_SHADERS.find((s) => s.id === id);
|
||||
if (!found) {
|
||||
throw new Error(`Unknown territory shader: ${id}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function territoryShaderIdFromInt(value: number): TerritoryShaderId {
|
||||
return value === 1 ? "retro" : "classic";
|
||||
}
|
||||
|
||||
export function territoryShaderIntFromId(id: TerritoryShaderId): number {
|
||||
return id === "retro" ? 1 : 0;
|
||||
}
|
||||
|
||||
export function readTerritoryShaderId(userSettings: {
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
}): TerritoryShaderId {
|
||||
return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0));
|
||||
}
|
||||
|
||||
export function buildTerritoryShaderParams(
|
||||
userSettings: {
|
||||
get: (key: string, defaultValue: boolean) => boolean;
|
||||
getFloat: (key: string, defaultValue: number) => number;
|
||||
getInt: (key: string, defaultValue: number) => number;
|
||||
},
|
||||
shaderId: TerritoryShaderId,
|
||||
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
|
||||
if (shaderId === "retro") {
|
||||
const thicknessPx = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.thicknessPx",
|
||||
6,
|
||||
);
|
||||
const borderStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.borderStrength",
|
||||
1,
|
||||
);
|
||||
const glowStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.glowStrength",
|
||||
0,
|
||||
);
|
||||
const glowRadiusMul = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.glowRadiusMul",
|
||||
1,
|
||||
);
|
||||
|
||||
const colorByRelations = userSettings.get(
|
||||
"settings.webgpu.territory.retro.colorByRelations",
|
||||
true,
|
||||
);
|
||||
const patternWhenDefended = userSettings.get(
|
||||
"settings.webgpu.territory.retro.patternWhenDefended",
|
||||
true,
|
||||
);
|
||||
const splitBorder = userSettings.get(
|
||||
"settings.webgpu.territory.retro.splitBorder",
|
||||
true,
|
||||
);
|
||||
const drawDefendedRadius = userSettings.get(
|
||||
"settings.webgpu.territory.retro.drawDefendedRadius",
|
||||
true,
|
||||
);
|
||||
const disableDefendedTint = userSettings.get(
|
||||
"settings.webgpu.territory.retro.disableDefendedTint",
|
||||
true,
|
||||
);
|
||||
const relationTintStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.relationTintStrength",
|
||||
1,
|
||||
);
|
||||
const defendedPatternStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.defendedPatternStrength",
|
||||
0.5,
|
||||
);
|
||||
const defendedThreshold = userSettings.getFloat(
|
||||
"settings.webgpu.territory.retro.defendedThreshold",
|
||||
0.01,
|
||||
);
|
||||
|
||||
let flags = 0;
|
||||
if (colorByRelations) flags |= 1 << 0;
|
||||
if (patternWhenDefended) flags |= 1 << 1;
|
||||
if (splitBorder) flags |= 1 << 2;
|
||||
if (drawDefendedRadius) flags |= 1 << 3;
|
||||
if (disableDefendedTint) flags |= 1 << 4;
|
||||
|
||||
const params0 = new Float32Array([
|
||||
thicknessPx,
|
||||
borderStrength,
|
||||
glowStrength,
|
||||
glowRadiusMul,
|
||||
]);
|
||||
const params1 = new Float32Array([
|
||||
flags,
|
||||
relationTintStrength,
|
||||
defendedPatternStrength,
|
||||
defendedThreshold,
|
||||
]);
|
||||
|
||||
return { shaderPath: "render/retro.wgsl", params0, params1 };
|
||||
}
|
||||
|
||||
const borderMode = userSettings.getInt(
|
||||
"settings.webgpu.territory.classic.borderMode",
|
||||
1,
|
||||
);
|
||||
const thicknessPx = userSettings.getFloat(
|
||||
"settings.webgpu.territory.classic.thicknessPx",
|
||||
1,
|
||||
);
|
||||
const borderStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.classic.borderStrength",
|
||||
0.64,
|
||||
);
|
||||
const glowStrength = userSettings.getFloat(
|
||||
"settings.webgpu.territory.classic.glowStrength",
|
||||
0.42,
|
||||
);
|
||||
const glowRadiusMul = userSettings.getFloat(
|
||||
"settings.webgpu.territory.classic.glowRadiusMul",
|
||||
1,
|
||||
);
|
||||
const drawDefendedRadius = userSettings.get(
|
||||
"settings.webgpu.territory.classic.drawDefendedRadius",
|
||||
false,
|
||||
);
|
||||
const disableDefendedTint = userSettings.get(
|
||||
"settings.webgpu.territory.classic.disableDefendedTint",
|
||||
false,
|
||||
);
|
||||
|
||||
const params0 = new Float32Array([
|
||||
borderMode,
|
||||
thicknessPx,
|
||||
borderStrength,
|
||||
glowStrength,
|
||||
]);
|
||||
const params1 = new Float32Array([
|
||||
glowRadiusMul,
|
||||
drawDefendedRadius ? 1 : 0,
|
||||
disableDefendedTint ? 1 : 0,
|
||||
0,
|
||||
]);
|
||||
return { shaderPath: "render/territory.wgsl", params0, params1 };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
struct Params {
|
||||
_dirtyCount: u32,
|
||||
range: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(3) var<storage, read> ownerOffsets: array<vec2u>;
|
||||
@group(0) @binding(4) var<storage, read> postsByOwner: array<vec2u>;
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let dims = textureDimensions(stateTex);
|
||||
if (globalId.x >= dims.x || globalId.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = i32(globalId.x);
|
||||
let y = i32(globalId.y);
|
||||
let state = textureLoad(stateTex, vec2i(x, y), 0).x;
|
||||
let owner = state & 0xFFFu;
|
||||
|
||||
let range = i32(p.range);
|
||||
if (owner == 0u || range <= 0) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let off = ownerOffsets[owner];
|
||||
let start = off.x;
|
||||
let count = off.y;
|
||||
if (count == 0u) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = f32(range);
|
||||
let r2 = range * range;
|
||||
var bestDist2: i32 = 0x7FFFFFFF;
|
||||
var i: u32 = 0u;
|
||||
loop {
|
||||
if (i >= count) { break; }
|
||||
let pos = postsByOwner[start + i];
|
||||
let dx = i32(pos.x) - x;
|
||||
let dy = i32(pos.y) - y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < bestDist2) {
|
||||
bestDist2 = d2;
|
||||
}
|
||||
i = i + 1u;
|
||||
}
|
||||
|
||||
if (bestDist2 > r2) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let dist = sqrt(f32(bestDist2));
|
||||
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
struct Params {
|
||||
dirtyCount: u32,
|
||||
range: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var<storage, read> dirtyTiles: array<u32>;
|
||||
@group(0) @binding(2) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
|
||||
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let idx = globalId.x;
|
||||
if (idx >= p.dirtyCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tileIndex = dirtyTiles[idx];
|
||||
let dims = textureDimensions(stateTex);
|
||||
let mapWidth = dims.x;
|
||||
let x = i32(tileIndex % mapWidth);
|
||||
let y = i32(tileIndex / mapWidth);
|
||||
|
||||
let state = textureLoad(stateTex, vec2i(x, y), 0).x;
|
||||
let owner = state & 0xFFFu;
|
||||
let range = i32(p.range);
|
||||
if (owner == 0u || range <= 0) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let off = ownerOffsets[owner];
|
||||
let start = off.x;
|
||||
let count = off.y;
|
||||
if (count == 0u) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = f32(range);
|
||||
let r2 = range * range;
|
||||
var bestDist2: i32 = 0x7FFFFFFF;
|
||||
var i: u32 = 0u;
|
||||
loop {
|
||||
if (i >= count) { break; }
|
||||
let pos = postsByOwner[start + i];
|
||||
let dx = i32(pos.x) - x;
|
||||
let dy = i32(pos.y) - y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < bestDist2) {
|
||||
bestDist2 = d2;
|
||||
}
|
||||
i = i + 1u;
|
||||
}
|
||||
|
||||
if (bestDist2 > r2) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let dist = sqrt(f32(bestDist2));
|
||||
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
struct Update {
|
||||
tileIndex: u32,
|
||||
newState: u32,
|
||||
};
|
||||
|
||||
struct Params {
|
||||
updateCount: u32,
|
||||
range: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var<storage, read> updates: array<Update>;
|
||||
@group(0) @binding(2) var stateTex: texture_storage_2d<r32uint, write>;
|
||||
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
|
||||
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let idx = globalId.x;
|
||||
if (idx >= p.updateCount) {
|
||||
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));
|
||||
|
||||
// Update defended strength for this tile based on the new owner.
|
||||
let owner = update.newState & 0xFFFu;
|
||||
let range = i32(p.range);
|
||||
if (owner == 0u || range <= 0) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let off = ownerOffsets[owner];
|
||||
let start = off.x;
|
||||
let count = off.y;
|
||||
if (count == 0u) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = f32(range);
|
||||
let r2 = range * range;
|
||||
var bestDist2: i32 = 0x7FFFFFFF;
|
||||
var i: u32 = 0u;
|
||||
loop {
|
||||
if (i >= count) { break; }
|
||||
let pos = postsByOwner[start + i];
|
||||
let dx = i32(pos.x) - x;
|
||||
let dy = i32(pos.y) - y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < bestDist2) {
|
||||
bestDist2 = d2;
|
||||
}
|
||||
i = i + 1u;
|
||||
}
|
||||
|
||||
if (bestDist2 > r2) {
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
let dist = sqrt(f32(bestDist2));
|
||||
let strength = clamp(1.0 - (dist / rx), 0.0, 1.0);
|
||||
textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
struct TerrainParams {
|
||||
shoreColor: vec4f, // Shore (land adjacent to water)
|
||||
waterColor: vec4f, // Deep water base color
|
||||
shorelineWaterColor: vec4f, // Water near shore
|
||||
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
|
||||
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
|
||||
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
|
||||
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterDepthStrength, w=waterDepthCurve
|
||||
tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=waterDepthBlur
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: TerrainParams;
|
||||
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
// Terrain bit constants (matching GameMapImpl)
|
||||
const IS_LAND_BIT: u32 = 7u;
|
||||
const SHORELINE_BIT: u32 = 6u;
|
||||
const MAGNITUDE_MASK: u32 = 0x1fu;
|
||||
|
||||
fn hash21(p: vec2u) -> f32 {
|
||||
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
|
||||
n ^= n >> 16u;
|
||||
n *= 0x85ebca6bu;
|
||||
n ^= n >> 13u;
|
||||
n *= 0xc2b2ae35u;
|
||||
n ^= n >> 16u;
|
||||
return f32(n & 0x00ffffffu) / 16777215.0;
|
||||
}
|
||||
|
||||
fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
|
||||
let maxX = i32(dims.x) - 1;
|
||||
let maxY = i32(dims.y) - 1;
|
||||
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
|
||||
}
|
||||
|
||||
fn sampleTerrainData(coord: vec2i, dims: vec2u) -> u32 {
|
||||
let c = clampCoord(coord, dims);
|
||||
return textureLoad(terrainDataTex, c, 0).x;
|
||||
}
|
||||
|
||||
fn computeLandColor(
|
||||
mag: f32,
|
||||
noise: f32,
|
||||
noiseStrength: f32,
|
||||
blendWidth: f32,
|
||||
) -> vec3f {
|
||||
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
|
||||
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
|
||||
|
||||
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
|
||||
let highland = vec3f(
|
||||
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
);
|
||||
|
||||
let mountainMag = max(mag - 20.0, 0.0);
|
||||
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
|
||||
let mountain = vec3f(gray, gray, gray);
|
||||
|
||||
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
|
||||
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
|
||||
var land = mix(plains, highland, tHigh);
|
||||
land = mix(land, mountain, tMount);
|
||||
|
||||
let noiseBias = (noise - 0.5) * noiseStrength;
|
||||
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let x = i32(globalId.x);
|
||||
let y = i32(globalId.y);
|
||||
let dims = textureDimensions(terrainDataTex);
|
||||
|
||||
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
let texCoord = vec2i(x, y);
|
||||
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
|
||||
|
||||
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
|
||||
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
|
||||
let magnitude = terrainData & MAGNITUDE_MASK;
|
||||
let mag = f32(magnitude);
|
||||
|
||||
let noise = hash21(vec2u(texCoord));
|
||||
let noiseFine = hash21(vec2u(texCoord) * 3u + vec2u(17u, 29u));
|
||||
let noiseStrength = max(params.tuning0.x, 0.0);
|
||||
let blendWidth = max(params.tuning0.y, 0.1);
|
||||
let waterDepthStrength = clamp(params.tuning0.z, 0.0, 1.0);
|
||||
let waterDepthCurve = max(params.tuning0.w, 0.1);
|
||||
let detailNoiseStrength = max(params.tuning1.x, 0.0);
|
||||
let lightingStrength = clamp(params.tuning1.y, 0.0, 1.0);
|
||||
let cavityStrength = clamp(params.tuning1.z, 0.0, 1.0);
|
||||
let waterDepthBlur = clamp(params.tuning1.w, 0.0, 1.0);
|
||||
let shoreMixLand = 0.6;
|
||||
let shoreMixWater = 0.55;
|
||||
let specularStrength = 0.05;
|
||||
|
||||
let hC = mag / 31.0;
|
||||
let dataL = sampleTerrainData(texCoord + vec2i(-1, 0), dims);
|
||||
let dataR = sampleTerrainData(texCoord + vec2i(1, 0), dims);
|
||||
let dataD = sampleTerrainData(texCoord + vec2i(0, -1), dims);
|
||||
let dataU = sampleTerrainData(texCoord + vec2i(0, 1), dims);
|
||||
|
||||
let magL = f32(dataL & MAGNITUDE_MASK);
|
||||
let magR = f32(dataR & MAGNITUDE_MASK);
|
||||
let magD = f32(dataD & MAGNITUDE_MASK);
|
||||
let magU = f32(dataU & MAGNITUDE_MASK);
|
||||
|
||||
let hL = magL / 31.0;
|
||||
let hR = magR / 31.0;
|
||||
let hD = magD / 31.0;
|
||||
let hU = magU / 31.0;
|
||||
|
||||
let dx = hR - hL;
|
||||
let dy = hU - hD;
|
||||
let normal = normalize(vec3f(-dx * 2.2, -dy * 2.2, 1.0));
|
||||
let lightDir = normalize(vec3f(0.55, 0.45, 1.0));
|
||||
let diffuse = clamp(dot(normal, lightDir), 0.0, 1.0);
|
||||
let baseLighting = 0.55 + 0.45 * diffuse;
|
||||
let lighting = mix(1.0, baseLighting, lightingStrength);
|
||||
|
||||
let slope = length(vec2f(dx, dy));
|
||||
let rockiness = smoothstep(0.08, 0.28, slope);
|
||||
|
||||
let cavity = clamp(((hL + hR + hD + hU) * 0.25 - hC) * 2.0, 0.0, 0.25);
|
||||
|
||||
var color: vec4f;
|
||||
|
||||
if (isLand) {
|
||||
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
|
||||
|
||||
if (isShoreline) {
|
||||
land = mix(land, params.shoreColor.rgb, shoreMixLand);
|
||||
}
|
||||
|
||||
land = mix(land, params.mountainBaseColor.rgb, rockiness * 0.6);
|
||||
|
||||
land = clamp(land * lighting, vec3f(0.0), vec3f(1.0));
|
||||
land = clamp(land * (1.0 - cavity * cavityStrength), vec3f(0.0), vec3f(1.0));
|
||||
land = clamp(
|
||||
land + vec3f((noiseFine - 0.5) * detailNoiseStrength),
|
||||
vec3f(0.0),
|
||||
vec3f(1.0),
|
||||
);
|
||||
|
||||
color = vec4f(land, 1.0);
|
||||
} else {
|
||||
var sum = mag;
|
||||
var count = 1.0;
|
||||
if ((dataL & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + magL;
|
||||
count = count + 1.0;
|
||||
}
|
||||
if ((dataR & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + magR;
|
||||
count = count + 1.0;
|
||||
}
|
||||
if ((dataD & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + magD;
|
||||
count = count + 1.0;
|
||||
}
|
||||
if ((dataU & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + magU;
|
||||
count = count + 1.0;
|
||||
}
|
||||
|
||||
let avgMag = sum / count;
|
||||
let smoothMag = mix(mag, avgMag, waterDepthBlur);
|
||||
let depth01 = clamp(smoothMag / 10.0, 0.0, 1.0);
|
||||
let depth = clamp(pow(depth01, waterDepthCurve), 0.0, 1.0);
|
||||
let depthColor = mix(
|
||||
params.shorelineWaterColor.rgb,
|
||||
params.waterColor.rgb,
|
||||
depth,
|
||||
);
|
||||
var water = mix(params.waterColor.rgb, depthColor, waterDepthStrength);
|
||||
let noiseBias = (noise - 0.5) * (noiseStrength * 0.6);
|
||||
water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
|
||||
|
||||
if (isShoreline) {
|
||||
water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater);
|
||||
}
|
||||
|
||||
let viewDir = vec3f(0.0, 0.0, 1.0);
|
||||
let spec = pow(max(dot(reflect(-lightDir, normal), viewDir), 0.0), 24.0);
|
||||
water = clamp(
|
||||
water + vec3f(spec * specularStrength),
|
||||
vec3f(0.0),
|
||||
vec3f(1.0),
|
||||
);
|
||||
|
||||
color = vec4f(water, 1.0);
|
||||
}
|
||||
|
||||
textureStore(terrainTex, texCoord, color);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
struct TerrainParams {
|
||||
shoreColor: vec4f, // Shore (land adjacent to water)
|
||||
waterColor: vec4f, // Deep water base color
|
||||
shorelineWaterColor: vec4f, // Water near shore
|
||||
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
|
||||
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
|
||||
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
|
||||
tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterBlurStrength, w=unused
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: TerrainParams;
|
||||
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
// Terrain bit constants (matching GameMapImpl)
|
||||
const IS_LAND_BIT: u32 = 7u;
|
||||
const SHORELINE_BIT: u32 = 6u;
|
||||
const MAGNITUDE_MASK: u32 = 0x1fu;
|
||||
|
||||
fn hash21(p: vec2u) -> f32 {
|
||||
var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u;
|
||||
n ^= n >> 16u;
|
||||
n *= 0x85ebca6bu;
|
||||
n ^= n >> 13u;
|
||||
n *= 0xc2b2ae35u;
|
||||
n ^= n >> 16u;
|
||||
return f32(n & 0x00ffffffu) / 16777215.0;
|
||||
}
|
||||
|
||||
fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
|
||||
let maxX = i32(dims.x) - 1;
|
||||
let maxY = i32(dims.y) - 1;
|
||||
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
|
||||
}
|
||||
|
||||
fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -> vec3f {
|
||||
let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0);
|
||||
let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b);
|
||||
|
||||
let highlandMag = clamp(mag - 10.0, 0.0, 9.0);
|
||||
let highland = vec3f(
|
||||
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
);
|
||||
|
||||
let mountainMag = max(mag - 20.0, 0.0);
|
||||
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
|
||||
let mountain = vec3f(gray, gray, gray);
|
||||
|
||||
let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag);
|
||||
let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag);
|
||||
var land = mix(plains, highland, tHigh);
|
||||
land = mix(land, mountain, tMount);
|
||||
|
||||
let noiseBias = (noise - 0.5) * noiseStrength;
|
||||
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let x = i32(globalId.x);
|
||||
let y = i32(globalId.y);
|
||||
let dims = textureDimensions(terrainDataTex);
|
||||
|
||||
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
let texCoord = vec2i(x, y);
|
||||
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
|
||||
|
||||
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
|
||||
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
|
||||
let magnitude = terrainData & MAGNITUDE_MASK;
|
||||
let mag = f32(magnitude);
|
||||
|
||||
let noise = hash21(vec2u(texCoord));
|
||||
let noiseStrength = max(params.tuning0.x, 0.0);
|
||||
let blendWidth = max(params.tuning0.y, 0.1);
|
||||
let waterDepthBlur = clamp(params.tuning0.z, 0.0, 1.0);
|
||||
let shoreMixLand = 0.6;
|
||||
var color: vec4f;
|
||||
|
||||
if (isLand) {
|
||||
var land = computeLandColor(mag, noise, noiseStrength, blendWidth);
|
||||
if (isShoreline) {
|
||||
land = mix(land, params.shoreColor.rgb, shoreMixLand);
|
||||
}
|
||||
color = vec4f(land, 1.0);
|
||||
} else {
|
||||
if (isShoreline) {
|
||||
color = vec4f(params.shorelineWaterColor.rgb, 1.0);
|
||||
textureStore(terrainTex, texCoord, color);
|
||||
return;
|
||||
}
|
||||
|
||||
var sum = mag;
|
||||
var count = 1.0;
|
||||
let dataL = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(-1, 0), dims), 0).x;
|
||||
if ((dataL & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + f32(dataL & MAGNITUDE_MASK);
|
||||
count = count + 1.0;
|
||||
}
|
||||
let dataR = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(1, 0), dims), 0).x;
|
||||
if ((dataR & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + f32(dataR & MAGNITUDE_MASK);
|
||||
count = count + 1.0;
|
||||
}
|
||||
let dataD = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, -1), dims), 0).x;
|
||||
if ((dataD & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + f32(dataD & MAGNITUDE_MASK);
|
||||
count = count + 1.0;
|
||||
}
|
||||
let dataU = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, 1), dims), 0).x;
|
||||
if ((dataU & (1u << IS_LAND_BIT)) == 0u) {
|
||||
sum = sum + f32(dataU & MAGNITUDE_MASK);
|
||||
count = count + 1.0;
|
||||
}
|
||||
|
||||
let avgMag = sum / count;
|
||||
let smoothMag = mix(mag, avgMag, waterDepthBlur);
|
||||
let magClamped = min(smoothMag, 10.0);
|
||||
let adjustment = (1.0 - magClamped) / 255.0;
|
||||
let water = vec3f(
|
||||
max(params.waterColor.r + adjustment, 0.0),
|
||||
max(params.waterColor.g + adjustment, 0.0),
|
||||
max(params.waterColor.b + adjustment, 0.0),
|
||||
);
|
||||
color = vec4f(water, 1.0);
|
||||
}
|
||||
|
||||
textureStore(terrainTex, texCoord, color);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
struct TerrainParams {
|
||||
shoreColor: vec4f, // Shore (land adjacent to water)
|
||||
waterColor: vec4f, // Deep water base color
|
||||
shorelineWaterColor: vec4f, // Water near shore
|
||||
plainsBaseColor: vec4f, // Plains base RGB (magnitude 0)
|
||||
highlandBaseColor: vec4f, // Highland base RGB (magnitude 10)
|
||||
mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20)
|
||||
tuning0: vec4f, // Shader tuning params (unused in classic)
|
||||
tuning1: vec4f, // Shader tuning params (unused in classic)
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: TerrainParams;
|
||||
@group(0) @binding(1) var terrainDataTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var terrainTex: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
// Terrain bit constants (matching GameMapImpl)
|
||||
const IS_LAND_BIT: u32 = 7u;
|
||||
const SHORELINE_BIT: u32 = 6u;
|
||||
const OCEAN_BIT: u32 = 5u;
|
||||
const MAGNITUDE_MASK: u32 = 0x1fu;
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let x = i32(globalId.x);
|
||||
let y = i32(globalId.y);
|
||||
let dims = textureDimensions(terrainDataTex);
|
||||
|
||||
if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
let texCoord = vec2i(x, y);
|
||||
let terrainData = textureLoad(terrainDataTex, texCoord, 0).x;
|
||||
|
||||
// Extract terrain bits
|
||||
let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u;
|
||||
let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u;
|
||||
let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u;
|
||||
let magnitude = terrainData & MAGNITUDE_MASK;
|
||||
let mag = f32(magnitude);
|
||||
|
||||
var color: vec4f;
|
||||
|
||||
// Check if shore (land adjacent to water)
|
||||
if (isLand && isShoreline) {
|
||||
color = params.shoreColor;
|
||||
} else if (!isLand) {
|
||||
// Water tile
|
||||
if (isShoreline) {
|
||||
color = params.shorelineWaterColor;
|
||||
} else {
|
||||
// Deep water - color varies by magnitude
|
||||
// CPU formula: waterColor - 10 + (11 - min(mag, 10))
|
||||
// In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0
|
||||
// Simplified: waterColor + (1 - min(mag, 10)) / 255.0
|
||||
let magClamped = min(mag, 10.0);
|
||||
let adjustment = (1.0 - magClamped) / 255.0;
|
||||
color = vec4f(
|
||||
max(params.waterColor.r + adjustment, 0.0),
|
||||
max(params.waterColor.g + adjustment, 0.0),
|
||||
max(params.waterColor.b + adjustment, 0.0),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Land tile - determine terrain type from magnitude
|
||||
// CPU formulas:
|
||||
// Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9
|
||||
// Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19
|
||||
// Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20
|
||||
//
|
||||
// We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255
|
||||
// We sampled highland at some mag 10-19, need to compute from mag 10
|
||||
if (magnitude < 10u) {
|
||||
// Plains: rgb(190, 220 - 2*mag, 138)
|
||||
color = vec4f(
|
||||
params.plainsBaseColor.r, // 190/255
|
||||
max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255
|
||||
params.plainsBaseColor.b, // 138/255
|
||||
1.0
|
||||
);
|
||||
} else if (magnitude < 20u) {
|
||||
// Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag)
|
||||
// We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255
|
||||
// For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255
|
||||
let highlandMag = mag - 10.0;
|
||||
color = vec4f(
|
||||
min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0),
|
||||
1.0
|
||||
);
|
||||
} else {
|
||||
// Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2)
|
||||
// We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel
|
||||
// For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255
|
||||
let mountainMag = mag - 20.0;
|
||||
let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0);
|
||||
color = vec4f(gray, gray, gray, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
textureStore(terrainTex, texCoord, color);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
struct Temporal {
|
||||
nowSec: f32,
|
||||
lastTickSec: f32,
|
||||
tickDtSec: f32,
|
||||
tickDtEmaSec: f32,
|
||||
tickAlpha: f32,
|
||||
tickCount: f32,
|
||||
historyValid: f32,
|
||||
_pad0: f32,
|
||||
};
|
||||
|
||||
struct Params {
|
||||
params0: vec4f, // x=mode, y=curveExp
|
||||
params1: vec4f, // x=updateCount
|
||||
};
|
||||
|
||||
struct Update {
|
||||
tileIndex: u32,
|
||||
newState: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> t: Temporal;
|
||||
@group(0) @binding(1) var<uniform> p: Params;
|
||||
@group(0) @binding(2) var<storage, read> updates: array<Update>;
|
||||
@group(0) @binding(3) var visualStateTex: texture_storage_2d<r32uint, write>;
|
||||
|
||||
fn hashUint(x: u32) -> u32 {
|
||||
var h = x * 1664525u + 1013904223u;
|
||||
h ^= h >> 16u;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13u;
|
||||
h *= 3266489917u;
|
||||
h ^= h >> 16u;
|
||||
return h;
|
||||
}
|
||||
|
||||
fn hashToUnitFloat(x: u32) -> f32 {
|
||||
return f32(x & 0x00FFFFFFu) / 16777216.0;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
|
||||
let idx = globalId.x;
|
||||
let updateCount = u32(max(0.0, p.params1.x) + 0.5);
|
||||
if (idx >= updateCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = u32(max(0.0, p.params0.x) + 0.5);
|
||||
let curveExp = max(0.001, p.params0.y);
|
||||
let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0);
|
||||
|
||||
let update = updates[idx];
|
||||
|
||||
if (mode == 1u) {
|
||||
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
|
||||
let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u));
|
||||
let r = hashToUnitFloat(h);
|
||||
if (r > alpha) {
|
||||
return;
|
||||
}
|
||||
} else if (mode == 2u) {
|
||||
let targetCount = u32(floor(f32(updateCount) * alpha));
|
||||
if (idx >= targetCount) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let dims = textureDimensions(visualStateTex);
|
||||
let mapWidth = dims.x;
|
||||
let x = i32(update.tileIndex % mapWidth);
|
||||
let y = i32(update.tileIndex / mapWidth);
|
||||
textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u));
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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=myPlayerSmallId, w unused
|
||||
shaderParams0: vec4f, // x=thicknessPx, y=borderStrength, z=glowStrength, w=glowRadiusMul
|
||||
shaderParams1: vec4f, // x=flags, y=relationTintStrength, z=defendedPatternStrength, w=defendedThreshold
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var defendedStrengthTex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var paletteTex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var terrainTex: texture_2d<f32>;
|
||||
@group(0) @binding(5) var ownerIndexTex: texture_2d<u32>;
|
||||
@group(0) @binding(6) var relationsTex: texture_2d<u32>;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
fn hasFlag(flags: u32, bit: u32) -> bool {
|
||||
return (flags & (1u << bit)) != 0u;
|
||||
}
|
||||
|
||||
fn relationCode(ownerA: u32, ownerB: u32) -> u32 {
|
||||
if (ownerA == 0u || ownerB == 0u) {
|
||||
return 0u;
|
||||
}
|
||||
let aDense = textureLoad(ownerIndexTex, vec2i(i32(ownerA), 0), 0).x;
|
||||
let bDense = textureLoad(ownerIndexTex, vec2i(i32(ownerB), 0), 0).x;
|
||||
if (aDense == 0u || bDense == 0u) {
|
||||
return 0u;
|
||||
}
|
||||
return textureLoad(relationsTex, vec2i(i32(aDense), i32(bDense)), 0).x;
|
||||
}
|
||||
|
||||
fn applyDefendedPattern(
|
||||
baseRgb: vec3f,
|
||||
strength: f32,
|
||||
texCoord: vec2i,
|
||||
) -> vec3f {
|
||||
let parity = (u32(texCoord.x) ^ u32(texCoord.y)) & 1u;
|
||||
let factor = select(0.75, 1.25, parity == 1u);
|
||||
let patterned = clamp(baseRgb * factor, vec3f(0.0), vec3f(1.0));
|
||||
return mix(baseRgb, patterned, clamp(strength, 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 myPlayerSmallId = u.viewSize_pad.z;
|
||||
|
||||
let thicknessPx = u.shaderParams0.x;
|
||||
let borderStrength = u.shaderParams0.y;
|
||||
let glowStrength = u.shaderParams0.z;
|
||||
let glowRadiusMul = u.shaderParams0.w;
|
||||
|
||||
let flags = u32(max(0.0, u.shaderParams1.x) + 0.5);
|
||||
let relationTintStrength = u.shaderParams1.y;
|
||||
let defendedPatternStrength = u.shaderParams1.z;
|
||||
let defendedThreshold = u.shaderParams1.w;
|
||||
|
||||
let enableRelations = hasFlag(flags, 0u);
|
||||
let enableDefendedPattern = hasFlag(flags, 1u);
|
||||
let enableSplit = hasFlag(flags, 2u);
|
||||
let drawDefendedRadius = hasFlag(flags, 3u);
|
||||
let disableDefendedTint = hasFlag(flags, 4u);
|
||||
|
||||
// WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...).
|
||||
let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5);
|
||||
let mapHalf = mapRes * 0.5;
|
||||
let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf;
|
||||
|
||||
if (
|
||||
mapCoord.x < 0.0 ||
|
||||
mapCoord.y < 0.0 ||
|
||||
mapCoord.x >= mapRes.x ||
|
||||
mapCoord.y >= mapRes.y
|
||||
) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let texCoord = vec2i(mapCoord);
|
||||
let state = textureLoad(stateTex, texCoord, 0).x;
|
||||
let owner = state & 0xFFFu;
|
||||
let hasFallout = (state & 0x2000u) != 0u;
|
||||
|
||||
let terrain = textureLoad(terrainTex, texCoord, 0);
|
||||
let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x;
|
||||
|
||||
var outColor = terrain;
|
||||
if (owner != 0u) {
|
||||
// Player colors start at index 10
|
||||
let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0);
|
||||
var territoryRgb = c.rgb;
|
||||
if (!disableDefendedTint) {
|
||||
let defendedTint = select(
|
||||
0.0,
|
||||
clamp(0.8 * defendedStrength, 0.1, 0.35),
|
||||
defendedStrength > 0.001,
|
||||
);
|
||||
territoryRgb = mix(
|
||||
territoryRgb,
|
||||
vec3f(1.0, 0.0, 1.0),
|
||||
defendedTint,
|
||||
);
|
||||
}
|
||||
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) {
|
||||
let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;
|
||||
outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0);
|
||||
}
|
||||
|
||||
// In alt view we show only borders on top of terrain.
|
||||
if (altView > 0.5) {
|
||||
outColor = terrain;
|
||||
}
|
||||
|
||||
if (owner != 0u) {
|
||||
let fx = fract(mapCoord.x);
|
||||
let fy = fract(mapCoord.y);
|
||||
|
||||
var bestDist = 1e9;
|
||||
var otherOwner = 0u;
|
||||
var otherCoord = texCoord;
|
||||
|
||||
// Only border against other non-zero owners.
|
||||
if (texCoord.x > 0) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
let d = fx;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
otherOwner = o;
|
||||
otherCoord = texCoord + vec2i(-1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (texCoord.x + 1 < i32(mapRes.x)) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
let d = 1.0 - fx;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
otherOwner = o;
|
||||
otherCoord = texCoord + vec2i(1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (texCoord.y > 0) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
let d = fy;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
otherOwner = o;
|
||||
otherCoord = texCoord + vec2i(0, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (texCoord.y + 1 < i32(mapRes.y)) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
let d = 1.0 - fy;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
otherOwner = o;
|
||||
otherCoord = texCoord + vec2i(0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (otherOwner != 0u) {
|
||||
let pxPerTile = max(viewScale, 0.001);
|
||||
let aaTiles = 1.0 / pxPerTile;
|
||||
let thicknessTiles = max(0.1, thicknessPx) / pxPerTile;
|
||||
|
||||
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, bestDist);
|
||||
let glowTiles = (max(0.1, thicknessPx) * max(0.1, glowRadiusMul)) / pxPerTile;
|
||||
let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, bestDist);
|
||||
|
||||
var baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
|
||||
|
||||
if (!enableSplit) {
|
||||
let otherBorderRgb = textureLoad(paletteTex, vec2i(i32(otherOwner) + 10, 1), 0).rgb;
|
||||
baseBorderRgb = 0.5 * (baseBorderRgb + otherBorderRgb);
|
||||
}
|
||||
|
||||
var edgeDefendedStrength = defendedStrength;
|
||||
if (!enableSplit) {
|
||||
let otherDef = textureLoad(defendedStrengthTex, otherCoord, 0).x;
|
||||
edgeDefendedStrength = max(edgeDefendedStrength, otherDef);
|
||||
}
|
||||
|
||||
// Determine relation color (normal: between owners, altView: relation to viewer).
|
||||
var rel = 0u;
|
||||
if (enableRelations) {
|
||||
if (altView > 0.5) {
|
||||
rel = relationCode(owner, u32(max(0.0, myPlayerSmallId) + 0.5));
|
||||
} else {
|
||||
rel = relationCode(owner, otherOwner);
|
||||
}
|
||||
}
|
||||
|
||||
var borderRgb = baseBorderRgb;
|
||||
if (rel != 0u) {
|
||||
let tintTarget = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), rel == 2u);
|
||||
let tint = clamp(0.35 * relationTintStrength, 0.0, 1.0);
|
||||
borderRgb = mix(borderRgb, tintTarget, tint);
|
||||
}
|
||||
|
||||
if (enableDefendedPattern && edgeDefendedStrength >= defendedThreshold) {
|
||||
borderRgb = applyDefendedPattern(borderRgb, defendedPatternStrength, texCoord);
|
||||
}
|
||||
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, borderRgb, clamp(line * borderStrength, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, borderRgb, clamp(glow * glowStrength, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (drawDefendedRadius && defendedStrength > 0.001 && owner != 0u) {
|
||||
let fx = fract(mapCoord.x);
|
||||
let fy = fract(mapCoord.y);
|
||||
|
||||
var dist = 1e9;
|
||||
|
||||
if (texCoord.x > 0) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.x + 1 < i32(mapRes.x)) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, 1.0 - fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.y > 0) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, fy);
|
||||
}
|
||||
}
|
||||
if (texCoord.y + 1 < i32(mapRes.y)) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, 1.0 - fy);
|
||||
}
|
||||
}
|
||||
|
||||
if (dist < 1e8) {
|
||||
let pxPerTile = max(viewScale, 0.001);
|
||||
let aaTiles = 1.0 / pxPerTile;
|
||||
let thicknessTiles = 1.5 / pxPerTile;
|
||||
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
|
||||
|
||||
let baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
|
||||
let ringRgb = mix(baseBorderRgb, vec3f(1.0, 1.0, 1.0), 0.5);
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
struct Temporal {
|
||||
nowSec: f32,
|
||||
lastTickSec: f32,
|
||||
tickDtSec: f32,
|
||||
tickDtEmaSec: f32,
|
||||
tickAlpha: f32,
|
||||
tickCount: f32,
|
||||
historyValid: f32,
|
||||
_pad0: f32,
|
||||
};
|
||||
|
||||
struct Params {
|
||||
params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> t: Temporal;
|
||||
@group(0) @binding(1) var<uniform> p: Params;
|
||||
@group(0) @binding(2) var currentTex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var historyTex: texture_2d<f32>;
|
||||
|
||||
struct FragOutput {
|
||||
@location(0) color: vec4f,
|
||||
@location(1) history: vec4f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||||
var pos = array<vec2f, 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);
|
||||
}
|
||||
|
||||
fn hashUint(x: u32) -> u32 {
|
||||
var h = x * 1664525u + 1013904223u;
|
||||
h ^= h >> 16u;
|
||||
h *= 2246822519u;
|
||||
h ^= h >> 13u;
|
||||
h *= 3266489917u;
|
||||
h ^= h >> 16u;
|
||||
return h;
|
||||
}
|
||||
|
||||
fn hashToUnitFloat(x: u32) -> f32 {
|
||||
return f32(x & 0x00FFFFFFu) / 16777216.0;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fsMain(@builtin(position) pos: vec4f) -> FragOutput {
|
||||
let texCoord = vec2i(pos.xy);
|
||||
let curr = textureLoad(currentTex, texCoord, 0);
|
||||
let hist = textureLoad(historyTex, texCoord, 0);
|
||||
|
||||
let mode = u32(max(0.0, p.params0.x) + 0.5);
|
||||
let strength = clamp(p.params0.y, 0.0, 1.0);
|
||||
let width = max(0.001, p.params0.z);
|
||||
|
||||
var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0);
|
||||
if (t.historyValid < 0.5) {
|
||||
alpha = 1.0;
|
||||
}
|
||||
|
||||
if (mode == 1u) {
|
||||
let outColor = mix(hist, curr, alpha);
|
||||
return FragOutput(outColor, outColor);
|
||||
}
|
||||
|
||||
if (mode == 2u) {
|
||||
let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u);
|
||||
let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
|
||||
let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u)));
|
||||
let mask = smoothstep(alpha - width, alpha + width, r);
|
||||
let outColor = mix(hist, curr, mask);
|
||||
return FragOutput(outColor, outColor);
|
||||
}
|
||||
|
||||
return FragOutput(curr, curr);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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=myPlayerSmallId, w unused
|
||||
shaderParams0: vec4f,
|
||||
shaderParams1: vec4f,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var stateTex: texture_2d<u32>;
|
||||
@group(0) @binding(2) var defendedStrengthTex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var paletteTex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var terrainTex: texture_2d<f32>;
|
||||
@group(0) @binding(5) var ownerIndexTex: texture_2d<u32>;
|
||||
@group(0) @binding(6) var relationsTex: texture_2d<u32>;
|
||||
|
||||
@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;
|
||||
let borderMode = u.shaderParams0.x;
|
||||
let thicknessPx = u.shaderParams0.y;
|
||||
let borderStrength = u.shaderParams0.z;
|
||||
let glowStrength = u.shaderParams0.w;
|
||||
let glowRadiusMul = u.shaderParams1.x;
|
||||
let drawDefendedRadius = u.shaderParams1.y;
|
||||
let disableDefendedTint = u.shaderParams1.z;
|
||||
|
||||
// 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);
|
||||
let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x;
|
||||
var outColor = terrain;
|
||||
if (owner != 0u) {
|
||||
// Player colors start at index 10
|
||||
let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0);
|
||||
var territoryRgb = c.rgb;
|
||||
if (disableDefendedTint <= 0.5) {
|
||||
let defendedTint = select(
|
||||
0.0,
|
||||
clamp(0.8 * defendedStrength, 0.1, 0.35),
|
||||
defendedStrength > 0.001,
|
||||
);
|
||||
territoryRgb = mix(
|
||||
territoryRgb,
|
||||
vec3f(1.0, 0.0, 1.0),
|
||||
defendedTint,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Borders (purely visual): render a stable-pixel-width line at ownership edges.
|
||||
if (borderMode > 0.5 && altView <= 0.5 && owner != 0u) {
|
||||
let fx = fract(mapCoord.x);
|
||||
let fy = fract(mapCoord.y);
|
||||
|
||||
var dist = 1e9;
|
||||
|
||||
// Only border against other non-zero owners.
|
||||
if (texCoord.x > 0) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
dist = min(dist, fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.x + 1 < i32(mapRes.x)) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
dist = min(dist, 1.0 - fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.y > 0) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
dist = min(dist, fy);
|
||||
}
|
||||
}
|
||||
if (texCoord.y + 1 < i32(mapRes.y)) {
|
||||
let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu;
|
||||
if (o != 0u && o != owner) {
|
||||
dist = min(dist, 1.0 - fy);
|
||||
}
|
||||
}
|
||||
|
||||
if (dist < 1e8) {
|
||||
let pxPerTile = max(viewScale, 0.001);
|
||||
let aaTiles = 1.0 / pxPerTile;
|
||||
|
||||
// Mode 1: thin black border.
|
||||
// Mode 2: thicker black border + obvious tinted glow.
|
||||
let isGlow = borderMode > 1.5;
|
||||
let thicknessTiles = thicknessPx / pxPerTile;
|
||||
|
||||
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
|
||||
if (isGlow) {
|
||||
let glowTiles = (thicknessPx * glowRadiusMul) / pxPerTile;
|
||||
let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, dist);
|
||||
let ownerRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0).rgb;
|
||||
let glowColor = mix(vec3f(1.0, 1.0, 1.0), ownerRgb, 0.85);
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, glowColor, clamp(glow * glowStrength, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: defended radius boundary (based on defendedStrengthTex coverage).
|
||||
if (drawDefendedRadius > 0.5 && defendedStrength > 0.001 && owner != 0u) {
|
||||
let fx = fract(mapCoord.x);
|
||||
let fy = fract(mapCoord.y);
|
||||
|
||||
var dist = 1e9;
|
||||
|
||||
if (texCoord.x > 0) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.x + 1 < i32(mapRes.x)) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, 1.0 - fx);
|
||||
}
|
||||
}
|
||||
if (texCoord.y > 0) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, fy);
|
||||
}
|
||||
}
|
||||
if (texCoord.y + 1 < i32(mapRes.y)) {
|
||||
let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x;
|
||||
if (s <= 0.001) {
|
||||
dist = min(dist, 1.0 - fy);
|
||||
}
|
||||
}
|
||||
|
||||
if (dist < 1e8) {
|
||||
let pxPerTile = max(viewScale, 0.001);
|
||||
let aaTiles = 1.0 / pxPerTile;
|
||||
let thicknessTiles = 1.5 / pxPerTile;
|
||||
let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist);
|
||||
|
||||
let borderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb;
|
||||
let ringRgb = mix(borderRgb, vec3f(1.0, 1.0, 1.0), 0.5);
|
||||
outColor = vec4f(
|
||||
mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)),
|
||||
outColor.a,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ export interface Theme {
|
||||
allyColor(): Colord;
|
||||
neutralColor(): Colord;
|
||||
enemyColor(): Colord;
|
||||
playerHighlightColor(): Colord;
|
||||
spawnHighlightColor(): Colord;
|
||||
spawnHighlightSelfColor(): Colord;
|
||||
spawnHighlightTeamColor(): Colord;
|
||||
|
||||
@@ -35,6 +35,8 @@ export class PastelTheme implements Theme {
|
||||
/** Alternate View colors for enemies, red */
|
||||
private _enemyColor = colord("rgb(255,0,0)");
|
||||
|
||||
/** Hover highlight color for player territories */
|
||||
private _playerHighlightColor = colord("rgb(221, 221, 221)");
|
||||
/** Default spawn highlight colors for other players in FFA, yellow */
|
||||
private _spawnHighlightColor = colord("rgb(255,213,79)");
|
||||
/** Added non-default spawn highlight colors for self, full white */
|
||||
@@ -209,6 +211,9 @@ export class PastelTheme implements Theme {
|
||||
enemyColor(): Colord {
|
||||
return this._enemyColor;
|
||||
}
|
||||
playerHighlightColor(): Colord {
|
||||
return this._playerHighlightColor;
|
||||
}
|
||||
|
||||
spawnHighlightColor(): Colord {
|
||||
return this._spawnHighlightColor;
|
||||
|
||||
@@ -8,6 +8,7 @@ export class PastelThemeDark extends PastelTheme {
|
||||
|
||||
private darkWater = colord("rgb(14,11,30)");
|
||||
private darkShorelineWater = colord("rgb(50,50,50)");
|
||||
private darkPlayerHighlight = colord("rgb(99, 42, 42)");
|
||||
|
||||
// | Terrain Type | Magnitude | Base Color Logic | Visual Description |
|
||||
// | :---------------- | :-------- | :---------------------------------------------- | :-------------------- |
|
||||
@@ -59,4 +60,8 @@ export class PastelThemeDark extends PastelTheme {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
playerHighlightColor(): Colord {
|
||||
return this.darkPlayerHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +692,7 @@ export class GameImpl implements Game {
|
||||
owner._lastTileChange = this._ticks;
|
||||
this.updateBorders(tile);
|
||||
this._map.setFallout(tile, false);
|
||||
this.updateDefendedStateForTileChange(tile, owner);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -710,6 +711,9 @@ export class GameImpl implements Game {
|
||||
|
||||
this._map.setOwnerID(tile, 0);
|
||||
this.updateBorders(tile);
|
||||
if (this._map.isDefended(tile)) {
|
||||
this._map.setDefended(tile, false);
|
||||
}
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -958,7 +962,6 @@ export class GameImpl implements Game {
|
||||
playerID: id,
|
||||
});
|
||||
}
|
||||
|
||||
addUnit(u: Unit) {
|
||||
this.unitGrid.addUnit(u);
|
||||
this._unitMap.set(u.id(), u);
|
||||
@@ -972,9 +975,18 @@ export class GameImpl implements Game {
|
||||
}
|
||||
}
|
||||
updateUnitTile(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
this.unitGrid.updateUnitCell(u);
|
||||
}
|
||||
|
||||
refreshDefensePostDefendedState(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
}
|
||||
|
||||
hasUnitNearby(
|
||||
tile: TileRef,
|
||||
searchRange: number,
|
||||
@@ -1097,6 +1109,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 +1173,12 @@ export class GameImpl implements Game {
|
||||
updateTile(tile: TileRef, state: number): boolean {
|
||||
return this._map.updateTile(tile, state);
|
||||
}
|
||||
tileStateView(): Uint16Array {
|
||||
return this._map.tileStateView();
|
||||
}
|
||||
terrainDataView(): Uint8Array {
|
||||
return this._map.terrainDataView();
|
||||
}
|
||||
numTilesWithFallout(): number {
|
||||
return this._map.numTilesWithFallout();
|
||||
}
|
||||
@@ -1243,6 +1267,49 @@ export class GameImpl implements Game {
|
||||
gold: goldCaptured,
|
||||
});
|
||||
}
|
||||
|
||||
private updateDefendedStateForDefensePost(
|
||||
center: TileRef,
|
||||
owner: PlayerImpl,
|
||||
) {
|
||||
const range = this.config().defensePostRange();
|
||||
const rangeSq = range * range;
|
||||
|
||||
for (const tile of owner._borderTiles) {
|
||||
if (this._map.euclideanDistSquared(center, tile) <= rangeSq) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
range,
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
this.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
}
|
||||
|
||||
if (
|
||||
this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id())
|
||||
) {
|
||||
this.updateDefendedStateForDefensePost(tile, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or a more dynamic approach that will catch new enum values:
|
||||
|
||||
@@ -33,6 +33,10 @@ 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;
|
||||
terrainDataView(): Uint8Array;
|
||||
isOnEdgeOfMap(ref: TileRef): boolean;
|
||||
isBorder(ref: TileRef): boolean;
|
||||
neighbors(ref: TileRef): TileRef[];
|
||||
@@ -96,6 +100,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 +271,26 @@ 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;
|
||||
}
|
||||
|
||||
terrainDataView(): Uint8Array {
|
||||
return this.terrain;
|
||||
}
|
||||
|
||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||
const x = this.x(ref);
|
||||
const y = this.y(ref);
|
||||
|
||||
@@ -669,6 +669,11 @@ export class GameView implements GameMap {
|
||||
private _units = new Map<number, UnitView>();
|
||||
private updatedTiles: TileRef[] = [];
|
||||
private updatedTerrainTiles: TileRef[] = [];
|
||||
private updatedOwnerChanges: Array<{
|
||||
tile: TileRef;
|
||||
previousOwner: number;
|
||||
newOwner: number;
|
||||
}> = [];
|
||||
|
||||
private _myPlayer: PlayerView | null = null;
|
||||
|
||||
@@ -780,15 +785,25 @@ export class GameView implements GameMap {
|
||||
|
||||
this.updatedTiles = [];
|
||||
this.updatedTerrainTiles = [];
|
||||
this.updatedOwnerChanges = [];
|
||||
const packed = this.lastUpdate.packedTileUpdates;
|
||||
for (let i = 0; i + 1 < packed.length; i += 2) {
|
||||
const tile = packed[i];
|
||||
const state = packed[i + 1];
|
||||
const previousOwner = this._map.ownerID(tile);
|
||||
const terrainChanged = this.updateTile(tile, state);
|
||||
this.updatedTiles.push(tile);
|
||||
if (terrainChanged) {
|
||||
this.updatedTerrainTiles.push(tile);
|
||||
}
|
||||
const newOwner = this._map.ownerID(tile);
|
||||
if (previousOwner !== newOwner) {
|
||||
this.updatedOwnerChanges.push({
|
||||
tile,
|
||||
previousOwner,
|
||||
newOwner,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (gu.packedMotionPlans) {
|
||||
@@ -1107,6 +1122,14 @@ export class GameView implements GameMap {
|
||||
return this.updatedTerrainTiles;
|
||||
}
|
||||
|
||||
recentlyUpdatedOwnerTiles(): Array<{
|
||||
tile: TileRef;
|
||||
previousOwner: number;
|
||||
newOwner: number;
|
||||
}> {
|
||||
return this.updatedOwnerChanges;
|
||||
}
|
||||
|
||||
nearbyUnits(
|
||||
tile: TileRef,
|
||||
searchRange: number,
|
||||
@@ -1323,6 +1346,18 @@ 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();
|
||||
}
|
||||
terrainDataView(): Uint8Array {
|
||||
return this._map.terrainDataView();
|
||||
}
|
||||
isBorder(ref: TileRef): boolean {
|
||||
return this._map.isBorder(ref);
|
||||
}
|
||||
|
||||
@@ -433,6 +433,9 @@ export class UnitImpl implements Unit {
|
||||
setUnderConstruction(underConstruction: boolean): void {
|
||||
if (this._underConstruction !== underConstruction) {
|
||||
this._underConstruction = underConstruction;
|
||||
if (this._type === UnitType.DefensePost) {
|
||||
this.mg.refreshDefensePostDefendedState(this);
|
||||
}
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ export const COLOR_KEY = "settings.territoryColor";
|
||||
export const DARK_MODE_KEY = "settings.darkMode";
|
||||
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
|
||||
export const KEYBINDS_KEY = "settings.keybinds";
|
||||
export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer";
|
||||
export const WEBGL_DEBUG_KEY = "settings.webglDebug";
|
||||
export const WEBGPU_DEBUG_KEY = "settings.webgpuDebug";
|
||||
export type TerritoryRendererPreference =
|
||||
| "auto"
|
||||
| "classic"
|
||||
| "webgl"
|
||||
| "webgpu";
|
||||
|
||||
export class UserSettings {
|
||||
private static cache = new Map<string, string | null>();
|
||||
@@ -110,7 +118,15 @@ export class UserSettings {
|
||||
this.setCached(key, value);
|
||||
}
|
||||
|
||||
private getFloat(key: string, defaultValue: number): number {
|
||||
get(key: string, defaultValue: boolean): boolean {
|
||||
return this.getBool(key, defaultValue);
|
||||
}
|
||||
|
||||
set(key: string, value: boolean): void {
|
||||
this.setBool(key, value);
|
||||
}
|
||||
|
||||
getFloat(key: string, defaultValue: number): number {
|
||||
const value = this.getCached(key);
|
||||
if (!value) return defaultValue;
|
||||
|
||||
@@ -119,10 +135,24 @@ export class UserSettings {
|
||||
return floatValue;
|
||||
}
|
||||
|
||||
private setFloat(key: string, value: number) {
|
||||
setFloat(key: string, value: number) {
|
||||
this.setCached(key, value.toString());
|
||||
}
|
||||
|
||||
getInt(key: string, defaultValue: number): number {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return defaultValue;
|
||||
|
||||
const intValue = parseInt(value, 10);
|
||||
if (!Number.isFinite(intValue)) return defaultValue;
|
||||
|
||||
return intValue;
|
||||
}
|
||||
|
||||
setInt(key: string, value: number): void {
|
||||
localStorage.setItem(key, Math.trunc(value).toString());
|
||||
}
|
||||
|
||||
emojis() {
|
||||
return this.getBool("settings.emojis", true);
|
||||
}
|
||||
@@ -131,6 +161,22 @@ export class UserSettings {
|
||||
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
|
||||
}
|
||||
|
||||
webgpuDebug(): boolean {
|
||||
return this.get(WEBGPU_DEBUG_KEY, false);
|
||||
}
|
||||
|
||||
webglDebug(): boolean {
|
||||
return this.get(WEBGL_DEBUG_KEY, false);
|
||||
}
|
||||
|
||||
setWebgpuDebug(value: boolean): void {
|
||||
this.set(WEBGPU_DEBUG_KEY, value);
|
||||
}
|
||||
|
||||
setWebglDebug(value: boolean): void {
|
||||
this.set(WEBGL_DEBUG_KEY, value);
|
||||
}
|
||||
|
||||
alertFrame() {
|
||||
return this.getBool("settings.alertFrame", true);
|
||||
}
|
||||
@@ -167,6 +213,33 @@ export class UserSettings {
|
||||
return this.getBool("settings.attackingTroopsOverlay", true);
|
||||
}
|
||||
|
||||
territoryBorderMode(): number {
|
||||
return this.getInt("settings.territoryBorderMode", 1);
|
||||
}
|
||||
|
||||
territoryRenderer(): TerritoryRendererPreference {
|
||||
const value = this.getString(TERRITORY_RENDERER_KEY, "auto");
|
||||
if (
|
||||
value === "auto" ||
|
||||
value === "classic" ||
|
||||
value === "webgl" ||
|
||||
value === "webgpu"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
setTerritoryRenderer(value: string): void {
|
||||
const renderer =
|
||||
value === "classic" || value === "webgl" || value === "webgpu"
|
||||
? value
|
||||
: "auto";
|
||||
this.setWebglDebug(renderer === "webgl");
|
||||
this.setWebgpuDebug(renderer === "webgpu");
|
||||
this.setString(TERRITORY_RENDERER_KEY, renderer);
|
||||
}
|
||||
|
||||
toggleAttackingTroopsOverlay() {
|
||||
this.setBool(
|
||||
"settings.attackingTroopsOverlay",
|
||||
@@ -196,6 +269,10 @@ export class UserSettings {
|
||||
this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
|
||||
}
|
||||
|
||||
toggleWebgpuDebug() {
|
||||
this.setWebgpuDebug(!this.webgpuDebug());
|
||||
}
|
||||
|
||||
toggleAlertFrame() {
|
||||
this.setBool("settings.alertFrame", !this.alertFrame());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
selectTerritoryBackend,
|
||||
type TerritoryBackendCandidate,
|
||||
type TerritoryRendererId,
|
||||
type TerritoryRendererPreference,
|
||||
} from "../src/client/graphics/layers/TerritoryBackend";
|
||||
|
||||
type FakeBackendSpec = {
|
||||
initError?: string;
|
||||
ready?: boolean;
|
||||
failureReason?: string;
|
||||
};
|
||||
|
||||
type FakeBackendSpecs = Partial<Record<TerritoryRendererId, FakeBackendSpec>>;
|
||||
|
||||
class FakeBackend implements TerritoryBackendCandidate {
|
||||
initialized = false;
|
||||
disposed = false;
|
||||
|
||||
constructor(
|
||||
readonly id: TerritoryRendererId,
|
||||
private readonly spec: FakeBackendSpec = {},
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.initialized = true;
|
||||
if (this.spec.initError) {
|
||||
throw new Error(this.spec.initError);
|
||||
}
|
||||
}
|
||||
|
||||
async whenReady(): Promise<boolean> {
|
||||
return this.spec.ready ?? true;
|
||||
}
|
||||
|
||||
getFailureReason(): string | null {
|
||||
return this.spec.failureReason ?? null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RendererSelectionHarness {
|
||||
active: TerritoryRendererId | null = null;
|
||||
readonly failed = new Set<TerritoryRendererId>();
|
||||
preference: TerritoryRendererPreference;
|
||||
|
||||
constructor(preference: TerritoryRendererPreference) {
|
||||
this.preference = preference;
|
||||
}
|
||||
|
||||
setPreference(preference: TerritoryRendererPreference) {
|
||||
this.preference = preference;
|
||||
this.failed.clear();
|
||||
}
|
||||
|
||||
async select(specs: FakeBackendSpecs = {}) {
|
||||
const created: FakeBackend[] = [];
|
||||
const selection = await selectTerritoryBackend(
|
||||
this.preference,
|
||||
this.failed,
|
||||
(id) => {
|
||||
const backend = new FakeBackend(id, specs[id]);
|
||||
created.push(backend);
|
||||
return backend;
|
||||
},
|
||||
);
|
||||
|
||||
for (const failure of selection.failures) {
|
||||
if (failure.id !== "classic") {
|
||||
this.failed.add(failure.id);
|
||||
}
|
||||
}
|
||||
if (selection.backend) {
|
||||
this.active = selection.backend.id;
|
||||
}
|
||||
|
||||
return { ...selection, created };
|
||||
}
|
||||
|
||||
async failActiveRuntime(specs: FakeBackendSpecs = {}) {
|
||||
if (this.active && this.active !== "classic") {
|
||||
this.failed.add(this.active);
|
||||
}
|
||||
return this.select(specs);
|
||||
}
|
||||
}
|
||||
|
||||
describe("territory renderer backend selection", () => {
|
||||
test("auto selects WebGPU when ready", async () => {
|
||||
const harness = new RendererSelectionHarness("auto");
|
||||
|
||||
const result = await harness.select();
|
||||
|
||||
expect(result.backend?.id).toBe("webgpu");
|
||||
expect(harness.active).toBe("webgpu");
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.created.map((backend) => backend.id)).toEqual(["webgpu"]);
|
||||
});
|
||||
|
||||
test("auto falls back to WebGL when WebGPU init fails", async () => {
|
||||
const harness = new RendererSelectionHarness("auto");
|
||||
|
||||
const result = await harness.select({
|
||||
webgpu: { initError: "navigator.gpu unavailable" },
|
||||
});
|
||||
|
||||
expect(result.backend?.id).toBe("webgl");
|
||||
expect(harness.active).toBe("webgl");
|
||||
expect(result.failures.map((failure) => failure.id)).toEqual(["webgpu"]);
|
||||
expect(result.created[0].disposed).toBe(true);
|
||||
});
|
||||
|
||||
test("auto falls back to classic when both accelerated backends fail", async () => {
|
||||
const harness = new RendererSelectionHarness("auto");
|
||||
|
||||
const result = await harness.select({
|
||||
webgpu: { initError: "navigator.gpu unavailable" },
|
||||
webgl: { failureReason: "WebGL2 unavailable" },
|
||||
});
|
||||
|
||||
expect(result.backend?.id).toBe("classic");
|
||||
expect(harness.active).toBe("classic");
|
||||
expect(result.failures.map((failure) => failure.id)).toEqual([
|
||||
"webgpu",
|
||||
"webgl",
|
||||
]);
|
||||
});
|
||||
|
||||
test("forced WebGPU falls back on runtime failure without changing saved setting", async () => {
|
||||
const harness = new RendererSelectionHarness("webgpu");
|
||||
await harness.select();
|
||||
|
||||
const result = await harness.failActiveRuntime();
|
||||
|
||||
expect(result.backend?.id).toBe("webgl");
|
||||
expect(harness.active).toBe("webgl");
|
||||
expect(harness.preference).toBe("webgpu");
|
||||
expect(harness.failed.has("webgpu")).toBe(true);
|
||||
});
|
||||
|
||||
test("manual setting change retries previously failed backends", async () => {
|
||||
const harness = new RendererSelectionHarness("auto");
|
||||
await harness.select({
|
||||
webgpu: { initError: "navigator.gpu unavailable" },
|
||||
});
|
||||
|
||||
expect(harness.active).toBe("webgl");
|
||||
expect(harness.failed.has("webgpu")).toBe(true);
|
||||
|
||||
harness.setPreference("auto");
|
||||
const retry = await harness.select();
|
||||
|
||||
expect(retry.backend?.id).toBe("webgpu");
|
||||
expect(harness.active).toBe("webgpu");
|
||||
expect(harness.failed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user