42. Álgebra lineal aplicada a gráficos 2D y 3D

Los gráficos convierten vértices en píxeles mediante vectores, matrices y cambios de coordenadas. El recorrido completo combina transformaciones de modelo, cámara, proyección y pantalla.

42.1 Introducción

Una escena gráfica está formada por puntos, líneas, triángulos, colores y texturas. El álgebra lineal permite posicionar esos datos, observarlos desde una cámara y proyectarlos sobre una pantalla bidimensional.

Las GPU realizan millones de operaciones con vectores y matrices en paralelo. Comprender esas operaciones ayuda a interpretar tanto gráficos 2D como renderizado 3D.

Geometría local ⟶ mundo ⟶ cámara ⟶ proyección ⟶ pantalla

42.2 Geometría como vectores

Una malla almacena vértices. Cada vértice puede contener posición, normal, color y coordenadas de textura.

const vertices = [
  { posicion: [-1, -1, 0], normal: [0, 0, 1], uv: [0, 0] },
  { posicion: [ 1, -1, 0], normal: [0, 0, 1], uv: [1, 0] },
  { posicion: [ 0,  1, 0], normal: [0, 0, 1], uv: [0.5, 1] }
];

console.log(vertices.length); // 3 vértices forman un triángulo

Los índices conectan vértices para formar triángulos, que son la unidad geométrica habitual del renderizado.

42.3 Espacios de coordenadas

EspacioQué representa
Local o modeloVértices respecto del origen propio del objeto
MundoObjetos colocados en una escena común
Vista o cámaraEscena expresada respecto de la cámara
ClipResultado homogéneo de la proyección
NDCCoordenadas normalizadas tras dividir por w
PantallaCoordenadas expresadas en píxeles

42.4 Transformaciones 2D

En 2D, una matriz homogénea 3 × 3 puede reunir la posición, rotación y escala de un objeto. Cada vértice local (x, y, 1) se transforma al espacio del mundo.

function aplicar3x3(M, [x, y, w = 1]) {
  const v = [x, y, w];
  return M.map(fila =>
    fila.reduce((suma, valor, i) => suma + valor * v[i], 0)
  );
}

const modelo = [[2, 0, 100], [0, 2, 50], [0, 0, 1]];
console.log(aplicar3x3(modelo, [10, 5, 1])); // [120, 60, 1]

42.5 Del plano cartesiano a la pantalla

En una pantalla, el origen suele estar arriba a la izquierda y el eje y crece hacia abajo. Para dibujar coordenadas cartesianas se traslada el origen y se invierte y.

function cartesianoAPantalla([x, y], ancho, alto, escala = 1) {
  return [ancho / 2 + x * escala, alto / 2 - y * escala];
}

console.log(cartesianoAPantalla([3, 2], 800, 600, 20)); // [460, 260]

Las interfaces y bibliotecas pueden usar otras convenciones; lo importante es convertirlas explícitamente.

42.6 Jerarquías y transformaciones locales

En una jerarquía, la transformación mundial de un hijo combina la matriz de su padre con su matriz local:

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 padre = [[1, 0, 100], [0, 1, 50], [0, 0, 1]];
const hijoLocal = [[1, 0, 20], [0, 1, 0], [0, 0, 1]];
console.log(multiplicar(padre, hijoLocal)); // hijo trasladado a x = 120

42.7 Matriz de modelo 3D

La matriz de modelo 4 × 4 transforma posiciones locales a coordenadas mundiales. Suele combinar escala, rotación y traslación:

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

const M = [
  [2, 0, 0, 5],
  [0, 2, 0, 1],
  [0, 0, 2, -3],
  [0, 0, 0, 1]
];
console.log(aplicar4x4(M, [1, 2, 3, 1])); // [7, 5, 3, 1]

42.8 Matriz de vista

La matriz de vista transforma el mundo al sistema de la cámara. Es la inversa de la transformación mundial de la cámara.

Si la cámara no está rotada y se encuentra en c = (cx, cy, cz), la vista traslada por −c.

function aEspacioCamara([x, y, z], [cx, cy, cz]) {
  return [x - cx, y - cy, z - cz];
}

const puntoMundo = [7, 3, -10];
const camara = [2, 1, 0];
console.log(aEspacioCamara(puntoMundo, camara)); // [5, 2, -10]

42.9 Proyección ortográfica

La proyección ortográfica no reduce el tamaño de los objetos lejanos. En una vista sencilla, x e y se escalan al intervalo normalizado sin dividir por profundidad.

function proyectarOrtografica([x, y, z], anchoVista, altoVista) {
  return [2 * x / anchoVista, 2 * y / altoVista, z];
}

console.log(proyectarOrtografica([4, 3, -10], 8, 6)); // [1, 1, -10]

Es útil en interfaces, planos técnicos, mapas y juegos con aspecto isométrico.

42.10 Proyección en perspectiva

En perspectiva, los objetos se ven más pequeños al aumentar su distancia. Para una cámara que mira hacia −z, una forma simplificada es:

x' = f x/(−z),   y' = f y/(−z)

f depende del campo de visión. La división por profundidad produce el efecto de perspectiva.

function perspectivaSimple([x, y, z], focal = 1) {
  if (z >= 0) return null;
  return [focal * x / -z, focal * y / -z];
}

console.log(perspectivaSimple([2, 1, -5]));  // [0.4, 0.2]
console.log(perspectivaSimple([2, 1, -10])); // [0.2, 0.1]

42.11 Matriz de perspectiva

Una matriz de perspectiva genera coordenadas de clip (xc, yc, zc, wc). Esta versión usa una convención habitual de mano derecha y profundidad de estilo OpenGL:

function matrizPerspectiva(fovYGrados, aspecto, cercano, lejano) {
  const f = 1 / Math.tan(fovYGrados * Math.PI / 360);
  return [
    [f / aspecto, 0, 0, 0],
    [0, f, 0, 0],
    [0, 0, (lejano + cercano) / (cercano - lejano),
      2 * lejano * cercano / (cercano - lejano)],
    [0, 0, -1, 0]
  ];
}

console.log(matrizPerspectiva(90, 16 / 9, 0.1, 100));

Los signos y el intervalo de profundidad varían entre API; no deben mezclarse matrices de convenciones diferentes.

42.12 División por perspectiva

Después de la proyección se dividen xc, yc y zc por wc para obtener coordenadas normalizadas del dispositivo (NDC).

(xndc, yndc, zndc) = (xc/wc, yc/wc, zc/wc)
function dividirPerspectiva([x, y, z, w]) {
  if (w === 0) return null;
  return [x / w, y / w, z / w];
}

console.log(dividirPerspectiva([2, 1, 3, 5])); // [0.4, 0.2, 0.6]

42.13 Matriz modelo-vista-proyección

Con vectores columna, el recorrido principal se combina como:

pclip = PVMplocal

M se aplica primero, luego V y finalmente P. La GPU puede recibir una única matriz MVP por objeto.

function multiplicar(A, B) {
  return A.map(fila => B[0].map((_, j) =>
    fila.reduce((suma, valor, k) => suma + valor * B[k][j], 0)
  ));
}

// Las tres matrices serían 4 × 4 en una escena real.
const I = [[1, 0], [0, 1]];
const MVP = multiplicar(multiplicar(I, I), I);
console.log(MVP); // identidad en este ejemplo mínimo

42.14 Recorte y coordenadas NDC

Antes o durante la división por perspectiva, la tubería descarta o recorta primitivas fuera del volumen visible. En la convención simétrica de clip:

−w ≤ x ≤ w,   −w ≤ y ≤ w,   −w ≤ z ≤ w
function dentroDelClip([x, y, z, w]) {
  return w > 0 && Math.abs(x) <= w && Math.abs(y) <= w && Math.abs(z) <= w;
}

console.log(dentroDelClip([0.5, -0.2, 0.4, 1])); // true
console.log(dentroDelClip([2, 0, 0, 1]));        // false

42.15 Transformación de viewport

El viewport convierte x e y desde el intervalo NDC [−1, 1] a píxeles. Para una pantalla con origen superior izquierdo:

function ndcAPantalla([x, y], ancho, alto) {
  return [(x + 1) * ancho / 2, (1 - y) * alto / 2];
}

console.log(ndcAPantalla([0, 0], 800, 600));   // [400, 300]
console.log(ndcAPantalla([1, 1], 800, 600));   // [800, 0]
console.log(ndcAPantalla([-1, -1], 800, 600)); // [0, 600]

42.16 Normales e iluminación

Una normal unitaria describe la orientación de una superficie. En iluminación difusa, el producto escalar con la dirección unitaria hacia la luz mide cuánto recibe la superficie.

intensidad = max(0, n · l)
function iluminacionDifusa(normal, haciaLuz) {
  const producto = normal.reduce(
    (suma, valor, i) => suma + valor * haciaLuz[i],
    0
  );
  return Math.max(0, producto);
}

console.log(iluminacionDifusa([0, 0, 1], [0, 0.6, 0.8])); // 0.8

42.17 Transformación correcta de normales

Una normal no se transforma como una posición. Si la matriz de modelo contiene escalado no uniforme, debe usarse la inversa transpuesta de su parte lineal:

n' = normalizar((M-1)Tn)
function normalBajoEscala(normal, [sx, sy, sz]) {
  if ([sx, sy, sz].some(s => s === 0)) return null;
  const transformada = [normal[0] / sx, normal[1] / sy, normal[2] / sz];
  const longitud = Math.hypot(...transformada);
  return transformada.map(valor => valor / longitud);
}

console.log(normalBajoEscala([1, 1, 0], [2, 1, 1]));

42.18 Profundidad, rendimiento y errores frecuentes

La coordenada de profundidad permite que un búfer z conserve el fragmento visible más cercano. La interpolación, el recorte y las pruebas de profundidad evitan dibujar superficies ocultas de forma incorrecta.

En rendimiento, se combinan matrices cuando es posible y se aprovechan operaciones vectorizadas de la GPU.

Errores habituales:

  • Multiplicar modelo, vista y proyección en orden incorrecto.
  • Mezclar vectores fila y columna.
  • Combinar convenciones de profundidad o sistemas de mano distintos.
  • Olvidar la división por w.
  • Transformar normales como posiciones.
  • Usar una matriz de modelo singular cuando se necesita su normal inversa.
  • Confundir coordenadas NDC con píxeles.

42.19 Qué debes recordar de este tema

  • Los vértices recorren espacios local, mundo, cámara, clip, NDC y pantalla.
  • La matriz de modelo coloca el objeto en el mundo.
  • La matriz de vista expresa la escena respecto de la cámara.
  • La proyección ortográfica no reduce objetos lejanos.
  • La perspectiva divide por profundidad mediante la coordenada w.
  • Con vectores columna, pclip = PVMplocal.
  • El viewport transforma NDC en píxeles.
  • Las normales requieren la inversa transpuesta bajo transformaciones generales.

42.20 Conclusión

La tubería gráfica es una sucesión de cambios de coordenadas. Las matrices permiten mover objetos y cámaras, mientras que la proyección y el viewport convierten una escena tridimensional en píxeles.

En el próximo tema aplicaremos estas mismas herramientas a movimiento, colisiones, cámaras y mecánicas de videojuegos.