Qué es un atributo y cómo llega a la GPU
La diferencia entre atributo, uniform y varying, el camino exacto que recorre un array de JavaScript hasta la memoria de vídeo, y qué pasa cuando marcas un atributo como actualizable.
Atributo, uniform, varying
Las tres palabras describen datos que entran en un shader, y se distinguen por una sola cosa: con qué frecuencia cambian.
Un uniform es constante durante toda una llamada de dibujo. La matriz de proyección, la hora, un color base, una textura: todos los vértices y todos los fragmentos de esa llamada ven el mismo valor. Se sube antes de dibujar y no se toca más.
Un atributo cambia por vértice. Es un array con tantas entradas como vértices tenga la geometría, y cada invocación del vertex shader recibe la entrada que le corresponde. La posición es un atributo, la normal es un atributo, las coordenadas de textura son un atributo. El vertex shader no puede leer el atributo de un vértice vecino: solo ve el suyo, y esa restricción es lo que permite ejecutar millones de invocaciones en paralelo sin ninguna sincronización.
Un varying no es una entrada, es una salida del vertex shader que el rasterizador interpola por cada fragmento antes de entregársela al fragment shader. Es el único canal por el que la información viaja de una etapa a la siguiente, y explica por qué el color de la cara de un triángulo cambia suavemente entre sus tres esquinas aunque solo hayas definido tres valores.
De ahí sale una regla que evita muchos errores: si un dato varía por vértice, tiene que ser un atributo; si varía por objeto, un uniform; si lo calculas en el vertex shader y lo necesitas en el fragment, un varying. Meter en un uniform algo que varía por vértice obliga a partir la geometría en tantas llamadas de dibujo como valores distintos, que es exactamente el problema que resuelve el instancing.
De un array de JavaScript a la memoria de vídeo
El camino tiene más escalones de los que sugiere la API, y conocerlos explica los costes.
Al principio del camino, tu array vive en el montón de JavaScript, gestionado por el recolector de basura. Un BufferAttribute no lo copia: guarda una referencia y añade metadatos —cuántos componentes tiene cada elemento, cuántos elementos hay, si está normalizado—. Hasta aquí no ha ocurrido nada gráfico: puedes crear geometrías y nunca renderizarlas, y no se habrá tocado la GPU.
El paso a la GPU ocurre en el primer render, no antes. El módulo interno WebGLAttributes mantiene un WeakMap de atributos a buffers de GL; cuando el renderer va a dibujar una geometría, pide el buffer de cada atributo, y si no existe lo crea con createBuffer y sube el contenido con bufferData. A partir de ese momento hay dos copias de tus datos: la de JavaScript y la de la memoria de vídeo.
El Vertex Array Object es la pieza que ata todo. Guarda, por cada atributo del programa de shaders, qué buffer usar, cuántos componentes leer, de qué tipo, con qué salto entre elementos y desde qué desplazamiento. Three.js cachea uno por combinación de geometría y programa, y por eso cambiar de material entre objetos con la misma geometría no es gratis: puede forzar un VAO distinto.
El ciclo de vida de un atributo
Una vez subido, el buffer de la GPU es independiente del array de JavaScript. Si escribes en el array, el buffer no se entera. La forma de decírselo es esta:
geometria.attributes.position.needsUpdate = true;Y aquí hay un detalle que sorprende a todo el mundo la primera vez: needsUpdate solo tiene setter. En BufferAttribute el código es literalmente este:
set needsUpdate( value ) { if ( value === true ) this.version ++;}No hay getter. Leer attr.needsUpdate devuelve undefined, siempre, aunque acabes de ponerlo a true. Lo que existe de verdad es version, un contador que se incrementa, y WebGLAttributes guarda por su lado la versión que subió la última vez. Cuando difieren, resube. Si escribes código que comprueba if ( attr.needsUpdate ) para decidir algo, ese código nunca entra, y la depuración es especialmente ingrata porque no falla nada visible.
La resubida tiene dos modos. Por defecto sube el array entero con bufferSubData desde el desplazamiento cero. Si has declarado rangos con addUpdateRange( start, count ), sube solo esos, y Three.js los ordena y fusiona antes —mutando tu array de rangos— y los limpia él mismo al terminar. Esa limpieza es importante: no tienes que llamar a clearUpdateRanges() tú en el caso normal, pero sí tienes que asegurarte de que cada frame declara sus propios rangos, porque los del frame anterior ya no están.
Hay una restricción dura que conviene conocer antes de diseñar nada: no se puede redimensionar un atributo. Si cambias el tamaño del array subyacente y marcas la actualización, WebGLAttributes lanza un error explícito diciendo que redimensionar no está soportado. La razón es que el buffer de GL se asignó con un tamaño concreto y el VAO tiene punteros calculados sobre él. La consecuencia de diseño es que las geometrías dinámicas se asignan con el tamaño máximo desde el principio y se dibuja solo una parte, que es exactamente para lo que existe setDrawRange.
Un triángulo con un atributo propio
El ejemplo mínimo completo. Tres vértices, la posición obligatoria, y un escalar propio por vértice que el fragment shader convierte en un degradado.
import * as THREE from 'three';const renderer = new THREE.WebGLRenderer( { antialias: true } );renderer.setSize( innerWidth, innerHeight );document.body.appendChild( renderer.domElement );const scene = new THREE.Scene();const camera = new THREE.PerspectiveCamera( 50, innerWidth / innerHeight, 0.1, 10 );camera.position.z = 2;const geometria = new THREE.BufferGeometry();// Tres vértices, tres componentes cada uno: x, y, z.const posiciones = new Float32Array( [ 0.0, 0.6, 0.0, -0.6, -0.4, 0.0, 0.6, -0.4, 0.0] );geometria.setAttribute( 'position', new THREE.BufferAttribute( posiciones, 3 ) );// Un atributo propio: un escalar por vértice, itemSize 1.const intensidad = new Float32Array( [ 1.0, 0.15, 0.5 ] );geometria.setAttribute( 'aIntensidad', new THREE.BufferAttribute( intensidad, 1 ) );const material = new THREE.ShaderMaterial( { vertexShader: ` attribute float aIntensidad; varying float vIntensidad; void main() { vIntensidad = aIntensidad; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `, fragmentShader: ` varying float vIntensidad; void main() { gl_FragColor = vec4( vec3( vIntensidad ), 1.0 ); } `} );scene.add( new THREE.Mesh( geometria, material ) );renderer.setAnimationLoop( () => renderer.render( scene, camera ) );Fíjate en que position, projectionMatrix y modelViewMatrix no se declaran: ShaderMaterial inyecta esas declaraciones en el prefijo del shader, junto con normal, uv, modelMatrix, viewMatrix, normalMatrix y cameraPosition. Los atributos propios sí hay que declararlos, y por convención se les pone un prefijo para distinguirlos a simple vista de los que aporta el motor.
El resultado es un triángulo con un vértice blanco, uno casi negro y uno gris medio, con el degradado entre ellos calculado por el rasterizador. Ese degradado es la interpolación de la varying, y es la mejor demostración visual de qué hace realmente un varying: tú definiste tres números y la pantalla muestra decenas de miles de valores intermedios que nadie calculó explícitamente.