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); +}