Add WebGPU Debug Overlay and Shader Management

- Introduced WebGPUComputeMetricsEvent to track compute timing.
- Added WebGPUDebugOverlay component for displaying WebGPU performance metrics.
- Refactored TerritoryLayer to utilize new shader management for territory rendering.
- Updated shaders to support new parameters for enhanced visual effects.
- Removed deprecated territory border mode settings from UserSettingModal and SettingsModal.
- Enhanced GroundTruthData to manage new textures for owner indices and relations.
- Improved shader parameter handling in TerritoryRenderer and related classes.

This commit enhances the WebGPU rendering pipeline, providing better performance insights and visual fidelity through improved shader management and debugging capabilities.
This commit is contained in:
scamiv
2026-01-18 19:16:40 +01:00
parent 29e74af95f
commit d82c33863f
12 changed files with 1356 additions and 117 deletions
+4
View File
@@ -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;
+15
View File
@@ -44,6 +44,7 @@ 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(
@@ -241,6 +242,16 @@ 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 alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
@@ -318,6 +329,7 @@ export function createRenderer(
inGamePromo,
alertFrame,
performanceOverlay,
webgpuDebugOverlay,
];
return new GameRenderer(
@@ -328,6 +340,7 @@ export function createRenderer(
uiState,
layers,
performanceOverlay,
webgpuDebugOverlay,
);
}
@@ -345,6 +358,7 @@ export class GameRenderer {
public uiState: UIState,
private layers: Layer[],
private performanceOverlay: PerformanceOverlay,
private webgpuDebugOverlay: WebGPUDebugOverlay,
) {
// Keep the main canvas transparent; the WebGPU territory canvas renders the background.
const context = canvas.getContext("2d", { alpha: true });
@@ -451,6 +465,7 @@ export class GameRenderer {
}
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
}
this.webgpuDebugOverlay.updateFrameMetrics(duration);
if (duration > 50) {
console.warn(
+28 -38
View File
@@ -149,16 +149,6 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onTerritoryBorderModeChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
const mode = Number.parseInt(value, 10);
if (!Number.isFinite(mode))
throw new Error(`Invalid border mode: ${value}`);
this.userSettings.setInt("settings.territoryBorderMode", mode);
this.requestUpdate();
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
@@ -184,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 = "/";
@@ -309,34 +304,6 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img
src=${treeIcon}
alt="territoryBorderMode"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.territory_border_mode_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.territory_border_mode_desc")}
</div>
</div>
<select
class="shrink-0 bg-slate-900 border border-slate-600 text-white/90 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-600"
.value=${String(this.userSettings.territoryBorderMode())}
@change=${this.onTerritoryBorderModeChange}
>
<option value="0">Off</option>
<option value="1">Simple</option>
<option value="2">Glow</option>
</select>
</div>
<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.onToggleEmojisButtonClick}"
@@ -564,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"
+34 -12
View File
@@ -4,9 +4,17 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler";
import {
AlternateViewEvent,
MouseOverEvent,
WebGPUComputeMetricsEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
import {
buildTerritoryShaderParams,
readTerritoryShaderId,
} from "../webgpu/render/TerritoryShaderRegistry";
import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
import { Layer } from "./Layer";
@@ -27,7 +35,7 @@ export class TerritoryLayer implements Layer {
private lastPaletteSignature: string | null = null;
private lastDefensePostsSignature: string | null = null;
private lastBorderMode: number | null = null;
private lastTerritoryShaderSignature: string | null = null;
private lastMousePosition: { x: number; y: number } | null = null;
private hoveredOwnerSmallId: number | null = null;
@@ -69,7 +77,7 @@ export class TerritoryLayer implements Layer {
this.refreshPaletteIfNeeded();
this.refreshDefensePostsIfNeeded();
this.refreshBorderModeIfNeeded();
this.applyTerritoryShaderSettings();
const updatedTiles = this.game.recentlyUpdatedTiles();
for (let i = 0; i < updatedTiles.length; i++) {
@@ -79,7 +87,12 @@ export class TerritoryLayer implements Layer {
// After collecting pending updates and handling palette/theme changes,
// invoke the renderer's tick() to process compute passes. This ensures
// compute shaders run at the simulation rate rather than every frame.
this.territoryRenderer?.tick();
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);
}
@@ -100,8 +113,7 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer = renderer;
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
this.lastBorderMode = this.userSettings.territoryBorderMode();
this.territoryRenderer.setBorderMode(this.lastBorderMode);
this.applyTerritoryShaderSettings(true);
this.territoryRenderer.markAllDirty();
this.territoryRenderer.refreshPalette();
this.lastPaletteSignature = this.computePaletteSignature();
@@ -130,7 +142,7 @@ export class TerritoryLayer implements Layer {
}
// Apply user settings even while the game is paused (settings modal).
this.refreshBorderModeIfNeeded();
this.applyTerritoryShaderSettings();
this.ensureTerritoryCanvasAttached(context.canvas);
this.updateHoverHighlight();
@@ -289,15 +301,25 @@ export class TerritoryLayer implements Layer {
}
}
private refreshBorderModeIfNeeded() {
private applyTerritoryShaderSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
}
const mode = this.userSettings.territoryBorderMode();
if (mode !== this.lastBorderMode) {
this.lastBorderMode = mode;
this.territoryRenderer.setBorderMode(mode);
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 computeDefensePostsSignature(): string {
@@ -0,0 +1,286 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
import {
TERRITORY_SHADER_KEY,
TERRITORY_SHADERS,
territoryShaderIdFromInt,
territoryShaderIntFromId,
} from "../webgpu/render/TerritoryShaderRegistry";
import { Layer } from "./Layer";
@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;
private frameTimes: number[] = [];
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;
}
.title {
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.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;
}
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.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
this.tickComputeMs = e.computeMs;
this.requestUpdate();
}
});
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 renderOptionControl(
option: (typeof TERRITORY_SHADERS)[number]["options"][number],
) {
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>
`;
}
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];
return html`
<div class="overlay">
<div class="title">
<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="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>
`;
}
}
@@ -28,7 +28,9 @@ export class TerritoryRenderer {
private resources: GroundTruthData | null = null;
private ready = false;
private initPromise: Promise<void> | null = null;
private borderMode = 1;
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
// Compute passes
private computePasses: ComputePass[] = [];
@@ -99,8 +101,10 @@ export class TerritoryRenderer {
this.theme,
state,
);
this.resources.setBorderMode(this.borderMode);
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
// Upload terrain data and params (terrain colors will be computed on GPU)
this.resources.uploadTerrainData();
@@ -136,6 +140,10 @@ export class TerritoryRenderer {
);
}
if (this.territoryRenderPass) {
await this.territoryRenderPass.setShader(this.territoryShaderPath);
}
// Compute dependency order (topological sort)
this.computePassOrder = this.topologicalSort(this.computePasses);
this.renderPassOrder = this.topologicalSort(this.renderPasses);
@@ -230,12 +238,29 @@ export class TerritoryRenderer {
this.resources.setHighlightedOwnerId(ownerSmallId);
}
setBorderMode(mode: number): void {
this.borderMode = mode;
setTerritoryShader(shaderPath: string): void {
this.territoryShaderPath = shaderPath;
if (this.territoryRenderPass) {
void this.territoryRenderPass.setShader(shaderPath);
}
}
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.setBorderMode(mode);
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
}
markTile(tile: TileRef): void {
@@ -322,6 +347,9 @@ export class TerritoryRenderer {
// 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();
@@ -23,6 +23,8 @@ export class GroundTruthData {
public readonly terrainTexture: GPUTexture;
public readonly terrainDataTexture: GPUTexture;
public readonly paletteTexture: GPUTexture;
public readonly ownerIndexTexture: GPUTexture;
public readonly relationsTexture: GPUTexture;
public readonly defendedStrengthTexture: GPUTexture;
// Buffers
@@ -65,7 +67,7 @@ export class GroundTruthData {
private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...]
// Uniform data arrays
private readonly uniformData = new Float32Array(12);
private readonly uniformData = new Float32Array(20);
private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase
private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad
private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad
@@ -78,7 +80,13 @@ export class GroundTruthData {
private viewOffsetY = 0;
private alternativeView = false;
private highlightedOwnerId = -1;
private borderMode = 1;
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
private paletteMaxSmallId = 0;
private ownerIndexWidth = 1;
private relationsSize = 1;
private constructor(
private readonly device: GPUDevice,
@@ -102,9 +110,9 @@ export class GroundTruthData {
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
// Render uniforms: 3x vec4f = 48 bytes
// Render uniforms: 5x vec4f = 80 bytes
this.uniformBuffer = device.createBuffer({
size: 48,
size: 80,
usage: UNIFORM | COPY_DST_BUF,
});
@@ -140,13 +148,27 @@ export class GroundTruthData {
usage: TEXTURE_BINDING | STORAGE_BINDING,
});
// Palette texture (rgba8unorm)
// Palette texture (rgba8unorm): row 0 territory colors, row 1 border colors
this.paletteTexture = device.createTexture({
size: { width: 1, height: 1 },
size: { width: 1, height: 2 },
format: "rgba8unorm",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
// SmallID -> dense index lookup texture (r32uint)
this.ownerIndexTexture = device.createTexture({
size: { width: 1, height: 1 },
format: "r32uint",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
// Dense relation matrix texture (r8uint)
this.relationsTexture = device.createTexture({
size: { width: 1, height: 1 },
format: "r8uint",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
// Terrain texture (rgba8unorm) - output of terrain compute shader
this.terrainTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
@@ -224,8 +246,14 @@ export class GroundTruthData {
this.highlightedOwnerId = ownerSmallId ?? -1;
}
setBorderMode(mode: number): void {
this.borderMode = Math.max(0, Math.min(2, Math.trunc(mode)));
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);
}
}
// =====================
@@ -447,6 +475,7 @@ export class GroundTruthData {
for (const player of this.game.playerViews()) {
maxSmallId = Math.max(maxSmallId, player.smallID());
}
this.paletteMaxSmallId = maxSmallId;
const nextPaletteWidth =
GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1);
@@ -458,21 +487,23 @@ export class GroundTruthData {
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
(this as any).paletteTexture = this.device.createTexture({
size: { width: this.paletteWidth, height: 1 },
size: { width: this.paletteWidth, height: 2 },
format: "rgba8unorm",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
textureRecreated = true;
}
const bytes = new Uint8Array(this.paletteWidth * 4);
const rowStride = this.paletteWidth * 4;
const row0 = new Uint8Array(rowStride);
const row1 = new Uint8Array(rowStride);
// Store special colors in reserved slots (0-9)
const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4;
bytes[falloutIdx] = 120;
bytes[falloutIdx + 1] = 255;
bytes[falloutIdx + 2] = 71;
bytes[falloutIdx + 3] = 255;
row0[falloutIdx] = 120;
row0[falloutIdx + 1] = 255;
row0[falloutIdx + 2] = 71;
row0[falloutIdx + 3] = 255;
// Store player colors starting at index 10
for (const player of this.game.playerViews()) {
@@ -480,27 +511,126 @@ export class GroundTruthData {
if (id <= 0) continue;
const rgba = player.territoryColor().rgba;
const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4;
bytes[idx] = rgba.r;
bytes[idx + 1] = rgba.g;
bytes[idx + 2] = rgba.b;
bytes[idx + 3] = 255;
row0[idx] = rgba.r;
row0[idx + 1] = rgba.g;
row0[idx + 2] = rgba.b;
row0[idx + 3] = 255;
const borderRgba = player.borderColor().rgba;
row1[idx] = borderRgba.r;
row1[idx + 1] = borderRgba.g;
row1[idx + 2] = borderRgba.b;
row1[idx + 3] = 255;
}
const bytesPerRow = align(this.paletteWidth * 4, 256);
const padded =
bytesPerRow === this.paletteWidth * 4
? bytes
: (() => {
const tmp = new Uint8Array(bytesPerRow);
tmp.set(bytes);
return tmp;
})();
const bytesPerRow = align(rowStride, 256);
const padded = new Uint8Array(bytesPerRow * 2);
padded.set(row0, 0);
padded.set(row1, bytesPerRow);
this.device.queue.writeTexture(
{ texture: this.paletteTexture },
padded,
{ bytesPerRow, rowsPerImage: 1 },
{ width: this.paletteWidth, height: 1, depthOrArrayLayers: 1 },
{ bytesPerRow, rowsPerImage: 2 },
{ width: this.paletteWidth, height: 2, depthOrArrayLayers: 1 },
);
return textureRecreated;
}
uploadRelations(): boolean {
const players = this.game
.playerViews()
.filter((p) => p.smallID() > 0)
.slice()
.sort((a, b) => a.smallID() - b.smallID());
const maxSmallId = this.paletteMaxSmallId;
const nextOwnerIndexWidth = Math.max(1, maxSmallId + 1);
let textureRecreated = false;
if (nextOwnerIndexWidth !== this.ownerIndexWidth) {
this.ownerIndexWidth = nextOwnerIndexWidth;
(this.ownerIndexTexture as any).destroy?.();
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
(this as any).ownerIndexTexture = this.device.createTexture({
size: { width: this.ownerIndexWidth, height: 1 },
format: "r32uint",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
textureRecreated = true;
}
const denseBySmallId = new Uint32Array(this.ownerIndexWidth);
let dense = 0;
for (const p of players) {
const id = p.smallID();
if (id <= 0 || id >= this.ownerIndexWidth) continue;
dense++;
denseBySmallId[id] = dense;
}
const ownerIndexBytesPerRow = align(this.ownerIndexWidth * 4, 256);
const ownerIndexPaddedU32 = new Uint32Array(ownerIndexBytesPerRow / 4);
ownerIndexPaddedU32.set(denseBySmallId);
this.device.queue.writeTexture(
{ texture: this.ownerIndexTexture },
ownerIndexPaddedU32,
{ bytesPerRow: ownerIndexBytesPerRow, rowsPerImage: 1 },
{ width: this.ownerIndexWidth, height: 1, depthOrArrayLayers: 1 },
);
const nextRelationsSize = Math.max(1, dense + 1);
if (nextRelationsSize !== this.relationsSize) {
this.relationsSize = nextRelationsSize;
(this.relationsTexture as any).destroy?.();
const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
(this as any).relationsTexture = this.device.createTexture({
size: { width: this.relationsSize, height: this.relationsSize },
format: "r8uint",
usage: COPY_DST_TEX | TEXTURE_BINDING,
});
textureRecreated = true;
}
const relBytesPerRow = align(this.relationsSize, 256);
const relPadded = new Uint8Array(relBytesPerRow * this.relationsSize);
// 0 = neutral, 1 = friendly, 2 = embargo
for (let i = 0; i < players.length; i++) {
for (let j = i + 1; j < players.length; j++) {
const a = players[i];
const b = players[j];
const aDense = denseBySmallId[a.smallID()];
const bDense = denseBySmallId[b.smallID()];
if (aDense === 0 || bDense === 0) continue;
let code = 0;
if (a.hasEmbargo(b)) {
code = 2;
} else if (a.isFriendly(b) || b.isFriendly(a)) {
code = 1;
}
relPadded[aDense + bDense * relBytesPerRow] = code;
relPadded[bDense + aDense * relBytesPerRow] = code;
}
}
this.device.queue.writeTexture(
{ texture: this.relationsTexture },
relPadded,
{ bytesPerRow: relBytesPerRow, rowsPerImage: this.relationsSize },
{
width: this.relationsSize,
height: this.relationsSize,
depthOrArrayLayers: 1,
},
);
return textureRecreated;
@@ -853,9 +983,19 @@ export class GroundTruthData {
this.uniformData[7] = this.highlightedOwnerId;
this.uniformData[8] = this.viewWidth;
this.uniformData[9] = this.viewHeight;
this.uniformData[10] = this.borderMode;
this.uniformData[10] = this.game.myPlayer()?.smallID() ?? 0;
this.uniformData[11] = 0;
this.uniformData[12] = this.territoryShaderParams0[0];
this.uniformData[13] = this.territoryShaderParams0[1];
this.uniformData[14] = this.territoryShaderParams0[2];
this.uniformData[15] = this.territoryShaderParams0[3];
this.uniformData[16] = this.territoryShaderParams1[0];
this.uniformData[17] = this.territoryShaderParams1[1];
this.uniformData[18] = this.territoryShaderParams1[2];
this.uniformData[19] = this.territoryShaderParams1[3];
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData);
}
@@ -16,6 +16,7 @@ export class TerritoryRenderPass implements RenderPass {
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;
@@ -29,9 +30,6 @@ export class TerritoryRenderPass implements RenderPass {
this.resources = resources;
this.canvasFormat = canvasFormat;
const shaderCode = await loadShader("render/territory.wgsl");
const shaderModule = device.createShaderModule({ code: shaderCode });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
@@ -59,21 +57,20 @@ export class TerritoryRenderPass implements RenderPass {
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "float" },
},
{
binding: 5,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "uint" },
},
{
binding: 6,
visibility: 2 /* FRAGMENT */,
texture: { sampleType: "uint" },
},
],
});
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex: { module: shaderModule, entryPoint: "vsMain" },
fragment: {
module: shaderModule,
entryPoint: "fsMain",
targets: [{ format: canvasFormat }],
},
primitive: { topology: "triangle-list" },
});
await this.setShader(this.shaderPath);
this.rebuildBindGroup();
@@ -84,6 +81,30 @@ export class TerritoryRenderPass implements RenderPass {
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;
@@ -139,7 +160,9 @@ export class TerritoryRenderPass implements RenderPass {
!this.resources.stateTexture ||
!this.resources.defendedStrengthTexture ||
!this.resources.paletteTexture ||
!this.resources.terrainTexture
!this.resources.terrainTexture ||
!this.resources.ownerIndexTexture ||
!this.resources.relationsTexture
) {
return;
}
@@ -164,6 +187,14 @@ export class TerritoryRenderPass implements RenderPass {
binding: 4,
resource: this.resources.terrainTexture.createView(),
},
{
binding: 5,
resource: this.resources.ownerIndexTexture.createView(),
},
{
binding: 6,
resource: this.resources.relationsTexture.createView(),
},
],
});
}
@@ -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,
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,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;
}
@@ -1,7 +1,9 @@
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=borderMode, w unused
viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused
shaderParams0: vec4f,
shaderParams1: vec4f,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@@ -9,6 +11,8 @@ struct Uniforms {
@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 {
@@ -30,7 +34,13 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let altView = u.viewOffset_alt_highlight.z;
let highlightId = u.viewOffset_alt_highlight.w;
let viewSize = u.viewSize_pad.xy;
let borderMode = u.viewSize_pad.z;
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);
@@ -55,11 +65,18 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f {
// Player colors start at index 10
let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0);
var territoryRgb = c.rgb;
territoryRgb = mix(
territoryRgb,
vec3f(1.0, 0.0, 1.0),
clamp(0.8 * defendedStrength, 0.1, 0.35),
);
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;
@@ -117,28 +134,74 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f {
// Mode 1: thin black border.
// Mode 2: thicker black border + obvious tinted glow.
let isGlow = borderMode > 1.5;
let thicknessPx = select(1.0, 3.0, isGlow);
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 * 0.95, 0.0, 0.95)),
mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)),
outColor.a,
);
if (isGlow) {
let glowTiles = (thicknessPx * 5.0) / pxPerTile;
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 * 0.35, 0.0, 0.35)),
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);
+8 -4
View File
@@ -145,6 +145,10 @@ export class UserSettings {
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
webgpuDebug(): boolean {
return this.get("settings.webgpuDebug", true);
}
alertFrame() {
return this.getBool("settings.alertFrame", true);
}
@@ -188,10 +192,6 @@ export class UserSettings {
);
}
territoryBorderMode(): number {
return this.getInt("settings.territoryBorderMode", 1);
}
cursorCostLabel() {
const legacy = this.getBool("settings.ghostPricePill", true);
return this.getBool("settings.cursorCostLabel", legacy);
@@ -214,6 +214,10 @@ export class UserSettings {
this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
}
toggleWebgpuDebug() {
this.set("settings.webgpuDebug", !this.webgpuDebug());
}
toggleAlertFrame() {
this.setBool("settings.alertFrame", !this.alertFrame());
}