From a6e5fdb2da73e05ae517a12dc965bc436f6bd6ae Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:50:39 +0100 Subject: [PATCH] replace defended epoch stamping with defended-strength field Store defended influence in defendedStrengthTexture and sample it in territory render shader Recompute defended strength on tick for state-updated tiles and for post-change dirty tiles, with full-map fallback when diffs are large Pack defense posts by owner on GPU (owner offsets + posts buffer) Remove old defended clear/update passes and epoch-based params --- .../graphics/webgpu/TerritoryRenderer.ts | 103 +---- .../webgpu/compute/DefendedClearPass.ts | 105 ----- ...atePass.ts => DefendedStrengthFullPass.ts} | 86 ++-- .../webgpu/compute/DefendedStrengthPass.ts | 172 ++++++++ .../webgpu/compute/StateUpdatePass.ts | 81 +++- .../graphics/webgpu/core/GroundTruthData.ts | 393 +++++++++++++----- .../webgpu/render/TerritoryRenderPass.ts | 27 +- .../webgpu/shaders/common/uniforms.wgsl | 12 - .../shaders/compute/defended-clear.wgsl | 12 - .../compute/defended-strength-full.wgsl | 65 +++ .../shaders/compute/defended-strength.wgsl | 69 +++ .../shaders/compute/defended-update.wgsl | 53 --- .../webgpu/shaders/compute/state-update.wgsl | 58 ++- .../webgpu/shaders/render/territory.wgsl | 26 +- 14 files changed, 806 insertions(+), 456 deletions(-) delete mode 100644 src/client/graphics/webgpu/compute/DefendedClearPass.ts rename src/client/graphics/webgpu/compute/{DefendedUpdatePass.ts => DefendedStrengthFullPass.ts} (59%) create mode 100644 src/client/graphics/webgpu/compute/DefendedStrengthPass.ts delete mode 100644 src/client/graphics/webgpu/shaders/common/uniforms.wgsl delete mode 100644 src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl create mode 100644 src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl delete mode 100644 src/client/graphics/webgpu/shaders/compute/defended-update.wgsl diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index f5910727c..6b3116b99 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -3,8 +3,8 @@ import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { createCanvas } from "../../Utils"; import { ComputePass } from "./compute/ComputePass"; -import { DefendedClearPass } from "./compute/DefendedClearPass"; -import { DefendedUpdatePass } from "./compute/DefendedUpdatePass"; +import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass"; +import { DefendedStrengthPass } from "./compute/DefendedStrengthPass"; import { StateUpdatePass } from "./compute/StateUpdatePass"; import { TerrainComputePass } from "./compute/TerrainComputePass"; import { GroundTruthData } from "./core/GroundTruthData"; @@ -40,13 +40,10 @@ export class TerritoryRenderer { // Pass instances private terrainComputePass: TerrainComputePass | null = null; private stateUpdatePass: StateUpdatePass | null = null; - private defendedClearPass: DefendedClearPass | null = null; - private defendedUpdatePass: DefendedUpdatePass | null = null; + private defendedStrengthFullPass: DefendedStrengthFullPass | null = null; + private defendedStrengthPass: DefendedStrengthPass | null = null; private territoryRenderPass: TerritoryRenderPass | null = null; - - // State tracking - private needsDefendedRebuild = true; - private needsDefendedHardClear = true; + private readonly defensePostRange: number; private constructor( private readonly game: GameView, @@ -56,6 +53,7 @@ export class TerritoryRenderer { this.canvas.style.pointerEvents = "none"; this.canvas.width = 1; this.canvas.height = 1; + this.defensePostRange = game.config().defensePostRange(); } static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { @@ -108,14 +106,14 @@ export class TerritoryRenderer { // Create compute passes (terrain compute should run first) this.terrainComputePass = new TerrainComputePass(); this.stateUpdatePass = new StateUpdatePass(); - this.defendedClearPass = new DefendedClearPass(); - this.defendedUpdatePass = new DefendedUpdatePass(); + this.defendedStrengthFullPass = new DefendedStrengthFullPass(); + this.defendedStrengthPass = new DefendedStrengthPass(); this.computePasses = [ this.terrainComputePass, this.stateUpdatePass, - this.defendedClearPass, - this.defendedUpdatePass, + this.defendedStrengthFullPass, + this.defendedStrengthPass, ]; // Create render passes @@ -236,10 +234,7 @@ export class TerritoryRenderer { } markAllDirty(): void { - this.needsDefendedRebuild = true; - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } + this.resources?.markDefensePostsDirty(); } refreshPalette(): void { @@ -254,10 +249,6 @@ export class TerritoryRenderer { return; } this.resources.markDefensePostsDirty(); - this.needsDefendedRebuild = true; - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } } refreshTerrain(): void { @@ -313,73 +304,31 @@ export class TerritoryRenderer { return; } + if (this.game.config().defensePostRange() !== this.defensePostRange) { + throw new Error("defensePostRange changed at runtime; unsupported."); + } + // Upload palette if needed this.resources.uploadPalette(); - // Upload defense posts if needed + // Upload defense posts if needed (also produces defended dirty tiles on changes) this.resources.uploadDefensePosts(); // Initial state upload this.resources.uploadState(); - // Check if we need to run compute passes - const hasStateUpdates = this.stateUpdatePass - ? this.stateUpdatePass.needsUpdate() - : false; - const needsTerrainCompute = this.terrainComputePass - ? this.terrainComputePass.needsUpdate() - : false; - const range = this.game.config().defensePostRange(); - const rangeChanged = range !== this.resources.getLastDefenseRange(); - const countChanged = - this.resources.getDefensePostsCount() !== - this.resources.getLastDefensePostsCount(); - const hasPosts = this.resources.getDefensePostsCount() > 0; - - // Use explicit boolean checks to satisfy linter (|| is correct for boolean OR) - const shouldRebuildDefended = - this.needsDefendedRebuild === true || - rangeChanged === true || - countChanged === true || - (hasPosts && hasStateUpdates === true); - const needsCompute = - needsTerrainCompute === true || - hasStateUpdates === true || - shouldRebuildDefended === true || - this.needsDefendedHardClear === true; + (this.terrainComputePass?.needsUpdate() ?? false) || + (this.stateUpdatePass?.needsUpdate() ?? false) || + (this.defendedStrengthFullPass?.needsUpdate() ?? false) || + (this.defendedStrengthPass?.needsUpdate() ?? false); - // Update defense params even if we early-out if (!needsCompute) { - this.resources.writeDefenseParamsBuffer(); - this.resources.setLastDefenseRange(range); - this.resources.setLastDefensePostsCount( - this.resources.getDefensePostsCount(), - ); return; } const encoder = this.device.device.createCommandEncoder(); - // Handle defended rebuild (before executing passes) - if (shouldRebuildDefended) { - // Hard-clear defended texture before restamping. This avoids relying on - // epoch-stamping for correctness and prevents transient mismatches where - // defended rendering disappears between rebuilds. - this.needsDefendedHardClear = true; - - if (this.defendedUpdatePass) { - this.defendedUpdatePass.markDirty(); - } - - this.needsDefendedRebuild = false; - } - - // Update hard clear flag for DefendedClearPass - if (this.defendedClearPass) { - this.defendedClearPass.setNeedsHardClear(this.needsDefendedHardClear); - } - // Execute compute passes in dependency order (clear will run before update if needed) for (const pass of this.computePassOrder) { if (!pass.needsUpdate()) { @@ -388,18 +337,6 @@ export class TerritoryRenderer { pass.execute(encoder, this.resources); } - // After all passes, update defense params and clear flags - this.resources.writeDefenseParamsBuffer(); - if (this.needsDefendedHardClear && this.defendedClearPass) { - this.needsDefendedHardClear = false; - this.defendedClearPass.setNeedsHardClear(false); - } - - this.resources.setLastDefenseRange(range); - this.resources.setLastDefensePostsCount( - this.resources.getDefensePostsCount(), - ); - this.device.device.queue.submit([encoder.finish()]); } diff --git a/src/client/graphics/webgpu/compute/DefendedClearPass.ts b/src/client/graphics/webgpu/compute/DefendedClearPass.ts deleted file mode 100644 index d6aa8642f..000000000 --- a/src/client/graphics/webgpu/compute/DefendedClearPass.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { GroundTruthData } from "../core/GroundTruthData"; -import { loadShader } from "../core/ShaderLoader"; -import { ComputePass } from "./ComputePass"; - -/** - * Compute pass that clears the defended texture (sets all texels to 0). - * Used for initial clear and epoch wrap scenarios. - */ -export class DefendedClearPass implements ComputePass { - name = "defended-clear"; - 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 needsHardClear = true; - - async init(device: GPUDevice, resources: GroundTruthData): Promise { - this.device = device; - this.resources = resources; - - const shaderCode = await loadShader("compute/defended-clear.wgsl"); - const shaderModule = device.createShaderModule({ code: shaderCode }); - - this.bindGroupLayout = device.createBindGroupLayout({ - entries: [ - { - binding: 0, - visibility: 4 /* COMPUTE */, - storageTexture: { format: "r32uint" }, - }, - ], - }); - - this.pipeline = device.createComputePipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [this.bindGroupLayout], - }), - compute: { - module: shaderModule, - entryPoint: "main", - }, - }); - - this.rebuildBindGroup(); - } - - needsUpdate(): boolean { - return this.needsHardClear; - } - - execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { - if (!this.device || !this.pipeline || !this.bindGroup) { - return; - } - - const mapWidth = resources.getMapWidth(); - const mapHeight = resources.getMapHeight(); - const workgroupCountX = Math.ceil(mapWidth / 8); - const workgroupCountY = Math.ceil(mapHeight / 8); - - const pass = encoder.beginComputePass(); - pass.setPipeline(this.pipeline); - pass.setBindGroup(0, this.bindGroup); - pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); - pass.end(); - - this.needsHardClear = false; - } - - private rebuildBindGroup(): void { - if ( - !this.device || - !this.bindGroupLayout || - !this.resources || - !this.resources.defendedTexture - ) { - return; - } - - this.bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: this.resources.defendedTexture.createView(), - }, - ], - }); - } - - setNeedsHardClear(value: boolean): void { - this.needsHardClear = value; - } - - dispose(): void { - this.pipeline = null; - this.bindGroupLayout = null; - this.bindGroup = null; - this.device = null; - this.resources = null; - } -} diff --git a/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts similarity index 59% rename from src/client/graphics/webgpu/compute/DefendedUpdatePass.ts rename to src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts index abac77c7f..3d747803b 100644 --- a/src/client/graphics/webgpu/compute/DefendedUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts @@ -3,10 +3,11 @@ import { loadShader } from "../core/ShaderLoader"; import { ComputePass } from "./ComputePass"; /** - * Compute pass that updates the defended texture from defense posts. + * Full defended strength recompute across the entire map. + * Used on initial upload or when post diffs are too large for a tile list. */ -export class DefendedUpdatePass implements ComputePass { - name = "defended-update"; +export class DefendedStrengthFullPass implements ComputePass { + name = "defended-strength-full"; dependencies: string[] = ["state-update"]; private pipeline: GPUComputePipeline | null = null; @@ -14,13 +15,13 @@ export class DefendedUpdatePass implements ComputePass { private bindGroup: GPUBindGroup | null = null; private device: GPUDevice | null = null; private resources: GroundTruthData | null = null; - private needsRebuild = true; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; async init(device: GPUDevice, resources: GroundTruthData): Promise { this.device = device; this.resources = resources; - const shaderCode = await loadShader("compute/defended-update.wgsl"); + const shaderCode = await loadShader("compute/defended-strength-full.wgsl"); const shaderModule = device.createShaderModule({ code: shaderCode }); this.bindGroupLayout = device.createBindGroupLayout({ @@ -33,17 +34,22 @@ export class DefendedUpdatePass implements ComputePass { { binding: 1, visibility: 4 /* COMPUTE */, - buffer: { type: "read-only-storage" }, + texture: { sampleType: "uint" }, }, { binding: 2, visibility: 4 /* COMPUTE */, - texture: { sampleType: "uint" }, + storageTexture: { format: "rgba8unorm" }, }, { binding: 3, visibility: 4 /* COMPUTE */, - storageTexture: { format: "r32uint" }, + buffer: { type: "read-only-storage" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, }, ], }); @@ -60,12 +66,7 @@ export class DefendedUpdatePass implements ComputePass { } needsUpdate(): boolean { - if (!this.resources || !this.needsRebuild) { - return false; - } - - // Only run if we have defense posts - return this.resources.getDefensePostsCount() > 0; + return this.resources?.needsDefendedFullRecompute() ?? false; } execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { @@ -73,38 +74,35 @@ export class DefendedUpdatePass implements ComputePass { return; } - const range = resources.getGame().config().defensePostRange(); - const postsCount = resources.getDefensePostsCount(); - - if (postsCount === 0) { - this.needsRebuild = false; + if (!resources.needsDefendedFullRecompute()) { return; } - // Epoch is incremented by orchestrator before this pass runs - resources.writeDefenseParamsBuffer(); + resources.writeDefendedStrengthParamsBuffer(0); - const oldBuffer = this.resources?.defensePostsBuffer; - const bufferChanged = oldBuffer !== resources.defensePostsBuffer; - - if (bufferChanged || !this.bindGroup) { + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + if ( + !this.bindGroup || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer + ) { this.rebuildBindGroup(); } - if (!this.bindGroup) { return; } - const gridSize = 2 * range + 1; - const workgroupCount = Math.ceil(gridSize / 8); + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); const pass = encoder.beginComputePass(); pass.setPipeline(this.pipeline); pass.setBindGroup(0, this.bindGroup); - pass.dispatchWorkgroups(workgroupCount, workgroupCount, postsCount); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); pass.end(); - this.needsRebuild = false; + resources.clearDefendedFullRecompute(); } private rebuildBindGroup(): void { @@ -112,11 +110,11 @@ export class DefendedUpdatePass implements ComputePass { !this.device || !this.bindGroupLayout || !this.resources || - !this.resources.defenseParamsBuffer || - !this.resources.defensePostsBuffer || + !this.resources.defendedStrengthParamsBuffer || !this.resources.stateTexture || - !this.resources.defendedTexture || - this.resources.getDefensePostsCount() <= 0 + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer ) { this.bindGroup = null; return; @@ -127,26 +125,28 @@ export class DefendedUpdatePass implements ComputePass { entries: [ { binding: 0, - resource: { buffer: this.resources.defenseParamsBuffer }, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, }, { binding: 1, - resource: { buffer: this.resources.defensePostsBuffer }, - }, - { - binding: 2, resource: this.resources.stateTexture.createView(), }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, { binding: 3, - resource: this.resources.defendedTexture.createView(), + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 4, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, }, ], }); - } - markDirty(): void { - this.needsRebuild = true; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; } dispose(): void { diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts new file mode 100644 index 000000000..ae0034ce6 --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts @@ -0,0 +1,172 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Recomputes defended strength for a list of dirty tiles. + * Dirty tiles are produced when defense posts are added/removed/moved. + */ +export class DefendedStrengthPass implements ComputePass { + name = "defended-strength"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundDirtyTilesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const dirtyCount = resources.getDefendedDirtyTilesCount(); + if (dirtyCount === 0) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(dirtyCount); + + const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer; + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundDirtyTilesBuffer !== dirtyTilesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(dirtyCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + resources.clearDefendedDirtyTiles(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.defendedDirtyTilesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: { buffer: this.resources.defendedDirtyTilesBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts index f2d6e47a4..05dee89ff 100644 --- a/src/client/graphics/webgpu/compute/StateUpdatePass.ts +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -15,6 +15,8 @@ export class StateUpdatePass implements ComputePass { private device: GPUDevice | null = null; private resources: GroundTruthData | null = null; private readonly pendingTiles: Set = new Set(); + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; async init(device: GPUDevice, resources: GroundTruthData): Promise { this.device = device; @@ -28,13 +30,33 @@ export class StateUpdatePass implements ComputePass { { binding: 0, visibility: 4 /* COMPUTE */, - buffer: { type: "read-only-storage" }, + buffer: { type: "uniform" }, }, { binding: 1, visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, storageTexture: { format: "r32uint" }, }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, ], }); @@ -65,9 +87,8 @@ export class StateUpdatePass implements ComputePass { return; } - const oldBuffer = this.resources?.updatesBuffer; const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); - const bufferChanged = oldBuffer !== updatesBuffer; + resources.writeStateUpdateParamsBuffer(numUpdates); const staging = resources.getUpdatesStaging(); const state = resources.getState(); @@ -88,8 +109,13 @@ export class StateUpdatePass implements ComputePass { staging.subarray(0, numUpdates * 2), ); - // Rebuild bind group if buffer changed - if (bufferChanged) { + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { this.rebuildBindGroup(); } @@ -97,15 +123,12 @@ export class StateUpdatePass implements ComputePass { return; } - if (this.bindGroup) { - const pass = encoder.beginComputePass(); - pass.setPipeline(this.pipeline); - pass.setBindGroup(0, this.bindGroup); - // Dispatch with workgroup_size(64), so divide by 64 and round up - const workgroupCount = Math.ceil(numUpdates / 64); - pass.dispatchWorkgroups(workgroupCount); - pass.end(); - } + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(numUpdates / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); this.pendingTiles.clear(); } @@ -115,22 +138,46 @@ export class StateUpdatePass implements ComputePass { !this.device || !this.bindGroupLayout || !this.resources || + !this.resources.stateUpdateParamsBuffer || !this.resources.updatesBuffer || - !this.resources.stateTexture + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer ) { + this.bindGroup = null; return; } this.bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ - { binding: 0, resource: { buffer: this.resources.updatesBuffer } }, { - binding: 1, + binding: 0, + resource: { buffer: this.resources.stateUpdateParamsBuffer }, + }, + { binding: 1, resource: { buffer: this.resources.updatesBuffer } }, + { + binding: 2, resource: this.resources.stateTexture.createView(), }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, ], }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; } markTile(tile: number): void { diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 6485fa1ed..d27a05bee 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -16,28 +16,35 @@ function align(value: number, alignment: number): number { export class GroundTruthData { public static readonly PALETTE_RESERVED_SLOTS = 10; public static readonly PALETTE_FALLOUT_INDEX = 0; + private static readonly MAX_OWNER_SLOTS = 0x1000; // ownerId is 12 bits // Textures public readonly stateTexture: GPUTexture; public readonly terrainTexture: GPUTexture; public readonly terrainDataTexture: GPUTexture; public readonly paletteTexture: GPUTexture; - public readonly defendedTexture: GPUTexture; + public readonly defendedStrengthTexture: GPUTexture; // Buffers public readonly uniformBuffer: GPUBuffer; - public readonly defenseParamsBuffer: GPUBuffer; public readonly terrainParamsBuffer: GPUBuffer; + public readonly stateUpdateParamsBuffer: GPUBuffer; + public readonly defendedStrengthParamsBuffer: GPUBuffer; public updatesBuffer: GPUBuffer | null = null; - public defensePostsBuffer: GPUBuffer | null = null; + public readonly defenseOwnerOffsetsBuffer: GPUBuffer; + public defensePostsByOwnerBuffer: GPUBuffer; + public defendedDirtyTilesBuffer: GPUBuffer; // Staging arrays for buffer uploads private updatesStaging: Uint32Array | null = null; - private defensePostsStaging: Uint32Array | null = null; + private defenseOwnerOffsetsStaging: Uint32Array; + private defensePostsByOwnerStaging: Uint32Array | null = null; + private defendedDirtyTilesStaging: Uint32Array | null = null; // Buffer capacities private updatesCapacity = 0; - private defensePostsCapacity = 0; + private defensePostsByOwnerCapacity = 0; + private defendedDirtyTilesCapacity = 0; // State tracking private readonly mapWidth: number; @@ -49,13 +56,19 @@ export class GroundTruthData { private needsTerrainDataUpload = true; private needsTerrainParamsUpload = true; private paletteWidth = 1; - private defensePostsCount = 0; private needsDefensePostsUpload = true; + private defensePostsTotalCount = 0; + private defendedDirtyTilesCount = 0; + private needsFullDefendedStrengthRecompute = false; + private lastDefensePostKeys = new Set(); + private defenseCircleRange = -1; + private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] // Uniform data arrays private readonly uniformData = new Float32Array(12); - private readonly defenseParamsData = new Uint32Array(4); 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 // View state (updated by renderer) private viewWidth = 1; @@ -66,11 +79,6 @@ export class GroundTruthData { private alternativeView = false; private highlightedOwnerId = -1; - // Defense state - private defendedEpoch = 1; - private lastDefenseRange = -1; - private lastDefensePostsCount = -1; - private constructor( private readonly device: GPUDevice, private readonly game: GameView, @@ -99,8 +107,14 @@ export class GroundTruthData { usage: UNIFORM | COPY_DST_BUF, }); - // Defense params: 4x u32 = 16 bytes - this.defenseParamsBuffer = device.createBuffer({ + // State update params: 4x u32 = 16 bytes + this.stateUpdateParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Defended strength params: 4x u32 = 16 bytes + this.defendedStrengthParamsBuffer = device.createBuffer({ size: 16, usage: UNIFORM | COPY_DST_BUF, }); @@ -118,10 +132,10 @@ export class GroundTruthData { usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, }); - // Defended texture (r32uint) - this.defendedTexture = device.createTexture({ + // Defended strength texture (rgba8unorm, r channel used) + this.defendedStrengthTexture = device.createTexture({ size: { width: mapWidth, height: mapHeight }, - format: "r32uint", + format: "rgba8unorm", usage: TEXTURE_BINDING | STORAGE_BINDING, }); @@ -145,6 +159,28 @@ export class GroundTruthData { format: "r8uint", usage: COPY_DST_TEX | TEXTURE_BINDING, }); + + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + + // Defense posts data: ownerOffsets[ownerId] = {start, count}, postsByOwner[start..] = {x,y} + this.defenseOwnerOffsetsBuffer = device.createBuffer({ + size: GroundTruthData.MAX_OWNER_SLOTS * 8, + usage: STORAGE | COPY_DST_BUF, + }); + this.defenseOwnerOffsetsStaging = new Uint32Array( + GroundTruthData.MAX_OWNER_SLOTS * 2, + ); + + this.defensePostsByOwnerBuffer = device.createBuffer({ + size: 8, + usage: STORAGE | COPY_DST_BUF, + }); + + // Dirty tile indices to recompute defended strength when posts change + this.defendedDirtyTilesBuffer = device.createBuffer({ + size: 4 * 8, + usage: STORAGE | COPY_DST_BUF, + }); } static create( @@ -471,30 +507,83 @@ export class GroundTruthData { } this.needsDefensePostsUpload = false; + const range = this.game.config().defensePostRange(); const posts = this.collectDefensePosts(); - this.defensePostsCount = posts.length; + this.defensePostsTotalCount = posts.length; - if (this.defensePostsCount > 0) { - this.ensureDefensePostsBuffer(this.defensePostsCount); + // Diff posts to produce dirty tiles for recompute (include removed + added). + const nextKeys = new Set(); + for (const p of posts) { + nextKeys.add(`${p.ownerId},${p.x},${p.y}`); } - if ( - this.defensePostsCount > 0 && - this.defensePostsStaging && - this.defensePostsBuffer - ) { - for (let i = 0; i < this.defensePostsCount; i++) { - const p = posts[i]; - this.defensePostsStaging[i * 3] = p.x >>> 0; - this.defensePostsStaging[i * 3 + 1] = p.y >>> 0; - this.defensePostsStaging[i * 3 + 2] = p.ownerId >>> 0; + const changedPosts: Array<{ x: number; y: number }> = []; + for (const key of this.lastDefensePostKeys) { + if (!nextKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); } - this.device.queue.writeBuffer( - this.defensePostsBuffer, - 0, - this.defensePostsStaging.subarray(0, this.defensePostsCount * 3), - ); } + for (const key of nextKeys) { + if (!this.lastDefensePostKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + this.lastDefensePostKeys = nextKeys; + + // Pack posts by owner into GPU buffers. + this.packDefensePostsByOwner(posts); + + // Build dirty tiles around changed posts (so removals clear too). + this.buildDefendedDirtyTiles(changedPosts, range); + } + + getDefensePostsTotalCount(): number { + return this.defensePostsTotalCount; + } + + getDefendedDirtyTilesCount(): number { + return this.defendedDirtyTilesCount; + } + + needsDefendedFullRecompute(): boolean { + return this.needsFullDefendedStrengthRecompute; + } + + clearDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = false; + } + + clearDefendedDirtyTiles(): void { + this.defendedDirtyTilesCount = 0; + } + + writeStateUpdateParamsBuffer(updateCount: number): void { + this.stateUpdateParamsData[0] = updateCount >>> 0; + this.stateUpdateParamsData[1] = this.game.config().defensePostRange() >>> 0; + this.stateUpdateParamsData[2] = 0; + this.stateUpdateParamsData[3] = 0; + this.device.queue.writeBuffer( + this.stateUpdateParamsBuffer, + 0, + this.stateUpdateParamsData, + ); + } + + writeDefendedStrengthParamsBuffer(dirtyCount: number): void { + this.defendedStrengthParamsData[0] = dirtyCount >>> 0; + this.defendedStrengthParamsData[1] = + this.game.config().defensePostRange() >>> 0; + this.defendedStrengthParamsData[2] = 0; + this.defendedStrengthParamsData[3] = 0; + this.device.queue.writeBuffer( + this.defendedStrengthParamsBuffer, + 0, + this.defendedStrengthParamsData, + ); } private collectDefensePosts(): Array<{ @@ -518,8 +607,11 @@ export class GroundTruthData { return posts; } - private ensureDefensePostsBuffer(capacity: number): void { - if (this.defensePostsBuffer && capacity <= this.defensePostsCapacity) { + private ensureDefensePostsByOwnerBuffer(capacityPosts: number): void { + if ( + this.defensePostsByOwnerBuffer && + capacityPosts <= this.defensePostsByOwnerCapacity + ) { return; } @@ -527,24 +619,182 @@ export class GroundTruthData { const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; - this.defensePostsCapacity = Math.max( + this.defensePostsByOwnerCapacity = Math.max( 8, - Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacity)))), + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityPosts)))), ); - const bytesPerPost = 12; // 3 * u32 - const bufferSize = this.defensePostsCapacity * bytesPerPost; + const bytesPerPost = 8; // 2 * u32 (x,y) + const bufferSize = this.defensePostsByOwnerCapacity * bytesPerPost; - if (this.defensePostsBuffer) { - (this.defensePostsBuffer as any).destroy?.(); - } - - (this as any).defensePostsBuffer = this.device.createBuffer({ + (this.defensePostsByOwnerBuffer as any).destroy?.(); + this.defensePostsByOwnerBuffer = this.device.createBuffer({ size: bufferSize, usage: STORAGE | COPY_DST_BUF, }); - this.defensePostsStaging = new Uint32Array(this.defensePostsCapacity * 3); + this.defensePostsByOwnerStaging = new Uint32Array( + this.defensePostsByOwnerCapacity * 2, + ); + } + + private ensureDefendedDirtyTilesBuffer(capacityTiles: number): void { + if ( + this.defendedDirtyTilesBuffer && + capacityTiles <= this.defendedDirtyTilesCapacity + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defendedDirtyTilesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityTiles)))), + ); + + const bufferSize = this.defendedDirtyTilesCapacity * 4; // u32 per tile + + (this.defendedDirtyTilesBuffer as any).destroy?.(); + this.defendedDirtyTilesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defendedDirtyTilesStaging = new Uint32Array( + this.defendedDirtyTilesCapacity, + ); + } + + private packDefensePostsByOwner( + posts: Array<{ x: number; y: number; ownerId: number }>, + ): void { + // Reset counts + this.defenseOwnerOffsetsStaging.fill(0); + const counts = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + counts[owner]++; + } + + // Prefix sums into offsets (start,count) pairs. + let running = 0; + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + const count = counts[owner]; + this.defenseOwnerOffsetsStaging[owner * 2] = running; + this.defenseOwnerOffsetsStaging[owner * 2 + 1] = count; + running += count; + } + + this.ensureDefensePostsByOwnerBuffer(running); + if (!this.defensePostsByOwnerStaging) { + throw new Error("defensePostsByOwnerStaging not allocated"); + } + + const writeCursor = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + writeCursor[owner] = this.defenseOwnerOffsetsStaging[owner * 2]; + } + + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + const idx = writeCursor[owner]++; + this.defensePostsByOwnerStaging[idx * 2] = p.x >>> 0; + this.defensePostsByOwnerStaging[idx * 2 + 1] = p.y >>> 0; + } + + this.device.queue.writeBuffer( + this.defenseOwnerOffsetsBuffer, + 0, + this.defenseOwnerOffsetsStaging, + ); + if (running > 0) { + this.device.queue.writeBuffer( + this.defensePostsByOwnerBuffer, + 0, + this.defensePostsByOwnerStaging.subarray(0, running * 2), + ); + } + } + + private ensureDefenseCircleOffsets(range: number): void { + if (range === this.defenseCircleRange) { + return; + } + this.defenseCircleRange = range; + if (range <= 0) { + this.defenseCircleOffsets = new Int16Array(0); + return; + } + + const offsets: number[] = []; + const r2 = range * range; + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { + if (dx * dx + dy * dy <= r2) { + offsets.push(dx, dy); + } + } + } + this.defenseCircleOffsets = new Int16Array(offsets); + } + + private buildDefendedDirtyTiles( + changedPosts: Array<{ x: number; y: number }>, + range: number, + ): void { + if (changedPosts.length === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + this.ensureDefenseCircleOffsets(range); + const offsets = this.defenseCircleOffsets; + const offsetsCount = offsets.length / 2; + if (offsetsCount === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + const worstCase = changedPosts.length * offsetsCount; + const mapTiles = this.mapWidth * this.mapHeight; + if (worstCase > mapTiles) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = true; + return; + } + + this.needsFullDefendedStrengthRecompute = false; + this.ensureDefendedDirtyTilesBuffer(worstCase); + if (!this.defendedDirtyTilesStaging) { + throw new Error("defendedDirtyTilesStaging not allocated"); + } + + let cursor = 0; + for (const post of changedPosts) { + for (let i = 0; i < offsets.length; i += 2) { + const x = post.x + offsets[i]; + const y = post.y + offsets[i + 1]; + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + continue; + } + this.defendedDirtyTilesStaging[cursor++] = + (y * this.mapWidth + x) >>> 0; + } + } + + this.defendedDirtyTilesCount = cursor; + this.device.queue.writeBuffer( + this.defendedDirtyTilesBuffer, + 0, + this.defendedDirtyTilesStaging.subarray(0, cursor), + ); } ensureUpdatesBuffer(capacity: number): GPUBuffer { @@ -566,13 +816,14 @@ export class GroundTruthData { (this.updatesBuffer as any).destroy?.(); } - (this as any).updatesBuffer = this.device.createBuffer({ + const buffer = this.device.createBuffer({ size: bufferSize, usage: STORAGE | COPY_DST_BUF, }); + (this as any).updatesBuffer = buffer; this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); - return this.updatesBuffer; + return buffer; } getUpdatesStaging(): Uint32Array { @@ -601,54 +852,10 @@ export class GroundTruthData { this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); } - writeDefenseParamsBuffer(): void { - const range = this.game.config().defensePostRange() >>> 0; - this.defenseParamsData[0] = range; - this.defenseParamsData[1] = this.defensePostsCount >>> 0; - this.defenseParamsData[2] = this.defendedEpoch >>> 0; - this.defenseParamsData[3] = 0; - this.device.queue.writeBuffer( - this.defenseParamsBuffer, - 0, - this.defenseParamsData, - ); - } - // ===================== // State getters/setters // ===================== - getDefendedEpoch(): number { - return this.defendedEpoch; - } - - incrementDefendedEpoch(): void { - this.defendedEpoch = (this.defendedEpoch + 1) >>> 0; - if (this.defendedEpoch === 0) { - this.defendedEpoch = 1; - } - } - - getDefensePostsCount(): number { - return this.defensePostsCount; - } - - getLastDefenseRange(): number { - return this.lastDefenseRange; - } - - setLastDefenseRange(range: number): void { - this.lastDefenseRange = range; - } - - getLastDefensePostsCount(): number { - return this.lastDefensePostsCount; - } - - setLastDefensePostsCount(count: number): void { - this.lastDefensePostsCount = count; - } - markPaletteDirty(): void { this.needsPaletteUpload = true; } diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts index 41249ba49..b5d30bc86 100644 --- a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -42,25 +42,20 @@ export class TerritoryRenderPass implements RenderPass { { binding: 1, visibility: 2 /* FRAGMENT */, - buffer: { type: "uniform" }, + texture: { sampleType: "uint" }, }, { binding: 2, visibility: 2 /* FRAGMENT */, - texture: { sampleType: "uint" }, + texture: { sampleType: "float" }, }, { binding: 3, visibility: 2 /* FRAGMENT */, - texture: { sampleType: "uint" }, - }, - { - binding: 4, - visibility: 2 /* FRAGMENT */, texture: { sampleType: "float" }, }, { - binding: 5, + binding: 4, visibility: 2 /* FRAGMENT */, texture: { sampleType: "float" }, }, @@ -112,7 +107,6 @@ export class TerritoryRenderPass implements RenderPass { // Update uniforms resources.writeUniformBuffer(performance.now() / 1000); - resources.writeDefenseParamsBuffer(); const pass = encoder.beginRenderPass({ colorAttachments: [ @@ -142,9 +136,8 @@ export class TerritoryRenderPass implements RenderPass { !this.bindGroupLayout || !this.resources || !this.resources.uniformBuffer || - !this.resources.defenseParamsBuffer || !this.resources.stateTexture || - !this.resources.defendedTexture || + !this.resources.defendedStrengthTexture || !this.resources.paletteTexture || !this.resources.terrainTexture ) { @@ -157,22 +150,18 @@ export class TerritoryRenderPass implements RenderPass { { binding: 0, resource: { buffer: this.resources.uniformBuffer } }, { binding: 1, - resource: { buffer: this.resources.defenseParamsBuffer }, - }, - { - binding: 2, resource: this.resources.stateTexture.createView(), }, { - binding: 3, - resource: this.resources.defendedTexture.createView(), + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), }, { - binding: 4, + binding: 3, resource: this.resources.paletteTexture.createView(), }, { - binding: 5, + binding: 4, resource: this.resources.terrainTexture.createView(), }, ], diff --git a/src/client/graphics/webgpu/shaders/common/uniforms.wgsl b/src/client/graphics/webgpu/shaders/common/uniforms.wgsl deleted file mode 100644 index d60f9986f..000000000 --- a/src/client/graphics/webgpu/shaders/common/uniforms.wgsl +++ /dev/null @@ -1,12 +0,0 @@ -struct Uniforms { - mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec - viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId - viewSize_pad: vec4f, // x=viewW, y=viewH, z/w unused -}; - -struct DefenseParams { - range: u32, - postCount: u32, - epoch: u32, - _pad: u32, -}; diff --git a/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl deleted file mode 100644 index 682cc4786..000000000 --- a/src/client/graphics/webgpu/shaders/compute/defended-clear.wgsl +++ /dev/null @@ -1,12 +0,0 @@ -@group(0) @binding(0) var defendedTex: texture_storage_2d; - -@compute @workgroup_size(8, 8) -fn main(@builtin(global_invocation_id) globalId: vec3) { - let dims = textureDimensions(defendedTex); - let x = i32(globalId.x); - let y = i32(globalId.y); - if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { - return; - } - textureStore(defendedTex, vec2i(x, y), vec4u(0u, 0u, 0u, 0u)); -} diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl new file mode 100644 index 000000000..0cea311a2 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl @@ -0,0 +1,65 @@ +struct Params { + _dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(3) var ownerOffsets: array; +@group(0) @binding(4) var postsByOwner: array; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let dims = textureDimensions(stateTex); + if (globalId.x >= dims.x || globalId.y >= dims.y) { + return; + } + + let x = i32(globalId.x); + let y = i32(globalId.y); + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl new file mode 100644 index 000000000..828392ce0 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl @@ -0,0 +1,69 @@ +struct Params { + dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var dirtyTiles: array; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.dirtyCount) { + return; + } + + let tileIndex = dirtyTiles[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(tileIndex % mapWidth); + let y = i32(tileIndex / mapWidth); + + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl deleted file mode 100644 index 0d3780661..000000000 --- a/src/client/graphics/webgpu/shaders/compute/defended-update.wgsl +++ /dev/null @@ -1,53 +0,0 @@ -struct DefenseParams { - range: u32, - postCount: u32, - epoch: u32, - _pad: u32, -}; - -struct DefensePost { - x: u32, - y: u32, - ownerId: u32, -}; - -@group(0) @binding(0) var d: DefenseParams; -@group(0) @binding(1) var posts: array; -@group(0) @binding(2) var stateTex: texture_2d; -@group(0) @binding(3) var defendedTex: texture_storage_2d; - -@compute @workgroup_size(8, 8, 1) -fn main(@builtin(global_invocation_id) globalId: vec3) { - let postIdx = globalId.z; - let postCount = d.postCount; - if (postIdx >= postCount) { - return; - } - - let range = i32(d.range); - if (range < 0) { - return; - } - - let dx = i32(globalId.x) - range; - let dy = i32(globalId.y) - range; - if (dx * dx + dy * dy > range * range) { - return; - } - - let post = posts[postIdx]; - let x = i32(post.x) + dx; - let y = i32(post.y) + dy; - - let dims = textureDimensions(stateTex); - if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { - return; - } - - let texCoord = vec2i(x, y); - let state = textureLoad(stateTex, texCoord, 0).x; - let owner = state & 0xFFFu; - if (owner == post.ownerId) { - textureStore(defendedTex, texCoord, vec4u(d.epoch, 0u, 0u, 0u)); - } -} diff --git a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl index 8fb0821a5..dec940fc9 100644 --- a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -3,13 +3,24 @@ struct Update { newState: u32, }; -@group(0) @binding(0) var updates: array; -@group(0) @binding(1) var stateTex: texture_storage_2d; +struct Params { + updateCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var updates: array; +@group(0) @binding(2) var stateTex: texture_storage_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) globalId: vec3) { let idx = globalId.x; - if (idx >= arrayLength(&updates)) { + if (idx >= p.updateCount) { return; } let update = updates[idx]; @@ -18,4 +29,45 @@ fn main(@builtin(global_invocation_id) globalId: vec3) { let x = i32(update.tileIndex % mapWidth); let y = i32(update.tileIndex / mapWidth); textureStore(stateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); + + // Update defended strength for this tile based on the new owner. + let owner = update.newState & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); } diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl index 591b59941..5f1d7c334 100644 --- a/src/client/graphics/webgpu/shaders/render/territory.wgsl +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -4,19 +4,11 @@ struct Uniforms { viewSize_pad: vec4f, // x=viewW, y=viewH, z/w unused }; -struct DefenseParams { - range: u32, - postCount: u32, - epoch: u32, - _pad: u32, -}; - @group(0) @binding(0) var u: Uniforms; -@group(0) @binding(1) var d: DefenseParams; -@group(0) @binding(2) var stateTex: texture_2d; -@group(0) @binding(3) var defendedTex: texture_2d; -@group(0) @binding(4) var paletteTex: texture_2d; -@group(0) @binding(5) var terrainTex: texture_2d; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; @vertex fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { @@ -56,15 +48,17 @@ fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { let hasFallout = (state & 0x2000u) != 0u; let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; var outColor = terrain; if (owner != 0u) { // Player colors start at index 10 let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); - let defended = textureLoad(defendedTex, texCoord, 0).x == d.epoch; var territoryRgb = c.rgb; - if (defended) { - territoryRgb = mix(territoryRgb, vec3f(1.0, 0.0, 1.0), 0.35); - } + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + clamp(0.35 * defendedStrength, 0.0, 0.35), + ); if (hasFallout) { // Fallout color is at index 0 let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb;