VIDraft/WorldForge
0
1// The world model on top of the height field: where water goes, and what that2// implies for everything else.3//4// A height field alone is scenery. What makes it read as a world is that the5// terrain decides the water, the water decides the moisture, and moisture plus6// slope and elevation decide what lives where. Each layer here is derived from7// the one above it, so a rerolled world stays internally consistent: rivers run8// downhill into the basins, forests thicken along them, and cliffs stay bare.9 10import { GRID } from './world.js';11 12const idx = (x, y) => y * GRID + x;13const inside = (x, y) => x >= 0 && x < GRID && y >= 0 && y < GRID;14 15/**16 * Droplet erosion. Each drop walks downhill, picking up sediment on steep ground17 * and dropping it where the slope eases, which is what cuts valleys instead of18 * just adding more noise. The flow it leaves behind is the river network.19 */20export function erode(height, rng, drops = 12000) {21 const flow = new Float32Array(GRID * GRID);22 const capacity = 3.2, deposition = 0.28, erosion = 0.42, evaporation = 0.02;23 24 for (let d = 0; d < drops; d++) {25 let x = rng() * (GRID - 1);26 let y = rng() * (GRID - 1);27 let vx = 0, vy = 0, water = 1, sediment = 0;28 29 for (let step = 0; step < 64; step++) {30 const gx = Math.floor(x), gy = Math.floor(y);31 if (!inside(gx + 1, gy + 1) || !inside(gx - 1, gy - 1)) break;32 33 // bilinear gradient34 const fx = x - gx, fy = y - gy;35 const h00 = height[idx(gx, gy)], h10 = height[idx(gx + 1, gy)];36 const h01 = height[idx(gx, gy + 1)], h11 = height[idx(gx + 1, gy + 1)];37 const gradX = (h10 - h00) * (1 - fy) + (h11 - h01) * fy;38 const gradY = (h01 - h00) * (1 - fx) + (h11 - h10) * fx;39 40 vx = vx * 0.82 - gradX;41 vy = vy * 0.82 - gradY;42 const len = Math.hypot(vx, vy);43 if (len < 1e-4) break;44 vx /= len; vy /= len;45 46 const hOld = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) +47 h01 * (1 - fx) * fy + h11 * fx * fy;48 x += vx; y += vy;49 if (!inside(Math.floor(x), Math.floor(y))) break;50 51 const hNew = height[idx(Math.floor(x), Math.floor(y))];52 const drop = hOld - hNew;53 flow[idx(Math.floor(x), Math.floor(y))] += water;54 55 const cap = Math.max(0, drop) * water * capacity;56 if (sediment > cap || drop < 0) {57 // uphill or over capacity: lay sediment down, filling the hollow58 const give = drop < 0 ? Math.min(sediment, -drop) : (sediment - cap) * deposition;59 height[idx(gx, gy)] += give;60 sediment -= give;61 } else {62 const take = Math.min((cap - sediment) * erosion, Math.max(0, drop));63 height[idx(gx, gy)] -= take;64 sediment += take;65 }66 67 water *= (1 - evaporation);68 if (water < 0.02) break;69 }70 }71 return flow;72}73 74/**75 * Drainage area per cell (D8): every cell sheds one unit of rain into its76 * steepest downhill neighbour, processed from the highest ground down so each77 * cell already holds everything upstream of it by the time it drains.78 *79 * Droplet paths alone do not make a river network — with one drop per few cells80 * the traces never converge. Drainage area does, and it is what actually decides81 * where a stream becomes a river: the network is dendritic because the terrain is.82 */83/**84 * Priority-Flood depression filling. Water is poured in from the borders and85 * raised only as much as it must be, so every cell ends up with a downhill path86 * to the edge — and wherever the filled surface sits above the ground, that is a87 * lake, obtained for free.88 *89 * Without this, drainage is meaningless here: erosion leaves thousands of small90 * pits, each one swallowing its catchment, so accumulation never grows past a91 * couple of hundred cells and no river ever forms.92 */93export function fillDepressions(height) {94 const n = GRID * GRID;95 const filled = Float32Array.from(height);96 const closed = new Uint8Array(n);97 98 // binary heap keyed on height99 const hp = [];100 const push = (i) => {101 hp.push(i);102 let c = hp.length - 1;103 while (c > 0) {104 const p = (c - 1) >> 1;105 if (filled[hp[p]] <= filled[hp[c]]) break;106 [hp[p], hp[c]] = [hp[c], hp[p]];107 c = p;108 }109 };110 const pop = () => {111 const top = hp[0], last = hp.pop();112 if (hp.length) {113 hp[0] = last;114 let p = 0;115 for (;;) {116 const l = p * 2 + 1, r = l + 1;117 let s = p;118 if (l < hp.length && filled[hp[l]] < filled[hp[s]]) s = l;119 if (r < hp.length && filled[hp[r]] < filled[hp[s]]) s = r;120 if (s === p) break;121 [hp[p], hp[s]] = [hp[s], hp[p]];122 p = s;123 }124 }125 return top;126 };127 128 for (let x = 0; x < GRID; x++) {129 for (const y of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); }130 }131 for (let y = 1; y < GRID - 1; y++) {132 for (const x of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); }133 }134 135 while (hp.length) {136 const i = pop();137 const x = i % GRID, y = (i / GRID) | 0;138 for (let dy = -1; dy <= 1; dy++) {139 for (let dx = -1; dx <= 1; dx++) {140 if (!dx && !dy) continue;141 const nx = x + dx, ny = y + dy;142 if (!inside(nx, ny)) continue;143 const j = idx(nx, ny);144 if (closed[j]) continue;145 closed[j] = 1;146 // raise just enough to drain, with a hair of slope so D8 has a direction147 filled[j] = Math.max(filled[j], filled[i] + 1e-4);148 push(j);149 }150 }151 }152 return filled;153}154 155export function drainage(height) {156 const n = GRID * GRID;157 const acc = new Float32Array(n).fill(1);158 const order = Array.from({ length: n }, (_, i) => i)159 .sort((a, b) => height[b] - height[a]);160 const sinks = [];161 162 for (const i of order) {163 const x = i % GRID, y = (i / GRID) | 0;164 let best = -1, bestDrop = 0;165 for (let dy = -1; dy <= 1; dy++) {166 for (let dx = -1; dx <= 1; dx++) {167 if (!dx && !dy) continue;168 const nx = x + dx, ny = y + dy;169 if (!inside(nx, ny)) continue;170 const j = idx(nx, ny);171 const drop = (height[i] - height[j]) / Math.hypot(dx, dy);172 if (drop > bestDrop) { bestDrop = drop; best = j; }173 }174 }175 if (best >= 0) acc[best] += acc[i];176 else sinks.push(i);177 }178 return { acc, sinks };179}180 181/**182 * Standing and running water. Rivers come from drainage area; seas and lakes183 * are simply everything below the water line. Returns the water depth per cell184 * (0 where dry) and the surface height to render.185 */186export function hydrology(height, flow, seaLevel, filled) {187 const surface = new Float32Array(GRID * GRID);188 const depth = new Float32Array(GRID * GRID);189 190 // A stream becomes visible once it drains enough ground — the same rule a map191 // uses. 0.4% of the grid is roughly a first-order stream at this resolution.192 const riverThreshold = GRID * GRID * 0.004;193 let maxFlow = riverThreshold;194 for (let i = 0; i < flow.length; i++) maxFlow = Math.max(maxFlow, flow[i]);195 196 for (let y = 0; y < GRID; y++) {197 for (let x = 0; x < GRID; x++) {198 const i = idx(x, y);199 const h = height[i];200 201 if (seaLevel > -900 && h < seaLevel) {202 surface[i] = seaLevel;203 depth[i] = seaLevel - h;204 continue;205 }206 // A filled depression is a lake — but only a real one. The fill raises207 // cells by a hair as it propagates outward, and treating those as water208 // hangs sheets of it down every cliff, so a lake has to be deep enough209 // to be a lake.210 if (filled && filled[i] - h > 0.4) {211 surface[i] = filled[i];212 depth[i] = filled[i] - h;213 continue;214 }215 if (flow[i] <= riverThreshold) continue;216 217 // Water only stays where the ground can hold it. Drops run down steep218 // faces and leave flow behind them, but painting a surface there gives219 // sheets of water clinging to cliffs — so the channel has to be flat220 // enough, and the steeper it is the more flow it takes to qualify.221 const dx = height[idx(Math.min(GRID - 1, x + 1), y)] - height[idx(Math.max(0, x - 1), y)];222 const dy = height[idx(x, Math.min(GRID - 1, y + 1))] - height[idx(x, Math.max(0, y - 1))];223 const grade = Math.hypot(dx, dy) / 2; // metres per cell224 const maxGrade = 0.7;225 if (grade > maxGrade) continue;226 227 const strength = Math.min(1, (flow[i] - riverThreshold) / (maxFlow - riverThreshold + 1e-6));228 if (strength < (grade / maxGrade) * 0.35) continue;229 230 const d = 0.12 + strength * 0.45 * (1 - grade / maxGrade * 0.6);231 surface[i] = h + d;232 depth[i] = d;233 }234 }235 return { surface, depth };236}237 238/**239 * Distance to the nearest water, in cells, by two-pass chamfer transform — cheap240 * and accurate enough to drive vegetation. Everything is thirsty; how thirsty is241 * what separates a riverbank from a dune field.242 */243export function moisture(depth) {244 const INF = 1e6;245 const dist = new Float32Array(GRID * GRID).fill(INF);246 for (let i = 0; i < depth.length; i++) if (depth[i] > 0) dist[i] = 0;247 248 for (let y = 0; y < GRID; y++) {249 for (let x = 0; x < GRID; x++) {250 let d = dist[idx(x, y)];251 if (inside(x - 1, y)) d = Math.min(d, dist[idx(x - 1, y)] + 1);252 if (inside(x, y - 1)) d = Math.min(d, dist[idx(x, y - 1)] + 1);253 if (inside(x - 1, y - 1)) d = Math.min(d, dist[idx(x - 1, y - 1)] + 1.414);254 dist[idx(x, y)] = d;255 }256 }257 for (let y = GRID - 1; y >= 0; y--) {258 for (let x = GRID - 1; x >= 0; x--) {259 let d = dist[idx(x, y)];260 if (inside(x + 1, y)) d = Math.min(d, dist[idx(x + 1, y)] + 1);261 if (inside(x, y + 1)) d = Math.min(d, dist[idx(x, y + 1)] + 1);262 if (inside(x + 1, y + 1)) d = Math.min(d, dist[idx(x + 1, y + 1)] + 1.414);263 dist[idx(x, y)] = d;264 }265 }266 267 // 0..1, saturating about 25 cells out268 const m = new Float32Array(GRID * GRID);269 for (let i = 0; i < m.length; i++) m[i] = Math.max(0, 1 - dist[i] / 25);270 return m;271}272 273/**274 * Where a creature can live. Habitats are expressed the way a field guide would:275 * water or land, how steep, how high, how wet — never "region 3", so the same276 * table works on any world the generator produces.277 */278export const HABITAT = {279 water: { water: [0.6, 99], slope: [0, 9], height: [-99, 99], moist: [0, 1] },280 shallows: { water: [0.05, 1.2], slope: [0, 0.5], height: [-99, 99], moist: [0.5, 1] },281 riverbank: { water: [0, 0.02], slope: [0, 0.45], height: [0, 99], moist: [0.55, 1] },282 plain: { water: [0, 0.02], slope: [0, 0.35], height: [1, 99], moist: [0.1, 0.8] },283 forest: { water: [0, 0.02], slope: [0, 0.6], height: [2, 99], moist: [0.35, 1] },284 arid: { water: [0, 0.02], slope: [0, 0.5], height: [1, 99], moist: [0, 0.25] },285 highland: { water: [0, 0.02], slope: [0.2, 1.2], height: [14, 99], moist: [0, 1] },286 cliff: { water: [0, 0.02], slope: [0.7, 9], height: [6, 99], moist: [0, 1] },287};288 289function fits(rule, ctx) {290 return ctx.water >= rule.water[0] && ctx.water <= rule.water[1] &&291 ctx.slope >= rule.slope[0] && ctx.slope <= rule.slope[1] &&292 ctx.height >= rule.height[0] && ctx.height <= rule.height[1] &&293 ctx.moist >= rule.moist[0] && ctx.moist <= rule.moist[1];294}295 296/**297 * Populate the world. `species` is a list of {id, habitat, weight, scale, herd},298 * so the caller supplies its own cast — dinosaurs, livestock, anything — and the299 * rules here decide where each one belongs. Herd animals are placed in clusters300 * because a lone sauropod on an empty plain does not read as a living world.301 */302export function populate(world, species, rng, opts = {}) {303 const { height, seaLevel } = world;304 const { depth, } = world.water;305 const moist = world.moisture;306 const step = (opts.worldSize || 200) / (GRID - 1);307 const budget = opts.budget || 120;308 309 const placed = [];310 const total = species.reduce((a, s) => a + (s.weight || 1), 0);311 312 for (const sp of species) {313 const want = Math.max(1, Math.round(budget * (sp.weight || 1) / total));314 const rule = HABITAT[sp.habitat] || HABITAT.plain;315 let made = 0, tries = 0;316 317 while (made < want && tries < want * 220) {318 tries++;319 const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID);320 const i = idx(gx, gy);321 const h = height[i];322 const dxh = (height[idx(Math.min(GRID - 1, gx + 1), gy)] - height[idx(Math.max(0, gx - 1), gy)]);323 const dyh = (height[idx(gx, Math.min(GRID - 1, gy + 1))] - height[idx(gx, Math.max(0, gy - 1))]);324 const slope = Math.hypot(dxh, dyh) / (step * 2);325 326 if (!fits(rule, { water: depth[i], slope, height: h, moist: moist[i] })) continue;327 328 // herd members share a neighbourhood rather than being sprinkled329 const group = sp.herd ? 1 + Math.floor(rng() * sp.herd) : 1;330 for (let g = 0; g < group && made < want; g++) {331 const jx = gx + (g ? Math.round((rng() - 0.5) * 10) : 0);332 const jy = gy + (g ? Math.round((rng() - 0.5) * 10) : 0);333 if (!inside(jx, jy)) continue;334 const j = idx(jx, jy);335 if (!fits(rule, { water: depth[j], slope, height: height[j], moist: moist[j] })) continue;336 placed.push({337 id: sp.id,338 x: -(opts.worldSize || 200) / 2 + jx * step,339 z: -(opts.worldSize || 200) / 2 + jy * step,340 y: depth[j] > 0.05 ? Math.max(height[j], seaLevel) : height[j],341 scale: (sp.scale || 1) * (0.85 + rng() * 0.3),342 rot: rng() * Math.PI * 2,343 });344 made++;345 }346 }347 }348 return placed;349}350 