This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Mô phỏng vật lý 2D mang lại sinh khí và tính chân thực vượt trội cho game và hoạt ảnh trên trình duyệt. Thay vì đặt tọa độ cứng nhắc, chúng ta sẽ xây dựng một mô hình toán học động lực học dựa trên lực, ma sát, quán tính và sự tích phân thời gian. Bài học bắt đầu từ nền tảng — vận tốc và gia tốc — rồi tiến tới trọng lực, ma sát, lò xo và con lắc.
1. Velocity & Acceleration Cơ Bản
Toàn bộ vật lý chuyển động xoay quanh ba đại lượng vector: vị trí (position), vận tốc (velocity) và gia tốc (acceleration). Mối quan hệ giữa chúng rất đơn giản nhưng là gốc rễ của mọi mô phỏng: gia tốc làm thay đổi vận tốc, và vận tốc làm thay đổi vị trí. Mỗi đại lượng có hai thành phần x và y vì ta đang ở không gian 2D.
Trong mỗi khung hình, ta thực hiện một bước Euler: cộng gia tốc vào vận tốc, rồi cộng vận tốc vào vị trí. Đây chính là cách máy tính xấp xỉ tích phân chuyển động liên tục bằng các bước rời rạc.
\[\vec{v} \mathrel{+}= \vec{a} \cdot \Delta t \qquad \vec{p} \mathrel{+}= \vec{v} \cdot \Delta t\]
Tại sao cần delta-time (\(\Delta t\))? Vì tốc độ khung hình của trình duyệt không cố
định — máy mạnh chạy 144 FPS, máy yếu 30 FPS. Nếu bạn cộng thẳng vận tốc mà không nhân với khoảng thời
gian thật giữa hai khung hình, vật thể sẽ di chuyển nhanh/chậm khác nhau trên mỗi máy. Nhân với
\(\Delta t\) (tính bằng giây) giúp chuyển động nhất quán bất kể FPS. Ta lấy \(\Delta
t\) từ tham số timestamp của requestAnimationFrame.
const obj = {
pos: { x: 100, y: 100 },
vel: { x: 60, y: 0 }, // pixel / giây
acc: { x: 0, y: 200 } // gia tốc (vd: trọng lực) pixel / giây^2
};
function step(dt) { // dt tính bằng GIÂY
obj.vel.x += obj.acc.x * dt;
obj.vel.y += obj.acc.y * dt;
obj.pos.x += obj.vel.x * dt;
obj.pos.y += obj.vel.y * dt;
}
let last = performance.now();
function loop(now) {
let dt = (now - last) / 1000; // ms -> giây
dt = Math.min(dt, 0.05); // kẹp trần để tránh "nhảy" khi tab bị treo
last = now;
step(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
2. Gravity & Bouncing (Trọng Lực & Nảy)
Trọng lực đơn giản là một gia tốc không đổi hướng xuống (trục y dương trong tọa độ canvas). Ta cộng nó vào vận tốc mỗi khung hình, khiến vật rơi nhanh dần — đúng như rơi tự do thực tế. Khi vật chạm biên (sàn, tường), ta cần đảo ngược thành phần vận tốc vuông góc với biên đó để tạo hiệu ứng nảy.
Nảy hoàn hảo (vật bật lại cao bằng lúc thả) là phi thực tế. Trong đời thực, mỗi lần va chạm mất một phần cơ năng do biến dạng và nhiệt. Ta mô hình hóa bằng hệ số đàn hồi (restitution) \(e\), giá trị từ 0 đến 1: nhân vận tốc với \(-e\) khi nảy. \(e = 1\) là nảy tuyệt đối (không mất năng lượng), \(e = 0\) là dính chặt không nảy, \(e = 0.7\) là quả bóng cao su điển hình. Vì mỗi lần nảy giảm vận tốc, bóng dần dần nảy thấp hơn rồi dừng — đó chính là mất mát năng lượng (energy loss) tự nhiên.
const GRAVITY = 900; // pixel / giây^2
function applyGravity(ball, dt) {
ball.vel.y += GRAVITY * dt; // trọng lực chỉ tác động trục y
ball.pos.y += ball.vel.y * dt;
ball.pos.x += ball.vel.x * dt;
}
const RESTITUTION = 0.7; // hệ số đàn hồi (0..1)
function bounceWalls(ball, W, H) {
const r = ball.radius;
if (ball.pos.y > H - r) { // chạm sàn
ball.pos.y = H - r; // phục hồi vị trí (tránh kẹt biên)
ball.vel.y *= -RESTITUTION; // đảo chiều + mất năng lượng
}
if (ball.pos.x < r) { ball.pos.x = r; ball.vel.x *= -RESTITUTION; }
if (ball.pos.x > W - r) { ball.pos.x = W - r; ball.vel.x *= -RESTITUTION; }
}
3. Friction & Drag (Ma Sát & Lực Cản)
Có hai loại lực cản chuyển động thường gặp. Ma sát (friction) xuất hiện khi vật trượt trên bề mặt — ta mô hình bằng cách nhân thành phần vận tốc tiếp tuyến với một hệ số nhỏ hơn 1 (vd 0.98) mỗi khung hình, làm vật trượt chậm dần rồi dừng. Lực cản không khí (air drag) tác động lên vật đang bay; ở mô hình tuyến tính đơn giản, ta cũng nhân toàn bộ vận tốc với một hệ số đệm như 0.99 mỗi bước.
Một khái niệm thú vị là vận tốc cuối (terminal velocity): khi vật rơi, trọng lực kéo nó nhanh hơn, nhưng lực cản tỉ lệ với vận tốc lại tăng theo. Tới một điểm, hai lực cân bằng và vật ngừng tăng tốc — rơi đều với vận tốc tối đa. Đó là lý do hạt mưa hay người nhảy dù không rơi nhanh vô hạn. Mô hình cản tuyến tính (\(F_{drag} = -d \cdot v\)) tự nhiên tạo ra hành vi này.
const FRICTION = 0.96; // hệ số ma sát mặt sàn (<1)
function applyGroundFriction(ball, H) {
const onGround = ball.pos.y >= H - ball.radius - 0.5;
if (onGround) {
ball.vel.x *= FRICTION; // chỉ hãm chuyển động ngang
if (Math.abs(ball.vel.x) < 1) ball.vel.x = 0; // chốt dừng hẳn
}
}
const DRAG = 0.4; // hệ số cản không khí
function applyDrag(obj, dt) {
// F_drag = -d * v => a_drag = -d * v / m (giả sử m = 1)
obj.vel.x -= DRAG * obj.vel.x * dt;
obj.vel.y -= DRAG * obj.vel.y * dt;
// Khi trọng lực = lực cản, vận tốc đạt terminal velocity và ngừng tăng
}
4. Euler Integration vs. Verlet Integration
Tích phân thời gian (Time Integration) là phương pháp cập nhật vị trí của vật thể dựa trên lực tác dụng qua mỗi bước thời gian \(\Delta t\). Có hai phương pháp phổ biến nhất trong đồ họa 2D:
[Lực F] ────► [Gia tốc A = F/m]
│
├──────────────────────────┐
▼ (Phương pháp Euler) ▼ (Phương pháp Verlet)
[Cập nhật Vận tốc V] [Tính từ X_hiện tại & X_trước đó]
│ │
▼ ▼
[Dịch chuyển X] [Dịch chuyển X cực kỳ ổn định]
A. Tích phân Euler (Euler Integration)
Đây là phương pháp cơ bản và trực quan nhất. Ta cộng gia tốc vào vận tốc, rồi cộng vận tốc vào vị trí.
// Euler dạng tường minh (Explicit Euler)
vel.x += acc.x * dt;
pos.x += vel.x * dt;
Ưu điểm: Rất đơn giản, dễ hiểu.
Nhược điểm: Tích lũy sai số
làm tăng năng lượng hệ thống theo thời gian (vật thể di chuyển nhanh dần và mất kiểm soát). Không ổn
định khi mô phỏng liên kết phức tạp như dây xích hoặc vải.
B. Tích phân Verlet (Verlet Integration)
Phương pháp này không lưu trữ vận tốc rõ ràng, mà tính toán vị trí tiếp theo dựa trên vị trí hiện tại và vị trí ở bước thời gian trước đó.
// Verlet vị trí (Position Verlet)
const temp = pos.x;
pos.x = 2 * pos.x - prevPos.x + acc.x * dt * dt;
prevPos.x = temp;
Ưu điểm: Cực kỳ ổn định. Năng lượng hệ thống được bảo toàn tự nhiên. Thích hợp cho
các trò chơi vật lý như Angry Birds (vẽ đường quỹ đạo) hay mô phỏng vải, dây thừng (Ragdoll physics).
Nhược điểm: Khó tích hợp trực tiếp lực cản phức tạp thay đổi liên tục.
5. Góc Tối Va Chạm: Lỗi Vật Thể Bị Kẹt Biên (Penetration Stuck)
Một lỗi kinh điển khi lập trình vật lý nảy là vật thể bị kẹt và trượt/rung lắc ở biên dưới sàn nhà hoặc tường khi chịu tác dụng liên tục của lực kéo nặng (Trọng lực).
[Nguyên nhân]: Ở frame \(t\), bóng đi xuyên sâu vào sàn nhà. Ta đảo ngược vận tốc \(v_y = -v_y \times 0.7\). Ở frame tiếp theo \(t+1\), bóng chưa kịp thoát ra khỏi sàn thì trọng lực đã cộng dồn kéo bóng xuống tiếp. Trình duyệt tiếp tục phát hiện bóng chạm sàn và đảo vận tốc lần nữa. Bóng bị khóa chặt bên trong sàn và rung lắc liên tục.
Giải pháp sửa lỗi (Overlap Resolution): Chúng ta buộc phải đẩy vật thể ra ngoài hoàn toàn khỏi vùng va chạm (phục hồi vị trí vật lý) trước khi đảo ngược vectơ vận tốc.
function checkFloorBounce(ball, floorY) {
if (ball.pos.y > floorY - ball.radius) {
// 1. Phục hồi vị trí vật lý: đẩy bóng sát mặt sàn
ball.pos.y = floorY - ball.radius;
// 2. Phản xạ vận tốc kèm độ hao hụt cơ năng (restitution)
ball.vel.y *= -0.75;
}
}
6. Công Thức Vật Lý Lò Xo Chuyên Sâu (Định Luật Hooke & Damping)
Chuyển động đàn hồi của lò xo nối từ điểm neo (Anchor) đến vật thể chịu tác động bởi hai lực đối nghịch:
\[F_{spring} = -k \cdot x\] \[F_{damping} = -d \cdot v\]Trong đó:
- \(k\) là độ cứng của lò xo (stiffness coefficient).
- \(x\) là vector độ lệch ly tâm: \(\vec{x} = \vec{pos}_{vật} - \vec{pos}_{neo}\). Lực phục hồi \(F_{spring}\) luôn hướng ngược chiều độ lệch để kéo vật về điểm cân bằng (do đó có dấu âm).
- \(d\) là hệ số cản ma sát giảm chấn (damping coefficient). Lực cản \(F_{damping}\) tỉ lệ thuận và ngược chiều vận tốc hiện tại \(\vec{v}\) để triệt tiêu dần cơ năng. Thiếu \(F_{damping}\), lò xo sẽ dao động vô hạn vĩnh viễn.
class Spring {
constructor(anchor, k, damping) {
this.anchor = anchor; // Vector2D
this.k = k;
this.damping = damping;
}
update(particle) {
// Vector độ lệch x = pos - anchor
const x = new Vector2D(
particle.pos.x - this.anchor.x,
particle.pos.y - this.anchor.y
);
// Lực hồi lò xo: F_spring = -k * x
const fSpring = { x: -this.k * x.x, y: -this.k * x.y };
// Lực cản damping: F_damping = -d * v
const fDamping = { x: -this.damping * particle.vel.x, y: -this.damping * particle.vel.y };
// Tổng hợp lực F_total = F_spring + F_damping
const fTotal = { x: fSpring.x + fDamping.x, y: fSpring.y + fDamping.y };
// Gia tốc a = F_total / m (giả sử m = 1)
particle.vel.x += fTotal.x;
particle.vel.y += fTotal.y;
particle.pos.x += particle.vel.x;
particle.pos.y += particle.vel.y;
}
}
Lưu ý thực tế: để lò xo ổn định số học, hệ số k không nên quá lớn so với bước thời gian —
nếu không phương pháp Euler sẽ "nổ" (vận tốc tăng vô hạn). Nếu cần lò xo rất cứng, hãy chia nhỏ mỗi
khung hình thành nhiều bước con (sub-steps) hoặc dùng tích phân ngầm. Demo dưới cho phép bạn cầm và
quăng quả nặng treo trên lò xo, đồng thời chỉnh độ cứng và giảm chấn để cảm nhận trực tiếp:
7. Con Lắc & Hấp Dẫn N-body (Giới Thiệu)
Một con lắc đơn (pendulum) là khối lượng treo trên dây cứng dao động quanh điểm neo
dưới tác dụng trọng lực. Khác lò xo, dây không co giãn nên ta mô hình bằng góc \(\theta\): gia tốc góc
tỉ lệ với -(g/L)·sin(θ). Khi góc nhỏ, dao động gần như điều hòa; khi góc lớn, chuyển động
phi tuyến rõ rệt. Thêm hệ số giảm chấn để con lắc từ từ dừng lại.
Mở rộng lên nhiều vật là bài toán N-body: mỗi cặp vật hút nhau theo định luật vạn vật hấp dẫn Newton \(F = G\,\frac{m_1 m_2}{r^2}\), hướng dọc theo đường nối hai tâm. Ta cộng dồn lực từ mọi vật khác lên từng vật rồi tích phân như bình thường. Với \(n\) vật, độ phức tạp là \(O(n^2)\); để mô phỏng hàng nghìn vật người ta dùng thuật toán xấp xỉ Barnes–Hut (\(O(n \log n)\)).
// Con lắc đơn: tích phân theo góc theta
function stepPendulum(p, dt) {
const g = 9.8, damping = 0.999;
const angAcc = -(g / p.length) * Math.sin(p.angle);
p.angVel = (p.angVel + angAcc * dt) * damping;
p.angle += p.angVel * dt;
// Quy đổi sang tọa độ Descartes để vẽ
p.x = p.anchorX + Math.sin(p.angle) * p.length * 60;
p.y = p.anchorY + Math.cos(p.angle) * p.length * 60;
}
// N-body: lực hấp dẫn giữa từng cặp vật
function gravityForce(a, b, G = 50) {
const dx = b.x - a.x, dy = b.y - a.y;
const r2 = dx * dx + dy * dy + 25; // +25: làm mềm để tránh chia 0
const f = G * a.mass * b.mass / r2;
const r = Math.sqrt(r2);
return { fx: f * dx / r, fy: f * dy / r };
}
8. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 0: Delta-time
Vì sao phải nhân vận tốc với delta-time (\(\Delta t\)) khi cập nhật vị trí?
Trắc nghiệm 0b: Restitution
Hệ số đàn hồi (restitution) e = 1 nghĩa là gì?
9. Câu hỏi trắc nghiệm nâng cao
Trắc nghiệm 1: Verlet Integration
Tại sao phương pháp Verlet Integration lại ổn định hơn Euler đối với các hệ thống dây xích hay vải?
Trắc nghiệm 2: Penentration Stuck
Cách đúng nhất để sửa lỗi quả bóng bị kẹt và rung lắc liên tục khi chạm sàn nhà là gì?
Trắc nghiệm 3: Damping lò xo
Điều gì xảy ra với mô phỏng lò xo nếu ta đặt hệ số damping d = 0?
Tải file code thực hành minh họa bài học
File chứa toàn bộ lớp Vector, bouncing ball dưới trọng lực và mô phỏng lò xo đàn hồi:
Tải về canvas_physics.js
Comments
Bình luận