wandres.dev
PORTABILIDAD · Límites, features y fallback

Comprobar las features antes de pedirlas

El catálogo real de features de WebGPU, por qué pedir una que no existe rechaza la creación del dispositivo, y el patrón de capacidades que convierte una comprobación en una decisión de render.

⏱ 18 min

Una feature de WebGPU es una capacidad opcional que el adaptador puede tener o no, y que tienes que pedir explícitamente para poder usarla. Suena trivial hasta que descubres las tres asimetrías: pedir una que no existe no degrada nada, revienta la creación del dispositivo; tener la feature en el adaptador no basta si no la pides; y la especificación define muchas más de las que cualquier navegador implementa hoy.

🎯 Al terminar esta lección sabrás
  • Consultar adapter.features y device.features con la API de conjunto que exponen.
  • Construir el array requiredFeatures de forma condicional para que nunca rechace.
  • Traducir el catálogo de features a decisiones concretas de formato, de shader y de calidad.
  • Distinguir una feature de WebGPU de una extensión del lenguaje WGSL.

El catálogo y cómo se consulta

adapter.features y device.features son objetos de tipo conjunto de solo lectura: tienen size, has(), values(), keys(), entries() y forEach(), y se pueden recorrer con for...of. No son arrays, así que no tienen includes ni filter; si necesitas manipularlos, conviértelos con Array.from(adapter.features).

const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) throw new Error('Sin adaptador WebGPU');

console.log('features del adaptador:', [...adapter.features].sort());
console.log('¿f16?', adapter.features.has('shader-f16'));

Los nombres son cadenas exactas y en minúsculas con guiones. Este es el catálogo que define la especificación, agrupado por para qué sirve cada uno:

Feature Qué desbloquea
timestamp-query Query sets de tipo "timestamp" y timestampWrites en los passes
shader-f16 El tipo f16 en WGSL
subgroups Operaciones de subgrupo en WGSL, y subgroupMinSize y subgroupMaxSize en adapter.info
float32-filterable Filtrar con sampler texturas r32float, rg32float y rgba32float
float32-blendable Mezclar (blending) sobre esos mismos formatos
rg11b10ufloat-renderable Usar rg11b10ufloat como attachment, con blending y multisampling
bgra8unorm-storage Usar bgra8unorm como storage texture
depth32float-stencil8 El formato de profundidad y estarcido combinado de 32 bits
depth-clip-control La propiedad unclippedDepth en el bloque primitive del pipeline
indirect-first-instance Un firstInstance distinto de cero en los dibujados indirectos
dual-source-blending Los factores src1, one-minus-src1, src1-alpha y one-minus-src1-alpha
clip-distances El builtin clip_distances de WGSL
primitive-index El builtin primitive_index en el fragment shader
texture-component-swizzle La propiedad swizzle en createView()
texture-compression-bc Los formatos BC, los de escritorio
texture-compression-bc-sliced-3d Los mismos formatos BC en texturas tridimensionales
texture-compression-etc2 Los formatos ETC2 y EAC, los de Android
texture-compression-etc2-sliced-3d ETC2 en tres dimensiones
texture-compression-astc Los formatos ASTC
texture-compression-astc-sliced-3d ASTC en tres dimensiones
texture-formats-tier1 Un conjunto ampliado de formatos de textura
texture-formats-tier2 Formatos de storage texture que el núcleo no cubre
core-features-and-limits Señala que el adaptador soporta WebGPU completo y no el modo de compatibilidad

Dos avisos que ahorran horas. El primero: que una feature esté en la especificación no significa que ningún navegador la implemente, y menos aún que tu GPU la tenga; la lista de arriba es lo que puede existir, no lo que vas a encontrar. El segundo: las tres familias de compresión de textura se reparten el mundo, con texture-compression-bc en escritorio, texture-compression-etc2 en Android y texture-compression-astc en móviles modernos y bastante escritorio reciente, así que si distribuyes texturas comprimidas necesitas los tres juegos de assets o aceptas volver a formatos sin comprimir.

Pedir sin que reviente

La regla es simple y las consecuencias de saltársela no lo son: si requiredFeatures contiene una feature que el adaptador no tiene, requestDevice() rechaza con un TypeError. No te da un dispositivo degradado ni ignora la petición. Se cae.

Por eso el array se construye filtrando, nunca a mano:

/** Pide solo las features que el adaptador ofrece de verdad. */
function negociarFeatures(
  adapter: GPUAdapter,
  deseadas: GPUFeatureName[],
): GPUFeatureName[] {
  return deseadas.filter((f) => adapter.features.has(f));
}

const deseadas: GPUFeatureName[] = [
  'timestamp-query',
  'shader-f16',
  'float32-filterable',
  'texture-compression-bc',
  'texture-compression-astc',
];

const device = await adapter.requestDevice({
  label: 'dispositivo-principal',
  requiredFeatures: negociarFeatures(adapter, deseadas),
});

Y a partir de ahí la fuente de verdad es device.features, no adapter.features. El adaptador te dice lo que podrías haber pedido; el dispositivo, lo que puedes usar. Consultar el adaptador después de crear el dispositivo es el error que produce el fallo más difícil de leer: la feature aparece disponible, la usas, y el error de validación te dice que no está habilitada.

Lo natural es congelar esa negociación en un objeto de capacidades que se calcula una vez y se consulta en todas partes:

export interface Capacidades {
  perfilado: boolean;
  mediaPrecision: boolean;
  hdrFiltrable: boolean;
  compresion: 'bc' | 'astc' | 'etc2' | 'ninguna';
}

export function leerCapacidades(device: GPUDevice): Capacidades {
  const f = device.features;
  return {
    perfilado: f.has('timestamp-query'),
    mediaPrecision: f.has('shader-f16'),
    hdrFiltrable: f.has('float32-filterable'),
    compresion: f.has('texture-compression-bc') ? 'bc'
      : f.has('texture-compression-astc') ? 'astc'
      : f.has('texture-compression-etc2') ? 'etc2'
      : 'ninguna',
  };
}

Ese objeto es el que decide qué shader se compila, qué formato se pide y qué assets se descargan. La alternativa (comprobar has() esparcido por el código) garantiza que un día una comprobación se olvide.

De la feature al shader

Varias features no se activan solo pidiéndolas: además hay que declarar la extensión correspondiente en el WGSL con una directiva enable al principio del módulo, antes de cualquier declaración. El caso más frecuente es la media precisión:

enable f16;

@group(0) @binding(0) var<storage, read_write> pesos: array<f16>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3u) {
  let i = gid.x;
  if (i >= arrayLength(&pesos)) { return; }
  pesos[i] = pesos[i] * f16(0.5);
}

Si el dispositivo no tiene shader-f16, ese módulo ni siquiera compila, así que la variante en f16 y la variante en f32 tienen que ser dos fuentes distintas o una sola con la directiva y las declaraciones inyectadas por concatenación. La forma limpia es una función que devuelve el fuente según las capacidades:

function fuenteDelKernel(cap: Capacidades): string {
  const escalar = cap.mediaPrecision ? 'f16' : 'f32';
  const cabecera = cap.mediaPrecision ? 'enable f16;\n' : '';
  return `${cabecera}
@group(0) @binding(0) var<storage, read_write> pesos: array<${escalar}>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3u) {
  let i = gid.x;
  if (i >= arrayLength(&pesos)) { return; }
  pesos[i] = pesos[i] * ${escalar}(0.5);
}`;
}

Fíjate en que el cambio no es solo de tipo: también cambia el tamaño del buffer que subes desde JavaScript, porque f16 ocupa dos bytes y f32 cuatro. Una feature nunca es un interruptor aislado; arrastra el formato de los datos con ella.

Hay un tercer plano, distinto de las features, que conviene no confundir: las características del lenguaje WGSL, que se consultan en navigator.gpu.wgslLanguageFeatures, otro objeto de tipo conjunto con has(). Son cosas que la implementación del compilador soporta o no, independientes del hardware y sin necesidad de pedirlas al crear el dispositivo. Si escribes WGSL generado o usas construcciones recientes del lenguaje, comprobar ahí antes de compilar te ahorra un error de compilación en el navegador equivocado.

console.log('lenguaje WGSL:', [...navigator.gpu.wgslLanguageFeatures]);
Ninguna feature se comprueba una vez: se comprueba en el punto donde cambiaría tu decisión

El patrón que casi todo el mundo escribe la primera vez es un bloque al arrancar que consulta todas las features, imprime un informe bonito y guarda un montón de booleanos. Funciona, y no es lo que hacen los motores que aguantan. La diferencia es sutil pero cambia la arquitectura: una feature no es información, es una bifurcación, y una bifurcación tiene que vivir pegada a la decisión que gobierna, no a doscientas líneas de distancia. El perfilado es el ejemplo más claro: si timestamp-query está detrás de un booleano global consultado en el arranque, el día que ese booleano sea false tu perfilador devolverá ceros silenciosamente y te pasarás una tarde optimizando un pass que dura cero. Si en cambio la comprobación vive dentro del propio perfilador y este devuelve null en vez de números cuando no puede medir, el fallo se ve al instante. La misma lógica vale para la compresión de texturas (la decisión no es “tengo BC”, es “qué URL descargo”), para float32-filterable (la decisión no es un booleano, es qué formato pide createTexture) y para shader-f16 (la decisión es qué fuente se concatena). Cuando llevas esto al extremo aparece la propiedad que de verdad quieres: puedes forzar cualquier rama a false desde un parámetro de la URL y probar la ruta degradada en tu propia máquina, sin buscar un dispositivo antiguo. Un motor en el que no puedes simular la ausencia de una feature es un motor cuya ruta de reserva nunca se ha ejecutado.

⚔️ Negocia y falsea
  1. Vuelca adapter.features en tu navegador y compáralo con el catálogo de la tabla; cuenta cuántas de las veintitantas tienes de verdad.
  2. Pide a propósito una feature que no tengas y comprueba en la consola que el rechazo es un TypeError y no otra cosa.
  3. Escribe leerCapacidades para tu proyecto y añade un mecanismo que fuerce cualquier capacidad a false desde la cadena de consulta de la URL.
  4. Compila el mismo kernel en f16 y en f32 y comprueba que el buffer que subes tiene la mitad de bytes en el primer caso.