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

1999 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Elevation pipeline: collision detection, stress propagation,
// distance fields, and final elevation assignment.
import { makeRandInt, makeRng } from './rng.js';
import { SimplexNoise } from './simplex-noise.js';
import {
COLLISION_THRESHOLD, COLLISION_DT_BASE, COLLISION_DT_REF_REGIONS,
PAIR_INTENSITY_BASE, SUBDUCT_UNDULATION_DENSITY_DECAY, SUBDUCT_UNDULATION_FREQ,
SUBDUCT_UNDULATION_AMP, SUBDUCT_FACTOR_BASE, SUBDUCT_FACTOR_TANH_SCALE,
SUBDUCT_THRESHOLD, BOUNDARY_TYPE_THRESH_FACTOR,
STRESS_PROPAGATE_MIN, STRESS_PROPAGATE_CUTOFF,
STRESS_DIR_FACTOR_MIN, STRESS_DIR_FACTOR_BASE, STRESS_DIR_FACTOR_SCALE,
STRESS_DIR_BLEND_PARENT, STRESS_DIR_BLEND_TRAVEL,
STRESS_DIR_SMOOTH_PASSES, STRESS_DIR_SELF_WEIGHT,
STRESS_DECAY_BASE, STRESS_DECAY_SPREAD_FACTOR, STRESS_SUBDUCT_DECAY_MULT,
STRESS_PASSES_PER_SPREAD, STRESS_PERCENTILE,
SMALL_W, SUPER_W,
INTERIOR_BAND_BASE, TECTONIC_REACH_BASE, COASTAL_PLAIN_WIDTH_BASE, COAST_BFS_WIDTH_BASE,
RIDGE_STRENGTH, RIDGE_SIGMA_BASE as RIDGE_SIGMA_BASE_CFG,
RIDGE_PEAK_SHIFT_BASE, RIDGE_EXTENT_BASE,
RIDGE_ASYM_SUBDUCT_NARROW, RIDGE_ASYM_OVERRIDE_WIDEN,
RIDGE_STRESS_WIDTH_BASE, RIDGE_STRESS_WIDTH_SCALE, RIDGE_WIDTH_NOISE_AMP,
RIDGE_HEIGHT_VAR_BASE, RIDGE_HEIGHT_VAR_SCALE, RIDGE_HEIGHT_VAR_FREQ,
BASE_SCALE, ASYMMETRY_FACTOR,
SUBDUCTING_SUPPRESSION, STRESS_MAG_SCALE, STRESS_DEPRESS_FRAC,
STRESS_HEIGHT_VAR_BASE, STRESS_HEIGHT_VAR_SCALE,
SUBDUCTING_REACH_MIN, SUBDUCTING_REACH_RANGE,
FOLD_FREQ_PRIMARY, FOLD_FREQ_SECONDARY, FOLD_MEAN_OFFSET,
FOLD_PHASE_WARP_AMP, FOLD_PHASE_WARP2_AMP,
FOLD_AMP_MOD_BASE, FOLD_AMP_MOD_SCALE, FOLD_AMP_MOD2_BASE, FOLD_AMP_MOD2_SCALE,
FOLD_SECONDARY_ALONG, FOLD_SECONDARY_CROSS, FOLD_SECONDARY_AMP,
FOLD_NOISE_MAG_SCALE, FOLD_ELEV_THRESHOLD, FOLD_ELEV_SCALE,
FOLD_ELEV_BOOST_OFFSET, FOLD_ELEV_BOOST_SCALE, FOLD_SF_SUPPRESS,
FOLD_FREQ_MULT_SCALE,
RIFT_HALF_WIDTH_BASE, RIFT_FLOOR_MULT, RIFT_SHOULDER_MULT,
RIFT_AXIS_DEPTH, RIFT_AXIS_VOLCANIC_AMP,
RIFT_FLOOR_DEPTH, RIFT_FLOOR_TAPER, RIFT_FLOOR_VOLCANIC_AMP,
RIFT_SHOULDER_UPLIFT, RIFT_FADEOUT_RESIDUAL,
BASIN_FREQ, BASIN_FACTOR_BIAS, BASIN_FACTOR_SCALE,
FORELAND_STRESS_THRESH, FORELAND_WIDTH_FRAC, FORELAND_BASIN_DEPTH, FORELAND_PEAK_POS,
FORELAND_BASIN_DEEPENING_BASE, FORELAND_BASIN_DEEPENING_SCALE,
BACK_ARC_START_BASE, BACK_ARC_PEAK_BASE, BACK_ARC_END_BASE,
BACK_ARC_DEPTH, BACK_ARC_SUBDUCT_THRESH,
WARP_SCALE, OROGENIC_FREQ,
NOISE_ACTIVITY_SCALE, NOISE_BASE_SCALE, NOISE_ACTIVITY_CONTRIB,
PLATEAU_SUPPRESS_MIN, PLATEAU_SUPPRESS_SCALE,
BASIN_AMP_SUPPRESS, CRATON_AMP_SUPPRESS,
RIDGED_NOISE_AMP, DETAIL_NOISE_FREQ_MULT, DETAIL_NOISE_AMP,
FINE_NOISE_FREQ_MULT, FINE_NOISE_AMP, OCEAN_NOISE_AMP,
DISSECT_THRESHOLD as DISSECT_THRESHOLD_CFG, DISSECT_AMP, DISSECT_ELEV_SCALE,
SUMMIT_THRESHOLD as SUMMIT_THRESHOLD_CFG, SUMMIT_STRESS_MIN, SUMMIT_SPIKE_OFFSET,
SUMMIT_STRESS_FLOOR,
PLATE_BASE_HEIGHT_MEAN, PLATE_BASE_HEIGHT_STDDEV,
INTERIOR_BASE_SHIELD, INTERIOR_BASE_BASIN, INTERIOR_TECTONIC,
COASTAL_DEPRESSION, COASTAL_DEPRESSION_BASIN_REDUCE,
INTERIOR_UPLIFT_RAMP_FRAC, INTERIOR_UPLIFT_MOD_AMP, INTERIOR_FLOOR, PLATEAU_BOOST,
PLATEAU_START_BASE, MOUNTAIN_BOOST_FRAC,
FOLD_BELT_MULT, CRATON_TECTONIC_MULT, BASIN_TECTONIC_MULT,
SHELF_NARROW_BASE, SHELF_WIDE_BASE, SLOPE_WIDTH_BASE,
SHELF_DEPTH_START, SHELF_DEPTH_RANGE, SLOPE_DEPTH_RANGE,
ABYSS_BASE, ABYSS_NOISE_AMP, OCEAN_FLOOR_CLAMP,
RIDGE_HALF_WIDTH_BASE as RIDGE_HW_BASE, RIDGE_UPLIFT_NOISE, RIDGE_UPLIFT_BASE,
FRACTURE_HALF_WIDTH_BASE, FRACTURE_DEPTH,
TRENCH_BASE_DEPTH, TRENCH_STRESS_DEPTH,
COAST_ROUGHEN_BASE, COAST_PASSIVE_FREQ, COAST_ACTIVE_FREQ,
COAST_PASSIVE_AMP, COAST_ACTIVE_AMP,
COAST_WARP_PASSIVE_REACH, COAST_WARP_ACTIVE_REACH, COAST_WARP_AMT,
COAST_SUBDUCT_SUP_LOW, COAST_SUBDUCT_SUP_RANGE,
ISLAND_DIST_BASE, ISLAND_FREQ, ISLAND_THRESHOLD_BASE, ISLAND_THRESHOLD_STRESS,
ISLAND_BUMP_AMP, ISLAND_PEAK_FLOOR, ISLAND_SUBDUCT_MAX, MAX_OCEAN_ARC_ELEV,
ARC_DIST_BASE, ARC_PEAK_DIST_BASE, ARC_SIGMA_BASE_VAL, ARC_THRESHOLD,
ARC_UPLIFT_AMP, ARC_SUBDUCT_THRESH,
VOLC_MIN_SPACING, VOLC_SIGMA_BASE, VOLC_HEIGHT_BASE,
VOLC_HEIGHT_VAR_BASE, VOLC_HEIGHT_VAR_RANGE,
VOLC_SIGMA_VAR_BASE, VOLC_SIGMA_VAR_RANGE, VOLC_SUBDUCT_THRESH,
LIP_SIGMA, LIP_HEIGHT,
NUM_HOTSPOTS, CHAIN_LENGTH, CHAIN_DECAY, CHAIN_SPACING,
DOME_SIGMA, DOME_STRENGTH, SWELL_SIGMA_MULT, SWELL_STR_MULT,
DOME_OCEAN_BOOST, DOME_PEAK_THRESH_SIGMA, DOME_SWELL_THRESH_SIGMA,
DOME_DRIFT_STRETCH, DOME_RIFT_BOOST, DOME_CALDERA_SIGMA_FRAC,
DOME_CALDERA_DEPTH_FRAC, DOME_CALDERA_STRENGTH_MIN,
DOME_AGE_BROADENING, DOME_SHAPE_WARP_FREQ, DOME_SHAPE_WARP_AMP,
DOME_SHAPE_WARP_DETAIL_FREQ, DOME_SHAPE_WARP_DETAIL_AMP,
DOME_TEXTURE_BASE_WEIGHT, DOME_TEXTURE_DETAIL_WEIGHT,
DOME_TEXTURE_ACTIVE_MIN, DOME_TEXTURE_ACTIVE_MAX,
DOME_TEXTURE_AGE_MIN_SHIFT, DOME_TEXTURE_AGE_MAX_SHIFT,
PEAK_COMPRESS_POWER, ISOSTATIC_K, HYPS_BLEND,
HYPS_LOW_BREAK, HYPS_MID_BREAK, HYPS_LOW_ELEV_FRAC, HYPS_MID_ELEV_FRAC,
HYPS_HIGH_POWER, FILL_LEVEL,
PLAIN_TARGET, PLAIN_SUPPRESSION_STRENGTH,
} from './terrain-config.js';
// ----------------------------------------------------------------
// Euler-pole velocity helper
// ----------------------------------------------------------------
export function plateVelocityAt(plateVec, plateId, x, y, z) {
const pv = plateVec[plateId];
const px = pv.pole[0], py = pv.pole[1], pz = pv.pole[2];
const omega = pv.omega;
// v = omega * cross(pole, position)
return [
omega * (py * z - pz * y),
omega * (pz * x - px * z),
omega * (px * y - py * x)
];
}
// ----------------------------------------------------------------
// Collision detection
// ----------------------------------------------------------------
export function findCollisions(mesh, r_xyz, plateIsOcean, r_plate, plateVec, plateDensity, noise) {
const dt = COLLISION_DT_BASE / Math.max(1, Math.sqrt(mesh.numRegions / COLLISION_DT_REF_REGIONS));
const { numRegions } = mesh;
const mountain_r = new Set();
const coastline_r = new Set();
const ocean_r = new Set();
const r_stress = new Float32Array(numRegions);
const r_stressDir = new Float32Array(numRegions * 3);
const r_subductFactor = new Float32Array(numRegions).fill(0.5);
const r_boundaryType = new Int8Array(numRegions);
const r_bothOcean = new Uint8Array(numRegions);
const r_hasOcean = new Uint8Array(numRegions);
const { adjOffset, adjList } = mesh;
const plateOcean = {};
for (const pid of plateIsOcean) plateOcean[pid] = 1;
const pairIntensityCache = new Map();
function getPairIntensity(a, b) {
const lo = Math.min(a, b), hi = Math.max(a, b);
const key = lo * 1000003 + hi;
if (pairIntensityCache.has(key)) return pairIntensityCache.get(key);
let h = ((lo * 16807) ^ (hi * 48271)) >>> 0;
h = (((h >> 16) ^ h) * 0x45d9f3b) >>> 0;
const val = PAIR_INTENSITY_BASE + (h % 10001) / 10000;
pairIntensityCache.set(key, val);
return val;
}
const undulOctaves = numRegions > 200000 ? 2 : 3;
for (let r = 0; r < numRegions; r++) {
const myPlate = r_plate[r];
let bestComp = -Infinity;
let best = -1;
let bestNormalComp = 0;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (myPlate !== r_plate[nb]) {
const ri3 = 3*r, ni3 = 3*nb;
const dx = r_xyz[ri3]-r_xyz[ni3], dy = r_xyz[ri3+1]-r_xyz[ni3+1], dz = r_xyz[ri3+2]-r_xyz[ni3+2];
const dBefore = Math.sqrt(dx*dx+dy*dy+dz*dz);
const v1 = plateVelocityAt(plateVec, myPlate, r_xyz[ri3], r_xyz[ri3+1], r_xyz[ri3+2]);
const v2 = plateVelocityAt(plateVec, r_plate[nb], r_xyz[ni3], r_xyz[ni3+1], r_xyz[ni3+2]);
const ax = r_xyz[ri3] +v1[0]*dt, ay = r_xyz[ri3+1] +v1[1]*dt, az = r_xyz[ri3+2] +v1[2]*dt;
const bx = r_xyz[ni3] +v2[0]*dt, by = r_xyz[ni3+1] +v2[1]*dt, bz = r_xyz[ni3+2] +v2[2]*dt;
const adx = ax-bx, ady = ay-by, adz = az-bz;
const dAfter = Math.sqrt(adx*adx+ady*ady+adz*adz);
const comp = dBefore - dAfter;
if (comp > bestComp) {
bestComp = comp; best = nb;
const rvx = v1[0]-v2[0], rvy = v1[1]-v2[1], rvz = v1[2]-v2[2];
const bnLen = dBefore || 1;
bestNormalComp = -(rvx*dx + rvy*dy + rvz*dz) / bnLen;
}
}
}
if (best !== -1) {
const collided = bestComp > COLLISION_THRESHOLD * dt;
const rOcean = plateOcean[myPlate] || 0;
const nOcean = plateOcean[r_plate[best]] || 0;
r_bothOcean[r] = (rOcean && nOcean) ? 1 : 0;
r_hasOcean[r] = (rOcean || nOcean) ? 1 : 0;
const thresh = BOUNDARY_TYPE_THRESH_FACTOR * dt;
if (bestNormalComp > thresh) r_boundaryType[r] = 1;
else if (bestNormalComp < -thresh) r_boundaryType[r] = 2;
else r_boundaryType[r] = 3;
if (collided) {
r_stress[r] = (bestComp / dt) * getPairIntensity(myPlate, r_plate[best]);
// Stress direction: points from boundary neighbor toward this cell
// (the direction compression pushes material into the plate interior)
const sdx = r_xyz[3*r] - r_xyz[3*best], sdy = r_xyz[3*r+1] - r_xyz[3*best+1], sdz = r_xyz[3*r+2] - r_xyz[3*best+2];
const sdLen = Math.sqrt(sdx*sdx + sdy*sdy + sdz*sdz) || 1e-10;
r_stressDir[3*r] = sdx / sdLen;
r_stressDir[3*r+1] = sdy / sdLen;
r_stressDir[3*r+2] = sdz / sdLen;
}
const myDensity = plateDensity[myPlate];
const nbDensity = plateDensity[r_plate[best]];
const densityDiff = myDensity - nbDensity;
const baseFactor = SUBDUCT_FACTOR_BASE + SUBDUCT_FACTOR_BASE * Math.tanh(densityDiff * SUBDUCT_FACTOR_TANH_SCALE);
const densityContrast = Math.abs(densityDiff);
const undulationStrength = Math.exp(-densityContrast * SUBDUCT_UNDULATION_DENSITY_DECAY);
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
const undulation = noise.fbm(x * SUBDUCT_UNDULATION_FREQ, y * SUBDUCT_UNDULATION_FREQ, z * SUBDUCT_UNDULATION_FREQ, undulOctaves) * SUBDUCT_UNDULATION_AMP * undulationStrength;
r_subductFactor[r] = Math.max(0, Math.min(1, baseFactor + undulation));
if (rOcean && nOcean) {
(collided ? coastline_r : ocean_r).add(r);
} else if (!rOcean && !nOcean) {
if (collided) {
if (r_subductFactor[r] < SUBDUCT_THRESHOLD) mountain_r.add(r);
else coastline_r.add(r);
}
} else {
(collided ? mountain_r : coastline_r).add(r);
}
}
}
return { mountain_r, coastline_r, ocean_r, r_stress, r_stressDir, r_subductFactor, r_boundaryType, r_bothOcean, r_hasOcean };
}
// ----------------------------------------------------------------
// Stress propagation — frontier-based BFS diffusion inward
// ----------------------------------------------------------------
export function propagateStress(mesh, r_stress, r_stressDir, r_subductFactor, r_plate, r_xyz, plateIsOcean, decayFactor, subductDecayFactor, numPasses) {
const { adjOffset, adjList } = mesh;
const plateOcean = {};
for (const pid of plateIsOcean) plateOcean[pid] = 1;
let frontier = [];
for (let r = 0; r < mesh.numRegions; r++) {
if (r_stress[r] > STRESS_PROPAGATE_MIN) frontier.push(r);
}
for (let pass = 0; pass < numPasses && frontier.length > 0; pass++) {
const nextFrontier = [];
for (let fi = 0; fi < frontier.length; fi++) {
const r = frontier[fi];
const plate = r_plate[r];
if (plateOcean[plate]) continue;
const sf = r_subductFactor[r];
const effDecay = sf > SUBDUCT_FACTOR_BASE ? subductDecayFactor : decayFactor;
const basePropagate = r_stress[r] * effDecay;
if (basePropagate < STRESS_PROPAGATE_CUTOFF) continue;
// Stress direction at this cell
const sdx = r_stressDir[3*r], sdy = r_stressDir[3*r+1], sdz = r_stressDir[3*r+2];
const hasDir = (sdx !== 0 || sdy !== 0 || sdz !== 0);
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (r_plate[nb] !== plate) continue;
let propagated = basePropagate;
if (hasDir) {
// Direction from r toward neighbor nb
const tdx = r_xyz[3*nb] - r_xyz[3*r];
const tdy = r_xyz[3*nb+1] - r_xyz[3*r+1];
const tdz = r_xyz[3*nb+2] - r_xyz[3*r+2];
const tLen = Math.sqrt(tdx*tdx + tdy*tdy + tdz*tdz) || 1e-10;
// Alignment: 1 = propagating in stress direction, -1 = backward
const alignment = (sdx * tdx + sdy * tdy + sdz * tdz) / tLen;
// Directional factor: aligned propagation strong, perpendicular moderate, backward weak
const dirFactor = Math.max(STRESS_DIR_FACTOR_MIN, STRESS_DIR_FACTOR_BASE + STRESS_DIR_FACTOR_SCALE * alignment);
propagated *= dirFactor;
}
if (propagated > r_stress[nb]) {
r_stress[nb] = propagated;
r_subductFactor[nb] = sf;
nextFrontier.push(nb);
if (hasDir) {
// Propagate direction: blend parent direction with travel direction
// so the stress flow curves naturally through the plate
const tdx = r_xyz[3*nb] - r_xyz[3*r];
const tdy = r_xyz[3*nb+1] - r_xyz[3*r+1];
const tdz = r_xyz[3*nb+2] - r_xyz[3*r+2];
const tLen = Math.sqrt(tdx*tdx + tdy*tdy + tdz*tdz) || 1e-10;
const bx = sdx * STRESS_DIR_BLEND_PARENT + (tdx / tLen) * STRESS_DIR_BLEND_TRAVEL;
const by = sdy * STRESS_DIR_BLEND_PARENT + (tdy / tLen) * STRESS_DIR_BLEND_TRAVEL;
const bz = sdz * STRESS_DIR_BLEND_PARENT + (tdz / tLen) * STRESS_DIR_BLEND_TRAVEL;
const bLen = Math.sqrt(bx*bx + by*by + bz*bz) || 1e-10;
r_stressDir[3*nb] = bx / bLen;
r_stressDir[3*nb+1] = by / bLen;
r_stressDir[3*nb+2] = bz / bLen;
}
}
}
}
frontier = nextFrontier;
}
// Post-BFS direction smoothing: relax each stressed cell's direction toward
// the stress-weighted average of its neighbors. Cleans up artifacts where
// competing stress paths from different boundary segments meet.
for (let pass = 0; pass < STRESS_DIR_SMOOTH_PASSES; pass++) {
for (let r = 0; r < mesh.numRegions; r++) {
if (r_stress[r] < STRESS_PROPAGATE_MIN) continue;
const plate = r_plate[r];
if (plateOcean[plate]) continue;
let ax = 0, ay = 0, az = 0, totalW = 0;
// Self contribution (strong anchor to prevent drift)
const selfW = r_stress[r] * STRESS_DIR_SELF_WEIGHT;
ax += r_stressDir[3*r] * selfW;
ay += r_stressDir[3*r+1] * selfW;
az += r_stressDir[3*r+2] * selfW;
totalW += selfW;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (r_plate[nb] !== plate || r_stress[nb] < STRESS_PROPAGATE_MIN) continue;
const w = r_stress[nb];
ax += r_stressDir[3*nb] * w;
ay += r_stressDir[3*nb+1] * w;
az += r_stressDir[3*nb+2] * w;
totalW += w;
}
if (totalW > 0) {
const len = Math.sqrt(ax*ax + ay*ay + az*az) || 1e-10;
r_stressDir[3*r] = ax / len;
r_stressDir[3*r+1] = ay / len;
r_stressDir[3*r+2] = az / len;
}
}
}
}
// ----------------------------------------------------------------
// Distance field — random-fill outward from seeds, stopping at barriers
// ----------------------------------------------------------------
export function assignDistanceField(mesh, seeds, stops, seed) {
const randInt = makeRandInt(seed);
const { numRegions } = mesh;
const r_dist = new Float32Array(numRegions).fill(Infinity);
const isStop = new Uint8Array(numRegions);
for (const r of stops) isStop[r] = 1;
const queue = [];
for (const r of seeds) { queue.push(r); r_dist[r] = 0; }
const { adjOffset, adjList } = mesh;
for (let qi = 0; qi < queue.length; qi++) {
const pos = qi + randInt(queue.length - qi);
const cur = queue[pos];
queue[pos] = queue[qi];
for (let ni = adjOffset[cur], niEnd = adjOffset[cur + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (r_dist[nb] === Infinity && !isStop[nb]) {
r_dist[nb] = r_dist[cur] + 1;
queue.push(nb);
}
}
}
return r_dist;
}
// BFS-expand a set of regions outward by `steps` rings
export function expandRegions(mesh, regions, steps) {
if (steps <= 0) return regions;
const expanded = new Set(regions);
let frontier = [...regions];
const { adjOffset, adjList } = mesh;
for (let i = 0; i < steps; i++) {
const next = [];
for (const r of frontier) {
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
const nb = adjList[j];
if (!expanded.has(nb)) {
expanded.add(nb);
next.push(nb);
}
}
}
frontier = next;
}
return expanded;
}
// ----------------------------------------------------------------
// Elevation assignment — combines distance fields, stress, noise
// ----------------------------------------------------------------
export function assignElevation(mesh, r_xyz, plateIsOcean, r_plate, plateVec, plateSeeds, noise, noiseMag, seed, spread, plateDensity, superPlateData) {
const { numRegions } = mesh;
const r_elevation = new Float32Array(numRegions);
const _timing = [];
let _t0 = performance.now();
// Debug layers — track each component's contribution
const dl_base = new Float32Array(numRegions);
const dl_tectonic = new Float32Array(numRegions);
const dl_noise = new Float32Array(numRegions);
const dl_interior = new Float32Array(numRegions);
const dl_coastal = new Float32Array(numRegions);
const dl_ocean = new Float32Array(numRegions);
const dl_hotspot = new Float32Array(numRegions);
const dl_tecActivity = new Float32Array(numRegions);
const dl_margins = new Float32Array(numRegions);
const dl_backArc = new Float32Array(numRegions);
const dl_foldRidge = new Float32Array(numRegions);
const dl_orogenicPower = new Float32Array(numRegions);
// --- Small-plate collisions (always computed) ---
const smallCol = findCollisions(mesh, r_xyz, plateIsOcean, r_plate, plateVec, plateDensity, noise);
// --- Super-plate collisions (when available) ---
const hasSuperPlates = superPlateData != null;
let superCol = null;
if (hasSuperPlates) {
superCol = findCollisions(mesh, r_xyz, superPlateData.superPlateIsOcean,
superPlateData.r_superPlate, superPlateData.superPlateVec,
superPlateData.superPlateDensity, noise);
}
_timing.push({ stage: 'Collisions' + (hasSuperPlates ? ' (dual)' : ''), ms: performance.now() - _t0 }); _t0 = performance.now();
// --- Blend collision results ---
let mountain_r, coastline_r, ocean_r, r_stress, r_stressDir, r_subductFactor, r_boundaryType, r_bothOcean, r_hasOcean;
// Blend weights for dual-layer orogeny (small plates vs super plates).
// All collision outputs use these same weights for consistency.
// SMALL_W and SUPER_W imported from terrain-config.js
if (!hasSuperPlates) {
({ mountain_r, coastline_r, ocean_r, r_stress, r_stressDir, r_subductFactor, r_boundaryType, r_bothOcean, r_hasOcean } = smallCol);
} else {
// Seed sets: union of both layers (small plates add noise everywhere)
mountain_r = new Set([...superCol.mountain_r, ...(SMALL_W > 0 ? smallCol.mountain_r : [])]);
ocean_r = new Set([...superCol.ocean_r, ...(SMALL_W > 0 ? smallCol.ocean_r : [])]);
coastline_r = new Set();
for (const r of superCol.coastline_r) {
if (!mountain_r.has(r)) coastline_r.add(r);
}
if (SMALL_W > 0) {
for (const r of smallCol.coastline_r) {
if (!mountain_r.has(r) && !coastline_r.has(r)) coastline_r.add(r);
}
}
// Stress: smooth ramp — small-plate contribution scales up from SMALL_W²
// (isolated, far from super plate orogeny) to full SMALL_W (where super
// plate stress is strong). This lets small plates add texture everywhere
// while keeping super plates as the dominant pattern.
r_stress = new Float32Array(numRegions);
{
let maxSuperStress = 0;
for (let r = 0; r < numRegions; r++) {
if (superCol.r_stress[r] > maxSuperStress) maxSuperStress = superCol.r_stress[r];
}
const invMax = maxSuperStress > 1e-6 ? 1 / maxSuperStress : 0;
for (let r = 0; r < numRegions; r++) {
const sS = smallCol.r_stress[r], sP = superCol.r_stress[r];
// proximity: 0 = no super plate stress, 1 = at max super plate stress
const proximity = Math.min(1, sP * invMax * 3);
// Smooth ramp: SMALL_W² at proximity=0 → SMALL_W at proximity=1
const effectiveSmallW = SMALL_W * (SMALL_W + (1 - SMALL_W) * proximity);
r_stress[r] = effectiveSmallW * sS + SUPER_W * sP;
}
}
// SubductFactor: same blend weights
r_subductFactor = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const wS = SMALL_W * smallCol.r_stress[r], wP = SUPER_W * superCol.r_stress[r];
const total = wS + wP;
if (total > 1e-6) {
r_subductFactor[r] = (wS * smallCol.r_subductFactor[r] + wP * superCol.r_subductFactor[r]) / total;
} else {
r_subductFactor[r] = SMALL_W * smallCol.r_subductFactor[r] + SUPER_W * superCol.r_subductFactor[r];
}
}
// BoundaryType: weighted by blended stress
r_boundaryType = new Int8Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const wS = SMALL_W * smallCol.r_stress[r];
const wP = SUPER_W * superCol.r_stress[r];
r_boundaryType[r] = wS > wP
? smallCol.r_boundaryType[r]
: superCol.r_boundaryType[r];
}
// Stress direction: stress-weighted blend of both layers
r_stressDir = new Float32Array(numRegions * 3);
for (let r = 0; r < numRegions; r++) {
const wS = SMALL_W * smallCol.r_stress[r], wP = SUPER_W * superCol.r_stress[r];
const total = wS + wP;
if (total > 1e-6) {
const bx = wS * smallCol.r_stressDir[3*r] + wP * superCol.r_stressDir[3*r];
const by = wS * smallCol.r_stressDir[3*r+1] + wP * superCol.r_stressDir[3*r+1];
const bz = wS * smallCol.r_stressDir[3*r+2] + wP * superCol.r_stressDir[3*r+2];
const bLen = Math.sqrt(bx*bx + by*by + bz*bz) || 1e-10;
r_stressDir[3*r] = bx / bLen;
r_stressDir[3*r+1] = by / bLen;
r_stressDir[3*r+2] = bz / bLen;
}
}
// Boolean flags: blend-aware (only include a layer's flags if it has weight)
r_bothOcean = new Uint8Array(numRegions);
r_hasOcean = new Uint8Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const bSmall = SMALL_W > 0 ? smallCol.r_bothOcean[r] : 0;
const bSuper = SUPER_W > 0 ? superCol.r_bothOcean[r] : 0;
r_bothOcean[r] = bSmall | bSuper;
const hSmall = SMALL_W > 0 ? smallCol.r_hasOcean[r] : 0;
const hSuper = SUPER_W > 0 ? superCol.r_hasOcean[r] : 0;
r_hasOcean[r] = hSmall | hSuper;
}
}
// Propagate stress inward
const scaleFactor = Math.sqrt(numRegions / COLLISION_DT_REF_REGIONS);
const baseDecay = STRESS_DECAY_BASE + spread * STRESS_DECAY_SPREAD_FACTOR;
const decayFactor = Math.pow(baseDecay, 1 / scaleFactor);
const subductBaseDecay = baseDecay * STRESS_SUBDUCT_DECAY_MULT;
const subductDecayFactor = Math.pow(subductBaseDecay, 1 / scaleFactor);
const numPasses = Math.max(1, Math.round(spread * STRESS_PASSES_PER_SPREAD * scaleFactor));
if (!hasSuperPlates) {
propagateStress(mesh, r_stress, r_stressDir, r_subductFactor, r_plate, r_xyz, plateIsOcean, decayFactor, subductDecayFactor, numPasses);
} else {
// Dual stress propagation: propagate each layer within its own plates, then blend
const smallStress = new Float32Array(smallCol.r_stress);
const smallDir = new Float32Array(smallCol.r_stressDir);
const smallSubduct = new Float32Array(smallCol.r_subductFactor);
propagateStress(mesh, smallStress, smallDir, smallSubduct, r_plate, r_xyz, plateIsOcean, decayFactor, subductDecayFactor, numPasses);
const superStress = new Float32Array(superCol.r_stress);
const superDir = new Float32Array(superCol.r_stressDir);
const superSubduct = new Float32Array(superCol.r_subductFactor);
propagateStress(mesh, superStress, superDir, superSubduct, superPlateData.r_superPlate, r_xyz, superPlateData.superPlateIsOcean, decayFactor, subductDecayFactor, numPasses);
// Blend propagated stress using same SMALL_W / SUPER_W weights
for (let r = 0; r < numRegions; r++) {
r_stress[r] = SMALL_W * smallStress[r] + SUPER_W * superStress[r];
}
// Update subduct factor from propagated values using same weights
for (let r = 0; r < numRegions; r++) {
const wS = SMALL_W * smallStress[r], wP = SUPER_W * superStress[r];
const total = wS + wP;
if (total > 1e-6) {
r_subductFactor[r] = (wS * smallSubduct[r] + wP * superSubduct[r]) / total;
}
}
}
_timing.push({ stage: 'Stress propagation' + (hasSuperPlates ? ' (dual)' : ''), ms: performance.now() - _t0 }); _t0 = performance.now();
// Plate interiors are also seeds — find a representative hi-res region
// per plate (plate seed IDs are coarse mesh indices that may not correspond
// to regions of that plate on the hi-res mesh).
{
const plateRep = {};
for (let r = 0; r < numRegions; r++) {
const pid = r_plate[r];
if (plateRep[pid] === undefined && !mountain_r.has(r) && !coastline_r.has(r) && !ocean_r.has(r)) {
plateRep[pid] = r;
}
}
for (const pid of plateSeeds) {
const rep = plateRep[pid];
if (rep !== undefined) {
(plateIsOcean.has(pid) ? ocean_r : coastline_r).add(rep);
}
}
}
const stress_mountain_r = new Set();
for (const r of mountain_r) {
if (r_subductFactor[r] < SUBDUCT_THRESHOLD) stress_mountain_r.add(r);
}
const stop_r = new Set([...stress_mountain_r, ...coastline_r, ...ocean_r]);
// Three distance fields
const dist_mountain = assignDistanceField(mesh, stress_mountain_r, ocean_r, seed + 1);
const dist_ocean = assignDistanceField(mesh, ocean_r, coastline_r, seed + 2);
const dist_coastline = assignDistanceField(mesh, coastline_r, stop_r, seed + 3);
// Coast distance for ocean floor features
const r_isOcean = new Uint8Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (plateIsOcean.has(r_plate[r])) r_isOcean[r] = 1;
}
const coastSeeds = new Set();
const { adjOffset, adjList } = mesh;
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r]) {
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
if (r_isOcean[adjList[ni]]) { coastSeeds.add(adjList[ni]); break; }
}
}
}
const dist_coast = assignDistanceField(mesh, coastSeeds, new Set(), seed + 4);
// Land-only coast distance: seeds are land cells adjacent to ocean,
// propagates only through land (ocean cells are barriers).
const landCoastSeeds = new Set();
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) continue;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
if (r_isOcean[adjList[ni]]) { landCoastSeeds.add(r); break; }
}
}
const oceanBarriers = new Set();
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) oceanBarriers.add(r);
}
const dist_coast_land = assignDistanceField(mesh, landCoastSeeds, oceanBarriers, seed + 5);
_timing.push({ stage: 'Distance fields (6x BFS)', ms: performance.now() - _t0 }); _t0 = performance.now();
// Fixed band width for interior uplift (in BFS cells), scaled by resolution.
// Tune INTERIOR_BAND_BASE to control how many cells deep the transition is.
const interiorBand = Math.max(4, Math.round(INTERIOR_BAND_BASE * scaleFactor));
// How far mountain-building collisions influence interior uplift (BFS cells).
// Uses dist_mountain (already computed from stress_mountain_r seeds, blocked by ocean).
// Only major convergent boundaries drive plateau formation, not every minor boundary.
const tectonicReach = Math.max(6, Math.round(TECTONIC_REACH_BASE * scaleFactor));
// Use 95th-percentile of non-zero stress for normalization.
// Euler-pole velocity varies across plates, creating outlier high-stress
// cells that would skew a raw-max normalizer and make typical mountains shorter.
let maxStress = 0;
const stressVals = [];
for (let r = 0; r < numRegions; r++) {
if (r_stress[r] > STRESS_PROPAGATE_MIN) stressVals.push(r_stress[r]);
if (r_stress[r] > maxStress) maxStress = r_stress[r];
}
if (stressVals.length > 0) {
stressVals.sort((a, b) => a - b);
maxStress = stressVals[Math.min(stressVals.length - 1, Math.floor(stressVals.length * STRESS_PERCENTILE))];
}
if (maxStress < 0.01) maxStress = 1;
const eps = 1e-3;
const warpScale = WARP_SCALE;
const warpOctaves = numRegions > 200000 ? 2 : 3;
// Plateau zone: overriding-side cells beyond this distance from mountain front
const plateauStart = Math.max(2, Math.round(PLATEAU_START_BASE * scaleFactor));
// ---- Coast-boundary BFS (hoisted for use by ocean floor + coastal roughening) ----
// Identifies each cell's nearest coastline boundary and propagates boundary type info.
const coastBdry = [];
for (let r = 0; r < numRegions; r++) {
const rOc = r_isOcean[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
if (r_isOcean[adjList[ni]] !== rOc) {
coastBdry.push(r);
break;
}
}
}
const maxCD = Math.max(8, Math.round(COAST_BFS_WIDTH_BASE * scaleFactor));
const dBdry = new Float32Array(numRegions);
dBdry.fill(maxCD + 1);
const coastStressMax = new Float32Array(numRegions);
const coastSubductMax = new Float32Array(numRegions);
const coastConvergent = new Uint8Array(numRegions);
for (let i = 0; i < coastBdry.length; i++) {
const r = coastBdry[i];
dBdry[r] = 0;
coastStressMax[r] = Math.min(1, r_stress[r] / maxStress);
coastSubductMax[r] = r_subductFactor[r];
coastConvergent[r] = r_boundaryType[r] === 1 ? 1 : 0;
}
{
let qi = 0;
while (qi < coastBdry.length) {
const r = coastBdry[qi++];
const nd = dBdry[r] + 1;
if (nd > maxCD) continue;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < dBdry[nr]) {
dBdry[nr] = nd;
coastStressMax[nr] = coastStressMax[r];
coastSubductMax[nr] = coastSubductMax[r];
coastConvergent[nr] = coastConvergent[r];
coastBdry.push(nr);
} else if (nd === dBdry[nr] && coastStressMax[r] > coastStressMax[nr]) {
coastStressMax[nr] = coastStressMax[r];
coastSubductMax[nr] = coastSubductMax[r];
coastConvergent[nr] = coastConvergent[r];
}
}
}
}
// ---- Rift BFS (structured graben profile for divergent continent-continent boundaries) ----
const riftHalfWidth = Math.max(2, Math.round(RIFT_HALF_WIDTH_BASE * scaleFactor));
const riftDist = new Float32Array(numRegions);
riftDist.fill(Infinity);
const riftSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 2 && !r_hasOcean[r]) {
riftSeeds.push(r);
riftDist[r] = 0;
}
}
{
let qi = 0;
while (qi < riftSeeds.length) {
const r = riftSeeds[qi++];
const nd = riftDist[r] + 1;
if (nd > riftHalfWidth) continue;
const plate = r_plate[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < riftDist[nr] && r_plate[nr] === plate && !r_isOcean[nr]) {
riftDist[nr] = nd;
riftSeeds.push(nr);
}
}
}
}
const riftNoise = new SimplexNoise(seed + 419);
_timing.push({ stage: 'Coast boundary + rift BFS', ms: performance.now() - _t0 }); _t0 = performance.now();
// ---- Mid-ocean ridge BFS (wider ridge feature from divergent ocean-ocean boundaries) ----
const ridgeHalfWidth = Math.max(2, Math.round(RIDGE_HW_BASE * scaleFactor));
const ridgeDist = new Float32Array(numRegions);
ridgeDist.fill(Infinity);
const ridgeSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 2 && r_bothOcean[r]) {
ridgeSeeds.push(r);
ridgeDist[r] = 0;
}
}
{
let qi = 0;
while (qi < ridgeSeeds.length) {
const r = ridgeSeeds[qi++];
const nd = ridgeDist[r] + 1;
if (nd > ridgeHalfWidth) continue;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < ridgeDist[nr] && r_isOcean[nr]) {
ridgeDist[nr] = nd;
ridgeSeeds.push(nr);
}
}
}
}
// ---- Oceanic fracture zone BFS (transform ocean-ocean boundaries) ----
const fractureHalfWidth = Math.max(2, Math.round(FRACTURE_HALF_WIDTH_BASE * scaleFactor));
const fractureDist = new Float32Array(numRegions);
fractureDist.fill(Infinity);
const fractureSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 3 && r_bothOcean[r]) {
fractureSeeds.push(r);
fractureDist[r] = 0;
}
}
{
let qi = 0;
while (qi < fractureSeeds.length) {
const r = fractureSeeds[qi++];
const nd = fractureDist[r] + 1;
if (nd > fractureHalfWidth) continue;
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < fractureDist[nr] && r_isOcean[nr]) {
fractureDist[nr] = nd;
fractureSeeds.push(nr);
}
}
}
}
// ---- Back-arc basin BFS (depression behind subduction zones) ----
// Seeds: overriding side of any convergent boundary involving ocean.
// Excludes continent-continent collisions (r_hasOcean === 0).
const baStart = Math.max(1, Math.round(BACK_ARC_START_BASE * scaleFactor));
const baPeak = Math.max(2, Math.round(BACK_ARC_PEAK_BASE * scaleFactor));
const baEnd = Math.max(3, Math.round(BACK_ARC_END_BASE * scaleFactor));
const backArcDist = new Float32Array(numRegions);
backArcDist.fill(Infinity);
const backArcStress = new Float32Array(numRegions);
const backArcSeeds = [];
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 1 && r_hasOcean[r] && r_subductFactor[r] < BACK_ARC_SUBDUCT_THRESH) {
backArcSeeds.push(r);
backArcDist[r] = 0;
backArcStress[r] = Math.min(1, r_stress[r] / maxStress);
}
}
{
let qi = 0;
while (qi < backArcSeeds.length) {
const r = backArcSeeds[qi++];
const nd = backArcDist[r] + 1;
if (nd > baEnd) continue;
const plate = r_plate[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < backArcDist[nr] && r_plate[nr] === plate) {
backArcDist[nr] = nd;
backArcStress[nr] = backArcStress[r];
backArcSeeds.push(nr);
}
}
}
}
_timing.push({ stage: 'Ridge/fracture/back-arc BFS', ms: performance.now() - _t0 }); _t0 = performance.now();
// Convergent boundary ridgeline parameters (scale-invariant)
const ridgeSigmaBase = Math.max(2, Math.round(RIDGE_SIGMA_BASE_CFG * scaleFactor));
const ridgePeakShift = Math.max(1, Math.round(RIDGE_PEAK_SHIFT_BASE * scaleFactor));
const ridgeExtent = Math.max(4, Math.round(RIDGE_EXTENT_BASE * scaleFactor));
// Separate noise instance for fold ridges (decorrelated from main noise)
const foldNoise = new SimplexNoise(seed + 557);
// Per-plate random starting height for land plates (mean -25m, stddev 12.5m).
// In normalized elevation units: -25m ≈ -0.0025, 12.5m ≈ 0.00125.
const plateBaseHeight = {};
{
const plateHeightRng = makeRng(seed + 777);
// Box-Muller transform for normal distribution
for (const pid of plateSeeds) {
if (!plateIsOcean.has(pid)) {
const u1 = plateHeightRng();
const u2 = plateHeightRng();
const normal = Math.sqrt(-2 * Math.log(u1 || 1e-10)) * Math.cos(2 * Math.PI * u2);
plateBaseHeight[pid] = PLATE_BASE_HEIGHT_MEAN + normal * PLATE_BASE_HEIGHT_STDDEV;
}
}
}
// Basin vs Shield classification: low-frequency noise field.
// 0.0 = cratonic shield (resistant, higher), 1.0 = sedimentary basin (low, flat).
const r_basinFactor = new Float32Array(numRegions);
{
const basinNoise = new SimplexNoise(seed + 661);
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) continue;
const bx = r_xyz[3 * r], by = r_xyz[3 * r + 1], bz = r_xyz[3 * r + 2];
const raw = basinNoise.fbm(bx * BASIN_FREQ + 7.3, by * BASIN_FREQ + 3.1, bz * BASIN_FREQ + 9.7, 2, 0.5);
r_basinFactor[r] = Math.max(0, Math.min(1, BASIN_FACTOR_BIAS + raw * BASIN_FACTOR_SCALE));
}
}
for (let r = 0; r < numRegions; r++) {
const isOceanPlate = r_isOcean[r];
// Asymmetric mountain profiles: shift ridge peak toward subducting side.
// sf > 0.5 (subducting): inflated distance → lower base → steeper drop-off
// sf < 0.5 (overriding): compressed distance → higher base → gentler slope
// sf = 0.5 (neutral / far from boundary): no effect
const sfAsym = r_subductFactor[r];
const asymmetry = 1.0 + (sfAsym - 0.5) * ASYMMETRY_FACTOR;
const a = dist_mountain[r] * asymmetry + eps;
const b = dist_ocean[r] + eps;
const c = dist_coastline[r] + eps;
if (a === Infinity && b === Infinity) {
r_elevation[r] = 0.1 * BASE_SCALE;
} else {
r_elevation[r] = (1/a - 1/b) / (1/a + 1/b + 1/c) * BASE_SCALE;
}
dl_base[r] = r_elevation[r];
const stressNorm = Math.min(1, r_stress[r] / maxStress);
const btype = r_boundaryType[r];
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
const wx = x + warpScale * noise.fbm(x + 5.3, y + 1.7, z + 3.1, warpOctaves);
const wy = y + warpScale * noise.fbm(x + 8.1, y + 2.9, z + 7.3, warpOctaves);
const wz = z + warpScale * noise.fbm(x + 1.4, y + 6.2, z + 4.8, warpOctaves);
// Orogenic power: single-octave noise for blocky, high-contrast
// zones. Computed for ALL regions so the debug layer shows the
// full noise field (not skewed by ocean zeros).
const rawOro = noise.noise3D(x * OROGENIC_FREQ + 33.7, y * OROGENIC_FREQ + 11.2, z * OROGENIC_FREQ + 22.9);
const shaped = rawOro >= 0 ? Math.sqrt(rawOro) : -Math.sqrt(-rawOro);
const orogenicPower = Math.max(0, Math.min(1, 0.5 + 0.5 * shaped));
dl_orogenicPower[r] = orogenicPower - 0.5; // center on 0 for diverging debug color scale
if (!isOceanPlate) {
const sf = r_subductFactor[r];
// Apply per-plate random starting height
const pid = r_plate[r];
if (plateBaseHeight[pid] !== undefined) {
r_elevation[r] += plateBaseHeight[pid];
}
const elevBefore = r_elevation[r];
if (sf > 0.5 && r_elevation[r] > 0) {
const suppression = (sf - 0.5) * 2;
r_elevation[r] *= 1 - suppression * SUBDUCTING_SUPPRESSION;
}
if (stressNorm > 0.01) {
const stressMag = stressNorm * stressNorm * STRESS_MAG_SCALE * orogenicPower;
const uplift = stressMag * (1 - sf);
const depress = stressMag * STRESS_DEPRESS_FRAC * sf;
const heightVar = STRESS_HEIGHT_VAR_BASE + STRESS_HEIGHT_VAR_SCALE * noise.fbm(x * 8 + 13.7, y * 8 + 9.2, z * 8 + 4.5, 3);
r_elevation[r] += (uplift - depress) * heightVar;
}
// Foreland basin: distance-aware depression ahead of orogen on overriding side.
// Deepest near the mountain front, tapering away into the continental interior.
{
const dMtn = dist_mountain[r];
if (dMtn !== Infinity && stressNorm < FORELAND_STRESS_THRESH && sf < BACK_ARC_SUBDUCT_THRESH) {
const forelandWidth = Math.max(2, Math.round(interiorBand * FORELAND_WIDTH_FRAC));
if (dMtn < forelandWidth) {
const t = dMtn / forelandWidth;
const peakPos = FORELAND_PEAK_POS; // deepest at 20% of width from orogen
let profile;
if (t < peakPos) {
const s = t / peakPos;
profile = s * s * (3 - 2 * s); // smoothstep ramp to max depth
} else {
const s = (t - peakPos) / (1 - peakPos);
profile = 1 - s * s * (3 - 2 * s); // smoothstep taper back to zero
}
const stressFade = 1 - Math.min(1, stressNorm / FORELAND_STRESS_THRESH);
// Basins near orogens deepen more (foreland basin connection)
const basinDeepening = FORELAND_BASIN_DEEPENING_BASE + FORELAND_BASIN_DEEPENING_SCALE * r_basinFactor[r];
r_elevation[r] -= FORELAND_BASIN_DEPTH * profile * stressFade * basinDeepening;
}
}
}
// Rift valley: structured graben profile replaces flat depression.
// Uses pre-computed riftDist BFS from divergent continent-continent boundaries.
{
const rd = riftDist[r];
if (rd !== Infinity) {
const floorEnd = Math.max(1, Math.round(RIFT_FLOOR_MULT * scaleFactor));
const shoulderEnd = Math.max(2, Math.round(RIFT_SHOULDER_MULT * scaleFactor));
let riftEffect = 0;
if (rd <= 0.5) {
// Rift axis: deepest depression
riftEffect = RIFT_AXIS_DEPTH;
// Volcanic ridged noise along axis
riftEffect += riftNoise.ridgedFbm(x * 8, y * 8, z * 8, 3) * RIFT_AXIS_VOLCANIC_AMP;
} else if (rd <= floorEnd) {
// Rift floor: still depressed, with volcanic texture
const t = rd / floorEnd;
riftEffect = RIFT_FLOOR_DEPTH * (1 - t * RIFT_FLOOR_TAPER);
riftEffect += riftNoise.ridgedFbm(x * 8, y * 8, z * 8, 3) * RIFT_FLOOR_VOLCANIC_AMP * (1 - t);
} else if (rd <= shoulderEnd) {
// Rift shoulders: modest uplift flanking the graben
const t = (rd - floorEnd) / (shoulderEnd - floorEnd);
riftEffect = RIFT_SHOULDER_UPLIFT * (1 - t);
} else if (riftHalfWidth > shoulderEnd) {
// Smooth fadeout to ambient
const t = (rd - shoulderEnd) / (riftHalfWidth - shoulderEnd);
const fadeT = Math.min(1, t);
const fade = fadeT * fadeT * (3 - 2 * fadeT); // smoothstep
riftEffect = RIFT_SHOULDER_UPLIFT * (1 - fade) * RIFT_FADEOUT_RESIDUAL; // tiny residual shoulder
}
r_elevation[r] += riftEffect;
}
}
// Back-arc basin: bell-shaped depression behind subduction zones.
// Uses pre-computed backArcDist BFS from convergent boundaries with ocean involvement.
// Suppressed when another mountain-building collision is closer than the subduction source.
{
const bad = backArcDist[r];
if (bad !== Infinity && bad >= baStart) {
// Orogeny suppression: if dist_mountain < backArcDist, another collision is closer
const dMtn = dist_mountain[r];
const orogenyFactor = (dMtn !== Infinity && dMtn < bad)
? Math.max(0, dMtn / bad)
: 1.0;
let baEffect = 0;
if (bad <= baPeak) {
const t = (bad - baStart) / Math.max(1, baPeak - baStart);
const s = t * t * (3 - 2 * t);
baEffect = -BACK_ARC_DEPTH * backArcStress[r] * s * orogenyFactor;
} else if (bad <= baEnd) {
const t = (bad - baPeak) / Math.max(1, baEnd - baPeak);
const s = t * t * (3 - 2 * t);
baEffect = -BACK_ARC_DEPTH * backArcStress[r] * (1 - s) * orogenyFactor;
}
r_elevation[r] += baEffect;
dl_backArc[r] = baEffect;
}
}
// Convergent boundary ridgeline: Gaussian peak near the collision front.
// Symmetric when plates have equal density (sf ≈ 0.5), biased heavily
// toward the overriding side when one plate subducts.
{
const dMtnRidge = dist_mountain[r];
if (dMtnRidge !== Infinity && dMtnRidge < ridgeExtent && stressNorm > 0.01) {
const sfAsymmetry = Math.abs(sf - 0.5) * 2; // 0 = equal, 1 = full subduction
// Signed distance: positive on subducting side, negative on overriding
const signedDist = sf > 0.5 ? dMtnRidge : -dMtnRidge;
// Peak shifts toward overriding side with subduction asymmetry
const peakPos = -sfAsymmetry * ridgePeakShift;
const dFromPeak = signedDist - peakPos;
// Modulate range width by local convergence rate and along-strike noise
const stressWidthMod = RIDGE_STRESS_WIDTH_BASE + RIDGE_STRESS_WIDTH_SCALE * stressNorm;
const widthNoise = 1.0 + RIDGE_WIDTH_NOISE_AMP * foldNoise.fbm(x * 3 + 44.1, y * 3 + 22.7, z * 3 + 11.3, 2);
const localRidgeSigma = ridgeSigmaBase * stressWidthMod * widthNoise;
// Asymmetric sigma: narrow on subducting side, wider on overriding
const sigma = dFromPeak > 0
? localRidgeSigma * (1 - sfAsymmetry * RIDGE_ASYM_SUBDUCT_NARROW) // subducting: tighter
: localRidgeSigma * (1 + sfAsymmetry * RIDGE_ASYM_OVERRIDE_WIDEN); // overriding: broader
const safeSigma = Math.max(0.5, sigma);
const gauss = Math.exp(-0.5 * (dFromPeak / safeSigma) ** 2);
// Along-strike height variation: low-frequency noise creates
// peaks and saddles along the ridge rather than a uniform wall.
// Uses a different noise offset from width noise for independence.
const ridgeHeightNoise = RIDGE_HEIGHT_VAR_BASE + RIDGE_HEIGHT_VAR_SCALE * foldNoise.fbm(x * RIDGE_HEIGHT_VAR_FREQ + 17.3, y * RIDGE_HEIGHT_VAR_FREQ + 31.7, z * RIDGE_HEIGHT_VAR_FREQ + 8.9, 2);
r_elevation[r] += gauss * stressNorm * RIDGE_STRENGTH * ridgeHeightNoise;
}
}
dl_tectonic[r] = r_elevation[r] - elevBefore;
// Compute tectonic activity early — used by noise, interior, and plateau sections.
// Uses dist_mountain: distance from mountain-building collisions only.
// Plates with no major collisions get tectonicActivity ≈ 0 (cratons).
// Subducting side (sf > 0.5) falls off much faster to keep tectonic
// influence concentrated near the boundary.
const dMtn = dist_mountain[r];
const effReach = sf > 0.5
? tectonicReach * (SUBDUCTING_REACH_MIN + SUBDUCTING_REACH_RANGE * (1 - sf)) // ~35-50% reach on subducting side
: tectonicReach;
const rawProximity = (dMtn === Infinity || dMtn >= effReach)
? 0
: (1 - dMtn / effReach);
const tectonicActivity = Math.max(stressNorm, rawProximity * rawProximity * rawProximity);
dl_tecActivity[r] = tectonicActivity;
// Fold ridge noise: directional ridges parallel to plate boundaries.
// Uses dot(pos, Euler pole) as fold coordinate — creates concentric
// arcs around the pole, perpendicular to plate motion.
let foldContrib = 0;
{
const pid = r_plate[r];
const pv = plateVec[pid];
// Elevation-driven folds only kick in on substantial terrain (> 0.15 ≈ 1.5km),
// so flat coasts and lowlands stay smooth while mountains get ridge texture
const elevFoldDrive = Math.max(0, (r_elevation[r] - FOLD_ELEV_THRESHOLD) * FOLD_ELEV_SCALE);
const clampedElevDrive = Math.min(1, elevFoldDrive);
const foldActivity = Math.max(tectonicActivity, clampedElevDrive * SUBDUCT_FACTOR_BASE);
if (pv && foldActivity > 0.01) {
const ppx = pv.pole[0], ppy = pv.pole[1], ppz = pv.pole[2];
// Fold coordinate: project velocity onto tangent plane, then use
// the component along velocity direction. Ridges form perpendicular
// to plate motion (parallel to the collision boundary).
// velocity = omega * cross(pole, pos)
const vx = ppy * z - ppz * y;
const vy = ppz * x - ppx * z;
const vz = ppx * y - ppy * x;
// u = dot(pos, velocity_direction) — oscillates along motion direction,
// creating ridges perpendicular to it
const vLen = Math.sqrt(vx * vx + vy * vy + vz * vz) || 1e-10;
const u = (x * vx + y * vy + z * vz) / vLen;
// Mild phase warp for natural irregularity
// (arbitrary domain-shift offsets decorrelate from other noise channels)
const phaseWarp = foldNoise.fbm(x * 3 + 55.3, y * 3 + 33.7, z * 3 + 17.2, 2) * FOLD_PHASE_WARP_AMP;
const FOLD_FREQ = FOLD_FREQ_PRIMARY;
const phase = (u + phaseWarp) * FOLD_FREQ * Math.PI;
// Sharp ridges with valleys: 1-|sin| peaks at zero-crossings
const ridge = 1 - Math.abs(Math.sin(phase));
// Center around zero (mean of 1-|sin| ≈ 0.36)
const foldCentered = ridge - FOLD_MEAN_OFFSET;
// Amplitude varies along ridges to break uniformity
const ampMod = FOLD_AMP_MOD_BASE + FOLD_AMP_MOD_SCALE * foldNoise.fbm(x * 4 + 88.1, y * 4 + 62.3, z * 4 + 41.7, 2);
// Scale by elevation — folds only carve into significant terrain,
// flat coasts and lowlands stay smooth even near boundaries
const elevBoost = Math.max(0, r_elevation[r] - FOLD_ELEV_BOOST_OFFSET) * FOLD_ELEV_BOOST_SCALE;
// Strong near orogeny (squared falloff), suppressed on subducting side
const foldAmp = foldActivity * Math.max(0, 1 - sf * FOLD_SF_SUPPRESS) * noiseMag * FOLD_NOISE_MAG_SCALE * elevBoost;
foldContrib = foldCentered * foldAmp * ampMod;
// Secondary fold layer: 2.5x frequency, noisier, slightly cross-grain
// Perpendicular tangent direction: cross(pos, velocity)
const cx = y * vz - z * vy, cy = z * vx - x * vz, cz = x * vy - y * vx;
const cLen = Math.sqrt(cx * cx + cy * cy + cz * cz) || 1e-10;
// Mix: mostly along velocity (0.85) with slight cross-grain (0.15)
const u2 = (FOLD_SECONDARY_ALONG * (x * vx + y * vy + z * vz) / vLen
+ FOLD_SECONDARY_CROSS * (x * cx + y * cy + z * cz) / cLen);
const phaseWarp2 = foldNoise.fbm(x * 5 + 71.2, y * 5 + 19.8, z * 5 + 43.6, 3) * FOLD_PHASE_WARP2_AMP;
const FOLD_FREQ_2 = FOLD_FREQ_SECONDARY; // 2.5x primary
const phase2 = (u2 + phaseWarp2) * FOLD_FREQ_2 * Math.PI;
const ridge2 = 1 - Math.abs(Math.sin(phase2));
const fold2Centered = ridge2 - FOLD_MEAN_OFFSET;
const ampMod2 = FOLD_AMP_MOD2_BASE + FOLD_AMP_MOD2_SCALE * foldNoise.fbm(x * 6 + 33.4, y * 6 + 77.1, z * 6 + 52.9, 2);
foldContrib += fold2Centered * foldAmp * ampMod2 * FOLD_SECONDARY_AMP;
r_elevation[r] += foldContrib;
dl_foldRidge[r] = foldContrib;
}
}
// Plateau zone: overriding side, behind collision front, with tectonic influence
const isPlateauZone = sf < 0.45 && dMtn !== Infinity && dMtn > plateauStart;
// Terrain-type-aware noise: classify into archetypes and modulate noise parameters.
// Fold belts get higher frequency + ridged noise, cratons get smooth low-frequency,
// sedimentary basins get suppressed amplitude (flat plains).
const isFoldBelt = Math.min(1, stressNorm * FOLD_BELT_MULT);
const isCraton = Math.max(0, 1 - tectonicActivity * CRATON_TECTONIC_MULT) * (1 - r_basinFactor[r]);
const isBasin = r_basinFactor[r] * Math.max(0, 1 - tectonicActivity * BASIN_TECTONIC_MULT);
// Fold belts get 1x-2.5x frequency for tighter, more chaotic terrain
const foldFreqMult = 1.0 + isFoldBelt * FOLD_FREQ_MULT_SCALE;
// Basin/craton amplitude suppression
const basinAmpSuppress = 1.0 - isBasin * BASIN_AMP_SUPPRESS; // basins: 30-100% amplitude
const cratonAmpSuppress = 1.0 - isCraton * CRATON_AMP_SUPPRESS; // cratons: 60-100% amplitude
const terrainTypeSuppress = basinAmpSuppress * cratonAmpSuppress;
const blend = isFoldBelt;
const smoothNoise = noise.fbm(wx * foldFreqMult, wy * foldFreqMult, wz * foldFreqMult) * noiseMag;
const ridgedNoise = noise.ridgedFbm(wx * foldFreqMult, wy * foldFreqMult, wz * foldFreqMult) * noiseMag * RIDGED_NOISE_AMP;
const noiseVal = smoothNoise * (1 - blend) + ridgedNoise * blend;
// Higher-freq detail layer: zero-mean, half strength
const detailNoise = noise.fbm(wx * DETAIL_NOISE_FREQ_MULT * foldFreqMult + 22.1, wy * DETAIL_NOISE_FREQ_MULT * foldFreqMult + 6.8, wz * DETAIL_NOISE_FREQ_MULT * foldFreqMult + 15.4, 4, 0.5) * noiseMag * DETAIL_NOISE_AMP;
// Scale noise amplitude by tectonic activity: rough near collisions, smooth in quiet interiors
const noiseActivity = Math.min(1, stressNorm * NOISE_ACTIVITY_SCALE);
// Plateau flatness: additionally suppress noise on overriding side behind collisions
const plateauSuppress = isPlateauZone
? Math.max(PLATEAU_SUPPRESS_MIN, 1 - tectonicActivity * PLATEAU_SUPPRESS_SCALE)
: 1.0;
const noiseScale = (NOISE_BASE_SCALE + NOISE_ACTIVITY_CONTRIB * noiseActivity) * plateauSuppress * terrainTypeSuppress;
// Fine detail layer: 8x frequency, quarter strength, half-dampened.
// Uses sqrt of noiseScale so it retains texture in quiet interiors where other noise is suppressed.
const fineNoise = noise.fbm(wx * FINE_NOISE_FREQ_MULT + 41.7, wy * FINE_NOISE_FREQ_MULT + 13.2, wz * FINE_NOISE_FREQ_MULT + 27.9, 3, 0.5) * noiseMag * FINE_NOISE_AMP;
const fineScale = Math.sqrt(noiseScale);
const totalNoise = (noiseVal + detailNoise) * noiseScale + fineNoise * fineScale;
r_elevation[r] += totalNoise;
dl_noise[r] = totalNoise;
// Mountain dissection: high-frequency zero-mean noise on tall terrain.
// Carves valleys and sharpens ridges in large mountain masses.
// Activates above ~1.2 km equivalent (0.12 in normalized elevation,
// where 1.0 ≈ 10 km Everest-scale).
{
const DISSECT_THRESHOLD = DISSECT_THRESHOLD_CFG;
const currentElev = r_elevation[r];
if (currentElev > DISSECT_THRESHOLD) {
const elevExcess = currentElev - DISSECT_THRESHOLD;
const dissectVal = noise.fbm(
wx * 16 + 71.3, wy * 16 + 44.8, wz * 16 + 29.1, 3, 0.5
);
// Elevation-driven dissection: tall terrain always gets valley carving,
// with stress adding extra intensity near boundaries
const elevDrive = Math.min(1, Math.sqrt(elevExcess) * DISSECT_ELEV_SCALE);
const dissectAmp = Math.sqrt(elevExcess) * Math.max(elevDrive, stressNorm) * noiseMag * DISSECT_AMP;
const dissectContrib = dissectVal * dissectAmp;
r_elevation[r] += dissectContrib;
dl_noise[r] += dissectContrib;
}
}
// Summit peaks: sparse, sharp spikes along the tallest mountain
// ridges. Uses very high-frequency ridged noise with a high
// threshold so only occasional points jut upward. Base orogeny
// caps around 4-5 km; only these peaks can push toward 6 km.
{
const SUMMIT_THRESHOLD = SUMMIT_THRESHOLD_CFG; // ~2.6 km
const currentElev = r_elevation[r];
if (currentElev > SUMMIT_THRESHOLD && stressNorm > SUMMIT_STRESS_MIN) {
const excess = currentElev - SUMMIT_THRESHOLD;
// Ridged noise at high frequency — sharp, spiky features
const peakNoise = noise.ridgedFbm(
wx * 24 + 91.3, wy * 24 + 55.7, wz * 24 + 38.2, 3, 0.5
);
// Only the highest peaks of the noise create summits
const spike = Math.max(0, peakNoise - SUMMIT_SPIKE_OFFSET);
const peakContrib = spike * excess * Math.max(stressNorm, SUMMIT_STRESS_FLOOR) * 1.0;
r_elevation[r] += peakContrib;
dl_noise[r] += peakContrib;
}
}
// Continental interior uplift: tectonic-aware.
// Collision-backed interiors (plateaus) get higher uplift than quiet cratons.
const lcd = dist_coast_land[r];
if (lcd < Infinity) {
// Mountains make nearby land on the overriding side act more "interior":
// only applies when the mountain is BETWEEN the cell and the coast
// (dMtn < lcd), meaning the cell is behind the orogen, not in front of it.
let mountainBoost = 0;
if (dMtn !== Infinity && sf < BACK_ARC_SUBDUCT_THRESH && dMtn < lcd) {
const proximity = Math.max(0, 1 - dMtn / Math.max(1, tectonicReach));
mountainBoost = proximity * interiorBand * MOUNTAIN_BOOST_FRAC;
}
const effectiveLcd = lcd + mountainBoost;
// Depression: smoothstep over full band (0 → coastal depression at coast)
const tDown = Math.min(effectiveLcd / interiorBand, 1);
const sDown = tDown * tDown * (3 - 2 * tDown);
// Uplift: reaches plateau much sooner (40% of band)
const tUp = Math.min(effectiveLcd / (interiorBand * INTERIOR_UPLIFT_RAMP_FRAC), 1);
const sUp = tUp * tUp * (3 - 2 * tUp);
// Basin/shield modulated uplift: shields higher, basins flatter.
const bf = r_basinFactor[r];
const interiorBase = INTERIOR_BASE_SHIELD * (1 - bf) + INTERIOR_BASE_BASIN * bf;
const interiorUplift = interiorBase + tectonicActivity * INTERIOR_TECTONIC;
// Coastal depression: reduced in basins to prevent pockmarks
// (basins have less uplift to compensate, so depression must also be less)
const coastalDepression = COASTAL_DEPRESSION * (1 - bf * COASTAL_DEPRESSION_BASIN_REDUCE);
const baseBias = coastalDepression * (1 - sDown) + interiorUplift * sUp;
// Low-freq noise modulation: 80%–120% of bias
const mod = 1.0 + INTERIOR_UPLIFT_MOD_AMP * noise.fbm(x * 2 + 19.3, y * 2 + 7.6, z * 2 + 13.1, 2);
const bias = baseBias * mod;
r_elevation[r] += bias;
dl_interior[r] = bias;
}
// Plateau uplift boost: modest extra elevation on overriding side behind collisions
if (isPlateauZone && tectonicActivity > 0.1) {
const plateauBoost = PLATEAU_BOOST * tectonicActivity * (1 - sf);
r_elevation[r] += plateauBoost;
dl_interior[r] += plateauBoost;
}
// Passive margin coastal plain: suppress elevation near passive coasts
// to create broad lowland zones (e.g. US East Coast, Brazil).
{
const coastPlainWidth = Math.max(6, Math.round(COASTAL_PLAIN_WIDTH_BASE * scaleFactor));
const lcd = dist_coast_land[r];
if (lcd < coastPlainWidth && dBdry[r] <= maxCD && !coastConvergent[r]) {
const t = lcd / coastPlainWidth;
const fade = t * t * (3 - 2 * t); // smoothstep: full at coast, zero at width
const suppressionStrength = PLAIN_SUPPRESSION_STRENGTH * (1 - fade);
if (r_elevation[r] > PLAIN_TARGET) {
const excess = r_elevation[r] - PLAIN_TARGET;
const suppression = excess * suppressionStrength;
r_elevation[r] -= suppression;
dl_coastal[r] -= suppression;
}
}
}
// Soft floor: prevent continental interiors from dipping below sea level.
// Near the coast (lcd < 5), allow near-zero elevations for natural shoreline
// gradients. Further inland, enforce a minimum to prevent "great lake" artifacts
// from compounding negative fold/noise/basin contributions.
{
// Interior floor
const floorRamp = Math.min(1, lcd / (5 * scaleFactor));
const minElev = INTERIOR_FLOOR * floorRamp;
if (r_elevation[r] < minElev) r_elevation[r] = minElev;
}
} else {
const dc = dist_coast[r];
// Ocean floor profile: shelf width varies by margin type (active vs passive).
// Active margins (subduction coasts): narrow shelf, steep slope.
// Passive margins (trailing edges): wide shelf, gradual slope.
const isActiveMarginShelf = coastConvergent[r] === 1;
const shelfWidth = isActiveMarginShelf
? Math.max(2, Math.round(SHELF_NARROW_BASE * scaleFactor))
: Math.max(4, Math.round(SHELF_WIDE_BASE * scaleFactor));
const slopeWidth = Math.max(3, Math.round(SLOPE_WIDTH_BASE * scaleFactor));
const totalMargin = shelfWidth + slopeWidth;
let oceanBase;
if (dc < shelfWidth) {
// Continental shelf: -0.08 at coast edge, down to -0.16 at shelf break
oceanBase = SHELF_DEPTH_START - SHELF_DEPTH_RANGE * (dc / shelfWidth);
} else if (dc < totalMargin) {
// Continental slope: -0.16 down to -0.35
oceanBase = (SHELF_DEPTH_START - SHELF_DEPTH_RANGE) - SLOPE_DEPTH_RANGE * ((dc - shelfWidth) / slopeWidth);
} else {
oceanBase = ABYSS_BASE + noise.fbm(x * 2, y * 2, z * 2, 3) * ABYSS_NOISE_AMP;
}
r_elevation[r] = Math.min(r_elevation[r], oceanBase);
dl_ocean[r] = r_elevation[r];
// Margins debug: encode margin type + features
// 0.2=passive, 0.8=active, boosted by ridge/fracture presence
const isActiveMargin = coastConvergent[r] === 1;
dl_margins[r] = isActiveMargin ? 0.8 : 0.2;
if (ridgeDist[r] !== Infinity && ridgeDist[r] <= ridgeHalfWidth) dl_margins[r] = 1.0;
if (fractureDist[r] !== Infinity && fractureDist[r] <= fractureHalfWidth) dl_margins[r] = -0.5;
const elevBeforeOcTec = r_elevation[r];
// Mid-ocean ridge: wider feature with quadratic falloff from divergent boundary
const rd = ridgeDist[r];
if (rd !== Infinity && rd <= ridgeHalfWidth) {
const t = rd / ridgeHalfWidth;
const ridgeFade = (1 - t) * (1 - t);
const ridgeNoise = noise.ridgedFbm(x * 3, y * 3, z * 3, 4);
const ridgeUplift = (RIDGE_UPLIFT_NOISE * ridgeNoise + RIDGE_UPLIFT_BASE) * ridgeFade;
r_elevation[r] += ridgeUplift;
}
// Oceanic fracture zones: linear depressions at transform boundaries
const fd = fractureDist[r];
if (fd !== Infinity && fd <= fractureHalfWidth) {
const ft = fd / fractureHalfWidth;
const fractureFade = 1 - ft;
r_elevation[r] -= FRACTURE_DEPTH * fractureFade;
}
// Trenches at convergent boundaries
if (btype === 1) {
r_elevation[r] -= TRENCH_BASE_DEPTH + TRENCH_STRESS_DEPTH * stressNorm;
}
// Back-arc basin: deepen ocean floor behind subduction zones
{
const bad = backArcDist[r];
if (bad !== Infinity && bad >= baStart) {
const dMtn = dist_mountain[r];
const orogenyFactor = (dMtn !== Infinity && dMtn < bad)
? Math.max(0, dMtn / bad)
: 1.0;
let baEffect = 0;
if (bad <= baPeak) {
const t = (bad - baStart) / Math.max(1, baPeak - baStart);
const s = t * t * (3 - 2 * t);
baEffect = -BACK_ARC_DEPTH * backArcStress[r] * s * orogenyFactor;
} else if (bad <= baEnd) {
const t = (bad - baPeak) / Math.max(1, baEnd - baPeak);
const s = t * t * (3 - 2 * t);
baEffect = -BACK_ARC_DEPTH * backArcStress[r] * (1 - s) * orogenyFactor;
}
r_elevation[r] += baEffect;
dl_backArc[r] = baEffect;
}
}
dl_tectonic[r] = r_elevation[r] - elevBeforeOcTec;
const oceanNoise = noise.fbm(wx, wy, wz) * noiseMag * OCEAN_NOISE_AMP;
r_elevation[r] += oceanNoise;
dl_noise[r] = oceanNoise;
// Clamp ocean-plate cells below sea level after all ocean-branch processing.
// Only intentional features after the main loop (island scatter, island arcs,
// hotspots) may push ocean cells above 0.
if (r_elevation[r] > OCEAN_FLOOR_CLAMP) r_elevation[r] = OCEAN_FLOOR_CLAMP;
}
}
_timing.push({ stage: 'Main elevation loop (land+ocean)', ms: performance.now() - _t0 }); _t0 = performance.now();
// Coastal roughening (uses hoisted coastBdry BFS data: dBdry, coastStressMax, etc.)
{
const coastRoughenDist = Math.max(8, Math.round(COAST_ROUGHEN_BASE * scaleFactor));
const cNoise = new SimplexNoise(seed + 77);
const cNoise2 = new SimplexNoise(seed + 133);
const cNoise3 = new SimplexNoise(seed + 211);
for (let r = 0; r < numRegions; r++) {
if (dBdry[r] > coastRoughenDist) continue;
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
const t = dBdry[r] / coastRoughenDist;
const sn = Math.min(1, Math.max(coastStressMax[r], r_stress[r] / maxStress));
const isSubductingOcean = r_isOcean[r]
&& coastConvergent[r]
&& coastSubductMax[r] > COAST_SUBDUCT_SUP_LOW;
const subSup = isSubductingOcean
? Math.min(1, (coastSubductMax[r] - COAST_SUBDUCT_SUP_LOW) / COAST_SUBDUCT_SUP_RANGE)
: 0;
const elevBeforeCoast = r_elevation[r];
const isPassiveCoast = !coastConvergent[r];
// Layer 1: Coastal fractal noise
// Passive: lower freq + amp → broad bays, gentle peninsulas
// Active: higher freq + amp → rugged, fjord-like
const falloff1 = (1 - t) * (1 - t);
const stressAmp1 = 1 + sn * 5;
const coastFreq = isPassiveCoast ? COAST_PASSIVE_FREQ : COAST_ACTIVE_FREQ;
const coastAmp = isPassiveCoast ? COAST_PASSIVE_AMP : COAST_ACTIVE_AMP;
let n1 = cNoise.fbm(x * coastFreq + 3.7, y * coastFreq + 7.1, z * coastFreq + 2.3, 5, 0.55);
let coastNoise1 = n1 * coastAmp * falloff1 * stressAmp1;
if (subSup > 0 && coastNoise1 > 0) {
coastNoise1 *= (1 - subSup);
}
r_elevation[r] += coastNoise1;
// Layer 3: Coastline-aware domain warping
// Passive: wider influence (warp dies slower). Active: concentrated near coast.
const warpReach = isPassiveCoast ? COAST_WARP_PASSIVE_REACH : COAST_WARP_ACTIVE_REACH;
const falloffW = Math.max(0, 1 - t * warpReach);
if (falloffW > 0) {
const warpAmt = COAST_WARP_AMT * falloffW * (1 + sn * 2);
const dwx = cNoise3.fbm(x * 3 + 11.3, y * 3 + 4.7, z * 3 + 8.2, 3, 0.6) * warpAmt;
const dwy = cNoise3.fbm(x * 3 + 2.9, y * 3 + 9.4, z * 3 + 1.6, 3, 0.6) * warpAmt;
const dwz = cNoise3.fbm(x * 3 + 7.5, y * 3 + 0.3, z * 3 + 5.9, 3, 0.6) * warpAmt;
const origN = noise.fbm(x, y, z) * noiseMag;
const warpN = noise.fbm(x + dwx, y + dwy, z + dwz) * noiseMag;
let warpDelta = (warpN - origN) * falloffW;
if (subSup > 0 && warpDelta > 0) {
warpDelta *= (1 - subSup);
}
r_elevation[r] += warpDelta;
}
// Clamp ocean-plate cells: coastal noise (layers 1 & 3) should roughen
// the coastline but not create false land on ocean plates.
// Only the intentional island scatter below may push ocean cells above 0.
if (r_isOcean[r] && r_elevation[r] > OCEAN_FLOOR_CLAMP) {
r_elevation[r] = OCEAN_FLOOR_CLAMP;
}
// Layer 2: Island scattering — peaked volcanic islands, not flat blobs.
// The bump is shaped by ridged noise so that each island cluster has
// a sharp central peak tapering to steep flanks. Cells that would
// only barely breach sea level stay submerged instead.
if (r_isOcean[r] && dBdry[r] > 0
&& dBdry[r] <= Math.max(4, Math.round(ISLAND_DIST_BASE * scaleFactor))
&& subSup < ISLAND_SUBDUCT_MAX) {
const islandN = cNoise2.fbm(x * ISLAND_FREQ + 5.1, y * ISLAND_FREQ + 9.3, z * ISLAND_FREQ + 2.7, 4, 0.5);
const threshold = ISLAND_THRESHOLD_BASE - sn * ISLAND_THRESHOLD_STRESS;
if (islandN > threshold) {
const excess = (islandN - threshold) / (1 - threshold);
const distFade = 1 - (dBdry[r] / Math.max(4, Math.round(ISLAND_DIST_BASE * scaleFactor)));
// Peak mask: ridged noise produces sparse tall spikes.
const peakN = cNoise2.ridgedFbm(x * ISLAND_FREQ * 2.5 + 31.7, y * ISLAND_FREQ * 2.5 + 17.3, z * ISLAND_FREQ * 2.5 + 8.9, 3, 0.5);
const peakMask = peakN * peakN; // [0, 1] — most values near 0
let bump = excess * excess * ISLAND_BUMP_AMP * (1 + sn * 2) * distFade * peakMask;
bump *= (1 - subSup / ISLAND_SUBDUCT_MAX);
// Only apply if the bump would push clearly above sea level.
// Cells with tiny bumps stay submerged — no flat fringe.
if (bump + r_elevation[r] > ISLAND_PEAK_FLOOR) {
r_elevation[r] += bump;
}
}
}
dl_coastal[r] += r_elevation[r] - elevBeforeCoast;
}
}
_timing.push({ stage: 'Coastal roughening', ms: performance.now() - _t0 }); _t0 = performance.now();
// Island arcs — ocean-ocean convergent boundary uplift
{
const arcNoise = new SimplexNoise(seed + 307);
const maxArcDist = Math.max(5, Math.round(ARC_DIST_BASE * scaleFactor));
const arcSeeds = [];
const arcDist = new Float32Array(numRegions);
arcDist.fill(maxArcDist + 1);
const arcStress = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 1 && r_bothOcean[r] && r_subductFactor[r] < ARC_SUBDUCT_THRESH) {
arcSeeds.push(r);
arcDist[r] = 0;
arcStress[r] = Math.min(1, r_stress[r] / maxStress);
}
}
let aq = 0;
while (aq < arcSeeds.length) {
const r = arcSeeds[aq++];
const nd = arcDist[r] + 1;
if (nd > maxArcDist) continue;
const plate = r_plate[r];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nr = adjList[ni];
if (nd < arcDist[nr] && r_plate[nr] === plate && r_isOcean[nr]) {
arcDist[nr] = nd;
arcStress[nr] = arcStress[r];
arcSeeds.push(nr);
}
}
}
for (let r = 0; r < numRegions; r++) {
const d = arcDist[r];
if (d < 1 || d > maxArcDist) continue;
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
const peakDist = Math.max(ARC_PEAK_DIST_BASE, ARC_PEAK_DIST_BASE * scaleFactor);
const sigma = Math.max(ARC_SIGMA_BASE_VAL, ARC_SIGMA_BASE_VAL * scaleFactor);
const distWeight = Math.exp(-0.5 * ((d - peakDist) / sigma) ** 2);
const n = arcNoise.ridgedFbm(x * 4, y * 4, z * 4, 4, 2.0, 0.5, 1.0);
const threshold = ARC_THRESHOLD;
if (n > threshold) {
const excess = (n - threshold) / (1 - threshold);
let uplift = excess * excess * ARC_UPLIFT_AMP * distWeight * (0.5 + arcStress[r]);
// On ocean plates, cap uplift so arcs form islands with realistic peaks,
// not broad elevated plateaus. The cap allows volcanic-island-scale
// peaks (~500–2000m) while preventing continent-sized uplift.
if (r_isOcean[r]) {
const maxOceanUplift = Math.max(0, -r_elevation[r] + MAX_OCEAN_ARC_ELEV);
uplift = Math.min(uplift, maxOceanUplift);
}
r_elevation[r] += uplift;
dl_coastal[r] += uplift;
}
}
}
_timing.push({ stage: 'Island arcs', ms: performance.now() - _t0 }); _t0 = performance.now();
// Volcanic arcs — discrete stratovolcano edifices along subduction zones.
// Placed at quasi-regular spacing along convergent boundaries where at least
// one plate is oceanic — includes both ocean-continent AND ocean-ocean convergence.
{
const arcVolcNoise = new SimplexNoise(seed + 713);
const VOLC_MIN_SPACING_SQ = VOLC_MIN_SPACING * VOLC_MIN_SPACING;
// Collect candidate cells at convergent boundaries with oceanic involvement (overriding side)
const candidates = [];
for (let r = 0; r < numRegions; r++) {
if (r_boundaryType[r] === 1 && r_hasOcean[r]
&& r_subductFactor[r] < VOLC_SUBDUCT_THRESH) {
const stressLocal = Math.min(1, r_stress[r] / maxStress);
// Score by stress + noise for selection priority
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
const score = stressLocal + 0.3 * arcVolcNoise.noise3D(x * 8, y * 8, z * 8);
candidates.push({ r, x, y, z, score, stressLocal });
}
}
// Sort by score descending — highest-stress cells placed first
candidates.sort((a, b) => b.score - a.score);
// Greedy minimum-distance placement: skip candidates too close to an existing volcano
const volcPositions = [];
for (let ci = 0; ci < candidates.length; ci++) {
const c = candidates[ci];
let tooClose = false;
for (let vi = 0; vi < volcPositions.length; vi++) {
const v = volcPositions[vi];
const dot = c.x * v.x + c.y * v.y + c.z * v.z;
const distSq = Math.max(0, 2 * (1 - dot));
if (distSq < VOLC_MIN_SPACING_SQ) { tooClose = true; break; }
}
if (tooClose) continue;
const heightVar = VOLC_HEIGHT_VAR_BASE + VOLC_HEIGHT_VAR_RANGE * arcVolcNoise.noise3D(c.x * 10, c.y * 10, c.z * 10);
const height = VOLC_HEIGHT_BASE * (0.5 + c.stressLocal) * heightVar;
const sigmaVar = VOLC_SIGMA_VAR_BASE + VOLC_SIGMA_VAR_RANGE * arcVolcNoise.noise3D(c.x * 5 + 17.3, c.y * 5 + 9.1, c.z * 5 + 4.7);
volcPositions.push({ x: c.x, y: c.y, z: c.z, height, sigma: VOLC_SIGMA_BASE * sigmaVar });
}
// Apply Gaussian cones to all nearby regions (land and ocean).
// Ocean cells get volcanic peaks that can breach sea level.
for (let r = 0; r < numRegions; r++) {
const rx = r_xyz[3 * r], ry = r_xyz[3 * r + 1], rz = r_xyz[3 * r + 2];
let volcUplift = 0;
for (let vi = 0; vi < volcPositions.length; vi++) {
const v = volcPositions[vi];
const dot = rx * v.x + ry * v.y + rz * v.z;
if (dot < 0.9999) continue; // early exit: too far (~0.8 deg)
const angleSq = Math.max(0, 2 * (1 - dot));
const invS2 = -0.5 / (v.sigma * v.sigma);
const gauss = Math.exp(angleSq * invS2);
if (gauss > 0.01) volcUplift += v.height * gauss;
}
if (volcUplift > 0.001) {
r_elevation[r] += volcUplift;
dl_hotspot[r] += volcUplift;
}
}
}
// Large Igneous Provinces — broad flood basalt regions on continental interiors.
// Subtle, very wide elevation bumps adding variety to interior topography.
{
const lipRng = makeRng(seed + 821);
const NUM_LIPS = Math.round(1 + lipRng() * 2); // 1-3 LIPs
// LIP_SIGMA ~250km angular radius, LIP_HEIGHT imported from config
const lips = [];
for (let i = 0; i < NUM_LIPS; i++) {
const theta = 2 * Math.PI * lipRng();
const cosPhi = 2 * lipRng() - 1;
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
const lx = sinPhi * Math.cos(theta);
const ly = sinPhi * Math.sin(theta);
const lz = cosPhi;
// Find nearest land interior region
let bestR = -1, bestDot = -2;
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) continue;
if (dist_coast_land[r] < interiorBand * 0.3) continue;
const dot = lx * r_xyz[3 * r] + ly * r_xyz[3 * r + 1] + lz * r_xyz[3 * r + 2];
if (dot > bestDot) { bestDot = dot; bestR = r; }
}
if (bestR >= 0) {
lips.push({
x: r_xyz[3 * bestR], y: r_xyz[3 * bestR + 1], z: r_xyz[3 * bestR + 2],
sigma: LIP_SIGMA * (0.7 + 0.6 * lipRng()),
height: LIP_HEIGHT * (0.5 + lipRng())
});
}
}
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) continue;
const rx = r_xyz[3 * r], ry = r_xyz[3 * r + 1], rz = r_xyz[3 * r + 2];
for (let li = 0; li < lips.length; li++) {
const lip = lips[li];
const dot = rx * lip.x + ry * lip.y + rz * lip.z;
const angleSq = Math.max(0, 2 * (1 - dot));
const invS2 = -0.5 / (lip.sigma * lip.sigma);
const gauss = Math.exp(angleSq * invS2);
if (gauss > 0.01) {
r_elevation[r] += lip.height * gauss;
dl_interior[r] += lip.height * gauss;
}
}
}
}
_timing.push({ stage: 'Volcanic arcs + LIPs', ms: performance.now() - _t0 }); _t0 = performance.now();
// Hotspot volcanism — mantle plumes with drift chains
// Dual-component model: broad thermal swell + volcanic peak with
// domain-warped shape distortion, age-dependent texture, drift
// elongation, summit calderas, and radial rift-zone ridges.
{
// Hotspot constants imported from terrain-config.js
const hsRng = makeRng(seed + 999);
const hsNoise = new SimplexNoise(seed + 501);
const hsNoise2 = new SimplexNoise(seed + 502); // for domain warp
const hsNoise3 = new SimplexNoise(seed + 503); // for rift angles
// Build list of all dome sources
// Each dome carries: position, strength, sigma, chainIndex (0 = active),
// chainLength, drift direction, and tangent frame for rift ridges.
const domes = [];
// Tangent frame for drift elongation & rift ridges
// tU = drift projected onto tangent plane at dome center
// tV = cross(normal, tU) — perpendicular in tangent plane
const buildTangentFrame = (px, py, pz, dx, dy, dz) => {
const dd = dx*px + dy*py + dz*pz;
let ux = dx - dd*px, uy = dy - dd*py, uz = dz - dd*pz;
const uLen = Math.sqrt(ux*ux + uy*uy + uz*uz) || 1;
ux /= uLen; uy /= uLen; uz /= uLen;
const vx = py*uz - pz*uy, vy = pz*ux - px*uz, vz = px*uy - py*ux;
return { ux, uy, uz, vx, vy, vz };
};
// Generate hotspot positions as random points on the unit sphere
// (resolution-independent) then find nearest region for plate lookup.
const hsPosRng = makeRng(seed + 1001);
const findNearestR = (px, py, pz) => {
let bestDot = -2, bestR = 0;
for (let r = 0; r < numRegions; r++) {
const dot = px * r_xyz[3*r] + py * r_xyz[3*r+1] + pz * r_xyz[3*r+2];
if (dot > bestDot) { bestDot = dot; bestR = r; }
}
return bestR;
};
for (let h = 0; h < NUM_HOTSPOTS; h++) {
const hStrength = DOME_STRENGTH * (0.4 + hsRng() * 1.2);
const hSigma = DOME_SIGMA * (0.4 + hsRng() * 1.2);
const hDecay = CHAIN_DECAY + (hsRng() - 0.5) * 0.35;
const hLength = Math.max(3, CHAIN_LENGTH + Math.round((hsRng() - 0.5) * 10));
// Random point on unit sphere — same position regardless of numRegions
const theta = 2 * Math.PI * hsPosRng();
const cosPhiVal = 2 * hsPosRng() - 1;
const sinPhiVal = Math.sqrt(1 - cosPhiVal * cosPhiVal);
const hx = sinPhiVal * Math.cos(theta);
const hy = sinPhiVal * Math.sin(theta);
const hz = cosPhiVal;
const centerR = findNearestR(hx, hy, hz);
const plate = r_plate[centerR];
const pv = plateVec[plate];
if (!pv) continue;
const drift = plateVelocityAt(plateVec, plate, hx, hy, hz);
const driftLen = Math.sqrt(drift[0]*drift[0] + drift[1]*drift[1] + drift[2]*drift[2]);
if (driftLen < 1e-6) continue;
drift[0] /= driftLen; drift[1] /= driftLen; drift[2] /= driftLen;
const isOceanHotspot = plateIsOcean.has(plate);
const oceanBoost = isOceanHotspot ? DOME_OCEAN_BOOST : 1.0;
// Rift angles: 2-3 evenly spaced rifts for active dome, fewer for older
const baseRiftAngle = hsNoise3.noise3D(hx*10, hy*10, hz*10) * Math.PI;
const riftAnglesForDome = (ci, cl) => {
if (ci === 0) return [baseRiftAngle, baseRiftAngle + Math.PI * 0.6, baseRiftAngle - Math.PI * 0.6];
if (ci === 1) return [baseRiftAngle, baseRiftAngle + Math.PI];
if (ci <= Math.floor(cl * 0.4)) return [baseRiftAngle];
return [];
};
// Active dome — store base strength (no ocean boost) for swell;
// peak strength gets ocean boost so it punches through ocean floor.
const frame0 = buildTangentFrame(hx, hy, hz, drift[0], drift[1], drift[2]);
domes.push({
x: hx, y: hy, z: hz,
strength: hStrength * oceanBoost, baseStrength: hStrength,
sigma: hSigma,
chainIndex: 0, chainLength: hLength,
dx: drift[0], dy: drift[1], dz: drift[2],
...frame0,
riftAngles: riftAnglesForDome(0, hLength),
});
// Chain trail
let perpX = drift[1] * hz - drift[2] * hy;
let perpY = drift[2] * hx - drift[0] * hz;
let perpZ = drift[0] * hy - drift[1] * hx;
const perpLen = Math.sqrt(perpX*perpX + perpY*perpY + perpZ*perpZ) || 1;
perpX /= perpLen; perpY /= perpLen; perpZ /= perpLen;
let cx = hx, cy = hy, cz = hz;
let str = hStrength * oceanBoost;
let baseStr = hStrength;
for (let c = 0; c < hLength; c++) {
const ci = c + 1; // chainIndex (0 = active, 1+ = trail)
const decayJitter = hDecay * (0.7 + hsRng() * 0.6);
str *= decayJitter;
baseStr *= decayJitter;
const stepSpacing = CHAIN_SPACING * (0.3 + hsRng() * 1.4);
// #4: age broadening — older domes get wider
const ageBroadening = 1.0 + ci * DOME_AGE_BROADENING;
const stepSigma = hSigma * (0.5 + hsRng() * 1.0) * ageBroadening;
const wobble = (hsRng() - 0.5) * 0.8;
const ddx = -drift[0] + perpX * wobble;
const ddy = -drift[1] + perpY * wobble;
const ddz = -drift[2] + perpZ * wobble;
const dot = ddx * cx + ddy * cy + ddz * cz;
let tx = ddx - dot * cx, ty = ddy - dot * cy, tz = ddz - dot * cz;
const tLen = Math.sqrt(tx*tx + ty*ty + tz*tz);
if (tLen < 1e-6) break;
tx /= tLen; ty /= tLen; tz /= tLen;
const cosA = Math.cos(stepSpacing);
const sinA = Math.sin(stepSpacing);
cx = cx * cosA + tx * sinA;
cy = cy * cosA + ty * sinA;
cz = cz * cosA + tz * sinA;
const nL = Math.sqrt(cx*cx + cy*cy + cz*cz);
cx /= nL; cy /= nL; cz /= nL;
const frameC = buildTangentFrame(cx, cy, cz, drift[0], drift[1], drift[2]);
domes.push({
x: cx, y: cy, z: cz,
strength: str, baseStrength: baseStr,
sigma: stepSigma,
chainIndex: ci, chainLength: hLength,
dx: drift[0], dy: drift[1], dz: drift[2],
...frameC,
riftAngles: riftAnglesForDome(ci, hLength),
});
}
}
// Pre-compute per-dome constants
for (let d = 0; d < domes.length; d++) {
const dm = domes[d];
// Peak threshold — 5.5σ (slight increase from 5 for drift elongation)
dm.cosThreshPeak = Math.cos(dm.sigma * DOME_PEAK_THRESH_SIGMA);
dm.invS2 = -0.5 / (dm.sigma * dm.sigma);
// Swell uses base strength (no ocean boost) so it doesn't
// broadly raise ocean floor — only peaks punch through.
const swSigma = dm.sigma * SWELL_SIGMA_MULT;
dm.swellSigma = swSigma;
dm.swellStrength = dm.baseStrength * SWELL_STR_MULT;
dm.cosThreshSwell = Math.cos(swSigma * DOME_SWELL_THRESH_SIGMA);
dm.invS2Swell = -0.5 / (swSigma * swSigma);
// #5: drift elongation scale factor (1/1.4 for parallel axis)
dm.driftStretch = 1.0 / DOME_DRIFT_STRETCH;
// #6: caldera (only on active or most recent chain member with enough strength)
dm.hasCaldera = dm.chainIndex <= 1 && dm.strength > DOME_CALDERA_STRENGTH_MIN;
dm.calderaSigma = dm.sigma * DOME_CALDERA_SIGMA_FRAC;
dm.calderaDepth = dm.strength * DOME_CALDERA_DEPTH_FRAC;
dm.invS2Caldera = -0.5 / (dm.calderaSigma * dm.calderaSigma);
// Age factor for texture (0 = active = most textured, 1 = oldest = smooth)
dm.ageFactor = dm.chainLength > 0 ? dm.chainIndex / dm.chainLength : 0;
}
// Apply dome uplift to all cells
for (let r = 0; r < numRegions; r++) {
const rx = r_xyz[3*r], ry = r_xyz[3*r+1], rz = r_xyz[3*r+2];
// Two-level early exit:
// Level 1 — check if near any dome's swell radius (cheap, wide)
let nearSwell = false;
let nearPeak = false;
for (let d = 0; d < domes.length; d++) {
const dm = domes[d];
const cdot = dm.x * rx + dm.y * ry + dm.z * rz;
if (cdot > dm.cosThreshSwell) {
nearSwell = true;
if (cdot > dm.cosThreshPeak) { nearPeak = true; break; }
}
}
if (!nearSwell) continue;
// Compute shape warp only if near a peak (expensive noise)
let shapeWarp = 1.0, shapeWarpSq = 1.0;
if (nearPeak) {
// #2: domain-warped fbm for aggressive shape distortion
const hsWarpScale = DOME_SHAPE_WARP_FREQ;
const wx = hsNoise2.fbm(rx * hsWarpScale + 5.1, ry * hsWarpScale + 3.7, rz * hsWarpScale + 9.2, 2, 0.5) * DOME_SHAPE_WARP_AMP;
const wy = hsNoise2.fbm(rx * hsWarpScale + 11.3, ry * hsWarpScale + 7.1, rz * hsWarpScale + 2.9, 2, 0.5) * DOME_SHAPE_WARP_AMP;
const wz = hsNoise2.fbm(rx * hsWarpScale + 1.7, ry * hsWarpScale + 13.5, rz * hsWarpScale + 6.4, 2, 0.5) * DOME_SHAPE_WARP_AMP;
shapeWarp = 1.0 + DOME_SHAPE_WARP_DETAIL_AMP * hsNoise.fbm(
(rx + wx) * DOME_SHAPE_WARP_DETAIL_FREQ + 3.2, (ry + wy) * DOME_SHAPE_WARP_DETAIL_FREQ + 7.8, (rz + wz) * DOME_SHAPE_WARP_DETAIL_FREQ + 1.5, 4, 0.5
);
shapeWarpSq = shapeWarp * shapeWarp;
}
let totalUplift = 0;
let totalSwellUplift = 0;
let weightedAge = 0;
let ageWeightSum = 0;
for (let d = 0; d < domes.length; d++) {
const dm = domes[d];
const dot = dm.x * rx + dm.y * ry + dm.z * rz;
// --- Thermal swell (smooth, no warp) ---
if (dot > dm.cosThreshSwell) {
const swAngleSq = 2 * (1 - dot);
totalSwellUplift += dm.swellStrength * Math.exp(swAngleSq * dm.invS2Swell);
}
// --- Volcanic peak (warped, textured) ---
if (dot < dm.cosThreshPeak) continue;
// #5: drift-direction elongation
// Decompose angular offset into drift-parallel and drift-perpendicular
// by projecting the tangent-plane offset vector onto the dome's frame
const offX = rx - dot * dm.x, offY = ry - dot * dm.y, offZ = rz - dot * dm.z;
const parComp = offX * dm.ux + offY * dm.uy + offZ * dm.uz;
const perpComp = offX * dm.vx + offY * dm.vy + offZ * dm.vz;
// Stretch parallel component (makes profile elongated along drift)
const stretchedParSq = (parComp * dm.driftStretch) * (parComp * dm.driftStretch);
const angleSq = stretchedParSq + perpComp * perpComp;
let gauss = Math.exp(angleSq * shapeWarpSq * dm.invS2);
// #7: radial rift-zone ridges — boost Gaussian along rift angles
if (dm.riftAngles.length > 0 && gauss > 0.01) {
const angle = Math.atan2(perpComp, parComp);
let maxRift = 0;
for (let ri = 0; ri < dm.riftAngles.length; ri++) {
let da = angle - dm.riftAngles[ri];
// Wrap to [-PI, PI]
da = da - Math.round(da / (2 * Math.PI)) * 2 * Math.PI;
const c2 = Math.cos(da);
const riftFactor = c2 * c2 * c2 * c2; // cos^4 for tighter ridges
if (riftFactor > maxRift) maxRift = riftFactor;
}
gauss *= (1.0 + DOME_RIFT_BOOST * maxRift);
}
const peakUplift = dm.strength * gauss;
totalUplift += peakUplift;
// Track weighted age for texture blending
weightedAge += dm.ageFactor * peakUplift;
ageWeightSum += peakUplift;
// #6: summit caldera — subtract a narrow Gaussian at center
if (dm.hasCaldera) {
const calderaGauss = Math.exp(angleSq * dm.invS2Caldera);
totalUplift -= dm.calderaDepth * calderaGauss;
}
}
const combinedUplift = totalSwellUplift + totalUplift;
if (combinedUplift > 0.001) {
// #3: age-dependent volcanic texture
const age = ageWeightSum > 0 ? weightedAge / ageWeightSum : 0;
// Active: dramatic gullies (0.4-1.2); Old: smooth eroded (0.7-1.0)
const texBase = DOME_TEXTURE_BASE_WEIGHT * hsNoise.ridgedFbm(rx * 12, ry * 12, rz * 12, 4, 2.0, 0.5, 1.0);
const texDetail = DOME_TEXTURE_DETAIL_WEIGHT * hsNoise.ridgedFbm(rx * 30, ry * 30, rz * 30, 3, 2.0, 0.5, 1.0);
const texRaw = texBase + texDetail;
// Blend texture range based on age
const texMin = DOME_TEXTURE_ACTIVE_MIN + age * DOME_TEXTURE_AGE_MIN_SHIFT; // 0.4 (active) → 0.7 (old)
const texMax = DOME_TEXTURE_ACTIVE_MAX - age * DOME_TEXTURE_AGE_MAX_SHIFT; // 1.2 (active) → 1.0 (old)
const volc = texMin + (texMax - texMin) * texRaw;
// Apply texture only to peak component; swell is smooth
const uplift = totalSwellUplift + Math.max(0, totalUplift) * volc;
r_elevation[r] += uplift;
dl_hotspot[r] = uplift;
}
}
}
_timing.push({ stage: 'Hotspot volcanism', ms: performance.now() - _t0 }); _t0 = performance.now();
// Compress positive elevations to soften tall peaks
for (let r = 0; r < numRegions; r++) {
if (r_elevation[r] > 0) {
r_elevation[r] = Math.pow(r_elevation[r], PEAK_COMPRESS_POWER);
}
}
_timing.push({ stage: 'Peak compression', ms: performance.now() - _t0 }); _t0 = performance.now();
// Isostatic adjustment: Airy isostasy compresses elevation extremes.
// Tall mountains sink slightly under their own weight, deep basins are buoyed.
{
// Isostatic adjustment
for (let r = 0; r < numRegions; r++) {
const e = r_elevation[r];
r_elevation[r] = e - Math.abs(e) * e * ISOSTATIC_K;
}
}
_timing.push({ stage: 'Isostatic adjustment', ms: performance.now() - _t0 }); _t0 = performance.now();
// Hypsometric curve shaping: remap land elevations toward Earth-like distribution.
// Lots of low-lying land, fewer mid-altitude areas, rare high peaks.
{
const landRegions = [];
for (let r = 0; r < numRegions; r++) {
if (r_elevation[r] > 0) landRegions.push(r);
}
const n = landRegions.length;
if (n > 1) {
landRegions.sort((a, b) => r_elevation[a] - r_elevation[b]);
const minLandElev = r_elevation[landRegions[0]];
const maxLandElev = r_elevation[landRegions[n - 1]];
const range = maxLandElev - minLandElev;
if (range > 0.01) {
// Hypsometric curve shaping
for (let i = 0; i < n; i++) {
const r = landRegions[i];
const rank = i / (n - 1); // 0 to 1 percentile
// Target elevation percentile from hypsometric curve
let targetPct;
if (rank < HYPS_LOW_BREAK) {
// Low plains: 60% of area in bottom 25% of elevation range
targetPct = HYPS_LOW_ELEV_FRAC * (rank / HYPS_LOW_BREAK);
} else if (rank < HYPS_MID_BREAK) {
// Moderate highlands
targetPct = HYPS_LOW_ELEV_FRAC + HYPS_MID_ELEV_FRAC * ((rank - HYPS_LOW_BREAK) / (HYPS_MID_BREAK - HYPS_LOW_BREAK));
} else {
// Mountain peaks: power curve for rare tall peaks
const t = (rank - HYPS_MID_BREAK) / (1 - HYPS_MID_BREAK);
targetPct = (HYPS_LOW_ELEV_FRAC + HYPS_MID_ELEV_FRAC) + (1 - HYPS_LOW_ELEV_FRAC - HYPS_MID_ELEV_FRAC) * Math.pow(t, HYPS_HIGH_POWER);
}
const targetElev = minLandElev + targetPct * range;
r_elevation[r] = r_elevation[r] * (1 - HYPS_BLEND) + targetElev * HYPS_BLEND;
}
}
}
}
_timing.push({ stage: 'Hypsometric curve shaping', ms: performance.now() - _t0 }); _t0 = performance.now();
// Fill interior seas: any below-sea-level land-plate cell that can't reach
// the ocean through a continuous path of below-sea-level cells is an artifact.
// BFS from ocean cells through sub-sea-level terrain; anything not reached gets raised.
{
const visited = new Uint8Array(numRegions);
const queue = [];
// Seed BFS from all ocean-plate cells
for (let r = 0; r < numRegions; r++) {
if (r_isOcean[r]) {
visited[r] = 1;
queue.push(r);
}
}
// Flood through any cell at or below sea level
let qi = 0;
while (qi < queue.length) {
const r = queue[qi++];
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
const nb = adjList[ni];
if (!visited[nb] && r_elevation[nb] <= 0) {
visited[nb] = 1;
queue.push(nb);
}
}
}
// Raise unvisited below-sea-level land-plate cells
// Fill level imported from config
for (let r = 0; r < numRegions; r++) {
if (!r_isOcean[r] && !visited[r] && r_elevation[r] <= 0) {
r_elevation[r] = FILL_LEVEL;
}
}
}
_timing.push({ stage: 'Fill interior seas', ms: performance.now() - _t0 });
const debugLayers = { base: dl_base, tectonic: dl_tectonic, noise: dl_noise, interior: dl_interior, coastal: dl_coastal, ocean: dl_ocean, hotspot: dl_hotspot, tecActivity: dl_tecActivity, margins: dl_margins, backArc: dl_backArc, foldRidge: dl_foldRidge, orogenicPower: dl_orogenicPower, basin: r_basinFactor };
if (hasSuperPlates) {
debugLayers.superPlates = new Float32Array(superPlateData.r_superPlate);
}
return { r_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers, _timing };
}