Added a WebGL map renderer with a DOM fallback

This commit is contained in:
2026-09-19 10:34:04 +02:00
parent aa2a8f6e69
commit 3b4e19b451
11 changed files with 1020 additions and 16 deletions
+7
View File
@@ -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 `GameState`, validates orders and broadcasts snapshots over the transport in
`server/server.js`. The browser client is a view: `client/js/map_view.js` `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. 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 - Movement is continuous: `server/server.js` advances movement every tick and
strikes an in-game hour every `SECONDS_PER_HOUR` real seconds. The client strikes an in-game hour every `SECONDS_PER_HOUR` real seconds. The client
extrapolates each unit along its snapshot path and predicts move orders extrapolates each unit along its snapshot path and predicts move orders
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Battle for 'Tismo — renderer benchmark</title>
<link rel="stylesheet" href="css/style.css" />
<style>
html, body { margin: 0; height: 100%; background: #05070c; }
#bench-root { position: fixed; inset: 0; }
#bench-report {
position: fixed; left: 8px; bottom: 8px; z-index: 999;
max-width: 46vw; max-height: 60vh; overflow: auto;
font: 12px/1.45 monospace; color: #cfe; background: rgba(0,0,0,0.72);
padding: 8px 10px; border-radius: 6px; white-space: pre-wrap;
}
</style>
</head>
<body>
<div id="bench-root"></div>
<pre id="bench-report">running…</pre>
<script src="vendor/jquery.min.js"></script>
<script type="module" src="js/bench.js"></script>
</body>
</html>
+17 -2
View File
@@ -149,7 +149,22 @@ input:focus, select:focus { border-color: var(--accent); }
} }
#map-viewport.dragging { cursor: grabbing; } #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 /* 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 translation instead of a repaint of every hex. Keeping it off when idle
avoids holding a huge composited layer in memory. */ avoids holding a huge composited layer in memory. */
@@ -406,7 +421,7 @@ input:focus, select:focus { border-color: var(--accent); }
/* ------------------------------------------------------------------ HUD -- */ /* ------------------------------------------------------------------ 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; } #hud .panel { pointer-events: auto; }
.hidden { display: none !important; } .hidden { display: none !important; }
+276
View File
@@ -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 = `
<div id="map-viewport">
<div id="map-world">
<div class="layer" id="layer-overview"></div>
<div class="layer" id="layer-terrain"></div>
<div class="layer" id="layer-roads"></div>
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div>
</div>`;
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;
});
+43 -1
View File
@@ -12,12 +12,13 @@ import { terrainMethods } from "./map_view/terrain.js";
import { entityMethods } from "./map_view/entities.js"; import { entityMethods } from "./map_view/entities.js";
import { motionMethods } from "./map_view/motion.js"; import { motionMethods } from "./map_view/motion.js";
import { inputMethods } from "./map_view/input.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. // Re-exported for tests and callers that use the border geometry directly.
export { inwardOffset } from "./map_view/utils.js"; export { inwardOffset } from "./map_view/utils.js";
export class MapView { export class MapView {
constructor($viewport, $world, layers) { constructor($viewport, $world, layers, options = {}) {
this.$viewport = $viewport; this.$viewport = $viewport;
this.$world = $world; this.$world = $world;
this.$terrain = layers.terrain; this.$terrain = layers.terrain;
@@ -124,8 +125,49 @@ export class MapView {
this.onScheduleOrdered = () => {}; this.onScheduleOrdered = () => {};
this.onStackMenu = () => {}; 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(); 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( Object.assign(
+1
View File
@@ -67,6 +67,7 @@ export const entityMethods = {
} }
this._renderBorders(); this._renderBorders();
if (this.glRenderer) this.glRenderer.buildBorders(this);
this._drawOverview(); this._drawOverview();
}, },
+1
View File
@@ -44,6 +44,7 @@ export const inputMethods = {
const ty = height / 2 - y * zoom; const ty = height / 2 - y * zoom;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`); this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._syncChunks(); if (applyWrap) this._syncChunks();
this._glRender();
}, },
// Switches between the textured hex view and the simplified overview. The // Switches between the textured hex view and the simplified overview. The
+1
View File
@@ -86,6 +86,7 @@ export const motionMethods = {
this._applyEntityWrap(); this._applyEntityWrap();
this._drawPaths(); this._drawPaths();
this._drawStrikeTargets(); this._drawStrikeTargets();
this._glRender();
}, },
// Repositions every unit and city at the wrapped copy nearest the camera. // Repositions every unit and city at the wrapped copy nearest the camera.
+38 -6
View File
@@ -76,6 +76,9 @@ export const terrainMethods = {
this._battles.clear(); this._battles.clear();
this._centered = false; this._centered = false;
this._chunkDirty = true; 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; // 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. // keeping it out of startup leaves the initial view to the visible chunks.
if (this.lod) this._drawOverview(true); if (this.lod) this._drawOverview(true);
@@ -271,6 +274,8 @@ export const terrainMethods = {
// it walks the chunk grid, not the tiles. // it walks the chunk grid, not the tiles.
_syncChunks() { _syncChunks() {
if (!this.topology || !this._chunks || this.lod) return; 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 width = this.$viewport.width();
const height = this.$viewport.height(); const height = this.$viewport.height();
if (!width || !height) return; if (!width || !height) return;
@@ -370,6 +375,10 @@ export const terrainMethods = {
}, },
_refreshFog() { _refreshFog() {
if (this.glRenderer) {
this.glRenderer.buildKnowledge(this);
return;
}
if (!this._chunks) return; if (!this._chunks) return;
for (const rec of this._chunks.values()) this._buildFog(rec); for (const rec of this._chunks.values()) this._buildFog(rec);
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true; 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; if (this._roadsSignature === signature) return;
this._roadsSignature = signature; this._roadsSignature = signature;
this.roads = new Set(list.map((entry) => key(entry[0], entry[1]))); 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); for (const rec of this._chunks.values()) this._buildRoads(rec);
if (this._chunkCache) { if (this._chunkCache) {
for (const rec of this._chunkCache.values()) rec.roadsDirty = true; 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 // OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
// elements replace the detailed chunks, so seeing the entire map stays cheap. // elements replace the detailed chunks, so seeing the entire map stays cheap.
_drawOverview(force = false) { _drawOverview(force = false) {
if (this.glRenderer) {
if (force || this.lod) this.glRenderer.buildOverview(this);
return;
}
if (!this.$overview || !this.$overview.length || !this.topology) return; if (!this.$overview || !this.$overview.length || !this.topology) return;
// While the detailed view is on screen the overview is hidden; keep the // 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 // 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); 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 endX = Math.min(bx + step, this.topology.originX + this.topology.width);
const endY = Math.min(by + step, this.topology.originY + this.topology.height); const endY = Math.min(by + step, this.topology.originY + this.topology.height);
let minX = Infinity; let minX = Infinity;
@@ -538,13 +558,25 @@ export const terrainMethods = {
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45); if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1); 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"); const div = document.createElement("div");
div.className = "overview-block"; div.className = "overview-block";
div.style.left = `${minX}px`; div.style.left = `${data.minX}px`;
div.style.top = `${minY}px`; div.style.top = `${data.minY}px`;
div.style.width = `${maxX - minX}px`; div.style.width = `${data.width}px`;
div.style.height = `${maxY - minY}px`; div.style.height = `${data.height}px`;
div.style.background = colour; div.style.background = data.colour;
return div; return div;
}, },
+14 -7
View File
@@ -132,13 +132,20 @@ export function blendColours(a, b, t) {
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
} }
function parseColour(value) { export function parseColour(value) {
const hex = value.charAt(0) === "#" ? value.slice(1) : value; if (value.charAt(0) === "#") {
return [ const hex = value.slice(1);
parseInt(hex.slice(0, 2), 16), return [
parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(4, 6), 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) { export function clamp(value, min, max) {
+597
View File
@@ -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);
}
}