Files
Battle-for-Tismo/client/js/map_view/heightmap.js
T

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);
}