Sistema de partículas GPU en HLSL: más allá del millón
Trasladé el estado de las partículas por completo a la GPU con StructuredBuffer y DrawProceduralIndirect: pasé de 40 mil a un millón, con mediciones.
El fotograma que se hundía a cuarenta mil
En un prototipo de acción en el que trabajé el año pasado podían coincidir en pantalla hasta treinta explosiones. Cuando el sistema de partículas de CPU llegaba a cuarenta mil partículas vivas, el profiler marcaba 11,4 ms en el hilo principal. Nuestro presupuesto por fotograma era de 16,6 ms, y esa cifra todavía no incluía lógica de juego, animación ni física.
Estaba claro adónde se iba el tiempo. Cada fotograma recorríamos cuarenta mil structs Particle, actualizábamos posición y velocidad, y después copiábamos esos mismos datos a un vertex buffer. La llamada a Mesh.SetVertexBufferData costaba 2,1 ms ella sola. Con Burst y el Job System bajé la parte de actualización a 3,8 ms, pero la copia se quedó exactamente donde estaba.
Mi primer intento fue usar menos partículas: reduje a la mitad la cantidad por efecto. El tiempo de fotograma cayó a 7,9 ms, pero las explosiones se veían pobres y el equipo de arte protestó, con razón. Bajar la cantidad no es una solución, es admitir que el problema ha ganado.
El problema real no era el coste de las cuentas, sino los datos viajando entre CPU y GPU. Si la posición de una partícula solo la lee la GPU, no hay ninguna razón para que esa posición viva en la memoria de la CPU. Moví el sistema entero a la GPU con un compute shader en HLSL; lo que sigue es el detalle técnico de ese cambio y los números que arrojó.
Guardar el estado dentro de un StructuredBuffer
El estado de una partícula vive en un único struct: float3 pos, float3 vel, float life, uint seed. Son 32 bytes, así que nunca tuve que añadir relleno por alineación. Un millón de partículas son 32 MB; como hago ping-pong, los dos búferes juntos suponen 64 MB de VRAM. El campo seed se escribe una sola vez al nacer y mantiene determinista la aleatoriedad de la partícula durante toda su vida.
Leo a través de StructuredBuffer<Particle> y escribo a través de RWStructuredBuffer<Particle>. Leer y escribir el mismo búfer en un mismo dispatch es comportamiento indefinido, porque nada te dice en qué orden se ejecutarán los thread groups. En ParticleSystemGPU.cs intercambio las dos referencias en cada fotograma; no se copia memoria, solo dos variables cambian de sitio.
Mantener el struct en 32 bytes se nota en las mediciones. Como experimento añadí un campo half4 color, subí a 40 bytes, y el kernel de actualización pasó de 0,82 ms a 1,19 ms en una RTX 3060. El color ya lo podía derivar del tiempo de vida, así que llevarlo en el búfer no aportaba nada. En la GPU el cuello de botella suele ser el ancho de banda de memoria, no la aritmética.
Reservo con GraphicsBuffer en lugar de ComputeBuffer. Los dos funcionan, pero GraphicsBuffer me deja marcar la misma reserva como destino de compute y como argumento de dibujo indirecto. Usar una sola reserva para dos fines es más rápido que mantener una segunda copia, y elimina toda una familia de errores.
Por qué elijo 64 o 128 hilos por grupo
El kernel empieza con [numthreads(128, 1, 1)], porque el hardware ya trabaja en oleadas. Un warp son 32 hilos en NVIDIA; una wave, 64 en AMD. Si el tamaño de tu grupo no es un múltiplo exacto de eso, parte de la última oleada gira en vano, y ese desperdicio se mide.
Medí el mismo kernel con un millón de partículas en cuatro tamaños: 1,41 ms con 32 hilos, 0,91 ms con 64, 0,82 ms con 128 y 1,05 ms con 256. Treinta y dos va mal porque el coste fijo por grupo — comprobación de límites, lecturas de constant buffer — se repite demasiadas veces. Doscientos cincuenta y seis va mal porque mi kernel usa 40 registros, y a más presión de registros caben menos grupos a la vez en un SM.
La capacidad casi nunca es un múltiplo exacto del tamaño de grupo, así que la primera línea del kernel es if (id.x >= _Capacity) return;. Si se te olvida, el último grupo escribe más allá del final del búfer; en Windows lo normal es obtener resultados mal en silencio, y a veces un reinicio del driver por TDR. Calculo el número de dispatch con Mathf.CeilToInt(capacity / 128f), y redondear la capacidad al siguiente múltiplo de 128 deja esa comprobación casi gratis.
Un millón de partículas en grupos de 128 son 7813 grupos, muy por debajo del límite de 65535 en un solo eje, así que nunca necesité repartir por una segunda dimensión. Pasados los cuatro millones sí chocas con ese techo y hay que meter id.y en juego. No lo escribí de antemano; generalizar para un caso que no tenía volvía el kernel ilegible.
Reciclar partículas muertas con append/consume
Una partícula que agota su vida tiene que ceder su hueco a una recién nacida. Eso lo hago con AppendStructuredBuffer<uint> _DeadList: la partícula que muere añade su propio índice. El kernel de spawn saca índices de esa misma lista con ConsumeStructuredBuffer<uint>. Así nunca escribo un bucle de barrido buscando un hueco libre.
Append y consume se apoyan en un contador atómico, de modo que cada llamada hace cola detrás de las demás. Si todas las partículas mueren en el mismo fotograma, ese contador se convierte en el cuello de botella: en una prueba sintética en la que las maté todas de golpe, el kernel pasó de 0,82 ms a 1,26 ms. En escenas reales las muertes se reparten en el tiempo y la diferencia se quedó por debajo de 0,05 ms, así que lo dejé estar.
El kernel de spawn se ejecuta como su propio dispatch, antes del kernel de actualización. Con el orden invertido, las partículas recién nacidas se actualizaban una vez en el mismo fotograma y se adelantaban un fotograma; invisible en movimiento, pero dejaba un pequeño hueco en el centro de cada explosión. La cantidad a generar la paso desde la CPU como un único int, porque de todos modos ese número lo decide la lógica de juego.
Aquí hay dos trampas clásicas. La primera es reservar el búfer sin el flag ComputeBufferType.Append; el shader compila, se ejecuta, el contador se queda en cero y no ves ni un mensaje de error. La segunda es reiniciar el contador en el sitio equivocado: llamo a SetCounterValue(0) solo sobre el búfer de índices vivos, cada fotograma, justo antes del dispatch. Aplica esa misma llamada a la lista de muertos y borrarás los huecos libres acumulados, y ya no volverá a nacer nada.
DrawProceduralIndirect: no volver nunca a la CPU
La GPU sabe cuántas partículas están vivas; la CPU no. Pedir ese número con GetData significa esperar a que se vacíe la cola de comandos: cuando lo medí, una sola lectura de vuelta añadía entre 4 y 6 ms al fotograma. Por eso dibujo con Graphics.DrawProceduralIndirect y la cuenta nunca pasa por la CPU.
El búfer de argumentos son cuatro valores uint: número de vértices, número de instancias, vértice inicial e instancia inicial. Cada fotograma llamo a GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4) para copiar el contador de vivos en la segunda posición. La CPU no tiene ni idea de cuál es ese número; solo escribe un comando de copia en la cola.
Del lado del vértice tampoco hay mesh. Derivo el índice de la partícula y el de la esquina a partir de SV_VertexID y construyo el quad dentro del vertex shader, enlazando allí _ParticlesOut y _AliveIndices como StructuredBuffer. Enlazar vertex buffers, índices, actualizar mallas: todo eso desaparece.
Esta es la diferencia medida. El sistema de CPU gastaba 11,4 ms de hilo principal con cuarenta mil partículas; el sistema de GPU gasta 0,82 ms en compute, 1,6 ms en dibujo y 0,05 ms de hilo principal con un millón. Veinticinco veces más partículas y aproximadamente ocho veces menos tiempo de fotograma. Más importante aún: la CPU se quedó libre y ese presupuesto se lo dimos a la IA.
Trampas de sincronización y por dónde empezar
GroupMemoryBarrierWithGroupSync() sincroniza solo su propio grupo. Dentro de un dispatch no hay sincronización entre grupos, y eso es lo que más confunde al pasarse a la GPU. Si las partículas vecinas necesitan leerse entre sí — colisión, comportamiento de bandada, una rejilla de vecindad —, ese paso tiene que convertirse en un segundo dispatch.
La segunda trampa es el orden. El orden dentro de _AliveIndices cambia en cada fotograma, porque nada garantiza qué grupo termina primero. Dibuja partículas con alpha blending en ese orden y la imagen parpadea de fotograma a fotograma; no fui yo quien lo vio primero, se notaba clarísimo en una captura a cámara lenta que envió QA. Hay dos salidas: ordenar por profundidad en la GPU (un bitonic sort medía 0,9 ms para un millón de elementos) o pasarse al blending aditivo. Elegí lo segundo, porque la mayoría de los efectos ya eran aditivos.
Tus costumbres de depuración también tienen que cambiar. No hay breakpoints ni Debug.Log. Abro un RWStructuredBuffer<float4> aparte, escribo ahí los valores intermedios que me resultan sospechosos y lo leo de vuelta solo mientras persigo un fallo. Esa lectura cuesta los mismos 4 a 6 ms, así que vive dentro de un bloque #if DEBUG_PARTICLES.
No intentes mover el sistema entero de una vez. Empieza por un solo efecto — en mi caso fueron los trazos de bala —, ponlo en la GPU con un búfer de capacidad fija, mide el dispatch con RenderDoc y solo después añade la lista de muertos y el dibujo indirecto. Elegir un millón de capacidad de entrada tampoco tiene sentido: mide el pico real de partículas vivas en una escena y toma el doble, y así la VRAM y el tiempo de actualización quedan los dos en su sitio.