2054 lines
85 KiB
JavaScript
2054 lines
85 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,
|
|
bentPath,
|
|
distortedBoundary,
|
|
edgePoint,
|
|
latticeKey,
|
|
periodUnits,
|
|
vertexKey,
|
|
} from "./distortion.js";
|
|
import {
|
|
TEXTURE_DIR,
|
|
TEXTURE_FOREST,
|
|
TEXTURE_GRASS,
|
|
TEXTURE_MOUNTAIN,
|
|
TEXTURE_MOUNTAIN_NORMAL,
|
|
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;
|
|
|
|
// A flat sample, used when a view has no relief field (tests, stub views).
|
|
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
|
|
|
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
|
// 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.
|
|
float rock = vOverlay.y * smoothstep(uRockLow, uRockHigh, vHeight);
|
|
base = mix(base, forest.rgb, forest.a * vOverlay.x);
|
|
base = mix(base, mountain.rgb, mountain.a * rock);
|
|
// 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 {
|
|
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 (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;
|
|
// 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.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;
|
|
|
|
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.ocean, this.roads, 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;
|
|
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_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
|
|
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.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);
|
|
}
|
|
|
|
_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] = 0;
|
|
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 road network as mitered strokes. Each pair of adjacent road tiles
|
|
// is drawn once as a single polyline from centre to centre, crossing the
|
|
// shared wavy border at its midpoint; the bend there and the gentle in-tile
|
|
// drifts are joined with the same miter as the borders, so no piece overlaps
|
|
// and no notch is left at a bend. A small cap at every tile centre covers the
|
|
// junction where several roads meet.
|
|
buildRoads(view) {
|
|
const arr = [];
|
|
const roads = view.roads;
|
|
if (!roads || roads.size === 0 || !view.topology) {
|
|
this._upload(this.roads, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const topo = view.topology;
|
|
const period = periodUnits(topo);
|
|
const field = this._heightField(view);
|
|
const lift = (point) => {
|
|
if (field) point.h = field.at(point.x, point.y);
|
|
return point;
|
|
};
|
|
const steps = 3;
|
|
const drawn = new Set();
|
|
for (const k of roads) {
|
|
const coords = parseKey(k);
|
|
const centre = lift(mapToLocal(coords.x, coords.y));
|
|
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (!roads.has(nk)) continue;
|
|
const pair = k < nk ? `${k}|${nk}` : `${nk}|${k}`;
|
|
if (drawn.has(pair)) continue;
|
|
drawn.add(pair);
|
|
const delta = topo.pixelDelta(coords, neighbour);
|
|
const target = lift({ x: centre.x + delta.x, y: centre.y + delta.y });
|
|
// Cross the border exactly where it is now: the midpoint of the shared
|
|
// wavy edge, not the ideal straight boundary.
|
|
const [idealA, idealB] = nearestCorners(centre, target);
|
|
const mid = lift(edgePoint(seed, idealA, idealB, 0.5, period));
|
|
// Half the road drifts in from each tile centre towards that midpoint.
|
|
const points = [centre];
|
|
for (let j = 1; j <= steps; j++) {
|
|
points.push(lift(bentPath(seed, centre, mid, 0x5a17, j / steps)));
|
|
}
|
|
for (let j = steps - 1; j >= 1; j--) {
|
|
points.push(lift(bentPath(seed, target, mid, 0x5a17, j / steps)));
|
|
}
|
|
points.push(target);
|
|
this._roadBand(arr, points, 5, ROAD);
|
|
}
|
|
}
|
|
for (const k of roads) {
|
|
const coords = parseKey(k);
|
|
this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y)));
|
|
}
|
|
this._upload(this.roads, arr);
|
|
}
|
|
|
|
// A centered band along a polyline. Both edges are mitered (and consecutive
|
|
// quads share them), so a bend is filled rather than left as a wedge.
|
|
_roadBand(arr, points, width, colour) {
|
|
const n = points.length;
|
|
if (n < 2) return;
|
|
const half = width / 2;
|
|
const normals = [];
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const a = points[i];
|
|
const b = points[i + 1];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
normals.push({ x: -dy / length, y: dx / length });
|
|
}
|
|
const offsets = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const before = normals[i === 0 ? 0 : i - 1];
|
|
const after = normals[i === n - 1 ? n - 2 : i];
|
|
let denom = 1 + before.x * after.x + before.y * after.y;
|
|
if (denom < 0.2) denom = 0.2;
|
|
const scale = half / denom;
|
|
offsets.push({ x: (before.x + after.x) * scale, y: (before.y + after.y) * scale });
|
|
}
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const a = points[i];
|
|
const b = points[i + 1];
|
|
const oa = offsets[i];
|
|
const ob = offsets[i + 1];
|
|
this._quad(arr, [
|
|
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
|
|
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
|
|
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h },
|
|
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h },
|
|
], colour);
|
|
}
|
|
}
|
|
|
|
// A small square that hides the notch where several roads meet at a centre.
|
|
_roadJoint(arr, point) {
|
|
const r = 2.5;
|
|
const h = point.h;
|
|
this._quad(arr, [
|
|
{ x: point.x - r, y: point.y - r, h },
|
|
{ x: point.x - r, y: point.y + r, h },
|
|
{ x: point.x + r, y: point.y + r, h },
|
|
{ x: point.x + r, y: point.y - r, h },
|
|
], ROAD);
|
|
}
|
|
|
|
// Draws each nation's coastline/border as one continuous stroked outline.
|
|
//
|
|
// Every border edge is inherited from its owning tile and oriented so the
|
|
// owner lies on the left of the directed edge. Edges are then stitched across
|
|
// tile boundaries by their shared lattice corners, so a corner where two
|
|
// *different* tiles of the same nation meet is joined with a single miter
|
|
// instead of two independent (and divergent) butt caps.
|
|
buildBorders(view) {
|
|
const arr = [];
|
|
const territory = view.territory;
|
|
if (!territory || territory.size === 0 || !view.topology || !view.civilisations) {
|
|
this._upload(this.borders, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const period = periodUnits(view.topology);
|
|
// 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.
|
|
buildKnowledge(view) {
|
|
const arr = [];
|
|
const tiles = view.tiles || {};
|
|
const topo = view.topology;
|
|
const seed = view.seed | 0;
|
|
const period = periodUnits(topo);
|
|
const explored = view.explored || new Set();
|
|
const visible = view.visible || new Set();
|
|
const field = this._ignoresRelief(view) ? null : this._heightField(view);
|
|
const segments = EDGE_SEGMENTS;
|
|
const rowStart = [];
|
|
const originX = topo.originX;
|
|
const originY = topo.originY;
|
|
const levels = new Map();
|
|
const levelAt = (x, y) => {
|
|
const k = key(x, y);
|
|
if (!tiles[k]) return null;
|
|
let level = levels.get(k);
|
|
if (!level) {
|
|
level = fogLevel(visible.has(k), explored.has(k));
|
|
levels.set(k, level);
|
|
}
|
|
return level;
|
|
};
|
|
for (let y = originY; y < originY + topo.height; y++) {
|
|
rowStart.push(arr.length / FOG_FLOATS);
|
|
for (let x = originX; x < originX + topo.width; x++) {
|
|
if (!tiles[key(x, y)]) continue;
|
|
const centre = mapToLocal(x, y);
|
|
// The tile and its neighbours; a fully clear tile with clear
|
|
// surroundings needs no geometry at all.
|
|
const ring = this._fogRing(topo, x, y, centre, levelAt);
|
|
if (!ring.some((sample) => isFogged(sample.level))) continue;
|
|
const fogAt = (px, py) => {
|
|
const samples = [];
|
|
for (const sample of ring) {
|
|
samples.push({ level: sample.level, dx: sample.x - px, dy: sample.y - py });
|
|
}
|
|
return blurFog(samples);
|
|
};
|
|
// Fog must cover exactly the 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);
|
|
}
|
|
const centreFog = fogAt(centre.x, centre.y);
|
|
// Fog sheets over the same raised hexagon the terrain draws, so it has
|
|
// to read the relief from the same ring.
|
|
const heightRing = field ? field.ring(x, y) : null;
|
|
const heightAt = (px, py) =>
|
|
(field && heightRing ? field.blend(heightRing, px, py) : 0);
|
|
const push = (point, fog) => {
|
|
this._pushSimple(arr, point.x, point.y, heightAt(point.x, point.y),
|
|
[fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]);
|
|
};
|
|
for (let i = 0; i < boundary.length; i++) {
|
|
const a = boundary[i];
|
|
const b = boundary[(i + 1) % boundary.length];
|
|
push(centre, centreFog);
|
|
push(a, vertices[i]);
|
|
push(b, vertices[(i + 1) % boundary.length]);
|
|
}
|
|
}
|
|
}
|
|
rowStart.push(arr.length / FOG_FLOATS);
|
|
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, FOG_FLOATS);
|
|
this.fog.rowStart = rowStart;
|
|
this.fog.originY = originY;
|
|
this.fog.rows = topo.height;
|
|
}
|
|
|
|
// The region of the selected/inspected tile: a soft translucent fill over
|
|
// every tile in it, plus a thin dotted border along its edge. Rebuilt on
|
|
// selection and when the region membership changes; empty when nothing is
|
|
// selected.
|
|
buildRegionOverlay(view) {
|
|
const arr = [];
|
|
const region = view._selectedRegion;
|
|
const regions = view.regions;
|
|
if (region === null || region === undefined || !regions || regions.size === 0 ||
|
|
!view.topology) {
|
|
this._upload(this.regionOverlay, arr);
|
|
return;
|
|
}
|
|
const seed = view.seed | 0;
|
|
const topo = view.topology;
|
|
const period = periodUnits(topo);
|
|
const segments = EDGE_SEGMENTS;
|
|
const boundaryLength = CORNERS.length * segments;
|
|
const territory = view.territory;
|
|
const field = this._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 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));
|
|
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 {
|
|
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 would only clutter the flat colours; borders stay.
|
|
if (!political && !economic) 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);
|
|
}
|
|
}
|