wandres.dev
BIND GROUPS I · El modelo de recursos

Por qué WebGPU agrupa recursos en vez de bindearlos uno a uno

El coste de validación que hundía el modelo de bindeo individual de WebGL, cómo lo resolvieron Vulkan, D3D12 y Metal, y por qué WebGPU separa la forma de los recursos de su contenido.

⏱ 18 min

En WebGL cada recurso se ata por separado: una llamada para la textura, otra para el buffer de uniformes, otra para cambiar el valor de una matriz. Parece cómodo hasta que cuentas las llamadas de un frame real y descubres que la mitad del tiempo de CPU se va en revalidar un estado que casi no ha cambiado. WebGPU cambió la unidad: en lugar de recursos sueltos, se ata un grupo ya validado, y esa decisión arrastra consigo tres objetos nuevos que conviene entender antes de escribir la primera línea.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el bindeo individual obliga al driver a revalidar el estado en cada draw call.
  • Relacionar el bind group de WebGPU con el descriptor set de Vulkan, la descriptor table de D3D12 y el argument buffer de Metal.
  • Distinguir la forma de un conjunto de recursos de su contenido, y saber qué objeto representa cada cosa.
  • Enumerar los tres objetos del modelo de recursos y en qué momento del ciclo de vida se crea cada uno.

Lo que costaba bindear uno a uno

El modelo de WebGL es una máquina de estados global con ranuras numeradas. Para dibujar un objeto texturizado activas una unidad de textura, atas la textura a esa unidad, escribes el número de unidad en un uniform de tipo sampler2D, y repites por cada textura del material:

gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, albedo);
gl.uniform1i(locAlbedo, 0);

gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, normal);
gl.uniform1i(locNormal, 1);

gl.uniformMatrix4fv(locModel, false, matrizModelo);
gl.drawElements(gl.TRIANGLES, cuenta, gl.UNSIGNED_SHORT, 0);

Cada una de esas llamadas es barata por separado. El problema aparece en el drawElements. En ese instante el driver tiene que comprobar que el estado completo es coherente: que la textura atada a la unidad 0 tiene un formato compatible con el tipo que el shader declara, que ninguna textura está simultáneamente atada como framebuffer y como fuente, que los uniforms tienen tamaños que encajan, que el programa enlazado admite ese formato de vértices. Como cualquiera de las llamadas anteriores puede haber invalidado cualquier suposición, la comprobación es completa y por draw call.

Los drivers reales mitigan esto con cachés de estado, con hashes del estado atado y con detección de cambios, y esa mitigación es justamente el problema: es código imprevisible, distinto en cada implementación, que consume CPU en el hilo que menos te sobra. Un renderer con dos mil objetos hace dos mil validaciones completas por frame de un estado que, en la práctica, solo cambia en dos o tres ranuras entre objeto y objeto.

Hay un segundo coste, menos visible pero igual de real: la traducción. En una GPU moderna los recursos que un shader puede leer no viven en ranuras numeradas sino en tablas de descriptores en memoria: bloques contiguos de metadatos que dicen dónde está cada buffer y cada textura. La ranura de WebGL es una ficción que el driver mantiene reconstruyendo esa tabla en cada draw. Bindear uno a uno significa, a nivel de hardware, reescribir una tabla entera para cambiar una entrada.

ℹ️
No es que WebGL fuera mal diseño

El modelo de ranuras era razonable para el hardware de 1998, cuando había ocho unidades de textura físicas y el driver escribía registros. Lo que rompió el modelo fue que las GPUs pasaron a leer descriptores desde memoria, y la abstracción dejó de corresponderse con nada real.

Cómo lo resolvieron las APIs nativas

Las tres APIs explícitas de la generación anterior a WebGPU llegaron a la misma conclusión con nombres distintos. Vulkan lo llama descriptor set: un bloque de descriptores creado a partir de un VkDescriptorSetLayout, que se ata entero con vkCmdBindDescriptorSets. D3D12 lo llama descriptor table, y la root signature declara qué tablas espera el pipeline. Metal lo llama argument buffer: un buffer que contiene referencias a otros recursos y que se ata con una sola llamada.

El patrón compartido tiene dos mitades. Primero se declara la forma: cuántas entradas hay, de qué tipo es cada una, qué etapas del shader las ven. Esa declaración se valida una vez, al crearla, y produce un objeto inmutable. Después se rellena el contenido: qué buffer concreto, qué textura concreta va en cada entrada. Como la forma ya está validada, rellenar el contenido solo exige comprobar que cada recurso encaja en su hueco, y atarlo en un draw call no exige comprobar nada.

WebGPU adopta el patrón entero y lo traduce a tres objetos:

Objeto Qué representa Cuándo se crea
GPUBindGroupLayout La forma: tipos, índices y visibilidad Al arrancar, una vez
GPUBindGroup El contenido: los recursos concretos Cuando cambia el conjunto de recursos
GPUPipelineLayout La lista ordenada de layouts que un pipeline espera Al arrancar, una vez

La consecuencia práctica es que setBindGroup puede ser, en el mejor caso, escribir un puntero en el command buffer. No hay nada que validar porque la validación ocurrió cuando se creó el grupo, y el pipeline ya declaró que acepta grupos de esa forma exacta.

Forma y contenido, y por qué la separación paga

La separación parece burocracia hasta que la ves funcionar. Un GPUBindGroupLayout dice, por ejemplo: entrada 0, un buffer uniform, visible desde vertex y fragment; entrada 1, un sampler filtrante, visible desde fragment; entrada 2, una textura float 2D, visible desde fragment. Eso describe a todos los materiales de tu escena que tengan una textura y un sampler. Se crea una vez.

Después, cada material crea su propio GPUBindGroup contra ese layout, con su textura y su buffer. Cien materiales, cien bind groups, un solo layout. Y como todos comparten layout, todos son intercambiables en el mismo hueco del mismo pipeline sin recrear nada:

const layoutMaterial = device.createBindGroupLayout({
  label: 'material',
  entries: [
    { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: 'uniform' } },
    { binding: 1, visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'filtering' } },
    { binding: 2, visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float', viewDimension: '2d' } },
  ],
});

// Un grupo por material, todos con la misma forma.
const grupos = materiales.map((m) => device.createBindGroup({
  label: `material ${m.nombre}`,
  layout: layoutMaterial,
  entries: [
    { binding: 0, resource: { buffer: m.uniformes } },
    { binding: 1, resource: m.sampler },
    { binding: 2, resource: m.textura.createView() },
  ],
}));

Fíjate en el label. WebGPU lo acepta en casi todos los descriptores y lo devuelve en los mensajes de error de validación. En un renderer con cuarenta bind groups, la diferencia entre depurar con etiquetas y sin ellas es la diferencia entre un mensaje que dice qué material está mal y uno que dice bind group inválido en el índice 1.

El grupo es la unidad de coste, no el recurso

Cuando alguien viene de WebGL y mide su primer renderer de WebGPU, casi siempre encuentra que ha creado un bind group por objeto y por frame. Funciona y no da errores, pero tira por tierra el motivo entero del diseño: crear un GPUBindGroup implica validar cada recurso contra el layout, y hacerlo dos mil veces por frame es reproducir el coste de WebGL con más ceremonia. La regla que hay que interiorizar es que un bind group es un objeto de larga vida: se crea cuando cambia el conjunto de recursos, no cuando cambian sus valores. Si lo único que cambia son los bytes dentro de un buffer, escribes el buffer y reutilizas el grupo tal cual.

El precio que pagas a cambio

Nada de esto es gratis. El modelo agrupado impone tres restricciones que sorprenden al principio.

La primera es que el número de grupos simultáneos es pequeño. El límite garantizado maxBindGroups vale 4. No son cuatro recursos: son cuatro grupos, cada uno con hasta maxBindingsPerBindGroup entradas (640 por defecto), sujetas a su vez a límites por etapa como maxUniformBuffersPerShaderStage (12) o maxSampledTexturesPerShaderStage (16). Pero cuatro ranuras de grupo obligan a decidir de antemano qué va junto con qué, y esa decisión es el tema del nivel siguiente.

La segunda es que el pipeline y los bind groups están acoplados por el layout. Un pipeline creado con un GPUPipelineLayout concreto solo acepta bind groups creados con los layouts de esa lista. Si dos pipelines usan layouts distintos aunque idénticos en contenido, sus bind groups no son intercambiables. Es un detalle que decide si tu renderer puede reutilizar el grupo de cámara entre pipelines o tiene que duplicarlo.

La tercera es que la visibilidad se declara y se respeta. Si una entrada dice visibility: GPUShaderStage.FRAGMENT y el vertex shader la lee, el pipeline es inválido. Esto es deliberado: declarar visibilidad estrecha permite a la implementación colocar el descriptor donde el hardware lo lee más rápido, y en algunas arquitecturas móviles la diferencia es medible.

Hay además restricciones concretas que conviene conocer desde el principio, porque el mensaje de error no siempre es obvio: si una entrada es visible desde VERTEX, no puede ser un buffer de tipo storage (sí read-only-storage) ni una storage texture. La razón es histórica y de portabilidad: escribir desde la etapa de vértices no está disponible en todo el hardware que WebGPU tiene que cubrir.

Con el mapa puesto, los cuatro objetos se explican solos. El layout describe la forma, el grupo rellena el contenido, el pipeline layout ordena los grupos y el modo auto es el atajo que conviene abandonar pronto.

⚔️ Reto práctico

Coge un renderer tuyo de WebGL, aunque sea pequeño, y cuenta las llamadas de estado por frame: bindTexture, uniform*, bindBuffer, useProgram. Después agrupa esas llamadas por frecuencia real de cambio: cuáles cambian una vez por frame, cuáles por material y cuáles por objeto. Ese recuento es el borrador de tus bind groups.