El índice y lo que ahorra de verdad
Cómo elige Three.js entre índices de 16 y 32 bits, cuánta memoria ahorra indexar con números concretos, y por qué el ahorro que importa no es el de memoria sino el de invocaciones del vertex shader.
Indexar una geometría se explica siempre igual: como los triángulos comparten vértices, el índice evita repetirlos. Es cierto y es la parte menos interesante. El ahorro de memoria es real pero modesto, y hay geometrías donde no existe. El ahorro que de verdad cambia el rendimiento es otro, ocurre dentro de la GPU, depende no de que haya índice sino del orden en que están escritos los índices, y es la razón de que los optimizadores de malla existan.
- Explicar cuándo
setIndexelige enteros de 16 bits y cuándo de 32, y por qué el umbral es 65535. - Calcular el ahorro de memoria de indexar una geometría concreta.
- Describir la caché post-transformación y estimar el ahorro de invocaciones.
- Reconocer las geometrías donde indexar no aporta nada.
setIndex y la elección del tipo
El índice es un atributo más, con itemSize uno, que Three.js guarda aparte en la propiedad index en lugar de en el mapa de atributos, porque el hardware lo consume por otro camino: va a un buffer de elementos y lo lee la unidad de ensamblado, no el vertex shader.
geometria.setIndex( [ 0, 1, 2, 2, 3, 0 ] );
Con un array normal de números, el código de r184 hace esto:
this.index = new ( arrayNeedsUint32( index ) ? Uint32BufferAttribute : Uint16BufferAttribute )( index, 1 );
Y arrayNeedsUint32 recorre el array desde el final y devuelve true en cuanto encuentra un valor mayor o igual que 65535. Fíjate en el detalle: el umbral no es 65536, que sería el máximo representable en dieciséis bits, sino uno menos. La razón es que el valor 65535 está reservado como marca de reinicio de primitiva, así que usarlo como índice normal produciría un comportamiento indefinido en cualquier ruta de dibujo que active esa característica.
Si en lugar de un array normal le pasas un array tipado, setIndex lo asigna tal cual, sin envolverlo, y luego falla al dibujar. Con arrays tipados hay que construir el atributo explícitamente:
geometria.setIndex( new THREE.Uint32BufferAttribute( indices, 1 ) );
La consecuencia práctica del umbral es que una geometría de menos de 65535 vértices usa índices de dos bytes y una de más usa cuatro, y el salto es abrupto. Una malla de 65534 vértices tiene un índice que ocupa la mitad que la de 65536. En un modelo grande partido en trozos, ese umbral es un criterio de partición perfectamente razonable.
Lo que ahorra en memoria
Vamos con números concretos. Un plano de cien segmentos por lado: diez mil celdas, veinte mil triángulos.
Sin índice, cada triángulo necesita sus tres vértices propios: 60 000 vértices. Con posición, normal y UV en flotante de treinta y dos bits son 12 más 12 más 8, es decir, 32 bytes por vértice. Total: 1 920 000 bytes, 1,83 MiB.
Con índice, los vértices únicos son los nudos de la rejilla: 101 por 101, o sea 10 201. A 32 bytes cada uno, 326 432 bytes. El índice tiene 60 000 entradas y como el máximo es 10 200, cabe en dieciséis bits: 120 000 bytes. Total: 446 432 bytes, 0,43 MiB.
El ahorro es del 77 por ciento. No está mal, pero conviene ver de dónde sale: de que en una rejilla cada vértice interior lo comparten seis triángulos. En una malla cerrada genérica la relación de Euler dice que el número de vértices es aproximadamente la mitad del de triángulos, así que sin índice tendrías seis veces más entradas de las necesarias y con índice tienes una, más los seis índices. La proporción típica de ahorro para geometría orgánica ronda el sesenta y cinco por ciento.
Comprobarlo en tu propia geometría es una línea:
import { estimateBytesUsed } from 'three/addons/utils/BufferGeometryUtils.js';
const indexada = new THREE.PlaneGeometry( 10, 10, 100, 100 );
const suelta = indexada.toNonIndexed();
console.log( estimateBytesUsed( indexada ), estimateBytesUsed( suelta ) );
console.log( indexada.attributes.position.count, suelta.attributes.position.count );
Lo que ahorra de verdad
Aquí está la parte que casi nunca se cuenta. La GPU tiene una caché post-transformación: una pequeña memoria, de unas pocas decenas de entradas, donde guarda el resultado del vertex shader para los últimos vértices procesados. Cuando la unidad de ensamblado va a montar un triángulo y encuentra un índice que ya está en esa caché, no vuelve a ejecutar el vertex shader: reutiliza el resultado.
Ese mecanismo solo funciona si hay índice. Sin él, cada entrada del buffer es un vértice distinto por definición y la caché no puede acertar nunca: el vertex shader se ejecuta exactamente tres veces por triángulo, siempre.
Con índice, la métrica que se usa es la media de invocaciones por triángulo. Sus valores de referencia:
| Situación | Invocaciones por triángulo |
|---|---|
| Sin índice | 3,0 |
| Con índice y orden aleatorio | entre 2,5 y 3,0 |
| Con índice y orden natural de generación | entre 1,0 y 1,5 |
| Con índice optimizado para caché | entre 0,6 y 0,8 |
| Mínimo teórico en malla cerrada grande | 0,5 |
Léelo dos veces, porque la conclusión es contraintuitiva: indexar sin cuidar el orden puede no ahorrar casi nada. Si los índices están desordenados, el vértice que se reutilizará dentro de cincuenta triángulos ya habrá salido de la caché cuando se necesite. Lo que produce el ahorro es la localidad: que los triángulos consecutivos en el buffer compartan vértices.
En nuestro plano de veinte mil triángulos, la diferencia entre sin índice y con índice bien ordenado es de 60 000 invocaciones a unas 14 000. Cuatro veces menos ejecuciones del vertex shader, con el mismo resultado en pantalla y la misma cantidad de triángulos.
Las geometrías integradas de Three.js generan sus índices en orden natural por filas, que ya es bastante bueno. Los modelos exportados de una herramienta de modelado, no necesariamente. El camino práctico para optimizar el orden es la herramienta meshoptimizer, integrada en la cadena de gltf-transform, que reordena índices y vértices para maximizar los aciertos de caché y de paso mejora la compresión. Es un paso de compilación, no de ejecución, y no cambia ni un píxel del resultado.
Cuándo el índice no sirve
Hay tres situaciones en las que indexar no aporta nada o directamente estorba, y conocerlas evita perseguir una optimización que no existe.
Cuando cada triángulo necesita sus propias normales. El sombreado plano exige que los tres vértices de un triángulo tengan la normal de la cara, y como una normal es parte de la identidad del vértice, dos triángulos con normales distintas no pueden compartir vértice aunque compartan posición. La geometría resultante tiene tantos vértices como esquinas de triángulo, y el índice se convierte en la secuencia trivial cero, uno, dos, tres… que solo añade peso. Es exactamente lo que hace PolyhedronGeometry y toda su familia —IcosahedronGeometry, OctahedronGeometry, TetrahedronGeometry, DodecahedronGeometry—: son no indexadas a propósito.
Cuando hay costuras de UV. El mismo argumento aplicado a las coordenadas de textura. Un desdoblado corta la superficie, y en cada corte los vértices se duplican porque la misma posición necesita dos UVs. Un modelo con muchas islas de UV tiene bastantes más vértices de los que sugiere su forma.
Cuando cada triángulo lleva datos propios. Efectos de explosión, cartas que se despliegan, geometría donde cada cara se anima por separado: si vas a poner un atributo por triángulo, tienes que soltar la geometría de todos modos.
Los dos métodos para ir de un lado a otro:
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
const suelta = indexada.toNonIndexed(); // duplica todo, quita el índice
const fusionada = mergeVertices( suelta, 1e-4 ); // vuelve a indexar por proximidad
toNonIndexed() tiene dos comportamientos que conviene saber: devuelve un BufferGeometry plano —no una instancia de la subclase original, así que un BoxGeometry deja de serlo y pierde su objeto parameters— y no copia userData ni los volúmenes envolventes. Si la geometría no tenía índice, avisa por consola y se devuelve a sí misma.
mergeVertices compara todos los atributos, no solo la posición, con la tolerancia que le pases. Eso es lo correcto: dos vértices con la misma posición pero distinta normal deben seguir separados. Y es también la razón de que a veces “no funcione”: si las normales o las UVs difieren en el último decimal por errores de coma flotante, no se fusionan. Subir la tolerancia ayuda, con el riesgo de fusionar cosas que no debías.
Casi todo el mundo entiende el índice como un mecanismo de deduplicación, y por eso lo evalúa contando bytes. Es una lectura pobre. Lo que un índice declara de verdad es la topología de la malla: qué vértices están conectados a cuáles. Un buffer sin índice describe una nube de triángulos que casualmente comparten posiciones; un buffer indexado describe una superficie. Y esa diferencia es la que habilita todo lo demás. La caché post-transformación funciona porque hay topología que explotar. El cálculo de normales suaves con computeVertexNormals solo tiene sentido con topología, porque necesita saber qué caras concurren en cada vértice. La simplificación de malla, la subdivisión, el desdoblado automático de UVs, el cálculo de curvatura, la detección de bordes de silueta, el cosido de tiras de triángulos: todos son algoritmos sobre el grafo que el índice define, y ninguno puede operar sobre una nube suelta sin reconstruirlo primero, que es caro y ambiguo. De ahí sale una regla de higiene que la gente aprende tarde y a base de disgustos: mantén la geometría indexada tanto tiempo como puedas en tu cadena de proceso, y suéltala lo más tarde posible. Si aplanas las normales al principio, has destruido la información que necesitarías después para simplificar, para calcular oclusión ambiental o para exportar. El orden correcto es indexar, procesar, optimizar el orden de los índices, y solo entonces, si el efecto lo exige, soltar. Quien invierte ese orden acaba escribiendo un mergeVertices con una tolerancia mágica para reconstruir una información que tenía exacta veinte líneas antes.
- Compara el peso de un
PlaneGeometryde cien segmentos indexado y suelto conestimateBytesUsed. - Comprueba en qué número de vértices exacto salta
setIndexde dieciséis a treinta y dos bits. - Desordena aleatoriamente el índice de una malla grande sin cambiar los triángulos y mide la diferencia de fotogramas por segundo.
- Aplica
toNonIndexeda unSphereGeometryy cuenta los vértices antes y después. - Suelta una geometría y vuelve a indexarla con
mergeVertices: comprueba si recuperas exactamente el mismo número de vértices y explica por qué no.