This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
1. requestAnimationFrame cơ bản
Trước khi có requestAnimationFrame (rAF), lập trình viên thường dùng
setInterval hoặc setTimeout để tạo animation. Tuy nhiên, cách tiếp cận này
có nhiều nhược điểm nghiêm trọng:
- Không đồng bộ với tần số quét màn hình: setInterval chạy theo thời gian cố định (ví dụ 16ms), nhưng trình duyệt render theo vsync (~16.67ms ở 60Hz). Kết quả là frame bị lệch, gây hiện tượng jank hoặc tearing.
- Tốn pin: setInterval tiếp tục chạy ngay cả khi tab bị ẩn, lãng phí CPU và pin laptop/điện thoại.
- Không được tối ưu: Trình duyệt không thể gộp (batch) các lệnh vẽ setInterval như với rAF.
// ❌ Cách cũ: setInterval — KHÔNG nên dùng cho animation
setInterval(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
x += 2;
ctx.fillRect(x, 100, 50, 50);
}, 1000 / 60); // Cố gắng chạy 60fps nhưng không chính xác
// ✅ Cách đúng: requestAnimationFrame
function animate(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
x += 2;
ctx.fillRect(x, 100, 50, 50);
requestAnimationFrame(animate); // Yêu cầu frame tiếp theo
}
requestAnimationFrame(animate); // Bắt đầu vòng lặp
requestAnimationFrame nhận một callback và truyền vào tham số timestamp —
thời gian tính bằng millisecond kể từ khi trang được load (giống performance.now()). Đặc
biệt quan trọng: trình duyệt tự động tạm dừng rAF khi tab bị ẩn, tiết kiệm tài nguyên
đáng kể.
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function animate(timestamp) {
// timestamp là DOMHighResTimeStamp (ms)
console.log('Frame tại:', timestamp.toFixed(2), 'ms');
// Dừng animation khi chuyển tab
// → rAF TỰ ĐỘNG dừng, không cần code thêm
// Hủy animation nếu cần
const animId = requestAnimationFrame(animate);
// cancelAnimationFrame(animId); // gọi khi muốn dừng
}
// Bắt đầu
const firstFrameId = requestAnimationFrame(animate);
// Dừng animation sau 5 giây
setTimeout(() => {
cancelAnimationFrame(firstFrameId);
console.log('Animation đã dừng');
}, 5000);
2. Game Loop Pattern
Một game loop chuyên nghiệp tách biệt rõ ràng 3 giai đoạn trong mỗi frame: Input → Update → Draw. Việc tách biệt này giúp code dễ bảo trì, dễ debug, và quan trọng nhất là cho phép thay đổi tốc độ update độc lập với tốc độ render.
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Trạng thái game
const state = {
ball: { x: 100, y: 200, vx: 150, vy: -100, radius: 15 },
score: 0,
running: true
};
// Hệ thống input
const keys = {};
window.addEventListener('keydown', e => keys[e.code] = true);
window.addEventListener('keyup', e => keys[e.code] = false);
function handleInput() {
if (keys['ArrowLeft']) state.ball.vx -= 10;
if (keys['ArrowRight']) state.ball.vx += 10;
if (keys['Space']) state.ball.vy = -300; // nhảy
}
function update(deltaTime) {
const ball = state.ball;
// Cập nhật vị trí
ball.x += ball.vx * deltaTime;
ball.y += ball.vy * deltaTime;
// Gravity
ball.vy += 500 * deltaTime;
// Va chạm biên
if (ball.x - ball.radius < 0 || ball.x + ball.radius > canvas.width) {
ball.vx *= -0.8;
ball.x = Math.max(ball.radius, Math.min(canvas.width - ball.radius, ball.x));
}
if (ball.y + ball.radius > canvas.height) {
ball.y = canvas.height - ball.radius;
ball.vy *= -0.7;
}
}
function draw() {
// Xóa canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Vẽ ball
const ball = state.ball;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fillStyle = '#06b6d4';
ctx.fill();
ctx.strokeStyle = '#0891b2';
ctx.lineWidth = 2;
ctx.stroke();
// Vẽ score
ctx.fillStyle = '#f8fafc';
ctx.font = '16px monospace';
ctx.fillText(`Score: ${state.score}`, 10, 25);
}
// Game Loop chính
let lastTimestamp = 0;
function gameLoop(timestamp) {
if (!state.running) return;
const deltaTime = (timestamp - lastTimestamp) / 1000; // chuyển sang giây
lastTimestamp = timestamp;
// Bỏ qua frame đầu tiên (deltaTime quá lớn)
if (deltaTime < 0.1) {
handleInput();
update(deltaTime);
draw();
}
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
Trong ví dụ trên, hàm update() nhận deltaTime tính bằng giây. Mọi chuyển
động đều nhân với deltaTime để đảm bảo tốc độ nhất quán bất kể FPS. Hàm draw() chỉ đọc
state và vẽ — không bao giờ thay đổi state.
// Game Loop nâng cao: Fixed Timestep
// Đảm bảo physics luôn chạy ở tốc độ cố định
const FIXED_DT = 1 / 60; // 60 updates/giây
let accumulator = 0;
let previousTimestamp = 0;
function advancedGameLoop(timestamp) {
let frameTime = (timestamp - previousTimestamp) / 1000;
previousTimestamp = timestamp;
// Giới hạn frameTime tránh "spiral of death"
if (frameTime > 0.25) frameTime = 0.25;
accumulator += frameTime;
// Chạy update nhiều lần nếu cần (fixed timestep)
while (accumulator >= FIXED_DT) {
handleInput();
update(FIXED_DT); // Luôn update với dt cố định
accumulator -= FIXED_DT;
}
// Interpolation cho smooth rendering
const alpha = accumulator / FIXED_DT;
draw(alpha); // Truyền alpha để lerp vị trí render
requestAnimationFrame(advancedGameLoop);
}
requestAnimationFrame(advancedGameLoop);
3. Delta Time
Delta Time (thời gian giữa 2 frame liên tiếp) là khái niệm then chốt trong game development. Nếu không dùng deltaTime, tốc độ animation sẽ phụ thuộc vào FPS — máy mạnh chạy nhanh hơn, máy yếu chạy chậm hơn.
// ❌ SAI: Không dùng deltaTime — tốc độ phụ thuộc FPS
function updateBad() {
player.x += 5; // 5px mỗi frame
// Ở 60fps: di chuyển 300px/s
// Ở 30fps: di chuyển 150px/s ← KHÔNG NHẤT QUÁN!
}
// ✅ ĐÚNG: Dùng deltaTime — tốc độ nhất quán
const SPEED = 300; // 300 pixels mỗi giây
function updateGood(deltaTime) {
player.x += SPEED * deltaTime;
// Ở 60fps: deltaTime ≈ 0.0167s → 300 * 0.0167 = 5px/frame
// Ở 30fps: deltaTime ≈ 0.0333s → 300 * 0.0333 = 10px/frame
// Kết quả: cùng 300px/s bất kể FPS!
}
// Tính deltaTime từ timestamp của rAF
let lastTime = 0;
function animate(timestamp) {
const deltaTime = (timestamp - lastTime) / 1000; // ms → giây
lastTime = timestamp;
// Bảo vệ: bỏ qua deltaTime quá lớn
// (xảy ra khi quay lại tab sau thời gian ẩn)
const safeDt = Math.min(deltaTime, 0.1); // tối đa 100ms
update(safeDt);
draw();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Quy tắc vàng: mọi giá trị thay đổi theo thời gian đều phải nhân với deltaTime. Vị trí, góc xoay, độ mờ (opacity), kích thước — tất cả.
function update(dt) {
// Vị trí: velocity * deltaTime
player.x += player.vx * dt;
player.y += player.vy * dt;
// Góc xoay: tốc độ quay * deltaTime
spinner.angle += spinner.rotationSpeed * dt; // rad/s
// Fade in: tăng opacity theo thời gian
if (overlay.opacity < 1) {
overlay.opacity += 0.5 * dt; // 0.5 = fade hết trong 2 giây
overlay.opacity = Math.min(overlay.opacity, 1);
}
// Scale animation
if (button.scale < 1.2) {
button.scale += 0.8 * dt; // phóng to trong ~0.25s
}
// Cooldown timer
if (weapon.cooldown > 0) {
weapon.cooldown -= dt; // trừ thời gian thực
}
}
4. FPS Counter
Hiển thị FPS (Frames Per Second) trên canvas giúp bạn theo dõi hiệu năng realtime. Có nhiều cách tính FPS, từ đơn giản đến chính xác:
// === Cách 1: FPS đơn giản (nhảy số nhiều) ===
let fps = 0;
let frameCount = 0;
let lastFpsTime = 0;
function simpleFPS(timestamp) {
frameCount++;
if (timestamp - lastFpsTime >= 1000) {
fps = frameCount;
frameCount = 0;
lastFpsTime = timestamp;
}
return fps;
}
// === Cách 2: Smoothed FPS (Moving Average) ===
class FPSCounter {
constructor(sampleSize = 60) {
this.samples = new Array(sampleSize).fill(16.67);
this.index = 0;
this.sampleSize = sampleSize;
}
update(deltaTime) {
this.samples[this.index] = deltaTime;
this.index = (this.index + 1) % this.sampleSize;
}
getFPS() {
const avgDelta = this.samples.reduce((a, b) => a + b, 0) / this.sampleSize;
return avgDelta > 0 ? (1000 / avgDelta).toFixed(1) : 0;
}
getFrameTime() {
return (this.samples.reduce((a, b) => a + b, 0) / this.sampleSize).toFixed(2);
}
}
// Sử dụng
const fpsCounter = new FPSCounter(60);
let prevTime = 0;
function gameLoop(timestamp) {
const dt = timestamp - prevTime;
prevTime = timestamp;
fpsCounter.update(dt);
// Vẽ FPS lên canvas
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(5, 5, 140, 40);
ctx.fillStyle = '#00ff00';
ctx.font = '12px monospace';
ctx.fillText(`FPS: ${fpsCounter.getFPS()}`, 10, 20);
ctx.fillText(`Frame: ${fpsCounter.getFrameTime()}ms`, 10, 36);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
Moving average cho kết quả mượt hơn vì trung bình hóa nhiều frame thay vì chỉ đếm frames trong 1 giây. Bạn cũng có thể dùng Exponential Moving Average (EMA) để phản ứng nhanh hơn với thay đổi đột ngột:
// Exponential Moving Average FPS
class EMA_FPS {
constructor(smoothing = 0.9) {
this.smoothing = smoothing; // 0.9 = rất mượt, 0.5 = phản ứng nhanh
this.avgFrameTime = 16.67;
}
update(deltaMs) {
// EMA: new = old * smoothing + current * (1 - smoothing)
this.avgFrameTime =
this.avgFrameTime * this.smoothing +
deltaMs * (1 - this.smoothing);
}
getFPS() {
return (1000 / this.avgFrameTime).toFixed(1);
}
}
const fpsEma = new EMA_FPS(0.95);
// Trong game loop: fpsEma.update(deltaTimeMs);
5. Sprite Animation cơ bản
Sprite animation sử dụng một spritesheet — hình ảnh chứa nhiều frame xếp cạnh nhau. Để tạo hiệu ứng animation, ta lần lượt vẽ từng frame theo thời gian. Điểm quan trọng: chuyển frame dựa theo thời gian (deltaTime), không phải theo số lần gọi rAF.
class SpriteAnimation {
constructor(image, frameWidth, frameHeight, frameCount, fps = 12) {
this.image = image; // Image object đã load
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.frameCount = frameCount;
this.fps = fps;
this.currentFrame = 0;
this.frameTimer = 0;
this.frameDuration = 1 / fps; // thời gian mỗi frame (giây)
this.loop = true;
this.finished = false;
}
update(deltaTime) {
if (this.finished) return;
this.frameTimer += deltaTime;
// Chuyển frame khi đủ thời gian
if (this.frameTimer >= this.frameDuration) {
this.frameTimer -= this.frameDuration; // giữ phần dư
this.currentFrame++;
if (this.currentFrame >= this.frameCount) {
if (this.loop) {
this.currentFrame = 0;
} else {
this.currentFrame = this.frameCount - 1;
this.finished = true;
}
}
}
}
draw(ctx, x, y, scale = 1) {
const sx = this.currentFrame * this.frameWidth; // vị trí cắt trên spritesheet
const sy = 0;
ctx.drawImage(
this.image,
sx, sy, this.frameWidth, this.frameHeight, // source
x, y, // destination position
this.frameWidth * scale, // destination width
this.frameHeight * scale // destination height
);
}
reset() {
this.currentFrame = 0;
this.frameTimer = 0;
this.finished = false;
}
}
// === Sử dụng ===
const spritesheet = new Image();
spritesheet.src = 'character_run.png';
// Giả sử: 8 frame, mỗi frame 64x64px, xếp ngang
spritesheet.onload = () => {
const runAnim = new SpriteAnimation(spritesheet, 64, 64, 8, 12);
let lastTime = 0;
function animate(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
runAnim.update(dt);
runAnim.draw(ctx, 100, 150, 2); // vẽ ở (100,150), scale 2x
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
};
Để quản lý nhiều animation cho một nhân vật (idle, run, jump, attack), ta dùng Animation State Machine:
class AnimationController {
constructor() {
this.animations = {}; // { 'idle': SpriteAnimation, 'run': ..., }
this.currentAnim = null;
this.currentName = '';
}
add(name, animation) {
this.animations[name] = animation;
if (!this.currentAnim) this.play(name);
}
play(name) {
if (this.currentName === name) return; // đã đang chạy
this.currentName = name;
this.currentAnim = this.animations[name];
this.currentAnim.reset();
}
update(deltaTime) {
if (this.currentAnim) {
this.currentAnim.update(deltaTime);
}
}
draw(ctx, x, y, scale) {
if (this.currentAnim) {
this.currentAnim.draw(ctx, x, y, scale);
}
}
}
// Sử dụng
const playerAnim = new AnimationController();
playerAnim.add('idle', new SpriteAnimation(sheet, 64, 64, 4, 6));
playerAnim.add('run', new SpriteAnimation(sheet, 64, 64, 8, 12));
playerAnim.add('jump', new SpriteAnimation(sheet, 64, 64, 3, 8));
// Trong update():
if (player.vy !== 0) playerAnim.play('jump');
else if (Math.abs(player.vx) > 10) playerAnim.play('run');
else playerAnim.play('idle');
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: requestAnimationFrame
Điều gì xảy ra với requestAnimationFrame khi người dùng chuyển sang tab khác?
Trắc nghiệm 2: Delta Time
Tại sao cần nhân velocity với deltaTime thay vì cộng giá trị cố định mỗi frame?
Trắc nghiệm 3: Game Loop
Trong game loop pattern Input → Update → Draw, tại sao hàm draw() không nên
thay đổi state?
Tải file code thực hành minh họa bài học
File tổng hợp các pattern animation loop, delta time, FPS counter và sprite animation:
Tải về canvas_animation.js
Comments
Bình luận