tower / files / src / terrain / field.ts
field.ts
376f2ce6 2026-09-16 Pavel Safronov | 1 | /** |
| 2 | * The elevation function. See world-generation.md §5.3. | |
| 3 | * | |
| 4 | * One pure function of a point on the surface, used by every tile, every pixel of the | |
| 5 | * map, the observer's feet and the tests — so there is exactly one answer to "how high | |
| 6 | * is the ground here", and the view, the map and the camera cannot disagree about it | |
| 7 | * the way the GPU and the CPU did under the rasters (D-035). | |
| 8 | * | |
| 9 | * The stack, in order: | |
| 10 | * | |
| 11 | * ground gentle relief everywhere | |
| 12 | * + uplift desert and forest shelves | |
e7a9f9dd 2026-09-17 Pavel Safronov | 13 | * + mountains eroded ranges, on top of the land |
| 14 | * ∨ raised regions low ground lifted to a height | |
376f2ce6 2026-09-16 Pavel Safronov | 15 | * − basins lakes and seas, carved, with their water level |
| 16 | * ∧ rivers valleys cut down to each river's long profile | |
| 17 | * | |
| 18 | * Every noise call takes `detailM`, the shortest wavelength worth computing. A tile | |
| 19 | * 30 km across passes a few hundred metres; a tile under the observer's feet passes | |
| 20 | * a metre. Same function, same answer, fewer octaves where they could not be seen. | |
| 21 | */ | |
51c39d8b 2026-09-17 Pavel Safronov | 22 | import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' |
abc0aad0 2026-10-01 Pavel Safronov | 23 | import { type CBasin, type CFarm, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js' |
51c39d8b 2026-09-17 Pavel Safronov | 24 | import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './desert.js' |
00ceeedb 2026-09-27 Pavel Safronov | 25 | import { type CForest, canopyAt, stemsAt } from './forest.js' |
e7a9f9dd 2026-09-17 Pavel Safronov | 26 | import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js' |
376f2ce6 2026-09-16 Pavel Safronov | 27 | import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js' |
e18dad04 2026-09-30 Pavel Safronov | 28 | import { iceCover, iceMantle, iceMargin, iceSide, rangeRock } from './ice.js' |
abc0aad0 2026-10-01 Pavel Safronov | 29 | import { fieldAt, growNode } from './farms.js' |
376f2ce6 2026-09-16 Pavel Safronov | 30 | import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js' |
| 31 | ||
| 32 | export const Stage = { | |
| 33 | /** Only the mountain domes, on nothing: used to measure a summit. */ | |
| 34 | MountainsOnly: 0, | |
| 35 | /** Everything up to and including the basins, without rivers. */ | |
| 36 | Basins: 3, | |
| 37 | All: 4, | |
| 38 | } as const | |
| 39 | export type Stage = (typeof Stage)[keyof typeof Stage] | |
| 40 | ||
| 41 | /** What the world is at one point. */ | |
| 42 | export interface Sample { | |
| 43 | /** Ground elevation above the datum, metres. Lake and sea floors are below zero. */ | |
| 44 | h: number | |
| 45 | /** Water surface, or -Infinity where there is none. */ | |
| 46 | water: number | |
| 47 | /** | |
14d35b7d 2026-09-16 Pavel Safronov | 48 | * How much of the ground this sample stands for is water, 0..1, at this level of |
| 49 | * detail. Lakes and seas cover all of it. A river 80 m wide covers a fraction of a | |
| 50 | * 500 m cell — and covers it whether or not the sample point itself is in the | |
| 51 | * channel, so a coarse tile draws a river as a faint continuous line rather than as | |
| 52 | * dots where its vertices happened to land in the water. | |
376f2ce6 2026-09-16 Pavel Safronov | 53 | */ |
14d35b7d 2026-09-16 Pavel Safronov | 54 | waterCover: number |
80bcc97a 2026-09-17 Pavel Safronov | 55 | /** |
| 56 | * True where the water here is wide enough for a tile to draw it as a surface of its | |
| 57 | * own. A river narrower than the tile's vertex spacing is not: it shows only as | |
| 58 | * `waterCover`, a tint on the ground. | |
| 59 | */ | |
| 60 | waterMesh: boolean | |
a088e5d5 2026-09-17 Pavel Safronov | 61 | /** The lake or sea this water belongs to, as an index into the compiled basins, or -1. */ |
| 62 | body: number | |
80bcc97a 2026-09-17 Pavel Safronov | 63 | /** River flow here, m/s along z and around, and how white it is. */ |
| 64 | flowZ: number | |
| 65 | flowA: number | |
| 66 | foam: number | |
e18dad04 2026-09-30 Pavel Safronov | 67 | /** How much of a sea's surface here is ice, 0..1: pack ice below 1, fast ice at 1 (D-090). */ |
| 68 | ice: number | |
| 69 | /** Grounded ice standing out of the sea here, 0..1: the ice wall at the cap (D-093). */ | |
| 70 | glacier: number | |
abc0aad0 2026-10-01 Pavel Safronov | 71 | /** Farmland here, 0..1 (farms.ts, D-095): which crop is the shader's to work out. */ |
| 72 | farm: number | |
376f2ce6 2026-09-16 Pavel Safronov | 73 | /** 0..1 claims on the surface. The shader turns these into colour. */ |
| 74 | desert: number | |
| 75 | forest: number | |
| 76 | /** Relief the mountains added here, metres: what decides bare rock. */ | |
| 77 | relief: number | |
51c39d8b 2026-09-17 Pavel Safronov | 78 | /** Bare rock the desert claims here, 0..1: plateau tops, cliff bands, gravel floors. */ |
| 79 | rock: number | |
| 80 | /** Salt crust, 0..1: a pan's floor. */ | |
| 81 | salt: number | |
00ceeedb 2026-09-27 Pavel Safronov | 82 | /** Trees standing here, stems per hectare, and how high their canopy reaches, metres. */ |
| 83 | stems: number | |
| 84 | canopyM: number | |
376f2ce6 2026-09-16 Pavel Safronov | 85 | snowlineM: number |
| 86 | } | |
| 87 | ||
| 88 | const smoothstep = (e0: number, e1: number, x: number): number => { | |
| 89 | const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))) | |
| 90 | return t * t * (3 - 2 * t) | |
| 91 | } | |
| 92 | ||
| 93 | /** Polynomial smooth minimum: min(a, b), with the corner rounded over `k`. */ | |
| 94 | const smin = (a: number, b: number, k: number): number => { | |
| 95 | const h = Math.max(k - Math.abs(a - b), 0) / k | |
| 96 | return Math.min(a, b) - h * h * k * 0.25 | |
| 97 | } | |
| 98 | ||
| 99 | function inBox(b: Box, z: number, a: number): boolean { | |
| 100 | if (z < b.z0 || z > b.z1) return false | |
| 101 | if (!Number.isFinite(b.a0)) return true | |
| 102 | const q = nearestA(a, (b.a0 + b.a1) / 2) | |
| 103 | return q >= b.a0 && q <= b.a1 | |
| 104 | } | |
| 105 | ||
| 106 | /** Seeds for each layer of a feature, so no two layers share a field. */ | |
| 107 | const layer = (seed: number, n: number): number => hash3(seed, n, 0x1a7e) & 0x7fffffff | |
| 108 | ||
| 109 | /** Offset a point by low-frequency noise, so no outline reads as the shape it was drawn as. */ | |
| 110 | function warp(seed: number, p: CylPoint, z: number, a: number, ampM: number, scaleM: number, detailM: number): [number, number] { | |
| 111 | if (!ampM) return [z, a] | |
| 112 | const dz = fbm(layer(seed, 91), p, scaleM, 3, detailM) | |
| 113 | const da = fbm(layer(seed, 92), p, scaleM, 3, detailM) | |
| 114 | return [z + dz * ampM, a + da * ampM] | |
| 115 | } | |
| 116 | ||
| 117 | // --------------------------------------------------------------------- runtime | |
| 118 | ||
| 119 | /** | |
| 120 | * A compiled world made ready to sample in this thread: the per-thread structures | |
| 121 | * (segment indexes) that are not plain data and so never travel with it. | |
| 122 | */ | |
| 123 | export class Runtime { | |
| 124 | private indexes: LineIndex[] | |
80bcc97a 2026-09-17 Pavel Safronov | 125 | private out: Sample = { |
| 126 | h: 0, water: -Infinity, waterCover: 0, waterMesh: false, | |
abc0aad0 2026-10-01 Pavel Safronov | 127 | body: -1, flowZ: 0, flowA: 0, foam: 0, ice: 0, glacier: 0, farm: 0, |
00ceeedb 2026-09-27 Pavel Safronov | 128 | desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, stems: 0, canopyM: 0, snowlineM: 3000, |
80bcc97a 2026-09-17 Pavel Safronov | 129 | } |
376f2ce6 2026-09-16 Pavel Safronov | 130 | private nearest: Nearest = { d: 0, seg: 0, t: 0 } |
| 131 | ||
| 132 | constructor(readonly world: CompiledWorld) { | |
| 133 | this.indexes = world.rivers.map((r) => new LineIndex(r.line, r.reach)) | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * Everything the world is at (theta, z). Returns a shared object: read it before | |
| 138 | * sampling again. | |
| 139 | */ | |
| 140 | sample(theta: number, z: number, detailM: number, upTo: Stage = Stage.All): Sample { | |
| 141 | const w = this.world | |
| 142 | const o = this.out | |
| 143 | const a = theta * RADIUS_M | |
| 144 | const p = cylPoint(theta, z) | |
| 145 | o.water = -Infinity | |
14d35b7d 2026-09-16 Pavel Safronov | 146 | o.waterCover = 0 |
80bcc97a 2026-09-17 Pavel Safronov | 147 | o.waterMesh = false |
a088e5d5 2026-09-17 Pavel Safronov | 148 | o.body = -1 |
80bcc97a 2026-09-17 Pavel Safronov | 149 | o.flowZ = 0 |
| 150 | o.flowA = 0 | |
| 151 | o.foam = 0 | |
e18dad04 2026-09-30 Pavel Safronov | 152 | o.ice = 0 |
| 153 | o.glacier = 0 | |
abc0aad0 2026-10-01 Pavel Safronov | 154 | o.farm = 0 |
376f2ce6 2026-09-16 Pavel Safronov | 155 | o.desert = 0 |
| 156 | o.forest = 0 | |
| 157 | o.relief = 0 | |
51c39d8b 2026-09-17 Pavel Safronov | 158 | o.rock = 0 |
| 159 | o.salt = 0 | |
00ceeedb 2026-09-27 Pavel Safronov | 160 | o.stems = 0 |
| 161 | o.canopyM = 0 | |
376f2ce6 2026-09-16 Pavel Safronov | 162 | o.snowlineM = 3000 |
| 163 | ||
| 164 | if (upTo === Stage.MountainsOnly) { | |
| 165 | o.h = 0 | |
| 166 | for (const m of w.mountains) o.h = Math.max(o.h, mountain(m, p, z, a, detailM)) | |
| 167 | return o | |
| 168 | } | |
| 169 | ||
14d35b7d 2026-09-16 Pavel Safronov | 170 | const g0 = ground(w, p, detailM) |
| 171 | o.h = g0 | |
| 172 | for (const u of w.uplifts) { | |
| 173 | if (u.kind !== 'region' && inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o) | |
| 174 | } | |
51c39d8b 2026-09-17 Pavel Safronov | 175 | // The deserts' own landforms, on the shelf the uplift above just raised. |
| 176 | for (const d of w.deserts ?? []) { | |
| 177 | if (inBox(d.box, z, a)) desert(d, p, z, a, detailM, o) | |
| 178 | } | |
376f2ce6 2026-09-16 Pavel Safronov | 179 | let relief = 0 |
| 180 | for (const m of w.mountains) { | |
| 181 | if (!inBox(m.box, z, a)) continue | |
| 182 | const r = mountain(m, p, z, a, detailM) | |
| 183 | if (r > relief) { relief = r; o.snowlineM = m.snowlineM } | |
| 184 | } | |
| 185 | o.h += relief | |
| 186 | o.relief = relief | |
e7a9f9dd 2026-09-17 Pavel Safronov | 187 | // Raised regions after the mountains: they lift low ground *to* a height, not by one, |
| 188 | // so they fill a lowland and leave a range's foot standing where it already is (D-064). | |
| 189 | for (const u of w.uplifts) { | |
| 190 | if (u.kind === 'region' && inBox(u.box, z, a)) raise(u, p, z, a, detailM, g0, o) | |
| 191 | } | |
376f2ce6 2026-09-16 Pavel Safronov | 192 | for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o) |
| 193 | if (upTo === Stage.Basins) return o | |
| 194 | w.rivers.forEach((r, i) => { | |
80bcc97a 2026-09-17 Pavel Safronov | 195 | if (inBox(r.box, z, a)) river(r, this.indexes[i]!, p, z, a, detailM, o, this.nearest) |
376f2ce6 2026-09-16 Pavel Safronov | 196 | }) |
00ceeedb 2026-09-27 Pavel Safronov | 197 | // Trees last: what stops them — water, rock, sand, the treeline — is everything above. |
| 198 | for (const f of w.forests ?? []) { | |
| 199 | if (!inBox(f.box, z, a)) continue | |
| 200 | forest(f, p, z, a, detailM, o) | |
| 201 | } | |
abc0aad0 2026-10-01 Pavel Safronov | 202 | // Fields after the trees: a farm is cleared ground, and it takes its ground from the wood. |
| 203 | for (const f of w.farms ?? []) if (inBox(f.box, z, a)) farm(f, p, z, a, detailM, o) | |
376f2ce6 2026-09-16 Pavel Safronov | 204 | return o |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | const runtimes = new WeakMap<CompiledWorld, Runtime>() | |
| 209 | ||
| 210 | export function prepare(world: CompiledWorld): Runtime { | |
| 211 | let rt = runtimes.get(world) | |
| 212 | if (!rt) { | |
| 213 | rt = new Runtime(world) | |
| 214 | runtimes.set(world, rt) | |
| 215 | } | |
| 216 | return rt | |
| 217 | } | |
| 218 | ||
| 219 | /** Just the elevation, for callers that want nothing else. */ | |
| 220 | export function elevationStage(rt: Runtime, theta: number, z: number, detailM: number, upTo: Stage = Stage.All): number { | |
| 221 | return rt.sample(theta, z, detailM, upTo).h | |
| 222 | } | |
| 223 | ||
| 224 | // ---------------------------------------------------------------------- layers | |
| 225 | ||
| 226 | /** | |
| 227 | * The open ground. Five octaves of scale, from 78 km swells down to a few metres of | |
| 228 | * unevenness underfoot. The broad layers are v1's; the last is new, and exists only | |
| 229 | * because the ground can now be looked at from a metre away. | |
| 230 | */ | |
| 231 | function ground(w: CompiledWorld, p: CylPoint, detailM: number): number { | |
| 232 | const s = w.seed | |
| 233 | const k = w.ground.reliefM / 260 | |
| 234 | return w.ground.baseM | |
| 235 | + fbm(layer(s, 1), p, 78_000, 5, detailM) * w.ground.reliefM | |
| 236 | + fbm(layer(s, 2), p, 12_000, 4, detailM) * 45 * k | |
| 237 | + (ridged(layer(s, 3), p, 3400, 3, detailM) - 0.5) * 26 * k | |
| 238 | + fbm(layer(s, 4), p, 1500, 4, detailM) * 26 * k | |
| 239 | + fbm(layer(s, 5), p, 90, 6, detailM) * 3 * k | |
| 240 | } | |
| 241 | ||
| 242 | /** A raised shelf with a soft, wandering edge: the desert plateau, the forest floor. */ | |
| 243 | function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { | |
| 244 | const [wz, wa] = warp(u.seed, p, z, a, u.warpM, u.edgeM * 2, detailM) | |
| 245 | const m = smoothstep(-u.edgeM, 0, signedDistance(u.shape, wz, wa)) | |
| 246 | if (m <= 0) return | |
| 247 | const n = fbm(layer(u.seed, 1), p, u.noiseScaleM, 4, detailM) | |
| 248 | o.h += m * (u.upliftM + n * u.noiseAmpM) | |
| 249 | // The claim is dithered by the same noise, so neighbouring regions meet on a ragged | |
| 250 | // line rather than a ruled one. | |
| 251 | const claim = smoothstep(0.25, 0.4, m + n * 0.45) | |
| 252 | if (u.kind === 'desert') o.desert = Math.max(o.desert, claim) | |
14d35b7d 2026-09-16 Pavel Safronov | 253 | else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim) |
| 254 | } | |
| 255 | ||
abc0aad0 2026-10-01 Pavel Safronov | 256 | /** A grown farm's patches, as a set, made once per compiled farm. */ |
| 257 | const grown = new WeakMap<CFarm, Set<number>>() | |
| 258 | function grownNodes(f: CFarm): Set<number> { | |
| 259 | let s = grown.get(f) | |
| 260 | if (!s) grown.set(f, (s = new Set(f.nodes))) | |
| 261 | return s | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Farmland (farms.ts, D-095): cleared and sown inside its outline, which wanders by noise | |
| 266 | * so the farm's edge is not a drawn line. Not on water, sand or rock, and not up a range: | |
| 267 | * the fields stop where the ground is too broken to plough. | |
| 268 | */ | |
| 269 | function farm(f: CFarm, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { | |
| 270 | if (o.water > o.h) return | |
| 271 | let m: number | |
| 272 | if (f.shape) { | |
| 273 | const [wz, wa] = warp(f.seed, p, z, a, f.edgeM * 0.8, f.edgeM * 4, detailM) | |
| 274 | m = smoothstep(-f.edgeM, 0, signedDistance(f.shape, wz, wa)) | |
| 275 | } else { | |
| 276 | // A grown farm (D-097) holds a field whole or not at all: whichever patch of ground the | |
| 277 | // field was laid out from decides, so the farm's edge runs along its fields' edges. | |
| 278 | const fld = fieldAt(z, a) | |
| 279 | m = grownNodes(f).has(growNode(fld.centreZ, fld.centreA)) ? 1 : 0 | |
| 280 | } | |
| 281 | if (m <= 0) return | |
| 282 | const claim = m * (1 - o.desert) * (1 - o.rock) * (1 - smoothstep(150, 400, o.relief)) | |
| 283 | if (claim <= 0) return | |
| 284 | o.farm = claim | |
| 285 | o.forest *= 1 - claim | |
| 286 | o.stems *= 1 - claim | |
| 287 | o.canopyM *= 1 - claim | |
| 288 | } | |
| 289 | ||
00ceeedb 2026-09-27 Pavel Safronov | 290 | /** |
| 291 | * The trees standing on this ground (forest.ts). A tile draws the canopy from a distance | |
| 292 | * and the trees themselves near the eye (views/forest-view.ts); both read this. | |
| 293 | * | |
| 294 | * Where two forests overlap, the taller canopy is the one you would see — and a smaller | |
| 295 | * forest has already taken its own ground out of the larger one (forest.ts `holes`), so | |
| 296 | * the tall wood stands instead of Greatwood rather than through it. | |
| 297 | */ | |
| 298 | function forest(f: CForest, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { | |
| 299 | const stems = stemsAt(f, p, z, a, detailM, { | |
| 300 | h: o.h, water: o.water > o.h, desert: o.desert, rock: o.rock, | |
| 301 | }) | |
| 302 | if (stems <= 0) return | |
| 303 | const canopy = canopyAt(f, stems) | |
| 304 | if (canopy < o.canopyM) return | |
| 305 | o.stems = stems | |
| 306 | o.canopyM = canopy | |
| 307 | } | |
| 308 | ||
51c39d8b 2026-09-17 Pavel Safronov | 309 | /** |
| 310 | * A desert's landforms on top of its shelf (desert.ts, world-generation.md §4.5). | |
| 311 | * | |
| 312 | * The plateau's tiers are cut here rather than compiled, so a cliff band is as sharp as | |
| 313 | * the tile is fine. Hills and dunes are made here too: a dune needs to know nothing about | |
| 314 | * the ground next door, only which way Tower blows the wind. | |
| 315 | */ | |
| 316 | function desert(d: CDesert, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { | |
| 317 | /** | |
| 318 | * The compiled fields are read through a small warp. Sampled straight, a cliff band | |
| 319 | * follows the 250 m grid and its rim reads as a row of facets; warped, the rim wanders | |
| 320 | * and the grid disappears. | |
| 321 | */ | |
| 322 | const [wz, wa] = warp(d.seed ^ 0x51, p, z, a, 140, 1100, detailM) | |
| 323 | const g = sampleDesert(d, wz, wa) | |
| 324 | if (g.inside <= 0.001) return | |
| 325 | // The broad crown the washes run off (desert.ts). | |
| 326 | let h = g.base | |
| 327 | ||
| 328 | // Rock, in tiers: flat tops with steep faces, roughened across the faces. | |
| 329 | if (g.plateau > 0.001) { | |
| 330 | h += terrace(g.plateau, d.terraces) * d.plateauHeightM | |
| 331 | h += (fbm(layer(d.seed, 2), p, 240, 4, detailM) - 0.5) * 12 * smoothstep(0.02, 0.15, g.plateau) | |
| 332 | o.rock = Math.max(o.rock, smoothstep(0.02, 0.16, g.plateau)) | |
| 333 | } | |
| 334 | ||
| 335 | // Rocky hills on the open ground: low ridged domes, where the coverage noise says so. | |
| 336 | // The same function the washes were routed over (desert.ts), so the two agree. | |
| 337 | const hills = hillsAt( | |
| 338 | { hills: { coverage: d.hillsCoverage, heightM: d.hillsHeightM } }, | |
| 339 | d.seed, p, detailM, g.plateau, g.inside * (1 - g.salt)) | |
| 340 | if (hills > 0) { | |
| 341 | h += hills | |
| 342 | o.rock = Math.max(o.rock, smoothstep(0.25, 0.8, hills / Math.max(1, d.hillsHeightM)) * 0.6) | |
| 343 | } | |
| 344 | ||
| 345 | // Dunes: crests across the wind, gentle to windward and steep on the slip face. Where | |
| 346 | // the dune fields are was settled in the compile (desert.ts `sand`). | |
| 347 | if (d.duneHeightM > 0 && g.sand > 0.02) { | |
| 348 | h += dunes(d, p, z, a, detailM) * d.duneHeightM * g.sand | |
| 349 | } | |
| 350 | ||
| 351 | // The washes, cut through all of it, with a gravel floor. | |
| 352 | if (g.cut > 0.2) { | |
| 353 | h -= g.cut | |
| 354 | o.rock = Math.max(o.rock, smoothstep(3, 18, g.cut) * 0.4) | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * A pan is dead flat, and the ground around it steps down to it in the benches a lake | |
| 359 | * would have left. Both are levels in the *world*, not in the sand — the shelf under the | |
| 360 | * desert has a hundred metres of its own roll, and a floor that followed it would not be | |
| 361 | * a floor — so this works on the total elevation and then puts it back. | |
| 362 | */ | |
| 363 | let total = o.h + h | |
| 364 | if (g.bench > 0.02) { | |
| 365 | const above = total - g.benchLevelM | |
| 366 | if (above > 0) { | |
| 367 | const stepped = Math.round(above / BENCH_STEP_M) * BENCH_STEP_M | |
| 368 | total += (stepped - above) * Math.min(1, g.bench * 1.4) | |
| 369 | } | |
| 370 | } | |
| 371 | if (g.salt > 0.02) { | |
| 372 | const flat = Math.min(1, g.salt) | |
| 373 | total += (g.panLevelM - total) * flat | |
| 374 | o.salt = Math.max(o.salt, flat) | |
| 375 | o.rock *= 1 - flat | |
| 376 | } | |
| 377 | ||
| 378 | o.h = total | |
| 379 | } | |
| 380 | ||
| 381 | /** | |
| 382 | * One dune field's profile, 0..1 of the dune height. Crests run across the wind; each | |
| 383 | * dune climbs gently to windward and drops at the angle dry sand stands at, and the crest | |
| 384 | * line wanders along itself rather than ruling straight across the desert. | |
| 385 | */ | |
| 386 | function dunes(d: CDesert, p: CylPoint, z: number, a: number, detailM: number): number { | |
| 387 | /** | |
| 388 | * Across the wind, in dune wavelengths. The circumferential part is counted in *whole* | |
| 389 | * dunes around the world — a pattern that does not close on itself leaves a seam where | |
| 390 | * theta wraps, and a dune field running round a ring has to close. | |
| 391 | */ | |
| 392 | const across = (z * Math.cos(d.duneDirRad)) / d.duneSpacingM + d.dunePeriods * (a / CIRCUMFERENCE_M) | |
| 393 | // The crest line wanders along itself; the noise is on the cylinder, so it closes too. | |
| 394 | const wander = fbm(layer(d.seed, 6), p, d.duneSpacingM * 7, 3, detailM) - 0.5 | |
| 395 | const x = across + wander * 1.7 | |
| 396 | const f = x - Math.floor(x) | |
| 397 | // Three-quarters of the wavelength climbing, a quarter falling at about 33°: the angle | |
| 398 | // of repose of dry sand, which is what makes a slip face. | |
| 399 | const profile = f < 0.76 ? (f / 0.76) ** 1.35 : 1 - (f - 0.76) / 0.24 | |
| 400 | // Ripples on the sand itself, only ever seen from a few metres up. | |
| 401 | const ripple = (fbm(layer(d.seed, 7), p, 11, 2, detailM) - 0.5) * 0.05 | |
| 402 | return Math.max(0, profile + ripple) | |
| 403 | } | |
| 404 | ||
14d35b7d 2026-09-16 Pavel Safronov | 405 | /** |
| 406 | * Ground Tower raised: the open ground lifted by the region's uplift, wherever that is | |
| 407 | * higher than what is already here. Taking the higher rather than adding is what lets | |
| 408 | * a raised region run up to a desert plateau without the two stacking into a wall at | |
| 409 | * the join — the plateau stands where it is higher, the region where the land was low. | |
| 410 | */ | |
| 411 | function raise(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, g0: number, o: Sample): void { | |
| 412 | const [wz, wa] = warp(u.seed, p, z, a, u.warpM, u.edgeM * 2, detailM) | |
| 413 | const m = smoothstep(-u.edgeM, 0, signedDistance(u.shape, wz, wa)) | |
| 414 | if (m <= 0) return | |
| 415 | const n = fbm(layer(u.seed, 1), p, u.noiseScaleM, 4, detailM) | |
| 416 | o.h = Math.max(o.h, g0 + m * (u.upliftM + n * u.noiseAmpM)) | |
376f2ce6 2026-09-16 Pavel Safronov | 417 | } |
| 418 | ||
e7a9f9dd 2026-09-17 Pavel Safronov | 419 | /** |
| 420 | * How high a range stands here, above the land under it: the eroded relief grid | |
| 421 | * (mountains.ts), and the detail no 200 m grid can hold laid over it. | |
| 422 | * | |
| 423 | * The detail scales with the relief, so a valley floor stays smooth and a crest breaks | |
| 424 | * into rock. It is kept off the authored summits, which were pinned to their exact | |
| 425 | * height and would otherwise gain or lose a few tens of metres to the noise. | |
| 426 | */ | |
| 427 | function mountain(m: CMountainGroup, p: CylPoint, z: number, a: number, detailM: number): number { | |
| 428 | const r = sampleGrid(m, m.relief, z, a) | |
| 429 | if (r <= 0) return 0 | |
| 430 | let clear = 1 | |
| 431 | for (const s of m.summits) { | |
| 432 | const d = Math.hypot(z - s.z, nearestA(a, s.a) - s.a) | |
| 433 | clear = Math.min(clear, 1 - pinKernel(d)) | |
376f2ce6 2026-09-16 Pavel Safronov | 434 | } |
e7a9f9dd 2026-09-17 Pavel Safronov | 435 | if (clear <= 0) return r |
| 436 | const rock = (ridged(layer(m.seed, 3), p, 1600, 4, detailM) - 0.47) * 2 | |
| 437 | const grain = fbm(layer(m.seed, 4), p, 180, 4, detailM) | |
| 438 | // Kept small: at a few percent of the relief the grid's 200 m facets break up; much more | |
| 439 | // and every slope turns to lumps under an overhead light. | |
| 440 | const detail = r * ((0.008 + 0.014 * m.ruggedness) * rock + 0.0025 * grain) | |
| 441 | return Math.max(0, r + detail * clear) | |
376f2ce6 2026-09-16 Pavel Safronov | 442 | } |
| 443 | ||
80bcc97a 2026-09-17 Pavel Safronov | 444 | /** A lake or sea's water, where it is the highest water here. */ |
| 445 | function setBasinWater(b: CBasin, o: Sample): void { | |
| 446 | if (b.levelM < o.water) return | |
| 447 | o.water = b.levelM | |
| 448 | o.waterMesh = true | |
| 449 | o.waterCover = 1 | |
a088e5d5 2026-09-17 Pavel Safronov | 450 | o.body = b.index |
80bcc97a 2026-09-17 Pavel Safronov | 451 | o.flowZ = 0 |
| 452 | o.flowA = 0 | |
| 453 | o.foam = 0 | |
e18dad04 2026-09-30 Pavel Safronov | 454 | o.ice = 0 |
| 455 | } | |
| 456 | ||
| 457 | /** | |
| 458 | * The ice mountains at the foot of the cap (ice.ts, D-093): the range Tower raised, and the | |
| 459 | * ice buried in it. The rock is ridged — a range, not a ramp — and ridged harder the higher it | |
| 460 | * stands, so round the world the heights vary by a kilometre and more; the ice lies smoother, | |
| 461 | * and the sharpest ridges stand out of it as bare rock. | |
| 462 | */ | |
| 463 | function glacier(b: CBasin, p: CylPoint, z: number, detailM: number, o: Sample): void { | |
| 464 | const range = b.ice!.range | |
| 465 | if (!range || b.shape.type !== 'band') return | |
| 466 | const side = iceSide(b.ice!) | |
| 467 | const cap = side > 0 ? b.shape.zTo : b.shape.zFrom | |
| 468 | const span = Math.abs(cap - range.fromM) | |
| 469 | const t = side > 0 ? (z - range.fromM) / span : (range.fromM - z) / span | |
| 470 | if (t <= 0) return | |
| 471 | const bed = o.h | |
| 472 | // The range stands higher in some places round the ring than others: massifs and saddles. | |
| 473 | const height = range.heightM * (1 + 0.3 * fbm(layer(b.seed, 97), p, 30_000, 2, detailM)) | |
| 474 | const rock = rangeRock(t, bed, height) | |
| 475 | const up = Math.max(0, rock - b.levelM) | |
| 476 | const ridge = (ridged(layer(b.seed, 96), p, 4000, 4, detailM) - 0.45) * 1.4 * up | |
| 477 | const rockTop = rock + ridge | |
| 478 | /** | |
| 479 | * The ice, from where the rock leaves the water. It drapes the ridges rather than burying | |
| 480 | * them level — ice on a range follows the rock, thinner over the crests — so the range | |
| 481 | * still reads as mountains through it, and the sharpest crests stand out bare. | |
| 482 | */ | |
| 483 | const margin = iceMargin(b.levelM, bed, height) | |
| 484 | const ice = t > margin ? rock + 0.6 * ridge + iceMantle((t - margin) * span) : -Infinity | |
| 485 | o.h = Math.max(rockTop, ice, o.h) | |
| 486 | // White where the ice is over the rock, bare where a ridge stands out of it. | |
| 487 | o.glacier = ice >= rockTop ? 1 : ice > rockTop - 60 ? (ice - rockTop + 60) / 60 : 0 | |
| 488 | o.relief = Math.max(o.relief, up * (1 - o.glacier)) | |
| 489 | } | |
| 490 | ||
| 491 | /** | |
| 492 | * A sea's ice here (ice.ts). The bands are rings, but no ice edge runs ruler-straight round | |
| 493 | * a world: the pack's edge wanders a kilometre and a half either way, where the wind and the | |
| 494 | * water have left it, and the fast ice's, cracked off along one line, a few hundred metres. | |
| 495 | */ | |
| 496 | function seaIce(b: CBasin, p: CylPoint, z: number, detailM: number, o: Sample): void { | |
| 497 | if (o.body !== b.index) return | |
| 498 | const ice = b.ice! | |
| 499 | const side = iceSide(ice) | |
| 500 | const edge = ice.edgeM + side * fbm(layer(b.seed, 93), p, 9000, 3, detailM) * 1500 | |
| 501 | const fast = ice.fastM + side * fbm(layer(b.seed, 94), p, 5000, 2, detailM) * 400 | |
| 502 | o.ice = iceCover((z - edge) / (fast - edge)) | |
80bcc97a 2026-09-17 Pavel Safronov | 503 | } |
| 504 | ||
376f2ce6 2026-09-16 Pavel Safronov | 505 | /** Carve a lake or sea basin and set its water where the floor is below its level. */ |
| 506 | function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { | |
| 507 | if (b.basin === 'flooded') { | |
| 508 | const [wz, wa] = warp(b.seed, p, z, a, Math.min(7000, b.shoreM * 0.5), 32_000, detailM) | |
| 509 | /** | |
| 510 | * A sea's shelf starts outside its band and is at full depth just inside it, so the | |
| 511 | * water reaches the band's edge — the cap seas meet the caps. A lake's shelf starts | |
| 512 | * at its outline and deepens inward, so the water sits inside the outline drawn for | |
| 513 | * it, as v1's did. | |
| 514 | */ | |
| 515 | const sd = signedDistance(b.shape, wz, wa) | |
| 516 | const m = b.kind === 'sea' | |
| 517 | ? smoothstep(-b.shoreM, 0.2 * b.shoreM, sd) | |
| 518 | : smoothstep(0, b.shoreM, sd) | |
| 519 | if (m <= 0.001) return | |
| 520 | o.h -= b.depthM * m ** 0.7 | |
e18dad04 2026-09-30 Pavel Safronov | 521 | if (b.ice) glacier(b, p, z, detailM, o) |
| 522 | if (m > 0.05 && o.h < b.levelM) { | |
| 523 | setBasinWater(b, o) | |
| 524 | if (b.ice) seaIce(b, p, z, detailM, o) | |
| 525 | } | |
376f2ce6 2026-09-16 Pavel Safronov | 526 | return |
| 527 | } | |
| 528 | const sd = signedDistance(b.shape, z, a) | |
| 529 | if (sd <= 0) return | |
| 530 | const d = 1 - Math.min(1, sd / b.maxInsideM) | |
| 531 | o.h -= b.depthM * Math.cos((d * Math.PI) / 2) ** 1.2 | |
| 532 | if (d > 0.68) o.h += b.rimM * Math.sin(((d - 0.68) / 0.32) * Math.PI) | |
80bcc97a 2026-09-17 Pavel Safronov | 533 | if (d < 0.85 && o.h < b.levelM) setBasinWater(b, o) |
376f2ce6 2026-09-16 Pavel Safronov | 534 | } |
| 535 | ||
| 536 | /** | |
| 537 | * Shape the floor to carry a river: cut a valley down to its surface where the ground | |
| 538 | * is too high, and build the ground up under it where the ground is too low. | |
| 539 | * | |
| 540 | * The valley's width is not a parameter: its walls rise at a fixed shape from the | |
| 541 | * channel until they meet the ground, so the same river is a shallow trench on a plain | |
| 542 | * and a canyon where it crosses high ground. An embankment is the same idea the other | |
| 543 | * way up — flanks falling away at a fixed slope until they meet the ground. | |
| 544 | */ | |
80bcc97a 2026-09-17 Pavel Safronov | 545 | function river(r: CRiver, index: LineIndex, p: CylPoint, z: number, a: number, detailM: number, o: Sample, near: Nearest): void { |
376f2ce6 2026-09-16 Pavel Safronov | 546 | const segs = index.near(z, a) |
| 547 | if (!segs.length) return | |
| 548 | nearestOnSegments(r.line, z, a, segs, near) | |
| 549 | if (near.d > r.reach) return | |
| 550 | const i = near.seg, t = near.t | |
| 551 | const lerp = (arr: number[]): number => arr[i]! + (arr[i + 1]! - arr[i]!) * t | |
80bcc97a 2026-09-17 Pavel Safronov | 552 | /** |
| 553 | * At a waterfall the surface does not slope across the segment: it holds its level to | |
| 554 | * the middle and steps. A few metres of easing keep the cliff from being a knife-edge | |
| 555 | * that no tile could sample honestly. | |
| 556 | */ | |
| 557 | const fall = r.fallAt.includes(i) | |
| 558 | const level = fall | |
| 559 | ? r.level[i]! + (r.level[i + 1]! - r.level[i]!) * smoothstep(0.5, 0.5 + 6 / Math.max(1, segLength(r, i)), t) | |
| 560 | : lerp(r.level) | |
376f2ce6 2026-09-16 Pavel Safronov | 561 | const hw = lerp(r.halfWidth) |
| 562 | const bed = lerp(r.bed) | |
| 563 | const x = near.d - hw | |
| 564 | const floor = level - bed * (1 - Math.min(1, near.d / hw) ** 2) - 0.2 | |
80bcc97a 2026-09-17 Pavel Safronov | 565 | /** |
| 566 | * Beyond the channel the ground rises along the valley's own wall shape, measured from | |
| 567 | * the wall level — which ramps smoothly through a waterfall — and never higher than a | |
| 568 | * steep gorge wall rising from the water. Away from falls the two levels are the same | |
| 569 | * and the valley shape is the lower of the two everywhere; below a fall the gorge is, | |
| 570 | * close in, so the river runs in a slot and the valley walls stand back unbroken. | |
| 571 | */ | |
| 572 | const wall = lerp(r.wallLevel) | |
| 573 | let gorge = Infinity | |
| 574 | if (wall - level > 1) { | |
| 575 | // Only where there is a gorge, since it costs a noise call: its walls wander, so the | |
| 576 | // slot below a fall reads as rock that broke where it broke rather than as a cutting. | |
| 577 | const jitter = 1 + 0.35 * fbm(GORGE_SEED, p, 420, 3, detailM) | |
| 578 | // The same curvature as the valley's walls, so once the gorge wall has risen past | |
| 579 | // the valley's shape it stays above it. With only the steep straight term, the | |
| 580 | // valley's x² outgrew it a few kilometres out and a second cliff was cut there. | |
| 581 | gorge = level + GORGE_WALL * x * jitter + (x * x) / 6000 | |
| 582 | } | |
| 583 | const cut = x <= 0 ? floor : Math.min(gorge, wall + 0.08 * x + (x * x) / 6000) | |
| 584 | // The embankment's crest is the bank, a metre above the water, with a few metres of | |
| 585 | // level top before the flanks fall away. It is shaped from the wall level too, so an | |
| 586 | // embankment carrying a river to a fall ramps down beside the gorge rather than ending | |
| 587 | // in a cliff the whole width of the embankment. | |
| 588 | const fill = x <= 0 ? floor : wall + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE | |
376f2ce6 2026-09-16 Pavel Safronov | 589 | o.h = smin(-smin(-o.h, -fill, 12), cut, 12) |
14d35b7d 2026-09-16 Pavel Safronov | 590 | // A sample stands for a cell about half the detail wavelength across (tile.ts). |
| 591 | const cell = Math.max(1, detailM / 2) | |
80bcc97a 2026-09-17 Pavel Safronov | 592 | const drawn = 2 * hw >= cell |
| 593 | if (x <= 0 && level > o.h && level >= o.water) { | |
| 594 | o.water = level | |
| 595 | if (drawn) { | |
| 596 | o.waterMesh = true | |
| 597 | o.waterCover = 1 | |
| 598 | const len = segLength(r, i) | |
| 599 | const speed = lerp(r.speed) | |
| 600 | o.flowZ = ((r.line.pts[2 * i + 2]! - r.line.pts[2 * i]!) / len) * speed | |
| 601 | o.flowA = ((r.line.pts[2 * i + 3]! - r.line.pts[2 * i + 1]!) / len) * speed | |
| 602 | o.foam = Math.max(lerp(r.foam), fall ? 1 : 0) | |
a088e5d5 2026-09-17 Pavel Safronov | 603 | o.body = -1 |
80bcc97a 2026-09-17 Pavel Safronov | 604 | } |
| 605 | } | |
| 606 | if (!drawn && near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, (2 * hw) / cell) | |
| 607 | } | |
| 608 | ||
| 609 | /** Rise per metre across of a gorge's walls: about 50°. */ | |
| 610 | const GORGE_WALL = 1.2 | |
| 611 | const GORGE_SEED = 0x60e5 | |
| 612 | ||
| 613 | function segLength(r: CRiver, i: number): number { | |
| 614 | return Math.hypot(r.line.pts[2 * i + 2]! - r.line.pts[2 * i]!, r.line.pts[2 * i + 3]! - r.line.pts[2 * i + 1]!) || 1 | |
376f2ce6 2026-09-16 Pavel Safronov | 615 | } |