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:
2026-09-21 11:06:44 +02:00
parent bb097ea601
commit 4b634e4561
10 changed files with 510 additions and 115 deletions
+63 -10
View File
@@ -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"
);
}
}