Files
OpenFrontIO/src/client/render/gl/passes/TerritoryPass.ts
T
EvanandGitHub d1ce199a52 Upload tile delta to GPU (#4159)
## Description

Reduces the amount of tile data sent to the gpu each tick, roughly
~10fps rate increase on 10 year old chromebook.

Two changes to the territory rendering path:

### 1. Split `passEnabled.mapOverlay` into four flags

The single `mapOverlay` toggle controlled four unrelated passes
(territory fill, border compute, border stamp, trail). Splits it into
`territory`, `borderCompute`, `borderStamp`, `trail` so each can be
toggled independently in the debug GUI. Pure rename — default behavior
is unchanged (all four default to `true`).

### 2. GPU scatter for per-frame tile texture updates

Replaces the dirty-row bbox `texSubImage2D` upload in `TerritoryPass`
with a new `TileScatterPass` that uploads a small attribute buffer of
`(x, y, state)` patches and runs a single `POINTS` draw into an FBO
bound to `tileTex`. Each patch rasterizes as a 1×1 point into exactly
its target texel.

**Why:** the old path's cost scaled with the bounding box of the dirty
rows, not the number of changed tiles. In typical play, tile changes are
spread across the whole map (multiple players fighting in different
regions, scattered trails/fallout), so the bbox covered most of the
map's rows and we re-uploaded mostly-unchanged data every frame. The new
path is constant cost in patch count regardless of spatial distribution,
and no longer scales with map size.

The full-upload path (initial load / seek / spawn-phase flush) is
unchanged. `fullUploadPending` correctly supersedes any queued scatter
patches.

## Please complete the following:

- [x] I have added screenshots for all UI updates *(N/A — no UI
changes)*
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file *(N/A — no user-facing text)*
- [x] I have added relevant tests to the test directory *(renderer code,
not covered by unit tests; verified visually)*

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-06-05 07:07:03 -07:00

439 lines
14 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 { 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 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 altView = false;
private showPatterns = true;
/** CPU-side tile state — what is currently on the GPU (display state). */
private cpuTileState: Uint16Array;
private tilesDirty = 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;
/**
* 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")!;
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);
this.vao = createMapQuad(gl, mapW, mapH);
this.scatter = new TileScatterPass(gl, mapW, mapH, tileTex);
}
// ---------------------------------------------------------------------------
// Tile data upload
// ---------------------------------------------------------------------------
/** Full tile state upload (on seek). */
uploadFullTileState(tileState: Uint16Array): void {
this.cpuTileState.set(tileState);
this.clearDripBuckets();
this.scatter.clear();
this.fullUploadPending = true;
this.tilesDirty = true;
}
/** 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;
}
/** Apply tile deltas (during playback). */
uploadDeltaTiles(changedTiles: TilePair[]): void {
const ts = this.cpuTileState;
const w = this.mapW;
const pending = this.fullUploadPending;
for (let i = 0; i < changedTiles.length; i++) {
const tp = changedTiles[i];
ts[tp.ref] = tp.state;
if (!pending) {
const x = tp.ref % w;
const y = (tp.ref - x) / w;
this.scatter.push(x, y, tp.state);
}
}
this.tilesDirty = true;
}
/**
* 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;
for (let i = 0; i < bucket.length; i += 2) {
const ref = bucket[i];
const state = bucket[i + 1];
ts[ref] = state;
if (!pending) {
const x = ref % w;
const y = (ref - x) / w;
this.scatter.push(x, y, state);
}
}
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;
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];
ts[ref] = state;
if (!pending) {
const x = ref % w;
const y = (ref - x) / w;
this.scatter.push(x, y, state);
}
}
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
// ---------------------------------------------------------------------------
/**
* Get ownerID at a tile reference. Returns 0 for unowned.
* Reads display state (post-drip), so queries match what's visible.
*/
getOwnerAt(tileRef: number): number {
const ts = this.cpuTileState;
if (tileRef < 0 || tileRef >= ts.length) return 0;
return ts[tileRef] & OWNER_MASK;
}
/** AABB of all tiles owned by ownerID. */
getBBoxForOwner(
ownerID: number,
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const w = this.mapW;
const ts = this.cpuTileState;
for (let i = 0; i < ts.length; i++) {
if ((ts[i] & OWNER_MASK) === ownerID) {
const x = i % w;
const y = (i - x) / w;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return minX === Infinity ? null : { minX, minY, maxX, maxY };
}
// ---------------------------------------------------------------------------
// GPU flush + draw
// ---------------------------------------------------------------------------
/** Flush tile texture to GPU early (before heat update reads it). Returns true if data was uploaded. */
flushTileTexture(): boolean {
if (!this.tilesDirty) return false;
const gl = this.gl;
let uploaded = false;
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;
uploaded = true;
} else 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();
uploaded = true;
}
this.tilesDirty = false;
return uploaded;
}
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;
}
/** 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.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);
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
}
}