El uniform buffer: qué es, qué límites tiene y cuándo no sirve
Por qué existe un tipo de buffer separado para datos constantes, el límite de 64 KiB que decide su uso, la regla de uniformidad que lo hace rápido, y cómo se escribe en él.
Un uniform buffer es memoria de solo lectura desde el shader, pequeña, y con la promesa implícita de que todas las invocaciones leen lo mismo. Esas tres propiedades no son restricciones arbitrarias: son exactamente lo que el hardware necesita para colocar esos datos en una caché de constantes especializada que sirve el dato en un ciclo en vez de en cientos. Entender qué compras con cada restricción es lo que te dice cuándo un uniform es la herramienta correcta y cuándo hay que pasar a storage.
- Crear un uniform buffer con los flags de uso correctos y escribir en él.
- Explicar por qué existe
maxUniformBufferBindingSizey qué implica su valor de 64 KiB. - Relacionar la regla de acceso uniforme con la caché de constantes del hardware.
- Decidir, ante un dato concreto, si va en un uniform buffer o en otro sitio.
Crear, atar y escribir
Un buffer de uniformes es un GPUBuffer con el flag UNIFORM y, casi siempre, COPY_DST para poder escribirlo desde la CPU:
const bufferCamara = device.createBuffer({
label: 'camara',
size: 208, // múltiplo de 4, ver más abajo
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const datos = new Float32Array(52); // 52 × 4 = 208 bytes
datos.set(matrizVista, 0);
datos.set(matrizProyeccion, 16);
datos.set(matrizViewProj, 32);
datos.set(posicionCamara, 48);
datos[51] = tiempo;
device.queue.writeBuffer(bufferCamara, 0, datos);
El size de un buffer tiene que ser múltiplo de 4. queue.writeBuffer exige además que el offset de destino sea múltiplo de 4 y que el tamaño escrito lo sea también. Estas son las únicas reglas de alineación que impone la API sobre la escritura; las que de verdad muerden son las del contenido, que las impone WGSL y son el tema de la lección siguiente.
En el WGSL se declara con el espacio de direcciones uniform, que no admite modo de acceso explícito porque siempre es de lectura:
struct Camara {
vista : mat4x4<f32>,
proyeccion : mat4x4<f32>,
viewProj : mat4x4<f32>,
posicion : vec3<f32>,
tiempo : f32,
};
@group(0) @binding(0) var<uniform> camara : Camara;
Y en el bind group layout, con buffer: { type: 'uniform' }, que es además el valor por defecto si omites el campo type.
queue.writeBuffer toma una copia de los datos en el momento de la llamada. Puedes reutilizar el Float32Array inmediatamente después sin esperar a nada. Esto lo diferencia de mapAsync, donde el rango mapeado sí es memoria compartida con la GPU y hay que respetar el ciclo de vida. Para datos pequeños que cambian cada frame, writeBuffer es casi siempre la opción correcta y la más simple.
El límite de 64 KiB y lo que significa
maxUniformBufferBindingSize vale 65536 bytes en el mínimo garantizado. No es el tamaño del buffer: es el tamaño máximo de un binding, es decir, del rango que un bind group expone al shader. Un buffer puede ser mayor de 64 KiB siempre que cada binding individual no lo supere, lo cual encaja perfectamente con el patrón de un buffer grande con muchos subrangos.
En el modo de compatibilidad —el subconjunto de WebGPU que cubre hardware más antiguo— este límite baja a 16384 bytes. Si te importa la cobertura, 16 KiB es el presupuesto real.
Sesenta y cuatro kilobytes son 4096 vec4<f32>, o 1024 matrices de 4×4. Suena mucho hasta que cuentas un array de luces con posición, color, dirección y parámetros de atenuación: unas 64 luces con array<Luz, 256> ya se comen el presupuesto y el array es de tamaño fijo, así que el shader tiene que llevar un contador y salir del bucle antes. Ese patrón —array fijo grande, contador de elementos vivos— es la firma de que el dato debería estar en un storage buffer.
El otro límite relevante es maxUniformBuffersPerShaderStage, que vale 12. Doce bindings uniform por etapa, sumados sobre todo el pipeline layout. Es generoso para un renderer normal y estrecho para uno que use uniforms como si fueran variables globales.
Por qué el hardware puede ser más rápido con ellos
La ventaja del uniform buffer no está en la API sino en el silicio. En una GPU, las invocaciones de un shader se ejecutan en grupos de 32 o 64 en lock-step. Cuando todas leen la misma dirección, el hardware puede resolver la lectura una vez y difundir el resultado a todas: es una operación de broadcast, no 32 lecturas independientes.
Las GPUs llevan hardware dedicado a esto: un camino de constantes con su propia caché, pequeña y muy rápida, distinta de la caché de texturas y de la de datos. Los uniform buffers se colocan ahí porque la API garantiza que son de solo lectura durante toda la ejecución del pass, lo que permite precargarlos y no invalidarlos nunca.
Esa garantía se paga con dos restricciones. La primera es el tamaño: la caché de constantes es pequeña, y de ahí el límite. La segunda es más sutil: indexar un array de uniforms con un índice que varía entre invocaciones anula la ventaja. Si el shader hace luces[i] donde i depende del fragmento, cada invocación lee una dirección distinta y el hardware pierde el broadcast, cayendo a un camino más lento o serializando el acceso.
// Rápido: todas las invocaciones leen lo mismo.
let vp = camara.viewProj;
// Potencialmente lento: el índice diverge entre invocaciones.
for (var i = 0u; i < numLuces; i = i + 1u) {
acumulado = acumulado + evaluar(luces[i]); // i es uniforme aquí, va bien
}
// Lento de verdad: el índice depende del fragmento.
let l = luces[u32(entrada.idMaterial)]; // diverge
El bucle del medio sí conserva la ventaja porque i recorre el mismo valor en todas las invocaciones al mismo tiempo: el índice es uniforme aunque varíe en el tiempo. El caso de abajo es el que hay que evitar en un uniform, y es exactamente para lo que existe el storage buffer.
Se repite mucho que los uniform buffers son rápidos y los storage lentos, y es una simplificación que lleva a decisiones malas. Lo que es rápido es leer la misma dirección desde todas las invocaciones, y eso el hardware lo aprovecha venga de donde venga el dato. En GPUs modernas, un storage buffer leído uniformemente se sirve desde la caché general con una latencia perfectamente comparable, y algunas implementaciones detectan el patrón y usan el mismo camino de constantes. Al revés, un uniform buffer indexado divergentemente puede ser más lento que el storage equivalente, porque el camino de constantes no está diseñado para accesos dispersos. La regla útil no es uniform para lo pequeño, storage para lo grande: es uniform para lo que todas las invocaciones leen igual, storage para lo que cada invocación lee distinto. El tamaño es una consecuencia, no la causa.
El árbol de decisión
Ante un dato concreto, tres preguntas resuelven casi todos los casos.
¿Lo leen todas las invocaciones por igual? Si la respuesta es no —cada instancia lee su propia entrada, cada píxel lee su propio tile—, es un storage buffer y no hay más que hablar.
¿Cabe holgadamente en 16 KiB? Si el tamaño depende de datos de la escena y puede crecer, es un storage buffer, aunque hoy quepa. Un array de tamaño fijo con contador es una deuda que se paga el día que alguien duplica el número de luces.
¿Cambia por draw? Si sí, y sigue siendo pequeño y uniforme, es un uniform buffer con dynamic offset, que es la forma de tener miles de versiones de un dato pequeño sin miles de buffers. Es el tema de la lección de dynamic offsets.
Lo que queda para el uniform buffer puro es lo que era desde el principio: matrices de cámara, parámetros globales del frame, constantes de material. Datos pequeños, uniformes y de vida larga. Es un uso más estrecho del que la gente le da, y esa estrechez es justamente lo que lo hace valioso.
Todo lo anterior asume que los bytes que escribes desde JavaScript aterrizan donde el shader espera. Ese supuesto es falso con una frecuencia sorprendente, y arreglarlo es el resto de este nivel.