SelfDXD von Martin Pyka
SelfDXD --- DirectXGraphic --- Vertex- und Indexbuffer
Der Vertexbuffer
Was ist der Vertexbuffer?
IDirect3DDevice8.CreateVertexbuffer
Vertexbuffer mit Daten füllen
Vertices rendern
Die letzten Einstellungen
Den Vertexbuffer freigeben
Das Beispielprogramm

Was ist der Vertexbuffer?

Nun, im Prinzip ist der Name ja fast selbst erklärend. Im wesentlichen ist der Vertexbuffer ein Ort, an dem die Vertices gespeichert werden und für Direct3D jederzeit zum Rendern abrufbereit sind. Das Interface des Vertexbuffer selbst enthält eigentlich nicht so viele Funktionen, die wir brauchen, um Vertices darzustellen. In diesem Kapitel lernen Sie, wie Sie einen Vertexbuffer erstellen, ihn mit Vertices füttern und anschliessend ein Objekt darstellen. In diesem Fall wollen wir zunächst ein einfaches Dreieck darstellen. Im nächsten Kapitel werden wir uns dann an einen Zylinder wagen.


IDirect3DDevice8.CreateVertexbuffer

Wie bei allen Interface-Objekten, die wir benötigen, muss auch dieses erstmal initialisiert werden. Da sich ein Vertexbuffer zum Teil über die Anzahl der Vertices und dem Format der Vertices definiert, müssen wir uns also zunächst erstmal klar machen, welche Eigenschaften unsere Vertices haben sollen, und wieviele wir davon brauchen.

Angenommen, wir wollen ein einfaches Dreieck darstellen, dann brauchen wir demzufolge auch nur 3 Vertices. Da wir zunächst nicht mit Licht arbeiten wollen, aber dafür mit Animationen brauchen wir nicht-transformierte aber beleuchtete Vertices. Erstellen wir also ein Record dieser Art:


type
  CUSTOMVERTEX = record
    x, y, z : Single;   // Positionskoordinaten
    color : TD3DColor;  // Farbe 
  end;

const                   // Format von CUSTOMVERTEX
  D3DFVF_CUSTOMVERTEX = ( D3DFVF_XYZ or D3DFVF_DIFFUSE );

procedure InitGeometry;
var
  Vertices: Array[0..2] of CUSTOMVERTEX;
begin
  ...

Ausserdem erstellen wir schonmal eine Konstante, die das Format unseres Records angibt. Das ist beim Initialisieren wichtig. Des weiteren definieren wir ein Array mit 3 Elementen vom Typ CUSTOMVERTEX. Hierrein schreiben wir später die nötigen Informationen für unser Dreieck. Es reicht, wenn Sie Vertices nur innerhalb einer Funktion definieren, da die Daten ja später sowieso Bestandteil des Vertexbuffers sein werden und nicht noch ein zweites Mal in einem Array stehen müssen.

Jetzt, wo wir also wissen, was wir darstellen wollen, können wir uns an's eigentliche Initialisieren wagen. Dazu benötigen wir eine Variable vom Typ IDirect3DVertexBuffer8. Das Erstellen des Vertexbuffers funktioniert über das Direct3DDevice8-Interface, dass dafür folgende Funktion bereithält:


function CreateVertexBuffer(const Length : Cardinal;
                            const Usage, FVF : LongWord;
                            const Pool : TD3DPool;
                              out ppVertexBuffer : IDirect3DVertexBuffer8) : HResult;

Length: Hier steht die Länge des Vertexbuffers in Bytes. Die gewünschte Bytegrössen errechnet man am besten so: Sizeof( Vertexformat ) * Anzahl_der_Vertices.

Usage: Gibt an, für welche Zwecke der Vertexbuffer erstellt wird. Hier kann man bei grossem Datenaufwand die ein oder andere FPS-Zahl gewinnen. Sie können dafür einen Teil der D3DUSAGEFLAGS verwenden. Man kann auch mehrere miteinander kombinieren. Desweiteren ist es auch möglich einfach 0 zu übergeben und damit einen Standardvertexbuffer zu erzeugen. Für Ihre ersten Samples ist das sicherlich die einfachste Möglichkeit. Die Wahl der Flags hängt davon ab, wie Sie die Vertices verwenden wollen. Wenn die Anzahl der Vertices veränderbar sein soll, empfiehlt es sich D3DUSAGE_DYNAMIC zu verwenden. Wenn Sie Vertexbuffer nur zum rendern und beschreiben brauchen dann empfiehlt es sich D3DUSAGE_WRITEONLY zu verwenden. All diese Flags sorgen für eine sinnvolle Verwaltung der Vertexdaten im Speicher um Ihren Verwendungszweck optimal erfüllen zu können.

FVF: Hier geben Sie das Format Ihrer Vertices an, die Sie in den Buffer schreiben. Verwenden Sie hierzu eine Kombination der D3DFVFFLAGS-Flags. Wenn wir die oberen Vorgaben betrachten, würden wir hier D3DFVF_CUSTOMVERTEX übergeben.

Pool: Eine Konstante der D3DPool-Aufzählungen, die angeben, in welcher Speicherklasse die Daten abgelegt werden sollen.

ppVertexBuffer: eine Variable, die auf das erstellte Direct3DVertexBuffer8-Interface zeigt.


Vertexbuffer mit Daten füllen

Wie auch im vorhergehenden Abschnitt müssen wir erstmal Daten haben, die wir dem Vertexbuffer zuweisen können. Definieren wir doch einfach mal eine Prozedur, in der wir das oben angefangene Beispiel fortsetzen.


var
  vbuffer: IDirect3DVertexBuffer8;

function InitGeometry: HRESULT;
var
  Vertices: Array[0..2] of CUSTOMVERTEX;
  pVertices : PByte;
begin
  // Wenn die Funktion vorzeitig abgebrochen wird, wird E_FAIL zurückgegeben
  Result := E_FAIL;

  // In das Vertex-Array werden die Koordinaten und Farben geschrieben
  Vertices[ 0 ].x := 1.0;
  Vertices[ 0 ].y := -1.0;
  Vertices[ 0 ].z := 0.0;
  Vertices[ 0 ].color := $FFFF0000;

  Vertices[ 1 ].x := -1.0;
  Vertices[ 1 ].y := -1.0;
  Vertices[ 1 ].z := 0.0;
  Vertices[ 1 ].color := $FF0000FF;

  Vertices[ 2 ].x := 0.0;
  Vertices[ 2 ].y := 1.0;
  Vertices[ 2 ].z := 0.0;
  Vertices[ 2 ].color := $FFFFFFFF;

  // Erstellen des Vertexbuffers
  if ( d3ddev8.CreateVertexBuffer( sizeof( CUSTOMVERTEX ) * 3, 0, D3DFVF_CUSTOMVERTEX,
    D3DPOOL_DEFAULT, vbuffer ) <> D3D_OK ) then Exit;

  // wird fortgesetzt....

Wenn die .CreateVertexBuffer-Funktion nicht D3D_OK zurück gibt, wird die Funktion an dieser Stelle abgebrochen, weil etwas schief gelaufen ist. Durch Abfragen wie diese, lassen sich ggf. leichter Fehlerquellen finden. Der typisierte Pointer pVertices wird in folgendem eine Rolle spielen.

Um dem Vertexbuffer Daten übergeben zu können, müssen wir diesen locken. Das bedeutet, wir sperren ihn für Zugriffe, die das System auf den Speicherbereich machen möchte, an dem sich der Vertexbuffer befindet, damit keine Komplikationen auftreten, wenn wir diese Speicherstelle beschreiben. Der Lock-Befehl des Vertexbuffers hat folgende Syntax.


function Lock(const OffsetToLock: Cardinal;
              const SizeToLock: Cardinal;
                var ppbData : PByte;
              const Flags : LongWord) : HResult;

OffsetLock: Die Stelle (in Bytes) an der der Vertexbuffer beschrieben bzw. ausgelesen werden soll.

SizeToLock: Die Grösse des zu sperrenden Speichers (in Bytes).

ppbData: Ein Zeiger/Pointer auf den Speicherbereich, den man mit OffsetToLock und SizeToLock "markiert" hat.

Flags: 0 oder eine Kombination aus den D3DLOCKFLAGS, mit denen man angibt, für welche Zwecke der Vertexbuffer gelockt werden soll.

Die eigentliche Übergabe der Daten ist sehr simpel. Sie müssen lediglich die Daten aus dem Array an die Stelle kopieren, auf die der Pointer zeigt. Wenn wir unser Beispielprogramm fortsetzen, dann sieht das so aus:


// Fortsetzung des Beispielprogrammes

  if ( vbuffer.Lock( 0, sizeof( Vertices ), pVertices, 0 ) <> D3D_OK ) then Exit;
  Move( Vertices, pVertices^, sizeof( Vertices ) );
  vbuffer.Unlock;

end;

Hier der Rest unserer InitGeometry-Funktion. Der Vertexbuffer wird gelockt, mit dem Move-Befehl werden alle Vertices in den Speicherbereich geschrieben, auf den der Pointer zeigt, und schliesslich wird mit der parameterfreien .Unlock-Funktion der Zugriff auf den Vertexbuffer wieder freigegeben. Die GetMem-Funktion ist für den Pointer diesmal nicht notwendig, da er ja über den Vertexbuffer bereits Speicher zugewiesen bekommt. Wenn Sie keine Lust haben, immer zwei Variablen für das Beschreiben eines simplen Vertexbuffer zu verwenden, dann führen Sie sich bitte folgendes Beispiel kurz zu Gemüte:


var
  pVertices: Array of CUSTOMVERTEX;
begin

  //Initialisieren des Vertexbuffers
  if ( d3ddev8.CreateVertexBuffer( sizeof( CUSTOMVERTEX ) * 3, 0, D3DFVF_CUSTOMVERTEX,
    D3DPOOL_DEFAULT, vbuffer ) <> D3D_OK ) then Exit;

  // Grösse des dynamischen Array's festlegen
  setlength( pVertices, 3);

  // Beim locken wird einfach nur ein Zeiger auf pVertices übergeben, dass heisst
  // pVertices ist nun der Ort im Vertexbuffer, an dem die Vertices gespeichert werden
  if ( vbuffer.Lock( 0, sizeof(customvertex) * 3, PBYTE(pVertices), 0 ) <> D3D_OK ) then Exit;

  // diese Änderungen werden direkt im Speicher des Vertexbuffers geändert
  pVertices[ 0 ].x := 1.0;
  pVertices[ 0 ].y := -1.0;
  pVertices[ 0 ].z := 0.0;
  pVertices[ 0 ].color := $FFFF0000;

  pVertices[ 1 ].x := -1.0;
  pVertices[ 1 ].y := -1.0;
  pVertices[ 1 ].z := 0.0;
  pVertices[ 1 ].color := $FF0000FF;

  pVertices[ 2 ].x := 0.0;
  pVertices[ 2 ].y := 1.0;
  pVertices[ 2 ].z := 0.0;
  pVertices[ 2 ].color := $FFFFFFFF;

  vbuffer.Unlock;
end;

Hier wird der Ort, an dem die Vertices gespeichert werden als pVertices benannt. Damit sparen wir uns eine Variable.


Vertices rendern

Und nun kommt endlich der Abschnitt, auf den Sie sicher schon lange gewartet haben, der eigentliche Rendervorgang. Jetzt gleich werden Sie zum ersten Mal ein 3D-Objekt sehen, das zwar zugegebener massen noch ziemlich zweidimensional wirken wird, weil wir ja nur ein Dreieck zeichnen, aber der Weg bis zur Dreidimensionalität ist dann nur noch ein Katzensprung.

Das Rendern der Vertices aus einem Vertexbuffer erfolgt in 3 Schritten:

  1. dem Device mitteilen, welchen Vertexbuffer er auslesen soll
  2. dem Device mitteilen, welches Vertexformat es benutzen soll
  3. die Vertices rendern

Schritt 1 wird mit folgender Funktion des Devices ausgeführt:


function SetStreamSource(const StreamNumber : Cardinal;
                         const pStreamData : IDirect3DVertexBuffer8;
                         const Stride : Cardinal) : HResult;

StreamNumber: Das Device ist in der Lage, mehrere Vertexbuffers in einem Rutsch zu rendern. Hier weisen Sie also dem Vertexbuffer, das Sie rendern wollen, eine Streamnummer zu. Dadurch, dass jedem Vertexbuffer im Device einer Nummer zugewiesen wird, können Sie später Vertexbuffer, die nicht mehr gerendert werden sollen, gezielt rausnehmen.

pStreamData: Hier geben Sie den Vertexbuffer an, den Sie rendern wollen.

Stride: Gibt den Abstand in Bytes an, der zwischen zwei Vertexdatensätzen im Speicher liegt. Benutzen Sie hier am besten Sizeof( Vertexrecord ). In dem Sie der Funktion die Grösse unserer Record-Struktur übergeben, stellen wir sicher, dass das Device jedes Vertex korrekt abarbeitet.

Als nächstes muss dem Device mitgeteilt werden, was für ein Vertexformat er auf die zu rendernden Vertexbuffer anwenden soll. Dafür benutzen wir folgende Funktion des Devices:


function SetVertexShader(const Handle : LongWord) : HResult;

Handle: Das Vertexformat, dass Sie verwenden möchten.

So, nun kommen wir zum letzten Schritt, dem eigentlichen Rendervorgang. Auch dieser erfolgt über eine einfache Funktion des IDirect3DDevice8-Interfaces:


function DrawPrimitive(const PrimitiveType : TD3DPrimitiveType;
                       const StartVertex: Cardina;
                       const PrimitiveCount : Cardinal) : HResult;

PrimitiveType: Eine Konstante der D3DPRIMITIVETYPE-Aufzählungen, die angibt, wie die Dreiecke gerendert werden sollen.

StartVertex: Gibt an, bei welchem Vertex angefangen werden soll, zu rendern. Wenn Sie zum Beispiel 100 Vertices haben, aber erst mit dem 50sten Vertex anfangen wollen zu rendern, dann geben Sie hier 50 ein.

PrimitiveCount: Anzahl der Primitiven, die ab StartVertex gerendert werden soll. Die Zahl ist desweiteren abhängig von PrimitivType. Wenn Sie zum Beispiel 100 Vertices als Punkte (D3DPT_POINTLIST) darstellen wollen, dann ist jeder Vertex auch gleichzeitig eine vollständige Primitive, demnach müssen Sie also 100 angeben. Bei D3DPT_TRIANGLELIST würden immer 3 aufeinanderfolgende Vertices ein Dreieck bilden. Die maximale Anzahl an darstellbaren Primitiven wäre dann also 33, wobei der 100ste Vertex entfallen würde. Bei D3DPT_TRIANGLESTRIP wäre die maximale Anzahl 100 - 2 = 98, da ab dem 3 Vertex mit jedem Vertex auch ein neues Dreieck hinzukommt.

Diese drei Funktionen werden in den Rahmen Ihrer Anwendung eingefügt. Dies könnte dann bei unserem Beispielprogramm so aussehen:


procedure TForm1.OnIdle(Sender: TObject; var done: boolean);
begin
  done:= false;
  // den Backbuffer löschen
  d3dDev8.Clear( 0, nil, D3DCLEAR_TARGET, D3DCOLOR_XRGB( 0, 0, 0 ), 1.0, 0 );

  // Rendern der Szene
  d3dDev8.BeginScene;

  // Rendern des Vertexbuffers
  d3dDev8.SetStreamSource( 0, vBuffer, sizeof( CUSTOMVERTEX ) );
  d3dDev8.SetVertexShader( D3DFVF_CUSTOMVERTEX );
  d3dDev8.DrawPrimitive( D3DPT_TRIANGLELIST, 0, 1 );

  // Ende des Renderns
  d3ddev8.EndScene;

  // Den Backbuffer mit dem Frontbuffer vertauschen.
  d3ddev8.Present( nil, nil, 0, nil );
end;

Da wir ja nur 3 Vertices haben, können wir auch nur ein Dreieck rendern. Deswegen geben wir in der .DrawPrimitive()-Funktion als letzten Parameter 1 an. Nun fehlen nur noch ein paar kleine Details, bis wir unser Dreieck auf dem Bildschirm sehen können.


Die letzten Einstellungen

Ein winziges Detail müssen wir nur noch einstellen, dann haben wir es geschafft. Und zwar wird beim Initialisieren des Devices die Lichtberechnung für das Rendern von Vertices immer auf AN gestellt. Da wir aber Vertices benutzen, die keine Normale haben, kann das Device nicht wissen, wo es seine Vorderseite hat. Beim Rendern mit Lichtberechnung würde unser Dreieck deshalb in schwarz erscheinen. Deswegen müssen wir nur noch die Lichtberechnung ausschalten, und das geht über folgenden Befehl:


  d3ddev8.SetRenderState( D3DRS_LIGHTING, Cardinal( FALSE ) );

Mit den Renderstates werden wir uns später noch genauer befassen. Für diesen Moment reicht es, wenn Sie wissen, dass das Device eine Fülle an Renderstates beinhaltet, die mit der Syntax .SetRenderState( <was eingestellt werden soll>, <wie es eingestellt werden soll> ) aufgerufen werden. In diesem Fall soll die Lichtberechnung eingestellt werden, und zwar schalten wir sie aus. Da die Funktion nicht direkte boolische Werte erwartet, sondern eine Zahl, müssen wir den boolischen Wert über Cardinal() in einen numerischen Wert umwandeln. Sie können diesen Befehl direkt nach dem Initialisiern eingeben oder erst beim Rendern der Szene.

Ein weiteres Renderstate, das ich Ihnen jetzt schonmal zeigen möchte, legt fest, welche Renderstates gecullt werden sollen, und welche nicht. Sie erinnern sich? Culling nennt man das Nicht-Rendern von Dreiecken, weil Sie in einer bestimmten Reihenfolge liegen. Standardmässig werden in DirectXGraphics nur die Dreiecke angezeigt, dessen Vertices im Uhrzeigersinn liegen. Alle anderen werden gecullt. Sie können jedoch auch selbst einstellen, welche gecullt werden sollen, und welche nicht. Dazu sieht die .SetRenderState-Funktion wie folgt aus:


  d3ddev8.SetRenderState( D3DRS_CULLMODE, D3DCULL_CCW );

Mit diesen Parametern werden alle Dreiecke gecullt, die gegen den Uhrzeigersinn angeordnet sind (counterclockwise). D3DCULL_CW würde bewirken, dass alle Dreiecke nicht angezeigt werden würden, deren Vertices im Uhrzeigersinn (clockwise) angeordnet sind. Desweiteren können Sie noch D3DCULL_NONE angeben. Ist diese Einstellung aktiv, sehen Sie das Dreieck von beiden Seiten.


Den Vertexbuffer freigeben

Achten Sie darauf, dass Sie alle Vertexbuffer, die Sie erstellen, beim Beenden des Programmes auch wieder freigeben. Die Close-Prozedur wächst also um ein drittes Element an, betrachtet man das, was wir bisher erarbeitet haben:


procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  if vbuffer <> NIL then vbuffer := NIL;
  if d3ddev8 <> NIL then d3ddev8 := NIL;
  if d3d8 <> NIL then d3d8:= NIL;
end;

Das Beispielprogramm

So, jetzt ist es an der Zeit, das Gelernte ein wenig auszuprobieren. Unter directxgraphics/demos/vertices/ finden Sie ein kleines Programm, dass alles bisher gelernte beinhaltet (Achtung: nur in der Offline-Version enthalten). Alle Beispielcodes, die Sie hier finden, werden Sie auch in dieser Demo finden (als Beweis dafür, dass das auch wirklich funktioniert, was ich hier geschrieben habe). Damit Sie sofort verstehen, wie das Programm strukturiert ist, hier eine kurze Übersicht über die enthaltenen Prozeduren und Funktionen:

InitGeometry
Erstellt das Dreieck und schreibt die Vertices in den Vertexbuffer

FormCreate
Initialisiert IDirect3D8 und IDirect3DDevice8. Danach wird InitGeometry aufgerufen. Anschliessend wird das Licht ausgeschaltet und die OnIdle-Prozedur in Gang gesetzt.

OnIdle
Das Dreieck wird immer wieder neu gerendert.

FormClose
Wenn die Anwendung beendet wird, werden alle wichtigen Objekte wieder auf NIL gesetzt.

Nehmen Sie sich Zeit und probieren Sie ruhig aus, die einzelnen Einstellungen beim Initialisieren zu verändern. Bei dieser Anwendung ist es eigentlich überflüssig, den Rendervorgang in die OnIdle-Prozedur zu schreiben, da es genügen würde, wenn das Programm das Dreieck nur einmal rendern würde. Daher kann der Rendervorgang auch in der FormPaint-Prozedur stehen.

Im nächsten Kapitel werden wir uns mit Transformationen beschäftigen. Wir erstellen einen Zylinder und werden diesen im 3D-Raum rotieren lassen. Sie werden überrascht sein, wie einfach das geht!