Tutorial Terrain2

Aus DGL Wiki
Wechseln zu: Navigation, Suche

Heightmap Texturen

Vorwort

Hi,

dies ist ein weiteres Tutorial zu Heightmaps. Ich verrate euch heute, wie man Heightmaps ein paar interessante Texturen verpasst. Der erste Teil besteht darin, eine Procedural Texture zu erstellen, die eine Kombination aus der Heightmap und einer Wahl von Bodentexturen ist, z.B. werden die höchsten Punkte der Landschaft mit Schnee bedeckt sein, niedrigere mit Stein, Gras und zum Schluss Wasser.

Der zweite Teil besteht darin, eine Lightmap zu Rendern. Sie enthält die Lichtintensitat der einfallenden Sonne auf der Karte. Damit ihr wisst, wofür die ganze Müh erst einmal ein kleiner Teaser:

Besonders zu beachten: Die Landschaft wirft Schatten auf sich selbst

Der Trick der Procedural Textures

Wollen wir uns zuerst den Procedural Textures widmen. Die Idee:

Wir haben eine Heightmap. Wir wollen über diese eine Textur ziehen. Je nach Hohe soll eine andere Grundtextur gewählt werden, z.B. Hohenstufe 0 Unterwassertextur. Stufe 60 Sand, Stufe 100 Gras, 170 Stein und 255 Schnee. Daneben wollen wir erreichen, dass die Texturen ineinander übergehe, indem wir immer genau zwischen zwei Stufen die Farbwerte linear interpolieren. Die Grundtexturen werden gekachelt, damit sie nicht so hoch aufgelöst werden müssen, wie die Heightmap selbst.

Ich habe folgende Texturen verwandt:

Tutorial Terrain2 underwater.jpg Tutorial Terrain2 sand.jpg Tutorial Terrain2 grass.jpg Tutorial Terrain2 rock.jpg Tutorial Terrain2 snow.jpg
Stufe: 0 Stufe: 60 Stufe: 100 Stufe: 170 Stufe: 255

{{Hinweis|Wenn ihr selbst Texturen erstellen wollt, zeichnet oder fotografiert wild drauf los, und lasst einen Kachel Filter in einem Bildbearbeitungsprogramm drüberlaufen. Von Hand Kacheln erzeugen geht natürlich auch, bedarf aber deutlich mehr Aufwandt.

Bevor wir unsere Textur rendern können, müssen wir uns eine Umgebung schaffen. Wir brauchen eine geladene Heightmap und die Grundtexturen:

type
  TProcTexStage = record
                    Image : TImage;
                    ScrollBox : TScrollBox;
                    Stage : Byte;
                    StageLabel : TLabel;
                  end;
  ...
    HeightMap : TBitMap;

    ProceduralTex : TBitMap;
    ProcTexs : Array of TProcTexStage;
  ...

Der Typ TProcTexStage beinhaltet je eine Grundtextur, geladen in der Image Komponente. Die jeweilige Höhenstufe ist in Stage abgelegt. Die restlichen Objekte sind nur für die GUI gedacht und haben keine weitere Bedeutung für den Algorithmus selbst.

Die Variable ProcTexs enhalt mehrere TProcTexStages. Sie sind bereits nach Stufen sortiert, beginnend mit der kleinsten, wobei gilt: Die niedrigste Stufe ist immer 0, die Hochste immer 255 (wird spater wichtig). Die Mindestzahl der Elemente ist demnach 2. Mehr ist kein Problem, unterschiedliche TProcTexStage sollten aber niemals die selbe Stufe haben.

Sobal diese Annahmen erfüllt sind, können wir beginnen, jedem Pixel der ProceduralTex einen neuen Farbwert zuzuweisen. Das Grundgerüst ist wie folgt:

...
var
  X,Y, HeightMapHeight : LongInt;
  ...
begin
  //Texturen sind bereits sortiert...
  if Length(ProcTexs) < 2 then
    Exit;
  //Größe anpassen
  ProceduralTex.Width := HeightMap.Width;
  ProceduralTex.Height := HeightMap.Height;

  for X := 0 to HeightMap.Width - 1 do
    for Y := 0 to HeightMap.Height - 1 do
    begin
      HeightMapHeight :=
         Trunc((HeightMap.Canvas.Pixels[X,Y]/clWhite)*255);
      //Farbwert zuweisen
      ...
    end
end;

Da wir die Stufen nicht auf bestimmte Werte festgelegt haben (z.B. alle 64 Hohenwerte eine Stufe) müssen wir zunächst das Intervall finden, in welchem die aktuelle Stufe liegt. Die jeweils niedrigere Textur wird in einer Variable Lower abgelegt, die höhere in Upper. Daneben benötigen wir noch einen Satz weiterer Variablen:

var
  ...
  I : Integer;
  Upper, Lower : LongInt;
  LevelDifference : LongInt; //Levelunterschied zw. Upper u. Lower
  LowerDifference : LongInt; //Abstand HeightmapHeight - Lower
  LowerRGB, UpperRGB : TRGB; //RGB werte von Upper Lower
  Prozent : Single;          //Prozent der eingesetzten Farbwerte
  FinalRGB : TRGB;           //End RGB Wert

      ...
      HeightMapHeight := Trunc(
             (HeightMap.Canvas.Pixels[X,Y]/clWhite)*255);
      Upper := 0;
      Lower := 0;
      //Intervall suchen, das für diese Höhenstufe interessant ist
      // Schonmal was von binärer Suche gehört? 
      for I := 1 to Length(ProcTexs)-1 do
        if HeightMapHeight <= ProcTexs[I].Stage then
        begin
          Upper := I;
          Lower := I -1;
          Break
        end;
      Assert(Upper <> 0);
      LevelDifference := ProcTexs[Upper].Stage - ProcTexs[Lower].Stage;
      LowerDifference := HeightMapHeight - ProcTexs[Lower].Stage;
      Assert(LevelDifference <> 0);
      Prozent := LowerDifference / LevelDifference;
      ...

Der verwendete Typ TRGB ist ein Array[0..2] of Byte. Wenn ein TColor Wert als Ausgangsmaterial dient, wird dieser mit in ein TRGB umgewandelt:

  procedure MakeRGB(Color : TColor; var RGB : TRGB);
  begin
    Move(Color, RGB[0], 3)
  end;

Damit haben wir's fast geschafft. Das einzige was noch fehlt ist die lineare Interpolation zwischen den Farbwerten der Upper und Lower Textur. Dies ist aber an sich nicht weiter schwierig. Man denke an den Mathematikunterricht zurück und erinnert sich an folgende gleichung: y= m * x + t, wobei m die Steigung und t der y-Achsenabschnitt ist:

      ...
      Prozent := LowerDifference / LevelDifference;

      MakeRGB(ProcTexs[Lower].Image.Canvas.
           Pixels[X mod ProcTexs[Lower].Image.Width,
           Y mod ProcTexs[Lower].Image.Height],LowerRGB);
      MakeRGB(ProcTexs[Upper].Image.Canvas.
           Pixels[X mod ProcTexs[Upper].Image.Width,
           Y mod ProcTexs[Upper].Image.Height],UpperRGB);

      for I := 0 to 2 do
        FinalRGB[I] := Trunc(
                    (UpperRGB[I] - LowerRGB[I])*Prozent +
                                 LowerRGB[I]);
        //    y      =       (            m            )*   x    +      t
      ProceduralTex.Canvas.Pixels[X,Y] := RGB(FinalRGB[0],
                                       FinalRGB[1],
                                       FinalRGB[2])

Zum Einsatz kam folgende Heightmap:

Tutorial Terrain2 heightmap.jpg

Jede andere funktioniert natürlich auch, wie z.B. die, aus meinem letzten Tutorial. Der Algorithmus liefert zusammen mit den Oben gezeigten Grundtexturen dieses Bild:

Tutorial Terrain2 proctex.jpg

Das Spiel von Licht und Schatten

Wir könnten damit jetzt unsere Welt überziehen. Was dann aber noch fehlt ist Licht und Schatten. Wir wollen Ambient Light und Diffuse Light zum Einsatz kommen lassen. Die Typische Formel zum Berechnen ist:

Intensity=Ambient + Diffuse * (L • N)

L ist ein Vektor zur Lichtquelle, N die Flachennormale. Ambient und Diffuse die Stärken der jeweiligen Lichttypen. Wir wollen abermals alle Punkte der Heightmap durchgehen und unser Ergebnis in LightMaptex ablegen. Wenn ein Punkt nicht im Schatten eines anderen liegt, wird sein Farbwert mithilfe obiger Formel berechnet, ansonsten bekommt der Punkt den Farbwert des Ambient Light zugewiesen.

Die Normal Map

Bevor wir loslegen, legen wir eine Normal Map an, deren Inhalt alle Normalen der Heightmap ist. Diese Komprimieren wir gleich auf Bytes, damit sie nicht Unmengen an Platz beansprucht. Leider kann man die Normalen nur schlecht für einzelne Punkte direkt berechnen, nehmen also Flächen zur Hilfe. Damit die Sache rund wird, betrachten wir aber nicht nur eine Fläche, sondern gleich alle anliegenden und berechnen daraus einen Normalendurchschnitt.

...
type
  TNormalMap = Array of Array[0..2] of ShortInt;
...

  procedure CreateNormalMap(HM : TBitMap; HeightScale : Single;
                               var NormalMap : TNormalMap);
  begin
    ...
  end;
...
var
  NormalMap : TNormalMap;
  HeightMapScale : Single;
  NormalMap : TNormalMap;

begin
  Sun[0] := Cos(LightRotation.Position*Pi/180);
  Sun[1] := (LightHeight.Max - LightHeight.Position)/100;
  Sun[2] := Sin(LightRotation.Position*Pi/180);
  HeightMapScale := HeightScaleEdit.Value;

  LightMapTex.Width := HeightMap.Width;
  LightMapTex.Height := HeightMap.Height;

  CreateNormalMap(HeightMap, HeightMapScale, NormalMap);

  CreateLightMap(HeightMap, HeightMapScale, NormalMap, Sun, LightMapTex,
    AmbientLight.Position / 100, DiffuseLight.Position/100,
    RaytracingCheckBox.Checked)
end;

Dies stellt erst einmal ein Grundgerust dar. Statt einer einzelnen Beschreibung der einzelnen UI Komponenten folgendes Bild:

Tutorial Terrain2 lightedit.jpg

Nun also zur Funktion CreateNormalMap. Wie gesagt berechnen wir den Durchschnitt der Anliegenden Normalen. Folgendes Schaubild soll die verwendeten Flächen verdeutlichen, wobei v0 das derzeit betrachtete Vertex ist, alle anderen vxs anliegende:

           v8
     v7•--•--•v1
       |8 /|1 /|
       | / | / |
       |/ 7|/ 2|
     v6•-v0•--•v2
       |6 /|3 /|
       | / | / |
       |/ 5|/ 4|
     v5•--•--•v3
           v4

Mit dem Vektor Kreuzprodukt werden die Normalen für die 8 Flachen berechnet, zusammengezahlt und normiert:

  procedure CreateNormalMap(HM : TBitMap; HeightScale : Single;
                               var NormalMap : TNormalMap);
  const
    VertexPoss : Array[0..8] of Array[0..1] of ShortInt =
      ((0,0),(1,-1),(1,0),(1,1),(0,1),(-1,1),(-1,0),(-1,-1),(0,-1));
    Faces : Array[0..7] of Array[0..2] of Byte =
      ((0,1,8),(0,2,1),(0,4,2),(4,3,2),(0,5,4),(0,6,5),(0,8,6),(8,7,6));
  var
    X,Z : LongInt;
    I : LongInt;

    Vertieces : Array[0..8] of TVertex;
    Normals   : Array[0..7] of TVertex;
  begin
    SetLength(NormalMap, HM.Width*HM.Height);
    FillChar(Vertieces[0,0], SizeOf(Vertieces), 0);
    for Z := 0 to HM.Height - 1 do
      for X := 0 to HM.Width - 1 do
      begin
        //Vertieces holen
        for I := 0 to 8 do
        begin
          Vertieces[I][0] := X + VertexPoss[I][0]*VertexPosMultiply;
          Vertieces[I][2] := Z + VertexPoss[I][1]*VertexPosMultiply;
          Vertieces[I][1] := HM.Canvas.Pixels[X + VertexPoss[I][0], Z +
                          VertexPoss[I][1]]
            /clWhite*255*HeightScale
        end;
        //Normalen holen
        for I := 0 to 7 do
        begin
          Normals[I] := VectorCrossProduct(
            VectorNormalize(VectorSubtract(Vertieces[Faces[I][0]],
               Vertieces[Faces[I][1]])),
            VectorNormalize(VectorSubtract(Vertieces[Faces[I][1]],
               Vertieces[Faces[I][2]])));
          Normals[I] := VectorNormalize(Normals[I])
        end;
        //Den "Durchschnittsvektor"...
        for I := 1 to 7 do
          Normals[0] := VectorAdd(Normals[0], Normals[I]);
        Normals[0] := VectorScale(VectorNormalize(Normals[0]),127.0);
        NormalMap[Z*(HM.Height) + X][0] := Trunc(Normals[0][0]);
        NormalMap[Z*(HM.Height) + X][1] := Trunc(Normals[0][1]);
        NormalMap[Z*(HM.Height) + X][2] := Trunc(Normals[0][2])
      end
  end;


Es werde Licht

Jetzt sind wir soweit. Wir konnen jetzt mit Hilfe unserer Formel fur jeden Punkt die Beleuchtung berechnen. Wie immer: Alle Punkte durchgehen, berechnen:

  procedure CreateLightMap(HM : TBitMap; HeightMapScale : Single;
    NormalMap : TNormalMap; Sun : TVertex; LightMap : TBitMap;
    LightAmbient, LightDiffuse : Single; Raytracing : Boolean);

  type
    TRay = record
             Origin, Direction : TVertex
           end;

  var
    HeightMapData : Array of Array of byte;

    procedure PrecacheHeightmap;
    var
      X,Z : LongInt;
    begin
      //Läd die Heightmap in ein Delphi Array
      SetLength(HeightMapData, HM.Width);
      for X := 0 to Length(HeightMapData) -1 do
        SetLength(HeightMapData[X], HM.Height);

      for Z := 0 to HM.Height - 1 do
        for X := 0 to HM.Width - 1 do
          HeightMapData[X,Z] := Trunc(HM.Canvas.Pixels[X,Z]/clWhite * 255)
    end;

    function RayIntersected(Ray : TRay; HM : TBitMap;
                                HeightMapScale : Single):Boolean;
    begin
      Result := False;
    end;

  var
    X,Z : LongInt;
    Ray : TRay;
    n : TVertex;
    f : Single;
  begin
    if Raytracing then PrecacheHeightmap;

    //Wir haben paraleles Licht, also bleibt der Richtungsvektor des
    //Rays immer gleich
    Ray.Direction := VectorNormalize(Sun);


    for Z := 0 to HM.Height - 1 do
      for X := 0 to HM.Width - 1 do
      begin
        Ray.Origin[0] := X;
        Ray.Origin[1] := HM.Canvas.Pixels[X,Z]/clWhite*255*HeightMapScale;
        Ray.Origin[2] := Z;
        if RayTracing and RayIntersected(Ray, HM, HeightMapScale) then
          f := LightAmbient
        else begin
          //Komprimierte Normale zurückverwandeln
          n[0] := NormalMap[Z*(HM.Height) + X][0];
          n[1] := NormalMap[Z*(HM.Height) + X][1];
          n[2] := NormalMap[Z*(HM.Height) + X][2];

          f := LightAmbient+LightDiffuse*(VectorDotProduct(
          VectorNormalize(n),VectorNormalize(Ray.Direction)));
          //Zuviel licht?
          if f > 1.0 then f := 1.0;
          if f < 0.0 then f := 0.0
        end;
        LightMap.Canvas.Pixels[X,Z] := RGB(Trunc(f*255),
                                       Trunc(f*255),
                                       Trunc(f*255))
      end
  end;

Argh.... Wieder so ein Monster an Code! Naja, kämpfen wir uns der Reihe mach durch... In HeightMapData wird die Heightmap geladen, um schnelleren Zugriff auf sie zu haben (zur Erinnerung an das 1. Tutorial: GDI Befehle sind arsch langsam). Die entstandenen Daten werden von RayIntersected verwendet um schneller ausführbar zu sein (glaubt mir, hier ist das wirklich nötig!). RayIntersected selbst gibt bisher immer False züruck (wir werden das spater noch ändern), was bedeutet, auf den derzeit betrachteten Punkt wird kein Schatten geworfen.

Bevor ich weitermache erläutere ich kurz das Prinzip von Ray Tracern (z.B.: POV-Ray). Ein Ray ist ein Strahl, der von einem bestimmten Punkt (Origin) losgeschickt, in eine bestimmte Richtung (Direction). Das Tracing, also das abgehen des Rays wird die Funktion RayIntersected für uns übernehmen.

Die Richtung des Rays ist fur alle Punkte gleich, da die Sonne zur Erde einen so großen Abstand hat, dass Licht parallel einfällt. Wird ein Ray geschnitten, wird dem Punkt Origin der Wert des Ambient Light zugewiesen, ansonsten wird mit der vorhin vorgestellten Formel die stärke des einfallenden Lichtes berechnet.

Dies ist noch nichts besonderes, denn OpenGL berechnet das Licht auf ähnliche Weise. Was noch fehlt sind die Schatten. Wollen wir also ein wenig über RayIntersected nachdenken...

Schattenspiele

Wir haben also den Ray. Der Einfachheit halber, gehen wir diesen immer ein Stücken weiter, schauen ob die Höhe der Heightmap gröser ist als der Ray oder nicht, solange bis wir am Ende der Karte angelangt sind. Klingt simpel? Ist es.

    function RayIntersected(Ray : TRay; HM : TBitMap;
                                HeightMapScale : Single):Boolean;
    var
       Position : TVertex;
       Vor : TVertex;
    begin
      Result := False;
      Position := Ray.Origin;
      Vor := VectorNormalize(Ray.Direction);
      Position := VectorAdd(Position, Vor);
      while (Position[0] > 0) and (Position[0] < HM.Width - 1) and
        (Position[2] > 0) and (Position[2] < HM.Height - 1) do
      begin
        if HeightMapData[Trunc(Position[0]), Trunc(Position[2])]*
          HeightMapScale >= Position[1] then
        begin
          Result := True;
          Break
        end;
        Position := VectorAdd(Position, Vor)
      end
    end;
    (*Achtung: In diesem Codestück ist eine kleine Performancebremse eingebaut. 
      Wer sie findet kommt deutlich schneller an die ersehnte Textur. 
      Es handelt sich übrigens um eine fehlende Bedingung im while. *)

Das entlanglaufen des Rays ist nich unbedingt Ideal gelöst, aber es erfüllt seinen Zweck. Starten wir einmal unseren nun fertigen Lightmap Generator und betrachten das Ergebnis:

Tutorial Terrain2 lightmap.jpg

Diese kann man dann mit der Procedural Texture verknupfen... Je Heller ein Punkt in der Lightmap, desto mehr wird von den Farben der Procedural Texture genommen:

Tutorial Terrain2 combined.jpg

Nachwort

Aha, du hast also durchgehalten. Ich hoffe jetzt bald viele schone Landschaften mit Schatten zu sehen. Bin jetzt etwas geschafft vom vielen Tippen.

...have a lot of fun!

Nico Michaelis aka DelphiC



Vorhergehendes Tutorial:
Tutorial_Terrain1
Nächstes Tutorial:
Tutorial_Terrain3

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