1421 lines
56 KiB
JavaScript
1421 lines
56 KiB
JavaScript
// WebGL renderer for the world map. It draws the terrain, the pre-generated
|
|
// roads, the territory borders, the fog of war and the selected region as
|
|
// batched triangles on a single canvas behind the DOM icon layers. The icons
|
|
// (units, cities, labels, paths, targets) stay ordinary DOM elements, so the
|
|
// game's look, hit-testing and accessibility are unchanged.
|
|
//
|
|
// This module is client-only: it is never imported by server/ or shared/, and
|
|
// it degrades to null when the browser cannot give us a context (which is also
|
|
// how the jsdom test harness keeps exercising the DOM renderer).
|
|
//
|
|
// Geometry is built once per world in world pixels and uploaded to a static
|
|
// vertex buffer. Each layer is one interleaved POSITION/UV/COLOR buffer and one
|
|
// draw pass; a cylindrical world is drawn up to three times with a one-period
|
|
// shift so the copies wrap around the camera. Per-frame work is just uniforms
|
|
// and a handful of draw calls, so panning does not touch the DOM at all.
|
|
|
|
import { HEX_W, HEX_H, mapToLocal, parseKey, key } from "../../../shared/hex.js";
|
|
import { nearestCorners, parseColour } from "./utils.js";
|
|
import {
|
|
CORNERS,
|
|
EDGE_SEGMENTS,
|
|
bentPath,
|
|
distortedBoundary,
|
|
edgePoint,
|
|
latticeKey,
|
|
periodUnits,
|
|
} from "./distortion.js";
|
|
import {
|
|
TEXTURE_DIR,
|
|
TEXTURE_FOREST,
|
|
TEXTURE_GRASS,
|
|
TEXTURE_HILLS,
|
|
TEXTURE_HILLS_NORMAL,
|
|
TEXTURE_MOUNTAIN,
|
|
TEXTURE_MOUNTAIN_NORMAL,
|
|
TEXTURE_SAND,
|
|
isTexturedLand,
|
|
textureRepeat,
|
|
vertexTextureWeights,
|
|
} from "./textures.js";
|
|
import { blurFog, fogLevel, isFogged } from "./fog.js";
|
|
import { CAMERA_TILT } from "./constants.js";
|
|
|
|
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, plus the base
|
|
// colour (grass, sand, atlas) and one weight per overlay. Fog uses a compact
|
|
// x, y, r, g, b, a layout instead. The flat-colour passes (roads, borders,
|
|
// region) use x, y, u, v, r, g, b, a.
|
|
const ATTR_POS = 0;
|
|
const ATTR_UV = 1;
|
|
const ATTR_COLOUR = 2;
|
|
const ATTR_WORLD = 3;
|
|
const ATTR_OVERLAY = 4;
|
|
|
|
// Floats per vertex in each layout.
|
|
const TERRAIN_FLOATS = 14;
|
|
const COLOUR_FLOATS = 8;
|
|
const FOG_FLOATS = 6;
|
|
|
|
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
|
// The terrain atlas is a single row of equal cells, one per terrain type.
|
|
const ATLAS_COLS = 7;
|
|
// Texture unit per sampler.
|
|
const UNIT_ATLAS = 0;
|
|
const UNIT_GRASS = 1;
|
|
const UNIT_SAND = 2;
|
|
const UNIT_FOREST = 3;
|
|
const UNIT_HILLS = 4;
|
|
const UNIT_MOUNTAIN = 5;
|
|
const UNIT_HILLS_NORMAL = 6;
|
|
const UNIT_MOUNTAIN_NORMAL = 7;
|
|
// National border stroke, and the region border that is half as thick and
|
|
// drawn as dotted dashes instead of a continuous line.
|
|
const NATIONAL_BORDER = 3.5;
|
|
const REGION_BORDER = NATIONAL_BORDER / 2;
|
|
const REGION_FILL = [1, 1, 1, 0.2];
|
|
const REGION_BORDER_COLOUR = [1, 1, 1, 0.9];
|
|
const REGION_DASHES = 6;
|
|
|
|
const VERTEX_SHADER = `
|
|
attribute vec2 aPos;
|
|
attribute vec2 aUV;
|
|
attribute vec4 aColour;
|
|
attribute vec2 aWorld;
|
|
attribute vec4 aOverlay;
|
|
uniform vec2 uCamera;
|
|
uniform vec2 uViewport;
|
|
uniform float uZoom;
|
|
uniform float uTilt;
|
|
uniform float uShiftX;
|
|
varying vec2 vUV;
|
|
varying vec4 vColour;
|
|
varying vec2 vWorld;
|
|
varying vec4 vOverlay;
|
|
void main() {
|
|
// Orthographic camera looking north at uTilt over the ground: the north-south
|
|
// axis is foreshortened, the east-west axis is untouched.
|
|
vec2 world = vec2(aPos.x + uShiftX, aPos.y);
|
|
vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt) * 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;
|
|
}
|
|
`;
|
|
|
|
// Terrain (uMode 0) blends the two tiling land textures and the atlas by the
|
|
// per-vertex weights, composites the forest/hills/mountain overlays on top and
|
|
// replaces sea with the animated water. The flat passes (uMode 1) just output
|
|
// their vertex colour.
|
|
const FRAGMENT_SHADER = `
|
|
precision mediump float;
|
|
uniform sampler2D uAtlas;
|
|
uniform sampler2D uGrass;
|
|
uniform sampler2D uSand;
|
|
uniform sampler2D uForest;
|
|
uniform sampler2D uHills;
|
|
uniform sampler2D uMountain;
|
|
uniform sampler2D uHillsNormal;
|
|
uniform sampler2D uMountainNormal;
|
|
uniform float uTextureRepeat;
|
|
uniform float uTime;
|
|
uniform float uMode;
|
|
varying vec2 vUV;
|
|
varying vec4 vColour;
|
|
varying vec2 vWorld;
|
|
varying vec4 vOverlay;
|
|
// The relief normal is rebuilt from the red/green gradient and a fixed up axis,
|
|
// so the maps' blue channel (which is not a clean 255 at rest) cannot bias the
|
|
// lighting. A flat texel is (0, 0, 1) and lights to exactly 1.
|
|
vec3 reliefNormal(vec4 texel) {
|
|
vec2 slope = texel.xy * 2.0 - 1.0;
|
|
return normalize(vec3(slope, 1.0));
|
|
}
|
|
// Procedural sea: a few crossing swells drive the deep/shallow gradient and a
|
|
// soft specular sparkle. It is driven by the same period-snapped texture
|
|
// coordinate as the land, whose whole cycles repeat with the world, so the waves
|
|
// stay seamless across both tile edges and the cylindrical wrap.
|
|
float waterWave(vec2 uv, vec2 freq, float speed, float t) {
|
|
return sin(6.2831853 * dot(freq, uv) + t * speed);
|
|
}
|
|
vec3 waterColour(vec2 uv, float t) {
|
|
float swell = waterWave(uv, vec2(1.0, 0.6), 0.55, t) * 0.4
|
|
+ waterWave(uv, vec2(-2.0, 1.3), 0.8, t) * 0.3
|
|
+ waterWave(uv, vec2(3.0, 0.4), 1.05, t) * 0.25;
|
|
float ripple = 0.5 + 0.5 * waterWave(uv, vec2(5.0, -3.0), 1.6, t);
|
|
// A narrow, low-contrast blue so the sea reads as calm water rather than a
|
|
// busy pattern. Depth drifts only slightly and the highlights are faint.
|
|
vec3 deep = vec3(0.078, 0.235, 0.353);
|
|
vec3 shallow = vec3(0.098, 0.275, 0.396);
|
|
vec3 colour = mix(deep, shallow, clamp(swell * 0.35 + 0.5, 0.0, 1.0));
|
|
colour += vec3(0.04, 0.06, 0.07) * pow(clamp(swell, 0.0, 1.0), 3.0);
|
|
colour += vec3(0.06, 0.08, 0.09) * pow(ripple, 8.0);
|
|
return colour;
|
|
}
|
|
void main() {
|
|
if (uMode < 0.5) {
|
|
vec2 uv = vWorld / uTextureRepeat;
|
|
vec3 grass = texture2D(uGrass, uv).rgb;
|
|
vec3 sand = texture2D(uSand, uv).rgb;
|
|
vec4 atlas = texture2D(uAtlas, vUV);
|
|
vec3 base = grass * vColour.r + sand * vColour.g + atlas.rgb * vColour.b;
|
|
vec4 forest = texture2D(uForest, uv);
|
|
vec4 hills = texture2D(uHills, uv);
|
|
vec4 mountain = texture2D(uMountain, uv);
|
|
base = mix(base, forest.rgb, forest.a * vOverlay.x);
|
|
base = mix(base, hills.rgb, hills.a * vOverlay.y);
|
|
base = mix(base, mountain.rgb, mountain.a * vOverlay.z);
|
|
// Coastal sand fades the sand texture into non-mountain shores.
|
|
base = mix(base, sand, vOverlay.w);
|
|
// Sea tiles are replaced by the animated water; the flag is 0 elsewhere.
|
|
base = mix(base, waterColour(uv, uTime), vColour.a);
|
|
// A light from the upper left rakes over the hills and mountains. The term
|
|
// is one over flat ground, so it only touches the relief overlays.
|
|
vec3 light = normalize(vec3(-0.5, -0.6, 0.8));
|
|
float hillsLight = max(dot(reliefNormal(texture2D(uHillsNormal, uv)), light), 0.0) / light.z;
|
|
float mountainLight = max(dot(reliefNormal(texture2D(uMountainNormal, uv)), light), 0.0) / light.z;
|
|
float relief = 1.0 + (hillsLight - 1.0) * vOverlay.y
|
|
+ (mountainLight - 1.0) * vOverlay.z;
|
|
base *= clamp(relief, 0.55, 1.6);
|
|
// Tiling land is opaque; the legacy atlas keeps its hexagonal alpha so its
|
|
// transparent corners are not painted black.
|
|
float alpha = mix(1.0, atlas.a, vColour.b);
|
|
gl_FragColor = vec4(base, alpha);
|
|
} else {
|
|
gl_FragColor = vColour;
|
|
}
|
|
}
|
|
`;
|
|
|
|
// A clip-space quad with a matching 0..1 UV, used by the fog post-process. It
|
|
// carries no camera transform, so it fills whatever framebuffer is bound.
|
|
const FS_VERTEX_SHADER = `
|
|
attribute vec2 aPos;
|
|
attribute vec2 aUV;
|
|
varying vec2 vUV;
|
|
void main() {
|
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
vUV = aUV;
|
|
}
|
|
`;
|
|
|
|
// Nine-tap separable Gaussian (uMode 0) and a plain copy (uMode 1) for the
|
|
// final composite. `uTexel` is one off-screen texel and `uDirection` picks the
|
|
// axis, so the same shader runs both blur passes.
|
|
const FS_FRAGMENT_SHADER = `
|
|
precision mediump float;
|
|
uniform sampler2D uTexture;
|
|
uniform vec2 uTexel;
|
|
uniform vec2 uDirection;
|
|
uniform float uMode;
|
|
varying vec2 vUV;
|
|
void main() {
|
|
if (uMode > 0.5) {
|
|
gl_FragColor = texture2D(uTexture, vUV);
|
|
return;
|
|
}
|
|
vec2 step = uTexel * uDirection;
|
|
vec4 sum = texture2D(uTexture, vUV) * 0.2270270270;
|
|
sum += texture2D(uTexture, vUV + step) * 0.1945945946;
|
|
sum += texture2D(uTexture, vUV - step) * 0.1945945946;
|
|
sum += texture2D(uTexture, vUV + step * 2.0) * 0.1216216216;
|
|
sum += texture2D(uTexture, vUV - step * 2.0) * 0.1216216216;
|
|
sum += texture2D(uTexture, vUV + step * 3.0) * 0.0540540541;
|
|
sum += texture2D(uTexture, vUV - step * 3.0) * 0.0540540541;
|
|
sum += texture2D(uTexture, vUV + step * 4.0) * 0.0162162162;
|
|
sum += texture2D(uTexture, vUV - step * 4.0) * 0.0162162162;
|
|
gl_FragColor = sum;
|
|
}
|
|
`;
|
|
|
|
// How far the fog blur reaches, in off-screen texels. The buffer is half
|
|
// resolution, so a step of 3 is six screen pixels per tap and the kernel
|
|
// spreads over about twenty-four.
|
|
const FOG_BLUR_STEP = 3.0;
|
|
|
|
// Builds a renderer on the given canvas, or null when no WebGL context can be
|
|
// created. The availability check is done without touching canvas.getContext in
|
|
// environments (jsdom) that would throw and log.
|
|
export function createGLMapRenderer(canvas) {
|
|
if (typeof window === "undefined" || typeof document === "undefined") return null;
|
|
if (!window.WebGL2RenderingContext && !window.WebGLRenderingContext) return null;
|
|
try {
|
|
return new GLMapRenderer(canvas);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class GLMapRenderer {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.gl = null;
|
|
this.isWebGL2 = false;
|
|
this.lost = false;
|
|
this.version = 0;
|
|
this._sig = null;
|
|
this.view = null;
|
|
// One GL texture per source: the legacy atlas plus the three land textures.
|
|
this.textures = {};
|
|
this.texReady = false;
|
|
// One pass per layer: { buffer, count } and, for terrain, its atlas cell.
|
|
this.terrain = { buffer: null, count: 0 };
|
|
this.roads = { buffer: null, count: 0 };
|
|
this.borders = { buffer: null, count: 0 };
|
|
this.fog = { buffer: null, count: 0 };
|
|
// The selected/inspected tile's region: a soft fill plus dotted borders.
|
|
this.regionOverlay = { buffer: null, count: 0 };
|
|
// The fog is rendered off-screen and blurred before it is composited, so
|
|
// its frontier is a soft gradient rather than the hex-tiling edges.
|
|
this.fullscreenProgram = null;
|
|
this.fullscreenQuad = null;
|
|
this.fogBlur = null;
|
|
|
|
this._initContext();
|
|
this._initProgram();
|
|
try {
|
|
this._initFullscreenProgram();
|
|
} catch {
|
|
// Without the blur program the fog is still drawn, just without the
|
|
// post-process, so a picky driver does not cost us the whole map.
|
|
this.fullscreenProgram = null;
|
|
this.fullscreenQuad = null;
|
|
}
|
|
this._initTexture();
|
|
this._listenForContextLoss();
|
|
}
|
|
|
|
_initContext() {
|
|
const attributes = {
|
|
alpha: true,
|
|
antialias: false,
|
|
depth: false,
|
|
stencil: false,
|
|
premultipliedAlpha: false,
|
|
preserveDrawingBuffer: false,
|
|
powerPreference: "high-performance",
|
|
failIfMajorPerformanceCaveat: false,
|
|
};
|
|
const gl =
|
|
this.canvas.getContext("webgl2", attributes) ||
|
|
this.canvas.getContext("webgl", attributes) ||
|
|
this.canvas.getContext("experimental-webgl", attributes);
|
|
if (!gl) throw new Error("no WebGL context");
|
|
this.gl = gl;
|
|
this.isWebGL2 = typeof window !== "undefined" &&
|
|
!!window.WebGL2RenderingContext &&
|
|
gl instanceof window.WebGL2RenderingContext;
|
|
}
|
|
|
|
// Human-readable backing renderer, used by the benchmark and by callers that
|
|
// want to know whether they are on a GPU or a software rasteriser.
|
|
info() {
|
|
const gl = this.gl;
|
|
if (!gl) return { renderer: "none", vendor: "none" };
|
|
const debug = gl.getExtension("WEBGL_debug_renderer_info");
|
|
return {
|
|
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
|
|
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
|
|
webgl2: this.isWebGL2,
|
|
};
|
|
}
|
|
|
|
// Whether the context is backed by a CPU rasteriser (SwiftShader, llvmpipe,
|
|
// ...). Those show up on machines with no usable GPU and can be slower than
|
|
// the DOM renderer, so "auto" mode prefers the DOM path for them.
|
|
softwareRenderer() {
|
|
const renderer = String(this.info().renderer || "").toLowerCase();
|
|
return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|basic render/.test(renderer);
|
|
}
|
|
|
|
dispose() {
|
|
const gl = this.gl;
|
|
if (!gl) return;
|
|
for (const pass of [this.terrain, this.roads, this.borders, this.fog, this.regionOverlay]) {
|
|
if (pass.buffer) gl.deleteBuffer(pass.buffer);
|
|
pass.buffer = null;
|
|
pass.count = 0;
|
|
}
|
|
for (const texture of Object.values(this.textures)) gl.deleteTexture(texture);
|
|
this.textures = {};
|
|
if (this.program) gl.deleteProgram(this.program);
|
|
if (this.fullscreenProgram) gl.deleteProgram(this.fullscreenProgram);
|
|
if (this.fullscreenQuad) gl.deleteBuffer(this.fullscreenQuad);
|
|
this._disposeFogBlur();
|
|
const lose = gl.getExtension("WEBGL_lose_context");
|
|
if (lose) lose.loseContext();
|
|
this.gl = null;
|
|
this.lost = true;
|
|
}
|
|
|
|
_initProgram() {
|
|
const gl = this.gl;
|
|
const vertex = this._compile(gl.VERTEX_SHADER, VERTEX_SHADER);
|
|
const fragment = this._compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
|
const program = gl.createProgram();
|
|
gl.attachShader(program, vertex);
|
|
gl.attachShader(program, fragment);
|
|
gl.bindAttribLocation(program, ATTR_POS, "aPos");
|
|
gl.bindAttribLocation(program, ATTR_UV, "aUV");
|
|
gl.bindAttribLocation(program, ATTR_COLOUR, "aColour");
|
|
gl.bindAttribLocation(program, ATTR_WORLD, "aWorld");
|
|
gl.bindAttribLocation(program, ATTR_OVERLAY, "aOverlay");
|
|
gl.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.uCamera = gl.getUniformLocation(program, "uCamera");
|
|
this.uViewport = gl.getUniformLocation(program, "uViewport");
|
|
this.uZoom = gl.getUniformLocation(program, "uZoom");
|
|
this.uTilt = gl.getUniformLocation(program, "uTilt");
|
|
this.uShiftX = gl.getUniformLocation(program, "uShiftX");
|
|
this.uAtlas = gl.getUniformLocation(program, "uAtlas");
|
|
this.uGrass = gl.getUniformLocation(program, "uGrass");
|
|
this.uSand = gl.getUniformLocation(program, "uSand");
|
|
this.uForest = gl.getUniformLocation(program, "uForest");
|
|
this.uHills = gl.getUniformLocation(program, "uHills");
|
|
this.uMountain = gl.getUniformLocation(program, "uMountain");
|
|
this.uHillsNormal = gl.getUniformLocation(program, "uHillsNormal");
|
|
this.uMountainNormal = gl.getUniformLocation(program, "uMountainNormal");
|
|
this.uTextureRepeat = gl.getUniformLocation(program, "uTextureRepeat");
|
|
this.uTime = gl.getUniformLocation(program, "uTime");
|
|
this.uMode = gl.getUniformLocation(program, "uMode");
|
|
}
|
|
|
|
_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.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenQuad);
|
|
gl.enableVertexAttribArray(this.fsPos);
|
|
gl.vertexAttribPointer(this.fsPos, 2, gl.FLOAT, false, 16, 0);
|
|
gl.enableVertexAttribArray(this.fsUV);
|
|
gl.vertexAttribPointer(this.fsUV, 2, gl.FLOAT, false, 16, 8);
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.uniform1i(this.fsTexture, 0);
|
|
gl.uniform2f(this.fsTexel, texelX, texelY);
|
|
gl.uniform2f(this.fsDirection, dirX, dirY);
|
|
gl.uniform1f(this.fsMode, mode);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
}
|
|
|
|
// One off-screen colour target for the fog blur. Returns null if the
|
|
// framebuffer is not complete, so the renderer can fall back to drawing the
|
|
// fog straight to the canvas.
|
|
_createFogTarget(width, height) {
|
|
const gl = this.gl;
|
|
const texture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
const fbo = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
if (!complete) {
|
|
gl.deleteFramebuffer(fbo);
|
|
gl.deleteTexture(texture);
|
|
return null;
|
|
}
|
|
return { fbo, texture, width, height };
|
|
}
|
|
|
|
_disposeFogBlur() {
|
|
const gl = this.gl;
|
|
if (!gl || !this.fogBlur) return;
|
|
for (const target of [this.fogBlur.a, this.fogBlur.b]) {
|
|
if (target.fbo) gl.deleteFramebuffer(target.fbo);
|
|
if (target.texture) gl.deleteTexture(target.texture);
|
|
}
|
|
this.fogBlur = null;
|
|
}
|
|
|
|
// Two half-resolution targets, recreated when the viewport resizes.
|
|
_ensureFogBlur(width, height) {
|
|
const w = Math.max(1, Math.floor(width / 2));
|
|
const h = Math.max(1, Math.floor(height / 2));
|
|
if (this.fogBlur && this.fogBlur.width === w && this.fogBlur.height === h) {
|
|
return this.fogBlur;
|
|
}
|
|
this._disposeFogBlur();
|
|
if (!this.fullscreenProgram) return null;
|
|
try {
|
|
const a = this._createFogTarget(w, h);
|
|
const b = this._createFogTarget(w, h);
|
|
if (!a || !b) {
|
|
if (a) {
|
|
this.gl.deleteFramebuffer(a.fbo);
|
|
this.gl.deleteTexture(a.texture);
|
|
}
|
|
if (b) {
|
|
this.gl.deleteFramebuffer(b.fbo);
|
|
this.gl.deleteTexture(b.texture);
|
|
}
|
|
return null;
|
|
}
|
|
this.fogBlur = { a, b, width: w, height: h };
|
|
} catch {
|
|
this._disposeFogBlur();
|
|
return null;
|
|
}
|
|
return this.fogBlur;
|
|
}
|
|
|
|
// Loads the four sources the terrain shader samples. The atlas is clamped
|
|
// (its cells must not bleed into one another); the three land textures repeat,
|
|
// so the seam between two tiles continues the same pattern.
|
|
_initTexture() {
|
|
this.textures = {};
|
|
this.texReady = false;
|
|
let pending = 0;
|
|
const loaded = () => {
|
|
pending -= 1;
|
|
if (pending > 0 || this.lost) return;
|
|
this.texReady = true;
|
|
this.version += 1;
|
|
this._sig = null;
|
|
};
|
|
const load = (name, src, repeat) => {
|
|
const texture = this._createTexture(repeat);
|
|
this.textures[name] = texture;
|
|
pending += 1;
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
if (this.lost) return;
|
|
this._uploadImage(texture, image);
|
|
loaded();
|
|
};
|
|
image.src = src;
|
|
};
|
|
load("atlas", "assets/terrain.png", false);
|
|
load(TEXTURE_GRASS, `${TEXTURE_DIR}${TEXTURE_GRASS}.png`, true);
|
|
load(TEXTURE_SAND, `${TEXTURE_DIR}${TEXTURE_SAND}.png`, true);
|
|
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
|
|
load(TEXTURE_HILLS, `${TEXTURE_DIR}${TEXTURE_HILLS}.png`, true);
|
|
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
|
|
load(TEXTURE_HILLS_NORMAL, `${TEXTURE_DIR}${TEXTURE_HILLS_NORMAL}.png`, true);
|
|
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.png`, true);
|
|
}
|
|
|
|
_createTexture(repeat) {
|
|
const gl = this.gl;
|
|
const texture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
// A 1x1 transparent texel until the image arrives.
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
|
new Uint8Array([0, 0, 0, 0]));
|
|
const wrap = repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE;
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
return texture;
|
|
}
|
|
|
|
_uploadImage(texture, image) {
|
|
const gl = this.gl;
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
}
|
|
|
|
_listenForContextLoss() {
|
|
this.canvas.addEventListener("webglcontextlost", (event) => {
|
|
event.preventDefault();
|
|
this.lost = true;
|
|
});
|
|
this.canvas.addEventListener("webglcontextrestored", () => {
|
|
try {
|
|
this.fogBlur = null;
|
|
this._initProgram();
|
|
try {
|
|
this._initFullscreenProgram();
|
|
} catch {
|
|
this.fullscreenProgram = null;
|
|
this.fullscreenQuad = null;
|
|
}
|
|
this._initTexture();
|
|
this.lost = false;
|
|
if (this.view) this.setWorld(this.view);
|
|
} catch {
|
|
this.lost = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ geometry --
|
|
|
|
_push(arr, x, y, u, v, colour) {
|
|
arr.push(x, y, u, v, 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, plus the base
|
|
// (grass, sand, atlas), the per-overlay weights, the water flag and the
|
|
// coastal beach weight.
|
|
_pushTerrain(arr, x, y, u, v, wx, wy, weights) {
|
|
arr.push(
|
|
x, y, u, v, wx, wy,
|
|
weights[0], weights[1], weights[2], weights[6],
|
|
weights[3], weights[4], weights[5], weights[7]
|
|
);
|
|
}
|
|
|
|
// Fog has no texture, so it uses a compact position/colour-only vertex. That
|
|
// keeps the buffer (re-uploaded whenever visibility changes) small.
|
|
_pushSimple(arr, x, y, colour) {
|
|
arr.push(x, y, colour[0], colour[1], colour[2], colour[3]);
|
|
}
|
|
|
|
// Adds a convex quad given its four vertices (with per-vertex UVs) as two
|
|
// triangles. Wind order does not matter: we never cull faces.
|
|
_quad(arr, corners, colour) {
|
|
this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
|
|
this._push(arr, corners[1].x, corners[1].y, corners[1].u || 0, corners[1].v || 0, colour);
|
|
this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
|
|
this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
|
|
this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
|
|
this._push(arr, corners[3].x, corners[3].y, corners[3].u || 0, corners[3].v || 0, colour);
|
|
}
|
|
|
|
// A rotated, optionally inward-nudged rectangle from a to b.
|
|
_segment(arr, a, b, width, colour, inward = 0) {
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
const nx = -dy / length;
|
|
const ny = dx / length;
|
|
const half = width / 2;
|
|
const ox = nx * inward;
|
|
const oy = ny * inward;
|
|
const ax = a.x + ox;
|
|
const ay = a.y + oy;
|
|
const bx = b.x + ox;
|
|
const by = b.y + oy;
|
|
this._quad(arr, [
|
|
{ x: ax + nx * half, y: ay + ny * half },
|
|
{ x: bx + nx * half, y: by + ny * half },
|
|
{ x: bx - nx * half, y: by - ny * half },
|
|
{ x: ax - nx * half, y: ay - ny * half },
|
|
], colour);
|
|
}
|
|
|
|
_upload(pass, arr, usage, floats = 8) {
|
|
const gl = this.gl;
|
|
if (!pass.buffer) pass.buffer = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arr), usage || gl.STATIC_DRAW);
|
|
pass.count = arr.length / floats;
|
|
pass.floats = floats;
|
|
// Only terrain and fog are row-banded; everything else draws whole.
|
|
pass.rowStart = null;
|
|
pass.originY = 0;
|
|
pass.rows = 0;
|
|
this.version += 1;
|
|
this._sig = null;
|
|
}
|
|
|
|
// -------------------------------------------------------------- layers --
|
|
|
|
// Builds every layer from the current view state. Called when a new world is
|
|
// generated and again after a context loss, since the view owns all the data
|
|
// the renderer needs.
|
|
setWorld(view) {
|
|
this.view = view;
|
|
this.buildTerrain(view);
|
|
this.buildRoads(view);
|
|
this.buildBorders(view);
|
|
this.buildKnowledge(view);
|
|
this.buildRegionOverlay(view);
|
|
}
|
|
|
|
buildTerrain(view) {
|
|
const arr = [];
|
|
const tiles = view.tiles || {};
|
|
const topo = view.topology;
|
|
const seed = view.seed | 0;
|
|
const period = periodUnits(topo);
|
|
const segments = EDGE_SEGMENTS;
|
|
const boundaryLength = CORNERS.length * segments;
|
|
// Half-texel inset so linear filtering cannot sample the neighbouring atlas
|
|
// cell along the hexagon's border.
|
|
const uPad = 0.5 / HEX_W;
|
|
const vPad = 0.5 / HEX_H;
|
|
// Built row by row so the renderer can draw only the tile rows on screen.
|
|
// `rowStart[i]` is the first vertex of tile row i; the last entry is the
|
|
// total, which makes a visible range one draw call.
|
|
const rowStart = [];
|
|
const originX = topo.originX;
|
|
const originY = topo.originY;
|
|
for (let y = originY; y < originY + topo.height; y++) {
|
|
rowStart.push(arr.length / TERRAIN_FLOATS);
|
|
for (let x = originX; x < originX + topo.width; x++) {
|
|
const tile = tiles[key(x, y)];
|
|
if (!tile) continue;
|
|
const centre = mapToLocal(x, y);
|
|
const column = tile.col | 0;
|
|
const cu = (column + 0.5) / ATLAS_COLS;
|
|
const cv = 0.5;
|
|
// Land textures are blended from the tile and its neighbours; the ring
|
|
// is only needed when this tile is one of the textured lands.
|
|
const ring = isTexturedLand(tile) ? this._textureRing(view, x, y, centre) : null;
|
|
// A fan over the wavy boundary: the six points (and the extra border
|
|
// points) can all be moved independently, which a quad could not do.
|
|
const boundary = distortedBoundary(seed, centre, period, segments);
|
|
// The weights are a pure function of the undistorted world position, so
|
|
// the two tiles that share a boundary point compute the same value.
|
|
const colours = new Array(boundaryLength);
|
|
for (let i = 0; i < boundaryLength; i++) {
|
|
colours[i] = this._textureWeights(tile, ring, boundary[i].idealX, boundary[i].idealY);
|
|
}
|
|
const centreColour = this._textureWeights(tile, ring, centre.x, centre.y);
|
|
for (let i = 0; i < CORNERS.length; i++) {
|
|
for (let j = 0; j < segments; j++) {
|
|
const a = boundary[i * segments + j];
|
|
const b = boundary[(i * segments + j + 1) % boundaryLength];
|
|
const ua = this._cellUV(column, a.idealX - centre.x, a.idealY - centre.y, uPad, vPad);
|
|
const ub = this._cellUV(column, b.idealX - centre.x, b.idealY - centre.y, uPad, vPad);
|
|
this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y, centreColour);
|
|
this._pushTerrain(arr, a.x, a.y, ua.u, ua.v, a.idealX, a.idealY, colours[i * segments + j]);
|
|
this._pushTerrain(arr, b.x, b.y, ub.u, ub.v, b.idealX, b.idealY, colours[(i * segments + j + 1) % boundaryLength]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
rowStart.push(arr.length / TERRAIN_FLOATS);
|
|
this._upload(this.terrain, arr, undefined, TERRAIN_FLOATS);
|
|
this.terrain.rowStart = rowStart;
|
|
this.terrain.originY = originY;
|
|
this.terrain.rows = topo.height;
|
|
}
|
|
|
|
// The tile itself plus its six neighbours, each with its wrapped pixel
|
|
// centre, used to blend the tiling textures across tile boundaries.
|
|
_textureRing(view, x, y, centre) {
|
|
const topo = view.topology;
|
|
const tiles = view.tiles;
|
|
const coords = { x, y };
|
|
const ring = [{ tile: tiles[key(x, y)], x: centre.x, y: centre.y }];
|
|
for (const neighbour of topo.neighbours(x, y)) {
|
|
const delta = topo.pixelDelta(coords, neighbour);
|
|
ring.push({
|
|
tile: tiles[key(neighbour.x, neighbour.y)],
|
|
x: centre.x + delta.x,
|
|
y: centre.y + delta.y,
|
|
});
|
|
}
|
|
return ring;
|
|
}
|
|
|
|
// The shader weights at one undistorted world point, from the nearby tiles.
|
|
_textureWeights(tile, ring, worldX, worldY) {
|
|
if (!ring) return vertexTextureWeights(tile, []);
|
|
const samples = ring.map((sample) => ({
|
|
tile: sample.tile,
|
|
dx: sample.x - worldX,
|
|
dy: sample.y - worldY,
|
|
}));
|
|
return vertexTextureWeights(tile, samples);
|
|
}
|
|
|
|
// The fog level of the tile itself plus its six neighbours, each with its
|
|
// wrapped pixel centre, used to blur the visibility frontier.
|
|
_fogRing(topo, x, y, centre, levelAt) {
|
|
const coords = { x, y };
|
|
const ring = [{ level: levelAt(x, y), x: centre.x, y: centre.y }];
|
|
for (const neighbour of topo.neighbours(x, y)) {
|
|
const delta = topo.pixelDelta(coords, neighbour);
|
|
ring.push({
|
|
level: levelAt(neighbour.x, neighbour.y),
|
|
x: centre.x + delta.x,
|
|
y: centre.y + delta.y,
|
|
});
|
|
}
|
|
return ring;
|
|
}
|
|
|
|
// UV of a point at local offset (dx, dy) from the tile centre, inside the
|
|
// tile's atlas cell and inset by half a texel.
|
|
_cellUV(column, dx, dy, uPad, vPad) {
|
|
let uc = (dx + HEX_W / 2) / HEX_W;
|
|
let vc = (dy + HEX_H / 2) / HEX_H;
|
|
uc = Math.min(Math.max(uc, uPad), 1 - uPad);
|
|
vc = Math.min(Math.max(vc, vPad), 1 - vPad);
|
|
return { u: (column + uc) / ATLAS_COLS, v: vc };
|
|
}
|
|
|
|
// Draws the road network as mitered strokes. Each pair of adjacent road tiles
|
|
// is drawn once as a single polyline from centre to centre, crossing the
|
|
// shared wavy border at its midpoint; the bend there and the gentle in-tile
|
|
// drifts are joined with the same miter as the borders, so no piece overlaps
|
|
// and no notch is left at a bend. A small cap at every tile centre covers the
|
|
// junction where several roads meet.
|
|
buildRoads(view) {
|
|
const arr = [];
|
|
const roads = view.roads;
|
|
if (!roads || roads.size === 0 || !view.topology) {
|
|
this._upload(this.roads, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const topo = view.topology;
|
|
const period = periodUnits(topo);
|
|
const steps = 3;
|
|
const drawn = new Set();
|
|
for (const k of roads) {
|
|
const coords = parseKey(k);
|
|
const centre = mapToLocal(coords.x, coords.y);
|
|
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (!roads.has(nk)) continue;
|
|
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
|
|
if (drawn.has(pair)) continue;
|
|
drawn.add(pair);
|
|
const delta = topo.pixelDelta(coords, neighbour);
|
|
const target = { x: centre.x + delta.x, y: centre.y + delta.y };
|
|
// Cross the border exactly where it is now: the midpoint of the shared
|
|
// wavy edge, not the ideal straight boundary.
|
|
const [idealA, idealB] = nearestCorners(centre, target);
|
|
const mid = edgePoint(seed, idealA, idealB, 0.5, period);
|
|
// Half the road drifts in from each tile centre towards that midpoint.
|
|
const points = [centre];
|
|
for (let j = 1; j <= steps; j++) {
|
|
points.push(bentPath(seed, centre, mid, 0x5a17, j / steps));
|
|
}
|
|
for (let j = steps - 1; j >= 1; j--) {
|
|
points.push(bentPath(seed, target, mid, 0x5a17, j / steps));
|
|
}
|
|
points.push(target);
|
|
this._roadBand(arr, points, 5, ROAD);
|
|
}
|
|
}
|
|
for (const k of roads) {
|
|
const coords = parseKey(k);
|
|
this._roadJoint(arr, mapToLocal(coords.x, coords.y));
|
|
}
|
|
this._upload(this.roads, arr);
|
|
}
|
|
|
|
// A centered band along a polyline. Both edges are mitered (and consecutive
|
|
// quads share them), so a bend is filled rather than left as a wedge.
|
|
_roadBand(arr, points, width, colour) {
|
|
const n = points.length;
|
|
if (n < 2) return;
|
|
const half = width / 2;
|
|
const normals = [];
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const a = points[i];
|
|
const b = points[i + 1];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
normals.push({ x: -dy / length, y: dx / length });
|
|
}
|
|
const offsets = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const before = normals[i === 0 ? 0 : i - 1];
|
|
const after = normals[i === n - 1 ? n - 2 : i];
|
|
let denom = 1 + before.x * after.x + before.y * after.y;
|
|
if (denom < 0.2) denom = 0.2;
|
|
const scale = half / denom;
|
|
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
|
|
}
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const a = points[i];
|
|
const b = points[i + 1];
|
|
const oa = offsets[i];
|
|
const ob = offsets[i + 1];
|
|
this._quad(arr, [
|
|
{ x: a.x + oa.x, y: a.y + oa.y },
|
|
{ x: b.x + ob.x, y: b.y + ob.y },
|
|
{ x: b.x - ob.x, y: b.y - ob.y },
|
|
{ x: a.x - oa.x, y: a.y - oa.y },
|
|
], colour);
|
|
}
|
|
}
|
|
|
|
// A small square that hides the notch where several roads meet at a centre.
|
|
_roadJoint(arr, point) {
|
|
const r = 2.5;
|
|
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 },
|
|
], ROAD);
|
|
}
|
|
|
|
// Draws each nation's coastline/border as one continuous stroked outline.
|
|
//
|
|
// Every border edge is inherited from its owning tile and oriented so the
|
|
// owner lies on the left of the directed edge. Edges are then stitched across
|
|
// tile boundaries by their shared lattice corners, so a corner where two
|
|
// *different* tiles of the same nation meet is joined with a single miter
|
|
// instead of two independent (and divergent) butt caps.
|
|
buildBorders(view) {
|
|
const arr = [];
|
|
const territory = view.territory;
|
|
if (!territory || territory.size === 0 || !view.topology || !view.civilisations) {
|
|
this._upload(this.borders, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const period = periodUnits(view.topology);
|
|
const 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);
|
|
// Orient the edge so the owner sits on its left; the stroke then always
|
|
// offsets towards the owner and neighbouring edges agree on a miter.
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const mx = (a.x + b.x) / 2;
|
|
const my = (a.y + b.y) / 2;
|
|
const ownerIsLeft = (centre.x - mx) * -dy + (centre.y - my) * dx >= 0;
|
|
let run = points;
|
|
let start = a;
|
|
let end = b;
|
|
if (!ownerIsLeft) {
|
|
run = points.slice().reverse();
|
|
start = b;
|
|
end = a;
|
|
}
|
|
if (!edgesByOwner.has(owner)) edgesByOwner.set(owner, []);
|
|
edgesByOwner.get(owner).push({
|
|
points: run,
|
|
startKey: latticeKey(start.x, start.y),
|
|
endKey: latticeKey(end.x, end.y),
|
|
});
|
|
}
|
|
}
|
|
for (const [owner, edges] of edgesByOwner) {
|
|
const rgb = parseColour(view.civilisations[owner].primaryColour);
|
|
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
|
|
const soft = [colour[0], colour[1], colour[2], 0.18];
|
|
for (const chain of this._chainEdges(edges)) {
|
|
this._stroke(arr, chain.points, glow, soft, chain.closed);
|
|
this._stroke(arr, chain.points, NATIONAL_BORDER, colour, chain.closed);
|
|
}
|
|
}
|
|
this._upload(this.borders, arr);
|
|
}
|
|
|
|
// Stitches directed edges into runs by matching each edge's end corner to the
|
|
// next edge's start corner. Because a nation's boundary is a set of closed
|
|
// loops (plus open runs cut off at the map edge), every vertex pairs exactly
|
|
// one incoming and one outgoing edge, so the follow is unambiguous.
|
|
_chainEdges(edges) {
|
|
const byStart = new Map();
|
|
for (const edge of edges) byStart.set(edge.startKey, edge);
|
|
const hasIncoming = new Set(edges.map((edge) => edge.endKey));
|
|
const used = new Set();
|
|
const chains = [];
|
|
const follow = (first) => {
|
|
const points = [];
|
|
let closed = false;
|
|
let edge = first;
|
|
while (edge && !used.has(edge)) {
|
|
used.add(edge);
|
|
for (let i = edge === first ? 0 : 1; i < edge.points.length; i++) {
|
|
points.push(edge.points[i]);
|
|
}
|
|
const next = byStart.get(edge.endKey);
|
|
if (next === first) {
|
|
closed = true;
|
|
if (points.length > 1) points.pop();
|
|
break;
|
|
}
|
|
edge = next;
|
|
}
|
|
return { points, closed };
|
|
};
|
|
// Open runs first (their start has no incoming edge), then any closed loop.
|
|
for (const edge of edges) {
|
|
if (used.has(edge) || hasIncoming.has(edge.startKey)) continue;
|
|
chains.push(follow(edge));
|
|
}
|
|
for (const edge of edges) {
|
|
if (used.has(edge)) continue;
|
|
chains.push(follow(edge));
|
|
}
|
|
return chains;
|
|
}
|
|
|
|
// The neighbour tile across each of the hexagon's six edges, in CORNERS order
|
|
// (edge i runs from corner i to corner i+1). Matching by direction keeps this
|
|
// correct for both column parities and for the wrapped seam.
|
|
_edgeNeighbours(view, x, y) {
|
|
const topo = view.topology;
|
|
const coords = { x, y };
|
|
const neighbours = topo.neighbours(x, y);
|
|
const result = [];
|
|
for (let i = 0; i < CORNERS.length; i++) {
|
|
const a = CORNERS[i];
|
|
const b = CORNERS[(i + 1) % CORNERS.length];
|
|
const mx = (a.x + b.x) / 2;
|
|
const my = (a.y + b.y) / 2;
|
|
const length = Math.hypot(mx, my) || 1;
|
|
let best = null;
|
|
let bestDot = -Infinity;
|
|
for (const neighbour of neighbours) {
|
|
const delta = topo.pixelDelta(coords, neighbour);
|
|
const distance = Math.hypot(delta.x, delta.y) || 1;
|
|
const dot = (mx * delta.x + my * delta.y) / (length * distance);
|
|
if (dot > bestDot) {
|
|
bestDot = dot;
|
|
best = neighbour;
|
|
}
|
|
}
|
|
result.push(best);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The jagged polyline (EDGE_SEGMENTS + 1 points) of one hexagon edge.
|
|
_edgePolyline(seed, centre, edge, period) {
|
|
const a = { x: centre.x + CORNERS[edge].x, y: centre.y + CORNERS[edge].y };
|
|
const next = CORNERS[(edge + 1) % CORNERS.length];
|
|
const b = { x: centre.x + next.x, y: centre.y + next.y };
|
|
const points = [];
|
|
for (let j = 0; j <= EDGE_SEGMENTS; j++) {
|
|
points.push(edgePoint(seed, a, b, j / EDGE_SEGMENTS, period));
|
|
}
|
|
return points;
|
|
}
|
|
|
|
// Draws a stroke of the given width along a polyline, flush with it on the
|
|
// boundary side and offset to the left of the direction of travel. Callers
|
|
// orient their points so the owner lies on the left, which keeps the offset
|
|
// towards the owner. The offset at each vertex is the miter (the intersection
|
|
// of the two adjacent offset edges), and consecutive quads share their
|
|
// offset edge, so corners are filled without the pieces overlapping.
|
|
_stroke(arr, points, width, colour, closed) {
|
|
const n = points.length;
|
|
if (n < 2) return;
|
|
const count = closed ? n : n - 1;
|
|
const segments = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const a = points[i];
|
|
const b = points[(i + 1) % n];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
segments.push({ x: -dy / length, y: dx / length });
|
|
}
|
|
const offsets = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const before = closed ? segments[(i - 1 + count) % count] : segments[i === 0 ? 0 : i - 1];
|
|
const after = closed ? segments[i % count] : segments[i === n - 1 ? n - 2 : i];
|
|
// Miter offset: (n1 + n2) * width / (1 + n1 . n2). The clamp keeps a
|
|
// near-doubling-back corner from shooting out a huge spike.
|
|
let denom = 1 + before.x * after.x + before.y * after.y;
|
|
if (denom < 0.2) denom = 0.2;
|
|
const scale = width / denom;
|
|
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
|
|
}
|
|
for (let i = 0; i < count; i++) {
|
|
const a = points[i];
|
|
const b = points[(i + 1) % n];
|
|
const oa = offsets[i];
|
|
const ob = offsets[(i + 1) % n];
|
|
this._quad(arr, [
|
|
a,
|
|
b,
|
|
{ x: b.x + ob.x, y: b.y + ob.y },
|
|
{ x: a.x + oa.x, y: a.y + oa.y },
|
|
], colour);
|
|
}
|
|
}
|
|
|
|
// Fog is no longer a flat colour per tile: every vertex takes the blurred
|
|
// average of the fog levels around it, so the frontier ramps over about a
|
|
// tile instead of stepping at the hex edge. Visible tiles that border fog are
|
|
// drawn too, which is what lets the ramp fade into the clear side.
|
|
buildKnowledge(view) {
|
|
const arr = [];
|
|
const tiles = view.tiles || {};
|
|
const topo = view.topology;
|
|
const seed = view.seed | 0;
|
|
const period = periodUnits(topo);
|
|
const explored = view.explored || new Set();
|
|
const visible = view.visible || new Set();
|
|
const segments = EDGE_SEGMENTS;
|
|
const rowStart = [];
|
|
const originX = topo.originX;
|
|
const originY = topo.originY;
|
|
const levels = new Map();
|
|
const levelAt = (x, y) => {
|
|
const k = key(x, y);
|
|
if (!tiles[k]) return null;
|
|
let level = levels.get(k);
|
|
if (!level) {
|
|
level = fogLevel(visible.has(k), explored.has(k));
|
|
levels.set(k, level);
|
|
}
|
|
return level;
|
|
};
|
|
for (let y = originY; y < originY + topo.height; y++) {
|
|
rowStart.push(arr.length / 6);
|
|
for (let x = originX; x < originX + topo.width; x++) {
|
|
if (!tiles[key(x, y)]) continue;
|
|
const centre = mapToLocal(x, y);
|
|
// The tile and its neighbours; a fully clear tile with clear
|
|
// surroundings needs no geometry at all.
|
|
const ring = this._fogRing(topo, x, y, centre, levelAt);
|
|
if (!ring.some((sample) => isFogged(sample.level))) continue;
|
|
const fogAt = (px, py) => {
|
|
const samples = [];
|
|
for (const sample of ring) {
|
|
samples.push({ level: sample.level, dx: sample.x - px, dy: sample.y - py });
|
|
}
|
|
return blurFog(samples);
|
|
};
|
|
// Fog must cover exactly the wavy terrain hexagon underneath it.
|
|
const boundary = distortedBoundary(seed, centre, period, segments);
|
|
const vertices = new Array(boundary.length);
|
|
for (let i = 0; i < boundary.length; i++) {
|
|
vertices[i] = fogAt(boundary[i].x, boundary[i].y);
|
|
}
|
|
const centreFog = fogAt(centre.x, centre.y);
|
|
const push = (point, fog) => {
|
|
this._pushSimple(arr, point.x, point.y, [fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]);
|
|
};
|
|
for (let i = 0; i < boundary.length; i++) {
|
|
const a = boundary[i];
|
|
const b = boundary[(i + 1) % boundary.length];
|
|
push(centre, centreFog);
|
|
push(a, vertices[i]);
|
|
push(b, vertices[(i + 1) % boundary.length]);
|
|
}
|
|
}
|
|
}
|
|
rowStart.push(arr.length / 6);
|
|
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, 6);
|
|
this.fog.rowStart = rowStart;
|
|
this.fog.originY = originY;
|
|
this.fog.rows = topo.height;
|
|
}
|
|
|
|
// The region of the selected/inspected tile: a soft translucent fill over
|
|
// every tile in it, plus a thin dotted border along its edge. Rebuilt on
|
|
// selection and when the region membership changes; empty when nothing is
|
|
// selected.
|
|
buildRegionOverlay(view) {
|
|
const arr = [];
|
|
const region = view._selectedRegion;
|
|
const regions = view.regions;
|
|
if (region === null || region === undefined || !regions || regions.size === 0 ||
|
|
!view.topology) {
|
|
this._upload(this.regionOverlay, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const topo = view.topology;
|
|
const period = periodUnits(topo);
|
|
const segments = EDGE_SEGMENTS;
|
|
const boundaryLength = CORNERS.length * segments;
|
|
const territory = view.territory;
|
|
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);
|
|
for (let i = 0; i < boundaryLength; i++) {
|
|
const a = boundary[i];
|
|
const b = boundary[(i + 1) % boundaryLength];
|
|
this._push(arr, centre.x, centre.y, 0, 0, REGION_FILL);
|
|
this._push(arr, a.x, a.y, 0, 0, REGION_FILL);
|
|
this._push(arr, b.x, b.y, 0, 0, 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);
|
|
this._segment(arr, p0, p1, REGION_BORDER, REGION_BORDER_COLOUR);
|
|
}
|
|
}
|
|
}
|
|
this._upload(this.regionOverlay, arr, this.gl.DYNAMIC_DRAW);
|
|
}
|
|
|
|
// -------------------------------------------------------------- render --
|
|
|
|
// The horizontal periods whose copy of the world intersects the view. On a
|
|
// flat map this is just [0]; on a cylindrical one it is usually one shift,
|
|
// and two only while the seam is on screen. Drawing fewer copies avoids
|
|
// re-rasterising the whole map three times when zoomed out.
|
|
_neededShifts(view, width) {
|
|
const period = view._period || 0;
|
|
if (!period) return [0];
|
|
const zoom = view.camera.zoom || 1;
|
|
const halfWidth = width / (2 * zoom);
|
|
const visibleMin = view.camera.x - halfWidth;
|
|
const visibleMax = view.camera.x + halfWidth;
|
|
const worldMin = view._worldMinX;
|
|
const worldMax = view._worldMinX + view._worldWidth;
|
|
const first = Math.ceil((visibleMin - worldMax) / period);
|
|
const last = Math.floor((visibleMax - worldMin) / period);
|
|
const shifts = [];
|
|
for (let n = first; n <= last && shifts.length < 4; n++) shifts.push(n);
|
|
return shifts.length ? shifts : [0];
|
|
}
|
|
|
|
// The tile-row range the camera can see, as indices into a row-banded buffer.
|
|
// `mapToLocal` shifts a row by up to half a hex for odd columns, so the range
|
|
// carries a one-row margin on each side.
|
|
_visibleRows(view, width, height) {
|
|
const topo = view.topology;
|
|
if (!topo) return null;
|
|
const zoom = view.camera.zoom || 1;
|
|
// The tilt compresses the ground, so a taller slice of world Y is on screen.
|
|
const halfHeight = height / (2 * zoom * CAMERA_TILT);
|
|
const top = view.camera.y - halfHeight;
|
|
const bottom = view.camera.y + halfHeight;
|
|
let start = Math.floor(top / HEX_H) - 1 - topo.originY;
|
|
let end = Math.ceil(bottom / HEX_H) + 1 - topo.originY;
|
|
start = Math.max(0, Math.min(topo.height - 1, start));
|
|
end = Math.max(0, Math.min(topo.height - 1, end));
|
|
return { start, end };
|
|
}
|
|
|
|
_drawPass(pass, mode, shifts, period, rows) {
|
|
const gl = this.gl;
|
|
if (!pass || !pass.buffer || pass.count === 0) return;
|
|
gl.uniform1f(this.uMode, mode);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, pass.buffer);
|
|
const floats = pass.floats || COLOUR_FLOATS;
|
|
const stride = floats * 4;
|
|
gl.enableVertexAttribArray(this.aPos);
|
|
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, stride, 0);
|
|
if (floats === TERRAIN_FLOATS) {
|
|
// Terrain carries the atlas UV, the world position and the blend weights.
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
|
gl.uniform1i(this.uAtlas, UNIT_ATLAS);
|
|
gl.activeTexture(gl.TEXTURE1);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.grass);
|
|
gl.uniform1i(this.uGrass, UNIT_GRASS);
|
|
gl.activeTexture(gl.TEXTURE2);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.sand);
|
|
gl.uniform1i(this.uSand, UNIT_SAND);
|
|
gl.activeTexture(gl.TEXTURE3);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.forest);
|
|
gl.uniform1i(this.uForest, UNIT_FOREST);
|
|
gl.activeTexture(gl.TEXTURE4);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.hills);
|
|
gl.uniform1i(this.uHills, UNIT_HILLS);
|
|
gl.activeTexture(gl.TEXTURE5);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountain);
|
|
gl.uniform1i(this.uMountain, UNIT_MOUNTAIN);
|
|
gl.activeTexture(gl.TEXTURE6);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.hills_normal_map);
|
|
gl.uniform1i(this.uHillsNormal, UNIT_HILLS_NORMAL);
|
|
gl.activeTexture(gl.TEXTURE7);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountains_normal_map);
|
|
gl.uniform1i(this.uMountainNormal, UNIT_MOUNTAIN_NORMAL);
|
|
gl.enableVertexAttribArray(this.aUV);
|
|
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
|
gl.enableVertexAttribArray(this.aWorld);
|
|
gl.vertexAttribPointer(this.aWorld, 2, gl.FLOAT, false, stride, 16);
|
|
gl.enableVertexAttribArray(this.aColour);
|
|
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 24);
|
|
gl.enableVertexAttribArray(this.aOverlay);
|
|
gl.vertexAttribPointer(this.aOverlay, 4, gl.FLOAT, false, stride, 40);
|
|
} else if (floats === FOG_FLOATS) {
|
|
// Fog passes only carry position 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.enableVertexAttribArray(this.aColour);
|
|
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 8);
|
|
} else {
|
|
gl.enableVertexAttribArray(this.aUV);
|
|
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
|
gl.disableVertexAttribArray(this.aWorld);
|
|
gl.disableVertexAttribArray(this.aOverlay);
|
|
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;
|
|
// 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.uTextureRepeat, textureRepeat(view._period || 0));
|
|
gl.uniform1f(this.uTime, now);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
gl.disable(gl.DEPTH_TEST);
|
|
|
|
const period = view._period || 0;
|
|
const shifts = this._neededShifts(view, width);
|
|
const rows = this._visibleRows(view, width, height);
|
|
if (this.texReady) this._drawPass(this.terrain, 0, shifts, period, rows);
|
|
this._drawPass(this.roads, 1, shifts, period);
|
|
this._drawPass(this.borders, 1, shifts, period);
|
|
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
|
this._drawPass(this.regionOverlay, 1, shifts, period);
|
|
}
|
|
|
|
// Draws the fog into an off-screen, half-resolution target, blurs it in two
|
|
// separable passes and composites it back. Doing it as a post-process smooths
|
|
// the frontier into a soft gradient regardless of the tile-shaped geometry.
|
|
// If the framebuffer cannot be built, it falls back to the direct pass.
|
|
_drawFog(bufferWidth, bufferHeight, shifts, period, rows) {
|
|
const gl = this.gl;
|
|
const blur = this._ensureFogBlur(bufferWidth, bufferHeight);
|
|
if (!blur) {
|
|
this._drawPass(this.fog, 1, shifts, period, rows);
|
|
return;
|
|
}
|
|
// Draw with straight alpha and no blending, so the stored alpha is exact.
|
|
gl.disable(gl.BLEND);
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.a.fbo);
|
|
gl.viewport(0, 0, blur.width, blur.height);
|
|
gl.clearColor(0, 0, 0, 0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
gl.useProgram(this.program);
|
|
this._drawPass(this.fog, 1, shifts, period, rows);
|
|
|
|
const texelX = FOG_BLUR_STEP / blur.width;
|
|
const texelY = FOG_BLUR_STEP / blur.height;
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.b.fbo);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
this._drawFullscreen(blur.a.texture, texelX, texelY, 1, 0, 0);
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, blur.a.fbo);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
this._drawFullscreen(blur.b.texture, texelX, texelY, 0, 1, 0);
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.viewport(0, 0, bufferWidth, bufferHeight);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
this._drawFullscreen(blur.a.texture, 1 / blur.width, 1 / blur.height, 0, 0, 1);
|
|
gl.useProgram(this.program);
|
|
}
|
|
}
|