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.
Un atributo es lo único del shader que cambia de un vértice a otro sin que la CPU intervenga por cada uno. Es el canal más barato para meter variedad en una malla: un número aleatorio por vértice, un retardo, un índice de grupo, una semilla. La mecánica es sencilla y la parte que hay que dominar es otra: qué nombres ya están ocupados, qué pasa cuando un atributo falta, y cómo la topología de la geometría decide qué efecto obtienes.
- Añadir un atributo propio a una
BufferGeometryy leerlo desde el vertex shader. - Enumerar los nombres reservados por Three.js y evitar colisiones.
- Explicar la diferencia de efecto entre una geometría indexada y una que no lo está.
- Actualizar un atributo en tiempo de ejecución sin subir el buffer entero.
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.
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 ) );
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:
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:
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 vertices
const 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 misma
const 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.
const atributo = geometry.attributes.aSemilla;
atributo.setUsage( THREE.DynamicDrawUsage ); // antes del primer dibujado
// Modificar solo un tramo
for ( let i = 100; i < 140; i ++ ) atributo.array[ i ] = Math.random();
atributo.clearUpdateRanges();
atributo.addUpdateRange( 100, 40 ); // desde el componente 100, 40 componentes
atributo.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.
La especificación de WebGL 2 garantiza al menos dieciséis localizaciones de atributo, y en la práctica casi todo el hardware ofrece exactamente dieciséis. La trampa es que la unidad de la cuenta no es el atributo sino la localización, y una matriz consume una localización por columna: un mat4 se lleva cuatro, un mat3 tres. Un InstancedMesh ya gasta cuatro solo con instanceMatrix, y si además usas instanceColor son cinco. Súmale position, normal, uv, tangent y un par de conjuntos de UV adicionales y estás en once sin haber añadido nada propio. El fallo, cuando llega, es un error de enlace con un mensaje que habla de atributos activos y no menciona ninguna de tus variables, o —peor— un dibujado en el que uno de tus atributos recibe los datos de otro. Se comprueba antes de que ocurra: renderer.capabilities.maxAttributes te da el número real del dispositivo, y Object.keys( programa.getAttributes() ) te da los que el programa usa, con su locationSize. Y cuando te quedas sin sitio, la salida no es reducir sino empaquetar: cuatro valores escalares independientes caben en un solo vec4 y se recuperan con swizzling sin coste alguno, y una matriz de instancia se puede sustituir por un vec4 de posición y escala más un vec4 de cuaternión, que son dos localizaciones en lugar de cuatro y encima menos ancho de banda.