wandres.dev
MATEMÁTICAS DEL 3D I · Vectores y espacios

El producto vectorial: la fábrica de perpendiculares

Cómo se calcula la normal de una cara, cómo se construye la base de una cámara, y por qué el orden de los vértices decide hacia dónde mira una superficie.

⏱ 18 min

Si el producto escalar mide alineación, el vectorial fabrica perpendicularidad. Es la única operación elemental que, dadas dos direcciones, produce una tercera que no estaba: la que sale del plano de ambas. De ahí salen las normales de las superficies, las bases de las cámaras, las pruebas de orientación en pantalla y el criterio con el que la GPU decide si estás viendo la cara buena de un triángulo.

🎯 Al terminar esta lección sabrás
  • Calcular la normal de una cara a partir de tres vértices y saber de qué depende su sentido.
  • Relacionar la magnitud del producto vectorial con el área y con el seno del ángulo.
  • Construir una base ortonormal completa para orientar una cámara.
  • Reconocer el caso degenerado de vectores paralelos y sus consecuencias visibles.

Perpendicular a los dos, con una magnitud que significa algo

El producto vectorial de a y b devuelve un vector perpendicular a ambos. Su dirección la fija la regla de la mano derecha: si los dedos van de a hacia b, el pulgar señala el resultado. Su magnitud vale |a| |b| sin θ, que es el área del paralelogramo que forman los dos vectores.

De la definición salen tres propiedades que conviene tener presentes.

No es conmutativo, es anticonmutativo. Invertir el orden invierte el resultado. Esto no es un detalle académico: es la razón de que el orden en que escribes los vértices de un triángulo decida hacia dónde mira su cara.

Se anula cuando los vectores son paralelos. El seno de cero y de ciento ochenta grados es cero, así que dos vectores alineados producen un vector nulo. Es el caso degenerado que rompe todas las construcciones basadas en producto vectorial, y hay que preverlo.

Su magnitud es un seno, complementaria del coseno del producto escalar. Juntos determinan el ángulo completo sin ambigüedad, que es la forma numéricamente estable de medir ángulos.

Normal de una cara y orden de los vértices

La aplicación número uno. Dados tres vértices de un triángulo, restas para obtener dos aristas y las multiplicas vectorialmente: el resultado es perpendicular al plano del triángulo, es decir, su normal. Normalizándolo tienes la normal unitaria que la iluminación necesita.

El sentido de esa normal depende por completo del orden en que tomes las aristas, y por tanto del orden en que estén escritos los vértices en el búfer de índices. Ese orden se llama winding, y la convención de Three.js y de WebGL es que el orden antihorario visto desde fuera corresponde a la cara frontal. Un triángulo con los índices al revés tiene la normal invertida, se descarta al rasterizar si el material solo dibuja caras frontales, y produce el síntoma clásico de «he cargado el modelo y hay agujeros por donde se ve el interior».

import * as THREE from 'three';

const a = new THREE.Vector3(0, 0, 0);
const b = new THREE.Vector3(1, 0, 0);
const c = new THREE.Vector3(0, 1, 0);

const ab = new THREE.Vector3().subVectors(b, a);
const ac = new THREE.Vector3().subVectors(c, a);

const normal = new THREE.Vector3().crossVectors(ab, ac);
console.log('area del triangulo:', normal.length() / 2);   // 0.5
console.log('normal unitaria:', normal.normalize());        // (0, 0, 1)

// Invertir el orden invierte la normal: mismo triangulo, cara opuesta.
const invertida = new THREE.Vector3().crossVectors(ac, ab).normalize();
console.log('con el orden opuesto:', invertida);            // (0, 0, -1)

// Three.js tiene una utilidad especifica que hace exactamente esto.
const normalDirecta = new THREE.Vector3();
THREE.Triangle.getNormal(a, b, c, normalDirecta);
console.log('con Triangle.getNormal:', normalDirecta);      // (0, 0, 1)

La misma operación explica el descarte de caras traseras. El rasterizador no compara normales con la dirección de vista: calcula el área con signo del triángulo ya proyectado en pantalla, que es un producto vectorial en dos dimensiones, y mira el signo. Es más barato y es exacto, porque tras la proyección el signo del área codifica exactamente si estás viendo la cara de delante o la de atrás.

Las normales de vértice de Three.js están ponderadas por área, y eso decide cómo se ve tu malla

Cuando llamas a geometry.computeVertexNormals(), la implementación calcula la normal de cada cara con un producto vectorial y la acumula sin normalizar en cada uno de sus tres vértices, normalizando solo al final. Ese detalle de implementación, que parece una omisión, es en realidad una decisión con nombre propio: como la magnitud del producto vectorial es proporcional al área del triángulo, cada cara contribuye a la normal del vértice en proporción a su tamaño. Las normales quedan ponderadas por área. En una malla con triángulos de tamaño parecido el resultado es indistinguible de un promedio simple, y por eso nadie lo nota. En una malla con teselación desigual —muy típico de geometría generada por extrusión, de modelos escaneados o de cualquier cosa con triángulos alargados— sí se nota: los triángulos grandes dominan la normal del vértice y el sombreado se inclina hacia ellos, produciendo bandas suaves que parecen un problema de iluminación y son un problema de topología. Hay otras ponderaciones posibles —por ángulo interior, que es la que usan varios paquetes de modelado— y dan resultados distintos en esas mismas mallas. La conclusión práctica es que si el sombreado de un modelo es sutilmente distinto en Three.js y en tu herramienta de modelado, y las normales las calcula cada uno por su cuenta, no hay ningún bug: hay dos convenciones. La solución no es pelearse con el algoritmo sino exportar las normales desde el modelador, que es lo que hace glTF por defecto.

Construir una base: cómo se orienta una cámara

Una base ortonormal son tres vectores unitarios mutuamente perpendiculares. Es lo que necesitas para definir la orientación de cualquier cosa: una cámara, un objeto que mira hacia un punto, el espacio tangente de una superficie para leer un mapa de normales.

El problema es que normalmente solo conoces una dirección —hacia dónde quieres mirar— y una referencia aproximada de qué es «arriba». El producto vectorial completa las otras dos.

El algoritmo es este. Partes de la dirección de vista y de un vector de referencia vertical, que no tiene por qué ser perpendicular a la primera. El producto vectorial de ambos da un vector perpendicular a los dos: es el eje lateral, el «derecha» de la cámara, y sale unitario tras normalizar. Con el lateral y la dirección de vista, otro producto vectorial da el «arriba» real, que ahora sí es perpendicular a los otros dos. Tres vectores ortonormales a partir de una dirección y una referencia.

Three.js hace exactamente eso en Matrix4.lookAt, con un detalle de convención importante: como la cámara mira a lo largo de su eje Z negativo, el eje Z de la base se construye apuntando del objetivo hacia el ojo, no al revés.

import * as THREE from 'three';

const ojo = new THREE.Vector3(4, 3, 5);
const objetivo = new THREE.Vector3(0, 0, 0);
const arribaAproximado = new THREE.Vector3(0, 1, 0);

// Eje Z: del objetivo hacia el ojo, porque la camara mira hacia su Z negativo.
const ejeZ = new THREE.Vector3().subVectors(ojo, objetivo).normalize();
// Eje X: perpendicular al plano que forman arriba y Z.
const ejeX = new THREE.Vector3().crossVectors(arribaAproximado, ejeZ).normalize();
// Eje Y: perpendicular a los dos anteriores, y ya ortogonal por construccion.
const ejeY = new THREE.Vector3().crossVectors(ejeZ, ejeX);

console.log('ortogonalidad X con Y:', ejeX.dot(ejeY).toFixed(6));   // 0.000000
console.log('ortogonalidad X con Z:', ejeX.dot(ejeZ).toFixed(6));   // 0.000000

// La misma base que construye Three.js internamente.
const matriz = new THREE.Matrix4().lookAt(ojo, objetivo, arribaAproximado);
const [x, y, z] = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
matriz.extractBasis(x, y, z);
console.log('coincide el eje X:', x.distanceTo(ejeX) < 1e-6);
console.log('coincide el eje Z:', z.distanceTo(ejeZ) < 1e-6);

El caso degenerado está a la vista: si la dirección de vista es paralela al vector de referencia vertical, el primer producto vectorial da cero y la base se derrumba. Ocurre cuando una cámara mira exactamente hacia arriba o hacia abajo, que no es un caso raro sino uno que cualquier usuario provoca en diez segundos arrastrando el ratón. Three.js detecta la situación y perturba mínimamente uno de los ejes para poder continuar, lo que evita el fallo pero produce un giro brusco de la cámara alrededor de su eje de vista: es el tirón característico que notas al llegar al polo en un control de órbita. La solución real no es matemática sino de diseño: limitar el ángulo de elevación para no llegar nunca al polo.

Ángulo estable con las dos operaciones juntas

Cierra el círculo el hecho de que el producto escalar da el coseno y la magnitud del vectorial da el seno. Con ambos, el arcotangente de dos argumentos devuelve el ángulo con precisión uniforme en todo el rango, sin los problemas del arcocoseno cerca de los extremos.

import * as THREE from 'three';

const _cruz = new THREE.Vector3();

/** Angulo entre dos vectores, estable tambien cuando son casi paralelos. */
export function anguloEstable(a, b) {
  _cruz.crossVectors(a, b);
  return Math.atan2(_cruz.length(), a.dot(b));
}

const a = new THREE.Vector3(1, 0, 0);
const b = new THREE.Vector3(1, 0.0001, 0);
console.log('estable:', anguloEstable(a, b));    // ~0.0001 rad, correcto
console.log('con angleTo:', a.angleTo(b));       // el mismo orden, con menos digitos fiables

Con la diferencia de que el primero mantiene la precisión relativa cuando el ángulo tiende a cero y el segundo la pierde. En una cámara que se aproxima a su objetivo, o en un cálculo de curvatura entre caras casi coplanares, esa diferencia se convierte en temblor visible.

Con el producto escalar y el vectorial en la mano, ya está el vocabulario completo para entender por dónde pasa un vértice desde que sale de tu geometría hasta que llega a la pantalla.