Animasi di canvas adalah menggambar ulang seluruh gambar berkali-kali per detik, tiap kali sedikit berbeda. Bayangin buku flipbook: tiap halaman digambar lengkap, cangkirnya juga, padahal cuma uapnya yang bergeser sedikit. Dibalik cepat, uapnya kelihatan naik. Di lesson ini, uap di atas cangkir Sablon Kopi Raka kita bikin bergerak.
🧪 Tebakan cepat: Raka menulis
y -= 1tiap frame, dan di laptopnya (layar 60 Hz) uapnya naik pas. Di HP dengan layar 120 Hz, uapnya naik lebih cepat, sama, atau lebih lambat? Tebak dulu, jawabannya ada di bagian "Gerak per frame vs gerak per detik".
Loop dengan requestAnimationFrame
requestAnimationFrame(fungsi) meminta browser memanggil fungsi tepat sebelum menggambar frame berikutnya. Frame adalah satu gambar yang tampil di layar. Browser memanggilnya sebanyak refresh rate layar: 60 kali per detik di layar 60 Hz, 120 kali di layar 120 Hz. Supaya jadi loop, fungsinya meminta frame berikutnya lagi:
function frame(now) {
// now: waktu frame ini dalam milidetik, dikirim browser
// ...update dan gambar...
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Kenapa bukan setInterval(fn, 16)? Interval gak sinkron dengan layar, jadi kadang dua update jatuh di satu frame dan gerakannya patah-patah. Di tab yang disembunyikan, interval tetap jalan (dibatasi browser jadi paling sering sekali per detik), sedangkan requestAnimationFrame berhenti.
Satu frame: hapus, update, gambar ulang
Canvas cuma menyimpan pixel, gak ada "objek uap" yang bisa digeser. Jadi tiap frame:
- Hapus frame lama:
ctx.clearRect(0, 0, 300, 300)membuat seluruh canvas transparan lagi. - Update posisi tiap partikel.
- Gambar ulang semuanya: sablonnya juga, karena
clearRectikut menghapusnya.
Tanpa langkah 1, uap baru digambar di atas uap lama, dan yang terlihat jadi jejak putih tebal, bukan uap yang naik. Dengan clearRect tapi tanpa menggambar ulang sablonnya, cangkirnya hilang dan tinggal uap di canvas kosong. Kalau gambar pertamamu mengecat seluruh canvas (kain hitam fillRect(0, 0, 300, 300)), kain itu sudah menimpa frame lama, dan clearRect jadi gak wajib.
Gerak per frame vs gerak per detik
Gerak per frame (y -= 1) menggeser 1 px tiap kali fungsi dipanggil. Jaraknya per detik ikut banyaknya frame. Gerak per detik memakai dt (delta time): berapa detik berlalu sejak frame sebelumnya, dihitung dari now. Uap Raka naik 60 px per detik dan hidup 1,5 detik, jadi naik 90 px (dari y 112 sampai y 22).
| Layar | Jarak antar frame | y -= 1 per frame |
y -= 60 * dt |
|---|---|---|---|
| 30 Hz | 33,3 ms | 30 px/detik, sampai atas 3 detik | 2 px per frame, 60 px/detik, 1,5 detik |
| 60 Hz | 16,7 ms | 60 px/detik, 1,5 detik | 1 px per frame, 60 px/detik, 1,5 detik |
| 120 Hz | 8,3 ms | 120 px/detik, 0,75 detik | 0,5 px per frame, 60 px/detik, 1,5 detik |
Di HP 120 Hz, uap per frame naik dua kali lebih cepat. Itu jawaban tebakannya. Dengan dt, layar yang lebih cepat cuma memberi langkah yang lebih kecil, jadi kecepatannya sama di semua layar.
Uap kopi
let last = null; // waktu frame sebelumnya (ms)
let uap = []; // partikel uap yang masih hidup
let tunggu = 0; // detik sampai partikel berikutnya lahir
function frame(now) {
if (last === null) last = now;
const dt = Math.min((now - last) / 1000, 0.1); // detik, paling besar 0,1
last = now;
// update: geser yang sudah ada
for (const p of uap) {
p.y -= 60 * dt; // naik 60 px per detik
p.x += p.vx * dt; // melayang ke samping
p.umur += dt;
}
uap = uap.filter((p) => p.umur < 1.5);
// lalu lahirkan yang baru
tunggu -= dt;
if (tunggu <= 0) {
uap.push({ x: 120 + Math.random() * 60, y: 112, umur: 0, vx: (Math.random() - 0.5) * 12 });
tunggu += 0.1; // 10 partikel per detik
}
// gambar ulang
ctx.clearRect(0, 0, 300, 300);
gambarSablon(); // kain, cangkir, tulisan dari lesson sebelumnya
for (const p of uap) {
const t = p.umur / 1.5; // 0 baru lahir, 1 hampir hilang
ctx.globalAlpha = 0.6 * (1 - t);
ctx.beginPath();
ctx.arc(p.x, p.y, 6 + 8 * t, 0, Math.PI * 2);
ctx.fillStyle = '#f5e6c8';
ctx.fill();
}
ctx.globalAlpha = 1;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
dt = (now - last) / 1000: milidetik diubah ke detik. Di frame pertamalastbelum ada, jadidt0.60 * dtadalah kecepatan (px per detik) kali waktu (detik) = jarak frame ini.tunggumenghitung mundur 0,1 detik, jadi lahir 10 partikel per detik. Dengan umur 1,5 detik, sekitar 15 partikel hidup bersamaan.filtermembuang partikel yang umurnya sudah 1,5 detik, supaya array-nya gak tumbuh terus.globalAlphaadalah transparansi semua gambar sesudahnya: 0,6 saat lahir, turun ke 0. Kembalikan ke 1 di akhir supaya frame berikutnya gak ikut pudar.
Memantul di tepi
Benda yang digeser tiap frame tanpa cek tepi jalan terus melewati tepi canvas dan gak pernah berhenti. Benda yang memantul membalik arah kecepatannya saat menyentuh tepi. Contoh biji kopi berjari-jari 20 di canvas setinggi 400, mulai di y 370, dy = 4 per frame:
y += dy; // geser dulu
if (y + radius > 400 || y - radius < 0) dy *= -1; // lalu cek tepi
| Frame | y sesudah geser | y + radius | Lebih dari 400? | dy |
|---|---|---|---|---|
| 1 | 374 | 394 | gak | 4 |
| 2 | 378 | 398 | gak | 4 |
| 3 | 382 | 402 | ya, balik | -4 |
| 4 | 378 | 398 | gak | -4 |
dy *= -1 cuma membalik sumbu yang menyentuh tepi; sumbu x tetap. Di latihan, satu kali update() sama dengan satu frame.
Tab disembunyikan dan "kurangi gerakan"
Kebanyakan browser menghentikan requestAnimationFrame di tab yang disembunyikan dan di iframe yang tersembunyi, untuk menghemat baterai. Safari di iPhone juga membatasinya jadi 30 frame per detik dalam mode hemat daya (Low Power Mode), dan di iframe dari situs lain yang belum disentuh pengguna. Gerak per detik tetap benar di 30 fps. Tapi saat tab dibuka lagi setelah 5 detik, now - last bernilai 5.000 ms: tanpa Math.min, dt jadi 5 detik, semua uap umurnya lewat 1,5 detik dan hilang serentak. Dengan Math.min(..., 0.1), frame itu cuma maju 0,1 detik.
Sebagian orang pusing atau mual melihat gerakan, dan menyalakan "Kurangi gerakan" (Reduce motion) di sistemnya. Browser memberi tahu lewat media query:
const kurangiGerak = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (kurangiGerak) gambarSatuFrameDiam(); // uap diam, plus tombol Putar
else requestAnimationFrame(frame);
Animasi yang mulai sendiri dan berjalan lebih dari 5 detik juga butuh tombol Jeda (WCAG 2.2.2). Jeda berarti berhenti memanggil requestAnimationFrame, dan saat lanjut, set last = null lagi.
⚠️ Jebakan yang sering ketemu
- Gerak per frame untuk animasi sungguhan.
y -= 1dua kali lebih cepat di layar 120 Hz dan setengahnya di 30 Hz. Kalikan kecepatan dengandt. - Lupa menghapus, atau lupa menggambar ulang. Tanpa
clearRect(atau latar yang mengecat seluruh canvas), frame lama menumpuk jadi jejak. DenganclearRectsaja, sablonnya ikut hilang. dtgak dibatasi. Tab yang kembali setelah lama tersembunyi memberidtbeberapa detik, dan semuanya melompat. PakaiMath.min(dt, 0.1).- Cek tepi tanpa radius, atau membalik dua arah sekaligus.
y > 400membiarkan separuh biji keluar dulu sebelum memantul; di latihan hasilnya300,400, bukan300,364. Satuifyang membalikdxdandybersamaan membuat x ikut berbalik padahal gak menyentuh tepi:276,364.
TL;DR: Animasi canvas adalah loop requestAnimationFrame(frame): tiap frame hapus (clearRect), update, lalu gambar ulang semuanya, termasuk bagian yang diam. requestAnimationFrame dipanggil sebanyak refresh rate layar dan memberi timestamp now; hitung dt = (now - last) / 1000 dan gerakkan dengan kecepatan × dt, supaya uap naik 60 px per detik di 30, 60 maupun 120 Hz, sedangkan y -= 1 per frame jadi dua kali lebih cepat di 120 Hz. Memantul: geser, lalu kalau y + radius > tinggi atau y - radius < 0, dy *= -1. Browser menghentikan rAF di tab tersembunyi, jadi batasi dt dengan Math.min(dt, 0.1). Hormati prefers-reduced-motion dan sediakan tombol Jeda.