Урок · Фізика · Алгоритми · JavaScript
📅 Березень 2026 ⏱ ≈ 25 хв 🎯 Середній – Просунутий

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

Фізичні рушії на кшталт Cannon-es і Rapier.js побудовані на невеликому наборі добре вивчених алгоритмів. Розуміння їх з перших принципів робить вас кращим розробником симуляцій і допомагає налагоджувати загадкове тремтіння, тунелювання та нестабільність стопок, з якими рано чи пізно стикається кожна фізична гра.

1. Тверді тіла та інтегрування

Тверде тіло зберігає положення, орієнтацію, лінійну швидкість, кутову швидкість і масу. Ми просуваємо симуляцію за допомогою напівнеявного інтегрування Ейлера (симплектичного Ейлера) — стабільнішого за явний Ейлер для коливальних систем:

class Body {
  constructor(mass, x, y) {
    this.pos  = { x, y };
    this.vel  = { x: 0, y: 0 };
    this.force = { x: 0, y: 0 };
    this.invMass = mass > 0 ? 1 / mass : 0; // 0 = статичне тіло
    this.angle  = 0;
    this.omega  = 0; // кутова швидкість [рад/с]
    this.torque = 0;
    this.invI   = mass > 0 ? 12 / (mass * 1) : 0; // I=m·L²/12 для одиничного квадрата
  }

  integrate(dt) {
    // Напівнеявний Ейлер: спершу оновлюємо швидкість, потім положення
    this.vel.x += this.invMass * this.force.x * dt;
    this.vel.y += this.invMass * this.force.y * dt;
    this.pos.x += this.vel.x * dt;
    this.pos.y += this.vel.y * dt;
    this.omega  += this.invI * this.torque * dt;
    this.angle  += this.omega * dt;
    this.force.x = this.force.y = this.torque = 0; // скидаємо сили
  }
}

2. Широка фаза: тест перекриття AABB

Перевірка кожної пари з N тіл має складність O(N²). Широка фаза швидко відсіює пари, що не перекриваються, за допомогою вирівняних за осями обмежувальних прямокутників (AABB):

function aabbOverlap(a, b) {
  // AABB: {minX, maxX, minY, maxY}
  return a.maxX >= b.minX && b.maxX >= a.minX
      && a.maxY >= b.minY && b.maxY >= a.minY;
}

// Широка фаза sweep-and-prune: сортуємо за minX, проходимо праворуч
function broadPhase(bodies) {
  bodies.sort((a, b) => a.aabb.minX - b.aabb.minX);
  const pairs = [];
  for (let i = 0; i < bodies.length; i++)
    for (let j = i + 1; j < bodies.length; j++) {
      if (bodies[j].aabb.minX > bodies[i].aabb.maxX) break; // відсортовано → більше перекриттів немає
      if (aabbOverlap(bodies[i].aabb, bodies[j].aabb))
        pairs.push([bodies[i], bodies[j]]);
    }
  return pairs;
}
Динамічне BVH: для великих сцен (1000+ тіл) побудуйте дерево ієрархії обмежувальних об'ємів (BVH). Вставляйте тіла знизу вгору, користуючись евристикою площі поверхні. І Cannon-es, і Rapier використовують динамічні дерева AABB (DbvtBroadphase).

3. Вузька фаза: теорема про розділяльну вісь (SAT)

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

// Повертає null, якщо зіткнення немає, або {normal, depth} за наявності
function satCollision(bodyA, bodyB) {
  const axes = [...getFaceNormals(bodyA), ...getFaceNormals(bodyB)];
  let minDepth = Infinity, minAxis = null;

  for (const axis of axes) {
    const [minA, maxA] = project(bodyA.vertices, axis);
    const [minB, maxB] = project(bodyB.vertices, axis);
    const overlap = Math.min(maxA, maxB) - Math.max(minA, minB);
    if (overlap <= 0) return null; // знайдено розділяльну вісь → зіткнення немає
    if (overlap < minDepth) { minDepth = overlap; minAxis = axis; }
  }
  return { normal: minAxis, depth: minDepth };
}

function project(vertices, axis) {
  const dots = vertices.map(v => v.x * axis.x + v.y * axis.y);
  return [Math.min(...dots), Math.max(...dots)];
}

4. Імпульсна реакція на зіткнення

Коли виявлено зіткнення, ми застосовуємо до обох тіл миттєві зміни швидкості (імпульси) так, щоб вони більше не проникали одне в одне, а їхня відносна швидкість уздовж нормалі зіткнення задовольняла коефіцієнт відновлення:

Відносна швидкість у точці контакту: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A) Нормальна складова: v_n = v_rel · n̂ Скаляр імпульсу (без тертя): j = −(1 + e) · v_n ───────────────────────────────────────────────────── invMassA + invMassB + (r_A × n̂)²·invIA + (r_B × n̂)²·invIB Застосовуємо: v_A −= j · invMassA · n̂ v_B += j · invMassB · n̂ ω_A −= j · invIA · (r_A × n̂) ω_B += j · invIB · (r_B × n̂)
function resolveCollision(a, b, contact) {
  const { normal: n, depth, pointA: rA, pointB: rB } = contact;
  const e = 0.5; // коефіцієнт відновлення

  const rAxn = rA.x * n.y - rA.y * n.x; // векторний добуток у 2D
  const rBxn = rB.x * n.y - rB.y * n.x;

  const vRelN =
    (b.vel.x - a.vel.x) * n.x + (b.vel.y - a.vel.y) * n.y
    + b.omega * rBxn - a.omega * rAxn;

  if (vRelN > 0) return; // тіла вже віддаляються

  const denom = a.invMass + b.invMass + rAxn**2 * a.invI + rBxn**2 * b.invI;
  const j = -(1 + e) * vRelN / denom;

  a.vel.x -= j * a.invMass * n.x;
  a.vel.y -= j * a.invMass * n.y;
  b.vel.x += j * b.invMass * n.x;
  b.vel.y += j * b.invMass * n.y;
  a.omega -= j * a.invI * rAxn;
  b.omega += j * b.invI * rBxn;

  // Позиційна корекція (стабілізація Баумгарте) — запобігаємо провалюванню
  const correction = Math.max(depth - 0.01, 0) * 0.2 / (a.invMass + b.invMass);
  a.pos.x -= a.invMass * correction * n.x;
  a.pos.y -= a.invMass * correction * n.y;
  b.pos.x += b.invMass * correction * n.x;
  b.pos.y += b.invMass * correction * n.y;
}

5. Тертя

Після застосування нормального імпульсу застосуйте дотичний імпульс, обмежений конусом тертя (закон Кулона: |j_t| ≤ μ · |j_n|):

function resolveFriction(a, b, contact, jNormal) {
  const mu = 0.4; // коефіцієнт тертя
  const { normal: n, pointA: rA, pointB: rB } = contact;
  const t = { x: -n.y, y: n.x }; // дотична у 2D = поворот нормалі на 90°

  const rAxT = rA.x * t.y - rA.y * t.x;
  const rBxT = rB.x * t.y - rB.y * t.x;
  const vRelT =
    (b.vel.x - a.vel.x) * t.x + (b.vel.y - a.vel.y) * t.y
    + b.omega * rBxT - a.omega * rAxT;

  const denom = a.invMass + b.invMass + rAxT**2*a.invI + rBxT**2*b.invI;
  let   jt = -vRelT / denom;

  // Обмежуємо конусом тертя
  jt = Math.max(-mu * jNormal, Math.min(mu * jNormal, jt));

  a.vel.x -= jt * a.invMass * t.x;
  a.vel.y -= jt * a.invMass * t.y;
  b.vel.x += jt * b.invMass * t.x;
  b.vel.y += jt * b.invMass * t.y;
  a.omega -= jt * a.invI * rAxT;
  b.omega += jt * b.invI * rBxT;
}

6. Кутова динаміка — момент інерції

Момент інерції I залежить від форми. Типові значення у 2D для тіла масою m:

Прямокутник (w×h): I = m(w² + h²) / 12 Коло (радіус r): I = m·r² / 2 Тонкий стрижень (довжина L, відносно центра): I = m·L² / 12 Порожнє кільце (внутрішній r₁, зовнішній r₂): I = m(r₁² + r₂²) / 2 У 3D I стає тензором інерції 3×3. Для паралелепіпеда (lx, ly, lz) діагональний тензор: Ixx = m(ly² + lz²)/12 Iyy = m(lx² + lz²)/12 Izz = m(lx² + ly²)/12

7. Послідовні імпульсні обмеження (PGS)

Складання коробок у стопку вимагає одночасного розв'язання кількох контактів. Метод проєкованого Гаусса-Зейделя (PGS) багаторазово ітерує по всіх контактах, застосовуючи невеликі імпульси на кожному проході, аж до збіжності:

function solveContacts(contacts, iterations = 10) {
  for (let iter = 0; iter < iterations; iter++) {
    for (const contact of contacts) {
      resolveCollision(contact.a, contact.b, contact);
      resolveFriction(contact.a, contact.b, contact, contact.jNormal);
    }
  }
}

// Warm starting: зберігаємо накопичені імпульси з попереднього кадру
// → значно пришвидшує збіжність і стабільність стопок
Warm starting: збережіть сумарний імпульс, застосований до кожного контакту в попередньому кадрі. У наступному кадрі застосуйте цей імпульс заздалегідь, перед ітераціями. Це зменшує потрібну кількість ітерацій для стабільних стопок з ~40 до ~8.

8. Цикл підкроків та неперервне виявлення зіткнень

Швидкорухомі об'єкти можуть протунелювати крізь тонкі стіни за один часовий крок. Є два розв'язки:

function update(dt) {
  const SUBSTEPS = 4;
  const subDt = dt / SUBSTEPS;
  for (let s = 0; s < SUBSTEPS; s++) {
    for (const body of bodies) body.applyForce({x:0, y:-9.8 * (1/body.invMass)});
    const pairs = broadPhase(bodies);
    const contacts = pairs.flatMap(([a,b]) => satCollision(a,b) ? [{a,b,...satCollision(a,b)}] : []);
    solveContacts(contacts);
    for (const body of bodies) body.integrate(subDt);
  }
}