Dynamisches Schneiden einer konvexen Form

Die Fähigkeit, eine konvexe Form dynamisch in zwei separate Formen aufzuteilen, ist eine sehr wertvolle Fähigkeit oder ein Werkzeug, das Sie in Ihrem Arsenal von Gamedev einsetzen können. Diese Aufteilung ermöglicht fortgeschrittene Simulationstypen, wie z. B. binäre Raumpartitionen für Grafik oder Physik, dynamisch zerstörende Umgebungen (Sprödbruch!), Meeres- oder Wassersimulation, Kollisionsauflösung für Physik-Engines, binäre räumliche Partionierung und die Liste wird fortgesetzt. Ein gutes Beispiel ist das Spiel Fruit Ninja für Kinect.

Was bedeutet es genau, eine konvexe Form zu teilen? In zwei Dimensionen bezeichnen wir eine Form als a Polygon; In drei Dimensionen bezeichnen wir eine Form als a Polyeder. (Polyeder ist das Wort, das für mehr als ein Polyeder verwendet wird.)

Spitze: Im Allgemeinen vereinfachen konvexe Polygone und Polyeder viele Aspekte der Volumen- oder Maschenmanipulation und -verwaltung. Aufgrund dieser Vereinfachung nimmt der gesamte Artikel konvexe Polygone und konvexe Polyeder an. Konkave Formen stehen hier nicht zur Diskussion. Im Allgemeinen werden komplizierte konkave Formen als eine Sammlung von verbundenen konvexen Formen simuliert.


Voraussetzungen

Um die in diesem Artikel vorgestellten Ideen zu verstehen, benötigen Sie gute Kenntnisse der Programmiersprache und ein einfaches Verständnis des dot-Produkts.

Eine großartige Möglichkeit, Formen in zwei und drei Dimensionen zu teilen, ist die Verwendung der Sutherland-Hodgman-Clipping-Routine. Diese Routine ist recht einfach und sehr effizient und kann auch leicht erweitert werden, um der numerischen Robustheit Rechnung zu tragen. Wenn Sie mit dem Algorithmus nicht vertraut sind, lesen Sie meinen vorherigen Artikel zum Thema.

Ein Verständnis von Ebenen in zwei oder drei Dimensionen ist ebenfalls ein Muss. Es ist zu beachten, dass man sich eine zweidimensionale Ebene als Projektion einer dreidimensionalen Ebene in zwei Dimensionen vorstellen kann - oder mit anderen Worten eine Linie.

Bitte haben Sie Verständnis, dass eine Ebene auch als halber Raum gedacht werden kann. Die Berechnung der Entfernung oder des Schnittpunkts von Punkten zu halben Räumen ist eine erforderliche Voraussetzung: Informationen dazu finden Sie im letzten Abschnitt Erstellen einer benutzerdefinierten 2D-Physik-Engine: The Core Engine.


Demo-Quelle

Bitte beziehen Sie sich auf die Demo-Quelle (auch auf Bitbucket), die ich erstellt habe, während Sie dieses Tutorial durchlesen. Ich habe diese Demo zum Erstellen aller GIF-Bilder im Artikel verwendet. Der Quellcode ist in C ++ (und sollte plattformübergreifend kompatibel sein), ist jedoch so geschrieben, dass er problemlos in jede Programmiersprache portiert werden kann.


Dreieck teilen

Bevor wir das Problem des Aufteilens eines gesamten Polygons angehen, betrachten wir das Problem des Aufteilens eines Dreiecks durch eine Schnittebene. Dies wird die Grundlage des Verständnisses bilden, um zu einer robusten und verallgemeinerten Lösung überzugehen.

Das Schöne an der Formteilung ist, dass Algorithmen in 2D oft ohne großen Aufwand direkt in 3D erweitert werden können. Ich werde hier einen sehr einfachen Dreiecksaufteilungsalgorithmus für zwei und drei Dimensionen vorstellen.

Wenn ein Dreieck eine Teilungsebene schneidet, sollten drei neue Dreiecke generiert werden. Hier ist ein Bild, das ein Dreieck vor und nach dem Teilen entlang einer Ebene zeigt:

Bei einer Teilungsebene werden während des Schnittvorgangs drei neue Dreiecke ausgegeben. Lassen Sie uns etwas Code ins Freie werfen, vorausgesetzt die drei Eckpunkte eines Dreiecks sind a, b, c im Gegenuhrzeigersinn (CCW), und dass wir wissen, dass die Kante ab (Kante der Eckpunkte a bis b) hat die Teilungsebene geschnitten:

 // Schneide ein Dreieck gegen eine Ebene, wissend, dass // a nach b die Clipping-Ebene kreuzt // Referenz: Exaktes Bouyancy für Polyhedra von // Erin Catto in Game Programming Gems 6 void SliceTriangle (std :: vector & out, const Vec2 & a // erster Punkt auf Dreieck, CCW-Reihenfolge const Vec2 & b, // zweiter Punkt auf Dreieck, CCW-Reihenfolge const Vec2 & c, // dritter Punkt auf Dreieck, CCW-Reihenfolge f32 d1, // Abstand des Punktes a zur Teilungsebene f32 d2 , // Abstand des Punktes b zur Teilungsebene f32 d3 // Abstand des Punktes c zur Teilungsebene) // Berechne den Schnittpunkt von a nach b Vec2 ab = a + (d1 / (d1 - d2)) * (b - a); Dreieck Tri; if (d1 < 0.0f)  // b to c crosses the clipping plane if(d3 < 0.0f)  // Calculate intersection point from b to c Vec2 bc = b + (d2 / (d2 - d3)) * (c - b); tri.Set( b, bc, ab ); out.push_back( tri ); tri.Set( bc, c, a ); out.push_back( tri ); tri.Set( ab, bc, a ); out.push_back( tri );  // c to a crosses the clipping plane else  // Calculate intersection point from a to c Vec2 ac = a + (d1 / (d1 - d3)) * (c - a); tri.Set( a, ab, ac ); out.push_back( tri ); tri.Set( ab, b, c ); out.push_back( tri ); tri.Set( ac, ab, c ); out.push_back( tri );   else  // c to a crosses the clipping plane if(d3 < 0.0f)  // Calculate intersection point from a to c Vec2 ac = a + (d1 / (d1 - d3)) * (c - a); tri.Set( a, ab, ac ); out.push_back( tri ); tri.Set( ac, ab, b ); out.push_back( tri ); tri.Set( b, c, ac ); out.push_back( tri );  // b to c crosses the clipping plane else  // Calculate intersection point from b to c Vec2 bc = b + (d2 / (d2 - d3)) * (c - b); tri.Set( b, bc, ab ); out.push_back( tri ); tri.Set( a, ab, bc ); out.push_back( tri ); tri.Set( c, a, bc ); out.push_back( tri );   

Hoffentlich hat der obige Code Sie etwas erschreckt. Aber fürchte dich nicht; Wir berechnen hier nur einige Schnittpunkte, um zu wissen, wie drei neue Dreiecke erzeugt werden. Wenn man das vorherige Bild sorgfältig untersucht hatte, könnten die Positionen der Schnittpunkte offensichtlich sein: Sie sind dort, wo die gepunktete Linie auf die Teilungsebene trifft und wo die Kante liegt ab schneidet die Teilungsebene. Hier ist ein Diagramm zur Vereinfachung:

Aus diesem Diagramm ist leicht ersichtlich, dass die Ausgabedreiecke die Scheitelpunkte enthalten sollten a, ac, ab, ac, c, b, und ab, ac, b. (Aber nicht unbedingt in diesem exakten Format, zum Beispiel, a, b, c wäre das gleiche Dreieck wie c, b, a weil Scheitelpunkte einfach nach rechts verschoben wurden.)

Um zu bestimmen, welche Scheitelpunkte zu welchem ​​der drei neuen Dreiecke beitragen, müssen wir bestimmen, ob der Scheitelpunkt ist ein und Scheitelpunkt c über oder unter der Ebene liegen. Da gehen wir davon aus, dass der Rand ab Ist bekannt, sich zu schneiden, können wir implizit daraus ableiten b liegt auf der gegenüberliegenden Seite der Clipping-Ebene aus ein.

Wenn die Konvention eine negative Entfernung von einer Teilungsebene bedeutet durchdringend In der Ebene können wir ein Prädikat formulieren, um zu bestimmen, ob ein Punkt einen halben Raum schneidet: #define HitHalfspace (Abstand) ((Abstand) < 0.0f). Dieses Prädikat wird in jedem verwendet ob Anweisung, um zu prüfen, ob sich ein Punkt innerhalb des halben Bereichs der Schnittebene befindet.

Es gibt vier Fälle von Kombinationen von ein und b den halben Raum der Schnittebene treffen:

 a | T T F F ----------- b | T F T F

Da erfordert unsere Funktion beides ein und b auf gegenüberliegenden Seiten der Ebene liegen, werden zwei dieser Fälle fallen gelassen. Es bleibt nur noch zu sehen, auf welcher Seite c legt Von hier aus ist die Orientierung aller drei Ecken bekannt; Schnittpunkte und Ausgabescheitelpunkte können direkt berechnet werden.

Die erste Kante finden

Um die SliceTriangle () Funktion müssen wir eine Schnittkante eines Dreiecks finden. Die folgende Implementierung ist effizient und kann auf alle Dreiecke in der Simulation angewendet werden, um potenziell aufgeteilt zu werden:

 // Schneidet alle Dreiecke mit einem Dreiecksvektor. // Eine neue Liste mit Ausgabedreiecken wird generiert. Die alte // Liste der Dreiecke wird verworfen. // n - Die Normale der Schnittebene // d - Entfernung der Schnittebene vom Ursprung // Referenz: Exaktes Bouyancy für Polyhedra von // Erin Catto in Game Programming Gems 6 void SliceAllTriangles (const Vec2 & n, f32 d)  std :: vector out; für (uint32i = 0; i < g_tris.size( ); ++i)  // Grab a triangle from the global triangle list Triangle tri = g_tris[i]; // Compute distance of each triangle vertex to the clipping plane f32 d1 = Dot( tri.a, n ) - d; f32 d2 = Dot( tri.b, n ) - d; f32 d3 = Dot( tri.c, n ) - d; // a to b crosses the clipping plane if(d1 * d2 < 0.0f) SliceTriangle( out, tri.a, tri.b, tri.c, d1, d2, d3 ); // a to c crosses the clipping plane else if(d1 * d3 < 0.0f) SliceTriangle( out, tri.c, tri.a, tri.b, d3, d1, d2 ); // b to c crosses the clipping plane else if(d2 * d3 < 0.0f) SliceTriangle( out, tri.b, tri.c, tri.a, d2, d3, d1 ); // No clipping plane intersection; keep the whole triangle else out.push_back( tri );  g_tris = out; 

Nach dem Berechnen des vorzeichenbehafteten Abstands jedes Dreiecks kann die Multiplikation verwendet werden, um zu sehen, ob zwei unterschiedliche Punkte auf gegenüberliegenden Seiten einer Ebene liegen. Da die Abstände innerhalb eines Paares von einem positiven und einem negativen Fließkomma sind, muss das Produkt der beiden multiplizierten Werte negativ sein. Dies ermöglicht die Verwendung eines einfachen Prädikats, um zu sehen, ob zwei Punkte auf beiden Seiten einer Ebene liegen: #define OnOppositeSides (AbstandA, AbstandB) ((AbstandA) * (AbstandB) < 0.0f).

Einmal irgendein Wenn sich die Kante mit der Teilungsebene schneidet, werden die Dreieckscheitelpunkte umbenannt und verschoben sofort ging weiter ins Innere SliceTriangle Funktion. Auf diese Weise wird die erste gefundene Schnittkante in umbenannt ab.

So kann ein fertiges Produkt aussehen:


Dreieck entlang der Schnittebenen dynamisch durch Benutzerinteraktion aufteilen.

Das Aufteilen von Dreiecken auf diese Weise berücksichtigt jedes Dreieck unabhängig, und der hier vorgestellte Algorithmus erstreckt sich ohne zusätzliche Autorisierung von zwei bis drei Dimensionen. Diese Form des Beschneidens von Dreiecken ist ideal, wenn keine benachbarten Informationen zu Dreiecken erforderlich sind oder wenn abgeschnittene Dreiecke nicht irgendwo im Speicher gespeichert werden. Dies ist häufig der Fall bei der Berechnung von Schnittmengen, wie bei der Auftriebssimulation.

Das einzige Problem beim Trennen von Dreiecken besteht darin, dass keine Informationen über Dreiecke vorhanden sind benachbart zueinander. Wenn Sie die oben genannte GIF sorgfältig prüfen, können Sie feststellen, dass viele Dreiecke kollineare Scheitelpunkte aufweisen und als Ergebnis in ein einzelnes Dreieck zusammengefasst werden können, um effizient gerendert zu werden. Der nächste Abschnitt dieses Artikels behandelt dieses Problem mit einem anderen, komplexeren Algorithmus (der alle in diesem Abschnitt enthaltenen Taktiken verwendet)..


Sutherland-Hodgman

Nun zum abschließenden Thema. Unter der Annahme eines funktionierenden Verständnisses des Sutherland-Hodgman-Algorithmus ist es ziemlich einfach, dieses Verständnis zu erweitern, um eine Form mit einer Ebene zu teilen und Scheitelpunkte auszugeben beide Seiten des Flugzeugs. Numerische Robustheit kann (und sollte) ebenfalls berücksichtigt werden.

Schauen wir uns kurz die alten Sutherland-Hodgman-Clipping-Fälle an:

 // InFront = plane.Distance (Punkt)> 0.0f // Hinter = plane.Distance (Punkt) < 0.0f Vec2 p1, p2; ClipPlane plane; case p1 InFront and p2 InFront push p2 case p1 InFront and p2 Behind push intersection case p1 Behind and p2 InFront push intersection push p2

Diese drei Fälle funktionieren anständig, berücksichtigen jedoch nicht wirklich die Dicke der Teilungsebene. Infolgedessen können numerische Fehler eindringen, wenn sich Objekte bewegen, was zu einer geringen zeitlichen Rahmenkohärenz führt. Diese Art der numerischen Instabilität kann dazu führen, dass eine Ecke um ein Bild und nicht in einem anderen Bild beschnitten wird, und diese Art von Jittering kann visuell ziemlich hässlich sein oder für die physikalische Simulation inakzeptabel sein.

Ein weiterer Vorteil dieses Dickenebenen-Tests ist, dass Punkte, die sehr nahe an der Ebene liegen, tatsächlich als vorhanden betrachtet werden können auf die Ebene, die die abgeschnittene Geometrie etwas nützlicher macht. Es ist durchaus möglich, dass ein berechneter Schnittpunkt numerisch auf der falschen Seite einer Ebene liegt! Dicke Flugzeuge vermeiden solche seltsamen Probleme.

Durch die Verwendung dicker Ebenen für Kreuzungstests kann ein neuer Falltyp hinzugefügt werden: Punktverlegung direkt auf in einem Flugzeug.

Sutherland-Hodgman sollte so modifiziert werden (mit einem Fließkommawert) EPSILON dicke Flugzeuge berücksichtigen):

// InFront = plane.Distance (Punkt)> EPSILON // Hinter = plane.Distance (Punkt) < -EPSILON // OnPlane = !InFront( dist ) && !Behind( dist ) Vec2 p1, p2; ClipPlane plane; case p1 InFront and p2 InFront push p2 case p1 InFront and p2 Behind push intersection case p1 Behind and p2 InFront push intersection push p2 case any p1 and p2 OnPlane push p2

Diese Form von Sutherland-Hodgman gibt jedoch nur Scheitelpunkte aus eine Seite der Spaltungsebene. Diese fünf Fälle können leicht auf neun erweitert werden, um Scheitelpunkte auf beiden Seiten einer Teilungsebene auszugeben:

// InFront = plane.Distance (Punkt)> EPSILON // Hinter = plane.Distance (Punkt) < -EPSILON // OnPlane = !InFront( dist ) && !Behind( dist ) Vec2 p1, p2; Poly front, back; ClipPlane plane; case p1 InFront and p2 InFront front.push( p2 ) case p1 OnPlane and p2 InFront front.push( p2 ) case p1 Behind and p2 InFront front.push( intersection ) front.push( p2 ) back.push( intersection ) case p1 InFront and p2 OnPlane front.push( p2 ) case p1 OnPlane and p2 OnPlane front.push( p2 ) case p1 Behind and p2 OnPlane front.push( p2 ) back.push( p2 ) case p1 InFront and p2 Behind front.push( intersection ) back.push( intersection ) back.push( p2 ) case p1 OnPlane and p2 Behind back.push( p1 ) back.push( p2 ) case p1 Behind and p2 Behind back.push( p2 )

Eine Implementierung dieser neun Fälle könnte folgendermaßen aussehen (abgeleitet von Ericsons Echtzeit-Kollisionserkennung):

// Teilt ein Polygon entlang einer Teilungsebene mithilfe eines Ausschnittsalgorithmus in zwei Hälften // Aufruf des Sutherland-Hodgman-Ausschnitts // Ressource: Seite 367 von Ericson (Echtzeit-Kollisionserkennung) void SutherlandHodgman (const Vec2 & n, f32 d, const Poly *) poly, std :: vector * out) Poly frontPoly; Poly backPoly; uint32 s = poly -> vertices.size (); Vec2 a = Poly → Vertices [s - 1]; f32 da = Punkt (n, a) - d; für (uint32i = 0; i < s; ++i)  Vec2 b = poly->Scheitelpunkte [i]; f32 db = Punkt (n, b) - d; if (InFront (b)) if (Hinter (a)) Vec2 i = Intersect (b, a, db, da); frontPoly.vertices.push_back (i); backPoly.vertices.push_back (i);  frontPoly.vertices.push_back (b);  else if (Behind (b)) if (InFront (a)) Vec2 i = Überschneidung (a, b, da, db); frontPoly.vertices.push_back (i); backPoly.vertices.push_back (i);  else if (On (a)) backPoly.vertices.push_back (a); backPoly.vertices.push_back (b);  else frontPoly.vertices.push_back (b); if (On (a)) backPoly.vertices.push_back (b);  a = b; da = db;  if (frontPoly.vertices.size ()) out-> push_back (frontPoly); if (backPoly.vertices.size ()) out-> push_back (backPoly); 

Hier ist ein Beispiel für Sutherland-Hodgman in Aktion:


Dynamisches Teilen eines Polygons über Sutherland-Hodgman durch Benutzerinteraktion. Polygone werden als Dreieckfächer trianguliert.

Es ist erwähnenswert, dass die endgültigen Polygone als Scheitelliste mit dem Format des Dreiecksfächers dargestellt werden können.

Numerische Robustheit

Es gibt ein Problem, das Sie beachten sollten: beim Berechnen eines Schnittpunkts von ab und eine Spaltungsebene, an der dieser Punkt leidet numerische Quantisierung. Dies bedeutet, dass jeder Schnittwert eine Annäherung an den tatsächlichen Schnittwert ist. Es bedeutet auch, dass der Schnittpunkt ba ist numerisch nicht gleich; Eine kleine numerische Abweichung führt tatsächlich zu zwei verschiedenen Werten!

Beispiel eines sichtbaren Risses zwischen Dreiecken als Folge eines inkonsistenten Ausschnitts (Bild inspiriert von Ericsons Real-Time Collision Detection-Buch).

Eine naive Clipping-Routine kann einen großen Fehler bei der blinden Berechnung von Schnittpunkten machen, was zu T-Kreuzungen oder anderen geometrischen Lücken führen kann. Um ein solches Problem zu vermeiden, muss eine konsistente Schnittausrichtung verwendet werden. Nach der Konvention sollten Punkte von einer Seite einer Ebene zur anderen geschnitten werden. Diese strikte Schnittreihenfolge stellt sicher, dass derselbe Schnittpunkt berechnet wird, und schließt mögliche Lücken in der Geometrie aus (wie in der Abbildung oben dargestellt, wird rechts ein konsistentes Abschneidergebnis angezeigt)..


UV-Koordinaten

Um Texturen tatsächlich über geteilte Formen zu rendern (z. B. beim Aufteilen von Sprites), müssen Sie die UV-Koordinaten verstehen. Eine vollständige Besprechung der UV-Koordinaten und der Texturzuordnung geht weit über den Rahmen dieses Artikels hinaus. Wenn Sie dieses Wissen jedoch bereits kennen, sollten Schnittpunkte leicht in den UV-Raum umgewandelt werden können.

Bitte haben Sie Verständnis dafür, dass eine Transformation vom Weltraum zum UV-Raum eine Änderung der Basentransformation erfordert. Ich lasse UV-Transformationen als Übung für den Leser!


Fazit

In diesem Tutorial haben wir uns mit einfachen linearen Algebra-Techniken befasst, um das Problem der dynamischen Teilung generischer Formen zu lösen. Ich habe auch einige numerische Robustheitsprobleme angesprochen. Sie sollten jetzt in der Lage sein, Ihren eigenen Shape-Splitting-Code zu implementieren oder die Demonstrationsquelle zu verwenden, um viele fortgeschrittene und interessante Effekte oder Funktionen für die allgemeine Spieleprogrammierung zu erzielen.

Verweise

  • Vorschaubild: Eine modifizierte Birne von Edward Boatman aus dem Noun-Projekt