Los flags de usage y las combinaciones que la validación rechaza
Los diez flags de GPUBufferUsage, qué habilita cada uno, las dos reglas de exclusión que afectan a MAP_READ y MAP_WRITE, y por qué declarar de más tiene coste.
El campo usage de un búfer es una máscara de bits que declara de antemano todo lo que vas a hacer con él. La declaración es obligatoria, es inmutable y es estricta: usar un búfer para algo que no declaraste es un error de validación, aunque el hardware lo permitiera perfectamente. Y hay dos combinaciones que la especificación rechaza de forma explícita, ambas relacionadas con el mapeo, que son la fuente del error más común al empezar.
- Enumerar los diez flags de
GPUBufferUsagey qué operación habilita cada uno. - Aplicar las dos reglas de exclusión del mapeo.
- Componer las combinaciones canónicas para los casos de uso habituales.
- Explicar por qué declarar usos de más tiene coste real.
Los diez flags
GPUBufferUsage tiene exactamente diez constantes:
| Flag | Valor | Habilita |
|---|---|---|
MAP_READ |
0x0001 | mapAsync con GPUMapMode.READ |
MAP_WRITE |
0x0002 | mapAsync con GPUMapMode.WRITE |
COPY_SRC |
0x0004 | Ser origen de copyBufferToBuffer o copyBufferToTexture |
COPY_DST |
0x0008 | Ser destino de una copia o de queue.writeBuffer |
INDEX |
0x0010 | setIndexBuffer |
VERTEX |
0x0020 | setVertexBuffer |
UNIFORM |
0x0040 | Enlazarse como var<uniform> |
STORAGE |
0x0080 | Enlazarse como var<storage> |
INDIRECT |
0x0100 | Ser origen de drawIndirect o dispatchWorkgroupsIndirect |
QUERY_RESOLVE |
0x0200 | Ser destino de resolveQuerySet |
Se combinan con el operador de bits |, y hay dos que sorprenden por lo que no habilitan.
COPY_DST es necesario para queue.writeBuffer. Escribir desde JavaScript es una copia, y por tanto el búfer tiene que declararse como destino de copia. Es el olvido número uno de los primeros días: creas un búfer uniforme con usage: GPUBufferUsage.UNIFORM, llamas a writeBuffer, y no pasa absolutamente nada visible salvo un error en la consola que quizá no estás mirando.
MAP_WRITE no sirve para lo que la gente cree. No es «el búfer al que puedo escribir»: eso es COPY_DST. MAP_WRITE es «el búfer que puedo mapear en memoria de JavaScript para escribir directamente en él», y su uso normal es como origen de staging, no como destino final.
Las dos reglas de exclusión
La especificación define dos restricciones explícitas, y las dos son sobre los flags de mapeo:
Si MAP_READ está presente, el único otro flag permitido es COPY_DST.
Si MAP_WRITE está presente, el único otro flag permitido es COPY_SRC.
Es decir, hay exactamente cuatro combinaciones legales que incluyan mapeo: MAP_READ, MAP_READ | COPY_DST, MAP_WRITE y MAP_WRITE | COPY_SRC. Todo lo demás con un flag de mapeo dentro es inválido:
// INVALIDO: MAP_READ solo admite COPY_DST.
device.createBuffer({ size: 256, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.STORAGE });
// INVALIDO: MAP_READ y MAP_WRITE juntos.
device.createBuffer({ size: 256, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.MAP_WRITE });
// INVALIDO: MAP_WRITE no admite COPY_DST.
device.createBuffer({ size: 256, usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_DST });
// VALIDO: el buffer de staging para leer resultados.
device.createBuffer({ size: 256, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
El motivo es el modelo de memoria. Un búfer mapeable tiene que vivir en memoria visible desde la CPU, que en una tarjeta discreta es memoria del sistema o una región especial accesible por el bus, y en cualquier caso no es donde vive la memoria rápida de la GPU. Permitir que ese mismo búfer fuera también un búfer de vértices obligaría a la implementación a elegir: o lo pone en memoria lenta y el render sufre, o lo mueve constantemente. La especificación corta por lo sano y obliga a que la copia sea explícita.
De ahí sale el patrón que gobierna toda la transferencia de datos en WebGPU: un búfer de staging mapeable, un búfer de trabajo no mapeable, y una copia entre ellos. Es la razón de que leer resultados sea el proceso de varios pasos que es.
createBuffer con una combinación inválida no lanza: devuelve un GPUBuffer marcado como inválido y emite un GPUValidationError. Ese objeto se puede pasar por ahí, guardar en una estructura y referenciar en un bind group; el fallo aparece más tarde, en la primera operación que intente usarlo, y el mensaje habla de un búfer inválido sin decir por qué lo es. Sin un manejador de uncapturederror instalado, la única señal es que nada funciona.
Las combinaciones canónicas
Nueve casos cubren prácticamente todo lo que vas a escribir.
const U = GPUBufferUsage;
// Vertices o indices estaticos, escritos una vez al cargar.
{ usage: U.VERTEX | U.COPY_DST }
{ usage: U.INDEX | U.COPY_DST }
// Uniformes que se actualizan cada fotograma.
{ usage: U.UNIFORM | U.COPY_DST }
// Datos de almacenamiento que escribe la CPU y lee un shader.
{ usage: U.STORAGE | U.COPY_DST }
// Resultado de un compute que hay que leer de vuelta.
{ usage: U.STORAGE | U.COPY_SRC }
// Staging de lectura: recibe la copia y se mapea.
{ usage: U.MAP_READ | U.COPY_DST }
// Staging de escritura: se mapea, se llena y se copia al de trabajo.
{ usage: U.MAP_WRITE | U.COPY_SRC }
// Parametros de un dibujado indirecto escritos por un compute shader.
{ usage: U.INDIRECT | U.STORAGE | U.COPY_DST }
// Simulacion en GPU cuyo resultado se dibuja directamente como vertices.
{ usage: U.STORAGE | U.VERTEX | U.COPY_DST }
Las dos últimas merecen atención porque son las que hacen interesante a WebGPU. Un búfer con INDIRECT | STORAGE permite que un compute shader escriba el número de instancias a dibujar y que el dibujado lo lea sin que la CPU se entere. Un búfer con STORAGE | VERTEX permite que una simulación escriba posiciones de partículas y que el render las consuma como atributos de vértice, en el mismo command buffer, sin ninguna copia.
Por qué no declararlo todo
La tentación obvia es poner todos los flags compatibles en todos los búferes y olvidarse. No es catastrófico y tiene coste real por tres vías.
La disposición de memoria. La implementación elige dónde y cómo colocar el búfer según sus usos. Un búfer solo de vértices puede ir en memoria exclusiva de la GPU con la alineación que mejor le venga al ensamblador de vértices. Uno que además puede ser destino de copia y origen de copia y almacenamiento tiene que satisfacer todas esas restricciones a la vez, y la intersección puede ser peor que cualquiera de ellas.
Las transiciones de estado. Recuerda que WebGPU inserta las barreras por ti, deduciéndolas del uso de cada recurso en cada pase. Cuantos más usos posibles declara un búfer, más conservadora tiene que ser esa deducción.
La validación de las reglas de exclusión. Con MAP_READ o MAP_WRITE en la máscara, añadir cualquier otro flag rompe el búfer entero. La estrategia de «lo declaro todo» no es siquiera aplicable a los búferes de staging.
Y hay un cuarto motivo que no es de rendimiento: el usage documenta la intención. Un búfer declarado VERTEX | COPY_DST dice sin ambigüedad qué es y cómo se llena. Uno declarado con siete flags no dice nada, y quien lea el código dentro de seis meses —tú— tendrá que reconstruirlo leyendo todos los usos.
Merece la pena hacer explícita la cadena de razonamiento, porque es la misma que explica el patrón de lectura que casi todo el mundo escribe mal la primera vez.
La memoria de una GPU discreta no es visible desde la CPU salvo por ventanas limitadas. La memoria visible desde la CPU es lenta para la GPU. Un búfer no puede estar en los dos sitios. Por tanto, un búfer que la CPU pueda mapear no puede ser al mismo tiempo un búfer que la GPU use con eficiencia, y por eso la especificación prohíbe las combinaciones en vez de aceptarlas y rendir mal en silencio.
La consecuencia es que cualquier movimiento de datos entre CPU y GPU pasa por dos búferes y una copia. En la dirección de escritura, queue.writeBuffer esconde el búfer de staging por ti y por eso parece que no hace falta. En la dirección de lectura no lo esconde nadie, y ahí es donde la gente descubre la regla: intenta crear un búfer STORAGE | MAP_READ para leer directamente el resultado de su compute shader, la validación lo rechaza, y no queda claro por qué.
La respuesta corta, para cuando llegues ahí: el búfer que escribe el compute shader es STORAGE | COPY_SRC, el búfer que lees es MAP_READ | COPY_DST, y entre los dos hay un copyBufferToBuffer. No hay atajo, y buscarlo es lo que hace perder la tarde. Que un dispositivo con memoria unificada podría en teoría evitar la copia no cambia nada: la API es la misma en todas partes, y la implementación ya optimiza lo que puede por debajo.
Con los usos claros, el siguiente paso es la forma más eficiente de llenar un búfer recién creado: mappedAtCreation.