Soft probabilistic peak cap: spread mountain heights, drop the 9000 m plateau

Drift-time peaks used to rail into the hard elevation clamp and flatten into a
9000 m plateau (the clamp lived in step(), erode() AND hydrology(), so erosion
re-flattened them every tick). Replace the hard ceiling with a probabilistic
soft cap: above peakSoftCapStart (7000 m) the chance a tick's uplift "takes"
falls linearly to 0 at peakSoftCapEnd (12000 m); a lost grow roll forfeits the
uplift and shaves a random 0..peakFailDrop (200 m) off. Peaks now spread smoothly
across a height band (strong orogeny reaches ~10-11 km, most cluster lower) with
zero cells pinned at the ceiling.

- The roll is a pure hash of (cellIndex, erodeIter, seed): never touches
  rngState, bit-identical across OpenMP thread counts, and since erodeIter is
  saved (step/erode run 1:1 in drift) F5/F9 resumes bit-identical -- no save bump.
- Drift-only (gated on Planet::drifting) so Phase-1 forming still auto-settles.
- The hard clamp's upper bound now tracks peakSoftCapEnd in all three places
  (step/erode/hydrology); lower -11000 m unchanged.
- New planet.cfg knobs peakSoftCapStart / peakSoftCapEnd / peakFailDrop with
  validation (+ start < end cross-rule). Docs updated (CLAUDE.md, BUILD.md).

Verified headless: smooth 8.5->10.5 km taper, 0 pinned, determinism + exact
resume intact, test_logic + test_biota pass, full app builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-27 22:26:38 +02:00
parent 53e371bb2f
commit 4aacfdabdd
7 changed files with 80 additions and 7 deletions

View File

@ -99,6 +99,9 @@ Phase 1 forms as before; tall mountains grow during Phase-2 drift.
arcFactor 1.4 continental subduction-arc uplift (Andes)
isostaticPersist 0.85 how strongly high crust resists relax (0..<1)
rootScale 2500 m above continentBase where persistence saturates
peakSoftCapStart 7000 m below this a tick's uplift always takes (P=1)
peakSoftCapEnd 12000 m at/above this uplift never takes (P=0); also the elev clamp
peakFailDrop 200 m max random drop when the grow roll loses (drift-only)
ridgeDepth -2500 m shallow elevation of brand-new crust at a ridge
seafloorSubsidence 280 m per sqrt(My): seafloor deepens with crustal age
seafloorSeedAge 80 My initial oceanic age spread at generation

View File

@ -535,9 +535,24 @@ triangles (plates are fixed in phase 1).
toward 1 = ranges barely erode and continents stay high; lower = more dynamic
rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three
boosts are **drift-only** (gated on `Planet::drifting`); in Phase-2 drift the
per-tick `erode()` is what limits their height, so they don't rail the clamp. The
hard clamp `[-11000, 9000]` in `step()` is the Everest-class cap; a few peak
cells may sit there during drift (<2% fine).
per-tick `erode()` is what limits their height, so they don't rail the clamp.
- **Soft peak cap (drift-only) — no 9000 m plateau:** previously the hard
`[-11000, 9000]` clamp (in `step()`, `erode()` *and* `hydrology()`) flattened many
drift-time peaks into a 9000 m plateau. Now growth is **probabilistic** above
`peakSoftCapStart` (7000 m): the chance a tick's positive uplift "takes" falls
linearly to 0 at `peakSoftCapEnd` (12000 m), so peaks spread smoothly across a
height band instead of railing at one ceiling. A **lost** grow roll forfeits that
tick's uplift *and* shaves a random `0..peakFailDrop` (200 m) off, so a peak hovers
near its own height (height now tracks orogeny strength: strong seeds reach
1011 km, most cluster lower). The hard clamp's **upper bound now tracks
`peakSoftCapEnd`** in all three places (just a safety rail; lower 11000 m
unchanged). The roll is a **pure hash of `(cellIndex, erodeIter, seed)`** — never
touches `rngState`, stays bit-identical across OpenMP thread counts, and since
`erodeIter` is saved (step/erode run 1:1 in drift) F5/F9 resumes **bit-identical**
(no save-version bump). **Drift-only** (gated on `drifting`) so Phase-1 forming
still auto-settles. Tune `peakSoftCapStart` / `peakSoftCapEnd` / `peakFailDrop` in
`planet.cfg`. Verified headless: smooth 8.5→10.5 km taper, 0 cells pinned at the
ceiling, determinism + exact resume intact.
- **Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):** `oceanBase` is now the
**deep abyssal floor** (-6000 m, not a flat ocean base) and `ridgeDepth` the
shallow young value (-2500 m); `seafloorSubsidence` (280 m per sqrt(My)) sets how

View File

@ -30,9 +30,11 @@ void Planet::erode(double dtMy) {
}
sErode[i] = delta;
}
// Upper clamp tracks peakSoftCapEnd (matches step()); the soft peak cap, not a
// fixed 9000 m wall here, governs how high mountains stand.
#pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(cells[i].elevation + sErode[i], -11000.0, 9000.0);
cells[i].elevation = std::clamp(cells[i].elevation + sErode[i], -11000.0, cfg.peakSoftCapEnd);
if (++erodeIter % cfg.seaLevelEvery == 0) adjustSeaLevel();
}

View File

@ -98,7 +98,8 @@ void Planet::hydrology(double dtMy) {
}
load[d] += carried; // pass remaining sediment downstream
}
// Upper clamp tracks peakSoftCapEnd (matches step()/erode()).
#pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(cells[i].elevation, -11000.0, 9000.0);
cells[i].elevation = std::clamp(cells[i].elevation, -11000.0, cfg.peakSoftCapEnd);
}

View File

@ -15,6 +15,7 @@
#define CONFIG_FIELDS(D, I, U) \
D(radius) D(seaLevel) D(axialTilt) D(continentBase) D(oceanBase) D(upliftGain) D(relax) \
D(collisionFactor) D(arcFactor) D(isostaticPersist) D(rootScale) \
D(peakSoftCapStart) D(peakSoftCapEnd) D(peakFailDrop) \
D(seafloorSubsidence) D(seafloorSeedAge) \
D(maxDriftSpeed) D(ridgeDepth) D(splitFraction) D(splitProbBase) D(splitProbSlope) \
D(stalemateEps) D(stalemateBoost) D(babyPromoteFrac) D(volcanicLandFrac) \
@ -114,6 +115,9 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.arcFactor, 0.0, 20.0, "arcFactor"));
E(rng(cfg.isostaticPersist, 0.0, 0.95, "isostaticPersist"));
E(rng(cfg.rootScale, 100.0, 20000.0, "rootScale"));
E(rng(cfg.peakSoftCapStart, 0.0, 20000.0, "peakSoftCapStart"));
E(rng(cfg.peakSoftCapEnd, 0.0, 20000.0, "peakSoftCapEnd"));
E(rng(cfg.peakFailDrop, 0.0, 5000.0, "peakFailDrop"));
E(rng(cfg.seafloorSubsidence, 0.0, 2000.0, "seafloorSubsidence"));
E(rng(cfg.seafloorSeedAge, 0.0, 1000.0, "seafloorSeedAge"));
E(rng(cfg.maxDriftSpeed, 0.1, 100.0, "maxDriftSpeed"));
@ -193,6 +197,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
if (cfg.oceanBase >= cfg.continentBase)
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)
bad.push_back("peakSoftCapStart >= peakSoftCapEnd (grow probability must span a band)");
if (bad.empty()) return {};
std::string msg = "Bad config:";

View File

@ -1,6 +1,22 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
// Deterministic, thread-independent value in [0,1). Pure function of (cell, tick,
// seed) -- so the parallel step() loops stay bit-identical for any thread count
// and this never touches Planet::rngState. `salt` selects an independent stream
// (one for the grow roll, one for the drop magnitude). splitmix64 finalizer.
static inline double peakHash(int i, uint64_t tick, uint32_t seed, uint32_t salt) {
uint64_t x = (uint64_t)(uint32_t)i * 0x9E3779B97F4A7C15ull
+ (tick + 1) * 0xD1B54A32D192ED03ull
+ (uint64_t)seed * 0xBF58476D1CE4E5B9ull
+ (uint64_t)salt + 0x123456789ull;
x ^= x >> 30; x *= 0xBF58476D1CE4E5B9ull;
x ^= x >> 27; x *= 0x94D049BB133111EBull;
x ^= x >> 31;
return (double)(x >> 11) * (1.0 / 9007199254740992.0); // 53-bit -> [0,1)
}
// --- Phase 1/2 tectonics: one stress->uplift->relax tick ---------------------
// Data-parallel: every pass writes only its own cell index (double-buffered
@ -138,7 +154,23 @@ double Planet::step() {
relaxEff = cfg.relax;
}
}
cells[i].elevation += delta[i];
double d = delta[i];
// Soft probabilistic peak cap (drift-only): above peakSoftCapStart the
// chance that uplift takes falls linearly to 0 at peakSoftCapEnd, so peaks
// spread across a height band instead of railing at the old hard ceiling.
// A lost roll forfeits this tick's uplift and shaves a random 0..peakFailDrop
// metres off (so a peak hovers near its own height). erodeIter is the tick
// counter (step/erode run 1:1 in drift; it's saved -> exact resume).
if (drifting && d > 0.0 && cells[i].elevation > cfg.peakSoftCapStart) {
double span = cfg.peakSoftCapEnd - cfg.peakSoftCapStart;
double p = span > 0.0 ? (cfg.peakSoftCapEnd - cells[i].elevation) / span : 0.0;
p = std::clamp(p, 0.0, 1.0);
if (peakHash(i, (uint64_t)erodeIter, cfg.seed, 0u) >= p) { // grow roll lost
d = 0.0;
cells[i].elevation -= peakHash(i, (uint64_t)erodeIter, cfg.seed, 1u) * cfg.peakFailDrop;
}
}
cells[i].elevation += d;
cells[i].elevation += (base - cells[i].elevation) * relaxEff;
// (geoAge is crust age in My, advanced by advect() in Phase 2.)
}
@ -151,9 +183,13 @@ double Planet::step() {
for (int nb : cells[i].neighbors) { sum += cells[nb].elevation; ++cnt; }
smoothed[i] = cells[i].elevation * 0.96 + (sum / cnt) * 0.04;
}
// Upper clamp tracks peakSoftCapEnd so the soft cap governs peak heights
// (a fixed 9000 m ceiling would re-flatten everything the soft cap allows);
// it's just a safety rail now. Lower clamp (-11000 m, trenches) is unchanged.
const double elevCeil = cfg.peakSoftCapEnd;
#pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(smoothed[i], -11000.0, 9000.0);
cells[i].elevation = std::clamp(smoothed[i], -11000.0, elevCeil);
// Largest elevation change this tick -> 0 as the world reaches equilibrium.
double maxChange = 0.0;

View File

@ -94,6 +94,16 @@ struct PlanetConfig {
double isostaticPersist = 0.85; // how much high crust resists relax (0..<1)
double rootScale = 2500.0;// m above continentBase where persistence saturates
// --- Soft peak cap (drift-only): spread mountain heights, no hard plateau --
// Above peakSoftCapStart the chance that a tick's uplift "takes" falls linearly
// to 0 at peakSoftCapEnd, so peaks settle across a height band instead of all
// railing at one ceiling. A lost grow roll forfeits that tick's uplift and
// shaves a random 0..peakFailDrop metres off. The hard elevation clamp's upper
// bound tracks peakSoftCapEnd. Active only during drift (forming still settles).
double peakSoftCapStart = 7000.0; // m: below this, uplift always takes (P=1)
double peakSoftCapEnd = 12000.0; // m: at/above this, uplift never takes (P=0)
double peakFailDrop = 200.0; // m: max random drop when the grow roll loses
// --- Plate drift (Phase 2) ----------------------------------------------
double maxDriftSpeed = 20.0; // cm/year; fastest plate (Earth is 1-10)
double ridgeDepth = -2500.0; // m, elevation of brand-new crust at a ridge