Skip to content

Latest commit

Β 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

@zakkster/lite-noise

npm version sponsor npm bundle size npm downloads npm total downloads Zero-GC TypeScript Dependencies License: MIT

🌊 What is lite-noise?

@zakkster/lite-noise generates coherent noise for terrain, particles, animations, and procedural art β€” all deterministic and zero-allocation.

New in v1.1.0: field baking, Quilez-style domain warp, and 3D curl.

It gives you:

  • 🌊 Simplex 2D and 3D noise
  • πŸ”οΈ FBM (fractal Brownian motion) with configurable octaves
  • ⛰️ ridged2 / billow2 multifractals β€” mountains and clouds, sharing the FBM skeleton
  • πŸ” noiseLoop β€” seamless periodic 1D noise for perfect animation loops
  • 🧩 tileable2 β€” seamless tiling textures (cross-checked by lite-patternforge's seamlessScore)
  • πŸŒ€ Curl noise (2D and 3D) for smoke, fluid, and volumetric particle movement
  • πŸŒ€ Quilez-style domain warping over FBM
  • πŸ—ΊοΈ fillField2 β€” bake a Float32Array heightfield in one call, row-incremental coord stepping, optional normalize to [0,1]
  • 🧡 tileableField2 β€” bake a whole seamless multifractal field (fbm/ridged/billow) whose opposite edges wrap seamlessly (bit-exact === on a grid-aligned bake β€” power-of-two width + integer period)
  • 🎲 Seeded via an inlined Mulberry32 PRNG (deterministic, reproducible, zero runtime dependencies)
  • 0️⃣ Zero allocation in any hot-path function (unrolled FBM, no rest/spread, no per-cell object synthesis)
  • 🧹 Caller-owned output for curl2 / curl3 / warp2 (no shared reference bugs)
  • πŸ›‘οΈ Zero-alloc claim made falsifiable via @zakkster/lite-gc-profiler gates (npm run torture)
  • πŸͺΆ ~2.69 KB minified + gzipped, self-contained (zero dependencies; measured by npm run bundle-check)

Part of the @zakkster/lite-* ecosystem β€” micro-libraries built for deterministic, cache-friendly game development.

πŸš€ Install

npm i @zakkster/lite-noise

πŸ•ΉοΈ Quick Start

import {
    seedNoise,
    simplex2, simplex3,
    fbm2, fbm3,
    ridged2, billow2,
    noiseLoop, tileable2,
    curl2, curl3,
    warp2,
    fillField2, tileableField2,
} from '@zakkster/lite-noise';

// Seed for reproducibility
seedNoise(42);

// v1.3.0: ridged mountains + billow clouds (share the FBM octave skeleton)
const peak = ridged2(x * 0.01, y * 0.01, 6);
const puff = billow2(x * 0.01, y * 0.01, 6);

// v1.3.0: a perfect animation loop β€” t sweeps 0..2Ο€, closes seamlessly
const wobble = noiseLoop((frame / TOTAL) * Math.PI * 2, 1.5);

// v1.3.0: a seamless tiling texture, period 4 in noise space
const tile = tileable2(u * 4, v * 4, 4, 4);  // wraps edge-to-edge

// v1.1.0: bake a heightfield in one call, zero allocation
const heightmap = new Float32Array(256 * 256);
fillField2(heightmap, 256, 256, { scale: 0.01, octaves: 6 });

// v1.5.0: bake a SEAMLESS multifractal field. Grid-aligned here (256 is a power
// of two, period 4 is an integer) so opposite edges wrap bit-exact (===).
const tileField = new Float32Array(256 * 256);
tileableField2(tileField, 256, 256, { model: 'fbm', periodX: 4, periodY: 4, octaves: 6 });

// v1.1.0: Quilez-style domain warp β†’ richer procedural art per byte
const warped = { x: 0, y: 0 };
warp2(x * 0.01, y * 0.01, 1.5, warped);
const value = fbm2(warped.x, warped.y);

// v1.1.0: 3D curl for volumetric smoke (twelve simplex3 samples, caller-owned out)
const flow3d = { x: 0, y: 0, z: 0 };
curl3(px * 0.005, py * 0.005, pz * 0.005, flow3d);

// Fluid particles (zero-GC)
const vel = { x: 0, y: 0 };
curl2(particle.x * 0.005, particle.y * 0.005, vel);
particle.vx += vel.x * 0.5;
particle.vy += vel.y * 0.5;

🧭 Two consumers? Use createNoise

There are two ways to sample, and the difference is ownership of the permutation table.

createNoise(seed) β€” an independent field. Each instance owns its own table. Two instances are two fields that cannot disturb each other, which is what you want the moment more than one subsystem samples noise on the same page.

import { createNoise } from '@zakkster/lite-noise';

const terrain   = createNoise(42);   // its own table
const particles = createNoise(7);    // a different, independent table

terrain.fillField2(field, w, h, { scale: 0.01, octaves: 6 });
particles.curl2(x * 0.005, y * 0.005, vel);
// Neither call can change what the other samples. Reseed one with
// `particles.seed(99)` and `terrain` is untouched.

Every module function has an instance method of the same name: simplex2, simplex3, fbm2, fbm3, curl2, curl3, warp2, fillField2, plus .seed(s) to re-seed in place. An instance at seed S is byte-identical to the module functions after seedNoise(S) β€” same values, isolated ownership.

The module functions β€” one shared table. Convenient for a single consumer, but they share one module-scoped table that seedNoise rewrites for everyone:

// terrain module
seedNoise(42);
const h = simplex2(x, y);        // uses seed-42 table

// particles module (elsewhere in the same process)
seedNoise(7);                     // ← now everyone samples from seed-7
const p = simplex2(px, py);       // seed-7 table

// terrain module samples again
const h2 = simplex2(x, y);        // ← this changed silently

Under a single seed and single consumer this is invisible; the moment two consumers each own "their" seed, it isn't. seedNoise warns once (dev builds) when called more than once, naming createNoise as the fix. When in doubt, give each consumer its own createNoise(seed).

πŸ“Š Comparison

Library Size Seeded FBM Warp Curl 2D Curl 3D Field bake Zero-GC Install
simplex-noise ~8 KB No No No No No No No npm i simplex-noise
noisejs ~4 KB Yes No No No No No No npm i noisejs
lite-noise ~2.69 KB† Yes Yes Yes Yes Yes Yes Yes npm i @zakkster/lite-noise

† lite-noise's figure is minified + gzipped, self-contained β€” the full installed footprint with zero runtime dependencies (2,755 B, npm run bundle-check). The other libraries' sizes are their published bundle sizes as listed on npm. And this figure buys more: ridged/billow multifractals, seamless noiseLoop, and tileable2 on top of the columns above.

βš™οΈ API

Every sampler below exists twice: as a module function sharing one table, and as a method on a Noise instance owning its own table. Same signatures, same values at the same seed.

Instance / factory

  • createNoise(seed?) β†’ Noise β€” an independent noise field owning its own permutation table. The way to run two consumers without collision.
  • new Noise(seed?) β€” the class behind createNoise, exported for instanceof / typing.
  • noise.seed(seed) β†’ this β€” re-seed an instance in place; affects only that instance.
  • noise.simplex2 / simplex3 / fbm2 / fbm3 / ridged2 / billow2 / noiseLoop / tileable2 / curl2 / curl3 / warp2 / fillField2 / tileableField2 β€” instance methods mirroring the module functions below.

Scalar samplers

  • simplex2(x, y) β†’ number β€” 2D Simplex, approx. [-1, 1]
  • simplex3(x, y, z) β†’ number β€” 3D Simplex, approx. [-1, 1]
  • fbm2(x, y, octaves?, lacunarity?, gain?) β†’ number β€” unrolled 2D FBM, zero alloc. octaves β‰₯ 1; octaves = 0 returns 0, not NaN. gain β‰₯ 0 (per-octave amplitude decay, the standard FBM domain, typically 0..1) β€” a negative gain alternates the amplitude sign and pushes the output past ~[-1, 1]. This domain caveat is shared by ridged2 / billow2.
  • fbm3(x, y, z, octaves?, lacunarity?, gain?) β†’ number β€” unrolled 3D FBM, same octaves and gain β‰₯ 0 contract.
  • ridged2(x, y, octaves?, lacunarity?, gain?) β†’ number β€” ridged multifractal, (1 βˆ’ |simplex|)Β² per octave over the shared FBM skeleton. Sharp creases reaching the unit ceiling; range ~[0, 1], skewed high. Same octaves contract. The [0, 1] range holds for gain β‰₯ 0 (the FBM domain); a negative gain voids it (see FBM note below).
  • billow2(x, y, octaves?, lacunarity?, gain?) β†’ number β€” billow, |simplex| per octave. Soft absolute-value fold piling at zero; range ~[0, 1]. Same octaves and gain β‰₯ 0 contract.
  • noiseLoop(t, radius?) β†’ number β€” seamless periodic 1D noise on a circle of radius radius (default 1). noiseLoop(0) === noiseLoop(2Ο€) exactly and the derivative matches at the seam β€” drive t over 0..2Ο€ for a perfect loop. t is reduced mod 2Ο€.
  • tileable2(x, y, periodX, periodY) β†’ number β€” tileable 2D noise over [0, periodX) Γ— [0, periodY). Opposite edges are byte-identical (tileable2(0, y) === tileable2(periodX, y)), so tiles are seamless by construction. Four simplex2 samples; the blend narrows the extremes slightly inside [-1, 1]. Precondition: periodX, periodY > 0 β€” a period of 0 divides by zero and returns a non-finite value (NaN or Β±Infinity) (unguarded on the hot path; a zero tile size is a caller error, not a data value).

Vector samplers (caller-owned out)

  • curl2(x, y, out) β†’ out β€” divergence-free 2D vector. out = { x, y }. Typical magnitude for scale ~0.005 inputs: mean |v| β‰ˆ 3.4. Scale before wiring to particle velocities.
  • curl3(x, y, z, out) β†’ out β€” divergence-free 3D vector via Bridson-style offset vector-potential (twelve simplex3 samples). out = { x, y, z }. Typical mean |v| β‰ˆ 3.8, max β‰ˆ 10.6. Divergence residual ~0.6 % of |v| β€” finite-difference truncation floor.
  • warp2(x, y, strength, out) β†’ out β€” Quilez-style domain warp; writes warped coords into out = { x, y }. Compose with fbm2(out.x, out.y). strength = 0 returns the input unchanged.

Field bake

  • fillField2(out, w, h, opts?) β†’ out β€” bake a w Γ— h FBM heightfield into a caller-supplied Float32Array / Float64Array. opts (all optional): scale, octaves, lacunarity, gain, ox, oy, normalize. Row-incremental coord stepping (no per-cell multiplies), zero allocation. The raw fill is amplitude-normalised but ~[-0.84, 0.82] at the defaults; normalize: true does a second in-place pass to exact [0, 1] (a colour ramp usually wants this). out is caller-owned and written start-to-end β€” don't alias it with anything read during the call.
  • tileableField2(out, w, h, opts) β†’ out β€” bake a seamless, multifractal w Γ— h field into a caller-supplied Float32Array / Float64Array. It sums octaves of tileable2 at harmonic periods and computes the per-cell coordinate by multiply. Exact-wrap precondition: opposite edges wrap bit-exact (===) when the grid is aligned β€” w * (periodX/w) === periodX in float64, i.e. a power-of-two width with an integer period (the safe seamless recipe); for non-aligned dims the seam is seamless only to within float epsilon (~1e-14). Grid alignment alone governs this β€” not lacunarity/gain. opts: model ('fbm' | 'ridged' | 'billow', default 'fbm'), required periodX/periodY, plus optional octaves, lacunarity, gain, scale, ox, oy, normalize. Fails closed at setup, before out is written: an unknown model throws a RangeError naming the valid set, and a non-finite or non-positive periodX/periodY (including an omitted required period, or Infinity) throws. Zero allocation once options are read; normalize: true remaps to exact [0, 1] as in fillField2.

Seed

  • seedNoise(seed?) β€” build the shared module permutation table. Call once, or call again to re-seed. Auto-seeded with 0 on module load. Warns once in dev builds if called more than once (silent under NODE_ENV === 'production'); for independent fields use createNoise instead.

πŸ›‘οΈ Zero-GC β€” falsifiable, not asserted

The zero-allocation claim on every hot path (simplex2, simplex3, fbm2, fbm3, ridged2, billow2, noiseLoop, tileable2, curl2, curl3, warp2, and the fillField2 / tileableField2 bakes incl. normalize) β€” across both the module functions and the instance methods β€” is gated by @zakkster/lite-gc-profiler:

npm run torture

Point samplers use measureOps / checkOps with stabilize: true so heap deltas reflect the surviving-allocation delta (retention), not transient churn. Rules: maxBytesPerOp: 2 (V8's inline-cache / feedback-vector noise floor per the profiler docs) plus maxMajorsPerKOp: 0 β€” a real allocation crosses both bars, V8 noise crosses neither. Heavy fillField2 bakes are gated on major-GC count and ArrayBuffer retention instead. NOISE_TORTURE_BREAK=1 npm run torture injects a leak and must exit non-zero β€” proof the gate can bite.

🎯 Determinism goldens

seed 42 produces byte-identical fields across versions unless a kernel change is deliberate. The test suite commits FNV-1a hashes of three baked fields:

  • 256Γ—256 default fillField2 β†’ ddef5970
  • 128Γ—128 warp2 + fbm2 β†’ ca4f9f1e
  • 32Γ—32Γ—8 curl3 slab β†’ 1ac7a518
  • 128Γ—128 ridged2 β†’ 2342c230
  • 128Γ—128 billow2 β†’ acf96355
  • 64Γ—64 period-4 tileable2 β†’ b6d00662
  • 720-sample noiseLoop β†’ 2cfa58f8
  • 64Γ—64 period-4 tileableField2 fbm β†’ 8f34c3b8, ridged β†’ e117b1a8, billow β†’ b5d78012

Any change to those numbers is a breaking change and requires a CHANGELOG entry. Regenerate via npm run goldens when the change is intentional.

πŸ”— Ecosystem recipes

Runnable, CI-tested integration recipes live in examples/ (not shipped in the tarball). Each adds zero runtime dependency β€” the peers are dev-only, and each recipe holds its own createNoise instance, so two on one page never collide (the reason the instance API came first).

  • curl2 β†’ @zakkster/lite-particles (curl-advection.mjs) β€” advect particles through a curl flow field. Needs no API from either side: an onUpdate(p, dt) hook samples curl2 into a pre-allocated {x,y} and writes p.vx/p.vy. Both sides are 0 B/call, so the loop is zero-alloc.
  • fillField2 β†’ @zakkster/lite-gl (field-to-gl.mjs) β€” a baked Float32Array is a GL instance buffer. Packs one LAYOUT.POINT per cell (stride 8, and LAYOUT.POINT === lite-particles POINT_STRIDE) and proves the handoff with a bit-exact round-trip. The live GPU upload/readback is lite-gl's own tested territory.
  • Curl ambient behavior (ambient-curl.mjs) β€” a registerBehavior('CURL', …) whose tick advances particles through a curl field. Registration + physics are CI-tested headless; the canvas render is browser-only. ambient-fx keeps its zero-dep pledge β€” a recipe, not a dependency.
  • tileableField2 β†’ @zakkster/lite-gradient-studio (tileable-to-gradient.mjs) β€” paint a seamless tile through a gradient LUT. A grid-aligned tileableField2 field is bit-exact seamless; bakeGradientToLut β†’ sampleLut is a pure per-cell map, so the coloured tile inherits the exact wrap. Proven headless by a zero colour-seam-gap on the wrap column (fbm/ridged/billow); the returned RGBA-LE Uint32Array is ImageData-ready. The recipe fails closed on a non-grid-aligned bake rather than ship an epsilon-off seam.

N1 composability is asserted at the integration level: two instance-backed flows interleaved in one process produce byte-identical trajectories to each run alone. npm test runs all of this against the installed peers.

πŸ§ͺ Benchmark

npm run bench

Measures three ways to bake a 256Γ—256, 6-octave FBM heightfield (naive alloc-per-bake, row-step reused buffer, fillField2), median-of-20 with a 3-rep warmup. Bench output carries a machine stamp (Node version, platform, CPU) so numbers trace back to the hardware they were measured on.

The dominant cost is ~393K simplex2 calls per bake (256 Γ— 256 Γ— 6 octaves). Saved two-multiplies-per-cell register as ~1.03–1.07Γ— against that β€” the row-step optimisation is numerically clean (drift < Float32 epsilon over 512Γ—512) but not a headline speedup. The real win of fillField2 is API surface (one call, no user-written loop) and buffer reuse.

πŸ“¦ TypeScript

Full declarations in Noise.d.ts β€” includes Vec2, Vec3, and FillField2Options.

πŸ“š LLM-Friendly Documentation

See llms.txt for AI-optimized metadata and usage examples.

License

MIT. Copyright (c) Zahary Shinikchiev.

About

Zero-GC seeded Simplex 2D/3D noise + FBM + Curl. Deterministic via seeded permutation table.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages