diff --git a/src/client/render/CLAUDE.md b/src/client/render/CLAUDE.md index ad9e0b761..b5441d1d2 100644 --- a/src/client/render/CLAUDE.md +++ b/src/client/render/CLAUDE.md @@ -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 diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index fc76c240e..7acdfe99e 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -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; } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 6e6041349..8aeb25d1d 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -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(); } diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 1edbd8653..27ef16595 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -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 } } diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts index db1cd40ff..f27c28e34 100644 --- a/src/client/render/gl/passes/TrailPass.ts +++ b/src/client/render/gl/passes/TrailPass.ts @@ -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); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 08d6234d0..fc6cf7743 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -254,6 +254,9 @@ "gridFontSize": 16, "recolorStructures": true }, + "tileDrip": { + "bucketCount": 9 + }, "lightConfigs": { "City": { "radius": 18,