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:
@@ -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