mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 11:02:12 +00:00
26d8a314ae
## 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.
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|