Files
Battle-for-Tismo/client/js/map_view/webgl.js
T
adrien bb097ea601 Built the map fog for the visible rows only
Marching into unexplored or enemy land reveals a tile every step, and each
reveal rebuilt the whole-map fog mesh: about 1.8M vertices and 50 MB on a
160x160 map, regenerated at the rebuild throttle. Walking through
already-explored home land reveals nothing, which is why the stutter only
showed up on a border crossing, with a single unit.

- buildKnowledge now takes a row range and builds only the rows on screen; the
  row table stays full length so the existing row-banded draw keeps working.
- _flushFog rebuilds on a visibility change or a pan to a new row band, still
  coalesced, and the draw clamps to the band that was built.
- A mode/zoom change schedules the band rebuild instead of building the whole
  mesh, and uniform interior tiles skip the per-vertex blur kernel.
2026-09-21 09:46:34 +02:00

2392 lines
101 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, COL_STEP, mapToLocal, parseKey, key } from "../../../shared/hex.js";
import { nearestCorners, parseColour } from "./utils.js";
import {
CORNERS,
EDGE_SEGMENTS,
cubicBezier,
distortedBoundary,
edgePoint,
latticeKey,
periodUnits,
vertexKey,
} from "./distortion.js";
import {
TEXTURE_DIR,
TEXTURE_FOREST,
TEXTURE_GRASS,
TEXTURE_MOUNTAIN,
TEXTURE_MOUNTAIN_NORMAL,
TEXTURE_RAIL,
TEXTURE_ROAD,
TEXTURE_SAND,
isTexturedLand,
isWaterTile,
textureRepeat,
vertexTextureWeights,
} from "./textures.js";
import { blurFog, fogLevel, isFogged } from "./fog.js";
import {
CAMERA_TILT,
HEIGHT_LIFT,
MOUNTAIN_ROCK_LOW,
MOUNTAIN_ROCK_HIGH,
GRASS_DRY_LOW,
GRASS_DRY_HIGH,
GRASS_DRY_STRENGTH,
GRASS_DRY_TINT,
COAST_WAVE_PULSE,
COAST_WAVE_STRENGTH,
COAST_RANGE,
WATER_REPEAT,
WATER_BUMP_CELLS,
WATER_BUMP,
TERRAIN_LODS,
terrainLod,
} from "./constants.js";
import { createHeightField } from "./heightmap.js";
import { coastDistanceField } from "./coast.js";
import { generateWaterBump } from "./water.js";
import { economicColour } from "./economic.js";
import { bootLog } from "../loading.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;
// Transport quads: x, y, u, v, height, then a tint/alpha colour. The road and
// railway textures run along the link, so they need the UVs the flat passes do
// without, and a colour to tint them.
const ROAD_FLOATS = 9;
// World pixels of road/rail texture per repeat, and the width of a link quad.
// The textures are 16px wide with the opaque art spanning x 3..13, so the quad
// samples exactly that band to keep its edges clean.
const TRANSPORT_TEXTURE_LENGTH = 16;
const TRANSPORT_WIDTH = 10;
const TRANSPORT_U0 = 3 / 16;
const TRANSPORT_U1 = 13 / 16;
// How finely a road link's cubic Bezier is tessellated. Enough points that the
// mitered band reads as a smooth curve rather than a chain of faceted quads.
const ROAD_CURVE_SEGMENTS = 14;
// On the flat political/economic maps the transport network is drawn as plain
// straight lines instead of textured curve quads: dark grey for roads, brown for
// railways.
const FLAT_TRANSPORT_WIDTH = 4;
const FLAT_ROAD_COLOUR = [0x3a / 255, 0x3a / 255, 0x3d / 255, 1];
const FLAT_RAIL_COLOUR = [0x8a / 255, 0x5a / 255, 0x2b / 255, 1];
// A flat sample, used when a view has no relief field (tests, stub views).
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
// Whether two fog levels produce the same blurred value, so a tile whose whole
// ring shares one level can skip the per-vertex kernel.
function sameFog(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return (
a.opacity === b.opacity &&
a.rgb[0] === b.rgb[0] &&
a.rgb[1] === b.rgb[1] &&
a.rgb[2] === b.rgb[2]
);
}
// The context attributes every map context is created with. Kept here so the
// capability probe (client/js/capabilities.js) requests exactly the same context
// the renderer would, instead of a subtly different one.
export const CONTEXT_ATTRIBUTES = {
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
failIfMajorPerformanceCaveat: false,
};
// Unclaimed land on the political map, a flat muted olive. Painting it here
// lets the political/economic modes skip the textured terrain mesh entirely.
const UNCLAIMED_LAND = [0x5b / 255, 0x5f / 255, 0x49 / 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_MOUNTAIN = 4;
const UNIT_MOUNTAIN_NORMAL = 5;
const UNIT_COAST = 6;
const UNIT_WATER = 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/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 uMountain;
uniform sampler2D uMountainNormal;
uniform float uTextureRepeat;
uniform float uTime;
uniform float uMode;
uniform float uRelief;
uniform float uRockLow;
uniform float uRockHigh;
uniform float uGrassDryLow;
uniform float uGrassDryHigh;
uniform float uGrassDryStrength;
uniform vec3 uGrassDryTint;
uniform sampler2D uCoast;
uniform vec2 uCoastOrigin;
uniform vec2 uCoastSize;
uniform vec2 uHexStep;
uniform float uWavePulse;
uniform float uWaveStrength;
uniform sampler2D uWaterNormal;
uniform float uWaterRepeat;
uniform float uWaterBump;
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));
}
void main() {
if (uMode < 0.5) {
vec2 uv = vWorld / uTextureRepeat;
vec3 grass = texture2D(uGrass, uv).rgb;
// Grass shifts towards a drier yellow-green as the ground climbs, standing
// in for the old hill art. The tint is renormalised so a neutral pixel
// keeps its luminance instead of brightening.
float dry = smoothstep(uGrassDryLow, uGrassDryHigh, vHeight) * uGrassDryStrength;
vec3 luma = vec3(0.299, 0.587, 0.114);
vec3 dryGrass = grass * uGrassDryTint / dot(uGrassDryTint, luma);
grass = mix(grass, dryGrass, dry);
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 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. Tundra reuses the mountain art but is never raised, so
// its own weight (vOverlay.w) shows the texture at full strength while the
// relief normal below stays flat.
float rock = vOverlay.y * smoothstep(uRockLow, uRockHigh, vHeight);
float mountainMix = max(rock, vOverlay.w);
base = mix(base, forest.rgb, forest.a * vOverlay.x);
base = mix(base, mountain.rgb, mountain.a * mountainMix);
// Coastal sand fades the sand texture into non-mountain shores.
base = mix(base, sand, vOverlay.z);
// Sea tiles are replaced by the animated water; the flag is 0 elsewhere.
if (vColour.a > 0.0) {
// The coast field holds the distance to land per tile: the wavelets run
// strongest over the shallow shelf and come and go over time. The world
// position is the base frame's, so the wrapped copies sample the same
// tile as the base.
float col = floor(vWorld.x / uHexStep.x + 0.5);
col = mod(col - uCoastOrigin.x, uCoastSize.x) + uCoastOrigin.x;
float row = floor(vWorld.y / uHexStep.y - 0.5 * mod(col, 2.0) + 0.5);
vec2 coastUV = (vec2(col, row) - uCoastOrigin + 0.5) / uCoastSize;
float shore = 1.0 - texture2D(uCoast, coastUV).r;
float pulse = 0.5 + 0.5 * sin(uTime * uWavePulse);
float wavelets = shore * shore * pulse;
// The baked Voronoi normal map, two scrolling layers, gives the surface
// its ripples. There is no large-scale sine field: the only pattern is
// this fine one, scaled up towards the coast.
vec2 wuv = vWorld / uWaterRepeat;
vec4 samp = texture2D(uWaterNormal, wuv + vec2(uTime * 0.02, uTime * 0.013));
vec2 ripple = samp.rg * 2.0 - 1.0;
ripple += texture2D(uWaterNormal, wuv * 1.7 - vec2(uTime * 0.017, uTime * 0.011)).rg * 2.0 - 1.0;
ripple *= uWaterBump * (0.5 + 1.5 * wavelets * uWaveStrength);
vec3 waterNormal = normalize(vec3(ripple, 1.0));
vec3 halfDir = normalize(normalize(vec3(-0.5, -0.6, 0.8)) + vec3(0.0, 0.0, 1.0));
float spec = pow(max(dot(waterNormal, halfDir), 0.0), 48.0);
vec3 deep = vec3(0.016, 0.078, 0.196);
vec3 shallow = vec3(0.043, 0.184, 0.337);
vec3 water = mix(deep, shallow, samp.a * 0.6 + 0.2);
water += vec3(0.75, 0.85, 0.95) * (spec * (0.35 + 0.65 * wavelets));
water += vec3(0.10, 0.15, 0.17) * (wavelets * samp.a * uWaveStrength);
base = mix(base, water, 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 mountain normal
// map perturbs it with its 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(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 if (uMode < 1.5) {
gl_FragColor = vColour;
} else {
// Transport quads: the road/rail texture (bound to the atlas sampler in
// this mode) tinted and alpha-blended by the vertex colour, so the
// transparent margins of the strip show the ground. Reusing the atlas
// sampler keeps the program inside the eight texture units WebGL 1.0
// guarantees.
vec4 tex = texture2D(uAtlas, vUV);
gl_FragColor = vec4(tex.rgb * vColour.rgb, tex.a * vColour.a);
}
}
`;
// 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;
// The exact programs the renderer compiles, exported so the capability probe can
// test GLSL ES support against the real shaders rather than a stand-in.
export {
VERTEX_SHADER as MAP_VERTEX_SHADER,
FRAGMENT_SHADER as MAP_FRAGMENT_SHADER,
FS_VERTEX_SHADER as FOG_VERTEX_SHADER,
FS_FRAGMENT_SHADER as FOG_FRAGMENT_SHADER,
};
// 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 (error) {
// The DOM renderer takes over, but do not hide why: shader and context
// failures are otherwise invisible.
if (typeof console !== "undefined") console.warn("WebGL map renderer unavailable:", error);
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 land textures.
this.textures = {};
this.texReady = false;
// How many terrain textures have finished uploading, out of the total the
// terrain shader samples. The loading log and the readiness gate read these.
this.texLoaded = 0;
this.texTotal = 0;
// The per-tile distance-to-land field the water shader samples for its
// coastal wavelets, built with the world.
this.coastTexture = null;
// 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 = [];
// One flat quad for the whole ocean, drawn under the land. Sea tiles are
// not part of the terrain mesh, so this is the only water geometry.
this.ocean = { buffer: null, count: 0 };
// The deterministic relief field for the current world.
this.heightField = null;
this._heightView = null;
// The lattice vertices the cosmetic warp may move (those touched by land),
// so open water hexagons stay regular.
this._warp = null;
this.roads = { buffer: null, count: 0 };
this.railways = { buffer: null, count: 0 };
// The flat political/economic transport lines: straight solid strokes.
this.flatTransport = { 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 far-out political map: one flat country colour per owned tile.
this.politicalFill = { buffer: null, count: 0 };
// The economic modes: one flat colour per tile, from the shared scale.
this.economicFill = { 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;
bootLog.measure("Created the WebGL context", () => this._initContext());
bootLog.measure("Compiled the map shaders", () => this._initProgram());
bootLog.measure("Compiled the fog shaders", () => {
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;
}
});
bootLog.measure("Started loading the terrain textures", () => this._initTexture());
this._listenForContextLoss();
}
_initContext() {
const gl =
this.canvas.getContext("webgl2", CONTEXT_ATTRIBUTES) ||
this.canvas.getContext("webgl", CONTEXT_ATTRIBUTES) ||
this.canvas.getContext("experimental-webgl", CONTEXT_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.ocean, this.roads, this.railways, this.flatTransport, this.borders, this.fog, this.regionOverlay,
this.politicalFill, this.economicFill, ...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.coastTexture) gl.deleteTexture(this.coastTexture);
this.coastTexture = null;
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.uMountain = gl.getUniformLocation(program, "uMountain");
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");
this.uGrassDryLow = gl.getUniformLocation(program, "uGrassDryLow");
this.uGrassDryHigh = gl.getUniformLocation(program, "uGrassDryHigh");
this.uGrassDryStrength = gl.getUniformLocation(program, "uGrassDryStrength");
this.uGrassDryTint = gl.getUniformLocation(program, "uGrassDryTint");
this.uCoast = gl.getUniformLocation(program, "uCoast");
this.uCoastOrigin = gl.getUniformLocation(program, "uCoastOrigin");
this.uCoastSize = gl.getUniformLocation(program, "uCoastSize");
this.uHexStep = gl.getUniformLocation(program, "uHexStep");
this.uWavePulse = gl.getUniformLocation(program, "uWavePulse");
this.uWaveStrength = gl.getUniformLocation(program, "uWaveStrength");
this.uWaterNormal = gl.getUniformLocation(program, "uWaterNormal");
this.uWaterRepeat = gl.getUniformLocation(program, "uWaterRepeat");
this.uWaterBump = gl.getUniformLocation(program, "uWaterBump");
}
_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 image sources the terrain shader samples and bakes the water
// normal map. The atlas is clamped (its cells must not bleed into one
// another); the tiling textures repeat, so the seam between two tiles
// continues the same pattern.
_initTexture() {
const gl = this.gl;
this.textures = {};
this.texReady = false;
this.texLoaded = 0;
this.texTotal = 0;
let pending = 0;
const loaded = (name) => {
pending -= 1;
this.texLoaded += 1;
// A lost context is rebuilt wholesale, so these counters are thrown away;
// still count down so an onload racing the loss cannot wedge `pending`.
if (this.lost) return;
bootLog.step(`Uploaded the ${name} texture (${this.texLoaded}/${this.texTotal})`);
if (pending > 0) return;
this.texReady = true;
this.version += 1;
this._sig = null;
bootLog.step("Terrain textures ready");
};
const load = (name, src, repeat) => {
const texture = this._createTexture(repeat);
this.textures[name] = texture;
pending += 1;
this.texTotal += 1;
const image = new Image();
image.onload = () => {
if (!this.lost) this._uploadImage(texture, image);
loaded(name);
};
// A texture that fails to load must not hold the loading screen open: the
// placeholder texel stays and the map still draws, just without its art.
image.onerror = () => loaded(name);
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_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.png`, true);
// The transport strips tile along a link, so they repeat.
load(TEXTURE_ROAD, `${TEXTURE_DIR}${TEXTURE_ROAD}.png`, true);
load(TEXTURE_RAIL, `${TEXTURE_DIR}${TEXTURE_RAIL}.png`, true);
// The water normal map is baked rather than loaded, so it is ready at once.
const bump = generateWaterBump(256, WATER_BUMP_CELLS, 1337);
const water = this._createTexture(true);
this.textures.water = water;
gl.bindTexture(gl.TEXTURE_2D, water);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, bump.size, bump.size, 0,
gl.RGBA, gl.UNSIGNED_BYTE, bump.data);
// Its features are about a world pixel, so mipmaps keep them from
// shimmering when the camera pulls back.
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
}
// True once every terrain texture has been uploaded (or failed), so the map
// can be revealed instead of a blank canvas. A lost context is treated as
// ready so a crash cannot leave the loading screen open forever.
isReady() {
return this.texReady || this.lost;
}
_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.coastTexture = null;
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 forest/mountain 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[5],
weights[3], weights[4], weights[6], 0,
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);
// A buffer that is already a typed array (the terrain mesh preallocates its
// exact size) is uploaded as is; the flat passes hand over plain arrays.
const data = arr instanceof Float32Array ? arr : new Float32Array(arr);
gl.bufferData(gl.ARRAY_BUFFER, data, 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;
bootLog.measure("Built the relief field", () => {
this.heightField = createHeightField(view);
this._heightView = view;
this._warp = this._buildWarpSet(view);
});
bootLog.measure("Built the coastal wave field", () => this._buildCoastTexture(view));
this.terrainLevels = [];
bootLog.measure("Built the terrain mesh", () => {
this.terrain = this._buildTerrainPass(view, 0);
this.terrainLevels[0] = this.terrain;
});
bootLog.measure("Built the ocean", () => this.buildOcean(view));
bootLog.measure("Built the roads and borders", () => {
this.buildRoads(view);
this.buildBorders(view);
});
// A brand-new world has no knowledge yet, so the fog built here would cover
// the whole map only to be thrown away when the snapshot's visibility
// arrives a moment later. Skip it and let that update build it once.
bootLog.measure("Built the fog of war", () => {
if (view.explored && view.explored.size > 0) this.buildKnowledge(view);
else this._upload(this.fog, []);
});
bootLog.measure("Built the selected-region overlay", () => this.buildRegionOverlay(view));
}
// The per-tile distance-to-land field the water shader samples for its
// coastal wavelets. Rebuilt with the world; a one-texel far field when there
// is no coast to find, so the shader still has a texture to bind.
_buildCoastTexture(view) {
const gl = this.gl;
const field = view && view.topology ? coastDistanceField(view, COAST_RANGE) : null;
if (!this.coastTexture) this.coastTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.coastTexture);
// One byte per texel, and the width is rarely a multiple of four.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
if (field) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, field.width, field.height, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, field.data);
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8Array([255]));
}
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
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);
}
// Every lattice vertex a land tile touches, keyed the way `edgePoint` looks
// them up. Open-water vertices are left out, so a sea tile with only sea
// neighbours is drawn as a regular hexagon while the coast still meets the
// warped land.
_buildWarpSet(view) {
const warp = new Set();
const tiles = view.tiles || {};
if (!view.topology) return warp;
const period = periodUnits(view.topology);
for (const k in tiles) {
const tile = tiles[k];
if (!tile || isWaterTile(tile)) continue;
const coords = parseKey(k);
const centre = mapToLocal(coords.x, coords.y);
for (const corner of CORNERS) {
warp.add(vertexKey(centre.x + corner.x, centre.y + corner.y, period));
}
}
return warp;
}
// The boundary of one tile for the selection outline: wavy over land and the
// coast, straight and regular over open water.
tileBoundary(view, x, y, segments = EDGE_SEGMENTS) {
const centre = mapToLocal(x, y);
return distortedBoundary(view.seed | 0, centre, periodUnits(view.topology), segments,
this.edgeWaviness(view, x, y), this._warp);
}
// Whether the view currently ignores the terrain relief: the political and
// economic fills replace the terrain, so every ground layer drawn with them
// (fill, borders, fog, region) is pinned flat instead of riding the heightmap.
_ignoresRelief(view) {
return !!(view && (view.political || view.economic));
}
// 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;
}
// The normalised relief at a world point. Callers that place a DOM overlay on
// the terrain (the selected-tile outline) use it to lift their own geometry;
// it is zero when no field has been built, so a flat view stays flat.
pointHeight(x, y) {
return this.heightField ? this.heightField.at(x, y) : 0;
}
// Whether a ridge between a world point and the camera hides it, for the DOM
// overlays that are drawn outside the border pass.
isOccluded(x, y) {
return this.heightField ? this._isOccluded(this.heightField, x, y) : false;
}
// Builds the terrain mesh for one level of detail and uploads it. Level 0 is
// the wavy mesh with each fan triangle subdivided to sample the relief more
// finely; the coarser levels trade edge and height detail for far-out zooms,
// where both are sub-pixel anyway.
_buildTerrainPass(view, level) {
const lod = TERRAIN_LODS[level] || TERRAIN_LODS[0];
const segments = lod.segments;
const subdivisions = lod.subdivisions || 0;
const field = this._heightField(view);
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;
const originX = topo.originX;
const originY = topo.originY;
// Count the vertices first so the mesh is written straight into a typed
// array. At the closest level of detail this is millions of floats, and
// growing a plain array and copying it into the buffer afterwards was the
// single biggest cost of building a game.
const trianglesPerTile = CORNERS.length * segments * (4 ** subdivisions);
let vertexCount = 0;
for (let y = originY; y < originY + topo.height; y++) {
for (let x = originX; x < originX + topo.width; x++) {
const tile = tiles[key(x, y)];
if (tile && !isWaterTile(tile)) vertexCount += trianglesPerTile * 3;
}
}
const buffer = new Float32Array(vertexCount * TERRAIN_FLOATS);
let offset = 0;
// 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 = [];
for (let y = originY; y < originY + topo.height; y++) {
rowStart.push(offset / TERRAIN_FLOATS);
for (let x = originX; x < originX + topo.width; x++) {
const tile = tiles[key(x, y)];
if (!tile) continue;
// The sea is one flat quad built by `buildOcean`, so sea tiles are left
// out of the terrain mesh entirely.
if (isWaterTile(tile)) continue;
const centre = mapToLocal(x, y);
const column = tile.col | 0;
// 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, null, this._warp);
// 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 vertices = new Array(boundaryLength);
for (let i = 0; i < boundaryLength; i++) {
const point = boundary[i];
const sample = sampleAt(point.x, point.y);
vertices[i] = {
x: point.x,
y: point.y,
idealX: point.idealX,
idealY: point.idealY,
h: sample.h,
dx: sample.dx,
dy: sample.dy,
weights: this._textureWeights(tile, ring, point.idealX, point.idealY),
};
}
const centreSample = sampleAt(centre.x, centre.y);
const centreVertex = {
x: centre.x,
y: centre.y,
idealX: centre.x,
idealY: centre.y,
h: centreSample.h,
dx: centreSample.dx,
dy: centreSample.dy,
weights: this._textureWeights(tile, ring, centre.x, centre.y),
};
const push = (vertex) => {
// The terrain vertex layout, written in place: x, y, u, v, wx, wy,
// height, the four base weights, the four overlay weights, then the
// two slope components. Keeping this here avoids a per-vertex call
// and a push into a growing array.
let uc = (vertex.idealX - centre.x + HEX_W / 2) / HEX_W;
let vc = (vertex.idealY - centre.y + HEX_H / 2) / HEX_H;
uc = Math.min(Math.max(uc, uPad), 1 - uPad);
vc = Math.min(Math.max(vc, vPad), 1 - vPad);
const w = vertex.weights;
buffer[offset] = vertex.x;
buffer[offset + 1] = vertex.y;
buffer[offset + 2] = (column + uc) / ATLAS_COLS;
buffer[offset + 3] = vc;
buffer[offset + 4] = vertex.idealX;
buffer[offset + 5] = vertex.idealY;
buffer[offset + 6] = vertex.h;
buffer[offset + 7] = w[0];
buffer[offset + 8] = w[1];
buffer[offset + 9] = w[2];
buffer[offset + 10] = w[5];
buffer[offset + 11] = w[3];
buffer[offset + 12] = w[4];
buffer[offset + 13] = w[6];
buffer[offset + 14] = w[7];
buffer[offset + 15] = vertex.dx;
buffer[offset + 16] = vertex.dy;
offset += TERRAIN_FLOATS;
};
// The midpoint of an edge re-samples the field and the texture blend at
// its own ideal position, so a subdivided triangle follows the relief
// instead of just splitting the flat facets.
const midpoint = (a, b) => {
const idealX = (a.idealX + b.idealX) / 2;
const idealY = (a.idealY + b.idealY) / 2;
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
const sample = sampleAt(x, y);
return {
x,
y,
idealX,
idealY,
h: sample.h,
dx: sample.dx,
dy: sample.dy,
weights: this._textureWeights(tile, ring, idealX, idealY),
};
};
// Splits a triangle into four by its edge midpoints. Shared midpoints
// are recomputed from the same endpoints, so the pieces still meet.
const emit = (a, b, c, depth) => {
if (depth <= 0) {
push(a);
push(b);
push(c);
return;
}
const ab = midpoint(a, b);
const bc = midpoint(b, c);
const ca = midpoint(c, a);
emit(a, ab, ca, depth - 1);
emit(ab, b, bc, depth - 1);
emit(ca, bc, c, depth - 1);
emit(ab, bc, ca, depth - 1);
};
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;
emit(centreVertex, vertices[indexA], vertices[indexB], subdivisions);
}
}
}
}
rowStart.push(offset / TERRAIN_FLOATS);
const pass = { buffer: null, count: 0 };
this._upload(pass, buffer, 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;
}
// The ocean is a single flat quad over the whole world, drawn before the
// land. Because the sea tiles are not part of the terrain mesh, every part of
// the map the land does not cover reads as water, and the surface is exactly
// flat at height zero. The quad carries the water flag and its world position,
// which the terrain shader turns into the same procedural waves the per-tile
// sea used to draw.
buildOcean(view) {
const arr = [];
if (!view.topology || !Number.isFinite(view._worldWidth) || view._worldWidth <= 0) {
this._upload(this.ocean, arr, undefined, TERRAIN_FLOATS);
return;
}
const minX = view._worldMinX;
const minY = view._worldMinY;
const maxX = minX + view._worldWidth;
const maxY = minY + view._worldHeight;
// x, y, u, v, wx, wy, height, then the weights: grass, sand, atlas, forest,
// mountain, water (1), beach (0).
const water = [0, 0, 0, 0, 0, 1, 0];
const push = (x, y) => this._pushTerrain(arr, x, y, 0, 0, x, y, 0, 0, 0, water);
push(minX, minY);
push(maxX, minY);
push(maxX, maxY);
push(minX, minY);
push(maxX, maxY);
push(minX, maxY);
this._upload(this.ocean, arr, undefined, TERRAIN_FLOATS);
}
// Draws the whole transport network as texture quads. Roads and railways
// share one graph: each link is a single cubic Bezier from centre to centre,
// and when its two ends are different kinds the curve is split at the border
// so the road half and the rail half meet there. Upgrading a tile therefore
// never leaves a gap where the two networks meet. A small quad at every tile
// centre covers a junction, or a stub for a lone tile.
buildRoads(view) {
const roadArr = [];
const railArr = [];
this.roads.texture = TEXTURE_ROAD;
this.railways.texture = TEXTURE_RAIL;
this.buildFlatTransport(view);
const roads = view.roads || new Set();
const railways = view.railways || new Set();
if ((roads.size === 0 && railways.size === 0) || !view.topology) {
this._upload(this.roads, roadArr, undefined, ROAD_FLOATS);
this._upload(this.railways, railArr, undefined, ROAD_FLOATS);
return;
}
// 0 = road, 1 = railway. A tile carries one kind.
const kind = new Map();
for (const k of roads) kind.set(k, 0);
for (const k of railways) kind.set(k, 1);
const topo = view.topology;
const field = this._heightField(view);
const lift = (point) => {
if (field) point.h = field.at(point.x, point.y);
return point;
};
// The centre of every transport tile, its transport neighbours (of either
// kind), and its coords, so a link can be curved through the tiles on either
// side of it and a wrapped seam link stays one step.
const coordsOf = new Map();
const centres = new Map();
for (const k of kind.keys()) {
const coords = parseKey(k);
coordsOf.set(k, coords);
centres.set(k, lift(mapToLocal(coords.x, coords.y)));
}
const neighboursOf = new Map();
for (const k of kind.keys()) {
const coords = coordsOf.get(k);
const list = [];
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (kind.has(nk)) list.push(nk);
}
neighboursOf.set(k, list);
}
const arrFor = (k) => (kind.get(k) === 0 ? roadArr : railArr);
const drawn = new Set();
const links = new Map();
const bump = (k) => links.set(k, (links.get(k) || 0) + 1);
const at = (origin, originKey, otherKey) => {
const delta = topo.pixelDelta(coordsOf.get(originKey), coordsOf.get(otherKey));
return { x: origin.x + delta.x, y: origin.y + delta.y };
};
for (const k of kind.keys()) {
for (const nk of neighboursOf.get(k)) {
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
if (drawn.has(pair)) continue;
drawn.add(pair);
bump(k);
bump(nk);
const from = centres.get(k);
const to = lift(at(from, k, nk));
// A Catmull-Rom tangent at a shared centre: continue through the tile
// on the other side, so every link meeting there leaves in the same
// direction (C1 continuity). Because the neighbour list mixes roads and
// railways, the tangent is right across an upgrade too.
const beforeKey = this._continuationKey(neighboursOf.get(k), k, nk, coordsOf, topo);
const afterKey = this._continuationKey(neighboursOf.get(nk), nk, k, coordsOf, topo);
const before = beforeKey ? at(from, k, beforeKey) : from;
const after = afterKey ? at(to, nk, afterKey) : to;
const c1 = { x: from.x + (to.x - before.x) / 6, y: from.y + (to.y - before.y) / 6 };
const c2 = { x: to.x - (after.x - from.x) / 6, y: to.y - (after.y - from.y) / 6 };
const points = cubicBezier(from, c1, c2, to, ROAD_CURVE_SEGMENTS).map(lift);
const same = kind.get(k) === kind.get(nk);
this._transportBand(points, arrFor(k), same ? null : arrFor(nk));
}
}
// A transport tile joined to nothing has no link to draw, so give it a stub.
// A plain run or bend needs no cap: the bands overshoot the centre and
// overlap, so the path stays continuous. A real junction (three or more
// links) gets a small uniform patch to fill the middle.
for (const k of kind.keys()) {
const count = links.get(k) || 0;
if (count === 1 || count === 2) continue;
const coords = coordsOf.get(k);
const point = lift(mapToLocal(coords.x, coords.y));
const arr = arrFor(k);
if (count === 0) this._transportCappedJoint(arr, point);
else this._transportJunction(arr, point);
}
this._upload(this.roads, roadArr, undefined, ROAD_FLOATS);
this._upload(this.railways, railArr, undefined, ROAD_FLOATS);
}
// The flat political/economic transport layer: straight solid strokes from
// tile centre to tile centre, dark grey for roads and brown for railways, with
// no curves and no textures. A mixed link changes colour at the border. A
// small square at every centre hides the notches where strokes meet.
buildFlatTransport(view) {
const arr = [];
const roads = view.roads || new Set();
const railways = view.railways || new Set();
if ((roads.size === 0 && railways.size === 0) || !view.topology) {
this._upload(this.flatTransport, arr);
return;
}
const kind = new Map();
for (const k of roads) kind.set(k, FLAT_ROAD_COLOUR);
for (const k of railways) kind.set(k, FLAT_RAIL_COLOUR);
const topo = view.topology;
const coordsOf = new Map();
const centres = new Map();
for (const k of kind.keys()) {
const coords = parseKey(k);
coordsOf.set(k, coords);
centres.set(k, mapToLocal(coords.x, coords.y));
}
const drawn = new Set();
for (const k of kind.keys()) {
const from = centres.get(k);
const coords = coordsOf.get(k);
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (!kind.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 mid = { x: from.x + delta.x / 2, y: from.y + delta.y / 2 };
const target = { x: from.x + delta.x, y: from.y + delta.y };
const here = kind.get(k);
const there = kind.get(nk);
if (here === there) {
this._segment(arr, from, target, FLAT_TRANSPORT_WIDTH, here);
} else {
this._segment(arr, from, mid, FLAT_TRANSPORT_WIDTH, here);
this._segment(arr, mid, target, FLAT_TRANSPORT_WIDTH, there);
}
}
}
// Cap every centre so the strokes join cleanly, using the tile's own colour.
for (const k of kind.keys()) {
const point = centres.get(k);
const r = FLAT_TRANSPORT_WIDTH / 2;
this._quad(arr, [
{ x: point.x - r, y: point.y - r },
{ x: point.x + r, y: point.y - r },
{ x: point.x + r, y: point.y + r },
{ x: point.x - r, y: point.y + r },
], kind.get(k));
}
this._upload(this.flatTransport, arr);
}
// The transport neighbour of `originKey` that best continues the road arriving
// from `forwardKey`: the one most nearly opposite it, so the Catmull-Rom
// tangent runs before -> origin -> forward. Null at a dead end, which gives a
// straight tangent out of the stub. Deltas are wrapped, so the choice is right
// even across the cylindrical seam.
_continuationKey(list, originKey, forwardKey, coordsOf, topo) {
const originCoords = coordsOf.get(originKey);
const forward = topo.pixelDelta(originCoords, coordsOf.get(forwardKey));
const forwardLength = Math.hypot(forward.x, forward.y) || 1;
let best = null;
let bestAlign = Infinity;
for (const nk of list) {
if (nk === forwardKey) continue;
const delta = topo.pixelDelta(originCoords, coordsOf.get(nk));
const length = Math.hypot(delta.x, delta.y) || 1;
const align = (delta.x * forward.x + delta.y * forward.y) / (length * forwardLength);
if (align < bestAlign) {
bestAlign = align;
best = nk;
}
}
return best;
}
// A centered, mitered band along a polyline. v runs along the polyline (one
// texture repeat per TRANSPORT_TEXTURE_LENGTH world pixels) and u spans the
// quad's width, clipped to the texture's opaque band. Consecutive quads share
// their edges and their v, so the pattern runs continuously. Both ends are
// extended half a road width past the tile centre, so neighbouring links
// overlap instead of leaving a wedge.
//
// `secondArr` (optional) receives the second half of the band, split at the
// border. That is how a road link meets a railway link: the geometry stays one
// continuous curve, but the near half is textured as a road and the far half
// as a railway.
_transportBand(points, firstArr, secondArr = null) {
if (points.length < 2) return;
const half = TRANSPORT_WIDTH / 2;
const extended = [this._extend(points[1], points[0], half)];
for (const point of points) extended.push(point);
extended.push(this._extend(points[points.length - 2], points[points.length - 1], half));
points = extended;
const n = points.length;
const normals = [];
const lengths = [];
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;
lengths.push(length);
normals.push({ x: -dy / length, y: dx / length });
}
const offsets = [];
const distance = [0];
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 });
if (i > 0) distance.push(distance[i - 1] + lengths[i - 1]);
}
const quads = n - 1;
const split = secondArr ? Math.ceil(quads / 2) : quads;
for (let i = 0; i < quads; i++) {
const arr = i < split ? firstArr : secondArr;
const a = points[i];
const b = points[i + 1];
const oa = offsets[i];
const ob = offsets[i + 1];
const va = distance[i] / TRANSPORT_TEXTURE_LENGTH;
const vb = distance[i + 1] / TRANSPORT_TEXTURE_LENGTH;
this._transportQuad(arr, [
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h, u: TRANSPORT_U0, v: va },
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h, u: TRANSPORT_U0, v: vb },
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h, u: TRANSPORT_U1, v: vb },
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h, u: TRANSPORT_U1, v: va },
]);
}
}
// A point `distance` beyond `to` along the direction `to - from`, at the same
// height. Used to run a band a little past a tile centre so it melts into the
// neighbouring link's band.
_extend(from, to, distance) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy) || 1;
return { x: to.x + (dx / length) * distance, y: to.y + (dy / length) * distance, h: to.h };
}
// A stub for a transport tile with no neighbours, so a lone road or railway
// still shows on the map.
_transportCappedJoint(arr, point) {
const r = TRANSPORT_WIDTH / 2;
const h = point.h;
this._transportQuad(arr, [
{ x: point.x - r, y: point.y - r, h, u: TRANSPORT_U0, v: 0 },
{ x: point.x + r, y: point.y - r, h, u: TRANSPORT_U1, v: 0 },
{ x: point.x + r, y: point.y + r, h, u: TRANSPORT_U1, v: 1 },
{ x: point.x - r, y: point.y + r, h, u: TRANSPORT_U0, v: 1 },
]);
}
// A small round patch at a three-or-more-way junction. Every vertex samples
// the same spot of the strip (its asphalt/grey body), so the patch is a flat
// colour with no direction of its own and never reads as a straight road tile.
_transportJunction(arr, point) {
const r = TRANSPORT_WIDTH / 2;
const sides = 10;
const push = (x, y) => arr.push(x, y, 0.3, 0.5, point.h, 1, 1, 1, 1);
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2;
const b = ((i + 1) / sides) * Math.PI * 2;
push(point.x, point.y);
push(point.x + Math.cos(a) * r, point.y + Math.sin(a) * r);
push(point.x + Math.cos(b) * r, point.y + Math.sin(b) * r);
}
}
// Two triangles from a quad of { x, y, h, u, v } corners, tinted white so the
// transport texture shows as authored.
_transportQuad(arr, corners) {
const push = (corner) =>
arr.push(corner.x, corner.y, corner.u, corner.v, corner.h || 0, 1, 1, 1, 1);
push(corners[0]);
push(corners[1]);
push(corners[2]);
push(corners[0]);
push(corners[2]);
push(corners[3]);
}
// 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);
// The political and economic maps sit on a flat sheet: their borders must
// not ride (or be dotted by) the terrain relief that is no longer shown.
const flat = !!this._ignoresRelief(view);
const field = flat ? null : 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)) {
// A run that a ridge to the south would hide is drawn dotted, so it
// still reads as a border without pretending to sit in front of the
// terrain. The soft glow stays continuous underneath it.
const hidden = field
? chain.points.map((point) => this._isOccluded(field, point.x, point.y))
: null;
this._stroke(arr, chain.points, glow, soft, chain.closed);
this._stroke(arr, chain.points, NATIONAL_BORDER, colour, chain.closed, hidden);
}
}
this._upload(this.borders, arr);
}
// Whether a ridge between a point and the camera hides it. The camera looks
// north, so the occluder lies south (larger y); it hides the point when its
// screen height `(y+dy)*tilt - height*HEIGHT_LIFT` rises above the point's.
// The field is sampled straight south, the only direction that can project
// onto the same screen column, and the callers pass a point already on the
// terrain (so its own gradient is not what hides it).
_isOccluded(field, x, y) {
const height = field.at(x, y);
const reach = HEIGHT_LIFT / CAMERA_TILT;
const steps = 12;
for (let i = 1; i <= steps; i++) {
const dy = (reach * i) / steps;
const ahead = field.at(x, y + dy);
if ((ahead - height) * HEIGHT_LIFT > dy * CAMERA_TILT) return true;
}
return false;
}
// 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;
}
// Which of a tile's six edges stay wavy, as a boolean per edge in CORNERS
// order. A sea tile keeps straight edges against open water but stays wavy
// against any land, so the coast still follows the land. Land tiles return
// null, meaning every edge is wavy (the default). Both tiles on a shared edge
// agree: a land/sea edge is wavy from either side, a sea/sea edge is straight.
edgeWaviness(view, x, y) {
const tiles = (view && view.tiles) || {};
if (!isWaterTile(tiles[key(x, y)])) return null;
return this._edgeNeighbours(view, x, y).map((neighbour) => {
const other = neighbour ? tiles[key(neighbour.x, neighbour.y)] : null;
return !!other && !isWaterTile(other);
});
}
// 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.
// `hidden`, when given, is one boolean per point: a segment with either end
// hidden is drawn dotted instead of solid, so a run behind a ridge reads as
// dashed while the rest keeps its continuous stroke.
_stroke(arr, points, width, colour, closed, hidden = null) {
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];
if (hidden && (hidden[i] || hidden[(i + 1) % n])) {
this._dottedSegment(arr, a, b, width, colour);
continue;
}
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);
}
}
// A run of dashes along a->b, used for the hidden parts of a border. The
// dash and gap scale with the stroke width so a wider line does not turn into
// a solid again. Height is interpolated so the dashes ride the relief too.
_dottedSegment(arr, a, b, width, colour) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (length <= 0) return;
const dash = Math.max(2, width * 1.6);
const period = dash + Math.max(2, width * 1.4);
const ah = a.h || 0;
const bh = b.h || 0;
for (let start = 0; start < length; start += period) {
const end = Math.min(start + dash, length);
const t0 = start / length;
const t1 = end / length;
this._segment(arr, { x: a.x + dx * t0, y: a.y + dy * t0, h: ah + (bh - ah) * t0 },
{ x: a.x + dx * t1, y: a.y + dy * t1, h: ah + (bh - ah) * t1 }, width, 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.
// Builds the fog sheet. `rowRange` limits the work to a band of tile rows:
// the whole-map mesh runs to millions of vertices, and a unit exploring new
// ground changes the fog constantly, so rebuilding all of it on every change
// was the biggest client-side cost of marching into enemy territory. The
// renderer's row-banded draw already only needs the rows on screen.
buildKnowledge(view, rowRange = null) {
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._ignoresRelief(view) ? null : this._heightField(view);
const segments = EDGE_SEGMENTS;
const originX = topo.originX;
const originY = topo.originY;
const startRow = rowRange ? Math.max(0, rowRange.start) : 0;
const endRow = rowRange ? Math.min(topo.height - 1, rowRange.end) : topo.height - 1;
// A full-length table so the absolute row indices the draw pass uses stay
// valid; rows outside the built band keep their zero offset.
const rowStart = new Array(topo.height + 1).fill(0);
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 r = startRow; r <= endRow; r++) {
const y = originY + r;
rowStart[r] = 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;
// When every sample carries the same fog, the blur resolves to that
// value over the whole tile, so the per-vertex kernel is skipped. This
// is the common interior case and the bulk of a rebuild.
const uniform = ring.every((sample) => sameFog(sample.level, ring[0].level));
let fogAt;
let centreFog;
if (uniform) {
centreFog = ring[0].level;
fogAt = () => centreFog;
} else {
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);
};
centreFog = fogAt(centre.x, centre.y);
}
// Fog must cover exactly the hexagon the terrain draws underneath it:
// wavy over the land and the coast, a regular hexagon over open water.
const boundary = distortedBoundary(seed, centre, period, segments,
this.edgeWaviness(view, x, y), this._warp);
const vertices = new Array(boundary.length);
for (let i = 0; i < boundary.length; i++) {
vertices[i] = fogAt(boundary[i].x, boundary[i].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[endRow + 1] = 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 band actually built, so the draw can clamp to it while a newer one is
// still being coalesced.
this.fog.bandStart = startRow;
this.fog.bandEnd = endRow;
}
// 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._ignoresRelief(view) ? null : 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, null, this._warp);
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);
}
// The far-out political map: every owned tile is filled with its country's
// colour. The hexagon follows the same warped boundary as the terrain it
// covers, so the colour stops exactly on the coastline, but it is drawn flat:
// this map is about territory, not relief. The land the terrain already draws
// supplies the neutral ground for unclaimed tiles; here we paint what a
// nation owns.
buildPolitical(view) {
const arr = [];
if (!view || !view.political || !view.topology || !view.tiles) {
this._upload(this.politicalFill, arr);
return;
}
const seed = view.seed | 0;
const topo = view.topology;
const period = periodUnits(topo);
const territory = view.territory;
const civilisations = view.civilisations;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
rowStart.push(arr.length / COLOUR_FLOATS);
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const k = key(x, y);
const tile = view.tiles[k];
if (!tile || isWaterTile(tile)) continue;
const owner = territory ? territory.get(k) : undefined;
const civ = owner !== undefined && civilisations ? civilisations[owner] : null;
let colour = UNCLAIMED_LAND;
if (civ) {
const rgb = parseColour(civ.primaryColour);
colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
}
this._pushTileFill(arr, x, y, colour, seed, period);
}
}
rowStart.push(arr.length / COLOUR_FLOATS);
this._upload(this.politicalFill, arr, this.gl.DYNAMIC_DRAW);
this.politicalFill.rowStart = rowStart;
this.politicalFill.originY = topo.originY;
this.politicalFill.rows = topo.height;
}
// The economic modes: every tile with a figure is filled with its place on the
// shared colour scale. The geometry is identical to the political fill, flat
// on the ground and row-banded like the terrain.
buildEconomic(view) {
const arr = [];
if (!view || !view.economic || !view.economicValues ||
view.economicValues.size === 0 || !view.topology || !view.tiles) {
this._upload(this.economicFill, arr);
return;
}
const seed = view.seed | 0;
const topo = view.topology;
const period = periodUnits(topo);
const { min, max } = view.economicRange || { min: 0, max: 0 };
const scale = view.economicScale || null;
const values = view.economicValues;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
rowStart.push(arr.length / COLOUR_FLOATS);
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const value = values.get(key(x, y));
if (value === undefined) continue;
const rgb = parseColour(economicColour(value, min, max, undefined, scale));
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, x, y, colour, seed, period);
}
}
rowStart.push(arr.length / COLOUR_FLOATS);
this._upload(this.economicFill, arr, this.gl.DYNAMIC_DRAW);
this.economicFill.rowStart = rowStart;
this.economicFill.originY = topo.originY;
this.economicFill.rows = topo.height;
}
// One flat, filled hexagon for a tile, appended to a flat-colour buffer.
_pushTileFill(arr, x, y, colour, seed, period) {
const centre = mapToLocal(x, y);
const boundary = distortedBoundary(seed, centre, period, 1, null, this._warp);
for (let i = 0; i < boundary.length; i++) {
const a = boundary[i];
const b = boundary[(i + 1) % boundary.length];
this._push(arr, centre.x, centre.y, 0, colour);
this._push(arr, a.x, a.y, 0, colour);
this._push(arr, b.x, b.y, 0, colour);
}
}
// -------------------------------------------------------------- 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.mountain);
gl.uniform1i(this.uMountain, UNIT_MOUNTAIN);
gl.activeTexture(gl.TEXTURE5);
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountains_normal_map);
gl.uniform1i(this.uMountainNormal, UNIT_MOUNTAIN_NORMAL);
gl.activeTexture(gl.TEXTURE6);
gl.bindTexture(gl.TEXTURE_2D, this.coastTexture);
gl.uniform1i(this.uCoast, UNIT_COAST);
gl.activeTexture(gl.TEXTURE7);
gl.bindTexture(gl.TEXTURE_2D, this.textures.water);
gl.uniform1i(this.uWaterNormal, UNIT_WATER);
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 if (floats === ROAD_FLOATS) {
// Transport quads carry a UV and a tint on top of position and height.
// The strip is bound to the atlas sampler, which the uMode 2 shader branch
// reads instead of the terrain atlas.
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.textures[pass.texture]);
gl.uniform1i(this.uAtlas, UNIT_ATLAS);
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, 16);
gl.enableVertexAttribArray(this.aColour);
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 20);
} 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;
const political = !!view.political;
const economic = !!view.economic;
// 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. The flat modes do not draw the
// textured mesh, so they never build a level for it either.
const level = terrainLod(camera.zoom);
const terrainPass = !political && !economic && 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.uGrassDryLow, GRASS_DRY_LOW);
gl.uniform1f(this.uGrassDryHigh, GRASS_DRY_HIGH);
gl.uniform1f(this.uGrassDryStrength, GRASS_DRY_STRENGTH);
gl.uniform3f(this.uGrassDryTint, GRASS_DRY_TINT[0], GRASS_DRY_TINT[1], GRASS_DRY_TINT[2]);
gl.uniform1f(this.uWavePulse, COAST_WAVE_PULSE);
gl.uniform1f(this.uWaveStrength, COAST_WAVE_STRENGTH);
gl.uniform1f(this.uWaterRepeat, WATER_REPEAT);
gl.uniform1f(this.uWaterBump, WATER_BUMP);
gl.uniform2f(this.uHexStep, COL_STEP, HEX_H);
if (view.topology) {
gl.uniform2f(this.uCoastOrigin, view.topology.originX, view.topology.originY);
gl.uniform2f(this.uCoastSize, view.topology.width, view.topology.height);
}
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);
// The flat ocean sits under everything. The textured land mesh is only drawn
// on the terrain map: the political and economic fills cover every land tile
// themselves (unclaimed land included), so drawing the mesh too would only
// double the geometry and pay for the expensive land shader twice.
this._drawPass(this.ocean, 0, shifts, period);
if (!political && !economic && this.texReady && terrainPass) {
this._drawPass(terrainPass, 0, shifts, period, rows);
}
if (political) this._drawPass(this.politicalFill, 1, shifts, period, rows);
else if (economic) this._drawPass(this.economicFill, 1, shifts, period, rows);
// Roads and railways: textured curve quads on the terrain map, plain solid
// strokes (dark grey / brown) on the flat political and economic maps.
if (!political && !economic) {
this._drawPass(this.roads, 2, shifts, period);
this._drawPass(this.railways, 2, shifts, period);
} else {
this._drawPass(this.flatTransport, 1, shifts, period);
}
this._drawPass(this.borders, 1, shifts, period);
// The fog mesh is only built for the rows currently on screen, so clamp the
// drawn band to it: while a pan is waiting its turn in the rebuild
// throttle, rows outside the built band have no fog geometry to draw.
const fogRows = this._fogBand(rows);
if (fogRows) this._drawFog(bufferWidth, bufferHeight, shifts, period, fogRows);
this._drawPass(this.regionOverlay, 1, shifts, period);
}
// The intersection of the visible rows and the fog band last built, or null
// when they do not overlap.
_fogBand(rows) {
if (!rows) return null;
const fog = this.fog;
if (fog.bandStart === undefined || fog.bandEnd === undefined) return rows;
const start = Math.max(rows.start, fog.bandStart);
const end = Math.min(rows.end, fog.bandEnd);
return start <= end ? { start, end } : null;
}
// 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);
}
}