wandres.dev
DEPTH Y STENCIL · Profundidad y máscaras

Las ocho funciones de comparación y qué escena pide cada una

El catálogo completo de GPUCompareFunction, el orden exacto de la comparación, y las técnicas concretas que dependen de elegir una función distinta de la obvia.

⏱ 16 min

La prueba de profundidad es una comparación entre dos números, y WebGPU te deja elegir cuál de los ocho operadores posibles se usa. Siete de esos ocho parecen decorativos hasta que te encuentras el problema que cada uno resuelve: el skybox que se dibuja al final sin desperdiciar sombreado, la segunda pasada que solo toca los píxeles que ya ganaron, el volumen de luz que se recorta contra la geometría. El operador no es un detalle de configuración, es la mitad del algoritmo.

🎯 Al terminar esta lección sabrás
  • Enumerar los ocho valores de GPUCompareFunction y el sentido exacto de la comparación.
  • Justificar por qué less es la elección por defecto y qué implica sobre el valor de limpieza.
  • Aplicar equal, always y greater-equal a técnicas concretas.
  • Reconocer cuándo depthCompare no es obligatorio en el descriptor.

El orden de la comparación, que es lo que casi siempre se confunde

La comparación se lee fragmento primero, búfer después. Con depthCompare: 'less', el fragmento pasa la prueba si su profundidad es menor que la almacenada. Es decir: si está más cerca. Ese orden importa porque los nombres son ambiguos y la documentación de las APIs nativas lo ha expresado al revés en distintas épocas.

Los ocho operadores son exactamente estos:

Valor El fragmento pasa si
never nunca
less su profundidad es menor que la almacenada
equal es igual
less-equal es menor o igual
greater es mayor
not-equal es distinta
greater-equal es mayor o igual
always siempre

Son las mismas ocho de la prueba de estarcido y las mismas ocho de un sampler de comparación para sombras. Es un único enum, GPUCompareFunction, reutilizado en tres sitios del API.

Con less como operador, el valor de limpieza coherente es 1.0, el máximo del rango: un búfer «vacío» significa infinitamente lejos, y cualquier fragmento gana contra él. Los dos van siempre juntos, y si cambias uno sin cambiar el otro la escena se vacía o se llena de basura. Esa pareja —operador y valor de limpieza— es el eje sobre el que gira la técnica del reversed-z, que consiste precisamente en invertir ambos a la vez.

Cuándo la respuesta no es less

less-equal para dibujar dos veces la misma superficie. Si necesitas repasar geometría ya dibujada —una capa de decorado, un contorno, una segunda pasada de iluminación aditiva— con less el segundo dibujado falla contra sí mismo, porque su profundidad es exactamente igual, no menor. less-equal deja pasar el empate. Es el operador correcto para los materiales de multipasada y para el dibujado de un mismo objeto con varios pipelines.

equal para la segunda mitad de un prepass. Cuando ya has escrito toda la profundidad opaca en una pasada previa, la pasada de color se configura con depthWriteEnabled: false y depthCompare: 'equal'. Solo sobreviven los fragmentos que ganaron la primera vez, es decir, exactamente los visibles. El sobredibujado del fragment shader caro baja a cero. La condición es que las dos pasadas produzcan valores idénticos bit a bit, lo cual obliga a compartir el vertex shader.

always para el skybox y para las pasadas de pantalla completa. Un triángulo que cubre la pantalla para aplicar un efecto no debe compararse con nada. Y un skybox tiene un truco que conviene conocer: si en el vertex shader devuelves la posición con z igual a w, tras la división en perspectiva la profundidad sale exactamente 1.0, el fondo absoluto. Dibujándolo al final con depthCompare: 'less-equal' y depthWriteEnabled: false, solo se sombrean los píxeles que ninguna geometría ha ocupado. Un skybox dibujado al principio se sombrea entero y luego se tapa; dibujado al final, se sombrea solo el cielo visible.

struct VsOut {
  @builtin(position) pos: vec4f,
  @location(0) dir: vec3f,
}

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VsOut {
  // Triangulo que cubre toda la pantalla: (-1,-1), (3,-1), (-1,3).
  let xy = vec2f(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0);
  var out: VsOut;
  // z = w hace que z_ndc valga exactamente 1.0 tras la division.
  out.pos = vec4f(xy, 1.0, 1.0);
  // Direccion del rayo, reconstruida con la inversa de vista-proyeccion.
  let mundo = camara.invViewProj * vec4f(xy, 1.0, 1.0);
  out.dir = normalize(mundo.xyz / mundo.w - camara.pos);
  return out;
}

@fragment
fn fs(in: VsOut) -> @location(0) vec4f {
  return textureSample(cielo, muestreador, in.dir);
}

El pipeline de ese shader lleva depthCompare: 'less-equal' —no less, porque el valor de limpieza es 1.0 y less fallaría contra él— y depthWriteEnabled: false.

greater y greater-equal para el reversed-z, donde toda la escena se dibuja con el orden invertido. Y también, en su forma clásica, para el recorte de volúmenes de luz en un renderizador diferido: se dibuja la cara trasera de la esfera de luz con greater, de modo que solo se sombrean los píxeles cuya geometría está dentro del volumen.

never y not-equal casi no aparecen en profundidad. Tienen sentido en la prueba de estarcido, donde el búfer guarda etiquetas y no distancias.

depthCompare no siempre es obligatorio

El descriptor de pipeline tiene una regla de validación que sorprende la primera vez: depthCompare y depthWriteEnabled solo son obligatorios si el formato tiene componente de profundidad. Con un stencil8 puro, no los pones. Y a la inversa: si depthWriteEnabled es true o depthCompare es distinto de always, el formato tiene que tener componente de profundidad. La validación te obliga a ser explícito sobre para qué usas el attachment.

Hay un caso más que merece nombre propio: un pipeline sin bloque fragment. WebGPU lo permite, y no es un error. Un pipeline sin fragment shader rasteriza, calcula profundidad y ejecuta la prueba de estarcido, pero no produce ningún color. Es la forma canónica del depth prepass y de la generación de shadow maps: dos casos donde el color sobra y donde tener un fragment shader vacío sería pagar por nada.

const prepass = device.createRenderPipeline({
  label: 'depth prepass',
  layout: pipelineLayout,
  vertex: { module, entryPoint: 'vs' },
  // Sin bloque fragment: no hay color attachment que rellenar.
  primitive: { topology: 'triangle-list', cullMode: 'back' },
  depthStencil: {
    format: 'depth32float',
    depthWriteEnabled: true,
    depthCompare: 'less',
  },
});
El operador no ordena tu escena: la ordenación de dibujado sigue siendo tuya y sigue importando

El búfer de profundidad resuelve la visibilidad sin importar el orden, y de ahí sale la creencia extendida de que con depth test uno puede dibujar como quiera. El resultado final es efectivamente el mismo, pero el coste no lo es en absoluto. Si dibujas de atrás hacia delante, cada píxel ejecuta el fragment shader tantas veces como capas de geometría lo cubran, porque cada nueva capa gana la comparación y sobrescribe. Si dibujas de delante hacia atrás, la primera capa gana y todas las demás fallan la prueba antes de ejecutar el shader, siempre que la prueba temprana esté activa. La diferencia en una escena con sobredibujado de cinco es literalmente cinco veces el coste de fragmento. Por eso los motores ordenan los opacos de delante a atrás aproximadamente —por grupos, por distancia del centro, con cualquier heurística barata; no hace falta que sea exacta— y los transparentes de atrás a delante por obligación matemática. Y de ahí sale el criterio para decidir si un depth prepass compensa: el prepass hace que el sobredibujado sea irrelevante a costa de duplicar el coste de vértice y de una pasada extra de rasterización, así que gana cuando el fragment shader es caro y el sobredibujado es alto, y pierde en escenas con mucha geometría y sombreado barato. Medir cuál de los dos casos es el tuyo requiere el nivel de los timestamp queries; adivinarlo, no.