Die eierlegende Wollmilchsau
(oder von der Kunst ein allgemeingültiges Objektformat zu erstellen)

Synopsis
Darstellung einer flexiblen Datenverwaltung für polygonzentrierte Renderer.

 

Bei der Programmierung von 3D-Szenen ist es notwendig, Daten auf die eine oder andere Art zu organisieren. Obwohl es viele verschiedene Methoden gibt, 3D Umgebungen computerunterstützt zu simulieren (polygonzentrierte Scanlinerenderer, Raytracer und Voxelszenarien, um die drei Bekanntesten zu nennen), konzentriert sich dieser Artikel vor allem auf Polygonrenderer, die von aktueller Consumergrafikhardware am Besten unterstützt werden.

Im Speziellen geht es um das Objektmodell von BaseGraph, in dem versucht wird OpenGL Funktionalität möglichst flexibel zu kapseln, bzw. dessen Erweiterung in Carad, die fähig ist, beinahe beliebige Objekte zu verwalten.

Eine der Prämissen von BaseGraph ist es, dem Anwender einerseits nicht vorzuschreiben, wie eine bestimmter Effekt implementiert wird, andererseits sollen mit möglichst wenigen Befehlen komplexe Szenarien erstellt werden können – dass sich beides widerspricht, ist leicht einsichtig – die Philosophie von BaseGraph lautet daher, dass komplexe Funktionalität zwar vorhanden ist und genutzt werden kann - aber nicht muss. Um eigene Objektstrukturen in den BaseGraph Szenegraphen einzubinden, reicht es aus, Objekte von der Basisklasse Obj abzuleiten und mit eigener Funktionalität zu befüllen – wobei der Term Objekt durchaus weit gefasst ist, und z.B. auch die gesamte „Welt“ einer Simulation umfassen kann.

Jede Entität, sichtbar oder nicht, die eine bestimmte Position im Raum einnimmt, wird als Objekt definiert und sollte von Obj abgeleitet werden, um möglichst leicht in BaseGraph integriert werden zu können.

Obj selbst ist dabei eher von abstrakter Natur – schließlich soll der Anwender in der Art der Realisierung von Entitäten nicht eingeschränkt sein – und beinhält folgende Eigenschaften:

objHandle

Eindeutiger Bezeichner

objX, objY, objZ

Position im Raum

objXS, objYS, objZS

Skalierungsfaktoren

objRMatrix

Rotationsmatrix

objMin, objMax, objCent

Minimum, Maximum und Mittelpunkt

objBSRad

Radius der kleinsten umgebenden Kugel

objChildren

Anzahl der Kindobjekte

objChild

Zeiger auf Kindobjekttabelle

objParent

Zeiger auf Elternobjekt

objTransparency

Aktivierung spezieller Behandlung (Sortierung) durchsichtiger Objekte

objVisible

Sichtbarkeit

objFrustumTest

Testen gegenüber dem Frustum. Manche unsichtbaren Objekte, die Statechanges für ihre Kindobjekte beinhalten sollten z.B. nicht gegen das Frustum geclippt werden

objShadowState

Aktivierung speziellen Codes für Schattenwurf, nur sinnvoll in Verbindung mit objMesh

objMatrix

Transformationsmatrix des Objektes, wird intern berechnet

objScaleFactor

Größter Skalierungsfaktor des Objektes, wird intern berechnet

objMesh

Verwaltung von Dreiecksdaten für Kollisionserkennung und Schattenwurf, optional

objDistance

Distanz des Objektes vom Betrachter, wird intern für Sortierungen berechnet

objOnBeforeDrawObj

Callback bevor Objekt gezeichnet wird

objOnAfterDrawObj

Callback nachdem Objekt gezeichnet wurde

objJoints

Anzahl der „Verbindungsgelenke“ eines Objekts

objJoint

Zeiger auf Tabelle der Verbindungsgelenke

objAnimations

Anzahl der Animationen eines Objekts

objAnimation

Zeiger auf Animationstabelle

objStaticJoints

Information ob Verbindungsgelenke bei Zerstörung des Objektes freigegeben werden sollen

objStaticAnimations

Information ob Animationen bei Zerstörung des Objektes freigegeben werden sollen

Name

Name des Objektes, steht zur freien Verwendung

Tag

Ein 32 Bit Integer, steht zur freien Verwendung

Data

Ein Zeiger, steht zur freien Verwendung

Selbstverständlich ist es nicht notwendig, ein Objekt etwa mit einem Mesh, Animationen oder Verbindungsgelenken zu versehen, allerdings erweisen sich diese Eigenschaften als recht häufig benötigt, sodass Verweise darauf bereits in der grundlegenden Objektstruktur verankert wurden.

Wer sich die gekapselten Daten genau ansieht, wird bemerken, dass keinerlei Informationen vorliegen, wie das Objekt nun tatsächlich aussieht (das Mesh ist nur optional und enthält, wenn vorhanden, reine Positionsdaten, die z.B. auch nur ein vereinfachtes Objektmodell enthalten können) – dies ist auch so beabsichtigt, als sich ein Obj für sich gesehen gar nicht darstellen kann, erst davon abgeleitete Objekte haben dann die Informationen, die für eine tatsächliche Darstellung notwendig sind, die im einfachsten Fall aus einer Aneinanderreihung von OpenGL Kommandos in der (in Obj abstrakten) Routine DrawObj bestehen.

Nun mag die Obj Klasse zwar recht flexibel sein, für die tatsächliche Verwaltung beliebiger Objekte zur Laufzeit, wie es etwa in Carad notwendig ist, reicht sie aber bei Weitem nicht aus, da sie eben keine Informationen über das gekapselte Objekt enthält.

Carad instanziiert daher nur die von Obj abgeleitete Klasse Body (nicht in BaseGraph enthalten, da nur für die Entwicklung von 3D-Editoren interessant), bzw. von Body abgeleitete Klassen. Body erfüllt dabei drei Aufgaben:

  1. Kapselung einer beliebigen Aneinanderreihung von OpenGL Primitiven und Shadern
  2. Metaobjektinformationen für Carad Primitive (etwa die Parameter einer Kugel für eine Sphere)
  3. Wrapper für spezielle Objekte wie Evaluatoren, Frame- und Boneanimationen

Interessant für eine eigene Implementation einer ähnlichen Klasse ist dabei Punkt 1., da die Verwaltung von OpenGL Primitiven in Carad erst kürzlich revidiert und neu strukturiert wurde.

Ursprünglich (bis Version 1.2) konnte der Mcad, der Vorläufer von Carad, nur statische Objekte erstellen, die direkt als OpenGL Kommandos realisiert wurden und vom Anwender im erstellten Programm selbst animiert werden mussten, falls eine Animation gewünscht war. Nachdem OpenGL Primitive recht einfach gehalten sind – man gibt an, um was es sich handelt (GL_TRIANGLES, GL_QUADS, GL_QUAD_STRIP, …) und sendet die entsprechenden Schnittpunktdaten, wurden diese in Mcad genau so verwaltet. Der Nachteil dieser Methode ist, dass es recht viele identische Schnittpunkte gibt.

Inzwischen kann Carad aber nicht nur direkte OpenGL Kommandos erstellen, sondern Objekte auch als Vertex Arrays (mit Erweiterung durch VBOs) und Frame- und Boneanimationen speichern. Diese Funktionalität, die doch ziemlich unterschiedliche Datenstrukturen erfordert, sowie die Möglichkeit Schnittpunkte und deren Eigenschaften direkt zu berechnen, zu optimieren und später eventuell sogar einen „echten“ Schnittpunkt- und Polygoneditor einzubauen, setzen eine etwas differenziertere Betrachtungsweise der gekapselten Daten voraus.

Gesucht ist also eine Datenstruktur, die einerseits möglichst wenig Platz braucht, andererseits möglichst schnell in möglichst viele unterschiedliche „Formen“ umberechnet werden kann, ohne dabei aufwendigerweise doppelte Schnittpunkte entfernen zu müssen.

Diese Formen sind:

  1. Dreiecksdaten eines Meshes: Schnittpunkte kommen genau einmal vor und werden als Dreieckslisten indiziert
  2. Dreiecksdaten eines Vertex Arrays / VBOs alle Kombinationen von Schnittpunkten und Schnittpunkteigenschaften kommt genau einmal vor und werden als Dreieckslisten indiziert. OpenGL unterstützt keine unterschiedlichen Indextabellen für (z.B.) Normalvektoren und Texturkoordinaten.
  3. FrameAnimationen und BoneAnimationen, die Bedingungen sind die selben wie für Vertex Arrays
  4. OpenGL Kommandos (zumeist in einer Displayliste gekapselt). Sämtliche Schnittpunkte und Schnittpunkteigenschaften werden direkt als OpenGL Kommandos gesendet. Indizierung findet keine statt.

Um das Ganze ein wenig zu illustrieren, werden verschiedene Möglichkeiten dargstellt, das folgende Viereck zu realisieren (Orientierung erfolgt im Uhrzeigersinn)

Im Folgenden steht:

V für Vertex (ein Schnittpunkt mit drei Komponenten)
N für Normale (eine Normale mit drei Komponenten)
T für Texturkoordinate (eine Texturposition mit zwei Komponenten)
I für Index in glDrawElements
VI für Index in Schnittpunkttabelle
NI für Index in Normalentabelle
TI für Index in Texturkoordinatentabelle

 

OpenGL Kommandos, normal

glBegin(GL_TRIANGLES)
glNormal3f(0,1,0)
glTexCoord2f(0,0)
glVertex3f(0,0,0)
glNormal3f(0,1,0)
glTexCoord2f(1,0)
glVertex3f(1,0,0)
glNormal3f(0,1,0)
glTexCoord2f(1,1)
glVertex3f(1,1,0)

glNormal3f(0,1,0)
glTexCoord2f(1,1)
glVertex3f(1,1,0)
glNormal3f(0,1,0)
glTexCoord2f(0,1)
glVertex3f(0,1,0)
glNormal3f(0,1,0)
glTexCoord2f(0,0)
glVertex3f(0,0,0)
glEnd

 

OpenGL Kommandos, optimiert (macht BaseGraph automatisch)

glBegin(GL_TRIANGLES)
glNormal3f(0,1,0)
glTexCoord2f(0,0)
glVertex3f(0,0,0)
glTexCoord2f(1,0)
glVertex3f(1,0,0)
glTexCoord2f(1,1)
glVertex3f(1,1,0)

glVertex3f(1,1,0)
glTexCoord2f(0,1)
glVertex3f(0,1,0)
glTexCoord2f(0,0)
glVertex3f(0,0,0)
glEnd

 

Schnittpunktdaten für glDrawArrays

V: (0,0,0)(1,0,0)(1,1,0)(1,1,0)(0,1,0)(0,0,0)
N: (0,1,0)(0,1,0)(0,1,0)(0,1,0)(0,1,0)(0,1,0)
T: (0,0)(1,0)(1,1)(1,1)(0,1)(0,0)

 

Schnittpunktdaten und Indizes für glDrawElements

V: (0,0,0)(1,0,0)(1,1,0)(0,1,0)
N: (0,1,0)(0,1,0)(0,1,0)(0,1,0)
T: (0,0)(1,0)(1,1)(0,1)

I: 0,1,2, 2,3,0

 

Schnittpunktdaten und Indizes intern von Body verwaltet - diese können leicht auf jede der obigen Strukturen umgemünzt werden

V: (0,0,0)(1,0,0)(1,1,0)(0,1,0)
N: (0,1,0)
T: (0,0)(1,0)(1,1)(0,1)

VI: 0,1,2, 2,3,0
NI: 0,0,0, 0,0,0
TI: 0,1,2, 2,3,0

 

Wie man sieht, kommt nur im letzten Beispiel jeder Schnittpunkt und Schnittpunktparameter nur genau einmal vor, als optimal für die Kapselung sämtlicher OpenGL Funktionalität haben sich dabei folgende Eigenschaften gezeigt:

bVerts

Anzahl der Schnittpunkte

bVert

Zeiger auf Schnittpunkttabelle

bSendNorm

Info ob Normalvektoren gesendet werden

bNorms

Anzahl der Normalvektoren

bNorm

Zeiger auf Normalvektorentabelle

bSendColor

Info ob Schnittpunktfarben gesendet werden

bColors

Anzahl der Schnittpunktfarben

bColor

Zeiger auf Schnittpunktfarbentabelle

bColorComponents

Anzahl der Farbkomponenten (3 oder 4)

bSendSecondaryColor

Info ob Zweitschnittpunktfarben gesendet werden

bSecondaryColors

Anzahl der Zweitschnittpunktfarben

bSecondaryColor

Zeiger auf Zweitschnittpunktfarbentabelle

bSendFogCoord

Info ob Nebelkoordinaten gesendet werden

bFogCoords

Anzahl der Nebelkoordinaten

bFogCoord

Zeiger auf Nebelkoordinatentabelle

bSendTexCoord

Info ob Texturkoordinaten gesendet werden

bTexUnits

Anzahl der Textureinheiten (bis zu 32)

bTexUnit

Befüllte Textureinheiten (GL_TEXTURE0, GL_TEXTURE1, ..)

bTexCoords

Tabelle mit Anzahl der Texturkoordinaten pro Einheit

bTexCoord

Tabelle der Zeiger auf Texturkoordinatentabellen

bTexComponents

Tabelle der Texturkomponenten pro Einheit (1 bis 4)

bSendAttrib

Info ob Schnittpunktattribute gesendet werden

bAttribIndices

Anzahl der befüllten Attribute (bis zu 32)

bAttribIndex

Befüllte Attribute (0, 1, ...)

bAttribs

Tabelle mit Anzahl der Attribute pro Einheit

bAttrib

Tabelle der Zeiger auf Attributtabellen

bAttribComponents

Tabelle der Attributkomponenten (1 bis 4)

bPrimitives

Anzahl der Primitive

bPrimitive

Zeiger auf Primitivetabelle

Ein Primitive ist dabei eine Liste von OpenGL Kommandos, die zwischen glBegin und glEnd stehen kann, wird dieses als Befehlsstrom realisiert, werden tatsächlich nur diejenigen Kommandos gesendet, die sich tatsächlich ändern. Haben also mehrere aufeinanderfolgende Schnittpunkte die selbe Normale, wird die Normale nur einmal beim ersten Schnittpunkt gesendet, da sich OpenGL den entsprechenden State „merkt“.

Die Primitivestruktur schaut dabei folgendermaßen aus:

Shaders

Anzahl der Shader

Shader

Shadertabelle

Mode

Primitivetyp (z.B. GL_QUADS)

Points

Anzahl der Schnittpunkte im Primitive

VertInd

Points Indizes in Schnittpunkttabelle

NormInd

Points Indizes in Normalvektortabelle, NIL wenn nicht definiert

ColorInd

Points Indizes in Farbtabelle, NIL wenn nicht definiert

SecondaryColorInd

Points Indizes in Zweitfarbtabelle, NIL wenn nicht definiert

FogCoordInd

Points Indizes in Nebelkoordinatentabelle, NIL wenn nicht definiert

TexCoordInd

Points Indizes in Texturkoordinatentabellen, 32 Indextabellen für ebensoviele Texturkoordinatentabellen, NIL wenn nicht definiert

AttribInd

Points Indizes in Attributtabellen, 32 Indextabellen in ebensoviele Attributtabellen, NIL wenn nicht definiert

Vorteile gegenüber unindizierten Daten sind:

1)      keine doppelten Schnittpunkte oder Schnittpunktparameter
2)      leichte Umwandlung eines Primitivetyps in einen anderen (z.B. GL_TRIANGLES nach GL_TRIANGLE_STRIP)
3)      Schnittpunktparameter können reduziert oder ganz freigegeben werden, ohne dass dies andere Schnittpunktparameter beeinflusst (z.B. ist es den Texturkoordinaten relativ egal, was mit den Normalen passiert – bei einer zusammengesetzten Schnittpunktstruktur könnte der Speicher für Normale nicht einfach freigegeben werden)
4)      Weitere Schnittpunktparameter können leicht hinzugefügt werden, ohne dass sich an bestehenden Code- und Datenstrukturen viel ändert
5)      Berechnung von Normalvektoren ist beinahe „gratis“

Der einzige Nachteil dabei ist die etwas umständlichere Verwaltung der ganzen Listen, sowie das Sicherstellen, dass ein neu hinzugefügter Schnittpunkt oder Schnittpunktparameter nicht bereits vorhanden ist.

Ich hoffe, dass dieser Text bei der Implementierung einer eigenen Objektstruktur die eine oder andere Orientierungshilfe bietet,
viel Spaß beim Programmieren,