Immer die Übersicht behalten

OpenGL: GUI-Rendering und Textdarstellung
Kommentare

Der Artikel befasst sich mit dem Design und der Darstellung von grafischen Benutzeroberflächen. Ferner werden Bewegungen des Mauszeigers (Cursors) im 3-D-Raum, die Selektion von 3-D-Objekten in der Spielewelt (Object Picking) und der Einsatz von Geometry Instancing demonstriert.

Den Stellenwert einer gut durchdachten Spielsteuerung und einer intuitiv zu bedienenden grafischen Benutzeroberfläche bzw. Schnittstelle (Graphical User Interface bzw. GUI) sollte man nicht unterschätzen. Eines dürfte klar sein – miserable Spiele lassen sich auch durch ein erstklassiges Benutzerinterface nicht mehr retten, obwohl jenes natürlich über viele spielerische Mängel hinwegtäuschen kann. Andererseits kann eine schlecht geplante Schnittstelle für ein ansonsten gutes Spiel unter Umständen den Todesstoß bedeuten; denn nichts ist für einen Spieler frustrierender als ein umständlich oder gar unmöglich zu handhabendes Spiel. Findet ein Spieler keinen Zugang zum Spielgeschehen, wird besagtes Spiel oftmals bereits nach wenigen Minuten beendet – und die Suche nach etwas Neuem geht weiter. Bedenken Sie, in Zeiten der Free-to-Play-Titel ist das nächste Spiel oftmals nur wenige Mausklicks entfernt. Es bleibt daher nur wenig Zeit, um einen neuen Spieler zu gewinnen, geschweige denn, ihn langfristig zu binden und ihn im weiteren Spielverlauf zum Erwerb käuflicher Inhalte zu motivieren.

Quellcodebeispiele
Die Listings 2, 6 bis 11, 14 bis 15 und 18 finden Sie hier.

Die Funktionsweise einer (grafischen) Benutzerschnittstelle ist uns allen aus dem täglichen Umgang mit Smartphones, Tabletcomputern sowie mit den Windows-Betriebssystemen und Programmen bestens vertraut. Während bei der Windows-Oberfläche jedoch die Funktionalität und Benutzerfreundlichkeit im Vordergrund steht, dreht sich bei der Konzeption eines Spiele-GUI alles um das Thema Spielspaß. Im Prinzip umfasst eine solche Benutzerschnittstelle alle Dinge, die für ein unbeschwertes und erfolgreiches Spielvergnügen erforderlich sind. Hierzu zählen nicht nur Textboxen oder Menüs, sondern auch Head-up-Displays, die Soundkulisse samt Sprachausgabe, sowie Gameplay-relevante Orte, wie virtuelle Kommandostände oder Bars und Gegenstände, wie Med-Kits, Munition, Waffen, Nachtsichtgeräte oder Auslösevorrichtungen für Fallen. Es ist wichtig, dass die grafische Schnittstelle optisch ansprechend sowie stimmig gestaltet ist, ohne jedoch zu sehr vom eigentlichen Spielgeschehen abzulenken. Alle spielrelevanten Informationen müssen übersichtlich angezeigt bzw. mittels Sprachausgabe ausgegeben werden, sodass der Spieler stets korrekt reagieren und spielentscheidende Fehleinschätzungen vermeiden kann. Ein Spieler sollte niemals lange überlegen müssen, wie das Spiel zu steuern ist oder was er als Nächstes zu tun hat. Aus diesem Grund muss eine gut durchdachte Benutzeroberfläche nicht nur selbsterklärend sein. Der Spieler sollte darüber hinaus, zur Not auch ungefragt, mit wichtigen Hinweisen bei seinem weiteren Handeln unterstützt werden. Tritt in einer Space-Combat/Trading-Simulation das Spielerraumschiff beispielsweise in den Orbit eines bewohnten Planeten ein, dann sollte die Schiffs-KI automatisch nachfragen, ob ein Kommunikationskanal zum Handelszentrum geöffnet oder zwecks Reparaturen und Nachrüstung Kontakt mit der lokalen Schiffswerft aufgenommen werden soll. Oder betrachten wir ein Abenteuerspiel: Sobald wir nach einer längeren Reise ein bislang unbekanntes Dorf erreichen, sollten uns die Dorfbewohner in Abhängigkeit von unserer momentanen Verfassung (Gesundheit bzw. Zustand der Ausrüstung) ungefragt den Weg zur Schmiede, zu einem Heilkundigen oder zum Dorfkrug weisen.

Untermenüs und mehrstufige Benutzereingaben sollten auf alle Fälle vermieden werden. Eine komplizierte Handhabung lenkt alle Aufmerksamkeit auf die Benutzeroberfläche, während das eigentliche Spielgeschehen im schlimmsten Fall zur reinen Nebensache verkommt. Wo es auf Reaktionszeit ankommt, sind einfache Tastaturbefehle (Hotkeys) unverzichtbar. Strategiespiele sollten ausschließlich mit der Maus gespielt werden können, sich jederzeit pausieren lassen und auch im Pausenmodus auf Benutzereingaben reagieren. Auf diese Weise verlieren auch Spielanfänger niemals die Übersicht.

Anforderungen an ein spieletaugliches GUI-Framework

Auch wenn es schwer fällt, bei der Arbeit an einem spieletauglichen GUI-Framework sollte man sich nicht zu sehr von den allseits bekannten Windows-, Android- oder iOS-Oberflächen ablenken lassen. Und schon gar nicht sollte man der Versuchung erliegen, irgendetwas Vergleichbares nachprogrammieren zu wollen. Zeit ist nun einmal ein kostbares Gut – und genau aus diesem Grund sollte das Framework so konzipiert sein, dass sich bei Bedarf jederzeit schnell und unkompliziert weitere GUI-Elemente implementieren lassen. In unserer OpenGL-Grafikbibliothek ist die CGUI-Klasse für die Verwaltung aller GUI-Elemente, Fonts, Text-Strings sowie Shader-Programme verantwortlich und stellt darüber hinaus sowohl Methoden für die Darstellung wie auch für die Interaktion mit allen GUI-Elementen bereit. Bis zum jetzigen Zeitpunkt wurden die folgenden GUI-Elemente implementiert:

• Text Areas (CGUI_TextArea)
• Overlays (CGUI_Overlay)
• Buttons (CGUI_Button)
• horizontale und vertikale Schieberegler (CGUI_HorizontalSlider, CGUI_VerticalSlider)
• horizontale und vertikale Indikatoren bzw. Fortschrittsbalken (CGUI_HorizontalIndicator, CGUI_VerticalIndicator)
• beliebig texturierbare Bereiche (CGUI_TextureArea)

Da die Handhabung einzelner GUI-Elemente mehr als nur unpraktisch ist, bietet es sich an, mehrere solcher Elemente zu einem so genannten GUI-Objekt zusammenzufassen. Im Rahmen des Demoprogramms OpenGL-Framework-Demo 36 nutzen wir in diesem Zusammenhang die CGUI_Object-Klasse, um ein Energie-Management-Panel für ein Raumschiff, ein Schiffswerft-Panel sowie einen einfachen Kompass darzustellen:

• Kompass (Abb. 1, Demoprogramm „GUI_Compass.txt“ auf [1]):

#Used Text Areas:# 4
    #Id-List:# 0, 1, 2, 3
#Used Overlays:# 2
    #Id-List:# 0, 1
#Used Buttons:# 0
#Used Texture Areas:# 0
#Used horizontal Sliders:# 0
#Used vertical Sliders:# 0
#Used horizontal Indicators:# 0
#Used vertical Indicators:# 0
GUI-Element mit Texturrotation (drehbare Kompassnadel)

Abb. 1: GUI-Element mit Texturrotation (drehbare Kompassnadel)

• Schiffswerft-Panel (Abb. 2 bzw. Abb. 3, Demoprogramm „GUI_SpaceShipConstruction.txt“ auf spieleprogrammierung.net):

#Used Text Areas:# 6
    #Id-List:# 11, 12, 13, 14, 15, 16
#Used Overlays:# 3
    #Id-List:# 6, 7, 8
#Used Buttons:# 0
#Used Texture Areas:# 0
#Used horizontal Sliders:# 4
    #Id-List:# 0, 1, 2, 3
#Used vertical Sliders:# 0
#Used horizontal Indicators:# 0
#Used vertical Indicators:# 0
GUI-Darstellung

Abb. 2: GUI-Darstellung

Auf eine 3-D-Fläche projizierte GUI-Elemente

Abb. 3: Auf eine 3-D-Fläche projizierte GUI-Elemente

• Energie-Management-Panel eines Raumschiffs (Abb. 4 bzw. Abb. 5, Demoprogramm „GUI_EnergySystems.txt“ auf spieleprogrammierung.net):

#Used Text Areas:# 7
    #Id-List:# 4, 5, 6, 7, 8, 9, 10
#Used Overlays:# 4
    #Id-List:# 2, 3, 4, 5
#Used Buttons:# 0
#Used Texture Areas:# 0
#Used horizontal Sliders:# 0
#Used vertical Sliders:# 0
#Used horizontal Indicators:# 4
    #Id-List:# 0, 1, 2, 3
#Used vertical Indicators:# 3
    #Id-List:# 0, 1, 2
GUI-Darstellung (OpenGL-Framework-Demoprogramm 36)

Abb. 4: GUI-Darstellung (OpenGL-Framework-Demoprogramm 36)

Auf eine 3-D-Fläche projizierte GUI-Elemente

Abb. 5: Auf eine 3-D-Fläche projizierte GUI-Elemente

Bei der Darstellung der einzelnen GUI-Elemente kommen vier unterschiedliche Shader-Programme zum Einsatz:

• Shader-Programm Nr. 1 (CGLSLShader* GUIShader) ermöglicht die Darstellung von statischen GUI-Elementen, deren Größen und Positionen zur Laufzeit unveränderlich sind.
• Shader-Programm Nr. 2 (CGLSLShader* GUIExtShader) ermöglicht die Darstellung von dynamischen GUI-Elementen, die sich zur Laufzeit beliebig skalieren und positionieren lassen.
• Shader-Programm Nr. 3 (CGLSLShader* GUIExtTextureRotationShader) ermöglicht die Darstellung von Analoginstrumenten (Tachometer, Drehzahlmesser, Kompass etc.) durch Texturrotation im Fragment Shader. Als Beispiel sei auf die drehbare Kompassnadel in Abbildung 1 verwiesen.
• Shader-Programm Nr. 4 (CGLSLShader* GUIExtInstancingShader) ermöglicht die Darstellung von GUI-Elementen mithilfe von Geometry Instancing. Auf diese Weise lassen sich die in den Abbildungen 4 und 5 dargestellten Energieindikatorwerte (Energiebalken) mit einem einzigen Draw Call rendern.

Da die Darstellung einer zweidimensionalen Benutzeroberfläche zugegebenermaßen etwas altbacken – um nicht zu sagen langweilig – wirkt, ist es mithilfe unseres GUI-Frameworks ein Leichtes, alle GUI-Elemente und Texte in ein separates Render Target (eine Textur) zu zeichnen, um dieses dann im weiteren Verlauf des Renderings für die Darstellung dreidimensionaler GUI- und Schrifteffekte zu verwenden:

• Zusätzliches Render Target (Textur) festlegen:

GUI->Set_Additional_RenderTarget(EffectTexture, EffectTextureResolutionWidth,
     EffectTextureResolutionHeight, EffectFrameBuffer, EffectRenderBuffer);

• GUI-Elemente samt Text in das zuvor festgelegte Render Target zeichnen:

GUI->Start_RenderingToTexture(&BackgroundColor);
[GUI-Elemente und Texte rendern]
GUI->Stop_RenderingToTexture();

• Bei der sich anschließenden Darstellung der gewünschten dreidimensionalen GUI- und Schrifteffekte müssen wir die zuvor erstellte Textur in einem letzten Schritt auf ein geeignetes 3-D-Modell mappen. Für die in den Abbildungen 3 und 5 gezeigten perspektivischen GUI-Objekte haben wir hierfür beispielsweise ein einfaches Vertex-Quad verwendet, welches wir schräg zur Kamera ausgerichtet haben.

Bewegung des Mauszeigers im 3-D-Raum

Entgegen aller Touchscreeneuphorie ist die Maus in Verbindung mit Tastaturbefehlen bei der Steuerung von PC-Spielen nach wie vor unübertroffen. In 2-D-Spielen nimmt die Berechnung der aktuellen Cursorposition unter Berücksichtigung der Mausbewegungen mithilfe der zugehörigen DirectInput-Variablen dims2.lX und dims2.lY gerade einmal zwei Codezeilen in Anspruch:

// Cursorposition aktualisieren:
g_CursorPosition.x += 0.05f*dims2.lX;
g_CursorPosition.y -= 0.05f*dims2.lY;

Im Vergleich dazu ist die Positionsberechnung in 3-D-Spielen erheblich komplizierter, da sich bei jeder Blickrichtungsänderung die Ausrichtung der Kameraachsen und damit verbunden die Cursorposition verändert – und zwar unabhängig davon, ob die Maus währenddessen bewegt wurde oder nicht. Möchte man jedoch erreichen, dass die Position des Mauszeigers auf dem Bildschirm bei einer Blickrichtungsänderung unverändert bleibt, dann muss man, wie nachfolgend gezeigt wird, die Cursorposition relativ zu den Kameraachsen berechnen:

float dot1 = D3DXVec3Dot(&g_CursorPosition, &g_CameraHorizontalLastFrame);
float dot2 = D3DXVec3Dot(&g_CursorPosition, &g_CameraVerticalLastFrame);

g_CursorPosition = g_CameraViewDirection +
                   g_CameraVertical*(dot2-0.002f*dims2.lY) +
                   g_CameraHorizontal*(dot1+0.002f*dims2.lX);

Versuchen wir einmal, diesen mathematischen Trick anhand von einigen einfachen Fallbeispielen nachzuvollziehen. Für den Fall, dass sich der Mauszeiger genau in der Bildschirmmitte befindet, ist der Cursorpositionsvektor g_CursorPosition unabhängig von der momentanen Blickrichtung sowohl senkrecht zu der horizontalen wie auch zu der vertikalen Kameraachse orientiert. Mit anderen Worten, der Positionsvektor entspricht dem Kamerablickrichtungsvektor g_CameraViewDirection, und die beiden Skalarprodukte dot1 sowie dot2 haben jeweils einen Wert von null. Bewegt man nun die Maus nach rechts bzw. nach links, dann wandert der Mauszeiger auf dem Bildschirm entlang der horizontalen Kameraachse von der Bildschirmmitte hin zum rechten bzw. linken Bildschirmrand. Der Positionsvektor berechnet sich in beiden Fällen wie folgt:

g_CursorPosition = g_CameraViewDirection +
                   g_CameraHorizontal*(dot1+0.002f*dims2.lX);

Würde man die Maus stattdessen auf- oder abwärts bewegen, dann wandert der Cursor entlang der vertikalen Kameraachse hin zur oberen bzw. zur unteren Bildschirmbegrenzung. Im Unterschied zu den vorangegangenen Beispielen erfolgt die Berechnung des zugehörigen Positionsvektors in diesen Fällen mithilfe der vertikalen Kameraachse:

g_CursorPosition = g_CameraViewDirection +
                   g_CameraVertical*(dot2-0.002f*dims2.lY);

Kommen wir nun zum zweiten Problem. Zum jetzigen Zeitpunkt hindert einen Spieler nichts daran, den Mauszeiger aus dem sichtbaren Bildausschnitt hinauszubewegen. Doch was dann? Wie soll man den Cursor wiederfinden, sobald er einmal vom Bildschirm verschwunden ist? Es gibt natürlich einen „schmutzigen“ Trick, um das Problem zu lösen – mit einem einfachen Klick auf die mittlere Maustaste könnte man einen Mauszeiger auf Abwegen jederzeit wieder in der Bildschirmmitte positionieren. Weitaus eleganter ist es natürlich, einen solchen „Unfall“ von vornherein auszuschließen. Hierbei wird nach einer Mausbewegung die neue Position des Mauszeigers, wie in Listing 1 gezeigt, zunächst nur testweise ermittelt. Stellt man dann im zweiten Schritt fest, dass sich auch die neue Position noch innerhalb des sichtbaren Bereichs befindet, kann man dem Cursor im letzten Schritt die neue Position zuweisen. Sollte dies nicht der Fall sein, wird die Mausbewegung einfach ignoriert.

CursorTestPosition = g_CameraViewDirection +
                     g_CameraVertical*(dot2-0.002f*dims2.lY) +
                     g_CameraHorizontal*(dot1+0.002f*dims2.lX);

float tempCursorScreenX, tempCursorScreenY;
float tempCursorViewportX, tempCursorViewportY;

/* Bildschirm- und Viewportkoordinaten der Cursortestposition berechnen
   (siehe hierzu Listing 2): */
Calculate_Screen_And_Viewport_Coordinates(&tempCursorScreenX,
&tempCursorScreenY, &tempCursorViewportX, &tempCursorViewportY,
&CursorTestPosition, &g_ViewProjectionMatrix, g_screenwidth, g_screenheight);

if(tempCursorViewportX > -0.975f && tempCursorViewportX < 0.975f &&
   tempCursorViewportY > -0.975f && tempCursorViewportY < 0.975f)
{
  // neue Cursorposition liegt innerhalb der Bildschirmbegrenzungen
  //=> Cursor kann neu positioniert werden:
  g_CursorPosition = CursorTestPosition;
  g_CursorScreenX = tempCursorScreenX;
  g_CursorScreenY = tempCursorScreenY;
  g_CursorViewportX = tempCursorViewportX;
  g_CursorViewportY = tempCursorViewportY;
}
Zusammenhang zwischen Screen- (Rot) und Viewport-Koordinaten (Schwarz)

Abb. 6: Zusammenhang zwischen Screen- (Rot) und Viewport-Koordinaten (Schwarz)

Der sichtbare Bildschirmbereich kann nun, wie in Abbildung 6 gezeigt, mithilfe von zwei unterschiedlichen Koordinatensystemen beschrieben werden. Arbeitet man mit den so genannten Bildschirmkoordinaten (Screen Coordinates), so ordnet man per Definition der linken oberen Bildschirmecke das Koordinatenpaar (0, 0) und der rechten unteren Bildschirmecke die Koordinaten (Bildschirmauflösung Breite, Bildschirmauflösung Höhe) zu (z. B. 1024, 768). Die Viewport-Koordinaten werden hingegen unabhängig von der Bildschirmauflösung auf folgende Weise definiert:

• -1 / 1: linke obere Bildschirmecke
• 1 / -1: rechte untere Bildschirmecke
• 0 / 0: Bildschirmmitte

Die Bildschirm- und Viewport-Koordinaten können mithilfe der nachfolgend skizzierten Formeln ineinander umgerechnet werden:

• Umrechnung von Bildschirm- in Viewport-Koordinaten:

ViewportX = 2.0f*ScreenX/Screenwidth - 1.0f;
ViewportY = 1.0f - 2.0f*ScreenY/Screenheight;

• Umrechnung von Viewport- in Bildschirmkoordinaten:

ScreenX = 0.5f*(ViewportX + 1.0f)*Screenwidth;
ScreenY = 0.5f*(1.0f - ViewportY)*Screenheight;

Wie genau sich jetzt für einen beliebigen 3-D-Vektor – also beispielsweise für die Position des Mauszeigers – die zugehörigen Viewport- und Bildschirmkoordinaten berechnen lassen, können Sie anhand von Listing 2 nachvollziehen. Für die Berechnung der betreffenden Koordinaten sind sowohl die View-(Sicht) wie auch die Projektionsmatrix erforderlich. Beide Matrizen übergeben wir jedoch nicht einzeln an die Calculate_Screen_And_Viewport_Coordinates()-Funktion, sondern in Form einer kombinierten View-Projection-Matrix:

g_ViewProjectionMatrix = g_matView*g_matProj;

Darstellung des Mauszeigers im 3-D-Raum

Definition der für die GUI-Darstellung verwendeten Vertex-Quads

Abb. 7: Definition der für die GUI-Darstellung verwendeten Vertex-Quads

Für die Darstellung des Mauszeigers verwenden wir ein einfaches, in der xy-Ebene definiertes, texturiertes Vertex-Quad (Abb. 7). In einem 2-D-Spiel, bei welchem sich das komplette Spielgeschehen ebenfalls ausschließlich in der xy-Ebene abspielt, müssen wir uns in diesem Zusammenhang lediglich Gedanken über die Positionierung und Skalierung des Cursors machen. In einer dreidimensionalen Spielewelt wird die Angelegenheit jedoch ungleich komplizierter, da wir sicherstellen müssen, dass ein Spieler unabhängig von seiner momentanen Blickrichtung stets frontal auf das Vertex-Quad blickt. In diesem Zusammenhang kommt die in Listing 3 skizzierte Positioning_2DObject()-Funktion zum Einsatz, mit deren Hilfe sich die für die korrekte Vertex-Quad-Ausrichtung benötigte Transformationsmatrix berechnen lässt. Damit Sie die Arbeitsweise dieser Funktion besser nachvollziehen können, versuchen wir einmal, die einzelnen Berechnungsschritte anhand zweier einfacher Beispiele zu veranschaulichen. Ausgangspunkt für die anstehenden Berechnungen ist die so genannte Kameratransformationsmatrix (g_CameraTransformationMatrix), welche die momentane Orientierung der Kamera beschreibt und in dieser Eigenschaft der inversen View-Matrix (g_matView) entspricht.

Fall 1 – Blickrichtung = +z-Achse: Blickt der Spieler entlang der positiven z-Achse, dann entsprechen sowohl die View-Matrix als auch die Kameratransformationsmatrix gleichsam der Einheitsmatrix. Da wir das Vertex-Quad in der xy-Ebene definiert haben, würde der Spieler auch ohne eine zusätzliche Korrektur der Vertex-Quad-Orientierung stets frontal auf den Mauszeiger blicken. Dieser muss demzufolge lediglich korrekt skaliert und positioniert werden.

Fall 2 – Blickrichtung = +x-Achse: Für den Fall, dass der Spieler entlang der positiven x-Achse blickt, entspricht die Kameratransformationsmatrix einer Rotationsmatrix, welche eine 90-Grad-Drehung um die y-Achse beschreibt. Ohne eine Korrektur der Vertex-Quad-Orientierung würde der Spieler seitlich auf den Mauszeiger blicken und diesen demzufolge überhaupt nicht mehr wahrnehmen können. Um dies zu korrigieren, müssen wir das Cursor-Vertex-Quad nicht nur korrekt skalieren und positionieren, sondern es zusätzlich mithilfe der Kameratransformationsmatrix um 90 Grad um die y-Achse drehen – und schon kann man wieder frontal auf den Mauszeiger blicken.

Nach Abschluss dieser Berechnungen müssen wir die gefundene Transformationsmatrix nun noch mit der bereits zuvor erwähnten View-Projection-Matrix (g_ViewProjectionMatrix) multiplizieren und können die resultierende Produktmatrix (matWorldViewProjection) dann im nächsten Schritt an das in Listing 4 skizzierte Vertex-Shader-Programm übergeben:

D3DXMATRIXA16 matWorldViewProjection;
Positioning_2DObject(&matWorldViewProjection, pCursorPositionVector, scale);
matWorldViewProjection = matWorldViewProjection*g_ViewProjectionMatrix;
CursorShader->Set_ShaderMatrixWorldViewProjection(&matWorldViewProjection);
INLINE void Positioning_2DObject(
            D3DXMATRIXA16* pCameraSpaceTransformationMatrix,
            D3DXVECTOR3* pCameraSpacePosition, float &scale)
{
  D3DXMATRIXA16 TranslationMatrix, ScaleMatrix;

  ScaleMatrix = g_identityMatrix;
  TranslationMatrix = g_identityMatrix;

  TranslationMatrix._41 = pCameraSpacePosition->x;
  TranslationMatrix._42 = pCameraSpacePosition->y;
  TranslationMatrix._43 = pCameraSpacePosition->z;
    
  ScaleMatrix._11 = scale;
  ScaleMatrix._22 = scale;
  ScaleMatrix._33 = 1.0f;

  *pCameraSpaceTransformationMatrix = ScaleMatrix*
  g_CameraTransformationMatrix*TranslationMatrix;

  /* Hinweis: Die g_CameraTransformationMatrix entspricht der inversen
  View-Matrix (g_matView) */
}
#version 330
precision highp float;

#define ATTR_POSITION  0
#define ATTR_NORMAL    1
#define ATTR_TEXCOORD0 2
[...]
#define ATTR_TEXCOORD7 9

layout(location = ATTR_POSITION)  in vec4 gs_Vertex;
layout(location = ATTR_NORMAL)    in vec3 gs_Normal;
layout(location = ATTR_TEXCOORD0) in vec4 gs_MultiTexCoord0;
[...]
layout(location = ATTR_TEXCOORD7) in vec4 gs_MultiTexCoord7;

out vec4 gs_TexCoord[8];

// Transformationsmatrix:
uniform mat4 matWorldViewProjection;

void main()
{
  gl_Position    = matWorldViewProjection*gs_Vertex;
  gs_TexCoord[0] = gs_MultiTexCoord0;
}
#version 330
precision highp float;

in  vec4 gs_TexCoord[8];
out vec4 gs_FragColor;

uniform sampler2D CursorTexture;

void main()
{
  vec4 CursorColor = texture(CursorTexture, gs_TexCoord[0].st);

// Alpha-Test – schwarze Texturbereiche sollen durchsichtig erscheinen:
  if(CursorColor.a < 0.01)
  discard;

  gs_FragColor = CursorColor;
}

Selektieren von 3-D-Objekten in der Spielewelt (Object Picking)

Angefangen beim Anklicken eines GUI-Buttons über die Einheiten- und Zielauswahl in einem Strategiespiel bis hin zur Aufnahme neuer Ausrüstungsgegenstände wie Waffen und Munitionsnachschub – zu den wichtigsten Interaktionen zwischen Spieler und Spiel zählt zweifelsohne die Objektauswahl. Das denkbar einfachste Object-Picking-Verfahren können Sie anhand von Listing 6 nachvollziehen. Es besteht in einem simplen Abgleich der Bildschirm- bzw. Viewport-Koordinaten des Mauszeigers mit den Koordinaten des zu selektierenden Spieleobjekts. Da wir hierbei den Kameraabstand des Objekts unberücksichtigt lassen, sind die Einsatzmöglichkeiten dieser Technik stark begrenzt und beschränken sich im Wesentlichen auf Interaktionen mit einzelnen Buttons oder anderen GUI-Elementen, bzw. auf Interaktionen mit den Spieleobjekten eines 2-D-Spiels. Möchte man hingegen „echte“ 3-D-Objekte in einer dreidimensionalen Spielewelt selektieren, so muss man zusätzlich zur Größe dieser Objekte (den Skalierungsfaktoren) immer auch deren aktuelle Kameraabstände mit berücksichtigen. In diesem Zusammenhang besteht unsere Aufgabe darin, den Bildschirmausschnitt zu ermitteln, der vom zu selektierenden Objekt momentan verdeckt wird. Verringert sich der Kameraabstand, dann erstreckt sich das betreffende Objekt über einen immer größeren Bildschirmausschnitt, vergrößert sich der Abstand, so wird dieser Ausschnitt immer kleiner, bis das 3-D-Objekt schließlich nicht mehr selektiert werden kann. Im Rahmen von Listing 7 berechnen wir daher zusätzlich zu den Bildschirmkoordinaten des Objektmittelpunkts (ObjectCenterScreenPosX, ObjectCenterScreenPosY) zunächst einen zweiten Satz von Koordinaten (ScreenPosBorderX, ScreenPosBorderY), der sowohl von der Objektgröße wie auch vom Kameraabstand abhängig ist. Zusammengenommen definieren beide Koordinatensätze den Picking-Bereich eines Spieleobjekts. Als selektiert gilt ein 3-D-Objekt immer dann, wenn sich der Mauszeiger innerhalb des zugehörigen Picking-Bereichs befindet.

Statische GUI-Elemente

Bei der Darstellung sämtlicher GUI-Elemente kommen die in Abbildung 7 skizzierten texturierten Vertex-Quads zum Einsatz. Gemäß Listing 8 werden bei einem statischen GUI-Element die Viewport-Koordinaten der beteiligten Vertices bereits während der Initialisierungsphase im zugehörigen Vertexbuffer gespeichert. Weitere Transformationsberechnungen sind zur Laufzeit demzufolge nicht mehr erforderlich. Das beim Rendering zum Einsatz kommende Vertex-Shader-Programm (Listing 9) muss daher lediglich die Vertex- und Texturkoordinaten der einzelnen Vertices an den in Listing 10 skizzierten Fragment Shader weiterleiten, um so die korrekte Texturierung des betreffenden GUI-Elements zu gewährleisten.

Dynamische GUI-Elemente

Bei der Initialisierung eines für die Darstellung dynamischer GUI-Elemente verwendeten Vertex-Quads speichert man im verwendeten Vertexbuffer anstelle der Viewport-Vertexkoordinaten lediglich die Abstände der Vertices in horizontaler und vertikaler Richtung relativ zum Vertex-Quad-Mittelpunkt ab (Listing 11). Unsere Vertex-Quads werden hierbei implizit in der Bildschirmmitte bei den Viewport-Koordinaten (0,0) positioniert. Die korrekte Positionierung und Skalierung der dynamischen GUI-Elemente erfolgt zur Laufzeit mithilfe des in Listing 12 skizzierten Vertex-Shader-Programms. Verantwortlich für sämtliche Berechnung ist die nachfolgend gezeigte, unscheinbar wirkende Codezeile:

gl_Position = vec4(CenterPosition + ScaleFactors*gs_Vertex.xy, gs_Vertex.zw);

Verzichtet man darüber hinaus auf weitere Textureffekte, können wir bei der Texturierung der statischen und dynamischen GUI-Elemente denselben Fragment Shader (siehe Listing 10) verwenden. Fans von Rennspielen oder Flugsimulationen haben sich sicher dann und wann schon einmal die Frage gestellt, welche Tricks wohl bei der Darstellung animierter Analoginstrumente wie Tachometer oder Drehzahlmesser zum Einsatz kommen könnten. Da wir unsere Vertex-Quads in der xy-Ebene definiert haben, sollte eine Drehung der Vertex-Quad-Eckpunkte mithilfe einer z-Achsenrotationsmatrix eigentlich zum gewünschten Ergebnis führen. Weil jedoch bei einer Rotationstransformation der Viewport-Koordinaten das Seitenverhältnis (Aspect Ratio = screenwidth / screenheight) der eingestellten Bildschirmauflösung unberücksichtigt bleibt, ist das tatsächliche Resultat wenig überzeugend – so würde sich die in Abbildung 1 gezeigte Kompassnadel zwar drehen, gleichzeitig würde sie jedoch in horizontaler Richtung gedehnt und in vertikaler Richtung gestaucht werden. Besagte Darstellungsfehler lassen sich aber gottlob vermeiden, wenn man auf eine Transformation der Vertexkoordinaten verzichtet und stattdessen, wie in Listing 13 demonstriert, lediglich die Texturkoordinaten in Form einer Texturrotation modifiziert. Zu diesem Zweck müssen wir im ersten Schritt zunächst die Texturkoordinaten (Wertebereich 0 bis 1) in kartesische Koordinaten mit einem Wertebereich von -1 bis +1 umrechnen (Hinweis: Die Texturkoordinaten des GUI-Elementmittelpunkts (0,5, 0,5) würden dann dem kartesischen Koordinatenpaar (0, 0) entsprechen):

vec2 XY_Coord = 2.0*gs_TexCoord[0].st-vec2(1.0);

Im zweiten Schritt können wir nun die kartesischen Koordinaten mithilfe einer z-Achsenrotationsmatrix um den GUI-Elementmittelpunkt in die gewünschte Orientierung drehen:

vec2 XY_rotatedCoord = matTextureRotation*XY_Coord;

Im letzten Schritt können wir schließlich die rotierten kartesischen Koordinaten wieder zurück in Texturkoordinaten umrechnen und diese dann für den Texturzugriff verwenden:

vec2 rotatedTextureCoord = 0.5*XY_rotatedCoord+0.5;
vec4 SurfaceColor = texture(Texture, rotatedTextureCoord);

Das Letzte, worüber sich die allerwenigsten Programmieranfänger im Rahmen des GUI- und Textrenderings so ihre Gedanken machen, ist zweifelsohne das Thema Performance. Was kann denn bitte auch an der Darstellung einiger GUI-Elemente schon so problematisch sein? Um diese Frage beantworten zu können, gibt es ein einfaches Rezept: Zählen Sie alle erforderlichen Draw Calls (Render-Aufrufe) zusammen. Je weniger, desto besser. Akut wird das Problem insbesondere bei der Textdarstellung – jedenfalls dann, wenn für jedes Zeichen ein separater Render-Aufruf erforderlich ist. Es genügt dann schon ein mäßig langer Text, um die Framerate spürbar einbrechen zu lassen. Aber auch beim Rendering der in Abbildung 4 dargestellten Energieindikatorwerte (Energiebalken) gibt es einiges zu beachten. Würden die einzelnen Indikatorwerte unabhängig voneinander gerendert werden, so wären insgesamt 56 Draw Calls erforderlich. Damit wir stattdessen pro Energiebalken mit einem einzigen Draw Call auskommen (sieben Balken => sieben Render-Aufrufe), verwenden wir bei deren Darstellung die in den Listings 14 und 15 skizzierten, auf Geometry Instancing ausgerichteten, Vertex- und Fragment-Shader-Programme. Bei jedem Render-Aufruf lassen sich die Positionsdaten von bis zu 64 Vertex-Quads an den Vertex-Shader übergeben:

uniform float CenterPositionX[64];
uniform float CenterPositionY[64];

Der Zugriff auf die Position einer Vertex-Quad-Instanz erfolgt nun über die Build-in-Vertex-Shader-Variable gl_InstanceID:

gl_Position = vec4(vec2(CenterPositionX[gl_InstanceID],
                        CenterPositionY[gl_InstanceID]) +
                        ScaleFactors*gs_Vertex.xy, gs_Vertex.zw);
#version 330
precision highp float;

#define ATTR_POSITION  0
#define ATTR_NORMAL    1
#define ATTR_TEXCOORD0 2
[...]
#define ATTR_TEXCOORD7 9

layout(location = ATTR_POSITION)  in vec4 gs_Vertex;
layout(location = ATTR_NORMAL)    in vec3 gs_Normal;
layout(location = ATTR_TEXCOORD0) in vec4 gs_MultiTexCoord0;
[...]
layout(location = ATTR_TEXCOORD7) in vec4 gs_MultiTexCoord7;

out vec4 gs_TexCoord[8];

uniform vec2 CenterPosition;
uniform vec2 ScaleFactors;

void main()
{
  gl_Position = vec4(CenterPosition + ScaleFactors*gs_Vertex.xy, gs_Vertex.zw);
  gs_TexCoord[0] = gs_MultiTexCoord0;

  /* Hinweis: Für die weiteren Berechnungen kann im einfachsten Fall auf den
  in Listing 10 skizzierten Fragment Shader zurückgegriffen werden. */
}
#version 330
precision highp float;

in  vec4 gs_TexCoord[8];
out vec4 gs_FragColor;

uniform sampler2D Texture;
uniform vec4 Color;
uniform mat2 matTextureRotation;

void main()
{
  vec2 XY_Coord = 2.0*gs_TexCoord[0].st-vec2(1.0);

  vec2 rotatedTextureCoord;
  rotatedTextureCoord = matTextureRotation*XY_Coord;
  rotatedTextureCoord = 0.5*rotatedTextureCoord+0.5;

  vec4 SurfaceColor = texture(Texture, rotatedTextureCoord);

  if(SurfaceColor.a < 0.01)
  discard;

  gs_FragColor = Color*SurfaceColor;
}

Effiziente Textdarstellung mithilfe von Geometry Instancing

Font-Textur für die Textdarstellung

Abb. 8: Font-Textur für die Textdarstellung

Zu den allerersten Herausforderungen bei der Entwicklung eines neuen Grafikframeworks oder einer neuen 3-D-Engine zählt ohne jeden Zweifel die möglichst effiziente Darstellung von gleichsam kurzen wie langen Textpassagen. Während man früher noch jedes Schriftzeichen einzeln rendern musste, ist es heutzutage mithilfe von Geometry Instancing möglich, die Anzahl der erforderlichen Render-Aufrufe drastisch zu reduzieren und damit die Performance auch bei der Anzeige von umfangreichen Texten weiterhin aufrecht zu erhalten. In diesem Sinne lassen sich mithilfe der im heutigen Demoprogramm zum Einsatz kommenden C2DFont_ModernOpenGL-Klasse bis zu 64 Textzeichen mit einem einzigen Draw Call rendern. Darüber hinaus lassen sich die einzelnen Buchstaben, wie in den vorangegangenen Screenshots gezeigt, entweder einfarbig oder mit einem vertikalen bzw. horizontalen Farbverlauf (Color Gradient) darstellen. Bevor wir uns jedoch mit den Einzelheiten des Renderingprozesses befassen können, müssen wir zunächst eine Textur mit allen für die Textdarstellung erforderlichen Schriftzeichen erstellen. Hierfür kopieren wir im ersten Schritt nacheinander die ersten 256 Zeichen des ausgewählten Fonts in eine Bitmap, wobei wir diese gemäß Abbildung 8 in 16 mal 16 gleich große quadratische Bereiche aufteilen. Im zweiten Schritt legen wir ein neues Texturobjekt an und kopieren abschließend die Bitmap-Daten in den zugehörigen Grafikkartenspeicherbereich.

Unabhängig davon, welche Schriftzeichen gerendert werden sollen, kommt man bei ihrer Darstellung mit einem einzigen Vertex-Quad aus – vorausgesetzt, wir berechnen die Texturkoordinaten für die Zugriffe auf die den jeweiligen Zeichen zugeordneten Texturbereiche zur Laufzeit innerhalb des verwendeten Vertex-Shader-Programms. Bei einer Unterteilung der Font-Textur in 16 mal 16 quadratische Bereiche beträgt der Texturkoordinatenbereich eines einzelnen Textzeichens jeweils 0,0625 Einheiten (1/16 = 0,0625) in horizontaler und vertikaler Richtung. Die Umrechnung der Texturkoordinaten des in Abbildung 7 illustrierten Textzeichen-Vertex-Quads in die Texturkoordinaten des darzustellenden Zeichens erfolgt, wie nachfolgend gezeigt, mithilfe von zwei Texturkoordinatenverschiebungswerten (dtu, dtv):

tu(Zeichen i) = tu(Vertex-Quad) + dtu(Zeichen i)
tv(Zeichen i) = tv(Vertex-Quad) + dtv(Zeichen i)

Bei jedem Draw Call lassen sich die Texturkoordinatenverschiebungswerte und Positionsdaten von bis zu 64 Schriftzeichen an den in Listing 16 skizzierten Vertex-Shader übergeben:

uniform float dtu[64];
uniform float dtv[64];
uniform float PositionX[64];
uniform float PositionY[64];

Die Berechnung der Schriftzeichenpositionen und Texturkoordinaten erfolgt – wie beim Geometry Instancing üblich – mithilfe der gl_InstanceID-Shader-Variable:

• Positionsdatenberechnung:

gl_Position  = gs_Vertex;
gl_Position.x += PositionX[gl_InstanceID];
gl_Position.y += PositionY[gl_InstanceID];

• Texturkoordinatenberechnung:

gs_TexCoord[0] = vec4(gs_MultiTexCoord0.x+dtu[gl_InstanceID],
                      gs_MultiTexCoord0.y+dtv[gl_InstanceID],
                      gs_MultiTexCoord0.x, gs_MultiTexCoord0.y);

Die letzten beiden Parameter bei der vorausgegangenen Texturkoordinatenberechnung – die unveränderten Texturkoordinaten des Vertex-Quads (gs_MultiTexCoord0.x, gs_MultiTexCoord0.y) – werden für die Implementierung von zusätzlichen Farbverlaufseffekten benötigt. Vertikale Farbverläufe lassen sich mithilfe des in Listing 18 dargestellten Fragment Shaders realisieren. Für die Berechnung eines solchen Effekts benötigen wir zwei Farbwerte sowie einen zusätzlichen Parameter (ColorGradient), mit dessen Hilfe sich das Farbmischungsverhältnis einstellen lässt:

uniform vec3 BottomColor;
uniform vec3 TopColor;
uniform float ColorGradient;

Unter Berücksichtigung der vertikalen Texturkoordinaten (gs_TexCoord[0].w) des Textzeichen-Vertex-Quads können wir mithilfe des Gradientenparameters das Farbmischungsverhältnis schließlich wie folgt ermitteln:

float weight1 = min(1.0, gs_TexCoord[0].w*ColorGradient);
float weight2 = 1.0-weight1;
gs_FragColor = vec4(weight1*BottomColor+weight2*TopColor, 1.0)*
               SurfaceColor;
#version 330
precision highp float;

#define ATTR_POSITION  0
#define ATTR_NORMAL    1
#define ATTR_TEXCOORD0 2
[...]
#define ATTR_TEXCOORD7 9

layout(location = ATTR_POSITION)  in vec4 gs_Vertex;
layout(location = ATTR_NORMAL)    in vec3 gs_Normal;
layout(location = ATTR_TEXCOORD0) in vec4 gs_MultiTexCoord0;
[...]
layout(location = ATTR_TEXCOORD7) in vec4 gs_MultiTexCoord7;

out vec4 gs_TexCoord[8];

// Es lassen sich maximal 64 Schriftzeichen pro Draw Call darstellen:
uniform float PositionX[64];
uniform float PositionY[64];

uniform float dtu[64];
uniform float dtv[64];

void main()
{
  gl_Position  = gs_Vertex;

  gl_Position.x += PositionX[gl_InstanceID];
  gl_Position.y += PositionY[gl_InstanceID];

  gs_TexCoord[0] = vec4(gs_MultiTexCoord0.x+dtu[gl_InstanceID],
                        gs_MultiTexCoord0.y+dtv[gl_InstanceID],
                        gs_MultiTexCoord0.x, gs_MultiTexCoord0.y);
}
#version 330
precision highp float;

in  vec4 gs_TexCoord[8];
out vec4 gs_FragColor;

uniform sampler2D Texture;
uniform vec3 Color;

void main()
{
  vec4 SurfaceColor = texture2D(Texture, gs_TexCoord[0].st);

  if(SurfaceColor.a < 0.01)
  discard;

  gs_FragColor = vec4(Color, 1.0)*SurfaceColor;
}

 Aufmacherbild: TBusinesswomans hand presenting the word opengl against sunny green landscape von Shutterstock / Urheberrecht: wavebreakmedia

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -