10.5 Mapeamento de normais
Mapeamento de normais (normal mapping) é uma técnica de melhoramento da percepção da iluminação de detalhes de uma superfície.
Assim como a textura difusa é utilizada para modificar o atributo de reflexão difusa (componente \(\kappa_d\) do modelo de reflexão) de cada ponto de uma superfície, o mapeamento de normais usa uma textura de normais (ou mapa de normais, do inglês normal map) para determinar o valor do vetor normal (vetor \(\hat{\mathbf{n}}\)) utilizado na avaliação da equação do modelo de reflexão.
Observe, na figura 10.19, como o uso de mapeamento de normais melhora a percepção de detalhes da lâmpada romana apresentada originalmente na figura 10.11.
O exemplo interativo a seguir permite habilitar e desabilitar o mapeamento de normais sobre um cubo texturizado com um padrão de tijolos:
Os mapas de textura utilizados na renderização do cubo são mostrados na figura 10.20.
Na textura de normais, cada texel corresponde às coordenadas de um vetor normal convertidas em uma cor RGB. O critério de conversão de uma tupla \((x,y,z)\) para \((r, g, b)\) é o mesmo que utilizamos no projeto viewer2
(seção 9.5) para exibir as normais como cores:
\[ r = \frac{x+1}{2}, \qquad g = \frac{y+1}{2}, \qquad b = \frac{z+1}{2}. \]
Assim, quando a textura de normais é amostrada, as coordenadas do vetor normal podem ser obtidas pelo mapeamento inverso:
\[ x = 2r-1, \qquad y = 2g-1, \qquad z = 2b-1. \]
Espaço tangente
Os vetores normais do mapa de normais estão representados em um espaço tangente ao plano sobre o qual a textura é aplicada. A figura 10.21 mostra uma ilustração dos vetores que formam a base de um espaço tangente em um triângulo. O eixo \(z\) aponta na direção do vetor normal \(\hat{\mathbf{n}}\). Isso significa que, no espaço tangente, o vetor normal do triângulo é o vetor \(\hat{\mathbf{n}}=(0,0,1)\). Os eixos \(x\) e \(y\) apontam na direção de dois vetores tangentes ao plano, chamados respectivamente de vetor tangente (\(\hat{\mathbf{t}}\)) e vetor bitangente (\(\hat{\mathbf{b}}\)). A direção do vetor tangente e bitangente sobre o plano depende da direção de crescimento das coordenadas de textura mapeadas no triângulo.
Em relação ao plano da textura, o vetor tangente aponta na direção da coordenada \(u\). O vetor bitangente aponta na direção na coordenada \(v\). O vetor normal aponta na direção perpendicular ao plano (figura 10.22).
Os texels da textura de normais representam alterações da direção do vetor normal no espaço tangente. Essas alterações serão mais evidentes quanto mais rugosa for a textura que queremos aplicar. No caso da textura de muro de tijolinhos, a cor é azulada na superfície de cada tijolo pois o vetor normal nesses pontos é próximo de \((0,0,1)\), que é o vetor normal do triângulo. A cor é ligeiramente avermelhada no lado direito dos tijolos, pois nesses pontos o vetor normal desvia para um valor mais próximo de \((1,0,0)\). De forma semelhante, a cor é esverdeada no lado de cima dos tijolos, pois nesses pontos o vetor normal está apontando em uma direção mais próxima de \((0,1,0)\).
Se o vetor normal da textura de normais for utilizado na equação do modelo de reflexão, a iluminação será calculada como se a superfície tivesse sido deformada localmente, criando a ilusão de uma superfície com mais detalhes.
O mapeamento de normais é uma forma de bump mapping (Blinn 1978). A técnica de bump mapping original usa um mapa de deslocamento (displacement map) como o mostrado na figura 10.23.
A intensidade de cada texel do mapa de deslocamento determina a altura da superfície texturizada em relação à altura da superfície sem textura. Entretanto, assim como no mapeamento de normais, bump mapping não modifica a geometria do objeto 3D; apenas os vetores normais utilizados no modelo de reflexão são modificados.
No mapeamento de normais, os vetores normais já estão pré-calculados e representados em um espaço tangente à superfície. No bump mapping original, os vetores normais são calculados no espaço do objeto a partir da variação da intensidade dos texels do mapa de deslocamento (método de diferenças finitas).
Transformação para o espaço tangente
Na avaliação da equação do modelo de reflexão, é importante que todos os vetores envolvidos estejam representados em um mesmo espaço.
Em nossos projetos até agora, avaliamos o modelo de Phong e Blinn–Phong usando os vetores \(\hat{\mathbf{n}}\) (vetor normal), \(\hat{\mathbf{l}}\) (vetor de direção à fonte de luz) e \(\hat{\mathbf{v}}\) (vetor de direção à câmera) no espaço da câmera. No mapeamento de normais, o vetor normal \(\hat{\mathbf{n}}\) amostrado da textura de normais está no espaço tangente. Para que todos os vetores estejam em um mesmo espaço, temos duas opções: ou convertemos esse vetor normal para o espaço da câmera, ou primeiro convertemos \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\) para o espaço tangente, e então calculamos a iluminação nesse novo espaço tangente. Esta última opção é a mais utilizada, pois a conversão de \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\) pode ser feita no vertex shader.
Para converter \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\) do espaço da câmera para o espaço tangente, precisamos criar uma matriz de mudança de base. Isso pode feito desde que tenhamos os vetores \(\hat{\mathbf{t}}=(t_x, t_y, t_z)\), \(\hat{\mathbf{b}}=(b_x, b_y, b_z)\) e \(\hat{\mathbf{n}}=(n_x, n_y, n_z)\) que formam uma base do espaço tangente. Suponha que temos tais vetores de base tangente, e que esses vetores estão representados em relação ao espaço do objeto (mais adiante veremos como calcular \(\hat{\mathbf{t}}\) e \(\hat{\mathbf{b}}\)). Então, a matriz
\[ \mathbf{M}_{\mathrm{tan}\rightarrow\mathrm{obj}}= \begin{bmatrix} t_x & b_x & n_x \\ t_y & b_y & n_y \\ t_z & b_z & n_z \end{bmatrix} \]
transforma vetores do espaço tangente para vetores do espaço do objeto. Para a transformação no sentido oposto, devemos calcular a inversa da matriz. Como a matriz é ortogonal, a inversa é a própria transposta. Assim,
\[ \mathbf{M}_{\mathrm{obj}\rightarrow\mathrm{tan}}= \begin{bmatrix} t_x & t_y & t_z \\ b_x & b_y & b_z \\ n_x & n_y & n_z \end{bmatrix} \]
é a matriz que transforma vetores do espaço do objeto para vetores do espaço tangente.
Na verdade, o que queremos é uma matriz que seja capaz de transformar do espaço da câmera para o espaço tangente. Para isso precisamos transformar primeiro os vetores de base (\(\hat{\mathbf{t}}\), \(\hat{\mathbf{b}}\) e \(\hat{\mathbf{n}}\)) do espaço do objeto para o espaço da câmera.
Já sabemos como transformar um vetor normal do espaço do objeto para o espaço da câmera: basta multiplicarmos a matriz \(M_{\mathrm{normal}}=(\mathbf{M_{\textrm{modelview}}}^{-1})^T\) pela normal de vértice \(\hat{\mathbf{n}}\) (atributo de entrada do vertex shader) como vimos na seção 9.5. Os vetores tangente e bitangente podem ser transformados da mesma forma. Logo, se tivermos os vetores
\[ \hat{\mathbf{n}}'=M_{\mathrm{normal}}.\hat{\mathbf{n}},\\ \hat{\mathbf{t}}'=M_{\mathrm{normal}}.\hat{\mathbf{t}},\\ \hat{\mathbf{b}}'=M_{\mathrm{normal}}.\hat{\mathbf{b}}. \]
então
\[ \mathbf{M}_{\mathrm{eye}\rightarrow\mathrm{tan}}= \begin{bmatrix} t'_x & t'_y & t'_z \\ b'_x & b'_y & b'_z \\ n'_x & n'_y & n'_z \end{bmatrix} \]
é a matriz que transforma vetores do espaço da câmera para o espaço tangente. Uma vez calculada a matriz, podemos transformar \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\). Então, podemos avaliar a equação do modelo de reflexão no espaço tangente usando \(\hat{\mathbf{n}}\) amostrado da textura de normais.
Vetor tangente e bitangente
A figura 10.24 ilustra um triângulo \(\triangle ABC\) no espaço do objeto (esquerda) e mapeado no espaço da textura (direita).
Nessa figura, \(A\), \(B\) e \(C\) são pontos no espaço do objeto, e \(\mathbf{e}_1\) e \(\mathbf{e}_2\) são vetores formados por dois lados do triângulo:
\[ \mathbf{e}_{1}=(e_{1x}, e_{1y}, e_{1z})=B-A,\\ \mathbf{e}_{2}=(e_{2x}, e_{2y}, e_{2z})=C-A. \]
As diferenças entre as coordenadas de textura dos lados \(\mathbf{e}_{1}\) e \(\mathbf{e}_{2}\) são calculadas como
\[ \Delta u_1=B_u-A_u,\qquad\Delta v_1=B_v-A_v,\\ \Delta u_2=C_u-A_u,\qquad\Delta v_2=C_v-A_v. \]
Observe que, no espaço da textura, os vetores tangente e bitangente são os vetores
\[ \hat{\mathbf{t}}_{\mathrm{tex}}=(1,0),\\ \hat{\mathbf{b}}_{\mathrm{tex}}=(0,1). \]
Logo, os vetores \(\mathbf{e}_{1}\) e \(\mathbf{e}_{2}\) mapeados no espaço da textura podem ser escritos como uma combinação linear:
\[ \mathbf{e}_{1uv}=\Delta u_1 \hat{\mathbf{t}}_{\mathrm{tex}} + \Delta v_1 \hat{\mathbf{b}}_{\mathrm{tex}},\\ \mathbf{e}_{2uv}=\Delta u_2 \hat{\mathbf{t}}_{\mathrm{tex}} + \Delta v_2 \hat{\mathbf{b}}_{\mathrm{tex}}. \]
Entretanto, precisamos calcular \(\hat{\mathbf{t}}=(t_x, t_y, t_z)\) e \(\hat{\mathbf{b}}=(b_x, b_y, b_z)\) no espaço do objeto.
No espaço do objeto, os vetores \(\mathbf{e}_{1}\) e \(\mathbf{e}_{2}\) podem ser representados como
\[ \mathbf{e}_1=\Delta u_1 \hat{\mathbf{t}} + \Delta v_1 \hat{\mathbf{b}},\\ \mathbf{e}_2=\Delta u_2 \hat{\mathbf{t}} + \Delta v_2 \hat{\mathbf{b}}, \]
que é o mesmo que
\[ (e_{1x}, e_{1y}, e_{1z})=\Delta u_1 (t_x, t_y, t_z) + \Delta v_1 (b_x, b_y, b_z),\\ (e_{2x}, e_{2y}, e_{2z})=\Delta u_2 (t_x, t_y, t_z) + \Delta v_2 (b_x, b_y, b_z). \]
Em notação matricial,
\[ \begin{bmatrix} e_{1x} & e_{1y} & e_{1z}\\ e_{2x} & e_{2y} & e_{2z} \end{bmatrix} = \begin{bmatrix} \Delta u_1 & \Delta v_1\\ \Delta u_2 & \Delta v_2 \end{bmatrix} \begin{bmatrix} t_x & t_y & t_z\\ b_x & b_y & b_z \end{bmatrix}. \]
Para determinarmos os valores de \((t_x, t_y, t_z)\) e \((b_x, b_y, b_z)\), multiplicamos os dois lados da equação pela inversa da matriz dos deltas:
\[ \begin{bmatrix} \Delta u_1 & \Delta v_1\\ \Delta u_2 & \Delta v_2 \end{bmatrix}^{-1} \begin{bmatrix} e_{1x} & e_{1y} & e_{1z}\\ e_{2x} & e_{2y} & e_{2z} \end{bmatrix} = \begin{bmatrix} t_x & t_y & t_z\\ b_x & b_y & b_z \end{bmatrix}. \]
Como a matriz dos deltas é uma matriz de ordem 2, podemos calcular explicitamente a inversa através do valor recíproco do determinante multiplicado pela matriz adjunta:
\[ \begin{bmatrix} t_x & t_y & t_z\\ b_x & b_y & b_z \end{bmatrix}= \frac{1}{\Delta u_1\Delta v_2 - \Delta u_2\Delta v_1} \begin{bmatrix} \phantom{-}\Delta v_2 & -\Delta v_1\\ -\Delta u_2 & \phantom{-}\Delta u_1 \end{bmatrix} \begin{bmatrix} e_{1x} & e_{1y} & e_{1z}\\ e_{2x} & e_{2y} & e_{2z} \end{bmatrix} . \]
Com isso obtemos \(\hat{\mathbf{t}}\) e \(\hat{\mathbf{b}}\) para cada triângulo. Entretanto, se a malha aproximar uma superfície suave, precisamos de um passo a mais para calcular vetores tangente e bitangente para cada vértice.
Para calcular vetores tangente e bitangente para cada vértice, podemos seguir a mesma estratégia que utilizamos para calcular os vetores normais em uma malha indexada. Primeiro calculamos os vetores tangente e bitangente de cada triângulo. Em seguida, acumulamos esses vetores nos vértices da malha indexada. Por fim, normalizamos os vetores acumulados. O resultado será uma média dos vetores dos triângulos adjacentes. Assim, junto com as normais de vértices, teremos também tangentes de vértices e bitangentes de vértices que podem ser armazenados como atributos dos vértices.
Ao calcular a média dos vetores tangente e bitangente, é possível que os vetores resultantes não sejam mais ortogonais entre si. Para corrigir isso podemos usar o processo de ortogonalização de Gram-Schmidt descrito a seguir.
Para fazer com que \(\hat{\mathbf{t}}\) volte a ser ortogonal a \(\hat{\mathbf{n}}\), primeiro projetamos \(\hat{\mathbf{t}}\) sobre \(\hat{\mathbf{n}}\) (acompanhe na figura 10.25):
\[ a\hat{\mathbf{n}}=(\hat{\mathbf{t}} \cdot \hat{\mathbf{n}})\hat{\mathbf{n}}. \]
Em seguida, calculamos \(\hat{\mathbf{t}}'\) como
\[ \mathbf{t}'=\hat{\mathbf{t}}-a\hat{\mathbf{n}}. \]
\(\mathbf{t}'\) é ortogonal a \(\hat{\mathbf{n}}\), mas não tem tamanho unitário. Então, como passo final, normalizamos \(\mathbf{t}'\):
\[ \hat{\mathbf{t}}'=\frac{\mathbf{t}'}{|\mathbf{t}'|}. \]
Logo, \(\hat{\mathbf{t}}'\) é o vetor \(\hat{\mathbf{t}}\) ortogonalizado.
Para fazer com que \(\hat{\mathbf{b}}\) volte a ser ortogonal a \(\hat{\mathbf{t}}\) e \(\hat{\mathbf{n}}\), basta calcular o produto vetorial \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}'\) ou \(\hat{\mathbf{t}}' \times \hat{\mathbf{n}}\). A ordem dependerá de qual vetor tem o menor ângulo quando comparado com o vetor \(\hat{\mathbf{b}}\) original:
\[ \hat{\mathbf{b}}' = \begin{cases} \hat{\mathbf{n}} \times \hat{\mathbf{t}}' &\text{se } (\hat{\mathbf{n}} \times \hat{\mathbf{t}}') \cdot \hat{\mathbf{b}} \geq 0, \\ \hat{\mathbf{t}}' \times \hat{\mathbf{n}} &\text{caso contrário}. \end{cases} \]
Mapeamento de normais na prática
Vamos agora implementar o mapeamento de normais no visualizador de modelos 3D apresentado na seção 10.4.
Esta será a versão 5 do visualizador (viewer5
) e terá os shaders normalmapping.vert
e normalmapping.frag
modificados a partir dos shaders texture.vert
e texture.frag
do projeto anterior.
Nesta nova versão, o menu “File” terá a opção de carregar uma textura difusa (“Load Diffuse Map”) e carregar uma textura de normais (“Load Normal Map”). Se o arquivo .mtl
tiver a descrição de uma textura de normais (como no arquivo roman_lamp.mtl
), a textura será carregada automaticamente.
O resultado ficará como a seguir:
Baixe o código completo deste link.
Experimente usar as texturas brick_base.jpg
(textura difusa) e brick_normal.jpg
(textura de normais) com diferentes mapeamentos (triplanar, cilíndrico e esférico) nos modelos chamferbox.obj
e teapot.obj
.
Carregando a textura de normais
No projeto anterior, utilizamos a função Model::loadDiffuseTexture
para carregar um arquivo de imagem e criar um identificador de textura difusa em uma variável m_diffuseTexture
. Agora, incluiremos a função Model::loadNormalTexture
para carregar a textura de normais em uma variável m_normalTexture
. O código é praticamente o mesmo de Model::loadDiffuseTexture
. A definição ficará como a seguir:
void Model::loadNormalTexture(std::string_view path) {
if (!std::filesystem::exists(path)) return;
abcg::glDeleteTextures(1, &m_normalTexture);
m_normalTexture = abcg::opengl::loadTexture(path);
}
Essa função é chamada em Model::loadObj
junto com o trecho de código que chama Model::loadDiffuseTexture
. A textura de normais é carregada apenas se o arquivo .mtl
fornecer um nome de textura de normais (em mat.normal_texname
ou mat.bump_texname
):
if (!mat.diffuse_texname.empty())
loadDiffuseTexture(basePath + mat.diffuse_texname);
if (!mat.normal_texname.empty()) {
loadNormalTexture(basePath + mat.normal_texname);
} else if (!mat.bump_texname.empty()) {
loadNormalTexture(basePath + mat.bump_texname);
}
Em Model::render
, precisamos habilitar a unidade de textura que utilizará a textura de normais. No projeto anterior (viewer4
), ativamos apenas a primeira unidade de textura (GL_TEXTURE0
) com a textura difusa:
Agora, ativaremos também a segunda unidade de textura (GL_TEXTURE1
), usando m_normalTexture
:
abcg::glActiveTexture(GL_TEXTURE0);
abcg::glBindTexture(GL_TEXTURE_2D, m_diffuseTexture);
abcg::glActiveTexture(GL_TEXTURE1);
abcg::glBindTexture(GL_TEXTURE_2D, m_normalTexture);
Desse modo, no fragment shader poderemos usar dois amostradores de textura definidos por variáveis uniformes. O nome dessas variáveis uniformes será diffuseTex
(como no projeto anterior) e normalTex
.
Há ainda mais um passo necessário para habilitar o uso da textura no shader. Em OpenGLWindow::paintGL
, precisamos definir o valor da variável uniforme normalTex
. Esse valor deve ser 1
pois queremos que essa variável use a unidade de textura GL_TEXTURE1
. Assim, em OpenGLWindow::paintGL
teremos o seguinte trecho de código atualizado:
const GLint diffuseTexLoc{abcg::glGetUniformLocation(program, "diffuseTex")};
const GLint normalTexLoc{abcg::glGetUniformLocation(program, "normalTex")};
const GLint mappingModeLoc{
abcg::glGetUniformLocation(program, "mappingMode")};
// Set uniform variables used by every scene object
abcg::glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE, &m_viewMatrix[0][0]);
abcg::glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE, &m_projMatrix[0][0]);
abcg::glUniform1i(diffuseTexLoc, 0);
abcg::glUniform1i(normalTexLoc, 1);
abcg::glUniform1i(mappingModeLoc, m_mappingMode);
Calculando os vetores tangente e bitangente
Na classe Model
definiremos uma função Model::computeTangents
para calcular, para cada vértice, os vetores tangente e bitangente a partir das coordenadas de textura definidas no arquivo OBJ. Se o arquivo não fornecer coordenadas de textura, Model::computeTangents
não será chamada e precisaremos calcular os vetores tangente e bitangente diretamente no shader, da mesma forma como geramos as coordenadas de textura usando o mapeamento planar, cilíndrico ou esférico.
Por enquanto, vamos considerar que o objeto tem coordenadas de textura. No final de Model::loadFromFile
, teremos a seguinte atualização de código:
if (standardize) {
this->standardize();
}
if (!m_hasNormals) {
computeNormals();
}
if (m_hasTexCoords) {
computeTangents();
}
createBuffers();
Note que Model::computeTangents
é chamada depois de Model::computeNormals
, pois os vetores normais também são necessários para o cálculo dos vetores tangente e bitangente.
Em model.hpp
, a estrutura Vertex
precisa ser atualizada para armazenar o vetor tangente que será calculado:
struct Vertex {
glm::vec3 position{};
glm::vec3 normal{};
glm::vec2 texCoord{};
glm::vec4 tangent{};
bool operator==(const Vertex& other) const noexcept {
static const auto epsilon{std::numeric_limits<float>::epsilon()};
return glm::all(glm::epsilonEqual(position, other.position, epsilon)) &&
glm::all(glm::epsilonEqual(normal, other.normal, epsilon)) &&
glm::all(glm::epsilonEqual(texCoord, other.texCoord, epsilon));
}
};
Observe que agora temos o atributo de vetor tangente (tangent
), mas não temos o atributo de vetor bitangente.
O vetor bitangente não precisa ser armazenado como um atributo de vértice, pois pode ser calculado diretamente no shader como \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}\) ou \(\hat{\mathbf{t}} \times \hat{\mathbf{n}}\) (sendo que \(\hat{\mathbf{n}}\) é normal
, e \(\hat{\mathbf{t}}\) é tangent
). Fazendo isso economizamos memória do VBO. Só precisamos saber a ordem dos operandos do produto vetorial.
Note que tangent
é um glm::vec4
em vez de glm::vec3
. A coordenada \(w\) será utilizada para armazenar um escalar que multiplica o resultado do produto vetorial de \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}\). Se \(w=1\), então o vetor bitangente será \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}\). Se \(w=-1\), o vetor bitangente será \(-(\hat{\mathbf{n}} \times \hat{\mathbf{t}})\), que é o mesmo que \(\hat{\mathbf{t}} \times \hat{\mathbf{n}}\).
Vamos à definição de Model::computeTangents
:
void Model::computeTangents() {
// Reserve space for bitangents
std::vector<glm::vec3> bitangents(m_vertices.size(), glm::vec3(0));
// Compute face tangents and bitangents
for (const auto offset : iter::range<int>(0, m_indices.size(), 3)) {
// Get face indices
const auto i1{m_indices.at(offset + 0)};
const auto i2{m_indices.at(offset + 1)};
const auto i3{m_indices.at(offset + 2)};
// Get face vertices
Vertex& v1{m_vertices.at(i1)};
Vertex& v2{m_vertices.at(i2)};
Vertex& v3{m_vertices.at(i3)};
const auto e1{v2.position - v1.position};
const auto e2{v3.position - v1.position};
const auto delta1{v2.texCoord - v1.texCoord};
const auto delta2{v3.texCoord - v1.texCoord};
glm::mat2 M;
M[0][0] = delta2.t;
M[0][1] = -delta1.t;
M[1][0] = -delta2.s;
M[1][1] = delta1.s;
M *= (1.0f / (delta1.s * delta2.t - delta2.s * delta1.t));
const auto tangent{glm::vec4(M[0][0] * e1.x + M[0][1] * e2.x,
M[0][0] * e1.y + M[0][1] * e2.y,
M[0][0] * e1.z + M[0][1] * e2.z, 0.0f)};
const auto bitangent{glm::vec3(M[1][0] * e1.x + M[1][1] * e2.x,
M[1][0] * e1.y + M[1][1] * e2.y,
M[1][0] * e1.z + M[1][1] * e2.z)};
// Accumulate on vertices
v1.tangent += tangent;
v2.tangent += tangent;
v3.tangent += tangent;
bitangents.at(i1) += bitangent;
bitangents.at(i2) += bitangent;
bitangents.at(i3) += bitangent;
}
for (auto&& [i, vertex] : iter::enumerate(m_vertices)) {
const auto& n{vertex.normal};
const auto& t{glm::vec3(vertex.tangent)};
// Orthogonalize t with respect to n
const auto tangent{t - n * glm::dot(n, t)};
vertex.tangent = glm::vec4(glm::normalize(tangent), 0);
// Compute handedness of re-orthogonalized basis
const auto b{glm::cross(n, t)};
const auto handedness{glm::dot(b, bitangents.at(i))};
vertex.tangent.w = (handedness < 0.0f) ? -1.0f : 1.0f;
}
}
Esta função adota uma estratégia parecida com aquela que utilizamos no código de Model::computeNormals
.
Primeiramente, os vetores tangente e bitangente são calculados para cada triângulo (laço da linha 61). O resultado é acumulado nos vértices da malha indexada: o vetor tangente é acumulado no atributo tangent
(linhas 93 a 95), e o vetor bitangente é acumulado em um arranjo bitangents
temporário (linhas 97 a 99), uma vez que os vértices não têm um atributo bitangent
.
Após a acumulação dos vetores tangente e bitangente nos vértices, o laço da linha 102 itera sobre os vértices e usa o método de Gram-Schmidt para ortogonalizar os vetores tangente em relação às normais de vértice (linhas 106 a 108). Em seguida (linhas 110 a 113), o valor \(w\) de tangent
é calculado comparando o resultado do produto vetorial \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}\) com bitangents
(bitangente acumulada).
Shaders
Os shaders de mapeamento de normais são adaptados de texture.vert
e texture.frag
do projeto anterior.
normalmapping.vert
O código completo do vertex shader é mostrado a seguir:
#version 410
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;
layout(location = 3) in vec4 inTangent;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform vec4 lightDirWorldSpace;
out vec2 fragTexCoord;
out vec3 fragPObj;
out vec3 fragTObj;
out vec3 fragBObj;
out vec3 fragNObj;
out vec3 fragLEye;
out vec3 fragVEye;
void main() {
vec3 PEye = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
vec3 LEye = -(viewMatrix * lightDirWorldSpace).xyz;
fragTexCoord = inTexCoord;
fragPObj = inPosition;
fragTObj = inTangent.xyz;
fragBObj = inTangent.w * cross(inNormal, inTangent.xyz);
fragNObj = inNormal;
fragLEye = LEye;
fragVEye = -PEye;
gl_Position = projMatrix * vec4(PEye, 1.0);
}
Em comparação com o código de texture.vert
, temos agora o atributo de entrada inTangent
(linha 6) e alguns novos atributos de saída:
fragTObj
é o vetor tangente no espaço do objeto.fragBObj
é o vetor bitangente no espaço do objeto.fragNObj
é o vetor normal no espaço do objeto.
Com esses atributos podemos criar a matriz de mudança de base para transformar vetores do espaço do objeto para o espaço tangente.
As variáveis fragV
e fragL
de texture.vert
foram renomeadas para fragVEye
e fragLEye
para deixar explícito que são vetores no espaço da câmera (eye space). A variável fragN
foi removida pois o vetor normal utilizado na equação do modelo de Blinn–Phong é lido diretamente da textura de normais.
Observe que não temos mais a variável uniforme normalMatrix
. Ela foi movida para o fragment shader. No fragment shader, fragTObj
, fragBObj
e fragNObj
são transformados por normalMatrix
para obter vetores no espaço da câmera. Com isso é possível construir a matriz que transforma os vetores fragLEye
e fragVEye
do espaço da câmera para o espaço tangente.
A matriz que transforma vetores do espaço da câmera para vetores do espaço tangente pode ser criada no vertex shader. Assim, podemos enviar ao fragment shader os vetores \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\) já no espaço tangente (o vetor \(\hat{\mathbf{n}}\) é obtido da textura de normais). Isso deixa o código mais eficiente, pois é mais custoso transformar os vetores para cada fragmento do que para cada vértice.
Entretanto, nesta versão do visualizador optamos por calcular a matriz no fragment shader para manter a compatibilidade com objetos que usam o mapeamento planar, cilíndrico e esférico no fragment shader. Quando usamos esses mapeamentos, precisamos calcular manualmente os vetores tangente e bitangente. Nesse caso, a matriz só pode ser construída no fragment shader.
normalmapping.frag
A maior parte do processamento do mapeamento de normais é feita no fragment shader.
Primeiramente, definimos uma função ComputeTBN
que retorna a matriz que será utilizada para transformar vetores no espaço da câmera para vetores no espaço tangente:
// Compute matrix to transform from camera space to tangent space
mat3 ComputeTBN(vec3 TObj, vec3 BObj, vec3 NObj) {
vec3 TEye = normalMatrix * normalize(TObj);
vec3 BEye = normalMatrix * normalize(BObj);
vec3 NEye = normalMatrix * normalize(NObj);
return mat3(TEye.x, BEye.x, NEye.x,
y, BEye.y, NEye.y,
TEye.z, BEye.z, NEye.z);
TEye. }
A matriz recebe vetores no espaço do objeto e transforma-os para o espaço da câmera usando normalMatrix
. O resultado é utilizado para criar a matriz \(\mathbf{M}_{\mathrm{eye}\rightarrow\mathrm{tan}}\).
Em GLSL, as matrizes são armazenadas na ordem “column-major.” Isso significa que, na matriz construída com o código a seguir, os três primeiros argumentos (TEye.x
, BEye.x
, NEye.x
) definem os elementos da primeira coluna da matriz, e não os elementos da primeira linha!
mat3(TEye.x, BEye.x, NEye.x,
y, BEye.y, NEye.y,
TEye.z, BEye.z, NEye.z); TEye.
Logo, a matriz resultante é a matriz
\[ \mathbf{M}_{\mathrm{eye}\rightarrow\mathrm{tan}}= \begin{bmatrix} t'_x & t'_y & t'_z \\ b'_x & b'_y & b'_z \\ n'_x & n'_y & n'_z \end{bmatrix}. \] onde \(\hat{\mathbf{t}}'\), \(\hat{\mathbf{b}}'\), \(\hat{\mathbf{n}}'\) são, respectivamente, os vetores tangente, bitangente e normal no espaço da câmera.
Com a função ComputeTBN
definida, podemos criar a matriz TBN
a partir dos vetores fragTObj
, fragBObj
e fragNObj
recebidos do vertex shader:
mat3 TBN = ComputeTBN(fragTObj, fragBObj, fragNObj);
Em seguida, usamos TBN
para transformar fragLEye
e fragVEye
para o espaço tangente:
vec3 LTan = TBN * normalize(fragLEye);
vec3 VTan = TBN * normalize(fragVEye);
O vetor normal no espaço tangente é lido da textura de normais usando o amostrador normalTex
:
vec3 NTan = texture(normalTex, fragTexCoord).xyz;
normalize(NTan * 2.0 - 1.0); // From [0, 1] to [-1, 1] NTan =
Agora, basta chamarmos BlinnPhong
com os vetores calculados:
vec4 color = BlinnPhong(NTan, LTan, VTan, fragTexCoord);
Se o objeto renderizado não tiver coordenadas de textura fornecidas pelo arquivo OBJ, também não terá vetores tangentes e bitangentes. Então, a estratégia anterior não poderá ser utilizada. No projeto viewer4
, deixamos a possibilidade do usuário escolher entre o mapeamento triplanar, cilíndrico ou esférico para esses objetos. Para esses casos, as coordenadas de textura foram calculadas no fragment shader pelas funções:
PlanarMappingX
,PlanarMappingY
ePlanarMappingZ
para o mapeamento triplanar;CylindricalMapping
para o mapeamento cilíndrico;SphericalMapping
para o mapeamento esférico.
Para usar mapeamento de normais, precisamos criar igualmente funções que calculem o vetor tangente e bitangente.
Em normalmapping.frag
, definiremos as seguintes funções adicionais que retornam a matriz TBN
correspondente a cada mapeamento:
mat3 PlanarMappingXTBN(vec3 P) {
vec3 T = vec3(0, 0, -1);
vec3 N = fragNObj;
vec3 B = cross(N, T);
return ComputeTBN(T, B, N);
}
mat3 PlanarMappingYTBN(vec3 P) {
vec3 T = vec3(1, 0, 0);
vec3 N = fragNObj;
vec3 B = cross(N, T);
return ComputeTBN(T, B, N);
}
mat3 PlanarMappingZTBN(vec3 P) {
vec3 T = vec3(1, 0, 0);
vec3 N = fragNObj;
vec3 B = cross(N, T);
return ComputeTBN(T, B, N);
}
mat3 CylindricalTBN(vec3 P) {
vec3 T = vec3(P.z, 0, -P.x);
vec3 N = fragNObj;
vec3 B = cross(N, T);
return ComputeTBN(T, B, N);
}
mat3 SphericalTBN(vec3 P) {
vec3 T = vec3(P.z, 0, -P.x);
vec3 N = fragNObj;
vec3 B = cross(N, T);
return ComputeTBN(T, B, N);
}
Observe como o vetor T
é construído explicitamente em cada mapeamento. Por exemplo, no mapeamento planar na direção \(y\), o vetor tangente é sempre o vetor \((1,0,0)\) (direção \(x\)). No mapeamento cilíndrico ou esférico, o vetor tangente é o vetor que tangencia o círculo no plano \(y=0\).