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.
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.
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.
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.
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.
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.
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]
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.
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]
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.
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
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
Para eliminar la velocidad que atraviesa una pared, se resta la proyección sobre su normal n:
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]
La reflexión respecto de una superficie con normal n se calcula como:
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]
Un personaje ve un objetivo si la dirección hacia él forma un ángulo suficientemente pequeño con su dirección frontal.
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.
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.
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.
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]
Un rayo p(t) = o + td intersecta el plano n · p = k cuando:
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]
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.
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);
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.
| Error | Consecuencia |
|---|---|
Omitir dt | Movimiento dependiente de FPS |
| Normalizar el vector cero | NaN en el estado |
| Usar normales no unitarias sin ajustar la fórmula | Rebotes o proyecciones incorrectas |
| Mezclar coordenadas locales y mundiales | Posiciones y colisiones desplazadas |
| Usar igualdad exacta con decimales | Estados que nunca convergen |
También deben limitarse pasos de tiempo extremos, evitar acumulación de error y documentar unidades, ejes y convenciones.
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.