// 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]]; } }