Physics-based Animation — Web Animation

Physics-based Animation Physics-based animation menggunakan model matematika dari fisika nyata — pegas, gravitasi, inersia — untuk menghasilkan gerakan yang…

Physics-based animation gak bilang "geser 16 px dalam 300 ms dengan kurva ini". Dia menulis gaya: pegas yang menarik ke tujuan, redaman yang menahan, gesekan yang memperlambat. Lalu setiap frame posisinya dihitung dari gaya itu. Durasinya muncul sendiri. Bayangin shock breaker motor: per-nya (pegas) mendorong roda kembali ke posisi normal, oli di dalamnya (redaman) mencegah motor terus memantul, dan berat penumpang (massa) membuat semuanya lebih lambat. Shock yang olinya habis bikin motor memantul-mantul; shock yang terlalu keras kembali pelan sekali.

🧪 Tebakan cepat: Kode galeri foto mengurangi kecepatan 5% di setiap frame (v *= 0.95). Tamu melempar galeri dengan kecepatan 1.500 px per detik. Di laptop 60 Hz galerinya meluncur sekitar 494 px. Di laptop 120 Hz, sejauh apa? Jawabannya ada di bagian "Momentum: galeri yang dilempar".

Gaya pegas dan redaman

Pegas mengikuti hukum Hooke: makin jauh dari tujuan, makin kuat tarikannya. Redaman melawan kecepatan. Keduanya bertanda minus karena sama-sama melawan:

gaya pegas  = -stiffness × (posisi - tujuan)
gaya redam  = -damping × kecepatan
percepatan  = (gaya pegas + gaya redam) / mass

Posisi dalam px, kecepatan dalam px per detik, waktu dalam detik. Ini tiga angka yang sama dengan transition={{ type: 'spring', stiffness, damping, mass }} di Motion.

Satu langkah: semi-implicit Euler

Komputer gak bisa menghitung "terus-menerus". Dia maju sedikit-sedikit, dt detik per langkah: hitung percepatan, perbarui kecepatan dulu, lalu posisi dengan kecepatan yang baru. Urutan ini namanya semi-implicit Euler:

function simulateSpring(from, to, { stiffness, damping, mass }) {
  const dt = 1 / 60;
  let position = from, velocity = 0;
  const positions = [Number(position.toFixed(4))];
  for (let step = 0; step < 4; step++) {
    const acceleration = (-stiffness * (position - to) - damping * velocity) / mass;
    velocity = velocity + acceleration * dt;   // kecepatan dulu...
    position = position + velocity * dt;       // ...baru posisi, dengan kecepatan yang baru
    positions.push(Number(position.toFixed(4)));
  }
  return positions;
}

Jejak simulateSpring(0, 100, { stiffness: 100, damping: 10, mass: 1 }):

Langkah Percepatan Kecepatan Posisi
awal 0 0
1 (-100 × (0 - 100) - 0) / 1 = 10.000 166,67 2,7778
2 9.722 - 1.667 = 8.056 300,93 7,7932
3 14,534
4 22,5254

Hasilnya [0, 2.7778, 7.7932, 14.534, 22.5254]. Kalau posisi diperbarui duluan (Euler biasa), langkah pertama masih memakai kecepatan 0 dan hasilnya [0, 0, 2.7778, 7.8704, 14.8148]; versi itu juga menambah energi sedikit demi sedikit, sehingga pegas yang redamannya kecil gak pernah tenang. Pegas yang sudah di tujuan (simulateSpring(50, 50, ...)) gak bergerak: gayanya nol.

Rasio redaman ζ

Satu angka menentukan apakah pegas memantul: rasio redaman ζ (zeta) = damping / (2 × √(stiffness × mass)).

stiffness, damping, mass ζ Jenis Kartu 16 px
500, 25, 1 (bawaan y di Motion) 0,56 underdamped melewati tujuan 1,9 px
300, 30, 1 0,87 underdamped hampir gak memantul (0,03 px)
300, 10, 1 0,29 underdamped memantul 6,2 px
100, 20, 1 1 critically damped berhenti tepat, secepat mungkin tanpa memantul
100, 20, 4 0,5 underdamped massa 4 kali lipat = memantul
100, 25, 1 1,25 overdamped pelan, gak pernah melewati tujuan

Material 3 menulis pegasnya langsung dengan ζ: motionSpringFastSpatial damping 0,9 dan stiffness 1400 untuk tombol dan switch, dan pegas "effects" (warna, opacity) memakai ζ 1 supaya nilainya gak melewati tujuan.

Langkah tetap dengan akumulator

Di pelajaran requestAnimationFrame, jarak antar frame berubah-ubah: 8,3 ms di 120 Hz, 16,7 ms di 60 Hz, lebih lama saat halaman sibuk. Kalau dt ikut berubah-ubah, hasil fisikanya beda tiap layar, dan dt yang besar membuat pegas meledak. Solusinya: tampung waktu nyata, lalu maju dengan langkah tetap:

const STEP = 1 / 120;                       // langkah fisika tetap: 8,3 ms
let acc = 0, last = null, x = 16, v = 0;    // kartu mulai 16 px di bawah tujuannya

function frame(now) {
  if (last !== null) acc += Math.min((now - last) / 1000, 0.1); // waktu nyata, dibatasi
  last = now;
  while (acc >= STEP) {                     // 0, 1, atau 2 langkah per frame
    const a = (-300 * x - 10 * v) / 1;
    v += a * STEP; x += v * STEP;           // semi-implicit Euler
    acc -= STEP;
  }
  card.style.transform = `translateY(${x}px)`;
  const diam = Math.abs(v) <= 2 && Math.abs(x) <= 0.5;  // ambang diam yang dipakai Motion
  if (diam) card.style.transform = 'none'; else requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Kami bandingkan integrator ini dengan fungsi spring Motion 12.38, yang menghitung rumus tertutupnya (bukan per langkah), untuk kartu 16 px:

Pegas Langkah 1/120 s Langkah 1/1000 s Motion
500, 25 (ambang kecepatan 10) diam 275 ms, lewat 1,6 px 285 ms, 1,9 px 287 ms, 1,9 px
300, 30 333 ms 317 ms 315 ms
300, 10 725 ms, lewat 6,1 px 739 ms, 6,2 px 741 ms, 6,2 px

Semi-implicit Euler kehilangan sedikit energi di tiap langkah, jadi pantulannya sedikit lebih kecil; makin kecil langkahnya, makin dekat ke Motion. Pada 1/120 s selisihnya paling banyak 18 ms, gak terasa.

Momentum: galeri yang dilempar

Saat jari dilepas, galeri "Teras pagi / Kamar / Kamar mandi" punya kecepatan, misalnya 1.500 px per detik. Gesekan menguranginya sampai hampir diam (di bawah 20 px per detik), lalu pegas menarik ke foto terdekat (lebar foto 280 px):

Cara mengurangi kecepatan 60 Hz 120 Hz
v *= 0.95 tiap frame 494 px, foto Kamar mandi 247 px, foto Kamar
v *= Math.pow(0.95, dt * 60) (berbasis waktu) 494 px, Kamar mandi 487 px, Kamar mandi

Itu jawaban tebakannya: sekitar 247 px, separuhnya, dan tamu mendarat di foto yang berbeda. Di 120 Hz ada dua kali lebih banyak frame per detik, jadi kecepatannya dipotong 5% dua kali lebih sering. Versi berbasis waktu mengubah "0,95 per frame 60 Hz" jadi "per detik": konstanta waktunya -(1/60) / ln(0,95) = 325 ms, sama dengan timeConstant bawaan inertia di Motion 12.38. Rumus yang setara: v *= Math.exp(-dt / 0.325).

Pegas penarik itu mulai dengan kecepatan yang tersisa dari lemparan, jadi gerakannya menyambung. Itu sebabnya pegas lebih cocok dari ease-out untuk gesture: kurva tween selalu mulai dari kecepatan yang ditentukan kurvanya, bukan dari kecepatan jari. Saat "Kurangi gerakan" menyala, lewati luncuran dan pantulan: pindah langsung ke foto tujuan dengan fade 150 ms.

⚠️ Jebakan yang sering ketemu

TL;DR: Fisika = gaya, bukan durasi. Pegas (hukum Hooke): percepatan = (-stiffness × (posisi - tujuan) - damping × kecepatan) / mass. Semi-implicit Euler: kecepatan dulu (v += a * dt), baru posisi (x += v * dt); simulateSpring(0, 100, { stiffness: 100, damping: 10, mass: 1 }) dengan dt = 1/60 memberi [0, 2.7778, 7.7932, 14.534, 22.5254], dan pegas yang sudah di tujuan gak bergerak. ζ = damping / (2 × √(stiffness × mass)): < 1 underdamped (memantul; 300/10 = 0,29, lewat 6,2 px), = 1 critically damped (100/20), > 1 overdamped; mass ikut menentukan (100/20/4 = 0,5). Langkah tetap 1/120 s dengan akumulator dan dt dibatasi 0,1 detik; ambang diam Motion 2 px/s dan 0,5 px; hasilnya 275, 333, 725 ms dibanding Motion 287, 315, 741 ms. Gesekan harus berbasis waktu: v *= 0.95 per frame meluncur 494 px di 60 Hz tapi 247 px di 120 Hz; Math.pow(0.95, dt * 60) = konstanta waktu 325 ms (sama dengan timeConstant inertia Motion). Pegas cocok untuk gesture karena mulai dari kecepatan jari; saat "Kurangi gerakan", pindah langsung dengan fade 150 ms.

Yang akan kamu pelajari