Saltar al contenido

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.

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.

JS
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());        // 10console.log('distancia al cuadrado:', haciaElEnemigo.lengthSq()); // 100const 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.

JS
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.