wandres.dev
MATEMÁTICAS DEL 3D II · Matrices y transformaciones

La matriz normal: por qué las normales no usan la matriz de modelo

La traspuesta de la inversa, deducida desde la condición de perpendicularidad, y por qué el error solo se ve cuando escalas de forma no uniforme.

⏱ 20 min

Transformar una normal con la matriz de modelo es incorrecto, y sin embargo funciona en la inmensa mayoría de los casos. Esa combinación —incorrecto pero casi siempre inofensivo— es la peor posible para aprender, porque el error se instala en el código durante meses y aparece el día que alguien escala un objeto de forma no uniforme. Esta lección deduce la fórmula correcta desde la geometría, explica exactamente cuándo la incorrecta coincide con ella y cuándo no, y muestra el fallo en pantalla.

🎯 Al terminar esta lección sabrás
  • Deducir la matriz normal desde la condición de perpendicularidad con las tangentes.
  • Demostrar por qué coincide con la matriz de modelo en rotaciones y escalas uniformes.
  • Calcular a mano la normal correcta de una superficie escalada de forma no uniforme.
  • Saber a qué espacio lleva normalMatrix en Three.js y cuándo hay que construir otra.

Una normal no es una dirección cualquiera

La normal de una superficie no se define por sí misma: se define por lo que es perpendicular a ella. Es la dirección ortogonal al plano tangente en ese punto. Y ahí está la clave del asunto: si la superficie se deforma, lo que tiene que seguir cumpliéndose es la perpendicularidad, no la dirección.

Las tangentes sí son direcciones normales y corrientes. Una tangente es la diferencia entre dos puntos de la superficie infinitamente próximos, así que se transforma como se transforma cualquier diferencia de puntos: con la parte de tres por tres de la matriz de modelo.

Ahora, la pregunta correcta. Dada una normal que era perpendicular a las tangentes originales, ¿qué transformación hay que aplicarle para que siga siendo perpendicular a las tangentes transformadas?

La deducción

Llama M a la submatriz de tres por tres de la transformación —la parte que rota, escala y cizalla, sin la traslación— y N a la transformación desconocida que buscamos para la normal.

La condición de partida es que la normal y la tangente son perpendiculares: su producto escalar es cero. La condición que queremos conservar es que la normal transformada y la tangente transformada también lo sean.

Escrito con productos de matrices, el producto escalar de N n con M t es n traspuesto por N traspuesto por M por t. Queremos que eso valga cero siempre que n traspuesto por t valga cero. La forma más sencilla de garantizarlo para cualquier tangente es que el bloque del centro sea la identidad:

N^T · M = I

despejando:

N = (M^-1)^T

La matriz normal es la traspuesta de la inversa de la submatriz de tres por tres. Esa es toda la deducción, y es puramente geométrica: no hay ninguna convención de por medio, solo la exigencia de que la perpendicularidad sobreviva a la transformación.

Por qué el error casi nunca se nota

Ahora la parte que explica el desconcierto. Hay dos familias enormes de transformaciones en las que la fórmula correcta y la incorrecta coinciden.

Rotaciones. Una matriz de rotación es ortogonal, lo que significa que su inversa es su traspuesta. Sustituye eso en la fórmula: la traspuesta de la inversa es la traspuesta de la traspuesta, es decir, la matriz original. Para una rotación pura, la matriz normal es exactamente la matriz de modelo. No hay diferencia posible.

Escalas uniformes. Si la transformación es una rotación multiplicada por un factor de escala igual en los tres ejes, la traspuesta de la inversa resulta ser la misma rotación dividida por ese factor al cuadrado. Es un vector con la misma dirección que el que produce la matriz de modelo, solo que con otra longitud. Y como las normales se normalizan siempre antes de usarlas, la longitud da igual: el resultado final es idéntico.

Junta las dos familias y tienes casi todo el 3D del mundo. Objetos que rotan, objetos que se hacen más grandes o más pequeños, jerarquías de rotaciones. En todos esos casos, usar la matriz de modelo para transformar la normal da el mismo resultado que la fórmula correcta, y el código incorrecto sobrevive sin dar señales.

El caso que rompe es la escala no uniforme. Y ahí la diferencia no es sutil: la normal apunta en la dirección equivocada, y no un poco.

import * as THREE from 'three';

// Una superficie inclinada 45 grados en el plano YZ.
const tangente = new THREE.Vector3(0, 1, 1).normalize();
const normal = new THREE.Vector3(0, 1, -1).normalize();
console.log('perpendiculares al principio:', normal.dot(tangente).toFixed(6));  // 0

// Escala no uniforme: el triple de alto.
const modelo = new THREE.Matrix4().makeScale(1, 3, 1);
const m3 = new THREE.Matrix3().setFromMatrix4(modelo);

// La tangente si se transforma con la matriz de modelo.
const tangenteNueva = tangente.clone().applyMatrix3(m3).normalize();

// Normal transformada MAL, con la matriz de modelo.
const normalMal = normal.clone().applyMatrix3(m3).normalize();
console.log('mal, ya no es perpendicular:', normalMal.dot(tangenteNueva).toFixed(4));  // 0.8

// Normal transformada BIEN, con la traspuesta de la inversa.
const matrizNormal = new THREE.Matrix3().getNormalMatrix(modelo);
const normalBien = normal.clone().applyNormalMatrix(matrizNormal);
console.log('bien, sigue perpendicular:', normalBien.dot(tangenteNueva).toFixed(6)); // 0

console.log('normal mal:', normalMal.toArray().map((n) => +n.toFixed(3)));
console.log('normal bien:', normalBien.toArray().map((n) => +n.toFixed(3)));

Los dos vectores del final no se parecen. La normal incorrecta se inclina hacia el eje que has estirado; la correcta se inclina en sentido contrario, que es lo que dicta la geometría: estirar una superficie en vertical la vuelve más empinada, y una superficie más empinada tiene su normal más tumbada.

El síntoma visual es inconfundible una vez lo has visto: un objeto escalado en un solo eje se ilumina como si no estuviera escalado. Los brillos están en el sitio equivocado, las zonas que deberían quedar en sombra reciben luz, y el volumen se percibe mal sin que puedas señalar qué falla.

Cómo lo hace Three.js

El renderer calcula, para cada objeto y cada fotograma, dos matrices derivadas: modelViewMatrix, que combina modelo y vista, y normalMatrix, que es exactamente getNormalMatrix aplicado a la anterior. Ambas llegan al shader como uniformes, y por eso en cualquier vertex shader de Three.js la línea de la normal se escribe así:

vNormal = normalize(normalMatrix * normal);

El normalize no es opcional aunque la normal de entrada sea unitaria, porque la matriz normal no conserva longitudes: es una inversa traspuesta, no una rotación. Y hay un segundo normalize obligatorio en el fragment shader, porque la interpolación lineal entre tres normales unitarias no produce vectores unitarios: el resultado se acorta hacia el centro del triángulo, más cuanto más divergentes sean las tres.

normalMatrix lleva a espacio de vista, no a espacio de mundo, y no hay ninguna matriz integrada que lleve al mundo

Este es el matiz que convierte a la matriz normal en una fuente inagotable de bugs sutiles, y no aparece en casi ninguna explicación. La normalMatrix que Three.js inyecta se calcula a partir de modelViewMatrix, no de modelMatrix. Es decir: transforma normales de espacio de modelo a espacio de vista, porque ese es el espacio en el que los materiales integrados calculan la iluminación. Si tú escribes un shader que trabaja en espacio de mundo —cosa muy habitual, porque cameraPosition llega en espacio de mundo y porque los efectos que dependen de la posición global son más fáciles de razonar ahí— y usas normalMatrix, estás mezclando dos espacios. El resultado es una iluminación que gira cuando gira la cámara, con el objeto y la luz completamente quietos. Es un bug precioso porque no se parece a un problema de normales: parece un problema de la luz, y la gente se pasa la tarde tocando la luz. No existe una matriz normal de mundo entre las variables inyectadas, así que tienes dos opciones. La barata, válida solo si el objeto no tiene escala no uniforme, es normalize(mat3(modelMatrix) * normal). La correcta en el caso general es calcular la traspuesta de la inversa de la matriz de mundo en JavaScript y pasarla como uniforme propio, actualizándola cuando el objeto cambie. La regla que resume esto y que conviene escribir en un comentario del proyecto: decide en qué espacio calculas la iluminación antes de escribir la primera línea del shader, y anota en cada variable interpolada a qué espacio pertenece. Los shaders que fallan por esto suelen tener los dos espacios mezclados en el mismo archivo sin que su autor lo sepa.

Verlo fallar

El ejemplo siguiente dibuja el mismo objeto dos veces, con la misma escala no uniforme y la misma luz. El de la izquierda transforma la normal con la matriz de modelo-vista; el de la derecha, con la matriz normal. La diferencia es obvia en cuanto se ve.

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11111b);
const camera = new THREE.PerspectiveCamera(
  45, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 0, 8);

const vertexMal = `
  varying vec3 vNormal;
  void main() {
    // INCORRECTO: la parte 3x3 de modelView no conserva la perpendicularidad.
    vNormal = normalize(mat3(modelViewMatrix) * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const vertexBien = `
  varying vec3 vNormal;
  void main() {
    // CORRECTO: traspuesta de la inversa, calculada por el renderer.
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = `
  varying vec3 vNormal;
  void main() {
    // Segundo normalize: la interpolacion acorta los vectores.
    vec3 n = normalize(vNormal);
    vec3 l = normalize(vec3(0.4, 0.9, 0.6));
    float difusa = max(dot(n, l), 0.0);
    gl_FragColor = vec4(vec3(0.05) + vec3(0.65, 0.85, 1.0) * difusa, 1.0);
  }
`;

const geometria = new THREE.SphereGeometry(1, 64, 32);

const malo = new THREE.Mesh(
  geometria,
  new THREE.ShaderMaterial({ vertexShader: vertexMal, fragmentShader: fragment })
);
malo.position.x = -1.8;
malo.scale.set(1, 3, 1);            // la escala no uniforme que revela el error

const bueno = new THREE.Mesh(
  geometria,
  new THREE.ShaderMaterial({ vertexShader: vertexBien, fragmentShader: fragment })
);
bueno.position.x = 1.8;
bueno.scale.set(1, 3, 1);

scene.add(malo, bueno);

renderer.setAnimationLoop((tiempo) => {
  const giro = tiempo / 3000;
  malo.rotation.y = giro;
  bueno.rotation.y = giro;
  renderer.render(scene, camera);
});

El elipsoide de la izquierda se ilumina como si fuera una esfera: el terminador entre luz y sombra está en el sitio que le correspondería a la geometría sin escalar, y el objeto parece plano y de plástico. El de la derecha tiene el terminador donde debe, y se percibe el volumen alargado. Es la misma geometría, la misma luz, la misma escala, y una sola línea de diferencia.

Un detalle más para cerrar. Si la transformación incluye una escala negativa —un objeto reflejado—, el determinante es negativo y la matriz normal invierte el sentido de todas las normales: apuntan hacia dentro. Three.js compensa el problema de la orientación de las caras invirtiendo el criterio de cara frontal cuando detecta un determinante negativo, pero en un shader propio que use las normales para algo más que la iluminación difusa conviene tenerlo presente.