CoolFace
Apppublic

VIDraft/WorldForge

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes
hydro.js350 linesDownload Raw Back to root
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