Ada satu jenis bug yang bikin kamu ngerasa lagi dikerjain: kodenya jalan, tween-nya kepanggil, onComplete-nya bunyi, tapi di layar tidak ada yang bergerak. Saya kena yang seperti itu waktu membangun hero galeri WebGL untuk sebuah situs company profile kelas premium. Butuh waktu cukup lama sampai saya sadar masalahnya bukan di animasinya sama sekali.
Gejalanya: tween-nya jalan, mesh-nya diam
Hero-nya berupa strip galeri: deretan plane bergambar yang digeser ke kiri dan kanan mengikuti scroll, dirender pakai Three.js. Waktu halaman pertama kali dibuka, semua plane seharusnya menumpuk di tengah lalu mekar ke samping satu per satu, gerakan fan-out yang klasik. Saya tulis pakai gsap.to() ke mesh.position, dan di atas kertas itu tiga baris kode.
Yang muncul di layar lain ceritanya. Sejak frame pertama plane-nya sudah terpampang di susunan akhirnya, bergetar sekejap, lalu diam total. Gerakan mekar dari tengah itu tidak pernah kelihatan sama sekali, seolah tween-nya tidak pernah dieksekusi.
Saya cek yang biasa dicek:
- Console bersih, tidak ada error.
onCompletetiap tween tetap jalan, jadi GSAP memang menjalankan animasinya sampai selesai.- Kalau nilai
mesh.position.xsaya log dari dalamonUpdate, angkanya berubah mulus dari 0 ke target. Persis seperti yang saya minta.
Jadi GSAP mengerjakan tugasnya dengan benar. Angkanya berubah. Tapi yang tergambar di canvas bukan angka itu.
Akar masalahnya: dua penulis, satu properti
Render loop galeri ini scroll-driven. Tiap frame dia menghitung ulang posisi setiap mesh dari state scroll saat itu:
render() {
this.meshes.forEach((mesh, i) => {
const b = this.getOffset(i); // offset hasil hitungan dari state scroll
mesh.position.x = b * this.spacing;
});
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render);
}Baca ulang baris mesh.position.x = b * this.spacing. Itu bukan "geser posisi", itu menetapkan posisi, tanpa syarat, enam puluh kali per detik.
Urutan kejadian di dalam satu frame jadi begini: GSAP menulis nilai tween ke mesh.position.x, lalu render loop menimpanya dengan angka hasil hitungan scroll, baru setelah itu renderer menggambar. Yang tergambar selalu angka milik loop. Nilai dari GSAP hidup beberapa mikrodetik lalu mati sebelum sempat kelihatan.
Ini bukan bug GSAP dan bukan bug Three.js. Keduanya melakukan persis apa yang diminta. Yang salah ada di desain saya: render loop tidak punya konsep bahwa sedang ada animasi lain yang memegang kendali atas posisi. Dia menganggap dirinya satu-satunya pemilik properti itu.
Dan ini masalah yang lebih umum daripada kelihatannya. Setiap kali kamu punya loop requestAnimationFrame yang menulis properti dari sebuah sumber kebenaran, lalu kamu pasang tween ke properti yang sama, kamu bukan sedang menganimasikan apa-apa. Kamu sedang balapan yang sudah pasti kalah, karena loop selalu menulis terakhir sebelum frame digambar.
Perbaikannya: satu boolean yang menentukan siapa pemiliknya
Solusinya ternyata sederhana, dan bukan saya yang pertama menemukannya. Waktu saya bongkar bundle situs referensi yang jadi acuan desainnya, polanya ada di sana: satu flag boolean bernama isIntro yang menentukan siapa yang sedang memiliki posisi.
Selama isIntro bernilai true, render loop melewati perhitungan posisi sepenuhnya. GSAP jadi satu-satunya penulis. Begitu intro selesai, flag-nya dimatikan dan kendali dikembalikan ke scroll.
render() {
this.meshes.forEach((mesh, i) => {
const b = this.getOffset(i);
if (!this.isIntro) {
mesh.position.x = b * this.spacing; // hanya jalan kalau intro sudah kelar
}
const mat = mesh.material;
mat.uniforms.uTime.value = this.clock.getElapsedTime();
mat.uniforms.uVelocity.value = this.isIntro ? 0 : shaderVel;
});
this.renderer.render(this.scene, this.camera);
requestAnimationFrame(this.render);
}Perhatikan bahwa yang dilewati cuma baris posisinya. Uniform shader tetap diupdate, karena efek reveal dan animasi berbasis waktu di shader tetap harus jalan selama intro.
Satu-satunya uniform yang perlu perlakuan khusus adalah velocity. Nilainya dihitung dari selisih posisi scroll antar frame, dan selama intro posisi scroll tidak bergerak sama sekali, sementara mesh-nya bergerak jauh karena tween. Kalau velocity dibiarkan mengikuti hitungan biasa, angkanya jadi omong kosong dan shader menampilkan distorsi di momen yang justru paling ingin kamu bikin bersih. Jadi selama intro dia dipaksa 0.
Menyerahkan kendali kembali ke scroll
Sisanya adalah soal kapan isIntro dimatikan. Tween fan-out-nya sendiri seperti ini:
playIntro() {
const total = this.meshes.length;
let done = 0;
this.meshes.forEach((mesh, i) => {
let f = i - this.centerIndex;
// normalisasi untuk strip yang berputar tanpa ujung
f = ((f % total) + total) % total;
if (f > total / 2) f -= total;
gsap.to(mesh.position, {
x: f * this.spacing,
duration: 1.2,
ease: 'expo.out',
delay: Math.abs(f) * 0.05,
onComplete: () => {
if (++done === total) {
this.isIntro = false; // kendali balik ke render loop
}
},
});
});
}Dua detail di sini yang saya pelajari dengan cara susah.
Pertama, hitung tween yang selesai, jangan menebak mana yang terakhir. Delay-nya berbasis Math.abs(f), jadi urutan selesainya mengikuti jarak dari tengah, bukan urutan indeks di array. Tween milik elemen terakhir di array bisa jadi selesai duluan. Counter sederhana yang naik di tiap onComplete menghilangkan seluruh tebak-tebakan itu, dan flag baru dimatikan setelah tween paling akhir benar-benar mendarat.
Kedua, normalisasi modulo itu wajib untuk strip tak berujung. Tanpa dua baris itu, plane yang indeksnya jauh dari tengah akan dianimasikan ke offset raksasa, terbang melintasi layar dulu sebelum sampai posisi akhirnya. Modulo membungkus indeks ke rentang penuh, lalu pengurangan total membalik apa pun yang lewat setengah putaran menjadi jarak negatif yang lebih pendek. Hasilnya tiap plane bergerak ke arah terdekat, bukan memutari seluruh strip.
Mengulang intro tanpa merusak state
Karena intronya juga dipakai ulang, misalnya waktu galeri di-reset, saya bikin fungsi terpisah:
replayIntro() {
this.isIntro = true; // kunci dulu
this.meshes.forEach((mesh) => {
mesh.position.x = 0;
mesh.material.uniforms.uReveal.value = 0;
});
this.playIntro();
}Urutannya penting, tapi yang lebih penting lagi ketiganya harus terjadi di tick yang sama. Kunci dulu dengan isIntro = true, baru reset posisinya, baru panggil playIntro(). Selama semuanya sinkron seperti itu, tidak ada frame yang bisa menyelinap di antaranya, karena render loop hanya jalan setelah kode ini selesai. Yang bikin celaka adalah menunda kuncinya: flag baru disetel di dalam callback, setelah await, atau di tick berikutnya. Cukup satu frame digambar dengan loop masih merasa memiliki posisi, reset yang baru kamu tulis langsung ditimpa angka scroll, dan fan-out-nya berangkat dari susunan yang salah, bukan dari tumpukan di tengah. Bug seperti itu munculnya kadang-kadang saja, dan itu jenis bug yang paling menyebalkan dikejar.
Yang saya bawa pulang
- Kalau dua sistem menulis properti yang sama tiap frame, yang menang bukan yang paling benar, tapi yang menulis terakhir sebelum frame digambar. Di kasus ini itu selalu render loop.
- Loop
requestAnimationFrameyang menetapkan nilai secara tanpa syarat harus punya cara untuk melepas kepemilikan. Satu boolean sudah cukup, tidak perlu state machine. - Kalau bagian dari loop dilewati, cek nilai turunan yang ikut terdampak. Velocity yang dihitung dari selisih posisi jadi tidak masuk akal begitu posisi digerakkan oleh sumber lain.
- Tween yang berjalan tapi tidak kelihatan hampir selalu berarti ada yang menimpanya, bukan berarti tween-nya gagal. Log nilainya dari
onUpdateuntuk membuktikan hal itu sejak awal, sebelum membongkar konfigurasi animasinya.