terrain.js
109 lines · in natura-src-1.0.0.zip as natura/terrain.js · a module · its entry in the module reference · its test: terrain-test.js
// terrain.js -- Natura TERRAIN (SYSTEMS.md #2, ALGORITHMS.md): the ground the world sits on, and the
// PHYSICAL half of "weather changes the world it falls on." Wraps the Terrain Engine for the initial
// island, then EVOLVES its heightfield by stream-power EROSION driven by the weather's moisture: where
// the rain runs (high flow) over steep ground that has been WET, it incises. So the windward slopes wear
// down under the rain-shadow's rain while the dry lee is preserved -- erosion inherits the rain-shadow,
// the way vegetation does, only in stone instead of leaf.
//
// A Natura system spec (init/step/fields), deterministic + node-testable. Couples to WEATHER (moisture).
// Computes its own D8 flow accumulation (the engine's flow needs separate setup). UMD.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.NaturaTerrain = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// make a Terrain system. config: { N, seed, preset, TE (the TerrainEngine), Kf, m, n, floor, seaLevel }.
// TE is the terrain-engine module (window.TerrainEngine / require('terrain.js')); if absent, uses
// config.elevation(x,y) as the initial heightfield sampler (so this tests without the engine).
function makeTerrain(config) {
config = config || {};
const N = config.N || 48;
const seaLevel = config.seaLevel != null ? config.seaLevel : 0;
const Kf = config.Kf != null ? config.Kf : 0.0016; // erodibility
const mm = config.m != null ? config.m : 0.5; // flow exponent
const nn = config.n != null ? config.n : 1.0; // slope exponent
const floor = config.floor != null ? config.floor : -0.35; // bedrock floor (bound runaway)
const weatherName = config.weather || 'weather';
const gx = (i) => (i / (N - 1)) * 2 - 1;
function initElev(rng) {
const H = new Float32Array(N * N);
let sampler = config.elevation;
if (!sampler && config.TE) { const t = new config.TE.Terrain(config.seed || 1, { preset: config.preset || 'temperate-island', size: config.size || 120 }); sampler = (x, y) => t.heightAt(x, y); }
if (!sampler) sampler = () => 0.2;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) H[j * N + i] = sampler(gx(i), gx(j));
return H;
}
// D8 flow accumulation on H: each land cell passes (1 + upstream) to its lowest neighbour, processed
// high-to-low so upstream is done first. Returns a flow grid (drainage area proxy).
function flowAccum(H) {
const idx = []; for (let k = 0; k < N * N; k++) idx.push(k);
idx.sort((a, b) => H[b] - H[a]); // high to low
const flow = new Float32Array(N * N); for (let k = 0; k < N * N; k++) flow[k] = 1;
for (const k of idx) {
const i = k % N, j = (k / N) | 0; let lo = k, lh = H[k];
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) {
const ni = i + di, nj = j + dj; if (ni < 0 || ni >= N || nj < 0 || nj >= N) continue;
const m = nj * N + ni; if (H[m] < lh) { lh = H[m]; lo = m; }
}
if (lo !== k) flow[lo] += flow[k]; // give my water to the lowest neighbour
}
return flow;
}
return {
name: config.name || 'terrain',
domain: 'physical',
scale: 'planetary',
params: config,
init(rng) {
const H = initElev(rng), land = new Uint8Array(N * N);
for (let k = 0; k < N * N; k++) land[k] = H[k] > seaLevel ? 1 : 0;
return { N, H, land, seaLevel, slope: new Float32Array(N * N), flow: new Float32Array(N * N), eroded: 0 };
},
step(st, ctx, dt) {
const dd = clamp(dt / 86400, 0, 3); // erosion works on sim-DAYS (slow)
if (dd <= 0) return st;
const { N, H, land } = st;
const w = ctx.fields[weatherName];
const moist = w && w.moisture ? w.moisture : null; // accumulated rain -> wetness
// slope from the current (eroding) heightfield
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const il = Math.max(0, i - 1), ir = Math.min(N - 1, i + 1), jd = Math.max(0, j - 1), ju = Math.min(N - 1, j + 1);
const dx = (H[j * N + ir] - H[j * N + il]) * 0.5 * N, dz = (H[ju * N + i] - H[jd * N + i]) * 0.5 * N;
st.slope[j * N + i] = Math.hypot(dx, dz);
}
const flow = flowAccum(H); st.flow = flow;
// sample wetness from the weather moisture grid (may be a different N)
const wetAt = (i, j) => {
if (!moist) return 1; // no weather -> uniform (still erodes by slope)
const WN = w.N || N; const fi = i / (N - 1) * (WN - 1), fj = j / (N - 1) * (WN - 1);
const x0 = fi | 0, y0 = fj | 0, x1 = Math.min(WN - 1, x0 + 1), y1 = Math.min(WN - 1, y0 + 1), tx = fi - x0, ty = fj - y0;
return moist[y0 * WN + x0] * (1 - tx) * (1 - ty) + moist[y0 * WN + x1] * tx * (1 - ty) + moist[y1 * WN + x0] * (1 - tx) * ty + moist[y1 * WN + x1] * tx * ty;
};
let tot = 0;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i; if (!land[k]) continue;
const wet = clamp(wetAt(i, j) * 0.5, 0, 3); // wetter -> more erosion (stream power scales with discharge)
const E = Kf * Math.pow(flow[k], mm) * Math.pow(st.slope[k], nn) * wet * dd;
const dH = Math.min(E, H[k] - floor); // never below bedrock floor
if (dH > 0) { H[k] -= dH; tot += dH; }
}
st.eroded += tot;
return st;
},
fields(st) {
return { N: st.N, elevation: st.H, slope: st.slope, flow: st.flow, land: st.land, seaLevel: st.seaLevel };
}
};
}
return { makeTerrain };
});