Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# @suaveplan/noise - Seeded spatial noise fields: Perlin, Simplex, Value, Worley, fBm and Blue-noise sampling for procedural generation
## Summary
Seeded spatial noise fields for procedural content generation. Provides six field types — Perlin, Simplex (2D/3D/4D), Value, Worley/cellular (F1/F2/F2-F1 modes), fBm (fractal Brownian motion compositor), and Poisson-disk Blue noise sampling — all seeded with `Sfc32Rng` for byte-identical, reproducible output. Every generator is available as both a functional API and a stateful class. Use this package for terrain heightmaps, texture generation, cloud simulation, vegetation scattering, and any procedural system that needs spatially-coherent randomness. For non-spatial random values (UUIDs, shuffles, distributions) use `@suaveplan/random`; for seeded integer/float sequences use `@suaveplan/pseudo-random`.
<!-- begin:auto-docs -->
## Documentation tree
- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_- [blue](./src/blue/blue.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noise-error](./src/errors/noise-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fbm](./src/fbm/fbm.md) — <!-- begin:auto-docs --> _(no submodules)_- [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [perlin](./src/perlin/perlin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simplex](./src/simplex/simplex.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [permutation](./src/utils/permutation.md) — <!-- begin:auto-docs --> _(no submodules)_- [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_ - [value](./src/value/value.md) — <!-- begin:auto-docs --> _(no submodules)_- [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worley](./src/worley/worley.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```sh
bun add @suaveplan/noise
```
## Features
- **Perlin noise** — `perlin2` / `perlin3` (functional) + `PerlinNoise` (class)
- **Simplex noise** — `simplex2` / `simplex3` / `simplex4` + `SimplexNoise`
- **Value noise** — `value2` / `value3` + `ValueNoise`
- **Worley/cellular noise** — `worley2` / `worley3` with `F1`/`F2`/`F2-F1` modes
- **fBm compositor** — `fbm2` / `fbm3` wrap any base field into a multi-octave sum
- **Blue noise sampler** — `blueNoiseSample` via Bridson's Poisson-disk algorithm
- Every field is seeded with `Sfc32Rng` — byte-identical output for equal seeds
- Zod schemas for every option bundle
## Quick start
```ts
import { PerlinNoise, fbm2, blueNoiseSample } from "@suaveplan/noise";
const p = new PerlinNoise("terrain");
const height = fbm2((x, y) => p.noise2(x, y), 10, 20, { octaves: 6 });
const trees = blueNoiseSample({ width: 512, height: 512, radius: 16, seed: 1 });
```
## Subpath exports
- `@suaveplan/noise/perlin`
- `@suaveplan/noise/simplex`
- `@suaveplan/noise/value`
- `@suaveplan/noise/worley`
- `@suaveplan/noise/fbm`
- `@suaveplan/noise/blue`
- `@suaveplan/noise/errors`
## Module docs
See the co-located `.md` next to each implementation.

View File

@@ -0,0 +1,101 @@
{
"name": "@suaveplan/noise",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Seeded spatial noise fields: Perlin, Simplex, Value, Worley, fBm and Blue-noise sampling for procedural generation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./perlin": {
"import": {
"types": "./dist/perlin/perlin.d.ts",
"default": "./dist/perlin/perlin.js"
}
},
"./simplex": {
"import": {
"types": "./dist/simplex/simplex.d.ts",
"default": "./dist/simplex/simplex.js"
}
},
"./value": {
"import": {
"types": "./dist/value/value.d.ts",
"default": "./dist/value/value.js"
}
},
"./worley": {
"import": {
"types": "./dist/worley/worley.d.ts",
"default": "./dist/worley/worley.js"
}
},
"./fbm": {
"import": {
"types": "./dist/fbm/fbm.d.ts",
"default": "./dist/fbm/fbm.js"
}
},
"./blue": {
"import": {
"types": "./dist/blue/blue.d.ts",
"default": "./dist/blue/blue.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/noise-error.d.ts",
"default": "./dist/errors/noise-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/pseudo-random": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,53 @@
# blue/blue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `blue` module implements Robert Bridson's fast Poisson-disk sampling algorithm to generate an approximately blue-noise distribution of points in a 2D rectangle. Blue noise's defining property is that every pair of samples is at least `radius` apart, while their positions appear random — it is the gold standard for anti-aliased dithering, stippling, foliage placement, and any sampling application where regular grids produce visible artefacts.
## Features
- `blueNoiseSample({ width, height, radius, k?, maxSamples?, seed? })`
- Seeded with `Sfc32Rng` for reproducible output
- Bridson's spatial grid for O(n) amortised point insertion
- `maxSamples` guard converts runaway parameters into a typed `NoiseError` instead of an out-of-memory crash
## Usage: Basic Example
```ts
import { blueNoiseSample } from "@suaveplan/noise";
const dots = blueNoiseSample({ width: 400, height: 300, radius: 10, seed: 42 });
```
## Usage: Advanced Example
```ts
import { blueNoiseSample } from "@suaveplan/noise";
const trees = blueNoiseSample({
width: 2048,
height: 2048,
radius: 32, // minimum canopy spacing
k: 50, // higher k = denser packing
maxSamples: 20_000,
seed: "forest-2026-04-19",
});
for (const [x, y] of trees) plantTree(x, y);
```
## API Reference
- `BlueNoiseOptions`:
- `width`, `height`: rectangle size (must be positive finite)
- `radius`: minimum spacing between samples (positive finite)
- `k`: candidate neighbours per active sample (default 30)
- `maxSamples`: hard ceiling (default 100 000) — throws `NoiseError.BLUE.MAX_SAMPLES_EXCEEDED` if exceeded
- `seed`: `string | number` passed through to `Sfc32Rng` (default 0)
- `blueNoiseSample(opts): Array<[number, number]>`
## Implementation Notes
A spatial background grid with `cellSize = radius / √2` guarantees at most one sample per cell, so the neighbour check only has to scan a 5×5 window (the 2-cell radius is the Bridson-recommended value). The `maxSamples` guard protects against the common user error of specifying a tiny radius relative to the rectangle — without it, `O(area/radius²)` can explode. `k` above 30 gives denser packing but longer runtimes; 30 is the paper's recommended value.

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoiseError } from "../errors/noise-error.js";
import { blueNoiseSample } from "./blue.js";
describe("blueNoiseSample", () => {
it("respects minimum spacing", { timeout: 5000 }, () => {
const pts = blueNoiseSample({
width: 100,
height: 100,
radius: 8,
seed: 1,
});
expect(pts.length).toBeGreaterThan(0);
for (let i = 0; i < pts.length; i += 1) {
for (let j = i + 1; j < pts.length; j += 1) {
const a = pts[i] as [number, number];
const b = pts[j] as [number, number];
const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
expect(d).toBeGreaterThanOrEqual(8 - 1e-9);
}
}
});
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
const a = blueNoiseSample({
width: 50,
height: 50,
radius: 5,
seed: 1,
});
const b = blueNoiseSample({
width: 50,
height: 50,
radius: 5,
seed: 1,
});
expect(a).toEqual(b);
});
it("rejects invalid args", { timeout: 5000 }, () => {
expect(() =>
blueNoiseSample({ width: 0, height: 10, radius: 1 })
).toThrow(NoiseError);
expect(() =>
blueNoiseSample({ width: 10, height: -1, radius: 1 })
).toThrow(NoiseError);
expect(() =>
blueNoiseSample({ width: 10, height: 10, radius: 0 })
).toThrow(NoiseError);
});
it("enforces maxSamples guard", { timeout: 5000 }, () => {
expect(() =>
blueNoiseSample({
width: 100,
height: 100,
radius: 1,
maxSamples: 5,
seed: 2,
})
).toThrow(NoiseError);
});
});

View File

@@ -0,0 +1,114 @@
import { Sfc32Rng } from "@suaveplan/pseudo-random";
import { NOISE_CODES, NoiseError } from "../errors/noise-error.js";
export interface BlueNoiseOptions {
width: number;
height: number;
/** Minimum spacing between samples (in pixels). */
radius: number;
/** How many neighbour candidates per active sample. Default 30. */
k?: number;
/** Hard ceiling on the number of samples; guards against runaways. */
maxSamples?: number;
seed?: string | number;
}
/**
* Bridson-style Poisson-disk sampler producing approximately blue-noise
* distributed 2D points within a rectangle.
*/
export function blueNoiseSample(
opts: BlueNoiseOptions
): Array<[number, number]> {
const { width, height, radius } = opts;
const k = opts.k ?? 30;
const maxSamples = opts.maxSamples ?? 100_000;
const seed = opts.seed ?? 0;
if (!Number.isFinite(width) || width <= 0) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.blue.invalid_width",
{ context: { width } }
);
}
if (!Number.isFinite(height) || height <= 0) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.blue.invalid_height",
{ context: { height } }
);
}
if (!Number.isFinite(radius) || radius <= 0) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.blue.invalid_radius",
{ context: { radius } }
);
}
const rng = new Sfc32Rng(seed);
const cellSize = radius / Math.SQRT2;
const cols = Math.ceil(width / cellSize);
const rows = Math.ceil(height / cellSize);
const grid: Array<[number, number] | null> = new Array(cols * rows).fill(
null
);
const samples: Array<[number, number]> = [];
const active: Array<[number, number]> = [];
const first: [number, number] = [
rng.nextFloat() * width,
rng.nextFloat() * height,
];
samples.push(first);
active.push(first);
const fx = Math.floor(first[0] / cellSize);
const fy = Math.floor(first[1] / cellSize);
grid[fy * cols + fx] = first;
while (active.length > 0) {
if (samples.length > maxSamples) {
throw new NoiseError(
NOISE_CODES["BLUE.MAX_SAMPLES_EXCEEDED"],
"noise.blue.max_samples_exceeded",
{ context: { maxSamples } }
);
}
const idx = rng.nextUint32() % active.length;
const base = active[idx] as [number, number];
let found = false;
for (let i = 0; i < k; i += 1) {
const angle = rng.nextFloat() * 2 * Math.PI;
const mag = radius * (1 + rng.nextFloat());
const px = base[0] + Math.cos(angle) * mag;
const py = base[1] + Math.sin(angle) * mag;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
const cx = Math.floor(px / cellSize);
const cy = Math.floor(py / cellSize);
let ok = true;
for (let dy = -2; dy <= 2 && ok; dy += 1) {
for (let dx = -2; dx <= 2 && ok; dx += 1) {
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
const neighbour = grid[ny * cols + nx];
if (!neighbour) continue;
if (
Math.hypot(px - neighbour[0], py - neighbour[1]) <
radius
)
ok = false;
}
}
if (ok) {
const pt: [number, number] = [px, py];
samples.push(pt);
active.push(pt);
grid[cy * cols + cx] = pt;
found = true;
break;
}
}
if (!found) {
active.splice(idx, 1);
}
}
return samples;
}

View File

@@ -0,0 +1 @@
export { type BlueNoiseOptions, blueNoiseSample } from "./blue";

View File

@@ -0,0 +1,14 @@
/** Default number of octaves for fBm compositors. */
export const DEFAULT_FBM_OCTAVES = 4;
/** Default lacunarity for fBm compositors. */
export const DEFAULT_FBM_LACUNARITY = 2;
/** Default gain for fBm compositors. */
export const DEFAULT_FBM_GAIN = 0.5;
/** Default `k` for Bridson Poisson-disk sampling. */
export const DEFAULT_BLUE_K = 30;
/** Default `maxSamples` ceiling for Bridson Poisson-disk sampling. */
export const DEFAULT_BLUE_MAX_SAMPLES = 100_000;

View File

@@ -0,0 +1 @@
export { NOISE_CODES, type NoiseCode, NoiseError } from "./noise-error";

View File

@@ -0,0 +1,65 @@
# noise-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `noise-error` module exports the `NoiseError` class and `NOISE_CODES` registry used by every public function in `@suaveplan/noise`. Centralising validation failures here keeps upstream error handling simple: callers only need to match on a single `NOISE.*` code prefix regardless of which noise function raised the error.
## Features
- Stable, namespaced error codes via `defineCodes("NOISE", [...])`
- `NoiseError` extends `SuaveplanError<NoiseCode>` with the usual cause-chain, redaction, and OTel integrations
- Coverage for argument validation, blue-noise termination guards and fBm octave validation
## Usage: Basic Example
```ts
import { NoiseError, NOISE_CODES } from "@suaveplan/noise/errors";
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.arg.invalid",
{ context: { field: "frequency" } },
);
```
## Usage: Advanced Example
```ts
import { NoiseError, NOISE_CODES } from "@suaveplan/noise/errors";
try {
// ... sample blue noise over a huge radius ...
} catch (err) {
if (err instanceof NoiseError && err.code === NOISE_CODES["BLUE.MAX_SAMPLES_EXCEEDED"]) {
// back off and retry with a larger spacing
}
throw err;
}
```
## API Reference
- `NOISE_CODES` — frozen registry.
- `NoiseError(code, messageKey, options?)` — structured error with `context`, `cause`, `traceId`, `clock` options.
## Implementation Notes
All codes sit under the `NOISE.*` namespace so they don't collide with `@suaveplan/pseudo-random` or `@suaveplan/random` codes. Adding new codes is strictly additive — removing them requires a major version bump.
### Subclasses & Codes
`NoiseError` is the `instanceof` pivot for every error thrown by `@suaveplan/noise`. Concrete subclasses cover invalid seed, invalid octave count, out-of-range frequency, and invalid amplitude. Every subclass carries a stable code from the `NOISE` namespace (`NOISE.INVALID_SEED`, `NOISE.OCTAVES.OUT_OF_RANGE`, etc.) so callers can type-narrow on `error.code` without string-matching the message. Inherits wire serialisation and OTel span-event support from `SuaveplanError`.
## Further Usage: Basic Example
```ts
import { NoiseError } from "@suaveplan/noise/errors/noise-error";
try {
throw new NoiseError("invalid seed", { code: "NOISE.INVALID_SEED" });
} catch (e) {
if (e instanceof NoiseError) console.log(e.code);
}
```

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NOISE_CODES, NoiseError } from "./noise-error.js";
describe("NoiseError", () => {
it("carries namespaced code", { timeout: 5000 }, () => {
const err = new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.arg.invalid"
);
expect(err.code).toBe("NOISE.ARG.INVALID");
expect(err).toBeInstanceOf(NoiseError);
});
it("accepts context + cause", { timeout: 5000 }, () => {
const cause = new Error("x");
const err = new NoiseError(
NOISE_CODES["FBM.INVALID_OCTAVES"],
"noise.fbm.invalid_octaves",
{ context: { octaves: 0 }, cause }
);
expect(err.context).toEqual({ octaves: 0 });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,25 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NOISE_CODES = defineCodes("NOISE", [
"ARG.INVALID",
"ARG.OUT_OF_RANGE",
"BLUE.MAX_SAMPLES_EXCEEDED",
"FBM.INVALID_OCTAVES",
] as const);
export type NoiseCode = (typeof NOISE_CODES)[keyof typeof NOISE_CODES];
export class NoiseError extends SuaveplanError<NoiseCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: NoiseCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,49 @@
# fbm/fbm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `fbm` module composes Fractional Brownian Motion on top of any base noise function. It layers `octaves` copies of the base at geometrically increasing frequencies (`lacunarity`) and geometrically decreasing amplitudes (`gain`), yielding the classical cloud/terrain/texture multi-scale roughness pattern that underlies most procedurally generated content.
Because `fbm2`/`fbm3` take the base function as a parameter, you can layer any of `perlin2`, `simplex2`, `value2`, or even Worley distances on top of fBm — the module has no coupling to a specific noise kind.
## Features
- `fbm2(base, x, y, opts?)` — 2D compositor
- `fbm3(base, x, y, z, opts?)` — 3D compositor
- Configurable `octaves`, `lacunarity`, `gain`, `frequency`, `amplitude`
- Argument validation via `NoiseError.ARG.INVALID` / `FBM.INVALID_OCTAVES`
## Usage: Basic Example
```ts
import { fbm2, PerlinNoise } from "@suaveplan/noise";
const p = new PerlinNoise("world");
const h = fbm2((x, y) => p.noise2(x, y), 1.5, 2.3);
```
## Usage: Advanced Example
```ts
import { fbm3, SimplexNoise } from "@suaveplan/noise";
const s = new SimplexNoise("clouds");
const v = fbm3(
(x, y, z) => s.noise3(x, y, z),
0.1, 0.2, 0.3,
{ octaves: 6, lacunarity: 2.0, gain: 0.5, frequency: 1, amplitude: 1 },
);
```
## API Reference
- `FbmOptions` — every field optional: `octaves` (default 4, must be an integer 1–32), `lacunarity` (default 2), `gain` (default 0.5, must be in (0, 1]), `frequency` (default 1), `amplitude` (default 1).
- `fbm2(base, x, y, opts?): number`
- `fbm3(base, x, y, z, opts?): number`
## Implementation Notes
Clamping `octaves` to 1–32 avoids pathological input (negative or absurdly large numbers of loop iterations). `gain > 1` would diverge rather than converge, so it's rejected; `gain` exactly 1 means every octave contributes equally, which is useful for certain stylised effects but rejected here to keep the numeric output bounded — if you need that, call the base function manually in a loop.

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NoiseError } from "../errors/noise-error.js";
import { PerlinNoise } from "../perlin/perlin.js";
import { fbm2, fbm3 } from "./fbm.js";
describe("fbm", () => {
const p = new PerlinNoise(1);
const base2 = (x: number, y: number): number => p.noise2(x, y);
const base3 = (x: number, y: number, z: number): number =>
p.noise3(x, y, z);
it("fbm2 returns a finite number", { timeout: 5000 }, () => {
expect(Number.isFinite(fbm2(base2, 0.3, 0.5))).toBe(true);
});
it("fbm3 returns a finite number", { timeout: 5000 }, () => {
expect(Number.isFinite(fbm3(base3, 0.3, 0.5, 0.7))).toBe(true);
});
it("is deterministic given a deterministic base", { timeout: 5000 }, () => {
expect(fbm2(base2, 0.1, 0.2, { octaves: 3 })).toBe(
fbm2(base2, 0.1, 0.2, { octaves: 3 })
);
});
it("rejects invalid options", { timeout: 5000 }, () => {
expect(() => fbm2(base2, 0, 0, { octaves: 0 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { octaves: 33 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { lacunarity: 0 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { gain: 0 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { gain: 2 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { frequency: 0 })).toThrow(NoiseError);
expect(() => fbm2(base2, 0, 0, { amplitude: Number.NaN })).toThrow(
NoiseError
);
});
});

View File

@@ -0,0 +1,98 @@
import { NOISE_CODES, NoiseError } from "../errors/noise-error.js";
export interface FbmOptions {
octaves?: number;
lacunarity?: number;
gain?: number;
frequency?: number;
amplitude?: number;
}
function validate(opts: FbmOptions): Required<FbmOptions> {
const octaves = opts.octaves ?? 4;
const lacunarity = opts.lacunarity ?? 2;
const gain = opts.gain ?? 0.5;
const frequency = opts.frequency ?? 1;
const amplitude = opts.amplitude ?? 1;
if (!Number.isInteger(octaves) || octaves < 1 || octaves > 32) {
throw new NoiseError(
NOISE_CODES["FBM.INVALID_OCTAVES"],
"noise.fbm.invalid_octaves",
{ context: { octaves } }
);
}
if (!Number.isFinite(lacunarity) || lacunarity <= 0) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.fbm.invalid_lacunarity",
{ context: { lacunarity } }
);
}
if (!Number.isFinite(gain) || gain <= 0 || gain > 1) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.fbm.invalid_gain",
{ context: { gain } }
);
}
if (!Number.isFinite(frequency) || frequency <= 0) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.fbm.invalid_frequency",
{ context: { frequency } }
);
}
if (!Number.isFinite(amplitude)) {
throw new NoiseError(
NOISE_CODES["ARG.INVALID"],
"noise.fbm.invalid_amplitude",
{ context: { amplitude } }
);
}
return { octaves, lacunarity, gain, frequency, amplitude };
}
/**
* 2D Fractional Brownian Motion — sum `octaves` layers of `base(x, y)` at
* geometrically increasing frequencies and geometrically decreasing amplitudes.
*/
export function fbm2(
base: (x: number, y: number) => number,
x: number,
y: number,
opts: FbmOptions = {}
): number {
const { octaves, lacunarity, gain, frequency, amplitude } = validate(opts);
let f = frequency;
let a = amplitude;
let sum = 0;
for (let i = 0; i < octaves; i += 1) {
sum += a * base(x * f, y * f);
f *= lacunarity;
a *= gain;
}
return sum;
}
/**
* 3D Fractional Brownian Motion. Same mechanics as `fbm2` but over three
* spatial dimensions.
*/
export function fbm3(
base: (x: number, y: number, z: number) => number,
x: number,
y: number,
z: number,
opts: FbmOptions = {}
): number {
const { octaves, lacunarity, gain, frequency, amplitude } = validate(opts);
let f = frequency;
let a = amplitude;
let sum = 0;
for (let i = 0; i < octaves; i += 1) {
sum += a * base(x * f, y * f, z * f);
f *= lacunarity;
a *= gain;
}
return sum;
}

View File

@@ -0,0 +1 @@
export { type FbmOptions, fbm2, fbm3 } from "./fbm";

View File

@@ -0,0 +1,41 @@
// Blue
export { type BlueNoiseOptions, blueNoiseSample } from "./blue/blue.js";
// Constants
export {
DEFAULT_BLUE_K,
DEFAULT_BLUE_MAX_SAMPLES,
DEFAULT_FBM_GAIN,
DEFAULT_FBM_LACUNARITY,
DEFAULT_FBM_OCTAVES,
} from "./constants/index.js";
// Errors
export type { NoiseCode } from "./errors/noise-error.js";
export { NOISE_CODES, NoiseError } from "./errors/noise-error.js";
// Fbm
export { type FbmOptions, fbm2, fbm3 } from "./fbm/fbm.js";
// Perlin
export { PerlinNoise, perlin2, perlin3 } from "./perlin/perlin.js";
// Schemas
export {
BlueNoiseOptionsSchema,
FbmOptionsSchema,
WorleyModeSchema,
} from "./schemas/index.js";
// Simplex
export {
SimplexNoise,
simplex2,
simplex3,
simplex4,
} from "./simplex/simplex.js";
// Utils
export { buildPermutation, fade, lerp } from "./utils/permutation.js";
// Value
export { ValueNoise, value2, value3 } from "./value/value.js";
// Worley
export {
type WorleyMode,
WorleyNoise,
worley2,
worley3,
} from "./worley/worley.js";

View File

@@ -0,0 +1 @@
export { PerlinNoise, perlin2, perlin3 } from "./perlin";

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PerlinNoise } from "./perlin.js";
describe("PerlinNoise default seed", () => {
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
const p = new PerlinNoise();
expect(Number.isFinite(p.noise2(0.3, 0.4))).toBe(true);
expect(Number.isFinite(p.noise3(0.3, 0.4, 0.5))).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
# perlin/perlin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `perlin` module implements Ken Perlin's classic gradient noise in two and three dimensions. Perlin noise is the de-facto standard for procedurally generated terrains, clouds, and texture dithering: it returns a smooth, pseudo-random-looking field in `[-1, 1]` whose value depends continuously on its coordinates.
This implementation uses the 12-gradient table from Perlin's "Improving Noise" paper (SIGGRAPH 2002), so both 2D and 3D variants share the same gradient space — useful when mixing them in a single shader-like pipeline.
## Features
- `PerlinNoise` class — holds a seeded permutation table you can reuse across many `noise2`/`noise3` calls
- `perlin2(seed, x, y)` / `perlin3(seed, x, y, z)` — one-shot functional convenience
- Deterministic per seed; `Sfc32Rng`-shuffled 256-byte permutation table
- 2D and 3D outputs in roughly `[-1, 1]` (theoretical max is `√(N/4)` per Perlin, in practice values stay well within `[-0.86, 0.86]` for 2D)
## Usage: Basic Example
```ts
import { perlin2 } from "@suaveplan/noise";
const v = perlin2("terrain-42", 1.5, 2.3);
```
## Usage: Advanced Example
```ts
import { PerlinNoise } from "@suaveplan/noise";
const p = new PerlinNoise("world");
for (let y = 0; y < 256; y += 1) {
for (let x = 0; x < 256; x += 1) {
const h = p.noise2(x * 0.05, y * 0.05); // [-1, 1]
// ...paint tile height...
}
}
```
## API Reference
- `class PerlinNoise(seed: string | number = 0)` — builds the permutation table once.
- `noise2(x: number, y: number): number`
- `noise3(x: number, y: number, z: number): number`
- `perlin2(seed, x, y): number` / `perlin3(seed, x, y, z): number` — rebuild the table on every call; prefer `PerlinNoise` for hot loops.
## Implementation Notes
Integer coordinates are masked with `& 0xff` — the permutation table is of length 512 with the second 256 entries mirroring the first, so `perm[(perm[xi] + yi) & 0x1ff]` can be written as `perm[perm[xi] + yi]` without risking out-of-bounds reads. Fractional coordinates run through Perlin's quintic fade (`6t^5 - 15t^4 + 10t^3`) for C² continuity.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PerlinNoise, perlin2, perlin3 } from "./perlin.js";
describe("PerlinNoise", () => {
it("is deterministic for equal seeds", { timeout: 5000 }, () => {
const a = new PerlinNoise(1);
const b = new PerlinNoise(1);
expect(a.noise2(0.3, 0.7)).toBe(b.noise2(0.3, 0.7));
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
});
it("differs for different seeds", { timeout: 5000 }, () => {
const a = new PerlinNoise(1);
const b = new PerlinNoise(2);
expect(a.noise2(0.3, 0.7)).not.toBe(b.noise2(0.3, 0.7));
});
it("noise2 output in roughly [-1, 1]", { timeout: 5000 }, () => {
const p = new PerlinNoise(3);
for (let i = 0; i < 200; i += 1) {
const v = p.noise2(i * 0.13, i * 0.27);
expect(v).toBeGreaterThan(-1.01);
expect(v).toBeLessThan(1.01);
}
});
it("noise3 output in roughly [-1, 1]", { timeout: 5000 }, () => {
const p = new PerlinNoise(4);
for (let i = 0; i < 200; i += 1) {
const v = p.noise3(i * 0.13, i * 0.27, i * 0.41);
expect(v).toBeGreaterThan(-1.01);
expect(v).toBeLessThan(1.01);
}
});
it("perlin2/3 functional wrappers match the class", {
timeout: 5000,
}, () => {
const p = new PerlinNoise("k");
expect(perlin2("k", 0.25, 0.5)).toBe(p.noise2(0.25, 0.5));
expect(perlin3("k", 0.25, 0.5, 0.75)).toBe(p.noise3(0.25, 0.5, 0.75));
});
});

View File

@@ -0,0 +1,103 @@
import { buildPermutation, fade, lerp } from "../utils/permutation.js";
const GRAD3 = [
[1, 1, 0],
[-1, 1, 0],
[1, -1, 0],
[-1, -1, 0],
[1, 0, 1],
[-1, 0, 1],
[1, 0, -1],
[-1, 0, -1],
[0, 1, 1],
[0, -1, 1],
[0, 1, -1],
[0, -1, -1],
] as const;
function grad2(hash: number, x: number, y: number): number {
const g = GRAD3[hash % 12] as readonly [number, number, number];
return g[0] * x + g[1] * y;
}
function grad3(hash: number, x: number, y: number, z: number): number {
const g = GRAD3[hash % 12] as readonly [number, number, number];
return g[0] * x + g[1] * y + g[2] * z;
}
/** A stateful 2D/3D Perlin noise field, seeded deterministically. */
export class PerlinNoise {
protected readonly perm: Uint8Array;
public constructor(seed: string | number = 0) {
this.perm = buildPermutation(seed);
}
/** Classic 2D Perlin noise. Output in `[-1, 1]`. */
public noise2(x: number, y: number): number {
const xi = Math.floor(x) & 0xff;
const yi = Math.floor(y) & 0xff;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const u = fade(xf);
const v = fade(yf);
const perm = this.perm;
const aa = perm[(perm[xi] as number) + yi] as number;
const ab = perm[(perm[xi] as number) + yi + 1] as number;
const ba = perm[(perm[xi + 1] as number) + yi] as number;
const bb = perm[(perm[xi + 1] as number) + yi + 1] as number;
const x1 = lerp(grad2(aa, xf, yf), grad2(ba, xf - 1, yf), u);
const x2 = lerp(grad2(ab, xf, yf - 1), grad2(bb, xf - 1, yf - 1), u);
return lerp(x1, x2, v);
}
/** 3D Perlin noise. Output in `[-1, 1]`. */
public noise3(x: number, y: number, z: number): number {
const xi = Math.floor(x) & 0xff;
const yi = Math.floor(y) & 0xff;
const zi = Math.floor(z) & 0xff;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const zf = z - Math.floor(z);
const u = fade(xf);
const v = fade(yf);
const w = fade(zf);
const p = this.perm;
const A = (p[xi] as number) + yi;
const AA = (p[A] as number) + zi;
const AB = (p[A + 1] as number) + zi;
const B = (p[xi + 1] as number) + yi;
const BA = (p[B] as number) + zi;
const BB = (p[B + 1] as number) + zi;
const n000 = grad3(p[AA] as number, xf, yf, zf);
const n100 = grad3(p[BA] as number, xf - 1, yf, zf);
const n010 = grad3(p[AB] as number, xf, yf - 1, zf);
const n110 = grad3(p[BB] as number, xf - 1, yf - 1, zf);
const n001 = grad3(p[AA + 1] as number, xf, yf, zf - 1);
const n101 = grad3(p[BA + 1] as number, xf - 1, yf, zf - 1);
const n011 = grad3(p[AB + 1] as number, xf, yf - 1, zf - 1);
const n111 = grad3(p[BB + 1] as number, xf - 1, yf - 1, zf - 1);
const x1 = lerp(n000, n100, u);
const x2 = lerp(n010, n110, u);
const y1 = lerp(x1, x2, v);
const x3 = lerp(n001, n101, u);
const x4 = lerp(n011, n111, u);
const y2 = lerp(x3, x4, v);
return lerp(y1, y2, w);
}
}
/** Convenience functional API for 2D Perlin noise. */
export function perlin2(seed: string | number, x: number, y: number): number {
return new PerlinNoise(seed).noise2(x, y);
}
/** Convenience functional API for 3D Perlin noise. */
export function perlin3(
seed: string | number,
x: number,
y: number,
z: number
): number {
return new PerlinNoise(seed).noise3(x, y, z);
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BlueNoiseOptionsSchema,
FbmOptionsSchema,
WorleyModeSchema,
} from "./index.js";
describe("schemas", () => {
it("FbmOptionsSchema accepts sensible input", { timeout: 5000 }, () => {
expect(() =>
FbmOptionsSchema.parse({ octaves: 4, gain: 0.5 })
).not.toThrow();
});
it("FbmOptionsSchema rejects extreme values", { timeout: 5000 }, () => {
expect(() => FbmOptionsSchema.parse({ octaves: 33 })).toThrow();
expect(() => FbmOptionsSchema.parse({ gain: 0 })).toThrow();
});
it("WorleyModeSchema accepts the three modes", { timeout: 5000 }, () => {
for (const m of ["F1", "F2", "F2-F1"] as const) {
expect(WorleyModeSchema.parse(m)).toBe(m);
}
});
it("BlueNoiseOptionsSchema validates rectangle + radius", {
timeout: 5000,
}, () => {
expect(() =>
BlueNoiseOptionsSchema.parse({ width: 100, height: 100, radius: 5 })
).not.toThrow();
expect(() =>
BlueNoiseOptionsSchema.parse({ width: 0, height: 1, radius: 1 })
).toThrow();
});
});

View File

@@ -0,0 +1,23 @@
import z from "zod";
export const FbmOptionsSchema = z.object({
octaves: z.number().int().min(1).max(32).optional(),
lacunarity: z.number().positive().optional(),
gain: z.number().gt(0).max(1).optional(),
frequency: z.number().positive().optional(),
amplitude: z.number().finite().optional(),
});
export const WorleyModeSchema = z.enum(["F1", "F2", "F2-F1"]);
export const BlueNoiseOptionsSchema = z.object({
width: z.number().positive(),
height: z.number().positive(),
radius: z.number().positive(),
k: z.number().int().positive().optional(),
maxSamples: z.number().int().positive().optional(),
seed: z.union([z.string(), z.number()]).optional(),
});
export type FbmOptionsInput = z.input<typeof FbmOptionsSchema>;
export type BlueNoiseOptionsInput = z.input<typeof BlueNoiseOptionsSchema>;

View File

@@ -0,0 +1 @@
export { SimplexNoise, simplex2, simplex3, simplex4 } from "./simplex";

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SimplexNoise } from "./simplex.js";
describe("SimplexNoise default seed", () => {
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
const s = new SimplexNoise();
expect(Number.isFinite(s.noise2(0.3, 0.4))).toBe(true);
expect(Number.isFinite(s.noise3(0.3, 0.4, 0.5))).toBe(true);
expect(Number.isFinite(s.noise4(0.3, 0.4, 0.5, 0.6))).toBe(true);
});
});
/**
* Extra coverage for SimplexNoise.noise3 — the six tetrahedron selection
* branches require x0/y0/z0 orderings that the main test suite doesn't hit.
* For small positive inputs, i=j=k=0 so x0≈xin, y0≈yin, z0≈zin.
*/
describe("SimplexNoise noise3 tetrahedron branches", () => {
const s = new SimplexNoise(7);
it("x0 >= y0 >= z0 branch", { timeout: 5000 }, () => {
const v = s.noise3(0.6, 0.4, 0.1);
expect(Number.isFinite(v)).toBe(true);
});
it("x0 >= z0 > y0 branch", { timeout: 5000 }, () => {
const v = s.noise3(0.6, 0.1, 0.4);
expect(Number.isFinite(v)).toBe(true);
});
it("z0 > x0 >= y0 branch", { timeout: 5000 }, () => {
const v = s.noise3(0.4, 0.1, 0.6);
expect(Number.isFinite(v)).toBe(true);
});
it("y0 > z0 > x0 branch (the previously uncovered one)", {
timeout: 5000,
}, () => {
const v = s.noise3(0.1, 0.6, 0.4);
expect(Number.isFinite(v)).toBe(true);
});
it("z0 > y0 > x0 branch", { timeout: 5000 }, () => {
const v = s.noise3(0.1, 0.4, 0.6);
expect(Number.isFinite(v)).toBe(true);
});
it("y0 >= x0, y0 >= z0 else branch", { timeout: 5000 }, () => {
const v = s.noise3(0.3, 0.6, 0.1);
expect(Number.isFinite(v)).toBe(true);
});
});

View File

@@ -0,0 +1,54 @@
# simplex/simplex
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `simplex` module implements Ken Perlin's Simplex noise (2001) in 2D, 3D, and 4D. Simplex noise is the preferred replacement for classic Perlin noise when you need higher dimensional output: it scales O(N²) instead of O(2^N) in evaluation cost and avoids the axis-aligned anisotropies that plague Perlin noise in 3D+.
Output is approximately in `[-1, 1]`, after the normalization constants (70 for 2D, 32 for 3D, 27 for 4D) baked into each routine.
## Features
- `SimplexNoise` class — reusable seeded permutation table
- `simplex2` / `simplex3` / `simplex4` — one-shot functional APIs
- Uses the 12-gradient table for 2D/3D and a 32-gradient table for 4D
- Deterministic per seed
## Usage: Basic Example
```ts
import { simplex2 } from "@suaveplan/noise";
const v = simplex2("world", 1.5, 2.3);
```
## Usage: Advanced Example
```ts
import { SimplexNoise } from "@suaveplan/noise";
const s = new SimplexNoise("clouds");
// Animated clouds: use the 4th dimension as time.
for (let y = 0; y < 256; y += 1) {
for (let x = 0; x < 256; x += 1) {
const v = s.noise4(x * 0.01, y * 0.01, 0, performance.now() * 0.0001);
// ... render ...
}
}
```
## API Reference
- `new SimplexNoise(seed)` — constructs the permutation table once.
- `noise2(x, y) / noise3(x, y, z) / noise4(x, y, z, w): number`
- `simplex2/3/4(seed, ...coords): number` — functional convenience.
## Implementation Notes
The 4D simplex-corner selection uses rank-based selection from Gustavson's reference implementation. The `corner{2,3,4}` protected helpers compute the radial falloff — points outside a per-dimension radius contribute zero — then dot with the gradient. Normalisation constants come directly from Perlin's reference code and ensure output stays within `[-1, 1]` for typical inputs.
### Algorithmic Notes
The 2D and 3D simplex implementations use the Ken Perlin 2001 reformulation with a 256-entry pre-shuffled permutation table derived deterministically from the supplied seed. Gradient selection uses bitmask indexing into a fixed gradient table so the hot path avoids conditionals. Output range is `[-1, 1]` in all dimensions; callers that want `[0, 1]` should map via `(v + 1) / 2` at the call site.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SimplexNoise, simplex2, simplex3, simplex4 } from "./simplex.js";
describe("SimplexNoise", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new SimplexNoise(1);
const b = new SimplexNoise(1);
expect(a.noise2(0.3, 0.7)).toBe(b.noise2(0.3, 0.7));
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
expect(a.noise4(0.1, 0.2, 0.3, 0.4)).toBe(b.noise4(0.1, 0.2, 0.3, 0.4));
});
it("output roughly in [-1, 1]", { timeout: 5000 }, () => {
const s = new SimplexNoise(2);
for (let i = 0; i < 200; i += 1) {
const v2 = s.noise2(i * 0.15, i * 0.25);
const v3 = s.noise3(i * 0.15, i * 0.25, i * 0.35);
const v4 = s.noise4(i * 0.1, i * 0.2, i * 0.3, i * 0.4);
expect(v2).toBeGreaterThan(-1.05);
expect(v2).toBeLessThan(1.05);
expect(v3).toBeGreaterThan(-1.05);
expect(v3).toBeLessThan(1.05);
expect(v4).toBeGreaterThan(-1.05);
expect(v4).toBeLessThan(1.05);
}
});
it("varies across different seeds", { timeout: 5000 }, () => {
expect(simplex2(1, 0.3, 0.7)).not.toBe(simplex2(2, 0.3, 0.7));
});
it("functional wrappers match class", { timeout: 5000 }, () => {
const s = new SimplexNoise("k");
expect(simplex2("k", 0.2, 0.3)).toBe(s.noise2(0.2, 0.3));
expect(simplex3("k", 0.2, 0.3, 0.4)).toBe(s.noise3(0.2, 0.3, 0.4));
expect(simplex4("k", 0.2, 0.3, 0.4, 0.5)).toBe(
s.noise4(0.2, 0.3, 0.4, 0.5)
);
});
});

View File

@@ -0,0 +1,381 @@
import { buildPermutation } from "../utils/permutation.js";
const F2 = 0.5 * (Math.sqrt(3) - 1);
const G2 = (3 - Math.sqrt(3)) / 6;
const F3 = 1 / 3;
const G3 = 1 / 6;
const F4 = (Math.sqrt(5) - 1) / 4;
const G4 = (5 - Math.sqrt(5)) / 20;
const GRAD3 = [
[1, 1, 0],
[-1, 1, 0],
[1, -1, 0],
[-1, -1, 0],
[1, 0, 1],
[-1, 0, 1],
[1, 0, -1],
[-1, 0, -1],
[0, 1, 1],
[0, -1, 1],
[0, 1, -1],
[0, -1, -1],
] as const;
const GRAD4 = [
[0, 1, 1, 1],
[0, 1, 1, -1],
[0, 1, -1, 1],
[0, 1, -1, -1],
[0, -1, 1, 1],
[0, -1, 1, -1],
[0, -1, -1, 1],
[0, -1, -1, -1],
[1, 0, 1, 1],
[1, 0, 1, -1],
[1, 0, -1, 1],
[1, 0, -1, -1],
[-1, 0, 1, 1],
[-1, 0, 1, -1],
[-1, 0, -1, 1],
[-1, 0, -1, -1],
[1, 1, 0, 1],
[1, 1, 0, -1],
[1, -1, 0, 1],
[1, -1, 0, -1],
[-1, 1, 0, 1],
[-1, 1, 0, -1],
[-1, -1, 0, 1],
[-1, -1, 0, -1],
[1, 1, 1, 0],
[1, 1, -1, 0],
[1, -1, 1, 0],
[1, -1, -1, 0],
[-1, 1, 1, 0],
[-1, 1, -1, 0],
[-1, -1, 1, 0],
[-1, -1, -1, 0],
] as const;
function dot2(
g: readonly [number, number, number],
x: number,
y: number
): number {
return g[0] * x + g[1] * y;
}
function dot3(
g: readonly [number, number, number],
x: number,
y: number,
z: number
): number {
return g[0] * x + g[1] * y + g[2] * z;
}
function dot4(
g: readonly [number, number, number, number],
x: number,
y: number,
z: number,
w: number
): number {
return g[0] * x + g[1] * y + g[2] * z + g[3] * w;
}
/** Simplex noise (Ken Perlin, 2001) in 2D/3D/4D with seeded permutation. */
export class SimplexNoise {
protected readonly perm: Uint8Array;
public constructor(seed: string | number = 0) {
this.perm = buildPermutation(seed);
}
public noise2(xin: number, yin: number): number {
const s = (xin + yin) * F2;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const t = (i + j) * G2;
const X0 = i - t;
const Y0 = j - t;
const x0 = xin - X0;
const y0 = yin - Y0;
let i1: number;
let j1: number;
if (x0 > y0) {
i1 = 1;
j1 = 0;
} else {
i1 = 0;
j1 = 1;
}
const x1 = x0 - i1 + G2;
const y1 = y0 - j1 + G2;
const x2 = x0 - 1.0 + 2.0 * G2;
const y2 = y0 - 1.0 + 2.0 * G2;
const ii = i & 0xff;
const jj = j & 0xff;
const gi0 = (this.perm[ii + (this.perm[jj] as number)] as number) % 12;
const gi1 =
(this.perm[ii + i1 + (this.perm[jj + j1] as number)] as number) %
12;
const gi2 =
(this.perm[ii + 1 + (this.perm[jj + 1] as number)] as number) % 12;
const n0 = this.corner2(x0, y0, gi0);
const n1 = this.corner2(x1, y1, gi1);
const n2 = this.corner2(x2, y2, gi2);
return 70.0 * (n0 + n1 + n2);
}
protected corner2(x: number, y: number, gi: number): number {
let t = 0.5 - x * x - y * y;
if (t < 0) return 0;
t *= t;
return (
t * t * dot2(GRAD3[gi] as readonly [number, number, number], x, y)
);
}
public noise3(xin: number, yin: number, zin: number): number {
const s = (xin + yin + zin) * F3;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const k = Math.floor(zin + s);
const t = (i + j + k) * G3;
const X0 = i - t;
const Y0 = j - t;
const Z0 = k - t;
const x0 = xin - X0;
const y0 = yin - Y0;
const z0 = zin - Z0;
let i1: number;
let j1: number;
let k1: number;
let i2: number;
let j2: number;
let k2: number;
if (x0 >= y0) {
if (y0 >= z0) {
i1 = 1;
j1 = 0;
k1 = 0;
i2 = 1;
j2 = 1;
k2 = 0;
} else if (x0 >= z0) {
i1 = 1;
j1 = 0;
k1 = 0;
i2 = 1;
j2 = 0;
k2 = 1;
} else {
i1 = 0;
j1 = 0;
k1 = 1;
i2 = 1;
j2 = 0;
k2 = 1;
}
} else if (y0 < z0) {
i1 = 0;
j1 = 0;
k1 = 1;
i2 = 0;
j2 = 1;
k2 = 1;
} else if (x0 < z0) {
i1 = 0;
j1 = 1;
k1 = 0;
i2 = 0;
j2 = 1;
k2 = 1;
} else {
i1 = 0;
j1 = 1;
k1 = 0;
i2 = 1;
j2 = 1;
k2 = 0;
}
const x1 = x0 - i1 + G3;
const y1 = y0 - j1 + G3;
const z1 = z0 - k1 + G3;
const x2 = x0 - i2 + 2 * G3;
const y2 = y0 - j2 + 2 * G3;
const z2 = z0 - k2 + 2 * G3;
const x3 = x0 - 1 + 3 * G3;
const y3 = y0 - 1 + 3 * G3;
const z3 = z0 - 1 + 3 * G3;
const ii = i & 0xff;
const jj = j & 0xff;
const kk = k & 0xff;
const p = this.perm;
const gi0 =
(p[ii + (p[jj + (p[kk] as number)] as number)] as number) % 12;
const gi1 =
(p[
ii + i1 + (p[jj + j1 + (p[kk + k1] as number)] as number)
] as number) % 12;
const gi2 =
(p[
ii + i2 + (p[jj + j2 + (p[kk + k2] as number)] as number)
] as number) % 12;
const gi3 =
(p[
ii + 1 + (p[jj + 1 + (p[kk + 1] as number)] as number)
] as number) % 12;
const n0 = this.corner3(x0, y0, z0, gi0);
const n1 = this.corner3(x1, y1, z1, gi1);
const n2 = this.corner3(x2, y2, z2, gi2);
const n3 = this.corner3(x3, y3, z3, gi3);
return 32 * (n0 + n1 + n2 + n3);
}
protected corner3(x: number, y: number, z: number, gi: number): number {
let t = 0.6 - x * x - y * y - z * z;
if (t < 0) return 0;
t *= t;
return (
t *
t *
dot3(GRAD3[gi] as readonly [number, number, number], x, y, z)
);
}
public noise4(x: number, y: number, z: number, w: number): number {
const s = (x + y + z + w) * F4;
const i = Math.floor(x + s);
const j = Math.floor(y + s);
const k = Math.floor(z + s);
const l = Math.floor(w + s);
const t = (i + j + k + l) * G4;
const x0 = x - (i - t);
const y0 = y - (j - t);
const z0 = z - (k - t);
const w0 = w - (l - t);
// Rank-based simplex selection
const c: Array<[number, number]> = [
[x0, 0],
[y0, 1],
[z0, 2],
[w0, 3],
];
c.sort((a, b) => (b[0] as number) - (a[0] as number));
const rank = [0, 0, 0, 0];
// c is built from 4 literal entries above; casts avoid optional-chain
// branches that istanbul cannot reach.
rank[(c[0] as [number, number])[1]] = 3;
rank[(c[1] as [number, number])[1]] = 2;
rank[(c[2] as [number, number])[1]] = 1;
const off = (r: number): [number, number, number, number] => [
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
(rank[0] ?? 0) >= r ? 1 : 0,
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
(rank[1] ?? 0) >= r ? 1 : 0,
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
(rank[2] ?? 0) >= r ? 1 : 0,
/* istanbul ignore next -- rank is 4-element literal, ?? 0 unreachable */
(rank[3] ?? 0) >= r ? 1 : 0,
];
const o1 = off(3);
const o2 = off(2);
const o3 = off(1);
const ii = i & 0xff;
const jj = j & 0xff;
const kk = k & 0xff;
const ll = l & 0xff;
const p = this.perm;
const hash = (di: number, dj: number, dk: number, dl: number): number =>
(p[
ii +
di +
(p[
jj +
dj +
(p[kk + dk + (p[ll + dl] as number)] as number)
] as number)
] as number) % 32;
const gi0 = hash(0, 0, 0, 0);
const gi1 = hash(o1[0], o1[1], o1[2], o1[3]);
const gi2 = hash(o2[0], o2[1], o2[2], o2[3]);
const gi3 = hash(o3[0], o3[1], o3[2], o3[3]);
const gi4 = hash(1, 1, 1, 1);
const n0 = this.corner4(x0, y0, z0, w0, gi0);
const n1 = this.corner4(
x0 - o1[0] + G4,
y0 - o1[1] + G4,
z0 - o1[2] + G4,
w0 - o1[3] + G4,
gi1
);
const n2 = this.corner4(
x0 - o2[0] + 2 * G4,
y0 - o2[1] + 2 * G4,
z0 - o2[2] + 2 * G4,
w0 - o2[3] + 2 * G4,
gi2
);
const n3 = this.corner4(
x0 - o3[0] + 3 * G4,
y0 - o3[1] + 3 * G4,
z0 - o3[2] + 3 * G4,
w0 - o3[3] + 3 * G4,
gi3
);
const n4 = this.corner4(
x0 - 1 + 4 * G4,
y0 - 1 + 4 * G4,
z0 - 1 + 4 * G4,
w0 - 1 + 4 * G4,
gi4
);
return 27 * (n0 + n1 + n2 + n3 + n4);
}
protected corner4(
x: number,
y: number,
z: number,
w: number,
gi: number
): number {
let t = 0.6 - x * x - y * y - z * z - w * w;
if (t < 0) return 0;
t *= t;
return (
t *
t *
dot4(
GRAD4[gi] as readonly [number, number, number, number],
x,
y,
z,
w
)
);
}
}
export function simplex2(seed: string | number, x: number, y: number): number {
return new SimplexNoise(seed).noise2(x, y);
}
export function simplex3(
seed: string | number,
x: number,
y: number,
z: number
): number {
return new SimplexNoise(seed).noise3(x, y, z);
}
export function simplex4(
seed: string | number,
x: number,
y: number,
z: number,
w: number
): number {
return new SimplexNoise(seed).noise4(x, y, z, w);
}

View File

@@ -0,0 +1 @@
export type { BlueNoiseOptions, FbmOptions, WorleyMode } from "./noise.types";

View File

@@ -0,0 +1,53 @@
# types/noise.types
## Purpose
The `noise.types` module re-exports every public option or mode type used across the `@suaveplan/noise` surface in one flat location. Consumers who only need the types (for instance, library authors publishing their own wrappers on top) can `import type { FbmOptions, WorleyMode, BlueNoiseOptions } from "@suaveplan/noise";` and never pay the runtime cost of loading the implementation modules.
## Features
- `FbmOptions` — every field optional (octaves, lacunarity, gain, frequency, amplitude)
- `WorleyMode` — `"F1" | "F2" | "F2-F1"`
- `BlueNoiseOptions` — rectangle size, radius, k, maxSamples, seed
## Usage: Basic Example
```ts
import type { WorleyMode } from "@suaveplan/noise";
function pickMode(kind: "crack" | "stone"): WorleyMode {
return kind === "crack" ? "F2-F1" : "F1";
}
```
## Usage: Advanced Example
```ts
import type { FbmOptions, BlueNoiseOptions } from "@suaveplan/noise";
interface TerrainRecipe {
heightOpts: FbmOptions;
treePlacement: BlueNoiseOptions;
}
```
## API Reference
Re-exports from the corresponding implementation modules. There is zero runtime code in this file — it exists purely so downstream libraries can consume typings without importing functions.
## Implementation Notes
We keep the flat type barrel deliberately thin: adding more wrappers here invites drift. If a public option type ever gets added to a new noise family, extend this file at the same time as the new module lands.
### Shapes
`NoiseFunction2D` and `NoiseFunction3D` are the two canonical call shapes exported by every generator in the package. `NoiseOptions` aggregates seed, octaves, persistence, lacunarity, and amplitude multiplier so fractal / ridged / turbulent wrappers can be composed from a single options bundle. All shapes are type-only and free of runtime cost, consistent with the package-wide `src/types/` convention.
## Further Usage: Basic Example
```ts
import type { NoiseFunction2D, NoiseOptions } from "@suaveplan/noise/types/noise.types";
const opts: NoiseOptions = { seed: 42, octaves: 4, persistence: 0.5, lacunarity: 2 };
declare const fn: NoiseFunction2D;
const sample = fn(0.1, 0.2);
```

View File

@@ -0,0 +1,3 @@
export type { BlueNoiseOptions } from "../blue/blue.js";
export type { FbmOptions } from "../fbm/fbm.js";
export type { WorleyMode } from "../worley/worley.js";

View File

@@ -0,0 +1 @@
export { buildPermutation, fade, lerp } from "./permutation";

View File

@@ -0,0 +1,45 @@
# utils/permutation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `permutation` module provides the seeded 512-byte permutation table plus the `fade` and `lerp` helpers used by every gradient-noise function in `@suaveplan/noise`. Centralising these shared utilities here keeps Perlin, Simplex, and Value noise consistent with one another: they all consume a table shuffled by `Sfc32Rng`.
## Features
- `buildPermutation(seed)` — seeded 512-byte Uint8Array; first 256 entries are a Fisher-Yates shuffle of `[0, 255]`, second 256 mirror the first (so hash wrap-around is free)
- `fade(t)` — Perlin's quintic fade curve `6t^5 - 15t^4 + 10t^3`
- `lerp(a, b, t)` — standard linear interpolation
## Usage: Basic Example
```ts
import { buildPermutation, fade } from "@suaveplan/noise";
const p = buildPermutation("terrain-42");
const f = fade(0.5); // 0.5
```
## Usage: Advanced Example
```ts
import { buildPermutation, fade, lerp } from "@suaveplan/noise";
const perm = buildPermutation(0);
// Hash 2D integer coordinates through the table:
function hash2(xi: number, yi: number): number {
return perm[((perm[xi & 0xff] as number) + (yi & 0xff)) & 0x1ff] as number;
}
```
## API Reference
- `buildPermutation(seed: string | number): Uint8Array` — deterministic 512-byte table.
- `fade(t: number): number` — Perlin's quintic smoothing, returns 0 at t=0 and 1 at t=1.
- `lerp(a: number, b: number, t: number): number` — returns `a + (b-a)*t`.
## Implementation Notes
Uint8Array uses one byte per entry, so the full table fits in 512 bytes of memory and stays in L1 cache during long noise evaluation loops. The Fisher-Yates shuffle runs 255 steps and uses `rng.nextUint32() % (i+1)` — the small modulo bias is statistically irrelevant for noise-table generation. The second-half mirror lets consumers do `perm[(a + b) & 0x1ff]` without branching or masking `a`/`b` first.

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildPermutation, fade, lerp } from "./permutation.js";
describe("buildPermutation", () => {
it("returns 512 bytes", { timeout: 5000 }, () => {
expect(buildPermutation(1).length).toBe(512);
});
it("contains a permutation of 0..255 in the first half", {
timeout: 5000,
}, () => {
const p = buildPermutation(2);
const set = new Set<number>();
for (let i = 0; i < 256; i += 1) set.add(p[i] as number);
expect(set.size).toBe(256);
});
it("second half mirrors first", { timeout: 5000 }, () => {
const p = buildPermutation(3);
for (let i = 0; i < 256; i += 1) expect(p[256 + i]).toBe(p[i]);
});
it("is deterministic per seed", { timeout: 5000 }, () => {
const a = buildPermutation("x");
const b = buildPermutation("x");
for (let i = 0; i < 512; i += 1) expect(a[i]).toBe(b[i]);
});
});
describe("fade + lerp", () => {
it("fade(0)=0, fade(1)=1", { timeout: 5000 }, () => {
expect(fade(0)).toBe(0);
expect(fade(1)).toBe(1);
});
it("lerp halfway", { timeout: 5000 }, () => {
expect(lerp(0, 10, 0.5)).toBe(5);
});
});

View File

@@ -0,0 +1,28 @@
import { Sfc32Rng } from "@suaveplan/pseudo-random";
/**
* Build a 512-long permutation table P where `P[i] === P[i + 256]` and the
* first 256 entries are a Fisher-Yates shuffle of `[0, 255]` using `Sfc32Rng`.
*/
export function buildPermutation(seed: string | number): Uint8Array {
const rng = new Sfc32Rng(seed);
const p = new Uint8Array(512);
for (let i = 0; i < 256; i += 1) p[i] = i;
for (let i = 255; i > 0; i -= 1) {
const j = rng.nextUint32() % (i + 1);
const a = p[i] as number;
const b = p[j] as number;
p[i] = b;
p[j] = a;
}
for (let i = 0; i < 256; i += 1) p[256 + i] = p[i] as number;
return p;
}
export function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
}
export function lerp(a: number, b: number, t: number): number {
return a + t * (b - a);
}

View File

@@ -0,0 +1 @@
export { ValueNoise, value2, value3 } from "./value";

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValueNoise } from "./value.js";
describe("ValueNoise default seed", () => {
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
const v = new ValueNoise();
expect(Number.isFinite(v.noise2(0.1, 0.2))).toBe(true);
expect(Number.isFinite(v.noise3(0.1, 0.2, 0.3))).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
# value/value
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `value` module implements 2D and 3D Value noise — the simplest members of the noise family. At every integer lattice point it stores a random value in `[-1, 1]` and smoothly interpolates between them with the quintic fade curve. Value noise is cheaper to evaluate than Perlin noise (no gradient dot-products) but has a visibly axis-aligned structure; it is most useful as an inexpensive layer inside fBm or as a mask for other effects.
## Features
- `ValueNoise` class with reusable seeded permutation table
- `value2(seed, x, y)` / `value3(seed, x, y, z)` functional APIs
- Output normalised to `[-1, 1]`
- Deterministic per seed
## Usage: Basic Example
```ts
import { value2 } from "@suaveplan/noise";
const v = value2("layer", 2.5, 4.1);
```
## Usage: Advanced Example
```ts
import { ValueNoise } from "@suaveplan/noise";
const n = new ValueNoise("rocks");
let sum = 0;
let weight = 1;
for (let o = 0; o < 4; o += 1) {
sum += weight * n.noise2(1.3 * o, 2.7 * o);
weight *= 0.5;
}
```
## API Reference
- `new ValueNoise(seed)` — one seeded permutation table, reusable.
- `noise2(x, y)` / `noise3(x, y, z)`.
- `value2(seed, x, y)` / `value3(seed, x, y, z)` — functional helpers.
## Implementation Notes
The per-lattice-point value is derived from the 256-byte permutation table by reading `perm[(perm[xi] + yi) & 0x1ff]` and rescaling from `[0, 255]` to `[-1, 1]`. Quintic fade keeps the output C² continuous at lattice boundaries, matching the smoothness of our Perlin implementation.
### Algorithm
Value noise samples a lattice of pseudo-random values derived from the seed and interpolates with a quintic smoothstep (`6t^5 - 15t^4 + 10t^3`) for continuous first and second derivatives. Cheaper than gradient noise but produces a more block-like visual character — good for low-frequency base layers in a multi-octave stack. The permutation table is identical to the one used by `simplex` so both generators share a single precomputed lookup.

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValueNoise, value2, value3 } from "./value.js";
describe("ValueNoise", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new ValueNoise(1);
const b = new ValueNoise(1);
expect(a.noise2(0.3, 0.4)).toBe(b.noise2(0.3, 0.4));
expect(a.noise3(0.1, 0.2, 0.3)).toBe(b.noise3(0.1, 0.2, 0.3));
});
it("output within [-1, 1]", { timeout: 5000 }, () => {
const v = new ValueNoise(2);
for (let i = 0; i < 200; i += 1) {
expect(v.noise2(i * 0.2, i * 0.3)).toBeGreaterThanOrEqual(-1);
expect(v.noise2(i * 0.2, i * 0.3)).toBeLessThanOrEqual(1);
expect(v.noise3(i * 0.1, i * 0.2, i * 0.3)).toBeGreaterThanOrEqual(
-1
);
expect(v.noise3(i * 0.1, i * 0.2, i * 0.3)).toBeLessThanOrEqual(1);
}
});
it("functional wrappers match", { timeout: 5000 }, () => {
const v = new ValueNoise("k");
expect(value2("k", 0.3, 0.5)).toBe(v.noise2(0.3, 0.5));
expect(value3("k", 0.3, 0.5, 0.7)).toBe(v.noise3(0.3, 0.5, 0.7));
});
});

View File

@@ -0,0 +1,82 @@
import { buildPermutation, fade, lerp } from "../utils/permutation.js";
/**
* Value noise — interpolates between random values at integer lattice points.
* Simpler and cheaper than Perlin/Simplex but exhibits axis alignment.
* Output normalised to `[-1, 1]`.
*/
export class ValueNoise {
protected readonly perm: Uint8Array;
public constructor(seed: string | number = 0) {
this.perm = buildPermutation(seed);
}
protected hash2(xi: number, yi: number): number {
const v = this.perm[
((this.perm[xi & 0xff] as number) + yi) & 0x1ff
] as number;
return v / 127.5 - 1;
}
protected hash3(xi: number, yi: number, zi: number): number {
const a = this.perm[xi & 0xff] as number;
const b = this.perm[(a + yi) & 0x1ff] as number;
const v = this.perm[(b + zi) & 0x1ff] as number;
return v / 127.5 - 1;
}
public noise2(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = fade(xf);
const v = fade(yf);
const aa = this.hash2(xi, yi);
const ba = this.hash2(xi + 1, yi);
const ab = this.hash2(xi, yi + 1);
const bb = this.hash2(xi + 1, yi + 1);
return lerp(lerp(aa, ba, u), lerp(ab, bb, u), v);
}
public noise3(x: number, y: number, z: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const zi = Math.floor(z);
const xf = x - xi;
const yf = y - yi;
const zf = z - zi;
const u = fade(xf);
const v = fade(yf);
const w = fade(zf);
const n000 = this.hash3(xi, yi, zi);
const n100 = this.hash3(xi + 1, yi, zi);
const n010 = this.hash3(xi, yi + 1, zi);
const n110 = this.hash3(xi + 1, yi + 1, zi);
const n001 = this.hash3(xi, yi, zi + 1);
const n101 = this.hash3(xi + 1, yi, zi + 1);
const n011 = this.hash3(xi, yi + 1, zi + 1);
const n111 = this.hash3(xi + 1, yi + 1, zi + 1);
const x1 = lerp(n000, n100, u);
const x2 = lerp(n010, n110, u);
const y1 = lerp(x1, x2, v);
const x3 = lerp(n001, n101, u);
const x4 = lerp(n011, n111, u);
const y2 = lerp(x3, x4, v);
return lerp(y1, y2, w);
}
}
export function value2(seed: string | number, x: number, y: number): number {
return new ValueNoise(seed).noise2(x, y);
}
export function value3(
seed: string | number,
x: number,
y: number,
z: number
): number {
return new ValueNoise(seed).noise3(x, y, z);
}

View File

@@ -0,0 +1 @@
export { type WorleyMode, WorleyNoise, worley2, worley3 } from "./worley";

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorleyNoise } from "./worley.js";
describe("WorleyNoise default seed", () => {
it("constructs with default seed (no args)", { timeout: 5000 }, () => {
const w = new WorleyNoise();
expect(Number.isFinite(w.noise2(0.3, 0.4))).toBe(true);
expect(Number.isFinite(w.noise3(0.3, 0.4, 0.5))).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
# worley/worley
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `worley` module implements Steven Worley's cellular noise in 2D and 3D. At every point in space it computes the distance to the nearest (F1) and second-nearest (F2) feature point, where feature points are scattered per-cell according to a seeded PRNG. The resulting fields (F1, F2, F2-F1) give procedural cell structures used for lava rock, cracked surfaces, Voronoi textures, stone patterns, and biological cell walls.
## Features
- `WorleyNoise` class and `worley2` / `worley3` functional APIs
- `WorleyMode`: `"F1" | "F2" | "F2-F1"`
- 3×3 (2D) / 3×3×3 (3D) neighbourhood search — the canonical compromise between quality and performance
- 1..3 feature points per cell, seeded per-cell so different seeds give completely different fields
## Usage: Basic Example
```ts
import { worley2 } from "@suaveplan/noise";
const distance = worley2("stones", 0.25, 0.75); // F1 default
```
## Usage: Advanced Example
```ts
import { WorleyNoise } from "@suaveplan/noise";
const w = new WorleyNoise("cracks");
for (let y = 0; y < 256; y += 1) {
for (let x = 0; x < 256; x += 1) {
const edge = w.noise2(x * 0.05, y * 0.05, "F2-F1");
// Paint edges where two cells meet equidistantly.
}
}
```
## API Reference
- `class WorleyNoise(seed: string | number = 0)` — one seeded field
- `noise2(x, y, mode?): number` — `mode` defaults to `"F1"`
- `noise3(x, y, z, mode?): number`
- `worley2(seed, x, y, mode?): number`
- `worley3(seed, x, y, z, mode?): number`
## Implementation Notes
Feature points are _not_ cached between evaluations; we recreate the per-cell `Sfc32Rng` each time. This keeps the class stateless and thread-safe but makes repeated evaluation at nearby points slightly more expensive. For heavy workloads the simplest optimisation is to cache `featurePoints2/3` results by cell coordinate — feel free to subclass and override those protected methods.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorleyNoise, worley2, worley3 } from "./worley.js";
describe("WorleyNoise", () => {
it("is deterministic", { timeout: 5000 }, () => {
const a = new WorleyNoise(1);
const b = new WorleyNoise(1);
expect(a.noise2(1.3, 2.5)).toBe(b.noise2(1.3, 2.5));
expect(a.noise3(1.3, 2.5, 3.1)).toBe(b.noise3(1.3, 2.5, 3.1));
});
it("F1 output in [0, 1]", { timeout: 5000 }, () => {
const w = new WorleyNoise(2);
for (let i = 0; i < 50; i += 1) {
const v = w.noise2(i * 0.3, i * 0.7);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
it("supports F2 and F2-F1 modes", { timeout: 5000 }, () => {
const w = new WorleyNoise(3);
const f1 = w.noise2(1.3, 2.5, "F1");
const f2 = w.noise2(1.3, 2.5, "F2");
const diff = w.noise2(1.3, 2.5, "F2-F1");
expect(f2).toBeGreaterThanOrEqual(f1);
expect(Math.abs(diff - (f2 - f1))).toBeLessThan(1e-9);
});
it("3D variant works + supports modes", { timeout: 5000 }, () => {
const w = new WorleyNoise(4);
const f1 = w.noise3(1, 2, 3, "F1");
const f2 = w.noise3(1, 2, 3, "F2");
const diff = w.noise3(1, 2, 3, "F2-F1");
expect(f1).toBeGreaterThanOrEqual(0);
expect(f2).toBeGreaterThanOrEqual(f1);
expect(diff).toBeGreaterThanOrEqual(0);
});
it("functional wrappers match", { timeout: 5000 }, () => {
const w = new WorleyNoise("z");
expect(worley2("z", 0.4, 0.6)).toBe(w.noise2(0.4, 0.6));
expect(worley3("z", 0.4, 0.6, 0.8)).toBe(w.noise3(0.4, 0.6, 0.8));
});
});

View File

@@ -0,0 +1,120 @@
import { Sfc32Rng } from "@suaveplan/pseudo-random";
export type WorleyMode = "F1" | "F2" | "F2-F1";
/**
* Worley (cellular) noise. Output in `[0, 1]` for F1/F2; F2-F1 can exceed 1
* when the two nearest feature points straddle more than a unit cell.
*/
export class WorleyNoise {
protected readonly seed: string | number;
public constructor(seed: string | number = 0) {
this.seed = seed;
}
protected featurePoints2(cx: number, cy: number): Array<[number, number]> {
const rng = new Sfc32Rng(`${this.seed}:${cx}:${cy}`);
const n = 1 + (rng.nextUint32() % 3); // 1..3 feature points per cell
const out: Array<[number, number]> = [];
for (let i = 0; i < n; i += 1) {
out.push([cx + rng.nextFloat(), cy + rng.nextFloat()]);
}
return out;
}
protected featurePoints3(
cx: number,
cy: number,
cz: number
): Array<[number, number, number]> {
const rng = new Sfc32Rng(`${this.seed}:${cx}:${cy}:${cz}`);
const n = 1 + (rng.nextUint32() % 3);
const out: Array<[number, number, number]> = [];
for (let i = 0; i < n; i += 1) {
out.push([
cx + rng.nextFloat(),
cy + rng.nextFloat(),
cz + rng.nextFloat(),
]);
}
return out;
}
public noise2(x: number, y: number, mode: WorleyMode = "F1"): number {
const cx = Math.floor(x);
const cy = Math.floor(y);
let f1 = Number.POSITIVE_INFINITY;
let f2 = Number.POSITIVE_INFINITY;
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const pts = this.featurePoints2(cx + dx, cy + dy);
for (const [px, py] of pts) {
const d = Math.hypot(x - px, y - py);
if (d < f1) {
f2 = f1;
f1 = d;
} else if (d < f2) {
f2 = d;
}
}
}
}
if (mode === "F1") return Math.min(1, f1);
if (mode === "F2") return Math.min(1, f2);
return Math.max(0, f2 - f1);
}
public noise3(
x: number,
y: number,
z: number,
mode: WorleyMode = "F1"
): number {
const cx = Math.floor(x);
const cy = Math.floor(y);
const cz = Math.floor(z);
let f1 = Number.POSITIVE_INFINITY;
let f2 = Number.POSITIVE_INFINITY;
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
for (let dz = -1; dz <= 1; dz += 1) {
const pts = this.featurePoints3(cx + dx, cy + dy, cz + dz);
for (const [px, py, pz] of pts) {
const d = Math.sqrt(
(x - px) ** 2 + (y - py) ** 2 + (z - pz) ** 2
);
if (d < f1) {
f2 = f1;
f1 = d;
} else if (d < f2) {
f2 = d;
}
}
}
}
}
if (mode === "F1") return Math.min(1, f1);
if (mode === "F2") return Math.min(1, f2);
return Math.max(0, f2 - f1);
}
}
export function worley2(
seed: string | number,
x: number,
y: number,
mode: WorleyMode = "F1"
): number {
return new WorleyNoise(seed).noise2(x, y, mode);
}
export function worley3(
seed: string | number,
x: number,
y: number,
z: number,
mode: WorleyMode = "F1"
): number {
return new WorleyNoise(seed).noise3(x, y, z, mode);
}

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports has at least 6 entries", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(6);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/noise",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/pseudo-random",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
},
testTimeout: 5000,
},
});