El catálogo, la caja y el plano: aprender a contar triángulos
Las veintiuna geometrías que exporta r184, por qué un cubo por defecto tiene veinticuatro vértices y seis grupos, y cómo calcular exactamente lo que cuesta cada elección de segmentos.
Las geometrías integradas de Three.js son generadores paramétricos: no cargan datos, los calculan. Cada parámetro de segmentos multiplica el número de triángulos según una fórmula concreta y conocida, y esa fórmula es la única forma de elegir con criterio en lugar de a ojo. Empezamos por las dos más simples —la caja y el plano— porque en ellas todo es exacto, y porque el cubo esconde el hecho más útil de toda la geometría en tiempo real.
- Enumerar las geometrías que exporta r184 y saber cuál está en el núcleo y cuál en addons.
- Derivar el número de vértices y triángulos de
BoxGeometrypara cualquier combinación de segmentos. - Explicar por qué la caja genera seis grupos y qué cuesta usarlos.
- Escribir la función que cuenta triángulos de cualquier geometría y contrastarla con
renderer.info.
El catálogo de r184
El módulo de geometrías del núcleo exporta exactamente veintiuna clases:
| Familia | Clases |
|---|---|
| Rectilíneas | BoxGeometry, PlaneGeometry |
| Revolución | SphereGeometry, CylinderGeometry, ConeGeometry, TorusGeometry, TorusKnotGeometry, CapsuleGeometry, LatheGeometry, TubeGeometry |
| Poliedros | PolyhedronGeometry, TetrahedronGeometry, OctahedronGeometry, IcosahedronGeometry, DodecahedronGeometry |
| Planas | CircleGeometry, RingGeometry, ShapeGeometry |
| Derivadas de curvas | ExtrudeGeometry |
| Auxiliares | EdgesGeometry, WireframeGeometry |
TextGeometry no está en esa lista: vive en los addons, en three/addons/geometries/TextGeometry.js, y extiende ExtrudeGeometry.
Todas guardan los argumentos con los que se construyeron en una propiedad parameters, que es lo que permite serializarlas y reconstruirlas sin volcar los vértices. Es también la forma más fiable de inspeccionar una geometría que te llega de otro sitio:
const g = new THREE.SphereGeometry( 2, 48, 24 );
console.log( g.parameters );
// { radius: 2, widthSegments: 48, heightSegments: 24, phiStart: 0, ... }
Las dos auxiliares merecen mención aparte porque no generan sólidos: EdgesGeometry( geometria, umbralEnGrados ) extrae solo las aristas cuyo ángulo diedro supera el umbral —las que perfilan la forma— y WireframeGeometry extrae todas. Las dos producen geometrías de líneas y se usan con LineSegments, no con Mesh.
BoxGeometry: por qué veinticuatro vértices
new THREE.BoxGeometry( width = 1, height = 1, depth = 1,
widthSegments = 1, heightSegments = 1, depthSegments = 1 )
Con los valores por defecto genera veinticuatro vértices y doce triángulos. Los doce triángulos son evidentes: seis caras de dos triángulos. Los veinticuatro vértices no lo son, porque un cubo tiene ocho esquinas.
La razón está en cómo se construye. El generador no crea ocho puntos y los conecta: llama seis veces a una función que construye un plano, una por cara, y cada plano genera sus propios vértices. Nunca se comparte nada entre caras. Y eso no es una implementación perezosa, es lo único posible: cada esquina del cubo pertenece a tres caras con tres normales distintas y tres coordenadas de textura distintas, y como un vértice es una fila de la tabla de atributos, tres combinaciones distintas son tres vértices. Ocho esquinas por tres caras cada una dan veinticuatro. Este es el hecho que hay que interiorizar y al que volveremos al construir geometría a mano.
Las fórmulas generales, con wS, hS y dS por segmentos de anchura, altura y profundidad:
vertices = 2·(dS+1)·(hS+1) + 2·(wS+1)·(dS+1) + 2·(wS+1)·(hS+1)
triangulos = 4·( dS·hS + wS·dS + wS·hS )
Los tres sumandos son los tres pares de caras opuestas. Con todo a uno: 2·4 + 2·4 + 2·4 = 24 vértices y 4·(1+1+1) = 12 triángulos. Con todo a diez: 2·121·3 = 726 vértices y 4·300 = 1200 triángulos.
La geometría es indexada, con lo cual el índice tiene treinta y seis entradas para los doce triángulos y no ahorra ni un vértice: cada cara de un segmento tiene cuatro vértices y seis índices, así que el índice cuesta más de lo que ahorra. Es el ejemplo de manual de geometría donde indexar no aporta nada, y aun así Three.js lo hace por uniformidad: hay código —computeTangents, mergeVertices, los algoritmos de simplificación— que exige índice.
Seis grupos y su precio
BoxGeometry declara seis grupos, uno por cara, con índices de material del cero al cinco en el orden más X, menos X, más Y, menos Y, más Z, menos Z. Si le pasas un array de seis materiales, cada cara usa el suyo.
const cubo = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), [
new THREE.MeshStandardMaterial( { color: 0xf38ba8 } ),
new THREE.MeshStandardMaterial( { color: 0xa6e3a1 } ),
new THREE.MeshStandardMaterial( { color: 0x89b4fa } ),
new THREE.MeshStandardMaterial( { color: 0xf9e2af } ),
new THREE.MeshStandardMaterial( { color: 0xcba6f7 } ),
new THREE.MeshStandardMaterial( { color: 0xfab387 } )
] );
Con un solo material, los grupos se ignoran y sale una única llamada de dibujo. Con seis, salen seis. Es un coste real y multiplicable: cien cubos con seis materiales son seiscientas llamadas de dibujo, y ahí ya estás en territorio de problema. Cuando necesites caras distintas en muchos objetos, la respuesta correcta casi siempre es un atlas de texturas y un solo material, no seis materiales.
PlaneGeometry y el suelo
new THREE.PlaneGeometry( width = 1, height = 1, widthSegments = 1, heightSegments = 1 )
Es la más simple del catálogo. Fórmulas:
vertices = (widthSegments + 1) · (heightSegments + 1)
triangulos = 2 · widthSegments · heightSegments
Un plano de cien por cien segmentos son 10 201 vértices y 20 000 triángulos.
Dos cosas hay que saber siempre. La primera es que el plano mira hacia el eje Z positivo, no hacia arriba. Para un suelo hay que girarlo, y hay dos formas que no son equivalentes:
// Opción A: girar la malla. La geometría sigue en el plano XY.
const suelo = new THREE.Mesh( new THREE.PlaneGeometry( 20, 20 ), material );
suelo.rotation.x = - Math.PI / 2;
// Opción B: girar la geometría. Los vértices se reescriben una vez.
const g = new THREE.PlaneGeometry( 20, 20 );
g.rotateX( - Math.PI / 2 );
const suelo2 = new THREE.Mesh( g, material );
La opción B deja el objeto con rotación identidad, lo que simplifica cualquier razonamiento posterior sobre transformaciones y hace que las normales ya salgan hacia arriba en espacio local. La opción A es reversible y no toca los datos. Para un suelo estático, la B; para algo que vayas a orientar dinámicamente, la A.
La segunda es que un plano solo se ve por delante, porque los materiales usan FrontSide por defecto. Un suelo que desaparece al mirarlo desde abajo no es un bug: es la respuesta correcta al culling de caras traseras.
Contar triángulos
La función vale para cualquier geometría y hay que tener en cuenta el índice:
function contarTriangulos( geometria ) {
const entradas = geometria.index
? geometria.index.count
: geometria.attributes.position.count;
return entradas / 3;
}
function contarVertices( geometria ) {
return geometria.attributes.position.count;
}
Los dos números son distintos y los dos importan. Los triángulos determinan el trabajo del rasterizador; los vértices, el del vertex shader y el peso en memoria. Una geometría no indexada con mil triángulos tiene tres mil vértices; una indexada bien construida puede tener quinientos.
Y para el total real de la escena, la fuente de verdad es el renderer, que cuenta lo que se ha dibujado de verdad después del culling:
renderer.render( scene, camera );
console.log( renderer.info.render.calls, renderer.info.render.triangles );
Ese contador se resetea automáticamente al principio de cada render() salvo que pongas renderer.info.autoReset = false, que es lo que hay que hacer cuando renderizas varias veces por frame —post-procesado, sombras manuales, varias vistas— y quieres el total. El reinicio ocurre justo después de la pasada de sombras y antes de la pasada principal, así que la cifra que lees tras render() es la de la escena y no incluye lo que dibujaron los mapas de sombra: para contar también eso hay que desactivar autoReset y llamar a renderer.info.reset() tú al cerrar el frame.
Ahora la pregunta que importa: ¿cuándo merece la pena subir los segmentos de una caja o un plano? La respuesta corta es que casi nunca por motivos de iluminación. Los materiales PBR de Three.js calculan la iluminación por fragmento, interpolando la normal entre los vértices, así que subdividir una superficie plana no cambia absolutamente nada de cómo se ilumina: la normal interpolada entre cuatro normales idénticas sigue siendo la misma.
Los segmentos hacen falta por tres motivos concretos y solo por esos tres: porque vas a desplazar los vértices, en la CPU o en el vertex shader, y necesitas resolución donde deformar; porque usas un mapa de desplazamiento, que actúa sobre vértices y no sobre fragmentos; o porque la superficie es tan grande que la interpolación de algo por vértice —un color, una coordenada— se nota. Fuera de esos casos, un plano de cien por cien segmentos son veinte mil triángulos gastados en algo que dos habrían resuelto igual de bien.
Hay una razón por la que merece la pena leer el código fuente de BoxGeometry aunque nunca vayas a escribir un cubo a mano, y es que cada geometría integrada es un ejemplo de referencia de una técnica de generación distinta, escrita por gente que llevaba años haciéndolo. PlaneGeometry enseña el patrón de rejilla indexada con dos bucles anidados, que es la base de cualquier terreno. BoxGeometry enseña a componer una geometría a partir de submallas reutilizando el mismo generador con ejes permutados. SphereGeometry enseña a manejar los casos degenerados de los polos. CylinderGeometry enseña a emitir grupos y a generar abanicos de triángulos para las tapas. PolyhedronGeometry enseña subdivisión recursiva y el problema de la costura de UVs en una esfera. ExtrudeGeometry enseña triangulación de polígonos con agujeros y transporte paralelo de marcos a lo largo de una curva. Son seis técnicas que cubren prácticamente toda la generación procedural que vas a necesitar en tu vida, están en ficheros de doscientas líneas y las tienes en node_modules. La diferencia entre quien las ha leído y quien no se nota en algo muy concreto: cuando llega el momento de generar una geometría que Three.js no trae —una escalera, un cable, una carretera, un tejado—, el que las ha leído reconoce cuál de los seis patrones aplica y lo adapta en media hora, y el que no empieza a colocar cubos.
- Escribe
contarTriangulosy compruébala contra las fórmulas de la caja para cinco combinaciones de segmentos. - Comprueba que
BoxGeometrycon segmentos por defecto tiene exactamente veinticuatro vértices y explica por qué. - Monta un cubo con seis materiales y cuenta las llamadas de dibujo antes y después de reducirlo a uno.
- Compara visualmente un plano de un segmento con uno de cien bajo un
MeshStandardMaterialiluminado y comprueba que no hay diferencia. - Añade un mapa de desplazamiento a los dos planos anteriores y explica la diferencia que ahora sí aparece.