tower / commit
feat(desert): mesas, dune fields, dry washes and salt pans, and a Capital with a waterfront
.vitest/json/output.json | 1 + README.md | 33 +- content/world.json | 29 +- decisions.md | 61 ++++ design.md | 42 ++- docs/screenshots/desktop-exterior-true.png | Bin 534371 -> 538823 bytes docs/screenshots/desktop-exterior.png | Bin 510088 -> 527653 bytes docs/screenshots/desktop-interior-dawn.png | Bin 466498 -> 492539 bytes docs/screenshots/desktop-interior-night.png | Bin 299304 -> 295555 bytes docs/screenshots/desktop-interior-overhead.png | Bin 509234 -> 509330 bytes docs/screenshots/desktop-interior-suncap.png | Bin 384016 -> 399943 bytes docs/screenshots/desktop-interior.png | Bin 846058 -> 863312 bytes docs/screenshots/desktop-map-sightline.png | Bin 917532 -> 939872 bytes docs/screenshots/desktop-map.png | Bin 907090 -> 973258 bytes docs/screenshots/desktop-reader.png | Bin 895119 -> 957878 bytes docs/screenshots/desktop-tour-deep.png | Bin 342244 -> 342461 bytes docs/screenshots/desktop-tour-falls.png | Bin 923859 -> 962167 bytes docs/screenshots/desktop-tour-girdle.png | Bin 675092 -> 742554 bytes docs/screenshots/desktop-tour-mesas.png | Bin 0 -> 806627 bytes docs/screenshots/desktop-tour-narrows.png | Bin 689037 -> 724197 bytes docs/screenshots/desktop-tour-pan.png | Bin 0 -> 701953 bytes docs/screenshots/desktop-tour-river.png | Bin 533479 -> 538717 bytes docs/screenshots/desktop-tour-ship.png | Bin 414025 -> 413989 bytes docs/screenshots/desktop-tour-squall.png | Bin 404218 -> 407719 bytes docs/screenshots/desktop-tour-storm.png | Bin 388165 -> 466188 bytes docs/screenshots/desktop-tour-summit.png | Bin 914702 -> 986118 bytes docs/screenshots/mobile-exterior.png | Bin 115025 -> 119350 bytes docs/screenshots/mobile-interior.png | Bin 264804 -> 266383 bytes docs/screenshots/mobile-map.png | Bin 239624 -> 242719 bytes docs/screenshots/mobile-panel.png | Bin 100781 -> 101248 bytes docs/screenshots/overview.png | Bin 2254247 -> 2588884 bytes index.html | 40 +++ src/terrain/compile.ts | 21 +- src/terrain/desert.ts | 422 +++++++++++++++++++++++++ src/terrain/field.ts | 111 ++++++- src/terrain/grid.ts | 179 +++++++++++ src/terrain/mountains.ts | 165 +--------- src/terrain/palette.ts | 28 +- src/terrain/service.ts | 4 +- src/terrain/tile.ts | 27 +- src/ui/app.ts | 113 ++++++- src/views/interior-model.ts | 26 +- src/views/interior.ts | 9 +- src/views/map.ts | 22 ++ src/views/scenic.ts | 88 +++++- src/views/terrain-lod.ts | 2 + src/world/schema.ts | 97 +++++- static/world.json | 27 +- test/desert.test.ts | 169 ++++++++++ test/terrain.test.ts | 39 ++- tools/screenshots.mjs | 44 ++- world-generation.md | 68 +++- 52 files changed, 1648 insertions(+), 219 deletions(-) diff --git a/.vitest/json/output.json b/.vitest/json/output.json new file mode 100644 index 0000000..29205e1 --- /dev/null +++ b/.vitest/json/output.json @@ -0,0 +1 @@ +{"numTotalTestSuites":1,"numPassedTestSuites":0,"numFailedTestSuites":1,"numPendingTestSuites":0,"numTotalTests":1,"numPassedTests":0,"numFailedTests":1,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1789675306635,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":[],"fullName":"probe","status":"failed","title":"probe","duration":30.458458000000064,"failureMessages":["Error: {\"inside\":{\"max\":1,\"mean\":0.855,\"over\":0.855},\"plateau\":{\"max\":1,\"mean\":0.214,\"over\":0.214},\"cut\":{\"max\":9.625,\"mean\":1.375,\"over\":0.584},\"salt\":{\"max\":1,\"mean\":0.006,\"over\":0.006},\"sand\":{\"max\":1,\"mean\":0.665,\"over\":0.652},\"bench\":{\"max\":1,\"mean\":0.003,\"over\":0.003},\"samples\":[{\"h\":951,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":935,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":976,\"rock\":0.01,\"salt\":0,\"desert\":1},{\"h\":910,\"rock\":0,\"salt\":0,\"desert\":1},{\"h\":1363,\"rock\":1,\"salt\":0,\"desert\":1}]}\n at /Users/pavel/code/tower/test/tmp2.test.ts:21:9\n at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:1628:35\n at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:2783:26\n at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3319:20\n at new Promise (<anonymous>)\n at runWithCancel (file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3314:10)\n at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3299:20\n at new Promise (<anonymous>)\n at runWithTimeout (file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3257:10)\n at file:///Users/pavel/code/tower/node_modules/vitest/dist/chunks/run.CQOUYP-x.js:3876:64"],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1789675310914,"endTime":1789675310944.4585,"status":"failed","message":"","name":"/Users/pavel/code/tower/test/tmp2.test.ts"}]} \ No newline at end of file diff --git a/README.md b/README.md index dd52c03..2617f7e 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,14 @@ over it, feature editing for fourteen kinds (points, lines, polygons and bands), localStorage autosave, backup and static exports, import, permalinks, and the sightline probe. -**In progress: [world-generation.md](world-generation.md).** Phases 1 to 4 are built — +**In progress: [world-generation.md](world-generation.md).** Phases 1 to 5 are built — the terrain is no longer baked into images by a build step; every mountain, lake, sea, river, desert and forest is a feature the browser turns into ground on demand, in web workers, from a metre underfoot to the whole world. Mountain ranges are summits joined by ridges and cut by water into valleys; lakes, seas and rivers are water with waves, flow and waterfalls; Tower publishes a weather plan, and ships sail the ring seas -by it. Phases 5–8 add deserts, forests of individual trees, settlements and roads. +by it. The Dry Reach has mesas, dune fields, dry washes and salt pans. Phases 6–8 add +forests of individual trees, settlements and roads. - [design.md](design.md) — canon, physics and application architecture - [world-generation.md](world-generation.md) — how the world is generated, and the plan @@ -66,7 +67,11 @@ and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI the point: a shot taken this way fails when the UI fails, so a black canvas ends up in the picture instead of hiding behind a passing unit test. -The whole tour takes about **a minute and a half**, and waits on the world being +A scene the tour photographs is held at one moment with the water's clock stopped too, so +the sea in a shot is the same sea every run and a divergence means a real change. +`--only <ids>` retakes a few shots and leaves the sheet alone. + +The whole tour takes about **three minutes**, and waits on the world being generated rather than on fixed delays: a shot is taken once the terrain has compiled, the map is drawn, and every tile of ground the view wants has arrived. It is kept light on the machine: the world is **compiled once** for the whole tour and every shot starts from that @@ -90,6 +95,8 @@ another machine will show a large one-off divergence rather than a regression. | [tour-summit](docs/screenshots/desktop-tour-summit.png) | scenic tour: round Snowcrown's summit, 4,300 m | | [tour-river](docs/screenshots/desktop-tour-river.png) | scenic tour: down the Long Water to its 167 m fall into The Deep | | [tour-squall](docs/screenshots/desktop-tour-squall.png) | scenic tour: a ship caught out by a gale Tower gave no warning of | +| [tour-mesas](docs/screenshots/desktop-tour-mesas.png) | scenic tour: past the mesas of the Dry Reach, strata lining up across all of them | +| [tour-pan](docs/screenshots/desktop-tour-pan.png) | scenic tour: over a salt pan, ringed by the shorelines of a lake that never stood | | [tour-narrows](docs/screenshots/desktop-tour-narrows.png) | scenic tour: through The Narrows, the one gap in all three ridges | | [tour-deep](docs/screenshots/desktop-tour-deep.png) | scenic tour: out from the Capital over The Deep | | [tour-storm](docs/screenshots/desktop-tour-storm.png) | scenic tour: the published storm on the Sunward Sea, every ship in harbour | @@ -138,9 +145,10 @@ regressions, not things that were already broken. ``` npm install npm run dev # http://localhost:5173, plus a LAN URL and a QR code for it -npm test # 167 tests; canon figures are pinned to design.md +npm test # 178 tests; canon figures are pinned to design.md npm run build npm run screenshots # drive the app and rephotograph docs/screenshots/ +npm run screenshots -- --only tour-storm # just those shots, for working on one view ``` `npm run dev` binds to every interface (`vite --host`) and prints a scannable QR @@ -268,6 +276,11 @@ caption — still **walks you there**, which is the only way to cross the world one move: most of it is beyond the haze horizon from any given spot, so travelling is more use than turning. +**The desert is a desert.** The Dry Reach rings the world with tiers of rock standing over +dune fields, hills, dry washes routed the way water would have routed them, and salt pans +in its lowest ground with the shorelines of lakes that never stood stepped around them. +None of it is a remnant: Tower had seen deserts on Earth and built one. + **The water is water.** Lakes, seas and rivers are surfaces at their own levels, with waves sized from each body's wind and fetch, rivers running downstream, and rapids white. A river that cannot slope falls: the Long Water drops 680 m off the range into a gorge, @@ -365,6 +378,18 @@ sea (it is published the moment you add it), **revise** an event from now with a the storm eased because someone foolish went sailing — delete one, and set how many days the plan takes to repeat. None of this regenerates the ground. +Select a **settlement** to set its size — hamlet, village, town, city or station — and how +far it reaches across. A settlement is a point with an extent, not a traced outline: Tower +lays the streets out inside it. The Capital is 20 km across, centred on the axial midpoint +of the world, which is just wide enough to put its spinward edge on The Deep's shore. + +Select the **desert** to lay out its landforms: how high its dunes stand, how far apart, +and which way Tower blows the sand; how much of it is rocky hills; how tall its plateaus +are, how much of the ground they cover, and how many cliff bands they are cut into; and how +much past water Tower quotes there — dry washes, how many salt pans, and whether they are +ringed by the shorelines of lakes that never stood. The hint underneath says what was +actually made: "24% standing rock, 37% dune field, 51% cut by washes, 4 salt pans." + A **Region** is a label, and it can also be ground Tower raised: give it an uplift and everything inside it is lifted to that height above the open ground, wherever that is higher than what is there. The Uplands are one. diff --git a/content/world.json b/content/world.json index 6a3192b..65ee6bc 100644 --- a/content/world.json +++ b/content/world.json @@ -597,7 +597,27 @@ "zTo": 218000 }, "upliftM": 780, - "edgeM": 20000 + "edgeM": 20000, + "dunes": { + "coverage": 0.5, + "heightM": 80, + "spacingM": 1100, + "directionDeg": 0 + }, + "hills": { + "coverage": 0.3, + "heightM": 160 + }, + "plateaus": { + "coverage": 0.28, + "heightM": 520, + "terraces": 3 + }, + "pastWater": { + "washes": 0.7, + "saltPans": 4, + "shorelines": true + } }, { "id": "the-uplands", @@ -846,16 +866,17 @@ "id": "the-capital", "name": "The Capital", "blurb": "", - "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. On The Deep's antispinward shore, so the city looks across 32 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate.", + "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. 20 km across, so the city holds the centre of the world and still reaches The Deep: its spinward edge is the waterfront, and from there it looks across 18 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate.", "public": true, "seed": 1711426401, "kind": "settlement", "geometry": { "type": "point", - "theta": 0.02, + "theta": 0.04, "z": 270000 }, - "size": "city" + "size": "city", + "radiusM": 10000 }, { "id": "the-spindle", diff --git a/decisions.md b/decisions.md index 2b7af5e..982e76e 100644 --- a/decisions.md +++ b/decisions.md @@ -2238,3 +2238,64 @@ honest about its warnings is also a better story than a fleet that vanishes on c readers were the default and `?writer=1` enabled editing. The app has always done the opposite — writer by default, `?reader=1` to preview — and the README said so. The docs now match the code; D-011's reasoning is unchanged. + +--- + +## D-069 — The desert is quoted, not remembered + +**Built.** `world-generation.md` phase 5; §20 there has the detail. + +**Tower laid out the whole grammar of a desert, dry channels included.** The Dry Reach now +has tiered plateaus and mesas over sand, dune fields whose crests run across the wind Tower +sends, rocky hills, a network of dry washes, salt pans in its low ground, and the shoreline +benches of lakes that never stood. None of it is history — §0.1 — and the washes are routed +by the same drainage the ranges erode with precisely because a channel network that ignores +drainage is the one thing that would look wrong. + +**Two findings worth keeping.** A flat desert has no drainage: water fans out from every +edge and nothing gathers, so Tower crowns the Reach 150 m in its middle and the washes run +off it. And widening a one-cell channel by blurring destroys it — dilate first, blur after. + +**Terracing belongs to the tiles, not to the compile.** The grid stores how far up the +plateau the ground stands; the tile turns that into flat tops and steep risers at its own +detail. The other way round, every cliff band is one grid cell wide, which is a ramp. + +**Tower does not build rock across water it has already routed.** Mesas dropped across The +Narrows turned the world's whole drainage into a kilometre-deep slot. Rivers carry a 6.5 km +corridor clear of plateau rock, which reads as the sand-and-gravel valley a river makes +through a mesa field. The consequence recorded: the Long Water's canyon through the lower +Dry Reach now runs about 780 m deep where the old test expected under 700, because the rim +above it is the desert's rock as often as the ridge's. + +**Materials gained `salt`, and rock is now claimed rather than inferred.** Bare rock came +only from mountain relief before; it now comes from the desert's plateaus and wash floors +and from any steep desert face, since sand does not stand at 60°. The ground shader lays +strata by elevation — a level in Tower is a constant radius — so the bands line up across +separate mesas, which is what makes a mesa field read as one formation. + +--- + +## D-070 — The Capital is a footprint, and it reaches the water + +**Built** alongside phase 5, at the author's suggestion. + +**The Capital was a dot 11 km inland of the lake it was said to stand on.** design.md §1.11 +described it as sitting on The Deep's antispinward shore; the generated water starts +10.75 km spinward of its marker, on ground falling from 300 m to the water's 120 m. The fix +the author chose is the better one: make the city big enough to be both things at once. + +**So a settlement is now a point with an extent** — a size and a radius — rather than a +marker alone. The author places where a town stands and how far it reaches; Tower lays the +streets and buildings out inside that footprint (phase 7). The map draws the extent as a +ring around the marker, and each size carries the usual radius a new settlement starts from. + +**The Capital is 20 km across, centred on the axial midpoint of the world** — the only +centre a cylinder has — which is just wide enough that its spinward edge is the waterfront. +It holds the centre of the world and a shore on The Deep, and nothing had to move to make +that true but 2 km of marker. + +**A canon figure corrected on the way:** §1.11 said the far shore was "32 km away and +2.6 km above eye level". Thirty-two kilometres around the cylinder would stand 9.9 km above +the eye; 2.6 km is the figure for the 18 km of water The Deep actually fills, which is what +the crossing is. The outline is 154 × 32 km; a flooded basin's water is narrower than its +outline, as it should be. diff --git a/design.md b/design.md index e421164..bbac896 100644 --- a/design.md +++ b/design.md @@ -547,16 +547,32 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4): | 0–42 km | **The Sunward Sea** | ring ocean at the foot of the sun cap | | 55–140 km | **the range** | Westwall, The Sisters, **Snowcrown** (4,300 m), Eastwall — the named high points of **The Girdle**, which rings the world at z 90 km (D-051) | | 105–150 km | **The Uplands** | ground Tower raised to about 1,000 m, ringing the world, so the rivers off the range can run down to The Narrows (D-064) | -| 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface | +| 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface: mesas over dune fields, dry washes, and salt pans ringed by shorelines (D-069) | | 176/190/203 km | **three ridges** | ringing, concentric — and **The Narrows** is the only breach in all three | | 214–430 km | **Greatwood** | forest belt | | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around | -| 270 km | **The Capital / The Spindle** | axial midpoint, on The Deep's antispinward shore | +| 270 km | **The Capital / The Spindle** | axial midpoint; 20 km across, its spinward edge on The Deep's shore (D-070) | | 496–540 km | **The Deepward Sea** | ring ocean at the foot of the far cap | Both ring seas carry ninety sailing ships each, and Tower's published weather plan (D-067) runs a 28-day cycle of storms, gales and a calm over them and The Deep. +#### What the desert is made of + +The Dry Reach is not sand with some rock in it; it is the whole grammar of a desert, +because Tower had seen deserts (D-069). Tiers of rock stand over dune fields whose crests +run across the wind Tower sends there; rocky hills break up the open ground; a network of +**dry washes** is routed by the same drainage the ranges are eroded by, because a channel +network that ignores drainage is the one thing that would look wrong. In the lowest ground +there are **salt pans**, dead flat, ringed by the **shoreline benches** of lakes that never +stood. The middle of the Reach stands 150 m above its rim so that the washes have somewhere +to run, and Tower keeps its rock 6.5 km clear of the rivers it had already routed — a mesa +across The Narrows would leave the world's drainage in a slot. + +Rock is laid in beds, and a bed in Tower is a **constant radius**, so the strata line up +across separate mesas however far apart they stand. That is what makes a field of them read +as one formation eroded out of one plateau. + #### Why the desert rings **On a cylinder you cannot go around a band.** Every desert on Earth can be @@ -596,9 +612,16 @@ under them is mostly cut — The Narrows about 500 m deep — and built up by no #### The Deep is oriented for the view Long down the axis and narrow around it, so you cross it the **short** way — around -θ, where the floor curves up. From the Capital the far shore is 32 km away and -stands **2.6 km above eye level**. Crossing it lengthwise instead would be dead -flat. The orientation is the whole point. +θ, where the floor curves up. The authored outline is 154 × 32 km and the water fills about +18 km of that width; from the Capital's waterfront the far shore is that 18 km away and +stands **2.6 km above eye level**. Crossing it lengthwise instead would be dead flat. The +orientation is the whole point. + +**The Capital is a footprint, not a dot** (D-070). It is 20 km across, centred on the axial +midpoint — the only centre a cylinder has — which is just wide enough that its spinward edge +is on The Deep's shore, 9.8 km out, where the ground has come down from 300 m to the +water's 120 m. So the city holds both the middle of the world and a waterfront. The streets +and buildings inside that footprint are phase 7's. It is also the world's flywheel: 19× the diurnal heat storage of bare ground, and 5.9 days at full power to move its temperature one kelvin. Tower still supplies @@ -672,6 +695,8 @@ src/ main thread and under vitest alike (world-generation.md §5) compile.ts the world compile: shapes, river profiles, lake levels mountains.ts summits, ridge graphs, steady-state erosion, pinned summits + grid.ts grids on the surface: drainage, blur, sampling — shared by the above + desert.ts plateaus and mesas, dry washes, salt pans, abandoned shorelines water.ts wind seas, fetch, river speed and foam: the water's physics sea-state.ts every lake and sea's waves and whitecaps now, from the weather ships.ts ship lanes, positions from the clock, the wave sum they ride @@ -1079,7 +1104,8 @@ Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, til ground, the status dialog, keyboard flight, the map drawn from the features. Phase 2 ✅ (D-065): mountain ranges of summits, ridges and eroded valleys, and summit editing. Phase 3 ✅ (D-066): water as surfaces with waves and flow, and waterfalls that lean. Phase 4 ✅ -(D-067): Tower's weather plan with revisions, a day on the clock, and ships. Phases 5–8 -bring deserts, forests, settlements and roads — procedural scenery this section used to -list as out of scope. Clouds and rain stay +(D-067): Tower's weather plan with revisions, a day on the clock, and ships. Phase 5 ✅ +(D-069): the desert's mesas, dunes, washes, salt pans and shorelines. Phases 6–8 bring +forests, settlements and roads — procedural scenery this section used to list as out of +scope. Clouds and rain stay undrawn (D-049). diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index a5a49f2..0648b8b 100644 Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png index f41595a..a62bc3e 100644 Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png index f67ad41..0113287 100644 Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index 1a7b366..47bdd3f 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png index d1cee16..ee8670c 100644 Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index 0e878fe..b0119b5 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png index e2ec16e..3715955 100644 Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png index 8e95896..b39ed84 100644 Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png index 5719dbf..6740459 100644 Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png index 6050db2..5e9f17c 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png index 16cfcfe..91fe3d1 100644 Binary files a/docs/screenshots/desktop-tour-deep.png and b/docs/screenshots/desktop-tour-deep.png differ diff --git a/docs/screenshots/desktop-tour-falls.png b/docs/screenshots/desktop-tour-falls.png index 152abd5..4b2fbf5 100644 Binary files a/docs/screenshots/desktop-tour-falls.png and b/docs/screenshots/desktop-tour-falls.png differ diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png index 2e4666d..aa2d2e3 100644 Binary files a/docs/screenshots/desktop-tour-girdle.png and b/docs/screenshots/desktop-tour-girdle.png differ diff --git a/docs/screenshots/desktop-tour-mesas.png b/docs/screenshots/desktop-tour-mesas.png new file mode 100644 index 0000000..36345a0 Binary files /dev/null and b/docs/screenshots/desktop-tour-mesas.png differ diff --git a/docs/screenshots/desktop-tour-narrows.png b/docs/screenshots/desktop-tour-narrows.png index 5308203..f3daaba 100644 Binary files a/docs/screenshots/desktop-tour-narrows.png and b/docs/screenshots/desktop-tour-narrows.png differ diff --git a/docs/screenshots/desktop-tour-pan.png b/docs/screenshots/desktop-tour-pan.png new file mode 100644 index 0000000..550b3dc Binary files /dev/null and b/docs/screenshots/desktop-tour-pan.png differ diff --git a/docs/screenshots/desktop-tour-river.png b/docs/screenshots/desktop-tour-river.png index 102d240..45cefda 100644 Binary files a/docs/screenshots/desktop-tour-river.png and b/docs/screenshots/desktop-tour-river.png differ diff --git a/docs/screenshots/desktop-tour-ship.png b/docs/screenshots/desktop-tour-ship.png index 451cdae..b32a83b 100644 Binary files a/docs/screenshots/desktop-tour-ship.png and b/docs/screenshots/desktop-tour-ship.png differ diff --git a/docs/screenshots/desktop-tour-squall.png b/docs/screenshots/desktop-tour-squall.png index 5fe4344..b023fa5 100644 Binary files a/docs/screenshots/desktop-tour-squall.png and b/docs/screenshots/desktop-tour-squall.png differ diff --git a/docs/screenshots/desktop-tour-storm.png b/docs/screenshots/desktop-tour-storm.png index d057f13..958a624 100644 Binary files a/docs/screenshots/desktop-tour-storm.png and b/docs/screenshots/desktop-tour-storm.png differ diff --git a/docs/screenshots/desktop-tour-summit.png b/docs/screenshots/desktop-tour-summit.png index bdcf9a9..449ed59 100644 Binary files a/docs/screenshots/desktop-tour-summit.png and b/docs/screenshots/desktop-tour-summit.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index 477746b..4275098 100644 Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png index a92edb0..c291002 100644 Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png index 1c95243..d2d9f35 100644 Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png index 2945722..8e6689a 100644 Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png index 9508aad..41f9b32 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/index.html b/index.html index 91a9fa2..bbf48ea 100644 --- a/index.html +++ b/index.html @@ -174,6 +174,46 @@ <input id="f-wind-speed" type="range" min="0" max="30" step="1" /></label> <p class="hint" id="f-sea-state"></p> </div> + <div id="f-settlement" hidden> + <label>Size <select id="f-size"> + <option value="hamlet">Hamlet</option> + <option value="village">Village</option> + <option value="town">Town</option> + <option value="city">City</option> + <option value="station">Station</option> + </select></label> + <label>Across, km <span class="dim">— Tower lays the streets out inside this</span> + <input id="f-extent" type="number" min="0.1" max="80" step="0.5" /></label> + </div> + <div id="f-desert" hidden> + <p class="sub">Dunes <span class="dim">— crests run across the wind Tower sends here</span></p> + <div class="pair"> + <label>Height, m <input id="f-dune-height" type="number" min="0" max="300" step="5" /></label> + <label>Spacing, m <input id="f-dune-spacing" type="number" min="50" max="4000" step="50" /></label> + </div> + <div class="pair"> + <label>Blowing toward, ° <input id="f-dune-dir" type="number" min="0" max="359" step="15" /></label> + <label>Coverage <input id="f-dune-cover" type="number" min="0" max="1" step="0.05" /></label> + </div> + <p class="sub">Rock</p> + <div class="pair"> + <label>Hills, m <input id="f-hill-height" type="number" min="0" max="600" step="10" /></label> + <label>Hill coverage <input id="f-hill-cover" type="number" min="0" max="1" step="0.05" /></label> + </div> + <div class="pair"> + <label>Plateaus, m <input id="f-plat-height" type="number" min="0" max="1500" step="20" /></label> + <label>Coverage <input id="f-plat-cover" type="number" min="0" max="1" step="0.05" /></label> + </div> + <label>Terraces <span class="dim">— cliff bands between flat tops</span> + <input id="f-plat-terraces" type="number" min="1" max="8" step="1" /></label> + <p class="sub">Past water <span class="dim">— marks of water that never ran here</span></p> + <div class="pair"> + <label>Washes <input id="f-washes" type="number" min="0" max="1" step="0.1" /></label> + <label>Salt pans <input id="f-pans" type="number" min="0" max="12" step="1" /></label> + </div> + <label class="row"><input id="f-shorelines" type="checkbox" /> Shorelines around the pans</label> + <p class="hint" id="f-desert-state"></p> + </div> <label>ID <span class="dim">— permalink target; follows the name until you edit it</span> <input id="f-id" type="text" /></label> <label>Public blurb <textarea id="f-blurb" rows="2" placeholder="Shown to readers."></textarea></label> diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts index 1f56dfa..656c3a3 100644 --- a/src/terrain/compile.ts +++ b/src/terrain/compile.ts @@ -13,8 +13,9 @@ * the answer. */ import { RADIUS_M } from '../canon/constants.js' -import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js' +import type { Desert as DesertFeature, Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js' import { type CMountainGroup, compileMountains } from './mountains.js' +import { type CDesert, compileDeserts } from './desert.js' import { FALL_MIN_M, foamOf, manningSpeed } from './water.js' import { hash3 } from './noise.js' import { @@ -108,6 +109,8 @@ export interface CompiledWorld { seed: number ground: { baseM: number; reliefM: number } uplifts: CUplift[] + /** The deserts' compiled landforms: plateaus, washes, salt pans (desert.ts). */ + deserts: CDesert[] mountains: CMountainGroup[] basins: CBasin[] rivers: CRiver[] @@ -132,7 +135,7 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com const cw: CompiledWorld = { seed: world.seed, ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM }, - uplifts: [], mountains: [], basins: [], rivers: [], falls: [], + uplifts: [], deserts: [], mountains: [], basins: [], rivers: [], falls: [], } const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed) @@ -155,6 +158,20 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com }) } + /** + * The deserts' own landforms stand on the shelf the uplift above raised, so they are + * compiled after it and before the ranges — which may stand on a desert's plateau. + */ + // The shelf a desert's landforms stand on: the open ground and the uplift under it, so a + // salt pan's floor and its shorelines are elevations in the world rather than in the sand. + const shelf = prepare({ ...cw, deserts: [], mountains: [], basins: [], rivers: [] }) + cw.deserts = compileDeserts( + world.features.filter((f): f is DesertFeature => f.kind === 'desert'), world.seed, seedOf, onStep, + // The rivers, as authored: the desert keeps its plateaus clear of them (desert.ts). + world.features.flatMap((f) => (f.kind === 'river' && f.geometry.type === 'polyline' + ? [smoothLine(f.geometry.points, 400)] : [])), + (z, a) => shelf.sample(a / RADIUS_M, z, 1).h) + // Authored tarns keep a shelf of ground in whatever range they sit in (mountains.ts). const benches = world.features.flatMap((f) => { if (f.kind !== 'lake' || f.basin !== 'bowl') return [] diff --git a/src/terrain/desert.ts b/src/terrain/desert.ts new file mode 100644 index 0000000..f31ab89 --- /dev/null +++ b/src/terrain/desert.ts @@ -0,0 +1,422 @@ +/** + * The desert: plateaus and mesas, dry washes, salt pans, and the shorelines of lakes that + * never were. See world-generation.md §4.5. + * + * Nothing here is a remnant (§0.1). Tower built a desert because it had seen deserts, so + * it laid out the whole grammar of one: tiers of rock standing over sand, a drainage of dry + * channels routed the way water would have routed them, flats of salt in the lowest ground, + * and bathtub rings around them at the levels a lake would have stood. The washes are cut + * by the same priority-flood routing the ranges erode with (grid.ts) — because a channel + * network that does not obey drainage is the one thing that would look wrong. + * + * What is compiled here is what a tile cannot work out for itself: the plateau field, the + * wash network, and where the pans and their shorelines are. Dunes and rocky hills are + * noise and are added by the tiles (field.ts), which is also what keeps them crisp — the + * plateau's height fraction is compiled smooth and the **terraces are applied as the tile + * samples it**, so a cliff band is as sharp as the tile is fine, not as sharp as a 250 m + * grid allows. + */ +import { type Grid, Heap, blur, drainage, sampleGrid } from './grid.js' +import { type CylPoint, cylPoint, fbm, hash3, mulberry32, ridged } from './noise.js' +import { + type Box, LineIndex, type Line, type Nearest, nearestA, nearestOnSegments, shapeBox, shapeOf, + signedDistance, type Shape, +} from './shapes.js' +import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' +import type { Desert } from '../world/schema.js' + +/** Cell size of the desert grid. Fine enough for a wash, coarse enough for 22,000 km². */ +export const DESERT_CELL_M = 250 + +export interface CPan { + z: number + a: number + /** Elevation of the pan floor above the datum. */ + levelM: number + radiusM: number +} + +export interface CDesert { + id: string + shape: Shape + box: Box + grid: Grid + /** 0..1 how much of this cell is desert at all: it fades out over the edge, and is 0 + * in the corridors kept clear for the rivers. */ + inside: Float32Array + /** + * The desert's own broad shape, metres: it crowns toward its middle, so the washes have + * somewhere to run. Compiled rather than computed twice, so the routing and the drawn + * ground are the same surface by construction. + */ + base: Float32Array + /** 0..1 of the way up the plateau, smooth; the tiles terrace it. */ + plateau: Float32Array + /** How deep the washes cut here, metres. */ + cut: Float32Array + /** 0..1 salt crust: the pan floors, and the elevation each pan's floor stands at. */ + salt: Float32Array + panLevelM: Float32Array + /** 0..1 how much of the ground here is a shoreline bench, and the pan's elevation. */ + bench: Float32Array + benchLevelM: Float32Array + /** Where the dunes are, 0..1: the open sand, away from rock. */ + sand: Float32Array + /** The salt pans Tower laid out, for anything that wants to look at one. */ + pans: CPan[] + plateauHeightM: number + terraces: number + hillsHeightM: number + hillsCoverage: number + duneHeightM: number + duneSpacingM: number + duneDirRad: number + /** Whole dune wavelengths around the circumference, so the field closes on itself. */ + dunePeriods: number + duneCoverage: number + seed: number +} + +/** Height of the shoreline benches above a pan, and how many stand there. */ +export const BENCH_STEP_M = 20 +export const BENCH_COUNT = 3 + +/** + * The terrace profile: flat tops with steep risers between them. `p` is how far up the + * plateau this ground stands, 0..1; the result is the same range, in `terraces` tiers. + */ +export function terrace(p: number, terraces: number): number { + const x = Math.min(1, Math.max(0, p)) * terraces + const tier = Math.min(terraces - 1, Math.floor(x)) + const f = x - tier + // The riser occupies the middle of each tier's band, so tops are flat and faces are steep. + const riser = f <= 0.42 ? 0 : f >= 0.78 ? 1 : (f - 0.42) / 0.36 + const smooth = riser * riser * (3 - 2 * riser) + return (tier + smooth) / terraces +} + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))) + return t * t * (3 - 2 * t) +} + +/** The threshold on a 0..1 field that leaves `coverage` of it above, from a sample of it. */ +function quantile(samples: number[], coverage: number): number { + if (!samples.length) return 1 + const sorted = [...samples].sort((x, y) => x - y) + const i = Math.min(sorted.length - 1, Math.max(0, Math.round((1 - coverage) * (sorted.length - 1)))) + return sorted[i]! +} + +/** How far the middle of a desert stands above its rim, so that washes have a fall. */ +export const CROWN_M = 150 + +/** Drainage area past which a wash is a channel: fifteen square kilometres of desert. */ +const WASH_AREA_M2 = 1.5e7 + +/** + * `corridors` are lines the desert keeps its rock away from: the rivers Tower had already + * routed through here. A mesa dropped across The Narrows would leave the world's whole + * drainage cutting a kilometre-deep slot through it, which is not what Tower would build. + */ +export function compileDeserts( + features: Desert[], worldSeed: number, seedOf: (f: Desert) => number, + onStep: (label: string) => void = () => {}, corridors: Line[] = [], + landAt: (z: number, a: number) => number = () => 0, +): CDesert[] { + const out: CDesert[] = [] + for (const f of features) { + const shape = shapeOf(f.geometry) + if (!shape) continue + out.push(compileDesert(f, shape, seedOf(f), worldSeed, onStep, corridors, landAt)) + } + return out +} + +/** + * How wide a river's corridor is kept clear of plateau rock: a valley of sand and gravel + * either side of the water, which is also what a river crossing a mesa field looks like. + */ +export const CORRIDOR_M = 6500 + +function compileDesert( + f: Desert, shape: Shape, seed: number, worldSeed: number, onStep: (label: string) => void, + corridors: Line[], landAt: (z: number, a: number) => number, +): CDesert { + const box = shapeBox(shape) + const ring = shape.type === 'band' + const margin = f.edgeM * 0.5 + const z0 = box.z0 - margin + const a0 = ring ? 0 : box.a0 - margin + const na = ring + ? Math.round(CIRCUMFERENCE_M / DESERT_CELL_M) + : Math.max(4, Math.ceil((box.a1 + margin - a0) / DESERT_CELL_M) + 1) + // A ring's cells divide the circumference exactly, or the grid has a seam where it wraps. + const cell = ring ? CIRCUMFERENCE_M / na : DESERT_CELL_M + const nz = Math.max(4, Math.ceil((box.z1 + margin - z0) / cell) + 1) + const grid: Grid = { z0, a0, cell, nz, na, ring } + const n = nz * na + const rand = mulberry32(hash3(worldSeed, seed, 0xd35e27)) + + const at = (k: number): { z: number; a: number; p: CylPoint } => { + const i = Math.floor(k / na), j = k % na + const z = z0 + i * cell, a = a0 + j * cell + return { z, a, p: cylPoint(a / RADIUS_M, z) } + } + + // How much of each cell is desert at all: it fades out over the outer half of the edge. + onStep(`Quoting the deserts of Earth in ${f.name}`) + const inside = new Float32Array(n) + const plateauNoise = new Float32Array(n) + const samples: number[] = [] + const indexes = corridors.map((line) => new LineIndex(line, CORRIDOR_M)) + const near: Nearest = { d: 0, seg: 0, t: 0 } + const clear = (z: number, a: number): number => { + let m = 1 + for (let i = 0; i < corridors.length; i++) { + const segs = indexes[i]!.near(z, a) + if (!segs.length) continue + nearestOnSegments(corridors[i]!, z, a, segs, near) + m = Math.min(m, smoothstep(CORRIDOR_M * 0.45, CORRIDOR_M, near.d)) + } + return m + } + for (let k = 0; k < n; k++) { + const { z, a, p } = at(k) + // Landforms begin at the desert's boundary and are fully themselves a few km in. + inside[k] = smoothstep(0, f.edgeM * 0.35, signedDistance(shape, z, a)) + if (inside[k]! <= 0) continue + inside[k]! *= clear(z, a) + if (inside[k]! <= 0) continue + // Broad blotches the size of a mesa group, warped so their edges are not noise-smooth. + const warp = fbm(seed ^ 0x11, p, 9000, 2, cell) - 0.5 + plateauNoise[k] = fbm(seed ^ 0x27, p, 26_000, 4, cell) + warp * 0.12 + if (k % 7 === 0) samples.push(plateauNoise[k]!) + } + + /** + * The plateau field. A threshold picked to leave the authored coverage above it, and a + * narrow band of noise either side of it: that band's width is what makes the cliffs + * steep, since the whole rise happens across it. + */ + /** + * The crown. A desert that is dead flat has no drainage at all — water on it fans out + * from wherever it lands and nothing gathers into a channel — so Tower raised the middle + * of the Reach a little, and everything runs off it to the edges. + */ + const half = ring ? (box.z1 - box.z0) / 2 : Math.max(500, Math.min(box.z1 - box.z0, box.a1 - box.a0) / 2) + const base = new Float32Array(n) + for (let k = 0; k < n; k++) { + if (inside[k]! <= 0) continue + const { z, a, p } = at(k) + const t = Math.min(1, Math.max(0, signedDistance(shape, z, a) / half)) + base[k] = CROWN_M * (1 - (1 - t) * (1 - t)) * inside[k]! + + (fbm(layer(seed, 8), p, 7000, 3, cell) - 0.5) * 40 * inside[k]! + } + + const thr = quantile(samples, f.plateaus.coverage) + const plateau = new Float32Array(n) + for (let k = 0; k < n; k++) { + if (inside[k]! <= 0) continue + plateau[k] = smoothstep(thr, thr + 0.05, plateauNoise[k]!) * inside[k]! + } + + /** + * The surface the washes are routed over: the crown, the terraced rock and the rocky + * hills, all exactly as the tiles make them (field.ts `desert`), so a wash is cut into + * the ground that is actually drawn. + */ + const surface = new Float32Array(n) + for (let k = 0; k < n; k++) { + if (inside[k]! <= 0) continue + const { z, a, p } = at(k) + // Above the datum, shelf and all: a pan's floor is a height in the world, and the + // benches around it are levels in the world, so both are measured from there. + surface[k] = landAt(z, a) + base[k]! + terrace(plateau[k]!, f.plateaus.terraces) * f.plateaus.heightM + + hillsAt(f, seed, p, cell, plateau[k]!, inside[k]!) + } + + const cut = new Float32Array(n) + const salt = new Float32Array(n) + const panLevelM = new Float32Array(n) + const bench = new Float32Array(n) + const benchLevelM = new Float32Array(n) + const pans: CPan[] = [] + + const work = { + receiver: new Int32Array(n), stack: new Int32Array(n), reached: new Uint8Array(n), + key: new Float64Array(n), heap: new Heap(n), + } + /** Off the desert, the ground carries the water away: those cells are the outlets. */ + const outlet = new Uint8Array(n) + for (let k = 0; k < n; k++) if (inside[k]! <= 0.02) outlet[k] = 1 + // A ring desert has no outside along theta, so its two ends are the way out. + if (ring) for (let j = 0; j < na; j++) { outlet[j] = 1; outlet[(nz - 1) * na + j] = 1 } + + if (f.pastWater.washes > 0) { + onStep(`Cutting riverbeds for rivers that never ran in ${f.name}`) + const top = drainage(grid, surface, outlet, work) + // Drainage area, accumulated from the far end of the stack back down to the outlets. + const area = new Float64Array(n).fill(cell * cell) + for (let s = top - 1; s >= 0; s--) { + const k = work.stack[s]! + const r = work.receiver[k]! + if (r >= 0) area[r]! += area[k]! + } + for (let k = 0; k < n; k++) { + if (inside[k]! <= 0 || area[k]! < WASH_AREA_M2) continue + // A wash deepens with what it would drain, slowly: a big one is tens of metres. + const depth = 4 + 14 * Math.log10(area[k]! / WASH_AREA_M2) + cut[k] = Math.min(55, depth) * f.pastWater.washes * inside[k]! + } + /** + * A wash is wider than one cell and flat-floored. Widening is a *dilation* — the + * deepest cut within a couple of cells — and only then a blur for the banks: blurring + * a one-cell channel straight away had averaged its depth away to nothing. + */ + const wide = new Float32Array(n) + const reach = 2 + for (let i = 0; i < nz; i++) { + for (let j = 0; j < na; j++) { + let m = 0 + for (let di = -reach; di <= reach; di++) { + const ii = i + di + if (ii < 0 || ii >= nz) continue + for (let dj = -reach; dj <= reach; dj++) { + const jj = ring ? ((j + dj) % na + na) % na : j + dj + if (!ring && (jj < 0 || jj >= na)) continue + m = Math.max(m, cut[ii * na + jj]!) + } + } + wide[i * na + j] = m + } + } + const banked = blur(grid, wide, 1) + for (let k = 0; k < n; k++) cut[k] = Math.max(cut[k]!, banked[k]!) + } + + if (f.pastWater.saltPans > 0) { + onStep(`Laying salt in the low ground of ${f.name}`) + const floor = new Float32Array(n) + for (let k = 0; k < n; k++) floor[k] = surface[k]! - cut[k]! + /** + * Where a pan goes: the lowest ground, spaced out, so the set pieces land where the + * desert is deepest rather than on top of each other. A pan is a disc — Tower is + * placing these, not discovering them (§4.5) — flattened to one level, with the + * ground outside it stepped down in benches: the rings of a lake that never stood. + */ + const order = Array.from({ length: n }, (_, k) => k) + .filter((k) => inside[k]! > 0.9) + .sort((x, y) => floor[x]! - floor[y]!) + const wanted = f.pastWater.saltPans + for (const k of order) { + if (pans.length >= wanted) break + const { z, a } = at(k) + const radiusM = 1500 + 1600 * rand() + const spacing = radiusM * 4 + if (pans.some((q) => Math.hypot(q.z - z, nearestA(q.a, a) - a) < spacing)) continue + pans.push({ z, a, levelM: floor[k]!, radiusM }) + } + for (const pan of pans) { + const reach = pan.radiusM + (f.pastWater.shorelines ? BENCH_STEP_M * BENCH_COUNT * 24 : 0) + const cells = Math.ceil(reach / cell) + const ci = Math.round((pan.z - z0) / cell), cj = Math.round((pan.a - a0) / cell) + for (let i = Math.max(0, ci - cells); i <= Math.min(nz - 1, ci + cells); i++) { + for (let jj = cj - cells; jj <= cj + cells; jj++) { + const j = ring ? ((jj % na) + na) % na : jj + if (!ring && (j < 0 || j >= na)) continue + const k = i * na + j + const d = Math.hypot(z0 + i * cell - pan.z, a0 + jj * cell - pan.a) + if (d > reach) continue + /** + * The floor of the pan, and a crust that frays at the very edge. The level is + * written well beyond the crust as well, or sampling it near the rim would + * interpolate against the zeros outside and tilt the floor. + */ + const flat = 1 - smoothstep(pan.radiusM * 0.94, pan.radiusM, d) + if (flat > salt[k]!) salt[k] = flat + if (d < pan.radiusM * 1.5) panLevelM[k] = pan.levelM + if (f.pastWater.shorelines && d > pan.radiusM * 0.9) { + const b = (1 - smoothstep(reach * 0.75, reach, d)) * smoothstep(pan.radiusM * 0.9, pan.radiusM * 1.05, d) + if (b > bench[k]!) { bench[k] = b; benchLevelM[k] = pan.levelM } + } + } + } + } + } + + /** + * Where the dunes are: the open ground, away from rock and off the salt, and only in the + * share of it the authored coverage asks for — thresholded against the *measured* spread + * of the noise, as the plateaus are, because a threshold guessed against an assumed + * spread puts dunes over everything or nowhere. Blurred, since a dune field is + * kilometres across or it is not a field. + */ + const open = new Float32Array(n) + const fieldNoise = new Float32Array(n) + const fieldSamples: number[] = [] + for (let k = 0; k < n; k++) { + open[k] = inside[k]! * (1 - Math.min(1, plateau[k]! * 2.5)) * (1 - salt[k]!) + if (open[k]! <= 0.2) continue + const { p } = at(k) + fieldNoise[k] = fbm(layer(seed, 5), p, 21_000, 3, cell) + if (k % 7 === 0) fieldSamples.push(fieldNoise[k]!) + } + const fieldThr = quantile(fieldSamples, f.dunes.coverage) + for (let k = 0; k < n; k++) { + if (open[k]! <= 0.2) { open[k] = 0; continue } + open[k]! *= smoothstep(fieldThr - 0.01, fieldThr + 0.04, fieldNoise[k]!) + } + const sand = blur(grid, open, 4) + for (let k = 0; k < n; k++) sand[k] = Math.min(1, Math.max(0, sand[k]! * 1.2)) + + return { + id: f.id, shape, box: grow(box, f.edgeM * 1.2), grid, + inside, base, plateau, cut, salt, panLevelM, bench, benchLevelM, sand, pans, + plateauHeightM: f.plateaus.heightM, terraces: f.plateaus.terraces, + hillsHeightM: f.hills.heightM, hillsCoverage: f.hills.coverage, + duneHeightM: f.dunes.heightM, duneSpacingM: f.dunes.spacingM, + duneDirRad: (f.dunes.directionDeg * Math.PI) / 180, + dunePeriods: Math.round((CIRCUMFERENCE_M * Math.sin((f.dunes.directionDeg * Math.PI) / 180)) / f.dunes.spacingM), + duneCoverage: f.dunes.coverage, + seed, + } +} + +/** + * The rocky hills, as both the compile and the tiles make them: low ridged domes on the + * open ground. Shared so the washes are routed over the ground the tiles actually draw. + */ +export function hillsAt( + f: { hills: { coverage: number; heightM: number } }, seed: number, p: CylPoint, detailM: number, + plateau: number, inside: number, +): number { + if (f.hills.heightM <= 0 || f.hills.coverage <= 0) return 0 + const where = fbm(layer(seed, 3), p, 14_000, 3, detailM) + const m = smoothstep(0.92 - f.hills.coverage, 1.02 - f.hills.coverage, where) * inside * (1 - plateau) + if (m <= 0) return 0 + return Math.max(0, (ridged(layer(seed, 4), p, 2600, 3, detailM) - 0.4) * 1.7) * f.hills.heightM * m +} + +/** One layer of a feature's noise, as field.ts numbers them. */ +export const layer = (seed: number, i: number): number => hash3(seed, i, 0x1f7a) + +const grow = (b: Box, m: number): Box => ({ z0: b.z0 - m, z1: b.z1 + m, a0: b.a0 - m, a1: b.a1 + m }) + +/** The compiled fields at a point, for the tiles. */ +export function sampleDesert(d: CDesert, z: number, a: number): { + inside: number; base: number; plateau: number; cut: number; salt: number; panLevelM: number + bench: number; benchLevelM: number; sand: number +} { + return { + inside: sampleGrid(d.grid, d.inside, z, a), + base: sampleGrid(d.grid, d.base, z, a), + plateau: sampleGrid(d.grid, d.plateau, z, a), + cut: sampleGrid(d.grid, d.cut, z, a), + salt: sampleGrid(d.grid, d.salt, z, a), + panLevelM: sampleGrid(d.grid, d.panLevelM, z, a), + bench: sampleGrid(d.grid, d.bench, z, a), + benchLevelM: sampleGrid(d.grid, d.benchLevelM, z, a), + sand: sampleGrid(d.grid, d.sand, z, a), + } +} diff --git a/src/terrain/field.ts b/src/terrain/field.ts index bb399e5..612fd40 100644 --- a/src/terrain/field.ts +++ b/src/terrain/field.ts @@ -19,8 +19,9 @@ * 30 km across passes a few hundred metres; a tile under the observer's feet passes * a metre. Same function, same answer, fewer octaves where they could not be seen. */ -import { RADIUS_M } from '../canon/constants.js' +import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' import { type CBasin, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js' +import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './desert.js' import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js' import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js' import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js' @@ -65,6 +66,10 @@ export interface Sample { forest: number /** Relief the mountains added here, metres: what decides bare rock. */ relief: number + /** Bare rock the desert claims here, 0..1: plateau tops, cliff bands, gravel floors. */ + rock: number + /** Salt crust, 0..1: a pan's floor. */ + salt: number snowlineM: number } @@ -108,7 +113,7 @@ export class Runtime { private out: Sample = { h: 0, water: -Infinity, waterCover: 0, waterMesh: false, body: -1, flowZ: 0, flowA: 0, foam: 0, - desert: 0, forest: 0, relief: 0, snowlineM: 3000, + desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, snowlineM: 3000, } private nearest: Nearest = { d: 0, seg: 0, t: 0 } @@ -135,6 +140,8 @@ export class Runtime { o.desert = 0 o.forest = 0 o.relief = 0 + o.rock = 0 + o.salt = 0 o.snowlineM = 3000 if (upTo === Stage.MountainsOnly) { @@ -148,6 +155,10 @@ export class Runtime { for (const u of w.uplifts) { if (u.kind !== 'region' && inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o) } + // The deserts' own landforms, on the shelf the uplift above just raised. + for (const d of w.deserts ?? []) { + if (inBox(d.box, z, a)) desert(d, p, z, a, detailM, o) + } let relief = 0 for (const m of w.mountains) { if (!inBox(m.box, z, a)) continue @@ -218,6 +229,102 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim) } +/** + * A desert's landforms on top of its shelf (desert.ts, world-generation.md §4.5). + * + * The plateau's tiers are cut here rather than compiled, so a cliff band is as sharp as + * the tile is fine. Hills and dunes are made here too: a dune needs to know nothing about + * the ground next door, only which way Tower blows the wind. + */ +function desert(d: CDesert, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { + /** + * The compiled fields are read through a small warp. Sampled straight, a cliff band + * follows the 250 m grid and its rim reads as a row of facets; warped, the rim wanders + * and the grid disappears. + */ + const [wz, wa] = warp(d.seed ^ 0x51, p, z, a, 140, 1100, detailM) + const g = sampleDesert(d, wz, wa) + if (g.inside <= 0.001) return + // The broad crown the washes run off (desert.ts). + let h = g.base + + // Rock, in tiers: flat tops with steep faces, roughened across the faces. + if (g.plateau > 0.001) { + h += terrace(g.plateau, d.terraces) * d.plateauHeightM + h += (fbm(layer(d.seed, 2), p, 240, 4, detailM) - 0.5) * 12 * smoothstep(0.02, 0.15, g.plateau) + o.rock = Math.max(o.rock, smoothstep(0.02, 0.16, g.plateau)) + } + + // Rocky hills on the open ground: low ridged domes, where the coverage noise says so. + // The same function the washes were routed over (desert.ts), so the two agree. + const hills = hillsAt( + { hills: { coverage: d.hillsCoverage, heightM: d.hillsHeightM } }, + d.seed, p, detailM, g.plateau, g.inside * (1 - g.salt)) + if (hills > 0) { + h += hills + o.rock = Math.max(o.rock, smoothstep(0.25, 0.8, hills / Math.max(1, d.hillsHeightM)) * 0.6) + } + + // Dunes: crests across the wind, gentle to windward and steep on the slip face. Where + // the dune fields are was settled in the compile (desert.ts `sand`). + if (d.duneHeightM > 0 && g.sand > 0.02) { + h += dunes(d, p, z, a, detailM) * d.duneHeightM * g.sand + } + + // The washes, cut through all of it, with a gravel floor. + if (g.cut > 0.2) { + h -= g.cut + o.rock = Math.max(o.rock, smoothstep(3, 18, g.cut) * 0.4) + } + + /** + * A pan is dead flat, and the ground around it steps down to it in the benches a lake + * would have left. Both are levels in the *world*, not in the sand — the shelf under the + * desert has a hundred metres of its own roll, and a floor that followed it would not be + * a floor — so this works on the total elevation and then puts it back. + */ + let total = o.h + h + if (g.bench > 0.02) { + const above = total - g.benchLevelM + if (above > 0) { + const stepped = Math.round(above / BENCH_STEP_M) * BENCH_STEP_M + total += (stepped - above) * Math.min(1, g.bench * 1.4) + } + } + if (g.salt > 0.02) { + const flat = Math.min(1, g.salt) + total += (g.panLevelM - total) * flat + o.salt = Math.max(o.salt, flat) + o.rock *= 1 - flat + } + + o.h = total +} + +/** + * One dune field's profile, 0..1 of the dune height. Crests run across the wind; each + * dune climbs gently to windward and drops at the angle dry sand stands at, and the crest + * line wanders along itself rather than ruling straight across the desert. + */ +function dunes(d: CDesert, p: CylPoint, z: number, a: number, detailM: number): number { + /** + * Across the wind, in dune wavelengths. The circumferential part is counted in *whole* + * dunes around the world — a pattern that does not close on itself leaves a seam where + * theta wraps, and a dune field running round a ring has to close. + */ + const across = (z * Math.cos(d.duneDirRad)) / d.duneSpacingM + d.dunePeriods * (a / CIRCUMFERENCE_M) + // The crest line wanders along itself; the noise is on the cylinder, so it closes too. + const wander = fbm(layer(d.seed, 6), p, d.duneSpacingM * 7, 3, detailM) - 0.5 + const x = across + wander * 1.7 + const f = x - Math.floor(x) + // Three-quarters of the wavelength climbing, a quarter falling at about 33°: the angle + // of repose of dry sand, which is what makes a slip face. + const profile = f < 0.76 ? (f / 0.76) ** 1.35 : 1 - (f - 0.76) / 0.24 + // Ripples on the sand itself, only ever seen from a few metres up. + const ripple = (fbm(layer(d.seed, 7), p, 11, 2, detailM) - 0.5) * 0.05 + return Math.max(0, profile + ripple) +} + /** * Ground Tower raised: the open ground lifted by the region's uplift, wherever that is * higher than what is already here. Taking the higher rather than adding is what lets diff --git a/src/terrain/grid.ts b/src/terrain/grid.ts new file mode 100644 index 0000000..28792a4 --- /dev/null +++ b/src/terrain/grid.ts @@ -0,0 +1,179 @@ +/** + * Grids on the unrolled surface, and the passes that run over them. + * + * A generator that has to know what the ground next door is doing — a range eroding to a + * drainage network, a desert routing washes and finding its closed basins — works on a + * grid of cells rather than on one sample at a time. This is the machinery they share: + * the grid itself, a priority flood that finds which cell drains into which, a separable + * box blur, bilinear sampling for the tiles that read the result back, and a walk over + * the cells near a point. + * + * A grid rings the world when its feature does (`ring`), so a band's drainage and blur + * wrap round theta instead of stopping at a seam. + */ +import { nearestA } from './shapes.js' + +export interface Grid { + z0: number + a0: number + cell: number + nz: number + na: number + ring: boolean +} + +/** A binary min-heap of cell indices keyed by height, on typed arrays. */ +export class Heap { + private keys: Float64Array + private ids: Int32Array + size = 0 + constructor(capacity: number) { + this.keys = new Float64Array(capacity) + this.ids = new Int32Array(capacity) + } + push(key: number, id: number): void { + let i = this.size++ + while (i > 0) { + const parent = (i - 1) >> 1 + if (this.keys[parent]! <= key) break + this.keys[i] = this.keys[parent]! + this.ids[i] = this.ids[parent]! + i = parent + } + this.keys[i] = key + this.ids[i] = id + } + pop(): number { + const top = this.ids[0]! + const key = this.keys[--this.size]! + const id = this.ids[this.size]! + let i = 0 + for (;;) { + let child = 2 * i + 1 + if (child >= this.size) break + if (child + 1 < this.size && this.keys[child + 1]! < this.keys[child]!) child++ + if (this.keys[child]! >= key) break + this.keys[i] = this.keys[child]! + this.ids[i] = this.ids[child]! + i = child + } + this.keys[i] = key + this.ids[i] = id + return top + } +} + +/** Priority flood from the outlets: receivers, and cells in order from the outlets up. */ +export function drainage(grid: Grid, h: Float32Array, outlet: Uint8Array, out: { + receiver: Int32Array; stack: Int32Array; reached: Uint8Array; key: Float64Array; heap: Heap +}): number { + const n = grid.nz * grid.na + const { receiver, stack, reached, key, heap } = out + reached.fill(0) + heap.size = 0 + for (let k = 0; k < n; k++) { + if (!outlet[k]) continue + reached[k] = 1 + receiver[k] = -1 + key[k] = h[k]! + heap.push(h[k]!, k) + } + let top = 0 + while (heap.size) { + const c = heap.pop() + stack[top++] = c + const ci = Math.floor(c / grid.na), cj = c % grid.na + for (let e = 0; e < 8; e++) { + const ni = ci + DI[e]! + if (ni < 0 || ni >= grid.nz) continue + let nj = cj + DJ[e]! + if (grid.ring) nj = (nj + grid.na) % grid.na + else if (nj < 0 || nj >= grid.na) continue + const nk = ni * grid.na + nj + if (reached[nk]) continue + reached[nk] = 1 + receiver[nk] = c + key[nk] = Math.max(h[nk]!, key[c]!) + heap.push(key[nk]!, nk) + } + } + return top +} + +export const DI = [-1, -1, -1, 0, 0, 1, 1, 1] +export const DJ = [-1, 0, 1, -1, 1, -1, 0, 1] + +/** A separable box blur over a grid: three passes, which is near enough a Gaussian. */ +export function blur(grid: Grid, src: Float32Array, radius: number): Float32Array { + let a = Float32Array.from(src) + let b = new Float32Array(src.length) + const { nz, na } = grid + const width = 2 * radius + 1 + for (let pass = 0; pass < 3; pass++) { + for (let i = 0; i < nz; i++) { + let sum = 0 + for (let d = -radius; d <= radius; d++) { + const j = grid.ring ? ((d % na) + na) % na : Math.min(na - 1, Math.max(0, d)) + sum += a[i * na + j]! + } + for (let j = 0; j < na; j++) { + b[i * na + j] = sum / width + const outJ = grid.ring ? ((j - radius) % na + na) % na : Math.max(0, j - radius) + const inJ = grid.ring ? (j + radius + 1) % na : Math.min(na - 1, j + radius + 1) + sum += a[i * na + inJ]! - a[i * na + outJ]! + } + } + ;[a, b] = [b, a] + for (let j = 0; j < na; j++) { + let sum = 0 + for (let d = -radius; d <= radius; d++) sum += a[Math.min(nz - 1, Math.max(0, d)) * na + j]! + for (let i = 0; i < nz; i++) { + b[i * na + j] = sum / width + sum += a[Math.min(nz - 1, i + radius + 1) * na + j]! - a[Math.max(0, i - radius) * na + j]! + } + } + ;[a, b] = [b, a] + } + return a +} + +/** Call `fn` for every cell within `radius` of (z, a): its index, the distance, and its own (z, a). */ +export function forCells( + grid: Grid, z: number, a: number, radius: number, fn: (k: number, d: number, z: number, a: number) => void, +): void { + const cells = Math.ceil(radius / grid.cell) + const ci = Math.round((z - grid.z0) / grid.cell) + const aa = grid.ring ? a : nearestA(a, grid.a0 + ((grid.na - 1) * grid.cell) / 2) + const cj = Math.round((aa - grid.a0) / grid.cell) + for (let i = ci - cells; i <= ci + cells; i++) { + if (i < 0 || i >= grid.nz) continue + for (let jj = cj - cells; jj <= cj + cells; jj++) { + let j = jj + if (grid.ring) j = ((jj % grid.na) + grid.na) % grid.na + else if (jj < 0 || jj >= grid.na) continue + const cz = grid.z0 + i * grid.cell, ca = grid.a0 + jj * grid.cell + const d = Math.hypot(cz - z, ca - aa) + if (d <= radius) fn(i * grid.na + j, d, cz, ca) + } + } +} + +/** Bilinear value of a grid at (z, a), zero outside a bounded grid. */ +export function sampleGrid(g: Grid, relief: Float32Array, z: number, a: number): number { + const fi = (z - g.z0) / g.cell + if (fi < 0 || fi > g.nz - 1) return 0 + let fj: number + if (g.ring) { + fj = ((((a - g.a0) / g.cell) % g.na) + g.na) % g.na + } else { + fj = (nearestA(a, g.a0 + ((g.na - 1) * g.cell) / 2) - g.a0) / g.cell + if (fj < 0 || fj > g.na - 1) return 0 + } + const i0 = Math.min(g.nz - 2, Math.floor(fi)), j0 = Math.min(g.ring ? g.na - 1 : g.na - 2, Math.floor(fj)) + const ti = fi - i0, tj = fj - j0 + const j1 = g.ring ? (j0 + 1) % g.na : j0 + 1 + const at = (i: number, j: number): number => relief[i * g.na + j]! + const top = at(i0, j0) * (1 - tj) + at(i0, j1) * tj + const bot = at(i0 + 1, j0) * (1 - tj) + at(i0 + 1, j1) * tj + return top * (1 - ti) + bot * ti +} diff --git a/src/terrain/mountains.ts b/src/terrain/mountains.ts index 1f01f87..ba30ae8 100644 --- a/src/terrain/mountains.ts +++ b/src/terrain/mountains.ts @@ -30,9 +30,12 @@ import Delaunator from 'delaunator' import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' import type { Mountains } from '../world/schema.js' +import { type Grid, Heap, blur, drainage, forCells, sampleGrid } from './grid.js' import { hash3, mulberry32, simplex } from './noise.js' import { type Box, type Shape, nearestA, shapeBox, shapeOf, signedDistance } from './shapes.js' +export { type Grid, sampleGrid } from './grid.js' + const C = CIRCUMFERENCE_M export interface CPeak { @@ -355,100 +358,11 @@ function upliftField( // --------------------------------------------------------------------- erosion -export interface Grid { - z0: number - a0: number - cell: number - nz: number - na: number - ring: boolean -} - -/** A binary min-heap of cell indices keyed by height, on typed arrays. */ -class Heap { - private keys: Float64Array - private ids: Int32Array - size = 0 - constructor(capacity: number) { - this.keys = new Float64Array(capacity) - this.ids = new Int32Array(capacity) - } - push(key: number, id: number): void { - let i = this.size++ - while (i > 0) { - const parent = (i - 1) >> 1 - if (this.keys[parent]! <= key) break - this.keys[i] = this.keys[parent]! - this.ids[i] = this.ids[parent]! - i = parent - } - this.keys[i] = key - this.ids[i] = id - } - pop(): number { - const top = this.ids[0]! - const key = this.keys[--this.size]! - const id = this.ids[this.size]! - let i = 0 - for (;;) { - let child = 2 * i + 1 - if (child >= this.size) break - if (child + 1 < this.size && this.keys[child + 1]! < this.keys[child]!) child++ - if (this.keys[child]! >= key) break - this.keys[i] = this.keys[child]! - this.ids[i] = this.ids[child]! - i = child - } - this.keys[i] = key - this.ids[i] = id - return top - } -} - /** Drainage area past which ground is a channel rather than hillslope: 1 km². */ const CHANNEL_AREA_M2 = 1e6 /** Hillslope gradient at full uplift. The rescaling to the authored shape sets heights. */ const STEADY_SLOPE = 0.6 -/** Priority flood from the outlets: receivers, and cells in order from the outlets up. */ -function drainage(grid: Grid, h: Float32Array, outlet: Uint8Array, out: { - receiver: Int32Array; stack: Int32Array; reached: Uint8Array; key: Float64Array; heap: Heap -}): number { - const n = grid.nz * grid.na - const { receiver, stack, reached, key, heap } = out - reached.fill(0) - heap.size = 0 - for (let k = 0; k < n; k++) { - if (!outlet[k]) continue - reached[k] = 1 - receiver[k] = -1 - key[k] = h[k]! - heap.push(h[k]!, k) - } - let top = 0 - while (heap.size) { - const c = heap.pop() - stack[top++] = c - const ci = Math.floor(c / grid.na), cj = c % grid.na - for (let e = 0; e < 8; e++) { - const ni = ci + DI[e]! - if (ni < 0 || ni >= grid.nz) continue - let nj = cj + DJ[e]! - if (grid.ring) nj = (nj + grid.na) % grid.na - else if (nj < 0 || nj >= grid.na) continue - const nk = ni * grid.na + nj - if (reached[nk]) continue - reached[nk] = 1 - receiver[nk] = c - key[nk] = Math.max(h[nk]!, key[c]!) - heap.push(key[nk]!, nk) - } - } - return top -} -const DI = [-1, -1, -1, 0, 0, 1, 1, 1] -const DJ = [-1, 0, 1, -1, 1, -1, 0, 1] - /** * Erode to the stream-power steady state, and scale it back to the authored shape. * @@ -544,39 +458,6 @@ function erode(grid: Grid, field: Float32Array, outlet: Uint8Array, strength: nu } /** Separable box blur, `radius` cells, three passes each way — close to a Gaussian. */ -function blur(grid: Grid, src: Float32Array, radius: number): Float32Array { - let a = Float32Array.from(src) - let b = new Float32Array(src.length) - const { nz, na } = grid - const width = 2 * radius + 1 - for (let pass = 0; pass < 3; pass++) { - for (let i = 0; i < nz; i++) { - let sum = 0 - for (let d = -radius; d <= radius; d++) { - const j = grid.ring ? ((d % na) + na) % na : Math.min(na - 1, Math.max(0, d)) - sum += a[i * na + j]! - } - for (let j = 0; j < na; j++) { - b[i * na + j] = sum / width - const outJ = grid.ring ? ((j - radius) % na + na) % na : Math.max(0, j - radius) - const inJ = grid.ring ? (j + radius + 1) % na : Math.min(na - 1, j + radius + 1) - sum += a[i * na + inJ]! - a[i * na + outJ]! - } - } - ;[a, b] = [b, a] - for (let j = 0; j < na; j++) { - let sum = 0 - for (let d = -radius; d <= radius; d++) sum += a[Math.min(nz - 1, Math.max(0, d)) * na + j]! - for (let i = 0; i < nz; i++) { - b[i * na + j] = sum / width - sum += a[Math.min(nz - 1, i + radius + 1) * na + j]! - a[Math.max(0, i - radius) * na + j]! - } - } - ;[a, b] = [b, a] - } - return a -} - // ------------------------------------------------------------------ the compile /** A place a range must keep a shelf of ground: a cirque for an authored tarn. */ @@ -754,26 +635,6 @@ function summitCone(grid: Grid, relief: Float32Array, peaks: CPeak[], landAt: (z } /** Visit every cell within `radius` of (z, a), with its distance. */ -function forCells( - grid: Grid, z: number, a: number, radius: number, fn: (k: number, d: number, z: number, a: number) => void, -): void { - const cells = Math.ceil(radius / grid.cell) - const ci = Math.round((z - grid.z0) / grid.cell) - const aa = grid.ring ? a : nearestA(a, grid.a0 + ((grid.na - 1) * grid.cell) / 2) - const cj = Math.round((aa - grid.a0) / grid.cell) - for (let i = ci - cells; i <= ci + cells; i++) { - if (i < 0 || i >= grid.nz) continue - for (let jj = cj - cells; jj <= cj + cells; jj++) { - let j = jj - if (grid.ring) j = ((jj % grid.na) + grid.na) % grid.na - else if (jj < 0 || jj >= grid.na) continue - const cz = grid.z0 + i * grid.cell, ca = grid.a0 + jj * grid.cell - const d = Math.hypot(cz - z, ca - aa) - if (d <= radius) fn(i * grid.na + j, d, cz, ca) - } - } -} - /** The pinning kernel's radius: how far around a summit the correction is spread. */ export const PIN_RADIUS_M = 1500 @@ -798,23 +659,3 @@ export function pinKernel(d: number): number { const u = 1 - d / PIN_RADIUS_M return u * u * (3 - 2 * u) } - -/** Bilinear value of a grid at (z, a), zero outside a bounded grid. */ -export function sampleGrid(g: Grid, relief: Float32Array, z: number, a: number): number { - const fi = (z - g.z0) / g.cell - if (fi < 0 || fi > g.nz - 1) return 0 - let fj: number - if (g.ring) { - fj = ((((a - g.a0) / g.cell) % g.na) + g.na) % g.na - } else { - fj = (nearestA(a, g.a0 + ((g.na - 1) * g.cell) / 2) - g.a0) / g.cell - if (fj < 0 || fj > g.na - 1) return 0 - } - const i0 = Math.min(g.nz - 2, Math.floor(fi)), j0 = Math.min(g.ring ? g.na - 1 : g.na - 2, Math.floor(fj)) - const ti = fi - i0, tj = fj - j0 - const j1 = g.ring ? (j0 + 1) % g.na : j0 + 1 - const at = (i: number, j: number): number => relief[i * g.na + j]! - const top = at(i0, j0) * (1 - tj) + at(i0, j1) * tj - const bot = at(i0 + 1, j0) * (1 - tj) + at(i0 + 1, j1) * tj - return top * (1 - ti) + bot * ti -} diff --git a/src/terrain/palette.ts b/src/terrain/palette.ts index 4bced05..097e0b1 100644 --- a/src/terrain/palette.ts +++ b/src/terrain/palette.ts @@ -10,7 +10,7 @@ */ type Ramp = readonly (readonly [number, readonly [number, number, number]])[] -export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock', Ramp> = { +export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock' | 'sandstone', Ramp> = { plain: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]], [700, [140, 138, 106]], [1600, [146, 138, 120]]], desert: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]], @@ -18,8 +18,13 @@ export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock', Ramp> = { forest: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]], rock: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]], [2900, [166, 162, 156]]], + /** Desert rock: the sandstones and mudstones a mesa is cut from, warm and reddened. */ + sandstone: [[400, [190, 132, 94]], [800, [176, 114, 78]], [1200, [160, 100, 70]], + [1800, [146, 96, 72]]], } export const SNOW = [247, 249, 253] as const +/** Salt: white, but a dirtier and warmer white than snow (world-generation.md §4.5). */ +export const SALT = [232, 227, 213] as const /** Shallow water, and how much darker it gets at full depth. */ export const WATER = [58, 96, 132] as const export const WATER_DEEPEN = [20, 26, 24] as const @@ -44,7 +49,7 @@ export function ramp(r: Ramp, h: number, out: number[]): void { /** * sRGB 0..255 for a material mix at an elevation. `m` is tile.ts MATERIALS order: - * plain, desert, forest, rock, snow, water, depth. + * plain, desert, forest, rock, snow, water, depth, bed, salt, sandstone. */ export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void { const tmp = [0, 0, 0] @@ -57,7 +62,15 @@ export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void { r += tmp[0]! * w; g += tmp[1]! * w; b += tmp[2]! * w sum += w } + const sandstone = m[9] ?? 0 + if (sandstone > 0) { + ramp(RAMPS.sandstone, h, tmp) + r += tmp[0]! * sandstone; g += tmp[1]! * sandstone; b += tmp[2]! * sandstone + sum += sandstone + } if (sum > 0) { r /= sum; g /= sum; b /= sum } + const salt = m[8] ?? 0 + r += (SALT[0] - r) * salt; g += (SALT[1] - g) * salt; b += (SALT[2] - b) * salt const snow = m[4]! r += (SNOW[0] - r) * snow; g += (SNOW[1] - g) * snow; b += (SNOW[2] - b) * snow const water = m[5]!, d = m[6]! @@ -81,15 +94,18 @@ function glslRamp(name: string, r: Ramp): string { return lines.join('\n') } -/** The palette as GLSL: `groundColour(matA, matB, h)` in sRGB 0..1. */ +/** The palette as GLSL: `groundColour(matA, matB, extra, h)`, `extra` = (salt, sandstone). */ export const PALETTE_GLSL = [ glslRamp('rampPlain', RAMPS.plain), glslRamp('rampDesert', RAMPS.desert), glslRamp('rampForest', RAMPS.forest), glslRamp('rampRock', RAMPS.rock), - `vec3 groundColour(vec4 a, vec4 b, float h) { - float sum = max(a.x + a.y + a.z + a.w, 1e-3); - vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w) / sum; + glslRamp('rampSandstone', RAMPS.sandstone), + `vec3 groundColour(vec4 a, vec4 b, vec2 extra, float h) { + float sum = max(a.x + a.y + a.z + a.w + extra.y, 1e-3); + vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w + + rampSandstone(h) * extra.y) / sum; + c = mix(c, ${vec(SALT)}, extra.x); c = mix(c, ${vec(SNOW)}, b.x); vec3 water = ${vec(WATER)} - ${vec(WATER_DEEPEN)} * b.z; return mix(c, water, b.y); diff --git a/src/terrain/service.ts b/src/terrain/service.ts index 92cff17..94c327a 100644 --- a/src/terrain/service.ts +++ b/src/terrain/service.ts @@ -154,7 +154,9 @@ export class TerrainService { || (f.kind === 'region' && f.upliftM)).length // Mountain ranges add an erosion step per group; counting one per range is an upper bound. const ranges = world.features.filter((f) => f.kind === 'mountains').length - const total = 1 + counted + ranges + 1 + // A desert names three steps of its own: its rock, its washes and its salt (desert.ts). + const deserts = world.features.filter((f) => f.kind === 'desert').length + const total = 1 + counted + ranges + 2 * deserts + 1 let done = 0 this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() }) diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts index ff76795..5c1f496 100644 --- a/src/terrain/tile.ts +++ b/src/terrain/tile.ts @@ -52,7 +52,9 @@ export function tileCentreKm(t: TileKey): [number, number, number] { * shader turns them into colour, so a tile carries what the ground *is* and the look * of it can change without regenerating anything. */ -export const MATERIALS = ['plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed'] as const +export const MATERIALS = [ + 'plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed', 'salt', 'sandstone', +] as const /** * Per water vertex, in this order: depth of water over the ground, main wave amplitude, @@ -107,10 +109,19 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number // eye: 40 m of river in a 200 m cell is a 20% tint, and it vanished where tiles coarsened. // The square root keeps a sub-resolution river continuous with the surface drawn nearer. const water = s.waterMesh ? 0 : Math.min(1, Math.sqrt(s.waterCover) * 1.2) - const rock = smoothstep(1300, 1900, s.relief) - const desert = s.desert * (1 - rock) - const forest = s.forest * (1 - rock) * (1 - desert) - const plain = Math.max(0, 1 - rock - desert - forest) + /** + * Bare rock: high ground in a range, whatever the desert claims as rock (plateau tops, + * cliff bands, a wash's gravel floor), and any steep desert face — a mesa's riser reads + * as rock because it is too steep to hold sand, which is the same reason snow slides off. + */ + const rock = Math.min(1, Math.max( + smoothstep(1300, 1900, s.relief), + Math.max(s.rock, smoothstep(0.45, 0.85, slope) * s.desert))) + // A salt crust is not sand, rock or anything else: it covers them. + const salt = s.salt * (1 - water) + const desert = s.desert * (1 - rock) * (1 - salt) + const forest = s.forest * (1 - rock) * (1 - desert) * (1 - salt) + const plain = Math.max(0, 1 - rock - desert - forest - salt) // Snow does not lie on cliffs. Blending it out with slope is what turns a white blob // into a mountain with rock showing through. const holds = 1 - smoothstep(0.55, 1.1, slope) @@ -118,12 +129,16 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number out[offset] = plain out[offset + 1] = desert out[offset + 2] = forest - out[offset + 3] = rock + // Rock in a desert is sandstone: warm, and bedded. Rock in a range is grey stone. + const sandstone = rock * (1 - salt) * s.desert + out[offset + 3] = rock * (1 - salt) - sandstone out[offset + 4] = snow out[offset + 5] = water // Water deepens to its darkest over the first 90 m, as the v1 map did. out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0 out[offset + 7] = s.waterMesh ? 1 : 0 + out[offset + 8] = salt + out[offset + 9] = sandstone } /** diff --git a/src/ui/app.ts b/src/ui/app.ts index 9db5b3b..35292d2 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -9,8 +9,9 @@ import { landStripWidth, slatCount, slatWidth } from '../canon/slats.js' import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js' import { sightline } from '../world/instruments.js' import { - type Feature, type FeatureKind, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isAutoId, - isTracedKind, makeId, newSeed, parseWorld, withKind, + type Desert as DesertFeature, type Feature, type FeatureKind, FEATURE_KINDS, anchorOf, + geometryTypesFor, isAreaKind, isAutoId, isTracedKind, makeId, newSeed, parseWorld, + SETTLEMENT_RADIUS_M, withKind, } from '../world/schema.js' import { terrain } from '../terrain/service.js' import { seaStateOf } from '../terrain/sea-state.js' @@ -743,6 +744,8 @@ export class App { $('f-delete').onclick = () => this.deleteSelected() this.wireMountains() this.wireWater() + this.wireDesert() + this.wireSettlement() this.wireWeather() } @@ -792,6 +795,110 @@ export class App { ? ' The wind runs round the ring, so the sea is fully developed.' : '') } + /** Where a settlement stands, how big it is, and what it is called for its size. */ + private wireSettlement(): void { + const selected = (): Extract<Feature, { kind: 'settlement' }> | null => { + const f = this.selectedId ? this.store.get(this.selectedId) : undefined + return f?.kind === 'settlement' ? f : null + } + const size = $<HTMLSelectElement>('f-size') + size.onchange = () => { + const f = selected() + if (!f) return + const next = size.value as typeof f.size + // A size carries its usual extent, unless this one was given its own. + const usual = SETTLEMENT_RADIUS_M[f.size] + this.store.update(f.id, { + size: next, + radiusM: Math.abs(f.radiusM - usual) < 1 ? SETTLEMENT_RADIUS_M[next] : f.radiusM, + }) + } + const extent = $<HTMLInputElement>('f-extent') + extent.onchange = () => { + const f = selected() + const km = Number(extent.value) + if (f && Number.isFinite(km) && km > 0) this.store.update(f.id, { radiusM: (km * 1000) / 2 }) + } + } + + private renderSettlement(f: Feature | undefined): void { + $('f-settlement').hidden = f?.kind !== 'settlement' + if (f?.kind !== 'settlement') return + const size = $<HTMLSelectElement>('f-size') + if (document.activeElement !== size) size.value = f.size + const extent = $<HTMLInputElement>('f-extent') + if (document.activeElement !== extent) extent.value = String(((2 * f.radiusM) / 1000).toFixed(1)) + } + + /** + * A desert's landforms (world-generation.md §4.5). Every one of these regenerates the + * desert — a second or so under the status dialog — so they commit on change rather + * than while a number is being typed. + */ + private desertFields(): { id: string; get: (f: DesertFeature) => number; set: (f: DesertFeature, v: number) => FeaturePatch }[] { + return [ + { id: 'f-dune-height', get: (f) => f.dunes.heightM, set: (f, v) => ({ dunes: { ...f.dunes, heightM: v } }) }, + { id: 'f-dune-spacing', get: (f) => f.dunes.spacingM, set: (f, v) => ({ dunes: { ...f.dunes, spacingM: v } }) }, + { id: 'f-dune-dir', get: (f) => f.dunes.directionDeg, set: (f, v) => ({ dunes: { ...f.dunes, directionDeg: v } }) }, + { id: 'f-dune-cover', get: (f) => f.dunes.coverage, set: (f, v) => ({ dunes: { ...f.dunes, coverage: v } }) }, + { id: 'f-hill-height', get: (f) => f.hills.heightM, set: (f, v) => ({ hills: { ...f.hills, heightM: v } }) }, + { id: 'f-hill-cover', get: (f) => f.hills.coverage, set: (f, v) => ({ hills: { ...f.hills, coverage: v } }) }, + { id: 'f-plat-height', get: (f) => f.plateaus.heightM, set: (f, v) => ({ plateaus: { ...f.plateaus, heightM: v } }) }, + { id: 'f-plat-cover', get: (f) => f.plateaus.coverage, set: (f, v) => ({ plateaus: { ...f.plateaus, coverage: v } }) }, + { id: 'f-plat-terraces', get: (f) => f.plateaus.terraces, set: (f, v) => ({ plateaus: { ...f.plateaus, terraces: v } }) }, + { id: 'f-washes', get: (f) => f.pastWater.washes, set: (f, v) => ({ pastWater: { ...f.pastWater, washes: v } }) }, + { id: 'f-pans', get: (f) => f.pastWater.saltPans, set: (f, v) => ({ pastWater: { ...f.pastWater, saltPans: v } }) }, + ] + } + + private selectedDesert(): DesertFeature | null { + const f = this.selectedId ? this.store.get(this.selectedId) : undefined + return f?.kind === 'desert' ? f : null + } + + private wireDesert(): void { + for (const field of this.desertFields()) { + const input = $<HTMLInputElement>(field.id) + input.onchange = () => { + const f = this.selectedDesert() + const v = Number(input.value) + if (f && Number.isFinite(v)) this.store.update(f.id, field.set(f, v)) + } + } + const shores = $<HTMLInputElement>('f-shorelines') + shores.onchange = () => { + const f = this.selectedDesert() + if (f) this.store.update(f.id, { pastWater: { ...f.pastWater, shorelines: shores.checked } }) + } + // What was actually made is read off the compiled world, as the sea state is. + terrain().onWorld(() => this.renderDesert(this.selectedId ? this.store.get(this.selectedId) : undefined)) + } + + private renderDesert(f: Feature | undefined): void { + $('f-desert').hidden = f?.kind !== 'desert' + if (f?.kind !== 'desert') return + for (const field of this.desertFields()) { + const input = $<HTMLInputElement>(field.id) + if (document.activeElement !== input) input.value = String(field.get(f)) + } + const shores = $<HTMLInputElement>('f-shorelines') + if (document.activeElement !== shores) shores.checked = f.pastWater.shorelines + const d = terrain().compiled?.deserts.find((x) => x.id === f.id) + if (!d) { $('f-desert-state').textContent = ''; return } + let rock = 0, sand = 0, cut = 0, inside = 0 + for (let k = 0; k < d.inside.length; k += 7) { + if (d.inside[k]! < 0.9) continue + inside++ + if (d.plateau[k]! > 0.5) rock++ + if (d.sand[k]! > 0.5) sand++ + if (d.cut[k]! > 3) cut++ + } + const pct = (n: number) => `${Math.round((100 * n) / Math.max(1, inside))}%` + $('f-desert-state').textContent = + `Made: ${pct(rock)} standing rock, ${pct(sand)} dune field, ${pct(cut)} cut by washes, ` + + `${d.pans.length} salt pan${d.pans.length === 1 ? '' : 's'}.` + } + // --------------------------------------------------------------- weather /** @@ -1259,6 +1366,8 @@ export class App { $<HTMLInputElement>('f-public').checked = p.public this.renderMountains(p) this.renderWater(p) + this.renderDesert(p) + this.renderSettlement(p) $('f-where').textContent = this.whereText(p) } diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index e9cbeb0..c78508d 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -171,16 +171,19 @@ const GROUND_VERT = /* glsl */` uniform mat4 uFrame; attribute vec4 aMatA; attribute vec4 aMatB; +attribute vec2 aExtra; varying vec3 vWorld; varying vec3 vNormal2; varying vec4 vMatA; varying vec4 vMatB; +varying vec2 vExtra; void main() { vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; vNormal2 = normal; vMatA = aMatA; vMatB = aMatB; + vExtra = aExtra; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); #include <logdepthbuf_vertex> } @@ -197,6 +200,7 @@ varying vec3 vWorld; varying vec3 vNormal2; varying vec4 vMatA; varying vec4 vMatB; +varying vec2 vExtra; ${HAZE_GLSL} ${PALETTE_GLSL} @@ -217,7 +221,17 @@ void main() { float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0; // The palette is sRGB; every uniform colour here is linear. Three inserts that // decode into its own materials and never into a custom one. - vec3 base = pow(groundColour(vMatA, vMatB, elevM), vec3(2.2)); + vec3 base = pow(groundColour(vMatA, vMatB, vExtra, elevM), vec3(2.2)); + /** + * Strata. Rock is laid in beds, and a bed is a level in Tower — a constant radius — so + * the bands line up across separate mesas however far apart they stand, which is what + * makes a field of them read as one formation eroded out of one plateau (§4.5). + */ + float bed = sin(elevM * 0.11) * 0.5 + 0.5; + float thin = sin(elevM * 0.53) * 0.5 + 0.5; + vec3 warm = vec3(1.05, 0.99, 0.93); + float rocky = vMatA.w + vExtra.y; + base *= mix(vec3(1.0), mix(vec3(0.93), warm, bed * 0.7 + thin * 0.3), rocky * 0.6); // Under drawn water the ground is bed: sediment, darker, whatever the land around it. base = mix(base, vec3(0.16, 0.14, 0.11), vMatB.w * 0.8); @@ -501,8 +515,14 @@ void main() { float t = 1.01 - 0.68 * sqrt(wp.w); float cap = smoothstep(t, t + 0.06, crest * 0.7 + patches * 0.45); // Foam is lace, not a sheet: broken up finely, and thinner toward the cap's edge. - // Resolved only close in: beyond, its 0.8 m cells drew as blocks, so it averages out. - float lace = mix(smoothstep(0.25, 0.75, wNoise(sp / 0.8 + drift * 0.1)), 0.5, smoothstep(0.05, 0.3, footprint)); + /** + * Resolved only close in: beyond, its metre-scale cells drew as blocks, so it averages + * out. Two octaves, the second rotated, because one octave of value noise thresholded + * this hard reads as a honeycomb of its own lattice. + */ + vec2 lp = vec2(sp.x * 0.83 - sp.y * 0.56, sp.x * 0.56 + sp.y * 0.83); + float fine = wNoise(sp / 0.8 + drift * 0.1) * 0.6 + wNoise(lp / 2.1 - drift * 0.05) * 0.4; + float lace = mix(smoothstep(0.3, 0.8, fine), 0.5, smoothstep(0.05, 0.4, footprint)); cap *= lace * 0.5 + 0.5 * cap; float far = smoothstep(1.0, 8.0, footprint); foam = max(foam, mix(cap, wp.w * 0.5, far) * 0.8); diff --git a/src/views/interior.ts b/src/views/interior.ts index 0778692..12da44f 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -109,6 +109,13 @@ interface Probe { b: number } +/** + * The water's clock while a scene is held still, seconds. Waves and foam move with time, so + * a held scene has to hold the clock too, or the screenshot tour photographs a different + * sea every run and its divergence report means nothing. + */ +const FROZEN_TIME_S = 12 + /** Seconds a scene change takes to fade out, and in again once the ground has arrived. */ const TOUR_FADE_S = 0.6 /** Longest the tour holds black waiting for the ground; past this it shows what there is. */ @@ -1077,7 +1084,7 @@ export class InteriorView { if (this.frames % 10 === 0) this.model.setWeather(this.worldHours) for (const m of this.shared) { m.uniforms['uSpin']!.value = this.spin - m.uniforms['uTime']!.value = this.time + m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : this.time } if (this.tour) this.stepTour(dt) else this.fly(dt) diff --git a/src/views/map.ts b/src/views/map.ts index a561d0d..1c69832 100644 --- a/src/views/map.ts +++ b/src/views/map.ts @@ -515,6 +515,28 @@ export class TowerMap { } else { const m = toMap({ theta: p.geometry.theta, z: p.geometry.z }) const [sx, sy] = this.worldToScreen(m.x, m.y) + /** + * A settlement is a point with an extent: the marker says where it stands, the ring + * says how far it reaches. Drawn only when it is bigger than the marker, so a + * hamlet is a dot rather than a dot inside a circle. + */ + if (p.kind === 'settlement') { + const r = p.radiusM * this.scale + if (r > MARKER_R + 2) { + ctx.beginPath() + ctx.arc(sx, sy, r, 0, Math.PI * 2) + const overTerrain = this.showTerrain && this.terrain + if (!overTerrain || selected) { + ctx.fillStyle = color + (selected ? '2e' : '22') + ctx.fill() + } + ctx.strokeStyle = overTerrain && !selected ? color + '77' : color + ctx.lineWidth = selected ? 2.5 : 1.5 + ctx.setLineDash(p.public ? [] : [5, 4]) + ctx.stroke() + ctx.setLineDash([]) + } + } ctx.beginPath() ctx.arc(sx, sy, MARKER_R, 0, Math.PI * 2) ctx.fillStyle = color diff --git a/src/views/scenic.ts b/src/views/scenic.ts index e3c2717..748b9b2 100644 --- a/src/views/scenic.ts +++ b/src/views/scenic.ts @@ -12,6 +12,7 @@ */ import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' import type { CFall, CompiledWorld } from '../terrain/compile.js' +import type { CDesert } from '../terrain/desert.js' import { type Line, nearestA, smoothLine } from '../terrain/shapes.js' import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js' import type { Feature, Sea, World } from '../world/schema.js' @@ -144,9 +145,19 @@ function flight(w: SceneWorld, line: Line, from: number, speed: number, clearanc */ function orbit(w: SceneWorld, target: At, radius: number, altM: number, startRad: number, sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose { + return orbitLooking(w, target, target, radius, altM, startRad, sweepRad, minClearM, fovDeg) +} + +/** + * The same arc, but circling one point and looking at another: for keeping the camera over + * open ground while it watches something that has no room to be circled — a mesa field the + * camera would otherwise be flying *over*. + */ +function orbitLooking(w: SceneWorld, centre: At, target: At, radius: number, altM: number, + startRad: number, sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose { return (t) => { const ang = startRad + (sweepRad * t) / SCENE_S - const z = target.z + Math.cos(ang) * radius, a = target.a + Math.sin(ang) * radius + const z = centre.z + Math.cos(ang) * radius, a = centre.a + Math.sin(ang) * radius const cam = { z, a, altM: Math.max(altM, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) } return pose(cam, lookAt(cam, target), fovDeg) } @@ -287,6 +298,81 @@ export const SCENES: Scene[] = [ return null }, }, + { + id: 'mesas', + prepare: (w) => { + /** + * The broadest stand of rock, not merely the first tall cell: scored by how much + * plateau there is within a couple of kilometres, so the camera is put in front of a + * mesa field rather than a lone butte. + */ + let best: { z: number; a: number; d: CDesert; score: number } | null = null + for (const d of w.compiled.deserts ?? []) { + const { grid } = d + const reach = Math.round(2000 / grid.cell) + for (let i = reach; i < grid.nz - reach; i += 4) { + for (let j = 0; j < grid.na; j += 4) { + if (d.plateau[i * grid.na + j]! < 0.9) continue + let score = 0 + for (let di = -reach; di <= reach; di += 2) { + for (let dj = -reach; dj <= reach; dj += 2) { + const jj = grid.ring ? ((j + dj) % grid.na + grid.na) % grid.na : j + dj + if (jj < 0 || jj >= grid.na) continue + score += d.plateau[(i + di) * grid.na + jj]! + } + } + if (!best || score > best.score) { + best = { z: grid.z0 + i * grid.cell, a: grid.a0 + j * grid.cell, d, score } + } + } + } + } + if (!best) return null + const name = feature(w.world, best.d.id)?.name ?? 'the desert' + const top = w.ground(wrapA(best.a) / RADIUS_M, best.z) + /** + * Somewhere on the open plain to watch them from: the nearest low ground a few + * kilometres off, so the camera circles over sand and the rock stands over it. + */ + const { grid } = best.d + let stand = { z: best.z, a: best.a + 4000 } + for (let r = 3500; r <= 7000; r += 500) { + for (let k = 0; k < 16; k++) { + const ang = (k / 16) * Math.PI * 2 + const z = best.z + Math.cos(ang) * r, a = best.a + Math.sin(ang) * r + const i = Math.round((z - grid.z0) / grid.cell) + const j = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na + if (i < 0 || i >= grid.nz) continue + if (best.d.plateau[i * grid.na + j]! < 0.03) { stand = { z, a }; r = 1e9; break } + } + } + const standGround = w.ground(wrapA(stand.a) / RADIUS_M, stand.z) + return { + id: 'mesas', hours: NOON, + title: `Past the mesas of ${name}: ${best.d.terraces} tiers of rock, with the strata lining up across all of them`, + pose: orbitLooking(w, + { z: stand.z, a: stand.a, altM: standGround }, + { z: best.z, a: best.a, altM: top - best.d.plateauHeightM * 0.45 }, + 1500, standGround + 320, 0.4, 1.3, 220, 55), + } + }, + }, + { + id: 'pan', + prepare: (w) => { + const d = (w.compiled.deserts ?? []).find((x) => x.pans.length > 0) + const pan = d?.pans[0] + if (!d || !pan) return null + const name = feature(w.world, d.id)?.name ?? 'the desert' + // Across the pan, low, so the benches around it stand against the sky. + const line = straight(pan.z - pan.radiusM * 2.2, pan.a, 0, pan.radiusM * 4.4) + return { + id: 'pan', hours: NOON, + title: `Over a salt pan in ${name}, ringed by the shorelines of a lake that never stood`, + pose: flight(w, line, 0, (pan.radiusM * 4.4 - 2500) / SCENE_S, 70, 2500, 60), + } + }, + }, { id: 'narrows', prepare: (w) => { diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts index 7332643..ea687f4 100644 --- a/src/views/terrain-lod.ts +++ b/src/views/terrain-lod.ts @@ -236,6 +236,8 @@ export class TerrainLOD { const mats = new InterleavedBuffer(data.materials, MATERIALS.length) g.setAttribute('aMatA', new InterleavedBufferAttribute(mats, 4, 0, true)) g.setAttribute('aMatB', new InterleavedBufferAttribute(mats, 4, 4, true)) + // Salt and sandstone sit past the two vec4s (tile.ts MATERIALS). + g.setAttribute('aExtra', new InterleavedBufferAttribute(mats, 2, 8, true)) /** * The index array is shared, but each tile gets its own attribute around it. * Sharing the attribute too would share one GL buffer, and disposing any tile diff --git a/src/world/schema.ts b/src/world/schema.ts index b1caa0f..a76d310 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -139,11 +139,44 @@ export interface River extends Base<'river'> { outlet: string | 'auto' } +/** + * A desert (world-generation.md §4.5). Tower chose every part of this: the dunes run + * where Tower sends the wind, and the washes and shorelines are marks of water that never + * ran here, quoted from Earth because that is what a desert looks like (§0.1). + */ export interface Desert extends Base<'desert'> { /** How far the desert's ground stands above the land around it. */ upliftM: number /** Width of the slope up onto it. */ edgeM: number + dunes: { + /** Share of the open ground the dune fields take, 0..1. */ + coverage: number + /** Crest to trough of the big dunes, metres. */ + heightM: number + /** Crest to crest, metres. */ + spacingM: number + /** Which way the wind blows the sand: degrees, 0 deepward, 90 spinward. */ + directionDeg: number + } + hills: { + coverage: number + heightM: number + } + plateaus: { + coverage: number + heightM: number + /** Cliff bands between flat tops. 1 is a single tier. */ + terraces: number + } + /** The marks of water: dry washes, salt pans, and the shorelines of lakes that never were. */ + pastWater: { + /** How dense the wash network is, 0..1; 0 for none. */ + washes: number + /** How many salt pans to lay out, and whether to ring them with abandoned shorelines. */ + saltPans: number + shorelines: boolean + } } export interface Forest extends Base<'forest'> { @@ -153,6 +186,14 @@ export interface Forest extends Base<'forest'> { export interface Settlement extends Base<'settlement'> { size: 'hamlet' | 'village' | 'town' | 'station' | 'city' + /** + * How far the built-up ground reaches from the marker. A settlement is a point with an + * extent rather than a traced outline: Tower lays the streets and buildings out inside it + * (world-generation.md §4.7), and what the author places is where it stands and how big + * it is. The Capital's is 10 km, which is what reaches The Deep's shore from the axial + * midpoint of the world (D-070). + */ + radiusM: number } export interface Route extends Base<'road' | 'path'> { @@ -298,11 +339,17 @@ export function withKind(common: Common, kind: FeatureKind): Feature { case 'river': return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' } case 'desert': - return { ...c, kind, upliftM: 300, edgeM: 4000 } + return { + ...c, kind, upliftM: 300, edgeM: 4000, + dunes: { coverage: 0.45, heightM: 60, spacingM: 900, directionDeg: 90 }, + hills: { coverage: 0.25, heightM: 120 }, + plateaus: { coverage: 0.3, heightM: 420, terraces: 3 }, + pastWater: { washes: 0.6, saltPans: 3, shorelines: true }, + } case 'forest': return { ...c, kind, upliftM: 0, edgeM: 3000 } case 'settlement': - return { ...c, kind, size: 'village' } + return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village } case 'road': case 'path': return { ...c, kind, widthM: kind === 'road' ? 8 : 2 } @@ -333,6 +380,11 @@ export function isAutoId(id: string, name: string): boolean { } /** A single representative point for a feature, whatever shape it is. */ +/** How far the usual settlement of each size reaches from its marker, metres. */ +export const SETTLEMENT_RADIUS_M: Record<Settlement['size'], number> = { + hamlet: 250, village: 600, station: 500, town: 1800, city: 5000, +} + export function anchorOf(f: Feature): { theta: number; z: number } { if (f.kind === 'mountains' && f.peaks[0]) return { theta: f.peaks[0].theta, z: f.peaks[0].z } const g = f.geometry @@ -434,14 +486,51 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature { return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)), sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain', outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' } - case 'desert': + case 'desert': { + const group = <T extends Record<string, number | boolean>>(src: unknown, def: T): T => { + const o = (src && typeof src === 'object' ? src : {}) as Record<string, unknown> + const out = { ...def } as Record<string, number | boolean> + for (const [k, v] of Object.entries(def)) { + out[k] = typeof v === 'boolean' ? (typeof o[k] === 'boolean' ? o[k] : v) : num(o[k], v) + } + return out as T + } + const dunes = group(f['dunes'], d.dunes) + const plateaus = group(f['plateaus'], d.plateaus) + return { + ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)), + dunes: { + ...dunes, + coverage: unit(dunes.coverage, d.dunes.coverage), + heightM: Math.max(0, dunes.heightM), + spacingM: Math.max(50, dunes.spacingM), + directionDeg: ((dunes.directionDeg % 360) + 360) % 360, + }, + hills: { + coverage: unit(group(f['hills'], d.hills).coverage, d.hills.coverage), + heightM: Math.max(0, group(f['hills'], d.hills).heightM), + }, + plateaus: { + coverage: unit(plateaus.coverage, d.plateaus.coverage), + heightM: Math.max(0, plateaus.heightM), + terraces: Math.max(1, Math.min(8, Math.round(plateaus.terraces))), + }, + pastWater: { + washes: unit(group(f['pastWater'], d.pastWater).washes, d.pastWater.washes), + saltPans: Math.max(0, Math.min(12, Math.round(group(f['pastWater'], d.pastWater).saltPans))), + shorelines: group(f['pastWater'], d.pastWater).shorelines, + }, + } + } case 'forest': case 'region': return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } case 'settlement': { const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const const size = sizes.includes(f['size'] as never) ? (f['size'] as Settlement['size']) : d.size - return { ...d, size } + // A settlement that names no extent gets the usual one for its size. + const radiusM = Math.max(50, Math.min(40_000, num(f['radiusM'], SETTLEMENT_RADIUS_M[size]))) + return { ...d, size, radiusM } } case 'road': case 'path': diff --git a/static/world.json b/static/world.json index fa1f2ef..aa32816 100644 --- a/static/world.json +++ b/static/world.json @@ -597,7 +597,27 @@ "zTo": 218000 }, "upliftM": 780, - "edgeM": 20000 + "edgeM": 20000, + "dunes": { + "coverage": 0.5, + "heightM": 80, + "spacingM": 1100, + "directionDeg": 0 + }, + "hills": { + "coverage": 0.3, + "heightM": 160 + }, + "plateaus": { + "coverage": 0.28, + "heightM": 520, + "terraces": 3 + }, + "pastWater": { + "washes": 0.7, + "saltPans": 4, + "shorelines": true + } }, { "id": "the-uplands", @@ -852,10 +872,11 @@ "kind": "settlement", "geometry": { "type": "point", - "theta": 0.02, + "theta": 0.04, "z": 270000 }, - "size": "city" + "size": "city", + "radiusM": 10000 }, { "id": "the-spindle", diff --git a/test/desert.test.ts b/test/desert.test.ts new file mode 100644 index 0000000..a106851 --- /dev/null +++ b/test/desert.test.ts @@ -0,0 +1,169 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { RADIUS_M } from '../src/canon/constants.js' +import { compileWorld } from '../src/terrain/compile.js' +import { BENCH_STEP_M, CORRIDOR_M, terrace } from '../src/terrain/desert.js' +import { prepare } from '../src/terrain/field.js' +import { MATERIALS, materialsAt } from '../src/terrain/tile.js' +import { type Desert, parseWorld } from '../src/world/schema.js' + +const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))) +const cw = compileWorld(world) +const rt = prepare(cw) +const reach = world.features.find((f): f is Desert => f.kind === 'desert')! +const d = cw.deserts.find((x) => x.id === reach.id)! +const ground = (z: number, a: number): number => rt.sample(a / RADIUS_M, z, 2).h + +/** Every cell of the desert's grid, as (index, z, a). */ +function* cells(step = 3): Generator<[number, number, number]> { + const { grid } = d + for (let i = 0; i < grid.nz; i += step) { + for (let j = 0; j < grid.na; j += step) { + yield [i * grid.na + j, grid.z0 + i * grid.cell, grid.a0 + j * grid.cell] + } + } +} + +describe('the terrace profile', () => { + it('stays within its range and climbs in the number of tiers asked for', () => { + for (const terraces of [1, 3, 5]) { + expect(terrace(0, terraces)).toBe(0) + expect(terrace(1, terraces)).toBeCloseTo(1, 6) + // As many rises as there are tiers, each with a flat bench before the next. + let risers = 0, climbing = false + let prev = terrace(0, terraces) + for (let p = 0; p <= 1; p += 0.002) { + const v = terrace(p, terraces) + expect(v).toBeGreaterThanOrEqual(prev - 1e-9) + const rising = v - prev > 1e-6 + if (rising && !climbing) risers++ + climbing = rising + prev = v + } + expect(risers).toBe(terraces) + } + }) + + it('leaves the tops flat: most of a tier is a level bench', () => { + let flat = 0, n = 0 + for (let p = 0; p <= 1; p += 0.001) { + const slope = Math.abs(terrace(p + 0.001, 3) - terrace(p, 3)) / 0.001 + if (slope < 0.05) flat++ + n++ + } + expect(flat / n).toBeGreaterThan(0.55) + }) +}) + +describe('the Dry Reach as compiled', () => { + it('lays out the plateaus over about the coverage asked for', () => { + let inside = 0, rock = 0 + for (const [k] of cells()) { + if (d.inside[k]! < 0.9) continue + inside++ + if (d.plateau[k]! > 0.5) rock++ + } + expect(inside).toBeGreaterThan(1000) + expect(rock / inside).toBeGreaterThan(reach.plateaus.coverage * 0.6) + expect(rock / inside).toBeLessThan(reach.plateaus.coverage * 1.4) + }) + + it('cuts washes that run: a network, deep where it has gathered', () => { + let cutCells = 0, inside = 0, deepest = 0 + for (const [k] of cells()) { + if (d.inside[k]! < 0.9) continue + inside++ + if (d.cut[k]! > 3) cutCells++ + deepest = Math.max(deepest, d.cut[k]!) + } + // Channels, not a lowered desert: a minority of the ground, and tens of metres deep. + expect(cutCells / inside).toBeGreaterThan(0.05) + expect(cutCells / inside).toBeLessThan(0.6) + expect(deepest).toBeGreaterThan(15) + expect(deepest).toBeLessThan(60) + }) + + it('lays the salt pans Tower asked for, spaced out, in the low ground', () => { + expect(d.pans).toHaveLength(reach.pastWater.saltPans) + for (let i = 0; i < d.pans.length; i++) { + for (let j = i + 1; j < d.pans.length; j++) { + const p = d.pans[i]!, q = d.pans[j]! + expect(Math.hypot(p.z - q.z, p.a - q.a)).toBeGreaterThan(p.radiusM * 2) + } + } + }) + + it('makes a pan floor dead flat, and steps the ground around it in benches', () => { + const pan = d.pans[0]! + const level = ground(pan.z, pan.a) + for (const off of [-pan.radiusM * 0.5, -pan.radiusM * 0.25, pan.radiusM * 0.25, pan.radiusM * 0.5]) { + expect(Math.abs(ground(pan.z + off, pan.a) - level)).toBeLessThan(2) + expect(Math.abs(ground(pan.z, pan.a + off) - level)).toBeLessThan(2) + } + // Outside it, the ground stands at the levels a lake would have left: heights cluster + // on multiples of the bench step above the floor, rather than climbing smoothly. + let onStep = 0, n = 0 + for (let off = pan.radiusM * 1.1; off < pan.radiusM + 900; off += 25) { + const above = ground(pan.z + off, pan.a) - level + if (above <= 0) continue + const away = Math.abs(above / BENCH_STEP_M - Math.round(above / BENCH_STEP_M)) + if (away < 0.22) onStep++ + n++ + } + expect(n).toBeGreaterThan(10) + expect(onStep / n).toBeGreaterThan(0.5) + }) + + it('keeps its rock off the rivers Tower had already routed', () => { + const trunk = cw.rivers.find((r) => r.id === 'the-long-water')! + for (let i = 0; i < trunk.line.s.length; i += 40) { + const z = trunk.line.pts[2 * i]!, a = trunk.line.pts[2 * i + 1]! + const { grid } = d + const gi = Math.round((z - grid.z0) / grid.cell) + if (gi < 0 || gi >= grid.nz) continue + const gj = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na + expect(d.plateau[gi * grid.na + gj]!).toBeLessThan(0.05) + } + }) + + it('claims rock where the rock is, and salt on the pans', () => { + const out = new Float32Array(MATERIALS.length) + const pan = d.pans[0]! + materialsAt(rt.sample(pan.a / RADIUS_M, pan.z, 2), 0, out) + expect(out[MATERIALS.indexOf('salt')]!).toBeGreaterThan(0.8) + expect(out[MATERIALS.indexOf('desert')]!).toBeLessThan(0.2) + + // A mesa top: bare rock, and no sand claim on it. + let mesa: [number, number] | null = null + for (const [k, z, a] of cells()) if (!mesa && d.plateau[k]! > 0.98) mesa = [z, a] + materialsAt(rt.sample(mesa![1] / RADIUS_M, mesa![0], 2), 0, out) + expect(out[MATERIALS.indexOf('rock')]! + out[MATERIALS.indexOf('sandstone')]!).toBeGreaterThan(0.8) + }) + + it('stands a dune field up in the sand, crest to trough about the height asked for', () => { + let sandy: [number, number] | null = null + for (const [k, z, a] of cells()) { + if (!sandy && d.sand[k]! > 0.95 && d.plateau[k]! < 0.02 && d.cut[k]! < 1) sandy = [z, a] + } + expect(sandy).not.toBeNull() + // Across the wind — the dunes' crests run across it, so a transect along it crosses them. + let lo = Infinity, hi = -Infinity + for (let i = 0; i < 40; i++) { + const h = ground(sandy![0] + i * 60, sandy![1]) + lo = Math.min(lo, h); hi = Math.max(hi, h) + } + expect(hi - lo).toBeGreaterThan(reach.dunes.heightM * 0.3) + expect(hi - lo).toBeLessThan(reach.dunes.heightM * 2.5) + }) + + it('costs about a second of the compile, and no seam where theta wraps', () => { + const { grid } = d + expect(grid.cell * grid.na).toBeCloseTo(2 * Math.PI * RADIUS_M, 3) + for (const z of [152_000, 170_000, 200_000]) { + const before = ground(z, -30) + const after = ground(z, 2 * Math.PI * RADIUS_M - 30) + expect(Math.abs(after - before)).toBeLessThan(0.01) + } + expect(CORRIDOR_M).toBeGreaterThan(1000) + }) +}) diff --git a/test/terrain.test.ts b/test/terrain.test.ts index f71ccd3..751cd1b 100644 --- a/test/terrain.test.ts +++ b/test/terrain.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { RADIUS_M } from '../src/canon/constants.js' +import { LENGTH_M, RADIUS_M } from '../src/canon/constants.js' import { compileWorld } from '../src/terrain/compile.js' import { Stage, prepare } from '../src/terrain/field.js' import { nearestA } from '../src/terrain/shapes.js' @@ -256,7 +256,42 @@ describe('water', () => { deepest = Math.max(deepest, rim - floor) } expect(deepest).toBeGreaterThan(300) - expect(deepest).toBeLessThan(700) + // Since the Dry Reach grew mesas (D-069), the rim above the lower river is the desert's + // rock as often as the ridge's, and the deepest section runs about 780 m. + expect(deepest).toBeLessThan(1100) + }) + + it('puts the Capital at the centre of the world with its edge on the water', () => { + // D-070: 19 km across, centred on the axial midpoint, reaching The Deep's shore. + const capital = world.features.find((f) => f.id === 'the-capital')! + expect(capital.kind).toBe('settlement') + if (capital.kind !== 'settlement' || capital.geometry.type !== 'point') throw new Error('shape') + expect(capital.geometry.z).toBe(LENGTH_M / 2) + const { theta, z } = capital.geometry + // Dry at the marker, and wet at the spinward edge: the city has a waterfront. + expect(rt.sample(theta, z, 5).water).toBe(-Infinity) + expect(rt.sample(theta + capital.radiusM / RADIUS_M, z, 5).water).toBeGreaterThan(0) + // And the shore is inside the city rather than a long way outside it: within 2 km. + expect(rt.sample(theta + (capital.radiusM - 2000) / RADIUS_M, z, 5).water).toBe(-Infinity) + }) + + it('keeps the plateaus off the river, so The Narrows is a gap and not a slot', () => { + // Tower did not raise rock across a route it had already cut (desert.ts CORRIDOR_M). + const trunk = cw.rivers.find((r) => r.id === 'the-long-water')! + let deepest = 0 + for (let i = 0; i < trunk.level.length; i++) { + const z = trunk.line.pts[2 * i]! + if (z < 165_000 || z > 180_000) continue + const theta = trunk.line.pts[2 * i + 1]! / RADIUS_M + const floor = rt.sample(theta, z, 5).h + for (const off of [2000, 3000, 4000, 5000]) { + deepest = Math.max(deepest, + rt.sample(theta + off / RADIUS_M, z, 5).h - floor, + rt.sample(theta - off / RADIUS_M, z, 5).h - floor) + } + } + expect(deepest).toBeGreaterThan(150) + expect(deepest).toBeLessThan(500) }) it('builds the floor up under a river only a little, now the lowland is raised', () => { diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 9b34a7d..4d1fcfb 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -334,6 +334,30 @@ const SHOTS = [ await settle(page, 1400) }, }, + { + id: 'tour-mesas', + caption: 'Scenic tour — past the mesas of the Dry Reach, strata lining up across all of them', + on: ['desktop'], + scene: 'mesas', + run: async (page) => { + await openView(page, 'interior') + const title = await page.evaluate(() => window.tower.showScene('mesas', 16)) + if (!title) throw new Error('no scene mesas in this world') + await settle(page, 1400) + }, + }, + { + id: 'tour-pan', + caption: 'Scenic tour — over a salt pan, ringed by the shorelines of a lake that never stood', + on: ['desktop'], + scene: 'pan', + run: async (page) => { + await openView(page, 'interior') + const title = await page.evaluate(() => window.tower.showScene('pan', 26)) + if (!title) throw new Error('no scene pan in this world') + await settle(page, 1400) + }, + }, { id: 'tour-narrows', caption: 'Scenic tour — through The Narrows, the one gap in all three ridges', @@ -688,6 +712,13 @@ const flag = (name) => { } const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD) const givenUrl = flag('--url') +/** + * `--only tour-storm,interior` retakes just those shots (id prefixes) and leaves the rest + * of the sheet alone. For working on one view: the whole tour is 23 shots and three + * minutes of somebody's fans. + */ +const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean) +const wanted = (shot) => only.length === 0 || only.some((o) => shot.id.startsWith(o)) await mkdir(OUT, { recursive: true }) /** @@ -697,7 +728,7 @@ await mkdir(OUT, { recursive: true }) const previous = new Map() for (const profile of PROFILES) { for (const shot of SHOTS) { - if (!shot.on.includes(profile.id)) continue + if (!shot.on.includes(profile.id) || !wanted(shot)) continue const file = fileFor(profile.id, shot.id) const buf = await readFile(file).catch(() => null) if (buf) previous.set(`${profile.id}-${shot.id}`, `data:image/png;base64,${buf.toString('base64')}`) @@ -749,7 +780,7 @@ try { const jobs = [] for (const profile of PROFILES) { for (const shot of SHOTS) { - if (shot.on.includes(profile.id)) jobs.push({ profile, shot }) + if (shot.on.includes(profile.id) && wanted(shot)) jobs.push({ profile, shot }) } } const results = new Array(jobs.length) @@ -780,8 +811,13 @@ try { */ const sheet = await launch() try { - console.log('Overview') - await buildOverview(sheet, cells) + // The sheet is every shot together, so it is only rebuilt when every shot was taken. + if (only.length) { + console.log(`Only ${only.join(', ')}: the overview sheet is left as it was.`) + } else { + console.log('Overview') + await buildOverview(sheet, cells) + } if (!previous.size) { console.log('\nNothing to compare against — this run is the baseline.') diff --git a/world-generation.md b/world-generation.md index 41160a9..486bf24 100644 --- a/world-generation.md +++ b/world-generation.md @@ -1,7 +1,8 @@ # Tower — Live World Generation -**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–4 are built** -(D-063 to D-067), with a scenic tour between 4 and 5 (§19, D-068); phases 5–8 are not. This document replaces the terrain pipeline of D-019 and reverses +**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–5 are built** +(D-063 to D-067, D-069), with a scenic tour between 4 and 5 (§19, D-068); phases 6–8 are +not. This document replaces the terrain pipeline of D-019 and reverses D-006; see §11 for every existing decision it touches, and §15 for where Phase 1 departed from the plan and why. @@ -439,6 +440,9 @@ Because none of it is history, the generator is free to place these as **set pieces** — a salt pan ringed by shorelines where the author wants the view, not where a drainage model would demand one. +*As built (§20): all of the above, with the pans placed in the lowest ground rather than +by hand, and the terraces cut by the tiles rather than compiled.* + ### 4.6 Forest — "trees, planned concentrations, meadows, paths" **Trees are real size.** Conifers 20–45 m tall with narrow crowns; broadleaf 15–30 m @@ -501,6 +505,10 @@ other feature. Where it overlaps Greatwood, the Tall Wood's trees replace Greatw ### 4.7 Settlements — "blocky buildings and a few simple roads" +*As built so far (D-070): a settlement is a **point with an extent** — a size and a radius, +drawn on the map as a ring — and the Capital is 20 km across, centred on the axial midpoint, +which reaches The Deep's shore. The layout inside it is this phase's work.* + No usable city generator exists on npm (§7.3), so this is written in-house and kept deliberately simple, as asked. One generator handles every size; `size` sets the defaults: @@ -1026,7 +1034,7 @@ screenshot check before any commit. | 2 ✅ | **Mountains** | ridge graph, erosion, peak editing | | 3 ✅ | **Water** | seas and lakes with surfaces and waves; rivers with profiles, flow and waterfalls | | 4 ✅ | **Weather and ships** | weather plan with revisions and the day clock; plan board in the panel; procedural hulls, routes, harbour waits, heave on the waves | -| 5 | **Desert** | dunes, hills, terraced plateaus, washes, salt pans, shorelines | +| 5 ✅ | **Desert** | dunes, hills, terraced plateaus, washes, salt pans, shorelines | | 6 | **Forest** | density field, stands, meadows, the tall forest, three-distance tree rendering | | 7 | **Settlements, roads, paths** | one layout generator for every size, generated settlements and Adopt, routing, grading, buildings | | 8 | **Editing polish, performance, phones** | incremental compile everywhere, IndexedDB caches, phone tier, screenshot tour | @@ -1253,7 +1261,59 @@ find them, and a screenshot at a fixed place was a poor way to show them. - **Controls**: *Scenic tour* in the interior's panel starts and stops it; **N** skips to the next scene; **Esc**, a drag, the wheel or a flight key stops it where it is. - **The screenshot tour photographs the scenes**, each held at one moment - (`tower.showScene(id, t)`), in place of four hand-placed shots. A test checks that every + (`tower.showScene(id, t)`), in place of four hand-placed shots. A held scene stops the + water's clock as well as the world's, or waves and foam made every run diverge; and + `--only <ids>` retakes a few shots rather than all twenty-three. A test checks that every scene the screenshot tool names exists, and flies every scene over the real world: cameras stay in the world and above the ground, move and turn smoothly, and keep a followed ship in the middle of the frame. + +--- + +## 20. Phase 5, as built (D-069) + +- **A desert is a grid and a pile of noise.** `terrain/desert.ts` compiles what a tile + cannot work out alone — the plateau field, the wash network, the pans and their + shorelines — on a 250 m grid over the desert (352,000 cells for the Dry Reach, about a + second). Dunes and rocky hills are noise, and the tiles make them (field.ts). +- **The grid machinery is now shared.** `terrain/grid.ts` holds the grid, the priority + flood, the box blur, the neighbourhood walk and bilinear sampling, which the ranges and + the deserts both use; the ranges' copies moved there unchanged. +- **Terraces are cut as the tile samples, not in the compile.** The compile stores how far + up the plateau each cell stands, smooth; `terrace()` turns that into flat tops and steep + risers at whatever detail the tile has. Terraced in the compile, every cliff would be as + wide as a grid cell — a 250 m ramp instead of a cliff band. +- **The plateau threshold is measured, not guessed.** The authored coverage is turned into + a threshold by taking that quantile of the noise as sampled over this desert, and the + same for the dune fields. Thresholds picked against an assumed spread put mesas over + everything or nowhere — which is exactly what the first attempt did. +- **The washes are routed, not drawn.** The same priority flood the ranges erode with runs + over the desert's own surface, and where a cell would drain more than 15 km² it is cut + by 4 m and more, to tens of metres on the trunks. Two things this taught: + - A flat desert has no drainage at all. The first version fanned out from every edge and + gathered nothing, so **Tower crowns its deserts**: the middle of the Reach stands 150 m + above its rim, and everything runs off it. + - Widening a one-cell channel by blurring it averages the channel away. The wash is + **dilated** to its full depth over a couple of cells first, and blurred afterwards for + the banks. +- **Salt pans are set pieces in the low ground** (§4.5 allows exactly this): the lowest + cells, spaced apart, each a disc flattened to one level with a crust that frays at the + rim, and benches stepped every 20 m around it — the shorelines of a lake that never + stood. Both are **levels in the world**, not in the sand: the shelf under the desert + rolls by a hundred metres, and a floor that followed it would not be a floor. +- **Tower keeps its rock off its rivers.** A mesa field dropped across The Narrows left + the whole world's drainage cutting a kilometre-deep slot through it. Rivers now carry a + 6.5 km corridor the plateaus keep out of, which is also what a river crossing a mesa + field looks like. Beside the lower Long Water the desert's rock still stands over the + river, and that section of canyon now runs about 780 m rather than 480. +- **Rock is a material, and so is salt.** Materials gained a ninth weight, `salt`, and + bare rock is now claimed by the desert's plateaus, its gravel wash floors, and **any + steep desert face** — sand does not stand at 60°. The ground shader lays **strata** by + elevation, so the bands line up across separate mesas however far apart they stand, + which is what makes a field of them read as one eroded formation. +- **The panel edits all of it**: dune height, spacing, direction and coverage; hills; + plateau height, coverage and terraces; washes, pans and shorelines — with a readout of + what was actually made ("24% standing rock, 37% dune field, 4 salt pans"). +- **The tour gained two scenes** (§19): past the mesas from the sand, and over a salt pan + ringed by its shorelines. +- **Cost**: the whole world compiles in about 5 s, of which the Dry Reach is about 1 s.