Reversed-Z: por qué invertir el rango resuelve el z-fighting de raíz
La matemática exacta de por qué la no linealidad del depth en perspectiva y la no uniformidad de los floats se cancelan al invertir el rango, y las cuatro líneas que hay que cambiar.
El z-fighting se combate normalmente con parches: acercar el plano lejano, alejar el cercano, aplicar un sesgo, separar la geometría. Existe una técnica que no es un parche sino una corrección del error de diseño original, cuesta cuatro líneas de código, no tiene coste en tiempo de ejecución y mejora la precisión de la profundidad en dos o tres órdenes de magnitud. Se llama reversed-z y consiste en guardar el uno donde antes iba el cero. La razón por la que funciona tan bien es una coincidencia matemática preciosa entre dos distribuciones no uniformes que se cancelan.
- Derivar la distribución de valores de profundidad en una proyección en perspectiva.
- Explicar la distribución de los
f32representables y por qué se concentran cerca de cero. - Demostrar por qué al invertir el rango ambas no uniformidades se compensan.
- Aplicar reversed-z en WebGPU con la matriz, el formato, el operador y el valor de limpieza correctos.
Las dos distribuciones, por separado
La primera: la profundidad es hiperbólica. Ya vimos la fórmula. Con n el plano cercano, f el lejano y d la distancia a la cámara medida sobre el eje de vista, la proyección en perspectiva produce
z = (f / (f - n)) * (1 - n / d)
La derivada respecto a la distancia es dz/dd = (f/(f-n)) * n/d², que para cualquier f razonable es prácticamente n/d². La resolución cae con el cuadrado de la distancia. A diez metros, un metro de mundo ocupa cien veces menos rango de z que a un metro. Esto no es un defecto: es la consecuencia inevitable de que el hardware necesita un valor que se interpole linealmente en espacio de pantalla, y solo 1/d cumple eso. Pero deja el rango de profundidad brutalmente sesgado hacia el plano cercano.
La segunda: los floats también son hiperbólicos. Un f32 tiene veintitrés bits explícitos de mantisa y un exponente. Para cualquier valor en el intervalo [2^e, 2^(e+1)), la distancia entre dos floats consecutivos es exactamente 2^(e-23). Es decir, la resolución absoluta de un float es proporcional a su magnitud, y por tanto la resolución es mejor cuanto más cerca de cero estás. Entre 0.5 y 1.0 hay unos ocho millones de floats, separados por 2^-24. Entre 0.001 y 0.002 hay los mismos ocho millones, separados por 2^-33, quinientas doce veces más finos.
Y ahora el problema. Con el rango convencional —cero en el plano cercano, uno en el lejano— la lejanía cae en valores próximos a 1.0, donde los floats tienen su peor resolución, mientras que la cercanía cae en valores próximos a cero, donde los floats tienen su mejor resolución y donde la proyección ya daba de sobra. Las dos no uniformidades apuntan en la misma dirección y se multiplican.
El resultado es demoledor y se demuestra con una cuenta. Con n = 0.1 y f = 1000, a cien metros de la cámara, el valor de z vale 0.9991. Su ULP es 2^-24, unos 5.96e-8. Como dz/dd vale ahí 1e-5 por metro, el escalón de profundidad más pequeño representable equivale a seis milímetros de mundo. A mil metros son sesenta centímetros. Ese es el z-fighting que todo el mundo conoce.
Peor aún: en ese régimen, un búfer f32 no es mejor que uno de veinticuatro bits enteros. Un unorm de veinticuatro bits tiene escalones constantes de 2^-24 en todo el rango; el float, cerca de uno, tiene exactamente ese mismo escalón. Con Z convencional, los treinta y dos bits del float están regalando su rango dinámico entero.
La cancelación
Invierte el rango: el plano cercano produce 1.0 y el lejano produce 0.0. Ahora la lejanía cae en valores diminutos, próximos a cero, que es justo donde el float tiene su resolución más fina. Y la cercanía cae en valores próximos a uno, donde el float es grueso, pero donde la proyección ya daba una resolución absurdamente buena que sobraba.
Las dos distribuciones se compensan. Y no aproximadamente: la compensación es casi exacta, y se ve al hacer la misma cuenta en forma cerrada.
Con reversed-z y plano lejano infinito, la fórmula se simplifica de un modo casi milagroso: z = n / d. El error mínimo representable en unidades de mundo es el ULP de z dividido por el módulo de dz/dd. Como el ULP de un float es aproximadamente z · 2^-23 y dz/dd = n/d²:
error_mundo ≈ (n/d · 2^-23) / (n/d²) = d · 2^-23
La n desaparece y la d² se convierte en d. El error de profundidad pasa de crecer con el cuadrado de la distancia a crecer linealmente con ella, y deja de depender del plano cercano. Traducido: la precisión relativa es constante en toda la escena, aproximadamente una parte entre ocho millones de la distancia al observador. A cien metros, doce micras. A mil metros, ciento veinte micras. Frente a los seis milímetros y los sesenta centímetros de antes.
flowchart TB
A[Distancia a la camara d] --> B[Proyeccion en perspectiva]
B --> C[z es hiperbolico y se concentra cerca del plano cercano]
C --> D{Donde ponemos el cero}
D -->|Z convencional cero cerca uno lejos| E[Lo lejano cae en valores proximos a uno]
E --> F[Ahi el float32 tiene su peor paso 2 elevado a menos 24]
F --> G[Las dos no uniformidades se suman]
G --> H[Error proporcional a d al cuadrado partido por n]
D -->|Reversed Z uno cerca cero lejos| I[Lo lejano cae en valores proximos a cero]
I --> J[Ahi el float32 tiene su paso mas fino exponente muy negativo]
J --> K[Las dos no uniformidades se cancelan]
K --> L[Error proporcional a d y ya no depende de n]
style A fill:#89b4fa,color:#11111b
style B fill:#89b4fa,color:#11111b
style C fill:#cba6f7,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b
style F fill:#f38ba8,color:#11111b
style G fill:#f38ba8,color:#11111b
style H fill:#f38ba8,color:#11111b
style I fill:#94e2d5,color:#11111b
style J fill:#94e2d5,color:#11111b
style K fill:#a6e3a1,color:#11111b
style L fill:#a6e3a1,color:#11111bHay un corolario que conviene fijar porque contradice el consejo popular. Con Z convencional el error va como d²/n, así que alejar el plano cercano es el remedio que funciona y mover el lejano casi no hace nada. Con reversed-z el error va como d y el plano cercano ha desaparecido de la ecuación, así que puedes poner el plano cercano en un centímetro sin pagar nada. Esa es la diferencia entre pelear con los parámetros de la cámara y no tener que pensar en ellos.
Las cuatro líneas
Uno: el formato tiene que ser de coma flotante. depth32float, o depth32float-stencil8 si además necesitas estarcido y la feature está disponible. Con depth16unorm o con un depth24plus respaldado por un entero normalizado, reversed-z no aporta absolutamente nada: los escalones de un unorm son uniformes, y voltear un rango uniforme lo deja igual de uniforme. Toda la técnica descansa en la no uniformidad del float. Es el error más común al implementarla y produce la conclusión falsa de que «no se nota».
Dos: el valor de limpieza pasa a cero. depthClearValue: 0.0.
Tres: el operador se invierte. depthCompare: 'greater', o 'greater-equal' donde antes usabas less-equal.
Cuatro: la matriz de proyección se cambia. La forma directa es intercambiar n y f en la fórmula estándar. La forma buena es usar el plano lejano infinito, que con reversed-z está perfectamente condicionado:
// Proyeccion en perspectiva, reversed-Z, plano lejano infinito.
// Espacio de vista diestro: la camara mira hacia -Z.
// Devuelve column-major, listo para subir a un uniform de mat4x4f.
function perspectivaReversedInfinita(fovY, aspecto, cerca) {
const t = 1 / Math.tan(fovY / 2);
return new Float32Array([
t / aspecto, 0, 0, 0, // columna 0
0, t, 0, 0, // columna 1
0, 0, 0, -1, // columna 2
0, 0, cerca, 0 // columna 3
]);
}
// Comprobacion: z_clip = cerca, w_clip = -z_vista = d.
// z_ndc = cerca / d. Vale 1 en el plano cercano y tiende a 0 en el infinito.
Y en los descriptores:
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive: { topology: 'triangle-list', cullMode: 'back' },
depthStencil: {
format: 'depth32float', // obligatorio que sea float
depthWriteEnabled: true,
depthCompare: 'greater', // antes era 'less'
},
});
const pass = encoder.beginRenderPass({
colorAttachments: [/* ... */],
depthStencilAttachment: {
view: depthView,
depthClearValue: 0.0, // antes era 1.0
depthLoadOp: 'clear',
depthStoreOp: 'discard',
},
});
Nada más. En particular, el sentido de las caras no cambia: frontFace y cullMode se deciden por el área con signo del triángulo en coordenadas de framebuffer, que no depende de z. Es la duda que más frena a quien va a probarlo por primera vez.
La técnica es de 1998 y la analizó a fondo Nathan Reed en 2015, pero su adopción en la web ha sido nula hasta hace poco por una razón puramente histórica. En OpenGL, el rango canónico de z en NDC va de menos uno a uno, y el hardware lo mapea al búfer con una transformación afín. Aplicar reversed-z ahí no sirve de nada: la conversión de [-1, 1] a [0, 1] introduce una suma que destruye exactamente la precisión que la inversión pretendía ganar, porque 0.5 * z + 0.5 para z cerca de menos uno produce una cancelación catastrófica. Hacía falta la extensión ARB_clip_control para cambiar el rango, y esa extensión nunca existió en WebGL. WebGPU nace con el rango [0, 1], igual que Direct3D, Metal y Vulkan, porque es lo que hace el hardware de verdad y porque el rango simétrico de OpenGL era un accidente de diseño de 1992. Eso significa que en WebGPU reversed-z funciona directamente, sin extensiones, sin comprobaciones de soporte y sin coste. Y el corolario práctico: si estás portando un renderizador de WebGL a WebGPU y arrastras la matriz de proyección de gl-matrix sin tocarla, no solo estás dejando reversed-z sobre la mesa, es que probablemente estás usando perspective en su variante de OpenGL, que mapea a [-1, 1] y hace que la mitad cercana de tu escena se recorte. La función correcta de esa biblioteca es perspectiveZO, y a partir de ahí invertirla es trivial.
Lo que reversed-z no arregla
Conviene delimitarlo, porque la mejora es tan grande que invita a creer que la profundidad deja de ser un problema.
El z-fighting entre superficies coplanares sigue existiendo, porque no es un problema de precisión sino de igualdad: dos triángulos exactamente en el mismo plano producen el mismo valor y el resultado depende del orden. Para eso está el depthBias, que veremos junto al estarcido.
La precisión de las coordenadas de mundo en escenas de escala planetaria no la toca. Si tus vértices están a un millón de unidades del origen, el problema está en el f32 de la posición antes de proyectarse, no en el búfer de profundidad. La solución de ese problema es distinta: trasladar el origen del mundo a la cámara antes de transformar.
Y la profundidad de las sombras hay que invertirla también, o tendrás la mitad del renderizador en una convención y la otra mitad en la contraria. Es la fuente número uno de bugs al adoptar la técnica: un shadow map generado con less y consultado desde un renderizador con greater produce sombras invertidas que parecen un problema de sesgo y no lo son.
Implementa las dos versiones y mídelas en la misma escena: dos planos separados por un milímetro, colocados a distancias crecientes de la cámara con n = 0.1. Con Z convencional y depth32float empezarás a ver z-fighting alrededor de los cuarenta metros. Con reversed-z y el mismo formato, tendrás que irte más allá de los mil para que aparezca. Después repite el experimento con depth16unorm en ambas configuraciones y comprueba que el resultado es idéntico: es la prueba de que la técnica vive enteramente en la representación del float.