1999 lines
101 KiB
JavaScript
1999 lines
101 KiB
JavaScript
// 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 };
|
||
}
|