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\) do espaço tangente 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}}\)). Os vetores tangente e bitangente são calculados de forma a ficarem alinhados, respectivamente, às direções das coordenadas \(u\) e \(v\) da textura de normais.
Figura 10.21: Espaço tangente de um triângulo.
Se a textura de normais for mapeada para um quadrilátero como mostra a figura 10.22, o vetor tangente apontará na direção do eixo \(u\), e o vetor bitangente apontará na direção do eixo \(v\).
Figura 10.22: Espaço tangente em relação ao espaço da textura.
Os texels da textura de normais representam direções do vetor normal no espaço tangente. Assim, a textura pode ser usada para alterar localmente o valor do vetor normal após a rasterização. Essas alterações locais do vetor normal são mais pronunciadas 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)\) em relação ao espaço tangente, que é o vetor normal do triângulo em relação ao espaço do mundo. 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)\) no espaço tangente. 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)\) no espaço tangente.
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.
Uma forma alternativa e mais antiga de aumentar o detalhe de superfícies é o bump mapping (Blinn 1978). A técnica de bump mapping utiliza 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 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, mas apenas os vetores normais utilizados no modelo de reflexão. Mapeamento de normais é simplemente uma variação do bump mapping na qual 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).
O mapa de deslocamento também pode ser empregado na técnica de displacement mapping. Nessa técnica, a malha de triângulos é refinada de tal modo que cada texel do mapa de deslocamento corresponda a um vértice da malha. Cada vértice é então deslocado ao longo de seu vetor normal usando o valor do texel como magnitude do deslocamento. Ao contrário do mapeamento de normais e do bump mapping, displacement mapping altera a geometria do objeto. Nas GPUs atuais, o refinamento da malha pode ser feito nos estágios de tesselação, a partir do VBO original (não refinado). Ainda assim, displacement mapping é um processo computacionalmente intensivo pois aumenta o número de primitivas que devem ser rasterizadas.
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 nova 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, 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 no espaço da câmera
\[ \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}}\). Em seguida, 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 tangente (direita).
Figura 10.24: Mapeamento de um triângulo no espaço tangente, 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. \]
Primeiro calculamos as diferenças entre as coordenadas de textura dos lados \(\mathbf{e}_{1}\) e \(\mathbf{e}_{2}\):
\[ \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 tangente, os vetores tangente e bitangente são os vetores
\[ \hat{\mathbf{t}}_{\mathrm{uv}}=(1,0,0),\\ \hat{\mathbf{b}}_{\mathrm{uv}}=(0,1,0). \]
Logo, os vetores \(\mathbf{e}_{1}\) e \(\mathbf{e}_{2}\) podem ser representados no espaço tangente como uma combinação linear:
\[ \mathbf{e}_{1uv}=\Delta u_1 \hat{\mathbf{t}}_{\mathrm{uv}} + \Delta v_1 \hat{\mathbf{b}}_{\mathrm{uv}},\\ \mathbf{e}_{2uv}=\Delta u_2 \hat{\mathbf{t}}_{\mathrm{uv}} + \Delta v_2 \hat{\mathbf{b}}_{\mathrm{uv}}. \]
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 escritos 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 à esquerda 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 e ilustrado na figura 10.25.
Para fazer com que \(\hat{\mathbf{t}}\) volte a ser ortogonal a \(\hat{\mathbf{n}}\), primeiro projetamos \(\hat{\mathbf{t}}\) sobre \(\hat{\mathbf{n}}\):
\[ 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.
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 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::loadOpenGLTexture({.path = 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 Window::onPaint
, 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 Window::onPaint
teremos o seguinte trecho de código atualizado:
auto const diffuseTexLoc{abcg::glGetUniformLocation(program, "diffuseTex")};
auto const normalTexLoc{abcg::glGetUniformLocation(program, "normalTex")};
auto const mappingModeLoc{abcg::glGetUniformLocation(program, "mappingMode")};
// Set uniform variables that have the same value for every model
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
é definida 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::loadObj
, temos o seguinte código atualizado:
if (standardize) {
Model::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
é atualizada para armazenar o vetor tangente que será calculado:
struct Vertex {
glm::vec3 position{};
glm::vec3 normal{};
glm::vec2 texCoord{};
glm::vec4 tangent{};
friend bool operator==(Vertex const &, Vertex const &) = default;
};
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 no 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\) é 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 bitangents(m_vertices.size(), glm::vec3(0));
// Compute face tangents and bitangents
for (auto const offset : iter::range(0UL, m_indices.size(), 3UL)) {
// Get face indices
auto const i1{m_indices.at(offset + 0)};
auto const i2{m_indices.at(offset + 1)};
auto const i3{m_indices.at(offset + 2)};
// Get face vertices
auto &v1{m_vertices.at(i1)};
auto &v2{m_vertices.at(i2)};
auto &v3{m_vertices.at(i3)};
auto const e1{v2.position - v1.position};
auto const e2{v3.position - v1.position};
auto const delta1{v2.texCoord - v1.texCoord};
auto const 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));
auto const 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 const 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)) {
auto const &n{vertex.normal};
auto const &t{glm::vec3(vertex.tangent)};
// Orthogonalize t with respect to n
auto const tangent{t - n * glm::dot(n, t)};
vertex.tangent = glm::vec4(glm::normalize(tangent), 0);
// Compute handedness of re-orthogonalized basis
auto const b{glm::cross(n, t)};
auto const 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 53). O resultado é acumulado nos vértices da malha indexada: o vetor tangente é acumulado no atributo tangent
(linhas 85 a 87), e o vetor bitangente é acumulado em um arranjo bitangents
temporário (linhas 89 a 91), 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 94 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 99 a 100). Em seguida (linhas 103 a 105), o valor \(w\) de tangent
é calculado comparando o resultado do produto vetorial \(\hat{\mathbf{n}} \times \hat{\mathbf{t}}\) com bitangents
(vetor bitangente acumulado).
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 300 es
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\).