position, normal, uv y los atributos propios
Qué atributo espera cada material de Three.js, cómo funcionan los juegos de UV y sus canales, y cómo añadir datos por vértice que solo tu shader entienda.
Three.js no tiene una lista cerrada de atributos: tiene un puñado de nombres convenidos que sus materiales buscan por convención, y todo lo demás es territorio libre. Saber cuáles son esos nombres, qué forma tiene que tener cada uno y qué ocurre cuando falta es la diferencia entre una geometría que ilumina bien y una que se ve plana sin motivo aparente. Y saber cómo añadir los tuyos abre la puerta a todo lo que hace un shader interesante.
- Enumerar los atributos convenidos, su
itemSizey qué material los necesita. - Usar varios juegos de UV y asignar cada mapa a su canal.
- Preparar colores por vértice y tangentes correctamente.
- Añadir un atributo propio a una geometría integrada y consumirlo sin perder el PBR.
Los tres que espera todo material
position, itemSize 3. El único obligatorio. Sin él no se dibuja nada, y computeBoundingSphere avisa por consola si no existe. Está en el espacio local del objeto, y el vertex shader lo lleva al espacio de recorte multiplicándolo por la matriz de modelo y vista y luego por la de proyección.
normal, itemSize 3. La dirección perpendicular a la superficie en ese vértice, en espacio local y de longitud uno. La necesita cualquier material que calcule iluminación, es decir, todos menos MeshBasicMaterial, MeshNormalMaterial en su variante de espacio de vista, MeshDepthMaterial y los materiales de puntos y líneas. Si falta, los materiales iluminados renderizan negro o con artefactos, y el fallo es silencioso.
La normal no se transforma con la matriz de modelo, sino con la matriz normal, que es la traspuesta de la inversa de la submatriz de rotación y escala. Three.js la calcula por ti y la expone como el uniform normalMatrix. La razón de que haga falta una matriz distinta es que bajo una escala no uniforme las perpendiculares dejan de ser perpendiculares: si aplanas un objeto a la mitad en el eje vertical, las normales inclinadas tienen que rotar en sentido contrario al que sugiere la intuición.
uv, itemSize 2. Las coordenadas de textura, por convención en el intervalo de cero a uno, aunque nada impide salirse: lo que hay fuera lo gobiernan wrapS y wrapT de la textura. La necesita cualquier material con algún mapa.
Todos ellos se declaran solos en un ShaderMaterial: el prefijo que Three.js inyecta ya los trae, junto con las matrices y cameraPosition. Redeclararlos es un error de compilación.
Juegos de UV y canales
Una superficie puede necesitar más de una parametrización. El caso clásico es el de un mapa de oclusión ambiental horneado: la textura de color se repite en mosaico por todo el modelo, mientras que la oclusión necesita un desdoblado único donde cada triángulo ocupa su propio trozo del atlas. Son dos mapeos incompatibles y hacen falta dos juegos de coordenadas.
Three.js los llama uv, uv1, uv2 y uv3. Y la asignación no es implícita: cada textura declara qué canal usa.
import * as THREE from 'three';
const geometria = new THREE.BoxGeometry( 1, 1, 1 );
// Un segundo juego de UVs: aquí, una copia del primero.
geometria.setAttribute( 'uv1', geometria.attributes.uv.clone() );
const material = new THREE.MeshStandardMaterial( { map: colorMap, aoMap: oclusionMap } );
material.aoMap.channel = 1; // este mapa lee uv1 en lugar de uv
La propiedad channel vive en la textura, no en el material, así que la misma textura reutilizada en dos materiales comparte el canal. Si necesitas lo contrario, clona la textura.
Cuando cargas un glTF, el cargador ya coloca cada mapa en su canal según lo que declare el archivo, así que en el flujo normal esto es transparente. Se vuelve relevante en cuanto generas geometría o combinas fuentes distintas.
Colores, tangentes y pesos
color, itemSize 3 o 4. Requiere activar la bandera en el material:
const n = geometria.attributes.position.count;
const colores = new Float32Array( n * 3 );
const c = new THREE.Color();
for ( let i = 0; i < n; i ++ ) {
c.setHSL( i / n, 0.7, 0.55 ); // Color trabaja ya en el espacio lineal
c.toArray( colores, i * 3 );
}
geometria.setAttribute( 'color', new THREE.BufferAttribute( colores, 3 ) );
material.vertexColors = true; // sin esto, el atributo se ignora
El detalle de espacio de color importa. Con la gestión de color activada, que es lo predeterminado, un Color construido desde un hexadecimal o una cadena CSS convierte de sRGB al espacio de trabajo lineal, y toArray escribe ya los valores lineales. Si en su lugar rellenas el array a mano con los valores que te da un selector de color de la interfaz, estarás escribiendo valores sRGB donde el shader espera lineales, y el resultado sale lavado. Usa siempre Color como intermediario.
Con itemSize 4 tienes alfa por vértice, y el material lo respeta si es transparente. En r184 la varying de color en los shaders es un vec4, así que los dos casos comparten camino.
tangent, itemSize 4. Necesaria para el mapeo de normales de calidad, y la mayoría de los modelos la traen del archivo. Si no, Three.js la calcula:
geometria.computeTangents(); // exige index, position, normal y uv
Ese método falla con un aviso si a la geometría le falta alguno de los cuatro, incluido el índice. Su algoritmo es el clásico y a veces produce costuras visibles; la alternativa de mayor calidad es computeMikkTSpaceTangents del módulo three/addons/utils/BufferGeometryUtils.js, que implementa el estándar de la industria y es el mismo que usa Blender, pero exige cargar un módulo WebAssembly aparte. La cuarta componente de la tangente es el signo de la bitangente, y es lo que evita que el mapeo se invierta en las caras espejadas.
skinIndex y skinWeight, itemSize 4 ambos. Los índices de los cuatro huesos que influyen en cada vértice y sus pesos, que deben sumar uno. Solo tienen sentido en un SkinnedMesh.
Y una aclaración que ahorra búsquedas: instanceMatrix e instanceColor no son atributos de la geometría. Viven en el InstancedMesh y el renderer los inyecta aparte, con un divisor de instancia distinto de cero. Los atributos propios por instancia sí van en la geometría, pero como InstancedBufferAttribute.
Atributos propios y sus límites
El nombre de un atributo propio es libre; la convención de prefijar con una a minúscula viene de la comunidad y sirve para distinguir de un vistazo lo tuyo de lo que aporta el motor. Lo único que Three.js hace con él es buscar una entrada con ese nombre en el programa de shaders compilado y, si la encuentra, configurar el puntero.
Lo interesante es que no hace falta renunciar al PBR para usarlos. onBeforeCompile deja inyectar la declaración y el uso dentro de un MeshStandardMaterial completo:
import * as THREE from 'three';
const geometria = new THREE.IcosahedronGeometry( 1, 5 );
const n = geometria.attributes.position.count;
const semilla = new Float32Array( n );
for ( let i = 0; i < n; i ++ ) semilla[ i ] = Math.random();
geometria.setAttribute( 'aSemilla', new THREE.BufferAttribute( semilla, 1 ) );
const material = new THREE.MeshStandardMaterial( { color: 0x88aaff, roughness: 0.35 } );
material.onBeforeCompile = ( shader ) => {
shader.uniforms.uTiempo = { value: 0 };
shader.vertexShader = shader.vertexShader
.replace( '#include <common>', `
#include <common>
attribute float aSemilla;
uniform float uTiempo;
` )
.replace( '#include <begin_vertex>', `
#include <begin_vertex>
transformed += normal * sin( uTiempo + aSemilla * 6.2831853 ) * 0.04;
` );
material.userData.shader = shader; // para poder tocar el uniform después
};
const malla = new THREE.Mesh( geometria, material );
scene.add( malla );
const reloj = new THREE.Timer();
reloj.connect( document );
renderer.setAnimationLoop( ( tiempo ) => {
reloj.update( tiempo );
const s = material.userData.shader;
if ( s ) s.uniforms.uTiempo.value = reloj.getElapsed();
renderer.render( scene, camera );
} );
El truco de guardar el objeto de shader en userData es necesario porque onBeforeCompile se llama una sola vez, cuando el programa se compila, y los uniforms que añadas ahí solo son accesibles a través de ese objeto.
Hay dos límites que conviene tener presentes.
El primero es el número máximo de atributos. WebGL2 garantiza al menos dieciséis ranuras, y Three.js te dice cuántas tienes:
console.log( renderer.capabilities.maxAttributes );
Un MeshStandardMaterial con mapa de normales, colores por vértice y esqueleto ya consume seis o siete, y una matriz de cuatro por cuatro como atributo se come cuatro ranuras ella sola. Con instancing y varios atributos propios se puede llegar al techo, y el síntoma es un fallo de enlazado del programa con un mensaje poco útil.
El segundo es que un atributo declarado y no usado desaparece. El compilador de GLSL elimina cualquier entrada que no afecte a la salida, y entonces su localización pasa a valer menos uno y Three.js simplemente no configura el puntero. Eso es correcto y deseable, pero produce una confusión frecuente al depurar: comentas la línea que usa el atributo para probar algo, y de golpe el atributo “deja de existir” en las herramientas de inspección. No es un error, es el optimizador.
La intuición inicial dice que meter datos en un atributo es caro porque ocupa memoria por vértice, y que un uniform es gratis porque es un solo valor. Con volúmenes pequeños es cierto. A partir de cierto tamaño, la relación se invierte de forma drástica, y la razón está en cómo la GPU accede a cada cosa. Un atributo se lee de forma perfectamente secuencial: la unidad de ensamblado de vértices busca el bloque contiguo que le toca a la invocación, y esa lectura está tan optimizada en hardware que a efectos prácticos es gratuita, porque se solapa con el cómputo de las invocaciones anteriores. Un uniform, en cambio, se lee de un bloque de memoria constante compartida por todas las invocaciones, y aunque el acceso es rapidísimo, el coste real está en subirlo: cada cambio de uniform es una llamada al driver desde la CPU, y ese es el recurso escaso. De ahí sale la regla que separa a quien ha optimizado una escena de quien no: cuando dudes entre precalcular un valor por vértice y meterlo en un atributo, o calcularlo cada frame en la CPU y pasarlo como uniform, el atributo casi siempre gana, aunque multiplique la memoria de la geometría. Un atributo de un flotante en una malla de cien mil vértices son cuatrocientos kilobytes, que a la GPU no le duelen; recalcular esos cien mil valores en JavaScript cada frame son varios milisegundos del único hilo que tienes. Ese razonamiento —mover trabajo de la CPU a la memoria de la GPU— es el mismo que justifica el instancing, las texturas de datos y los morph targets basados en textura, y es probablemente el patrón de optimización más rentable de todo el 3D en tiempo real.
- Quita el atributo
normalde una geometría condeleteAttributey observa cómo se ve con un material iluminado. - Duplica el juego de UVs, cambia el canal de un mapa y verifica que el resultado cambia.
- Rellena colores por vértice a mano con valores sRGB y compáralos con los que produce
Color. - Añade el atributo de semilla del ejemplo a una geometría integrada sin perder el PBR.
- Consulta
renderer.capabilities.maxAttributesy calcula cuántos atributos propios te quedan libres con tu material.