From 962d4cabd45710c8bacd1e81ecddcb0e33350923 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Mon, 19 Jan 2026 02:22:12 +0100
Subject: [PATCH] add temporal smoothing for territory rendering
Add user-selectable temporal smoothing pipeline to create smooth visual
transitions between simulation ticks (~10Hz) and display frames (~60Hz).
Pre-render smoothing provides sharp tile dissolve transitions using compute
shaders, while post-render smoothing blends frames for fluid animation.
Includes tick timing with exponential moving averages for stable temporal
parameters.
New Components:
- TerritoryPreSmoothingRegistry & TerritoryPostSmoothingRegistry for mode selection
- VisualStateSmoothingPass compute shader for pre-render dissolve effects
- TemporalResolvePass render shader for post-render temporal compositing
- Enhanced GroundTruthData with temporal uniforms and history textures
Performance: No full-map per-frame compute, single fullscreen post-render pass.
Compatible with all territory shaders (classic, retro, future variants).
Files: 12 files changed, 1357 insertions(+), 8 deletions(-)
---
src/client/graphics/layers/TerritoryLayer.ts | 49 ++++
.../graphics/layers/WebGPUDebugOverlay.ts | 107 +++++++++
.../graphics/webgpu/TerritoryRenderer.ts | 179 +++++++++++++-
.../webgpu/compute/StateUpdatePass.ts | 2 +
.../compute/VisualStateSmoothingPass.ts | 203 ++++++++++++++++
.../graphics/webgpu/core/GroundTruthData.ts | 224 +++++++++++++++++-
.../webgpu/render/TemporalResolvePass.ts | 218 +++++++++++++++++
.../render/TerritoryPostSmoothingRegistry.ts | 128 ++++++++++
.../render/TerritoryPreSmoothingRegistry.ts | 114 +++++++++
.../webgpu/render/TerritoryRenderPass.ts | 5 +-
.../compute/visual-state-smoothing.wgsl | 76 ++++++
.../shaders/render/temporal-resolve.wgsl | 81 +++++++
12 files changed, 1378 insertions(+), 8 deletions(-)
create mode 100644 src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts
create mode 100644 src/client/graphics/webgpu/render/TemporalResolvePass.ts
create mode 100644 src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts
create mode 100644 src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts
create mode 100644 src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl
create mode 100644 src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index ca84253f0..f2a2c93b5 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -11,6 +11,14 @@ import {
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { TransformHandler } from "../TransformHandler";
+import {
+ buildTerritoryPostSmoothingParams,
+ readTerritoryPostSmoothingId,
+} from "../webgpu/render/TerritoryPostSmoothingRegistry";
+import {
+ buildTerritoryPreSmoothingParams,
+ readTerritoryPreSmoothingId,
+} from "../webgpu/render/TerritoryPreSmoothingRegistry";
import {
buildTerritoryShaderParams,
readTerritoryShaderId,
@@ -36,6 +44,8 @@ export class TerritoryLayer implements Layer {
private lastPaletteSignature: string | null = null;
private lastDefensePostsSignature: 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;
@@ -78,6 +88,7 @@ export class TerritoryLayer implements Layer {
this.refreshPaletteIfNeeded();
this.refreshDefensePostsIfNeeded();
this.applyTerritoryShaderSettings();
+ this.applyTerritorySmoothingSettings();
const updatedTiles = this.game.recentlyUpdatedTiles();
for (let i = 0; i < updatedTiles.length; i++) {
@@ -114,6 +125,7 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer.setAlternativeView(this.alternativeView);
this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId);
this.applyTerritoryShaderSettings(true);
+ this.applyTerritorySmoothingSettings(true);
this.territoryRenderer.markAllDirty();
this.territoryRenderer.refreshPalette();
this.lastPaletteSignature = this.computePaletteSignature();
@@ -143,6 +155,7 @@ export class TerritoryLayer implements Layer {
// Apply user settings even while the game is paused (settings modal).
this.applyTerritoryShaderSettings();
+ this.applyTerritorySmoothingSettings();
this.ensureTerritoryCanvasAttached(context.canvas);
this.updateHoverHighlight();
@@ -322,6 +335,42 @@ export class TerritoryLayer implements Layer {
this.territoryRenderer.setTerritoryShaderParams(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[] = [];
diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts
index 8d9619ab1..cf60eee5a 100644
--- a/src/client/graphics/layers/WebGPUDebugOverlay.ts
+++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts
@@ -4,6 +4,18 @@ import { live } from "lit/directives/live.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { WebGPUComputeMetricsEvent } from "../../InputHandler";
+import {
+ TERRITORY_POST_SMOOTHING,
+ TERRITORY_POST_SMOOTHING_KEY,
+ territoryPostSmoothingIdFromInt,
+ territoryPostSmoothingIntFromId,
+} from "../webgpu/render/TerritoryPostSmoothingRegistry";
+import {
+ TERRITORY_PRE_SMOOTHING,
+ TERRITORY_PRE_SMOOTHING_KEY,
+ territoryPreSmoothingIdFromInt,
+ territoryPreSmoothingIntFromId,
+} from "../webgpu/render/TerritoryPreSmoothingRegistry";
import {
TERRITORY_SHADER_KEY,
TERRITORY_SHADERS,
@@ -89,6 +101,15 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
user-select: none;
}
+ .sectionTitle {
+ margin-top: 10px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: rgba(255, 255, 255, 0.85);
+ text-transform: uppercase;
+ font-size: 11px;
+ }
+
select,
input[type="range"] {
width: 170px;
@@ -165,6 +186,32 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
this.requestUpdate();
}
+ private selectedPreSmoothingId() {
+ const selected = this.userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0);
+ return territoryPreSmoothingIdFromInt(selected);
+ }
+
+ private setSelectedPreSmoothingId(id: "off" | "dissolve" | "budget") {
+ this.userSettings.setInt(
+ TERRITORY_PRE_SMOOTHING_KEY,
+ territoryPreSmoothingIntFromId(id),
+ );
+ this.requestUpdate();
+ }
+
+ private selectedPostSmoothingId() {
+ const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0);
+ return territoryPostSmoothingIdFromInt(selected);
+ }
+
+ private setSelectedPostSmoothingId(id: "off" | "fade" | "dissolve") {
+ this.userSettings.setInt(
+ TERRITORY_POST_SMOOTHING_KEY,
+ territoryPostSmoothingIntFromId(id),
+ );
+ this.requestUpdate();
+ }
+
private renderOptionControl(
option: (typeof TERRITORY_SHADERS)[number]["options"][number],
) {
@@ -242,6 +289,14 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
const shaderId = this.selectedShaderId();
const shader =
TERRITORY_SHADERS.find((s) => s.id === shaderId) ?? TERRITORY_SHADERS[0];
+ const preId = this.selectedPreSmoothingId();
+ const pre =
+ TERRITORY_PRE_SMOOTHING.find((s) => s.id === preId) ??
+ TERRITORY_PRE_SMOOTHING[0];
+ const postId = this.selectedPostSmoothingId();
+ const post =
+ TERRITORY_POST_SMOOTHING.find((s) => s.id === postId) ??
+ TERRITORY_POST_SMOOTHING[0];
return html`
@@ -280,6 +335,58 @@ export class WebGPUDebugOverlay extends LitElement implements Layer {
${shader.options.map((opt) => this.renderOptionControl(opt))}
+
+ Temporal
+
+
+
Post Compute
+
+
+
+ ${pre.options.map((opt) => this.renderOptionControl(opt))}
+
+
+
Post Render
+
+
+
+ ${post.options.map((opt) => this.renderOptionControl(opt))}
`;
}
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);
+}