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.
setInterval_vs_rAF.js
// ❌ 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ể.

rAF_timestamp.js
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.

game_loop.js
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.

fixed_timestep.js
// 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.

delta_time.js
// ❌ 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);
📐 Demo tương tác: Delta-time vs Fixed-step

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ả.

delta_time_examples.js
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:

fps_counter.js
// === 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:

fps_ema.js
// 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.

sprite_animation.js
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:

animation_state.js
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

Related Articles

Bài viết liên quan trong series

Lesson 8: Easing & Tweening: Advanced Animation Formulas Bài 8: Easing & Tweening — Công Thức Animation Chuyên Sâu Lesson 6: Math Foundations for Canvas: Trigonometry, 2D Vectors & Matrices Bài 6: Toán Nền Tảng: Lượng Giác, Vector 2D & Ma Trận Back to Canvas Series Overview Quay lại Lộ trình Canvas Series