307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
// 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. A tile then climbs from the level of its lowest neighbour at its
|
|
// own edge to its full height at its centre, so a mountain is lifted inside its
|
|
// own hexagon instead of dragging the plain beside it up into a long slope. The
|
|
// climb is smoothstepped, so it eases out of the plain and rounds off at the
|
|
// peak rather than coming to a spike. The edge level is a function of the point,
|
|
// 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.
|
|
//
|
|
// Open sea is then pinned flat: any point whose nearest tile is Sea is forced
|
|
// to zero, so the coast's blend cannot drag the water up towards the land. The
|
|
// renderer draws one flat quad for the whole ocean, and the land fans slope
|
|
// down to meet it at sea level.
|
|
//
|
|
// 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";
|
|
import { CORNERS } from "./distortion.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,
|
|
};
|
|
|
|
// How far a tile's height still counts towards a point's neighbour level. A
|
|
// neighbour's centre is about one edge away, so only the tiles that share the
|
|
// point's corner can lower its edge; beyond this they drop out, which is what
|
|
// lets two overlapping rings agree on the same level.
|
|
export const HEIGHT_BLEND_RADIUS = HEX_H;
|
|
|
|
// The hexagon's edge normals and its inradius. The distance from an interior
|
|
// point to its tile's boundary is the smallest drop from the inradius along an
|
|
// edge normal, which is what the edge ramp below ramps over.
|
|
const HEX_EDGES = CORNERS.map((corner, i) => {
|
|
const next = CORNERS[(i + 1) % CORNERS.length];
|
|
const mx = (corner.x + next.x) / 2;
|
|
const my = (corner.y + next.y) / 2;
|
|
const length = Math.hypot(mx, my) || 1;
|
|
return { nx: mx / length, ny: my / length };
|
|
});
|
|
const HEX_INRADIUS = Math.hypot(
|
|
(CORNERS[0].x + CORNERS[1].x) / 2,
|
|
(CORNERS[0].y + CORNERS[1].y) / 2
|
|
);
|
|
|
|
// 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 },
|
|
Tundra: { floor: 0.0, relief: 0.0 },
|
|
Ice: { floor: 0.0, relief: 0.04 },
|
|
Sea: { floor: 0.0, relief: 0.0 },
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
// True when the tile nearest a world point is open sea. The test is a pure
|
|
// function of the point, so the two fans that meet at a shared vertex resolve
|
|
// it identically and the coast stays watertight. Applying it keeps the whole
|
|
// sea surface at zero even where the coast's blend would otherwise pull the
|
|
// water up towards the land. A point with no tile under it (off the map) is
|
|
// not sea: it keeps the blend's own fall towards zero.
|
|
seaAt(x, y) {
|
|
if (!this.topology) return true;
|
|
const coords = this.nearest(x, y);
|
|
const tile = this.tiles[key(this.topology.wrapX(coords.x), coords.y)];
|
|
return !!(tile && tile.terrainType === "Sea");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// The two reference heights for a point: `low` is the shortest tile in reach
|
|
// (the level a shared edge settles to) and `own` the height of the tile
|
|
// nearest the point, whose hexagon the ramp is measured against. `low` only
|
|
// counts tiles inside the blend radius, so the two overlapping rings of a
|
|
// shared edge take the same minimum and agree.
|
|
reference(ring, x, y) {
|
|
let low = Infinity;
|
|
let own = 0;
|
|
let near = null;
|
|
let nearest = Infinity;
|
|
for (const sample of ring) {
|
|
const distance = Math.hypot(sample.x - x, sample.y - y);
|
|
if (distance < nearest) {
|
|
nearest = distance;
|
|
own = sample.h;
|
|
near = sample;
|
|
}
|
|
if (distance < HEIGHT_BLEND_RADIUS && sample.h < low) low = sample.h;
|
|
}
|
|
if (low === Infinity) low = own;
|
|
return { low, own, near };
|
|
}
|
|
|
|
// How far inside its hexagon a point is, from 0 on the edge to 1 at the
|
|
// centre, smoothed so the tile eases out of its neighbour and rounds off at
|
|
// its peak. The nearest tile's hexagon is used, which is a function of the
|
|
// point, so both tiles of a shared edge read the same factor and meet at the
|
|
// same height.
|
|
edgeFactor(centre, x, y) {
|
|
const px = x - centre.x;
|
|
const py = y - centre.y;
|
|
let edge = Infinity;
|
|
for (const e of HEX_EDGES) {
|
|
const distance = HEX_INRADIUS - (px * e.nx + py * e.ny);
|
|
if (distance < edge) edge = distance;
|
|
}
|
|
const t = Math.max(0, Math.min(1, edge / HEX_INRADIUS));
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
// The height at a world point. A tile climbs from the level of its lowest
|
|
// neighbour at its own edge to its full height at its centre, so a mountain
|
|
// never drags a lower neighbour's border up into a slope.
|
|
blend(ring, x, y) {
|
|
if (this.seaAt(x, y)) return 0;
|
|
const { low, own, near } = this.reference(ring, x, y);
|
|
const centre = near || ring[0] || { x, y, h: own };
|
|
return low + (own - low) * this.edgeFactor(centre, x, y);
|
|
}
|
|
|
|
// The height and the normal gradient at a world point, in normalised height
|
|
// per world pixel. The gradient is the gradient of the distance-weighted tile
|
|
// average, the same smooth slope the textures blend on, so a mountain lights
|
|
// the same way as the forest beside it instead of snapping to a different
|
|
// normal at the tile edge; both tiles of a shared vertex read the same ring
|
|
// and so agree on the slope.
|
|
sample(ring, x, y) {
|
|
if (this.seaAt(x, y)) return { h: 0, dx: 0, dy: 0 };
|
|
let low = Infinity;
|
|
let own = 0;
|
|
let near = null;
|
|
let nearest = Infinity;
|
|
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 < nearest) {
|
|
nearest = distance;
|
|
own = tile.h;
|
|
near = tile;
|
|
}
|
|
if (distance >= HEIGHT_BLEND_RADIUS) continue;
|
|
if (tile.h < low) low = tile.h;
|
|
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 (low === Infinity) low = own;
|
|
const centre = near || ring[0] || { x, y, h: own };
|
|
const h = low + (own - low) * this.edgeFactor(centre, x, y);
|
|
if (total <= 0) return { h, dx: 0, dy: 0 };
|
|
const average = sum / total;
|
|
return {
|
|
h,
|
|
dx: (sumX - average * totalX) / total,
|
|
dy: (sumY - average * totalY) / total,
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
}
|