Saltar al contenido

El modelo de cómputo de Three.js

Qué es un nodo de cómputo en TSL, cómo se despacha con compute y computeAsync, por qué solo existe sobre WebGPU, y cuándo mover trabajo de la CPU a la GPU compensa de verdad.

Un kernel es una función que se ejecuta N veces

En un fragment shader no eliges cuántas veces se ejecuta tu código: lo decide la rasterización. En un compute shader lo decides tú. Le dices a la GPU “ejecuta esta función doscientas mil veces” y cada ejecución recibe un identificador distinto, que en TSL se llama instanceIndex. Ese índice es lo único que distingue una invocación de otra, y es la base de todo el modelo: cada hilo mira su índice, decide qué trozo de memoria le toca y trabaja sobre él.

La construcción mínima tiene tres piezas: una función TSL, su invocación, y la llamada a .compute() con el número de hilos.

JS
import * as THREE from 'three/webgpu';import { Fn, instancedArray, instanceIndex, float } from 'three/tsl';const total = 200000;// Un buffer de 200k floats que vive en la GPU.const valores = instancedArray( total, 'float' );// El kernel: se ejecutará una vez por cada indice de 0 a total-1.const rellenar = Fn( () => {	valores.element( instanceIndex ).assign( float( instanceIndex ).mul( 0.001 ) );} )().compute( total );

Fíjate en el doble paréntesis de Fn( () => { ... } )(). Fn devuelve una plantilla de función; el segundo paréntesis la invoca, produciendo el nodo que representa la llamada; y .compute( total ) envuelve ese nodo en un ComputeNode con el número de invocaciones. Es la misma cadena que ves en todos los ejemplos oficiales de Three.js y no es adorno sintáctico: Fn puede recibir parámetros, y separar la definición de la invocación es lo que permite reutilizar el mismo kernel con argumentos distintos.

El objeto que devuelve .compute() es un nodo normal de Three.js. Puedes guardarlo, nombrarlo con .setName( 'Rellenar' ) para que aparezca identificado en las herramientas de depuración, y despacharlo tantas veces como quieras.

Despachar: compute frente a computeAsync

El renderer tiene dos métodos y la diferencia entre ellos es exactamente una: si el backend ya está inicializado o no.

JS
const renderer = new THREE.WebGPURenderer( { antialias: true } );renderer.setSize( window.innerWidth, window.innerHeight );document.body.appendChild( renderer.domElement );// Inicializar el backend es asincrono: pide el adaptador y el dispositivo.await renderer.init();// A partir de aqui, compute() sincrono es seguro.renderer.compute( rellenar );

renderer.compute( nodo ) graba el despacho en la cola de comandos y vuelve inmediatamente. No espera a que la GPU termine, y eso es correcto: es lo que quieres dentro del bucle de render, donde la llamada tiene que costar microsegundos. Si lo llamas antes de que el backend esté listo, Three.js emite un aviso y delega internamente en la versión asíncrona, pero es un parche, no un patrón. La forma limpia es hacer await renderer.init() una vez durante el arranque.

renderer.computeAsync( nodo ) es la versión que garantiza la inicialización: si el backend no está listo, lo inicializa y después despacha. Devuelve una promesa que resuelve cuando el despacho se ha encolado, no cuando la GPU ha terminado el trabajo. Es la herramienta correcta para los kernels de inicialización que ejecutas una sola vez en el arranque, cuando todavía no sabes si el dispositivo está disponible.

JS
// Inicializacion: una vez, sin bucle de render todavia.await renderer.computeAsync( rellenar );// Cada frame: sincrono, dentro del animation loop.function animate() {	renderer.compute( actualizar );	renderer.render( scene, camera );}renderer.setAnimationLoop( animate );

La detección es directa una vez inicializado el renderer:

JS
await renderer.init();const esWebGPU = renderer.backend.isWebGPUBackend === true;const esWebGL = renderer.backend.isWebGLBackend === true;if ( ! esWebGPU ) {	console.warn( 'Sin WebGPU: la simulacion se ejecutara en CPU con menos particulas.' );}

Antes incluso de construir el renderer puedes preguntar por la existencia de la API con navigator.gpu, pero eso solo te dice que el objeto existe, no que se haya conseguido un adaptador. La comprobación fiable es la de después de init().

Cuándo compensa bajar a la GPU

Un compute shader no es gratis. Tiene tres costes que la CPU no tiene y que deciden si el cambio merece la pena.

El primero es el coste de despacho. Cada compute() implica grabar comandos, validar el pipeline y sincronizar con el sistema gráfico. Para diez mil elementos el trabajo en CPU probablemente sea más rápido que la ceremonia de enviarlo a la GPU. La ventaja aparece cuando el número de elementos es tan grande que el paralelismo domina: cien mil, un millón, diez millones.

El segundo es el coste de volver. Leer un buffer de GPU desde JavaScript obliga a esperar a que el trabajo termine y a copiar la memoria a un buffer mapeable. Eso destruye el paralelismo entre CPU y GPU y puede costar un frame entero. La regla es dura y es la que separa un uso bueno de uno malo: si los datos tienen que volver a la CPU cada frame, no lo hagas en la GPU. El caso ideal es el que nunca cruza de vuelta —simular partículas y renderizarlas desde el mismo buffer— porque los datos se quedan donde ya están.

El tercero es el coste de divergencia. Las invocaciones se ejecutan en grupos de hilos que comparten el contador de programa. Si dentro de tu kernel unos hilos toman una rama y otros toman la contraria, el hardware ejecuta las dos ramas y descarta los resultados que no tocan. Un kernel lleno de condicionales dependientes del índice puede rendir peor que el mismo cálculo sin ramas y con máscaras aritméticas.

Con eso en la mano, la lista de candidatos buenos es corta y reconocible: sistemas de partículas, simulaciones de fluidos y telas, cálculos de vecindad sobre mallas grandes, generación procedural de geometría, reducciones sobre conjuntos masivos. Y la lista de candidatos malos también: lógica de juego, máquinas de estado, cualquier cosa cuyo resultado necesite una condición en JavaScript el mismo frame.