mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Stagger territory tile rendering across frames (#3973)
relates to #893 ## Description: Territory updates were uploaded in one shot per game tick, producing a 10 Hz tile update which looked choppy. This change drips each tick's tile changes across the ~6 render frames between ticks so the fill flows continuously instead of stepping. Inside TerritoryPass, each changed tile is hashed by ref into one of N buckets (configurable via tileDrip.bucketCount, set to 9 — gives ~50 ms of jitter headroom over the tick period without making attacks feel laggy). One bucket drains per render frame. The stable per-ref hash keeps repeated updates to the same tile in arrival order, so the latest owner always wins. While in there, moved trail state ownership out of TerritoryPass and into TrailPass where it belongs — the territory shader doesn't sample trailTex, so the colocation was just code-reuse drift. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -121,9 +121,14 @@ Live mode upload semantics (in `frame/Upload.ts`):
|
||||
- `changedTiles.length > 0` → "only these tiles changed, sub-upload dirty rows"
|
||||
- `changedTiles.length === 0` → "nothing changed, skip"
|
||||
|
||||
`tileState` is drip-applied per render frame (see `gameView.drainPendingTileUpdates`
|
||||
in `view/GameView.ts`) so big territory changes don't teleport in one chunk
|
||||
each tick — they spread across the ~6 render frames between ticks.
|
||||
Live tile changes are drip-applied per render frame inside `TerritoryPass`
|
||||
(see `applyLiveDelta` + `drainDripBucket` in `gl/passes/TerritoryPass.ts`).
|
||||
Each tick's `changedTiles` is hashed by `ref` into N round-robin buckets
|
||||
(`tileDrip.bucketCount`, default 12); the renderer drains one bucket per
|
||||
60Hz frame in `uploadTextures()`. The stable per-ref hash guarantees that
|
||||
repeated updates to the same tile stay in arrival order, so the latest
|
||||
owner always wins. During spawn phase, `flushAllDripBuckets()` is called
|
||||
instead so initial state pops without staggering.
|
||||
|
||||
## Asset pipeline
|
||||
|
||||
|
||||
@@ -238,6 +238,15 @@ export interface RenderSettings {
|
||||
gridFontSize: number;
|
||||
recolorStructures: boolean;
|
||||
};
|
||||
tileDrip: {
|
||||
/**
|
||||
* Round-robin bucket count for staggering territory tile uploads across
|
||||
* render frames. One bucket drains per frame at 60Hz. 12 ≈ 200ms max
|
||||
* latency, which absorbs a 100ms tick delay without a visible freeze.
|
||||
* Changing at runtime requires reload.
|
||||
*/
|
||||
bucketCount: number;
|
||||
};
|
||||
lightConfigs: Record<string, { radius: number; intensity: number }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export class GPURenderer {
|
||||
private affiliationPalette: AffiliationPalette;
|
||||
private coordinateGridPass: CoordinateGridPass;
|
||||
private spawnOverlayPass: SpawnOverlayPass;
|
||||
private inSpawnPhase = false;
|
||||
|
||||
private paletteTex: WebGLTexture;
|
||||
private paletteData: Float32Array;
|
||||
@@ -281,13 +282,12 @@ export class GPURenderer {
|
||||
this.settings,
|
||||
);
|
||||
|
||||
// --- Territory (needs tileTex, trailTex, paletteTex, patternTexs) ---
|
||||
// --- Territory (needs tileTex, paletteTex, patternTexs) ---
|
||||
this.territoryPass = new TerritoryPass(
|
||||
gl,
|
||||
mapW,
|
||||
mapH,
|
||||
this.res.tileTex,
|
||||
this.res.trailTex,
|
||||
this.paletteTex,
|
||||
this.patternMetaTex,
|
||||
this.patternDataTex,
|
||||
@@ -545,25 +545,26 @@ export class GPURenderer {
|
||||
currentTick?: number,
|
||||
): void {
|
||||
this.territoryPass.uploadFullTileState(tileState);
|
||||
this.territoryPass.uploadFullTrailState(trailState);
|
||||
this.trailPass.uploadFullState(trailState);
|
||||
this.heatManager.resetForSeek(tileState, nukeEvents, currentTick);
|
||||
}
|
||||
|
||||
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void {
|
||||
this.territoryPass.uploadFullTileState(tileState);
|
||||
this.territoryPass.uploadFullTrailState(trailState);
|
||||
this.trailPass.uploadFullState(trailState);
|
||||
}
|
||||
|
||||
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void {
|
||||
this.territoryPass.uploadDeltaTiles(changedTiles);
|
||||
this.territoryPass.uploadFullTrailState(trailState);
|
||||
this.trailPass.uploadFullState(trailState);
|
||||
}
|
||||
|
||||
uploadTileAndTrailState(
|
||||
tileState: Uint16Array,
|
||||
trailState: Uint8Array,
|
||||
): void {
|
||||
this.territoryPass.setLiveRefs(tileState, trailState);
|
||||
this.territoryPass.setLiveRef(tileState);
|
||||
this.trailPass.setLiveRef(trailState);
|
||||
}
|
||||
|
||||
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
|
||||
@@ -575,11 +576,7 @@ export class GPURenderer {
|
||||
dirtyRowMin: number,
|
||||
dirtyRowMax: number,
|
||||
): void {
|
||||
this.territoryPass.applyLiveTrailDelta(
|
||||
trailState,
|
||||
dirtyRowMin,
|
||||
dirtyRowMax,
|
||||
);
|
||||
this.trailPass.applyLiveDelta(trailState, dirtyRowMin, dirtyRowMax);
|
||||
}
|
||||
|
||||
/** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */
|
||||
@@ -781,6 +778,7 @@ export class GPURenderer {
|
||||
}
|
||||
|
||||
updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
|
||||
this.inSpawnPhase = inSpawnPhase;
|
||||
this.spawnOverlayPass.update(inSpawnPhase, centers);
|
||||
}
|
||||
|
||||
@@ -1060,9 +1058,14 @@ export class GPURenderer {
|
||||
|
||||
private uploadTextures(): void {
|
||||
if (this.altView) this.affiliationPalette.flush();
|
||||
if (this.inSpawnPhase) {
|
||||
this.territoryPass.flushAllDripBuckets();
|
||||
} else {
|
||||
this.territoryPass.drainDripBucket();
|
||||
}
|
||||
if (this.territoryPass.flushTileTexture())
|
||||
this.borderPass.notifyTilesChanged();
|
||||
this.territoryPass.flushTrailTexture();
|
||||
this.trailPass.flushTexture();
|
||||
this.heatManager.updateHeat();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* No borders, embers, trails, or defense checkerboard — those are
|
||||
* handled by BorderStampPass and TrailPass at full brightness.
|
||||
*
|
||||
* Also owns the CPU-side tile and trail state, flushing to shared
|
||||
* GPU textures on draw.
|
||||
* Owns the CPU-side tile state and the drip queue that staggers tile
|
||||
* uploads across render frames.
|
||||
*/
|
||||
|
||||
import type { TilePair } from "../../types";
|
||||
@@ -41,7 +41,6 @@ export class TerritoryPass {
|
||||
|
||||
private vao: WebGLVertexArrayObject;
|
||||
private tileTex: WebGLTexture;
|
||||
private trailTex: WebGLTexture;
|
||||
private paletteTex: WebGLTexture;
|
||||
private patternMetaTex: WebGLTexture;
|
||||
private patternDataTex: WebGLTexture;
|
||||
@@ -49,32 +48,32 @@ export class TerritoryPass {
|
||||
private altView = false;
|
||||
private showPatterns = true;
|
||||
|
||||
/** CPU-side tile state (deltas written here, flushed to GPU before draw). */
|
||||
/** CPU-side tile state — what is currently on the GPU (display state). */
|
||||
private cpuTileState: Uint16Array;
|
||||
private tilesDirty = false;
|
||||
|
||||
/** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */
|
||||
private cpuTrailState: Uint8Array;
|
||||
private trailsDirty = false;
|
||||
|
||||
/** Live-game references — bypasses memcpy. Null for replay path. */
|
||||
private liveTileRef: Uint16Array | null = null;
|
||||
private liveTrailRef: Uint8Array | null = null;
|
||||
|
||||
/** Dirty row range for partial tile upload. Infinity/-1 = full upload. */
|
||||
private dirtyRowMin = Infinity;
|
||||
private dirtyRowMax = -1;
|
||||
|
||||
/** Dirty row range for partial trail upload. Infinity/-1 = full upload. */
|
||||
private trailDirtyRowMin = Infinity;
|
||||
private trailDirtyRowMax = -1;
|
||||
/**
|
||||
* 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,
|
||||
trailTex: WebGLTexture,
|
||||
paletteTex: WebGLTexture,
|
||||
patternMetaTex: WebGLTexture,
|
||||
patternDataTex: WebGLTexture,
|
||||
@@ -85,12 +84,13 @@ export class TerritoryPass {
|
||||
this.mapW = mapW;
|
||||
this.mapH = mapH;
|
||||
this.tileTex = tileTex;
|
||||
this.trailTex = trailTex;
|
||||
this.paletteTex = paletteTex;
|
||||
this.patternMetaTex = patternMetaTex;
|
||||
this.patternDataTex = patternDataTex;
|
||||
this.cpuTileState = new Uint16Array(mapW * mapH);
|
||||
this.cpuTrailState = new Uint8Array(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,
|
||||
@@ -137,86 +137,117 @@ export class TerritoryPass {
|
||||
|
||||
/** Full tile state upload (on seek). */
|
||||
uploadFullTileState(tileState: Uint16Array): void {
|
||||
this.liveTileRef = null;
|
||||
this.cpuTileState.set(tileState);
|
||||
this.clearDripBuckets();
|
||||
this.dirtyRowMin = Infinity;
|
||||
this.dirtyRowMax = -1;
|
||||
this.tilesDirty = true;
|
||||
}
|
||||
|
||||
/** Live-game path: reference the game's own arrays directly. */
|
||||
setLiveRefs(tileState: Uint16Array, trailState: Uint8Array): void {
|
||||
this.liveTileRef = tileState;
|
||||
this.liveTrailRef = trailState;
|
||||
/** Live-game path: snapshot the initial tile state and clear pending drip. */
|
||||
setLiveRef(tileState: Uint16Array): void {
|
||||
this.cpuTileState.set(tileState);
|
||||
this.clearDripBuckets();
|
||||
this.dirtyRowMin = Infinity;
|
||||
this.dirtyRowMax = -1;
|
||||
this.tilesDirty = true;
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Apply tile deltas (during playback). */
|
||||
uploadDeltaTiles(changedTiles: TilePair[]): void {
|
||||
const ts = this.cpuTileState;
|
||||
const w = this.mapW;
|
||||
for (let i = 0; i < changedTiles.length; i++) {
|
||||
const tp = changedTiles[i];
|
||||
ts[tp.ref] = tp.state;
|
||||
const row = (tp.ref / w) | 0;
|
||||
if (row < this.dirtyRowMin) this.dirtyRowMin = row;
|
||||
if (row > this.dirtyRowMax) this.dirtyRowMax = row;
|
||||
}
|
||||
this.tilesDirty = true;
|
||||
}
|
||||
|
||||
/** Live delta: update live ref + compute dirty row range from deltas. */
|
||||
/**
|
||||
* 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 {
|
||||
this.liveTileRef = tileState;
|
||||
let minRow = Infinity,
|
||||
maxRow = -1;
|
||||
const N = this.nBuckets;
|
||||
const buckets = this.dripBuckets;
|
||||
for (let i = 0; i < changedTiles.length; i++) {
|
||||
const row = (changedTiles[i].ref / this.mapW) | 0;
|
||||
if (row < minRow) minRow = row;
|
||||
if (row > maxRow) maxRow = row;
|
||||
const ref = changedTiles[i].ref;
|
||||
const b = ((ref * 2654435761) >>> 0) % N;
|
||||
buckets[b].push(ref, tileState[ref]);
|
||||
}
|
||||
if (maxRow >= 0) {
|
||||
this.dirtyRowMin = Math.min(this.dirtyRowMin, minRow);
|
||||
this.dirtyRowMax = Math.max(this.dirtyRowMax, maxRow);
|
||||
}
|
||||
|
||||
/** Drain one drip bucket into cpuTileState. Called once per render frame. */
|
||||
drainDripBucket(): void {
|
||||
const bucket = this.dripBuckets[this.currentBucket];
|
||||
if (bucket.length > 0) {
|
||||
const w = this.mapW;
|
||||
let minRow = this.dirtyRowMin;
|
||||
let maxRow = this.dirtyRowMax;
|
||||
for (let i = 0; i < bucket.length; i += 2) {
|
||||
const ref = bucket[i];
|
||||
this.cpuTileState[ref] = bucket[i + 1];
|
||||
const row = (ref / w) | 0;
|
||||
if (row < minRow) minRow = row;
|
||||
if (row > maxRow) maxRow = row;
|
||||
}
|
||||
this.dirtyRowMin = minRow;
|
||||
this.dirtyRowMax = maxRow;
|
||||
bucket.length = 0;
|
||||
this.tilesDirty = true;
|
||||
}
|
||||
this.tilesDirty = true;
|
||||
this.currentBucket = (this.currentBucket + 1) % this.nBuckets;
|
||||
}
|
||||
|
||||
/** Live trail delta: update live ref + accept dirty row range from TrailManager. */
|
||||
applyLiveTrailDelta(
|
||||
trailState: Uint8Array,
|
||||
dirtyRowMin: number,
|
||||
dirtyRowMax: number,
|
||||
): void {
|
||||
this.liveTrailRef = trailState;
|
||||
if (dirtyRowMax >= 0) {
|
||||
this.trailDirtyRowMin = Math.min(this.trailDirtyRowMin, dirtyRowMin);
|
||||
this.trailDirtyRowMax = Math.max(this.trailDirtyRowMax, dirtyRowMax);
|
||||
/**
|
||||
* 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 {
|
||||
const w = this.mapW;
|
||||
let minRow = this.dirtyRowMin;
|
||||
let maxRow = this.dirtyRowMax;
|
||||
let any = false;
|
||||
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];
|
||||
this.cpuTileState[ref] = bucket[i + 1];
|
||||
const row = (ref / w) | 0;
|
||||
if (row < minRow) minRow = row;
|
||||
if (row > maxRow) maxRow = row;
|
||||
}
|
||||
bucket.length = 0;
|
||||
}
|
||||
if (any) {
|
||||
this.dirtyRowMin = minRow;
|
||||
this.dirtyRowMax = maxRow;
|
||||
this.tilesDirty = true;
|
||||
}
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Full trail state upload (on seek). */
|
||||
uploadFullTrailState(trailState: Uint8Array): void {
|
||||
this.liveTrailRef = null;
|
||||
this.cpuTrailState.set(trailState);
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Set a single trail tile (during playback advance). */
|
||||
setTrailTile(ref: number, ownerID: number): void {
|
||||
this.cpuTrailState[ref] = ownerID;
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Clear all trails (on seek before rebuilding). */
|
||||
clearTrails(): void {
|
||||
this.cpuTrailState.fill(0);
|
||||
this.trailsDirty = 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. */
|
||||
/**
|
||||
* 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.liveTileRef ?? this.cpuTileState;
|
||||
const ts = this.cpuTileState;
|
||||
if (tileRef < 0 || tileRef >= ts.length) return 0;
|
||||
return ts[tileRef] & OWNER_MASK;
|
||||
}
|
||||
@@ -230,7 +261,7 @@ export class TerritoryPass {
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const w = this.mapW;
|
||||
const ts = this.liveTileRef ?? this.cpuTileState;
|
||||
const ts = this.cpuTileState;
|
||||
for (let i = 0; i < ts.length; i++) {
|
||||
if ((ts[i] & OWNER_MASK) === ownerID) {
|
||||
const x = i % w;
|
||||
@@ -252,7 +283,7 @@ export class TerritoryPass {
|
||||
flushTileTexture(): boolean {
|
||||
if (!this.tilesDirty) return false;
|
||||
const gl = this.gl;
|
||||
const src = this.liveTileRef ?? this.cpuTileState;
|
||||
const src = this.cpuTileState;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
|
||||
|
||||
@@ -293,50 +324,6 @@ export class TerritoryPass {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Flush trail texture to GPU (called before TrailPass draws). */
|
||||
flushTrailTexture(): void {
|
||||
if (!this.trailsDirty) return;
|
||||
const gl = this.gl;
|
||||
const src = this.liveTrailRef ?? this.cpuTrailState;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
|
||||
|
||||
if (this.trailDirtyRowMax >= 0) {
|
||||
// Partial upload — only dirty rows
|
||||
const minRow = this.trailDirtyRowMin;
|
||||
const rowCount = this.trailDirtyRowMax - minRow + 1;
|
||||
const offset = minRow * this.mapW;
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
minRow,
|
||||
this.mapW,
|
||||
rowCount,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
src.subarray(offset, offset + rowCount * this.mapW),
|
||||
);
|
||||
} else {
|
||||
// Full upload (first tick, seek, replay, etc.)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
this.mapW,
|
||||
this.mapH,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
src,
|
||||
);
|
||||
}
|
||||
|
||||
this.trailDirtyRowMin = Infinity;
|
||||
this.trailDirtyRowMax = -1;
|
||||
this.trailsDirty = false;
|
||||
}
|
||||
|
||||
setAltView(active: boolean): void {
|
||||
this.altView = active;
|
||||
}
|
||||
@@ -353,7 +340,6 @@ export class TerritoryPass {
|
||||
/** Draw territory fill + fallout charcoal. Blending must be enabled by caller. */
|
||||
draw(cameraMatrix: Float32Array): void {
|
||||
this.flushTileTexture();
|
||||
this.flushTrailTexture();
|
||||
|
||||
const gl = this.gl;
|
||||
const mo = this.settings.mapOverlay;
|
||||
@@ -389,6 +375,6 @@ export class TerritoryPass {
|
||||
const gl = this.gl;
|
||||
gl.deleteProgram(this.program);
|
||||
gl.deleteVertexArray(this.vao);
|
||||
// tileTex, trailTex, paletteTex, patternMetaTex, patternDataTex owned by GPUResources / renderer
|
||||
// tileTex, paletteTex, patternMetaTex, patternDataTex owned by GPUResources / renderer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* TrailPass — boat trail lines.
|
||||
*
|
||||
* Simple dedicated pass: for each tile with a non-zero trail owner,
|
||||
* output the owner's territory color at configurable alpha.
|
||||
* Always draws at full brightness (after night composite).
|
||||
* Owns the CPU-side trail state (R8UI, 0=none, 1–255=ownerID), the dirty-row
|
||||
* bookkeeping for partial GPU uploads, and the trail fragment shader that
|
||||
* draws the colored breadcrumb behind moving units.
|
||||
*/
|
||||
|
||||
import type { RenderSettings } from "../RenderSettings";
|
||||
@@ -32,6 +32,17 @@ export class TrailPass {
|
||||
private affiliationTex: WebGLTexture | null = null;
|
||||
private altView = false;
|
||||
|
||||
/** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */
|
||||
private cpuTrailState: Uint8Array;
|
||||
private trailsDirty = false;
|
||||
|
||||
/** Live-game reference — bypasses memcpy. Null for replay path. */
|
||||
private liveTrailRef: Uint8Array | null = null;
|
||||
|
||||
/** Dirty row range for partial trail upload. Infinity/-1 = full upload. */
|
||||
private dirtyRowMin = Infinity;
|
||||
private dirtyRowMax = -1;
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
mapW: number,
|
||||
@@ -46,6 +57,7 @@ export class TrailPass {
|
||||
this.mapH = mapH;
|
||||
this.trailTex = trailTex;
|
||||
this.paletteTex = paletteTex;
|
||||
this.cpuTrailState = new Uint8Array(mapW * mapH);
|
||||
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
@@ -75,8 +87,96 @@ export class TrailPass {
|
||||
this.affiliationTex = tex;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trail data upload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Live-game path: reference the game's own trail array directly. */
|
||||
setLiveRef(trailState: Uint8Array): void {
|
||||
this.liveTrailRef = trailState;
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Live trail delta: update live ref + accept dirty row range from TrailManager. */
|
||||
applyLiveDelta(
|
||||
trailState: Uint8Array,
|
||||
dirtyRowMin: number,
|
||||
dirtyRowMax: number,
|
||||
): void {
|
||||
this.liveTrailRef = trailState;
|
||||
if (dirtyRowMax >= 0) {
|
||||
this.dirtyRowMin = Math.min(this.dirtyRowMin, dirtyRowMin);
|
||||
this.dirtyRowMax = Math.max(this.dirtyRowMax, dirtyRowMax);
|
||||
}
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Full trail state upload (on seek). */
|
||||
uploadFullState(trailState: Uint8Array): void {
|
||||
this.liveTrailRef = null;
|
||||
this.cpuTrailState.set(trailState);
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Set a single trail tile (during playback advance). */
|
||||
setTile(ref: number, ownerID: number): void {
|
||||
this.cpuTrailState[ref] = ownerID;
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Clear all trails (on seek before rebuilding). */
|
||||
clear(): void {
|
||||
this.cpuTrailState.fill(0);
|
||||
this.trailsDirty = true;
|
||||
}
|
||||
|
||||
/** Flush trail texture to GPU. Called once per render frame in uploadTextures. */
|
||||
flushTexture(): void {
|
||||
if (!this.trailsDirty) return;
|
||||
const gl = this.gl;
|
||||
const src = this.liveTrailRef ?? this.cpuTrailState;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
|
||||
|
||||
if (this.dirtyRowMax >= 0) {
|
||||
// Partial upload — only dirty rows
|
||||
const minRow = this.dirtyRowMin;
|
||||
const rowCount = this.dirtyRowMax - minRow + 1;
|
||||
const offset = minRow * this.mapW;
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
minRow,
|
||||
this.mapW,
|
||||
rowCount,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
src.subarray(offset, offset + rowCount * this.mapW),
|
||||
);
|
||||
} else {
|
||||
// Full upload (first tick, seek, replay, etc.)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
this.mapW,
|
||||
this.mapH,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
src,
|
||||
);
|
||||
}
|
||||
|
||||
this.dirtyRowMin = Infinity;
|
||||
this.dirtyRowMax = -1;
|
||||
this.trailsDirty = false;
|
||||
}
|
||||
|
||||
/** Draw trail overlay. Blending must be enabled by caller. */
|
||||
draw(cameraMatrix: Float32Array): void {
|
||||
this.flushTexture();
|
||||
const gl = this.gl;
|
||||
|
||||
gl.useProgram(this.program);
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
"gridFontSize": 16,
|
||||
"recolorStructures": true
|
||||
},
|
||||
"tileDrip": {
|
||||
"bucketCount": 9
|
||||
},
|
||||
"lightConfigs": {
|
||||
"City": {
|
||||
"radius": 18,
|
||||
|
||||
Reference in New Issue
Block a user