Degradar a WebGL2 sin engañarte
Qué se traduce de WebGPU a WebGL2 y qué no tiene traducción posible, las tres estrategias reales de reserva, y el criterio para decidir si tu proyecto necesita una.
“Si no hay WebGPU, caemos a WebGL2” es la frase que más veces se dice y menos veces se cumple. No porque sea imposible, sino porque quien la dice suele imaginar una capa fina de traducción, y lo que hay debajo son dos modelos de máquina distintos cuya intersección es exactamente el conjunto de cosas por las que no habrías elegido WebGPU. La decisión honesta no es si degradar, sino qué degradar: la API, o la experiencia.
- Enumerar qué conceptos de WebGPU tienen equivalente en WebGL2 y cuáles no tienen ninguno.
- Elegir entre las tres estrategias de reserva según lo que tu proyecto use de verdad.
- Escribir el arranque que detecta y selecciona el backend sin bloquear el primer pintado.
- Decidir con criterio cuándo construir una ruta de reserva es tirar el tiempo.
Qué se traduce y qué no
WebGL2 es, a día de hoy, la base más segura que existe en la web para gráficos acelerados: está en los cuatro motores desde 2021 y llega a dispositivos donde WebGPU no llegará nunca porque el hardware o el driver no dan para más. Esa es la razón por la que el enfoque correcto es la mejora progresiva y no la sustitución.
Ahora la parte incómoda. Esto es lo que se traduce y lo que no:
| Concepto de WebGPU | En WebGL2 |
|---|---|
| Render pipeline y estado inmutable | Se emula guardando y restaurando estado de la máquina; funciona, cuesta CPU |
| Vertex buffers y atributos | Equivalente directo con vertex array objects |
| Instancing | Núcleo de WebGL2 |
| Uniform buffers | Uniform buffer objects, con el mismo límite práctico de 64 KiB |
| Múltiples render targets | drawBuffers, hasta 8 |
| Texturas 3D, arrays y cubemaps | Núcleo de WebGL2 |
| Attachments en coma flotante | Con la extensión EXT_color_buffer_float |
| Timestamp queries | Con la extensión EXT_disjoint_timer_query_webgl2, y con menos garantías |
| Occlusion queries | Núcleo de WebGL2 |
| Compute shaders | No existe equivalente |
| Storage buffers de escritura | No existe equivalente |
| Memoria compartida de workgroup y barreras | No existe |
| Operaciones atómicas | No existen |
| Draw y dispatch indirectos | No existen |
| Storage textures | No existen |
Las seis filas en negrita son el problema entero, y no es casualidad que sean exactamente las que motivaron WebGPU. WebGL2 nunca tuvo compute: hubo una especificación de WebGL 2.0 Compute que se abandonó precisamente porque el esfuerzo se redirigió a WebGPU. Lo más parecido que queda es el transform feedback, que permite capturar la salida de un vertex shader en un buffer y por tanto hacer una simulación de partículas por elemento. Sirve para eso y para poco más: no hay escritura en direcciones arbitrarias, no hay comunicación entre invocaciones, no hay atómicos y el orden de salida es el de entrada. Un sistema de partículas independientes se traduce; un solucionador de fluidos con búsqueda de vecinos, una reducción, un scan, un culling en GPU o cualquier cosa que necesite que dos hilos se hablen, no.
La conclusión operativa es dura y conviene tenerla clara antes de empezar: si tu motor usa compute para algo que no sea decorativo, no tienes una ruta de reserva a WebGL2; tienes un segundo motor.
Las tres estrategias
La primera es la abstracción propia. Defines una interfaz que cubre lo que tu motor necesita y la implementas dos veces. Es la que más control da y la que más cuesta, porque la interfaz acaba siendo el mínimo común denominador, es decir WebGL2 con nombres de WebGPU. Merece la pena en un caso concreto y solo en ese: cuando el grueso de tu render es rasterización clásica y lo que usas de WebGPU es la organización (pipelines inmutables, bind groups, validación previa) y no las capacidades nuevas. Ahí la ganancia de WebGPU es de coste de CPU y de mantenibilidad, y ese modelo sí se puede emular por encima de WebGL2.
export interface Backend {
readonly tipo: 'webgpu' | 'webgl2';
crearMalla(datos: DatosMalla): Malla;
crearMaterial(desc: DescMaterial): Material;
render(escena: Escena, camara: Camara): void;
destruir(): void;
}
Lo que hace o rompe esta estrategia es dónde pones la costura. Si la interfaz habla de buffers, pipelines y bind groups, has escrito WebGPU otra vez y la implementación de WebGL2 será un infierno. Si habla de mallas, materiales y pasadas, las dos implementaciones son razonables. La regla: abstrae por encima del vocabulario de la API, nunca al nivel del vocabulario de la API.
La segunda es delegar en una librería. El WebGPURenderer de Three.js cae automáticamente a WebGL cuando no hay WebGPU, y TSL compila un mismo grafo de nodos a WGSL y a GLSL, así que un material escrito una vez funciona en las dos rutas. Es, con diferencia, la mejor relación entre esfuerzo y cobertura si tu proyecto encaja en un motor de escena. Tiene su letra pequeña: la ruta GLSL es la del backend WebGL2 de WebGPURenderer y no la del WebGLRenderer clásico, y los nodos de cómputo no tienen a dónde caer, así que lo que hagas con computeAsync sigue necesitando WebGPU.
La tercera es degradar la experiencia, no la API. Es la que más se desprecia y la que más veces es correcta. En lugar de reimplementar tu renderizador, defines dos productos: la versión completa en WebGPU y una versión sencilla que no intenta parecerse. Un visor de producto puede ser un modelo con iluminación básica en WebGL2 en vez de un path tracer progresivo. Una visualización de datos puede ser SVG o Canvas 2D con menos puntos. Una escena de fondo puede ser un vídeo o una imagen. La ventaja no es el ahorro: es que la ruta de reserva es tan sencilla que no se rompe, mientras que una segunda implementación completa se pudre en cuanto nadie la prueba durante tres meses.
El arranque que decide
La detección tiene tres pasos y todos pueden fallar. navigator.gpu puede no existir; requestAdapter() puede devolver null aunque navigator.gpu exista (pasa cuando el navegador ha puesto la GPU en su lista negra o cuando no hay ningún adaptador utilizable); y requestDevice() puede rechazar o devolver un dispositivo que ya está perdido.
type Eleccion =
| { backend: 'webgpu'; device: GPUDevice; adapter: GPUAdapter }
| { backend: 'webgl2'; gl: WebGL2RenderingContext }
| { backend: 'ninguno'; motivo: string };
export async function elegirBackend(canvas: HTMLCanvasElement): Promise<Eleccion> {
// Permite forzar la ruta de reserva para poder probarla.
const forzado = new URLSearchParams(location.search).get('gfx');
if (forzado !== 'webgl2' && 'gpu' in navigator) {
try {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
const device = await adapter.requestDevice({ label: 'principal' });
// Un dispositivo puede nacer perdido: comprobamos sin bloquear.
let vivo = true;
device.lost.then(() => { vivo = false; });
await Promise.resolve();
if (vivo) return { backend: 'webgpu', device, adapter };
}
} catch (e) {
console.warn('WebGPU no utilizable, se degrada:', e);
}
}
const gl = canvas.getContext('webgl2');
if (gl) return { backend: 'webgl2', gl };
return { backend: 'ninguno', motivo: 'ni WebGPU ni WebGL2' };
}
Tres detalles que importan más de lo que parecen. El parámetro gfx=webgl2 en la URL no es un lujo: es la única forma barata de ejecutar la ruta de reserva en tu propia máquina, y una ruta que no ejecutas no funciona. El try alrededor de todo el bloque cubre que requestDevice() rechace por un TypeError o un OperationError, que es exactamente lo que pasa si en algún punto del código pediste una feature sin filtrar. Y el catch no debe tragarse el error en silencio: si tu telemetría no distingue “no hay WebGPU en este dispositivo” de “hay WebGPU y mi código lo pidió mal”, vas a leer los mismos números durante meses sin entender nada.
El otro detalle, de rendimiento: la detección es asíncrona y el usuario está mirando una pantalla en blanco mientras ocurre. Pinta algo primero (el fondo, un esqueleto, la primera imagen) y arranca el backend después, porque requestAdapter puede tardar decenas de milisegundos, y en un dispositivo lento bastante más.
Cuándo no merece la pena
Escribir una ruta de reserva completa cuesta entre un tercio y la mitad de lo que costó el motor, y hay que mantenerla para siempre. Antes de empezar, responde a estas cuatro preguntas con números y no con intuiciones.
¿Qué fracción de tus usuarios reales no tiene WebGPU? No de los usuarios de internet: de los tuyos. Si tu producto es una herramienta interna para un equipo con portátiles recientes, la respuesta puede ser cero. Si es una web pública con tráfico móvil masivo, es una fracción grande. Mídelo antes: publica una sonda que solo compruebe navigator.gpu y requestAdapter() y registre el resultado, y toma la decisión con datos.
¿Qué pasa si un usuario sin WebGPU no ve tu escena? Si tu escena es la aplicación entera, necesitas reserva. Si es un adorno de la portada, necesitas una imagen.
¿Usas compute para algo estructural? Si la respuesta es sí, deja de llamarlo reserva y llámalo por su nombre: un segundo producto con otro alcance.
¿Puedes probar la reserva en cada cambio? Si no puedes ejecutarla en tu máquina y en integración continua con un interruptor, se va a romper. Una ruta de reserva rota es peor que no tenerla, porque falla delante del usuario en vez de fallar en el arranque con un mensaje claro.
El fallo estructural de casi todas las rutas de reserva no es técnico: es que nadie las usa. Se escriben el mes tres, se prueban una vez, y a partir del mes cuatro todo el equipo desarrolla sobre WebGPU porque es lo que tienen. Seis meses después la ruta de WebGL2 lleva veinte cambios de escena sin ejecutar y está rota de una forma que nadie descubrirá hasta que la vea un usuario. La solución que funciona no es más disciplina, es cambiar el incentivo: haz que la ruta de reserva sea la ruta por defecto de alguien real. Puede ser un entorno de vista previa que se sirva siempre en WebGL2, un modo de bajo consumo que la use cuando la batería está por debajo de un umbral, o una captura de imágenes de referencia en integración continua que renderice en las dos rutas y compare píxel a píxel. Cualquiera de las tres convierte un camino muerto en un camino vivo, y el efecto secundario es el que de verdad quieres: te obliga a mantener la escena expresada en un vocabulario que las dos rutas entienden, lo que a su vez te impide que la lógica del juego se enrede con los detalles de la API. Cuando llevas un tiempo trabajando así descubres la ironía: la ruta de reserva mejora la arquitectura del camino principal más de lo que cuesta mantenerla, y ese es el único argumento que hace que sobreviva a la primera fecha de entrega apretada.
- Publica una sonda mínima que compruebe
navigator.gpuy el resultado derequestAdapter()y registre el par junto al agente de usuario. Déjala una semana. - Haz el inventario de tu motor: lista qué pasadas usan compute, storage buffers de escritura, atómicos o indirectos. Esa lista es lo que no tiene reserva.
- Implementa
elegirBackendcon el parámetro de URL y ejecuta tu proyecto entero congfx=webgl2. Apunta lo primero que se rompe. - Decide, por escrito y con los números del punto 1, cuál de las tres estrategias te toca. Y si la respuesta es “una imagen estática”, escríbelo también.