20. Mapeamento de texturas no WebGL

Agora vamos aplicar o que vimos na aula passada para mapear texturas (imagens) para a superfície de objetos. Para variar, vamos começar com um exemplo bem simples, usando uma textura procedimental.

20.1. Textura procedimental

Uma textura procedimental é uma textura criada por uma função matemática (ou algoritmo), ao invés de ser carregada de uma imagem.

Vamos usar o trecho de código abaixo para criar um tabuleiro de xadrez com \(8 \times 8\) casas. Esse tabuleiro é armazenado em um array do tipo Uint8 com \(128 \times 128\) pixels, sendo que cada pixel contém uma cor RGBA.

// textura é um tabuleiro de xadrez 8x8
const TEX_LINS = 8;
const TEX_COLS = 8;
const TEX_LADO = 128; // lado da imagem textura
const TEX_COR_SIZE = 4; // RGBA
// cria array de textura. Pixels costumam ser do tipo Uint8
var gaTextura = new Uint8Array( TEX_COR_SIZE * TEX_LADO * TEX_LADO);
// varre e carrega o array
for (var i = 0, ind = 0; i<TEX_LADO; i++) {
    var casax = Math.floor(i/(TEX_LADO/TEX_LINS));
    for (var j=0; j<TEX_LADO; j++) {
        var casay = Math.floor(j/(TEX_LADO/TEX_COLS));
        var c = (casax%2 != casay%2 ? 255 : 0); // banco ou preto?
        gaTextura[ind++] = c;
        gaTextura[ind++] = c;
        gaTextura[ind++] = c;
        gaTextura[ind++] = 255;
    };
};

Por hora, vamos considerar essa textura para ser aplicada nas faces de um cubo.

20.2. Mapeamento a textura

Lembre-se de que todos os objetos em WebGL são renderizados como polígonos (ou malhas de triângulos). Isso simplifica o processo de mapeamento de textura porque significa que precisamos apenas fornecer a função de desembrulho para os vértices e podemos contar com uma interpolação simples para preencher o interior do polígono.

Por exemplo, suponha que um triângulo está sendo desenhado. Ao definir os seus vértices, podemos também especificar as coordenadas de textura correspondentes \((s, t)\) desses pontos. Vamos chamá-las de coordenadas de textura dos vértices. Isso define implicitamente a função de desembrulho da superfície do polígono para um ponto no espaço de textura.

Assim como as normais de superfície (que foram usadas nos cálculos de iluminação), os vértices de textura são especificados antes de serem desenhados e passados aos shaders. Assim, para desenhar um objeto usando textura e iluminação, vamos precisar calcular um buffer de normais \(n = (nx,ny,nz)\) à superfície, outro buffer contendo as coordenadas da textura \((s,t)\) e, como sempre, o buffer com as posições \(p = (px,py,pz)\) de cada vértice.

O trecho de código abaixo ilustra como podemos criar um cubo de lado unitário centrado na origem, sem iluminação, mas com textura em cada vértice. A função quad() é chamada para cada face do cubo pela função crieCubo(), que carrega os buffers gaPosicoes (com a posição dos vértices) e gaTexCoords (com a correspondente posição do vértice no espaço de textura).

// posições dos 8 vértices de um cubo de lado 1
// centrado na origem
var gaPosicoes = [];
var vCubo = [
    vec3(-0.5, -0.5,  0.5),
    vec3(-0.5,  0.5,  0.5),
    vec3( 0.5,  0.5,  0.5),
    vec3( 0.5, -0.5,  0.5),
    vec3(-0.5, -0.5, -0.5),
    vec3(-0.5,  0.5, -0.5),
    vec3( 0.5,  0.5, -0.5),
    vec3( 0.5, -0.5, -0.5)
];

// textura: coordenadas (s, t) entre 0 e 1.
var gaTexCoords = [];
var vTextura = [
    vec2(0.0, 0.0),
    vec2(0.0, 1.0),
    vec2(1.0, 1.0),
    vec2(1.0, 0.0)
];

function quad(a, b, c, d) {
    // recebe 4 indices de vertices de uma face
    // monta os dois triangulos voltados para "fora"
    // usando a cor do 1o vértice

    gaPosicoes.push(vCubo[a]);
    gaTexCoords.push(vTextura[0]);

    gaPosicoes.push(vCubo[b]);
    gaTexCoords.push(vTextura[1]);

    gaPosicoes.push(vCubo[c]);
    gaTexCoords.push(vTextura[2]);

    gaPosicoes.push(vCubo[a]);
    gaTexCoords.push(vTextura[0]);

    gaPosicoes.push(vCubo[c]);
    gaTexCoords.push(vTextura[2]);

    gaPosicoes.push(vCubo[d]);
    gaTexCoords.push(vTextura[3]);
};

function crieCubo() {
    // define as seis faces de um cubo usando os 8 vértices
    quad(1, 0, 3, 2);
    quad(2, 3, 7, 6);
    quad(3, 0, 4, 7);
    quad(6, 5, 1, 2);
    quad(4, 5, 6, 7);
    quad(5, 4, 0, 1);
};

20.3. Interpolação

Dada as coordenadas de textura no buffer gaTexCoords, a próxima questão é como interpolar as coordenadas de textura para os pontos no interior do polígono. Uma primeira ideia seria projetar os vértices do triângulo diretamente na viewport. Isso nos dá três pontos \(P_0\), \(P_1\) e \(P_2\) para os vértices do triângulo no espaço 2D. Seja \(Q_0\), \(Q_1\) e \(Q_2\) as três coordenadas de textura correspondentes a esses pontos. Agora, para qualquer pixel no triângulo, seja \(P\) o seu centro. Podemos representar \(P\) como a combinação afim

\(P = \alpha_0 P_0 + \alpha_1 P_1 + \alpha_2 P_2 \;\;\;\) para \(\alpha_0 + \alpha_1 + \alpha_2 = 1.0\).

O cálculo dos valores de \(\alpha\) para um ponto arbitrário do triângulo geralmente envolve a resolução de um sistema de equações lineares, mas existem métodos simples e eficientes que podem ser usados na rasterização de polígonos. Uma vez calculados os \(\alpha_i\), o ponto correspondente no espaço de textura é dado por

\(Q = \alpha_0 Q_0 + \alpha_1 Q_1 + \alpha_2 Q_2\).

Assim como o WebGL faz com cores, a textura também é interpolada automaticamente pelo WebGL. Para isso, podemos usar os seguintes códigos fonte para os shaders:

var gVertexShaderSrc = `#version 300 es
// buffers de entrada
in vec3 aPosition;
in vec2 aTexCoord;

uniform mat4 uModelView;
uniform mat4 uPerspective;

out vec2 vTexCoord;

void main() {
    gl.Position = uPerspective * uModelView * vec4(aPosition, 1);
    // Experimento: correção perspectiva manual.
    // Remova o comentário da linha abaixo para ver o que acontece.
    // gl.Position /= gl.Position.w;
    vTexCoord = aTexCoord;
}
`;

var gFragmentShaderSrc = `#version 300 es

precision highp float;

in vec2 vTexCoord;
uniform sampler2D uTextureMap;

out vec4 outColor;

void main() {
    outColor = texture(uTextureMap, vTexCoord);
}
`;

Observe que as coordenadas de textura são passadas do vertex shader diretamente ao fragment shader pela variante vTexCoord. A textura (imagem do tabuleiro) é passada pelo uniforme uTextureMap, do tipo sampler2D. Dessa forma, a cor de saída é dada pelo comando texture(uTextureMap, vTexCoord) que realiza a interpolação da textura como discutimos anteriormente.

O que pode acontecer de errado com essa abordagem direta? O primeiro problema tem a ver com a perspectiva. A abordagem direta faz a suposição incorreta de que as combinações afins são preservadas sob a projeção em perspectiva. Lembre-se que isso não é verdade. O WebGL usa a coordenada homogênea W para corrigir as distorções perspectivas nas variantes também. Para verificar isso, remova o comentário da linha com gl.Position /= gl.Position.w; no vertex shader. Mais sobre esse assunto você pode ler na página sobre WebGL2 3D Perspective Correct Texture Mapping.

Um segundo problema tem a ver com algo chamado aliasing. Lembre-se que dissemos que depois de determinar o fragmento do espaço de textura no qual o pixel se projeta, devemos calcular a média das cores dos texels neste fragmento. O procedimento acima considera apenas um único ponto no espaço de textura e não tem média. Em situações em que o pixel corresponde a um ponto muito distante e, portanto, cobre uma grande região no espaço de textura, isso pode produzir resultados com uma aparência estranha porque a cor de todo o pixel é determinada inteiramente por um único ponto no espaço de textura que correspondem (digamos) às coordenadas do centro do pixel.

Lidar com aliasing em geral é um problema que é tipicamente estudado na área de processamento de sinais. O WebGL aplica um método simples para lidar com esse problema, chamado de mipmapping. A sigla “mip” vem da frase latina multum in parvo, que significa “muito em pouco”.

A ideia por trás do mipmapping é gerar uma série de imagens de textura em níveis de resolução decrescentes. Por exemplo, se você começou originalmente com uma imagem de \(128 \times 128\), um mipmap consistiria nessa imagem junto com uma imagem de \(64 \times 64\), uma imagem de \(32 \times 32\) etc.. Cada pixel da imagem de \(64 \times 64\) representa a média de um bloco de \(2 \times 2\) do original. Cada pixel da imagem \(32 \times 32\) representa a média de um bloco \(4 \times 4\) do original e assim por diante.

Quando fazemos o WebGL aplicar o mapeamento de textura a um pixel que se sobrepõe a muitos texels, ele determina o mipmap na hierarquia que está no nível mais próximo de resolução e usa o correspondente valor médio de pixel dessa imagem do mipmap, resultando em uma imagem mais suave.

A seguir, veremos como configurar o mipmap e outros elementos para realizar o mapeamento de texturas usando WebGL.

20.4. Como configurar um mapa de textura no WebGL?

O WebGL suporta um mecanismo bastante geral para mapeamento de textura mas o processo envolve um grande número de opções diferentes. Você deve consultar a documentação do WebGL para obter informações mais detalhadas. Vamos nos limitar a descrever alguns parâmetros mais comuns para implementar programas gráficos usando texturas, como ilustrado no trecho de código abaixo com a função configureTextura().

function configureTextura(img) {
    var texture = gl.createTexture();  // cria uma textura
    gl.activeTexture(gl.TEXTURE0);     // ativa a unidade TEXTURE0
    gl.bindTexture(gl.TEXTURE_2D, texture);   // conecta a textura a unidade ativa
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, TEX_LADO, TEX_LADO, 0, gl.RGBA, gl.UNSIGNED_BYTE, img); // carrega a textura
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}

Essa função recebe uma imagem img, no caso desse exemplo, a imagem do tabuleiro de xadrez. Um buffer de textura é criado por gl.createTexture() e associado a unidade gl.TEXTURE0. O número de unidades de textura disponíveis depende da sua plataforma, mas é ao menos 8. O número máximo disponível pode ser conhecido pela constante gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS.

De forma semelhante ao buffer de vértices, a textura se torna ativa pelo comando gl.bindTexture(), usando para isso a textura que acabamos de criar. Nesse exemplo estamos criando uma textura 2D, mas ela poderia ainda ser 1D ou 3D. Os comandos seguintes ao bindTexture são aplicados à textura ativa.

O comando seguinte, gl.texImage2D() passa a textura para a GPU e recebe vários parâmetros para que possa ser convertido para seu formato interno. Vamos discutir alguns desses parâmetros. A sintaxe usada para esse comando é:

glTexImage2d(gl.TEXTURE_2D, nível, formato interno, largura, altura, borda, formato, tipo, imagem);

Em nosso exemplo, a imagem img é um array composto por elementos do tipo gl.UNSIGNED_BYTE (um byte sem sinal) e cada pixel está no formato interno gl.RGBA. Essa imagem possui largura e altura TEX_LADO e não tem borda (borda = 0). Estamos armazenando a resolução de nível mais alto. Outros níveis de resolução são usados para implementar o processo de mipmap. Normalmente, o parâmetro de nível será 0 (nível = 0). O formato dos texels também é gl.RGBA.

A chamada gl.generateMipmap(gl.TEXTURE_2D) cria o mipmap com todos os níveis menores de resolução.

A definição de parâmetros de textura é realizada usando o comando gl.texParamenteri (para inteiros, mas pode ser também gl.texParamenterf para reais).

UM parâmetro útil que determina como o arredondamento é realizado durante a ampliação (gl.TEXTURE_MAG_FILTER quando um pixel de tela é menor que o pixel de textura correspondente) e redução (gl.TEXTURE_MIN_FILTER quando um pixel de tela é maior que o pixel de textura correspondente). A opção mais simples, mas não a mais bonita, em cada caso, é usar apenas o pixel mais próximo na textura usando gl.NEAREST:

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

No caso de redução, quando muitos texels precisam ser convertidos para 1 pixel, podemos aplicar os seguintes 6 modos de filtragem para gl.TEXTURE_MIN_FILTER:

  • NEAREST : escolhe 1 pixel do maior nível do mipmap;
  • LINEAR : escolhe 4 pixels do maior nível do mipmap e os mistura linearmente;
  • NEAREST_MIPMAP_NEAREST : escolhe o melhor nível (MIPMAP_NEAREST) e escolhe 1 pixel desse nível;
  • LINEAR_MIPMAP_NEAREST : escolhe o melhor nível e mistura 4 pixels desse nível;
  • NEAREST_MIPMAP_LINEAR : escolhe os 2 melhores níveis (MIPMAP_LINEAR), escolhe 1 pixel de cada e os mistura (valor default);
  • LINEAR_MIPMAP_LINEAR : escolhe os 2 melhores níveis, escolhe 4 de cada e os mistura.

Na magnificação, o valor default de gl.TEXTURE_MAG_FILTER no WebGL é gl.LINEAR.

Outro parâmetro comum a ser definido serve para especificar se uma textura deve ser repetida ou não. As seguintes opções podem ser usadas.

gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); // default gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);

Essas opções determinam o que acontece se o parâmetro s da coordenada de textura for menor que 0 ou maior que 1. Se isso nunca acontecer, você não precisa se preocupar com esta opção. A opção gl.REPEAT é usada por default pelo WebGL e faz com que a textura seja enrolada repetidamente usando o valor fracionário de s. Assim, s = 1.234 e s = 99.234 são ambos equivalentes a s = 0.234. Isso pode ser definido independentemente para o parâmetro t da coordenada de textura usando gl.TEXTURE_WRAP_T ao invés de gl.TEXTURE_WRAP_S.

Usando a opção gl.CLAMP_TO_EDGE os valores de s que são negativos são tratados como se fossem 0, e os valores de s que excedem 1 são tratados como se fossem 1. Já a opção g.MIRRORED_REPEAT alterna valores da textura com valores espelhados.

O código completo desse exemplo está disponível no JSitor e também pode ser visto logo abaixo. Modifique os valores da textura, como número de linhas e colunas da textura, para ver seu resultado.

20.5. Combinando textura com cores e iluminação

Como as cores das texturas são combinadas com as cores dos objetos?

Nosso exemplo anterior simplesmente torna a cor do pixel igual à cor da textura. Essa forma pode ser adotada para pintar texturas que já estão pré-iluminadas, o que significa que a iluminação já foi aplicada. Exemplos incluem skyboxes (usadas para pintar o céu por exemplo) e iluminação pré-computada para o teto e as paredes de uma sala.

Uma outra forma comum é modular a iluminação usando a textura. Dessa forma, a cor resultante de cada pixel se torna o produto da cor do pixel (que pode ou não usar um modelo de iluminação como a de Phong) pela cor da textura. O programa a seguir mostra como o nosso exercício anterior pode ser estendido para combinar a textura com cores distintas em cada vértice.

O trecho de código a seguir ilustra os shaders para receber um buffer de cores.

var gVertexShaderSrc = `#version 300 es
// buffers de entrada
in vec3 aPosition;
in vec2 aTexCoord;
in  vec4 aColor;

uniform mat4 uModelView;
uniform mat4 uPerspective;

out vec2 vTexCoord;
out vec4 vColor;

void main() {
    gl_Position = uPerspective * uModelView * vec4(aPosition, 1);
    vTexCoord = aTexCoord;
    vColor = aColor;
}
`;

var gFragmentShaderSrc = `#version 300 es
precision highp float;

in vec4 vColor;
in vec2 vTexCoord;
uniform sampler2D uTextureMap;

out vec4 outColor;

void main() {
outColor = vColor * texture(uTextureMap, vTexCoord);
}
`;

O código completo desse exemplo está disponível no JSitor e pode ser visto logo a seguir.

Exercício

Estenda esse código para incluir também efeitos de iluminação segundo o modelo de Phong.

20.6. Usando imagens de arquivos como textura

Um problema de utilizar arquivos de imagens que estão em alguma ‘nuvem’ é que seu carregamento ocorre de forma assíncrona. Assim, seu programa gráfico deve esperar até que o navegador faça o download da imagem. Há duas soluções comuns para resolver esse problema. A primeira é fazer o programa ficar esperando até que tudo tenha sido trazido da nuvem para iniciar a renderização. Uma solução alternativa é usar uma textura ‘falsa’ até que a imagem seja descarregada da nuvem. Assim a renderização pode começar imediatamente e, quando a imagem estiver disponível, basta passar a imagem verdadeira para ser renderizada. Isso pode ser feito pelo trecho de código abaixo:

function configureTexturaDaURL( url ) {
    // cria a textura
    var texture = gl.createTexture();
    // seleciona a unidade TEXTURE0
    gl.activeTexture(gl.TEXTURE0);
    // ativa a textura
    gl.bindTexture(gl.TEXTURE_2D, texture);

    // Carrega uma textura de um pixel 1x1 vermelho, temporariamente
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
        new Uint8Array([255, 0, 0, 255]));

    // Carraga a imagem da URL:
    // veja https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image
    var img = new Image(); // cria um bitmap
    img.src = url;
    img.crossOrigin = "anonymous";
    // espera carregar.
    img.addEventListener('load', function() {
        console.log("Carregou imagem", img.width, img.height);
        // depois de carregar, copiar para a textura
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.width, img.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, img);
        gl.generateMipmap(gl.TEXTURE_2D);
        // experimente usar outros filtros removendo o comentário da linha abaixo.
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    }
    );
    return img;
};

Para esse exemplo vamos considerar a seguinte figura:

Imagem contendo várias flores que vamos usar como textura.

Fig. 20.1 Imagem contendo várias flores que vamos usar como textura.

Para usar essa imagem como textura vamos escolher os cantos para selecionar apenas as primeiras 9 flores, como ilustrado no seguinte trecho de código:

const URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Flower_poster_2.jpg/1200px-Flower_poster_2.jpg"

var gaTexCoords = [];
var vTextura = [      // cantos escolhidos para recortar a parte desejada
    vec2(0.05, 0.05),
    vec2(0.05, 0.75),
    vec2(0.95, 0.75),
    vec2(0.95, 0.05)
];

O código completo desse exemplo está disponível no JSitor e pode ser visto logo a seguir.

Texturas devem ser carregadas de domínios seguros.

O WebGL exige que as texturas sejam carregadas de contextos seguros e não permite usar texturas carregadas de file://. Isso significa que você precisa usar um servidor web seguro para testar e implantar seu código usando imagens em arquivos locais. Seu servidor precisa ser configurado para fornecer aprovação do CORS (*cross-origin resource sharing*).

20.7. Onde estamos e para onde vamos?

Nessa aula aplicamos os conceitos vistos em aulas passadas para implementar programas gráficos usando superfícies texturizadas.

Na próxima aula vamos discutir como renderizar sombras, um outro efeito de iluminação capaz de melhorar ainda mais o realismo das imagens geradas por computador.

20.8. Para saber mais