wandres.dev
BIND GROUPS I · El modelo de recursos

GPUBindGroupLayout: el contrato entre el shader y la API

Las cinco clases de entrada que admite un bind group layout, cada campo de sus descriptores con sus valores por defecto, y las reglas de validación que no aparecen en el error del navegador.

⏱ 22 min

Un GPUBindGroupLayout no contiene ningún recurso. Es una descripción de qué recursos van a venir, de qué tipo son y desde qué etapas del shader se van a leer. Toda la validación cara ocurre aquí, una sola vez, y a cambio el resto del ciclo de vida es barato. Conocer cada campo de este descriptor es lo que separa un error de validación de veinte minutos de un error de treinta segundos.

🎯 Al terminar esta lección sabrás
  • Escribir un GPUBindGroupLayout con las cinco clases de entrada y sus campos exactos.
  • Elegir el type, el sampleType y el viewDimension correctos para cada recurso de un shader dado.
  • Usar minBindingSize para mover errores de tiempo de dibujado a tiempo de creación del pipeline.
  • Enumerar las restricciones de visibilidad que invalidan un layout y por qué existen.

La estructura de una entrada

El descriptor tiene un label opcional y un array entries. Cada entrada lleva tres cosas: un binding, una visibility y exactamente uno de los cinco objetos que declaran de qué clase es el recurso.

const layout = device.createBindGroupLayout({
  label: 'escena',
  entries: [
    {
      binding: 0,                                   // coincide con @binding(0)
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: 'uniform', minBindingSize: 144 },
    },
  ],
});

El binding es el número que aparece en el atributo @binding(n) del WGSL. No tiene que ser consecutivo ni empezar en cero; solo tiene que ser único dentro del layout y menor que maxBindingsPerBindGroup. Dejar huecos numéricos es legítimo y a veces útil para reservar sitio a recursos opcionales, pero recuerda que los límites por etapa cuentan entradas, no índices.

La visibility es una máscara de bits con GPUShaderStage.VERTEX (1), GPUShaderStage.FRAGMENT (2) y GPUShaderStage.COMPUTE (4), combinables con |. Declarar más etapas de las necesarias no da error pero desperdicia presupuesto: los límites como maxUniformBuffersPerShaderStage se cuentan por etapa, así que una entrada visible desde tres etapas consume una ranura en cada una de las tres.

Las cinco clases de objeto de recurso son buffer, sampler, texture, storageTexture y externalTexture. Poner dos en la misma entrada es un error de validación inmediato, igual que no poner ninguno.

Los cinco descriptores de recurso, campo a campo

buffer acepta tres campos, todos opcionales:

Campo Valores Por defecto
type 'uniform', 'storage', 'read-only-storage' 'uniform'
hasDynamicOffset booleano false
minBindingSize número de bytes 0

type decide contra qué límites se cuenta la entrada y qué flag de uso necesita el buffer: 'uniform' exige GPUBufferUsage.UNIFORM, las dos variantes de storage exigen GPUBufferUsage.STORAGE. hasDynamicOffset habilita el offset que se pasa en setBindGroup, y lo desarrollo en la lección de dynamic offsets.

minBindingSize merece atención porque casi nadie lo usa y casi todo el mundo debería. Con el valor por defecto de 0, el tamaño mínimo se ignora al crear el pipeline y se comprueba en cada draw. Poniendo el tamaño real de tu struct, la comprobación se hace una vez al crear el pipeline y el error aparece con un mensaje concreto en lugar de aparecer a mitad de un render pass.

sampler solo tiene type, con tres valores: 'filtering' (por defecto), 'non-filtering' y 'comparison'. La distinción no es cosmética. Un sampler declarado 'filtering' no puede muestrear una textura cuyo sampleType sea 'unfilterable-float', y un sampler 'comparison' corresponde al tipo WGSL sampler_comparison, que es el que se usa para sombras.

texture tiene tres campos:

Campo Valores Por defecto
sampleType 'float', 'unfilterable-float', 'depth', 'sint', 'uint' 'float'
viewDimension '1d', '2d', '2d-array', 'cube', 'cube-array', '3d' '2d'
multisampled booleano false

El sampleType tiene que coincidir con el tipo que el WGSL declara: texture_2d<f32> exige 'float' o 'unfilterable-float', texture_2d<u32> exige 'uint', y texture_depth_2d exige 'depth'. Si multisampled es true, el viewDimension tiene que ser '2d' y el sampleType no puede ser 'float'; el motivo es que las texturas multimuestreadas no se filtran.

storageTexture exige format y admite dos opcionales:

Campo Valores Por defecto
format un formato con capacidad de storage obligatorio
access 'write-only', 'read-only', 'read-write' 'write-only'
viewDimension como en texture, sin 'cube' ni 'cube-array' '2d'

Los valores 'read-only' y 'read-write' requieren que la extensión de lenguaje readonly_and_readwrite_storage_textures esté presente en navigator.gpu.wgslLanguageFeatures. El nivel 23 entero va de esto.

externalTexture es el objeto vacío {}. Corresponde al tipo WGSL texture_external y sirve para vídeo importado con device.importExternalTexture().

⚠️
Los nombres de las opciones se validan, los tipos no siempre

WebGPU ignora silenciosamente las claves desconocidas en un descriptor, porque el binding de WebIDL solo lee las que conoce. Escribir sampletype en minúsculas o viewDimensions en plural no produce ningún error: produce una entrada con los valores por defecto, y el fallo aparece mucho después como una incompatibilidad de pipeline incomprensible. Si usas TypeScript, los tipos de @webgpu/types atrapan esto en el editor.

Las reglas que invalidan un layout

Hay un puñado de reglas de validación que conviene memorizar porque el mensaje del navegador no siempre las explica bien.

Si la visibility de una entrada incluye GPUShaderStage.VERTEX, entonces esa entrada no puede ser un buffer con type: 'storage' ni un storageTexture de ningún tipo. La etapa de vértices es de solo lectura en el modelo portable de WebGPU. Sí puede ser 'read-only-storage', que es la forma correcta de leer un array grande desde el vertex shader.

Los límites por etapa se aplican sumando todas las entradas visibles desde esa etapa a lo largo de todo el pipeline layout, no de un solo grupo. Los valores garantizados que más se rozan en la práctica son maxUniformBuffersPerShaderStage = 12, maxStorageBuffersPerShaderStage = 8, maxSampledTexturesPerShaderStage = 16, maxSamplersPerShaderStage = 16 y maxStorageTexturesPerShaderStage = 4. Hay además límites separados y más estrictos para la etapa de vértices en algunos dispositivos: maxStorageBuffersInVertexStage y maxStorageTexturesInVertexStage, que en el modo de compatibilidad valen 0.

Un storageTexture no admite viewDimension: 'cube' ni 'cube-array', y su format tiene que ser uno de los que soportan STORAGE_BINDING. Y una entrada multimuestreada, como se dijo, se restringe a '2d' con sampleType distinto de 'float'.

Un layout completo contra un shader real

La forma sana de escribir un layout es leer el WGSL y traducir cada declaración. Dado este fragmento:

struct Camara {
  vista       : mat4x4<f32>,
  proyeccion  : mat4x4<f32>,
  posicion    : vec3<f32>,
};

@group(0) @binding(0) var<uniform> camara : Camara;
@group(0) @binding(1) var muestreo : sampler;
@group(0) @binding(2) var sombraCmp : sampler_comparison;
@group(0) @binding(3) var mapaSombra : texture_depth_2d;
@group(0) @binding(4) var<storage, read> luces : array<Luz>;

la traducción es mecánica:

const layoutEscena = device.createBindGroupLayout({
  label: 'escena',
  entries: [
    { binding: 0,
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      // 2 mat4x4 (64 B cada una) + vec3 alineado a 16 = 144 bytes
      buffer: { type: 'uniform', minBindingSize: 144 } },

    { binding: 1, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'filtering' } },

    { binding: 2, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'comparison' } },

    { binding: 3, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'depth', viewDimension: '2d' } },

    { binding: 4, visibility: GPUShaderStage.FRAGMENT,
      buffer: { type: 'read-only-storage' } },
  ],
});

El minBindingSize de 144 no es un número inventado: sale de aplicar las reglas de alineación de WGSL a la struct, que es el tema del nivel 19. Y fíjate en que la entrada 4 es 'read-only-storage' aunque solo la use el fragment shader: si mañana la necesitas también en vertex, el layout ya lo permite.

Escribe el layout a mano aunque el shader ya lo diga

Existe la tentación de generar el layout desde el WGSL con una librería de reflexión, y hay buenas que lo hacen. Pero el layout escrito a mano cumple una función que la reflexión no puede cumplir: es una declaración de intenciones que el compilador de shaders verifica. Si cambias el WGSL y olvidas el layout, el error salta al crear el pipeline con un mensaje preciso. Si el layout se genera del shader, el error no salta nunca porque el layout siempre coincide, y el fallo se desplaza al bind group, donde ya no tienes contexto. El layout explícito es un test de tu shader que se ejecuta en cada arranque.