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