All articles
14 min read

Perlin Noise: How to Create Organic Generative Art With Code

perlin noisegenerative artcreative codingjavascripttutorialflow fieldnoise art

If you've ever wondered how generative art achieves those flowing, organic, natural-looking patterns — the kind that feel like wind, water, smoke, or terrain — the answer is almost always Perlin noise. Invented by Ken Perlin in 1983 for the original Tron, this algorithm produces smooth, continuous randomness that looks like nature rather than static. It's the single most important tool in creative coding.

This guide covers everything you need to create stunning noise-based art with plain JavaScript and Canvas. No frameworks, no libraries — except a tiny noise function we'll build from scratch. Each example is a working piece you can paste into your browser and modify.

What is Perlin noise?

Regular random numbers (Math.random()) are chaotic — every value is independent of the last. Perlin noise is coherent randomness: nearby inputs produce nearby outputs. If you sample noise at position 5.0 and then at 5.01, the values will be almost identical. This smoothness is what makes it look organic.

Think of it like topography. Math.random() is a city of skyscrapers of random heights. Perlin noise is rolling hills — smooth transitions, no sudden jumps.

Key properties:

  • Continuous — no sudden jumps between neighboring values
  • Deterministic — same input always gives same output
  • Multi-dimensional — works in 1D (waves), 2D (textures), 3D (volumes), 4D (animated textures)
  • Controllable — adjust frequency and amplitude to change character

Setting up: a minimal noise implementation

We need a noise function. Here's a compact 2D value noise (simplified Perlin-style) that's good enough for art and fits in a few lines:

<canvas id="c" width="800" height="800"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

// Simple 2D noise implementation
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }

const PERM = new Uint8Array(512);
const GRAD = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
(function seed() {
  const p = Array.from({length: 256}, (_, i) => i);
  for (let i = 255; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [p[i], p[j]] = [p[j], p[i]];
  }
  for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
})();

function noise2D(x, y) {
  const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  const xf = x - Math.floor(x), yf = y - Math.floor(y);
  const u = fade(xf), v = fade(yf);
  const g = (hash, dx, dy) => {
    const g = GRAD[hash & 7];
    return g[0] * dx + g[1] * dy;
  };
  const aa = PERM[PERM[X] + Y], ab = PERM[PERM[X] + Y + 1];
  const ba = PERM[PERM[X + 1] + Y], bb = PERM[PERM[X + 1] + Y + 1];
  return lerp(
    lerp(g(aa, xf, yf), g(ba, xf - 1, yf), u),
    lerp(g(ab, xf, yf - 1), g(bb, xf - 1, yf - 1), u),
    v
  );
}
</script>

This is our foundation. Every example below uses this same noise function. The magic is in how you apply it.

1. The noise texture — your first visualization

The simplest use: sample noise at every pixel and map the value to brightness.

const img = ctx.createImageData(W, H);
const scale = 0.01;
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const v = (noise2D(x * scale, y * scale) + 1) * 0.5;
    const c = Math.floor(v * 255);
    const i = (y * W + 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);

Change scale to zoom in (smaller = smoother, bigger = more detail). At 0.005, you get gentle clouds. At 0.05, you get rough terrain. This single parameter is your primary creative control.

2. Octave noise (fractal Brownian motion)

Real natural textures have detail at multiple scales — large rolling hills and small bumps. We achieve this by layering noise at different frequencies, each layer (octave) with half the amplitude. This is called fractal Brownian motion (fBm):

function fbm(x, y, octaves = 6) {
  let value = 0, amplitude = 0.5, frequency = 1;
  for (let i = 0; i < octaves; i++) {
    value += amplitude * noise2D(x * frequency, y * frequency);
    amplitude *= 0.5;
    frequency *= 2;
  }
  return value;
}

// Render fBm texture
const img = ctx.createImageData(W, H);
const scale = 0.005;
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const v = (fbm(x * scale, y * scale) + 1) * 0.5;
    const c = Math.floor(v * 255);
    const i = (y * W + 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 difference is dramatic. Single-octave noise looks like soft blobs. fBm looks like clouds — because that's literally how cloud rendering works in movies and games. Most fractal art uses this technique.

3. Flow fields — particles riding the noise

This is where noise art gets truly beautiful. Instead of visualizing the noise directly, use it to steer particles. At each point, the noise value becomes an angle. Particles follow these angles, leaving trails that reveal the invisible field.

ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);

const particles = Array.from({length: 2000}, () => ({
  x: Math.random() * W,
  y: Math.random() * H,
  hue: Math.random() * 360
}));

const scale = 0.003;
ctx.globalAlpha = 0.03;

function draw() {
  for (const p of particles) {
    const angle = noise2D(p.x * scale, p.y * scale) * Math.PI * 4;
    const prev = { x: p.x, y: p.y };
    p.x += Math.cos(angle) * 1.5;
    p.y += Math.sin(angle) * 1.5;

    ctx.strokeStyle = `hsl(${p.hue}, 70%, 60%)`;
    ctx.beginPath();
    ctx.moveTo(prev.x, prev.y);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();

    // Wrap around edges
    if (p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
      p.x = Math.random() * W;
      p.y = Math.random() * H;
    }
  }
  requestAnimationFrame(draw);
}
draw();

Run this and watch. In seconds, an intricate field of flowing color emerges — rivers of hue that follow invisible currents. This technique is behind some of the most famous generative art ever made, from Tyler Hobbs' Fidenza to Zach Lieberman's daily sketches. Lumitree uses flow fields in several of its micro-worlds.

4. Domain warping — bending space itself

Domain warping uses noise to distort the input coordinates of another noise sample. The result looks like alien geology, fluid dynamics, or molten metal.

const img = ctx.createImageData(W, H);
const scale = 0.004;

for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const nx = x * scale, ny = y * scale;

    // First warp
    const wx = fbm(nx, ny, 4);
    const wy = fbm(nx + 5.2, ny + 1.3, 4);

    // Second warp (warp the warp)
    const v = fbm(nx + 4 * wx, ny + 4 * wy, 4);

    // Color mapping
    const t = (v + 1) * 0.5;
    const r = Math.floor(lerp(20, 180, t));
    const g = Math.floor(lerp(60, 120, Math.pow(t, 2)));
    const b = Math.floor(lerp(120, 220, Math.sqrt(t)));

    const i = (y * W + 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);

This is Inigo Quilez's famous technique. The double-warp creates structures that look like satellite imagery of Jupiter or a microscope view of mineral crystals. Tweak the constants (5.2, 1.3, 4.0) to explore an infinite space of patterns.

5. Noise-driven terrain

One of the earliest uses of Perlin noise: generating landscapes. We use noise as a heightmap and draw it as a series of horizontal slices:

ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);

const scale = 0.006;
const slices = 60;

for (let row = 0; row < slices; row++) {
  const yOffset = 200 + row * 8;
  const depth = row / slices;

  ctx.beginPath();
  ctx.moveTo(0, H);

  for (let x = 0; x <= W; x += 2) {
    const height = fbm(x * scale, row * 0.15, 5) * 150 * (0.3 + depth * 0.7);
    const y = yOffset - height;
    ctx.lineTo(x, y);
  }

  ctx.lineTo(W, H);
  ctx.closePath();

  const g = Math.floor(lerp(60, 20, depth));
  const b = Math.floor(lerp(140, 40, depth));
  ctx.fillStyle = `rgb(${g * 0.3}, ${g}, ${b})`;
  ctx.fill();
  ctx.strokeStyle = `rgba(${g * 0.5}, ${g + 30}, ${b + 20}, 0.3)`;
  ctx.stroke();
}

The result is a mountain range receding into the distance. Back rows have less height variation (they're further away), and the colors shift from bright foreground to dark background. This is how Minecraft, No Man's Sky, and countless other games generate their worlds.

6. Animated clouds

Add a time dimension and the noise comes alive. For clouds, use 3D noise where the third axis is time:

ctx.fillStyle = '#1a2a4a';
ctx.fillRect(0, 0, W, H);

const scale = 0.004;
let t = 0;

function drawClouds() {
  const img = ctx.createImageData(W, H);

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      // Use 2D noise with time offset for pseudo-3D
      const v = fbm(x * scale + t, y * scale, 5);
      const cloud = Math.max(0, v) * 2; // threshold: only positive values

      const i = (y * W + x) * 4;
      // Sky gradient
      const skyR = 25 + y * 0.05;
      const skyG = 40 + y * 0.1;
      const skyB = 80 + y * 0.15;

      img.data[i] = Math.min(255, skyR + cloud * 200);
      img.data[i + 1] = Math.min(255, skyG + cloud * 200);
      img.data[i + 2] = Math.min(255, skyB + cloud * 220);
      img.data[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  t += 0.003;
  requestAnimationFrame(drawClouds);
}
drawClouds();

The clouds drift slowly across a twilight sky. Because Perlin noise is continuous, the animation is perfectly smooth — no popping, no flickering. Adjust the threshold (Math.max(0, v)) to control cloud density.

7. Noise rings — polar coordinate art

Map noise to the radius of a circle to create organic, wobbly ring forms. Stack multiple rings for a striking piece:

ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
ctx.translate(W / 2, H / 2);

const rings = 40;
const points = 360;

for (let r = 0; r < rings; r++) {
  const baseRadius = 50 + r * 8;
  const noiseScale = 0.8;
  const noiseAmp = 15 + r * 1.5;
  const hue = (r / rings) * 240;

  ctx.beginPath();
  for (let i = 0; i <= points; i++) {
    const angle = (i / points) * Math.PI * 2;
    const nx = Math.cos(angle) * noiseScale + r * 0.2;
    const ny = Math.sin(angle) * noiseScale + r * 0.2;
    const offset = noise2D(nx, ny) * noiseAmp;
    const radius = baseRadius + offset;
    const x = Math.cos(angle) * radius;
    const y = Math.sin(angle) * radius;
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.closePath();
  ctx.strokeStyle = `hsla(${hue}, 70%, 60%, 0.6)`;
  ctx.lineWidth = 0.8;
  ctx.stroke();
}
ctx.resetTransform();

Each ring is a circle distorted by noise sampled along its circumference. The noise input shifts slightly per ring, so adjacent rings have related but different shapes. The overall effect looks like a topographic map of an alien planet or the cross-section of a mathematical tree trunk.

8. The full piece — animated flow field with fBm

Let's combine everything into a single animated artwork: a flow field with layered octave noise, color cycling, and fading trails.

const particles = Array.from({length: 3000}, () => ({
  x: Math.random() * W,
  y: Math.random() * H,
  life: Math.random() * 200
}));

ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);

let frame = 0;
const scale = 0.002;

function animate() {
  // Slow fade instead of clear — creates trails
  ctx.fillStyle = 'rgba(10, 10, 10, 0.01)';
  ctx.fillRect(0, 0, W, H);

  for (const p of particles) {
    const n = fbm(p.x * scale + frame * 0.0003,
                  p.y * scale, 4);
    const angle = n * Math.PI * 6;
    const speed = 1 + Math.abs(n) * 2;

    const ox = p.x, oy = p.y;
    p.x += Math.cos(angle) * speed;
    p.y += Math.sin(angle) * speed;
    p.life--;

    // Color based on angle and position
    const hue = (n * 180 + 200 + frame * 0.1) % 360;
    const lightness = 40 + Math.abs(n) * 30;
    ctx.strokeStyle = `hsla(${hue}, 80%, ${lightness}%, 0.15)`;
    ctx.beginPath();
    ctx.moveTo(ox, oy);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();

    // Reset dead or escaped particles
    if (p.life <= 0 || p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
      p.x = Math.random() * W;
      p.y = Math.random() * H;
      p.life = 100 + Math.random() * 200;
    }
  }
  frame++;
  requestAnimationFrame(animate);
}
animate();

This is a complete generative artwork. The slow fade creates glowing trails that reveal the underlying field structure over time. The noise evolves with the frame count, so the field slowly shifts. Let it run for 30 seconds and you'll see rivers of light form, merge, and dissolve.

Beyond 2D: where to go from here

Once you're comfortable with 2D Perlin noise, the territory opens up:

  • 3D noise — animate 2D textures by moving through a 3D noise volume (time = z-axis)
  • Simplex noise — Ken Perlin's improved algorithm (2001), faster and fewer artifacts at higher dimensions
  • Curl noise — take the curl of the noise field for divergence-free flow (looks like fluid dynamics)
  • Worley/cellular noise — produces cell-like patterns (stones, scales, bubbles)
  • Noise on the GPU — implement in GLSL shaders for real-time full-screen effects

At Lumitree, many of our micro-worlds use variations of Perlin noise — from flowing particle fields to terrain generators to animated cloud scapes. Each branch is a self-contained artwork under 50KB, and noise is the secret ingredient that makes them feel alive rather than mechanical.

The beauty of noise-based art is that small parameter changes produce wildly different results. Change a single number and your flowing river becomes a mountain range, a star field, or a neural network. That's the magic of creative coding: a few lines of math, rendered at 60fps, producing something that feels like it grew rather than was built.

Start with example 3 (the flow field). Tweak the scale, the particle count, the color mapping. Run it again. You'll never get the same result twice, and every version will surprise you.

Related articles