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:
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user