tower / commit
feat(forest): three billion trees, meadows, and the Tall Wood of giants
README.md | 26 +- content/world.json | 81 +++++ decisions.md | 41 +++ design.md | 19 +- docs/screenshots/desktop-exterior-true.png | Bin 538823 -> 542364 bytes docs/screenshots/desktop-exterior.png | Bin 527653 -> 550576 bytes docs/screenshots/desktop-interior-dawn.png | Bin 492539 -> 496197 bytes docs/screenshots/desktop-interior-night.png | Bin 295555 -> 408719 bytes docs/screenshots/desktop-interior-overhead.png | Bin 509330 -> 739221 bytes docs/screenshots/desktop-interior-suncap.png | Bin 399943 -> 415457 bytes docs/screenshots/desktop-interior.png | Bin 863312 -> 988678 bytes docs/screenshots/desktop-map-sightline.png | Bin 939872 -> 1042951 bytes docs/screenshots/desktop-map.png | Bin 973258 -> 1161098 bytes docs/screenshots/desktop-reader.png | Bin 957878 -> 1149474 bytes docs/screenshots/desktop-tour-canopy.png | Bin 0 -> 882973 bytes docs/screenshots/desktop-tour-deep.png | Bin 342461 -> 451631 bytes docs/screenshots/desktop-tour-falls.png | Bin 962167 -> 962615 bytes docs/screenshots/desktop-tour-giants.png | Bin 0 -> 136478 bytes docs/screenshots/desktop-tour-girdle.png | Bin 742554 -> 742666 bytes docs/screenshots/desktop-tour-mesas.png | Bin 806627 -> 807033 bytes docs/screenshots/desktop-tour-narrows.png | Bin 724197 -> 778918 bytes docs/screenshots/desktop-tour-pan.png | Bin 701953 -> 805207 bytes docs/screenshots/desktop-tour-river.png | Bin 538717 -> 957780 bytes docs/screenshots/desktop-tour-ship.png | Bin 413989 -> 413835 bytes docs/screenshots/desktop-tour-squall.png | Bin 407719 -> 408750 bytes docs/screenshots/desktop-tour-storm.png | Bin 466188 -> 466243 bytes docs/screenshots/desktop-tour-summit.png | Bin 986118 -> 986199 bytes docs/screenshots/mobile-exterior.png | Bin 119350 -> 121175 bytes docs/screenshots/mobile-interior.png | Bin 266383 -> 288214 bytes docs/screenshots/mobile-map.png | Bin 242719 -> 339538 bytes docs/screenshots/mobile-panel.png | Bin 101248 -> 106125 bytes docs/screenshots/overview.png | Bin 2588884 -> 3052463 bytes index.html | 13 + src/terrain/compile.ts | 17 +- src/terrain/field.ts | 32 +- src/terrain/forest.ts | 346 +++++++++++++++++++ src/terrain/service.ts | 24 ++ src/terrain/tile.ts | 68 +++- src/terrain/worker.ts | 1 + src/ui/app.ts | 76 ++++- src/views/forest-view.ts | 443 +++++++++++++++++++++++++ src/views/interior-model.ts | 271 ++++++++++++++- src/views/scenic.ts | 45 +++ src/views/terrain-lod.ts | 22 +- src/world/schema.ts | 28 +- static/world.json | 81 +++++ test/forest.test.ts | 170 ++++++++++ test/terrain.test.ts | 8 +- tools/screenshots.mjs | 31 +- world-generation.md | 78 ++++- 50 files changed, 1896 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2617f7e..8decb7e 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ sea, river, desert and forest is a feature the browser turns into ground on dema 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. The Dry Reach has mesas, dune fields, dry washes and salt pans. Phases 6–8 add -forests of individual trees, settlements and roads. +by it. The Dry Reach has mesas, dune fields, dry washes and salt pans, and Greatwood is +three billion individual trees with meadows through it and a wood of 100 m giants near the +Capital. Phases 7–8 add 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 @@ -95,6 +96,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-giants](docs/screenshots/desktop-tour-giants.png) | scenic tour: under the giants of the Tall Wood, 115 m of them | +| [tour-canopy](docs/screenshots/desktop-tour-canopy.png) | scenic tour: low over Greatwood, closed canopy and the meadows left open in it | | [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 | @@ -145,7 +148,7 @@ 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 # 178 tests; canon figures are pinned to design.md +npm test # 189 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 @@ -276,6 +279,18 @@ 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 forest is trees.** Greatwood is about three billion of them, so only the ones you +could see as trees exist at any moment: meshes within a couple of hundred metres, then +cards turned to face you, then clumps — one card for every thirty metres of wood — out to +nearly three kilometres, over a canopy that closes the ground behind them, with the meadows +showing through as grass. Which trees stand where is a rule rather than a list, so they are the +same trees for everyone and they are still there when you walk back. **The Tall Wood** is +52 km² of giants near the Capital — 90 to 115 m, about as tall as a tree can be at 1 g — +placed once from the world's seed and then left where it fell. + +A settlement clears the trees it stands on, so the Capital is a 20 km opening in Greatwood +rather than a city in a wood. + **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. @@ -383,6 +398,11 @@ far it reaches across. A settlement is a point with an extent, not a traced outl 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 a **forest** to set what Tower planted there: stems per hectare, how much of it is +conifer, how much of the ground is left open as meadows, the treeline, and whether it is a +wood of giants. The hint says what that comes to — how many trees, over how much ground, +how far apart, and how high the canopy stands. + 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 diff --git a/content/world.json b/content/world.json index 65ee6bc..53c641f 100644 --- a/content/world.json +++ b/content/world.json @@ -922,6 +922,87 @@ "z": 102000 }, "size": "station" + }, + { + "id": "the-tall-wood", + "name": "The Tall Wood", + "blurb": "", + "notes": "Giants: 90–115 m, near the tallest a tree can be at 1 g. Tower drew this place once from the world's seed — 17 km spinward-and-deepward of the Capital, well inside Greatwood, clear of the water and the rivers — and it has stood there ever since. Greatwood gives up its own ground where the giants stand.", + "public": true, + "seed": 1846203771, + "kind": "forest", + "geometry": { + "type": "polygon", + "points": [ + [ + 6.04707, + 282963 + ], + [ + 6.07639, + 282501 + ], + [ + 6.11914, + 282453 + ], + [ + 6.13135, + 280578 + ], + [ + 6.11988, + 278826 + ], + [ + 6.1282, + 276867 + ], + [ + 6.08876, + 276340 + ], + [ + 6.06402, + 275222 + ], + [ + 6.02828, + 274789 + ], + [ + 5.9941, + 275563 + ], + [ + 5.97356, + 277086 + ], + [ + 5.97544, + 278832 + ], + [ + 5.96794, + 280494 + ], + [ + 5.97979, + 282237 + ], + [ + 6.01586, + 282713 + ] + ] + }, + "upliftM": 0, + "edgeM": 900, + "densityPerHa": 30, + "conifer": 1, + "meadows": 0.05, + "giants": true, + "treelineM": 2200 } ], "weather": { diff --git a/decisions.md b/decisions.md index 982e76e..4e746ed 100644 --- a/decisions.md +++ b/decisions.md @@ -2299,3 +2299,44 @@ that true but 2 km of marker. 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. + +--- + +## D-071 — Three billion trees, and only the near ones exist + +**Built.** `world-generation.md` phase 6; §21 there has the detail. + +**A forest is a rule, not a list.** Greatwood holds about three billion trees at 450 stems +per hectare. What exists at any moment is the few thousand near the eye, and they are found +by a stratified rule — a fixed grid about one tree apart, each cell hashing its own +coordinates — so every tile, every view and every visitor works out the same trees without +knowing about any other. Walk a kilometre and come back and every trunk is where it was. + +**Four ranges, and the handovers are the hard part.** Meshes to about 210 m, cards to +560 m, clumps — one card for every 30 m cell of wood — to 2.8 km, and under all of it from +620 m the canopy skin: the tile's own ground vertices lifted by the canopy's height, which +sinks back onto the ground as the eye comes close. The plan had three ranges ending at +760 m; the author saw the result at once — the wood ended a few hundred metres away and its +edge walked along with him. Clumps are what made the reach affordable: a card per tree is a +million of them at two kilometres, a clump every thirty metres is twenty-six thousand. + +**The skin was a black ceiling from underneath**, because a solid surface is what it is. +Real canopy is about a third gaps, and those gaps are the only reason you can see any +distance through a forest. From below it now drops half its fragments on a ground-fixed +pattern and lights the rest as light that has come through leaves. + +**Two numbers the plan had wrong, found by looking:** a meadow's edge cannot be a fixed +±0.02 of noise (the same lesson the desert taught about thresholds), and a giant's crown +cannot be 0.34 of its height — at that width a grove at 55 stems/ha closes over into +unbroken black. Redwoods carry about 0.18, and 30 stems per hectare puts 18 m between +trunks. + +**A city is not a wood.** The Capital sits in the middle of Greatwood's belt, so once the +forest was trees it was standing in one. Settlements now clear the forest inside their +footprint — which is also the first thing the Capital's new extent (D-070) has been used +for. + +**The Tall Wood was placed once and written down.** Drawn from the world's seed under the +author's rules — inside Greatwood, 15–50 km from the Capital, off the water and the rivers +— it landed 17 km from the city and is now a feature in `content/world.json` like any +other. Greatwood gives up its own ground wherever a smaller forest stands inside it. diff --git a/design.md b/design.md index bbac896..88ce1fd 100644 --- a/design.md +++ b/design.md @@ -549,7 +549,7 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4): | 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: 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 | +| 214–430 km | **Greatwood** | forest belt: about three billion trees, with meadows Tower left open in it (D-071) | | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around | | 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 | @@ -697,6 +697,7 @@ src/ 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 + forest.ts where trees stand, how big they are, and what a canopy is 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 @@ -714,6 +715,7 @@ src/ palette.ts one colour per feature kind, shared by map, list and editor terrain-lod.ts the ground as a quadtree of tiles, refined around the eye scenic.ts the scenic tour's camera moves, worked out from the world + forest-view.ts the trees near the eye: meshes, then cards, then the canopy skin world/ schema.ts world.json v2 types + validation: features, not places store.ts feature CRUD, localStorage drafts, JSON import/export @@ -927,6 +929,15 @@ rivers carry ripples downstream at the speed Manning's equation gives them, and white on their rapids. Whitecaps cover the share of a sea the wind speed gives. Water mirrors the hazed land overhead, and glints where it mirrors the beam. +**The forest is trees** (D-071). Greatwood is a density field — stems per hectare, thinned +at its edges, opened where Tower left meadows, stopped by water, rock and the treeline — and +the trees themselves are found by a rule rather than kept in a list, so the same trunks +stand in the same places for everyone who walks there. Within a few hundred metres they are +meshes; out to three quarters of a kilometre, cards turned to face the eye; beyond that the +tile carries a canopy skin at the canopy's own height, which sinks back onto the ground as +you approach. **The Tall Wood** is 52 km² of 90–115 m giants near the Capital, placed once +from the world's seed: a landmark standing 75 m proud of the wood around it. + **Ships** sail the ring seas (D-067): procedural hulls, mainsails and jibs, instanced, each on a lane round the world at a position that is a function of the clock, riding the waves drawn under it with the mainsail set to the wind. While a gale or storm is published for @@ -1105,7 +1116,7 @@ ground, the status dialog, keyboard flight, the map drawn from the features. Pha (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. 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 +(D-069): the desert's mesas, dunes, washes, salt pans and shorelines. Phase 6 ✅ (D-071): +Greatwood as individual trees, its meadows, and the Tall Wood of giants. Phases 7–8 bring +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 0648b8b..78745c1 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 a62bc3e..543952f 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 0113287..68c64fe 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 47bdd3f..1b55b69 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 ee8670c..f2aff89 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 b0119b5..ebb8347 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 3715955..7f1fca2 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 b39ed84..f767409 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 6740459..343f2f7 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 5e9f17c..7aa838e 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/desktop-tour-canopy.png b/docs/screenshots/desktop-tour-canopy.png new file mode 100644 index 0000000..34e2c1b Binary files /dev/null and b/docs/screenshots/desktop-tour-canopy.png differ diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png index 91fe3d1..e0e595f 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 4b2fbf5..7588507 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-giants.png b/docs/screenshots/desktop-tour-giants.png new file mode 100644 index 0000000..7dd9d67 Binary files /dev/null and b/docs/screenshots/desktop-tour-giants.png differ diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png index aa2d2e3..0de139e 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 index 36345a0..b54b03c 100644 Binary files a/docs/screenshots/desktop-tour-mesas.png 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 f3daaba..53c70cc 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 index 550b3dc..7f2a827 100644 Binary files a/docs/screenshots/desktop-tour-pan.png 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 45cefda..5398d34 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 b32a83b..64ce063 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 b023fa5..82dce57 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 958a624..c82691e 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 449ed59..eb48017 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 4275098..e067e8c 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 c291002..f15bb62 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 d2d9f35..cdffcb9 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 8e6689a..baf2316 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 41f9b32..656e338 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/index.html b/index.html index bbf48ea..7b01b52 100644 --- a/index.html +++ b/index.html @@ -185,6 +185,19 @@ <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-forest" hidden> + <p class="sub">Trees <span class="dim">— what Tower planted here</span></p> + <div class="pair"> + <label>Stems per hectare <input id="f-density" type="number" min="1" max="2000" step="25" /></label> + <label>Conifer share <input id="f-conifer" type="number" min="0" max="1" step="0.05" /></label> + </div> + <div class="pair"> + <label>Meadows <input id="f-meadows" type="number" min="0" max="1" step="0.05" /></label> + <label>Treeline, m <input id="f-treeline" type="number" min="0" max="5000" step="100" /></label> + </div> + <label class="row"><input id="f-giants" type="checkbox" /> Giants — a tall wood of 90–115 m trees</label> + <p class="hint" id="f-forest-state"></p> + </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"> diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts index 656c3a3..70006ef 100644 --- a/src/terrain/compile.ts +++ b/src/terrain/compile.ts @@ -13,9 +13,12 @@ * the answer. */ import { RADIUS_M } from '../canon/constants.js' -import type { Desert as DesertFeature, Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js' +import type { + Desert as DesertFeature, Feature, Forest as ForestFeature, Lake, Mountains, River, Sea, World, +} from '../world/schema.js' import { type CMountainGroup, compileMountains } from './mountains.js' import { type CDesert, compileDeserts } from './desert.js' +import { type CForest, compileForests } from './forest.js' import { FALL_MIN_M, foamOf, manningSpeed } from './water.js' import { hash3 } from './noise.js' import { @@ -111,6 +114,8 @@ export interface CompiledWorld { uplifts: CUplift[] /** The deserts' compiled landforms: plateaus, washes, salt pans (desert.ts). */ deserts: CDesert[] + /** The forests: their density fields and what grows in them (forest.ts). */ + forests: CForest[] mountains: CMountainGroup[] basins: CBasin[] rivers: CRiver[] @@ -135,7 +140,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: [], deserts: [], mountains: [], basins: [], rivers: [], falls: [], + uplifts: [], deserts: [], forests: [], mountains: [], basins: [], rivers: [], falls: [], } const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed) @@ -172,6 +177,14 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com ? [smoothLine(f.geometry.points, 400)] : [])), (z, a) => shelf.sample(a / RADIUS_M, z, 1).h) + // Forests need nothing but their own shape: what thins them out — water, rock, the + // treeline — is read off the ground as a tile samples it (forest.ts). + cw.forests = compileForests( + world.features.filter((f): f is ForestFeature => f.kind === 'forest'), seedOf, onStep, + // Settlements clear the ground they stand on: no forest inside a city (§4.6). + world.features.flatMap((f) => (f.kind === 'settlement' && f.geometry.type === 'point' + ? [{ z: f.geometry.z, a: f.geometry.theta * RADIUS_M, radiusM: f.radiusM }] : []))) + // 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/field.ts b/src/terrain/field.ts index 612fd40..7f848ae 100644 --- a/src/terrain/field.ts +++ b/src/terrain/field.ts @@ -22,6 +22,7 @@ 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 CForest, canopyAt, stemsAt } from './forest.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' @@ -70,6 +71,9 @@ export interface Sample { rock: number /** Salt crust, 0..1: a pan's floor. */ salt: number + /** Trees standing here, stems per hectare, and how high their canopy reaches, metres. */ + stems: number + canopyM: number snowlineM: number } @@ -113,7 +117,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, rock: 0, salt: 0, snowlineM: 3000, + desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, stems: 0, canopyM: 0, snowlineM: 3000, } private nearest: Nearest = { d: 0, seg: 0, t: 0 } @@ -142,6 +146,8 @@ export class Runtime { o.relief = 0 o.rock = 0 o.salt = 0 + o.stems = 0 + o.canopyM = 0 o.snowlineM = 3000 if (upTo === Stage.MountainsOnly) { @@ -177,6 +183,11 @@ export class Runtime { w.rivers.forEach((r, i) => { if (inBox(r.box, z, a)) river(r, this.indexes[i]!, p, z, a, detailM, o, this.nearest) }) + // Trees last: what stops them — water, rock, sand, the treeline — is everything above. + for (const f of w.forests ?? []) { + if (!inBox(f.box, z, a)) continue + forest(f, p, z, a, detailM, o) + } return o } } @@ -229,6 +240,25 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim) } +/** + * The trees standing on this ground (forest.ts). A tile draws the canopy from a distance + * and the trees themselves near the eye (views/forest-view.ts); both read this. + * + * Where two forests overlap, the taller canopy is the one you would see — and a smaller + * forest has already taken its own ground out of the larger one (forest.ts `holes`), so + * the tall wood stands instead of Greatwood rather than through it. + */ +function forest(f: CForest, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { + const stems = stemsAt(f, p, z, a, detailM, { + h: o.h, water: o.water > o.h, desert: o.desert, rock: o.rock, + }) + if (stems <= 0) return + const canopy = canopyAt(f, stems) + if (canopy < o.canopyM) return + o.stems = stems + o.canopyM = canopy +} + /** * A desert's landforms on top of its shelf (desert.ts, world-generation.md §4.5). * diff --git a/src/terrain/forest.ts b/src/terrain/forest.ts new file mode 100644 index 0000000..da0c4dc --- /dev/null +++ b/src/terrain/forest.ts @@ -0,0 +1,346 @@ +/** + * Forests: where trees stand, how big they are, and how to find the ones near you without + * knowing about any of the others. See world-generation.md §4.6. + * + * Tower planted these, so the numbers are a statement of what it planted: stems per + * hectare, the mix of conifer and broadleaf, the share of ground left open as meadows, and + * — in the tall wood — sequoia-class giants grown near the limit of what a tree can be at + * 1 g (about 122–130 m, Koch et al. 2004). + * + * **Placement is stratified, not solved.** The surface is divided into a fixed global grid + * of cells about one tree apart. Each cell hashes its own coordinates to decide whether it + * holds a tree, where in the cell it stands, and what it is — and accepts it with + * probability density/max. That is blue-noise-like, and, the property that matters here, + * **any tile or any view can work out its own trees with no knowledge of any other** + * (§4.6). Greatwood holds about three billion trees; only the few thousand near the eye + * are ever made. + */ +import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' +import { type CylPoint, cylPoint, fbm, hash3, mulberry32 } from './noise.js' +import { type Box, type Shape, nearestA, shapeBox, shapeOf, signedDistance } from './shapes.js' +import type { Forest } from '../world/schema.js' + +export const HECTARE_M2 = 10_000 + +export interface CForest { + id: string + shape: Shape + box: Box + seed: number + densityPerHa: number + conifer: number + giants: boolean + edgeM: number + treelineM: number + /** + * The band of this forest's meadow noise that a meadow's edge falls in: below `meadowLo` + * is closed forest, above `meadowHi` is open ground. Both are measured over the forest + * itself rather than assumed — and so is the *width* between them, or a band guessed in + * noise units swallows the whole forest when the noise turns out to vary by less + * than that. + */ + meadowLo: number + meadowHi: number + /** Mean height of a mature tree here, metres: what the canopy stands at from a distance. */ + canopyM: number + /** + * Forests inside this one that replace it: the tall wood stands *instead of* Greatwood + * where it stands, rather than as giants mixed through it (§4.6). The smaller forest + * wins its own ground. + */ + holes: Shape[] + /** + * Ground the trees are off: a settlement's footprint. Tower did not plant a forest where + * it built a city, and the Capital stands in the middle of Greatwood's belt. + */ + clearings: { z: number; a: number; radiusM: number }[] +} + +/** Heights, crown widths and spacing, by what a tree is. See §4.6. */ +export const TREE = { + conifer: { minM: 20, maxM: 45, crown: 0.28 }, + broadleaf: { minM: 15, maxM: 30, crown: 0.62 }, + /** + * Coast redwoods reach 116 m on Earth; hydraulics cap any tree near 122–130 m. Their + * crowns are narrow for their height — a 100 m redwood carries about 18 m of crown, not + * the 35 m a conifer's usual proportions would give it, and at 0.34 the grove closed + * over into unbroken dark. + */ + giant: { minM: 90, maxM: 115, crown: 0.18 }, +} as const + +/** Giants stand far apart: a grove is dark and open, not a thicket — 18 m between trunks. */ +export const GIANT_DENSITY_PER_HA = 30 + +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) +} + +const layer = (seed: number, i: number): number => hash3(seed, i, 0x0f0e) & 0x7fffffff + +/** The meadow field: high where Tower left the ground open. */ +function meadowNoise(f: CForest, p: CylPoint, detailM: number): number { + return fbm(layer(f.seed, 1), p, 2600, 3, detailM) * 0.7 + + fbm(layer(f.seed, 2), p, 11_000, 2, detailM) * 0.3 +} + +export function compileForests( + features: Forest[], seedOf: (f: Forest) => number, + onStep: (label: string) => void = () => {}, + clearings: { z: number; a: number; radiusM: number }[] = [], +): CForest[] { + const out: CForest[] = [] + for (const f of features) { + const shape = shapeOf(f.geometry) + if (!shape) continue + onStep(f.giants ? `Growing the giants of ${f.name}` : `Planting ${f.name}`) + const seed = seedOf(f) + const box = shapeBox(shape) + const density = f.giants ? Math.min(f.densityPerHa, GIANT_DENSITY_PER_HA) : f.densityPerHa + const kind = f.giants ? TREE.giant : null + const c: CForest = { + id: f.id, shape, box: grow(box, f.edgeM), seed, + densityPerHa: density, conifer: f.conifer, giants: f.giants, + edgeM: f.edgeM, treelineM: f.treelineM, meadowLo: Infinity, meadowHi: Infinity, + holes: [], clearings, + canopyM: kind + ? (kind.minM + kind.maxM) / 2 + : (f.conifer * (TREE.conifer.minM + TREE.conifer.maxM) + + (1 - f.conifer) * (TREE.broadleaf.minM + TREE.broadleaf.maxM)) / 2, + } + const band = meadowBandOf(c, f.meadows) + c.meadowLo = band.lo + c.meadowHi = band.hi + out.push(c) + } + // Each forest yields its ground to any smaller forest standing inside it. + for (const f of out) { + for (const g of out) { + if (g === f || areaOf(g.shape) >= areaOf(f.shape)) continue + f.holes.push(g.shape) + } + } + return out +} + +/** Roughly how much ground a shape covers, m²: only ever compared with another's. */ +function areaOf(shape: Shape): number { + if (shape.type === 'band') return (shape.zTo - shape.zFrom) * CIRCUMFERENCE_M + let sum = 0 + const { pts } = shape + for (let i = 0, n = pts.length / 2; i < n; i++) { + const j = (i + 1) % n + sum += pts[2 * i]! * pts[2 * j + 1]! - pts[2 * j]! * pts[2 * i + 1]! + } + return Math.abs(sum) / 2 +} + +const grow = (b: Box, m: number): Box => ({ z0: b.z0 - m, z1: b.z1 + m, a0: b.a0 - m, a1: b.a1 + m }) + +/** + * The noise levels that leave `coverage` of this forest open, measured by sampling the + * forest — the same lesson the deserts taught, and one more besides: the *edge* of a + * meadow is a quantile too. Taking it as a fixed ±0.02 of noise left Greatwood as + * scattered islands, because its noise only varies by about that much in total. + */ +function meadowBandOf(f: CForest, coverage: number): { lo: number; hi: number } { + if (coverage <= 0) return { lo: Infinity, hi: Infinity } + if (coverage >= 1) return { lo: -Infinity, hi: -Infinity } + const samples: number[] = [] + const rand = mulberry32(hash3(f.seed, 0x3ee, 0x77)) + const b = f.box + // A band rings the world, so its box has no bounds around theta: sample a whole turn. + const a0 = Number.isFinite(b.a0) ? b.a0 : 0 + const a1 = Number.isFinite(b.a1) ? b.a1 : CIRCUMFERENCE_M + for (let i = 0; i < 4000; i++) { + const z = b.z0 + (b.z1 - b.z0) * rand() + const a = a0 + (a1 - a0) * rand() + if (signedDistance(f.shape, z, a) < 0) continue + samples.push(meadowNoise(f, cylPoint(a / RADIUS_M, z), 40)) + } + if (!samples.length) return { lo: Infinity, hi: Infinity } + samples.sort((x, y) => x - y) + const at = (share: number): number => + samples[Math.min(samples.length - 1, Math.max(0, Math.round((1 - share) * (samples.length - 1))))]! + // The meadow's edge is the fifth of the ground either side of the threshold. + return { lo: at(Math.min(1, coverage * 1.35 + 0.01)), hi: at(Math.max(0, coverage * 0.65 - 0.01)) } +} + +/** What the ground at a point says about trees standing on it. */ +export interface Ground { + /** Elevation, metres. */ + h: number + /** True where water stands here. */ + water: boolean + /** How much of this ground the desert claims, 0..1. */ + desert: number + /** Bare rock, 0..1: trees do not root on a cliff band or a wash floor. */ + rock: number +} + +/** + * Stems per hectare at a point: the forest's own density, thinned toward its edge, opened + * where Tower left a meadow, and stopped by water, rock, sand and the treeline. + */ +export function stemsAt(f: CForest, p: CylPoint, z: number, a: number, detailM: number, g: Ground): number { + if (g.water) return 0 + let inside = smoothstep(-f.edgeM, -f.edgeM * 0.15, signedDistance(f.shape, z, a)) + if (inside <= 0) return 0 + // Where a smaller forest stands, this one does not. + for (const hole of f.holes) { + inside *= 1 - smoothstep(-f.edgeM * 0.5, 0, signedDistance(hole, z, a)) + if (inside <= 0) return 0 + } + // Nor where a settlement stands: the trees thin out over its last fifth and stop. + for (const c of f.clearings) { + const d = Math.hypot(z - c.z, nearestA(c.a, a) - a) + inside *= smoothstep(c.radiusM * 0.8, c.radiusM * 1.15, d) + if (inside <= 0) return 0 + } + const open = f.meadowLo === Infinity + ? 1 + : 1 - smoothstep(f.meadowLo, f.meadowHi, meadowNoise(f, p, detailM)) + // Trees thin out over the last 400 m below the treeline, and stop above it. + const height = 1 - smoothstep(f.treelineM - 400, f.treelineM, g.h) + const ground = (1 - Math.min(1, g.desert * 1.4)) * (1 - Math.min(1, g.rock * 1.6)) + return f.densityPerHa * inside * open * height * ground +} + +/** Mean canopy height where that many stems stand, metres: a meadow is no canopy at all. */ +export function canopyAt(f: CForest, stemsPerHa: number): number { + if (stemsPerHa <= 0) return 0 + return f.canopyM * smoothstep(0, f.densityPerHa * 0.35, stemsPerHa) +} + +export interface Tree { + z: number + a: number + heightM: number + /** Crown radius, metres. */ + crownM: number + /** Trunk radius at the base, metres. */ + trunkM: number + conifer: boolean + giant: boolean + /** 0..1, the tree's own number: for turning it, tinting it, and choosing its shape. */ + rand: number +} + +/** The placement grid's cell size for a forest: about one tree apart at full density. */ +export function cellSizeM(f: CForest): number { + return Math.sqrt(HECTARE_M2 / Math.max(1, f.densityPerHa)) +} + +/** What a candidate spot is: how thick the wood is there, and how high the ground is. */ +export interface Spot { + stems: number + h: number +} + +/** + * Every tree of `f` whose cell lies in the box, handed to `emit`. `at` answers how thick + * the wood is at a point and how high the ground is; it is called once per candidate cell, + * so a caller drawing a kilometre of forest should keep it cheap — the view reads both off + * a grid it sampled once, rather than working the density out per tree. + * + * Deterministic in the world's seed and nothing else: two tiles, two views, or two visitors + * asking about the same ground get exactly the same trees. + */ +export function forEachTree( + f: CForest, box: Box, + at: (z: number, a: number) => Spot, + emit: (tree: Tree) => void, + /** Asked before the spot is looked at, so a caller can cut its box down to a circle. */ + wanted: (z: number, a: number) => boolean = () => true, +): void { + const cell = cellSizeM(f) + const i0 = Math.floor(box.z0 / cell), i1 = Math.ceil(box.z1 / cell) + const j0 = Math.floor(box.a0 / cell), j1 = Math.ceil(box.a1 / cell) + const perCell = (f.densityPerHa * cell * cell) / HECTARE_M2 + for (let i = i0; i <= i1; i++) { + for (let j = j0; j <= j1; j++) { + const h = hash3(f.seed, i, j) + const rand = mulberry32(h) + const z = (i + rand()) * cell + const a = (j + rand()) * cell + if (z < box.z0 || z > box.z1 || a < box.a0 || a > box.a1) continue + if (!wanted(z, a)) continue + const spot = at(z, a) + // Accept in proportion to how full the forest is here against its fullest. + if (rand() > (spot.stems * cell * cell) / HECTARE_M2 / Math.max(1e-6, perCell)) continue + emit(treeAt(f, z, a, spot.h, rand())) + } + } +} + +/** + * A clump of trees: what a stand of them is drawn as once single trees are a pixel or two + * wide. One of these stands for everything in a cell of `cellM`, so the wood can be drawn + * kilometres out for the cost of a card every twenty-odd metres rather than every five. + */ +export interface Clump { + z: number + a: number + /** How high the crowns in it reach, metres, and how wide the clump is. */ + heightM: number + widthM: number + /** How much of it is conifer, 0..1, and the clump's own number. */ + conifer: number + rand: number +} + +/** + * Every clump of `f` in the box. Same idea as `forEachTree` — a fixed grid, hashed per + * cell — on a coarser grid, and accepted in proportion to how full the wood is there, so + * the meadows stay open at any distance. + */ +export function forEachClump( + f: CForest, box: Box, cellM: number, + at: (z: number, a: number) => Spot, + emit: (clump: Clump) => void, + wanted: (z: number, a: number) => boolean = () => true, +): void { + const i0 = Math.floor(box.z0 / cellM), i1 = Math.ceil(box.z1 / cellM) + const j0 = Math.floor(box.a0 / cellM), j1 = Math.ceil(box.a1 / cellM) + for (let i = i0; i <= i1; i++) { + for (let j = j0; j <= j1; j++) { + const rand = mulberry32(hash3(f.seed ^ 0x5c1a, i, j)) + const z = (i + 0.25 + rand() * 0.5) * cellM + const a = (j + 0.25 + rand() * 0.5) * cellM + if (z < box.z0 || z > box.z1 || a < box.a0 || a > box.a1) continue + if (!wanted(z, a)) continue + const spot = at(z, a) + const full = spot.stems / Math.max(1e-6, f.densityPerHa) + if (rand() > full) continue + // As high as the canopy here, give or take the luck of what grew in this one. + const canopy = canopyAt(f, spot.stems) + emit({ + z, a, + heightM: canopy * (0.88 + 0.3 * rand()), + widthM: cellM * (1.05 + 0.35 * rand()), + conifer: f.giants ? 1 : f.conifer, + rand: rand(), + }) + } + } +} + +/** One tree's dimensions, from its own number and the ground under it. */ +export function treeAt(f: CForest, z: number, a: number, groundM: number, rand: number): Tree { + const conifer = !f.giants && rand < f.conifer + const kind = f.giants ? TREE.giant : conifer ? TREE.conifer : TREE.broadleaf + // Its own size, and smaller with height: the last trees below a treeline are stunted. + const size = 0.25 + 0.75 * (1 - (1 - rand) ** 2) + const small = 0.55 + 0.45 * (1 - smoothstep(f.treelineM - 900, f.treelineM, groundM)) + const heightM = (kind.minM + (kind.maxM - kind.minM) * size) * small + return { + z, a, heightM, + crownM: (heightM * kind.crown) / 2, + // Trunks: 1/60 of height for a conifer or broadleaf, far stouter for a giant. + trunkM: f.giants ? heightM / 22 : heightM / 120, + conifer: f.giants ? true : conifer, + giant: f.giants, + rand, + } +} diff --git a/src/terrain/service.ts b/src/terrain/service.ts index 94c327a..d1c66a6 100644 --- a/src/terrain/service.ts +++ b/src/terrain/service.ts @@ -243,6 +243,30 @@ export class TerrainService { const s = this.runtime.sample(theta, z, 1) return Math.max(s.h, s.water) } + + /** + * What the ground is at a point, for the things that stand on it: the trees near the eye + * (views/forest-view.ts) ask this on a grid and then place themselves between its nodes. + * `detailM` is the finest wavelength worth computing, as a tile's would be. + */ + groundAt(theta: number, z: number, detailM = 8): GroundAt | null { + if (!this.runtime) return null + const s = this.runtime.sample(theta, z, detailM) + return { + h: s.h, water: s.water > s.h, stems: s.stems, canopyM: s.canopyM, + desert: s.desert, rock: s.rock, + } + } +} + +/** The ground as anything standing on it needs to know it. */ +export interface GroundAt { + h: number + water: boolean + stems: number + canopyM: number + desert: number + rock: number } let instance: TerrainService | null = null diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts index 5c1f496..4109771 100644 --- a/src/terrain/tile.ts +++ b/src/terrain/tile.ts @@ -62,6 +62,19 @@ export const MATERIALS = [ */ export const WATER_ATTRS = 8 +/** + * Per canopy vertex: how high the canopy stands over the ground here, how full the stand + * is, 0..1, and how much of it is conifer. + */ +export const CANOPY_ATTRS = 3 + +export interface TileCanopy { + /** The *ground* vertices, relative to the tile centre, km: the shader lifts them. */ + positions: Float32Array + attrs: Float32Array + indices: Uint16Array +} + export interface TileWater { /** Water surface vertices, relative to the tile centre, km: the same grid as the ground. */ positions: Float32Array @@ -79,6 +92,8 @@ export interface TileData { materials: Uint8Array /** The water surfaces on this tile, or null where there is none. */ water: TileWater | null + /** The forest canopy over this tile, seen from far off, or null where nothing grows. */ + canopy: TileCanopy | null /** Lowest and highest displayed elevation on the tile, ground or water, metres. */ minM: number maxM: number @@ -120,7 +135,12 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number // 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) + /** + * Forest floor only where trees actually stand: a meadow inside a forest is grass, and + * the palette's plain ramp is the grass (world-generation.md §4.6). + */ + const canopy = Math.min(1, s.stems / 150) + const forest = s.forest * (1 - rock) * (1 - desert) * (1 - salt) * canopy 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. @@ -162,6 +182,9 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { const wLevel = new Float64Array(n * n).fill(NaN) const wAttrs = new Float32Array(n * n * WATER_ATTRS) const wet = new Uint8Array(n * n) + // The canopy over the tile's own grid: how high it stands, and what it is made of. + const cAttrs = new Float32Array(n * n * CANOPY_ATTRS) + let wooded = false for (let v = 0; v < W; v++) { for (let u = 0; u < W; u++) { // z is clamped to the world: the ring beyond a tile at either cap samples the @@ -192,6 +215,19 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { wAttrs[o + 5] = s.flowA wAttrs[o + 6] = s.foam } + if (s.canopyM > 0 && u >= 1 && u <= n && v >= 1 && v <= n) { + const c = ((v - 1) * n + (u - 1)) * CANOPY_ATTRS + /** + * A rough top. The canopy of a stand is not a sheet at one height — from a + * distance what reads as forest is the broken line of it — so each vertex is + * knocked about by a hash of where it is, which every tile agrees on. + */ + const jitter = 1 + (hash01(z, a0 + (v - 1) * da) - 0.5) * 0.35 + cAttrs[c] = s.canopyM * jitter + cAttrs[c + 1] = Math.min(1, s.stems / 450) + cAttrs[c + 2] = s.forest + wooded = true + } // Slope is not known until the neighbours are, so snow is thinned by it below. materialsAt(s, 0, mats, k * MATERIALS.length) } @@ -267,13 +303,41 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { for (let e = 0; e < n; e++) write(idx++, n - 1 - e, n - 1, drop) for (let e = 0; e < n; e++) write(idx++, 0, n - 1 - e, drop) + const canopy = wooded ? buildCanopy(n, positions, cAttrs) : null const water = buildWater(n, z0, a0, dz, da, [cx, cy, cz], wLevel, wAttrs, wet) if (water) { for (let w = 0; w < n * n; w++) { if (Number.isFinite(wLevel[w]!)) { minM = Math.min(minM, wLevel[w]!); maxM = Math.max(maxM, wLevel[w]!) } } } - return { key: tileKey(t), positions, normals, materials, water, minM, maxM } + return { key: tileKey(t), positions, normals, materials, water, canopy, minM, maxM } +} + +/** A stable 0..1 from a surface position: the same everywhere that position is asked about. */ +function hash01(z: number, a: number): number { + const x = Math.sin(z * 0.0173 + a * 0.0411) * 43758.5453 + return x - Math.floor(x) +} + +/** + * The canopy over a tile: the *ground* vertices again, with the canopy's height beside + * them. The shader lifts them and drops them back as the eye comes close, where the real + * trees take over (views/forest-view.ts) — so this mesh is the far view of a forest, and + * the ground it is built from is the ground it collapses onto exactly. + */ +function buildCanopy(n: number, ground: Float32Array, attrs: Float32Array): TileCanopy | null { + const positions = Float32Array.from(ground) + const idx: number[] = [] + const has = (k: number): boolean => attrs[k * CANOPY_ATTRS]! > 0.5 + for (let v = 0; v < n - 1; v++) { + for (let u = 0; u < n - 1; u++) { + const a = v * n + u, b = a + 1, c = a + n, d = c + 1 + if (has(a) && has(b) && has(c)) idx.push(a, b, c) + if (has(b) && has(d) && has(c)) idx.push(b, d, c) + } + } + if (!idx.length) return null + return { positions, attrs, indices: new Uint16Array(idx) } } /** diff --git a/src/terrain/worker.ts b/src/terrain/worker.ts index c323dde..2cd71ae 100644 --- a/src/terrain/worker.ts +++ b/src/terrain/worker.ts @@ -31,6 +31,7 @@ const api = { const data = buildTile(runtime, t, n) const buffers: Transferable[] = [data.positions.buffer, data.normals.buffer, data.materials.buffer] if (data.water) buffers.push(data.water.positions.buffer, data.water.attrs.buffer, data.water.indices.buffer) + if (data.canopy) buffers.push(data.canopy.positions.buffer, data.canopy.attrs.buffer, data.canopy.indices.buffer) return transfer(data, buffers) }, diff --git a/src/ui/app.ts b/src/ui/app.ts index 35292d2..b0a54d0 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -22,7 +22,7 @@ import { type FeaturePatch, WorldStore, backupFilename, download } from '../worl import { TowerMap } from '../views/map.js' import type { ExteriorView } from '../views/exterior.js' import type { InteriorView, Lighting } from '../views/interior.js' -import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' +import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js' type Tool = 'select' | FeatureKind @@ -74,6 +74,26 @@ const facingOf = (p: Feature): SurfacePoint | null => { } const pct = (x: number): string => `${(x * 100).toFixed(1)}%` +/** Ground a feature's shape covers, m². A band rings the world. */ +const areaOfGeometry = (g: Feature['geometry']): number => { + if (g.type === 'band') return Math.abs(g.zTo - g.zFrom) * CIRCUMFERENCE_M + if (g.type !== 'polygon') return 0 + let sum = 0 + for (let i = 0; i < g.points.length; i++) { + const p = g.points[i]!, q = g.points[(i + 1) % g.points.length]! + sum += p[0] * RADIUS_M * q[1] - q[0] * RADIUS_M * p[1] + } + return Math.abs(sum) / 2 +} + +/** A big count, as people say it: 3.1 billion rather than 3,100,000,000. */ +const fmtCount = (n: number): string => { + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} billion` + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} million` + if (n >= 1e3) return `${Math.round(n / 1e3)} thousand` + return String(Math.round(n)) +} + const fmtDuration = (s: number): string => s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` @@ -746,6 +766,7 @@ export class App { this.wireWater() this.wireDesert() this.wireSettlement() + this.wireForest() this.wireWeather() } @@ -795,6 +816,58 @@ export class App { ? ' The wind runs round the ring, so the sea is fully developed.' : '') } + /** What Tower planted in a forest (world-generation.md §4.6). */ + private wireForest(): void { + const selected = (): Extract<Feature, { kind: 'forest' }> | null => { + const f = this.selectedId ? this.store.get(this.selectedId) : undefined + return f?.kind === 'forest' ? f : null + } + const fields: [string, (v: number) => FeaturePatch][] = [ + ['f-density', (v) => ({ densityPerHa: v })], + ['f-conifer', (v) => ({ conifer: v })], + ['f-meadows', (v) => ({ meadows: v })], + ['f-treeline', (v) => ({ treelineM: v })], + ] + for (const [id, patch] of fields) { + const input = $<HTMLInputElement>(id) + input.onchange = () => { + const f = selected() + const v = Number(input.value) + if (f && Number.isFinite(v)) this.store.update(f.id, patch(v)) + } + } + const giants = $<HTMLInputElement>('f-giants') + giants.onchange = () => { + const f = selected() + if (f) this.store.update(f.id, { giants: giants.checked }) + } + terrain().onWorld(() => this.renderForest(this.selectedId ? this.store.get(this.selectedId) : undefined)) + } + + private renderForest(f: Feature | undefined): void { + $('f-forest').hidden = f?.kind !== 'forest' + if (f?.kind !== 'forest') return + const set = (id: string, v: number) => { + const input = $<HTMLInputElement>(id) + if (document.activeElement !== input) input.value = String(v) + } + set('f-density', f.densityPerHa) + set('f-conifer', f.conifer) + set('f-meadows', f.meadows) + set('f-treeline', f.treelineM) + const giants = $<HTMLInputElement>('f-giants') + if (document.activeElement !== giants) giants.checked = f.giants + const c = terrain().compiled?.forests.find((x) => x.id === f.id) + if (!c) { $('f-forest-state').textContent = ''; return } + // Its area, and what that many stems per hectare comes to across all of it. + const area = areaOfGeometry(f.geometry) + const trees = (area / 10_000) * c.densityPerHa * (1 - f.meadows) + const spacing = Math.sqrt(10_000 / c.densityPerHa) + $('f-forest-state').textContent = + `${fmtCount(trees)} trees over ${(area / 1e6).toFixed(0)} km², about ${spacing.toFixed(1)} m apart, ` + + `canopy ${c.canopyM.toFixed(0)} m.` + } + /** 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 => { @@ -1368,6 +1441,7 @@ export class App { this.renderWater(p) this.renderDesert(p) this.renderSettlement(p) + this.renderForest(p) $('f-where').textContent = this.whereText(p) } diff --git a/src/views/forest-view.ts b/src/views/forest-view.ts new file mode 100644 index 0000000..a227b0b --- /dev/null +++ b/src/views/forest-view.ts @@ -0,0 +1,443 @@ +/** + * The trees near the eye. See world-generation.md §4.6. + * + * Greatwood holds about three billion trees, so what exists at any moment is only the ones + * you could actually see as trees: a few thousand as **meshes** within a couple of hundred + * metres, tens of thousands as **cards** — one billboarded quad each — out to about half a + * kilometre, and beyond that nothing, because the tile's canopy skin has taken over + * (interior-model.ts CANOPY_VERT). The three ranges meet where the change is under a pixel. + * + * Which trees they are comes from forest.ts, whose placement is a pure function of the + * world's seed and the ground: walking a kilometre and back puts every tree back exactly + * where it was, and two people standing in the same clearing see the same trees. + * + * The ground is sampled on a grid and the trees interpolate between its nodes — one full + * elevation sample per tree would be tens of thousands of them per rebuild. + */ +import { + BufferAttribute, BufferGeometry, ConeGeometry, CylinderGeometry, Group, IcosahedronGeometry, + InstancedBufferAttribute, InstancedMesh, Matrix4, type ShaderMaterial, Vector3, +} from 'three' +import { RADIUS_M } from '../canon/constants.js' +import { + type CForest, type Clump, type Spot, type Tree, cellSizeM, forEachClump, forEachTree, +} from '../terrain/forest.js' +import { nearestA } from '../terrain/shapes.js' +import { type GroundAt, type TerrainService } from '../terrain/service.js' + +/** How many trees are drawn as meshes, as cards, and as clumps. */ +const MAX_MESH = 6000 +const MAX_CARD = 45_000 +const MAX_CLUMP = 30_000 +/** + * Past the cards a stand of trees is drawn as clumps: one card for everything in a cell + * this wide. A card each would be a quarter of a million of them at a kilometre and a + * million at two; a clump every twenty-six metres reaches nearly three kilometres for + * thirty thousand, and at that distance a clump is about the width of a thumbnail held out + * at arm's length — which is what a group of crowns looks like from there anyway. + */ +const CLUMP_CELL_M = 30 +const CLUMP_REACH_M = 2800 +/** How far apart the clump layer samples the ground. */ +const CLUMP_GROUND_M = 90 +/** Rebuilt once the eye has moved this far, metres — and the clump layer, this far. */ +const MOVED_M = 30 +const MOVED_FAR_M = 170 +/** + * Moving faster than this between rebuilds — flying rather than walking — the wood is + * rebuilt at a fraction of the budget. A full rebuild is about 19 ms, which is nothing + * every twenty seconds of walking and a stutter every sixth of a second in flight; at + * 100 m/s the near trees are a blur going past, and the canopy behind them is not. + */ +const FLYING_M = 260 +const FLYING_SHARE = 0.28 +/** The ground is sampled this far apart for the mesh trees, and for the cards. */ +const FINE_M = 12 +const COARSE_M = 45 + +export interface Fleet { + conifer: InstancedMesh + broadleaf: InstancedMesh + giant: InstancedMesh + cards: InstancedMesh + clumps: InstancedMesh +} + +export class ForestView { + readonly group = new Group() + readonly meshes: Fleet + /** Trees standing as meshes, cards and clumps in the last rebuild. */ + counts = { mesh: 0, card: 0, clump: 0 } + + private eye = new Vector3(Infinity, 0, 0) + private farEye = new Vector3(Infinity, 0, 0) + private generation = -1 + /** The last rebuild ran at the flying budget, so the wood is thinner than it should be. */ + private thinned = false + private cards: InstancedBufferAttribute + private clumps: InstancedBufferAttribute + + constructor( + private service: TerrainService, + treeMaterial: ShaderMaterial, + cardMaterial: ShaderMaterial, + ) { + this.group.name = 'forest' + const mesh = (geometry: BufferGeometry, max: number, material: ShaderMaterial, name: string): InstancedMesh => { + const m = new InstancedMesh(geometry, material, max) + m.name = name + m.count = 0 + // The instances are placed around the eye every rebuild; the mesh's own bounds mean + // nothing, and its matrix is the eye's. + m.frustumCulled = false + return m + } + this.meshes = { + conifer: mesh(coniferGeometry(), MAX_MESH, treeMaterial, 'conifers'), + broadleaf: mesh(broadleafGeometry(), MAX_MESH, treeMaterial, 'broadleaves'), + giant: mesh(giantGeometry(), MAX_MESH, treeMaterial, 'giants'), + cards: mesh(cardGeometry(), MAX_CARD, cardMaterial, 'tree cards'), + clumps: mesh(cardGeometry(), MAX_CLUMP, cardMaterial, 'tree clumps'), + } + // What each card is: 0 broadleaf, 1 conifer, 2 giant, 3 a clump; and its own number. + this.cards = new InstancedBufferAttribute(new Float32Array(MAX_CARD * 2), 2) + this.meshes.cards.geometry.setAttribute('aCard', this.cards) + this.clumps = new InstancedBufferAttribute(new Float32Array(MAX_CLUMP * 2), 2) + this.meshes.clumps.geometry.setAttribute('aCard', this.clumps) + this.group.add( + this.meshes.conifer, this.meshes.broadleaf, this.meshes.giant, + this.meshes.cards, this.meshes.clumps) + } + + /** Put the trees around the eye. Cheap to call every frame: it only rebuilds when it must. */ + update(eye: Vector3): void { + const compiled = this.service.compiled + if (!compiled || !compiled.forests?.length) { + for (const m of Object.values(this.meshes)) m.count = 0 + return + } + const moved = this.eye.distanceTo(eye) * 1000 + const gen = this.service.generation + /** + * A thinned wood is rebuilt in full as soon as the eye settles — otherwise arriving + * somewhere by flight or by selecting a place left the trees at a quarter strength + * until you walked thirty metres, which looked like a thin wood rather than a fast one. + */ + if (moved < MOVED_M && !this.thinned && this.generation === gen) return + this.eye.copy(eye) + const share = moved > FLYING_M ? FLYING_SHARE : 1 + this.thinned = share < 1 + /** + * The clumps are two kilometres out and cost more than everything else together, and at + * that distance a hundred metres of walking moves them by nothing anyone can see. So + * they are left where they are until the eye has gone a good way — which is why they + * keep their own origin rather than sharing the near layers'. + */ + const far = this.farEye.distanceTo(eye) * 1000 > MOVED_FAR_M || this.generation !== gen + if (far) this.farEye.copy(eye) + this.rebuild( + compiled.forests, eye, Math.round(MAX_MESH * share), Math.round(MAX_CARD * share), far) + } + + private rebuild( + forests: CForest[], eye: Vector3, maxMesh: number, maxCard: number, rebuildFar: boolean, + ): void { + /** + * Every instance is placed relative to the eye, so a rebuild starts from nothing. + * Left to grow, the counts kept last rebuild's trees as well — at the old eye, and + * turned for the ground they used to stand on, which drew them lying over as logs. + */ + for (const [name, m] of Object.entries(this.meshes)) { + if (name === 'clumps' && !rebuildFar) continue + m.position.copy(eye) + m.count = 0 + } + this.generation = this.service.generation + const theta = Math.atan2(eye.y, eye.x) + const centre = { z: eye.z * 1000, a: theta * RADIUS_M } + + let mesh = 0, card = 0, clump = 0 + const matrix = new Matrix4() + /** + * Only the woods the eye is anywhere near share the budget. Dividing it between every + * forest in the world halved the reach of the one you were standing in because the tall + * wood exists twenty kilometres away. + */ + const nearby = forests.filter((f) => near(f.box, centre, CLUMP_REACH_M)) + for (const f of nearby) { + /** + * How far out the trees reach: whatever fits the budget at this forest's density, + * capped so a sparse wood does not reach past where the canopy skin takes over. + */ + const perM2 = f.densityPerHa / 10_000 + const meshR = Math.min(320, Math.sqrt(maxMesh / nearby.length / perM2 / Math.PI)) + const cardR = Math.min(760, Math.sqrt(maxCard / nearby.length / perM2 / Math.PI)) + if (cardR < cellSizeM(f)) continue + + const fine = new Field(this.service, centre, meshR, FINE_M) + const coarse = new Field(this.service, centre, cardR, COARSE_M) + const box = (r: number) => ({ z0: centre.z - r, z1: centre.z + r, a0: centre.a - r, a1: centre.a + r }) + + const within = (r: number) => (z: number, a: number): boolean => + (z - centre.z) * (z - centre.z) + (a - centre.a) * (a - centre.a) <= r * r + forEachTree(f, box(cardR), (z, a) => coarse.at(z, a), (tree) => { + const d = Math.hypot(tree.z - centre.z, tree.a - centre.a) + if (d > cardR) return + // Near the eye a tree is a mesh, and its ground is resampled finely so its foot + // sits on the ground rather than on the interpolation between two samples. + if (d <= meshR) { + if (mesh >= maxMesh) return + place(matrix, tree, fine.at(tree.z, tree.a).h, tree.heightM / 1000, eye) + const target = tree.giant ? this.meshes.giant : tree.conifer ? this.meshes.conifer : this.meshes.broadleaf + target.setMatrixAt(target.count++, matrix) + mesh++ + return + } + if (card >= maxCard) return + cardMatrix(matrix, tree, coarse.at(tree.z, tree.a).h, eye) + this.cards.setXY(card, tree.giant ? 2 : tree.conifer ? 1 : 0, tree.rand) + this.meshes.cards.setMatrixAt(card++, matrix) + }, within(cardR)) + + /** + * And the wood beyond the cards, as clumps, out to where the tile's canopy skin + * takes over. Its ground is sampled coarsely: at two kilometres nobody can tell + * whether a stand of trees is standing ten metres higher than it is. + */ + if (!rebuildFar) continue + const clumpR = Math.min( + CLUMP_REACH_M, + Math.sqrt((MAX_CLUMP / nearby.length) * CLUMP_CELL_M * CLUMP_CELL_M / Math.PI)) + const far = new Field(this.service, centre, clumpR, CLUMP_GROUND_M) + forEachClump(f, box(clumpR), CLUMP_CELL_M, (z, a) => far.at(z, a), (c) => { + if (clump >= MAX_CLUMP) return + const d = Math.hypot(c.z - centre.z, c.a - centre.a) + if (d < cardR || d > clumpR) return + clumpMatrix(matrix, c, far.at(c.z, c.a).h, eye) + this.clumps.setXY(clump, 3, c.rand) + this.meshes.clumps.setMatrixAt(clump++, matrix) + }, within(clumpR)) + } + + this.meshes.cards.count = card + this.cards.needsUpdate = true + for (const m of Object.values(this.meshes)) { + if (m === this.meshes.clumps && !rebuildFar) continue + m.instanceMatrix.needsUpdate = true + } + if (rebuildFar) { + this.meshes.clumps.count = clump + this.clumps.needsUpdate = true + this.counts = { mesh, card, clump } + } else { + this.counts = { mesh, card, clump: this.counts.clump } + } + } + + dispose(): void { + for (const m of Object.values(this.meshes)) { + m.geometry.dispose() + m.dispose() + } + } +} + +/** Whether a forest's box comes within `reach` of a point, allowing for theta's wrap. */ +function near(box: { z0: number; z1: number; a0: number; a1: number }, at: { z: number; a: number }, reach: number): boolean { + if (at.z < box.z0 - reach || at.z > box.z1 + reach) return false + if (!Number.isFinite(box.a0) || !Number.isFinite(box.a1)) return true + const a = nearestA(at.a, (box.a0 + box.a1) / 2) + return a >= box.a0 - reach && a <= box.a1 + reach +} + +/** + * A grid of what the ground is, sampled once and read between its nodes — including **how + * thick the wood is**, which is what the placement reads rather than working the density + * out from the noise at every candidate cell. The meadows' edges are then as fine as this + * grid, which at twelve metres near the eye is finer than the trees are apart. + */ +class Field { + private readonly n: number + private readonly z0: number + private readonly a0: number + private readonly h: Float32Array + private readonly stems: Float32Array + private readonly water: Uint8Array + + constructor(service: TerrainService, centre: { z: number; a: number }, reach: number, private cell: number) { + this.n = Math.ceil((2 * reach) / cell) + 2 + this.z0 = centre.z - reach - cell + this.a0 = centre.a - reach - cell + const n = this.n + this.h = new Float32Array(n * n) + this.stems = new Float32Array(n * n) + this.water = new Uint8Array(n * n) + const blank: GroundAt = { h: 0, water: false, stems: 0, canopyM: 0, desert: 0, rock: 0 } + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + const z = this.z0 + i * cell, a = this.a0 + j * cell + const g = service.groundAt(a / RADIUS_M, z, cell) ?? blank + const k = i * n + j + this.h[k] = g.h + this.stems[k] = g.stems + this.water[k] = g.water ? 1 : 0 + } + } + } + + /** Shared, as Runtime.sample's is: read it before asking again. */ + private readonly out: Spot = { stems: 0, h: 0 } + + at(z: number, a: number): Spot { + const fi = Math.min(this.n - 2, Math.max(0, (z - this.z0) / this.cell)) + const fj = Math.min(this.n - 2, Math.max(0, (a - this.a0) / this.cell)) + const i = Math.floor(fi), j = Math.floor(fj) + const ti = fi - i, tj = fj - j + const k = i * this.n + j + const lerp = (arr: Float32Array): number => { + const top = arr[k]! * (1 - tj) + arr[k + 1]! * tj + const bot = arr[k + this.n]! * (1 - tj) + arr[k + this.n + 1]! * tj + return top * (1 - ti) + bot * ti + } + const out = this.out + out.h = lerp(this.h) + // Any wet corner takes the trees out: a tree half in a river is worse than none. + out.stems = this.water[k]! || this.water[k + 1]! || this.water[k + this.n]! || this.water[k + this.n + 1]! + ? 0 + : lerp(this.stems) + return out + } + +} + +/** + * A tree's instance matrix. The tree stands on the floor, so its up is toward the axis and + * it is turned about that by its own number — every tree the same way round would read as + * a pattern from the air. + */ +const scratch = { + up: new Vector3(), x: new Vector3(), zAxis: new Vector3(), scale: new Vector3(), +} + +function place(out: Matrix4, tree: Tree, groundM: number, scaleKm: number, eye: Vector3): Matrix4 { + const theta = tree.a / RADIUS_M + const cos = Math.cos(theta), sin = Math.sin(theta) + const r = (RADIUS_M - groundM) / 1000 + const turn = tree.rand * Math.PI * 2 + const { up, x, zAxis, scale } = scratch + up.set(-cos, -sin, 0) + // Deepward turned by the tree's own number: x = cos·deepward + sin·spinward. + x.set(-sin * Math.sin(turn), cos * Math.sin(turn), Math.cos(turn)) + zAxis.crossVectors(x, up) + out.makeBasis(x, up, zAxis) + out.scale(scale.setScalar(scaleKm)) + out.setPosition(r * cos - eye.x, r * sin - eye.y, tree.z / 1000 - eye.z) + return out +} + +/** A clump's matrix: the same as a card's, at the size of the whole stand. */ +function clumpMatrix(out: Matrix4, c: Clump, groundM: number, eye: Vector3): Matrix4 { + const theta = c.a / RADIUS_M + const cos = Math.cos(theta), sin = Math.sin(theta) + const r = (RADIUS_M - groundM) / 1000 + out.identity() + out.elements[0] = c.widthM / 1000 + out.elements[5] = c.heightM / 1000 + out.setPosition(r * cos - eye.x, r * sin - eye.y, c.z / 1000 - eye.z) + return out +} + +/** + * A card's matrix: where the tree stands, how wide it is and how tall. The quad itself is + * turned to face the eye in the shader, about the tree's own upright, so a card never + * leans and never shows its edge. + */ +function cardMatrix(out: Matrix4, tree: Tree, groundM: number, eye: Vector3): Matrix4 { + const theta = tree.a / RADIUS_M + const cos = Math.cos(theta), sin = Math.sin(theta) + const r = (RADIUS_M - groundM) / 1000 + out.identity() + out.elements[0] = (tree.crownM * 2.1) / 1000 + out.elements[5] = tree.heightM / 1000 + out.setPosition(r * cos - eye.x, r * sin - eye.y, tree.z / 1000 - eye.z) + return out +} + +// ------------------------------------------------------------------ geometries + +/** + * What a vertex belongs to, for the shader: bark, or the foliage of one kind of tree. + * Every tree mesh shares one material, so the part is carried by the geometry. + */ +export const PART = { bark: 0, conifer: 1, broadleaf: 2, giant: 3 } as const + +/** Merge a few geometries into one non-indexed buffer, tagging each vertex's part. */ +function merge(parts: { geometry: BufferGeometry; part: number; y: number; scale?: number }[]): BufferGeometry { + const positions: number[] = [], normals: number[] = [], kinds: number[] = [] + for (const part of parts) { + const g = part.geometry.toNonIndexed() + const pos = g.getAttribute('position'), nor = g.getAttribute('normal') + const s = part.scale ?? 1 + for (let i = 0; i < pos.count; i++) { + positions.push(pos.getX(i) * s, pos.getY(i) * s + part.y, pos.getZ(i) * s) + normals.push(nor.getX(i), nor.getY(i), nor.getZ(i)) + kinds.push(part.part) + } + g.dispose() + part.geometry.dispose() + } + const out = new BufferGeometry() + out.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3)) + out.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3)) + out.setAttribute('aPart', new BufferAttribute(new Float32Array(kinds), 1)) + return out +} + +/** + * A conifer, one unit tall: a tapered trunk and three stacked cones, narrow — §4.6 puts a + * conifer's crown at 0.28 of its height. Sixty-odd triangles. + */ +function coniferGeometry(): BufferGeometry { + const crown = 0.14 + return merge([ + { geometry: new CylinderGeometry(0.006, 0.014, 1, 6, 1), part: PART.bark, y: 0.5 }, + { geometry: new ConeGeometry(crown, 0.44, 8, 1), part: PART.conifer, y: 0.36 }, + { geometry: new ConeGeometry(crown * 0.78, 0.38, 8, 1), part: PART.conifer, y: 0.6 }, + { geometry: new ConeGeometry(crown * 0.5, 0.3, 8, 1), part: PART.conifer, y: 0.83 }, + ]) +} + +/** A broadleaf: a short trunk under a crown of three lumps, wide and round (§4.6). */ +function broadleafGeometry(): BufferGeometry { + const crown = 0.31 + return merge([ + { geometry: new CylinderGeometry(0.008, 0.018, 0.55, 6, 1), part: PART.bark, y: 0.27 }, + { geometry: new IcosahedronGeometry(crown, 0), part: PART.broadleaf, y: 0.68 }, + { geometry: new IcosahedronGeometry(crown * 0.72, 0), part: PART.broadleaf, y: 0.52 }, + { geometry: new IcosahedronGeometry(crown * 0.62, 0), part: PART.broadleaf, y: 0.85 }, + ]) +} + +/** + * A giant: a column of a trunk — 1/22 of its height, so a 110 m tree is 5 m through — bare + * for half its height, with the crown above that (§4.6). + */ +function giantGeometry(): BufferGeometry { + const crown = 0.09 + return merge([ + { geometry: new CylinderGeometry(0.028, 0.048, 1, 10, 1), part: PART.bark, y: 0.5 }, + { geometry: new ConeGeometry(crown, 0.4, 10, 1), part: PART.giant, y: 0.62 }, + { geometry: new ConeGeometry(crown * 0.72, 0.34, 10, 1), part: PART.giant, y: 0.82 }, + { geometry: new ConeGeometry(crown * 0.44, 0.22, 8, 1), part: PART.giant, y: 0.95 }, + ]) +} + +/** A card: a unit quad standing on the ground, billboarded by the shader. */ +function cardGeometry(): BufferGeometry { + const g = new BufferGeometry() + g.setAttribute('position', new BufferAttribute(new Float32Array([ + -0.5, 0, 0, 0.5, 0, 0, 0.5, 1, 0, -0.5, 0, 0, 0.5, 1, 0, -0.5, 1, 0, + ]), 3)) + g.setAttribute('uv', new BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), 2)) + return g +} diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index c78508d..630e44b 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -25,6 +25,7 @@ import { import { PALETTE_GLSL } from '../terrain/palette.js' import { LOWEST_GROUND_M } from '../terrain/tile.js' import { fallSheet } from '../terrain/falls.js' +import { ForestView } from './forest-view.js' import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js' import { WAVE_AMP, WAVE_LEN, WAVE_PHASE, WAVE_TURN, boomAngle, shipsAt, waveAt } from '../terrain/ships.js' import { terrain } from '../terrain/service.js' @@ -540,6 +541,262 @@ void main() { } ` +/** + * The forest canopy, seen from a distance (tile.ts `buildCanopy`, world-generation.md §4.6). + * + * Greatwood holds about three billion trees, so past a kilometre the *ground* under them is + * this: a skin over the tile, lifted by the canopy's height, with a top left rough by the + * tile. Clumps of trees stand in front of it out to nearly three kilometres + * (forest-view.ts) and it fills what they leave between them — a stand of cards seen from + * above is all gaps, and the wood has to be closed. Coming closer, the skin **sinks back + * onto the ground it was built from** and stops being drawn, where the trees themselves + * have taken over. + */ +const CANOPY_NEAR_M = 620 +const CANOPY_FAR_M = 950 + +const CANOPY_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform mat4 uFrame; +uniform vec3 uCam; +attribute vec3 aCanopy; +varying vec3 vWorld; +varying vec3 vCanopy; +varying float vLift; +void main() { + vec3 world = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; + float lift = smoothstep(${CANOPY_NEAR_M.toFixed(1)}, ${CANOPY_FAR_M.toFixed(1)}, distance(uCam, world) * 1000.0); + vec3 up = -normalize(vec3(world.xy, 0.0)); + vec3 p = position + up * (aCanopy.x * lift / 1000.0); + vWorld = world + up * (aCanopy.x * lift / 1000.0); + vCanopy = aCanopy; + vLift = lift; + gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); + #include <logdepthbuf_vertex> +} +` + +const CANOPY_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform float uFrontZ; +uniform float uFrontWidth; +uniform float uBelowFront; +uniform float uAboveFront; +varying vec3 vWorld; +varying vec3 vCanopy; +varying float vLift; +${HAZE_GLSL} +float canopyHash(vec2 p) { + return fract(sin(dot(mod(p, 512.0), vec2(37.1, 91.7))) * 24634.6345); +} + +void main() { + #include <logdepthbuf_fragment> + if (vLift < 0.03) discard; + // The shape of the canopy is the shape of this surface: its normal comes from the + // surface itself, so the rough top the tile built reads as tree crowns rather than felt. + vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld))); + vec3 radial = normalize(vec3(vWorld.xy, 0.0)); + if (dot(n, -radial) < 0.0) n = -n; + float lambert = clamp(dot(n, -radial), 0.0, 1.0); + + /** + * Seen from underneath — standing in the wood, looking out through it — a solid skin is + * a black ceiling over everything past the last drawn tree. A canopy is not solid: a + * third of it is gaps between crowns, and that is what lets you see any distance through + * a forest at all. So from below it is opened up, on a pattern fixed to the ground so it + * reads as gaps rather than as static on the screen. + */ + vec3 toEye = normalize(uCam - vWorld); + bool under = dot(n, toEye) < 0.0; + if (under) { + vec2 sp = vec2(vWorld.z * 1000.0, atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)}); + if (canopyHash(floor(sp / 9.0)) > 0.45) discard; + } + + /** + * Closed canopy is the darkest ground in Tower — a crown swallows most of what lands on + * it, which is why a forest reads as a dark mass and its clearings as bright grass. So + * this has to come out darker than the forest floor the palette paints in the meadows, + * or the wood looks like mist lying over the grass, which is what it looked like first. + */ + vec3 conifer = vec3(0.037, 0.067, 0.043); + vec3 broadleaf = vec3(0.070, 0.098, 0.040); + vec3 base = mix(broadleaf, conifer, clamp(vCanopy.z, 0.0, 1.0)); + // Thinner stands let the floor show through, so they are lighter than closed canopy. + base *= 1.0 + 0.35 * (1.0 - clamp(vCanopy.y, 0.0, 1.0)); + float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); + float daylight = mix(uBelowFront, uAboveFront, side); + // Under the canopy the light is what has come through it: green, and a tenth as much. + vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure * daylight; + + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +/** + * Trees near the eye (forest-view.ts): instanced meshes of bark and foliage, lit from the + * axis like everything else. The kind of foliage is carried by the geometry, since all three + * tree meshes share this one material. + */ +const FOLIAGE_GLSL = /* glsl */` +vec3 foliageColour(float part, float tint) { + vec3 bark = vec3(0.055, 0.037, 0.025); + vec3 conifer = vec3(0.030, 0.062, 0.038); + vec3 broadleaf = vec3(0.062, 0.098, 0.035); + vec3 giant = vec3(0.035, 0.058, 0.030); + vec3 c = part < 0.5 ? bark : part < 1.5 ? conifer : part < 2.5 ? broadleaf : giant; + // No two trees the same: a little lighter or darker, and a little yellower or bluer. + return c * (0.78 + 0.44 * tint) + vec3(0.006, 0.004, -0.004) * (tint - 0.5); +} +` + +const TREE_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform mat4 uFrame; +attribute float aPart; +varying vec3 vWorld; +varying vec3 vN; +varying float vPart; +varying float vTint; +void main() { + mat4 m = modelMatrix * instanceMatrix; + vWorld = (uFrame * m * vec4(position, 1.0)).xyz; + vN = mat3(uFrame * m) * normal; + vPart = aPart; + // The instance's own position is as good a per-tree number as any. + vTint = fract(dot(instanceMatrix[3].xyz, vec3(17.3, 13.7, 29.1))); + gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0); + #include <logdepthbuf_vertex> +} +` + +const TREE_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform float uFrontZ; +uniform float uFrontWidth; +uniform float uBelowFront; +uniform float uAboveFront; +varying vec3 vWorld; +varying vec3 vN; +varying float vPart; +varying float vTint; +${HAZE_GLSL} +${FOLIAGE_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec3 radial = normalize(vec3(vWorld.xy, 0.0)); + vec3 n = normalize(vN); + // The beam is overhead, so a crown is lit on top and its own underside is its shadow. + float lambert = clamp(dot(n, -radial), 0.0, 1.0); + float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); + float daylight = mix(uBelowFront, uAboveFront, side); + // Ambient is high for foliage: under a crown the light is what the crowns around it have + // bounced, and a canopy with none of that reads as pitch black rather than as shade. + vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure * daylight; + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +/** + * A tree as a card: one quad, turned to face the eye about the tree's own upright, with its + * shape cut out of it in the fragment — a cone for a conifer, a round crown for a broadleaf, + * a column and a high crown for a giant. No texture: at this distance a tree is a few pixels + * and its silhouette is all of it that survives. + */ +const CARD_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform mat4 uFrame; +uniform vec3 uCam; +attribute vec2 aCard; +varying vec3 vWorld; +varying vec2 vUv; +varying vec2 vCard; +void main() { + vec3 base = (modelMatrix * vec4(instanceMatrix[3].xyz, 1.0)).xyz; + vec3 world = (uFrame * vec4(base, 1.0)).xyz; + // Up is toward the axis; the card turns about it to face the eye and never leans. + vec3 up = -normalize(vec3(world.xy, 0.0)); + vec3 toEye = uCam - world; + vec3 right = normalize(cross(toEye, up)); + // The tree stands on the ground, so the card grows along up from its foot. + vec3 p = instanceMatrix[3].xyz + + right * (position.x * instanceMatrix[0][0]) + + up * (position.y * instanceMatrix[1][1]); + vWorld = (uFrame * modelMatrix * vec4(p, 1.0)).xyz; + vUv = uv; + vCard = aCard; + gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); + #include <logdepthbuf_vertex> +} +` + +const CARD_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform float uFrontZ; +uniform float uFrontWidth; +uniform float uBelowFront; +uniform float uAboveFront; +varying vec3 vWorld; +varying vec2 vUv; +varying vec2 vCard; +${HAZE_GLSL} +${FOLIAGE_GLSL} +void main() { + #include <logdepthbuf_fragment> + float u = vUv.x - 0.5, v = vUv.y; + float kind = vCard.x; + bool inside; + if (kind > 2.5) { + /** + * A clump: everything in twenty-odd metres of wood, drawn as one card. Three crowns + * over a solid band, so its outline is lumpy the way a stand of trees is, rather than + * the single cone a lone tree would make at this distance. + */ + float bump = 0.0; + bump = max(bump, 0.62 * (1.0 - pow(abs((u + 0.30) / 0.24), 1.7))); + bump = max(bump, 0.95 * (1.0 - pow(abs((u - 0.02) / 0.26), 1.7))); + bump = max(bump, 0.70 * (1.0 - pow(abs((u - 0.31) / 0.22), 1.7))); + // A little noise on the tops, so a row of clumps is not a row of the same shape. + bump *= 0.86 + 0.28 * fract(sin(vCard.y * 91.7 + u * 13.3) * 4371.3); + inside = v < bump; + } else if (kind > 1.5) { + // A giant: a bare column for half its height, and a crown above. + float crownHalf = 0.5 * (1.0 - smoothstep(0.55, 1.0, v)); + inside = v < 0.55 ? abs(u) < 0.1 : abs(u) < crownHalf; + } else if (kind > 0.5) { + // A conifer: a narrow cone on a stem. + inside = v < 0.16 ? abs(u) < 0.08 : abs(u) < 0.5 * (1.0 - (v - 0.16) / 0.84); + } else { + // A broadleaf: a round crown over a short stem. + float dy = (v - 0.68) / 0.34; + inside = v < 0.4 ? abs(u) < 0.07 : (u * u) / 0.25 + dy * dy < 1.0; + } + if (!inside) discard; + float part = kind > 2.5 ? 1.0 : kind > 1.5 ? 3.0 : kind > 0.5 ? 1.0 : 2.0; + // A clump is all crown: no trunk of its own, since it is a stand and not a tree. + if (kind < 2.5 && v < (kind > 1.5 ? 0.55 : kind > 0.5 ? 0.16 : 0.4)) part = 0.0; + // Lit from overhead: the top of a crown is bright and its foot is in its own shade. + float shade = 0.22 + 0.52 * smoothstep(0.0, 0.9, v); + float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); + float daylight = mix(uBelowFront, uAboveFront, side); + vec3 lit = foliageColour(part, vCard.y) * shade * uExposure * daylight; + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + /** * Ships (ships.ts): instanced hulls and sails, lit from the axis and hazed like everything * else. The instances are placed relative to the mesh, which sits at the eye, so their @@ -1028,6 +1285,9 @@ export class InteriorModel { readonly materials: ShaderMaterial[] = [] readonly groundMat: ShaderMaterial readonly waterMat: ShaderMaterial + readonly canopyMat: ShaderMaterial + /** The trees near the eye, and the cards beyond them (forest-view.ts). */ + readonly forest: ForestView /** Every waterfall in the world, rebuilt when the world is. */ readonly falls = new Group() /** The generated ground: a quadtree of tiles, refined around the eye. */ @@ -1052,7 +1312,12 @@ export class InteriorModel { } }) this.waterMat.transparent = true this.waterMat.depthWrite = false - this.lod = new TerrainLOD(terrain(), this.groundMat, this.waterMat, opts.lod) + this.canopyMat = this.material(CANOPY_VERT, CANOPY_FRAG, { side: DoubleSide, uniforms: {} }) + this.forest = new ForestView( + terrain(), + this.material(TREE_VERT, TREE_FRAG, { side: FrontSide, uniforms: {} }), + this.material(CARD_VERT, CARD_FRAG, { side: DoubleSide, uniforms: {} })) + this.lod = new TerrainLOD(terrain(), this.groundMat, this.waterMat, this.canopyMat, opts.lod) this.ground = this.lod.group this.falls.name = 'falls' const fleet = (geometry: BufferGeometry, colour: number, name: string): InstancedMesh => { @@ -1069,7 +1334,7 @@ export class InteriorModel { this.hulls = fleet(hullGeometry(), 0x4a3526, 'hulls') this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails') this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs') - this.meshes = [this.ground, this.falls, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam()] + this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam()] terrain().onWorld(() => this.buildFalls()) if (terrain().compiled) this.buildFalls() } @@ -1224,6 +1489,8 @@ export class InteriorModel { this.surfRef.set(Math.cos(refA / RADIUS_M), Math.sin(refA / RADIUS_M), refZ, 0) this.surfRefA = refA this.updateWaveOffsets() + // The trees around the eye, rebuilt only when it has moved far enough to matter. + this.forest.update(eye) this.lod.update(eye) } diff --git a/src/views/scenic.ts b/src/views/scenic.ts index 748b9b2..7ff3171 100644 --- a/src/views/scenic.ts +++ b/src/views/scenic.ts @@ -14,6 +14,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 { forEachTree, type Tree } from '../terrain/forest.js' import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js' import type { Feature, Sea, World } from '../world/schema.js' import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js' @@ -373,6 +374,50 @@ export const SCENES: Scene[] = [ } }, }, + { + id: 'giants', + prepare: (w) => { + const tall = (w.compiled.forests ?? []).find((f) => f.giants) + if (!tall) return null + const name = feature(w.world, tall.id)?.name ?? 'the tall wood' + const b = tall.box + const centre = { z: (b.z0 + b.z1) / 2, a: (b.a0 + b.a1) / 2 } + /** + * One particular giant, found the same way the view finds them (forest.ts), and + * circled from far enough out to see it whole. Flying *through* a grove at 13 m + * spacing put the camera inside a trunk about every other second. + */ + let best: Tree | null = null + forEachTree(tall, { z0: centre.z - 300, z1: centre.z + 300, a0: centre.a - 300, a1: centre.a + 300 }, + (z, a) => ({ stems: tall.densityPerHa, h: w.ground(wrapA(a) / RADIUS_M, z) }), + (tree) => { if (!best || tree.heightM > best.heightM) best = tree }) + if (!best) return null + const tree = best as Tree + const foot = w.ground(wrapA(tree.a) / RADIUS_M, tree.z) + return { + id: 'giants', hours: NOON, + title: `Under the giants of ${name}: ${Math.round(tree.heightM)} m, near the tallest a tree can be at 1 g`, + pose: orbit(w, { z: tree.z, a: tree.a, altM: foot + tree.heightM * 0.45 }, + 70, foot + 14, 0.6, 1.5, 12, 70), + } + }, + }, + { + id: 'canopy', + prepare: (w) => { + const wood = (w.compiled.forests ?? []).find((f) => !f.giants && f.densityPerHa > 100) + if (!wood) return null + const name = feature(w.world, wood.id)?.name ?? 'the forest' + const b = wood.box + const z = (b.z0 + b.z1) / 2 + const line = straight(z - 3000, 0, 0.35, 12_000) + return { + id: 'canopy', hours: NOON, + title: `Low over ${name}: closed canopy, and the meadows Tower left open in it`, + pose: flight(w, line, 0, 300, 260, 5000, 65), + } + }, + }, { id: 'narrows', prepare: (w) => { diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts index ea687f4..6acd492 100644 --- a/src/views/terrain-lod.ts +++ b/src/views/terrain-lod.ts @@ -17,7 +17,8 @@ import { } from 'three' import type { TerrainService } from '../terrain/service.js' import { - MATERIALS, MAX_LEVEL, ROOTS_ALONG, ROOTS_AROUND, type TileData, type TileGrid, type TileKey, WATER_ATTRS, + CANOPY_ATTRS, MATERIALS, MAX_LEVEL, ROOTS_ALONG, ROOTS_AROUND, type TileData, type TileGrid, + type TileKey, WATER_ATTRS, tileCentreKm, tileIndices, tileKey, tileSize, } from '../terrain/tile.js' @@ -75,6 +76,7 @@ export class TerrainLOD { private service: TerrainService, private material: ShaderMaterial, private waterMaterial: ShaderMaterial, + private canopyMaterial: ShaderMaterial, private opts: TerrainLODOptions, ) { this.group.name = 'ground' @@ -263,6 +265,24 @@ export class TerrainLOD { mesh.updateMatrix() mesh.visible = false if (data.water) mesh.add(this.buildWater(data.water)) + if (data.canopy) mesh.add(this.buildCanopy(data.canopy)) + return mesh + } + + /** The tile's forest canopy, a child of its ground like the water. */ + private buildCanopy(c: NonNullable<TileData['canopy']>): Mesh { + const g = new BufferGeometry() + g.setAttribute('position', new BufferAttribute(c.positions, 3)) + g.setAttribute('aCanopy', new BufferAttribute(c.attrs, CANOPY_ATTRS)) + g.setIndex(new BufferAttribute(c.indices, 1)) + g.computeBoundingSphere() + const release = function (this: { array: unknown }): void { this.array = null } + for (const a of [g.attributes['position'], g.attributes['aCanopy'], g.index]) { + (a as BufferAttribute).onUpload(release) + } + const mesh = new Mesh(g, this.canopyMaterial) + mesh.name = 'canopy' + mesh.matrixAutoUpdate = false return mesh } diff --git a/src/world/schema.ts b/src/world/schema.ts index a76d310..6b6ef94 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -179,9 +179,24 @@ export interface Desert extends Base<'desert'> { } } +/** + * A forest (world-generation.md §4.6). Tower plants these: the density is what Tower + * planted, the meadows are where it left the ground open, and a `giants` forest grows + * sequoia-class trees near the limit of what a tree can be at 1 g. + */ export interface Forest extends Base<'forest'> { upliftM: number edgeM: number + /** Mature stems per hectare where the forest is at its fullest. */ + densityPerHa: number + /** 0 all broadleaf, 1 all conifer. */ + conifer: number + /** Share of the forest floor left open as meadows, 0..1. */ + meadows: number + /** The tall wood: 90–115 m giants, far more widely spaced (§4.6). */ + giants: boolean + /** Trees thin out below this elevation and stop above it. */ + treelineM: number } export interface Settlement extends Base<'settlement'> { @@ -347,7 +362,10 @@ export function withKind(common: Common, kind: FeatureKind): Feature { pastWater: { washes: 0.6, saltPans: 3, shorelines: true }, } case 'forest': - return { ...c, kind, upliftM: 0, edgeM: 3000 } + return { + ...c, kind, upliftM: 0, edgeM: 3000, densityPerHa: 450, conifer: 0.55, + meadows: 0.12, giants: false, treelineM: 2200, + } case 'settlement': return { ...c, kind, size: 'village', radiusM: SETTLEMENT_RADIUS_M.village } case 'road': @@ -523,6 +541,14 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature { } } case 'forest': + return { + ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)), + densityPerHa: Math.max(1, Math.min(2000, num(f['densityPerHa'], d.densityPerHa))), + conifer: unit(f['conifer'], d.conifer), + meadows: unit(f['meadows'], d.meadows), + giants: typeof f['giants'] === 'boolean' ? f['giants'] : d.giants, + treelineM: num(f['treelineM'], d.treelineM), + } case 'region': return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } case 'settlement': { diff --git a/static/world.json b/static/world.json index aa32816..5fa89ca 100644 --- a/static/world.json +++ b/static/world.json @@ -922,6 +922,87 @@ "z": 102000 }, "size": "station" + }, + { + "id": "the-tall-wood", + "name": "The Tall Wood", + "blurb": "", + "notes": "", + "public": true, + "seed": 1846203771, + "kind": "forest", + "geometry": { + "type": "polygon", + "points": [ + [ + 6.04707, + 282963 + ], + [ + 6.07639, + 282501 + ], + [ + 6.11914, + 282453 + ], + [ + 6.13135, + 280578 + ], + [ + 6.11988, + 278826 + ], + [ + 6.1282, + 276867 + ], + [ + 6.08876, + 276340 + ], + [ + 6.06402, + 275222 + ], + [ + 6.02828, + 274789 + ], + [ + 5.9941, + 275563 + ], + [ + 5.97356, + 277086 + ], + [ + 5.97544, + 278832 + ], + [ + 5.96794, + 280494 + ], + [ + 5.97979, + 282237 + ], + [ + 6.01586, + 282713 + ] + ] + }, + "upliftM": 0, + "edgeM": 900, + "densityPerHa": 30, + "conifer": 1, + "meadows": 0.05, + "giants": true, + "treelineM": 2200 } ], "weather": { diff --git a/test/forest.test.ts b/test/forest.test.ts new file mode 100644 index 0000000..7dcf10a --- /dev/null +++ b/test/forest.test.ts @@ -0,0 +1,170 @@ +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 { + type CForest, type Ground, GIANT_DENSITY_PER_HA, HECTARE_M2, TREE, canopyAt, cellSizeM, + forEachTree, stemsAt, +} from '../src/terrain/forest.js' +import { prepare } from '../src/terrain/field.js' +import { cylPoint } from '../src/terrain/noise.js' +import { nearestA, signedDistance } from '../src/terrain/shapes.js' +import { type Forest, 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 greatwood = cw.forests.find((f) => f.id === 'greatwood')! +const tall = cw.forests.find((f) => f.giants)! +const authored = (id: string): Forest => world.features.find((f): f is Forest => f.id === id)! + +const groundAt = (z: number, a: number): Ground => { + const s = rt.sample(a / RADIUS_M, z, 8) + return { h: s.h, water: s.water > s.h, desert: s.desert, rock: s.rock } +} +const stems = (z: number, a: number, f = greatwood): number => + stemsAt(f, cylPoint(a / RADIUS_M, z), z, a, 8, groundAt(z, a)) +/** What a placement asks about a spot: the density there, and the ground. */ +const spotAt = (f: CForest) => (z: number, a: number) => ({ stems: stems(z, a, f), h: groundAt(z, a).h }) + +describe('what Tower planted', () => { + it('fills a forest to the density asked for, and leaves about the meadows asked for', () => { + const f = authored('greatwood') + let open = 0, full = 0, n = 0 + for (let i = 0; i < 600; i++) { + const z = 240_000 + (i % 30) * 4000 + const a = ((i * 7919) % 300) * 1000 + const g = groundAt(z, a) + if (g.water || signedDistance(greatwood.shape, z, a) < 20_000) continue + n++ + const s = stems(z, a) + if (s < 40) open++ + if (s > f.densityPerHa * 0.9) full++ + } + expect(n).toBeGreaterThan(100) + // Meadows are roughly what was asked for, and the rest of the wood is full. + expect(open / n).toBeGreaterThan(f.meadows * 0.4) + expect(open / n).toBeLessThan(f.meadows * 2.2) + expect(full / n).toBeGreaterThan(0.6) + }) + + it('stops at water, at rock, and above the treeline', () => { + const wet = { h: 100, water: true, desert: 0, rock: 0 } + const stone = { h: 100, water: false, desert: 0, rock: 1 } + const high = { h: greatwood.treelineM + 10, water: false, desert: 0, rock: 0 } + const here = cylPoint(0.2, 300_000) + expect(stemsAt(greatwood, here, 300_000, 10_000, 8, wet)).toBe(0) + expect(stemsAt(greatwood, here, 300_000, 10_000, 8, stone)).toBe(0) + expect(stemsAt(greatwood, here, 300_000, 10_000, 8, high)).toBe(0) + }) + + it('gives its ground to a smaller forest standing inside it', () => { + // Inside the tall wood, Greatwood plants nothing: the giants stand instead of it (§4.6). + const centre = { z: (tall.box.z0 + tall.box.z1) / 2, a: (tall.box.a0 + tall.box.a1) / 2 } + expect(stems(centre.z, centre.a, greatwood)).toBe(0) + expect(stems(centre.z, centre.a, tall)).toBeGreaterThan(10) + // And away from it, Greatwood is itself again. + expect(stems(300_000, 40_000, greatwood)).toBeGreaterThan(200) + }) + + it('makes the canopy stand where the trees are and lie down in the meadows', () => { + expect(canopyAt(greatwood, 0)).toBe(0) + expect(canopyAt(greatwood, greatwood.densityPerHa)).toBeCloseTo(greatwood.canopyM, 6) + expect(canopyAt(greatwood, 20)).toBeLessThan(greatwood.canopyM * 0.5) + // The giants' canopy stands where §4.6 says: about 80 m proud of the ordinary wood. + expect(tall.canopyM - greatwood.canopyM).toBeGreaterThan(65) + }) +}) + +describe('where the trees stand', () => { + const box = { z0: 300_000, z1: 300_400, a0: 40_000, a1: 40_400 } + const collect = (f: CForest): { z: number; a: number; heightM: number }[] => { + const out: { z: number; a: number; heightM: number }[] = [] + forEachTree(f, box, spotAt(f), (t) => out.push({ z: t.z, a: t.a, heightM: t.heightM })) + return out + } + + it('puts the same trees in the same places every time it is asked', () => { + expect(collect(greatwood)).toEqual(collect(greatwood)) + }) + + it('agrees about a tree whoever asks: the same ones in an overlapping patch', () => { + const half = { z0: 300_100, z1: 300_400, a0: 40_100, a1: 40_400 } + const inHalf = collect(greatwood).filter((t) => + t.z >= half.z0 && t.z <= half.z1 && t.a >= half.a0 && t.a <= half.a1) + const asked: { z: number; a: number; heightM: number }[] = [] + forEachTree(greatwood, half, spotAt(greatwood), (t) => asked.push({ z: t.z, a: t.a, heightM: t.heightM })) + expect(asked).toEqual(inHalf) + }) + + it('stands them about as thickly as the density says, and never two in a place', () => { + const trees = collect(greatwood) + const hectares = ((box.z1 - box.z0) * (box.a1 - box.a0)) / HECTARE_M2 + const density = trees.length / hectares + expect(density).toBeGreaterThan(greatwood.densityPerHa * 0.5) + expect(density).toBeLessThanOrEqual(greatwood.densityPerHa * 1.1) + // Stratified placement: one tree to a cell of the global grid, and never two. + const cell = cellSizeM(greatwood) + const seen = new Set<string>() + for (const t of trees) { + const key = `${Math.floor(t.z / cell)},${Math.floor(t.a / cell)}` + expect(seen.has(key)).toBe(false) + seen.add(key) + } + }) + + it('grows them at the sizes §4.6 gives, giants included', () => { + for (const t of collect(greatwood)) { + expect(t.heightM).toBeGreaterThan(TREE.broadleaf.minM * 0.5) + expect(t.heightM).toBeLessThanOrEqual(TREE.conifer.maxM) + } + const giants: number[] = [] + const b = { z0: tall.box.z0 + 2000, z1: tall.box.z0 + 2400, a0: tall.box.a0 + 2000, a1: tall.box.a0 + 2400 } + forEachTree(tall, b, spotAt(tall), (t) => giants.push(t.heightM)) + expect(giants.length).toBeGreaterThan(0) + for (const h of giants) { + expect(h).toBeGreaterThan(TREE.giant.minM * 0.5) + expect(h).toBeLessThanOrEqual(TREE.giant.maxM) + } + expect(tall.densityPerHa).toBeLessThanOrEqual(GIANT_DENSITY_PER_HA) + }) + + it('leaves room between the giants: their crowns do not close over twice', () => { + // Crown area against ground area, at the wood's own density: a grove, not a thicket. + const crown = Math.PI * ((TREE.giant.crown * ((TREE.giant.minM + TREE.giant.maxM) / 2)) / 2) ** 2 + expect((crown * tall.densityPerHa) / HECTARE_M2).toBeLessThan(1.1) + }) +}) + +describe('settlements', () => { + it('stand on cleared ground: no forest inside a city', () => { + const capital = world.features.find((f) => f.id === 'the-capital')! + if (capital.kind !== 'settlement' || capital.geometry.type !== 'point') throw new Error('capital') + const { theta, z } = capital.geometry + const a = theta * RADIUS_M + // The Capital stands in the middle of Greatwood's belt, and yet it is not in a wood. + expect(signedDistance(greatwood.shape, z, a)).toBeGreaterThan(0) + expect(stems(z, a)).toBe(0) + expect(stems(z, a + capital.radiusM * 0.5)).toBe(0) + // And the trees come back outside it — antispinward, away from The Deep. + expect(stems(z, a - capital.radiusM * 1.6)).toBeGreaterThan(100) + }) +}) + +describe('the tall wood, placed once', () => { + it('is a giants forest inside Greatwood, near the Capital', () => { + const f = authored('the-tall-wood') + expect(f.giants).toBe(true) + expect(f.geometry.type).toBe('polygon') + const capital = world.features.find((x) => x.id === 'the-capital')! + if (capital.geometry.type !== 'point') throw new Error('capital') + const centre = { z: (tall.box.z0 + tall.box.z1) / 2, a: (tall.box.a0 + tall.box.a1) / 2 } + const ca = capital.geometry.theta * RADIUS_M + const d = Math.hypot(centre.z - capital.geometry.z, nearestA(centre.a, ca) - ca) + expect(d).toBeGreaterThan(15_000) + expect(d).toBeLessThan(50_000) + // On dry ground, inside Greatwood. + expect(signedDistance(greatwood.shape, centre.z, centre.a)).toBeGreaterThan(5000) + expect(rt.sample(centre.a / RADIUS_M, centre.z, 8).water).toBe(-Infinity) + }) +}) diff --git a/test/terrain.test.ts b/test/terrain.test.ts index 751cd1b..e92b4e4 100644 --- a/test/terrain.test.ts +++ b/test/terrain.test.ts @@ -16,15 +16,17 @@ const rt = prepare(cw) describe('the world compiles', () => { it('ports every v1 feature, plus the ground raised under the rivers', () => { - expect(world.features).toHaveLength(29) + // Twenty-nine ported from v1, plus The Tall Wood, which Tower placed from the seed (D-071). + expect(world.features).toHaveLength(30) // Eight ranges in two groups: the Girdle with the four massifs on it, and the // three desert ridges, which overlap each other. expect(cw.mountains).toHaveLength(2) expect(cw.mountains.flatMap((g) => g.ids)).toHaveLength(8) expect(cw.basins).toHaveLength(7) expect(cw.rivers).toHaveLength(4) - // The Dry Reach, Greatwood, and The Uplands (D-064). - expect(cw.uplifts).toHaveLength(3) + // The Dry Reach, Greatwood, and The Uplands (D-064); the tall wood raises nothing. + expect(cw.uplifts).toHaveLength(4) + expect(cw.forests.map((f) => f.id)).toEqual(['greatwood', 'the-tall-wood']) }) it('names every step it takes, in the order it takes them', () => { diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 4d1fcfb..843d177 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -358,6 +358,30 @@ const SHOTS = [ await settle(page, 1400) }, }, + { + id: 'tour-giants', + caption: 'Scenic tour — under the giants of the Tall Wood, 100 m of them', + on: ['desktop'], + scene: 'giants', + run: async (page) => { + await openView(page, 'interior') + const title = await page.evaluate(() => window.tower.showScene('giants', 14)) + if (!title) throw new Error('no scene giants in this world') + await settle(page, 1400) + }, + }, + { + id: 'tour-canopy', + caption: 'Scenic tour — low over Greatwood, closed canopy and the meadows left open in it', + on: ['desktop'], + scene: 'canopy', + run: async (page) => { + await openView(page, 'interior') + const title = await page.evaluate(() => window.tower.showScene('canopy', 18)) + if (!title) throw new Error('no scene canopy in this world') + await settle(page, 1400) + }, + }, { id: 'tour-narrows', caption: 'Scenic tour — through The Narrows, the one gap in all three ridges', @@ -412,7 +436,12 @@ const SHOTS = [ on: ['desktop'], run: async (page) => { await openView(page, 'interior') - await pick(page, 'Greatwood') + /** + * From open ground, not from Greatwood: since the forest is trees (D-071), standing + * in it and looking up shows a canopy, which is honest and is not what this shot is + * about — the claim being photographed is about the air, not about the wood. + */ + await pick(page, 'The Capital') const s = await stage(page) /** * Dragging down pitches the view up. The interior turns at 0.0032 rad per diff --git a/world-generation.md b/world-generation.md index 486bf24..e976ca5 100644 --- a/world-generation.md +++ b/world-generation.md @@ -1,8 +1,8 @@ # Tower — Live World Generation -**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 +**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–6 are built** +(D-063 to D-067, D-069, D-071), with a scenic tour between 4 and 5 (§19, D-068) and the +Capital given an extent (D-070); phases 7–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. @@ -503,6 +503,10 @@ other feature. Where it overlaps Greatwood, the Tall Wood's trees replace Greatw **Why not render every tree.** Greatwood is 216 × 314 km. At 450 stems/ha that is **3 billion trees.** Only the ones within 4 km of the eye exist at any moment. +*As built (§21): the density field, meadows, the three distances and the tall wood, with +the ranges closer in than planned (meshes to 320 m, cards to 760 m, canopy skin beyond) and +without authored stands or paths — stands and the trail network wait for phase 7's router.* + ### 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, @@ -1035,7 +1039,7 @@ screenshot check before any commit. | 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 | -| 6 | **Forest** | density field, stands, meadows, the tall forest, three-distance tree rendering | +| 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 | @@ -1317,3 +1321,69 @@ find them, and a screenshot at a fixed place was a poor way to show them. - **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. + +--- + +## 21. Phase 6, as built (D-071) + +- **A forest is a density field and a placement rule** (`terrain/forest.ts`). Stems per + hectare come from what Tower planted, thinned toward the forest's edge, opened where it + left a meadow, and stopped by water, rock, sand and the treeline. The meadow threshold + *and the width of a meadow's edge* are quantiles measured over the forest itself: a band + of ±0.02 in noise units left Greatwood as scattered islands, because its noise only + varies by about that much in total. +- **Placement is stratified, as planned.** A fixed global grid about one tree apart; each + cell hashes its own coordinates for whether it holds a tree, where in the cell, and what + it is, accepted in proportion to the density there. So **any tile, any view and any + visitor works out the same trees with no knowledge of the others**, and walking away and + back puts every tree back where it was. Tested. +- **Four ranges, not the three the plan drew** — because at three the wood visibly ended a + few hundred metres off, and that edge walked along with you (reported by the author): + - **to ~210 m — meshes.** A conifer is a tapered trunk and three cones; a broadleaf is a + trunk and three lumps; a giant is a column with its crown in the top half. 40–70 + triangles each, instanced, 6,000 of them. + - **to ~560 m — cards.** One quad each, turned to face the eye about the tree's own + upright, with the silhouette cut out in the fragment shader: a cone, a round crown or a + column. No texture — at that distance a tree is a few pixels and its outline is all of + it that survives. About 39,000. + - **to 2.8 km — clumps.** One card for everything in a 30 m cell, drawn as three crowns + over a band: a card per tree would be a quarter of a million at a kilometre and a + million at two, and a clump every thirty metres reaches nearly three kilometres for + 26,000. At that distance a clump is a thumbnail held at arm's length, which is what a + group of crowns looks like from there. + - **under all of it, from 620 m out — the canopy skin.** Each tile carries a second + surface: its own ground vertices, lifted by the canopy's height with a rough top, + sinking back onto the ground where the trees themselves take over. It is what closes the + wood behind the clumps — a field of cards seen from above is all gaps. +- **The skin is opened up from below.** Seen from inside the wood it was a black ceiling + over everything past the last drawn tree. A canopy is not solid — the gaps between crowns + are what let you see any distance through a forest — so from underneath it drops about + half its fragments on a pattern fixed to the ground, and what is left is lit as light that + has come *through* the canopy rather than off it. +- **Meadows are grass.** The forest-floor colour now follows the stems, so a clearing + inside a wood is the plain ramp and reads as open grass, which is what makes the meadows + visible from the air at all. +- **The Tall Wood exists** (§4.6): 52 km² of giants, 90–115 m, drawn once from the world's + seed — 17 km from the Capital, well inside Greatwood, clear of the water and the rivers — + and written into the world file, where the author can move it like anything else. Its + crowns are narrow for their height, as a redwood's are: at the planned 0.34 of height the + grove closed over into unbroken dark, so a giant carries 0.18 and stands 18 m from its + neighbours. **Greatwood gives up its ground where a smaller forest stands**, so the + giants stand *instead of* the ordinary wood rather than mixed through it. +- **Cost**: 130–160 fps standing in Greatwood, and a rebuild is about 19 ms for 71,000 + trees. Three things bought that: the placement reads the density off the grid the view + already samples rather than working the noise out per candidate tree; the clump layer, + two kilometres out, is left alone until the eye has moved 170 m; and a box is cut to its + circle before the ground is looked at. In flight the near budget drops to a quarter, and + is restored in full the moment the eye settles — arriving somewhere used to leave the wood + thin until you walked thirty metres. +- **The panel** sets stems per hectare, the conifer share, the meadows, the treeline and + whether a wood is giants, and says what that comes to: "3.1 billion trees over 67,858 km², + about 4.7 m apart, canopy 28 m." +- **Two more tour scenes**: under one of the giants, and low over Greatwood's canopy. +- **A settlement clears its ground.** The Capital stands at z 270 km, in the middle of + Greatwood's belt, and was standing in a wood; a forest now stops at any settlement's + footprint and thins over its last fifth. Until phase 7 fills that footprint the Capital is + 20 km of grass, which is at least the right kind of nothing. +- **Not built:** authored stands (a polygon with its own density and species), paths + through the woods, and trees on the map. Stands and paths belong with phase 7's router.