/* perlin-noise standalone bundle — generated by scripts/build-standalone.js.
Do not edit; run the script to regenerate. @license AGPL-3.0-or-later */
/* ===== perlin.js ===== */
/**
* perlin.js — Ken Perlin's "Improved Noise" in 3D and 4D, fBm, and color modes.
*
* Vanilla JS, no dependencies. An IIFE exports to `window.MTPerlin`.
* The whole module is also available as a string under `MTPerlin.WORKER_SOURCE`,
* so it can be loaded 1:1 into a Web Worker (Blob URL) — essential for the
* smooth animation in the studio.
*
* Public API (`window.MTPerlin`):
* - `makePermutation(base)` → Uint8Array(512) — seeded permutation
* - `perlin3(perm, x, y, z)` → value in roughly [-1, 1]
* - `perlin4(perm, x, y, z, w)` → value in roughly [-1, 1]
* - `fbm3(perm, x, y, z, octaves, persistence, lacunarity)` → value in [0, 1]
* - `fbm4(perm, x, y, z, w, octaves, persistence, lacunarity)` → value in [0, 1]
* - `generatePerlinImage(params, ctx)` renders via the 3D/4D path into a canvas ctx
* - `colorize(noiseValue, params)` → `[r, g, b]` in 0–255
* - `shadeRelief(rgb, gx, gy, params)` → lit `[r, g, b]` (relief lighting, `params.reliefOn`)
* - `WORKER_SOURCE` — full module code as a string for inline workers
*
* Color modes (`params.colorMode`):
* - `"rgb"` per-channel scale + invert (default, like the Python original)
* - `"gradient"` color stops `[{pos:0..1, color:[r,g,b]}, ...]`, linearly interpolated
* - `"hsv"` noise → hue, hsvSat/hsvVal as constants
* - `"posterize"` noise quantised to N steps, then mapped onto a gradient
* - `"cosine"` a + b·cos(2π(c·n + d)) per channel, `params.cosine = {a,b,c,d}` (three numbers each)
*
* Animation loop (`params.loop = true`):
* Instead of running linearly through the time axis, the 4D noise traces a
* circular orbit in `(z, w) = (cos(α)·r, sin(α)·r)`. After one full
* revolution (α = 2π), z and w match the starting position
* → a mathematically seamless loop.
*
* @license AGPL-3.0-or-later
*/
(function () {
"use strict";
const BASE_PERM = [
151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,
8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,
35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,
134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,
55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,
18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,
226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,
17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,
167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,
246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,
14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,
150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,
180
];
/**
* Builds a seeded permutation table for Perlin noise.
* Shifts the classic `BASE_PERM` deterministically by `base` steps so every
* integer seed produces a distinct noise field. The table is duplicated
* (512 entries) so `perm[i + 1]` lookups avoid a modulo — faster in the
* inner render loop.
*
* @param {number} base Seed (any integer)
* @returns {Uint8Array} 512 bytes, the permutation table
*/
function makePermutation(base) {
const offset = ((base | 0) % 256 + 256) % 256;
const perm = new Uint8Array(512);
for (let i = 0; i < 256; i++) perm[i] = BASE_PERM[(i + offset) & 255];
for (let i = 256; i < 512; i++) perm[i] = perm[i - 256];
return perm;
}
// Ken Perlin's "fade" polynomial 6t⁵ − 15t⁴ + 10t³: enforces C² continuity
// at grid points (first and second derivative are zero there).
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(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);
}
/**
* 3D Perlin noise (Improved Noise, Ken Perlin 2002).
* Value range roughly `[-0.88, 0.88]`. At grid points (integer
* coordinates) it's exactly 0 — by design, not a bug.
*
* @param {Uint8Array} perm Permutation from `makePermutation`
* @param {number} x Coordinate (arbitrary)
* @param {number} y
* @param {number} z
* @returns {number} Noise value in roughly `[-1, 1]`
*/
// Wrap an integer cell index for tileable noise. `period=0` falls back
// to the classic `& 255` infinite-noise behaviour — bit-identical to
// the pre-tileable code, that's the regression guarantee.
function wrapCell(v, period) {
return period > 0 ? (((v % period) + period) % period) & 255 : v & 255;
}
function nextCell(v, period) {
return period > 0 ? ((((v + 1) % period) + period) % period) & 255 : (v + 1) & 255;
}
function perlin3(perm, x, y, z, repeatX, repeatY) {
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
const rx = repeatX || 0;
const ry = repeatY || 0;
const Xa = wrapCell(xi, rx), Xb = nextCell(xi, rx);
const Ya = wrapCell(yi, ry), Yb = nextCell(yi, ry);
const Za = zi & 255, Zb = (zi + 1) & 255;
x -= xi; y -= yi; z -= zi;
const u = fade(x), v = fade(y), w = fade(z);
// Eight-corner hash, written with explicit X/Y wrap so the tile
// closes seamlessly. (Z does not tile — loop mode uses tiny Z and
// doesn't benefit from spatial tiling on the time axis.)
const Aa = perm[Xa] + Ya, Ab = perm[Xa] + Yb;
const Ba = perm[Xb] + Ya, Bb = perm[Xb] + Yb;
const Aa0 = perm[Aa] + Za, Aa1 = perm[Aa] + Zb;
const Ab0 = perm[Ab] + Za, Ab1 = perm[Ab] + Zb;
const Ba0 = perm[Ba] + Za, Ba1 = perm[Ba] + Zb;
const Bb0 = perm[Bb] + Za, Bb1 = perm[Bb] + Zb;
return lerp(
lerp(
lerp(grad(perm[Aa0], x, y, z), grad(perm[Ba0], x - 1, y, z), u),
lerp(grad(perm[Ab0], x, y - 1, z), grad(perm[Bb0], x - 1, y - 1, z), u),
v
),
lerp(
lerp(grad(perm[Aa1], x, y, z - 1), grad(perm[Ba1], x - 1, y, z - 1), u),
lerp(grad(perm[Ab1], x, y - 1, z - 1), grad(perm[Bb1], x - 1, y - 1, z - 1), u),
v
),
w
);
}
/**
* fBm (Fractal Brownian Motion) on top of `perlin3`.
* Sums several octaves with progressively higher frequency and smaller
* amplitude. Normalised to `[0, 1]`.
*
* @param {Uint8Array} perm
* @param {number} x
* @param {number} y
* @param {number} z
* @param {number} octaves Octave count (1–10 is sensible)
* @param {number} persistence Amplitude factor per octave (0.5 = standard)
* @param {number} lacunarity Frequency factor per octave (2.0 = standard)
* @returns {number} Value in `[0, 1]`
*/
function fbm3(perm, x, y, z, octaves, persistence, lacunarity, repeatX, repeatY) {
const rx0 = repeatX || 0;
const ry0 = repeatY || 0;
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
// Tile period scales with octave frequency so all octaves agree on
// where the seam is. Rounded to the nearest integer cell — small
// mismatch for non-integer lacunarity is unavoidable, but with the
// default lacunarity = 2 every octave lands on an integer multiple.
const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
sum += amp * perlin3(perm, x * freq, y * freq, z * freq, rx, ry);
norm += amp;
amp *= persistence;
freq *= lacunarity;
}
const n = norm > 0 ? sum / norm : 0;
return (n + 1) * 0.5;
}
// ---------- 4D NOISE (for loopable animation) ----------
// 32 4D gradient vectors from Ken Perlin's Improved Noise reference (Java).
// Each of the 16 corners of a 4D hypercube gets one of these gradients via
// a hash; the dot product with the offset vector gives that corner's
// contribution. A circular path in (z, w) yields a perfectly seamless loop
// because cos/sin return to the starting position after 2π.
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],
];
function grad4(hash, x, y, z, w) {
const g = GRAD4[hash & 31];
return g[0] * x + g[1] * y + g[2] * z + g[3] * w;
}
/**
* 4D Perlin noise. Same idea as `perlin3`, but with 16 hypercube corners
* and a 4-deep nested lerp. The studio only uses it for loop mode: a
* circular orbit in `(z, w)` closes seamlessly.
*
* @param {Uint8Array} perm
* @param {number} x
* @param {number} y
* @param {number} z
* @param {number} w
* @returns {number} Noise value in roughly `[-1, 1]`
*/
function perlin4(perm, x, y, z, w, repeatX, repeatY) {
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z), wi = Math.floor(w);
const rx = repeatX || 0;
const ry = repeatY || 0;
const Xa = wrapCell(xi, rx), Xb = nextCell(xi, rx);
const Ya = wrapCell(yi, ry), Yb = nextCell(yi, ry);
const Za = zi & 255, Zb = (zi + 1) & 255;
const Wa = wi & 255, Wb = (wi + 1) & 255;
x -= xi; y -= yi; z -= zi; w -= wi;
const u = fade(x), v = fade(y), s = fade(z), tt = fade(w);
// Hash tree along X → Y → Z → W. Suffixes: a/b = low/high cell per axis,
// chosen explicitly so X and Y can wrap for tileability while Z and W
// pass through unchanged. The internal +1 lookups still go through the
// duplicated perm table; only the X / Y entry points are wrap-aware.
const Aa = perm[Xa] + Ya;
const Ab = perm[Xa] + Yb;
const Ba = perm[Xb] + Ya;
const Bb = perm[Xb] + Yb;
const AA = perm[Aa] + Za;
const AB = perm[Ab] + Za;
const BA = perm[Ba] + Za;
const BB = perm[Bb] + Za;
const AAA = perm[AA] + Wa, AAB = perm[AA + 1] + Wa;
const ABA = perm[AB] + Wa, ABB = perm[AB + 1] + Wa;
const BAA = perm[BA] + Wa, BAB = perm[BA + 1] + Wa;
const BBA = perm[BB] + Wa, BBB = perm[BB + 1] + Wa;
// 16 corners → 4-deep nested lerp.
// Hash convention: AAB = Z shift (perm[AA+1]+W), AAA+1 = W shift (perm[AA]+W+1).
// → Offsets must line up: AAB uses z-1, AAA+1 uses w-1.
// Lerp order from inside out: u (X) → v (Y) → s (Z) → tt (W).
return lerp(
// W-low half
lerp(
// Z-low
lerp(
lerp(grad4(perm[AAA], x, y, z, w), grad4(perm[BAA], x - 1, y, z, w), u),
lerp(grad4(perm[ABA], x, y - 1, z, w), grad4(perm[BBA], x - 1, y - 1, z, w), u),
v
),
// Z-high (AAB = perm[AA+1]+W is a Z shift → offset z-1, w stays)
lerp(
lerp(grad4(perm[AAB], x, y, z - 1, w), grad4(perm[BAB], x - 1, y, z - 1, w), u),
lerp(grad4(perm[ABB], x, y - 1, z - 1, w), grad4(perm[BBB], x - 1, y - 1, z - 1, w), u),
v
),
s
),
// W-high half (AAA+1 is a W shift → offset w-1, z stays)
lerp(
// Z-low
lerp(
lerp(grad4(perm[AAA + 1], x, y, z, w - 1), grad4(perm[BAA + 1], x - 1, y, z, w - 1), u),
lerp(grad4(perm[ABA + 1], x, y - 1, z, w - 1), grad4(perm[BBA + 1], x - 1, y - 1, z, w - 1), u),
v
),
// Z-high (AAB+1 = (Z shift)+1 = Z and W shift combined → offset z-1, w-1)
lerp(
lerp(grad4(perm[AAB + 1], x, y, z - 1, w - 1), grad4(perm[BAB + 1], x - 1, y, z - 1, w - 1), u),
lerp(grad4(perm[ABB + 1], x, y - 1, z - 1, w - 1), grad4(perm[BBB + 1], x - 1, y - 1, z - 1, w - 1), u),
v
),
s
),
tt
);
}
/** fBm on top of `perlin4`. Value range `[0, 1]`. */
function fbm4(perm, x, y, z, w, octaves, persistence, lacunarity, repeatX, repeatY) {
const rx0 = repeatX || 0;
const ry0 = repeatY || 0;
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
sum += amp * perlin4(perm, x * freq, y * freq, z * freq, w * freq, rx, ry);
norm += amp;
amp *= persistence;
freq *= lacunarity;
}
const n = norm > 0 ? sum / norm : 0;
return (n + 1) * 0.5;
}
// ---------- CELLULAR FIELDS (Voronoi) ----------
// The second kind of field next to Perlin: distance to the nearest of a jittered grid of feature
// points (Worley / Voronoi). The grid hash is the SAME permutation table as the Perlin noise, so
// `base` seeds it and tiling wraps cells the same way. Time does not move a third axis: each
// point circles inside its own cell with a per-cell phase, so the field repeats after one turn.
// That makes a seamless loop with a plain 2D 3x3 search (a 4D Worley would search 81 cells).
// `turns` is the fraction of a full turn, folded into [0, 1) by the caller in float64.
//
// kind "cells" F1, distance to the nearest point (dark at the points)
// kind "borders" F2 - F1, close to 0 on the borders between cells
// kind "smooth" -ln(sum e^(-16 d)) / 16, a smooth minimum: soft blobs (Quilez, "Smooth Voronoi")
//
// Octaves add finer cells exactly like fBm adds finer noise; `jitter` 0 is a regular grid, 1 is
// fully random inside each cell. The point never leaves its own cell, which is what makes the
// 3x3 search exact.
function cellFbm(perm, x, y, turns, octaves, persistence, lacunarity, repeatX, repeatY, kind, jitter) {
const rx0 = repeatX || 0;
const ry0 = repeatY || 0;
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
const px = x * freq, py = y * freq;
const xi = Math.floor(px), yi = Math.floor(py);
const fx = px - xi, fy = py - yi;
let f1 = 8, f2 = 8, acc = 0;
for (let j = -1; j <= 1; j++) {
for (let i = -1; i <= 1; i++) {
const cx = wrapCell(xi + i, rx), cy = wrapCell(yi + j, ry);
const h1 = perm[perm[cx] + cy];
const h2 = perm[perm[(cx + 53) & 255] + ((cy + 97) & 255)];
const ox = 0.5 + 0.5 * jitter * Math.sin(Math.PI * 2 * (turns + h1 / 256));
const oy = 0.5 + 0.5 * jitter * Math.sin(Math.PI * 2 * (turns + h2 / 256));
const dx = i + ox - fx, dy = j + oy - fy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) { f2 = d; }
acc += Math.exp(-16 * d);
}
}
let v = kind === "borders" ? f2 - f1 : kind === "smooth" ? -Math.log(acc) / 16 : f1;
v = v < 0 ? 0 : v > 1 ? 1 : v;
sum += amp * v;
norm += amp;
amp *= persistence;
freq *= lacunarity;
}
return norm > 0 ? sum / norm : 0;
}
// ---------- DOMAIN WARPING ----------
// Inigo-Quilez-style domain warp: displace the sample coordinate by a
// second, smoother noise field before running the main fbm. Tiny code,
// huge visual change — Perlin output stops looking like "Perlin" and
// starts looking like marble veins, ink in water, alien terrain.
//
// The displacement uses single-octave `perlin3/4` (not fbm) — cheap,
// smooth, and detail isn't useful in the warp field. Two arbitrary
// offsets (137/23 and 211/-53) decorrelate the x and y displacements
// from each other and from the main field, otherwise you'd get
// diagonal streaking.
//
// `warpStrength=0` skips the warp entirely → byte-identical output to
// the un-warped fbm path. That's the regression guarantee.
// When tile is on AND warp is on, warpScale must be integer for the
// tile to actually close — the warp samples in (x*s + offset) space,
// so for the seam to coincide we need (tileWidth * s) to be an integer
// multiple of the warp's wrap period. The simplest correctness-first
// rule: round warpScale to the nearest non-zero integer whenever both
// features are co-active. Outside this case the user keeps the full
// sub-1.0 fidelity of the warp.
function effectiveWarpScale(warpScale, repeatX, repeatY) {
const s = warpScale || 1;
if ((repeatX > 0 || repeatY > 0) && warpScale) return Math.max(1, Math.round(s));
return s;
}
function warpedFbm3(perm, x, y, z, octaves, persistence, lacunarity, warpStrength, warpScale, repeatX, repeatY, fld) {
if (!warpStrength) {
return fld ? cellFbm(perm, x, y, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
: fbm3(perm, x, y, z, octaves, persistence, lacunarity, repeatX, repeatY);
}
const s = effectiveWarpScale(warpScale, repeatX, repeatY);
// Warp's perlin3 wraps at (tile * s) so the seam in main space coincides.
// With integer s (guaranteed above when tile is on), this is always integer.
const wrx = repeatX > 0 ? repeatX * s : 0;
const wry = repeatY > 0 ? repeatY * s : 0;
const dx = perlin3(perm, x * s + 137.0, y * s + 23.0, z, wrx, wry) * warpStrength;
const dy = perlin3(perm, x * s + 211.0, y * s - 53.0, z, wrx, wry) * warpStrength;
return fld ? cellFbm(perm, x + dx, y + dy, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
: fbm3(perm, x + dx, y + dy, z, octaves, persistence, lacunarity, repeatX, repeatY);
}
function warpedFbm4(perm, x, y, z, w, octaves, persistence, lacunarity, warpStrength, warpScale, repeatX, repeatY, fld) {
if (!warpStrength) {
return fld ? cellFbm(perm, x, y, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
: fbm4(perm, x, y, z, w, octaves, persistence, lacunarity, repeatX, repeatY);
}
const s = effectiveWarpScale(warpScale, repeatX, repeatY);
const wrx = repeatX > 0 ? repeatX * s : 0;
const wry = repeatY > 0 ? repeatY * s : 0;
const dx = perlin4(perm, x * s + 137.0, y * s + 23.0, z, w, wrx, wry) * warpStrength;
const dy = perlin4(perm, x * s + 211.0, y * s - 53.0, z, w, wrx, wry) * warpStrength;
return fld ? cellFbm(perm, x + dx, y + dy, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
: fbm4(perm, x + dx, y + dy, z, w, octaves, persistence, lacunarity, repeatX, repeatY);
}
// ---------- LAYER BLENDING ----------
// Composite two noise samples in [0, 1] with one of a few classic
// blend modes. The studio uses these to stack a second noise field
// ("Layer 2") on top of the main one — e.g. fine grain over slow
// rolling hills, or a contour grid multiplied with a colour field.
// All modes return a value in [0, 1] so the downstream `colorize`
// pipeline is unchanged.
function blendLayers(n1, n2, mode) {
if (mode === "multiply") return n1 * n2;
if (mode === "add") return n1 + n2 > 1 ? 1 : n1 + n2;
if (mode === "screen") return 1 - (1 - n1) * (1 - n2);
if (mode === "darken") return n1 < n2 ? n1 : n2;
if (mode === "lighten") return n1 > n2 ? n1 : n2;
if (mode === "difference") {
const d = n1 - n2;
return d < 0 ? -d : d;
}
return n1; // unknown mode — Layer 2 ignored
}
// ---------- COLOR MODES ----------
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// Linear interpolation over a gradient-stop list.
// stops: [{pos: 0..1, color: [r,g,b]}, ...] sorted by pos.
function gradientLookup(stops, t) {
t = clamp01(t);
if (stops.length === 0) return [0, 0, 0];
if (stops.length === 1) return stops[0].color.slice();
if (t <= stops[0].pos) return stops[0].color.slice();
const last = stops[stops.length - 1];
if (t >= last.pos) return last.color.slice();
for (let i = 1; i < stops.length; i++) {
const a = stops[i - 1], b = stops[i];
if (t <= b.pos) {
const span = b.pos - a.pos;
const u = span > 0 ? (t - a.pos) / span : 0;
return [
Math.round(a.color[0] + (b.color[0] - a.color[0]) * u),
Math.round(a.color[1] + (b.color[1] - a.color[1]) * u),
Math.round(a.color[2] + (b.color[2] - a.color[2]) * u),
];
}
}
return last.color.slice();
}
// HSV → RGB, h in [0,1), s/v in [0,1]
function hsvToRgb(h, s, v) {
h = ((h % 1) + 1) % 1;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
// ---------- RGB MULTI-CHANNEL (per-channel offset noise) ----------
// When the studio's RGB mode runs in "multi-channel" mode, three
// independent noise samples (taken at offset positions, but from the
// same permutation) feed the R / G / B channels separately. The result:
// colour channels drift independently, producing nebula-/film-grain-
// like outputs that the single-noise RGB mode can't reach.
function colorizeRgbMulti(nr, ng, nb, p) {
const r = (p.rInvert ? 1 - nr : nr) * (p.rScale ?? 1);
const g = (p.gInvert ? 1 - ng : ng) * (p.gScale ?? 1);
const b = (p.bInvert ? 1 - nb : nb) * (p.bScale ?? 1);
return [
Math.max(0, Math.min(255, Math.round(r * 255))),
Math.max(0, Math.min(255, Math.round(g * 255))),
Math.max(0, Math.min(255, Math.round(b * 255))),
];
}
// ---------- FLOW FIELD ----------
// Instead of mapping the noise *value* to colour, map the local
// *gradient direction* — i.e. visualise the noise as a vector field.
// Hue = atan2(dy, dx); magnitude tints brightness. Renders Perlin as
// a flow chart of how it'd push a particle around. The render loops
// sample a 3-point stencil per pixel and pass dx / dy here.
function colorizeFlow(dx, dy, p) {
const angle = Math.atan2(dy, dx); // (-π, π]
// Map angle to hue with an optional shift so users can rotate the
// colour wheel. (angle/(2π) + 0.5) brings it into [0, 1).
let hue = angle / (Math.PI * 2) + 0.5 + (p.flowHueShift || 0);
hue = ((hue % 1) + 1) % 1;
const mag = Math.sqrt(dx * dx + dy * dy);
// Magnitude can exceed 1 for noisy/warped fields. Scale by flowGain
// (default 1) and clamp. flowFloor lifts the darkest pixels so the
// image doesn't dissolve into black where the noise is flat.
const gain = p.flowGain ?? 1.0;
const floor = p.flowFloor ?? 0.0;
const v = Math.max(0, Math.min(1, floor + mag * gain * (1 - floor)));
const sat = p.flowSat ?? 0.85;
return hsvToRgb(hue, sat, v);
}
// ---------- RELIEF LIGHTING ----------
// Treat the noise as a height field and light it. The surface slope comes from the same
// 3-point stencil the flow mode uses (so it works with warp, tiling, layer 2 and the loop
// without a derivative per feature); (gx, gy) are slopes per noise-space unit, y pointing
// down like the image. Colour comes from any colour mode, the light only scales it:
// a flat surface keeps its colour (shade is divided by the flat-surface value), slopes
// facing the light brighten, slopes facing away darken, and a Blinn-Phong highlight sits
// on top. Azimuth runs clockwise from the top of the image (0 = light from above, 90 = from
// the right), elevation is degrees above the surface.
function shadeRelief(c, gx, gy, p) {
const gain = p.reliefGain != null ? p.reliefGain : 1;
let nx = -gain * gx, ny = -gain * gy, nz = 1;
const inv = 1 / Math.sqrt(nx * nx + ny * ny + nz * nz);
nx *= inv; ny *= inv; nz *= inv;
const az = (p.lightAzimuth != null ? p.lightAzimuth : 315) * Math.PI / 180;
const el = (p.lightElevation != null ? p.lightElevation : 45) * Math.PI / 180;
const lx = Math.sin(az) * Math.cos(el), ly = -Math.cos(az) * Math.cos(el), lz = Math.sin(el);
const amb = p.reliefAmbient != null ? p.reliefAmbient : 0.35;
const flat = Math.max(1e-3, amb + (1 - amb) * lz);
const shade = (amb + (1 - amb) * Math.max(0, nx * lx + ny * ly + nz * lz)) / flat;
// Half vector for a viewer straight above the image; highlight relative to a flat surface.
let hx = lx, hy = ly, hz = lz + 1;
const hl = Math.sqrt(hx * hx + hy * hy + hz * hz);
hx /= hl; hy /= hl; hz /= hl;
const shine = p.reliefShine != null ? p.reliefShine : 32;
const amt = p.reliefSpec != null ? p.reliefSpec : 0.25;
const hit = Math.max(0, nx * hx + ny * hy + nz * hz);
const hitFlat = Math.max(0, hz);
const spec = amt * Math.max(0, (hit > 0 ? Math.pow(hit, shine) : 0) - (hitFlat > 0 ? Math.pow(hitFlat, shine) : 0)) * 255;
return [
Math.max(0, Math.min(255, Math.round(c[0] * shade + spec))),
Math.max(0, Math.min(255, Math.round(c[1] * shade + spec))),
Math.max(0, Math.min(255, Math.round(c[2] * shade + spec))),
];
}
/**
* Maps a noise value to RGB. Branches on `params.colorMode`.
*
* @param {number} n Noise value in `[0, 1]`
* @param {Object} p Studio parameters (see the module header for color modes)
* @returns {[number, number, number]} RGB in 0–255
*/
function colorize(n, p) {
const mode = p.colorMode || "rgb";
if (mode === "rgb") {
const r = (p.rInvert ? 1 - n : n) * (p.rScale ?? 0);
const g = (p.gInvert ? 1 - n : n) * (p.gScale ?? 1);
const b = (p.bInvert ? 1 - n : n) * (p.bScale ?? 0);
return [
Math.max(0, Math.min(255, Math.round(r * 255))),
Math.max(0, Math.min(255, Math.round(g * 255))),
Math.max(0, Math.min(255, Math.round(b * 255))),
];
}
if (mode === "gradient") {
return gradientLookup(p.gradient || [], n);
}
if (mode === "hsv") {
const hue = ((p.hsvHueShift ?? 0) + n * (p.hsvHueRange ?? 1)) % 1;
return hsvToRgb(hue, p.hsvSat ?? 0.8, p.hsvVal ?? 1.0);
}
if (mode === "posterize") {
const steps = Math.max(2, p.posterizeSteps || 5);
const quantized = Math.floor(n * steps) / (steps - 1);
return gradientLookup(p.gradient || [], Math.min(1, quantized));
}
if (mode === "cosine") {
// Inigo Quilez' cosine palette (iquilezles.org/articles/palettes, code snippets MIT):
// four 3-vectors give a whole colour ramp, no stop list. Kept inline (Math only) so
// colorize.toString() carries it into the worker unchanged.
const q = p.cosine || {};
const a = q.a || [0.5, 0.5, 0.5], b = q.b || [0.5, 0.5, 0.5];
const c = q.c || [1, 1, 1], d = q.d || [0, 0.33, 0.67];
const out = [0, 0, 0];
for (let k = 0; k < 3; k++) {
const v = a[k] + b[k] * Math.cos(Math.PI * 2 * (c[k] * n + d[k]));
out[k] = Math.max(0, Math.min(255, Math.round(v * 255)));
}
return out;
}
return [0, 0, 0];
}
/**
* Renders a Perlin image into a 2D canvas context. Internally branches
* between the 3D path (`params.loop = false`) and the 4D path (loop).
*
* Synchronous — for animations use the worker via `WORKER_SOURCE`,
* otherwise the main thread blocks.
*
* @param {Object} params Studio parameters (see the module header)
* @param {CanvasRenderingContext2D} ctx
*/
function generatePerlinImage(params, ctx) {
const {
width, height, t,
scaleX, scaleY, scaleT,
octaves, persistence, lacunarity, base,
loop, loopProgress, loopRadius,
warpStrength, warpScale,
tileX, tileY,
layer2On, layer2ScaleX, layer2ScaleY, layer2Octaves, layer2Base,
layer2BlendMode, layer2Mix,
colorMode, rgbMultiChannel,
} = params;
const ws = warpStrength || 0;
const wsc = warpScale || 1;
const rx = tileX || 0;
const ry = tileY || 0;
const isFlow = colorMode === "flow";
const isMulti = colorMode === "rgb" && rgbMultiChannel;
const isRelief = !!params.reliefOn && !isFlow && !isMulti;
const reps = params.reliefEps != null ? params.reliefEps : 0.6;
const fieldType = params.fieldType || "perlin";
const isCells = fieldType === "cells" || fieldType === "borders" || fieldType === "smooth";
const cellJitter = params.cellJitter != null ? params.cellJitter : 1;
const eps = (params.flowEps ?? 0.6);
// Layer 2 only composes into the standard render path — combining it
// with the multi-sample flow stencil or per-channel RGB would 6× the
// per-pixel cost and the visual win is unclear. Document the
// restriction in the UI.
const isLayer = !!layer2On && (layer2Mix || 0) > 0 && !isFlow && !isMulti;
const l2sx = layer2ScaleX || scaleX;
const l2sy = layer2ScaleY || scaleY;
const l2oct = layer2Octaves || octaves;
const l2blend = layer2BlendMode || "multiply";
const lmix = layer2Mix || 0;
const perm2 = isLayer ? makePermutation(layer2Base || 0) : null;
const perm = makePermutation(base);
const img = ctx.createImageData(width, height);
const data = img.data;
if (loop) {
// Loop mode: (z, w) trace a circle in 4D noise. Modulo 1 ensures that
// progress=1 is computed exactly like progress=0 —
// Math.sin(2π) is otherwise ~ -2.4e-16 as a float, and Math.floor of
// that pushes the hash to -1, masked to 255 → a completely different
// image.
const radius = (loopRadius || 1) * (scaleT || 1);
const safeProgress = ((loopProgress || 0) % 1 + 1) % 1;
const alpha = safeProgress * Math.PI * 2;
const zc = Math.cos(alpha) * radius;
const wc = Math.sin(alpha) * radius;
const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: safeProgress } : null;
const H = (x, y) => {
const a = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
if (!isLayer) return a;
const b = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r, g, b;
if (isFlow) {
const n0 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n1 = warpedFbm4(perm, (x + eps) * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n2 = warpedFbm4(perm, x * scaleX, (y + eps) * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
[r, g, b] = colorizeFlow(n1 - n0, n2 - n0, params);
} else if (isMulti) {
const nr = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const ng = warpedFbm4(perm, (x * scaleX) + 53.0, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const nb = warpedFbm4(perm, x * scaleX, (y * scaleY) + 71.0, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
[r, g, b] = colorizeRgbMulti(nr, ng, nb, params);
} else if (isRelief) {
const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
[r, g, b] = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
} else {
const n1 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
let n = n1;
if (isLayer) {
const n2 = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
}
[r, g, b] = colorize(n, params);
}
const i = (y * width + x) * 4;
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
}
}
} else {
const tz = (t || 0) * scaleT;
const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: tz - Math.floor(tz) } : null;
const H = (x, y) => {
const a = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
if (!isLayer) return a;
const b = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r, g, b;
if (isFlow) {
const n0 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n1 = warpedFbm3(perm, (x + eps) * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n2 = warpedFbm3(perm, x * scaleX, (y + eps) * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
[r, g, b] = colorizeFlow(n1 - n0, n2 - n0, params);
} else if (isMulti) {
const nr = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const ng = warpedFbm3(perm, (x * scaleX) + 53.0, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const nb = warpedFbm3(perm, x * scaleX, (y * scaleY) + 71.0, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
[r, g, b] = colorizeRgbMulti(nr, ng, nb, params);
} else if (isRelief) {
const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
[r, g, b] = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
} else {
const n1 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
let n = n1;
if (isLayer) {
const n2 = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
}
[r, g, b] = colorize(n, params);
}
const i = (y * width + x) * 4;
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
}
}
}
ctx.putImageData(img, 0, 0);
}
// ---------- WORKER SOURCE ----------
// Loaded by the main thread as a Blob URL so the worker also runs under
// file://. Self-contained copy of the core functions.
const WORKER_SOURCE = `
const BASE_PERM = ${JSON.stringify(BASE_PERM)};
const GRAD4 = ${JSON.stringify(GRAD4)};
${makePermutation.toString()}
${fade.toString()}
${lerp.toString()}
${grad.toString()}
${grad4.toString()}
${wrapCell.toString()}
${nextCell.toString()}
${perlin3.toString()}
${perlin4.toString()}
${fbm3.toString()}
${fbm4.toString()}
${cellFbm.toString()}
${effectiveWarpScale.toString()}
${warpedFbm3.toString()}
${warpedFbm4.toString()}
${blendLayers.toString()}
${clamp01.toString()}
${gradientLookup.toString()}
${hsvToRgb.toString()}
${colorizeRgbMulti.toString()}
${colorizeFlow.toString()}
${colorize.toString()}
${shadeRelief.toString()}
self.onmessage = function(e) {
const { id, params } = e.data;
const { width, height, t, scaleX, scaleY, scaleT,
octaves, persistence, lacunarity, base,
loop, loopProgress, loopRadius,
warpStrength, warpScale,
tileX, tileY,
layer2On, layer2ScaleX, layer2ScaleY, layer2Octaves,
layer2Base, layer2BlendMode, layer2Mix,
colorMode, rgbMultiChannel } = params;
const ws = warpStrength || 0;
const wsc = warpScale || 1;
const rx = tileX || 0;
const ry = tileY || 0;
const isFlow = colorMode === "flow";
const isMulti = colorMode === "rgb" && rgbMultiChannel;
const isRelief = !!params.reliefOn && !isFlow && !isMulti;
const reps = params.reliefEps != null ? params.reliefEps : 0.6;
const fieldType = params.fieldType || "perlin";
const isCells = fieldType === "cells" || fieldType === "borders" || fieldType === "smooth";
const cellJitter = params.cellJitter != null ? params.cellJitter : 1;
const eps = params.flowEps != null ? params.flowEps : 0.6;
const isLayer = !!layer2On && (layer2Mix || 0) > 0 && !isFlow && !isMulti;
const l2sx = layer2ScaleX || scaleX;
const l2sy = layer2ScaleY || scaleY;
const l2oct = layer2Octaves || octaves;
const l2blend = layer2BlendMode || "multiply";
const lmix = layer2Mix || 0;
const perm = makePermutation(base);
const perm2 = isLayer ? makePermutation(layer2Base || 0) : null;
const buf = new Uint8ClampedArray(width * height * 4);
if (loop) {
const radius = (loopRadius || 1) * (scaleT || 1);
const safeProgress = ((loopProgress || 0) % 1 + 1) % 1;
const alpha = safeProgress * Math.PI * 2;
const zc = Math.cos(alpha) * radius;
const wc = Math.sin(alpha) * radius;
const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: safeProgress } : null;
const H = (x, y) => {
const a = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
if (!isLayer) return a;
const b = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let c;
if (isFlow) {
const n0 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n1 = warpedFbm4(perm, (x + eps) * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n2 = warpedFbm4(perm, x * scaleX, (y + eps) * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
c = colorizeFlow(n1 - n0, n2 - n0, params);
} else if (isMulti) {
const nr = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const ng = warpedFbm4(perm, (x * scaleX) + 53.0, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const nb = warpedFbm4(perm, x * scaleX, (y * scaleY) + 71.0, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
c = colorizeRgbMulti(nr, ng, nb, params);
} else if (isRelief) {
const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
c = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
} else {
const n1 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
let n = n1;
if (isLayer) {
const n2 = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
}
c = colorize(n, params);
}
const i = (y * width + x) * 4;
buf[i] = c[0]; buf[i+1] = c[1]; buf[i+2] = c[2]; buf[i+3] = 255;
}
}
} else {
const tz = (t || 0) * scaleT;
const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: tz - Math.floor(tz) } : null;
const H = (x, y) => {
const a = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
if (!isLayer) return a;
const b = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let c;
if (isFlow) {
const n0 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n1 = warpedFbm3(perm, (x + eps) * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const n2 = warpedFbm3(perm, x * scaleX, (y + eps) * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
c = colorizeFlow(n1 - n0, n2 - n0, params);
} else if (isMulti) {
const nr = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const ng = warpedFbm3(perm, (x * scaleX) + 53.0, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
const nb = warpedFbm3(perm, x * scaleX, (y * scaleY) + 71.0, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
c = colorizeRgbMulti(nr, ng, nb, params);
} else if (isRelief) {
const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
c = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
} else {
const n1 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
let n = n1;
if (isLayer) {
const n2 = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
}
c = colorize(n, params);
}
const i = (y * width + x) * 4;
buf[i] = c[0]; buf[i+1] = c[1]; buf[i+2] = c[2]; buf[i+3] = 255;
}
}
}
// Transferable ArrayBuffer — zero-copy back to the main thread.
self.postMessage({ id, width, height, buffer: buf.buffer }, [buf.buffer]);
};
`;
window.MTPerlin = {
makePermutation, perlin3, perlin4, fbm3, fbm4,
warpedFbm3, warpedFbm4, cellFbm, blendLayers,
generatePerlinImage, colorize, colorizeRgbMulti, colorizeFlow, shadeRelief,
WORKER_SOURCE,
};
})();
/* ===== perlin-presets.js ===== */
/**
* perlin-presets.js — colour gradients, built-in presets, and param resolution.
*
* Shared by the studio controller (perlin-studio.js) and the embeddable
* <perlin-noise> element (perlin-element.js) so both turn a preset into a
* renderable params object identically.
*
* Plain script: attaches `window.MTPerlinPresets`. Also CommonJS-exportable so
* the smoke test can import `resolveParams` under Node.
*
* @license AGPL-3.0-or-later
*/
(function (root) {
"use strict";
// ============= COSINE PALETTES =============
// color(t) = a + b·cos(2π(c·t + d)), per channel. The seven parameter sets are the examples of
// Inigo Quilez' "Simple color palettes" (iquilezles.org/articles/palettes; his code snippets are
// MIT). Names are ours, from how the ramps look.
const COSINE_PALETTES = {
rainbow: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [1.0, 1.0, 1.0], d: [0.00, 0.33, 0.67] },
dusk: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [1.0, 1.0, 1.0], d: [0.00, 0.10, 0.20] },
rose: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [1.0, 1.0, 1.0], d: [0.30, 0.20, 0.20] },
citrus: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [1.0, 1.0, 0.5], d: [0.80, 0.90, 0.30] },
peach: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [1.0, 0.7, 0.4], d: [0.00, 0.15, 0.20] },
neon: { a: [0.5, 0.5, 0.5], b: [0.5, 0.5, 0.5], c: [2.0, 1.0, 0.0], d: [0.50, 0.20, 0.25] },
coral: { a: [0.8, 0.5, 0.4], b: [0.2, 0.4, 0.2], c: [2.0, 1.0, 1.0], d: [0.00, 0.25, 0.25] },
};
// ============= GRADIENTS =============
const GRADIENTS = {
viridis: [
{ pos: 0.0, color: [68, 1, 84] },
{ pos: 0.25, color: [59, 82, 139] },
{ pos: 0.5, color: [33, 145, 140] },
{ pos: 0.75, color: [94, 201, 98] },
{ pos: 1.0, color: [253, 231, 37] },
],
magma: [
{ pos: 0.0, color: [0, 0, 4] },
{ pos: 0.25, color: [80, 18, 123] },
{ pos: 0.5, color: [183, 55, 121] },
{ pos: 0.75, color: [251, 136, 97] },
{ pos: 1.0, color: [252, 253, 191] },
],
fire: [
{ pos: 0.0, color: [0, 0, 0] },
{ pos: 0.3, color: [136, 0, 0] },
{ pos: 0.6, color: [255, 89, 0] },
{ pos: 0.85, color: [255, 213, 0] },
{ pos: 1.0, color: [255, 255, 240] },
],
ocean: [
{ pos: 0.0, color: [3, 11, 41] },
{ pos: 0.3, color: [5, 56, 107] },
{ pos: 0.6, color: [0, 144, 178] },
{ pos: 0.85, color: [120, 220, 220] },
{ pos: 1.0, color: [240, 248, 255] },
],
forest: [
{ pos: 0.0, color: [25, 35, 20] },
{ pos: 0.3, color: [40, 80, 30] },
{ pos: 0.6, color: [80, 140, 50] },
{ pos: 0.85, color: [180, 200, 110] },
{ pos: 1.0, color: [240, 245, 200] },
],
grayscale: [
{ pos: 0.0, color: [0, 0, 0] },
{ pos: 1.0, color: [255, 255, 255] },
],
sunset: [
{ pos: 0.0, color: [12, 12, 60] },
{ pos: 0.3, color: [110, 30, 100] },
{ pos: 0.6, color: [240, 110, 80] },
{ pos: 0.85, color: [255, 200, 90] },
{ pos: 1.0, color: [255, 245, 200] },
],
ice: [
{ pos: 0.0, color: [10, 20, 50] },
{ pos: 0.4, color: [80, 130, 200] },
{ pos: 0.8, color: [180, 220, 250] },
{ pos: 1.0, color: [255, 255, 255] },
],
};
// ============= BUILTIN PRESETS =============
// All presets default to loop=on with a small scaleT for a gentle ripple.
// loopRadius = 1 traces a full circle in noise space; scaleT shrinks it so
// the loop stays within a single cell-sized region.
const LOOP_DEFAULTS = { loop: true, loopDuration: 4, loopRadius: 1.0 };
// Default warp = off. Presets that want warp opt in explicitly.
const WARP_OFF = { warpStrength: 0, warpScale: 1 };
// Default layer 2 = off. Presets that want a composite opt in explicitly.
const LAYER2_OFF = {
layer2On: false,
layer2ScaleX: 0.06, layer2ScaleY: 0.06,
layer2Octaves: 4, layer2Base: 101,
layer2BlendMode: "multiply", layer2Mix: 0.5,
};
const BUILTIN_PRESETS = {
default: {
width: 256, height: 256,
scaleX: 0.011, scaleY: 0.011, scaleT: 0.15,
octaves: 5, persistence: 0.62, lacunarity: 2.1, base: 0,
colorMode: "gradient", gradientName: "viridis",
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS,
},
lava: {
width: 384, height: 256,
scaleX: 0.015, scaleY: 0.015, scaleT: 0.12,
octaves: 5, persistence: 0.65, lacunarity: 2.2, base: 7,
colorMode: "gradient", gradientName: "fire",
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 6,
},
aurora: {
width: 384, height: 256,
scaleX: 0.009, scaleY: 0.024, scaleT: 0.18,
octaves: 5, persistence: 0.6, lacunarity: 2.4, base: 19,
colorMode: "hsv", hsvHueShift: 0.30, hsvHueRange: 0.48, hsvSat: 0.88, hsvVal: 1.0,
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 5,
},
clouds: {
width: 384, height: 256,
scaleX: 0.0075, scaleY: 0.0075, scaleT: 0.1,
octaves: 7, persistence: 0.62, lacunarity: 2.1, base: 3,
colorMode: "gradient", gradientName: "grayscale",
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 8,
},
terrain: {
width: 384, height: 256,
scaleX: 0.0085, scaleY: 0.0085, scaleT: 0.08,
octaves: 6, persistence: 0.58, lacunarity: 2.1, base: 42,
colorMode: "posterize", posterizeSteps: 8, gradientName: "forest",
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 10,
},
circuit: {
width: 384, height: 256,
scaleX: 0.024, scaleY: 0.024, scaleT: 0.18,
octaves: 4, persistence: 0.6, lacunarity: 2.8, base: 11,
colorMode: "posterize", posterizeSteps: 5, gradientName: "ocean",
...WARP_OFF, ...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 3,
},
// Domain-warp showcase: chunky low-octave field gets pulled into
// marble-vein swirls by a slower, stronger warp field. Warm earthy
// gradient sells the stone look.
marble: {
width: 384, height: 256,
scaleX: 0.012, scaleY: 0.012, scaleT: 0.1,
octaves: 4, persistence: 0.55, lacunarity: 2.2, base: 31,
warpStrength: 1.6, warpScale: 0.6,
colorMode: "gradient", gradientName: "sunset",
...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 8,
},
// Per-channel-noise showcase: three independent noise fields drive
// R / G / B separately. Warp adds vein-like coherence.
nebula: {
width: 384, height: 256,
scaleX: 0.013, scaleY: 0.013, scaleT: 0.12,
octaves: 5, persistence: 0.6, lacunarity: 2.2, base: 53,
warpStrength: 1.3, warpScale: 0.7,
colorMode: "rgb",
rScale: 1.4, gScale: 0.55, bScale: 1.5,
rInvert: false, gInvert: true, bInvert: false,
rgbMultiChannel: true,
...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 9,
},
// Flow-field showcase: noise rendered as vector-field directions, so
// the eye reads it as a storm front or a wind map. High gain pushes
// even gentle gradients to readable saturation.
flow: {
width: 384, height: 256,
scaleX: 0.02, scaleY: 0.02, scaleT: 0.1,
octaves: 5, persistence: 0.5, lacunarity: 2.0, base: 23,
...WARP_OFF,
colorMode: "flow",
flowHueShift: 0.0, flowSat: 0.9, flowGain: 14, flowFloor: 0.24, flowEps: 0.5,
...LAYER2_OFF, ...LOOP_DEFAULTS, loopDuration: 6,
},
// Layer-2 showcase: slow rolling base + fine, denser second layer in
// multiply blend. Reads like clouds with embossed contours. Composed
// on the ice gradient for a frozen-pane look.
composite: {
width: 384, height: 256,
scaleX: 0.008, scaleY: 0.008, scaleT: 0.1,
octaves: 4, persistence: 0.5, lacunarity: 2.0, base: 17,
...WARP_OFF,
layer2On: true,
layer2ScaleX: 0.05, layer2ScaleY: 0.05,
layer2Octaves: 5, layer2Base: 211,
layer2BlendMode: "multiply", layer2Mix: 0.6,
colorMode: "gradient", gradientName: "magma",
...LOOP_DEFAULTS, loopDuration: 7,
},
};
/**
* Returns a new, fully-resolved params object the engine/worker can consume.
* For gradient/posterize modes with a named gradient but no explicit stops,
* injects the concrete stop list (falling back to viridis for unknown
* names). Never mutates the input.
*
* @param {Object} preset A preset or params snapshot (may be partial)
* @returns {Object} A shallow clone with `gradient` resolved when applicable
*/
function resolveParams(preset) {
const p = Object.assign({}, preset);
const mode = p.colorMode;
if (mode === "gradient" || mode === "posterize") {
const hasStops = Array.isArray(p.gradient) && p.gradient.length >= 2;
if (!hasStops) {
p.gradient = GRADIENTS[p.gradientName] || GRADIENTS.viridis;
}
}
if (mode === "cosine") {
const q = p.cosine;
const hasPalette = q && ["a", "b", "c", "d"].every((k) => Array.isArray(q[k]) && q[k].length >= 3);
if (!hasPalette) p.cosine = COSINE_PALETTES[p.cosineName] || COSINE_PALETTES.rainbow;
}
return p;
}
root.MTPerlinPresets = { GRADIENTS, COSINE_PALETTES, BUILTIN_PRESETS, resolveParams };
if (typeof module !== "undefined" && module.exports) {
module.exports = { GRADIENTS, COSINE_PALETTES, BUILTIN_PRESETS, resolveParams };
}
})(typeof window !== "undefined" ? window : globalThis);
/* ===== perlin-engine.js ===== */
/**
* perlin-engine.js — headless render engine for Perlin Studio.
*
* Owns a canvas, the Web Worker lifecycle (with a synchronous main-thread
* fallback), and the requestAnimationFrame loop. Knows nothing about the
* studio UI, presets, masks, or recording chrome — those live in consumers
* (perlin-studio.js, perlin-element.js).
*
* Plain script: attaches `window.PerlinEngine`. file://-safe — the worker is
* an inline Blob built from MTPerlin.WORKER_SOURCE. If the worker can't start
* (CSP under file://, older engines), it transparently falls back to
* main-thread rendering via MTPerlin.generatePerlinImage.
*
* Callbacks (all optional, passed to the constructor):
* - onTick(position) fired every RAF tick while playing —
* lets a consumer track the time position
* (e.g. move a slider) at display rate.
* - onFrame({ position, fps, isMain }) fired after each painted frame —
* carries the smoothed FPS for a readout.
* - transformFrame(imageData) mutate pixels in place before paint
* (e.g. apply an alpha mask).
* - transformActive() tells the GPU path whether transformFrame
* would change anything right now; without
* it every frame pays a pixel read-back.
*
* Two backends render the same params: "cpu" (worker, the default) and "gpu"
* (perlin-gl.js, WebGL2). `setBackend("gpu")` opts in; frames the GPU renderer
* does not support (flow / multi-channel colour modes) quietly use the CPU
* path, and a GPU failure drops back to "cpu" for good (`onBackendChange`).
*
* @license AGPL-3.0-or-later
*/
(function (root) {
"use strict";
class PerlinEngine {
/**
* @param {HTMLCanvasElement} canvas
* @param {{onTick?: Function, onFrame?: Function, transformFrame?: Function}} [opts]
*/
constructor(canvas, opts = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.onTick = opts.onTick || null;
this.onFrame = opts.onFrame || null;
this.transformFrame = opts.transformFrame || null;
this.transformActive = opts.transformActive || null;
this.onBackendChange = opts.onBackendChange || null;
this._backend = "cpu";
this._gl = null; // perlin-gl.js renderer, created on first setBackend("gpu")
this.params = null;
this.worker = null;
this.workerBusy = false;
this.workerJobId = 0;
this.latestJobId = 0; // highest requested id; older replies are dropped
this.pendingResolves = []; // [{id, resolve}] — promises awaiting a frame
this._playing = false;
this.rafHandle = 0;
this.animStartTime = 0; // performance.now() when play()/seek() anchored
this.animStartPos = 0; // time position at that anchor
this.position = 0; // current time position (loop progress or t)
this.lastFrameTime = 0;
this.fpsEMA = 0; // exponentially smoothed FPS
this._initWorker();
}
_initWorker() {
const M = root.MTPerlin;
if (!M || !M.WORKER_SOURCE) return; // no module → main-thread fallback
try {
const blob = new Blob([M.WORKER_SOURCE], { type: "application/javascript" });
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (e) => this._onWorkerMessage(e);
this.worker.onerror = (e) => {
// The worker died (CSP, OOM, internal throw). Drop it so renders
// fall back to the main thread — and fail any frames awaiting it so
// callers (e.g. WebM recording) surface the error instead of hanging
// forever on a promise that can never resolve.
this.worker = null;
this.workerBusy = false;
const pending = this.pendingResolves;
this.pendingResolves = [];
const err = new Error("Perlin worker crashed" + (e && e.message ? ": " + e.message : ""));
for (const p of pending) { if (p.reject) p.reject(err); }
// The in-flight frame is lost with the worker; repaint it on the main
// thread so the canvas isn't left blank and consumers' onFrame still
// fires (e.g. to clear a first-paint loading state). Subsequent
// renders take the main-thread path automatically (worker is null).
if (this.params) this.renderOnce();
};
} catch (e) {
this.worker = null;
}
}
/** Store the current params. Does not render. */
setParams(params) { this.params = params; }
get backend() { return this._backend; }
/**
* Choose the renderer: "cpu" (worker) or "gpu" (WebGL2). Returns the backend
* that is active afterwards — "gpu" falls back to "cpu" when WebGL2 or the
* module is missing. Does not render; call renderOnce() to repaint.
*/
setBackend(name) {
let next = "cpu";
if (name === "gpu") {
const G = root.MTPerlinGL;
if (!this._gl && G && G.isAvailable()) this._gl = G.createRenderer();
if (this._gl) next = "gpu";
}
if (next !== this._backend) {
this._backend = next;
if (this.onBackendChange) this.onBackendChange(next);
}
return next;
}
_useGpu(params) { return this._backend === "gpu" && !!this._gl && this._gl.supports(params); }
get playing() { return this._playing; }
get fps() { return this.fpsEMA; }
/**
* Set the time position. When playing, re-anchors the wall-clock origin so
* the animation continues smoothly from the new position.
*/
seek(value) {
this.position = value;
if (this._playing) {
this.animStartTime = performance.now();
this.animStartPos = value;
}
}
// Build a per-frame params object for a given time position, mapping it to
// loopProgress (loop mode) or t (linear mode), matching the worker's reader.
_paramsAt(position) {
const p = Object.assign({}, this.params);
if (p.loop) { p.loopProgress = ((position % 1) + 1) % 1; p.t = 0; }
else { p.t = position; p.loopProgress = 0; }
return p;
}
/** Render the current params once at the current position. */
renderOnce() {
if (!this.params) return;
this._requestFrame(this._paramsAt(this.position));
}
play() {
if (this._playing || !this.params) return;
this._playing = true;
this.animStartTime = performance.now();
this.animStartPos = this.position;
this.lastFrameTime = 0;
this.fpsEMA = 0;
this.rafHandle = requestAnimationFrame((now) => this._tick(now));
}
pause() {
if (!this._playing) return;
this._playing = false;
cancelAnimationFrame(this.rafHandle);
}
// Wall-clock drives the position: loop mode wraps progress over
// loopDuration seconds; linear mode advances t at 1 unit/second.
_tick(now) {
if (!this._playing) return;
const elapsedSec = (now - this.animStartTime) / 1000;
const p = Object.assign({}, this.params);
let position;
if (p.loop) {
const dur = p.loopDuration || 4;
position = ((this.animStartPos + elapsedSec / dur) % 1 + 1) % 1;
p.loopProgress = position; p.t = 0;
} else {
position = this.animStartPos + elapsedSec;
p.t = position; p.loopProgress = 0;
}
this.position = position;
if (this.onTick) this.onTick(position);
this._requestFrame(p);
this.rafHandle = requestAnimationFrame((n) => this._tick(n));
}
// Only one frame is ever in flight in the worker — no queue, no drift. If
// the worker is busy the request is dropped; wall-clock timing means the
// next tick simply renders the then-current position.
_requestFrame(params) {
if (this._useGpu(params) && this._renderGpu(params)) return;
if (!this.worker) { this._renderInMain(params); return; }
if (this.workerBusy) return;
this.workerBusy = true;
this.latestJobId = ++this.workerJobId;
this.worker.postMessage({ id: this.workerJobId, params });
}
/**
* Render one frame and resolve when it has painted. Used for deterministic
* recording where every frame must land (the skip logic would drop frames).
*/
renderFrameAndWait(params) {
if (this._useGpu(params) && this._renderGpu(params)) return Promise.resolve();
if (!this.worker) { this._renderInMain(params); return Promise.resolve(); }
return new Promise((resolve, reject) => {
const id = ++this.workerJobId;
this.latestJobId = id;
this.pendingResolves.push({ id, resolve, reject });
this.workerBusy = true;
this.worker.postMessage({ id, params });
});
}
_renderInMain(params) {
const M = root.MTPerlin;
if (this.canvas.width !== params.width || this.canvas.height !== params.height) {
this.canvas.width = params.width;
this.canvas.height = params.height;
}
M.generatePerlinImage(params, this.ctx);
if (this.transformFrame) {
const imgData = this.ctx.getImageData(0, 0, params.width, params.height);
this.transformFrame(imgData);
this.ctx.putImageData(imgData, 0, 0);
}
this._measureFps(true);
}
// GPU frames are synchronous. Bumping latestJobId makes any worker frame still
// in flight (from before a backend switch) stale, so it cannot overwrite this one.
// Returns false when the GPU failed and the engine switched to "cpu": the caller
// then renders the frame on the CPU path, so a recording never skips a frame.
_renderGpu(params) {
this.latestJobId = ++this.workerJobId;
try {
if (this.canvas.width !== params.width || this.canvas.height !== params.height) {
this.canvas.width = params.width;
this.canvas.height = params.height;
}
this._gl.render(params, this.ctx);
} catch (err) {
// Lost context, driver bug: give the CPU path the frame and stay there.
if (typeof console !== "undefined") console.warn("GPU renderer failed, using the CPU path:", err && err.message);
this._gl = null;
this.setBackend("cpu");
return false;
}
if (this.transformFrame && (!this.transformActive || this.transformActive())) {
const imgData = this.ctx.getImageData(0, 0, params.width, params.height);
this.transformFrame(imgData);
this.ctx.putImageData(imgData, 0, 0);
}
this._measureFps(false);
return true;
}
_onWorkerMessage(e) {
const { id, width, height, buffer } = e.data;
this.workerBusy = false;
if (id < this.latestJobId) return; // stale frame after a newer request
const imgData = new ImageData(new Uint8ClampedArray(buffer), width, height);
if (this.transformFrame) this.transformFrame(imgData);
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
}
this.ctx.putImageData(imgData, 0, 0);
this._measureFps(false);
for (let i = this.pendingResolves.length - 1; i >= 0; i--) {
if (this.pendingResolves[i].id === id) {
this.pendingResolves[i].resolve();
this.pendingResolves.splice(i, 1);
}
}
}
_measureFps(isMain) {
const now = performance.now();
if (this.lastFrameTime > 0) {
const inst = 1000 / (now - this.lastFrameTime);
this.fpsEMA = this.fpsEMA ? this.fpsEMA * 0.85 + inst * 0.15 : inst;
}
this.lastFrameTime = now;
if (this.onFrame) this.onFrame({ position: this.position, fps: this.fpsEMA, isMain: !!isMain, backend: this._backend });
}
/** Stop the loop, terminate the worker, drop pending resolves. */
destroy() {
this.pause();
if (this.worker) { this.worker.terminate(); this.worker = null; }
if (this._gl) { this._gl.destroy(); this._gl = null; }
this.pendingResolves = [];
}
}
root.PerlinEngine = PerlinEngine;
})(typeof window !== "undefined" ? window : globalThis);
/* ===== perlin-element.js ===== */
/**
* perlin-element.js — <perlin-noise> custom element.
*
* Renderer-only embeddable Perlin animation. Wraps a PerlinEngine pointed at a
* shadow-DOM canvas. Configure via attributes (preset, src, autoplay,
* max-resolution, static) or the `params` JS property.
*
* Load order (before this script): perlin.js, perlin-presets.js,
* perlin-engine.js. Plain script: registers the element on load.
*
* file://-safe except `src` — fetch() is CORS-restricted under file://, so use
* `preset` or the `params` property when embedding from the local filesystem.
*
* Defaults tuned for decorative use: respects prefers-reduced-motion (renders a
* single still frame instead of animating) and pauses while fully off-screen.
* The render resolution tracks the element's displayed size × devicePixelRatio,
* capped at `max-resolution` (default 1280) so a full-bleed hero can't saturate
* the CPU. The preset's declared width/height act as a reference composition:
* scaleX/scaleY are rescaled so the look is preserved across resolutions.
*
* @license AGPL-3.0-or-later
*/
(function (root) {
"use strict";
if (!root.customElements) return;
const DEFAULT_MAX_RES = 1280;
class PerlinNoiseElement extends HTMLElement {
static get observedAttributes() {
return ["preset", "src", "autoplay", "max-resolution", "static"];
}
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = ":host{display:block} canvas{display:block;width:100%;height:100%}";
this._canvas = document.createElement("canvas");
shadow.append(style, this._canvas);
this._engine = null;
this._params = null; // resolved params; width/height = reference composition
this._paramsExplicit = false; // true once the .params setter is used
this._ro = null;
this._io = null;
this._mql = null;
this._connected = false;
this._wantPlay = false; // intent (autoplay attr or .play()); gated by motion/visibility
this._visible = undefined; // set by the IntersectionObserver
}
// ---- properties ----
get params() { return this._params; }
set params(p) {
this._paramsExplicit = true;
this._params = p ? root.MTPerlinPresets.resolveParams(p) : null;
this._resize();
}
get playing() { return this._engine ? this._engine.playing : false; }
play() { this._wantPlay = true; this._applyMotion(); }
pause() { this._wantPlay = false; if (this._engine) this._engine.pause(); }
// ---- lifecycle ----
connectedCallback() {
if (this._connected) return;
this._connected = true;
this._engine = new PerlinEngine(this._canvas);
this._ro = new ResizeObserver(() => this._resize());
this._ro.observe(this);
this._io = new IntersectionObserver((entries) => {
for (const en of entries) {
this._visible = en.isIntersecting;
if (en.isIntersecting) this._applyMotion();
else if (this._engine.playing) this._engine.pause();
}
});
this._io.observe(this);
this._mql = matchMedia("(prefers-reduced-motion: reduce)");
this._mql.addEventListener("change", () => this._applyMotion());
this._wantPlay = this.hasAttribute("autoplay");
this._loadParams().then(() => this._resize());
}
disconnectedCallback() {
if (this._ro) this._ro.disconnect();
if (this._io) this._io.disconnect();
if (this._engine) this._engine.destroy();
this._connected = false;
}
attributeChangedCallback(name, oldV, newV) {
if (!this._connected || oldV === newV) return;
if ((name === "preset" || name === "src") && !this._paramsExplicit) {
this._loadParams().then(() => this._resize());
} else if (name === "autoplay") {
this._wantPlay = this.hasAttribute("autoplay");
this._applyMotion();
} else if (name === "max-resolution") {
this._resize();
} else if (name === "static") {
this._applyMotion();
}
}
// ---- params resolution ----
// Precedence: explicit .params (skips this) > src JSON > preset built-in >
// the `default` built-in. On a failed src fetch, falls back to default.
async _loadParams() {
if (this._paramsExplicit) return;
const P = root.MTPerlinPresets;
const src = this.getAttribute("src");
if (src) {
try {
const res = await fetch(src);
this._params = P.resolveParams(await res.json());
return;
} catch (e) {
console.warn("[perlin-noise] src load failed, using default:", e);
}
}
const name = this.getAttribute("preset");
const base = (name && P.BUILTIN_PRESETS[name]) || P.BUILTIN_PRESETS.default;
this._params = P.resolveParams(base);
}
// ---- sizing ----
_maxRes() {
const v = parseInt(this.getAttribute("max-resolution"), 10);
return Number.isFinite(v) && v > 0 ? v : DEFAULT_MAX_RES;
}
_resize() {
if (!this._engine || !this._params) return;
const rect = this.getBoundingClientRect();
const dpr = root.devicePixelRatio || 1;
const cap = this._maxRes();
let renderW = Math.max(1, Math.round(Math.max(1, rect.width) * dpr));
let renderH = Math.max(1, Math.round(Math.max(1, rect.height) * dpr));
const longest = Math.max(renderW, renderH);
if (longest > cap) {
const k = cap / longest;
renderW = Math.max(1, Math.round(renderW * k));
renderH = Math.max(1, Math.round(renderH * k));
}
// Keep the preset's composition resolution-independent: its declared
// width/height is the reference, and scaleX/scaleY are rescaled to the
// actual render buffer so feature size tracks the look, not pixel count.
const refW = this._params.width || renderW;
const refH = this._params.height || renderH;
const scaled = Object.assign({}, this._params, {
width: renderW,
height: renderH,
scaleX: (this._params.scaleX || 0.02) * (refW / renderW),
scaleY: (this._params.scaleY || 0.02) * (refH / renderH),
});
this._engine.setParams(scaled);
this._applyMotion();
}
// ---- motion / reduced-motion / visibility ----
_applyMotion() {
if (!this._engine || !this._params) return;
const blocked =
(this._mql && this._mql.matches) ||
this.hasAttribute("static") ||
!this._wantPlay ||
this._visible === false;
if (blocked) {
this._engine.pause();
this._engine.renderOnce();
} else {
this._engine.play();
}
}
}
customElements.define("perlin-noise", PerlinNoiseElement);
})(typeof window !== "undefined" ? window : globalThis);