wandres.dev
BUNDLES · RenderBundle y el coste de CPU

Bundles inmutables en escenas que cambian

Cómo se organiza una escena real cuando los comandos están congelados: agrupar por frecuencia de cambio, decidir la granularidad, y la aritmética que dice si regrabar compensa.

⏱ 17 min

Un render bundle no se puede editar. No hay forma de cambiar un dibujado, quitar un objeto ni añadir uno: se regraba entero o no se toca. Esa rigidez es la que permite que sea barato de ejecutar, y es también la que obliga a organizar la escena de una manera concreta si quieres beneficiarte de ellos sin pasarte el día reconstruyéndolos. La organización correcta no es por jerarquía ni por material: es por cuánto cambia cada cosa.

🎯 Al terminar esta lección sabrás
  • Repartir una escena en bundles según la frecuencia con que cambian sus comandos.
  • Calcular en cuántos fotogramas se amortiza la regrabación de un bundle.
  • Elegir la granularidad de los bundles con un criterio numérico.
  • Mantener válido un bundle cuando aparecen y desaparecen objetos.

Agrupar por frecuencia de cambio

La misma idea que organiza los bind groups organiza los bundles, y por la misma razón: lo que cambia junto va junto, y lo que no cambia nunca no debe estar cerca de lo que cambia siempre.

Una repartición que funciona en la mayoría de las escenas tiene cuatro cajones:

Nunca cambia. El entorno, la arquitectura, el mobiliario fijo, el terreno. Se graba una vez al cargar y sobrevive toda la sesión. Suele ser la mayor parte de los dibujados y es donde está casi toda la ganancia.

Cambia al cargar una zona. El contenido de un nivel, de una sala, de un capítulo. Se regraba en el momento en que ya estás pagando una carga, así que el coste es invisible.

Cambia cuando el usuario actúa. Un objeto que se añade en un configurador, una capa que se enciende. Se regraba en respuesta a un evento, decenas o cientos de fotogramas después del anterior.

Cambia cada fotograma. Personajes animados, partículas, interfaz, transparentes ordenados. Esto no va en un bundle: se dibuja directamente en el pass, después de ejecutar los bundles.

La proporción típica es reveladora: en la mayoría de las escenas, entre el 80 y el 95% de los dibujados están en los dos primeros cajones. Eso significa que el reparto captura casi toda la ganancia posible dejando fuera lo complicado.

La aritmética de la regrabación

Cuando algo cambia y hay que regrabar, la pregunta es si sigue compensando. La cuenta es directa.

Sea n el número de comandos del bundle, c el coste de grabar un comando —del orden de un microsegundo y medio— y f el número de fotogramas que pasan entre dos regrabaciones. Sin bundles pagas n · c en cada fotograma. Con bundles pagas n · c una vez cada f fotogramas, más el coste de ejecutar el bundle, que es aproximadamente el de un comando.

El ahorro por fotograma es entonces n · c · (1 - 1/f), y de ahí sale la conclusión que conviene tener presente: el beneficio no crece indefinidamente con f, sino que satura muy pronto. Con f igual a 10 ya has capturado el 90% del beneficio máximo; con f igual a 100, el 99%.

La lectura práctica es tranquilizadora. No hace falta que un bundle sea eterno para que valga la pena: si sobrevive diez fotogramas, ya está haciendo casi todo lo que puede hacer. Regrabar un bundle una vez por segundo es perfectamente razonable. Regrabarlo cada fotograma es lo único que está claramente mal.

La granularidad

Dos fuerzas tiran en direcciones opuestas.

Bundles grandes amortizan mejor la configuración inicial —cada bundle paga sus propios setPipeline, setBindGroup y setVertexBuffer, porque el estado se limpia a su alrededor— y reducen el número de ejecuciones.

Bundles pequeños limitan el daño de un cambio: si un objeto de una zona cambia, regrabas solo los comandos de esa zona y no los de la escena entera.

El criterio que resuelve el compromiso es numérico y se comprueba fácil: un bundle debería contener al menos varias decenas de dibujados. Por debajo de eso, la configuración pesa demasiado respecto al trabajo útil y el bundle apenas ahorra nada. Por encima de unos pocos cientos, partirlo por regiones o por zonas de carga cuesta poco y hace las regrabaciones mucho más baratas.

Un reparto espacial funciona bien para mundos grandes: un bundle por celda de una rejilla, ejecutando solo los de las celdas que el frustum toca. Eso añade además un culling grueso gratuito, porque el coste de un bundle que no ejecutas es cero.

Cuando aparecen y desaparecen objetos

Aquí es donde la inmutabilidad duele más, y donde la solución viene de la lección anterior del nivel 29.

Si los dibujados del bundle son indirectos, aparecer y desaparecer no es un cambio de comandos sino de datos. Un objeto que se oculta pone su instanceCount a cero desde un compute o desde writeBuffer, y el bundle sigue siendo válido. Un objeto que aparece ocupa una ranura reservada de antemano.

Ese es el patrón completo: reserva un número máximo de lotes, graba el bundle con todos ellos, y controla cuáles están activos desde el búfer de argumentos. La lista de comandos deja de depender del contenido de la escena y pasa a depender solo de su capacidad, que es un número que eliges tú.

// Capacidad reservada, no ocupacion real.
const MAX_LOTES = 256;

// El bundle se graba una vez con los 256 comandos, aunque solo haya 40 activos.
for (let i = 0; i < MAX_LOTES; i++) {
  codificador.drawIndexedIndirect(argumentos, i * 20);
}

// Desactivar un lote desde la CPU, sin tocar el bundle.
function desactivar(lote) {
  const cero = new Uint32Array([0]);
  device.queue.writeBuffer(argumentos, lote * 20 + 4, cero); // instanceCount
}

El coste de tener comandos de más es el de leer veinte bytes y decidir que no hay trabajo, multiplicado por los lotes vacíos. Con doscientos cincuenta y seis lotes es despreciable; con doscientos cincuenta mil no lo sería, así que la capacidad hay que dimensionarla con algo de cabeza y no ponerla en un número redondo grande por comodidad.

Un bundle es una caché, y todas las reglas de las cachés se le aplican, incluida la peor de todas

La forma más útil de pensar en un render bundle no es como una optimización de dibujado sino como una caché del resultado de grabar comandos. En cuanto lo ves así, todo el conocimiento que ya tienes sobre cachés se transfiere directamente y te evita los errores típicos. Tiene una clave: el conjunto de cosas de las que depende el resultado grabado, es decir, qué objetos hay, con qué pipeline, con qué bind groups y en qué orden. Tiene una política de invalidación, y ahí está el primer error clásico: casi todo el mundo invalida de menos al principio —el bundle se queda obsoleto y dibuja el estado anterior, que es un bug precioso porque no da ningún error y solo se nota si sabes lo que buscas— y luego, tras el susto, invalida de más y regraba en cuanto se mueve una hoja, con lo que pierde el beneficio entero sin darse cuenta. La disciplina que evita las dos cosas es enunciar explícitamente la clave: escribe en un comentario, al lado de la creación del bundle, de qué depende exactamente, y haz que la invalidación compruebe justo eso y nada más. Si la lista es larga, es señal de que el bundle está mal repartido y hay que sacar de él lo volátil. Tiene también un coste de reconstrucción, que es lo que hemos calculado arriba, y la regla de las cachés dice que una caché con una tasa de aciertos baja es peor que no tener caché; aquí eso se traduce en que un bundle que se regraba cada dos fotogramas es peor que no usar bundles. Y tiene el problema más difícil de todos los sistemas con caché: la coherencia. El día que alguien de tu equipo añada un objeto a la escena por un camino que no invalida el bundle, no aparecerá nada en pantalla y la persona buscará el fallo en su código nuevo, no en una caché que no sabe que existe. Por eso, si adoptas bundles, el único punto por el que se puedan añadir o quitar objetos de la escena tiene que ser una función que invalide, y no debe haber ningún otro. Un Set de objetos con métodos de añadir y quitar que marquen sucio el bundle correspondiente resuelve el problema estructuralmente, y es media hora de trabajo que ahorra una clase entera de bugs difíciles.