This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.

1. Linear Interpolation (lerp)

Linear Interpolation (nội suy tuyến tính) là nền tảng của mọi animation. Công thức cực kỳ đơn giản nhưng vô cùng mạnh mẽ: cho 2 giá trị ab, tìm giá trị trung gian tại vị trí t (0 đến 1).

lerp_basic.js
// Công thức lerp: a + (b - a) * t
function lerp(a, b, t) {
  return a + (b - a) * t;
}

// t = 0 → trả về a (điểm bắt đầu)
// t = 1 → trả về b (điểm kết thúc)
// t = 0.5 → trả về trung điểm

console.log(lerp(0, 100, 0));    // 0
console.log(lerp(0, 100, 0.25)); // 25
console.log(lerp(0, 100, 0.5));  // 50
console.log(lerp(0, 100, 1));    // 100

// Lerp cho vector 2D
function lerpVec2(a, b, t) {
  return {
    x: lerp(a.x, b.x, t),
    y: lerp(a.y, b.y, t)
  };
}

// Lerp cho màu sắc (RGB)
function lerpColor(colorA, colorB, t) {
  return {
    r: Math.round(lerp(colorA.r, colorB.r, t)),
    g: Math.round(lerp(colorA.g, colorB.g, t)),
    b: Math.round(lerp(colorA.b, colorB.b, t))
  };
}

Ứng dụng thực tế: di chuyển mượt mà từ A đến B bằng cách tăng dần t theo thời gian.

lerp_animation.js
// Animation di chuyển mượt từ A đến B
const start = { x: 50, y: 300 };
const end = { x: 700, y: 100 };
const duration = 2; // 2 giây
let elapsed = 0;

function update(dt) {
  elapsed += dt;
  const t = Math.min(elapsed / duration, 1); // clamp 0-1

  const pos = lerpVec2(start, end, t);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
  ctx.fillStyle = '#f59e0b';
  ctx.fill();

  if (t >= 1) {
    console.log('Animation hoàn thành!');
  }
}

// Smooth follow (lerp mỗi frame — không cần t tích lũy)
// Rất phổ biến cho camera follow, smooth cursor
function smoothFollow(current, target, smoothing, dt) {
  // smoothing: 0.1 = rất mượt, 0.5 = nhanh
  return lerp(current, target, 1 - Math.pow(1 - smoothing, dt * 60));
}

// Ví dụ: camera mượt theo player
camera.x = smoothFollow(camera.x, player.x, 0.1, dt);
camera.y = smoothFollow(camera.y, player.y, 0.1, dt);

2. Easing Functions (Robert Penner)

Linear lerp di chuyển đều — trông cứng và không tự nhiên. Easing functions biến đổi giá trị t (0→1) thành đường cong, tạo cảm giác tăng tốc, giảm tốc, hoặc nảy. Robert Penner là người đầu tiên hệ thống hóa các công thức này. Tham khảo trực quan tại easings.net. Demo bên dưới vẽ đường cong easing và đồng thời cho một chấm chạy theo timing đó để bạn cảm nhận chuyển động:

📈 Demo tương tác: Easing Visualizer
easing_functions.js
const Easing = {
  // === Quad (bậc 2) — nhẹ nhàng ===
  easeInQuad:    t => t * t,
  easeOutQuad:   t => t * (2 - t),
  easeInOutQuad: t => t < 0.5
    ? 2 * t * t
    : -1 + (4 - 2 * t) * t,

  // === Cubic (bậc 3) — mạnh hơn Quad ===
  easeInCubic:    t => t * t * t,
  easeOutCubic:   t => (--t) * t * t + 1,
  easeInOutCubic: t => t < 0.5
    ? 4 * t * t * t
    : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,

  // === Quart (bậc 4) ===
  easeInQuart:  t => t * t * t * t,
  easeOutQuart: t => 1 - (--t) * t * t * t,

  // === Quint (bậc 5) ===
  easeInQuint:  t => t * t * t * t * t,
  easeOutQuint: t => 1 + (--t) * t * t * t * t,

  // === Sine — rất mượt, tự nhiên ===
  easeInSine:    t => 1 - Math.cos(t * Math.PI / 2),
  easeOutSine:   t => Math.sin(t * Math.PI / 2),
  easeInOutSine: t => -(Math.cos(Math.PI * t) - 1) / 2,

  // === Expo — rất mạnh, gần như dừng rồi bắn ra ===
  easeInExpo:  t => t === 0 ? 0 : Math.pow(2, 10 * (t - 1)),
  easeOutExpo: t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),

  // === Back — đi ngược trước khi tiến (overshoot) ===
  easeInBack: t => {
    const s = 1.70158; // overshoot amount
    return t * t * ((s + 1) * t - s);
  },
  easeOutBack: t => {
    const s = 1.70158;
    t -= 1;
    return t * t * ((s + 1) * t + s) + 1;
  },

  // === Elastic — nảy như dây thun ===
  easeInElastic: t => {
    if (t === 0 || t === 1) return t;
    return -Math.pow(2, 10 * (t - 1)) *
      Math.sin((t - 1.1) * 5 * Math.PI);
  },
  easeOutElastic: t => {
    if (t === 0 || t === 1) return t;
    return Math.pow(2, -10 * t) *
      Math.sin((t - 0.1) * 5 * Math.PI) + 1;
  },

  // === Bounce — nảy như quả bóng rơi ===
  easeOutBounce: t => {
    if (t < 1 / 2.75) {
      return 7.5625 * t * t;
    } else if (t < 2 / 2.75) {
      t -= 1.5 / 2.75;
      return 7.5625 * t * t + 0.75;
    } else if (t < 2.5 / 2.75) {
      t -= 2.25 / 2.75;
      return 7.5625 * t * t + 0.9375;
    } else {
      t -= 2.625 / 2.75;
      return 7.5625 * t * t + 0.984375;
    }
  },
  easeInBounce: t => 1 - Easing.easeOutBounce(1 - t)
};

Sử dụng easing rất đơn giản — thay vì dùng t trực tiếp trong lerp, ta cho t đi qua easing function trước:

easing_usage.js
// Không easing (linear) — cứng nhắc
const linearPos = lerp(startX, endX, t);

// Có easing — mượt mà, tự nhiên
const easedT = Easing.easeOutCubic(t);
const easedPos = lerp(startX, endX, easedT);

// So sánh trực quan: vẽ nhiều ball với easing khác nhau
const easings = [
  { name: 'linear',       fn: t => t },
  { name: 'easeInQuad',   fn: Easing.easeInQuad },
  { name: 'easeOutQuad',  fn: Easing.easeOutQuad },
  { name: 'easeInOutCubic', fn: Easing.easeInOutCubic },
  { name: 'easeOutElastic', fn: Easing.easeOutElastic },
  { name: 'easeOutBounce',  fn: Easing.easeOutBounce }
];

function drawComparison(t) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  easings.forEach((easing, i) => {
    const y = 50 + i * 60;
    const easedT = easing.fn(t);
    const x = lerp(50, canvas.width - 50, easedT);

    // Label
    ctx.fillStyle = '#94a3b8';
    ctx.font = '12px monospace';
    ctx.fillText(easing.name, 10, y + 5);

    // Ball
    ctx.beginPath();
    ctx.arc(x, y, 12, 0, Math.PI * 2);
    ctx.fillStyle = '#06b6d4';
    ctx.fill();
  });
}

3. Tween Engine tự viết

Một Tween (in-between) là object quản lý animation từ giá trị A đến B trong khoảng thời gian nhất định, với easing tùy chọn. Tween engine quản lý danh sách các tween đang chạy và update chúng mỗi frame. Hãy click vào bất kỳ đâu trên demo bên dưới — ô vuông sẽ tween mượt mà tới vị trí đó với easing bạn chọn, để lại vệt mờ:

🎯 Demo tương tác: Tween Playground (click để di chuyển)

💡 Click vào canvas để đặt đích đến cho ô vuông.

tween_engine.js
class Tween {
  constructor(target, properties, options = {}) {
    this.target = target;
    this.properties = {};
    this.duration = options.duration || 1; // giây
    this.easing = options.easing || (t => t); // linear mặc định
    this.delay = options.delay || 0;
    this.onUpdate = options.onUpdate || null;
    this.onComplete = options.onComplete || null;

    this.elapsed = 0;
    this.started = false;
    this.completed = false;

    // Lưu giá trị from/to cho mỗi property
    for (const [key, toValue] of Object.entries(properties)) {
      this.properties[key] = {
        from: target[key],
        to: toValue
      };
    }
  }

  update(deltaTime) {
    if (this.completed) return;

    // Delay
    if (this.delay > 0) {
      this.delay -= deltaTime;
      return;
    }

    if (!this.started) {
      this.started = true;
      // Cập nhật "from" tại thời điểm bắt đầu thực
      for (const [key, prop] of Object.entries(this.properties)) {
        prop.from = this.target[key];
      }
    }

    this.elapsed += deltaTime;
    const rawT = Math.min(this.elapsed / this.duration, 1);
    const easedT = this.easing(rawT);

    // Cập nhật tất cả properties
    for (const [key, prop] of Object.entries(this.properties)) {
      this.target[key] = prop.from + (prop.to - prop.from) * easedT;
    }

    if (this.onUpdate) this.onUpdate(easedT);

    if (rawT >= 1) {
      this.completed = true;
      if (this.onComplete) this.onComplete();
    }
  }
}

// Tween Manager
class TweenManager {
  constructor() {
    this.tweens = [];
  }

  add(target, properties, options) {
    const tween = new Tween(target, properties, options);
    this.tweens.push(tween);
    return tween;
  }

  update(deltaTime) {
    for (let i = this.tweens.length - 1; i >= 0; i--) {
      this.tweens[i].update(deltaTime);
      if (this.tweens[i].completed) {
        this.tweens.splice(i, 1);
      }
    }
  }

  clear() {
    this.tweens = [];
  }
}

// === Sử dụng ===
const tweenMgr = new TweenManager();
const box = { x: 50, y: 200, width: 40, height: 40, opacity: 0 };

// Fade in + di chuyển
tweenMgr.add(box, { x: 400, y: 100, opacity: 1 }, {
  duration: 1.5,
  easing: Easing.easeOutCubic,
  onComplete: () => {
    // Chain: sau khi xong, tween tiếp
    tweenMgr.add(box, { x: 700, y: 300 }, {
      duration: 1,
      easing: Easing.easeInOutQuad
    });
  }
});

// Trong game loop:
function update(dt) {
  tweenMgr.update(dt);
}

function draw() {
  ctx.globalAlpha = box.opacity;
  ctx.fillStyle = '#8b5cf6';
  ctx.fillRect(box.x, box.y, box.width, box.height);
  ctx.globalAlpha = 1;
}

4. Bezier Curves cho Animation

Cubic Bezier là đường cong được xác định bởi 4 điểm điều khiển (P0, P1, P2, P3). CSS transition-timing-function: cubic-bezier(x1,y1,x2,y2) chính là dạng này. Trên Canvas, ta dùng Bezier cho cả animation timing và path animation.

bezier_curves.js
// Quadratic Bezier: 3 điểm điều khiển
function quadraticBezier(p0, p1, p2, t) {
  const mt = 1 - t;
  return {
    x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
    y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y
  };
}

// Cubic Bezier: 4 điểm điều khiển
function cubicBezier(p0, p1, p2, p3, t) {
  const mt = 1 - t;
  const mt2 = mt * mt;
  const t2 = t * t;
  return {
    x: mt2 * mt * p0.x + 3 * mt2 * t * p1.x +
       3 * mt * t2 * p2.x + t2 * t * p3.x,
    y: mt2 * mt * p0.y + 3 * mt2 * t * p1.y +
       3 * mt * t2 * p2.y + t2 * t * p3.y
  };
}

// Path animation: di chuyển object dọc theo đường Bezier
const pathPoints = {
  p0: { x: 50, y: 350 },
  p1: { x: 200, y: 50 },   // điểm điều khiển 1
  p2: { x: 500, y: 50 },   // điểm điều khiển 2
  p3: { x: 700, y: 350 }
};

let pathT = 0;

function animatePath(dt) {
  pathT += dt * 0.3; // tốc độ di chuyển
  if (pathT > 1) pathT = 0; // loop

  const pos = cubicBezier(
    pathPoints.p0, pathPoints.p1,
    pathPoints.p2, pathPoints.p3, pathT
  );

  // Vẽ đường path
  ctx.beginPath();
  ctx.moveTo(pathPoints.p0.x, pathPoints.p0.y);
  ctx.bezierCurveTo(
    pathPoints.p1.x, pathPoints.p1.y,
    pathPoints.p2.x, pathPoints.p2.y,
    pathPoints.p3.x, pathPoints.p3.y
  );
  ctx.strokeStyle = 'rgba(148, 163, 184, 0.3)';
  ctx.lineWidth = 2;
  ctx.stroke();

  // Vẽ object
  ctx.beginPath();
  ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
  ctx.fillStyle = '#f59e0b';
  ctx.fill();
}

Để dùng Cubic Bezier như CSS cubic-bezier(x1,y1,x2,y2) cho easing timing, ta cần giải ngược: cho t thời gian, tìm y (progress).

css_bezier_easing.js
// CSS cubic-bezier() tương đương — dùng Newton-Raphson
function cubicBezierEasing(x1, y1, x2, y2) {
  // Tính x(t) từ cubic bezier (P0=0,0 P3=1,1)
  function sampleCurveX(t) {
    return ((1 - 3 * x2 + 3 * x1) * t +
            (3 * x2 - 6 * x1)) * t + 3 * x1 * t;
  }
  function sampleCurveY(t) {
    return ((1 - 3 * y2 + 3 * y1) * t +
            (3 * y2 - 6 * y1)) * t + 3 * y1 * t;
  }
  function sampleCurveDerivX(t) {
    return (3 * (1 - 3 * x2 + 3 * x1)) * t * t +
           (2 * (3 * x2 - 6 * x1)) * t + 3 * x1;
  }

  // Newton-Raphson: tìm t sao cho x(t) = input
  function solveCurveX(x) {
    let t = x;
    for (let i = 0; i < 8; i++) {
      const residue = sampleCurveX(t) - x;
      if (Math.abs(residue) < 1e-7) return t;
      const d = sampleCurveDerivX(t);
      if (Math.abs(d) < 1e-7) break;
      t -= residue / d;
    }
    return t;
  }

  // Trả về easing function: input t(0-1) → output y(0-1)
  return function(t) {
    return sampleCurveY(solveCurveX(t));
  };
}

// CSS tương đương:
const easeCSS = cubicBezierEasing(0.25, 0.1, 0.25, 1.0);     // ease
const easeInOutCSS = cubicBezierEasing(0.42, 0, 0.58, 1.0);   // ease-in-out
const customCSS = cubicBezierEasing(0.68, -0.55, 0.27, 1.55); // custom overshoot

5. Spring Animation (Hooke's Law)

Spring (lò xo) animation tạo chuyển động tự nhiên nhất vì dựa trên vật lý thực. Thay vì định nghĩa durationeasing cố định, spring animation được điều khiển bởi stiffness (độ cứng), damping (ma sát), và mass (khối lượng). Kết quả: overshoot tự nhiên, settle mượt mà.

spring_animation.js
// Hooke's Law: F = -kx - dv
// k = stiffness (độ cứng lò xo)
// d = damping (hệ số ma sát/giảm chấn)
// x = displacement (khoảng cách từ vị trí cân bằng)
// v = velocity (vận tốc hiện tại)

class Spring {
  constructor(options = {}) {
    this.stiffness = options.stiffness || 180;  // k: lò xo cứng
    this.damping = options.damping || 12;       // d: giảm chấn
    this.mass = options.mass || 1;
    this.velocity = 0;
    this.current = options.from || 0;
    this.target = options.to || 1;
    this.settled = false;
    this.restThreshold = 0.001;
  }

  setTarget(value) {
    this.target = value;
    this.settled = false;
  }

  update(deltaTime) {
    if (this.settled) return this.current;

    // Lực lò xo: F = -k * (current - target)
    const displacement = this.current - this.target;
    const springForce = -this.stiffness * displacement;

    // Lực giảm chấn: F = -d * velocity
    const dampingForce = -this.damping * this.velocity;

    // Tổng lực → gia tốc: a = F/m
    const acceleration = (springForce + dampingForce) / this.mass;

    // Cập nhật velocity và position
    this.velocity += acceleration * deltaTime;
    this.current += this.velocity * deltaTime;

    // Kiểm tra đã ổn định chưa
    if (
      Math.abs(this.velocity) < this.restThreshold &&
      Math.abs(displacement) < this.restThreshold
    ) {
      this.current = this.target;
      this.velocity = 0;
      this.settled = true;
    }

    return this.current;
  }
}

// === Sử dụng ===
const springX = new Spring({ stiffness: 200, damping: 15, from: 50, to: 600 });
const springY = new Spring({ stiffness: 120, damping: 10, from: 300, to: 100 });

// Presets phổ biến:
const presets = {
  gentle:  { stiffness: 120, damping: 14 },
  wobbly:  { stiffness: 180, damping: 8 },   // nhiều overshoot
  stiff:   { stiffness: 300, damping: 20 },   // nhanh, ít overshoot
  slow:    { stiffness: 60,  damping: 12 },   // chậm, mượt
  molasses:{ stiffness: 80,  damping: 30 }    // rất nặng nề
};

function update(dt) {
  const x = springX.update(dt);
  const y = springY.update(dt);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  ctx.arc(x, y, 20, 0, Math.PI * 2);
  ctx.fillStyle = '#ec4899';
  ctx.fill();
}

// Click để đổi target → spring tự animate
canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect();
  springX.setTarget(e.clientX - rect.left);
  springY.setTarget(e.clientY - rect.top);
});

So sánh spring với easing: easing có duration cố định và kết thúc chính xác tại thời điểm định trước. Spring không có duration — nó kết thúc khi năng lượng tiêu hao hết. Overshoot là tự nhiên, không cần công thức đặc biệt. Thư viện tham khảo: react-spring, Framer Motion, Anime.js.

spring_multi.js
// Spring cho nhiều thuộc tính cùng lúc
class SpringAnimator {
  constructor(target, config = {}) {
    this.target = target;
    this.springs = {};
    this.config = { stiffness: 170, damping: 26, ...config };
  }

  to(properties) {
    for (const [key, value] of Object.entries(properties)) {
      if (!this.springs[key]) {
        this.springs[key] = new Spring({
          ...this.config,
          from: this.target[key],
          to: value
        });
      } else {
        this.springs[key].setTarget(value);
      }
    }
  }

  update(dt) {
    let allSettled = true;
    for (const [key, spring] of Object.entries(this.springs)) {
      this.target[key] = spring.update(dt);
      if (!spring.settled) allSettled = false;
    }
    return allSettled;
  }
}

// Ví dụ: animate scale + opacity khi hover
const card = { x: 200, y: 150, scale: 1, opacity: 0.8 };
const cardSpring = new SpringAnimator(card, { stiffness: 200, damping: 18 });

// Hover in
cardSpring.to({ scale: 1.1, opacity: 1 });

// Hover out
cardSpring.to({ scale: 1, opacity: 0.8 });

6. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: Linear Interpolation

lerp(100, 300, 0.75) trả về giá trị nào?

Trắc nghiệm 2: Easing Functions

Easing function nào tạo hiệu ứng "bắt đầu chậm, kết thúc nhanh" (tăng tốc dần)?

Trắc nghiệm 3: Spring Animation

Trong spring animation, thông số nào kiểm soát mức độ overshoot (vượt quá đích)?

Tải file code thực hành minh họa bài học

File tổng hợp các easing functions, tween engine, bezier curves và spring animation:

Tải về canvas_easing.js

Related Articles

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

Lesson 9: Sprite Animation & Spritesheets in Canvas Bài 9: Sprite Animation & Spritesheet trong Canvas Lesson 7: Animation Loop: requestAnimationFrame & Delta Time Bài 7: Animation Loop — requestAnimationFrame & Delta Time Back to Canvas Series Overview Quay lại Lộ trình Canvas Series