tower / commit
fix(world): raise the lowland, thicken the hull, and let rivers prefer to cut
README.md | 4 ++ content/world.json | 16 +++++++ decisions.md | 46 ++++++++++++++++++ design.md | 31 +++++++------ docs/screenshots/desktop-exterior-true.png | Bin 524368 -> 489413 bytes docs/screenshots/desktop-exterior.png | Bin 539835 -> 519903 bytes docs/screenshots/desktop-interior-dawn.png | Bin 469376 -> 463879 bytes docs/screenshots/desktop-interior-night.png | Bin 305425 -> 305481 bytes docs/screenshots/desktop-interior-overhead.png | Bin 510622 -> 510708 bytes docs/screenshots/desktop-interior-suncap.png | Bin 389055 -> 384139 bytes docs/screenshots/desktop-interior.png | Bin 414916 -> 410017 bytes docs/screenshots/desktop-map-sightline.png | Bin 928873 -> 928303 bytes docs/screenshots/desktop-map.png | Bin 901075 -> 901096 bytes docs/screenshots/desktop-reader.png | Bin 885047 -> 884742 bytes docs/screenshots/mobile-exterior.png | Bin 121671 -> 116101 bytes docs/screenshots/mobile-interior.png | Bin 120338 -> 117806 bytes docs/screenshots/mobile-map.png | Bin 239517 -> 239561 bytes docs/screenshots/mobile-panel.png | Bin 81032 -> 80595 bytes docs/screenshots/overview.png | Bin 1211003 -> 1198797 bytes src/terrain/compile.ts | 62 +++++++++++++++++-------- src/terrain/field.ts | 56 +++++++++++++++------- src/terrain/map-image.ts | 2 +- src/terrain/service.ts | 3 +- src/terrain/tile.ts | 28 +++++------ src/ui/app.ts | 14 +++++- src/views/exterior.ts | 41 ++++++++++++---- src/views/interior-model.ts | 9 +--- src/world/schema.ts | 21 +++++++-- static/world.json | 16 +++++++ test/terrain.test.ts | 44 ++++++++++++++---- world-generation.md | 10 +++- 31 files changed, 304 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 8a2c524..9c15b74 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,10 @@ The rest are a single click. The ground regenerates as soon as the feature exist Drag markers to move them, **shift-click two points** to measure a sightline, **f** to fit the view. +A **Region** is a label, and it can also be ground Tower raised: give it an uplift and +everything inside it is lifted to that height above the open ground, wherever that is +higher than what is there. The Uplands are one. + Changing a feature's **Kind** swaps its parameters for the new kind's defaults and keeps its name, notes, shape and seed; only kinds that can take its shape are offered. diff --git a/content/world.json b/content/world.json index 03f885e..a8c5fd2 100644 --- a/content/world.json +++ b/content/world.json @@ -599,6 +599,22 @@ "upliftM": 780, "edgeM": 20000 }, + { + "id": "the-uplands", + "name": "The Uplands", + "blurb": "", + "notes": "Provisional name. Ground Tower raised between the Girdle and the Dry Reach, so the four rivers off the range can run down to The Narrows instead of into a hole. v1 left this as a lowland 60 m high below an 800 m plateau and painted the rivers across it a kilometre up; generated honestly they needed embankments up to 950 m high (D-063). Raised to about 1,000 m, the rivers need at most ~200 m of levee (D-064).", + "public": true, + "seed": 1990705996, + "kind": "region", + "geometry": { + "type": "band", + "zFrom": 105000, + "zTo": 150000 + }, + "upliftM": 1000, + "edgeM": 8000 + }, { "id": "first-ridge", "name": "First Ridge", diff --git a/decisions.md b/decisions.md index 2686df4..6c70078 100644 --- a/decisions.md +++ b/decisions.md @@ -2039,3 +2039,49 @@ since the picture on the hull changed anyway. 1.2 s to draw the map, so the status dialog rarely appears on the author's laptop; it will on slower machines, and it will once erosion arrives in phase 2. A production build reaches a fully detailed interior 4.3 s after a cold load. + +--- + +## D-064 — The Uplands, a hull a kilometre thick, and rivers that prefer to cut + +**The author's call on D-063's open question: raise the lowland.** A region can now +carry an uplift, and **The Uplands** (provisional name) lift the ring of ground between +the Girdle and the Dry Reach — z 105 to 150 km — to about 1,000 m. A raised region lifts +the ground *to* a height rather than *by* one: it takes the higher of itself and what is +already there. Added, it stacked onto the plateau's rising edge and moved the river's +problem ten kilometres deepward instead of solving it; taken as a maximum, it meets the +plateau and stops. + +Measured before choosing, as each river's worst embankment: + +| | worst fill | The Narrows cut | +|---|---|---| +| v1's lowland | 895–980 m | ~670 m | +| Uplands at 1,000 m, least-squares profile | 240–350 m | ~390 m | +| Uplands at 1,000 m, **0.2-quantile profile** | **93–199 m** | ~540 m | + +**The profile now prefers cutting to filling.** Least squares treats a metre of +embankment like a metre of cutting, and so balances the two; but a cut through high +ground reads as a valley and a 300 m bank across a plain reads as a dam. Fitting the +0.2-quantile instead keeps the water below four-fifths of the ground in any reach. The +Narrows comes out a little past design.md's "up to 480 m" at about 540 m, which is +recorded rather than tuned away. + +**The hull is a kilometre thick all the way round.** D-044 gave Tower a 1 km wall and +drew it only at the caps, reasoning that a closed can shows no thickness from outside. +That held while the ground never went far below the floor. Generated sea floors reach +330 m down and the tiles hang skirts below that, and with the outer skin at the floor's +own radius they came through the hull as a lattice of lines (the author's screenshot). +The skin now sits a kilometre out, rings close the wall at both ends, and nothing +generated may go deeper than 950 m below the datum. + +**Two smaller faults found on the way.** + +- *A `#view=` link built its 3D view twice.* Reading the hash and choosing the initial + view each started the deferred import before either had finished, so the exterior + drew every caption twice and the interior would have run two renderers and two sets + of tiles. The import is now shared. +- *Rivers drew as dotted lines at a distance.* D-063 weighted water by the channel's + width over the vertex spacing, but only at vertices that landed in the channel. It + now weighs every vertex by the share of its own cell the channel covers, so a coarse + tile draws a river as a faint continuous line. diff --git a/design.md b/design.md index 197a786..9df4b33 100644 --- a/design.md +++ b/design.md @@ -546,6 +546,7 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4): |---|---|---| | 0–42 km | **The Sunward Sea** | ring ocean at the foot of the sun cap | | 55–140 km | **the range** | Westwall, The Sisters, **Snowcrown** (4,300 m), Eastwall — the named high points of **The Girdle**, which rings the world at z 90 km (D-051) | +| 105–150 km | **The Uplands** | ground Tower raised to about 1,000 m, ringing the world, so the rivers off the range can run down to The Narrows (D-064) | | 148–218 km | **The Dry Reach** | desert **ringing the world**, 22,000 km², 13% of the surface | | 176/190/203 km | **three ridges** | ringing, concentric — and **The Narrows** is the only breach in all three | | 214–430 km | **Greatwood** | forest belt | @@ -574,16 +575,17 @@ gathering each other, and leave with the fourth through the single gap. where Coriolis is exactly zero. - Whoever holds The Narrows holds the axis of the world. The Capital sits behind it. -**The drainage needs the floor built up in one place, and generating it honestly is -what showed it** (D-063). Between the Girdle's foot and the Dry Reach's rise — z 115 -to 135 km — the ground authored in v1 is a lowland barely 60 m high, while the plateau -the rivers must cross beyond it stands at 800–1,100 m. v1 drew its rivers across that -lowland at about 1,000 m, painted over ground a kilometre below them. A river cannot -climb, so the generator gives each river the descending profile nearest the ground it -crosses: it cuts The Narrows about 300–500 m into the ridges — the depth this section -has always given — and **builds the lowland up by up to 880 m under each river** to -carry it there. Tower shapes its floor so its rivers run; here that means four -engineered embankments across a plain. See the open question below. +**The drainage needed the floor raised, and generating it honestly is what showed it** +(D-063, D-064). As v1 authored it, the ground between the Girdle's foot and the Dry +Reach — z 115 to 135 km — was a lowland barely 60 m high, below a plateau of +800–1,100 m, and v1 painted its rivers across it a kilometre up. A river cannot climb. +Generated faithfully, each needed an embankment up to 950 m high to reach The Narrows. + +So Tower raised the lowland: **The Uplands**, a ring of ground lifted to about 1,000 m, +meeting the Girdle's skirt on one side and the plateau's edge on the other. The rivers +now fall steadily off the range, across the Uplands and onto the plateau, and the floor +under them is mostly cut — The Narrows about 500 m deep — and built up by no more than +~200 m anywhere, the height of a levee rather than a dam. #### The Deep is oriented for the view @@ -620,10 +622,6 @@ Hollow Station — around 2,100 m, with the runs above them. #### Open questions -- **The lowland under the rivers.** The four embankments above follow from the ground - as authored. The alternatives are to raise the lowland itself — the Girdle's skirt - reaching out to meet the Dry Reach — or to let the rivers pool there as a lake that - spills over the plateau's lip. Either is a change to the world, not to the generator. - **The Spindle's height.** 3.2 km is a placeholder; an object dropped from its top lands ~760 m antispinward of its base. - **z 430–496 km** is unauthored. The circumference outside the named peaks is no @@ -784,7 +782,10 @@ collector and the stars beyond the far one. The shaders work in the tower's fram and the exterior hands them the world-to-tower transform each frame, because the hull is turning and the air turns with it. -The outer skin keeps its own mesh and its own two modes. Three's cylinder runs u +The outer skin keeps its own mesh and its own two modes. It sits **a kilometre out from +the floor**, where D-044 puts the wall's outer face, with a ring closing the wall at each +end — generated ground goes below the datum, and a skin at the floor's own radius let +sea floors and tile skirts show through the hull (D-064). Three's cylinder runs u around and v along, while the image is the other way round, so the UVs are rewritten from the original attribute rather than derived from vertex positions — the seam vertices are duplicated, and deriving an angle from them smears one column of diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index 7ef525d..177726b 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 4635b8a..a297bfa 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 71fac24..75e2e9b 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 8e2fb63..5b7b3d3 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 7168884..db2e892 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 0981048..8ee131e 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 62b49a1..41d3b9b 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 efb751d..dedc82a 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 f6f8f95..2f0ec03 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 da6bd35..8310124 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index bca5379..0485ef0 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 ce788ec..8c8d284 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 20977b8..db21807 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 3359577..8497ed6 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 ff14d4d..9b29edf 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts index 56964e6..a13761a 100644 --- a/src/terrain/compile.ts +++ b/src/terrain/compile.ts @@ -22,7 +22,8 @@ import { elevationStage, prepare, Stage } from './field.js' export interface CUplift { id: string - kind: 'desert' | 'forest' + /** A region raises ground and claims no cover; a desert or forest does both. */ + kind: 'desert' | 'forest' | 'region' shape: Shape upliftM: number edgeM: number @@ -124,8 +125,9 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com // Order is the order of the elevation stack (field.ts): each stage may sample // everything compiled before it, and nothing after. for (const f of world.features) { - if (f.kind !== 'desert' && f.kind !== 'forest') continue - onStep(f.kind === 'desert' ? `Quoting the deserts of Earth in ${f.name}` : `Planting ${f.name}`) + if (f.kind !== 'desert' && f.kind !== 'forest' && !(f.kind === 'region' && f.upliftM)) continue + onStep(f.kind === 'desert' ? `Quoting the deserts of Earth in ${f.name}` + : f.kind === 'forest' ? `Planting ${f.name}` : `Raising ${f.name}`) const shape = shapeOf(f.geometry) if (!shape) continue cw.uplifts.push({ @@ -282,6 +284,13 @@ const RIVER_STEP_M = 200 * reaches. Anything flatter is a canal, and a canal has to be pumped. */ const MIN_GRADE = 0.002 +/** + * How strongly a river's profile prefers cutting to filling: the quantile of the ground + * it sits at. 0.5 would treat a metre of embankment like a metre of cutting; 0.2 lets + * the water run below four-fifths of the ground in any pooled reach, so the floor is + * mostly dug and only built up where it has to be. + */ +const FILL_QUANTILE = 0.2 /** Rise per metre of run on the flanks of ground built up to carry a river. */ export const EMBANKMENT_SLOPE = 0.15 /** How far past its end a river may be from the water it flows into. */ @@ -415,7 +424,7 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener) // An authored source height is only a ceiling: pinning the water to it where the // ground is far lower would stand the river's first kilometre on a tower of fill. if (lake) { target[0] = lake.levelM; weight[0] = n } - const fitted = nonIncreasing(target.map((v, i) => v + grade * line.s[i]!), weight) + const fitted = nonIncreasing(target.map((v, i) => v + grade * line.s[i]!), weight, FILL_QUANTILE) /** * Never below the water it drains into plus the grade still to fall. Where the whole * course lies lower than the river it joins — a tributary drawn across a lowland to @@ -439,27 +448,40 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener) } /** - * The weighted least-squares non-increasing sequence nearest `y`: pool adjacent - * violators. Linear time. Each block holds the weighted mean of the values pooled into it. + * The non-increasing sequence nearest `y`, by pool adjacent violators. + * + * `tau` sets what "nearest" means. At 0.5 each pooled block takes the weighted median + * of its values — least absolute error, cut and fill alike. Below 0.5 it takes a lower + * quantile, so the sequence prefers to sit *under* the data: for a river, cutting the + * ground is cheaper than building it up, which is the right way round (D-064). The + * blocks keep their values, so this is quadratic in the worst case; a river is a few + * hundred points. */ -export function nonIncreasing(y: readonly number[], w: readonly number[] = y.map(() => 1)): number[] { - const mean: number[] = [] - const weight: number[] = [] - const count: number[] = [] +export function nonIncreasing( + y: readonly number[], w: readonly number[] = y.map(() => 1), tau = 0.5, +): number[] { + type Block = { values: [number, number][]; stat: number; count: number } + const quantile = (values: [number, number][]): number => { + values.sort((a, b) => a[0] - b[0]) + const total = values.reduce((t, v) => t + v[1], 0) + let run = 0 + for (const [v, wt] of values) { + run += wt + if (run >= tau * total) return v + } + return values[values.length - 1]![0] + } + const blocks: Block[] = [] y.forEach((v, i) => { - mean.push(v) - weight.push(w[i]!) - count.push(1) + blocks.push({ values: [[v, w[i]!]], stat: v, count: 1 }) // A later block higher than the one before it is a violation: merge them. - while (mean.length > 1 && mean[mean.length - 1]! > mean[mean.length - 2]!) { - const m1 = mean.pop()!, w1 = weight.pop()!, c1 = count.pop()! - const m0 = mean.pop()!, w0 = weight.pop()!, c0 = count.pop()! - mean.push((m0 * w0 + m1 * w1) / (w0 + w1)) - weight.push(w0 + w1) - count.push(c0 + c1) + while (blocks.length > 1 && blocks[blocks.length - 1]!.stat > blocks[blocks.length - 2]!.stat) { + const b1 = blocks.pop()!, b0 = blocks.pop()! + const values = b0.values.concat(b1.values) + blocks.push({ values, stat: quantile(values), count: b0.count + b1.count }) } }) const out: number[] = [] - mean.forEach((m, k) => { for (let i = 0; i < count[k]!; i++) out.push(m) }) + for (const b of blocks) for (let i = 0; i < b.count; i++) out.push(b.stat) return out } diff --git a/src/terrain/field.ts b/src/terrain/field.ts index 90fb628..30d44c8 100644 --- a/src/terrain/field.ts +++ b/src/terrain/field.ts @@ -39,10 +39,13 @@ export interface Sample { /** Water surface, or -Infinity where there is none. */ water: number /** - * How wide the water here is, metres: Infinity for a lake or sea, the channel for a - * river. A tile whose vertices are further apart than this cannot show it. + * How much of the ground this sample stands for is water, 0..1, at this level of + * detail. Lakes and seas cover all of it. A river 80 m wide covers a fraction of a + * 500 m cell — and covers it whether or not the sample point itself is in the + * channel, so a coarse tile draws a river as a faint continuous line rather than as + * dots where its vertices happened to land in the water. */ - waterWidthM: number + waterCover: number /** 0..1 claims on the surface. The shader turns these into colour. */ desert: number forest: number @@ -88,7 +91,7 @@ function warp(seed: number, p: CylPoint, z: number, a: number, ampM: number, sca */ export class Runtime { private indexes: LineIndex[] - private out: Sample = { h: 0, water: -Infinity, waterWidthM: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 } + private out: Sample = { h: 0, water: -Infinity, waterCover: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 } private nearest: Nearest = { d: 0, seg: 0, t: 0 } constructor(readonly world: CompiledWorld) { @@ -105,7 +108,7 @@ export class Runtime { const a = theta * RADIUS_M const p = cylPoint(theta, z) o.water = -Infinity - o.waterWidthM = 0 + o.waterCover = 0 o.desert = 0 o.forest = 0 o.relief = 0 @@ -117,8 +120,15 @@ export class Runtime { return o } - o.h = ground(w, p, detailM) - for (const u of w.uplifts) if (inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o) + const g0 = ground(w, p, detailM) + o.h = g0 + for (const u of w.uplifts) { + if (u.kind !== 'region' && inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o) + } + // Raised regions last: they lift the ground *to* a height, not by one (D-064). + for (const u of w.uplifts) { + if (u.kind === 'region' && inBox(u.box, z, a)) raise(u, p, z, a, detailM, g0, o) + } let relief = 0 for (const m of w.mountains) { if (!inBox(m.box, z, a)) continue @@ -130,7 +140,7 @@ export class Runtime { for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o) if (upTo === Stage.Basins) return o w.rivers.forEach((r, i) => { - if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, o, this.nearest) + if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, detailM, o, this.nearest) }) return o } @@ -181,7 +191,21 @@ function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, // line rather than a ruled one. const claim = smoothstep(0.25, 0.4, m + n * 0.45) if (u.kind === 'desert') o.desert = Math.max(o.desert, claim) - else o.forest = Math.max(o.forest, claim) + else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim) +} + +/** + * Ground Tower raised: the open ground lifted by the region's uplift, wherever that is + * higher than what is already here. Taking the higher rather than adding is what lets + * a raised region run up to a desert plateau without the two stacking into a wall at + * the join — the plateau stands where it is higher, the region where the land was low. + */ +function raise(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, g0: number, o: Sample): void { + const [wz, wa] = warp(u.seed, p, z, a, u.warpM, u.edgeM * 2, detailM) + const m = smoothstep(-u.edgeM, 0, signedDistance(u.shape, wz, wa)) + if (m <= 0) return + const n = fbm(layer(u.seed, 1), p, u.noiseScaleM, 4, detailM) + o.h = Math.max(o.h, g0 + m * (u.upliftM + n * u.noiseAmpM)) } /** How high a range stands here, above the land under it. */ @@ -248,7 +272,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: : smoothstep(0, b.shoreM, sd) if (m <= 0.001) return o.h -= b.depthM * m ** 0.7 - if (m > 0.05 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity } + if (m > 0.05 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 } return } const sd = signedDistance(b.shape, z, a) @@ -256,7 +280,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: const d = 1 - Math.min(1, sd / b.maxInsideM) o.h -= b.depthM * Math.cos((d * Math.PI) / 2) ** 1.2 if (d > 0.68) o.h += b.rimM * Math.sin(((d - 0.68) / 0.32) * Math.PI) - if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity } + if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 } } /** @@ -268,7 +292,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: * and a canyon where it crosses high ground. An embankment is the same idea the other * way up — flanks falling away at a fixed slope until they meet the ground. */ -function river(r: CRiver, index: LineIndex, z: number, a: number, o: Sample, near: Nearest): void { +function river(r: CRiver, index: LineIndex, z: number, a: number, detailM: number, o: Sample, near: Nearest): void { const segs = index.near(z, a) if (!segs.length) return nearestOnSegments(r.line, z, a, segs, near) @@ -285,8 +309,8 @@ function river(r: CRiver, index: LineIndex, z: number, a: number, o: Sample, nea // with a few metres of level top before the flanks fall away. const fill = x <= 0 ? floor : level + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE o.h = smin(-smin(-o.h, -fill, 12), cut, 12) - if (x <= 0 && level > o.h) { - o.water = Math.max(o.water, level) - o.waterWidthM = Math.max(o.waterWidthM, 2 * hw) - } + if (x <= 0 && level > o.h) o.water = Math.max(o.water, level) + // A sample stands for a cell about half the detail wavelength across (tile.ts). + const cell = Math.max(1, detailM / 2) + if (near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, Math.min(1, (2 * hw) / cell)) } diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts index 18651f8..1df5b6a 100644 --- a/src/terrain/map-image.ts +++ b/src/terrain/map-image.ts @@ -37,7 +37,7 @@ export function renderMapRows( for (let x = 0; x < width; x++) { const s = rt.sample(theta, (x + 0.5) * mppX, detail) shown[r * width + x] = Math.max(s.h, s.water) - if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length, Math.max(mppX, mppY)) + if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length) } } diff --git a/src/terrain/service.ts b/src/terrain/service.ts index 38d1c45..256cf26 100644 --- a/src/terrain/service.ts +++ b/src/terrain/service.ts @@ -101,7 +101,8 @@ export class TerrainService { // Every compile step, plus drawing the map. const counted = world.features.filter((f) => - ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)).length + ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind) + || (f.kind === 'region' && f.upliftM)).length const total = 1 + counted + 1 let done = 0 this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() }) diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts index 37739b1..4fb1853 100644 --- a/src/terrain/tile.ts +++ b/src/terrain/tile.ts @@ -16,6 +16,14 @@ import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' import type { Runtime, Sample } from './field.js' +/** + * The deepest anything generated may reach, metres below the datum. Tower's wall is a + * kilometre thick (D-044), and its outer skin sits at that depth: sea floors are cut to + * about -330 m, and nothing — not a basin, not a tile's skirt — may reach the skin, or + * it shows through the hull from outside (D-064). + */ +export const LOWEST_GROUND_M = -950 + export const ROOTS_ALONG = 16 export const ROOTS_AROUND = 10 export const MAX_LEVEL = 10 @@ -73,16 +81,9 @@ const smoothstep = (e0: number, e1: number, x: number): number => { * * `out` receives MATERIALS.length values in 0..1. */ -export function materialsAt( - s: Sample, slope: number, out: Float32Array | number[], offset = 0, spacingM = 0, -): void { +export function materialsAt(s: Sample, slope: number, out: Float32Array | number[], offset = 0): void { const shown = Math.max(s.h, s.water) - /** - * Water covers a vertex only as far as it covers the ground that vertex stands for. - * An 80 m river under a vertex of a 500 m tile would otherwise paint a 500 m blue - * diamond, and a line of them down every river on the far side of the world. - */ - const water = s.water > s.h ? Math.min(1, s.waterWidthM / Math.max(1, spacingM)) : 0 + const water = s.waterCover const rock = smoothstep(1300, 1900, s.relief) const desert = s.desert * (1 - rock) const forest = s.forest * (1 - rock) * (1 - desert) @@ -90,7 +91,7 @@ export function materialsAt( // Snow does not lie on cliffs. Blending it out with slope is what turns a white blob // into a mountain with rock showing through. const holds = 1 - smoothstep(0.55, 1.1, slope) - const snow = water ? 0 : smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds + const snow = (1 - water) * smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds out[offset] = plain out[offset + 1] = desert out[offset + 2] = forest @@ -98,7 +99,7 @@ export function materialsAt( out[offset + 4] = snow out[offset + 5] = water // Water deepens to its darkest over the first 90 m, as the v1 map did. - out[offset + 6] = water ? Math.min(1, (s.water - s.h) / 90) : 0 + out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0 out[offset + 7] = 0 } @@ -129,7 +130,7 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { const k = v * W + u shown[k] = Math.max(s.h, s.water) // Slope is not known until the neighbours are, so snow is thinned by it below. - materialsAt(s, 0, mats, k * MATERIALS.length, Math.max(dz, da)) + materialsAt(s, 0, mats, k * MATERIALS.length) } } @@ -146,7 +147,8 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { const theta = (a0 + v * da) / RADIUS_M const cos = Math.cos(theta), sin = Math.sin(theta) const h = shown[k]! - const r = (RADIUS_M - h + drop) / 1000 + // A skirt hangs down past any crack, but never through the wall. + const r = (RADIUS_M - Math.max(LOWEST_GROUND_M, h - drop)) / 1000 positions[3 * index] = r * cos - cx positions[3 * index + 1] = r * sin - cy positions[3 * index + 2] = z / 1000 - cz diff --git a/src/ui/app.ts b/src/ui/app.ts index 73b71bc..e06fbe3 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -77,6 +77,14 @@ export class App { private map: TowerMap private exterior: ExteriorView | null = null private interior: InteriorView | null = null + /** + * The deferred imports in flight. Opening on a `#view=` link asks for a view twice + * before the first import has resolved — once reading the hash, once choosing the + * initial view — and without these each built its own renderer: two WebGL contexts, + * two sets of terrain tiles, and every exterior caption drawn twice. + */ + private exteriorModule: Promise<typeof import('../views/exterior.js')> | null = null + private interiorModule: Promise<typeof import('../views/interior.js')> | null = null private view: ViewName = 'map' /** * Writer mode is a UI mode, not a security boundary (D-011). It is guessable @@ -334,7 +342,8 @@ export class App { // Genuinely deferred: Three.js is by far the largest dependency, and a static // import would bundle it into the entry chunk whether or not this view is // ever opened. Vite splits it into its own chunk on this await. - const { ExteriorView } = await import('../views/exterior.js') + const { ExteriorView } = await (this.exteriorModule ??= import('../views/exterior.js')) + if (this.exterior) return // the other request built it, and is starting it this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), { onPickPoint: (p) => { this.map.probe = { from: p, to: null } @@ -356,7 +365,8 @@ export class App { /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */ private async openInterior(): Promise<void> { if (!this.interior) { - const { InteriorView } = await import('../views/interior.js') + const { InteriorView } = await (this.interiorModule ??= import('../views/interior.js')) + if (this.interior) return // the other request built it, and is starting it this.interior = new InteriorView($<HTMLCanvasElement>('interior'), { onPickPlace: (id) => { this.selectedId = id diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 5b5ea4b..acd5307 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -200,7 +200,14 @@ export class ExteriorView { * surface projected onto it, which is a diagram and not a view (D-047). */ private buildHull(): Mesh { - const g = new CylinderGeometry(R, R, L, 160, 1, true) + /** + * The skin is the wall's outer face, a kilometre out from the floor (D-044), not + * the floor itself. While the ground was a raster that never went below the datum + * by more than a lake, the difference could be ignored; generated ground has sea + * floors 330 m down and tile skirts below that, and at radius R they came through + * the hull as a lattice of lines (D-064). + */ + const g = new CylinderGeometry(R + HULL_D, R + HULL_D, L, 160, 1, true) g.rotateX(Math.PI / 2) g.translate(0, 0, L / 2) this.remapHullUVs(g) @@ -299,19 +306,37 @@ export class ExteriorView { * hazed like everything else in there, and drawing both would put two surfaces at * one depth. The sun collar has no bore behind it — the sun cap window sits at the * inner face, z = 0 — so it keeps both faces, as the lip you look past to see in. + * + * Since D-064 the wall is a kilometre thick around the whole circumference too, so + * each end also gets its end face: a ring from the window's edge out to the skin. */ private buildCollars(): void { + const metal = (side: typeof FrontSide | typeof DoubleSide) => new MeshStandardMaterial({ + color: 0x6f7787, roughness: 0.55, metalness: 0.42, side, + }) for (const z0 of [-HULL_D, L]) { - const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true) - g.rotateX(Math.PI / 2) - g.translate(0, 0, z0 + HULL_D / 2) - this.tower.add(new Mesh(g, new MeshStandardMaterial({ - color: 0x6f7787, roughness: 0.55, metalness: 0.42, - side: z0 < 0 ? DoubleSide : FrontSide, - }))) + // The outer skin carried on past each end of the world, to the caps. + const outer = new CylinderGeometry(R + HULL_D, R + HULL_D, HULL_D, 160, 1, true) + outer.rotateX(Math.PI / 2) + outer.translate(0, 0, z0 + HULL_D / 2) + this.tower.add(new Mesh(outer, metal(FrontSide))) + // The end face of the wall: the kilometre between the window's edge and the skin. + const face = new RingGeometry(R, R + HULL_D, 160) + face.translate(0, 0, z0 < 0 ? z0 : L + HULL_D) + this.tower.add(new Mesh(face, metal(DoubleSide))) } + /** + * The sun end's inner face has no bore behind it — the sun cap window sits at the + * floor's end, z = 0 — so it is drawn here, as the lip you look past to see in. The + * far end's inner face is the interior model's bore, hazed like everything in there. + */ + const lip = new CylinderGeometry(R, R, HULL_D, 160, 1, true) + lip.rotateX(Math.PI / 2) + lip.translate(0, 0, -HULL_D / 2) + this.tower.add(new Mesh(lip, metal(DoubleSide))) } + /** * The beam's way in: the hole in the middle of the sun cap's annulus, where the * converging rays arrive. The caps themselves are the interior model's windows diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index c310b96..6d3f6e2 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -23,6 +23,7 @@ import { SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, } from '../canon/constants.js' import { PALETTE_GLSL } from '../terrain/palette.js' +import { LOWEST_GROUND_M } from '../terrain/tile.js' import { terrain } from '../terrain/service.js' import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js' import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js' @@ -66,13 +67,6 @@ const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_ export const HAZE_DAY = 0x9aa3ad export const HAZE_DUSK = 0x8d8792 -/** - * The lowest the ground is expected to go, metres. Sea and lake floors are cut below - * the datum, and the haze integral's air has to reach down past them. Generated - * terrain has no hard floor, so this is a margin rather than a measurement: the - * deepest basin today is about 330 m under The Deepward Sea's 90 m surface. - */ -const LOWEST_GROUND_M = -1000 // ---------------------------------------------------------------------- shaders @@ -100,6 +94,7 @@ uniform vec3 uHaze; uniform float uExposure; const float A = ${DENSITY_A.toExponential(8)}; const float RR = ${(R * R).toFixed(4)}; +// The air reaches down past the deepest ground anything generates (tile.ts). const float RR_AIR = ${((R - LOWEST_GROUND_M / KM + 0.05) ** 2).toFixed(4)}; const float Z_AIR0 = 0.0; const float Z_AIR1 = ${(L + HULL_D).toFixed(4)}; diff --git a/src/world/schema.ts b/src/world/schema.ts index 21ca3e4..127ae00 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -23,11 +23,11 @@ export const WORLD_VERSION = 2 // ------------------------------------------------------------------------ kinds -/** Features that shape the ground. */ +/** Features that shape the ground. A region can too, when it is given an uplift. */ export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest'] as const /** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */ export const BUILT_KINDS = ['settlement', 'road', 'path'] as const -/** Labels on the world that generate nothing. */ +/** Labels on the world. They generate nothing, except a region with an uplift. */ export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const export const FEATURE_KINDS = [...TERRAIN_KINDS, ...BUILT_KINDS, ...MARKER_KINDS] as const @@ -143,13 +143,23 @@ export interface Route extends Base<'road' | 'path'> { widthM: number } -export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note' | 'region'> { +export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> { /** Height of a tower, for when towers are drawn. */ heightM?: number } +/** + * A named area. It is a label, and it can also be ground Tower raised: `upliftM` lifts + * everything inside it, with a soft edge `edgeM` wide, and paints nothing — the land + * keeps whatever cover it has. v1's world needed one under its rivers (D-064). + */ +export interface Region extends Base<'region'> { + upliftM: number + edgeM: number +} + export type Feature = - | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker + | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker | Region /** The gentle relief everywhere nothing else is (today's "open ground"). */ export interface Ground { @@ -224,6 +234,8 @@ export function withKind(common: Common, kind: FeatureKind): Feature { case 'road': case 'path': return { ...c, kind, widthM: kind === 'road' ? 8 : 2 } + case 'region': + return { ...c, kind, upliftM: 0, edgeM: 4000 } default: return { ...c, kind } } @@ -346,6 +358,7 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature { outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' } case 'desert': case 'forest': + case 'region': return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } case 'settlement': { const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const diff --git a/static/world.json b/static/world.json index 964a4bb..c377a1b 100644 --- a/static/world.json +++ b/static/world.json @@ -599,6 +599,22 @@ "upliftM": 780, "edgeM": 20000 }, + { + "id": "the-uplands", + "name": "The Uplands", + "blurb": "", + "notes": "", + "public": true, + "seed": 1990705996, + "kind": "region", + "geometry": { + "type": "band", + "zFrom": 105000, + "zTo": 150000 + }, + "upliftM": 1000, + "edgeM": 8000 + }, { "id": "first-ridge", "name": "First Ridge", diff --git a/test/terrain.test.ts b/test/terrain.test.ts index 08ca16f..e72121b 100644 --- a/test/terrain.test.ts +++ b/test/terrain.test.ts @@ -13,12 +13,13 @@ const compileMs = performance.now() - t0 const rt = prepare(cw) describe('the world compiles', () => { - it('ports every v1 feature', () => { - expect(world.features).toHaveLength(28) + it('ports every v1 feature, plus the ground raised under the rivers', () => { + expect(world.features).toHaveLength(29) expect(cw.mountains).toHaveLength(8) expect(cw.basins).toHaveLength(7) expect(cw.rivers).toHaveLength(4) - expect(cw.uplifts).toHaveLength(2) + // The Dry Reach, Greatwood, and The Uplands (D-064). + expect(cw.uplifts).toHaveLength(3) }) it('names every step it takes, in the order it takes them', () => { @@ -151,14 +152,37 @@ describe('water', () => { }) it('cuts a canyon where the river crosses the ridges', () => { - // First Ridge is at z 176 km; The Narrows is the only breach (D-020). + // The ridges are at z 176, 190 and 203 km; The Narrows is the only breach in all + // three (D-020), and design.md §1.11 puts it at up to 480 m deep. const trunk = cw.rivers.find((r) => r.id === 'the-long-water')! - const i = trunk.line.pts.findIndex((v, k) => k % 2 === 0 && v > 176_000) / 2 - const z = trunk.line.pts[2 * i]!, theta = trunk.line.pts[2 * i + 1]! / RADIUS_M - const floor = rt.sample(theta, z, 5).h - const rim = Math.max(rt.sample(theta + 2500 / RADIUS_M, z, 5).h, - rt.sample(theta - 2500 / RADIUS_M, z, 5).h) - expect(rim - floor).toBeGreaterThan(150) + let deepest = 0 + for (let i = 0; i < trunk.level.length; i++) { + const z = trunk.line.pts[2 * i]! + if (z < 170_000 || z > 210_000) continue + const theta = trunk.line.pts[2 * i + 1]! / RADIUS_M + const floor = rt.sample(theta, z, 5).h + let rim = -Infinity + for (const off of [2000, 3000, 4000, 5000]) { + rim = Math.max(rim, rt.sample(theta + off / RADIUS_M, z, 5).h, rt.sample(theta - off / RADIUS_M, z, 5).h) + } + deepest = Math.max(deepest, rim - floor) + } + expect(deepest).toBeGreaterThan(300) + expect(deepest).toBeLessThan(700) + }) + + it('builds the floor up under a river only a little, now the lowland is raised', () => { + // v1's lowland needed embankments of up to 950 m; The Uplands bring that under + // 250 m (D-064). Measured against the ground as it is before the rivers shape it. + const before = prepare({ ...cw, rivers: [] }) + for (const r of cw.rivers) { + let fill = 0 + for (let i = 0; i < r.level.length; i++) { + const s = before.sample(r.line.pts[2 * i + 1]! / RADIUS_M, r.line.pts[2 * i]!, 60, Stage.Basins) + fill = Math.max(fill, r.level[i]! - Math.max(s.h, s.water)) + } + expect(fill).toBeLessThan(250) + } }) }) diff --git a/world-generation.md b/world-generation.md index 137c1f9..2a9f5e2 100644 --- a/world-generation.md +++ b/world-generation.md @@ -1036,8 +1036,14 @@ What the plan did not know until the world was generated honestly: 900 m deep through the whole desert; the second — a graded curve — needed a 1,300 m embankment. The least-earthwork profile of §4.3 cuts The Narrows 300–500 m deep, which is what design.md §1.11 always said, and builds the lowland up by up to - 880 m under each river. That is the world as authored, generated faithfully; whether - to change the world is an open question in design.md §1.11. + 880 m under each river. That is the world as authored, generated faithfully. + **Resolved by the author (D-064):** raise the lowland. The Uplands, a region lifting + the ground to ~1,000 m, bring the worst embankment under 200 m; and the profile now + prefers cutting to filling (a 0.2-quantile isotonic fit rather than least squares), + since a cut reads as a valley and an embankment as a dam. +- **The hull was at the floor's radius.** Seen from outside, generated sea floors and + tile skirts came through it as a lattice. The skin now sits at the wall's outer face, + a kilometre out (D-044, D-064), and nothing generated may reach it. - **Tiles were measured against the tallest mountain.** A fixed 4.5 km relief allowance made every tile within 5 km of the eye split to the finest level: 11,000 tiles on screen. Each tile is now bounded by its own measured relief.