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();
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
-
Transparent background surprises. If you never
painted an opaque background,
toDataURLexports a transparent PNG — fine for overlays, surprising if you expected the dark canvas colour to be baked in. Paint an explicitfillRectbackground before the particle loop starts. - Exporting mid-fade. Because we never fully clear the canvas, the exported PNG always contains whatever partially-faded trails happen to be on screen at the moment of the click — this is a feature (every export is a unique frame), but it means "Save PNG" gives a different result each time.
-
Canvas size vs display (CSS) size. Setting
canvas.style.widthwithout also settingcanvas.widthstretches the existing pixel buffer — the exported PNG will be blurry at the CSS size but still correct at its native pixel resolution. Always set thewidth/heightattributes, not just CSS. -
Tainted canvas (cross-origin).
toDataURLthrows a security error if the canvas has ever drawn an image from a different origin without CORS headers. Not an issue here (we only draw vector shapes), but relevant the moment you add a background photo or texture.
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);
});
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.