18. Iluminação e sombreamento no WebGL

Agora vamos aplicar o modelo de Phong para gerar cenas com os efeitos de iluminação que discutimos nas aulas anteriores.

Mas vamos começar com algo mais simples, fazendo os shaders calcularem apenas a componente de reflexão de difusão para uma fonte de luz pontual no infinito.

18.1. Iluminação com luz direcional

Imagine uma cena iluminada pelo Sol ao meio dia. Como o Sol está muito distante da Terra, os raios de luz que chegam aqui podem ser considerados paralelos e, por serem paralelos, podemos simplificar a equação de iluminação.

Para representar uma fonte de luz no infinito vamos considerar apenas a direção da luz por um vetor LUZ_DIR como:

const LUZ_DIR = vec4(1.0, 1.0, 1.0, 0.0);
const MAT_DIF = vec4(0.5, 1.0, 0.0, 1.0);

Vamos aproveitar e considerar um cubo de lado unitário, centrado na origem, com todas as suas faces com a mesma cor definida por MAT_DIF, ou seja, vamos assumir que essa já seja a cor refletida pela componente de difusão da luz depois de interagir com o material que compõe a superfície que é muito semelhante ao que a gente fazia nos exemplos vistos nas aulas anteriores.

O efeito da componente de difusão da iluminação depende da normal em cada ponto, que deve apontar para fora do objeto. Devemos então incluir em nosso modelo, além da posição de cada vértice, a direção da normal que pode ser feito pelo trecho de código abaixo.

const CUBO_CANTOS = [
    vec4(-0.5, -0.5,  0.5, 1.0),
    vec4(-0.5,  0.5,  0.5, 1.0),
    vec4( 0.5,  0.5,  0.5, 1.0),
    vec4( 0.5, -0.5,  0.5, 1.0),
    vec4(-0.5, -0.5, -0.5, 1.0),
    vec4(-0.5,  0.5, -0.5, 1.0),
    vec4( 0.5,  0.5, -0.5, 1.0),
    vec4( 0.5, -0.5, -0.5, 1.0)
];

/**  ................................................................
* Objeto Cubo de lado 1 centrado na origem.
*
* usa função auxiliar quad(pos, nor, vert, a, b, c, d)
*/
function Cubo() {
    this.np  = 36;  // número de posições (vértices)
    this.pos = [];  // vetor de posições
    this.nor = [];  // vetor de normais

    this.axis = EIXO_X_IND;  // usado na animação da rotação
    this.theta = vec3(0, 0, 0);  // rotação em cada eixo
    this.rodando = false;        // pausa a animação
    this.init = function () {    // carrega os buffers
        quad(this.pos, this.nor, CUBO_CANTOS, 1, 0, 3, 2);
        quad(this.pos, this.nor, CUBO_CANTOS, 2, 3, 7, 6);
        quad(this.pos, this.nor, CUBO_CANTOS, 3, 0, 4, 7);
        quad(this.pos, this.nor, CUBO_CANTOS, 6, 5, 1, 2);
        quad(this.pos, this.nor, CUBO_CANTOS, 4, 5, 6, 7);
        quad(this.pos, this.nor, CUBO_CANTOS, 5, 4, 0, 1);
    };
};

/**  ................................................................
* cria triângulos de um quad e os carrega nos arrays
* pos (posições) e nor (normais).
* @param {*} pos : array de posições a ser carregado
* @param {*} nor : array de normais a ser carregado
* @param {*} vert : array com vértices do quad
* @param {*} a : indices de vertices
* @param {*} b : em ordem anti-horária
* @param {*} c :
* @param {*} d :
*/
function quad (pos, nor, vert, a, b, c, d) {
    var t1 = subtract(vert[b], vert[a]);
    var t2 = subtract(vert[c], vert[b]);
    var normal = cross(t1, t2);
    normal = vec3(normal);

    pos.push(vert[a]);
    nor.push(normal);
    pos.push(vert[b]);
    nor.push(normal);
    pos.push(vert[c]);
    nor.push(normal);
    pos.push(vert[a]);
    nor.push(normal);
    pos.push(vert[c]);
    nor.push(normal);
    pos.push(vert[d]);
    nor.push(normal);
};

Cada uma das 6 faces do cubo é definida como uma sequência de 4 cantos do array CUBOS_CANTOS. Para que as normais apontem para fora do cubo, as sequências devem ser definidas em ordem anti-horária. A função quad() é chamada para criar 2 triângulo para cada face e carregar os arrays de posições e normais do cubo.

A função Cubo() é usada para criar um objeto Cubo que, nesse caso, ainda precisa ser inicializado por init() antes de ser utilizado, por exemplo, por meio do seguinte trecho de código:

var gCubo = new Cubo();
gCubo.init();

Observe que poderíamos ainda definir cores diferentes para cada vértice mas, para realçar o efeito de iluminação, vamos usar uma única cor MAT_DIF, uniforme para todas as faces.

18.1.1. Vertex shader

Nesse primeiro exemplo vamos fazer o vertex shader apenas transformar os vetores de luz e normal para a posição da câmera e os passe para o fragment shader para calcular a cor final.

var gVertexShaderSrc = `#version 300 es

in  vec4 aPosition;
in  vec3 aNormal;

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

uniform vec4 uLuzDir;

out vec3 vNormal;
out vec3 vLuzDir;

void main() {
    mat4 modelView = uView * uModel;
    gl_Position = uPerspective * modelView * aPosition;

    // orienta as normais como vistas pela câmera
    vNormal = mat3(modelView) * aNormal;
    vLuzDir = mat3(uView) * uLuzDir.xyz;
}
`;

Como o vetor normal é uma propriedade de cada vértice, a normal é transformada pela matriz modelView, que combina as transformações uModel e uView. Assim, se o objeto estiver rodando com relação à câmera, esses vetores também estarão rodando e vão ajudar a calcular a iluminação de acordo com essa rotação.

No entanto, observe que a direção da luz é transformada apenas segundo a matriz uView (que controla a vista da câmera). Assim, a orientação relativa entre a câmera e a direção da luz não se altera com a movimentação dos objetos (como deve ser, certo?).

A cor dos vértices poderia ser calculada no vertex shader. Mas como elas são interpoladas, o efeito da iluminação pode ficar diluído. Para obter um resultado melhor, vamos passar esses dois vetores ao fragment shader, sem normalizar. Assim evitamos também os efeitos de interpolação sobre esses vetores.

18.1.2. Fragment shader

Lembre-se que a reflexão de difusão é proporcional ao cosseno do ângulo entre o vetor normal e a direção da luz. Como a luz é direcional, essa direção é a mesma para todos os pontos. Podemos então calcular o coeficiente de reflexão de difusão kdd como o produto escalar dos vetores normal e direção da luz, após serem normalizados.

Quando o coeficiente for negativo, sabemos também que o ponto não está sendo iluminado (ou está iluminado por trás e não pela frente). Nesse exemplo, esses pontos estão sendo pintados de vermelho, para que você possa verificar esse efeito também. Na prática, essas faces vermelhas receberiam a cor da componente ambiente, como veremos mais tarde.

var gFragmentShaderSrc = `#version 300 es

precision highp float;

in vec3 vNormal;
in vec3 vLuzDir;
out vec4 corSaida;

uniform vec4 uMatDif;  // cor de difusao do material

void main() {
    vec3 normal = normalize(vNormal);
    vec3 nvl = normalize(vLuzDir);
    float kdd = dot(normal, nvl);

    corSaida = vec4(1, 0, 0, 1); // parte não iluminada
    if (kdd > 0.0) {  // parte iluminada
        corSaida = kdd * uMatDif;
    }
    corSaida.a = 1.0;
}
`;

18.1.3. Exemplo completo

O código completo desse exemplo está disponível no JSitor e também pode ser visto logo abaixo. Modifique os valores para colocar a câmera em posições distintas como eye=(2, 0, 0), eye=(2,2,0) e eye=(0,0,2) para ver o que acontece com a iluminação. Varie também a direção da luz e as cores.

18.2. Correção dos vetores normais

Um problema que pode ocorrer quando aplicamos a matriz modelView diretamente para transformar as normais é ilustrado na Figura Fig. 18.1.

Cubo em 3D iluminado usando o modelo de iluminação local de Phong.

Fig. 18.1 Observe que as normais podem ser deformadas pela transformação modelView, como ilustrado no lado esquerdo. Essa deformação pode ser corrigida usando a transposta da inversa da matriz modelView, como mostrado na animação do lado direito. Fonte: webgl2fundamentals.org.

Nessa animação, a esfera mostrada ao centro sofre uma transformação de escala não linear pela matriz modelView, mostrada do lado esquerdo como world. Observe que os vetores normais, ao serem transformados, deixam de ser perpendiculares à superfície. Ao invés de aplicar a matriz modelView, para evitar esse problema devemos usar a matriz transposta da inversa da modelView, como mostrado na animação do lado direito da figura como worldinverseTranspose.

18.2.1. Por que isso funciona?

Uma explicação pode ser derivada da definição de vetor normal.

Considere um vetor normal \(\vec{n}\) e seja \(\vec{t}\) o vetor tangente ao ponto \(P\) que estamos desenhando. Como esses vetores são perpendiculares temos que \(\vec{n} \cdot \vec{t} = 0\). Vamos considerar uma superfície plana ao redor do ponto (que pode ser tão pequena quanto você queira). Seja o ponto \(Q\) contido nesse plano, de forma que podemos calcular a tangente como \(\vec{t} = Q - P\).

Vamos agora aplicar uma transformação \(M\) sobre a superfície. O novo vetor tangente pode então ser calculado como:

\(\vec{t}' = MQ - MP = M(Q-P) = M\vec{t}\).

Considere agora a transformação \(N\) que deve ser aplicada à normal \(\vec{n}\) tal que

\((N \vec{n}) \cdot (M \vec{t}) = 0\).

Vamos escrever essa equação na forma de um produto entre um vetor linha e um vetor coluna tal que

\(transpose(N \vec{n}) \; (M \vec{t}) = transpose(\vec{n})\; transpose(N)\; M \; \vec{t} = 0\).

Observe agora que, caso \(tranpose(N) \; M = I\) (onde \(I\) é a matriz identidade) temos que \(transpose(\vec{n}) \; \vec{t} = 0\).

Agora, como exercício, mostre que quando aplicamos a transformação \(N = transpose(inverse(M))\) sobre a normal, a normal transformada é perpendicular ao vetor \(M \vec{t}\).

18.2.2. Corrigindo as normais no WebGL

O código completo desse exemplo está disponível no JSitor e também pode ser visto abaixo.

Em particular, preste atenção na matriz uInverseTranspose (passada como uniforme) no vertex shader. Essa matriz é calculada na função render().

18.3. Modelo de Phong com fonte de luz pontual

Vamos agora implementar um programa gráfico que utilizada todas as componentes de reflexão do modelo de Phong usando uma fonte de luz pontual, ao invés de uma fonte direcional (no infinito) como usada nos exemplos anteriores.

Para aplicar o modelo completo, precisamos definir as propriedades da luz e do material da superfície, para cada componente do modelo de reflexão. Nesse exemplo, vamos considerar dois objetos, LUZ e MAT com os seguintes atributos:

const LUZ = {
    pos : vec4(0.0, 3.0, 0.0, 1.0), // posição
    amb : vec4(0.2, 0.2, 0.2, 1.0), // ambiente
    dif : vec4(1.0, 1.0, 1.0, 1.0), // difusão
    esp : vec4(1.0, 1.0, 1.0, 1.0), // especular
};

const MAT = {
    amb : vec4(0.8, 0.8, 0.8, 1.0),
    dif : vec4(1.0, 0.0, 1.0, 1.0),
    alfa : 50.0,    // brilho ou shininess
};

O modelo do cubo, constituído por arrays de vértices e normais é exatamente o mesmo usado nos exemplos anteriores. Observe que cada vértice poderia receber ainda materiais diferentes. Nesse exemplo, novamente consideramos apenas um único material, para realçar os efeitos de iluminação apenas. É por essa razão também (realçar cada componente) que as cores de cada componente de reflexão do material foi escolhida para ser diferente das demais. Você pode alterar esses valores no exemplo do JSitor mais abaixo para ver o resultado.

18.3.1. Vertex shader

Vamos primeiro discutir as mudanças que precisamos fazer no código do vertex shader, fornecido abaixo.

Como a fonte de luz não está no infinito, não podemos mais considerar que todos os raios de luz são paralelos. Por isso precisamos calcular o vetor vLight que aponta para a fonte de luz a partir de cada ponto da superfície, como vistos pela câmera (mas antes da deformação perspectiva!).

Novamente, observe que cada posição pos é transformada pela matriz modelView, enquanto a posição da fonte de luz vista pela câmera sofre apenas a transformação uView. O vetor vLight é calculado pela diferença entre esses dois pontos. Devido à transformação da câmera, o vetor vView (que aponta para o observador) é simplesmente -pos, já que o observador agora é o novo “centro de coordenadas”. O vetor normal é corrigido pela matriz uInverseTranspose e todos esses vetores são passados para o fragment shader.

var gVertexShaderSrc = `#version 300 es

in  vec4 aPosition;
in  vec3 aNormal;

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

uniform vec4 uLuzPos;

out vec3 vNormal;
out vec3 vLight;
out vec3 vView;

void main() {
    mat4 modelView = uView * uModel;
    gl_Position = uPerspective * modelView * aPosition;

    // orienta as normais como vistas pela câmera
    vNormal = mat3(uInverseTranspose) * aNormal;
    vec4 pos = modelView * aPosition;

    vLight = (uView * uLuzPos - pos).xyz;
    vView = -(pos.xyz);
}
`;

18.3.2. Fragment shader

No fragment shader, exibido abaixo, os vetores calculados pelo vertex shader são normalizados. O vetor halfway é calculado em função dos vetores vLight e vView como vimos em aulas passadas.

var gFragmentShaderSrc = `#version 300 es

precision highp float;

in vec3 vNormal;
in vec3 vLight;
in vec3 vView;
out vec4 corSaida;

// cor = produto luz * material
uniform vec4 uCorAmbiente;
uniform vec4 uCorDifusao;
uniform vec4 uCorEspecular;
uniform float uAlfaEsp;

void main() {
    vec3 normalV = normalize(vNormal);
    vec3 lightV = normalize(vLight);
    vec3 viewV = normalize(vView);
    vec3 halfV = normalize(lightV + viewV);

    // difusao
    float kd = max(0.0, dot(normalV, lightV) );
    vec4 difusao = kd * uCorDifusao;

    // especular
    float ks = pow( max(0.0, dot(normalV, halfV)), uAlfaEsp);
    vec4 especular = vec4(1, 0, 0, 1); // parte não iluminada
    if (kd > 0.0) {  // parte iluminada
        especular = ks * uCorEspecular;
    }
    corSaida = difusao + especular + uCorAmbiente;
    corSaida.a = 1.0;
}
`;

Ao invés de passar as componentes de luz e material, as cores resultantes do produto LUZ * MAT são passadas como uniformes ao fragment shader. Assim, a uCorAmbiente recebe o produto (elemento a elemento) da LUZ.amb por MAT.amb e, da mesma forma, a CPU calcula e passa para a GPU a uCorDifusao. A uCorEspecular só depende da fonte de luz. O fragment shader recebe ainda o parâmetro de brilho especular (uAlfaEsp) e calcula as componentes de difusão e especular aplicando as equações de iluminação do modelo de Phong. Essas componentes são somadas à uCorAmbiente para compor a cor final.

18.3.3. Exemplo completo

O código completo desse exemplo está disponível no JSitor e também pode ser visto abaixo.

A interface desse exemplo permite modificar o parâmetro de brilho especular. Para valores grandes, observe que é possível observar uma “bola branca” (região brilhante) que diminui de tamanho com o aumento do valor de alfaEsp. Para valores menores, esse brilho fica cada vez mais esparramado pela superfície. Note também que o brilho depende muito da orientação relativa da superfície, observador e fonte de luz.

Nesse exemplo, as faces não diretamente iluminadas pela fonte de luz ainda são pintadas de vermelho. Modifique esse exemplo para que calcular as cores corretamente segundo o modelo de Phong.

18.4. Onde estamos e para onde vamos?

O processo para geração de imagens tridimensionais usados pelo WebGL é baseado no modelo de câmera sintética. Vimos como posicionar uma câmera virtual usando a função lookAt(eye, at, up) e como configurar suas propriedades de projeção perspectiva com profundidade usando a função perspective(fovy, aspect, near, far). A aplicação dessas funções sobre um modelo de objeto constituído por vértices em 3D resulta em vértices como vistos pela câmera, dentro de um volume canônico de visualização. A adoção desse volume permite que o recorte das partes não visíveis seja feito de forma bastante eficaz. A profundidade dos fragmentos restantes, dentro do volume, é testada para renderizar apenas os pontos mais próximos ao observador.

Depois disso, para aumentar o realismo das imagens geradas por essa câmera sintética, consideramos os efeitos que uma fonte de luz pontual pode criar na imagem aplicando o modelo de reflexão de Phong. Esse é um modelo de iluminação local, que considera apenas as interações da luz (de uma fonte pontual) diretamente com cada elemento da superfície de um objeto (ou seja, não testa oclusão), por meio de 4 componentes: emissão, ambiente, difusa e especular.

Vimos nessa aula que o modelo de Phong é computacionalmente eficiente e, embora o modelo não necessariamente retrate o comportamento real da luz, ele permite a renderização de imagens com efeitos de iluminação bem realistas em tempo real usando WebGL. Além de não tratar sombras (por se tratar de um modelo local), outra limitação dessa técnica é que ela considera superfícies lisas, que modelam bem superfícies metálicas por exemplo. A partir da próxima aula, veremos como tratar de superfícies com texturas.

18.5. Para saber mais