Vamos aprender XNA? – Parte 12 – Criando terrenos com heightmaps

Publicado em 04/08/2011

3


Olá pessoal, como estamos!?

Passados uns dias de “DLR”, volto a escrever um pouco sobre XNA.

No post de hoje, deixo um pouco de lado HLSL e volto a falar de algoritmo. Minha proposta: substituir o já habitual “quadriculado” que estou usando de “chão”, por um terreno (irregular). O resultado é mais ou menos esse:

image

Repare que optei por não utilizar texturas para o terreno. Isso fica para outros posts.

Para gerar esse terreno, utilizei uma técnica bacana: geração de terreno através de heightmaps.

Se você está chegando agora e não entende muito de desenho 3D no XNA, de uma olhada na parte 4.

Recomendo fortemente que você pegue os fontes em https://github.com/ElemarJR/VamosAprenderXNA.

O que são HeightMaps?

HeightMaps são texturas, geralmente em tons de cinza usadas como referência para geração de uma malha simulando terrenos. É muito útil para construção de espaços “acidentados”.

Para uma idéia, o terreno acima orgina-se desse mapa:

image

Na lógica que adotei, quanto mais claro (próximo de branco) for o ponto, mais alto será a posição correspondnete no terreno.

A classe Terrain

Isolei a lógica para criação do terreno a partir de um heightmap em uma única classe chamada Terrain. Observe a assinatura básica dos métodos e atributos:

public class Terrain : IDrawableModel
{
    public Terrain(Texture2D heightMap, 
        float cellSize, 
        int maxHeight,
        GraphicsDevice device)
    {
		// ...
    }

    void ComputeHeights()
    {
		// ...
    }

    void CreateVertices()
    {
		// ...
    }

    void CreateIndices()
    {
		// ...
    }

        
    float[,] Heights;
    int[] Indexes;

    public readonly Texture2D HeightMap;
    public readonly int Width;
    public readonly int Height;
    public readonly int Length;
    public readonly float CellSize;
        
    readonly int VerticesCount;
    VertexPositionColor[] Vertices;

    readonly int IndexCount;
    readonly VertexBuffer VertexBuffer;
    readonly IndexBuffer IndexBuffer;
    GraphicsDevice Device;
    BasicEffect Effect;

    public void Draw(Matrix View, Matrix Projection)
    {
        // ...           
    }
}

Repare como a interface pública da classe é reduzida. Há apenas um construtor e o método de desenho. 

O construtor é “acionado” no LoadContent do game. O método Draw, no Draw do game.

Todo “cálculo pesado” é executado por métodos privados que são acionados no construtor.

O construtor do terreno

Antes de falar do código do construtor, vamos ver ele sendo acionado:

ground = new Terrain(
    Content.Load<Texture2D>("heightmap1"),
    30, 4800, device
    );

Vamos falar um poquinho sobre os parâmetros:

  1. heightmap – a textura com indicação das elevações que desejamos ver no terreno. Repare que estou usando a Content Pipeline aqui para carregar esse conteúdo;
  2. cellSize – o tamanho de cada célula. Na prática, haverá uma “célula” (quadradinho) para cada pixel da textura. No nosso exemplo, estaremos assumindo que cada célula terá dimensão 30 para cada pixel;
  3. maxHeight – altura máxima para o terreno. No exemplo, estou orientando que o pixel totalmente branco equivale a 4800 de altura;
  4. device – device onde o desenho deverá ser realizado.

Agora, o código do construtor:

public Terrain(Texture2D heightMap,
    float cellSize,
    int maxHeight,
    GraphicsDevice device)
{
    rs.FillMode = FillMode.WireFrame;
    this.HeightMap = heightMap;
    this.Width = heightMap.Width;
    this.Height = maxHeight;
    this.Length = heightMap.Height;
    this.CellSize = cellSize;

    this.Device = device;
    this.Effect = new BasicEffect(device);

    this.VerticesCount = Width * Length;
    this.IndexCount = (Width - 1) * (Length - 1) * 6;

    this.VertexBuffer = new VertexBuffer(device,
        typeof(VertexPositionColor), VerticesCount,
        BufferUsage.WriteOnly);

    this.IndexBuffer = new IndexBuffer(device,
        IndexElementSize.ThirtyTwoBits,
        IndexCount, BufferUsage.WriteOnly);

    ComputeHeights();
    CreateVertices();
    CreateIndices();

    this.VertexBuffer.SetData<VertexPositionColor>(Vertices);
    this.IndexBuffer.SetData<int>(Indexes);
}

Repare que nosso construtor simplesmente atribui valores para os “atributos” do terreno. Além disso, ele invoca os métodos que geram a malha.

Calculando alturas (método ComputeHeights)

Antes de gerar o terreno, calculo as alturas equivalentes a cada pixel. Observe o código:

void ComputeHeights()
{
    var heightMapColors = new Color[Width * Length];
    HeightMap.GetData<Color>(heightMapColors);

    Heights = new float[Width, Length];

    for (int y = 0; y < Length; y++)
        for (int x = 0; x < Width; x++)
            Heights[x, y] = heightMapColors[y * Width + x].R / 255.0f * Height;
}

Lindo, não! Basicamente, varro cada pixel da textura e calculo a altura. Observe que:

  • utilizo apenas o componente vermelho da cor, isso é possível porque em imagens em “tons de cinza” todas os componentes tem o mesmo valor;
  • divido o valor de cada pixel por 255. Isso gera um peso entre 0 e 1;
  • multiplico o componente da altura pela “altura máxima” passada no construtor.

Calculando vértices (método CreateVertices)

Já conhecemos as “alturas” da textura. Agora, vamos computar os vértices da malha. Observe:

void CreateVertices()
{
    this.Vertices = new VertexPositionColor[VerticesCount];
    Vector3 offset = new Vector3(
		(Width / 2f) * CellSize, 0, 
		(Length / 2f) * CellSize
	);

    for (int z = 0; z < Length; z++)
        for (int x = 0; x < Width; x++)
        {
            Vector3 position = new Vector3(
                x * this.CellSize,
                Heights[x, z],
                z * CellSize
                ) - offset;

            this.Vertices[z * Width + x] = new VertexPositionColor(position, Color.Green);
        }
}

Para começar, repare que computo um offset para garantir que o centro da malha esteja no ponto 0.

Perceba que a utilização de CellSize “escala” a textura.

Criando índices (método CreateIndices)

Já temos os vértices, agora temos que calcular índices para a malha.

Repare que, para cada célula, computamos dois triângulos:

image

 

Eis o cálculo:

void CreateIndices()
{
    this.Indexes = new int[this.IndexCount];
    int index = 0;
    for (int x = 0; x < Width - 1; x++)
        for (int z = 0; z < Length - 1; z++)
        {
            int ul = z * Width + x;
            int ur = ul + 1;
            int ll = ul + Width;
            int lr = ll + 1;

            Indexes[index++] = ul;
            Indexes[index++] = ur;
            Indexes[index++] = ll;

            Indexes[index++] = ll;
            Indexes[index++] = ur;
            Indexes[index++] = lr;
        }
}

Lindo demais!

Desenhando.. (método Draw)

Tudo calculado. Problema resolvido. Agora é só desenhar. Observe:

public void Draw(Matrix View, Matrix Projection)
{
    Device.SetVertexBuffer(VertexBuffer);
    Device.Indices = IndexBuffer;
    var old = Device.RasterizerState;
    Device.RasterizerState = rs;

    Effect.World = Matrix.Identity;
    Effect.View = View;
    Effect.Projection = Projection;
    Effect.TextureEnabled = false;
    Effect.VertexColorEnabled = false;

    foreach (var pass in Effect.CurrentTechnique.Passes)
    {
        pass.Apply();
        Device.DrawIndexedPrimitives(
            PrimitiveType.TriangleList,
            0, 0,
            VerticesCount, 0,
            IndexCount / 3);
    }

    Device.RasterizerState = old;
}

O método, em si, não apresenta grandes novidades. O único ponto aqui é o “set” do RasterizeState para wireframe.

Por hoje, temos isso:

image

 

Outro dia, vamos colocar um pouco de texturas nesse terreno.

Etiquetado:,
Publicado em: Sem categoria