Tutorial · Generative Art · Canvas 2D · JavaScript
📅 July 2026 ⏱ ≈ 30 min 🎯 Intermediate

Animating a Flow Field with PNG Export

This tutorial takes the 50-line Canvas 2D sketch from the previous tutorial and levels it up into a real generative-art tool: a Perlin-noise flow field with thousands of trailing particles, animated in real time, plus a working "Save as PNG" button using nothing but canvas.toDataURL.

1. A Minimal 2D Perlin Noise Function

We need a noise function that returns a smooth, continuous value for any (x, y). Here is a compact classic-Perlin implementation — permutation table for pseudo-randomness, gradient vectors at integer lattice points, and bilinear interpolation with a smoothing curve (the "fade" function):

const perm = buildPermutationTable(1337); // 512-entry shuffled table, seeded

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) {
  const h = hash & 7; // 8 possible gradient directions
  const gx = 1 - (h & 1) * 2, gy = 1 - ((h >> 1) & 1) * 2;
  return gx * x + gy * y;
}

function perlin2(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 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];

  const x1 = lerp(grad(aa, xf, yf),     grad(ba, xf - 1, yf),     u);
  const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u);
  return lerp(x1, x2, v); // roughly in [-1, 1]
}

A third argument for time-varying noise (used to keep the field evolving) can be approximated cheaply by offsetting the (x, y) sample point along a slowly-rotating direction based on t, avoiding the cost of true 3D Perlin noise while still animating smoothly.

2. The Particle System

Each particle only needs a position — the field itself supplies the direction every frame, so there's no velocity or acceleration to integrate:

const PARTICLE_COUNT = 3000;
const particles = Array.from({ length: PARTICLE_COUNT }, spawnParticle);

function spawnParticle() {
  return {
    x: rand() * canvas.width,
    y: rand() * canvas.height,
  };
}

function outOfBounds(p) {
  return p.x < 0 || p.x > canvas.width || p.y < 0 || p.y > canvas.height;
}

3. The Animation Loop

Each animation frame does four things: fades the previous frame (instead of clearing it), samples the noise field at every particle's position, advances the particle, and draws a short trail segment coloured by the local angle:

let t = 0;
const NOISE_SCALE = 0.0025;
const TIME_SCALE = 0.0006;
const SPEED = 1.6;

function frame() {
  // fade instead of clear → trailing streaks
  ctx.fillStyle = 'rgba(8,10,20,0.035)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const p of particles) {
    const nx = p.x * NOISE_SCALE + Math.cos(t * TIME_SCALE) * 2;
    const ny = p.y * NOISE_SCALE + Math.sin(t * TIME_SCALE) * 2;
    const angle = perlin2(nx, ny) * Math.PI * 4;

    const px = p.x, py = p.y;
    p.x += Math.cos(angle) * SPEED;
    p.y += Math.sin(angle) * SPEED;

    const hue = ((angle / (Math.PI * 2)) * 360 + 360) % 360;
    ctx.strokeStyle = `hsla(${hue}, 85%, 62%, 0.85)`;
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.moveTo(px, py);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();

    if (outOfBounds(p)) Object.assign(p, spawnParticle());
  }

  t += 1;
  requestAnimationFrame(frame);
}
frame();
Why requestAnimationFrame, not setInterval: requestAnimationFrame syncs to the display's refresh rate, pauses automatically in background tabs (saving battery), and gives a smoother, tear-free animation than a fixed-interval timer.

4. Exporting a PNG with toDataURL

canvas.toDataURL('image/png') synchronously reads back the canvas's current pixel buffer and encodes it as a base64 data URI. A synthetic <a> element with a download attribute can then trigger a save without any server round-trip:

document.getElementById('saveBtn').addEventListener('click', () => {
  const dataUrl = canvas.toDataURL('image/png');

  const link = document.createElement('a');
  link.href = dataUrl;
  link.download = `flow-field-${Date.now()}.png`;
  document.body.appendChild(link);
  link.click();               // programmatically trigger the download
  document.body.removeChild(link);
});

The whole export is client-side and instant — no upload, no server, and it works identically whether the canvas holds a static sketch or a mid-animation frame of a flow field.

5. Common Pitfalls

Large canvases: toDataURL is synchronous and blocks the main thread — for very large canvases (print-resolution exports, 4K+) prefer canvas.toBlob(), which is asynchronous and doesn't freeze the animation while encoding.

6. Full Source

A complete, self-contained flow field with a working Save PNG button, rendered live below (seed 1337, 900 particles for a smooth preview):


// HTML: <canvas id="ff" width="600" height="600"></canvas>
// <button id="ffSave">Save as PNG</button>

const canvas = document.getElementById('ff');
const ctx = canvas.getContext('2d');

// seeded PRNG + permutation table (see section 1)
function mulberry32(seed) {
  return function() {
    seed |= 0; seed = seed + 0x6D2B79F5 | 0;
    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(1337);
const perm = new Uint8Array(512);
{
  const p = Array.from({ length: 256 }, (_, i) => i);
  for (let i = 255; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [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 + t * (b - a); }
function grad(hash, x, y) {
  const h = hash & 7;
  const gx = 1 - (h & 1) * 2, gy = 1 - ((h >> 1) & 1) * 2;
  return gx * x + gy * y;
}
function perlin2(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 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];
  const x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u);
  const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u);
  return lerp(x1, x2, v);
}

// particles + animation loop (see sections 2-3)
const N = 900;
function spawn() { return { x: rand() * canvas.width, y: rand() * canvas.height }; }
const particles = Array.from({ length: N }, spawn);
ctx.fillStyle = '#0a0e1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);

let t = 0;
function frame() {
  ctx.fillStyle = 'rgba(8,10,20,0.035)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  for (const p of particles) {
    const angle = perlin2(p.x * 0.006 + t * 0.0008, p.y * 0.006) * Math.PI * 4;
    const px = p.x, py = p.y;
    p.x += Math.cos(angle) * 1.6;
    p.y += Math.sin(angle) * 1.6;
    ctx.strokeStyle = `hsla(${((angle/(Math.PI*2))*360+360)%360},85%,62%,0.85)`;
    ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(p.x, p.y); ctx.stroke();
    if (p.x < 0 || p.x > canvas.width || p.y < 0 || p.y > canvas.height) Object.assign(p, spawn());
  }
  t++; requestAnimationFrame(frame);
}
frame();

// Save PNG button
document.getElementById('ffSave').addEventListener('click', () => {
  const link = document.createElement('a');
  link.href = canvas.toDataURL('image/png');
  link.download = `flow-field-${Date.now()}.png`;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
});
Try it live: The canvas above this code block is running this exact script right now — click "Save as PNG" to download whatever moment of the animation is currently on screen.

Frequently Asked Questions

What will I learn in this tutorial?

Build an animated Perlin-noise flow field on Canvas 2D — particle system, trail-fade rendering — and add a Save PNG button using canvas.toDataURL and a synthetic download link.

What topics are covered in this tutorial?

This tutorial covers: A Minimal 2D Perlin Noise Function, The Particle System, The Animation Loop, Exporting a PNG with toDataURL, Common Pitfalls, Full Source.

How long does this tutorial take?

This tutorial takes approximately 30 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.