Sin estado: dónde vive la memoria de un shader
Un shader olvida todo al terminar. Las cuatro vías por las que entra información y el patrón de doble búfer que da memoria a un fragment shader.
Las cuatro vías de entrada
Toda la información que un shader puede leer entra por exactamente cuatro sitios, y cada uno tiene una granularidad distinta. Esa granularidad es la que decide dónde va cada dato.
Uniforms. Un valor por dibujado, idéntico para todas las invocaciones. Los escribe la CPU antes de cada drawElements. Granularidad: el objeto entero.
Attributes. Un valor por vértice, leído desde un buffer de la geometría, solo accesible en el vertex shader. Granularidad: el vértice.
Varyings. Escritas por el vertex shader, leídas interpoladas por el fragment shader. Granularidad: el fragmento, pero derivadas de los vértices.
Texturas. Memoria de solo lectura, direccionable arbitrariamente desde cualquier etapa. Granularidad: el téxel, y tú eliges qué téxel. Ésta es la única de las cuatro que permite acceso aleatorio, y por eso es donde vive todo lo demás.
Lo que no hay: variables globales persistentes. Una declaración global en GLSL crea una variable por invocación, no compartida. Se inicializa cada vez y se pierde cada vez.
// Esto NO es un contador global. Es una variable local a cada invocacion.float contador = 0.0;void main() { contador += 1.0; // siempre vale 1.0 al terminar. Siempre. ...}Dar memoria a un fragment shader
Si el estado no cabe dentro del shader, tiene que estar fuera. La forma canónica es guardarlo en una textura y hacer que el shader que lo actualiza escriba en otra textura, porque leer y escribir la misma en la misma pasada no está permitido: el orden entre invocaciones no está definido y no hay garantía de que el téxel que lees no lo haya sobrescrito ya otra invocación.
De ahí el patrón de doble búfer, o ping-pong: dos objetivos de render, uno de lectura y otro de escritura, que se intercambian cada paso.
import * as THREE from 'three';const LADO = 256;const opciones = { type: THREE.HalfFloatType, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping, depthBuffer: false, stencilBuffer: false, generateMipmaps: false,};let leer = new THREE.WebGLRenderTarget( LADO, LADO, opciones );let escribir = new THREE.WebGLRenderTarget( LADO, LADO, opciones );const materialSim = new THREE.ShaderMaterial( { uniforms: { uEstado: { value: null }, uDelta: { value: 0 }, uTiempo: { value: 0 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `, fragmentShader: ` uniform sampler2D uEstado; uniform float uDelta; varying vec2 vUv; void main() { vec4 estado = texture2D( uEstado, vUv ); vec2 posicion = estado.xy; vec2 velocidad = estado.zw; velocidad += vec2( 0.0, -0.6 ) * uDelta; // gravedad posicion += velocidad * uDelta; // rebote contra el suelo if ( posicion.y < -1.0 ) { posicion.y = -1.0; velocidad.y = abs( velocidad.y ) * 0.7; } gl_FragColor = vec4( posicion, velocidad ); } `,} );const quad = new THREE.Mesh( new THREE.PlaneGeometry( 2, 2 ), materialSim );const escenaSim = new THREE.Scene().add( quad );const camaraSim = new THREE.OrthographicCamera( -1, 1, 1, -1, 0.1, 10 );camaraSim.position.z = 1;function paso( dt ) { materialSim.uniforms.uEstado.value = leer.texture; materialSim.uniforms.uDelta.value = dt; renderer.setRenderTarget( escribir ); renderer.render( escenaSim, camaraSim ); renderer.setRenderTarget( null ); const t = leer; leer = escribir; escribir = t;}El if del rebote es coherente para la mayoría de grupos —las partículas cercanas en la textura suelen estar en estados parecidos— y su alternativa aritmética no ahorraría nada, así que aquí está bien.
Después de cada paso, leer.texture contiene el estado nuevo y puedes usarlo como uniform del material que dibuja las partículas de verdad, leyendo la posición desde el vertex shader con un atributo que diga a cada partícula qué téxel le corresponde.
Sembrar el estado inicial
El primer estado no puede venir de una pasada anterior, así que hay que crearlo. Lo más limpio es una DataTexture y una pasada de copia, o directamente escribir el estado inicial con un shader de inicialización que no lea nada.
function crearEstadoInicial( lado ) { const datos = new Float32Array( lado * lado * 4 ); for ( let i = 0; i < lado * lado; i ++ ) { datos[ i * 4 + 0 ] = ( Math.random() - 0.5 ) * 2; // posicion x datos[ i * 4 + 1 ] = Math.random() * 2; // posicion y datos[ i * 4 + 2 ] = ( Math.random() - 0.5 ) * 0.4; // velocidad x datos[ i * 4 + 3 ] = 0; // velocidad y } const textura = new THREE.DataTexture( datos, lado, lado, THREE.RGBAFormat, THREE.FloatType ); textura.needsUpdate = true; return textura;}Elegir el tipo de textura
Aquí se decide si la simulación funciona o no, y las opciones no son intercambiables.
UnsignedByteType da ocho bits por canal y valores en el rango cero a uno. Sirve para máscaras y colores, y no sirve para posiciones ni velocidades: 256 niveles discretos producen un movimiento a saltos visible desde el primer segundo.
HalfFloatType da coma flotante de dieciséis bits, con once bits efectivos de mantisa. Es el tipo por defecto sensato para simulación: rango amplio, precisión relativa suficiente para posiciones en un mundo de tamaño moderado, y la mitad de ancho de banda que el flotante completo.
FloatType da treinta y dos bits. Es lo que necesitas si acumulas mucho o si el rango dinámico es grande.
Y hay una comprobación obligatoria: dibujar a una textura de coma flotante requiere una extensión, aunque leerla no.
const puedeFloat = renderer.extensions.has( 'EXT_color_buffer_float' );const tipo = puedeFloat ? THREE.HalfFloatType : THREE.UnsignedByteType;Dos ajustes más que no son opcionales. NearestFilter en los dos filtros: estás leyendo datos, no una imagen, y una interpolación bilineal entre el estado de dos partículas distintas no significa nada. Y generateMipmaps: false, porque los mipmaps de un búfer de estado son basura que además se recalcula cada pasada.