Occlusion queries: preguntar si algo se ve, y llegar tarde
La segunda consulta de WebGPU: contar muestras que pasan el test de profundidad, decidir con ella qué no dibujar, y por qué su latencia de dos fotogramas la ha ido dejando fuera de los motores modernos.
La forma más barata de dibujar un objeto es no dibujarlo. Las occlusion queries son el mecanismo que el hardware ofrece para averiguar si un objeto está tapado por otro: dibujas su caja envolvente con la escritura apagada y la GPU te dice cuántas muestras sobrevivieron al test de profundidad. La idea es de 1998, es correcta, y sigue funcionando. Lo que ha cambiado es que la respuesta tarda uno o dos fotogramas en llegar a tu código, y esa espera es la que ha ido empujando a los motores serios hacia otra solución.
- Crear y usar un query set de oclusión con
occlusionQuerySet,beginOcclusionQueryyendOcclusionQuery. - Aplicar las tres reglas de validación sin provocar errores en el encoder.
- Construir el pass de cajas envolventes con escritura de color y profundidad desactivadas.
- Argumentar cuándo la latencia de la consulta hace preferible el culling en compute.
Una consulta que no pide permiso
La primera diferencia con las timestamp queries es administrativa y muy práctica: la oclusión no es una feature opcional. No hay nada que comprobar en adapter.features, nada que pedir en requiredFeatures. Está en el núcleo de la especificación y funciona en cualquier dispositivo con WebGPU.
const CANDIDATOS = 256;
const querySet = device.createQuerySet({
label: "oclusion",
type: "occlusion",
count: CANDIDATOS,
});
"occlusion" y "timestamp" son los dos únicos valores de type, y el mismo límite de 4096 consultas se aplica aquí. La segunda diferencia es dónde se engancha: no va en timestampWrites sino en un campo propio del descriptor del render pass, occlusionQuerySet, y solo existe en render passes. No hay oclusión en compute, lo cual tiene sentido porque no hay test de profundidad que contar.
const pass = encoder.beginRenderPass({
colorAttachments: [{ view: vistaColor, loadOp: "load", storeOp: "store" }],
depthStencilAttachment: {
view: vistaProfundidad,
depthLoadOp: "load",
depthStoreOp: "store",
},
occlusionQuerySet: querySet,
});
Dentro del pass, cada consulta se abre y se cierra alrededor de los draws que quieres contar:
pass.beginOcclusionQuery(7);
pass.drawIndexed(36, 1, 0, 0, 7);
pass.endOcclusionQuery();
Las reglas de validación son tres y las tres son errores de encoder si las rompes:
- No se pueden anidar. Llamar a
beginOcclusionQuerycon otra consulta ya abierta es un error. Solo hay un contador activo por pass. - No se puede repetir un índice en el mismo pass. Cada
queryIndexes de un solo uso dentro de unbeginRenderPass. En passes distintos puedes reutilizarlo. - El índice debe ser menor que
count. Como siempre.
Y una cuarta implícita: hay que cerrar la consulta antes de pass.end(), y beginOcclusionQuery es un error si el descriptor del pass no traía occlusionQuerySet.
Nada te impide meter varios draws entre el begin y el end. El contador acumula, así que un objeto compuesto por tres mallas se puede probar con una sola consulta si dibujas las tres cajas seguidas. Lo que obtienes es la suma, que es justo lo que quieres: distinto de cero significa que algo de ese objeto se ve.
Contar muestras y dibujar una caja para no dibujar un objeto
El resultado de cada consulta es un entero de 64 bits: el número de muestras que pasaron todas las pruebas por fragmento. Muestras, no píxeles, y la distinción importa en cuanto activas antialiasing: con MSAA de cuatro muestras, un píxel completamente visible aporta 4 al contador, así que el mismo objeto en el mismo sitio devuelve un número cuatro veces mayor sin que nada haya cambiado. Si vas a usar la magnitud para algo —descartar objetos que ocupan menos de veinte píxeles, por ejemplo— divide por el número de muestras por píxel o el umbral dejará de significar lo mismo al cambiar la configuración.
La técnica clásica es esta: en lugar de probar la geometría real, que es cara, dibujas su caja envolvente, que son doce triángulos, y apagas todo lo que produce efectos secundarios. El objetivo es que ese draw no cambie ni un píxel del framebuffer ni un valor del buffer de profundidad; solo cuente.
Se apaga en dos sitios distintos del pipeline, y hay que hacer los dos:
const pipelineCaja = device.createRenderPipeline({
layout: layoutCajas,
vertex: {
module: moduloCaja,
entryPoint: "vs",
buffers: [{
arrayStride: 12,
attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }],
}],
},
fragment: {
module: moduloCaja,
entryPoint: "fs",
targets: [{ format: formatoLienzo, writeMask: 0 }], // ningun canal se escribe
},
primitive: {
topology: "triangle-list",
cullMode: "none", // critico: ver la nota de abajo
},
depthStencil: {
format: "depth24plus",
depthWriteEnabled: false, // la caja no ensucia la profundidad
depthCompare: "less-equal", // pero si se compara contra ella
},
});
writeMask: 0 es la máscara de canales vacía: la constante GPUColorWrite.ALL vale 15, y cero significa que ni rojo ni verde ni azul ni alfa llegan al attachment. depthWriteEnabled: false con depthCompare: "less-equal" es la otra mitad: la caja se compara contra la profundidad ya escrita por la escena, que es exactamente la prueba que quieres, pero no deja rastro.
cullMode: "none" es el detalle que arruina la técnica a mucha gente y no aparece en ningún tutorial. Si dejas el descarte de caras traseras y la cámara entra dentro de la caja envolvente de un objeto, las únicas caras que la cámara ve son las traseras, el descarte las elimina todas, la consulta devuelve cero y el objeto desaparece justo cuando lo tienes delante de la cara. Con "none" se dibujan las doce caras y el problema no existe.
El shader es corto porque la caja no tiene que parecerse a nada:
struct Camara { viewProj: mat4x4f }
struct Volumen { centro: vec3f, radio: f32 }
@group(0) @binding(0) var<uniform> camara: Camara;
@group(0) @binding(1) var<storage, read> volumenes: array<Volumen>;
const MARGEN: f32 = 1.15; // 15% de holgura, ver la seccion de latencia
@vertex
fn vs(@location(0) esquina: vec3f,
@builtin(instance_index) ii: u32) -> @builtin(position) vec4f {
let v = volumenes[ii];
return camara.viewProj * vec4f(v.centro + esquina * v.radio * MARGEN, 1.0);
}
@fragment
fn fs() -> @location(0) vec4f {
return vec4f(0.0); // writeMask 0: nunca llega al attachment
}
El bucle completo, con el cubo unitario subido una sola vez al arrancar:
// Cubo unitario centrado en el origen: 8 vertices, 36 indices.
const ESQUINAS = new Float32Array([
-1,-1,-1, 1,-1,-1, 1, 1,-1, -1, 1,-1,
-1,-1, 1, 1,-1, 1, 1, 1, 1, -1, 1, 1,
]);
const INDICES = new Uint16Array([
0,1,2, 0,2,3, 4,6,5, 4,7,6, 0,4,5, 0,5,1,
3,2,6, 3,6,7, 0,3,7, 0,7,4, 1,5,6, 1,6,2,
]);
pass.setPipeline(pipelineCaja);
pass.setBindGroup(0, grupoCajas);
pass.setVertexBuffer(0, bufferEsquinas);
pass.setIndexBuffer(bufferIndices, "uint16");
for (let i = 0; i < candidatos.length; i++) {
pass.beginOcclusionQuery(i);
pass.drawIndexed(36, 1, 0, 0, i); // firstInstance = i, indexa volumenes[]
pass.endOcclusionQuery();
}
pass.end();
La lectura del resultado usa exactamente la misma maquinaria que las timestamp queries y con las mismas reglas: resolveQuerySet a un búfer con GPUBufferUsage.QUERY_RESOLVE, destinationOffset múltiplo de 256, 8 bytes por consulta, y un copyBufferToBuffer a un segundo búfer con MAP_READ | COPY_DST porque esos dos flags no se pueden combinar. Está desarrollado paso a paso en timestamp queries; aquí cambia el tipo del query set y nada más.
encoder.resolveQuerySet(querySet, 0, candidatos.length, bufferResolucion, 0);
encoder.copyBufferToBuffer(bufferResolucion, 0, bufferLectura, 0, candidatos.length * 8);
// ...mas tarde, sin await en el bucle de fotograma:
const muestras = new BigUint64Array(bufferLectura.getMappedRange());
visible[i] = muestras[i] > 0n;
La latencia es el problema entero
Todo lo anterior es plomería. Esto es la lección.
El resultado de una occlusion query se escribe en la línea de tiempo de la GPU, y para que tu JavaScript lo vea tiene que viajar de vuelta: resolver, copiar, mapear, y esperar a que la GPU haya llegado a ese punto de la cola. Como la CPU va uno o dos fotogramas por delante grabando comandos, ese viaje de ida y vuelta tarda de uno a tres fotogramas. En 60 Hz, entre 16 y 50 milisegundos.
Solo hay dos salidas y las dos son incómodas.
La primera es bloquear: await sobre el mapAsync en el mismo fotograma. Funciona, la información es fresca, y el precio es sincronizar CPU y GPU cada fotograma, destruyendo el solapamiento. Una técnica de optimización que cuesta más de lo que ahorra no es una optimización. Descartada.
La segunda es aceptar el retraso y decidir con información vieja. Es lo que se hace, y hay que entender qué implica: estás pilotando la escena mirando por el retrovisor. Mientras la cámara está quieta o se mueve despacio, la escena de hace dos fotogramas y la de ahora son casi la misma y el sistema acierta. Cuando la cámara gira rápido, o un muro se abre, o un objeto sale de detrás de una columna, la respuesta que tienes describe un mundo que ya no existe.
Los dos tipos de error no cuestan lo mismo, y eso lo decide todo:
| Error | Qué pasa | Coste |
|---|---|---|
| Dibujar algo que estaba tapado | Trabajo desperdiciado | Unos microsegundos de GPU |
| Saltarse algo que se veía | Un agujero en la imagen | Un artefacto visible |
La asimetría es brutal, y de ella salen las dos correcciones estándar. La primera es ampliar la caja envolvente, ese MARGEN de 1,15 del shader. Una caja un 15 por ciento más grande empieza a asomar por el borde de la columna un par de fotogramas antes que el objeto real, que es justo el margen que necesitas para compensar el retraso de la consulta. Es histéresis pura: introduces un sesgo hacia “visible” para que el retraso del lazo no produzca agujeros. El precio es que dibujas algunos objetos que todavía no se ven, que es el error barato.
La segunda es no cerrar nunca el lazo. Un objeto que descartaste sigue siendo candidato: tienes que dibujar su caja cada fotograma aunque no dibujes el objeto. Si dejas de probarlo, no hay ninguna consulta que pueda devolverlo a la lista de visibles y desaparece para siempre. Esto tiene una consecuencia económica que conviene ver antes de escribir el código: el coste de las cajas es fijo y se paga siempre, se ahorre algo o no. Con 256 candidatos son 256 pares de beginOcclusionQuery y endOcclusionQuery, 256 draws y una resolución de 2 KB por fotograma. Si tus objetos son baratos, ese suelo se come el ahorro entero.
La forma productiva de pensar en esto no es “una API que devuelve un booleano tarde”, sino un sistema de control realimentado con retardo puro. Mides el estado del mundo, la medida tarda dos fotogramas en llegar, y actúas sobre el mundo con esa medida caducada. Cualquiera que haya sintonizado un PID reconoce la situación: con tiempo muerto en el lazo, subir la ganancia hace oscilar el sistema. Aquí la ganancia es lo agresivo que eres descartando, y la oscilación se ve como parpadeo: un objeto en el borde de una columna se declara oculto, deja de dibujarse, la caja sigue asomando, se declara visible, vuelve, y a 60 Hz eso es un objeto que titila. Las dos correcciones que usa todo el mundo son exactamente las que prescribe la teoría de control. El margen del 15 por ciento en la caja es histéresis: ensancha la banda muerta para que el ruido no cruce el umbral en los dos sentidos. Y probar siempre, incluso lo descartado, es mantener el lazo cerrado. Hay una tercera que se usa menos y es la más elegante: exigir N fotogramas consecutivos con cero muestras antes de descartar, y uno solo con muestras para volver a dibujar. Esa asimetría deliberada —lento para apagar, instantáneo para encender— es el filtro correcto cuando los dos errores tienen costes tan distintos, y convierte el parpadeo en un no-problema por unos pocos objetos dibujados de más. Ninguna de las tres es un truco de gráficos: son las respuestas conocidas a un retardo en un lazo, y por eso funcionan.
El veredicto frente al culling en compute
La alternativa moderna hace la misma pregunta sin sacar la respuesta de la GPU. Un compute shader recorre la lista de objetos, prueba cada caja envolvente contra el frustum y contra una pirámide de profundidad jerárquica, y escribe directamente el búfer de argumentos que consumirá un drawIndexedIndirect. El resultado no vuelve nunca a JavaScript: la GPU decide y la GPU dibuja, en el mismo envío.
Merece la pena ser honesto sobre en qué gana y en qué no.
Gana en lo que importa: no hay viaje de ida y vuelta, así que no hay latencia de decisión. Y gana en escala, porque probar cien mil objetos en compute son unos pocos cientos de microsegundos, mientras que cien mil pares de begin y end de consulta son cien mil comandos que la CPU tiene que grabar uno a uno.
No gana en frescura de los datos, y esto se cuenta mal a menudo. La pirámide de profundidad contra la que se prueba se construye normalmente a partir del buffer de profundidad del fotograma anterior, porque el del actual todavía no existe cuando hay que decidir. Así que el culling en compute también decide con información de hace un fotograma. La diferencia es que uno es un fotograma de retraso, no tres, y sobre todo que la CPU no participa: el lazo entero vive dentro de la GPU y se cierra en el mismo envío.
Dicho esto, las occlusion queries siguen teniendo su sitio, y es un sitio pequeño y bien definido: pocos candidatos, cada uno muy caro. Docenas, no miles. Una sala contigua con doscientos mil triángulos detrás de una puerta. Un sistema de portales en un interior. Un modelo CAD gigante dividido en subensamblajes. En esos casos, cada acierto ahorra milisegundos y el suelo de coste de veinte cajas es ruido.
El criterio operativo cabe en una frase: si el coste medio de dibujar un candidato no supera con holgura el coste de probarlo más el riesgo del artefacto, no uses occlusion queries. Y si tienes miles de candidatos, tampoco: ese es territorio de compute.
- Monta el pass de cajas con veinte objetos y comprueba que
writeMask: 0no deja rastro en la imagen. - Pon
cullMode: "back"y mete la cámara dentro de una caja para ver el objeto desaparecer. - Mide con timestamps el coste del pass de cajas y compáralo con lo que ahorras al descartar.
- Añade el margen del 15 por ciento y cuenta cuántos artefactos de aparición tardía desaparecen al girar la cámara rápido.
- Implementa la regla de tres fotogramas para apagar y uno para encender, y comprueba que el parpadeo se acaba.