Live World weather: dynamic clouds & rain cycle (save v10)

A per-cell humidity/cloud/rain cycle advanced on the live clock (PlanetWeather.cpp,
raylib-free): evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing
wind (upwind differencing) -> condense into cloud (saturation vs temperature + windward
orographic lift) -> rain out thick cloud -> dissipate. Bounded exp-rate forms keep it stable at
any timestep, so it runs cleanly from hours/sec up to a month/sec. initWeather() spins the
fields up from the moisture climatology; fully deterministic (no RNG).

Render: a translucent cloud shell over the 3D globe (white -> dark slate where it rains,
alpha = cover) plus a matching drawWeather2D layer on the 2D map (shared drawMapTris
rasterizer), toggled with K (default on). stepSim runs stepWeather each live frame at the
sim-hours added to liveTime (held when paused). Cell-info shows cloud/humidity/raining.

Saved as v10 (humidity/cloud/rain, flag-gated; pre-v10 saves spin weather up live). New
weather* config knobs. Reseed/regen now also drops out of Live World. test_weather.cpp:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10
round-trip; the other four suites still pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-28 14:01:34 +02:00
parent d4b46afe00
commit f84e06507a
17 changed files with 404 additions and 31 deletions

View File

@ -51,6 +51,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
N toggle the day/night terminator (Live World)
T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
K toggle weather clouds/rain cover (Live World)
SPACE pause while forming / re-evolve once settled (or the on-screen button)
[ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month)
S single tectonic tick
@ -71,9 +72,9 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v9: +moons; v8 +Live World clock;
v7 +biota): seed + config + full planet state; F5 writes it, F9 reloads and
resumes deterministically. As of v6
planet.save binary snapshot (versioned, currently v10: +weather; v9 +moons; v8 +Live
World clock; v7 +biota): seed + config + full planet state; F5 writes it, F9
reloads and resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves
(unknown keys ignored, missing keys default). v6 cannot load pre-v6
@ -202,18 +203,31 @@ moving snow line. axialTilt (above) drives the seasonal declination. Press W to
Moons (1-3, randomized in generateMoons() + saved v9): orbit on the live clock, raise the
tides with the sun, and render as small lit spheres with phases, orbit rings and eclipses.
Weather (PlanetConfig, Live World): a dynamic clouds/rain cycle on the live clock (key K),
saved v10. Evaporate over warm seas -> advect along the wind -> condense -> rain -> dissipate.
weatherEvapRate 0.4 /h ocean evaporation toward marine saturation
weatherWindKmh 45 km/h wind speed advecting humidity/cloud
weatherSatBase 0.4 air saturation at 0 C (lower = cloudier)
weatherSatTempCoef 0.025 saturation rise per +1 C
weatherCondense 0.6 /h supersaturation -> cloud
weatherOrographic 0.0009 extra condensation per m of windward upslope
weatherRainThresh 0.5 cloud cover above this rains
weatherRainRate 0.5 /h rain rate from excess cloud
weatherCloudDissip 0.12 /h cloud clearing
## Headless logic test (no display)
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/PlanetLive.cpp \
src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t
src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# Biota / Live World / Ocean suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp or test_ocean.cpp
# Biota / Live World / Ocean / Weather suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp, test_ocean.cpp or test_weather.cpp
Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step().

View File

@ -83,6 +83,12 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
**warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly
(`climateCurrentFactor`), so biomes shift naturally. Rendered as warm/cold **current arrows**
over the sea (key `O`, 3D + 2D). This completes the Live World ocean/sky pass.
- **Weather — dynamic clouds & rain** *(done — see `PlanetWeather.cpp`)* — a per-cell
humidity/cloud/rain cycle advanced on the live clock: **evaporate** over warm sunlit seas →
**advect** humidity & cloud along the prevailing wind → **condense** into cloud (extra on
windward upslopes) → **rain** out → **dissipate**. Rendered as a translucent moving cloud
shell (white → dark storm where it rains) over the globe + 2D map (key `K`). Saved (v10).
**Next:** tropical cyclones (hurricanes/typhoons) as moving vortex agents on top of this field.
## Current state
@ -373,6 +379,19 @@ Working and verified (logic tested headless):
in `refreshView`. New knob `climateCurrentFactor` (4 °C). `test_ocean.cpp` adds: currents
tangent + zero on land + widespread, feedback bounded by the knob and produces both warming and
cooling, deterministic. Live-World ocean/sky pass complete.
- **Live World — dynamic weather (clouds & rain):** `Planet::stepWeather(dtHours)`
(PlanetWeather.cpp) advances a per-cell humidity/cloud/rain cycle on the live clock:
**evaporate** over warm sunlit ocean (uses `sInsolation`+`sTemp`), **advect** humidity & cloud
downwind (upwind differencing along `sWind`/`sUpwind`, `weatherWindKmh`), **condense** the
supersaturated air into cloud — saturation `weatherSatBase + weatherSatTempCoef·T`, plus
windward **orographic** lift — **rain** out cloud above `weatherRainThresh`, then **dissipate**.
`initWeather()` spins the fields up from the moisture climatology; bounded exponential rate
forms keep it stable at any timestep. Runs each live frame in `stepSim` (dt = the same sim-hours
added to `liveTime`; held when paused). Render: a translucent **cloud shell** (white → dark
storm where it rains, alpha = cover) over the 3D globe + a `drawWeather2D` layer on the 2D map,
key `K` (default on); cell-info adds cloud/humidity/raining. **Saved v10** (humidity/cloud/rain,
flag-gated; older saves spin weather up live). Deterministic (no RNG). `test_weather.cpp`:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10 round-trip.
- 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
@ -405,7 +424,8 @@ src/
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetClimate.cpp computeClimate() (temperature + orographic precipitation)
PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons)
PlanetOcean.cpp moons (generate/orbit) + computeTides (Live World sky & tides)
PlanetOcean.cpp moons (generate/orbit) + computeTides + computeOceanCurrents
PlanetWeather.cpp stepWeather (Live World dynamic clouds & rain cycle)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
@ -471,12 +491,12 @@ 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/PlanetLive.cpp \
src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t
src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp` or `test_ocean.cpp` to run the
Biota / Live World / Ocean suites — same source list.)
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp` or
`test_weather.cpp` to run the Biota / Live World / Ocean / Weather suites — same source list.)
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
@ -507,7 +527,8 @@ elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
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) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `SPACE` or on-screen button pause ·
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
@ -541,9 +562,11 @@ save header is versioned (currently **9**; 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, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
clock — a flag byte + `liveTime`, v9 appends the **moons** block); newer-than-supported is
clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block —
humidity/cloud/rain, flag-gated); newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed.
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
saves spin weather up live.
**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 —
@ -605,6 +628,13 @@ triangles (plates are fixed in phase 1).
`climateCurrentFactor` (4 °C) is the max coastal warming/cooling from ocean currents (0 = off;
ocean-current arrows toggle with `O`). Current deflection angle + smoothing passes are
constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config.
- **Weather (`weather*` in PlanetConfig / `planet.cfg`):** the Live World clouds/rain cycle —
`weatherEvapRate` (ocean evaporation speed), `weatherWindKmh` (advection speed of humidity/cloud),
`weatherSatBase`/`weatherSatTempCoef` (how much moisture the air holds vs temperature — lower
base = cloudier), `weatherCondense` (supersaturation→cloud rate), `weatherOrographic` (windward
rain on mountains), `weatherRainThresh`/`weatherRainRate` (when/how fast thick cloud rains),
`weatherCloudDissip` (cloud clearing). Toggle the overlay with `K`. Cloud render colours
(white→storm, alpha) are constants in ViewerRender/Map2D.
- Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the
master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full
tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings`

View File

@ -28,6 +28,7 @@ add_executable(planetsim
src/sim/PlanetClimate.cpp
src/sim/PlanetLive.cpp
src/sim/PlanetOcean.cpp
src/sim/PlanetWeather.cpp
src/sim/PlanetBiota.cpp
src/sim/PlanetFloraGen.cpp
src/sim/PlanetFaunaGen.cpp

View File

@ -34,7 +34,10 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
- `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
seasonal temperature; derived, not saved).
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
`computeTides()` (Live World sky & equilibrium tides). Moons are saved (v9); tides derived.
`computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved
(v9); tides/currents derived.
- `PlanetWeather.cpp``initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle;
saved v10).
- `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
@ -171,6 +174,25 @@ back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed
applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea
(`buildCurrents`, key `O`). Currents/feedback are derived (not saved).
## Weather (Live World dynamic clouds & rain)
`PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock
(`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over
warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation`
daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed
`weatherWindKmh`), **condense** the supersaturated air into cloud (saturation
`weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above
`weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded
`1exp(rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec).
`initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live
frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused).
Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha
= cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the
2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain,
flag-gated; pre-v10 saves spin weather up on entering Live World). Future: tropical cyclones
(moving vortex agents) layered on this field.
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:
@ -178,7 +200,7 @@ Engine is raylib-free, so logic is tested without a display. Build/run:
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/PlanetLive.cpp src/sim/PlanetOcean.cpp \
src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota.cpp uses the same source list (Biota suite).

View File

@ -29,8 +29,9 @@ Vector2 mapScreen(const Map2D& m, int idx, Rectangle r, double lonOffset) {
return projLonLat(m.lon[idx], m.lat[idx], lonOffset, r);
}
void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const Map2D& m, Rectangle r, double lonOffset) {
// Shared triangle rasterizer for the 2D map: calls colorAt(cellIndex) -> Color per vertex.
template <typename ColorFn>
static void drawMapTris(const Planet& p, const Map2D& m, Rectangle r, double lonOffset, ColorFn colorAt) {
double hw = EqualEarth::halfWidth();
auto px = [&](double lon, double lat) -> float {
double x, y; EqualEarth::forward(lon, lat, x, y);
@ -47,7 +48,8 @@ void drawMap2D(const Planet& p, const std::vector<Color>& vc,
double mx = std::max({lo[0], lo[1], lo[2]});
if (mx - mn <= M_PI) { // fast path (no wrap)
for (int t = 0; t < 3; ++t) {
rlColor4ub(vc[v[t]].r, vc[v[t]].g, vc[v[t]].b, 255);
Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(lo[t], m.lat[v[t]]), m.pos[v[t]].y);
}
} else { // antimeridian seam
@ -60,10 +62,28 @@ void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const double shift[3] = { 0.0, 2 * M_PI, -2 * M_PI }; // both edges; scissor clips
for (double sh : shift)
for (int t = 0; t < 3; ++t) {
rlColor4ub(vc[v[t]].r, vc[v[t]].g, vc[v[t]].b, 255);
Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(ul[t] + sh, m.lat[v[t]]), m.pos[v[t]].y);
}
}
}
rlEnd();
}
void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const Map2D& m, Rectangle r, double lonOffset) {
drawMapTris(p, m, r, lonOffset, [&](int i) { return Color{ vc[i].r, vc[i].g, vc[i].b, 255 }; });
}
void drawWeather2D(const Planet& p, const std::vector<double>& cloud, const std::vector<double>& rain,
const Map2D& m, Rectangle r, double lonOffset) {
if (cloud.empty()) return;
double maxR = 1e-6; for (double v : rain) maxR = std::max(maxR, v);
drawMapTris(p, m, r, lonOffset, [&](int i) -> Color {
double c = std::clamp(cloud[i], 0.0, 1.0);
double rain01 = std::clamp(rain[i] / maxR, 0.0, 1.0);
return Color{ (unsigned char)(245 - 150 * rain01), (unsigned char)(245 - 130 * rain01),
(unsigned char)(250 - 95 * rain01), (unsigned char)(c * 205.0) };
});
}

View File

@ -21,3 +21,8 @@ Vector2 mapScreen(const Map2D& m, int idx, Rectangle r, double lonOffset);
// lonOffset pans the map east/west (radians); y is unchanged by the pan.
void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const Map2D& m, Rectangle r, double lonOffset);
// Translucent Live World cloud/rain layer over the 2D map (white -> dark storm where it rains;
// alpha = cloud cover). Same triangle iteration as drawMap2D but blended on top.
void drawWeather2D(const Planet& p, const std::vector<double>& cloud, const std::vector<double>& rain,
const Map2D& m, Rectangle r, double lonOffset);

View File

@ -64,6 +64,11 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
if (sized(p.tide()))
L.push_back(std::string(TextFormat("tide %+.2f m (%s)", p.tide()[i],
p.tide()[i] >= 0.0 ? "high" : "low")));
if (sized(p.cloud()))
L.push_back(std::string(TextFormat("weather: cloud %.0f%% humidity %.0f%%%s",
p.cloud()[i] * 100.0,
sized(p.humidity()) ? p.humidity()[i] * 100.0 : 0.0,
(sized(p.rain()) && p.rain()[i] > 0.02) ? " raining" : "")));
L.push_back(std::string(TextFormat("geoAge %.0f My neighbors %d", age, (int)c.neighbors.size())));
// Hydrology (derived; present once routeFlow()/hydrology() has run).
if (sized(p.discharge()) && p.discharge()[i] > p.cfg.riverThreshold)

View File

@ -227,6 +227,7 @@ void Viewer::regenWorld() { // after generate(): geometry change
buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear();
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; // reseed/regen drops back to World Creation
planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear();
@ -278,7 +279,7 @@ void Viewer::loadGame(const char* path) {
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: biome, v7: biota, v9: moons
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
@ -302,7 +303,8 @@ void Viewer::loadGame(const char* path) {
void Viewer::stepSim() {
if (liveWorld) {
// --- Live World: advance the slow clock; geology is frozen --------
if (!paused) liveTime += liveRate * GetFrameTime(); // hours
double dtH = (!paused) ? liveRate * GetFrameTime() : 0.0; // simulated hours this frame
liveTime += dtH;
double days = liveTime / planet.cfg.dayLengthHours;
double dayOfYear01 = days / planet.cfg.yearLengthDays;
dayOfYear01 -= std::floor(dayOfYear01);
@ -319,6 +321,7 @@ void Viewer::stepSim() {
moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z });
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
}
planet.stepWeather(dtH); // dynamic clouds & rain on the live clock
rebuildLiveOverlay();
return;
}

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 9; // v9: +moons; v8: +Live World clock; v7: +biota population; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr uint32_t SAVE_VERSION = 10; // v10: +weather; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides)
@ -90,6 +90,7 @@ struct Viewer {
bool showTides = false; // colour the coastline by the live tide level (key T)
std::vector<Vector3> currentSegs; std::vector<Color> currentCols; // ocean-current arrows
bool showCurrents = false; // ocean current arrows, warm/cold (key O)
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
// Selection + subgrid (phase 4/5 preview).
int selectedCell = -1;

View File

@ -145,6 +145,7 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled)

View File

@ -103,6 +103,31 @@ void Viewer::renderGlobe3D() {
}
rlEnd(); rlSetLineWidth(1.0f);
}
// Live World weather: a translucent cloud shell over the globe (white -> dark storm where it
// rains), alpha = cloud cover. Drawn as a second triangle layer just above the terrain.
if (liveWorld && showClouds && !planet.cloud().empty()) {
const std::vector<double>& cl = planet.cloud();
const std::vector<double>& rn = planet.rain();
double maxR = 1e-6; for (double r : rn) maxR = std::max(maxR, r);
const std::vector<int>& ctri = planet.triIndices();
const float cr = visBase + 0.03f;
rlBegin(RL_TRIANGLES);
for (size_t k = 0; k + 2 < ctri.size(); k += 3) {
for (int j = 0; j < 3; ++j) {
int idx = ctri[k + j];
double c = std::clamp(cl[idx], 0.0, 1.0);
double rain01 = std::clamp(rn[idx] / maxR, 0.0, 1.0);
unsigned char R = (unsigned char)(245 - 150 * rain01); // white -> slate
unsigned char G = (unsigned char)(245 - 130 * rain01);
unsigned char B = (unsigned char)(250 - 95 * rain01);
unsigned char A = (unsigned char)(std::clamp(c, 0.0, 1.0) * 205.0);
const Vec3& u = planet.cells[idx].unit;
rlColor4ub(R, G, B, A);
rlVertex3f((float)(u.x * cr), (float)(u.y * cr), (float)(u.z * cr));
}
}
rlEnd();
}
if (showGrat) drawGraticule3D(graticule, gratR);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
if (selectedCell >= 0) {
@ -195,6 +220,7 @@ void Viewer::renderMap2D() {
if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, mapRect, mapLon);
if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, mapRect, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, mapRect, mapLon);
if (liveWorld && showClouds && !planet.cloud().empty()) drawWeather2D(planet, planet.cloud(), planet.rain(), map2D, mapRect, mapLon);
if (phase3 && showRivers) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, mapRect, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, mapRect, mapLon);
@ -374,8 +400,8 @@ void Viewer::renderHUD() {
y += 8;
line("hover: cell info | click tile: open detail panel | C close");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off"));
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");

View File

@ -51,6 +51,8 @@ void Planet::buildGeometry() {
}
sBiota.assign(cells.size(), {}); // empty biota population until generateBiota()
sHasBiota = false;
sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World
sHasWeather = false;
}
void Planet::assignPlates() {

View File

@ -89,6 +89,17 @@ public:
void computeOceanCurrents();
const std::vector<Vec3>& current() const { return sCurrent; }
// Weather (Live World): dynamic per-cell humidity / cloud cover / rain advanced on the live
// clock. initWeather() spins the fields up from the climatology; stepWeather(dtHours) runs
// one cycle (evaporate over warm seas -> advect along the wind -> condense into cloud, with
// orographic lift -> rain out -> dissipate). Reads sInsolation/sTemp/sWind/sUpwind/sMoist
// (computeClimate + computeInsolation set those). Saved (v10).
void initWeather();
void stepWeather(double dtHours);
const std::vector<double>& humidity() const { return sHumidity; }
const std::vector<double>& cloud() const { return sCloud; }
const std::vector<double>& rain() const { return sRain; }
// Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into
// cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves.
@ -124,8 +135,10 @@ public:
// older saves (v3) pass false -- biomes are reclassified after the cells load.
// hasBiota: whether the stream carries the biota population block (save v7+).
// hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true);
// synthesize moons from the seed instead. hasWeather: the weather block (save v10+);
// older saves leave weather to spin up on entering Live World.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true);
// Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing
@ -199,6 +212,9 @@ private:
// sTide is the equilibrium tidal height (m) from the moons + sun.
std::vector<double> sInsolation, sLiveTemp, sTide;
std::vector<Vec3> sCurrent; // ocean surface current velocity (tangent; zero on land)
// Weather (Live World; saved v10). sHasWeather latches once spun up/loaded.
std::vector<double> sHumidity, sCloud, sRain;
bool sHasWeather = false;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.

View File

@ -36,6 +36,9 @@
D(bioFungaTempMin) D(bioRegionBonus) \
D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \
D(tideAmplitude) D(tideSunFactor) \
D(weatherEvapRate) D(weatherWindKmh) D(weatherSatBase) D(weatherSatTempCoef) \
D(weatherCondense) D(weatherOrographic) D(weatherRainThresh) D(weatherRainRate) \
D(weatherCloudDissip) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \
@ -191,6 +194,15 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.seaIceTemp, -60.0, 20.0, "seaIceTemp"));
E(rng(cfg.tideAmplitude, 0.0, 100.0, "tideAmplitude"));
E(rng(cfg.tideSunFactor, 0.0, 5.0, "tideSunFactor"));
E(rng(cfg.weatherEvapRate, 0.0, 50.0, "weatherEvapRate"));
E(rng(cfg.weatherWindKmh, 0.0, 1000.0, "weatherWindKmh"));
E(rng(cfg.weatherSatBase, 0.01, 5.0, "weatherSatBase"));
E(rng(cfg.weatherSatTempCoef, 0.0, 1.0, "weatherSatTempCoef"));
E(rng(cfg.weatherCondense, 0.0, 50.0, "weatherCondense"));
E(rng(cfg.weatherOrographic, 0.0, 1.0, "weatherOrographic"));
E(rng(cfg.weatherRainThresh, 0.0, 1.5, "weatherRainThresh"));
E(rng(cfg.weatherRainRate, 0.0, 50.0, "weatherRainRate"));
E(rng(cfg.weatherCloudDissip, 0.0, 50.0, "weatherCloudDissip"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -279,9 +291,12 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga);
}
}
// v10: Live World weather (humidity/cloud/rain). Flag-gated like biota.
uint8_t hasWx = (sHasWeather && sHumidity.size() == cells.size()) ? 1 : 0; writePod(os, hasWx);
if (hasWx) { writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain); }
}
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons) {
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather) {
// Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their
// current defaults. The length guard rejects pre-v6 (raw-POD-config) saves.
@ -327,6 +342,16 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
}
}
}
// v10: Live World weather. Older saves leave it to spin up on entering Live World.
sHasWeather = false; sHumidity.clear(); sCloud.clear(); sRain.clear();
if (hasWeather) {
uint8_t hasWx = 0; readPod(is, hasWx);
if (hasWx) {
readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain);
if (!is || (int)sHumidity.size() != (int)cells.size()) return false;
sHasWeather = true;
}
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;
}

View File

@ -257,4 +257,18 @@ struct PlanetConfig {
double seaIceTemp = -2.0; // C: ocean below the live temperature shows sea ice
double tideAmplitude = 0.6; // m: equilibrium-tide scale per unit tide-raising weight
double tideSunFactor = 0.46; // sun's tide weight relative to a unit moon (Earth ~0.46)
// --- Weather (Live World dynamic clouds & rain) -- see PlanetWeather.cpp -----
// A per-cell humidity/cloud/rain cycle advanced on the live clock: evaporate over warm
// sunlit seas, advect along the prevailing wind, condense into cloud (more on windward
// upslopes), rain out, and dissipate. Rates are per simulated hour.
double weatherEvapRate = 0.4; // /h: ocean evaporation toward marine saturation
double weatherWindKmh = 45.0; // km/h: prevailing wind speed for advecting humidity/cloud
double weatherSatBase = 0.4; // air saturation humidity at 0 C (warmer air holds more)
double weatherSatTempCoef = 0.025; // saturation rise per +1 C
double weatherCondense = 0.6; // /h: fraction of supersaturation that becomes cloud
double weatherOrographic = 0.0009; // extra condensation per m of windward upslope
double weatherRainThresh = 0.5; // cloud cover above this precipitates
double weatherRainRate = 0.5; // /h: rain rate from excess cloud
double weatherCloudDissip = 0.12; // /h: cloud clearing (half returns to humidity)
};

96
src/sim/PlanetWeather.cpp Normal file
View File

@ -0,0 +1,96 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
// Live World weather: a dynamic per-cell humidity / cloud / rain cycle advanced on the live
// clock (geometry fixed -- these are fields flowed over the grid, like climate, but time-varying).
// One step: evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing wind
// -> condense the supersaturated air into cloud (extra on windward upslopes) -> rain out the
// thick cloud -> dissipate. Reads the static climate scaffolding (sTemp/sWind/sUpwind/sMoist set
// by computeClimate) and the live sInsolation (computeInsolation). Deterministic; saved (v10).
void Planet::initWeather() {
const int n = (int)cells.size();
sHumidity.assign(n, 0.0);
sCloud.assign(n, 0.0);
sRain.assign(n, 0.0);
const double sea = cfg.seaLevel;
const bool haveM = ((int)sMoist.size() == n);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea) sHumidity[i] = 0.9; // saturated marine air
else sHumidity[i] = haveM ? (0.2 + 0.5 * sMoist[i]) : 0.3; // land: from climatology
}
sHasWeather = true;
}
void Planet::stepWeather(double dtHours) {
const int n = (int)cells.size();
if (!sHasWeather || (int)sHumidity.size() != n || (int)sCloud.size() != n || (int)sRain.size() != n)
initWeather();
if (dtHours <= 0.0) return; // paused: hold the current sky
if ((int)sTemp.size() != n) return; // need the climate fields
const double sea = cfg.seaLevel;
auto isOcean = [&](int i) { return cells[i].elevation <= sea; };
const double cw = std::max(1.0, cellWidthMeters());
double advFrac = std::clamp(cfg.weatherWindKmh * 1000.0 * dtHours / cw, 0.0, 1.0);
const bool haveSun = ((int)sInsolation.size() == n);
const bool haveUp = ((int)sUpwind.size() == n);
// 1. Advect humidity downwind (upwind differencing) + evaporate over warm sunlit ocean.
std::vector<double> nh(n);
for (int i = 0; i < n; ++i) {
double hUp = (haveUp && sUpwind[i] >= 0) ? sHumidity[sUpwind[i]] : sHumidity[i];
double h = sHumidity[i] * (1.0 - advFrac) + hUp * advFrac;
if (isOcean(i)) {
double tf = std::clamp((sTemp[i] + 2.0) / 30.0, 0.0, 1.0); // warm seas evaporate more
double sun = haveSun ? (0.5 + 0.5 * sInsolation[i]) : 0.7; // daytime boost
double target = 0.55 + 0.45 * tf; // marine humidity target
double rate = 1.0 - std::exp(-cfg.weatherEvapRate * sun * dtHours);
if (target > h) h += (target - h) * rate;
}
nh[i] = h;
}
sHumidity.swap(nh);
// 2. Advect cloud (it drifts with the wind too).
std::vector<double> nc(n);
for (int i = 0; i < n; ++i) {
double cUp = (haveUp && sUpwind[i] >= 0) ? sCloud[sUpwind[i]] : sCloud[i];
nc[i] = sCloud[i] * (1.0 - advFrac) + cUp * advFrac;
}
sCloud.swap(nc);
// 3. Condense (saturation + orographic lift) -> rain -> dissipate, per cell.
const double condR = 1.0 - std::exp(-cfg.weatherCondense * dtHours);
const double rainR = 1.0 - std::exp(-cfg.weatherRainRate * dtHours);
const double dissR = 1.0 - std::exp(-cfg.weatherCloudDissip * dtHours);
const double invDt = 1.0 / dtHours;
for (int i = 0; i < n; ++i) {
double sat = std::max(0.05, cfg.weatherSatBase + cfg.weatherSatTempCoef * std::max(0.0, sTemp[i]));
double cond = 0.0;
double excess = sHumidity[i] - sat;
if (excess > 0.0) cond += excess * condR; // convective/thermal
if (haveUp && sUpwind[i] >= 0) { // orographic (windward)
double up = cells[i].elevation - cells[sUpwind[i]].elevation;
if (up > 0.0) cond += sHumidity[i] * std::min(1.0, up * cfg.weatherOrographic) * condR;
}
cond = std::min(cond, sHumidity[i]);
sHumidity[i] -= cond;
sCloud[i] += cond;
double rain = 0.0;
if (sCloud[i] > cfg.weatherRainThresh) {
rain = (sCloud[i] - cfg.weatherRainThresh) * rainR;
sCloud[i] -= rain;
}
double diss = sCloud[i] * dissR;
sCloud[i] -= diss;
sHumidity[i] += diss * 0.5; // half re-evaporates
sRain[i] = rain * invDt; // intensity (per hour)
if (sHumidity[i] < 0.0) sHumidity[i] = 0.0;
sCloud[i] = std::clamp(sCloud[i], 0.0, 1.5);
}
}

92
test_weather.cpp Normal file
View File

@ -0,0 +1,92 @@
// Headless test for the Live World weather cycle (humidity / cloud / rain). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_weather.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/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
// src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
// src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/tw && /tmp/tw
//
// Verifies: fields stay in range; oceans (the evaporation source) end up moister than land;
// clouds form and rain falls somewhere; the cycle is deterministic; and save v10 round-trips it.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
// Run a fixed weather sequence on a planet (returns whether rain ever fell, max cloud seen).
static void runWeather(Planet& p, bool& everRained, double& maxCloud) {
p.initWeather();
everRained = false; maxCloud = 0.0;
for (int k = 0; k < 200; ++k) {
p.computeInsolation(0.25, std::fmod(0.3 + 0.01 * k, 1.0)); // sun advances
p.stepWeather(1.0); // 1-hour steps
const std::vector<double>& rn = p.rain();
const std::vector<double>& cl = p.cloud();
for (size_t i = 0; i < rn.size(); ++i) {
if (rn[i] > 0.0) everRained = true;
maxCloud = std::max(maxCloud, cl[i]);
}
}
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 1337;
Planet p; p.generate(cfg);
const int n = (int)p.cells.size();
std::printf("Weather: cycle\n");
bool rained = false; double maxCloud = 0.0;
runWeather(p, rained, maxCloud);
bool inRange = true;
for (int i = 0; i < n; ++i) {
if (p.humidity()[i] < -1e-9) inRange = false;
if (p.cloud()[i] < -1e-9 || p.cloud()[i] > 1.5 + 1e-9) inRange = false;
if (p.rain()[i] < -1e-9) inRange = false;
}
check(inRange, "humidity/cloud/rain stay in range");
check(maxCloud > 0.05, "clouds form");
check(rained, "rain falls somewhere");
// Oceans are the moisture source -> moister than land on average.
double oh = 0, lh = 0; int oc = 0, lc = 0;
for (int i = 0; i < n; ++i) {
if (p.cells[i].elevation <= cfg.seaLevel) { oh += p.humidity()[i]; ++oc; }
else { lh += p.humidity()[i]; ++lc; }
}
oh /= std::max(1, oc); lh /= std::max(1, lc);
std::printf(" mean humidity: ocean %.3f, land %.3f\n", oh, lh);
check(oh > lh, "oceans end up moister than land");
std::printf("Weather: determinism\n");
Planet p2; p2.generate(cfg);
bool r2; double mc2; runWeather(p2, r2, mc2);
bool same = true;
for (int i = 0; i < n; ++i)
if (p2.humidity()[i] != p.humidity()[i] || p2.cloud()[i] != p.cloud()[i]
|| p2.rain()[i] != p.rain()[i]) same = false;
check(same, "same seed + sequence -> identical weather");
std::printf("Weather: save v10\n");
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss);
Planet q;
bool ok = q.readState(ss, true, true, true, true);
bool rt = ok && (int)q.cloud().size() == n;
for (int i = 0; i < n && rt; ++i)
if (q.humidity()[i] != p.humidity()[i] || q.cloud()[i] != p.cloud()[i] || q.rain()[i] != p.rain()[i])
rt = false;
check(rt, "save v10 round-trips the weather state");
std::printf(failures ? "\nSOME WEATHER CHECKS FAILED (%d)\n" : "\nALL WEATHER CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}