Imported from Amon20044/threejs-skills (
nodes/10-material/noise-and-fields/SKILL.md). Install upstream withnpx skills add Amon20044/threejs-skills --skill noise-and-fields. Copyright stays with the author.
Noise and Fields
Intent
Provide the primitive every procedural surface is built from: noise that wraps exactly at the tile boundary, hashes that do not band on real GPUs, and a vocabulary of field types (fbm, ridged, billow, worley, voronoi edges, domain warp) with a stated use for each.
Everything downstream — textures, terrain, cloud decks, crack networks, wear masks — is a composition of these.
When to use
- Generating any texture, height field, mask, or scatter distribution on the GPU.
- Any time a surface must tile seamlessly across a wall, a ground plane, or a mesh with repeating UVs.
When not to
- CPU-side one-shot values — use the seeded PRNG (
deterministic-random) instead. GPU noise is for fields, indexed by position; the PRNG is for streams.
Contract
Every function takes a period in lattice cells and wraps its hash lattice with
mod(). A texture generated over uv ∈ [0,1) with p = uv * per therefore tiles
seamlessly. Octaves double both frequency and period, which keeps the whole fbm stack
seamless rather than just its first octave.
float owNoise (vec2 p, vec2 per); // periodic Perlin, ~[-1,1]
float owNoise01(vec2 p, vec2 per); // [0,1]
float owValue (vec2 p, vec2 per); // blockier value noise
float owFbm (vec2 p, vec2 per, int oct, float gain); // ~[-1,1]
float owFbm01 (vec2 p, vec2 per, int oct, float gain); // [0,1]
float owRidged(vec2 p, vec2 per, int oct, float gain); // sharp creases
float owBillow(vec2 p, vec2 per, int oct, float gain); // puffy clumps
vec2 owWarp (vec2 p, vec2 per, float amp, int oct); // domain warp
vec4 owWorley(vec2 p, vec2 per, float jitter); // .x=F1 .y=F2 .zw=cell id
float owVoronoiEdge(vec2 p, vec2 per, float jitter); // distance to cell edge
float owCracks(vec2 p, vec2 per, float j, float w, float b);
float owScratches(vec2 p, vec2 per, float stretch, float k, float thin);
The period argument is not optional and not decorative. Passing a period that does not divide the lattice is the single most common cause of a visible seam.
Implementation
Hashes: sin-free
float owHash12(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec2 owHash22(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
fract(sin(dot(p, k)) * 43758.5453) is the hash everyone reaches for first. It bands
visibly on Apple GPUs at high lattice coordinates — sin() precision degrades once the
argument gets large, and a 1024-cell lattice gets there. The Dave Hoskins style hashes
above are pure integer-ish float arithmetic and stay well-distributed everywhere.
Periodic gradient noise
vec2 owGrad2(vec2 i, vec2 per){
float a = owHash12(mod(i, per) + 0.317) * 6.28318530718;
return vec2(cos(a), sin(a));
}
float owNoise(vec2 p, vec2 per){
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // quintic: C2 continuous
float a = dot(owGrad2(i + vec2(0,0), per), f - vec2(0,0));
float b = dot(owGrad2(i + vec2(1,0), per), f - vec2(1,0));
float c = dot(owGrad2(i + vec2(0,1), per), f - vec2(0,1));
float d = dot(owGrad2(i + vec2(1,1), per), f - vec2(1,1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 1.4142;
}
The mod(i, per) inside the gradient lookup is the whole trick. The quintic fade is not
cosmetic: the cubic f*f*(3-2f) is only C1, and its second-derivative discontinuity
shows up as a faint grid in a normal map derived from the height — invisible in the
height itself, obvious once lit.
fbm and its relatives
float owFbm(vec2 p, vec2 per, int oct, float gain){
float s = 0.0, a = 0.5, n = 0.0;
for (int i = 0; i < 10; i++){
if (i >= oct) break; // GLSL ES 1.00 needs a constant bound
s += a * owNoise(p, per);
n += a;
p *= 2.0; per *= 2.0; a *= gain; // period doubles WITH frequency
}
return s / max(n, 1e-4); // normalise: gain-independent range
}
| variant | shape | use for |
|---|---|---|
owFbm |
smooth, cloud-like | broad tonal variation, dust, general grain |
owRidged |
(1-|n|)² — sharp creases |
rock, cracks, mountain silhouettes |
owBillow |
|n| — puffy clumps |
rust blooms, clay, cumulus |
owValue |
blocky, axis-aligned | cell-ish tint variation, brick colour jitter |
Normalising by the accumulated amplitude n is what makes gain a shape control
rather than a brightness control — change gain and the contrast changes, not the mean.
Worley and the edge distance
vec4 owWorley(vec2 p, vec2 per, float jitter){
vec2 ip = floor(p), fp = fract(p);
float f1 = 8.0, f2 = 8.0;
vec2 id = vec2(0.0);
for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++){
vec2 g = vec2(float(x), float(y));
vec2 cell = mod(ip + g, per);
vec2 o = owHash22(cell + 0.771) * jitter + (1.0 - jitter) * 0.5;
vec2 r = g + o - fp;
float d = dot(r, r);
if (d < f1){ f2 = f1; f1 = d; id = owHash22(cell + 3.117); }
else if (d < f2){ f2 = d; }
}
return vec4(sqrt(f1), sqrt(f2), id);
}
Returning the cell id in .zw is what makes worley useful beyond blobs: it gives
every cell a stable random pair, so pebbles get individual colours, tiles get individual
heights, and a step() on .z selects a random subset of cells without a second noise
lookup.
For crack networks, F2 - F1 is the obvious choice and it is the wrong one — it produces
soft, uneven-width lines that read as a lumpy net. Use Quilez's two-pass distance to the
cell edge:
float owVoronoiEdge(vec2 p, vec2 per, float jitter){
// pass 1: find the owning cell
vec2 ip = floor(p), fp = fract(p);
vec2 mr = vec2(0.0), mg = vec2(0.0);
float md = 8.0;
for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++){
vec2 g = vec2(float(x), float(y));
vec2 o = owHash22(mod(ip + g, per) + 0.771) * jitter + (1.0 - jitter) * 0.5;
vec2 r = g + o - fp;
float d = dot(r, r);
if (d < md){ md = d; mr = r; mg = g; }
}
// pass 2: distance to the perpendicular bisector with each neighbour — a
// 5x5 search, because the true edge can belong to a cell two steps away
md = 8.0;
for (int y = -2; y <= 2; y++) for (int x = -2; x <= 2; x++){
vec2 g = mg + vec2(float(x), float(y));
vec2 o = owHash22(mod(ip + g, per) + 0.771) * jitter + (1.0 - jitter) * 0.5;
vec2 r = g + o - fp;
vec2 diff = r - mr;
if (dot(diff, diff) > 1e-5)
md = min(md, dot(0.5 * (mr + r), normalize(diff)));
}
return md;
}
Then break the network so it reads as damage rather than as a mesh:
float owCracks(vec2 p, vec2 per, float jitter, float width, float breakUp){
vec2 wp = owWarp(p, per, 0.20, 3); // warp first: no straight edges
float e = owVoronoiEdge(wp, per, jitter);
float c = 1.0 - smoothstep(0.0, width, e);
float mask = owFbm01(p * 1.7 + 11.3, per * 1.7, 4, 0.55);
c *= smoothstep(breakUp, breakUp + 0.28, mask); // cracks must TERMINATE
return clamp(c, 0.0, 1.0);
}
A complete, unbroken voronoi network is instantly readable as procedural. Real cracks
start somewhere, run, and stop. The mask multiply is what buys that.
Domain warp
vec2 owWarp(vec2 p, vec2 per, float amp, int oct){
vec2 q = vec2(owFbm(p + vec2(1.7, 9.2), per, oct, 0.5),
owFbm(p + vec2(8.3, 2.8), per, oct, 0.5));
return p + amp * q;
}
The cheapest way to make any field stop looking like noise. Amplitudes: 0.1–0.3 for "slightly organic", 0.5–1.2 for "flowing", above 2 for "melted". Warping is periodic because the fbm driving it is.
Anisotropy that survives tiling
Scratches and brushed metal need stretched features. A rotation breaks the lattice wrap; an integer shear does not:
/** k and stretch must be INTEGERS or the lattice no longer wraps on `per`. */
vec2 owShear(vec2 p, float k, float stretch){ return vec2(p.x + p.y * k, p.y * stretch); }
vec2 owShearPer(vec2 per, float stretch){ return vec2(per.x, per.y * stretch); }
float owScratches(vec2 p, vec2 per, float stretch, float k, float thin){
vec2 q = owShear(p, k, stretch);
vec2 qper = owShearPer(per, stretch);
float n = owFbm01(q, qper, 4, 0.5);
// a thin band of the fbm, not a threshold: gives lines with soft ends
return smoothstep(thin, thin + 0.06, n) * (1.0 - smoothstep(thin + 0.06, thin + 0.2, n));
}
Nyquist: the reason your detail disappears at 2 m
This is the most expensive lesson in the node.
A 1024 px tile spanning 0.25 m gives 0.244 mm per texel. With p = uv * 8, a term
written at p * K puts 8K feature cells across 1024 texels — that is 128/K texels
per cell.
| K | texels per feature | result |
|---|---|---|
| 8 | 16 | solid, survives several mips |
| 20 | 6.4 | ~1.6 mm — the practical floor |
| 24 | 5.3 | marginal |
| 40 | 3.2 | salt-and-pepper dither at mip 0, flat grey at mip 1 |
The failure presents as "sandpaper in a close-up, featureless at 2 m", and the instinct is to add more high-frequency terms, which makes it strictly worse. The fix is to cap every band at K ≈ 20 and give the surviving bands real amplitude instead.
// 3.9 mm pits and 1.6 mm grains — both wide enough to survive two mip levels
vec4 pores = owWorley(p * 8.0, P * 8.0, 1.0);
vec4 grit = owWorley(p * 20.0, P * 20.0, 1.0);
// Proud grains as solid rounded bumps, not threshold specks:
float gritA = smoothstep(0.34, 0.08, pores.x) * step(0.38, pores.z);
float pit = smoothstep(0.26, 0.00, pores.x) * step(0.72, pores.w);
Note step(0.38, pores.z) — the cell id selecting which cells get a grain. That is
free variation that a second noise octave would have cost a full evaluation.
Verify
node kit/testing/tiling.mjs --surface all
- Seamless. Bake at 512, tile 2×2, compare column 511 against column 0 and row 511 against row 0. Max per-channel delta ≤ 1/255.
- Seamless in the normal map too. The height can wrap while its Sobel does not,
if the Sobel samples off the edge without
RepeatWrapping. Check the normal texture with the same test. - No banding at high lattice coordinates. Evaluate the hash over
p ∈ [0, 4096]²and histogram: flat within 2 %. Asin-based hash fails visibly here. - fbm range.
owFbm01over 10⁶ samples stays in[0,1]; mean 0.5 ± 0.02 for everygain ∈ [0.4, 0.7]. - Nyquist audit. For each surface, list every
p * Kterm and compute128/K × (worldSize/0.25)texels per feature. Nothing below 5. - Mip survival. Render the tile at 1 m, 2 m, 4 m, 8 m. Compute per-image luminance variance; it must decay smoothly, not collapse between two adjacent distances.
- Determinism. Same seed, same GPU, two bakes → bit-identical.
- Cracks terminate. Threshold the crack field and count connected components; a broken network has many, a mesh has one.
Failure modes
| symptom | cause | fix |
|---|---|---|
| Visible seam at the tile edge | period not passed, or not doubled per octave | thread per through everything |
| Seam only in the lighting | normal map baked without RepeatWrapping on the height RT |
set wrap on the scratch target |
| Diagonal banding on Apple GPUs | sin()-based hash |
Hoskins-style hash |
| Faint grid in the normal map | cubic fade instead of quintic | f³(f(6f-15)+10) |
| Sandpaper close up, flat at 2 m | features above Nyquist | cap at ~6 texels/feature, raise amplitude |
| Cracks read as a net | F2-F1, and no break-up mask |
owVoronoiEdge + fbm mask |
| Brushed metal seams | rotation used for anisotropy | integer shear |
Changing gain changes brightness |
fbm not normalised by accumulated amplitude | divide by n |
| Detail looks like static in motion | high-frequency term aliasing under TAA | lower K; TAA cannot fix sub-texel content |
Extend
kit/glsl/noise.glsl.js— the full library as an exportable GLSL string.- Baking these fields into PBR texture sets:
texture-forge. - Composing them into named surfaces:
surface-authoring. - The 3-D CPU-side equivalents for mesh deformation:
organic-forms.