Saltar al contenido

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.

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:

JS
// 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:

JS
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.

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.

JS
// Desactivar la ordenacion entera: la escena se dibuja en orden de insercionrenderer.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.

JS
import * as THREE from 'three';// Un halo que siempre debe verse por encima del resto de transparentesconst 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 opacosconst 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.

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.