Built the map fog for the visible rows only

Marching into unexplored or enemy land reveals a tile every step, and each
reveal rebuilt the whole-map fog mesh: about 1.8M vertices and 50 MB on a
160x160 map, regenerated at the rebuild throttle. Walking through
already-explored home land reveals nothing, which is why the stutter only
showed up on a border crossing, with a single unit.

- buildKnowledge now takes a row range and builds only the rows on screen; the
  row table stays full length so the existing row-banded draw keeps working.
- _flushFog rebuilds on a visibility change or a pan to a new row band, still
  coalesced, and the draw clamps to the band that was built.
- A mode/zoom change schedules the band rebuild instead of building the whole
  mesh, and uniform interior tiles skip the per-vertex blur kernel.
This commit is contained in:
2026-09-21 09:46:34 +02:00
parent b2ab4d76eb
commit bb097ea601
7 changed files with 125 additions and 29 deletions
+3 -1
View File
@@ -125,9 +125,11 @@ export class MapView {
// along railways matches the server.
this.railSpeed = 0;
this._economicSignatureDone = null;
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`).
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`),
// and only for the band of rows on screen.
this._fogDirty = false;
this._fogBuiltAt = 0;
this._fogBand = undefined;
// The colour scale shown while an economic mode is active.
this.$legend = $("#map-legend");
this._labelSvg = null;
+5 -5
View File
@@ -5,11 +5,11 @@ import { EDGE_SEGMENTS } from "./distortion.js";
export const RESYNC_HOURS = 0.75;
export const PREDICTION_GRACE_HOURS = 2.0;
// Moving units change the visible set constantly. The fog is a whole-map buffer
// in WebGL, so rebuilding it on every snapshot spike built up; the latest one is
// flushed from the frame loop at most this often. Each rebuild walks the entire
// map, so the interval is kept well above the render's 24 fps but short enough
// that revealing a newly explored tile is not noticeable.
// Moving units change the visible set constantly. The fog is a dynamically
// rebuilt mesh, so rebuilding it on every snapshot spike built up; the latest
// change is flushed from the frame loop at most this often. The mesh is only
// built for the rows on screen, so a rebuild stays cheap while a unit reveals
// new ground.
export const FOG_REBUILD_MS = 300;
// Bombardment arcs, travelling shells and target rings are DOM/SVG overlays, not
// GPU geometry, so they are not covered by the WebGL renderer's own 24 fps
+5 -4
View File
@@ -84,10 +84,11 @@ export const modeMethods = {
_repaintMode() {
this._refreshMapColours();
if (this.glRenderer && this.topology) {
this.glRenderer.buildKnowledge(this);
// This rebuild is current, so a pending coalesced one would be redundant.
this._fogDirty = false;
this._fogBuiltAt = typeof performance !== "undefined" ? performance.now() : Date.now();
// The fog only needs the rows on screen, so leave it to the frame loop's
// coalesced band rebuild instead of building the whole mesh here.
this._fogDirty = true;
this._fogBuiltAt = 0;
this._fogBand = undefined;
this.glRenderer.buildRegionOverlay(this);
}
},
+16 -4
View File
@@ -441,15 +441,27 @@ export const terrainMethods = {
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true;
},
// Rebuilds the WebGL fog if a visibility change is pending and the last
// rebuild is old enough. Called once per frame.
// Rebuilds the WebGL fog if a visibility change (or a pan to a new band of
// rows) is pending and the last rebuild is old enough. The mesh is only built
// for the rows on screen, so this is cheap enough to run while a unit is
// revealing new ground. Called once per frame.
_flushFog() {
if (!this.glRenderer || !this._fogDirty) return;
if (!this.glRenderer) return;
const width = this.$viewport.width();
const height = this.$viewport.height();
if (!width || !height) return;
const rows = this.glRenderer._visibleRows
? this.glRenderer._visibleRows(this, width, height)
: null;
const band = rows ? `${rows.start}:${rows.end}` : "all";
const bandChanged = band !== this._fogBand;
if (!bandChanged && !this._fogDirty) return;
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
if (this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return;
this._fogDirty = false;
this._fogBand = band;
this._fogBuiltAt = now;
this.glRenderer.buildKnowledge(this);
this.glRenderer.buildKnowledge(this, rows);
},
// Installs the transport network (roads and railways). Both change as players
+67 -14
View File
@@ -109,6 +109,19 @@ const FLAT_RAIL_COLOUR = [0x8a / 255, 0x5a / 255, 0x2b / 255, 1];
// A flat sample, used when a view has no relief field (tests, stub views).
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
// Whether two fog levels produce the same blurred value, so a tile whose whole
// ring shares one level can skip the per-vertex kernel.
function sameFog(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return (
a.opacity === b.opacity &&
a.rgb[0] === b.rgb[0] &&
a.rgb[1] === b.rgb[1] &&
a.rgb[2] === b.rgb[2]
);
}
// The context attributes every map context is created with. Kept here so the
// capability probe (client/js/capabilities.js) requests exactly the same context
// the renderer would, instead of a subtly different one.
@@ -1854,7 +1867,12 @@ export class GLMapRenderer {
// average of the fog levels around it, so the frontier ramps over about a
// tile instead of stepping at the hex edge. Visible tiles that border fog are
// drawn too, which is what lets the ramp fade into the clear side.
buildKnowledge(view) {
// Builds the fog sheet. `rowRange` limits the work to a band of tile rows:
// the whole-map mesh runs to millions of vertices, and a unit exploring new
// ground changes the fog constantly, so rebuilding all of it on every change
// was the biggest client-side cost of marching into enemy territory. The
// renderer's row-banded draw already only needs the rows on screen.
buildKnowledge(view, rowRange = null) {
const arr = [];
const tiles = view.tiles || {};
const topo = view.topology;
@@ -1864,9 +1882,13 @@ export class GLMapRenderer {
const visible = view.visible || new Set();
const field = this._ignoresRelief(view) ? null : this._heightField(view);
const segments = EDGE_SEGMENTS;
const rowStart = [];
const originX = topo.originX;
const originY = topo.originY;
const startRow = rowRange ? Math.max(0, rowRange.start) : 0;
const endRow = rowRange ? Math.min(topo.height - 1, rowRange.end) : topo.height - 1;
// A full-length table so the absolute row indices the draw pass uses stay
// valid; rows outside the built band keep their zero offset.
const rowStart = new Array(topo.height + 1).fill(0);
const levels = new Map();
const levelAt = (x, y) => {
const k = key(x, y);
@@ -1878,8 +1900,9 @@ export class GLMapRenderer {
}
return level;
};
for (let y = originY; y < originY + topo.height; y++) {
rowStart.push(arr.length / FOG_FLOATS);
for (let r = startRow; r <= endRow; r++) {
const y = originY + r;
rowStart[r] = arr.length / FOG_FLOATS;
for (let x = originX; x < originX + topo.width; x++) {
if (!tiles[key(x, y)]) continue;
const centre = mapToLocal(x, y);
@@ -1887,13 +1910,25 @@ export class GLMapRenderer {
// surroundings needs no geometry at all.
const ring = this._fogRing(topo, x, y, centre, levelAt);
if (!ring.some((sample) => isFogged(sample.level))) continue;
const fogAt = (px, py) => {
const samples = [];
for (const sample of ring) {
samples.push({ level: sample.level, dx: sample.x - px, dy: sample.y - py });
}
return blurFog(samples);
};
// When every sample carries the same fog, the blur resolves to that
// value over the whole tile, so the per-vertex kernel is skipped. This
// is the common interior case and the bulk of a rebuild.
const uniform = ring.every((sample) => sameFog(sample.level, ring[0].level));
let fogAt;
let centreFog;
if (uniform) {
centreFog = ring[0].level;
fogAt = () => centreFog;
} else {
fogAt = (px, py) => {
const samples = [];
for (const sample of ring) {
samples.push({ level: sample.level, dx: sample.x - px, dy: sample.y - py });
}
return blurFog(samples);
};
centreFog = fogAt(centre.x, centre.y);
}
// Fog must cover exactly the hexagon the terrain draws underneath it:
// wavy over the land and the coast, a regular hexagon over open water.
const boundary = distortedBoundary(seed, centre, period, segments,
@@ -1902,7 +1937,6 @@ export class GLMapRenderer {
for (let i = 0; i < boundary.length; i++) {
vertices[i] = fogAt(boundary[i].x, boundary[i].y);
}
const centreFog = fogAt(centre.x, centre.y);
// Fog sheets over the same raised hexagon the terrain draws, so it has
// to read the relief from the same ring.
const heightRing = field ? field.ring(x, y) : null;
@@ -1921,11 +1955,15 @@ export class GLMapRenderer {
}
}
}
rowStart.push(arr.length / FOG_FLOATS);
rowStart[endRow + 1] = arr.length / FOG_FLOATS;
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, FOG_FLOATS);
this.fog.rowStart = rowStart;
this.fog.originY = originY;
this.fog.rows = topo.height;
// The band actually built, so the draw can clamp to it while a newer one is
// still being coalesced.
this.fog.bandStart = startRow;
this.fog.bandEnd = endRow;
}
// The region of the selected/inspected tile: a soft translucent fill over
@@ -2295,10 +2333,25 @@ export class GLMapRenderer {
this._drawPass(this.flatTransport, 1, shifts, period);
}
this._drawPass(this.borders, 1, shifts, period);
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
// The fog mesh is only built for the rows currently on screen, so clamp the
// drawn band to it: while a pan is waiting its turn in the rebuild
// throttle, rows outside the built band have no fog geometry to draw.
const fogRows = this._fogBand(rows);
if (fogRows) this._drawFog(bufferWidth, bufferHeight, shifts, period, fogRows);
this._drawPass(this.regionOverlay, 1, shifts, period);
}
// The intersection of the visible rows and the fog band last built, or null
// when they do not overlap.
_fogBand(rows) {
if (!rows) return null;
const fog = this.fog;
if (fog.bandStart === undefined || fog.bandEnd === undefined) return rows;
const start = Math.max(rows.start, fog.bandStart);
const end = Math.min(rows.end, fog.bandEnd);
return start <= end ? { start, end } : null;
}
// Draws the fog into an off-screen, half-resolution target, blurs it in two
// separable passes and composites it back. Doing it as a post-process smooths
// the frontier into a soft gradient regardless of the tile-shaped geometry.
+5 -1
View File
@@ -209,7 +209,11 @@ export class MapModesTest extends TestCase {
map.mapMode = "terrain";
map.political = false;
map.setMapMode("political");
this.assertTrue(calls.includes("fog"), "a mode change does rebuild the fog");
// A mode change schedules the fog for the frame loop's band rebuild
// rather than building the whole mesh synchronously.
this.assertTrue(map._fogDirty, "a mode change schedules a fog rebuild");
map._flushFog();
this.assertTrue(calls.includes("fog"), "and the frame loop rebuilds the fog");
this.assertTrue(calls.includes("region"), "and the region overlay");
} finally {
teardownDom(env);
+24
View File
@@ -584,4 +584,28 @@ export class WebglGeometryTest extends TestCase {
}
this.assertTrue(moved, "a land tile keeps its warped outline");
}
async test_fog_builds_only_the_requested_rows() {
const renderer = makeRenderer();
renderer.fog = { name: "fog" };
renderer._warp = null;
const state = view(renderer, { size: 6 });
// Explored-but-not-visible ground is fogged, so every tile contributes.
state.explored = new Set(Object.keys(state.tiles));
state.visible = new Set();
renderer.buildKnowledge(state);
const whole = renderer.captured.fog.length;
this.assertGreater(whole, 0, "the whole-map build emits fog geometry");
renderer.buildKnowledge(state, { start: 1, end: 2 });
const band = renderer.captured.fog.length;
this.assertGreater(whole, band, "a two-row band builds far less than the whole map");
this.assertEqual(renderer.fog.bandStart, 1, "the built band start is recorded");
this.assertEqual(renderer.fog.bandEnd, 2, "the built band end is recorded");
// The row table stays full length so the draw pass can index it absolutely.
this.assertEqual(renderer.fog.rowStart.length, state.topology.height + 1);
this.assertEqual(renderer.fog.rowStart[0], 0, "rows before the band are empty");
this.assertGreater(renderer.fog.rowStart[3], 0, "rows after the band carry offsets");
}
}