9.5 Normais como cores
Nesta seção, implementaremos a segunda versão do visualizador de modelos 3D apresentado originalmente na seção 8.4.
As seguintes funcionalidade serão incorporadas:
- Cálculo de normais de vértices, usando o procedimento descrito no final da seção 6.3;
- Um novo shader que visualiza os vetores normais através de cores;
- Uma caixa de combinação (combo box) para selecionar entre o shader do projeto anterior e o novo shader.
- Um botão “Load 3D Model” para carregar arquivos OBJ durante a execução.
O resultado ficará como a seguir:
Configuração inicial
Faça uma cópia do projeto viewer1
da seção 8.4 e renomeie-o para viewer2
.
Dentro de
abcg/examples/viewer2/assets
, crie os arquivos vaziosnormal.frag
enormal.vert
. Esses serão os arquivos do novo shader de visualização de vetores normais como cores.Baixe o arquivo
imfilebrowser.h
do repositórioAirGuanZ/imgui-filebrowser
e salve-o emabcg/examples/viewer2
. Esse arquivo contém a implementação do controle “imgui-filebrowser,” que é uma caixa de diálogo de seleção de arquivos usando a interface da ImGui.
Como os demais arquivos utilizados são os mesmos do projeto anterior, vamos nos concentrar apenas nas partes que serão modificadas.
model.hpp
Modifique a estrutura Vertex
para que cada vértice tenha um atributo adicional de vetor normal glm::vec3 normal
:
struct Vertex {
glm::vec3 position{};
glm::vec3 normal{};
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));
}
};
Na implementação do operador de igualdade de Vertex
, estamos agora utilizando um método mais preciso de determinar se dois vetores são iguais.
Em vez de fazer position == other.position
e normal == other.normal
, verificamos se cada elemento de cada tupla está a uma distância que corresponde a um epsilon de um float
(diferença entre 1.0f
e o próximo valor representado por um float
).
Essa é uma forma mais robusta de comparar dois vértices, pois vértices equivalentes podem ter coordenadas ligeiramente diferentes por conta de erros de conversão de ponto flutuante durante a leitura do arquivo OBJ.
Dentro da classe Model
, incluiremos as seguintes definições:
Durante a leitura do arquivo OBJ, se o modelo já vier com vetores normais calculados, m_hasNormals
será true
. Caso contrário, será false
e então chamaremos Model::computeNormals
para calcular as normais.
model.cpp
Como modificamos a estrutura Vertex
em model.hpp
, precisamos modificar também a especialização de std::hash
para gerar um valor de hashing que leve em conta tanto a posição do vértice quanto o vetor normal. Afinal, dois vértices na mesma posição espacial são vértices diferentes caso tenham vetores normais diferentes:
// Explicit specialization of std::hash for Vertex
namespace std {
template <>
struct hash<Vertex> {
size_t operator()(Vertex const& vertex) const noexcept {
const std::size_t h1{std::hash<glm::vec3>()(vertex.position)};
const std::size_t h2{std::hash<glm::vec3>()(vertex.normal)};
return h1 ^ h2;
}
};
} // namespace std
O valor de hashing é calculado como h1 ^ h2
, onde ^
é o operador “ou exclusivo” bit a bit. Essa é uma forma simples de misturar dois valores para obter um valor de hashing.
Calculando as normais de vértices
O cálculo dos vetores normais dos vértices é feito em Model::computeNormals
:
void Model::computeNormals() {
// Clear previous vertex normals
for (auto& vertex : m_vertices) {
vertex.normal = glm::zero<glm::vec3>();
}
// Compute face normals
for (const auto offset : iter::range<int>(0, m_indices.size(), 3)) {
// Get face vertices
Vertex& a{m_vertices.at(m_indices.at(offset + 0))};
Vertex& b{m_vertices.at(m_indices.at(offset + 1))};
Vertex& c{m_vertices.at(m_indices.at(offset + 2))};
// Compute normal
const auto edge1{b.position - a.position};
const auto edge2{c.position - b.position};
const glm::vec3 normal{glm::cross(edge1, edge2)};
// Accumulate on vertices
a.normal += normal;
b.normal += normal;
c.normal += normal;
}
// Normalize
for (auto& vertex : m_vertices) {
vertex.normal = glm::normalize(vertex.normal);
}
m_hasNormals = true;
}
Esta função é chamada logo após o carregamento do modelo, isto é, quando m_vertices
e m_indices
já contêm a geometria indexada do modelo, mas antes da criação do VBO/EBO.
Antes de calcular os vetores normais, todos os vetores normais em m_vertices
são definidos como \((0,0,0)\):
// Clear previous vertex normals
for (auto& vertex : m_vertices) {
vertex.normal = glm::zero<glm::vec3>();
}
Para cada triângulo \(\triangle ABC\) da malha, o vetor normal é calculado como
\[\mathbf{n}=(B-A) \times (C-B),\]
// Compute normal
const auto edge1{b.position - a.position};
const auto edge2{c.position - b.position};
const glm::vec3 normal{glm::cross(edge1, edge2)};
O resultado é acumulado nos vértices:
Lembre-se que, como estamos usando geometria indexada, um mesmo vértice pode ser compartilhado por vários triângulos. Então, ao final do laço que itera todos os triângulos, o atributo normal
de cada vértice será a soma dos vetores normais dos triângulos que usam tal vértice. Por exemplo, se um vértice é compartilhado por 5 triângulos, então seu atributo normal
será a soma dos vetores normais desses 5 triângulos.
Para finalizar, basta normalizar o atributo normal
de cada vértice. O resultado será um vetor unitário que corresponde à média dos vetores normais dos triângulos adjacentes:
Leitura do arquivo OBJ
A função Model::loadObj
é modificada para ler vetores normais caso estejam presentes (linhas 116 a 126 do código abaixo). Se os vetores normais não são encontrados, chamamos Model::computeNormals
(linhas 148 a 150):
void Model::loadObj(std::string_view path, bool standardize) {
tinyobj::ObjReader reader;
if (!reader.ParseFromFile(path.data())) {
if (!reader.Error().empty()) {
throw abcg::Exception{abcg::Exception::Runtime(
fmt::format("Failed to load model {} ({})", path, reader.Error()))};
}
throw abcg::Exception{
abcg::Exception::Runtime(fmt::format("Failed to load model {}", path))};
}
if (!reader.Warning().empty()) {
fmt::print("Warning: {}\n", reader.Warning());
}
const auto& attrib{reader.GetAttrib()};
const auto& shapes{reader.GetShapes()};
m_vertices.clear();
m_indices.clear();
m_hasNormals = false;
// A key:value map with key=Vertex and value=index
std::unordered_map<Vertex, GLuint> hash{};
// Loop over shapes
for (const auto& shape : shapes) {
// Loop over indices
for (const auto offset : iter::range(shape.mesh.indices.size())) {
// Access to vertex
const tinyobj::index_t index{shape.mesh.indices.at(offset)};
// Vertex position
const int startIndex{3 * index.vertex_index};
const float vx{attrib.vertices.at(startIndex + 0)};
const float vy{attrib.vertices.at(startIndex + 1)};
const float vz{attrib.vertices.at(startIndex + 2)};
// Vertex normal
float nx{};
float ny{};
float nz{};
if (index.normal_index >= 0) {
m_hasNormals = true;
const int normalStartIndex{3 * index.normal_index};
nx = attrib.normals.at(normalStartIndex + 0);
ny = attrib.normals.at(normalStartIndex + 1);
nz = attrib.normals.at(normalStartIndex + 2);
}
Vertex vertex{};
vertex.position = {vx, vy, vz};
vertex.normal = {nx, ny, nz};
// If hash doesn't contain this vertex
if (hash.count(vertex) == 0) {
// Add this index (size of m_vertices)
hash[vertex] = m_vertices.size();
// Add this vertex
m_vertices.push_back(vertex);
}
m_indices.push_back(hash[vertex]);
}
}
if (standardize) {
this->standardize();
}
if (!m_hasNormals) {
computeNormals();
}
createBuffers();
}
Mapeamento do VBO com as normais
Uma vez que cada vértice tem agora dois atributos (posição e vetor normal), precisamos configurar como o VBO será mapeado para os atributos de entrada do vertex shader que chamaremos de inPosition
e inNormal
. Isso é feito em Model::setupVAO
:
void Model::setupVAO(GLuint program) {
// Release previous VAO
abcg::glDeleteVertexArrays(1, &m_VAO);
// Create VAO
abcg::glGenVertexArrays(1, &m_VAO);
abcg::glBindVertexArray(m_VAO);
// Bind EBO and VBO
abcg::glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_EBO);
abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
// Bind vertex attributes
const GLint positionAttribute{
abcg::glGetAttribLocation(program, "inPosition")};
if (positionAttribute >= 0) {
abcg::glEnableVertexAttribArray(positionAttribute);
abcg::glVertexAttribPointer(positionAttribute, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex), nullptr);
}
const GLint normalAttribute{abcg::glGetAttribLocation(program, "inNormal")};
if (normalAttribute >= 0) {
abcg::glEnableVertexAttribArray(normalAttribute);
GLsizei offset{sizeof(glm::vec3)};
abcg::glVertexAttribPointer(normalAttribute, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex),
reinterpret_cast<void*>(offset));
}
// End of binding
abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);
abcg::glBindVertexArray(0);
}
Nosso VBO usa dados intercalados no formato
\[\left[ [x\;\; y\;\; z]_1\;\; [n_x\;\; n_y\;\; n_z]_1\;\; [x\;\; y\;\; z]_2\;\; [n_x\;\; n_y\;\; n_z]_2\;\; \cdots\;\; [x\;\; y\;\; z]_m\;\; [n_x\;\; n_y\;\; n_z]_m \right],\]
onde \([x\;\; y\;\; z]_i\) e \([n_x\;\; n_y\;\; n_z]_i\) são a posição e vetor normal do \(i\)-ésimo vértice do arranjo.
Logo, o mapeamento para inNormal
precisa usar um deslocamento (offset) de sizeof(glm::vec3)
, que é o que fazemos nas linhas 191 a 194.
openglwindow.hpp
Na versão anterior deste visualizador (projeto viewer1
) só era possível usar um único programa de shader, identificado por m_program
. Em particular, esse programa de shader correspondia ao par de shaders depth.vert
e depth.frag
. Nesta aplicação, o usuário poderá escolher entre dois programas de shaders. Para permitir isso, a variável m_program
definida na classe OpenGLWindow
será substituída por um conjunto de variáveis:
std::vector<const char*> m_shaderNames{"normal", "depth"};
std::vector<GLuint> m_programs;
int m_currentProgramIndex{-1};
onde
m_shaderNames
é um arranjo de nomes dos pares de shaders contidos no subdiretórioassets
. Neste projeto usaremos os shadersnormal
edepth
. Vamos supor que cada nome corresponde a dois arquivos, um com extensão.vert
(vertex shader) e outro com extensão.frag
(fragment shader).m_programs
é um arranjo de identificadores dos programas de shader compilados, um para cada elemento dem_shaderNames
;m_currentProgramIndex
é um índice param_programs
que indica qual é o programa atualmente selecionado pelo usuário.Sempre que um novo programa for selecionado usando a caixa de combinação da ImGui,
Model::SetupVAO
será chamada para o novo programa, pois o VAO é modificado de acordo com os shaders.Por padrão, o valor de
m_currentProgramIndex
é-1
. Na primeira chamada aOpenGLWindow::paintUI
, esse valor é modificado para0
, que é o índice padrão da caixa de combinação de seleção de shaders. Como isso equivale a uma mudança de programa, a funçãoModel::SetupVAO
é chamada (poderíamos chamarModel::SetupVAO
emOpenGLWindow::initializeGL
, mas assim evitamos duplicação de código).
openglwindow.cpp
No início do arquivo precisamos incluir alguns cabeçalhos a mais:
#include <glm/gtc/matrix_inverse.hpp>
#include "imfilebrowser.h"
initializeGL
Em OpenGLWindow::initializeGL
, compilamos e ligamos todos os shaders mencionados em m_shaderNames
, supondo que o arquivo .vert
tem o mesmo nome do arquivo .frag
:
void OpenGLWindow::initializeGL() {
abcg::glClearColor(0, 0, 0, 1);
abcg::glEnable(GL_DEPTH_TEST);
// Create programs
for (const auto& name : m_shaderNames) {
const auto program{createProgramFromFile(getAssetsPath() + name + ".vert",
getAssetsPath() + name + ".frag")};
m_programs.push_back(program);
}
// Load model
m_model.loadObj(getAssetsPath() + "bunny.obj");
m_trianglesToDraw = m_model.getNumTriangles();
}
Observe que continuamos carregando bunny.obj
como modelo 3D inicial. A função Model::loadObj
será chamada novamente sempre que o usuário selecionar um novo arquivo usando o botão “Load 3D Model” que definiremos mais adiante em OpenGLWindow::paintUI
.
paintGL
A definição de OpenGLWindow::paintGL
ficará assim:
void OpenGLWindow::paintGL() {
update();
abcg::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
abcg::glViewport(0, 0, m_viewportWidth, m_viewportHeight);
// Use currently selected program
const auto program{m_programs.at(m_currentProgramIndex)};
abcg::glUseProgram(program);
// Get location of uniform variables
const GLint viewMatrixLoc{abcg::glGetUniformLocation(program, "viewMatrix")};
const GLint projMatrixLoc{abcg::glGetUniformLocation(program, "projMatrix")};
const GLint modelMatrixLoc{
abcg::glGetUniformLocation(program, "modelMatrix")};
const GLint normalMatrixLoc{
abcg::glGetUniformLocation(program, "normalMatrix")};
// Set uniform variables used by every scene object
abcg::glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE, &m_viewMatrix[0][0]);
abcg::glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE, &m_projMatrix[0][0]);
// Set uniform variables of the current object
abcg::glUniformMatrix4fv(modelMatrixLoc, 1, GL_FALSE, &m_modelMatrix[0][0]);
const auto modelViewMatrix{glm::mat3(m_viewMatrix * m_modelMatrix)};
const glm::mat3 normalMatrix{glm::inverseTranspose(modelViewMatrix)};
abcg::glUniformMatrix3fv(normalMatrixLoc, 1, GL_FALSE, &normalMatrix[0][0]);
m_model.render(m_trianglesToDraw);
abcg::glUseProgram(0);
}
Observe que, além de enviar para o shader as matrizes \(4 \times 4\) de visão (viewMatrix
), projeção (projMatrix
) e modelo (modelMatrix
), também enviamos uma matriz \(3 \times 3\) chamada de normalMatrix
.
Nas linhas 73 a 75, a matriz normalMatrix
é calculada como a transposta da inversa de \(M_{\textrm{view}}M_{\textrm{model}}\), isto é:
\[ M_{\textrm{normal}}=\left((M_{\textrm{view}}M_{\textrm{model}})^{-1}\right)^{T}. \]
\(M_{\textrm{normal}}\) é a matriz que transforma um vetor normal do espaço do mundo para um vetor normal do espaço da câmera. Existe um motivo especial para usar essa matriz no lugar de \(M_{\textrm{view}}M_{\textrm{model}}\) para transformar vetores normais. Isso será explicado logo mais no final desta seção.
paintUI
No início de OpenGLWindow::paintUI
, inicializamos o objeto que define a caixa de diálogo do elemento de interface “imgui-filebrowser”:
static ImGui::FileBrowser fileDialog;
fileDialog.SetTitle("Load 3D Model");
fileDialog.SetTypeFilters({".obj"});
fileDialog.SetWindowSize(m_viewportWidth * 0.8f, m_viewportHeight * 0.8f);
fileDialog.SetPwd(getAssetsPath());
Com essa configuração, o navegador de arquivos mostrará arquivos com extensão .obj
no subdiretório assets
, e a caixa de diálogo ocupará 80% do tamanho do viewport.
A caixa de seleção de shaders é implementada com o seguinte trecho de código:
// Shader combo box
{
static std::size_t currentIndex{};
ImGui::PushItemWidth(120);
if (ImGui::BeginCombo("Shader", m_shaderNames.at(currentIndex))) {
for (const auto index : iter::range(m_shaderNames.size())) {
const bool isSelected{currentIndex == index};
if (ImGui::Selectable(m_shaderNames.at(index), isSelected))
currentIndex = index;
if (isSelected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::PopItemWidth();
// Set up VAO if shader program has changed
if (static_cast<int>(currentIndex) != m_currentProgramIndex) {
m_currentProgramIndex = static_cast<int>(currentIndex);
m_model.setupVAO(m_programs.at(m_currentProgramIndex));
}
}
Veja que usamos os nomes de m_shaderNames
como elementos da caixa de combinação. Observe também que a função Model::setupVAO
é chamada sempre que um novo shader é selecionado (linhas 196 a 200).
O botão “Load 3D Model” é criado com o código a seguir:
Quando o botão é pressionado, chamamos fileDialog.Open
para abrir a caixa de diálogo de seleção de arquivos OBJ.
No fim de OpenGLWindow::paintUI
, colocamos o código responsável pela renderização da caixa de diálogo e pela leitura do novo modelo 3D.
fileDialog.Display();
if (fileDialog.HasSelected()) {
// Load model
m_model.loadObj(fileDialog.GetSelected().string());
m_model.setupVAO(m_programs.at(m_currentProgramIndex));
m_trianglesToDraw = m_model.getNumTriangles();
fileDialog.ClearSelected();
}
Se algum arquivo foi selecionado na caixa de diálogo (linha 212), chamamos Model::loadObj
para carregar o arquivo, e então Model::setupVAO
para configurar o VAO. Por fim, atualizamos a variável m_trianglesToDraw
, utilizada para controlar o número de triângulos processados por glDrawElements
.
Isso é tudo em relação às mudanças do código em C++. Vamos agora à definição dos shaders normal.vert
e normal.frag
usando GLSL.
normal.frag
Vamos começar pelo conteúdo de normal.frag
, que é bem simples:
A cor de entrada é simplesmente copiada para a cor de saída, como já fizemos em vários outros projetos. Assim, se cada vértice do triângulo tiver uma cor diferente, fragColor
será uma cor interpolada linearmente a partir dos vértices. O resultado será um gradiente de cor.
normal.vert
Este shader converte as coordenadas do vetor normal de vértice em uma cor RGB.
Em muitos casos, é mais fácil visualizar a direção de vetores normais através de cores do que através do desenho de setas que saem dos vértices. Se o modelo tiver muitos vértices, as setas cobrirão todo o objeto e não conseguiremos distinguir um vetor de outro. Isso é ainda mais importante se quisermos observar os vetores normais calculados para cada fragmento.
As coordenadas \((x, y, z)\) de um vetor unitário estão no intervalo \([-1,1]\). Uma cor RGB tem componentes \((r, g, b)\) no intervalo \([0,1]\). Logo, a conversão das coordenadas em cores é um simples mapeamento linear de \([-1,1]\) para \([0,1]\):
\[ r = \frac{x+1}{2}, \qquad g = \frac{y+1}{2}, \qquad b = \frac{z+1}{2}. \]
Assim, se o vetor normal tiver coordenadas \((1,0,0)\) (direção do eixo \(x\) positivo), o resultado será um tom próximo ao vermelho \((1, 0.5, 0.5)\). Se o vetor normal tiver coordenadas \((0,1,0)\) (direção de \(y\) positivo), o resultado será um tom próximo ao verde \((0.5, 1, 0.5)\). Se tiver coordenadas \((0,0,1)\) (direção de \(z\) positivo), terá um tom próximo ao azul \((0.5, 0.5, 1)\). Essa convenção de cores é a mesma que temos utilizado nas ilustrações dos eixos principais em todas as figuras. A figura 9.20 mostra as cores correspondentes para as direções \(\pm x\), \(\pm y\) e \(\pm z\).
O código ficará como a seguir:
#version 410
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;
out vec4 fragColor;
void main() {
mat4 MVP = projMatrix * viewMatrix * modelMatrix;
gl_Position = MVP * vec4(inPosition, 1.0);
vec3 N = inNormal; // Object space
// vec3 N = normalMatrix * inNormal; // Eye space
// Convert from [-1,1] to [0,1]
fragColor = vec4((N + 1.0) / 2.0, 1.0);
}
Temos dois atributos de entrada: inPosition
(linha 3) e inNormal
(linha 4), que correspondem à posição do vértice e seu vetor normal unitário. Vamos supor que ambos estão no espaço do objeto.
Temos apenas um atributo de saída (linha 11), que é a cor que iremos calcular com base no vetor normal.
Na linha 14, criamos uma matriz MVP
que é a composição das matrizes de modelo, visão e projeção.
Na linha 16, multiplicamos MVP
pela posição do vértice, de modo a converter a posição em coordenadas do espaço do objeto para coordenadas do espaço de recorte. O resultado é atribuído a gl_Position
.
Na linha 18, criamos um vetor N
que é uma cópia de inNormal
.
A conversão de XYZ para RGBA é feita na linha 22 (a componente A é sempre 1).
Da forma como está, fragColor
é a cor que representa um vetor normal unitário no espaço do objeto.
Experimente comentar a linha 18 e, no lugar, usar o código que está comentado na linha 19. Isso fará com que fragColor
represente um vetor normal unitário no espaço da câmera.
Tente identificar visualmente a diferença entre vetores normais no espaço do objeto e no espaço da câmera. Há alguma cor que aparece para N
em um espaço e não aparece para N
em outro espaço? Por quê?
Convertendo normais para o espaço da câmera
Se usarmos a linha 19 no lugar da linha 18, N
será transformado por normalMatrix
para converter o vetor normal do espaço do objeto para o espaço da câmera. Em muitos casos, isso é o mesmo que fazer
vec4 N = viewMatrix * modelMatrix * vec4(inNormal, 0);
O problema é que transformar um vetor normal pela matriz de modelo e visão nem sempre resulta em um vetor normal à superfície. Esse é o caso quando a matriz de modelo (ou de visão) contém uma escala não uniforme. Veja, na figura 9.21, como uma escala não uniforme faz com que a maioria dos vetores normais não sejam mais perpendiculares às faces (que nesse caso são lados) do objeto.
Suponha que os vetores \(\mathbf{n}\) e \(\mathbf{t}\) da figura 9.21 sejam matrizes coluna
\[\mathbf{n}=\begin{bmatrix}n_x\\n_y\\n_z\end{bmatrix},\qquad \mathbf{t}=\begin{bmatrix}t_x\\t_y\\t_z\end{bmatrix}.\]
Os vetores são perpendiculares. Logo,
\[\mathbf{n} \cdot \mathbf{t} = 0.\]
Também podemos escrever na notação de multiplicação entre matrizes:
\[ \mathbf{n}^T\mathbf{t} = 0. \]
Seja \(\mathbf{M}\) a matriz modelo-visão:
\[ \mathbf{M}=\mathbf{M}_{\textrm{view}}\mathbf{M}_{\textrm{model}}. \] Já sabemos que nem sempre \(\mathbf{M}\mathbf{n} \cdot \mathbf{M}\mathbf{t}=0\). Acabamos de ver um contraexemplo na figura 9.21. Entretanto, suponha que existe uma matriz \(\mathbf{W}\) tal que
\[(\mathbf{W}\mathbf{n}) \cdot (\mathbf{M}\mathbf{t}) = 0.\] Podemos reescrever a expressão como
\[ \begin{align} (\mathbf{W}\mathbf{n})^T(\mathbf{M}\mathbf{t}) = 0,\\ (\mathbf{n}^T\mathbf{W}^T)(\mathbf{M}\mathbf{t}) = 0,\\ \mathbf{n}^T(\mathbf{W}^T\mathbf{M})\mathbf{t} = 0.\\ \end{align} \] Nesta última expressão, observe que, se o termo entre parênteses resultar em uma matriz identidade, isto é, se
\[(\mathbf{W}^T\mathbf{M})=\mathbf{I},\]
então
\[\mathbf{n}^T\mathbf{t} = 0,\]
que é o que declaramos no início (os vetores são perpendiculares). Podemos isolar \(\mathbf{W}\) para obter a forma final da matriz que devemos usar para transformar o vetor normal:
\[ \begin{align} \mathbf{W}^T\mathbf{M}&=\mathbf{I},\\ \mathbf{W}^T&=\mathbf{M}^{-1},\\ \mathbf{W}&=(\mathbf{M}^{-1})^T.\\ \end{align} \] Isso mostra que a matriz que devemos utilizar para converter um vetor normal do espaço do objeto para o espaço da câmera é a transposta da inversa da matriz modelo-visão:
\[ \mathbf{M}_{\textrm{normal}}=(\mathbf{M_{\textrm{modelview}}}^{-1})^T. \]
Baixe o código completo do projeto usando este link.