Lost in Space

OpenGL: 3D-Modellbau, Kollisionsmodelle & Schadensberechnungen [How-to]
Kommentare

3-D-Modelle bilden den Dreh- und Angelpunkt einer jeden 3-D-Anwendung. Im heutigen Artikel möchte ich Ihnen eine einfache Methode demonstrieren, mit deren Hilfe sich eine Vielzahl von 3-D-Modellen schnell und unkompliziert erzeugen lässt. Darüber hinaus befassen wir uns mit Kollisionsmodellen sowie Schadensberechnungen und gehen der Frage nach, wie man den Warpflug eines Raumschiffs effektvoll in Szene setzen kann.

Ohne 3-D-Modelle keine 3-D-Computerspiele. Aber woher nehmen, wenn nicht stehlen? Mit genügend Geld in der Hinterhand stellt das Ganze natürlich kein Problem dar. Doch welche Möglichkeiten haben Entwickler, die beispielsweise an ihrem ersten Spieleprototyp arbeiten und sich während dieser Zeit weder professionelle 3-D-Modeller-Tools geschweige denn talentierte Grafik-Artists leisten können? Auf einen finanzstarken Publisher im Hintergrund können sich wohl nur die wenigsten Entwicklerteams verlassen. Und auch wenn heutzutage immer mehr unabhängige Spieleentwickler stattdessen auf das Crowdfunding-Finanzierungsmodell setzen, ohne einen optisch ansprechenden Spieleprototyp, den man zuvor in Eigenregie finanziert hat, wird man kaum die benötigten Mittel sammeln können.

Quellcodebeispiele
Die Listings 4, 5, 7, 8, 10, 14 und 15 finden Sie hier

Von allen Fallstricken, die im Verlauf der Prototypentwicklung zu bewältigen sind, werden wir uns in diesem Artikel mit der Frage auseinandersetzen, auf welche Weise man eine große Anzahl von 3-D-Modellen in möglichst kurzer Zeit mit möglichst einfachen Mitteln erstellen kann. Wer sich in jüngeren Jahren Stunden über Stunden mit seinem Legospielzeug zu beschäftigen wusste, der ist bei der Lösung des Problems klar im Vorteil. Ob Gebäude, Raumschiffe oder futuristische Roboter, ob Flugzeuge, Schiffe oder andere Fahrzeuge – mit etwas Kreativität, Fantasie und einigen wenigen Legosteintypen lassen sich im Prinzip alle möglichen Objekte zusammenbauen. Und in der virtuellen Minecraft-Welt scheint dieses Konstruktionsprinzip sogar noch besser zu funktionieren. Beispielsweise haben begeisterte Star-Trek-Fans unlängst das Raumschiff Enterprise komplett mithilfe von einfachen Minecraft-Blöcken (Würfelmodelle) nachgebaut.
Warum also sollten wir die von uns benötigten 3-D-Modelle nicht auf eine ganz ähnliche Weise konstruieren? Zu diesem Zweck müssten wir lediglich eine Handvoll einfacher Modellteile (unsere Legosteine) erstellen und diese dann in einem dreidimensionalen Raster zusammenfügen und mit unterschiedlichen Texturen überziehen (Raster Based Modelling). Sind alle Konstruktionsarbeiten abgeschlossen, müssen wir im letzten Schritt alle verdeckten Dreiecksflächen entfernen und das betreffende Rastermodell in ein Modellformat konvertieren, das von der verwendeten Game Engine unterstützt wird. Um die für die Modelldarstellung erforderliche Anzahl von Draw Calls (Render-Aufrufe) zu minimieren, sollte man bei der Konvertierung alle Modelloberflächen, die mit der gleichen Surface Map texturiert werden, zu einem einzigen Modellteil zusammenfassen. Kommt lediglich eine einzige Surface-Textur zum Einsatz, dann bestünde das konvertierte 3-D-Modell aus einem einzelnen Modellteil, bei zwei Surface-Texturen bestünde es aus zwei Modellteilen usw.

Modellbau

Auf den ersten Blick mag es vielleicht ein wenig primitiv – oder gar billig – erscheinen, wenn die verwendeten 3-D-Modelle lediglich aus einer begrenzten Anzahl von miteinander verbundenen Bauteilen bestehen. Im Rahmen von Weltraumspielen, -serien und -filmen ist diese Vorgehensweise jedoch alles andere als unüblich und wird ab und an sogar als ein spezielles Spielefeature beworben: fully-customizable Space Ships. Anhand der allseits bekannten Raumschiffe aus dem Star-Trek-Universum lässt sich der komponentenbasierte Schiffsbau besonders anschaulich nachvollziehen. Für die Konstruktion der Föderationsschiffe benötigt man beispielsweise lediglich vier unterschiedliche Bauteiltypen: Untertassensektionen, Warpgondeln, Maschinenraumsektionen sowie ein paar Überbrückungselemente, um die einzelnen Schiffsteile miteinander zu verbinden.
Komponentenbasierte 3-D-Modelle kann praktisch jeder erstellen, auch ohne dass man sich hierfür zunächst in ein komplexes 3-D-Modeller-Programm einarbeiten muss. Die in den Abbildungen 1 und 2 dargestellten Raumschiffe wurden beispielsweise mithilfe unseres OpenGL-Framework-Demoprogramms 32 erstellt. Eine klassische Benutzeroberfläche, wie man sie von anderen Tools her kennt, wurde aus zeitlichen Gründen bisher noch nicht implementiert. Möchte man etwas am Bauplan eines 3-D-Modells verändern, so muss die zugehörige Skriptdatei zunächst geöffnet, editiert und anschließend neu geladen werden.

Abb. 1: 3-D-Modell

Abb. 2: 3-D-Modell + Bounding-Sphären-Kollisionsmodell

Schauen wir uns einmal den Aufbau einer solchen Skriptdatei an. Im ersten Schritt definieren wir zunächst so genannte Platzhalterelemente, die entsprechend ihres jeweiligen Verwendungszwecks mithilfe von kleinen roten, grünen oder gelben (momentan nicht sichtbar) würfelförmigen 3-D-Elementen visualisiert werden (Abb. 1). An den Positionen der grünen Platzhalterelemente werden im fertigen Spiel die Positionslichter des Raumschiffs gerendert, die gelben Platzhalter dienen zur Positionierung der Waffensysteme, und die roten Platzhalter benötigen wir für die Darstellung der in Abbildung 3 gezeigten Antriebseffekte (Antriebspartikel, Triebwerksglühen sowie Linsenreflexionen):

#Anzahl der Triebwerke:# 2
-0.5, 0.0, -5.5
0.5, 0.0, -5.5

#Anzahl der Positionslichter:# 3
0.0, 0.5, 3.5
3.0, 0.0, -2.5
-3.0, 0.0, -2.5

#Anzahl der Waffensysteme:# 2
0.0, 0.5, 4.0
0.0, 0.5, -4.5

Im zweiten Schritt erstellen wir eine Liste mit allen Bauteilen, die bei der Konstruktion des betreffenden 3-D-Modells benötigt werden:

#Anzahl der verwendeten Konstruktionselemente:# 35

#1# ../ModelParts/Meshes/BaseBox.txt
[...]
#35# ../ModelParts/Meshes/PyramidTrunk2_50_PosZ.txt

Bei der Darstellung und Beleuchtung der 3-D-Modelle kommen insgesamt vier unterschiedliche Texturtypen zum Einsatz:

• Surface Maps für die Farbgebung
• Specular Maps für die Beschreibung der Reflexionseigenschaften (raue Oberflächen: keinerlei spiegelnde Reflexion; glatte Oberflächen: starke Lichtspiegelungen)
• Light Maps für die Darstellung aller selbstleuchtenden Oberflächen (z. B. hell erleuchtete Fenster)
• Height Maps (Höhenwerte der Oberfläche), mit deren Hilfe sich die für die pixelgenaue Beleuchtung erforderlichen Normal Maps berechnen lassen

Die Namen der verwendeten Texturen speichern wir jedoch nicht im Skript selbst, sondern in einer separaten Datei (TextureDesc.txt) ab. Auf den ersten Blick mag diese Vorgehensweise ein wenig umständlich erscheinen, die Handhabung der Texturen im fertigen Spiel lässt sich hierdurch jedoch immens vereinfachen. Speichert man beispielsweise sämtliche Namen der für die Darstellung aller Raumschiffmodelle erforderlichen Texturen in einer zentralen Datei ab, können wir nach dem Spielstart problemlos für jeden Texturtyp ein separates Texture-Array-Objekt anlegen und mithilfe dieser Arrays alle Raumschiffe ohne zwischenzeitige Texturwechsel rendern (Listing 1).

Abb. 3: Warpflugeffekte (Spieleprototyp)

#NumTextures:# 6

#Surface Maps:#
../ModelParts/Textures/HullType1.bmp
[...]
../ModelParts/Textures/ThrustersHullType1.bmp

#Specular Maps:#
../ModelParts/Textures/HullType1Specular.bmp
[...]
../ModelParts/Textures/ThrustersHullType1Specular.bmp

#Light Maps:#
../ModelParts/Textures/HullType1Light.bmp
[...]
../ModelParts/Textures/ThrustersHullType1Light.bmp

#Height Maps:#
0.005, ../ModelParts/Textures/HullType1Height.bmp
[...]
0.005, ../ModelParts/Textures/ThrustersHullType1Height.bmp

Wenden wir uns nun wieder unserer Skriptdatei zu. Nachdem wir zuvor eine Liste mit allen benötigten Konstruktionselementen erstellt haben, müssen wir im dritten Schritt festlegen, welche Texturen bei der Darstellung der einzelnen Bauteile zum Einsatz kommen sollen. In diesem Zusammenhang verwenden wir jedoch keine einfache Liste mit den zugehörigen Texturindizes, sondern definieren uns stattdessen mehrere so genannter Textur-Sets. Jedes dieser Sets besteht aus insgesamt sechs Texturindizes, mit deren Hilfe wir die sechs Seiten eines würfelbasierten Konstruktionselements unabhängig voneinander texturieren können:

#Anzahl der Textur-Sets:# 7

#Hinweis: Die Namen der verwendeten Texturen finden sich in der Datei TextureDesc.txt!#

#(-y) (+y) (-z) (+z) (-x) (+x)#

#1# 0, 0, 0, 0, 0, 0
[...]
#7# 0, 0, 1, 1, 1, 1

Nachdem wir zuvor alle benötigten Bauteile und Texturen spezifiziert haben, müssen wir nun noch die Ausmaße des dreidimensionalen Konstruktionsrasters festlegen und können dann mit der eigentlichen Konstruktion des 3-D-Modells beginnen (Listing 2).

#Ausmaße des Konstruktionsrasters:#
#Model Height (y-direction):# 3   #Hinweis: y = 1, 0, y = -1#
#Model Length (z-direction):# 11
#Model Width (x-direction):# 6

#Bauplan des 3-D-Modells:
 freie Rasterelemente sind mit 00 00 gekennzeichnet
 Beispiel für ein belegtes Rasterelement: 08 01
 Hinweis: MeshID: 08 gefolgt von der TexursetID: 01 #

#y = 1:#
[...]

#y = 0:#

00 00, 00 00, 08 01, 08 01, 00 00, 00 00
00 00, 05 01, 01 01, 01 01, 04 01, 00 00
[...]
00 00, 00 00, 35 02, 35 02, 00 00, 00 00 

#y = -1:#
[...]

Rasterbasierte 3-D-Modelle lassen sich zwar recht einfach und unkompliziert erstellen, beim Design von runden Formen stößt diese Methode jedoch aufgrund der limitierten Anzahl der zur Verfügung stehenden Bauteile schnell an ihre Grenzen. Möchte man nun verschiedene Bereiche der Oberfläche abrunden, so ist dies nur im Nachhinein mithilfe von so genannten Geometry-Modifier-Elementen möglich (Listing 3).

#NumGeometryModifierElements:# 1

#Modifier-Types:
OuterSphere := 1       InnerSphere := -1
xz-OuterCylinder := 2  xz-InnerCylinder := -2
xy-OuterCylinder := 3  xy-InnerCylinder := -3
yz-OuterCylinder := 4  yz-InnerCylinder := -4
#

#GeometryModifier1_Position:# 0.0, 0.0, 0.0
#GeometryModifier1_Type:# 1
#GeometryModifier1_RangeXYZ:# 1.95, 1.95, 1.95
#GeometryModifier1_InfluenceDirection:# 0.0, 0.0, 1.0
#GeometryModifier1_HalfAngle:# 2.057

Immersion

Das Konzept der Immersion nimmt bei der Entwicklung von modernen Computerspielen einen immer wichtigeren Stellenwert ein. Idealerweise sollte ein Spiel heutzutage etwas mehr als nur ein wenig kurzweilige Unterhaltung bieten, weshalb insbesondere bei den so genannten AAA-Titeln weder Kosten noch Mühen gescheut werden, um die Spielewelten so glaubhaft wie möglich zu gestalten. In diesem Zusammenhang spielt es keine Rolle, ob Dinge wie Laserschwerter, Warpantriebe, Schutzschilde, Phaserwaffen oder Photonentorpedos physikalisch plausibel sind oder ins Reich der Fantasie gehören. Durchdringt ein Photonentorpedo beispielsweise den Schutzschild eines Raumschiffs und detoniert dann in der Nähe der Schiffshülle, so muss die Darstellung auf dem Bildschirm lediglich mit der Vorstellungskraft des Spielers konform gehen. In dieser Hinsicht haben die Spiele Klingon Academy und Star Trek Bridge Commander bei mir – einem bekennenden Star-Trek-Fan – einen besonders nachhaltigen Eindruck hinterlassen. Die Grafik dieser Spiele war zu ihrer Zeit wirklich erstklassig, entscheidend für den Spielspaß waren jedoch die detailliert ausmodellierten Schadensmodelle. Wir stimmen wohl alle darin überein, dass ein nach außen völlig unbeschädigt wirkendes Raumschiff nicht einfach so in einer gigantischen Explosion (Versagen der Antimaterieeindämmung) verglühen darf. Erst die Kampfspuren und Hüllenschäden auf den Schiffsrümpfen (Abb. 3) sorgen für ein intensives Spielerlebnis. Während unsere Furcht vor einer drohenden Niederlage durch immer neue Schäden am eigenen Schiff zusehends stärker wird, werden wir durch die Beschädigungen an den gegnerischen Schiffen zunehmend siegessicher.

Aufmacherbild: Stars of a planet and galaxy in a free space „Elements of this image furnished by NASA“ von Shutterstock / Urheberrecht: Maria Starovoytova

[ header = Seite 2: Kollisionsmodelle ]

Kollisionsmodelle

Doch bevor wir uns mit den Einzelheiten einer solchen Schadenssimulation befassen können, müssen wir uns zunächst einmal in die Grundlagen der Kollisions- und Treffererkennung einarbeiten. Um eine effiziente Durchführung dieser Tests zu gewährleisten, führt man die erforderlichen Berechnungen normalerweise in einem 3-Schritt-Verfahren durch:

• Sektorisierung der Spielewelt (Objekte, die sich in unterschiedlichen Sektoren befinden, können nicht miteinander kollidieren)
• Ausschlusstest (überprüfen, ob eine Kollision zwischen zwei Objekten innerhalb eines Sektors im Bereich des Möglichen liegt)
• Exakte Kollisions- und Treffererkennung (sofern eine mögliche Kollision im zweiten Schritt nicht ausgeschlossen werden konnte)

Zu den bekanntesten Verfahren, mit deren Hilfe sich eine mögliche Kollision oder ein möglicher Treffer ohne großen Rechenaufwand ausschließen lässt, zählt zweifelsohne der so genannte Bounding-Sphärentest. Zur Erklärung: Bei einer Bounding-Sphäre handelt es sich um einen kugelförmigen Bereich, der ein 3-D-Modell entweder teilweise (Abb. 2) oder vollständig umschließt.

struct CBoundingSphere
{
  D3DXVECTOR3 CenterPosition;
  float radius;
};

Eine Bounding-Sphäre für die Durchführung eines Kollisions- bzw. eines Trefferausschlusstests ist schnell definiert, da die Position des Kugelmittelpunkts mit der Position des betreffenden 3-D-Objekts in der Spielewelt übereinstimmt. Für den Fall, dass sämtliche Vertexkoordinaten der Modelloberfläche innerhalb eines Bereichs von -1 bis +1 liegen (unskaliertes Modell), muss der Kugelradius mindestens dem 1,7-fachen des maximalen Skalierungsfaktors entsprechen:

Radius >=  (ScaleX*ScaleX + ScaleY*ScaleY + ScaleY*ScaleY)
bzw.
Radius >=  (ScaleMax*ScaleMax + ScaleMax*ScaleMax + ScaleMax*ScaleMax)
Radius >= ScaleMax*3 = 1,732*ScaleMax

Die Durchführung eines Trefferausschlusstests ist nun besonders einfach, da wir hierbei lediglich die Bounding-Sphäre des möglicherweise getroffenen Objekts berücksichtigen müssen. Im Vergleich dazu sind die Waffenprojektile derart klein, dass wir ihre Ausdehnung problemlos vernachlässigen können. Der eigentliche Testablauf ist schnell erklärt – wir müssen lediglich die Größe des quadratischen Bounding-Sphärenradius mit dem quadratischen Abstand zwischen dem Projektil und dem Objektmittelpunkt vergleichen:

Abstand(zwischen Projektil u. Objektmittelpunkt)² >= Radius² => Treffer ausgeschlossen
Abstand(zwischen Projektil u. Objektmittelpunkt)² < Radius² => Treffer möglich

Die Implementierung des Trefferausschlusstests können Sie anhand von Listing 4 nachvollziehen.
Auch das Ergebnis des Kollisionsausschlusstests basiert auf einem einfachen Abstandsvergleich. Im Unterschied zum zuvor behandelten Trefferausschlusstest müssen wir nun jedoch die Bounding-Sphärenradien (Radius1 sowie Radius 2) beider an einer möglichen Kollision beteiligten 3-D-Objekte berücksichtigen:

Abstand² >= Radius1² + Radius2² + 2*Radius1*Radius2 => Kollision ausgeschlossen
Abstand² < Radius1² + Radius2² + 2*Radius1*Radius2 => Kollision möglich

Definiert man die Kollisionsradien ein wenig großzügiger, dann können wir auf den Mischterm 2*Radius1*Radius2 verzichten, und die Bedingung für eine mögliche Kollision vereinfacht sich zu (Listing 5):

Abstand(zwischen den Objektmittelpunkten 1 u. 2)² < Radius1² + Radius2²

Für den Fall, dass sich eine mögliche Kollision oder ein möglicher Treffer mithilfe der zuvor betrachteten Verfahren nicht vollständig ausschließen lässt, müssen wir uns mit der Frage auseinandersetzen, auf welche Weise man die Genauigkeit der Treffer- und Kollisionserkennung verbessern kann. Wir wissen bereits, dass ein einzelner Bounding-Sphärentest nur sehr wenig Rechenzeit beansprucht. Was wäre demzufolge naheliegender, als die Geometrie eines 3-D-Objekts, wie in Abbildung 2 gezeigt, mithilfe einer größeren Anzahl von kleinen Bounding-Sphären so genau wie möglich nachzubilden? Für rasterbasierte 3-D-Modelle lassen sich die benötigten Bounding-Sphären zudem besonders leicht berechnen, da wir lediglich jedem Bauteil auf dem Konstruktionsraster eine einzelne Bounding-Sphäre zuordnen müssen. Bevor man mit der Implementierung von anderweitigen – teils deutlich rechenintensiveren – Testverfahren beginnt (orientierte Bounding-Boxen, Kollisions- und Schnittpunktberechnungen auf Dreiecksbasis usw.), sollte man zunächst immer erst einmal überprüfen, ob die Verwendung eines Bounding-Sphärenkollisionsmodells eine brauchbare Alternative darstellen könnte.
Stellt sich bei der Durchführung unserer Bounding-Sphärenausschlusstests (Schritt 2) letzten Endes heraus, dass eine Kollision oder ein Treffer im Bereich des Möglichen liegt, müssen wir als Vorbereitung für alle weiteren Kollisions- und Trefferberechnungen zunächst einmal die Kollisionsmodelle der beteiligten 3-D-Objekte aktualisieren. Im Unterschied zu einem Bounding-Box-basierten Kollisionsmodell ist dies bei einem Bounding-Sphärenkollisionsmodell besonders einfach, da wir hierbei lediglich die Spieleweltpositionen der einzelnen Bounding-Sphären neu berechnen müssen. In diesem Zusammenhang gilt es jedoch zu beachten, dass sich die einzelnen Teile einer Modellinstanz (Instanzen: 3-D-Objekte, für deren Darstellung dasselbe 3-D-Modell verwendet wird) im Zuge einer Animation (Beispiel: Neuausrichtung der Geschütztürme) durchaus unterschiedlich bewegen können. Da wir diese Modellteilanimationen bei der Aktualisierung natürlich berücksichtigen wollen, müssen wir nach dem Erstellen eines neuen Kollisionsmodells zusätzlich zu den Radien und Mittelpunktpositionen der einzelnen Sphären auch die jeweiligen Modellteilzuordnungen in der Kollisionsmodelldatei mit abspeichern. Listing 6 zeigt einen kurzen Auszug aus der Kollisionsmodelldatei des in Abbildung 2 gezeigten Raumschiffs.

NumCollisionSpheres: 70

Radius: 0.866000
CenterModelSpacePos.x: -0.500000
CenterModelSpacePos.y: 0.833500
CenterModelSpacePos.z: -1.166500
NumModelPartsInside: 1
IndicesOfModelPartsInside: 0

Radius: 0.866000
CenterModelSpacePos.x: 0.500000
CenterModelSpacePos.y: 0.833500
CenterModelSpacePos.z: -1.166500
NumModelPartsInside: 1
IndicesOfModelPartsInside: 0

[...]

Für die Handhabung solcher Bounding-Sphärenkollisionsmodelle bietet sich der Einsatz unserer beiden Frameworkklassen CSphereCollisionModel sowie CSphereCollisionModelInstance an.

• Während der Initialisierungsphase einer 3-D-Anwendung müssen die benötigten Kollisionsmodelle zunächst mithilfe der Init_CollisionModel()-Methode der CSphereCollisionModel-Klasse wie folgt geladen werden:

SphereCollisionModel = new CSphereCollisionModel;
SphereCollisionModel->Init_CollisionModel(ModelFolderName);

• Im zweiten Schritt muss für jedes 3-D-Objekt eine Kopie des zu verwendenden Kollisionsmodells angelegt werden:

// Die Kollisionsmodellinstanz repräsentiert das zu einem
// 3-D-Objekt zugehörige Kollisionsmodell:
SphereCollisionModelInstance = new CSphereCollisionModelInstance;
SphereCollisionModelInstance->Init_CollisionModelInstance(SphereCollisionModel);

• Bevor wir eine Kollisionsmodellinstanz im Rahmen einer genauen Kollisions- bzw. Treffererkennung verwenden können, müssen wir zunächst die Spieleweltpositionen der einzelnen Bounding-Sphären aktualisieren. Die Einzelheiten der hierbei erforderlichen Berechnungen können Sie anhand von Listing 7 nachvollziehen:

SphereCollisionModelInstance->Update_From_ModelInstance(ModelInstance);

Zerstörung als Post-Processing-Effekt (Screen Space Damage Calculations)

Klassische Schadenssimulationen bedeuten in erster Linie eine Vielzahl von Überstunden für die Grafikabteilung, denn für jedes unbeschädigte 3-D-Modell muss mindestens eine weitere, nahezu vollständig zerstörte Version erstellt werden. Erschwerend kommt hinzu, dass wir die besagten 3-D-Modelle in Abhängigkeit vom angestrebten Detailgrad des Schadensmodells in eine mehr oder weniger große Anzahl von Teilstücken zerlegen müssen. Kurzum, für jeden zerstörbaren Bereich benötigen wir ein separates Modellteil. Für großflächige Schäden (typisches Beispiel aus den Star-Trek-Spielen und -Filmen: die Explosion einer Warpgondel) ist die klassische Schadenssimulation nach wie vor die Methode der Wahl. Kleinere Beschädigungen und Kampfspuren lassen sich auf diese Weise jedoch nicht simulieren – denn nicht jeder Treffer wirkt sich so verheerend aus, dass gleich das halbe Raumschiff explodiert. Interessanterweise haben bereits die Trickspezialisten der klassischen Kampfstern-Galactica-Serie (1978–1979) eine Lösung für das Problem gefunden und einen für die damalige Zeit wirklich sehenswerten Spezialeffekt konzipiert, den wir heutzutage im Verlauf der Post-Processing-Phase ohne große Schwierigkeiten nachstellen können: Durch lokale Brände im Inneren des Raumschiffs heizt sich die Hülle in der unmittelbaren Umgebung bis zur Rotglut auf. Damit nun die Schiffshülle an mehreren Stellen rotglühend erscheint, müssen wir lediglich im Rahmen des Deferred Lightings in der Nähe dieser Bereiche jeweils eine sehr helle rote bzw. orange leuchtende Punktlichtquelle mit extrem kurzer Reichweite positionieren. Zur Erklärung: Im Unterschied zum klassischen Forward Rendering können wir beim Deferred Lighting eine deutlich größere Anzahl von Lichtquellen berücksichtigen.
Variiert man nun die zuvor beschriebene Technik ein wenig, so lassen sich auch die in Abbildung 4 gezeigten Hüllenschäden und Kampfspuren problemlos darstellen.

Abb. 4: Screen Space Damage Calculations

Verantwortlich für die Berechnung dieser Effekte ist der in Listing 8 skizzierte ScreenSpaceDamageCalculations-Fragment-Shader, der seinerseits durch die Calculate_VisibleSurfaceDamages()-Methode der CPostProcessingEffects-Frameworkklasse aufgerufen wird.
Die den Schadensberechnungen zugrunde liegende Idee ist denkbar einfach: Nach Abschluss der Beleuchtungsberechnungen (Deferred Lighting) werden die beschädigten Bereiche eines 3-D-Objekts in einem nachgeschalteten Post-Processing-Schritt in Abhängigkeit von der Entfernung zum jeweiligen Schadenszentrum mehr oder weniger stark abgedunkelt:

[...]
PostProcessingEffects->Stop_SceneRendering();
PostProcessingEffects->Calculate_ScreenSpaceLighting(&g_CameraViewDirection);
PostProcessingEffects->Calculate_VisibleSurfaceDamages();
[...]

Verschaffen wir uns einmal einen Überblick über den Programmablauf innerhalb des ScreenSpaceDamageCalculations-Fragment-Shaders:

• Im ersten Schritt müssen wir zunächst überprüfen, ob der betrachtete Pixel Teil eines 3-D-Modells ist. Sollte dies nicht der Fall sein, kann auf alle weitere Berechnungen verzichtet werden:

if(CameraSpacePos.w < 0.0)
  gs_FragColor = ScreenColor;
else
{
  float InvDamageFactor = 1.0;
  [Schadensberechnungen durchführen]
}

• Im zweiten Schritt gehen wir nacheinander alle Schadenspositionen durch und überprüfen, ob sich der betrachtete Pixel innerhalb der zugehörigen Schadensbereiche befindet. Sollte dies der Fall sein, dann berechnen wir die Stärke der Beschädigung in Abhängigkeit vom Abstand des Pixels zum jeweiligen Schadenszentrum, wobei der Schaden mit zunehmendem Abstand immer geringer wird (Listing 9).

for(int i = 0; i < NumDamagePositionsUsed; i++)
{
  [...]
  if(abs(distanceFromDamageCenter.x) > actualDamageCameraSpacePosAndRange.w)
    continue;
  if(abs(distanceFromDamageCenter.y) > actualDamageCameraSpacePosAndRange.w)
    continue;
  if(abs(distanceFromDamageCenter.z) > actualDamageCameraSpacePosAndRange.w)
    continue;

  // Pixel innerhalb des Schadensbereichs, Schadensberechnung durchführen:
  tempFactor = dot(distanceFromDamageCenter, distanceFromDamageCenter)/
  (actualDamageCameraSpacePosAndRange.w*actualDamageCameraSpacePosAndRange.w);

  InvDamageFactor = min(InvDamageFactor, tempFactor);
}

• Im letzten Schritt können wir schließlich die Pixelfarbe unter Berücksichtigung des zuvor berechneten Beschädigungsgrads (1.0-InvDamageFactor) wie folgt modifizieren:

InvDamageFactor = clamp(min(1.0, InvDamageFactor), 0.0, 1.0);
gs_FragColor = (InvDamageFactor + 0.175*(1.0-InvDamageFactor))*ScreenColor;

[ header = Seite 3: Warpflugeffekte ]

Warpflugeffekte

Die Effekte des überlichtschnellen Raumflugs sind allen Science-Fiction-Fans durch Filme wie Krieg der Sterne oder Star Trek bestens vertraut. Freilich hat das Ganze wenig mit ernsthafter Physik zu tun, Hauptsache die Optik stimmt – alles andere ist zweitrangig. Die sichtbaren Sterne werden mit zunehmender Geschwindigkeit immer weiter in die Länge gezogen (Warpstreifen) und das Sternenlicht selbst wird, wie in Abbildung 3 gezeigt, ähnlich wie bei einem Prisma in seine farbigen Bestandteile zerlegt (Chromatic Distortion). Berücksichtigt man darüber hinaus noch den aus der Astronomie bekannten Dopplereffekt, wirkt die Szenerie gleich doppelt so beeindruckend – denn das Licht der Sterne und Nebel in Flugrichtung erscheint aufgrund der hohen Reisegeschwindigkeit stark ins Blaue verschoben, während die Objekte hinter dem Schiff in warmen Rottönen erstrahlen (Abb. 5).
Abb. 5: Warpflug-Dopper-Effekt (Spieleprototyp)

In unseren Spieleprototypen ist die CPostProcessingEffects-Methode Calculate_WarpEffect() für die Simulation der Warpflugeffekte verantwortlich. Im ersten Schritt ermitteln wir zunächst die Stärke der Rot- bzw. Blauverschiebung in Abhängigkeit von der Kamerablickrichtung (Pseudo-Doppler-Effekt) und übergeben das Ergebnis dieser Berechnungen im Anschluss daran als Parameter an das in Listing 10 skizzierte WarpEffect-Fragment-Shader-Programm:

D3DXVECTOR4 BlueColor = D3DXVECTOR4(0.0f, 0.0f, 0.4f, 0.0f);
D3DXVECTOR4 RedColor =  D3DXVECTOR4(0.4f, 0.0f, 0.0f, 0.0f);

float dot = D3DXVec3Dot(pFlightDirection, &g_CameraViewDirection);

D3DXVECTOR4 DopplerLightColor = D3DXVECTOR4(0.0f, 0.0f, 0.0f, 1.0f);

if(dot > 0.0f)
  DopplerLightColor += dot*BlueColor;
else
  DopplerLightColor -= dot*RedColor;

WarpEffectShader->Set_ShaderFloatVector4(&DopplerLightColor, "DopplerLightColor");

Verantwortlich für die Simulation der Farbverzerrungseffekte innerhalb des WarpEffect-Fragment-Shaders ist die nachfolgend skizzierte benutzerdefinierte GLSL-Shader-Funktion textureDistorted(). Eine ähnliche Funktion haben Sie bereits im letzten Heftartikel „Göttliche Weltraumeffekte“ im Zusammenhang mit der Screen-Space-basierten Lens-Flare-Darstellung kennengelernt. Im Unterschied zu besagter Funktion müssen wir an dieser Stelle vor der Berechnung der Farbverzerrung zunächst überprüfen, ob die betrachteten Szenenpixel zu unserem Raumschiff gehören. Sollte dies der Fall sein, dann liefert uns die neue Funktion als Rückgabewert den unverzerrten Szenenpixel. Zur Erklärung, die Farbverzerrungseffekte sollen sich nicht auf das Raumschiff auswirken, da sich die Kamera mit diesem mitbewegt und die Relativgeschwindigkeit zwischen beiden entsprechend gleich null ist (Listing 11).

vec4 textureDistorted(in sampler2D tex, in vec2 texcoord,
                      in vec2 direction, in vec3 distortion)
{
  // Verzerrungsberechnungen abbrechen, falls die betrachteten
  // Szenenpixel zu unserem Raumschiff gehören:
  vec2 texCoord1 = texcoord + direction*distortion.r;
  if(texture(CameraDepthTexture, texCoord1).w > 0.0)
    return texture(tex, texcoord);

  vec2 texCoord2 = texcoord + direction*distortion.g;
  if(texture(CameraDepthTexture, texCoord2).w > 0.0) 
    return texture(tex, texcoord);

  vec2 texCoord3 = texcoord + direction*distortion.b;
  if(texture(CameraDepthTexture, texCoord3).w > 0.0) 
    return texture(tex, texcoord);

  // Farbverzerrungsberechnungen durchführen:
  return vec4(texture(tex, texCoord1).r, texture(tex, texCoord2).g,
              texture(tex, texCoord3).b, 1.0);
}

Kombiniert man nun unter Berücksichtigung der nachfolgend skizzierten Color-Blending-Formel die im ersten Schritt ermittelte Doppler-Farbverschiebung (DopplerLightColor) mit der innerhalb der textureDistorted()-Funktion berechneten Szenenpixelfarbe (ScreenColor), dann erhalten wir als Ergebnis die in den Abbildungen 2 und 3 gezeigten Warpflug-Farbeffekte:

gs_FragColor = DopplerLightColor + (vec4(1.0, 1.0, 1.0, 1.0) –
               DopplerLightColor)*ScreenColor;

Partikeleffekte und Soft Particles

Stellen Sie sich nur einmal eine Spielewelt ohne Wolken, Nebel, Regen, Schnee, Feuer, Rauch oder Explosionen vor. Nun ja, freiwillig würde ein solches Spiel wohl niemand von uns gerne spielen. Bringen wir es also auf den Punkt: Beeindruckende Partikeleffekte sind unverzichtbar und zweifelsohne das Salz in der Suppe eines jeden Computerspiels.
Bei all den Möglichkeiten, die uns Entwicklern heute zur Verfügung stehen, vergisst man schnell, dass die Implementierung solcher Effekte vor nicht allzu langer Zeit noch mit allerlei Schwierigkeiten verbunden war. Anhand von diversen „Let’s Play“-Videos älterer Spieletitel auf youtube.com können Sie sich selbst ein Bild davon machen, dass Transparenz- und Render-Fehler im Zusammenhang mit der Partikeldarstellung praktisch an der Tagesordnung waren. Mittlerweile gehören diese Probleme gottlob der Vergangenheit an. Einerseits ist es möglich, mithilfe von Geometry Instancing eine Vielzahl von Partikeln mit nur einem einzigen Draw Call darzustellen und andererseits sind wir bei der Vermeidung von Transparenzfehlern nicht mehr auf die Unterstützung des z-Buffers (Tiefenpuffer) angewiesen. Die einzelnen Partikel werden heutzutage standardmäßig bei ausgeschaltetem z-Buffer gerendert. Die Überprüfung, ob ein Partikel teilweise oder vollständig durch die opake (undurchsichtige) Szenengeometrie (z. B. unsere Raumschiffe) verdeckt wird, erfolgt stattdessen im Fragment Shader mithilfe des in einer Textur gespeicherten Tiefenabbilds der 3-D-Szene. Hier nun die Details der in diesem Zusammenhang erforderlichen Berechnungen:

• Im ersten Schritt berechnen wir innerhalb des verwendeten Vertex-Shader-Programms für jede Partikelinstanz die Bildraum-Vertexpositionen und speichern die Ergebnisse für den späteren Verdeckungstest im Fragment-Shader zwischen:

gl_Position = matWorldViewProjection[gl_InstanceID]*gs_Vertex;
gs_TexCoord[1].xyz = gl_Position.xyz;

• In Vorbereitung auf den sich anschließenden Verdeckungstest müssen wir im Fragment-Shader zunächst mithilfe der im Vertex-Shader zwischengespeicherten Bildraum-Vertexpositionen die Texturkoordinaten für den Zugriff auf das Tiefenabbild der 3-D-Szene ermitteln:

float tempFloat = 1.0/gs_TexCoord[1].z;
float distanceBasedBrightness = min(1.0, 20.0*tempFloat);

vec2 ProjectedTexCoord = vec2(0.5*gs_TexCoord[1].x*tempFloat + 0.5,
                              0.5*gs_TexCoord[1].y*tempFloat + 0.5);

• Unter Berücksichtigung der im vorangegangenen Schritt berechneten Texturkoordinaten können wir nun zusätzlich zum eigentlich relevanten Tiefenwert CameraDepth1 auch vier weitere, in der unmittelbaren Nachbarschaft liegende Werte (CameraDepth2 bis CameraDepth5), aus der CameraDepthTexture auslesen (Listing 12).
• Im Anschluss daran bestimmen wir zur Vermeidung von möglichen Render-Artefakten sowohl den Minimal- wie auch den Maximalwert aus den fünf zuvor ermittelten Tiefenwerten (Listing 13).
• Im letzten Schritt können wir schließlich mithilfe der minimalen und maximalen Tiefenwerte überprüfen, ob das betreffende Partikelpixel sichtbar ist oder durch die opake Szenengeometrie verdeckt wird:

if(MinCameraDepth < gs_TexCoord[1].z-0.0125 &&
  MaxCameraDepth < gs_TexCoord[1].z-0.0125)
  discard;
else
  [Partikelfarbe berechnen]
float CameraDepth1 = texture(CameraDepthTexture, ProjectedTexCoord).w;

float CameraDepth2 = texture(CameraDepthTexture, ProjectedTexCoord+
                             vec2(0.001, 0.001)).w;
float CameraDepth3 = texture(CameraDepthTexture, ProjectedTexCoord-
                             vec2(0.001, 0.001)).w;
float CameraDepth4 = texture(CameraDepthTexture, ProjectedTexCoord+
                             vec2(-0.001, 0.001)).w;
float CameraDepth5 = texture(CameraDepthTexture, ProjectedTexCoord-
                             vec2(-0.001, 0.001)).w;
float MinCameraDepth = CameraDepth1;
MinCameraDepth = min(MinCameraDepth, CameraDepth2);
MinCameraDepth = min(MinCameraDepth, CameraDepth3);
MinCameraDepth = min(MinCameraDepth, CameraDepth4);
MinCameraDepth = min(MinCameraDepth, CameraDepth5);

if(MinCameraDepth < 0.0) // kein opakes Pixel an dieser Stelle
  MinCameraDepth = 1000000.0;

float MaxCameraDepth = CameraDepth1;
MaxCameraDepth = max(MaxCameraDepth, CameraDepth2);
MaxCameraDepth = max(MaxCameraDepth, CameraDepth3);
MaxCameraDepth = max(MaxCameraDepth, CameraDepth4);
MaxCameraDepth = max(MaxCameraDepth, CameraDepth5);

if(MaxCameraDepth < 0.0) // kein opakes Pixel an dieser Stelle
  MaxCameraDepth = 1000000.0;

Bei der Berechnung der Partikelfarbe müssen wir wohl oder übel ein wenig tricksen, denn andernfalls würde man bereits für die Darstellung einer einzelnen Rauchwolke eine gigantische Anzahl von 1 x 1 Pixel großen Partikeln benötigen. Aus Performancegründen sind wir jedoch dazu gezwungen, die Struktur von an sich mikroskopisch kleinen Rauch- und Dunstpartikeln (Wolken und Nebel usw.) mithilfe einer verhältnismäßig geringen Anzahl von texturierten Vertexquads (Vierecke) nachzubilden. An den Übergängen zur opaken Szenengeometrie fällt dieser „Schwindel“ jedoch auf, und der Spieler erkennt, dass unsere „schöne“ Rauchwolke in Wahrheit nur aus einigen wenigen übergroßen zweidimensionalen Partikeln besteht. Die Lösung für unser Problem ist denkbar einfach, obgleich die hochtrabende und werbewirksame Umschreibung „Soft Particle Rendering“ etwas vollkommen anderes vermuten lässt. Wir müssen lediglich überall dort, wo sich die Szenengeometrie mit den texturierten Vertexquads überschneidet, einen weichen Farbübergang berechnen (Stichwort Color Fading) – das ist auch schon alles:

float FadeValue = clamp(DistanceFactor*abs(SceneDepth-ParticleDepth), 0.0, 1.0);
gs_FragColor = FadeValue*ParticleColor;

bzw.

float FadeValue = min(1.0, DistanceFactor*abs(SceneDepth-ParticleDepth));
gs_FragColor = FadeValue*ParticleColor;

Das Ergebnis dieser Soft-Particle-Berechnungen können Sie anhand der in Abbildung 2 dargestellten Antriebspartikel nachvollziehen. Die in diesem Zusammenhang zum Einsatz kommenden Shader-Programme sind in den Listings 14 (Vertex-Shader) und 15 (Fragment-Shader) skizziert.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -