mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:10:55 +00:00
1ad71b9cfa
## Problem
When you hover over a territory, it highlights with a band that is
`highlightThicken` (default **2**) tiles deep — the edge plus 2 interior
rings, computed via a Chebyshev expansion in `border-compute.frag.glsl`.
Starting a hover triggers a full border recompute, which paints the band
correctly. But while you keep hovering and tiles change owner (territory
growing/shrinking, combat at the front), only the cheap **incremental**
scatter path runs. `BorderScatterPass.pushWithNeighbors` repainted only
the changed tile **+ its 4 cardinal neighbors** (radius 1) — fine for
normal borders, but not for the highlight band. A changed tile affects
the thickening of *every* highlight-owner tile within `highlightThicken`
of it, and those interior tiles were never repainted, so the **inner
edge of the highlight band stayed stale** ("the inside border is not
getting updated"). This was a documented trade-off in the class comment.
## Fix
When a highlight is active, `pushWithNeighbors` now repaints a Chebyshev
**box of radius `highlightThicken`** around each changed tile (the box
subsumes the cardinal cross, so normal borders still update). With no
highlight active it stays on the cheap 5-point cross, preserving the
pass's O(dirty-tiles) scaling. The extra cost (~25 vs 5 points/tile at
default) only applies while actually hovering.
## Testing
Hover over a territory while it grows/shrinks (early-game expansion or a
war front) and confirm the inner edge of the highlight band now tracks
the moving border instead of lagging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
/**
|
|
* TerritoryPass — territory fill + stale-nuke ground.
|
|
*
|
|
* Draws only what should be darkened by the night cycle:
|
|
* - Owned territory (player color fill)
|
|
* - Any fallout tile (stale-nuke ground, overrides owned territory)
|
|
*
|
|
* No borders, embers, trails, or defense checkerboard — those are
|
|
* handled by BorderStampPass and TrailPass at full brightness.
|
|
*
|
|
* Owns the CPU-side tile state and the drip queue that staggers tile
|
|
* uploads across render frames.
|
|
*/
|
|
|
|
import type { TilePair } from "../../types";
|
|
import type { RenderSettings } from "../RenderSettings";
|
|
import { getPaletteSize } from "../utils/ColorUtils";
|
|
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
|
|
import { FALLOUT_BIT, OWNER_MASK, TILE_DEFINES } from "../utils/TileCodec";
|
|
|
|
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
|
|
import territoryFragSrc from "../shaders/map-overlay/territory.frag.glsl?raw";
|
|
import { TileScatterPass } from "./TileScatterPass";
|
|
|
|
export class TerritoryPass {
|
|
private gl: WebGL2RenderingContext;
|
|
private settings: RenderSettings;
|
|
private mapW: number;
|
|
private mapH: number;
|
|
|
|
private program: WebGLProgram;
|
|
private uCamera: WebGLUniformLocation;
|
|
private uMapSize: WebGLUniformLocation;
|
|
private uAltView: WebGLUniformLocation;
|
|
private uStaleNukeBase: WebGLUniformLocation;
|
|
private uStaleNukeVariation: WebGLUniformLocation;
|
|
private uStaleNukeAlpha: WebGLUniformLocation;
|
|
private uStaleNukeColor: WebGLUniformLocation;
|
|
private uHighlightOwner: WebGLUniformLocation;
|
|
private uHighlightBrighten: WebGLUniformLocation;
|
|
private uShowPatterns: WebGLUniformLocation;
|
|
private uIsTeamMode: WebGLUniformLocation;
|
|
private uDefenseDarken: WebGLUniformLocation;
|
|
private uSaturation: WebGLUniformLocation;
|
|
private uTerritoryAlpha: WebGLUniformLocation;
|
|
private highlightOwner = 0;
|
|
private isTeamMode = false;
|
|
|
|
private vao: WebGLVertexArrayObject;
|
|
private tileTex: WebGLTexture;
|
|
private paletteTex: WebGLTexture;
|
|
private patternMetaTex: WebGLTexture;
|
|
private patternDataTex: WebGLTexture;
|
|
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;
|
|
|
|
/** CPU-side tile state — what is currently on the GPU (display state). */
|
|
private cpuTileState: Uint16Array;
|
|
private tilesDirty = false;
|
|
|
|
/**
|
|
* True when a tile's fallout bit flipped since the last consume (or a full
|
|
* state replacement happened, which may contain fallout). The renderer uses
|
|
* this to activate the heat-decay pass only while fallout is in play.
|
|
*/
|
|
private falloutTouched = false;
|
|
|
|
/**
|
|
* True after a full state replacement (initial load / seek). flushTileTexture
|
|
* uploads the full cpuTileState via texSubImage2D and discards any queued
|
|
* scatter patches — those are already covered by the full upload.
|
|
*/
|
|
private fullUploadPending = false;
|
|
|
|
/**
|
|
* GPU scatter pass for per-frame patches. Replaces the old dirty-row bbox
|
|
* upload — constant cost regardless of how spatially scattered patches are.
|
|
*/
|
|
private scatter!: TileScatterPass;
|
|
|
|
/**
|
|
* Hook for forwarding tile changes to the border-compute pipeline so it can
|
|
* incrementally repaint affected tiles instead of rebuilding the whole map.
|
|
* Wired by the renderer to `borderPass.patchTile`.
|
|
*/
|
|
private borderPatchConsumer:
|
|
| ((x: number, y: number, prevOwner: number, newOwner: number) => void)
|
|
| null = null;
|
|
|
|
/**
|
|
* Drip buckets — round-robin staggering of tile updates across render frames.
|
|
* Each incoming change is hashed by tile ref to a fixed bucket (stable hash
|
|
* preserves per-tile ordering across ticks). One bucket drains per render
|
|
* frame, giving a ~bucketCount-frame buffer that smooths over network jitter.
|
|
*
|
|
* Each bucket is a flat number[] with interleaved [ref, state, ref, state, …]
|
|
* pairs — avoids per-tile object allocation on the hot push path.
|
|
*/
|
|
private readonly nBuckets: number;
|
|
private dripBuckets: number[][] = [];
|
|
private currentBucket = 0;
|
|
|
|
constructor(
|
|
gl: WebGL2RenderingContext,
|
|
mapW: number,
|
|
mapH: number,
|
|
tileTex: WebGLTexture,
|
|
paletteTex: WebGLTexture,
|
|
patternMetaTex: WebGLTexture,
|
|
patternDataTex: WebGLTexture,
|
|
skinAtlasTex: WebGLTexture,
|
|
skinLayerTex: WebGLTexture,
|
|
skinAnchorTex: WebGLTexture,
|
|
settings: RenderSettings,
|
|
) {
|
|
this.gl = gl;
|
|
this.settings = settings;
|
|
this.mapW = mapW;
|
|
this.mapH = mapH;
|
|
this.tileTex = tileTex;
|
|
this.paletteTex = paletteTex;
|
|
this.patternMetaTex = patternMetaTex;
|
|
this.patternDataTex = patternDataTex;
|
|
this.skinAtlasTex = skinAtlasTex;
|
|
this.skinLayerTex = skinLayerTex;
|
|
this.skinAnchorTex = skinAnchorTex;
|
|
this.cpuTileState = new Uint16Array(mapW * mapH);
|
|
|
|
this.nBuckets = Math.max(1, settings.tileDrip.bucketCount | 0);
|
|
for (let i = 0; i < this.nBuckets; i++) this.dripBuckets.push([]);
|
|
|
|
this.program = createProgram(
|
|
gl,
|
|
overlayVertSrc,
|
|
shaderSrc(territoryFragSrc, {
|
|
PALETTE_SIZE: getPaletteSize(),
|
|
...TILE_DEFINES,
|
|
}),
|
|
);
|
|
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
|
|
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
|
|
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
|
|
this.uStaleNukeBase = gl.getUniformLocation(
|
|
this.program,
|
|
"uStaleNukeBase",
|
|
)!;
|
|
this.uStaleNukeVariation = gl.getUniformLocation(
|
|
this.program,
|
|
"uStaleNukeVariation",
|
|
)!;
|
|
this.uStaleNukeAlpha = gl.getUniformLocation(
|
|
this.program,
|
|
"uStaleNukeAlpha",
|
|
)!;
|
|
this.uStaleNukeColor = gl.getUniformLocation(
|
|
this.program,
|
|
"uStaleNukeColor",
|
|
)!;
|
|
this.uHighlightOwner = gl.getUniformLocation(
|
|
this.program,
|
|
"uHighlightOwner",
|
|
)!;
|
|
this.uHighlightBrighten = gl.getUniformLocation(
|
|
this.program,
|
|
"uHighlightBrighten",
|
|
)!;
|
|
this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!;
|
|
this.uIsTeamMode = gl.getUniformLocation(this.program, "uIsTeamMode")!;
|
|
this.uDefenseDarken = gl.getUniformLocation(
|
|
this.program,
|
|
"uDefenseDarken",
|
|
)!;
|
|
this.uSaturation = gl.getUniformLocation(this.program, "uSaturation")!;
|
|
this.uTerritoryAlpha = gl.getUniformLocation(
|
|
this.program,
|
|
"uTerritoryAlpha",
|
|
)!;
|
|
|
|
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, "uPatternMeta"), 2);
|
|
gl.uniform1i(gl.getUniformLocation(this.program, "uPatternData"), 3);
|
|
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);
|
|
|
|
this.scatter = new TileScatterPass(gl, mapW, mapH, tileTex);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tile data upload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Live-game path: snapshot the initial tile state and clear pending drip. */
|
|
setLiveRef(tileState: Uint16Array): void {
|
|
this.cpuTileState.set(tileState);
|
|
this.clearDripBuckets();
|
|
this.scatter.clear();
|
|
this.fullUploadPending = true;
|
|
this.tilesDirty = true;
|
|
this.falloutTouched = true; // conservative: replaced state may have fallout
|
|
}
|
|
|
|
/**
|
|
* Wire a consumer that will be called once per tile coordinate change while
|
|
* scatter mode is active (i.e., not during a full upload). The renderer
|
|
* hooks this to `borderPass.patchTile` so border recompute scales with the
|
|
* number of changed tiles instead of full map area.
|
|
*/
|
|
setBorderPatchConsumer(
|
|
fn: (x: number, y: number, prevOwner: number, newOwner: number) => void,
|
|
): void {
|
|
this.borderPatchConsumer = fn;
|
|
}
|
|
|
|
/**
|
|
* Live delta: dispatch each changed tile into a round-robin drip bucket.
|
|
* Stable per-ref hash means repeated updates to the same tile stay in
|
|
* arrival order in the same bucket — last write wins when drained.
|
|
*/
|
|
applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
|
|
const N = this.nBuckets;
|
|
const buckets = this.dripBuckets;
|
|
for (let i = 0; i < changedTiles.length; i++) {
|
|
const ref = changedTiles[i].ref;
|
|
const b = ((ref * 2654435761) >>> 0) % N;
|
|
buckets[b].push(ref, tileState[ref]);
|
|
}
|
|
}
|
|
|
|
/** Drain one drip bucket into cpuTileState. Called once per render frame. */
|
|
drainDripBucket(): void {
|
|
const bucket = this.dripBuckets[this.currentBucket];
|
|
if (bucket.length > 0) {
|
|
const ts = this.cpuTileState;
|
|
const w = this.mapW;
|
|
const pending = this.fullUploadPending;
|
|
const borderFn = this.borderPatchConsumer;
|
|
for (let i = 0; i < bucket.length; i += 2) {
|
|
const ref = bucket[i];
|
|
const state = bucket[i + 1];
|
|
const prev = ts[ref];
|
|
if (((prev ^ state) & FALLOUT_BIT) !== 0) {
|
|
this.falloutTouched = true;
|
|
}
|
|
ts[ref] = state;
|
|
if (!pending) {
|
|
const x = ref % w;
|
|
const y = (ref - x) / w;
|
|
this.scatter.push(x, y, state);
|
|
if (borderFn) {
|
|
borderFn(x, y, prev & OWNER_MASK, state & OWNER_MASK);
|
|
}
|
|
}
|
|
}
|
|
bucket.length = 0;
|
|
this.tilesDirty = true;
|
|
}
|
|
this.currentBucket = (this.currentBucket + 1) % this.nBuckets;
|
|
}
|
|
|
|
/**
|
|
* Drain every drip bucket immediately. Used during spawn phase and after
|
|
* seek so tile state pops to current sim state without the 60Hz stagger.
|
|
*/
|
|
flushAllDripBuckets(): void {
|
|
let any = false;
|
|
const ts = this.cpuTileState;
|
|
const w = this.mapW;
|
|
const pending = this.fullUploadPending;
|
|
const borderFn = this.borderPatchConsumer;
|
|
for (let b = 0; b < this.nBuckets; b++) {
|
|
const bucket = this.dripBuckets[b];
|
|
if (bucket.length === 0) continue;
|
|
any = true;
|
|
for (let i = 0; i < bucket.length; i += 2) {
|
|
const ref = bucket[i];
|
|
const state = bucket[i + 1];
|
|
const prev = ts[ref];
|
|
if (((prev ^ state) & FALLOUT_BIT) !== 0) {
|
|
this.falloutTouched = true;
|
|
}
|
|
ts[ref] = state;
|
|
if (!pending) {
|
|
const x = ref % w;
|
|
const y = (ref - x) / w;
|
|
this.scatter.push(x, y, state);
|
|
if (borderFn) {
|
|
borderFn(x, y, prev & OWNER_MASK, state & OWNER_MASK);
|
|
}
|
|
}
|
|
}
|
|
bucket.length = 0;
|
|
}
|
|
if (any) {
|
|
this.tilesDirty = true;
|
|
}
|
|
}
|
|
|
|
private clearDripBuckets(): void {
|
|
for (let b = 0; b < this.nBuckets; b++) this.dripBuckets[b].length = 0;
|
|
this.currentBucket = 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns true (and resets) if any fallout bit flipped since the last call.
|
|
* Checked by the renderer each frame to (re)activate heat decay.
|
|
*/
|
|
consumeFalloutTouched(): boolean {
|
|
const touched = this.falloutTouched;
|
|
this.falloutTouched = false;
|
|
return touched;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GPU flush + draw
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Flush tile texture to GPU early (before heat update reads it).
|
|
* Return value lets the renderer decide what downstream invalidation is
|
|
* needed — full uploads require a full border recompute, scatter uploads
|
|
* already pushed per-tile border patches via `borderPatchConsumer`.
|
|
*/
|
|
flushTileTexture(): "none" | "full" | "scatter" {
|
|
if (!this.tilesDirty) return "none";
|
|
const gl = this.gl;
|
|
|
|
if (this.fullUploadPending) {
|
|
// Full upload (first tick, seek, replay full frame, etc.) — supersedes
|
|
// any queued scatter patches.
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
this.mapW,
|
|
this.mapH,
|
|
gl.RED_INTEGER,
|
|
gl.UNSIGNED_SHORT,
|
|
this.cpuTileState,
|
|
);
|
|
this.scatter.clear();
|
|
this.fullUploadPending = false;
|
|
this.tilesDirty = false;
|
|
return "full";
|
|
}
|
|
if (this.scatter.count > 0) {
|
|
// Per-frame patches — scatter via FBO + POINTS draw. Constant cost in
|
|
// patch count regardless of spatial distribution.
|
|
this.scatter.flush();
|
|
this.tilesDirty = false;
|
|
return "scatter";
|
|
}
|
|
|
|
this.tilesDirty = false;
|
|
return "none";
|
|
}
|
|
|
|
setAltView(active: boolean): void {
|
|
this.altView = active;
|
|
}
|
|
|
|
setShowPatterns(show: boolean): void {
|
|
this.showPatterns = show;
|
|
}
|
|
|
|
/**
|
|
* Update the skin atlas texture handle. Called once at game start after
|
|
* the renderer learns the locked-in skin URL set.
|
|
*/
|
|
setSkinAtlas(tex: WebGLTexture): void {
|
|
this.skinAtlasTex = tex;
|
|
}
|
|
|
|
/** Whether this game has teams (controls skin tinting). */
|
|
setTeamMode(isTeamMode: boolean): void {
|
|
this.isTeamMode = isTeamMode;
|
|
}
|
|
|
|
/** Set the hovered player's smallID for territory-fill brightening (0 = off). */
|
|
setHighlightOwner(ownerID: number): void {
|
|
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();
|
|
|
|
const gl = this.gl;
|
|
const mo = this.settings.mapOverlay;
|
|
|
|
gl.useProgram(this.program);
|
|
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
|
|
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
|
|
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
|
|
gl.uniform1f(this.uStaleNukeBase, mo.staleNukeBase);
|
|
gl.uniform1f(this.uStaleNukeVariation, mo.staleNukeVariation);
|
|
gl.uniform1f(this.uStaleNukeAlpha, mo.staleNukeAlpha);
|
|
gl.uniform3f(
|
|
this.uStaleNukeColor,
|
|
mo.staleNukeR,
|
|
mo.staleNukeG,
|
|
mo.staleNukeB,
|
|
);
|
|
gl.uniform1ui(this.uHighlightOwner, this.highlightOwner);
|
|
gl.uniform1f(this.uHighlightBrighten, mo.highlightFillBrighten);
|
|
gl.uniform1i(
|
|
this.uShowPatterns,
|
|
this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0,
|
|
);
|
|
gl.uniform1i(this.uIsTeamMode, this.isTeamMode ? 1 : 0);
|
|
gl.uniform1f(this.uDefenseDarken, mo.territoryDefenseDarken);
|
|
gl.uniform1f(this.uSaturation, mo.territorySaturation);
|
|
gl.uniform1f(this.uTerritoryAlpha, mo.territoryAlpha);
|
|
|
|
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.patternMetaTex);
|
|
gl.activeTexture(gl.TEXTURE3);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex);
|
|
gl.activeTexture(gl.TEXTURE4);
|
|
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.skinAtlasTex);
|
|
gl.activeTexture(gl.TEXTURE5);
|
|
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);
|
|
}
|
|
|
|
dispose(): void {
|
|
const gl = this.gl;
|
|
gl.deleteProgram(this.program);
|
|
gl.deleteVertexArray(this.vao);
|
|
this.scatter.dispose();
|
|
// tileTex, paletteTex, patternMetaTex, patternDataTex owned by GPUResources / renderer
|
|
}
|
|
}
|