Saltar al contenido

Attributes: de dónde vienen los datos por vértice

Cómo un atributo de BufferGeometry llega a una variable del vertex shader, qué nombres están reservados, y cómo añadir los tuyos sin colisionar.

El enlace por nombre

Three.js empareja atributos por nombre, sin más ceremonia. Un geometry.setAttribute( 'aSemilla', ... ) se conecta con un attribute float aSemilla; del vertex shader porque las cadenas coinciden. El motor consulta los atributos activos del programa enlazado, busca cada nombre en la geometría y configura el puntero al buffer correspondiente.

JS
const geometry = new THREE.IcosahedronGeometry( 1, 6 );const total = geometry.attributes.position.count;const semillas = new Float32Array( total );for ( let i = 0; i < total; i ++ ) semillas[ i ] = Math.random();geometry.setAttribute( 'aSemilla', new THREE.BufferAttribute( semillas, 1 ) );
GLSL
attribute float aSemilla;uniform float uProgreso;varying float vSemilla;void main() {  vSemilla = aSemilla;  float empuje = aSemilla * uProgreso;  vec3 desplazada = position + normal * empuje;  gl_Position = projectionMatrix * modelViewMatrix * vec4( desplazada, 1.0 );}

El tamaño de ítem del BufferAttribute decide el tipo en GLSL: 1 es float, 2 es vec2, 3 es vec3 y 4 es vec4. Y hay que tener cuidado con el número de componentes, porque new THREE.BufferAttribute( semillas, 1 ) con un array de longitud distinta del número de vértices no da error al crearlo: da un count incorrecto y un dibujado con basura.

Los atributos son de solo lectura y solo existen en el vertex shader. Para que un valor por vértice llegue al fragment shader hay que pasarlo por una varying, como en el ejemplo.

Los nombres reservados

El prefijo de Three.js declara un conjunto de atributos, y volver a declarar uno de ellos es un error de compilación por doble definición. Peor: usar uno de esos nombres para tus propios datos hace que el motor los interprete con su semántica habitual.

position, normal y uv están siempre declarados. uv1, uv2 y uv3 aparecen cuando el material usa mapas que los piden. tangent aparece con USE_TANGENT. color aparece con vértices coloreados, y su tipo cambia entre vec3 y vec4 según si hay alfa. skinIndex y skinWeight aparecen en mallas con esqueleto. instanceMatrix e instanceColor aparecen en InstancedMesh.

Además, morphTarget0 y compañía ya no existen como atributos: los morph targets viajan en una textura desde que Three.js dejó WebGL 1.

La convención que evita todos los choques es prefijar los tuyos con a: aSemilla, aRetardo, aIndiceDeGrupo. Cuesta nada y hace evidente en el shader qué viene de tu código y qué viene del motor.

Hay un caso de red de seguridad que conviene conocer. ShaderMaterial.defaultAttributeValues define valores constantes para tres atributos:

JS
material.defaultAttributeValues = {  color: [ 1, 1, 1 ],  uv: [ 0, 0 ],  uv1: [ 0, 0 ],};

Si la geometría no trae uno de esos tres, el renderer le asigna el valor constante en vez de fallar. Para cualquier otro atributo declarado en el shader pero ausente en la geometría no hay red: el resultado depende del driver, y en la práctica obtienes o bien ceros o bien lo que quedara en ese slot de la última malla dibujada. El síntoma clásico es un efecto que funciona hasta que cambias el orden de los objetos en la escena.

La topología decide el efecto

Éste es el detalle que convierte un mismo atributo en dos efectos completamente distintos, y casi nunca se explica.

En una geometría indexada, los vértices se comparten entre triángulos vecinos. Un atributo por vértice es, por tanto, un atributo por esquina compartida, y cualquier valor que le asignes se interpola suavemente por toda la superficie. Un desplazamiento aleatorio por vértice deforma la malla pero la mantiene cerrada.

En una geometría no indexada, cada triángulo tiene sus tres vértices propios. Un atributo por vértice puede ser el mismo para los tres vértices de una cara y distinto entre caras, con lo cual cada triángulo se puede mover como una unidad rígida. Es exactamente lo que hace falta para un efecto de explosión en el que la malla se desintegra en fragmentos.

THREE.BufferGeometry tiene el método que convierte:

JS
const geometry = new THREE.IcosahedronGeometry( 1, 4 ).toNonIndexed();const total = geometry.attributes.position.count;   // ahora es 3 * triangulos// Un valor aleatorio por CARA, replicado en sus tres verticesconst semillas = new Float32Array( total );for ( let i = 0; i < total; i += 3 ) {  const r = Math.random();  semillas[ i ] = r;  semillas[ i + 1 ] = r;  semillas[ i + 2 ] = r;}geometry.setAttribute( 'aSemilla', new THREE.BufferAttribute( semillas, 1 ) );// Y el centro de la cara, para poder rotarla sobre si mismaconst pos = geometry.attributes.position;const centros = new Float32Array( total * 3 );for ( let i = 0; i < total; i += 3 ) {  const cx = ( pos.getX( i ) + pos.getX( i + 1 ) + pos.getX( i + 2 ) ) / 3;  const cy = ( pos.getY( i ) + pos.getY( i + 1 ) + pos.getY( i + 2 ) ) / 3;  const cz = ( pos.getZ( i ) + pos.getZ( i + 1 ) + pos.getZ( i + 2 ) ) / 3;  for ( let k = 0; k < 3; k ++ ) {    centros[ ( i + k ) * 3 ] = cx;    centros[ ( i + k ) * 3 + 1 ] = cy;    centros[ ( i + k ) * 3 + 2 ] = cz;  }}geometry.setAttribute( 'aCentro', new THREE.BufferAttribute( centros, 3 ) );

Con esos dos atributos, cada triángulo puede alejarse del centro del objeto en la dirección de su propio centroide y con su propio retardo, y el resultado es una desintegración creíble sin ningún cálculo en CPU.

Actualizar sin subir todo

Un atributo se puede modificar en tiempo de ejecución. Lo importante es avisar de la intención y limitar el rango.

JS
const atributo = geometry.attributes.aSemilla;atributo.setUsage( THREE.DynamicDrawUsage );   // antes del primer dibujado// Modificar solo un tramofor ( let i = 100; i < 140; i ++ ) atributo.array[ i ] = Math.random();atributo.clearUpdateRanges();atributo.addUpdateRange( 100, 40 );    // desde el componente 100, 40 componentesatributo.needsUpdate = true;

addUpdateRange acumula rangos en updateRanges, y el renderer los usa para llamar a bufferSubData solo en esos tramos en vez de resubir el buffer entero. Sin rangos declarados, needsUpdate = true sube todo. Para un atributo de cien mil vértices modificado en cuarenta posiciones, la diferencia es de tres órdenes de magnitud.

setUsage( THREE.DynamicDrawUsage ) es una pista para el driver sobre dónde colocar el buffer en memoria. Solo tiene efecto antes del primer uso: después, hay que crear un atributo nuevo.