Meine Werkzeuge
Namensräume
Varianten

DrawPrimitives Grundlagen

Aus indiedev
Wechseln zu: Navigation, Suche
Tutorial
DrawPrimitives Grundlagen
Autor Roland "Glatzemann" Rosenkranz
Programmier­sprache C#
Kategorie XNA
Diskussion Thread im Forum
Lizenz indiedev article license


Im Wiki-Artikel-Wunsch-Forum kam vor kurzem der Wunsch auf, einen Artikel über die unterschiedlichen DrawPrimitives-Varianten und deren Einsatzgebiet zu schreiben. Da ich solch einen Artikel schon immer geplant hatte möchte ich nun die Gelegenheit nutzen und diesen Wunsch erfüllen.

Zunächst eine kleine Übersicht, die Auskunft darüber gibt, welche Methoden ich hier besprechen möchte:

  1. DrawPrimitives [1]
  2. DrawIndexedPrimitives [2]
  3. DrawUserPrimitives [3]
  4. DrawUserIndexedPrimitives [4]
  5. DrawInstancedPrimitives [5]

Dies sind alles Methoden des GraphicsDevice von XNA. Alle sehen ähnlich aus und haben ähnliche Parameter, unterscheiden sich im Detail aber teils erheblich.

Hinweis
Hinweis

DrawInstancedPrimitives ist auf dem Windows Phone nicht verfügbar, da Custom Shader notwendig sind um instanzierte Primitive zu rendern. Custom Shader sind mit XNA auf dem Windows Phone jedoch aktuell nicht verfügbar.


Inhaltsverzeichnis

Gemeinsamkeiten

Alle der aufgeführten Methoden haben gemeinsam, dass sie Primitive rendern. Dies erfolgt selbstverständlich beschleunigt durch die Grafikkarte. Alle diese Methoden verwenden dazu einen Vertex Buffer und alle verwenden Vertex- und Pixel Shader zur eigentlichen Darstellung. Alle Methoden benötigen auch einen PrimitiveType.

Es ist grundsätzlich immer so, daß die Vertices, die die Grafikkarte rendern soll in einem Vertex Buffer abgelegt sind. Im verlinkten Artikel wird dieser ausführlich beschrieben, daher möchte ich dies hier nicht wiederholen. Teilweise unterscheiden sich die Draw-Methoden jedoch darin, wie die Daten im Vertex Buffer interpretiert werden. Dazu kommen wir aber im weiteren Verlauf noch.

Eine weitere Gemeinsamkeit ist der PrimitiveType [6]. Jede der Draw-Methoden erwartet diesen als Parameter und er beschreibt, was für Primitive aus den Vertices im Vertex Buffer erzeugt werden sollen. Dazu stehen bei XNA 4.0 vier unterschiedliche Arten zur Verfügung:

  • TriangleList
  • TriangleStrip
  • LineList
  • LineStrip

TriangleList

Die TriangleList ist der Standard und beschreibt eine Liste von Dreiecken. Jeweils drei aufeinanderfolgende Vertices aus dem Vertex Buffer bilden dabei ein Dreieck. Das Backface Culling wird dabei durch den eingestellten CullMode beeinflusst.

Dreieck in einer TriangleList

Die Berechnung der Anzahl der Primitive in einem Vertex Buffer erfolgt schlicht und einfach durch die Multiplikation der Vertex Anzahl durch 3, da jedes Dreieck drei Vertices enthält.

TriangleStrip

Der nächste PrimitiveType ist der TriangleStrip und beschreibt ein Band, dass aus Dreiecken besteht. Dies sieht beispielsweise so aus, wie ich es im folgenden Bild dargestellt habe.

TriangleStrip.jpg

Das hier dargestellte Band besteht aus fünf Dreiecken und mit einer TriangleList wären 15 Vertices notwendig um dieses Band zu beschreiben. Tatsächlich teilt sich jedoch jedes Dreieck mindestens zwei Vertices mit einem der anderen Dreiecke. Und genau hier setzt der TriangleStrip an. Das erste Dreieck wird aus drei Vertices beschrieben (V0.0 bis V0.2), genau so, wie es auch bei der TriangleList ist. Jedes weitere Dreieck kann nun durch einen einzelnen Vertex beschrieben werden (z.B. V1.0). Die Grafikkarte nimmt dazu einfach die beiden vorherigen Vertices und formt mit dem aktuellen ein Dreieck. In obigem Beispiel benötigen wir also zur Darstellung des Bandes aus Dreiecken mit einem TriangleStrip lediglich 7 Vertices.

Ein kleines Problem bei dieser Verfahrensweise ist, dass Bänder entstehen. Um neu anzusetzen muss man sich daher eines kleines Tricks bedienen, den sogenannten degenerierten Dreiecken. Dabei wird ein Dreieck erzeugt, dass eine Fläche von 0 hat. Dazu wird einfach der vorherige Vertex wiederholt. Als nächsten Vertex gibt man den neuen Startpunkt des Dreiecks an, an dem man neu ansetzen will und wiederholt diesen Vertex. Dadurch entstehen zwei Dreiecke mit einer Fläche von 0, die von der Grafikkarte nicht gerendert werden. Das erste am Ende des ersten Band und das zweite zu Begin des neu angesetzten Bandes.

Wer aufmerksam gelesen hat und sich ein wenig mit Backface Culling auskennt, dem wird sicherlich auffallen, dass jedes zweite Dreieck die falsche Vertex-Reihenfolge hat und somit von der Grafikkarte geculled, also nicht gerendert werden sollte. Dies ist aber nicht so, da die Grafikkarte für einen Strip grundsätzlich für jedes zweite Primitive die Abwicklungsreihenfolge umdreht.

LineList

Die LineList ist wie der Name schon andeutet eine simple Liste von Linien. Dabei formen jeweils zwei Vertices eine Linie. Die Dicke der Linie kann dabei übrigens nicht beeinflusst werden. Benötigt man breitere Linien, so muss diese aus einem TriangleStrip oder einer TriangleList erzeugt werden. Ein praktisches Anwendungsgebiet für eine LineList sind Debug-Ausgaben. Bounding-Boxen lassen sich so leicht visualisieren, aber auch ein Grid im Hintergrund um Objekte in einem Editor einfacher positionieren zu können.

LineStrip

Der LineStrip funktioniert so ähnlich wie der TriangleStrip. Die erste Linie wird aus den ersten beiden Vertices geformt. Jeder weitere Vertex erzeugt nun, gemeinsam mit dem vorherigen, eine neue Linie und so können Vertices gespart werden.

Index Buffer

Der Index Buffer ist - mit Ausnahme von DrawInstancedPrimitives - optional. Es steht jeweils eine Methode mit und eine ohne Verwendung des Index Buffer zur Verfügung. Der Index Buffer macht dabei etwas ähnliches wie der TriangleStrip. Er ermöglicht es, dass Vertices nicht in der Reihenfolge angesprochen werden, wie diese im Vertex Buffer aufgeführt sind, sondern in der Reihenfolge, wie sie im Index Buffer angegeben sind. Dabei wird der Index Buffer von vorne nach hinten durchgegangen und der jeweilige Index gelesen. Dieser gibt an, an welcher Position im Vertex Buffer der zu verwendende Vertex zu finden ist.

Beispiel

Als Beispiel verwende ich wieder die gleiche Grafik wie zuvor: unseren TriangleStrip.

TriangleStrip.jpg

Wir erzeugen nun einen Vertex Buffer mit unseren sieben Vertices (blaue Punkte).

V0.0
V0.1
V0.2
V1.0
V2.0
V3.0
V4.0

Den PrimitiveType stellen wir auf TriangleList und bauen folgenden Index Buffer auf:

0 1 2
2 1 3
3 1 4
3 4 5
5 4 6

Wie wir dabei unschwer erkennen können verwenden wir sieben Vertices zur Beschreibung von fünf Dreiecken. Diese fünf Dreiecke erfordern bei einer TriangleList jedoch fünfzehn Vertices und diese werden durch die Indices beschrieben. Jeweils drei formen ein Dreieck.

Wozu nun dieser Aufwand? Wir müssen zwei Listen pflegen und verbrauchen daher mehr Speicher, das sieht auf den ersten Blick nicht sinnvoll aus. Auf den zweiten Blick fällt uns jedoch bei Betrachtung des Index Buffer auf, dass einige Vertices mehrfach verwendet werden. Der erste, dritte und vierte wird dreimal verwendet und der zweite und fünfte zweimal. Und genau hier liegt der Hund begraben. Die Grafikkarte hat einen sogenannten Vertex Cache in dem Vertices gespeichert werden, die bereits transformiert wurden. Werden diese Vertices mehrfach benötigt, so muss der Vertex Shader nicht mehr ausgeführt werden und auch ein paar andere Teile der Grafikpipeline können eingespart werden. Der Vertex kann einfach aus dem Cache gelesen werden. Dies kann der Grafikkarte deutlich Arbeit sparen, auch wenn der Vertex Cache mit ungefähr 16 bis 32 Plätzen relativ klein erscheint. Die genaue Größe hängt übrigens von der Grafikkarte ab und ist meist nicht öffentlich bekannt.

Der Vertex Cache kann jedoch nur dann funktionieren, wenn ein Index Buffer verwendet wird. Der Grund dafür ist, dass nur so zwei gleiche Vertices schnell und eindeutig identifiziert werden können. Der Vertex an Position 1 ist ganz eindeutig und eine Prüfung 1 == 1 ist schnell. Müsste der gesamte Inhalt des Vertex verglichen werden, so wäre der Vorteil des Caches oft dahin, da die Elemente eines Vertex ja dynamisch aufgebaut werden können.

Wir können also daraus eine Faustformel ableiten, die für die Erklärung der unterschiedlichen Draw-Methoden wichtig ist:

Hinweis
Hinweis

Werden Vertices im Vertex Buffer mehrfach verwendet, sollte der Index Buffer verwendet werden, da nur so der Vertex Cache arbeiten kann.


Übrigens: Der Index Buffer funktioniert selbstverständlich auch mit TriangleStrip und LineStrip. Der Vorteil fällt hier aber etwas geringer aus, da Vertices nicht so häufig wiederverwendet werden.

Wie hoch genau der jeweilige Vorteil ausfällt kann nicht pauschal beantwortet werden, da die Reihenfolge und Anzahl der Vertices dazu bekannt sein müsste, genau so wie die exakte Vertex-Cache-Größe der Grafikkarte, aber auch die exakten Kosten für den Vertex Shader etc. Im Zweifelsfall sollte hier also einfach getestet werden. Übrigens liegt hier ein kleiner Vorteil des Model-Processors von XNA versteckt. Die ContentPipeline importiert zur Kompilierzeit ein Mesh und der Model-Processor ordnet die Vertices in den Buffern neu an und zwar so, dass der Vertex Cache den höchsten Wirkungsgrad erzielt. Die dazu verwendeten Algorithmen sind relativ komplex und das ist einer der Gründe, warum die Content-Pipeline beim importieren von 3D-Modellen relativ langsam ist.

Ich denke, dass damit der Unterschied zwischen den Varianten mit und ohne Index Buffer hinreichend erklärt sein sollte und eine Entscheidung für die jeweilige Variante leichter fallen sollte.

User vs. Non-User

Kommen wir nun zu einer weiteren, wichtigen Unterscheidung. Wann verwenden wir Draw*Primives und wann DrawUser*Primitives?

Ich hatte ja eingangs behauptet, dass alle Draw-Methoden einen Vertex Buffer benötigen. DrawUserPrimitives und DrawUserIndexedPrimitives arbeiten aber mit einem C#-Array, welches Vertices enthält. Das ist richtig, aber XNA erzeugt für uns im Hintergrund einen Vertex Buffer und lädt die Daten aus unserem Array dort hinein. Und genau das ist der Unterschied:

Die User-Draw-Methoden laden die Vertex-Daten unmittelbar vor dem Rendern in den Grafikkartenspeicher und rendern diesen. Dies kostet selbstverständlich Zeit und macht nur dann Sinn, wenn sich die Vertex-Daten häufig ändern und diese Änderungen durch die CPU erfolgen sollen. Die Daten werden dabei in einen Vertex Buffer geladen, dessen Verwaltung XNA für uns übernimmt. Dieser ist für uns also unsichtbar, aber trotzdem vorhanden.

Deutlich schneller ist in jedem Fall eine statische Geometrie. Diese wird einmalig in einen Vertex Buffer gepackt, der auf die Grafikkarte geladen wird. Dieser kann nun durch einen Draw-Aufruf beliebig häufig gerendert werden. Einfluss darauf nehmen kann man beispielsweise über Shader Parameter, dass würde aber hier den Rahmen sprengen.

DrawUserPrimitives und DrawUserIndexedPrimitives sollte also immer dann verwendet werden, wenn sich die Daten häufig ändern. Der SpriteBatch verwendet beispielsweise intern diese Methoden zum rendern seiner Sprites. Zunächst werden die Daten, die wir per SpriteBatch.Draw festlegen in ein Array geschrieben. Dieses wird anschliessend von der CPU je nach verwendeter Einstellung sortiert und dann in einem Rutsch an die Grafikkarte gesendet und dann gerendert. Da sich die Sprites in jedem Frame ändern, macht hier DrawUserIndexedPrimitives absolut Sinn. Ähnliches gilt auch für Partikelsysteme, deren Partikel von der CPU erzeugt und verwaltet werden sollen. Hier ist DrawUserIndexedPrimitives sicherlich keine schlechte Idee.

Eine weitere Alternative ist übrigens der DynamicVertexBuffer und der DynamicIndexBuffer. Aber auch hier gilt: Testet einfach aus was für euren konkreten Anwendungsfall am schnellsten ist. Es ist schwer hier generelle Tips zu geben, da zuviele Faktoren einen Einfluss auf das Ergebnis haben.

DrawInstancedPrimitives

Die letzte Methode ist DrawInstancedPrimitives. Die Verwendung davon ist sehr komplex und das ist eine sehr mächtige Geschichte, die aber in bestimmten Fällen ein paar Vorteile bringen kann. Ich erkläre hier nicht ausführlich, wie diese Methode angewendet wird, sondern gebe nur eine kurze Erklärung über die Funktionsweise und die Anwendungsgebiete, damit ihr selbst in die Lage versetzt werdet zu beurteilen, ob ein Einsatz zu erwägen ist oder nicht.

Instanzierte Primitive machen immer dann Sinn, wenn komplexe Objekte häufig gerendert werden sollen. Dabei können sich einige Parameter wie Position, Rotation und Skalierung, aber auch Dinge wie der aktuelle zu rendernde Animationsschritt verändern. Dazu bedient sich DirectX eines kleinen Tricks. Wir arbeiten nicht mit einem Vertex Buffer sondern mit zwei.

In einem der beiden Vertex Buffer haben wir die Vertex Daten unseres Modells. Das kann beispielsweise eine Spielfigur sein. Wichtig ist, dass diese Spielfigur nur ein einziges mal im Buffer existieren muss. Im zweiten Vertex Buffer haben wir eine Liste mit den jeweiligen World Matrices. Für jede Spielfigur die wir rendern wollen eine eigene. Wenn wir also fünf Figuren darstellen wollen, dann benötigen wir auch fünf unterschiedliche Matrizen in diesem Buffer.

Beim Rendern passiert nun folgendes: Die Grafikkarte holt sich eine World Matrix aus dem einen Buffer und die Model Vertices aus dem anderen und jagt dies durch den Vertex Shader und den Rest der Grafikpipeline. Ist sie damit fertig geht es weiter mit der zweiten World Matrix, es werden aber wieder die gleichen Model Vertices aus dem anderen Buffer verwendet. Wieder wird alles durch die Grafikpipeline gejagt und zwar solange, bis alle Modelle gerendert wurden.

Wichtig dabei ist, dass dies nicht nur mit einer World Matrix funktioniert. Im ersten Buffer könnten auch einfach Farbdaten stehen um die Spielfigur unterschiedlich einzufärben. Dort können auch Vertex Gewichtungen stehen um eine Morph-Animation zu beeinflussen, oder Bone-Transformationen um eine Skelett-Animation voranzutreiben. Das genaue Verhalten wird dabei durch den Vertex Shader bestimmt und das ist auch der Grund, warum dazu Custom Shader benötigt werden, was der Grund dafür ist, dass DrawInstancedPrimitives nicht auf dem Windows Phone verfügbar ist.

Auch hier entsteht durch die Verwendung wieder ein Verwaltungsoverhead. Ein Modell mit einer World Matrix wird durch Draw*Primitives schneller gerendert als unter Verwendung von DrawInstancedPrimitives. Allein schon aus dem Grund, weil der Overhead nicht vorhanden ist. Wann welche Methode sinnvoll ist, sollte auch hier einfach wieder ausgetestet werden. Als Faustregel kann man jedoch festhalten:

Hinweis
Hinweis

Je komplexer das Modell und je öfters dieses gerendert werden soll, desto mehr lohnt sich DrawInstancedPrimitives.


Wichtig ist in jedem Fall die Information, dass durch Verwendung von DrawInstancedPrimitives nicht die Anzahl der Vertices die gerendert werden gesenkt wird. Auch der Aufwand für Vertex- und Pixel Shader wird nicht gesenkt. Dieser ist sogar noch leicht höher (komplexerer Vertex Shader) als ohne Instanzierung. Die Vorteile werden daraus gezogen, dass man mehrere ähnliche Modelle, die sich lediglich durch wenige Parameter unterscheiden mit einem einzigen Draw-Aufruf rendern kann. Der Geschwindigkeitsvorteil entsteht also durch die Einsparung von mehrfachen Aufrufen von Draw*Primitives.

Zusammenfassung

Ich habe in diesem Artikel versucht zu erklären, wie die einzelnen Draw-Methoden von XNA zu bewerten sind und wie diese funktionieren. Ich habe euch das notwendige Hintergrundwissen vermittelt, mit dem ihr nun selbst in der Lage sein solltet zu entscheiden, wann welche Methode Sinn macht. Durch ein paar Regeln solltet ihr nun in der Lage sein eine grobe Einschätzung abgeben zu können, wann welche der Methoden Sinn macht.

Ich hoffe, dass ich mit diesem Artikel eine umfassende Erklärung liefern konnte. Sollten noch Fragen oder Verbesserungsvorschläge auftauchen, so bitte ich um die Nutzung der Diskussionsfunktion (Link links oben auf dieser Seite), welche automatisch einen Thread im Forum erzeugt. Im Forum können solche Themen ausführlich, schnell und kompetent behandelt werden.

Weiterführende Links

Referenzen

  1. MSDN: DrawPrimitives
  2. MSDN: DrawIndexedPrimitives
  3. MSDN: DrawUserPrimitives
  4. MSDN: DrawUserIndexedPrimitives
  5. MSDN: DrawInstancedPrimitives
  6. MSDN: PrimitiveType
Navigation
Tutorials und Artikel
Community Project
Werkzeuge