Ponto V!

Home WebGL Heightmap: Criando terrenos
Vinícius Godoy de Mendonça
Heightmap: Criando terrenosImprimir
Escrito por Vinícius Godoy de Mendonça

No artigo passado, vimos como criar uma série de quadrados dispostos lado-a-lado. Uma das coisas interessantes sobre essa geometria é que ela pode ser distorcida para criar os mais variados tipos de superfícies. Nesse artigo, veremos como utilizar uma imagem em 2D para transformar a malha num terreno.

Heightmaps

Boas técnicas de programação de games sempre dão a liberdade para que a equipe de produção de um game (artistas, músicos, game e level designers) trabalhem livremente. Você, como programador de jogos, vai estar mais interessado em montar um algoritmo inovador, do que ficar calibrando parâmetros e ser interrompido toda vez que um game designer tiver uma nova idéia. O mesmo raciocínio vale para os terrenos. Como permitir que os artistas modelem o terreno que quiserem, sem que tenhamos que refazer um arquivo complicado de malha? Uma das soluções mais prática para isso está no uso de heightmaps. Heigtmaps são similares aos mapas de relevo, que conhecemos em geografia.

Um heightmap trata-se de uma imagem 2D, em tons de cinza, onde o tom define a altura do relevo. Ela pode ser facilmente criada por um artista em ferramentas apropriadas de desenho. Abaixo, um exemplo do heightmap de um vulcão:

Heightmap de um vulcão

A imagem nada mais é do que um conjunto de pixels. Nesse caso, uma imagem com 256x256 pixels. Para representa-la em 3D, basta que cada um desses pixels seja convertido em um vértice. E que a altura desse vértice seja proporcial a tonalidade de branco da imagem.

Carregando o heightmap do disco

O primeiro problema que temos é o de carregar os dados do heightmap do disco. Este não é um trabalho difícil, e é bastante similar ao que já fizemos com os shaders. Entretanto, utilizaremos o objeto Image para fazer a carga da imagem no lugar do HttpXmlRequest, uma vez que temos interesse em fazer a leitura dos pixels da imagem. Para isso, definimos a seguinte função, que faz a requisição na forma de um Promise:

//Cria o Promise que carrega a imagem do servidor
//Veja também a função imp.loadImageData
function requestImage(url) {
    return new Promise(function(resolve) {
        var img = new Image();
        img.onload = function() {
            resolve(img)
        }
        img.src = url;
    });
}

Observe que trata-se de uma função privada. Afinal, não basta ter um objeto Image em mãos, se não podemos manipular seus pixels.

Obtendo os dados da imagem

Para obter os valores de R, G e B de cada pixel precisaremos recorrer ao objeto canvas do html5. O processo é bastante simples. Basta:

  • Criar um canvas do tamanho da imagem;
  • Desenhar a imagem sobre ele;
  • Solicitar os pixels do canvas.

O procedimento para isso está descrito abaixo:

/**
* Retorna um promise que lê a imagem do disco E extrai seus dados.
*/
imp.requestImageData = function(url) {
   //O then retorna o Promise de todas essas operações juntas.        
   return requestImage(url).then(function(source) {            
       //Criamos um canvas do tamanho da imagem
       var srcCanvas = document.createElement("canvas")
       srcCanvas.width = source.naturalWidth;
       srcCanvas.height = source.naturalHeight;            

//Obtermos o contexto 2D e desenhamos a imagem sobre o canvas var c = srcCanvas.getContext("2d") c.drawImage(source, 0, 0); //Requisitamos os dados da imagem return c.getImageData(0, 0, srcCanvas.width, srcCanvas.height); }); }

Os pixels serão retornados na forma de um array unidimensional. Estarão armazenados nesse array, em ordem, os valores de R, G, B e A de cada pixel, de cima para baixo, da esquerda para direita. O valor em R, G, B e A pode ser um número de 0 até 255, sendo o valor 255 mais claro, e 0 mais escuro - como usamos comumente em aplicações de desenho. Como a imagem está em tons de cinza, os valores de R, G e B serão sempre iguais.

Seguindo a mesma lógica do artigo anterior, a fórmula para converter um índice bidimensional no índice linear indicando o canal de cor de um pixel da imagem é:

function coordToIndex(pixels, x, y) {
    return (x + y * pixels.width) * 4;
}

Com isso, podemos criar uma função chamada getRGB, que passado um array, retorna a cor do pixel correspondente num vetor:

imp.getRGB = function(pixels, x, y) {
    var index = coordToIndex(pixels, x, y);
    return vec4.fromValues(
        pixels.data[index],
        pixels.data[index+1],
        pixels.data[index+2],
        pixels.data[index+3]
    );
}

Observe que as funções estão num pacote chamado “imp”. Essa é a abreviatura de “Image Processing”. Iremos incluir artigos sobre processamento de imagens e filtros no futuro. O exemplo desse artigo também contém funções para escrita no pixel.

Alterando a função createData

Vamos alterar a função createData para carregar nosso terreno. Para isso, passaremos a receber apenas 2 parâmetros: o nome da imagem e o fator de escala. O fator de escala é simplesmente um número pelo qual multiplicaremos o tom de cinza, permitindo assim regular o quão “alta” será cada diferença de tom. Ele será opcional, caso o usuário não o forneça, será definido para 0.5.

Observe o efeito que o fator de escala tem sobre o terreno do vulcão. Abaixo são mostradas imagens com escala 0.2, 0.5 e 1.0:

Vulcão com escalas de 0.2, 0.5 e 1.0

As mudanças na função são muito pequenas. Inicialmente, preparamos os parâmetros:

scale = scale || 0.5;        
var width = img.width;
var depth = img.height;

Em seguida, alteramos a linha que definia a altura de y como 0 de:

data.vertices.push(0);

para:

data.vertices.push(imp.getRGB(img, x, z)[0] * scale);

Com a função alterada, podemos agora fazer a carga do terreno na função initMesh:

function initMesh() {
    imp.requestImageData("/terrain/volcano.png").then(function(img) {
        var data  = createData(img);
        mesh = {};
        mesh.vertexPosition = glc.createBuffer(gl, gl.ARRAY_BUFFER, 3, data.vertices);    
        mesh.indices = glc.createBuffer(gl, gl.ELEMENT_ARRAY_BUFFER, 1, data.indices);
        mesh.transform = mat4.create();    
    });
}

E voilá!

Os últimos detalhes

Se você executasse o código nesse ponto, obteria um vulcão totalmente branco, onde o relevo seria difícil de distringuir. Vamos deixar o exemplo um pouco mais interessante criando um array de atributos de cor, e então, voltando a usar nosso shader básico. Vamos também ampliar o giro do terreno para mais de um eixo, para podermos olhar dentro da cratera do vulcão.

Colocando cor no terreno

Iremos adicionar um array chamado colors dentro do objeto data. Isso permitirá que voltemos a usar nosso shader básico, o mesmo que utilizamos nos artigos do quadrado colorido.

Para tanto, alteramos o for de criação do terreno para:

//Criação dos vértices
for (var z = 0; z < depth; z++) {
    for (var x = 0; x < width; x++) {        
        var tone = imp.getRGB(img, x, z)[0];
        
        //Posição
        data.vertices.push(x - hw);
        data.vertices.push(tone * scale);
        data.vertices.push(z - hd);
        
        //Cores
        var color = tone / 255 * 0.8 + 0.2;
        data.colors.push(0);
        data.colors.push(color);
        data.colors.push(0);
        data.colors.push(1);
    }
}

Observe que para criar o array de cores, simplesmente inserimos os valores de R, G, B e A sequenciamente em data.colors. Alterei o valor de G para ser proporcial ao tom de branco do pixel. E o que é aquela fórmula maluca?

Sabemos que o valor da cor virá no intervalo de 0 até 255. Observe que estamos dividindo esse valor por 255. Assim, transformamos o intervalo de cor em um valor de 0 até 1, como pede a WebGL. Para não correr o risco de termos pixels pretos, que ficariam invisíveis no fundo, multiplicamos esse valor por 0.8. O intervalo de cores com essa multiplicação agora será de 0 até 0.8. Nos livramos do 0 então somando 0.2 ao resultado, obtendo um tom de cor no intervalo de 0.2 até 1.0. Esse valor será colocado em nosso tom de verde.

Não se esqueça de alterar também as funções initMesh, initProgram e drawScene para considerar esse novo array de cores, exatamente igual fizemos no exemplo do quadrado colorido. Além disso, você também deve copiar os shaders basic.vs e basic.fs e utilizá-los, no lugar dos shaders white.vs e white.js.

Aumentando os eixos de rotação do terreno

Vamos alterar a nossa função de rotação do terreno para trabalhar sobre 2 eixos. O x e o y. Para isso, vamos definir duas variáveis de ângulo ao invés de uma só:

var angleY = 0;
var angleX = 0;

Em seguida, basta atualizar nossa função updateScene para:

function update(secs) {
    var speed = glc.toRadians(72) * secs;
    if (Key.isDown(Key.SPACE)) {
        angleX = 0;
        angleY = 0;
    }

    if (Key.isDown(Key.SHIFT)) {
        speed *= 3;
    }
    
    if (Key.isDown(Key.LEFT)) {
        angleY += speed;
    } else if (Key.isDown(Key.RIGHT)) {
        angleY -= speed;
    }
    
    if (Key.isDown(Key.UP)) {
        angleX += speed;
    } else if (Key.isDown(Key.DOWN)) {
        angleX -= speed;
    }
    
    if (mesh) {
        mat4.rotateY(mesh.transform, mat4.create(), angleY);
        mat4.rotateX(mesh.transform, mesh.transform, angleX);
    }
}

Observe que como o mesh agora é carregado do disco, tivemos que testar se ele estava disponível antes de alterar suas matrizes.

Habilitando depth test e culling

Por fim, precisamos habilitar 2 propriedades da OpenGL. A primeira é o teste de profundidade (depth test). Quando está ativo, a WebGL usará um buffer chamado de DEPTH_BUFFER para controlar o que está desenhado no fundo da cena e o que está na frente. Assim, não importando assim ordem que as coisas são desenhadas. Esse teste é absolutamente necessário para imagens 3D.

A segunda propriedade não é obrigatória, mas desejável. É chamada de backface culling. Trata-se de pedir para a OpenGL não desenhar as costas dos triângulos. Isso otimiza o processo de desenho, já que, teoricamente, ninguém olharia o terreno debaixo para cima. Para habilitar essas duas propriedades daremos os seguintes comandos em nossa função init:

//Habilita o depth test
gl.enable(gl.DEPTH_TEST);

//Habilita o culling
gl.enable(gl.CULL_FACE);

Concluindo

Você pode ver a demonstração do resultado final clicando aqui. Você também pode baixar o exemplo clicando no link abaixo. Incluí vários outros terrenos no arquivo de exemplo, assim como constantes no início do programa para facilitar a carga de todos eles. Experimente cada um deles!

Nos próximos artigos iremos mostrar como fazer iluminação no terreno através da técnica de Phong Shading. Até lá!


Comentários (5)
  • Leonardo
    avatar

    Muito massa!! :side:

  • Aml  - Voxel Terrain
    avatar

    Gostei muito desse tutorial sobre terrenos.
    Você poderia fazer um sobre voxel terrain?
    É algo sobre o qual sempre tenho dúvidas, especialmente sobre tratamento de oclusão nos blocos e o uso de blocos "partidos" pro terreno não ficar quadriculado que nem minecraft.
    De qualquer forma, valeu pelo artigo.

  • Rodrigo Appendino  - Preciso de sugestões
    avatar

    Oi, Vinícius. Gostaria de uma ajuda.
    Sou auto-didata em programação, mas sinto como se meu conhecimento estivesse estagnado.
    Sei programar coisas básicas que apresentam textos na tela. Mas as vezes leio coisas como "quem escreve um pequeno compilador para ler do disco as configurações do seu personagem" aqui no Ponto V mesmo, e fico sem saber como se faz isso.

    Você tem bom material para me recomendar para eu avançar mais em programação?

    Obrigado.

  • WHataF  - Nada Haver
    avatar

    Por que não colocam coisas mais interessantes no site de vocês ?
    Ah, é porque aqui é só para enfeitar o currículo de vocês.

    Tenham mais respeito, coloquem coisas realmente uteis.

Escrever um comentário
Your Contact Details:
Gravatar enabled
Comentário:
[b] [i] [u] [url] [quote] [code] [img]   
:angry::0:confused::cheer:B):evil::silly::dry::lol::kiss::D:pinch::(:shock:
:X:side::):P:unsure::woohoo::huh::whistle:;):S:!::?::idea::arrow:
Security
Por favor coloque o código anti-spam que você lê na imagem.
LAST_UPDATED2  

Busca

Linguagens

Twitter