43. Álgebra lineal aplicada a videojuegos

Los vectores y matrices modelan movimiento, fuerzas, colisiones, orientación, cámaras y jerarquías. Una mecánica de juego suele ser una combinación de operaciones lineales actualizadas en el tiempo.

43.1 Introducción

Un videojuego representa el estado de sus objetos mediante números: posiciones, velocidades, aceleraciones, orientaciones y escalas. Los vectores reúnen esas componentes y las matrices permiten cambiar entre sistemas de coordenadas.

El álgebra lineal aparece tanto en la imagen final como en la lógica que decide cómo se mueven e interactúan los objetos.

Estado + entrada + tiempo ⟶ operaciones vectoriales ⟶ nuevo estado

43.2 Posición, velocidad y aceleración

La posición indica dónde está un objeto, la velocidad cuánto cambia su posición por unidad de tiempo y la aceleración cuánto cambia la velocidad.

velocidadNueva = velocidad + aceleración · Δt
posiciónNueva = posición + velocidadNueva · Δt
function integrar(estado, aceleracion, dt) {
  const velocidad = estado.velocidad.map(
    (valor, i) => valor + aceleracion[i] * dt
  );
  const posicion = estado.posicion.map(
    (valor, i) => valor + velocidad[i] * dt
  );
  return { posicion, velocidad };
}

const estado = { posicion: [0, 0], velocidad: [2, 0] };
console.log(integrar(estado, [0, -9.8], 0.5));

Este esquema se conoce como Euler semiimplícito y suele comportarse mejor que actualizar primero la posición con la velocidad anterior.

43.3 Movimiento independiente de la frecuencia de cuadros

Multiplicar por dt expresa el avance según el tiempo transcurrido, no según la cantidad de cuadros renderizados.

function avanzar(posicion, velocidad, dt) {
  return posicion.map((valor, i) => valor + velocidad[i] * dt);
}

const posicion = [10, 5];
const velocidad = [6, -2]; // unidades por segundo
console.log(avanzar(posicion, velocidad, 1 / 60));

En simulaciones sensibles también se limita un dt anormalmente grande o se utiliza un paso fijo de física.

43.4 Dirección hacia un objetivo

La dirección desde una posición p hasta un objetivo q se obtiene normalizando q − p.

function direccionHacia(posicion, objetivo, tolerancia = 1e-12) {
  const delta = objetivo.map((valor, i) => valor - posicion[i]);
  const distancia = Math.hypot(...delta);
  return distancia <= tolerancia ? null : delta.map(valor => valor / distancia);
}

console.log(direccionHacia([1, 1], [4, 5])); // [0.6, 0.8]

Si ambos puntos coinciden no existe una dirección preferente y debe manejarse el caso por separado.

43.5 Moverse sin sobrepasar el objetivo

Un avance de rapidez constante puede cruzar el objetivo si el paso supera la distancia restante. Se compara primero la longitud del desplazamiento.

function moverHacia(posicion, objetivo, rapidez, dt) {
  const delta = objetivo.map((valor, i) => valor - posicion[i]);
  const distancia = Math.hypot(...delta);
  const paso = Math.max(0, rapidez) * dt;
  if (distancia <= paso || distancia === 0) return [...objetivo];
  return posicion.map((valor, i) => valor + delta[i] / distancia * paso);
}

console.log(moverHacia([0, 0], [3, 4], 2, 1)); // [1.2, 1.6]
console.log(moverHacia([2.8, 4], [3, 4], 2, 1)); // [3, 4]

43.6 Comportamiento de búsqueda

En un comportamiento seek, se calcula una velocidad deseada hacia el objetivo y se resta la velocidad actual para obtener una fuerza de dirección.

fuerza = velocidadDeseada − velocidadActual
function buscar(posicion, velocidad, objetivo, rapidezMaxima) {
  const delta = objetivo.map((valor, i) => valor - posicion[i]);
  const distancia = Math.hypot(...delta);
  if (distancia === 0) return velocidad.map(valor => -valor);
  const deseada = delta.map(valor => valor / distancia * rapidezMaxima);
  return deseada.map((valor, i) => valor - velocidad[i]);
}

console.log(buscar([0, 0], [1, 0], [3, 4], 5)); // [2, 4]

43.7 Limitar velocidad y fuerza

Limitar la magnitud evita aceleraciones o velocidades excesivas sin alterar su dirección.

function limitar(vector, maximo) {
  const longitud = Math.hypot(...vector);
  if (longitud <= maximo || longitud === 0) return [...vector];
  return vector.map(valor => valor / longitud * maximo);
}

console.log(limitar([6, 8], 5)); // [3, 4]

Se suele limitar por separado la fuerza de dirección y la velocidad resultante.

43.8 Colisión entre círculos o esferas

Dos círculos o esferas colisionan si la distancia entre sus centros no supera la suma de sus radios. Puede compararse la distancia al cuadrado para evitar una raíz.

function colisionEsferas(a, radioA, b, radioB) {
  const distanciaCuadrada = a.reduce(
    (suma, valor, i) => suma + (b[i] - valor) ** 2,
    0
  );
  return distanciaCuadrada <= (radioA + radioB) ** 2;
}

console.log(colisionEsferas([0, 0], 2, [3, 0], 1)); // true

43.9 Punto más cercano en una caja

Para comprobar una esfera contra una caja alineada con los ejes, cada coordenada del centro se limita al intervalo de la caja. El resultado es el punto de la caja más cercano.

const limitarValor = (x, minimo, maximo) => Math.max(minimo, Math.min(maximo, x));

function esferaContraCaja(centro, radio, minimo, maximo) {
  const cercano = centro.map(
    (valor, i) => limitarValor(valor, minimo[i], maximo[i])
  );
  const distancia2 = centro.reduce(
    (suma, valor, i) => suma + (valor - cercano[i]) ** 2,
    0
  );
  return distancia2 <= radio ** 2;
}

console.log(esferaContraCaja([3, 1], 1, [0, 0], [2, 2])); // true

43.10 Deslizamiento sobre una superficie

Para eliminar la velocidad que atraviesa una pared, se resta la proyección sobre su normal n:

vdeslizamiento = v − proyn(v)
function deslizar(velocidad, normal) {
  const nn = normal.reduce((suma, valor) => suma + valor ** 2, 0);
  if (nn === 0) return null;
  const factor = velocidad.reduce(
    (suma, valor, i) => suma + valor * normal[i], 0
  ) / nn;
  return velocidad.map((valor, i) => valor - factor * normal[i]);
}

console.log(deslizar([4, -3], [0, 1])); // [4, 0]

43.11 Rebote mediante reflexión

La reflexión respecto de una superficie con normal n se calcula como:

r = v − 2 proyn(v)

Un coeficiente de restitución entre 0 y 1 puede reducir la velocidad después del impacto.

function reflejar(velocidad, normal, restitucion = 1) {
  const nn = normal.reduce((suma, valor) => suma + valor ** 2, 0);
  const factor = 2 * velocidad.reduce(
    (suma, valor, i) => suma + valor * normal[i], 0
  ) / nn;
  return velocidad.map((valor, i) => (valor - factor * normal[i]) * restitucion);
}

console.log(reflejar([3, -4], [0, 1], 0.5)); // [1.5, 2]

43.12 Campo de visión

Un personaje ve un objetivo si la dirección hacia él forma un ángulo suficientemente pequeño con su dirección frontal.

frente · direcciónObjetivo ≥ cos(semiángulo)
function enCampoVisual(frente, haciaObjetivo, semiAnguloGrados) {
  const normalizar = v => {
    const n = Math.hypot(...v);
    return n === 0 ? null : v.map(valor => valor / n);
  };
  const f = normalizar(frente);
  const d = normalizar(haciaObjetivo);
  if (!f || !d) return false;
  const punto = f.reduce((suma, valor, i) => suma + valor * d[i], 0);
  return punto >= Math.cos(semiAnguloGrados * Math.PI / 180);
}

console.log(enCampoVisual([1, 0], [3, 1], 30)); // true

En un juego real también se comprueba distancia y oclusión mediante una consulta de física.

43.13 Orientación y apuntado 2D

Math.atan2(y, x) devuelve el ángulo de una dirección teniendo en cuenta el cuadrante.

function anguloHacia(posicion, objetivo) {
  const dx = objetivo[0] - posicion[0];
  const dy = objetivo[1] - posicion[1];
  return Math.atan2(dy, dx) * 180 / Math.PI;
}

console.log(anguloHacia([0, 0], [1, 1]));  // 45
console.log(anguloHacia([0, 0], [-1, 1])); // 135

Usar solo atan(y/x) pierde información de cuadrante y falla cuando x es cero.

43.14 Cámara con seguimiento suave

Una interpolación exponencial acerca la cámara al objetivo con comportamiento similar para distintas frecuencias de cuadros.

function seguirSuave(camara, objetivo, rapidez, dt) {
  const t = 1 - Math.exp(-rapidez * dt);
  return camara.map((valor, i) => valor + (objetivo[i] - valor) * t);
}

console.log(seguirSuave([0, 0], [10, 5], 4, 1 / 60));

La interpolación lineal con un factor fijo por cuadro depende de la frecuencia; el factor exponencial incorpora el tiempo.

43.15 Coordenadas locales y mundiales

Un punto local de un objeto se convierte a mundo con su matriz de modelo. La inversa permite expresar un punto mundial en coordenadas locales.

function aplicar(M, v) {
  return M.map(fila => fila.reduce((suma, valor, i) => suma + valor * v[i], 0));
}

const modelo = [
  [0, -1, 10],
  [1,  0,  5],
  [0,  0,  1]
];
console.log(aplicar(modelo, [2, 0, 1])); // [10, 7, 1]

43.16 Intersección de un rayo con un plano

Un rayo p(t) = o + td intersecta el plano n · p = k cuando:

t = (k − n · o)/(n · d)
function rayoPlano(origen, direccion, normal, k, tolerancia = 1e-10) {
  const denominador = normal.reduce(
    (suma, valor, i) => suma + valor * direccion[i], 0
  );
  if (Math.abs(denominador) <= tolerancia) return null;
  const no = normal.reduce((suma, valor, i) => suma + valor * origen[i], 0);
  const t = (k - no) / denominador;
  if (t < 0) return null;
  return origen.map((valor, i) => valor + t * direccion[i]);
}

console.log(rayoPlano([0, 5, 0], [1, -1, 0], [0, 1, 0], 0));
// [5, 0, 0]

43.17 Jerarquías y esqueletos

Un arma unida a una mano, una rueda unida a un vehículo o un hueso unido a otro heredan la transformación del padre.

MmundoHijo = MmundoPadreMlocalHijo
const multiplicar = (A, B) => A.map(fila => B[0].map((_, j) =>
  fila.reduce((suma, valor, k) => suma + valor * B[k][j], 0)
));

const personaje = [[1, 0, 100], [0, 1, 50], [0, 0, 1]];
const manoLocal = [[1, 0, 20], [0, 1, 10], [0, 0, 1]];
const armaLocal = [[1, 0, 8], [0, 1, 0], [0, 0, 1]];
const armaMundo = multiplicar(multiplicar(personaje, manoLocal), armaLocal);

console.log(armaMundo);

43.18 Rendimiento, estabilidad y errores frecuentes

En un bucle de juego se reutilizan vectores temporales, se comparan distancias al cuadrado cuando es posible y se separa el paso de física del renderizado.

ErrorConsecuencia
Omitir dtMovimiento dependiente de FPS
Normalizar el vector ceroNaN en el estado
Usar normales no unitarias sin ajustar la fórmulaRebotes o proyecciones incorrectas
Mezclar coordenadas locales y mundialesPosiciones y colisiones desplazadas
Usar igualdad exacta con decimalesEstados que nunca convergen

También deben limitarse pasos de tiempo extremos, evitar acumulación de error y documentar unidades, ejes y convenciones.

43.19 Qué debes recordar de este tema

  • Posición, velocidad y aceleración se actualizan mediante vectores y tiempo.
  • La dirección a un objetivo es la normalización de objetivo − posición.
  • Limitar magnitudes conserva dirección y controla el movimiento.
  • Las distancias al cuadrado aceleran pruebas simples de colisión.
  • Restar la proyección normal produce deslizamiento.
  • Restar dos veces la proyección normal produce reflexión.
  • El producto escalar permite comprobar campos de visión.
  • Las matrices conectan coordenadas locales, mundiales y jerarquías.

43.20 Conclusión

Las mecánicas de videojuegos convierten intenciones como perseguir, rebotar, mirar o seguir en operaciones con vectores y matrices. Separar dirección, magnitud, espacio de coordenadas y tiempo produce código más consistente.

En el próximo tema aplicaremos el álgebra lineal a imágenes digitales, filtros y transformaciones de color.