Aristas duras: por qué un cubo tiene 24 vértices y no 8
El momento en que el modelo mental hace clic: un vértice no es un punto del espacio, es una fila de atributos, y de ahí sale toda la duplicación de la geometría en tiempo real.
Un cubo tiene ocho esquinas. BoxGeometry genera veinticuatro vértices. No es un desperdicio, no es una implementación perezosa y no hay forma de hacerlo con menos: es la consecuencia inevitable de cómo la GPU lee la geometría, y entender por qué es el punto en el que todo lo demás del 3D en tiempo real deja de parecer arbitrario. Las costuras de UV, las aristas duras, el coste del sombreado plano y la mitad de las rarezas de los formatos de intercambio salen todas de este mismo hecho.
- Explicar por qué un vértice es una tupla de atributos y no una posición.
- Demostrar experimentalmente qué pasa con un cubo de ocho vértices.
- Elegir entre duplicar vértices y usar
flatShading, sabiendo qué cuesta cada uno. - Cuantificar el sobrecoste de las aristas duras en una malla real.
Un vértice no es un punto
La GPU lee la geometría con un solo índice que aplica a todos los atributos a la vez. Cuando la unidad de ensamblado va a procesar el vértice número diecisiete, lee la posición diecisiete, la normal diecisiete y las UV diecisiete. No hay forma de decirle “coge la posición cinco y la normal doce”.
De ahí sale la regla que hay que interiorizar y que no se puede esquivar: si dos triángulos necesitan valores distintos de cualquier atributo en el mismo punto del espacio, ese punto tiene que existir dos veces. No importa que sea la misma posición: son dos filas distintas de la tabla, y por tanto dos vértices.
Esa restricción no es de Three.js ni de WebGL: es de cómo funciona el ensamblado de vértices en todo el hardware gráfico desde hace treinta años. Y la razón de que sea así es la misma que hace que el ensamblado sea gratis: un solo índice significa un solo acceso indexado por atributo, perfectamente paralelizable y sin ninguna indirección adicional.
Por qué el cubo tiene veinticuatro
Vamos a construir el cubo de ocho vértices y ver qué pasa. El código es correcto: ocho posiciones, doce triángulos, sentido de recorrido bien.
import * as THREE from 'three';
const cubo8 = new THREE.BufferGeometry();
cubo8.setAttribute( 'position', new THREE.Float32BufferAttribute( [
- 0.5, - 0.5, 0.5, // 0
0.5, - 0.5, 0.5, // 1
0.5, 0.5, 0.5, // 2
- 0.5, 0.5, 0.5, // 3
- 0.5, - 0.5, - 0.5, // 4
0.5, - 0.5, - 0.5, // 5
0.5, 0.5, - 0.5, // 6
- 0.5, 0.5, - 0.5 // 7
], 3 ) );
cubo8.setIndex( [
0, 1, 2, 0, 2, 3, // cara +Z
1, 5, 6, 1, 6, 2, // cara +X
5, 4, 7, 5, 7, 6, // cara -Z
4, 0, 3, 4, 3, 7, // cara -X
3, 2, 6, 3, 6, 7, // cara +Y
4, 5, 1, 4, 1, 0 // cara -Y
] );
cubo8.computeVertexNormals();
scene.add( new THREE.Mesh( cubo8, new THREE.MeshStandardMaterial( { color: 0x89b4fa } ) ) );
Ponlo en pantalla con una luz y verás un cubo redondeado. Las aristas no existen visualmente: el sombreado atraviesa las esquinas con una transición suave, como si fuera una esfera achatada.
La razón es exacta y calculable. Cada esquina pertenece a tres caras, cuyas normales son tres vectores unitarios en tres ejes distintos. computeVertexNormals los promedia, y el promedio de (1,0,0), (0,1,0) y (0,0,1) normalizado es (0,577; 0,577; 0,577): la diagonal. Los ocho vértices acaban con normales que apuntan radialmente hacia fuera desde el centro, que es exactamente lo que tendría una esfera. Por eso se ve como una esfera.
Y ahora la parte que no tiene arreglo dentro de esa geometría: para que la cara superior se vea plana, sus cuatro vértices necesitan la normal (0,1,0). Para que la cara frontal se vea plana, los suyos necesitan (0,0,1). La esquina que pertenece a las dos necesita las dos normales a la vez, y eso es imposible en una tabla con un solo índice. La única salida es que sea dos vértices. Tres caras por esquina, tres normales, tres vértices por esquina. Ocho por tres son veinticuatro.
Eso es exactamente lo que hace BoxGeometry: no construye ocho puntos y los conecta, sino que llama seis veces a un generador de planos, uno por cara, y cada uno emite sus propios cuatro vértices con su propia normal constante. Nunca comparte nada entre caras.
El mismo argumento vale para las coordenadas de textura, y por eso el resultado no cambia aunque no te importe la iluminación. Si quieres que cada cara del cubo muestre la textura completa, la esquina superior frontal derecha necesita ser (1,1) en la cara frontal, (1,0) en la superior y (0,1) en la lateral. Tres valores, tres vértices. Un cubo con seis islas de UV independientes necesita veinticuatro vértices aunque todo el objeto fuera de un solo color.
Comprobarlo es inmediato:
console.log( new THREE.BoxGeometry( 1, 1, 1 ).attributes.position.count ); // 24
flatShading: la vía sin duplicar
Hay una manera de tener aristas duras sin duplicar ni un vértice, y consiste en no usar la normal almacenada en absoluto. flatShading calcula la normal en el fragment shader a partir de las derivadas de la posición en pantalla:
#ifdef FLAT_SHADED
vec3 fdx = dFdx( vViewPosition );
vec3 fdy = dFdy( vViewPosition );
vec3 normal = normalize( cross( fdx, fdy ) );
#else
vec3 normal = normalize( vNormal );
#endif
dFdx y dFdy devuelven cuánto cambia un valor entre fragmentos vecinos. Como la posición en espacio de vista varía linealmente sobre un triángulo plano, esas dos derivadas son dos vectores tangentes a la cara, y su producto vectorial es la normal exacta de la cara. Sin atributo, sin duplicación.
const material = new THREE.MeshStandardMaterial( { color: 0x89b4fa, flatShading: true } );
Con eso, el cubo de ocho vértices se ve perfectamente facetado. Y hay que decir claramente lo que cuesta y lo que se pierde.
Cuesta por fragmento, no por vértice. Las derivadas en pantalla se calculan agrupando los fragmentos de dos en dos en ambas direcciones, así que el hardware siempre sombrea bloques de cuatro aunque solo uno esté cubierto. Ese sobrecoste existe de todas formas en la práctica, pero las dos llamadas a las derivadas y el producto vectorial son trabajo adicional en el shader más caliente del pipeline.
Pierdes el sombreado suave por completo. No es un ajuste por arista: es global al material. Todo el objeto se ve facetado, incluidas las partes que querías redondeadas. Si tu modelo tiene un cilindro y un cubo en la misma malla, no puedes tener uno suave y el otro facetado.
No sirve para las UV. Las derivadas resuelven el problema de la normal y nada más. Si necesitas costuras de textura, hay que duplicar vértices igualmente.
Así que flatShading es la respuesta correcta para un estilo visual completamente facetado —geometría de bajo poligonaje, estética de cristal— y no lo es para un modelo mixto, que es lo normal.
El coste de las aristas duras
Poner números a la duplicación. La forma bruta de conseguir aristas duras en todas partes es soltar la geometría y recalcular:
const dura = geometria.toNonIndexed();
dura.computeVertexNormals(); // ahora cada vértice pertenece a un solo triángulo
Con eso, cada triángulo tiene sus tres vértices propios y sus tres normales iguales a la de la cara. El resultado se ve idéntico a flatShading y el coste se ha movido de fragmentos a vértices y memoria:
| Geometría | Suave | Dura, sin índice | Factor |
|---|---|---|---|
SphereGeometry(1, 32, 16) |
561 vértices | 2 880 | 5,1 |
CylinderGeometry() |
196 | 384 | 2,0 |
TorusGeometry() |
637 | 3 456 | 5,4 |
PlaneGeometry(1, 1, 50, 50) |
2 601 | 15 000 | 5,8 |
El factor tiende a seis en mallas regulares, que es la relación entre las esquinas de triángulo y los vértices únicos en una rejilla. En memoria, una esfera pasa de unos 23 KB a unos 92 KB.
Lo razonable casi nunca es ninguno de los dos extremos, sino duplicar solo donde hace falta. Esa es la función del ángulo de suavizado, y en Three.js vive en los addons:
import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';
const resultado = toCreasedNormals( geometria, Math.PI / 3 ); // aristas a partir de 60 grados
Divide los vértices únicamente donde el ángulo entre caras adyacentes supera el umbral y deja el resto compartido. Sobre un objeto que combina superficies curvas y aristas vivas, la duplicación resultante es de unos pocos puntos porcentuales en lugar de un factor de seis. Devuelve una geometría sin índice, que es el precio de la operación, pero puedes volver a indexarla con mergeVertices, que respetará las normales distintas y por tanto conservará las aristas.
El criterio final, resumido: si el modelo viene de una herramienta de modelado, las aristas duras ya vienen resueltas en el archivo y no hay que tocar nada; el exportador ya duplicó lo que había que duplicar. El problema aparece cuando generas geometría tú, y ahí la decisión es entre flatShading si todo va facetado, toCreasedNormals si es mixto, o normales analíticas si la superficie viene de una fórmula.
Esta lección resuelve un misterio que desconcierta a todo el que trabaja entre modelado y motor: el número de vértices que dice Blender nunca coincide con el que dice Three.js, y siempre es menor. Un cubo son ocho vértices en el modelador y veinticuatro en el motor; una malla de personaje son treinta mil en el modelador y cincuenta y tantos mil al importarla. La gente asume que el importador hace algo mal, o que el exportador infla, y ninguna de las dos cosas es cierta. Lo que ocurre es que los dos programas usan la palabra “vértice” para cosas distintas, y las dos tienen razón. Un modelador trabaja con una estructura de malla topológica —típicamente una malla de aristas aladas o una lista de caras con índices independientes por atributo— donde un vértice es un punto del espacio y cada cara puede referenciar su propia normal y su propia UV en ese punto. Es una estructura pensada para editar: mover un vértice mueve todas las caras que lo comparten, que es lo que quieres. Un motor trabaja con la estructura columnar que la GPU sabe leer, donde esa flexibilidad no existe. El exportador hace la conversión, y esa conversión es exactamente la duplicación que hemos descrito: recorre las caras, agrupa las combinaciones únicas de posición, normal y UV, y emite una fila por cada combinación distinta. El factor de expansión te dice literalmente cuántas discontinuidades de atributo tiene tu modelo, y por eso es una métrica útil de higiene: un factor cercano a uno significa una malla suave y bien desdoblada; un factor de tres o más significa muchas aristas duras y muchas islas de UV, y suele ser señal de que el desdoblado se puede simplificar. Cuando alguien te diga que un modelo “pesa” treinta mil vértices, la pregunta correcta no es cuántos son, sino en qué estructura se han contado.
- Monta el cubo de ocho vértices, aplícale
computeVertexNormalsy compruébalo con una luz: se ve redondo. - Añade
flatShading: trueal material y comprueba que el mismo cubo se ve facetado. - Construye el cubo de veinticuatro vértices a mano y verifica que se ve igual sin
flatShading. - Aplica
toNonIndexedmáscomputeVertexNormalsa una esfera y cuenta los vértices antes y después. - Usa
toCreasedNormalssobre un cilindro y comprueba cuántos vértices añade frente a soltarlo entero.