Zum Inhalt springen
Michael Malura
570 Github Stars 5810 npm Downloads 53 Projekte

Rigid Bodies

Im Update Loop prallt ein Quadrat an den Rand und dreht um. Dafür reicht ein if. Sobald zwei bewegte Körper aufeinander treffen, hilft das nicht mehr: Wer bekommt wie viel Geschwindigkeit, wohin kippt die Kiste, und wieso bleibt ein Stapel überhaupt stehen?

Die Demo hat Kreise und Boxen, einen Boden, zwei Seitenwände und eine Rampe. Beim Start stehen schon eine Pyramide und ein Turm da, der Rest fällt noch herein.

Ein Klick auf freie Fläche erzeugt einen Körper, ein Zug an einem Körper zerrt ihn per Feder durch die Szene. Die Chips stellen Restitution, Schwerkraft und die Kontaktpunkte (orange) um, R räumt auf. Die Pyramide steht, bis der kleine Kreis auf ihrer Spitze landet. Der kommt mit rund 10 m/s an, danach liegt sie in Trümmern.

Unter der Demo läuft der Loop aus Fester Zeitschritt: Die Welt macht Schritte von genau 1/120 Sekunde aus einem Accumulator, bei 60 fps sind das zwei Schritte pro Frame. Die Zahl steht oben rechts in der Leiste.

Wo, wie tief, in welche Richtung

Ein Kontakt besteht aus einer Normalen, die von Körper A nach Körper B zeigt, einer Eindringtiefe und einem Punkt. Bei zwei Kreisen ist das billig:

const Vec2 d = b.pos - a.pos;
const float dist = length(d);
const float radii = a.radius + b.radius;
if (dist >= radii) return m;
m.normal = dist > 1e-6F ? d * (1.0F / dist) : Vec2{0.0F, 1.0F};
m.contacts[0].separation = dist - radii;

separation ist negativ, solange sich die Körper überlappen. Kreis gegen Box rechnet im Koordinatensystem der Box: Kreismittelpunkt auf die Box klemmen, der Abstand zu diesem Punkt ist der Abstand zur Box. Liegt der Mittelpunkt in der Box, geht der Kreis durch die nächste Fläche wieder raus.

Box gegen Box ist der Brocken. Nach dem Separating Axis Theorem überlappen zwei konvexe Formen nur, wenn sie auf jeder Kantennormalen überlappen, bei zwei Boxen sind das vier Achsen. Die Achse mit der kleinsten Überlappung wird zur Kontaktnormalen. Für die Punkte nimmt man die Kante der einen Box, die der Normalen am meisten entgegenzeigt, und schneidet sie an den Seitenebenen der anderen ab. Was hinter der Referenzfläche liegt, wird Kontakt, also höchstens zwei Punkte. Das Verfahren ist das von Box2D Lite, ich hab mich beim Aufbau daran gehalten. Die Boxen sind echte gedrehte Rechtecke, keine vereinfachte Variante.

Der Impuls

Am Kontaktpunkt zählt nur, wie schnell sich die beiden Körper entlang der Normalen aufeinander zu bewegen. Die Relativgeschwindigkeit am Punkt enthält auch die Drehung: vel + angVel x r, mit r als Abstand vom Schwerpunkt zum Kontakt. Der Impuls, der sie auf den gewünschten Wert bringt:

j = -(1 + e) * dot(vRel, n) / (1/mA + 1/mB + cross(rA, n)^2 / IA + cross(rB, n)^2 / IB)

Oben steht die Relativgeschwindigkeit, e ist die Restitution (0: der Stoß schluckt alles, 1: voll elastisch). Unten steht die effektive Masse. Weil auch die Trägheit I drinsteckt, schiebt ein Stoß weit neben dem Schwerpunkt die Kiste weniger weg, als er sie dreht. Im Code ist das der Kehrwert, einmal pro Kontakt berechnet:

c.massNormal = 1.0F / (a.invMass + b.invMass + a.invInertia * rn1 * rn1 + b.invInertia * rn2 * rn2);

Zwei gleich schwere Kreise mit e = 1 tauschen damit ihre Geschwindigkeiten, gegen eine statische Wand (invMass 0) wird die Geschwindigkeit gespiegelt. Beides sind Tests in test.cpp, von Hand gerechnet.

Zwei Dinge musste ich dazunehmen. Die Restitution greift erst ab 1 m/s Aufprallgeschwindigkeit, sonst würde jede ruhende Kiste hüpfen: Die Schwerkraft macht pro Schritt schon 0,08 m/s. Und die Überlappung baut der Solver nicht nur durch den Impuls ab. Dazu kommt eine kleine Zielgeschwindigkeit, 20 Prozent der Tiefe über 1 cm Toleranz (slop) pro Schritt, höchstens 4 m/s. Der Slop lässt Körper minimal eindringen, damit ein Kontakt nicht in jedem Schritt zwischen Berühren und Getrennt wechselt.

Reibung

Dieselbe Rechnung noch einmal, entlang der Tangente (Normale um 90 Grad gedreht). Gedeckelt wird sie durch den Normalimpuls mal Reibungskoeffizient:

const float maxFriction = friction * c.pn;
c.pt = std::clamp(oldPt + c.massTangent * -vt, -maxFriction, maxFriction);

Der Koeffizient ist fest 0,5. Eine Box, die mit 3 m/s über den Boden rutscht, bremst mit μ·g, etwa 4,9 m/s², und steht nach gut einer halben Sekunde.

Warum Stapel zittern

Die Kontakte hängen voneinander ab: Die unterste Kiste trägt alle darüber, jeder Impuls an einem Kontakt stört die Nachbarn. Der Solver korrigiert die Kontakte nacheinander (Sequential Impulses) und dreht die Runde mehrmals, hier achtmal pro Schritt. Das nähert sich der richtigen Lösung an, erreicht sie aber nicht. Warm Starting hilft enorm: Der Impuls, den ein Kontakt im letzten Schritt gebraucht hat, wird am Anfang des nächsten gleich wieder aufgebracht. Wiedererkannt wird der Kontakt über das Körperpaar und einen Punkt im Umkreis von 10 cm.

Ich habs mit dem Code der Demo nachgemessen, 10 Sekunden Simulation, Kisten von 1 x 1 m. Der Fünferturm sackt mit acht Iterationen und Warm Starting oben 1 cm ein (das ist der Slop) und bewegt sich danach nicht mehr. Ohne Warm Starting sind es 5 cm, und er zuckt noch mit 3 cm/s. Lasse ich zusätzlich die Positionskorrektur weg, fällt er in sich zusammen. Beim Zehnerturm bricht er mit einer einzelnen Iteration zusammen, mit zwei steht er, zuckt aber mit bis zu 6 cm/s, ab vier ist er ruhig. Ohne Warm Starting kippt der Zehnerturm auch bei acht Iterationen.

Was fehlt

Es wird nicht interpoliert, gezeichnet wird der letzte Schritt. Körper schlafen nie, das Aussortieren ruhender Körper spart in großen Szenen viel. Es gibt keine Continuous Collision Detection, sehr schnelle Körper können dünne Wände durchschlagen. Die Kollisionssuche prüft jedes Paar mit einem Hüllkreis, bei den höchstens 90 Körpern der Demo reicht das.

Den Code findest du im Repo unter rigid-bodies. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.

11.09.2026 aktualisiert 06.11.2026
Teilen X Reddit Hacker News LinkedIn E-Mail
Neue Beiträge abonnieren RSS JSON