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.
Figura 10.19: Melhoramento da percepção de detalhes usando mapeamento de normais.
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.
Figura 10.20: Mapa de textura difusa e mapa de normais.
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.
Figura 10.21: Espaço tangente de um 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).
Figura 10.22: Espaço tangente em relação ao espaço da textura.
Os texels da textura de normais representam alterações da direção do vetor normal no espaço tangente. 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 está 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.
Figura 10.23: Mapa de deslocamento.
A intensidade de cada texel do mapa de deslocamento determina a altura da superfície 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 ao espaço da câmera, ou convertemos \(\hat{\mathbf{l}}\) e \(\hat{\mathbf{v}}\) para o espaço tangente e então calculamos a iluminação no 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 a base do espaço tangente. Suponha que temos tais vetores no 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 converte transforme 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}}\)), que estão originalmente no 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 multiplicar 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 na textura (direita).
Figura 10.24: Mapeamento de um triângulo no espaço da textura, e geometria do cálculo do vetor tangente e bitangente.
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 determinar 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 (regra de Cramer):
\[ \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 conseguimos obter \(\hat{\mathbf{t}}\) e \(\hat{\mathbf{b}}\) para cada triângulo. Entretanto, se a malha aproximar uma superfície suave, precisamos 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.
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}}\) re-ortogonalizado.
Figura 10.25: Ortogonalização do vetor tangente.
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 o método Model::loadDiffuseTexture para carregar um arquivo de imagem e criar um identificador de textura difusa em uma variável m_diffuseTexture. Agora, incluiremos o método 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;
glDeleteTextures(1, &m_normalTexture);
m_normalTexture = abcg::opengl::loadTexture(path);
}Esse método é chamado em Model::loadFromFile 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:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_diffuseTexture);
glActiveTexture(GL_TEXTURE1);
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:
GLint diffuseTexLoc{glGetUniformLocation(program, "diffuseTex")};
GLint normalTexLoc{glGetUniformLocation(program, "normalTex")};
GLint mappingModeLoc{glGetUniformLocation(program, "mappingMode")};
// Set uniform variables used by every scene object
glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE, &m_viewMatrix[0][0]);
glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE, &m_projMatrix[0][0]);
glUniform1i(diffuseTexLoc, 0);
glUniform1i(normalTexLoc, 1);
glUniform1i(mappingModeLoc, m_mappingMode);Calculando os vetores tangente e bitangente
Na classe Model, definiremos um método Model::computeTangents para calcular os vetores tangente e bitangente para cada vértice, usando as coordenadas de textura definidas no arquivo OBJ. Se o arquivo não fornecer coordenadas de textura, Model::computeTangents não será chamado 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 é chamado 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á computado:
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 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 quarta coordenada (coordenada \(w\)) será 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};
// clang-format off
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));
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)};
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)};
// clang-format on
// 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;
}
}Este método 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 69). O resultado é acumulado nos vértices da malha indexada: o vetor tangente é acumulado no atributo tangent (linhas 103 a 105), e o vetor bitangente é acumulado em um arranjo bitangents (linhas 107 a 109), 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 112 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 116 a 118). Em seguida (linhas 120 a 123), a coordenada \(w\) de tangent é calculada 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. 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, 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,
TEye.y, BEye.y, NEye.y,
TEye.z, BEye.z, NEye.z);
}A matriz recebe vetores no espaço do objeto e transforma-os ao 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!
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:
Em seguida, usamos TBN para transformar fragLEye e fragVEye para o espaço tangente:
O vetor normal no espaço tangente é lido da textura de normais, usando o amostrador normalTex:
vec3 NTan = texture(normalTex, fragTexCoord).xyz;
NTan = normalize(NTan * 2.0 - 1.0); // From [0, 1] to [-1, 1]Agora, basta chamar BlinnPhong com os vetores calculados:
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,PlanarMappingYePlanarMappingZpara o mapeamento triplanar;CylindricalMappingpara o mapeamento cilíndrico;SphericalMappingpara o mapeamento esférico.
Para usar mapeamento de normais, precisamos criar igualmente funções que calculem o vetor tangente e bitangente.
Em normalmapping.frag, são definidas 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\).
Referências
Blinn, James F. 1978. “Simulation of Wrinkled Surfaces.” SIGGRAPH Comput. Graph. 12 (3): 286–92.