La inicialización asíncrona
Por qué WebGPU obliga a esperar antes de dibujar, qué hace init exactamente, y los dos patrones válidos según cómo dispares el render.
Obtener un contexto de WebGL es una llamada síncrona que devuelve un objeto o null. Obtener un dispositivo de WebGPU es una negociación en dos pasos con el sistema, cada uno de ellos asíncrono. Esa diferencia, que parece burocrática, se propaga hasta tu bucle de render y es la primera cosa que rompe al migrar.
- Explicar por qué la obtención de un dispositivo WebGPU es asíncrona.
- Usar
init()correctamente y saber que es idempotente. - Elegir entre el patrón de bucle continuo y el de render bajo demanda.
- Reconocer el error exacto de dibujar antes de tiempo.
Por qué hace falta esperar
En WebGL el navegador te entrega un contexto de inmediato. En WebGPU hay dos pasos, y los dos devuelven promesas.
Primero se pide un adaptador, que representa una implementación concreta de la GPU disponible en el sistema. El navegador puede tener que consultar al sistema operativo, decidir entre una GPU integrada y una discreta según la preferencia de energía, y comprobar listas de bloqueo por controlador. Después se pide un dispositivo a ese adaptador, declarando qué características y qué límites necesitas, y el navegador crea el objeto con la configuración negociada.
Nada de eso puede resolverse sin ceder el hilo, así que la API es asíncrona por diseño y no hay forma de esquivarlo.
El código de Three.js que lo hace es directo:
const adapter = ( typeof navigator !== 'undefined' ) ? await navigator.gpu.requestAdapter( adapterOptions ) : null;
if ( adapter === null ) {
throw new Error( 'WebGPUBackend: Unable to create WebGPU adapter.' );
}
Ese throw es la puerta por la que entra el mecanismo de respaldo, que es el tema de la lección siguiente.
Qué hace init
async init() {
if ( this._initPromise !== null ) {
return this._initPromise;
}
// ... crea el backend, gestiona el respaldo, monta los gestores internos
}
Tres propiedades que conviene saber.
Es idempotente. La promesa se memoriza, así que llamar a init() diez veces devuelve la misma promesa y solo inicializa una vez. No hace falta protegerlo con banderas.
Resuelve con el propio renderer, así que const r = await renderer.init() devuelve el mismo objeto y permite encadenar.
Cuando resuelve, todo está listo: el backend definitivo está elegido, los gestores internos construidos, el bucle de animación arrancado y renderer.initialized vale true.
Los dos patrones
Patrón uno: bucle continuo
Si tu aplicación dibuja en cada frame, no tienes que hacer nada. setAnimationLoop es asíncrono y espera la inicialización por dentro:
async setAnimationLoop( callback ) {
if ( this._initialized === false ) await this.init();
this._animation.setAnimationLoop( callback );
}
Así que esto es correcto tal cual:
const renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setAnimationLoop( () => {
controles.update();
renderer.render( escena, camara );
} );
El callback no se ejecutará hasta que el backend esté vivo, así que el render() de dentro nunca encuentra un renderer sin inicializar. Ni siquiera hace falta poner await delante de setAnimationLoop.
Patrón dos: render bajo demanda
Si dibujas solo cuando algo cambia —un configurador de producto, un visor que solo se actualiza al mover la cámara— tienes que esperar tú:
const renderer = new THREE.WebGPURenderer( { antialias: true } );
document.body.appendChild( renderer.domElement );
await renderer.init();
function dibujar() {
renderer.render( escena, camara );
}
controles.addEventListener( 'change', dibujar );
addEventListener( 'resize', () => {
renderer.setSize( innerWidth, innerHeight );
camara.aspect = innerWidth / innerHeight;
camara.updateProjectionMatrix();
dibujar();
} );
dibujar();
Si te lo saltas, el error es explícito:
Renderer: .render() called before the backend is initialized.
Use "await renderer.init();" before rendering.
Y la documentación del propio método resume las dos situaciones:
This method can only be called if the renderer has been initialized. When
using render() inside an animation loop, it's guaranteed the renderer will
be initialized. The animation loop must be defined with setAnimationLoop
though. For all other use cases (like when using on-demand rendering), you
must call init() before rendering.
await renderer.init() fuera de una función solo es válido dentro de un módulo ES, es decir, con <script type="module"> o desde un empaquetador. En un script clásico hay que envolverlo, y la forma más limpia es una función asíncrona autoinvocada que contenga todo el arranque.
Qué hacer mientras se espera
En la práctica la inicialización tarda unas decenas de milisegundos, no es un problema de rendimiento. Pero sí es un punto donde algo puede fallar, y merece un manejo explícito:
async function arrancar() {
const renderer = new THREE.WebGPURenderer( { antialias: true } );
try {
await renderer.init();
} catch ( error ) {
// Ni WebGPU ni el respaldo de WebGL2 han funcionado.
mostrarMensajeSinSoporte( error );
return;
}
document.body.appendChild( renderer.domElement );
montarEscena( renderer );
}
Que init() rechace es raro pero real: significa que ni WebGPU ni el respaldo de WebGL2 han conseguido arrancar. Ocurre en entornos virtualizados sin aceleración, con controladores en lista de bloqueo, o en máquinas muy antiguas. Un mensaje decente en ese caso vale más que una pantalla en blanco.
Aprovecha también ese punto para consultar qué has conseguido de verdad:
await renderer.init();
console.log( renderer.backend.constructor.name ); // WebGPUBackend o WebGLBackend
console.log( renderer.coordinateSystem === THREE.WebGPUCoordinateSystem );
Esa consulta solo dice la verdad después de init(), porque el backend puede haberse sustituido durante la inicialización.
Otros métodos que esperan por ti
El mismo patrón de auto-inicialización aparece en varios sitios:
async computeAsync( computeNodes, dispatchSize = null ) {
if ( this._initialized === false ) await this.init();
this.compute( computeNodes, dispatchSize );
}
computeAsync, resolveTimestampsAsync e initTextureAsync llevan esa misma guarda. Nota que computeAsync no está obsoleto, al contrario que renderAsync: ahí la variante asíncrona sigue siendo la forma normal de trabajar.
Vale la pena fijarse en cómo ha evolucionado este diseño, porque es una lección de API que se repite en muchos sitios. La primera versión del renderer de WebGPU en Three.js hacía lo obvio: si la GPU es asíncrona, que el render sea asíncrono. De ahí salieron renderAsync(), hasFeatureAsync() y compañía, y de ahí salió una generación entera de ejemplos con await renderer.renderAsync( escena, camara ) dentro del bucle de animación. Funcionaba, y era un mal diseño por dos razones. La primera, práctica: poner un await en el bucle de render introduce un salto al microtask queue en cada frame, y aunque la promesa ya esté resuelta eso no es gratis y complica el control del ritmo. La segunda, conceptual y más importante: la asincronía no era del render, era del arranque. Dibujar un frame es una operación síncrona sobre un dispositivo que ya tienes; lo que era asíncrono es conseguir el dispositivo, y eso ocurre exactamente una vez en la vida de la aplicación. Marcar como asíncrono el render era propagar hacia arriba una incertidumbre que se resuelve abajo y una sola vez. La corrección de r181 consistió en empujar la espera al sitio correcto —un await renderer.init() al arrancar— y devolver a render() su naturaleza síncrona. El resultado es que la asincronía aparece una vez, en el borde del sistema, y todo lo de dentro vuelve a ser código normal. Ese patrón tiene nombre en el diseño de APIs y merece la pena reconocerlo cuando aparece: empuja la incertidumbre hacia el borde y mantén el núcleo síncrono. Es lo mismo que hacer la validación en la entrada en lugar de comprobar por todas partes, o cargar la configuración al arrancar en lugar de leerla en cada uso. Y cuando veas una API donde todo devuelve promesas, casi siempre significa que alguien no hizo ese trabajo.