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
This commit is contained in:
scamiv
2026-01-17 23:50:39 +01:00
parent 655bb211e1
commit 97603f7a1a
14 changed files with 806 additions and 456 deletions
+20 -83
View File
@@ -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()]);
}
@@ -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<void> {
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;
}
}
@@ -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<void> {
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 {
@@ -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<void> {
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;
}
}
@@ -15,6 +15,8 @@ export class StateUpdatePass implements ComputePass {
private device: GPUDevice | null = null;
private resources: GroundTruthData | null = null;
private readonly pendingTiles: Set<number> = new Set();
private boundUpdatesBuffer: GPUBuffer | null = null;
private boundPostsByOwnerBuffer: GPUBuffer | null = null;
async init(device: GPUDevice, resources: GroundTruthData): Promise<void> {
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 {
@@ -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<string>();
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<string>();
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;
}
@@ -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(),
},
],
@@ -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,
};
@@ -1,12 +0,0 @@
@group(0) @binding(0) var defendedTex: texture_storage_2d<r32uint, write>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
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));
}
@@ -0,0 +1,65 @@
struct Params {
_dirtyCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var stateTex: texture_2d<u32>;
@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(4) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
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));
}
@@ -0,0 +1,69 @@
struct Params {
dirtyCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var<storage, read> dirtyTiles: array<u32>;
@group(0) @binding(2) var stateTex: texture_2d<u32>;
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
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));
}
@@ -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<uniform> d: DefenseParams;
@group(0) @binding(1) var<storage, read> posts: array<DefensePost>;
@group(0) @binding(2) var stateTex: texture_2d<u32>;
@group(0) @binding(3) var defendedTex: texture_storage_2d<r32uint, write>;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
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));
}
}
@@ -3,13 +3,24 @@ struct Update {
newState: u32,
};
@group(0) @binding(0) var<storage, read> updates: array<Update>;
@group(0) @binding(1) var stateTex: texture_storage_2d<r32uint, write>;
struct Params {
updateCount: u32,
range: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var<storage, read> updates: array<Update>;
@group(0) @binding(2) var stateTex: texture_storage_2d<r32uint, write>;
@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(4) var<storage, read> ownerOffsets: array<vec2u>;
@group(0) @binding(5) var<storage, read> postsByOwner: array<vec2u>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
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<u32>) {
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));
}
@@ -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<uniform> u: Uniforms;
@group(0) @binding(1) var<uniform> d: DefenseParams;
@group(0) @binding(2) var stateTex: texture_2d<u32>;
@group(0) @binding(3) var defendedTex: texture_2d<u32>;
@group(0) @binding(4) var paletteTex: texture_2d<f32>;
@group(0) @binding(5) var terrainTex: texture_2d<f32>;
@group(0) @binding(1) var stateTex: texture_2d<u32>;
@group(0) @binding(2) var defendedStrengthTex: texture_2d<f32>;
@group(0) @binding(3) var paletteTex: texture_2d<f32>;
@group(0) @binding(4) var terrainTex: texture_2d<f32>;
@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;