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
`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
+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-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; }
+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 { 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(
+1
View File
@@ -67,6 +67,7 @@ export const entityMethods = {
}
this._renderBorders();
if (this.glRenderer) this.glRenderer.buildBorders(this);
this._drawOverview();
},
+1
View File
@@ -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
+1
View File
@@ -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.
+38 -6
View File
@@ -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;
},
+14 -7
View File
@@ -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) {
+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);
}
}