mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user