Створення фізичного рушія з нуля
Без бібліотек. Лише JavaScript. Цей урок проводить вас через кожен шар мінімального рушія твердого тіла: інтегрування симплектичним методом Ейлера, виявлення зіткнень осьово-вирівняними обмежувальними прямокутниками (AABB), реакцію на зіткнення на основі імпульсів із пружністю та тертя. Результат: симуляція складання та підстрибування ящиків, яку ви створили самостійно.
- Базові класи, масиви JavaScript та Canvas 2D API
- Розуміння векторів (додавання, множення на скаляр, скалярний добуток)
- Попередні знання про фізичні рушії не потрібні
Структура даних тіла
Кожному фізичному тілу потрібні положення, швидкість, розмір, маса та
пружність (здатність відскакувати). Нескінченна маса
(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 }),
];