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.
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.
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.
| Espacio | Qué representa |
|---|---|
| Local o modelo | Vértices respecto del origen propio del objeto |
| Mundo | Objetos colocados en una escena común |
| Vista o cámara | Escena expresada respecto de la cámara |
| Clip | Resultado homogéneo de la proyección |
| NDC | Coordenadas normalizadas tras dividir por w |
| Pantalla | Coordenadas expresadas en píxeles |
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]
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.
En una jerarquía, la transformación mundial de un hijo combina la matriz de su padre con su matriz local:
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
La matriz de modelo 4 × 4 transforma posiciones locales a coordenadas mundiales. Suele combinar escala, rotación y traslación:
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]
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]
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.
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:
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]
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.
Después de la proyección se dividen xc, yc y zc por wc para obtener coordenadas normalizadas del dispositivo (NDC).
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]
Con vectores columna, el recorrido principal se combina como:
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
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:
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
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]
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.
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
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:
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]));
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:
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.