diff --git a/client/js/game_screen.js b/client/js/game_screen.js index 5ed5f88..9a30c45 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -501,10 +501,19 @@ export class GameScreen { _frame(now) { if (!this._entered) return; + // Schedule the next frame before doing any work: a one-off error in a + // renderer or a modal must never stop the animation loop for good. + requestAnimationFrame((t) => this._frame(t)); const dt = Math.min(0.1, (now - this._lastFrame) / 1000); this._lastFrame = now; - this.map.animate(dt); - if (this._loading) this._pollLoading(); + try { + this.map.animate(dt); + if (this._loading) this._pollLoading(); + } catch (error) { + if (typeof console !== "undefined" && console.error) { + console.error("Frame update failed:", error); + } + } this._fpsFrames += 1; this._fpsElapsed += dt; if (this._fpsElapsed >= 0.25) { @@ -514,7 +523,6 @@ export class GameScreen { const $label = $("#fps-label").text(`${fps} FPS`); $label.css("color", fps < 30 ? "#f24" : fps < 50 ? "#fa4" : "#8fdc8f"); } - requestAnimationFrame((t) => this._frame(t)); } } diff --git a/client/js/map_view.js b/client/js/map_view.js index ef6e1f3..e2c17a4 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -129,7 +129,11 @@ export class MapView { // and only for the band of rows on screen. this._fogDirty = false; this._fogBuiltAt = 0; - this._fogBand = undefined; + // The row band the fog mesh currently covers, so a pan inside it needs no + // rebuild. + this._fogCover = undefined; + // Tiles whose visibility changed since the last flush, patched per tile. + this._fogChanged = null; // 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 c2074a6..914fb2c 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -7,10 +7,14 @@ export const RESYNC_HOURS = 0.75; export const PREDICTION_GRACE_HOURS = 2.0; // 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. +// change is flushed from the frame loop at most this often, and only for the +// rows on screen. A pan that leaves the built rows bypasses this interval so it +// never shows a fog-hole. export const FOG_REBUILD_MS = 300; +// When a pan leaves the built fog rows, the replacement mesh is built a few +// rows taller than the viewport on each side, so an ordinary drag does not +// rebuild it again on the very next frame. +export const FOG_BAND_MARGIN = 8; // 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 // throttle. Rebuilding them every animation frame scales with the army (and diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index ddf5985..ab54366 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -247,10 +247,26 @@ export const entityMethods = { if (!changed) return; this._exploredCount = exploredEntries.length; this._visibleSignature = visibleSignature; - this.explored = new Set(exploredEntries.map((e) => key(e[0], e[1]))); - this.visible = new Set(visibleEntries.map((e) => key(e[0], e[1]))); + const previousExplored = this.explored; + const previousVisible = this.visible; + const nextExplored = new Set(exploredEntries.map((e) => key(e[0], e[1]))); + const nextVisible = new Set(visibleEntries.map((e) => key(e[0], e[1]))); + // The WebGL fog is built per tile, so hand it exactly the tiles whose level + // changed instead of letting it rebuild the whole visible band. A tile's + // geometry only depends on its own level and its ring's, so the frontier of + // a moving unit is all that has to be revisited. + let fogChanged = null; + if (previousExplored && previousVisible) { + fogChanged = new Set(); + for (const k of previousVisible) if (!nextVisible.has(k)) fogChanged.add(k); + for (const k of nextVisible) if (!previousVisible.has(k)) fogChanged.add(k); + for (const k of nextExplored) if (!previousExplored.has(k)) fogChanged.add(k); + if (fogChanged.size === 0) fogChanged = null; + } + this.explored = nextExplored; + this.visible = nextVisible; - this._refreshFog(); + this._refreshFog(fogChanged); }, _syncCities(cities, training = null) { diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js index b89a12d..f2e2492 100644 --- a/client/js/map_view/input.js +++ b/client/js/map_view/input.js @@ -27,8 +27,9 @@ export const inputMethods = { // `applyWrap` is false while dragging: the per-element wrapped copies are // refreshed once per animation frame in `animate`, so a high pointer rate - // cannot multiply the work. Panning and zooming from elsewhere apply them - // immediately so the view is always consistent when read. + // cannot multiply the work. The WebGL redraw still runs here, though, so the + // map canvas keeps pace with the icons the drag is moving. Panning and + // zooming from elsewhere apply the wrapped copies immediately too. _applyCamera(applyWrap = true) { const width = this.$viewport.width(); const height = this.$viewport.height(); diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index f44fb16..533b569 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -85,10 +85,12 @@ export const modeMethods = { this._refreshMapColours(); if (this.glRenderer && this.topology) { // 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. + // coalesced rebuild instead of building the whole mesh here. Clearing the + // cover makes the next flush treat it as an escape, so it runs at once. this._fogDirty = true; this._fogBuiltAt = 0; - this._fogBand = undefined; + this._fogCover = undefined; + this._fogChanged = null; this.glRenderer.buildRegionOverlay(this); } }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 0117904..ef79d3c 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -19,6 +19,7 @@ import { CHUNK_CACHE_LIMIT, CAMERA_TILT, FOG_REBUILD_MS, + FOG_BAND_MARGIN, } from "./constants.js"; import { terrainLayers, textureRepeat } from "./textures.js"; import { bootLog } from "../loading.js"; @@ -429,8 +430,17 @@ export const terrainMethods = { rec.fogEl.appendChild(fragment); }, - _refreshFog() { + // `changedTiles` is the set of tiles whose visibility just changed. The WebGL + // fog rebuilds only those (plus their rings), which is cheap enough to do + // without coalescing; anything else schedules the coalesced band rebuild. + _refreshFog(changedTiles = null) { if (this.glRenderer) { + if (changedTiles && changedTiles.size > 0 && this._fogCover && !this._fogDirty && + this.glRenderer.buildFogTiles) { + if (!this._fogChanged) this._fogChanged = new Set(); + for (const k of changedTiles) this._fogChanged.add(k); + return; + } // Do not rebuild the whole-map fog buffer on every snapshot: mark it and // let the frame loop flush it at a bounded rate. this._fogDirty = true; @@ -441,27 +451,73 @@ export const terrainMethods = { if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true; }, - // 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) return; + // The tile rows the viewport needs fog for, or null when the renderer cannot + // report them (a stub in tests, or a zero-sized viewport). + _fogVisibleRows() { + if (!this.glRenderer || !this.glRenderer._visibleRows) return null; 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; + if (!width || !height) return null; + return this.glRenderer._visibleRows(this, width, height); + }, + + // Rebuilds the WebGL fog. A visibility change patches only the tiles that + // changed (see `buildFogTiles`); a pan outside the built rows, a mode change + // or the first snapshot builds the band on screen. Called once per frame. + _flushFog() { + if (!this.glRenderer) return; + // The cheap path first: the frontier of a moving unit. Bounded by the + // vision ring, so it does not need the rebuild throttle. + if (this._fogChanged && this._fogChanged.size > 0 && this.glRenderer.buildFogTiles) { + const changed = this._fogChanged; + this._fogChanged = null; + this.glRenderer.buildFogTiles(this, changed); + } + const rows = this._fogVisibleRows(); + const cover = this._fogCover; + const covered = !!(rows && cover && rows.start >= cover.start && rows.end <= cover.end); + if (!this._fogDirty && covered) return; const now = typeof performance !== "undefined" ? performance.now() : Date.now(); - if (this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return; + // A pan that escaped the built band is covered at once, even if a previous + // rebuild is still inside the throttle window; an ordinary visibility change + // waits for the interval so a moving unit cannot rebuild every frame. + const escaping = !!(rows && !covered); + if (!escaping && this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return; this._fogDirty = false; - this._fogBand = band; this._fogBuiltAt = now; - this.glRenderer.buildKnowledge(this, rows); + if (!rows) { + this._fogCover = null; + this.glRenderer.buildKnowledge(this); + return; + } + // An escaping pan builds a margin so the next frames do not rebuild again. + // The margin grows with the viewport so a long drag hits a rebuild only every + // `margin` rows, which keeps the per-row cost of panning bounded. + const away = rows.end - rows.start; + const margin = Math.max(FOG_BAND_MARGIN, away); + const build = { start: rows.start - margin, end: rows.end + margin }; + // Only a pan can reuse the previous band: a mode change rebuilds the rows on + // screen even though they were already covered. + const previous = escaping ? this._fogCover : undefined; + this._fogCover = build; + if (!previous) { + this.glRenderer.buildKnowledge(this, build); + return; + } + // The rows the cover already holds are still valid (visibility patches keep + // them current), so a pan only builds the newly exposed rows. + if (build.start < previous.start) { + this.glRenderer.buildKnowledge(this, { + start: build.start, + end: Math.min(build.end, previous.start - 1), + }); + } + if (build.end > previous.end) { + this.glRenderer.buildKnowledge(this, { + start: Math.max(build.start, previous.end + 1), + end: build.end, + }); + } }, // 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 b88aed1..1991949 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -440,6 +440,16 @@ export class GLMapRenderer { this.flatTransport = { buffer: null, count: 0 }; this.borders = { buffer: null, count: 0 }; this.fog = { buffer: null, count: 0 }; + // The fog mesh gives every tile a fixed slot in one buffer, so rebuilding + // the few tiles whose visibility changed never shifts another tile's + // vertices and only the touched rows are re-uploaded. A tile depends only + // on its own fog level and its six neighbours' (the blur ring), so a unit + // revealing ground rewrites a handful of tiles instead of the whole band. + this._fogBuffer = null; + this._fogSlot = 0; + this._fogRowFloats = 0; + this._fogUploaded = false; + this._fogBoundary = null; // The selected/inspected tile's region: a soft fill plus dotted borders. this.regionOverlay = { buffer: null, count: 0 }; // The far-out political map: one flat country colour per owned tile. @@ -934,6 +944,16 @@ export class GLMapRenderer { this._heightView = view; this._warp = this._buildWarpSet(view); }); + // A new world has a new seed, warp and topology, so the cached fog boundary + // geometry no longer applies and the old slot layout is invalid. + this._fogBoundary = null; + this._fogBuffer = null; + this._fogSlot = 0; + this._fogRowFloats = 0; + this._fogUploaded = false; + this.fog.rowStart = null; + this.fog.bandStart = undefined; + this.fog.bandEnd = undefined; bootLog.measure("Built the coastal wave field", () => this._buildCoastTexture(view)); this.terrainLevels = []; bootLog.measure("Built the terrain mesh", () => { @@ -950,7 +970,7 @@ export class GLMapRenderer { // arrives a moment later. Skip it and let that update build it once. bootLog.measure("Built the fog of war", () => { if (view.explored && view.explored.size > 0) this.buildKnowledge(view); - else this._upload(this.fog, []); + else this._clearFog(); }); bootLog.measure("Built the selected-region overlay", () => this.buildRegionOverlay(view)); } @@ -1257,6 +1277,27 @@ export class GLMapRenderer { return vertexTextureWeights(tile, samples); } + // The wavy boundary of a tile as flat [x0, y0, x1, y1, ...] pairs. It depends + // only on the world (seed, warp, terrain), never on visibility, so it is + // computed once and reused by every fog rebuild. + _fogBoundaryAt(view, x, y, seed, period, segments) { + let cache = this._fogBoundary; + if (!cache) cache = this._fogBoundary = new Map(); + const k = key(x, y); + let points = cache.get(k); + if (!points) { + const boundary = distortedBoundary(seed, mapToLocal(x, y), period, segments, + this.edgeWaviness(view, x, y), this._warp); + points = new Float32Array(boundary.length * 2); + for (let i = 0; i < boundary.length; i++) { + points[i * 2] = boundary[i].x; + points[i * 2 + 1] = boundary[i].y; + } + cache.set(k, points); + } + return points; + } + // The fog level of the tile itself plus its six neighbours, each with its // wrapped pixel centre, used to blur the visibility frontier. _fogRing(topo, x, y, centre, levelAt) { @@ -1872,23 +1913,15 @@ export class GLMapRenderer { // 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 || {}; + // The per-tile slot layout and the level lookup shared by every fog build. + _fogContext(view) { const topo = view.topology; - const seed = view.seed | 0; - const period = periodUnits(topo); + const segments = EDGE_SEGMENTS; + const boundaryLength = CORNERS.length * segments; + const slot = boundaryLength * 3 * FOG_FLOATS; + const tiles = view.tiles || {}; const explored = view.explored || new Set(); const visible = view.visible || new Set(); - const field = this._ignoresRelief(view) ? null : this._heightField(view); - const segments = EDGE_SEGMENTS; - 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); @@ -1900,70 +1933,222 @@ export class GLMapRenderer { } return level; }; + return { + view, + topo, + tiles, + seed: view.seed | 0, + period: periodUnits(topo), + segments, + boundaryLength, + slot, + levelAt, + originX: topo.originX, + originY: topo.originY, + field: this._ignoresRelief(view) ? null : this._heightField(view), + }; + } + + // Writes one tile's fog geometry at `offset`, exactly `slot` floats. Returns + // false when the tile clears, so the caller can zero the slot. + _writeFogTile(ctx, x, y, out, offset) { + const { topo, tiles, levelAt, slot, boundaryLength } = ctx; + if (!tiles[key(x, y)]) return false; + const centre = mapToLocal(x, y); + const ring = this._fogRing(topo, x, y, centre, levelAt); + if (!ring.some((sample) => isFogged(sample.level))) return false; + 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); + } + const points = this._fogBoundaryAt(ctx.view, x, y, ctx.seed, ctx.period, ctx.segments); + const heightRing = ctx.field ? ctx.field.ring(x, y) : null; + const heightAt = (px, py) => (ctx.field && heightRing ? ctx.field.blend(heightRing, px, py) : 0); + let o = offset; + const emit = (px, py, fog) => { + out[o++] = px; + out[o++] = py; + out[o++] = heightAt(px, py) || 0; + out[o++] = fog.rgb[0]; + out[o++] = fog.rgb[1]; + out[o++] = fog.rgb[2]; + out[o++] = fog.opacity; + }; + let lastX = points[(boundaryLength - 1) * 2]; + let lastY = points[(boundaryLength - 1) * 2 + 1]; + let lastFog = fogAt(lastX, lastY); + for (let i = 0; i < boundaryLength; i++) { + const ax = points[i * 2]; + const ay = points[i * 2 + 1]; + const aFog = fogAt(ax, ay); + emit(centre.x, centre.y, centreFog); + emit(lastX, lastY, lastFog); + emit(ax, ay, aFog); + lastX = ax; + lastY = ay; + lastFog = aFog; + } + return true; + } + + _writeFogRows(ctx, startRow, endRow, out) { + const { topo, slot, originX, originY } = ctx; 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); - // The tile and its neighbours; a fully clear tile with clear - // surroundings needs no geometry at all. - const ring = this._fogRing(topo, x, y, centre, levelAt); - if (!ring.some((sample) => isFogged(sample.level))) continue; - // 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, - this.edgeWaviness(view, x, y), this._warp); - const vertices = new Array(boundary.length); - for (let i = 0; i < boundary.length; i++) { - vertices[i] = fogAt(boundary[i].x, boundary[i].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; - const heightAt = (px, py) => - (field && heightRing ? field.blend(heightRing, px, py) : 0); - const push = (point, fog) => { - this._pushSimple(arr, point.x, point.y, heightAt(point.x, point.y), - [fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]); - }; - for (let i = 0; i < boundary.length; i++) { - const a = boundary[i]; - const b = boundary[(i + 1) % boundary.length]; - push(centre, centreFog); - push(a, vertices[i]); - push(b, vertices[(i + 1) % boundary.length]); + let offset = r * topo.width * slot; + for (let c = 0; c < topo.width; c++) { + if (!this._writeFogTile(ctx, originX + c, y, out, offset)) { + out.fill(0, offset, offset + slot); } + offset += slot; } } - 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; + } + + // Uploads the whole buffer (first build of a world) or just the rows rebuilt. + _ensureFogBuffer() { + const gl = this.gl; + if (!this.fog.buffer) this.fog.buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.fog.buffer); + if (this._fogUploaded) return false; + gl.bufferData(gl.ARRAY_BUFFER, this._fogBuffer, gl.DYNAMIC_DRAW); + this._fogUploaded = true; + return true; + } + + _uploadFogFloats(from, to) { + if (from >= to) return; + // A first build uploads the whole buffer, so a range upload is redundant. + if (!this._ensureFogBuffer()) { + const gl = this.gl; + gl.bufferSubData(gl.ARRAY_BUFFER, from * 4, this._fogBuffer.subarray(from, to)); + } + this.version += 1; + this._sig = null; + } + + _clearFog() { + this._fogBuffer = null; + this._fogSlot = 0; + this._fogUploaded = false; + this.fog.rowStart = null; + this.fog.originY = 0; + this.fog.rows = 0; + this.fog.bandStart = undefined; + this.fog.bandEnd = undefined; + this._upload(this.fog, [], this.gl.DYNAMIC_DRAW, FOG_FLOATS); + } + + buildKnowledge(view, rowRange = null) { + const topo = view.topology; + // The frame loop runs before the first snapshot has built the world, so + // there is no topology yet. Clear the fog instead of throwing: an exception + // here would escape `animate` and kill the whole frame loop. + if (!topo) { + this._clearFog(); + return; + } + const ctx = this._fogContext(view); + const width = topo.width; + const height = topo.height; + const rowFloats = width * ctx.slot; + const total = height * rowFloats; + if (ctx.slot !== this._fogSlot || !this._fogBuffer || this._fogBuffer.length !== total) { + this._fogBuffer = new Float32Array(total); + this._fogSlot = ctx.slot; + this._fogRowFloats = rowFloats; + this._fogUploaded = false; + } + const startRow = rowRange ? Math.max(0, rowRange.start) : 0; + const endRow = rowRange ? Math.min(height - 1, rowRange.end) : height - 1; + this._writeFogRows(ctx, startRow, endRow, this._fogBuffer); + const rowFloatsTotal = this._fogRowFloats; + this._uploadFogFloats(startRow * rowFloatsTotal, (endRow + 1) * rowFloatsTotal); + if (!this.fog.rowStart) { + // A full-length table of fixed offsets: every row owns `width * slot` + // floats, so rebuilding a tile never moves another tile's vertices and + // the existing row-banded draw keeps working unchanged. + const rowStart = new Array(height + 1); + for (let r = 0; r <= height; r++) rowStart[r] = (r * rowFloats) / FOG_FLOATS; + this.fog.rowStart = rowStart; + } + this.fog.floats = FOG_FLOATS; + this.fog.count = total / FOG_FLOATS; + this.fog.originY = topo.originY; + this.fog.rows = height; + // Every row has a slot, so the draw never clamps to a band; rows never + // built hold zeroed slots and draw nothing. + this.fog.bandStart = 0; + this.fog.bandEnd = height - 1; + } + + // Rebuilds only the tiles whose visibility changed and their six neighbours, + // then re-uploads just the rows touched. A tile's fog depends only on its own + // level and its ring's, so this is what keeps a unit revealing ground cheap: + // the work scales with the frontier, not with the viewport. + buildFogTiles(view, tiles) { + const topo = view && view.topology; + if (!topo || !this._fogBuffer || !tiles || tiles.size === 0) return; + const total = topo.height * topo.width * this._fogSlot; + if (this._fogSlot === 0 || this._fogBuffer.length !== total) return; + const ctx = this._fogContext(view); + if (ctx.slot !== this._fogSlot) return; + const seen = new Set(); + const rowSpan = new Map(); + const rebuild = (x, y) => { + const r = y - ctx.originY; + const c = x - ctx.originX; + if (r < 0 || r >= topo.height || c < 0 || c >= topo.width) return; + const idx = r * topo.width + c; + if (seen.has(idx)) return; + seen.add(idx); + const offset = idx * ctx.slot; + if (!this._writeFogTile(ctx, x, y, this._fogBuffer, offset)) { + this._fogBuffer.fill(0, offset, offset + ctx.slot); + } + const span = rowSpan.get(r); + if (!span) rowSpan.set(r, { min: c, max: c }); + else { + if (c < span.min) span.min = c; + if (c > span.max) span.max = c; + } + }; + for (const k of tiles) { + const coords = parseKey(k); + rebuild(coords.x, coords.y); + for (const neighbour of topo.neighbours(coords.x, coords.y)) { + rebuild(neighbour.x, neighbour.y); + } + } + if (rowSpan.size === 0) return; + // Upload only the columns touched on each row, so a change on the frontier + // is a small upload rather than a whole row of the map. + const first = this._ensureFogBuffer(); + if (first) { + this.version += 1; + this._sig = null; + return; + } + const gl = this.gl; + for (const [r, span] of rowSpan) { + const from = (r * topo.width + span.min) * ctx.slot; + const to = (r * topo.width + span.max + 1) * ctx.slot; + gl.bufferSubData(gl.ARRAY_BUFFER, from * 4, this._fogBuffer.subarray(from, to)); + } + this.version += 1; + this._sig = null; } // The region of the selected/inspected tile: a soft translucent fill over diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index bac4de2..1eb8ffa 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -166,6 +166,41 @@ export class MapModesTest extends TestCase { } } + async test_a_pan_inside_the_fog_cover_does_not_rebuild() { + const env = await setupDom(); + const map = this.buildMap(env); + try { + let builds = 0; + let rows = { start: 10, end: 20 }; + map.glRenderer = { + _visibleRows: () => rows, + buildKnowledge: () => { builds += 1; }, + }; + map._fogCover = undefined; + map._fogBuiltAt = 0; + map._fogDirty = false; + + map._flushFog(); + this.assertEqual(builds, 1, "the first flush covers the viewport"); + + // Inside the margin that flush built, so a small pan is free even once + // the throttle window has passed. + rows = { start: 12, end: 22 }; + map._fogBuiltAt = 0; + map._flushFog(); + this.assertEqual(builds, 1, "a pan inside the cover needs no rebuild"); + + // A pan that leaves the built rows must be covered immediately, throttle + // or not, or the view would show an unfogged hole. + rows = { start: 60, end: 70 }; + map._fogBuiltAt = performance.now(); + map._flushFog(); + this.assertEqual(builds, 2, "leaving the cover rebuilds at once"); + } finally { + teardownDom(env); + } + } + async test_country_names_sit_below_every_icon_layer() { const env = await setupDom(); try { @@ -220,6 +255,37 @@ export class MapModesTest extends TestCase { } } + async test_an_escaping_pan_builds_only_the_new_rows() { + const env = await setupDom(); + const map = this.buildMap(env); + try { + const ranges = []; + let rows = { start: 10, end: 20 }; + map.glRenderer = { + _visibleRows: () => rows, + buildFogTiles: () => {}, + buildKnowledge: (_view, range) => ranges.push(range ? { ...range } : null), + }; + map._fogCover = undefined; + map._fogBuiltAt = 0; + map._fogDirty = false; + map._fogChanged = null; + map._flushFog(); + this.assertSize(ranges, 1, "the first flush builds the band"); + + // A pan that leaves the cover should rebuild only the rows the cover did + // not already hold, so dragging costs a few rows rather than a screenful. + rows = { start: 40, end: 50 }; + map._fogBuiltAt = 0; + map._flushFog(); + this.assertSize(ranges, 2, "leaving the cover builds the new rows"); + this.assertEqual(ranges[1].start, ranges[0].end + 1, "only rows past the old cover"); + this.assertTrue(ranges[1].start > 20 && ranges[1].end >= 50, "the newly exposed rows"); + } finally { + teardownDom(env); + } + } + buildMap(env) { const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); map.$viewport.width = () => 800; diff --git a/tests/webgl_geometry_test.js b/tests/webgl_geometry_test.js index c82fce5..58cfc78 100644 --- a/tests/webgl_geometry_test.js +++ b/tests/webgl_geometry_test.js @@ -10,7 +10,15 @@ import { MapTopology, key, mapToLocal } from "../shared/hex.js"; // stubbed so the builders can run without a context. function makeRenderer() { const renderer = Object.create(GLMapRenderer.prototype); - renderer.gl = { DYNAMIC_DRAW: 0 }; + renderer._uploads = []; + renderer._subUploads = []; + renderer.gl = { + DYNAMIC_DRAW: 0, + createBuffer: () => ({}), + bindBuffer: () => {}, + bufferData: (_target, data) => { renderer._uploads.push(data); }, + bufferSubData: (_target, _offset, data) => { renderer._subUploads.push(data); }, + }; renderer.captured = {}; renderer.borders = { name: "borders" }; renderer.roads = { name: "roads" }; @@ -585,6 +593,19 @@ export class WebglGeometryTest extends TestCase { this.assertTrue(moved, "a land tile keeps its warped outline"); } + async test_fog_build_is_a_noop_before_the_world_exists() { + const renderer = makeRenderer(); + renderer.fog = { name: "fog" }; + renderer._warp = null; + // The frame loop runs before the first snapshot has built the world, so + // there is no topology yet. This must not throw, or the exception escapes + // `animate` and the animation loop never schedules another frame. + renderer.buildKnowledge({ seed: 1, tiles: {} }); + this.assertEqual(renderer.captured.fog.length, 0, "no fog is built without a topology"); + this.assertEqual(renderer.fog.rowStart, null, "the row table is cleared"); + this.assertEqual(renderer.fog.bandStart, undefined, "no band is claimed"); + } + async test_fog_builds_only_the_requested_rows() { const renderer = makeRenderer(); renderer.fog = { name: "fog" }; @@ -595,17 +616,49 @@ export class WebglGeometryTest extends TestCase { state.visible = new Set(); renderer.buildKnowledge(state); - const whole = renderer.captured.fog.length; - this.assertGreater(whole, 0, "the whole-map build emits fog geometry"); + const slot = renderer._fogSlot; + const rowFloats = renderer._fogRowFloats; + this.assertGreater(slot, 0, "a tile owns a fixed slot"); + this.assertEqual(renderer._fogBuffer.length, 6 * 6 * slot, "the buffer holds every tile"); + // A band build rewrites only its rows and re-uploads only those rows. + renderer._fogBuffer.fill(-1); + renderer._subUploads.length = 0; 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. + const row0 = renderer._fogBuffer.subarray(0, rowFloats); + const row1 = renderer._fogBuffer.subarray(1 * rowFloats, 2 * rowFloats); + this.assertTrue(row0.every((v) => v === -1), "rows outside the band are untouched"); + this.assertTrue(row1.some((v) => v > 0), "the requested rows are rebuilt"); + this.assertTrue(renderer._subUploads.length > 0, "only the rebuilt rows are re-uploaded"); + // The row table stays full length with fixed offsets the draw can index. 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"); + this.assertEqual(renderer.fog.rowStart[0], 0, "row zero starts at zero"); + this.assertEqual(renderer.fog.rowStart[1], rowFloats / 7, "rows carry fixed offsets"); + } + + async test_fog_patches_only_the_changed_tiles() { + const renderer = makeRenderer(); + renderer.fog = { name: "fog" }; + renderer._warp = null; + const state = view(renderer, { size: 6 }); + state.explored = new Set(Object.keys(state.tiles)); + state.visible = new Set(); + renderer.buildKnowledge(state); + const slot = renderer._fogSlot; + const before = Float32Array.from(renderer._fogBuffer); + const index = (r, c) => (r * 6 + c) * slot; + const revealed = key(state.topology.originX + 3, state.topology.originY + 3); + state.visible = new Set([revealed]); + renderer.buildFogTiles(state, new Set([revealed])); + this.assertTrue( + renderer._fogBuffer.subarray(index(3, 3), index(3, 3) + slot) + .some((v, i) => v !== before[index(3, 3) + i]), + "the revealed tile is rebuilt" + ); + this.assertTrue( + renderer._fogBuffer.subarray(index(0, 0), index(0, 0) + slot) + .every((v, i) => v === before[index(0, 0) + i]), + "a distant tile is left alone" + ); } }