Added a seeded terrain heightmap with relief lighting and LODs

This commit is contained in:
2026-09-19 20:19:12 +02:00
parent 45dabed2c7
commit 2126129515
6 changed files with 715 additions and 84 deletions
+39
View File
@@ -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.
+233
View File
@@ -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);
}
+246 -80
View File
@@ -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);
+147
View File
@@ -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");
}
}
+2 -2
View File
@@ -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) {
+48 -2
View File
@@ -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");
}
}