wandres.dev
RENDIMIENTO II · Las optimizaciones que valen

Overdraw, early-z y el orden en que Three dibuja

Por qué el mismo píxel se sombrea varias veces, cómo el rechazo temprano de profundidad lo evita, qué orden usa Three.js por defecto y qué hace realmente renderOrder.

⏱ 20 min

El contador de triángulos miente sobre el coste de una escena. Lo que la GPU paga en el fragment shader no depende de cuántos triángulos haya sino de cuántas veces se sombrea cada píxel, y ese número puede ser uno o puede ser quince sin que la geometría cambie. El mecanismo que lo mantiene cerca de uno es el rechazo temprano de profundidad, y es frágil: hay cuatro cosas que lo desactivan sin previo aviso, y tres de ellas son decisiones que tomas en el material sin pensar en el pipeline.

🎯 Al terminar esta lección sabrás
  • Definir overdraw y estimar el factor de una escena a partir de su composición.
  • Explicar cuándo el hardware puede rechazar un fragmento antes de sombrearlo y qué lo impide.
  • Describir el orden de dibujado por defecto de Three.js para opacos y transparentes.
  • Usar renderOrder y sortObjects sabiendo qué se rompe al tocarlos.

Qué se paga por píxel

Un fragmento es un candidato a píxel. La rasterización genera uno por cada píxel cubierto por cada triángulo, y el fragment shader se ejecuta para cada fragmento que sobreviva a las pruebas. Si cinco objetos se apilan delante de la cámara y todos cubren el centro de la pantalla, ese píxel central genera cinco fragmentos. El overdraw es la relación entre fragmentos generados y píxeles finales.

Un factor de uno es el ideal teórico e imposible. Dos o tres es normal. Diez es una escena que va a arrastrarse en cualquier móvil aunque tenga cuatro mil triángulos, porque el coste de un fragmento de MeshPhysicalMaterial con transmisión es de un orden que no admite repetirse diez veces por píxel.

La forma barata de detectar que estás limitado por relleno es cambiar el tamaño del render y ver qué pasa. El número de triángulos no cambia, el número de draw calls no cambia, solo cambia el número de fragmentos:

// Si esto multiplica el framerate, el cuello esta en el fragment shader
// o en el ancho de banda, no en la CPU ni en la geometria.
renderer.setPixelRatio(1);
renderer.setSize(window.innerWidth / 2, window.innerHeight / 2, false);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';

El tercer argumento false de setSize es el que evita que Three toque los estilos CSS del canvas, de modo que el buffer baja de resolución pero el elemento sigue ocupando la pantalla completa. Es la prueba de diagnóstico más rápida que existe y no cuesta nada dejarla detrás de un parámetro de URL en desarrollo.

Para visualizar dónde está el overdraw, la técnica clásica es pintar cada fragmento con una aportación aditiva pequeña y mirar dónde se satura:

import * as THREE from 'three';

const materialOverdraw = new THREE.MeshBasicMaterial({
  color: 0x111111,
  blending: THREE.AdditiveBlending,
  depthTest: false,
  depthWrite: false,
  transparent: true,
});

function verOverdraw(scene, renderer, camera) {
  const anterior = scene.overrideMaterial;
  scene.overrideMaterial = materialOverdraw;
  renderer.render(scene, camera);
  scene.overrideMaterial = anterior;
}

Con depthTest: false ningún fragmento se descarta, así que el resultado es literalmente el mapa de cuántas capas hay en cada píxel. Las zonas blancas son las que te están costando el frame.

Early-z y las cuatro formas de romperlo

El hardware moderno intenta hacer la prueba de profundidad antes de ejecutar el fragment shader. Si el fragmento queda detrás de lo que ya hay en el z-buffer, se descarta sin sombrear y el coste es prácticamente cero. Eso es el rechazo temprano, y es lo que hace que una escena con overdraw geométrico alto siga siendo barata: se generan muchos fragmentos, pero casi todos mueren antes de entrar al shader.

El rechazo temprano exige que el resultado de la prueba de profundidad se conozca antes de ejecutar el shader. Cuatro cosas lo impiden:

discard en el shader. Si el shader puede descartar el fragmento, el valor de profundidad que se escribiría depende de ejecutar el shader, y el hardware no puede adelantarse. En Three.js, material.alphaTest mayor que cero inserta un discard en el fragment shader. Un bosque de planos con alfa recortado es el caso canónico: mucha geometría, mucho solapamiento y cero rechazo temprano.

Escribir gl_FragDepth. Lo mismo por la misma razón. Aparece en raymarching y en técnicas de impostores.

depthWrite: false. Los transparentes no escriben profundidad, así que no ocluyen a los que vienen detrás y todos se sombrean. Esto no es un fallo: es lo que hace que la transparencia se vea bien. Pero significa que cada capa transparente es overdraw garantizado.

depthTest: false. Elimina la prueba entera. Se usa en overlays y en helpers, y es correcto ahí, pero un objeto de pantalla completa con depthTest: false sombrea todos sus píxeles siempre.

⚠️
Cuidado

alphaTest se presenta a menudo como “la alternativa barata a la transparencia”. Es más barata en corrección, porque el objeto sí escribe profundidad y no depende del orden. Pero no es más barata en relleno: el discard desactiva el rechazo temprano en la mayoría del hardware, de modo que un follaje con alphaTest puede costar más que el mismo follaje opaco con geometría real. Si el recorte es sencillo, modelar la silueta suele ganar.

El orden que usa Three.js

Three.js separa cada frame en dos listas y las ordena de forma opuesta, porque persiguen objetivos opuestos.

Los opacos se ordenan de delante hacia atrás. La razón es exactamente el rechazo temprano: si dibujas primero lo que está cerca, el z-buffer se llena con los valores más pequeños y todo lo que venga detrás muere en la prueba temprana. Dibujarlos en orden inverso funcionaría igual de bien visualmente y costaría muchísimo más.

Los transparentes se ordenan de atrás hacia delante. Aquí no hay elección: la mezcla alfa no es conmutativa, y pintar el cristal cercano antes que el lejano da un resultado incorrecto. El orden correcto obliga a renunciar al rechazo temprano.

Dentro de cada lista, el criterio de ordenación es una cascada. Primero renderOrder. Después, para los opacos, la identidad del programa y del material, de modo que los objetos que comparten shader queden juntos y se minimicen los cambios de estado. Y por último la profundidad. Para los transparentes la profundidad pesa más que el material, porque la corrección manda.

// Desactivar la ordenacion entera: la escena se dibuja en orden de insercion
renderer.sortObjects = false;

Desactivar sortObjects tiene un uso legítimo y solo uno: cuando tú controlas el orden mejor que el criterio genérico, típicamente en una escena de capas 2D donde todo está a la misma profundidad y la ordenación por z es ruido puro. En cualquier otro caso, apagarlo hunde el rechazo temprano de los opacos y estropea el resultado de los transparentes a la vez.

La profundidad que Three usa para ordenar es la del centro del objeto proyectado, no la de sus triángulos. De ahí sale el artefacto más famoso de la transparencia: dos objetos transparentes que se intersecan no tienen un orden correcto, porque no hay un orden entre ellos que sea válido para todos sus píxeles. Ningún ajuste de renderOrder lo arregla; hay que resolverlo con la geometría o con una técnica independiente del orden.

renderOrder: qué controla y qué no

object.renderOrder es un número que se compara antes que cualquier otro criterio, dentro de su lista. Esto es lo que casi nadie tiene claro: un objeto opaco con renderOrder = 999 no se dibuja después de los transparentes. Se dibuja el último de los opacos, y los transparentes siguen viniendo después. Las dos listas son secuenciales y renderOrder no salta entre ellas.

import * as THREE from 'three';

// Un halo que siempre debe verse por encima del resto de transparentes
const halo = new THREE.Mesh(
  new THREE.PlaneGeometry(2, 2),
  new THREE.MeshBasicMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  }),
);
halo.renderOrder = 10;

// Un fondo que debe dibujarse el primero de todos los opacos
const fondo = new THREE.Mesh(
  new THREE.SphereGeometry(500, 32, 16),
  new THREE.MeshBasicMaterial({ side: THREE.BackSide }),
);
fondo.renderOrder = -1;

El segundo caso es interesante por lo que revela. Un cielo esférico gigante es, en área de pantalla, el objeto con más overdraw de la escena: cubre todos los píxeles. Dibujarlo el primero significa sombrear la pantalla entera y luego taparla con la escena. Dibujarlo el último de los opacos significa que el z-buffer ya está lleno y la mayoría de sus fragmentos muere en la prueba temprana. Es exactamente lo contrario de lo que dice la intuición, y es la razón por la que scene.background con una textura de entorno es más barato que una esfera invertida.

Nivel dios

El truco de los motores de juegos para escenas con overdraw geométrico masivo es la pasada previa de profundidad: renderizar toda la escena con escritura de color desactivada y un material trivial, para llenar el z-buffer, y luego renderizarla de verdad con depthFunc en igualdad y sin escribir profundidad. Se dibuja todo dos veces, pero la segunda vez cada píxel se sombrea exactamente una. En Three.js se monta con scene.overrideMaterial, material.colorWrite = false, y luego material.depthFunc = THREE.EqualDepth con material.depthWrite = false. Compensa cuando el fragment shader es caro y el overdraw pasa de tres o cuatro; por debajo de eso, la pasada extra cuesta más de lo que ahorra. Y no funciona con materiales que desplazan vértices, porque las dos pasadas tienen que producir la misma profundidad exacta.

Reducir overdraw sin tocar el shader

Cuatro medidas concretas, ordenadas por lo que suelen aportar.

Recortar la geometría de los planos con alfa. Un plano cuadrado con una hoja recortada desperdicia el setenta por ciento de sus fragmentos en píxeles descartados. Un polígono de ocho vértices que siga la silueta de la hoja tiene siete vértices más y la mitad de fragmentos. En sistemas de partículas y follaje es la optimización con mejor relación entre esfuerzo y resultado.

Colapsar capas transparentes. Tres planos semitransparentes apilados para simular niebla cuestan tres capas de overdraw. Un único plano con la mezcla ya resuelta en la textura cuesta una. La niebla nativa de Three, que se calcula dentro del fragment shader del propio objeto, cuesta cero capas.

Bajar la resolución donde no se nota. El post-procesado a media resolución, el bloom a un cuarto, los reflejos a un octavo. El ojo no distingue la resolución de un desenfoque, y el ahorro es cuadrático.

Evitar el fondo de pantalla completa evitable. scene.background con un color plano es un clear, que el hardware resuelve casi gratis. Un ShaderMaterial de degradado en un plano ortográfico es un fragment shader ejecutado en cada píxel. La diferencia entre ambos, en un móvil a resolución nativa, son milisegundos.

⚔️ Reto práctico

Monta una escena con un cielo esférico invertido, un suelo y una decena de objetos. Mide el tiempo de frame con el cielo en renderOrder = -1 y con el cielo en renderOrder = 1000. Después sustituye el shader del cielo por uno artificialmente caro, con un bucle de ruido de cincuenta octavas, y repite la medición: la diferencia entre los dos órdenes pasará de ser ruido a ser el frame entero. Ese salto es el rechazo temprano volviéndose visible.