fuzzyworld

tower / interior-flight / files / tools / terrain.mjs

Branch interior-flight · default branch · all branches

terrain.mjs

30.3 KB · history · blame

1/**
2 * Terrain generator. `npm run terrain`
3 *
4 * Reads content/geography.json — the authored source of truth — and writes:
5 * static/terrain-height.png 16-bit greyscale, render.minHeightM..maxHeightM
6 * static/terrain-color.png 8-bit RGB, hypsometric tint + hillshade
7 * static/world.json seed places for the app
8 *
9 * The map is the cylinder unrolled: x is z (sun cap at the left), y is theta.
10 * y wraps; x does not. Every distance below is in metres, converted to pixels only
11 * at the edges, because the canon coordinates are metres (design.md §1.7).
12 */
13import { readFileSync, writeFileSync, existsSync } from 'node:fs'
14import { encodeGray16, encodeRGB } from './png.mjs'
15import { fbm, ridged } from './noise.mjs'
16
17// ------------------------------------------------------------------- progress
18const T0 = Date.now()
19const secs = (from) => `${((Date.now() - from) / 1000).toFixed(1)}s`
20const mb = (n) => `${(n / 1048576).toFixed(1)} MB`
21/** Print `label` padded out to a timing column, so the log reads as a table. */
22function line(indent, label, right = '') {
23 const pad = Math.max(1, 58 - indent * 2 - label.length - right.length)
24 console.log(' '.repeat(indent * 2) + label + ' '.repeat(pad) + right)
25}
26/** Run `fn`, timing it and printing one row. */
27function step(indent, label, fn, extra = () => '') {
28 const t = Date.now()
29 const out = fn()
30 line(indent, label, `${extra(out)}${secs(t)}`.trim())
31 return out
32}
33
34const geo = JSON.parse(readFileSync('content/geography.json', 'utf8'))
35console.log(`\nTower terrain — content/geography.json, ${geo.features.length} features\n`)
36const { radiusM: R, lengthM: L, circumferenceM: C } = geo.datum
37const BIOME = { plain: 0, desert: 1, forest: 2, alpine: 3, water: 4 }
38const ORIGIN = geo.render.mapThetaOrigin ?? 0
39
40// Mutable view state. Every op reads these, so the same generator renders the
41// whole world or a close crop of the corridor without duplicating any terrain code.
42let W, H, MPP_X, MPP_Y, VZ0, VY0
43let height, biome, water, biomeStrength
44
45const px2z = (x) => VZ0 + (x + 0.5) * MPP_X
46const px2y = (y) => VY0 + (y + 0.5) * MPP_Y
47/** Canon theta -> map y, honouring where the map is cut. */
48const theta2y = (t) => {
49 const d = (((t - ORIGIN) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
50 return d * R
51}
52
53/**
54 * Noise sample coordinates at a given feature size, with the theta axis snapped to
55 * a whole number of lattice cells. The circumference is not a round multiple of any
56 * useful scale, so an unsnapped lattice tears at the seam and streaks the terrain.
57 * Snapping makes the y frequency differ from x by a few percent, which is invisible.
58 */
59function nc(z, y, scaleM) {
60 const cells = Math.max(1, Math.round(C / scaleM))
61 return { x: z / scaleM, y: (y / C) * cells, wrapY: cells }
62}
63
64/**
65 * Domain warp: offset a sample point by low-frequency noise before evaluating a
66 * mask. Without it every region reads as the rectangle or ellipse it was declared
67 * as; with it the edges wander like real ones.
68 */
69function warp(z, y, ampM, scaleM, seed) {
70 if (!ampM) return [z, y]
71 const s = nc(z, y, scaleM)
72 const wz = fbm(s.x, s.y, { octaves: 3, wrapY: s.wrapY, seed }) - 0.5
73 const wy = fbm(s.x + 37.1, s.y + 13.7, { octaves: 3, wrapY: s.wrapY, seed: seed + 500 }) - 0.5
74 return [z + wz * 2 * ampM, y + wy * 2 * ampM]
75}
76/** Shortest distance along the wrapping theta axis. */
77const dy = (a, b) => {
78 let d = Math.abs(a - b) % C
79 return d > C / 2 ? C - d : d
80}
81const smoothstep = (e0, e1, x) => {
82 const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)))
83 return t * t * (3 - 2 * t)
84}
85
86// ---------------------------------------------------------------- relief ops
87
88function opBase(f) {
89 const r = f.relief
90 for (let y = 0; y < H; y++) {
91 for (let x = 0; x < W; x++) {
92 const z = px2z(x)
93 const wy = px2y(y)
94 // Broad rolling relief, plus a finer layer so the ground has texture at
95 // reading scale. Still gentle: this is engineered ground nobody dramatised.
96 const sb = nc(z, wy, r.noiseScaleM)
97 const sf = nc(z, wy, r.detailScaleM)
98 const sg = nc(z, wy, 3400)
99 const broad = fbm(sb.x, sb.y, { octaves: r.octaves ?? 5, seed: 7, wrapY: sb.wrapY })
100 const fine = fbm(sf.x, sf.y, { octaves: 4, seed: 313, wrapY: sf.wrapY })
101 const grain = ridged(sg.x, sg.y, { octaves: 3, seed: 517, wrapY: sg.wrapY })
102 /**
103 * A fourth layer, down near what the raster can actually hold.
104 *
105 * The three above bottom out at 3.4 km, and a pixel is 264 m — so between
106 * 3.4 km and the ~530 m Nyquist limit there was an octave and a half of
107 * nothing, and the ground read as smooth swells with no grain on them. This
108 * fills that band. It is deliberately the last thing the base does: carve and
109 * flood run afterwards, so rivers still cut through it and lake surfaces stay
110 * flat rather than inheriting a rippled floor.
111 */
112 const sm = nc(z, wy, r.microScaleM ?? 1500)
113 const micro = fbm(sm.x, sm.y, { octaves: 4, seed: 1097, wrapY: sm.wrapY })
114 height[y * W + x] =
115 r.baseM + (broad - 0.45) * 2 * r.noiseAmpM + (fine - 0.5) * 2 * r.detailAmpM
116 + (grain - 0.5) * 26 + (micro - 0.5) * 2 * (r.microAmpM ?? 26)
117 }
118 }
119}
120
121/** Raised block with soft edges — the desert plateau and the forest shelf. */
122/** Theta mask for a footprint, or a flat 1 for a band that rings the world. */
123function thetaMask(g, r, wyw) {
124 if (g.ring) return 1
125 const span = dy(theta2y(g.thetaFrom), theta2y(g.thetaTo))
126 const mid = theta2y((g.thetaFrom + g.thetaTo) / 2)
127 return smoothstep(span / 2, span / 2 - r.edgeM, dy(wyw, mid))
128}
129
130function opPlateau(f) {
131 const g = f.geometry
132 const r = f.relief
133 for (let y = 0; y < H; y++) {
134 const wy = px2y(y)
135 for (let x = 0; x < W; x++) {
136 // Reject on unwarped z first: the warp costs two noise evaluations, and most
137 // pixels are nowhere near this footprint.
138 const zr = px2z(x)
139 if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue
140 const [z, wyw] = warp(zr, wy, r.warpM, r.warpScaleM ?? 40000, 61)
141 const inTheta = thetaMask(g, r, wyw)
142 if (inTheta <= 0) continue
143 const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom, z) * (1 - smoothstep(g.zTo, g.zTo + r.edgeM, z))
144 const m = inTheta * inZ
145 if (m <= 0) continue
146 const sn = nc(z, wy, r.noiseScaleM)
147 const n = fbm(sn.x, sn.y, { octaves: 4, seed: 23, wrapY: sn.wrapY })
148 const i = y * W + x
149 height[i] += m * (r.heightM + (n - 0.5) * 2 * r.noiseAmpM)
150 // Claim the pixel only if this region's mask is the strongest here, with a
151 // noise dither so neighbouring regions meet on a ragged line, not a ruler.
152 const claim = m + (n - 0.5) * 0.5
153 if (f.biome && claim > biomeStrength[i] && claim > 0.25) {
154 biomeStrength[i] = claim
155 biome[i] = BIOME[f.biome]
156 }
157 }
158 }
159}
160
161/** A ridge crossing the theta axis — what the canyon cuts through. */
162function opRidge(f) {
163 const g = f.geometry
164 const r = f.relief
165 for (let y = 0; y < H; y++) {
166 const inTheta = g.ring
167 ? 1
168 : smoothstep(dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2,
169 dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2 - 12000,
170 dy(px2y(y), theta2y((g.thetaFrom + g.thetaTo) / 2)))
171 if (inTheta <= 0) continue
172 const reach = r.widthZM * 2 + (r.warpM ?? 0)
173 for (let x = 0; x < W; x++) {
174 if (Math.abs(px2z(x) - g.z) > reach) continue
175 const [zw] = warp(px2z(x), px2y(y), r.warpM, 30000, 77)
176 const d = Math.abs(zw - g.z)
177 if (d > r.widthZM * 2) continue
178 const bump = Math.exp(-(d * d) / (2 * (r.widthZM / 2) ** 2))
179 const sr = nc(zw, px2y(y), 5000)
180 const n = fbm(sr.x, sr.y, { octaves: 3, seed: 41, wrapY: sr.wrapY })
181 let scale = 0.75 + 0.5 * n
182 /**
183 * Optional ridged noise, which is what turns a ridge into a *range*. Without
184 * it a ring band is a smooth swell all the way round — right for the three
185 * low walls across the waist, wrong for anything meant to read as peaks.
186 */
187 if (r.ridgedAmp) {
188 const sd = nc(zw, px2y(y), r.ridgedScaleM ?? 15000)
189 const rg = ridged(sd.x, sd.y, { octaves: 6, seed: 91, wrapY: sd.wrapY })
190 scale *= 1 - r.ridgedAmp + r.ridgedAmp * rg * 1.6
191 }
192 const h = inTheta * bump * r.heightM * scale
193 const i = y * W + x
194 /**
195 * `max` rather than `+=` matters wherever a ring runs under named summits:
196 * added, the ring's 2,900 m would stack onto Snowcrown's 4,300 and push the
197 * massif past the raster's 4,400 m ceiling. Taking the greater of the two
198 * lets the named peaks stay the high points of their own range, which is
199 * what they are.
200 */
201 height[i] = r.blend === 'max' ? Math.max(height[i], h) : height[i] + h
202 if (f.biome === 'alpine' && h > 1600) {
203 biome[i] = BIOME.alpine
204 biomeStrength[i] = 2
205 }
206 }
207 }
208}
209
210/** The mountain: a dome with ridged noise laid over it. */
211function opDome(f) {
212 const g = f.geometry
213 const r = f.relief
214 const cy = theta2y(g.theta)
215 // Build the massif into a scratch buffer first: ridged noise overshoots by an
216 // amount that depends on the noise, so the only way to land the summit on the
217 // declared 4,300 m is to normalise after the fact.
218 const tmp = new Float64Array(W * H)
219 let maxH = 0
220 for (let y = 0; y < H; y++) {
221 const ddy = dy(px2y(y), cy) / r.radiusThetaM
222 if (ddy > 1.6) continue
223 for (let x = 0; x < W; x++) {
224 if (Math.abs(px2z(x) - g.z) > r.radiusZM + (r.warpM ?? 0)) continue
225 const [zw, yw] = warp(px2z(x), px2y(y), r.warpM, 26000, 131)
226 const ddx = (zw - g.z) / r.radiusZM
227 const dd = Math.hypot(ddx, dy(yw, cy) / r.radiusThetaM)
228 if (dd >= 1) continue
229 const dome = Math.pow(Math.cos((dd * Math.PI) / 2), r.falloff)
230 const sd = nc(zw, yw, r.ridgedScaleM)
231 const rg = ridged(sd.x, sd.y, { octaves: 6, seed: 91, wrapY: sd.wrapY })
232 // Ridges bite hardest on the flanks and least at the summit, which is what
233 // stops the massif reading as a smooth blister.
234 const flank = Math.sin(dd * Math.PI) * 0.8 + 0.2
235 const h = dome * (1 - r.ridgedAmp * flank + r.ridgedAmp * flank * rg * 1.9)
236 tmp[y * W + x] = h
237 if (h > maxH) maxH = h
238 }
239 }
240 if (maxH <= 0) return
241 const scale = r.heightM / maxH
242 for (let i = 0; i < tmp.length; i++) {
243 if (tmp[i] <= 0) continue
244 const h = tmp[i] * scale
245 height[i] += h
246 // Only the high ground is bare alpine rock; the skirts stay vegetated.
247 if (h > 1600) {
248 biome[i] = BIOME.alpine
249 biomeStrength[i] = 2
250 }
251 }
252}
253
254/** A lake basin, plus its still water surface. */
255function opBasin(f) {
256 const g = f.geometry
257 const r = f.relief
258 const cy = theta2y(g.theta)
259 for (let y = 0; y < H; y++) {
260 const ddy = dy(px2y(y), cy) / (g.radiusThetaM + r.edgeM)
261 if (ddy > 1.3) continue
262 for (let x = 0; x < W; x++) {
263 const ddx = (px2z(x) - g.z) / (g.radiusZM + r.edgeM)
264 const d = Math.hypot(ddx, ddy)
265 if (d >= 1) continue
266 const i = y * W + x
267 height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.6)
268 if (height[i] < f.waterLevelM) {
269 water[i] = f.waterLevelM
270 biome[i] = BIOME.water
271 }
272 }
273 }
274}
275
276/**
277 * Carve a river valley along a polyline, interpolating the long profile.
278 * Reaches let one river be a broad mountain valley, then a slot canyon, then a
279 * shallow floodplain — which is the whole shape of this watercourse.
280 */
281/** Catmull-Rom through the authored waypoints, so the river curves instead of zigzagging. */
282function smoothPolyline(points, profile, perSegment = 14) {
283 const at = (i) => points[Math.max(0, Math.min(points.length - 1, i))]
284 const pAt = (i) => profile[Math.max(0, Math.min(profile.length - 1, i))]
285 const out = []
286 for (let i = 0; i < points.length - 1; i++) {
287 const p0 = at(i - 1)
288 const p1 = at(i)
289 const p2 = at(i + 1)
290 const p3 = at(i + 2)
291 for (let s = 0; s < perSegment; s++) {
292 const u = s / perSegment
293 const u2 = u * u
294 const u3 = u2 * u
295 const c = (a, b, cc, d) =>
296 0.5 * (2 * b + (cc - a) * u + (2 * a - 5 * b + 4 * cc - d) * u2 + (-a + 3 * b - 3 * cc + d) * u3)
297 out.push({
298 y: c(p0.y, p1.y, p2.y, p3.y),
299 z: c(p0.z, p1.z, p2.z, p3.z),
300 elev: pAt(i) + (pAt(i + 1) - pAt(i)) * u,
301 src: i + u,
302 })
303 }
304 }
305 const last = points[points.length - 1]
306 out.push({ y: last.y, z: last.z, elev: profile[profile.length - 1], src: points.length - 1 })
307 return out
308}
309
310function opCarve(f) {
311 const raw = f.geometry.points.map(([t, z]) => ({ y: theta2y(t), z }))
312 const pts = smoothPolyline(raw, f.profileM)
313 const reaches = f.relief.reaches
314 const KEYS = ['valleyWidthM', 'channelWidthM', 'depthM', 'wallExponent', 'shoulderM']
315 const BLEND = 2.2 // waypoints over which one reach becomes the next
316
317 /**
318 * Reach parameters at a fractional position along the river, blended across
319 * boundaries. Switching reaches abruptly steps the valley from 5 km wide to 18 km
320 * in a single pixel column, which reads as a wall across the map.
321 */
322 function reachAt(sf) {
323 let idx = reaches.findIndex((r) => sf < r.toIndex)
324 if (idx < 0) idx = reaches.length - 1
325 const cur = reaches[idx]
326 const out = {}
327 const edge = cur.toIndex
328 const next = reaches[idx + 1]
329 if (next && sf > edge - BLEND) {
330 const t = smoothstep(edge - BLEND, edge + BLEND, sf)
331 for (const k of KEYS) out[k] = cur[k] + (next[k] - cur[k]) * t
332 return out
333 }
334 const prev = reaches[idx - 1]
335 if (prev && sf < prev.toIndex + BLEND) {
336 const t = smoothstep(prev.toIndex - BLEND, prev.toIndex + BLEND, sf)
337 for (const k of KEYS) out[k] = prev[k] + (cur[k] - prev[k]) * t
338 return out
339 }
340 for (const k of KEYS) out[k] = cur[k]
341 return out
342 }
343
344 // Precompute segments once, with their theta unwrapped and their influence
345 // radius, then bin them into a coarse grid. Testing every segment against every
346 // pixel is ~3 billion distance tests for four rivers at world resolution; the
347 // grid turns it into a handful per pixel.
348 const segs = []
349 for (let s = 0; s < pts.length - 1; s++) {
350 const a = pts[s]
351 const b = pts[s + 1]
352 let by = b.y
353 if (Math.abs(by - a.y) > C / 2) by += by > a.y ? -C : C
354 const rr = reachAt(a.src)
355 segs.push({ a, by, infl: rr.valleyWidthM / 2 + rr.shoulderM })
356 }
357
358 const CELL = 4000
359 const NZ = Math.ceil(L / CELL)
360 const NY = Math.ceil(C / CELL)
361 const grid = new Map()
362 const wrapCell = (cy) => ((cy % NY) + NY) % NY
363 for (let s = 0; s < segs.length; s++) {
364 const { a, by, infl } = segs[s]
365 const z0 = Math.min(a.z, pts[s + 1].z) - infl
366 const z1 = Math.max(a.z, pts[s + 1].z) + infl
367 const y0 = Math.min(a.y, by) - infl
368 const y1 = Math.max(a.y, by) + infl
369 for (let cz = Math.floor(z0 / CELL); cz <= Math.floor(z1 / CELL); cz++) {
370 if (cz < 0 || cz >= NZ) continue
371 for (let cy = Math.floor(y0 / CELL); cy <= Math.floor(y1 / CELL); cy++) {
372 const key = cz * NY + wrapCell(cy)
373 let list = grid.get(key)
374 if (!list) grid.set(key, (list = []))
375 list.push(s)
376 }
377 }
378 }
379
380 for (let y = 0; y < H; y++) {
381 const wy = px2y(y)
382 const cy = wrapCell(Math.floor(wy / CELL))
383 for (let x = 0; x < W; x++) {
384 const wz = px2z(x)
385 const list = grid.get(Math.floor(wz / CELL) * NY + cy)
386 if (!list) continue
387 let best = Infinity
388 let bestElev = 0
389 let bestSrc = 0
390 for (const si of list) {
391 const { a, by } = segs[si]
392 const b = pts[si + 1]
393 let py = wy
394 if (Math.abs(py - a.y) > C / 2) py += py > a.y ? -C : C
395 const vz = b.z - a.z
396 const vy = by - a.y
397 const tt = Math.max(0, Math.min(1, ((wz - a.z) * vz + (py - a.y) * vy) / (vz * vz + vy * vy || 1)))
398 const d = Math.hypot(wz - (a.z + vz * tt), py - (a.y + vy * tt))
399 if (d < best) {
400 best = d
401 bestElev = a.elev + (b.elev - a.elev) * tt
402 bestSrc = a.src
403 }
404 }
405 if (best === Infinity) continue
406 const reach = reachAt(bestSrc)
407 const half = reach.valleyWidthM / 2
408 const shoulder = reach.shoulderM
409 if (best > half + shoulder) continue
410 const i = y * W + x
411 const chan = reach.channelWidthM / 2
412 const u = Math.max(0, Math.min(1, (best - chan) / Math.max(1, half - chan)))
413 const rim = bestElev + Math.pow(u, reach.wallExponent) * reach.depthM
414 const k = 1 - smoothstep(half, half + shoulder, best)
415 height[i] = Math.min(height[i], rim * k + height[i] * (1 - k))
416 if (best < chan) {
417 water[i] = bestElev + 2
418 biome[i] = BIOME.water
419 biomeStrength[i] = 3
420 }
421 }
422 }
423}
424
425/**
426 * Carve a footprint down and fill it to a water level. Handles the axial sea (a
427 * trough long in z and narrow in theta) and the cap seas (bands ringing the world).
428 */
429function opFlood(f) {
430 const g = f.geometry
431 const r = f.relief
432 for (let y = 0; y < H; y++) {
433 const wy = px2y(y)
434 for (let x = 0; x < W; x++) {
435 const zr = px2z(x)
436 if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue
437 const [z, wyw] = warp(zr, wy, r.warpM ?? 0, r.warpScaleM ?? 30000, 197)
438 const inTheta = thetaMask(g, r, wyw)
439 if (inTheta <= 0) continue
440 const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom + r.edgeM * 0.2, z) *
441 (1 - smoothstep(g.zTo - r.edgeM * 0.2, g.zTo + r.edgeM, z))
442 const m = inTheta * inZ
443 if (m <= 0.001) continue
444 const i = y * W + x
445 height[i] -= r.depthM * Math.pow(m, 0.7)
446 if (height[i] < f.waterLevelM && m > 0.05) {
447 water[i] = f.waterLevelM
448 biome[i] = BIOME.water
449 biomeStrength[i] = 3
450 }
451 }
452 }
453}
454
455/**
456 * A mountain tarn. Floods relative to the ground it finds rather than to an
457 * absolute level, so a lake can be dropped anywhere on a range without first
458 * knowing how high that spot came out.
459 */
460function opTarn(f) {
461 const g = f.geometry
462 const r = f.relief
463 const cy = theta2y(g.theta)
464 const rz = g.radiusZM
465 const rt = g.radiusThetaM
466 const inside = []
467
468 // Carve the bowl and raise a moraine rim around it.
469 for (let y = 0; y < H; y++) {
470 const ddy = dy(px2y(y), cy) / rt
471 if (ddy > 1.05) continue
472 for (let x = 0; x < W; x++) {
473 const d = Math.hypot((px2z(x) - g.z) / rz, ddy)
474 if (d >= 1) continue
475 const i = y * W + x
476 height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.2)
477 if (d > 0.68) height[i] += (r.rimM ?? r.depthM * 0.5) * Math.sin(((d - 0.68) / 0.32) * Math.PI)
478 if (d < 0.85) inside.push(i)
479 }
480 }
481 if (!inside.length) return
482
483 // Fill from the bowl floor upward. Taking the level from the rim instead lets the
484 // water spill out downhill and leaves a half-moon rather than a lake.
485 let floor = Infinity
486 for (const i of inside) if (height[i] < floor) floor = height[i]
487 const level = floor + r.depthM * (r.fillFraction ?? 0.55)
488 for (const i of inside) {
489 if (height[i] < level) {
490 water[i] = level
491 biome[i] = BIOME.water
492 biomeStrength[i] = 3
493 }
494 }
495 f.$computedLevelM = Math.round(level)
496}
497
498const OPS = { base: opBase, plateau: opPlateau, ridge: opRidge, dome: opDome,
499 basin: opBasin, carve: opCarve, flood: opFlood, tarn: opTarn }
500
501/** Build the height/biome/water fields for one rectangular view of the surface. */
502function generate(view, label, verbose) {
503 W = view.width
504 H = view.height
505 VZ0 = view.zFrom
506 VY0 = view.yFrom
507 MPP_X = (view.zTo - view.zFrom) / W
508 MPP_Y = (view.yTo - view.yFrom) / H
509 height = new Float64Array(W * H)
510 biome = new Uint8Array(W * H)
511 water = new Float64Array(W * H)
512 biomeStrength = new Float64Array(W * H)
513
514 line(1, `${label} ${W} x ${H}`, `${MPP_X.toFixed(0)} m/px`)
515 const started = Date.now()
516 const shaped = []
517 for (const f of geo.features) {
518 if (!f.relief) continue
519 const op = OPS[f.relief.op]
520 if (!op) throw new Error(`unknown relief op "${f.relief.op}" on ${f.id}`)
521 // Capture W/H/etc, which the ops read from module state.
522 const w = W, h = H, mx = MPP_X, my = MPP_Y, vz = VZ0, vy = VY0
523 const run = () => {
524 W = w; H = h; MPP_X = mx; MPP_Y = my; VZ0 = vz; VY0 = vy
525 op(f)
526 }
527 if (verbose) step(3, `${f.relief.op.padEnd(9)} ${f.name}`, run)
528 else run()
529 shaped.push(f.id)
530 }
531 W = view.width; H = view.height
532 line(2, `relief ${shaped.length} features`, secs(started))
533 return { width: W, height: H, field: height, biome, water, mppX: MPP_X, mppY: MPP_Y }
534}
535
536// ------------------------------------------------------------------- rendering
537
538const SNOW = geo.render.snowlineM
539
540const RAMP = {
541 0: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]],
542 [700, [140, 138, 106]], [1600, [146, 138, 120]]],
543 1: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]],
544 [1400, [148, 120, 86]]],
545 2: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]],
546 3: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]],
547 [2900, [166, 162, 156]]],
548 4: [[0, [40, 84, 128]], [5000, [58, 108, 150]]],
549}
550
551function tint(b, h) {
552 const stops = RAMP[b]
553 if (h <= stops[0][0]) return stops[0][1]
554 for (let i = 1; i < stops.length; i++) {
555 if (h <= stops[i][0]) {
556 const t = (h - stops[i - 1][0]) / (stops[i][0] - stops[i - 1][0])
557 return stops[i - 1][1].map((c, k) => c + (stops[i][1][k] - c) * t)
558 }
559 }
560 return stops[stops.length - 1][1]
561}
562
563/**
564 * Hillshade. The light direction is a cartographic convention, not Tower's real
565 * lighting — Tower's sun is a luminous line down the axis, so true shading there
566 * is soft and nearly shadowless. This is for reading the map, not for realism.
567 */
568function colorise(g) {
569 const { azimuthDeg, altitudeDeg, strength } = geo.render.hillshade
570 const az = (azimuthDeg * Math.PI) / 180
571 const alt = (altitudeDeg * Math.PI) / 180
572 const lx = Math.cos(alt) * Math.sin(az)
573 const ly = Math.cos(alt) * Math.cos(az)
574 const lz = Math.sin(alt)
575 const { width: w, height: h, field, biome: bio, water: wat, mppX, mppY } = g
576 const rgb = new Uint8Array(w * h * 3)
577
578 for (let y = 0; y < h; y++) {
579 for (let x = 0; x < w; x++) {
580 const i = y * w + x
581 const nx = (field[y * w + Math.max(0, x - 1)] - field[y * w + Math.min(w - 1, x + 1)]) / (2 * mppX)
582 const ny = (field[((y - 1 + h) % h) * w + x] - field[((y + 1) % h) * w + x]) / (2 * mppY)
583 const len = Math.hypot(nx, ny, 1)
584 let shade = (nx * lx + ny * ly + lz) / len
585 shade = 1 - strength + strength * Math.max(0, shade) * 1.35
586
587 let c = tint(bio[i], field[i])
588 if (wat[i] > 0) {
589 const d = Math.min(1, Math.max(0, wat[i] - field[i]) / 90)
590 c = [58 - 20 * d, 96 - 26 * d, 132 - 24 * d]
591 shade = 0.93 + 0.07 * shade
592 } else {
593 /**
594 * Two layers of per-pixel variation, because one was not enough to stop a
595 * region reading as flat paint.
596 *
597 * The broad one dims and brightens. The fine one **mottles**: it pushes the
598 * channels apart rather than scaling them together, so ground reads as
599 * patchy — vegetation against bare soil — instead of as a single colour
600 * turned up and down. Green moves most and blue least, which is roughly how
601 * real ground cover varies, and it keeps deserts sandy rather than tinting
602 * them toward the same green as the forest.
603 */
604 const sj = nc(px2z(x), px2y(y), 5200)
605 const j = fbm(sj.x, sj.y, { octaves: 3, seed: 733, wrapY: sj.wrapY })
606 const sm = nc(px2z(x), px2y(y), 1150)
607 const m = fbm(sm.x, sm.y, { octaves: 4, seed: 941, wrapY: sm.wrapY })
608 const k = 0.9 + 0.2 * j
609 const mo = (m - 0.5) * 0.13
610 c = [c[0] * (k - mo * 0.55), c[1] * (k + mo), c[2] * (k - mo * 0.25)]
611 if (field[i] > SNOW) {
612 // Snow does not lie on cliffs. Blending it out with slope is what turns a
613 // white blob into a mountain with rock showing through.
614 const slope = Math.hypot(nx, ny)
615 const holds = 1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55))
616 const t = Math.min(1, (field[i] - SNOW) / 550) * holds
617 c = c.map((v, q) => v + ([247, 249, 253][q] - v) * t)
618 }
619 }
620 rgb[i * 3] = Math.max(0, Math.min(255, c[0] * shade))
621 rgb[i * 3 + 1] = Math.max(0, Math.min(255, c[1] * shade))
622 rgb[i * 3 + 2] = Math.max(0, Math.min(255, c[2] * shade))
623 }
624 }
625 return rgb
626}
627
628// --------------------------------------------------------------------- outputs
629
630const worldView = {
631 width: geo.render.gridWidth, height: geo.render.gridHeight,
632 zFrom: 0, zTo: L, yFrom: 0, yTo: C,
633}
634const world = generate(worldView, 'world', !process.argv.includes('--quiet'))
635const worldRGB = step(2, 'colourise', () => colorise(world))
636
637let peak = 0
638let low = Infinity
639for (const v of world.field) {
640 if (v > peak) peak = v
641 if (v < low) low = v
642}
643
644// A close view of the developed corridor. At world scale the canyon is six pixels
645// wide, which is not enough to judge anything by.
646const crop = geo.render.corridor
647const cropView = {
648 width: crop.width, height: crop.height,
649 zFrom: crop.zFrom, zTo: crop.zTo,
650 yFrom: theta2y(crop.thetaFrom), yTo: theta2y(crop.thetaFrom) + (crop.thetaTo - crop.thetaFrom) * R,
651}
652const corridor = generate(cropView, 'corridor', false)
653const corridorRGB = step(2, 'colourise', () => colorise(corridor))
654
655line(1, 'encode')
656const write = (name, make) =>
657 step(2, name, () => {
658 const buf = make()
659 writeFileSync(`static/${name}`, buf)
660 return buf
661 }, (b) => `${mb(b.length).padEnd(10)}`)
662
663/**
664 * Normalise the height field into the declared working range.
665 *
666 * The raster does not have to span anything in particular — it only has to span the
667 * terrain that exists, and the narrower the range the finer every step in it. That
668 * matters more than it looks: browsers decode a 16-bit PNG down to 8 bits, so what
669 * the app actually reads is 256 levels across whatever range is declared here.
670 * Clipping the bottom at zero also flattened every basin below the datum, which is
671 * why lake floors had none.
672 */
673const HMIN = geo.render.minHeightM
674const HMAX = geo.render.maxHeightM
675let lo = Infinity, hi = -Infinity
676for (const h of world.field) { if (h < lo) lo = h; if (h > hi) hi = h }
677const span = HMAX - HMIN
678const norm = new Float64Array(world.field.length)
679let clipped = 0
680for (let i = 0; i < norm.length; i++) {
681 const v = (world.field[i] - HMIN) / span
682 if (v < 0 || v > 1) clipped++
683 norm[i] = Math.min(1, Math.max(0, v))
684}
685line(1, 'height range',
686 `terrain ${lo.toFixed(0)}..${hi.toFixed(0)} m in ${HMIN}..${HMAX} m, ` +
687 `${(span / 255).toFixed(1)} m per 8-bit step`)
688if (clipped) {
689 /**
690 * Fail, rather than warn. This warned for one run and nobody saw it: the terrain
691 * had grown 1 m past the floor and every pixel down there was quietly flattened,
692 * which is D-024's failure mode exactly — the raster displaces terrain rather
693 * than breaking it, so nothing downstream complains. The unit test cannot catch
694 * it either, because it compares the declaration against numbers written down
695 * when it was last true rather than against the terrain that just came out.
696 */
697 console.error(`\n ! ${clipped} pixel(s) fall outside minHeightM..maxHeightM and were clamped.`)
698 console.error(` Terrain spans ${lo.toFixed(0)}..${hi.toFixed(0)} m; widen the range in`)
699 console.error(' content/geography.json to keep them.\n')
700 process.exitCode = 1
701}
702write('terrain-height.png', () => encodeGray16(world.width, world.height, norm))
703write('terrain-color.png', () => encodeRGB(world.width, world.height, worldRGB))
704write('terrain-corridor.png', () => encodeRGB(corridor.width, corridor.height, corridorRGB))
705
706// -------------------------------------------------------- seed places for the app
707
708const KIND = { mountain: 'mountain', river: 'river', canyon: 'canyon', forest: 'forest',
709 desert: 'desert', lake: 'lake', city: 'city', tower: 'tower', region: 'region' }
710
711function toPlaceGeometry(g) {
712 if (g.type === 'point') return { type: 'point', theta: g.theta, z: g.z }
713 if (g.type === 'polyline') return { type: 'polyline', points: g.points }
714 if (g.type === 'ellipse') {
715 const pts = []
716 for (let i = 0; i < 32; i++) {
717 const a = (i / 32) * 2 * Math.PI
718 pts.push([g.theta + (g.radiusThetaM / R) * Math.cos(a), g.z + g.radiusZM * Math.sin(a)])
719 }
720 return { type: 'polygon', points: pts }
721 }
722 if (g.type === 'rect') {
723 return { type: 'polygon', points: [
724 [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] }
725 }
726 // A band rings the world. As a polygon it would collapse — theta 0 and theta 2pi
727 // land on the same map row — so it stays its own shape all the way through.
728 if (g.type === 'band') return { type: 'band', zFrom: g.zFrom, zTo: g.zTo }
729 throw new Error(`cannot convert geometry ${g.type}`)
730}
731
732const places = geo.features
733 // Ridges are bands with no z extent — relief only, not places on a map.
734 .filter((f) => f.geometry.type !== 'whole-surface' && f.relief?.op !== 'ridge')
735 .map((f) => ({
736 id: f.id, name: f.name, kind: KIND[f.kind] ?? 'landmark',
737 geometry: toPlaceGeometry(f.geometry), blurb: '',
738 }))
739
740const target = 'static/world.json'
741const existing = existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : { places: [] }
742const authored = existing.places?.filter((p) => !places.some((q) => q.id === p.id)) ?? []
743if (authored.length && !process.argv.includes('--force')) {
744 console.warn(`\n ! ${target} holds ${authored.length} place(s) not in geography.json.`)
745 console.warn(' Not overwriting. Re-run with --force to replace them.\n')
746} else {
747 writeFileSync(target, JSON.stringify({ version: 1, places }, null, 2) + '\n')
748}
749
750const probeArg = process.argv.indexOf('--probe')
751if (probeArg >= 0) {
752 const theta = Number(process.argv[probeArg + 1] ?? 0)
753 const row = Math.round((theta2y(theta) / C) * world.height) % world.height
754 console.log(`\n elevation profile along theta = ${theta} (row ${row})`)
755 let prev = null
756 for (let x = 0; x < world.width; x += 4) {
757 const z = (x + 0.5) * world.mppX
758 const h = world.field[row * world.width + x]
759 const jump = prev === null ? 0 : h - prev
760 if (Math.abs(jump) > 60) console.log(` z ${(z / 1000).toFixed(1)} km ${h.toFixed(0)} m JUMP ${jump.toFixed(0)} m`)
761 prev = h
762 }
763 console.log('')
764}
765
766line(1, `seed ${places.length} places`, target)
767console.log('')
768line(1, 'elevation', `${low.toFixed(0)} m .. ${peak.toFixed(0)} m`)
769line(1, 'done', secs(T0))
770console.log('')