diff --git a/AGENTS.md b/AGENTS.md
index 64b454a..18745c1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,13 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`GameState`, validates orders and broadcasts snapshots over the transport in
`server/server.js`. The browser client is a view: `client/js/map_view.js`
rebuilds terrain from the shared seed, renders snapshots and sends orders.
+ The map geometry (terrain, roads, borders, fog and the zoomed-out overview)
+ is drawn on a single WebGL canvas by `client/js/map_view/webgl.js`, batched
+ into per-layer buffers and row-banded so only visible tile rows are drawn;
+ the unit, city, label, path and target icons stay DOM elements on top.
+ `MapView` picks WebGL in `renderer` `"auto"` mode and falls back to the DOM
+ chunk renderer when there is no context (jsdom), when the context is backed
+ by a software rasteriser, or when constructed with `{ renderer: "dom" }`.
- Movement is continuous: `server/server.js` advances movement every tick and
strikes an in-game hour every `SECONDS_PER_HOUR` real seconds. The client
extrapolates each unit along its snapshot path and predicts move orders
diff --git a/client/bench.html b/client/bench.html
new file mode 100644
index 0000000..d40debd
--- /dev/null
+++ b/client/bench.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/client/css/style.css b/client/css/style.css
index 062b270..0f27343 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -149,7 +149,22 @@ input:focus, select:focus { border-color: var(--accent); }
}
#map-viewport.dragging { cursor: grabbing; }
-#map-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
+#map-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; z-index: 1; }
+/* The WebGL map canvas sits behind the DOM icon layers. It is viewport-sized
+ and drawn from the same camera, so it never scales with #map-world. */
+#map-canvas {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ pointer-events: none;
+}
+/* When WebGL owns the map, the DOM geometry layers are left empty and hidden;
+ the icon layers (paths, targets, entities, labels) still render. */
+#map-world.gl-render .detail-layer,
+#map-world.gl-render #layer-overview { display: none; }
/* Promote the map to its own layer only while panning, so a drag is a GPU
translation instead of a repaint of every hex. Keeping it off when idle
avoids holding a huge composited layer in memory. */
@@ -406,7 +421,7 @@ input:focus, select:focus { border-color: var(--accent); }
/* ------------------------------------------------------------------ HUD -- */
-#hud { position: absolute; inset: 0; pointer-events: none; }
+#hud { position: absolute; inset: 0; pointer-events: none; z-index: 10; }
#hud .panel { pointer-events: auto; }
.hidden { display: none !important; }
diff --git a/client/js/bench.js b/client/js/bench.js
new file mode 100644
index 0000000..6779b90
--- /dev/null
+++ b/client/js/bench.js
@@ -0,0 +1,276 @@
+// Renderer benchmark. It builds the same generated world twice — once with the
+// classic DOM/chunk renderer and once with the WebGL renderer — and measures how
+// long each takes to build and to pan. It can be driven from the browser console
+// (window.__benchPromise) or headlessly over WebDriver BiDi.
+//
+// bench.html?size=120&dpr=1 full comparison
+// bench.html?render=webgl render one map, no measurement
+//
+// The numbers count only work the page can time synchronously. Pan steps force
+// layout for the DOM renderer (getBoundingClientRect) and gl.finish() for WebGL,
+// so both include the CPU/GPU work a frame cannot avoid; compositing itself is
+// still asynchronous and is what the rAF frame-rate pass captures.
+
+import { GameState } from "../../shared/game_state.js";
+import { CIVILISATIONS, PROTO_UNITS, MAP_CONFIG } from "../../shared/data.js";
+import { MapGenerator } from "../../shared/map_generator.js";
+import { MapTopology } from "../../shared/hex.js";
+import { MapView } from "./map_view.js";
+
+const MARKUP = `
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+function mapLayers($) {
+ return {
+ overview: $("#layer-overview"),
+ terrain: $("#layer-terrain"),
+ roads: $("#layer-roads"),
+ borders: $("#layer-borders"),
+ fog: $("#layer-fog"),
+ paths: $("#layer-paths"),
+ targets: $("#layer-targets"),
+ entities: $("#layer-entities"),
+ labels: $("#layer-labels"),
+ };
+}
+
+function mount(width, height) {
+ document.getElementById("bench-root").innerHTML = MARKUP;
+ if (width && height) {
+ const viewport = document.getElementById("map-viewport");
+ viewport.style.inset = "auto";
+ viewport.style.left = "0";
+ viewport.style.top = "0";
+ viewport.style.width = `${width}px`;
+ viewport.style.height = `${height}px`;
+ }
+}
+
+function median(values) {
+ const sorted = values.slice().sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+function percentile(values, p) {
+ const sorted = values.slice().sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
+}
+
+function stats(values) {
+ const total = values.reduce((sum, v) => sum + v, 0);
+ return {
+ mean: +(total / values.length).toFixed(3),
+ median: +median(values).toFixed(3),
+ p95: +percentile(values, 0.95).toFixed(3),
+ max: +Math.max(...values).toFixed(3),
+ };
+}
+
+function waitFor(predicate, timeoutMs) {
+ return new Promise((resolve) => {
+ const start = performance.now();
+ const tick = () => {
+ if (predicate() || performance.now() - start > timeoutMs) resolve();
+ else requestAnimationFrame(tick);
+ };
+ tick();
+ });
+}
+
+function flush(map) {
+ if (map.glRenderer) {
+ map.glRenderer.gl.finish();
+ } else {
+ // Forces style recalculation and layout, the DOM renderer's dominant cost.
+ map.$world[0].getBoundingClientRect();
+ }
+}
+
+// Panning in fixed steps, timed synchronously per step.
+function measurePan(map, frames, dx) {
+ const times = [];
+ for (let i = 0; i < frames; i++) {
+ map.camera.x += dx;
+ const start = performance.now();
+ map._applyCamera();
+ flush(map);
+ times.push(performance.now() - start);
+ }
+ return stats(times);
+}
+
+// The same pan, but paced by the compositor: this is where a slow frame shows
+// up as a dropped one.
+function measureRafPan(map, durationMs, dx) {
+ return new Promise((resolve) => {
+ const frameTimes = [];
+ const start = performance.now();
+ let last = start;
+ let frames = 0;
+ const step = (now) => {
+ map.camera.x += dx;
+ map._applyCamera();
+ frames += 1;
+ frameTimes.push(now - last);
+ last = now;
+ if (now - start < durationMs) {
+ requestAnimationFrame(step);
+ } else {
+ const elapsed = now - start;
+ resolve({
+ frames,
+ elapsedMs: +elapsed.toFixed(1),
+ fps: +(frames / (elapsed / 1000)).toFixed(1),
+ frameMs: stats(frameTimes),
+ });
+ }
+ };
+ requestAnimationFrame(step);
+ });
+}
+
+function buildMap(mode, state, snapshot, width, height, zoom) {
+ mount(width, height);
+ const $ = window.jQuery;
+ const map = new MapView($("#map-viewport"), $("#map-world"), mapLayers($), { renderer: mode });
+ map.setCatalogue(
+ CIVILISATIONS.filter((c) => snapshot.civs.some((s) => s.id === c.id)),
+ PROTO_UNITS
+ );
+ const start = performance.now();
+ map.applySnapshot(snapshot);
+ if (zoom) {
+ map.camera.zoom = zoom;
+ map._applyCamera();
+ }
+ flush(map);
+ const buildMs = performance.now() - start;
+ return { map, buildMs };
+}
+
+function mapStats(map) {
+ const out = { renderer: map.glRenderer ? "webgl" : "dom" };
+ if (map.glRenderer) {
+ out.gpu = map.glRenderer.info();
+ out.vertices = {
+ terrain: map.glRenderer.terrain.count,
+ roads: map.glRenderer.roads.count,
+ borders: map.glRenderer.borders.count,
+ fog: map.glRenderer.fog.count,
+ };
+ } else {
+ let hexes = 0;
+ for (const rec of map._chunks.values()) hexes += rec.tileKeys.length;
+ out.chunks = map._chunks.size;
+ out.visibleHexes = hexes;
+ out.domNodes = document.getElementById("bench-root").querySelectorAll("*").length;
+ }
+ return out;
+}
+
+async function run() {
+ const params = new URLSearchParams(location.search);
+ const size = Number(params.get("size") || 120);
+ const vw = Number(params.get("vw") || window.innerWidth);
+ const vh = Number(params.get("vh") || window.innerHeight);
+ const zoom = Number(params.get("zoom") || 1);
+ const config = { ...MAP_CONFIG, mapSize: { x: size, y: size } };
+ const seed = 987654321;
+
+ const state = new GameState();
+ state.mapConfig = config;
+ const civs = ["france", "britain"].map((id) => CIVILISATIONS.find((c) => c.id === id));
+ state.configure(civs, seed);
+ const snapshot = state.snapshot(0);
+
+ // Generation is identical for both renderers and dominates a cold build, so
+ // time it once and report it separately.
+ const topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
+ const genStart = performance.now();
+ const generator = new MapGenerator(config, topology);
+ generator.generate(seed);
+ const generationMs = performance.now() - genStart;
+
+ // Warm up any shared caches (settlements, terrain stats) so the second
+ // renderer is not punished for being second.
+ {
+ const warm = new MapGenerator(config, topology);
+ warm.generate(seed);
+ }
+
+ const report = {
+ mapSize: size,
+ tiles: size * size,
+ viewport: { width: vw, height: vh, dpr: window.devicePixelRatio, zoom },
+ generationMs: +generationMs.toFixed(1),
+ };
+
+ // Render-only mode: draw one frame so a screenshot can be taken.
+ const renderMode = params.get("render");
+ if (renderMode) {
+ mount(vw, vh);
+ const $ = window.jQuery;
+ const map = new MapView($("#map-viewport"), $("#map-world"), mapLayers($), { renderer: renderMode });
+ map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
+ map.applySnapshot(snapshot);
+ if (zoom) {
+ map.camera.zoom = zoom;
+ map._applyCamera();
+ }
+ await waitFor(() => !map.glRenderer || map.glRenderer.texReady, 5000);
+ map._glRender();
+ flush(map);
+ report.renderOnly = mapStats(map);
+ return report;
+ }
+
+ // First the WebGL pass, then the DOM pass (the DOM pass is the one whose
+ // layout could be affected by a large live canvas, so it goes last).
+ const gl = buildMap("webgl", state, snapshot, vw, vh, zoom);
+ if (gl.map.glRenderer) {
+ report.webglBuildMs = +gl.buildMs.toFixed(1);
+ report.webgl = mapStats(gl.map);
+ }
+ const glPan = gl.map.glRenderer ? measurePan(gl.map, 240, 3) : null;
+ const glRaf = gl.map.glRenderer ? await measureRafPan(gl.map, 2000, 3) : null;
+
+ const dom = buildMap("dom", state, snapshot, vw, vh, zoom);
+ report.domBuildMs = +dom.buildMs.toFixed(1);
+ report.dom = mapStats(dom.map);
+ const domPan = measurePan(dom.map, 240, 3);
+ const domRaf = await measureRafPan(dom.map, 2000, 3);
+
+ if (glPan) report.webglPanStepMs = glPan;
+ report.domPanStepMs = domPan;
+ if (glRaf) report.webglRaf = glRaf;
+ report.domRaf = domRaf;
+
+ return report;
+}
+
+async function main() {
+ const report = await run();
+ window.__benchResult = report;
+ document.getElementById("bench-report").textContent = JSON.stringify(report, null, 2);
+ return report;
+}
+
+window.__benchResult = null;
+window.__benchPromise = main().catch((error) => {
+ window.__benchResult = { error: String((error && error.stack) || error) };
+ document.getElementById("bench-report").textContent = window.__benchResult.error;
+ throw error;
+});
diff --git a/client/js/map_view.js b/client/js/map_view.js
index 2135d79..401d578 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -12,12 +12,13 @@ import { terrainMethods } from "./map_view/terrain.js";
import { entityMethods } from "./map_view/entities.js";
import { motionMethods } from "./map_view/motion.js";
import { inputMethods } from "./map_view/input.js";
+import { createGLMapRenderer } from "./map_view/webgl.js";
// Re-exported for tests and callers that use the border geometry directly.
export { inwardOffset } from "./map_view/utils.js";
export class MapView {
- constructor($viewport, $world, layers) {
+ constructor($viewport, $world, layers, options = {}) {
this.$viewport = $viewport;
this.$world = $world;
this.$terrain = layers.terrain;
@@ -124,8 +125,49 @@ export class MapView {
this.onScheduleOrdered = () => {};
this.onStackMenu = () => {};
+ // The map (terrain, roads, borders, fog, overview) is drawn on a WebGL
+ // canvas when the browser provides a context; the icon layers stay DOM.
+ // "dom" forces the classic renderer (tests, benchmark, troubleshooting).
+ this.glRenderer = null;
+ this.glCanvas = null;
+ this.rendererMode = options.renderer || "auto";
+ if (this.rendererMode !== "dom") this._setupGLRenderer();
+
this._setupCameraInput();
}
+
+ _setupGLRenderer() {
+ // The viewport element survives between games; drop any canvas a previous
+ // MapView left behind so contexts do not accumulate.
+ const previous = this.$viewport[0].querySelector("#map-canvas");
+ if (previous) previous.remove();
+ const canvas = document.createElement("canvas");
+ canvas.id = "map-canvas";
+ const world = this.$world[0];
+ if (world.parentNode) world.parentNode.insertBefore(canvas, world);
+ const renderer = createGLMapRenderer(canvas);
+ if (!renderer) {
+ if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
+ return;
+ }
+ // A CPU rasteriser (no usable GPU) can be slower than the DOM renderer.
+ // In auto mode, keep the DOM path there; "webgl" forces the canvas.
+ if (this.rendererMode === "auto" && renderer.softwareRenderer()) {
+ renderer.dispose();
+ if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
+ return;
+ }
+ this.glRenderer = renderer;
+ this.glCanvas = canvas;
+ this.$world.addClass("gl-render");
+ }
+
+ // Draws the map canvas from the current view state, if WebGL is active. Safe
+ // to call every frame: the renderer skips untouched camera/content state.
+ _glRender() {
+ if (!this.glRenderer) return;
+ this.glRenderer.render(this, this.$viewport.width(), this.$viewport.height());
+ }
}
Object.assign(
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index 93c6131..c9d3a2c 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -67,6 +67,7 @@ export const entityMethods = {
}
this._renderBorders();
+ if (this.glRenderer) this.glRenderer.buildBorders(this);
this._drawOverview();
},
diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js
index 772750a..154f817 100644
--- a/client/js/map_view/input.js
+++ b/client/js/map_view/input.js
@@ -44,6 +44,7 @@ export const inputMethods = {
const ty = height / 2 - y * zoom;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._syncChunks();
+ this._glRender();
},
// Switches between the textured hex view and the simplified overview. The
diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js
index 4fc86d6..b68fac7 100644
--- a/client/js/map_view/motion.js
+++ b/client/js/map_view/motion.js
@@ -86,6 +86,7 @@ export const motionMethods = {
this._applyEntityWrap();
this._drawPaths();
this._drawStrikeTargets();
+ this._glRender();
},
// Repositions every unit and city at the wrapped copy nearest the camera.
diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js
index 9668b69..8f147fd 100644
--- a/client/js/map_view/terrain.js
+++ b/client/js/map_view/terrain.js
@@ -76,6 +76,9 @@ export const terrainMethods = {
this._battles.clear();
this._centered = false;
this._chunkDirty = true;
+ // A WebGL build reads the whole world out of the view, so hand it the new
+ // terrain before any of the per-layer update hooks run.
+ if (this.glRenderer) this.glRenderer.setWorld(this);
// The overview is only built when the player is (or becomes) zoomed out;
// keeping it out of startup leaves the initial view to the visible chunks.
if (this.lod) this._drawOverview(true);
@@ -271,6 +274,8 @@ export const terrainMethods = {
// it walks the chunk grid, not the tiles.
_syncChunks() {
if (!this.topology || !this._chunks || this.lod) return;
+ // With WebGL the map lives in one canvas, not in DOM chunks.
+ if (this.glRenderer) return;
const width = this.$viewport.width();
const height = this.$viewport.height();
if (!width || !height) return;
@@ -370,6 +375,10 @@ export const terrainMethods = {
},
_refreshFog() {
+ if (this.glRenderer) {
+ this.glRenderer.buildKnowledge(this);
+ return;
+ }
if (!this._chunks) return;
for (const rec of this._chunks.values()) this._buildFog(rec);
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true;
@@ -383,6 +392,10 @@ export const terrainMethods = {
if (this._roadsSignature === signature) return;
this._roadsSignature = signature;
this.roads = new Set(list.map((entry) => key(entry[0], entry[1])));
+ if (this.glRenderer) {
+ this.glRenderer.buildRoads(this);
+ return;
+ }
for (const rec of this._chunks.values()) this._buildRoads(rec);
if (this._chunkCache) {
for (const rec of this._chunkCache.values()) rec.roadsDirty = true;
@@ -479,6 +492,10 @@ export const terrainMethods = {
// OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
// elements replace the detailed chunks, so seeing the entire map stays cheap.
_drawOverview(force = false) {
+ if (this.glRenderer) {
+ if (force || this.lod) this.glRenderer.buildOverview(this);
+ return;
+ }
if (!this.$overview || !this.$overview.length || !this.topology) return;
// While the detailed view is on screen the overview is hidden; keep the
// redraw for the moment the player zooms out rather than paying for it on
@@ -500,7 +517,10 @@ export const terrainMethods = {
this.$overview.empty()[0].appendChild(fragment);
},
- _overviewBlock(bx, by, step) {
+ // The block's geometry and flat colour, independent of how it is drawn. The
+ // DOM overview turns it into a positioned div; the WebGL renderer turns it
+ // into a coloured quad. Returns null for a block with no tiles.
+ _overviewBlockData(bx, by, step) {
const endX = Math.min(bx + step, this.topology.originX + this.topology.width);
const endY = Math.min(by + step, this.topology.originY + this.topology.height);
let minX = Infinity;
@@ -538,13 +558,25 @@ export const terrainMethods = {
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1);
+ return {
+ minX,
+ minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ colour,
+ };
+ },
+
+ _overviewBlock(bx, by, step) {
+ const data = this._overviewBlockData(bx, by, step);
+ if (!data) return null;
const div = document.createElement("div");
div.className = "overview-block";
- div.style.left = `${minX}px`;
- div.style.top = `${minY}px`;
- div.style.width = `${maxX - minX}px`;
- div.style.height = `${maxY - minY}px`;
- div.style.background = colour;
+ div.style.left = `${data.minX}px`;
+ div.style.top = `${data.minY}px`;
+ div.style.width = `${data.width}px`;
+ div.style.height = `${data.height}px`;
+ div.style.background = data.colour;
return div;
},
diff --git a/client/js/map_view/utils.js b/client/js/map_view/utils.js
index 128f11c..b0cb0a6 100644
--- a/client/js/map_view/utils.js
+++ b/client/js/map_view/utils.js
@@ -132,13 +132,20 @@ export function blendColours(a, b, t) {
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
-function parseColour(value) {
- const hex = value.charAt(0) === "#" ? value.slice(1) : value;
- return [
- parseInt(hex.slice(0, 2), 16),
- parseInt(hex.slice(2, 4), 16),
- parseInt(hex.slice(4, 6), 16),
- ];
+export function parseColour(value) {
+ if (value.charAt(0) === "#") {
+ const hex = value.slice(1);
+ return [
+ parseInt(hex.slice(0, 2), 16),
+ parseInt(hex.slice(2, 4), 16),
+ parseInt(hex.slice(4, 6), 16),
+ ];
+ }
+ // Tolerates the "rgb(r, g, b)" form blendColours produces, so a blended
+ // overview colour can be fed straight back in.
+ const match = value.match(/(\d+)\D+(\d+)\D+(\d+)/);
+ if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
+ return [0, 0, 0];
}
export function clamp(value, min, max) {
diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js
new file mode 100644
index 0000000..b94cd1d
--- /dev/null
+++ b/client/js/map_view/webgl.js
@@ -0,0 +1,597 @@
+// WebGL renderer for the world map. It draws the terrain, the pre-generated
+// roads, the territory borders, the fog of war and the zoomed-out overview as
+// batched triangles on a single canvas behind the DOM icon layers. The icons
+// (units, cities, labels, paths, targets) stay ordinary DOM elements, so the
+// game's look, hit-testing and accessibility are unchanged.
+//
+// This module is client-only: it is never imported by server/ or shared/, and
+// it degrades to null when the browser cannot give us a context (which is also
+// how the jsdom test harness keeps exercising the DOM renderer).
+//
+// Geometry is built once per world in world pixels and uploaded to a static
+// vertex buffer. Each layer is one interleaved POSITION/UV/COLOR buffer and one
+// draw pass; a cylindrical world is drawn up to three times with a one-period
+// shift so the copies wrap around the camera. Per-frame work is just uniforms
+// and a handful of draw calls, so panning does not touch the DOM at all.
+
+import { HEX_W, HEX_H, mapToLocal, parseKey, key } from "../../../shared/hex.js";
+import { OVERVIEW_BLOCK } from "./constants.js";
+import { nearestCorners, inwardOffset, parseColour } from "./utils.js";
+
+// Bytes per vertex: x, y, u, v, r, g, b, a (all 32-bit floats).
+const STRIDE = 8 * 4;
+const ATTR_POS = 0;
+const ATTR_UV = 1;
+const ATTR_COLOUR = 2;
+
+const WHITE = [1, 1, 1, 1];
+const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
+const FOG_EXPLORED = [4 / 255, 6 / 255, 18 / 255, 0.62];
+const FOG_UNEXPLORED = [4 / 255, 4 / 255, 10 / 255, 1];
+// The terrain atlas is a single row of equal cells, one per terrain type.
+const ATLAS_COLS = 7;
+const ATLAS_W = HEX_W * ATLAS_COLS;
+const ATLAS_H = HEX_H;
+
+const VERTEX_SHADER = `
+attribute vec2 aPos;
+attribute vec2 aUV;
+attribute vec4 aColour;
+uniform vec2 uCamera;
+uniform vec2 uViewport;
+uniform float uZoom;
+uniform float uShiftX;
+varying vec2 vUV;
+varying vec4 vColour;
+void main() {
+ vec2 world = vec2(aPos.x + uShiftX, aPos.y);
+ vec2 screen = (world - uCamera) * uZoom + uViewport * 0.5;
+ vec2 clip = (screen / uViewport) * 2.0 - 1.0;
+ gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
+ vUV = aUV;
+ vColour = aColour;
+}
+`;
+
+const FRAGMENT_SHADER = `
+precision mediump float;
+uniform sampler2D uTexture;
+uniform float uMode;
+varying vec2 vUV;
+varying vec4 vColour;
+void main() {
+ if (uMode < 0.5) {
+ gl_FragColor = texture2D(uTexture, vUV) * vColour;
+ } else {
+ gl_FragColor = vColour;
+ }
+}
+`;
+
+// Builds a renderer on the given canvas, or null when no WebGL context can be
+// created. The availability check is done without touching canvas.getContext in
+// environments (jsdom) that would throw and log.
+export function createGLMapRenderer(canvas) {
+ if (typeof window === "undefined" || typeof document === "undefined") return null;
+ if (!window.WebGL2RenderingContext && !window.WebGLRenderingContext) return null;
+ try {
+ return new GLMapRenderer(canvas);
+ } catch {
+ return null;
+ }
+}
+
+export class GLMapRenderer {
+ constructor(canvas) {
+ this.canvas = canvas;
+ this.gl = null;
+ this.isWebGL2 = false;
+ this.lost = false;
+ this.version = 0;
+ this._sig = null;
+ this.view = null;
+ this.texture = null;
+ this.texReady = false;
+ // One pass per layer: { buffer, count } and, for terrain, its atlas cell.
+ this.terrain = { buffer: null, count: 0 };
+ this.roads = { buffer: null, count: 0 };
+ this.borders = { buffer: null, count: 0 };
+ this.fog = { buffer: null, count: 0 };
+ this.overview = { buffer: null, count: 0 };
+
+ this._initContext();
+ this._initProgram();
+ this._initTexture();
+ this._listenForContextLoss();
+ }
+
+ _initContext() {
+ const attributes = {
+ alpha: true,
+ antialias: false,
+ depth: false,
+ stencil: false,
+ premultipliedAlpha: false,
+ preserveDrawingBuffer: false,
+ powerPreference: "high-performance",
+ failIfMajorPerformanceCaveat: false,
+ };
+ const gl =
+ this.canvas.getContext("webgl2", attributes) ||
+ this.canvas.getContext("webgl", attributes) ||
+ this.canvas.getContext("experimental-webgl", attributes);
+ if (!gl) throw new Error("no WebGL context");
+ this.gl = gl;
+ this.isWebGL2 = typeof window !== "undefined" &&
+ !!window.WebGL2RenderingContext &&
+ gl instanceof window.WebGL2RenderingContext;
+ }
+
+ // Human-readable backing renderer, used by the benchmark and by callers that
+ // want to know whether they are on a GPU or a software rasteriser.
+ info() {
+ const gl = this.gl;
+ if (!gl) return { renderer: "none", vendor: "none" };
+ const debug = gl.getExtension("WEBGL_debug_renderer_info");
+ return {
+ vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
+ renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
+ webgl2: this.isWebGL2,
+ };
+ }
+
+ // Whether the context is backed by a CPU rasteriser (SwiftShader, llvmpipe,
+ // ...). Those show up on machines with no usable GPU and can be slower than
+ // the DOM renderer, so "auto" mode prefers the DOM path for them.
+ softwareRenderer() {
+ const renderer = String(this.info().renderer || "").toLowerCase();
+ return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|basic render/.test(renderer);
+ }
+
+ dispose() {
+ const gl = this.gl;
+ if (!gl) return;
+ for (const pass of [this.terrain, this.roads, this.borders, this.fog, this.overview]) {
+ if (pass.buffer) gl.deleteBuffer(pass.buffer);
+ pass.buffer = null;
+ pass.count = 0;
+ }
+ if (this.texture) gl.deleteTexture(this.texture);
+ if (this.program) gl.deleteProgram(this.program);
+ const lose = gl.getExtension("WEBGL_lose_context");
+ if (lose) lose.loseContext();
+ this.gl = null;
+ this.lost = true;
+ }
+
+ _initProgram() {
+ const gl = this.gl;
+ const vertex = this._compile(gl.VERTEX_SHADER, VERTEX_SHADER);
+ const fragment = this._compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
+ const program = gl.createProgram();
+ gl.attachShader(program, vertex);
+ gl.attachShader(program, fragment);
+ gl.bindAttribLocation(program, ATTR_POS, "aPos");
+ gl.bindAttribLocation(program, ATTR_UV, "aUV");
+ gl.bindAttribLocation(program, ATTR_COLOUR, "aColour");
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ throw new Error(`link failed: ${gl.getProgramInfoLog(program)}`);
+ }
+ gl.deleteShader(vertex);
+ gl.deleteShader(fragment);
+ this.program = program;
+ this.aPos = gl.getAttribLocation(program, "aPos");
+ this.aUV = gl.getAttribLocation(program, "aUV");
+ this.aColour = gl.getAttribLocation(program, "aColour");
+ this.uCamera = gl.getUniformLocation(program, "uCamera");
+ this.uViewport = gl.getUniformLocation(program, "uViewport");
+ this.uZoom = gl.getUniformLocation(program, "uZoom");
+ this.uShiftX = gl.getUniformLocation(program, "uShiftX");
+ this.uTexture = gl.getUniformLocation(program, "uTexture");
+ this.uMode = gl.getUniformLocation(program, "uMode");
+ }
+
+ _compile(type, source) {
+ const gl = this.gl;
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const log = gl.getShaderInfoLog(shader);
+ gl.deleteShader(shader);
+ throw new Error(`shader failed: ${log}`);
+ }
+ return shader;
+ }
+
+ _initTexture() {
+ const gl = this.gl;
+ this.texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ // A 1x1 transparent texel until the real atlas arrives.
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
+ new Uint8Array([0, 0, 0, 0]));
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ this.texReady = false;
+ const image = new Image();
+ image.onload = () => {
+ if (this.lost) return;
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+ this.texReady = true;
+ this.version += 1;
+ this._sig = null;
+ };
+ image.src = "assets/terrain.png";
+ }
+
+ _listenForContextLoss() {
+ this.canvas.addEventListener("webglcontextlost", (event) => {
+ event.preventDefault();
+ this.lost = true;
+ });
+ this.canvas.addEventListener("webglcontextrestored", () => {
+ try {
+ this._initProgram();
+ this._initTexture();
+ this.lost = false;
+ if (this.view) this.setWorld(this.view);
+ } catch {
+ this.lost = true;
+ }
+ });
+ }
+
+ // ------------------------------------------------------------ geometry --
+
+ _push(arr, x, y, u, v, colour) {
+ arr.push(x, y, u, v, colour[0], colour[1], colour[2], colour[3]);
+ }
+
+ // Adds a convex quad given its four vertices (with per-vertex UVs) as two
+ // triangles. Wind order does not matter: we never cull faces.
+ _quad(arr, corners, colour) {
+ this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
+ this._push(arr, corners[1].x, corners[1].y, corners[1].u || 0, corners[1].v || 0, colour);
+ this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
+ this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
+ this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
+ this._push(arr, corners[3].x, corners[3].y, corners[3].u || 0, corners[3].v || 0, colour);
+ }
+
+ // A rotated, optionally inward-nudged rectangle from a to b.
+ _segment(arr, a, b, width, colour, inward = 0) {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const length = Math.hypot(dx, dy) || 1;
+ const nx = -dy / length;
+ const ny = dx / length;
+ const half = width / 2;
+ const ox = nx * inward;
+ const oy = ny * inward;
+ const ax = a.x + ox;
+ const ay = a.y + oy;
+ const bx = b.x + ox;
+ const by = b.y + oy;
+ this._quad(arr, [
+ { x: ax + nx * half, y: ay + ny * half },
+ { x: bx + nx * half, y: by + ny * half },
+ { x: bx - nx * half, y: by - ny * half },
+ { x: ax - nx * half, y: ay - ny * half },
+ ], colour);
+ }
+
+ _upload(pass, arr, usage) {
+ const gl = this.gl;
+ if (!pass.buffer) pass.buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arr), usage || gl.STATIC_DRAW);
+ pass.count = arr.length / 8;
+ // Only terrain and fog are row-banded; everything else draws whole.
+ pass.rowStart = null;
+ pass.originY = 0;
+ pass.rows = 0;
+ this.version += 1;
+ this._sig = null;
+ }
+
+ // -------------------------------------------------------------- layers --
+
+ // Builds every layer from the current view state. Called when a new world is
+ // generated and again after a context loss, since the view owns all the data
+ // the renderer needs.
+ setWorld(view) {
+ this.view = view;
+ this.buildTerrain(view);
+ this.buildRoads(view);
+ this.buildBorders(view);
+ this.buildKnowledge(view);
+ this.buildOverview(view);
+ }
+
+ buildTerrain(view) {
+ const arr = [];
+ const tiles = view.tiles || {};
+ const topo = view.topology;
+ const uPad = 0.5 / ATLAS_W;
+ const vPad = 0.5 / ATLAS_H;
+ // Built row by row so the renderer can draw only the tile rows on screen.
+ // `rowStart[i]` is the first vertex of tile row i; the last entry is the
+ // total, which makes a visible range one draw call.
+ const rowStart = [];
+ const originX = topo.originX;
+ const originY = topo.originY;
+ for (let y = originY; y < originY + topo.height; y++) {
+ rowStart.push(arr.length / 8);
+ for (let x = originX; x < originX + topo.width; x++) {
+ const tile = tiles[key(x, y)];
+ if (!tile) continue;
+ const centre = mapToLocal(x, y);
+ const column = tile.col | 0;
+ const u0 = (column * HEX_W) / ATLAS_W + uPad;
+ const u1 = ((column + 1) * HEX_W) / ATLAS_W - uPad;
+ const v0 = vPad;
+ const v1 = 1 - vPad;
+ const x0 = centre.x - HEX_W / 2;
+ const x1 = centre.x + HEX_W / 2;
+ const y0 = centre.y - HEX_H / 2;
+ const y1 = centre.y + HEX_H / 2;
+ this._quad(arr, [
+ { x: x0, y: y0, u: u0, v: v0 },
+ { x: x0, y: y1, u: u0, v: v1 },
+ { x: x1, y: y1, u: u1, v: v1 },
+ { x: x1, y: y0, u: u1, v: v0 },
+ ], WHITE);
+ }
+ }
+ rowStart.push(arr.length / 8);
+ this._upload(this.terrain, arr);
+ this.terrain.rowStart = rowStart;
+ this.terrain.originY = originY;
+ this.terrain.rows = topo.height;
+ }
+
+ buildRoads(view) {
+ const arr = [];
+ const roads = view.roads;
+ if (!roads || roads.size === 0 || !view.topology) {
+ this._upload(this.roads, arr);
+ return;
+ }
+ for (const k of roads) {
+ const coords = parseKey(k);
+ const centre = mapToLocal(coords.x, coords.y);
+ for (const neighbour of view.topology.neighbours(coords.x, coords.y)) {
+ if (!roads.has(key(neighbour.x, neighbour.y))) continue;
+ const delta = view.topology.pixelDelta(coords, neighbour);
+ this._segment(arr, centre, { x: centre.x + delta.x, y: centre.y + delta.y }, 5, ROAD);
+ }
+ }
+ this._upload(this.roads, arr);
+ }
+
+ buildBorders(view) {
+ const arr = [];
+ const territory = view.territory;
+ if (!territory || territory.size === 0 || !view.topology) {
+ this._upload(this.borders, arr);
+ return;
+ }
+ for (const [k, owner] of territory) {
+ const civ = view.civilisations && view.civilisations[owner];
+ if (!civ) continue;
+ const rgb = parseColour(civ.primaryColour);
+ const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
+ const coords = parseKey(k);
+ const centre = mapToLocal(coords.x, coords.y);
+ for (const neighbour of view.topology.neighbours(coords.x, coords.y)) {
+ const nk = key(neighbour.x, neighbour.y);
+ if (territory.has(nk) && territory.get(nk) === owner) continue;
+ const delta = view.topology.pixelDelta(coords, neighbour);
+ const target = { x: centre.x + delta.x, y: centre.y + delta.y };
+ const [a, b] = nearestCorners(centre, target);
+ const width = 6;
+ const inward = inwardOffset(a, b, centre, width / 2);
+ this._segment(arr, a, b, width, colour, inward);
+ // A dot at each corner fills the notch left where two rounded edges meet.
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const length = Math.hypot(dx, dy) || 1;
+ const nx = -dy / length;
+ const ny = dx / length;
+ const corner = width * 1.5;
+ for (const point of [a, b]) {
+ const cx = point.x + nx * inward;
+ const cy = point.y + ny * inward;
+ this._quad(arr, [
+ { x: cx - corner / 2, y: cy - corner / 2 },
+ { x: cx - corner / 2, y: cy + corner / 2 },
+ { x: cx + corner / 2, y: cy + corner / 2 },
+ { x: cx + corner / 2, y: cy - corner / 2 },
+ ], colour);
+ }
+ }
+ }
+ this._upload(this.borders, arr);
+ }
+
+ buildKnowledge(view) {
+ const arr = [];
+ const tiles = view.tiles || {};
+ const topo = view.topology;
+ const explored = view.explored || new Set();
+ const visible = view.visible || new Set();
+ // A flat-top hexagon fan, matching the corners the territory code uses.
+ const half = { x: HEX_W / 2, y: HEX_H / 2 };
+ const corners = [
+ { x: half.x, y: 0 },
+ { x: half.x * 0.5, y: -half.y },
+ { x: -half.x * 0.5, y: -half.y },
+ { x: -half.x, y: 0 },
+ { x: -half.x * 0.5, y: half.y },
+ { x: half.x * 0.5, y: half.y },
+ ];
+ const rowStart = [];
+ const originX = topo.originX;
+ const originY = topo.originY;
+ for (let y = originY; y < originY + topo.height; y++) {
+ rowStart.push(arr.length / 8);
+ for (let x = originX; x < originX + topo.width; x++) {
+ const k = key(x, y);
+ if (!tiles[k] || visible.has(k)) continue;
+ const colour = explored.has(k) ? FOG_EXPLORED : FOG_UNEXPLORED;
+ const centre = mapToLocal(x, y);
+ for (let i = 0; i < corners.length; i++) {
+ const a = corners[i];
+ const b = corners[(i + 1) % corners.length];
+ this._push(arr, centre.x, centre.y, 0, 0, colour);
+ this._push(arr, centre.x + a.x, centre.y + a.y, 0, 0, colour);
+ this._push(arr, centre.x + b.x, centre.y + b.y, 0, 0, colour);
+ }
+ }
+ }
+ rowStart.push(arr.length / 8);
+ this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW);
+ this.fog.rowStart = rowStart;
+ this.fog.originY = originY;
+ this.fog.rows = topo.height;
+ }
+
+ buildOverview(view) {
+ const arr = [];
+ if (view.topology && typeof view._overviewBlockData === "function") {
+ const step = OVERVIEW_BLOCK;
+ const startX = view.topology.originX;
+ const startY = view.topology.originY;
+ const endX = startX + view.topology.width;
+ const endY = startY + view.topology.height;
+ for (let by = startY; by < endY; by += step) {
+ for (let bx = startX; bx < endX; bx += step) {
+ const data = view._overviewBlockData(bx, by, step);
+ if (!data) continue;
+ const rgb = parseColour(data.colour);
+ const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
+ this._quad(arr, [
+ { x: data.minX, y: data.minY },
+ { x: data.minX, y: data.minY + data.height },
+ { x: data.minX + data.width, y: data.minY + data.height },
+ { x: data.minX + data.width, y: data.minY },
+ ], colour);
+ }
+ }
+ }
+ this._upload(this.overview, arr);
+ }
+
+ // -------------------------------------------------------------- render --
+
+ // The horizontal periods whose copy of the world intersects the view. On a
+ // flat map this is just [0]; on a cylindrical one it is usually one shift,
+ // and two only while the seam is on screen. Drawing fewer copies avoids
+ // re-rasterising the whole map three times when zoomed out.
+ _neededShifts(view, width) {
+ const period = view._period || 0;
+ if (!period) return [0];
+ const zoom = view.camera.zoom || 1;
+ const halfWidth = width / (2 * zoom);
+ const visibleMin = view.camera.x - halfWidth;
+ const visibleMax = view.camera.x + halfWidth;
+ const worldMin = view._worldMinX;
+ const worldMax = view._worldMinX + view._worldWidth;
+ const first = Math.ceil((visibleMin - worldMax) / period);
+ const last = Math.floor((visibleMax - worldMin) / period);
+ const shifts = [];
+ for (let n = first; n <= last && shifts.length < 4; n++) shifts.push(n);
+ return shifts.length ? shifts : [0];
+ }
+
+ // The tile-row range the camera can see, as indices into a row-banded buffer.
+ // `mapToLocal` shifts a row by up to half a hex for odd columns, so the range
+ // carries a one-row margin on each side.
+ _visibleRows(view, width, height) {
+ const topo = view.topology;
+ if (!topo) return null;
+ const zoom = view.camera.zoom || 1;
+ const halfHeight = height / (2 * zoom);
+ const top = view.camera.y - halfHeight;
+ const bottom = view.camera.y + halfHeight;
+ let start = Math.floor(top / HEX_H) - 1 - topo.originY;
+ let end = Math.ceil(bottom / HEX_H) + 1 - topo.originY;
+ start = Math.max(0, Math.min(topo.height - 1, start));
+ end = Math.max(0, Math.min(topo.height - 1, end));
+ return { start, end };
+ }
+
+ _drawPass(pass, mode, shifts, period, rows) {
+ const gl = this.gl;
+ if (!pass || !pass.buffer || pass.count === 0) return;
+ gl.uniform1f(this.uMode, mode);
+ if (mode === 0) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.uniform1i(this.uTexture, 0);
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
+ gl.enableVertexAttribArray(this.aPos);
+ gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, STRIDE, 0);
+ gl.enableVertexAttribArray(this.aUV);
+ gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, STRIDE, 8);
+ gl.enableVertexAttribArray(this.aColour);
+ gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, STRIDE, 16);
+ let start = 0;
+ let count = pass.count;
+ if (rows && pass.rowStart) {
+ start = pass.rowStart[rows.start];
+ count = pass.rowStart[rows.end + 1] - start;
+ if (count <= 0) return;
+ }
+ for (const shift of shifts) {
+ gl.uniform1f(this.uShiftX, shift * period);
+ gl.drawArrays(gl.TRIANGLES, start, count);
+ }
+ }
+
+ render(view, width, height) {
+ const gl = this.gl;
+ if (!gl || this.lost || !view || !width || !height) return;
+ const dpr = Math.min((typeof window !== "undefined" && window.devicePixelRatio) || 1, 2);
+ const bufferWidth = Math.max(1, Math.round(width * dpr));
+ const bufferHeight = Math.max(1, Math.round(height * dpr));
+ if (this.canvas.width !== bufferWidth) this.canvas.width = bufferWidth;
+ if (this.canvas.height !== bufferHeight) this.canvas.height = bufferHeight;
+
+ const camera = view.camera;
+ const lod = !!view.lod;
+ const signature = `${camera.x},${camera.y},${camera.zoom},${width},${height},${lod ? 1 : 0},${this.version}`;
+ if (signature === this._sig) return;
+ this._sig = signature;
+
+ gl.viewport(0, 0, bufferWidth, bufferHeight);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.useProgram(this.program);
+ gl.uniform2f(this.uCamera, camera.x, camera.y);
+ gl.uniform2f(this.uViewport, width, height);
+ gl.uniform1f(this.uZoom, camera.zoom);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ gl.disable(gl.DEPTH_TEST);
+
+ const period = view._period || 0;
+ if (lod && this.overview.count > 0) {
+ this._drawPass(this.overview, 1, [0], period);
+ return;
+ }
+ const shifts = this._neededShifts(view, width);
+ const rows = this._visibleRows(view, width, height);
+ if (this.texReady) this._drawPass(this.terrain, 0, shifts, period, rows);
+ this._drawPass(this.roads, 1, shifts, period);
+ this._drawPass(this.borders, 1, shifts, period);
+ this._drawPass(this.fog, 1, shifts, period, rows);
+ }
+}