wandres.dev
ADAPTADOR Y DISPOSITIVO · Iniciar WebGPU

requestDevice y las features: pedir capacidades sin romperse

El descriptor del dispositivo, el catálogo de capacidades opcionales de WebGPU, cómo pedirlas de forma que un dispositivo modesto no falle, y qué es device.queue.

⏱ 17 min

El dispositivo es tu conexión lógica con la GPU y la fábrica de todo lo demás. Crearlo son tres líneas, y en esas tres líneas se decide si tu aplicación funciona en un portátil moderno y en nada más, o si funciona en todo lo que tenga WebGPU. La diferencia está entera en cómo se piden las capacidades opcionales.

🎯 Al terminar esta lección sabrás
  • Describir los cuatro miembros de GPUDeviceDescriptor y su efecto.
  • Consultar el catálogo de features y decidir cuáles son opcionales y cuáles requisitos.
  • Escribir una petición de dispositivo que degrada en vez de rechazar.
  • Explicar qué es device.queue y por qué solo hay una.

El descriptor

Cuatro miembros, todos opcionales:

const device = await adapter.requestDevice({
  label: 'dispositivo principal',
  requiredFeatures: ['timestamp-query'],
  requiredLimits: { maxStorageBufferBindingSize: 268435456 },
  defaultQueue: { label: 'cola principal' },
});

label es la etiqueta de depuración. Como en todos los descriptores de WebGPU, no cuesta nada y aparece en los mensajes de error.

requiredFeatures es un array de nombres de capacidades opcionales. Si pides una que el adaptador no anuncia, la promesa rechaza con TypeError, no devuelve un dispositivo degradado.

requiredLimits es un objeto con nombres de límite y valores. Si pides más de lo que el adaptador soporta, la promesa rechaza con OperationError. Tiene su propia lección porque el modelo de límites es más sutil de lo que parece.

defaultQueue es un GPUQueueDescriptor que solo contiene label. Sirve para que los mensajes de error relativos a la cola sean legibles y para nada más.

La consecuencia práctica de los dos rechazos es la regla que gobierna todo el arranque: nunca pidas directamente lo que quieres. Pide la intersección entre lo que quieres y lo que hay.

const deseadas = ['timestamp-query', 'shader-f16', 'texture-compression-bc'];
const concedidas = deseadas.filter((f) => adapter.features.has(f));
const device = await adapter.requestDevice({ requiredFeatures: concedidas });

// Y despues, el codigo consulta que se concedio:
const puedoMedirTiempos = device.features.has('timestamp-query');

Fíjate en que la consulta posterior se hace sobre device.features, no sobre adapter.features. Son conjuntos distintos: el adaptador dice lo que podría dar, el dispositivo dice lo que se concedió. Un dispositivo no tiene automáticamente todas las features de su adaptador: solo las que pediste. Es un diseño deliberado, para que una aplicación no dependa por accidente de una capacidad que no declaró y que en otro equipo no estará.

Las capacidades opcionales de WebGPU son pocas a propósito. Estas son las que más se usan, agrupadas por para qué sirven.

Feature Qué desbloquea
timestamp-query Medir tiempo de GPU dentro del flujo de comandos
shader-f16 El tipo f16 en WGSL: la mitad de ancho de banda y registros
texture-compression-bc Formatos BC, típicos de escritorio
texture-compression-etc2 Formatos ETC2, típicos de móvil
texture-compression-astc Formatos ASTC, móvil moderno
depth32float-stencil8 Formato de profundidad de 32 bits con estencil
depth-clip-control Desactivar el recorte por profundidad
indirect-first-instance Usar firstInstance en dibujado indirecto
rg11b10ufloat-renderable Dibujar a ese formato compacto de alto rango
bgra8unorm-storage Usar bgra8unorm como textura de almacenamiento
float32-filterable Filtrado lineal en texturas de 32 bits en coma flotante

Dos observaciones sobre esa tabla que importan al diseñar.

Los formatos de textura comprimida están repartidos por plataforma. BC domina en escritorio, ETC2 y ASTC en móvil, y prácticamente ningún dispositivo tiene los tres. Cualquier canalización de assets seria genera las tres variantes y elige en tiempo de ejecución cuál descargar. Comprobar cuál hay antes de decidir la URL de descarga es una de las razones más comunes para consultar adapter.features antes de pedir el dispositivo.

timestamp-query no está en todas partes y no se puede sustituir. Es la única forma de medir tiempo de GPU desde dentro de WebGPU. Si no está, la alternativa son los experimentos de bisección del nivel 2.

El catálogo crece con cada revisión de la especificación —hay capacidades más recientes en torno a la mezcla de doble fuente, las operaciones de subgrupo y las distancias de recorte— y ninguna implementación las tiene todas. El código que las consulta con has() antes de usarlas no se rompe cuando aparecen ni cuando faltan.

ℹ️
core-features-and-limits

Existe una feature llamada core-features-and-limits cuya presencia indica que el dispositivo cumple el conjunto completo de WebGPU y no está en modo de compatibilidad. Es la forma de comprobar desde el dispositivo qué nivel se te ha concedido, sin tener que recordar qué featureLevel pediste.

Requisito o mejora: la pregunta que hay que hacerse antes

Cada feature de tu lista es una de dos cosas, y decidir cuál antes de escribir el código de arranque ahorra reescribirlo después.

Una mejora es algo cuya ausencia degrada la experiencia sin romperla. Las texturas comprimidas son el ejemplo perfecto: sin ellas se usan texturas sin comprimir, ocupan más memoria y tardan más en cargar, y todo funciona. Las mejoras se piden filtradas y se consultan al usarlas.

Un requisito es algo sin lo cual tu aplicación no tiene sentido. Si tu producto es un visor de datos científicos que necesita filtrado en texturas de 32 bits, sin float32-filterable no hay producto. Los requisitos se comprueban antes de arrancar y producen un mensaje honesto al usuario, no un error de consola.

const REQUISITOS = ['float32-filterable'];
const MEJORAS = ['timestamp-query', 'shader-f16', 'texture-compression-bc'];

const faltan = REQUISITOS.filter((f) => !adapter.features.has(f));
if (faltan.length) {
  return { ok: false, motivo: 'requisitos', faltan };
}

const device = await adapter.requestDevice({
  requiredFeatures: [...REQUISITOS, ...MEJORAS.filter((f) => adapter.features.has(f))],
});

Diez líneas, y son la diferencia entre un producto que explica por qué no funciona y uno que se queda en negro.

La cola

device.queue es un GPUQueue y ya existe: no se crea. Es el único destino de trabajo de la API y tiene cuatro métodos.

submit(commandBuffers) entrega un array de command buffers para su ejecución, en orden.

writeBuffer(buffer, offset, data, dataOffset, size) escribe datos desde JavaScript a un búfer de la GPU.

writeTexture(destino, datos, disposicion, tamano) hace lo equivalente con una textura.

copyExternalImageToTexture(origen, destino, tamano) sube una imagen, un ImageBitmap, un HTMLVideoElement o un canvas a una textura por el camino rápido de la implementación.

Y una promesa, onSubmittedWorkDone(), que resuelve cuando todo el trabajo enviado hasta ese momento ha terminado.

Que haya una sola cola es una simplificación deliberada respecto a Vulkan, que expone familias de colas distintas para gráficos, cómputo y transferencia. La consecuencia es que no puedes expresar «esta copia grande puede ir en paralelo con el render»: todo lo que envías entra en el mismo orden. La ventaja es que desaparece toda la sincronización entre colas, que en Vulkan es de las partes más difíciles de hacer bien.

Las features que no pides son features que no tienes, y ese es exactamente el punto

Hay una pregunta que aparece siempre: si el adaptador ya tiene la capacidad, por qué hay que pedirla. Parece burocracia y es la decisión de portabilidad más importante de la API.

El motivo es que WebGPU quiere que tu código falle en tu máquina, no en la del usuario. Si el dispositivo concediera automáticamente todo lo que el hardware tiene, tu shader con f16 funcionaría perfectamente en tu portátil sin que hubieras declarado nada, y fallaría en el móvil de un usuario tres meses después. Al obligarte a declarar, la validación puede rechazar el uso de una capacidad no declarada aunque el hardware la tenga, y ese rechazo ocurre en tu máquina, la primera vez que lo ejecutas.

Es el mismo principio que hace útiles los tipos estáticos: el error no se elimina, se adelanta a un momento en que es barato. Y tiene una consecuencia de método que conviene adoptar desde el primer día: para probar el camino degradado, comenta la feature en requiredFeatures. Sin tocar nada más, tu código pasa a ejecutarse como en el dispositivo que no la tiene, y descubres inmediatamente si la rama alternativa existe o si era una intención. Ese experimento cuesta diez segundos y es lo más cerca que vas a estar de probar en hardware que no tienes.

Queda la parte del arranque que más problemas causa en producción: los límites y la diferencia entre lo garantizado y lo real.