Tutorial Terrain1

Aus DGL Wiki
Version vom 28. Dezember 2005, 16:19 Uhr von BenBE (Diskussion | Beiträge) (Rechtschreibung korrigiert)

Wechseln zu: Navigation, Suche

Heightmaps - Außenlandschaften mit System

Vorwort

Hi,

dies Tutorial wird euch in die Basics des Terrain Rendering einweisen. Ich werde euch zeigen, wie man am Besten mit Heightmaps umgeht und wie man sie rendert. Daneben werde ich euch auch verraten, wie man der Szene etwas mehr Atmosphäre verleiht, was durch den Einsatz einer Skybox erreicht wird.

Vorarbeit

Bevor irgendetwas passiert, müssen wir OpenGl initialisieren. Ich möchte hierauf nicht weiter eingehen, da ihr das ja bereits hinter euch habt. Für dieses kleine Demo habe ich die OpenGl Bibliothek 1.2 von Mike Lischke verwendet (OpenGL12.pas). Dazu habe ich eine für 3DNow! optimierte Geometry.pas eingebunden, die aus der GlScene Komponentensammlung stammt (ich hab alle Dateien den Sources hinzugefügt). Zum Laden der Textur kommt noch die GlAux Library zum Einsatz. Ich war einfach zu faul, die Dinger von Hand an OpenGl zu senden. Den GL Initialisierungsteil solltet ihr verstehen können (wenn nicht, ist dies hier eh das falsche Tutorial für euch). Gerendert wird im OnIdle-Event der Anwendung, aber schaut sie euch am Besten einfach einmal an.

Wer mit Matrizen umgehen kann, beherrscht das Wichtigste an der 3D-Programmierung. Wer nicht zu den Genies zählt, sollte nicht sofort aufgeben und sich viele Beispiele ansehen und viel mit diesen herumspielen. Letztendlich siegt die Erfahrung doch über das Wissen :)

Etwas Atmosphäre bitte

Widmen wir uns zunächst einmal dem Himmel. Wenn gerade brauchbares Wetter ist, dann schaut einfach mal hoch (Solltet ihr jetzt die Decke eines Zimmers erblicken, dann seid mal nicht so faul und bewegt euch kurz auf die Straße). Nun, was seht ihr? Ich sehe blauen Himmel, Wolken,... Jedem sollte der Anblick bestens bekannt sein. Um die Bilder einer Skybox zu erstellen, kann man einige Programme zu Hilfe nehmen. Ich benutze meistens Terragen, da es kostenlos ist und gute Ergebnisse liefert. Sehr bekannt ist auch Bryce. Was wir brauchen sind sechs Bilder. Eines von oben, eines von unten und je eines für die 4 Himmelsrichtungen. Macht also 6 Seiten, wie die eines Würfels. Ich habe mir die Arbeit bereits gemacht, ihr müsst euch also nicht abrackern und auch eine Skybox erstellen. An der Texturauflösung solltet ihr nicht sparen. Hier ist wirklich einmal klotzen angesagt, damit die Sache nicht klobig aussieht. Ich habe eine Auflösung von 512x512 gewählt. Bei solchen Texturen ist der Einsatz von Kompression empfehlenswert, bei so wenigen Bildern jedoch nicht wirklich nötig.

Was wir benötigen ist: Sechs geladene Texturen und ein unbelichteter Würfel. Die IDs der Texturen werden in einem Array gehalten, damit wir sie so einfach wie möglich erreichen:

SkyBoxTexturen : Array[0..5] of TGlUInt;

Nun müssen wir die Bilder nur noch anzeigen:

procedure TSkyBoxForm.ApplicationEventsIdle(Sender: TObject;
  var Done: Boolean);

  procedure PaintSkyBox;
  begin
    ...
  end; (*PaintSkyBox*)

var
  Error : LongInt;
begin
  Done := True;
  if not OpenGLInitialized then Exit;
  Done := False;
  glClear(GL_DEPTH_BUFFER_BIT or GL_COLOR_BUFFER_BIT);
  glLoadIdentity;
  glRotatef(XRotation, 1, 0,0);
  glRotatef(YRotation, 0, 1,0);
  PaintSkyBox;
  //Error Handler
  Error := glgetError;
  if Error <> GL_NO_ERROR then
  begin
    MessageBeep(65535);
    Caption := gluErrorString(Error)
  end;
  //FrameCounter
  Inc(Frames);
  if GetTickCount - StartTick >=1000 then
  begin
    Caption := Format('Sky Box Demo FPS: %f',
	[Frames/(GetTickCount - StartTick)*1000]);
    Frames := 0;
    StartTick := GetTickCount
  end;
  SwapBuffers(Canvas.Handle)
end; (*ApplicationEventsIdle*)

Soweit das Framework. Wenn OpenGL noch nicht initialisiert wurde, wird abgebrochen. Ansonsten werden die Puffer geleert und die Einheitsmatrix geladen. Nun wird die Kamera gedreht. Die Winkel werden im OnMouseDown-, OnMouseMove- und OnMouseUp-Event verändert und berechnet. Dann wird die Skybox gerendert. Es folgt noch die Fehlerprüfung und ein Framecounter.

Doch wie zeichnen wir jetzt die Skybox? Zuerst sollten wir festlegen, wie die Texturen in unserem Textur Array abgelegt werden. Ich fand diese Sortierung sinnvoll: Norden, Osten, Süden, Westen, Oben und Unten. Mit diesen Texturen wird ein Würfel der Kantenlänge 4 bemalt. Eine Länge von 2 wäre unpraktisch, da ich die nahe Clipping Plane auf eine Entfernung von 1 gesetzt habe (Folglich verschwindet ein solcher am Ursprung aufgehängter Würfel). Zum Zeichnen definieren wir uns ein Array, das am Ende das Rendering erleichtert: Jede Wand hat 4 Vertices, mit je 3 Koordinaten:

  procedure PaintSkyBox;
  const
    QuadPosition : Array[0..5] of Array[0..3] of Array[0..2] of Single =
      (((2.005,2.005,-1.995),(2.005,-2.005,-1.995),(-2.005,-2.005,-1.995),
          (-2.005,2.005,-1.995)),     //Nordseite
       ((1.995,2.005,2),(1.995,-2.005,2),(1.995,-2.005,-2),
          (1.995,2.005,-2)),         //Ostseite
       ((-2.005,2.005,1.995),(-2.005,-2.005, 1.995),(2.005,-2.005,1.995),
          (2.005,2.005,1.995)),          //Südseite
       ((-1.995,2.005,-2),(-1.995,-2.005,-2),(-1.995,-2.005,2),
          (-1.995,2.005,2)),      //Westseite
       ((-2,2,-2),(-2,2,2),(2,2,2), (2,2,-2)), //Oberseite
       ((2,-2,-2),(2,-2,2),(-2,-2,2),(-2,-2,-2))); //Unterseite

Man könnte mir jetzt vorwerfen, dass dies kein echter Würfel ist, sondern nur fast. Die etwas abgearteten Werte sind alle nahe an 2. Sie sind nötig, da OpenGl sich ein klein wenig verrechnet, was bei einem Wert von genau 2.0 Streifen erzeugt. Um dies zu umgehen, werden die vier Himmelrichtungen etwas näher an den Betrachter gezogen und gleichzeitig etwas gestreckt. Jetzt wollen wir noch Texturkoordinaten pro Vertex und einige andere Variablen definieren:

   TexturePos : Array[0..3] of Array[0..1] of Single = ((1,1),(1,0),(0, 0),(0, 1));
  var
    Side, Vertex : Integer;
  begin
    ...
  end; (*PaintSkyBox*)

Nun müssen wir unsere mühsam eingetippten Daten OpenGl verständlich übermitteln. Wir gehen also alle Seiten durch, aktivieren eine Textur, übergeben Vertices sowie Texturkoordinaten. Normalen sind nicht nötig, da Skyboxes nicht beleuchtet werden:

    for Side := 0 to 5 do
    begin
      //Textur aktivieren
      glBindTexture(GL_TEXTURE_2D, SkyBoxTexturen[Side]);
      glBegin(GL_QUADS);
      //Vertieces und Tex Coords übergeben
      for Vertex := 3 downto 0 do
      begin
        glTexCoord2fv(@TexturePos[Vertex][0]);
        glVertex3fv(@QuadPosition[Side][Vertex][0])
      end;
      glEnd()
    end

Das Ergebnis:

Tutorial Heightmaps-Aussenlandschaften mit System skyboxdemo.jpg

Das System der Heightmaps

Jetzt haben wir bereits ein nettes kleines Programm. Doch das eigentliche Ziel ist noch in weiter Ferne. Heightmaps sind Graustufenbilder. Je weißer ein Pixel ist, desto höher ist der Punkt in der Landschaft. Oft haben die Maps eine Größe von 2nx2n, aber an sich spielt das keine Rolle. Auf alle Fälle wird die Map in viele kleine Dreiecke aufgeteilt, die dann auf dem Bildschirm angezeigt werden.

Die Sache mit dem Bitmap

Als erstes brauchen wir ein Bitmap mit einer Landschaft. Dieses lässt sich mit einem besseren Malprogramm erzeugen oder mit einem Generator. Ich werde hier ein Heightmap einer echten Landschaft verwenden, welches ich irgendwo im Netz gefunden habe:

Tutorial Heightmaps-Aussenlandschaften mit System YU14H.jpg

Da das Auslesen der Pixeldaten direkt aus einem Bitmap eine wirklich sehr, sehr langsame Angelegenheit ist, sollten wir die Daten zuerst in einem wesentlich schnelleren Delphi Array zwischenspeichern. Die Größe des Arrays wählen wir (86+1)x(86+1), ein Viertel der Größe des Bitmaps. Das sollte uns eigentlich genügen, allein schon da höhere Auflösungen mehr Rechenzeit beanspruchen. Wir sollten auch überlegen, auf welche Größe wir unsere Karte strecken wollen (eigentlich ist das nicht nötig, will man aber die Auflösung der Umgebung später doch einmal erhöhen, ohne dabei die Größe der Map auch gleich mit zu verändern, ist dies durchaus sinnvoll). Fügen wir also unserem Code folgende Zeilen hinzu:

  ...
  MapSize = 1024;
  MapResolution = 86;

type
  TMapDaten = Array[0..MapResolution, 0..MapResolution] of Byte;

  TSkyBoxForm = class(TForm)
  ...
  private
  ...
    MapDaten : TMapDaten;
  ...

Bevor wir anfangen können zu rendern, müssen wir die Daten aus dem Bitmap auch noch laden. Ein guter Zeitpunkt dafür wäre im OnShow-Ereignis, in dem bereits OpenGl initialisiert wird. Fügen wir dort also eine neue Prozedur ein, die ein Bitmap öffnet und die für uns interessanten Daten in den MapDaten ablegt:

  procedure LoadHeightMap;
  var
    Bmp : TBitMap;
    X,Z : LongInt;
  begin
    Bmp := TBitMap.Create;
    try
      Bmp.LoadFromFile('YU14H.bmp');
      //Bitmap in MapDaten laden...
      for X := 0 to MapResolution do
        for Z := 0 to MapResolution do
          MapDaten[X,Z] := Trunc(Bmp.Canvas.Pixels
                             [Trunc(X/MapResolution*Bmp.Width),
                             Trunc(Z/MapResolution*Bmp.Height)]
                             / clWhite * 255)
    except
      MessageDlg('Fehler beim laden der Heightmap', mtError, [mbOk], 0)
    end;
    Bmp.Free
  end; (*LoadHeightMap*)

Ich denke, das meiste erklärt sich bereits von selbst. Die Zeile MapDaten[X,Z] := ... bedarf möglicherweise etwas genaueren Erläuterungen. Da jeder Eintrag in MapDaten genau ein Byte groß ist, dürfen die eingesetzten Werte nur im Bereich 0 bis 255 liegen. Wenn wir uns aber die Farbe eines Pixels aus dem Bitmap holen, sind in ihm Werte für Blau, Rot und Grün enthalten (Für jede Farbe wird 1 Byte verwendet. Das sind dann 3 Byte, nicht 1). Durch Teilen durch clWhite erhalten wir den Weißheitsgrad in Prozent. Multiplizieren wir den erhaltenen Wert mit 255, landen wir im gewünschten Bereich zwischen 0 und 255. Da der Wert jetzt aber noch ein Single ist, wir aber ein Byte brauchen, müssen wir den Teil hinter dem Komma abschneiden, was die Funktion Trunc erledigt. Beim Zugriff auf das Pixel mittels Bmp.Canvas.Pixles[...] wird ähnlich verfahren.

Rendern der Heightmap

Wie bereits erwähnt, wird die Heightmap mit Dreiecken gerendert (Vierecke die zwar naheliegend wären, sind eher unpraktisch, aber das wird sicher erst in fortgeschritteneren Tutorials ersichtlich). Wir werden immer vier aneinanderliegende Punkte aus den frisch erzeugten MapDaten entnehmen und daraus zwei Dreiecke zaubern.

Tutorial Heightmaps-Aussenlandschaften mit System triangle-grid.gif


Es dürfte also kein größeres Problem vor uns liegen. Grid abgehen, Dreiecke zeichnen, nächster Gridpunkt...

  procedure PaintHeightMap;
  const
    VertPos : Array[0..3] of Array[0..1] of Byte =
      ((0,0),(0,1),(1,1),(1,0));
  var
    X,Z,I : LongInt;
    Vertieces : Array[0..3] of TVertex;

    procedure PaintTriangle(V1, V2, V3 : Integer);
    var
      Normale : TVertex;
    begin
      Inc(Triangles);
      //Flächennormale berechnen
      Normale := VectorCrossProduct(
	  			 VectorSubtract(Vertieces[V1], Vertieces[V2]),
                 VectorSubtract(Vertieces[V2], Vertieces[V3]));
      NormalizeVector(Normale);
      //Dreieck zeichnen
      glNormal3fv(@Normale[0]);
      glBegin(GL_TRIANGLES);
        glVertex3fv(@Vertieces[V1][0]);
        glVertex3fv(@Vertieces[V2][0]);
        glVertex3fv(@Vertieces[V3][0]);
      glEnd()
    end; (*PaintTriangle*)

  begin
    for X := 0 to MapResolution - 1 do
      for Z := 0 to MapResolution - 1 do
      begin
        //Alle Vertieces erzeugen
        for I := 0 to 3 do
        begin
          Vertieces[I][0] := (X + VertPos[I][0])/MapResolution*MapSize;
          Vertieces[I][2] := (Z + VertPos[I][1])/MapResolution*MapSize;
          Vertieces[I][1] := MapDaten[X + VertPos[I][0],Z + VertPos[I][1]]
        end;
        PaintTriangle(0,1,3);
        PaintTriangle(1,2,3)
      end
  end; (*PaintHeightMap*)

Was passiert genau? Wie bereits gesagt, werden die Gridpunkte der Reihe nach abgegangen. An jedem werden die Orte der vier anliegenden Vertices berechnet und schließlich die zwei Dreiecke gezeichnet. Als Parameter werden der Funktion PaintTriangle die drei für das zu zeichnende Dreieck relevanten Vertices übergeben. PaintTriangle selbst berechnet zuerst die Flächennormale, damit auch eine halbwegs realistische Darstellung des Lichts zusammen kommt. Um das Licht zu aktivieren, habe ich die Initialisierungsfunktion SetupGl etwas erweitern müssen. Zu beachten ist, dass im OnIdle-Event das Licht mittels glDisable(GL_LIGHTING) vor dem Aufruf von PaintSkyBox deaktiviert werden muss und vor dem Aufruf von PaintHeightMap wieder aktiviert. Nachdem Aufruf von PaintSkyBox wird der Tiefenpuffer geleert. Würde das nicht geschehen, würden sich Skybox und Landschaft überschneiden (Zum Ausprobieren: Den Tiefenpuffer nach PaintSkyBox nicht wieder leeren und sich einmal im Kreise drehen. Na, gesehen?).

Der Lohn der ganzen Aktion:

Tutorial Heightmaps-Aussenlandschaften mit System ergebnis.jpg

Workshop

Um euer Hirn noch ein wenig zu belasten, habe ich hier noch ein paar Ideen zur Erweiterung des kleinen Programms:

  • Die Landschaft mit einer Textur überziehen, damit sie nicht ganz so langweilig grau aussieht
  • Die Normalen nicht für einzelne Polygone berechnen, sondern für jedes

Vertex. Dadurch sieht die Landschaft am Ende nicht so eckig aus, sondern wird rund. Tipp: Idealerweise werden alle Normalen vorberechnet und der Typ TMapDaten um einen Vektor für eben diese Normalen erweitert.

Ausblick auf die nächsten Tutorials

Ich hoffe jetzt natürlich, dass euch dieses schnuckelige Tutorial interessiert hat, es ist im Übrigen mein erstes :-). Das Thema Heightmaps ist noch lange nicht abgehandelt. Als nächstes werde ich ein Tutorial schreiben, das erklärt, wie man echte Schatten in die Landschaft bekommt Wenn ich die Zeit finde, werde ich euch vielleicht auch das Prinzip von Level of Details näherbringen, mit dem erreicht werden kann, dass nicht immer gleich die komplette Map mit vollen Details gerendert wird, sondern nur der Teil, der in direkter Nähe zur Kamera ist und der Teil, der wegen seiner starken Höhendifferenzen beim Rendern einen höheren Detailgrad verlangt als der Rest.

Euer

Nico Michaelis aka DelphiC



Vorhergehendes Tutorial:
-
Nächstes Tutorial:
-

Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com.
Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen.