GPUBindGroup: rellenar el contrato con recursos concretos
Cómo se crea un bind group contra su layout, las cuatro formas que puede tomar un recurso, el subrango de buffer con offset y size, y por qué un bind group es inmutable.
Si el layout es el molde, el bind group es la pieza. Se crea contra un layout concreto, se valida entera en ese momento, y a partir de ahí es un objeto inmutable que se ata con una sola llamada. La inmutabilidad es lo que la gente tarda más en aceptar: no puedes cambiar un recurso de un bind group ya creado, y esa restricción condiciona cómo organizas la memoria del renderer.
- Crear un
GPUBindGroupque satisfaga un layout dado, con las cuatro formas de recurso. - Usar
offsetysizepara exponer un subrango de un buffer grande como si fuera un buffer propio. - Explicar por qué un bind group es inmutable y qué patrón lo sustituye cuando los datos cambian.
- Reconocer los errores de validación típicos y traducirlos a la causa real.
Las cuatro formas de un recurso
El descriptor de createBindGroup tiene un label, un layout y un array entries. Cada entrada solo lleva dos campos: binding y resource. El binding tiene que coincidir con uno del layout, y el conjunto de bindings tiene que ser exactamente el mismo: ni uno de más ni uno de menos.
El resource puede tomar cuatro formas, y cuál corresponde depende de la clase de entrada declarada en el layout:
const grupo = device.createBindGroup({
label: 'material mármol',
layout: layoutMaterial,
entries: [
// 1. Un objeto GPUBufferBinding, para entradas de clase `buffer`.
{ binding: 0, resource: { buffer: uniformes, offset: 0, size: 96 } },
// 2. Un GPUSampler, para entradas de clase `sampler`.
{ binding: 1, resource: samplerLineal },
// 3. Un GPUTextureView, para `texture` y para `storageTexture`.
{ binding: 2, resource: albedo.createView() },
// 4. Un GPUExternalTexture, para `externalTexture`.
{ binding: 3, resource: device.importExternalTexture({ source: video }) },
],
});
El detalle que más confunde es el tercero: una entrada de textura no recibe la GPUTexture sino una GPUTextureView. La razón es que la vista es lo que fija la dimensión, el formato visto, el rango de mips y el rango de capas; la textura por sí sola es ambigua. Pasar la textura directamente da un error de tipo, no de validación, porque el binding de WebIDL rechaza el valor.
importExternalTexture devuelve un objeto con una particularidad: se invalida sola. Un GPUExternalTexture importado de un HTMLVideoElement deja de ser válido cuando el vídeo avanza de fotograma, así que el bind group que lo contiene hay que recrearlo cada frame. Es la única excepción real a la regla de que los bind groups son de larga vida, y está forzada por cómo funciona la decodificación de vídeo.
El subrango: offset y size
Para las entradas de clase buffer, el recurso es un GPUBufferBinding con tres campos. buffer es obligatorio; offset vale 0 por defecto; size vale, por defecto, lo que queda del buffer desde offset.
Estos dos campos son la puerta al patrón más importante de gestión de memoria en WebGPU: un solo buffer grande, muchos bindings pequeños. En vez de crear doscientos buffers de 96 bytes, creas uno de 200 × 256 bytes y expones cada tramo como un binding independiente:
const ALINEACION = device.limits.minUniformBufferOffsetAlignment; // 256 garantizado
const TAM = 96;
const paso = Math.ceil(TAM / ALINEACION) * ALINEACION; // 256
const grande = device.createBuffer({
label: 'uniformes por objeto',
size: paso * objetos.length,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const gruposPorObjeto = objetos.map((_, i) => device.createBindGroup({
layout: layoutObjeto,
entries: [{ binding: 0, resource: { buffer: grande, offset: i * paso, size: TAM } }],
}));
El offset tiene que ser múltiplo de minUniformBufferOffsetAlignment para buffers uniform, o de minStorageBufferOffsetAlignment para buffers de storage. Ambos límites valen 256 bytes en el mínimo garantizado, y ambos pueden ser menores en un dispositivo concreto, nunca mayores; son límites de tipo alignment, donde el valor garantizado es el peor caso. Consultarlos y usar el valor real ahorra memoria en hardware que solo exige 32 o 64.
Ese size: TAM de 96 y no de 256 no es un detalle estético: limita lo que el shader puede leer. Si el shader intenta leer más allá del size declarado, el acceso queda fuera de límites y WGSL lo trata con su regla de comportamiento acotado, que en la práctica devuelve ceros o el primer elemento en lugar de leer memoria ajena.
El fragmento anterior crea un bind group por objeto, todos apuntando al mismo buffer. Funciona y es rápido, pero hay una alternativa que crea un solo bind group y mueve el offset en tiempo de dibujado. Es el patrón de dynamic offsets, y lo comparo con este en la lección correspondiente del nivel 19.
Inmutable de verdad
Un GPUBindGroup no tiene métodos. No hay setEntry, no hay update, no hay forma de cambiar qué textura ocupa el binding 2. Si el recurso cambia, creas un bind group nuevo.
Esto obliga a distinguir con precisión dos cosas que en WebGL se confundían: cambiar el valor de un dato y cambiar el recurso que lo contiene. Escribir una matriz nueva en un buffer con queue.writeBuffer no toca el bind group para nada, porque el bind group referencia el buffer, no su contenido. Cambiar la textura de un material sí exige un bind group nuevo, porque la referencia cambia.
La consecuencia de diseño es clara: todo lo que cambia por frame debe ser contenido de buffer, no identidad de recurso. Un renderer bien montado escribe buffers en cada frame y no crea ningún bind group; los crea al cargar la escena y cuando el usuario cambia un material.
// Por frame: escribir bytes. Barato, no invalida nada.
device.queue.writeBuffer(bufferCamara, 0, camara.matrices);
// Al cambiar de material: bind group nuevo. Caro, hazlo fuera del bucle.
material.grupo = device.createBindGroup({ layout: layoutMaterial, entries: [...] });
Cuesta aceptar la restricción hasta que ves qué compra. Como el bind group no puede cambiar después de creado, la implementación puede materializarlo en memoria de GPU en el momento de la creación: escribir los descriptores en su bloque contiguo, resolver direcciones, y quedarse con un handle. setBindGroup pasa entonces a ser escribir ese handle en el command buffer. Si el bind group fuera mutable, cada setBindGroup tendría que comprobar si algo cambió desde la última vez, que es exactamente el trabajo de detección de cambios que hacían los drivers de WebGL y que WebGPU nació para eliminar. La inmutabilidad no es rigidez: es el mecanismo por el que se paga una vez lo que antes se pagaba en cada draw.
Los errores que verás y qué significan de verdad
La validación de createBindGroup es exhaustiva, y sus mensajes son razonables pero abstractos. Estos son los cuatro que más aparecen y su traducción.
El número de entradas no coincide con el layout. Casi siempre significa que añadiste una entrada al WGSL y al layout pero olvidaste el grupo, o que estás usando un layout de otro pipeline. Es el error que las etiquetas resuelven en un vistazo.
El uso del buffer no incluye el flag requerido. Un buffer atado a una entrada type: 'uniform' necesita GPUBufferUsage.UNIFORM en su creación. El error no dice qué flag falta; lo dice el type de tu layout.
El tamaño del binding es menor que minBindingSize. Aquí el error es preciso y útil, y es precisamente el que no obtienes si dejas minBindingSize en 0: en ese caso el fallo se pospone hasta el draw y aparece como un error de render pass.
El viewDimension de la vista no coincide. Una textura creada con depthOrArrayLayers mayor que 1 produce por defecto una vista '2d-array', no '2d'. Si el layout pide '2d', hay que pedir la vista explícitamente con createView({ dimension: '2d', baseArrayLayer: n, arrayLayerCount: 1 }).
Para depurar cualquiera de ellos, envuelve la creación en un error scope y tendrás el mensaje sin ruido:
device.pushErrorScope('validation');
const grupo = device.createBindGroup(descriptor);
const error = await device.popErrorScope();
if (error) console.error('bind group inválido:', error.message);
Monta un buffer único de 64 KiB y expón desde él cuatro bind groups, cada uno con un offset distinto múltiplo de minUniformBufferOffsetAlignment y un size ajustado al tamaño real de tu struct. Comprueba con un error scope qué pasa si pones un offset que no es múltiplo del límite, y lee el mensaje entero: es de los pocos casos en que WebGPU te dice el número exacto que esperaba.