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

El producto escalar: la medida de alineación

Por qué el producto escalar es un coseno disfrazado, qué significa su signo, y cómo de él salen la iluminación difusa, las pruebas de visibilidad y la descomposición de vectores.

⏱ 18 min

El producto escalar cabe en cinco operaciones aritméticas y sostiene la mitad de los gráficos por computador. No porque sea profundo, sino porque responde a la pregunta que más veces hay que hacerse en una escena: cuánto apuntan dos cosas en la misma dirección. La iluminación, el descarte de caras, las pruebas de frustum, el efecto Fresnel y el movimiento relativo a la cámara son todos la misma operación con distinto vestido.

🎯 Al terminar esta lección sabrás
  • Deducir la equivalencia entre la fórmula algebraica y el coseno del ángulo.
  • Interpretar el signo del producto escalar como una prueba de semiespacio.
  • Derivar la iluminación difusa de Lambert desde un argumento geométrico.
  • Descomponer un vector en componentes paralela y perpendicular a otro.

Un coseno disfrazado de suma de productos

El producto escalar de dos vectores es la suma de los productos de sus componentes. Tres multiplicaciones y dos sumas para vectores de tres dimensiones. Eso es toda la definición algebraica, y no sugiere absolutamente nada geométrico.

La identidad que lo cambia todo es que ese mismo número vale |a| |b| cos θ, donde theta es el ángulo entre ambos. La demostración sale del teorema del coseno, pero lo que hay que memorizar es la consecuencia: si los dos vectores son unitarios, el producto escalar es exactamente el coseno del ángulo. Un número entre menos uno y uno que mide alineación.

Con esa lectura, la tabla de significados es inmediata:

Producto escalar de dos unitarios Ángulo Interpretación
1 0 grados Misma dirección
0.7 45 grados Bastante alineados
0 90 grados Perpendiculares
-0.7 135 grados Casi opuestos
-1 180 grados Opuestos

Y con ella, el uso más frecuente de todos: el signo es una prueba de semiespacio. Positivo significa que el segundo vector cae en el hemisferio del primero; negativo, en el contrario; cero, justo en el plano perpendicular. Casi todas las pruebas geométricas de una escena se reducen a eso.

¿Está el objeto delante o detrás de la cámara? Resta las posiciones y compara con la dirección de la cámara. ¿Mira el enemigo hacia el jugador? El mismo cálculo. ¿Está la cara de un triángulo orientada hacia nosotros? Producto escalar de la normal con la dirección de vista. ¿Está el punto dentro del cono de una linterna? Producto escalar con el eje del cono comparado con el coseno del ángulo de apertura, calculado una sola vez.

import * as THREE from 'three';

const _haciaObjeto = new THREE.Vector3();
const _direccionCamara = new THREE.Vector3();
const _posicionObjeto = new THREE.Vector3();
const _posicionCamara = new THREE.Vector3();

/** Devuelve true si el objeto esta en el hemisferio frontal de la camara. */
export function estaDelante(camara, objeto) {
  camara.getWorldDirection(_direccionCamara);           // ya viene normalizada
  camara.getWorldPosition(_posicionCamara);
  objeto.getWorldPosition(_posicionObjeto);
  _haciaObjeto.subVectors(_posicionObjeto, _posicionCamara);
  return _direccionCamara.dot(_haciaObjeto) > 0;
}

/** Devuelve true si el objeto cae dentro de un cono de apertura dada. */
export function estaEnElCono(origen, ejeUnitario, punto, aperturaEnGrados) {
  const cosApertura = Math.cos(THREE.MathUtils.degToRad(aperturaEnGrados));
  _haciaObjeto.subVectors(punto, origen).normalize();
  return ejeUnitario.dot(_haciaObjeto) >= cosApertura;
}

Observa que en la segunda función el coseno se calcula una vez y la comparación es directa entre productos escalares. Nunca conviertas a ángulos para comparar ángulos: comparar cosenos es más rápido y más estable numéricamente.

De dónde sale la iluminación difusa

La ley de Lambert es la aplicación más importante del producto escalar y merece una derivación, no una fórmula.

Imagina una linterna que emite un haz de luz de sección cuadrada, un centímetro por un centímetro. Si apuntas perpendicular a una mesa, ese haz ilumina exactamente un centímetro cuadrado. Si inclinas la linterna cuarenta y cinco grados, el mismo haz —la misma energía— se reparte sobre una elipse más grande. La energía por unidad de superficie ha bajado, y el factor por el que baja es precisamente el coseno del ángulo entre la dirección de la luz y la normal de la mesa.

Esa es toda la ley: el brillo difuso de una superficie es proporcional al coseno del ángulo entre su normal y la dirección hacia la luz. No es una aproximación empírica, es geometría de reparto de energía. Y como el coseno de dos unitarios es el producto escalar, la fórmula final es un dot.

Falta un detalle: cuando el ángulo pasa de noventa grados, el coseno se vuelve negativo. Geométricamente significa que la luz está detrás de la superficie, así que la contribución debe ser cero, no negativa. De ahí el recorte a cero que aparece en todos los shaders difusos del mundo.

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(
  50, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 0, 4);

const material = new THREE.ShaderMaterial({
  uniforms: {
    uDireccionLuz: { value: new THREE.Vector3(0.6, 0.8, 0.4).normalize() },
    uColorLuz: { value: new THREE.Color(0xfff4d6) },
    uColorBase: { value: new THREE.Color(0x89b4fa) },
  },
  vertexShader: `
    varying vec3 vNormalVista;
    void main() {
      vNormalVista = normalize(normalMatrix * normal);
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform vec3 uDireccionLuz;
    uniform vec3 uColorLuz;
    uniform vec3 uColorBase;
    varying vec3 vNormalVista;

    void main() {
      vec3 n = normalize(vNormalVista);
      // La direccion de la luz llega en espacio de mundo; hay que llevarla
      // al mismo espacio que la normal. viewMatrix la rota sin trasladarla.
      vec3 l = normalize((viewMatrix * vec4(uDireccionLuz, 0.0)).xyz);

      // Ley de Lambert: coseno recortado a cero.
      float difusa = max(dot(n, l), 0.0);

      vec3 color = uColorBase * (0.08 + difusa * 1.0) * uColorLuz;
      gl_FragColor = vec4(color, 1.0);
    }
  `,
});

const malla = new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.3, 200, 32), material);
scene.add(malla);

renderer.setAnimationLoop(() => {
  malla.rotation.y += 0.005;
  renderer.render(scene, camera);
});

El cuarto componente cero en vec4(uDireccionLuz, 0.0) no es decorativo: marca ese vector como dirección y anula la parte de traslación de la matriz. Con un uno ahí, la luz se movería con la cámara de una forma que no tiene ningún sentido físico. Es la distinción entre punto y dirección haciéndose visible.

Nunca calcules un ángulo si lo que necesitas es una comparación

La función angleTo existe y es cómoda, pero convertir un producto escalar en un ángulo con arcocoseno es una de las peores ideas numéricas del 3D, y conviene entender por qué. El arcocoseno tiene pendiente infinita en sus extremos: cerca de menos uno y de uno, un cambio minúsculo en la entrada produce un cambio enorme en la salida. Como los productos escalares de vectores casi paralelos viven exactamente ahí, el error de coma flotante se amplifica de forma brutal justo en el caso que más te interesa. Y hay un problema todavía más burdo: por redondeo, el producto escalar de dos vectores unitarios puede salir 1.0000001, que está fuera del dominio del arcocoseno y devuelve un valor no numérico que contamina todo lo que toca. Three.js recorta el argumento al intervalo válido antes de llamar al arcocoseno precisamente por esto, así que no explotará, pero la pérdida de precisión sigue ahí. Las dos reglas prácticas: si vas a comparar ángulos, compara cosenos y no conviertas nunca; y si de verdad necesitas el ángulo numérico, calcúlalo con el arcotangente de dos argumentos usando la magnitud del producto vectorial como componente perpendicular y el producto escalar como componente paralela, que es estable en todo el rango. Este detalle no importa hasta que estás depurando una cámara que da un tirón cuando el objetivo se alinea con el eje vertical, y entonces importa mucho.

Proyección: descomponer un vector en dos

El producto escalar también responde a «cuánto de este vector va en esta dirección». Si u es unitario, a · u es la longitud con signo de la proyección de a sobre u, y multiplicar ese número por u da el vector proyectado. Restarlo del original da la componente perpendicular.

Esa descomposición en parte paralela y parte perpendicular resuelve una lista larga de problemas prácticos.

Deslizar contra una pared. Si un personaje choca con una superficie de normal n, la parte de su velocidad que va contra la pared es la componente paralela a n; restarla deja un movimiento que resbala en lugar de detenerse en seco. Es el algoritmo de colisión más simple que produce una sensación decente.

Rebotar. Un reflejo es lo mismo pero restando el doble: se invierte la componente perpendicular a la superficie y se conserva la tangencial.

Movimiento relativo a la cámara. Para que la tecla de avanzar mueva al personaje hacia donde mira la cámara pero sin volar, se toma la dirección de la cámara, se elimina su componente vertical proyectándola sobre el plano horizontal, y se normaliza el resto.

Alinear una fuerza con un eje. Cualquier restricción de movimiento sobre un raíl es una proyección.

Three.js implementa las tres operaciones con nombres directos: projectOnVector para la componente paralela, projectOnPlane para la perpendicular a una normal, y reflect para el rebote.

import * as THREE from 'three';

const velocidad = new THREE.Vector3(2, -1, 0.5);
const normalPared = new THREE.Vector3(1, 0, 0);   // ya unitaria

// Deslizar: quitar la componente que empuja contra la pared.
const deslizamiento = velocidad.clone().projectOnPlane(normalPared);
console.log('velocidad al deslizar:', deslizamiento);   // (0, -1, 0.5)

// Rebotar: invertir esa misma componente en lugar de eliminarla.
const rebote = velocidad.clone().reflect(normalPared);
console.log('velocidad al rebotar:', rebote);           // (-2, -1, 0.5)

// Movimiento horizontal relativo a la camara.
const camara = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camara.position.set(0, 5, 8);
camara.lookAt(0, 0, 0);

const adelante = new THREE.Vector3();
camara.getWorldDirection(adelante);
adelante.projectOnPlane(new THREE.Vector3(0, 1, 0)).normalize();
console.log('avanzar sin volar:', adelante);

Cinco operaciones aritméticas, y de ellas salen la iluminación difusa, el descarte de caras traseras, las pruebas de frustum, el cono de un foco, el deslizamiento contra paredes, el rebote y el movimiento en tercera persona. Ninguna otra operación del 3D tiene esa relación entre coste y alcance. La que más se le acerca es su compañera, el producto vectorial.