Tooling
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -11,6 +11,8 @@ Two layers, kept apart on purpose.
|
||||
| [Ideas.md](Ideas.md) | The ideas carried over from the two earlier projects as features a world could hold, with what each needs. None scheduled. |
|
||||
| [Decisions.md](Decisions.md) | One line per decision with a pointer, the open decisions that block steps, and what is deliberately deferred. |
|
||||
| [Worklog.md](Worklog.md) | One terse line per step closed or wall hit. The memory. |
|
||||
| [World-Pipeline.md](World-Pipeline.md) | Off the ladder: how a painted world map becomes ground in Unreal, in order — the Go generator, the browser twin, the tiles, the level, the map view. The orientation document for all of it; owns no decisions of its own. |
|
||||
| [World-Dressing.md](World-Dressing.md) | Off the ladder: the plan for what the ground wears — biome paint layers, the landscape material, sky and light, grass and trees. Five decisions taken in D-74, then five phases. |
|
||||
|
||||
**For an implementer that lifts code**, which today means Claude. Long, C++-shaped, and meant to be built from.
|
||||
|
||||
@@ -27,6 +29,7 @@ Two layers, kept apart on purpose.
|
||||
| [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. |
|
||||
| [Terrain-Next.md](Terrain-Next.md) | The generator's working brief: how to run it, what each output map is for, what still looks wrong, what has been measured and rejected. Anything settled here folds back into `Terrain.md` and is deleted from it. |
|
||||
|
||||
## Reading order
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ agree. A row is added to a spec and here in the same pull request as the emit ca
|
||||
| Stats | `status_applied`, `status_blocked`, `status_removed` |
|
||||
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
|
||||
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
|
||||
| World | `world_map_opened` (layer, metres_per_pixel, opened_by) |
|
||||
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
|
||||
|
||||
### The rows that carry weight
|
||||
|
||||
@@ -161,12 +161,80 @@ Hooks now, content later; none of the later work touches a widget.
|
||||
| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings |
|
||||
| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb |
|
||||
|
||||
## The world map
|
||||
|
||||
```
|
||||
[DECIDED] The map is a picture of the world with a linear transform on it. There is no capture. (D-73)
|
||||
```
|
||||
|
||||
`L_World` is a whole planet map imported with no crop (`RawContent/World/Region.json`), so the art and the ground
|
||||
are the same rectangle at two scales and the whole projection is one multiply and one add per axis. Everything
|
||||
else about a map view falls out of that and most of it is absence: no scene capture, no render target, no minimap
|
||||
actor, no per-tile bookkeeping. A capture would also be *wrong* rather than merely expensive — the level is
|
||||
world-partitioned, so a capture only ever sees the streamed-in region, and a map is exactly the thing that must
|
||||
show ground nobody is standing on.
|
||||
|
||||
```
|
||||
Source/SaltyCore/World/
|
||||
└── WorldMapProjection.h // pure: world cm <-> 0..1 across the art, the seam, distance on the ground
|
||||
|
||||
Source/Salty/World/
|
||||
├── WorldMapDefinition.h // UPrimaryDataAsset: the projection, the layers, which level it is a map of
|
||||
└── WorldMapSubsystem.h // world subsystem: finds the definition, holds the markers, emits the event
|
||||
|
||||
Source/Salty/UI/
|
||||
├── SWorldMap.h // the map itself: drawing, panning, zooming, markers, scale bar
|
||||
└── WorldMapWidget.h // UWidget wrapping it, so a Blueprint can drop one into a screen
|
||||
|
||||
Source/SaltyEditor/WorldMap/
|
||||
└── WorldMapTab.h // the same SWorldMap in a dockable tab; a click moves the viewport camera
|
||||
```
|
||||
|
||||
**Why Slate and not a `UUserWidget`.** The map has two hosts with nothing else in common, and the editor tab has
|
||||
no `UWorld` at all — anything that needed one to exist could not be shared, and a second implementation is where
|
||||
two maps quietly start disagreeing about where things are. `UWorldMapWidget` is the UMG wrapper; `WBP_WorldMap`
|
||||
is what owns the frame around it, which is the usual C++/Blueprint line.
|
||||
|
||||
**Markers are registered, never searched for.** An actor that wants to be on the map adds one in `BeginPlay` and
|
||||
removes it in `EndPlay`. The local player's own body is deliberately not a registered marker: it moves every
|
||||
frame, and a registry entry rewritten every frame is a registry being used as a variable. It is queried each
|
||||
paint instead, through `GetLocalPlayerMarker`, which also means it is drawn last and can never end up hidden
|
||||
under a waypoint that happens to be on top of it.
|
||||
|
||||
**Following is sticky in both directions.** The map opens centred on the player and tracks them. Panning by hand
|
||||
turns the follow off, because a map that snaps back the moment you let go cannot be read — and because that
|
||||
would otherwise be a one-way door, a left double-click (or `bs.WorldMapFollow`) goes back to the player and
|
||||
resumes it. `FocusOnWorld` deliberately does *not* re-arm the follow: a "go here" jump and a "go back to me"
|
||||
are different intents and only one of them means "and keep up".
|
||||
|
||||
**The art is data.** `RawContent/World/MapArt/layers.json` says which planet images become layers; `Tools/MapArt`
|
||||
renders them (including the derived shaded-relief layer) and `Scripts/Authoring/create_world_map.py` imports them
|
||||
and writes the definition. The projection is copied out of `Region.json` rather than typed, because a map that
|
||||
disagrees with the landscape about how big the world is is the one bug here that still looks like a plausible map.
|
||||
|
||||
**The key is a debug bind, deliberately.** `M` opens the map and `N` cycles its layers, both `DebugExecBindings`
|
||||
in `Config/DefaultInput.ini` pointing at the `bs.*` commands. That is the engine's own mechanism for putting a
|
||||
key on a console command: development builds only, no Input Action, no mapping context, no asset. It is the
|
||||
right shape *because* it is not the input map — step 3 designs that, and a map key invented here would be a
|
||||
guess at an `Input.*` action that step has to live with. When the map gets a HUD it gets a real action and
|
||||
those two lines go away.
|
||||
|
||||
**Not yet:** there is no HUD, so the only ways in are that key, the console and the editor tab. It does
|
||||
not read the theme asset either, because there is not one yet; its colours are local and will move to `UUITheme`
|
||||
when that lands. Overlay marks — the towns, roads and forests `overlay.json` already carries in world metres —
|
||||
are not drawn; they transfer by the same normalised coordinates and are the obvious next thing.
|
||||
|
||||
## Telemetry
|
||||
|
||||
`settings_changed` with `setting_id` as the dotted path (`camera.fov.first_person`, `input.bindings.interact`) and
|
||||
the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and
|
||||
`prop_interacted` already answers whether players find things.
|
||||
|
||||
`world_map_opened` with the layer, the zoom in ground metres per pixel and what opened it. One event, because
|
||||
opening the map is the act worth counting and which layer and how far in say what a person wanted from it.
|
||||
Every way of opening it goes through `UWorldMapSubsystem::NoteMapOpened`, so the count is of maps opened and not
|
||||
of the ways of opening them.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. World-space widget legibility.** A `UWidgetComponent` at bench distance in both camera modes has to be
|
||||
|
||||
+585
-143
@@ -36,6 +36,30 @@ Every `bs.*`-style knob in the manifest has a `--flag` override so an experiment
|
||||
and for the coast `--no-coast`, `--outline-octaves`, `--outline-gain`, `--shelf-km`, `--surf-reach`,
|
||||
`--cut-fraction`, `--deposit-reach`, `--drift`, `--river-sediment`.
|
||||
|
||||
### The planet, which is the other half of the tool now
|
||||
|
||||
A painted template is a different command and a different manifest. `RawContent/World/Planet.json` points at
|
||||
an image and a legend; `RawContent/World/Templates/README.md` is how to paint one.
|
||||
|
||||
```bash
|
||||
# four seconds: read the painting, cut the planet into regions, solve nothing.
|
||||
# map_class.png and map_regions.png are the two pictures that decide whether a bake is worth starting.
|
||||
Tools/Terrain/bin/terrain.exe plan
|
||||
|
||||
# about two hours at 100 km round. Run it detached, never under a tool timeout.
|
||||
Tools/Terrain/bin/terrain.exe bake --out RawContent/World/Bake --jobs 4
|
||||
|
||||
# one landmass, short, for tuning the legend's numbers
|
||||
Tools/Terrain/bin/terrain.exe bake --only 11 --steps 200 --out /tmp/try
|
||||
|
||||
# the detail passes over a bake, a batch of tiles at a time. About twelve seconds a 5 km tile.
|
||||
Tools/Terrain/bin/terrain.exe tiles --bake RawContent/World/Bake --only 11,7,13,8
|
||||
```
|
||||
|
||||
**Every number in this section is pre-D-53 and has not been re-measured.** The router jitter moved from a
|
||||
hash of the grid index to a hash of the world position, which changes the square canvas's output everywhere;
|
||||
the summary block below was measured before it. Re-baseline before comparing anything against it.
|
||||
|
||||
### What a run writes
|
||||
|
||||
| File | What it is for |
|
||||
@@ -43,18 +67,31 @@ and for the coast `--no-coast`, `--outline-octaves`, `--outline-gain`, `--shelf-
|
||||
| `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_uplift.png` | **The most useful diagnostic.** Fault traces are stroked over it in cyan - the line rather than the rate it contributes, because even after D-62 a fault is a few kilometres wide and the map is a hundred kilometres across | 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_erodibility.png` | The lithology multiplier on K. Where texture inside a range comes from - on a painted planet that is each class's `k_mult` times the planet's rock field, which the seed re-rolls (D-58) |
|
||||
| `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 |
|
||||
| `map_overlay.png` | The annotation layer over a dimmed class map, when the planet has one. A mark means nothing on its own and everything against the coastline it was drawn along |
|
||||
| `overlay.json` | Every mark's area and piece count, and every feature in **world metres**: a centre, area, radius and extent per painted blob, an ordered polyline per path. What the engine places things from; nothing in the generator reads it back |
|
||||
| `meta.json` | The full manifest as resolved, plus every statistic |
|
||||
|
||||
### How a run is judged
|
||||
|
||||
**The class table prints two angles now and the second one is the one to read (D-57).** `divide` is
|
||||
`U/(K·A^m)` at a single cell, which is exact and is the *steepest* ground a rate can make; `typical` is the
|
||||
median over the class, measured at a third of it in tangent and flat across a factor of twenty in rate. Almost
|
||||
none of a map is divide. The old single column is why a legend could be set two or three times too hot and
|
||||
still look reasonable on paper.
|
||||
|
||||
**A planet bake prints this block now (D-59).** Until then it printed its elevation range and nothing else,
|
||||
which is why two questions this session - "are the lowlands hilly" and "do faults leave scarps" - had to be
|
||||
answered by hand in Python off a PNG. A partial run (`bake --only`) marks itself PARTIAL: its extent is the
|
||||
whole cylinder and its ground statistics are only the landmasses that were solved.
|
||||
|
||||
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²:
|
||||
@@ -93,134 +130,237 @@ after — and across at least two seeds, or raise the bin count before leaning o
|
||||
|
||||
## 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.
|
||||
Painted planets (D-53). The source stops being a seed: an author paints a flat cylindrical world map, a JSON
|
||||
legend beside it says what each colour means in uplift and erodibility, and the simulation makes the terrain.
|
||||
X wraps and Y does not, so a landmass may straddle the seam and comes out whole. The geology is solved **one
|
||||
landmass at a time**, which is exact rather than approximate because ocean cells are fixed at sea level for
|
||||
the whole run and nothing in the solve can move them, so no flow path crosses open water; the coastal pass and
|
||||
everything else run once over the whole cylinder, because the coast costs 26 ns a cell against 80 ns a cell
|
||||
*per step* for the solve and cutting it up would truncate the fetch across every strait and split the sediment
|
||||
budget. `terrain plan` reads the painting and cuts the planet into regions in four seconds, without eroding
|
||||
anything; `terrain bake` solves it. The router jitter moved to a hash of the world position at the same time,
|
||||
which is rule 1 of the tiling plan and re-baselines §1.
|
||||
|
||||
Full detail, including four things that were wrong first, is in `Terrain.md` under **What was built, and where
|
||||
it differs**.
|
||||
The detail passes came with it: 8 to 12 and 14 are built and tiled, so there is a full-resolution output for
|
||||
the first time — 5 km tiles of 2500 samples at 2 m, twelve seconds each, with a margin **measured** at three
|
||||
droplet lifetimes rather than reasoned at `rounds × lifetime`. The droplets had to become a pure function of
|
||||
world position for that to close, which is rule 1 arriving where it was always headed.
|
||||
|
||||
Full detail, including the eight things the shape of the work revealed, 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.
|
||||
still listed below, but they are *tuning*, and on a painted world two of the three are the author's job now.
|
||||
Do not spend the next session on them. One item that looked like composition was not and is closed: a class
|
||||
was one rate and therefore one landscape, which is §4.A0 and D-55.
|
||||
|
||||
The goal is: **get generation working end to end, then make the world author-driven and scalable.** Three
|
||||
things, in order.
|
||||
The goal is: **get generation working end to end at player scale.** Three things, in order.
|
||||
|
||||
### 3.1 Finish the pipeline (build-order step 6)
|
||||
### 3.1 Finish the planet's own passes
|
||||
|
||||
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.
|
||||
Two things the planet needed that the square canvas did not, and neither was optional for a bake to be judged.
|
||||
Both are closed:
|
||||
|
||||
### 3.2 Painted maps as the source
|
||||
- ~~**The coastal pass has to wrap.**~~ **Closed (D-60).** Four primitives wrap now, the abyss is a field so
|
||||
the derived slope meets the painted ocean depth rather than stepping to it, and `Geometry.Ref` holds a
|
||||
waterline slot so `supply` is a few hundred thousand entries instead of 608 MB. `Measure` holds one distance
|
||||
transform at a time. Measured: 7.9 s over the whole 76 M cell cylinder, and the seam step in the sea floor
|
||||
went from a mean of 9.1 m to 0.32 m, which is what an ordinary interior column is. The pass is now the
|
||||
memory peak of a bake, about 8.3 GB against the solve's 3.6.
|
||||
- ~~**`internal/stats` does not survive 28 M land cells.**~~ **Closed (D-59).** Fixed-bin histograms
|
||||
replaced every sort and `field.LocalRelief` replaced the O(radius²) window, but the change that mattered was
|
||||
not either of those: a histogram **adds**, so a planet's statistics are now *pooled* from its regions rather
|
||||
than computed on a grid that never exists. Each region accumulates while its own grid is alive and they
|
||||
merge in region order; the extent is measured once on the composited cylinder, because regions carry
|
||||
overlapping ocean margins and pooling their cell counts would double-count the water between them.
|
||||
Measured: 1.09 s for a 9 M cell region, about 120 ns a cell, so a whole planet is a few seconds at the end
|
||||
of a two-hour bake. A bake prints the full block now, and a *partial* one says so rather than letting the
|
||||
whole world's extent be compared with three islands' worth of ground.
|
||||
|
||||
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.
|
||||
And a third, smaller, which is both a cost and a correctness wart:
|
||||
|
||||
**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.
|
||||
- **`DiffuseNonlinear` bounds its sub-step count with the steepest slope on the whole grid**
|
||||
(`hillslope.go:62`). That is where the five-fold cost of mountains comes from and most of it is honest work
|
||||
— the sub-steps buy stability, and truncating them checkerboards the surface a few hundred steps later. But
|
||||
the bound is a *global statistic of the grid it is given*, so the steepest cell anywhere in a region sets
|
||||
the diffusion for every plain in it, and two different decompositions of the same world would differ
|
||||
slightly. It is the one place where the per-landmass split leaks into the answer, which is why the margin
|
||||
and the minimum landmass size are in the manifest and in `meta.json`. A per-band bound would close it and
|
||||
would be cheaper; whether it changes anything visible has not been measured.
|
||||
|
||||
Suggested channels, all optional, all falling back to the procedural field where absent:
|
||||
### 3.1a The painting has a tool now
|
||||
|
||||
| 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 |
|
||||
`terrain studio` (D-56) is the loop for everything in this section that is *authoring* rather than physics:
|
||||
brushes that carry the legend's numbers, a seam-aware canvas, and `plan` as a button. It has two sheets now
|
||||
(D-57): `classes` is the geology and `overlay` is the annotation layer, whose brushes are its marks and whose
|
||||
`coast_jitter` marks are the only thing on it any pass reads. The two measurements
|
||||
below - the 9.4 % seam disagreement and the JPEG halo - are both things it exists to stop happening again, and
|
||||
the first is a thing it can fix by painting.
|
||||
|
||||
**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.
|
||||
**Plan and Bake apply the panel before they run.** They always pushed both paintings first and never the
|
||||
numbers beside them, and since both read the legend and the manifest off *disk*, an edit still sitting in the
|
||||
rail was an edit the prepare never saw. A re-rolled seed was the case that showed it: the plan key did not
|
||||
change, the cached prepare came back, and the uplift and erodibility maps were identical - which looks exactly
|
||||
like a generator that ignores its seed. Both buttons now flush the planet block, the class legend and the
|
||||
overlay legend, in that order, and the report says what it applied; a `unsaved:` note under the Plan bar says
|
||||
what is pending before you press anything. Bake also pushes the paintings, which it never did: it solves the
|
||||
server's copy, so a stroke made since the last plan was two hours of answering the wrong question.
|
||||
|
||||
**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.
|
||||
**The region map is numbered.** Region hues came from a hash of the index, and independent hues collide - the
|
||||
closest pair of the first twenty was 8.5 apart in RGB, which nobody can distinguish on a map whose whole job is
|
||||
"is that one landmass or two". They walk by the golden angle now, with saturation and value on a 3 and 2 cycle:
|
||||
44.0 at twenty regions, 41.9 at twenty-six, 37.7 at forty. Colour alone still cannot carry forty regions, so
|
||||
`planet.RegionLabels` returns the centroid of each region's land and the studio writes the id over the map in
|
||||
screen-space text - crisp at any zoom, and tiled across the seam like everything else. The mean across is
|
||||
circular, because a landmass at `x = 0` and `x = W-1` has an arithmetic mean on the far side of the planet.
|
||||
These are the ids `bake --only` takes.
|
||||
|
||||
### 3.3 Scale, and why tiling is an architecture question
|
||||
**A finished bake lets go of the screen.** Its status and stamp outlive the run, so the first poll of every page
|
||||
load re-opened the preview of a bake that had ended hours ago - over the painting, blocking the brush, and
|
||||
reloading the page put it straight back. A load that finds nothing running now adopts the stamp instead of
|
||||
drawing it; a bake that finishes while somebody watches still lands its final preview. Every map view also has a
|
||||
visible way out now rather than only Escape, and the bake block has a Preview button to bring the last one back.
|
||||
|
||||
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:
|
||||
### 3.1b The first template does not wrap, and that is the input rather than the tool
|
||||
|
||||
| World side | Area | Geology cells at 8 m | Fluvial solve, 1000 steps | Grid memory |
|
||||
Measured by `terrain plan` on `Map3.jpg`: the left and right edges, which are the same meridian, **disagree on
|
||||
9.4 % of rows, 261 of them land against water**. The crater island crosses the seam perfectly — heights run
|
||||
continuously from the last geology column into the first — but islets drawn touching `x = 0` have nothing to
|
||||
meet them at `x = W-1`, so the world has a 400 m cliff down the seam wherever that happens. There is also a
|
||||
two-pixel JPEG halo on the outermost columns which classifies as shelf, putting a 400 m ledge the height of
|
||||
the map down the same line.
|
||||
|
||||
The tool is right and the painting is not, so nothing here is a defect to fix in code. What was added is the
|
||||
measurement, because it is the one defect an author cannot see by looking at their own picture: the two edges
|
||||
are as far apart on screen as they can be. The fix is to paint round the edge and export PNG.
|
||||
|
||||
### 3.2 The coastal detail, which is the last pass with nothing built
|
||||
|
||||
Passes 8 to 12 and 14 are built (see `Terrain.md`), so there is a full-resolution output: 5 km tiles of 2500
|
||||
samples at 2 m, about twelve seconds each. What is missing at player scale is now only the *shore*, and it is
|
||||
no longer blocked: `internal/coast` wraps (D-60), so there is a shelf, a shore platform and a beach for a
|
||||
detail pass to refine. Section 4.E3 is still the shape of it - the surf reach is 110 m, which is
|
||||
55 detail cells, enough for a real berm, a wave-cut notch and a scree apron below a cliff - and the tile bake
|
||||
is where it goes.
|
||||
|
||||
Two smaller gaps in pass 14: the weightmaps are not derived (nothing imports them yet), and pass 13, the spawn
|
||||
pad, is deliberately skipped because a planet has no single centre.
|
||||
|
||||
### 3.3 What is left of tiling
|
||||
|
||||
Both halves are built. The geology solve is decomposed per landmass and everything else runs whole (D-53);
|
||||
the detail passes tile, with a margin **measured** rather than reasoned - three droplet lifetimes plus the
|
||||
brush, which is 122 detail cells at the defaults, about five per cent of a 5 km tile on each side. Rule 1 is
|
||||
done for the router, for the painted path and for every detail pass; the one place still on map-relative
|
||||
coordinates is the square canvas's own `uplift.Build`, and that is deliberate, because there the noise *is*
|
||||
the continent.
|
||||
|
||||
**How big can a world be, now.** The binding number is no longer the planet but its largest landmass, because
|
||||
the solve is per landmass. Measured on the 100 km template: 18 regions, 49 M cells of 76 M, the largest 14 M
|
||||
at under a gigabyte, and the detail another forty minutes for all 200 tiles, fully batchable. A 200 km world
|
||||
with landmasses of the same *shape* is four times that; the case to watch is one landmass four times as wide,
|
||||
because that single region is the peak.
|
||||
|
||||
**The wall time is set by the largest single region, and that region runs at about one core.** Measured on
|
||||
the second bake of the 100 km template: seventeen regions finished in 11 586 s of solve with four in flight,
|
||||
and the eighteenth - the 14 M cell central lowland - then ran alone for over 90 minutes at 1.0 to 1.1 cores.
|
||||
That is not a defect, it is the shape of the solver: `Terrain.md` records that most of the runtime is the
|
||||
stack walk and the priority-flood's cursor, and neither parallelises *within* one grid. Running regions
|
||||
concurrently hides it while there are several left and hides nothing at the end.
|
||||
|
||||
Two consequences. The `--jobs` throughput number is not the wall time: a template whose land is one big
|
||||
landmass gets almost no benefit from it. And **parallelising the stack update by basin** - which `Terrain.md`
|
||||
already lists as option 2 for the time budget, and which is where the cores would actually go - has moved
|
||||
from "a real gain, bounded" to the only thing that would shorten a bake of this shape. Disjoint basins are
|
||||
independent; only the walk within one is sequential.
|
||||
|
||||
**And the cost per cell depends on the uplift rate, by a factor of eighteen.** Measured on the same bake at
|
||||
1000 steps with four regions in flight:
|
||||
|
||||
| class | rate | cells | time | per million cells |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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 |
|
||||
| lowland | 0.08 mm/yr | 14.0 M | 1014 s | 72 s |
|
||||
| highland | 0.90 mm/yr | 4.0 M | 1394 s | 350 s |
|
||||
| crater | 1.60 mm/yr | 1.4 M | 1829 s | 1278 s |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
It is not the stream power; it is the hillslope. `DiffuseNonlinear` sub-steps to stay stable, the count rises
|
||||
with the steepest slope on the grid, and it saturates at `max_hillslope_substeps` — 24 by default. Steep
|
||||
ground pays all 24 every step and a plain pays one. Three consequences: `terrain plan`'s estimate is
|
||||
calibrated on the plains and is a **floor**; raising an `uplift_mm_yr` changes the bake time as well as the
|
||||
terrain; and the wall time is set by the single slowest region, not the total, so one small steep landmass can
|
||||
be the whole tail.
|
||||
|
||||
## 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
|
||||
### C. Detail — built, and what it left behind
|
||||
|
||||
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.
|
||||
~~Nothing exists at player scale~~ — closed. Passes 8 to 12 and 14 are built and tiled; see `Terrain.md` for
|
||||
what the port cost and the four things that were wrong on the way. What is left of this entry is three
|
||||
narrower items, none of them a blocker:
|
||||
|
||||
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.
|
||||
- ~~**The shore is still a step.**~~ Closed (D-60): the coastal pass wraps, so a planet has a shelf, a
|
||||
shore platform and a beach for the detail passes to refine. §4.E3 is now unblocked and is the next thing.
|
||||
D-56's coast mask is a different thing and does not close this: it decides *where the waterline is*, at the
|
||||
paint's own resolution, before anything is solved. `internal/coast` is what puts a shelf and a shore
|
||||
platform under it, and that still has to wrap.
|
||||
- **The weightmaps of pass 14 are not derived.** The rules are in the numpy and they are ported unchanged when
|
||||
something imports them; flow, wear and deposit already come out per tile, which is what those rules read.
|
||||
- **Nothing has been judged on the ground.** The tiles look right in a hillshade and the seam is measured, but
|
||||
the question the whole pipeline exists to answer — does this read as ground to somebody standing on it — has
|
||||
not been asked, because nothing imports a tile yet.
|
||||
|
||||
### A0b. What the lowlands are actually doing — measured, and mostly the author's numbers
|
||||
|
||||
Raised again as "the lowlands still by default become super hilly". Measured at last, on a 600² grid of 8 m
|
||||
cells with the manifest's own constants at 1000 steps and a uniform rate:
|
||||
|
||||
| U mm/yr | divide | median | P90 | over 3° | max elevation |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 0.012 | 1.7° | 0.58° | 1.00° | 4 % | 32 m |
|
||||
| 0.045 | 6.4° | 2.12° | 2.85° | 8 % | 38 m |
|
||||
| 0.080 | 11.3° | 3.72° | 4.85° | 75 % | 54 m |
|
||||
| 0.250 | 32.0° | 11.13° | 14.03° | 99 % | 143 m |
|
||||
|
||||
Three things follow and the first is the answer to the complaint.
|
||||
|
||||
**The massif floor already is a plain, and the real continent confirms it.** Region 12 baked whole — 45.9 ×
|
||||
19.8 km, 9.0 M land cells, 1000 steps, 27 minutes — comes out **0..47 m** with a slope distribution of p50
|
||||
0.61°, p90 1.22°, 4.4 % over three degrees and **nothing at all over eight**. The controlled run at a uniform
|
||||
0.012 mm/yr gives 0.58° and 4 %, so the two agree. There is no missing process here and no fine dissection to
|
||||
remove; D-55 did what it said.
|
||||
|
||||
**What made it look hilly was `preview.png`.** The hypsometric ramp's top is a *percentile of the world being
|
||||
drawn*, so the whole ramp — green, tan, rock, snow — was stretched over this continent's 32 m, and its 40 m
|
||||
hills came out with the white caps a 2800 m range would get. Redrawn against a fixed 400 m ceiling the same
|
||||
heightmap is a flat green plain with four pale massifs on it. Closed: `palette.land_top_m` is an absolute
|
||||
ceiling, and every run now prints which ceiling its preview was drawn against. The percentile stays the
|
||||
default, because an absolute ramp over a world with no mountains is a green shape with nothing legible on it.
|
||||
|
||||
**What makes a painted lowland read as hill country is its massif share and the rate the massifs reach.** At
|
||||
`fraction` 0.16 the ramp opens at the 76th percentile of the planet, so about a *quarter* of the class is off
|
||||
the floor, and the class rate it climbs to — 0.08 mm/yr — is a 3.7° median, which is continuous rolling
|
||||
ground. Both numbers are the author's. The lever for "more flat ground" is a smaller `fraction`; the lever
|
||||
for "gentler hills where they are" is a lower `uplift_mm_yr`.
|
||||
|
||||
**And the number they were steering by was wrong by a factor of three**, which is D-57 and is closed: the
|
||||
table printed the divide angle, which is the steepest place in a catchment, as if it were the landscape.
|
||||
|
||||
What is *not* closed, and is the real version of "lowlands should not consider mountainous erosion": every
|
||||
cell of the world runs one process with one diffusivity, one critical slope and one channel threshold, and the
|
||||
only per-class levers are `U` and `k_mult`. At a fixed cell those two set relief and steepness together
|
||||
(§4.B0), so "flat but with real relief" is not expressible. The principled fix is the pairing in §4.D.3 — a
|
||||
critical area with a hillslope diffusivity to match — and the thing that makes it newly plausible is that it
|
||||
could be **per class**: §6 rejects it because D large enough to shed the uplift "smooths away every landform",
|
||||
which is a fatal objection on a mountain and a *description of a plain*. Cost is the obstacle, not principle:
|
||||
`DiffuseNonlinear` sub-steps on `D·dt/dx²`, so D 0.3 on a lowland region is 36 sub-steps against 3, and the
|
||||
14 M cell region that already takes an hour and a half would take most of a day. Implicit diffusion, or a
|
||||
per-band sub-step bound, is what would make it affordable. **Not started, and not to be started without
|
||||
measuring the sub-step cost first.**
|
||||
|
||||
### D. Scale-independence — the one that becomes load-bearing
|
||||
|
||||
@@ -267,16 +407,20 @@ strip the surf works in, so the cliff began a hundred metres inland instead of a
|
||||
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.
|
||||
**E1b. The map margin draws one coastline in seven, and it draws it straight — on the square canvas only.**
|
||||
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.
|
||||
|
||||
**On a painted planet this is closed rather than deferred (D-53):** there is no `continentMask` and no map
|
||||
border, because the outline is the paint and a region's edges are open ocean by construction. The cheapest fix
|
||||
for the square canvas is still the same one — perturb the margin distance with a low-amplitude noise field so
|
||||
the cut follows a crenellated line instead of a ruled one — and it now has one fewer consumer.
|
||||
|
||||
**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
|
||||
@@ -285,6 +429,12 @@ budget came back unplaced, because real exposed coasts do have beaches, they jus
|
||||
next door. Re-measure this once the outline is painted rather than noised; it is the same question as E1 from
|
||||
the other end.
|
||||
|
||||
*First painted reading (D-60), and it is not yet the answer:* a partial planet bake came out 0.96 / 1.00 /
|
||||
1.00, which is not a thinner contrast than the square canvas had but a flatter world - the run solved one
|
||||
landmass, so most of the painted coast was still unsolved ground sitting at sea level with nothing behind it
|
||||
to shelter anything. Exposure is measured on the waterline and the shape of the land behind the shore is half
|
||||
of what sets it. Read this off a whole bake before touching the floor.
|
||||
|
||||
**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
|
||||
@@ -302,8 +452,51 @@ at `--size 1400` (10.2 m cells). A finer grid resolves more of the threshold's w
|
||||
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.
|
||||
|
||||
**E6. The shelf break is fixed, and the whole-planet measurement is owed (D-64).** The near-shore sea was
|
||||
30 m deep everywhere because `BreakM` came from `pipeline.continent.sea_floor_m`, a square-canvas default; it
|
||||
is `pipeline.coast.break_m` now and a planet gets 130 m. The mechanism is measured at unit scale — a painted
|
||||
512 m sea goes from 33 % to 8 % shallower than 50 m — but **the re-bake that would give the planet numbers
|
||||
was killed by memory pressure before it wrote anything.** What to run, and the numbers to put beside
|
||||
`Bake_020`'s:
|
||||
|
||||
```bash
|
||||
Tools/Terrain/bin/terrain.exe bake --out RawContent/World/Bake_D64 --jobs 4 # detached; ~13 min, peaks near 8.3 GB
|
||||
```
|
||||
|
||||
Read back the depth histogram of `planet_height_low.png` against `Bake_020`'s, which was `-520 m 11.7 %`,
|
||||
`-20 m 26.8 %`, `0 m 23.6 %`. What should have changed: the −20 m spike disperses across 0…−130 m, the share
|
||||
deeper than 400 m rises towards the 55.9 % the legend paints, and `preview.png` gains a bathymetric gradient
|
||||
where it had one flat mid-blue halo — the preview ramps `sea_shallow`→`sea_deep` linearly over the deepest
|
||||
sea, so 20 m out of 520 was the first colour and nothing else. `--jobs 2` if memory is tight.
|
||||
|
||||
**E7. `elevation_m` is three times wider than any world that has been baked, and it is the author's key.**
|
||||
−1024…2048 against data of −521…+340: the planet uses 28 % of its 16-bit ramp and its land 7 %, which is most
|
||||
of why the exported heightmap reads as a flat grey picture with no coastline in it. A bake prints this now.
|
||||
It is not a defect — headroom is a legitimate choice and D-64 deliberately did not touch `Planet.json` — but
|
||||
about −576…320 is 3.4× the contrast and 3.4× the vertical resolution for the compositions built so far. **The
|
||||
trap if it is changed:** `tiles` decodes `planet_height.png` through the *current* manifest, so a bake made
|
||||
under one range and tiled under another is silently wrong by the difference. Re-bake, or do not change it.
|
||||
|
||||
### B. Texture — wrong at mid scale
|
||||
|
||||
**B0. The clamp ceiling has a number now, and a painted legend walks straight into it.** Steady state is
|
||||
`S = U/(K·A^m)` applied down to a single cell, so at a divide `A` is one cell squared and `A^m` is the cell
|
||||
size. Setting that equal to the angle of repose gives the rate above which the clamp does all the shaping:
|
||||
|
||||
> `U_max = tan(talus) · K · cell` — at 35°, K 5e-5 and an 8 m cell, **0.280 mm/yr**.
|
||||
|
||||
The first painted legend put `highland` at 0.9 mm/yr, which is **66° at a divide**, 3.2 times over. Baked, the
|
||||
geology comes out as flat polygonal faces with hard 45- and 90-degree edges - the D8 clamp, visible at a
|
||||
glance once the detail passes are stripped off with `terrain tiles --no-detail`. Not 33 % of the class shaped
|
||||
by the clamp: all of it.
|
||||
|
||||
`terrain plan` now prints the implied divide angle for every land class and says which are clamped, which is
|
||||
four seconds against an hour and a half. The deeper point is the one §4.D.3 is about: **at a fixed cell,
|
||||
relief and steepness are the same knob.** `U/K` sets both, so there is no setting that gives a 700 m range
|
||||
with hillslopes below repose - 0.28 mm/yr on a 20 km island is about 350 m. Getting more relief than that out
|
||||
of erosion-shaped ground needs the channelization threshold to work, which is exactly the open problem in
|
||||
§4.D.3 and §6.
|
||||
|
||||
**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
|
||||
@@ -311,23 +504,165 @@ keeps it from showing as hard facets. Levers, most principled first: raise `max_
|
||||
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.
|
||||
**B2. ~~Multiple-flow-direction accumulation is unbuilt~~ - built (D-65), and it turned out to be B3's
|
||||
cause rather than a refinement of the plains.** This entry read "some basin boundaries on the plains in
|
||||
`map_basins.png` are visibly straight" and treated MFD as tidying. It is not: on a planar hillslope the
|
||||
correct specific catchment area is the same at every point along a contour, and D8 cannot say so - every
|
||||
cell picks the same steepest neighbour, the flow lines run exactly parallel and never converge, and a cell
|
||||
either sits on a line and carries the whole tube or sits off one and carries a single cell for ever.
|
||||
Measured on a ramp at an aspect of 22.5 degrees with no erosion at all, one fill and one accumulate
|
||||
(`internal/fluvial/flow_test.go`): the most-drained cell in a contour band carried **769 times the median**
|
||||
and **29.5 % of the grid drained nothing**. At an MFD exponent of one the same numbers are **1.34** and
|
||||
**0.4 %**.
|
||||
|
||||
**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.
|
||||
Built as planned - Freeman/Quinn/Holmgren partition for `Accumulate` only, D8 receivers kept for the
|
||||
implicit update, because Braun-Willett walks one receiver chain and has no unconditionally stable
|
||||
multi-receiver form. Two things this entry had wrong. **The processing order is not descending elevation**:
|
||||
Kahn's algorithm over the flow graph is exact, O(n), and needs no elevation comparison at all - count each
|
||||
cell's strictly higher neighbours, release on zero. A bucket sort by elevation would have been worse than
|
||||
useless, because the queue quantises to a centimetre while the flood's epsilon ladder across a filled flat is
|
||||
a millimetre a cell, so ten cells of one descending chain share a bucket and every lake bed would leak its
|
||||
area. And **float32 is enough**: a cell's accumulator takes at most eight contributions, each already an
|
||||
aggregate, so the drift is a random walk over the flow path and measures 2.4e-9 relative over a closed basin.
|
||||
|
||||
**The bake-scale verification is owed.** Everything above is measured on the router in isolation and on the
|
||||
square canvas. The comparison that matters - region 8 of `Planet.json`, the streaked left continent, the same
|
||||
painting and seed - has a D8 baseline in `RawContent/World/R8_d8` (15m40s, 922 s of solve, land relief 195 m,
|
||||
slope-area exponent -0.509 at R² 0.966, drainage density 0.43 /km, and 31 sources to 13 confluences in the
|
||||
12.8 km window at 1391,2625) and **no MFD twin**: that run was killed by memory pressure about 70 % through
|
||||
and wrote nothing. Until it exists, what is established is the mechanism, not the cure - and one result
|
||||
argues for caution rather than optimism, which is that after three hundred steps of solving a planar ramp the
|
||||
leaf fraction converges (D8 8.2 %, MFD 8.9 %), because a dissected landscape's own divides dominate that
|
||||
count. Run:
|
||||
|
||||
```bash
|
||||
Tools/Terrain/bin/terrain.exe bake --only 8 --steps 1000 --jobs 1 --out RawContent/World/R8_all # detached, ~20 min
|
||||
```
|
||||
|
||||
and read the hillshade of the same window against `R8_d8`'s first, the sources-per-confluence second, and the
|
||||
slope-area exponent third - it should move *towards* -0.5, which is the number that says the fix is physics
|
||||
rather than a smoother.
|
||||
|
||||
The cost is real and it is the argument against, if there is one: **101 ns a cell against D8's 17**, measured
|
||||
on a 1024² ramp, and the walk is serial where `ComputeReceivers` and the hillslope law are not, so it lands on
|
||||
wall clock rather than on cores. `pipeline.fluvial.mfd_exponent` is 1 by default and 0 is the old behaviour,
|
||||
so the A/B is one flag. **`secondsPerCellStep` in `internal/planet/planet.go` is still the D8 number**, so
|
||||
`terrain plan`'s estimate now reads low; recalibrate it from the first full MFD bake rather than guessing, and
|
||||
note that its comment already says it is a floor.
|
||||
|
||||
**B3. ~~A ribbed, combed texture on the range flanks~~ - the diagnosis in this entry was wrong, and the
|
||||
cause is B2 (D-65).** It read "the ridged-noise initial relief showing through where the solve has not had
|
||||
time to overwrite it", and the reasoning that ruled out the alternative does not hold: it dismissed grid
|
||||
locking because "the ribs are oblique, not axis-aligned", but D8's parallel-flow grooves run in whatever
|
||||
direction the slope faces, so obliqueness is the expected appearance and not a counter-indication.
|
||||
|
||||
What the grooves are, measured on `Bake_x4` and `Bake_020` - the same painting and seed at 32 m and at 8 m,
|
||||
both 12500 x 6076, so the same window in cells compares directly:
|
||||
|
||||
- they are in `map_flow.png` as parallel high-accumulation lines, so they are **channels**, not surface
|
||||
texture, and they are absent from `map_uplift.png`;
|
||||
- the network re-derived from `planet_height.png` is **pinnate** - ruler-straight parallel trunks with short
|
||||
barbs joining at a near-constant angle - not dendritic. In a 12.8 km window at a 1 km² channel threshold:
|
||||
25 sources and **0 confluences** at x4, 30 and 11 at 100 km, against about one source per confluence for a
|
||||
dendritic network; the largest catchment in a 164 km² window is 4.07 km²;
|
||||
- the D8 receiver histogram over that window is anisotropic: 17.3 % on one diagonal, 14.3 % on its opposite,
|
||||
the other six 10.7-12.4 %, against 12.5 % uniform;
|
||||
- and the pitch is **the same 18 cells in both bakes**. That is the decisive one. Every physical candidate -
|
||||
the ridged fBm this entry blamed (250-300 m), the massif fabric, the fault grain - is fixed in *metres* and
|
||||
would change its pitch in cells by four. Only a grid-scale mechanism survives it.
|
||||
|
||||
The spacing is set by the ±0.05 % tie-break jitter at `fluvial.go`, which is a **static** field - the same
|
||||
hash at step 1 and at step 1000 - so the rare merges it allows are re-carved a thousand times instead of
|
||||
averaged out. The ridged fBm may still contribute; it cannot make grooves that are strictly downslope,
|
||||
strictly parallel, and visible in the flow map.
|
||||
|
||||
**B4. `ClampToRepose` left grid-aligned facets, and `bucketPQ` broke ties in raster order** - fixed (D-65), and the fix is worth less than it looks.
|
||||
|
||||
*The entry as written, which is still the right description of the mechanism:* Checked
|
||||
while chasing B3 and **ruled out as its cause** - B3's ribs are oblique - but real and worth not
|
||||
re-deriving. `internal/fluvial/repose.go` pushes every cell in flat-index order and lowers neighbours in
|
||||
place, so which neighbour gets cut is decided by pop order; `bucketpq.go` pops LIFO within a 1 cm bucket,
|
||||
so ground flat to within a centimetre propagates consistently along −X within a row. `hillslope.go`'s own
|
||||
comment admits the signature: "pyramids with faces aligned to the grid - the blocky, ruler-cut facets". The
|
||||
designed mitigation is `DiffuseNonlinear` running after it, which bails entirely at `diffusion_m2_yr` 0.
|
||||
Visible today only as a fine chevron texture inside B3's ribs. The related hazard - `bucketPQ` collapsing
|
||||
everything above `SetElevationRange`'s ceiling into one bucket processed in strict reverse row-major - is
|
||||
**not** firing: `ClipFrac` is 0 in every region of every bake measured.
|
||||
|
||||
*What the fix did, and what it did not.* The clamp now jitters both its pop order and its allowance with the
|
||||
same world-keyed hash `ComputeReceivers` uses, through a `pushJittered` that scatters a cell over sixteen
|
||||
buckets rather than one. Half a bucket was tried first and is not enough - it splits a tie across two buckets
|
||||
and halves the correlation instead of removing it. Sixteen is safe for a reason worth keeping: the clamp's
|
||||
order can only matter between two cells whose heights differ by about the talus allowance, which is *metres*,
|
||||
so reordering cells that are centimetres apart cannot break a constraint that only bites metres apart. The
|
||||
bound is `talus*cell/2`, 2.8 m at 35 degrees on an 8 m cell. Measured: the pop order's rank correlation with
|
||||
the flat index went from **-1.000 to -0.025**.
|
||||
|
||||
But the isotropy test built for it (`TestClampToReposeIsIsotropic`) reads **identical** with the jitter, without
|
||||
it, and with either half alone - 0.97 % four-fold and 2.39 % eight-fold on a clamped cone. On a cone no two
|
||||
cells share a bucket, because the surface falls twenty metres a cell against a one-centimetre bucket, so the
|
||||
ordering bias has nothing to bite on. The residual octagon is geometry, not order: a path to a point at 22.5
|
||||
degrees is built of cardinal and diagonal steps and the octile distance it accumulates exceeds the straight
|
||||
line by up to 8 %, so an eight-connected clamp cuts an octagon out of a cone whatever order it works in. That
|
||||
is irreducible without a wider neighbourhood. **And the clamp is a small actor anyway** - `near_talus_fraction`
|
||||
is 0.32 % of mountain cells - so this was housekeeping, not the fix.
|
||||
|
||||
**B5. The hillslope smoother transported across four faces while the clamp cut across eight** - fixed (D-65).
|
||||
`Run`'s design is that the clamp cuts and `DiffuseNonlinear` rounds off what it cut before the next step sees
|
||||
it, and a five-point stencil cannot transport across a diagonal face at all, so a diagonally-cut facet was
|
||||
left standing by construction. That was a hole in the stated design rather than a refinement of it. The
|
||||
stencil is nine-point now, weights 4/6 cardinal and 1/6 diagonal - the isotropic nine-point Laplacian, which
|
||||
on `h = (a/2)(x²+y²)` gives `(1/6)(8ad² + 4ad²) = 2ad²`, exactly what the five-point gave, so `coeff` is
|
||||
unchanged. A diagonal face carries its own critical height difference, `sc*dx*sqrt(2)`, or every diagonal
|
||||
would read as 1.41 times its true S/Sc. Stability improves and pays for the extra faces: the checkerboard
|
||||
amplification goes from `1 - 8*coeff` to `1 - 5.333*coeff`, so the limit moves 0.25 -> 0.375 and the sub-step
|
||||
target moves 0.2 -> 0.3 at the same 1.25x margin. Making either change without the other is a scheme that
|
||||
checkerboards a few hundred steps in, which is why they are one commit and why the stability test now runs
|
||||
2000 steps rather than 500.
|
||||
|
||||
**B6. There is an edge-preserving smooth now, and it is off.** `field.SmoothEdgePreserving`, ported from the
|
||||
World Orogen browser generator, which has one for exactly this reason - to blend its own routing artefacts
|
||||
without rounding the landforms off with them. `w = 1/(1 + |dh|/(d*slopeRef))` over eight neighbours, land
|
||||
only, waterline locked, run once after the solve and never inside the step loop: it conserves nothing and has
|
||||
no time in it, so per-step it would act as an uncontrolled extra diffusivity, and that moves the steady-state
|
||||
slope, which is `U/K`, which is the one knob the generator's relief hangs on. The deviation from the
|
||||
reference is units: a sensitivity in 1/m is a height threshold and means something four times as aggressive on
|
||||
an 8 m cell as on a 32 m one, which is exactly what section 4.D says the generator lives or dies by, so it is
|
||||
a slope. Measured on a synthetic: 77 % of a 4 m ripple removed, 98 % of a 300 m cliff kept. `pipeline.smooth.passes`
|
||||
is **0** by default - turning it on is a decision to hide something rather than fix it, so it is a decision
|
||||
somebody makes in a file - and a run with it on has to match a run with it off on the slope-area exponent,
|
||||
the drainage density and the per-class median slopes, or it is shaping terrain rather than polishing it.
|
||||
|
||||
**And measured against those gates it fails, which is the point of having them.** On the square canvas at
|
||||
`--size 500`, 300 steps: two passes at `slope_ref` 0.3 take the slope-area exponent from **-1.02 to +0.36**
|
||||
with the fit collapsing from R² 0.92 to 0.36, the channel cells from 772 to 566, and the mountain class's
|
||||
median slope from 15.3° to 11.6°. Backing off does not rescue it - one pass at 0.02, which is a one-degree
|
||||
reference, still lands at **+0.16** and 14.4°. Drainage density is the one thing that does not move (0.43 /km
|
||||
throughout). So this is not a free polish at any setting: it is a filter, it changes the slope-area relation
|
||||
the solve exists to produce, and what it is for is somebody deciding in a file that they want the look more
|
||||
than they want the statistic. It is not a substitute for B2, and it was not turned on to get B2's result.
|
||||
|
||||
### 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.
|
||||
**A0. ~~One class is one landscape~~ — closed (D-55), and it was not a tuning item at all.** This entry used
|
||||
to be absent and the defect it names is the one a person spotted by looking at the bake: every landmass came
|
||||
out *uniformly* dissected, coast to summit, with no flat ground on any of them. `n` is 1, a class was one rate,
|
||||
and D-49 says the rate alone fixes the hillslope angle — so one painted colour was one landscape, at whatever
|
||||
angle its rate named, over every cell of it. A class now carries `massif: {floor_mm_yr, fraction}` and cuts one
|
||||
planet-wide upland fabric, so a painted lowland is a plain with hill masses standing out of it. See `Terrain.md`
|
||||
for the threshold problem, which is the part with a wrong answer available: a percentile of the region would
|
||||
have made two regions disagree along every boundary.
|
||||
|
||||
Two numbers from it worth keeping here. **The fabric wavelength has to sit well below a landmass** — 12.5 km
|
||||
against islands of 20–45 km put one island entirely above the cut, which is this same defect one size down; 7 km
|
||||
is what the current template uses. And **`internal/stats`' "plain below 0.1 mm/yr" is a reporting bucket, not a
|
||||
description of terrain**: 0.1 mm/yr is a fourteen-degree hillslope, and reading that line as guidance is how the
|
||||
legend's plains were set ten times too hot. The buckets are unchanged — they are an axis with a run of measured
|
||||
numbers behind them — but `terrain plan` now prints what a class reads as from its *angle*, which is the number
|
||||
an author is really choosing.
|
||||
|
||||
The three below are one-or-two-constant changes on the *procedural* path. 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:
|
||||
@@ -356,7 +691,20 @@ property the percentile ramp exists to provide. Note also that the manifest key
|
||||
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
|
||||
**A2a. ~~A fault is a welt with a cliff down the middle~~ - closed (D-62).** The cross-fault profile put
|
||||
the whole throw either side of the trace one cell apart (89 degrees) inside a 600 m flank - narrower than the
|
||||
1.1 km hillslope the drainage density implies, so nothing could dissect it and the uplift profile printed
|
||||
onto the surface as a smooth ruled ridge. Continuous, kilometres wide and bell-tapered along strike now; see
|
||||
Terrain.md. The general lesson is in section 6.
|
||||
|
||||
**A2. ~~Fault traces are drawn curves with stamped ends~~ - closed on the painted path (D-58), still open on
|
||||
the procedural one.** The painted implementation is a separate file written against this list rather than a
|
||||
port of the code below: a walked heading-perturbed trace, a throw tapered over the last sixth at each tip,
|
||||
en-echelon segments past twelve kilometres, and no clamp to a fraction of a global rate. The procedural
|
||||
`buildFaults` is untouched and still has all four. What follows is that list, kept because it is what the new
|
||||
one was written against.
|
||||
|
||||
*The original entry, against the procedural `buildFaults`:* 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
|
||||
@@ -364,7 +712,13 @@ influence stops dead; and `if r > convergent*1.6` flattens the strongest throws
|
||||
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.
|
||||
**A3. ~~Range grain runs as straight parallel bands~~ - closed on the painted path (D-58).** The painted
|
||||
fault set takes its strike from a grain field sampled as a vector through `atan2`, so traces are sub-parallel
|
||||
within a province and the set swings across the world. Sampled as an *angle* it would have been worse than one
|
||||
global angle: a value lattice runs 0..1 and jumps a whole turn along its own wrap. The procedural path below
|
||||
is unchanged.
|
||||
|
||||
*The original entry, against the procedural path:* 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.
|
||||
@@ -373,20 +727,14 @@ 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.
|
||||
1. ~~**Make `internal/coast` wrap.**~~ Done (D-60). The mass-balance tests still pass unchanged on a flat
|
||||
grid and have cylinder twins.
|
||||
2. ~~**Fix `internal/stats` for planet scale.**~~ Done (D-59). A bake prints the whole block, pooled from
|
||||
its regions.
|
||||
3. **Then the coastal detail (§3.2, §4.E3)**, which by then has a shelf and a shore platform to refine and is
|
||||
where a player will stand first.
|
||||
4. **Then composition (§4.A)**, which by then can be judged against a real painted world rather than against
|
||||
noise — and on a painted world two thirds of it is the author's job, not the generator's.
|
||||
|
||||
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
|
||||
@@ -394,8 +742,6 @@ in the editor cannot be iterated on, and sculpting that does not survive a rerun
|
||||
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.
|
||||
@@ -421,11 +767,107 @@ Each was measured, not guessed.
|
||||
- **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.
|
||||
- **Giving each region enough margin to run the coastal pass inside it.** It needs `shelf_km` plus `slope_km`,
|
||||
4.6 km, which nearly doubles every region; and it buys nothing while costing the fetch across every strait,
|
||||
the sediment budget's conservation proof, an ocean-ownership rule where two margins overlap, and pooled
|
||||
coastal statistics. The pass is 26 ns a cell. Run it once on the cylinder.
|
||||
- **Clustering landmasses by overlapping dilated bounding boxes.** Box overlap is transitively closed and one
|
||||
long thin landmass has an enormous box; on the first real template — where one landmass is 70 km of a 100 km
|
||||
circumference — it collapses the planet into a single region. Dilate the mask itself with the distance
|
||||
transform and connected-component the result, which groups exactly those landmasses within a margin of each
|
||||
other and is the same code the stroke fill already needs.
|
||||
- **Hanging a world origin on `field.Field`.** A `Field` is used for masks, coordinate pairs and scratch,
|
||||
`field.New` has no origin to give them, and every one of them would quietly claim to sit at (0, 0). A
|
||||
wrong-by-default origin cannot be seen; a missing argument is a compile error.
|
||||
- **Taking the massif threshold as a percentile of the region.** It is the obvious implementation and it is
|
||||
the one thing D-53's decomposition forbids: `uplift.Build`'s percentile band is a global operation over the
|
||||
grid it is given, and two regions taking quantiles of their own extents put the same physical hillside on
|
||||
different sides of the cut, so the planet disagrees with itself along every region boundary. The threshold is
|
||||
a quantile of the **planet**, from a fixed probe, identical in every region by construction.
|
||||
`TestTwoFramesAgreeAboutTheSameGround` is the negative control.
|
||||
- **A massif fabric per class.** One field for the whole planet, cut at a different level by each class. A
|
||||
fabric per class makes a highland belt and the hills in the lowland beside it two unrelated noises meeting at
|
||||
a painted edge, when what they should be is the high and low parts of one structure.
|
||||
- **A massif wavelength near the size of a landmass.** Measured: 12.5 km against islands of 20–45 km put one
|
||||
whole island above the cut, which is the defect the fabric exists to fix, one size down. Several blocks per
|
||||
continent, so well under a landmass.
|
||||
- **Reading a hypsometric preview as a statement about scale.** The ramp is normalised to the world in
|
||||
front of it, and it has to be - absolute over a world with no mountains is a green shape with nothing
|
||||
legible on it. `palette.land_top_m` is there for when absolute is what you want, and the run summary
|
||||
names the ceiling either way. The judgement of steepness belongs to `map_slope.png` and the plan's
|
||||
`typical` column.
|
||||
- **Porting `uplift.Build`'s lithology or faults as they stand.** The first ends in `f.Percentile()` of the
|
||||
grid it is handed and the second places trace centres at `Float()` pairs read as fractions of it, so on a
|
||||
decomposed planet both give a different answer in every region. D-58 rewrote both against world coordinates
|
||||
and a planet quantile; `TestTwoFramesAgreeAboutTheSameRock` and `TestTwoFramesAgreeAboutTheSameFaults` are
|
||||
the negative controls.
|
||||
- **Blurring a rock boundary to soften it.** A blur is a neighbourhood operation and one near a region's edge
|
||||
reads cells another decomposition would not have given it. The softening is pointwise, in rank space, which
|
||||
needs only the cell's own value and grades the boundary by the fabric's own gradient.
|
||||
- **Cutting a fault's influence off where the exponential is still worth something.** At three gentle lengths
|
||||
it is 5 % of peak, which on a 400 m throw is a fifth of a lowland's whole uplift rate - a step at a line
|
||||
six kilometres out that the solve carves into a straight scarp. Superseded by D-62, which drops the
|
||||
exponential for an envelope that reaches zero *with zero gradient* at its own width - the cut-off is then
|
||||
the support of the function rather than a truncation of it, and there is nothing to renormalise. The
|
||||
lesson is the general one and it is why the old profile's near end was worse than its far end: **any step
|
||||
left in the uplift field, anywhere, is a straight scarp the solve cannot undo.**
|
||||
- **Reading the divide angle as the landscape.** It is exact and it is the steepest ground a rate can make,
|
||||
because `A` is smallest at the top of a catchment; the median is a third of it in tangent and almost none of
|
||||
a map is divide. Both print now (D-57). This is the same class of mistake as reading `internal/stats`'
|
||||
"plain below 0.1 mm/yr" as terrain, and it cost the same thing: every rate in the first legend set two or
|
||||
three times too hot.
|
||||
- **A reserved background colour for the overlay's blank.** Alpha already says it, every editor produces it,
|
||||
and a colour would be spent on nothing and lost the moment somebody exported with a white matte.
|
||||
- **Snapping an unmatched overlay pixel to its nearest mark.** That is right on a class template, where every
|
||||
pixel must become something, and wrong here, where most of the sheet is nothing: it turns a JPEG halo round
|
||||
a road into road. Drop it and count it.
|
||||
- **One mask image per overlay mark.** Marks cannot overlap - one painting, one colour a pixel - so an 8-bit
|
||||
index raster holds 254 of them in the space one boolean mask would take.
|
||||
- **Treating an unmarked cell as `coast_jitter` 1.** A mark lands on whichever side of the waterline the
|
||||
author's hand was on, so an uninstructed cell has to take its instruction from the far side or a stroke on
|
||||
the land is overruled by the water beside it.
|
||||
- **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
|
||||
|
||||
- **`map_flow.png` at planet scale is aliased, and it looks exactly like broken drainage.** It point-samples
|
||||
every fourth cell (3000 px on a 12500 grid), so a one-cell channel survives about a quarter of the time and
|
||||
the network reads as disconnected yellow stubs, while the divides - which are broad - come through whole.
|
||||
Bake_001 shows the identical pattern, so it is the diagnostic and not the terrain. Judge the network from
|
||||
the preview's drawn rivers, or crop the map at 1:1.
|
||||
- **Max elevation cannot see a change to the *distribution* of uplift.** D-55 dropped the lowland continent
|
||||
from 71 m to 41 m while leaving the rate its massifs reach untouched, because relief is the integral of
|
||||
slope along the whole flow path: a trunk crossing a plain climbs where the massifs are and nowhere else.
|
||||
The statistic to read is the slope distribution, which `internal/stats` still cannot produce at planet
|
||||
scale - it had to be taken off the heightmap by hand for that measurement.
|
||||
- **Rendering a diagnostic map smaller does not make `plan` quicker.** Measured: prepare is flat at about
|
||||
6.5 s from a 400 px map to a 2400 px one, because the cost is `region.Build` at **2.68 s** over the 76 M
|
||||
cell planet grid - classify 0.09, dissolve strokes 0.93, despeckle 1.52, the coast mask 1.62, project 0.05.
|
||||
Neither the size of the painting nor the size of the maps touches the big one. What does work is caching:
|
||||
the studio reuses the whole prepare when only the legend's *numbers* changed, 7.0 s to 0.37 s.
|
||||
- **`terrain plan`'s `divide` column is not the ground.** See §6. Read `typical`.
|
||||
- **`preview.png`'s tint is relative and says nothing about scale.** The ramp's top is a percentile of
|
||||
the world being drawn, so a 47 m lowland continent whose median slope is 0.61° comes out with the bare
|
||||
rock and white caps of an alpine massif. That is where "the lowlands are hilly" came from, twice. Every
|
||||
run prints the ceiling it used; read that line, or read `map_slope.png`, or set `palette.land_top_m`.
|
||||
- **A manifest key with a default is not a feature.** `coast_jitter_px` was declared, documented, defaulted
|
||||
and *never read by anything* from D-53 until D-56, and the only reason it was found is that somebody asked
|
||||
why the coastlines looked drawn. A grep for a key's own name is three seconds and it is worth doing before
|
||||
tuning one.
|
||||
- **A planet bake is not a rerun-while-you-judge loop.** It is about two hours at 100 km round. `terrain plan`
|
||||
is four seconds and settles the two decisions that can waste those two hours — how the legend read the
|
||||
painting, and how the planet was cut up. `--only <id> --steps 200` is the loop for tuning the legend's
|
||||
numbers; the elevation range is confirmed from that measurement, not guessed before it.
|
||||
- **The seed alone no longer names a painted world.** The ocean margin and the minimum landmass size decide
|
||||
how the planet is cut into regions, and the priority-flood's epsilon ladder across a flat depends on the box
|
||||
it is flooding. All three are in `Planet.json` and all three are recorded in `meta.json`; a change to any of
|
||||
them is a change to the world.
|
||||
- **A noise period that does not divide the circumference breaks every noise field at the seam.**
|
||||
`noise.Lattice.Sample` wraps modulo its cell count and `WorldUV` divides world metres by the period, so `u`
|
||||
returns to the same lattice point at `x = W` only when the circumference is a whole number of periods.
|
||||
`world.Planet.Validate` refuses anything else, and `TestNoiseBreaksWhenThePeriodDoesNotDivide` is the
|
||||
negative control that keeps the positive test honest.
|
||||
- **`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
|
||||
|
||||
+1168
-7
File diff suppressed because it is too large
Load Diff
+766
@@ -5,6 +5,772 @@ the reasoning lives in the specs, this is the memory.
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-09-20 - Phase 2 closed: seven layers, end to end. `ULandscapeAuthoringLibrary::CreateLayerInfo` built
|
||||
and the four layer infos created with the right `LayerName`. Verified per layer - layer info, parameter
|
||||
prefix and substance - plus `weightmap_entries` resolving **7 of 7**, which is the call a level build makes
|
||||
per tile.
|
||||
**The verification earned itself twice.** `LandscapeLayerInfoObject::LayerName` turned out to have a public
|
||||
`SetLayerName` (the member is deprecated and goes private next release), so the first build's two C4996
|
||||
warnings became a one-line fix rather than code that breaks on upgrade. And the check caught that **the rock
|
||||
swap had silently done nothing**: the pack prefixes its parameters by how it *displays* a layer, so
|
||||
Base_Layer's parameter is "Base Texture" and not "Base_Layer Texture" - and setting a parameter that does
|
||||
not exist is not an error, the instance just stores an override that drives nothing. The prefix is now read
|
||||
off each layer function rather than derived from the layer's name, and only three of them - the pack's - have
|
||||
to be written down at all, because `LayerBlendInput.layer_input` is exposed to MCP but not to Python.
|
||||
|
||||
- 2026-09-20 - Phase 2, most of it: the material blends seven layers now. `build_ground_material.py` extends
|
||||
the pack's material rather than replacing it - a new layer is a *copy of an existing layer function* with its
|
||||
six parameters renamed, which inherits the camera-distance colour blend that D-69a found load-bearing at
|
||||
71 km and that a freshly built material would have thrown away. Verified: the `LandscapeLayerBlend` carries
|
||||
**7 layers, all wired**, `MF_Ground_Beach` and friends carry uniquely prefixed parameters so they do not
|
||||
collide in the instance, and 20 texture parameters point the layers at the Fab substances.
|
||||
**Run through the editor's own console over MCP**, which is worth writing down: `SlateInspectorToolset` can
|
||||
type into the Output Log's command box, and `py <one-liner>` there runs the authoring script in the *running*
|
||||
editor - same code path as a commandlet, but no second process fighting over assets and no stale editor
|
||||
afterwards. `sys.argv` and `__file__` are set explicitly in the one-liner because `py` guarantees neither.
|
||||
**Two engine limits found the hard way, both silent.** `ObjectTools.set_properties` over MCP **does nothing
|
||||
on a Texture2D** - returns success, changes nothing, no error - which nearly passed review because the
|
||||
read-back looked right: a `_Normal` suffix makes UE set TC_Normalmap and sRGB off by itself, so only the
|
||||
Roughness maps exposed it. And `ULandscapeLayerInfoObject::LayerName` is **read-only**, so the obvious
|
||||
duplicate-and-rename produces a layer info that silently keeps the name it was copied from and paints that
|
||||
substance wherever the new layer should be. That one is now `ULandscapeAuthoringLibrary::CreateLayerInfo`,
|
||||
in the editor module for the same reason `CreateLandscapeFromHeightmap` is - and **it needs a build**, so
|
||||
the four layer infos are still owed and the script says so rather than pretending.
|
||||
|
||||
- 2026-09-20 - The Fab substances, sorted and extracted (D-76). Eight Quixel sets arrived dropped straight into
|
||||
`Content/Terrain/Textures/`: 805 MB of 4K JPGs, **no `.uasset` among them**, so the Content Browser could not
|
||||
see any of it. Moved to `RawContent/Terrain/Source/` and untracked - raw input belongs there, `Content/Terrain`
|
||||
is a product (D-69a), and they are re-downloadable. All eight were still untracked when found, so nothing had
|
||||
reached LFS yet; a commit first would have made this expensive.
|
||||
`mapart substances` now pulls three maps of nine at 2048 instead of 4096: **52 MB against 805**. The
|
||||
resolution argument is not about the screen - a UTexture2D stores its source inside the uasset, so 4K would
|
||||
have been hundreds of megabytes of LFS for ground seen at grazing distance. Normals are renormalised after
|
||||
the downsample and written as PNG; colour and roughness stay JPEG.
|
||||
**Reading the physical size from the metadata rather than typing it paid for itself immediately**: every set
|
||||
is a 2 m scan except Snow, which is **0.30 m**. One tiling number for every layer would have made the ice
|
||||
cap's grain nearly seven times too big, and nothing about that would have looked like a units bug.
|
||||
Seven layers now, one spare. `Jungle` was dropped rather than dressed with the mossy rocky ground, which is
|
||||
temperate where a tropical floor is leaf litter - the equator wears the remainder grass, generic but not
|
||||
wrong, and bringing it back is one manifest entry plus a substance. The rock is swapped for
|
||||
Layered_Rock_Cliff, which repaints every steep slope in the same pass. Re-verified after: seams **0
|
||||
differing vertices**, sums **255..256**.
|
||||
Not done, and it needs the editor: nothing is imported yet. `collect_terrain_assets.py` only duplicates
|
||||
assets already in `/Game` and has no import-from-disk path, so that is the next thing, along with the five
|
||||
new material layers and their layer infos.
|
||||
|
||||
- 2026-09-20 - Phase 1 of the dressing: the biome reaches the tiles (D-75). `Region.json`'s `layers` block now
|
||||
carries `biomes` and an ordered `paint` list - eight layers, five of them not enabled until there is a
|
||||
substance - and `mapart biomes` writes one blurred mask per biome that `generate_region_tiles.py` samples at
|
||||
the same global coordinates as the height.
|
||||
**Three things had to be measured rather than assumed, and two of them changed the design.** Orogen's class
|
||||
render is *not* a usable class source: it is the legend's colours double-encoded to sRGB, so exported
|
||||
`desert` sits 53 from legend `ice` and 60 from legend `desert` - nearest-colour matching against it is wrong,
|
||||
not approximate. The painting is exact, worst distance **0.0** over 29 M pixels, so it is the source.
|
||||
Tropical came out of `koppen.js`, which turned out to hold a real classification: the same sRGB encode
|
||||
reproduces every observed colour to **1.4/255** with **0.423%** of pixels unmatched, which is the
|
||||
anti-aliased ring at class boundaries and nothing more - so jungle is Af+Am+Aw, 8.11% of the planet, rather
|
||||
than the latitude band I was going to fall back to. And the painting registers to the heightmap by
|
||||
**identity** in u,v despite 7738x3761 against 8192x4096: 98.09% land/sea agreement against 96.14% for the
|
||||
scaled alternative, winning in every latitude band including the polar ones.
|
||||
The blur is global rather than per tile, which is what keeps the seams: per-tile would need a 200-vertex
|
||||
margin at 400 m and 2 m quads, a third more area on all ninety-eight. Measured after: **0 differing
|
||||
vertices** across all nine files on a shared column, weights **255..256 and never under**, and the unchanged
|
||||
three-layer path reproduces the built world with height, rock and high rock **bit-identical** and meadow
|
||||
differing in **6 of 6 507 601** vertices by one - the deliberate change that lets the remainder absorb the
|
||||
rounding residual, which took the sum from 253..257 to 255..256.
|
||||
The classification is Go for the same reason Tools/MapArt is Go at all. Left for phase 2: the Megascans
|
||||
substances, `rocky_meadows.LAYER_INFOS` (which now refuses a layer it has no info for rather than dropping
|
||||
it silently), and the beach rule, which wants tuning against a real coast once there is sand to see it with.
|
||||
|
||||
- 2026-09-20 - `Docs/World-Dressing.md`: the plan for what the ground wears (D-74). Asked for as "materials
|
||||
works for the deserts, coasts, tropical, maybe even crater. Also skybox, ligthing and a selection of trees".
|
||||
**No classification had to be invented**, which is the finding that shaped the whole plan: `Map5.legend.json`
|
||||
is already `ocean, deep, ice, lowland, highland, desert, crater` with an RGB each, and that map exists at
|
||||
planet resolution in `Plan/`, in every bake and in `Orogen Gens/`, registered to the world by the same `u,v`
|
||||
as the heightmap. Tropical is the only one on the list that is not painted - it comes from the climate export
|
||||
- and `forest` is already a generated overlay mark. So this is a carrying-and-authoring job, not a design one.
|
||||
**Eight layers is the budget and the budget is the design**: Unreal packs four paint layers per weightmap
|
||||
texture per component and there are 9800 components, so the fourth is free and the fifth doubles the
|
||||
weightmap memory. Rock, grass, high rock, sand, beach, jungle floor, ice, regolith; anything past that
|
||||
displaces something. Trees are `LandscapeGrassOutput` rather than foliage actors (the only thing that scales
|
||||
to 2549 km², at the cost of no collision), and one planet gets one sky.
|
||||
Decided: dress **Route A now** rather than wiring Route C first - nothing authored for it is wasted, because
|
||||
Route C changes the layer rules' *inputs* and not the material, sky, grass or trees - and **Megascans** for
|
||||
the substances and trees. Phase 1, the biome carry into the tiles, needs no assets and is the next thing to
|
||||
build; Phase 2 onward waits on the Fab assets being added to the project.
|
||||
|
||||
- 2026-09-20 - `Docs/World-Pipeline.md`: the world pipeline written down in order. Asked for as "a docs file
|
||||
about how this all supposed to work, what order ... so terrain tool, orogen, what do we have to do to get it
|
||||
into unreal". The knowledge existed and was spread over CLAUDE.md, `Terrain.md`, `Terrain-Next.md` and two
|
||||
READMEs, none of which says what happens first. It owns no decisions - every one is linked to where it was
|
||||
argued - and the thing it makes visible is that **there are three routes into Unreal and only one is live**:
|
||||
Orogen's whole-planet PNG through the Python cutter (what `L_World` is), Orogen's direct tile export
|
||||
(built, better, unused), and `terrain bake` -> `terrain tiles` (the point of the generator, not wired).
|
||||
Writing it turned up **two stale claims in `RawContent/World/README.md`**: its "what it is today" table still
|
||||
described the 6 x 6, 30.60 km, 22.583 m/px window, where `Region.json` has been 14 x 7 over the whole
|
||||
8192 x 4096 export at 8.7158 m/px since D-72 - 71.40 x 35.70 km, 9800 components. Corrected. And the second
|
||||
correction is not cosmetic: the "read flat" paragraph's 9.6% east-west stretch was true of a 1355 px window
|
||||
at latitude -24, and reading the **entire** cylinder flat stretches by 1/cos(latitude) at every row, which is
|
||||
unbounded at the poles. `L_World`'s polar strips are smeared and always have been; the README now says so.
|
||||
|
||||
- 2026-09-20 - A world map, in the game and in the editor, from one widget (D-73). Asked for as "can we make a
|
||||
map view or something we can use either ingame or as its own window in editor, based of either the generated
|
||||
world in unreal, or the python ones, or then we have also orogens map". **The three sources are one.**
|
||||
`Region.json`'s window is the whole 8192 x 4096 export with no crop, so `L_World` *is* the planet map and
|
||||
world-to-map is `u = (X/100 + 35700) / 71400`, one multiply and one add. No scene capture, no render target,
|
||||
no minimap actor - and a capture would have been wrong as well as expensive, because a world-partitioned
|
||||
level only ever has the streamed-in region loaded and a map is the thing that must show ground nobody is
|
||||
standing on. The numpy pipeline's `L_Canvas_Proto` shares nothing with this and was not targeted.
|
||||
**Checked that the colormap is the same planet**: `orogen-colormap-14733759` against
|
||||
`orogen-heightmap-7945` reads as two worlds because Orogen numbers each export rather than each planet, and
|
||||
it is one - 97.5 % land/sea agreement over a 16 px grid, against satellite's 96.8 % and climate's 87.7 %
|
||||
(ice scores lower without being wrong). `mapart check` is that test kept, and it is agreement rather than a
|
||||
hash because every layer is a different render of one planet.
|
||||
**One `SWorldMap`, three ways in.** Slate rather than a `UUserWidget` because the editor tab has no `UWorld`
|
||||
at all; `UWorldMapWidget` wraps it for UMG, the tab hosts it directly, `bs.WorldMap` puts it over the
|
||||
viewport with no content asset needed. Wrapping, pan, zoom about the cursor, a scale bar and a cursor
|
||||
readout are in the widget, so all three get them.
|
||||
**`Tools/MapArt` renders the art**, in Go, because the engine's Python cannot decode 33 megapixels of RGB -
|
||||
`heightmap_io.py` is greyscale-only with a per-byte unfilter loop. Four layers in 6.0 s: relief 2.1 s,
|
||||
colour 1.4, satellite 1.3, climate 1.0. `relief` is derived rather than exported - hillshade plus a
|
||||
hypsometric tint off the heightmap the landscape was cut from - and is the default, because a colormap shows
|
||||
biome where a player needs landform. Land tops at 3810 m, deepest -850 m, 4096 x 2048 at 17.43 m a pixel.
|
||||
Two real bugs `go vet` caught in the manifest struct: `X, Y int \`json:"x"\`` gives *both* fields the tag,
|
||||
so `window.y` and `window.height` would never have been read.
|
||||
**Built, tested and imported.** `SaltyEditor` compiles clean across all three modules; 10 of 10
|
||||
`Salty.Core.*` tests pass; `build_world_map.sh` writes four 4096x2048 textures and
|
||||
`DA_WorldMap_L_World` into `Content/World/Maps/` and reads them back.
|
||||
Three things cost time and are worth knowing.
|
||||
**`EditDefaultsOnly` cannot be written from Python**, because it means edit-on-default-only and a
|
||||
`UDataAsset` is an *instance* - so every property on the definition is `EditAnywhere`, which is right for an
|
||||
asset a script authors: the flag guards a placed actor's copy of something and there is no such copy here.
|
||||
Direct `setattr` is not a way round it; it routes to the same checked setter, so the fallback that looked
|
||||
like a fix was deleted rather than left in place implying a path that does not exist.
|
||||
**An `FSoftObjectPath` reprs as `{}` in Python whether it is set or not**, because its fields are not
|
||||
`UPROPERTY`s. A first verification pass reported `level` empty and it was never empty - `export_text()` says
|
||||
`/Game/Maps/L_World.L_World` and the asset registry lists `/Game/Maps/L_World` as a dependency of the
|
||||
definition. Comparing a Python enum with `str()` lied the same way: `str(TA_WRAP)` is
|
||||
`"<TextureAddress.TA_WRAP: 0>"`, not the name.
|
||||
Both traps are now inside `create_world_map.py`: it reads every property back and **refuses to save** if one
|
||||
did not take, because a property set that silently does nothing looks exactly like one that worked, and a
|
||||
save is not evidence that the value is there. My first test also failed for its own bad arithmetic -
|
||||
`-3569900 cm` is 1 m from the map edge, not 100 - so it asserted 200 m where the answer is 2.
|
||||
|
||||
- 2026-09-20 - One world level: `L_World` is the region now (D-72). Called as "we shouldn't have a secondary
|
||||
level anyway, it all should be working on L_World including the world we built".
|
||||
`Region.json`'s `level` is `/Game/Maps/L_World`; `World.json`'s is `/Game/Maps/L_Canvas_Proto`. Nothing
|
||||
else was entangled - `L_World` was named only by `World.json` and `world_manifest.py`'s default, no
|
||||
gameplay code refers to either level, and both default maps are `L_Gym` - so this was two keys and a
|
||||
rebuild. Rebuilt rather than renamed: a world-partitioned level owns 475 external actor packages plus two
|
||||
HLOD assets, the editor fixes those up on a Content Browser rename and headless is not a path walked here,
|
||||
and a half-fixed rename is worse than an hour of unattended batches.
|
||||
**Three things that had to move with the name.** The tile PNGs are named after the level (`tile_name` is
|
||||
its last segment plus the coordinates), so changing `level` made all 98 tile sets look missing and would
|
||||
have regenerated 208 MB that already existed - the 392 files were renamed instead. `build_region.sh` reads
|
||||
the level out of the manifest now, the way it already read the grid, because a name written into the driver
|
||||
goes stale the moment the manifest changes and the lock probe would then guard the wrong file. And the
|
||||
numpy pipeline's own PNGs became `L_Canvas_Proto_*.png`, because `L_World_Height.png` in `Heightmaps/`
|
||||
beside `L_World_x0_y0_Height.png` in `RegionTiles/` is two files differing by a tile suffix.
|
||||
The old canvas is repointed, not deleted, and the different name is the point: `create_world.py` empties
|
||||
whatever level it is handed, so a manifest still saying `L_World` would replace 98 landscapes with a 14 km
|
||||
square on one run. Kept because it is still the only path carrying the erosion pass's flow, wear and
|
||||
deposit maps into Unreal. Verified on the rebuild: `L_World` emptied to 9 actors and the sweep removed
|
||||
**exactly 256** stale packages, the old landscape's 16 x 16 proxies, and nothing else.
|
||||
|
||||
- 2026-09-20 - The sea wears a placeholder grey. Reported as "the sea material is abit bugged right now, make
|
||||
it a testing grey". `/Game/World/M_Sea_Proto` - opaque, default-lit, base colour 0.18 - replaces the
|
||||
engine's single-layer water on `World_Sea_Proto`. The water material is a lake shader stretched over a whole
|
||||
planet and reads at every scale as something it is not; `rocky_meadows.SEA_GREY` is the switch back and the
|
||||
water path is unchanged behind it. Authored on demand rather than picked out of `/Engine`, because nothing
|
||||
there is the right value: `BasicShapeMaterial` is the near-white that once read as an ice sheet to the
|
||||
horizon (2026-09-16) and `WorldGridMaterial` puts a metre grid on a plane 70 km across.
|
||||
The part worth keeping: **`ensure_dressing` spawns the sea only when the level has none**, which is the
|
||||
right rule - a rerun must not leave two suns - and it means a material change cannot reach a world that
|
||||
already exists. `ensure_sea` now re-applies the material on every run, and `fix_sea_material.py` repaints a
|
||||
finished level and saves it, so changing one material reference does not cost a rebuild of 98 landscapes.
|
||||
Applied to `L_Region` and read back out of the asset to confirm the value landed rather than defaulting.
|
||||
|
||||
- 2026-09-20 - Off the ladder: World Orogen exports Unreal landscape tiles directly (D-71), and a locked
|
||||
level is refused before it is emptied (D-71a).
|
||||
**The export.** Asked for as "can you make it so we can export from orogen as intended … its the 2m/px
|
||||
type exports, whatever unreal needs". Orogen's two exports are a picture and one flat 8192 x 4096 PNG with
|
||||
no scale on it, which is why D-69's `metres_per_pixel` had to be invented. The new **Unreal Landscape**
|
||||
panel (in the export card on both pages) renders a window of the planet straight into the tile set the
|
||||
importer wants: a 16-bit height at 255*N+1 vertices and three 8-bit weightmaps per tile, plus the
|
||||
`Region.json` that describes them, written into a folder through the File System Access API - so the
|
||||
files land in `RawContent/World/RegionTiles/` and `create_region_world.py` reads them unchanged.
|
||||
The window is sampled **once** into a float raster and every tile is cut out of it by global vertex
|
||||
position, which is what makes the seams exact: measured 0 of 1021 vertices differing on both the
|
||||
east-west and the north-south seam, heights and paint. The paint only closes because tiles carry a
|
||||
one-vertex margin for the slope's central difference - the same defect D-69 hit in numpy, hit again here.
|
||||
Heights ride `heightmapColor`'s -5..6 km ramp but are read out of the float target rather than quantised,
|
||||
so precision is a millimetre against the 16-bit PNG's 17 cm.
|
||||
**What the panel is for is the number you have not typed yet.** It re-plans on the keystroke and prints
|
||||
ground, components, file count, sample spacing and the flat reading's cost. It earned that immediately:
|
||||
D-69's own numbers - 936 km2 on a 100 km planet - are a window **110 degrees on a side, stretched 74.7 %
|
||||
at its edge**, because a 100 km circumference is a 3183 km2 world and 936 km2 is 29 % of it. The
|
||||
projection is cosine-corrected at the centre latitude so the error splits between the two edges instead
|
||||
of landing on one, and the defaults are 4 x 2 tiles: 208 km2 at 5.4 %, which a sphere this small can hold.
|
||||
**Not `<input type="number">`** - it parses in the browser's locale, so on a comma-decimal machine 0.17
|
||||
shows as "0,17" and `.value` returns empty, making the setting NaN and the export a tile set of nothing.
|
||||
Caught in the first screenshot. An existing `Region.json` is **never replaced** - most of that file is the
|
||||
reasoning behind its numbers, and D-56 already settled that a generated save must not eat an author's
|
||||
commentary - so a second one lands as `Region.generated.json` and the status line says so; the tiles are
|
||||
data and are overwritten. Verified in a real browser over the DevTools protocol, 28 checks including the
|
||||
encoder's own scanlines inflated back out and the button wiring itself up on both pages with no console
|
||||
errors. This does **not** retire `generate_region_tiles.py`, which is the path that will read
|
||||
`terrain tiles`.
|
||||
**The locked level.** Reported as "currently the map IS BUGGED OUT somehow, super glitchy, nothing left".
|
||||
Nothing was corrupt and nothing was lost. A full rebuild had been run with the editor holding `L_Region`
|
||||
open: batch 0 emptied the level, batches 0-3 built twelve tiles, and batch 4's save died on
|
||||
`MoveFile … (Error Code 32)`, a sharing violation. The run aborted loudly and correctly - but the wipe
|
||||
had already happened, and the twelve survivors were all of row y=0, the northern polar strip, which on
|
||||
this planet is nearly all ocean. The level opened on 71 km of sea. A failure detected only *after* the
|
||||
destructive step is indistinguishable from data loss, so both the script and the driver now probe the
|
||||
`.umap` with `open(path, 'r+b')` before anything is emptied. Refilled with `--append`; the twelve good
|
||||
tiles were kept.
|
||||
|
||||
- 2026-09-20 - L_Region: the spawn was 180 m underground and the fog was the pack's 8 km fog on a 30.6 km
|
||||
world. Reported as "when spawning in the world i am under the landscape" and "i cant see the world in the
|
||||
editor, its just some random fog on the ground".
|
||||
**The spawn.** `ensure_player_starts` traced straight down for the ground. In a commandlet the landscape's
|
||||
collision is not reliably present but the sea plane at Z 0 **is** - it keeps collision so a walk off the
|
||||
coast is a walk - so the trace hit the sea and returned `0.0`, which is not `None` and so went straight past
|
||||
the guard written to catch exactly this case. The starts were saved at **1.20 m with the ground at 181.83 m**.
|
||||
The pad height is read out of the heightmap now: the window's centre vertex is the [0, 0] pixel of the middle
|
||||
tile, because half of six tiles is a whole number of them. Existing starts are moved rather than skipped, so
|
||||
a rerun repairs a level instead of leaving it wrong. No trace anywhere in the path.
|
||||
**The fog.** `PACK_FOG` is Rocky Meadows' demo tuning for an 8 km map; density is per unit distance, so on
|
||||
30.6 km it is opaque - a capture looking straight down from 25 km was pure white. `rocky_meadows.scale_fog`
|
||||
divides the density by how much bigger the world is than the 8 km demo (0.027143 -> **0.0071** here, 0.0152
|
||||
for L_World) and sets the height falloff to the engine's 0.2 so fog thins with altitude instead of reaching
|
||||
the top of the sky. PACK_FOG is left as read; the scaling returns a copy.
|
||||
**What is still not visible, and it is not the fog.** Measured: `trace_world` straight down at the origin
|
||||
hits at **Z = 0**, the sea plane. The 36 `Landscape` actors are always-loaded and carry **zero components**;
|
||||
`ChangeGridSize` moved every component onto **144 LandscapeStreamingProxy** actors, which are spatially
|
||||
loaded. World Partition knows all 190 actor descriptors and reports the right world bounds (+/-16384 m), so
|
||||
nothing is lost - the proxies are simply never loaded, in the editor or (apparently) in PIE at the spawn.
|
||||
Everything in the diagnostic captures was the **sea plane** lit through the cloud-shadow light function,
|
||||
not terrain. **L_World has the same symptom** (open item 2), and it also splits into proxies, so this is the
|
||||
project's landscape path rather than anything about this world. Measured on one tile: grid size 0 leaves
|
||||
**100 components on the Landscape actor**, grid size 5 leaves **0**. Unresolved; the choice is to load a
|
||||
region in the World Partition window, to build HLODs so unloaded ground still draws, or to stop splitting.
|
||||
|
||||
- 2026-09-20 - Off the ladder: **Generate marks** is a button on the studio's overlay tab (D-70).
|
||||
`internal/studio/overlaygen.go` + a handler in `page.html`. Every press is a fresh seed, so it is a
|
||||
re-roll; the server remembers what the last generation put down and clears exactly that first, so drafts
|
||||
replace each other instead of silting up and hand-painted work is never touched. It runs **before the first
|
||||
bake** on the painting alone (flat height, nil drainage) and says which path ran, because a sketch that
|
||||
knows nothing about rivers or slope must not be read as one that does. Two defects found by pressing it:
|
||||
settlement placement had no seed dependence at all, so two presses gave byte-identical drafts - fixed with
|
||||
a seeded score jitter, since the forest count is a quantile and therefore invariant by design; and Map3's
|
||||
bake was silently accepted for Map5, because `CheckBake` compares only how a heightmap is *encoded* and two
|
||||
paintings of one planet agree on all of it - `BakeIsOfThisPainting` now compares the recorded template and
|
||||
falls back with the reason printed. The sheet is replaced rather than stroked, so undo history is dropped
|
||||
and the status line says so. Verified headless over the DevTools protocol: hidden on the class sheet, shown
|
||||
on the overlay, two presses differ, wrong bake named and ignored, zero page errors. Tests:
|
||||
`TestASecondSeedMovesTheSettlements`.
|
||||
- 2026-09-20 - Off the ladder: 900 km2 of ground in Unreal, cut from the Orogen planet export and tiled
|
||||
(D-69). `RawContent/World/Region.json` + `Scripts/Authoring/region_manifest.py`,
|
||||
`generate_region_tiles.py`, `create_region_world.py`, `build_region.sh`; the Rocky Meadows kit moved out of
|
||||
`create_world.py` into `rocky_meadows.py` so both worlds are dressed by one set of numbers.
|
||||
6x6 landscapes of 2551 vertices at 200 cm, 10x10 components of 255 quads each: **30.60 km a side, 936 km2
|
||||
of map holding 905 km2 of land**, -510 to 2972 m, nothing clipped. Tiles in 134 s and 208 MB (untracked).
|
||||
**The planet's scale had to be invented.** The export carries none, and at `Planet.json`'s 100 km
|
||||
circumference the planet is 31.8 km across, so a flat 30 km square does not fit on it. Searched the land
|
||||
mask over scale and position: 22.583 m a pixel (a 185 km circumference) is the finest reading whose best
|
||||
window still clears 900 km2 of land. Read flat rather than unprojected, which at latitude -24 stretches the
|
||||
ground 9.6% east-west; cos-correcting the crop would stretch its own edges by +/-35% across the latitudes
|
||||
it spans. Heights are Orogen's normalised metres, not the generator's - `terrain bake` makes this same
|
||||
continent a 116 m plain - so the relief is art; `sea_scale` 0.17 is the only correction, and only to the sea.
|
||||
**Seams are exact by construction**: every vertex is sampled from its global position in the window, so a
|
||||
shared column is computed twice from the same coordinates and comes out bit-identical, heights and all three
|
||||
weightmaps. That did not hold for slope until the tiles were sampled with a one-vertex margin - `np.gradient`
|
||||
takes a one-sided difference at an array edge, and every tile boundary was a one-vertex line of different
|
||||
paint. Resampling is Catmull-Rom clamped to its two central taps, because an 11.3x upsample of a source whose
|
||||
steps are its coastlines rings otherwise.
|
||||
Three walls hit, all in the engine rather than the data:
|
||||
1. *Memory.* A landscape of a hundred components costs about a gigabyte the editor never gives back, so one
|
||||
process asked for all thirty-six reached 14.7 GB by the ninth tile with 0.3 GB of commit left on a 31.9 GB
|
||||
machine. Killed twice. The builder is incremental now - it adds the tiles it is told to, saves and exits -
|
||||
and `build_region.sh` runs it in batches of three. Six peaked at 13.6 GB and left 1.2 GB of commit, which
|
||||
is why the default is three.
|
||||
2. *Arguments never reached the script.* `UPythonScriptCommandlet::Main` reads `-Script=` as one quoted
|
||||
string and hands the whole thing to the Python plugin, which splits it into a filename and arguments;
|
||||
anything after it on the command line is the engine's. Passed the wrong way the script saw no arguments
|
||||
and silently built all thirty-six - the exact run the batching exists to prevent.
|
||||
3. *A commandlet with the editor open and a relative project path writes nothing at all* and exits zero,
|
||||
which reads as success. Absolute paths and `-abslog` throughout.
|
||||
`UnrealEditor-Cmd` also exits non-zero whenever anything logged an Error, and this project logs three on
|
||||
every start (no GameFeatureData asset rule; the open editor already holds MCP's port 8000), so the driver
|
||||
gates on the script's own "saved" line instead of the exit code.
|
||||
Fixed on the way past: the sea plane's material is `/Engine/EngineMaterials/WaterMaterial.WaterMaterial`,
|
||||
the full object path - the package path alone resolves in the editor but not under `-run=pythonscript`, so
|
||||
`L_World`'s sea has been a grey shape material rather than water. Not yet re-verified by a rebuild.
|
||||
- 2026-09-20 - Off the ladder: `Content/Terrain/` collects the ground out of the asset packs (D-69).
|
||||
`RawContent/Terrain/ground.json` names fourteen assets - six textures, three layer functions, the master
|
||||
material, its instance and three layer infos - and `Scripts/Authoring/collect_terrain_assets.py` copies them
|
||||
in, idempotently. Copies rather than moves, so a pack stays as it shipped. A copy is not ownership: a
|
||||
duplicated material function still samples the pack's textures, because the reference is inside the graph,
|
||||
so every copy is walked and repointed at its siblings and the asset registry is then asked what still points
|
||||
outside `Content/Terrain` - that report is the answer to whether a pack can be deleted. Only Rocky Meadows
|
||||
has real ground today; HouseForge's stone is architecture and its grass is a foliage card.
|
||||
Getting there took four corrections, each found by the report rather than reasoned: `Expressions` is
|
||||
protected on UMaterial and absent on UMaterialFunction, so it has to come from
|
||||
`MaterialEditingLibrary`; a function needs `get_material_function_expressions`, since
|
||||
`get_material_expressions` refuses one outright, which is what left all three layer functions still
|
||||
sampling the pack; the registry must be rescanned before the report, or the new assets come back with
|
||||
no dependencies and **an empty answer was being read as clean** - the first run claimed the folder
|
||||
stood on its own while the master was still calling the pack's functions; and a material caches the
|
||||
textures it and its functions reference, so every copy is recompiled and force-saved at the end or the
|
||||
stale cache outlives the repointing. Final state: 21 graph references and 13 instance references
|
||||
repointed, **one left** - `MI_Ground_RockyMeadows` still names the pack's `T_Rock_Shade_Variation`,
|
||||
which is not one of its parent's parameters and is not in its override array, so neither pass reaches
|
||||
it. Everything renders from `Content/Terrain`; the pack cannot be deleted until that one is cleared.
|
||||
|
||||
- 2026-09-20 - Off the ladder: `terrain overlay` proposes an annotation layer from a bake (D-68).
|
||||
`internal/overlay/generate.go` + `generate_roads.go` + `internal/planet/overlaygen.go`, and a `generate`
|
||||
block per mark in the overlay legend. Four kinds: forest (noise-broken, treeline from a quantile of the
|
||||
land's own heights), settlement (scored on drainage, flat ground and distance to the sea, one spacing rule
|
||||
across tiers), road (minimum spanning tree on least-cost paths, water impassable so each landmass has its
|
||||
own network), coast (supported, deliberately not shipped enabled - `coast_jitter` changes the next bake).
|
||||
**Generation fills blanks and never touches a painted pixel**, so the round trip runs both ways.
|
||||
Three defects found by running it: the no-overwrite rule blocked every kind after the first (a coast band
|
||||
took 21 % of the world and the towns inside it painted nothing); settlements were scored onto painted
|
||||
ground where they could not be stamped, so a city and six villages were placed, reported and then dropped
|
||||
by the feature pass; and forest grew on both ice caps until `not_classes` existed. Measured on Map3 against
|
||||
Bake_022: 17 s, 3.88 M painted px kept, 2.48 M added, and `terrain plan` reads it back with 0 unmatched
|
||||
pixels and counts matching the run exactly. Tests: `internal/overlay/generate_test.go` - painted pixels
|
||||
survive, marks without a block are never generated, nothing lands at sea, spacing holds across tiers, roads
|
||||
never cross water, two runs are identical, and a generated sheet classifies back to itself.
|
||||
- 2026-09-20 - Off the ladder: World Orogen reads the planet's own files (D-67). Three ports from
|
||||
`Tools/Terrain`, each one a question an author has while typing a number. (1) `js/painted-report.js` is
|
||||
`plan.go`'s angle functions exactly - the class table shows the **typical** median hillslope and what it
|
||||
reads as, with the divide angle and P90 in the tooltip and a "clamped" flag past the angle of repose;
|
||||
verified against `RawContent/World/Plan/plan.json` on Map3, five land classes and the clamp ceiling all to
|
||||
1e-9. (2) `js/painted-overlay.js` + `painted-overlay-view.js` carry the annotation layer: same classifier
|
||||
rule (alpha is blank, no-match dropped and counted), drawn as a **texture** on the globe's triangles and on
|
||||
a map quad rather than voted onto regions, with an Overlay Sheet toggle, an Overlay inspect layer and
|
||||
export type, and the mark named in the hover. Marks are voted only for `coast_jitter`, which pins or
|
||||
roughens the shore. (3) `Planet.json` is read directly and outranks a legend's `planet` block, bringing the
|
||||
pipeline constants the angles need. Plus `Tools/Terrain/internal/studio/share.go`: the studio serves the
|
||||
painting, both legends and the manifest with a CORS header **on GET and HEAD only** and no preflight, so
|
||||
"Load from studio" brings the whole planet in 4.0 s and no page can ever start a bake. Measured headless:
|
||||
overlay painted px identical to `plan.json` (3,875,832, 0 far), 200 steps in 7.3 s, marks on 45,745
|
||||
regions, zero page errors; mobile at 390 px has no horizontal scroll and 44 px targets. Tests:
|
||||
`internal/studio/share_test.go` (CORS is read-only on five methods; the served file is the text on disk).
|
||||
Not carried, same reason as D-66: faults, craters, the coast pass, the detail passes, the slope histograms.
|
||||
- 2026-09-19 - Off the ladder: World Orogen (`Tools/Orogen/`, GPL, plain ES modules) has a **Painted Map**
|
||||
source on its import page that reads the same painting and `*.legend.json` as `terrain plan` and solves
|
||||
the uplift into terrain on its sphere mesh (D-66): `js/painted.js` (legend, nearest-colour classify with
|
||||
a 6-bit colour cache, majority vote per region, stroke dissolve / pole strokes to `edge_class`, coast
|
||||
roughening on the signed hop distance, massif and rock fields cut at planet quantiles, coastal-plain ramp,
|
||||
swell, Braun-Willett implicit solve with a per-step priority flood, relief scaled to a Peak Height
|
||||
slider), `js/painted-layers.js` (class, uplift, erodibility, drainage, slope, basin colours for globe,
|
||||
map and export), worker handler, six inspect layers and export types, an editable legend table with
|
||||
Download, a `planet` block in the legend for the Planet.json numbers, `assets/painted-legend.json` and a
|
||||
quarter-size `assets/painted-demo.png` made from Map3. Verified headless (Chrome over the DevTools
|
||||
protocol from node): Map3 at 204K regions classifies in 0.45 s, solves 200 steps in 12.3 s, 25 s with
|
||||
climate, no page errors; dendritic networks with trunk rivers on every landmass, massifs standing out of
|
||||
the lowlands, 16-bit heightmap and the six painted PNGs export. Not carried: faults, craters, overlay,
|
||||
plates, repose clamp, detail passes (44 km cells). Uncommitted, with the rest of the working tree.
|
||||
- 2026-09-19 - Off the ladder: the laser-carved flanks were D8's, and drainage area is multiple-flow now
|
||||
(D-65). Reported as "streaks going down the side of the mountains making them look like laser carved",
|
||||
against the x4 bake. `Terrain-Next.md` 4.B3 blamed the ridged-fBm initial relief; it was wrong, and the
|
||||
test that ruled out grid locking - "the ribs are oblique, not axis-aligned" - was not a test, because D8's
|
||||
parallel-flow grooves run in whatever direction the slope faces. The grooves are channels: present in
|
||||
`map_flow.png`, absent from `map_uplift.png`, and the network is pinnate rather than dendritic - 25 sources
|
||||
and no confluences in a 12.8 km window. The pitch is 18 cells in both `Bake_x4` at 32 m and `Bake_020` at
|
||||
8 m, which no mechanism fixed in metres can produce. Isolated with no erosion at all on a planar ramp:
|
||||
D8 gives the most-drained cell in a contour band 769x the median and leaves 29.5 % of the grid draining
|
||||
nothing, where the true answer on a plane is 1 and 0. Freeman MFD for `Accumulate` only, D8 receivers kept
|
||||
for the implicit update, Kahn order rather than an elevation sort: 1.34 and 0.4 %. 101 ns a cell against
|
||||
D8's 17. Three repairs alongside - the repose clamp jitters its pop order and allowance, `DiffuseNonlinear`
|
||||
goes to the isotropic nine-point stencil because the clamp cuts across eight faces and a five-point
|
||||
smoother cannot transport across a diagonal one, and `field.SmoothEdgePreserving` is ported from the
|
||||
World Orogen browser generator, off by default. Tests: `flow_test.go`, `smooth_test.go`, the clamp isotropy
|
||||
and bucket-order pair, and a benchmark - which is what caught the first version of the MFD precondition
|
||||
returning a silently wrong area on a second call (D-65a).
|
||||
|
||||
- 2026-09-19 - Off the ladder: the ocean was thirty metres deep (D-64). Reported as "it is just a
|
||||
landmass and no oceans really", against the exported heightmap. `Bake_020` measured: the legend paints
|
||||
`ocean` and `deep` at 512 m over 55.9 % of the planet and 17 % of it gets there, while 40 % of the world
|
||||
is water between 0 and 30 m - one 26.8 % spike at -20 m. At -1024..2048 m that is 1 % of the 16-bit ramp
|
||||
from sea level, so shelf and land encode to the same grey and the shelf halos fuse the continents.
|
||||
**One number, taken from the other canvas.** `coast.Build` wants a `BreakM`, the depth at the shelf
|
||||
break, and both call sites read `-pipeline.continent.sea_floor_m.hi()` = 30 m - a square-canvas default
|
||||
whose own comment says it is not a shelf break, since on a 14.28 km canvas a real one does not fit.
|
||||
`AbyssM` has been per-cell from the painting since D-60; the break never was.
|
||||
**What hid it:** the derived margin reaches `shelf_km.hi() + slope_km` = 4.6 km from every shore, and
|
||||
1069 km of shoreline against a 3111 km2 sea is 4917 km2 of margin over a smaller ocean, so the painting is
|
||||
never consulted in any strait. The profile is monotone and correct at any break depth, which is why the
|
||||
profile tests all passed and this had to be found in a histogram.
|
||||
`pipeline.coast.break_m` is a key; `ShelfBreakM()` falls back to the old reading so the square canvas is
|
||||
unchanged (verified, still 30 m), and a planet defaults to 130 m - the depth the template's own `shelf`
|
||||
class is painted at. The 4.6 km margin is unchanged: 512 m over it is a 6.3 deg slope, which is right.
|
||||
Two more of the same read in `tiles.go`, both commented "the shelf break": `restoreSeaFloor` would have
|
||||
staircased the new shelf and the tile hillshade would have flattened it.
|
||||
Also: a bake prints its sea floor's three numbers, and how much of the 16-bit ramp the world used -
|
||||
28 %, land 7 %. Too *wide* a range clips nothing, so `clip_fraction` never saw it. `Planet.json` untouched;
|
||||
~-576..320 would be 3.4x the contrast, and that is the author's call.
|
||||
Measured at unit scale, 9.6 km of sea painted at 512 m: 33 % -> 8 % of it left shallower than 50 m, and a
|
||||
sea painted at 20 m still 20 m deep. **The whole-planet re-bake was killed by memory pressure (the coastal
|
||||
pass peaks near 8.3 GB) before it wrote anything - its numbers are still owed.**
|
||||
|
||||
- 2026-09-19 - Off the ladder: a fault set saturates, and the initial relief stops reading the faults
|
||||
(D-63). Reported as "the mountains seem to be streaking horizontally like someone just cut the mountains
|
||||
apart with a knife", in `Bake_018`, bottom right. Both mechanisms are D-62's, and D-62 missed them
|
||||
because it was verified on region 8, which two traces reach.
|
||||
**Faults stacked.** `FaultDelta` accumulates with `+=`. That was harmless at a 600 m reach because two
|
||||
faults almost never met; at 6 km they meet constantly, and a set is sub-parallel *by construction* -
|
||||
traces within one cell of the orientation grain share a strike, a belt fault takes its from the margin -
|
||||
so where they meet they all push the same way. Region 11 is 22 km across with 13 traces at strikes
|
||||
spanning 14 degrees. Measured on a 200 m grid: **75 % of the faulted ground had two or more faults on
|
||||
it**, the sum a median **1.77x** the largest single contribution and up to 4.46x, 13 % of it over the
|
||||
repose ceiling on its own - so the hard clamp fired on **160 289 cells, 4.1 % of the region**, against
|
||||
0.15 % planet-wide before, and a hard clamp makes plateaus.
|
||||
Now `softStack`: a soft knee per cell keyed to the **largest single contribution at that cell**. A
|
||||
planet-wide throw would not bite - the biggest throw on this template is 744 m and the biggest single
|
||||
contribution in the region is 209 m once taper and falloff have had it. Identity below the knee, so a
|
||||
lone fault is untouched and D-62's "the step across a fault is its throw" still holds; above it the
|
||||
excess bends onto 1.6x the knee. Continuous (a max of continuous functions, gradient 1 either side of
|
||||
the join) and frame-independent, which is what keeps `TestTwoFramesAgreeAboutTheSameFaults` true - and
|
||||
it runs unconditionally, because skipping it when one trace reached a frame would make a cell's value
|
||||
depend on which frame asked.
|
||||
**And the initial relief was reading the finished rate.** `painted.go` scaled the symmetry-breaking
|
||||
noise by `rate/maxClassRate` with the fault delta in it and no bound, so D-62 took the stamped amplitude
|
||||
from ~39 m on unfaulted foreland to ~166 m on a 6 km footwall, on a landmass whose whole relief was
|
||||
221 m. A thousand steps cannot erase initial relief the size of the landscape, so the ridged fBm stopped
|
||||
breaking symmetry and became the texture - the ribs are 250-300 m, octave five of a 4.2 km ridged fBm.
|
||||
Pre-fault rate now, bounded at one.
|
||||
Measured, region 11 re-baked at the same seed and steps, `Bake_018` -> `Bake_D63`: max **221.4 ->
|
||||
115.6 m**, repose-clamped cells **160 289 -> 2 865**, median slope **6.26 -> 1.79 deg**, slope-area
|
||||
exponent **-0.938 -> -0.599** (theory -0.500), drainage 0.58 -> 0.60 /km, stacking median **1.77 ->
|
||||
1.50** and p90 2.87 -> 1.60. The corduroy is gone. The wall time went 1m34s -> 12m56s and that is the
|
||||
right direction: the old run was cheap because unbounded negative stacking had driven whole aprons to
|
||||
zero uplift, and dead flat ground costs one hillslope sub-step instead of twenty-four.
|
||||
Not fixed: the residual ribbing is Terrain-Next 4.B3, which is visible in `Bake_013` too and is now
|
||||
diagnosed there rather than fixed. `ClampToRepose`'s raster-order facets were checked and ruled out as
|
||||
the cause - its facets are grid-aligned and these ribs are oblique - and recorded as 4.B4.
|
||||
- 2026-09-19 - Off the ladder: a fault is a range front rather than a welt (D-62). Raised as "each fault
|
||||
line makes a rough line of mountains that just doesn't look realistic", and asked as "is it a json setting
|
||||
that is too low". It was not: the legend says how many faults, how long and how much they throw, and
|
||||
nothing in any manifest said what shape one is. The shape was `faultSteepM = 200` and `faultGentleM = 2000`
|
||||
in `painted_faults.go` with a **step** between them - the whole throw one side of the trace, the whole
|
||||
throw negated the other, one 8 m cell apart. Measured on the designed field: 2 x throw across one cell,
|
||||
**89 degrees**, inside an upthrown flank that reached zero 600 m out.
|
||||
Two things are wrong with that and they are the same thing twice. A discontinuity in the *rate* is a
|
||||
painted cliff - the surface can only put it into a scarp at the angle of repose, so the trace facets at
|
||||
any throw and turning `throw_m` down just lowers the artefact. And 600 m is **narrower than one
|
||||
hillslope**: `Bake_013`'s drainage density is 0.45 channels per km, so a divide sits 1.1 km from its
|
||||
channel, and nothing can cut a valley into a block that width. The uplift profile is therefore printed
|
||||
onto the surface rather than eroded into a landform, which is exactly what the hillshade shows - a smooth
|
||||
ruled ridge with no drainage on it at all, running through terrain dissected everywhere else.
|
||||
Now: an odd saturating ramp across the trace (`d/sqrt(R^2+d^2)`, R = 900 m, about one hillslope) times a
|
||||
flank envelope with finite support and zero gradient at its edge (`(1-u^2)^2`, 6 km footwall, 4 km hanging
|
||||
wall). Zero *on* the trace, which is the honest reading - a rate difference across a line says one side
|
||||
rises relative to the other and the two average to the regional rate at the line. Algebraic rather than
|
||||
transcendental on purpose: it runs at a few hundred million cells a planet, and the reach is unchanged so
|
||||
the box is too. `tipTaper` was a flat top over the middle two thirds, which extrudes the cross-section
|
||||
along most of every trace; it is a bell now. And `throw_m` is normalised to mean the whole step across the
|
||||
fault rather than a full throw on each side, which is what the old one built - the normaliser is measured
|
||||
off the profile so changing a width cannot silently change what the legend's number means.
|
||||
Measured, 400 m throw: steepest cell in the rate field **89 -> 17.4 degrees**, step across the fault 400 m
|
||||
over 2.66 km (8.5 degrees mean) against 800 m over one cell, footwall above half its crest for 3.7 km
|
||||
against 0.6 km. Solved for 1000 steps on the same synthetic landscape, old against new: a ruler-straight
|
||||
cliff with a dead apron and 43 m of relief (45 m with no fault at all) against a dissected range front
|
||||
with its own valleys at 97 m. `TestAFaultIsSolvableRatherThanPrinted` is the three properties as
|
||||
assertions - continuous, zero on the trace, and wide enough for three hillslope lengths of footwall.
|
||||
- 2026-09-18 - Off the ladder: the studio's canvas becomes a GPU texture (D-61). The template is 7738 x 3761
|
||||
and every pointer event pushed all of it through the 2D canvas: a full-width `putImageData` band, then a
|
||||
high-quality downsample of the whole image once per repetition across the seam. Neither cost is a function
|
||||
of what the stroke touched, so a 24 px brush and a 400 px one both measured **about 105 ms an event** at
|
||||
fit zoom, worst case a 1.4 s p90 at 1:1 from the canvas read-back stall. Now: one WebGL2 texture per sheet,
|
||||
a stroke uploads only its own rectangle through `UNPACK_ROW_LENGTH` with no copy, drawing is one quad, and
|
||||
the seam is `REPEAT` rather than a tiling loop - which also fixed the blur at the wrap, where each
|
||||
repetition's own texture coordinates gave the wrong derivative and so the wrong mip. Measured after on the
|
||||
same machine: **6.0-6.3 ms flat** at fit zoom with a 400 px brush, at 1:1, at 4x and panning. Input is off
|
||||
the drawing path (one rAF loop, `getCoalescedEvents` keeps every sub-frame position), mips rebuild at most
|
||||
ten times a second while the brush is down, both offscreen canvases are gone (230 MB, and the sheets are
|
||||
encoded only when pushed), and a lost GPU context re-uploads instead of going black. Added: a brush ring in
|
||||
the brush's colour, eased zoom anchored under the cursor, `f` to fit, `1` for 1:1, space to drag, rendering
|
||||
at device pixels, and shortcuts that stay out of input fields - `o` had been swapping the sheet under a
|
||||
half-written number. Verified live over the DevTools protocol against the running studio: sheet and GPU
|
||||
agree on a painted pixel, a seam stroke uploads as two rectangles and both ends take the colour, all 200
|
||||
control columns and both sides of the wrap map exactly at 1:1, the eraser restores alpha, plan round-trips
|
||||
both sheets, and no GL error on any path. The 12.1 % of rows that disagree across the seam are the
|
||||
painting's own, which `plan`'s SEAM check already reports.
|
||||
**And `ctrl+z` takes back a stroke** (`ctrl+shift+z` or `ctrl+y` puts it back). Same constraint, same
|
||||
answer: a sheet is 116 MB so a stack of snapshots is not a stack, and the unit is one stroke rather than
|
||||
one frame. A step is copy-on-write over a 256 px tile grid - a tile is kept the first time a stroke writes
|
||||
into it - so a dab costs one tile and 256 KB, a 60 px drag two and 512 KB, a 400 px brush dragged 800 px
|
||||
42 tiles and 9.1 MB, against a 192 MB cap that bounds both stacks. `keepTiles` sits at the top of `stamp`,
|
||||
the only writer, so nothing can be painted that undo has not recorded; undo and redo are one swap in
|
||||
opposite directions. **The test caught a bug reading would not have:** 7738 is not a multiple of 256, so
|
||||
the tile grid does not line up with itself across the seam, and cutting the stamp's unwrapped rectangle
|
||||
into tiles before wrapping the indices kept columns 30 and 0 for a brush that had also written into 29 -
|
||||
every sampled pixel restored, only a whole-sheet hash disagreed. Wrap into runs first, then cut, which is
|
||||
`pushRect`'s own order. Verified live: bit-for-bit restore of sheet and texture, four undo/redo cycles
|
||||
exact, four strokes back in order, a seam stroke whole again, one sheet's undo leaving the other alone, the
|
||||
eraser undone back to its mark, a new stroke dropping the redo future, mid-stroke ctrl+z ignored and the
|
||||
stroke still undoable after, and ctrl+z in a text field still the field's own.
|
||||
- 2026-09-18 - Off the ladder: a planet has a shore (D-60). `internal/coast` ran on a flat grid, so a bake
|
||||
laid the painted sea floor and stopped - no shelf, no surf platform, no beach, no sediment, no exposure
|
||||
anywhere. Four primitives to wrap, three of them one loop: `boxBlur`'s running sum, `fetch`'s ray march,
|
||||
`shelfWidth`'s inland march, and the distance-field gradient both marches take their direction from.
|
||||
**The abyss is a field now**, because a painted planet's sea classes carry their own depths and a derived
|
||||
slope bottoming out at one global number would step to the painting wherever they disagreed. It also fixes
|
||||
the shallow case honestly: the break cannot be deeper than the water it is a break in, so a strait painted
|
||||
at 20 m against a 30 m break is shelf all the way across rather than a trench.
|
||||
**Memory.** `Geometry.Ref` holds a waterline *slot* rather than a cell index, so the sediment supply is a
|
||||
few hundred thousand entries instead of 76 million - 608 MB gone. `Measure` holds one distance transform at
|
||||
a time instead of two: the waterline comes straight off the mask, and a sea cell's shore is the shore its
|
||||
nearest land cell already found, so the second pass reads `Ref` rather than the first pass's index.
|
||||
`boxMean`'s coverage is separable (`cx(x)*cy(y)`, exactly, any pass count) so it is two vectors rather than
|
||||
a field plus a second blur, and the before snapshot is taken into the change map and subtracted in place.
|
||||
**A latent NaN fell out of the last of those.** `deposit` does `math.Pow(1-exposure, 1.5)`, so an exposure
|
||||
over 1 by 5e-5 - which the new float64 divisor produced where the float32 one had not - is NaN, and one NaN
|
||||
spreads through the drift kernel into the whole budget. 1720 cells of a 200x40 test. Clamped at the point
|
||||
of use; relying on a smoother a hundred lines away to bound its output is not an invariant.
|
||||
Measured: **7.9 s over the whole 76 M cell cylinder**, and the seam step in the sea floor went from a mean
|
||||
of 9.1 m (worst 523 m) to **0.32 m**, which is what an ordinary interior column is - checked against six of
|
||||
them. What is left is the template's own 9.4 % wrap disagreement, which is an author's to fix. The pass is
|
||||
now the memory peak of a bake at about 8.3 GB working set; the solve's was 3.6.
|
||||
|
||||
- 2026-09-18 - Off the ladder: a planet can be judged now (D-59). `internal/stats` sorted a copy of every
|
||||
land cell in four different places, so a planet bake printed its elevation range and nothing else - no
|
||||
slopes, no per-uplift-class breakdown, no drainage density, no slope-area fit. It cost twice today alone:
|
||||
the lowland slope distribution and the fault scarp measurement both had to be taken by hand in Python off a
|
||||
PNG, because the tool could not answer questions about its own output.
|
||||
**Fixed-bin histograms replace all of it**, and the property that matters is not the speed but that they
|
||||
**add**: two regions' bins summed and quantiled give exactly what one pass over both would. A median of
|
||||
medians would not. Each region accumulates while its own grid is alive - the composited planet has no uplift
|
||||
field or flow topology left to recover them from - and they merge in *region order*, because the bins are
|
||||
integers but the sums are floats and float addition is not associative.
|
||||
**Extent and ground are measured in different places on purpose.** Regions carry overlapping ocean margins,
|
||||
so pooling "cells" double-counts water and reports a meaningless land fraction; `AddExtent` runs once on the
|
||||
composited cylinder, `Add` runs per region over the land that region owns. Found the consequence by running
|
||||
a partial bake: drainage density came out an order of magnitude low because it divided three islands' worth
|
||||
of channels by a planet's worth of land. It divides by the land actually walked now and says PARTIAL.
|
||||
`field.SlidingMin` and `field.LocalRelief` join `SlidingMax`, so local relief stops being 1.1e11 comparisons
|
||||
at a 500 m window on 28 M cells. Measured: **1.09 s for a 9 M cell region**, ~120 ns a cell, a few seconds
|
||||
for a planet at the end of a two-hour bake. `generate` and `bake` share the code now, so their numbers are
|
||||
comparable - which they were not before, and nobody had noticed because one of them printed none.
|
||||
|
||||
- 2026-09-18 - Off the ladder: the seed re-rolls what the painting does not fix (D-58). Asked for as "different
|
||||
faultlines and stuff". Half of it already worked and was worth measuring before building anything: seed 7
|
||||
against 9342 on one painting moves **13.6 %** of the uplift map, because the massifs, the swell, the initial
|
||||
relief, the crests and the coast jitter are all seeded. The other half did not exist - **faults and lithology
|
||||
were procedural-path only** - so `map_erodibility.png` was a recolour of `map_class.png` and two seeds
|
||||
differed on it only where the coastline had moved. It is 24.9 % now.
|
||||
Neither ported as it stood. The old lithology takes `f.Percentile()` of the grid it is given and the old
|
||||
fault centres are `Float()` pairs read as fractions of it: both are exactly what D-53's per-landmass
|
||||
decomposition forbids. The rock cut is a quantile of the **planet** now, from the probe the massif fabric
|
||||
already uses (`measureFabric` went generic), and the fault set is drawn once in world metres with each region
|
||||
filtering to the traces that reach its frame. A class says `faults: {per_1000km2, throw_m, length_km}` and
|
||||
`lithology_mix`, so an author says which ground is faulted and which rock shows through.
|
||||
Wrote the faults fresh rather than porting: 4.A2's four defects (polygonal parabola, stamped tips, no
|
||||
en-echelon step, strongest throws flattened by a global clamp) and 4.A3's single strike angle are all fixed
|
||||
in the new one. The softening of a rock boundary is **pointwise in rank space** and not a blur, because a
|
||||
blur near a region edge reads cells another decomposition would not have given it.
|
||||
**One defect found by arithmetic rather than by looking.** A hard cut-off at three gentle lengths leaves 5 %
|
||||
of the peak - 0.013 mm/yr on a 400 m throw, a fifth of a lowland's entire rate - as a step at a line 6 km
|
||||
from every fault, which the solve would have carved into a straight scarp nobody placed. Subtracting the
|
||||
floor and renormalising fixes it and makes the box a real optimisation: with per-segment boxes hoisted out of
|
||||
the cell loop, 5.2 s to 1.5 s on a 6.3 M cell region.
|
||||
`--seed` on plan, bake and tiles (`fs.Visit`, not a sentinel - every sentinel is a seed somebody wants), and
|
||||
a seed box with a Re-roll button in the studio. Faults cost 1.5 s on a 6.3 M cell region and the rock field
|
||||
nothing measurable; the trace geometry goes into the bake's meta.json.
|
||||
**Baked region 15 to check the faults survive the solve** (Bake_008, 12 min): five traces with throws of
|
||||
139..399 m left scarps of 2.7..50 m and **five of five face the side the fault raises**. A small fraction of
|
||||
the throw is the right answer - the rivers cut it down about as fast as the rate rebuilds it, which is the
|
||||
whole reason a fault is a rate and not a painted step. `TestAFaultLeavesAScarpAfterTheSolve` is that in
|
||||
miniature, nine seconds, because everything in internal/uplift tests the rate field and none of it says the
|
||||
solve leaves anything behind.
|
||||
|
||||
- 2026-09-18 - Off the ladder: a second painting, for everything that is not geology (D-57). Asked for as
|
||||
"select which coastlines not to give jitter to" and "a layer for forest generation, where a city/town/village
|
||||
is, spline roads" - two requests with one shape, so one mechanism. `internal/overlay` is a sheet the same
|
||||
size as the template with a legend of *marks*; blank is **alpha**, not a reserved colour, and an opaque pixel
|
||||
matching no mark is dropped and counted rather than snapped to the nearest, which is the class legend's rule
|
||||
inverted on purpose.
|
||||
**One mark property is read by anything.** `coast_jitter` scales D-56's waterline roughening per pixel: 0
|
||||
pins a hand-drawn shore exactly as painted, 2.5 makes a fjord coast out of one brush stroke. An *unmarked*
|
||||
cell is uninstructed rather than 1 and takes its instruction from the far side of the waterline, or a stroke
|
||||
painted on the land would be overruled by the water beside it. Checked exactly rather than by eye: pinning
|
||||
every pixel gives a `map_class.png` byte for byte identical to `--coast-jitter 0`.
|
||||
Everything else is inert. Marks come out as an 8-bit index raster beside every detail tile (indexed, not one
|
||||
mask each - marks cannot overlap on one painting, so 254 fit in one file) and as features in world metres in
|
||||
`overlay.json`: areas get a centre, an area, a radius and an extent; paths are thinned to their geodesic
|
||||
diameter and get an ordered polyline, because what is built from a road on the other side is a spline. A blob
|
||||
across the seam is one feature with a **circular** centroid; a tile samples through world metres, which is
|
||||
rule 1 for a raster. A fork is one path and loses its third arm - stated, and `plan` prints the piece count.
|
||||
The studio grew a second tab (`o`), the marks as brushes, an eraser, and the sheet composited over the
|
||||
painting at full strength while you are on it and dimmed while you are not.
|
||||
**And the class table stopped lying.** It printed the divide angle - the *steepest* ground a rate can make -
|
||||
as if it were the landscape. Measured on 600² of 8 m cells at 1000 steps with the manifest's own numbers:
|
||||
median slope is a third of the divide angle in tangent, and the ratio is flat (0.34, 0.33, 0.33, 0.32) across
|
||||
0.012 to 0.250 mm/yr; P90 drifts 0.59 to 0.40. Both columns print now and `reads as` comes from the median,
|
||||
so `highland` is hill country at 11.7° rather than alpine at 32°. D-55's defect one level up.
|
||||
**And the picture lies the same way, which is where the complaint actually came from.** Baked the real
|
||||
continent to settle it - region 12, 45.9 x 19.8 km, 9.0 M land cells, 27 min - and it is **0..47 m** with
|
||||
a median slope of **0.61°**, 4.4 % over three degrees and nothing over eight. A plain. What made it look
|
||||
like an alpine massif is that `preview.png`'s hypsometric ramp tops out at a *percentile of the world
|
||||
being drawn*, so green-to-snow was stretched over its 32 m and the 40 m hills got white caps. Redrawn at
|
||||
a fixed 400 m ceiling it is flat green with four pale massifs. `palette.land_top_m` is that ceiling, and
|
||||
every run now prints which one its preview used. The percentile stays the default - absolute over a
|
||||
world with no mountains is a green shape with nothing legible on it - so what was added is the
|
||||
sentence, not the option.
|
||||
|
||||
- 2026-09-17 - Off the ladder: the coastline stops being a drawn line, and the template gets a tool (D-56).
|
||||
**`coast_jitter_px` was in the manifest, documented and defaulted, and nothing read it** - three grep hits,
|
||||
all in manifest.go - so every painted shore went to the solve exactly as drawn. It is now a mask on the
|
||||
signed distance to the waterline: add fractal noise to the distance, re-read the sign, and land juts out
|
||||
where it is positive and the sea reaches in where it is negative. A domain warp was built first and binned:
|
||||
a smooth warp of a smooth boundary cannot cut a bay. A cell that changes sides takes its class from the
|
||||
distance transform's nearest-feature index; the amplitude is capped per cell at two thirds of the widest
|
||||
land within reach, or a wavelength bigger than an islet takes the islet whole - measured, 2 of 12 gone
|
||||
without the guard, and `field.SlidingMax` (monotonic deque, O(1) a cell) exists because the naive window is
|
||||
seven billion comparisons at planet scale.
|
||||
**The mask exposed a classifier bug and made it load-bearing.** A JPEG blend of surf and lowland is
|
||||
(186,219,174), which is 53.8 from `desert` and 77.9 from either parent - so every temperate coast carried a
|
||||
1 px ribbon of desert, 1607 px of it, and the mask made those strays the nearest *land* to open water and
|
||||
gave their class to an 11 px band of new shore. The colorimetric fix was built, measured and thrown away:
|
||||
it moved 943 000 real `shelf` pixels, because `shelf` sits 10 units off the ocean-surf line. The spatial one
|
||||
works - 5x5 majority, kills a 1 px ribbon, leaves a 2 px band - and moves 0.068 % of the map, 1607 -> 0.
|
||||
**`terrain studio`**: a painting tool on loopback where the brushes are the legend's classes and the panel
|
||||
shows the divide angle each rate buys as you type it. Hard-edged exact colours only (an antialiased brush
|
||||
manufactures the blend the despeckle pass exists to remove), the canvas wraps at the seam, `plan` is a
|
||||
button (7.5 s round trip), and it saves by patching the *text* of the legend and manifest so their
|
||||
commentary survives - `MarshalIndent` over a map returns the file alphabetised.
|
||||
Then two notes from using it. **Saves are versioned and never overwrite** - `Map3_001.png`, `Map3_002.png`,
|
||||
as `Bake_NNN` already is, with the manifest repointed; the base map is a hand-made input with no undo
|
||||
outside the process, and writing back over a JPEG would recreate the blend artefacts on every save.
|
||||
**And plan is cached**: 7.0 s cold, **0.37 s** when only a legend number changed, because the
|
||||
classification, despeckle, coast mask, projection and region cuts depend on the painting and the class
|
||||
*colours* alone. Low-res maps were the obvious guess for making it quick and they do nothing - prepare is
|
||||
flat at 6.5 s from a 400 px map to 2400 - because the cost is `region.Build` at 2.68 s on the 76 M planet
|
||||
grid (classify 0.09, strokes 0.93, despeckle 1.52, coast 1.62, project 0.05). The four maps have keys now,
|
||||
served from the code that drew them rather than reimplemented in the browser.
|
||||
**And a Bake button that can be watched.** `BakeOptions.OnRegion` fires as each region's land is
|
||||
composited, holding a new composite lock so the hook can read the whole planet without racing the workers
|
||||
still solving; the studio draws a preview there, so the world fills in a landmass at a time and the first
|
||||
continent out answers "is this what I meant" an hour before the last one. Zero-copy views over
|
||||
`res.Height`/`res.Flow` rather than `Painted()`, which allocates 300 MB a call. `fluvial.Grid.SetCancel`
|
||||
checks once a step, so a bake can be stopped - and a cancelled run is **written** to `Partial_NNN`, not
|
||||
thrown away: the solve is per landmass, so a region that finished is finished, and losing fifteen of
|
||||
eighteen because the last three were slow is not what a cancel button should do. Outside the `Bake_NNN`
|
||||
namespace on purpose, since `tiles --bake` takes the newest of those by default.
|
||||
|
||||
- 2026-09-17 - Off the ladder: a painted class becomes two rates and a fraction (D-55). The first planet's
|
||||
landmasses came out uniformly dissected - every divide on a continent at the same angle, coast to summit,
|
||||
no flat ground anywhere - and that is D-49 read one step further: a class was one rate, n is 1, so one
|
||||
class was one landscape. `lowland` 0.08 mm/yr is 11.3 degrees on every divide it touches. Now
|
||||
`massif: {floor_mm_yr, fraction}` per class, cutting **one** upland fabric for the whole planet
|
||||
(`planet.massif_wavelength_km`, 7 km against landmasses of 20-45 km; 12.5 put a whole island above the
|
||||
cut). The threshold is a quantile of the *planet*, never of the region - a percentile of the grid is
|
||||
exactly what `FromTemplate` exists not to do, and two regions would have disagreed along every boundary -
|
||||
so it is a fixed 1024-column probe binned into a histogram, ~10 ms, identical everywhere by construction;
|
||||
the ramp is cut in probability, so `fraction` means what it says whatever the noise's distribution is.
|
||||
`map_uplift.png` had to learn the fabric too, at the image's resolution, or the one diagnostic that would
|
||||
show this would have gone on drawing a flat continent. Legend retuned: lowland 0.08 over a sixth with a
|
||||
0.012 plain, highland 0.25 over three tenths with a 0.045 foreland, desert 0.10 over a seventh with a
|
||||
0.015 sand sea. Also corrected the misreading that set the old numbers - `internal/stats` calling under
|
||||
0.1 mm/yr "plain" is a reporting bucket, not terrain, and 0.1 is a 14 degree hillslope - so `terrain plan`
|
||||
prints what a class reads as from its angle, and `coastal_floor_mm_yr` defaults to 0.02 not 0.06.
|
||||
Five tests in `internal/uplift`, and the first version of the fraction test measured nothing: the test
|
||||
planet was narrower than the probe, so both sampled the identical grid and every number came out exact.
|
||||
Measured on Bake_004 against Bake_001/003, same region boxes: lowland continent median slope 3.7 -> 0.7
|
||||
deg and 7% -> 93% of it under 2 deg; highland island 6.7 -> 2.0 deg and 4% -> 49%, keeping a p90 of 8.9
|
||||
and a 167 m core. Peak elevation fell 71 -> 41 m on the lowland although the massifs still reach the same
|
||||
rate, because relief is the integral of slope along the whole flow path - so **max elevation cannot see
|
||||
this change** and nearly reported it as a regression. `map_flow.png` point-samples every fourth cell at
|
||||
3000 px on a 12500 grid, so channels read as disconnected stubs and only the divides survive: it looks
|
||||
like broken drainage and is not, and Bake_001 shows the identical pattern.
|
||||
|
||||
- 2026-09-17 - Off the ladder, three things the legend could not say (D-54), all three from looking at the
|
||||
first whole planet rather than from the plan. **coastal_plain_km**: for n=1 the uplift rate alone fixes the
|
||||
hillslope angle, so a uniformly painted island is at the angle of repose right down to the water - fjords
|
||||
end to end. The rate now ramps from a floor at the waterline up to the class rate over a stated distance,
|
||||
smoothstepped so the plain meets the range without a crease. This is D-52 read carefully, not reversed:
|
||||
that removed a *hidden* taper that went to zero and flattened the strip the surf works in; this is opt-in
|
||||
and the waterline keeps a real rate. **A crater cannot be an uplift rate** - the priority-flood raises every
|
||||
depression to its spill level every step, so a basin built from negative uplift is filled within a hundred
|
||||
steps, and an impact is an event rather than a rate anyway. It is stamped on the finished terrain after the
|
||||
solve, shape derived from the painted blob: distance in from its own boundary normalised by its widest
|
||||
point, so four numbers describe every crater whatever size it was drawn, and one across the seam is one
|
||||
crater. **A desert is not a low uplift rate** - a wet lowland has one too, and at the geology grid the
|
||||
only lever is k_mult. A class can now override what the *detail* passes do on it (droplets_per_cell,
|
||||
strata_contrast, amplitude_m), which is where the difference actually lives: measured over identical
|
||||
terrain, 43634 droplets moving 84 km of material against 2148 moving 4.1 km - a dendritic gully network
|
||||
against a few isolated wadis. **snow** is a display and material hint no pass reads: the hypsometric ramp
|
||||
tops out by elevation, so a polar cap fifty metres above the water was coming out the same green as a
|
||||
meadow. **And the thing chasing "too mountainous" turned up:** the mountains were flat polygonal facets -
|
||||
the repose clamp doing *all* the shaping, because highland at 0.9 mm/yr is 66 degrees at a divide against a
|
||||
35 degree repose angle. The ceiling is U = tan(talus)*K*cell = 0.280 mm/yr at an 8 m cell; `terrain plan`
|
||||
prints the angle per class now. **Did not work, in order:** blamed the strata hardness (turned it off,
|
||||
nothing changed), then the tile hillshade (`WriteThumbnail` saturates to black and white at 2 m a cell - a
|
||||
real bug, now a proper DEM hillshade, but not the cause). What settled it was `tiles --no-detail`, which
|
||||
writes the geology upsampled and nothing else, so "the solve or the detail passes" is a twelve-second
|
||||
question. Bakes are versioned Bake_NNN now, because ninety minutes is too long to spend on a change you
|
||||
cannot then compare. **And the preview palette is a file**: the ramp, the water, the rivers, the ice and the
|
||||
light, pointed at from the planet manifest, defaulting to what was hard-coded. Separate from the legend
|
||||
because the legend is about the world and a palette is about the picture - it changes no height, so two
|
||||
bakes under two palettes are the same terrain. Written by hand rather than by MarshalIndent, which
|
||||
re-indents a custom marshaler's output and will not keep a colour on one line.
|
||||
- 2026-09-17 - Off the ladder, the first whole planet baked: 18 regions, 49 M of 76 M cells, 44m41s of wall
|
||||
time (8805 s of solve) at 1000 steps with four regions in flight, land 0..741 m, nothing clipped. The
|
||||
painted classes do exactly what they say - the lowland island is 45 km of 31 m plain, the highland islands
|
||||
are 741 m of mountain with dendritic drainage to both coasts - which is the legend's numbers to tune rather
|
||||
than the tool's. **Measured, and it is the input rather than the tool:** the template's left and right edges
|
||||
are the same meridian and **disagree on 9.4 % of rows, 261 of them land against water**. The crater island
|
||||
crosses the seam perfectly; islets drawn touching x=0 with nothing to meet them at x=W-1 do not, and a JPEG
|
||||
halo two pixels wide on the outermost columns classifies as shelf and puts a 400 m ledge down the whole
|
||||
height of the map. `terrain plan` measures the wrap now, because it is the one defect an author cannot see
|
||||
by looking at their own picture - the two edges are as far apart on screen as they can be. **Also measured:**
|
||||
cost per cell varies eighteen-fold with the painted uplift rate (72 s per million cells at 0.08 mm/yr, 350 at
|
||||
0.9, 1278 at 1.6), because the nonlinear hillslope sub-steps up to its budget of 24 on steep ground and once
|
||||
per step on a plain - so raising an uplift rate changes the bake time as much as it changes the terrain, and
|
||||
the wall time is set by the slowest single region rather than by the total.
|
||||
- 2026-09-17 - Off the ladder, the detail passes and tiles (D-53 continued). Passes 8 to 12 and 14 in
|
||||
`internal/detail` and `internal/tile`, ported from `heightmap_erosion.py` with every brake by name, plus
|
||||
`terrain tiles`: 5 km tiles of 2500 samples at 2 m, about twelve seconds each, heightmap plus hillshade plus
|
||||
flow, wear and deposit. **The thing worth remembering:** the tile margin is *measured*, not reasoned. Taken
|
||||
literally it would be `rounds x lifetime`, 640 cells against a 2500-cell tile; measured against the same
|
||||
ground in one whole run it is 7.97 m of difference at the cut edge, 0.72 at eight cells, 0.03 at 24 and zero
|
||||
by 32, so three lifetimes plus the brush is the margin - 122 cells, five per cent of a tile. At one round the
|
||||
match is bit-for-bit, which is the test. **Did not work, and none of it would have failed loudly:** the
|
||||
droplets' round count derived from the droplet total, so a droplet landed in a different round in a tile than
|
||||
in the whole map and the seams never closed (it is a manifest number now); the derivative maps normalised by
|
||||
a percentile of the tile, which is a statistic of the piece being looked at and the same mistake the coastal
|
||||
exposure had already had withdrawn; the detail noise on the world period, which wants a 12500-squared lattice
|
||||
and a gigabyte and a half for one octave (a kilometre period instead, and what repeats has no shape); the sea
|
||||
left in place through the upsample, which rings at every coast and lets thermal pour the shore into the water
|
||||
- the mirror image of the first coast run eroding land to 174 m below sea level; and the droplet stencils
|
||||
straddling the waterline, quietly deleting the sediment that should have built a beach. **Also:** the
|
||||
per-band scatter was partitioned by core count, and floating-point addition is not associative, so
|
||||
determinism failed by one ulp - `field.FixedBands` is a partition fixed by the grid rather than the machine.
|
||||
- 2026-09-17 - Off the ladder, painted planets (D-53). The generator's source stops being a seed: a hand-painted
|
||||
flat cylindrical world map plus a JSON legend saying what each colour means in uplift mm/yr and erodibility,
|
||||
and the simulation makes the terrain. X wraps, Y does not. New `internal/world` (the cylinder and the frame),
|
||||
`internal/dt` (the exact distance transform moved out of `coast` and given a cylinder), `internal/template`
|
||||
(image, legend, classifier), `internal/region` (the partition) and `internal/planet` (the driver), plus
|
||||
`terrain plan` and `terrain bake`. First template: 7738x3761, nine classes, 100 km round at the 8 m geology
|
||||
cell = 12500 x 6076, 18 regions, 49 M cells of 76 M, largest 14 M at 0.8 GB, about two hours at 1000 steps.
|
||||
**The thing worth remembering:** the fluvial solve cannot be tiled but it *can* be decomposed per landmass,
|
||||
exactly, because ocean cells are fixed at sea level and nothing in the solve can move them, so no flow path
|
||||
crosses open water - `TestOceanCellsAreUntouchedByTheSolve` now asserts that premise directly. The coastal
|
||||
pass is deliberately *not* decomposed: it costs 26 ns a cell against 80 ns a cell per step for the solve, and
|
||||
cutting it up would truncate the fetch across every strait and split the sediment budget whose conservation
|
||||
is the one thing in it not derived from something already measured. Decompose the solve, not the map.
|
||||
**Did not work:** clustering landmasses by overlapping dilated bounding boxes - transitively closed, and one
|
||||
70 km landmass collapses a 100 km planet into a single region; dilating the mask itself and
|
||||
connected-componenting it is exact, wrap-aware and the same code the stroke fill needs. Also rejected:
|
||||
hanging a world origin on `field.Field`, because a Field is used for masks and scratch that have no position
|
||||
and would all quietly claim to sit at the origin. **Also:** the router jitter moved from a hash of the grid
|
||||
index to a hash of the world position (rule 1 of the tiling plan), done first and on its own because it
|
||||
re-baselines every measured number on the square canvas; and every image writer turned out to be silently
|
||||
square, which is invisible at 1:1 and squashes a 2:1 planet.
|
||||
- 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;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# Dressing the world: substances, sky, light and things that grow
|
||||
|
||||
A plan, not a specification. It covers what has to happen for `L_World` to read as deserts, coasts, tropics and
|
||||
craters rather than as one grass-and-rock material stretched over 2549 km², and for there to be a sky and trees
|
||||
worth looking at.
|
||||
|
||||
[`World-Pipeline.md`](World-Pipeline.md) is how the ground gets made and imported; this is what it wears
|
||||
afterwards. Everything here is **off the ladder** (D-47) and blocks no step in [`Steps.md`](Steps.md).
|
||||
|
||||
---
|
||||
|
||||
## What we already have, which is more than it looks
|
||||
|
||||
The single most important fact: **the biomes are already painted.** `Map5.legend.json` is not a set of
|
||||
erodibility numbers that happen to have names — it is a per-pixel classification of the planet.
|
||||
|
||||
| Class | RGB | Is |
|
||||
| --- | --- | --- |
|
||||
| `ocean` / `deep` | 91,175,185 / 66,165,180 | sea, two depths |
|
||||
| `ice` | 250,250,250 | ice cap |
|
||||
| `lowland` | 150,200,105 | plains |
|
||||
| `highland` | 71,175,100 | hill and mountain country |
|
||||
| `desert` | 226,215,145 | arid |
|
||||
| `crater` | 124,116,111 | impact ground |
|
||||
|
||||
That map exists at planet resolution in three forms already registered to the world by the same normalised
|
||||
`u,v` as everything else: `Plan/map_class.png`, `Bake_NNN/map_class.png`, and
|
||||
`Orogen Gens/orogen-painted-class-7945.png`. There is also `orogen-climate-7945.png`, Orogen's own climate
|
||||
bands, which is where **tropical** comes from — it is the one biome you asked for that is *not* a painted class.
|
||||
|
||||
The overlay legend already carries `forest` (generation enabled), plus `city`/`town`/`village` and
|
||||
`road`/`track`. `terrain overlay` and the studio's **Generate marks** button already place them.
|
||||
|
||||
And what we do **not** have:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Substances | **Three.** `Base_Layer` (rock), `Layer_02` (meadow grass), `Layer_03` (high rock) — all from Elite_RockyMeadows, collected into `Content/Terrain/` (D-69a). No sand, no beach, no jungle floor, no ice, no crater regolith. |
|
||||
| Trees | **None, in any pack.** HouseForge has one mushroom and a cover-plant material; Medieval_Weapons and RPGEnvironmentVFX have no ground cover at all. |
|
||||
| Grass | **None.** `M_Ground_Landscape` has no `LandscapeGrassOutput` node, so nothing grows anywhere. |
|
||||
| Sky | Elite_RockyMeadows' skybox mesh, sun with cloud shadows, sky light, exponential fog — scaled for world size in `rocky_meadows.py`. Serviceable; not authored. |
|
||||
| Biome weightmaps | **Not carried.** `region_manifest.LAYER_SUFFIXES` is a fixed dict of three, and `Region.json`'s `layers` block derives them from slope and altitude alone. |
|
||||
|
||||
---
|
||||
|
||||
## Five decisions that gate the work
|
||||
|
||||
**All five are now taken** (D-74); the reasoning is kept because it is why the phases are shaped as they are.
|
||||
The two that were a genuine fork went: **dress Route A now** rather than wiring Route C first, and **Fab/Quixel
|
||||
Megascans** for the substances and the trees.
|
||||
|
||||
**1. Where the substances come from. `[DECIDED]` Fab/Quixel Megascans.** Free for Unreal use and has every
|
||||
surface on the list — sand, shingle, jungle litter, regolith, ice — plus trees for Phase 5. Each goes into
|
||||
`RawContent/Terrain/ground.json` exactly as the Rocky Meadows set did, so `collect_terrain_assets.py` keeps
|
||||
owning `Content/Terrain/` and no pack is ever modified. **This is the one thing the plan needs from a person:**
|
||||
the assets have to be added to the project from Fab before Phase 2 can start. Phase 1 does not wait on them.
|
||||
|
||||
**2. The layer budget. `[DECIDED]` eight layers.** This is the real constraint and it is not obvious. Unreal packs **four paint layers per
|
||||
weightmap texture, per component**. We have three, so one texture each across **9800 components**. A fourth is
|
||||
free; the fifth doubles it. The shader cost is linear too — every layer is a full material function sampled and
|
||||
blended whether or not it contributes.
|
||||
|
||||
So the honest budget is **six to eight layers**, and that is the whole design:
|
||||
|
||||
| Layer | Rule | Substance |
|
||||
| --- | --- | --- |
|
||||
| `Base_Layer` | slope | **Layered_Rock_Cliff** (D-76) |
|
||||
| `Layer_03` | altitude | the pack's high rock |
|
||||
| `Layer_02` | remainder | the pack's meadow grass |
|
||||
| `Beach` | near sea level, low slope | **Thai_Beach_Sand** |
|
||||
| `Sand` | class `desert` | **Bright_Desert_Sand** |
|
||||
| `Ice` | class `ice` | **Snow** |
|
||||
| `Regolith` | class `crater` | **Desert_Western_Ground_Gravel_Coarse_04** |
|
||||
|
||||
**Seven, with one spare in the budget.** A `Jungle` layer reading Köppen `Af`+`Am`+`Aw` was specified and then
|
||||
dropped (D-76): the nearest substance to hand is a mossy rocky ground, which is temperate and damp where a
|
||||
tropical forest floor is leaf litter, and the wrong green over the whole equator is more misleading than no
|
||||
biome at all. The equator wears the remainder layer instead — generic, but not wrong. Everything needed to
|
||||
bring it back is still in place; it is one manifest entry and one substance.
|
||||
|
||||
**3. Route A or Route C first. `[DECIDED]` Route A, now.** Today's ground came from Orogen with **no erosion
|
||||
pass**, so there is no wear, flow or deposit map to read — which is why the paint rules are slope and altitude
|
||||
alone. `terrain tiles` already produces those maps at 2 m. Dressing Route A means sand where the *class* says
|
||||
desert; dressing Route C means sand where sediment actually accumulated, and beaches where the coast pass
|
||||
actually laid them.
|
||||
|
||||
Route C is better ground and is the point of the generator, but it is a whole world rebuild in front of the
|
||||
dressing rather than after it, with nothing to look at until it finishes. Route A gives a visible result in
|
||||
Phase 1 and **nothing authored for it is wasted**: when Route C lands it changes the *inputs* to the layer
|
||||
rules, not the material, the sky, the grass or the trees. The two costs accepted are that beaches are
|
||||
approximated from altitude and slope rather than taken from where the coast pass put them, and that no rule can
|
||||
read sediment or wear.
|
||||
|
||||
**4. Whether trees need collision. `[DECIDED]` no, for now.** `LandscapeGrassOutput` is the only thing that scales to 2549 km² without
|
||||
authoring actors — GPU-instanced, distance-culled, driven straight off the layer weights we are about to add,
|
||||
and it costs nothing to author once the material has the node. But grass-output instances **have no collision**.
|
||||
Trees you can walk into mean PCG or foliage actors, which is a different and much larger job at this scale.
|
||||
Recommendation for a prototype: grass output for everything including trees, and revisit when a step needs to
|
||||
collide with one.
|
||||
|
||||
**5. One sky or many. `[DECIDED]` one.** It is one planet, so it is one sun, one sky light and one skybox. "Desert lighting" and
|
||||
"tropical lighting" as distinct looks means per-region post-process volumes, which at 98 landscapes is a lot of
|
||||
actors to place and keep in step. Recommendation: one authored sky, and let the *ground* carry the biome.
|
||||
|
||||
---
|
||||
|
||||
## The plan
|
||||
|
||||
Each phase says what exists afterwards and what proves it. They are ordered so that nothing waits on anything
|
||||
later.
|
||||
|
||||
### Phase 1 · Carry the biome into the tiles — **done**
|
||||
|
||||
The class map and the climate map become per-vertex layer weights, sampled at the same global coordinates as the
|
||||
height, so they are seam-exact for the same reason it is.
|
||||
|
||||
- `Region.json`'s `layers` block gained `biomes` (the two sources and the blend width) and `paint`, an ordered
|
||||
list of layers each with a rule: `slope`, `altitude`, `beach`, `class`, `climate` or `remainder`.
|
||||
- `region_manifest.py` models the list; `LAYER_SUFFIXES` survives as what a manifest with no `paint` block means.
|
||||
- **`mapart biomes`** classifies the two sources and writes one blurred greyscale mask per biome.
|
||||
- `generate_region_tiles.py` samples the masks and composes every enabled layer, `--all-layers` to preview the
|
||||
ones nothing can import yet.
|
||||
|
||||
**Three things it turned up.**
|
||||
|
||||
**The class source is the painting, not Orogen's class render.** The render is the legend's colours
|
||||
double-encoded to sRGB, which puts exported `desert` nearer to legend `ice` than to legend `desert` — nearest
|
||||
colour matching against it is simply wrong. The painting is made of the legend's own colours and nothing else:
|
||||
measured, worst nearest-colour distance **0.0** over all 29 M pixels. One is data; the other is a picture of data.
|
||||
|
||||
**Tropical is Köppen, not latitude.** `Tools/Orogen/js/koppen.js` has a real classification with exact colours,
|
||||
and the climate export decodes against it cleanly once the same sRGB encode is applied — every observed colour
|
||||
within **1.4/255**, and only **0.423 %** of pixels not close to any class, which is the anti-aliased ring at
|
||||
class boundaries and nothing else. Jungle is `Af`+`Am`+`Aw`, 8.11 % of the planet.
|
||||
|
||||
**The registration is measured, not assumed.** The painting is 7738 × 3761 and the heightmap 8192 × 4096; the
|
||||
two candidate mappings were tested against each other on land/sea agreement and identity won, **98.09 % against
|
||||
96.14 %**, in every latitude band including the polar ones where a vertical scale error shows first.
|
||||
|
||||
**Proved by:** seams **0 differing vertices** across all nine files on a shared column; weights **255..256 and
|
||||
never under**, so no ground is unpainted; and the default three-layer path still produces the built world —
|
||||
height, rock and high rock **bit-identical**, meadow differing in **6 of 6 507 601** vertices by 1, which is the
|
||||
deliberate rounding change and nothing else.
|
||||
|
||||
**Left for Phase 2:** `rocky_meadows.LAYER_INFOS` still knows only three layers and now refuses loudly rather
|
||||
than dropping one silently, which is the hook the substances plug into. Underwater ground gets the remainder
|
||||
layer, because no rule claims the sea floor; the sea plane covers it, so it costs nothing but it does inflate
|
||||
the meadow's share in any per-tile number. And the beach rule wants tuning against real coastline once there is
|
||||
a sand substance to see — it lands at 0.1–0.2 % of a coastal tile today.
|
||||
|
||||
### Phase 2 · The project's own landscape material — **all but the layer infos**
|
||||
|
||||
Built by `Scripts/Authoring/build_ground_material.py`, which **extends** the pack's material rather than
|
||||
replacing it: a new layer is a copy of an existing layer function with its six parameters renamed, so it
|
||||
inherits the camera-distance colour blend D-69a found load-bearing at this scale. Verified: the
|
||||
`LandscapeLayerBlend` carries seven wired layers, the new functions' parameters are uniquely prefixed so they
|
||||
do not collide in the instance, and twenty texture parameters point the layers at the Fab substances.
|
||||
|
||||
The layer infos are done too. `LayerName` is read-only from Python and there is no LayerInfo factory in the
|
||||
bindings - duplicate-and-rename produces an asset that silently keeps the name it was copied from - so it goes
|
||||
through `ULandscapeAuthoringLibrary::CreateLayerInfo`, in the editor module for the same reason
|
||||
`CreateLandscapeFromHeightmap` is. Verified per layer: layer info, `LayerName`, parameter prefix, substance,
|
||||
and `weightmap_entries` resolving 7 of 7.
|
||||
|
||||
The notes below were the plan and are kept because they are still what the phase is for.
|
||||
|
||||
`M_Ground_Landscape` is already our copy (D-69a), so extending it modifies nothing of the pack's.
|
||||
|
||||
- Add the five new layers and their material functions, one per substance, following the three that exist.
|
||||
- Keep the existing distance-blend structure — it is the reason the world does not read as tiling mush from the
|
||||
air, and `MI_Ground_RockyMeadows`'s far-colour corrections are load-bearing at this scale.
|
||||
- New layer infos in `Content/Terrain/Layers/`, and the `LayerName` on each must match what the material blends.
|
||||
|
||||
**Proves it:** fly the world; deserts are sand, the ice cap is ice, the crater floor is not grass.
|
||||
|
||||
### Phase 3 · Sky and light
|
||||
|
||||
Authored rather than inherited. One pass, and it is mostly numbers.
|
||||
|
||||
- A sky worth having: Sky Atmosphere plus Volumetric Clouds, or keep the pack's skybox mesh if the atmosphere
|
||||
costs too much at this scale — decide by looking, not in advance.
|
||||
- Exposure that works **both** standing on the ground and looking down from 25 km, which is the case that has
|
||||
already bitten twice (the white sea material read as an ice sheet; the fog at demo density was opaque).
|
||||
- Fog density stays scaled by world size — `scale_fog` already does this and the reason is in `rocky_meadows.py`.
|
||||
- Sun angle and colour chosen once and written down, because "the sun is wrong" has already cost one commit
|
||||
(positional `unreal.Rotator` is `(roll, pitch, yaw)`).
|
||||
|
||||
**Proves it:** a screenshot from the ground and one from 25 km, both legible, no blown highlights on the sea.
|
||||
|
||||
### Phase 4 · Ground cover
|
||||
|
||||
- Add a `LandscapeGrassOutput` to the landscape material, fed by the layer weights from Phase 1.
|
||||
- One `ULandscapeGrassType` per layer that should have cover: grass on lowland, scrub on desert, none on rock,
|
||||
ice or sea.
|
||||
- Density and cull distance tuned at this scale, not the demo's.
|
||||
|
||||
**Proves it:** ground cover appears where the layer says and stops where it does not, and the frame time at
|
||||
ground level is still sane.
|
||||
|
||||
### Phase 5 · Trees
|
||||
|
||||
- Tree meshes from the chosen source (decision 1), as grass types placed by the same output.
|
||||
- Density driven by the layer weight and, where it exists, by the overlay's `forest` mark.
|
||||
- Treeline: the overlay generator already takes its treeline from a quantile of the land's own heights, because
|
||||
metres mean nothing until a world is baked (D-68). Use the same rule here.
|
||||
|
||||
**Proves it:** forests are where the overlay says, thin out with altitude, and are absent from desert, ice and
|
||||
open water.
|
||||
|
||||
---
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **The overlay carry into Unreal is still unbuilt.** `Region.json` reserves an `overlay` block and
|
||||
`region_manifest.py` has `marks_path`; nothing writes one and nothing reads one. Phase 5 can work off layer
|
||||
weights alone without it — that is the cheaper path and the one to take first. Reading actual `forest` blobs
|
||||
means building the carry.
|
||||
- **Water.** The sea is `M_Sea_Proto`, a placeholder grey, deliberately. Real water is its own job and the
|
||||
engine's single-layer water read as a lake shader stretched over a planet.
|
||||
- **Rivers.** The bake knows where they are (`map_flow.png`); nothing in Unreal does.
|
||||
- **HLODs.** Needed for any of this to be visible from the air at all, and they have to be rebuilt after the
|
||||
material changes. See the sculpting note in `Terrain.md` §5.1 before hand-editing anything.
|
||||
@@ -0,0 +1,272 @@
|
||||
# The world pipeline: from a painted map to ground in Unreal
|
||||
|
||||
**Read this when you want to know what order things happen in.** It is the orientation document for
|
||||
everything between "an author paints a world map" and "a player stands on it", and it owns no decisions of its
|
||||
own — every one of them is argued somewhere else and linked from here.
|
||||
|
||||
| If you want | Read |
|
||||
| --- | --- |
|
||||
| Why the generator exists and how it works | [`Terrain.md`](Terrain.md) — the specification and the decision record |
|
||||
| What is being worked on right now, what looks wrong | [`Terrain-Next.md`](Terrain-Next.md) — the working brief |
|
||||
| Every manifest key, explained | `RawContent/World/README.md` and `Scripts/Authoring/region_manifest.py` |
|
||||
| How to paint a world | `RawContent/World/Templates/README.md` |
|
||||
| Why a thing is the way it is | [`Decisions.md`](Decisions.md) — D-47 onwards is all world |
|
||||
|
||||
All of this is **off the ladder** (D-47). No gameplay code reaches into it and nothing here blocks a step in
|
||||
[`Steps.md`](Steps.md).
|
||||
|
||||
---
|
||||
|
||||
## The shape of it
|
||||
|
||||
```
|
||||
RawContent/World/Templates/Map5.png + Map5.legend.json
|
||||
a painted flat cylindrical world map what each colour means: uplift mm/yr, erodibility
|
||||
|
|
||||
| terrain studio paint it, 127.0.0.1:8099
|
||||
| terrain plan 4 s: cut the planet into regions, solve nothing
|
||||
v
|
||||
+----------------+----------------+
|
||||
| |
|
||||
terrain bake Tools/Orogen (the browser twin)
|
||||
~2 h, 8 m geology grid ~12 s, 204K-region sphere mesh
|
||||
| |
|
||||
v v
|
||||
RawContent/World/Bake_NNN/ "Export Map"
|
||||
planet_height.png RawContent/World/Orogen Gens/
|
||||
map_flow / class / uplift ... orogen-heightmap-7945.png (8192 x 4096)
|
||||
overlay.json, meta.json orogen-colormap / satellite / climate ...
|
||||
| |
|
||||
| +------------+-------------+
|
||||
| | |
|
||||
terrain tiles generate_region_tiles.py Tools/MapArt (Go)
|
||||
5 km tiles at 2 m cuts the window into tiles `biomes` -> biome masks
|
||||
| ^ `build` -> 4 map layers
|
||||
| | |
|
||||
| | |
|
||||
X NOT WIRED v v
|
||||
(this is the RawContent/World/ RawContent/World/MapArt/
|
||||
future; see RegionTiles/*.png map_relief / colour / satellite / climate
|
||||
"What is not | |
|
||||
wired yet") create_region_world.py create_world_map.py
|
||||
via build_region.sh via build_world_map.sh
|
||||
| |
|
||||
v v
|
||||
Content/Maps/L_World Content/World/Maps/
|
||||
98 landscapes DA_WorldMap_L_World + 4 textures
|
||||
71.40 x 35.70 km |
|
||||
\ /
|
||||
\________________________/
|
||||
|
|
||||
the map view, in game and in the editor
|
||||
```
|
||||
|
||||
Two generators sit side by side and they are **not** interchangeable. The Go tool in `Tools/Terrain/` is the
|
||||
one that makes ground a player can stand on: an 8 m geology grid, a coastal pass, faults, craters, detail
|
||||
tiles. World Orogen in `Tools/Orogen/` is a browser twin that reads *the same painting and the same legend* and
|
||||
solves them on a sphere mesh in about twelve seconds — for looking at a painting's rivers on a globe and tuning
|
||||
the legend's numbers before committing two hours to a bake. A 44 km cell cannot show a fault or a crater, and
|
||||
it never will (D-66).
|
||||
|
||||
**The ground in `L_World` today came from Orogen, not from the Go tool.** That is a deliberate shortcut: it
|
||||
put 900 km² of ground in the engine long before the generator's detail passes reach Unreal, and the relief you
|
||||
see is therefore *art* rather than a solve. `terrain bake` makes that same continent a 116 m plain; the Orogen
|
||||
export makes it 2972 m (D-69).
|
||||
|
||||
---
|
||||
|
||||
## The three routes
|
||||
|
||||
Only one of these is live. Knowing which is which saves an hour of confusion.
|
||||
|
||||
| Route | Status | What it builds | How the tiles are made |
|
||||
| --- | --- | --- | --- |
|
||||
| **A. Orogen → Python cutter → Unreal** | **live; this is what `L_World` is** | `Content/Maps/L_World` | `generate_region_tiles.py` cuts a window out of a whole-planet PNG. `Region.json`'s `source.kind` is `planet_map` and its `metres_per_pixel` is **a number somebody chose** |
|
||||
| **B. Orogen → direct tile export → Unreal** | built, not the one in use (D-71) | the same level | Orogen's **Unreal Landscape…** export renders the tiles itself and writes `Region.generated.json`. `source.kind` is `orogen_render`, and the scale is a *consequence* rather than a guess |
|
||||
| **C. `terrain bake` → `terrain tiles` → Unreal** | **not wired** | the same level, eventually | This is the point of the whole generator. `terrain tiles` already writes 5 km tiles at 2 m; nothing carries them into Unreal yet |
|
||||
|
||||
Route B is strictly better than A on the one thing that matters most — the scale stops being invented — and the
|
||||
only reason A is what is in the level is that A came first. Route C is where this is going, and when it lands,
|
||||
`generate_region_tiles.py` is the only thing that changes.
|
||||
|
||||
---
|
||||
|
||||
## Route A, step by step
|
||||
|
||||
This is the sequence that produced what is in the project now.
|
||||
|
||||
### 1. Paint the world
|
||||
|
||||
```bash
|
||||
cd Tools/Terrain && go build -o bin/terrain.exe ./cmd/terrain
|
||||
Tools/Terrain/bin/terrain.exe studio # 127.0.0.1:8099
|
||||
```
|
||||
|
||||
The brushes *are* the legend's classes. The panel prints the hillslope angle each uplift rate buys as you type
|
||||
it — read the **`typical`** column, not `divide`: almost none of a map is divide, and reading the divide angle
|
||||
as the landscape is how a legend gets set two or three times too hot (D-59). `o` switches to the overlay sheet,
|
||||
`ctrl+z` undoes a stroke, and **Re-roll** changes the seed, which moves the massifs, the rock, the faults and
|
||||
the coastline detail without touching a painted pixel (D-58).
|
||||
|
||||
**Paint the uplift, never the height.** A solve handed a painted surface erodes it into something else and
|
||||
throws the drainage network away, which is the reason the generator exists at all.
|
||||
|
||||
### 2. Check the painting before committing to a bake
|
||||
|
||||
```bash
|
||||
Tools/Terrain/bin/terrain.exe plan # four seconds, solves nothing
|
||||
```
|
||||
|
||||
`Plan/map_class.png` and `Plan/map_regions.png` are the two pictures that decide whether a bake is worth
|
||||
starting. `plan.json` carries the class table.
|
||||
|
||||
### 3. Solve it — in the browser first
|
||||
|
||||
Serve the repo and open the import page:
|
||||
|
||||
```bash
|
||||
npx serve Tools/Orogen # any static server
|
||||
# then open /import, choose the Painted Map source, and Load from studio
|
||||
```
|
||||
|
||||
**Load from studio** pulls the painting, the legend and `Planet.json` straight out of the running `terrain
|
||||
studio` in about four seconds, strokes included (D-67). The studio answers `GET` and `HEAD` only and no
|
||||
preflight, so no browser tab can ever paint, save, plan or bake.
|
||||
|
||||
Twelve seconds later there is a globe with rivers on it. Tune the legend's numbers in the table here, not after
|
||||
a two-hour bake.
|
||||
|
||||
### 4. Export the planet
|
||||
|
||||
Press **Export Map**. That writes the whole-planet PNGs into `RawContent/World/Orogen Gens/` — the heightmap
|
||||
(8192 × 4096, a fixed −5000…6000 m ramp), plus colormap, satellite, climate, landmask and the painted-layer
|
||||
debug maps.
|
||||
|
||||
> **Do not use "Export All" for the heightmap.** Its list is
|
||||
> `{biome: Satellite, koppen: Climate, landheightmap: Heightmap, landmask: Land Mask}` — the entry *labelled*
|
||||
> "Heightmap" is the **land** variant, whose `landHeightmapColor` returns black for `elevation <= 0`, so every
|
||||
> ocean pixel is 0 m. Import that and the sea floor sits flat at exactly sea level: z-fighting with the sea
|
||||
> plane across 64 % of the world, no shelf and no shore. The file `Region.json` reads is the *absolute*
|
||||
> heightmap, −5 km to +6 km, which has to be exported as a single layer. Both are 8192 x 4096 greyscale and
|
||||
> neither says which it is, so the tell is the filename: `orogen-heightmap-*.png` against
|
||||
> `orogen-land-heightmap-*.png`.
|
||||
>
|
||||
> Orogen also numbers each **export**, not each planet. `orogen-colormap-14733759.png` and
|
||||
> `orogen-heightmap-7945.png` look like two different worlds and are one. Nothing inside a PNG says which
|
||||
> planet it is, so if you are ever unsure, `cd Tools/MapArt && go run . check` measures land/sea agreement
|
||||
> against the heightmap — 97.5 % is the same planet, 50 % is not.
|
||||
|
||||
### 5. Render the biome masks
|
||||
|
||||
The painting's classes and the Köppen climate become one blurred greyscale mask per biome, which is what the
|
||||
paint layers are built from. Go, because the painting is 29 megapixels of RGB and the engine's Python cannot
|
||||
decode it; blurred once globally so a tile can read it without carrying a margin the width of the blend.
|
||||
|
||||
```bash
|
||||
cd Tools/MapArt && go run . biomes # a few seconds, into RawContent/World/Biomes/
|
||||
```
|
||||
|
||||
It prints how far the worst pixel was from any legend colour. On the painting that is **0.0** — a painted map
|
||||
is made of its legend's own colours. A number much above that means the image is a *render* of a
|
||||
classification rather than the classification itself, which is the difference between data and a picture of
|
||||
data, and is exactly why Orogen's own class export is not the source here.
|
||||
|
||||
### 6. Cut the window into landscape tiles
|
||||
|
||||
`RawContent/World/Region.json` is the contract: which level, how many tiles, what a heightmap value means in
|
||||
metres, and — the number that matters most — `source.metres_per_pixel`, because the export carries no scale of
|
||||
its own.
|
||||
|
||||
```bash
|
||||
# measure a window and print what it holds, writing nothing. Try scales here, not by rebuilding.
|
||||
D:/UE_5.8/.../python.exe Scripts/Authoring/generate_region_tiles.py --scout
|
||||
D:/UE_5.8/.../python.exe Scripts/Authoring/generate_region_tiles.py # ~2 min, 208 MB, untracked
|
||||
```
|
||||
|
||||
### 7. Build the level
|
||||
|
||||
```bash
|
||||
Scripts/Authoring/build_region.sh # the whole grid, a few tiles per process
|
||||
Scripts/Authoring/build_region.sh --append # add whatever is still missing
|
||||
```
|
||||
|
||||
One process per batch, because a landscape of a hundred components costs about a gigabyte the editor never
|
||||
gives back: thirty-six tiles in one process reached 14.7 GB by the ninth (D-69).
|
||||
|
||||
### 8. Build the map view
|
||||
|
||||
```bash
|
||||
Scripts/Authoring/build_world_map.sh # renders the art, imports it, writes the definition
|
||||
```
|
||||
|
||||
Independent of the level — it reads the same `Orogen Gens/` images and `Region.json`, and writes
|
||||
`Content/World/Maps/`. See [`Spec/UI.md`](Spec/UI.md) for the map itself; `M` opens it in game, **Window → World
|
||||
Map** in the editor.
|
||||
|
||||
---
|
||||
|
||||
## What each artefact is, and whether it is tracked
|
||||
|
||||
| Path | What | In git? |
|
||||
| --- | --- | --- |
|
||||
| `RawContent/World/Templates/` | The painting, its legend, its plates. **The real source.** | tracked |
|
||||
| `RawContent/World/Planet.json` | The planet's manifest: circumference, cell size, pipeline constants | tracked |
|
||||
| `RawContent/World/Orogen Gens/` | Orogen's whole-planet exports | tracked — they came out of a browser session and cannot be regenerated headlessly |
|
||||
| `RawContent/World/Plan/` | `terrain plan` output | ignored, 4 s to rebuild |
|
||||
| `RawContent/World/Bake_NNN/` | `terrain bake` output: `planet_height.png`, the data maps, `overlay.json`, `meta.json` | ignored, gigabytes, ~2 h to rebuild |
|
||||
| `RawContent/World/RegionTiles/` | 98 tile sets: height + 3 weightmaps each | ignored, 208 MB, 2 min to rebuild |
|
||||
| `RawContent/World/MapArt/` | `layers.json` (tracked) and the rendered map layers (ignored, 6 s) | mixed |
|
||||
| `Content/Maps/L_World` | The level: 98 landscapes and ~500 external actor packages | tracked, through LFS |
|
||||
| `Content/World/Maps/` | `DA_WorldMap_L_World` and four map textures | tracked, through LFS |
|
||||
|
||||
The rule is: **an input is tracked, a product is not.** The one exception is `Orogen Gens/`, because a browser
|
||||
session is not something a script can redo.
|
||||
|
||||
---
|
||||
|
||||
## Traps
|
||||
|
||||
Every one of these has cost real time.
|
||||
|
||||
- **Never run a level build while the editor holds that level.** `--rebuild` empties the level *first* and
|
||||
saves *last*, so a lock detected late is indistinguishable from data loss — that is exactly what happened on
|
||||
2026-09-20, leaving twelve of ninety-eight tiles, all of them the polar ocean strip, so the level opened on
|
||||
71 km of sea and read as corrupted. Both `build_region.sh` and `create_region_world.py` now probe the file
|
||||
before anything is destroyed (D-71a).
|
||||
- **Run the long ones detached.** `create_world.py` and `terrain bake` must never go under a tool timeout. A
|
||||
killed bake is two hours.
|
||||
- **A script's arguments go *inside* the quoted `-script=` value.** Anything after it is parsed by the engine
|
||||
and silently never reaches Python — which looks exactly like a script that ignored its arguments.
|
||||
- **`create_world.py` empties whatever level it is handed.** This is why the numpy canvas is named
|
||||
`L_Canvas_Proto` and not `L_World`: a manifest still pointing at the latter would replace 98 landscapes with
|
||||
a 14 km square on one run, with no prompt (D-72).
|
||||
- **Tile files are named after the level.** Change `Region.json`'s `level` and all 98 tile sets look missing;
|
||||
rename the PNGs or 208 MB regenerates.
|
||||
- **The heights out of Orogen are art.** Fixed −5000…6000 ramp, land normalised to a browser preview's peak
|
||||
setting. Only `sea_scale` corrects any of it, and only the sea.
|
||||
- **A freshly opened `L_World` can look empty.** The landscapes are split into world-partition streaming
|
||||
proxies and none is loaded; what you see is the sky, the fog and the sea plane at Z 0, which from above looks
|
||||
convincingly like soft terrain. Load a region, or build HLODs.
|
||||
- **Do not trace for the ground in a commandlet.** Landscape collision is not reliably there, the sea plane's
|
||||
is, and a trace that hits the sea returns `0.0` rather than failing. Read the heightmap instead.
|
||||
|
||||
---
|
||||
|
||||
## What is not wired yet
|
||||
|
||||
Stated plainly so nobody goes looking for it.
|
||||
|
||||
- **Route C.** `terrain tiles` writes 5 km tiles of 2500 samples at 2 m with hillshade, flow, wear and deposit
|
||||
maps beside each one, and **nothing carries them into Unreal**. This is the path that ends with ground worth
|
||||
standing on; `generate_region_tiles.py` is the piece that changes.
|
||||
- **The overlay in Unreal.** `terrain overlay` proposes woodland, settlements and roads, the studio has a
|
||||
**Generate marks** button, and `overlay.json` carries every feature in world metres. `Region.json` reserves an
|
||||
`overlay` block and `region_manifest.py` has the slot for a per-tile mark map — **neither is implemented**. No
|
||||
forest, road or settlement is placed from one. It is left unbuilt rather than written blind because a carry
|
||||
that has never carried anything is a guess about a file format.
|
||||
- **Overlay marks on the map view.** They are in whole-cylinder normalised coordinates, exactly like the map
|
||||
art, so they transfer by the same `u,v`. This is the cheapest useful thing left in the list.
|
||||
- **Erosion-derived paint layers in the region.** Route A has no erosion pass and therefore no wear, flow or
|
||||
deposit map, which is why its paint rules are slope and altitude alone. `L_Canvas_Proto` — the legacy numpy
|
||||
pipeline — is still the only path that carries those maps into Unreal, and that is the only reason it is kept.
|
||||
- **The coastal detail pass**, which the shelf and shore platform have just unblocked. See `Terrain-Next.md`.
|
||||
Reference in New Issue
Block a user