CPU basiertes Zeichnen von Terrains

Die Anbindung von unterschiedlichen Detailstufen

Die Anbindung von unterschiedlichen Detailstufen

Terrains werden benutzt um größere Gelände abzubilden. Vor allem in Spielen nehmen die Terrains einen immer größer werdenden Stellenwert ein, denn sie ermöglichen es mit überschaubarem Aufwand große Spielwelten zu erzeugen die zudem sehr realistisch wirken. Dekorative Elemente wie Bäume, Häuser und Felsen werden auf dieser Landschaft platziert so dass eine dicht bestückte Welt entsteht in der der Spieler sich dann frei bewegen kann.

In diesem Artikel möchte ich einen möglichen Ansatz zur Erzeugung und dem Rendering von Terrains vorstellen. Dieser Ansatz nutzt die Leistungsfähigkeit moderner Grafikkarten nicht gut aus. Dies liegt daran, dass die CPU viel Arbeit für die Echtzeitoptimierung des Terrains investieren muss. Ein Vorteil des Algorithmus ist allerdings der, dass die Qualität der Darstellung stufenlos eingestellt und zur Laufzeit verändert werden kann.

Definieren der Geländeform

Ein Terrain wird über eine Heightmap definiert. Eine Heightmap ist eine Bilddatei deren Bildpunkte als Höhenangaben interpretiert werden. Es ist eine Art Abbild des Geländes bei einem Blick von oben. Durch diese Art der Definition können große Datenmengen kompakt und zudem für Menschen anschaulich bereitgestellt werden.

Heightmaps können manuell mit einem Malprogramm erstellt werden, doch es gibt auch sehr pfiffige Algorithmen die Heightmaps erzeugen können indem sie unter anderem eine Erosion mit Bildverarbeitungsfiltern simulieren.

Wenn die GPL-Lizenz kein Hindernis ist, dann findet man den Terrain texture generation code von Oddlabs dazu vielleicht sehr hilfreich.

Sehr empfehlen kann ich auch L3DT das auch in der kostenlosen Version ausreichende Features bietet und derzeit mein Tool der Wahl ist für die Erstellung von Heightmaps mitsamt den dazu notwendigen Texturen.

Eine typische Heightmap

Eine typische Heightmap

Die dunklen Stellen sind diejenigen mit dem niedrigsten Höhenwert und die weißen entsprechend die mit dem höchsten. Eine Heightmap sollte immer quadratisch sein und eine genaue Zweierpotenz breit sein – dies macht ihre Verarbeitung sehr viel einfacher. Üblich sind zwischen 512 und 4096 Pixel. Sollen noch größere Heightmaps zum Einsatz kommen, muss man sich mit zunehmender Terraingröße über Caching und das Unterteilen der Heightmap in mehrere Teilbereiche Gedanken machen. Auch wenn die Rechner inzwischen sehr große Hauptspeicher haben ist der Bereich den der Spieler sehen kann stets beschränkt und warum sollte man unnötig Ressourcen vergeuden?

Umwandeln der Heightmap in ein 3D-Modell des Terrains

Da die Heightmap ein Bild ist, besteht diese aus Pixeln und jeder Pixel hat eine x und eine y-Koordinate im Bereich der Bildbreite bzw. -höhe. Wenn die Heightmap die Ausmaße einer glatten Zweierpotenz besitzt, dann ist ihre Breite und Höhe $2^n$.

Die X-Achse der Heightmap wird auf die X-Achse in der 3D-Welt abgebildet, die Y-Achse allerdings auf die Z-Achse. Die Y-Werte werden aus den Höhenwerten der Heightmap ermittelt. Da die Pixelkoordinaten nicht den Weltkoordinaten entsprechen, müssen diese noch transformiert werden:

x = s_{terrain} \frac{x_{pixel}}{s_{heightmap}}-0.5
y = h_{terrain} \frac{h_{pixel}}{255}-0.5
z = s_{terrain} \frac{y_{pixel}}{s_{heightmap}}-0.5
x_{pixel} X Wert des Pixels
y_{pixel} Y Wert des Pixels
h_{pixel} Die Höhe des Terrains an der Position (x_{pixel}, y_{pixel})
s_{heightmap} Breite und Höhe des Heightmap-Image in Pixel
s_{terrain} Die gewünschte Breite und Tiefe des Terrains in Welt-Koordinaten
h_{terrain} Gewünschte Höhe des Terrains in Welt-Koordinaten

Die Y-Achse wird anhand des Grauwertes in einen Bereich von 0 bis 1 konvertiert. Auch diese wird dann noch in den richtigen Bereich hochmultipliziert: Mit der gewünschten maximalen Höhe.

heightMapVectors = new vector[heightMap.getWidth()+1][heightMap.getWidth()+1];
for ( int x=0;xfor ( int y=0;y
1 /**  * Gets the position of a point on the heightmap.  *  * The values for x and y are clamped to the valid range.  *  * @param x x coordinate of the pixel  * @param y y coordinate of the pixel  * @param pos the vector to receive the coordinates  */ private void getVertexFromHeightmap(int x, int y, final Vector pos) {   final double heightMapObjectFactor = terrainExtend / heightMap.getWidth();   final double xx = 2.0*x*heightMapObjectFactor - terrainExtend;   final double zz = 2.0*y*heightMapObjectFactor - terrainExtend;   if ( x>=heightMapWidth-1 ) x=heightMapWidth-1;
  if ( y>=heightMapWidth-1 ) y=heightMapWidth-1;

  int h = heightMap.getRedColorAt(x, y);
  if ( h  final double yy = terrainHeight * (double)h / 255.0;

  pos.x=xx;
  pos.y=yy;
  pos.z=zz;
}

Eine interessante Idee findet man auf der Seite von Shamus Young. Dort schlägt er vor die drei Farbkomponenten für verschiedene Detailgrade zu verwenden um die volle Genauigkeit von 24Bit breiten Pixeln ausnutzen zu können. Sein Tutorial zu seiner Terrain Engine ist zudem insgesamt ausserordentlich lesenswert!

Da die Vertexkoordinaten des Terrains sehr oft benötigt werden und die Umrechnung in Weltkoordinaten Zeit kostet, speichere ich alle Vertices in dem zweidimensionalen Array heightMapVectors.

Die Heightmap wird eine Spalte und Zeile größer

Die Methode getVertexFromHeightmap schneidet die Koordinaten der Heightmappixel auf den gültigen Bereich zurecht weil diese den erlaubten Bereich verlassen werden. Dies hat einen einfachen Grund: Ich arbeite später nur mit den Flächen des Terrains und eine Fläche braucht mehr als einen Punkt um aufgespannt zu werden. 1024 Pixel in der Breite ergeben 1023 Flächen in der Breite. Da ich einen Quadtree einsetzen möchte, benötige ich eine exakte Zweierpotenz an Flächen. Um dies zu erreichen könnte man die Heightmap auf 1025 Pixel ausdehnen – ich dupliziere aber einfach die letzte Spalte und Zeile der Heightmap. Da alle Texturen glatte Zweierpotenzen sein sollen gelten dann auch für die Heightmaps keine besonderen anderen Regeln an die man immer denken muss (und die man gerne vergisst und sich anschließend über Fehlermeldungen wundert).

Das Terrain mit allen Details

Das Terrain mit allen Details

So sieht das Terrain aus wenn ich es mit allen Details zeichne. Das heißt, dass jeder Pixel der Heightmap berücksichtigt wird. Das Programm zeichnet so pro Frame 1024x1024x2 = 2.097.152 Dreiecke. Trotz der Verwendung von VBOs zur Beschleunigung erreicht mein Rechner maximal 1,1 Bilder pro Sekunde. Hier muss noch kräftig optimiert werden!

Frustum Culling mit dem Quadtree

Die erste Optimierung die ich beschreiben werde ist das Frustum Culling. Es sorgt dafür, dass nur das gezeichnet wird was auch für den Beobachter sichtbar ist. Warum sich das lohnt sieht man auf dem folgendem Bild.

Das Terrain mit dem Frustum der den sichtbaren Bereich markiert

Das Terrain mit dem Frustum der den sichtbaren Bereich markiert

Hier sieht man das gesamte Terraingebiet mit dem sichtbaren Bereich in weiß hervorgehoben. Es ist offensichtlich, dass viel Rechenzeit vergeudet wird für die Bereiche die der Beobachter gar nicht sehen kann.

Frustum

Der Frustum ist exakt der Bereich den die Kamera sehen kann – alles was außerhalb des Frustums liegt wird, sollte es doch gerendert werden, nicht auf dem Bildschirm zu sehen sein. Der Frustum hat die Form einer abgeschnittenen Pyramide.

Verwendung eines Quadtrees

Um die sichtbaren Bereiche von den unsichtbaren zu unterscheiden setze ich einen QuadTree ein. Dies ist eine hierarchische Datenstruktur die das Gelände, angefangen mit einem großen Quadrat in immer feinere Quadrate unterteilt. Die unterschiedlichen Grade der Unterteilung werden Levels (Ebenen) genannt. Während der erste Level nur ein Quadrat enthält, sind es beim zweiten schon vier und beim drittten 16 Stück:

Aufbau und Struktur eines Quadtrees

Aufbau und Struktur eines Quadtrees

Das Bild zeigt ein Terrain mit 8×8 Heightmap Pixeln (die auf 9×9 wie oben beschrieben erweitert wurden) und deren resultierenden 8×8 Flächen dann mit einem Quadtree überlagert wurden. Der Übersichtlichkeit wegen habe ich pro Level nur eine einzige Node des Trees eingezeichnet. Man sieht, dass Level 1 das gesamte Feld umfasst und Level 2 nur noch ein viertel davon. Die Folge ist, dass die Breite und Höhe eines Quadrats eines Levels jeweils die Hälfte des darüber liegenden Levels ist.

Der Level 1 des Quadtrees ist die Wurzel, der oberste Knotenpunkt (Root-Node). Dieser enthält den gesamten Bereich. Unterhalb dieser Node befindet sich der Level 2 – dieser enthält nun 4 Nodes, Level 4 schließlich besteht aus 64 (8×8) Quadraten die jeweils nicht weiter unterteilt werden können da sie nur noch Quadrate der Fläche 1×1 sind.

Diese Unterteilung erfolgt jedoch nur organisatorisch: Das bedeutet, dass lediglich immer kleinere Teile des Geländes betrachtet werden – die Heightmap wird nicht wirklich in so viele kleine Teile zerteilt.

Frustum Culling

Um nun das Frustum Culling durchzuführen wird das Terrain anhand dieses Quadtrees gezeichnet: Es wird mit dem Level 1 begonnen.

Für das aktuellen Level und das aktuelle Quadrat dieses Levels wird überprüft ob der Bereich teilweise innerhalb des Frustums liegt.

  • Falls dem nicht so ist und der Bereich komplett ausserhalb des Frustum ist wird er nicht gezeichnet.
  • Falls der Bereich komplett innerhalb des Frustum liegt, wird der gesamte Inhalt direkt gezeichnet.
  • Falls nur ein Teil den Frustum schneidet, werden die Kinder untersucht und dieser Algorithmus rekursiv wieder aufgerufen bis entweder einer der vorgenannten Kriterien eintritt oder die unterste Ebene erreicht ist. Diese wird dann direkt gezeichnet.

Um zu überprüfen ob eine Node innerhalb des Frustums liegt oder nicht wird um diese eine “axis aligned Bounding Box” gelegt und diese dann auf Schnittpunkte mit dem Frustum geprüft. Aus jedem zu prüfenden Quadrat wird eine Box erzeugt die den Grundriss des Quadrats hat und dessen Höhe das gesamte Terraingebiet in ihrem Bereich enthält.

Das folgende Bild zeigt von der Seite gesehen ein paar Quadrate einer Quadtree Ebene und darüber die BoundingBoxes die so gewählt wurden, dass die die Punkte des Terrains enthalten:

Boundingboxes um die Quadtree-Nodes

Boundingboxes um die Quadtree-Nodes

Das Terrain auf den sichtbaren Bereich zugeschnitten

Das Terrain auf den sichtbaren Bereich zugeschnitten

Hier sieht man in weiß wieder den sichtbaren Frustum hervorgehoben und das Terrain, welches nun auf den sichtbaren Bereich zugeschnitten ist. Diese Version kommt bereits mit 362.670 Dreiecken aus und wird schon mit 4,2 Bildern pro Sekunde gerendert.

Betrachtet man das Terrain aber im Wireframe Modus so sieht man, dass immer noch enorm viele Dreiecke verwendet werden. Vor allem im hinteren Bereich bilden die Dreiecke einen einzigen “weißen Brei”. Hier kann man einzelne Dreiecke gar nicht mehr ausmachen und daher würde es auch nichts ausmachen hier einige einfach wegzulassen…

Das gecullte Terrain im Wireframe Modus

Das gecullte Terrain im Wireframe Modus

Laufzeitoptimierung des Terrain-Meshes

Da aufgrund der Perspektive die Dreiecke weiter hinten sehr viel kleiner werden, tragen diese auch zur Qualität des gerenderten Bildes weniger bei. Dies umso weniger je weiter sie entfernt sind. Es bietet sich also an je nach dem wie „wichtig“ (im Sinne von: Das Weglassen wäre sehr deutlich sichtbar und störend für den Betrachter) ein Dreieck ist, dieses einfach wegzulassen und durch ein größeres zu ersetzen.

Die Fehlerformel

Um feststellen zu können welche Dreiecke weggelassen werden können ohne die Qualität zu stark einzuschränken wird eine Formel benötigt die einen Wert für den Fehler liefert. Der Fehler bei einer Vereinfachung resultiert daraus, dass Stützstellen aus der Heightmap weggelassen werden – dies ist klar da größere und damit gröbere Dreiecke verwendet werden.

/**
 * Visits the drawFlags-Tree and marks the nodes so they can be drawn later.
 *
 * @param frustum the frustum to use for frustum culling and LOD
 * @param level the level of the current node to process
 * @param x the x coordinate of the node
 * @param y the y coordinate of the node
 */
private void setDrawFlags(final Frustum frustum,final int level,final int x, final int y) {
  final int[][] flags = drawFlags.get(level-1);

  final double err = getErrorFor(frustum,level,x,y,tmpBBox);
  if ( err < Terrain1ConsoleVars.maxError || level>=maxDepth ) {
    // The error is small enough - draw this node
    flags[x][y]=nodeInfoDraw;
  } else {
    // Error is too high: subdivide further
    flags[x][y]=nodeInfoDontDraw;
    if ( level < maxDepth ) {
      setDrawFlags(frustum, level+1, 2*x, 2*y);
      setDrawFlags(frustum, level+1, 2*x+1, 2*y);
      setDrawFlags(frustum, level+1, 2*x, 2*y+1);
      setDrawFlags(frustum, level+1, 2*x+1, 2*y+1);
    }
  }
}

Das folgende Schaubild zeigt das Prinzip der Fehlerberechnung. Die roten Punkte fallen weg bei einem Vereinfachungsschritt:

Die Berechnung des Fehlers bei der Terrainvereinfachung

Die Berechnung des Fehlers bei der Terrainvereinfachung

Der Fehlerwert ist das Maximum aller Höhendifferenzen die entfallen. Es werden stets die Absolutwerte der Höhendifferenzen verwendet da eine Veränderung nach unten wie nach oben als Fehler gewertet wird. Dieser Fehlerwert wird nun für jede Node des Quadtrees errechnet und das Maximum des Fehlers wird in die darüber liegenden Ebenen durchgereicht – das bedeutet dass die Fehler hin zur Wurzel des Quadtrees nur steigen können.

Dieser Fehlerwert geht derzeit noch davon aus, dass die Höhe des Terrains für den Beobachter konstant ist – die Perspektive ist somit noch gar nicht berücksichtigt worden. Derzeit besagt der Fehlerwert nur unabhängig von der Beobachterposition was für eine Abweichung zum Original-Terrain zu erwarten ist. Wie sich der Fehler auf den Betrachter auswirkt lässt sich aber über den Strahlensatz berechnen:

F_{max} der Maximalfehler der aktuellen Node
ScreenError_{height} das vertikale Ausmaß des Fehlers auf der Near-Plane des Frustums
Frustum_{height} die Höhe des Frustums
Frustum_{near} der Near-Wert des Frustums
distance der Abstand des Nodemittelpunkts zum Frustum Ursprung (der Beobachter)
Der Fehler in Abhängigkeit von der Perspektive

Der Fehler in Abhängigkeit von der Perspektive

Anhand der Daten des Frustums können wir berechnen wie groß der Anteil des Fehlers zur Höhe der Bildschirmdarstellung ist:

errorScreenAbsolut = F_{max} * Frustum_{near} / distance
errorScreenPercent = 100 * errorScreenAbsolut / Frustum_{height}

Mit dieser Formel kann nun ausgerechnet werden wie groß ein Fehler bezogen auf die Höhe des Bildschirm-Bildes ist. Dadurch kann man nun das Berechnen des Terrains so optimieren, dass der Fehler unter einem vorher vergebenen Grenzwert bezüglich dessen Auswirkung auf den erkennbaren Fehler bleibt. Je größer der Grenzwert, desto stärker wird das Gelände optimiert und umso mehr Dreiecke können eingespart werden. Es ist wichtig die Bildschirmauflösung in die Berechung einfliessen zu lassen, denn auf einem 320×200 Pixel Fenster sieht man weniger Details -und damit auch weniger Fehler – als auf einer 1280×1024 Darstellung.

Wenn wir den Fehler als echten Vector darstellen und nicht nur als Zahl die die Höhendifferenz angibt, dann ergibt sich sogar eine weitere Verbesserungsmöglichkeit. Wird dieser Fehlervektor auf die Y-Achse der Kamera projiziert so erhält man den wirklich für die Kamera sichtbaren Fehler. Wenn die Kamera in einem steilen Winkel nach unten auf das Terrain schaut, dann ist der Fehler viel kleiner. Entsprechend auch wenn die Kamera gerade von vorne schaut, der Fehlervektor aber schräg nach vorn zeigt.

Das Prinzip ist wie bei einem Stab der im Boden steckt: Wenn man ihn von oben betrachtet sieht ist er (bezogen auf die Menge des Sichtfeldes den er einnimmt) kleiner aus als wenn man ihn von der Seite betrachtet.

Der Fehler in Abhängigkeit von der Perspektive

Der Fehler in Abhängigkeit von der Perspektive

Die Länge dieses auf die Kamera-Ansicht projizierten Vektors ist der echte Fehler. Dieser wird mit dem Grenzwert verglichen.

Der Grenzwert kann auch zur Laufzeit automatisch verändert werden. Wenn das Programm zum Beispiel feststellt, dass es zuviel Zeit für die Berechnung des Terrains verbraucht. Man könnte sich vorstellen, dass es einen Einstellungsdialog gibt der drei Qualitätsstufen für das Terrain vorsieht: high, medium und low.

Die high-Einstellung begrenzt den erlaubten Fehlerwert auf den Bereich von 0,1 bis 1, medium von 1 bis 3 und low auf den Bereich von 3 bis 8. Das Programm kann dann innerhalb dieser Grenzen die Optimierung frei verändern um eine optimale Zielframerate zu erreichen.

Der Fehlerwert muss für jede Node des Quadtree auf jedem Level eingetragen werden. Als Datenformat habe ich ein ArrayList Containerobjekt gewählt das double[][] Arrays beinhaltet- pro Eintrag im ArrayList-Objekt ist das ein zweidimensionales Array mit den Fehlerwerten. Der Level 1 (der Index 0 des ArrayList Objekts) enthält ein double[1][1] Array. Der Level 2 (Index 1) ein double[2][2]. Die beiden Dimensionen verdoppeln sich mit jedem weiteren Level.

Der Aufbau des Quadtrees

Der Aufbau des Quadtrees

Das Bild zeigt wie der Quadtree aufgebaut ist. Für jeden Level ist das zweidimensionale Array rechts daneben eingezeichnet. Die roten und blauen Elemente zeigen wie das Verhältnis von Vater zu Kind-Nodes aussieht und welche Nodes von den jeweiligen Vätern eingeschlossen werden. Beispielhaft zeigt das Bild für einen Übergang von Level 3 zu Level 4 wie die Koordinaten im Array umgerechnet werden müssen um für eine Vaternode die 4 Kindnodes zu erhalten. Die umgekehrte Rechnung ist sehr viel einfacher: Um die Vaterkoordinaten zu einem Kind zu erhalten sind einfach die X und Y Koordinate durch 2 zu dividieren und die Nachkommastellen abzuschneiden.
Nun sind die Fehlerwerte ermittelt. Der nächste Schritt ist herauszufinden welche Nodes des Quadtrees gezeichnet werden sollen.

Der Zeichenalgorithmus

Es sollten am besten genau die Nodes gezeichnet werden deren Fehlerwert gerade unter den vorgegebenen Grenzwert fällt. Um diese herauszufinden wird der Quadtree rekursiv von oben an durchlaufen und für jede besuchte Node der perspektivkorrigierte Fehlerwert berechnet (errorScreenPercent). Ist dieser Wert kleiner als der Grenzwert, so wird die Node als zu zeichnen markiert und die Rekursion an dieser Stelle beendet und mit der nächsten Node fortgefahren. Solange der Grenzwert jedoch kleiner als der effektive Fehler ist und die Node nicht vollständig unsichtbar ist (das Frustum Culling habe ich kurzerhand hier mit integriert) werden die jeweiligen Kindernodes einen Level tiefer weiter untersucht. Spätestens wenn die maximale Quadtreetiefe erreicht wurde werden die Nodes allerdings zum Zeichnen markiert. Nodes die nicht gezeichnet werden sollen werden ebenfalls entsprechend markiert.

Nach diesem Durchlauf ist bekannt welche Nodes gezeichnet werden sollen und welche nicht (Fehler zu gross oder unsichtbar).

Zur Speicherung dieser Daten wird ein weiterer Quadtree verwendet der den Typ Integer aufnimmt. Diese Integer werden mit den Infos zu der entsprechenden Node des Terrains gefüllt. Es gibt somit vier verschiedene Zustände:

nodeInfoUninitialized noch nicht analysiert, daher undefiniert
nodeInfoDontDraw nicht zu zeichnen, Fehler wäre zu groß
nodeInfoDraw zeichnen
nodeInfoInvisible Node ausserhalb des sichtbaren Frustums

Das Zeichnen des Terrains erfolgt über die Benutzung von VBOs mit indizierten Vertexdaten. Alle Punkte des Terrains (1025×1025 Stück) werden in einem VBO an die Grafikkarte übergeben. Die Punkte sind zudem mit den korrekten Texturkoordinaten bereits fertig ausgestattet. Das Zeichnen der Dreiecke wird dann über die Indizes in diese Liste vorgenommen.

Das Zeichnen erfolgt ebenfalls wieder rekursiv: Jede Node die als zum zeichnen markiert wurde wird dann gezeichnet.

Detaillierter Aufbau einer Node

Detaillierter Aufbau einer Node

Ausgehend von der Mitte als Zentrum werden nacheinander die obere Kante, die rechte, die untere und dann die linke Kante mit Dreiecken gezeichnet. Dies ist in jedem Fall so. Es kann allerdings passieren, dass deutlich mehr Dreiecke nötig sind – und genau dann, wenn eine Node mit einem groben Level an viele mit feineren Leveln angrenzt:

Das Anbinden von angrenzenden, unterschiedlichen Detailstufen

Das Anbinden von angrenzenden, unterschiedlichen Detailstufen

Um die angrenzenden Nodes so zu verbinden müssen die gröberen Nodes auf die angrenzenden feineren Rücksicht nehmen. Dies bedeutet, jede der zu zeichnende Node muss ihre Nachbarn prüfen ob diese feiner sind oder nicht. Dies ist einfach festzustellen, denn eine Nachbarnode ist genau dann feiner wenn die Nachbarnode den Status nodeInfoDontDraw hat. Denn dann war der Fehler der Nachbarnode zu gross und sie wurde weiter unterteilt.

Ist dies der Fall, so muss die Liste der Unternodes des Nachbarn ermittelt werden und die Punkte auf der angrenzenden Seite zur aktuellen Node gesammelt werden. In einer Schleife können dann alle Dreiecke der Seite gezeichnet werden.

Hier sieht man einen Screenshot eines optimierten Terrains. Die hervorgehobene hat zur rechten und unteren Seite die feineren Nodes korrekt angebunden.

Die Anbindung von unterschiedlichen Detailstufen

Die Anbindung von unterschiedlichen Detailstufen

Die Dreiecke die zu zeichnen sind werden in einem intBuffer gesammelt und anschließend mit einem Mal per glBufferDataARB an die Grafikkarte gesendet. Diese zeichnet anschließend mit dem Aufruf von glDrawElements alle Dreiecke in einem Durchgang.

	/**
	 * Recursively visits the nodes and adds the triangle indices to the intBuffer which
	 * need to be rendered.
	 *
	 * @param frustum the frustum to cull with
	 * @param testCull flag whether to test culling for this node
	 * @param level the level to render from
	 * @param x the x coordinate of the node
	 * @param y the y coordinate of the node
	 */
	private void renderTerrain(final Frustum frustum, final boolean testCull, final int level, final int x, finalint y) {
		final int levelExtend = 1 << (level-1);
		final int terrainW = heightMap.getWidth() / levelExtend;
		final int terrainX = x * terrainW;
		final int terrainY = y * terrainW;
		boolean testchildren = false;

		// These are the draw flags for this level
		final int[][] flags = drawFlags.get(level-1);

		// Should we perform a culling test?
		if ( testCull && level < Terrain1ConsoleVars.maxLevelCullTest ) { 			fillBoxFor(level,x,y,tmpBBox); 			if ( !frustum.touches(tmpBBox)) { 				// if the bounding box is outside the frustum - leave now 				return; 			} else { 				if ( frustum.contains(tmpBBox)) { 					// If the box is completely in the frustum we can draw everything beyound us 					testchildren = false; 				} else { 					// we must test further 					testchildren = true; 				} 			} 		} 		// Is the current node to be drawn? 		if ( flags[x][y] == nodeInfoDraw ) { 			// Yes - go for it! 			int drawFlagLeft = nodeInfoDraw; 			int drawFlagRight = nodeInfoDraw; 			int drawFlagTop = nodeInfoDraw; 			int drawFlagBottom = nodeInfoDraw; 			// Check the neighbouring nodes 			if ( x>0 ) {
				// the maximum depth that will be rendered in the node left of us
				drawFlagLeft = flags[x-1][y];
			}
			if ( x0 ) {
				drawFlagTop = flags[x][y-1];
			}
			if ( y				drawFlagBottom = flags[x][y+1];
			}

			//   0--1--2
			//   |\ | /|
			//   | \|/ |
			//   3--4--5
			//   | /|\ |
			//   |/ | \|
			//   6--7--8
			nodeVertexIndices[0]=indexOf(terrainX,terrainY); // 0
			nodeVertexIndices[1]=indexOf(terrainX+terrainW/2,terrainY); // 1
			nodeVertexIndices[2]=indexOf(terrainX+terrainW,terrainY); // 2
			nodeVertexIndices[3]=indexOf(terrainX,terrainY+terrainW/2); // 3
			nodeVertexIndices[4]=indexOf(terrainX+terrainW/2,terrainY+terrainW/2); // 4
			nodeVertexIndices[5]=indexOf(terrainX+terrainW,terrainY+terrainW/2); // 5
			nodeVertexIndices[6]=indexOf(terrainX,terrainY+terrainW); // 6
			nodeVertexIndices[7]=indexOf(terrainX+terrainW/2,terrainY+terrainW); // 7
			nodeVertexIndices[8]=indexOf(terrainX+terrainW,terrainY+terrainW); // 8

			// If this is the max depth level we dont need to investigate anything further
			if ( level==maxDepth ) {
				intBuffer.put( nodeVertexIndices[0]);
				intBuffer.put( nodeVertexIndices[8]);
				intBuffer.put( nodeVertexIndices[6]);
				intBuffer.put( nodeVertexIndices[0]);
				intBuffer.put( nodeVertexIndices[2]);
				intBuffer.put( nodeVertexIndices[8]);

				// leave early
				return;
			}

			// For every neighbor we determine the list of vertex indices that are on the
			// specific border to the neighbor. These vertices are then used to draw the
			// triangles. This way wo don't need to assume any static level difference.

			if ( drawFlagTop == nodeInfoDontDraw ) {
				findTopIndices(level, x, y);
			} else {
				intBuffer.put( nodeVertexIndices[0]); // 0
				intBuffer.put( nodeVertexIndices[2]); // 2
				intBuffer.put( nodeVertexIndices[4]); // 4
			}

			if ( drawFlagRight == nodeInfoDontDraw ) {
				findRightIndices(level, x, y);
			} else {
				intBuffer.put( nodeVertexIndices[2]); // 2
				intBuffer.put( nodeVertexIndices[8]); // 8
				intBuffer.put( nodeVertexIndices[4]); // 4
			}

			if ( drawFlagBottom == nodeInfoDontDraw ) {
				findBottomIndices(level, x, y);
			} else {
				intBuffer.put( nodeVertexIndices[4]); // 4
				intBuffer.put( nodeVertexIndices[8]); // 8
				intBuffer.put( nodeVertexIndices[6]); // 6
			}

			if ( drawFlagLeft == nodeInfoDontDraw ) {
				findLeftIndices(level, x, y);
			} else {
				intBuffer.put( nodeVertexIndices[4]); // 4
				intBuffer.put( nodeVertexIndices[6]); // 6
				intBuffer.put( nodeVertexIndices[0]); // 0
			}
		} else if ( flags[x][y] == nodeInfoDontDraw ) {
			if ( level < maxDepth ) {
				renderTerrain(frustum, testchildren, level+1, x*2, y*2);
				renderTerrain(frustum, testchildren, level+1, x*2+1, y*2);
				renderTerrain(frustum, testchildren, level+1, x*2+1, y*2+1);
				renderTerrain(frustum, testchildren, level+1, x*2, y*2+1);
			}
		}
	}

Hier folgen nun ein paar Beispiele wie und dasselbe Terrain aus identischer Kameraposition bei verschiedenen Fehlergrenzwerten aussieht.

Fehlergrenzwert von 0: 322564 Dreiecke, 5 FPS

Fehlergrenzwert von 0: 322564 Dreiecke, 5 FPS

Fehlergrenzwert von 1: 76637 Dreiecke, 30 FPS

Fehlergrenzwert von 1: 76637 Dreiecke, 30 FPS

Fehlergrenzwert von 3: 8327 Dreiecke, 120 FPS

Fehlergrenzwert von 3: 8327 Dreiecke, 120 FPS

Fehlergrenzwert von 5: 9070 Dreiecke, 230 FPS

Fehlergrenzwert von 5: 9070 Dreiecke, 230 FPS

Weitere Optimierungen

Der Quadtree der die Zustände der Nodes speichert muss eigentlich bei jedem Frame neu initialisiert werden. Um diesen Aufwand zu sparen ändere ich bei jedem Frame die Werte der Status-Konstanten so dass das Programm erkennt ob ein Node-Status in diesem Frame gesetzt wurde oder nicht.

Im ersten Frame haben die Konstanten die Werte 0 bis 3, im zweiten Frame 10 bis 13 und so weiter. Wird beim Zeichen des zweiten Frames ein Status mit dem Wert 2 gefunden so weiß das Programm dass diese Node als nicht initialisiert gelten muss.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>