Урок · Фізика · Алгоритми · 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);
  }
}

Часті запитання

Чого я навчуся в цьому уроці?

Створіть фізичний рушій твердих тіл з нуля на JavaScript: широка фаза AABB, вузька фаза SAT, імпульсна реакція на зіткнення, кутова динаміка та розв'язання обмежень.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Тверді тіла та інтегрування, Широка фаза: дерева AABB, Вузька фаза: теорема про розділяльну вісь, Імпульсна реакція на зіткнення, Тертя, Кутова динаміка, Послідовні імпульсні обмеження, Цикл підкроків та CCD.

Скільки часу займає цей урок?

Цей урок займає приблизно 25 хв.

Які попередні знання потрібні?

Це урок рівня «Середній – Просунутий» — окрема попередня підготовка, крім базового JavaScript, не потрібна.