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
egouraud.frag
); - O modelo de Phong, usando sombreamento de Phong (shaders
phong.vert
ephong.frag
); - O modelo de Blinn–Phong, usando sombreamento de Phong (shaders
blinnphong.vert
eblinnphong.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 300 es
precision mediump float;
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 9, verificamos se o fragmento pertence a um triângulo visto de frente. Em caso positivo, a cor de saída é a própria cor de entrada. Caso contrário, calculamos a média entre as componentes RGB (linha 12), e fazemos com que a cor de saída seja um tom de vermelho usando esse valor médio (linha 13). Essa é só uma forma de distinguirmos visualmente o que é o lado da frente e o lado de trás de um triângulo, como fizemos em shaders anteriores.
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 300 es
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;
// Material properties
uniform vec4 Ka, Kd, Ks;
uniform float shininess;
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):
A saída é um atributo de cor:
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}\).
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 é simplesmente 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:
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:
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}}\):
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:
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 300 es
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 300 es
precision mediump float;
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:
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 29, 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 30, 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
.