Added: Initial world generation tool
This commit is contained in:
@@ -7,6 +7,30 @@ say so.
|
||||
Status: `Decided` · `Supersedes` (contradicts an earlier decision, names it) · `Deferred` (deliberately not now,
|
||||
with a trigger)
|
||||
|
||||
## 2026-09-17 — Off the ladder, the terrain generator leaves numpy and gains a drainage network
|
||||
|
||||
| # | Decision | Where |
|
||||
| --- | --- | --- |
|
||||
| D-52 | **The continent mask is thresholded, not multiplied, into the uplift field: where the land ends does not decide how fast it is rising.** The rate was `r * l` with `l` the smoothstep continent mask, so uplift tapered to zero across the shore and every coastline on the map was the lowest-uplift ground on the map by construction. Steady state is `S = U/(K*A^m)`, so ground with no uplift grades to no slope: the surf had a flattened strip to work in wherever it looked. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its base level, and a range that runs out to the water rises at range rates right up to the waterline. Measured, seed 7 at 1400 with everything else held: surf cut 23.32 → 38.87 Mm³, planed 3.0 → 3.9 km², new beach 3.41 → 5.59 km²; seed 9342 the same in direction, 22.42 → 30.35 Mm³. `Corrects a measurement, not only the code`: the "mean cliff 2 m" that motivated this was a broken metric — it measured the drop from a cell to its seaward neighbour, which is a gradient, and at the angle of repose one cell of a 10 m grid is 7 m, so it could never have exceeded 7 whatever the coast did. Replaced by backshore height, the land's elevation one to two surf reaches inland, median and P90; on that metric the *old* build already read 88 m at P90, so the taper was never the difference between cliffs and no cliffs — it flattened the ~100 m strip the surf works in, so the cliff began a hundred metres inland instead of at the water. The slope–area fit moved −0.480 → −0.312 on seed 7 and −0.698 → −0.720 on seed 9342, in opposite directions and both inside a seed-to-seed spread that is several times larger, so it is not a regression; it is a warning that the fit at five or six bins cannot judge a single change on a single seed. `Decided`. | [Terrain.md](Terrain.md) |
|
||||
| D-51 | **The coastline becomes a pass of its own, run after the fluvial solve, and the continent outline gains the octaves that make it a coast rather than a blob.** The coast was a line in a mask: the sea floor dropped to a flat plane at −180 m in one step, the land met the water at whatever angle the last erosion step left, and no process knew the shoreline was there. `internal/coast` adds three, each derived rather than drawn. A shelf, from an exact signed distance transform: gentle to a break, then a continental slope to the abyss, its width read off the relief standing behind each stretch of shore, so a plain gets a wide shelf and a range that reaches the water gets a narrow one. A surf, which planes the land to a shore platform within a reach set by how open the water is — the cliff is the step where the reach ends, so its height is whatever the land behind it stands at. And a sediment budget: what the surf cuts is counted, carried a drift length along the shore, and laid in sheltered shallow water, with river mouths delivering their own load in proportion to what they drain, which is what makes a delta. It runs after the solve because two of the three need the finished terrain, and it owns the sea floor outright — `uplift.Result.Bathymetry` is gone, and ocean cells stay at sea level for the whole solve, which is the invariant that stopped rivers cutting to −180 m. Shelter is measured as effective fetch over the *seaward* half-space against fixed absolute anchors, not as a percentile of the map: casting in every direction counts the land behind the shore as shelter, and a percentile is a global statistic that two tiles would disagree about. Measured on seed 7: the outline's octave gain, swept with everything else held, gave 64 km of shoreline at 0.50, 81 at 0.58, 96 at 0.62 and 114 at 0.66, while the fetch's view of the median stretch of coast went 1.00, 0.98, 0.84, 0.51 — five octaves put the finest coastal feature at 450 m, which is a smooth blob with nothing to shelter anything from anything. 8 octaves at gain 0.62 is the setting that has islands, inlets and headlands without the outline breaking into speckle. The whole pass costs 83 ms at the manifest's geology grid. `Decided`. | [Terrain.md](Terrain.md) |
|
||||
| D-49 | **Plains are made by lowering the intraplate uplift rate an order of magnitude, not by tuning erosion; and the hillslope law becomes Roering nonlinear diffusion with the repose clamp demoted to a constraint the diffusion smooths over.** Steady state is `S = U/(K*A^m)`, and with `critical_area_m2` at 0 that holds down to a single cell, so at K 5e-5, m 0.5 and a ~10 m geology cell an intraplate rate of 0.25-0.9 mm/yr puts *every divide on the map* at 32 degrees or past the 35 degree repose angle. Measured on the old numbers: 81 % of the land in the >0.5 mm/yr class, the plain class 1 % of it and all sea cliff. The plains were never over-dissected; they were being uplifted at mountain rates, and for n = 1 the uplift rate alone fixes the hillslope angle. `intraplate_mm_yr` 0.25 -> 0.03 and `intraplate_swell_mm_yr` 0.9 -> 0.08 keeps the ~2.5-fold swell that puts divides on a plain while making the mountain-to-plain ratio 30-fold, which is what real ones are; `relief.amplitude_m` lo 50 -> 15 so the plains do not start as hills the run must erode away. Measured after: plains 42 % of land at 0.8 degrees median and 9 m of relief over 500 m. The repose clamp stays in the step loop but nonlinear diffusion runs *after* it every step, because the clamp cuts along the eight D8 directions and leaves grid-aligned pyramid faces; a symmetric five-point stencil rounds them off before the next step. Clamping once at the end instead was measured and is worse. `Supersedes` the three-fold mountain-to-plain ratio D-47's uplift notes settled on. `Decided`. | [Terrain.md](Terrain.md) |
|
||||
| D-50 | **The D8 router's flat-ground tie-breaks are jittered by a hash of the cell index, and `critical_area_m2` stays at 0.** Across a filled flat the only gradient is the priority-flood's millimetre of epsilon, applied in the order the flood happened to reach the cells, so the router draws the flood's own traversal geometry as ruler-straight diagonal rivers — the polygonal network that killed the first attempt at flat plains, and the dominant artefact once D-49 made the plains genuinely flat. The epsilon is scattered per cell by plus or minus half and the choice between two equally steep neighbours is broken by a hash of the cell and direction rather than by the fixed `dx8` order, both deterministic from the seed (cross-cutting rule 12). Measured: the slope-area fit went from R² 0.055 to 0.459 normalised, 0.039 to 0.664 raw, at no cost. A channelization threshold was re-measured after D-49 and still fails — at 1e4 the plains go back to 7.0 degrees and half the rolling class pins against the clamp — because the hillslope it creates must shed its uplift by diffusion and at D 0.02 it cannot. It stays at 0 until there is a transport law to pair it with. `Decided`. | [Terrain.md](Terrain.md) |
|
||||
| D-48 | **The world's canvas becomes 7141 vertices at 200 cm, elevation −512…1536 m, and it keeps its coastline.** 7140 = 255 × 28, so the engine's importer gives 28×28 components of 255 quads by its own rule (D-45) and the side stays 14 280 m, identical to today's, so nothing `create_world.py` places moves; cells go from 3.5 m to 2.0 m. A 2048 m elevation span *is* Z scale 400, so the incoming spec's canvas is expressed in the manifest's metres contract with no loss, at 3.125 cm precision. The ceiling drops from 2800 m to 1536 m deliberately: 2800 m over 14 km is a Himalayan gradient, and 800–1500 m of relief is what a fluvial landscape this size looks like. `Supersedes` the 2600 m crests the noise was tuned for. The coast stays and sea level is the fixed base level on every ocean cell, which is a better-posed boundary for the stream-power solve than one outlet edge and closes the spec's own open question. A full run holds today's five-minute bar, which sets `fill_every` at 50. `Decided`. | [Terrain.md](Terrain.md) |
|
||||
| D-47 | **The heightmap generator becomes a standalone Go CLI in `Tools/Terrain/`, and stream-power fluvial erosion replaces particle erosion as the thing that shapes the land.** Droplets carve the path a droplet takes; they do not make a drainage network, which is why the world reads as weathered noise rather than as terrain with a history. A Braun & Willett implicit solve over an uplift field on a coarse geology grid does, and plates, faults and lithology exist to feed it; particle erosion, thermal weathering and strata survive as detail passes at full resolution. The core is engine-free and deterministic from a seed (cross-cutting rule 12), and `World.json` stays the single contract, with its `erosion` block replaced by `pipeline`. Retires `heightmap_noise.py`, `heightmap_erosion.py`, `heightmap_io.py` and `generate_heightmap.py`, about 780 lines of tuned numpy whose lessons are carried into the port by name; keeps `create_world.py`, `dump_level.py`, `world_manifest.py` and `ULandscapeAuthoringLibrary`. `Supersedes` D-46 on the language and on which pass shapes the land. | [Terrain.md](Terrain.md) |
|
||||
|
||||
## 2026-09-17 — Off the ladder, the world's terrain is simulated, not only sampled
|
||||
|
||||
| # | Decision | Where |
|
||||
| --- | --- | --- |
|
||||
| D-46 | **The heightmap goes through geological passes after its source, in numpy, at the world's resolution.** Domain-warped ridged noise and cellular crest lines give the uplift; particle hydraulic erosion (a coarse pass for valleys, a fine pass for gullies), mass-conserving thermal weathering at an angle of repose and a strata hardness map shape it; flow, wear, deposition and curvature maps come out beside the weightmaps and drive the layer painting. All of it is engine-free so the source stays swappable, and `"enabled": false` in the manifest skips it for a real DEM. `Decided`. | [RawContent/World/README.md](../RawContent/World/README.md) |
|
||||
|
||||
## 2026-09-16 — Off the ladder, the world's terrain
|
||||
|
||||
| # | Decision | Where |
|
||||
| --- | --- | --- |
|
||||
| D-45 | **The world heightmap is 4081 vertices a side, not Epic's 4033.** The engine's importer picks the largest section size that divides the quad count, preferring one section per component; 4080 divides by 255 and gives 256 components, 4032 divides only by 63 and gives 4096. A resolution change stays on `255 x N + 1` or `127 x N + 1`. | [RawContent/World/README.md](../RawContent/World/README.md) |
|
||||
| D-44 | **`L_World` is a product of a manifest, generated PNGs and one script, never hand-edited.** `RawContent/World/World.json` fixes the size and what a 16-bit value means in metres (world Z 0 is sea level); the height comes from a named source, seeded noise now, a real heightmap by editing the source block; the paint layers are always derived from the height. The level is dressed as Elite_RockyMeadows dresses its own maps, with the numbers read from those maps rather than guessed. `Decided`. | [RawContent/World/README.md](../RawContent/World/README.md), [Worklog](Worklog.md) |
|
||||
|
||||
## 2026-09-16 — Step 2, the telemetry seam
|
||||
|
||||
| # | Decision | Where |
|
||||
|
||||
@@ -26,6 +26,7 @@ Two layers, kept apart on purpose.
|
||||
| [Spec/Networking.md](Spec/Networking.md) | The authority table, the responsiveness tiers, the posture, sessions, identity, persistence. |
|
||||
| [Spec/Telemetry.md](Spec/Telemetry.md) | The sink, the envelope, the catalogue. |
|
||||
| [Spec/UI.md](Spec/UI.md) | The HUD, the prompt, the theme, world-space text, localisation, the menu. |
|
||||
| [Terrain.md](Terrain.md) | Off the ladder: the world's heightmap generator, the canvas, the passes, the Go core, the editor bridge. Not in `Spec/` because it never runs in a game; only the determinism rule governs it. |
|
||||
|
||||
## Reading order
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
# Terrain: next steps
|
||||
|
||||
A working brief for the next session on the heightmap generator. It exists so a cleared session can pick the
|
||||
work up cold. [`Terrain.md`](Terrain.md) is still the specification and the decision record; this is only
|
||||
"where it stands, what looks wrong, and what to do about it". **Fold anything settled back into `Terrain.md`
|
||||
and delete the corresponding section here.** Do not let the two drift.
|
||||
|
||||
Everything below is off the ladder (D-47). No gameplay code may reach into the generator, and nothing here
|
||||
blocks a step in [`Steps.md`](Steps.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Read this first
|
||||
|
||||
The generator is a Go CLI in `Tools/Terrain/`. It turns a seed and `RawContent/World/World.json` into a
|
||||
heightmap, by building an **uplift rate field** and letting a stream-power erosion solve produce the terrain
|
||||
from it. The noise is not the terrain; the noise is the tectonics.
|
||||
|
||||
```bash
|
||||
cd Tools/Terrain && go build -o bin/terrain.exe ./cmd/terrain && go test ./...
|
||||
# a preview run: ~4 minutes at 1400², the iteration loop
|
||||
Tools/Terrain/bin/terrain.exe generate --size 1400 --out RawContent/World/Try1 --quiet
|
||||
# another continent
|
||||
Tools/Terrain/bin/terrain.exe generate --size 1400 --seed 9342 --out RawContent/World/Seed_9342 --quiet
|
||||
```
|
||||
|
||||
`--size` keeps the map's physical extent and samples it more coarsely, so metres, uplift rates and the
|
||||
stream-power constants all still mean what they mean. **Do not tune on `--size 512`**: the geology cell there
|
||||
is 28 m against 8–10 m at full size, and steady-state slope goes as `U/(K*A^m)` with `A = cell²` at every
|
||||
divide, so a 512 preview shows gentler ground than the real run for reasons that have nothing to do with the
|
||||
change being judged.
|
||||
|
||||
Every `bs.*`-style knob in the manifest has a `--flag` override so an experiment does not need a file edit:
|
||||
`--intraplate`, `--intraplate-swell`, `--convergent`, `--k`, `--diffusion`, `--talus`, `--critical-m2`,
|
||||
`--critical-slope`, `--slope-cap`, `--hillslope-substeps`, `--lithology-types`, `--fault-scale`, `--steps`,
|
||||
and for the coast `--no-coast`, `--outline-octaves`, `--outline-gain`, `--shelf-km`, `--surf-reach`,
|
||||
`--cut-fraction`, `--deposit-reach`, `--drift`, `--river-sediment`.
|
||||
|
||||
### What a run writes
|
||||
|
||||
| File | What it is for |
|
||||
| --- | --- |
|
||||
| `preview.png` | Hypsometric tint, hillshade, rivers. "Does this look like a landscape" |
|
||||
| `preview_detail.png` | A crop at 2× vertical exaggeration. The whole continent at 1600 px cannot show whether lowlands read as hill country or as small mountains; this can. Move it with `--crop-x/-y/-size`. It cannot be rendered finer than the grid: a crop of 0.14 at `--size 1400` is 196 cells, so that is the image, whatever `Size` asks for |
|
||||
| `geology_height.png` | The 16-bit heightmap itself, encoded to the manifest's elevation range |
|
||||
| `map_uplift.png` | **The most useful diagnostic.** Rock uplift in mm/yr — the field everything else is a consequence of. It and `map_slope` should be recognisably the same picture; when they are not, something downstream is overriding the tectonics |
|
||||
| `map_slope.png` | Degrees, 0–45 |
|
||||
| `map_relief.png` | Local relief over 500 m. Separates a 5 m hummock from a 500 m mountainside — both stand at 30° and the slope map cannot tell them apart |
|
||||
| `map_erodibility.png` | The lithology multiplier on K. Where texture inside a range comes from |
|
||||
| `map_exposure.png` | How open the water is in front of each stretch of shore, 0 sheltered to 1 open. Drawn only within a kilometre of the waterline, because past that it is a map of the continent's medial axis. The one to read when a beach turns up on a headland |
|
||||
| `map_coast.png` | Everything the coastal pass moved, in metres: cool where the surf cut, warm where the sediment landed. The sea floor is excluded, or its few hundred metres would swamp the few the processes move |
|
||||
| `map_flow.png`, `geology_flow.png` | Log drainage area: the rivers |
|
||||
| `map_basins.png` | One colour per drainage basin, hashed from the basin root. The direct test of whether the solve made a *network* rather than scratches: basins must tile the land, sizes must span orders of magnitude, and divides must sit on the ridge crests. Confetti means the router is re-deciding where water goes every few cells |
|
||||
| `meta.json` | The full manifest as resolved, plus every statistic |
|
||||
|
||||
### How a run is judged
|
||||
|
||||
The printed summary is the verdict, and the block that matters most is the per-uplift-class breakdown —
|
||||
map-wide medians cannot answer "are the plains plains", which is precisely how the last problem stayed
|
||||
invisible. Current state, seed 7 at 1400²:
|
||||
|
||||
```
|
||||
slopes: 54% under 15 deg, 77% under 30, 0.3% over 50, median 11.2 deg
|
||||
slope-area: exponent -0.384 (expect -0.500), R2 0.225 over 6 bins
|
||||
hypsometric integral 0.107 (concave: over-eroded); drainage density 0.48 /km
|
||||
by uplift class:
|
||||
plain 0.00..0.10 mm/yr 42% of land slope 0.8 deg median, 2.8 P90 relief 9 m/490 m at talus 1%
|
||||
rolling 0.10..0.50 mm/yr 7% of land slope 7.5 deg median, 23.1 P90 relief 85 m/490 m at talus 1%
|
||||
mountain 0.50.. up mm/yr 51% of land slope 28.5 deg median, 36.0 P90 relief 195 m/490 m at talus 33%
|
||||
```
|
||||
|
||||
The coast prints its own block after it, and the numbers to read first are the sediment budget — it is the one
|
||||
part of the pass not derived from something already measured — and the exposure percentiles, which say whether
|
||||
the shoreline has any bays for the shelter to work with:
|
||||
|
||||
```
|
||||
coast: 95 km of shoreline, 37% sea, shelf 86% of it; surf planed 2.8 km2 and cut 21.23 Mm3,
|
||||
16 river mouths delivered 3.87 Mm3, 25.04 Mm3 laid (0% unplaced) as 2.92 km2 of new beach;
|
||||
mean cliff 3 m, 0.00 km2 drowned; shore exposure 0.00 / 0.90 / 1.00 (p10/p50/p90)
|
||||
```
|
||||
|
||||
An unplaced fraction above a few per cent means the sediment has nowhere to go and the deposition gates are
|
||||
wrong; a median exposure of 1.00 means the outline has no bays at all and neither the surf reach nor the
|
||||
shelter is doing any work.
|
||||
|
||||
**The slope–area fit cannot judge one change on one seed, and it was nearly used to reject a good one.** It is
|
||||
the number this document calls the proof that closes the work, and at five or six bins on a 1400 grid its
|
||||
seed-to-seed spread on *identical code* is larger than most changes: seed 7 gives −0.480 at R² 0.317, seed 9342
|
||||
gives −0.698 at R² 0.704, and seed 67914 gives −0.575 at R² 0.944. Use it paired — the same seed before and
|
||||
after — and across at least two seeds, or raise the bin count before leaning on it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What was just built, in one paragraph
|
||||
|
||||
The coast, which until now was a line in a mask: the sea floor dropped to a flat plane at −180 m in one step
|
||||
and no process knew the shoreline was there. `internal/coast` adds three that do, each derived rather than
|
||||
drawn — a continental shelf whose width is read off the relief standing behind each stretch of shore, a surf
|
||||
that planes the land to a shore platform within a reach set by how open the water is (the cliff is the step
|
||||
where the reach ends), and a sediment budget that carries what the surf cut along the shore and lays it in
|
||||
sheltered shallow water, with river mouths delivering their own load. It runs after the fluvial solve, on the
|
||||
terrain the solve produced, and it owns the sea floor outright: `uplift.Result.Bathymetry` is gone and ocean
|
||||
cells stay at sea level for the whole solve. Measuring it then said something about the *continent* rather than
|
||||
about the coast — the fetch reported the median stretch of shoreline as fully open, because five octaves of
|
||||
outline noise over a 14 km map put the finest coastal feature at 450 m and a coastline is fractal. D-51 takes
|
||||
the outline to 8 octaves at gain 0.62, which is 96 km of shoreline against 64.
|
||||
|
||||
Full detail, including four things that were wrong first, is in `Terrain.md` under **What was built, and where
|
||||
it differs**.
|
||||
|
||||
## 3. Where this is going
|
||||
|
||||
**Composition is parked.** The mountain fraction, the range grain and the fault traces are all real and all
|
||||
still listed below, but they are *tuning* and the map is good enough to work against. Do not spend the next
|
||||
session on them.
|
||||
|
||||
The goal is: **get generation working end to end, then make the world author-driven and scalable.** Three
|
||||
things, in order.
|
||||
|
||||
### 3.1 Finish the pipeline (build-order step 6)
|
||||
|
||||
Passes 8–14 are unbuilt — upsample, detail noise, strata, particle, fine thermal, spawn pad, derive — so the
|
||||
generator stops at the geology grid and `L_World` is still built by the numpy pipeline it was meant to
|
||||
replace. Until this lands there is no full-resolution output and nothing to import, at any scale. It is also
|
||||
the only work that changes how the terrain reads to a player standing on it: see §4.C.
|
||||
|
||||
### 3.2 Painted maps as the source
|
||||
|
||||
An author paints a world map; the simulation turns it into terrain. The manifest already anticipates a file
|
||||
source — `"source": {"kind": "file", "path": ...}` is documented in `RawContent/World/README.md` and
|
||||
`field.ReadHeightmap` exists — but **nothing in the Go tool reads it**: `Source.Kind` appears only in a
|
||||
`Describe()` string, and the run always builds noise. So this is new work, not a re-wiring.
|
||||
|
||||
**Paint the uplift, not the height.** This is the one design decision that matters and it follows directly
|
||||
from D-47 and from everything measured this session. The architecture is *noise becomes tectonics, and the
|
||||
solve makes the terrain*; a painted heightmap would be handed to a solver that promptly erodes it into
|
||||
something else, throwing away the drainage network that is the entire reason the generator was rewritten.
|
||||
Painting uplift instead means an author draws intent — "a range here, lowlands there, coast like this" — and
|
||||
gets terrain with real rivers, real divides and real valley hierarchy honouring it.
|
||||
|
||||
Suggested channels, all optional, all falling back to the procedural field where absent:
|
||||
|
||||
| Painted layer | Feeds | Notes |
|
||||
| --- | --- | --- |
|
||||
| Land / sea mask | `uplift.Result.Land`, `Base` | The outline. Almost certainly the first thing anyone wants to draw |
|
||||
| Uplift rate | `Result.Rate` | The load-bearing one. Greyscale mapped to a mm/yr range from the manifest |
|
||||
| Erodibility | `Result.K` | Rock types. Cheap, and it is where texture inside a range comes from |
|
||||
| Sea floor | `Result.Bathymetry` | Cosmetic; it is put back after the solve and never erodes |
|
||||
| Fault lines | `buildFaults` | Later. A line layer, not a raster |
|
||||
|
||||
**Rivers cannot be painted directly**, and it is worth knowing why before someone tries: a river is an
|
||||
*output* of the drainage solve. What does work is biasing — raise `K` along a painted line so the water finds
|
||||
the soft rock, or drop the uplift slightly along it, or seed a shallow valley into the initial relief. The
|
||||
solve then chooses to put a river there for its own reasons and the result is still a coherent network. A
|
||||
painted line forced into the height directly would be cut apart by the first thousand steps.
|
||||
|
||||
**The blend rule, which keeps painted maps from looking painted.** A painted map is coarse — 2048 px across a
|
||||
100 km world is 50 m a pixel, five geology cells. Upsample it smoothly and let procedural noise supply
|
||||
everything below its pixel size: **the painted map owns wavelengths above its resolution, noise owns those
|
||||
below.** Without that rule a painted world is visibly blocky at the paint resolution; with it, an author
|
||||
controls structure and the generator still supplies texture.
|
||||
|
||||
### 3.3 Scale, and why tiling is an architecture question
|
||||
|
||||
A world is big. Today's canvas is 14.28 km a side; the interesting sizes are 50–200 km. The numbers, measured
|
||||
and extrapolated from the 256 s full geology run:
|
||||
|
||||
| World side | Area | Geology cells at 8 m | Fluvial solve, 1000 steps | Grid memory |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 14 km — today | 204 km² | 3.2 M | 4 min | ~150 MB |
|
||||
| 50 km | 2 500 km² | 39 M | ~50 min | ~1.8 GB |
|
||||
| 100 km | 10 000 km² | 156 M | ~3.5 h | ~7 GB |
|
||||
| 200 km | 40 000 km² | 625 M | ~14 h | ~28 GB |
|
||||
|
||||
**The fluvial solve cannot be tiled.** Drainage area accumulates across the whole map and the priority-flood
|
||||
needs global connectivity, so a river crossing a tile boundary needs its upstream catchment from the next
|
||||
tile. Solving tiles independently gives wrong drainage areas and a discontinuity at every seam — and drainage
|
||||
area is the term the whole model is built on. Halo exchange between tiles would work in principle and is a
|
||||
large, iterative piece of work.
|
||||
|
||||
**The detail passes tile perfectly**, because every one of them is local: noise is pointwise, thermal
|
||||
weathering propagates a cell at a time, and a droplet travels at most its lifetime in cells.
|
||||
|
||||
So the architecture already contains the answer, and it is the two-grid split that is already there:
|
||||
|
||||
> **Solve the geology whole, once, at a fixed physical cell size. Tile only the detail.**
|
||||
|
||||
That gives consistent relief for free, because the geology cell never changes — which matters more than it
|
||||
sounds, and §4.D.3 explains why. It makes maximum world size a memory-and-patience question rather than a
|
||||
correctness one: ~50 km is an hour, 100 km is an overnight bake, and beyond that the geology stage needs to
|
||||
go out-of-core. Since the goal is explicitly a batched, offline bake, that seems an acceptable trade — but it
|
||||
should be a decision made deliberately, with these numbers in front of whoever makes it.
|
||||
|
||||
**Two rules that make tiles seamless, and are much easier to adopt now than to retrofit:**
|
||||
|
||||
1. **Index every noise and every hash by absolute world coordinates, never by tile-local index.** Both the
|
||||
fBm lattices in `internal/noise` and the D8 router's jitter (`internal/fluvial/jitter.go`, D-50) currently
|
||||
key off grid index. Two tiles would then get different values for the same physical place and every seam
|
||||
would show. This is a small change now and a pervasive one later.
|
||||
2. **Every tile carries an overlap margin, discarded after the pass.** Size it by how far the pass can move
|
||||
material: a few cells for thermal, the droplet lifetime (~40–64 cells) for particle, zero for pointwise
|
||||
noise. Cheapest correct approach; no inter-tile communication needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. What looks wrong now
|
||||
|
||||
Ordered by how much it matters to the direction above, which is *not* the order of how visible it is on a
|
||||
preview image.
|
||||
|
||||
### C. Detail — nothing exists at player scale · the blocker
|
||||
|
||||
Passes 8–14 of the pipeline table in `Terrain.md` are entirely unbuilt: upsample, detail noise, strata,
|
||||
particle erosion, fine thermal, spawn pad, derive. The generator stops at the geology grid — 8 m cells at full
|
||||
resolution — so at 2 m quads a player stands on a 4× upsample of a coarse grid with **no detail added at
|
||||
all**. Ledges, scree, gullies, the strata shelves on a cut face: all of it lives in those passes, and every
|
||||
one already exists as tuned numpy in `Scripts/Authoring/heightmap_erosion.py` waiting to be **ported, not
|
||||
reinvented**. Carry its brakes across unchanged — the droplet slope gate, the per-step cut cap, the load cap,
|
||||
the 3×3 cut brush and the own-cell deposit are each a lesson from the Worklog.
|
||||
|
||||
Budget from `Terrain.md`: upsample and detail noise 15 s, particle 90 s, fine thermal 20 s. The fluvial pass
|
||||
is already 256 s against 120 s budgeted, so the five-minute bar is at risk before these land — and §3.3 says
|
||||
the bar is probably the wrong constraint for a batch bake anyway. Worth deciding rather than drifting.
|
||||
|
||||
### D. Scale-independence — the one that becomes load-bearing
|
||||
|
||||
**D.3 Relief is resolution-dependent, and multi-scale makes that a blocker rather than a wart.** Measured
|
||||
earlier: 1020 m relief at 512² against 2605 m at 1786² on one seed. The cause is that with
|
||||
`critical_area_m2` at 0, stream power is applied down to a single cell, so the divide slope is
|
||||
`U/(K·cell^2m)` — halve the cell and every divide steepens, for ever. As long as there was one canvas this was
|
||||
a wart. The moment the same painted map is meant to produce worlds at different sizes or resolutions, it
|
||||
means **the same input gives a different landscape depending on grid size**, which is fatal to the whole idea.
|
||||
|
||||
The textbook fix is a critical area, and it was re-measured this session and still fails (§6) because the
|
||||
hillslope it creates has no transport law strong enough to shed its uplift. So this is genuinely open, and
|
||||
the two candidate directions are:
|
||||
|
||||
- Fix the pairing: critical area *plus* a hillslope diffusivity that scales with cell size (effective `D`
|
||||
grows roughly as cell², which is the standard sub-grid argument). Principled, and it makes the critical
|
||||
area work rather than fail.
|
||||
- Sidestep it: **always solve the geology at one fixed physical cell size** and never vary it, per §3.3. Free,
|
||||
correct by construction, and it costs the ability to trade resolution for runtime on a big world.
|
||||
|
||||
The second is the recommendation for now because it is free and unblocks everything; the first is what to
|
||||
build if the bake times in §3.3 turn out to be unacceptable.
|
||||
|
||||
**D.4 Two open questions that are design, not defects.** The hypsometric integral is 0.10 against 0.4–0.6 for
|
||||
a mature landscape — no longer a bimodal uplift field, now simply that 42 % of the land is a near-sea-level
|
||||
plain, which is a question for the `continent` block and will move when composition is tuned. And drainage
|
||||
density is 0.41–0.48 /km, right on the 0.5 floor set as do-not-cross; it is measured at a 1 km² channel
|
||||
threshold so it is not directly comparable to a field value, but if it drops further while tuning, raise the
|
||||
intraplate rate to 0.05 before touching anything else.
|
||||
|
||||
### E. The coast — what it built, and the four things it cannot do yet
|
||||
|
||||
**E1. ~~There are no sea cliffs~~ — withdrawn, and the metric that said so is replaced.** This entry read "mean
|
||||
cliff height is 2 to 3 m, and the surf has nothing to cut". Both halves were wrong, and the way they were wrong
|
||||
is the part worth keeping: *the statistic measured the drop from a cell to its seaward neighbour*, which is a
|
||||
gradient. One cell of a 10 m grid at the angle of repose is 7 m, so the number could never have exceeded 7
|
||||
whatever the coast did — it read 2 m on a plain coast and 3 m on a cliffed one because it could not tell them
|
||||
apart. It is now backshore height: the land's elevation one to two surf reaches inland, median and P90. On that
|
||||
metric the coast has always had cliffs (seed 7 P90 88 m, seed 9342 108 m, seed 67914 120 m); the median, 3 to
|
||||
9 m, says the ordinary coast is a plain, which it should.
|
||||
|
||||
What the mask taper was actually doing is narrower and is now fixed anyway (D-52): it flattened the ~100 m
|
||||
strip the surf works in, so the cliff began a hundred metres inland instead of at the water. Removing it is
|
||||
worth 35 to 67 % more surf cut and a visibly steeper shore, and the principle stands — **where the land ends
|
||||
does not decide how fast it is rising** — but it was a sharpening, not the transformation this entry predicted.
|
||||
|
||||
**E1b. The map margin draws one coastline in seven, and it draws it straight.** Measured on three seeds:
|
||||
14.1 %, 15.0 % and 14.0 % of the waterline sits inside the 4 % margin band that `continentMask` imposes to keep
|
||||
land off the map border. The margin tapers by distance-to-edge, and a contour of distance-to-edge is a line
|
||||
parallel to that edge, so wherever the continent would have run past the boundary it is cut off square. This is
|
||||
pre-existing and it is *not* the frozen-rim failure the margin exists to prevent — `TestBorderIsAlwaysOcean`
|
||||
confirms every border cell is still ocean on all three seeds, so nothing is frozen. It is cosmetic, and D-52
|
||||
made it conspicuous: land inside the band now takes the full 2.0 mm/yr instead of a tapered rate, so the
|
||||
straight-cut coast can be a mountain range rather than a low plain, which is exactly what seed 67914's southern
|
||||
coast is. Cheapest fix, and it belongs with the outline work rather than with the coastal pass: perturb the
|
||||
margin distance with a low-amplitude noise field so the cut follows a crenellated line instead of a ruled one.
|
||||
|
||||
**E2. The shelter contrast is real but thin.** Exposure comes out 0.00 / 0.90 / 1.00 at p10 / p50 / p90, so the
|
||||
distribution is one long tail: a handful of genuine embayments and a lot of open coast. A floor of 0.15 on
|
||||
shelter carries most of the deposition, which is deliberate — measured with no floor, 73 % of the sediment
|
||||
budget came back unplaced, because real exposed coasts do have beaches, they just have less sand than the bay
|
||||
next door. Re-measure this once the outline is painted rather than noised; it is the same question as E1 from
|
||||
the other end.
|
||||
|
||||
**E3. The beach is a beach at 8 m, which is to say it is not one.** The surf reach is 110 m, or 14 cells on the
|
||||
geology grid, and the berm, the wave-cut notch, the scree below a cliff and the sand itself are all finer than
|
||||
that. They belong in the detail passes (§4.C) — and note that the surf reach is one of the few lengths in the
|
||||
generator that is set by physics rather than by the canvas, so it does *not* scale with the map: at the 2 m
|
||||
detail grid it is 55 cells, which is enough for a real profile. The coastal detail pass is a natural addition
|
||||
to the list in §4.C rather than a separate piece of work.
|
||||
|
||||
**E4. Deltas are placed, not tuned.** `river_m3_per_km2` is 1.2e5 with an exponent of 0.6, and both are guesses;
|
||||
on seed 7 they put 3.9 Mm³ through 16 mouths against 21 Mm³ from the cliffs. The mechanism is right — the supply
|
||||
concentrates at the mouth and the drift kernel spreads it into the shallows — but nothing has yet asked whether
|
||||
what comes out reads as a delta. `map_coast.png` at a crop is where that is judged.
|
||||
|
||||
**E5. Unverified: speckle at the finer canvas.** The outline gain went from 0.50 to 0.62 on measurements taken
|
||||
at `--size 1400` (10.2 m cells). A finer grid resolves more of the threshold's wander, so the D-48 canvas at
|
||||
8 m, and any painted map after it, could turn the same setting into a scatter of one-cell islands. Check the
|
||||
shoreline length per unit land area and look at `preview.png` before assuming it carries over.
|
||||
|
||||
### B. Texture — wrong at mid scale
|
||||
|
||||
**B1. 33 % of the mountain class still sits within 2° of the repose angle**, so a third of the mountains are
|
||||
shaped by the clamp rather than by erosion. Down from 44 %, and the nonlinear diffusion pass after the clamp
|
||||
keeps it from showing as hard facets. Levers, most principled first: raise `max_hillslope_substeps` and
|
||||
`slope_cap` (costs runtime, buys real stiffening); raise `diffusion_m2_yr` (cheap, but past ~0.05 it smooths
|
||||
away the landforms — measured before as "melted wax"); or accept it, since a belt rising at 2 mm/yr genuinely
|
||||
*is* landslide-dominated in the real world and the clamp is the right model there.
|
||||
|
||||
**B2. Multiple-flow-direction accumulation is unbuilt.** The hash jitter recovered most of the damage (R²
|
||||
0.055 → 0.459) but D8 still lets a cell drain to only one of eight neighbours, and some basin boundaries on
|
||||
the plains in `map_basins.png` are visibly straight. The proper fix is Freeman/Quinn MFD for `Accumulate`
|
||||
only, keeping D8 receivers for the implicit solve — Braun–Willett needs a single receiver per node for the
|
||||
update, but the *area* can come from MFD. Cost: MFD needs its own processing order (descending elevation)
|
||||
rather than the D8 stack.
|
||||
|
||||
**B3. A ribbed, combed texture on the range flanks**, regularly spaced, roughly perpendicular to the crest.
|
||||
Not diagnosed. Candidates to check before changing anything: the ridged-noise initial relief showing through
|
||||
where the solve has not had time to overwrite it; channel spacing locking to the grid at small drainage area;
|
||||
or the `crests` cellular-edge field at `crest_weight` 0.12. Test with `--stage uplift` and compare the initial
|
||||
relief against the final flanks.
|
||||
|
||||
### A. Composition — parked, but recorded
|
||||
|
||||
All three are one-or-two-constant changes. They are listed so they are not rediscovered, not because they are
|
||||
next.
|
||||
|
||||
**A1. Half the continent is mountain** — 51 % of land on seed 7, 44 % on seed 9342, against nothing like that
|
||||
in reality. The cause is arithmetic:
|
||||
|
||||
```go
|
||||
// internal/uplift/uplift.go
|
||||
rangeMask := percentileMask(band, cfg.Plates.LowUpliftFraction.Hi()*100, 86) // ramps 40th → 86th percentile
|
||||
r := base + (convergent-base)*float64(rangeMask.Data[i])
|
||||
```
|
||||
|
||||
With `base` ≈ 0.055 and `convergent` 2.0 a cell clears the 0.5 mm/yr mountain threshold at `rangeMask` 0.229,
|
||||
which the smoothstep reaches at the **54th percentile** — so 46 % of the map is mountain-class by
|
||||
construction, matching the 44–51 % measured on land. Computed options:
|
||||
|
||||
| percentile ramp | exponent on the mask | mountain class, % of map |
|
||||
| --- | --- | --- |
|
||||
| 40 → 86 | 1 — today | 46 % |
|
||||
| 40 → 86 | 2 | 38 % |
|
||||
| 40 → 86 | 3 | 34 % |
|
||||
| 60 → 92 | 2 | 24 % |
|
||||
| 70 → 92 | 2 | **19 %** |
|
||||
| 75 → 95 | 2 | 15 % |
|
||||
|
||||
Squaring the mask is *not* the same as narrowing the ramp: the foreland stays continuous, which is the
|
||||
property the percentile ramp exists to provide. Note also that the manifest key `low_uplift_fraction` encodes
|
||||
the spec's "20–40 % of the map at low uplift" constraint, which is satisfied trivially and always has been;
|
||||
the constraint that actually binds is what fraction is *high* uplift, and nothing names it.
|
||||
|
||||
**A2. Fault traces are drawn curves with stamped ends** — visible in `map_uplift.png` as straight-edged
|
||||
polygonal facets and an abrupt cut across a summit. Four causes, all in `buildFaults`: the trace is a single
|
||||
8-point parabola (`const segs = 8`, one `wander` bow); `signedDistance` over 8 straight segments gives a
|
||||
piecewise-linear distance field, hence polygonal contours; beyond the last segment `inside` is false and the
|
||||
influence stops dead; and `if r > convergent*1.6` flattens the strongest throws into plateaus. Fix: an
|
||||
fBm-perturbed heading, `ThrowM` tapered to zero over the last ~15 % of length instead of cut at the tip, and
|
||||
long faults broken into 2–3 overlapping en-echelon segments.
|
||||
|
||||
**A3. Range grain runs as straight parallel bands** — chains run NW–SE like corduroy on seed 9342.
|
||||
`bv.Data[i] = float32(0.5 + across*2.2 + float64(wy.Data[i]-0.5)*0.32)` stretches the band 2.2× along one
|
||||
angle with a single mild warp octave. Raise the warp, or warp with two octaves at different scales so chains
|
||||
bend and bifurcate.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested order
|
||||
|
||||
1. **Adopt the two seam rules from §3.3 now** — world-coordinate indexing for all noise and hashes. It is a
|
||||
small change today and a pervasive one after the detail passes exist.
|
||||
2. **Build the detail passes (§4.C), and put the coastal detail in with them (§4.E3).** Port from the numpy,
|
||||
keep every brake, profile before any GPU work. This is the blocker for everything else and the only work
|
||||
that changes how the ground reads to a player — and the shore is where a player will stand first.
|
||||
3. **Decide the scale question (§3.3 and §4.D.3)** with the bake-time table in front of you: fixed geology
|
||||
cell and tiled detail, or critical area with cell-scaled diffusivity. The first is free; take it unless
|
||||
the bake times are unacceptable.
|
||||
4. **Wire the painted-map source (§3.2).** Mask first, then uplift, then erodibility — each independently
|
||||
useful, each falling back to the procedural field. Get the blend rule right from the start. Two of them now
|
||||
have consumers that did not exist before: the mask is the coastline the coastal pass works on, and the
|
||||
uplift is what decides whether the shore is a plain or a cliff (§4.E1).
|
||||
5. **Then composition (§4.A)**, which by then can be judged against a real painted world rather than against
|
||||
noise.
|
||||
|
||||
Build-order steps 1 and 2 in `Terrain.md` — the editor viewport and the `Generated` edit layer — remain open,
|
||||
remain first in that list, and are worth doing whatever happens here: a generator whose output cannot be seen
|
||||
in the editor cannot be iterated on, and sculpting that does not survive a rerun makes the whole tool
|
||||
one-shot. They matter *more* under this direction, not less, because an authored world is one somebody will
|
||||
want to touch up by hand.
|
||||
|
||||
---
|
||||
|
||||
## 6. Do not redo these
|
||||
|
||||
Each was measured, not guessed.
|
||||
|
||||
- **`critical_area_m2` above 0, on its own.** Re-measured after the uplift fix and it still fails: 1e4 sends
|
||||
the plains back to 7.0°, pins 49 % of the rolling class and 79 % of the mountains against the clamp, and
|
||||
collapses the slope-area fit to R² 0.001. The hillslope it creates has to shed its uplift by diffusion and
|
||||
at D 0.02 it cannot. It is not a tuning knob — it needs a transport law to pair with. See §4.D.3, where it
|
||||
comes back as a real candidate for a real reason.
|
||||
- **Running the repose clamp only once at the end.** A thousand steps of growth arrive together, it cuts
|
||||
deeply, and nothing runs afterwards to soften it. The grid facets came back in the summits.
|
||||
- **`fill_every` above 1.** The 50 was written to protect the time budget and it silently destroys the solve.
|
||||
- **Raising the intraplate uplift rate to give the plains relief.** That is the mistake this round undid. For
|
||||
n = 1 the uplift rate alone fixes the hillslope angle; `U` sets how high summits get, not how steep ground
|
||||
is. If plains need texture it comes from the detail passes or from lithology, never from U.
|
||||
- **An elevation-gated talus** ("below 150 m, don't dissect"). It would work immediately because the clamp
|
||||
binds everywhere, but elevation is the *output* of the solve, so gating a process on it is circular, it
|
||||
produces a visible shelf at the threshold, and it hides the cause.
|
||||
- **A percentile stretch for shore exposure, and casting fetch in every direction.** Both measured, both
|
||||
wrong, and both recorded in `Terrain.md`: a percentile collapses on a coast that does not vary and is a
|
||||
global statistic two tiles would disagree about, and an all-directions fetch counts the land behind the shore
|
||||
as shelter, which made a straight open coast score as more sheltered than a bay.
|
||||
- **One blur kernel for both the sediment and the carried per-shore values.** The sediment balance needs a
|
||||
symmetric kernel, which means zero padding; a carried value needs edge clamping, or every shelf near the map
|
||||
border shrinks to nothing. There are two, and they share their arithmetic on purpose.
|
||||
- **Importing a painted *heightmap* as the terrain.** See §3.2. The solve will erode it into something else
|
||||
and the drainage network — the reason the generator exists — is thrown away. Paint the uplift.
|
||||
|
||||
## 7. Traps
|
||||
|
||||
- **`RawContent/World/World.json` is still pre-D-48**: 4081 vertices at 350 cm, elevation −460…2800, and it
|
||||
still carries the legacy `erosion` block the tool warns about on every run. The Go defaults implement D-48
|
||||
(7141 at 200 cm, −512…1536) and the manifest overrides them straight back. Migrating it is build-order step
|
||||
8 and it changes every measured number in this doc, so either do it deliberately and re-baseline, or leave
|
||||
it alone.
|
||||
- **Never run an authoring script while the editor has `L_World` open**, and run `create_world.py` detached,
|
||||
never under a tool timeout — a timeout killed one mid-import before.
|
||||
- **The elevation ceiling is a hard clip** in the 16-bit encoding, so a run reporting a clip fraction above
|
||||
0.1 % is a failed run, not a rounded one. `U/K` is the one relief knob. Note this gets harder with painted
|
||||
uplift, where an author can ask for more relief than the range holds.
|
||||
- **Determinism from the seed is cross-cutting rule 12 and it applies here completely.** Anything random must
|
||||
be a hash of (seed, position), never a stateful source: the value for a cell must not depend on how many
|
||||
cells were visited first, which goroutine ran, or how many steps have passed.
|
||||
`TestDeterministicAcrossGOMAXPROCS` hashes the output at five values of `GOMAXPROCS` and requires one hash.
|
||||
Under §3.3's rule 1 that becomes a hash of (seed, *world position*).
|
||||
- **A test on a grid whose edge is an outlet must say which cells it is asking about.** Two of the four new
|
||||
hillslope tests passed while measuring nothing: one read the fixed border cells back and called them the
|
||||
result, the other wrote its initial condition across the fixed border, which then re-injected it into the
|
||||
interior for ever.
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
# Terrain
|
||||
|
||||
The world's heightmap generator: what it is, what it will be, and what happens to what exists. Off the ladder,
|
||||
like everything about `L_World`, so nothing here blocks a step and no gameplay code may reach into it.
|
||||
|
||||
**The current working brief is [`Terrain-Next.md`](Terrain-Next.md)**: what the generator produces today,
|
||||
what still looks wrong and in what order to fix it. Read it if you are picking the work up; read this one for
|
||||
why anything is the way it is. Settled work moves from there to here.
|
||||
|
||||
This document reconciles a procedural terrain specification (tectonics → faults → lithology → stream-power
|
||||
erosion, a Go core, an editor bridge into a Landscape edit layer) with the pipeline already in the repository.
|
||||
The two agree on the goal and disagree on almost every number, so the point of this document is to settle the
|
||||
disagreements once, in writing, before any of it is built. Where the incoming spec is called "the spec" below,
|
||||
its resolution is marked with the project's own notation: `[DECIDED]` settled with the reason, `[PROPOSED]` the
|
||||
recommended shape not yet built on, `Qn` an open question collected at the end.
|
||||
|
||||
It is not in [`Spec/`](Spec/README.md). The spec set is the gameplay specification, C++-shaped and governed by
|
||||
the twelve cross-cutting rules; this is an offline tool that never runs in a game. Of those rules only the
|
||||
twelfth, determinism from a seed, applies, and it applies completely. The level *dressing* — the material, the
|
||||
sun, the fog, the sea plane, the pack's misleading layer names — stays where it is documented, in
|
||||
[`../RawContent/World/README.md`](../RawContent/World/README.md).
|
||||
|
||||
## Why change anything
|
||||
|
||||
The user's verdict on the current world was "still somewhat rough", and the Worklog's open item says the same
|
||||
three things: sculpting does not survive a rerun, the terrain is invisible in the editor viewport, and nothing
|
||||
grows on it. None of those is the reason to rewrite the generator. The reason is narrower and the spec names it:
|
||||
|
||||
**Particle erosion does not make drainage.** Droplets carve the path each droplet happens to take. They produce
|
||||
gullies, rills, scree and plausible-looking wear, and the current pipeline does all of that well, but they do
|
||||
not produce a *network*: no branching hierarchy, no valley whose width matches the area it drains, no divide
|
||||
that sits where the two basins either side of it put it. Stream power does, because it solves for drainage area
|
||||
first and then erodes proportionally to it. That is the single change that moves the terrain from "noise that
|
||||
has been weathered" to "terrain that has a history", and everything else in the spec — plates, faults,
|
||||
lithology — exists to give that simulation something to chew on.
|
||||
|
||||
So the order is inverted, and that inversion is the whole reconciliation: today the noise *is* the terrain and
|
||||
erosion decorates it; afterwards the noise is an *uplift field* and the simulation produces the terrain.
|
||||
|
||||
## What exists today, and what becomes of it
|
||||
|
||||
| File | What it is | Fate |
|
||||
| --- | --- | --- |
|
||||
| `RawContent/World/World.json` | The manifest: size, quad, elevation range, sea level, source, erosion settings, layer rules | **Kept and extended.** Its `erosion` block is replaced by a `pipeline` block. It stays the single contract |
|
||||
| `Scripts/Authoring/world_manifest.py` | Reads the manifest, derives Z scale, Z offset, the height encoding | **Kept.** `create_world.py` still needs it. The Go core reads the same file and must derive the same numbers |
|
||||
| `Scripts/Authoring/heightmap_noise.py` | Value noise, fBm, domain warp, Worley crests, blur; the continent generator | **Retired.** Its shapes are reimplemented in Go, its *tuning* is carried across (below) |
|
||||
| `Scripts/Authoring/heightmap_erosion.py` | Particle hydraulic erosion, thermal weathering, strata hardness, curvature | **Demoted and reimplemented.** Particle erosion survives as a detail pass only; thermal and strata survive whole |
|
||||
| `Scripts/Authoring/heightmap_io.py` | 8/16-bit greyscale PNG, raw `.r16`, resample, centred crop — no PIL, numpy only | **Retired.** Go's `image/png` and a small resample cover it. The DEM ingest rules must be ported exactly |
|
||||
| `Scripts/Authoring/generate_heightmap.py` | The driver: source → erosion → spawn pad → encode → derive layers → write PNGs | **Retired**, replaced by the Go CLI. Its layer derivation is ported unchanged in behaviour |
|
||||
| `Scripts/Authoring/create_world.py` | Imports the PNGs, dresses the level from Rocky Meadows' demo maps | **Kept.** Gains the edit-layer path |
|
||||
| `Scripts/Authoring/dump_level.py` | Dumps any level's actors to JSON; how the dressing numbers were read | **Kept**, untouched |
|
||||
| `Source/SaltyEditor/Authoring/LandscapeAuthoringLibrary.*` | `CreateLandscapeFromHeightmap`, the editor's Import button callable from a script | **Kept**, gains `ReimportHeightmapIntoLayer` and a guard on the component layout |
|
||||
|
||||
Choosing Go retires about 780 lines of working numpy. That is the real price of the decision and it is worth
|
||||
stating plainly: those lines are not just shapes, they are five rounds of tuning, and every lesson in the
|
||||
Worklog's "Did not work" section is encoded in a constant somewhere in them. They are listed here so the Go
|
||||
port carries them rather than rediscovering them:
|
||||
|
||||
- **Octave gain.** Eight octaves at gain 0.5 makes every octave as steep as the last and puts a third of the
|
||||
land above 50°. Gains stay at 0.42–0.45 and no octave is finer than about 50 m.
|
||||
- **Cellular crest lines** at 30 % of mountain height turn ranges into a honeycomb of polygon walls. 12 %,
|
||||
through a stronger warp.
|
||||
- **The droplet slope gate** must sit well above the median lowland slope (0.25 rise over run, about 14°), or
|
||||
the meadows come out brushed with rills.
|
||||
- **A droplet's cut is capped per step** (a fifth of a cell height), because droplets step in batches, share
|
||||
cells, and a crowd in one cell runs away to infinity without the cap.
|
||||
- **Cuts go through a 3×3 brush; deposits land on the droplet's own cell.** Spread deposits through the brush
|
||||
and a pit's rim rises faster than its floor, so the pit never fills and every droplet feeds a mound.
|
||||
- **Thermal weathering sheds half the *largest* excess**, not half the mean, or it converges far slower.
|
||||
- **Measure before tuning.** A stage-by-stage slope histogram attributed the rill damage to the coarse pass in
|
||||
one run. No knob is turned on an impression.
|
||||
|
||||
## The canvas `[DECIDED]`
|
||||
|
||||
The spec and the manifest disagree here and the spec is closer to right, but neither number survives contact
|
||||
with what the engine's importer actually does.
|
||||
|
||||
| | Today | The spec | Resolved |
|
||||
| --- | --- | --- | --- |
|
||||
| Vertices a side | 4081 | 7113 | **7141** |
|
||||
| Quad | 350 cm | 200 cm | **200 cm** |
|
||||
| Side | 14.28 km, 204 km² | 14.22 km, 202 km² | **14.28 km, 204 km²** |
|
||||
| Components | 16×16 of 255 quads, 1 section | 28×28 of 127 quads, 2×2 sections | **28×28 of 255 quads, 1 section** |
|
||||
| Component count | 256 | 784 | **784** |
|
||||
| Elevation | −460…2800 m | ±1024 m (Z scale 400) | **−512…1536 m** (Z scale 400) |
|
||||
| Height precision | 5.0 cm | ~3 cm | **3.125 cm** |
|
||||
|
||||
**Why not 7113.** D-45 is not a preference, it is a description of the importer: it picks the largest section
|
||||
size that divides the quad count exactly, preferring one section per component. 7112 = 127 × 56 and is not
|
||||
divisible by 255, so a 7113 heightmap handed to `CreateLandscapeFromHeightmap` as it stands produces 56×56 =
|
||||
**3136 components**, not the spec's 784 — the same trap that made a 4033 import take forty minutes instead of
|
||||
two. The spec's 28×28 layout is only reachable by specifying section size and section count explicitly instead
|
||||
of deriving them, which is a change to the C++ for no gain over a resolution that divides correctly.
|
||||
|
||||
**Why 7141.** 7140 = 255 × 28, so the importer's own rule gives exactly 28×28 components of 255 quads, one
|
||||
section each: the spec's component grid, reached without touching the importer path. At 200 cm the side is
|
||||
14 280 m, *identical* to today's, so the manifest's side length, the sea plane, the spawn pad and every number
|
||||
`create_world.py` places are unchanged. Cells go from 3.5 m to 2.0 m and the sample count from 16.7 M to
|
||||
51.0 M, a factor of 3.06.
|
||||
|
||||
**Why −512…1536 m.** The manifest's contract is an elevation range in metres, from which the Z scale follows;
|
||||
that contract is better than the spec's (a raw Z scale) and it stays. A span of 2048 m *is* Z scale 400, so the
|
||||
spec's canvas is expressible in the manifest's own terms with no loss. It puts the ceiling at 1536 m instead of
|
||||
2800 m, which is the spec's deliberate judgement: 2800 m peaks in a 14 km-wide region is a Himalayan gradient,
|
||||
and the spec's 800–1500 m target relief is what a fluvial landscape of this size actually looks like. The sea
|
||||
floor gets 512 m, more than today's 460 m. Taken with the trade understood: the mountains get lower and the
|
||||
valleys get better. It supersedes the 2600 m crests the noise was tuned for on 2026-09-16, which is the one
|
||||
piece of that tuning the port deliberately does not carry.
|
||||
|
||||
**Streaming.** `streaming_grid_components` stays a manifest key; 784 components at one per proxy is 784
|
||||
packages, three times today's. Recommended value **2**, giving 14×14 = 196 proxies of 1020 m each. This
|
||||
interacts with the Worklog's open item 2 (nothing visible in the editor viewport, most likely because every
|
||||
proxy is spatially loaded and none is loaded in the editor); that item is fixed independently and first, since
|
||||
a generator whose output cannot be looked at cannot be iterated on.
|
||||
|
||||
## The pipeline
|
||||
|
||||
Fields live on a context and are named. Every pass reads and writes named fields and nothing else. Two
|
||||
resolutions: the **geology grid** at 1786² (`(7141 − 1) / 4 + 1`, 8.0 m cells — the spec asked for 2048² at
|
||||
~7 m, and an exact factor of four buys an integer upsample with no resample artefacts), and the **detail grid**
|
||||
at 7141².
|
||||
|
||||
| # | Pass | Grid | Reads | Writes | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | Plates | geology | — | `uplift`, `boundaries` | New |
|
||||
| 2 | Continent | geology | — | `landMask`, `baseLevel` | From today's continent falloff |
|
||||
| 3 | Faults | geology | `boundaries` | `uplift`, `faults`, `warp` | New |
|
||||
| 4 | Lithology | geology | — | `K` | New (plan view) |
|
||||
| 5 | Base relief | geology | `uplift`, `warp` | `height` | From today's noise, amplitude cut hard |
|
||||
| 6 | Fluvial | geology | `height`, `uplift`, `K`, `baseLevel` | `height`, `flowAccum`, `flowDir` | New — the point of the exercise |
|
||||
| 7 | Thermal (coarse) | geology | `height` | `height` | Today's, unchanged |
|
||||
| 7b | Coast | geology | `height`, `landMask`, `flowAccum` | `height`, `landMask`, `exposure` | New (D-51) |
|
||||
| 8 | Upsample | → detail | all | all | New |
|
||||
| 9 | Detail noise | detail | `height`, slope | `height` | From today's detail octaves |
|
||||
| 10 | Strata | detail | — | `hardness` | Today's, unchanged (vertical) |
|
||||
| 11 | Particle | detail | `height`, `hardness` | `height`, `wear`, `deposit` | Today's, demoted to detail |
|
||||
| 12 | Thermal (fine) | detail | `height` | `height` | Today's, unchanged |
|
||||
| 13 | Spawn pad | detail | `height` | `height` | Today's, unchanged |
|
||||
| 14 | Derive | detail | everything | weightmaps, flow/wear/deposit/curvature, `meta.json` | Today's rules, ported |
|
||||
|
||||
Notes where this departs from the spec, each for a reason:
|
||||
|
||||
**Continent and sea (pass 2) are not in the spec at all.** The spec builds an inland region with an outlet on
|
||||
one edge and leaves the boundary condition as an open question. This world has a coast, a sea plane
|
||||
(`World_Sea_Proto`), a `sea_level_m` manifest key and statistics reported as "% above sea level". Keeping the
|
||||
continent is also the better *simulation* choice: sea level is a fixed base level on every cell the land mask
|
||||
calls ocean, which is a far better-posed boundary for a stream-power solve than one fixed edge, and it removes
|
||||
the artificial drainage divide that a single-outlet map has along three of its sides. `[DECIDED]`: the world
|
||||
keeps its coastline, and the spec's §9 boundary question is closed in favour of the continent.
|
||||
|
||||
**Lithology and strata are both kept, because they are orthogonal.** The spec's lithology is a plan-view field
|
||||
of rock types multiplying `K` by 0.5× / 1× / 3×; the existing strata model is *vertical* banding of hardness
|
||||
with a slow tilt, which is what puts shelves and ledges on a cliff face. One varies with where you are, the
|
||||
other with how deep you have cut. Lithology enters the fluvial solve at geology resolution; strata scales the
|
||||
particle pass at detail resolution, exactly as today.
|
||||
|
||||
**Uplift replaces the range mask.** Today `heightmap_noise.generate_metres` builds ranges from an elongated,
|
||||
warped, percentile-thresholded band, thresholded so ranges and foothills cover about two fifths of the map
|
||||
whatever the seed. That percentile trick is the thing that makes the result seed-independent and it is kept —
|
||||
but it now shapes the *uplift rate field* that the fluvial pass integrates, not the height directly. The
|
||||
spec's own constraint (20–40 % of the map at low uplift, so there are basins to build in) is the same
|
||||
statement from the other end, and both are enforced by thresholding on percentile rather than on value.
|
||||
|
||||
**Base relief amplitude is cut hard.** The spec says 50–150 m × normalised uplift and it means it: the fluvial
|
||||
pass is what produces relief, and starting it from 2600 m ridged crests means it spends its whole run tearing
|
||||
them down. Today's noise makes the mountains; after this it only breaks the symmetry.
|
||||
|
||||
**Particle erosion keeps every brake it has.** Demoted from "carves the valleys" to "detail only", which is the
|
||||
spec's §4.8, and explicitly forbidden from reshaping what the fluvial pass produced: the slope gate, the
|
||||
per-step cut cap, the load cap, the 3×3 cut brush and the own-cell deposit all carry over unchanged, and the
|
||||
droplet count scales with cell count (about 9 M at 7141² to hold today's density at 4081²).
|
||||
|
||||
### The fluvial pass, in detail
|
||||
|
||||
`dh/dt = U − K · A^m · S^n`, with `m = 0.5`, `n = 1`, `K` from the lithology field around a base of 2e-5 to
|
||||
1e-4 /yr with A in m². Braun & Willett's 2013 implicit formulation: compute D8 receivers by steepest descent,
|
||||
build the stack, update up the stack. It is O(n) per step and unconditionally stable in `dt`, which is why it
|
||||
is the right solver and a naive explicit one is not.
|
||||
|
||||
- **Pits.** Priority-flood (Barnes) to fill or route depressions, because a D8 receiver graph with a pit in it
|
||||
has no path to base level and the implicit update has nothing to solve against. **It runs every step.**
|
||||
`fill_every` survives as a knob and its value is 1.
|
||||
|
||||
This corrects the paragraph that stood here, which set it to 50 to protect the time budget. That was
|
||||
wrong, and measurably so: uplift reaches 5 mm/yr, which at `dt` 1500 is 7.5 m a step, so fifty steps is up
|
||||
to 375 m of differential uplift between floods — far more than enough to close basins that then sit
|
||||
unrouted while everything upstream of them stops eroding. Measured at 512² over 3000 steps, against an
|
||||
expected exponent of −0.5:
|
||||
|
||||
| `fill_every` | 1 | 5 | 10 | 25 | 50 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| exponent | **−0.500** | −0.277 | −0.121 | −0.061 | −0.100 |
|
||||
| R² | **0.992** | 0.861 | 0.318 | 0.120 | 0.371 |
|
||||
| run | 93 s | 38 s | 32 s | 28 s | 27 s |
|
||||
|
||||
Even every fifth step is already broken, and the saving is not worth having. The budget is paid for
|
||||
elsewhere — see the time budget below. The Cordonnier/Barnes lake-flow variant, which routes through
|
||||
depressions instead of refilling them, remains the fallback if the flood ever has to get cheaper.
|
||||
- **Hillslope diffusion** `D = 0.01–0.05 m²/yr` after each step, which is what rounds the divides and stops
|
||||
the channel heads from being needles.
|
||||
- **Time.** `dt = 1000–2000 yr`, **1000 steps, 1.5 Myr**, not the 5000 steps and 5–15 Myr the incoming spec
|
||||
asks for. That figure is generic advice; at this K and this scale the knickpoint celerity `K·A^m` puts the
|
||||
response time of a trunk channel near 45 000 yr, so 1.5 Myr is already tens of response times. Measured at
|
||||
512², the exponent is −0.499 at 500 steps and does not move afterwards. Finer grids need more, because
|
||||
headwaters carry small drainage areas and so respond slowest: at 1024² the exponent is −0.640 at 500 steps
|
||||
and −0.584 at 1000, still converging while R² sits at 0.99. Stopping early is the knob for a "young",
|
||||
high-relief look, and it is a real one — the landscape is straight-line graded long before it is finished.
|
||||
- **Parallelism.** The stack update is sequential *along a flow path* but independent *between basins*.
|
||||
Partition by basin, not by row, and reduce in basin-id order so the result does not depend on scheduling.
|
||||
|
||||
**The parameter ranges and the canvas are not jointly consistent, and this must be checked every run.** Steady
|
||||
state puts channel slope at `S = U / (K · A^m)`. At the spec's aggressive corner — `U` = 5 mm/yr, `K` = 2e-5 —
|
||||
a cell draining 1 km² sits at 25 % slope, and integrating that up a 7 km profile overshoots the 1536 m ceiling
|
||||
badly. At the gentle corner it undershoots into a plain. `U/K` is effectively the single relief knob and the
|
||||
ceiling is a hard clip in the 16-bit encoding, so the generator reports the fraction of the map it clipped, as
|
||||
`generate_heightmap.py` does today, and a run that clips more than a fraction of a percent is a failed run, not
|
||||
a rounded one.
|
||||
|
||||
## The Go core `[DECIDED]`
|
||||
|
||||
Go, per the user's decision, in `Tools/Terrain/`, module path `salty/terrain`, never published. The binary is
|
||||
built to `Tools/Terrain/bin/` and is gitignored; `Scripts/build-terrain.sh` builds it beside `build.sh`. Go
|
||||
1.25.0 is already on the development machine, which has 16 cores, so the toolchain costs nothing and
|
||||
`math/rand/v2` is available — but Go is not in the repository and not in the engine's toolchain, so a machine
|
||||
that regenerates the world needs its own install, and `build-terrain.sh` says so when `go` is missing.
|
||||
|
||||
```
|
||||
Tools/Terrain/
|
||||
go.mod
|
||||
cmd/terrain/main.go the CLI
|
||||
field/ Field, HeightField, resample, upsample, PNG in and out
|
||||
pass/ one file per pass, each implementing Pass
|
||||
pipeline/ Context, Pipeline, the manifest reader
|
||||
stats/ slope-area, hypsometry, drainage density
|
||||
```
|
||||
|
||||
```go
|
||||
type Field struct { W, H int; CellM float64; Data []float32 }
|
||||
|
||||
type Pass interface {
|
||||
Name() string
|
||||
Apply(ctx *Context) error
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Seed int64
|
||||
Fields map[string]*Field // iterated only through an explicitly sorted key list
|
||||
Params Params
|
||||
Log func(string, ...any)
|
||||
}
|
||||
```
|
||||
|
||||
**Determinism is the one cross-cutting rule that applies here, and Go fights it in three specific ways.** Map
|
||||
iteration order is randomised by design, so no pass may iterate `Fields` (or any map) directly to produce
|
||||
output; goroutine completion order is nondeterministic, so every parallel reduction must be into a
|
||||
pre-allocated indexed slot rather than a channel drain; and `math/rand`'s global source is shared. Each pass
|
||||
takes its own `rand.New(rand.NewPCG(seed, passIndex))`, works on integer coordinates, and its result must be
|
||||
byte-identical whatever `GOMAXPROCS` is. That last sentence is a test: run the pipeline at `GOMAXPROCS=1` and
|
||||
at `GOMAXPROCS=N` and compare the output hashes.
|
||||
|
||||
**The CLI keeps today's flags**, so the README, the muscle memory and the two documented commands survive:
|
||||
|
||||
```bash
|
||||
Tools/Terrain/bin/terrain generate # the manifest as it stands
|
||||
Tools/Terrain/bin/terrain generate --seed 12 # another continent
|
||||
Tools/Terrain/bin/terrain generate --source-file RawContent/World/Sources/dem.png --source-elevation 0 2400
|
||||
Tools/Terrain/bin/terrain generate --stage fluvial --size 1024 # one pass at a small size, for iterating
|
||||
```
|
||||
|
||||
Output goes where `create_world.py` already looks, `RawContent/World/Heightmaps/`, with the same file names.
|
||||
The spec's `out/<preset>/<seed>/` layout serves a preset gallery that does not exist; it is deferred with the
|
||||
gallery.
|
||||
|
||||
**The DEM escape hatch is already built and is better specified than the spec's §8.** `source.kind = "file"`
|
||||
reads 16-bit PNG or raw little-endian `.r16`, widens 8-bit, optionally flips, centre-crops to a square,
|
||||
converts with the file's own elevation range, resamples (box when shrinking, bilinear otherwise), optionally
|
||||
smooths, and re-encodes into the world's range while reporting what it clipped. That behaviour is ported to Go
|
||||
as-is, including `--source-file` and `--source-elevation`. A file source skips passes 1 through 8 and enters
|
||||
the pipeline at the detail grid, as `"enabled": false` does today.
|
||||
|
||||
## The manifest
|
||||
|
||||
One file, not two. The spec's `UTerrainPreset` data asset and its JSON preset are a second and third place for
|
||||
the same numbers to live, for a gallery of presets that does not exist and one world that does; the project's
|
||||
own rule against abstraction for a single implementation applies. `World.json` keeps `level`,
|
||||
`vertices_per_side`, `quad_cm`, `elevation_m`, `sea_level_m`, `spawn_pad_m`, `streaming_grid_components`,
|
||||
`source` and `layers` unchanged in meaning, and the `erosion` block is replaced by `pipeline`:
|
||||
|
||||
```json
|
||||
"pipeline": {
|
||||
"geology_factor": 4,
|
||||
"plates": { "count": 6, "velocity_cm_yr": [1, 5], "convergent_mm_yr": [2, 5], "band_km": [2, 4],
|
||||
"divergent_mm_yr": [-2, -1], "rift_km": [3, 6], "intraplate_mm_yr": 0.2,
|
||||
"low_uplift_fraction": [0.2, 0.4] },
|
||||
"faults": { "major": [3, 6], "minor": [10, 30], "length_km": [2, 15], "spacing_km": [1, 4],
|
||||
"throw_major_m": [100, 400], "throw_minor_m": [20, 80], "strike_slip_m": [200, 800] },
|
||||
"lithology": { "types": 3, "k_multipliers": [0.5, 1.0, 3.0] },
|
||||
"coast": { "shelf_km": [0.6, 3.0], "steep_coast_m": 300, "slope_km": 1.6, "surf_reach_m": 110,
|
||||
"platform_grade": 0.02, "cut_fraction": 0.85, "deposit_reach_m": 350, "drift_m": 300,
|
||||
"river_m3_per_km2": 1.2e5 },
|
||||
"relief": { "octaves": 7, "gain": 0.45, "base_frequency_m": 4000, "amplitude_m": [50, 150],
|
||||
"crest_weight": 0.12 },
|
||||
"fluvial": { "k": 5e-5, "m": 0.5, "n": 1.0, "dt_yr": 1500, "steps": 1000,
|
||||
"diffusion_m2_yr": 0.02, "fill_every": 1 },
|
||||
"thermal": { "coarse_passes": 12, "fine_passes": 24, "talus_deg": 35 },
|
||||
"strata": { "period_m": 160, "contrast": 0.6 },
|
||||
"detail": { "octaves": 4, "amplitude_m": [2, 8] },
|
||||
"particle": { "droplets": 9000000, "lifetime": 40, "scale": 0.5, "min_erode_slope": 0.25,
|
||||
"max_change": 0.2, "inertia": 0.1, "capacity": 2.0, "max_load": 2.0, "erode_rate": 0.2,
|
||||
"deposit_rate": 0.2, "evaporation": 0.02, "gravity": 4.0 }
|
||||
}
|
||||
```
|
||||
|
||||
Every key has a default in the Go source, as `heightmap_erosion.DEFAULTS` holds them today, so the manifest
|
||||
carries only what differs. `create_world.py` and `world_manifest.py` do not read `pipeline` at all and do not
|
||||
need to.
|
||||
|
||||
## The bridge
|
||||
|
||||
Two changes to `ULandscapeAuthoringLibrary`, one of which was already the Worklog's open item 1.
|
||||
|
||||
**`ReimportHeightmapIntoLayer` `[PROPOSED]`.** The spec's §5.1 is the project's existing open item: edit
|
||||
layers, `Generated` at the bottom owned by the tool and rewritten whole on every apply, `Sculpt` above it never
|
||||
touched. Today `create_world.py` empties the level and builds a fresh landscape, so any hand sculpting dies
|
||||
with the next rerun, which is why nobody sculpts. The new entry point takes an existing `ALandscape`, a layer
|
||||
name, a heightmap file and the weightmaps, and writes only into that layer — `FScopedSetLandscapeEditingLayer`
|
||||
around `FLandscapeEditDataInterface::SetHeightData`, or `ULandscapeEditorObject::ImportHeightmap` per layer;
|
||||
the exact API is confirmed against 5.8 before it is written, not guessed. `create_world.py` then reimports
|
||||
instead of rebuilding whenever the landscape already exists with the right resolution, and falls back to
|
||||
`CreateLandscapeFromHeightmap` when it does not. **Check first** whether edit-layer data survives
|
||||
`ChangeGridSize`, since the world-partition split happens after creation. Header changes mean a full rebuild of
|
||||
`SaltyEditor` with the editor closed.
|
||||
|
||||
**A guard on the component layout.** Not needed at 7141, which the importer's own rule resolves correctly, but
|
||||
the resolution is now something a manifest edit can break silently and expensively. `CreateLandscapeFromHeightmap`
|
||||
logs the layout it chose and refuses a resolution that yields more than 1024 components rather than spending
|
||||
forty minutes proving the point.
|
||||
|
||||
**Deferred, with triggers**, in the project's sense — not undecided, decided against for now:
|
||||
|
||||
| Deferred | Until |
|
||||
| --- | --- |
|
||||
| The Editor Utility Widget: preset gallery, thumbnails, preview render target, watched folder | There is a second preset. One world does not need a gallery; two shell commands are the interface |
|
||||
| `UTerrainPreset` data assets | The same trigger. `World.json` is the preset |
|
||||
| Water plugin river splines from `meta.json` | A water body is wanted in the world. The polylines are exported meanwhile, so the day it happens is a script |
|
||||
| Build-zone volumes in the level | Something places a settlement. The polygons are exported meanwhile |
|
||||
| Porting a pass to a compute shader | A pass is measured too slow, not predicted to be |
|
||||
| A GPU or C++ path for the fluvial solve | The same trigger |
|
||||
|
||||
## Validation
|
||||
|
||||
The project already measures rather than eyeballs — the slope histogram settled the noise tuning and attributed
|
||||
the rill damage. The spec adds two standard checks that cost nothing and say more, and both go in `meta.json`
|
||||
beside the existing statistics:
|
||||
|
||||
- **Slope–area.** log slope against log drainage area over channel cells should be a straight line with
|
||||
negative slope. Curvature or scatter means `K`, `m`, `n` or the run length is wrong. This is the direct test
|
||||
of whether the fluvial pass did what it is there for, and it is the reason for the rewrite, so it is the
|
||||
proof that closes the work.
|
||||
- **Hypsometry.** Cumulative area against normalised elevation should be S-shaped. Convex means too young or
|
||||
too much uplift; concave means over-eroded.
|
||||
- **Kept from today:** the slope histogram (the standing target is 80 % of the land under 15°), the fraction
|
||||
above sea level, the fraction clipped by the elevation range, and the per-layer coverage percentages.
|
||||
|
||||
Drainage density and a straight slope–area plot are what "it reads as real geology" means operationally. Not a
|
||||
screenshot.
|
||||
|
||||
## Build order
|
||||
|
||||
The spec's order, adjusted for what is already proven. Steps 1 and 2 of the spec's own list are effectively
|
||||
done: the loop is proven end to end, every day, at full resolution.
|
||||
|
||||
1. **Fix the viewport first.** Worklog open item 2. A generator whose output cannot be seen in the editor
|
||||
cannot be iterated on, and this is a World Partition setting, not work.
|
||||
2. **Edit layers.** `ReimportHeightmapIntoLayer`, `Generated` and `Sculpt`, `create_world.py` reimporting. Done
|
||||
against the *current* PNGs, so it is proven before the generator moves. Worklog open item 1.
|
||||
3. ~~**The Go skeleton.**~~ **Done, 2026-09-17.** `Field`, the manifest reader, 16-bit PNG out, the
|
||||
thumbnail, the noise toolkit and the continent. The determinism test runs at five values of `GOMAXPROCS`.
|
||||
4. ~~**The fluvial solver.**~~ **Done, 2026-09-17.** D8 receivers, stack, implicit update, priority-flood,
|
||||
diffusion. The slope–area plot is straight at the expected gradient and the analytic steady-state test
|
||||
passes exactly. See what was built, below.
|
||||
5. **Plates, faults, lithology** feeding `uplift` and `K`. Scale to the geology grid at 1786².
|
||||
6. **Thermal, upsample, detail noise, strata, particle** at 7141². Profile; no GPU work before a measurement.
|
||||
7. **Derived outputs**: weightmaps by today's rules, the four derivative maps, rivers, build zones, statistics.
|
||||
8. **The canvas move**: manifest to 7141 at 200 cm, elevation −512…1536, streaming grid 2. Full rebuild,
|
||||
timed, with the component layout logged.
|
||||
|
||||
Steps 1 and 2 are worth doing whatever happens to the rest, which is why they are first.
|
||||
|
||||
## The time budget `[DECIDED]`
|
||||
|
||||
**A full run holds today's bar of about five minutes.** That is a design constraint, not an aspiration: the
|
||||
generator is a thing you rerun while judging a change, and a pipeline you stop rerunning is a pipeline you stop
|
||||
tuning. The budget on the development machine's 16 cores, to be replaced by measurements as each pass lands:
|
||||
|
||||
| Stage | Grid | Budget | Measured |
|
||||
| --- | --- | --- | --- |
|
||||
| Plates, continent, faults, lithology, base relief | 1786² | 5 s | 1 s (continent and relief only) |
|
||||
| Fluvial, 1000 steps, flooding every step | 1786² | 120 s | **256 s** |
|
||||
| Thermal, coarse | 1786² | 5 s | — |
|
||||
| Coast: shelf, surf, sediment | 1786² | 5 s | **0.08 s** |
|
||||
| Upsample and detail noise | → 7141² | 15 s | — |
|
||||
| Particle, 9 M droplets × 40 steps | 7141² | 90 s | — |
|
||||
| Thermal, fine, 24 passes | 7141² | 20 s | — |
|
||||
| Weightmaps, derivative maps, statistics | 7141² | 15 s | — |
|
||||
| PNG encode and write, one 16-bit and seven 8-bit | 7141² | 30 s | — |
|
||||
|
||||
**The fluvial pass is over its budget by a factor of two and the five-minute bar is at risk.** 256 s measured
|
||||
against 120 s budgeted, with the rest of the pipeline unbuilt and notionally another 175 s. It is not the
|
||||
flood's `log n` — that is already gone, see what was built — it is simply 3.2 M cells × 1000 steps, most of
|
||||
it in the two genuinely sequential parts (the stack walk and the flood's cursor). Three ways out, in the
|
||||
order they should be tried, and this is a decision for build-order step 6, when there is something to
|
||||
measure against:
|
||||
|
||||
1. **`geology_factor` 8 instead of 4**, a 894² geology grid at 16 m cells: four times cheaper, about 64 s,
|
||||
and the upsample has to invent more of the fine drainage.
|
||||
2. **Parallelise the stack update by basin.** Disjoint basins are independent; only the walk within one is
|
||||
sequential. On this continent the trunk basins are large and few, so the gain is real but bounded.
|
||||
3. **Spend the time.** Seven minutes instead of five, with `--size` carrying the iteration loop anyway.
|
||||
|
||||
**PNG writing is not free at this size** either: eight maps of 51 M samples is over 100 MB through zlib, and
|
||||
`heightmap_io.py` compresses at level 6 today. The generated maps are rebuilt from a seed, never archived, so
|
||||
the Go writer uses level 1 for the 8-bit derivative maps and keeps a higher level only for the height, which
|
||||
the editor imports once.
|
||||
|
||||
**Relief is resolution-dependent, and by a lot.** The same seed and the same uplift field give 1020 m of land
|
||||
relief at 512², and **2605 m at 1786²** — well past D-48's 1536 m ceiling. Finer grids resolve smaller
|
||||
drainage areas near the divides, and `S = (U/K)^(1/n)·A^(-m/n)` makes small `A` steep, so the headwaters keep
|
||||
getting taller as the grid gets finer. The practical consequence is a trap: **`U/K` tuned at `--size 512`
|
||||
will overshoot at full resolution.** The iteration loop is for judging the *shape*; the elevation budget has
|
||||
to be confirmed at the real geology grid, and the clip warning is what confirms it.
|
||||
|
||||
`--stage` and `--size` exist so nobody waits for a full run to judge one pass; the iteration loop is
|
||||
`--stage fluvial --size 1024` against the slope–area plot, and the full run is what you do when it looks right.
|
||||
|
||||
## Settled
|
||||
|
||||
Everything the reconciliation left open was answered on 2026-09-17. Recorded here so the reasoning stays with
|
||||
the document; the decisions themselves are D-47 and D-48 in [`Decisions.md`](Decisions.md).
|
||||
|
||||
| Was | Settled |
|
||||
| --- | --- |
|
||||
| Continent and sea, or an inland region? | **The continent stays.** Sea level is the base level on every ocean cell; the spec's §9 boundary question closes with it |
|
||||
| 1536 m peaks or 2800 m? | **−512…1536 m, Z scale 400.** The mountains get lower and the valleys get better |
|
||||
| 7141, 5101 or 4081? | **7141 at 200 cm**, 2.0 m cells, 784 components |
|
||||
| Go on the machine? | **Go 1.25.0, 16 cores, already installed.** No toolchain cost |
|
||||
| How long may a run take? | **About five minutes**, as today. `steps` is the knob; `fill_every` turned out not to be one (see what was built) |
|
||||
|
||||
Nothing is open. What remains is measurement, and the first thing that could reopen any of this is the
|
||||
slope–area plot at build-order step 4 coming out curved.
|
||||
|
||||
---
|
||||
|
||||
## What was built, and where it differs
|
||||
|
||||
**2026-09-17, last. The mask is thresholded, not multiplied (D-52), and a broken statistic is retired.** The
|
||||
coastal pass reported a mean sea cliff of two metres, and the conclusion drawn from it — that the continent
|
||||
mask, by multiplying the uplift rate, made every coastline the lowest-uplift ground on the map — was half
|
||||
right and rested on a measurement that could not have said anything else.
|
||||
|
||||
**The statistic first, because it is the more useful lesson.** "Mean cliff" measured the drop from a cell to
|
||||
its seaward neighbour. That is a *gradient*: at the angle of repose one cell of a 10 m grid is 7 m, so the
|
||||
number was bounded above by 7 however tall the coast was, and it read 2 m on a plain coast and 3 m on a
|
||||
cliffed one because it could not distinguish them. It is now backshore height — the land's elevation between
|
||||
one and two surf reaches inland, median and P90 — and on that metric the coast always had cliffs: P90 88 m on
|
||||
seed 7, 108 m on seed 9342, 120 m on seed 67914, against a median of 3 to 9 m that correctly says the ordinary
|
||||
coast is a plain. **A cliff is how far you fall, not how steep the first cell is.**
|
||||
|
||||
**The change is still right, for a narrower reason.** `rate = r * l` tapered uplift to zero across the shore,
|
||||
and steady state is `S = U/(K*A^m)`, so ground with no uplift grades to no slope. What that flattened was the
|
||||
hundred-metre strip the surf works in, not the backshore — so the cliff began a hundred metres inland instead
|
||||
of at the water. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its
|
||||
base level, and a range that runs out to the water rises at range rates right up to it. Measured with
|
||||
everything else held: surf cut 23.32 → 38.87 Mm³ and planed area 3.0 → 3.9 km² on seed 7, 22.42 → 30.35 Mm³
|
||||
and 2.4 → 2.9 km² on seed 9342, with the detail crop showing high ground reaching the waterline where a
|
||||
uniform low fringe stood in front of it before.
|
||||
|
||||
**What the margin was quietly relying on.** `continentMask` keeps land off the map border because a border
|
||||
cell is an outlet — it takes no uplift and is never eroded, so land that reaches it freezes while the interior
|
||||
erodes out beneath it. The margin tapers the mask, and while the rate was multiplied by the mask it was
|
||||
tapering the uplift too, as a side effect nothing named. `TestBorderIsAlwaysOcean` now asserts the invariant
|
||||
directly on three seeds and it holds: every border cell is ocean, the nearest land is five cells in, and that
|
||||
land drains to ocean at sea level, so nothing is frozen. The test also logs what the margin costs, which had
|
||||
never been measured: **14 to 15 % of the waterline lies inside the margin band**, cut along a contour of
|
||||
distance-to-edge, which is a straight line parallel to that edge. Pre-existing, cosmetic, and made conspicuous
|
||||
by this change because the land there now carries the full 2.0 mm/yr.
|
||||
|
||||
**And a warning about the acceptance test itself.** The slope–area fit moved −0.480 → −0.312 on seed 7 and
|
||||
−0.698 → −0.720 on seed 9342 — opposite directions, and both inside a seed-to-seed spread on identical code
|
||||
(−0.480, −0.698, −0.575 at R² 0.317, 0.704, 0.944) that is several times the size of the effect. It is the
|
||||
number this document calls the proof that closes the work, and at five or six bins on a 1400 grid it cannot
|
||||
carry that on a single run. Pair it — same seed, before and after — and read at least two seeds.
|
||||
|
||||
**2026-09-17, later still. The coast: a pass rather than a line.** D-48 kept the continent because sea level
|
||||
is a better-posed base level for the solve than one outlet edge, and that is all it was: the mask said which
|
||||
cells were ocean, the solve held them at sea level, and afterwards the sea floor dropped to a flat plane at
|
||||
−180 m in a single step. A third of the map and a third of the elevation range was one flat surface; the land
|
||||
met the water at whatever angle the last erosion step happened to leave; no process in the generator knew the
|
||||
shoreline was there. `internal/coast` is the pass that does, and it runs *after* the solve because two of its
|
||||
three parts need the finished terrain.
|
||||
|
||||
**The coordinate is a signed distance, not a line.** Every coastal process is written as "how far is this cell
|
||||
from the waterline, and which stretch of shore does it belong to", so the pass opens with an exact Euclidean
|
||||
distance transform carrying a feature index — Felzenszwalb and Huttenlocher's two 1-D passes, O(n) whatever
|
||||
the radius. Exact rather than a chamfer: there is nothing to buy by approximating an O(n) algorithm, and a
|
||||
chamfer's 2 % anisotropy would show as a shelf wider along the grid axes than across them. Everything after it
|
||||
is a lookup.
|
||||
|
||||
**The shelf is derived, not set.** A margin is a gentle shelf out to a break, then a much steeper continental
|
||||
slope to the abyssal floor. Its width is read off the relief standing behind each stretch of shore, so a low
|
||||
coastal plain gets a wide shelf and a range that comes down to the water gets a narrow one, out of the same two
|
||||
manifest numbers and without either having been asked for. `sea_floor_m` keeps its meaning; what changed is that
|
||||
the depth between its two ends is now a function of distance offshore.
|
||||
|
||||
**The cliff is a consequence.** Within a reach of the waterline the land is planed towards a shore platform, and
|
||||
the reach is set by how open the water is. Nothing draws a cliff: the cliff is the step where the reach ends, so
|
||||
its height is whatever the land behind it stands at, which is the right way round. The cut rolls off only over
|
||||
the last quarter of the reach — rolling it off across the whole reach gives a ramp, which is what a coast looks
|
||||
like when it has been smoothed rather than eroded.
|
||||
|
||||
**The sediment is accounted for.** What the surf cuts is counted, carried a drift length along the shore and
|
||||
laid in sheltered water shallower than a few tens of metres; river mouths deliver their own load in proportion
|
||||
to what they drain, which is what makes a delta. The summary prints the volume cut, delivered, laid and left
|
||||
unplaced, because the sediment budget is the one part of this that is not derived from something already
|
||||
measured.
|
||||
|
||||
**And the invariant that is now enforced in one place.** `uplift.Result.Bathymetry` is gone. Ocean cells sit at
|
||||
sea level for the whole solve and the coast pass owns the sea floor outright, which is the same rule as before
|
||||
— a coastal cell drains into an ocean cell, and an ocean cell already at −180 m makes the solver cut the river
|
||||
down to −180 m — but with one owner instead of two.
|
||||
|
||||
*Four things that were wrong first, each worth keeping.*
|
||||
|
||||
1. **Exposure by percentile.** Stretching the map's own 5th-to-95th percentile onto 0..1 is robust and collapses
|
||||
to nonsense on a coast that does not vary: a straight one has no spread, so the whole continent came out at
|
||||
one end of the scale. It is also a global statistic, which rule 1 of the tiling plan rules out — two tiles
|
||||
would stretch by different anchors and their shared bay would be two different colours. The anchors are now
|
||||
fixed and physical.
|
||||
2. **Fetch in every direction.** That counts the land *behind* the shore as shelter, and every coast has land
|
||||
behind it, so a straight open coast — where seven rays in sixteen stop after one cell — scored as more
|
||||
sheltered than the back of a bay half a kilometre wide. Restricting to the seaward half-space, cosine-weighted
|
||||
from the shore normal, is the standard effective fetch and gets the sign right.
|
||||
3. **`cut_fraction` as a fraction of the height above the platform.** Fifteen per cent of a 120 m headland is
|
||||
18 m, which is not a rough platform, it is an uncut headland. The residual is capped at a few metres.
|
||||
4. **The deposition kernel, twice.** `dep = blur(supply) * want / blur(want)` looks like a normalised convolution
|
||||
and is not one: the blur spreads supply onto land, deep water and exposed headlands, all of which want
|
||||
nothing and are skipped, and 68 % of the budget was silently dropped there. The conserving order is to divide
|
||||
the supply by the blurred want *first*, then blur, then multiply by the want — which sums to exactly the
|
||||
supply. And one kernel cannot do both jobs: the sediment needs zero padding to keep the kernel symmetric,
|
||||
which is what the balance rests on, while a carried *value* like the shelf width needs edge clamping, and
|
||||
smoothing the width with the mass-preserving kernel shrank every shelf near the border to nothing. There are
|
||||
now two, sharing their arithmetic so they cannot drift apart.
|
||||
|
||||
**What the pass measured about the continent, which is the part that mattered most.** With the coast built, the
|
||||
fetch reported that the median stretch of shoreline was *fully open*: there were no bays. The cause is the
|
||||
continent outline itself — five octaves over a 14 km map puts its finest feature at about 450 m, which is a
|
||||
smooth blob, and a coastline is fractal, which is the whole content of the Richardson paradox. Sweeping the
|
||||
outline's octave gain on seed 7 at 1400, everything else held:
|
||||
|
||||
| outline gain | 0.50 | 0.58 | **0.62** | 0.66 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| shoreline | 64 km | 81 km | **96 km** | 114 km |
|
||||
| median shore exposure | 1.00 | 0.98 | **0.84** | 0.51 |
|
||||
|
||||
and the octave count, at gain 0.50, gave 59, 63, 64, 65, 66 km at 5, 7, 8, 9, 10 — flat past 9. D-51 takes 8
|
||||
octaves at gain 0.62: islands, inlets and headlands that shelter each other, without the outline breaking into
|
||||
speckle. The whole coastal pass costs 83 ms at the manifest's geology grid, against 130 s for the solve.
|
||||
|
||||
**2026-09-17, later. The plains problem: the uplift field, the router's flat ground, and the hillslope law.**
|
||||
The complaint was that the lowlands read as mountains that had been shrunk - same texture, same shading,
|
||||
lower peaks. It was not an erosion-tuning problem and no amount of work downstream would have fixed it.
|
||||
|
||||
**The diagnosis, which is arithmetic.** Steady state is `S = U/(K*A^m)`. With `critical_area_m2` at 0 that law
|
||||
is applied down to a single cell, so at every divide `A = cell^2`; at K 5e-5, m 0.5 and a ~10 m geology cell
|
||||
that makes `S = U/4.8e-4`. An intraplate rate of 0.25 mm/yr is therefore a 28 degree hillslope and the 0.9
|
||||
mm/yr swell is past the 35 degree repose angle - so the repose clamp, which is meant to be a mountain
|
||||
process, was the surface of the entire continent. For n = 1 the uplift rate alone fixes the hillslope angle:
|
||||
`U` sets how *high* the summits get, not how steep the ground is.
|
||||
|
||||
**The measurement that says so** is new, and is the first thing built: `stats.UpliftBuckets` splits the land
|
||||
by the uplift rate that caused it - plain below 0.1 mm/yr, rolling to 0.5, mountain above - and reports each
|
||||
bucket's median and P90 slope, local relief over a 500 m window, and the fraction pinned within 2 degrees of
|
||||
talus. Uplift is the right axis because it is an *input*: a cell does not change bucket when the run does,
|
||||
which elevation-banding cannot promise. Map-wide aggregates cannot answer "are the plains plains", which is
|
||||
why this was invisible for so long.
|
||||
|
||||
| Run | plain, % of land | plain median | mountain median | mountain at talus | slope-area R2 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Before | 1 % | 6.5 deg (sea cliff) | 26.4 deg | 32 % | 0.688 at -1.59 |
|
||||
| Uplift fixed (D-49) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.055 at -0.26 |
|
||||
| plus router jitter (D-50) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.459 at -0.33 |
|
||||
| plus nonlinear hillslope | 42 % | 0.8 deg | 28.5 deg | **33 %** | 0.225 at -0.38 |
|
||||
|
||||
The first row is the whole diagnosis in one line: 81 % of the land sat in the mountain uplift class and the
|
||||
plain class held 1 %, all of it coastal cliff.
|
||||
|
||||
**Fixing the uplift field immediately exposed the next thing**, exactly as expected: once the plains were
|
||||
genuinely flat, the only gradient across them was the priority-flood's epsilon and the router drew the
|
||||
flood's traversal order as rivers. Hence D-50. The jitter costs nothing and recovered most of the slope-area
|
||||
fit on its own.
|
||||
|
||||
**The hillslope law** is now `q = D*S/(1-(S/Sc)^2)` (`internal/fluvial/hillslope.go`), replacing linear
|
||||
diffusion. It is linear diffusion as `S -> 0`, so the lowlands are untouched, and it is mass-conserving,
|
||||
which the clamp is not. Three things are worth knowing about it:
|
||||
|
||||
- **It is stiff, and the stiffening is bounded.** `D_eff = D(1+u^2)/(1-u^2)^2` diverges at `u = 1`; at the
|
||||
defaults `u = 0.9` alone wants seventy sub-steps a step. So `u` is capped at `slope_cap` and, if the
|
||||
`max_hillslope_substeps` budget cannot buy even that, the cap is lowered further rather than the sub-step
|
||||
count truncated. Truncating is the tempting branch and it is wrong: it leaves alpha above the stability
|
||||
limit and grows a checkerboard over hundreds of steps, which by then looks like texture.
|
||||
- **It therefore cannot replace the clamp.** A belt rising at millimetres a year asks for slopes no
|
||||
bounded-flux transport law holds; that is a fact about the forcing, not the scheme. The clamp stays in the
|
||||
loop.
|
||||
- **What changed is the order.** Diffusion runs *after* the clamp, every step. The clamp cuts along eight D8
|
||||
directions and leaves grid-aligned pyramid faces - the blocky facets visible in every earlier mountain
|
||||
preview - and a symmetric five-point stencil rounds them off before the next step sees them. Clamping once
|
||||
at the end instead was tried and measured: a thousand steps of growth arrive together, it cuts deeply, and
|
||||
nothing runs afterwards to soften it. The facets came back.
|
||||
|
||||
**The maps.** A run now also writes `map_uplift`, `map_erodibility`, `map_slope`, `map_relief`, `map_flow`
|
||||
and `map_basins` beside the preview (`internal/field/datamap.go`). `preview.png` says whether the landscape
|
||||
looks right; these say *why*, and the uplift map would have shown this whole problem at a glance with no
|
||||
arithmetic at all. `map_basins` is the direct test of whether the solve made a network rather than scratches.
|
||||
|
||||
### Three findings worth remembering
|
||||
|
||||
1. **A channelization threshold still fails, and now we know what it is waiting for.** Re-measured after the
|
||||
uplift fix: `critical_area_m2` 1e4 sends the plains back to 7.0 degrees, pins 49 % of the rolling class
|
||||
and 79 % of the mountains against the clamp, and collapses the slope-area fit to R2 0.001. The hillslope
|
||||
it creates has to shed its uplift by diffusion and at D 0.02 it cannot, so the clamp takes the job. It is
|
||||
not a tuning question; it needs a transport law strong enough to pair with, and it stays at 0 until there
|
||||
is one.
|
||||
2. **The nonlinear flux was written in height differences and fed a slope.** `u = dh/Sc` instead of
|
||||
`dh/(Sc*dx)` makes `u` a factor of `dx` too large, which pins every face against the cap and turns the
|
||||
whole law into linear diffusion with a constant multiplier. It produced *better-looking* terrain than the
|
||||
correct version, because over-smoothing hides facets. The unit test caught it; the preview did not.
|
||||
3. **Two of the four new tests passed while measuring nothing.** One read the fixed border cells back and
|
||||
called them the result; the other wrote a checkerboard across the fixed border, which then re-injected it
|
||||
into the interior for ever, so the scheme was blamed for a boundary condition. A test on a grid whose
|
||||
edge is an outlet has to say which cells it is actually asking about.
|
||||
|
||||
### Still open
|
||||
|
||||
- **The hypsometric integral is still 0.10**, and the reason has changed: it is no longer a bimodal uplift
|
||||
field, it is that 42 % of the land is now a near-sea-level plain. Whether that is wrong depends on whether
|
||||
a broad low continent is what is wanted; it is a question for the continent block, not the solve.
|
||||
- **Half the land is still in the mountain uplift class** (51 % on seed 7, 44 % on seed 9342), because
|
||||
`rangeMask` ramps from the 40th to the 86th percentile and anything above about the 55th clears 0.5 mm/yr.
|
||||
Real continents are nothing like half mountain. This is the next uplift-field question.
|
||||
- **Nature does not like straight lines, and three sources remain.** The fault traces are single 8-point
|
||||
parabolas with a hard cutoff at the tips, and their polygonal influence regions are plainly visible as
|
||||
straight-edged facets in `map_uplift`. The range grain is `across*2.2` with one warp octave, so chains run
|
||||
as straight parallel bands. And multiple-flow-direction accumulation would dissolve what is left of the
|
||||
diagonal river grain that the jitter only reduced. None is built.
|
||||
|
||||
|
||||
**2026-09-17. Build-order steps 3 and 4: the Go skeleton and the fluvial solver.** Nothing in the editor has
|
||||
been touched, so steps 1 and 2 (the viewport, the edit layers) are still open and still first in the list
|
||||
above; they need the editor closed and it was open. The numpy pipeline is untouched and still the thing that
|
||||
builds `L_World`.
|
||||
|
||||
Built, in `Tools/Terrain/` (module `salty/terrain`, `Scripts/build-terrain.sh`):
|
||||
|
||||
| Package | What it is |
|
||||
| --- | --- |
|
||||
| `internal/field` | `Field`, the one array type: shape, cell size in metres, float32 data. Resampling on the vertex convention, Catmull-Rom integer upsample, five-point blur, slope, curvature, greyscale PNG in and out through `image/png`, hillshaded thumbnails. `field.Rows` is the only place goroutines are created |
|
||||
| `internal/manifest` | `World.json` over Go-side defaults, the height contract mirroring `world_manifest.py`, and a validator that refuses a resolution the importer would turn into thousands of components |
|
||||
| `internal/noise` | Value noise, fBm on warped coordinates, Worley crest lines, per-pass PCG sources |
|
||||
| `internal/uplift` | The continent, the uplift *rate* field and the small initial relief |
|
||||
| `internal/fluvial` | The solver: priority-flood, D8 receivers, the stack, drainage accumulation, the implicit stream-power update, sub-stepped hillslope diffusion |
|
||||
| `internal/stats` | Slope–area, hypsometry, slope histogram, drainage density, and the verdict line |
|
||||
| `internal/check` | The two integration tests |
|
||||
|
||||
**The proof.** `TestSteadyStateMatchesStreamPower` puts uniform uplift on a uniform grid and checks the
|
||||
analytic answer of `dh/dt = U − K·A^m·S^n`, which is that `K·A^m·S^n / U` is 1 at every channel cell. It
|
||||
measures **1.0000** over 822 channel cells. On the real continent the fitted slope–area exponent is **−0.498
|
||||
against an expected −0.500 at R² 0.996**, and drainage density is 1.33 /km, inside the real-world 1–10 band.
|
||||
That is what build-order step 4 asked for and it is met.
|
||||
|
||||
`TestDeterministicAcrossGOMAXPROCS` hashes the pipeline output at GOMAXPROCS 1, 2, 4, 8 and 16 and requires
|
||||
one hash, which is the assertion this document makes about Go and cross-cutting rule 12. It passes.
|
||||
|
||||
### Where it differs from the spec above
|
||||
|
||||
- **`fill_every` is 1, not 50**, and the table in the fluvial section is why. The 50 was written to protect
|
||||
the time budget and it silently destroyed the solve.
|
||||
- **1000 steps, not 5000.** Measured, not assumed; see the time note. This is most of the budget back.
|
||||
- **The priority-flood uses a monotone bucket queue, not a binary heap.** The flood pops in non-decreasing
|
||||
elevation and never pushes below the current front, which is exactly the condition that makes a bucket
|
||||
queue valid, and it removed the `log n` from two thirds of the runtime for a measured 1.6× on the whole
|
||||
solve at identical output (−0.498 against −0.500 before).
|
||||
- **Slope–area is measured two ways and the normalised one is the verdict.** Steady-state stream power gives
|
||||
the same gradient but a different *intercept* per uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so
|
||||
regressing every channel together stacks twenty-five-fold-separated parallel lines and fits nonsense: the
|
||||
raw fit reads −0.70 at R² 0.91 on a landscape whose true exponent is −0.50. Slope is normalised by
|
||||
`(U/K)^(1/n)` first. The raw figure is still reported beside it.
|
||||
- **`Continent` is a pipeline block the spec above does not list**, because D-48 kept the coast and the coast
|
||||
needs parameters.
|
||||
- **`uplift.Result` carries `Bathymetry` separately from `Height`.** The sea floor is held at sea level for
|
||||
the duration of the solve and put back afterwards. Left in, a coastal cell drains into an ocean cell at
|
||||
−180 m and the solver obligingly cuts the river down to −180 m; the first run with a coast eroded the land
|
||||
to 174 m *below* sea level. A river's base level is sea level; the bathymetry is scenery.
|
||||
|
||||
### Three bugs worth remembering
|
||||
|
||||
All three produced plausible-looking terrain, which is the point: none of them would have been caught by
|
||||
looking at it.
|
||||
|
||||
1. **Slope–area measured with the topographic gradient instead of the channel gradient.** For a cell on a
|
||||
valley floor the central difference is dominated by the valley walls, not by the direction the water
|
||||
runs. The exponent read −0.78 where the truth was −0.50. `S` in the stream-power law is
|
||||
`(h − h_receiver)/L` and nothing else.
|
||||
2. **Cells at a local minimum were skipped entirely, uplift included.** They are the cells differential
|
||||
uplift is actively pushing up, so freezing them removes exactly the basins that should be forming. Fixed
|
||||
by letting a root still rise.
|
||||
3. **The fix for (2) then uplifted the outlets.** The map border is an outlet but is not ocean, so on a map
|
||||
with no coast every border cell had `Receiver == self` and `Base == false` and base level rose 2 m a step
|
||||
with the whole landscape chasing it. The steady-state ratio read 0.03 instead of 1.0. The union of "ocean"
|
||||
and "border" is now a single `fixed` mask. The real runs were never wrong, because there the border *is*
|
||||
ocean — only the test had no coast, which is why it caught it.
|
||||
|
||||
### Still open
|
||||
|
||||
- Passes 1, 3 and 4 (plates, faults, lithology) are not built; `uplift` currently derives its rate field from
|
||||
the percentile-thresholded range band alone, which is build-order step 5.
|
||||
- **The hypsometric integral is 0.11, against 0.4–0.6 for a mature landscape**, and it has been at 0.11 in
|
||||
every run. The map is a wide, near-flat coastal plain with mountains on a fraction of it, which is what an
|
||||
intraplate rate of 0.2 mm/yr against a convergent 5 mm/yr produces: a 25-fold ratio is bimodal by
|
||||
construction. This is a tuning question for the uplift field, so it belongs to step 5, but it is the next
|
||||
thing that will look wrong.
|
||||
- Everything from the upsample onward (passes 8 to 14) is unbuilt, so there is no full-resolution output yet
|
||||
and the canvas has not moved: the manifest is still 4081 at 350 cm.
|
||||
- **The fluvial pass costs 256 s at the real geology grid, against 120 s budgeted**, and relief there reaches
|
||||
2605 m against D-48's 1536 m ceiling. Neither is a defect in the solver — the first is arithmetic and the
|
||||
second is `U/K` untuned — but both are decisions waiting at steps 5 and 6. See the time budget.
|
||||
- The exponent at 1786² is −0.640 after 1000 steps, still converging toward −0.5 while R² holds at 0.990. A
|
||||
straight plot at 0.64 is a perfectly ordinary real landscape (measured concavities run 0.35 to 0.6), so
|
||||
this is a question of how long to run rather than a fault, and it trades directly against the item above.
|
||||
+192
-1
@@ -5,6 +5,115 @@ the reasoning lives in the specs, this is the memory.
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-09-17 - Off the ladder, the coast, part two: the uplift field stops being multiplied by the continent
|
||||
mask (D-52), so a range that reaches the water rises at range rates right up to the waterline instead of
|
||||
being tapered to nothing across the shore. Surf cut 23.3 → 38.9 Mm3 on seed 7 and 22.4 → 30.4 on seed 9342;
|
||||
high ground now meets the water in the detail crop where it previously had a uniform low fringe in front of
|
||||
it. New `TestBorderIsAlwaysOcean` in `internal/check`, because the margin that keeps land off the map border
|
||||
was relying on that same multiplication as a side effect: the border is still all ocean on three seeds, so
|
||||
there is no frozen rim. **Did not work, and it was mine:** the "mean cliff" statistic measured the drop from
|
||||
a cell to its seaward neighbour and called it a cliff. That is a gradient - one cell of a 10 m grid at the
|
||||
angle of repose is 7 m - so it could not exceed 7 whatever the coast did, and it read 2 m on a plain coast
|
||||
and 3 m on a cliffed one. It is why yesterday's brief claimed there were no sea cliffs anywhere; the
|
||||
replacement, backshore height at one to two surf reaches inland, shows the *old* build already at 88 m P90.
|
||||
A cliff is how far you fall, not how steep the first cell is. **Also measured:** the slope-area fit is too
|
||||
noisy at five or six bins to judge one change on one seed - the same code gives -0.480 on seed 7 and -0.698
|
||||
on seed 9342 - and 14 to 15 % of every coastline is drawn by the map margin rather than by the noise, which
|
||||
is a straight line parallel to the map edge and is pre-existing.
|
||||
- 2026-09-17 - Off the ladder, the coast. `internal/coast`, running after the fluvial solve, on the terrain
|
||||
the solve produced. An exact signed distance transform with a feature index (Felzenszwalb, two 1-D passes)
|
||||
is the coordinate everything is written in; on it sit a continental shelf whose width is read off the relief
|
||||
behind each stretch of shore, a surf that planes the land to a shore platform within a reach set by how open
|
||||
the water is - the cliff is the step where the reach ends - and a sediment budget that carries what the surf
|
||||
cut a drift length along the shore and lays it in sheltered shallow water, with river mouths delivering
|
||||
their own load. `uplift.Result.Bathymetry` is gone: the coast pass owns the sea floor, and ocean cells stay
|
||||
at sea level for the whole solve. New: `map_exposure.png`, `map_coast.png`, a coast block in `meta.json`
|
||||
and in the summary, `--no-coast` and six other overrides, nine tests, and the determinism test now covers
|
||||
the pass. The measured finding was the coastline *outline*: at five octaves its finest feature is 450 m,
|
||||
which is a blob, and the fetch called the median stretch of coast fully open. Swept, seed 7 at 1400:
|
||||
shoreline 64/81/96/114 km and median exposure 1.00/0.98/0.84/0.51 at outline gain 0.50/0.58/0.62/0.66. D-51
|
||||
sets 8 octaves at 0.62. Whole pass 83 ms. **Did not work:** a percentile stretch for exposure (degenerate on
|
||||
an even coast, and a global statistic two tiles would disagree about); casting fetch in every direction
|
||||
rather than seaward only (it counts the land behind the shore as shelter, so a straight coast scored more
|
||||
sheltered than a bay); a cut fraction read as a fraction of the height above the platform (15 % of a 120 m
|
||||
headland is 18 m, which is not a platform); `dep = blur(supply) * want / blur(want)`, which looks like a
|
||||
normalised convolution and lost 68 % of the sediment budget - the supply blurred onto land and deep water
|
||||
where nothing wants it was silently dropped, and the conserving order is to divide by the blurred want
|
||||
*first*, then blur; and one kernel for both jobs - the sediment needs zero padding to stay symmetric and a
|
||||
carried value needs edge clamping, and using the mass-preserving one on the shelf width shrank every shelf
|
||||
near the border to nothing.
|
||||
- 2026-09-17 - Off the ladder, the plains problem. The lowlands read as shrunken mountains because they were
|
||||
being uplifted at mountain rates: `S = U/(K*A^m)` applied down to one cell puts a 0.25 mm/yr divide at 28
|
||||
degrees and the 0.9 mm/yr swell past the 35 degree repose clamp, so the clamp was the surface of the
|
||||
continent (81 % of land in the >0.5 mm/yr class, the plain class 1 % and all sea cliff). Built
|
||||
`stats.UpliftBuckets` first, to measure per uplift class rather than map-wide - that is what made it
|
||||
visible. Then D-49 (intraplate 0.25 to 0.03, swell 0.9 to 0.08, relief lo 50 to 15 m) and D-50
|
||||
(hash-jittered flood epsilon and D8 tie-breaks, for the flat ground the fix exposed). Plains are now 42 %
|
||||
of land at 0.8 degrees and 9 m relief; slope-area R2 0.06 to 0.46. Then Roering nonlinear hillslope
|
||||
diffusion in `internal/fluvial/hillslope.go`, running *after* the repose clamp each step so the clamp's D8
|
||||
pyramid facets get rounded off: mountains 31.1 to 28.5 degrees and 44 % to 33 % pinned at talus, at 8 %
|
||||
more runtime. Four tests on it. A run now also writes uplift, erodibility, slope, relief, flow and basin
|
||||
maps. **Did not work:** `critical_area_m2` 1e4, re-measured after the uplift fix and still failing (plains
|
||||
back to 7 degrees, 79 % of mountains at the clamp) - it needs a hillslope transport law to pair with;
|
||||
clamping once at the end of the run instead of in the loop (facets returned). **Still open:** half the land
|
||||
is still in the mountain uplift class; fault traces and the range grain are still visibly straight lines;
|
||||
MFD accumulation unbuilt.
|
||||
- 2026-09-17 — Off the ladder, build-order steps 3 and 4 of [`Terrain.md`](Terrain.md): the Go core exists and
|
||||
the stream-power solver works. `Tools/Terrain/` (module `salty/terrain`, `Scripts/build-terrain.sh`, binary
|
||||
and `RawContent/World/Preview/` gitignored): `field` (the one array type, vertex-convention resampling,
|
||||
Catmull-Rom upsample, greyscale PNG through `image/png`, hillshaded thumbnails, and `field.Rows` as the only
|
||||
place goroutines are made), `manifest` (World.json over Go defaults, the same height contract as
|
||||
`world_manifest.py`, and a validator that refuses a resolution the importer would turn into thousands of
|
||||
components), `noise`, `uplift` (the continent, now producing an uplift *rate* rather than terrain), `fluvial`
|
||||
(priority-flood, D8 receivers, stack, accumulation, implicit Braun & Willett update, sub-stepped diffusion),
|
||||
`stats` and two integration tests. **The proof: the analytic check `K*A^m*S^n/U` measures 1.0000 over 822
|
||||
channel cells, and on the continent the slope-area exponent is -0.498 against -0.500 at R2 0.996 with
|
||||
drainage density 1.33 /km.** Determinism is asserted across five values of GOMAXPROCS, not assumed. Nothing
|
||||
in the editor was touched (it was open), so steps 1 and 2 are still first and still open; the numpy pipeline
|
||||
still builds `L_World` and is unchanged. Three bugs, all of which produced plausible-looking terrain and
|
||||
none of which could have been seen by looking: slope-area measured with the topographic gradient instead of
|
||||
the channel gradient read -0.78 where the truth was -0.50; cells at a local minimum were skipped entirely
|
||||
including their uplift, which froze exactly the basins that differential uplift was forming; and the fix for
|
||||
that then uplifted the outlets, because a map border is an outlet but is not ocean, so base level rose 2 m a
|
||||
step and the steady-state ratio read 0.03. The last one was invisible in real runs, where the border *is*
|
||||
ocean, and only the test caught it.
|
||||
- 2026-09-17 — Off the ladder, no code: an incoming procedural terrain specification (tectonics, faults,
|
||||
lithology, stream-power erosion, a Go core, an editor bridge into a Landscape edit layer) reconciled against
|
||||
the built pipeline in [`Terrain.md`](Terrain.md), and the Go core taken as D-47. What the reconciliation
|
||||
found: the spec's 7113 canvas is a trap, because 7112 divides by 127 and not by 255, so the importer's own
|
||||
rule gives 3136 components rather than the spec's 784 — the 4033 mistake again (D-45); 7141 at 200 cm gives
|
||||
28x28 components of 255 quads with the same 14.28 km side the manifest already has. The spec's Z scale 400
|
||||
is exactly a 2048 m elevation span, so its canvas is expressible in the manifest's own metres contract, at
|
||||
the cost of dropping the crests from 2800 m to 1536 m (Q2). The spec's uplift/K parameter ranges and its
|
||||
elevation budget are not jointly consistent at their corners: steady state puts slope at U/(K*A^m), and the
|
||||
aggressive corner overshoots 1536 m badly, so U/K is the one relief knob and the clip fraction is the check.
|
||||
The spec has no coast at all and leaves base level open, where this world has one and a sea plane (Q1).
|
||||
Lithology (plan view) and strata (vertical) are orthogonal and both kept. The spec's §8 DEM escape hatch is
|
||||
already built and better specified than the spec's version. Deferred with triggers: the preset gallery
|
||||
widget, `UTerrainPreset`, river splines, build-zone volumes, any GPU port. The five open questions were put
|
||||
to the user the same day and all five went the recommended way (D-48): the coast stays and sea level is the
|
||||
base level, the ceiling drops to 1536 m, the canvas goes to 7141 at 200 cm, and a full run holds the
|
||||
five-minute bar. Go 1.25 turned out to be installed already, on 16 cores, so the toolchain cost was zero.
|
||||
Nothing is built; the ladder is untouched.
|
||||
- 2026-09-17 — Off the ladder, third pass: geological simulation in the generator (D-46). `heightmap_erosion.py`,
|
||||
numpy only: particle hydraulic erosion in vectorised batches, a coarse pass on the 4x-downsampled map for the
|
||||
valleys and a fine pass at full resolution for the gullies; mass-conserving thermal weathering at a 35° angle
|
||||
of repose; strata hardness scaling the erosion; flow, wear, deposition and curvature maps written as PNGs and
|
||||
used by the layer rules. The uplift gained Worley cellular crest lines through the same domain warp. The
|
||||
manifest's `erosion` block drives it; `"enabled": false` for a real DEM. Two things the smoke test caught:
|
||||
droplets stepping together in a batch and crowding one cell run away to infinity without a per-step cap, and
|
||||
thermal weathering that sheds half the mean excess converges far slower than half the largest excess.
|
||||
- 2026-09-16 — Off the ladder, second pass: the world's terrain is now a manifest, `RawContent/World/World.json`
|
||||
(D-44). `world_manifest.py` derives the landscape's Z scale and Z offset from an elevation range in metres so
|
||||
world Z 0 is sea level; `heightmap_io.py` reads 16-bit PNG and raw r16 with numpy alone and resamples;
|
||||
`heightmap_noise.py` builds the continent in metres (retuned: 80 % of the land under 15°, ranges on a fifth
|
||||
of the map); `generate_heightmap.py` takes either the noise or a real heightmap named in the manifest and
|
||||
derives the three Rocky Meadows layers from the height by slope and altitude, so a real heightmap needs no
|
||||
weightmaps. `create_world.py` dresses `L_World` with the pack's kit as `dump_level.py` (new, dumps any level's
|
||||
actors to JSON) read it from `Rocky_Meadows_01`: sun with the cloud-shadow light function, skybox dome, sky
|
||||
light, height fog, fixed exposure and saturation; plus `World_Sea_Proto`, a plane with the engine's water
|
||||
material. Resolution moved from 4033 to 4081 so the engine's importer picks 16x16 components of 255 quads
|
||||
instead of 64x64 of 63 (D-45): the import takes two minutes instead of forty.
|
||||
- 2026-09-16 — Off the ladder, at the user's request: `L_World`, a world-partitioned ~200 km² landscape from a
|
||||
seeded heightmap. `Scripts/Authoring/generate_heightmap.py` (numpy, in `Scripts/Authoring/.pylib` via
|
||||
`bootstrap-pylib.sh`) writes 16-bit height and 8-bit weight PNGs to `RawContent/World/Heightmaps/`;
|
||||
@@ -38,10 +147,92 @@ the reasoning lives in the specs, this is the memory.
|
||||
|
||||
## Did not work
|
||||
|
||||
- (nothing built yet)
|
||||
- 2026-09-17 — `"fill_every": 50` in the terrain spec, written to protect the five-minute budget, silently
|
||||
destroyed the stream-power solve. Uplift reaches 5 mm/yr, which at dt 1500 is 7.5 m a step, so fifty steps
|
||||
is up to 375 m of differential uplift between floods: basins close and sit unrouted while everything above
|
||||
them stops eroding. Measured at 512² over 3000 steps the fitted exponent goes -0.500 (R2 0.992) at every
|
||||
step, -0.277 at every fifth, -0.121 at every tenth, noise beyond. Even every fifth step is broken, and the
|
||||
saving was 93 s against 38 s. The budget came back from the step count instead: 1000 steps, not the spec's
|
||||
5000, because at this K the trunk response time is about 45 000 yr and the exponent stops moving after 500.
|
||||
- 2026-09-17 — The heap in the priority-flood was the wrong data structure. The flood pops in non-decreasing
|
||||
elevation and never pushes below the current front, which is exactly when a monotone bucket queue is valid;
|
||||
swapping the binary heap for one took 1.6x off the whole solve at identical output. 22 comparisons and as
|
||||
many cache misses per operation, on two thirds of the runtime.
|
||||
- 2026-09-17 — Relief from the stream-power solve is strongly resolution-dependent: the same seed and uplift
|
||||
field give 1020 m of land relief at 512² and 2605 m at 1786², because finer grids resolve smaller drainage
|
||||
areas near the divides and S goes as A^(-m/n). Tuning U/K on a `--size` preview will therefore overshoot the
|
||||
elevation ceiling at full resolution. Judge shape on the preview; confirm the elevation budget at the real
|
||||
geology grid, where the clip warning is the check.
|
||||
|
||||
- 2026-09-16 — The first `L_World` rebuild of the evening died at twelve minutes with no error: it was run under a
|
||||
tool with a ten-minute timeout, which killed the commandlet mid-import and left an empty level. Long
|
||||
commandlets are launched detached (`Start-Process`) and watched through their log.
|
||||
- 2026-09-16 — A rebuild while the editor had ever loaded `L_World` ended in a nameless `/Temp/Untitled` world
|
||||
whose save went nowhere: deleting and recreating the level resaves `L_World_HLODLayer_Instanced` and
|
||||
`_Merged`, and the open editor keeps those two files locked. `create_world.py` now loads and empties an
|
||||
existing level instead of recreating it, checks the world's package name before building, and sweeps
|
||||
leftover proxy packages after the save. Check the editor's current level (`SceneTools.get_current_level`
|
||||
over MCP) before a rebuild; never rebuild the level the editor has open.
|
||||
- 2026-09-16 — Epic's recommended 4033 resolution gives the engine's importer 64x64 components of 63 quads, because
|
||||
4032 does not divide by 127 or 255 and the importer prefers one section per component: 4096 components, over
|
||||
8000 textures to build, forty minutes. 4081 divides by 255: 256 components, two minutes.
|
||||
- 2026-09-16 — The noise stacked eight octaves at gain 0.5, so every octave was as steep as the last and a third of
|
||||
the land stood above 50°. Lower gains, no octave finer than about 50 m, a percentile-thresholded range mask
|
||||
and a 38° thermal pass brought it to 80 % under 15°; measured with a slope histogram, not by eye.
|
||||
- 2026-09-17 — Cellular (Worley) crest lines at 30 % of the mountain height turned the ranges into a honeycomb of
|
||||
polygon walls with flat floors in the hillshade. Kept at 12 % through a stronger warp; the ridged noise and the
|
||||
erosion make the divides.
|
||||
- 2026-09-17 — The first eroded map had the meadows brushed with rills: land under 15° fell from 64 % to 38 %.
|
||||
A stage-by-stage slope histogram (uplift, coarse pass, fine pass, thermal) put most of it on the coarse pass:
|
||||
the slope gate sat at 7°, the median lowland slope, and one droplet could cut 7 m per step at 14 m cells.
|
||||
Gate at 14°, cut capped at a fifth of a cell per step. Attribute by measurement before turning knobs.
|
||||
- 2026-09-17 — Spreading droplet deposits through a 3x3 brush built mounds: a pit's rim rises faster than its
|
||||
floor, the pit never fills, and every droplet draining into it adds its load to the rim. Cuts go through the
|
||||
brush (no one-cell rills), deposits land on the droplet's own cell, and a droplet's load is capped, since
|
||||
capacity scales with the drop per step and a cliff hands a droplet a hundred cell-heights.
|
||||
- 2026-09-16 — The first `L_World` with the manifest pipeline showed rock everywhere: Elite_RockyMeadows' layer
|
||||
names mislead. Its `Base_Layer` function samples the rock textures, `Layer_02` the grass, `Layer_03` the high
|
||||
rock, and the meadow weightmap had gone to `Base_Layer`. Read a pack's layer functions before mapping layers.
|
||||
- 2026-09-16 — The retuned noise overshot into flat land with small hills; the user wanted aggressive ranges.
|
||||
Ranges now come from an elongated, warped, percentile-thresholded band (about two fifths of the land with
|
||||
foothills), ridged noise sampled through the same warp, crests to about 2600 m.
|
||||
- 2026-09-16 — `AActor.set_is_spatially_loaded` does not exist in Python; the property is set with
|
||||
`set_editor_property("is_spatially_loaded", False)`.
|
||||
- 2026-09-16 — Positional `unreal.Rotator(a, b, c)` is (roll, pitch, yaw), not (pitch, yaw, roll). Both authoring
|
||||
scripts pitched the sun 25 to 30 degrees upward, every map rendered as night, and the viewport's "cached lighting
|
||||
is going to be clipped" warning was the auto exposure at EV -8.5 saying so. Keyword arguments from now on.
|
||||
- 2026-09-16 — A landscape imported from a commandlet had collision but no visible surface: edit layers are always
|
||||
on in 5.8, the render heightmaps come from a GPU merge, and a commandlet reports it can never render unless
|
||||
launched with `-AllowCommandletRendering`. `ULandscapeAuthoringLibrary` now forces the full layer update and
|
||||
`create_world.py` documents the flag.
|
||||
- 2026-09-16 — The asset library cannot delete a folder of world-partition streaming proxies (their packages do not
|
||||
load on their own); `create_world.py` removes the folder on disk instead.
|
||||
|
||||
## Open
|
||||
|
||||
- **World, next time (2026-09-17, the user's verdict: "still somewhat rough").** Three items, none started:
|
||||
1. *Sculptable terrain.* Today `create_world.py` empties the level and re-imports the heightmap into a fresh
|
||||
landscape, so any hand sculpting dies with the next rerun. Wanted: the generated height applied to the
|
||||
level as its base, with sculpting on top that survives a regeneration. The engine's shape for this is edit
|
||||
layers: import the PNG into a named `Generated` layer and leave a `Sculpt` layer above it for hand work, then
|
||||
make a rerun re-import into `Generated` only (`ALandscape` edit-layer API, `FLandscapeEditDataInterface`, or
|
||||
`ULandscapeEditorObject::ImportHeightmap` per layer) instead of destroying the actor. Needs a C++ change in
|
||||
`ULandscapeAuthoringLibrary` (a `ReimportHeightmapIntoLayer` beside `CreateLandscapeFromHeightmap`), so a
|
||||
rebuild of `SaltyEditor` with the editor closed; the weightmaps follow the same path. Check first whether
|
||||
the proxies' edit-layer data survives `ChangeGridSize`.
|
||||
2. *Not visible in the editor.* The rebuilt terrain shows in PIE but the user reports it not visible in the
|
||||
editor viewport. Most likely cause, to verify: the level is world-partitioned and the 256 landscape proxies
|
||||
are spatially loaded, so the editor loads none of them until a region is loaded in the World Partition
|
||||
window; only the always-loaded dressing shows. Candidates: World Settings → World Partition → turn
|
||||
"Enable Streaming" off for now (everything loads, editor and runtime; 256 components is fine for a
|
||||
prototype), or save loaded regions with the level, or have `create_world.py` load the regions after the
|
||||
build. Also confirm the editor reloads `L_World` from disk after a rebuild rather than a stale in-memory copy.
|
||||
3. *No greenery on the floor.* The meadow layer is a flat grass texture; nothing grows. No pack in `Content/`
|
||||
has a grass mesh (Elite_RockyMeadows ships textures only, HouseForge's foliage folder holds one mushroom
|
||||
and a cover-plant material). Wanted: landscape grass on the meadow layer (`ULandscapeGrassType` fed from a
|
||||
`LandscapeGrassOutput` node, which means a child or copy of `M_Landscape_Main` since the pack's material has
|
||||
none), driven by the erosion's deposit and flow maps once the material samples them, plus a foliage pass for
|
||||
trees and bushes from a pack still to be chosen. Distance culling matters at 200 km².
|
||||
- Step 1's last proof is a person's: Play In Editor in `L_Gym`, two players, "Run Dedicated Server" on, two pawns
|
||||
in the gym. Until it is ticked the step is `◐`.
|
||||
- The default camera mode (OD-03) is deliberately undecided until step 8.
|
||||
|
||||
Reference in New Issue
Block a user