navigator.gpu y una detección de soporte que no se rompe
El punto de entrada de la API, por qué comprobar que existe no basta, los tres modos de fallo distintos y el arranque completo que se puede copiar a producción.
La detección de WebGPU parece la parte trivial del proyecto y es donde se acumulan los informes de error de los usuarios que no puedes reproducir. La causa es que hay al menos cuatro formas distintas de no tener WebGPU, cada una con su síntoma y su respuesta correcta, y la comprobación que todo el mundo escribe la primera vez solo cubre una.
- Localizar el punto de entrada de la API y sus requisitos de contexto.
- Distinguir los cuatro modos de fallo de la iniciación y su respuesta adecuada.
- Escribir un arranque que degrada sin romper la página.
- Explicar por qué la API es asíncrona desde la primera llamada.
El punto de entrada y sus condiciones
navigator.gpu es un objeto de tipo GPU y es la única puerta. Tiene tres miembros útiles: requestAdapter(), getPreferredCanvasFormat() y wgslLanguageFeatures, este último un conjunto de cadenas con las extensiones del lenguaje que la implementación soporta.
Su presencia está condicionada por dos cosas que producen la mitad de los «pero si mi navegador lo soporta».
Contexto seguro. WebGPU solo está disponible en contextos seguros: HTTPS, o localhost y 127.0.0.1 durante el desarrollo. Una página servida por HTTP plano desde una IP de red local —el caso clásico de probar en el móvil contra el portátil— no tiene navigator.gpu aunque el navegador lo implemente. Para probar en dispositivos reales hace falta un certificado, aunque sea autofirmado, o un túnel.
El tipo de contexto de ejecución. Está en el hilo principal y en workers dedicados, a través de WorkerNavigator.gpu. El soporte en service workers no está garantizado en todas las implementaciones y no conviene apoyarse en él.
Y una advertencia que ahorra confusión: navigator.gpu existiendo no significa que haya una GPU utilizable. Los navegadores mantienen listas de bloqueo de combinaciones de hardware y controlador con fallos conocidos, y en esas configuraciones la API está presente y requestAdapter() devuelve null.
Cuatro modos de fallo
Conviene tratarlos por separado porque la respuesta de producto es distinta en cada uno.
Uno: no hay API. navigator.gpu es undefined. Navegador antiguo, navegador sin WebGPU en esa plataforma, o contexto no seguro. Es el caso más común con diferencia y la respuesta es el camino alternativo: WebGL2, canvas 2D, o contenido estático.
Dos: no hay adaptador. requestAdapter() resuelve a null. Hay API pero el navegador no encuentra una GPU que quiera usar: lista de bloqueo, controlador demasiado antiguo, o se pidió un featureLevel que la implementación no soporta. La respuesta es la misma que en el caso uno, y conviene distinguirlo porque su frecuencia en tus datos de telemetría te dice algo distinto sobre tu público.
Tres: el dispositivo no se puede crear. requestDevice() rechaza. Ocurre cuando pides requiredFeatures que el adaptador no tiene —rechaza con TypeError— o requiredLimits que no puede satisfacer —rechaza con OperationError—. Es un fallo tuyo, no del usuario: pediste más de lo que ese dispositivo ofrece. La respuesta correcta es volver a pedir con menos requisitos, no rendirse.
Cuatro: el dispositivo se pierde. Todo funcionó y a mitad de sesión el dispositivo deja de ser válido. Se trata en su propia lección.
adapter.requestDevice() solo puede llamarse una vez con éxito por adaptador. Un segundo intento sobre el mismo objeto rechaza con OperationError. Si necesitas otro dispositivo —por ejemplo, tras perder el anterior— hay que pedir un adaptador nuevo con requestAdapter().
El arranque completo
Esta función cubre los tres primeros modos de fallo, devuelve información suficiente para decidir, y no lanza nunca.
/**
* Inicia WebGPU con degradacion escalonada.
* Devuelve { ok: true, adapter, device } o { ok: false, motivo, detalle }.
*/
export async function iniciarWebGPU({ features = [], limits = {} } = {}) {
if (!('gpu' in navigator)) {
return { ok: false, motivo: 'sin-api' };
}
let adapter;
try {
adapter = await navigator.gpu.requestAdapter({
powerPreference: 'high-performance',
});
} catch (e) {
return { ok: false, motivo: 'sin-adaptador', detalle: e };
}
if (!adapter) {
return { ok: false, motivo: 'sin-adaptador' };
}
// Pedir solo las features que este adaptador tiene de verdad.
const requiredFeatures = features.filter((f) => adapter.features.has(f));
// Recortar cada limite pedido a lo que el adaptador soporta.
const requiredLimits = {};
for (const [nombre, valor] of Object.entries(limits)) {
const disponible = adapter.limits[nombre];
if (typeof disponible === 'number') {
requiredLimits[nombre] = Math.min(valor, disponible);
}
}
let device;
try {
device = await adapter.requestDevice({
label: 'dispositivo principal',
requiredFeatures,
requiredLimits,
});
} catch (e) {
return { ok: false, motivo: 'sin-dispositivo', detalle: e };
}
device.addEventListener('uncapturederror', (e) => {
console.error('[webgpu]', e.error.message);
});
return { ok: true, adapter, device, features: requiredFeatures };
}
Tres decisiones de ese código merecen justificación.
Se filtran las features en lugar de pedirlas a ciegas. requestDevice() rechaza si pides una feature que el adaptador no anuncia. Filtrar contra adapter.features convierte «no tengo esta capacidad» en información que tu código consulta después, en lugar de en un fallo total del arranque. Quien llama recibe la lista de las que sí se concedieron.
Se recortan los límites en lugar de exigirlos. Mismo razonamiento: Math.min contra adapter.limits garantiza que la petición es satisfacible. La alternativa —pedir el número que te gustaría— convierte un dispositivo de gama media en una pantalla de error.
El manejador de errores se instala antes de crear nada. A partir de esa línea, cualquier error de validación aparece en la consola con su mensaje. Sin él, un descriptor mal formado produce un objeto inválido en silencio y el fallo se manifiesta mucho después, en otro sitio.
Por qué todo esto es asíncrono
requestAdapter y requestDevice devuelven promesas, y eso obliga a que la iniciación de cualquier aplicación de WebGPU sea asíncrona. No es un capricho: hay tres razones concretas.
Elegir un adaptador puede implicar consultar al sistema operativo qué GPUs hay, cuál está activa y qué controladores están cargados. En un portátil con gráficos conmutables, puede implicar despertar la GPU discreta, que tarda cientos de milisegundos.
Crear un dispositivo puede implicar inicializar el contexto del controlador, reservar memoria y arrancar la infraestructura del proceso de GPU.
Y el navegador puede querer preguntar o decidir según políticas: consumo de energía, número de contextos ya abiertos, permisos.
Bloquear el hilo principal mientras ocurre cualquiera de esas tres cosas sería inaceptable, así que la API se diseñó asíncrona desde el primer día. La consecuencia práctica es que el arranque de tu aplicación tiene forma de await, y que hay que decidir qué se muestra mientras tanto.
El patrón que sale solo es un módulo con un let device = null y una función init() que lo rellena. Funciona el primer mes y luego produce tres problemas que ya no se pueden arreglar sin tocar todo el código.
El primero: hace imposible el reinicio tras perder el dispositivo. Si medio proyecto importó device directamente, cuando llegue un dispositivo nuevo esas referencias apuntan al viejo. La solución correcta desde el principio es que nadie guarde el dispositivo: se pasa como parámetro, o se accede a través de una función que devuelve el actual.
El segundo: hace imposible tener dos dispositivos, que es exactamente lo que necesitas el día que quieras mover el render a un worker mientras el hilo principal conserva el suyo para otra cosa.
El tercero, y el que muerde antes: hace imposible probar. Un módulo con estado global de dispositivo no se puede montar dos veces en el mismo proceso ni sustituir por un doble en un test.
La forma que no genera deuda cuesta lo mismo de escribir: una función iniciar() que devuelve un objeto con todo lo que hace falta, y todo lo demás recibe ese objeto. No es una recomendación de estilo; es la diferencia entre poder implementar la recuperación ante pérdida de dispositivo en una tarde o en una semana.
Con la puerta abierta, toca elegir qué hay detrás: requestAdapter() y sus opciones.