www.codeworx.org/opengl-tutorials/Tutorial 45: Vertex Arrays und Vertex Buffer Objekte

Lektion 45: Vertex Arrays und Vertex Buffer Objekte

Willkommen zu diesem 45. Nehe-Tut, diesmal wird gezeigt werden, wie man mit Vertex Arrays und Vertex Buffer Objekten (VBOs) die Performance wesentlich verbessern kann. Auch wenn die Anzahl der dargestellten Dreiecke durch Sortierung, Culling und level-of-detail Algorithmen so gering wie möglich gehalten werden kann, braucht man in manchen Fällen eben doch sehr viele Polygone. Vertex Arrays und die OpenGL-Erweiterung ARB_vertex_buffer_object ermöglichen das, wobei die Dreiecksdaten in den sehr schnellen Speicher der Grafikkarte geladen werden und dadurch den Renderingvorgang beschleunigen. Da diese Erweiterung relativ neu ist, wird sie auf alten Grafikkarten nicht funktionieren, es muss also geprüft werden ob diese unterstützt wird. (Ab einer Geforce4 sollte es klappen...) Dargestellt wird eine Heightmap mit ca. 32000 Dreiecken, Heightmaps an sich wurden bereits im 34. Tutorial besprochen.

In diesem Tutorial werden:
- Daten aus einer Heightmap geladen,
- Vertex Arrays genutzt um Daten schneller an OpenGL zu übermitteln
- die dann mithilfe der VBO Erweiterung im Speicher der Grafikkarte landen.

Zuerst wie immer einige feste Parameter:

#define MESH_RESOLUTION 4.0f  // Pixels Per Vertex
#define MESH_HEIGHTSCALE 1.0f // Mesh Height Scale
// #define NO_VBOS            // If Defined, VBOs Will Be Forced Off

Die ersten beiden Konstanten sind typisch für Heightmaps, wobei die erste die Auflösung der zu zeichnen Heightmap einstellt (hier wird nur jeder vierte Pixel der Heightmap-Grafik tatsächlich einem Dreieck zugeordnet), die zweite skaliert die ganze Sache auf der Y-Achse. Sollte der dritte Parameter nicht auskommentiert sein, werden keine VBOs genutzt.

Jetzt folgen einige Konstanten, Datentypen und Funktionszeiger für die VBO Erweiterung:

// VBO Extension Definitions, From glext.h
#define GL_ARRAY_BUFFER_ARB 0x8892
#define GL_STATIC_DRAW_ARB 0x88E4
typedef void (APIENTRY * PFNGLBINDBUFFERARBPROC)    (GLenum target, GLuint buffer);
typedef void (APIENTRY * PFNGLDELETEBUFFERSARBPROC) (GLsizei n, const GLuint *buffers);
typedef void (APIENTRY * PFNGLGENBUFFERSARBPROC)    (GLsizei n, GLuint *buffers);
typedef void (APIENTRY * PFNGLBUFFERDATAARBPROC)    (GLenum target, int size, 
                                                     const GLvoid *data, GLenum usage);
// VBO Extension Function Pointers
PFNGLGENBUFFERSARBPROC glGenBuffersARB = NULL;      // VBO Name Generation Procedure
PFNGLBINDBUFFERARBPROC glBindBufferARB = NULL;      // VBO Bind Procedure
PFNGLBUFFERDATAARBPROC glBufferDataARB = NULL;      // VBO Data Loading Procedure
PFNGLDELETEBUFFERSARBPROC glDeleteBuffersARB = NULL;// VBO Deletion Procedure

Es wurde nur soviel definiert, wie für diese Demo notwendig ist. Sollen weitere OpenGL Extensions genutzt werden, lohnt es sich von opengl.org die neueste glext.h runterzuladen und den dortigen Code zu nehmen. Die Funktionen werden im einzelnen bei ihrer Verwendung beschrieben.

Unten folgen einige typische Klassen aus der Geometrie und die Struktur für die Heightmap. Eine eigene Klasse mit immer wieder genutzten Definitionen, Funktionen und Strukturen lohnt sich generell anzulegen, man muss ja nicht jedesmal alles neu erfinden...

class CVert// Vertex Class
{
   public:
   float x;// X Component
   float y;// Y Component
   float z;// Z Component
};
typedef CVert CVec; // The Definitions Are Synonymous
class CTexCoord     // Texture Coordinate Class
{
   public:
   float u;         // U Component
   float v;         // V Component
};
class CMesh
{
public: // Mesh Data intm_nVertexCount; // Vertex Count CVert*m_pVertices; // Vertex Data CTexCoord*m_pTexCoords; // Texture Coordinates unsigned intm_nTextureId; // Texture ID
   // Vertex Buffer Object Names
   unsigned intm_nVBOVertices; // Vertex VBO Name
   unsigned intm_nVBOTexCoords;// Texture Coordinate VBO Name
   // Temporary Data
   AUX_RGBImageRec* m_pTextureImage;// Heightmap Data
public:
   CMesh(); // Mesh Constructor
   ~CMesh();// Mesh Deconstructor
   // Heightmap Loader
   bool LoadHeightmap( char* szPath, float flHeightScale, float flResolution );
// Single Point Height float PtHeight( int nX, int nY );
// VBO Build Function void BuildVBOs(); };

Der größte Teil der Klassen erklärt sich von selbst. Punkt und Texturkoordinaten wurden in getrennt definiert, was nicht zwingend nötig ist, aber später noch Sinn machen wird.

Jetzt zu den globalen Variablen. Zuerst wird ein Flag benötigt um zu speichern, ob VBOs unterstützt werden. Es folgt ein Pointer auf die Heightmap, g_flYRot wird zur Rotation um die Y-Achse genutzt. Die letzten Variablen werden für die FPS-Berechnung genutzt, die sich recht gut macht um den Geschwindigkeitszuwachs zu demonstrieren.

bool g_fVBOSupported = false; // ARB_vertex_buffer_object supported?
CMesh *g_pMesh = NULL;        // Mesh Data
float g_flYRot = 0.0f;        // Rotation
int g_nFPS = 0,g_nFrames = 0; // FPS and FPS Counter
DWORD g_dwLastFPS = 0;        // Last FPS Check Time

Weiter zu den CMesh Funktionen. LoadHeightmap ist die erste. Eine Heightmap ist eine zweidimensionale Datenstruktur, üblicherweise eine Grafik, die Höhenwerte einer Oberfläche (oder Landschaft) als Graustufen speichert. Ausgegeben wird das ganze dann natürlich als ein großes Feld von Dreiecken. In dieser Implementierung wird ein einfaches Bitmap (drei Kanäle, RGB, *.bmp) als Datenquelle genutzt, wobei ein simpler Beleuchtungsalgorithmus (hat mit dem Thema der Lektion nicht viel zu tun, keine Angst ;) genutzt wird um die Höhenwerte zu ermitteln. Das Ergebnis ist damit das gleiche, nur das die Ausgangsgrafik auch farbig sein kann. Oft werden Grafikformate mit 4 Kanälen genutzt, z.B. *.tga-Grafiken um den Alpha-Kanal für die Höhenwerte zu verwenden und den Rest der Grafik als Farbwerte für die zu zeichnenden Dreiecke.

Zuerst wird überprüft, ob die in szPath übergebene Grafik überhaupt existiert, ist dies der Fall wird sie mit Hilfe von GLaux geladen.

bool CMesh :: LoadHeightmap( char* szPath, float flHeightScale, float flResolution    )
{
   // Error-Checking
   FILE* fTest = fopen( szPath, "r" ); // Open The Image
   if( !fTest )                        // Make Sure It Was Found
   return false;                       // If Not, The File Is Missing
   fclose( fTest );                    // Done With The Handle
   // Load Texture Data
   m_pTextureImage = auxDIBImageLoad( szPath ); // Utilize GLaux's Bitmap Load Routine

Jetzt zu interessanterem Code. Vorher muss angemerkt werden, dass für jedes Dreieck der Heightmap drei Punkte einzeln gespeicht werden, Punkte werden also nicht von mehreren Dreiecken geteilt.

Die Anzahl der benötigten Punkte wird berechnet ( (Terrainbreite / Auflösung) * (Terrainhöhe / Auflösung) * 3 Punkte pro Dreieck * 2 Dreiecke pro Quadrat im Terrain ), danach der für diese Punkte benötigte Speicher reserviert.

   // Generate Vertex Field
   m_nVertexCount = (int) (   m_pTextureImage->sizeX 
                            * m_pTextureImage->sizeY    
                            * 6 / ( flResolution * flResolution ) );

   m_pVertices = new CVec[m_nVertexCount];       // Allocate Vertex Data
   m_pTexCoords = new CTexCoord[m_nVertexCount]; // Allocate Tex Coord Data
   int nX, nZ, nTri, nIndex=0;                   // Create Variables
   float flX, flZ;

   for( nZ = 0; nZ < m_pTextureImage->sizeY; nZ += (int) flResolution )
   {
      for( nX = 0; nX < m_pTextureImage->sizeX; nX += (int) flResolution )
      {
         for( nTri = 0; nTri < 6; nTri++ )
         {
            // Using This Quick Hack, Figure The X,Z Position Of The Point
            flX = (float) nX + ( ( nTri == 1 || nTri == 2 || nTri == 5 ) ? flResolution : 0.0f );
            flZ = (float) nZ + ( ( nTri == 2 || nTri == 4 || nTri == 5 ) ? flResolution : 0.0f );
            // Set The Data, Using PtHeight To Obtain The Y Value
            m_pVertices[nIndex].x = flX - ( m_pTextureImage->sizeX / 2 );
            m_pVertices[nIndex].y = PtHeight( (int) flX, (int) flZ ) * flHeightScale;
            m_pVertices[nIndex].z = flZ - ( m_pTextureImage->sizeY / 2 );
            // Stretch The Texture Across The Entire Mesh
            m_pTexCoords[nIndex].u = flX / m_pTextureImage->sizeX;
            m_pTexCoords[nIndex].v = flZ / m_pTextureImage->sizeY;
            // Increment Our Index
            nIndex++;
         }
      }
   }

Zuletzt wird die Heightmap Textur an OpenGL übergeben und der genutzte Speicher wieder freigegeben. Das sollte aus den vorherigen Lektionen bekannt sein.

   // Load The Texture Into OpenGL
   glGenTextures( 1, &m_nTextureId );              // Get An Open ID
   glBindTexture( GL_TEXTURE_2D, m_nTextureId );   // Bind The Texture
glTexImage2D( GL_TEXTURE_2D, 0, 3, m_pTextureImage->sizeX, m_pTextureImage->sizeY, 0, GL_RGB, GL_UNSIGNED_BYTE, m_pTextureImage->data );
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
   // Free The Texture Data
   if( m_pTextureImage )
   {
      if( m_pTextureImage->data )
      free( m_pTextureImage->data );
      free( m_pTextureImage );
   }
return true; }

PtHeight ist relativ simpel. Für jede übergebene Position wird die Höhe berechnet, die der Einfachheit halber genutzte Beleuchtungsformel ist nicht allzu schwer:

float CMesh :: PtHeight( int nX, int nY )
{
   // Calculate The Position In The Texture, Careful Not To Overflow
   int nPos = ( ( nX % m_pTextureImage->sizeX ) + 
              ( ( nY % m_pTextureImage->sizeY ) 
                     * m_pTextureImage->sizeX ) ) * 3;

   float flR = (float) m_pTextureImage->data[ nPos ];     // Get The Red Component
   float flG = (float) m_pTextureImage->data[ nPos + 1 ]; // Get The Green Component
   float flB = (float) m_pTextureImage->data[ nPos + 2 ]; // Get The Blue Component

   // Calculate The Height Using The Luminance Algorithm
   return ( 0.299f * flR + 0.587f * flG + 0.114f * flB ); 
}

Nun zum Kern des Ganzen, den Vertex Arrays und VBOs. Vertex Arrays sind eine Möglichkeit, OpenGL die Geometriedaten eines zu rendernden Objektes direkt zu übergeben und im Stück anzeigen zu lassen. Dabei werden wesentlich weniger Funktionsaufrufe genutzt, man spart z.B. zahlreiche glVertex(...), was die Performance deutlich verbessern kann. VBOs speichern Dreiecksdaten direkt in den RAM der Grafikkarte, es muss also nicht der Umweg über den Arbeitsspeicher und die CPU genommen werden, gebraucht werden diese Daten ja nur im Grafikprozessor. Bei einigen Versuchen konnten VBOs die Framerate verdreifachen, es lohnt sich also allemal.

Die nächste Funktion erstellt VBOs. Dabei gibt es verschiedene Möglichkeiten die Daten in den Grafikspeicher zu verschieben, am einfachsten dürfte das sogenannte "mappen" sein. Dabei wird wie folgt vorgegangen: Zuerst wird glGenBuffersARB aufgerufen um eine gültige ID für das VBO zu generieren. Diese Nummer wird OpenGL mit den geladenen Daten assoziieren. Man könnte auch manuell eine ID vergeben, allerdings müßte dann geprüft werden, ob diese nicht schon genutzt wird. Das zu erstellende VBO wird mit glBindBufferARB aktiviert. Zuletzt wird das VBO mit Hilfe eines Aufrufes von glBufferDataARB in den Grafikspeicher kopiert, dessen Größe und ein Pointer auf dieses VBO werden übergeben. Dies geschieht für die Textur- und Punktkoordinaten getrennt. Da die Objektdaten jetzt doppelt vorliegen, kann die Kopie aus dem Arbeitsspeicher gelöscht werden.

void CMesh :: BuildVBOs()
{
   // Generate And Bind The Vertex Buffer
   glGenBuffersARB( 1, &m_nVBOVertices );                  // Get A Valid Name
   glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_nVBOVertices ); // Bind The Buffer
// Load The Data glBufferDataARB( GL_ARRAY_BUFFER_ARB, m_nVertexCount*3*sizeof(float), m_pVertices,GL_STATIC_DRAW_ARB);
   // Generate And Bind The Texture Coordinate Buffer
   glGenBuffersARB( 1, &m_nVBOTexCoords );                 // Get A Valid Name
   glBindBufferARB( GL_ARRAY_BUFFER_ARB, m_nVBOTexCoords );// Bind The Buffer

   // Load The Data
   glBufferDataARB( GL_ARRAY_BUFFER_ARB, m_nVertexCount*2*sizeof(float), 
                    m_pTexCoords,GL_STATIC_DRAW_ARB );
   // Our Copy Of The Data Is No Longer Necessary, It Is Safe In The Graphics Card
   delete [] m_pVertices; m_pVertices = NULL;
   delete [] m_pTexCoords; m_pTexCoords = NULL;
}

Ok, Zeit für die Initialisierungsfunktion. Die Heightmap wird alloziiert und geladen. Es wird geprüft ob VBO unterstützt werden, das Ergebnis landet in g_fVBOSupported. Werden diese durch die Grafikkarte unterstützt, können die benötigten Funktionspointer mit wglGetProcAddress geholt werden.

// Any GL Init Code & User Initialiazation Goes Here
BOOL Initialize (GL_Window* window, Keys* keys) 
{
   g_window = window;
   g_keys = keys;
// Load The Mesh Data g_pMesh = new CMesh(); // Instantiate Our Mesh if( !g_pMesh->LoadHeightmap( "terrain.bmp", // Load Our Heightmap MESH_HEIGHTSCALE, MESH_RESOLUTION ) ) { MessageBox( NULL, "Error Loading Heightmap", "Error", MB_OK ); return false; }
   // Check For VBOs Supported
#ifndef NO_VBOS
   g_fVBOSupported = IsExtensionSupported( "GL_ARB_vertex_buffer_object"    );
   if( g_fVBOSupported )
   {
      // Get Pointers To The GL Functions
      glGenBuffersARB = (PFNGLGENBUFFERSARBPROC) wglGetProcAddress("glGenBuffersARB");
      glBindBufferARB = (PFNGLBINDBUFFERARBPROC) wglGetProcAddress("glBindBufferARB");
      glBufferDataARB = (PFNGLBUFFERDATAARBPROC) wglGetProcAddress("glBufferDataARB");
      glDeleteBuffersARB = (PFNGLDELETEBUFFERSARBPROC) wglGetProcAddress("glDeleteBuffersARB");
      // Load Vertex Data Into The Graphics Card Memory
      g_pMesh->BuildVBOs(); // Build The VBOs
   }
#else /* NO_VBOS */
   g_fVBOSupported = false;
#endif
   //~TUTORIAL
   
   // Setup GL States
   glClearColor (0.0f, 0.0f, 0.0f, 0.5f); // Black Background
   glClearDepth (1.0f);                   // Depth Buffer Setup
   glDepthFunc (GL_LEQUAL);               // The Type Of Depth Testing (Less Or Equal)
   glEnable (GL_DEPTH_TEST);              // Enable Depth Testing
   glShadeModel (GL_SMOOTH);              // Select Smooth Shading

   // Set Perspective Calculations To Most Accurate
   glHint (GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST); 

   glEnable( GL_TEXTURE_2D );             // Enable Textures
   glColor4f( 1.0f, 1.0f, 1.0f, 1.0f );   // Set The Color To White
   return TRUE;                           // Return TRUE (Initialization Successful)
}

IsExtensionSupported gibt es auch auf OpenGL.org, diese Implementierung dürfte IMHO ein wenig sauberer sein. Geprüft wird, ob die Grafikkarte eine bestimmt OpenGL Erweiterung unterstützt.

bool IsExtensionSupported( char* szTargetExtension )
{
   const unsigned char *pszExtensions = NULL;
   const unsigned char *pszStart;
   unsigned char *pszWhere, *pszTerminator;
   // Extension names should not have spaces
   pszWhere = (unsigned char *) strchr( szTargetExtension, ' ' );
   if( pszWhere || *szTargetExtension == '\0' )
      return false;
   // Get Extensions String
   pszExtensions = glGetString( GL_EXTENSIONS );
   // Search The Extensions String For An Exact Copy
   pszStart = pszExtensions;

   for(;;)
   {
      pszWhere = (unsigned char *) strstr( (const char *) pszStart, szTargetExtension );

      if( !pszWhere )
         break;

      pszTerminator = pszWhere + strlen( szTargetExtension );

      if( pszWhere == pszStart || *( pszWhere - 1 ) == ' ' )
         if( *pszTerminator == ' ' || *pszTerminator == '\0' )
            return true;

      pszStart = pszTerminator;
   }
return false; }

Fast fertig, jetzt fehlt nur noch die Ausgabefunktion.

void Draw (void)
{
   glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Clear Screen And Depth    Buffer
   glLoadIdentity ();                                   // Reset The Modelview Matrix
   // Get FPS
   if( GetTickCount() - g_dwLastFPS >= 1000 )           // When A Second Has Passed...
   {
      g_dwLastFPS = GetTickCount();// Update Our Time Variable
      g_nFPS = g_nFrames;// Save The FPS
      g_nFrames = 0;// Reset The FPS Counter
      char szTitle[256]={0};                            // Build The Title String
      sprintf( szTitle, "Lesson 45: NeHe & Paul Frazee's VBO Tut - %d Triangles,    %d FPS", 
                         g_pMesh->m_nVertexCount / 3, g_nFPS );

      if( g_fVBOSupported )                             // Include A Notice About VBOs
         strcat( szTitle, ", Using VBOs" );

      else
         strcat( szTitle, ", Not Using VBOs" );

      SetWindowText( g_window->hWnd, szTitle );         // Set The Title
   }

   g_nFrames++;                                         // Increment Our FPS Counter
   // Move The Camera
   glTranslatef( 0.0f, -220.0f, 0.0f );                 // Move Above The Terrain
   glRotatef( 10.0f, 1.0f, 0.0f, 0.0f );                // Look Down Slightly
   glRotatef( g_flYRot, 0.0f, 1.0f, 0.0f );             // Rotate The Camera

Jede Sekunde wird die FPS Rate gemessen und der Framezähler zurückgesetzt. Die Kamera wird in die Mitte des Terrains verschoben und rotiert. g_flYRot wird in der Update Funktion jeweils um 1 erhöht.

Um Vertex Arrays und VBOs zu nutzen, müssen deren Pointer an OpenGL übergeben werden. Die states GL_VERTEX_ARRAY und GL_TEXTURE_COORD_ARRAY werden aktiviert. Jetzt müssen nur noch die einzelnen Pointer gesetzt werden.

Um einen Pointer auf einen bestimmten Datentyp zu setzen, müssen entsprechende Funktionen, in diesem Fall glVertexPointer und glTexCoordPointer aufgerufen werden. Die Anzahl der Variablen (3 für Punkt-, 2 für Texturkoordinaten), der Datentyp (foat), der Abstand der Daten (falls sich in der Struktur außer den Dreiecksdaten noch mehr befindet, in diesem Falle nicht) und der Pointer auf die gewünschten Daten müssen übergeben werden. Man könnte auch glInterleavedArrays nutzen um alles in einem großen Puffer zu speichern, allerdings wäre es dann schwierig mehrere VBOs auszugeben.

   // Set Pointers To Our Data
   if( g_fVBOSupported )
   {
      glBindBufferARB( GL_ARRAY_BUFFER_ARB, g_pMesh->m_nVBOVertices );

      // Set The Vertex Pointer To The Vertex Buffer
      glVertexPointer( 3, GL_FLOAT, 0, (char *) NULL );

      glBindBufferARB( GL_ARRAY_BUFFER_ARB, g_pMesh->m_nVBOTexCoords );

      // Set The TexCoord Pointer To The TexCoord Buffer
      glTexCoordPointer( 2, GL_FLOAT, 0, (char *) NULL );
   } else
   {
      // Set The Vertex Pointer To Our Vertex Data
      glVertexPointer( 3, GL_FLOAT, 0, g_pMesh->m_pVertices );

      // Set The Vertex Pointer To Our TexCoord Data
      glTexCoordPointer( 2, GL_FLOAT, 0, g_pMesh->m_pTexCoords );
   }

Das eigentliche Rendering passt jetzt in einen einzigen Funktionsaufruf:

// Render
// Draw All Of The Triangles At Once

glDrawArrays( GL_TRIANGLES, 0, g_pMesh->m_nVertexCount );

glDrawArrays übergibt die Geometriedaten an OpenGL. Dabei wird überprüft welche States gerade aktiv sind und deren Pointer werden zur Ausgabe genutzt. Übergeben wird die gewünschte Grundform (GL_TRIANGLES), ein Index wo die Daten anfangen und die Anzahl der Punkte die genutzt werden. Die Aufrufe glBegin und glEnd sind überflüssig.

Wie oben angedeutet, können sich die einzelnen Dreiecke keine Punkte teilen, da glDrawArrays dies nicht unterstützt. Sollten zusätzlich vorberechnete Normalen übergeben werden, braucht jeder Punkt eine eigene Normale, auch wenn diese sich oft wiederholen. Normalen können die Bildgenauigkeit etwas steigern.

Die States müssen zum Schluß wieder zurückgesetzt werden, um mögliche andere (einfache) Objekte auszugeben.

   // Disable Pointers
   glDisableClientState( GL_VERTEX_ARRAY );        // Disable Vertex Arrays
   glDisableClientState( GL_TEXTURE_COORD_ARRAY ); // Disable Texture Coord Arrays
}

Für mehr Informationen zu Vertex Buffer Objekten empfiehlt sich die umfangreichere Dokumentation zu den Extensions auf SGI.

Das wars für dieses Tutorial, ich hoffe es hat Spaß gemacht, bis zum nächsten Mal. Der Autor ist unter paulfrazee@cox.net zu erreichen.

Paul Frazee,
Jeff Molofee (NeHe) http://nehe.gamedev.net

Die Source Codes und Ausführbaren Dateien zu den Kursen liegen auf der Neon Helium Website

codeworx.

Übersetzt und modifiziert von Hans-Jakob Schwer 22.07.2k4, www.codeworx.org