fuzzyworld

tower / files / src / terrain / field.ts

field.ts

Blame: the commit that last changed each line · file · history

376f2ce6 2026-09-16 Pavel Safronov1/**
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 Safronov13 * + mountains eroded ranges, on top of the land
14 * ∨ raised regions low ground lifted to a height
376f2ce6 2026-09-16 Pavel Safronov15 * − 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 Safronov22import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
abc0aad0 2026-10-01 Pavel Safronov23import { type CBasin, type CFarm, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js'
51c39d8b 2026-09-17 Pavel Safronov24import { BENCH_STEP_M, type CDesert, hillsAt, sampleDesert, terrace } from './desert.js'
00ceeedb 2026-09-27 Pavel Safronov25import { type CForest, canopyAt, stemsAt } from './forest.js'
e7a9f9dd 2026-09-17 Pavel Safronov26import { type CMountainGroup, pinKernel, sampleGrid } from './mountains.js'
376f2ce6 2026-09-16 Pavel Safronov27import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js'
e18dad04 2026-09-30 Pavel Safronov28import { iceCover, iceMantle, iceMargin, iceSide, rangeRock } from './ice.js'
abc0aad0 2026-10-01 Pavel Safronov29import { fieldAt, growNode } from './farms.js'
376f2ce6 2026-09-16 Pavel Safronov30import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js'
31
32export 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
39export type Stage = (typeof Stage)[keyof typeof Stage]
40
41/** What the world is at one point. */
42export 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 Safronov48 * 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 Safronov53 */
14d35b7d 2026-09-16 Pavel Safronov54 waterCover: number
80bcc97a 2026-09-17 Pavel Safronov55 /**
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 Safronov61 /** 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 Safronov63 /** 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 Safronov67 /** 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 Safronov71 /** 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 Safronov73 /** 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 Safronov78 /** 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 Safronov82 /** Trees standing here, stems per hectare, and how high their canopy reaches, metres. */
83 stems: number
84 canopyM: number
376f2ce6 2026-09-16 Pavel Safronov85 snowlineM: number
86}
87
88const 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`. */
94const 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
99function 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. */
107const 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. */
110function 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 */
123export class Runtime {
124 private indexes: LineIndex[]
80bcc97a 2026-09-17 Pavel Safronov125 private out: Sample = {
126 h: 0, water: -Infinity, waterCover: 0, waterMesh: false,
abc0aad0 2026-10-01 Pavel Safronov127 body: -1, flowZ: 0, flowA: 0, foam: 0, ice: 0, glacier: 0, farm: 0,
00ceeedb 2026-09-27 Pavel Safronov128 desert: 0, forest: 0, relief: 0, rock: 0, salt: 0, stems: 0, canopyM: 0, snowlineM: 3000,
80bcc97a 2026-09-17 Pavel Safronov129 }
376f2ce6 2026-09-16 Pavel Safronov130 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 Safronov146 o.waterCover = 0
80bcc97a 2026-09-17 Pavel Safronov147 o.waterMesh = false
a088e5d5 2026-09-17 Pavel Safronov148 o.body = -1
80bcc97a 2026-09-17 Pavel Safronov149 o.flowZ = 0
150 o.flowA = 0
151 o.foam = 0
e18dad04 2026-09-30 Pavel Safronov152 o.ice = 0
153 o.glacier = 0
abc0aad0 2026-10-01 Pavel Safronov154 o.farm = 0
376f2ce6 2026-09-16 Pavel Safronov155 o.desert = 0
156 o.forest = 0
157 o.relief = 0
51c39d8b 2026-09-17 Pavel Safronov158 o.rock = 0
159 o.salt = 0
00ceeedb 2026-09-27 Pavel Safronov160 o.stems = 0
161 o.canopyM = 0
376f2ce6 2026-09-16 Pavel Safronov162 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 Safronov170 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 Safronov175 // 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 Safronov179 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 Safronov187 // 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 Safronov192 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 Safronov195 if (inBox(r.box, z, a)) river(r, this.indexes[i]!, p, z, a, detailM, o, this.nearest)
376f2ce6 2026-09-16 Pavel Safronov196 })
00ceeedb 2026-09-27 Pavel Safronov197 // 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 Safronov202 // 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 Safronov204 return o
205 }
206}
207
208const runtimes = new WeakMap<CompiledWorld, Runtime>()
209
210export 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. */
220export 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 */
231function 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. */
243function 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 Safronov253 else if (u.kind === 'forest') o.forest = Math.max(o.forest, claim)
254}
255
abc0aad0 2026-10-01 Pavel Safronov256/** A grown farm's patches, as a set, made once per compiled farm. */
257const grown = new WeakMap<CFarm, Set<number>>()
258function 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 */
269function 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 Safronov290/**
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 */
298function 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 Safronov309/**
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 */
316function 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 */
386function 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 Safronov405/**
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 */
411function 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 Safronov417}
418
e7a9f9dd 2026-09-17 Pavel Safronov419/**
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 */
427function 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 Safronov434 }
e7a9f9dd 2026-09-17 Pavel Safronov435 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 Safronov442}
443
80bcc97a 2026-09-17 Pavel Safronov444/** A lake or sea's water, where it is the highest water here. */
445function 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 Safronov450 o.body = b.index
80bcc97a 2026-09-17 Pavel Safronov451 o.flowZ = 0
452 o.flowA = 0
453 o.foam = 0
e18dad04 2026-09-30 Pavel Safronov454 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 */
463function 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 */
496function 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 Safronov503}
504
376f2ce6 2026-09-16 Pavel Safronov505/** Carve a lake or sea basin and set its water where the floor is below its level. */
506function 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 Safronov521 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 Safronov526 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 Safronov533 if (d < 0.85 && o.h < b.levelM) setBasinWater(b, o)
376f2ce6 2026-09-16 Pavel Safronov534}
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 Safronov545function river(r: CRiver, index: LineIndex, p: CylPoint, z: number, a: number, detailM: number, o: Sample, near: Nearest): void {
376f2ce6 2026-09-16 Pavel Safronov546 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 Safronov552 /**
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 Safronov561 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 Safronov565 /**
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 Safronov589 o.h = smin(-smin(-o.h, -fill, 12), cut, 12)
14d35b7d 2026-09-16 Pavel Safronov590 // 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 Safronov592 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 Safronov603 o.body = -1
80bcc97a 2026-09-17 Pavel Safronov604 }
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°. */
610const GORGE_WALL = 1.2
611const GORGE_SEED = 0x60e5
612
613function 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 Safronov615}