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 @@ + + + + + + Battle for 'Tismo — renderer benchmark + + + + +
+
running…
+ + + + 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); + } +}