Scale defense-post border + fill rendering to thousands of posts (#4181)

## Description

Scales the defense-post border effect so it works with **thousands** of
Defense Posts instead of silently capping at 64.

### Problem
The border "checkerboard" (drawn on a player's border tiles when a
same-owner Defense Post is within range) was computed per-pixel: for
every border fragment, the shader looped over a `uniform vec4
uDefensePosts[64]` array doing a distance test. Two issues:
- **Hard cap of 64** — posts beyond the first 64 were dropped, so their
checkerboard never appeared.
- **Wrong cost shape** — work was `border_tiles × posts`; every added
post made every border pixel slower.

### Solution: invert the loop into a coverage texture
New `DefenseCoveragePass` stamps one instanced circle per post into a
map-resolution `R8` coverage texture (`1.0` = tile is within range of a
**same-owner** post; the owner check samples `tileTex` at stamp time, so
enemy posts never light up your border). It's a single
`drawArraysInstanced` regardless of post count — the same instancing
pattern `UnitPass`/`StructurePass` already use. The border-stamp shader
now reads one texel of that texture instead of looping; the old uniform
array, the 64-cap, and the per-fragment scan are removed from
`border-compute`/`BorderStampPass`/`BorderScatterPass`.

### Incremental re-stamping (dirty-block grid)
Coverage depends on tile ownership, which drips every frame during
combat, so a full re-stamp every frame would be wasteful at high post
counts. Because a tile changing owner only changes *its own* coverage,
the pass tracks a grid of dirty **blocks** and re-stamps only the blocks
containing changed tiles, scissored to each block (`gl.scissor` confines
the clear + draw to the changed region). Post add/remove and full tile
uploads fall back to a whole-map stamp; so does a frame where most
blocks are dirty. Per-frame cost tracks *how much changed*, not *how
many posts exist*, and scattered fronts (e.g. opposite corners) become
independent small block draws.

### Territory-fill darkening
The coverage texture marks every same-owner in-range tile (interior
included, not just borders), so `TerritoryPass` now also samples it to
darken the territory **fill** around posts. New tunable
`mapOverlay.territoryDefenseDarken` (live-editable in the graphics debug
GUI alongside `defenseCheckerDarken`).

### Performance
Tested with ~1,000 posts blanketing a map — smooth, including on a
low-end (~10-year-old) Chromebook.

## Files
- **New:** `passes/DefenseCoveragePass.ts`,
`shaders/defense-coverage/defense-coverage.{vert,frag}.glsl`
- **Edited:** `Renderer.ts`, `BorderStampPass.ts`,
`BorderComputePass.ts`, `BorderScatterPass.ts`, `TerritoryPass.ts`,
`border-stamp.frag.glsl`, `border-compute.frag.glsl`,
`territory.frag.glsl`, `RenderSettings.ts`, `render-settings.json`,
`debug/Layout.ts`

## Notes
- No user-facing text (no `translateText`/`en.json` changes needed).
- No `src/core` changes — purely client rendering, so no simulation
tests; verified via `tsc`, ESLint, `build-prod`, and in-game.
This commit is contained in:
Evan
2026-06-08 10:18:02 -07:00
committed by GitHub
parent 8a510977ba
commit 26d8a314ae
14 changed files with 442 additions and 98 deletions
+1
View File
@@ -68,6 +68,7 @@ export interface RenderSettings {
mapOverlay: {
trailAlpha: number;
defenseCheckerDarken: number;
territoryDefenseDarken: number;
staleNukeBase: number;
staleNukeVariation: number;
staleNukeAlpha: number;
+34 -4
View File
@@ -33,6 +33,7 @@ import { BorderComputePass } from "./passes/BorderComputePass";
import { BorderStampPass } from "./passes/BorderStampPass";
import { CoordinateGridPass } from "./passes/CoordinateGridPass";
import { CrosshairPass } from "./passes/CrosshairPass";
import { DefenseCoveragePass } from "./passes/DefenseCoveragePass";
import { FalloutBloomPass } from "./passes/FalloutBloomPass";
import { FalloutLightPass } from "./passes/FalloutLightPass";
import { FxPass } from "./passes/fx-pass";
@@ -101,6 +102,7 @@ export class GPURenderer {
private trailPass: TrailPass;
private borderStampPass: BorderStampPass;
private borderPass: BorderComputePass;
private defenseCoveragePass: DefenseCoveragePass;
private bloomPass: FalloutBloomPass;
private pointLightPass: PointLightPass;
private falloutLightPass: FalloutLightPass;
@@ -308,6 +310,17 @@ export class GPURenderer {
);
this.res.borderTex = this.borderPass.getBorderTex();
// --- Defense coverage (needs tileTex) — per-tile "defended by same-owner
// post" flag, stamped one instanced circle per post. Replaces the old
// 64-cap uniform loop; consumed by BorderStampPass. ---
this.defenseCoveragePass = new DefenseCoveragePass(
gl,
mapW,
mapH,
this.res.tileTex,
this.settings,
);
// --- Heat manager (needs tileTex, heatTexA/B) ---
this.heatManager = new HeatManager(
gl,
@@ -334,10 +347,19 @@ export class GPURenderer {
this.settings,
);
// Route per-tile changes to the border pass so it can scatter-recompute
// just the affected tiles instead of rebuilding the whole map.
this.territoryPass.setBorderPatchConsumer((x, y) =>
this.borderPass.patchTile(x, y),
// just the affected tiles instead of rebuilding the whole map. A tile
// changing owner can also flip its defense-coverage flag (same-owner test),
// so mark the coverage stale too — one coalesced re-stamp happens per frame.
this.territoryPass.setBorderPatchConsumer((x, y) => {
this.borderPass.patchTile(x, y);
this.defenseCoveragePass.markTileDirty(x, y);
});
// Territory fill darkens on interior tiles defended by a same-owner post;
// borderTex lets the fill skip border tiles (those get the checkerboard).
this.territoryPass.setDefenseCoverageTex(
this.defenseCoveragePass.getCoverageTex(),
);
this.territoryPass.setBorderTex(this.res.borderTex);
// --- Spawn overlay (needs tileTex) ---
this.spawnOverlayPass = new SpawnOverlayPass(
@@ -368,6 +390,9 @@ export class GPURenderer {
this.res.borderTex,
this.settings,
);
this.borderStampPass.setDefenseCoverageTex(
this.defenseCoveragePass.getCoverageTex(),
);
// --- Fallout bloom (needs tileTex, heatManager) ---
this.bloomPass = new FalloutBloomPass(
@@ -822,7 +847,7 @@ export class GPURenderer {
});
}
}
this.borderPass.updateDefensePosts(posts);
this.defenseCoveragePass.updateDefensePosts(posts);
}
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
@@ -1203,6 +1228,7 @@ export class GPURenderer {
// pushed per-tile border patches via the wired `borderPatchConsumer`.
if (this.territoryPass.flushTileTexture() === "full") {
this.borderPass.markGlobalDirty();
this.defenseCoveragePass.markDirty();
}
this.trailPass.flushTexture();
this.heatManager.updateHeat();
@@ -1210,6 +1236,9 @@ export class GPURenderer {
private computeTextures(): void {
if (this.settings.passEnabled.borderCompute) this.borderPass.draw();
// Re-stamp defense coverage if posts/territory changed (dirty-gated).
// Leaves the default framebuffer bound; renderFrame resets the viewport.
this.defenseCoveragePass.draw();
}
private renderFrame(): void {
@@ -1328,6 +1357,7 @@ export class GPURenderer {
this.trailPass.dispose();
this.borderStampPass.dispose();
this.borderPass.dispose();
this.defenseCoveragePass.dispose();
this.bloomPass.dispose();
this.pointLightPass.dispose();
this.falloutLightPass.dispose();
+1
View File
@@ -122,6 +122,7 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
folder("Map Overlay", [
slider(s.mapOverlay, "trailAlpha", d.mapOverlay, 0, 1, 0.01),
slider(s.mapOverlay, "defenseCheckerDarken", d.mapOverlay, 0, 1, 0.01),
slider(s.mapOverlay, "territoryDefenseDarken", d.mapOverlay, 0, 1, 0.01),
slider(s.mapOverlay, "staleNukeBase", d.mapOverlay, 0, 0.3, 0.005),
slider(s.mapOverlay, "staleNukeVariation", d.mapOverlay, 0, 0.3, 0.005),
slider(s.mapOverlay, "staleNukeAlpha", d.mapOverlay, 0, 1, 0.01),
@@ -5,7 +5,7 @@
* RGBA8 texture:
* R = border type: 0 = interior, 0.5 = normal border, 1.0 = highlight border
* G = unused (was ember intensity — moved to FalloutBloomPass/FalloutLightPass)
* B = defense proximity: 1.0 if border tile is within range of same-owner defense post
* B = unused (was defense proximity — now computed per-tile by DefenseCoveragePass)
*
* Both MapOverlayPass (daytime) and the night stamp overlay read this buffer
* instead of independently computing neighbor checks. Border thickening is
@@ -24,8 +24,6 @@ import {
import { TILE_DEFINES } from "../utils/TileCodec";
import { BorderScatterPass } from "./BorderScatterPass";
const MAX_DEFENSE_POSTS = 64;
/** Max player smallID supported by the relationship texture. */
const RELATION_TEX_SIZE = 1024;
@@ -49,22 +47,15 @@ export class BorderComputePass {
private uMapSize: WebGLUniformLocation;
private uHighlightOwner: WebGLUniformLocation;
private uHighlightThicken: WebGLUniformLocation;
private uDefensePosts: WebGLUniformLocation;
private uDefensePostCount: WebGLUniformLocation;
private uDefensePostRange: WebGLUniformLocation;
private highlightOwner = 0;
/**
* True when something that affects ALL borders (highlight owner, relation
* matrix, defense posts) has changed since the last draw. Forces a full
* recompute next frame. Starts true so the first frame computes.
* matrix) has changed since the last draw. Forces a full recompute next
* frame. Starts true so the first frame computes.
*/
private globalDirty = true;
/** Packed defense post data: [x, y, ownerID, 0, x, y, ownerID, 0, ...] */
private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4);
private defensePostCount = 0;
/** Incremental per-tile recompute. Used between full recomputes. */
private scatter!: BorderScatterPass;
@@ -83,7 +74,7 @@ export class BorderComputePass {
this.program = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }),
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES }),
);
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
@@ -95,15 +86,6 @@ export class BorderComputePass {
this.program,
"uHighlightThicken",
)!;
this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!;
this.uDefensePostCount = gl.getUniformLocation(
this.program,
"uDefensePostCount",
)!;
this.uDefensePostRange = gl.getUniformLocation(
this.program,
"uDefensePostRange",
)!;
// Texture unit binding
gl.useProgram(this.program);
@@ -195,23 +177,6 @@ export class BorderComputePass {
this.globalDirty = true;
}
/** Update defense post positions for checkerboard proximity. */
updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void {
const count = Math.min(posts.length, MAX_DEFENSE_POSTS);
const data = this.defensePostData;
for (let i = 0; i < count; i++) {
const p = posts[i];
const off = i * 4;
data[off] = p.x;
data[off + 1] = p.y;
data[off + 2] = p.ownerID;
data[off + 3] = 0;
}
this.defensePostCount = count;
this.scatter.setDefensePostData(data, count);
this.globalDirty = true;
}
/**
* Force a full recompute next draw. Use this when tile state has been
* replaced wholesale (initial load, seek) — individual `patchTile` calls
@@ -238,8 +203,8 @@ export class BorderComputePass {
/**
* Update border flags for the current frame. Either a full recompute (when
* globalDirty is set by highlight/relation/defense-post changes) or a
* scatter of the per-tile patches queued via `patchTile`.
* globalDirty is set by highlight/relation changes) or a scatter of the
* per-tile patches queued via `patchTile`.
*
* Exit GL state:
* - Full recompute path: `borderFbo` is still bound; viewport at map size.
@@ -263,9 +228,6 @@ export class BorderComputePass {
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1ui(this.uHighlightOwner, this.highlightOwner);
gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken));
gl.uniform4fv(this.uDefensePosts, this.defensePostData);
gl.uniform1i(this.uDefensePostCount, this.defensePostCount);
gl.uniform1f(this.uDefensePostRange, mo.defensePostRange);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._tileTex);
@@ -26,7 +26,6 @@ import { TILE_DEFINES } from "../utils/TileCodec";
import borderComputeFragSrc from "../shaders/border-compute/border-compute.frag.glsl?raw";
import borderScatterVertSrc from "../shaders/border-compute/border-scatter.vert.glsl?raw";
const MAX_DEFENSE_POSTS = 64;
const FLOATS_PER_PATCH = 2;
const INITIAL_CAPACITY = 4096;
@@ -42,9 +41,6 @@ export class BorderScatterPass {
private uMapSize: WebGLUniformLocation;
private uHighlightOwner: WebGLUniformLocation;
private uHighlightThicken: WebGLUniformLocation;
private uDefensePosts: WebGLUniformLocation;
private uDefensePostCount: WebGLUniformLocation;
private uDefensePostRange: WebGLUniformLocation;
private fbo: WebGLFramebuffer;
private vao: WebGLVertexArrayObject;
@@ -52,8 +48,6 @@ export class BorderScatterPass {
// Mirrored from BorderComputePass — set via setters when those change.
private highlightOwner = 0;
private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4);
private defensePostCount = 0;
/** CPU-side patch buffer: [x, y, x, y, …]. */
private patchData: Float32Array;
@@ -80,7 +74,7 @@ export class BorderScatterPass {
this.program = createProgram(
gl,
borderScatterVertSrc,
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }),
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES }),
);
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
@@ -92,15 +86,6 @@ export class BorderScatterPass {
this.program,
"uHighlightThicken",
)!;
this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!;
this.uDefensePostCount = gl.getUniformLocation(
this.program,
"uDefensePostCount",
)!;
this.uDefensePostRange = gl.getUniformLocation(
this.program,
"uDefensePostRange",
)!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
@@ -158,12 +143,6 @@ export class BorderScatterPass {
this.highlightOwner = owner;
}
setDefensePostData(data: Float32Array, count: number): void {
// Caller may mutate the source array; copy to keep ours stable.
this.defensePostData.set(data.subarray(0, MAX_DEFENSE_POSTS * 4));
this.defensePostCount = count;
}
flush(): void {
if (this.patchCount === 0) return;
const gl = this.gl;
@@ -190,9 +169,6 @@ export class BorderScatterPass {
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1ui(this.uHighlightOwner, this.highlightOwner);
gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken));
gl.uniform4fv(this.uDefensePosts, this.defensePostData);
gl.uniform1i(this.uDefensePostCount, this.defensePostCount);
gl.uniform1f(this.uDefensePostRange, mo.defensePostRange);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
@@ -35,6 +35,7 @@ export class BorderStampPass {
private tileTex: WebGLTexture;
private paletteTex: WebGLTexture;
private borderTex: WebGLTexture;
private defenseCoverageTex: WebGLTexture | null = null;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
@@ -90,6 +91,7 @@ export class BorderStampPass {
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uDefenseCoverageTex"), 4);
this.vao = createMapQuad(gl, mapW, mapH);
}
@@ -100,6 +102,9 @@ export class BorderStampPass {
setAffiliationTex(tex: WebGLTexture): void {
this.affiliationTex = tex;
}
setDefenseCoverageTex(tex: WebGLTexture): void {
this.defenseCoverageTex = tex;
}
/** Draw borders + defense checkerboard. Blending must be enabled. */
draw(cameraMatrix: Float32Array): void {
@@ -137,6 +142,10 @@ export class BorderStampPass {
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
if (this.defenseCoverageTex) {
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.defenseCoverageTex);
}
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -0,0 +1,280 @@
/**
* DefenseCoveragePass — per-tile "is this tile defended by a same-owner Defense
* Post?" flag, computed by stamping one instanced circle per post.
*
* Replaces the old per-fragment scan (border-compute looped over a uniform array
* of up to 64 posts for every border tile). Here we invert the loop: each post
* draws a filled circle of its range into a map-resolution R8 texture, writing
* 1.0 on tiles it owns and within range. Cost is O(posts × circle area) with no
* cap on post count, and it's a single instanced draw call regardless of how
* many posts exist — the same pattern UnitPass/StructurePass already use.
*
* BorderStampPass samples the resulting `coverageTex` (one texel per border
* tile) instead of the old uniform loop. The texture marks every same-owner
* in-range tile, interior included — so a future PR can darken the territory
* fill by sampling the same texture in TerritoryPass.
*
* The result depends on tile ownership (the same-owner test), so coverage must
* be re-stamped whenever posts OR territory change. Territory drips every frame
* during combat, so a full map re-stamp every frame would be wasteful at high
* post counts. Instead we track a grid of dirty BLOCKs: a tile changing owner
* only changes its own coverage, so we recompute just the blocks containing
* changed tiles, scissored to each block (clear the block, redraw the posts —
* the scissor confines the GPU work to the changed region). A post add/remove
* or full tile upload sets `fullDirty` for one whole-map stamp; if too many
* blocks are dirty we fall back to a full stamp too.
*
* Exit GL state: default framebuffer bound; viewport left at map size; scissor
* test disabled. The caller (Renderer.renderFrame) rebinds framebuffer +
* viewport before screen draws.
*/
import { DynamicInstanceBuffer } from "../DynamicBuffer";
import type { RenderSettings } from "../RenderSettings";
import coverageFragSrc from "../shaders/defense-coverage/defense-coverage.frag.glsl?raw";
import coverageVertSrc from "../shaders/defense-coverage/defense-coverage.vert.glsl?raw";
import { createProgram, createTexture2D, shaderSrc } from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
/** Per-instance data (3 floats): tileX, tileY, ownerID. */
const FLOATS_PER_INSTANCE = 3;
/**
* Tile block size for incremental scissored re-stamping. ~2× the post diameter
* (range ≈ 30, so circles ≈ 60 wide): small enough to confine work to the
* changed region, large enough to keep the per-block draw-call count low.
*/
const BLOCK = 128;
export class DefenseCoveragePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
private program: WebGLProgram;
private uMapSize: WebGLUniformLocation;
private uRange: WebGLUniformLocation;
private coverageTex: WebGLTexture;
private fbo: WebGLFramebuffer;
private quadBuf: WebGLBuffer;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private count = 0;
// --- Dirty tracking (block grid) ---
private blocksX: number;
private blocksY: number;
/** Re-stamp the whole map next draw. Starts true so the first frame computes. */
private fullDirty = true;
/** Per-block dirty flag (0/1), indexed by blockY * blocksX + blockX. */
private dirtyBlock: Uint8Array;
/** Indices of currently-dirty blocks (to iterate + reset without scanning). */
private dirtyList: number[] = [];
/** Above this many dirty blocks, a single full stamp is cheaper. */
private fullFallback: number;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.blocksX = Math.ceil(mapW / BLOCK);
this.blocksY = Math.ceil(mapH / BLOCK);
this.dirtyBlock = new Uint8Array(this.blocksX * this.blocksY);
// Past ~half the blocks, one full stamp beats many scissored block draws.
this.fullFallback = Math.floor((this.blocksX * this.blocksY) / 2);
this.program = createProgram(
gl,
coverageVertSrc,
shaderSrc(coverageFragSrc, { OWNER_MASK: TILE_DEFINES.OWNER_MASK }),
);
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uRange = gl.getUniformLocation(this.program, "uRange")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
// --- R8 coverage texture at tile resolution + its FBO ---
this.coverageTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.R8,
format: gl.RED,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
this.fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.coverageTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// --- Shared unit quad [0,1]² ---
this.quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
// --- Per-post instance buffer + VAO ---
const instGlBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
instGlBuf,
256,
FLOATS_PER_INSTANCE,
);
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, instGlBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, FLOATS_PER_INSTANCE * 4, 0);
gl.vertexAttribDivisor(1, 1);
gl.bindVertexArray(null);
}
/** Replace the set of defense posts. No cap. */
updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void {
this.count = posts.length;
this.instanceBuf.ensureCapacity(posts.length);
const f = this.instanceBuf.float32;
for (let i = 0; i < posts.length; i++) {
const p = posts[i];
const off = i * FLOATS_PER_INSTANCE;
f[off] = p.x;
f[off + 1] = p.y;
f[off + 2] = p.ownerID;
}
if (posts.length > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
posts.length * FLOATS_PER_INSTANCE,
);
}
// A post appearing/disappearing affects its whole circle (possibly several
// blocks); post-set changes are rare, so just re-stamp the whole map.
this.fullDirty = true;
}
/**
* Mark the block containing tile (x, y) stale. Call when a tile changed owner
* — the same-owner test means only that tile's own coverage can flip, so just
* its block needs recomputing. Coalesced: the block is re-stamped once in the
* next draw() regardless of how many of its tiles changed.
*/
markTileDirty(x: number, y: number): void {
const bx = (x / BLOCK) | 0;
const by = (y / BLOCK) | 0;
const b = by * this.blocksX + bx;
if (this.dirtyBlock[b] === 0) {
this.dirtyBlock[b] = 1;
this.dirtyList.push(b);
}
}
/** Force a whole-map re-stamp next draw (full tile upload / seek). */
markDirty(): void {
this.fullDirty = true;
}
/** The R8 coverage texture (1.0 = tile is defended by a same-owner post). */
getCoverageTex(): WebGLTexture {
return this.coverageTex;
}
/**
* Re-stamp coverage if anything changed. Either a whole-map stamp (fullDirty,
* or too many blocks dirty) or a scissored clear+stamp per dirty block.
*
* Exit GL state: default framebuffer bound; scissor test disabled; viewport
* left at map size (caller resets before screen draws).
*/
draw(): void {
if (!this.fullDirty && this.dirtyList.length === 0) return;
const gl = this.gl;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
gl.viewport(0, 0, this.mapW, this.mapH);
gl.disable(gl.BLEND);
gl.clearColor(0, 0, 0, 0);
// Shared stamp state (uniforms/textures/VAO don't change between blocks).
gl.useProgram(this.program);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uRange, this.settings.mapOverlay.defensePostRange);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.bindVertexArray(this.vao);
if (this.fullDirty || this.dirtyList.length > this.fullFallback) {
// Whole-map stamp.
gl.disable(gl.SCISSOR_TEST);
gl.clear(gl.COLOR_BUFFER_BIT);
if (this.count > 0)
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.count);
} else {
// Per-block scissored stamp — confines clear + draw to changed regions.
gl.enable(gl.SCISSOR_TEST);
for (const b of this.dirtyList) {
const bx = (b % this.blocksX) * BLOCK;
const by = ((b / this.blocksX) | 0) * BLOCK;
// Tile coords map 1:1 to FBO pixels (no Y flip), so pass the block rect
// straight to scissor, clamping the right/bottom edge blocks to bounds.
const bw = Math.min(BLOCK, this.mapW - bx);
const bh = Math.min(BLOCK, this.mapH - by);
gl.scissor(bx, by, bw, bh);
gl.clear(gl.COLOR_BUFFER_BIT);
if (this.count > 0) {
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.count);
}
}
gl.disable(gl.SCISSOR_TEST);
}
// Reset dirty state.
for (const b of this.dirtyList) this.dirtyBlock[b] = 0;
this.dirtyList.length = 0;
this.fullDirty = false;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteTexture(this.coverageTex);
gl.deleteFramebuffer(this.fbo);
gl.deleteBuffer(this.quadBuf);
gl.deleteVertexArray(this.vao);
this.instanceBuf.dispose();
}
}
@@ -40,6 +40,7 @@ export class TerritoryPass {
private uHighlightBrighten: WebGLUniformLocation;
private uShowPatterns: WebGLUniformLocation;
private uIsTeamMode: WebGLUniformLocation;
private uDefenseDarken: WebGLUniformLocation;
private highlightOwner = 0;
private isTeamMode = false;
@@ -51,6 +52,8 @@ export class TerritoryPass {
private skinAtlasTex: WebGLTexture;
private skinLayerTex: WebGLTexture;
private skinAnchorTex: WebGLTexture;
private defenseCoverageTex: WebGLTexture | null = null;
private borderTex: WebGLTexture | null = null;
private altView = false;
private showPatterns = true;
@@ -158,6 +161,10 @@ export class TerritoryPass {
)!;
this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!;
this.uIsTeamMode = gl.getUniformLocation(this.program, "uIsTeamMode")!;
this.uDefenseDarken = gl.getUniformLocation(
this.program,
"uDefenseDarken",
)!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
@@ -167,6 +174,8 @@ export class TerritoryPass {
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAtlas"), 4);
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinLayer"), 5);
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAnchor"), 6);
gl.uniform1i(gl.getUniformLocation(this.program, "uDefenseCoverageTex"), 7);
gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 8);
this.vao = createMapQuad(gl, mapW, mapH);
@@ -411,6 +420,16 @@ export class TerritoryPass {
this.highlightOwner = ownerID;
}
/** Defense-coverage texture (R8) — darkens the fill on defended tiles. */
setDefenseCoverageTex(tex: WebGLTexture): void {
this.defenseCoverageTex = tex;
}
/** Border flags (RGBA8) — used to skip the defense darken on border tiles. */
setBorderTex(tex: WebGLTexture): void {
this.borderTex = tex;
}
/** Draw territory fill + stale-nuke ground. Blending must be enabled by caller. */
draw(cameraMatrix: Float32Array): void {
this.flushTileTexture();
@@ -438,6 +457,7 @@ export class TerritoryPass {
this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0,
);
gl.uniform1i(this.uIsTeamMode, this.isTeamMode ? 1 : 0);
gl.uniform1f(this.uDefenseDarken, mo.territoryDefenseDarken);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
@@ -453,6 +473,14 @@ export class TerritoryPass {
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
gl.activeTexture(gl.TEXTURE6);
gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex);
if (this.defenseCoverageTex) {
gl.activeTexture(gl.TEXTURE7);
gl.bindTexture(gl.TEXTURE_2D, this.defenseCoverageTex);
}
if (this.borderTex) {
gl.activeTexture(gl.TEXTURE8);
gl.bindTexture(gl.TEXTURE_2D, this.borderTex);
}
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -66,6 +66,7 @@
"mapOverlay": {
"trailAlpha": 0.588,
"defenseCheckerDarken": 0.7,
"territoryDefenseDarken": 0.85,
"staleNukeBase": 0,
"staleNukeVariation": 0.05,
"staleNukeAlpha": 1,
@@ -8,11 +8,6 @@ uniform vec2 uMapSize;
uniform uint uHighlightOwner;
uniform int uHighlightThicken; // Chebyshev radius for highlight expansion
// Defense post proximity — (x, y, ownerID, _) per post
uniform vec4 uDefensePosts[MAX_DEFENSE_POSTS];
uniform int uDefensePostCount;
uniform float uDefensePostRange;
out vec4 fragColor;
uint getOwner(ivec2 c) {
@@ -82,26 +77,10 @@ void main() {
}
}
// --- Defense post proximity ---
float defenseFlag = 0.0;
if (borderType > 0.0 && owner != 0u) {
float rangeSq = uDefensePostRange * uDefensePostRange;
for (int i = 0; i < MAX_DEFENSE_POSTS; i++) {
if (i >= uDefensePostCount) break;
vec4 dp = uDefensePosts[i];
if (uint(dp.z) != owner) continue;
float dx = float(tc.x) - dp.x;
float dy = float(tc.y) - dp.y;
if (dx * dx + dy * dy <= rangeSq) {
defenseFlag = 1.0;
break;
}
}
}
// A = relationship: 0.0=neutral, 0.5=friendly, 1.0=embargo
float relation = float(maxRel) * 0.5;
// G channel is unused (formerly emberIntensity; ember is now computed in
// FalloutBloomPass and FalloutLightPass).
fragColor = vec4(borderType, 0.0, defenseFlag, relation);
// FalloutBloomPass and FalloutLightPass). B channel is unused (defense post
// proximity is now computed per-tile by DefenseCoveragePass).
fragColor = vec4(borderType, 0.0, 0.0, relation);
}
@@ -5,6 +5,7 @@ precision highp usampler2D;
uniform usampler2D uTileTex;
uniform sampler2D uPalette;
uniform sampler2D uBorderTex; // RGBA8 — border flags from BorderComputePass
uniform sampler2D uDefenseCoverageTex; // R8 — 1.0 = defended by same-owner post
uniform sampler2D uAffiliation; // 256×2 RGBA8 — affiliation colors (row 0 = border)
uniform vec2 uMapSize;
uniform int uAltView;
@@ -28,7 +29,7 @@ void main() {
// Read pre-computed border flags from BorderComputePass
vec4 borderData = texelFetch(uBorderTex, tc, 0);
float borderType = borderData.r; // 0=interior, ~0.5=normal, ~1.0=highlight
bool defense = borderData.b > 0.5; // defense post proximity
bool defense = texelFetch(uDefenseCoverageTex, tc, 0).r > 0.5; // same-owner defense post nearby
float relation = borderData.a; // 0.0=neutral, ~0.5=friendly, ~1.0=embargo
bool isBorder = borderType > 0.25;
@@ -0,0 +1,34 @@
#version 300 es
precision highp float;
precision highp usampler2D;
// Writes 1.0 into the coverage texture for every tile within `uRange` of the
// post being stamped AND owned by that post's owner. The texture is cleared to
// 0 first, so a tile no post touches stays 0. Overlapping circles from the same
// owner compose as a boolean union (1.0 over 1.0 is still 1.0 — no blending);
// circles from other owners discard at this tile and leave it untouched.
uniform usampler2D uTileTex; // R16UI — tile state per cell
uniform vec2 uMapSize;
uniform float uRange;
flat in vec2 vPostCenter;
flat in float vOwner;
out vec4 fragColor;
void main() {
ivec2 tc = ivec2(gl_FragCoord.xy);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
// Circle test in tile-integer space (matches the old per-fragment loop).
float dx = float(tc.x) - vPostCenter.x;
float dy = float(tc.y) - vPostCenter.y;
if (dx * dx + dy * dy > uRange * uRange) discard;
// Same-owner test: only the tile's own owner's posts defend it.
uint owner = texelFetch(uTileTex, tc, 0).r & uint(OWNER_MASK);
if (owner != uint(vOwner)) discard;
fragColor = vec4(1.0);
}
@@ -0,0 +1,29 @@
#version 300 es
precision highp float;
// One instanced quad per defense post. The quad is a box centered on the post,
// sized to cover the post's range; the fragment shader trims it to a circle and
// filters by tile owner. See DefenseCoveragePass.
layout(location = 0) in vec2 aCorner; // unit quad corner, [0,1]²
layout(location = 1) in vec3 aPost; // (tileX, tileY, ownerID)
uniform vec2 uMapSize;
uniform float uRange;
flat out vec2 vPostCenter;
flat out float vOwner;
void main() {
vPostCenter = aPost.xy;
vOwner = aPost.z;
// Box spanning [center - range, center + range] in tile coords, plus a
// 1-tile margin so the boundary tiles at exactly `range` are rasterized
// (their pixel centers sit just past the un-padded edge).
vec2 tilePos = aPost.xy + (aCorner * 2.0 - 1.0) * (uRange + 1.0);
// Tile-resolution FBO (viewport = map size), so map straight to clip space.
vec2 ndc = (tilePos / uMapSize) * 2.0 - 1.0;
gl_Position = vec4(ndc, 0.0, 1.0);
}
@@ -22,6 +22,9 @@ uniform float uStaleNukeAlpha;
uniform vec3 uStaleNukeColor;
uniform uint uHighlightOwner; // 0 = no highlight; otherwise smallID of hovered owner
uniform float uHighlightBrighten; // mix amount toward white for highlighted tiles
uniform sampler2D uDefenseCoverageTex; // R8 — 1.0 = tile defended by same-owner post
uniform float uDefenseDarken; // multiplier applied to fill on defended tiles
uniform sampler2D uBorderTex; // RGBA8 — border flags; R > 0.25 = border tile
in vec2 vWorldPos;
out vec4 fragColor;
@@ -108,5 +111,15 @@ void main() {
color.rgb = clamp(mix(vec3(luma), color.rgb, 1.6), 0.0, 1.0);
}
// Defense bonus: darken the fill on interior tiles defended by a same-owner
// post. Border tiles are skipped — they get the checkerboard overlay from
// BorderStampPass instead. Coverage is tested first so the (rarer) defended
// tiles are the only ones that pay for the extra border fetch (&& short-
// circuits in GLSL ES 3.00; texelFetch is derivative-free so this is safe).
if (texelFetch(uDefenseCoverageTex, tc, 0).r > 0.5 &&
texelFetch(uBorderTex, tc, 0).r <= 0.25) {
color.rgb *= uDefenseDarken;
}
fragColor = color;
}