Animasi di three.js artinya mengubah properti benda (rotasi, posisi, skala) sedikit tiap frame, lalu render lagi. Bayangin meja putar di studio foto produk Raka: mejanya berputar dengan kecepatan tetap, misalnya satu putaran tiap 8 detik, gak peduli kameranya memotret 60 atau 120 foto per detik. Kecepatan itu milik waktu, bukan milik jumlah foto. Animasi yang baik juga begitu: dihitung dari waktu.
Di lesson ini Mug Sablon Kopi dari lesson sebelumnya kita taruh di meja putar: berputar sendiri, berhenti dengan halus di sisi sablon saat diminta, dan melompat dengan klip keyframe.
🧪 Tebakan cepat: mug berputar 0,8 radian per detik. Raka pindah ke tab lain selama 10 detik, lalu kembali. Loop-nya memakai
timer.getDelta()tanpatimer.connect(document). Apa yang terjadi di frame pertama setelah tab kembali? Tebak dulu, jawabannya ada di bagian "Tab tersembunyi dan gerakan dikurangi".
Loop: requestAnimationFrame
function animate() {
requestAnimationFrame(animate); // minta dipanggil lagi sebelum repaint berikutnya
cube.rotation.x += 0.01;
renderer.render(scene, camera);
}
animate();
requestAnimationFrame(fn)meminta browser memanggilfntepat sebelum layar digambar ulang: sekali per refresh, 60 kali per detik di layar 60 Hz, 120 kali di layar 120 Hz. Saat tab disembunyikan, panggilannya berhenti, jadi baterai dan GPU istirahat.- Callback-nya menerima
time, milidetik sejak halaman dibuka. Bukan selisih waktu sejak frame sebelumnya; selisih itu harus dihitung sendiri. setInterval(fn, 16)gak sinkron dengan refresh layar (ada frame yang dobel atau terlewat) dan tetap jalan di tab tersembunyi, cuma diperlambat jadi paling sering sekali per detik.- three.js punya pembungkusnya:
renderer.setAnimationLoop((time) => { ... })(lesson Three.js Setup). Isinya sama, dan WebXR butuh yang ini.
Waktu, bukan frame: THREE.Timer
rotation.y += 0.01 menambah per frame: 0,6 radian per detik di layar 60 Hz, 1,2 di 120 Hz. Supaya sama di mana pun, kalikan kecepatan per detik dengan delta, lama frame barusan dalam detik.
const timer = new THREE.Timer();
timer.connect(document); // pakai Page Visibility API, dibahas di bawah
const KECEPATAN = 0.8; // radian per detik: 1 putaran = 2π / 0,8 = 7,85 detik
renderer.setAnimationLoop((time) => {
timer.update(time); // sekali per frame, sebelum dibaca
const delta = timer.getDelta(); // detik sejak frame sebelumnya
meja.rotation.y += KECEPATAN * delta;
renderer.render(scene, camera);
});
| Layar | delta per frame | Tambah per frame | Per detik |
|---|---|---|---|
| 60 Hz | 0,0167 s | 0,0133 rad | 0,8 rad |
| 120 Hz | 0,0083 s | 0,0067 rad | 0,8 rad |
mejaadalahTHREE.Grouptempat mug berdiri: loop memutar mejanya, mug sendiri bebas digerakkan klip (bagian "Klip keyframe").timer.getElapsed()memberi total detik sejak mulai, cocok untuk gerak bolak-balik:x = Math.sin(t) * 3bergerak di antara -3 dan 3 (sinus selalu di antara -1 dan 1), satu bolak-balik tiap 2π = 6,28 detik. Jadi kubus denganrotation.y += 0.01danposition.x = Math.sin(Date.now() * 0.001) * 3berputar di sumbu Y sambil berayun kiri-kanan di sumbu X.THREE.Clocksudah deprecated sejak r183 (di r186 membuat peringatanTHREE.Clock: This module has been deprecated. Please use THREE.Timer instead.). Jebakan lamanya:getElapsedTime()memanggilgetDelta()di dalamnya, jadi kalau keduanya dipanggil di frame yang sama,getDelta()yang kedua hampir 0. Timer gak begitu:update()sekali, lalu baca berapa kali pun.
Easing: berhenti di sisi sablon
Tombol "Lihat sablon" memutar meja ke kelipatan 2π terdekat (sablon menghadap kamera, lesson Geometri & Material) dalam 0,8 detik:
const easeInOutCubic = (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
function lihatSablon() {
berputar = false;
const ke = Math.round(meja.rotation.y / (2 * Math.PI)) * 2 * Math.PI; // paling jauh setengah putaran
putaran = { dari: meja.rotation.y, ke, t: 0 };
}
// di dalam loop:
if (putaran) {
putaran.t += delta;
const p = Math.min(putaran.t / 0.8, 1); // 0 sampai 1
meja.rotation.y = putaran.dari + (putaran.ke - putaran.dari) * easeInOutCubic(p);
if (p === 1) putaran = null;
}
Easing adalah kurva yang mengubah kemajuan waktu p jadi kemajuan gerak: di p 0,25 gerakannya baru 0,0625, di 0,5 tepat 0,5, di 0,75 sudah 0,9375. Pelan, cepat, pelan, seperti orang yang memutar meja dengan tangan. Tanpa easing (p apa adanya), mug mulai dan berhenti mendadak.
Klip keyframe: AnimationMixer
Keyframe adalah nilai di detik tertentu; three.js mengisi nilai di antaranya. Bawaannya lurus (linear): di tengah-tengah waktu antara dua keyframe, nilainya juga di tengah-tengah, dan sudut hasil slerp pun berubah rata. Klip "lompat" (1,2 detik) dibuat tangan, persis bentuk klip yang datang dari Blender di gltf.animations:
const SUMBU_Y = new THREE.Vector3(0, 1, 0);
const q = new THREE.Quaternion();
const lompat = new THREE.AnimationClip('lompat', 1.2, [
new THREE.NumberKeyframeTrack('.position[y]', [0, 0.6, 1.2], [0, 0.4, 0]),
new THREE.QuaternionKeyframeTrack('.quaternion', [0, 0.4, 0.8, 1.2],
[0, 1, 2, 3].flatMap((i) => q.setFromAxisAngle(SUMBU_Y, (i * 2 * Math.PI) / 3).toArray())),
]);
const mixer = new THREE.AnimationMixer(mug);
const aksi = mixer.clipAction(lompat).setLoop(THREE.LoopOnce);
tombolLompat.addEventListener('click', () => aksi.reset().play());
// di dalam loop, sesudah menghitung delta:
mixer.update(delta);
- Nama track menunjuk properti milik
mug(akar mixer):'.position[y]'cuma komponen y,'.quaternion'seluruh rotasi.'gagang.quaternion'akan menunjuk anak bernamagagang. - Rotasi di klip disimpan sebagai quaternion (4 angka untuk satu arah) dan diisi dengan slerp, yang selalu lewat jalan terpendek. Quaternion 0° dan 360° menunjuk arah yang sama, jadi klip dengan dua keyframe itu saja membuat mug diam. Satu putaran butuh keyframe tiap 120°.
LoopOncemain sekali; setelah 1,2 detik mug kembali ke posisi awalnya.reset().play()memulai dari detik 0 lagi. Tanpamixer.update(delta)tiap frame, klipnya gak bergerak sama sekali.- Loop memegang
meja.rotation, mixer memegangmug.quaternion. Kalau keduanya mengubah benda yang sama, yang terakhir menimpa yang lain.
Tab tersembunyi dan gerakan dikurangi
Selama tab tersembunyi, requestAnimationFrame berhenti. Saat tab kembali, frame pertamanya datang 10 detik setelah frame terakhir, jadi tanpa connect delta-nya 10 detik dan meja melompat 0,8 × 10 = 8 radian (1,27 putaran) dalam satu frame. Itu jawaban tebakannya. Ada dua obat:
timer.connect(document): selama tab tersembunyi delta 0, dan saat kembali timer mulai menghitung dari saat itu. Meja melanjutkan dari posisi terakhir.Math.min(timer.getDelta(), 0.1): delta dibatasi 0,1 detik, trik dari lesson Animasi Canvas.
Untuk pengunjung yang sistemnya meminta gerakan dikurangi (vertigo, mabuk gerak), jangan berputar sendiri:
const gerakDikurangi = matchMedia('(prefers-reduced-motion: reduce)');
let berputar = !gerakDikurangi.matches;
gerakDikurangi.addEventListener('change', () => { berputar = !gerakDikurangi.matches; });
"Lihat sablon" lalu pindah langsung tanpa animasi, dan klip lompat dilewati.
Jangan bikin objek baru di dalam loop
Tiap new THREE.Vector3() atau .clone() di loop jadi sampah yang nanti dibersihkan garbage collector (GC), dan selama itu JavaScript berhenti sebentar. Diukur di Chrome (laptop, 120 fps, 8 detik, 1.000 partikel uap): memakai ulang satu vektor 1 kali GC; new THREE.Vector3() per partikel 7 kali; posisi.clone() per partikel 34 kali, total 6,6 ms. Di laptop tiap jeda cuma sekitar 0,3 ms; di HP yang CPU-nya 4 kali lebih lambat, perkiraannya sekitar 1,3 ms, padahal satu frame 120 Hz cuma 8,3 ms. Buat objek sementara sekali di luar loop:
const sementara = new THREE.Vector3(); // sekali saja
for (const p of partikel) p.position.add(sementara.set(0, 0.3 * delta, 0));
⚠️ Jebakan yang sering ketemu
- Menambah angka tetap per frame (
+= 0.01): mug berputar dua kali lebih cepat di layar 120 Hz. Kalikan dengandelta. getElapsedTime()lalugetDelta()di THREE.Clock: delta hampir 0, benda nyaris diam. PakaiTHREE.Timer.- Tanpa
timer.connect(document)atau batas delta: mug melompat setelah tab tersembunyi. - Keyframe quaternion 0° dan 360° saja: mug diam; beri keyframe tiap 120°.
- Lupa
mixer.update(delta): klip dimainkan tapi gak ada yang bergerak.
TL;DR: Animasi = ubah properti tiap frame lalu render. requestAnimationFrame (atau renderer.setAnimationLoop) jalan sekali per refresh layar dan berhenti di tab tersembunyi; callback-nya menerima time, bukan delta. Hitung gerak dari waktu: THREE.Timer (update(time), getDelta(), getElapsed()), bukan THREE.Clock yang deprecated sejak r183; += KECEPATAN * delta, Math.sin(t) * 3 untuk bolak-balik -3 sampai 3. Easing (easeInOutCubic) membuat mulai dan berhenti halus. Klip keyframe: AnimationClip + NumberKeyframeTrack / QuaternionKeyframeTrack, AnimationMixer, clipAction(...).play(), mixer.update(delta). timer.connect(document) mencegah lompatan setelah tab tersembunyi, prefers-reduced-motion mematikan putaran otomatis, dan objek sementara dibuat sekali di luar loop.