Wellenschlagen (MonoGame)



Normalerweise sind 3D Objekte die Importiert wurden oder programmatisch erstellt wurden sehr statisch. Man kann zwar das 3d Objekt per Transformation bewegen, aber wenn man aus dem Objekt einzelnen Polygone bewegen will, dann sieht das ganze schon etwas schwierig aus.
Vor Jahren hatte ich mal ein PDF Dokument erhalten, das die Animationen eines 3D Models beschrieb. Leider bin ich nie dazugekommen, dies auszuprobieren und mit der Zeit ist auch das Dokument auch verloren gegangen.
Die folgende Beschreibung ist sicherlich für Performante Ausführungen nicht geeignet und ist daher nur für weniger Aufwendige Anwendungen gedacht. Wenn man nicht auf alternativen und kompromisse eingeht.

Grundaufbau
Für dieses Beispiel verwende ich einen Auszug von meinem bereits erstellten Beispiel Projekt 'ExampleMoveOnMap3d' und habe dazu einen neuen Branch mit dem Namen Waterwaves erstellt. In diesen Beispiel werden diesmal keine 3d Objekte importiert, sondern hier wird komplett eine Fläche erzeugt, auf die eine Textur zugewiesen wird.

Die neue Klasse AnimatedWaterwaves wird in den Ordner Map eingesetzt.



Parameter Angaben
Die ersten Members legen die Einstellungen der Fläche fest. Das gewählte Raster 20 mal 20 sollte auf den meisten derzeitigen Rechner ohne weiteres flüssig darstellbar sein. Die sich daraus ergebenen 1600 Polygone sind nicht viel, aber werden vor jedem Rendern neu erzeugt und das kostet Rechenzeit. Aber dazu später mehr.
Die Member Variablen _waveStartX und _waveStartY, legen den aktuellen Startwert beim erzeugen der Wellen fest. Die restlichen Member werden allgemein benötigt, um die Fläche zu halten.
Die üblichen Methoden wie Initialize, Draw und Update sind hier weniger Interessant. Dafür ist die Erzeugung der Wellen ansprechender. Die Methode 'RegenerateVertexBuffer' ist relative lang, was dem geschuldet ist, das ich den Hauptteil in der doppelten Schleife nicht ausgelagert habe.

public class AnimatedWaterwaves  
   {  
     private readonly int _tilesX = 20;  
     private readonly int _tilesY = 20;  
     private readonly float _squareLength = 1f;  
     private float _waveStartX = 0f;  
     private float _waveStartY = 0f;  
     private VertexBuffer _vertexBuffer;  
     private IndexBuffer _indexBuffer;  
     private int _vertexCount;  
     private int _indexCount;  
     private BasicEffect _effect;  
     private readonly Texture2D _texture;  
     private List _vertexPositions;  
     public AnimatedWaterwaves(Texture2D texture)  
     {  
       this._texture = texture;  
     }  
     public void Initialize(GraphicsDevice graphicsDevice)  
     {  
       this._effect = new BasicEffect(graphicsDevice);  
       this._effect.World = Matrix.Identity;  
       this._effect.TextureEnabled = true;  
       this._effect.Texture = this._texture;  
       this._effect.EnableDefaultLighting();  
       this._vertexPositions = this.RegenerateVertexBuffer(graphicsDevice);  
     }  
     private List RegenerateVertexBuffer(GraphicsDevice graphicsDevice)  
     {  
       this.ClearBuffers();  
       this._waveStartX += .05f;  
       this._waveStartY += .2f;  
       List vertices = new List();  
       List index = new List();  
       float setHeight = 1.4f;  
       float waveLengthX = 10f;  
       float waveLengthY = 16f;  
       float aa = 5f / this._tilesY;  
       for (int iY = 0; iY < this._tilesY; iY++)  
       {  
         for (int iX = 0; iX < this._tilesX; iX++)  
         {  
           this.SetIndex(ref index, vertices.Count);  
           float waveX = iX + this._waveStartX;  
           float waveY = iY + this._waveStartY;  
           float heightY = (float)Math.Sin(((waveY + 1) * Math.PI) / waveLengthY);  
           float heigthX1 = (float)Math.Sin((float)(waveX * Math.PI) / waveLengthX);  
           float height1 = (heigthX1 + heightY) * setHeight;  
           float heightY2 = (float)Math.Sin(((waveY + 1) * Math.PI) / waveLengthY);  
           float heigthX2 = (float)Math.Sin((float)((waveX + 1) * Math.PI) / waveLengthX);  
           float height2 = (heigthX2 + heightY2) * setHeight;  
           float heightY3 = (float)Math.Sin((waveY * Math.PI) / waveLengthY);  
           float heigthX3 = (float)Math.Sin((float)(waveX * Math.PI) / waveLengthX);  
           float height3 = (heigthX3 + heightY3) * setHeight;  
           float heightY4 = (float)Math.Sin((waveY * Math.PI) / waveLengthY);  
           float heigthX4 = (float)Math.Sin((float)((waveX + 1) * Math.PI) / waveLengthX);  
           float height4 = (heigthX4 + heightY4) * setHeight;  
           float aaX = aa * iX;  
           float aaY = aa * iY;  
           float aaX1 = (aa * iX) + aa;  
           float aaY1 = (aa * iY) + aa;  
           if (aaX > 1f)  
           {  
             aaX = aaX % 1f;  
             aaX1 = aaX1 % 1f;  
           }  
           if (aaY > 1f)  
           {  
             aaY = aaY % 1f;  
             aaY1 = aaY1 % 1f;  
           }  
           var verticePos1 = new Vector3((iX * this._squareLength) + 0, (iY * this._squareLength) + this._squareLength, height1);  
           var verticePos2 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + this._squareLength, height2);  
           var verticePos3 = new Vector3((iX * this._squareLength) + 0, (iY * this._squareLength) + 0, height3);  
           var verticePos4 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + 0, height4);  
           vertices.Add(new VertexPositionNormalTexture(verticePos1, Vector3.Up, new Vector2(aaX, aaY1)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos2, Vector3.Up, new Vector2(aaX1, aaY1)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos3, Vector3.Up, new Vector2(aaX, aaY)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos4, Vector3.Up, new Vector2(aaX1, aaY)));  
         }  
       }  
       this._vertexCount = vertices.Count;  
       this._indexCount = index.Count;  
       this.InitVertexBuffer(graphicsDevice, vertices);  
       this._indexBuffer.SetData(index.ToArray());  
       return vertices;  
     }  
     public void Update(GraphicsDevice graphicsDevice)  
     {  
       this._vertexPositions = this.RegenerateVertexBuffer(graphicsDevice);  
     }  
     internal void Draw(GraphicsDevice graphicsDevice, Matrix view, Matrix projection)  
     {  
       this._effect.View = view;  
       this._effect.Projection = projection;  
       graphicsDevice.SetVertexBuffer(this._vertexBuffer);  
       graphicsDevice.Indices = this._indexBuffer;  
       foreach (EffectPass pass in this._effect.CurrentTechnique.Passes)  
       {  
         pass.Apply();  
         graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, this._vertexCount);  
       }  
     }  
     private void InitVertexBuffer(GraphicsDevice graphicsDevice, List vertices)  
     {  
       this._vertexBuffer = new VertexBuffer(graphicsDevice, VertexPositionNormalTexture.VertexDeclaration, this._vertexCount, BufferUsage.WriteOnly);  
       this._vertexBuffer.SetData(vertices.ToArray());  
       this._indexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, this._indexCount, BufferUsage.WriteOnly);  
     }  
     private void ClearBuffers()  
     {  
       if (this._vertexBuffer != null)  
       {  
         this._vertexBuffer.Dispose();  
         this._vertexBuffer = null;  
       }  
       if (this._indexBuffer != null)  
       {  
         this._indexBuffer.Dispose();  
         this._indexBuffer = null;  
       }  
     }  
     private void SetIndex(ref List index, int localOffset)  
     {  
       index.Add(localOffset + 0);  
       index.Add(localOffset + 1);  
       index.Add(localOffset + 3);  
       index.Add(localOffset + 0);  
       index.Add(localOffset + 3);  
       index.Add(localOffset + 2);  
     }  
   }

Nun muß noch in ComponentMap mit der neuen Klasse umgestellt werden. Dort wo die 3d Objekte geladen wurden, wird nun die AnimatedWaterwaves instanziiert und initialisiert.

public class ComponentMap : GameComponent  
   {  
     private const float _speed = 0.3f;  
     private readonly ComponentInputs _componentInputs;  
     private AnimatedWaterwaves _animatedWaterwaves;  
     public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)  
     {  
       this._componentInputs = componentInputs;  
     }  
     public override void Initialize()  
     {  
       var texture = this.Game.Content.Load("rpgTile029");  
       this._animatedWaterwaves = new AnimatedWaterwaves(texture);  
       this._animatedWaterwaves.Initialize(this.Game.GraphicsDevice);  
     }  
     public override void Update(GameTime gameTime)  
     {  
       RasterizerState raster = new RasterizerState();  
       raster.CullMode = CullMode.CullCounterClockwiseFace;  
       raster.FillMode = FillMode.WireFrame;  
       this.Game.GraphicsDevice.RasterizerState = raster;  

       this._animatedWaterwaves.Update(this.Game.GraphicsDevice);  
     }  
     public void DrawContent(Matrix view, Matrix projection)  
     {  
       this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;  

       this._animatedWaterwaves.Draw(this.Game.GraphicsDevice, view, projection);  
     }  
   }  

Ausprobieren
Auf dem PC sollte nun nach dem Start die Animation flüssig abgespielt werden. Vergrößert man die Fläche und gibt mehr Polygone hinzu, dann wird irgendwann der Rechner in die Knie gehen. Wie immer testete ich dies auch auf dem  Raspberry Pi, das auf dem folgenden Video zu sehen ist.


Gleich verbessern
Ehrlich gesagt, hat mich das dann doch gestört. Also schrieb ich am Beispiel weiter und auch weil ich bereits eine Lösung im Kopf hatte, die ich ausprobieren wollte. Stellt man die Variablen _tilesX und _tilesY auf 100, dann kommen wir hier auf 400.000 Polygone, aber die Animation ruckelt.

  • Das Problem ist das ständig neu erzeugen der Wellen. 
  • Die Lösung hierfür wäre die Wellenanimation vorher zu generieren und dann die fertige Wellen abzuspielen.


Gitter Erstellen auslagern
Als nächstes wird ein Teil aus der Klasse AnimatedWaterwaves in eine neue Klasse ausgelagert und zwar der Bestandteil für die Polygonen. In diesen Zug wird dann auch erstmal aufgeräumt. Alles was doppelt erscheint wird zu einer Methode zusammengezogen. Damit wird auch die Hauptmethode für das erstellen der Fläche etwas kleiner. Einstellungen werden als const oder einfach als readonly Member angelegt. Der Konstruktor wird nicht gebraucht, auch wenn man den Teil in Initialize, dort hätte einsetzen können.

public class BufferedWave  
 {  
   private float _waveStepX = .05f;  
   private float _waveStepY = .2f;  
   private readonly int _tilesX = 20;  
   private readonly int _tilesY = 20;  
   private readonly float _squareLength = 1f;  
   private float _textureSize;  
   public VertexBuffer VertexBuffer;  
   public IndexBuffer IndexBuffer;  
   public int VertexCount;  
   private int _indexCount;  
   public List VertexPositions;  
   public const float WaveHeight = 1.4f;  
   public const int WaveLengthX = 10;  
   public const int WaveLengthY = 10;  
   public void Initialize(int step, GraphicsDevice graphicsDevice)  
   {  
     this._textureSize = 5f / this._tilesY;  
     this.VertexPositions = this.GenerateVertexBuffer(step, graphicsDevice);  
   }  
   private List GenerateVertexBuffer(int step, GraphicsDevice graphicsDevice)  
   {  
     this.ClearBuffers();  
     var waveStartX = this._waveStepX * step;  
     var waveStartY = this._waveStepY * step;  
     List vertices = new List();  
     List index = new List();  
     for (int iY = 0; iY < this._tilesY; iY++)  
     {  
       for (int iX = 0; iX < this._tilesX; iX++)  
       {  
         this.SetIndex(ref index, vertices.Count);  
         float waveX = iX + waveStartX;  
         float waveY = iY + waveStartY;  
         float height1 = this.GetHeight(waveX, waveY, 0, 1);  
         float height2 = this.GetHeight(waveX, waveY, 1, 1);  
         float height3 = this.GetHeight(waveX, waveY);  
         float height4 = this.GetHeight(waveX, waveY, 1, 0);  
         var verticePos1 = new Vector3((iX * this._squareLength), (iY * this._squareLength) + this._squareLength, height1);  
         var verticePos2 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + this._squareLength, height2);  
         var verticePos3 = new Vector3((iX * this._squareLength), (iY * this._squareLength), height3);  
         var verticePos4 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength), height4);  
         float aaX = this.GetTextureCoordinate(iX);  
         float aaY = this.GetTextureCoordinate(iY);  
         float aaX1 = this.GetTextureCoordinate(iX) + this._textureSize;  
         float aaY1 = this.GetTextureCoordinate(iY) + this._textureSize;  
         vertices.Add(new VertexPositionNormalTexture(verticePos1, Vector3.Up, new Vector2(aaX, aaY1)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos2, Vector3.Up, new Vector2(aaX1, aaY1)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos3, Vector3.Up, new Vector2(aaX, aaY)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos4, Vector3.Up, new Vector2(aaX1, aaY)));  
       }  
     }  
     this.VertexCount = vertices.Count;  
     this._indexCount = index.Count;  
     this.InitVertexBuffer(graphicsDevice, vertices);  
     this.IndexBuffer.SetData(index.ToArray());  
     return vertices;  
   }  
   private float GetHeight(double waveX, double waveY, int x = 0, int y = 0)  
   {  
     float heightY = (float)Math.Sin(((waveY + y) * Math.PI) / WaveLengthY);  
     float heigthX1 = (float)Math.Sin((float)((waveX + x) * Math.PI) / WaveLengthX);  
     return (heigthX1 + heightY) * WaveHeight;  
   }  
   private float GetTextureCoordinate(int index)  
   {  
     var textureCoordinate = this._textureSize * index;  
     if (textureCoordinate >= 1f)  
     {  
       textureCoordinate %= 1f;  
     }  
     return textureCoordinate;  
   }  
   private void InitVertexBuffer(GraphicsDevice graphicsDevice, List vertices)  
   {  
     this.VertexBuffer = new VertexBuffer(graphicsDevice, VertexPositionNormalTexture.VertexDeclaration, this.VertexCount, BufferUsage.WriteOnly);  
     this.VertexBuffer.SetData(vertices.ToArray());  
     this.IndexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, this._indexCount, BufferUsage.WriteOnly);  
   }  
   private void ClearBuffers()  
   {  
     if (this.VertexBuffer != null)  
     {  
       this.VertexBuffer.Dispose();  
       this.VertexBuffer = null;  
     }  
     if (this.IndexBuffer != null)  
     {  
       this.IndexBuffer.Dispose();  
       this.IndexBuffer = null;  
     }  
   }  
   private void SetIndex(ref List index, int localOffset)  
   {  
     index.Add(localOffset + 0);  
     index.Add(localOffset + 1);  
     index.Add(localOffset + 3);  
     index.Add(localOffset + 0);  
     index.Add(localOffset + 3);  
     index.Add(localOffset + 2);  
   }   
}

In der AnimatedWaterwaves bleiben in den Member die Textur und der BasicEffect. Neu ist das Dictionary das später die alle erstellten schritte der Welle beinhaltet. Der index wird in der Update Methode hochgezählt oder zurück gesetzt, wenn das ende des Index Bereiches erreicht ist.
Mit der Methode Initialize werden nun alle schritte generiert. Der BufferIndexSize von 1600 ist mehr zufällig geschätzt, der genau paßt um die Animation in der Schleife abzubilden. Am Ende wird in der Methode Draw noch die bereits angelegten Vertex Werte zugewiesen, nach dem diese per Index Wert aus dem Dictionary abgerufen werden. In den Parametern kommt noch eine offsetPosition und ein offsetIndex rein und ermöglicht den Inhalt zu rendern, der gerade gebraucht wird.

public class AnimatedWaterwavesBuffered  
 {  
   private BasicEffect _effect;  
   private readonly Texture2D _texture;  
   private Dictionary _dictionaryVertexPositions = new Dictionary();  
   public int Index { get; private set; } = 0;  
   public AnimatedWaterwavesBuffered(Texture2D texture)  
   {  
     this._texture = texture;  
   }  
   public void Initialize(GraphicsDevice graphicsDevice)  
   {  
     this._effect = new BasicEffect(graphicsDevice);  
     this._effect.World = Matrix.Identity;  
     this._effect.TextureEnabled = true;  
     this._effect.Texture = this._texture;  
     this._effect.EnableDefaultLighting();  
     // is estimated  
     int bufferIndexSize = 1600;  
     for (int i = 0; i < bufferIndexSize; i++)  
     {  
       BufferedWave bufferedWave = new BufferedWave();  
       bufferedWave.Initialize(i, graphicsDevice);  
       this._dictionaryVertexPositions.Add(i, bufferedWave);  
     }  
   }  
   public void Update()  
   {  
     if (this.Index >= this._dictionaryVertexPositions.Count - 1)  
     {  
       this.Index = 0;  
     }  
     else  
     {  
       this.Index++;  
     }  
   }  
   internal void Draw(GraphicsDevice graphicsDevice, Matrix view, Matrix projection, Vector3 offsetPosition, int offsetIndex)  
   {  
     this._effect.View = view;  
     this._effect.Projection = projection;  
     var index = this.GetIndex(this.Index + offsetIndex);  
     var item = this._dictionaryVertexPositions[index];  
     graphicsDevice.SetVertexBuffer(item.VertexBuffer);  
     graphicsDevice.Indices = item.IndexBuffer;  
     this._effect.World = Matrix.CreateTranslation(offsetPosition);  
     foreach (EffectPass pass in this._effect.CurrentTechnique.Passes)  
     {  
       pass.Apply();  
       graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, item.VertexCount);  
     }  
   }  
   private int GetIndex(int index)  
   {  
     if (index >= this._dictionaryVertexPositions.Count)  
     {  
       index -= this._dictionaryVertexPositions.Count;  
       return this.GetIndex(index);  
     }  
     return index;  
   }  
 }  

Umstellung und Fläche vergrößern
In Update entfällt nun die Übergabe des GraphicsDevice und in DrawContent, wird mit einer doppelten Schleife die Fläche mehrmals an verschiedenen Positionen gerendert.

public class ComponentMap : GameComponent  
 {  
   private const float _speed = 0.3f;  
   private readonly ComponentInputs _componentInputs;  
   public AnimatedWaterwavesBuffered AnimatedWaterwavesBuffered;  
   public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)  
   {  
     this._componentInputs = componentInputs;  
   }  
   public override void Initialize()  
   {  
     var texture = this.Game.Content.Load("rpgTile029");  
     // the buffered waves  
     this.AnimatedWaterwavesBuffered = new AnimatedWaterwavesBuffered(texture);  
     this.AnimatedWaterwavesBuffered.Initialize(this.Game.GraphicsDevice);  
   }  
   public override void Update(GameTime gameTime)  
   {  
     RasterizerState raster = new RasterizerState();  
     raster.CullMode = CullMode.CullCounterClockwiseFace;  
     raster.FillMode = FillMode.WireFrame;  
     this.Game.GraphicsDevice.RasterizerState = raster;  
     // the buffered waves  
     this.AnimatedWaterwavesBuffered.Update();  
   }  
   public void DrawContent(Matrix view, Matrix projection)  
   {  
     this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;  
     // the buffered waves  
     for (int iY = 0; iY < 10; iY++)  
     {  
       for (int iX = 0; iX < 10; iX++)  
       {  
         this.AnimatedWaterwavesBuffered.Draw(this.Game.GraphicsDevice, view, projection, new Vector3(200 - (iX * 20), 200 - (iY * 20), 0), 400 * iY);  
       }  
     }  
   }  
 }  

Die CameraView sollte eine neue Start Position erhalten, damit die neu erzeugte Fläche in der Mitte des RenderScreens erscheint. Zuvor war es, das die Fläche bewegt wurde. In diesen Branch kann man allerdings auch die Kmaera mit den Tasten WASD bewegen.

Leistungsgewinn
Mit 400.000 Polygonen fing die nicht gepufferte Version schnell an zu Ruckeln. Diese Anzahl sollte für ein durchschnittlichen PC oder Notebook möglich sein. Leider existieren auf dem Markt Spiele, die bei sehr geringen Grafikdetails auf kleinen System nur rückelig angezeigt werden.
Hier muß man sich bewußt sein, daß der Teil nicht von der Grafikkarte berechnet wird. Die Umstellung mit gepufferten VertexBuffern führt zu einer geringeren CPU Last und wirkt sich dementsprechend mit einer flüssigen Animation aus.

Fazit
Eigentlich hatte ich nicht vor die gepufferte Version mit rein zu nehmen. Wie immer habe ich den Rapberry Pi verwendet, um zu sehen, wie sich diese Lösung auswirkt. Zwar ruckelt es dort, aber bei weiten nicht so schlimm wie vorher.
Im Grunde war dies auch eine Problem Darstellung und eine mögliche Lösung, ohne gleich HLSL Programmierung anzusetzen. Manipulationen von Drahtgitter Modellen lassen sich besser durch die Grafikarte berechnen und das ging schon bereits mit DirectX 8.1.




Kommentare

Beliebte Posts aus diesem Blog

Arduino Control (Teil 5) - PWM Signal einlesen

RC Fahrtenregler für Lego Kettenfahrzeug

Angular auf dem Raspberry Pi