diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 2bb0573..74fb07b 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -1,5 +1,8 @@ // Tuning constants for the world map view. +import { HEX_H } from "../../../shared/hex.js"; +import { EDGE_SEGMENTS } from "./distortion.js"; + export const RESYNC_HOURS = 0.75; export const PREDICTION_GRACE_HOURS = 2.0; export const DRAG_THRESHOLD = 5; @@ -14,6 +17,42 @@ export const ZOOM_MAX = 2.0; export const CAMERA_ELEVATION_DEG = 45; export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180); +// How far the tallest terrain (normalised height 1, see map_view/heightmap.js) +// is lifted off the ground plane, in world pixels. The projection turns a lift +// into a northward screen shift of `lift * cos(elevation)`, while the ground +// distance between a tile and its northern neighbour is +// `HEX_H * sin(elevation) = HEX_H * CAMERA_TILT`. A tile already reaches half a +// hex north of its centre, so keeping the lift under half that spacing (here at +// 45%) means even a height-1 ridge stops short of the next tile's centre: no +// tile north of a peak is eclipsed, and the painter's-order layers below need +// no depth buffer. +export const HEIGHT_LIFT = HEX_H * CAMERA_TILT * 1; // set by a human + +// The normalised-height band where a mountain's cap texture (rock/snow) fades +// in: below `low` the slopes stay grass, above `high` the cap is at full +// strength and in between it blends. Tune these to move the snow line. +export const MOUNTAIN_ROCK_LOW = 0.3; +export const MOUNTAIN_ROCK_HIGH = 0.8; + +// Levels of detail for the terrain mesh, from the closest zoom to the furthest. +// `segments` is the wavy-edge subdivision (1 draws straight hexes) and `relief` +// scales the normal maps' surface detail so distant terrain does not shimmer +// (the height-gradient lighting stays on at every level). The coarsest level's +// `minZoom` is -Infinity so every zoom maps to a level. +export const TERRAIN_LODS = [ + { minZoom: 0.6, segments: EDGE_SEGMENTS, relief: 1.0 }, + { minZoom: 0.3, segments: 2, relief: 0.55 }, + { minZoom: -Infinity, segments: 1, relief: 0.0 }, +]; + +// The level of detail that serves a camera zoom. +export function terrainLod(zoom) { + for (let i = 0; i < TERRAIN_LODS.length; i++) { + if (zoom >= TERRAIN_LODS[i].minZoom) return i; + } + return TERRAIN_LODS.length - 1; +} + // A battle spreads the two stacks to either side of the tile centre, with the // battle icon between them. Side offset is in pixels; step is the spacing // between the (half-size) units of one stack. diff --git a/client/js/map_view/heightmap.js b/client/js/map_view/heightmap.js new file mode 100644 index 0000000..9e7fd1e --- /dev/null +++ b/client/js/map_view/heightmap.js @@ -0,0 +1,233 @@ +// Deterministic relief for the world map. +// +// The simulation stays perfectly flat; this module only computes the cosmetic +// height every ground vertex is lifted by in the WebGL renderer. It is a pure +// function of (seed, mapConfig, terrain), so every client sees the same relief +// and a reload does not change the shape of the hills. +// +// The field has two parts. A fractal noise term, evaluated on a cylinder so it +// wraps seamlessly on a cylindrical map, gives a smooth rolling base; a +// per-terrain term raises mountains and hills above the plains and keeps the +// sea at zero. The terrain part is blended between neighbouring tiles with the +// same distance kernel the textures use, so a vertex shared by two tiles (and +// every border or road point that crosses between them) computes exactly the +// same height and the mesh stays watertight. +// +// Heights are normalised to [0, 1]. `constants.js` turns that into a screen +// lift small enough that a ridge can never rise high enough to eclipse the tile +// north of it. + +import { SimplexNoise } from "../../../shared/noise.js"; +import { HEX_H, COL_STEP, key, mapToLocal, parity } from "../../../shared/hex.js"; + +const TAU = Math.PI * 2; + +// Mixed into the world seed so the relief field is independent of the terrain +// generator's own noise. +const HEIGHT_SALT = 0x68e31da4; + +// Fractal detail of the rolling base. Frequencies are in tile units. +export const HEIGHT_NOISE = { + frequency: 0.045, + octaves: 3, + lacunarity: 2.0, + gain: 0.5, +}; + +// Blend radius of the terrain kernel, matching the texture blend so the two +// fields share the same seams. A neighbour's centre is about one edge away, so +// a shared edge lands at an even mix. +export const HEIGHT_BLEND_RADIUS = HEX_H; + +// Floor and relief per terrain type, as fractions of the maximum height. The +// floor is the height at the low end of the noise and the relief how much the +// noise adds on top, so Mountain ranges from 0.5 to 1.0 and the sea stays flat. +const TERRAIN_HEIGHT = { + Mountain: { floor: 0.5, relief: 0.5 }, + Hills: { floor: 0.22, relief: 0.38 }, + Forest: { floor: 0.08, relief: 0.16 }, + Land: { floor: 0.04, relief: 0.12 }, + Desert: { floor: 0.02, relief: 0.08 }, + Ice: { floor: 0.0, relief: 0.04 }, + Sea: { floor: 0.0, relief: 0.0 }, +}; + +// A smooth (squared) falloff that reaches zero at the blend radius. +function falloff(distance) { + if (distance >= HEIGHT_BLEND_RADIUS) return 0; + const t = 1 - distance / HEIGHT_BLEND_RADIUS; + return t * t; +} + +export class HeightField { + constructor(view) { + this.seed = ((view && view.seed) | 0) >>> 0; + this.topology = (view && view.topology) || null; + this.tiles = (view && view.tiles) || {}; + const config = view && view.mapConfig; + this.noiseConfig = (config && config.heightNoise) || HEIGHT_NOISE; + this._noise = new SimplexNoise(this.seed ^ HEIGHT_SALT); + this._cylindrical = !!(this.topology && this.topology.cylindrical && + this.topology.width > 0); + this._width = this.topology ? this.topology.width : 0; + this._originX = this.topology ? this.topology.originX : 0; + this._cache = new Map(); + } + + // The normalised height of one tile: its terrain floor plus the relief scaled + // by the wrapped noise field. Cached, because a vertex blend reads the same + // handful of tiles over and over. + tileHeight(x, y) { + const wrappedX = this._cylindrical ? this.topology.wrapX(x) : x; + const k = key(wrappedX, y); + const cached = this._cache.get(k); + if (cached !== undefined) return cached; + const tile = this.tiles[k]; + const profile = tile && TERRAIN_HEIGHT[tile.terrainType]; + const height = profile ? profile.floor + profile.relief * this._noise01(wrappedX, y) : 0; + this._cache.set(k, height); + return height; + } + + // The wrapped fractal field at a tile, remapped from [-1, 1] to [0, 1]. + _noise01(x, y) { + let value; + if (this._cylindrical) { + // Sample the noise on a cylinder whose circumference is the map width, so + // the seam joins itself and the relief does not jump across the wrap. + const angle = (TAU * (x - this._originX)) / this._width; + const radius = this._width / TAU; + value = this._noise.fbm( + Math.cos(angle) * radius, + Math.sin(angle) * radius, + y, + this.noiseConfig + ); + } else { + value = this._noise.fbm(x, y, undefined, this.noiseConfig); + } + return Math.min(1, Math.max(0, value * 0.5 + 0.5)); + } + + // The tile a world point falls in, by nearest centre. Direct distances are + // fine here: callers pass unwrapped world coordinates, and `tileHeight` + // folds the wrapped axis itself. + nearest(x, y) { + const column = Math.round(x / COL_STEP); + const baseY = Math.round(y / HEX_H - 0.5 * parity(column)); + let best = { x: column, y: baseY }; + let bestDistance = Infinity; + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const cx = column + dx; + const cy = baseY + dy; + const local = mapToLocal(cx, cy); + const distance = (local.x - x) ** 2 + (local.y - y) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = { x: cx, y: cy }; + } + } + } + return best; + } + + // A tile and its six (wrapped) neighbours, each with its pixel centre and + // height. The set is a pure function of the tile, so the two tiles sharing an + // edge build the same field around it. + ring(x, y) { + const centre = mapToLocal(x, y); + const samples = [{ h: this.tileHeight(x, y), x: centre.x, y: centre.y }]; + if (!this.topology) return samples; + const coords = { x, y }; + for (const neighbour of this.topology.neighbours(x, y)) { + const delta = this.topology.pixelDelta(coords, neighbour); + samples.push({ + h: this.tileHeight(neighbour.x, neighbour.y), + x: centre.x + delta.x, + y: centre.y + delta.y, + }); + } + return samples; + } + + // Blends a ring's tile heights at a world point by distance. Beyond the blend + // radius a tile does not contribute, so the extra tiles in one of two + // overlapping rings vanish and the point gets the same value from either. + blend(ring, x, y) { + let sum = 0; + let total = 0; + for (const sample of ring) { + const weight = falloff(Math.hypot(sample.x - x, sample.y - y)); + if (weight <= 0) continue; + sum += sample.h * weight; + total += weight; + } + if (total > 0) return sum / total; + // The containing tile is always within the radius, so this only guards an + // empty ring; fall back to the nearest sample. + let best = 0; + let bestDistance = Infinity; + for (const sample of ring) { + const distance = Math.hypot(sample.x - x, sample.y - y); + if (distance < bestDistance) { + bestDistance = distance; + best = sample.h; + } + } + return best; + } + + // The blended height and its gradient at a world point, in normalised height + // per world pixel. The gradient is the analytic derivative of the same + // distance-weighted average `blend` returns, so terrain lighting can build a + // per-vertex normal without sampling the field again. Both tiles of a shared + // vertex read the same ring at the same point and so agree on the slope. + sample(ring, x, y) { + let sum = 0; + let total = 0; + let sumX = 0; + let sumY = 0; + let totalX = 0; + let totalY = 0; + for (const tile of ring) { + const dx = tile.x - x; + const dy = tile.y - y; + const distance = Math.hypot(dx, dy); + if (distance >= HEIGHT_BLEND_RADIUS) continue; + const t = 1 - distance / HEIGHT_BLEND_RADIUS; + const weight = t * t; + // Derivative of the squared falloff with respect to distance, times the + // unit direction from the sample to the point (the gradient of the + // distance rises towards the point). + const slope = (-2 * t) / HEIGHT_BLEND_RADIUS; + const inv = distance > 0 ? 1 / distance : 0; + const weightX = slope * -dx * inv; + const weightY = slope * -dy * inv; + sum += tile.h * weight; + total += weight; + sumX += tile.h * weightX; + sumY += tile.h * weightY; + totalX += weightX; + totalY += weightY; + } + if (total <= 0) return { h: this.blend(ring, x, y), dx: 0, dy: 0 }; + const inv = 1 / total; + const h = sum * inv; + return { h, dx: (sumX - h * totalX) * inv, dy: (sumY - h * totalY) * inv }; + } + + // The height at an arbitrary world point, for points whose owning tile is not + // already known (border and road vertices). + at(x, y) { + if (!this.topology) return 0; + const coords = this.nearest(x, y); + return this.blend(this.ring(coords.x, coords.y), x, y); + } +} + +// Builds the field for a view, or null when the view carries no world. +export function createHeightField(view) { + if (!view || !view.topology || !view.tiles) return null; + return new HeightField(view); +} diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index 9cb224b..dbd40ab 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -39,22 +39,39 @@ import { vertexTextureWeights, } from "./textures.js"; import { blurFog, fogLevel, isFogged } from "./fog.js"; -import { CAMERA_TILT } from "./constants.js"; +import { + CAMERA_TILT, + HEIGHT_LIFT, + MOUNTAIN_ROCK_LOW, + MOUNTAIN_ROCK_HIGH, + TERRAIN_LODS, + terrainLod, +} from "./constants.js"; +import { createHeightField } from "./heightmap.js"; -// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, 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. +// 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. -const TERRAIN_FLOATS = 14; +// 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 = 6; +const FOG_FLOATS = 7; + +// A flat sample, used when a view has no relief field (tests, stub views). +const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 }; const ROAD = [138 / 255, 90 / 255, 43 / 255, 1]; // The terrain atlas is a single row of equal cells, one per terrain type. @@ -82,26 +99,38 @@ 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. + // 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); - vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt) * uZoom + uViewport * 0.5; + 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; } `; @@ -122,10 +151,15 @@ uniform sampler2D uMountainNormal; uniform float uTextureRepeat; uniform float uTime; uniform float uMode; +uniform float uRelief; +uniform float uRockLow; +uniform float uRockHigh; varying vec2 vUV; varying vec4 vColour; varying vec2 vWorld; varying vec4 vOverlay; +varying vec2 vSlope; +varying float vHeight; // The relief normal is rebuilt from the red/green gradient and a fixed up axis, // so the maps' blue channel (which is not a clean 255 at rest) cannot bias the // lighting. A flat texel is (0, 0, 1) and lights to exactly 1. @@ -164,21 +198,29 @@ void main() { vec4 forest = texture2D(uForest, uv); vec4 hills = texture2D(uHills, uv); vec4 mountain = texture2D(uMountain, uv); + // Mountain rock only caps the higher ground: the lower slopes reveal the + // grass base, so the texture follows the relief instead of filling the + // whole mountain tile. The terrain-type weight still decides where the + // range is at all. + float rock = vOverlay.z * smoothstep(uRockLow, uRockHigh, vHeight); base = mix(base, forest.rgb, forest.a * vOverlay.x); base = mix(base, hills.rgb, hills.a * vOverlay.y); - base = mix(base, mountain.rgb, mountain.a * vOverlay.z); + base = mix(base, mountain.rgb, mountain.a * rock); // Coastal sand fades the sand texture into non-mountain shores. base = mix(base, sand, vOverlay.w); // Sea tiles are replaced by the animated water; the flag is 0 elsewhere. base = mix(base, waterColour(uv, uTime), vColour.a); - // A light from the upper left rakes over the hills and mountains. The term - // is one over flat ground, so it only touches the relief overlays. + // A light from the upper left rakes over the terrain. The geometry normal + // is built from the height gradient carried per vertex; the hill and + // mountain normal maps perturb it with their surface detail. Dividing the + // dot product by the light's up component makes flat ground light to + // exactly 1, so only slopes and relief change. vec3 light = normalize(vec3(-0.5, -0.6, 0.8)); - 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); + vec2 detail = (reliefNormal(texture2D(uHillsNormal, uv)).xy * vOverlay.y + + reliefNormal(texture2D(uMountainNormal, uv)).xy * rock) * uRelief; + vec3 normal = normalize(vec3(-vSlope, 1.0) + vec3(detail * 0.5, 0.0)); + float shade = max(dot(normal, light), 0.0) / light.z; + base *= clamp(shade, 0.55, 1.6); // Tiling land is opaque; the legacy atlas keeps its hexagonal alpha so its // transparent corners are not painted black. float alpha = mix(1.0, atlas.a, vColour.b); @@ -262,6 +304,12 @@ export class GLMapRenderer { this.texReady = false; // One pass per layer: { buffer, count } and, for terrain, its atlas cell. this.terrain = { buffer: null, count: 0 }; + // Terrain meshes for the levels of detail, built lazily as the camera zooms + // out; level 0 is built with the world. + this.terrainLevels = []; + // The deterministic relief field for the current world. + this.heightField = null; + this._heightView = null; this.roads = { buffer: null, count: 0 }; this.borders = { buffer: null, count: 0 }; this.fog = { buffer: null, count: 0 }; @@ -333,11 +381,16 @@ export class GLMapRenderer { 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); + for (const pass of [ + this.terrain, this.roads, this.borders, this.fog, this.regionOverlay, + ...this.terrainLevels, + ]) { + if (!pass || !pass.buffer) continue; + gl.deleteBuffer(pass.buffer); pass.buffer = null; pass.count = 0; } + this.terrainLevels = []; for (const texture of Object.values(this.textures)) gl.deleteTexture(texture); this.textures = {}; if (this.program) gl.deleteProgram(this.program); @@ -362,6 +415,8 @@ export class GLMapRenderer { 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)}`); @@ -374,6 +429,8 @@ export class GLMapRenderer { 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"); @@ -390,6 +447,10 @@ export class GLMapRenderer { this.uTextureRepeat = gl.getUniformLocation(program, "uTextureRepeat"); this.uTime = gl.getUniformLocation(program, "uTime"); this.uMode = gl.getUniformLocation(program, "uMode"); + this.uHeightScale = gl.getUniformLocation(program, "uHeightScale"); + this.uRelief = gl.getUniformLocation(program, "uRelief"); + this.uRockLow = gl.getUniformLocation(program, "uRockLow"); + this.uRockHigh = gl.getUniformLocation(program, "uRockHigh"); } _compile(type, source) { @@ -448,6 +509,8 @@ export class GLMapRenderer { 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); @@ -612,37 +675,42 @@ export class GLMapRenderer { // ------------------------------------------------------------ geometry -- - _push(arr, x, y, u, v, colour) { - arr.push(x, y, u, v, colour[0], colour[1], colour[2], colour[3]); + // 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, plus the base + // shader turns into a continuous tiling-texture coordinate, the normalised + // height that lifts the vertex and its gradient (for lighting), plus the base // (grass, sand, atlas), the per-overlay weights, the water flag and the // coastal beach weight. - _pushTerrain(arr, x, y, u, v, wx, wy, weights) { + _pushTerrain(arr, x, y, u, v, wx, wy, height, slopeX, slopeY, weights) { arr.push( - x, y, u, v, wx, wy, + x, y, u, v, wx, wy, height, weights[0], weights[1], weights[2], weights[6], - weights[3], weights[4], weights[5], weights[7] + weights[3], weights[4], weights[5], weights[7], + slopeX, slopeY ); } - // Fog has no texture, so it uses a compact position/colour-only vertex. That + // 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, colour) { - arr.push(x, y, colour[0], colour[1], colour[2], colour[3]); + _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 UVs) as two + // 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].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); + 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. @@ -660,10 +728,10 @@ export class GLMapRenderer { 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 }, + { 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); } @@ -689,20 +757,46 @@ export class GLMapRenderer { // the renderer needs. setWorld(view) { this.view = view; - this.buildTerrain(view); + this.heightField = createHeightField(view); + this._heightView = view; + this.terrainLevels = []; + this.terrain = this._buildTerrainPass(view, 0); + this.terrainLevels[0] = this.terrain; this.buildRoads(view); this.buildBorders(view); this.buildKnowledge(view); this.buildRegionOverlay(view); } + // The relief field for a view, rebuilt when a builder invoked directly (as the + // tests do) passes a world `setWorld` never saw. + _heightField(view) { + if (!this.heightField || this._heightView !== view) { + this.heightField = createHeightField(view); + this._heightView = view; + } + return this.heightField; + } + + // Kept for callers (and tests) that build the closest level directly. buildTerrain(view) { + const pass = this._buildTerrainPass(view, 0); + if (!this.terrainLevels) this.terrainLevels = []; + this.terrain = pass; + this.terrainLevels[0] = pass; + } + + // Builds the terrain mesh for one level of detail and uploads it. Level 0 is + // the wavy, fully-subdivided mesh; the coarser levels trade edge detail for + // far-out zooms, where the warp is sub-pixel anyway. + _buildTerrainPass(view, level) { + const segments = (TERRAIN_LODS[level] || TERRAIN_LODS[0]).segments; + const field = this._heightField(view); const arr = []; const tiles = view.tiles || {}; const topo = view.topology; const seed = view.seed | 0; const period = periodUnits(topo); - const 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. @@ -726,34 +820,60 @@ export class GLMapRenderer { // Land textures are blended from the tile and its neighbours; the ring // is only needed when this tile is one of the textured lands. const ring = isTexturedLand(tile) ? this._textureRing(view, x, y, centre) : null; + // The relief is sampled from the same ring of tile heights, so the two + // tiles that share a boundary vertex compute the same lift and slope. + const heightRing = field ? field.ring(x, y) : null; + const sampleAt = heightRing + ? (px, py) => field.sample(heightRing, px, py) + : () => ZERO_SAMPLE; // A fan over the wavy boundary: the six points (and the extra border // points) can all be moved independently, which a quad could not do. const boundary = distortedBoundary(seed, centre, period, segments); // The weights are a pure function of the undistorted world position, so // the two tiles that share a boundary point compute the same value. const colours = new Array(boundaryLength); + const samples = new Array(boundaryLength); for (let i = 0; i < boundaryLength; i++) { colours[i] = this._textureWeights(tile, ring, boundary[i].idealX, boundary[i].idealY); + samples[i] = sampleAt(boundary[i].x, boundary[i].y); } const centreColour = this._textureWeights(tile, ring, centre.x, centre.y); + const centreSample = sampleAt(centre.x, centre.y); for (let i = 0; i < CORNERS.length; i++) { for (let j = 0; j < segments; j++) { - const a = boundary[i * segments + j]; - const b = boundary[(i * segments + j + 1) % boundaryLength]; + const indexA = i * segments + j; + const indexB = (indexA + 1) % boundaryLength; + const a = boundary[indexA]; + const b = boundary[indexB]; const ua = this._cellUV(column, a.idealX - centre.x, a.idealY - centre.y, uPad, vPad); const ub = this._cellUV(column, b.idealX - centre.x, b.idealY - centre.y, uPad, vPad); - this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y, 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]); + this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y, + centreSample.h, centreSample.dx, centreSample.dy, centreColour); + this._pushTerrain(arr, a.x, a.y, ua.u, ua.v, a.idealX, a.idealY, + samples[indexA].h, samples[indexA].dx, samples[indexA].dy, colours[indexA]); + this._pushTerrain(arr, b.x, b.y, ub.u, ub.v, b.idealX, b.idealY, + samples[indexB].h, samples[indexB].dx, samples[indexB].dy, colours[indexB]); } } } } rowStart.push(arr.length / TERRAIN_FLOATS); - this._upload(this.terrain, arr, undefined, TERRAIN_FLOATS); - this.terrain.rowStart = rowStart; - this.terrain.originY = originY; - this.terrain.rows = topo.height; + const pass = { buffer: null, count: 0 }; + this._upload(pass, arr, undefined, TERRAIN_FLOATS); + pass.rowStart = rowStart; + pass.originY = originY; + pass.rows = topo.height; + return pass; + } + + // Builds a coarser terrain mesh on first use, so zooming out does not pay for + // it until the camera actually reaches that level. + _ensureTerrainLevel(view, level) { + if (!this.terrainLevels) this.terrainLevels = []; + if (this.terrainLevels[level]) return this.terrainLevels[level]; + const pass = this._buildTerrainPass(view, level); + this.terrainLevels[level] = pass; + return pass; } // The tile itself plus its six neighbours, each with its wrapped pixel @@ -827,11 +947,16 @@ export class GLMapRenderer { 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 = mapToLocal(coords.x, coords.y); + 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; @@ -839,18 +964,18 @@ export class GLMapRenderer { 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 }; + 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 = edgePoint(seed, idealA, idealB, 0.5, period); + 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(bentPath(seed, centre, mid, 0x5a17, j / steps)); + points.push(lift(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(lift(bentPath(seed, target, mid, 0x5a17, j / steps))); } points.push(target); this._roadBand(arr, points, 5, ROAD); @@ -858,7 +983,7 @@ export class GLMapRenderer { } for (const k of roads) { const coords = parseKey(k); - this._roadJoint(arr, mapToLocal(coords.x, coords.y)); + this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y))); } this._upload(this.roads, arr); } @@ -893,10 +1018,10 @@ export class GLMapRenderer { 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 }, + { 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); } } @@ -904,11 +1029,12 @@ export class GLMapRenderer { // 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 }, - { 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, 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); } @@ -928,6 +1054,7 @@ export class GLMapRenderer { } const seed = view.seed | 0; const period = periodUnits(view.topology); + const field = this._heightField(view); const glow = 6; const edgesByOwner = new Map(); for (const [k, owner] of territory) { @@ -944,6 +1071,9 @@ export class GLMapRenderer { 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; @@ -1100,8 +1230,8 @@ export class GLMapRenderer { 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 }, + { 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); } } @@ -1118,6 +1248,7 @@ export class GLMapRenderer { const period = periodUnits(topo); const explored = view.explored || new Set(); const visible = view.visible || new Set(); + const field = this._heightField(view); const segments = EDGE_SEGMENTS; const rowStart = []; const originX = topo.originX; @@ -1134,7 +1265,7 @@ export class GLMapRenderer { return level; }; for (let y = originY; y < originY + topo.height; y++) { - rowStart.push(arr.length / 6); + 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); @@ -1156,8 +1287,14 @@ export class GLMapRenderer { 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, [fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]); + 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]; @@ -1168,8 +1305,8 @@ export class GLMapRenderer { } } } - rowStart.push(arr.length / 6); - this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, 6); + 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; @@ -1194,17 +1331,22 @@ export class GLMapRenderer { const segments = EDGE_SEGMENTS; const boundaryLength = CORNERS.length * segments; const territory = view.territory; + const field = this._heightField(view); for (const [k, cityId] of regions) { if (cityId !== region) continue; const coords = parseKey(k); const centre = mapToLocal(coords.x, coords.y); const boundary = distortedBoundary(seed, centre, period, segments); + const heightRing = field ? field.ring(coords.x, coords.y) : null; + const heightAt = (px, py) => + (field && heightRing ? field.blend(heightRing, px, py) : 0); + const centreHeight = heightAt(centre.x, centre.y); for (let i = 0; i < boundaryLength; i++) { const a = boundary[i]; const b = boundary[(i + 1) % boundaryLength]; - this._push(arr, centre.x, centre.y, 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); + 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)) { @@ -1219,6 +1361,8 @@ export class GLMapRenderer { 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); } } @@ -1305,23 +1449,34 @@ export class GLMapRenderer { 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, 24); + gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 28); gl.enableVertexAttribArray(this.aOverlay); - gl.vertexAttribPointer(this.aOverlay, 4, gl.FLOAT, false, stride, 40); + 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 and colour; the disabled attributes keep - // their generic defaults, unused in the untextured shader branch. + // 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, 8); + 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); } @@ -1348,6 +1503,13 @@ export class GLMapRenderer { if (this.canvas.height !== bufferHeight) this.canvas.height = bufferHeight; const camera = view.camera; + // Pick the terrain mesh for the camera distance. A coarser level is built + // the first time the camera reaches it, which increments `version`, so this + // runs before the frame signature is taken. + const level = terrainLod(camera.zoom); + const terrainPass = this.texReady && view.topology + ? this._ensureTerrainLevel(view, level) + : this.terrain; // The water animates, so the frame is repainted while the clock advances. // The timestamp is quantised to keep the redraw rate near 24 fps instead of // matching the display refresh exactly. @@ -1365,6 +1527,10 @@ export class GLMapRenderer { gl.uniform2f(this.uViewport, width, height); gl.uniform1f(this.uZoom, camera.zoom); gl.uniform1f(this.uTilt, CAMERA_TILT); + gl.uniform1f(this.uHeightScale, HEIGHT_LIFT); + gl.uniform1f(this.uRelief, (TERRAIN_LODS[level] || TERRAIN_LODS[0]).relief); + gl.uniform1f(this.uRockLow, MOUNTAIN_ROCK_LOW); + gl.uniform1f(this.uRockHigh, MOUNTAIN_ROCK_HIGH); gl.uniform1f(this.uTextureRepeat, textureRepeat(view._period || 0)); gl.uniform1f(this.uTime, now); gl.enable(gl.BLEND); @@ -1374,7 +1540,7 @@ export class GLMapRenderer { 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); + if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows); this._drawPass(this.roads, 1, shifts, period); this._drawPass(this.borders, 1, shifts, period); this._drawFog(bufferWidth, bufferHeight, shifts, period, rows); diff --git a/tests/heightmap_test.js b/tests/heightmap_test.js new file mode 100644 index 0000000..52ab720 --- /dev/null +++ b/tests/heightmap_test.js @@ -0,0 +1,147 @@ +import { TestCase } from "./framework/test_case.js"; +import { MapTopology, key, mapToLocal, HEX_W, HEX_H } from "../shared/hex.js"; +import { CORNERS, EDGE_SEGMENTS } from "../client/js/map_view/distortion.js"; +import { HEIGHT_LIFT, MOUNTAIN_ROCK_LOW, MOUNTAIN_ROCK_HIGH, TERRAIN_LODS, CAMERA_TILT, terrainLod } from "../client/js/map_view/constants.js"; +import { HeightField } from "../client/js/map_view/heightmap.js"; + +function fixture(terrainAt, size = 5, cylindrical = false) { + const topology = new MapTopology({ x: size, y: size }, cylindrical); + const tiles = {}; + for (let y = topology.originY; y < topology.originY + size; y++) { + for (let x = topology.originX; x < topology.originX + size; x++) { + tiles[key(x, y)] = { terrainType: terrainAt(x, y), col: 0 }; + } + } + return { seed: 90210, topology, tiles, _period: 0 }; +} + +export class HeightmapTest extends TestCase { + test_is_deterministic_for_a_seed() { + const view = fixture(() => "Hills"); + const first = new HeightField(view); + const second = new HeightField(view); + for (let x = -2; x <= 2; x++) { + for (let y = -2; y <= 2; y++) { + this.assertEqual(first.tileHeight(x, y), second.tileHeight(x, y), "tile height"); + } + } + const point = mapToLocal(0, 0); + this.assertApprox(first.at(point.x, point.y), second.at(point.x, point.y), 1e-12, "point height"); + const other = new HeightField({ ...view, seed: 1234 }); + this.assertNotEqual(first.tileHeight(1, 0), other.tileHeight(1, 0), "a new seed reshapes the relief"); + } + + test_terrain_type_raises_the_relief() { + const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land"))); + const mountain = field.tileHeight(0, 0); + const land = field.tileHeight(1, 0); + this.assertGreater(mountain, land, "mountains sit above the plains"); + this.assertGreater(mountain, 0.5, "a mountain is more than half height"); + this.assertLess(land, 0.2, "plains stay low"); + } + + test_the_sea_stays_flat() { + const field = new HeightField(fixture(() => "Sea")); + for (let x = -2; x <= 2; x++) { + for (let y = -2; y <= 2; y++) { + this.assertEqual(field.tileHeight(x, y), 0, "the sea never rises"); + } + } + const point = mapToLocal(0, 0); + this.assertEqual(field.at(point.x, point.y), 0, "points over the sea stay at zero"); + } + + test_heights_are_normalised_below_one() { + // With the tallest terrain everywhere the blend can only average down, so + // every sample must stay within the unit the lift is calibrated for. + const field = new HeightField(fixture(() => "Mountain")); + for (let x = -3; x <= 3; x += 0.5) { + for (let y = -3; y <= 3; y += 0.5) { + const height = field.at(x, y); + this.assertTrue(Number.isFinite(height), "finite height"); + this.assertTrue(height >= 0 && height <= 1, `height ${height} is inside [0, 1]`); + } + } + } + + test_displacement_never_exceeds_one_tile() { + // A height-1 feature is lifted HEIGHT_LIFT before the zoom; the ground gap + // to the northern neighbour is HEX_H * sin(elevation). Keeping the lift at + // or under that is the "must not exceed one" bound on the maximum height. + this.assertTrue( + HEIGHT_LIFT <= HEX_H * CAMERA_TILT, + `the lift ${HEIGHT_LIFT} exceeds one tile spacing` + ); + } + + test_shared_corners_agree_so_the_relief_stays_watertight() { + // Tile (0,0)'s right corner is tile (1,0)'s top-left corner. Both tiles must + // lift it identically or the mesh would tear along their shared edge. + const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land"))); + const centreA = mapToLocal(0, 0); + const centreB = mapToLocal(1, 0); + const corner = { x: centreA.x + CORNERS[0].x, y: centreA.y + CORNERS[0].y }; + const fromA = field.blend(field.ring(0, 0), corner.x, corner.y); + const fromB = field.blend(field.ring(1, 0), corner.x, corner.y); + this.assertApprox(fromA, fromB, 1e-9, "the shared corner is lifted identically"); + } + + test_the_gradient_points_uphill_and_stays_watertight() { + const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land"))); + const centreA = mapToLocal(0, 0); + const corner = { x: centreA.x + CORNERS[0].x, y: centreA.y + CORNERS[0].y }; + const sample = field.sample(field.ring(0, 0), corner.x, corner.y); + this.assertApprox(sample.h, field.blend(field.ring(0, 0), corner.x, corner.y), 1e-9, + "sample reports the blended height"); + // The mountain sits east of the corner, so the ground climbs towards +x. + this.assertGreater(sample.dx, 0, "the gradient points up the slope"); + // The two fans that meet on the shared edge must light it identically. + const fromB = field.sample(field.ring(1, 0), corner.x, corner.y); + this.assertApprox(sample.dx, fromB.dx, 1e-9, "shared slope x"); + this.assertApprox(sample.dy, fromB.dy, 1e-9, "shared slope y"); + } + + test_flat_sea_has_no_gradient() { + const field = new HeightField(fixture(() => "Sea")); + const centre = mapToLocal(0, 0); + const sample = field.sample(field.ring(0, 0), centre.x + 6, centre.y - 4); + this.assertEqual(sample.dx, 0); + this.assertEqual(sample.dy, 0); + } + + test_a_cylindrical_field_wraps_seamlessly() { + const view = fixture(() => "Hills", 8, true); + const field = new HeightField(view); + const width = view.topology.width; + for (let y = -3; y <= 3; y++) { + this.assertEqual( + field.tileHeight(view.topology.originX + width, y), + field.tileHeight(view.topology.originX, y), + "the seam joins itself" + ); + } + } + + test_the_mountain_snow_line_band_is_ordered() { + // smoothstep needs low < high, and the band has to sit inside the [0, 1] + // height range or the cap would never appear (or always would). + this.assertGreater(MOUNTAIN_ROCK_HIGH, MOUNTAIN_ROCK_LOW, "the cap band is ordered"); + this.assertGreaterOrEqual(MOUNTAIN_ROCK_LOW, 0, "the band starts inside the range"); + this.assertLess(MOUNTAIN_ROCK_HIGH, 1, "the band ends inside the range"); + } + + test_level_of_detail_coarsens_with_zoom() { + this.assertEqual(terrainLod(1.5), 0, "close in uses the detailed mesh"); + this.assertEqual(terrainLod(0.4), 1, "mid zoom uses the middle mesh"); + this.assertEqual(terrainLod(0.1), TERRAIN_LODS.length - 1, "far out uses the coarsest"); + for (let i = 1; i < TERRAIN_LODS.length; i++) { + this.assertLess( + TERRAIN_LODS[i].segments, + TERRAIN_LODS[i - 1].segments, + "each level is coarser than the one before" + ); + } + this.assertEqual(TERRAIN_LODS[0].segments, EDGE_SEGMENTS, "the closest level keeps the wavy edge"); + this.assertEqual(HEX_W, 63, "sanity"); + } +} diff --git a/tests/textures_test.js b/tests/textures_test.js index 1b19b97..28b7ac6 100644 --- a/tests/textures_test.js +++ b/tests/textures_test.js @@ -147,11 +147,11 @@ export class TexturesTest extends TestCase { pass.floats = floats; }; renderer.buildTerrain(view); - this.assertEqual(captured.length % 14, 0, "fourteen floats per terrain vertex"); + this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex"); // The undistorted world position rides along as UVs; tile (0,0)'s right // corner is the ideal lattice point (31.5, 0), independent of the warp. let found = false; - for (let i = 0; i < captured.length; i += 14) { + for (let i = 0; i < captured.length; i += 17) { this.assertTrue(Number.isFinite(captured[i + 4]), "world x is finite"); this.assertTrue(Number.isFinite(captured[i + 5]), "world y is finite"); if (Math.abs(captured[i + 4] - 31.5) < 1e-9 && Math.abs(captured[i + 5]) < 1e-9) { diff --git a/tests/webgl_geometry_test.js b/tests/webgl_geometry_test.js index 7c18c07..278956a 100644 --- a/tests/webgl_geometry_test.js +++ b/tests/webgl_geometry_test.js @@ -20,12 +20,12 @@ function makeRenderer() { return renderer; } -function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set() } = {}) { +function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), terrainAt = null } = {}) { const topology = new MapTopology({ x: size, y: size }, false); const tiles = {}; for (let y = topology.originY; y < topology.originY + size; y++) { for (let x = topology.originX; x < topology.originX + size; x++) { - tiles[key(x, y)] = { col: 0 }; + tiles[key(x, y)] = { col: 0, terrainType: terrainAt ? terrainAt(x, y) : "Land" }; } } return { @@ -159,4 +159,50 @@ export class WebglGeometryTest extends TestCase { this.assertTrue(Number.isFinite(value), "every road vertex is finite"); } } + + async test_terrain_vertices_carry_the_relief() { + const renderer = makeRenderer(); + let captured = null; + renderer._upload = (pass, arr, usage, floats) => { + captured = arr; + pass.floats = floats; + }; + const state = view(renderer, { + terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"), + }); + renderer._buildTerrainPass(state, 0); + this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex"); + let max = 0; + for (let i = 0; i < captured.length; i += 17) { + const height = captured[i + 6]; + this.assertTrue(Number.isFinite(height), "height is finite"); + this.assertTrue(height >= 0 && height <= 1, "height is normalised"); + this.assertTrue(Number.isFinite(captured[i + 15]), "slope x is finite"); + this.assertTrue(Number.isFinite(captured[i + 16]), "slope y is finite"); + max = Math.max(max, height); + } + this.assertGreater(max, 0.4, "the mountain lifts its vertices high"); + } + + async test_a_coarser_level_of_detail_uses_fewer_vertices() { + const renderer = makeRenderer(); + const state = view(renderer); + let counts = []; + renderer._upload = (pass, arr) => { counts.push(arr.length); }; + renderer._buildTerrainPass(state, 0); + renderer._buildTerrainPass(state, 2); + this.assertGreater(counts[0], counts[1], "the far level has less geometry"); + } + + async test_flat_layers_carry_the_relief_too() { + const renderer = makeRenderer(); + const state = view(renderer, { roads: new Set(["0,0"]) }); + renderer.buildRoads(state); + const arr = renderer.captured.roads; + let lifted = false; + for (let i = 0; i < arr.length; i += 8) { + if (arr[i + 2] > 0) lifted = true; + } + this.assertTrue(lifted, "road vertices ride the heightmap"); + } }