234 lines
8.7 KiB
JavaScript
234 lines
8.7 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. 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);
|
|
}
|