Tutorial Terrain1
Inhaltsverzeichnis
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 mithilfe einer Skybox der Szene etwas mehr Atmosphäre verleiht.
Vorarbeit
Für dieses kleine Demo habe ich die OpenGl Bibliothek 1.2 von Mike Lischke verwendet (OpenGL12.pas). Dazu eine für 3DNow! optimierte Geometry.pas eingebunden, die aus der GlScene Komponentensammlung stammt (alle Dateien liegen den Sources bei). Zum Laden der Textur kommt noch die GlAux Library zum Einsatz, was ihr besser nicht nachmacht, weils schrecklich alt ist und lang nicht mehr gepflegt wurde. Als moderne Alternative wäre da z.B. Glbitmap zu nennen. Gerendert wird im OnIdle-Event der Anwendung, aber schaut bei Fragen doch einfach selbst hinein.
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 hop hop, raus an die Frische Luft! Wenns geht Laptop gleich mitnehmen, an der frischen Luft machts gleich dreimal soviel Spaß!). Nun, was seht ihr? Ich sehe blauen Himmel, Wolken,... Nix unbekanntes jedenfalls. Das wollen wir doch gleich mal in OpenGl nachbauen... Wir nehmen einen Würfel (die schon angekündigte Skybox), den wir von innen anschauen und bekleben den mithübschen Bildchen, die unseren Himmel darstellen.
Zum erzeugen entsprechender Bilder können wir uns einer Kamera oder einiger kleiner Softwaretools bedienen. Ich mache letzteres und verweise mal auf die Programme Terragenund Bryce. Was wir brauchen sind sechs Bilder. Eines von oben, eines von unten und je eines für die 4 Himmelsrichtungen. Macht zusammen, ganz nach Adam Riese, 6. Ich habe mir die Arbeit bereits gemacht, ihr müsst euch also nicht abrackern und könnt für erste Gehversuche meine Bilder nehmen,. An der Texturauflösung solltet ihr nicht zu sehr sparen, immerhin ist die Skybox statisch und man hat sie doch recht häufig vor der Nase. Also ruhig klotzen statt kleckern, damits keine klötzchen gibt. Ich habe ürsprünglich eine Auflösung von 512x512 gewählt, wobei da ruhig mit der Zeit gegangen werden darf und mit jeder Grafikkartengeneration ist schließlich mehr Platz im Speicher. Trotzdem kann der Einsatz von Texturkompression nicht schaden.
Auf dem Einkaufszettelchen steht also:
- ein unbelichteter Würfel.
- Sechs geladene Texturen.
Um das Verständnis meines Codes ein bisschen zu erhöhen, gibts ein wenig von dem Framework, in das wir unsere Skybox einpflegen wollen
SkyBoxTexturen : Array[0..5] of TGlUInt;
...
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*)
Wenn OpenGL noch nicht initialisiert wurde, wird abgebrochen. Ansonsten werden die Puffer geleert und die Einheitsmatrix geladen, die Kamera gedreht, die Skybox gemalt, auf Fehler geprüft und die Leistung gemessen.
Und wie zeichnen wir jetzt die Skybox? Zuerst sollten wir festlegen, wie die Texturen in unserem Array abgelegt werden. Und weil ich hier der Capo bin, entscheide ich unumstürzlich: 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 bescheiden, da in der Demo die nahe Clipping Plane eine Entfernung von 1 zum Betrachter hat und mir ist es im echten Leben noch nie passiert, dass der Himmel abgeschnitten erscheint. Warum also sollte ich das dann in einem OpenGl_Programm provozieren? Zum Zeichnen definieren wir uns ein Array, welches die 4 Vertices jeder Wand des Würfels beschreibt.
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
Was erzählt jetzt der für einen Mist? Das ist kein anständiger Würfel, der kann doch wohl nicht die Ecken alle so unsystematisch wählen. Oder doch? Schau mer nochmal genauer hin: Die etwas abgearteten Werte sind alle nahe an 2. Sie sind nötig, da OpenGL sich ein klein wenig "verrechnet", was bei genauen Werten Streifen erzeugen kann. Um dies zu umgehen, werden die vier Himmelrichtungen etwas näher an den Betrachter gezogen und gleichzeitig etwas gestreckt. Jetzt 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*)
Und jetzt hat unsere Arraysammelwut doch noch etwas Gutes: Das Zeichnen geht in einer for-Schleife ganz einfach von der Hand.
glBegin(GL_QUADS);
for Side := 0 to 5 do
begin
//Textur aktivieren
glBindTexture(GL_TEXTURE_2D, SkyBoxTexturen[Side]);
//Ecken und Texturkoordinaten übergeben
for Vertex := 3 downto 0 do
begin
glTexCoord2fv(@TexturePos[Vertex][0]);
glVertex3fv(@QuadPosition[Side][Vertex][0])
end;
end
glEnd()
Und voilà. Schon fertig
Das System der Heightmaps
Das ist jetzt alles schön und toll, aber durch eine 3D-Landschaft kann man wandeln. In Unserer Skybox dürfen wir uns nur drehen, wie langweilig. Darum wollen wir uns um eine etwas "tiefergehende", weniger flache Landschaft bemühen.
Heightmaps sind Graustufenkarten einer Landschaft, topologische Karten sozusagen. Je weißer ein Pixel ist, desto höher ist der Punkt in der Landschaft. Die Punkte wollen wir mit Dreiecken verbinden, die wir schließlich zeichnen wollen.
Die Sache mit dem Bitmap
Also her mit so einer Karte! Die könnten wir mit einem Generator wie Perlin Noise erzeugen oder, für die ganz wilden, mit einem Bildbearbeitungsprogramm selbst zeichnen. Ich packe dagegen eine Heightmap einer echten Landschaft aus, welches ich irgendwo im Netz aufgetrieben habe:
Da das Auslesen der Pixeldaten direkt aus einem Bitmap mithilfe eines TBitmap 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 - hey, das Tutorial ist schon ein wenig älter! 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 erhöhen, ohne dabei die Größe der Map zu verändern, ist das 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*)
Benutzt man die Scanline Funktion von TBitmap, ein bisschen Pointerarithmetik und ein fest vorgegebenes Pixelformat im Bitmap, dann kann man das noch billiger haben.
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, schneiden wir den nichtganzzahligen Anteil einfach ab. Beim Zugriff auf das Pixel machen wirs wieder genauso. Da hat sichs dann wieder gerächt, dass MapDaten eine andere Auflösung hat, als das Bitmap. Das merken wir uns dann gleich mal für später geschriebene Heightmap-Renderer, gell?
Rendern der Heightmap
Ach ja, gezeichnet werden will der Kram ja jetzt auch noch. Wie war das? Dreiecke wollten wir nehmen? Die Höhen können wir ja jetzt aus MapDaten nehmen, sonst hätte sich der Aufwandt da oben mal echt nicht gelohnt.
Grid abgehen, Dreiecke zeichnen, nächster Gridpunkt..., danach siehts jedenfalls aus.
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? Die Gridpunkte werden der Reihe nach abgegangen. An jedem werden die Orte der vier anliegenden Vertices berechnet und schließlich mit PaintTriangle zwei Dreiecke gezeichnet, welches zuerst die Flächennormale fürs Licht berechnet und dann die Koordinaten an OpenGL weiterreicht. Und nicht vergessen, die Skybox verträgt das Licht nicht ganz so gut, weil deren Licht ist ja bereits durch die Texturen bestimmt. Was wir noch bedenken müssen ist, dass nach dem Aufruf von PaintSkyBox der Tiefenpuffer geleert werden muss. Alternativ könnten wir vor dem Zeichnen der Skybox auch mit glDepthMask das Schreiben in den Tiefenpuffer unterbinden und danach wieder anschalten.
Der Lohn der Müh::
Workshop
Um euer Hirn noch ein wenig zu belasten, habe ich noch eine Idee zur Erweiterung des kleinen Programms: Die Landschaft kann mit einer Textur überzogen werden, dann ist sie nicht ganz so langweilig grau. Details? Hier!
Und jetzt? Kann ich mich nur noch verabschieden... Euer
Delphic
Dateien
|
||
Vorhergehendes Tutorial: - |
Nächstes Tutorial: Tutorial_Terrain2 |
|
Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com. Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen. |