Створення фізичного рушія твердих тіл з нуля на 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;
}
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. Імпульсна реакція на зіткнення
Коли виявлено зіткнення, ми застосовуємо до обох тіл миттєві зміни швидкості (імпульси) так, щоб вони більше не проникали одне в одне, а їхня відносна швидкість уздовж нормалі зіткнення задовольняла коефіцієнт відновлення:
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:
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: зберігаємо накопичені імпульси з попереднього кадру
// → значно пришвидшує збіжність і стабільність стопок
8. Цикл підкроків та неперервне виявлення зіткнень
Швидкорухомі об'єкти можуть протунелювати крізь тонкі стіни за один часовий крок. Є два розв'язки:
- Підкроки: поділіть кожен кадр (1/60 с) на N підкроків по dt/N. Точніше, але коштує у N разів більше ресурсів CPU. Типово: 2–4 підкроки.
-
CCD (неперервне виявлення зіткнень): проведіть AABB
тіла через увесь часовий крок, знайдіть TOI (час удару),
проінтегруйте до TOI, розв'яжіть зіткнення, а потім проінтегруйте
залишок. Використовується в Cannon-es через
body.ccdSpeedThreshold.
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, не потрібна.