Doble buffer y un sistema de partículas entero en GPU
Cuándo hace falta el ping-pong y cuándo no, cómo se implementa con dos storage buffers en TSL, y cómo se dibuja el resultado sin que los datos vuelvan nunca a la CPU.
Cuándo el ping-pong es innecesario
La regla es exacta y se comprueba mirando el kernel: si cada invocación solo lee y escribe su propio elemento, no hace falta doble buffer. Ese es el caso de la integración de partículas independientes, que es también el caso más común.
// Cada hilo toca solo su indice: seguro en sitio.const integrar = Fn( () => { const p = posiciones.element( instanceIndex ); const v = velocidades.element( instanceIndex ); v.addAssign( vec3( 0, gravedad, 0 ) ); p.addAssign( v );} )().compute( total );Aquí no hay carrera posible. El hilo 5 lee posiciones[5] y escribe posiciones[5]; el hilo 6 hace lo propio con el suyo. Que se ejecuten en cualquier orden, o simultáneamente, es irrelevante: no comparten memoria. Duplicar el buffer solo serviría para gastar el doble de VRAM y añadir una copia.
El problema aparece en cuanto una invocación lee el elemento de otra:
// Cada hilo lee a sus vecinos: NO es seguro en sitio.const suavizar = Fn( () => { const izq = alturas.element( instanceIndex.sub( 1 ).max( 0 ) ); const der = alturas.element( instanceIndex.add( 1 ).min( total - 1 ) ); alturas.element( instanceIndex ).assign( izq.add( der ).mul( 0.5 ) );} )().compute( total );Este kernel está roto y produce resultados distintos en cada ejecución y en cada GPU. El hilo 5 quiere leer el valor anterior de alturas[4], pero el hilo 4 puede haberlo sobrescrito ya. No hay forma de imponer un orden entre workgroups: la especificación de WebGPU no lo garantiza, y las barreras solo sincronizan dentro de un workgroup, no entre ellos.
La lista de simulaciones que necesitan ping-pong es reconocible: difusión y suavizado, autómatas celulares, boids y cualquier comportamiento de bandada, fluidos, propagación de ondas, cualquier cosa donde el nuevo valor de un elemento dependa del valor antiguo de sus vecinos.
El intercambio con dos buffers
La solución es tener dos copias del estado y alternar cuál es la de lectura y cuál la de escritura. En TSL, como el buffer forma parte del grafo del nodo de cómputo, no puedes cambiar el buffer de un nodo ya construido: lo que se construye son dos nodos de cómputo, uno para cada dirección, y se alternan desde JavaScript.
import * as THREE from 'three/webgpu';import { Fn, instancedArray, instanceIndex, uniform, float, If } from 'three/tsl';const total = 512 * 512;const bufferA = instancedArray( total, 'float' );const bufferB = instancedArray( total, 'float' );const decaimiento = uniform( 0.995 );// Fabrica de kernels: recibe origen y destino, devuelve un nodo de computo.function crearPaso( origen, destino ) { return Fn( () => { const izq = origen.element( instanceIndex.sub( 1 ).max( 0 ) ); const cen = origen.element( instanceIndex ); const der = origen.element( instanceIndex.add( 1 ).min( total - 1 ) ); const media = izq.add( cen.mul( 2 ) ).add( der ).mul( 0.25 ); destino.element( instanceIndex ).assign( media.mul( decaimiento ) ); } )().compute( total );}const pasoAB = crearPaso( bufferA, bufferB ).setName( 'Difusion A a B' );const pasoBA = crearPaso( bufferB, bufferA ).setName( 'Difusion B a A' );El intercambio es un booleano:
let leerDeA = true;function simular() { renderer.compute( leerDeA ? pasoAB : pasoBA ); leerDeA = ! leerDeA;}Dos kernels compilados, un booleano, cero copias. El estado válido después de cada paso está siempre en el buffer de destino, que es el que hay que enlazar al material para dibujar. Como el material también forma parte del grafo, la solución simétrica es tener dos materiales —o un material cuyo nodo de color sea un mix controlado por un uniform binario— pero en la práctica casi siempre basta con un truco más simple: ejecutar siempre un número par de pasos por frame. Con dos pasos por frame, el estado final vuelve siempre al mismo buffer y el material puede apuntar a él para siempre.
Un sistema completo que nunca vuelve a la CPU
Este es el caso canónico y el que justifica toda la maquinaria. Partículas con posición y velocidad, simuladas en GPU, dibujadas desde el mismo buffer.
import * as THREE from 'three/webgpu';import { Fn, instancedArray, instanceIndex, uniform, vec3, float, hash, uv, shapeCircle, If} from 'three/tsl';const total = 300000;const posiciones = instancedArray( total, 'vec3' );const velocidades = instancedArray( total, 'vec3' );const colores = instancedArray( total, 'vec3' );const gravedad = uniform( - 0.00098 );const rebote = uniform( 0.8 );const rozamiento = uniform( 0.99 );const tamano = uniform( 0.1 );// --- Kernels ---const inicializar = Fn( () => { const p = posiciones.element( instanceIndex ); const c = colores.element( instanceIndex ); const lado = Math.floor( Math.sqrt( total ) ); const x = instanceIndex.mod( lado ); const z = instanceIndex.div( lado ); p.x = float( lado / 2 ).sub( float( x ) ).mul( 0.2 ); p.y = hash( instanceIndex ).mul( 10 ); p.z = float( lado / 2 ).sub( float( z ) ).mul( 0.2 ); c.x = hash( instanceIndex ); c.y = hash( instanceIndex.add( 2 ) ); c.z = hash( instanceIndex.add( 5 ) );} )().compute( total ).setName( 'Inicializar' );const actualizar = Fn( () => { const p = posiciones.element( instanceIndex ); const v = velocidades.element( instanceIndex ); v.addAssign( vec3( 0, gravedad, 0 ) ); p.addAssign( v ); v.mulAssign( rozamiento ); If( p.y.lessThan( 0 ), () => { p.y = float( 0 ); v.y = v.y.negate().mul( rebote ); v.x = v.x.mul( 0.9 ); v.z = v.z.mul( 0.9 ); } );} )().compute( total ).setName( 'Actualizar' );// --- Render desde el mismo buffer ---const material = new THREE.SpriteNodeMaterial();material.positionNode = posiciones.toAttribute();material.colorNode = uv().mul( colores.element( instanceIndex ) );material.scaleNode = tamano;material.opacityNode = shapeCircle();material.transparent = true;material.alphaToCoverage = true;const particulas = new THREE.Sprite( material );particulas.count = total;particulas.frustumCulled = false;scene.add( particulas );// --- Arranque ---await renderer.init();renderer.compute( inicializar );renderer.setAnimationLoop( () => { renderer.compute( actualizar ); renderer.render( scene, camera );} );Hay cuatro decisiones en ese código que no son evidentes y todas importan.
particulas.count = total es lo que convierte un único Sprite en trescientas mil instancias. La propiedad count existe en Mesh y en Sprite y solo funciona con WebGPURenderer; es la forma de Three.js de decir “dibuja este objeto N veces sin construir un InstancedMesh”. Combinada con positionNode apuntando a un storage buffer, produce el sistema de partículas más barato posible: un draw call, cero atributos subidos desde CPU.
posiciones.toAttribute() es la pieza central. Convierte el storage buffer en un atributo de vértice sin copiar memoria. Los datos que el kernel acaba de escribir son los mismos bytes que el vertex shader va a leer.
frustumCulled = false es obligatorio. El culling de Three.js usa la bounding sphere de la geometría, que aquí es la de un sprite de tamaño uno en el origen, porque las posiciones reales viven en la GPU y la CPU no las conoce. Sin desactivarlo, el sistema entero desaparece en cuanto la cámara mira a otro lado. Es el fallo más frecuente al montar esto por primera vez y es la consecuencia inevitable de que la CPU haya dejado de saber dónde están las cosas.
shapeCircle() es una función de TSL que devuelve una máscara circular a partir de las coordenadas del punto. Con alphaToCoverage activado y un material transparente, da partículas redondas sin necesidad de textura y sin los artefactos de ordenación de la transparencia clásica.
Reinicializar es simplemente volver a despachar el kernel de inicialización. No hay que recrear buffers, ni materiales, ni recompilar nada:
document.addEventListener( 'keydown', ( e ) => { if ( e.key === 'r' ) renderer.compute( inicializar );} );