Migrar desde WebGLRenderer
Qué cambia exactamente en la API al sustituir el renderer, qué parámetros de constructor son distintos, y qué métodos han desaparecido o cambiado de nombre.
El cambio de renderer es más pequeño de lo que la gente teme y más grande de lo que la gente cree. La mayoría del código de una escena no se entera: geometrías, materiales, luces, cargadores y controles funcionan igual. Lo que cambia es el punto de entrada, la inicialización, un puñado de parámetros de constructor y la forma en que se resuelve el tone mapping. Esta lección es el inventario completo.
- Sustituir
WebGLRendererporWebGPURendereren una escena existente. - Traducir los parámetros de constructor de uno a otro.
- Identificar los métodos añadidos, obsoletos y eliminados.
- Explicar por qué el tone mapping se resuelve de forma distinta en cada uno.
El cambio mínimo
// Antes
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer( { antialias: true } );
// Despues
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer( { antialias: true } );
Dos líneas. El resto de la escena —Scene, PerspectiveCamera, BufferGeometry, Mesh, las luces, GLTFLoader, OrbitControls— sigue igual, porque three/webgpu incluye el núcleo completo de Three.js.
Lo que sí cambia es que a partir de aquí hay una inicialización asíncrona de la que ocuparse. Con setAnimationLoop no hace falta hacer nada, porque el método la espera por dentro; con render bajo demanda sí, y eso es el tema de la lección siguiente.
Parámetros de constructor
WebGPURenderer documenta estos, con sus valores por defecto:
| Parámetro | Por defecto | Nota |
|---|---|---|
antialias |
false |
activa MSAA con 4 muestras |
samples |
0 |
número de muestras si quieres otro que 4 |
alpha |
true |
en WebGLRenderer el defecto es false |
depth |
true |
|
stencil |
false |
|
logarithmicDepthBuffer |
false |
|
reversedDepthBuffer |
false |
no existe en WebGLRenderer |
forceWebGL |
false |
fuerza el backend de WebGL2 |
multiview |
false |
para renderizado en WebXR |
outputType |
sin definir | tipo de textura de salida al canvas |
outputBufferType |
HalfFloatType |
tipo de los buffers intermedios |
Tres diferencias merecen atención.
alpha vale true por defecto, al contrario que en WebGLRenderer. Si tu escena asumía un lienzo opaco, puede que notes la diferencia al componer sobre la página.
outputBufferType vale HalfFloatType por defecto, según su propia documentación por calidad. Ponerlo a UnsignedByteType ahorra memoria y ancho de banda a costa de la calidad, y es un ajuste razonable en móviles de gama baja.
El antialiasing se pide igual pero se resuelve distinto. samples permite fijar un número de muestras que no sea el predeterminado, algo que en WebGLRenderer no está expuesto de esta forma.
Además, el backend acepta parámetros propios que se pasan por el mismo objeto: canvas para usar un lienzo existente, device para reutilizar un GPUDevice que ya tengas, requiredLimits para pedir límites concretos al adaptador, powerPreference, y trackTimestamp para habilitar la medición por consultas de marca de tiempo.
Métodos: lo que cambia
| Método | Estado en r184 |
|---|---|
render( scene, camera ) |
existe, lanza si el renderer no está inicializado |
init() |
nuevo, asíncrono, devuelve una promesa |
renderAsync( scene, camera ) |
obsoleto desde r181 |
hasFeature( name ) |
existe, lanza si no está inicializado |
hasFeatureAsync( name ) |
obsoleto desde r181 |
compute( nodes, dispatchSize ) |
nuevo |
computeAsync( nodes, dispatchSize ) |
existe y no está obsoleto |
waitForGPU() |
eliminado, su cuerpo solo emite un error |
resolveTimestampsAsync( tipo ) |
nuevo |
getArrayBufferAsync( atributo ) |
nuevo, lee de vuelta datos de la GPU |
setAnimationLoop( cb ) |
existe, ahora es asíncrono e inicializa |
setSize, setPixelRatio, setDrawingBufferSize |
igual |
getContext() |
devuelve GPUCanvasContext o WebGL2RenderingContext |
coordinateSystem |
getter nuevo, depende del backend |
initialized / hasInitialized() |
nuevos |
Los mensajes de obsolescencia de r181 son explícitos sobre qué hacer:
Renderer: "renderAsync()" has been deprecated. Use "render()" and
"await renderer.init();" when creating the renderer.
Es decir, el patrón recomendado ha pasado de esperar en cada frame a esperar una sola vez al arrancar. Si has visto código con await renderer.renderAsync( escena, camara ) dentro del bucle, está desactualizado y además era más lento de lo necesario.
La documentación de hasFeature() en r184 afirma que si el renderer no está inicializado el método devuelve false. El código hace otra cosa: lanza una excepción con el mensaje Renderer: .hasFeature() called before the backend is initialized. Es una discrepancia real entre documentación y comportamiento, y la forma segura de usarlo es siempre después de que la inicialización haya resuelto.
Tone mapping y espacio de color
Las propiedades son las mismas —toneMapping, toneMappingExposure, outputColorSpace— y sus valores por defecto también: sin tone mapping y salida en sRGB. Lo que cambia es dónde se aplican, y eso tiene consecuencias visibles.
En WebGLRenderer, el tone mapping y la conversión de espacio de color se inyectan dentro del fragment shader de cada material, como los dos últimos chunks del main(). Por eso material.toneMapped = false funciona: es una decisión por material que entra incluso en la clave de caché del programa.
En WebGPURenderer se aplican en un pase de render aparte. La documentación interna lo justifica así:
Returns an internal render target which is used when computing the output
tone mapping and color space conversion. Unlike in WebGLRenderer, this is
done in a separate render pass and not inline to achieve more correct results.
Y la consecuencia práctica está escrita en la documentación de Material.toneMapped: esa propiedad se ignora cuando se usa WebGPURenderer. Todos los materiales pasan por el tone mapping, sin excepciones.
Si tu escena dependía de excluir un material concreto —un elemento de interfaz superpuesto, un fondo que debía llegar intacto— ese truco deja de funcionar y hay que resolverlo de otra manera, normalmente compensando el valor para que el tone mapping lo devuelva donde querías.
Un ejemplo migrado completo
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) );
renderer.setSize( innerWidth, innerHeight );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
document.body.appendChild( renderer.domElement );
const escena = new THREE.Scene();
const camara = new THREE.PerspectiveCamera( 50, innerWidth / innerHeight, 0.1, 200 );
camara.position.set( 4, 2, 6 );
const controles = new OrbitControls( camara, renderer.domElement );
controles.enableDamping = true;
const sol = new THREE.DirectionalLight( 0xffffff, 3 );
sol.position.set( 5, 8, 3 );
sol.castShadow = true;
escena.add( sol, new THREE.HemisphereLight( 0x99bbff, 0x221100, 0.5 ) );
new GLTFLoader().load( '/modelo.glb', ( gltf ) => {
gltf.scene.traverse( ( o ) => { if ( o.isMesh ) o.castShadow = o.receiveShadow = true; } );
escena.add( gltf.scene );
} );
addEventListener( 'resize', () => {
renderer.setSize( innerWidth, innerHeight );
camara.aspect = innerWidth / innerHeight;
camara.updateProjectionMatrix();
} );
renderer.setAnimationLoop( () => {
controles.update();
renderer.render( escena, camara );
} );
Comparado con la versión de WebGLRenderer, las diferencias son las dos primeras líneas. Todo lo demás es idéntico, incluidas las sombras, el cargador y los controles.
Que puedas cambiar dos líneas y que las sombras, el cargador de glTF, el grafo de escena y los controles sigan funcionando no es un accidente afortunado: es una decisión de arquitectura que se tomó años antes y que costó bastante. La alternativa evidente —y la que tomaron otros proyectos— era escribir un motor nuevo para WebGPU, aprovechando para arreglar las cosas que la API de WebGL había forzado a hacer mal. Es tentador, y en el papel produce un resultado mejor. Lo que se pierde por el camino es el ecosistema: los miles de ejemplos, los addons, los cargadores de formatos raros, los tutoriales, las respuestas en foros, el código que la gente ya tiene escrito. Un motor nuevo empieza en cero de todo eso, y en la web, donde la mayor parte del valor de una librería está en lo que otros han construido encima, empezar en cero suele ser fatal. Three.js eligió el camino más difícil: meter WebGPU dentro de la misma librería, lo que obligó a extraer un Renderer común, a reescribir el sistema de materiales para que fuera independiente del lenguaje de shading —que es de donde salió TSL—, y a mantener los dos caminos funcionando durante varios años de transición. La factura de esa decisión se paga en complejidad interna y se ve en las costuras que quedan a la vista: dos puntos de entrada distintos, un adaptador aparte para usar nodos con el renderer clásico, una lista de obsolescencias en cada versión. Pero el resultado es que la migración de este nivel es de dos líneas, y que un proyecto de hace cinco años puede adoptar WebGPU sin reescribirse. Vale la pena tenerlo en cuenta cuando te toque decidir en tu propio código entre reescribir limpio y evolucionar sucio: la respuesta correcta casi nunca depende de cuál produce mejor código, sino de cuánto valor vive fuera de tu repositorio.