wandres.dev
CÓMO SE DIBUJA UN PÍXEL · El pipeline gráfico

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.

⏱ 20 min

Después del fragment shader hay una etapa de función fija que decide si el color calculado llega al framebuffer y cómo se combina con lo que ya estaba. Es la parte del pipeline con menos glamour y la que genera más preguntas sin respuesta clara: por qué un objeto transparente tapa a otro, por qué el orden de dibujo cambia el resultado, por qué desactivar la escritura de profundidad a veces arregla la escena y a veces la empeora.

🎯 Al terminar esta lección sabrás
  • Enumerar las pruebas por fragmento en su orden real y qué decide cada una.
  • Explicar por qué la mezcla no es conmutativa y qué obliga eso al motor.
  • Distinguir transparencia binaria de transparencia continua y elegir la técnica correcta.
  • Usar transparent, depthWrite, alphaTest y renderOrder con criterio.

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.

La pregunta que hay que hacerse antes de tocar transparent es si la transparencia es binaria o continua

Casi todo el sufrimiento con transparencias viene de aplicar la herramienta continua a un problema binario. Si tu textura tiene alfa de solo dos valores —una hoja, una valla, un icono recortado, cualquier cosa con silueta pero sin translucidez— no necesitas mezcla en absoluto: necesitas descartar los fragmentos cuyo alfa esté por debajo de un umbral y dejar el material como opaco. En Three.js eso es alphaTest con transparent en false, y cambia por completo el comportamiento: el objeto entra en la lista de opacos, se ordena de delante a atrás, escribe profundidad, y desaparecen de golpe todos los artefactos de orden. Se paga la desactivación de la prueba de profundidad temprana y unos bordes más duros que conviene suavizar con muestreo múltiple de alfa, pero eso es infinitamente más barato que pelear con la ordenación. La regla es corta: transparencia continua solo para cristal, humo, agua y efectos de luz; para todo lo que tiene silueta recortada, prueba alfa. Y una comprobación de tres segundos que revela el error más común de todos: si tu material tiene transparent: true y su alfa es 1.0 en toda la superficie, lo único que has conseguido es sacarlo de la ruta rápida y meterlo en la cola de ordenación.

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.