Proposal: Noise generators standard library (Noise.* — Perlin/Simplex/Cellular, fBm, domain warp), deterministic fixed-point #3

Closed
opened 2026-08-29 18:13:00 +02:00 by orkun · 1 comment
Owner

Context

Procedural generation is a core game-dev tool — terrain, caves, biomes, textures, clouds, wind, placement — and it all rests on noise. Ludic has none today. This proposes a Noise.* standard-library namespace, researched against FastNoiseLite / FastNoise2 (the de-facto game noise library, also what Godot ships as FastNoiseLite) and OpenSimplex2.

Determinism is the differentiator. Implemented in fixed-point with integer permutation tables seeded from a given seed, Ludic noise is bit-identical on every platform — so a seed reproduces the exact same world in a native build, a headless run, and (later) wasm. That is a genuine edge over float-based engines whose worlds can drift across platforms.

Noise types (FastNoiseLite set)

Type Use
value blocky/retro aesthetics, cheap
perlin classic gradient noise
simplex / simplex_smooth (OpenSimplex2) recommended default — organic terrain/biomes, fewer directional artifacts
cellular / worley Voronoi cells — structure boundaries, cracks, biomes, stone; returns distance(s) F1/F2 and a cell id
white / hash per-cell random values, stateless

Fractal layering (fBm) and warping

  • Octaves stacked at rising frequency: octaves, lacunarity (freq step), gain/persistence (amplitude step); variants fBm, ridged, billow.
  • Domain warp (progressive + independent) for rivers, caves, marble.

API sketch

# a configured generator handle (deterministic)
let terrain = Noise.new(kind: Noise.Simplex, seed: 1337, frequency: fx_ratio(1, 64),
                        octaves: 4, lacunarity: fx(2), gain: fx_ratio(1, 2))
let h = terrain.sample2(x, y)          # -> fixed in [-1, 1]

# one-shot helpers
let n  = Noise.simplex2(x, y, seed: 1337)
let c  = Noise.cellular2(x, y, seed: 7)     # -> { dist: fixed, id: int }
let w  = Noise.fbm2(x, y, octaves: 5)
let d  = Noise.warp2(x, y)                   # domain-warped coordinate
  • 2D is primary (a 2D engine); offer sample1/sample3 too (sample3 = 2D + time for animated fields, or caves).
  • Output is fixed, normalized to [-1, 1] (with a [0,1] helper Noise.unit(...)).
  • Seeded from an explicit seed or from Random.stream("worldgen") (see #2) so worldgen RNG never desyncs gameplay RNG.

Game use-cases

Terrain heightmaps, cave systems (3D/ridged), biome maps (cellular), texture/detail, cloud/fog fields, wind and screen-shake modulation, procedural placement (trees/loot) — combined with chunk streaming, enough for infinite worlds.

Phasing

  1. value2 / perlin2 / simplex2 + seed.
  2. fBm (octaves/lacunarity/gain) + Noise.new handle.
  3. cellular2 (Worley) + ridged/billow.
  4. domain warp, sample3, sample1.

References

Determinism-first fixed-point noise, tailored to reproducible procedural worlds.

## Context Procedural generation is a core game-dev tool — terrain, caves, biomes, textures, clouds, wind, placement — and it all rests on **noise**. Ludic has none today. This proposes a `Noise.*` standard-library namespace, researched against **FastNoiseLite / FastNoise2** (the de-facto game noise library, also what **Godot** ships as `FastNoiseLite`) and OpenSimplex2. > **Determinism is the differentiator.** Implemented in **fixed-point with integer permutation tables seeded from a given seed**, Ludic noise is **bit-identical on every platform** — so a seed reproduces the exact same world in a native build, a headless run, and (later) wasm. That is a genuine edge over float-based engines whose worlds can drift across platforms. ## Noise types (FastNoiseLite set) | Type | Use | |---|---| | `value` | blocky/retro aesthetics, cheap | | `perlin` | classic gradient noise | | `simplex` / `simplex_smooth` (OpenSimplex2) | **recommended default** — organic terrain/biomes, fewer directional artifacts | | `cellular` / `worley` | Voronoi cells — structure boundaries, cracks, biomes, stone; returns distance(s) `F1`/`F2` and a cell id | | `white` / `hash` | per-cell random values, stateless | ## Fractal layering (fBm) and warping - **Octaves** stacked at rising frequency: `octaves`, `lacunarity` (freq step), `gain`/`persistence` (amplitude step); variants **fBm**, **ridged**, **billow**. - **Domain warp** (progressive + independent) for rivers, caves, marble. ## API sketch ``` # a configured generator handle (deterministic) let terrain = Noise.new(kind: Noise.Simplex, seed: 1337, frequency: fx_ratio(1, 64), octaves: 4, lacunarity: fx(2), gain: fx_ratio(1, 2)) let h = terrain.sample2(x, y) # -> fixed in [-1, 1] # one-shot helpers let n = Noise.simplex2(x, y, seed: 1337) let c = Noise.cellular2(x, y, seed: 7) # -> { dist: fixed, id: int } let w = Noise.fbm2(x, y, octaves: 5) let d = Noise.warp2(x, y) # domain-warped coordinate ``` - 2D is primary (a 2D engine); offer `sample1`/`sample3` too (`sample3` = 2D + time for animated fields, or caves). - Output is `fixed`, normalized to `[-1, 1]` (with a `[0,1]` helper `Noise.unit(...)`). - Seeded from an explicit seed or from `Random.stream("worldgen")` (see #2) so worldgen RNG never desyncs gameplay RNG. ## Game use-cases Terrain heightmaps, cave systems (3D/ridged), biome maps (cellular), texture/detail, cloud/fog fields, wind and screen-shake modulation, procedural placement (trees/loot) — combined with chunk streaming, enough for infinite worlds. ## Phasing 1. `value2` / `perlin2` / `simplex2` + seed. 2. fBm (`octaves`/`lacunarity`/`gain`) + `Noise.new` handle. 3. `cellular2` (Worley) + `ridged`/`billow`. 4. domain warp, `sample3`, `sample1`. ## References - [FastNoiseLite documentation](https://github.com/Auburn/FastNoiseLite/wiki/Documentation) and [FastNoise2 — Understanding Noise Types](https://github.com/Auburn/FastNoise2/wiki/Understanding-Noise-Types) - [Godot `FastNoiseLite`](https://docs.godotengine.org/en/stable/classes/class_fastnoiselite.html) - Related: `Random.stream` in #2; `fixed`/`vec2` in #1. _Determinism-first fixed-point noise, tailored to reproducible procedural worlds._
orkun added the
proposal
priority:medium
area:stdlib
labels 2026-08-29 19:51:36 +02:00
Author
Owner

Done in commit a4f1494.

Added the Noise.* namespace — deterministic, fixed-point (Q16.16) procedural noise over an integer permutation hash seeded from an explicit seed, so a seed reproduces the exact same field on every platform and run (the determinism edge this issue calls for):

  • Noise.value2(x, y, seed) — value/lattice noise
  • Noise.perlin2(x, y, seed) — classic gradient noise (exactly 0 at integer lattice points)
  • Noise.simplex2(x, y, seed) — organic simplex noise (the recommended default)
  • Noise.fbm2(x, y, seed, octaves) — fractal Brownian motion (octaves of simplex, lacunarity 2 / gain 0.5, normalised)
  • Noise.cellular2(x, y, seed) — Worley F1 distance; Noise.cellular2_id(...) — nearest-cell id
  • Noise.unit(n) — remap [-1,1] to [0,1]

All pure integer IR, C-free; outputs are fixed in [-1,1]. cellular/fbm reuse the math prelude's fx_sqrt.

Phasing:

  • Phase 1 — value2 / perlin2 / simplex2 + seed
  • Phase 2 — fBm (octaves/lacunarity/gain)
  • Phase 3 — cellular2 (Worley F1 + id); ridged/billow not yet
  • Phase 4 — domain warp, sample1/sample3

Also not yet: the stateful Noise.new(...) generator handle (the one-shot helpers cover the same ground for now). These remain as follow-ups.

Tests: examples/library/noise.ludic asserts the invariants a fixed-point generator must hold — Perlin == 0 at lattice points, every sampler stays within [-1,1], reproducibility, seed sensitivity, non-negative cellular distance — wired into x test. Docs: new docs/language/noise/ section. (A float reference can't be matched bit-for-bit — that difference is the whole point — so tests assert structural invariants + determinism.)

Done in commit `a4f1494`. Added the `Noise.*` namespace — deterministic, fixed-point (Q16.16) procedural noise over an integer permutation hash seeded from an explicit seed, so a seed reproduces the **exact same field** on every platform and run (the determinism edge this issue calls for): - `Noise.value2(x, y, seed)` — value/lattice noise - `Noise.perlin2(x, y, seed)` — classic gradient noise (exactly 0 at integer lattice points) - `Noise.simplex2(x, y, seed)` — organic simplex noise (the recommended default) - `Noise.fbm2(x, y, seed, octaves)` — fractal Brownian motion (octaves of simplex, lacunarity 2 / gain 0.5, normalised) - `Noise.cellular2(x, y, seed)` — Worley F1 distance; `Noise.cellular2_id(...)` — nearest-cell id - `Noise.unit(n)` — remap [-1,1] to [0,1] All pure integer IR, C-free; outputs are `fixed` in [-1,1]. cellular/fbm reuse the math prelude's `fx_sqrt`. **Phasing:** - [x] Phase 1 — value2 / perlin2 / simplex2 + seed - [x] Phase 2 — fBm (octaves/lacunarity/gain) - [x] Phase 3 — cellular2 (Worley F1 + id); ridged/billow not yet - [ ] Phase 4 — domain warp, sample1/sample3 Also not yet: the stateful `Noise.new(...)` generator handle (the one-shot helpers cover the same ground for now). These remain as follow-ups. Tests: `examples/library/noise.ludic` asserts the invariants a fixed-point generator must hold — Perlin == 0 at lattice points, every sampler stays within [-1,1], reproducibility, seed sensitivity, non-negative cellular distance — wired into `x test`. Docs: new `docs/language/noise/` section. (A float reference can't be matched bit-for-bit — that difference is the whole point — so tests assert structural invariants + determinism.)
orkun closed this issue 2026-08-30 20:58:59 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#3
No description provided.