wandres.dev
CÁMARAS · Perspectiva, ortográfica y el frustum

Z-fighting: la culpa es de near, no de far

La matemática de la precisión del buffer de profundidad, por qué el noventa por ciento de los bits se gasta en el primer metro, y las cinco maneras reales de arreglarlo.

⏱ 24 min

Dos superficies casi coplanares parpadean en franjas, y el patrón cambia al mover la cámara. Es el bug visual más reconocible del 3D y también el peor diagnosticado: casi todo el mundo culpa al plano lejano y sube o baja far sin que nada mejore. La culpa es del plano cercano, y no de forma metafórica: la relación es exacta, se deriva en tres líneas de álgebra, y una vez que la ves ya no se te olvida cuál de los dos números tocar.

🎯 Al terminar esta lección sabrás
  • Derivar la función que relaciona la distancia a la cámara con el valor almacenado en el buffer de profundidad.
  • Calcular la separación mínima resoluble a una distancia dada para unos near y far concretos.
  • Demostrar con números por qué multiplicar far por diez casi no cambia nada y dividir near por diez lo empeora diez veces.
  • Elegir entre subir near, profundidad logarítmica, profundidad invertida, polygonOffset y pasadas separadas.

De dónde sale la no linealidad

La matriz de proyección en perspectiva hace algo que a primera vista parece innecesario: en lugar de guardar la distancia a la cámara, guarda algo proporcional a su inverso. La razón es la división por w que la propia proyección necesita para que la perspectiva funcione, y el efecto colateral es que la profundidad queda repartida de forma brutalmente desigual.

Escribamos la función. Llama n al plano cercano, f al lejano y d a la distancia real de un punto a la cámara medida sobre el eje de vista. El valor que acaba en el buffer, normalizado entre cero y uno, es:

z(d) = ( f / (f - n) ) · ( 1 - n / d )

Comprueba los extremos: en d = n da cero, en d = f da uno. Entre medias, la curva es una hipérbola, no una recta. Y esa es toda la historia.

Ahora la pregunta que importa: si el buffer tiene 24 bits, hay 2^24 = 16 777 216 valores distintos, así que el escalón mínimo es Δz = 5,96 · 10⁻⁸. ¿Cuánto tienen que separarse dos superficies en el mundo, a distancia d, para caer en escalones distintos? Derivando z respecto a d e invirtiendo:

Δd = Δz · d² · ( 1/n - 1/f )

Ahí está el resultado entero, y ya se lee la conclusión sin hacer una sola cuenta más. near y far no entran de la misma forma: entran como 1/n - 1/f. Cuando f es mucho mayor que n, el término 1/f es despreciable frente a 1/n, así que la precisión depende esencialmente solo de near, y far deja de importar en cuanto se aleja un poco.

Los números lo dejan brutal. Con un buffer de 24 bits:

near far 1/n − 1/f Δd a 10 m Δd a 100 m Δd a 1000 m
0.1 1000 9,999 0,06 mm 6,0 mm 60 cm
0.1 10000 9,9999 0,06 mm 6,0 mm 60 cm
0.1 100000 10,00 0,06 mm 6,0 mm 60 cm
1 1000 0,999 0,006 mm 0,6 mm 6 cm
0.01 1000 99,999 0,6 mm 6,0 cm 6 m

Multiplicar far por cien no cambia el resultado ni en la tercera cifra significativa. Dividir near por diez lo empeora exactamente diez veces. Y a mil metros de distancia, con near = 0.01, dos superficies necesitan estar separadas seis metros para que la GPU pueda distinguir cuál está delante.

Hay una segunda forma de contar lo mismo que resulta todavía más incómoda. ¿A qué distancia se ha gastado la mitad del buffer? Resolviendo z(d) = 0.5:

d = 2n / ( 1 + n/f )  ≈  2n

La mitad de los dieciséis millones de valores de profundidad se consume entre near y el doble de near. Con near = 0.1, la mitad del buffer entero se gasta en los primeros veinte centímetros delante de la cámara. Y repitiendo el cálculo para z = 0.9 sale d ≈ 10n: el noventa por ciento de la precisión vive en el primer metro, y todo el resto del mundo, de un metro a mil, se reparte el diez por ciento sobrante.

Las cinco respuestas

Subir near. Es la única medida que ataca la causa, es gratis, y es la que nadie prueba. La pregunta correcta no es “qué valor de near es seguro” sino “cuál es la distancia más corta a la que un objeto puede llegar a la cámara en mi escena”. En un visor de producto donde el usuario no puede acercarse a menos de medio metro, near = 0.5 es correcto y near = 0.1 es tirar tres cuartas partes de la precisión sin ganar nada. Un criterio operativo: mantén la relación far / near por debajo de diez mil, y por debajo de mil si puedes.

const camera = new THREE.PerspectiveCamera( 50, aspecto, 0.5, 500 );
// far/near = 1000. Con 24 bits, a 100 m distingues ~1,2 mm. Suficiente para casi todo.

Si usas OrbitControls, minDistance te dice exactamente cuál es tu near mínimo tolerable, porque el usuario no puede acercarse más que eso.

Profundidad logarítmica. logarithmicDepthBuffer: true sigue existiendo en r184. El renderer no lo desestructura en el constructor: lo lee del objeto de parámetros dentro de WebGLCapabilities, y por defecto es false.

const renderer = new THREE.WebGLRenderer( { canvas, logarithmicDepthBuffer: true } );

Lo que hace es escribir gl_FragDepth desde el fragment shader con una función logarítmica de w, lo que reparte la precisión de forma casi uniforme en escala logarítmica y permite escenas con siete u ocho órdenes de magnitud, del tornillo al planeta. El precio es duro y hay que decirlo: escribir gl_FragDepth desactiva el rechazo temprano de profundidad en prácticamente todo el hardware, porque la GPU ya no puede saber la profundidad de un fragmento antes de ejecutar su shader. En una escena con mucho solapamiento eso puede duplicar el coste de fragmentos. Además rompe cualquier efecto de post-procesado que interprete el buffer de profundidad asumiendo la distribución estándar, y la precisión muy cerca del plano cercano empeora en lugar de mejorar. Es la herramienta correcta para simuladores de escala planetaria y una mala idea para lo demás.

Profundidad invertida. reversedDepthBuffer: true requiere la extensión EXT_clip_control; si no está, Three.js avisa por consola y vuelve al modo normal. Invierte el mapeo, de modo que near va a uno y far a cero, y el volumen de recorte pasa a ir de cero a uno en lugar de menos uno a uno.

const renderer = new THREE.WebGLRenderer( { canvas, reversedDepthBuffer: true } );

Conviene entender por qué ayuda, porque se explica mal a menudo. Con un buffer de coma fija los valores representables están uniformemente espaciados, así que invertir el mapeo por sí solo no redistribuye nada: lo que se gana es que el volumen de recorte ya no necesita la conversión de menos uno a uno hacia cero y uno, que se comía precisión justo en la zona del plano cercano. El salto grande de la técnica aparece con un buffer de profundidad en coma flotante, donde los valores representables se concentran cerca de cero y esa concentración cancela casi exactamente la hipérbola de la proyección. Three.js te da el interruptor; el formato del buffer del lienzo por defecto no lo eliges tú, pero sí el de un WebGLRenderTarget propio. Nota práctica: cuando lo actives, propaga camera.reversedDepth a cualquier Frustum que construyas a mano, porque los planos cercano y lejano se calculan distinto.

polygonOffset para lo coplanar. Cuando el problema no es la escena entera sino dos superficies que de verdad están en el mismo plano —una calcomanía sobre una pared, una marca de neumático sobre el asfalto, una rejilla sobre un suelo—, no hay near que lo arregle, porque la separación real es cero. Lo que se hace es sesgar la profundidad del que va encima:

const calcomania = new THREE.MeshBasicMaterial( {
  map: textura,
  transparent: true,
  polygonOffset: true,
  polygonOffsetFactor: - 1,   // proporcional a la pendiente del triángulo
  polygonOffsetUnits: - 1     // en unidades del escalón mínimo del buffer
} );

El desplazamiento aplicado es factor · pendiente + units · escalón, y los valores negativos tiran hacia el observador. El término de pendiente es el que importa cuando la superficie está muy inclinada respecto a la cámara, porque ahí la profundidad varía mucho de un píxel al siguiente. Empieza por menos uno en ambos y sube si sigue peleando; pasarse produce el efecto contrario, la calcomanía flotando por delante de una geometría que debería taparla.

Ordenar tú. Si conoces el orden, no necesitas el buffer. Un renderOrder explícito más depthWrite: false en el elemento de encima resuelve el caso de dos capas conocidas sin ningún sesgo. Y para escenas con escalas muy separadas —una cabina en primer plano y un paisaje a diez kilómetros— la solución profesional es renderizar en dos pasadas con dos cámaras de rangos distintos, limpiando la profundidad en medio:

renderer.autoClear = false;
renderer.clear();

camaraLejana.near = 100;  camaraLejana.far = 100000;
camaraLejana.updateProjectionMatrix();
renderer.render( escenaLejana, camaraLejana );

renderer.clearDepth();                 // el buffer de profundidad empieza de cero

camaraCercana.near = 0.1;  camaraCercana.far = 200;
camaraCercana.updateProjectionMatrix();
renderer.render( escenaCercana, camaraCercana );

Cada pasada usa el rango de profundidad completo para su propio rango de distancias. Es la técnica que usan los simuladores de vuelo desde hace treinta años y sigue siendo la respuesta menos ingeniosa y más fiable.

Diagnosticar en treinta segundos

Antes de tocar nada, confirma que es z-fighting y no otra cosa. Las señales son inequívocas: el patrón es de franjas o moteado, cambia cuando mueves la cámara, y empeora al alejarte. Si el artefacto no cambia con la cámara, es otro problema; si es un borde limpio y no un patrón, es orden de transparencias.

La prueba definitiva cuesta una línea. Sube near en un factor de diez y mira:

camera.near = 1;   // estaba en 0.1
camera.updateProjectionMatrix();

Si el parpadeo desaparece o se reduce visiblemente, tienes el diagnóstico confirmado y probablemente la solución. Si no cambia nada, tus superficies están literalmente en el mismo plano y necesitas polygonOffset u ordenar a mano.

Un detalle sobre el formato: en WebGL2 el buffer de profundidad del lienzo suele ser de 24 bits. Si pides stencil: true en el renderer obtienes profundidad de 24 más estarcido de 8 en el mismo buffer, con la misma precisión de profundidad. En r184 stencil es false por defecto, así que si no lo necesitas no lo pidas: no cambia la precisión, pero sí el consumo de memoria.

La hipérbola no es un accidente: es una decisión de hardware que ya no se justifica

Vale la pena saber por qué la profundidad se guarda como el inverso de la distancia, porque no fue una elección estética. La razón es que el rasterizador necesita interpolar valores a lo largo de un triángulo, y la interpolación lineal en el espacio de pantalla solo es correcta para cantidades que sean lineales en el espacio de recorte. La distancia real no lo es: interpolar d linealmente entre dos vértices da resultados incorrectos en cuanto el triángulo está inclinado. Su inverso sí lo es. Así que guardar 1/d no fue una optimización de precisión, sino la única forma de que la interpolación de profundidad fuera gratis en hardware fijo de los años noventa, y de propina permitía comparar profundidades con enteros, que era lo único barato entonces. Ese diseño se congeló en la especificación de OpenGL, de ahí pasó a WebGL, y por eso sigues pagándolo hoy. Lo interesante es que la premisa ya no se sostiene: las GPU modernas interpolan con corrección de perspectiva por defecto y comparan flotantes sin coste añadido, así que la profundidad invertida con buffer en coma flotante —que es lo que hacen todos los motores serios desde hace una década— reparte los bits casi uniformemente y hace que el z-fighting sea un recuerdo. La web va tarde en esto porque WebGL heredó la convención de recorte de menos uno a uno, y EXT_clip_control es reciente. WebGPU nació ya con el volumen de cero a uno, que es el correcto. Cuando migres a WebGPURenderer, parte de tu problema de profundidad desaparece por debajo sin que hagas nada, y la razón es esta: no es que WebGPU sea “más preciso”, es que dejó atrás una decisión de compatibilidad de 1992.

⚔️ Cuantifica tu propia precisión
  1. Escribe una función que, dados near, far y d, devuelva la separación mínima resoluble con 24 bits.
  2. Monta dos planos separados 5 mm a 80 metros y encuentra el valor de near a partir del cual dejan de pelearse.
  3. Comprueba empíricamente que multiplicar far por diez no cambia nada.
  4. Activa logarithmicDepthBuffer en una escena con mucho solapamiento y mide la caída de fotogramas por segundo.
  5. Coloca una calcomanía coplanar y ajústala con polygonOffsetFactor y polygonOffsetUnits hasta que no parpadee en ningún ángulo.