wandres.dev
INSTANCING E INDIRECT · Menos llamadas, más objetos

Generar los comandos de dibujo desde un compute shader

El patrón en el que la CPU graba siempre la misma lista y la GPU decide cuánto se dibuja, con el encadenado de pases, la limpieza de contadores y la indirección que sustituye a firstInstance.

⏱ 20 min

Hasta aquí, el número de objetos que se dibujan lo decidía JavaScript. Con el búfer de argumentos en memoria de vídeo, ese número puede decidirlo un compute shader que ha examinado la escena entera en paralelo mientras la CPU estaba ocupada en otra cosa. La lista de comandos que graba el hilo principal se convierte en una constante: los mismos comandos, fotograma tras fotograma, con contenido que cambia por completo. Es el cambio de arquitectura más grande que permite esta API y cabe en unas cincuenta líneas.

🎯 Al terminar esta lección sabrás
  • Encadenar un compute pass y un render pass que compartan un búfer de argumentos.
  • Declarar los usos correctos de un búfer que se escribe como storage y se lee como indirecto.
  • Reiniciar los contadores de cada fotograma sin sincronizar con la CPU.
  • Sustituir firstInstance por una indirección en el vertex shader.

El fotograma completo

function fotograma() {
  const encoder = device.createCommandEncoder({ label: 'fotograma' });

  // 1. Poner a cero los contadores y los instanceCount del fotograma anterior.
  //    clearBuffer es un comando del encoder: no sincroniza con la CPU.
  encoder.clearBuffer(contadores);

  // 2. Decidir que se dibuja. Una invocacion por objeto de la escena.
  const cull = encoder.beginComputePass({ label: 'seleccion' });
  cull.setPipeline(pipeSeleccion);
  cull.setBindGroup(0, grupoSeleccion);
  cull.dispatchWorkgroups(Math.ceil(numObjetos / 64));
  cull.end();

  // 3. Convertir los contadores en argumentos de dibujo.
  const prep = encoder.beginComputePass({ label: 'argumentos' });
  prep.setPipeline(pipeArgumentos);
  prep.setBindGroup(0, grupoArgumentos);
  prep.dispatchWorkgroups(Math.ceil(numLotes / 64));
  prep.end();

  // 4. Dibujar. Ni un solo numero de escena en esta parte.
  const render = encoder.beginRenderPass(descriptorRender);
  render.setPipeline(pipeline);
  render.setBindGroup(0, grupoEscena);
  render.setVertexBuffer(0, megaVertices);
  render.setIndexBuffer(megaIndices, 'uint32');
  for (let i = 0; i < numLotes; i++) {
    render.drawIndexedIndirect(argumentos, i * 20);
  }
  render.end();

  device.queue.submit([encoder.finish()]);
}

Cuatro cosas de ese código merecen comentario.

clearBuffer es un comando, no una operación de cola. Se graba en el encoder y se ejecuta en la GPU en su turno, así que pone los contadores a cero justo antes del compute que los va a usar, sin bloquear nada.

El búfer de argumentos se escribe como storage y se lee como indirecto en el mismo command buffer. Eso exige que tenga los dos usos, y la transición entre ambos la inserta la implementación automáticamente: no hay barreras que escribir.

El bucle de dibujado tiene longitud numLotes, que es el número de combinaciones distintas de malla y material, no el número de objetos. Es la única cantidad que la CPU necesita conocer, es estable entre fotogramas y depende de la variedad del contenido, no de su cantidad.

No hay ninguna lectura desde la GPU. Ni un mapAsync, ni un await, ni un contador que cruce el bus. Esa ausencia es la propiedad que define la arquitectura.

flowchart TB
A[La CPU graba siempre la misma lista de comandos] --> B[Compute que examina cada objeto en paralelo]
H[Buffer de instancias en storage] --> B
B --> C[Contador atomico de visibles por lote]
B --> D[Lista compactada de indices de instancia]
C --> E[Compute que rellena instanceCount de cada lote]
E --> F[Buffer indirecto con veinte bytes por lote]
D --> G[Render pass con un drawIndexedIndirect por lote]
F --> G
H --> G
G --> I[Imagen final]
J[Ningun dato vuelve a la CPU en todo el fotograma] --> A
style A fill:#89b4fa,color:#11111b
style B fill:#fab387,color:#11111b
style E fill:#fab387,color:#11111b
style C fill:#94e2d5,color:#11111b
style D fill:#94e2d5,color:#11111b
style H fill:#94e2d5,color:#11111b
style F fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b
style I fill:#a6e3a1,color:#11111b
style J fill:#cba6f7,color:#11111b

Los dos compute shaders

El primero decide y cuenta. Cada invocación mira un objeto, aplica el criterio que sea —volumen envolvente contra el frustum, distancia máxima, bandera de visibilidad— y si lo acepta reserva una posición en la lista compactada de su lote con una operación atómica:

@group(0) @binding(0) var<storage, read>       objetos    : array<Objeto>;
@group(0) @binding(1) var<storage, read_write> contadores : array<atomic<u32>>;
@group(0) @binding(2) var<storage, read_write> compactada : array<u32>;
@group(0) @binding(3) var<uniform>             camara     : Camara;

@compute @workgroup_size(64)
fn seleccionar(@builtin(global_invocation_id) gid : vec3u) {
  let i = gid.x;
  if (i >= arrayLength(&objetos)) { return; }

  let obj = objetos[i];
  if (!visibleEnFrustum(obj.centro, obj.radio, camara)) { return; }

  // Reserva atomica: devuelve la posicion anterior y suma uno.
  let ranura = atomicAdd(&contadores[obj.lote], 1u);
  compactada[obj.lote * MAX_POR_LOTE + ranura] = i;
}

El segundo traduce contadores a argumentos, con una invocación por lote. Es el shader de la lección anterior: copia el contador al campo instanceCount y deja el resto de campos con los datos estáticos del lote.

Lo importante es qué ocurre cuando un lote se queda sin objetos visibles. Su instanceCount vale cero, la llamada correspondiente se ejecuta igualmente y no produce ningún vértice. El comando sigue en la lista y cuesta lo que cuesta que el procesador de comandos lea veinte bytes y decida que no hay trabajo, que es despreciable. Esa propiedad es la que permite que la lista grabada por la CPU sea constante.

La indirección que sustituye a firstInstance

El vertex shader recibe instance_index en el rango denso de cero a instanceCount - 1, y la lista compactada lo traduce al índice real dentro del búfer de objetos:

@vertex
fn vs(
  @location(0) posLocal : vec3f,
  @builtin(instance_index) ii : u32,
) -> Salida {
  // Un acceso de mas, y a cambio firstInstance se queda en cero.
  let indiceReal = compactada[baseDelLote + ii];
  let obj = objetos[indiceReal];

  var s : Salida;
  s.pos = camara.viewProj * obj.modelo * vec4f(posLocal, 1.0);
  return s;
}

Ese acceso extra es lo que evita necesitar la feature indirect-first-instance y su comportamiento silencioso. Cuesta una lectura de memoria por vértice que además está perfectamente coalescida —todos los vértices de una instancia leen la misma posición— y sale mucho más barato que depender de una funcionalidad opcional.

Un drawIndirect cuesta a la CPU exactamente lo mismo que un draw, y quien espera lo contrario está midiendo la ganancia equivocada

El malentendido más extendido sobre el dibujado indirecto es pensar que es una llamada más barata. No lo es: drawIndexedIndirect graba un comando en el búfer igual que drawIndexed, con un coste de CPU prácticamente idéntico y una validación equivalente. Si sustituyes cuatrocientas llamadas directas por cuatrocientas indirectas, tu tiempo de CPU no baja. Lo que compras no es velocidad por llamada, es independencia de la información. Y esa distinción, que suena filosófica, es la que decide arquitecturas. Piensa en qué hace un renderizador clásico para saber cuántos objetos dibujar: recorre la escena en la CPU, calcula volúmenes envolventes, los prueba contra el frustum, ordena, y con el resultado emite las llamadas. Todo ese trabajo es proporcional al número de objetos, ocurre en un solo hilo y compite con la lógica de la aplicación. Con dibujado indirecto, ese trabajo se hace en la GPU con miles de invocaciones en paralelo y la CPU no necesita el resultado, que es el punto clave: no lo necesita para grabar los comandos, porque los comandos ya no contienen ese número. La ganancia no está en la llamada de dibujo sino en todo lo que dejas de hacer antes de llegar a ella. De ahí sale el corolario que hay que tener presente al diseñar: en cuanto la GPU decide algo, la CPU solo puede enterarse con un fotograma o dos de retraso, porque leer un búfer de la GPU exige esperar a que termine el trabajo. Si tu lógica de aplicación necesita saber cuántos objetos son visibles —para un indicador, para una decisión de nivel de detalle, para una prueba de rendimiento—, esa cifra llegará tarde, y el diseño correcto es asumir el retraso, no forzar la sincronización. Los renderizadores que intentan tener lo mejor de los dos mundos leyendo el contador cada fotograma acaban con un tiempo de fotograma peor que el del renderizador clásico del que venían, y con la arquitectura complicada de propina.

Lo que la CPU sigue teniendo que saber

Conviene delimitar bien la frontera, porque es más estrecha de lo que parece.

La CPU sigue decidiendo cuántos comandos hay: el bucle de drawIndexedIndirect tiene una longitud que se graba en JavaScript. La GPU puede vaciar lotes, pero no puede crear uno que no estuviera grabado.

La CPU sigue decidiendo el pipeline y los bind groups de cada comando. Un compute shader no puede cambiar de pipeline ni de material.

Y la CPU sigue decidiendo el orden. Los comandos se ejecutan en el orden grabado, así que una ordenación por profundidad de los transparentes no se puede delegar a la GPU con este mecanismo, solo el contenido de cada lote.

Dentro de esos límites, la escena puede crecer todo lo que quiera sin que el coste de CPU se mueva un microsegundo. Fuera de ellos hacen falta funcionalidades que WebGPU todavía no tiene, y de eso trata la última lección del nivel.