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
- stiffness (kekakuan): makin besar, makin cepat dan responsif tarikannya.
- damping (redaman): makin besar, makin sedikit memantul.
- mass (massa): makin besar, makin berat dan lambat.
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 |
- ζ < 1: underdamped, melewati tujuan lalu bolak-balik sebelum diam. Seberapa jauh: 16 × e^(-ζπ / √(1 - ζ²)) = 6,2 px untuk ζ 0,29.
- ζ = 1: critically damped. Di latihan,
100, 20memberi 20 / (2 × 10) = tepat 1, jadi bandingkan dengan===tanpa membulatkan. - ζ > 1: overdamped, lambat sampai.
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
- Posisi diperbarui sebelum kecepatan. Itu Euler biasa: hasil latihannya jadi
[0, 0, 2.7778, ...], dan pegas lemah gak pernah diam. dtdari frame mentah tanpa batas. Tab yang kembali setelah 5 detik memberidt5 detik dan pegasnya meledak. Batasi denganMath.min(..., 0.1)dan pakai langkah tetap.- Gesekan per frame.
v *= 0.95meluncur separuh jarak di 120 Hz. PakaiMath.pow(0.95, dt * 60)atauMath.exp(-dt / 0.325). - Massa lupa ikut dihitung. ζ =
damping / (2 * Math.sqrt(stiffness * mass)):100, 20itu critically damped, tapi dengan mass 4 jadi underdamped (ζ 0,5). - ζ dibulatkan sebelum dibandingkan. Bandingkan
ratio === 1dengan angka utuh; 1,0004 dibulatkan jadi 1 bukan pegas kritis.
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.