3-D-Wellen, Wasserspiegelungen, Schaumkronen und Kaustiken als Post-Processing-Effekt

Pack die Badehose ein: Water Rendering 2.0
Kommentare

Realistische Echtzeitwassereffekte sorgen seit jeher für den berüchtigten Wow-Effekt in der Spielercommunity. Im heutigen Artikel tauchen wir gemeinsam in die Tiefen des Water Renderings hinab und untersuchen, wie sich 3-D-Wellen, Spiegelungen auf der Wasseroberfläche, Schaumkronen sowie Unterwasserkaustiken im Verlauf der Post-Processing-Phase simulieren lassen.

Tropische Inseln, traumhafte Sandstrände und glasklares Wasser – nie zuvor wurde das „Paradies auf Erden“ so stimmungsvoll in Szene gesetzt wie in Far Cry, eines der besten Spiele des Jahres 2004. Gemessen am heutigen Stand der Technik wirkt besagtes Spiel mittlerweile zwar ein wenig angestaubt, damals jedoch kam man als Spieler aus dem Staunen nicht mehr heraus. Die grafische Qualität und der Detailreichtum der Spielewelt waren bis dato unerreicht, wobei insbesondere die Wasserdarstellung hoch gelobt und als richtungsweisend angesehen wurde. Dreidimensionale Wellenbewegungen gab es zwar noch nicht zu bestaunen, dafür jedoch in Echtzeit berechnete Spiegelungen auf der Wasserfläche, Refraktionseffekte (Lichtbrechung), Unterwasserkaustiken sowie Brandungswellen.

Herausforderungen und Schwierigkeiten bei klassischer Wasserdarstellung

In den vorangegangenen Artikeln sind wir bereits mehrfach und ausführlich darauf zu sprechen gekommen, aufgrund welcher Nachteile die klassische Vorgehensweise bei der Echtzeitszenendarstellung, Forward Rendering genannt, mittlerweile nahezu vollständig durch einen grundlegend anderen Ansatz, das Deferred Rendering (Lighting), abgelöst wurde. Bevor wir uns gleich ausführlich damit befassen, inwieweit auch die Wasserdarstellung (Abb. 1, 2 und 3) von dieser neuartigen Technik (die Durchführung der Beleuchtungs- und Post-Processing-Berechnungen erfolgt zeitlich verzögert nach Abschluss der Geometrieverarbeitung) profitieren kann, sollten wir zunächst mit einigen Worten auf die Schwierigkeiten eingehen, die im Rahmen des klassischen Forward Renderings (Geometrieverarbeitung und Beleuchtungsberechnungen erfolgen hierbei zeitgleich) zu bewältigen sind.

Abb. 1: Wasserdarstellung (OpenGL-Framework-Demoprogramm 35)

Abb. 2: Wasserdarstellung

Zu Beginn des neuen Millenniums kamen bei der Wasserdarstellung lediglich blaue, halbtransparente Polygonflächen zum Einsatz, die dann einige Zeit später mithilfe von Texturanimationen optisch aufgewertet wurden. Für die Simulation der physikalischen Eigenschaften des Wassers war die Leistung der damaligen Prozessoren und Grafikkarten jedoch bei Weitem nicht ausreichend. Die besagten Wassereffekte wirkten primitiv und ließen sich ohne viel Aufwand implementieren – es war lediglich erforderlich, im letzten Schritt der Szenendarstellung alle sichtbaren halbtransparenten Wasserflächen bei aktiviertem Color Blending zu rendern. Wollte man jedoch darüber hinaus die grundverschiedenen Lichtverhältnisse an Land bzw. im Wasser berücksichtigen, so wurden die Dinge schlagartig komplizierter. Die Tatsache, dass man in diesem Zusammenhang für die Szenengeometrie unterhalb und oberhalb der Wasseroberfläche unterschiedliche Beleuchtungsberechnungen durchführen musste, machte es erforderlich, die Spielewelt in zwei Schritten zu rendern und die Wasserfläche in diesem Zusammenhang als Clipping Plane zu verwenden. Wie dem auch sei, die verbesserten Beleuchtungsberechnungen führten jedoch nicht dazu, dass die Wassereffekte in den damaligen Spielen auch nur ansatzweise wie echtes Wasser wirkten. Dies sollte sich erstmals mit der Darstellung von in Echtzeit berechneten Wasserspiegelungen ändern. Abermals verkomplizierte sich die Situation, denn hierfür war es nicht nur erforderlich, die gespiegelte Szenengeometrie von einer anderen Blickrichtung und einer anderen Position aus in einem separaten Render Target (Render to Texture) zwischenzuspeichern; fehlerhafte Wasserspiegelungen ließen sich darüber hinaus nur mithilfe zusätzlicher Sichtbarkeitstests vermeiden. Wie genau sich besagte Positionen und Blickrichtungen für diesen zusätzlichen Render-Durchgang ermitteln lassen, werden wir an dieser Stelle nicht im Detail erörtern. Stattdessen belassen wir es bei einem einfachen Beispiel: Blickt man als Spieler senkrecht auf die Wasseroberfläche, dann weist die gespiegelte Blickrichtung senkrecht zum Himmel. Befindet man sich als Spieler einen Meter über dem Wasser, dann befindet sich die gespiegelte Position einen Meter unterhalb der Wasseroberfläche.

Abb. 3: Wasserdarstellung

Den Wellengang simulierte man zunächst ausschließlich mittels einer Echtzeitmodifikation der Normalenvektoren der Wasseroberfläche (keine echten 3-D-Wellen), was zu einer verzerrten Darstellung der Wasserspiegelungen führt. Die tägliche Erfahrung lehrt uns jedoch, dass sich die Wellenbewegungen nicht nur auf das Erscheinungsbild der Reflexionen auswirken; Gleiches gilt für die Wahrnehmung aller Objekte, die sich auf der anderen Seite der Wasserfläche befinden. So hat ein Beobachter außerhalb des Wassers einen ähnlich verzerrten Blick auf die Unterwasserwelt wie ein Taucher auf die Welt über dem Wasser. Verantwortlich hierfür ist das gleiche optische Phänomen, dem wir auch die Regenbogen am Himmel zu verdanken haben – die Lichtbrechung bzw. Refraktion, welche immer am Übergang zweier optisch unterschiedlich dichter Medien (z. B. Luft und Wasser) auftritt. Wie in Abbildung 4 gezeigt, werden die Lichtstrahlen beim Übergang von der Luft (optische Dichte = 1) ins Wasser (optische Dichte = 1,333) zum Normalenvektor der Wasserfläche hin gebrochen, wobei die Änderung der Ausbreitungsrichtung bei kurzwelligem blauen Licht deutlich stärker ausgeprägt ist als bei langwelligem roten Licht (Dispersion, „Regenbogeneffekt“). Tritt ein Lichtstrahl hingegen aus einem optisch dichteren Medium aus, so wird dieser vom Normalenvektor der Grenzfläche weggebrochen. Quantitativ beschreiben lässt sich dieses Verhalten mithilfe des Brechungsgesetzes nach Snellius (Abb. 4).

Abb. 4: Simulation der Lichtbrechung beim Luft-Wasser-Übergang

Da die Darstellung dieser Refraktionseffekte mithilfe des bereits zur Sprache gekommenen Color Blendings nicht möglich ist, müssen wir stattdessen ähnlich wie bei der Berechnung der Wasserspiegelungen entweder die Unterwasserszenerie (sofern sich der Beobachter bzw. die Kamera über dem Wasser befindet) oder aber die 3-D-Szene außerhalb des Wassers (sofern sich die Kamera unter Wasser befindet) in einem weiteren Render Target zwischenspeichern. Hier nun eine Übersicht über alle Schritte, welche zur Darstellung einer Wasserfläche samt Spiegelungen und Refraktionseffekten erforderlich sind.

Fall 1 – Kamera über dem Wasser
• Im ersten Schritt wird die an der Wasseroberfläche gespiegelte 3-D-Szene in ein zusätzliches Render Target (Reflection Map) gezeichnet (Render to Texture).
• Im zweiten Schritt wird die Unterwasserwelt in ein zweites Render Target (Refraction Map) gezeichnet.
• Im dritten Schritt erfolgt die Wasserdarstellung, in deren Verlauf die zuvor generierten Texturen auf die Wasserfläche gemappt werden (Projective Texturing/Reflection bzw. Refraction Mapping).
• Im letzten Schritt wird schließlich die 3-D-Szene über dem Wasserspiegel gerendert.

Fall 2 – Kamera im Wasser
• Im ersten Schritt wird die 3-D-Szene oberhalb des Wasserspiegels in ein zusätzliches Render Target (Refraction Map) gezeichnet.
• Im zweiten Schritt erfolgt die Wasserdarstellung, in deren Verlauf die zuvor erstellte Refraction Map auf die Wasserfläche gemappt wird.
• Im letzten Schritt wird schließlich die Unterwasserwelt gerendert.

Als wäre das alles nicht schon kompliziert genug, wartet auf uns bereits die nächste Herausforderung – die Simulation von dreidimensionalen Wellenbewegungen. Während sich ebene Wasserflächen mithilfe einfacher Vertex-Quads darstellen lassen, erfordert die Visualisierung von realistisch wirkenden Wasserwellen ein engmaschiges Vertexgitter, welches noch dazu in Echtzeit animiert werden muss. Neben der offenkundigen Frage, wie sich eine solch große Anzahl von Wasser-Vertices ohne einen signifikanten Einbruch der Frame Rate animieren und rendern lässt, steht man als Programmierer noch vor einem ganz anderen Problem, für welches es im Rahmen der klassischen Wasserdarstellung leider keine saubere Lösung gibt: Sämtliche unserer Reflexions- und Refraktionsberechnungen liefern streng genommen nur bei ebenen Wasserflächen ein korrektes Ergebnis, da sich bei einer welligen Oberfläche die Lage der Reflexionsebene bzw. der Refraktionsgrenzfläche von Vertex zu Vertex verändert. Um die Performanceproblematik in den Griff zu bekommen, basiert die Wasserdarstellung in der Mehrzahl aller Game Engines heutzutage auf dem so genannten Projected-Grid-Konzept. Da es aussichtslos wäre, weitläufige Wasserflächen mithilfe von hochaufgelösten Vertexgittern nachbilden zu wollen, projiziert man die Wasserflächen stattdessen einfach auf ein im Projektionsraum (Bildraum, Screen Space) definiertes uniformes Vertexgitter. Durch Animation der einzelnen Vertices ergeben sich dann die gewünschten Wellenbewegungen.

Aufmacherbild: Beautiful beach at Seychelles, Praslin, Anse Lazio von Shutterstock / Urheberrecht: haveseen

[ header = Seite 2: Deferred Water Rendering – ein Praxisbeispiel zum Einstieg ]

Deferred Water Rendering – ein Praxisbeispiel zum Einstieg

Nach unserem kleinen Nostalgietrip dürfte wohl kein Zweifel mehr über den Aufwand bestehen, der mit der Integration von realistisch wirkenden Wassereffekten in ein klassisches Forward-Rendering-Framework verbunden ist. Nun aber zur guten Nachricht: Über viele der zuvor besprochenen Stolpersteine müssen wir uns mittlerweile gottlob keine Gedanken mehr machen. Im Rahmen des Deferred Renderings ist es heutzutage problemlos möglich, die einzelnen Wasserflächen nachträglich – unter Verwendung der in einem Render Target zwischengespeicherten Daten der Szenengeometrie (Kameraraumpositionen sowie Tiefenwerte aller Szenenpixel) – in eine ansonsten fertig berechnete 3-D-Szene zu integrieren. An der sonstigen Szenendarstellung ändert sich nichts. Hochaufgelöste Vertexgitter für die Wellensimulationen sind im Prinzip nicht mehr erforderlich, und Wasserspiegelungen sowie Refraktionseffekte lassen sich ohne zusätzliche Render-Durchgänge implementieren.
Bevor wir gleich tiefer in die jeweiligen Post-Processing-Berechnungen einsteigen, werden wir uns zunächst einmal damit befassen, wie die Darstellung einer 3-D-Szene im Rahmen des Deferred Renderings vonstattengeht und in diesem Zusammenhang die hierfür erforderlichen Schritte anhand eines einfachen Beispiels gemeinsam durcharbeiten.

• Im ersten Schritt werden zur Vorbereitung eines neuen Render-Durchgangs die Lichteigenschaften für die Szenenbeleuchtung festgelegt:

PostProcessingEffects->Reset_ScreenSpaceLights();
PostProcessingEffects->Add_Light(...);
PostProcessingEffects->Set_AmbientLight(...);
PostProcessingEffects->Prepare_DeferredLighting(&g_BackgroundScreenColor);

• Im zweiten Schritt wird vor Beginn des eigentlichen Render-Durchgangs zunächst ein verkleinertes Abbild des Szenenhintergrunds (die verwendete Auflösung beträgt in unserem Programmbeispiel lediglich 64 mal 64 Pixel) in ein separates Render Target gezeichnet. Die hierbei gespeicherten Farbinformationen kommen im späteren Verlauf – im Rahmen der Wasserdarstellung – bei der Berechnung von Nebel- und Dunsteffekten (Haze) zum Einsatz:

PostProcessingEffects->Begin_BackgroundRendering(&g_BackgroundScreenColor);
SkySphere->Render_BackgroundSphere(&g_ViewProjectionMatrix, 2,
                                   &g_BackgroundTextureColor);
PostProcessingEffects->Stop_BackgroundRendering();

• Im dritten Schritt kümmern wir uns zunächst um die Hintergrunddarstellung:

PostProcessingEffects->Continue_SceneRendering(RM_SceneColorOnly);
SkySphere->Render_BackgroundSphere(&g_ViewProjectionMatrix, 2,
                                   &g_BackgroundTextureColor);
PostProcessingEffects->Stop_SceneRendering();

• Im vierten Schritt erfolgt die räumliche Begrenzung der Wasserflächen mithilfe eines Wasserbassins. Für die Wasserdarstellung relevant sind für uns lediglich dessen geometrische Eigenschaften, mit anderen Worten die Kameraraumpositionen sowie Tiefenwerte der zugehörigen Pixel. Da die Darstellung des Bassins mit hundertprozentiger Transparenz erfolgt, sind die zugehörigen Farbwerte selbst jedoch nicht sichtbar (Listings 1 und 2):

PostProcessingEffects->Continue_SceneRendering(RM_ScreenSpaceLighting);
Water->Render_Bassin(&g_CameraPosition, &g_ViewProjectionMatrix);

• Im fünften Schritt erfolgt die Darstellung der Spielewelt (in unserem Programmbeispiel besteht diese lediglich aus einem kleinen Terrain):

LODTerrainWithTextureSplatting->Render_TerrainTiles(&g_CameraPosition,
                                                    &g_ViewProjectionMatrix);
PostProcessingEffects->Stop_SceneRendering();

• Nach Abschluss der Geometrieverarbeitung können wir schließlich im sechsten Schritt die Beleuchtungsberechnungen durchführen:

PostProcessingEffects->Calculate_ScreenSpaceLighting(&g_CameraViewDirection, 1);

• Im siebten Schritt müssen wir die geometrischen Eigenschaften der Wasserfläche(n) (Kameraraumpositionen sowie Tiefenwerte der zugehörigen Pixel) in einem separaten Render Target zwischenspeichern (Listing 3 und 4). Für die Darstellung einer Wasserfläche verwenden wir ein simples Vertex-Quad. Auf die Verwendung eines animierten feinmaschigen Vertexgitters können wir hingegen verzichten, da wir sämtliche 3-D-Wellenbewegungen im Verlauf der Post-Processing-Phase simulieren:

PostProcessingEffects->Begin_WaterRendering();
Water->Render_Surface(...);
PostProcessingEffects->Stop_WaterRendering();

• Im achten Schritt erfolgt schließlich die eigentliche Wasserdarstellung:

PostProcessingEffects->Render_3DWaterWithFoamWithCaustics(...);

• Nach Abschluss der Post-Processing-Phase wird im letzten Schritt das fertig berechnete Szenenbild als Textur auf ein bildschirmfüllendes Vertex-Quad gemappt. Im Zuge der Helligkeitsberechnungen werden dunkle Szenen etwas aufgehellt (in dunklen Räumen weitet sich die Pupille des Auges, wodurch sich der Lichteinfall vergrößert) und helle Szenen ein wenig abgedunkelt (in hellen Räumen verengt sich die Pupille des Auges, wodurch sich der Lichteinfall verringert):

PostProcessingEffects->Calculate_SceneGlow(...);
PostProcessingEffects->Calculate_Luminance();
PostProcessingEffects->Render_PrimaryScreenQuad(...);

Wasserdarstellung während des Post Processings

Obwohl die in unserem Demoprogramm zum Einsatz kommenden Wasser-Shader auf den ersten Blick überaus komplex erscheinen, lässt sich ihre Funktionsweise dennoch recht einfach nachvollziehen. Durch Vergleich der Geometriedaten der Wasserfläche mit denen der 3-D-Szene (ohne Wasser) lässt sich im Anschluss an die in Abbildung 5 skizzierte 3-D-Wellensimulation (Listing 6) unter Berücksichtigung der aktuellen Kameraposition ermitteln, welche der Szenenpixel oberhalb des Wasserspiegels liegen und welche sich im Wasser befinden:

Abb. 5: 3-D-Wellensimulation (Post-Processing-Effekt)

• Fall 1: Die Kamera befindet sich genau wie die betreffenden Szenenpixel über dem Wasser.
• Fall 2: Die Kamera befindet sich genau wie die betreffenden Szenenpixel im Wasser.
• Fall 3: Die Kamera befindet sich im Wasser, die betreffenden Szenenpixel befinden sich hingegen über dem Wasser.
• Fall 4: Die Kamera befindet sich über dem Wasser; die betreffenden Szenenpixel befinden sich unter Wasser in der Nähe der Kamera.
• Fall 5: Die Kamera befindet sich über dem Wasser; die betreffenden Szenenpixel befinden sich unter Wasser fernab der Kamera.

Für den Fall, dass sich die Kamera über dem Wasser befindet, kann auf eine Modifizierung des Erscheinungsbilds aller ebenfalls über dem Wasser liegenden Szenenpixel (Fall 1) verzichtet werden. Andernfalls müssen die Pixelfarbwerte in Abhängigkeit von ihrer Position mehr oder weniger aufwändig überarbeitet werden. Die einzelnen Fallunterscheidungen und die jeweils erforderlichen Berechnungsschritte können Sie anhand von Listing 5 nachvollziehen. Da sich der vollständige Sourcecode des in unserem Demoprogramm zum Einsatz kommenden Wasser-Shaders (ScreenSpace3DWaterWithFoamWithCaustics.frag) aufgrund seines Umfangs im Rahmen dieses Artikels leider nur auszugsweise wiedergeben lässt, werden wir uns im weiteren Verlauf lediglich auf die wichtigsten Aspekte der Wasserdarstellung konzentrieren.

void main()
{
  vec4 SceneCameraSpacePosAndDepth = max(
       texture(SceneCameraSpacePosAndDepthTexture, gs_TexCoord[0].st),
       texture(SceneCameraSpacePosAndDepthTexture,
       gs_TexCoord[0].st+vec2(0.001, 0.001)));

  vec4 WaterCameraSpacePosAndDepth =
       texture(WaterCameraSpacePosAndDepthTexture, gs_TexCoord[0].st);

  // Sicherstellen, dass Hintergrundbilder (Sky-Sphären, Billboards etc.)
  // wie weit entfernte Hintergrundobjekte behandelt werden können!
  if(SceneCameraSpacePosAndDepth.w < 0.0)
    SceneCameraSpacePosAndDepth.w = 10000000000000.0;

  vec3 WaterCameraSpacePos = WaterCameraSpacePosAndDepth.xyz;
  vec3 NormalizedWaterCameraSpacePos = normalize(WaterCameraSpacePos);

  vec3 SceneWorldSpacePos = SceneCameraSpacePosAndDepth.xyz + CameraPosition; 

  vec2 texCoord;

  // [Berechnung des 3-D-Welleneffekts, Listing 6]

  // Kamera sowie Szenenpixel über dem Wasser (Fall 1):
  if(CameraPosition.y > correctedWaterSurfaceHeight && WaterDepth < 0.0)
    gs_FragColor = texture(ScreenTexture, gs_TexCoord[0].st);
  else
  {
    // [Berechnung der Wellennormalenvektoren, Listing 7]
  
    // Kamera im Wasser:
    if(CameraPosition.y < correctedWaterSurfaceHeight)
    {
      // Szenenpixel befindet sich im Wasser (Fall 2):
      if(WaterDepth > 0.0)
      {
        // [Lichtabsorption des Wassers simulieren, Listing 10]
        // [Unterwasserkaustiken berechnen, Listing 14]
        vec4 ScreenColor = texture(ScreenTexture, gs_TexCoord[0].st);
        CausticsIntensity *= (max(ScreenColor.x, max(ScreenColor.y, ScreenColor.z)));

        // Wasserfarbe ohne Wellenbewegung!!
        float diffuseIntensityWater = 0.35;

        // Neuberechnung der Pixelfarbe:
        gs_FragColor = vec4(DepthColorValues*depthDependedLightIntensity, 1.0)*
          (CausticsColor*CausticsIntensity+ScreenColor) +
          WaterColor*vec4(diffuseIntensityWater*InvDepthColorValues, 1.0);
      }
      // Kamera im Wasser, Szenenpixel über dem Wasser (Fall 3):
      else
      {
        WaterDepth = WaterSurfaceHeight-CameraPosition.y;
        float diffuseIntensityWater = 0.35;

        // [Lichtabsorption des Wassers simulieren, Listing 11]
        // [Refraktions/Verzerrungseffekt simulieren, Listing 15]
        // [spiegelnde Reflexionen auf der Wasseroberfläche, Listing 9]
        // [Neuberechnung der Pixelfarbe (Kamera im Wasser, Szenenpixel oberhalb) Listing 17]
    }}
    // Kamera über dem Wasser, Wassertiefe größer null: 
    else if(WaterDepth > 0.0)
    {
      // [Berechnung der lokalen Wasserspiegelungen (Screen Space Reflections), Listing 8]

      // [spiegelnde Reflexionen auf der Wasseroberfläche, Listing 9]
      // [Schaumkronen u. Brandung, Listing 13]

      float InsideWaterViewDistance = SceneCameraSpacePosAndDepth.w –
        WaterCameraSpacePosAndDepth.w;

      float depthValueBlue = min(1.0, max(0.0,(InsideWaterMaxViewDistance –
        InsideWaterViewDistance)*InsideWaterInvMaxViewDistance));

      // Szenenpixel im Wasser in der Nähe der Kamera (Fall 4): 
      if(depthValueBlue > 0.0 && SceneCameraSpacePosAndDepth.w < 2000.0)
      {
        // [Lichtabsorption des Wassers simulieren, Listing 12]
        // [Refraktions/Verzerrungseffekt simulieren, Listing 16]
        // [Unterwasserkaustiken berechnen, Listing 14]
        // [Neuberechnung der Pixelfarbe (Kamera über dem Wasser, Unterwasserpixel nahe der Kamera), Listing 18]
      }
      // Szenenpixel im Wasser fernab der Kamera (Fall 5): 
      else
      {
        // Nebel- bzw. Dunstberechnungen:
        float OneMinusHazeValue = max(0.0, (2000.0-WaterCameraSpacePosAndDepth.w)*0.0005);
        float HazeValue = 1.0-OneMinusHazeValue;

        gs_FragColor = HazeValue*texture(BackgroundScreenTexture, 
          gs_TexCoord[0].st + OneMinusHazeValue*(FoamColor + 
            diffuseIntensity*(WaterColor + ScreenSpaceReflectionColor + 
              SpecularIntensity*vec4(LightColor.xyz, 1.0) +
              EnvironmentSpecularIntensity*EnvironmentLightColor)));
    }}
    // Szenengeometrie außerhalb des Wassers (Fall 1):
    else
      gs_FragColor = texture(ScreenTexture, gs_TexCoord[0].st);
}}

[ header = Seite 3: 3-D-Wellenbewegungen als Post-Processing-Effekt ]

3-D-Wellenbewegungen als Post-Processing-Effekt

Ob Meere, Seen oder Flüsse – dreidimensionale Wellenbewegungen gehören in heutigen Spielen zu einem unverzichtbaren Bestandteil der Wasserdarstellung. Basis einer jeden Echtzeitwellensimulation sind entweder mathematische Wellenfunktionen, aus einer Height Map (Abb. 6) ausgelesene Höhenwerte, die in einem zweiten Schritt zeitlich moduliert werden oder aber, wie in Listing 6 verwendet, eine Kombination aus beiden Ansätzen. Die zugehörigen mathematischen Grundlagen werden wir im nachfolgenden Abschnitt behandeln, für den Moment liegt unser Augenmerk auf der Fragestellung, wie sich das Ergebnis einer solchen Wellensimulation grafisch darstellen lässt. Bei der klassischen Wasserdarstellung im Rahmen des Forward Renderings ist man, wie bereits erwähnt, auf den Einsatz von feinmaschigen Vertexgittern angewiesen, deren einzelne Vertices in Echtzeit animiert und gerendert werden müssen. Beim Deferred Water Rendering kommen wir jedoch ganz ohne solche Vertexgitter aus. Ausgangspunkt aller weiteren Berechnungen sind die Geometriedaten einer flachen Wasserfläche.
Abb. 6: Höhentextur für die Beschreibung der Wasseroberfläche
Nehmen wir einmal an, dass gemäß Abbildung 5 bei einer ebenen Wasserfläche unter einem ganz bestimmten Blickwinkel am Punkt a’ ein Wasserflächenpixel sichtbar wäre. Betrachtet man jedoch stattdessen die wellige Wasserfläche, so würde man bei gleichem Blickwinkel anstelle des Pixels am Punkt a’ ein Wasserflächenpixel am Punkt a erblicken. Um nun einen 3-D-Welleneffekt simulieren zu können, müssen wir, wie in Listing 6 gezeigt, die Pixelpositionen (a’, b’, c’ usw.) einer ebenen Wasserfläche in die Pixelpositionen (a, b, c usw.) einer welligen Wasserfläche umrechnen (GPU Ray Marching).

float MaxCalculationStep;

if(CameraPosition.y > WaterSurfaceHeight)
  MaxCalculationStep = 10.0*MaxWaveAmplitude;
else
  MaxCalculationStep = -10.0*MaxWaveAmplitude;

float ActualCalculationStep = 0.5*MaxCalculationStep;

vec3 TestCameraSpacePos = WaterCameraSpacePos- ActualCalculationStep*NormalizedWaterCameraSpacePos;

float testHeight = 0.0;
vec3 TestWorldSpacePos;
vec2 texCoordWaveSim;
float PhaseAngle, cosPhaseAngle;

for(int i = 0; i < 10; i++)
{
  TestWorldSpacePos = TestCameraSpacePos+CameraPosition;

  texCoordWaveSim.x = WaveValues.x*TestWorldSpacePos.x+0.5;
  texCoordWaveSim.y = 0.5-WaveValues.x*TestWorldSpacePos.z;

  /* einfache Sinuswelle:
  testHeight = NegWindVector.y*(sin(WaveFrontValues.w+
  NegWindVector.x*TestWorldSpacePos.x+NegWindVector.z*TestWorldSpacePos.z) +
  WaveFrontValues.x*(2.0*texture(WaterHeightTexture, texCoordWaveSim).x-
  1.0));*/

  // zykloid (trochoid) ähnliche Welle:
  PhaseAngle = WaveFrontValues.w+NegWindVector.x*TestWorldSpacePos.x+ NegWindVector.z*TestWorldSpacePos.z;

  cosPhaseAngle = cos(PhaseAngle);

  testHeight = NegWindVector.y*(sin(PhaseAngle - 0.5*cosPhaseAngle) + WaveFrontValues.x*(2.0*texture(WaterHeightTexture, exCoordWaveSim).x-1.0));

  // Hinweise:
  // NegWindVector.y und WaveFrontValues.x entsprechen den Wellenamplituden
  // WaveFrontValues.w entspricht der Simulationszeit

  ActualCalculationStep *= 0.5;

  if((TestCameraSpacePos.y-WaterCameraSpacePos.y) < testHeight)
  {
    TestCameraSpacePos = TestCameraSpacePos – ActualCalculationStep*NormalizedWaterCameraSpacePos;
  }
  else
  {
    TestCameraSpacePos = TestCameraSpacePos + ActualCalculationStep*NormalizedWaterCameraSpacePos;
}}

WaterCameraSpacePos = TestCameraSpacePos;
vec3 WaterWorldSpacePos = WaterCameraSpacePos + CameraPosition;
float correctedWaterSurfaceHeight = WaterSurfaceHeight + testHeight;
float WaterDepth = correctedWaterSurfaceHeight-SceneWorldSpacePos.y;

Wellenfunktionen

GPU-Fluid-Simulationen, mit deren Hilfe man das alltägliche Verhalten von Wasser und anderen Flüssigkeiten in der Spielewelt nachzubilden versucht, sind derzeit groß im Kommen. Fluide bestehen aus einer großen Anzahl von miteinander wechselwirkenden Teilchen, wobei die Wechselwirkungskräfte mit zunehmendem Abstand immer geringer werden. Die Stärke und Reichweite dieser Kräfte bestimmt nicht nur die Dichte und das Fließverhalten (Viskosität) einer Flüssigkeit, sondern ist darüber hinaus für sämtliche Wellenphänomene verantwortlich, die es im Rahmen der Wasserdarstellung zu simulieren gilt. Am Beispiel eines Steins, den man ins Wasser wirft, lässt sich das zugrunde liegende physikalische Prinzip besonders gut veranschaulichen. Zunächst einmal geraten lediglich die Wassermoleküle am Eintrittspunkt in Bewegung. Die Anziehungskräfte verhindern jedoch, dass sich die einzelnen Teilchen unabhängig voneinander bewegen können. Die Folge davon ist, dass sich die Bewegungsenergie auf die benachbarten Moleküle überträgt und es zur Ausbildung von kreisförmigen Wellenmustern an der Wasseroberfläche kommt.
Eine tiefergehende physikalische Grundlagendiskussion ist gottlob an dieser Stelle nicht erforderlich – ein wenig Erfahrung im Umgang mit Sinus- und Kosinusfunktionen ist vollkommen ausreichend. Die wohl einfachste Wellenform, die zumindest einigen von Ihnen noch aus dem Physikunterricht vertraut sein dürfte, ist die harmonische Welle, die sich mithilfe der folgenden Gleichungen beschreiben lässt:

Wellenhöhe bzw. Elongation = Amplitude*sin(Phasenwinkel)
Wellenhöhe bzw. Elongation = Amplitude*cos(Phasenwinkel)

Die maximale Höhe einer Welle wird durch ihre Amplitude festgelegt. Die tatsächliche Wellenhöhe hängt jedoch vom Wert des Phasenwinkels ab, der seinerseits eine Funktion der Zeit, des Orts bzw. der zugehörigen Texturkoordinaten ist (Listing 6).
Deutlich besser an die Geometrie einer bewegten Wasserfläche angepasst sind hingegen die so genannten zykloidalen (trochoidalen) Wellen, auch Gerstner-Wellen genannt. Um den Rechenaufwand bei der Durchführung unserer Wellensimulation zu minimieren, verwenden wir jedoch einen modifizierten mathematischen Ansatz, mit dessen Hilfe wir sowohl harmonische (Formfaktor gleich 0) wie auch zykloidähnliche Wellen (Formfaktor ungleich 0) generieren können:

Wellenhöhe bzw. Elongation = Amplitude*
sin(Phasenwinkel-Formfaktor*cos(Phasenwinkel))

Eine einzelne Wellenfunktion hilft uns jedoch bei der Darstellung einer realistisch wirkenden Wasserfläche nicht wirklich weiter. Hierzu wäre es erforderlich, eine große Anzahl von Wellenfunktionen mit unterschiedlichen Welleneigenschaften (Amplitude, Wellenlänge, Ausbreitungsgeschwindigkeit) miteinander zu kombinieren:

Elongation = Elongation(Wellenfunktion 1) + Elongation(Wellenfunktion 2) + ...

Bei einer Echtzeitsimulation ist dieser Ansatz jedoch angesichts des enormen Rechenaufwands wenig zielführend. Stattdessen bietet es sich an, entweder die verwendete Wellenfunktion selbst oder aber die berechneten Wellenhöhen mithilfe der in einer Height Map gespeicherten Höhenwerte wie nachfolgend gezeigt zu modifizieren:

• Modifikation der Wellenamplitude mithilfe der in einer Height Map gespeicherten Höhenwerte (Amplitude = HeightMapValue bzw. Amplitude = BaseValue + HeightMapValue).
• Verwendung der in einer Height Map gespeicherten Höhenwerte als Höhen-Offset (Elongation += HeightmapValue).

Berechnung der Normalenvektoren einer Wasserfläche

Im nächsten Schritt müssen wir uns darüber Gedanken machen, wie wir für die einzelnen Wasserflächenpixel einen passenden Normalenvektor ermitteln können. Da es hierbei weniger auf die Genauigkeit als auf die Glaubwürdigkeit des Ergebnisses ankommt, begnügen wir uns in Listing 7 mit einer einfachen Abschätzung. Sofern sich die betrachtete Wasserfläche in der xz-Ebene ausdehnt und die Höhenwerte von jeweils vier benachbarten Wasserflächenpixeln bekannt sind, lässt sich der gesuchte Normalenvektor näherungsweise wie folgt berechnen:

WaveNormal = normalize(vec3(Höhe(x1)-Höhe(x2),
                            Gewichtungsfaktor,
                            Höhe(z1)-Höhe(z2)));

Mithilfe des Gewichtungsfaktors können wir auf einfache Weise festlegen, wie stark sich die Schwankungen der Höhenwerte auf die Richtung des Normalenvektors auswirken sollen. Möchte man darüber hinaus sicherstellen, dass sich die Wellencharakteristika mit zunehmendem Kameraabstand mehr und mehr abschwächen (aus großer Höhe sind selbst bei starkem Seegang kaum noch einzelne Wasserwellen auszumachen), dann bietet sich bei der Berechnung des Gewichtungsfaktors die folgende Vorgehensweise an:

Gewichtungsfaktor = Basiswert + Distanzfaktor*Kameraabstand(Wasserflächen-Pixel)

Beleuchtungsberechnungen

Kommen wir nun zu den Beleuchtungsberechnungen. Im ersten Schritt simulieren wir zunächst etwaige Helligkeitsschwankungen (diffuseIntensity, siehe Listing 7) der Wasserfläche, die durch unterschiedliche Höhenwerte benachbarter Wasserflächenpixel hervorgerufen werden. Die zugrunde liegende Idee ist denkbar einfach. Wellenberge erscheinen etwas heller als Wellentäler, da sie mehr Licht in Richtung des Spielers reflektieren können:

Helligkeit = Basiswert + Intensitätsfaktor*Wellenhöhe

Die Helligkeitsschwankungen der Wasserfläche wirken sich sowohl auf die Grundfarbe des Wassers wie auch auf die Wasserspiegelungen aus:

Wasserfarbe = Helligkeit*(Grundfarbe des Wassers + Wasserspiegelungen) + Refraktionsfarbe
Wasserspiegelungen = lokale Wasserspiegelungen +
                     spiegelnde Reflexion des Sonnenlichts +
                     spiegelnde Reflexion des Umgebungslichts

Die Berechnung der spiegelnden Reflexion des Sonnen- bzw. des Umgebungslichts können Sie anhand von Listing 9 nachvollziehen. Auf die Berechnung der lokalen Wasserspiegelungen werden wir im nächsten Abschnitt zu sprechen kommen.
Um die Refraktionsfarbe berechnen zu können, müssen wir die Farbwerte der Unterwasser- bzw. Überwasserszenenpixel (abhängig von der Position der Kamera) unter Berücksichtigung der Absorptionseigenschaften des Wassers gemäß den Listings 10, 11 und 12 modifizieren. Auf die Einzelheiten der Berechnungen werden wir im Rahmen dieses Artikels nicht weiter eingehen. Nur soviel sei gesagt – mit zunehmender Wassertiefe bzw. mit zunehmendem Kameraabstand wirkt die Unterwasserwelt zusehends blauer, weil die roten und grünen Farbanteile des Lichts deutlich stärker absorbiert werden als der blaue Anteil.

[ header = Seite 4: Lokale Wasserspiegelungen als Post-Processing-Effekt ]

Lokale Wasserspiegelungen als Post-Processing-Effekt

Trotz der enormen Fortschritte, die in den letzten Jahren auf dem Gebiet der Echtzeitgrafikdarstellung erzielt wurden, ist die Berechnung von Spiegelungen nach wie vor mit enormen Schwierigkeiten verbunden. Lange Zeit wurden Spiegeleffekte ausschließlich mithilfe diverser Environment-Mapping-Techniken realisiert. All diesen Verfahren ist jedoch gemein, dass ihre Umsetzung mehrere Render-Durchgänge erfordert und sie dementsprechend wahre Performancekiller sind. Erschwerend kommt hinzu, dass die Berechnungen nur für den jeweils betrachteten Spezialfall – beispielsweise für eine ebene Wasserfläche – ein korrektes Ergebnis liefern.
Aus diesem Grund werden wir an dieser Stelle einen vollkommen anderen Ansatz vorstellen, mit dessen Hilfe sich Spiegelungen, wie in Abbildung 7 veranschaulicht, an beliebigen Oberflächen – beispielsweise an dreidimensionalen Wasserflächen – im Verlauf der Post-Processing-Phase berechnen lassen. Das Verfahren selbst ist schnell erklärt; entlang des an einem Szenenpixel gespiegelten Blickrichtungsvektors wird in diskreten Schritten nach einem möglichen Schnittpunkt mit der Szenengeometrie gesucht (GPU Ray Marching). Die Einzelheiten der Berechnungen können Sie anhand von Listing 8 nachvollziehen. Doch seien Sie gewarnt, dass Verfahren selbst ist natürlich alles andere als perfekt, da lediglich die bereits sichtbaren (die lokalen) Szenenpixel gespiegelt werden können. Blickt man beispielsweise senkrecht auf eine Wasserfläche hinab, würde man mitnichten die Spiegelung des Himmels sehen. Die Berechnungen würden stattdessen ein unsinniges Ergebnis liefern, dass wir schlicht und einfach aussortieren müssen. Zu diesem Zweck berechnen wir nach Abschluss der Ray-Marching-Testdurchläufe einen Intensitätswert für die Stärke der Spiegelung, der lediglich bei zulässigen Resultaten (die gefundenen Texturkoordinaten für den Zugriff auf das gespiegelte Szenenpixel müssen in einem Bereich von 0 bis 1 liegen) einen Wert zwischen 0 und 1 annimmt.

Abb. 7: Berechnung der Wasserspiegelungen (Post-Processing-Effekt)

vec4 ScreenSpaceReflectionColor = vec4(0.0, 0.0, 0.0, 0.0);

if(WaterCameraSpacePosAndDepth.w < ScreenSpaceReflectionRange)
{
  vec3 ReflectionSurfaceNormal = normalize(vec3(Height1-Height2, (5.0+0.025*WaterCameraSpacePosAndDepth.w)* WaterSurfaceNormalCalculationParameter.w, Height3-Height4));

  // gespiegelten Blickrichtungsvektor berechnen:
  vec3 ReflectionVector;
  vec3 ReflectionDirection = reflect(NormalizedWaterCameraSpacePos,
       ReflectionSurfaceNormal);

  float tempDot = dot(ReflectionDirection, ReflectionSurfaceNormal);

  float distanceStep = 7.0/max(0.6, tempDot*tempDot);
  float actualDistanceStep = 0.5*distanceStep;

  vec4 Projection;
  float InvW;
  float ReflectionvectorTexY;
  float ReflectionvectorTexX;
  vec3 diffVector;
  vec4 TestSceneCameraSpacePosAndDepth;
  float DistancSq;
  float distanceTestFactor = 1.0;
  int i;

  for(i = 0; i < 10; i++)
  {
    // neue Ray-Marching-Position berechnen:
    ReflectionVector = WaterCameraSpacePos + actualDistanceStep*ReflectionDirection;

    actualDistanceStep += distanceStep;

    Projection = matViewProjection*vec4(ReflectionVector, 0.0);

    InvW = 1.0/Projection.w;
    ReflectionvectorTexY = 0.5*Projection.y*InvW+0.5;
    ReflectionvectorTexX = 0.5*Projection.x*InvW+0.5;

    TestSceneCameraSpacePosAndDepth = 
      texture(SceneCameraSpacePosAndDepthTexture,
        vec2(ReflectionvectorTexX, ReflectionvectorTexY));

    // quadratischen Abstand zwischen der aktuellen Ray-Marching-Position
    // und der Szenengeometrie ermitteln: 
    diffVector = TestSceneCameraSpacePosAndDepth.xyz-ReflectionVector;
    DistancSq = dot(diffVector, diffVector);

    // Schnittpunkt gefunden?
    if(DistancSq < 16.0*distanceTestFactor)
      break;

    // Genauigkeit des Schnittpunkttests nach 
    // jedem Testdurchlauf ein wenig vergrößern:
    distanceTestFactor *= 1.1;
  }

  // Falls kein Schnittpunkt gefunden wurde, spiegeln wir
  // stattdessen einfach den Szenenhintergrund:
  if(i == 10)
  {
    Projection = matViewProjection*vec4(ReflectionDirection, 0.0);
    InvW = 1.0/Projection.w;
    ReflectionvectorTexY = 0.5*Projection.y*InvW+0.5;
    ReflectionvectorTexX = 0.5*Projection.x*InvW+0.5;
  }

  // Intensität der Spiegelung in Abhängigkeit von den
  // gefundenen Texturkoordinaten berechnen:
  float DTexX = 2.0*ReflectionvectorTexX - 1.0;
  float DTexY = 2.0*ReflectionvectorTexY - 1.0;

  float intensity = 1.0/inversesqrt(inversesqrt(
                    1.0/(0.01+DTexX*DTexX + DTexY*DTexY)));
  //float intensity = sqrt(sqrt(0.01+DTexX*DTexX + DTexY*DTexY));

  intensity = max(1.0-intensity, 0.0)*min(1.0,
    ScreenSpaceReflectionIntensity/WaterCameraSpacePosAndDepth.w);

  ScreenSpaceReflectionColor = intensity*texture(ScreenTexture,
    vec2(ReflectionvectorTexX,ReflectionvectorTexY));
}

Refraktionseffekte

Sofern sich ein Beobachter über dem Wasser befindet, kommt es bei Wellengang aufgrund der unregelmäßigen Lichtbrechung an der Wasseroberfläche zu einer verzerrten Darstellung der Unterwasserwelt, bzw. für einen Taucher zu einer verzerrten Darstellung der Welt über dem Wasser (Abb. 8 und 9). Um diesen Effekt simulieren zu können, müssen wir lediglich die Texturkoordinaten der einzelnen Szenenpixel unter Berücksichtigung der Wasserflächennormalenvektoren wie folgt modifizieren (Listing 15 und 16):

vec2 ModifiedTexCoord = gs_TexCoord[0].st +
     DistortionFactor*vec2(SurfaceNormal.x, SurfaceNormal.z);

Auf eine physikalisch korrekte Berechnung der Verzerrungseffekte (basierend auf der Änderung der Ausbreitungsrichtung der Lichtstrahlen beim Luft-Wasser-Übergang) haben wir im Rahmen unseres Demoprogramms verzichtet. Doch glauben Sie mir, die Spieler werden den Unterschied nicht bemerken.
Abb. 8: Unterwasserdarstellung (1)

Abb. 9: Unterwasserdarstellung (2)

Unterwasserkaustiken

Durch die unregelmäßige Lichtbrechung an der Wasseroberfläche bei Wellengang kommt es bei der Beleuchtung der Unterwasserwelt zu dynamischen Helligkeitsschwankungen, den so genannten Unterwasserkaustiken. Um diesen Effekt darstellen zu können, projizieren wir die Wasserflächenhöhentextur auf die Unterwasserszenengeometrie und rechnen dabei die gespeicherten Höhenwerte in einen möglichst kontrastreichen Helligkeitsverlauf um (Listing 14).

Schaumkronen und Brandungswellen (Foam Rendering)

Zum Abschluss des Artikels gehen wir noch kurz auf die Darstellung von Schaumkronen und Brandungswellen ein. Unter technischen Gesichtspunkten betrachtet ist die Implementierung dieser Effekte wenig spektakulär (Listing 13). Wir müssen hierzu lediglich eine zusätzliche Schaumkronentextur über die Wasserfläche legen und die Intensität des in der Textur gespeicherten Schaumkronenmusters unter Berücksichtigung der Wellenhöhe und der Wassertiefe wie folgt modifizieren:

• Brandungswellen: Mit abnehmender Wassertiefe wird die Intensität des Schaumkronenmusters kontinuierlich erhöht.
• Schaumkronen auf den Wellenbergen: Mit abnehmender Wellenhöhe wird die Intensität des Schaumkronenmusters kontinuierlich verringert.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -