Туторіал · Генеративне мистецтво · Canvas 2D · JavaScript
📅 Липень 2026 ⏱ ≈ 30 хв 🎯 Середній рівень

Анімація Flow Field із збереженням PNG

Цей туторіал бере скетч на 50 рядків Canvas 2D з попереднього туторіалу й прокачує його до справжнього інструменту генеративного мистецтва: flow field на шумі Перліна з тисячами часток зі слідом, анімований у реальному часі, плюс робоча кнопка «Зберегти як PNG», використовуючи лише canvas.toDataURL.

1. Мінімальна функція 2D шуму Перліна

Нам потрібна функція шуму, що повертає плавне неперервне значення для будь-якої (x, y). Ось компактна класична реалізація Перліна — таблиця перестановок для псевдовипадковості, градієнтні вектори у точках цілочисельної решітки, і білінійна інтерполяція з функцією згладжування («fade»):

const perm = buildPermutationTable(1337); // таблиця з 512 записів, перемішана, з seed

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 можливих напрямків градієнта
  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); // приблизно у [-1, 1]
}

Третій аргумент для шуму, що змінюється в часі (використовується, щоб поле продовжувало еволюціонувати), можна дешево апроксимувати, зсуваючи точку вибірки (x, y) уздовж повільно обертового напрямку на основі t, уникаючи вартості справжнього 3D шуму Перліна, і водночас плавно анімуючи.

2. Система часток

Кожній частці потрібна лише позиція — саме поле надає напрямок щокадру, тож немає швидкості чи прискорення для інтеграції:

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. Цикл анімації

Кожен кадр анімації робить чотири речі: згасає попередній кадр (замість очищення), вибирає значення поля шуму у позиції кожної частки, просуває частку і малює короткий відрізок сліду, забарвлений за локальним кутом:

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

function frame() {
  // згасання замість очищення → сліди-хвости
  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, а не setInterval: requestAnimationFrame синхронізується з частотою оновлення дисплея, автоматично призупиняється у фонових вкладках (заощаджуючи заряд), і дає плавнішу анімацію без розривів кадру, ніж таймер з фіксованим інтервалом.

4. Експорт PNG через toDataURL

canvas.toDataURL('image/png') синхронно зчитує поточний піксельний буфер canvas і кодує його як base64 data URI. Синтетичний елемент <a> з атрибутом download потім може запустити збереження без будь-якого звернення до сервера:

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();               // програмно запускаємо завантаження
  document.body.removeChild(link);
});

Увесь експорт клієнтський і миттєвий — без завантаження на сервер, і працює однаково незалежно від того, чи містить canvas статичний скетч, чи кадр анімації flow field посеред руху.

5. Типові підводні камені

Великі canvas: toDataURL синхронний і блокує основний потік — для дуже великих canvas (експорти в роздільній здатності для друку, 4K+) краще використовувати canvas.toBlob(), що асинхронний і не заморожує анімацію під час кодування.

6. Повний код

Повний, самодостатній flow field з робочою кнопкою Save PNG, відрендерений наживо нижче (seed 1337, 900 часток для плавного прев'ю):


// 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');

// засіяний PRNG + таблиця перестановок (див. розділ 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);
}

// частки + цикл анімації (див. розділи 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
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);
});
Спробуйте наживо: Canvas над цим блоком коду прямо зараз виконує цей самий скрипт — натисніть «Зберегти як PNG», щоб завантажити той момент анімації, що зараз на екрані.

Часті запитання

Чого я навчуся в цьому уроці?

Створіть анімований flow field на основі шуму Перліна на Canvas 2D — систему часток, рендер із згасанням сліду — і додайте кнопку Save PNG через canvas.toDataURL і синтетичне посилання завантаження.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Мінімальна функція 2D шуму Перліна, Система часток, Цикл анімації, Експорт PNG через toDataURL, Типові підводні камені, Повний код.

Скільки часу займає цей урок?

Цей урок займає приблизно 30 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.