9.6 Iluminação na prática

Nesta seção, veremos mais um aprimoramento do visualizador de modelos 3D, como uma continuação do projeto viewer2 da seção anterior (seção 9.6).

Esta será a versão 3 do visualizador (viewer3), e terá shaders extras que implementam os seguintes modelos de reflexão e sombreamento:

  • O modelo de Phong, usando sombreamento de Gouraud (shaders gouraud.vert e gouraud.frag);
  • O modelo de Phong, usando sombreamento de Phong (shaders phong.vert e phong.frag);
  • O modelo de Blinn–Phong, usando sombreamento de Phong (shaders blinnphong.vert e blinnphong.frag);

Há também algumas funcionalidades complementares, como a possibilidade de modificar em tempo real os parâmetros \(\kappa\), \(\iota\) e \(\alpha\) através de sliders do tipo ImGui::ColorEdit3, e a possibilidade de mudar a orientação da fonte de luz (uma fonte de luz direcional) usando o botão direito do mouse com o trackball virtual.

O resultado ficará como a seguir.

O código C++ é semelhante ao do projeto anterior. As poucas modificações feitas são relacionadas a conceitos que já vimos anteriormente: modificação da interface ImGui e uso do trackball virtual. A seguir, vamos nos concentrar nas modificações mais relevantes que são os novos shaders de iluminação.

Baixe o código completo deste link.

Phong com sombreamento de Gouraud

Iniciaremos com o sombreamento de Gouraud, que consiste em avaliar a equação do modelo de iluminação para cada vértice. Desse modo, praticamente todo o trabalho será feito no vertex shader.

Como o fragment shader é mais simples, começaremos por ele.

gouraud.frag

#version 410

in vec4 fragColor;
out vec4 outColor;

void main() {
  if (gl_FrontFacing) {
    outColor = fragColor;
  } else {
    float i = (fragColor.r + fragColor.g + fragColor.b) / 3.0;
    outColor = vec4(i, 0, 0, 1.0);
  }
}

Este fragment shader é parecido com o que utilizamos nos últimos projetos. O shader recebe uma cor interpolada do rasterizador (fragColor) e copia essa cor para o atributo de saída (outColor).

Na linha 7, verificamos se o fragmento pertence a um triângulo visto de frente. Se sim, a cor de saída é a própria cor de entrada. Se não, calculamos a média entre as componentes RGB (linha 10), e fazemos com que a cor de saída seja um tom de vermelho usando esse valor médio (linha 11). Tal cálculo não faz parte do modelo de Phong ou sombreamento de Gouraud. É só uma forma de distinguirmos visualmente o que é o lado da frente e o lado de trás de um triângulo.

Vamos ao que interessa, que é o shader gouraud.vert.

gouraud.vert

Para simplificar, implementaremos o modelo de reflexão de Phong para apenas uma fonte de luz direcional. Assim, a equação terá o formato mais simples

\[ \mathbf{I}=\kappa_a \iota_a + \kappa_d \iota_{d} (\hat{\mathbf{l}} \cdot \hat{\mathbf{n}}) + \kappa_s \iota_{s} (\hat{\mathbf{r}} \cdot \hat{\mathbf{v}})^\alpha. \]

O conteúdo completo do shader é listado a seguir. Vamos comentá-lo parte por parte.

#version 410

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;

// Light properties
uniform vec4 lightDirWorldSpace;
uniform vec4 Ia, Id, Is;
uniform float shininess;

// Material properties
uniform vec4 Ka, Kd, Ks;

out vec4 fragColor;

vec4 Phong(vec3 N, vec3 L, vec3 V) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    // vec3 R = normalize(2.0 * dot(N, L) * N - L);
    vec3 R = reflect(-L, N);
    V = normalize(V);
    float angle = max(dot(R, V), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 diffuseColor = Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

void main() {
  vec3 P = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  vec3 N = normalMatrix * inNormal;
  vec3 L = -(viewMatrix * lightDirWorldSpace).xyz;
  vec3 V = -P;

  fragColor = Phong(N, L, V);

  gl_Position = projMatrix * vec4(P, 1.0);
}

A entrada é um atributo de posição e um atributo de vetor normal unitário. Ambos estão em coordenadas do espaço do objeto (coordenadas do VBO):

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;

A saída é um atributo de cor:

out vec4 fragColor;

O trecho a seguir contém a definição das variáveis uniformes:

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;

// Light properties
uniform vec4 lightDirWorldSpace;
uniform vec4 Ia, Id, Is;

// Material properties
uniform vec4 Ka, Kd, Ks;
uniform float shininess;

Entre as variáveis uniformes, temos as matrizes de transformação (modelMatrix até normalMatrix), as constantes de intensidade da fonte de luz (Ia,Id,Is), as constantes de reflexão do material (Ka,Kd,Ks) e o expoente de brilho especular (shininess) que é a constante \(\alpha\) do termo especular da equação.

Como a fonte de luz é direcional, a direção da luz é dada pelo vetor lightDirWorldSpace, que está em coordenadas do espaço do mundo (isto é, não precisamos multiplicar pela matriz modelMatrix).

Vamos analisar inicialmente o código de main:

void main() {
  vec3 P = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  vec3 N = normalMatrix * inNormal;
  vec3 L = -(viewMatrix * lightDirWorldSpace).xyz;
  vec3 V = -P;

  fragColor = Phong(N, L, V);

  gl_Position = projMatrix * vec4(P, 1.0);
}

Na linha 46, calculamos P como inPosition transformado para o espaço da câmera. O sufixo .xyz significa que queremos apenas as coordenadas \(x\), \(y\) e \(z\) do vetor (no cálculo da iluminação, não utilizamos a coordenada homogênea). Esse P corresponde ao ponto \(P\) do modelo de Phong apresentado na seção 9.2.

Na linha 47, N é a normal de vértice inNormal convertida para o espaço da câmera. N corresponde ao vetor \(\mathbf{n}\) do modelo de Phong, mas sem estar normalizado.

Na linha 48, L é a direção oposta da direção da luz, convertida para o espaço da câmera. O vetor resultante é o vetor \(\mathbf{l}\) do modelo de Phong. Este vetor também não está normalizado (ainda).

Na linha 49, V é o vetor de direção até a câmera, e corresponde ao vetor

\[ \mathbf{v}=E-P \] do modelo de Phong, onde \(E\) é a posição da câmera, e \(P\) é a posição do ponto. Como \(P\) (P) está no espaço da câmera, então \(E=(0,0,0)\). Isso é assim porque, no espaço da câmera, a posição da câmera é a própria origem. Logo,

\[ \begin{align} \mathbf{v}&=\mathbf{0}-P\\ &=-P. \end{align} \]

Perceba que agora temos os vetores principais (\(\mathbf{n}\), \(\mathbf{l}\) e \(\mathbf{v}\)) necessários para avaliar a equação do modelo de reflexão de Phong. Só ficou faltando o vetor \(\mathbf{r}\) (vetor de reflexão ideal), mas este pode ser obtido a partir de \(\mathbf{n}\) e \(\mathbf{l}\).

Importante

Os vetores N, L, V, e também o ponto P, estão em um mesmo espaço, que neste caso é o espaço da câmera.

Poderíamos ter representado os pontos e vetores em outro espaço, como o espaço do objeto ou o espaço do mundo. Para o modelo de reflexão de Phong, isso não faz diferença. Entretanto, na avaliação da equação, todos os vetores da equação devem estar em um mesmo espaço.

Nossa escolha em usar o espaço da câmera é simplesmnte uma conveniência. No espaço da câmera, \(E=(0,0,0)\) e assim não precisamos enviar a posição da câmera ao vertex shader como mais uma variável uniforme.

Após a definição dos vetores, chamamos uma função Phong que recebe N, L e V, e avalia a equação do modelo de reflexão O resultado é a cor do vértice que será enviada ao rasterizador:

  fragColor = Phong(N, L, V);

Na linha 53, gl_Position é o ponto P transformado do espaço da câmera para o espaço de recorte através da matriz de projeção:

  gl_Position = projMatrix * vec4(P, 1.0);

Vamos agora à definição da função Phong:

vec4 Phong(vec3 N, vec3 L, vec3 V) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    // vec3 R = normalize(2.0 * dot(N, L) * N - L);
    vec3 R = reflect(-L, N);
    V = normalize(V);
    float angle = max(dot(R, V), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 diffuseColor = Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

A função começa com a normalização de N e L para obter \(\hat{\mathbf{n}}\) e \(\hat{\mathbf{l}}\):

  N = normalize(N);
  L = normalize(L);

Em seguida é calculado o cosseno do ângulo entre \(\hat{\mathbf{n}}\) e \(\hat{\mathbf{l}}\) (\(\hat{\mathbf{n}} \cdot \hat{\mathbf{l}}\)), que aqui chamamos de termo lambertiano:

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

Só consideramos valores no intervalo \([0,1]\). O mínimo é fixado em \(0\) através da função max(..., 0.0); o máximo é \(1\) porque os vetores são unitários.

Após o cálculo do termo lambertiano, temos o cálculo do termo especular:

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    // vec3 R = normalize(2.0 * dot(N, L) * N - L);
    vec3 R = reflect(-L, N);
    V = normalize(V);
    float angle = max(dot(R, V), 0.0);
    specular = pow(angle, shininess);
  }

Primeiro, note que só calculamos o termo especular se o termo lambertiano for positivo (linha 30). Fazemos isso pois, na equação de renderização, não existe brilho especular caso a luz não incida sobre a superfície. Em outras palavras, não existe brilho especular na sombra.

O vetor \(\hat{\mathbf{r}}\) é calculado na linha 32 usando a função reflect do GLSL. Essa função faz o mesmo que está comentado na linha 31.

O vetor \(\hat{\mathbf{v}}\) é obtido na linha 33 através da normalização de \(\mathbf{v}\).

Na linha 34 é calculado o cosseno do ângulo entre \(\hat{\mathbf{r}}\) e \(\hat{\mathbf{v}}\) (isto é, \(\hat{\mathbf{r}} \cdot \hat{\mathbf{v}}\)), e novamente só são considerados valores no intervalo \([0,1]\).

Na linha 35, o valor é elevado à constante shininess para obter o valor final \((\hat{\mathbf{r}} \cdot \hat{\mathbf{v}})^\alpha\).

No restante do código, as reflexões difusa, especular e ambiente são calculadas usando as intensidades da fonte de luz e termos de reflexão do material. O resultado é somado para obter a cor final:

  vec4 diffuseColor = Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = Ka * Ia;

  return ambientColor + diffuseColor + specularColor;

Isso conclui o modelo de reflexão de Phong com sombreamento de Gouraud.

Phong com sombreamento de Phong

phong.vert

Quando usamos sombreamento de Phong, precisamos calcular vetores \(\mathbf{v}\), \(\mathbf{n}\) e \(\mathbf{l}\) para cada fragmento, pois a função Phong que utilizamos em gouraud.vert agora será chamada no fragment shader.

No sombreamento de Phong, o vertex shader é responsável por calcular os vetores V, L e N e enviá-los ao fragment shader através de atributos de saída fragV, fragL e fragN. Assim, o atributo de saída não será mais uma cor fragColor.

O código completo é mostrado a seguir:

#version 410

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;

uniform vec4 lightDirWorldSpace;

out vec3 fragV;
out vec3 fragL;
out vec3 fragN;

void main() {
  vec3 P = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  vec3 N = normalMatrix * inNormal;
  vec3 L = -(viewMatrix * lightDirWorldSpace).xyz;

  fragL = L;
  fragV = -P;
  fragN = N;

  gl_Position = projMatrix * vec4(P, 1.0);
}

phong.frag

O código completo do fragment shader é listado abaixo. Vamos analisá-lo na sequência.

#version 410

in vec3 fragN;
in vec3 fragL;
in vec3 fragV;

// Light properties
uniform vec4 Ia, Id, Is;

// Material properties
uniform vec4 Ka, Kd, Ks;
uniform float shininess;

out vec4 outColor;

vec4 Phong(vec3 N, vec3 L, vec3 V) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    // vec3 R = normalize(2.0 * dot(N, L) * N - L);
    vec3 R = reflect(-L, N);
    V = normalize(V);
    float angle = max(dot(R, V), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 diffuseColor = Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

void main() {
  vec4 color = Phong(fragN, fragL, fragV);

  if (gl_FrontFacing) {
    outColor = color;
  } else {
    float i = (color.r + color.g + color.b) / 3.0;
    outColor = vec4(i, 0, 0, 1.0);
  }
}

Observe que os atributos de entrada do fragment shader são os atributos de saída do vertex shader:

in vec3 fragN;
in vec3 fragL;
in vec3 fragV;

As constantes utilizadas na equação são definidas como variáveis uniformes:

// Light properties
uniform vec4 Ia, Id, Is;

// Material properties
uniform vec4 Ka, Kd, Ks;
uniform float shininess;

A função Phong é exatamente a mesma que utilizamos em gouraud.vert. O resto do código é o código de main:

void main() {
  vec4 color = Phong(fragN, fragL, fragV);

  if (gl_FrontFacing) {
    outColor = color;
  } else {
    float i = (color.r + color.g + color.b) / 3.0;
    outColor = vec4(i, 0, 0, 1.0);
  }
}

Esta função main é praticamente a mesma de gouraud.frag. A diferença é que a cor do fragmento é calculada por Phong.

Blinn-Phong com sombreamento de Phong

O vertex shader do modelo de Blinn–Phong com sombreamento de Phong é exatamente o mesmo de phong.vert, pois os vetores da equação de iluminação são os mesmos.

O fragment shader também é praticamente idêntico. A única diferença é que utilizamos uma função BlinnPhong no lugar de Phong. A função é definida a seguir:

vec4 BlinnPhong(vec3 N, vec3 L, vec3 V) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    V = normalize(V);
    vec3 H = normalize(L + V);
    float angle = max(dot(H, N), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 diffuseColor = Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

O código também é muito parecido com o da função Phong. A única mudança está na forma de calcular o termo especular.

Na linha 27, o vetor halfway \(\hat{\mathbf{h}}\) (H) é computado como a normalização da soma de \(\hat{\mathbf{l}}\) (L) e \(\hat{\mathbf{v}}\) (V).

Na linha 28, calcula-se o cosseno do ângulo entre \(\hat{\mathbf{h}}\) e \(\hat{\mathbf{n}}\) (isto é, \(\hat{\mathbf{h}} \cdot \hat{\mathbf{n}}\)), e novamente consideramos apenas os valores no intervalo \([0,1]\).

O restante do código é o mesmo de Phong.