Vectores: desplazamientos, no posiciones
Las operaciones con vectores desde su significado geométrico, la diferencia entre punto y dirección, y las dos trampas de la clase Vector3 que arruinan proyectos enteros.
Un vector no es una posición. Es un desplazamiento: una cantidad con dirección y magnitud que no tiene origen propio. Que en código se represente igual que una posición —tres números— hace que la distinción parezca pedante, hasta que una matriz de traslación afecta a una y no a la otra y algo empieza a fallar sin mensaje de error. Esta lección construye el vocabulario geométrico entero desde esa distinción.
- Distinguir punto de dirección y predecir qué transformaciones afectan a cada uno.
- Interpretar geométricamente suma, resta, escalado, longitud y normalización.
- Aplicar la resta de posiciones como operación básica del código 3D.
- Evitar los dos errores estructurales de
Vector3: la mutación y la reserva de memoria por fotograma.
Punto y dirección: la distinción que importa
Un punto responde a la pregunta «dónde». Solo tiene sentido respecto a un origen. Moverlo con una traslación tiene sentido.
Una dirección responde a «hacia dónde» y «cuánto», sin decir desde dónde. La normal de una superficie, la dirección de una luz, la velocidad de una partícula: todo eso son direcciones. Trasladar una dirección no tiene ningún sentido geométrico. Si mueves un objeto tres metros a la derecha, su normal no se mueve tres metros: sigue apuntando hacia donde apuntaba.
En código las dos son un Vector3, y esa ambigüedad es deliberada porque comparten casi todas las operaciones. Pero el sistema de transformaciones sí las distingue, y para poder distinguirlas necesita una cuarta componente: la coordenada homogénea que se estudia en el nivel siguiente. Por eso en un shader verás vec4(position, 1.0) para un punto y vec4(direccion, 0.0) para una dirección: ese uno o cero es lo que activa o desactiva la traslación.
Mientras tanto, la disciplina que evita el problema es de nomenclatura. Llama a las cosas por lo que son —posicionMundo, direccionLuz, desplazamiento— y no mezcles ambos tipos en la misma variable. Suena trivial y es la costumbre que más depuración ahorra.
Las operaciones y lo que significan
Suma. Componer dos desplazamientos: ir por A y luego por B. Sumar un punto y una dirección da un punto nuevo, y es la operación de mover algo. Sumar dos puntos no significa nada, aunque el compilador lo permita; la excepción es el promedio, donde la división posterior lo rescata.
Resta. La más usada del 3D, y con diferencia. b.sub(a) es el vector que va de A hasta B. De ahí sale casi todo: la dirección de un objeto a otro, la distancia entre dos puntos, la dirección de la cámara al objetivo, el vector de una arista de un triángulo. Cuando no sepas cómo obtener una dirección, la respuesta casi siempre es restar dos posiciones y normalizar.
Escalado. Multiplicar por un número cambia la magnitud y, si el número es negativo, invierte el sentido. Escalar por cero destruye la dirección de forma irrecuperable, cosa que importa al normalizar.
Longitud. El teorema de Pitágoras en tres dimensiones. Tiene una hermana pequeña que hay que usar más: la longitud al cuadrado. Comparar distancias no necesita la raíz cuadrada, porque la raíz es monótona: si quieres saber cuál de dos objetos está más cerca, o si algo está dentro de un radio, compara cuadrados y ahórrate la raíz. En un bucle sobre diez mil objetos, la diferencia se mide.
Normalización. Dividir un vector por su longitud produce un vector unitario: dirección pura, magnitud uno. Es el requisito de casi todas las fórmulas de iluminación, porque el producto escalar solo equivale al coseno del ángulo cuando ambos vectores son unitarios.
Hay un caso degenerado que conviene conocer: normalizar el vector cero. Matemáticamente es indefinido, y las implementaciones eligen. Three.js divide por la longitud o por uno si la longitud es cero, así que normalizar un vector cero devuelve un vector cero en lugar de propagar valores no numéricos. Es una decisión defensiva razonable, y significa que el síntoma de este error no será una pantalla negra con NaN sino una iluminación negra en una superficie concreta. Más difícil de detectar, no más difícil de arreglar.
import * as THREE from 'three';
const jugador = new THREE.Vector3(3, 0, 4);
const enemigo = new THREE.Vector3(9, 0, 12);
// La resta como operacion fundamental: direccion y distancia de un tiron.
const haciaElEnemigo = new THREE.Vector3().subVectors(enemigo, jugador);
console.log('distancia:', haciaElEnemigo.length()); // 10
console.log('distancia al cuadrado:', haciaElEnemigo.lengthSq()); // 100
const direccion = haciaElEnemigo.clone().normalize();
console.log('direccion unitaria:', direccion); // (0.6, 0, 0.8)
// Avanzar dos unidades hacia el enemigo, sin tocar los vectores originales.
const nuevaPosicion = jugador.clone().addScaledVector(direccion, 2);
console.log('nueva posicion:', nuevaPosicion); // (4.2, 0, 5.6)
// Comparar distancias sin raiz cuadrada.
const objetivos = [new THREE.Vector3(1, 0, 1), new THREE.Vector3(8, 0, 8)];
const masCercano = objetivos.reduce((a, b) =>
a.distanceToSquared(jugador) < b.distanceToSquared(jugador) ? a : b
);
console.log('mas cercano:', masCercano);
addScaledVector merece un apunte: suma un vector multiplicado por un escalar en una sola llamada, sin crear objetos intermedios. Es el idioma correcto para «avanzar una cantidad en una dirección», que en el bucle de animación se escribe cientos de veces.
Los tipos matemáticos de Three.js son mutables y devuelven this. a.add(b) no devuelve un vector nuevo: modifica a y devuelve a. Es una decisión de rendimiento perfectamente defendible —evita reservar memoria en operaciones que se hacen millones de veces— pero rompe la intuición de quien viene de bibliotecas inmutables, y produce un bug muy concreto: guardas una referencia a objeto.position en una variable, la modificas para calcular otra cosa, y acabas de mover el objeto sin querer. La regla es corta: si el vector no es tuyo, clónalo antes de tocarlo, y si vas a escribir un resultado, usa las variantes con dos argumentos como subVectors o crossVectors, que escriben en el objeto receptor sin modificar las entradas. La segunda trampa es peor porque no da síntomas hasta que ya está instalada. Cada new THREE.Vector3() dentro del bucle de render es basura que el recolector tendrá que recoger; con diez mil objetos y tres vectores temporales por objeto son treinta mil reservas por fotograma, es decir casi dos millones por segundo. El resultado no es lentitud constante, que se detectaría enseguida, sino microcortes periódicos cuando el recolector pasa, exactamente el patrón que arruina una animación y que la gente atribuye a la GPU. La solución es un idioma que verás en todo el código serio de Three.js: declara los vectores temporales una sola vez fuera del bucle, en el ámbito del módulo, y reutilízalos. Es feo, y es lo correcto.
import * as THREE from 'three';
// Vectores temporales reutilizados: ni una reserva dentro del bucle.
const _direccion = new THREE.Vector3();
const _posicionMundo = new THREE.Vector3();
export function actualizarSeguidores(seguidores, objetivo, delta) {
objetivo.getWorldPosition(_posicionMundo);
for (const seguidor of seguidores) {
_direccion.subVectors(_posicionMundo, seguidor.position);
const distancia = _direccion.length();
if (distancia < 0.001) continue;
_direccion.divideScalar(distancia); // normalizar sin recalcular la longitud
seguidor.position.addScaledVector(_direccion, delta * 2);
}
}
Fíjate en divideScalar(distancia) en lugar de normalize(): ya habías calculado la longitud para comprobar el caso degenerado, así que normalizar de nuevo repetiría una raíz cuadrada. Son los detalles que separan un bucle que aguanta diez mil elementos de uno que aguanta mil.
Base, coordenadas y la elección de unidad
Los tres números de un vector no son el vector: son sus coordenadas respecto a una base. La base canónica son los tres ejes unitarios, y por eso (3, 0, 4) significa tres pasos en el eje X más cuatro en el eje Z. Cambiar de base es cambiar los números sin cambiar la flecha, y eso es exactamente lo que hacen las matrices del nivel siguiente.
Three.js usa un sistema dextrógiro con el eje Y hacia arriba. Con el pulgar en X y el índice en Y, el corazón de la mano derecha apunta a Z, que sale hacia el observador. La cámara, por convención, mira a lo largo de su eje Z negativo. Esa convención concreta —no es la única del sector— explica el signo de media docena de fórmulas que verás más adelante.
Queda una decisión que casi nadie toma explícitamente y que luego cuesta cara: qué representa una unidad. La recomendación es un metro, porque es lo que asumen los motores de física, lo que exportan las herramientas de modelado por defecto y lo que hace que los valores de atenuación de las luces se comporten de forma predecible. Un proyecto donde la unidad es un centímetro funciona igual de bien mientras sea coherente, y se rompe el día que integras una biblioteca que asume metros. Escríbelo en el primer comentario del proyecto y respétalo.
Escribe una función que, dado un array de posiciones y un punto, devuelva el índice del más cercano sin crear ningún objeto intermedio y sin calcular ninguna raíz cuadrada. Después mídela contra una versión ingenua que use clone() y distanceTo con cien mil puntos. La diferencia que verás es la razón de que las clases matemáticas de Three.js sean mutables.