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

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