Урок
⏱️ ~60 хвилин 🎓 Середній рівень 🛠️ JavaScript · Canvas 2D · Фізика

Створення фізичного рушія з нуля

Без бібліотек. Лише JavaScript. Цей урок проводить вас через кожен шар мінімального рушія твердого тіла: інтегрування симплектичним методом Ейлера, виявлення зіткнень осьово-вирівняними обмежувальними прямокутниками (AABB), реакцію на зіткнення на основі імпульсів із пружністю та тертя. Результат: симуляція складання та підстрибування ящиків, яку ви створили самостійно.

Передумови

Структура даних тіла

Кожному фізичному тілу потрібні положення, швидкість, розмір, маса та пружність (здатність відскакувати). Нескінченна маса (invMass = 0) використовується для статичних стін:

class Body {
  constructor({ x, y, w, h, mass = 1, restitution = 0.5, isStatic = false }) {
    this.x = x;   this.y = y;   // положення центра
    this.w = w;   this.h = h;   // половинні розміри (AABB)
    this.vx = 0;  this.vy = 0;  // швидкість
    this.restitution = restitution;
    this.invMass = isStatic ? 0 : 1 / mass;
  }
}

Інтегрування симплектичним методом Ейлера

Симплектичний Ейлер спершу оновлює швидкість, а потім — положення з новою швидкістю. Це зберігає енергію краще за стандартний Ейлер і є стандартом для ігрової фізики:

const GRAVITY = 980; // px/с²

function integrate(body, dt) {
  if (body.invMass === 0) return; // статичне тіло — пропускаємо

  // Застосовуємо гравітацію (сила = маса * g → прискорення = g)
  body.vy += GRAVITY * dt;   // спочатку швидкість

  // Потім оновлюємо положення з новою швидкістю
  body.x += body.vx * dt;
  body.y += body.vy * dt;
}

Чому не стандартний Ейлер (pos += vel * dt; vel += accel * dt)? Стандартний Ейлер з часом набирає енергію — м'яч, що підстрибує, з кожним відскоком підстрибує вище. Симплектичний Ейлер змінює порядок і значно краще зберігає енергію.

Виявлення зіткнень AABB

Два осьово-вирівняні прямокутники перетинаються тоді й лише тоді, коли вони перетинаються по обох осях. Глибина проникнення по кожній осі підказує, наскільки їх потрібно розштовхати:

function detectAABB(a, b) {
  // Перекриття по осі x
  const dx = b.x - a.x;
  const overlapX = (a.w + b.w) - Math.abs(dx);
  if (overlapX <= 0) return null; // немає зіткнення

  // Перекриття по осі y
  const dy = b.y - a.y;
  const overlapY = (a.h + b.h) - Math.abs(dy);
  if (overlapY <= 0) return null; // немає зіткнення

  // Вісь мінімального розділення — виштовхуємо вздовж меншого перекриття
  let nx, ny, depth;
  if (overlapX < overlapY) {
    nx = dx < 0 ? -1 : 1;
    ny = 0;
    depth = overlapX;
  } else {
    nx = 0;
    ny = dy < 0 ? -1 : 1;
    depth = overlapY;
  }
  return { nx, ny, depth }; // нормаль зіткнення + проникнення
}

Реакція на зіткнення на основі імпульсів

Імпульс — це миттєва зміна кількості руху (J = Δ(mv)). Маючи нормаль контакту, ми обчислюємо величину імпульсу, що точно запобігає проникненню та додає бажаний відскок:

function resolveCollision(a, b, contact) {
  const { nx, ny, depth } = contact;

  // 1. Корекція положення — розштовхуємо тіла
  const totalInvMass = a.invMass + b.invMass;
  if (totalInvMass === 0) return; // обидва статичні
  const correction = depth / totalInvMass * 0.8; // 0.8 = уникнення "люфту"
  a.x -= nx * correction * a.invMass;
  a.y -= ny * correction * a.invMass;
  b.x += nx * correction * b.invMass;
  b.y += ny * correction * b.invMass;

  // 2. Складова швидкості вздовж нормалі
  const relVn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
  if (relVn > 0) return; // тіла вже розходяться — імпульс не потрібен

  // 3. Коефіцієнт пружності (об'єднаний)
  const e = Math.min(a.restitution, b.restitution);

  // 4. Скаляр імпульсу: j = -(1+e) * relVn / (1/mA + 1/mB)
  const j = -(1 + e) * relVn / totalInvMass;

  // 5. Застосовуємо імпульс
  a.vx -= j * nx * a.invMass;
  a.vy -= j * ny * a.invMass;
  b.vx += j * nx * b.invMass;
  b.vy += j * ny * b.invMass;
}

Пружність (коефіцієнт відновлення) керує здатністю відскакувати: 0 = абсолютно непружне (без відскоку), 1 = абсолютно пружне (повний відскок). Справжня гума має ~0.8, сталеві кульки підшипників ~0.95.

Тертя

Після нормального імпульсу застосуйте тангенціальний імпульс (тертя), щоб сповільнити ковзання:

function applyFriction(a, b, contact, j) {
  const { nx, ny } = contact;
  // Тангенс = перпендикуляр до нормалі
  const tx = -ny, ty = nx;

  const relVt = (b.vx - a.vx) * tx + (b.vy - a.vy) * ty;
  const totalInvMass = a.invMass + b.invMass;
  if (totalInvMass === 0) return;

  const mu = 0.3; // коефіцієнт тертя
  let jt = -relVt / totalInvMass;

  // Обмежуємо конусом тертя Кулона: |jt| ≤ μ * |j|
  jt = Math.max(-mu * Math.abs(j), Math.min(mu * Math.abs(j), jt));

  a.vx -= jt * tx * a.invMass;
  a.vy -= jt * ty * a.invMass;
  b.vx += jt * tx * b.invMass;
  b.vy += jt * ty * b.invMass;
}

Цикл із фіксованим часовим кроком

const FIXED_DT = 1 / 120;
let accumulator = 0;
let prevTime = performance.now();

function tick(now) {
  requestAnimationFrame(tick);
  const elapsed = Math.min((now - prevTime) / 1000, 0.05);
  prevTime = now;
  accumulator += elapsed;

  while (accumulator >= FIXED_DT) {
    // Крок фізики
    bodies.forEach(b => integrate(b, FIXED_DT));

    // Широка фаза + вузька фаза зіткнень
    for (let i = 0; i < bodies.length; i++) {
      for (let j = i + 1; j < bodies.length; j++) {
        const c = detectAABB(bodies[i], bodies[j]);
        if (c) {
          const jImpulse = computeImpulseMagnitude(bodies[i], bodies[j], c);
          resolveCollision(bodies[i], bodies[j], c);
          applyFriction(bodies[i], bodies[j], c, jImpulse);
        }
      }
    }
    accumulator -= FIXED_DT;
  }

  render();
}

requestAnimationFrame(tick);

Рендеринг та повна демонстрація

Мінімальний рендерер на canvas2D для візуалізації ящиків:

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const b of bodies) {
    ctx.fillStyle = b.invMass === 0 ? '#334155' : '#3b82f6';
    ctx.strokeStyle = '#60a5fa';
    ctx.lineWidth = 1;
    ctx.fillRect(b.x - b.w, b.y - b.h, b.w * 2, b.h * 2);
    ctx.strokeRect(b.x - b.w, b.y - b.h, b.w * 2, b.h * 2);
  }
}

// Налаштування сцени
const bodies = [
  // Підлога (статична)
  new Body({ x: 400, y: 580, w: 400, h: 20, isStatic: true }),
  // Ліва/права стіни
  new Body({ x: 10,  y: 300, w: 10,  h: 300, isStatic: true }),
  new Body({ x: 790, y: 300, w: 10,  h: 300, isStatic: true }),
  // Динамічні ящики
  new Body({ x: 400, y: 100, w: 25, h: 25, mass: 1,   restitution: 0.6 }),
  new Body({ x: 390, y: 200, w: 30, h: 20, mass: 2,   restitution: 0.3 }),
  new Body({ x: 410, y: 300, w: 20, h: 30, mass: 0.5, restitution: 0.8 }),
];

Продовжуйте навчання

🛠

Експериментуйте в Пісочниці

Пишіть, запускайте та налаштовуйте фізику Three.js + Cannon-es прямо у своєму браузері — без жодного налаштування.

Відкрити Пісочницю → Переглянути симуляцію ↗