Vitor Pamplona

Innovation on Vision: Imaging , Enhancement and Simulation

Shadow Mapping sem Shaders

 Sombra é um dos mais importantes efeitos visuais para a computação gráfica. Não pelo fotorrealismo, mas pela sensação de profundidade e perspectiva que o efeito ajuda a enaltecer. É através da sombra que sabemos se um objeto está flutuando ou sobre uma superfície, se está próximo ou distante do observador.

Apesar de ser importante, o pipeline de rendering tradicional, implementado nas placas gráficas e suas APIs, não desenha sombras. O programador deve criar sua própria implementação seguindo um dos muitos algoritmos existentes na literatura.

O tópico deste post é o algoritmo de Shadow Mapping, uma técnica bem simples e eficiente que adiciona sombras em tempo real a uma cena de computação gráfica. A técnica foi publicada em 1978 por Lance Williams em um artigo intitulado Casting curved shadows on curved surfaces. Desde então é utilizada em rendering off-line e real-time, nas mais variadas versões e tecnologias de hardware.

Este post cobre uma implementação do Shadow Mapping feita sem shaders. Esta é a mais simples de todas e não deve ser a mais rápida.

Visão Conceitual

Podemos pensar em sombras como um conjunto de pontos, alguns claros e outros escuros, em sombra. Pontos claros ou iluminados são aqueles que são atingidos pelos raios de luz que se propagaram em várias direções, sempre em linha reta, a partir da fonte de luz. Os pontos escuros, não iluminados, ou em sombra, são aqueles que estão atrás dos objetos iluminados, atrás da fonte de luz ou distantes o suficiente para receberem alguma energia, sempre considerando a fonte de luz como referência.

Se um observador virtual estiver na posição da luz, ele verá todos os objetos iluminados. Não conseguirá ver os que estão na sua retaguarda, nem os muito distantes. A técnica de Shadow Mapping simula este raciocínio de observador virtual para identificar os pontos iluminados pela luz. Armazena-se o que este observador vê em uma primeira etapa, e consulta-se a informação armazenada no momento de desenhar os objetos. O algoritmo, portanto, assume que a fonte de luz é um spot com o ângulo de abertura igual ao ângulo de visão deste observador virtual. A luz não é nem pontual, nem direcional.

A segunda figura a direita mostra o resultado do algoritmo aplicado a cena da primeira imagem. O Shadow Mapping possui uma característica marcante que é a de não desenhar sombras suaves. Quando um objeto está muito longe da sua sombra, é normal existir uma região de penumbra, uma transição entre a região em sombra e a região iluminada. Este comportamento não faz parte do nosso algoritmo.  

Visão Técnica

O Shadow Mapping consiste em calcular, para cada ponto que será projetado na imagem final, a sua distância à luz e compará-la com a distância do objeto mais próximo da luz na direção ponto-luz. É necessário ter uma lista de pontos tridimensionais próximos a luz para que suas distâncias possam ser comparadas com todos os outros pontos da cena. Por este motivo, a técnica se divide em dois passos: (i) computar os objetos mais próximos da luz e armazenar o resultado; e (ii) comparar as distâncias relativas à luz, dos objetos visíveis ao observador, no momento do rendering. Estes dois passos são implementados em duas passadas de rendering, o que significa que a cena será desenhada duas vezes:

  • Na primeira, configura-se uma câmera da OpenGL na posição da luz e, ao invés de capturar uma imagem da cena, captura-se um mapa de profundidade da mesma (Depth Buffer). O resultado é uma imagem em escala de cinza onde cada pixel representa a distância do objeto mais próximo à luz naquela direção de projeção. O mecanismo para criar o mapa de profundidade está implementado em todas as placas gráficas, com um objetivo diferente, mas é possível utilizá-lo para gerar as sombras.  

  • Na segunda passada, submete-se o mapa de profundidade como uma textura da cena e as matrizes de transformação para o sistema de coordenadas da luz, e calcula-se o rendering. Para cada ponto 3D que será projetado na tela, aplica-se uma transformação para o sistema de coordenadas da luz utilizando as matrizes. De posse das coordenadas resultantes, calcula-se a distância do ponto a luz. Consulta-se o mapa de profundidades e se o valor retornado for menor que a distância do ponto, o ponto em questão será projetado está atrás de algum objeto, logo está em sombra e deve emitir a cor preta.

Implementação

Devido as tecnologias existentes, há várias maneiras de implementar este algoritmo. Atualmente, as mais conhecidas utilizam Fragment Shaders, funções que são executadas dentro da placa gráfica a cada pixel desenhado. A solução que vou mostrar neste post é uma versão histórica, utiliza apenas os comandos padrões da OpenGL e características específicas do hardware gráfico tradicional. Não necessita de hardware programável e é estável em muitas placas antigas.

O buffer com os objetos mais próximos da luz é computado através do Depth Buffer tradicional das placas gráficas produzido com uma câmera na posição da luz. Nesta etapa, a cena inteira é desenhada apenas para capturar o mapa de profundidade, portanto os algoritmos de iluminação e demais efeitos de luz podem ser desabilitados.

Ao código

Imaginem um programa X possua duas classes: Scene e Camera. A classe Scene é composta por um método draw que é o responsável por desenhar a cena usando as funções da OpenGL. A classe Camera possui um método setup cujo objetivo é configurar a câmera da OpenGL ( gluPerspective e glViewport ). Considerando uma instância para cada classe, a função de display, repaint ou desenho deste programa X seria algo como:

Camera camera;
Scene scene;

void display() {
    // Limpa os Buffers
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    // Configura a câmera
    camera.setup(SCREEN_SIZE, SCREEN_SIZE);
    // Desenha a cena
    scene.draw();
}

Para adicionar sombras, vamos criar uma classe ShadowMapping (que pode ser baixada clicando neste link) e modificar esta função para algo como:

ShadowMapping shadowRenderer;
Camera camera;
Scene scene;
Light light;

void display() {
    // Limpa os Buffers
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // Configura a câmera da opengl na posição da luz
    setupCameraIn(light);

    // Primeira passada: Captura o mapa de profundidades
    shadowRenderer.enableDepthCapture();
    scene.draw();
    shadowRenderer.disableDepthCapture();

    // Configura a câmera normal
    camera.setup();

    // Segunda passada: Desenha a cena testando os pixels em sombra
    shadowRenderer.enableShadowTest();
    scene.draw()
    shadowRenderer.disableShadowTest();
}

Vejam que a nossa classe ShadowMapping é facilmente acoplável a qualquer programa, pois somente a função de display muda. Ela possui quatro métodos públicos que habilitam e desabilitam a captura do mapa de profundidades e o teste de sombra.

A função setupCameraIn é a função que coloca uma câmera da OpenGL na posição da luz, deve ser uma função semelhante a função setup da classe Câmera:

// Prepara a câmera.
void setupCameraIn(Light light) {
//Use viewport the same size as the shadow map
glMatrixMode(GL_PROJECTION);
glLoadIdentity();

// fovy, aspect, near, far.
gluPerspective(60, 1, 85, 400);

glMatrixMode(GL_MODELVIEW);
glLoadIdentity();

gluLookAt(light.x, light.y, light.z, // posição
0, 0, 0, // olhando para...
0, 1, 0); // up vector.
}

Se preferir, você pode implementar esta função dentro da classe Light. Ou extender a classe Light da classe Camera e reutilizar a implementação de setup.

Primeira passada

Recapitulando: A primeira passada tem a função de capturar o mapa de profundidade da cena. Precisa ser chamada cada vez que a cena ou a iluminação for alterada. Para isso, assumindo que a câmera da OpenGL já está devidamente configurada na posição da luz, deve-se:

  • Criar um repositório na placa gráfica onde será salvo o Depth Buffer;
  • Configurar a Viewport da OpenGL do mesmo tamanho que o depth buffer;
  • Armazenar a matriz de transformação do sistema de coordenadas da luz para uso no rendering;
  • Habilitar um deslocamento espacial para evitar erros numéricos.
  • Desabilitar os efeitos de luz para performance.

Para criar um repositório para o Depth Buffer, criamos a função privada createDepthTexture na classe ShadowMapping. Esta função deve ser executada apenas uma vez, na primeira renderização.

void createDepthTexture() {
// Cria a textura na placa gráfica
glGenTextures(1, &shadowMapTexture);
glBindTexture(GL_TEXTURE_2D, shadowMapTexture);

// Configura filtragem linear e warping
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, shadowMapSize, shadowMapSize,
0, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE, NULL);

// Habilita textura de comparação
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE)
//Envia 1 (ponto luminado) se r (distância do ponto a ser desenhado) <= valor armazenado na textura
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
//Gera um valor de intensidade como resultado que será replicado nos 4 canais.
glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_INTENSITY);
}

Os 7 primeiros comandos da função anterior são os comandos tradicionais para criar uma textura na OpenGL. Neste caso a nossa textura terá shadowMapSize x shadowMapSize de tamanho e manterá o componente de profundidade ( DEPTH_COMPONENT ) em um único byte. Repare que além do mapeamento linear, estamos usando a CLAMP_TO_EDGE que evita a criação de bordas na textura. Como os centros dos textels são os 0.5s em OpenGL, um clamp em [0,1] causará um filtro bilinear entre as bordas da textura e as cores de borda configurado na OpenGL. O CLAMP_TO_EDGE então, trabalha no intervalo [0.5, 0.95].

As 3 últimas chamadas de função configuram a textura de profundidade para ser utilizada como comparação. O GL_TEXTURE_COMPARE_MODE só funciona com texturas de profundidade (GL_COMPARE_R_TO_TEXTURE). A função de comparação ( GL_TEXTURE_COMPARE_FUNC ) diz para a OpenGL gerar um valor 1 quando R (a distância calculado em cada ponto na segunda passada), for menor ou igual ao valor armazenado na textura. R faz parte dos canais de textura (S, T, R, Q) de forma semelhante aos canais X, Y, Z e W das coordenadas. Este resultado é transformado em GL_LUMINANCE , GL_INTENSITY ou GL_ALPHA , de acordo com o configurado em GL_DEPTH_TEXTURE_MODE . Acho que esta comparação ficará mais clara na segunda passada.

Os quatro itens restantes da nossa lista são executados a cada vez que a cena é alterada ou a cada vez que a luz se move. A função enableDepthCapture da classe ShadowMapping é:  

void enableDepthCapture() {
// Protege o código anterior a esta função
glPushAttrib(GL_ENABLE_BIT | GL_TEXTURE_BIT | GL_LIGHTING_BIT | GL_VIEWPORT_BIT | GL_COLOR_BUFFER_BIT);

// Se a textura ainda não tiver sido criada, crie
if (!shadowMapTexture) createDepthTexture();

// Seta a viewport com o mesmo tamanho da textura.
// O tamanho da viewport não pode ser maior que o tamanho da tela.
// SE for, deve-se usar offline rendering e FBOs.
glViewport(0, 0, shadowMapSize, shadowMapSize);

// Calcula a transformação do espaço de câmera para o espaço da luz
// e armazena a transformação para ser utilizada no teste de sombra do rendering
loadTextureTransform();

// Habilita Offset para evitar flickering.
// Desloca o mapa de altura 1.9 vezes + 4.00 para trás.
glPolygonOffset(1.9, 4.00);
glEnable(GL_POLYGON_OFFSET_FILL);

// Flat shading for speed
glShadeModel(GL_FLAT);
// Disable Lighting for performance.
glDisable(GL_LIGHTING);
// Não escreve no buffer de cor, apenas no depth
glColorMask(0, 0, 0, 0);
}

Repare que esta função cria a textura, caso ela não esteja inicializada, altera a viewport para a nova textura, carrega as matrizes de transformação e desloca os objetos para evitar um flickering na sombra. É importante salientar que o tamanho da Viewport deve ser o mesmo da textura usada como mapa de profundidade. Se elas não forem iguais, dados inválidos no mapa de profundidade poderão colocar em sombra alguns objetos iluminados.  

O glPolygonOffset é uma função bem interessante da OpenGL. A placa gráfica é bem instável em relação a precisão numérica. Como estamos usando um teste & lt; = (menor e IGUAL) com valores de ponto flutuante, é bem possível que a placa tenha problemas de precisão, fazendo com que alguns pontos que deveriam estar iluminados fiquem em sobra por uma diferença de 0.00000000001 nesta igualdade. A função glPolygonOffset foi criada para solucionar este problema em qualquer algoritmo que use o Depth Buffer. Seu propósito é deslocar em um delta todos os valores de profundidade, imediatamente antes que estes sejam gravados no buffer. Mais informações sobre esta função aqui.

A função loadTextureTransform, invocada dentro da enableDepthCapture, tem   objetivo de capturar as matrizes de transformação para o sistema de coordenadas da Luz e prepará-las para uso na segunda passada. Lembrem-se que na segunda passada temos que comparar duas distâncias relativas a luz, logo essa matriz de transformação é necessária.    

void loadTextureTransform() {
GLfloat lightProjectionMatrix[16];
GLfloat lightViewMatrix[16];

// Busca as matrizes de view e projection da luz
glGetFloatv(GL_PROJECTION_MATRIX, lightProjectionMatrix);
glGetFloatv(GL_MODELVIEW_MATRIX, lightViewMatrix);

// Salva o estado da matrix mode.
glPushAttrib(GL_TRANSFORM_BIT);
glMatrixMode(GL_TEXTURE);
glPushMatrix();

//Calculate texture matrix for projection
//This matrix takes us from eye space to the light's clip space
//It is postmultiplied by the inverse of the current view matrix when specifying texgen
GLfloat biasMatrix[16]= {0.5f, 0.0f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f,
0.0f, 0.0f, 0.5f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f}; //bias from [-1, 1] to [0, 1]

GLfloat textureMatrix[16];

// Aplica as 3 matrizes em uma só, levando um fragmento em 3D para o espaço
// canônico da câmera.
glLoadMatrixf(biasMatrix);
glMultMatrixf(lightProjectionMatrix);
glMultMatrixf(lightViewMatrix);
glGetFloatv(GL_TEXTURE_MATRIX, textureMatrix);

// Separa as colunas em arrays diferentes por causa da opengl
for (int i=0; i<4; i++) {
textureTrasnformS[i] = textureMatrix[i*4];
textureTrasnformT[i] = textureMatrix[i*4+1];
textureTrasnformR[i] = textureMatrix[i*4+2];
textureTrasnformQ[i] = textureMatrix[i*4+3];
}

glPopMatrix();
glPopAttrib();
}

Toda a função faz o procedimento normal para capturar matrizes do contexto da OpenGL e multiplicá-las. A excessão é a matriz biasMatrix que é apenas uma matriz que transformará as coordenadas aplicadas a ela, do cubo canônico [- 1,1] para um cubo entre [0,1] nos três eixos. Ao multiplicar as três matrizes, a matriz resultante é aquela que usaremos na segunda passada para transformar os pontos 3D em coodenadas da camera e compará-las com o valor armazenado no DepthBuffer. Nas últimas linhas apenas desmembramos a matriz para passá-la para a OpenGL no segundo passo. Esse desmembramento é necessário pois é assim que a OpenGL trabalha.

A função disableDepthCapture retorna o estado da OpenGL para o anterior a habilitação e copia o DepthBuffer para a textura que já foi criada.

void disableDepthCapture () {
// Copia o Depth buffer para a textura.
glBindTexture(GL_TEXTURE_2D, shadowMapTexture);
// SubTexture não realoca a textura toda, como faz o glCopyTexImage2D
glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, shadowMapSize, shadowMapSize);

// Limpa o Buffer de profundidade
glClear(GL_DEPTH_BUFFER_BIT);
// Retorna as configurações anteriores ao depthCapture
glPopAttrib();
}

A função glCopyTexSubImage2D copiará o atual Depth Buffer para a nossa textura de comparação previamente criada. Repare que nós estamos usando o Depth Buffer da aplicação, o que significa que o tamanho da textura de comparação não deve ser maior do que o tamanho da janela OpenGL criado no contexto atual.

Segunda Passada

Para a segunda passada são necessárias duas funções: enableShadowTest e disable Shadow Test . Entre elas será executada a função de desenhar novamente. A função enableShadowTest é responsável por ativar a geração automática e linear das coordenadas de textura utilizadas para consultar a textura de profundidades e por repassar a matriz de transformação desmembrada para a OpenGL.  

void enableShadowTest() {
// Protege o código anterior a esta função
glPushAttrib(GL_TEXTURE_BIT | GL_ENABLE_BIT);

// Habilita a geração automática de coordenadas de textura do ponto de vista da câmera
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);

// Aplica a transformação destas coordenadas para o espaço da luz
glTexGenfv(GL_S, GL_EYE_PLANE, textureTrasnformS);
glTexGenfv(GL_T, GL_EYE_PLANE, textureTrasnformT);
glTexGenfv(GL_R, GL_EYE_PLANE, textureTrasnformR);
glTexGenfv(GL_Q, GL_EYE_PLANE, textureTrasnformQ);

// Ativa
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_GEN_R);
glEnable(GL_TEXTURE_GEN_Q);

//Bind & enable shadow map texture
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, shadowMapTexture);
}

Repare a matriz de transformação foi enviada dividida em colunas. Este é o método de trabalho da OpenGL.

Lembra-se do nosso teste de R & lt; = o valor da textura? Este código diz que as coordenadas de cada ponto da cena serão multiplicados pela matriz passada nas textureTransform * e o resultado salvo nas coordenadas de textura (S, T, R, Q). Após a transformação, o canal R nada mais será do que o valor do eixo Z com uma câmera na posição da luz. Z é a distância do ponto a luz e será salvo em R, por isso que lá no início habilitamos o teste de R.  

A função disableShadowTest, apenas retorna o estado anterior da OpenGL para que o usuário continue trabalhando sem problemas.  

void disableShadowTest() {
// Retorna as configurações anteriores do programa
glPopAttrib();
}

Demonstração

Baixe aqui o fonte de um aplicativo demonstração onde as classes Scene, Camera e ShadowMapping estão implementadas de uma maneira bem simples de entender. Coloquei todas elas em um único arquivo tornar mais fácil a compilação, mas o ideal é separá-las em arquivos diferentes.  

Compile com a seguinte linha de comando:  

g++ -lglut -lGLU -lGL -lpthread shadow_scene_total.cpp

O resultado deve ser algo como a imagem abaixo, onde a esfera branca que representa a luz gira em torno do Torus.


Considerações Finais

Como o algoritmo é calculado no espaço de imagem ele torna-se independente da complexidade da Cena, no entanto, deve tratar os casos de aliasing. O aliasing é diretamente proporcional a distância da luz à cena, a configuração de NEAR e FAR da câmera na posição da luz e a resolução do mapa de profundidades.

Em cenas diferentes da cena do aplicativo demonstração, os parâmetros para a função glPolygonOffset podem ser diferentes. Portanto, se ocorrer flickering ou sombras maiores do que deveriam ser, convém testar alterar aqueles valores.  

A versão atual não trabalha com múltiplas fontes de luz. Não há como trabalhar com mais de uma fonte de luz sem a utilização de shaders.  

Por comportamento padrão da OpenGL, o tamanho da textura de profundidade não poderá ser maior que a janela que foi criada no contexto de rendering atual. Se você precisar de mais detalhes no mapa de profundidades, terá que substituir o Depth Buffer por um Framebuffer Object.  

Lembre-se que:

  • A primeira passada só precisa ser realizada quando a cena ou a iluminação forem alteradas.
  • O algoritmo assume que a fonte de luz é um spot com o ângulo de abertura igual ao ângulo de visão deste observador virtual
  • Near, far, tamanho do mapa de profundidade e Offset não devem ser escolhidos aleatoriamente.
  • Não há sombras suaves. A área de penumbra não é criada no Shadow Mapping.
  • A performance do algoritmo não depende da complexidade da cena logo, pode-se abusar a vontade.
Espero que tenham gostado do tutorial, até a próxima.

Posted in Oct 25, 2009 by Vitor Pamplona - Edit - History

Showing Comments

Ao baixar o fonte e testar, o compilador indica que GL_TEXTURE_COMPARE_MODE não existe.
Retirando as linhas que usam ela, o exemplo funciona, porém a sombra não é gerada corretamente. Excelente tutorial. Parabéns.

- - bdjogos.com

- - Posted in Mar 5, 2010 by 201.86.82.74

bbk

- - desadoc

- - Posted in Apr 27, 2012 by 143.54.13.142

Add New Comment

Your Name:


Write the code showed above on the text below.