Files
Battle-for-Tismo/client/js/map_view/webgl.js
T

1587 lines
64 KiB
JavaScript

// WebGL renderer for the world map. It draws the terrain, the pre-generated
// roads, the territory borders, the fog of war and the selected region 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 { nearestCorners, parseColour } from "./utils.js";
import {
CORNERS,
EDGE_SEGMENTS,
bentPath,
distortedBoundary,
edgePoint,
latticeKey,
periodUnits,
} from "./distortion.js";
import {
TEXTURE_DIR,
TEXTURE_FOREST,
TEXTURE_GRASS,
TEXTURE_HILLS,
TEXTURE_HILLS_NORMAL,
TEXTURE_MOUNTAIN,
TEXTURE_MOUNTAIN_NORMAL,
TEXTURE_SAND,
isTexturedLand,
textureRepeat,
vertexTextureWeights,
} from "./textures.js";
import { blurFog, fogLevel, isFogged } from "./fog.js";
import {
CAMERA_TILT,
HEIGHT_LIFT,
MOUNTAIN_ROCK_LOW,
MOUNTAIN_ROCK_HIGH,
TERRAIN_LODS,
terrainLod,
} from "./constants.js";
import { createHeightField } from "./heightmap.js";
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, height, the base
// colour (grass, sand, atlas), one weight per overlay, then the two slope
// components. Fog uses a compact x, y, height, r, g, b, a layout instead. The
// flat-colour passes (roads, borders, region) use x, y, height, padding,
// r, g, b, a.
const ATTR_POS = 0;
const ATTR_UV = 1;
const ATTR_COLOUR = 2;
const ATTR_WORLD = 3;
const ATTR_OVERLAY = 4;
const ATTR_HEIGHT = 5;
const ATTR_SLOPE = 6;
// Floats per vertex in each layout. Terrain is
// x, y, u, v, wx, wy, height, four base weights, four overlay weights, then the
// two slope components. The flat colour passes are x, y, height, padding,
// r, g, b, a. Fog is x, y, height, r, g, b, a.
const TERRAIN_FLOATS = 17;
const COLOUR_FLOATS = 8;
const FOG_FLOATS = 7;
// A flat sample, used when a view has no relief field (tests, stub views).
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
// The terrain atlas is a single row of equal cells, one per terrain type.
const ATLAS_COLS = 7;
// Texture unit per sampler.
const UNIT_ATLAS = 0;
const UNIT_GRASS = 1;
const UNIT_SAND = 2;
const UNIT_FOREST = 3;
const UNIT_HILLS = 4;
const UNIT_MOUNTAIN = 5;
const UNIT_HILLS_NORMAL = 6;
const UNIT_MOUNTAIN_NORMAL = 7;
// National border stroke, and the region border that is half as thick and
// drawn as dotted dashes instead of a continuous line.
const NATIONAL_BORDER = 3.5;
const REGION_BORDER = NATIONAL_BORDER / 2;
const REGION_FILL = [1, 1, 1, 0.2];
const REGION_BORDER_COLOUR = [1, 1, 1, 0.9];
const REGION_DASHES = 6;
const VERTEX_SHADER = `
attribute vec2 aPos;
attribute vec2 aUV;
attribute vec4 aColour;
attribute vec2 aWorld;
attribute vec4 aOverlay;
attribute float aHeight;
attribute vec2 aSlope;
uniform vec2 uCamera;
uniform vec2 uViewport;
uniform float uZoom;
uniform float uTilt;
uniform float uShiftX;
uniform float uHeightScale;
varying vec2 vUV;
varying vec4 vColour;
varying vec2 vWorld;
varying vec4 vOverlay;
varying vec2 vSlope;
varying float vHeight;
void main() {
// Orthographic camera looking north at uTilt over the ground: the north-south
// axis is foreshortened, the east-west axis is untouched. A terrain height
// lifts the vertex northward on screen (the projection of the vertical axis),
// before the zoom scales the whole map.
vec2 world = vec2(aPos.x + uShiftX, aPos.y);
float lift = aHeight * uHeightScale;
vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt - lift) * 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;
vWorld = aWorld;
vOverlay = aOverlay;
// The gradient rides in normalised height per pixel; scale it into the same
// world units the lift uses so the fragment normal is geometrically true.
vSlope = aSlope * uHeightScale;
vHeight = aHeight;
}
`;
// Terrain (uMode 0) blends the two tiling land textures and the atlas by the
// per-vertex weights, composites the forest/hills/mountain overlays on top and
// replaces sea with the animated water. The flat passes (uMode 1) just output
// their vertex colour.
const FRAGMENT_SHADER = `
precision mediump float;
uniform sampler2D uAtlas;
uniform sampler2D uGrass;
uniform sampler2D uSand;
uniform sampler2D uForest;
uniform sampler2D uHills;
uniform sampler2D uMountain;
uniform sampler2D uHillsNormal;
uniform sampler2D uMountainNormal;
uniform float uTextureRepeat;
uniform float uTime;
uniform float uMode;
uniform float uRelief;
uniform float uRockLow;
uniform float uRockHigh;
varying vec2 vUV;
varying vec4 vColour;
varying vec2 vWorld;
varying vec4 vOverlay;
varying vec2 vSlope;
varying float vHeight;
// The relief normal is rebuilt from the red/green gradient and a fixed up axis,
// so the maps' blue channel (which is not a clean 255 at rest) cannot bias the
// lighting. A flat texel is (0, 0, 1) and lights to exactly 1.
vec3 reliefNormal(vec4 texel) {
vec2 slope = texel.xy * 2.0 - 1.0;
return normalize(vec3(slope, 1.0));
}
// Procedural sea: a few crossing swells drive the deep/shallow gradient and a
// soft specular sparkle. It is driven by the same period-snapped texture
// coordinate as the land, whose whole cycles repeat with the world, so the waves
// stay seamless across both tile edges and the cylindrical wrap.
float waterWave(vec2 uv, vec2 freq, float speed, float t) {
return sin(6.2831853 * dot(freq, uv) + t * speed);
}
vec3 waterColour(vec2 uv, float t) {
float swell = waterWave(uv, vec2(1.0, 0.6), 0.55, t) * 0.4
+ waterWave(uv, vec2(-2.0, 1.3), 0.8, t) * 0.3
+ waterWave(uv, vec2(3.0, 0.4), 1.05, t) * 0.25;
float ripple = 0.5 + 0.5 * waterWave(uv, vec2(5.0, -3.0), 1.6, t);
// A narrow, low-contrast blue so the sea reads as calm water rather than a
// busy pattern. Depth drifts only slightly and the highlights are faint.
vec3 deep = vec3(0.078, 0.235, 0.353);
vec3 shallow = vec3(0.098, 0.275, 0.396);
vec3 colour = mix(deep, shallow, clamp(swell * 0.35 + 0.5, 0.0, 1.0));
colour += vec3(0.04, 0.06, 0.07) * pow(clamp(swell, 0.0, 1.0), 3.0);
colour += vec3(0.06, 0.08, 0.09) * pow(ripple, 8.0);
return colour;
}
void main() {
if (uMode < 0.5) {
vec2 uv = vWorld / uTextureRepeat;
vec3 grass = texture2D(uGrass, uv).rgb;
vec3 sand = texture2D(uSand, uv).rgb;
vec4 atlas = texture2D(uAtlas, vUV);
vec3 base = grass * vColour.r + sand * vColour.g + atlas.rgb * vColour.b;
vec4 forest = texture2D(uForest, uv);
vec4 hills = texture2D(uHills, uv);
vec4 mountain = texture2D(uMountain, uv);
// Mountain rock only caps the higher ground: the lower slopes reveal the
// grass base, so the texture follows the relief instead of filling the
// whole mountain tile. The terrain-type weight still decides where the
// range is at all.
float rock = vOverlay.z * smoothstep(uRockLow, uRockHigh, vHeight);
base = mix(base, forest.rgb, forest.a * vOverlay.x);
base = mix(base, hills.rgb, hills.a * vOverlay.y);
base = mix(base, mountain.rgb, mountain.a * rock);
// Coastal sand fades the sand texture into non-mountain shores.
base = mix(base, sand, vOverlay.w);
// Sea tiles are replaced by the animated water; the flag is 0 elsewhere.
base = mix(base, waterColour(uv, uTime), vColour.a);
// A light from the upper left rakes over the terrain. The geometry normal
// is built from the height gradient carried per vertex; the hill and
// mountain normal maps perturb it with their surface detail. Dividing the
// dot product by the light's up component makes flat ground light to
// exactly 1, so only slopes and relief change.
vec3 light = normalize(vec3(-0.5, -0.6, 0.8));
vec2 detail = (reliefNormal(texture2D(uHillsNormal, uv)).xy * vOverlay.y
+ reliefNormal(texture2D(uMountainNormal, uv)).xy * rock) * uRelief;
vec3 normal = normalize(vec3(-vSlope, 1.0) + vec3(detail * 0.5, 0.0));
float shade = max(dot(normal, light), 0.0) / light.z;
base *= clamp(shade, 0.55, 1.6);
// Tiling land is opaque; the legacy atlas keeps its hexagonal alpha so its
// transparent corners are not painted black.
float alpha = mix(1.0, atlas.a, vColour.b);
gl_FragColor = vec4(base, alpha);
} else {
gl_FragColor = vColour;
}
}
`;
// A clip-space quad with a matching 0..1 UV, used by the fog post-process. It
// carries no camera transform, so it fills whatever framebuffer is bound.
const FS_VERTEX_SHADER = `
attribute vec2 aPos;
attribute vec2 aUV;
varying vec2 vUV;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vUV = aUV;
}
`;
// Nine-tap separable Gaussian (uMode 0) and a plain copy (uMode 1) for the
// final composite. `uTexel` is one off-screen texel and `uDirection` picks the
// axis, so the same shader runs both blur passes.
const FS_FRAGMENT_SHADER = `
precision mediump float;
uniform sampler2D uTexture;
uniform vec2 uTexel;
uniform vec2 uDirection;
uniform float uMode;
varying vec2 vUV;
void main() {
if (uMode > 0.5) {
gl_FragColor = texture2D(uTexture, vUV);
return;
}
vec2 step = uTexel * uDirection;
vec4 sum = texture2D(uTexture, vUV) * 0.2270270270;
sum += texture2D(uTexture, vUV + step) * 0.1945945946;
sum += texture2D(uTexture, vUV - step) * 0.1945945946;
sum += texture2D(uTexture, vUV + step * 2.0) * 0.1216216216;
sum += texture2D(uTexture, vUV - step * 2.0) * 0.1216216216;
sum += texture2D(uTexture, vUV + step * 3.0) * 0.0540540541;
sum += texture2D(uTexture, vUV - step * 3.0) * 0.0540540541;
sum += texture2D(uTexture, vUV + step * 4.0) * 0.0162162162;
sum += texture2D(uTexture, vUV - step * 4.0) * 0.0162162162;
gl_FragColor = sum;
}
`;
// How far the fog blur reaches, in off-screen texels. The buffer is half
// resolution, so a step of 3 is six screen pixels per tap and the kernel
// spreads over about twenty-four.
const FOG_BLUR_STEP = 3.0;
// 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;
// One GL texture per source: the legacy atlas plus the three land textures.
this.textures = {};
this.texReady = false;
// One pass per layer: { buffer, count } and, for terrain, its atlas cell.
this.terrain = { buffer: null, count: 0 };
// Terrain meshes for the levels of detail, built lazily as the camera zooms
// out; level 0 is built with the world.
this.terrainLevels = [];
// The deterministic relief field for the current world.
this.heightField = null;
this._heightView = null;
this.roads = { buffer: null, count: 0 };
this.borders = { buffer: null, count: 0 };
this.fog = { buffer: null, count: 0 };
// The selected/inspected tile's region: a soft fill plus dotted borders.
this.regionOverlay = { buffer: null, count: 0 };
// The fog is rendered off-screen and blurred before it is composited, so
// its frontier is a soft gradient rather than the hex-tiling edges.
this.fullscreenProgram = null;
this.fullscreenQuad = null;
this.fogBlur = null;
this._initContext();
this._initProgram();
try {
this._initFullscreenProgram();
} catch {
// Without the blur program the fog is still drawn, just without the
// post-process, so a picky driver does not cost us the whole map.
this.fullscreenProgram = null;
this.fullscreenQuad = null;
}
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.regionOverlay,
...this.terrainLevels,
]) {
if (!pass || !pass.buffer) continue;
gl.deleteBuffer(pass.buffer);
pass.buffer = null;
pass.count = 0;
}
this.terrainLevels = [];
for (const texture of Object.values(this.textures)) gl.deleteTexture(texture);
this.textures = {};
if (this.program) gl.deleteProgram(this.program);
if (this.fullscreenProgram) gl.deleteProgram(this.fullscreenProgram);
if (this.fullscreenQuad) gl.deleteBuffer(this.fullscreenQuad);
this._disposeFogBlur();
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.bindAttribLocation(program, ATTR_WORLD, "aWorld");
gl.bindAttribLocation(program, ATTR_OVERLAY, "aOverlay");
gl.bindAttribLocation(program, ATTR_HEIGHT, "aHeight");
gl.bindAttribLocation(program, ATTR_SLOPE, "aSlope");
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.aWorld = gl.getAttribLocation(program, "aWorld");
this.aOverlay = gl.getAttribLocation(program, "aOverlay");
this.aHeight = gl.getAttribLocation(program, "aHeight");
this.aSlope = gl.getAttribLocation(program, "aSlope");
this.uCamera = gl.getUniformLocation(program, "uCamera");
this.uViewport = gl.getUniformLocation(program, "uViewport");
this.uZoom = gl.getUniformLocation(program, "uZoom");
this.uTilt = gl.getUniformLocation(program, "uTilt");
this.uShiftX = gl.getUniformLocation(program, "uShiftX");
this.uAtlas = gl.getUniformLocation(program, "uAtlas");
this.uGrass = gl.getUniformLocation(program, "uGrass");
this.uSand = gl.getUniformLocation(program, "uSand");
this.uForest = gl.getUniformLocation(program, "uForest");
this.uHills = gl.getUniformLocation(program, "uHills");
this.uMountain = gl.getUniformLocation(program, "uMountain");
this.uHillsNormal = gl.getUniformLocation(program, "uHillsNormal");
this.uMountainNormal = gl.getUniformLocation(program, "uMountainNormal");
this.uTextureRepeat = gl.getUniformLocation(program, "uTextureRepeat");
this.uTime = gl.getUniformLocation(program, "uTime");
this.uMode = gl.getUniformLocation(program, "uMode");
this.uHeightScale = gl.getUniformLocation(program, "uHeightScale");
this.uRelief = gl.getUniformLocation(program, "uRelief");
this.uRockLow = gl.getUniformLocation(program, "uRockLow");
this.uRockHigh = gl.getUniformLocation(program, "uRockHigh");
}
_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;
}
// The post-process program and the clip-space quad it draws. Called again
// after a context loss, since all of it lives in GL.
_initFullscreenProgram() {
const gl = this.gl;
const vertex = this._compile(gl.VERTEX_SHADER, FS_VERTEX_SHADER);
const fragment = this._compile(gl.FRAGMENT_SHADER, FS_FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.bindAttribLocation(program, 0, "aPos");
gl.bindAttribLocation(program, 1, "aUV");
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(`fog link failed: ${gl.getProgramInfoLog(program)}`);
}
gl.deleteShader(vertex);
gl.deleteShader(fragment);
this.fullscreenProgram = program;
this.fsPos = gl.getAttribLocation(program, "aPos");
this.fsUV = gl.getAttribLocation(program, "aUV");
this.fsTexture = gl.getUniformLocation(program, "uTexture");
this.fsTexel = gl.getUniformLocation(program, "uTexel");
this.fsDirection = gl.getUniformLocation(program, "uDirection");
this.fsMode = gl.getUniformLocation(program, "uMode");
this.fullscreenQuad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenQuad);
// x, y, u, v for the four corners of a triangle strip.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, 1, 1, 1,
]), gl.STATIC_DRAW);
}
_drawFullscreen(texture, texelX, texelY, dirX, dirY, mode) {
const gl = this.gl;
gl.useProgram(this.fullscreenProgram);
// Attribute locations are shared across programs, so turn off the map
// program's extra arrays before pointing 0 and 1 at the quad.
gl.disableVertexAttribArray(this.aColour);
gl.disableVertexAttribArray(this.aWorld);
gl.disableVertexAttribArray(this.aOverlay);
gl.disableVertexAttribArray(this.aHeight);
gl.disableVertexAttribArray(this.aSlope);
gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenQuad);
gl.enableVertexAttribArray(this.fsPos);
gl.vertexAttribPointer(this.fsPos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.fsUV);
gl.vertexAttribPointer(this.fsUV, 2, gl.FLOAT, false, 16, 8);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(this.fsTexture, 0);
gl.uniform2f(this.fsTexel, texelX, texelY);
gl.uniform2f(this.fsDirection, dirX, dirY);
gl.uniform1f(this.fsMode, mode);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
// One off-screen colour target for the fog blur. Returns null if the
// framebuffer is not complete, so the renderer can fall back to drawing the
// fog straight to the canvas.
_createFogTarget(width, height) {
const gl = this.gl;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
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);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (!complete) {
gl.deleteFramebuffer(fbo);
gl.deleteTexture(texture);
return null;
}
return { fbo, texture, width, height };
}
_disposeFogBlur() {
const gl = this.gl;
if (!gl || !this.fogBlur) return;
for (const target of [this.fogBlur.a, this.fogBlur.b]) {
if (target.fbo) gl.deleteFramebuffer(target.fbo);
if (target.texture) gl.deleteTexture(target.texture);
}
this.fogBlur = null;
}
// Two half-resolution targets, recreated when the viewport resizes.
_ensureFogBlur(width, height) {
const w = Math.max(1, Math.floor(width / 2));
const h = Math.max(1, Math.floor(height / 2));
if (this.fogBlur && this.fogBlur.width === w && this.fogBlur.height === h) {
return this.fogBlur;
}
this._disposeFogBlur();
if (!this.fullscreenProgram) return null;
try {
const a = this._createFogTarget(w, h);
const b = this._createFogTarget(w, h);
if (!a || !b) {
if (a) {
this.gl.deleteFramebuffer(a.fbo);
this.gl.deleteTexture(a.texture);
}
if (b) {
this.gl.deleteFramebuffer(b.fbo);
this.gl.deleteTexture(b.texture);
}
return null;
}
this.fogBlur = { a, b, width: w, height: h };
} catch {
this._disposeFogBlur();
return null;
}
return this.fogBlur;
}
// Loads the four sources the terrain shader samples. The atlas is clamped
// (its cells must not bleed into one another); the three land textures repeat,
// so the seam between two tiles continues the same pattern.
_initTexture() {
this.textures = {};
this.texReady = false;
let pending = 0;
const loaded = () => {
pending -= 1;
if (pending > 0 || this.lost) return;
this.texReady = true;
this.version += 1;
this._sig = null;
};
const load = (name, src, repeat) => {
const texture = this._createTexture(repeat);
this.textures[name] = texture;
pending += 1;
const image = new Image();
image.onload = () => {
if (this.lost) return;
this._uploadImage(texture, image);
loaded();
};
image.src = src;
};
load("atlas", "assets/terrain.png", false);
load(TEXTURE_GRASS, `${TEXTURE_DIR}${TEXTURE_GRASS}.png`, true);
load(TEXTURE_SAND, `${TEXTURE_DIR}${TEXTURE_SAND}.png`, true);
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
load(TEXTURE_HILLS, `${TEXTURE_DIR}${TEXTURE_HILLS}.png`, true);
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
load(TEXTURE_HILLS_NORMAL, `${TEXTURE_DIR}${TEXTURE_HILLS_NORMAL}.png`, true);
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.png`, true);
}
_createTexture(repeat) {
const gl = this.gl;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// A 1x1 transparent texel until the image arrives.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]));
const wrap = repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return texture;
}
_uploadImage(texture, image) {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
}
_listenForContextLoss() {
this.canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
this.lost = true;
});
this.canvas.addEventListener("webglcontextrestored", () => {
try {
this.fogBlur = null;
this._initProgram();
try {
this._initFullscreenProgram();
} catch {
this.fullscreenProgram = null;
this.fullscreenQuad = null;
}
this._initTexture();
this.lost = false;
if (this.view) this.setWorld(this.view);
} catch {
this.lost = true;
}
});
}
// ------------------------------------------------------------ geometry --
// Flat-colour vertices (roads, borders, region) are x, y, height, padding,
// r, g, b, a. The padding keeps the colour at the same byte offset as in the
// terrain layout, which the single shader relies on.
_push(arr, x, y, height, colour) {
arr.push(x, y, height || 0, 0, colour[0], colour[1], colour[2], colour[3]);
}
// Terrain vertices also carry the undistorted world position, which the
// shader turns into a continuous tiling-texture coordinate, the normalised
// height that lifts the vertex and its gradient (for lighting), plus the base
// (grass, sand, atlas), the per-overlay weights, the water flag and the
// coastal beach weight.
_pushTerrain(arr, x, y, u, v, wx, wy, height, slopeX, slopeY, weights) {
arr.push(
x, y, u, v, wx, wy, height,
weights[0], weights[1], weights[2], weights[6],
weights[3], weights[4], weights[5], weights[7],
slopeX, slopeY
);
}
// Fog has no texture, so it uses a compact position/height/colour vertex. That
// keeps the buffer (re-uploaded whenever visibility changes) small.
_pushSimple(arr, x, y, height, colour) {
arr.push(x, y, height || 0, colour[0], colour[1], colour[2], colour[3]);
}
// Adds a convex quad given its four vertices (with per-vertex heights) 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].h, colour);
this._push(arr, corners[1].x, corners[1].y, corners[1].h, colour);
this._push(arr, corners[2].x, corners[2].y, corners[2].h, colour);
this._push(arr, corners[0].x, corners[0].y, corners[0].h, colour);
this._push(arr, corners[2].x, corners[2].y, corners[2].h, colour);
this._push(arr, corners[3].x, corners[3].y, corners[3].h, 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, h: a.h },
{ x: bx + nx * half, y: by + ny * half, h: b.h },
{ x: bx - nx * half, y: by - ny * half, h: b.h },
{ x: ax - nx * half, y: ay - ny * half, h: a.h },
], colour);
}
_upload(pass, arr, usage, floats = 8) {
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 / floats;
pass.floats = floats;
// 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.heightField = createHeightField(view);
this._heightView = view;
this.terrainLevels = [];
this.terrain = this._buildTerrainPass(view, 0);
this.terrainLevels[0] = this.terrain;
this.buildRoads(view);
this.buildBorders(view);
this.buildKnowledge(view);
this.buildRegionOverlay(view);
}
// The relief field for a view, rebuilt when a builder invoked directly (as the
// tests do) passes a world `setWorld` never saw.
_heightField(view) {
if (!this.heightField || this._heightView !== view) {
this.heightField = createHeightField(view);
this._heightView = view;
}
return this.heightField;
}
// Kept for callers (and tests) that build the closest level directly.
buildTerrain(view) {
const pass = this._buildTerrainPass(view, 0);
if (!this.terrainLevels) this.terrainLevels = [];
this.terrain = pass;
this.terrainLevels[0] = pass;
}
// Builds the terrain mesh for one level of detail and uploads it. Level 0 is
// the wavy, fully-subdivided mesh; the coarser levels trade edge detail for
// far-out zooms, where the warp is sub-pixel anyway.
_buildTerrainPass(view, level) {
const segments = (TERRAIN_LODS[level] || TERRAIN_LODS[0]).segments;
const field = this._heightField(view);
const arr = [];
const tiles = view.tiles || {};
const topo = view.topology;
const seed = view.seed | 0;
const period = periodUnits(topo);
const boundaryLength = CORNERS.length * segments;
// Half-texel inset so linear filtering cannot sample the neighbouring atlas
// cell along the hexagon's border.
const uPad = 0.5 / HEX_W;
const vPad = 0.5 / HEX_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 / TERRAIN_FLOATS);
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 cu = (column + 0.5) / ATLAS_COLS;
const cv = 0.5;
// Land textures are blended from the tile and its neighbours; the ring
// is only needed when this tile is one of the textured lands.
const ring = isTexturedLand(tile) ? this._textureRing(view, x, y, centre) : null;
// The relief is sampled from the same ring of tile heights, so the two
// tiles that share a boundary vertex compute the same lift and slope.
const heightRing = field ? field.ring(x, y) : null;
const sampleAt = heightRing
? (px, py) => field.sample(heightRing, px, py)
: () => ZERO_SAMPLE;
// A fan over the wavy boundary: the six points (and the extra border
// points) can all be moved independently, which a quad could not do.
const boundary = distortedBoundary(seed, centre, period, segments);
// The weights are a pure function of the undistorted world position, so
// the two tiles that share a boundary point compute the same value.
const colours = new Array(boundaryLength);
const samples = new Array(boundaryLength);
for (let i = 0; i < boundaryLength; i++) {
colours[i] = this._textureWeights(tile, ring, boundary[i].idealX, boundary[i].idealY);
samples[i] = sampleAt(boundary[i].x, boundary[i].y);
}
const centreColour = this._textureWeights(tile, ring, centre.x, centre.y);
const centreSample = sampleAt(centre.x, centre.y);
for (let i = 0; i < CORNERS.length; i++) {
for (let j = 0; j < segments; j++) {
const indexA = i * segments + j;
const indexB = (indexA + 1) % boundaryLength;
const a = boundary[indexA];
const b = boundary[indexB];
const ua = this._cellUV(column, a.idealX - centre.x, a.idealY - centre.y, uPad, vPad);
const ub = this._cellUV(column, b.idealX - centre.x, b.idealY - centre.y, uPad, vPad);
this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y,
centreSample.h, centreSample.dx, centreSample.dy, centreColour);
this._pushTerrain(arr, a.x, a.y, ua.u, ua.v, a.idealX, a.idealY,
samples[indexA].h, samples[indexA].dx, samples[indexA].dy, colours[indexA]);
this._pushTerrain(arr, b.x, b.y, ub.u, ub.v, b.idealX, b.idealY,
samples[indexB].h, samples[indexB].dx, samples[indexB].dy, colours[indexB]);
}
}
}
}
rowStart.push(arr.length / TERRAIN_FLOATS);
const pass = { buffer: null, count: 0 };
this._upload(pass, arr, undefined, TERRAIN_FLOATS);
pass.rowStart = rowStart;
pass.originY = originY;
pass.rows = topo.height;
return pass;
}
// Builds a coarser terrain mesh on first use, so zooming out does not pay for
// it until the camera actually reaches that level.
_ensureTerrainLevel(view, level) {
if (!this.terrainLevels) this.terrainLevels = [];
if (this.terrainLevels[level]) return this.terrainLevels[level];
const pass = this._buildTerrainPass(view, level);
this.terrainLevels[level] = pass;
return pass;
}
// The tile itself plus its six neighbours, each with its wrapped pixel
// centre, used to blend the tiling textures across tile boundaries.
_textureRing(view, x, y, centre) {
const topo = view.topology;
const tiles = view.tiles;
const coords = { x, y };
const ring = [{ tile: tiles[key(x, y)], x: centre.x, y: centre.y }];
for (const neighbour of topo.neighbours(x, y)) {
const delta = topo.pixelDelta(coords, neighbour);
ring.push({
tile: tiles[key(neighbour.x, neighbour.y)],
x: centre.x + delta.x,
y: centre.y + delta.y,
});
}
return ring;
}
// The shader weights at one undistorted world point, from the nearby tiles.
_textureWeights(tile, ring, worldX, worldY) {
if (!ring) return vertexTextureWeights(tile, []);
const samples = ring.map((sample) => ({
tile: sample.tile,
dx: sample.x - worldX,
dy: sample.y - worldY,
}));
return vertexTextureWeights(tile, samples);
}
// The fog level of the tile itself plus its six neighbours, each with its
// wrapped pixel centre, used to blur the visibility frontier.
_fogRing(topo, x, y, centre, levelAt) {
const coords = { x, y };
const ring = [{ level: levelAt(x, y), x: centre.x, y: centre.y }];
for (const neighbour of topo.neighbours(x, y)) {
const delta = topo.pixelDelta(coords, neighbour);
ring.push({
level: levelAt(neighbour.x, neighbour.y),
x: centre.x + delta.x,
y: centre.y + delta.y,
});
}
return ring;
}
// UV of a point at local offset (dx, dy) from the tile centre, inside the
// tile's atlas cell and inset by half a texel.
_cellUV(column, dx, dy, uPad, vPad) {
let uc = (dx + HEX_W / 2) / HEX_W;
let vc = (dy + HEX_H / 2) / HEX_H;
uc = Math.min(Math.max(uc, uPad), 1 - uPad);
vc = Math.min(Math.max(vc, vPad), 1 - vPad);
return { u: (column + uc) / ATLAS_COLS, v: vc };
}
// Draws the road network as mitered strokes. Each pair of adjacent road tiles
// is drawn once as a single polyline from centre to centre, crossing the
// shared wavy border at its midpoint; the bend there and the gentle in-tile
// drifts are joined with the same miter as the borders, so no piece overlaps
// and no notch is left at a bend. A small cap at every tile centre covers the
// junction where several roads meet.
buildRoads(view) {
const arr = [];
const roads = view.roads;
if (!roads || roads.size === 0 || !view.topology) {
this._upload(this.roads, arr);
return;
}
const seed = view.seed | 0;
const topo = view.topology;
const period = periodUnits(topo);
const field = this._heightField(view);
const lift = (point) => {
if (field) point.h = field.at(point.x, point.y);
return point;
};
const steps = 3;
const drawn = new Set();
for (const k of roads) {
const coords = parseKey(k);
const centre = lift(mapToLocal(coords.x, coords.y));
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (!roads.has(nk)) continue;
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
if (drawn.has(pair)) continue;
drawn.add(pair);
const delta = topo.pixelDelta(coords, neighbour);
const target = lift({ x: centre.x + delta.x, y: centre.y + delta.y });
// Cross the border exactly where it is now: the midpoint of the shared
// wavy edge, not the ideal straight boundary.
const [idealA, idealB] = nearestCorners(centre, target);
const mid = lift(edgePoint(seed, idealA, idealB, 0.5, period));
// Half the road drifts in from each tile centre towards that midpoint.
const points = [centre];
for (let j = 1; j <= steps; j++) {
points.push(lift(bentPath(seed, centre, mid, 0x5a17, j / steps)));
}
for (let j = steps - 1; j >= 1; j--) {
points.push(lift(bentPath(seed, target, mid, 0x5a17, j / steps)));
}
points.push(target);
this._roadBand(arr, points, 5, ROAD);
}
}
for (const k of roads) {
const coords = parseKey(k);
this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y)));
}
this._upload(this.roads, arr);
}
// A centered band along a polyline. Both edges are mitered (and consecutive
// quads share them), so a bend is filled rather than left as a wedge.
_roadBand(arr, points, width, colour) {
const n = points.length;
if (n < 2) return;
const half = width / 2;
const normals = [];
for (let i = 0; i < n - 1; i++) {
const a = points[i];
const b = points[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
normals.push({ x: -dy / length, y: dx / length });
}
const offsets = [];
for (let i = 0; i < n; i++) {
const before = normals[i === 0 ? 0 : i - 1];
const after = normals[i === n - 1 ? n - 2 : i];
let denom = 1 + before.x * after.x + before.y * after.y;
if (denom < 0.2) denom = 0.2;
const scale = half / denom;
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
}
for (let i = 0; i < n - 1; i++) {
const a = points[i];
const b = points[i + 1];
const oa = offsets[i];
const ob = offsets[i + 1];
this._quad(arr, [
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h },
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h },
], colour);
}
}
// A small square that hides the notch where several roads meet at a centre.
_roadJoint(arr, point) {
const r = 2.5;
const h = point.h;
this._quad(arr, [
{ x: point.x - r, y: point.y - r, h },
{ x: point.x - r, y: point.y + r, h },
{ x: point.x + r, y: point.y + r, h },
{ x: point.x + r, y: point.y - r, h },
], ROAD);
}
// Draws each nation's coastline/border as one continuous stroked outline.
//
// Every border edge is inherited from its owning tile and oriented so the
// owner lies on the left of the directed edge. Edges are then stitched across
// tile boundaries by their shared lattice corners, so a corner where two
// *different* tiles of the same nation meet is joined with a single miter
// instead of two independent (and divergent) butt caps.
buildBorders(view) {
const arr = [];
const territory = view.territory;
if (!territory || territory.size === 0 || !view.topology || !view.civilisations) {
this._upload(this.borders, arr);
return;
}
const seed = view.seed | 0;
const period = periodUnits(view.topology);
const field = this._heightField(view);
const glow = 6;
const edgesByOwner = new Map();
for (const [k, owner] of territory) {
if (!view.civilisations[owner]) continue;
const coords = parseKey(k);
const centre = mapToLocal(coords.x, coords.y);
const neighbours = this._edgeNeighbours(view, coords.x, coords.y);
for (let e = 0; e < CORNERS.length; e++) {
const neighbour = neighbours[e];
if (!neighbour) continue;
const nk = key(neighbour.x, neighbour.y);
if (territory.has(nk) && territory.get(nk) === owner) continue;
const a = { x: centre.x + CORNERS[e].x, y: centre.y + CORNERS[e].y };
const next = CORNERS[(e + 1) % CORNERS.length];
const b = { x: centre.x + next.x, y: centre.y + next.y };
const points = this._edgePolyline(seed, centre, e, period);
if (field) {
for (const point of points) point.h = field.at(point.x, point.y);
}
// Orient the edge so the owner sits on its left; the stroke then always
// offsets towards the owner and neighbouring edges agree on a miter.
const dx = b.x - a.x;
const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const ownerIsLeft = (centre.x - mx) * -dy + (centre.y - my) * dx >= 0;
let run = points;
let start = a;
let end = b;
if (!ownerIsLeft) {
run = points.slice().reverse();
start = b;
end = a;
}
if (!edgesByOwner.has(owner)) edgesByOwner.set(owner, []);
edgesByOwner.get(owner).push({
points: run,
startKey: latticeKey(start.x, start.y),
endKey: latticeKey(end.x, end.y),
});
}
}
for (const [owner, edges] of edgesByOwner) {
const rgb = parseColour(view.civilisations[owner].primaryColour);
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
const soft = [colour[0], colour[1], colour[2], 0.18];
for (const chain of this._chainEdges(edges)) {
this._stroke(arr, chain.points, glow, soft, chain.closed);
this._stroke(arr, chain.points, NATIONAL_BORDER, colour, chain.closed);
}
}
this._upload(this.borders, arr);
}
// Stitches directed edges into runs by matching each edge's end corner to the
// next edge's start corner. Because a nation's boundary is a set of closed
// loops (plus open runs cut off at the map edge), every vertex pairs exactly
// one incoming and one outgoing edge, so the follow is unambiguous.
_chainEdges(edges) {
const byStart = new Map();
for (const edge of edges) byStart.set(edge.startKey, edge);
const hasIncoming = new Set(edges.map((edge) => edge.endKey));
const used = new Set();
const chains = [];
const follow = (first) => {
const points = [];
let closed = false;
let edge = first;
while (edge && !used.has(edge)) {
used.add(edge);
for (let i = edge === first ? 0 : 1; i < edge.points.length; i++) {
points.push(edge.points[i]);
}
const next = byStart.get(edge.endKey);
if (next === first) {
closed = true;
if (points.length > 1) points.pop();
break;
}
edge = next;
}
return { points, closed };
};
// Open runs first (their start has no incoming edge), then any closed loop.
for (const edge of edges) {
if (used.has(edge) || hasIncoming.has(edge.startKey)) continue;
chains.push(follow(edge));
}
for (const edge of edges) {
if (used.has(edge)) continue;
chains.push(follow(edge));
}
return chains;
}
// The neighbour tile across each of the hexagon's six edges, in CORNERS order
// (edge i runs from corner i to corner i+1). Matching by direction keeps this
// correct for both column parities and for the wrapped seam.
_edgeNeighbours(view, x, y) {
const topo = view.topology;
const coords = { x, y };
const neighbours = topo.neighbours(x, y);
const result = [];
for (let i = 0; i < CORNERS.length; i++) {
const a = CORNERS[i];
const b = CORNERS[(i + 1) % CORNERS.length];
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const length = Math.hypot(mx, my) || 1;
let best = null;
let bestDot = -Infinity;
for (const neighbour of neighbours) {
const delta = topo.pixelDelta(coords, neighbour);
const distance = Math.hypot(delta.x, delta.y) || 1;
const dot = (mx * delta.x + my * delta.y) / (length * distance);
if (dot > bestDot) {
bestDot = dot;
best = neighbour;
}
}
result.push(best);
}
return result;
}
// The jagged polyline (EDGE_SEGMENTS + 1 points) of one hexagon edge.
_edgePolyline(seed, centre, edge, period) {
const a = { x: centre.x + CORNERS[edge].x, y: centre.y + CORNERS[edge].y };
const next = CORNERS[(edge + 1) % CORNERS.length];
const b = { x: centre.x + next.x, y: centre.y + next.y };
const points = [];
for (let j = 0; j <= EDGE_SEGMENTS; j++) {
points.push(edgePoint(seed, a, b, j / EDGE_SEGMENTS, period));
}
return points;
}
// Draws a stroke of the given width along a polyline, flush with it on the
// boundary side and offset to the left of the direction of travel. Callers
// orient their points so the owner lies on the left, which keeps the offset
// towards the owner. The offset at each vertex is the miter (the intersection
// of the two adjacent offset edges), and consecutive quads share their
// offset edge, so corners are filled without the pieces overlapping.
_stroke(arr, points, width, colour, closed) {
const n = points.length;
if (n < 2) return;
const count = closed ? n : n - 1;
const segments = [];
for (let i = 0; i < count; i++) {
const a = points[i];
const b = points[(i + 1) % n];
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
segments.push({ x: -dy / length, y: dx / length });
}
const offsets = [];
for (let i = 0; i < n; i++) {
const before = closed ? segments[(i - 1 + count) % count] : segments[i === 0 ? 0 : i - 1];
const after = closed ? segments[i % count] : segments[i === n - 1 ? n - 2 : i];
// Miter offset: (n1 + n2) * width / (1 + n1 . n2). The clamp keeps a
// near-doubling-back corner from shooting out a huge spike.
let denom = 1 + before.x * after.x + before.y * after.y;
if (denom < 0.2) denom = 0.2;
const scale = width / denom;
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
}
for (let i = 0; i < count; i++) {
const a = points[i];
const b = points[(i + 1) % n];
const oa = offsets[i];
const ob = offsets[(i + 1) % n];
this._quad(arr, [
a,
b,
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
], colour);
}
}
// Fog is no longer a flat colour per tile: every vertex takes the blurred
// average of the fog levels around it, so the frontier ramps over about a
// tile instead of stepping at the hex edge. Visible tiles that border fog are
// drawn too, which is what lets the ramp fade into the clear side.
buildKnowledge(view) {
const arr = [];
const tiles = view.tiles || {};
const topo = view.topology;
const seed = view.seed | 0;
const period = periodUnits(topo);
const explored = view.explored || new Set();
const visible = view.visible || new Set();
const field = this._heightField(view);
const segments = EDGE_SEGMENTS;
const rowStart = [];
const originX = topo.originX;
const originY = topo.originY;
const levels = new Map();
const levelAt = (x, y) => {
const k = key(x, y);
if (!tiles[k]) return null;
let level = levels.get(k);
if (!level) {
level = fogLevel(visible.has(k), explored.has(k));
levels.set(k, level);
}
return level;
};
for (let y = originY; y < originY + topo.height; y++) {
rowStart.push(arr.length / FOG_FLOATS);
for (let x = originX; x < originX + topo.width; x++) {
if (!tiles[key(x, y)]) continue;
const centre = mapToLocal(x, y);
// The tile and its neighbours; a fully clear tile with clear
// surroundings needs no geometry at all.
const ring = this._fogRing(topo, x, y, centre, levelAt);
if (!ring.some((sample) => isFogged(sample.level))) continue;
const fogAt = (px, py) => {
const samples = [];
for (const sample of ring) {
samples.push({ level: sample.level, dx: sample.x - px, dy: sample.y - py });
}
return blurFog(samples);
};
// Fog must cover exactly the wavy terrain hexagon underneath it.
const boundary = distortedBoundary(seed, centre, period, segments);
const vertices = new Array(boundary.length);
for (let i = 0; i < boundary.length; i++) {
vertices[i] = fogAt(boundary[i].x, boundary[i].y);
}
const centreFog = fogAt(centre.x, centre.y);
// Fog sheets over the same raised hexagon the terrain draws, so it has
// to read the relief from the same ring.
const heightRing = field ? field.ring(x, y) : null;
const heightAt = (px, py) =>
(field && heightRing ? field.blend(heightRing, px, py) : 0);
const push = (point, fog) => {
this._pushSimple(arr, point.x, point.y, heightAt(point.x, point.y),
[fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]);
};
for (let i = 0; i < boundary.length; i++) {
const a = boundary[i];
const b = boundary[(i + 1) % boundary.length];
push(centre, centreFog);
push(a, vertices[i]);
push(b, vertices[(i + 1) % boundary.length]);
}
}
}
rowStart.push(arr.length / FOG_FLOATS);
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, FOG_FLOATS);
this.fog.rowStart = rowStart;
this.fog.originY = originY;
this.fog.rows = topo.height;
}
// The region of the selected/inspected tile: a soft translucent fill over
// every tile in it, plus a thin dotted border along its edge. Rebuilt on
// selection and when the region membership changes; empty when nothing is
// selected.
buildRegionOverlay(view) {
const arr = [];
const region = view._selectedRegion;
const regions = view.regions;
if (region === null || region === undefined || !regions || regions.size === 0 ||
!view.topology) {
this._upload(this.regionOverlay, arr);
return;
}
const seed = view.seed | 0;
const topo = view.topology;
const period = periodUnits(topo);
const segments = EDGE_SEGMENTS;
const boundaryLength = CORNERS.length * segments;
const territory = view.territory;
const field = this._heightField(view);
for (const [k, cityId] of regions) {
if (cityId !== region) continue;
const coords = parseKey(k);
const centre = mapToLocal(coords.x, coords.y);
const boundary = distortedBoundary(seed, centre, period, segments);
const heightRing = field ? field.ring(coords.x, coords.y) : null;
const heightAt = (px, py) =>
(field && heightRing ? field.blend(heightRing, px, py) : 0);
const centreHeight = heightAt(centre.x, centre.y);
for (let i = 0; i < boundaryLength; i++) {
const a = boundary[i];
const b = boundary[(i + 1) % boundaryLength];
this._push(arr, centre.x, centre.y, centreHeight, REGION_FILL);
this._push(arr, a.x, a.y, heightAt(a.x, a.y), REGION_FILL);
this._push(arr, b.x, b.y, heightAt(b.x, b.y), REGION_FILL);
}
const owner = territory ? territory.get(k) : undefined;
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (regions.get(nk) === region) continue;
// A national border already traces this edge; the dotted region line
// would only muddy it, so leave that edge to the border pass.
if (!territory || owner === undefined || territory.get(nk) !== owner) continue;
const delta = topo.pixelDelta(coords, neighbour);
const target = { x: centre.x + delta.x, y: centre.y + delta.y };
const [idealA, idealB] = nearestCorners(centre, target);
for (let d = 0; d < REGION_DASHES; d++) {
const p0 = edgePoint(seed, idealA, idealB, (d + 0.28) / REGION_DASHES, period);
const p1 = edgePoint(seed, idealA, idealB, (d + 0.72) / REGION_DASHES, period);
p0.h = heightAt(p0.x, p0.y);
p1.h = heightAt(p1.x, p1.y);
this._segment(arr, p0, p1, REGION_BORDER, REGION_BORDER_COLOUR);
}
}
}
this._upload(this.regionOverlay, arr, this.gl.DYNAMIC_DRAW);
}
// -------------------------------------------------------------- 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;
// The tilt compresses the ground, so a taller slice of world Y is on screen.
const halfHeight = height / (2 * zoom * CAMERA_TILT);
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);
gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
const floats = pass.floats || COLOUR_FLOATS;
const stride = floats * 4;
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, stride, 0);
if (floats === TERRAIN_FLOATS) {
// Terrain carries the atlas UV, the world position and the blend weights.
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
gl.uniform1i(this.uAtlas, UNIT_ATLAS);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.grass);
gl.uniform1i(this.uGrass, UNIT_GRASS);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.textures.sand);
gl.uniform1i(this.uSand, UNIT_SAND);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.textures.forest);
gl.uniform1i(this.uForest, UNIT_FOREST);
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.textures.hills);
gl.uniform1i(this.uHills, UNIT_HILLS);
gl.activeTexture(gl.TEXTURE5);
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountain);
gl.uniform1i(this.uMountain, UNIT_MOUNTAIN);
gl.activeTexture(gl.TEXTURE6);
gl.bindTexture(gl.TEXTURE_2D, this.textures.hills_normal_map);
gl.uniform1i(this.uHillsNormal, UNIT_HILLS_NORMAL);
gl.activeTexture(gl.TEXTURE7);
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountains_normal_map);
gl.uniform1i(this.uMountainNormal, UNIT_MOUNTAIN_NORMAL);
gl.enableVertexAttribArray(this.aUV);
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
gl.enableVertexAttribArray(this.aWorld);
gl.vertexAttribPointer(this.aWorld, 2, gl.FLOAT, false, stride, 16);
gl.enableVertexAttribArray(this.aHeight);
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 24);
gl.enableVertexAttribArray(this.aColour);
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 28);
gl.enableVertexAttribArray(this.aOverlay);
gl.vertexAttribPointer(this.aOverlay, 4, gl.FLOAT, false, stride, 44);
gl.enableVertexAttribArray(this.aSlope);
gl.vertexAttribPointer(this.aSlope, 2, gl.FLOAT, false, stride, 60);
} else if (floats === FOG_FLOATS) {
// Fog passes only carry position, height and colour; the disabled
// attributes keep their generic defaults, unused in the untextured
// shader branch.
gl.disableVertexAttribArray(this.aUV);
gl.disableVertexAttribArray(this.aWorld);
gl.disableVertexAttribArray(this.aOverlay);
gl.disableVertexAttribArray(this.aSlope);
gl.enableVertexAttribArray(this.aHeight);
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 8);
gl.enableVertexAttribArray(this.aColour);
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 12);
} else {
gl.enableVertexAttribArray(this.aUV);
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
gl.disableVertexAttribArray(this.aWorld);
gl.disableVertexAttribArray(this.aOverlay);
gl.disableVertexAttribArray(this.aSlope);
gl.enableVertexAttribArray(this.aHeight);
gl.vertexAttribPointer(this.aHeight, 1, 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;
// Pick the terrain mesh for the camera distance. A coarser level is built
// the first time the camera reaches it, which increments `version`, so this
// runs before the frame signature is taken.
const level = terrainLod(camera.zoom);
const terrainPass = this.texReady && view.topology
? this._ensureTerrainLevel(view, level)
: this.terrain;
// The water animates, so the frame is repainted while the clock advances.
// The timestamp is quantised to keep the redraw rate near 24 fps instead of
// matching the display refresh exactly.
const now = (typeof performance !== "undefined" ? performance.now() : Date.now()) / 1000;
const tick = Math.floor(now * 24);
const signature = `${camera.x},${camera.y},${camera.zoom},${width},${height},${this.version},${tick}`;
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.uniform1f(this.uTilt, CAMERA_TILT);
gl.uniform1f(this.uHeightScale, HEIGHT_LIFT);
gl.uniform1f(this.uRelief, (TERRAIN_LODS[level] || TERRAIN_LODS[0]).relief);
gl.uniform1f(this.uRockLow, MOUNTAIN_ROCK_LOW);
gl.uniform1f(this.uRockHigh, MOUNTAIN_ROCK_HIGH);
gl.uniform1f(this.uTextureRepeat, textureRepeat(view._period || 0));
gl.uniform1f(this.uTime, now);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.disable(gl.DEPTH_TEST);
const period = view._period || 0;
const shifts = this._neededShifts(view, width);
const rows = this._visibleRows(view, width, height);
if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows);
this._drawPass(this.roads, 1, shifts, period);
this._drawPass(this.borders, 1, shifts, period);
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
this._drawPass(this.regionOverlay, 1, shifts, period);
}
// Draws the fog into an off-screen, half-resolution target, blurs it in two
// separable passes and composites it back. Doing it as a post-process smooths
// the frontier into a soft gradient regardless of the tile-shaped geometry.
// If the framebuffer cannot be built, it falls back to the direct pass.
_drawFog(bufferWidth, bufferHeight, shifts, period, rows) {
const gl = this.gl;
const blur = this._ensureFogBlur(bufferWidth, bufferHeight);
if (!blur) {
this._drawPass(this.fog, 1, shifts, period, rows);
return;
}
// Draw with straight alpha and no blending, so the stored alpha is exact.
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.a.fbo);
gl.viewport(0, 0, blur.width, blur.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.program);
this._drawPass(this.fog, 1, shifts, period, rows);
const texelX = FOG_BLUR_STEP / blur.width;
const texelY = FOG_BLUR_STEP / blur.height;
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.b.fbo);
gl.clear(gl.COLOR_BUFFER_BIT);
this._drawFullscreen(blur.a.texture, texelX, texelY, 1, 0, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.a.fbo);
gl.clear(gl.COLOR_BUFFER_BIT);
this._drawFullscreen(blur.b.texture, texelX, texelY, 0, 1, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, bufferWidth, bufferHeight);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
this._drawFullscreen(blur.a.texture, 1 / blur.width, 1 / blur.height, 0, 0, 1);
gl.useProgram(this.program);
}
}