www.rolandk.de
- Aktuelle Themen zu .Net -
Achtung: Hier handelt es sich um meine alte Seite.
Die aktuelle ist unter folgendem Link erreichbar: www.rolandk.de/wp/
Home Artikel Grafikprogrammierung DirectX 10 Instancing




















































DirectX 10 Instancing
Sonntag, den 19. Juli 2009 um 11:53 Uhr

 

Das Problem
Stellen wir uns mal folgendes Problem vor: Wir sollen eine Großstadt rendern, und zwar nicht nur die Häuser, sondern auch Autos, Ampeln, usw. Kurz: Jede Menge voneinander unabhängige 3D-Objekte. Kein Problem, könnte man meinen, schließlich braucht man ja bloß die entsprechenden 3D-Models öfters zeichnen. Es ist aber ein Problem, denn genau auf diese Weise geht schnell Performance flöten. Warum? Jeder Aufruf einer Draw Methode am Device erzeugt neben dem Aufwand für das Rendern selbst auch noch etwas CPU-Last für andere Dinge. Bei einer geringen Anzahl von Objekten macht sich das noch nicht bemerkbar, doch je mehr es werden, desto größer der Performanceverlust. Ruft man also etwa 50000 mal eine Draw Methode auf, selbst bei einem einzelnen Würfel als Objekt, kann man schnell Probleme kriegen. 

Die Lösung
Eine Möglichkeit zur Lösung dieses Problems ist das sog. Instancing. Die Idee ist an sich einfach: Die Draw Methoden sollen nicht so oft aufgerufen werden, also tun wir das auch nicht. Im Prinzip fasst man mehrere gleichartige Objekte zu einem Draw Aufruf zusammen. Hört sich jetzt erst mal etwas unverständlich an, ist aber nicht so schwer. Man stelle sich vor, wir haben einen Würfel, welchen wir 1000 mal zeichnen wollen. Alle 1000 Würfel gleichen sich dabei wie ein Haar dem anderen und unterscheiden sich nur durch ihre Position. Unter DirectX 10 kann nun ein VertexBuffer erstellt werden, der genau einen dieser Würfel repräsentiert. Zusätzlich dazu wird ein VertexBuffer angelegt, welcher die Positionen aller 1000 Würfel speichert, und nicht mehr. Mit dem DrawInstanced Befehl der Klasse Device werden intern diese beiden VertexBuffer zu einem großen verschmelzt, ähnlich wie es bei einem einfachen Join Befehl auf zwei Tabellen einer Datenbank passiert. Folgende Grafik zeigt diesen Vorgang:

 



 

Auf der C# Seite
Jetzt haben wir zwar in etwa eine Vorstellung, wie das Ganze funktioniert, doch wie wirkt sich das auf den Sourcecode aus? Zunächst haben wir jetzt 2 VertexBuffer und damit auch 2 Vertex-Formate. Somit müssen wir auch 2 Strukturen dafür bereitstellen:

  1. [StructLayout(LayoutKind.Sequential)]
  2. public struct Vertex
  3. {
  4. public Vector3 Position;
  5. public Vector2 TexCoord;
  6.  
  7. public Vertex(Vector3 position, Vector2 texCoord)
  8. {
  9. this.Position = position;
  10. this.TexCoord = texCoord;
  11. }
  12. }
  13.  
  14. [StructLayout(LayoutKind.Sequential)]
  15. public struct VertexInstance
  16. {
  17. public Vector3 Position;
  18.  
  19. public VertexInstance(Vector3 position)
  20. {
  21. this.Position = position;
  22. }
  23. }

Die Struktur Vertex wird im VertexBuffer des Würfels verwendet und muss somit eine Position und eine Texturkoordinate speichern, also genauso, wie wir es ohne Instancing auch machen würden. Die Struktur VertexInstance speichert dagegen lediglich eine Position, genauer gesagt, die Position eines ganzen Würfels. Die beiden benötigten VertexBuffer können jetzt relativ einfach aufgebaut werden. Den Buffer für den Würfel legen wir, zusammen mit dem IndexBuffer, genauso an, wie beispielsweise in den vorherigen Tutorials auch. Den Buffer mit den Instanz-Daten füllen wir anschließend je nach Bedarf. Für den obigen Screenshot habe ich 1000 Würfel verwendet was genau 1000 "Vertices" in diesem VertexBuffer entspricht - jeder mit einer anderen Position. Nachdem wir die beiden VertexBuffer erzeugt haben kommt allerdings schon die nächste Frage: Wie muss man das alles im InputLayout angeben? Die Antwort darauf sieht folgendermaßen aus:

  1. DX10.InputElement[] inputElements = new DX10.InputElement[]
  2. {
  3. new DX10.InputElement("POSITION",0,SlimDX.DXGI.Format.R32G32B32_Float,0,0),
  4. new DX10.InputElement("TEXCOORD",0,SlimDX.DXGI.Format.R32G32_Float,12,0),
  5. new DX10.InputElement(
  6. "POSITION",1,SlimDX.DXGI.Format.R32G32B32_Float,
  7. 0,1,DX10.InputClassification.PerInstanceData, 1)
  8. };

Wirklich neu ist hier nur die letzte Zeile, und zwar beschreibt diese den VertexBuffer für die Instanzdaten. Doch wie wird hier überhaupt zwischen den beiden Buffern unterschieden, scheint ja alles ein einziges InputLayout zu ergeben? Der Schlüssel ist der Parameter Slot. Die Elemente des ersten Buffers bekommen den Slot 0 und dem Element des 2. Buffers wird der Slot 1 zugewiesen, fertig. Wichtig ist noch, dass wir beim 2. VertexBuffer sagen, dass es sich um Instanzdaten handelt. Dies geschieht mit dem optionalen Parameter SlotClass, welchen wir auf PerInstanceData setzen.

Beim Rendern der Objekte weisen wir dem InputAssembler wie gewohnt die VertexBuffer zu, zu beachten ist hier allerdings der jeweils korrekte Slot:

  1. device.InputAssembler.SetVertexBuffers(
  2. 0,
  3. new DX10.VertexBufferBinding(m_vertexBuffer, Marshal.SizeOf(typeof(Vertex)), 0));
  4. device.InputAssembler.SetVertexBuffers(
  5. 1,
  6. new DX10.VertexBufferBinding(
  7. m_vertexBufferInstance,
  8. Marshal.SizeOf(typeof(VertexInstance)), 0));

Das Rendern selbst wird entweder mit dem Befehl DrawInstanced oder DrawIndexedInstanced ausgeführt, je nach dem, ob ein IndexBuffer verwendet wird, oder nicht. In diesem Beispiel verwende ich letzteres:

  1. device.DrawIndexedInstanced(36, 1000, 0, 0, 0);

Instancing im Shader
Jetzt müssen wir noch ein paar minimale Anpassungen am Shader machen, damit das alles auch funktioniert. Zunächst interessiert es dem Shader wenig, dass wir im C# Programm 2 VertexBuffer haben, im Vertex- und Pixelshader kommt nach wie vor jeweils immer nur eine Datenstruktur an. Diese Struktur sieht in diesem Beispiel so aus:

  1. struct VS_IN
  2. {
  3. float3 pos : POSITION0;
  4. float3 instancePos : POSITION1;
  5. float2 tex : TEXCOORD;
  6. };

Wie wir sehen, bekommen wir die Position und Texturkoordinate aus dem ersten Buffer und die Instanzposition aus dem 2. Im VertexBuffer werden schließlich die beiden Positionen zusammengerechnet:

  1. PS_IN VS(VS_IN input)
  2. {
  3. PS_IN output = (PS_IN)0;
  4.  
  5. float3 newPos = input.pos + input.instancePos;
  6. output.pos = mul(float4(newPos.xyz,1.0), WorldViewProj);
  7. output.tex = input.tex;
  8.  
  9. return output;
  10. }

Die beiden Positionen werden also lediglich zusammengerechnet, um deren tatsächliche Position zu erhalten. Das Ergebnis wird wie gewohnt transformiert und dem Pixelshader übergeben. An diesem ändert sich übrigens nichts.

/p

 

Kommentar hinzufügen

Ihr Name:
Kommentar:

Kommentare (1)

1. Henrik
Sonntag, den 19. Juli 2009 um 12:03 Uhr
Danke. Endlich mal ein einfaches Beispiel zur Beantwortung einer im Prinzip einfachen Frage! Dass man den Instance-Buffer im Shader einfach als zusätzliche Vertex-Attribute auslesen kann, ist in den anderen viel umfangreicheren Beispielen, die es so gibt, viel schwerer zu finden.