@@ -51,7 +57,7 @@ export class SettingSelect extends LitElement {
-
-

-
-
- ${translateText("user_setting.territory_border_mode_label")}
-
-
- ${translateText("user_setting.territory_border_mode_desc")}
-
-
-
-
-
-
-
-
-
+
+
`;
}
diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts
index 3cebb41c1..e202dc38f 100644
--- a/src/client/graphics/webgpu/TerritoryRenderer.ts
+++ b/src/client/graphics/webgpu/TerritoryRenderer.ts
@@ -7,9 +7,11 @@ import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass";
import { DefendedStrengthPass } from "./compute/DefendedStrengthPass";
import { StateUpdatePass } from "./compute/StateUpdatePass";
import { TerrainComputePass } from "./compute/TerrainComputePass";
+import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass";
import { GroundTruthData } from "./core/GroundTruthData";
import { WebGPUDevice } from "./core/WebGPUDevice";
import { RenderPass } from "./render/RenderPass";
+import { TemporalResolvePass } from "./render/TemporalResolvePass";
import { TerritoryRenderPass } from "./render/TerritoryRenderPass";
export interface TerritoryWebGLCreateResult {
@@ -31,10 +33,15 @@ export class TerritoryRenderer {
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
+ private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
+ private preSmoothingParams0 = new Float32Array(4);
+ private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
+ private postSmoothingParams0 = new Float32Array(4);
// Compute passes
private computePasses: ComputePass[] = [];
private computePassOrder: ComputePass[] = [];
+ private frameComputePasses: ComputePass[] = [];
// Render passes
private renderPasses: RenderPass[] = [];
@@ -45,9 +52,14 @@ export class TerritoryRenderer {
private stateUpdatePass: StateUpdatePass | null = null;
private defendedStrengthFullPass: DefendedStrengthFullPass | null = null;
private defendedStrengthPass: DefendedStrengthPass | null = null;
+ private visualStateSmoothingPass: VisualStateSmoothingPass | null = null;
private territoryRenderPass: TerritoryRenderPass | null = null;
+ private temporalResolvePass: TemporalResolvePass | null = null;
private readonly defensePostRange: number;
+ private preSmoothingEnabled = false;
+ private postSmoothingEnabled = false;
+
private constructor(
private readonly game: GameView,
private readonly theme: Theme,
@@ -115,6 +127,7 @@ export class TerritoryRenderer {
this.stateUpdatePass = new StateUpdatePass();
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
this.defendedStrengthPass = new DefendedStrengthPass();
+ this.visualStateSmoothingPass = new VisualStateSmoothingPass();
this.computePasses = [
this.terrainComputePass,
@@ -123,15 +136,22 @@ export class TerritoryRenderer {
this.defendedStrengthPass,
];
+ this.frameComputePasses = [this.visualStateSmoothingPass];
+
// Create render passes
this.territoryRenderPass = new TerritoryRenderPass();
- this.renderPasses = [this.territoryRenderPass];
+ this.temporalResolvePass = new TemporalResolvePass();
+ this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass];
// Initialize all passes
for (const pass of this.computePasses) {
await pass.init(webgpuDevice.device, this.resources);
}
+ for (const pass of this.frameComputePasses) {
+ await pass.init(webgpuDevice.device, this.resources);
+ }
+
for (const pass of this.renderPasses) {
await pass.init(
webgpuDevice.device,
@@ -144,6 +164,9 @@ export class TerritoryRenderer {
await this.territoryRenderPass.setShader(this.territoryShaderPath);
}
+ this.applyPreSmoothingConfig();
+ this.applyPostSmoothingConfig();
+
// Compute dependency order (topological sort)
this.computePassOrder = this.topologicalSort(this.computePasses);
this.renderPassOrder = this.topologicalSort(this.renderPasses);
@@ -215,6 +238,15 @@ export class TerritoryRenderer {
this.canvas.height = nextHeight;
this.resources.setViewSize(nextWidth, nextHeight);
this.device.reconfigure();
+
+ if (this.postSmoothingEnabled && this.resources) {
+ this.resources.ensurePostSmoothingTextures(
+ nextWidth,
+ nextHeight,
+ this.device.canvasFormat,
+ );
+ this.resources.invalidateHistory();
+ }
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
@@ -243,6 +275,7 @@ export class TerritoryRenderer {
if (this.territoryRenderPass) {
void this.territoryRenderPass.setShader(shaderPath);
}
+ this.resources?.invalidateHistory();
}
setTerritoryShaderParams(
@@ -261,6 +294,77 @@ export class TerritoryRenderer {
this.territoryShaderParams0,
this.territoryShaderParams1,
);
+ this.resources.invalidateHistory();
+ }
+
+ setPreSmoothing(
+ enabled: boolean,
+ shaderPath: string,
+ params0: Float32Array | number[],
+ ): void {
+ this.preSmoothingEnabled = enabled;
+ if (shaderPath) {
+ this.preSmoothingShaderPath = shaderPath;
+ }
+ for (let i = 0; i < 4; i++) {
+ this.preSmoothingParams0[i] = Number(params0[i] ?? 0);
+ }
+ this.applyPreSmoothingConfig();
+ }
+
+ setPostSmoothing(
+ enabled: boolean,
+ shaderPath: string,
+ params0: Float32Array | number[],
+ ): void {
+ this.postSmoothingEnabled = enabled;
+ if (shaderPath) {
+ this.postSmoothingShaderPath = shaderPath;
+ }
+ for (let i = 0; i < 4; i++) {
+ this.postSmoothingParams0[i] = Number(params0[i] ?? 0);
+ }
+ this.applyPostSmoothingConfig();
+ }
+
+ private applyPreSmoothingConfig(): void {
+ if (!this.resources || !this.visualStateSmoothingPass) {
+ return;
+ }
+
+ this.resources.setUseVisualStateTexture(this.preSmoothingEnabled);
+ if (this.preSmoothingEnabled) {
+ this.resources.ensureVisualStateTexture();
+ void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath);
+ this.visualStateSmoothingPass.setParams(this.preSmoothingParams0);
+ } else {
+ this.visualStateSmoothingPass.setParams(new Float32Array(4));
+ this.resources.releaseVisualStateTexture();
+ }
+
+ this.resources.invalidateHistory();
+ }
+
+ private applyPostSmoothingConfig(): void {
+ if (!this.resources || !this.temporalResolvePass || !this.device) {
+ return;
+ }
+
+ if (this.postSmoothingEnabled) {
+ void this.temporalResolvePass.setShader(this.postSmoothingShaderPath);
+ this.temporalResolvePass.setParams(this.postSmoothingParams0);
+ this.temporalResolvePass.setEnabled(true);
+ this.resources.ensurePostSmoothingTextures(
+ this.canvas.width,
+ this.canvas.height,
+ this.device.canvasFormat,
+ );
+ } else {
+ this.temporalResolvePass.setEnabled(false);
+ this.resources.releasePostSmoothingTextures();
+ }
+
+ this.resources.invalidateHistory();
}
markTile(tile: TileRef): void {
@@ -340,6 +444,8 @@ export class TerritoryRenderer {
return;
}
+ this.resources.updateTickTiming(performance.now() / 1000);
+
if (this.game.config().defensePostRange() !== this.defensePostRange) {
throw new Error("defensePostRange changed at runtime; unsupported.");
}
@@ -356,9 +462,14 @@ export class TerritoryRenderer {
// Initial state upload
this.resources.uploadState();
+ const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false;
+ if (!stateUpdatesPending) {
+ this.resources.setLastStateUpdateCount(0);
+ }
+
const needsCompute =
(this.terrainComputePass?.needsUpdate() ?? false) ||
- (this.stateUpdatePass?.needsUpdate() ?? false) ||
+ stateUpdatesPending ||
(this.defendedStrengthFullPass?.needsUpdate() ?? false) ||
(this.defendedStrengthPass?.needsUpdate() ?? false);
@@ -368,6 +479,23 @@ export class TerritoryRenderer {
const encoder = this.device.device.createCommandEncoder();
+ if (this.preSmoothingEnabled && stateUpdatesPending) {
+ this.resources.ensureVisualStateTexture();
+ const visualStateTexture = this.resources.getVisualStateTexture();
+ if (visualStateTexture) {
+ encoder.copyTextureToTexture(
+ { texture: this.resources.stateTexture },
+ { texture: visualStateTexture },
+ {
+ width: this.resources.getMapWidth(),
+ height: this.resources.getMapHeight(),
+ depthOrArrayLayers: 1,
+ },
+ );
+ this.resources.consumeVisualStateSyncNeeded();
+ }
+ }
+
// Execute compute passes in dependency order (clear will run before update if needed)
for (const pass of this.computePassOrder) {
if (!pass.needsUpdate()) {
@@ -393,6 +521,9 @@ export class TerritoryRenderer {
return;
}
+ const nowSec = performance.now() / 1000;
+ this.resources.writeTemporalUniformBuffer(nowSec);
+
// If terrain needs recomputation, trigger it asynchronously (no blocking)
// It will be ready for the next frame, acceptable trade-off for performance
if (this.terrainComputePass?.needsUpdate()) {
@@ -404,14 +535,54 @@ export class TerritoryRenderer {
}
const encoder = this.device.device.createCommandEncoder();
- const textureView = this.device.context.getCurrentTexture().createView();
+ const swapchainView = this.device.context.getCurrentTexture().createView();
+
+ if (
+ this.preSmoothingEnabled &&
+ this.resources.consumeVisualStateSyncNeeded()
+ ) {
+ const visualStateTexture = this.resources.getVisualStateTexture();
+ if (visualStateTexture) {
+ encoder.copyTextureToTexture(
+ { texture: this.resources.stateTexture },
+ { texture: visualStateTexture },
+ {
+ width: this.resources.getMapWidth(),
+ height: this.resources.getMapHeight(),
+ depthOrArrayLayers: 1,
+ },
+ );
+ }
+ }
+
+ for (const pass of this.frameComputePasses) {
+ if (!pass.needsUpdate()) {
+ continue;
+ }
+ pass.execute(encoder, this.resources);
+ }
// Execute render passes in dependency order
for (const pass of this.renderPassOrder) {
if (!pass.needsUpdate()) {
continue;
}
- pass.execute(encoder, this.resources, textureView);
+ if (pass === this.territoryRenderPass && this.postSmoothingEnabled) {
+ if (!this.resources.getCurrentColorTexture()) {
+ this.resources.ensurePostSmoothingTextures(
+ this.canvas.width,
+ this.canvas.height,
+ this.device.canvasFormat,
+ );
+ }
+ const currentTexture = this.resources.getCurrentColorTexture();
+ if (currentTexture) {
+ pass.execute(encoder, this.resources, currentTexture.createView());
+ }
+ continue;
+ }
+
+ pass.execute(encoder, this.resources, swapchainView);
}
this.device.device.queue.submit([encoder.finish()]);
diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts
index 05dee89ff..f874305e2 100644
--- a/src/client/graphics/webgpu/compute/StateUpdatePass.ts
+++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts
@@ -87,6 +87,8 @@ export class StateUpdatePass implements ComputePass {
return;
}
+ resources.setLastStateUpdateCount(numUpdates);
+
const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates);
resources.writeStateUpdateParamsBuffer(numUpdates);
diff --git a/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts
new file mode 100644
index 000000000..488c3c078
--- /dev/null
+++ b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts
@@ -0,0 +1,203 @@
+import { GroundTruthData } from "../core/GroundTruthData";
+import { loadShader } from "../core/ShaderLoader";
+import { ComputePass } from "./ComputePass";
+
+/**
+ * Per-frame compute pass that updates the visual state texture.
+ * Supports dissolve and budgeted reveal modes.
+ */
+export class VisualStateSmoothingPass implements ComputePass {
+ name = "visual-state-smoothing";
+ dependencies: string[] = [];
+
+ private pipeline: GPUComputePipeline | null = null;
+ private bindGroupLayout: GPUBindGroupLayout | null = null;
+ private bindGroup: GPUBindGroup | null = null;
+ private device: GPUDevice | null = null;
+ private resources: GroundTruthData | null = null;
+ private paramsBuffer: GPUBuffer | null = null;
+ private paramsData = new Float32Array(8);
+ private enabled = false;
+ private shaderPath = "compute/visual-state-smoothing.wgsl";
+ private mode = 0;
+ private curveExp = 1;
+ private boundUpdatesBuffer: GPUBuffer | null = null;
+ private boundVisualStateTexture: GPUTexture | null = null;
+
+ async init(device: GPUDevice, resources: GroundTruthData): Promise
{
+ this.device = device;
+ this.resources = resources;
+
+ const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
+ const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
+ const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
+
+ this.paramsBuffer = device.createBuffer({
+ size: 32,
+ usage: UNIFORM | COPY_DST,
+ });
+
+ await this.setShader(this.shaderPath);
+ this.rebuildBindGroup();
+ }
+
+ async setShader(shaderPath: string): Promise {
+ this.shaderPath = shaderPath;
+ if (!this.device) {
+ return;
+ }
+ const shaderCode = await loadShader(shaderPath);
+ const shaderModule = this.device.createShaderModule({ code: shaderCode });
+
+ this.bindGroupLayout = this.device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: 4 /* COMPUTE */,
+ buffer: { type: "uniform" },
+ },
+ {
+ binding: 1,
+ visibility: 4 /* COMPUTE */,
+ buffer: { type: "uniform" },
+ },
+ {
+ binding: 2,
+ visibility: 4 /* COMPUTE */,
+ buffer: { type: "read-only-storage" },
+ },
+ {
+ binding: 3,
+ visibility: 4 /* COMPUTE */,
+ storageTexture: { format: "r32uint" },
+ },
+ ],
+ });
+
+ this.pipeline = this.device.createComputePipeline({
+ layout: this.device.createPipelineLayout({
+ bindGroupLayouts: [this.bindGroupLayout],
+ }),
+ compute: {
+ module: shaderModule,
+ entryPoint: "main",
+ },
+ });
+
+ this.rebuildBindGroup();
+ }
+
+ setParams(params0: Float32Array | number[]): void {
+ this.mode = Number(params0[0] ?? 0);
+ this.curveExp = Number(params0[1] ?? 1);
+ this.enabled = this.mode > 0;
+ }
+
+ needsUpdate(): boolean {
+ if (!this.enabled || !this.resources) {
+ return false;
+ }
+ return this.resources.getLastStateUpdateCount() > 0;
+ }
+
+ execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void {
+ if (!this.device || !this.pipeline || !this.paramsBuffer) {
+ return;
+ }
+
+ const updateCount = resources.getLastStateUpdateCount();
+ if (updateCount <= 0) {
+ return;
+ }
+
+ const updatesBuffer = resources.updatesBuffer;
+ const visualStateTexture = resources.getVisualStateTexture();
+ if (!updatesBuffer || !visualStateTexture) {
+ return;
+ }
+
+ this.paramsData[0] = this.mode;
+ this.paramsData[1] = this.curveExp;
+ this.paramsData[2] = 0;
+ this.paramsData[3] = 0;
+ this.paramsData[4] = updateCount;
+ this.paramsData[5] = 0;
+ this.paramsData[6] = 0;
+ this.paramsData[7] = 0;
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
+
+ const shouldRebuild =
+ !this.bindGroup ||
+ this.boundUpdatesBuffer !== updatesBuffer ||
+ this.boundVisualStateTexture !== visualStateTexture;
+ if (shouldRebuild) {
+ this.rebuildBindGroup();
+ }
+
+ if (!this.bindGroup) {
+ return;
+ }
+
+ const pass = encoder.beginComputePass();
+ pass.setPipeline(this.pipeline);
+ pass.setBindGroup(0, this.bindGroup);
+ const workgroupCount = Math.ceil(updateCount / 64);
+ pass.dispatchWorkgroups(workgroupCount);
+ pass.end();
+ }
+
+ private rebuildBindGroup(): void {
+ if (
+ !this.device ||
+ !this.bindGroupLayout ||
+ !this.resources ||
+ !this.resources.temporalUniformBuffer ||
+ !this.paramsBuffer ||
+ !this.resources.updatesBuffer ||
+ !this.resources.getVisualStateTexture()
+ ) {
+ this.bindGroup = null;
+ return;
+ }
+
+ const visualStateTexture = this.resources.getVisualStateTexture();
+ if (!visualStateTexture) {
+ this.bindGroup = null;
+ return;
+ }
+
+ this.bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: { buffer: this.resources.temporalUniformBuffer },
+ },
+ {
+ binding: 1,
+ resource: { buffer: this.paramsBuffer },
+ },
+ {
+ binding: 2,
+ resource: { buffer: this.resources.updatesBuffer },
+ },
+ {
+ binding: 3,
+ resource: visualStateTexture.createView(),
+ },
+ ],
+ });
+
+ this.boundUpdatesBuffer = this.resources.updatesBuffer;
+ this.boundVisualStateTexture = visualStateTexture;
+ }
+
+ dispose(): void {
+ this.pipeline = null;
+ this.bindGroupLayout = null;
+ this.bindGroup = null;
+ this.device = null;
+ this.resources = null;
+ this.paramsBuffer = null;
+ }
+}
diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts
index bef95e294..e0a2923ee 100644
--- a/src/client/graphics/webgpu/core/GroundTruthData.ts
+++ b/src/client/graphics/webgpu/core/GroundTruthData.ts
@@ -26,9 +26,13 @@ export class GroundTruthData {
public readonly ownerIndexTexture: GPUTexture;
public readonly relationsTexture: GPUTexture;
public readonly defendedStrengthTexture: GPUTexture;
+ public visualStateTexture: GPUTexture | null = null;
+ public currentColorTexture: GPUTexture | null = null;
+ public historyColorTextures: [GPUTexture, GPUTexture] | null = null;
// Buffers
public readonly uniformBuffer: GPUBuffer;
+ public readonly temporalUniformBuffer: GPUBuffer;
public readonly terrainParamsBuffer: GPUBuffer;
public readonly stateUpdateParamsBuffer: GPUBuffer;
public readonly defendedStrengthParamsBuffer: GPUBuffer;
@@ -57,6 +61,19 @@ export class GroundTruthData {
private needsPaletteUpload = true;
private needsTerrainDataUpload = true;
private needsTerrainParamsUpload = true;
+ private useVisualStateTexture = false;
+ private visualStateNeedsSync = false;
+ private lastStateUpdateCount = 0;
+ private historyIndex = 0;
+ private historyValid = false;
+ private postSmoothingWidth = 0;
+ private postSmoothingHeight = 0;
+ private postSmoothingFormat: GPUTextureFormat | null = null;
+ private lastTickSec = 0;
+ private tickDtSec = 0.1;
+ private tickDtEmaSec = 0.1;
+ private tickCount = 0;
+ private readonly tickEmaAlpha = 0.2;
private paletteWidth = 1;
private needsDefensePostsUpload = true;
private defensePostsTotalCount = 0;
@@ -68,6 +85,7 @@ export class GroundTruthData {
// Uniform data arrays
private readonly uniformData = new Float32Array(20);
+ private readonly temporalData = new Float32Array(8);
private readonly terrainParamsData = new Float32Array(24); // 6 vec4f: shore, water, shorelineWater, plainsBase, highlandBase, mountainBase
private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad
private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad
@@ -107,6 +125,7 @@ export class GroundTruthData {
const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8;
const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
+ const COPY_SRC_TEX = GPUTextureUsage?.COPY_SRC ?? 0x1;
const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
@@ -116,6 +135,12 @@ export class GroundTruthData {
usage: UNIFORM | COPY_DST_BUF,
});
+ // Temporal uniforms: 2x vec4f = 32 bytes
+ this.temporalUniformBuffer = device.createBuffer({
+ size: 32,
+ usage: UNIFORM | COPY_DST_BUF,
+ });
+
// State update params: 4x u32 = 16 bytes
this.stateUpdateParamsBuffer = device.createBuffer({
size: 16,
@@ -138,7 +163,7 @@ export class GroundTruthData {
this.stateTexture = device.createTexture({
size: { width: mapWidth, height: mapHeight },
format: "r32uint",
- usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
+ usage: COPY_DST_TEX | COPY_SRC_TEX | TEXTURE_BINDING | STORAGE_BINDING,
});
// Defended strength texture (rgba8unorm, r channel used)
@@ -233,13 +258,24 @@ export class GroundTruthData {
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
+ const eps = 1e-4;
+ const changed =
+ Math.abs(scale - this.viewScale) > eps ||
+ Math.abs(offsetX - this.viewOffsetX) > eps ||
+ Math.abs(offsetY - this.viewOffsetY) > eps;
this.viewScale = scale;
this.viewOffsetX = offsetX;
this.viewOffsetY = offsetY;
+ if (changed) {
+ this.invalidateHistory();
+ }
}
setAlternativeView(enabled: boolean): void {
- this.alternativeView = enabled;
+ if (this.alternativeView !== enabled) {
+ this.alternativeView = enabled;
+ this.invalidateHistory();
+ }
}
setHighlightedOwnerId(ownerSmallId: number | null): void {
@@ -256,6 +292,190 @@ export class GroundTruthData {
}
}
+ setUseVisualStateTexture(enabled: boolean): void {
+ this.useVisualStateTexture = enabled;
+ if (enabled) {
+ this.visualStateNeedsSync = true;
+ }
+ }
+
+ consumeVisualStateSyncNeeded(): boolean {
+ if (!this.visualStateNeedsSync) {
+ return false;
+ }
+ this.visualStateNeedsSync = false;
+ return true;
+ }
+
+ ensureVisualStateTexture(): void {
+ if (this.visualStateTexture) {
+ return;
+ }
+ const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
+ const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2;
+ const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
+ const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8;
+ this.visualStateTexture = this.device.createTexture({
+ size: { width: this.mapWidth, height: this.mapHeight },
+ format: "r32uint",
+ usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING,
+ });
+ }
+
+ releaseVisualStateTexture(): void {
+ if (this.visualStateTexture) {
+ (this.visualStateTexture as any).destroy?.();
+ this.visualStateTexture = null;
+ }
+ }
+
+ getVisualStateTexture(): GPUTexture | null {
+ return this.visualStateTexture;
+ }
+
+ getRenderStateTexture(): GPUTexture {
+ if (this.useVisualStateTexture && this.visualStateTexture) {
+ return this.visualStateTexture;
+ }
+ return this.stateTexture;
+ }
+
+ setLastStateUpdateCount(count: number): void {
+ this.lastStateUpdateCount = Math.max(0, Math.floor(count));
+ }
+
+ getLastStateUpdateCount(): number {
+ return this.lastStateUpdateCount;
+ }
+
+ updateTickTiming(nowSec: number): void {
+ if (this.lastTickSec > 0) {
+ const dt = Math.max(1e-3, nowSec - this.lastTickSec);
+ this.tickDtSec = dt;
+ this.tickDtEmaSec =
+ this.tickDtEmaSec * (1 - this.tickEmaAlpha) + dt * this.tickEmaAlpha;
+ }
+ this.lastTickSec = nowSec;
+ this.tickCount += 1;
+ }
+
+ writeTemporalUniformBuffer(nowSec: number): void {
+ const denom = Math.max(1e-3, this.tickDtEmaSec);
+ const alpha = Math.max(0, Math.min(1, (nowSec - this.lastTickSec) / denom));
+
+ this.temporalData[0] = nowSec;
+ this.temporalData[1] = this.lastTickSec;
+ this.temporalData[2] = this.tickDtSec;
+ this.temporalData[3] = this.tickDtEmaSec;
+ this.temporalData[4] = alpha;
+ this.temporalData[5] = this.tickCount;
+ this.temporalData[6] = this.historyValid ? 1 : 0;
+ this.temporalData[7] = 0;
+
+ this.device.queue.writeBuffer(
+ this.temporalUniformBuffer,
+ 0,
+ this.temporalData,
+ );
+ }
+
+ invalidateHistory(): void {
+ this.historyValid = false;
+ }
+
+ markHistoryValid(): void {
+ this.historyValid = true;
+ }
+
+ swapHistoryTextures(): void {
+ if (!this.historyColorTextures) {
+ return;
+ }
+ this.historyIndex = this.historyIndex === 0 ? 1 : 0;
+ }
+
+ ensurePostSmoothingTextures(
+ width: number,
+ height: number,
+ format: GPUTextureFormat,
+ ): void {
+ const w = Math.max(1, Math.floor(width));
+ const h = Math.max(1, Math.floor(height));
+ const needsRebuild =
+ !this.currentColorTexture ||
+ !this.historyColorTextures ||
+ this.postSmoothingWidth !== w ||
+ this.postSmoothingHeight !== h ||
+ this.postSmoothingFormat !== format;
+
+ if (!needsRebuild) {
+ return;
+ }
+
+ this.releasePostSmoothingTextures();
+
+ const GPUTextureUsage = (globalThis as any).GPUTextureUsage;
+ const RENDER_ATTACHMENT = GPUTextureUsage?.RENDER_ATTACHMENT ?? 0x10;
+ const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4;
+
+ this.currentColorTexture = this.device.createTexture({
+ size: { width: w, height: h },
+ format,
+ usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
+ });
+ const historyA = this.device.createTexture({
+ size: { width: w, height: h },
+ format,
+ usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
+ });
+ const historyB = this.device.createTexture({
+ size: { width: w, height: h },
+ format,
+ usage: RENDER_ATTACHMENT | TEXTURE_BINDING,
+ });
+
+ this.historyColorTextures = [historyA, historyB];
+ this.historyIndex = 0;
+ this.historyValid = false;
+ this.postSmoothingWidth = w;
+ this.postSmoothingHeight = h;
+ this.postSmoothingFormat = format;
+ }
+
+ releasePostSmoothingTextures(): void {
+ if (this.currentColorTexture) {
+ (this.currentColorTexture as any).destroy?.();
+ this.currentColorTexture = null;
+ }
+ if (this.historyColorTextures) {
+ (this.historyColorTextures[0] as any).destroy?.();
+ (this.historyColorTextures[1] as any).destroy?.();
+ this.historyColorTextures = null;
+ }
+ this.historyValid = false;
+ this.postSmoothingWidth = 0;
+ this.postSmoothingHeight = 0;
+ this.postSmoothingFormat = null;
+ }
+
+ getCurrentColorTexture(): GPUTexture | null {
+ return this.currentColorTexture;
+ }
+
+ getHistoryReadTexture(): GPUTexture | null {
+ if (!this.historyColorTextures) {
+ return null;
+ }
+ return this.historyColorTextures[this.historyIndex];
+ }
+
+ getHistoryWriteTexture(): GPUTexture | null {
+ if (!this.historyColorTextures) {
+ return null;
+ }
+ return this.historyColorTextures[this.historyIndex === 0 ? 1 : 0];
+ }
+
// =====================
// Upload methods
// =====================
diff --git a/src/client/graphics/webgpu/render/TemporalResolvePass.ts b/src/client/graphics/webgpu/render/TemporalResolvePass.ts
new file mode 100644
index 000000000..1d9a4b162
--- /dev/null
+++ b/src/client/graphics/webgpu/render/TemporalResolvePass.ts
@@ -0,0 +1,218 @@
+import { GroundTruthData } from "../core/GroundTruthData";
+import { loadShader } from "../core/ShaderLoader";
+import { RenderPass } from "./RenderPass";
+
+/**
+ * Post-render temporal resolve pass. Blends current and history frames.
+ */
+export class TemporalResolvePass implements RenderPass {
+ name = "temporal-resolve";
+ dependencies: string[] = ["territory"];
+
+ private pipeline: GPURenderPipeline | null = null;
+ private bindGroupLayout: GPUBindGroupLayout | null = null;
+ private bindGroup: GPUBindGroup | null = null;
+ private device: GPUDevice | null = null;
+ private resources: GroundTruthData | null = null;
+ private canvasFormat: GPUTextureFormat | null = null;
+ private paramsBuffer: GPUBuffer | null = null;
+ private paramsData = new Float32Array(4);
+ private enabled = false;
+ private boundCurrentTexture: GPUTexture | null = null;
+ private boundHistoryTexture: GPUTexture | null = null;
+
+ async init(
+ device: GPUDevice,
+ resources: GroundTruthData,
+ canvasFormat: GPUTextureFormat,
+ ): Promise {
+ this.device = device;
+ this.resources = resources;
+ this.canvasFormat = canvasFormat;
+
+ const GPUBufferUsage = (globalThis as any).GPUBufferUsage;
+ const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40;
+ const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8;
+ this.paramsBuffer = device.createBuffer({
+ size: 16,
+ usage: UNIFORM | COPY_DST,
+ });
+
+ this.bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: 2 /* FRAGMENT */,
+ buffer: { type: "uniform" },
+ },
+ {
+ binding: 1,
+ visibility: 2 /* FRAGMENT */,
+ buffer: { type: "uniform" },
+ },
+ {
+ binding: 2,
+ visibility: 2 /* FRAGMENT */,
+ texture: { sampleType: "float" },
+ },
+ {
+ binding: 3,
+ visibility: 2 /* FRAGMENT */,
+ texture: { sampleType: "float" },
+ },
+ ],
+ });
+
+ await this.setShader("render/temporal-resolve.wgsl");
+ this.rebuildBindGroup();
+ }
+
+ async setShader(shaderPath: string): Promise {
+ if (!this.device || !this.bindGroupLayout || !this.canvasFormat) {
+ return;
+ }
+
+ const shaderCode = await loadShader(shaderPath);
+ const shaderModule = this.device.createShaderModule({ code: shaderCode });
+
+ this.pipeline = this.device.createRenderPipeline({
+ layout: this.device.createPipelineLayout({
+ bindGroupLayouts: [this.bindGroupLayout],
+ }),
+ vertex: { module: shaderModule, entryPoint: "vsMain" },
+ fragment: {
+ module: shaderModule,
+ entryPoint: "fsMain",
+ targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }],
+ },
+ primitive: { topology: "triangle-list" },
+ });
+ }
+
+ setParams(params0: Float32Array | number[]): void {
+ this.paramsData[0] = Number(params0[0] ?? 0);
+ this.paramsData[1] = Number(params0[1] ?? 1);
+ this.paramsData[2] = Number(params0[2] ?? 0.08);
+ this.paramsData[3] = 0;
+ this.enabled = this.paramsData[0] > 0;
+ }
+
+ setEnabled(enabled: boolean): void {
+ this.enabled = enabled;
+ }
+
+ needsUpdate(): boolean {
+ return this.enabled;
+ }
+
+ execute(
+ encoder: GPUCommandEncoder,
+ resources: GroundTruthData,
+ target: GPUTextureView,
+ ): void {
+ if (!this.device || !this.pipeline || !this.paramsBuffer) {
+ return;
+ }
+ if (!this.enabled) {
+ return;
+ }
+
+ const currentTexture = resources.getCurrentColorTexture();
+ const historyRead = resources.getHistoryReadTexture();
+ const historyWrite = resources.getHistoryWriteTexture();
+ if (!currentTexture || !historyRead || !historyWrite) {
+ return;
+ }
+
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData);
+
+ const shouldRebuild =
+ !this.bindGroup ||
+ this.boundCurrentTexture !== currentTexture ||
+ this.boundHistoryTexture !== historyRead;
+ if (shouldRebuild) {
+ this.rebuildBindGroup();
+ }
+
+ if (!this.bindGroup) {
+ return;
+ }
+
+ const pass = encoder.beginRenderPass({
+ colorAttachments: [
+ {
+ view: target,
+ loadOp: "clear",
+ storeOp: "store",
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ {
+ view: historyWrite.createView(),
+ loadOp: "clear",
+ storeOp: "store",
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ });
+
+ pass.setPipeline(this.pipeline);
+ pass.setBindGroup(0, this.bindGroup);
+ pass.draw(3);
+ pass.end();
+
+ resources.swapHistoryTextures();
+ resources.markHistoryValid();
+ }
+
+ rebuildBindGroup(): void {
+ if (
+ !this.device ||
+ !this.bindGroupLayout ||
+ !this.resources ||
+ !this.resources.temporalUniformBuffer ||
+ !this.paramsBuffer
+ ) {
+ return;
+ }
+
+ const currentTexture = this.resources.getCurrentColorTexture();
+ const historyRead = this.resources.getHistoryReadTexture();
+ if (!currentTexture || !historyRead) {
+ return;
+ }
+
+ this.bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: { buffer: this.resources.temporalUniformBuffer },
+ },
+ {
+ binding: 1,
+ resource: { buffer: this.paramsBuffer },
+ },
+ {
+ binding: 2,
+ resource: currentTexture.createView(),
+ },
+ {
+ binding: 3,
+ resource: historyRead.createView(),
+ },
+ ],
+ });
+
+ this.boundCurrentTexture = currentTexture;
+ this.boundHistoryTexture = historyRead;
+ }
+
+ dispose(): void {
+ this.pipeline = null;
+ this.bindGroupLayout = null;
+ this.bindGroup = null;
+ this.device = null;
+ this.resources = null;
+ this.paramsBuffer = null;
+ }
+}
diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
new file mode 100644
index 000000000..e0fcb073d
--- /dev/null
+++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
@@ -0,0 +1,128 @@
+import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
+
+export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve";
+
+export interface TerritoryPostSmoothingDefinition {
+ id: TerritoryPostSmoothingId;
+ label: string;
+ wgslPath: string;
+ options: TerritoryShaderOption[];
+}
+
+export const TERRITORY_POST_SMOOTHING_KEY =
+ "settings.webgpu.territory.smoothing.post";
+
+export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [
+ {
+ id: "off",
+ label: "Off",
+ wgslPath: "",
+ options: [],
+ },
+ {
+ id: "fade",
+ label: "Fade",
+ wgslPath: "render/temporal-resolve.wgsl",
+ options: [
+ {
+ kind: "range",
+ key: "settings.webgpu.territory.postSmoothing.blendStrength",
+ label: "Blend Strength",
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ ],
+ },
+ {
+ id: "dissolve",
+ label: "Dissolve",
+ wgslPath: "render/temporal-resolve.wgsl",
+ options: [
+ {
+ kind: "range",
+ key: "settings.webgpu.territory.postSmoothing.blendStrength",
+ label: "Blend Strength",
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ kind: "range",
+ key: "settings.webgpu.territory.postSmoothing.dissolveWidth",
+ label: "Dissolve Width",
+ defaultValue: 0.08,
+ min: 0.01,
+ max: 0.4,
+ step: 0.01,
+ },
+ ],
+ },
+];
+
+export function territoryPostSmoothingIdFromInt(
+ value: number,
+): TerritoryPostSmoothingId {
+ if (value === 1) return "fade";
+ if (value === 2) return "dissolve";
+ return "off";
+}
+
+export function territoryPostSmoothingIntFromId(
+ id: TerritoryPostSmoothingId,
+): number {
+ if (id === "fade") return 1;
+ if (id === "dissolve") return 2;
+ return 0;
+}
+
+export function readTerritoryPostSmoothingId(userSettings: {
+ getInt: (key: string, defaultValue: number) => number;
+}): TerritoryPostSmoothingId {
+ return territoryPostSmoothingIdFromInt(
+ userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0),
+ );
+}
+
+export function buildTerritoryPostSmoothingParams(
+ userSettings: {
+ getFloat: (key: string, defaultValue: number) => number;
+ },
+ smoothingId: TerritoryPostSmoothingId,
+): {
+ enabled: boolean;
+ shaderPath: string;
+ params0: Float32Array;
+ params1: Float32Array;
+} {
+ if (smoothingId === "off") {
+ return {
+ enabled: false,
+ shaderPath: "",
+ params0: new Float32Array(4),
+ params1: new Float32Array(4),
+ };
+ }
+
+ const blendStrength = userSettings.getFloat(
+ "settings.webgpu.territory.postSmoothing.blendStrength",
+ 1,
+ );
+ const dissolveWidth = userSettings.getFloat(
+ "settings.webgpu.territory.postSmoothing.dissolveWidth",
+ 0.08,
+ );
+
+ const mode = smoothingId === "fade" ? 1 : 2;
+ const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]);
+ const params1 = new Float32Array([0, 0, 0, 0]);
+
+ return {
+ enabled: true,
+ shaderPath: "render/temporal-resolve.wgsl",
+ params0,
+ params1,
+ };
+}
diff --git a/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts
new file mode 100644
index 000000000..e04ee0a6d
--- /dev/null
+++ b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts
@@ -0,0 +1,114 @@
+import { TerritoryShaderOption } from "./TerritoryShaderRegistry";
+
+export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget";
+
+export interface TerritoryPreSmoothingDefinition {
+ id: TerritoryPreSmoothingId;
+ label: string;
+ wgslPath: string;
+ options: TerritoryShaderOption[];
+}
+
+export const TERRITORY_PRE_SMOOTHING_KEY =
+ "settings.webgpu.territory.smoothing.pre";
+
+export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [
+ {
+ id: "off",
+ label: "Off",
+ wgslPath: "",
+ options: [],
+ },
+ {
+ id: "dissolve",
+ label: "Dissolve",
+ wgslPath: "compute/visual-state-smoothing.wgsl",
+ options: [
+ {
+ kind: "range",
+ key: "settings.webgpu.territory.preSmoothing.curveExp",
+ label: "Reveal Curve",
+ defaultValue: 1,
+ min: 0.25,
+ max: 3,
+ step: 0.05,
+ },
+ ],
+ },
+ {
+ id: "budget",
+ label: "Budgeted Reveal",
+ wgslPath: "compute/visual-state-smoothing.wgsl",
+ options: [
+ {
+ kind: "range",
+ key: "settings.webgpu.territory.preSmoothing.curveExp",
+ label: "Reveal Curve",
+ defaultValue: 1,
+ min: 0.25,
+ max: 3,
+ step: 0.05,
+ },
+ ],
+ },
+];
+
+export function territoryPreSmoothingIdFromInt(
+ value: number,
+): TerritoryPreSmoothingId {
+ if (value === 1) return "dissolve";
+ if (value === 2) return "budget";
+ return "off";
+}
+
+export function territoryPreSmoothingIntFromId(
+ id: TerritoryPreSmoothingId,
+): number {
+ if (id === "dissolve") return 1;
+ if (id === "budget") return 2;
+ return 0;
+}
+
+export function readTerritoryPreSmoothingId(userSettings: {
+ getInt: (key: string, defaultValue: number) => number;
+}): TerritoryPreSmoothingId {
+ return territoryPreSmoothingIdFromInt(
+ userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0),
+ );
+}
+
+export function buildTerritoryPreSmoothingParams(
+ userSettings: {
+ getFloat: (key: string, defaultValue: number) => number;
+ },
+ smoothingId: TerritoryPreSmoothingId,
+): {
+ enabled: boolean;
+ shaderPath: string;
+ params0: Float32Array;
+ params1: Float32Array;
+} {
+ if (smoothingId === "off") {
+ return {
+ enabled: false,
+ shaderPath: "",
+ params0: new Float32Array(4),
+ params1: new Float32Array(4),
+ };
+ }
+
+ const curveExp = userSettings.getFloat(
+ "settings.webgpu.territory.preSmoothing.curveExp",
+ 1,
+ );
+ const mode = smoothingId === "dissolve" ? 1 : 2;
+
+ const params0 = new Float32Array([mode, curveExp, 0, 0]);
+ const params1 = new Float32Array([0, 0, 0, 0]);
+ return {
+ enabled: true,
+ shaderPath: "compute/visual-state-smoothing.wgsl",
+ params0,
+ params1,
+ };
+}
diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts
index 34f5f278a..b9f875a5b 100644
--- a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts
+++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts
@@ -157,7 +157,6 @@ export class TerritoryRenderPass implements RenderPass {
!this.bindGroupLayout ||
!this.resources ||
!this.resources.uniformBuffer ||
- !this.resources.stateTexture ||
!this.resources.defendedStrengthTexture ||
!this.resources.paletteTexture ||
!this.resources.terrainTexture ||
@@ -167,13 +166,15 @@ export class TerritoryRenderPass implements RenderPass {
return;
}
+ const stateTexture = this.resources.getRenderStateTexture();
+
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.resources.uniformBuffer } },
{
binding: 1,
- resource: this.resources.stateTexture.createView(),
+ resource: stateTexture.createView(),
},
{
binding: 2,
diff --git a/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl
new file mode 100644
index 000000000..3be34cec0
--- /dev/null
+++ b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl
@@ -0,0 +1,76 @@
+struct Temporal {
+ nowSec: f32,
+ lastTickSec: f32,
+ tickDtSec: f32,
+ tickDtEmaSec: f32,
+ tickAlpha: f32,
+ tickCount: f32,
+ historyValid: f32,
+ _pad0: f32,
+};
+
+struct Params {
+ params0: vec4f, // x=mode, y=curveExp
+ params1: vec4f, // x=updateCount
+};
+
+struct Update {
+ tileIndex: u32,
+ newState: u32,
+};
+
+@group(0) @binding(0) var t: Temporal;
+@group(0) @binding(1) var p: Params;
+@group(0) @binding(2) var updates: array;
+@group(0) @binding(3) var visualStateTex: texture_storage_2d;
+
+fn hashUint(x: u32) -> u32 {
+ var h = x * 1664525u + 1013904223u;
+ h ^= h >> 16u;
+ h *= 2246822519u;
+ h ^= h >> 13u;
+ h *= 3266489917u;
+ h ^= h >> 16u;
+ return h;
+}
+
+fn hashToUnitFloat(x: u32) -> f32 {
+ return f32(x & 0x00FFFFFFu) / 16777216.0;
+}
+
+@compute @workgroup_size(64)
+fn main(@builtin(global_invocation_id) globalId: vec3) {
+ let idx = globalId.x;
+ let updateCount = u32(max(0.0, p.params1.x) + 0.5);
+ if (idx >= updateCount) {
+ return;
+ }
+
+ let mode = u32(max(0.0, p.params0.x) + 0.5);
+ let curveExp = max(0.001, p.params0.y);
+ let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0);
+
+ let update = updates[idx];
+
+ if (mode == 1u) {
+ let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
+ let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u));
+ let r = hashToUnitFloat(h);
+ if (r > alpha) {
+ return;
+ }
+ } else if (mode == 2u) {
+ let targetCount = u32(floor(f32(updateCount) * alpha));
+ if (idx >= targetCount) {
+ return;
+ }
+ } else {
+ return;
+ }
+
+ let dims = textureDimensions(visualStateTex);
+ let mapWidth = dims.x;
+ let x = i32(update.tileIndex % mapWidth);
+ let y = i32(update.tileIndex / mapWidth);
+ textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u));
+}
diff --git a/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl
new file mode 100644
index 000000000..e4cd48dbe
--- /dev/null
+++ b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl
@@ -0,0 +1,81 @@
+struct Temporal {
+ nowSec: f32,
+ lastTickSec: f32,
+ tickDtSec: f32,
+ tickDtEmaSec: f32,
+ tickAlpha: f32,
+ tickCount: f32,
+ historyValid: f32,
+ _pad0: f32,
+};
+
+struct Params {
+ params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth
+};
+
+@group(0) @binding(0) var t: Temporal;
+@group(0) @binding(1) var p: Params;
+@group(0) @binding(2) var currentTex: texture_2d;
+@group(0) @binding(3) var historyTex: texture_2d;
+
+struct FragOutput {
+ @location(0) color: vec4f,
+ @location(1) history: vec4f,
+};
+
+@vertex
+fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
+ var pos = array(
+ vec2f(-1.0, -1.0),
+ vec2f(3.0, -1.0),
+ vec2f(-1.0, 3.0),
+ );
+ let p = pos[vi];
+ return vec4f(p, 0.0, 1.0);
+}
+
+fn hashUint(x: u32) -> u32 {
+ var h = x * 1664525u + 1013904223u;
+ h ^= h >> 16u;
+ h *= 2246822519u;
+ h ^= h >> 13u;
+ h *= 3266489917u;
+ h ^= h >> 16u;
+ return h;
+}
+
+fn hashToUnitFloat(x: u32) -> f32 {
+ return f32(x & 0x00FFFFFFu) / 16777216.0;
+}
+
+@fragment
+fn fsMain(@builtin(position) pos: vec4f) -> FragOutput {
+ let texCoord = vec2i(pos.xy);
+ let curr = textureLoad(currentTex, texCoord, 0);
+ let hist = textureLoad(historyTex, texCoord, 0);
+
+ let mode = u32(max(0.0, p.params0.x) + 0.5);
+ let strength = clamp(p.params0.y, 0.0, 1.0);
+ let width = max(0.001, p.params0.z);
+
+ var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0);
+ if (t.historyValid < 0.5) {
+ alpha = 1.0;
+ }
+
+ if (mode == 1u) {
+ let outColor = mix(hist, curr, alpha);
+ return FragOutput(outColor, outColor);
+ }
+
+ if (mode == 2u) {
+ let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u);
+ let tickSeed = u32(max(0.0, t.tickCount) + 0.5);
+ let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u)));
+ let mask = smoothstep(alpha - width, alpha + width, r);
+ let outColor = mix(hist, curr, mask);
+ return FragOutput(outColor, outColor);
+ }
+
+ return FragOutput(curr, curr);
+}
From fd87b0e3f8de2ec7015111a81b205880f56ab7cf Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Mon, 19 Jan 2026 19:55:19 +0100
Subject: [PATCH 14/23] adjusted defaults
---
.../webgpu/render/TerritoryPostSmoothingRegistry.ts | 6 +++---
.../graphics/webgpu/render/TerritoryShaderRegistry.ts | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
index e0fcb073d..be5a76a8e 100644
--- a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
@@ -28,8 +28,8 @@ export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [
kind: "range",
key: "settings.webgpu.territory.postSmoothing.blendStrength",
label: "Blend Strength",
- defaultValue: 1,
- min: 0,
+ defaultValue: 0.2,
+ min: 0.01,
max: 1,
step: 0.01,
},
@@ -108,7 +108,7 @@ export function buildTerritoryPostSmoothingParams(
const blendStrength = userSettings.getFloat(
"settings.webgpu.territory.postSmoothing.blendStrength",
- 1,
+ 0.2,
);
const dissolveWidth = userSettings.getFloat(
"settings.webgpu.territory.postSmoothing.dissolveWidth",
diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
index 183993fa2..ee78cdf1c 100644
--- a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts
@@ -194,7 +194,7 @@ export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [
key: "settings.webgpu.territory.retro.defendedThreshold",
label: "Defended Threshold",
defaultValue: 0.01,
- min: 0,
+ min: 0.01,
max: 1,
step: 0.01,
},
From c9ea04abacc6b8629c0bdf78488d4f1e9f1c3ef6 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 20 Jan 2026 21:43:25 +0100
Subject: [PATCH 15/23] Add improved terrain compute shaders with lite and
heavy variants
- Add terrain-compute-improved-lite.wgsl and terrain-compute-improved-heavy.wgsl
- Create TerrainShaderRegistry.ts for shader management
- Refactor TerrainComputePass to support dynamic shader switching
- Update TerritoryRenderer, TerritoryLayer, and GroundTruthData for new shader integration
- Enhance WebGPUDebugOverlay with additional debugging capabilities
---
src/client/graphics/layers/TerritoryLayer.ts | 26 ++
.../graphics/layers/WebGPUDebugOverlay.ts | 55 ++++-
.../graphics/webgpu/TerritoryRenderer.ts | 37 +++
.../webgpu/compute/TerrainComputePass.ts | 66 +++--
.../graphics/webgpu/core/GroundTruthData.ts | 29 ++-
.../webgpu/render/TerrainShaderRegistry.ts | 233 ++++++++++++++++++
.../terrain-compute-improved-heavy.wgsl | 167 +++++++++++++
.../terrain-compute-improved-lite.wgsl | 103 ++++++++
.../shaders/compute/terrain-compute.wgsl | 2 +
9 files changed, 688 insertions(+), 30 deletions(-)
create mode 100644 src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl
create mode 100644 src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index f2a2c93b5..94b27d886 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -11,6 +11,10 @@ import {
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
+import {
+ buildTerrainShaderParams,
+ readTerrainShaderId,
+} from "../webgpu/render/TerrainShaderRegistry";
import {
buildTerritoryPostSmoothingParams,
readTerritoryPostSmoothingId,
@@ -43,6 +47,7 @@ export class TerritoryLayer implements Layer {
private lastPaletteSignature: string | null = null;
private lastDefensePostsSignature: string | null = null;
+ private lastTerrainShaderSignature: string | null = null;
private lastTerritoryShaderSignature: string | null = null;
private lastPreSmoothingSignature: string | null = null;
private lastPostSmoothingSignature: string | null = null;
@@ -87,6 +92,7 @@ export class TerritoryLayer implements Layer {
this.refreshPaletteIfNeeded();
this.refreshDefensePostsIfNeeded();
+ this.applyTerrainShaderSettings();
this.applyTerritoryShaderSettings();
this.applyTerritorySmoothingSettings();
@@ -124,6 +130,7 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer = renderer;
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
+ this.applyTerrainShaderSettings(true);
this.applyTerritoryShaderSettings(true);
this.applyTerritorySmoothingSettings(true);
this.territoryRenderer.markAllDirty();
@@ -335,6 +342,25 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer.setTerritoryShaderParams(params0, params1);
}
+ private applyTerrainShaderSettings(force: boolean = false) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ const terrainId = readTerrainShaderId(this.userSettings);
+ const { shaderPath, params0, params1 } = buildTerrainShaderParams(
+ this.userSettings,
+ terrainId,
+ );
+ const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`;
+ if (!force && signature === this.lastTerrainShaderSignature) {
+ return;
+ }
+ this.lastTerrainShaderSignature = signature;
+ this.territoryRenderer.setTerrainShader(shaderPath);
+ this.territoryRenderer.setTerrainShaderParams(params0, params1);
+ }
+
private applyTerritorySmoothingSettings(force: boolean = false) {
if (!this.territoryRenderer) {
return;
diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts
index cf60eee5a..908d31ce4 100644
--- a/src/client/graphics/layers/WebGPUDebugOverlay.ts
+++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts
@@ -4,6 +4,13 @@ import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
+import {
+ TERRAIN_SHADER_KEY,
+ TERRAIN_SHADERS,
+ terrainShaderIdFromInt,
+ terrainShaderIntFromId,
+ TerrainShaderOption,
+} from "../webgpu/render/TerrainShaderRegistry";
import {
TERRITORY_POST_SMOOTHING,
TERRITORY_POST_SMOOTHING_KEY,
@@ -21,9 +28,12 @@ import {
TERRITORY_SHADERS,
territoryShaderIdFromInt,
territoryShaderIntFromId,
+ TerritoryShaderOption,
} from "../webgpu/render/TerritoryShaderRegistry";
import { Layer } from "./Layer";
+type ShaderOption = TerrainShaderOption | TerritoryShaderOption;
+
@customElement("webgpu-debug-overlay")
export class WebGPUDebugOverlay extends LitElement implements Layer {
@property({ type: Object })
@@ -186,6 +196,18 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
+ private selectedTerrainShaderId() {
+ const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0);
+ return terrainShaderIdFromInt(selected);
+ }
+
+ private setSelectedTerrainShaderId(
+ id: "classic" | "improved-lite" | "improved-heavy",
+ ) {
+ this.userSettings.setInt(TERRAIN_SHADER_KEY, terrainShaderIntFromId(id));
+ this.requestUpdate();
+ }
+
private selectedPreSmoothingId() {
const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
return territoryPreSmoothingIdFromInt(selected);
@@ -212,9 +234,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
- private renderOptionControl(
- option: (typeof TERRITORY_SHADERS)[number]["options"][number],
- ) {
+ private renderOptionControl(option: ShaderOption) {
if (option.kind === "boolean") {
const enabled = this.userSettings.get(option.key, option.defaultValue);
return html`
@@ -289,6 +309,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
const shaderId = this.selectedShaderId();
const shader =
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
+ const terrainShaderId = this.selectedTerrainShaderId();
+ const terrainShader =
+ TERRAIN_SHADERS.find((s) => s.id === terrainShaderId) ??
+ TERRAIN_SHADERS[0];
const preId = this.selectedPreSmoothingId();
const pre =
TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
@@ -315,6 +339,31 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
+
Terrain Shader
diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
index 9cb568c87..7470a0806 100644
--- a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
@@ -49,7 +49,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedLite.noiseStrength",
label: "Noise Strength",
- defaultValue: 0.025,
+ defaultValue: 0.005,
min: 0,
max: 0.08,
step: 0.005,
@@ -58,7 +58,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedLite.blendWidth",
label: "Biome Blend Width",
- defaultValue: 2.5,
+ defaultValue: 5,
min: 0.5,
max: 5,
step: 0.25,
@@ -74,7 +74,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.noiseStrength",
label: "Noise Strength",
- defaultValue: 0.025,
+ defaultValue: 0.01,
min: 0,
max: 0.1,
step: 0.005,
@@ -83,7 +83,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
label: "Detail Noise Strength",
- defaultValue: 0.015,
+ defaultValue: 0.01,
min: 0,
max: 0.08,
step: 0.005,
@@ -92,7 +92,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.blendWidth",
label: "Biome Blend Width",
- defaultValue: 2.8,
+ defaultValue: 4.5,
min: 0.5,
max: 6,
step: 0.25,
@@ -101,7 +101,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.lightingStrength",
label: "Lighting Strength",
- defaultValue: 0.9,
+ defaultValue: 0.3,
min: 0,
max: 1,
step: 0.05,
@@ -110,7 +110,7 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
kind: "range",
key: "settings.webgpu.terrain.improvedHeavy.cavityStrength",
label: "Cavity Strength",
- defaultValue: 0.6,
+ defaultValue: 0.15,
min: 0,
max: 1,
step: 0.05,
@@ -160,11 +160,11 @@ export function buildTerrainShaderParams(
if (shaderId === "improved-lite") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.noiseStrength",
- 0.025,
+ 0.005,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedLite.blendWidth",
- 2.5,
+ 5,
);
const params0 = new Float32Array([
@@ -184,23 +184,23 @@ export function buildTerrainShaderParams(
if (shaderId === "improved-heavy") {
const noiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.noiseStrength",
- 0.025,
+ 0.01,
);
const detailNoiseStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.detailNoiseStrength",
- 0.015,
+ 0.01,
);
const blendWidth = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.blendWidth",
- 2.8,
+ 4.5,
);
const lightingStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.lightingStrength",
- 0.9,
+ 0.3,
);
const cavityStrength = userSettings.getFloat(
"settings.webgpu.terrain.improvedHeavy.cavityStrength",
- 0.6,
+ 0.15,
);
const params0 = new Float32Array([
From 9456d991a06d56e33c75730db27b03f1a86f6495 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 20 Jan 2026 22:58:27 +0100
Subject: [PATCH 17/23] Update terrain shader parameters
- Modified terrain shader parameters in GroundTruthData for better rendering.
- Added new user-configurable settings for water effects in TerrainShaderRegistry.
- Enhanced terrain compute shaders to incorporate water depth and blur adjustments.
- Refactored shader logic to improve water color blending and depth calculations
---
.../graphics/webgpu/core/GroundTruthData.ts | 4 +-
.../webgpu/render/TerrainShaderRegistry.ts | 77 +++++++++++++++----
.../terrain-compute-improved-heavy.wgsl | 76 +++++++++++++-----
.../terrain-compute-improved-lite.wgsl | 59 ++++++++++----
4 files changed, 166 insertions(+), 50 deletions(-)
diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts
index 44f125b3f..00daa40dc 100644
--- a/src/client/graphics/webgpu/core/GroundTruthData.ts
+++ b/src/client/graphics/webgpu/core/GroundTruthData.ts
@@ -101,8 +101,8 @@ export class GroundTruthData {
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
- private terrainShaderParams0 = new Float32Array([0.0, 2.5, 0.6, 0.7]);
- private terrainShaderParams1 = new Float32Array([0.0, 0.9, 0.6, 0.05]);
+ private terrainShaderParams0 = new Float32Array([0.0, 2.5, 1.0, 0.0]);
+ private terrainShaderParams1 = new Float32Array([0.6, 0.0, 0.0, 0.0]);
private paletteMaxSmallId = 0;
private ownerIndexWidth = 1;
diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
index 7470a0806..9d61cc2cd 100644
--- a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
+++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts
@@ -63,6 +63,15 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
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,
+ },
],
},
{
@@ -97,6 +106,33 @@ export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [
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",
@@ -153,9 +189,9 @@ export function buildTerrainShaderParams(
},
shaderId: TerrainShaderId,
): { shaderPath: string; params0: Float32Array; params1: Float32Array } {
- const shorelineMixLand = 0.6;
- const shorelineMixWater = 0.7;
- const specularStrength = 0.05;
+ const waterDepthStrengthDefault = 0.4;
+ const waterDepthCurveDefault = 2;
+ const waterDepthBlurDefault = 0.6;
if (shaderId === "improved-lite") {
const noiseStrength = userSettings.getFloat(
@@ -166,14 +202,17 @@ export function buildTerrainShaderParams(
"settings.webgpu.terrain.improvedLite.blendWidth",
5,
);
-
+ const waterBlurStrength = userSettings.getFloat(
+ "settings.webgpu.terrain.improvedLite.waterBlurStrength",
+ 1,
+ );
const params0 = new Float32Array([
noiseStrength,
blendWidth,
- shorelineMixLand,
- shorelineMixWater,
+ waterBlurStrength,
+ 0,
]);
- const params1 = new Float32Array([0, 0, 0, specularStrength]);
+ const params1 = new Float32Array([0, 0, 0, 0]);
return {
shaderPath: "compute/terrain-compute-improved-lite.wgsl",
params0,
@@ -194,6 +233,18 @@ export function buildTerrainShaderParams(
"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,
@@ -206,14 +257,14 @@ export function buildTerrainShaderParams(
const params0 = new Float32Array([
noiseStrength,
blendWidth,
- shorelineMixLand,
- shorelineMixWater,
+ waterDepthStrength,
+ waterDepthCurve,
]);
const params1 = new Float32Array([
detailNoiseStrength,
lightingStrength,
cavityStrength,
- specularStrength,
+ waterDepthBlur,
]);
return {
shaderPath: "compute/terrain-compute-improved-heavy.wgsl",
@@ -225,9 +276,9 @@ export function buildTerrainShaderParams(
const params0 = new Float32Array([
0,
2.5,
- shorelineMixLand,
- shorelineMixWater,
+ waterDepthStrengthDefault,
+ waterDepthCurveDefault,
]);
- const params1 = new Float32Array([0, 0, 0, specularStrength]);
+ const params1 = new Float32Array([waterDepthBlurDefault, 0, 0, 0]);
return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 };
}
diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl
index 09d64cea1..611a2c1a0 100644
--- a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl
+++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl
@@ -5,8 +5,8 @@ struct TerrainParams {
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=shoreMixLand, w=shoreMixWater
- tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=specularStrength
+ 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
params: TerrainParams;
@@ -34,10 +34,9 @@ fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i {
return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY));
}
-fn sampleMagnitude(coord: vec2i, dims: vec2u) -> f32 {
+fn sampleTerrainData(coord: vec2i, dims: vec2u) -> u32 {
let c = clampCoord(coord, dims);
- let data = textureLoad(terrainDataTex, c, 0).x;
- return f32(data & MAGNITUDE_MASK);
+ return textureLoad(terrainDataTex, c, 0).x;
}
fn computeLandColor(
@@ -69,14 +68,6 @@ fn computeLandColor(
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
-fn computeWaterColor(mag: f32, noise: f32, noiseStrength: f32) -> vec3f {
- let depth = clamp(mag / 10.0, 0.0, 1.0);
- var water = mix(params.shorelineWaterColor.rgb, params.waterColor.rgb, depth);
- let noiseBias = (noise - 0.5) * noiseStrength;
- water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
- return water;
-}
-
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3) {
let x = i32(globalId.x);
@@ -99,18 +90,31 @@ fn main(@builtin(global_invocation_id) globalId: vec3) {
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 shoreMixLand = clamp(params.tuning0.z, 0.0, 1.0);
- let shoreMixWater = clamp(params.tuning0.w, 0.0, 1.0);
+ 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 specularStrength = max(params.tuning1.w, 0.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 hL = sampleMagnitude(texCoord + vec2i(-1, 0), dims) / 31.0;
- let hR = sampleMagnitude(texCoord + vec2i(1, 0), dims) / 31.0;
- let hD = sampleMagnitude(texCoord + vec2i(0, -1), dims) / 31.0;
- let hU = sampleMagnitude(texCoord + vec2i(0, 1), dims) / 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;
@@ -146,7 +150,37 @@ fn main(@builtin(global_invocation_id) globalId: vec3) {
color = vec4f(land, 1.0);
} else {
- var water = computeWaterColor(mag, noise, noiseStrength * 0.6);
+ 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);
diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl
index 97666c68d..95e4dfa5a 100644
--- a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl
+++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl
@@ -5,8 +5,7 @@ struct TerrainParams {
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=shoreMixLand, w=shoreMixWater
- tuning1: vec4f, // unused in lite
+ tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterBlurStrength, w=unused
};
@group(0) @binding(0) var params: TerrainParams;
@@ -28,6 +27,12 @@ fn hash21(p: vec2u) -> f32 {
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);
@@ -52,14 +57,6 @@ fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -
return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
}
-fn computeWaterColor(mag: f32, noise: f32, noiseStrength: f32) -> vec3f {
- let depth = clamp(mag / 10.0, 0.0, 1.0);
- var water = mix(params.shorelineWaterColor.rgb, params.waterColor.rgb, depth);
- let noiseBias = (noise - 0.5) * noiseStrength;
- water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0));
- return water;
-}
-
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3) {
let x = i32(globalId.x);
@@ -81,8 +78,8 @@ fn main(@builtin(global_invocation_id) globalId: vec3) {
let noise = hash21(vec2u(texCoord));
let noiseStrength = max(params.tuning0.x, 0.0);
let blendWidth = max(params.tuning0.y, 0.1);
- let shoreMixLand = clamp(params.tuning0.z, 0.0, 1.0);
- let shoreMixWater = clamp(params.tuning0.w, 0.0, 1.0);
+ let waterDepthBlur = clamp(params.tuning0.z, 0.0, 1.0);
+ let shoreMixLand = 0.6;
var color: vec4f;
if (isLand) {
@@ -92,10 +89,44 @@ fn main(@builtin(global_invocation_id) globalId: vec3) {
}
color = vec4f(land, 1.0);
} else {
- var water = computeWaterColor(mag, noise, noiseStrength * 0.6);
if (isShoreline) {
- water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater);
+ 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);
}
From 07da3a3e3d677abbeb66864bd336dda6d1f08535 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 26 May 2026 20:37:03 +0200
Subject: [PATCH 18/23] Fix WebGPU settings accessors
---
src/client/graphics/webgpu/core/ShaderLoader.ts | 3 ++-
src/core/game/UserSettings.ts | 16 ++++++++++++++--
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/client/graphics/webgpu/core/ShaderLoader.ts b/src/client/graphics/webgpu/core/ShaderLoader.ts
index 19ad380ec..e5e81d400 100644
--- a/src/client/graphics/webgpu/core/ShaderLoader.ts
+++ b/src/client/graphics/webgpu/core/ShaderLoader.ts
@@ -4,7 +4,8 @@
*/
const shaderSources = import.meta.glob("../shaders/**/*.wgsl", {
- as: "raw",
+ query: "?raw",
+ import: "default",
eager: true,
}) as Record;
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index add4a66c4..180dc67db 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -110,7 +110,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,7 +127,7 @@ export class UserSettings {
return floatValue;
}
- private setFloat(key: string, value: number) {
+ setFloat(key: string, value: number) {
this.setCached(key, value.toString());
}
@@ -185,6 +193,10 @@ export class UserSettings {
return this.getBool("settings.attackingTroopsOverlay", true);
}
+ territoryBorderMode(): number {
+ return this.getInt("settings.territoryBorderMode", 1);
+ }
+
toggleAttackingTroopsOverlay() {
this.setBool(
"settings.attackingTroopsOverlay",
From d0be9e26d55fadde61d5ba55fc33ed844371cfc4 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 26 May 2026 21:58:45 +0200
Subject: [PATCH 19/23] Extend staging deployment lifetime
---
Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index e5e01590e..3fb9420a8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
From d8841fd1ed7ccff8f334689b329fb9c64ce22c6c Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 26 May 2026 22:43:23 +0200
Subject: [PATCH 20/23] Add territory renderer fallback controller
---
resources/lang/en.json | 2 +
src/client/UserSettingModal.ts | 24 +-
.../layers/ClassicCanvasTerritoryLayer.ts | 709 +++
.../layers/ClassicTerritoryBackend.ts | 59 +
src/client/graphics/layers/TerrainLayer.ts | 107 +
.../graphics/layers/TerritoryBackend.ts | 134 +
src/client/graphics/layers/TerritoryLayer.ts | 552 +--
.../graphics/layers/TerritoryWebGLRenderer.ts | 3870 +++++++++++++++++
.../graphics/layers/WebGLTerritoryBackend.ts | 1669 +++++++
.../graphics/layers/WebGPUTerritoryBackend.ts | 447 ++
.../graphics/webgpu/TerritoryRenderer.ts | 32 +-
src/core/configuration/Config.ts | 1 +
src/core/configuration/PastelTheme.ts | 5 +
src/core/configuration/PastelThemeDark.ts | 5 +
src/core/game/GameImpl.ts | 56 +
src/core/game/GameView.ts | 23 +
src/core/game/UnitImpl.ts | 3 +
src/core/game/UserSettings.ts | 29 +-
tests/TerritoryBackendSelection.test.ts | 161 +
19 files changed, 7519 insertions(+), 369 deletions(-)
create mode 100644 src/client/graphics/layers/ClassicCanvasTerritoryLayer.ts
create mode 100644 src/client/graphics/layers/ClassicTerritoryBackend.ts
create mode 100644 src/client/graphics/layers/TerrainLayer.ts
create mode 100644 src/client/graphics/layers/TerritoryBackend.ts
create mode 100644 src/client/graphics/layers/TerritoryWebGLRenderer.ts
create mode 100644 src/client/graphics/layers/WebGLTerritoryBackend.ts
create mode 100644 src/client/graphics/layers/WebGPUTerritoryBackend.ts
create mode 100644 tests/TerritoryBackendSelection.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index e119dae09..887fb7cd6 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -705,6 +705,8 @@
"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",
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 6f6ddb698..fafb8e0e2 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -300,7 +300,9 @@ export class UserSettingModal extends BaseModal {
this.requestUpdate();
}
- private changeTerritoryBorderMode(e: CustomEvent<{ value: number | string }>) {
+ private changeTerritoryBorderMode(
+ e: CustomEvent<{ value: number | string }>,
+ ) {
const rawValue = e.detail?.value;
const value =
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
@@ -310,6 +312,12 @@ export class UserSettingModal extends BaseModal {
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();
@@ -777,6 +785,20 @@ export class UserSettingModal extends BaseModal {
@change=${this.changeTerritoryBorderMode}
>
+
+
= 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();
+ }
+}
diff --git a/src/client/graphics/layers/ClassicTerritoryBackend.ts b/src/client/graphics/layers/ClassicTerritoryBackend.ts
new file mode 100644
index 000000000..1a1431f69
--- /dev/null
+++ b/src/client/graphics/layers/ClassicTerritoryBackend.ts
@@ -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.
+ }
+}
diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts
new file mode 100644
index 000000000..353555912
--- /dev/null
+++ b/src/client/graphics/layers/TerrainLayer.ts
@@ -0,0 +1,107 @@
+import { Config, Theme } from "../../../core/configuration/Config";
+import { GameView } from "../../../core/game/GameView";
+import { TransformHandler } from "../TransformHandler";
+import { Layer } from "./Layer";
+
+export class TerrainLayer implements Layer {
+ private canvas: HTMLCanvasElement;
+ private context: CanvasRenderingContext2D;
+ private imageData: ImageData;
+ private theme: Theme;
+ private config: Config;
+
+ constructor(
+ private game: GameView,
+ private transformHandler: TransformHandler,
+ ) {
+ this.config = this.game.config();
+ }
+ shouldTransform(): boolean {
+ return true;
+ }
+ tick() {
+ if (this.config.theme() !== this.theme) {
+ this.redraw();
+ return;
+ }
+ // Repaint terrain for tiles whose terrain changed (e.g. nuke
+ // turning land to water).
+ const updatedTiles = this.game.recentlyUpdatedTerrainTiles();
+ if (updatedTiles.length > 0) {
+ let dirty = false;
+ for (const tile of updatedTiles) {
+ const terrainColor = this.theme.terrainColor(this.game, tile);
+ const offset = tile * 4;
+ const r = terrainColor.rgba.r;
+ const g = terrainColor.rgba.g;
+ const b = terrainColor.rgba.b;
+ if (
+ this.imageData.data[offset] !== r ||
+ this.imageData.data[offset + 1] !== g ||
+ this.imageData.data[offset + 2] !== b
+ ) {
+ this.imageData.data[offset] = r;
+ this.imageData.data[offset + 1] = g;
+ this.imageData.data[offset + 2] = b;
+ dirty = true;
+ }
+ }
+ if (dirty) {
+ this.context.putImageData(this.imageData, 0, 0);
+ }
+ }
+ }
+
+ init() {
+ console.log("redrew terrain layer");
+ this.redraw();
+ }
+
+ redraw(): void {
+ this.canvas = document.createElement("canvas");
+ this.canvas.width = this.game.width();
+ this.canvas.height = this.game.height();
+
+ const context = this.canvas.getContext("2d", { alpha: false });
+ if (context === null) throw new Error("2d context not supported");
+ this.context = context;
+
+ this.imageData = this.context.createImageData(
+ this.canvas.width,
+ this.canvas.height,
+ );
+
+ this.initImageData();
+ this.context.putImageData(this.imageData, 0, 0);
+ }
+
+ initImageData() {
+ this.theme = this.config.theme();
+ this.game.forEachTile((tile) => {
+ const terrainColor = this.theme.terrainColor(this.game, tile);
+ // TODO: isn't tileref and index the same?
+ const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
+ const offset = index * 4;
+ this.imageData.data[offset] = terrainColor.rgba.r;
+ this.imageData.data[offset + 1] = terrainColor.rgba.g;
+ this.imageData.data[offset + 2] = terrainColor.rgba.b;
+ this.imageData.data[offset + 3] = 255;
+ });
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ if (this.transformHandler.scale < 1) {
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = "low";
+ } else {
+ context.imageSmoothingEnabled = false;
+ }
+ context.drawImage(
+ this.canvas,
+ -this.game.width() / 2,
+ -this.game.height() / 2,
+ this.game.width(),
+ this.game.height(),
+ );
+ }
+}
diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts
new file mode 100644
index 000000000..7b02694ca
--- /dev/null
+++ b/src/client/graphics/layers/TerritoryBackend.ts
@@ -0,0 +1,134 @@
+import { Layer } from "./Layer";
+
+export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
+export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
+
+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;
+}
+
+export interface TerritoryBackendCandidate {
+ readonly id: TerritoryRendererId;
+ init?: () => void | Promise;
+ dispose?: () => void;
+ getFailureReason?: () => string | null;
+ whenReady?: () => Promise;
+}
+
+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 = 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,
+ createBackend: (id: TerritoryRendererId) => T,
+ shouldContinue: () => boolean = () => true,
+): Promise> {
+ 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 };
+}
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 94b27d886..60833078b 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -1,68 +1,42 @@
-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";
+ TERRITORY_RENDERER_KEY,
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+} from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
+import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
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 { Layer } from "./Layer";
+ TerritoryBackend,
+ TerritoryRendererId,
+ selectTerritoryBackend,
+ territoryRendererOrder,
+} from "./TerritoryBackend";
+import { WebGLTerritoryBackend } from "./WebGLTerritoryBackend";
+import { WebGPUTerritoryBackend } from "./WebGPUTerritoryBackend";
-export class TerritoryLayer implements Layer {
- profileName(): string {
- return "TerritoryLayer:renderLayer";
- }
+export class TerritoryLayer implements TerritoryBackend {
+ readonly id = "classic";
- 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;
+ private activeBackend: TerritoryBackend | null = null;
+ private failedBackends = new Set();
+ private selectionToken = 0;
+ private initialized = false;
+ private readonly settingsChanged = () => {
+ this.failedBackends.clear();
+ void this.selectConfiguredBackend();
+ };
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private userSettings: UserSettings,
- ) {
- this.theme = game.config().theme();
+ ) {}
+
+ profileName(): string {
+ return "TerritoryLayer:renderLayer";
}
shouldTransform(): boolean {
@@ -70,355 +44,201 @@ export class TerritoryLayer implements Layer {
}
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();
+ 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"));
+ void this.selectConfiguredBackend();
}
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);
+ this.runActive("tick", (backend) => backend.tick?.());
}
redraw() {
- this.configureRenderer();
- }
-
- private configureRenderer() {
- const { renderer, reason } = TerritoryRenderer.create(
- this.game,
- this.theme,
- );
- if (!renderer) {
- throw new Error(reason ?? "WebGPU is required for territory rendering.");
+ if (!this.initialized) {
+ return;
}
-
- 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();
+ this.runActive("redraw", (backend) => backend.redraw?.());
+ void this.selectConfiguredBackend();
}
renderLayer(context: CanvasRenderingContext2D) {
- if (!this.territoryRenderer) {
+ if (!this.activeBackend) {
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();
+ if (this.activeBackend.id !== "webgpu") {
+ this.fillBackground(context);
}
- // Apply user settings even while the game is paused (settings modal).
- this.applyTerritoryShaderSettings();
- this.applyTerritorySmoothingSettings();
+ this.runActive("renderLayer", (backend) => backend.renderLayer?.(context));
+ }
- this.ensureTerritoryCanvasAttached(context.canvas);
- this.updateHoverHighlight();
-
- const renderTerritoryStart = FrameProfiler.start();
- this.territoryRenderer.setViewSize(
- context.canvas.width,
- context.canvas.height,
+ dispose() {
+ globalThis.removeEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_RENDERER_KEY}`,
+ this.settingsChanged,
);
- const viewOffset = this.transformHandler.viewOffset();
- this.territoryRenderer.setViewTransform(
- this.transformHandler.scale,
- viewOffset.x,
- viewOffset.y,
+ this.activeBackend?.dispose?.();
+ this.activeBackend = null;
+ }
+
+ 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;
+ }
+
+ const selection = await selectTerritoryBackend(
+ preference,
+ this.failedBackends,
+ (id) => this.createBackend(id),
+ () => token === this.selectionToken,
);
- this.territoryRenderer.render();
- FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
- }
- private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) {
- if (!this.territoryRenderer) {
+ if (selection.cancelled) {
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,
+ for (const failure of selection.failures) {
+ console.warn(
+ `[TerritoryLayer] ${failure.id} renderer unavailable: ${failure.reason}`,
+ failure.error ?? "",
);
- 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 (failure.id !== "classic") {
+ this.failedBackends.add(failure.id);
+ }
+ }
+
+ if (selection.backend !== null) {
+ this.activateBackend(selection.backend);
+ }
+ }
+
+ private async initializeCandidate(
+ backend: TerritoryBackend,
+ token: number,
+ ): Promise {
+ 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;
}
}
- }
-
- 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,
+ return true;
+ } catch (error) {
+ console.warn(
+ `[TerritoryLayer] ${backend.id} renderer failed init`,
+ error,
);
+ return false;
+ }
+ }
+
+ private activateBackend(backend: TerritoryBackend) {
+ if (this.activeBackend === backend) {
+ return;
+ }
+ const previous = this.activeBackend;
+ this.activeBackend = backend;
+ previous?.dispose?.();
+ console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
+ }
+
+ private runActive(
+ operation: "tick" | "redraw" | "renderLayer",
+ run: (backend: TerritoryBackend) => void,
+ ) {
+ const backend = this.activeBackend;
+ if (!backend) {
+ return;
}
- 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,
+ try {
+ run(backend);
+ const reason = backend.getFailureReason?.();
+ if (reason) {
+ this.handleBackendFailure(backend, `${operation}: ${reason}`);
+ }
+ } catch (error) {
+ this.handleBackendFailure(backend, `${operation}: ${String(error)}`);
+ }
+ }
+
+ private handleBackendFailure(backend: TerritoryBackend, reason: string) {
+ console.warn(`[TerritoryLayer] ${backend.id} renderer failed: ${reason}`);
+ if (backend.id !== "classic") {
+ this.failedBackends.add(backend.id);
+ }
+ 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();
+ }
+ },
);
}
}
- 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)}`,
+ private createBackend(id: TerritoryRendererId): TerritoryBackend {
+ if (id === "webgpu") {
+ return new WebGPUTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ this.userSettings,
);
}
- parts.sort();
- return parts.join("|");
+ if (id === "webgl") {
+ return new WebGLTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ );
+ }
+ return new ClassicTerritoryBackend(
+ this.game,
+ this.eventBus,
+ this.transformHandler,
+ );
}
- private refreshDefensePostsIfNeeded() {
- if (!this.territoryRenderer) {
- return;
- }
- const signature = this.computeDefensePostsSignature();
- if (signature !== this.lastDefensePostsSignature) {
- this.lastDefensePostsSignature = signature;
- this.territoryRenderer.markDefensePostsDirty();
- }
+ 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();
}
}
diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
new file mode 100644
index 000000000..f506f45aa
--- /dev/null
+++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
@@ -0,0 +1,3870 @@
+import { base64url } from "jose";
+import { DefaultPattern } from "../../../core/CosmeticSchemas";
+import { Theme } from "../../../core/configuration/Config";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
+import { FrameProfiler } from "../FrameProfiler";
+
+type DirtySpan = { minX: number; maxX: number };
+
+export interface TerritoryWebGLCreateResult {
+ renderer: TerritoryWebGLRenderer | null;
+ reason?: string;
+}
+
+export interface HoverHighlightOptions {
+ color?: { r: number; g: number; b: number };
+ strength?: number;
+ pulseStrength?: number;
+ pulseSpeed?: number;
+}
+
+const PATTERN_STRIDE_BYTES = 1052;
+
+// WebGL2 territory renderer that shades tiles from packed tile state
+// (Uint16Array) using palette, relation, and pattern textures.
+export class TerritoryWebGLRenderer {
+ public readonly canvas: HTMLCanvasElement;
+
+ private contestEnabled = false;
+ private contestPatternMode: 0 | 1 | 2 = 0; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
+ private debugDisableStaticBorders = false;
+ private debugDisableAllBorders = false;
+ private seedSamplingMode: 0 | 1 | 2 = 1; // 0=none(single texel), 1=2x2, 2=3x3
+ private debugStripeFixedColors = false; // Use fixed debug colors for moving stripe
+ private motionMode: 0 | 1 | 2 | 3 = 0; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev
+
+ private readonly gl: WebGL2RenderingContext | null;
+ private readonly program: WebGLProgram | null;
+ private readonly vao: WebGLVertexArrayObject | null;
+ private readonly vertexBuffer: WebGLBuffer | null;
+ private readonly jfaVao: WebGLVertexArrayObject | null;
+ private readonly jfaVertexBuffer: WebGLBuffer | null;
+ private readonly stateTexture: WebGLTexture | null;
+ private readonly terrainTexture: WebGLTexture | null;
+ private readonly paletteTexture: WebGLTexture | null;
+ private readonly relationTexture: WebGLTexture | null;
+ private readonly patternTexture: WebGLTexture | null;
+ private readonly contestOwnersTexture: WebGLTexture | null;
+ private readonly contestIdsTexture: WebGLTexture | null;
+ private readonly contestTimesTexture: WebGLTexture | null;
+ private readonly contestStrengthsTexture: WebGLTexture | null;
+ private readonly prevOwnerTexture: WebGLTexture | null;
+ private readonly olderOwnerTexture: WebGLTexture | null;
+ private readonly stateFramebuffer: WebGLFramebuffer | null;
+ private readonly prevStateFramebuffer: WebGLFramebuffer | null;
+ private readonly olderStateFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaTextureA: WebGLTexture | null;
+ private readonly jfaTextureB: WebGLTexture | null;
+ private readonly jfaFramebufferA: WebGLFramebuffer | null;
+ private readonly jfaFramebufferB: WebGLFramebuffer | null;
+ private readonly jfaResultOlderTexture: WebGLTexture | null;
+ private readonly jfaResultOldTexture: WebGLTexture | null;
+ private readonly jfaResultNewTexture: WebGLTexture | null;
+ private readonly jfaResultOlderFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaResultOldFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaResultNewFramebuffer: WebGLFramebuffer | null;
+ private readonly jfaSeedProgram: WebGLProgram | null;
+ private readonly jfaProgram: WebGLProgram | null;
+ private readonly changeMaskProgram: WebGLProgram | null;
+ private readonly changeMaskTextureOlder: WebGLTexture | null;
+ private readonly changeMaskTextureOld: WebGLTexture | null;
+ private readonly changeMaskTextureNew: WebGLTexture | null;
+ private readonly changeMaskFramebufferOlder: WebGLFramebuffer | null;
+ private readonly changeMaskFramebufferOld: WebGLFramebuffer | null;
+ private readonly changeMaskFramebufferNew: WebGLFramebuffer | null;
+ private readonly jfaSeedUniforms: {
+ resolution: WebGLUniformLocation | null;
+ owner: WebGLUniformLocation | null;
+ };
+ private readonly jfaUniforms: {
+ resolution: WebGLUniformLocation | null;
+ step: WebGLUniformLocation | null;
+ seeds: WebGLUniformLocation | null;
+ };
+ private readonly changeMaskUniforms: {
+ resolution: WebGLUniformLocation | null;
+ oldTexture: WebGLUniformLocation | null;
+ newTexture: WebGLUniformLocation | null;
+ };
+ private readonly uniforms: {
+ mapResolution: WebGLUniformLocation | null;
+ viewResolution: WebGLUniformLocation | null;
+ viewScale: WebGLUniformLocation | null;
+ viewOffset: WebGLUniformLocation | null;
+ state: WebGLUniformLocation | null;
+ terrain: WebGLUniformLocation | null;
+ latestState: WebGLUniformLocation | null;
+ palette: WebGLUniformLocation | null;
+ relations: WebGLUniformLocation | null;
+ patterns: WebGLUniformLocation | null;
+ contestEnabled: WebGLUniformLocation | null;
+ contestPatternMode: WebGLUniformLocation | null;
+ debugDisableStaticBorders: WebGLUniformLocation | null;
+ debugDisableAllBorders: WebGLUniformLocation | null;
+ seedSamplingMode: WebGLUniformLocation | null;
+ debugStripeFixedColors: WebGLUniformLocation | null;
+ motionMode: WebGLUniformLocation | null;
+ contestOwners: WebGLUniformLocation | null;
+ contestIds: WebGLUniformLocation | null;
+ contestTimes: WebGLUniformLocation | null;
+ contestStrengths: WebGLUniformLocation | null;
+ jfaAvailable: WebGLUniformLocation | null;
+ contestNow: WebGLUniformLocation | null;
+ contestDuration: WebGLUniformLocation | null;
+ prevOwner: WebGLUniformLocation | null;
+ jfaSeedsOld: WebGLUniformLocation | null;
+ jfaSeedsNew: WebGLUniformLocation | null;
+ smoothProgress: WebGLUniformLocation | null;
+ changeMask: WebGLUniformLocation | null;
+ smoothEnabled: WebGLUniformLocation | null;
+ patternStride: WebGLUniformLocation | null;
+ patternRows: WebGLUniformLocation | null;
+ fallout: WebGLUniformLocation | null;
+ altSelf: WebGLUniformLocation | null;
+ altAlly: WebGLUniformLocation | null;
+ altNeutral: WebGLUniformLocation | null;
+ altEnemy: WebGLUniformLocation | null;
+ alpha: WebGLUniformLocation | null;
+ alternativeView: WebGLUniformLocation | null;
+ hoveredPlayerId: WebGLUniformLocation | null;
+ hoverHighlightStrength: WebGLUniformLocation | null;
+ hoverHighlightColor: WebGLUniformLocation | null;
+ hoverPulseStrength: WebGLUniformLocation | null;
+ hoverPulseSpeed: WebGLUniformLocation | null;
+ time: WebGLUniformLocation | null;
+ viewerId: WebGLUniformLocation | null;
+ darkMode: WebGLUniformLocation | null;
+ };
+
+ private readonly mapWidth: number;
+ private readonly mapHeight: number;
+ private viewWidth: number;
+ private viewHeight: number;
+ private viewScale = 1;
+ private viewOffsetX = 0;
+ private viewOffsetY = 0;
+
+ private readonly state: Uint16Array;
+ private contestOwnersState: Uint16Array;
+ private contestIdsState: Uint16Array;
+ private contestTimesState: Uint16Array;
+ private contestStrengthsState: Uint16Array;
+ private readonly dirtyRows: Map = new Map();
+ private readonly contestDirtyRows: Map = new Map();
+ private needsFullUpload = true;
+ private needsContestFullUpload = true;
+ private needsContestTimesUpload = true;
+ private needsContestStrengthsUpload = true;
+ private alternativeView = false;
+ private paletteWidth = 0;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverHighlightStrength = 0.3;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverHighlightColor: [number, number, number] = [1, 1, 1];
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverPulseStrength = 0.25;
+ // Defaults are overridden by setHoverHighlightOptions() from TerritoryLayer.
+ private hoverPulseSpeed = Math.PI * 2;
+ private hoveredPlayerId = -1;
+ private hoverStartTime = 0;
+ private static readonly HOVER_DURATION_MS = 5000;
+ private animationStartTime = Date.now();
+ private contestNow = 0;
+ private contestDurationTicks = 0;
+ private smoothProgress = 1;
+ private smoothEnabled = true;
+ private jfaSupported = false;
+ private jfaDisabledReason: string | null = null;
+ private jfaDirty = false;
+ private jfaHistoryInitialized = false;
+ private changeMaskDirty = false;
+ private changeMaskHistoryInitialized = false;
+ private prevStateCopySupported = false;
+ private jfaSteps: number[] = [];
+ private interpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent";
+ private readonly userSettings = new UserSettings();
+ private readonly patternBytesCache = new Map();
+
+ private constructor(
+ private readonly game: GameView,
+ private readonly theme: Theme,
+ state: Uint16Array,
+ ) {
+ this.canvas = document.createElement("canvas");
+ this.mapWidth = game.width();
+ this.mapHeight = game.height();
+ this.viewWidth = this.mapWidth;
+ this.viewHeight = this.mapHeight;
+ this.canvas.width = this.viewWidth;
+ this.canvas.height = this.viewHeight;
+
+ this.state = state;
+ this.contestOwnersState = new Uint16Array(state.length * 2);
+ this.contestIdsState = new Uint16Array(state.length);
+ this.contestTimesState = new Uint16Array(1);
+ this.contestStrengthsState = new Uint16Array(1);
+
+ this.gl = this.canvas.getContext("webgl2", {
+ premultipliedAlpha: true,
+ antialias: false,
+ preserveDrawingBuffer: true,
+ });
+
+ if (!this.gl) {
+ this.program = null;
+ this.vao = null;
+ this.vertexBuffer = null;
+ this.jfaVao = null;
+ this.jfaVertexBuffer = null;
+ this.stateTexture = null;
+ this.terrainTexture = null;
+ this.paletteTexture = null;
+ this.relationTexture = null;
+ this.patternTexture = null;
+ this.contestOwnersTexture = null;
+ this.contestIdsTexture = null;
+ this.contestTimesTexture = null;
+ this.contestStrengthsTexture = null;
+ this.prevOwnerTexture = null;
+ this.olderOwnerTexture = null;
+ this.stateFramebuffer = null;
+ this.prevStateFramebuffer = null;
+ this.olderStateFramebuffer = null;
+ this.jfaTextureA = null;
+ this.jfaTextureB = null;
+ this.jfaFramebufferA = null;
+ this.jfaFramebufferB = null;
+ this.jfaResultOlderTexture = null;
+ this.jfaResultOldTexture = null;
+ this.jfaResultNewTexture = null;
+ this.jfaResultOlderFramebuffer = null;
+ this.jfaResultOldFramebuffer = null;
+ this.jfaResultNewFramebuffer = null;
+ this.jfaSeedProgram = null;
+ this.jfaProgram = null;
+ this.changeMaskProgram = null;
+ this.changeMaskTextureOlder = null;
+ this.changeMaskTextureOld = null;
+ this.changeMaskTextureNew = null;
+ this.changeMaskFramebufferOlder = null;
+ this.changeMaskFramebufferOld = null;
+ this.changeMaskFramebufferNew = null;
+ this.jfaSeedUniforms = { resolution: null, owner: null };
+ this.jfaUniforms = { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = {
+ resolution: null,
+ oldTexture: null,
+ newTexture: null,
+ };
+ this.uniforms = {
+ mapResolution: null,
+ viewResolution: null,
+ viewScale: null,
+ viewOffset: null,
+ state: null,
+ terrain: null,
+ latestState: null,
+ palette: null,
+ relations: null,
+ patterns: null,
+ contestEnabled: null,
+ contestPatternMode: null,
+ debugDisableStaticBorders: null,
+ debugDisableAllBorders: null,
+ seedSamplingMode: null,
+ debugStripeFixedColors: null,
+ motionMode: null,
+ contestOwners: null,
+ contestIds: null,
+ contestTimes: null,
+ contestStrengths: null,
+ jfaAvailable: null,
+ contestNow: null,
+ contestDuration: null,
+ prevOwner: null,
+ jfaSeedsOld: null,
+ jfaSeedsNew: null,
+ smoothProgress: null,
+ changeMask: null,
+ smoothEnabled: null,
+ patternStride: null,
+ patternRows: null,
+ fallout: null,
+ altSelf: null,
+ altAlly: null,
+ altNeutral: null,
+ altEnemy: null,
+ alpha: null,
+ alternativeView: null,
+ hoveredPlayerId: null,
+ hoverHighlightStrength: null,
+ hoverHighlightColor: null,
+ hoverPulseStrength: null,
+ hoverPulseSpeed: null,
+ time: null,
+ viewerId: null,
+ darkMode: null,
+ };
+ return;
+ }
+
+ const gl = this.gl;
+ this.program = this.createProgram(gl);
+ if (!this.program) {
+ this.vao = null;
+ this.vertexBuffer = null;
+ this.jfaVao = null;
+ this.jfaVertexBuffer = null;
+ this.stateTexture = null;
+ this.terrainTexture = null;
+ this.paletteTexture = null;
+ this.relationTexture = null;
+ this.patternTexture = null;
+ this.contestOwnersTexture = null;
+ this.contestIdsTexture = null;
+ this.contestTimesTexture = null;
+ this.contestStrengthsTexture = null;
+ this.prevOwnerTexture = null;
+ this.olderOwnerTexture = null;
+ this.stateFramebuffer = null;
+ this.prevStateFramebuffer = null;
+ this.olderStateFramebuffer = null;
+ this.jfaTextureA = null;
+ this.jfaTextureB = null;
+ this.jfaFramebufferA = null;
+ this.jfaFramebufferB = null;
+ this.jfaResultOlderTexture = null;
+ this.jfaResultOldTexture = null;
+ this.jfaResultNewTexture = null;
+ this.jfaResultOlderFramebuffer = null;
+ this.jfaResultOldFramebuffer = null;
+ this.jfaResultNewFramebuffer = null;
+ this.jfaSeedProgram = null;
+ this.jfaProgram = null;
+ this.changeMaskProgram = null;
+ this.changeMaskTextureOlder = null;
+ this.changeMaskTextureOld = null;
+ this.changeMaskTextureNew = null;
+ this.changeMaskFramebufferOlder = null;
+ this.changeMaskFramebufferOld = null;
+ this.changeMaskFramebufferNew = null;
+ this.jfaSeedUniforms = { resolution: null, owner: null };
+ this.jfaUniforms = { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = {
+ resolution: null,
+ oldTexture: null,
+ newTexture: null,
+ };
+ this.uniforms = {
+ mapResolution: null,
+ viewResolution: null,
+ viewScale: null,
+ viewOffset: null,
+ state: null,
+ terrain: null,
+ latestState: null,
+ palette: null,
+ relations: null,
+ patterns: null,
+ contestEnabled: null,
+ contestPatternMode: null,
+ debugDisableStaticBorders: null,
+ debugDisableAllBorders: null,
+ seedSamplingMode: null,
+ debugStripeFixedColors: null,
+ motionMode: null,
+ contestOwners: null,
+ contestIds: null,
+ contestTimes: null,
+ contestStrengths: null,
+ jfaAvailable: null,
+ contestNow: null,
+ contestDuration: null,
+ prevOwner: null,
+ jfaSeedsOld: null,
+ jfaSeedsNew: null,
+ smoothProgress: null,
+ changeMask: null,
+ smoothEnabled: null,
+ patternStride: null,
+ patternRows: null,
+ fallout: null,
+ altSelf: null,
+ altAlly: null,
+ altNeutral: null,
+ altEnemy: null,
+ alpha: null,
+ alternativeView: null,
+ hoveredPlayerId: null,
+ hoverHighlightStrength: null,
+ hoverHighlightColor: null,
+ hoverPulseStrength: null,
+ hoverPulseSpeed: null,
+ time: null,
+ viewerId: null,
+ darkMode: null,
+ };
+ return;
+ }
+
+ this.jfaSupported = !!gl.getExtension("EXT_color_buffer_float");
+ if (!this.jfaSupported) {
+ this.jfaDisabledReason = "EXT_color_buffer_float unavailable";
+ }
+ this.jfaSeedProgram = this.jfaSupported
+ ? this.createJfaSeedProgram(gl)
+ : null;
+ this.jfaProgram = this.jfaSupported ? this.createJfaProgram(gl) : null;
+ this.changeMaskProgram = this.jfaSupported
+ ? this.createChangeMaskProgram(gl)
+ : null;
+ if (!this.jfaSeedProgram || !this.jfaProgram) {
+ this.jfaSupported = false;
+ this.jfaDisabledReason ??= "JFA shaders unavailable";
+ }
+ this.jfaSeedUniforms = this.jfaSeedProgram
+ ? {
+ resolution: gl.getUniformLocation(
+ this.jfaSeedProgram,
+ "u_resolution",
+ ),
+ owner: gl.getUniformLocation(this.jfaSeedProgram, "u_ownerTexture"),
+ }
+ : { resolution: null, owner: null };
+ this.jfaUniforms = this.jfaProgram
+ ? {
+ resolution: gl.getUniformLocation(this.jfaProgram, "u_resolution"),
+ step: gl.getUniformLocation(this.jfaProgram, "u_step"),
+ seeds: gl.getUniformLocation(this.jfaProgram, "u_seeds"),
+ }
+ : { resolution: null, step: null, seeds: null };
+ this.changeMaskUniforms = this.changeMaskProgram
+ ? {
+ resolution: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_resolution",
+ ),
+ oldTexture: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_oldTexture",
+ ),
+ newTexture: gl.getUniformLocation(
+ this.changeMaskProgram,
+ "u_newTexture",
+ ),
+ }
+ : { resolution: null, oldTexture: null, newTexture: null };
+
+ this.uniforms = {
+ mapResolution: gl.getUniformLocation(this.program, "u_mapResolution"),
+ viewResolution: gl.getUniformLocation(this.program, "u_viewResolution"),
+ viewScale: gl.getUniformLocation(this.program, "u_viewScale"),
+ viewOffset: gl.getUniformLocation(this.program, "u_viewOffset"),
+ state: gl.getUniformLocation(this.program, "u_state"),
+ terrain: gl.getUniformLocation(this.program, "u_terrain"),
+ latestState: gl.getUniformLocation(this.program, "u_latestState"),
+ palette: gl.getUniformLocation(this.program, "u_palette"),
+ relations: gl.getUniformLocation(this.program, "u_relations"),
+ patterns: gl.getUniformLocation(this.program, "u_patterns"),
+ contestEnabled: gl.getUniformLocation(this.program, "u_contestEnabled"),
+ contestPatternMode: gl.getUniformLocation(
+ this.program,
+ "u_contestPatternMode",
+ ),
+ debugDisableStaticBorders: gl.getUniformLocation(
+ this.program,
+ "u_debugDisableStaticBorders",
+ ),
+ debugDisableAllBorders: gl.getUniformLocation(
+ this.program,
+ "u_debugDisableAllBorders",
+ ),
+ seedSamplingMode: gl.getUniformLocation(
+ this.program,
+ "u_seedSamplingMode",
+ ),
+ debugStripeFixedColors: gl.getUniformLocation(
+ this.program,
+ "u_debugStripeFixedColors",
+ ),
+ motionMode: gl.getUniformLocation(this.program, "u_motionMode"),
+ contestOwners: gl.getUniformLocation(this.program, "u_contestOwners"),
+ contestIds: gl.getUniformLocation(this.program, "u_contestIds"),
+ contestTimes: gl.getUniformLocation(this.program, "u_contestTimes"),
+ contestStrengths: gl.getUniformLocation(
+ this.program,
+ "u_contestStrengths",
+ ),
+ jfaAvailable: gl.getUniformLocation(this.program, "u_jfaAvailable"),
+ contestNow: gl.getUniformLocation(this.program, "u_contestNow"),
+ contestDuration: gl.getUniformLocation(
+ this.program,
+ "u_contestDurationTicks",
+ ),
+ prevOwner: gl.getUniformLocation(this.program, "u_prevOwner"),
+ jfaSeedsOld: gl.getUniformLocation(this.program, "u_jfaSeedsOld"),
+ jfaSeedsNew: gl.getUniformLocation(this.program, "u_jfaSeedsNew"),
+ smoothProgress: gl.getUniformLocation(this.program, "u_smoothProgress"),
+ changeMask: gl.getUniformLocation(this.program, "u_changeMask"),
+ smoothEnabled: gl.getUniformLocation(this.program, "u_smoothEnabled"),
+ patternStride: gl.getUniformLocation(this.program, "u_patternStride"),
+ patternRows: gl.getUniformLocation(this.program, "u_patternRows"),
+ fallout: gl.getUniformLocation(this.program, "u_fallout"),
+ altSelf: gl.getUniformLocation(this.program, "u_altSelf"),
+ altAlly: gl.getUniformLocation(this.program, "u_altAlly"),
+ altNeutral: gl.getUniformLocation(this.program, "u_altNeutral"),
+ altEnemy: gl.getUniformLocation(this.program, "u_altEnemy"),
+ alpha: gl.getUniformLocation(this.program, "u_alpha"),
+ alternativeView: gl.getUniformLocation(this.program, "u_alternativeView"),
+ hoveredPlayerId: gl.getUniformLocation(this.program, "u_hoveredPlayerId"),
+ hoverHighlightStrength: gl.getUniformLocation(
+ this.program,
+ "u_hoverHighlightStrength",
+ ),
+ hoverHighlightColor: gl.getUniformLocation(
+ this.program,
+ "u_hoverHighlightColor",
+ ),
+ hoverPulseStrength: gl.getUniformLocation(
+ this.program,
+ "u_hoverPulseStrength",
+ ),
+ hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"),
+ time: gl.getUniformLocation(this.program, "u_time"),
+ viewerId: gl.getUniformLocation(this.program, "u_viewerId"),
+ darkMode: gl.getUniformLocation(this.program, "u_darkMode"),
+ };
+
+ // Vertex data: two triangles covering the full view (pixel-perfect).
+ const vertices = new Float32Array([
+ 0,
+ 0,
+ this.viewWidth,
+ 0,
+ 0,
+ this.viewHeight,
+ 0,
+ this.viewHeight,
+ this.viewWidth,
+ 0,
+ this.viewWidth,
+ this.viewHeight,
+ ]);
+
+ this.vao = gl.createVertexArray();
+ this.vertexBuffer = gl.createBuffer();
+ gl.bindVertexArray(this.vao);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+
+ const posLoc = gl.getAttribLocation(this.program, "a_position");
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0);
+ gl.bindVertexArray(null);
+
+ const mapVertices = new Float32Array([
+ 0,
+ 0,
+ this.mapWidth,
+ 0,
+ 0,
+ this.mapHeight,
+ 0,
+ this.mapHeight,
+ this.mapWidth,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ ]);
+ this.jfaVao = gl.createVertexArray();
+ this.jfaVertexBuffer = gl.createBuffer();
+ gl.bindVertexArray(this.jfaVao);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.jfaVertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, mapVertices, gl.STATIC_DRAW);
+ gl.enableVertexAttribArray(posLoc);
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0);
+ gl.bindVertexArray(null);
+
+ this.stateTexture = gl.createTexture();
+ this.terrainTexture = gl.createTexture();
+ this.paletteTexture = gl.createTexture();
+ this.relationTexture = gl.createTexture();
+ this.patternTexture = gl.createTexture();
+ this.contestOwnersTexture = gl.createTexture();
+ this.contestIdsTexture = gl.createTexture();
+ this.contestTimesTexture = gl.createTexture();
+ this.contestStrengthsTexture = gl.createTexture();
+ this.prevOwnerTexture = gl.createTexture();
+ this.olderOwnerTexture = gl.createTexture();
+ this.stateFramebuffer = gl.createFramebuffer();
+ this.prevStateFramebuffer = gl.createFramebuffer();
+ this.olderStateFramebuffer = gl.createFramebuffer();
+ this.jfaTextureA = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaTextureB = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaFramebufferA = this.jfaSupported ? gl.createFramebuffer() : null;
+ this.jfaFramebufferB = this.jfaSupported ? gl.createFramebuffer() : null;
+ this.jfaResultOlderTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultOldTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultNewTexture = this.jfaSupported ? gl.createTexture() : null;
+ this.jfaResultOlderFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.jfaResultOldFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.jfaResultNewFramebuffer = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskTextureOlder = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskTextureOld = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskTextureNew = this.jfaSupported ? gl.createTexture() : null;
+ this.changeMaskFramebufferOlder = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskFramebufferOld = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+ this.changeMaskFramebufferNew = this.jfaSupported
+ ? gl.createFramebuffer()
+ : null;
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ // Terrain texture (immutable, only uploaded once)
+ gl.activeTexture(gl.TEXTURE14);
+ gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R8UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ game.terrainDataView(),
+ );
+
+ this.uploadPalette();
+
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestOwnersState,
+ );
+
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestIdsState,
+ );
+
+ gl.activeTexture(gl.TEXTURE6);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.contestTimesState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestTimesState,
+ );
+
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.contestStrengthsState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestStrengthsState,
+ );
+
+ gl.activeTexture(gl.TEXTURE7);
+ gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ gl.activeTexture(gl.TEXTURE13);
+ gl.bindTexture(gl.TEXTURE_2D, this.olderOwnerTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+
+ if (
+ this.stateFramebuffer &&
+ this.prevStateFramebuffer &&
+ this.olderStateFramebuffer &&
+ this.stateTexture &&
+ this.prevOwnerTexture &&
+ this.olderOwnerTexture
+ ) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.stateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.stateTexture,
+ 0,
+ );
+ const stateStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.prevOwnerTexture,
+ 0,
+ );
+ const prevStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.olderStateFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.olderOwnerTexture,
+ 0,
+ );
+ const olderStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ this.prevStateCopySupported =
+ stateStatus === gl.FRAMEBUFFER_COMPLETE &&
+ prevStatus === gl.FRAMEBUFFER_COMPLETE &&
+ olderStatus === gl.FRAMEBUFFER_COMPLETE;
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.jfaTextureA &&
+ this.jfaTextureB &&
+ this.jfaFramebufferA &&
+ this.jfaFramebufferB &&
+ this.jfaResultOlderTexture &&
+ this.jfaResultOldTexture &&
+ this.jfaResultNewTexture &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer &&
+ this.jfaResultNewFramebuffer
+ ) {
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureA);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE10);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaTextureB);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaTextureA,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferB);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaTextureB,
+ 0,
+ );
+
+ gl.activeTexture(gl.TEXTURE12);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOlderTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE10);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16F,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG,
+ gl.HALF_FLOAT,
+ null,
+ );
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultOlderTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultOldTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.jfaResultNewTexture,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ this.jfaSteps = this.buildJfaSteps(this.mapWidth, this.mapHeight);
+ this.jfaDirty = true;
+ }
+
+ if (
+ this.jfaSupported &&
+ this.changeMaskTextureOlder &&
+ this.changeMaskTextureOld &&
+ this.changeMaskTextureNew &&
+ this.changeMaskFramebufferOlder &&
+ this.changeMaskFramebufferOld &&
+ this.changeMaskFramebufferNew
+ ) {
+ const initMaskTex = (tex: WebGLTexture) => {
+ gl.bindTexture(gl.TEXTURE_2D, tex);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R8UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ null,
+ );
+ };
+
+ gl.activeTexture(gl.TEXTURE13);
+ initMaskTex(this.changeMaskTextureOlder);
+ initMaskTex(this.changeMaskTextureOld);
+ initMaskTex(this.changeMaskTextureNew);
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureOlder,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureOld,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ this.changeMaskTextureNew,
+ 0,
+ );
+ gl.clearBufferuiv(gl.COLOR, 0, new Uint32Array([0, 0, 0, 0]));
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ this.changeMaskDirty = true;
+ }
+
+ gl.useProgram(this.program);
+ gl.uniform1i(this.uniforms.state, 0);
+ if (this.uniforms.terrain) {
+ gl.uniform1i(this.uniforms.terrain, 14);
+ }
+ if (this.uniforms.latestState) {
+ gl.uniform1i(this.uniforms.latestState, 12);
+ }
+ gl.uniform1i(this.uniforms.palette, 1);
+ gl.uniform1i(this.uniforms.relations, 2);
+ gl.uniform1i(this.uniforms.patterns, 3);
+ gl.uniform1i(this.uniforms.contestOwners, 4);
+ gl.uniform1i(this.uniforms.contestIds, 5);
+ gl.uniform1i(this.uniforms.contestTimes, 6);
+ gl.uniform1i(this.uniforms.contestStrengths, 11);
+ gl.uniform1i(this.uniforms.prevOwner, 7);
+ gl.uniform1i(this.uniforms.jfaSeedsOld, 8);
+ gl.uniform1i(this.uniforms.jfaSeedsNew, 9);
+ if (this.uniforms.changeMask) {
+ gl.uniform1i(this.uniforms.changeMask, 13);
+ }
+
+ if (this.uniforms.mapResolution) {
+ gl.uniform2f(this.uniforms.mapResolution, this.mapWidth, this.mapHeight);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ if (this.uniforms.alpha) {
+ gl.uniform1f(this.uniforms.alpha, 150 / 255);
+ }
+ if (this.uniforms.fallout) {
+ const f = this.theme.falloutColor().rgba;
+ gl.uniform4f(
+ this.uniforms.fallout,
+ f.r / 255,
+ f.g / 255,
+ f.b / 255,
+ f.a ?? 1,
+ );
+ }
+ if (this.uniforms.altSelf) {
+ const c = this.theme.selfColor().rgba;
+ gl.uniform4f(
+ this.uniforms.altSelf,
+ c.r / 255,
+ c.g / 255,
+ c.b / 255,
+ c.a ?? 1,
+ );
+ }
+ if (this.uniforms.altAlly) {
+ const c = this.theme.allyColor().rgba;
+ gl.uniform4f(
+ this.uniforms.altAlly,
+ c.r / 255,
+ c.g / 255,
+ c.b / 255,
+ c.a ?? 1,
+ );
+ }
+ if (this.uniforms.altNeutral) {
+ const c = this.theme.neutralColor().rgba;
+ gl.uniform4f(
+ this.uniforms.altNeutral,
+ c.r / 255,
+ c.g / 255,
+ c.b / 255,
+ c.a ?? 1,
+ );
+ }
+ if (this.uniforms.altEnemy) {
+ const c = this.theme.enemyColor().rgba;
+ gl.uniform4f(
+ this.uniforms.altEnemy,
+ c.r / 255,
+ c.g / 255,
+ c.b / 255,
+ c.a ?? 1,
+ );
+ }
+ if (this.uniforms.viewerId) {
+ const viewerId = this.game.myPlayer()?.smallID() ?? 0;
+ gl.uniform1i(this.uniforms.viewerId, viewerId);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ if (this.uniforms.alternativeView) {
+ gl.uniform1i(this.uniforms.alternativeView, 0);
+ }
+ if (this.uniforms.hoveredPlayerId) {
+ gl.uniform1f(this.uniforms.hoveredPlayerId, -1);
+ }
+ if (this.uniforms.hoverHighlightStrength) {
+ gl.uniform1f(
+ this.uniforms.hoverHighlightStrength,
+ this.hoverHighlightStrength,
+ );
+ }
+ if (this.uniforms.hoverHighlightColor) {
+ const [r, g, b] = this.hoverHighlightColor;
+ gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b);
+ }
+ if (this.uniforms.hoverPulseStrength) {
+ gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
+ }
+ if (this.uniforms.hoverPulseSpeed) {
+ gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
+ }
+ if (this.uniforms.jfaAvailable) {
+ gl.uniform1i(this.uniforms.jfaAvailable, this.jfaSupported ? 1 : 0);
+ }
+ if (this.uniforms.contestNow) {
+ gl.uniform1i(this.uniforms.contestNow, this.contestNow);
+ }
+ if (this.uniforms.contestDuration) {
+ gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks);
+ }
+ if (this.uniforms.smoothProgress) {
+ gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress);
+ }
+ if (this.uniforms.smoothEnabled) {
+ gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.jfaResultOldTexture &&
+ this.jfaResultNewTexture
+ ) {
+ gl.activeTexture(gl.TEXTURE8);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultOldTexture);
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, this.jfaResultNewTexture);
+ }
+
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.viewport(0, 0, this.viewWidth, this.viewHeight);
+ }
+
+ 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; WebGL renderer disabled.",
+ };
+ }
+
+ const renderer = new TerritoryWebGLRenderer(game, theme, state);
+ if (!renderer.isValid()) {
+ return {
+ renderer: null,
+ reason: "WebGL2 not available; WebGL renderer disabled.",
+ };
+ }
+ return { renderer };
+ }
+
+ isValid(): boolean {
+ return !!this.gl && !!this.program && !!this.vao;
+ }
+
+ dispose(): void {
+ if (this.gl) {
+ this.gl.getExtension("WEBGL_lose_context")?.loseContext();
+ }
+ this.canvas.remove();
+ }
+
+ setAlternativeView(enabled: boolean) {
+ this.alternativeView = enabled;
+ }
+
+ setViewSize(width: number, height: number) {
+ const nextWidth = Math.max(1, Math.floor(width));
+ const nextHeight = Math.max(1, Math.floor(height));
+ if (nextWidth === this.viewWidth && nextHeight === this.viewHeight) {
+ return;
+ }
+ this.viewWidth = nextWidth;
+ this.viewHeight = nextHeight;
+ this.canvas.width = nextWidth;
+ this.canvas.height = nextHeight;
+ if (!this.gl || !this.vertexBuffer) {
+ return;
+ }
+ const gl = this.gl;
+ const vertices = new Float32Array([
+ 0,
+ 0,
+ this.viewWidth,
+ 0,
+ 0,
+ this.viewHeight,
+ 0,
+ this.viewHeight,
+ this.viewWidth,
+ 0,
+ this.viewWidth,
+ this.viewHeight,
+ ]);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
+ if (this.program) {
+ gl.useProgram(this.program);
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ }
+ }
+
+ setViewTransform(scale: number, offsetX: number, offsetY: number) {
+ this.viewScale = scale;
+ this.viewOffsetX = offsetX;
+ this.viewOffsetY = offsetY;
+ }
+
+ setHoveredPlayerId(playerSmallId: number | null) {
+ const encoded = playerSmallId ?? -1;
+ if (encoded !== this.hoveredPlayerId) {
+ this.hoveredPlayerId = encoded;
+ this.hoverStartTime = encoded >= 0 ? Date.now() : 0;
+ }
+ }
+
+ setHoverHighlightOptions(options: HoverHighlightOptions) {
+ if (options.strength !== undefined) {
+ this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength));
+ }
+ if (options.color) {
+ this.hoverHighlightColor = [
+ options.color.r / 255,
+ options.color.g / 255,
+ options.color.b / 255,
+ ];
+ }
+ if (options.pulseStrength !== undefined) {
+ this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength));
+ }
+ if (options.pulseSpeed !== undefined) {
+ this.hoverPulseSpeed = Math.max(0, options.pulseSpeed);
+ }
+ }
+
+ setContestEnabled(enabled: boolean) {
+ if (this.contestEnabled === enabled) {
+ return;
+ }
+ this.contestEnabled = enabled;
+ if (this.contestEnabled) {
+ this.needsContestFullUpload = true;
+ this.needsContestTimesUpload = true;
+ this.needsContestStrengthsUpload = true;
+ } else {
+ this.contestDirtyRows.clear();
+ }
+ }
+
+ setContestPatternMode(mode: "blueNoise" | "checkerboard" | "bayer4x4") {
+ if (mode === "checkerboard") this.contestPatternMode = 1;
+ else if (mode === "bayer4x4") this.contestPatternMode = 2;
+ else this.contestPatternMode = 0;
+ }
+
+ setDebugDisableStaticBorders(disabled: boolean) {
+ this.debugDisableStaticBorders = disabled;
+ }
+
+ setDebugDisableAllBorders(disabled: boolean) {
+ this.debugDisableAllBorders = disabled;
+ }
+
+ setSeedSamplingMode(mode: "none" | "2x2" | "3x3") {
+ this.seedSamplingMode = mode === "none" ? 0 : mode === "2x2" ? 1 : 2;
+ }
+
+ setDebugStripeFixedColors(enabled: boolean) {
+ this.debugStripeFixedColors = enabled;
+ }
+
+ setMotionMode(mode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev") {
+ if (mode === "axisSnap") this.motionMode = 1;
+ else if (mode === "manhattan") this.motionMode = 2;
+ else if (mode === "chebyshev") this.motionMode = 3;
+ else this.motionMode = 0;
+ }
+
+ markTile(tile: TileRef) {
+ if (this.needsFullUpload) {
+ return;
+ }
+ const x = tile % this.mapWidth;
+ const y = Math.floor(tile / this.mapWidth);
+ const span = this.dirtyRows.get(y);
+ if (span === undefined) {
+ this.dirtyRows.set(y, { minX: x, maxX: x });
+ } else {
+ span.minX = Math.min(span.minX, x);
+ span.maxX = Math.max(span.maxX, x);
+ }
+ }
+
+ setContestTile(
+ tile: TileRef,
+ defenderOwner: number,
+ attackerOwner: number,
+ componentId: number,
+ attackerEver: boolean,
+ ) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ const offset = tile * 2;
+ const defenderValue = defenderOwner & 0xffff;
+ const attackerValue = attackerOwner & 0xffff;
+ const idValue = (componentId & 0x7fff) | (attackerEver ? 0x8000 : 0);
+ if (
+ this.contestOwnersState[offset] === defenderValue &&
+ this.contestOwnersState[offset + 1] === attackerValue &&
+ this.contestIdsState[tile] === idValue
+ ) {
+ return;
+ }
+ this.contestOwnersState[offset] = defenderValue;
+ this.contestOwnersState[offset + 1] = attackerValue;
+ this.contestIdsState[tile] = idValue;
+ if (this.needsContestFullUpload) {
+ return;
+ }
+ const x = tile % this.mapWidth;
+ const y = Math.floor(tile / this.mapWidth);
+ const span = this.contestDirtyRows.get(y);
+ if (span === undefined) {
+ this.contestDirtyRows.set(y, { minX: x, maxX: x });
+ } else {
+ span.minX = Math.min(span.minX, x);
+ span.maxX = Math.max(span.maxX, x);
+ }
+ }
+
+ clearContestTile(tile: TileRef) {
+ this.setContestTile(tile, 0, 0, 0, false);
+ }
+
+ setContestTime(componentId: number, nowPacked: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ if (componentId <= 0) {
+ return;
+ }
+ this.ensureContestTimeCapacity(componentId);
+ const packed = nowPacked & 0xffff;
+ if (this.contestTimesState[componentId] === packed) {
+ return;
+ }
+ this.contestTimesState[componentId] = packed;
+ this.needsContestTimesUpload = true;
+ }
+
+ ensureContestTimeCapacity(componentId: number) {
+ if (componentId < this.contestTimesState.length) {
+ return;
+ }
+ let nextLength = Math.max(1, this.contestTimesState.length);
+ while (nextLength <= componentId) {
+ nextLength *= 2;
+ }
+ const nextState = new Uint16Array(nextLength);
+ nextState.set(this.contestTimesState);
+ this.contestTimesState = nextState;
+ this.needsContestTimesUpload = true;
+ }
+
+ setContestStrength(componentId: number, strength: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ if (componentId <= 0) {
+ return;
+ }
+ this.ensureContestStrengthCapacity(componentId);
+ const clamped = Math.max(0, Math.min(1, strength));
+ const packed = Math.round(clamped * 65535) & 0xffff;
+ if (this.contestStrengthsState[componentId] === packed) {
+ return;
+ }
+ this.contestStrengthsState[componentId] = packed;
+ this.needsContestStrengthsUpload = true;
+ }
+
+ ensureContestStrengthCapacity(componentId: number) {
+ if (componentId < this.contestStrengthsState.length) {
+ return;
+ }
+ let nextLength = Math.max(1, this.contestStrengthsState.length);
+ while (nextLength <= componentId) {
+ nextLength *= 2;
+ }
+ const nextState = new Uint16Array(nextLength);
+ nextState.set(this.contestStrengthsState);
+ this.contestStrengthsState = nextState;
+ this.needsContestStrengthsUpload = true;
+ }
+
+ setContestNow(nowPacked: number, durationTicks: number) {
+ if (!this.contestEnabled) {
+ return;
+ }
+ this.contestNow = nowPacked | 0;
+ this.contestDurationTicks = Math.max(0, durationTicks);
+ }
+
+ snapshotStateForSmoothing() {
+ if (
+ !this.gl ||
+ !this.prevStateCopySupported ||
+ !this.stateFramebuffer ||
+ !this.prevStateFramebuffer ||
+ !this.olderStateFramebuffer
+ ) {
+ return;
+ }
+ const gl = this.gl;
+
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.olderStateFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.stateFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevStateFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+
+ if (
+ this.jfaSupported &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer &&
+ this.jfaResultNewFramebuffer
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ }
+
+ if (
+ this.jfaSupported &&
+ this.changeMaskFramebufferOlder &&
+ this.changeMaskFramebufferOld &&
+ this.changeMaskFramebufferNew
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ }
+ this.jfaDirty = true;
+ this.changeMaskDirty = true;
+ }
+
+ setSmoothProgress(progress: number) {
+ this.smoothProgress = Math.max(0, Math.min(1, progress));
+ }
+
+ setSmoothEnabled(enabled: boolean) {
+ this.smoothEnabled =
+ enabled &&
+ this.jfaSupported &&
+ this.prevStateCopySupported &&
+ !!this.changeMaskProgram &&
+ !!this.changeMaskTextureOld &&
+ !!this.changeMaskTextureNew &&
+ !!this.jfaResultOldTexture &&
+ !!this.jfaResultNewTexture;
+ }
+
+ setInterpolationPair(pair: "prevCurrent" | "olderPrev") {
+ this.interpolationPair = pair;
+ }
+
+ markAllDirty() {
+ this.needsFullUpload = true;
+ this.dirtyRows.clear();
+ this.needsContestFullUpload = true;
+ this.needsContestTimesUpload = true;
+ this.needsContestStrengthsUpload = true;
+ this.contestDirtyRows.clear();
+ this.jfaDirty = true;
+ this.changeMaskDirty = true;
+ }
+
+ refreshPalette() {
+ if (!this.gl || !this.paletteTexture || !this.relationTexture) {
+ return;
+ }
+ this.uploadPalette();
+ }
+
+ render() {
+ if (!this.gl || !this.program || !this.vao) {
+ return;
+ }
+ const gl = this.gl;
+
+ const uploadStateSpan = FrameProfiler.start();
+ this.uploadStateTexture();
+ FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan);
+
+ if (this.contestEnabled) {
+ const uploadContestSpan = FrameProfiler.start();
+ this.uploadContestTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContests",
+ uploadContestSpan,
+ );
+
+ const uploadContestTimesSpan = FrameProfiler.start();
+ this.uploadContestTimesTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContestTimes",
+ uploadContestTimesSpan,
+ );
+
+ const uploadContestStrengthsSpan = FrameProfiler.start();
+ this.uploadContestStrengthsTexture();
+ FrameProfiler.end(
+ "TerritoryWebGLRenderer:uploadContestStrengths",
+ uploadContestStrengthsSpan,
+ );
+ }
+
+ if (this.jfaSupported) {
+ this.updateChangeMask();
+ this.updateJfa();
+ }
+
+ const renderSpan = FrameProfiler.start();
+ gl.viewport(0, 0, this.viewWidth, this.viewHeight);
+ gl.useProgram(this.program);
+ gl.bindVertexArray(this.vao);
+
+ const canUseOlderPair =
+ this.interpolationPair === "olderPrev" &&
+ !!this.prevOwnerTexture &&
+ !!this.olderOwnerTexture &&
+ !!this.jfaResultOldTexture &&
+ !!this.jfaResultOlderTexture;
+ const renderPair = canUseOlderPair ? "olderPrev" : "prevCurrent";
+
+ const toStateTexture =
+ renderPair === "olderPrev" ? this.prevOwnerTexture : this.stateTexture;
+ const fromStateTexture =
+ renderPair === "olderPrev"
+ ? this.olderOwnerTexture
+ : this.prevOwnerTexture;
+
+ if (toStateTexture) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, toStateTexture);
+ }
+ if (this.paletteTexture) {
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture);
+ }
+ if (this.relationTexture) {
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, this.relationTexture);
+ }
+ if (this.patternTexture) {
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, this.patternTexture);
+ }
+ if (this.contestOwnersTexture) {
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ }
+ if (this.contestIdsTexture) {
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ }
+ if (this.contestTimesTexture) {
+ gl.activeTexture(gl.TEXTURE6);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture);
+ }
+ if (fromStateTexture) {
+ gl.activeTexture(gl.TEXTURE7);
+ gl.bindTexture(gl.TEXTURE_2D, fromStateTexture);
+ }
+
+ const seedsOld =
+ renderPair === "olderPrev"
+ ? this.jfaResultOlderTexture
+ : this.jfaResultOldTexture;
+ const seedsNew =
+ renderPair === "olderPrev"
+ ? this.jfaResultOldTexture
+ : this.jfaResultNewTexture;
+ if (seedsOld) {
+ gl.activeTexture(gl.TEXTURE8);
+ gl.bindTexture(gl.TEXTURE_2D, seedsOld);
+ }
+ if (seedsNew) {
+ gl.activeTexture(gl.TEXTURE9);
+ gl.bindTexture(gl.TEXTURE_2D, seedsNew);
+ }
+
+ if (this.stateTexture) {
+ gl.activeTexture(gl.TEXTURE12);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+ }
+ if (this.terrainTexture) {
+ gl.activeTexture(gl.TEXTURE14);
+ gl.bindTexture(gl.TEXTURE_2D, this.terrainTexture);
+ }
+
+ const changeMaskTexture =
+ renderPair === "olderPrev"
+ ? this.changeMaskTextureOld
+ : this.changeMaskTextureNew;
+ if (changeMaskTexture) {
+ gl.activeTexture(gl.TEXTURE13);
+ gl.bindTexture(gl.TEXTURE_2D, changeMaskTexture);
+ }
+ if (this.contestStrengthsTexture) {
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ }
+ if (this.uniforms.viewResolution) {
+ gl.uniform2f(
+ this.uniforms.viewResolution,
+ this.viewWidth,
+ this.viewHeight,
+ );
+ }
+ if (this.uniforms.viewScale) {
+ gl.uniform1f(this.uniforms.viewScale, this.viewScale);
+ }
+ if (this.uniforms.viewOffset) {
+ gl.uniform2f(
+ this.uniforms.viewOffset,
+ this.viewOffsetX,
+ this.viewOffsetY,
+ );
+ }
+ if (this.uniforms.alternativeView) {
+ gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0);
+ }
+ if (this.uniforms.hoveredPlayerId) {
+ // Disable highlight after 5 seconds
+ const now = Date.now();
+ const elapsed = now - this.hoverStartTime;
+ const activeHoverId =
+ this.hoveredPlayerId >= 0 &&
+ elapsed < TerritoryWebGLRenderer.HOVER_DURATION_MS
+ ? this.hoveredPlayerId
+ : -1;
+ gl.uniform1f(this.uniforms.hoveredPlayerId, activeHoverId);
+ }
+ if (this.uniforms.hoverHighlightStrength) {
+ gl.uniform1f(
+ this.uniforms.hoverHighlightStrength,
+ this.hoverHighlightStrength,
+ );
+ }
+ if (this.uniforms.hoverHighlightColor) {
+ const [r, g, b] = this.hoverHighlightColor;
+ gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b);
+ }
+ if (this.uniforms.hoverPulseStrength) {
+ gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
+ }
+ if (this.uniforms.hoverPulseSpeed) {
+ gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
+ }
+ if (this.uniforms.time) {
+ const currentTime = (Date.now() - this.animationStartTime) / 1000.0;
+ gl.uniform1f(this.uniforms.time, currentTime);
+ }
+ if (this.uniforms.viewerId) {
+ const viewerId = this.game.myPlayer()?.smallID() ?? 0;
+ gl.uniform1i(this.uniforms.viewerId, viewerId);
+ }
+ if (this.uniforms.contestEnabled) {
+ gl.uniform1i(this.uniforms.contestEnabled, this.contestEnabled ? 1 : 0);
+ }
+ if (this.uniforms.contestPatternMode) {
+ gl.uniform1i(this.uniforms.contestPatternMode, this.contestPatternMode);
+ }
+ if (this.uniforms.debugDisableStaticBorders) {
+ gl.uniform1i(
+ this.uniforms.debugDisableStaticBorders,
+ this.debugDisableStaticBorders ? 1 : 0,
+ );
+ }
+ if (this.uniforms.debugDisableAllBorders) {
+ gl.uniform1i(
+ this.uniforms.debugDisableAllBorders,
+ this.debugDisableAllBorders ? 1 : 0,
+ );
+ }
+ if (this.uniforms.seedSamplingMode) {
+ gl.uniform1i(this.uniforms.seedSamplingMode, this.seedSamplingMode);
+ }
+ if (this.uniforms.debugStripeFixedColors) {
+ gl.uniform1i(
+ this.uniforms.debugStripeFixedColors,
+ this.debugStripeFixedColors ? 1 : 0,
+ );
+ }
+ if (this.uniforms.motionMode) {
+ gl.uniform1i(this.uniforms.motionMode, this.motionMode);
+ }
+ if (this.uniforms.contestNow) {
+ gl.uniform1i(this.uniforms.contestNow, this.contestNow);
+ }
+ if (this.uniforms.contestDuration) {
+ gl.uniform1f(this.uniforms.contestDuration, this.contestDurationTicks);
+ }
+ if (this.uniforms.smoothProgress) {
+ gl.uniform1f(this.uniforms.smoothProgress, this.smoothProgress);
+ }
+ if (this.uniforms.smoothEnabled) {
+ gl.uniform1i(this.uniforms.smoothEnabled, this.smoothEnabled ? 1 : 0);
+ }
+ if (this.uniforms.darkMode) {
+ gl.uniform1i(
+ this.uniforms.darkMode,
+ this.userSettings.darkMode() ? 1 : 0,
+ );
+ }
+
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ gl.bindVertexArray(null);
+ FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan);
+ }
+
+ getDebugStats() {
+ return {
+ mapWidth: this.mapWidth,
+ mapHeight: this.mapHeight,
+ viewWidth: this.viewWidth,
+ viewHeight: this.viewHeight,
+ viewScale: this.viewScale,
+ viewOffsetX: this.viewOffsetX,
+ viewOffsetY: this.viewOffsetY,
+ smoothEnabled: this.smoothEnabled,
+ smoothProgress: this.smoothProgress,
+ jfaSupported: this.jfaSupported,
+ jfaDisabledReason: this.jfaDisabledReason,
+ jfaDirty: this.jfaDirty,
+ prevStateCopySupported: this.prevStateCopySupported,
+ contestDurationTicks: this.contestDurationTicks,
+ contestNow: this.contestNow,
+ hoveredPlayerId: this.hoveredPlayerId,
+ };
+ }
+
+ private uploadStateTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.stateTexture) return { rows: 0, bytes: 0 };
+ const gl = this.gl;
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+
+ const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT;
+ let rowsUploaded = 0;
+ let bytesUploaded = 0;
+
+ if (this.needsFullUpload) {
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.state,
+ );
+ this.needsFullUpload = false;
+ this.dirtyRows.clear();
+ rowsUploaded = this.mapHeight;
+ bytesUploaded = this.mapWidth * this.mapHeight * bytesPerPixel;
+ return { rows: rowsUploaded, bytes: bytesUploaded };
+ }
+
+ if (this.dirtyRows.size === 0) {
+ return { rows: 0, bytes: 0 };
+ }
+
+ for (const [y, span] of this.dirtyRows) {
+ const width = span.maxX - span.minX + 1;
+ const offset = y * this.mapWidth + span.minX;
+ const rowSlice = this.state.subarray(offset, offset + width);
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ span.minX,
+ y,
+ width,
+ 1,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ rowSlice,
+ );
+ rowsUploaded++;
+ bytesUploaded += width * bytesPerPixel;
+ }
+ this.dirtyRows.clear();
+ return { rows: rowsUploaded, bytes: bytesUploaded };
+ }
+
+ private uploadContestTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.contestOwnersTexture || !this.contestIdsTexture) {
+ return { rows: 0, bytes: 0 };
+ }
+ const gl = this.gl;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+
+ const bytesPerOwnerPixel = Uint16Array.BYTES_PER_ELEMENT * 2;
+ const bytesPerIdPixel = Uint16Array.BYTES_PER_ELEMENT;
+ let rowsUploaded = 0;
+ let bytesUploaded = 0;
+
+ if (this.needsContestFullUpload) {
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RG16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestOwnersState,
+ );
+
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestIdsState,
+ );
+
+ this.needsContestFullUpload = false;
+ this.contestDirtyRows.clear();
+ rowsUploaded = this.mapHeight;
+ bytesUploaded =
+ this.mapWidth * this.mapHeight * (bytesPerOwnerPixel + bytesPerIdPixel);
+ return { rows: rowsUploaded, bytes: bytesUploaded };
+ }
+
+ if (this.contestDirtyRows.size === 0) {
+ return { rows: 0, bytes: 0 };
+ }
+
+ for (const [y, span] of this.contestDirtyRows) {
+ const width = span.maxX - span.minX + 1;
+ const ownerOffset = (y * this.mapWidth + span.minX) * 2;
+ const ownerSlice = this.contestOwnersState.subarray(
+ ownerOffset,
+ ownerOffset + width * 2,
+ );
+
+ gl.activeTexture(gl.TEXTURE4);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestOwnersTexture);
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ span.minX,
+ y,
+ width,
+ 1,
+ gl.RG_INTEGER,
+ gl.UNSIGNED_SHORT,
+ ownerSlice,
+ );
+
+ const idOffset = y * this.mapWidth + span.minX;
+ const idSlice = this.contestIdsState.subarray(idOffset, idOffset + width);
+ gl.activeTexture(gl.TEXTURE5);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestIdsTexture);
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ span.minX,
+ y,
+ width,
+ 1,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ idSlice,
+ );
+
+ rowsUploaded++;
+ bytesUploaded += width * (bytesPerOwnerPixel + bytesPerIdPixel);
+ }
+ this.contestDirtyRows.clear();
+ return { rows: rowsUploaded, bytes: bytesUploaded };
+ }
+
+ private uploadContestTimesTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.contestTimesTexture) {
+ return { rows: 0, bytes: 0 };
+ }
+ if (!this.needsContestTimesUpload) {
+ return { rows: 0, bytes: 0 };
+ }
+ const gl = this.gl;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.activeTexture(gl.TEXTURE6);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestTimesTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.contestTimesState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestTimesState,
+ );
+ this.needsContestTimesUpload = false;
+ const bytes = this.contestTimesState.length * Uint16Array.BYTES_PER_ELEMENT;
+ return { rows: 1, bytes };
+ }
+
+ private uploadContestStrengthsTexture(): { rows: number; bytes: number } {
+ if (!this.gl || !this.contestStrengthsTexture) {
+ return { rows: 0, bytes: 0 };
+ }
+ if (!this.needsContestStrengthsUpload) {
+ return { rows: 0, bytes: 0 };
+ }
+ const gl = this.gl;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.activeTexture(gl.TEXTURE11);
+ gl.bindTexture(gl.TEXTURE_2D, this.contestStrengthsTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R16UI,
+ this.contestStrengthsState.length,
+ 1,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_SHORT,
+ this.contestStrengthsState,
+ );
+ this.needsContestStrengthsUpload = false;
+ const bytes =
+ this.contestStrengthsState.length * Uint16Array.BYTES_PER_ELEMENT;
+ return { rows: 1, bytes };
+ }
+
+ private updateChangeMask() {
+ if (
+ !this.gl ||
+ !this.jfaSupported ||
+ !this.changeMaskDirty ||
+ !this.changeMaskProgram ||
+ !this.changeMaskFramebufferNew ||
+ !this.changeMaskFramebufferOld ||
+ !this.changeMaskFramebufferOlder ||
+ !this.prevOwnerTexture ||
+ !this.stateTexture ||
+ !this.jfaVao
+ ) {
+ return;
+ }
+
+ const gl = this.gl;
+ const prevBlend = gl.isEnabled(gl.BLEND);
+ gl.disable(gl.BLEND);
+ gl.viewport(0, 0, this.mapWidth, this.mapHeight);
+ gl.bindVertexArray(this.jfaVao);
+
+ gl.useProgram(this.changeMaskProgram);
+ if (this.changeMaskUniforms.resolution) {
+ gl.uniform2f(
+ this.changeMaskUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.changeMaskUniforms.oldTexture) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.prevOwnerTexture);
+ gl.uniform1i(this.changeMaskUniforms.oldTexture, 0);
+ }
+ if (this.changeMaskUniforms.newTexture) {
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
+ gl.uniform1i(this.changeMaskUniforms.newTexture, 1);
+ }
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ if (!this.changeMaskHistoryInitialized) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.changeMaskFramebufferNew);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOld);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.changeMaskFramebufferOlder);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ this.changeMaskHistoryInitialized = true;
+ }
+
+ this.changeMaskDirty = false;
+
+ if (prevBlend) {
+ gl.enable(gl.BLEND);
+ }
+ }
+
+ private updateJfa() {
+ if (
+ !this.gl ||
+ !this.jfaSupported ||
+ !this.jfaSeedProgram ||
+ !this.jfaProgram ||
+ !this.jfaFramebufferA ||
+ !this.jfaFramebufferB ||
+ !this.jfaTextureA ||
+ !this.jfaTextureB ||
+ !this.stateTexture ||
+ !this.jfaResultNewFramebuffer ||
+ !this.jfaResultNewTexture ||
+ !this.jfaVao
+ ) {
+ return;
+ }
+ if (!this.jfaDirty) {
+ return;
+ }
+ const gl = this.gl;
+ const prevBlend = gl.isEnabled(gl.BLEND);
+ gl.disable(gl.BLEND);
+ gl.viewport(0, 0, this.mapWidth, this.mapHeight);
+ gl.bindVertexArray(this.jfaVao);
+
+ const runJfa = (
+ ownerTexture: WebGLTexture,
+ resultFramebuffer: WebGLFramebuffer,
+ ) => {
+ gl.useProgram(this.jfaSeedProgram);
+ if (this.jfaSeedUniforms.resolution) {
+ gl.uniform2f(
+ this.jfaSeedUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.jfaSeedUniforms.owner) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, ownerTexture);
+ gl.uniform1i(this.jfaSeedUniforms.owner, 0);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.jfaFramebufferA);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+ let readTex = this.jfaTextureA;
+ let readFbo = this.jfaFramebufferA;
+ let writeFbo = this.jfaFramebufferB;
+ let writeTex = this.jfaTextureB;
+ for (const step of this.jfaSteps) {
+ gl.useProgram(this.jfaProgram);
+ if (this.jfaUniforms.resolution) {
+ gl.uniform2f(
+ this.jfaUniforms.resolution,
+ this.mapWidth,
+ this.mapHeight,
+ );
+ }
+ if (this.jfaUniforms.step) {
+ gl.uniform1f(this.jfaUniforms.step, step);
+ }
+ if (this.jfaUniforms.seeds) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, readTex);
+ gl.uniform1i(this.jfaUniforms.seeds, 0);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, writeFbo);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+ const tempTex = readTex;
+ readTex = writeTex;
+ writeTex = tempTex;
+ const tempFbo = readFbo;
+ readFbo = writeFbo;
+ writeFbo = tempFbo;
+ }
+
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, readFbo);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, resultFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ };
+
+ runJfa(this.stateTexture, this.jfaResultNewFramebuffer);
+
+ this.jfaDirty = false;
+
+ if (
+ !this.jfaHistoryInitialized &&
+ this.jfaResultOlderFramebuffer &&
+ this.jfaResultOldFramebuffer
+ ) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.jfaResultNewFramebuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOldFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.jfaResultOlderFramebuffer);
+ gl.blitFramebuffer(
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ 0,
+ 0,
+ this.mapWidth,
+ this.mapHeight,
+ gl.COLOR_BUFFER_BIT,
+ gl.NEAREST,
+ );
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ this.jfaHistoryInitialized = true;
+ }
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ if (prevBlend) {
+ gl.enable(gl.BLEND);
+ }
+ gl.bindVertexArray(null);
+ }
+
+ private buildJfaSteps(width: number, height: number): number[] {
+ const maxDim = Math.max(width, height);
+ let step = 1;
+ while (step < maxDim) {
+ step <<= 1;
+ }
+ step >>= 1;
+ const steps: number[] = [];
+ while (step >= 1) {
+ steps.push(step);
+ step >>= 1;
+ }
+ return steps;
+ }
+
+ private uploadPalette() {
+ if (
+ !this.gl ||
+ !this.paletteTexture ||
+ !this.relationTexture ||
+ !this.patternTexture ||
+ !this.program
+ )
+ return;
+ const gl = this.gl;
+ const players = this.game.playerViews().filter((p) => p.isPlayer());
+
+ const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1;
+ this.paletteWidth = Math.max(maxId, 1);
+
+ const paletteData = new Uint8Array(this.paletteWidth * 8);
+ const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth);
+ const patternData = new Uint8Array(
+ this.paletteWidth * PATTERN_STRIDE_BYTES,
+ );
+
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ const defaultPatternBytes = this.getPatternBytes(
+ DefaultPattern.patternData,
+ );
+
+ for (const p of players) {
+ const id = p.smallID();
+ const territoryRgba = p.territoryColor().rgba;
+ paletteData[id * 8] = territoryRgba.r;
+ paletteData[id * 8 + 1] = territoryRgba.g;
+ paletteData[id * 8 + 2] = territoryRgba.b;
+ paletteData[id * 8 + 3] = Math.round((territoryRgba.a ?? 1) * 255);
+
+ const borderRgba = p.borderColor().rgba;
+ paletteData[id * 8 + 4] = borderRgba.r;
+ paletteData[id * 8 + 5] = borderRgba.g;
+ paletteData[id * 8 + 6] = borderRgba.b;
+ paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255);
+
+ const patternBytes =
+ patternsEnabled && p.cosmetics.pattern
+ ? this.getPatternBytes(p.cosmetics.pattern.patternData)
+ : defaultPatternBytes;
+ const offset = id * PATTERN_STRIDE_BYTES;
+ patternData.set(patternBytes.slice(0, PATTERN_STRIDE_BYTES), offset);
+ }
+
+ for (let ownerId = 0; ownerId < this.paletteWidth; ownerId++) {
+ const owner = this.safePlayerBySmallId(ownerId);
+ for (let otherId = 0; otherId < this.paletteWidth; otherId++) {
+ const other = this.safePlayerBySmallId(otherId);
+ relationData[ownerId * this.paletteWidth + otherId] =
+ this.resolveRelationCode(owner, other);
+ }
+ }
+
+ gl.useProgram(this.program);
+
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA8,
+ this.paletteWidth * 2,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ paletteData,
+ );
+
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, this.relationTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R8UI,
+ this.paletteWidth,
+ this.paletteWidth,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ relationData,
+ );
+
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, this.patternTexture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.R8UI,
+ PATTERN_STRIDE_BYTES,
+ this.paletteWidth,
+ 0,
+ gl.RED_INTEGER,
+ gl.UNSIGNED_BYTE,
+ patternData,
+ );
+
+ if (this.uniforms.patternStride) {
+ gl.uniform1i(this.uniforms.patternStride, PATTERN_STRIDE_BYTES);
+ }
+ if (this.uniforms.patternRows) {
+ gl.uniform1i(this.uniforms.patternRows, this.paletteWidth);
+ }
+ }
+
+ private resolveRelationCode(
+ owner: PlayerView | null,
+ other: PlayerView | null,
+ ): number {
+ if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) {
+ return 0;
+ }
+
+ let code = 0;
+ if (owner.smallID() === other.smallID()) {
+ code |= 4;
+ }
+ if (owner.isFriendly(other) || other.isFriendly(owner)) {
+ code |= 1;
+ }
+ if (owner.hasEmbargo(other)) {
+ code |= 2;
+ }
+ return code;
+ }
+
+ private safePlayerBySmallId(id: number): PlayerView | null {
+ const player = this.game.playerBySmallID(id);
+ return player instanceof PlayerView ? player : null;
+ }
+
+ private getPatternBytes(patternData: string): Uint8Array {
+ const cached = this.patternBytesCache.get(patternData);
+ if (cached) {
+ return cached;
+ }
+ try {
+ const bytes = base64url.decode(patternData);
+ this.patternBytesCache.set(patternData, bytes);
+ return bytes;
+ } catch (error) {
+ const fallback = base64url.decode(DefaultPattern.patternData);
+ this.patternBytesCache.set(patternData, fallback);
+ return fallback;
+ }
+ }
+
+ private createJfaSeedProgram(
+ gl: WebGL2RenderingContext,
+ ): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) in vec2 a_position;
+ uniform vec2 u_resolution;
+ void main() {
+ vec2 zeroToOne = a_position / u_resolution;
+ vec2 clipSpace = zeroToOne * 2.0 - 1.0;
+ clipSpace.y = -clipSpace.y;
+ gl_Position = vec4(clipSpace, 0.0, 1.0);
+ }
+ `;
+
+ const fragmentShaderSource = `#version 300 es
+ precision highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_ownerTexture;
+ uniform vec2 u_resolution;
+
+ out vec2 outSeed;
+
+ uint ownerAt(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(u_ownerTexture, clamped, 0).r & 0xFFFu;
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+
+ uint owner = ownerAt(texCoord);
+ bool isBorder = false;
+ vec2 edgeDir = vec2(0.0);
+ uint nOwner = ownerAt(texCoord + ivec2(1, 0));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(1.0, 0.0); }
+ nOwner = ownerAt(texCoord + ivec2(-1, 0));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(-1.0, 0.0); }
+ nOwner = ownerAt(texCoord + ivec2(0, 1));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, 1.0); }
+ nOwner = ownerAt(texCoord + ivec2(0, -1));
+ if (nOwner != owner) { isBorder = true; edgeDir += vec2(0.0, -1.0); }
+
+ vec2 edgeOffset = vec2(
+ edgeDir.x == 0.0 ? 0.0 : (edgeDir.x > 0.0 ? 0.5 : -0.5),
+ edgeDir.y == 0.0 ? 0.0 : (edgeDir.y > 0.0 ? 0.5 : -0.5)
+ );
+
+ // Seed at the border edge (tile center +/- 0.5) so the front can move
+ // even when the border tile itself stays the same.
+ outSeed = isBorder
+ ? (vec2(texCoord) + vec2(0.5) + edgeOffset)
+ : vec2(-1.0, -1.0);
+ }
+ `;
+
+ const vertexShader = this.compileShader(
+ gl,
+ gl.VERTEX_SHADER,
+ vertexShaderSource,
+ );
+ const fragmentShader = this.compileShader(
+ gl,
+ gl.FRAGMENT_SHADER,
+ fragmentShaderSource,
+ );
+ if (!vertexShader || !fragmentShader) {
+ return null;
+ }
+
+ const program = gl.createProgram();
+ if (!program) return null;
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error(
+ "[TerritoryWebGLRenderer] JFA seed link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createJfaProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) in vec2 a_position;
+ uniform vec2 u_resolution;
+ void main() {
+ vec2 zeroToOne = a_position / u_resolution;
+ vec2 clipSpace = zeroToOne * 2.0 - 1.0;
+ clipSpace.y = -clipSpace.y;
+ gl_Position = vec4(clipSpace, 0.0, 1.0);
+ }
+ `;
+
+ const fragmentShaderSource = `#version 300 es
+ precision highp float;
+
+ uniform sampler2D u_seeds;
+ uniform vec2 u_resolution;
+ uniform float u_step;
+
+ out vec2 outSeed;
+
+ vec2 seedAt(ivec2 coord) {
+ // coord is in texCoord space (Y-flipped from fragCoord)
+ // JFA texture was written at fragCoord positions, so flip back
+ ivec2 jfaCoord = ivec2(coord.x, int(u_resolution.y) - 1 - coord.y);
+ ivec2 clamped = clamp(
+ jfaCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(u_seeds, clamped, 0).rg;
+ }
+
+ void considerSeed(ivec2 coord, ivec2 texCoord, inout vec2 bestSeed, inout float bestDist) {
+ vec2 seed = seedAt(coord);
+ if (seed.x < 0.0) {
+ return;
+ }
+ float dist = length(seed - (vec2(texCoord) + vec2(0.5)));
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestSeed = seed;
+ }
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+ int step = int(u_step + 0.5);
+
+ vec2 bestSeed = seedAt(texCoord);
+ vec2 texPos = vec2(texCoord) + vec2(0.5);
+ float bestDist = bestSeed.x < 0.0 ? 1e20 : length(bestSeed - texPos);
+
+ considerSeed(texCoord + ivec2(-step, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(0, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, -step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(-step, 0), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, 0), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(-step, step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(0, step), texCoord, bestSeed, bestDist);
+ considerSeed(texCoord + ivec2(step, step), texCoord, bestSeed, bestDist);
+
+ outSeed = bestSeed;
+ }
+ `;
+
+ const vertexShader = this.compileShader(
+ gl,
+ gl.VERTEX_SHADER,
+ vertexShaderSource,
+ );
+ const fragmentShader = this.compileShader(
+ gl,
+ gl.FRAGMENT_SHADER,
+ fragmentShaderSource,
+ );
+ if (!vertexShader || !fragmentShader) {
+ return null;
+ }
+
+ const program = gl.createProgram();
+ if (!program) return null;
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error(
+ "[TerritoryWebGLRenderer] JFA link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createChangeMaskProgram(
+ gl: WebGL2RenderingContext,
+ ): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) in vec2 a_position;
+ uniform vec2 u_resolution;
+ void main() {
+ vec2 zeroToOne = a_position / u_resolution;
+ vec2 clipSpace = zeroToOne * 2.0 - 1.0;
+ clipSpace.y = -clipSpace.y;
+ gl_Position = vec4(clipSpace, 0.0, 1.0);
+ }
+ `;
+
+ const fragmentShaderSource = `#version 300 es
+ precision highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_oldTexture;
+ uniform usampler2D u_newTexture;
+ uniform vec2 u_resolution;
+
+ layout(location = 0) out uint outMask;
+
+ uint ownerAt(usampler2D tex, ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
+ );
+ return texelFetch(tex, clamped, 0).r & 0xFFFu;
+ }
+
+ void main() {
+ ivec2 fragCoord = ivec2(gl_FragCoord.xy);
+ ivec2 texCoord = ivec2(
+ fragCoord.x,
+ int(u_resolution.y) - 1 - fragCoord.y
+ );
+
+ bool changed = ownerAt(u_oldTexture, texCoord) != ownerAt(u_newTexture, texCoord);
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(1, 0)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(-1, 0)) != ownerAt(u_newTexture, texCoord + ivec2(-1, 0)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, 1)) != ownerAt(u_newTexture, texCoord + ivec2(0, 1)));
+ changed = changed || (ownerAt(u_oldTexture, texCoord + ivec2(0, -1)) != ownerAt(u_newTexture, texCoord + ivec2(0, -1)));
+
+ outMask = changed ? 1u : 0u;
+ }
+ `;
+
+ const vertexShader = this.compileShader(
+ gl,
+ gl.VERTEX_SHADER,
+ vertexShaderSource,
+ );
+ const fragmentShader = this.compileShader(
+ gl,
+ gl.FRAGMENT_SHADER,
+ fragmentShaderSource,
+ );
+ if (!vertexShader || !fragmentShader) {
+ return null;
+ }
+
+ const program = gl.createProgram();
+ if (!program) return null;
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error(
+ "[TerritoryWebGLRenderer] change mask link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
+ const vertexShaderSource = `#version 300 es
+ precision highp float;
+ layout(location = 0) in vec2 a_position;
+ uniform vec2 u_viewResolution;
+ void main() {
+ vec2 zeroToOne = a_position / u_viewResolution;
+ vec2 clipSpace = zeroToOne * 2.0 - 1.0;
+ clipSpace.y = -clipSpace.y;
+ gl_Position = vec4(clipSpace, 0.0, 1.0);
+ }
+ `;
+
+ const fragmentShaderSource = `#version 300 es
+ precision highp float;
+ precision highp usampler2D;
+
+ uniform usampler2D u_state;
+ uniform usampler2D u_terrain;
+ uniform usampler2D u_latestState;
+ uniform sampler2D u_palette;
+ uniform usampler2D u_relations;
+ uniform usampler2D u_patterns;
+ uniform bool u_contestEnabled;
+ uniform int u_contestPatternMode; // 0=blueNoise(strength), 1=checkerboard(50/50), 2=bayer4x4(strength)
+ uniform bool u_debugDisableStaticBorders;
+ uniform bool u_debugDisableAllBorders;
+ uniform int u_seedSamplingMode; // 0=none(single texel), 1=2x2, 2=3x3
+ uniform bool u_debugStripeFixedColors; // Use fixed debug colors for moving stripe
+ uniform int u_motionMode; // 0=euclidean, 1=axisSnap, 2=manhattan, 3=chebyshev
+ uniform usampler2D u_contestOwners;
+ uniform usampler2D u_contestIds;
+ uniform usampler2D u_contestTimes;
+ uniform usampler2D u_contestStrengths;
+ uniform bool u_jfaAvailable;
+ uniform int u_contestNow;
+ uniform float u_contestDurationTicks;
+ uniform usampler2D u_prevOwner;
+ uniform usampler2D u_changeMask;
+ uniform sampler2D u_jfaSeedsOld;
+ uniform sampler2D u_jfaSeedsNew;
+ uniform float u_smoothProgress;
+ uniform bool u_smoothEnabled;
+ uniform int u_patternStride;
+ uniform int u_patternRows;
+ uniform int u_viewerId;
+ uniform vec2 u_mapResolution;
+ uniform vec2 u_viewResolution;
+ uniform float u_viewScale;
+ uniform vec2 u_viewOffset;
+ uniform vec4 u_fallout;
+ uniform vec4 u_altSelf;
+ uniform vec4 u_altAlly;
+ uniform vec4 u_altNeutral;
+ uniform vec4 u_altEnemy;
+ uniform float u_alpha;
+ uniform bool u_alternativeView;
+ uniform float u_hoveredPlayerId;
+ uniform vec3 u_hoverHighlightColor;
+ uniform float u_hoverHighlightStrength;
+ uniform float u_hoverPulseStrength;
+ uniform float u_hoverPulseSpeed;
+ uniform float u_time;
+ uniform bool u_darkMode;
+
+ out vec4 outColor;
+
+ uint stateAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_state, clamped, 0).r;
+ }
+
+ uint ownerAtTex(ivec2 texCoord) {
+ return stateAtTex(texCoord) & 0xFFFu;
+ }
+
+ // Terrain bit layout: bit7=land, bit6=shoreline, bit5=ocean, bits0-4=magnitude
+ uint terrainAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_terrain, clamped, 0).r;
+ }
+
+ bool isLand(uint terrain) {
+ return (terrain & 0x80u) != 0u; // bit 7
+ }
+
+ bool isShoreline(uint terrain) {
+ return (terrain & 0x40u) != 0u; // bit 6
+ }
+
+ bool isOcean(uint terrain) {
+ return (terrain & 0x20u) != 0u; // bit 5
+ }
+
+ uint getMagnitude(uint terrain) {
+ return terrain & 0x1Fu; // bits 0-4
+ }
+
+ // Compute terrain color based on type, magnitude, and theme
+ // Colors match PastelTheme (light) and PastelThemeDark exactly
+ vec3 terrainColor(uint terrain) {
+ uint mag = getMagnitude(terrain);
+ float fmag = float(mag);
+
+ if (isLand(terrain)) {
+ if (isShoreline(terrain)) {
+ // Shore/beach - land adjacent to water
+ // Light: rgb(204,203,158), Dark: rgb(134,133,88)
+ return u_darkMode
+ ? vec3(134.0/255.0, 133.0/255.0, 88.0/255.0)
+ : vec3(204.0/255.0, 203.0/255.0, 158.0/255.0);
+ }
+ if (mag < 10u) {
+ // Plains (mag 0-9)
+ // Light: rgb(190, 220-2*mag, 138), Dark: rgb(140, 170-2*mag, 88)
+ return u_darkMode
+ ? vec3(140.0/255.0, (170.0 - 2.0*fmag)/255.0, 88.0/255.0)
+ : vec3(190.0/255.0, (220.0 - 2.0*fmag)/255.0, 138.0/255.0);
+ } else if (mag < 20u) {
+ // Highland (mag 10-19)
+ // Light: rgb(200+2*mag, 183+2*mag, 138+2*mag)
+ // Dark: rgb(150+2*mag, 133+2*mag, 88+2*mag)
+ return u_darkMode
+ ? vec3((150.0 + 2.0*fmag)/255.0, (133.0 + 2.0*fmag)/255.0, (88.0 + 2.0*fmag)/255.0)
+ : vec3((200.0 + 2.0*fmag)/255.0, (183.0 + 2.0*fmag)/255.0, (138.0 + 2.0*fmag)/255.0);
+ } else {
+ // Mountain (mag 20-30)
+ // Light: rgb(230+mag/2, 230+mag/2, 230+mag/2)
+ // Dark: rgb(180+mag/2, 180+mag/2, 180+mag/2)
+ float base = u_darkMode ? 180.0 : 230.0;
+ float val = (base + fmag/2.0) / 255.0;
+ return vec3(val, val, val);
+ }
+ } else {
+ // Water
+ if (isShoreline(terrain)) {
+ // Shoreline water - lighter, adjacent to land
+ // Light: rgb(100,143,255), Dark: rgb(50,50,50)
+ return u_darkMode
+ ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0)
+ : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0);
+ }
+ if (isOcean(terrain)) {
+ // Ocean - depth-adjusted
+ // Light base: rgb(70,132,180), adjusted by +1-min(mag,10)
+ // Dark base: rgb(14,11,30), adjusted by +9-mag for mag<10
+ float depthAdj = float(min(mag, 10u));
+ if (u_darkMode) {
+ // Dark: rgb(14+9-mag, 11+9-mag, 30+9-mag) for mag<10, else rgb(14,11,30)
+ if (mag < 10u) {
+ return vec3(
+ (14.0 + 9.0 - fmag)/255.0,
+ (11.0 + 9.0 - fmag)/255.0,
+ (30.0 + 9.0 - fmag)/255.0
+ );
+ }
+ return vec3(14.0/255.0, 11.0/255.0, 30.0/255.0);
+ } else {
+ // Light: rgb(70-10+11-min(mag,10), 132-10+11-min(mag,10), 180-10+11-min(mag,10))
+ // = rgb(71-depthAdj, 133-depthAdj, 181-depthAdj)
+ return vec3(
+ (71.0 - depthAdj)/255.0,
+ (133.0 - depthAdj)/255.0,
+ (181.0 - depthAdj)/255.0
+ );
+ }
+ } else {
+ // Lake - use same as shoreline water for simplicity
+ // Light: rgb(100,143,255), Dark: rgb(50,50,50)
+ return u_darkMode
+ ? vec3(50.0/255.0, 50.0/255.0, 50.0/255.0)
+ : vec3(100.0/255.0, 143.0/255.0, 255.0/255.0);
+ }
+ }
+ }
+
+ uint prevStateAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_prevOwner, clamped, 0).r;
+ }
+
+ uint prevOwnerAtTex(ivec2 texCoord) {
+ return prevStateAtTex(texCoord) & 0xFFFu;
+ }
+
+ vec2 jfaSeedOldAtTex(ivec2 texCoord) {
+ // JFA texture was written with fragCoord (bottom-left origin), but we're reading with
+ // texCoord (top-left origin, same as state texture). Need to flip Y to match.
+ // JFA row 0 = fragCoord.y=0 = stateTexCoord.y=height-1 = bottom of map
+ // To read data for texCoord.y=0 (top), we need JFA row height-1
+ ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ ivec2 clamped = clamp(
+ flipped,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_jfaSeedsOld, clamped, 0).rg;
+ }
+
+ vec2 jfaSeedNewAtTex(ivec2 texCoord) {
+ // JFA texture was written with fragCoord (bottom-left origin), but we're reading with
+ // texCoord (top-left origin, same as state texture). Need to flip Y to match.
+ ivec2 flipped = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ ivec2 clamped = clamp(
+ flipped,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_jfaSeedsNew, clamped, 0).rg;
+ }
+
+ // Best-of-NxN seed sampling to reduce tile-boundary discontinuities.
+ // Returns the seed (from OLD JFA) that is closest to mapCoord.
+ vec2 bestSeedOld(vec2 mapCoord) {
+ ivec2 base = ivec2(floor(mapCoord));
+ float bestDist = 1e9;
+ vec2 bestSeed = vec2(-1.0);
+
+ int radius = u_seedSamplingMode == 2 ? 1 : 0; // 3x3 vs 2x2
+ int end = u_seedSamplingMode == 2 ? 2 : 2; // 3x3: -1..+1, 2x2: 0..+1
+ int start = u_seedSamplingMode == 2 ? -1 : 0;
+
+ for (int dy = start; dy < end; dy++) {
+ for (int dx = start; dx < end; dx++) {
+ ivec2 sampleTex = base + ivec2(dx, dy);
+ vec2 seed = jfaSeedOldAtTex(sampleTex);
+ if (seed.x >= 0.0) {
+ float d = distance(mapCoord, seed);
+ if (d < bestDist) {
+ bestDist = d;
+ bestSeed = seed;
+ }
+ }
+ }
+ }
+ return bestSeed;
+ }
+
+ // Best-of-NxN seed sampling for NEW JFA.
+ vec2 bestSeedNew(vec2 mapCoord) {
+ ivec2 base = ivec2(floor(mapCoord));
+ float bestDist = 1e9;
+ vec2 bestSeed = vec2(-1.0);
+
+ int radius = u_seedSamplingMode == 2 ? 1 : 0;
+ int end = u_seedSamplingMode == 2 ? 2 : 2;
+ int start = u_seedSamplingMode == 2 ? -1 : 0;
+
+ for (int dy = start; dy < end; dy++) {
+ for (int dx = start; dx < end; dx++) {
+ ivec2 sampleTex = base + ivec2(dx, dy);
+ vec2 seed = jfaSeedNewAtTex(sampleTex);
+ if (seed.x >= 0.0) {
+ float d = distance(mapCoord, seed);
+ if (d < bestDist) {
+ bestDist = d;
+ bestSeed = seed;
+ }
+ }
+ }
+ }
+ return bestSeed;
+ }
+
+ uvec2 contestOwnersAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_contestOwners, clamped, 0).rg;
+ }
+
+ uint contestIdRawAtTex(ivec2 texCoord) {
+ ivec2 clamped = clamp(
+ texCoord,
+ ivec2(0, 0),
+ ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1)
+ );
+ return texelFetch(u_contestIds, clamped, 0).r;
+ }
+
+ float contestStrength(uint contestId) {
+ if (contestId == 0u) {
+ return 0.5;
+ }
+ uint strengthRaw = texelFetch(
+ u_contestStrengths,
+ ivec2(int(contestId), 0),
+ 0
+ ).r;
+ return clamp(float(strengthRaw) / 65535.0, 0.0, 1.0);
+ }
+
+ float blueNoise(ivec2 texCoord) {
+ vec2 p = vec2(texCoord);
+ float x = fract(0.06711056 * p.x + 0.00583715 * p.y);
+ return fract(52.9829189 * x);
+ }
+
+ float bayer4x4(ivec2 texCoord) {
+ // Classic 4x4 Bayer matrix values 0..15 mapped to (0.5/16 .. 15.5/16)
+ int x = texCoord.x & 3;
+ int y = texCoord.y & 3;
+ int idx = (y << 2) | x;
+ int v = 0;
+ // Row-major:
+ // 0 8 2 10
+ // 12 4 14 6
+ // 3 11 1 9
+ // 15 7 13 5
+ if (idx == 0) v = 0;
+ else if (idx == 1) v = 8;
+ else if (idx == 2) v = 2;
+ else if (idx == 3) v = 10;
+ else if (idx == 4) v = 12;
+ else if (idx == 5) v = 4;
+ else if (idx == 6) v = 14;
+ else if (idx == 7) v = 6;
+ else if (idx == 8) v = 3;
+ else if (idx == 9) v = 11;
+ else if (idx == 10) v = 1;
+ else if (idx == 11) v = 9;
+ else if (idx == 12) v = 15;
+ else if (idx == 13) v = 7;
+ else if (idx == 14) v = 13;
+ else v = 5;
+ return (float(v) + 0.5) / 16.0;
+ }
+
+ bool contestPickAttacker(ivec2 texCoord, float strength) {
+ if (u_contestPatternMode == 1) {
+ // Checkerboard is always 50/50 (ignores strength)
+ return ((texCoord.x + texCoord.y) & 1) == 0;
+ }
+ if (u_contestPatternMode == 2) {
+ return bayer4x4(texCoord) < strength;
+ }
+ return blueNoise(texCoord) < strength;
+ }
+
+ uint relationCode(uint owner, uint other) {
+ if (owner == 0u || other == 0u) {
+ return 0u;
+ }
+ return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r;
+ }
+
+ bool isFriendly(uint code) {
+ return (code & 1u) != 0u;
+ }
+
+ bool isEmbargo(uint code) {
+ return (code & 2u) != 0u;
+ }
+
+ bool isSelf(uint code) {
+ return (code & 4u) != 0u;
+ }
+
+ uint patternByte(uint owner, uint offset) {
+ int x = int(offset);
+ int y = int(owner);
+ if (x < 0 || x >= u_patternStride || y < 0 || y >= u_patternRows) {
+ return 0u;
+ }
+ return texelFetch(u_patterns, ivec2(x, y), 0).r;
+ }
+
+ bool patternIsPrimary(uint owner, ivec2 texCoord) {
+ uint version = patternByte(owner, 0u);
+ if (version != 0u) {
+ return true;
+ }
+ uint b1 = patternByte(owner, 1u);
+ uint b2 = patternByte(owner, 2u);
+ uint scale = b1 & 7u;
+ uint width = (((b2 & 3u) << 5) | ((b1 >> 3) & 31u)) + 2u;
+ uint height = ((b2 >> 2) & 63u) + 2u;
+ if (width == 0u || height == 0u) {
+ return true;
+ }
+ uint px = (uint(texCoord.x) >> scale) % width;
+ uint py = (uint(texCoord.y) >> scale) % height;
+ uint idx = py * width + px;
+ uint byteIndex = idx >> 3;
+ uint bitIndex = idx & 7u;
+ uint byteVal = patternByte(owner, 3u + byteIndex);
+ return (byteVal & (1u << bitIndex)) == 0u;
+ }
+
+ vec3 applyDefended(vec3 color, bool defended, ivec2 texCoord) {
+ if (!defended) {
+ return color;
+ }
+ bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2));
+ const float LIGHT_FACTOR = 1.2;
+ const float DARK_FACTOR = 0.8;
+ return color * (isLightTile ? LIGHT_FACTOR : DARK_FACTOR);
+ }
+
+ vec3 applyBorderTint(vec3 color, bool hasFriendly, bool hasEmbargo) {
+ const float BORDER_TINT_RATIO = 0.35;
+ const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
+ const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
+ if (hasFriendly) {
+ color = color * (1.0 - BORDER_TINT_RATIO) +
+ FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
+ }
+ if (hasEmbargo) {
+ color = color * (1.0 - BORDER_TINT_RATIO) +
+ EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
+ }
+ return color;
+ }
+
+ void main() {
+ // gl_FragCoord.xy is already at pixel center (0.5, 0.5 ...).
+ // Use the pixel center to avoid half-pixel snapping/offset artifacts,
+ // especially noticeable on the interpolated JFA border/front.
+ vec2 viewCoord = vec2(
+ gl_FragCoord.x - 0.5,
+ u_viewResolution.y - gl_FragCoord.y - 0.5
+ );
+ vec2 mapHalf = u_mapResolution * 0.5;
+ vec2 mapCoord = (viewCoord - mapHalf) / u_viewScale + u_viewOffset + mapHalf;
+ if (
+ mapCoord.x < 0.0 ||
+ mapCoord.y < 0.0 ||
+ mapCoord.x >= u_mapResolution.x ||
+ mapCoord.y >= u_mapResolution.y
+ ) {
+ outColor = vec4(0.0);
+ return;
+ }
+ // Tile centers are at (0.5, 1.5, 2.5, ...). Floor gives the tile index.
+ // Original ivec2(mapCoord) is equivalent but less explicit.
+ ivec2 texCoord = ivec2(mapCoord);
+
+ uint state = stateAtTex(texCoord);
+ uint owner = state & 0xFFFu;
+ bool hasFallout = (state & 0x2000u) != 0u;
+ bool isDefended = (state & 0x1000u) != 0u;
+ uint latestState = texelFetch(u_latestState, texCoord, 0).r;
+ uint latestOwner = latestState & 0xFFFu;
+ uint oldState = prevStateAtTex(texCoord);
+ uint oldOwner = oldState & 0xFFFu;
+ bool oldHasFallout = (oldState & 0x2000u) != 0u;
+ bool oldIsDefended = (oldState & 0x1000u) != 0u;
+ // ChangeMask was written with Y-flipped coords, so flip when reading
+ ivec2 changeMaskCoord = ivec2(texCoord.x, int(u_mapResolution.y) - 1 - texCoord.y);
+ uint changeMask = texelFetch(u_changeMask, changeMaskCoord, 0).r;
+
+ // Expand the animation region by 1 tile (halo) so the *outer* border edge can move smoothly.
+ // If we only animate "changed" tiles, the leading edge stays pinned to tile coordinates because
+ // neighbor pixels are still rendered from the static FROM snapshot.
+ uint affectedMask = changeMask;
+ ivec2 cm;
+ cm = ivec2(clamp(texCoord.x + 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(clamp(texCoord.x - 1, 0, int(u_mapResolution.x) - 1), texCoord.y);
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(texCoord.x, clamp(texCoord.y + 1, 0, int(u_mapResolution.y) - 1));
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ cm = ivec2(texCoord.x, clamp(texCoord.y - 1, 0, int(u_mapResolution.y) - 1));
+ affectedMask |= texelFetch(u_changeMask, ivec2(cm.x, int(u_mapResolution.y) - 1 - cm.y), 0).r;
+ bool smoothActive = u_smoothEnabled &&
+ u_smoothProgress < 1.0 &&
+ !u_alternativeView &&
+ u_jfaAvailable &&
+ affectedMask != 0u;
+
+ uint contestIdRaw = 0u;
+ const uint CONTEST_ID_MASK = 0x7FFFu;
+ uint contestId = 0u;
+ uvec2 contestOwners = uvec2(0u);
+ uint defender = 0u;
+ bool contested = false;
+ if (u_contestEnabled) {
+ contestIdRaw = contestIdRawAtTex(texCoord);
+ contestId = contestIdRaw & CONTEST_ID_MASK;
+ contestOwners = contestOwnersAtTex(texCoord);
+ defender = contestOwners.r & 0xFFFu;
+
+ if (contestId != 0u) {
+ uint lastTime = texelFetch(u_contestTimes, ivec2(int(contestId), 0), 0).r;
+ const uint CONTEST_WRAP = 32768u;
+ uint nowTime = uint(u_contestNow);
+ uint elapsed = nowTime >= lastTime
+ ? (nowTime - lastTime)
+ : (CONTEST_WRAP - lastTime + nowTime);
+ contested = float(elapsed) < u_contestDurationTicks;
+ }
+ }
+
+ // Border detection: check if any neighbor has a different owner.
+ bool isBorder = false;
+ bool hasFriendlyRelation = false;
+ bool hasEmbargoRelation = false;
+ if (!smoothActive) {
+ uint nOwner = ownerAtTex(texCoord + ivec2(1, 0));
+ isBorder = isBorder || (nOwner != owner);
+ if (nOwner != owner && nOwner != 0u) {
+ uint rel = relationCode(owner, nOwner);
+ hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
+ hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
+ }
+
+ nOwner = ownerAtTex(texCoord + ivec2(-1, 0));
+ isBorder = isBorder || (nOwner != owner);
+ if (nOwner != owner && nOwner != 0u) {
+ uint rel = relationCode(owner, nOwner);
+ hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
+ hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
+ }
+
+ nOwner = ownerAtTex(texCoord + ivec2(0, 1));
+ isBorder = isBorder || (nOwner != owner);
+ if (nOwner != owner && nOwner != 0u) {
+ uint rel = relationCode(owner, nOwner);
+ hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
+ hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
+ }
+
+ nOwner = ownerAtTex(texCoord + ivec2(0, -1));
+ isBorder = isBorder || (nOwner != owner);
+ if (nOwner != owner && nOwner != 0u) {
+ uint rel = relationCode(owner, nOwner);
+ hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
+ hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
+ }
+ }
+
+ // Get terrain for background rendering (needed for both normal and alt view)
+ uint terrain = terrainAtTex(texCoord);
+ vec3 baseTerrainColor = terrainColor(terrain);
+
+ if (u_alternativeView) {
+ // Alt view: terrain + borders only, no territory fill
+ vec3 color = baseTerrainColor;
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) {
+ // Only draw borders, not territory fill
+ uint relationAlt = relationCode(owner, uint(u_viewerId));
+ vec4 altColor = u_altNeutral;
+ if (isSelf(relationAlt)) {
+ altColor = u_altSelf;
+ } else if (isFriendly(relationAlt)) {
+ altColor = u_altAlly;
+ } else if (isEmbargo(relationAlt)) {
+ altColor = u_altEnemy;
+ }
+ color = altColor.rgb;
+ }
+ if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) {
+ float pulse = u_hoverPulseStrength > 0.0
+ ? (1.0 - u_hoverPulseStrength) +
+ u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed))
+ : 1.0;
+ color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse);
+ }
+ outColor = vec4(color, 1.0);
+ return;
+ }
+
+ // Normal view: blend territory on top of terrain
+ vec3 fillColor = baseTerrainColor;
+ vec3 borderColor = vec3(0.0);
+ float borderAlpha = 0.0;
+ vec3 ownerBase = vec3(0.0);
+ vec4 ownerBorder = vec4(0.0);
+
+ if (owner == 0u) {
+ // Unowned tile - show terrain (or fallout if irradiated)
+ if (hasFallout) {
+ // Blend fallout on top of terrain
+ fillColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+ // Otherwise fillColor is already baseTerrainColor
+ } else {
+ vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0);
+ vec4 baseBorder = texelFetch(
+ u_palette,
+ ivec2(int(owner) * 2 + 1, 0),
+ 0
+ );
+ ownerBase = base.rgb;
+ ownerBorder = baseBorder;
+ bool isPrimary = patternIsPrimary(owner, texCoord);
+ vec3 patternColor = isPrimary ? base.rgb : baseBorder.rgb;
+ // Blend territory fill on top of terrain
+ fillColor = mix(baseTerrainColor, patternColor, u_alpha);
+
+ if (isBorder && !smoothActive) {
+ vec3 bColor = applyBorderTint(
+ baseBorder.rgb,
+ hasFriendlyRelation,
+ hasEmbargoRelation
+ );
+ borderColor = applyDefended(bColor, isDefended, texCoord);
+ borderAlpha = baseBorder.a;
+ }
+ }
+
+ vec3 color = fillColor;
+ bool useContestedFill = false;
+ if (contested && latestOwner != 0u) {
+ useContestedFill = true;
+ vec3 latestOwnerBase = texelFetch(
+ u_palette,
+ ivec2(int(latestOwner) * 2, 0),
+ 0
+ ).rgb;
+ vec3 defenderBase = latestOwnerBase;
+ if (defender != 0u) {
+ vec4 defenderColor = texelFetch(
+ u_palette,
+ ivec2(int(defender) * 2, 0),
+ 0
+ );
+ defenderBase = defenderColor.rgb;
+ }
+ float strength = contestStrength(contestId);
+ bool pickAttacker = contestPickAttacker(texCoord, strength);
+ vec3 contestColor = pickAttacker ? latestOwnerBase : defenderBase;
+ // Blend contested fill on top of terrain
+ color = mix(baseTerrainColor, contestColor, u_alpha);
+ }
+
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && !smoothActive && isBorder && owner != 0u) {
+ // Blend border on top of the current fill
+ color = mix(color, borderColor, borderAlpha);
+ }
+
+ if (smoothActive) {
+ // DEBUG: uncomment ONE line to visualize issues
+ // color = vec3(1.0, 0.0, 1.0); outColor = vec4(color, 1.0); return; // magenta = smoothActive tiles
+ // vec2 ds = jfaSeedOldAtTex(texCoord); color = vec3(ds.x >= 0.0 ? 0.0 : 1.0, jfaSeedNewAtTex(texCoord).x >= 0.0 ? 0.0 : 1.0, 0.0); outColor = vec4(color, 1.0); return; // seed validity
+
+ // Compute old color blended on terrain
+ vec3 oldColor = baseTerrainColor;
+ if (oldOwner == 0u) {
+ if (oldHasFallout) {
+ oldColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+ // Otherwise oldColor is already baseTerrainColor
+ } else {
+ vec4 oldBase = texelFetch(u_palette, ivec2(int(oldOwner) * 2, 0), 0);
+ vec4 oldBorder = texelFetch(
+ u_palette,
+ ivec2(int(oldOwner) * 2 + 1, 0),
+ 0
+ );
+ bool oldPrimary = patternIsPrimary(oldOwner, texCoord);
+ vec3 oldPatternColor = oldPrimary ? oldBase.rgb : oldBorder.rgb;
+ oldColor = mix(baseTerrainColor, oldPatternColor, u_alpha);
+ }
+
+ // JFA-based animation with tile-sized pixelated look
+ // Movement is pixel-smooth but edges remain hard/blocky like stable borders
+ // Use best-of-NxN seed sampling when enabled to reduce tile-boundary discontinuities.
+ // Use seeds picked at the TILE CENTER to avoid seed flipping inside a tile
+ // (which can cause direction/timing glitches). Distances still use mapCoord
+ // for smooth within-tile variation.
+ vec2 tileCenter = floor(mapCoord) + 0.5;
+ vec2 seedOld = u_seedSamplingMode == 0
+ ? jfaSeedOldAtTex(texCoord)
+ : bestSeedOld(tileCenter);
+ vec2 seedNew = u_seedSamplingMode == 0
+ ? jfaSeedNewAtTex(texCoord)
+ : bestSeedNew(tileCenter);
+
+ bool hasOldSeed = seedOld.x >= 0.0;
+ bool hasNewSeed = seedNew.x >= 0.0;
+
+ // CORRECT MODEL (no blending, no "future"):
+ // - We are interpolating between a *pair* of snapshots (from/to), selected by "renderPair" on CPU.
+ // - u_prevOwner is the FROM snapshot (texture unit 7).
+ // - u_state is the TO snapshot (texture unit 0).
+ // - u_jfaSeedsOld/u_jfaSeedsNew + u_changeMask also match that pair.
+ //
+ // We render:
+ // 1) Old snapshot at the true map coords (static).
+ // 2) New snapshot slid in from the old border position toward the new border position.
+ // No blending: the slid-in new snapshot overwrites old ONLY where changeMask indicates change.
+
+ float t = clamp(u_smoothProgress, 0.0, 1.0);
+
+ // --- Old layer (FROM snapshot), at texCoord ---
+ uint fromState = oldState;
+ uint fromOwner = oldOwner;
+
+ // Fill for FROM owner
+ vec3 fromColor = baseTerrainColor;
+ if (fromOwner != 0u) {
+ vec4 fromBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2, 0), 0);
+ vec4 fromBorderBase = texelFetch(
+ u_palette,
+ ivec2(int(fromOwner) * 2 + 1, 0),
+ 0
+ );
+ bool fromPrimary = patternIsPrimary(fromOwner, texCoord);
+ vec3 fromPatternColor = fromPrimary ? fromBase.rgb : fromBorderBase.rgb;
+ fromColor = mix(baseTerrainColor, fromPatternColor, u_alpha);
+ } else if (oldHasFallout) {
+ // preserve fallout tint when unowned
+ fromColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+
+ // Border for FROM owner (tile-width, stable look)
+ bool fromIsBorder = false;
+ uint fromOther = 0u;
+ uint nFrom;
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(1, 0), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(-1, 0), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, 1), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+ nFrom = texelFetch(u_prevOwner, texCoord + ivec2(0, -1), 0).r & 0xFFFu;
+ if (nFrom != fromOwner) { fromIsBorder = true; if (nFrom != 0u) fromOther = nFrom; }
+
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fromIsBorder && fromOwner != 0u) {
+ vec4 borderBase = texelFetch(u_palette, ivec2(int(fromOwner) * 2 + 1, 0), 0);
+ bool fromFriendly = false;
+ bool fromEmbargo = false;
+ if (fromOther != 0u) {
+ uint rel = relationCode(fromOwner, fromOther);
+ fromFriendly = isFriendly(rel);
+ fromEmbargo = isEmbargo(rel);
+ }
+ vec3 bColor = applyBorderTint(
+ borderBase.rgb,
+ fromFriendly,
+ fromEmbargo
+ );
+ bColor = applyDefended(bColor, oldIsDefended, texCoord);
+ fromColor = bColor;
+ }
+
+ // Start with FROM layer
+ color = fromColor;
+
+ // Draw a *constant-width* moving border stripe between the FROM and TO snapshots.
+ // Use a planar front (not radial) that moves coherently across tiles based on
+ // the displacement direction from old->new seeds.
+ if (affectedMask != 0u && hasOldSeed && hasNewSeed) {
+ vec2 disp = seedNew - seedOld;
+ vec2 absDisp = abs(disp);
+ vec2 dispSign = vec2(disp.x >= 0.0 ? 1.0 : -1.0, disp.y >= 0.0 ? 1.0 : -1.0);
+ float dispLen = length(disp);
+ if (dispLen > 1e-4) {
+ vec2 dir = vec2(1.0, 0.0);
+ vec2 frontOrigin = seedOld;
+ float frontPos = 0.0;
+ vec2 shift = vec2(0.0);
+
+ if (u_motionMode == 1) {
+ bool xDom = absDisp.x >= absDisp.y;
+ dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y);
+ float len = xDom ? absDisp.x : absDisp.y;
+ frontOrigin = seedOld;
+ frontPos = t * len;
+ shift = dir * (len * (1.0 - t));
+ } else if (u_motionMode == 2) {
+ bool xDom = absDisp.x >= absDisp.y;
+ vec2 axisX = vec2(dispSign.x, 0.0);
+ vec2 axisY = vec2(0.0, dispSign.y);
+ vec2 axis1 = xDom ? axisX : axisY;
+ vec2 axis2 = xDom ? axisY : axisX;
+ float len1 = xDom ? absDisp.x : absDisp.y;
+ float len2 = xDom ? absDisp.y : absDisp.x;
+ float total = len1 + len2;
+ float split = total > 1e-4 ? len1 / total : 0.5;
+ if (t <= split) {
+ float t1 = split > 1e-4 ? t / split : 1.0;
+ dir = axis1;
+ frontOrigin = seedOld;
+ frontPos = t1 * len1;
+ shift = axis1 * (len1 * (1.0 - t1)) + axis2 * len2;
+ } else {
+ float t2 = (t - split) / max(1.0 - split, 1e-4);
+ dir = axis2;
+ frontOrigin = seedOld + axis1 * len1;
+ frontPos = t2 * len2;
+ shift = axis2 * (len2 * (1.0 - t2));
+ }
+ } else if (u_motionMode == 3) {
+ float maxAbs = max(absDisp.x, absDisp.y);
+ float p = t * maxAbs;
+ vec2 remaining = max(absDisp - vec2(p), vec2(0.0));
+ shift = dispSign * remaining;
+ bool xDom = absDisp.x >= absDisp.y;
+ dir = xDom ? vec2(dispSign.x, 0.0) : vec2(0.0, dispSign.y);
+ frontOrigin = seedOld;
+ frontPos = t * maxAbs;
+ } else {
+ dir = disp / dispLen;
+ frontOrigin = seedOld;
+ frontPos = t * dispLen;
+ shift = disp * (1.0 - t);
+ }
+
+ // Project mapCoord onto the displacement direction, measured from frontOrigin.
+ // This gives us a global coordinate along the motion axis.
+ // At t=0, front should be near frontOrigin (s ~ 0).
+ // At t=1, front should be near frontOrigin + dir * frontPos.
+ float s = dot(mapCoord - frontOrigin, dir);
+
+ // Signed distance from the moving front plane.
+ // Positive means the front has passed this point (new territory side).
+ float frontDist = frontPos - s;
+
+ // Compute the sliding position: sample owners at the position where the front currently is.
+ // This ensures owner checks happen at the sliding position, not static.
+ vec2 slideOffsetFront = (frontPos - s) * dir; // Offset from current position to front position
+ vec2 slideCoordFront = mapCoord + slideOffsetFront;
+ ivec2 slideTexFront = clamp(ivec2(slideCoordFront), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+
+ // Sample owners at the sliding position
+ uint slideState = texelFetch(u_state, slideTexFront, 0).r;
+ uint slideOwner = slideState & 0xFFFu;
+ bool slideHasFallout = (slideState & 0x2000u) != 0u;
+ bool slideIsDefended = (slideState & 0x1000u) != 0u;
+
+ // Check if we're on a border at the sliding position (this is where the border currently is)
+ bool slideIsBorder = false;
+ bool slideHasFriendly = false;
+ bool slideHasEmbargo = false;
+ uint slideOther = 0u;
+ uint nSlide;
+ ivec2 nSlideTex;
+ nSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+ nSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nSlide = texelFetch(u_state, nSlideTex, 0).r & 0xFFFu;
+ if (nSlide != slideOwner) { slideIsBorder = true; if (nSlide != 0u) { slideOther = nSlide; uint rel = relationCode(slideOwner, nSlide); slideHasFriendly = slideHasFriendly || isFriendly(rel); slideHasEmbargo = slideHasEmbargo || isEmbargo(rel); } }
+
+ // Check if we're on a border in the FROM state (retreating side)
+ uint fromSlideState = prevStateAtTex(slideTexFront);
+ uint fromSlideOwner = fromSlideState & 0xFFFu;
+ bool fromSlideDefended = (fromSlideState & 0x1000u) != 0u;
+ bool fromIsBorderAtSlide = false;
+ uint fromOtherAtSlide = 0u;
+ uint nFromSlide;
+ ivec2 nFromSlideTex;
+ nFromSlideTex = clamp(slideTexFront + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+ nFromSlideTex = clamp(slideTexFront + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFromSlide = texelFetch(u_prevOwner, nFromSlideTex, 0).r & 0xFFFu;
+ if (nFromSlide != fromSlideOwner) { fromIsBorderAtSlide = true; if (nFromSlide != 0u) fromOtherAtSlide = nFromSlide; }
+
+ // Draw border stripe: check both expanding (TO) and retreating (FROM) sides
+ float stripeWidth = u_debugDisableAllBorders ? 0.0 : 0.5;
+ bool isStripe = abs(frontDist) <= stripeWidth;
+ bool drawExpandingBorder =
+ isStripe && slideIsBorder && slideOwner != 0u && frontDist > 0.0;
+ bool drawRetreatingBorder =
+ isStripe && fromIsBorderAtSlide && fromSlideOwner != 0u && frontDist <= 0.0;
+
+ if (!u_debugDisableAllBorders && (drawExpandingBorder || drawRetreatingBorder)) {
+ uint stripeOwner = drawExpandingBorder ? slideOwner : fromSlideOwner;
+ uint stripeOther = drawExpandingBorder ? slideOther : fromOtherAtSlide;
+
+ if (u_debugStripeFixedColors) {
+ // Debug mode: Use fixed colors
+ if (drawExpandingBorder) {
+ // Expanding: bright red
+ color = vec3(1.0, float(stripeOwner) / 255.0, 0.0);
+ } else {
+ // Retreating: bright blue
+ color = vec3(0.0, float(stripeOwner) / 255.0, 1.0);
+ }
+ } else {
+ // Normal mode: Use actual border colors
+ if (stripeOwner != 0u) {
+ vec4 borderBase = texelFetch(
+ u_palette,
+ ivec2(int(stripeOwner) * 2 + 1, 0),
+ 0
+ );
+ bool stripeFriendly = false;
+ bool stripeEmbargo = false;
+ if (stripeOther != 0u) {
+ uint rel = relationCode(stripeOwner, stripeOther);
+ stripeFriendly = isFriendly(rel);
+ stripeEmbargo = isEmbargo(rel);
+ }
+ bool stripeDefended = drawExpandingBorder
+ ? slideIsDefended
+ : fromSlideDefended;
+ vec3 bColor = applyBorderTint(
+ borderBase.rgb,
+ stripeFriendly,
+ stripeEmbargo
+ );
+ bColor = applyDefended(bColor, stripeDefended, slideTexFront);
+ color = bColor;
+ }
+ }
+ } else if (frontDist > stripeWidth) {
+ // Front has passed; show the new fill/border at the shifted position
+ vec2 slideCoordFill = mapCoord - shift;
+ ivec2 slideTexFill = clamp(ivec2(slideCoordFill), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+
+ uint fillState = texelFetch(u_state, slideTexFill, 0).r;
+ uint fillOwner = fillState & 0xFFFu;
+ bool fillHasFallout = (fillState & 0x2000u) != 0u;
+ bool fillIsDefended = (fillState & 0x1000u) != 0u;
+
+ bool fillIsBorder = false;
+ bool fillHasFriendly = false;
+ bool fillHasEmbargo = false;
+ uint fillOther = 0u;
+ uint nFill;
+ ivec2 nFillTex;
+ nFillTex = clamp(slideTexFill + ivec2(1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(-1, 0), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(0, 1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+ nFillTex = clamp(slideTexFill + ivec2(0, -1), ivec2(0), ivec2(int(u_mapResolution.x) - 1, int(u_mapResolution.y) - 1));
+ nFill = texelFetch(u_state, nFillTex, 0).r & 0xFFFu;
+ if (nFill != fillOwner) { fillIsBorder = true; if (nFill != 0u) { fillOther = nFill; uint rel = relationCode(fillOwner, nFill); fillHasFriendly = fillHasFriendly || isFriendly(rel); fillHasEmbargo = fillHasEmbargo || isEmbargo(rel); } }
+
+ vec3 toColor = baseTerrainColor;
+ if (fillOwner != 0u) {
+ vec4 toBase = texelFetch(u_palette, ivec2(int(fillOwner) * 2, 0), 0);
+ vec4 toBorderBase = texelFetch(
+ u_palette,
+ ivec2(int(fillOwner) * 2 + 1, 0),
+ 0
+ );
+ bool toPrimary = patternIsPrimary(fillOwner, slideTexFill);
+ vec3 toPatternColor = toPrimary ? toBase.rgb : toBorderBase.rgb;
+ toColor = mix(baseTerrainColor, toPatternColor, u_alpha);
+ if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && fillIsBorder) {
+ vec3 bColor = applyBorderTint(
+ toBorderBase.rgb,
+ fillHasFriendly,
+ fillHasEmbargo
+ );
+ bColor = applyDefended(bColor, fillIsDefended, slideTexFill);
+ toColor = bColor;
+ }
+ } else if (fillHasFallout) {
+ toColor = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
+
+ color = toColor;
+ }
+ // If frontDist < -stripeWidth, we're ahead of the front, so keep fromColor (already set).
+ }
+ }
+
+ }
+
+ bool pendingOwnerChange = latestOwner != owner;
+ if (pendingOwnerChange && !useContestedFill && !u_alternativeView) {
+ vec3 hintColor = baseTerrainColor;
+ if (latestOwner != 0u) {
+ vec3 latestColor = texelFetch(
+ u_palette,
+ ivec2(int(latestOwner) * 2, 0),
+ 0
+ ).rgb;
+ hintColor = mix(baseTerrainColor, latestColor, u_alpha * 0.12);
+ }
+ color = mix(color, hintColor, 0.5);
+ }
+
+ if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) {
+ float pulse = u_hoverPulseStrength > 0.0
+ ? (1.0 - u_hoverPulseStrength) +
+ u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed))
+ : 1.0;
+ color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse);
+ }
+
+ // Output fully opaque since we render terrain as background
+ outColor = vec4(color, 1.0);
+ }
+ `;
+
+ const vertexShader = this.compileShader(
+ gl,
+ gl.VERTEX_SHADER,
+ vertexShaderSource,
+ );
+ const fragmentShader = this.compileShader(
+ gl,
+ gl.FRAGMENT_SHADER,
+ fragmentShaderSource,
+ );
+ if (!vertexShader || !fragmentShader) {
+ return null;
+ }
+
+ const program = gl.createProgram();
+ if (!program) return null;
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error(
+ "[TerritoryWebGLRenderer] link error",
+ gl.getProgramInfoLog(program),
+ );
+ gl.deleteProgram(program);
+ return null;
+ }
+ return program;
+ }
+
+ private compileShader(
+ gl: WebGL2RenderingContext,
+ type: number,
+ source: string,
+ ): WebGLShader | null {
+ const shader = gl.createShader(type);
+ if (!shader) return null;
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.error(
+ "[TerritoryWebGLRenderer] shader error",
+ gl.getShaderInfoLog(shader),
+ );
+ gl.deleteShader(shader);
+ return null;
+ }
+ return shader;
+ }
+}
diff --git a/src/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts
new file mode 100644
index 000000000..e683b2d61
--- /dev/null
+++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts
@@ -0,0 +1,1669 @@
+import { Colord } from "colord";
+import { Theme } from "../../../core/configuration/Config";
+import { EventBus } from "../../../core/EventBus";
+import { ColoredTeams, PlayerType, Team } from "../../../core/game/Game";
+import { euclDistFN, TileRef } from "../../../core/game/GameMap";
+import { GameUpdateType } from "../../../core/game/GameUpdates";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ AlternateViewEvent,
+ ContextMenuEvent,
+ MouseOverEvent,
+} from "../../InputHandler";
+import { FrameProfiler } from "../FrameProfiler";
+import { getHoverInfo } from "../HoverInfo";
+import { TransformHandler } from "../TransformHandler";
+import { TerritoryBackend } from "./TerritoryBackend";
+import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer";
+
+const CONTEST_ID_MASK = 0x7fff;
+const CONTEST_ATTACKER_EVER_BIT = 0x8000;
+const CONTEST_TIME_WRAP = 32768;
+const DEFAULT_CONTEST_DURATION_TICKS = 2;
+const ENABLE_CONTEST_TRACKING = false;
+const CONTEST_STRENGTH_EMA_ALPHA = 0.8;
+const CONTEST_STRENGTH_MIN = 0.01;
+const CONTEST_STRENGTH_MAX = 0.95;
+const DEBUG_TERRITORY_OVERLAY = false;
+
+type ContestComponent = {
+ id: number;
+ attacker: number;
+ defender: number;
+ lastActivityPacked: number;
+ tiles: TileRef[];
+ strength: number;
+};
+
+export class WebGLTerritoryBackend implements TerritoryBackend {
+ readonly id = "webgl";
+
+ profileName(): string {
+ return "WebGLTerritoryBackend:renderLayer";
+ }
+
+ private userSettings = new UserSettings();
+ private borderAnimTime = 0;
+
+ private cachedTerritoryPatternsEnabled: boolean | undefined;
+
+ private theme: Theme;
+
+ // Used for spawn highlighting
+ private highlightCanvas: HTMLCanvasElement;
+ private highlightContext: CanvasRenderingContext2D;
+
+ private highlightedTerritory: PlayerView | null = null;
+ private territoryRenderer: TerritoryWebGLRenderer | null = null;
+
+ private alternativeView = false;
+ private lastMousePosition: { x: number; y: number } | null = null;
+
+ private lastFocusedPlayer: PlayerView | null = null;
+ private lastMyPlayerSmallId: number | null = null;
+ private lastPaletteSignature: string | null = null;
+ private contestDurationTicks = DEFAULT_CONTEST_DURATION_TICKS;
+ private contestActive = false;
+ private contestNextId = 1;
+ private contestFreeIds: number[] = [];
+ private contestComponentIds: Uint16Array | null = null;
+ private contestPrevOwners: Uint16Array | null = null;
+ private contestAttackers: Uint16Array | null = null;
+ private contestTileIndices: Int32Array | null = null;
+ private contestComponents = new Map();
+ private contestTileCount = 0;
+ private contestEnabled = ENABLE_CONTEST_TRACKING;
+ private tickSnapshotPending = false;
+ private tickTimeMsCurrent = 0;
+ private tickTimeMsPrev = 0;
+ private tickTimeMsOlder = 0;
+ private tickNumberCurrent: number | null = null;
+ private tickNumberPrev: number | null = null;
+ private tickNumberOlder: number | null = null;
+ private interpolationDelayMs = 100;
+ private lastInterpolationPair: "prevCurrent" | "olderPrev" = "prevCurrent";
+
+ // Runtime debug controls (UI)
+ private tripleBufferEnabled = true;
+ private interpolationDelayMode: "ema" | "fixed50" | "fixed100" | "fixed200" =
+ "ema";
+ private tickIntervalEmaMs = 0;
+ private readonly TICK_INTERVAL_EMA_ALPHA = 0.2;
+ private smoothingDebugUi: HTMLDivElement | null = null;
+ private contestedPatternMode: "blueNoise" | "checkerboard" | "bayer4x4" =
+ "blueNoise";
+ private debugDisableStaticBorders = false;
+ private debugDisableAllBorders = false;
+ private motionMode: "euclidean" | "axisSnap" | "manhattan" | "chebyshev" =
+ "euclidean";
+ private seedSamplingMode: "none" | "2x2" | "3x3" = "2x2";
+ private debugStripeFixedColors = false;
+ private failureReason: string | null = null;
+ private readonly contextLostHandler = (event: Event) => {
+ event.preventDefault();
+ this.failureReason = "WebGL context lost.";
+ };
+
+ constructor(
+ private game: GameView,
+ private eventBus: EventBus,
+ private transformHandler: TransformHandler,
+ ) {
+ this.theme = game.config().theme();
+ this.cachedTerritoryPatternsEnabled = undefined;
+ this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null;
+ }
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ tick() {
+ const tickProfile = FrameProfiler.start();
+ const now = this.nowMs();
+ const currentTheme = this.game.config().theme();
+ if (currentTheme !== this.theme) {
+ this.theme = currentTheme;
+ this.redraw();
+ }
+ if (this.game.inSpawnPhase()) {
+ this.spawnHighlight();
+ }
+
+ const patternsEnabled = this.userSettings.territoryPatterns();
+ if (this.cachedTerritoryPatternsEnabled !== patternsEnabled) {
+ this.cachedTerritoryPatternsEnabled = patternsEnabled;
+ this.redraw();
+ }
+ this.refreshPaletteIfNeeded();
+
+ const tickNumber = this.game.ticks();
+ if (this.tickNumberCurrent !== tickNumber) {
+ this.tickNumberOlder = this.tickNumberPrev;
+ this.tickNumberPrev = this.tickNumberCurrent;
+ this.tickNumberCurrent = tickNumber;
+
+ this.tickTimeMsOlder = this.tickTimeMsPrev;
+ this.tickTimeMsPrev = this.tickTimeMsCurrent;
+ this.tickTimeMsCurrent = now;
+
+ const lastInterval = this.tickTimeMsCurrent - this.tickTimeMsPrev;
+ if (lastInterval > 0) {
+ // Track tick interval EMA for stable delay at variable speeds.
+ this.tickIntervalEmaMs =
+ this.tickIntervalEmaMs <= 0
+ ? lastInterval
+ : this.tickIntervalEmaMs * (1 - this.TICK_INTERVAL_EMA_ALPHA) +
+ lastInterval * this.TICK_INTERVAL_EMA_ALPHA;
+
+ // Choose delay mode.
+ if (this.interpolationDelayMode === "fixed50") {
+ this.interpolationDelayMs = 50;
+ } else if (this.interpolationDelayMode === "fixed100") {
+ this.interpolationDelayMs = 100;
+ } else if (this.interpolationDelayMode === "fixed200") {
+ this.interpolationDelayMs = 200;
+ } else {
+ // "ema": render roughly one tick behind using the raw EMA interval.
+ // Do not clamp in EMA mode (debug requested).
+ this.interpolationDelayMs = this.tickIntervalEmaMs;
+ }
+ }
+
+ if (this.territoryRenderer) {
+ this.tickSnapshotPending = true;
+ }
+ }
+
+ this.game.recentlyUpdatedTiles().forEach((t) => this.markTile(t));
+ if (this.contestEnabled) {
+ const ownerUpdates = this.game.recentlyUpdatedOwnerTiles();
+ const nowTickPacked = this.packContestTick(this.game.ticks());
+ this.applyContestChanges(ownerUpdates, nowTickPacked);
+ this.updateContestState(nowTickPacked);
+ this.updateContestStrengths();
+ let tileCount = 0;
+ for (const component of this.contestComponents.values()) {
+ tileCount += component.tiles.length;
+ }
+ this.contestTileCount = tileCount;
+ } else {
+ this.contestTileCount = 0;
+ this.contestActive = false;
+ }
+ const updates = this.game.updatesSinceLastTick();
+
+ // 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.territoryRenderer?.refreshPalette();
+ }
+ });
+
+ 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.territoryRenderer?.refreshPalette();
+ }
+ }
+ });
+ 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.territoryRenderer?.refreshPalette();
+ }
+ });
+ }
+
+ const focusedPlayer = this.game.focusedPlayer();
+ if (focusedPlayer !== this.lastFocusedPlayer) {
+ this.redraw();
+ this.lastFocusedPlayer = focusedPlayer;
+ }
+
+ const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null;
+ if (currentMyPlayer !== this.lastMyPlayerSmallId) {
+ this.redraw();
+ }
+ FrameProfiler.end("TerritoryLayer:tick", tickProfile);
+ }
+
+ 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
+ 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;
+ const radius =
+ minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
+
+ const baseColor = this.theme.spawnHighlightSelfColor();
+ 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,
+ teamColor,
+ );
+
+ 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));
+
+ 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(ContextMenuEvent, (e) => this.onMouseOver(e));
+ this.eventBus.on(AlternateViewEvent, (e) => {
+ this.alternativeView = e.alternateView;
+ this.territoryRenderer?.setAlternativeView(this.alternativeView);
+ this.territoryRenderer?.markAllDirty();
+ this.territoryRenderer?.setHoverHighlightOptions(
+ this.hoverHighlightOptions(),
+ );
+ });
+ this.redraw();
+ this.ensureSmoothingDebugUi();
+ }
+
+ getFailureReason(): string | null {
+ return this.failureReason;
+ }
+
+ dispose() {
+ this.smoothingDebugUi?.remove();
+ this.smoothingDebugUi = null;
+ this.territoryRenderer?.canvas.removeEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer?.dispose();
+ this.territoryRenderer = null;
+ }
+
+ private ensureSmoothingDebugUi() {
+ if (!DEBUG_TERRITORY_OVERLAY) return;
+ if (this.smoothingDebugUi) return;
+
+ const root = document.createElement("div");
+ root.style.position = "fixed";
+ root.style.right = "10px";
+ root.style.top = "10px";
+ root.style.zIndex = "9999";
+ root.style.background = "rgba(0, 0, 0, 0.6)";
+ root.style.color = "rgba(255, 255, 255, 0.92)";
+ root.style.padding = "8px 10px";
+ root.style.borderRadius = "8px";
+ root.style.font = "12px monospace";
+ root.style.userSelect = "none";
+ root.style.touchAction = "none";
+
+ const title = document.createElement("div");
+ title.textContent = "Territory smoothing";
+ title.style.fontWeight = "700";
+ title.style.marginBottom = "6px";
+ title.style.cursor = "move";
+ root.appendChild(title);
+
+ // Restore last position (if any)
+ const POS_KEY = "debug.territorySmoothingPanelPos.v1";
+ try {
+ const raw = localStorage.getItem(POS_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as { left: number; top: number };
+ if (
+ typeof parsed?.left === "number" &&
+ typeof parsed?.top === "number" &&
+ Number.isFinite(parsed.left) &&
+ Number.isFinite(parsed.top)
+ ) {
+ root.style.left = `${parsed.left}px`;
+ root.style.top = `${parsed.top}px`;
+ root.style.right = "auto";
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // Make draggable via title bar
+ let dragging = false;
+ let dragDx = 0;
+ let dragDy = 0;
+ const clampPos = (left: number, top: number) => {
+ const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth);
+ const maxTop = Math.max(0, window.innerHeight - root.offsetHeight);
+ return {
+ left: Math.max(0, Math.min(maxLeft, left)),
+ top: Math.max(0, Math.min(maxTop, top)),
+ };
+ };
+
+ title.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragging = true;
+ title.setPointerCapture(e.pointerId);
+ const rect = root.getBoundingClientRect();
+ dragDx = e.clientX - rect.left;
+ dragDy = e.clientY - rect.top;
+ // Switch to explicit left/top positioning
+ root.style.left = `${rect.left}px`;
+ root.style.top = `${rect.top}px`;
+ root.style.right = "auto";
+ });
+
+ title.addEventListener("pointermove", (e) => {
+ if (!dragging) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const next = clampPos(e.clientX - dragDx, e.clientY - dragDy);
+ root.style.left = `${next.left}px`;
+ root.style.top = `${next.top}px`;
+ try {
+ localStorage.setItem(POS_KEY, JSON.stringify(next));
+ } catch {
+ // ignore
+ }
+ });
+
+ const endDrag = (e: PointerEvent) => {
+ if (!dragging) return;
+ e.preventDefault();
+ e.stopPropagation();
+ dragging = false;
+ try {
+ title.releasePointerCapture(e.pointerId);
+ } catch {
+ // ignore
+ }
+ };
+ title.addEventListener("pointerup", endDrag);
+ title.addEventListener("pointercancel", endDrag);
+
+ const tripleRow = document.createElement("label");
+ tripleRow.style.display = "flex";
+ tripleRow.style.alignItems = "center";
+ tripleRow.style.gap = "6px";
+ tripleRow.style.marginBottom = "6px";
+
+ const tripleCheckbox = document.createElement("input");
+ tripleCheckbox.type = "checkbox";
+ tripleCheckbox.checked = this.tripleBufferEnabled;
+ tripleCheckbox.addEventListener("change", () => {
+ this.tripleBufferEnabled = tripleCheckbox.checked;
+ });
+
+ const tripleText = document.createElement("span");
+ tripleText.textContent = "triple buffer (olderPrev)";
+ tripleRow.appendChild(tripleCheckbox);
+ tripleRow.appendChild(tripleText);
+ root.appendChild(tripleRow);
+
+ const modeRow = document.createElement("label");
+ modeRow.style.display = "flex";
+ modeRow.style.alignItems = "center";
+ modeRow.style.gap = "6px";
+ modeRow.style.marginBottom = "6px";
+
+ const modeText = document.createElement("span");
+ modeText.textContent = "delay mode:";
+
+ const modeSelect = document.createElement("select");
+ modeSelect.style.font = "12px monospace";
+ modeSelect.style.background = "rgba(0,0,0,0.35)";
+ modeSelect.style.color = "rgba(255,255,255,0.92)";
+ modeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ modeSelect.style.borderRadius = "4px";
+ modeSelect.style.padding = "2px 4px";
+
+ const modes: Array<"ema" | "fixed200" | "fixed100" | "fixed50"> = [
+ "ema",
+ "fixed200",
+ "fixed100",
+ "fixed50",
+ ];
+ for (const m of modes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ modeSelect.appendChild(opt);
+ }
+ modeSelect.value = this.interpolationDelayMode;
+ modeSelect.addEventListener("change", () => {
+ const v = modeSelect.value as typeof this.interpolationDelayMode;
+ this.interpolationDelayMode = v;
+ // Apply immediately using current EMA if available, otherwise fall back to existing delay.
+ if (v === "fixed50") this.interpolationDelayMs = 50;
+ else if (v === "fixed100") this.interpolationDelayMs = 100;
+ else if (v === "fixed200") this.interpolationDelayMs = 200;
+ else if (this.tickIntervalEmaMs > 0) {
+ // "ema": do not clamp (debug requested)
+ this.interpolationDelayMs = this.tickIntervalEmaMs;
+ }
+ });
+
+ modeRow.appendChild(modeText);
+ modeRow.appendChild(modeSelect);
+ root.appendChild(modeRow);
+
+ // Contested drawing controls
+ const contestedRow = document.createElement("label");
+ contestedRow.style.display = "flex";
+ contestedRow.style.alignItems = "center";
+ contestedRow.style.gap = "6px";
+ contestedRow.style.marginBottom = "6px";
+
+ const contestedCheckbox = document.createElement("input");
+ contestedCheckbox.type = "checkbox";
+ contestedCheckbox.checked = this.contestEnabled;
+ contestedCheckbox.addEventListener("change", () => {
+ const enabled = contestedCheckbox.checked;
+ this.contestEnabled = enabled;
+ this.contestTileCount = 0;
+ this.contestActive = false;
+ if (enabled) {
+ this.ensureContestScratch();
+ this.syncContestStateToRenderer();
+ } else {
+ this.contestComponents.clear();
+ }
+ this.territoryRenderer?.setContestEnabled(enabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const contestedText = document.createElement("span");
+ contestedText.textContent = "contested draw";
+ contestedRow.appendChild(contestedCheckbox);
+ contestedRow.appendChild(contestedText);
+ root.appendChild(contestedRow);
+
+ const contestedModeRow = document.createElement("label");
+ contestedModeRow.style.display = "flex";
+ contestedModeRow.style.alignItems = "center";
+ contestedModeRow.style.gap = "6px";
+ contestedModeRow.style.marginBottom = "0px";
+
+ const contestedModeText = document.createElement("span");
+ contestedModeText.textContent = "contested pattern:";
+
+ const contestedModeSelect = document.createElement("select");
+ contestedModeSelect.style.font = "12px monospace";
+ contestedModeSelect.style.background = "rgba(0,0,0,0.35)";
+ contestedModeSelect.style.color = "rgba(255,255,255,0.92)";
+ contestedModeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ contestedModeSelect.style.borderRadius = "4px";
+ contestedModeSelect.style.padding = "2px 4px";
+
+ const contestedModes: Array<"blueNoise" | "checkerboard" | "bayer4x4"> = [
+ "blueNoise",
+ "checkerboard",
+ "bayer4x4",
+ ];
+ for (const m of contestedModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ contestedModeSelect.appendChild(opt);
+ }
+ contestedModeSelect.value = this.contestedPatternMode;
+ contestedModeSelect.addEventListener("change", () => {
+ const v = contestedModeSelect.value as
+ | "blueNoise"
+ | "checkerboard"
+ | "bayer4x4";
+ this.contestedPatternMode = v;
+ this.territoryRenderer?.setContestPatternMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ contestedModeRow.appendChild(contestedModeText);
+ contestedModeRow.appendChild(contestedModeSelect);
+ root.appendChild(contestedModeRow);
+
+ // Debug: hide all borders
+ const allBordersRow = document.createElement("label");
+ allBordersRow.style.display = "flex";
+ allBordersRow.style.alignItems = "center";
+ allBordersRow.style.gap = "6px";
+ allBordersRow.style.marginTop = "6px";
+
+ const allBordersCheckbox = document.createElement("input");
+ allBordersCheckbox.type = "checkbox";
+ allBordersCheckbox.checked = this.debugDisableAllBorders;
+ allBordersCheckbox.addEventListener("change", () => {
+ const disabled = allBordersCheckbox.checked;
+ this.debugDisableAllBorders = disabled;
+ this.territoryRenderer?.setDebugDisableAllBorders(disabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const allBordersText = document.createElement("span");
+ allBordersText.textContent = "hide all borders";
+ allBordersRow.appendChild(allBordersCheckbox);
+ allBordersRow.appendChild(allBordersText);
+ root.appendChild(allBordersRow);
+
+ // Debug: hide non-smoothed (static) borders
+ const staticBordersRow = document.createElement("label");
+ staticBordersRow.style.display = "flex";
+ staticBordersRow.style.alignItems = "center";
+ staticBordersRow.style.gap = "6px";
+ staticBordersRow.style.marginTop = "6px";
+
+ const staticBordersCheckbox = document.createElement("input");
+ staticBordersCheckbox.type = "checkbox";
+ staticBordersCheckbox.checked = this.debugDisableStaticBorders;
+ staticBordersCheckbox.addEventListener("change", () => {
+ const disabled = staticBordersCheckbox.checked;
+ this.debugDisableStaticBorders = disabled;
+ this.territoryRenderer?.setDebugDisableStaticBorders(disabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const staticBordersText = document.createElement("span");
+ staticBordersText.textContent = "hide static borders";
+ staticBordersRow.appendChild(staticBordersCheckbox);
+ staticBordersRow.appendChild(staticBordersText);
+ root.appendChild(staticBordersRow);
+
+ // Seed sampling mode dropdown (none / 2x2 / 3x3)
+ const seedSamplingRow = document.createElement("label");
+ seedSamplingRow.style.display = "flex";
+ seedSamplingRow.style.alignItems = "center";
+ seedSamplingRow.style.gap = "6px";
+ seedSamplingRow.style.marginTop = "6px";
+
+ const seedSamplingText = document.createElement("span");
+ seedSamplingText.textContent = "seed sampling";
+
+ const seedSamplingSelect = document.createElement("select");
+ seedSamplingSelect.style.background = "rgba(0,0,0,0.5)";
+ seedSamplingSelect.style.color = "#fff";
+ seedSamplingSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ seedSamplingSelect.style.borderRadius = "4px";
+ seedSamplingSelect.style.padding = "2px 4px";
+
+ const seedModes: Array<"none" | "2x2" | "3x3"> = ["none", "2x2", "3x3"];
+ for (const m of seedModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ seedSamplingSelect.appendChild(opt);
+ }
+ seedSamplingSelect.value = this.seedSamplingMode;
+ seedSamplingSelect.addEventListener("change", () => {
+ const v = seedSamplingSelect.value as "none" | "2x2" | "3x3";
+ this.seedSamplingMode = v;
+ this.territoryRenderer?.setSeedSamplingMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ seedSamplingRow.appendChild(seedSamplingText);
+ seedSamplingRow.appendChild(seedSamplingSelect);
+ root.appendChild(seedSamplingRow);
+
+ // Motion mode dropdown
+ const motionModeRow = document.createElement("label");
+ motionModeRow.style.display = "flex";
+ motionModeRow.style.alignItems = "center";
+ motionModeRow.style.gap = "6px";
+ motionModeRow.style.marginTop = "6px";
+
+ const motionModeText = document.createElement("span");
+ motionModeText.textContent = "motion mode";
+
+ const motionModeSelect = document.createElement("select");
+ motionModeSelect.style.background = "rgba(0,0,0,0.5)";
+ motionModeSelect.style.color = "#fff";
+ motionModeSelect.style.border = "1px solid rgba(255,255,255,0.2)";
+ motionModeSelect.style.borderRadius = "4px";
+ motionModeSelect.style.padding = "2px 4px";
+
+ const motionModes: Array<
+ "euclidean" | "axisSnap" | "manhattan" | "chebyshev"
+ > = ["euclidean", "axisSnap", "manhattan", "chebyshev"];
+ for (const m of motionModes) {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ motionModeSelect.appendChild(opt);
+ }
+ motionModeSelect.value = this.motionMode;
+ motionModeSelect.addEventListener("change", () => {
+ const v = motionModeSelect.value as
+ | "euclidean"
+ | "axisSnap"
+ | "manhattan"
+ | "chebyshev";
+ this.motionMode = v;
+ this.territoryRenderer?.setMotionMode(v);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ motionModeRow.appendChild(motionModeText);
+ motionModeRow.appendChild(motionModeSelect);
+ root.appendChild(motionModeRow);
+
+ // Debug: fixed stripe colors
+ const stripeColorsRow = document.createElement("label");
+ stripeColorsRow.style.display = "flex";
+ stripeColorsRow.style.alignItems = "center";
+ stripeColorsRow.style.gap = "6px";
+ stripeColorsRow.style.marginTop = "6px";
+
+ const stripeColorsCheckbox = document.createElement("input");
+ stripeColorsCheckbox.type = "checkbox";
+ stripeColorsCheckbox.checked = this.debugStripeFixedColors;
+ stripeColorsCheckbox.addEventListener("change", () => {
+ const enabled = stripeColorsCheckbox.checked;
+ this.debugStripeFixedColors = enabled;
+ this.territoryRenderer?.setDebugStripeFixedColors(enabled);
+ this.territoryRenderer?.markAllDirty();
+ });
+
+ const stripeColorsText = document.createElement("span");
+ stripeColorsText.textContent =
+ "fixed stripe colors (red=expand, blue=retreat, green=owner)";
+ stripeColorsRow.appendChild(stripeColorsCheckbox);
+ stripeColorsRow.appendChild(stripeColorsText);
+ root.appendChild(stripeColorsRow);
+
+ document.body.appendChild(root);
+ this.smoothingDebugUi = root;
+ }
+
+ onMouseOver(event: MouseOverEvent) {
+ this.lastMousePosition = { x: event.x, y: event.y };
+ this.updateHighlightedTerritory();
+ }
+
+ private updateHighlightedTerritory() {
+ if (!this.lastMousePosition || !this.territoryRenderer) {
+ return;
+ }
+
+ const cell = this.transformHandler.screenToWorldCoordinates(
+ this.lastMousePosition.x,
+ this.lastMousePosition.y,
+ );
+ const previousTerritory = this.highlightedTerritory;
+ const info = getHoverInfo(this.game, cell);
+ let territory: PlayerView | null = null;
+ if (info.player) {
+ territory = info.player;
+ } else if (info.unit) {
+ territory = info.unit.owner();
+ }
+
+ if (territory) {
+ this.highlightedTerritory = territory;
+ } else {
+ this.highlightedTerritory = null;
+ }
+
+ if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
+ this.territoryRenderer.setHoveredPlayerId(
+ this.highlightedTerritory?.smallID() ?? null,
+ );
+ }
+ }
+
+ redraw() {
+ this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null;
+ this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns();
+ this.configureRenderers();
+ if (this.contestEnabled) {
+ this.ensureContestScratch();
+ this.syncContestStateToRenderer();
+ } else {
+ this.contestActive = false;
+ this.contestComponents.clear();
+ this.contestFreeIds = [];
+ this.contestNextId = 1;
+ }
+
+ // 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();
+ }
+
+ private configureRenderers() {
+ this.territoryRenderer?.canvas.removeEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer?.dispose();
+
+ const { renderer, reason } = TerritoryWebGLRenderer.create(
+ this.game,
+ this.theme,
+ );
+ if (!renderer) {
+ throw new Error(reason ?? "WebGL2 is required for territory rendering.");
+ }
+
+ this.territoryRenderer = renderer;
+ this.territoryRenderer.canvas.addEventListener(
+ "webglcontextlost",
+ this.contextLostHandler,
+ );
+ this.territoryRenderer.setContestEnabled(this.contestEnabled);
+ this.territoryRenderer.setContestPatternMode(this.contestedPatternMode);
+ this.territoryRenderer.setDebugDisableStaticBorders(
+ this.debugDisableStaticBorders,
+ );
+ this.territoryRenderer.setDebugDisableAllBorders(
+ this.debugDisableAllBorders,
+ );
+ this.territoryRenderer.setSeedSamplingMode(this.seedSamplingMode);
+ this.territoryRenderer.setMotionMode(this.motionMode);
+ this.territoryRenderer.setDebugStripeFixedColors(
+ this.debugStripeFixedColors,
+ );
+ this.territoryRenderer.setAlternativeView(this.alternativeView);
+ this.territoryRenderer.markAllDirty();
+ this.territoryRenderer.refreshPalette();
+ this.territoryRenderer.setHoverHighlightOptions(
+ this.hoverHighlightOptions(),
+ );
+ this.territoryRenderer.setHoveredPlayerId(
+ this.highlightedTerritory?.smallID() ?? null,
+ );
+ this.lastPaletteSignature = this.computePaletteSignature();
+ }
+
+ private hoverHighlightOptions() {
+ const baseColor = this.theme.playerHighlightColor();
+ const rgba = baseColor.rgba;
+
+ if (this.alternativeView) {
+ return {
+ color: { r: rgba.r, g: rgba.g, b: rgba.b },
+ strength: 0.8,
+ pulseStrength: 0.45,
+ pulseSpeed: Math.PI * 2,
+ };
+ }
+
+ return {
+ color: { r: rgba.r, g: rgba.g, b: rgba.b },
+ strength: 0.6,
+ pulseStrength: 0.35,
+ pulseSpeed: Math.PI * 2,
+ };
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const now = this.nowMs();
+ if (this.tickSnapshotPending) {
+ this.territoryRenderer.snapshotStateForSmoothing();
+ this.tickSnapshotPending = false;
+ }
+ this.updateInterpolationState(now);
+
+ 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);
+
+ const drawTerritoryStart = FrameProfiler.start();
+ // Draw the WebGL territory in screen space; overlays still use world space.
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.drawImage(
+ this.territoryRenderer.canvas,
+ 0,
+ 0,
+ context.canvas.width,
+ context.canvas.height,
+ );
+ context.restore();
+ FrameProfiler.end("TerritoryLayer:drawTerritoryCanvas", drawTerritoryStart);
+
+ 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,
+ );
+ }
+
+ if (DEBUG_TERRITORY_OVERLAY) {
+ const overlayStart = FrameProfiler.start();
+ this.drawDebugOverlay(context);
+ FrameProfiler.end("TerritoryLayer:debugOverlay", overlayStart);
+ }
+ }
+
+ private markTile(tile: TileRef) {
+ this.territoryRenderer?.markTile(tile);
+ }
+
+ paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
+ 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();
+ const transparent = transparentColor.alpha(0);
+ const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
+
+ radGrad.addColorStop(0, transparent.toRgbString());
+ radGrad.addColorStop(0.01, transparentColor.toRgbString());
+ radGrad.addColorStop(0.1, transparentColor.toRgbString());
+ radGrad.addColorStop(1, transparent.toRgbString());
+
+ ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad;
+ ctx.closePath();
+ ctx.fill();
+
+ const breatheInner = breathingColor.alpha(0);
+ ctx.beginPath();
+ const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
+ radGrad2.addColorStop(0, breatheInner.toRgbString());
+ radGrad2.addColorStop(0.01, breathingColor.toRgbString());
+ radGrad2.addColorStop(1, breathingColor.toRgbString());
+
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = radGrad2;
+ ctx.fill();
+ }
+
+ private nowMs(): number {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+ }
+
+ private ensureContestScratch() {
+ const size = this.game.width() * this.game.height();
+ if (!this.contestComponentIds || this.contestComponentIds.length !== size) {
+ this.contestComponentIds = new Uint16Array(size);
+ this.contestPrevOwners = new Uint16Array(size);
+ this.contestAttackers = new Uint16Array(size);
+ this.contestTileIndices = new Int32Array(size);
+ this.contestTileIndices.fill(-1);
+ this.contestComponents.clear();
+ this.contestFreeIds = [];
+ this.contestNextId = 1;
+ this.contestActive = false;
+ }
+ }
+
+ private updateInterpolationState(now: number) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+
+ if (this.tickTimeMsPrev <= 0 || this.tickTimeMsCurrent <= 0) {
+ this.lastInterpolationPair = "prevCurrent";
+ this.territoryRenderer.setInterpolationPair("prevCurrent");
+ this.territoryRenderer.setSmoothProgress(1);
+ this.territoryRenderer.setSmoothEnabled(false);
+ return;
+ }
+
+ const renderTime = now - this.interpolationDelayMs;
+
+ let pair: "prevCurrent" | "olderPrev" = "prevCurrent";
+ let fromTime = this.tickTimeMsPrev;
+ let toTime = this.tickTimeMsCurrent;
+
+ if (
+ this.tripleBufferEnabled &&
+ this.tickTimeMsOlder > 0 &&
+ renderTime < this.tickTimeMsPrev
+ ) {
+ pair = "olderPrev";
+ fromTime = this.tickTimeMsOlder;
+ toTime = this.tickTimeMsPrev;
+ }
+
+ // Use the real tick interval so interpolation duration scales with tick speed.
+ // The previous 250ms cap caused slow tick speeds (e.g. 0.5x) to finish animations early.
+ const denom = Math.max(1, toTime - fromTime);
+ const progress = Math.max(0, Math.min(1, (renderTime - fromTime) / denom));
+
+ this.lastInterpolationPair = pair;
+ this.territoryRenderer.setInterpolationPair(pair);
+ this.territoryRenderer.setSmoothProgress(progress);
+ this.territoryRenderer.setSmoothEnabled(true);
+ }
+
+ private applyContestChanges(
+ changes: Array<{ tile: TileRef; previousOwner: number; newOwner: number }>,
+ nowTickPacked: number,
+ ) {
+ if (!this.territoryRenderer || changes.length === 0) {
+ return;
+ }
+ this.ensureContestScratch();
+
+ for (const change of changes) {
+ if (change.newOwner === change.previousOwner) {
+ continue;
+ }
+ const tile = change.tile;
+ const currentId = this.contestId(tile);
+ if (currentId === 0) {
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ continue;
+ }
+
+ const component = this.contestComponents.get(currentId);
+ if (!component) {
+ this.clearContestTile(tile);
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ continue;
+ }
+
+ if (
+ change.newOwner === component.attacker ||
+ change.newOwner === component.defender
+ ) {
+ const attackerEver =
+ change.newOwner === component.attacker || this.hasAttackerEver(tile);
+ this.setContestTileData(
+ tile,
+ component.defender,
+ component.attacker,
+ component.id,
+ attackerEver,
+ );
+ component.lastActivityPacked = nowTickPacked;
+ this.territoryRenderer.setContestTime(component.id, nowTickPacked);
+ } else {
+ this.removeTileFromComponent(tile, component);
+ this.startContestForTile(
+ tile,
+ change.previousOwner,
+ change.newOwner,
+ nowTickPacked,
+ );
+ }
+ }
+ }
+
+ private updateContestStrengths() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ if (this.contestComponents.size === 0) {
+ return;
+ }
+
+ const involvedIds = new Set();
+ for (const component of this.contestComponents.values()) {
+ involvedIds.add(component.attacker);
+ involvedIds.add(component.defender);
+ }
+ const totalTroopsById = this.buildTotalTroopsLookup(involvedIds);
+ const attackTroopsById = this.buildAttackTroopsLookup(involvedIds);
+
+ const pairStrength = new Map();
+ for (const component of this.contestComponents.values()) {
+ const key = (component.attacker << 16) | component.defender;
+ let strength = pairStrength.get(key);
+ if (strength === undefined) {
+ strength = this.computeContestStrength(
+ component.attacker,
+ component.defender,
+ totalTroopsById,
+ attackTroopsById,
+ );
+ pairStrength.set(key, strength);
+ }
+ component.strength =
+ component.strength * (1 - CONTEST_STRENGTH_EMA_ALPHA) +
+ strength * CONTEST_STRENGTH_EMA_ALPHA;
+ component.strength = Math.max(
+ CONTEST_STRENGTH_MIN,
+ Math.min(CONTEST_STRENGTH_MAX, component.strength),
+ );
+ this.territoryRenderer.setContestStrength(
+ component.id,
+ component.strength,
+ );
+ }
+ }
+
+ private buildTotalTroopsLookup(
+ involvedIds: Set,
+ ): Map {
+ const totals = new Map();
+ for (const id of involvedIds) {
+ const player = this.game.playerBySmallID(id);
+ if (player instanceof PlayerView) {
+ totals.set(id, player.troops());
+ }
+ }
+ return totals;
+ }
+
+ private buildAttackTroopsLookup(
+ involvedIds: Set,
+ ): Map> {
+ const totals = new Map>();
+ for (const id of involvedIds) {
+ const player = this.game.playerBySmallID(id);
+ if (!(player instanceof PlayerView)) {
+ continue;
+ }
+ const outgoing = player.outgoingAttacks();
+ if (outgoing.length === 0) {
+ continue;
+ }
+ for (const attack of outgoing) {
+ if (!involvedIds.has(attack.targetID)) {
+ continue;
+ }
+ let byTarget = totals.get(id);
+ if (!byTarget) {
+ byTarget = new Map();
+ totals.set(id, byTarget);
+ }
+ byTarget.set(
+ attack.targetID,
+ (byTarget.get(attack.targetID) ?? 0) + attack.troops,
+ );
+ }
+ }
+ return totals;
+ }
+
+ private computeContestStrength(
+ attackerId: number,
+ defenderId: number,
+ totalTroopsById: Map,
+ attackTroopsById: Map>,
+ ) {
+ const attackerTroops = totalTroopsById.get(attackerId);
+ const defenderTroops = totalTroopsById.get(defenderId);
+ if (attackerTroops === undefined || defenderTroops === undefined) {
+ return 0.5;
+ }
+
+ const attackerAttackTroops =
+ attackTroopsById.get(attackerId)?.get(defenderId) ?? 0;
+ const defenderAttackTroops =
+ attackTroopsById.get(defenderId)?.get(attackerId) ?? 0;
+ const attackerPower = attackerTroops + attackerAttackTroops;
+ const defenderPower = defenderTroops + defenderAttackTroops;
+ const totalPower = attackerPower + defenderPower;
+ if (totalPower <= 0) {
+ return 0.5;
+ }
+ return Math.max(0, Math.min(1, attackerPower / totalPower));
+ }
+
+ private updateContestState(nowTickPacked: number) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ this.ensureContestScratch();
+ this.territoryRenderer.setContestNow(
+ nowTickPacked,
+ this.contestDurationTicks,
+ );
+
+ if (!this.contestActive) {
+ return;
+ }
+
+ const expired: ContestComponent[] = [];
+ for (const component of this.contestComponents.values()) {
+ const elapsed = this.contestElapsed(
+ nowTickPacked,
+ component.lastActivityPacked,
+ );
+ if (elapsed >= this.contestDurationTicks) {
+ expired.push(component);
+ }
+ }
+
+ for (const component of expired) {
+ this.expireContestComponent(component);
+ }
+ }
+
+ private startContestForTile(
+ tile: TileRef,
+ defender: number,
+ attacker: number,
+ nowTickPacked: number,
+ ): ContestComponent | null {
+ if (attacker === defender || attacker === 0 || defender === 0) {
+ return null;
+ }
+ const neighbors = this.collectNeighborComponents(tile, attacker, defender);
+ let component: ContestComponent;
+ if (neighbors.length === 0) {
+ component = this.createContestComponent(
+ attacker,
+ defender,
+ nowTickPacked,
+ );
+ } else {
+ component = neighbors[0];
+ for (let i = 1; i < neighbors.length; i++) {
+ this.mergeContestComponents(component, neighbors[i]);
+ }
+ }
+
+ this.addTileToComponent(tile, component, true);
+ component.lastActivityPacked = nowTickPacked;
+ this.territoryRenderer?.setContestTime(component.id, nowTickPacked);
+ return component;
+ }
+
+ private collectNeighborComponents(
+ tile: TileRef,
+ attacker: number,
+ defender: number,
+ ): ContestComponent[] {
+ const components: ContestComponent[] = [];
+ const seen = new Set();
+ for (const neighbor of this.game.neighbors(tile)) {
+ const id = this.contestId(neighbor);
+ if (id === 0 || seen.has(id)) {
+ continue;
+ }
+ const component = this.contestComponents.get(id);
+ if (!component) {
+ continue;
+ }
+ if (component.attacker === attacker && component.defender === defender) {
+ components.push(component);
+ seen.add(id);
+ }
+ }
+ return components;
+ }
+
+ private createContestComponent(
+ attacker: number,
+ defender: number,
+ nowTickPacked: number,
+ ): ContestComponent {
+ const id = this.allocateContestComponentId();
+ const component: ContestComponent = {
+ id,
+ attacker,
+ defender,
+ lastActivityPacked: nowTickPacked,
+ tiles: [],
+ strength: 0.5,
+ };
+ this.contestComponents.set(id, component);
+ this.contestActive = true;
+ this.territoryRenderer?.ensureContestTimeCapacity(id);
+ this.territoryRenderer?.setContestStrength(id, 0.5);
+ return component;
+ }
+
+ private allocateContestComponentId(): number {
+ const reused = this.contestFreeIds.pop();
+ if (reused !== undefined) {
+ return reused;
+ }
+ return this.contestNextId++;
+ }
+
+ private releaseContestComponentId(id: number) {
+ if (id <= 0) {
+ return;
+ }
+ this.contestFreeIds.push(id);
+ }
+
+ private addTileToComponent(
+ tile: TileRef,
+ component: ContestComponent,
+ attackerEver: boolean,
+ ) {
+ this.setContestTileData(
+ tile,
+ component.defender,
+ component.attacker,
+ component.id,
+ attackerEver,
+ );
+ this.contestTileIndices![tile] = component.tiles.length;
+ component.tiles.push(tile);
+ this.contestActive = true;
+ }
+
+ private removeTileFromComponent(tile: TileRef, component: ContestComponent) {
+ const tileIndex = this.contestTileIndices![tile];
+ const tiles = component.tiles;
+ const lastIndex = tiles.length - 1;
+ if (tileIndex >= 0 && tileIndex <= lastIndex) {
+ if (tileIndex !== lastIndex) {
+ const swapTile = tiles[lastIndex];
+ tiles[tileIndex] = swapTile;
+ this.contestTileIndices![swapTile] = tileIndex;
+ }
+ tiles.pop();
+ }
+ this.contestTileIndices![tile] = -1;
+ this.clearContestTile(tile);
+ if (component.tiles.length === 0) {
+ this.territoryRenderer?.setContestStrength(component.id, 0);
+ this.contestComponents.delete(component.id);
+ this.releaseContestComponentId(component.id);
+ this.contestActive = this.contestComponents.size > 0;
+ }
+ }
+
+ private mergeContestComponents(
+ target: ContestComponent,
+ source: ContestComponent,
+ ) {
+ const targetSize = target.tiles.length;
+ const sourceSize = source.tiles.length;
+ const totalSize = targetSize + sourceSize;
+ if (totalSize > 0) {
+ target.strength = Math.min(
+ 1,
+ (target.strength * targetSize + source.strength * sourceSize) /
+ totalSize,
+ );
+ }
+ for (const tile of source.tiles) {
+ const attackerEver = this.hasAttackerEver(tile);
+ this.setContestTileData(
+ tile,
+ target.defender,
+ target.attacker,
+ target.id,
+ attackerEver,
+ );
+ this.contestTileIndices![tile] = target.tiles.length;
+ target.tiles.push(tile);
+ }
+ target.lastActivityPacked = Math.max(
+ target.lastActivityPacked,
+ source.lastActivityPacked,
+ );
+ this.territoryRenderer?.setContestTime(
+ target.id,
+ target.lastActivityPacked,
+ );
+ this.contestComponents.delete(source.id);
+ this.territoryRenderer?.setContestStrength(source.id, 0);
+ this.releaseContestComponentId(source.id);
+ }
+
+ private expireContestComponent(component: ContestComponent) {
+ for (const tile of component.tiles) {
+ this.contestTileIndices![tile] = -1;
+ this.clearContestTile(tile);
+ }
+ component.tiles.length = 0;
+ this.territoryRenderer?.setContestStrength(component.id, 0);
+ this.contestComponents.delete(component.id);
+ this.releaseContestComponentId(component.id);
+ this.contestActive = this.contestComponents.size > 0;
+ }
+
+ private setContestTileData(
+ tile: TileRef,
+ defender: number,
+ attacker: number,
+ componentId: number,
+ attackerEver: boolean,
+ ) {
+ this.contestPrevOwners![tile] = defender;
+ this.contestAttackers![tile] = attacker;
+ this.contestComponentIds![tile] =
+ (componentId & CONTEST_ID_MASK) |
+ (attackerEver ? CONTEST_ATTACKER_EVER_BIT : 0);
+ this.territoryRenderer?.setContestTile(
+ tile,
+ defender,
+ attacker,
+ componentId,
+ attackerEver,
+ );
+ }
+
+ private clearContestTile(tile: TileRef) {
+ this.contestPrevOwners![tile] = 0;
+ this.contestAttackers![tile] = 0;
+ this.contestComponentIds![tile] = 0;
+ this.territoryRenderer?.clearContestTile(tile);
+ }
+
+ private contestId(tile: TileRef): number {
+ return this.contestComponentIds![tile] & CONTEST_ID_MASK;
+ }
+
+ private hasAttackerEver(tile: TileRef): boolean {
+ return (this.contestComponentIds![tile] & CONTEST_ATTACKER_EVER_BIT) !== 0;
+ }
+
+ private packContestTick(tick: number): number {
+ return Math.floor(tick) % CONTEST_TIME_WRAP;
+ }
+
+ private contestElapsed(nowPacked: number, startPacked: number): number {
+ if (nowPacked >= startPacked) {
+ return nowPacked - startPacked;
+ }
+ return CONTEST_TIME_WRAP - startPacked + nowPacked;
+ }
+
+ private syncContestStateToRenderer() {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ if (!this.contestComponentIds) {
+ return;
+ }
+ this.contestActive = this.contestComponents.size > 0;
+ let maxId = 0;
+ for (const component of this.contestComponents.values()) {
+ maxId = Math.max(maxId, component.id);
+ }
+ if (maxId > 0) {
+ this.territoryRenderer.ensureContestTimeCapacity(maxId);
+ this.territoryRenderer.ensureContestStrengthCapacity(maxId);
+ }
+ for (const component of this.contestComponents.values()) {
+ this.territoryRenderer.setContestTime(
+ component.id,
+ component.lastActivityPacked,
+ );
+ this.territoryRenderer.setContestStrength(
+ component.id,
+ component.strength,
+ );
+ for (const tile of component.tiles) {
+ const packed = this.contestComponentIds![tile];
+ const attackerEver = (packed & CONTEST_ATTACKER_EVER_BIT) !== 0;
+ this.territoryRenderer.setContestTile(
+ tile,
+ component.defender,
+ component.attacker,
+ component.id,
+ attackerEver,
+ );
+ }
+ }
+ }
+
+ 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 drawDebugOverlay(context: CanvasRenderingContext2D) {
+ if (!this.territoryRenderer) {
+ return;
+ }
+ const stats = this.territoryRenderer.getDebugStats();
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.font = "12px monospace";
+ context.textBaseline = "top";
+ const jfaStatus = stats.jfaSupported
+ ? "on"
+ : `off (${stats.jfaDisabledReason ?? "disabled"})`;
+ const lines = [
+ `map: ${stats.mapWidth}x${stats.mapHeight}`,
+ `view: ${stats.viewWidth}x${stats.viewHeight}`,
+ `scale: ${stats.viewScale.toFixed(2)}`,
+ `offset: ${stats.viewOffsetX.toFixed(1)}, ${stats.viewOffsetY.toFixed(1)}`,
+ `smooth: ${stats.smoothEnabled ? "on" : "off"} ${stats.smoothProgress.toFixed(2)} pair ${this.lastInterpolationPair}`,
+ `tick: ${this.tickNumberCurrent ?? "-"} prev ${this.tickNumberPrev ?? "-"}`,
+ `delayMs: ${this.interpolationDelayMs.toFixed(0)}`,
+ `motionMode: ${this.motionMode}`,
+ `tripleBuf: ${this.tripleBufferEnabled ? "on" : "off"}`,
+ `delayMode: ${this.interpolationDelayMode}${this.interpolationDelayMode === "ema" ? ` (ema=${this.tickIntervalEmaMs.toFixed(0)}ms)` : ""}`,
+ `smoothPrereq: prevCopy ${stats.prevStateCopySupported ? "yes" : "no"}`,
+ `jfa: ${jfaStatus} dirty ${stats.jfaDirty ? "yes" : "no"}`,
+ `contests: ${this.contestEnabled ? "on" : "off"} comps ${this.contestComponents.size}`,
+ `contestPattern: ${this.contestedPatternMode}`,
+ `hideAllBorders: ${this.debugDisableAllBorders ? "yes" : "no"}`,
+ `hideStaticBorders: ${this.debugDisableStaticBorders ? "yes" : "no"}`,
+ `contestTiles: ${this.contestTileCount}`,
+ `contestTicks: ${this.contestDurationTicks}`,
+ `hovered: ${stats.hoveredPlayerId}`,
+ ];
+ const padding = 6;
+ const lineHeight = 14;
+ let maxWidth = 0;
+ for (const line of lines) {
+ maxWidth = Math.max(maxWidth, context.measureText(line).width);
+ }
+ const width = Math.ceil(maxWidth + padding * 2);
+ const height = padding * 2 + lines.length * lineHeight;
+ context.fillStyle = "rgba(0, 0, 0, 0.6)";
+ context.fillRect(10, 10, width, height);
+ context.fillStyle = "rgba(255, 255, 255, 0.9)";
+ let y = 10 + padding;
+ for (const line of lines) {
+ context.fillText(line, 10 + padding, y);
+ y += lineHeight;
+ }
+ context.restore();
+ }
+}
diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
new file mode 100644
index 000000000..88d7b48de
--- /dev/null
+++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts
@@ -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 {
+ 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();
+ }
+ }
+}
diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts
index c3b0f84e6..5e6d0d0bb 100644
--- a/src/client/graphics/webgpu/TerritoryRenderer.ts
+++ b/src/client/graphics/webgpu/TerritoryRenderer.ts
@@ -30,6 +30,7 @@ export class TerritoryRenderer {
private resources: GroundTruthData | null = null;
private ready = false;
private initPromise: Promise | null = null;
+ private failureReason: string | null = null;
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
@@ -99,15 +100,25 @@ export class TerritoryRenderer {
private startInit(): void {
if (this.initPromise) return;
- this.initPromise = this.init();
+ 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 {
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(
@@ -182,6 +193,25 @@ export class TerritoryRenderer {
this.ready = true;
}
+ async whenReady(): Promise {
+ 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.
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index e0cacf57b..6b7e08c2d 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -205,6 +205,7 @@ export interface Theme {
allyColor(): Colord;
neutralColor(): Colord;
enemyColor(): Colord;
+ playerHighlightColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts
index 23ae4e653..354ca3dc8 100644
--- a/src/core/configuration/PastelTheme.ts
+++ b/src/core/configuration/PastelTheme.ts
@@ -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;
diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts
index 2cff80685..d840f0fb6 100644
--- a/src/core/configuration/PastelThemeDark.ts
+++ b/src/core/configuration/PastelThemeDark.ts
@@ -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;
+ }
}
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 1d2c87763..349613319 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -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);
}
@@ -971,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,
@@ -1254,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:
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 59a4b49e4..09d010cf3 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -669,6 +669,11 @@ export class GameView implements GameMap {
private _units = new Map();
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,
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 9444ed70b..ba9cda344 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -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());
}
}
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 180dc67db..2454176fc 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -47,6 +47,12 @@ 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 type TerritoryRendererPreference =
+ | "auto"
+ | "classic"
+ | "webgl"
+ | "webgpu";
export class UserSettings {
private static cache = new Map();
@@ -154,7 +160,7 @@ export class UserSettings {
}
webgpuDebug(): boolean {
- return this.get("settings.webgpuDebug", true);
+ return this.get("settings.webgpuDebug", false);
}
alertFrame() {
@@ -197,6 +203,27 @@ export class UserSettings {
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.setString(TERRITORY_RENDERER_KEY, renderer);
+ }
+
toggleAttackingTroopsOverlay() {
this.setBool(
"settings.attackingTroopsOverlay",
diff --git a/tests/TerritoryBackendSelection.test.ts b/tests/TerritoryBackendSelection.test.ts
new file mode 100644
index 000000000..b722f3b77
--- /dev/null
+++ b/tests/TerritoryBackendSelection.test.ts
@@ -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>;
+
+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 {
+ 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();
+ 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);
+ });
+});
From 2beb449fb47aa697325be8bbc2f064bc62d7f683 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 26 May 2026 23:00:46 +0200
Subject: [PATCH 21/23] Add renderer status panel
---
index.html | 1 +
src/client/graphics/GameRenderer.ts | 11 +
.../graphics/layers/RendererStatusPanel.ts | 383 ++++++++++++++++++
.../graphics/layers/TerritoryBackend.ts | 9 +
src/client/graphics/layers/TerritoryLayer.ts | 30 +-
.../graphics/layers/WebGPUDebugOverlay.ts | 148 ++++++-
6 files changed, 578 insertions(+), 4 deletions(-)
create mode 100644 src/client/graphics/layers/RendererStatusPanel.ts
diff --git a/index.html b/index.html
index 7636f6207..c5c1a622e 100644
--- a/index.html
+++ b/index.html
@@ -343,6 +343,7 @@
+
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 753fa2fb2..a7d630420 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -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";
@@ -252,6 +253,15 @@ export function createRenderer(
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");
@@ -285,6 +295,7 @@ export function createRenderer(
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
+ rendererStatusPanel,
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler, uiState),
new CoordinateGridLayer(game, eventBus, transformHandler),
diff --git a/src/client/graphics/layers/RendererStatusPanel.ts b/src/client/graphics/layers/RendererStatusPanel.ts
new file mode 100644
index 000000000..63fb077f0
--- /dev/null
+++ b/src/client/graphics/layers/RendererStatusPanel.ts
@@ -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).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`
+
+
+ Renderer
+
+
+
+
Active
+
+
+ ${this.activeRenderer
+ ? this.rendererLabel(this.activeRenderer)
+ : "Pending"}
+
+
+
+
+
+ ${TERRITORY_RENDERER_OPTIONS.map(
+ (option) =>
+ html``,
+ )}
+
+
+ ${note ? html`
${note}
` : null}
+
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/TerritoryBackend.ts b/src/client/graphics/layers/TerritoryBackend.ts
index 7b02694ca..1b466d269 100644
--- a/src/client/graphics/layers/TerritoryBackend.ts
+++ b/src/client/graphics/layers/TerritoryBackend.ts
@@ -2,6 +2,8 @@ import { Layer } from "./Layer";
export type TerritoryRendererId = "classic" | "webgl" | "webgpu";
export type TerritoryRendererPreference = "auto" | TerritoryRendererId;
+export const TERRITORY_RENDERER_STATUS_EVENT =
+ "event:territory-renderer-status";
export const TERRITORY_RENDERER_OPTIONS: TerritoryRendererPreference[] = [
"auto",
@@ -17,6 +19,13 @@ export interface TerritoryBackend extends Layer {
whenReady?: () => Promise;
}
+export interface TerritoryRendererStatus {
+ active: TerritoryRendererId | null;
+ preference: TerritoryRendererPreference;
+ failedBackends: TerritoryRendererId[];
+ message: string | null;
+}
+
export interface TerritoryBackendCandidate {
readonly id: TerritoryRendererId;
init?: () => void | Promise;
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 60833078b..1f2bf25e9 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -8,8 +8,10 @@ import {
import { TransformHandler } from "../TransformHandler";
import { ClassicTerritoryBackend } from "./ClassicTerritoryBackend";
import {
+ TERRITORY_RENDERER_STATUS_EVENT,
TerritoryBackend,
TerritoryRendererId,
+ TerritoryRendererStatus,
selectTerritoryBackend,
territoryRendererOrder,
} from "./TerritoryBackend";
@@ -25,6 +27,7 @@ export class TerritoryLayer implements TerritoryBackend {
private initialized = false;
private readonly settingsChanged = () => {
this.failedBackends.clear();
+ this.publishStatus("Retrying renderer selection");
void this.selectConfiguredBackend();
};
@@ -51,7 +54,10 @@ export class TerritoryLayer implements TerritoryBackend {
);
// Keep the map visible while accelerated renderers initialize.
- this.activateBackend(this.createBackend("classic"));
+ this.activateBackend(
+ this.createBackend("classic"),
+ "Using Classic while accelerated renderer initializes",
+ );
void this.selectConfiguredBackend();
}
@@ -122,6 +128,8 @@ export class TerritoryLayer implements TerritoryBackend {
if (selection.backend !== null) {
this.activateBackend(selection.backend);
+ } else {
+ this.publishStatus("No territory renderer is currently available");
}
}
@@ -161,7 +169,10 @@ export class TerritoryLayer implements TerritoryBackend {
}
}
- private activateBackend(backend: TerritoryBackend) {
+ private activateBackend(
+ backend: TerritoryBackend,
+ message: string | null = null,
+ ) {
if (this.activeBackend === backend) {
return;
}
@@ -169,6 +180,7 @@ export class TerritoryLayer implements TerritoryBackend {
this.activeBackend = backend;
previous?.dispose?.();
console.info(`[TerritoryLayer] active renderer: ${backend.id}`);
+ this.publishStatus(message);
}
private runActive(
@@ -196,6 +208,7 @@ export class TerritoryLayer implements TerritoryBackend {
if (backend.id !== "classic") {
this.failedBackends.add(backend.id);
}
+ this.publishStatus(`${backend.id} failed: ${reason}`);
if (this.activeBackend === backend) {
this.activeBackend = null;
backend.dispose?.();
@@ -241,4 +254,17 @@ export class TerritoryLayer implements TerritoryBackend {
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
+
+ private publishStatus(message: string | null = null) {
+ const detail: TerritoryRendererStatus = {
+ active: this.activeBackend?.id ?? null,
+ preference: this.userSettings.territoryRenderer(),
+ failedBackends: Array.from(this.failedBackends),
+ message,
+ };
+
+ globalThis.dispatchEvent?.(
+ new CustomEvent(TERRITORY_RENDERER_STATUS_EVENT, { detail }),
+ );
+ }
}
diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts
index 9aae56bf0..6c9275d57 100644
--- a/src/client/graphics/layers/WebGPUDebugOverlay.ts
+++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts
@@ -48,7 +48,19 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
@state()
private tickComputeMs: number = 0;
+ @state()
+ private position: { x: number; y: number } | null = null;
+
+ @state()
+ private isDragging = false;
+
private frameTimes: number[] = [];
+ private dragState: {
+ pointerId: number;
+ offsetX: number;
+ offsetY: number;
+ } | null = null;
+ private readonly positionStorageKey = "webgpuDebugOverlay.position.v1";
static styles = css`
.overlay {
@@ -71,6 +83,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
user-select: none;
}
+ .overlay.dragging {
+ opacity: 0.72;
+ }
+
.title {
font-weight: 700;
margin-bottom: 8px;
@@ -78,6 +94,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
align-items: center;
justify-content: space-between;
gap: 8px;
+ cursor: grab;
+ touch-action: none;
+ }
+
+ .overlay.dragging .title {
+ cursor: grabbing;
}
.metrics {
@@ -154,6 +176,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
`;
init() {
+ this.restorePosition();
this.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
this.tickComputeMs = e.computeMs;
@@ -163,6 +186,11 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.endDrag();
+ }
+
updateFrameMetrics(frameDurationMs: number): void {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return;
@@ -301,6 +329,118 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
`;
}
+ private restorePosition() {
+ try {
+ const raw = localStorage.getItem(this.positionStorageKey);
+ if (!raw) {
+ return;
+ }
+ const parsed = JSON.parse(raw) as { x: unknown; y: unknown };
+ if (
+ typeof parsed.x === "number" &&
+ typeof parsed.y === "number" &&
+ Number.isFinite(parsed.x) &&
+ Number.isFinite(parsed.y)
+ ) {
+ this.position = this.clampPosition(parsed.x, parsed.y);
+ }
+ } catch {
+ // Keep the default position.
+ }
+ }
+
+ private savePosition() {
+ if (!this.position) {
+ return;
+ }
+ try {
+ localStorage.setItem(
+ this.positionStorageKey,
+ JSON.stringify(this.position),
+ );
+ } catch {
+ // Position persistence is best-effort.
+ }
+ }
+
+ private clampPosition(x: number, y: number) {
+ const overlay = this.renderRoot.querySelector(
+ ".overlay",
+ ) as HTMLElement | null;
+ const width = overlay?.offsetWidth ?? 340;
+ const height = overlay?.offsetHeight ?? 420;
+ const margin = 8;
+ return {
+ x: Math.max(margin, Math.min(window.innerWidth - width - margin, x)),
+ y: Math.max(margin, Math.min(window.innerHeight - height - margin, y)),
+ };
+ }
+
+ private overlayStyle() {
+ if (!this.position) {
+ return "";
+ }
+ return `left: ${this.position.x}px; top: ${this.position.y}px;`;
+ }
+
+ private stopPointerEvent(event: PointerEvent) {
+ event.stopPropagation();
+ }
+
+ private handleDragPointerDown(event: PointerEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const overlay = this.renderRoot.querySelector(
+ ".overlay",
+ ) as HTMLElement | null;
+ if (!overlay) {
+ return;
+ }
+ const rect = overlay.getBoundingClientRect();
+ this.position = { x: rect.left, y: rect.top };
+ this.isDragging = true;
+ this.dragState = {
+ pointerId: event.pointerId,
+ offsetX: event.clientX - rect.left,
+ offsetY: event.clientY - rect.top,
+ };
+
+ globalThis.addEventListener("pointermove", this.handleDragPointerMove);
+ globalThis.addEventListener("pointerup", this.handleDragPointerUp);
+ globalThis.addEventListener("pointercancel", this.handleDragPointerUp);
+ }
+
+ private readonly handleDragPointerMove = (event: PointerEvent) => {
+ if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ this.position = this.clampPosition(
+ event.clientX - this.dragState.offsetX,
+ event.clientY - this.dragState.offsetY,
+ );
+ };
+
+ private readonly handleDragPointerUp = (event: PointerEvent) => {
+ if (!this.dragState || event.pointerId !== this.dragState.pointerId) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ this.savePosition();
+ this.endDrag();
+ };
+
+ private endDrag() {
+ globalThis.removeEventListener("pointermove", this.handleDragPointerMove);
+ globalThis.removeEventListener("pointerup", this.handleDragPointerUp);
+ globalThis.removeEventListener("pointercancel", this.handleDragPointerUp);
+ this.dragState = null;
+ this.isDragging = false;
+ }
+
render() {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return null;
@@ -323,8 +463,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
TERRITORY_POST_SMOOTHING[0];
return html`
-
-
+
+
From 93378846c8ca46a7aa750bd76471518122a0e0a9 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Tue, 26 May 2026 23:11:59 +0200
Subject: [PATCH 22/23] Show matching renderer debug panel
---
.../graphics/layers/WebGLTerritoryBackend.ts | 31 ++++++++++++++++---
.../graphics/layers/WebGPUDebugOverlay.ts | 18 ++++++++++-
src/core/game/UserSettings.ts | 20 ++++++++++--
3 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/src/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts
index e683b2d61..d714566d6 100644
--- a/src/client/graphics/layers/WebGLTerritoryBackend.ts
+++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts
@@ -5,7 +5,11 @@ import { ColoredTeams, PlayerType, Team } from "../../../core/game/Game";
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
-import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+ WEBGL_DEBUG_KEY,
+} from "../../../core/game/UserSettings";
import {
AlternateViewEvent,
ContextMenuEvent,
@@ -25,7 +29,6 @@ const ENABLE_CONTEST_TRACKING = false;
const CONTEST_STRENGTH_EMA_ALPHA = 0.8;
const CONTEST_STRENGTH_MIN = 0.01;
const CONTEST_STRENGTH_MAX = 0.95;
-const DEBUG_TERRITORY_OVERLAY = false;
type ContestComponent = {
id: number;
@@ -104,6 +107,7 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
event.preventDefault();
this.failureReason = "WebGL context lost.";
};
+ private readonly debugSettingChanged = () => this.syncSmoothingDebugUi();
constructor(
private game: GameView,
@@ -409,8 +413,12 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
this.hoverHighlightOptions(),
);
});
+ globalThis.addEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`,
+ this.debugSettingChanged,
+ );
this.redraw();
- this.ensureSmoothingDebugUi();
+ this.syncSmoothingDebugUi();
}
getFailureReason(): string | null {
@@ -418,6 +426,10 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
}
dispose() {
+ globalThis.removeEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${WEBGL_DEBUG_KEY}`,
+ this.debugSettingChanged,
+ );
this.smoothingDebugUi?.remove();
this.smoothingDebugUi = null;
this.territoryRenderer?.canvas.removeEventListener(
@@ -428,8 +440,17 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
this.territoryRenderer = null;
}
+ private syncSmoothingDebugUi() {
+ if (!this.userSettings.webglDebug()) {
+ this.smoothingDebugUi?.remove();
+ this.smoothingDebugUi = null;
+ return;
+ }
+ this.ensureSmoothingDebugUi();
+ }
+
private ensureSmoothingDebugUi() {
- if (!DEBUG_TERRITORY_OVERLAY) return;
+ if (!this.userSettings.webglDebug()) return;
if (this.smoothingDebugUi) return;
const root = document.createElement("div");
@@ -1001,7 +1022,7 @@ export class WebGLTerritoryBackend implements TerritoryBackend {
);
}
- if (DEBUG_TERRITORY_OVERLAY) {
+ if (this.userSettings.webglDebug()) {
const overlayStart = FrameProfiler.start();
this.drawDebugOverlay(context);
FrameProfiler.end("TerritoryLayer:debugOverlay", overlayStart);
diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts
index 6c9275d57..961cd61ce 100644
--- a/src/client/graphics/layers/WebGPUDebugOverlay.ts
+++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts
@@ -2,7 +2,11 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
-import { UserSettings } from "../../../core/game/UserSettings";
+import {
+ USER_SETTINGS_CHANGED_EVENT,
+ UserSettings,
+ WEBGPU_DEBUG_KEY,
+} from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
import {
TERRAIN_SHADER_KEY,
@@ -177,6 +181,10 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
init() {
this.restorePosition();
+ globalThis.addEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
+ this.handleDebugSettingChanged,
+ );
this.eventBus.on(WebGPUComputeMetricsEvent, (e) => {
if (typeof e.computeMs === "number" && Number.isFinite(e.computeMs)) {
this.tickComputeMs = e.computeMs;
@@ -189,8 +197,16 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
disconnectedCallback(): void {
super.disconnectedCallback();
this.endDrag();
+ globalThis.removeEventListener?.(
+ `${USER_SETTINGS_CHANGED_EVENT}:${WEBGPU_DEBUG_KEY}`,
+ this.handleDebugSettingChanged,
+ );
}
+ private readonly handleDebugSettingChanged = () => {
+ this.requestUpdate();
+ };
+
updateFrameMetrics(frameDurationMs: number): void {
if (!this.userSettings || !this.userSettings.webgpuDebug()) {
return;
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 2454176fc..35b7b3abf 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -48,6 +48,8 @@ export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer";
+export const WEBGL_DEBUG_KEY = "settings.webglDebug";
+export const WEBGPU_DEBUG_KEY = "settings.webgpuDebug";
export type TerritoryRendererPreference =
| "auto"
| "classic"
@@ -160,7 +162,19 @@ export class UserSettings {
}
webgpuDebug(): boolean {
- return this.get("settings.webgpuDebug", false);
+ return this.get(WEBGPU_DEBUG_KEY, false);
+ }
+
+ webglDebug(): boolean {
+ return this.get(WEBGL_DEBUG_KEY, false);
+ }
+
+ setWebgpuDebug(value: boolean): void {
+ this.set(WEBGPU_DEBUG_KEY, value);
+ }
+
+ setWebglDebug(value: boolean): void {
+ this.set(WEBGL_DEBUG_KEY, value);
}
alertFrame() {
@@ -221,6 +235,8 @@ export class UserSettings {
value === "classic" || value === "webgl" || value === "webgpu"
? value
: "auto";
+ this.setWebglDebug(renderer === "webgl");
+ this.setWebgpuDebug(renderer === "webgpu");
this.setString(TERRITORY_RENDERER_KEY, renderer);
}
@@ -254,7 +270,7 @@ export class UserSettings {
}
toggleWebgpuDebug() {
- this.set("settings.webgpuDebug", !this.webgpuDebug());
+ this.setWebgpuDebug(!this.webgpuDebug());
}
toggleAlertFrame() {
From 6ac8d4d1017fe58c0a8ff3da5efdabcb620755a5 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Wed, 27 May 2026 02:15:59 +0200
Subject: [PATCH 23/23] Show fallout in WebGL alt view
---
src/client/graphics/layers/TerritoryWebGLRenderer.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
index f506f45aa..002772b30 100644
--- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts
+++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts
@@ -3357,6 +3357,9 @@ export class TerritoryWebGLRenderer {
if (u_alternativeView) {
// Alt view: terrain + borders only, no territory fill
vec3 color = baseTerrainColor;
+ if (owner == 0u && hasFallout) {
+ color = mix(baseTerrainColor, u_fallout.rgb, u_alpha);
+ }
if (!u_debugDisableAllBorders && !u_debugDisableStaticBorders && owner != 0u && isBorder) {
// Only draw borders, not territory fill
uint relationAlt = relationCode(owner, uint(u_viewerId));