Files
UnrealPrototyping/Tools/Orogen/js/terrain-post.js
T
2026-09-25 17:02:24 +03:00

841 lines
33 KiB
JavaScript

// Terrain post-processing: domain warping, bilateral smoothing, and
// flow-based erosion. Runs after elevation assignment to deform terrain
// for organic shapes, soften harsh boundaries, and carve natural
// drainage patterns.
import { SimplexNoise } from './simplex-noise.js';
import {
FLOOD_NOISE_AMP, FLOOD_CARVE_RADIUS_FRAC,
WARP_FREQ, WARP_OCTAVES, WARP_MAX_AMP_MULT,
WARP_BIAS_BASE, WARP_BIAS_STRENGTH_SCALE, WARP_HOTSPOT_DAMPEN,
SMOOTH_EDGE_SENSITIVITY,
GLACIAL_LAT_DIVISOR, GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH,
GLACIAL_ELEV_FACTOR_SCALE, GLACIAL_ELEV_FACTOR_LAT_BASE, GLACIAL_ELEV_FACTOR_LAT_SCALE,
GLACIAL_CARVE_RATE, GLACIAL_CONVERGENCE_BONUS, GLACIAL_DEPOSIT_AMOUNT,
GLACIAL_FJORD_CARVE, GLACIAL_FLOW_THRESHOLD, GLACIAL_FJORD_THRESHOLD,
GLACIAL_WIDENING_FRAC, GLACIAL_TERMINUS_RATIO, GLACIAL_FJORD_ICE_MIN,
GLACIAL_POST_SMOOTH, GLACIAL_MID_FLOOD_FRAC, GLACIAL_MID_FLOOD_CARVE,
GLACIAL_INITIAL_CARVE,
HYDRAULIC_DEPOSIT_FRAC, HYDRAULIC_SLOPE_SENSITIVITY,
THERMAL_TRANSFER_FRAC,
RIDGE_SHARPEN_CAP, VALLEY_DEEPEN_FACTOR, VALLEY_FLOOR_FRAC, VALLEY_FLOOR_MIN,
} from './terrain-config.js';
/**
* Inline binary min-heap keyed on an external Float32Array of priorities.
* Each cell is pushed/popped exactly once — no decrease-key needed.
*/
class MinHeap {
constructor(keyArray) {
this._key = keyArray;
this._data = [];
}
get size() { return this._data.length; }
push(cell) {
this._data.push(cell);
let i = this._data.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this._key[this._data[i]] >= this._key[this._data[parent]]) break;
const tmp = this._data[i]; this._data[i] = this._data[parent]; this._data[parent] = tmp;
i = parent;
}
}
pop() {
const top = this._data[0];
const last = this._data.pop();
if (this._data.length > 0) {
this._data[0] = last;
let i = 0;
const n = this._data.length;
while (true) {
let smallest = i;
const l = 2 * i + 1, r = 2 * i + 2;
if (l < n && this._key[this._data[l]] < this._key[this._data[smallest]]) smallest = l;
if (r < n && this._key[this._data[r]] < this._key[this._data[smallest]]) smallest = r;
if (smallest === i) break;
const tmp = this._data[i]; this._data[i] = this._data[smallest]; this._data[smallest] = tmp;
i = smallest;
}
}
return top;
}
}
/**
* Priority-flood pit resolution with canyon carving.
* Ensures every land cell has a monotonically descending drainage path to
* the ocean, favoring carving through spill points over filling pit floors.
*
* Pass 1: Standard Barnes et al. priority-flood fill from ocean-adjacent
* land cells inward → surface[], drainTo[]
* Pass 2: Redistribute fill deficit as carving along spill paths
* Pass 3: Enforce monotonic drainage with epsilon gradient
*/
function priorityFloodCarve(mesh, r_elevation, r_isOcean, carveStrength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const EPS = 1e-7;
// --- Identify the main ocean body via BFS ---
// Find connected ocean components and mark only the largest as "open ocean"
const oceanLabel = new Int32Array(N).fill(-1);
const componentSizes = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] || oceanLabel[r] >= 0) continue;
const label = componentSizes.length;
let size = 0;
const queue = [r];
oceanLabel[r] = label;
while (queue.length > 0) {
const cur = queue.pop();
size++;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (r_isOcean[nb] && oceanLabel[nb] < 0) {
oceanLabel[nb] = label;
queue.push(nb);
}
}
}
componentSizes.push(size);
}
let mainOceanLabel = 0;
for (let i = 1; i < componentSizes.length; i++) {
if (componentSizes[i] > componentSizes[mainOceanLabel]) mainOceanLabel = i;
}
const isOpenOcean = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] && oceanLabel[r] === mainOceanLabel) isOpenOcean[r] = 1;
}
// --- Deterministic hash for noise perturbation (meander paths) ---
// Small noise on priority keys makes the flood front irregular,
// producing winding drainage paths instead of straight lines
const NOISE_AMP = FLOOD_NOISE_AMP; // amplitude relative to typical elevation range
function cellNoise(r) {
let h = (r * 2654435761) >>> 0; // Knuth multiplicative hash
h = ((h >>> 16) ^ h) * 0x45d9f3b >>> 0;
h = ((h >>> 16) ^ h) >>> 0;
return (h / 0xffffffff) * NOISE_AMP;
}
const surface = new Float32Array(r_elevation);
const drainTo = new Int32Array(N).fill(-1);
const visited = new Uint8Array(N);
// Priority key array — elevation + small noise for meandering
const key = new Float32Array(N);
for (let r = 0; r < N; r++) key[r] = r_elevation[r] + cellNoise(r);
const heap = new MinHeap(key);
// Seed: land cells adjacent to the main open ocean (not inland seas)
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) { visited[r] = 1; continue; }
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (isOpenOcean[adjList[i]]) {
visited[r] = 1;
drainTo[r] = adjList[i]; // drains to open ocean neighbor
heap.push(r);
break;
}
}
}
// Pass 1: priority-flood fill (noise-perturbed for winding paths)
while (heap.size > 0) {
const r = heap.pop();
const surfR = surface[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nb = adjList[i];
if (visited[nb]) continue;
visited[nb] = 1;
drainTo[nb] = r;
if (r_elevation[nb] < surfR + EPS) {
// Pit detected — fill to current surface + epsilon
surface[nb] = surfR + EPS;
key[nb] = surface[nb] + cellNoise(nb);
}
// else: neighbor drains naturally, surface[nb] already = r_elevation[nb]
heap.push(nb);
}
}
// Pass 2: carve-bias redistribution
// For each filled cell, trace path back to ocean, find the peak (spill point),
// and redistribute deficit as carving near the peak
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const deficit = surface[r] - r_elevation[r];
if (deficit <= EPS) continue;
// Trace drainTo path toward ocean, collect path and find peak
const path = [];
let peakIdx = -1;
let peakElev = -Infinity;
let cur = r;
while (cur >= 0 && !r_isOcean[cur]) {
path.push(cur);
if (r_elevation[cur] > peakElev) {
peakElev = r_elevation[cur];
peakIdx = path.length - 1;
}
cur = drainTo[cur];
}
if (peakIdx < 0 || path.length === 0) continue;
// Carve: lower cells near the peak using a triangle kernel
const carveAmount = deficit * carveStrength;
const radius = Math.max(3, Math.ceil(path.length * FLOOD_CARVE_RADIUS_FRAC));
const startIdx = Math.max(0, peakIdx - radius);
const endIdx = Math.min(path.length - 1, peakIdx + radius);
let kernelSum = 0;
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
kernelSum += 1 - dist / (radius + 1);
}
if (kernelSum > 0) {
for (let k = startIdx; k <= endIdx; k++) {
const dist = Math.abs(k - peakIdx);
const weight = (1 - dist / (radius + 1)) / kernelSum;
r_elevation[path[k]] -= carveAmount * weight;
if (r_elevation[path[k]] < 0) r_elevation[path[k]] = 0;
}
}
// Fill: raise the pit floor by the remaining fraction
const fillAmount = deficit * (1 - carveStrength);
r_elevation[r] += fillAmount;
}
// Pass 3: enforce monotonic drainage along drainTo paths
// Process cells in order of ascending surface (re-sort by surface)
const order = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) order.push(r);
}
order.sort((a, b) => surface[a] - surface[b]);
for (let i = 0; i < order.length; i++) {
const r = order[i];
const target = drainTo[r];
if (target < 0) continue;
const targetElev = r_isOcean[target] ? 0 : r_elevation[target];
if (r_elevation[r] <= targetElev) {
r_elevation[r] = targetElev + EPS;
}
}
}
/**
* Domain warping — displaces each region's elevation lookup by FBM simplex
* noise in the tangent plane, producing organic, squiggly coastlines and
* mountain ridges. Scale-invariant: noise is evaluated in 3D coordinate
* space and amplitude is in radians (physical distance on the sphere).
*
* For each region:
* 1. Compute a tangent-plane frame (east/north) at its position on the unit sphere
* 2. Use FBM simplex noise (4 octaves, frequency 6) to generate two
* displacement values in the tangent plane
* 3. Displace the region's 3D position along the tangent frame by the noise
* offsets, then re-project onto the unit sphere
* 4. Walk the mesh graph (greedy nearest-neighbor) from the original region
* toward the displaced point to find the closest region
* 5. Copy that source region's elevation to the output
*/
export function warpTerrain(mesh, r_elevation, r_xyz, seed, strength, r_hotspot) {
if (strength <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
const noise = new SimplexNoise(seed + 9999);
const freq = WARP_FREQ;
const octaves = WARP_OCTAVES;
const maxAmp = WARP_MAX_AMP_MULT * strength; // radians (~760 km at Earth scale when strength=1)
const out = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
// Tangent frame: east = normalize(cross(up, pos)), north = cross(pos, east)
let ex = -pz, ey = 0, ez = px; // cross([0,1,0], pos) = [-pz, 0, px]
const elen = Math.sqrt(ex * ex + ez * ez);
if (elen > 1e-10) { ex /= elen; ez /= elen; }
else { ex = 1; ez = 0; } // poles
const nx = py * ez;
const ny = pz * ex - px * ez;
const nz = -py * ex;
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
const nnx = nx / nlen, nny = ny / nlen, nnz = nz / nlen;
// FBM noise → two displacement values
const d1 = noise.fbm(px * freq, py * freq, pz * freq, octaves) * maxAmp;
const d2 = noise.fbm(px * freq + 31.7, py * freq + 47.3, pz * freq + 19.1, octaves) * maxAmp;
// Displace position along tangent frame and re-project onto unit sphere
let wx = px + ex * d1 + nnx * d2;
let wy = py + ey * d1 + nny * d2;
let wz = pz + ez * d1 + nnz * d2;
const wlen = Math.sqrt(wx * wx + wy * wy + wz * wz) || 1;
wx /= wlen; wy /= wlen; wz /= wlen;
// Greedy mesh walk from r toward the displaced point
let cur = r;
let bestDot = wx * px + wy * py + wz * pz;
for (;;) {
let moved = false;
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
const nb = adjList[i];
const dot = wx * r_xyz[3 * nb] + wy * r_xyz[3 * nb + 1] + wz * r_xyz[3 * nb + 2];
if (dot > bestDot) {
bestDot = dot;
cur = nb;
moved = true;
}
}
if (!moved) break;
}
out[r] = r_elevation[cur];
}
// Weighted max: pick whichever is larger, biased by strength
// At strength≈0 → 75% original, at strength=1 → 75% warped
// Dampen near hotspots so volcanic peaks keep their sculpted shape
const warpBias = WARP_BIAS_BASE + WARP_BIAS_STRENGTH_SCALE * strength;
for (let r = 0; r < N; r++) {
const orig = r_elevation[r];
const warped = out[r];
let bias = warpBias;
if (r_hotspot) {
const hotFrac = Math.min(1, Math.abs(r_hotspot[r]) / (Math.abs(orig) || 1));
bias *= 1 - WARP_HOTSPOT_DAMPEN * hotFrac;
}
if (warped > orig) {
r_elevation[r] = orig + (warped - orig) * bias;
} else {
r_elevation[r] = warped + (orig - warped) * (1 - bias);
}
}
}
/**
* Bilateral-weighted Laplacian smoothing.
* Neighbors with similar elevation receive more weight, preserving ridges
* and trenches while blending the banded artefacts from BFS distance fields.
* Coastline cells (land adjacent to ocean) are locked to prevent drift.
*/
export function smoothElevation(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const tmp = new Float32Array(N);
const { adjOffset, adjList } = mesh;
// Pre-compute coastline lock: land cells adjacent to at least one ocean cell
const locked = new Uint8Array(N);
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { locked[r] = 1; break; }
}
}
for (let iter = 0; iter < iterations; iter++) {
for (let r = 0; r < N; r++) {
if (locked[r]) { tmp[r] = r_elevation[r]; continue; }
const h = r_elevation[r];
let wSum = 0, hSum = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
const nh = r_elevation[adjList[i]];
const diff = Math.abs(nh - h);
const w = 1 / (1 + diff * SMOOTH_EDGE_SENSITIVITY);
wSum += w;
hSum += nh * w;
}
if (wSum > 0) {
const avg = hSum / wSum;
tmp[r] = h + (avg - h) * strength;
} else {
tmp[r] = h;
}
}
// Copy back
for (let r = 0; r < N; r++) r_elevation[r] = tmp[r];
}
}
/**
* Combined iterative erosion — interleaves hydraulic (stream power) and
* thermal (talus-angle) passes so they interact each iteration.
*
* Hydraulic: Braun-Willett implicit stream power. Rebuilds drainage graph
* each iteration so carved valleys attract more flow.
*
* Thermal: Slope-driven material transport. Redistributes material from
* steep slopes to lower neighbors using a simultaneous delta buffer.
*
* Each iteration runs one hydraulic step then one thermal step (if their
* respective iteration counts haven't been exhausted).
*/
export function erodeComposite(mesh, r_elevation, r_xyz, r_isOcean,
hIters, K, m, dt,
tIters, talusSlope, kThermal,
gIters, glacialStrength,
neighborDist)
{
gIters = gIters || 0;
glacialStrength = glacialStrength || 0;
const totalIters = Math.max(hIters, tIters, gIters);
if (totalIters <= 0) return;
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Collect land cell indices
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
if (landCount === 0) return;
// Shared buffers
const drainTarget = new Int32Array(N);
const cellDist = new Float32Array(N);
const flow = new Float32Array(N);
const delta = new Float32Array(N);
// Priority-flood pit resolution: ensure every land cell drains to ocean
// before hydraulic erosion begins. Carves canyons through spill points.
if (hIters > 0) {
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_INITIAL_CARVE);
}
// ---- Glacial precomputation (once — index is position-based) ----
let glacIdx = null;
let iceTarget = null;
let iceFlow = null;
let numIceUpstream = null;
if (gIters > 0 && glacialStrength > 0) {
function smoothstep(x, edge0, edge1) {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
glacIdx = new Float32Array(N);
// At strength=1 glaciation starts at ~50° latitude; at 0.5 it starts at ~70°
const thresholdLat = Math.PI / 2 - glacialStrength * Math.PI / GLACIAL_LAT_DIVISOR;
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
const y = r_xyz[3 * r + 1];
const polarDist = Math.abs(Math.asin(Math.max(-1, Math.min(1, y))));
const latFactor = smoothstep(polarDist, thresholdLat, Math.PI / 2);
const elevFactor = smoothstep(r_elevation[r], GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH);
const latScale = smoothstep(polarDist, Math.PI / 8, Math.PI / 3);
glacIdx[r] = Math.max(latFactor, elevFactor * GLACIAL_ELEV_FACTOR_SCALE * (GLACIAL_ELEV_FACTOR_LAT_BASE + GLACIAL_ELEV_FACTOR_LAT_SCALE * latScale)) * glacialStrength;
}
iceTarget = new Int32Array(N);
iceFlow = new Float32Array(N);
numIceUpstream = new Uint8Array(N);
}
// Per-iteration glacial rates (scaled so total effect ≈ same regardless of iter count)
const gScale = gIters > 0 ? 1.0 / gIters : 0;
const gCarveRate = GLACIAL_CARVE_RATE * gScale;
const gConvergenceBonus = GLACIAL_CONVERGENCE_BONUS * gScale;
const gDepositAmount = GLACIAL_DEPOSIT_AMOUNT * gScale;
const gFjordCarve = GLACIAL_FJORD_CARVE * gScale;
const gFlowThreshold = GLACIAL_FLOW_THRESHOLD;
const gFjordThreshold = GLACIAL_FJORD_THRESHOLD;
// Mid-loop drainage fix: at 75% of iterations, run a carve-biased
// priority-flood to cut outlets through basins created by glaciation.
const midFloodIter = Math.round(totalIters * GLACIAL_MID_FLOOD_FRAC);
let midFloodDone = false;
// Pre-allocate thermal erosion buffers (max neighbor degree)
let maxDeg = 0;
for (let r = 0; r < N; r++) {
const deg = adjOffset[r + 1] - adjOffset[r];
if (deg > maxDeg) maxDeg = deg;
}
const excNb = new Int32Array(maxDeg);
const excVal = new Float32Array(maxDeg);
const excAdjIdx = new Int32Array(maxDeg);
const excSlope = new Float32Array(maxDeg);
for (let iter = 0; iter < totalIters; iter++) {
if (!midFloodDone && iter >= midFloodIter) {
midFloodDone = true;
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_MID_FLOOD_CARVE);
}
// Sort land cells by descending elevation — needed by glacial ice flow
// and hydraulic flow accumulation. If glacial runs this iteration and
// hydraulic follows, glacial modifies elevations so we re-sort before hydraulic.
const glacialThisIter = iter < gIters && glacIdx;
const hydraulicThisIter = iter < hIters;
if (glacialThisIter || hydraulicThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// ---- Glacial step ----
if (glacialThisIter) {
// Rebuild ice drainage from current elevations
iceTarget.fill(-1);
numIceUpstream.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (glacIdx[r] <= 0) continue;
const h = r_elevation[r];
let bestNb = -1, bestDrop = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) { bestDrop = drop; bestNb = nb; }
}
if (bestNb >= 0) iceTarget[r] = bestNb;
}
// Accumulate ice flow downstream
for (let r = 0; r < N; r++) iceFlow[r] = glacIdx[r];
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = iceTarget[r];
if (target >= 0 && iceFlow[r] > 0) {
iceFlow[target] += iceFlow[r];
numIceUpstream[target]++;
}
}
// Carving: deepening + widening + over-deepening
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const deepening = gCarveRate * Math.pow(iceFlow[r], 0.6) * glacialStrength;
r_elevation[r] -= deepening;
// Valley widening for U-shape
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const d = neighborDist[j] || 1e-6;
const slope = Math.abs(r_elevation[r] - r_elevation[nb]) / d;
r_elevation[nb] -= deepening * GLACIAL_WIDENING_FRAC * Math.max(0, 1 - slope);
}
// Over-deepening at convergence zones
if (numIceUpstream[r] >= 2) {
r_elevation[r] -= gConvergenceBonus * Math.pow(iceFlow[r], 0.4);
}
}
// Moraine deposition at glacier termini
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
if (iceFlow[r] <= gFlowThreshold) continue;
const target = iceTarget[r];
if (target < 0 || r_isOcean[target]) continue;
if (glacIdx[target] < glacIdx[r] * GLACIAL_TERMINUS_RATIO) {
r_elevation[target] += gDepositAmount * Math.pow(iceFlow[r], 0.3);
}
}
// Fjord enhancement on coastal glaciated cells
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
if (glacIdx[r] <= GLACIAL_FJORD_ICE_MIN || iceFlow[r] <= gFjordThreshold) continue;
let isCoastal = false;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (r_isOcean[adjList[j]]) { isCoastal = true; break; }
}
if (isCoastal) {
r_elevation[r] -= gFjordCarve * Math.pow(iceFlow[r], 0.5);
if (r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// Clamp: land stays land
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && r_elevation[r] < 0) r_elevation[r] = 0;
}
}
// ---- Hydraulic step ----
if (hydraulicThisIter) {
// Re-sort if glacial step modified elevations this iteration
if (glacialThisIter) {
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
}
// Build drainage graph (steepest descent)
drainTarget.fill(-1);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let bestNb = -1, bestDrop = -Infinity, bestJ = -1;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const drop = h - r_elevation[nb];
if (drop > bestDrop) {
bestDrop = drop;
bestNb = nb;
bestJ = j;
}
}
// Pit handling: drain to least-steep-ascent neighbor
if (bestDrop <= 0) {
let minAscent = Infinity;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
const ascent = r_elevation[nb] - h;
if (ascent < minAscent) {
minAscent = ascent;
bestNb = nb;
bestJ = j;
}
}
}
if (bestNb >= 0) {
drainTarget[r] = bestNb;
cellDist[r] = neighborDist[bestJ] || 1e-6;
}
}
// Flow accumulation (already sorted descending at top of iteration)
flow.fill(0);
for (let i = 0; i < landCount; i++) flow[landCells[i]] = 1;
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const target = drainTarget[r];
if (target >= 0) flow[target] += flow[r];
}
// Implicit stream power solve (ascending elevation order) + sediment deposition
for (let i = landCount - 1; i >= 0; i--) {
const r = landCells[i];
const target = drainTarget[r];
if (target < 0 || cellDist[r] <= 0) continue;
const factor = K * Math.pow(flow[r], m) * dt / cellDist[r];
const h_receiver = Math.max(r_elevation[target], 0);
let h_new = (r_elevation[r] + factor * h_receiver) / (1 + factor);
if (h_new < h_receiver) h_new = h_receiver;
if (h_new < 0) h_new = 0;
// Sediment deposition: deposit fraction of eroded material at receiver
const eroded = r_elevation[r] - h_new;
if (eroded > 0 && !r_isOcean[target]) {
const drainOfTarget = drainTarget[target];
let receiverSlope = 0;
if (drainOfTarget >= 0 && cellDist[target] > 0) {
receiverSlope = Math.abs(r_elevation[target] - r_elevation[drainOfTarget]) / cellDist[target];
}
const depositFrac = HYDRAULIC_DEPOSIT_FRAC / (1 + receiverSlope * HYDRAULIC_SLOPE_SENSITIVITY);
const deposit = eroded * depositFrac;
r_elevation[target] += deposit;
if (r_elevation[target] > h_new) r_elevation[target] = h_new;
}
r_elevation[r] = h_new;
}
}
// ---- Thermal step ----
if (iter < tIters) {
delta.fill(0);
for (let i = 0; i < landCount; i++) {
const r = landCells[i];
const h = r_elevation[r];
let totalExcess = 0;
let excCount = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (r_isOcean[nb]) continue;
const nh = r_elevation[nb];
if (nh >= h) continue;
const d = neighborDist[j] || 1e-6;
const slope = (h - nh) / d;
if (slope > talusSlope) {
const excess = (slope - talusSlope) * d;
excNb[excCount] = nb;
excVal[excCount] = excess;
excAdjIdx[excCount] = j;
excCount++;
totalExcess += excess;
}
}
if (totalExcess <= 0) continue;
// Slope-weighted distribution: steeper neighbors get more debris
let totalSlopeWeighted = 0;
for (let k = 0; k < excCount; k++) {
const d = neighborDist[excAdjIdx[k]] || 1e-6;
excSlope[k] = (h - r_elevation[excNb[k]]) / d;
totalSlopeWeighted += excVal[k] * excSlope[k];
}
const transfer = kThermal * totalExcess * THERMAL_TRANSFER_FRAC;
if (totalSlopeWeighted > 0) {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] * excSlope[k] / totalSlopeWeighted) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
} else {
for (let k = 0; k < excCount; k++) {
const share = (excVal[k] / totalExcess) * transfer;
delta[r] -= share;
delta[excNb[k]] += share;
}
}
}
for (let i = 0; i < landCount; i++) {
r_elevation[landCells[i]] += delta[landCells[i]];
}
}
}
// Post-loop: light Laplacian smooth on glaciated cells to blend carving edges
if (glacIdx) {
const tmp = new Float32Array(r_elevation);
for (let r = 0; r < N; r++) {
if (r_isOcean[r] || glacIdx[r] <= 0) continue;
let sum = 0, count = 0;
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
if (!r_isOcean[adjList[j]]) { sum += r_elevation[adjList[j]]; count++; }
}
if (count > 0) {
const avg = sum / count;
tmp[r] = r_elevation[r] + (avg - r_elevation[r]) * GLACIAL_POST_SMOOTH;
}
}
for (let r = 0; r < N; r++) {
if (!r_isOcean[r] && glacIdx[r] > 0) r_elevation[r] = tmp[r];
}
}
}
/**
* Ridge sharpening — pushes cells that sit above their neighborhood average
* further upward, accentuating ridgelines without creating unrealistic spikes.
*/
export function sharpenRidges(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build land cell list to skip ~40% ocean cells each iteration
const landCells = [];
for (let r = 0; r < N; r++) {
if (!r_isOcean[r]) landCells.push(r);
}
const landCount = landCells.length;
const tmp = new Float32Array(N);
const original = new Float32Array(r_elevation);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < landCount; li++) {
const r = landCells[li];
const h = r_elevation[r];
let sum = 0;
const count = adjOffset[r + 1] - adjOffset[r];
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
sum += r_elevation[adjList[i]];
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
if (h > avg) {
// Ridge sharpening: push peaks up
let h_new = h + (h - avg) * strength;
// Clamp: don't exceed 1.5x original elevation
const cap = original[r] * RIDGE_SHARPEN_CAP;
if (h_new > cap) h_new = cap;
tmp[r] = h_new;
} else if (h < avg) {
// Valley deepening: push valleys down (weaker than ridge sharpening)
const VALLEY_FACTOR = VALLEY_DEEPEN_FACTOR;
let h_new = h - (avg - h) * strength * VALLEY_FACTOR;
// Floor cap: don't go below 0.5x original (symmetric to 1.5x ceiling)
const floor = original[r] * VALLEY_FLOOR_FRAC;
if (original[r] > 0 && h_new < floor) h_new = floor;
// Don't push land below sea level
if (original[r] > 0 && h_new < VALLEY_FLOOR_MIN) h_new = VALLEY_FLOOR_MIN;
tmp[r] = h_new;
} else {
tmp[r] = h;
}
}
for (let li = 0; li < landCount; li++) r_elevation[landCells[li]] = tmp[landCells[li]];
}
}
/**
* Soil creep — simple Laplacian diffusion on land cells.
* Unlike bilateral smoothing, this doesn't preserve ridges — it uniformly
* rounds off hillslopes. Coastline cells are locked.
*/
export function applySoilCreep(mesh, r_elevation, r_isOcean, iterations, strength) {
const N = mesh.numRegions;
const { adjOffset, adjList } = mesh;
// Pre-build interior land cell list: skip ocean cells and coastline-locked cells
const interiorLand = [];
for (let r = 0; r < N; r++) {
if (r_isOcean[r]) continue;
let coastal = false;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (r_isOcean[adjList[i]]) { coastal = true; break; }
}
if (!coastal) interiorLand.push(r);
}
const ilCount = interiorLand.length;
const tmp = new Float32Array(N);
for (let iter = 0; iter < iterations; iter++) {
for (let li = 0; li < ilCount; li++) {
const r = interiorLand[li];
const h = r_elevation[r];
let sum = 0, count = 0;
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
if (!r_isOcean[adjList[i]]) {
sum += r_elevation[adjList[i]];
count++;
}
}
if (count === 0) { tmp[r] = h; continue; }
const avg = sum / count;
tmp[r] = h + (avg - h) * strength;
}
for (let li = 0; li < ilCount; li++) r_elevation[interiorLand[li]] = tmp[interiorLand[li]];
}
}