diff --git a/client/js/map_view.js b/client/js/map_view.js index e69075a..ef6e1f3 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -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; diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 88bf6cf..c2074a6 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -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 diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index a39fef2..f44fb16 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -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); } }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index eb873fe..0117904 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -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 diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index e505dc3..b88aed1 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -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. diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index f1b855a..bac4de2 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -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); diff --git a/tests/webgl_geometry_test.js b/tests/webgl_geometry_test.js index 9a27a2a..c82fce5 100644 --- a/tests/webgl_geometry_test.js +++ b/tests/webgl_geometry_test.js @@ -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"); + } }