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{};
friend bool operator==(Vertex const &, Vertex const &) = default;
};
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 hash 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
template <> struct std::hash<Vertex> {
size_t operator()(Vertex const &vertex) const noexcept {
auto const h1{std::hash<glm::vec3>()(vertex.position)};
auto const h2{std::hash<glm::vec3>()(vertex.normal)};
return abcg::hashCombine(h1, h2);
}
};
Os valores de hash h1
e h2
são combinados com abcg::hashCombine
, que usa um algoritmo de combinação de valores de hash inspirado na função boost::hash_combine
da biblioteca Boost. Uma forma mais simples seria fazer h1^h2
, onde ^
é o operador “ou exclusivo” bit a bit, mas isso pode resultar em muitas colisões, uma vez que h1^h2 == h2^h1
, e h1^h2 == 0
se h1 == h2
.
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::vec3(0.0f);
}
// Compute face normals
for (auto const offset : iter::range(0UL, m_indices.size(), 3UL)) {
// Get face vertices
auto &a{m_vertices.at(m_indices.at(offset + 0))};
auto &b{m_vertices.at(m_indices.at(offset + 1))};
auto &c{m_vertices.at(m_indices.at(offset + 2))};
// Compute normal
auto const edge1{b.position - a.position};
auto const edge2{c.position - b.position};
auto const 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;
}
Essa 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::vec3(0.0f);
}
Para cada triângulo \(\triangle ABC\) da malha, o vetor normal é calculado como
\[\mathbf{n}=(B-A) \times (C-B),\]
// Compute normal
auto const edge1{b.position - a.position};
auto const edge2{c.position - b.position};
auto const 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 107 a 115 do código abaixo). Se os vetores normais não forem encontrados, chamamos Model::computeNormals
(linhas 135 a 137):
void Model::loadObj(std::string_view path, bool standardize) {
tinyobj::ObjReader reader;
if (!reader.ParseFromFile(path.data())) {
if (!reader.Error().empty()) {
throw abcg::RuntimeError(
fmt::format("Failed to load model {} ({})", path, reader.Error()));
}
throw abcg::RuntimeError(fmt::format("Failed to load model {}", path));
}
if (!reader.Warning().empty()) {
fmt::print("Warning: {}\n", reader.Warning());
}
auto const &attrib{reader.GetAttrib()};
auto const &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 (auto const &shape : shapes) {
// Loop over indices
for (auto const offset : iter::range(shape.mesh.indices.size())) {
// Access to vertex
auto const index{shape.mesh.indices.at(offset)};
// Vertex position
auto const startIndex{3 * index.vertex_index};
glm::vec3 position{attrib.vertices.at(startIndex + 0),
attrib.vertices.at(startIndex + 1),
attrib.vertices.at(startIndex + 2)};
// Vertex normal
glm::vec3 normal{};
if (index.normal_index >= 0) {
m_hasNormals = true;
auto const normalStartIndex{3 * index.normal_index};
normal = {attrib.normals.at(normalStartIndex + 0),
attrib.normals.at(normalStartIndex + 1),
attrib.normals.at(normalStartIndex + 2)};
}
Vertex const vertex{.position = position, .normal = normal};
// If hash doesn't contain this vertex
if (!hash.contains(vertex)) {
// 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) {
Model::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
auto const positionAttribute{
abcg::glGetAttribLocation(program, "inPosition")};
if (positionAttribute >= 0) {
abcg::glEnableVertexAttribArray(positionAttribute);
abcg::glVertexAttribPointer(positionAttribute, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex), nullptr);
}
auto const normalAttribute{abcg::glGetAttribLocation(program, "inNormal")};
if (normalAttribute >= 0) {
abcg::glEnableVertexAttribArray(normalAttribute);
auto const offset{offsetof(Vertex, normal)};
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 na linhas 177 usando a macro offsetof
.
window.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 Window
será substituída por um conjunto de variáveis:
std::vector<char const *> m_shaderNames{"normal", "depth"};
std::vector<GLuint> m_programs;
int m_currentProgramIndex{};
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.
window.cpp
No início do arquivo precisamos incluir um cabeçalho a mais:
#include "imfilebrowser.h"
onCreate
Em Window::onCreate
, compilamos e ligamos todos os shaders mencionados em m_shaderNames
, supondo que o arquivo .vert
tem o mesmo nome do arquivo .frag
:
void Window::onCreate() {
auto const assetsPath{abcg::Application::getAssetsPath()};
abcg::glClearColor(0, 0, 0, 1);
abcg::glEnable(GL_DEPTH_TEST);
// Create programs
for (auto const &name : m_shaderNames) {
auto const program{
abcg::createOpenGLProgram({{.source = assetsPath + name + ".vert",
.stage = abcg::ShaderStage::Vertex},
{.source = assetsPath + name + ".frag",
.stage = abcg::ShaderStage::Fragment}})};
m_programs.push_back(program);
}
// Load model
m_model.loadObj(assetsPath + "bunny.obj");
m_model.setupVAO(m_programs.at(m_currentProgramIndex));
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 Window::onPaintUI
.
onPaint
A definição de Window::onPaint
ficará assim:
void Window::onPaint() {
abcg::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
abcg::glViewport(0, 0, m_viewportSize.x, m_viewportSize.y);
// Use currently selected program
auto const program{m_programs.at(m_currentProgramIndex)};
abcg::glUseProgram(program);
// Get location of uniform variables
auto const viewMatrixLoc{abcg::glGetUniformLocation(program, "viewMatrix")};
auto const projMatrixLoc{abcg::glGetUniformLocation(program, "projMatrix")};
auto const modelMatrixLoc{abcg::glGetUniformLocation(program, "modelMatrix")};
auto const normalMatrixLoc{
abcg::glGetUniformLocation(program, "normalMatrix")};
// 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]);
// Set uniform variables for the current model
abcg::glUniformMatrix4fv(modelMatrixLoc, 1, GL_FALSE, &m_modelMatrix[0][0]);
auto const modelViewMatrix{glm::mat3(m_viewMatrix * m_modelMatrix)};
auto const 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
.
Na linha 81, 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.
onPaintUI
No início de Window::onPaintUI
, inicializamos o objeto que define a caixa de diálogo do elemento de interface “imgui-filebrowser”:
auto const scaledWidth{gsl::narrow_cast<int>(m_viewportSize.x * 0.8f)};
auto const scaledHeight{gsl::narrow_cast<int>(m_viewportSize.y * 0.8f)};
// File browser for models
static ImGui::FileBrowser fileDialogModel;
fileDialogModel.SetTitle("Load 3D Model");
fileDialogModel.SetTypeFilters({".obj"});
fileDialogModel.SetWindowSize(scaledWidth, scaledHeight);
#if defined(__EMSCRIPTEN__)
auto const assetsPath{abcg::Application::getAssetsPath()};
fileDialogModel.SetPwd(assetsPath);
#endif
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.
Na janela da ImGui na parte superior direita incluiremos uma caixa de seleção de shaders. Para que a janela tenha espaço vertical suficiente para essa caixa de seleção, mudaremos o widgetSize
, de (222, 90)
para (222, 142)
:
A caixa de seleção de shaders é implementada com o seguinte trecho de código, após a criação do combo box de seleção da projeção:
// Shader combo box
{
static std::size_t currentIndex{};
ImGui::PushItemWidth(120);
if (ImGui::BeginCombo("Shader", m_shaderNames.at(currentIndex))) {
for (auto const index : iter::range(m_shaderNames.size())) {
auto const 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 (gsl::narrow<int>(currentIndex) != m_currentProgramIndex) {
m_currentProgramIndex = gsl::narrow<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.
O botão “Load 3D Model” é criado com o código a seguir:
Quando o botão é pressionado, chamamos fileDialogModel.Open
para abrir a caixa de diálogo de seleção de arquivos OBJ.
No fim de Window::onPaintUI
, colocamos o código responsável pela renderização da caixa de diálogo e pela leitura do novo modelo 3D.
fileDialogModel.Display();
if (fileDialogModel.HasSelected()) {
// Load model
m_model.loadObj(fileDialogModel.GetSelected().string());
m_model.setupVAO(m_programs.at(m_currentProgramIndex));
m_trianglesToDraw = m_model.getNumTriangles();
fileDialogModel.ClearSelected();
}
Se algum arquivo foi selecionado na caixa de diálogo (linha 228), 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
.
depth.vert
Em depth.vert
, removemos a variável uniforme de mudança de cor. Todos os objetos agora serão desenhados com tons de cinza:
#version 300 es
layout(location = 0) in vec3 inPosition;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
out vec4 fragColor;
void main() {
vec4 posEyeSpace = viewMatrix * modelMatrix * vec4(inPosition, 1);
float i = 1.0 - (-posEyeSpace.z / 3.0);
fragColor = vec4(i, i, i, 1);
gl_Position = projMatrix * posEyeSpace;
}
normal.frag
O conteúdo do fragment shader responsável pelo desenho das normais de vértices como cores é bem simples:
#version 300 es
precision mediump float;
in vec4 fragColor;
out vec4 outColor;
void main() { outColor = fragColor; }
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.21 mostra as cores correspondentes para as direções \(\pm x\), \(\pm y\) e \(\pm z\).
O código ficará como a seguir:
#version 300 es
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.
Observe 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);
Entretanto, a transformação de 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.22, como uma escala não uniforme faz com que os 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.22 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.22. 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.