diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 99bb15d40..69cf31b0e 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -28,6 +28,8 @@ export class WebGLFrameBuilder { // unit colors, and SAM-radius perspective work. Push it once the local // player's update arrives (may take several ticks during join). private localPlayerSmallID = 0; + // Scratch buffer for terrain-delta uploads (parallel to the refs list). + private terrainDeltaBytes: Uint8Array = new Uint8Array(0); constructor(private readonly view: WebGLGameView) { this.palette = new Float32Array(PALETTE_SIZE * 2 * 4); @@ -37,9 +39,28 @@ export class WebGLFrameBuilder { this.syncPlayers(gameView); this.syncLocalPlayer(gameView); this.syncSpawnOverlay(gameView); + this.syncTerrainDeltas(gameView); uploadFrameData(this.view, gameView.frameData()); } + /** + * Water-nuke conversions (land → water) mutate the underlying terrain. + * Forward this tick's terrain-changed refs to the renderer so it can + * re-upload those texels in both the RGBA color texture and the R8UI + * water-detection texture used by railroads/bridges. + */ + private syncTerrainDeltas(gameView: GameView): void { + const refs = gameView.recentlyUpdatedTerrainTiles(); + if (refs.length === 0) return; + if (this.terrainDeltaBytes.length < refs.length) { + this.terrainDeltaBytes = new Uint8Array(refs.length); + } + for (let i = 0; i < refs.length; i++) { + this.terrainDeltaBytes[i] = gameView.terrainByte(refs[i]); + } + this.view.applyTerrainDelta(refs, this.terrainDeltaBytes); + } + private syncLocalPlayer(gameView: GameView): void { const sid = gameView.myPlayer()?.smallID() ?? 0; if (sid === this.localPlayerSmallID) return; diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts index 8108514e5..1d2a33202 100644 --- a/src/client/render/gl/GameView.ts +++ b/src/client/render/gl/GameView.ts @@ -252,6 +252,10 @@ export class GameView { applyRailroadDust(tileRefs: number[]): void { this.renderer.applyRailroadDust(tileRefs); } + /** Refresh terrain texels whose underlying terrain byte changed (water nukes). */ + applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void { + this.renderer.applyTerrainDelta(refs, terrainBytes); + } updateAttackRings(rings: AttackRingInput[]): void { this.renderer.updateAttackRings(rings); } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index c174aa32d..b0450eec5 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -659,6 +659,18 @@ export class GPURenderer { if (tileRefs.length > 0) this.fxPass.applyRailroadDust(tileRefs); } + /** + * Update terrain texels for tiles whose terrain byte changed (e.g. water + * nukes converting land → water). `terrainBytes[i]` is the new byte for + * `refs[i]`. Forwards to both TerrainPass (RGBA color) and RailroadPass + * (R8UI water-detection for bridges). + */ + applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void { + if (refs.length === 0) return; + this.terrainPass.applyTerrainDelta(refs, terrainBytes); + this.railroadPass.applyTerrainDelta(refs, terrainBytes); + } + applyConquestEvents(events: ConquestFx[]): void { if (events.length > 0) { this.fxPass.applyConquestEvents(events); diff --git a/src/client/render/gl/passes/RailroadPass.ts b/src/client/render/gl/passes/RailroadPass.ts index 4c3b89a43..ec1970c48 100644 --- a/src/client/render/gl/passes/RailroadPass.ts +++ b/src/client/render/gl/passes/RailroadPass.ts @@ -197,6 +197,36 @@ export class RailroadPass { this.railroadDirty = true; } + /** + * Sub-upload terrain bytes for tiles that changed (water-nuke conversions). + * Keeps the R8UI water-detection texture in sync with the simulation. + * `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays). + */ + applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { + if (refs.length === 0) return; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.terrainTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + const scratch = new Uint8Array(1); + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + const x = ref % this.mapW; + const y = (ref - x) / this.mapW; + scratch[0] = bytes[i]; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + x, + y, + 1, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + scratch, + ); + } + } + updateGhostPreview(data: GhostPreviewData | null): void { this.cpuGhostRailState.fill(0); diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index b5d16777a..c0754572d 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -1,16 +1,16 @@ /** - * TerrainPass — renders the static terrain map as a textured quad. + * TerrainPass — renders the terrain map as a textured quad. * - * The terrain never changes during a replay, so this texture is uploaded - * exactly once and blitted every frame as the opaque background layer. - * - * Vertex shader transforms the map quad by the camera mat3. - * Fragment shader samples the RGBA8 terrain texture with nearest-neighbour - * filtering so each terrain cell stays pixel-crisp at every zoom level. + * Initial upload happens once; per-tile updates flow through + * applyTerrainDelta() so water-nuke conversions (land → water) are reflected + * live. Vertex shader transforms the map quad by the camera mat3; fragment + * shader samples the RGBA8 terrain texture with nearest-neighbour filtering + * so each terrain cell stays pixel-crisp at every zoom level. */ import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw"; import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw"; +import { encodeTerrainTile } from "../utils/ColorUtils"; import { createMapQuad, createProgram, @@ -27,6 +27,9 @@ export class TerrainPass { private tex: WebGLTexture; private vao: WebGLVertexArrayObject; private uCamera: WebGLUniformLocation; + private mapW: number; + // Scratch buffer for 1×1 sub-uploads; reused across applyTerrainDelta calls. + private readonly pixelScratch = new Uint8Array(4); constructor( private gl: WebGL2RenderingContext, @@ -34,6 +37,7 @@ export class TerrainPass { mapW: number, mapH: number, ) { + this.mapW = mapW; this.program = createProgram( gl, shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }), @@ -41,7 +45,6 @@ export class TerrainPass { ); this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; - // Static RGBA8 terrain texture — uploaded once, never updated. this.tex = createTexture2D(gl, { width: mapW, height: mapH, @@ -55,6 +58,36 @@ export class TerrainPass { this.vao = createMapQuad(gl, mapW, mapH); } + /** + * Update a subset of terrain tiles in-place (e.g. land→water from a water + * nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays). + * One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke + * produces. + */ + applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { + if (refs.length === 0) return; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + const x = ref % this.mapW; + const y = (ref - x) / this.mapW; + encodeTerrainTile(bytes[i], this.pixelScratch, 0); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + x, + y, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.pixelScratch, + ); + } + } + /** Render the terrain. Call with depth test disabled, no blending. */ draw(cameraMatrix: Float32Array): void { const gl = this.gl; diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index 68b008c45..da575efc9 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -27,64 +27,69 @@ export function getPaletteSize(): number { * bit 5: isOcean (water only) * bits 0-4: magnitude (0-31) */ +/** Encode one terrain byte → RGBA, writing into `out[offset..offset+3]`. */ +export function encodeTerrainTile( + tb: number, + out: Uint8Array, + offset: number, +): void { + const isLand = (tb & 0x80) !== 0; + const isShoreline = (tb & 0x40) !== 0; + const magnitude = tb & 0x1f; + + let r: number, g: number, b: number; + + if (isLand && isShoreline) { + // Shore (sand) + r = 204; + g = 203; + b = 158; + } else if (isLand) { + if (magnitude < 10) { + // Plains + r = 190; + g = 220 - 2 * magnitude; + b = 138; + } else if (magnitude < 20) { + // Highland + r = 200 + 2 * magnitude; + g = 183 + 2 * magnitude; + b = 138 + 2 * magnitude; + } else { + // Mountain + const v = Math.min(255, 230 + Math.floor(magnitude / 2)); + r = v; + g = v; + b = v; + } + } else if (isShoreline) { + // Shoreline water + r = 100; + g = 143; + b = 255; + } else { + // Deep water + const m = Math.min(magnitude, 10); + const off = 11 - m; + r = Math.max(0, 70 - 10 + off); + g = Math.max(0, 132 - 10 + off); + b = Math.max(0, 180 - 10 + off); + } + + out[offset] = r; + out[offset + 1] = g; + out[offset + 2] = b; + out[offset + 3] = 255; +} + export function buildTerrainRGBA( terrainBytes: Uint8Array, w: number, h: number, ): Uint8Array { const pixels = new Uint8Array(w * h * 4); - for (let i = 0; i < w * h; i++) { - const tb = terrainBytes[i]; - const isLand = (tb & 0x80) !== 0; - const isShoreline = (tb & 0x40) !== 0; - const magnitude = tb & 0x1f; - - let r: number, g: number, b: number; - - if (isLand && isShoreline) { - // Shore (sand) - r = 204; - g = 203; - b = 158; - } else if (isLand) { - if (magnitude < 10) { - // Plains - r = 190; - g = 220 - 2 * magnitude; - b = 138; - } else if (magnitude < 20) { - // Highland - r = 200 + 2 * magnitude; - g = 183 + 2 * magnitude; - b = 138 + 2 * magnitude; - } else { - // Mountain - const v = Math.min(255, 230 + Math.floor(magnitude / 2)); - r = v; - g = v; - b = v; - } - } else if (isShoreline) { - // Shoreline water - r = 100; - g = 143; - b = 255; - } else { - // Deep water - const m = Math.min(magnitude, 10); - const offset = 11 - m; - r = Math.max(0, 70 - 10 + offset); - g = Math.max(0, 132 - 10 + offset); - b = Math.max(0, 180 - 10 + offset); - } - - const off = i * 4; - pixels[off] = r; - pixels[off + 1] = g; - pixels[off + 2] = b; - pixels[off + 3] = 255; + encodeTerrainTile(terrainBytes[i], pixels, i * 4); } - return pixels; }