El modo auto: qué hace, qué ahorra y por qué acaba estorbando
Cómo genera WebGPU un layout implícito a partir del shader, la regla de que esos layouts no se comparten entre pipelines, y las cuatro señales de que ha llegado el momento de abandonarlo.
layout: 'auto' es el atajo con el que casi todo el mundo escribe su primer pipeline de WebGPU, y hace bien: escribir tres layouts a mano para dibujar un triángulo es ceremonia sin retorno. El problema es que auto tiene una restricción concreta y muy poco intuitiva que no aparece hasta el segundo pipeline, y para entonces ya has escrito código que asume lo contrario.
- Describir qué layout genera WebGPU a partir de un módulo de shader y qué campos deduce.
- Recuperar un layout implícito con
getBindGroupLayout()y saber qué se puede hacer con él. - Explicar por qué un bind group creado desde un layout implícito no sirve en otro pipeline.
- Reconocer las cuatro señales que indican que hay que pasar a layouts explícitos.
Qué deduce el modo auto
Cuando pasas layout: 'auto' a createRenderPipeline o a createComputePipeline, la implementación analiza el WGSL del módulo o módulos, recoge todas las declaraciones con @group y @binding, y construye los bind group layouts necesarios. Deduce, para cada declaración, la clase de recurso, el tipo, la dimensión y la visibilidad, tomando esta última de las etapas que realmente usan la variable.
Después se recuperan con getBindGroupLayout(index), que existe tanto en GPURenderPipeline como en GPUComputePipeline:
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, buffers: [layoutVertices] },
fragment: { module, targets: [{ format }] },
});
const grupo = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: uniformes } },
{ binding: 1, resource: sampler },
{ binding: 2, resource: textura.createView() },
],
});
Para un ejemplo de una pantalla, esto es exactamente lo que quieres. Ahorra treinta líneas de descriptor y no puede desincronizarse del shader, porque se genera de él.
Hay dos matices sobre lo que deduce. El primero es que la visibilidad sale del uso real, no de la declaración: si una variable @group(0) @binding(0) solo se lee desde el fragment shader, la entrada generada tendrá visibility: GPUShaderStage.FRAGMENT a secas, aunque tú esperaras las dos etapas. El segundo es que minBindingSize se rellena con el tamaño de la struct declarada, que es una ventaja gratuita: los layouts implícitos validan tamaños mejor que los que la mayoría escribe a mano.
La restricción que rompe todo
La especificación lo dice en una frase: los bind group layouts generados por 'auto' solo pueden usarse con el pipeline que los generó.
Esto es más fuerte de lo que parece. No es que dos pipelines con shaders distintos produzcan layouts distintos —eso sería lógico—, es que dos pipelines con el mismo shader y 'auto' producen layouts distintos e incompatibles. Un bind group creado con pipelineA.getBindGroupLayout(0) no se puede usar mientras esté atado pipelineB, aunque sus layouts sean idénticos campo por campo:
const a = device.createRenderPipeline({ layout: 'auto', /* ... */ });
const b = device.createRenderPipeline({ layout: 'auto', /* ... */ }); // mismo módulo
const grupo = device.createBindGroup({ layout: a.getBindGroupLayout(0), entries });
pass.setPipeline(b);
pass.setBindGroup(0, grupo); // error de validación: layout incompatible
pass.draw(3);
El motivo es la compatibilidad de layouts. Dos GPUBindGroupLayout son compatibles si son el mismo objeto o si derivan del mismo GPUPipelineLayout explícito; la igualdad estructural no basta, y con 'auto' no hay ningún pipeline layout compartido del que derivar. La especificación lo definió así para no obligar a las implementaciones a comparar layouts campo por campo en cada setBindGroup, que es precisamente el trabajo que el modelo agrupado existe para evitar.
La reacción instintiva es crear un bind group por pipeline: uno con el layout de A y otro con el de B, apuntando a los mismos recursos. Funciona, y en un renderer con veinte pipelines te deja veinte copias del grupo de cámara que hay que recrear cuando cambia cualquier recurso. El coste no es de memoria sino de correctitud: es cuestión de tiempo que uno de los veinte se quede desincronizado.
Las cuatro señales
No hay que abandonar 'auto' el primer día. Hay que abandonarlo cuando aparece cualquiera de estas cuatro señales, y conviene reconocerlas antes de haber escrito mucho encima.
Tienes más de un pipeline que comparte recursos. En cuanto el pass opaco y el pass de sombras leen la misma cámara, 'auto' te obliga a duplicar. Es la señal más frecuente y la que llega antes.
Necesitas un binding que el shader no usa. Si el WGSL declara una variable y no la lee, el compilador puede eliminarla y el layout implícito no la incluirá. Tu bind group, que sí la incluye, dejará de validar. Esto muerde de forma especialmente traicionera cuando se compilan variantes de shader con override y una variante deja de usar una textura.
Necesitas hasDynamicOffset. No hay forma de expresarlo en WGSL, así que 'auto' nunca lo generará. Cualquier uso de dynamic offsets exige layout explícito.
Necesitas huecos nulos o índices estables. El patrón de bindGroupLayouts: [frame, null, objeto] para mantener los índices alineados entre passes solo existe con layouts explícitos.
Pasar de 'auto' a explícito en un proyecto pequeño es media hora; en uno grande es un fin de semana. La diferencia está en cuántos sitios asumieron que getBindGroupLayout estaba disponible. El truco para no pagarlo es escribir el código desde el principio como si los layouts fueran explícitos aunque uses 'auto': guarda los layouts en variables con nombre en el momento de crear el pipeline, y crea los bind groups desde esas variables, no llamando a getBindGroupLayout en línea. El día que migres solo tienes que cambiar el origen de esas variables, y el resto del renderer no se entera. Es una precaución de tres líneas que ahorra un refactor entero.
El patrón de transición
Esta es la forma de escribir el arranque para que la migración sea un cambio local:
// Fase 'auto': los layouts salen del pipeline, pero viven en variables con nombre.
const pipeline = device.createRenderPipeline({ layout: 'auto', /* ... */ });
const layoutFrame = pipeline.getBindGroupLayout(0);
const layoutMaterial = pipeline.getBindGroupLayout(1);
// El resto del renderer solo conoce estas dos variables.
const grupoFrame = device.createBindGroup({ layout: layoutFrame, entries: [...] });
Y así queda después, sin tocar nada más:
// Fase explícita: los layouts son la fuente, el pipeline el consumidor.
const layoutFrame = device.createBindGroupLayout({ label: 'frame', entries: [...] });
const layoutMaterial = device.createBindGroupLayout({ label: 'material', entries: [...] });
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [layoutFrame, layoutMaterial],
});
const pipeline = device.createRenderPipeline({ layout: pipelineLayout, /* ... */ });
const grupoFrame = device.createBindGroup({ layout: layoutFrame, entries: [...] });
La inversión de dependencia es todo el cambio: en la primera versión el pipeline produce los layouts, en la segunda los consume. Escribir la lección de bind group layout a mano cuesta lo que cuesta, pero es la única versión que permite compartir grupos entre pipelines.
Crea dos render pipelines con layout: 'auto' a partir del mismo módulo de shader y el mismo descriptor, y intenta atar un bind group del primero mientras el segundo está activo. Lee el mensaje de validación completo: verás que habla de compatibilidad de layouts y no de campos, que es la pista de que la comparación es por identidad y no por estructura.