Saltar al contenido

Storage buffers: instancedArray, attributeArray y storage

Cómo se declara memoria de GPU escribible desde TSL, qué diferencia hay entre las tres funciones que la crean, cómo se accede a un elemento y qué modificadores existen para el acceso.

Tres funciones, un mismo nodo

Las tres devuelven un StorageBufferNode. Lo que cambia es el tipo de atributo que crean por debajo, y ese detalle decide si los datos podrán usarse después como atributo de instancia al renderizar.

JS
import { instancedArray, attributeArray, storage } from 'three/tsl';import * as THREE from 'three/webgpu';const total = 100000;// 1. Datos por instancia: lo que usaras para dibujar N copias de una malla.const posiciones = instancedArray( total, 'vec3' );// 2. Datos generales: una tabla de trabajo que no se mapea a instancias.const acumulador = attributeArray( total, 'float' );// 3. Control total: tu construyes el atributo y decides su contenido inicial.const datos = new Float32Array( total * 4 );for ( let i = 0; i < total; i ++ ) datos[ i * 4 + 3 ] = 1;const atributo = new THREE.StorageBufferAttribute( datos, 4 );const estado = storage( atributo, 'vec4', total );

instancedArray( count, tipo ) construye un StorageInstancedBufferAttribute. Es el que necesitas cuando los datos van a alimentar el renderizado por instancias: un elemento por instancia dibujada. attributeArray( count, tipo ) construye un StorageBufferAttribute normal, sin la marca de instancia. Y storage( atributo, tipo, count ) es la función de bajo nivel que las dos anteriores usan internamente; la llamas directamente cuando quieres controlar el contenido inicial del buffer o su disposición exacta en memoria.

La diferencia entre las dos primeras importa en un punto muy concreto: al convertir el buffer en atributo para el material. posiciones.toAttribute() produce un atributo que la GPU lee una vez por instancia; si el buffer no está marcado como instanciado, se leería una vez por vértice, que no es lo que quieres. Cuando dudes, la pregunta es “¿cada elemento de este buffer corresponde a un objeto dibujado?”. Si la respuesta es sí, instancedArray.

El argumento de tipo acepta las cadenas habituales de TSL: 'float', 'int', 'uint', 'vec2', 'vec3', 'vec4', y sus variantes enteras. Three.js deduce de ahí el itemSize y el TypedArray correcto. Si lo omites, el valor por defecto es 'float'.

Acceder a un elemento

.element( indice ) devuelve un nodo que representa la posición del buffer en ese índice. Es un lvalue: puedes leerlo y puedes asignarle.

JS
import { Fn, instanceIndex, vec3, float } from 'three/tsl';const inicializar = Fn( () => {	const p = posiciones.element( instanceIndex );	// Asignacion por componente: la forma mas legible.	p.x = float( instanceIndex ).mod( 100 ).sub( 50 );	p.y = float( 0 );	p.z = float( instanceIndex ).div( 100 ).sub( 50 );} )().compute( total );

Hay tres formas de escribir en un elemento y las tres aparecen en el código real de Three.js, así que conviene reconocerlas:

JS
const v = velocidades.element( instanceIndex );v.assign( vec3( 0, - 0.001, 0 ) );      // sustituye el valor enterov.addAssign( vec3( 0, - 0.001, 0 ) );   // suma en sitiov.y = v.y.negate().mul( 0.8 );          // asignacion por swizzle

assign reemplaza. Los métodos con sufijo Assign —addAssign, subAssign, mulAssign, divAssign— son azúcar sobre la operación seguida de la asignación, y Three.js los genera automáticamente para toda operación encadenable. La asignación directa a un swizzle funciona porque el prototipo de nodo define setters para x, y, z, w y todas sus combinaciones.

El índice no tiene por qué ser instanceIndex. Puedes leer el elemento de otro hilo, y ahí es donde empieza lo interesante y lo peligroso:

JS
// Leer al vecino: valido, pero cuidado con las carreras.const anterior = posiciones.element( instanceIndex.sub( 1 ).max( 0 ) );

Si en el mismo kernel un hilo escribe en su elemento y otro lee ese mismo elemento, el resultado depende del orden de ejecución, que no está definido. Ese es exactamente el problema que resuelve el patrón de doble buffer, y es el asunto de la lección sobre simulación con doble buffer.

Estructuras en lugar de buffers paralelos

Con vectores sueltos acabas con cinco buffers para un mismo conjunto de partículas: posición, velocidad, color, tamaño, vida. Funciona, y de hecho es lo que hacen varios ejemplos oficiales, porque cada buffer se lee de forma perfectamente coalescente. Pero cuando el número de campos crece, TSL permite declarar una estructura y usarla como tipo del buffer.

JS
import { struct, Fn, instanceIndex, instancedArray, vec3, float } from 'three/tsl';const Particula = struct( {	posicion: 'vec3',	velocidad: 'vec3',	vida: 'float'}, 'Particula' );const particulas = instancedArray( total, Particula );const actualizar = Fn( () => {	const p = particulas.element( instanceIndex );	p.get( 'velocidad' ).addAssign( vec3( 0, - 0.0098, 0 ) );	p.get( 'posicion' ).addAssign( p.get( 'velocidad' ) );	p.get( 'vida' ).subAssign( float( 0.016 ) );} )().compute( total );

struct viene de three/tsl y instancedArray detecta que el tipo es una estructura, calcula la longitud total del layout y dimensiona el buffer en consecuencia. El acceso a un miembro se hace con .get( nombre ).

La elección entre estructuras y buffers paralelos no es de estilo. Es la vieja disyuntiva entre array de estructuras y estructura de arrays. Con buffers paralelos, un kernel que solo toca posiciones lee memoria contigua y aprovecha cada línea de caché al completo. Con una estructura, ese mismo kernel arrastra velocidad y vida en cada lectura aunque no las use. La estructura gana cuando casi todos los kernels tocan casi todos los campos; los buffers paralelos ganan cuando hay kernels especializados. Para un sistema de partículas sencillo, con dos o tres campos que se usan siempre juntos, la diferencia es ruido.

Los modificadores de acceso

Un StorageBufferNode tiene por defecto acceso de lectura y escritura. Tres métodos cambian ese contrato y los tres tienen consecuencias reales.

JS
// Solo lectura: permite al compilador optimizar y evita escrituras accidentales.const tabla = attributeArray( 256, 'vec4' ).toReadOnly();// Atomico: habilita operaciones atomicas sobre enteros.const contador = attributeArray( 1, 'uint' ).toAtomic();// PBO: solo relevante en el backend de WebGL.const salida = instancedArray( total, 'vec4' ).setPBO( true );

toReadOnly() marca el buffer como read en el shader generado. No es cosmético: un buffer de solo lectura puede colocarse en un espacio de dirección más restrictivo y el compilador puede asumir que nadie lo modifica durante el despacho, lo que a veces habilita optimizaciones de caché. Úsalo siempre que un kernel consuma una tabla que no modifica.

toAtomic() convierte el tipo del buffer en atómico, que es el requisito para usar operaciones como atomicAdd. Sin esa marca, una suma concurrente desde miles de hilos sobre el mismo elemento pierde incrementos: el clásico leer-modificar-escribir sin protección. Con ella, el hardware garantiza que cada incremento se aplica. El precio es la serialización: si todos los hilos atacan el mismo contador, el paralelismo desaparece en ese punto.

setPBO( true ) marca el nodo para usar pixel buffer objects y solo tiene efecto sobre el backend de WebGL, donde no hay storage buffers reales y Three.js tiene que emular el acceso a través de texturas. Si trabajas exclusivamente sobre WebGPU, ignóralo.