push terrain deltas to the WebGL view so water nukes show

Terrain was uploaded once at game start and treated as static — water
nukes (land → water conversion) mutated the sim's terrain bytes but
the rendered terrain stayed dry.

Plumbed a delta path: TerrainPass and RailroadPass each get
applyTerrainDelta(refs, bytes), Renderer + GameView forward, and
WebGLFrameBuilder pushes each tick from gameView.recentlyUpdatedTerrainTiles().
Per-tile encoding is shared via the new encodeTerrainTile helper in
ColorUtils so the startup full-map build and the per-tile delta updates
can't drift.
This commit is contained in:
evanpelle
2026-05-18 11:08:09 -07:00
parent f7dabe6a98
commit 1dd00f6264
6 changed files with 164 additions and 59 deletions
+21
View File
@@ -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;
+4
View File
@@ -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);
}
+12
View File
@@ -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);
@@ -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);
+41 -8
View File
@@ -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;
+56 -51
View File
@@ -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;
}