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
1324 lines
41 KiB
JavaScript
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;
|