El depth buffer: qué guarda, en qué formato y quién lo declara
Qué valor exacto acaba en el búfer de profundidad, los seis formatos que WebGPU define, y los tres sitios distintos del API donde hay que declararlo de forma coherente.
El búfer de profundidad es la estructura de datos que permite dibujar una escena tridimensional sin ordenar nada. Su idea cabe en una frase —guardar por píxel la profundidad del fragmento más cercano visto hasta ahora— pero su configuración en WebGPU está repartida entre tres descriptores distintos que tienen que coincidir, y el valor que realmente se almacena no es la distancia a la cámara sino una función no lineal de ella que arrastra consecuencias durante el resto del nivel.
- Describir qué valor concreto se escribe en el búfer de profundidad y de dónde sale.
- Enumerar los formatos de profundidad y estarcido de WebGPU y elegir uno con criterio.
- Declarar la profundidad de forma coherente en la textura, el pipeline y el render pass.
- Distinguir
depthWriteEnableddedepthComparey saber cuándo desacoplarlos.
Qué valor se guarda
Tu vertex shader devuelve una posición en @builtin(position), un vec4f en espacio de recorte. El hardware divide las tres primeras componentes por la cuarta y obtiene coordenadas normalizadas de dispositivo. En WebGPU, ese espacio tiene x e y en el rango de menos uno a uno, pero z va de cero a uno, no de menos uno a uno. Es la convención de Direct3D y Metal, no la de OpenGL, y esa decisión hace que buena parte de las técnicas de precisión de profundidad funcionen directamente en WebGPU sin trucos.
Después, el rasterizador interpola z linealmente sobre la superficie del triángulo en espacio de pantalla, y ese valor interpolado —recortado al rango de cero a uno— es lo que se compara y se escribe. Nota que la interpolación es lineal en z de NDC, no en la profundidad de vista: eso es precisamente lo que permite que la interpolación en pantalla sea correcta sin corrección de perspectiva, y es la razón por la que el hardware guarda z de NDC y no la distancia.
La consecuencia se ve mejor con la fórmula. Con una proyección en perspectiva clásica, si llamas d a la distancia a la cámara a lo largo del eje de vista, n al plano cercano y f al lejano:
z_ndc = (f / (f - n)) * (1 - n / d)
Es una hipérbola. A d = n vale cero, a d = f vale uno, y la mitad del rango de z se consume en los primeros 2n metros de escena. Con n = 0.1 y f = 1000, a diez metros de la cámara ya llevas gastado el 99% del rango. Todo lo que veremos sobre precisión sale de esta única expresión.
El fragment shader puede leer ese valor: en la etapa de fragmento, @builtin(position) llega con xy en coordenadas de framebuffer, z con la profundidad que va a compararse y w con el recíproco de la w de recorte. Y puede también sobrescribirlo declarando una salida @builtin(frag_depth), con un coste que merece su propia lección.
Los formatos
WebGPU define seis formatos de profundidad y estarcido. Cinco están garantizados en cualquier dispositivo; el sexto requiere una feature.
| Formato | Profundidad | Estarcido | Notas |
|---|---|---|---|
depth16unorm |
16 bits normalizado | — | 65536 escalones. Suficiente solo para rangos cortos |
depth24plus |
al menos 24 bits | — | El implementador elige entre 24 unorm y 32 float |
depth24plus-stencil8 |
al menos 24 bits | 8 bits | El combinado clásico |
depth32float |
32 bits float | — | El único que da precisión predecible |
stencil8 |
— | 8 bits | Solo estarcido |
depth32float-stencil8 |
32 bits float | 8 bits | Requiere la feature depth32float-stencil8 |
El formato depth24plus es el más usado y el peor entendido. El sufijo plus significa literalmente «veinticuatro bits o más, tú no sabes cuáles»: el navegador puede respaldarlo con un entero normalizado de 24 bits o con un float de 32. Esa indefinición es deliberada —permite que cada backend use su formato nativo más rápido— pero significa que cualquier razonamiento sobre precisión que hagas con depth24plus es un razonamiento sobre un formato que no controlas. Si vas a apoyarte en la distribución de los valores, y en la lección del reversed-z lo harás, pide depth32float y quítate la duda.
Hay una segunda diferencia práctica, la de las copias. El aspecto de profundidad de depth24plus y depth24plus-stencil8 no se puede copiar a un búfer: no existe una representación de bytes definida para copiarlo. Si tu técnica necesita leer la profundidad en la CPU, o volcarla a un búfer para un compute, el formato tiene que ser depth32float o depth16unorm.
Para el estarcido, la única opción real es ocho bits. No hay formatos de estarcido más anchos en WebGPU, y tampoco los hay en las APIs nativas: doscientos cincuenta y seis valores es el techo del hardware desde hace veinte años.
Los tres sitios donde se declara
La profundidad aparece en tres descriptores distintos y los tres tienen que ser coherentes. Un desajuste produce un error de validación, no un fallo silencioso, lo cual es una de las mejores propiedades de WebGPU frente a WebGL.
La textura. Se crea como cualquier otra, con el formato de profundidad y el uso RENDER_ATTACHMENT. Si además la vas a muestrear después —sombras, niebla, efectos de profundidad de campo— necesita también TEXTURE_BINDING.
El pipeline. El bloque depthStencil del descriptor de pipeline declara el format, si el pipeline escribe con depthWriteEnabled, y con qué función compara con depthCompare. El format aquí tiene que ser el mismo que el de la textura que se use en el pass.
El render pass. El depthStencilAttachment referencia la vista de la textura y dice qué hacer al entrar y al salir del pass, con depthLoadOp y depthStoreOp, más el valor de limpieza depthClearValue.
// 1. La textura. Se recrea cada vez que cambia el tamano del canvas.
let depthTexture = device.createTexture({
label: 'depth',
size: [canvas.width, canvas.height],
format: 'depth32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
// 2. El pipeline. El formato debe coincidir con el de la textura.
const pipeline = device.createRenderPipeline({
label: 'opacos',
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive: { topology: 'triangle-list', cullMode: 'back' },
depthStencil: {
format: 'depth32float',
depthWriteEnabled: true,
depthCompare: 'less',
},
});
// 3. El render pass, cada fotograma.
function frame() {
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.07, g: 0.07, b: 0.11, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: depthTexture.createView(),
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'discard',
},
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
}
Fíjate en depthStoreOp: 'discard'. La profundidad casi nunca hace falta después del pass que la produce, y decírselo al driver le ahorra escribir un búfer entero a memoria. Es la aplicación más rentable de esa opción y volveremos sobre ella cuando hablemos de loadOp y storeOp en hardware de tiles.
Y fíjate también en que la textura de profundidad no se puede reutilizar entre tamaños. Si el canvas cambia de tamaño y no recreas la textura, beginRenderPass falla: WebGPU exige que todos los attachments de un pass tengan exactamente las mismas dimensiones.
La intuición dice que un objeto o participa en la profundidad o no participa. El hardware permite cuatro combinaciones y las cuatro tienen un uso real. Comparar y escribir es el objeto opaco normal. Comparar sin escribir es todo lo transparente: el cristal se oculta detrás de lo opaco pero no impide que se vea lo que hay tras él; también es el patrón de las partículas y de casi todo efecto aditivo. No comparar y escribir es raro pero existe: el truco del skybox dibujado al final con depthCompare: 'always' y la z forzada al valor del plano lejano, o la escritura de una máscara de profundidad artificial para un portal. Ni comparar ni escribir es el HUD y cualquier cosa que deba verse siempre. La combinación que más se pasa por alto es la tercera aplicada a un depth prepass: dibujar toda la geometría opaca sin fragment shader de color, solo escribiendo profundidad, y luego repetir el dibujado con depthWriteEnabled: false y depthCompare: 'equal'. Suena a pagar dos veces por lo mismo, y en escenas con sombreado barato lo es; pero en escenas donde el fragment shader cuesta —PBR con muchas luces, parallax, cualquier cosa con ramas— el prepass garantiza que cada píxel ejecuta el shader caro exactamente una vez, y eso convierte un coste proporcional al sobredibujado en un coste proporcional a la resolución. Y hay una trampa a la que casi todo el mundo llega tarde: para que equal funcione, las dos pasadas tienen que producir el mismo valor de profundidad bit a bit, lo que obliga a usar el mismo vertex shader y las mismas transformaciones. Si el prepass usa una versión simplificada del vertex shader, aunque sea matemáticamente equivalente, los valores difieren en el último bit y la escena se queda negra.
El attachment de profundidad y sus modos de solo lectura
El depthStencilAttachment acepta dos banderas que se olvidan y que resuelven problemas concretos: depthReadOnly y stencilReadOnly.
Cuando depthReadOnly es true, el pass promete que ningún pipeline escribirá profundidad. A cambio, depthLoadOp y depthStoreOp no se pueden especificar —serían contradictorios— y el contenido del búfer sobrevive intacto. Esto es exactamente lo que necesitas en dos casos: la pasada de transparencias que reutiliza la profundidad de la pasada opaca sin poder corromperla, y la lectura simultánea de la misma textura de profundidad como recurso muestreado en el mismo pass, algo que WebGPU permite solo si el aspecto está en modo solo lectura.
Ese segundo caso es la puerta a técnicas como las partículas suavizadas, que leen la profundidad de la escena para atenuar el borde donde una partícula intersecaría la geometría. Sin depthReadOnly tendrías que copiar la textura de profundidad a otra, con el coste completo de ancho de banda que eso supone.
const passTransparentes = encoder.beginRenderPass({
colorAttachments: [{ view: colorView, loadOp: 'load', storeOp: 'store' }],
depthStencilAttachment: {
view: depthTexture.createView(),
depthReadOnly: true, // sin depthLoadOp ni depthStoreOp
},
});
El pipeline que se use en ese pass tiene que declarar depthWriteEnabled: false, o la validación lo rechaza. Es la clase de error que WebGPU detecta al crear el pass en lugar de producir resultados corruptos en el dispositivo del usuario, y es una de las razones por las que este API es más agradable de depurar que su predecesor.
Monta una escena con tres cubos que se solapan y un cuarto objeto grande al fondo. Renderízala con depth32float y con depth16unorm, y coloca el plano cercano a 0.01 con el lejano a 10000. Con dieciséis bits verás z-fighting a plena vista en el objeto del fondo. Después mueve el plano cercano a 1.0 y observa cómo el mismo formato deja de tener problemas: es la demostración empírica de que el plano cercano, y no el lejano, es el parámetro que gobierna la precisión.