Podstawowy silnik fizyczny: piłki i linie

Kliknij w czarne pole, aby dodać piłki

Kilka lat temu napisałem eksperyment fizyczny przy użyciu świetnego silnika Matter.js. Dzisiaj spróbujemy stworzyć podobny mechanizm (co prawda uproszczony) bez żadnych zewnętrznych bibliotek.

Co robi: symuluje ruch piłek (kół) pod wpływem grawitacji, wykrywa kolizje koło-koło oraz koło-linia, separuje nakładające się obiekty i stosuje proste impulsy elastyczne do zmiany prędkości.

Czego NIE robi: ciągłej detekcji kolizji (może wystąpić przenikanie przy dużej prędkości), dynamiki rotacji i optymalizacji szerokofazowej. Są to poważne ograniczenia, ale rozwiązanie jest nadal ciekawe, biorąc pod uwagę, że to tylko około 100 linii kodu.

Główne idee: matematyka wektorowa, całkowanie Eulera, rzutowanie punktu na linię przy kolizjach oraz rozwiązywanie impulsów dla zmian prędkości. Zobacz referencje algorytmów dla rzutowania koło-linia i matematyki kolizji.

Jak zorganizowany jest kod

  1. Klasa Vec — implementuje wektory 2D (dodawanie, odejmowanie, skalowanie, iloczyn skalarny, długość, normalizacja). Cała geometria korzysta z tych operacji.
  2. Klasa Circle — przechowuje pozycję, prędkość, promień, masę; posiada metodę integrate() do nakładania grawitacji i przesuwania koła w każdej klatce.
  3. Klasa Line — przechowuje dwa punkty końcowe i oblicza najbliższy punkt na odcinku dla dowolnego punktu (używane do wykrywania nakładania się koła na linię).
  4. Funkcje kolizji — resolveCircleCircle separuje nakładające się koła i nakłada impulsy; resolveCircleLine rzutuje środek koła na odcinek, sprawdza odległość, separuje i odbija prędkość wzdłuż normalnej kolizji.
  5. Główna pętla — całkuje ciała, uruchamia parowe rozwiązywanie kolizji, a następnie rysuje. Używa ograniczonego dt dla stabilności.
  6. Konfiguracja sceny — tablica linii obracanych funkcjami trygonometrycznymi oraz funkcja dodająca piłkę w miejscu kliknięcia ekranu.

Jak działa integrate()

Cel: Funkcja integrate() przesuwa obiekty w czasie poprzez aktualizację ich prędkości i pozycji.

Co oznacza całkowanie

Silniki fizyczne aktualizują ruch w małych krokach czasowych. W każdej klatce:

Ta metoda nazywa się całkowaniem Eulera, najprostszą techniką całkowania numerycznego.

W kodzie

integrate(dt) {
  let gravity = new Vec(0, 300);
  this.vel = this.vel.add(gravity.mul(dt));
  this.pos = this.pos.add(this.vel.mul(dt));
}

Rozkład koncepcyjny

Jest to zgodne z prawami Newtona: przyspieszenie wpływa na prędkość, a prędkość na pozycję.

Jak działa rozwiązywanie kolizji

Twój silnik rozwiązuje dwa typy kolizji:

Oba podążają za tym samym trzystopniowym wzorcem.

Krok 1 — Wykrywanie nakładania się

Krok 2 — Obliczanie normalnej kolizji

Normalna to wektor jednostkowy skierowany od powierzchni w stronę obiektu.

Krok 3 — Korekta pozycji

Obiekty są odpychane od siebie, aby przestały się nakładać. Zapobiega to drżeniu lub zapadaniu się.

let penetration = a.r + b.r - d;
a.pos = a.pos.add(n.mul(-penetration * (a.invM / totalInv)));
b.pos = b.pos.add(n.mul(penetration * (b.invM / totalInv)));

Masa określa, jak bardzo każdy obiekt się przesuwa — lżejsze obiekty ruszają się bardziej.

Jak działają impulsy

Impulsy to natychmiastowe zmiany prędkości spowodowane kolizjami. To nie są siły; działają natychmiastowo.

Wzór na impuls (koncepcyjnie)

impulse = -(1 + restitution)
          * relativeVelocityAlongNormal
          / totalInverseMass

W kodzie

let rel = b.vel.sub(a.vel);
let vn = rel.dot(n);
if (vn > 0) return;

let e = 0.8;
let j = -(1 + e) * vn / totalInv;
let impulse = n.mul(j);

a.applyImpulse(impulse.mul(-1));
b.applyImpulse(impulse);

Co to robi

Rezultatem jest realistyczne odbijanie i ślizganie się.

Składanie wszystkiego w całość

Każda klatka symulacji:

  1. Integracja — zastosuj grawitację i przesuń obiekty.
  2. Detekcja kolizji — koło–koło i koło–linia.
  3. Rozwiązywanie kolizji — odseparuj obiekty i nałóż impulsy.
  4. Renderowanie — narysuj zaktualizowane pozycje.

Ta pętla tworzy płynny, wiarygodny ruch i interakcje.

Więcej samouczków JS:


Pełny kod:

// Vector utilities
class Vec {
    constructor(x=0, y=0) {
        this.x = x;
        this.y = y;
    }
    add(v) {
        return new Vec(this.x + v.x,this.y + v.y);
    }
    sub(v) {
        return new Vec(this.x - v.x,this.y - v.y);
    }
    mul(s) {
        return new Vec(this.x * s,this.y * s);
    }
    dot(v) {
        return this.x * v.x + this.y * v.y;
    }
    len() {
        return Math.hypot(this.x, this.y);
    }
    norm() {
        let l = this.len() || 1;
        return this.mul(1 / l);
    }
}

// Circle body
class Circle {
    constructor(x, y, r, m=1) {
        this.pos = new Vec(x,y);
        this.vel = new Vec(0,0);
        this.r = r;
        this.m = m;
        this.invM = 1 / m;
    }
    applyImpulse(j) {
        this.vel = this.vel.add(j.mul(this.invM));
    }
    integrate(dt) {
        let gravity = new Vec(0,300);
        this.vel = this.vel.add(gravity.mul(dt));
        this.pos = this.pos.add(this.vel.mul(dt));
    }
}

// Line segment
class Line {
    constructor(x1, y1, x2, y2) {
        this.a = new Vec(x1,y1);
        this.b = new Vec(x2,y2);
    }
    closestPoint(p) {
        let ab = this.b.sub(this.a);
        let t = p.sub(this.a).dot(ab) / ab.dot(ab);
        t = Math.max(0, Math.min(1, t));
        return this.a.add(ab.mul(t));
    }
}

// Collision and scene
function resolveCircleLine(circle, line) {
    let cp = line.closestPoint(circle.pos);
    let diff = circle.pos.sub(cp);
    let dist = diff.len();
    if (dist < circle.r) {
        let n = diff.norm();
        let penetration = circle.r - dist;
        circle.pos = circle.pos.add(n.mul(penetration + 0.01));
        let vn = circle.vel.dot(n);
        if (vn < 0) {
            let e = 0.8;
            circle.vel = circle.vel.sub(n.mul((1 + e) * vn));
        }
    }
}

function resolveCircleCircle(a, b) {
    let diff = b.pos.sub(a.pos);
    let d = diff.len();
    if (d === 0) {
        return;
    }
    if (d < a.r + b.r) {
        let n = diff.mul(1 / d);
        let penetration = a.r + b.r - d;
        let totalInv = a.invM + b.invM;
        a.pos = a.pos.add(n.mul(-penetration * (a.invM / totalInv)));
        b.pos = b.pos.add(n.mul(penetration * (b.invM / totalInv)));
        let rel = b.vel.sub(a.vel);
        let vn = rel.dot(n);
        if (vn > 0) {
            return;
        }
        let e = 0.8;
        let j = -(1 + e) * vn / totalInv;
        let impulse = n.mul(j);
        a.applyImpulse(impulse.mul(-1));
        b.applyImpulse(impulse);
    }
}

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let counter = 0;
let circles = [new Circle(250,150,20,1), new Circle(510,120,16,1), new Circle(170,120,10,1)];
let lines = [];
const lineCount = 16;

for (let i = 0; i < lineCount; i++) {
    lines.push(new Line(100,100,200,200));
}

function step(dt) {
    for (let c of circles) {
        c.integrate(dt);
    }
    for (let i = 0; i < circles.length; i++) {
        for (let j = i + 1; j < circles.length; j++) {
            resolveCircleCircle(circles[i], circles[j]);
        }
    }
    for (let c of circles) {
        for (let l of lines) {
            resolveCircleLine(c, l);
        }
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let l of lines) {
        ctx.strokeStyle = '#888';
        ctx.lineWidth = 4;
        ctx.beginPath();
        ctx.moveTo(l.a.x, l.a.y);
        ctx.lineTo(l.b.x, l.b.y);
        ctx.stroke();
    }
    for (let c of circles) {
        ctx.fillStyle = '#4af';
        ctx.beginPath();
        ctx.arc(c.pos.x, c.pos.y, c.r, 0, Math.PI * 2);
        ctx.fill();
    }
}

const armLength = 70;
const speed = 0.009;
let last = performance.now();
function loop(t) {
    let dt = Math.min(0.033, (t - last) / 1000);
    step(dt);
    draw();
    last = t;

    // Spinning crosses
    for (let i = 0; i < lineCount / 2; i++) {
        lines[i * 2].a.x = 100 * i + armLength * Math.sin(counter);
        lines[i * 2].a.y = 350 + armLength * Math.cos(counter);
        lines[i * 2].b.x = 100 * i - armLength * Math.sin(counter);
        lines[i * 2].b.y = 350 - armLength * Math.cos(counter);

        lines[i * 2 + 1].a.x = 100 * i - armLength * Math.cos(counter);
        lines[i * 2 + 1].a.y = 350 + armLength * Math.sin(counter);
        lines[i * 2 + 1].b.x = 100 * i + armLength * Math.cos(counter);
        lines[i * 2 + 1].b.y = 350 - armLength * Math.sin(counter);
    }

    counter = counter + speed;
    requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

window.onpointerdown = function(e) {
    circles.push(new Circle(e.offsetX,e.offsetY,20,1));
}