Fragment shader, pruebas por fragmento y mezcla
La última mitad del pipeline: qué produce el fragment shader, qué pruebas decide el hardware después, y por qué la mezcla obliga a ordenar y hace cara la transparencia.
Qué produce el fragment shader y qué no decide
El fragment shader se ejecuta una vez por fragmento y produce un color, opcionalmente varios si escribes en múltiples destinos. Puede además escribir la profundidad, aunque hacerlo tiene el coste de desactivar la prueba temprana. Y puede descartar el fragmento, con el mismo coste.
Lo que no hace es decidir si su resultado se ve. Eso lo deciden las pruebas posteriores, y hay que separar mentalmente ambas cosas: el shader calcula un color candidato; el backend del pipeline decide su destino.
Un detalle que sorprende: el fragment shader no sabe qué hay debajo. No puede leer el píxel que ya estaba en el framebuffer. Esa prohibición no es caprichosa, es lo que permite que miles de fragmentos se ejecuten en paralelo sin sincronización. La mezcla existe precisamente porque el shader no puede hacerla: es una operación de función fija en unidades que sí tienen acceso ordenado al destino.
Las pruebas, en su orden
Después del shader, cada fragmento pasa por una secuencia fija. Conviene conocerla en orden porque el orden explica varios comportamientos.
Tijera. Si hay un rectángulo de tijera activo y el fragmento cae fuera, se descarta. Se usa para limitar el dibujado a una región, por ejemplo al renderizar varias vistas en un mismo canvas.
Stencil. Una prueba contra un búfer de enteros de propósito general. Es la herramienta para portales, recortes de forma arbitraria, máscaras y reflejos planos.
Profundidad. Compara la profundidad del fragmento con la almacenada. Si pasa, y la escritura de profundidad está activa, actualiza el búfer. Es lo que hace que los objetos se tapen correctamente sin ordenarlos.
Mezcla. Combina el color del fragmento con el que ya había según una fórmula configurable.
Máscaras de escritura. Deciden qué canales se escriben realmente.
La secuencia tiene una consecuencia importante: la prueba de profundidad ocurre antes de la mezcla. Un fragmento transparente que quede detrás de un objeto ya dibujado se descarta antes de mezclarse, lo cual es correcto. Pero un fragmento transparente que pase la prueba y escriba en el búfer de profundidad impedirá que se dibuje lo que quede detrás, aunque debiera verse a través. Ese es el mecanismo exacto del bug clásico de transparencias, y explica por qué la solución habitual es desactivar la escritura de profundidad en materiales transparentes.
La mezcla no es conmutativa, y de ahí sale todo lo demás
La fórmula de mezcla estándar para transparencia toma el color del fragmento, lo multiplica por su alfa, y le suma el color de destino multiplicado por uno menos ese alfa. Es una interpolación lineal, y aplicarla dos veces en distinto orden da resultados distintos.
Un ejemplo aritmético convence más que la explicación. Sobre un fondo negro, un cristal rojo con alfa 0.5 y uno azul con alfa 0.5. Si dibujas primero el rojo y luego el azul, el resultado tiene el doble de azul que de rojo. Si inviertes el orden, ocurre lo simétrico. El orden es parte del resultado.
De aquí sale la regla que gobierna todo el dibujado de transparencias: los objetos transparentes hay que dibujarlos de atrás hacia delante, después de todos los opacos. Three.js lo hace por ti: separa los objetos en dos listas según su bandera transparent, dibuja primero la lista opaca ordenada de delante a atrás —para aprovechar la prueba de profundidad temprana— y después la transparente ordenada de atrás a delante por distancia a la cámara.
El problema es que esa ordenación es por objeto, no por fragmento. Y hay tres situaciones en las que ordenar por objeto no basta:
Dos objetos transparentes que se atraviesan. No existe un orden correcto: parte de A está delante de B y parte detrás. Ningún criterio por objeto puede resolverlo.
Un objeto transparente cóncavo. Una esfera de cristal tiene una cara frontal y una trasera; se dibujan en el orden en que estén en el búfer de índices, no en orden de profundidad.
Objetos cuyo centro no representa su profundidad. Three.js ordena por la distancia del origen del objeto a la cámara. Un plano muy grande e inclinado puede tener su centro más lejos que otro objeto y aun así estar delante en la mayor parte de su superficie.
Estas tres situaciones no tienen solución exacta con mezcla clásica. Se resuelven aproximando: dividiendo la malla, ajustando renderOrder a mano, desactivando la escritura de profundidad, o pasando a técnicas de transparencia independiente del orden, que tienen su propio coste.
Los cuatro parámetros que controlan esto en Three.js
transparent decide en qué lista entra el objeto y si se activa la mezcla. Cambiarlo obliga a recompilar el material, así que no es algo que se active y desactive por fotograma.
opacity es el factor global de alfa del material. Solo tiene efecto si transparent está activo.
depthWrite decide si los fragmentos que pasan la prueba actualizan el búfer de profundidad. Desactivarlo en objetos transparentes evita que se tapen entre sí, a costa de perder la ordenación automática dentro del propio objeto.
alphaTest establece un umbral por debajo del cual el fragmento se descarta. Es la vía de la transparencia binaria.
Y renderOrder, que no está en el material sino en el objeto, permite forzar el orden dentro de su lista cuando la ordenación automática se equivoca.
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();scene.background = new THREE.Color(0x11111b);const camera = new THREE.PerspectiveCamera( 55, window.innerWidth / window.innerHeight, 0.1, 100);camera.position.set(0, 0, 5);scene.add(new THREE.AmbientLight(0xffffff, 1.2));const luz = new THREE.DirectionalLight(0xffffff, 2.5);luz.position.set(2, 3, 4);scene.add(luz);// Tres planos transparentes que se solapan, dibujados a proposito en el// orden equivocado dentro del array de hijos.const colores = [0xf38ba8, 0xa6e3a1, 0x89b4fa];const planos = colores.map((color, i) => { const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5, // Sin esto, el primero en dibujarse escribe profundidad y tapa a los demas. depthWrite: false, side: THREE.DoubleSide, }); const plano = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 2.4), material); plano.position.set(i * 0.5 - 0.5, 0, i * 0.6 - 0.6); scene.add(plano); return plano;});// Con depthWrite desactivado, el orden lo decide la distancia a la camara,// que Three.js recalcula cada fotograma. renderOrder lo forzaria a mano:// planos[0].renderOrder = 2;const opaco = new THREE.Mesh( new THREE.BoxGeometry(0.8, 0.8, 0.8), new THREE.MeshStandardMaterial({ color: 0xf9e2af }));opaco.position.z = -1.5;scene.add(opaco);renderer.setAnimationLoop((tiempo) => { const t = tiempo / 1000; camera.position.x = Math.sin(t * 0.4) * 5; camera.position.z = Math.cos(t * 0.4) * 5; camera.lookAt(0, 0, 0); renderer.render(scene, camera);});Gira la cámara alrededor de la escena y observa: los planos se reordenan solos porque Three.js recalcula el orden cada fotograma según la distancia. Prueba a poner depthWrite en true y verás aparecer el artefacto clásico: desde ciertos ángulos, un plano recorta a los otros con un borde duro que no corresponde a ninguna geometría.
Lo que hay que llevarse del pipeline entero
El pipeline explica, sin necesidad de medir nada, el coste de casi cualquier decisión:
Los objetos opacos son baratos porque el búfer de profundidad resuelve la visibilidad sin ordenar, y porque la prueba temprana evita sombrear lo tapado. Los transparentes son caros porque exigen orden, desactivan optimizaciones y acumulan sobredibujado: cada capa transparente que atraviesa un píxel es un fragment shader completo ejecutado sobre él.
Ese último punto merece un nombre, porque es la métrica que de verdad importa en escenas con efectos: el sobredibujado, es decir, cuántas veces se sombrea el mismo píxel en un fotograma. Un sistema de partículas con cien capas superpuestas de humo a pantalla completa ejecuta cien fragment shaders por píxel. No hay hardware que salve eso, y ninguna optimización de draw calls lo toca.
Con el pipeline completo en la cabeza, el siguiente bloque del track es el que da sentido a la primera mitad: las matemáticas que transforman los vértices antes de que el rasterizador vea nada.