Ensamblado de vértices y vertex shader
Qué es realmente un vértice, por qué un cubo tiene veinticuatro y no ocho, qué hace el índice, y cuál es la única obligación del vertex shader.
Un vértice no es un punto en el espacio. Es un registro de atributos, y la posición es solo uno de ellos. Esa confusión de vocabulario es responsable de una cantidad sorprendente de desconcierto: por qué un cubo con ocho esquinas necesita veinticuatro vértices, por qué una costura de textura duplica geometría, y por qué a veces el índice no ahorra nada. Todo se aclara en cuanto aceptas la definición correcta.
- Definir vértice como registro de atributos y deducir cuándo hay que duplicarlo.
- Explicar qué hace el búfer de índices y qué caché aprovecha.
- Enumerar las restricciones del vertex shader y por qué existen.
- Escribir un vertex shader funcional en Three.js usando las variables que el motor inyecta.
Un vértice es una tupla de atributos
En la GPU, un vértice es una fila de una tabla. Las columnas son los atributos: posición, normal, coordenada de textura, color, tangente, pesos de huesos, lo que declares. El ensamblado de entrada lee esa fila y la entrega al vertex shader.
De esa definición sale una consecuencia que resuelve una duda universal. Los vértices se comparten entre triángulos, pero solo se pueden compartir si la fila entera coincide. En una esfera suave, la normal de un punto es la misma para todos los triángulos que lo tocan, así que la fila coincide y se comparte. En un cubo con aristas duras, cada esquina pertenece a tres caras con normales distintas: misma posición, distinta normal, distinta fila. Hay que duplicarla tres veces. Un cubo tiene ocho esquinas geométricas y veinticuatro vértices: seis caras por cuatro vértices cada una.
Lo mismo pasa con las coordenadas de textura. Una costura en el desplegado UV es un sitio donde dos triángulos vecinos comparten posición pero necesitan coordenadas de textura distintas; hay que duplicar. Por eso el número de vértices que reporta un archivo exportado desde una herramienta de modelado casi nunca coincide con el número de puntos que ves al editar la malla.
import * as THREE from 'three';
const cubo = new THREE.BoxGeometry(1, 1, 1);
console.log('vertices:', cubo.attributes.position.count); // 24
console.log('indices:', cubo.index.count); // 36
console.log('triangulos:', cubo.index.count / 3); // 12
console.log('atributos:', Object.keys(cubo.attributes)); // position, normal, uv
const esfera = new THREE.SphereGeometry(1, 32, 16);
console.log('vertices de la esfera:', esfera.attributes.position.count);
console.log('indices de la esfera:', esfera.index.count);
Los doce triángulos del cubo necesitan treinta y seis referencias a vértices, pero solo hay veinticuatro vértices distintos. Esa diferencia es exactamente lo que aporta el índice.
Qué hace el búfer de índices y qué caché aprovecha
Sin índice, dibujar doce triángulos exige treinta y seis filas completas en memoria: posición, normal y coordenada de textura repetidas. Con índice, guardas veinticuatro filas y una lista de treinta y seis enteros que apuntan a ellas. El ahorro de memoria es evidente, pero no es el motivo principal.
El motivo principal es la caché de vértices post-transformación. La GPU guarda los resultados del vertex shader de los últimos vértices procesados en una caché pequeña, indexada por el índice del vértice. Cuando el siguiente triángulo referencia un índice que ya está en esa caché, el vertex shader no se vuelve a ejecutar: se reutiliza el resultado. En una malla bien indexada, cada vértice se transforma una sola vez aunque lo compartan seis triángulos.
Y de ahí sale un detalle que casi nadie conoce: el orden de los índices importa. La caché es pequeña, de unas pocas decenas de entradas. Si recorres la malla saltando de un extremo a otro, cuando vuelvas a un vértice ya habrá salido de la caché y se recalculará. Si la recorres con localidad espacial, los aciertos se disparan. Los optimizadores de malla que reordenan índices —los que usan las herramientas de compresión de assets— existen precisamente para esto, y no cambian ni un byte del resultado visual.
Sin índice no hay caché posible, porque no hay forma de saber que dos filas son la misma. Una malla no indexada transforma tantos vértices como referencias tenga.
Hay una tentación recurrente cuando descubres que tu modelo tiene tres veces más vértices de los que esperabas: fusionar los que comparten posición para ahorrar. Existen utilidades que lo hacen y a veces es lo correcto, pero conviene entender qué destruyes. Si fusionas dos vértices que tenían normales distintas, la normal resultante es un promedio, y una arista dura se convierte en un chaflán suave y sucio: los cubos parecen almohadas y los bordes mecanizados pierden su definición. Si fusionas a través de una costura de textura, la interpolación cruza el desplegado y aparece una banda de textura estirada que va del borde de una isla UV a la otra, casi siempre en el sitio más visible del modelo. La regla que hay que interiorizar es que la duplicación de vértices es información, no redundancia: codifica exactamente dónde la superficie deja de ser continua en alguno de sus atributos. El único caso en que fusionar es seguro es cuando las filas coinciden por completo, y ahí el exportador rara vez se equivoca. Cuando de verdad sobran vértices, el problema está en el modelo, no en el archivo.
Las tres prohibiciones del vertex shader
El vertex shader tiene una API deliberadamente pobre, y cada limitación compra rendimiento.
No puede crear ni destruir vértices. Una invocación, un vértice de salida. Sin esa garantía el planificador no sabría cuánta memoria reservar ni cuántos triángulos vienen después, y la etapa siguiente no podría empezar hasta que terminara toda la anterior.
No puede comunicarse con otras invocaciones. No hay memoria compartida, no hay sincronización, no puede leer el resultado de otro vértice. Por eso cien mil invocaciones se lanzan sin coordinación alguna. Es también por lo que no puedes recalcular una normal correctamente en el vertex shader después de deformar la malla: la normal depende de los vecinos, y el vertex shader no sabe quiénes son.
No puede escribir en memoria arbitraria. Sus salidas van a posiciones fijas que la etapa siguiente sabe leer.
Lo único obligatorio es asignar la posición en espacio de recorte. Todo lo demás son variables interpoladas que declaras porque el fragment shader las necesita.
En Three.js, el vertex shader de un ShaderMaterial recibe una serie de atributos y uniformes que el motor inyecta automáticamente: los atributos position, normal y uv de la geometría, y las matrices modelMatrix, modelViewMatrix, projectionMatrix, viewMatrix y normalMatrix, además de cameraPosition. No hay que declararlos.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 0, 4);
const material = new THREE.ShaderMaterial({
uniforms: {
uTiempo: { value: 0 },
uAmplitud: { value: 0.15 },
},
vertexShader: `
uniform float uTiempo;
uniform float uAmplitud;
varying vec3 vNormal;
void main() {
// La deformacion ocurre en espacio de modelo, antes de cualquier matriz.
float onda = sin(position.y * 6.0 + uTiempo * 2.0) * uAmplitud;
vec3 desplazada = position + normal * onda;
vNormal = normalize(normalMatrix * normal);
// Unica salida obligatoria: la posicion en espacio de recorte.
gl_Position = projectionMatrix * modelViewMatrix * vec4(desplazada, 1.0);
}
`,
fragmentShader: `
varying vec3 vNormal;
void main() {
float luz = max(dot(normalize(vNormal), normalize(vec3(0.5, 0.8, 1.0))), 0.0);
gl_FragColor = vec4(vec3(0.15) + vec3(0.54, 0.71, 0.98) * luz, 1.0);
}
`,
});
const malla = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), material);
scene.add(malla);
renderer.setAnimationLoop((tiempo) => {
material.uniforms.uTiempo.value = tiempo / 1000;
renderer.render(scene, camera);
});
Ese ejemplo deforma una esfera de 2145 vértices cada fotograma sin tocar un solo byte desde JavaScript: el atributo position en memoria no cambia nunca. Esa es la diferencia entre deformar en CPU y deformar en GPU, y es la razón por la que casi cualquier animación de forma debería vivir aquí.
Fíjate también en un detalle que la lección de la matriz normal desarrolla: vNormal no se multiplica por modelViewMatrix sino por normalMatrix. Si usaras la primera, la iluminación fallaría en cuanto escalaras el objeto de forma no uniforme. Y fíjate en el límite: la normal que se transporta es la de la esfera original, no la de la superficie ondulada, porque calcular la normal real exigiría conocer los vecinos, cosa que este shader no puede hacer.
Instancias y el identificador de vértice
Hay dos entradas más que no son atributos de la malla y que conviene conocer desde ya, aunque su uso profundo llegue más adelante.
El identificador de instancia permite dibujar el mismo conjunto de vértices muchas veces en una sola orden, dando a cada copia una transformación distinta. El vertex shader se ejecuta una vez por vértice y por instancia, y puede leer qué copia está procesando. Es el mecanismo que convierte diez mil órdenes de dibujo en una.
El identificador de vértice es un contador que el hardware proporciona sin necesidad de ningún atributo. Con él, un shader puede generar posiciones a partir de aritmética pura, sin que exista ningún dato de geometría en memoria. Es la base de varias técnicas de geometría procedural y aparece en la lección sobre las etapas que la web no expone.
Los dos comparten una idea de fondo que merece quedarse: la geometría no tiene por qué existir en memoria. Puede calcularse. Y calcular es mucho más barato que leer.
Coge la geometría de un cubo y compara attributes.position.count con el mismo cubo tras convertirlo a no indexado con cubo.toNonIndexed(). Después haz lo mismo con una esfera de 64 por 32 segmentos y calcula, para cada una, cuántas veces se ejecutaría el vertex shader con índice y sin él en el peor caso. La diferencia entre ambas geometrías te dice cuánto depende el ahorro de la topología y no del tamaño.