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.
Un BufferGeometry no contiene vértices: contiene arrays paralelos. La palabra “vértice” no aparece en ninguna estructura de datos de Three.js, y esa ausencia no es un descuido de diseño, es un reflejo exacto de cómo funciona el hardware. Entender qué es un atributo, cómo viaja de un Float32Array de JavaScript a un buffer de la GPU y qué ocurre cuando pides que se vuelva a subir es la base sobre la que se apoya todo lo demás de la geometría.
- Distinguir atributo, uniform y varying por su frecuencia de variación.
- Describir el camino completo de un array tipado hasta el vertex shader.
- Explicar el papel de
versiony por quéneedsUpdatesolo tiene setter. - Definir un atributo propio y consumirlo desde un
ShaderMaterial.
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.
flowchart TB js[Array tipado en el monton de JavaScript] --> attr[BufferAttribute con itemSize y count] attr --> geo[BufferGeometry y su mapa de atributos] geo --> wgl[WebGLAttributes crea el buffer de GL] wgl --> vbo[Buffer de vertices en memoria de la GPU] vbo --> vao[Vertex Array Object con los punteros de cada atributo] vao --> vs[Invocacion del vertex shader] geo --> idx[Atributo index] idx --> ebo[Buffer de elementos en memoria de la GPU] ebo --> draw[drawElements recorre el indice] draw --> vs vs --> rast[Rasterizador que interpola las varyings] rast --> fs[Invocacion del fragment shader] style js fill:#89b4fa,color:#11111b style attr fill:#89b4fa,color:#11111b style idx fill:#89b4fa,color:#11111b style geo fill:#cba6f7,color:#11111b style wgl fill:#f9e2af,color:#11111b style vbo fill:#94e2d5,color:#11111b style ebo fill:#94e2d5,color:#11111b style vao fill:#fab387,color:#11111b style draw fill:#fab387,color:#11111b style vs fill:#a6e3a1,color:#11111b style rast fill:#fab387,color:#11111b style fs fill:#a6e3a1,color:#11111b
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.
El modelo mental que hay que romper es el de “un vértice es un objeto con una posición, una normal y unas UVs”. Ese objeto no existe en ninguna parte. Lo que hay son columnas independientes, y un vértice es simplemente un índice que se usa para leer la misma posición en todas ellas. Es exactamente una base de datos columnar, y las consecuencias son las mismas que en una base de datos columnar. La primera: añadir un atributo no reorganiza nada, solo añade una columna, así que puedes tener geometrías con datos arbitrarios sin ningún coste estructural. La segunda: el acceso es secuencial y por columna, que es el patrón que mejor aprovecha el ancho de banda de la memoria de vídeo, porque cada atributo se lee de forma perfectamente contigua. La tercera, la que muerde: todas las columnas tienen que tener la misma altura, y por eso no puedes tener una geometría con ocho posiciones y veinticuatro normales aunque geométricamente sea lo que quieres describir en un cubo. La única forma de que un punto del espacio tenga dos normales distintas es que sea dos filas de la tabla, es decir, dos vértices con la misma posición. Ese es el origen de la duplicación de vértices en las aristas duras, del cubo de veinticuatro vértices y de las costuras de UV, y no es una limitación de Three.js ni de WebGL: es la consecuencia inevitable de que el hardware lea columnas paralelas con un solo índice. Una vez que ves la geometría como una tabla en lugar de como una lista de objetos, la mitad de las rarezas del 3D dejan de ser rarezas.
- Monta el triángulo del ejemplo y comprueba que leer
attr.needsUpdatedevuelveundefined. - Añade un segundo atributo propio de dos componentes y úsalo para desplazar el vértice en el shader.
- Intenta sustituir el array del atributo por otro más grande y lee el mensaje de error exacto.
- Anima el atributo de intensidad escribiendo en el array y marcando la actualización cada frame.
- Mide con
performance.now()cuánto tarda el primer render frente a los siguientes y explica la diferencia.