tower / files / src / terrain / noise.ts
noise.ts
376f2ce6 2026-09-16 Pavel Safronov | 1 | /** |
| 2 | * Seeded noise, evaluated on the cylinder itself. See world-generation.md §5.3, §5.5. | |
| 3 | * | |
| 4 | * **Why 3D noise for a 2D surface.** The surface wraps around theta. v1 sampled 2D | |
| 5 | * value noise on the unrolled map and had to snap the lattice to a whole number of | |
| 6 | * cells around the circumference, or the terrain tore at the seam (D-019). Sampling | |
| 7 | * 3D simplex noise at the actual point on the cylinder, (R cos θ, R sin θ, z), has no | |
| 8 | * seam to tear: theta = 0 and theta = 2π are the same point in space, so they get the | |
| 9 | * same value by construction. | |
| 10 | * | |
| 11 | * Everything here is deterministic. There is no Math.random: randomness comes from a | |
| 12 | * seed, through `mulberry32`, and the same seed gives the same world on every machine. | |
| 13 | */ | |
| 14 | import { createNoise3D, type NoiseFunction3D } from 'simplex-noise' | |
| 15 | import { RADIUS_M } from '../canon/constants.js' | |
| 16 | ||
| 17 | /** | |
| 18 | * 32-bit integer hash of up to three integers. Math.imul throughout: the obvious | |
| 19 | * float version silently loses precision on large products (D-019). | |
| 20 | */ | |
| 21 | export function hash3(a: number, b: number, c: number): number { | |
| 22 | let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 1274126177) | |
| 23 | h = Math.imul(h ^ (h >>> 15), 2246822519) | |
| 24 | h = Math.imul(h ^ (h >>> 13), 3266489917) | |
| 25 | return (h ^ (h >>> 16)) >>> 0 | |
| 26 | } | |
| 27 | ||
| 28 | /** A seeded PRNG in [0, 1). Small, fast, and good enough for building permutation tables. */ | |
| 29 | export function mulberry32(seed: number): () => number { | |
| 30 | let a = seed >>> 0 | |
| 31 | return () => { | |
| 32 | a = (a + 0x6d2b79f5) | 0 | |
| 33 | let t = Math.imul(a ^ (a >>> 15), 1 | a) | |
| 34 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t | |
| 35 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296 | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | const tables = new Map<number, NoiseFunction3D>() | |
| 40 | ||
| 41 | /** One simplex field per seed, built once per thread. */ | |
| 42 | export function simplex(seed: number): NoiseFunction3D { | |
| 43 | let n = tables.get(seed) | |
| 44 | if (!n) { | |
| 45 | n = createNoise3D(mulberry32(seed)) | |
| 46 | tables.set(seed, n) | |
| 47 | } | |
| 48 | return n | |
| 49 | } | |
| 50 | ||
| 51 | /** A point on the cylinder, in metres, precomputed once per sample. */ | |
| 52 | export interface CylPoint { x: number; y: number; z: number } | |
| 53 | ||
| 54 | export function cylPoint(theta: number, z: number): CylPoint { | |
| 55 | return { x: RADIUS_M * Math.cos(theta), y: RADIUS_M * Math.sin(theta), z } | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * Octave offsets, so successive octaves of one field do not line up on the same | |
| 60 | * lattice. Irrational-ish and large relative to one cell. | |
| 61 | */ | |
| 62 | const OX = [0, 131.7, -217.3, 311.9, -419.1, 523.3, -631.7, 739.9, -843.1, 953.3, | |
| 63 | -1061.7, 1171.9, -1277.3, 1381.1, -1489.7, 1597.3] | |
| 64 | ||
| 65 | /** | |
| 66 | * Fractal simplex noise, in roughly [-1, 1]. | |
| 67 | * | |
| 68 | * `minWavelengthM` is the level of detail. Octaves shorter than it are skipped, and | |
| 69 | * the sum is still normalised by the *full* amplitude, so dropping an octave removes | |
| 70 | * detail without shifting the value — a coarse tile and a fine tile of the same | |
| 71 | * ground agree to within the octaves the coarse one cannot show anyway. | |
| 72 | */ | |
| 73 | export function fbm( | |
| 74 | seed: number, p: CylPoint, scaleM: number, octaves: number, minWavelengthM = 0, gain = 0.5, | |
| 75 | ): number { | |
| 76 | const n = simplex(seed) | |
| 77 | let sum = 0, amp = 1, norm = 0, wl = scaleM | |
| 78 | for (let o = 0; o < octaves; o++) { | |
| 79 | norm += amp | |
| 80 | if (wl >= minWavelengthM) { | |
| 81 | const f = 1 / wl | |
| 82 | const off = OX[o]! | |
| 83 | sum += n(p.x * f + off, p.y * f - off, p.z * f + off * 0.5) * amp | |
| 84 | } | |
| 85 | amp *= gain | |
| 86 | wl /= 2 | |
| 87 | } | |
| 88 | return sum / norm | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Ridged fractal noise in [0, 1]: absolute-valued and inverted, which makes crests. | |
| 93 | * Same level-of-detail rule as `fbm`. | |
| 94 | */ | |
| 95 | export function ridged( | |
| 96 | seed: number, p: CylPoint, scaleM: number, octaves: number, minWavelengthM = 0, | |
| 97 | ): number { | |
| 98 | const n = simplex(seed) | |
| 99 | let sum = 0, amp = 1, norm = 0, wl = scaleM | |
| 100 | for (let o = 0; o < octaves; o++) { | |
| 101 | norm += amp | |
| 102 | if (wl >= minWavelengthM) { | |
| 103 | const f = 1 / wl | |
| 104 | const off = OX[o]! | |
| 105 | const v = 1 - Math.abs(n(p.x * f - off, p.y * f + off, p.z * f - off * 0.5)) | |
| 106 | sum += v * v * amp | |
| 107 | } else { | |
| 108 | // A skipped ridged octave still contributes its mean, or coarse tiles sag. | |
| 109 | sum += 0.47 * amp | |
| 110 | } | |
| 111 | amp *= 0.5 | |
| 112 | wl /= 2 | |
| 113 | } | |
| 114 | return sum / norm | |
| 115 | } |