Rebuilt the fog of war a tile at a time
Marching into enemy territory changes visibility every step, and each change rebuilt the whole on-screen fog band. A tile's fog depends only on its own level and its six neighbours', so the exact dependency radius is one tile, far tighter than the vision radius. - Every tile owns a fixed slot in one fog buffer, so rebuilding a tile never shifts another's vertices and only the touched row spans are re-uploaded. _applyKnowledge now diffs the visible/explored sets and hands the renderer the changed tiles; buildFogTiles rebuilds them with their rings. On a 100x100 map a frontier patch is ~3-4 ms against ~80 ms for the band. - A pan that leaves the built rows now builds only the newly exposed rows, and the escape margin scales with the viewport, so dragging rebuilds rarely. - _frame schedules the next animation frame before doing any work and catches an error, so a one-off failure can no longer stop the frame loop for good. - Added tests for the per-tile patch and the incremental pan.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+259
-74
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user