wandres.dev
COMMAND ENCODING · El modelo de comandos

finish y el GPUCommandBuffer inmutable

Qué produce finish, por qué el resultado es inmutable y de un solo uso, qué valida en ese momento, y en qué se diferencia de un render bundle.

⏱ 15 min

finish() es la frontera entre lo que puedes cambiar y lo que ya está decidido. Antes, tienes un encoder mutable al que se le añaden comandos. Después, un objeto opaco, inmutable y de un solo uso que solo sirve para entregárselo a la cola. Esa asimetría es intencionada y responde a un objetivo concreto: que la implementación pueda traducir la secuencia completa una sola vez, sin miedo a que cambie.

🎯 Al terminar esta lección sabrás
  • Describir qué produce finish() y qué valida en ese momento.
  • Explicar por qué el command buffer es inmutable y de un solo uso.
  • Diferenciar un GPUCommandBuffer de un GPURenderBundle.
  • Reconocer los errores que produce el ciclo de vida del encoder.

Qué hace finish

const commandBuffer = encoder.finish({ label: 'frame 1234' });

El descriptor solo admite label. La llamada hace tres cosas.

Cierra el encoder. A partir de ahí, cualquier método del encoder es un error de validación. El encoder queda inservible y su única función es haber producido el búfer.

Valida la secuencia completa. Que todos los pases se cerraron, que los comandos son coherentes, que los recursos referenciados siguen vivos, que no hay conflictos de uso dentro de un pase. Si algo falla, el command buffer resultante es inválido y el error se emite por el canal habitual.

Produce el objeto. Un GPUCommandBuffer con una sola propiedad, label. No tiene métodos. No se puede inspeccionar, ni recorrer, ni modificar.

Que no tenga API no es una omisión: es el punto. El command buffer no es una estructura de datos que tu código manipule, es un identificador de trabajo ya preparado. Su contenido real vive en el proceso de GPU, posiblemente ya traducido a comandos nativos.

Inmutable y de un solo uso

Las dos propiedades son distintas y las dos importan.

Inmutable significa que después de finish() no hay forma de añadir, quitar ni cambiar nada. La razón es la traducción: la implementación quiere convertir la secuencia a comandos nativos en el momento en que sabe que está completa, y no volver a mirarla. Si el búfer pudiera cambiar, esa traducción habría que rehacerla o diferirla.

De un solo uso significa que se envía una vez. Enviar el mismo command buffer dos veces es un error de validación, aunque el trabajo que describe sea perfectamente repetible.

Esta segunda restricción sorprende y su motivo es la sincronización. Un command buffer describe transiciones de estado de recursos: esta textura pasa de destino de render a fuente de lectura, este búfer pasa de escritura a lectura. Esas transiciones se calcularon suponiendo un estado inicial concreto. Si el búfer se ejecutara dos veces, la segunda partiría de un estado distinto y las transiciones serían incorrectas. Volver a validarlas en cada envío costaría lo que se quería ahorrar.

De ahí un patrón que hay que aceptar: grabar es una operación de cada fotograma. No se pueden cachear command buffers entre fotogramas. Como grabar es barato, en la mayoría de los casos no importa.

⚠️
Los recursos tienen que seguir vivos

Un command buffer referencia búferes, texturas y pipelines. Si destruyes cualquiera de ellos entre finish() y submit(), el envío falla. Es fácil que ocurra al redimensionar: se graba el fotograma, llega un evento de tamaño que destruye y recrea el búfer de profundidad, y el submit posterior referencia una textura destruida. Por eso el ajuste de tamaño va al principio del fotograma y no en un manejador de eventos que se dispara en cualquier momento.

Command buffer frente a render bundle

Cuando la grabación sí resulta ser un coste medible, existe una segunda herramienta que sí es reutilizable, y conviene tener clara la diferencia.

Un GPURenderBundle se crea con un GPURenderBundleEncoder y contiene una secuencia de comandos de render pass: setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer, draw y sus variantes. Se reproduce dentro de un pase con pase.executeBundles([bundle]), y se puede reproducir tantas veces y en tantos fotogramas como quieras.

const bundleEncoder = device.createRenderBundleEncoder({
  label: 'geometria estatica',
  colorFormats: [FORMATO_CANVAS],
  depthStencilFormat: 'depth24plus',
});
bundleEncoder.setPipeline(pipeline);
bundleEncoder.setBindGroup(0, grupoEscena);
bundleEncoder.setVertexBuffer(0, vertices);
bundleEncoder.draw(cuenta);
const bundle = bundleEncoder.finish();

// En cada fotograma, dentro de un pase compatible:
pase.executeBundles([bundle]);

Las diferencias que importan:

Command buffer Render bundle
Contiene Pases completos, copias, todo Solo comandos de dentro de un render pass
Reutilizable No Sí, muchas veces
Se ejecuta con queue.submit pase.executeBundles
Necesita saber Nada de antemano Los formatos de los adjuntos al crearlo

El bundle requiere declarar colorFormats y opcionalmente depthStencilFormat y sampleCount al crearlo, porque tiene que ser compatible con los pases donde se reproduzca. Y no puede contener nada que dependa del estado del pase que no sea el suyo: no admite setViewport ni setScissorRect ni consultas de oclusión.

Los bundles pagan cuando la misma secuencia de dibujos se repite sin cambios durante muchos fotogramas y esa secuencia es larga. Geometría estática de un nivel, interfaz que no cambia, decorados. Si la escena cambia cada fotograma, regrabar el bundle cuesta lo mismo que grabar los comandos, y no hay ganancia.

Los errores del ciclo de vida

Cuatro, y todos producen mensajes que apuntan al sitio correcto si has puesto etiquetas.

finish() con un pase abierto. Falta un end(). El command buffer es inválido.

Usar el encoder después de finish(). Suele venir de guardar el encoder en una variable de módulo y reutilizarlo por error entre fotogramas.

Enviar el mismo command buffer dos veces. Aparece cuando alguien intenta cachear el fotograma anterior para «ahorrarse la grabación».

Enviar un command buffer que referencia recursos destruidos. El caso del redimensionado.

Que la grabación sea barata no significa que sea gratis, y el punto en que deja de serlo es reconocible

Es fácil pasar del reflejo de WebGL —minimizar llamadas a toda costa— al reflejo contrario, que es asumir que grabar no cuesta nada y no volver a mirarlo. Hay un punto en el que sí cuesta y conviene saber cómo se detecta.

La señal es concreta: el hilo principal está ocupado durante todo el fotograma, el perfilador muestra que la mayor parte de ese tiempo está dentro de métodos del pase de render, y la GPU está infrautilizada. Ahí la grabación es el cuello, y ocurre a partir del orden de varios miles de dibujos por fotograma, o antes si cada dibujo va precedido de trabajo de JavaScript.

Y ahí hay tres respuestas, en orden de cuánto rinden. La primera, y casi siempre la buena, es la instanciación: mil objetos con la misma malla y el mismo material son un draw con instanceCount de mil, y los datos por instancia salen de un búfer de almacenamiento indexado por @builtin(instance_index). Pasa de mil comandos grabados a uno. La segunda son los render bundles, que ayudan cuando los objetos son distintos entre sí pero la secuencia se repite entre fotogramas. La tercera, y la que de verdad quita el problema de en medio, es el dibujado indirecto: un compute shader hace el descarte por frustum, escribe los parámetros de dibujado en un búfer con usage INDIRECT, y la CPU graba un solo drawIndexedIndirect sin saber cuántos objetos habrá. La CPU deja de escalar con el número de objetos.

La observación que ordena las tres: cada una elimina la grabación de una forma distinta y ninguna es una optimización local. No se aplican al final, sobre un renderizador ya escrito; condicionan cómo se guardan los datos de la escena. Por eso conviene decidir cuál necesitas cuando estimas el número de objetos, y no cuando mides que va lento.

Falta el destinatario final: la cola y el submit.