Canvas Animation Lanjutan — Web Animation

Canvas Animation Lanjutan Canvas 2D API memberikan kontrol piksel penuh untuk animasi — cocok untuk game, data visualisasi, efek partikel, dan animasi yang…

Canvas lanjutan berarti banyak benda kecil yang digambar ulang di satu <canvas> setiap frame: 300 keping konfeti yang meledak saat toast "Pesanan terkirim" muncul. Bayangin panitia hajatan yang menyiapkan 300 kursi untuk tamu. Panitia boros beli kursi baru tiap ada tamu, lalu membuangnya; sesekali truk sampah (garbage collector) datang dan semua orang berhenti sebentar. Panitia hemat menyewa 300 kursi sekali dan memakainya bergantian (pool). Dekorasinya dicap dengan stempel yang diukir sekali (sprite), dan kalau aula utama sibuk, ada panitia kedua di ruang sebelah (worker). Loop dasarnya (hapus, update, gambar), dt, dan devicePixelRatio sudah dibahas di pelajaran Canvas API Dasar dan Canvas Animation di track Creative Web; di sini kita pakai lagi.

🧪 Tebakan cepat: Konfeti 300 keping meledak tiap 2 detik selama 6 detik. Versi A membuat 300 object baru tiap ledakan. Versi B memakai pool 300 object yang dipakai ulang. Berapa kali garbage collector jalan di masing-masing? Jawabannya ada di bagian "Pool object: kapan berguna".

Satu keping konfeti

Satu partikel cuma object dengan posisi, kecepatan (px per detik), dan sisa umur (detik). Tiap frame dia maju sebesar kecepatan × dt, umurnya berkurang dt, dan kepekatannya (opacity) turun mengikuti sisa umur dibanding umur awalnya:

function createParticle(x, y, vx, vy, life) {
  return { x, y, vx, vy, life, maxLife: life, opacity: 1, alive: true };
}

function updateParticle(p, dt) {
  p.x += p.vx * dt;                              // maju: kecepatan × selang waktu
  p.y += p.vy * dt;
  p.life -= dt;                                  // umur berkurang
  p.opacity = Math.max(0, p.life / p.maxLife);   // memudar, gak pernah di bawah 0
  p.alive = p.life > 0;                          // umur habis = mati
  return p;
}

Jejaknya untuk createParticle(0, 0, 10, -5, 1):

Langkah dt x life opacity alive
baru dibuat 0 1 1 true
update 0,5 0 + 10 × 0,5 = 5 0,5 0,5 / 1 = 0,5 true
update lagi 0,6 11 -0,1 0 false

Sistemnya menyimpan daftar partikel dan daya tampung tetap: createParticleSystem(2) menerima dua partikel, dan partikel ketiga ditolak. Daya tampung tetap itulah ide dasar pool.

Konfeti "Pesanan terkirim"

const canvas = document.querySelector('.konfeti');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;            // tajam di layar retina
canvas.width = 320 * dpr; canvas.height = 200 * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

const COLORS = ['#15803d', '#4ade80', '#f59e0b'];
const pool = [];                                     // 300 "kursi sewaan", dibuat sekali
for (let i = 0; i < 300; i++) pool.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '' });
let active = 0;                                      // pool[0..active-1] sedang dipakai
let last = null;

function burst(x, y) {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; // tanpa konfeti
  for (active = 0; active < pool.length; active++) {
    const p = pool[active];
    p.x = x; p.y = y; p.vx = (Math.random() - 0.5) * 400; p.vy = -200 - Math.random() * 300;
    p.life = 1.2 + Math.random() * 0.8; p.color = COLORS[active % 3];
  }
  last = null; requestAnimationFrame(frame);
}

function frame(now) {
  const dt = last === null ? 0 : Math.min((now - last) / 1000, 0.1); // dibatasi: tab yang kembali
  last = now;
  ctx.clearRect(0, 0, 320, 200);                     // 1. hapus
  for (let i = 0; i < active; ) {
    const p = pool[i];
    p.vy += 600 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; // 2. update
    if (p.life <= 0) { pool[i] = pool[active - 1]; pool[active - 1] = p; active--; continue; }
    ctx.fillStyle = p.color; ctx.fillRect(p.x - 4, p.y - 2, 8, 4);    // 3. gambar
    i++;
  }
  if (active > 0) requestAnimationFrame(frame);      // berhenti sendiri saat semua mati
}

Pool object: kapan berguna

Kami jalankan tiga versi di Chromium 147 (laptop M1 Pro, layar 120 Hz), ledakan 300 keping tiap 2 detik selama 6 detik, lalu menghitung minor GC (garbage collection kecil di V8) dari trace-nya:

Versi Minor GC Total Paling lama Dengan CPU 4x lebih lambat
Pool 300 object 6 1,6 sampai 3,4 ms 1,8 ms 5,4 sampai 6,8 ms
300 object baru per ledakan, filter() tiap frame 6 3,8 sampai 4,0 ms 1,9 ms 6,0 sampai 6,8 ms
ditambah object { x, y } dan string warna baru per keping per frame 22 sampai 23 5,6 sampai 7,3 ms 0,8 ms 22,5 sampai 25,8 ms

Itu jawaban tebakannya: sama, 6 kali. Enam GC itu milik halamannya sendiri; 300 object per ledakan terlalu sedikit untuk membuat V8 bekerja lebih. Yang menambah GC hampir 4 kali lipat adalah membuat object dan string baru per keping per frame: 300 × 120 frame × 2 = 72.000 per detik. Jadi pool bukan jimat. Pakai kalau kamu membuat ribuan object per detik, terutama untuk HP lambat, dan ukur dulu di tab Performance DevTools ("Minor GC").

Biaya menggambar: bayangan, filter, sprite

Kode yang sama bisa murah di satu browser dan mahal di browser lain. 300 keping selama 2 detik, laptop yang sama:

Cara menggambar Chromium 147 Firefox 146 WebKit 26.4 (60 Hz)
fillRect polos 120 fps, JS 0,3 ms/frame 120 fps, 0,6 ms 60 fps, 0,4 ms
shadowBlur = 6 120 fps, 0,3 ms 36 sampai 49 fps, 20 sampai 27 ms 60 fps, 0,3 ms
ctx.filter = 'blur(1px)' 19 sampai 21 fps, 0,5 ms 109 sampai 118 fps, 8 sampai 9 ms gak didukung, diabaikan diam-diam
sprite pra-render (bayangan dicap sekali) 102 sampai 112 fps, 0,4 ms 120 fps, 3,3 ms 60 fps, 0,4 ms

Di Chromium, bayangannya digambar GPU; filter per keping yang jadi beban GPU, sehingga JavaScript-nya ringan tapi frame-nya tetap jatuh ke 20 fps. Di Firefox, bayangan dihitung di main thread. ctx.filter di Safari baru ada di balik flag (sejak 18). Sprite paling aman di ketiganya: gambar keping plus bayangannya sekali ke canvas lain, lalu cap dengan drawImage:

const sprite = new OffscreenCanvas(20 * dpr, 16 * dpr);   // atau document.createElement('canvas')
const s = sprite.getContext('2d');
s.setTransform(dpr, 0, 0, dpr, 0, 0);
s.shadowColor = 'rgba(0, 0, 0, 0.35)'; s.shadowBlur = 6;  // bayangan dihitung sekali di sini
s.fillStyle = '#15803d'; s.fillRect(6, 6, 8, 4);

ctx.drawImage(sprite, p.x - 10, p.y - 8, 20, 16);         // tiap keping, tiap frame: cuma menempel

OffscreenCanvas di Web Worker

OffscreenCanvas adalah canvas tanpa elemen di halaman. Dengan transferControlToOffscreen(), canvas di halaman diserahkan ke Web Worker (thread lain), dan worker yang menggambar:

// halaman
const off = document.querySelector('.konfeti').transferControlToOffscreen();
const worker = new Worker('konfeti-worker.js');
worker.postMessage({ canvas: off, dpr: devicePixelRatio, reduce: matchMedia('(prefers-reduced-motion: reduce)').matches }, [off]);

// konfeti-worker.js
onmessage = (e) => {
  const ctx = e.data.canvas.getContext('2d');   // loop yang sama, dengan requestAnimationFrame milik worker
};

Didukung Chrome 69, Firefox 105, dan Safari 16.4 untuk context 2D (WebGL di Safari 17). Kami blokir main thread 500 ms di tengah ledakan: worker tetap menggambar 60 frame di Chromium dan Firefox dan 28 di WebKit (60 Hz), sedangkan loop di main thread 0 frame. Harganya: worker gak bisa menyentuh DOM atau matchMedia, jadi semua yang dia butuhkan (ukuran, dpr, "kurangi gerakan") dikirim lewat postMessage.

Berhenti saat gak dilihat

Kapan pindah ke library: PixiJS

Ratusan sampai ribuan sprite yang bergerak bersamaan lebih ringan digambar GPU lewat WebGL. PixiJS 8.22 memakai WebGL secara bawaan, lalu WebGPU, dan Canvas 2D sebagai cadangan terakhir. Untuk 300 konfeti sekali jalan, Canvas 2D sudah cukup dan gak menambah unduhan.

⚠️ Jebakan yang sering ketemu

TL;DR: Partikel = object { x, y, vx, vy, life, maxLife, opacity }; tiap frame x += vx * dt, life -= dt, opacity = Math.max(0, life / maxLife), alive = life > 0 (0 + 10 × 0,5 = 5; opacity 0,5; setelah 0,6 detik lagi mati). Loop canvas: hapus, update, gambar dengan requestAnimationFrame, dt dibatasi 0,1 detik, devicePixelRatio untuk ketajaman, berhenti sendiri saat semua keping mati, dan tanpa konfeti saat "Kurangi gerakan". Pool = daya tampung tetap yang dipakai ulang (keping mati ditukar ke ujung); di Chromium 147, 300 object per ledakan tetap 6 minor GC seperti pool, sedangkan object baru per keping per frame menjadi 22 sampai 23. Biaya gambar beda per browser: shadowBlur 20 sampai 27 ms per frame di Firefox, ctx.filter 20 fps di Chromium dan gak didukung Safari; sprite pra-render aman di ketiganya. OffscreenCanvas + transferControlToOffscreen() memindahkan gambar ke Web Worker (Chrome 69, Firefox 105, Safari 16.4): main thread diblokir 500 ms, worker tetap 60 frame. PixiJS (WebGL, lalu WebGPU, lalu Canvas 2D) untuk ratusan sampai ribuan sprite.

Yang akan kamu pelajari