C++/raylib semi-realistic fantasy/sci-fi planet generator on a fixed icosphere grid (Eulerian: properties flow over fixed cells). World-creation stages: tectonics, continental drift & erosion, hydrology (rivers/lakes), climate (temperature + orographic precipitation), and biome classification. Engine in src/sim (raylib-free, headless-testable), viewer in src/render. See CLAUDE.md and docs/ (design-notes.md, fauna-flora-plan.md = next step). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
521 lines
35 KiB
Markdown
521 lines
35 KiB
Markdown
# CLAUDE.md
|
||
|
||
Guidance for Claude Code when working in this repository.
|
||
|
||
## Project
|
||
|
||
A C++/raylib simulation/game for creating semi-realistic fantasy & sci-fi
|
||
worlds/planets. Built in phases. Between phases the user can edit the world
|
||
or trigger events (meteor impact, magical events, sci-fi terraforming).
|
||
|
||
**Core principle:** the planet geometry is FIXED. Cells (vertices/faces) never
|
||
move. Only their *properties* flow over the fixed grid (Eulerian, not
|
||
Lagrangian). This keeps the data structure stable across all phases and makes
|
||
erosion, climate and drift far simpler to implement later.
|
||
|
||
## Roadmap
|
||
|
||
The project has two big arcs. **World Creation** builds a plausible planet through a
|
||
set of geological/environmental stages that **overlap and run together on a geological
|
||
clock** (My) — they are not strict sequential "phases" (the code keeps `phase*` names
|
||
internally for save/config compatibility, but think of them as continuous stages). Once
|
||
a world is "done", the long-term goal is a separate **Live World** mode that runs the
|
||
finished planet at a *much* slower, real-time-ish clock (hours/days/weeks/months) with
|
||
dynamic weather and life.
|
||
|
||
**World Creation (geological clock, mostly done):**
|
||
- **Tectonics & landmass** *(done)* — icosphere geometry, plate assignment, boundary
|
||
stress forming mountains (convergent) and trenches/rifts (divergent). The initial
|
||
"forming" pass settles to isostatic equilibrium, then plate motion continues.
|
||
- **Continental drift & erosion** *(done)* — real plate motion (cm/yr, My), the plate
|
||
lifecycle (fission, stalemate kick, spreading-born plates, merging), diffusive
|
||
erosion + a sea-level controller (~30% land), taller persistent mountains
|
||
(collision + arc + isostatic persistence), seafloor aging→depth. `planet.cfg` +
|
||
`planet.save`. See [[phase2-design-direction]].
|
||
- **Hydrology** *(done)* — rivers, lakes and fluvial erosion as a macro drainage network
|
||
(depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power
|
||
incision). Runs at a finer timestep alongside continuing drift. See [[phase3-hydrology]].
|
||
- **Climate** *(done)* — continuous per-cell temperature + orographic precipitation
|
||
(`Planet::computeClimate`); runs from forming onward (a live "base climate" that
|
||
updates as terrain changes). Color modes `6`/`7`.
|
||
- **Biomes** *(done)* — per-cell `Cell.biome` (13 biomes incl. polar Ice) from elevation
|
||
+ the climate fields (`Planet::classifyBiomes`), color mode `5`, saved per cell.
|
||
- **Fauna & flora** *(next, planned — see `docs/fauna-flora-plan.md`)* — derived
|
||
carrying-capacity densities: vegetation (flora) + a herbivore/carnivore food chain (fauna),
|
||
computed from the climate fields each tick (the living/evolving ecosystem is reserved for
|
||
Live World). Other follow-ups: feed precipitation into hydrology rainfall; seasons (obliquity).
|
||
|
||
> Durable design context (module layout, save format, climate/biome model, conventions)
|
||
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
|
||
> travel with the repo.
|
||
|
||
**Live World (future):** with planet creation finished, run the world at a slow real-time
|
||
scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living
|
||
**ecosystems / civilization** evolving in real time. This is a separate large effort;
|
||
the fixed-grid Eulerian model + the climate fields are the groundwork for it.
|
||
|
||
## Current state
|
||
|
||
Working and verified (logic tested headless):
|
||
- Icosphere level 5 → 10242 cells, ~223 km/cell, topologically correct
|
||
(exactly 12 degree-5 vertices, rest degree-6).
|
||
- Plate flood-fill, drift as rotation on the sphere, boundary-stress uplift.
|
||
- Relief builds *gradually* toward an isostatic equilibrium (no clamp-rail
|
||
saturation): after ~40 ticks graded mountain belts (>2000 m) and deep
|
||
subduction trenches (<-6000 m), 0% of cells pinned to the clamp.
|
||
- `test_logic.cpp` asserts geometry, plate assignment, non-saturation and
|
||
graded relief; run it after any `Planet::step()` change (see below).
|
||
- raylib render: 3D globe (top) + 2D Equal Earth map (bottom strip), orbit
|
||
camera, 4 color modes. Phase 1 is a **generator**: it paces tectonic ticks
|
||
toward isostatic equilibrium (watchable, ~3 s), renders live, and
|
||
**auto-pauses** once the terrain settles (max per-tick change < 2 m). No
|
||
background thread — that's Phase-2 (continuous drift/erosion) groundwork.
|
||
- **Phase 2 increment 1 (drift):** after forming settles, the app switches to
|
||
continuous **drift mode** on a My clock. Each plate has a real speed (1..20
|
||
cm/yr); `cflDtMy()` sets the timestep so the fastest plate advances ~half a
|
||
cell/step. `advect(dt)` moves plate membership + carried crust (plateId,
|
||
elevation, oceanic, geoAge) by an accumulation scheme: a boundary cell builds
|
||
`drift` (signed convergence distance) with its dominant other-plate neighbor;
|
||
+1 cell -> overrun (take that crust; but oceanic can't overrun a buoyant
|
||
continent -> it subducts under), -1 cell -> new young ridge crust (spreading).
|
||
Crust type now lives on the **cell** (`Cell.oceanic`), not the plate. The HUD
|
||
shows elapsed My; `[`/`]` set My/sec; auto-settle still gates Phase 1 -> 2.
|
||
- **Phase 2 increment 1.5 (plate lifecycle):** without this the count collapsed
|
||
to 1-3 and the world froze (a giant continental plate that can't shed cells).
|
||
Every `splitCheckEvery`(10) advect iterations (gated by `driftIter`) `advect()`
|
||
runs a Wilson-cycle lifecycle: **fission** (a plate over `splitFraction`(20%)
|
||
of cells splits along a random great circle through its centroid; prob ramps
|
||
`0.05+0.05*(pct-20)`, the new half gets a random drift — self-regulating, so
|
||
the count equilibrates ~8-12), **stalemate kick** (a plate whose size barely
|
||
changed over a window gets a new direction + speed boost to break deadlocks),
|
||
and **spreading plates** (rift cells become a `Plate.baby` young-ridge strip;
|
||
`coalesceBabyPlates()` merges connected blobs and dissolves tiny noise ones;
|
||
a strip past `babyPromoteFrac`(0.7%) is promoted to a real plate with random
|
||
drift + `volcanicLandFrac` of its interior turned to volcanic-island land).
|
||
The hard land clamp became a **soft band** (`landBand`) so volcanic land
|
||
persists; `acquirePlate()` reuses dead plate slots to keep `plates` bounded.
|
||
Stats panel separates real plates from "Young ridges: N strips"; plate-color
|
||
mode tints baby cells a uniform ridge grey. Verified by a headless soak.
|
||
- **Phase 2 increment 1.6 (calmer + cleaner plate map):** kicks were too twitchy
|
||
("strange border movement") so a plate must now stall for `stalemateWindows`(4)
|
||
consecutive check windows (`sStaleStreak`) before a kick. `deleteEnclosedPlates()`
|
||
absorbs any plate ringed by a single other plate into it (on a mutual pair only
|
||
the smaller). `fuseMiniPlates()` lets >= `fuseMinPlates`(3) clustered mini plates
|
||
(non-baby, < `miniPlateCells`) fuse into the largest member and steal one ring of
|
||
cells from their largest big neighbour (terrane amalgamation). Borders now draw
|
||
in two colors: real plate borders yellow, young spreading-ridge borders red
|
||
(3D + 2D, both via `B`). Soak: mini plates ~0, no enclosed slivers, ~8-12 plates.
|
||
- **Phase 2 increment 2 (erosion + sea level):** `Planet::erode(dtMy)` runs each
|
||
drift step after advect+step — a mass-conserving, slope-weighted downhill
|
||
sediment transport (one double-buffered gather pass): the higher cell of each
|
||
edge gives material to the lower, faster above sea level (`erosionLandRate`)
|
||
than below (`erosionSeaRate`). Highs wear down toward an uplift<->erosion
|
||
equilibrium; sediment fills basins and builds coastal shelves/deltas. No river
|
||
carving (sub-cell at 223 km -> subgrid later). `adjustSeaLevel()` (every
|
||
`seaLevelEvery` erode calls) eases `cfg.seaLevel` toward the percentile
|
||
elevation leaving `landFractionTarget` (30%) of cells above water — percentile
|
||
targeting (`nth_element`) because a proportional nudge oscillates across the
|
||
flat continental-base elevation. Two "land" notions coexist: crust type (plate
|
||
buoyancy) vs geographic (elev > seaLevel). Stats headline + water:land are now
|
||
geographic, with separate "Sea level" and "Crust %" lines. Headless: land -> 30%
|
||
all seeds, erosion conserves sum(elevation), deterministic.
|
||
- **Phase 2 increment 3 (gradual sea level + config/save files):** the sea-level
|
||
controller is now gradual — checked every `seaLevelEvery` (100) erode calls, it
|
||
nudges sea level by a fixed `seaLevelStep` (100 m) when outside a `seaLevelTol`
|
||
(2%) deadband, and only if the nudge reduces the error (so it rests near a flat
|
||
"cliff" instead of oscillating). A human-editable **`planet.cfg`** (key=value)
|
||
holds all PlanetConfig params (auto-created on first run, `F2` reloads +
|
||
regenerates); `loadConfig`/`saveConfig` share one `CONFIG_FIELDS` X-macro. A
|
||
**`planet.save`** binary holds seed + config + full planet state
|
||
(`Planet::writeState`/`readState`; geometry rebuilt from subdivisions via
|
||
`buildGeometry()`); `F5` saves, `F9` loads and resumes (deterministic
|
||
continuation, verified headless). File I/O lives in Planet (raylib-free).
|
||
- **Phase 2 polish (config validation, QoL, sim cleanups):** `validateConfig()`
|
||
range-checks every field (+ the cross-rule `oceanBase < continentBase`) on load
|
||
and `F2`; an invalid `planet.cfg` reverts to safe defaults without overwriting
|
||
it. Save bumped to **version 2** (now also persists the `[`/`]` drift rate;
|
||
version-gated reads accept older saves). Viewer adds a **crust-type color mode**
|
||
(`4`: continental warm brown / oceanic deep blue), `F` **fast-forward** (runs
|
||
`step()` to settled instantly), `F12` screenshot, `--seed`/`--config` CLI flags,
|
||
a `P<id>` label per plate on the drift arrows, and a min subdivision level of 1
|
||
(level 0 disallowed). Two sim refinements: `step()` re-anchors boundary (source)
|
||
cells to their original stress each dilation ring so adjacent belts don't
|
||
cross-inflate (sharper peaks; land flank cells 509->499); and the soft land-band
|
||
rift/accrete nudge now reads a frozen snapshot of pre-nudge `oceanic` so flipping
|
||
one cell can't cascade along vertex-index chains into linear "snakes". The
|
||
earlier drift-direction "inward" fix was reverted (it worsened snaking).
|
||
- **Phase 2 increment 4 (taller mountains + seafloor aging):** mountains used to
|
||
cap ~5000 m because collisions were under-weighted and `relax` snapped uplifted
|
||
crust back to `continentBase` once a migrating front passed. Now `step()` adds a
|
||
real **continent-continent collision** factor (`colliding[]` = continental cell
|
||
facing continental, `cfg.collisionFactor`) and an **Andes-class continental arc**
|
||
factor (`cfg.arcFactor`), and high continental crust gets **isostatic
|
||
persistence**: `relaxEff = relax*(1 - isostaticPersist*clamp((elev-continentBase)
|
||
/rootScale,0,1))`, so thick ranges stand and become *erosion-limited* (by the
|
||
drift-loop `erode()`) instead of relaxing away. These three boosts are **gated on
|
||
the `Planet::drifting` flag — active only in Phase-2 drift, OFF during Phase-1
|
||
forming** (which keeps the original mild factors + full relax). This is
|
||
deliberate: Phase-1 forming runs `step()` with no erosion, so if the strong
|
||
uplift + weak relax were active there it would never settle (uplift never
|
||
balanced) and would rail the clamp — gating to drift, where `erode()` runs every
|
||
tick, avoids both. main.cpp sets `planet.drifting=true` when forming settles (and
|
||
in `loadGame` from the saved phase), `false` on reseed/regen. **Seafloor aging->depth:**
|
||
oceanic crust subsides with `geoAge` via `oceanicBase(age) =
|
||
max(oceanBase, ridgeDepth - seafloorSubsidence*sqrt(age))` (half-space cooling);
|
||
`oceanBase` is now the **deep abyssal floor** (-6000 m), `ridgeDepth` the shallow
|
||
young value (-2500 m), and `seedInitialRelief()` seeds an oceanic age spread
|
||
(`seafloorSeedAge`) so the starting seafloor already has ridge->abyss variety.
|
||
Headless: ~half of seeds produce >7000 m ranges that persist (the rest are
|
||
legitimately low-relief ocean worlds), <2% pinned to the clamp on all seeds,
|
||
older seafloor markedly deeper, deterministic. Tune the new knobs in `planet.cfg`.
|
||
- **Phase 3 increment 1 (hydrology: rivers, lakes, fluvial erosion):**
|
||
`Planet::hydrology(dtMy)` = `routeFlow()` then mass-conserving fluvial erosion, on
|
||
the fixed grid (Eulerian, raylib-free). `routeFlow()` does priority-flood
|
||
depression-filling (epsilon tilt so flats drain; ocean cells are outlets) →
|
||
`sFill`/`sLakeDepth` (a cell with `lakeDepth>0` above sea level is a **lake**),
|
||
steepest-descent over the filled surface → `sFlowTo`, and flow accumulation in
|
||
descending-fill order → `sDischarge` (rivers = `discharge>riverThreshold`). The
|
||
erosion pass walks the network upstream→downstream carrying a sediment load:
|
||
stream-power incision `K*Q^m*S^n*dt` where under capacity, deposition where over
|
||
(`cap=riverTransport*Q*S`) — filling lakes, building deltas at mouths, depositing
|
||
the remainder at ocean sinks so **sum(elevation) is conserved**. Lakes/rivers are
|
||
**derived from elevation each tick** (no new saved per-cell field; only erosion
|
||
writes back to `elevation`). Orchestration (main.cpp): after `phase3AfterMy`
|
||
drift-My the sim **pauses and prompts** ("Continue Phase 2" / "Start Phase 3");
|
||
`H` toggles Phase 3 manually. In Phase 3 the drift loop keeps running
|
||
(advect/step/erode) but at a **finer dt** (`cflDtMy()*phase3DtScale`) plus
|
||
`hydrology(dt)` — drift never stops, just resolves finer. Lakes shade inland-water
|
||
blue (`recolor`); rivers draw as a `centroid→downstream` line network (3D + 2D,
|
||
two widths, `J` toggles). Save bumped to **v3** (+ a `phase3` header flag).
|
||
Headless: discharge grows downstream and all land rainfall reaches the sinks,
|
||
mass conserved to ~1e-15, ~10 lake systems + rivers persist, deterministic.
|
||
(Note: the hydrology phase is now framed in the UI as **Phase 2.5**; the
|
||
internal `phase3*` names are unchanged.)
|
||
- **Phase 3 increment 1 (biomes — classify + color):** `Planet::classifyBiomes()`
|
||
(src/sim/PlanetBiomes.cpp, raylib-free) writes a per-cell `Cell.biome` (enum
|
||
`Biome`, 13 entries: Ocean, **Ice**, Lake, Beach, Wetland, Grassland, Savanna,
|
||
Desert, Forest, Taiga, Tundra, Hills, Mountains). A first **rule-based** pass with
|
||
no real climate yet: temperature = warm-equator curve (super-linear in latitude so
|
||
cold concentrates at the poles) minus an elevation lapse; moisture = latitudinal
|
||
rainfall belts (wet equator/mid-lat, dry subtropics→deserts) + river discharge +
|
||
coastal proximity; classified first-match (ice→ocean→lake→beach→mountains→hills→
|
||
lowland-by-temp/moisture). **Polar ice caps** fall out of the temperature test (it
|
||
also snow-caps high peaks). All thresholds are **tunable in `planet.cfg`** (the
|
||
`biome*` PlanetConfig fields — `biomeIceTemp`, `biomeMountainElev`, the moisture
|
||
cutoffs, etc.; only the latitudinal rainfall-belt curve shape stays a fixed helper).
|
||
The biome is **saved per cell** — save bumped to **v4** (per-cell biome byte appended
|
||
after `invader`; `readState(is, hasBiome)` reads it for v4, reclassifies for v3, so v3
|
||
saves remain loadable). Rendered as color mode `5` (`biomeColor`, src/render/Colors.cpp);
|
||
re-run each `refreshView`. `lakeColor` changed to bright turquoise so lakes read clearly
|
||
vs ocean (the "blue speckle" near a clicked cell is just the subgrid detail overlay,
|
||
where ±250 m value-noise dips below sea level near coasts — not lakes). Headless:
|
||
every cell valid, both poles Ice, deep equatorial water Ocean, ≥4 land biomes present,
|
||
deterministic, v4 round-trips biome, v3 loads + reclassifies.
|
||
- **Phase 3 polish (smaller caps + axial tilt + grid labels):** ice caps trimmed a few
|
||
points (~17%→~14% of cells) by lowering `ICE_TEMP` in PlanetBiomes.cpp. Added a
|
||
planetary **`axialTilt`** (obliquity, default 23.44°, in PlanetConfig/`planet.cfg`):
|
||
the 3D globe + a drawn **spin-axis rod** (through the poles, red/blue pole caps) lean
|
||
by it via an `rlRotatef` about world Z wrapping all 3D content in `renderGlobe3D`;
|
||
picking un-rotates the world hit dir by −tilt (`rotateZ`, src/render/Picking.cpp) and
|
||
3D plate labels rotate by +tilt so everything stays consistent (the picking sphere is
|
||
rotation-invariant). Biomes/2D map are unchanged (tilt is visual + groundwork for
|
||
seasons). The graticule (`G`) now shows **lat/lon degree numbers on the 2D map** edges
|
||
(`drawGraticuleLabels2D`, plain "60N"/"120W" — the default font has no `°`). `axialTilt`
|
||
was added to PlanetConfig (`planet.cfg`). Headless: ice ~14%, biome + `axialTilt`
|
||
round-trip, deterministic.
|
||
- **Phase 3 polish (biome knobs in config + future-proof save):** the 17 biome
|
||
classification thresholds moved from constants into PlanetConfig `biome*` fields
|
||
(tunable in `planet.cfg`, `F2`). To stop config additions from breaking saves each
|
||
time, the save now stores config as a **self-describing key=value text block** (save
|
||
**v6**) parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared);
|
||
doubles written at `precision(17)` round-trip exactly. Adding/removing config fields no
|
||
longer breaks saves; v6 just can't load pre-v6 saves (one-time break). Headless: cfg
|
||
(incl. non-default `biome*`) round-trips exactly, unknown/missing keys handled.
|
||
- **Phase 3 increment 2 (climate model):** `Planet::computeClimate()`
|
||
(src/sim/PlanetClimate.cpp, raylib-free, derived/not saved) builds two continuous
|
||
per-cell fields. **Temperature** `sTemp` (°C) = the latitude curve (`biome*` temp
|
||
params) − elevation lapse. **Precipitation** `sPrecip`: prevailing winds are zonal by
|
||
band (tropics/polar easterly, mid-lat westerly); ocean cells are a moisture source and
|
||
each land cell takes its **upwind** neighbour's moisture, **rains out** more on windward
|
||
upslopes (orographic) and loses a multiplicative fraction per cell (continentality), so
|
||
leeward + deep-interior cells dry out. The raw field is near-binary (saturated where the
|
||
wind hits the sea, ~0 elsewhere), so it's **diffused** `climateMoistureSmooth` passes to
|
||
create wet→dry transition zones, then normalized to `sMoist` (0..1, **median land →
|
||
0.5**, robust to orographic spikes). `classifyBiomes()` now reads `sTemp`/`sMoist`
|
||
(dropping the old latitude+discharge+coast hack) → rain-shadow/interior **deserts** + a
|
||
varied, per-world biome spread; wetlands now require adjacency to water (ocean/lake).
|
||
Color modes `6` (temperature, blue→red) / `7` (precipitation, dry→wet). `computeClimate()`
|
||
runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config
|
||
knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior,
|
||
deserts present, deterministic.
|
||
- **UI polish (full cell info + view label + framing):** the cell-info panel
|
||
(`cellInfo`, src/render/Panels.cpp) now shows everything per cell — crust type, **biome**
|
||
(`biomeName`), **temperature** + **precipitation %**, and **river/lake** when hydrology is
|
||
on — in addition to the existing cell#, lat/lon, elevation, plate, geoAge. The active
|
||
color mode is shown **top-center** of the globe ("Biome view", etc., via `colorModeName`),
|
||
updating with `1`–`7`. The HUD title/status and the hydrology prompt were reworded to drop
|
||
the rigid "Phase N" labels (now "World Creation: forming / drift & erosion / hydrology");
|
||
internal `phase*` names are unchanged. Render/text only — no sim/save/config change.
|
||
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
|
||
right-side detail panel: tile info header + the tile's subgrid drawn as a
|
||
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
|
||
subgrid patch is also overlaid on the globe for context; the hovered subtile
|
||
is marked on the globe. `C` closes the panel.
|
||
|
||
## Architecture
|
||
|
||
The code is split into a **raylib-free engine** (`src/sim`, testable headless)
|
||
and a **raylib viewer** (`src/render`); `src/main.cpp` is a ~10-line entry point.
|
||
`Planet` is one class implemented across several `.cpp` files (one per phase/
|
||
concern, all sharing `Planet.hpp`); the viewer is one `Viewer` struct whose state
|
||
+ methods are likewise spread across a few render files. CMake adds both folders
|
||
to the include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
|
||
|
||
```
|
||
src/
|
||
main.cpp entry point: build Viewer, init(argc,argv), run()
|
||
sim/ (raylib-free engine -- testable headless)
|
||
Vec3.hpp double-precision 3D vector math
|
||
IcoSphere.* geodesic icosphere: fixed vertices + neighbor adjacency
|
||
Projection.hpp Equal Earth equal-area projection (forward + Newton inverse),
|
||
dir<->lon/lat helpers. Header-only, raylib-free, testable.
|
||
PlanetTypes.hpp Cell / Plate / SubGrid / PlanetConfig data structures
|
||
Planet.hpp the Planet class declaration + config-file func decls
|
||
Planet.cpp generation, geometry, plate seeding, shared helpers, subgrid
|
||
PlanetTectonics.cpp step() (Phase 1/2 stress->uplift->relax)
|
||
PlanetDrift.cpp cflDtMy/advect + plate lifecycle (fission/kick/baby/fuse)
|
||
PlanetErosion.cpp erode() + adjustSeaLevel()
|
||
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
|
||
PlanetIO.cpp config file (text) + binary save/load
|
||
render/ (raylib viewer)
|
||
Colors.* cell color modes (elevation/plate/age/crust/lake)
|
||
Map2D.* Equal Earth 2D map: positions + projection/draw helpers
|
||
Overlays.* borders, drift arrows, rivers, graticule, segments, subgrids
|
||
Picking.* mouse ray / sphere hit / nearest-cell / angle helpers
|
||
Panels.* right-column UI: detail panel, hover info, world stats
|
||
Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration
|
||
ViewerInput.cpp handleInput(): camera, hover picking, click, keys
|
||
ViewerRender.cpp renderGlobe3D / renderMap2D / renderPanels / renderHUD / renderPrompt
|
||
CMakeLists.txt fetches raylib 5.5 via FetchContent; lists src/sim + src/render
|
||
BUILD.md dependencies + build/run + controls
|
||
```
|
||
|
||
### Key data structures (src/sim/PlanetTypes.hpp)
|
||
|
||
- `Cell` — `unit` (fixed sphere direction), `elevation` (continuous meters,
|
||
double, NOT quantized), `plateId`, `geoAge`, `neighbors`, and a
|
||
`std::shared_ptr<SubGrid> subgrid` HOOK (still null on the cell; the viewer
|
||
builds subgrids on demand via `Planet::makeSubGrid(cell,res)`).
|
||
- `SubGrid`/`SubCell` — a res*res patch around one macro cell; elevation is an
|
||
inverse-distance blend of that cell + neighbors plus value-noise detail, and
|
||
each subcell records `nearestMacro`. Phase-4 preview, generated on click.
|
||
- `Plate` — `type` (Oceanic/Continental), `driftAxis`, `driftSpeed`.
|
||
- `PlanetConfig` — `radius` (default 6.371e6 m, Earth), `subdivisions`,
|
||
`seaLevel`, `plateCount`, `seed`.
|
||
|
||
### Resolution notes (important, was discussed in design)
|
||
|
||
- **Lateral** resolution = cell spacing = `sqrt(4*pi*R^2 / N)`. Coarse on
|
||
purpose (~223 km at level 5). Only mountain-range scale, not single hills.
|
||
- **Vertical** (elevation) resolution is effectively unlimited: it is a
|
||
`double` in meters. 100 m steps or finer are free. Mount-Everest /
|
||
Mariana-Trench range is exactly representable.
|
||
- Climate (phase 3) reads elevation as a smooth continuous function, so no
|
||
resolution is lost by coarse height bands.
|
||
- For dense detail later (population/culture), generate a per-cell **subgrid**
|
||
on demand and mark which edge sub-cells border which neighbor macro-cell,
|
||
so cross-boundary interaction (rain shadow, transition zones) works.
|
||
|
||
## Build & run
|
||
|
||
```bash
|
||
cmake -B build -DCMAKE_BUILD_TYPE=Release
|
||
cmake --build build -j
|
||
./build/planetsim
|
||
```
|
||
|
||
Target OS is Nobara Linux (KDE/Wayland, Intel Arc A770). Dependency install
|
||
line is in BUILD.md (`dnf install cmake gcc-c++ mesa-libGL-devel ...`).
|
||
raylib 5.5 is fetched automatically — do not vendor it.
|
||
|
||
### Quick headless logic test (no display needed)
|
||
|
||
```bash
|
||
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
|
||
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
|
||
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
|
||
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetIO.cpp \
|
||
-o /tmp/t && /tmp/t
|
||
```
|
||
|
||
Use this to verify tectonics after changing `Planet::step()` without launching
|
||
the window (the engine lives in `src/sim` and is raylib-free, so it links without
|
||
any render code). The `#pragma omp` lines in `step()` are ignored without
|
||
`-fopenmp`, so this serial build is correct; add `-fopenmp` to benchmark the
|
||
threaded path.
|
||
|
||
### Performance / parallelism
|
||
|
||
`Planet::step()` is data-parallel: each pass writes only its own cell index
|
||
(double-buffered where it reads a field it writes), so the OpenMP `parallel for`
|
||
loops are **bit-identical for any thread count** (determinism intact). It is
|
||
memory-bandwidth-bound, so the speedup tops out ~3x (sub-7: ~5.1 -> ~1.5 ms)
|
||
around 4-8 threads regardless of core count. `step()` also reuses persistent
|
||
scratch buffers (the `s*` members) so it allocates nothing per tick. Small grids
|
||
stay serial via `if(n > 20000)`. Control threads with `OMP_NUM_THREADS` (4-8 is the sweet spot;
|
||
the default uses all cores for no extra gain). OpenMP is optional and
|
||
auto-detected by CMake. For a bigger leap (or 1M+ cells) the next step is a GPU
|
||
compute-shader port -- a Phase-2 effort.
|
||
|
||
## Controls
|
||
|
||
LMB drag orbit · wheel zoom · hover for cell info (3D or map) ·
|
||
click a tile to open its detail panel (subtiles) · `C` close panel ·
|
||
drag the 2D map to pan it east/west · `1`..`7` color by
|
||
elevation/plate/age/crust-type/biome/temperature/precipitation (active mode shown
|
||
top-center of the globe) ·
|
||
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
|
||
all in 3D + 2D) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) ·
|
||
`S` single tick · `F` fast-forward Phase-1 forming to settled ·
|
||
`H` toggle Phase 3 (hydrology) · `R` reseed ·
|
||
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
|
||
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
|
||
|
||
Phase 3: after `phase3AfterMy` simulated years a modal prompt asks **Continue
|
||
Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
|
||
keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes
|
||
and fluvial erosion evolve.
|
||
|
||
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
|
||
file (both applied before the initial load/generate).
|
||
|
||
Two files in the working dir: `planet.cfg` (human-editable key=value of every
|
||
PlanetConfig param, auto-created on first run, reload with `F2`) and
|
||
`planet.save` (binary: seed + config + full planet state, written/read by
|
||
`Planet::writeState`/`readState`, resumes deterministically). Config is
|
||
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
|
||
defaults (without overwriting your `planet.cfg`) and shows a status message. The
|
||
save header is versioned (currently **6**; v2 adds the `[`/`]` drift rate, v3 a
|
||
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
|
||
key=value text block** instead of a raw POD dump); newer-than-supported is rejected.
|
||
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
|
||
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
|
||
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —
|
||
a one-time break; a length guard makes that fail gracefully.) `writeConfigFields`/
|
||
`parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save.
|
||
|
||
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
|
||
1344x648) + 2D Equal Earth map (bottom, 40% h); right column 30% wide = cell info
|
||
(top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
|
||
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
|
||
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
|
||
Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the
|
||
3D globe and the 2D map; drag the 2D map left/right to pan longitude (`mapLon`).
|
||
The graticule (`G`) also draws **lat/lon degree numbers along the 2D map edges**. The
|
||
3D globe (and its spin-axis rod) lean by `cfg.axialTilt` (an `rlRotatef` about world Z
|
||
around all 3D content in `renderGlobe3D`); 3D picking un-rotates the world hit dir by
|
||
−tilt and plate labels rotate by +tilt (`rotateZ`, src/render/Picking.cpp) to stay in sync.
|
||
Borders draw in two colors: real plate boundaries yellow, young spreading-ridge
|
||
(baby-plate) boundaries red — both via `buildBorders` filling two segment lists.
|
||
|
||
Drift arrows: one cyan arrow per plate at its centroid along the local drift
|
||
velocity `omega x r`; length scales with speed. Rebuilt per world-gen. Each plate
|
||
is also tagged with a `P<id>` label drawn just above its centroid — projected by
|
||
hand in 3D (the project's own `Vec3` camera-basis math, matching `BeginMode3D`'s
|
||
fovy/aspect, behind-camera points skipped) and via Equal Earth in the 2D map.
|
||
|
||
**Sim model (`Viewer::stepSim`, src/render/Viewer.cpp):** single-threaded. Each
|
||
frame (while not paused/settled)
|
||
it advances ~`formRate` (55) ticks/second of `planet.step()` (Phase 1 = pure
|
||
tectonic forming, no erosion — `planet.drifting` is false) and rebuilds the view
|
||
live, so you watch the terrain rise. `step()` returns the max per-tick
|
||
elevation change; after `settleNeed` (3) consecutive ticks below `settleThresh`
|
||
(2 m) it sets `settled` and stops stepping — no idle CPU. Re-evolve / reseed /
|
||
subdivision-change clear `settled` and restart the forming pass. Rendering reads
|
||
the live `cells` directly (safe: nothing else mutates them). Phase 2's
|
||
*continuous* sim will want a worker thread + a render snapshot (so the 60 fps
|
||
render never races the sim) — that's the natural place to reintroduce threading.
|
||
Plate borders are traced once per world-gen as a dual contour through boundary
|
||
triangles (plates are fixed in phase 1).
|
||
|
||
## Tuning knobs (expect to adjust these first)
|
||
|
||
- `elevExagg` (src/render/Viewer.hpp) — visual elevation exaggeration; without it the
|
||
sphere looks smooth.
|
||
- `axialTilt` (PlanetConfig, `planet.cfg`) — obliquity in degrees (default 23.44). Leans
|
||
the 3D globe + spin-axis rod; groundwork for seasons. Editable + `F2` to apply.
|
||
- Biome thresholds (`biome*` in PlanetConfig / `planet.cfg`) — `biomeIceTemp` (lower =
|
||
smaller polar/snow caps), `biomeMountainElev`/`biomeHillsElev`, the moisture cutoffs
|
||
(`biomeDesertMoist`/`biomeGrassMoist`/`biomeWetlandMoist`), and the temperature model
|
||
(`biomeEquatorTemp`/`biomePoleDrop`/`biomeLatExp`/`biomeElevLapse`, shared with climate).
|
||
Editable + `F2`.
|
||
- Climate (`climate*` in PlanetConfig / `planet.cfg`) — precipitation model:
|
||
`climateOrographic` (windward-rain strength), `climateRainEfficiency`,
|
||
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
|
||
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
|
||
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
|
||
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
|
||
knob for how fast/high relief builds.
|
||
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks
|
||
asymptote at `base + perTickUplift/relax`, so raising it lowers/flattens
|
||
the equilibrium; lowering it makes relief taller and slower to settle.
|
||
- `beltWidth` (PlanetConfig) — how many cell-rings a mountain belt spreads
|
||
inland (belt width / flank extent).
|
||
- `trenchFactor` (Planet::step) — depth multiplier for subduction trenches.
|
||
- `driftSpeed` (Planet::assignPlates) — how fast boundary stress builds.
|
||
- **Plate lifecycle (PlanetConfig, Phase 2 inc. 1.5):** `splitFraction` (0.20,
|
||
plate share that may rift), `splitProbBase`/`splitProbSlope` (fission prob
|
||
ramp), `splitCheckEvery` (10, iters between periodic checks), `stalemateEps` /
|
||
`stalemateBoost` (deadlock detection + kick), `stalemateWindows` (4, consecutive
|
||
stuck windows before a kick — raise to calm border jitter), `babyMinCells` (4,
|
||
baby blobs smaller than this dissolve as noise), `babyPromoteFrac` (0.7%,
|
||
ridge-strip size to become a real plate), `volcanicLandFrac` / `volcanicElev`
|
||
(land grown on promotion), `landBand` (0.10, soft land-conservation band),
|
||
`miniPlateCells` (50, below = "mini") + `fuseMinPlates` (3, cluster size to fuse
|
||
+ steal). Tune these to control how lively / fragmented the plate map stays.
|
||
- **Erosion + sea level (PlanetConfig, Phase 2 inc. 2):** `erosionLandRate` (0.08)
|
||
/ `erosionSeaRate` (0.02) — fraction/My worn off above / below sea level (raise
|
||
for faster, flatter terrain); `landFractionTarget` (0.30) — geographic land
|
||
goal; `seaLevelStep` (100 m) — fixed nudge per adjustment + `seaLevelTol` (0.02)
|
||
— deadband where sea level rests (raise step or shrink tol for a tighter 30/70,
|
||
but a big step can overshoot a flat "cliff"); `seaLevelEvery` (100) — iterations
|
||
between sea-level updates (higher = more gradual). All editable in `planet.cfg`.
|
||
- **Orogeny — taller mountains (PlanetConfig, Phase 2 inc. 4):** `collisionFactor`
|
||
(1.8, continent-continent uplift, Himalaya) and `arcFactor` (1.4, continental
|
||
subduction-arc uplift, Andes) — raise either to make ranges taller/more reliable
|
||
across seeds; `isostaticPersist` (0.85, how strongly high crust resists relax —
|
||
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).
|
||
- **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
|
||
fast oceanic crust deepens with `geoAge` (half-space cooling), and
|
||
`seafloorSeedAge` (80 My) is the initial oceanic age spread at generation so the
|
||
starting seafloor already has ridge->abyss variety.
|
||
- **Hydrology (PlanetConfig, Phase 3 inc. 1):** `phase3AfterMy` (300) — drift-My
|
||
before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep =
|
||
`cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall`
|
||
(1.0) — uniform precip per cell (drainage-area unit; orographic precip is a later
|
||
climate add-on); `riverThreshold` (50) — discharge above which a cell is a river
|
||
(also the river-render threshold); `riverIncision` K (0.02) + `riverDischargeExp`
|
||
m (0.5) + `riverSlopeExp` n (1.0) — stream-power incision `K*Q^m*S^n*dt` (raise K
|
||
for faster valley carving); `riverTransport` (0.1) — transport capacity
|
||
`cap=this*Q*S` and `depFrac` (0.25) — deposition rate of excess load (raise both
|
||
for more deltas / faster lake infill). All editable in `planet.cfg`.
|
||
|
||
## Conventions
|
||
|
||
- All code, identifiers, comments and filenames in **English**.
|
||
- Before generating code, summarize the approach and ask whether to proceed.
|
||
- Prefer **inline code in chat** over attachments; give exact file contents
|
||
rather than long explanations.
|
||
- Direct, concrete answers. Metric system throughout.
|
||
- C++17, multi-file CMake. Keep simulation logic free of raylib so it stays
|
||
testable headless: engine in `src/sim` (no raylib), all rendering in `src/render`.
|
||
- Geometry stays fixed — never make cells move; add new per-cell properties
|
||
and flow them over the existing grid + neighbor adjacency.
|