Files
OpenFrontIO/src/client/render/gl/passes/BorderStampPass.ts
T
Evan 26d8a314ae 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.
2026-06-08 10:18:02 -07:00

160 lines
5.1 KiB
TypeScript

/**
* BorderStampPass — territory borders + defense checkerboard.
*
* Always draws at full brightness (after the optional night composite).
* Reads pre-computed border flags and defense proximity
* from the BorderComputePass RGBA8 buffer.
*/
import type { RenderSettings } from "../RenderSettings";
import { getPaletteSize } from "../utils/ColorUtils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
import borderStampFragSrc from "../shaders/day-night/border-stamp.frag.glsl?raw";
import borderStampVertSrc from "../shaders/day-night/border-stamp.vert.glsl?raw";
export class BorderStampPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private program: WebGLProgram;
private uCam: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uHighlightBrighten: WebGLUniformLocation;
private uDefenseCheckerDarken: WebGLUniformLocation;
private uEmbargoTintRatio: WebGLUniformLocation;
private uFriendlyTintRatio: WebGLUniformLocation;
private uEmbargoTint: WebGLUniformLocation;
private uFriendlyTint: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private paletteTex: WebGLTexture;
private borderTex: WebGLTexture;
private defenseCoverageTex: WebGLTexture | null = null;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
paletteTex: WebGLTexture,
borderTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.paletteTex = paletteTex;
this.borderTex = borderTex;
this.program = createProgram(
gl,
borderStampVertSrc,
shaderSrc(borderStampFragSrc, {
PALETTE_SIZE: getPaletteSize(),
...TILE_DEFINES,
}),
);
this.uCam = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uHighlightBrighten = gl.getUniformLocation(
this.program,
"uHighlightBrighten",
)!;
this.uDefenseCheckerDarken = gl.getUniformLocation(
this.program,
"uDefenseCheckerDarken",
)!;
this.uEmbargoTintRatio = gl.getUniformLocation(
this.program,
"uEmbargoTintRatio",
)!;
this.uFriendlyTintRatio = gl.getUniformLocation(
this.program,
"uFriendlyTintRatio",
)!;
this.uEmbargoTint = gl.getUniformLocation(this.program, "uEmbargoTint")!;
this.uFriendlyTint = gl.getUniformLocation(this.program, "uFriendlyTint")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
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);
}
setAltView(active: boolean): void {
this.altView = active;
}
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 {
const gl = this.gl;
const mo = this.settings.mapOverlay;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCam, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uHighlightBrighten, mo.highlightBrighten);
gl.uniform1f(this.uDefenseCheckerDarken, mo.defenseCheckerDarken);
gl.uniform1f(this.uEmbargoTintRatio, mo.embargoTintRatio);
gl.uniform1f(this.uFriendlyTintRatio, mo.friendlyTintRatio);
gl.uniform3f(
this.uEmbargoTint,
mo.embargoTintR,
mo.embargoTintG,
mo.embargoTintB,
);
gl.uniform3f(
this.uFriendlyTint,
mo.friendlyTintR,
mo.friendlyTintG,
mo.friendlyTintB,
);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.borderTex);
if (this.affiliationTex) {
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);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}