WorldForge / hydro.js
openfree's picture
WorldForge: prompt-driven region plan, semantic layout map and composite height field
ddcb2ee verified
Raw
History Blame Contribute Delete
14.7 kB
// The world model on top of the height field: where water goes, and what that
// implies for everything else.
//
// A height field alone is scenery. What makes it read as a world is that the
// terrain decides the water, the water decides the moisture, and moisture plus
// slope and elevation decide what lives where. Each layer here is derived from
// the one above it, so a rerolled world stays internally consistent: rivers run
// downhill into the basins, forests thicken along them, and cliffs stay bare.
import { GRID } from './world.js';
const idx = (x, y) => y * GRID + x;
const inside = (x, y) => x >= 0 && x < GRID && y >= 0 && y < GRID;
/**
* Droplet erosion. Each drop walks downhill, picking up sediment on steep ground
* and dropping it where the slope eases, which is what cuts valleys instead of
* just adding more noise. The flow it leaves behind is the river network.
*/
export function erode(height, rng, drops = 12000) {
const flow = new Float32Array(GRID * GRID);
const capacity = 3.2, deposition = 0.28, erosion = 0.42, evaporation = 0.02;
for (let d = 0; d < drops; d++) {
let x = rng() * (GRID - 1);
let y = rng() * (GRID - 1);
let vx = 0, vy = 0, water = 1, sediment = 0;
for (let step = 0; step < 64; step++) {
const gx = Math.floor(x), gy = Math.floor(y);
if (!inside(gx + 1, gy + 1) || !inside(gx - 1, gy - 1)) break;
// bilinear gradient
const fx = x - gx, fy = y - gy;
const h00 = height[idx(gx, gy)], h10 = height[idx(gx + 1, gy)];
const h01 = height[idx(gx, gy + 1)], h11 = height[idx(gx + 1, gy + 1)];
const gradX = (h10 - h00) * (1 - fy) + (h11 - h01) * fy;
const gradY = (h01 - h00) * (1 - fx) + (h11 - h10) * fx;
vx = vx * 0.82 - gradX;
vy = vy * 0.82 - gradY;
const len = Math.hypot(vx, vy);
if (len < 1e-4) break;
vx /= len; vy /= len;
const hOld = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) +
h01 * (1 - fx) * fy + h11 * fx * fy;
x += vx; y += vy;
if (!inside(Math.floor(x), Math.floor(y))) break;
const hNew = height[idx(Math.floor(x), Math.floor(y))];
const drop = hOld - hNew;
flow[idx(Math.floor(x), Math.floor(y))] += water;
const cap = Math.max(0, drop) * water * capacity;
if (sediment > cap || drop < 0) {
// uphill or over capacity: lay sediment down, filling the hollow
const give = drop < 0 ? Math.min(sediment, -drop) : (sediment - cap) * deposition;
height[idx(gx, gy)] += give;
sediment -= give;
} else {
const take = Math.min((cap - sediment) * erosion, Math.max(0, drop));
height[idx(gx, gy)] -= take;
sediment += take;
}
water *= (1 - evaporation);
if (water < 0.02) break;
}
}
return flow;
}
/**
* Drainage area per cell (D8): every cell sheds one unit of rain into its
* steepest downhill neighbour, processed from the highest ground down so each
* cell already holds everything upstream of it by the time it drains.
*
* Droplet paths alone do not make a river network — with one drop per few cells
* the traces never converge. Drainage area does, and it is what actually decides
* where a stream becomes a river: the network is dendritic because the terrain is.
*/
/**
* Priority-Flood depression filling. Water is poured in from the borders and
* raised only as much as it must be, so every cell ends up with a downhill path
* to the edge — and wherever the filled surface sits above the ground, that is a
* lake, obtained for free.
*
* Without this, drainage is meaningless here: erosion leaves thousands of small
* pits, each one swallowing its catchment, so accumulation never grows past a
* couple of hundred cells and no river ever forms.
*/
export function fillDepressions(height) {
const n = GRID * GRID;
const filled = Float32Array.from(height);
const closed = new Uint8Array(n);
// binary heap keyed on height
const hp = [];
const push = (i) => {
hp.push(i);
let c = hp.length - 1;
while (c > 0) {
const p = (c - 1) >> 1;
if (filled[hp[p]] <= filled[hp[c]]) break;
[hp[p], hp[c]] = [hp[c], hp[p]];
c = p;
}
};
const pop = () => {
const top = hp[0], last = hp.pop();
if (hp.length) {
hp[0] = last;
let p = 0;
for (;;) {
const l = p * 2 + 1, r = l + 1;
let s = p;
if (l < hp.length && filled[hp[l]] < filled[hp[s]]) s = l;
if (r < hp.length && filled[hp[r]] < filled[hp[s]]) s = r;
if (s === p) break;
[hp[p], hp[s]] = [hp[s], hp[p]];
p = s;
}
}
return top;
};
for (let x = 0; x < GRID; x++) {
for (const y of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); }
}
for (let y = 1; y < GRID - 1; y++) {
for (const x of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); }
}
while (hp.length) {
const i = pop();
const x = i % GRID, y = (i / GRID) | 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const j = idx(nx, ny);
if (closed[j]) continue;
closed[j] = 1;
// raise just enough to drain, with a hair of slope so D8 has a direction
filled[j] = Math.max(filled[j], filled[i] + 1e-4);
push(j);
}
}
}
return filled;
}
export function drainage(height) {
const n = GRID * GRID;
const acc = new Float32Array(n).fill(1);
const order = Array.from({ length: n }, (_, i) => i)
.sort((a, b) => height[b] - height[a]);
const sinks = [];
for (const i of order) {
const x = i % GRID, y = (i / GRID) | 0;
let best = -1, bestDrop = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const j = idx(nx, ny);
const drop = (height[i] - height[j]) / Math.hypot(dx, dy);
if (drop > bestDrop) { bestDrop = drop; best = j; }
}
}
if (best >= 0) acc[best] += acc[i];
else sinks.push(i);
}
return { acc, sinks };
}
/**
* Standing and running water. Rivers come from drainage area; seas and lakes
* are simply everything below the water line. Returns the water depth per cell
* (0 where dry) and the surface height to render.
*/
export function hydrology(height, flow, seaLevel, filled) {
const surface = new Float32Array(GRID * GRID);
const depth = new Float32Array(GRID * GRID);
// A stream becomes visible once it drains enough ground — the same rule a map
// uses. 0.4% of the grid is roughly a first-order stream at this resolution.
const riverThreshold = GRID * GRID * 0.004;
let maxFlow = riverThreshold;
for (let i = 0; i < flow.length; i++) maxFlow = Math.max(maxFlow, flow[i]);
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
const i = idx(x, y);
const h = height[i];
if (seaLevel > -900 && h < seaLevel) {
surface[i] = seaLevel;
depth[i] = seaLevel - h;
continue;
}
// A filled depression is a lake — but only a real one. The fill raises
// cells by a hair as it propagates outward, and treating those as water
// hangs sheets of it down every cliff, so a lake has to be deep enough
// to be a lake.
if (filled && filled[i] - h > 0.4) {
surface[i] = filled[i];
depth[i] = filled[i] - h;
continue;
}
if (flow[i] <= riverThreshold) continue;
// Water only stays where the ground can hold it. Drops run down steep
// faces and leave flow behind them, but painting a surface there gives
// sheets of water clinging to cliffs — so the channel has to be flat
// enough, and the steeper it is the more flow it takes to qualify.
const dx = height[idx(Math.min(GRID - 1, x + 1), y)] - height[idx(Math.max(0, x - 1), y)];
const dy = height[idx(x, Math.min(GRID - 1, y + 1))] - height[idx(x, Math.max(0, y - 1))];
const grade = Math.hypot(dx, dy) / 2; // metres per cell
const maxGrade = 0.7;
if (grade > maxGrade) continue;
const strength = Math.min(1, (flow[i] - riverThreshold) / (maxFlow - riverThreshold + 1e-6));
if (strength < (grade / maxGrade) * 0.35) continue;
const d = 0.12 + strength * 0.45 * (1 - grade / maxGrade * 0.6);
surface[i] = h + d;
depth[i] = d;
}
}
return { surface, depth };
}
/**
* Distance to the nearest water, in cells, by two-pass chamfer transform — cheap
* and accurate enough to drive vegetation. Everything is thirsty; how thirsty is
* what separates a riverbank from a dune field.
*/
export function moisture(depth) {
const INF = 1e6;
const dist = new Float32Array(GRID * GRID).fill(INF);
for (let i = 0; i < depth.length; i++) if (depth[i] > 0) dist[i] = 0;
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
let d = dist[idx(x, y)];
if (inside(x - 1, y)) d = Math.min(d, dist[idx(x - 1, y)] + 1);
if (inside(x, y - 1)) d = Math.min(d, dist[idx(x, y - 1)] + 1);
if (inside(x - 1, y - 1)) d = Math.min(d, dist[idx(x - 1, y - 1)] + 1.414);
dist[idx(x, y)] = d;
}
}
for (let y = GRID - 1; y >= 0; y--) {
for (let x = GRID - 1; x >= 0; x--) {
let d = dist[idx(x, y)];
if (inside(x + 1, y)) d = Math.min(d, dist[idx(x + 1, y)] + 1);
if (inside(x, y + 1)) d = Math.min(d, dist[idx(x, y + 1)] + 1);
if (inside(x + 1, y + 1)) d = Math.min(d, dist[idx(x + 1, y + 1)] + 1.414);
dist[idx(x, y)] = d;
}
}
// 0..1, saturating about 25 cells out
const m = new Float32Array(GRID * GRID);
for (let i = 0; i < m.length; i++) m[i] = Math.max(0, 1 - dist[i] / 25);
return m;
}
/**
* Where a creature can live. Habitats are expressed the way a field guide would:
* water or land, how steep, how high, how wet — never "region 3", so the same
* table works on any world the generator produces.
*/
export const HABITAT = {
water: { water: [0.6, 99], slope: [0, 9], height: [-99, 99], moist: [0, 1] },
shallows: { water: [0.05, 1.2], slope: [0, 0.5], height: [-99, 99], moist: [0.5, 1] },
riverbank: { water: [0, 0.02], slope: [0, 0.45], height: [0, 99], moist: [0.55, 1] },
plain: { water: [0, 0.02], slope: [0, 0.35], height: [1, 99], moist: [0.1, 0.8] },
forest: { water: [0, 0.02], slope: [0, 0.6], height: [2, 99], moist: [0.35, 1] },
arid: { water: [0, 0.02], slope: [0, 0.5], height: [1, 99], moist: [0, 0.25] },
highland: { water: [0, 0.02], slope: [0.2, 1.2], height: [14, 99], moist: [0, 1] },
cliff: { water: [0, 0.02], slope: [0.7, 9], height: [6, 99], moist: [0, 1] },
};
function fits(rule, ctx) {
return ctx.water >= rule.water[0] && ctx.water <= rule.water[1] &&
ctx.slope >= rule.slope[0] && ctx.slope <= rule.slope[1] &&
ctx.height >= rule.height[0] && ctx.height <= rule.height[1] &&
ctx.moist >= rule.moist[0] && ctx.moist <= rule.moist[1];
}
/**
* Populate the world. `species` is a list of {id, habitat, weight, scale, herd},
* so the caller supplies its own cast — dinosaurs, livestock, anything — and the
* rules here decide where each one belongs. Herd animals are placed in clusters
* because a lone sauropod on an empty plain does not read as a living world.
*/
export function populate(world, species, rng, opts = {}) {
const { height, seaLevel } = world;
const { depth, } = world.water;
const moist = world.moisture;
const step = (opts.worldSize || 200) / (GRID - 1);
const budget = opts.budget || 120;
const placed = [];
const total = species.reduce((a, s) => a + (s.weight || 1), 0);
for (const sp of species) {
const want = Math.max(1, Math.round(budget * (sp.weight || 1) / total));
const rule = HABITAT[sp.habitat] || HABITAT.plain;
let made = 0, tries = 0;
while (made < want && tries < want * 220) {
tries++;
const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID);
const i = idx(gx, gy);
const h = height[i];
const dxh = (height[idx(Math.min(GRID - 1, gx + 1), gy)] - height[idx(Math.max(0, gx - 1), gy)]);
const dyh = (height[idx(gx, Math.min(GRID - 1, gy + 1))] - height[idx(gx, Math.max(0, gy - 1))]);
const slope = Math.hypot(dxh, dyh) / (step * 2);
if (!fits(rule, { water: depth[i], slope, height: h, moist: moist[i] })) continue;
// herd members share a neighbourhood rather than being sprinkled
const group = sp.herd ? 1 + Math.floor(rng() * sp.herd) : 1;
for (let g = 0; g < group && made < want; g++) {
const jx = gx + (g ? Math.round((rng() - 0.5) * 10) : 0);
const jy = gy + (g ? Math.round((rng() - 0.5) * 10) : 0);
if (!inside(jx, jy)) continue;
const j = idx(jx, jy);
if (!fits(rule, { water: depth[j], slope, height: height[j], moist: moist[j] })) continue;
placed.push({
id: sp.id,
x: -(opts.worldSize || 200) / 2 + jx * step,
z: -(opts.worldSize || 200) / 2 + jy * step,
y: depth[j] > 0.05 ? Math.max(height[j], seaLevel) : height[j],
scale: (sp.scale || 1) * (0.85 + rng() * 0.3),
rot: rng() * Math.PI * 2,
});
made++;
}
}
}
return placed;
}