Rasterización: del espacio de recorte a los fragmentos
Recorte, división perspectiva, transformación de viewport, cobertura por bloques de dos por dos e interpolación con corrección de perspectiva, y qué cuesta cada cosa.
La rasterización es la etapa que menos se explica y la que más consecuencias tiene. Es donde el triángulo deja de ser tres puntos y se convierte en una lista de fragmentos con atributos interpolados; es donde se descartan las caras traseras, donde se decide qué es visible antes de sombrear, y donde nace el coste que hace que un modelo de dos millones de triángulos rinda peor de lo que sus triángulos sugieren.
- Explicar por qué el recorte tiene que ocurrir antes de la división perspectiva.
- Describir la transformación de viewport y qué convención de coordenadas usa.
- Justificar por qué la interpolación necesita corrección de perspectiva.
- Predecir el coste de los triángulos pequeños a partir del bloque de dos por dos.
Recortar antes de dividir, y por qué ese orden es obligatorio
El vertex shader entrega posiciones en espacio de recorte, que son coordenadas de cuatro componentes. Para llegar a coordenadas normalizadas hay que dividir las tres primeras por la cuarta. Esa división es lo que produce la perspectiva: lo lejano tiene una cuarta componente grande y encoge.
El problema es que esa componente puede ser cero o negativa. Cero para un punto en el plano de la cámara; negativa para un punto detrás. Dividir por cero produce infinito y dividir por un número negativo invierte el signo de las coordenadas, lo que coloca un vértice de detrás de la cámara en el lado opuesto de la pantalla. Si un triángulo cruza el plano de la cámara y dejas que la división actúe sobre sus tres vértices, obtienes un triángulo con un vértice teletransportado y una figura absurda que se dibuja atravesando media pantalla.
Por eso el recorte va antes. El hardware corta cada triángulo contra los planos del volumen de visión —los seis del frustum, más los planos de recorte adicionales si los hay— y genera triángulos nuevos cuyos vértices están todos en la zona segura. Un triángulo recortado por un plano puede convertirse en dos; ese es el único caso del pipeline en el que la geometría aumenta sin que nadie lo pida.
Después de recortar, la división es segura. El resultado son coordenadas normalizadas de dispositivo, un espacio donde el volumen visible es un cubo. En WebGL, ese cubo va de menos uno a uno en los tres ejes. En WebGPU, la profundidad va de cero a uno; Three.js abstrae la diferencia, pero conviene saberla porque aparece al escribir shaders que manipulan profundidad.
Por último, la transformación de viewport escala esas coordenadas normalizadas al tamaño real en píxeles del destino. Aquí se decide también dónde está el origen: en las coordenadas normalizadas, el eje vertical crece hacia arriba, al contrario que en casi todas las APIs de dibujo 2D de la web. Esa inversión es el origen de una familia entera de errores al mezclar coordenadas de ratón con coordenadas de escena, y se resuelve en la lección sobre cambiar de espacio.
Cobertura, orientación y bloques de dos por dos
Con el triángulo ya en coordenadas de pantalla, el rasterizador hace tres cosas.
Decide la orientación. Calcula el área con signo del triángulo proyectado. Si el signo indica que estás viendo la cara de atrás, y el descarte de caras traseras está activo, el triángulo se elimina sin generar un solo fragmento. Es una optimización enorme —en un objeto cerrado, la mitad de los triángulos son traseros— y depende por completo del orden en que estén escritos los vértices. Three.js usa la convención de que el orden antihorario es la cara frontal, y material.side controla qué se descarta: THREE.FrontSide por defecto, THREE.BackSide, o THREE.DoubleSide para no descartar nada.
Determina la cobertura. Para cada píxel candidato, evalúa si el centro del píxel cae dentro del triángulo. El hardware lo hace por bloques jerárquicos, descartando regiones enteras de golpe antes de bajar a la prueba por píxel.
Genera fragmentos en bloques de dos por dos. Y aquí está el detalle que hay que memorizar. La unidad mínima de trabajo del rasterizador no es un píxel, es un cuadrado de cuatro. Siempre. Aunque el triángulo cubra un solo píxel de ese bloque, se generan las cuatro invocaciones del fragment shader; las tres que no están cubiertas se llaman invocaciones auxiliares, se ejecutan igualmente y se descarta su resultado.
La razón es que el fragment shader necesita derivadas. Para saber cuánto cambia una coordenada de textura de un píxel al siguiente —y con ello elegir el nivel de mipmap, que es lo que evita el ruido en superficies lejanas— el hardware resta el valor de invocaciones vecinas. Sin las cuatro invocaciones no hay resta posible.
De la regla del bloque de dos por dos sale el fenómeno de rendimiento que más desconcierta al llegar de la teoría: un modelo con triángulos diminutos desperdicia trabajo de forma masiva. Si un triángulo cubre un solo píxel, el hardware ejecuta cuatro invocaciones del fragment shader y aprovecha una: eficiencia del veinticinco por ciento. Cuando toda una malla llega a esa escala —un personaje muy detallado a lo lejos, un follaje con hojas de pocos píxeles, un modelo escaneado sin simplificar— el coste real de sombreado puede ser tres o cuatro veces el que sugieren los píxeles cubiertos, y ningún contador de la aplicación lo muestra. El síntoma es característico y engañoso: la escena va más lenta cuanto más te alejas, justo al revés de lo que dicta la intuición, porque al alejarte los triángulos encogen pero siguen siendo los mismos. Es la razón técnica por la que los niveles de detalle no son una optimización de memoria sino de sombreado, y por la que la regla de oro de los artistas técnicos es que ningún triángulo debería bajar de unos pocos píxeles de área en pantalla. También explica por qué el follaje con recorte alfa es el peor caso posible: triángulos pequeños, invocaciones auxiliares desperdiciadas y descarte de fragmentos que además desactiva la prueba de profundidad temprana.
Interpolación con corrección de perspectiva
Cada fragmento recibe los valores que el vertex shader dejó en las variables interpoladas, mezclados según su posición dentro del triángulo. La mezcla usa coordenadas baricéntricas: tres pesos que suman uno y que indican cuánto pesa cada vértice.
Pero interpolar linealmente en pantalla es incorrecto en perspectiva. Un plano inclinado que se aleja de la cámara ocupa cada vez menos píxeles por unidad de superficie; un avance constante en pantalla corresponde a un avance creciente sobre la superficie. Si interpolas las coordenadas de textura linealmente en pantalla, la textura se deforma con un pliegue característico en la diagonal que parte cada cuadrilátero en dos triángulos. Quien haya visto una consola de los noventa reconoce el efecto al instante: aquellas máquinas no tenían corrección de perspectiva.
La corrección consiste en interpolar los atributos divididos por la cuarta componente, interpolar también el inverso de esa componente, y dividir al final. Es matemáticamente exacto y el hardware lo hace por defecto en todos los atributos.
Hay dos casos en los que quieres desactivarlo o modificarlo, y ambos tienen palabra clave propia en el lenguaje de shaders: un atributo declarado plano no se interpola en absoluto y toma el valor de un vértice concreto, que es lo que quieres para identificadores o índices enteros; y la interpolación sin perspectiva existe para valores que se definen en el espacio de la pantalla y no sobre la superficie.
Antes o después del shader: la prueba de profundidad temprana
La especificación dice que la prueba de profundidad ocurre después del fragment shader. Tiene sentido, porque el shader puede modificar la profundidad o decidir que el fragmento no existe.
Ejecutar el shader para luego tirar el resultado es un desperdicio evidente, así que todo el hardware moderno hace la prueba antes cuando puede demostrar que el resultado será idéntico. Se llama prueba de profundidad temprana, y es una de las optimizaciones más importantes que existen: en una escena con mucho solapamiento, evita sombrear la mayoría de los fragmentos ocultos.
La condición para que el hardware pueda adelantarla es que el shader no haga nada que invalide la suposición. Se desactiva si el shader escribe la profundidad, si descarta fragmentos explícitamente, o si usa una prueba alfa, porque en todos esos casos el resultado del shader determina si el fragmento existe.
Esta es una de esas reglas que en la práctica decide arquitecturas enteras. Un material con recorte alfa —una hoja, una valla, un letrero con transparencia binaria— desactiva la prueba temprana para toda la superficie que dibuja, y en una escena con vegetación densa eso puede multiplicar el coste. La contramedida clásica es dibujar primero todo lo opaco de verdad, para que el búfer de profundidad esté lo más lleno posible cuando lleguen los materiales problemáticos.
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();
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 0, 3);
// Un plano visto casi de canto: el caso donde la correccion de perspectiva se nota.
const textura = new THREE.TextureLoader().load(
'https://threejs.org/examples/textures/uv_grid_opengl.jpg'
);
textura.colorSpace = THREE.SRGBColorSpace;
const suelo = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20, 1, 1),
new THREE.MeshBasicMaterial({ map: textura, side: THREE.DoubleSide })
);
suelo.rotation.x = -Math.PI / 2;
suelo.position.y = -0.8;
scene.add(suelo);
// side controla el descarte de caras traseras: FrontSide descarta la mitad
// de los triangulos de un objeto cerrado sin generar un solo fragmento.
const esfera = new THREE.Mesh(
new THREE.SphereGeometry(0.8, 48, 24),
new THREE.MeshBasicMaterial({ map: textura, side: THREE.FrontSide })
);
scene.add(esfera);
renderer.setAnimationLoop(() => {
esfera.rotation.y += 0.004;
renderer.render(scene, camera);
// Triangulos enviados tras el descarte por frustum. El descarte de caras
// traseras ocurre mas tarde, en el rasterizador, y no aparece en este contador.
if (renderer.info.render.frame % 120 === 0) {
console.log('triangulos enviados:', renderer.info.render.triangles);
}
});
El plano de ese ejemplo tiene un solo cuadrilátero, es decir dos triángulos, y aun así la rejilla se ve recta hasta el horizonte. Ese es el trabajo de la corrección de perspectiva; sin ella, la diagonal que separa los dos triángulos sería visible como un pliegue en la textura.