Noise Texture: How to Create Beautiful Procedural Textures With Code
Every tree bark, marble countertop, cloud formation, and ocean wave shares something in common: they look random but follow hidden rules. Procedural noise textures recreate this structured randomness in code. Instead of painting textures by hand or photographing surfaces, you define a mathematical function that generates infinite, non-repeating patterns at any resolution.
Game engines use noise textures for terrain, clouds, water, fire, and fog. Film studios generate alien landscapes and magical effects. Graphic designers create organic backgrounds and material surfaces. The technique dates back to Ken Perlin’s 1983 work at MAGI on the original Tron film, where he needed a way to make computer-generated surfaces look less plastic. His noise function won an Academy Award for Technical Achievement.
This guide walks through eight distinct noise texture techniques, each with a complete, runnable code example. Every example fits in a single HTML file with zero dependencies.
1. Value noise — the simplest texture generator
Value noise is the most intuitive form of procedural noise. You place random values on a grid, then interpolate between them to create smooth gradients. It produces soft, blobby patterns that work well for clouds, fog, and subtle surface variation.
The algorithm works in three steps: hash grid coordinates to get pseudo-random values at each lattice point, find where your sample point falls within its grid cell, and blend the corner values using smooth interpolation.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
// Pseudo-random hash — deterministic, no Math.random needed
function hash(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
// Smooth interpolation (quintic curve, avoids grid artifacts)
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function valueNoise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = fade(x - xi), yf = fade(y - yi);
const v00 = hash(xi, yi);
const v10 = hash(xi + 1, yi);
const v01 = hash(xi, yi + 1);
const v11 = hash(xi + 1, yi + 1);
return lerp(lerp(v00, v10, xf), lerp(v01, v11, xf), yf);
}
const img = ctx.createImageData(512, 512);
const scale = 8;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const v = valueNoise(x / 512 * scale, y / 512 * scale);
const c = Math.floor(v * 255);
const i = (y * 512 + x) * 4;
img.data[i] = c; img.data[i+1] = c; img.data[i+2] = c; img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The hash function replaces a lookup table with integer arithmetic. Multiplying coordinates by large primes and XOR-shifting produces well-distributed pseudo-random numbers without storing anything. The quintic fade curve 6t&sup5; − 15t&sup4; + 10t³ has zero first and second derivatives at 0 and 1, which eliminates the grid-aligned artifacts that linear interpolation creates.
2. Gradient noise — Perlin’s original technique
Gradient noise improves on value noise by placing random gradients (direction vectors) at grid points instead of random values. Each lattice point gets a vector, and you compute dot products between these gradients and the offset from the sample point to each corner. The result has smoother visual quality and fewer low-frequency blobs.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
// 12 gradient directions (Perlin's original choice for 2D)
const grads = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1],
[1,1],[-1,1],[1,-1],[-1,-1]];
// Permutation table
const perm = new Uint8Array(512);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (i * 374761393 + 668265263) & 255;
[p[i], p[j]] = [p[j], p[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function grad(hash, x, y) {
const g = grads[hash % 12];
return g[0] * x + g[1] * y;
}
function perlin(x, y) {
const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
const xf = x - Math.floor(x), yf = y - Math.floor(y);
const u = fade(xf), v = fade(yf);
const aa = perm[perm[xi] + yi];
const ab = perm[perm[xi] + yi + 1];
const ba = perm[perm[xi + 1] + yi];
const bb = perm[perm[xi + 1] + yi + 1];
return lerp(
lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
v
);
}
const img = ctx.createImageData(512, 512);
const scale = 6;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const n = (perlin(x / 512 * scale, y / 512 * scale) + 1) * 0.5;
const c = Math.floor(n * 255);
const i = (y * 512 + x) * 4;
img.data[i] = c; img.data[i+1] = c; img.data[i+2] = c; img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The permutation table shuffles indices so that nearby grid points get unrelated gradient vectors. The dot product between gradient and offset creates smooth directional variation—slopes that rise and fall naturally, unlike value noise’s tendency toward round blobs. Perlin noise output ranges from roughly −1 to +1, so we remap to 0–1 for display.
3. Worley noise — cellular and organic textures
Worley noise (also called cellular noise or Voronoi noise) measures the distance from each pixel to the nearest randomly placed feature point. The result looks like cells, bubbles, scales, cobblestones, or biological tissue. By using the distance to the second-nearest point, or the difference between first and second distances, you get different organic patterns.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
function hash2(ix, iy, seed) {
let h = ix * 374761393 + iy * 668265263 + seed * 1274126177;
h = ((h ^ (h >> 13)) * 1274126177) | 0;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function worley(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
let d1 = 999, d2 = 999;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const cx = xi + dx + hash2(xi + dx, yi + dy, 0);
const cy = yi + dy + hash2(xi + dx, yi + dy, 1);
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
if (dist < d1) { d2 = d1; d1 = dist; }
else if (dist < d2) { d2 = dist; }
}
}
return { d1, d2 };
}
const img = ctx.createImageData(512, 512);
const scale = 12;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const { d1, d2 } = worley(x / 512 * scale, y / 512 * scale);
const edge = d2 - d1;
const r = Math.floor(Math.min(1, d1 * 1.5) * 180);
const g = Math.floor(Math.min(1, edge * 3) * 220);
const b = Math.floor(Math.min(1, d1) * 140 + 60);
const i = (y * 512 + x) * 4;
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
Each grid cell contains one randomly jittered feature point. For every pixel, we check the 3×3 neighborhood of cells (9 cells total) and track the two closest points. The first distance d1 creates cell interiors that darken toward cell centers. The difference d2 − d1 highlights cell edges—bright where two cells are equidistant, dark inside cells. Combining these channels produces the organic, vein-like coloring seen in the output.
4. Simplex noise — faster, fewer artifacts
Simplex noise is Ken Perlin’s 2001 improvement over his original algorithm. Instead of interpolating on a square grid (4 corners in 2D, 8 in 3D), it uses a simplex grid—triangles in 2D, tetrahedra in 3D. This reduces the number of gradient evaluations and eliminates the axis-aligned visual artifacts that plague classic Perlin noise at certain frequencies.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
const F2 = 0.5 * (Math.sqrt(3) - 1);
const G2 = (3 - Math.sqrt(3)) / 6;
const grad3 = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
const perm = new Uint8Array(512);
const p0 = new Uint8Array(256);
for (let i = 0; i < 256; i++) p0[i] = i;
for (let i = 255; i > 0; i--) {
const j = (i * 48271 + 11) & 255;
[p0[i], p0[j]] = [p0[j], p0[i]];
}
for (let i = 0; i < 512; i++) perm[i] = p0[i & 255];
function simplex2(x, y) {
const s = (x + y) * F2;
const i = Math.floor(x + s), j = Math.floor(y + s);
const t = (i + j) * G2;
const x0 = x - (i - t), y0 = y - (j - t);
const i1 = x0 > y0 ? 1 : 0, j1 = x0 > y0 ? 0 : 1;
const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2 * G2, y2 = y0 - 1 + 2 * G2;
const ii = i & 255, jj = j & 255;
function contrib(gIdx, cx, cy) {
let t0 = 0.5 - cx * cx - cy * cy;
if (t0 < 0) return 0;
t0 *= t0;
const g = grad3[perm[gIdx] & 7];
return t0 * t0 * (g[0] * cx + g[1] * cy);
}
return 70 * (
contrib(ii + perm[jj], x0, y0) +
contrib(ii + i1 + perm[jj + j1], x1, y1) +
contrib(ii + 1 + perm[jj + 1], x2, y2)
);
}
const img = ctx.createImageData(512, 512);
const scale = 8;
let t = 0;
function draw() {
t += 0.005;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const nx = x / 512 * scale, ny = y / 512 * scale;
const n1 = simplex2(nx + t, ny);
const n2 = simplex2(nx * 2 + 100, ny * 2 + t * 0.7) * 0.5;
const v = (n1 + n2 + 0.75) * 0.5;
const hue = 200 + v * 60;
const sat = 0.6 + v * 0.3;
const lum = 0.15 + v * 0.45;
const c = hslToRgb(hue / 360, sat, lum);
const i = (y * 512 + x) * 4;
img.data[i] = c[0]; img.data[i+1] = c[1]; img.data[i+2] = c[2]; img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
requestAnimationFrame(draw);
}
function hslToRgb(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
draw();
The skewing factor F2 transforms the input square grid into a triangular simplex grid. Each sample point falls inside one triangle and only needs three gradient evaluations instead of four. The radial falloff kernel (0.5 − r²)&sup4; has compact support—contributions drop to zero at a fixed radius, so no clamping or wrapping is needed. Two octaves with a time offset create the slowly shifting deep-ocean pattern in the animated output.
5. FBM terrain texture — layered noise for natural landscapes
Fractal Brownian Motion (FBM) layers multiple octaves of noise at increasing frequency and decreasing amplitude. Each octave adds finer detail to the base shape, mimicking how real terrain has large mountains with smaller ridges and even smaller rocks on those ridges. The result is the single most useful technique for terrain textures in games and film.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
function hash(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function valueNoise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = fade(x - xi), yf = fade(y - yi);
return lerp(
lerp(hash(xi, yi), hash(xi+1, yi), xf),
lerp(hash(xi, yi+1), hash(xi+1, yi+1), xf), yf
);
}
function fbm(x, y, octaves) {
let val = 0, amp = 0.5, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
val += valueNoise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5; // persistence
freq *= 2.0; // lacunarity
}
return val / max;
}
// Biome color from elevation
function terrainColor(h) {
if (h < 0.35) return [30, 90, 160]; // deep water
if (h < 0.42) return [60, 130, 190]; // shallow water
if (h < 0.45) return [210, 200, 160]; // sand
if (h < 0.6) return [50, 140, 50]; // grass
if (h < 0.72) return [30, 100, 30]; // forest
if (h < 0.82) return [100, 90, 75]; // rock
if (h < 0.9) return [140, 135, 130]; // high rock
return [220, 225, 230]; // snow
}
const img = ctx.createImageData(512, 512);
const scale = 5;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const nx = x / 512 * scale, ny = y / 512 * scale;
const elevation = fbm(nx, ny, 8);
const c = terrainColor(elevation);
// Simple shading: compute slope from neighbors
const dx = fbm(nx + 0.01, ny, 8) - fbm(nx - 0.01, ny, 8);
const shade = 0.8 + dx * 8;
const i = (y * 512 + x) * 4;
img.data[i] = Math.min(255, c[0] * shade);
img.data[i+1] = Math.min(255, c[1] * shade);
img.data[i+2] = Math.min(255, c[2] * shade);
img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
Persistence (0.5) controls how much each octave contributes—lower values make smoother terrain, higher values make rougher surfaces. Lacunarity (2.0) controls the frequency jump between octaves. Eight octaves give enough detail for 512px resolution. The slope-based shading approximates directional lighting by sampling the noise function slightly left and right of each pixel; steeper slopes facing the light get brighter.
6. Wood grain texture — concentric ring distortion
Wood grain comes from the growth rings of a tree, which are roughly concentric cylinders. To simulate this in 2D, take the distance from a center point, multiply by a ring frequency, and use noise to distort the rings irregularly. Add axial noise for the grain lines that run along the length of the board.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
function hash(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function noise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = fade(x - xi), yf = fade(y - yi);
return lerp(
lerp(hash(xi, yi), hash(xi+1, yi), xf),
lerp(hash(xi, yi+1), hash(xi+1, yi+1), xf), yf
);
}
function fbm(x, y, oct) {
let v = 0, a = 0.5, f = 1, m = 0;
for (let i = 0; i < oct; i++) { v += noise(x*f, y*f)*a; m += a; a *= 0.5; f *= 2; }
return v / m;
}
const img = ctx.createImageData(512, 512);
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const nx = x / 512, ny = y / 512;
// Distance from center with noise distortion
const cx = 0.5 + fbm(nx * 2, ny * 2, 4) * 0.15;
const cy = 0.5 + fbm(nx * 2 + 50, ny * 2 + 50, 4) * 0.15;
const dist = Math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2);
// Growth rings
const ringFreq = 24;
const ringNoise = fbm(nx * 8, ny * 1.5, 3) * 0.08;
const ring = Math.sin((dist + ringNoise) * ringFreq * Math.PI * 2) * 0.5 + 0.5;
// Grain lines along Y
const grain = fbm(nx * 1, ny * 40, 4) * 0.15;
// Combine
const w = 0.3 + ring * 0.4 + grain;
const base = [160, 110, 55]; // warm brown
const dark = [90, 55, 25]; // dark ring
const r = lerp(dark[0], base[0], w);
const g = lerp(dark[1], base[1], w);
const b = lerp(dark[2], base[2], w);
const i = (y * 512 + x) * 4;
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
The center point itself is offset by low-frequency noise, which prevents the rings from looking perfectly circular—real wood has off-center growth patterns from uneven sunlight and soil nutrients. The sine function on the distorted distance creates the ring pattern. High-frequency vertical noise adds the fine grain lines visible when wood is sawn along the length. The warm brown palette comes from interpolating between two hand-picked colors.
7. Marble veins — turbulent sine waves
Marble texture is one of Perlin’s classic demonstrations. The trick is feeding noise-distorted coordinates into a sine function. The sine creates the characteristic light-and-dark banding, and the noise turbulence warps the bands into the veining pattern that makes marble look like frozen liquid.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
function hash(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function noise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = fade(x - xi), yf = fade(y - yi);
return lerp(
lerp(hash(xi, yi), hash(xi+1, yi), xf),
lerp(hash(xi, yi+1), hash(xi+1, yi+1), xf), yf
);
}
function turbulence(x, y, oct) {
let v = 0, a = 1, f = 1;
for (let i = 0; i < oct; i++) {
v += Math.abs(noise(x * f, y * f) * 2 - 1) * a;
a *= 0.5; f *= 2;
}
return v;
}
const img = ctx.createImageData(512, 512);
const xPeriod = 5, yPeriod = 10, turbPower = 5;
for (let y = 0; y < 512; y++) {
for (let x = 0; x < 512; x++) {
const nx = x / 512, ny = y / 512;
const turb = turbulence(nx * 8, ny * 8, 6);
const vein = Math.sin((nx * xPeriod + ny * yPeriod + turbPower * turb) * Math.PI);
const v = (vein + 1) * 0.5;
// Marble palette: white base with gray-blue veins
const r = 200 + v * 55;
const g = 195 + v * 55;
const b = 210 + v * 45;
// Darken in the veins
const veinDark = Math.pow(v, 0.3);
const i = (y * 512 + x) * 4;
img.data[i] = r * veinDark;
img.data[i+1] = g * veinDark;
img.data[i+2] = b * veinDark;
img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
Turbulence is the absolute value of noise summed across octaves—folding negative values to positive creates sharp creases instead of smooth undulations. The sine function uses x * xPeriod + y * yPeriod as its base input, creating diagonal banding. Adding turbulence to this input warps the straight bands into the organic veining pattern. The power curve on the vein value concentrates darkness in narrow lines, mimicking how mineral deposits form thin streaks through limestone.
8. Seamless tileable noise — infinite repeating textures
For game textures, wallpapers, and CSS backgrounds, you need noise that tiles seamlessly. The trick is to sample noise on the surface of a higher-dimensional torus. For 2D tileable noise, you map your 2D coordinates onto a 4D circle: use cosine and sine of the normalized coordinates as four dimensions. When the 2D coordinates wrap from 1 back to 0, the 4D point completes a smooth loop.
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 512;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
const ctx = canvas.getContext('2d');
function hash4(x, y, z, w) {
let h = x * 374761393 + y * 668265263 + z * 1274126177 + w * 2654435761;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + (b - a) * t; }
function noise4(x, y, z, w) {
const xi = Math.floor(x), yi = Math.floor(y);
const zi = Math.floor(z), wi = Math.floor(w);
const xf = fade(x - xi), yf = fade(y - yi);
const zf = fade(z - zi), wf = fade(w - wi);
// Trilinear in 4D = 16 corners
let val = 0;
for (let dw = 0; dw < 2; dw++) {
for (let dz = 0; dz < 2; dz++) {
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
const weight = (dx ? xf : 1-xf) * (dy ? yf : 1-yf) *
(dz ? zf : 1-zf) * (dw ? wf : 1-wf);
val += hash4(xi+dx, yi+dy, zi+dz, wi+dw) * weight;
}
}
}
}
return val;
}
function tileableNoise(x, y, freq) {
const angle1 = x * Math.PI * 2;
const angle2 = y * Math.PI * 2;
return noise4(
Math.cos(angle1) * freq, Math.sin(angle1) * freq,
Math.cos(angle2) * freq, Math.sin(angle2) * freq
);
}
function tileableFbm(x, y, baseFreq, octaves) {
let val = 0, amp = 0.5, freq = baseFreq, max = 0;
for (let i = 0; i < octaves; i++) {
val += tileableNoise(x, y, freq) * amp;
max += amp; amp *= 0.5; freq *= 2;
}
return val / max;
}
// Draw 2x2 tiled to prove seamless
const tileSize = 256;
const img = ctx.createImageData(512, 512);
for (let py = 0; py < 512; py++) {
for (let px = 0; px < 512; px++) {
const nx = (px % tileSize) / tileSize;
const ny = (py % tileSize) / tileSize;
const v = tileableFbm(nx, ny, 1.5, 6);
const h = 140 + v * 80;
const s = 0.5 + v * 0.3;
const l = 0.3 + v * 0.4;
const a2 = s * Math.min(l, 1 - l);
const f = (n) => {
const k = (n + h / 30) % 12;
return l - a2 * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
const i = (py * 512 + px) * 4;
img.data[i] = f(0) * 255;
img.data[i+1] = f(8) * 255;
img.data[i+2] = f(4) * 255;
img.data[i+3] = 255;
// Draw tile boundary guides
if (px % tileSize === 0 || py % tileSize === 0) {
img.data[i] = 255; img.data[i+1] = 255; img.data[i+2] = 255; img.data[i+3] = 40;
}
}
}
ctx.putImageData(img, 0, 0);
The 4D torus mapping works because cos(0) = cos(2π) and sin(0) = sin(2π)—when your 2D coordinates wrap, the 4D sample point returns to exactly where it started, making the transition seamless. The output renders four copies of the 256×256 tile to demonstrate the seamless wrapping. Thin white guides mark the tile boundaries so you can verify there are no visible seams. This technique works for any noise function; just replace the 2D input with the 4D torus coordinates.
Combining noise types for complex materials
Real-world textures rarely use a single noise function. Professional material shaders combine several:
- Perlin + Worley creates weathered stone: Perlin for the large-scale color variation, Worley for crack networks.
- FBM + turbulent sine produces realistic water surfaces: FBM for the wave height map, turbulent sine for caustic light patterns on the sea floor.
- Simplex + domain warping generates alien organic surfaces: sample noise, use the result as an offset to sample again, repeat. The recursive distortion creates unpredictable, almost biological patterns.
- Multiple Worley distances combined with different metrics (Euclidean, Manhattan, Chebyshev) create crystal structures, scales, or honeycomb patterns.
The key principle: low-frequency noise defines the large shape, high-frequency noise adds surface detail, and different noise types add structural variety. Start with the broadest feature and layer progressively finer details.
Performance considerations
Noise texture generation is computationally intensive—512×512 pixels with 8 octaves of FBM means over 2 million noise evaluations per frame. Some practical optimizations:
- Pre-compute to ImageData. Generate the texture once and draw the cached image, rather than recalculating every frame. Only regenerate when parameters change.
- Use typed arrays. Store permutation tables in
Uint8Arrayand output pixels inUint32Array(viaDataView) for faster memory access. - Reduce octaves. For real-time animation, 4–5 octaves are usually indistinguishable from 8 on screen. Each octave roughly doubles the computation time.
- Move to WebGL. Fragment shaders compute noise per-pixel in parallel on the GPU. A 512×512 Perlin noise texture that takes 50ms on CPU renders in under 1ms on GPU.
- Texture baking. For static textures, generate once at build time and ship the result as a PNG. No runtime computation needed.
Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more noise techniques, see the Perlin noise deep-dive, the procedural generation guide, or the fractal art guide.