Створення фізичного рушія твердих тіл з нуля на 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);
}
}