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.

Melhoramento da percepção de detalhes usando mapeamento de normais.

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.

Mapa de textura difusa e mapa de normais.

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.

Espaço tangente de um triângulo.

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\).

Espaço tangente em relação ao espaço da textura.

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.

Observação

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.

Mapa de deslocamento.

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).

Mapeamento de um triângulo no espaço tangente, e geometria do cálculo do vetor tangente e bitangente.

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.

Observação

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.

Ortogonalização do vetor tangente.

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:

  abcg::glActiveTexture(GL_TEXTURE0);
  abcg::glBindTexture(GL_TEXTURE_2D, m_diffuseTexture);

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.

Observação

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, 
              TEye.y, BEye.y, NEye.y, 
              TEye.z, BEye.z, NEye.z);
}

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}}\).

Importante

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, 
     TEye.y, BEye.y, NEye.y, 
     TEye.z, BEye.z, NEye.z);

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;
NTan = normalize(NTan * 2.0 - 1.0);  // From [0, 1] to [-1, 1]

Agora, basta chamarmos BlinnPhong com os vetores calculados:

vec4 color = BlinnPhong(NTan, LTan, VTan, fragTexCoord);
Observação

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 e PlanarMappingZ 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\).

Referências

———. 1978. “Simulation of Wrinkled Surfaces.” SIGGRAPH Comput. Graph. 12 (3): 286–92.