Cuándo un bundle ayuda de verdad y cuándo no cambia nada
Las tres condiciones que tienen que darse a la vez para que pregrabar comandos mejore el tiempo de fotograma, y los casos frecuentes en los que la mejora es exactamente cero.
Los render bundles tienen mala reputación entre quienes los han probado sin entenderlos, y con motivo: en la mayoría de las aplicaciones que los adoptan por recomendación general, el tiempo de fotograma no se mueve un microsegundo. No es que la técnica no funcione; es que ataca un coste muy concreto que en esas aplicaciones no era el que mandaba. Las condiciones para que ayuden son tres, hay que verificarlas antes y no después, y la primera se puede comprobar en cinco minutos.
- Enunciar las tres condiciones que hacen rentable un bundle.
- Reconocer los escenarios en los que la ganancia es nula por construcción.
- Combinar bundles con dibujado indirecto para mantenerlos válidos con contenido dinámico.
- Estimar la ganancia esperada antes de escribir el código.
Las tres condiciones
Primera: el cuello de botella tiene que estar en la CPU. Si el tiempo de GPU por fotograma es mayor que el de CPU, la GPU es la que marca el ritmo y ahorrar tiempo de CPU no acorta el fotograma. Es la condición que más veces falla y la más fácil de comprobar.
Segunda: dentro del tiempo de CPU, la grabación de comandos tiene que pesar. Una aplicación puede estar limitada por CPU y que el tiempo se le vaya en recorrer el grafo de escena, actualizar matrices, ejecutar física o recolectar basura. Los bundles solo tocan la parte de grabar comandos. Si esa parte es el 15% del tiempo de CPU, el mejor resultado imaginable es una mejora del 15%, y la real será menor.
Tercera: la lista de comandos tiene que ser estable. Un bundle que se regraba cada fotograma cuesta más que no usarlo: pagas la grabación igual, más la construcción del objeto y su validación. La ganancia aparece cuando el mismo bundle se ejecuta muchas veces, y crece con el número de ejecuciones por grabación.
Las tres son necesarias. Que se cumplan dos no sirve de nada.
Dónde se cumplen las tres
El caso canónico es una escena con muchos objetos estáticos y pocos comandos que cambien: un visor arquitectónico, un configurador de producto, un editor con un entorno fijo, un mapa. Cientos o miles de dibujados que son idénticos fotograma tras fotograma, con la cámara moviéndose —que es un cambio de contenido de un búfer de uniformes, no de comandos— y unos pocos objetos dinámicos encima.
Ahí un bundle grabado una vez al cargar la escena convierte, digamos, mil doscientos comandos por fotograma en uno. El tiempo de grabación de esos mil doscientos, que a un microsegundo y medio por comando son 1,8 ms, desaparece del presupuesto de cada fotograma.
El segundo caso donde se cumplen es el de una escena moderada dibujada varias veces: sombras desde varias luces, vistas múltiples, reflexiones planares. Ahí el número de comandos por fotograma se multiplica por el número de vistas, y el bundle se amortiza tantas veces como vistas haya, siempre que la forma del pass coincida.
Dónde no se cumplen
Escenas dirigidas por la GPU. Si ya has llegado a un bucle de cuarenta drawIndexedIndirect, la grabación cuesta menos de 0,1 ms y no hay nada que ahorrar. Los bundles y el dibujado indirecto atacan el mismo problema por caminos distintos y sus ganancias no se suman: la del segundo ya se ha comido la del primero.
Escenas limitadas por fragmento. Un renderizador con post-proceso pesado, iluminación cara o mucho sobredibujado está limitado por GPU y el tiempo de CPU le sobra. Aquí no es que los bundles den poco, es que dan exactamente cero.
Escenas con listas volátiles. Un sistema de partículas con creación y destrucción constante, una interfaz que se reconstruye, un editor donde el usuario añade y quita objetos. Cada cambio invalida el bundle.
Escenas con transparencia ordenada por distancia. El orden de dibujado cambia cuando se mueve la cámara, y el orden está congelado en el bundle. Los transparentes se quedan fuera casi siempre.
Escenas con estarcido por objeto. Como setStencilReference no se puede grabar, cualquier técnica que cambie la referencia entre objetos se queda fuera.
La combinación que salva los casos intermedios
Hay una categoría de escenas que parecen volátiles y no lo son: aquellas donde cambia qué se dibuja pero no cuántos comandos hay. Un mundo con culling, por ejemplo: los objetos visibles cambian cada fotograma, pero si los comandos son dibujados indirectos por lote, la lista de comandos es constante y solo cambia el contenido del búfer de argumentos.
// Grabado una vez: un comando por lote, argumentos en memoria.
const codificador = device.createRenderBundleEncoder({
colorFormats: [formato],
depthStencilFormat: 'depth32float',
});
codificador.setPipeline(pipeline);
codificador.setBindGroup(0, grupoEscena);
codificador.setVertexBuffer(0, megaVertices);
codificador.setIndexBuffer(megaIndices, 'uint32');
for (let i = 0; i < numLotes; i++) {
codificador.drawIndexedIndirect(argumentos, i * 20);
}
const bundleEscena = codificador.finish({ label: 'escena completa' });
// Cada fotograma: el compute reescribe los argumentos, el bundle no cambia.
function fotograma() {
const encoder = device.createCommandEncoder();
encoder.clearBuffer(contadores);
const cull = encoder.beginComputePass();
// ... seleccion y preparacion de argumentos ...
cull.end();
const pass = encoder.beginRenderPass(descriptorRender);
pass.executeBundles([bundleEscena]);
pass.end();
device.queue.submit([encoder.finish()]);
}
Con eso, el coste de CPU del dibujado de la escena entera es una llamada, y el contenido lo decide la GPU cada fotograma. Un lote sin objetos visibles tiene instanceCount a cero y no produce nada, tal y como se explica en la disposición del buffer indirecto. Es lo más cerca que se puede estar hoy de un coste de grabación constante.
Toda la discusión anterior asume que el objetivo es bajar el tiempo de fotograma, y en una aplicación limitada por GPU la conclusión es que los bundles no sirven. Esa conclusión es correcta y a la vez incompleta, porque hay tres situaciones donde el tiempo de CPU que liberas vale mucho más que su efecto sobre el contador de fotogramas. La primera es la web, donde el hilo principal es compartido. El tiempo que tu renderizador pasa grabando comandos es tiempo en el que el navegador no puede atender un evento de entrada, ejecutar una animación de CSS ni disponer el texto de la página. Un renderizador que se come 3 ms de cada fotograma en grabación puede ir a sesenta imágenes por segundo y aun así hacer que la interfaz de alrededor responda mal, y esa es una métrica que el usuario percibe con muchísima más claridad que la fluidez de la escena. La segunda es el móvil, donde el tiempo de CPU es energía. Un núcleo grande de un teléfono trabajando al máximo durante 3 ms de cada 16 consume batería y genera calor, y el calor dispara la limitación térmica, que baja la frecuencia de la GPU y acaba costándote fotogramas por una vía completamente indirecta. Ahí bajar el trabajo de CPU mejora el rendimiento de GPU con veinte o treinta segundos de retraso, que es un efecto que ninguna medición de un fotograma aislado puede capturar. Y la tercera es la varianza. El tiempo medio de grabación puede ser aceptable y su percentil 95 no serlo: un fotograma en el que el recolector de basura se activa mientras grabas mil doscientos comandos es un tirón visible, y menos trabajo por fotograma significa menos asignaciones, menos presión sobre el recolector y una distribución más estrecha. La fluidez percibida depende del peor fotograma de cada segundo, no del medio. Así que la regla completa no es «usa bundles solo si estás limitado por CPU», sino: si estás limitado por CPU, es una optimización de rendimiento; si no lo estás pero tu aplicación convive con una interfaz, va a un móvil o sufre tirones, sigue siendo una optimización, solo que de otra cosa. Y como el coste de adoptarlos en una escena estática es de una tarde, la barrera es baja.