feat(stdlib): add Noise.* — deterministic fixed-point procedural noise (#3)
All checks were successful
docs / build-and-deploy (push) Successful in 2s
All checks were successful
docs / build-and-deploy (push) Successful in 2s
A Noise.* namespace for procedural generation, implemented entirely in Q16.16 fixed point over an integer permutation hash so a seed reproduces the exact same field on every platform and run (native/headless/wasm) — the determinism edge over float noise that drifts across CPUs. - value2 / perlin2 / simplex2 — value, gradient, and simplex noise -> [-1,1] - fbm2(x,y,seed,octaves) — fractal Brownian motion (octaves of simplex) - cellular2 / cellular2_id — Worley F1 distance + nearest-cell id - unit(n) — remap [-1,1] -> [0,1] Covers issue phases 1–2 fully plus cellular from phase 3; domain warp, ridged/ billow, and sample1/sample3 remain as follow-ups. Pure integer IR, C-free; cellular/fbm reuse the math prelude's fx_sqrt. - examples/library/noise.ludic: asserts the invariants a fixed-point generator must hold (Perlin == 0 at lattice points, every sampler within [-1,1], reproducibility, seed sensitivity, non-negative cellular distance). Wired into `x test` (now 52 passed). - docs: a new Noise section + per-symbol pages; inventory and coverage pass. - seed regenerated; `x bootstrap-cfree` fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2ddf830f0b
commit
a4f1494a04
17 changed files with 11720 additions and 10432 deletions
13
docs/language/noise/_section.md
Normal file
13
docs/language/noise/_section.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
id: noise
|
||||
title: Noise
|
||||
order: 6
|
||||
---
|
||||
|
||||
Procedural noise — the primitive that terrain, caves, biomes, textures, clouds, wind, and object placement are built on. Every generator is implemented in Q16.16 **fixed point** over an integer permutation hash seeded from an explicit seed, so a given seed reproduces the *exact* same field on every platform and every run: native, headless, and (later) wasm all agree bit-for-bit. That is a real edge over float-based engines, whose worlds can drift subtly across CPUs and break shared-seed multiplayer or replays.
|
||||
|
||||
Coordinates are <a href="type-fixed"><code>fixed</code></a> values. The integer part of a coordinate selects a lattice cell and the fraction interpolates within it, so you scale feature size by sampling at a fractional *frequency* (e.g. multiply coordinates by <code>1/64</code>). Outputs are <code>fixed</code> normalised to <code>[-1, 1]</code>; <a href="noise-unit"><code>Noise.unit</code></a> remaps that to <code>[0, 1]</code> when you want a height or a probability.
|
||||
|
||||
Pick a generator by feel: <a href="noise-value2"><code>value2</code></a> is cheap and blocky; <a href="noise-perlin2"><code>perlin2</code></a> is the classic gradient noise; <a href="noise-simplex2"><code>simplex2</code></a> is the organic default with fewer directional artifacts; <a href="noise-fbm2"><code>fbm2</code></a> stacks octaves of simplex for natural, detailed fields; and <a href="noise-cellular2"><code>cellular2</code></a> (Worley) gives Voronoi-cell structure for stone, cracks, and biome boundaries. Every sampler is a pure function of <code>(x, y, seed)</code> — no global state, no allocation — so it is safe to call across a whole worldgen pass or a per-pixel fill.
|
||||
|
||||
Seed worldgen from its own seed (or a dedicated <a href="ns-Random"><code>Random</code></a> stream), kept separate from gameplay RNG, so generating the world never desyncs the simulation.
|
||||
30
docs/language/noise/noise-cellular2.md
Normal file
30
docs/language/noise/noise-cellular2.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
id: noise-cellular2
|
||||
name: Noise.cellular2
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.cellular2
|
||||
sig: Noise.cellular2(x, y, seed) -> fixed
|
||||
tip: Worley (cellular) F1 distance to the nearest cell point.
|
||||
order: 5
|
||||
ns: Noise
|
||||
member: cellular2
|
||||
---
|
||||
|
||||
Samples **cellular (Worley) noise** at <code>(x, y)</code> and returns the F1 distance — the distance to the nearest feature point — as a <a href="type-fixed"><code>fixed</code></a> (roughly <code>[0, 1.5]</code>). Each lattice cell holds one feature point placed by its hash; scanning the 3×3 neighbourhood finds the closest one. The distance field forms Voronoi cells, which are exactly the structure you want for stone and cracked textures, biome or region boundaries, and scattered-feature layouts. Small distances mark cell centres; ridges appear where two cells meet.
|
||||
|
||||
Pair it with <a href="noise-cellular2_id"><code>Noise.cellular2_id</code></a> to also know *which* cell you are in. Deterministic in fixed point across platforms and runs.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`)
|
||||
- `seed` — the field selector
|
||||
|
||||
```ludic
|
||||
program Cellular {
|
||||
entry {
|
||||
let seed = 7
|
||||
let d = Noise.cellular2(fixed(5) / fixed(4), fixed(3) / fixed(4), seed)
|
||||
print(Math.floor(d * fixed(1000))) # distance to nearest cell point
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/noise/noise-cellular2_id.md
Normal file
31
docs/language/noise/noise-cellular2_id.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: noise-cellular2_id
|
||||
name: Noise.cellular2_id
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.cellular2_id
|
||||
sig: Noise.cellular2_id(x, y, seed) -> int
|
||||
tip: The id of the nearest Worley cell — stable per cell.
|
||||
order: 6
|
||||
ns: Noise
|
||||
member: cellular2_id
|
||||
---
|
||||
|
||||
Returns the integer **id of the nearest cell** in the same Worley diagram that <a href="noise-cellular2"><code>Noise.cellular2</code></a> measures distance in — a stable hash that is identical for every sample point inside a given cell. Use it to assign something discrete per region: the biome or material of a cell, the variant of a scattered prop, a per-region colour. Combine it with the F1 distance to shade toward cell edges.
|
||||
|
||||
The id is a hash, so treat it as an opaque label (mod it into your table of choices); it is deterministic across platforms and runs.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`)
|
||||
- `seed` — the field selector (must match the `cellular2` call it pairs with)
|
||||
|
||||
```ludic
|
||||
program CellId {
|
||||
entry {
|
||||
let seed = 7
|
||||
let id = Noise.cellular2_id(fixed(5) / fixed(4), fixed(3) / fixed(4), seed)
|
||||
let biome = Math.posmod(id, 4) # one of 4 biomes for this cell
|
||||
print(biome)
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/noise/noise-fbm2.md
Normal file
31
docs/language/noise/noise-fbm2.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: noise-fbm2
|
||||
name: Noise.fbm2
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.fbm2
|
||||
sig: Noise.fbm2(x, y, seed, octaves) -> fixed
|
||||
tip: Fractal Brownian motion — octaves of simplex, in [-1, 1].
|
||||
order: 4
|
||||
ns: Noise
|
||||
member: fbm2
|
||||
---
|
||||
|
||||
Samples **fractal Brownian motion** at <code>(x, y)</code>: it stacks <code>octaves</code> layers of <a href="noise-simplex2"><code>simplex2</code></a>, each at double the frequency and half the amplitude of the last, and normalises by the total amplitude so the result stays a <a href="type-fixed"><code>fixed</code></a> in <code>[-1, 1]</code>. Layering this way adds fine detail on top of broad shapes — the standard recipe for natural-looking terrain, clouds, and marble. More octaves means more detail (and more cost); 4–6 is typical.
|
||||
|
||||
Each octave uses a different derived seed, and the whole thing is deterministic in fixed point across platforms and runs.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`)
|
||||
- `seed` — the base field selector (each octave derives from it)
|
||||
- `octaves` — how many layers to sum (higher = more detail)
|
||||
|
||||
```ludic
|
||||
program Fbm {
|
||||
entry {
|
||||
let seed = 1337
|
||||
let h = Noise.fbm2(fixed(2) / fixed(7), fixed(9) / fixed(7), seed, 5)
|
||||
print(Math.floor(Noise.unit(h) * fixed(100))) # detailed 0..100 height
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/noise/noise-perlin2.md
Normal file
31
docs/language/noise/noise-perlin2.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: noise-perlin2
|
||||
name: Noise.perlin2
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.perlin2
|
||||
sig: Noise.perlin2(x, y, seed) -> fixed
|
||||
tip: 2D Perlin gradient noise, deterministic, in [-1, 1].
|
||||
order: 2
|
||||
ns: Noise
|
||||
member: perlin2
|
||||
---
|
||||
|
||||
Samples 2D **Perlin gradient noise** at <code>(x, y)</code> for the given <code>seed</code> and returns a <a href="type-fixed"><code>fixed</code></a> in <code>[-1, 1]</code>. Instead of a random value per lattice point, Perlin places a random gradient at each corner and interpolates their dot products with the offset vectors — which gives smoother, more natural gradients than value noise, the familiar look of classic terrain and cloud fields. By construction the value is exactly <code>0</code> at every integer lattice point.
|
||||
|
||||
Deterministic in fixed point across platforms and runs. Scale the coordinates to set feature size, and stack octaves with <a href="noise-fbm2"><code>Noise.fbm2</code></a> for richer detail.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`)
|
||||
- `seed` — the field selector
|
||||
|
||||
```ludic
|
||||
program Perlin {
|
||||
entry {
|
||||
let seed = 1337
|
||||
if Noise.perlin2(fixed(0), fixed(0), seed) == 0 { print(1) } # zero at the lattice
|
||||
let n = Noise.perlin2(fixed(10) / fixed(3), fixed(7) / fixed(3), seed)
|
||||
print(Math.floor(n * fixed(1000)))
|
||||
}
|
||||
}
|
||||
```
|
||||
30
docs/language/noise/noise-simplex2.md
Normal file
30
docs/language/noise/noise-simplex2.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
id: noise-simplex2
|
||||
name: Noise.simplex2
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.simplex2
|
||||
sig: Noise.simplex2(x, y, seed) -> fixed
|
||||
tip: 2D simplex noise, the organic default, in [-1, 1].
|
||||
order: 3
|
||||
ns: Noise
|
||||
member: simplex2
|
||||
---
|
||||
|
||||
Samples 2D **simplex noise** at <code>(x, y)</code> for the given <code>seed</code> and returns a <a href="type-fixed"><code>fixed</code></a> in <code>[-1, 1]</code>. Simplex noise sums contributions from the three corners of a skewed triangular cell, which gives it fewer of the axis-aligned directional artifacts that gradient noise can show — making it the recommended default for organic fields like terrain height and biome masks.
|
||||
|
||||
Deterministic in fixed point across platforms and runs, and the generator that <a href="noise-fbm2"><code>Noise.fbm2</code></a> layers by default. Scale the coordinates to set feature size.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`)
|
||||
- `seed` — the field selector
|
||||
|
||||
```ludic
|
||||
program Simplex {
|
||||
entry {
|
||||
let seed = 1337
|
||||
let n = Noise.simplex2(fixed(4) / fixed(9), fixed(6) / fixed(9), seed)
|
||||
print(Math.floor(Noise.unit(n) * fixed(100)))
|
||||
}
|
||||
}
|
||||
```
|
||||
27
docs/language/noise/noise-unit.md
Normal file
27
docs/language/noise/noise-unit.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
id: noise-unit
|
||||
name: Noise.unit
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.unit
|
||||
sig: Noise.unit(n) -> fixed
|
||||
tip: Remap a [-1,1] noise sample to [0,1].
|
||||
order: 7
|
||||
ns: Noise
|
||||
member: unit
|
||||
---
|
||||
|
||||
Remaps a noise sample from <code>[-1, 1]</code> to <code>[0, 1]</code> — literally <code>n / 2 + 0.5</code> — returning a <a href="type-fixed"><code>fixed</code></a>. The signed range is the natural output of the samplers, but heights, densities, probabilities, and colour ramps usually want the unsigned <code>[0, 1]</code> range; <code>unit</code> is the one-step conversion. It is a plain affine remap, so <code>unit(-1) == 0</code>, <code>unit(0) == 0.5</code>, and <code>unit(1) == 1</code>.
|
||||
|
||||
Parameters:
|
||||
- `n` — a sample in `[-1, 1]` (e.g. from `perlin2`/`simplex2`/`fbm2`)
|
||||
|
||||
```ludic
|
||||
program Unit {
|
||||
entry {
|
||||
let n = Noise.simplex2(fixed(1) / fixed(2), fixed(1) / fixed(2), 1337)
|
||||
let height = Math.floor(Noise.unit(n) * fixed(64)) # 0..64 tiles
|
||||
print(height)
|
||||
}
|
||||
}
|
||||
```
|
||||
30
docs/language/noise/noise-value2.md
Normal file
30
docs/language/noise/noise-value2.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
id: noise-value2
|
||||
name: Noise.value2
|
||||
category: noise
|
||||
kind: namespace-method
|
||||
tokens: Noise.value2
|
||||
sig: Noise.value2(x, y, seed) -> fixed
|
||||
tip: 2D value (lattice) noise, deterministic, in [-1, 1].
|
||||
order: 1
|
||||
ns: Noise
|
||||
member: value2
|
||||
---
|
||||
|
||||
Samples 2D **value noise** at <code>(x, y)</code> for the given <code>seed</code> and returns a <a href="type-fixed"><code>fixed</code></a> in <code>[-1, 1]</code>. Value noise assigns a pseudo-random value to each integer lattice point and smoothly interpolates between them with a quintic fade — the cheapest generator here, with a slightly blocky, retro character that suits low-detail height fields, dithering, and per-cell variation.
|
||||
|
||||
Because it is a pure function of <code>(x, y, seed)</code> in fixed point, the same arguments always produce the same value on every platform. Sample at a fractional frequency (scale the coordinates) to change feature size.
|
||||
|
||||
Parameters:
|
||||
- `x`, `y` — the sample coordinates (`fixed`); the integer part picks a cell
|
||||
- `seed` — the field selector; different seeds give independent worlds
|
||||
|
||||
```ludic
|
||||
program Value {
|
||||
entry {
|
||||
let seed = 1337
|
||||
let n = Noise.value2(fixed(3) / fixed(8), fixed(5) / fixed(8), seed)
|
||||
print(Math.floor(Noise.unit(n) * fixed(100))) # a 0..100 height
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue