Qué se puede grabar en un bundle y qué no
La lista exacta de métodos que expone el codificador de bundles, los cuatro que faltan y por qué faltan, y la regla de limpieza de estado que rompe el código de quien no la conoce.
El codificador de bundles es casi el mismo objeto que el codificador de render pass, y ese casi es donde está todo. Faltan cuatro métodos, y los cuatro tienen en común algo que explica la ausencia mejor que cualquier lista: son estado que pertenece al pass, no al dibujado. Y hay además una regla sobre qué le pasa al estado cuando se ejecutan bundles que no es intuitiva en absoluto y que produce un bug muy concreto, muy común y muy fácil de diagnosticar una vez lo has visto una vez.
- Enumerar los métodos disponibles en un codificador de bundles.
- Justificar por qué faltan el viewport, el scissor, la constante de mezcla y la referencia de estarcido.
- Aplicar la regla de limpieza de estado alrededor de
executeBundles(). - Ordenar los comandos de un pass para no pagar dos veces la configuración.
Lo que hay
El codificador de bundles expone exactamente estos métodos:
setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer, draw, drawIndexed, drawIndirect, drawIndexedIndirect, pushDebugGroup, popDebugGroup, insertDebugMarker y finish.
Es decir: todo lo que emite geometría y todo lo que vincula recursos, más las marcas de depuración. Los setBindGroup con desplazamientos dinámicos están permitidos y los desplazamientos quedan grabados como cualquier otro argumento. Los dibujados indirectos están permitidos, y como sus argumentos viven en un búfer, son la vía para tener contenido dinámico dentro de comandos fijos.
Lo que no hay, y por qué
Faltan cuatro, y ninguno es un olvido:
setViewport y setScissorRect definen la región de la pantalla sobre la que se dibuja. Son propiedades del destino, no de los objetos: dos passes que dibujen la misma escena en dos mitades de la pantalla quieren el mismo bundle con viewports distintos, y si el viewport estuviera dentro del bundle harían falta dos.
setBlendConstant fija el color constante que consumen los factores de mezcla constant y one-minus-constant. Es un valor global del pass y, como se explicó en la ecuación de mezcla, se reinicia en cada render pass.
setStencilReference fija el valor de referencia de la prueba de estarcido, que también vive en el pass y se reinicia a cero al empezarlo. Su ausencia tiene una consecuencia práctica concreta: las técnicas de estarcido que cambian la referencia entre objetos no se pueden meter en un bundle. Contornos por objeto, portales, máscaras por identificador: todo eso se queda fuera y hay que dibujarlo directamente en el pass.
beginOcclusionQuery y endOcclusionQuery faltan por la misma razón: el conjunto de consultas se declara en el descriptor del pass y los índices son globales al pass.
Y falta executeBundles, es decir, un bundle no puede ejecutar otro bundle. No hay anidamiento. La estructura es de dos niveles y punto.
Los cuatro valores ausentes se heredan del pass que ejecuta el bundle. Si fijas un viewport y después ejecutas un bundle, el bundle dibuja con ese viewport. Es el comportamiento que quieres y hace que la ausencia de esos métodos casi nunca moleste.
La regla que rompe el código
Esta es la parte que hay que leer dos veces. Alrededor de executeBundles(), el estado del render pass se limpia:
Antes de ejecutar los bundles, el pipeline, los bind groups, los búferes de vértices y el búfer de índices fijados en el pass se descartan, de modo que el bundle empieza con el estado en blanco y tiene que fijar todo lo que necesite. Por eso un bundle es siempre autocontenido.
Y después de ejecutar los bundles, el estado vuelve a quedar limpio. Lo que el bundle fijó no sobrevive, y lo que el pass tenía fijado antes tampoco ha vuelto. La especificación es explícita en un punto que remata la trampa: eso ocurre aunque el array de bundles esté vacío.
const pass = encoder.beginRenderPass(descriptorRender);
pass.setPipeline(pipelineOpaco);
pass.setBindGroup(0, grupoEscena);
pass.setVertexBuffer(0, vertices);
pass.executeBundles([bundleEstatico]); // aqui se limpia todo
// ERROR: no hay pipeline fijado. La validacion lo rechaza.
// pass.draw(3);
// Correcto: volver a fijar lo que haga falta.
pass.setPipeline(pipelineOpaco);
pass.setBindGroup(0, grupoEscena);
pass.setVertexBuffer(0, vertices);
pass.draw(3);
pass.end();
El síntoma cuando esto se te escapa suele ser un error de validación claro —«no hay pipeline»— y por tanto es de los buenos. Pero hay una variante silenciosa: si el código que sigue al executeBundles fija el pipeline pero da por hecho que el bind group 1 sigue vinculado del bloque anterior, la validación puede pasar por casualidad si el layout coincide, y estarás dibujando con el material equivocado.
Es fácil leer la limpieza de estado como una torpeza del API que obliga a repetir configuración. Es exactamente lo contrario, y verlo cambia cómo se diseñan los bundles. Si el estado se filtrara hacia dentro, un bundle dependería de lo que se hubiera ejecutado antes que él: el mismo bundle daría resultados distintos según su posición en el pass, no se podría reordenar, no se podría reutilizar en otro pass, y la validación de sus comandos no se podría hacer en el momento de grabarlo porque dependería de un contexto futuro. La limpieza es lo que permite que finish() valide la secuencia entera de una vez y que el objeto resultante sea verdaderamente reutilizable. Es la misma decisión de diseño que la inmutabilidad de un pipeline, aplicada a una secuencia de comandos. Ahora la consecuencia práctica, que es donde está el valor: como cada bundle paga su propia configuración inicial, hay un tamaño mínimo por debajo del cual un bundle no compensa. Si tu bundle contiene tres dibujados y cuatro comandos de configuración, has convertido siete comandos por fotograma en siete comandos pregrabados más el coste de ejecutar el bundle; la ganancia es casi nula. Si contiene doscientos dibujados que comparten la mayor parte de la configuración, la proporción entre trabajo útil y configuración es excelente. La regla operativa que sale de ahí, y que rara vez se enuncia, es que la granularidad correcta de un bundle no la decide la organización lógica de tu escena sino el número de comandos que contiene: agrupa por lo que cambie a la vez, sí, pero con un suelo de varias decenas de dibujados por bundle. Un bundle por objeto es siempre un error. Un bundle por material puede serlo si tienes muchos materiales con pocos objetos cada uno. Y el patrón que casi siempre funciona es un bundle por frecuencia de cambio —todo lo que no cambia nunca en uno, lo que cambia al cargar una zona en otro— con lo dinámico fuera de los bundles y dibujado directamente. Y una vez adoptado ese patrón, ordena el pass en bloques: todos los bundles seguidos primero, todos los dibujados directos después. Alternar bundles y dibujados directos te hace pagar la reconfiguración del estado en cada transición.
Compatibilidad al ejecutar
Al llamar a executeBundles(), la validación comprueba que la forma declarada en el descriptor del bundle sea compatible con el pass: los formatos de color en el mismo orden, el formato de profundidad y estarcido, y el número de muestras.
Además hay dos comprobaciones sobre las banderas de solo lectura que conviene tener presentes porque su lógica no es simétrica: si el pass tiene depthReadOnly en true, el bundle también tiene que declararlo, porque un bundle que pudiera escribir profundidad violaría la promesa del pass. Lo mismo con el estarcido.
El array de executeBundles puede contener varios bundles y se ejecutan en orden. Cada uno empieza y termina con el estado limpio, así que son independientes entre sí: su orden decide el orden de dibujado y nada más. Pasar varios en una sola llamada es preferible a hacer varias llamadas, tanto por claridad como porque cada llamada es un comando más que transportar.