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
}
- Pola tiap frame tetap hapus, update, gambar, diulang dengan
requestAnimationFrame. - Keping yang mati ditukar ke ujung (
pool[i] = pool[active - 1]) laluactive--. Gak adasplice(yang menggeser semua keping di belakangnya) dan gak ada array baru. - Loop berhenti sendiri saat
activejadi 0. Konfeti selesai dalam 2 detik, jadi gak perlu tombol jeda (WCAG 2.2.2 baru berlaku untuk gerak otomatis lebih dari 5 detik).
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
- Browser berhenti memanggil
requestAnimationFramedi tab tersembunyi. BatasidtdenganMath.min(..., 0.1)supaya keping gak melompat saat tab dibuka lagi. - Saat "Kurangi gerakan" menyala, jangan ledakkan konfeti. Toast dengan
role="status"sudah membawa pesannya; cukup fade 150 ms. - Ledakan berhenti sendiri saat keping terakhir mati. Jangan biarkan loop kosong terus berjalan.
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
- Object dan string baru per keping per frame.
{ x, y }sementara atau'hsl(' + ... + ')'di dalam loop menambah GC hampir 4 kali. Simpan angka di object yang sama. shadowBluratauctx.filterper keping tanpa diukur. Firefox turun ke 36 sampai 49 fps karena bayangan, Chromium ke 20 fps karena filter, dan Safari mengabaikan filter. Pakai sprite pra-render.- Umur pas 0 masih dianggap hidup.
alive = life > 0, bukan>= 0; danopacityditahanMath.max(0, ...)supaya gak negatif. - Daya tampung dilewati. Sistem dengan
maxParticles2 menolak partikel ketiga; tanpa batas, ledakan yang ditekan berulang menumpuk ribuan keping.
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.