Files
oikos/web/vendor/src/fields.js
dtoro 3cd4cf98c3 chore: vendor @joan/procedural-glyph-engine for portable builds
Move @joan/procedural-glyph-engine from absolute path dep
(/private/tmp/orby-pkg) to vendored local dep (web/vendor/) so the
SPA builds in Docker and on other machines without the temp dir.

- Vendor Orby v5.0.0 into web/vendor/
- Switch package.json dep to file:../vendor
- Update Dockerfile to COPY vendor into build context
- Use npm install instead of npm ci (file: deps need install)
- Fix missing trailing newline in Dockerfile
2026-08-05 17:27:04 +02:00

1324 lines
41 KiB
JavaScript

/**
* Deterministic scalar fields for Orby.
*
* Coordinate convention
* ---------------------
* `x` and `y` are continuous field-space coordinates (normally normalized to
* -1..1 across a centered glyph), and `time` is an arbitrary monotonically
* increasing value, conventionally seconds. Every named field returns a finite
* value in 0..1. Options are optional plain objects and are never mutated.
*
* The hot sampling path is allocation-free. `FieldKit` owns its permutation
* table and a few scalar scratch values; pass an output array to `curl2` when
* calling that vector helper in an animation loop.
*/
export const TAU = Math.PI * 2;
export const BAYER8_SIZE = 8;
/** Clamp `value` to an inclusive range. Non-finite values become `min`. */
export function clamp(value, min = 0, max = 1) {
if (!Number.isFinite(value)) return Number.isFinite(min) ? min : 0;
if (value < min) return min;
if (value > max) return max;
return value;
}
/** Linear interpolation without implicit clamping. */
export function lerp(a, b, amount) {
return a + (b - a) * amount;
}
/** Hermite interpolation from 0 to 1 between `edge0` and `edge1`. */
export function smoothstep(edge0, edge1, value) {
if (!Number.isFinite(value)) return 0;
if (edge0 === edge1) return value < edge0 ? 0 : 1;
const t = clamp((value - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
/** Quintic interpolation from 0 to 1 between `edge0` and `edge1`. */
export function smootherstep(edge0, edge1, value) {
if (!Number.isFinite(value)) return 0;
if (edge0 === edge1) return value < edge0 ? 0 : 1;
const t = clamp((value - edge0) / (edge1 - edge0));
return t * t * t * (t * (t * 6 - 15) + 10);
}
/** Positive fractional part, including for negative inputs. */
export function fract(value) {
if (!Number.isFinite(value)) return 0;
return value - Math.floor(value);
}
/** Standard 8x8 Bayer ordered-dither matrix, containing each value 0..63. */
export const BAYER8 = Object.freeze([
Object.freeze([0, 48, 12, 60, 3, 51, 15, 63]),
Object.freeze([32, 16, 44, 28, 35, 19, 47, 31]),
Object.freeze([8, 56, 4, 52, 11, 59, 7, 55]),
Object.freeze([40, 24, 36, 20, 43, 27, 39, 23]),
Object.freeze([2, 50, 14, 62, 1, 49, 13, 61]),
Object.freeze([34, 18, 46, 30, 33, 17, 45, 29]),
Object.freeze([10, 58, 6, 54, 9, 57, 5, 53]),
Object.freeze([42, 26, 38, 22, 41, 25, 37, 21]),
]);
/** Return the centered Bayer threshold for an integer pixel coordinate. */
export function bayer8(x, y) {
const ix = Number.isFinite(x) ? Math.floor(x) & 7 : 0;
const iy = Number.isFinite(y) ? Math.floor(y) & 7 : 0;
return (BAYER8[iy][ix] + 0.5) / 64;
}
export const bayerThreshold = bayer8;
/**
* Quantize a 0..1 value with ordered dithering.
* `levels=2` produces a binary pixel switch; larger values produce terraces.
*/
export function orderedDither(value, x, y, levels = 2) {
const count = clamp(Math.floor(levels), 2, 256);
const scaled = clamp(value) * (count - 1);
const low = Math.floor(scaled);
const high = Math.min(count - 1, low + 1);
const mix = scaled - low;
return (mix > bayer8(x, y) ? high : low) / (count - 1);
}
/** Stable 32-bit hash for numeric, string, bigint, boolean, or null seeds. */
export function hashSeed(seed = 0) {
let hash = 0x811c9dc5;
if (typeof seed === "number" && Number.isFinite(seed)) {
if (Number.isInteger(seed)) {
hash ^= seed >>> 0;
hash = Math.imul(hash, 0x01000193);
} else {
const text = String(seed);
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
}
} else {
const text = typeof seed === "string" ? seed : String(seed ?? 0);
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
}
hash ^= hash >>> 16;
hash = Math.imul(hash, 0x7feb352d);
hash ^= hash >>> 15;
hash = Math.imul(hash, 0x846ca68b);
hash ^= hash >>> 16;
return hash >>> 0;
}
const EMPTY_OPTIONS = Object.freeze({});
const SQRT2 = Math.SQRT2;
const INV_255 = 1 / 255;
function finite(value, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
function option(options, key, fallback, min = -Infinity, max = Infinity) {
if (!options) return fallback;
const value = options[key];
if (!Number.isFinite(value)) return fallback;
return clamp(value, min, max);
}
function optionInt(options, key, fallback, min, max) {
return Math.floor(option(options, key, fallback, min, max));
}
function contrast(value, amount) {
return clamp((value - 0.5) * amount + 0.5);
}
function fade(value) {
return value * value * value * (value * (value * 6 - 15) + 10);
}
function grad2(hash, x, y) {
switch (hash & 7) {
case 0: return x;
case 1: return -x;
case 2: return y;
case 3: return -y;
case 4: return (x + y) * Math.SQRT1_2;
case 5: return (-x + y) * Math.SQRT1_2;
case 6: return (x - y) * Math.SQRT1_2;
default: return (-x - y) * Math.SQRT1_2;
}
}
function grad3(hash, x, y, z) {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : h === 12 || h === 14 ? x : z;
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}
function ensureKit(kit) {
return kit instanceof FieldKit ? kit : DEFAULT_FIELD_KIT;
}
/**
* Seeded noise and named-field sampler.
*
* Construct once and reuse:
* `const fields = new FieldKit("product-icon");`
* `const alpha = fields.sample("electric", x, y, seconds);`
*/
export class FieldKit {
constructor(seed = 0) {
const initialSeed =
seed && typeof seed === "object" && "seed" in seed ? seed.seed : seed;
this.seed = hashSeed(initialSeed);
this.permutation = new Uint8Array(512);
// Scalar scratch state keeps cellular/curl samplers allocation-free.
this._cellF1 = 0;
this._cellF2 = 0;
this._cellHash = 0;
this._curlX = 0;
this._curlY = 0;
this._curlMagnitude = 0;
this._buildPermutation();
}
/** Rebuild the lookup table from a new seed and return this instance. */
reseed(seed = 0) {
this.seed = hashSeed(seed);
this._buildPermutation();
return this;
}
setSeed(seed = 0) {
return this.reseed(seed);
}
/** Create an independent sampler with the same seed. */
clone() {
const copy = new FieldKit(0);
copy.seed = this.seed;
copy.permutation.set(this.permutation);
return copy;
}
/** Frozen list of canonical named scalar fields. */
list() {
return FIELD_IDS;
}
has(id) {
return resolveFieldId(id) !== null;
}
/**
* Return the standalone sampler function for an ID, or undefined.
* Standalone samplers accept `(x, y, time, options, kit)`.
*/
get(id) {
const resolved = resolveFieldId(id);
return resolved === null ? undefined : FIELDS[resolved];
}
/** Sample a canonical field or alias. Unknown IDs safely return 0. */
sample(id, x, y, time = 0, options = EMPTY_OPTIONS) {
const resolved = resolveFieldId(id);
if (resolved === null) return 0;
const value = FIELDS[resolved](
finite(x),
finite(y),
finite(time),
options || EMPTY_OPTIONS,
this,
);
return clamp(value);
}
/**
* Seeded lattice hash in 0..1. Useful for deterministic sprite decisions.
* Inputs are treated as integer lattice coordinates.
*/
hash2(x, y, salt = 0) {
const p = this.permutation;
const xi = finite(Math.floor(x)) & 255;
const yi = finite(Math.floor(y)) & 255;
const si = finite(Math.floor(salt)) & 255;
return p[xi + p[yi + p[si]]] * INV_255;
}
/** Signed 2D gradient noise in -1..1. */
signedNoise2(x, y) {
return this._signedNoise2(finite(x), finite(y));
}
/** 2D gradient noise remapped to 0..1. */
noise2(x, y) {
return this._signedNoise2(finite(x), finite(y)) * 0.5 + 0.5;
}
/** Signed 3D gradient noise in -1..1; use z as animation time. */
signedNoise3(x, y, z) {
return this._signedNoise3(finite(x), finite(y), finite(z));
}
/** 3D gradient noise remapped to 0..1. */
noise3(x, y, z) {
return this._signedNoise3(finite(x), finite(y), finite(z)) * 0.5 + 0.5;
}
/** Seeded smooth value noise in 0..1. */
value2(x, y) {
x = finite(x);
y = finite(y);
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const tx = fade(x - x0);
const ty = fade(y - y0);
const p = this.permutation;
const ax = x0 & 255;
const ay = y0 & 255;
const bx = (x0 + 1) & 255;
const by = (y0 + 1) & 255;
const a = p[ax + p[ay]] * INV_255;
const b = p[bx + p[ay]] * INV_255;
const c = p[ax + p[by]] * INV_255;
const d = p[bx + p[by]] * INV_255;
return clamp(lerp(lerp(a, b, tx), lerp(c, d, tx), ty));
}
/** Fractal gradient noise in 0..1. */
fbmNoise(x, y, z = 0, options = EMPTY_OPTIONS) {
const octaves = optionInt(options, "octaves", 5, 1, 9);
const lacunarity = option(options, "lacunarity", 2, 1.01, 4);
const gain = option(options, "gain", 0.5, 0.05, 0.95);
return this._fbmSigned(
finite(x),
finite(y),
finite(z),
octaves,
lacunarity,
gain,
) * 0.5 + 0.5;
}
/** Multi-octave ridged noise in 0..1. */
ridgedNoise(x, y, z = 0, options = EMPTY_OPTIONS) {
return this._ridged(
finite(x),
finite(y),
finite(z),
optionInt(options, "octaves", 5, 1, 9),
option(options, "lacunarity", 2.05, 1.01, 4),
option(options, "gain", 0.52, 0.05, 0.95),
);
}
/**
* Normalized curl vector of a scalar noise potential.
* Supply `out` (array or typed array) to avoid the one fallback allocation.
*/
curl2(x, y, z = 0, epsilon = 0.0125, out) {
this._setCurl(
finite(x),
finite(y),
finite(z),
clamp(finite(epsilon, 0.0125), 0.0001, 0.25),
);
const target = out || new Float32Array(2);
target[0] = this._curlX;
target[1] = this._curlY;
return target;
}
/** Nearest animated Worley feature distance, normalized to 0..1. */
cellular2(x, y, time = 0, options = EMPTY_OPTIONS) {
this._setCellular(
finite(x),
finite(y),
finite(time),
option(options, "jitter", 0.88, 0, 1),
option(options, "motion", 0.1, 0, 0.45),
);
return clamp(Math.sqrt(this._cellF1) / SQRT2);
}
/** Difference between smoothly seed-morphed Voronoi features, in 0..1. */
voronoi2(x, y, time = 0, options = EMPTY_OPTIONS) {
this._setCellular(
finite(x),
finite(y),
finite(time) + option(options, "phase", 0, -10000, 10000),
option(options, "jitter", 0.88, 0, 1),
option(options, "motion", 0.16, 0, 0.45),
option(options, "seedRate", 0.9, 0, 8),
);
return clamp((Math.sqrt(this._cellF2) - Math.sqrt(this._cellF1)) / SQRT2);
}
// Named convenience methods mirror `sample` without string lookup.
fbm(x, y, time = 0, options) {
return fbm(x, y, time, options, this);
}
ridged(x, y, time = 0, options) {
return ridged(x, y, time, options, this);
}
domainWarp(x, y, time = 0, options) {
return domainWarp(x, y, time, options, this);
}
curl(x, y, time = 0, options) {
return curl(x, y, time, options, this);
}
flow(x, y, time = 0, options) {
return flow(x, y, time, options, this);
}
worley(x, y, time = 0, options) {
return worley(x, y, time, options, this);
}
voronoi(x, y, time = 0, options) {
return voronoi(x, y, time, options, this);
}
plasma(x, y, time = 0, options) {
return plasma(x, y, time, options, this);
}
interference(x, y, time = 0, options) {
return interference(x, y, time, options, this);
}
vortex(x, y, time = 0, options) {
return vortex(x, y, time, options, this);
}
metaballs(x, y, time = 0, options) {
return metaballs(x, y, time, options, this);
}
caustics(x, y, time = 0, options) {
return caustics(x, y, time, options, this);
}
strata(x, y, time = 0, options) {
return strata(x, y, time, options, this);
}
radar(x, y, time = 0, options) {
return radar(x, y, time, options, this);
}
constellation(x, y, time = 0, options) {
return constellation(x, y, time, options, this);
}
liquid(x, y, time = 0, options) {
return liquid(x, y, time, options, this);
}
electric(x, y, time = 0, options) {
return electric(x, y, time, options, this);
}
ripple(x, y, time = 0, options) {
return ripple(x, y, time, options, this);
}
kaleidoscope(x, y, time = 0, options) {
return kaleidoscope(x, y, time, options, this);
}
_buildPermutation() {
const p = this.permutation;
for (let i = 0; i < 256; i += 1) p[i] = i;
let state = this.seed || 0x6d2b79f5;
for (let i = 255; i > 0; i -= 1) {
state += 0x6d2b79f5;
let random = state;
random = Math.imul(random ^ (random >>> 15), random | 1);
random ^= random + Math.imul(random ^ (random >>> 7), random | 61);
random = (random ^ (random >>> 14)) >>> 0;
const j = Math.floor((random / 0x100000000) * (i + 1));
const swap = p[i];
p[i] = p[j];
p[j] = swap;
}
for (let i = 0; i < 256; i += 1) p[i + 256] = p[i];
}
_signedNoise2(x, y) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const xf = x - x0;
const yf = y - y0;
const u = fade(xf);
const v = fade(yf);
const p = this.permutation;
const xi = x0 & 255;
const yi = y0 & 255;
const aa = p[xi + p[yi]];
const ba = p[xi + 1 + p[yi]];
const ab = p[xi + p[yi + 1]];
const bb = p[xi + 1 + p[yi + 1]];
const low = lerp(grad2(aa, xf, yf), grad2(ba, xf - 1, yf), u);
const high = lerp(
grad2(ab, xf, yf - 1),
grad2(bb, xf - 1, yf - 1),
u,
);
return clamp(lerp(low, high, v) * 1.55, -1, 1);
}
_signedNoise3(x, y, z) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const xf = x - x0;
const yf = y - y0;
const zf = z - z0;
const u = fade(xf);
const v = fade(yf);
const w = fade(zf);
const p = this.permutation;
const xi = x0 & 255;
const yi = y0 & 255;
const zi = z0 & 255;
const a = p[xi] + yi;
const aa = p[a] + zi;
const ab = p[a + 1] + zi;
const b = p[xi + 1] + yi;
const ba = p[b] + zi;
const bb = p[b + 1] + zi;
const zLow = lerp(
lerp(
grad3(p[aa], xf, yf, zf),
grad3(p[ba], xf - 1, yf, zf),
u,
),
lerp(
grad3(p[ab], xf, yf - 1, zf),
grad3(p[bb], xf - 1, yf - 1, zf),
u,
),
v,
);
const zHigh = lerp(
lerp(
grad3(p[aa + 1], xf, yf, zf - 1),
grad3(p[ba + 1], xf - 1, yf, zf - 1),
u,
),
lerp(
grad3(p[ab + 1], xf, yf - 1, zf - 1),
grad3(p[bb + 1], xf - 1, yf - 1, zf - 1),
u,
),
v,
);
return clamp(lerp(zLow, zHigh, w) * 0.94, -1, 1);
}
_fbmSigned(x, y, z, octaves, lacunarity, gain) {
let sum = 0;
let amplitude = 0.5;
let normalization = 0;
for (let octave = 0; octave < octaves; octave += 1) {
sum += this._signedNoise3(x, y, z) * amplitude;
normalization += amplitude;
// Rotate and offset between octaves to suppress axial lattice artifacts.
const nextX = (x * 0.8 - y * 0.6) * lacunarity + 17.17;
y = (x * 0.6 + y * 0.8) * lacunarity - 9.23;
x = nextX;
z = z * lacunarity + 5.71;
amplitude *= gain;
}
return normalization > 0 ? clamp(sum / normalization, -1, 1) : 0;
}
_ridged(x, y, z, octaves, lacunarity, gain) {
let sum = 0;
let amplitude = 0.5;
let normalization = 0;
let weight = 1;
for (let octave = 0; octave < octaves; octave += 1) {
let ridge = 1 - Math.abs(this._signedNoise3(x, y, z));
ridge *= ridge;
ridge *= weight;
weight = clamp(ridge * 2.25);
sum += ridge * amplitude;
normalization += amplitude;
const nextX = (x * 0.764 - y * 0.645) * lacunarity + 11.37;
y = (x * 0.645 + y * 0.764) * lacunarity + 3.19;
x = nextX;
z = z * lacunarity - 4.31;
amplitude *= gain;
}
return normalization > 0 ? clamp(sum / normalization) : 0;
}
_setCurl(x, y, z, epsilon) {
const dY =
(this._signedNoise3(x, y + epsilon, z) -
this._signedNoise3(x, y - epsilon, z)) /
(epsilon * 2);
const dX =
(this._signedNoise3(x + epsilon, y, z) -
this._signedNoise3(x - epsilon, y, z)) /
(epsilon * 2);
const vx = dY;
const vy = -dX;
const magnitude = Math.hypot(vx, vy);
this._curlMagnitude = Number.isFinite(magnitude) ? magnitude : 0;
if (magnitude > 1e-9 && Number.isFinite(magnitude)) {
this._curlX = vx / magnitude;
this._curlY = vy / magnitude;
} else {
this._curlX = 0;
this._curlY = 0;
}
}
_setCellular(x, y, time, jitter, motion, seedRate) {
const baseX = Math.floor(x);
const baseY = Math.floor(y);
let f1 = Infinity;
let f2 = Infinity;
let nearestHash = 0;
const p = this.permutation;
const morphing = seedRate !== undefined && motion > 0;
const seedPhase = morphing ? time * seedRate : 0;
const seedEpoch = Math.floor(seedPhase);
const seedU = morphing ? fract(seedPhase) : 0;
const seedU2 = seedU * seedU;
const seedU3 = seedU2 * seedU;
const seedB0 = ((1 - seedU) * (1 - seedU) * (1 - seedU)) / 6;
const seedB1 = (4 - 6 * seedU2 + 3 * seedU3) / 6;
const seedB2 = (1 + 3 * seedU + 3 * seedU2 - 3 * seedU3) / 6;
const seedB3 = seedU3 / 6;
const seedSalt0 = morphing ? p[(seedEpoch - 1) & 255] : 0;
const seedSalt1 = morphing ? p[seedEpoch & 255] : 0;
const seedSalt2 = morphing ? p[(seedEpoch + 1) & 255] : 0;
const seedSalt3 = morphing ? p[(seedEpoch + 2) & 255] : 0;
const anchorScale = morphing ? Math.max(0, 1 - motion * 2) : 1;
for (let oy = -1; oy <= 1; oy += 1) {
const cellY = baseY + oy;
const py = cellY & 255;
for (let ox = -1; ox <= 1; ox += 1) {
const cellX = baseX + ox;
const px = cellX & 255;
const hashA = p[px + p[py]];
const hashB = p[px + p[py + 71]];
const hashC = p[px + p[py + 149]];
let featureX = 0.5 + (hashA * INV_255 - 0.5) * jitter * anchorScale;
let featureY = 0.5 + (hashB * INV_255 - 0.5) * jitter * anchorScale;
if (morphing) {
const offsetX =
(p[hashA + seedSalt0] * seedB0 +
p[hashA + seedSalt1] * seedB1 +
p[hashA + seedSalt2] * seedB2 +
p[hashA + seedSalt3] * seedB3) *
INV_255 *
2 -
1;
const offsetY =
(p[hashB + seedSalt0] * seedB0 +
p[hashB + seedSalt1] * seedB1 +
p[hashB + seedSalt2] * seedB2 +
p[hashB + seedSalt3] * seedB3) *
INV_255 *
2 -
1;
featureX += offsetX * motion;
featureY += offsetY * motion;
} else {
const phase = hashC * INV_255 * TAU;
featureX += Math.sin(time + phase) * motion;
featureY += Math.cos(time * 0.91 + phase) * motion;
}
featureX = clamp(featureX, 0.015, 0.985);
featureY = clamp(featureY, 0.015, 0.985);
const dx = cellX + featureX - x;
const dy = cellY + featureY - y;
const distance = dx * dx + dy * dy;
if (distance < f1) {
f2 = f1;
f1 = distance;
nearestHash = hashC;
} else if (distance < f2) {
f2 = distance;
}
}
}
this._cellF1 = Number.isFinite(f1) ? f1 : 0;
this._cellF2 = Number.isFinite(f2) ? f2 : this._cellF1;
this._cellHash = nearestHash;
}
}
/** Soft multi-octave gradient noise. */
export function fbm(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.35, 0.001, 256);
const speed = option(options, "speed", 0.18, -20, 20);
const value =
kit._fbmSigned(
finite(x) * frequency,
finite(y) * frequency,
finite(time) * speed + option(options, "phase", 0, -10000, 10000),
optionInt(options, "octaves", 5, 1, 9),
option(options, "lacunarity", 2, 1.01, 4),
option(options, "gain", 0.5, 0.05, 0.95),
) *
0.5 +
0.5;
return contrast(value, option(options, "contrast", 1.08, 0, 8));
}
/** Sharp mountain/filament ridges with octave feedback. */
export function ridged(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.7, 0.001, 256);
const value = kit._ridged(
finite(x) * frequency,
finite(y) * frequency,
finite(time) * option(options, "speed", 0.22, -20, 20),
optionInt(options, "octaves", 5, 1, 9),
option(options, "lacunarity", 2.04, 1.01, 4),
option(options, "gain", 0.53, 0.05, 0.95),
);
return contrast(value, option(options, "contrast", 1.32, 0, 8));
}
/** fBm evaluated through a second pair of animated fBm coordinate fields. */
export function domainWarp(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.1, 0.001, 256);
const speed = option(options, "speed", 0.2, -20, 20);
const strength = option(options, "warp", 0.72, 0, 6);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const pz = finite(time) * speed;
const qx = kit._fbmSigned(px + 5.2, py + 1.3, pz, 3, 2, 0.5);
const qy = kit._fbmSigned(px - 2.8, py + 8.1, pz + 3.4, 3, 2, 0.5);
const value =
kit._fbmSigned(
px + qx * strength,
py + qy * strength,
pz + (qx - qy) * 0.22,
optionInt(options, "octaves", 5, 1, 9),
option(options, "lacunarity", 2.02, 1.01, 4),
option(options, "gain", 0.5, 0.05, 0.95),
) *
0.5 +
0.5;
return contrast(value, option(options, "contrast", 1.18, 0, 8));
}
/** Curl magnitude of animated gradient noise, useful as turbulent density. */
export function curl(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.2, 0.001, 256);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const pz = finite(time) * option(options, "speed", 0.18, -20, 20);
kit._setCurl(
px,
py,
pz,
option(options, "epsilon", 0.018, 0.0001, 0.2),
);
const density =
1 -
Math.exp(
-kit._curlMagnitude * option(options, "strength", 0.72, 0.001, 20),
);
const modulation =
kit._fbmSigned(px * 0.63, py * 0.63, pz + 4.7, 3, 2, 0.5) * 0.5 + 0.5;
return contrast(
density * 0.74 + modulation * 0.26,
option(options, "contrast", 1.2, 0, 8),
);
}
/** Animated ribbons advected along a normalized curl-noise direction. */
export function flow(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.15, 0.001, 256);
const speed = option(options, "speed", 0.24, -20, 20);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const pz = finite(time) * speed;
kit._setCurl(px * 0.72, py * 0.72, pz, 0.02);
const warp = option(options, "warp", 0.65, 0, 6);
const ax = px + kit._curlX * warp;
const ay = py + kit._curlY * warp;
const base = kit._fbmSigned(ax, ay, pz, 4, 2.03, 0.5);
const directional =
ax * kit._curlX * 0.37 + ay * kit._curlY * 0.37 + base * 1.35;
const ribbon =
0.5 +
Math.sin(
directional * TAU -
pz * option(options, "travel", 1.7, -20, 20),
) *
0.5;
const value = ribbon * 0.72 + (base * 0.5 + 0.5) * 0.28;
return contrast(value, option(options, "contrast", 1.28, 0, 8));
}
/** Animated Worley feature islands (bright centers, dark gaps). */
export function worley(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 3.4, 0.001, 256);
kit._setCellular(
finite(x) * frequency,
finite(y) * frequency,
finite(time) * option(options, "speed", 0.52, -20, 20),
option(options, "jitter", 0.9, 0, 1),
option(options, "motion", 0.1, 0, 0.45),
);
const distance = Math.sqrt(kit._cellF1) / SQRT2;
const value =
1 -
smoothstep(
option(options, "inner", 0.035, 0, 1),
option(options, "outer", 0.62, 0.001, 2),
distance,
);
return contrast(value, option(options, "contrast", 1.1, 0, 8));
}
/** Voronoi borders whose feature sites ease between deterministic seed states. */
export function voronoi(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 3.25, 0.001, 256);
kit._setCellular(
finite(x) * frequency,
finite(y) * frequency,
finite(time) * option(options, "speed", 0.43, -20, 20) +
option(options, "phase", 0, -10000, 10000),
option(options, "jitter", 0.92, 0, 1),
option(options, "motion", 0.16, 0, 0.45),
option(options, "seedRate", 0.9, 0, 8),
);
const edgeDistance = Math.sqrt(kit._cellF2) - Math.sqrt(kit._cellF1);
const width = option(options, "width", 0.085, 0.001, 1);
const value = 1 - smoothstep(width, width * 2.75, edgeDistance);
return contrast(value, option(options, "contrast", 1.32, 0, 8));
}
/** Layered sinusoidal plasma with a noise-driven phase field. */
export function plasma(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 1.45, 0.001, 256);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const phase =
finite(time) * option(options, "speed", 0.62, -20, 20) +
option(options, "phase", 0, -10000, 10000);
const noise = kit._fbmSigned(px * 0.7, py * 0.7, phase * 0.18, 3, 2, 0.5);
const a = Math.sin(px * TAU + phase + noise * 1.6);
const b = Math.sin(py * TAU * 1.13 - phase * 1.17 - noise * 1.35);
const c = Math.sin((px + py) * TAU * 0.61 + phase * 0.71 + noise * 2.1);
const d = Math.sin(Math.hypot(px - 0.5, py - 0.5) * TAU * 2.2 - phase);
return contrast(
0.5 + (a + b + c + d) * 0.125,
option(options, "contrast", 1.12, 0, 8),
);
}
/** Traveling circular waves from multiple moving emitters. */
export function interference(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 8.5, 0.01, 256);
const phase = finite(time) * option(options, "speed", 2.1, -50, 50);
const px = finite(x);
const py = finite(y);
const sourceA = phase * 0.23 + kit.seed * 1e-7;
const sourceB = -phase * 0.19 + kit.seed * 1.7e-7;
const ax = 0.24 + Math.sin(sourceA) * 0.09;
const ay = 0.35 + Math.cos(sourceA * 0.87) * 0.11;
const bx = 0.76 + Math.cos(sourceB) * 0.1;
const by = 0.64 + Math.sin(sourceB * 1.09) * 0.09;
const da = Math.hypot(px - ax, py - ay);
const db = Math.hypot(px - bx, py - by);
const waveA = Math.sin(da * frequency * TAU - phase);
const waveB = Math.sin(db * frequency * TAU * 1.037 - phase * 1.11);
const beat = Math.sin((da - db) * frequency * TAU * 0.53 + phase * 0.31);
return contrast(
0.5 + (waveA + waveB) * 0.19 + beat * 0.12,
option(options, "contrast", 1.2, 0, 8),
);
}
/** Rotating noisy spiral arms around a configurable center. */
export function vortex(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const centerX = option(options, "centerX", 0, -1000, 1000);
const centerY = option(options, "centerY", 0, -1000, 1000);
const frequency = option(options, "frequency", 1, 0.001, 256);
const dx = (finite(x) - centerX) * frequency;
const dy = (finite(y) - centerY) * frequency;
const radius = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
const phase = finite(time) * option(options, "speed", 1.4, -50, 50);
const turbulence = kit._fbmSigned(
dx * 2.1 + 4.2,
dy * 2.1 - 3.7,
phase * 0.11,
4,
2,
0.5,
);
const arms = optionInt(options, "arms", 4, 1, 16);
const turns = option(options, "turns", 3.8, -30, 30);
const spiral =
angle * arms + radius * turns * TAU + turbulence * 2.2 - phase;
const value = 0.5 + Math.sin(spiral) * 0.5;
const limit = option(options, "radius", 1.15, 0.01, 20);
const envelope = 1 - smoothstep(limit * 0.72, limit, radius);
return contrast(
value * (0.58 + envelope * 0.42),
option(options, "contrast", 1.25, 0, 8),
);
}
/** Smooth union of deterministic, independently moving metaballs. */
export function metaballs(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 1, 0.001, 256);
const px = fract(finite(x) * frequency);
const py = fract(finite(y) * frequency);
const phase = finite(time) * option(options, "speed", 0.7, -20, 20);
const count = optionInt(options, "count", 6, 2, 16);
const radius = option(options, "radius", 0.115, 0.005, 0.5);
const radiusSquared = radius * radius;
let influence = 0;
for (let i = 0; i < count; i += 1) {
const seedX = kit.hash2(i, 17, 41);
const seedY = kit.hash2(i, 79, 113);
const seedP = kit.hash2(i, 151, 197) * TAU;
const cx = fract(seedX + Math.sin(phase * (0.52 + seedY) + seedP) * 0.17);
const cy = fract(seedY + Math.cos(phase * (0.48 + seedX) + seedP) * 0.17);
let dx = Math.abs(px - cx);
let dy = Math.abs(py - cy);
dx = Math.min(dx, 1 - dx);
dy = Math.min(dy, 1 - dy);
const distanceSquared = dx * dx + dy * dy;
influence += radiusSquared / (distanceSquared + radiusSquared);
}
const threshold = option(options, "threshold", 0.72, 0.05, 8);
return contrast(
smoothstep(threshold * 0.55, threshold * 1.35, influence),
option(options, "contrast", 1.15, 0, 8),
);
}
/** Refractive, sharp cellular light bands resembling water caustics. */
export function caustics(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 4, 0.001, 256);
const phase = finite(time) * option(options, "speed", 0.56, -20, 20);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const warpX = kit._fbmSigned(px * 0.41, py * 0.41, phase * 0.2, 3, 2, 0.5);
const warpY = kit._fbmSigned(
px * 0.41 + 8.7,
py * 0.41 - 2.4,
phase * 0.2,
3,
2,
0.5,
);
kit._setCellular(
px + warpX * 0.42,
py + warpY * 0.42,
phase,
0.96,
0.12,
);
const delta = Math.sqrt(kit._cellF2) - Math.sqrt(kit._cellF1);
const width = option(options, "width", 0.075, 0.001, 0.5);
const cellularLine = 1 - smoothstep(width, width * 3.1, delta);
const noiseLine =
1 -
smoothstep(
0.04,
0.3,
Math.abs(
kit._signedNoise3(px * 0.72 + warpX, py * 0.72 + warpY, phase * 0.24),
),
);
return contrast(
Math.max(cellularLine, noiseLine * 0.68),
option(options, "contrast", 1.45, 0, 8),
);
}
/** Noise-warped sedimentary bands with controllable slope and sharpness. */
export function strata(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 1, 0.001, 256);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const phase = finite(time) * option(options, "speed", 0.12, -20, 20);
const warp = kit._fbmSigned(px * 1.7, py * 1.1, phase, 5, 2, 0.5);
const grain = kit._signedNoise3(px * 6.3, py * 2.2, phase * 0.7);
const bands = option(options, "bands", 8, 0.1, 128);
const slope = option(options, "slope", 0.12, -20, 20);
const position =
(py +
px * slope +
warp * option(options, "warp", 0.16, 0, 5) -
phase * 0.03) *
bands;
let value = 0.5 + Math.sin(position * TAU + grain * 0.35) * 0.5;
value = Math.pow(value, option(options, "sharpness", 1.5, 0.1, 12));
value = value * 0.86 + (grain * 0.5 + 0.5) * 0.14;
return contrast(value, option(options, "contrast", 1.18, 0, 8));
}
/** Rotating radar beam, concentric rings, and deterministic target blips. */
export function radar(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const dx = finite(x) - option(options, "centerX", 0, -1000, 1000);
const dy = finite(y) - option(options, "centerY", 0, -1000, 1000);
const radius = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
const sweep =
finite(time) * option(options, "speed", 1.15, -50, 50) +
option(options, "phase", 0, -10000, 10000);
let angleDelta = angle - sweep;
angleDelta = Math.atan2(Math.sin(angleDelta), Math.cos(angleDelta));
const beamWidth = option(options, "beamWidth", 0.18, 0.005, Math.PI);
const beam = Math.exp(-Math.abs(angleDelta) / beamWidth);
const limit = option(options, "radius", 1.05, 0.01, 20);
const envelope = 1 - smoothstep(limit * 0.86, limit, radius);
const rings = option(options, "rings", 5, 1, 64);
const ringPhase = fract((radius / limit) * rings);
const ringDistance = Math.min(ringPhase, 1 - ringPhase);
const ringLine =
(1 -
smoothstep(
option(options, "ringWidth", 0.025, 0.001, 0.49),
0.12,
ringDistance,
)) *
envelope *
0.34;
let blip = 0;
const targets = optionInt(options, "targets", 7, 0, 20);
for (let i = 0; i < targets; i += 1) {
const tx = kit.hash2(i, 31, 97) * 1.72 - 0.86;
const ty = kit.hash2(i, 83, 181) * 1.72 - 0.86;
const bx = finite(x) - tx;
const by = finite(y) - ty;
const spot = Math.exp(
-(bx * bx + by * by) /
option(options, "blipSize", 0.00085, 0.00001, 0.1),
);
if (spot > blip) blip = spot;
}
return clamp(
Math.max(beam * envelope * 0.78, ringLine, blip * (0.28 + beam * 0.72)),
);
}
/** Twinkling cellular stars connected by faint Voronoi filaments. */
export function constellation(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 4.2, 0.001, 256);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const phase = finite(time) * option(options, "speed", 1.7, -50, 50);
kit._setCellular(px, py, 0, option(options, "jitter", 0.96, 0, 1), 0);
const nearest = Math.sqrt(kit._cellF1);
const edge = Math.sqrt(kit._cellF2) - nearest;
const starSize = option(options, "starSize", 0.12, 0.005, 1);
const star = 1 - smoothstep(starSize * 0.16, starSize, nearest);
const twinkle =
0.68 +
0.32 *
Math.sin(phase + kit._cellHash * INV_255 * TAU + nearest * 8.0);
const lineWidth = option(options, "lineWidth", 0.035, 0.001, 0.5);
const network =
(1 - smoothstep(lineWidth, lineWidth * 3.8, edge)) *
option(options, "lineOpacity", 0.28, 0, 1);
return contrast(
Math.max(star * twinkle, network),
option(options, "contrast", 1.3, 0, 8),
);
}
/** Layered, domain-warped water surface with traveling highlights. */
export function liquid(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 2.3, 0.001, 256);
const phase = finite(time) * option(options, "speed", 0.35, -20, 20);
const px = finite(x) * frequency;
const py = finite(y) * frequency;
const qx = kit._fbmSigned(px * 0.72, py * 0.72, phase, 4, 2, 0.52);
const qy = kit._fbmSigned(
px * 0.72 + 6.2,
py * 0.72 - 1.7,
phase + 2.1,
4,
2,
0.52,
);
const warp = option(options, "warp", 0.58, 0, 6);
const surface = kit._fbmSigned(
px + qx * warp,
py + qy * warp,
phase * 0.72,
optionInt(options, "octaves", 5, 1, 9),
2.03,
0.51,
);
const wave =
0.5 +
Math.sin((py + qx * 0.42) * TAU * 1.35 - phase * 2.4) * 0.5;
const highlight = Math.pow(
1 - Math.abs(kit._signedNoise3(px * 1.8 + qy, py * 1.8, phase) || 0),
5,
);
return contrast(
(surface * 0.5 + 0.5) * 0.52 + wave * 0.31 + highlight * 0.17,
option(options, "contrast", 1.18, 0, 8),
);
}
/** Repeating branching lightning channels with traveling spark intensity. */
export function electric(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const frequency = option(options, "frequency", 1.6, 0.001, 256);
const px = fract(finite(x) * frequency) - 0.5;
const py = finite(y) * frequency;
const phase = finite(time) * option(options, "speed", 1.8, -50, 50);
const path =
kit._fbmSigned(py * 0.46, phase * 0.17, phase * 0.11, 6, 2.12, 0.54) *
option(options, "wander", 0.26, 0, 0.48);
const forkNoise = kit._fbmSigned(
py * 0.93 + 8.4,
phase * 0.23,
phase * 0.19,
4,
2,
0.5,
);
const width = option(options, "width", 0.025, 0.001, 0.3);
const main = Math.exp(-Math.abs(px - path) / width);
const forkOffset = 0.1 + Math.abs(forkNoise) * 0.17;
const forkGate = smoothstep(-0.2, 0.65, forkNoise);
const forkA =
Math.exp(-Math.abs(px - path - forkOffset) / (width * 1.8)) * forkGate;
const forkB =
Math.exp(-Math.abs(px - path + forkOffset) / (width * 1.8)) *
(1 - forkGate);
const pulse =
0.67 +
0.33 *
Math.sin(
py * TAU * option(options, "travel", 2.4, -30, 30) - phase * 4.1,
);
const sparks = kit._ridged(px * 14, py * 8, phase * 0.5, 3, 2.2, 0.46);
return contrast(
Math.max(main, forkA * 0.7, forkB * 0.7) * pulse * (0.72 + sparks * 0.28),
option(options, "contrast", 1.65, 0, 8),
);
}
/** Noise-distorted radial rings radiating from a configurable origin. */
export function ripple(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const centerX = option(options, "centerX", 0, -1000, 1000);
const centerY = option(options, "centerY", 0, -1000, 1000);
const dx = finite(x) - centerX;
const dy = finite(y) - centerY;
const phase = finite(time) * option(options, "speed", 1.5, -50, 50);
const distortion = kit._fbmSigned(dx * 4, dy * 4, phase * 0.12, 4, 2, 0.5);
const radius =
Math.hypot(dx, dy) +
distortion * option(options, "warp", 0.035, 0, 1);
const rings = option(options, "frequency", 11, 0.01, 256);
const wave = 0.5 + Math.sin(radius * rings * TAU - phase * TAU) * 0.5;
const envelope =
1 -
smoothstep(
option(options, "radius", 1.15, 0.01, 20) * 0.82,
option(options, "radius", 1.15, 0.01, 20),
radius,
);
return contrast(
Math.pow(wave, option(options, "sharpness", 2.1, 0.1, 16)) * envelope,
option(options, "contrast", 1.2, 0, 8),
);
}
/** Folded polar noise producing rotating mandala-like symmetry. */
export function kaleidoscope(x, y, time = 0, options = EMPTY_OPTIONS, kit) {
kit = ensureKit(kit);
options = options || EMPTY_OPTIONS;
const dx = finite(x) - option(options, "centerX", 0, -1000, 1000);
const dy = finite(y) - option(options, "centerY", 0, -1000, 1000);
const radius = Math.hypot(dx, dy);
const segments = optionInt(options, "segments", 8, 2, 32);
const sector = TAU / segments;
let angle =
Math.atan2(dy, dx) +
finite(time) * option(options, "speed", 0.32, -20, 20);
angle = fract(angle / sector) * sector;
angle = Math.abs(angle - sector * 0.5);
const px = Math.cos(angle) * radius;
const py = Math.sin(angle) * radius;
const frequency = option(options, "frequency", 7, 0.001, 256);
const noise = kit._fbmSigned(
px * frequency,
py * frequency,
finite(time) * 0.16,
optionInt(options, "octaves", 5, 1, 9),
2.03,
0.5,
);
const rings =
0.5 +
Math.sin(
radius * TAU * option(options, "rings", 5, 0, 64) + noise * 2.3,
) *
0.5;
return contrast(
(noise * 0.5 + 0.5) * 0.58 + rings * 0.42,
option(options, "contrast", 1.24, 0, 8),
);
}
/**
* Canonical named samplers. Each function accepts
* `(x, y, time = 0, options = {}, kit = DEFAULT_FIELD_KIT)` and returns 0..1.
*/
export const FIELDS = Object.freeze({
fbm,
ridged,
"domain-warp": domainWarp,
curl,
flow,
worley,
voronoi,
plasma,
interference,
vortex,
metaballs,
caustics,
strata,
radar,
constellation,
liquid,
electric,
ripple,
kaleidoscope,
});
export const FIELD_IDS = Object.freeze(Object.keys(FIELDS));
/** Compatibility aliases accepted by `FieldKit.sample`, `get`, and `has`. */
export const FIELD_ALIASES = Object.freeze({
noise: "fbm",
turbulence: "fbm",
ridge: "ridged",
domainWarp: "domain-warp",
domain_warp: "domain-warp",
warp: "domain-warp",
"curl-flow": "flow",
curlFlow: "flow",
cellular: "worley",
cells: "voronoi",
cell: "voronoi",
water: "liquid",
lightning: "electric",
waves: "ripple",
mandala: "kaleidoscope",
});
function resolveFieldId(id) {
if (typeof id !== "string") return null;
if (Object.prototype.hasOwnProperty.call(FIELDS, id)) return id;
if (Object.prototype.hasOwnProperty.call(FIELD_ALIASES, id)) {
return FIELD_ALIASES[id];
}
// Normalize only the uncommon path so canonical per-pixel sampling allocates
// no temporary strings.
const normalized = id.trim().toLowerCase().replace(/[\s_]+/g, "-");
if (Object.prototype.hasOwnProperty.call(FIELDS, normalized)) {
return normalized;
}
if (Object.prototype.hasOwnProperty.call(FIELD_ALIASES, normalized)) {
return FIELD_ALIASES[normalized];
}
return null;
}
/** Shared seed-0 kit for the standalone sampler functions. */
export const DEFAULT_FIELD_KIT = new FieldKit(0);
/** Factory form for consumers that prefer functions over constructors. */
export function createFieldKit(seed = 0) {
return new FieldKit(seed);
}
export default FieldKit;