Los límites que la especificación te garantiza y los que no
La tabla completa de límites por defecto de WebGPU, por qué el dispositivo no hereda las capacidades del adaptador, y cómo diseñar contra el contrato en vez de contra tu tarjeta gráfica.
Tu shader funciona. En tu máquina. Con tu GPU, tu driver y tu navegador. La pregunta que decide si tu proyecto es un experimento o un producto es otra: qué garantiza la especificación que va a funcionar en cualquier dispositivo que implemente WebGPU correctamente. Y la respuesta es un conjunto de treinta y pico números que casi nadie mira hasta que un usuario abre un informe de error desde un portátil de hace cuatro años.
- Enumerar los límites por defecto de WebGPU y traducirlos a decisiones de diseño concretas.
- Explicar por qué
device.limitsno coincide conadapter.limitsaunque no hayas pedido nada. - Pedir límites elevados con
requiredLimitssin que la petición reviente en dispositivos modestos. - Reconocer que los valores que el navegador reporta están escalonados y no son los del hardware.
El contrato mínimo
WebGPU no promete el hardware que tienes: promete un mínimo. La especificación fija un valor por defecto para cada límite, y cualquier implementación conforme tiene que ofrecer al menos ese valor. Ese conjunto de números es el contrato: si tu motor cabe dentro, funciona en todas partes; si no cabe, funciona donde tú probaste.
Los números no son arbitrarios. Salieron de mirar la intersección de lo que garantizan Direct3D 11 de nivel 11.0, Metal en dispositivos móviles antiguos y OpenGL ES 3.1, porque WebGPU tenía que poder implementarse encima de todos ellos. Por eso algunos parecen bajos para una GPU de escritorio de 2026: no describen la GPU media, describen el suelo.
Estos son los que de verdad cambian cómo se diseña un motor:
| Límite | Valor por defecto | Qué te obliga a hacer |
|---|---|---|
maxBindGroups |
4 | Organizar los recursos en exactamente cuatro frecuencias de cambio, ni una más |
maxBindingsPerBindGroup |
640 | Casi nunca molesta |
maxUniformBufferBindingSize |
65536 | Un uniform buffer útil son 64 KiB, no un megabyte |
maxStorageBufferBindingSize |
134217728 | Un binding de storage no pasa de 128 MiB |
maxBufferSize |
268435456 | Ningún buffer pasa de 256 MiB |
maxStorageBuffersPerShaderStage |
8 | Ocho storage buffers por etapa, contando los de solo lectura |
maxStorageTexturesPerShaderStage |
4 | Cuatro storage textures por etapa |
maxSampledTexturesPerShaderStage |
16 | Dieciséis texturas muestreadas por etapa |
maxSamplersPerShaderStage |
16 | Reutiliza samplers, no los crees por material |
maxUniformBuffersPerShaderStage |
12 | |
minUniformBufferOffsetAlignment |
256 | Todo offset dinámico de uniform va alineado a 256 bytes |
minStorageBufferOffsetAlignment |
256 | Lo mismo para storage |
maxTextureDimension2D |
8192 | Un atlas de 16K no está garantizado |
maxTextureDimension3D |
2048 | |
maxTextureArrayLayers |
256 | Un array de texturas no pasa de 256 capas |
maxVertexBuffers |
8 | |
maxVertexAttributes |
16 | |
maxVertexBufferArrayStride |
2048 | |
maxInterStageShaderVariables |
16 | Dieciséis variables interpoladas entre vertex y fragment |
maxColorAttachments |
8 | |
maxColorAttachmentBytesPerSample |
32 | El límite que define tu G-buffer |
maxComputeWorkgroupStorageSize |
16384 | 16 KiB de memoria compartida por workgroup |
maxComputeInvocationsPerWorkgroup |
256 | El producto de las tres dimensiones no pasa de 256 |
maxComputeWorkgroupSizeX y Y |
256 | |
maxComputeWorkgroupSizeZ |
64 | |
maxComputeWorkgroupsPerDimension |
65535 | El que más sorprende |
maxDynamicUniformBuffersPerPipelineLayout |
8 | |
maxDynamicStorageBuffersPerPipelineLayout |
4 |
Tres de ellos merecen que hagas la cuenta antes de escribir una línea.
maxComputeWorkgroupsPerDimension vale 65535. Con un tamaño de workgroup de 64, un solo dispatchWorkgroups(n, 1, 1) cubre como máximo 65535 · 64 = 4194240 elementos. Si simulas diez millones de partículas, un despacho unidimensional no llega, y la solución no es subir el tamaño del workgroup (tienes un techo de 256 invocaciones) sino repartir en dos dimensiones y reconstruir el índice lineal dentro del shader.
override TAM: u32 = 64u;
@compute @workgroup_size(TAM)
fn main(@builtin(workgroup_id) wid: vec3u,
@builtin(local_invocation_index) li: u32) {
// El despacho es de anchoGrupos x altoGrupos grupos.
let grupoLineal = wid.y * anchoGrupos + wid.x;
let i = grupoLineal * TAM + li;
if (i >= totalParticulas) { return; }
// ...
}
maxStorageBufferBindingSize vale 128 MiB. Diez millones de partículas con 32 bytes de estado cada una son 320 MB: no caben en un binding, ni siquiera en un buffer, porque maxBufferSize está en 256 MiB. La respuesta correcta no es pedir más: es dividir el estado en varios buffers o adelgazar la estructura, que casi siempre es lo que había que hacer de todos modos.
maxColorAttachmentBytesPerSample vale 32. Ese número, y no el de attachments, es el que dicta cuánto G-buffer cabe: cuatro targets rgba8unorm son exactamente 16 bytes, y dos rgba16float más uno rgba8unorm son 20. En cuanto metes un rgba32float te has comido la mitad del presupuesto de una sola vez.
El dispositivo no hereda el adaptador
Este es el malentendido que produce el bug más desconcertante de todos: el código funciona en tu máquina, y funciona porque el adaptador soporta mucho, pero el dispositivo que has creado no lo hereda.
adapter.limits describe lo que el adaptador puede hacer. device.limits describe lo que el dispositivo que creaste puede hacer. Y por defecto, el dispositivo se crea con los límites por defecto de la especificación, no con los del adaptador. Aunque tu GPU aguante buffers de 2 GiB, si no pediste nada, device.limits.maxBufferSize vale 268435456 y un buffer más grande genera un error de validación.
Es una decisión de diseño deliberada y buena: obliga a que el comportamiento por defecto sea el portable. Si quieres más, lo pides, y al pedirlo estás declarando explícitamente que tu aplicación no funcionará donde no haya ese margen.
La forma correcta de pedir es condicional, nunca a ciegas:
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) throw new Error('Sin adaptador WebGPU');
const requiredLimits: Record<string, number> = {};
// Quiero buffers de hasta 512 MiB, pero solo si el adaptador llega.
const deseado = 512 * 1024 * 1024;
if (adapter.limits.maxBufferSize >= deseado) {
requiredLimits.maxBufferSize = deseado;
requiredLimits.maxStorageBufferBindingSize = deseado;
}
// Quiero workgroups de 512 invocaciones si se puede.
if (adapter.limits.maxComputeInvocationsPerWorkgroup >= 512) {
requiredLimits.maxComputeInvocationsPerWorkgroup = 512;
requiredLimits.maxComputeWorkgroupSizeX = 512;
}
const device = await adapter.requestDevice({
label: 'dispositivo-principal',
requiredLimits,
});
// A partir de aquí, la fuente de verdad es device.limits y solo device.limits.
const tamMaximoBuffer = device.limits.maxBufferSize;
Las reglas de fallo importan porque son distintas según qué pidas mal. Si requiredLimits contiene un valor mejor que el que soporta el adaptador, o una clave que no es un límite válido, requestDevice() rechaza con un OperationError. Si requiredFeatures contiene una feature que el adaptador no tiene, rechaza con un TypeError. Y si llamas dos veces a requestDevice() sobre el mismo adaptador, la segunda rechaza con OperationError porque el adaptador se consume al crear el dispositivo.
Hay una excepción cómoda: puedes poner en requiredLimits una clave que no exista y no pasa nada; simplemente quedará undefined. Está pensado así a propósito para que tu código no se rompa cuando la especificación renombra o retira un límite, que ya ha pasado (maxInterStageShaderComponents fue sustituido por maxInterStageShaderVariables).
Lo que el navegador te oculta
Hay una última capa que casi nadie tiene en cuenta: los valores que lees en adapter.limits no son los del hardware. Los navegadores los escalonan a propósito.
La razón es la huella digital. Los límites exactos de una GPU son un identificador razonablemente discriminante, así que en lugar de reportar el valor real las implementaciones lo redondean hacia abajo hasta un peldaño de una escala predefinida. Si los peldaños de un límite son 2048, 8192 y 32768 y tu GPU aguanta 16384, el navegador te dirá 8192. Lo mismo pasa con adapter.info: vendor, architecture, device y description pueden venir vacíos, y cada navegador decide cuánto cuenta.
Esto tiene tres consecuencias prácticas. La primera es que no puedes deducir el modelo de GPU de forma fiable, así que cualquier lógica del tipo “si es una tarjeta potente, activo el post-proceso caro” está construida sobre arena; lo correcto es medir el tiempo de fotograma real y adaptar. La segunda es que los peldaños cambian entre versiones de navegador, así que un valor concreto no es un contrato. Y la tercera, la buena: como los valores son conservadores, si tu código cabe en lo que te reportan, cabe de verdad.
// Diagnóstico honesto: vuelca el contrato real con el que estás trabajando.
function volcarLimites(device: GPUDevice) {
const filas: Record<string, number> = {};
for (const clave in device.limits) {
const valor = (device.limits as unknown as Record<string, number>)[clave];
if (typeof valor === 'number') filas[clave] = valor;
}
console.table(filas);
console.log('adaptador:', device.adapterInfo.vendor,
device.adapterInfo.architecture,
'fallback:', device.adapterInfo.isFallbackAdapter);
}
device.adapterInfo existe justo para esto: te da el GPUAdapterInfo del adaptador que originó el dispositivo sin tener que guardarte el adaptador. Y isFallbackAdapter te dice si estás corriendo sobre un adaptador de reserva, que es información útil para bajar la calidad antes de que el usuario vea diez fotogramas por segundo.
Diseñar contra el contrato
La disciplina que separa un motor portable de una demo cabe en cuatro reglas.
Escribe primero la versión que cabe en los límites por defecto. No la versión que necesita 512 MiB de buffer y luego la degradas: al revés. La ruta portable es la principal y las mejoras son opcionales, porque una ruta opcional que nunca se prueba se rompe, y si la rota es la portable te enteras cuando ya está publicado.
Ten el número de límites en el código, no en la cabeza. Cualquier tamaño de buffer, de despacho o de atlas se calcula a partir de device.limits, nunca de una constante que copiaste de la tabla. El día que pidas un límite mayor, todo se reajusta solo.
Falla pronto y con un mensaje que sirva. Si tu aplicación de verdad necesita un mínimo por encima del contrato, compruébalo al arrancar y dilo claro; es infinitamente mejor que un error de validación en el fotograma trescientos.
const MINIMOS = {
maxStorageBufferBindingSize: 64 * 1024 * 1024,
maxComputeInvocationsPerWorkgroup: 256,
maxTextureDimension2D: 4096,
} as const;
function comprobarMinimos(device: GPUDevice): string[] {
const fallos: string[] = [];
for (const [clave, minimo] of Object.entries(MINIMOS)) {
const real = (device.limits as unknown as Record<string, number>)[clave];
if (typeof real !== 'number' || real < minimo) {
fallos.push(`${clave}: necesito ${minimo}, tengo ${real}`);
}
}
return fallos;
}
Prueba en el suelo, no en el techo. Puedes simular un dispositivo mínimo pidiendo explícitamente los valores por defecto en requiredLimits aunque el adaptador dé más; a partir de ahí, cualquier cosa que se pase de la raya genera un error de validación en tu propia máquina, que es exactamente donde quieres verlo.
Merece la pena entender de dónde salen estos números, porque explica cuáles van a subir y cuáles no. maxBindGroups vale 4 porque Metal en dispositivos móviles antiguos ofrecía un número muy contado de buffers de argumentos y Direct3D 11 organizaba los recursos en espacios que se mapeaban mal a más de cuatro grupos; maxComputeWorkgroupsPerDimension vale 65535 porque es exactamente el máximo de Direct3D 11, un valor de 16 bits que lleva ahí desde 2009; y maxColorAttachmentBytesPerSample vale 32 porque es lo que garantizan las GPU móviles con arquitectura por tiles, donde los attachments viven en una memoria on-chip diminuta y cara. Ninguno de los tres describe una GPU de escritorio de 2026, que aguanta órdenes de magnitud más en los tres casos. Lo importante es la consecuencia: estos números no van a subir, porque subirlos rompería la promesa de portabilidad que es la razón de ser de la especificación. Lo que sí va a pasar, y ya está pasando, es que las capacidades nuevas lleguen como features opcionales que se piden explícitamente y que no todo el mundo tendrá. Así que la pregunta de diseño correcta nunca es “cuándo subirán el límite” sino “qué hago si no lo tengo”, y esa pregunta tiene respuesta hoy: divides el buffer, repartes el despacho en dos dimensiones, adelgazas el G-buffer. Los tres motores gráficos de escritorio que más te han impresionado hacen exactamente eso, y lo hacen porque en consolas y en móviles llevan veinte años sin tener otra opción.
- Vuelca
adapter.limitsydevice.limitsde tu máquina uno al lado del otro y localiza los límites en los que difieren; comprueba que difieren aunque no hayas pedido nada. - Crea un dispositivo pidiendo explícitamente los valores por defecto de la tabla en
requiredLimitsy ejecuta tu proyecto con él. Apunta el primer error de validación que salga. - Calcula, para tu G-buffer actual, los bytes por muestra y compáralos con 32.
- Escribe la función que reparte un despacho de
nelementos en dos dimensiones cuandon / tamGruposuperamaxComputeWorkgroupsPerDimension, y comprueba que reconstruye el índice lineal correctamente paranno múltiplo del tamaño de grupo.