@group y @binding: las dos coordenadas de un recurso
Cómo se declara un recurso en WGSL, qué variables exigen las dos coordenadas, la regla de unicidad que permite declarar dos vistas del mismo buffer, y qué significa uso estático.
Un recurso en WGSL no se busca por nombre: se declara en unas coordenadas. @group dice a qué conjunto pertenece y @binding en qué ranura de ese conjunto está, y esas dos cifras son el único vínculo entre el texto del shader y los objetos que crea la API. El shader no sabe nada de buffers ni de texturas concretas; sabe que en el grupo 1, ranura 2, va a haber algo de un tipo determinado.
- Declarar cada clase de recurso con sus coordenadas y su tipo WGSL correcto.
- Enunciar la regla de unicidad de las coordenadas y usarla para declarar vistas alternativas.
- Explicar qué es el uso estático de un recurso y qué consecuencias tiene sobre el layout.
- Enumerar los límites que acotan el número de grupos y de recursos.
Las dos coordenadas
Las llevan todas las variables de módulo de los espacios uniform, storage y handle, y solo esas. Una var<private> no las lleva y no puede llevarlas.
struct Camara {
viewProj : mat4x4f,
posicion : vec3f,
tiempo : f32,
};
@group(0) @binding(0) var<uniform> camara : Camara;
@group(0) @binding(1) var muestreo : sampler;
@group(0) @binding(2) var entorno : texture_cube<f32>;
@group(1) @binding(0) var<storage, read> instancias : array<Instancia>;
@group(1) @binding(1) var<storage, read_write> visibles : array<u32>;
@group(2) @binding(0) var albedo : texture_2d<f32>;
@group(2) @binding(1) var normalMap : texture_2d<f32>;
El número de @binding no tiene que ser consecutivo ni empezar en cero; solo tiene que ser único dentro de su grupo. El de @group está acotado por maxBindGroups, con un mínimo garantizado de 4, así que los índices válidos en el caso peor son 0, 1, 2 y 3. Ese número es pequeño a propósito y es la razón de que la organización por frecuencia de cambio sea obligatoria y no opcional.
La correspondencia con la API es literal: el índice de @group es el primer argumento de setBindGroup, y el de @binding es el campo binding de la entrada correspondiente del bind group layout y del bind group.
pase.setBindGroup(0, grupoCamara); // @group(0)
pase.setBindGroup(1, grupoEscena); // @group(1)
pase.setBindGroup(2, grupoMaterial); // @group(2)
El tipo WGSL decide el tipo de entrada
Cada declaración de recurso implica una clase de entrada en el bind group layout, y la correspondencia es rígida:
| Declaración WGSL | Entrada del layout |
|---|---|
var<uniform> x : T |
buffer con type: 'uniform' |
var<storage, read> x : T |
buffer con type: 'read-only-storage' |
var<storage, read_write> x : T |
buffer con type: 'storage' |
var x : sampler |
sampler con type: 'filtering' o 'non-filtering' |
var x : sampler_comparison |
sampler con type: 'comparison' |
var x : texture_2d<f32> |
texture con sampleType: 'float' |
var x : texture_2d<u32> |
texture con sampleType: 'uint' |
var x : texture_depth_2d |
texture con sampleType: 'depth' |
var x : texture_storage_2d<f, write> |
storageTexture con access: 'write-only' |
var x : texture_external |
externalTexture |
La dimensión de la textura en el tipo WGSL —texture_2d, texture_2d_array, texture_cube, texture_3d— tiene que coincidir con el viewDimension de la entrada. Y el parámetro de tipo f32, u32 o i32 tiene que coincidir con el sampleType. Un desajuste no se detecta al compilar el módulo: se detecta al crear el pipeline, y el mensaje es de layout aunque el error esté en el shader.
La regla de unicidad y las vistas alternativas
La regla exacta es más permisiva de lo que parece: dos variables de módulo no pueden tener el mismo par @group y @binding si las dos las usa estáticamente el mismo punto de entrada. Si las usan puntos de entrada distintos, es perfectamente legal.
Eso abre un patrón muy útil: declarar el mismo buffer con dos tipos distintos y usar cada uno donde convenga.
struct Particula {
posicion : vec4f,
velocidad : vec4f,
};
// Vista tipada: la usa el shader de simulacion.
@group(0) @binding(0) var<storage, read_write> particulas : array<Particula>;
// Vista cruda del mismo buffer: la usa el shader que lo pone a cero.
@group(0) @binding(0) var<storage, read_write> crudo : array<vec4f>;
@compute @workgroup_size(64)
fn simular(@builtin(global_invocation_id) id : vec3u) {
particulas[id.x].posicion += particulas[id.x].velocidad; // solo usa 'particulas'
}
@compute @workgroup_size(64)
fn limpiar(@builtin(global_invocation_id) id : vec3u) {
crudo[id.x] = vec4f(0.0); // solo usa 'crudo'
}
Los dos puntos de entrada comparten bind group layout, comparten buffer y ven el mismo rango de memoria con dos tipos distintos. Es la forma limpia de tener un shader de inicialización que no necesita conocer la estructura, o de leer un buffer de estructuras como enteros para un radix sort.
Lo que no puedes hacer es usar las dos vistas en el mismo punto de entrada. Si lo intentas, el módulo no compila.
Uso estático y sus consecuencias
Un recurso lo usa estáticamente un punto de entrada cuando aparece en su cuerpo o en el de cualquier función que llame, directa o indirectamente. No importa si la ejecución llega a esa línea: importa que el compilador vea el nombre. Un acceso dentro de un if que siempre es falso cuenta como uso.
De ahí salen tres consecuencias que aparecen a diario:
El layout que exige un pipeline lo determinan los recursos usados estáticamente por sus puntos de entrada, no los declarados en el módulo. Puedes declarar diez recursos y usar tres; el pipeline solo necesita esos tres, aunque el layout puede declarar más.
Comentar una línea cambia el layout. Es el efecto secundario más desconcertante mientras depuras: quitas la textura de una fórmula para ver qué pasa, y de pronto el bind group deja de ser compatible. La solución rápida es una asignación de descarte que mantenga el uso vivo.
_ = textureSampleLevel(albedo, muestreo, vec2f(0.0), 0.0); // mantiene el uso estatico
Los límites por etapa se cuentan sobre lo declarado en el layout, no sobre lo usado. Un bind group layout con doce uniform buffers visibles desde tres etapas consume presupuesto en las tres, aunque el shader lea uno.
La primera reacción ante maxBindGroups con un mínimo de cuatro es pensar que se han quedado cortos. Con cientos de materiales y miles de objetos, cuatro conjuntos parecen pocos. La segunda reacción, después de entender de dónde sale el número, es que cuatro son exactamente los que hay.
El número viene del hardware. En una GPU, cambiar un descriptor de recurso implica escribir una tabla que la unidad de texturas y la de memoria consultan, y esa tabla tiene un coste de actualización que no depende de cuántas entradas cambies sino de cuántas veces la toques. Las APIs nativas modernas exponen entre cuatro y ocho conjuntos por la misma razón, y los backends de WebGPU tienen que mapear tus grupos sobre los del sistema sin desbordarlos.
Lo que esas cuatro ranuras te obligan a hacer es ordenar los recursos por frecuencia de cambio, y esa ordenación es lo que hace que un renderer sea rápido:
El grupo 0 para lo que cambia una vez por fotograma: cámara, luces globales, tiempo, ajustes de post-proceso. Se ata una vez y no se toca. El grupo 1 para lo que cambia por pasada o por tipo de pase: los targets, la sombra activa, los parámetros de la técnica. El grupo 2 para lo que cambia por material: las texturas y las constantes del material. Y el grupo 3 para lo que cambia por objeto, que idealmente no existe porque los datos por objeto están en un storage buffer indexado por instance_index y no hay ningún setBindGroup por objeto.
La consecuencia final es la que importa: si diseñas los grupos así, el bucle de dibujado hace un setBindGroup por material y ninguno por objeto, y el coste de CPU por objeto baja a un draw. Si no lo haces, acabas con un setBindGroup por objeto y con un renderer que se pasa el fotograma escribiendo tablas de descriptores. La API te da cuatro ranuras porque quiere que tomes esa decisión antes de escribir el primer shader, no después de medir.