22. Mapeamento de sombras no WebGL

O desenho de sombras é resultado da posição relativa entre objetos e as fontes de luz e portanto faz parte de um modelo de iluminação global. Na aula anterior vimos algumas formas de desenhar sombras fortes em superfícies planas, primeiro projetando os vértices do objeto sobre uma superfície plana, calculando um polígono de sombra, e depois sugerindo a criação de um mapa de sombras.

A ideia do mapa é calcular a profundidade dos fragmentos quando vistos pela fonte de luz e, ao renderizar a cena, comparar a distância armazenada com a distância do fragmento visto pela câmera à fonte de luz. Se a distância vista pela câmera for maior que a distância no mapa, consideramos esse ponto como sombra.

Veremos primeiro como calcular o mapa de sombras no WebGL.

22.1. Cálculo do mapa de sombras

Para calcular o mapa de sombras vamos usar um shader simples como mostrado no trecho de código abaixo.

// criação do programa para executar esse shader
var gShaderSombras = {};
gShaderSombras.program = makeProgram(gl, gVertexShaderSombrasSrc, gFragmentShaderSombrasSrc);

var gVertexShaderSombrasSrc = `#version 300 es

in vec3 aPosition;

uniform mat4 uModel;
uniform mat4 uLightView;
uniform mat4 uLightPerspective;

void main() {
    gl_Position = uLightPerspective * uLightView * uModel * vec4(aPosition, 1);
}
`;

var gFragmentShaderSombrasSrc = `#version 300 es

precision highp float;

out vec4 outColor;

void main() {
    // a cor/textura representa a profundidade do
    // fragmento visto pela luz
    outColor =  vec4(gl_FragCoord.z, gl_FragCoord.z, gl_FragCoord.z , 1.0);
}
`;

Observe que o vertex shader gVertexShaderSombrasSrc aplica a matriz de projeção perspectiva (e model-view) sobre os vértices. Lembre-se que a projeção perspectiva carrega também o buffer de profundidade usado pelo fragment shader desenhar apenas os fragmentos mais próximos da câmera. Observe também que o fragment shader gFragmentShaderSombrasSrc “pinta” a cor RGB com a profundidade gl_FragCoord.z (do fragment shader), ou seja, a cor outColor desse framebuffer possui informação de “profundidade”. Assim, ao aplicarmos esse shader usando uma câmera na mesma posição da fonte de luz e um framebuffer de rascunho (pois esse desenho não é mostrado no canvas), podemos salvar as distâncias (profundidades) até os objetos mais próximos à fonte de luz.

22.1.1. Como criar um framebuffer de rascunho

Lembre-se que a ideia é comparar a distância de um ponto a ser renderizado (visto pela câmera) com a distância de um ponto correspondente no mapa de sombras, para saber se o ponto está ou não em uma região de sombra.

O primeiro passo é criar uma imagem (textura) para receber o mapa de sombras. Vamos criar uma imagem com a mesma resolução do canvas, usando o trecho de código a seguir:

gShaderSombras.textura = crieTexturaVazia();

function crieTexturaVazia() {
    gShaderCores.textura = gl.createTexture();
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, gShaderCores.textura);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gCanvas.width, gCanvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
    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);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
};

Novamente, para carregar o mapa de sombras, precisamos “desenhar” a cena vista pela fonte de luz usando o programa gShaderSombras.program associado ao shader de sombras. Um shader “desenhar” em um framebuffer. Até agora usamos o framebuffer default do WebGL que é associado ao canvas. O WebGL permite a criação de outros framebuffers que nos permite criar e utilizar imagens de rascunho em nossos programas gráficos. Nesse caso, queremos usar essa imagem posteriormente como um mapa de sombras (textura).

O trecho de código a seguir cria um framebuffer de “rascunho” e o associa à textura criada em gShaderSombras.textura que vais receber o resultado do programa gShaderSombras.program.

function prepareBuffers () {
    // Criação de um FrameBuffer para renderizar mapa de sombras
    gShaderSombras.framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, gShaderSombras.framebuffer);
    gShaderSombras.framebuffer.width = gCanvas.width;
    gShaderSombras.framebuffer.height = gCanvas.height;

    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, gShaderSombras.textura, 0);

    // Verifica se deu certo
    var status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
    if (status != gl.FRAMEBUFFER_COMPLETE)
        alert('ERRO na criacao do Frame Buffer!?');
};

Finalmente, para fazer o WebGL desenhar nesse framebuffer, devemos usar o comando bindFramebuffer() antes de desenhar. Neste exemplo, teremos um programa (shader) para desenhar no mapa de sombras e outro que veremos mais tarde, que desenha no canvas. Por isso, antes de desenhar algo, precisamos habilitar tudo que for necessário para o desenho como:

  • habilitar (bind) o framebuffer
  • habilitar o programa
  • habilitar o VAO (vertex array object)
  • carregar os uniformes
  • desenhar
  • restaurar os valores default

Uma fez que usamos o framebuffer de rascunho, para fazer o WebGL voltar a desenhar no canvas (restaura o modo default), basta usar o comando bindFramebuffer(null). Tudo isso é ilustrado no trecho de código abaixo, que faz parte da função render() que cria o mapa de sombras:

// cria o mapa de sombras, com profundidade para a fonte de luz
gl.bindFramebuffer(gl.FRAMEBUFFER, gShaderSombras.framebuffer);
gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// vamos usar o shader de sombras
gl.useProgram(gShaderSombras.program);

// desenha alguma coisa com numVertices
// usando a transformação na matriz model
gl.bindVertexArray( gShaderSombras.VAO );
gl.uniformMatrix4fv( gShaderSombras.uModel, false, flatten(model) );
gl.drawArrays(gl.TRIANGLES, 0, numVertices);

// libera o buffers. O WebGL agora vai pintar no canvas.
gl.bindFramebuffer(gl.FRAMEBUFFER, null);

22.2. Usando o mapa de sombras como textura

Considere que agora já temos o mapa de textura em gShaderSombras.textura. Vamos usar um segundo programa gráfico que vai desenhar no canvas e usar essa textura como mapa de sombras. Para simplificar um pouco, não vamos assumir nenhum outro efeito de iluminação ou textura.

O trecho de código a seguir mostra o código fonte do vertex shader.

var gVertexShaderCoresSrc = `#version 300 es

in vec3 aPosition;
in vec4 aColor;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uPerspective;

// para usar o mapa de sombras
uniform mat4 uLightProjectionMatrix;
uniform mat4 uLightViewMatrix;
out vec4 vLightViewPosition;

out vec4 vColor;

void main()
{
    vec4 aPos = vec4(aPosition, 1);
    gl_Position = uPerspective * uView * uModel * aPos;
    vColor = aColor;

    // calcula a posição do vertice relativo a fonte de luz
    vLightViewPosition = uLightProjectionMatrix * uLightViewMatrix * uModel * aPos;
}
`;

A parte referente ao processamento dos vértices (gl_Position) e a passagem das cores ao fragmente shader é comum a vários exemplos que vimos anteriormente.

A novidade desse shader é a variante vLightViewPosition, que depende também dos uniformes uLightPositionMatriz e uLightViewMatrix. Essas matrizes transformam cada fragmento para o espaço da fonte de luz. O fragment shader pode então comparar a distância dos fragmentos com a distância armazenada no mapa de sombras. O trecho de código a seguir mostra como o fragment shader faz isso.

var gFragmentShaderCoresSrc = `#version 300 es

precision highp float;

in vec4 vColor;
in vec4 vLightViewPosition;
out vec4 corSaida;   // cor para o canvas

uniform float uDelta;
uniform sampler2D uTextureMap;

void main() {
    vec4 shadowColor = vec4(0.0, 0.0, 0.0, 1.0); // black

    // reescala profundidade de [-1, 1] para coords [0, 1] da textura
    // converte (x, y, z, w) para (x/w, y/w, z/w)
    vec3 shadowCoord = 0.5*vLightViewPosition.xyz/vLightViewPosition.w + 0.5;

    // pega a profundidade do mapa de textura
    float depth = texture(uTextureMap, shadowCoord.xy).x;

    // compara a profundidade transformada para o espaco da fonte de luz
    // usando a profundade do fragmento no espaco da fonte de luz
    corSaida = shadowColor;
    if (shadowCoord.z <= depth + uDelta) corSaida = vColor;
}
`;

Esse trecho mostra essencialmente que:

  • o fragment shader recebe as coordenadas homogêneas do fragmento que precisam ser normalizadas.
  • após a normalização, a coordenada está no intervalo padrão [-1, +1] e deve ser convertido para o intervalo [0, 1] do espaço de textura.
  • a profundidade depth no mapa da textura é determinado pelas componentes \(shadowCoord.xy\).
  • a distância à fonte de luz é determinado pela componente \(shadowCoord.z\).
  • caso essa distância for menor, o fragmento recebe a cor da variante vColor, caso contrário trata-se de um ponto de sombra.

Observe que, na última linha desse trecho de código, usamos o uniforme uDelta que é somado a profundidade do mapa. Fazemos isso para reduzir artefatos causados pela comparação desses dois valores reais (floats), que acumulam imprecisões ao longo do cálculo do mapa de profundidades. No caso de um fragmento sem oclusão, esses valores reais dificilmente serão exatamente iguais. O valor de uDelta é uma tolerância adicionada ao processo para melhorar o resultado. A escolha desse valor é arbitrária, você deve experimentar certos valores até conseguir um bom resultado.

O código completo desse exemplo está disponível no JSitor e também pode ser visto logo abaixo. O slider Offset da sombra permite variar o uniforme usado em uDelta. Veja o que acontece com a sombra quando usamos valores diferentes de uDelta.

22.3. Onde estamos e para onde vamos?

Nessa aula apresentamos uma forma de implementar a técnica de mapeamento de sombras (shadow mapping) para desenhar sombras fortes geradas por fontes de luz pontuais sobre objetos genéricos usando WebGL. Aproveitamos também para apresentar vários recursos poderosos que você pode utilizar em seus próximos programas gráficos, como o uso de framebuffers de rascunho e uso de múltiplos programas (shaders).

As sombras são efeitos de iluminação global pois consideram a relação dos objetos entre si e a fonte de luz, enquanto que em modelos de iluminação local, cada ponto considera apenas sua posição relativa a cada luz.

Nas próximas aulas vamos introduzir a técnica de ray tracing que, por ser baseada em um modelo de iluminação global, é capaz de reproduzir sombras fortes e suaves.

22.4. Para saber mais