wandres.dev
GEOMETRÍAS INTEGRADAS · El catálogo y sus parámetros

Cilindro, cono, toro y compañía: los sólidos de revolución

Cómo se reparten los triángulos entre torso y tapas, por qué los nombres de los segmentos del toro están al revés de lo que esperas, y el orden invertido del nudo.

⏱ 17 min

Los sólidos de revolución comparten estructura: una sección se barre alrededor de un eje y produce una rejilla cerrada. Eso los hace fáciles de razonar, y también hace que compartan las mismas trampas: dos parámetros de segmentos con nombres que no siempre significan lo que parecen, tapas que se generan aparte y cuestan más vértices de los que sugiere su geometría, y grupos que multiplican las llamadas de dibujo sin avisar.

🎯 Al terminar esta lección sabrás
  • Calcular vértices y triángulos de un cilindro contando torso y tapas por separado.
  • Explicar por qué las tapas generan más vértices que puntos tiene su borde.
  • Identificar qué controla cada parámetro de segmentos en el toro y en el nudo.
  • Elegir el número de segmentos de una silueta curva a partir de su tamaño en pantalla.

CylinderGeometry: torso, tapas y tres grupos

new THREE.CylinderGeometry( radiusTop = 1, radiusBottom = 1, height = 1,
                            radialSegments = 32, heightSegments = 1,
                            openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2 )

radialSegments son las divisiones alrededor del eje —lo que determina la suavidad de la silueta— y heightSegments las divisiones a lo largo. La geometría se genera en dos partes independientes, y hay que contarlas por separado.

El torso es una rejilla normal:

torso_vertices  = (heightSegments + 1) · (radialSegments + 1)
torso_triangulos = 2 · radialSegments · heightSegments
                   − (radiusTop === 0 ? radialSegments : 0)
                   − (radiusBottom === 0 ? radialSegments : 0)

Los descuentos son la misma degeneración de los polos que ya vimos en la esfera: si un radio es cero, esa fila se colapsa en un punto y la mitad de sus triángulos tendría área nula.

Las tapas son abanicos y esconden un detalle contraintuitivo:

tapa_vertices  = 2 · radialSegments + 1
tapa_triangulos = radialSegments

Un abanico de treinta y dos triángulos parece que debería necesitar treinta y tres vértices: uno central y treinta y dos del borde, más uno de cierre. Pero genera sesenta y cinco. La razón es que el vértice central se duplica por cada triángulo, y no por capricho: en un abanico, el centro necesita una coordenada de textura distinta para cada sector si se quiere que el mapeo sea correcto. Es otra vez la misma regla —un vértice es una fila de atributos— aplicada al centro de un disco.

Con los valores por defecto: torso de 66 vértices y 64 triángulos, dos tapas de 65 vértices y 32 triángulos cada una. Total 196 vértices y 128 triángulos. Más de la mitad de los triángulos están en las tapas, que en muchas escenas no se ven nunca.

De ahí una optimización trivial y muy rentable: si el cilindro es un poste, un tronco o cualquier cosa cuyas tapas queden ocultas, openEnded: true recorta la mitad de la geometría.

const poste = new THREE.CylinderGeometry( 0.1, 0.1, 3, 12, 1, true );
// 26 vértices, 24 triángulos. Frente a 76 y 48 con las tapas.

El cilindro declara tres grupos: el cero para el torso, el uno para la tapa superior y el dos para la inferior. Como siempre, con un solo material se ignoran y con un array de tres salen tres llamadas de dibujo.

Conos, cápsulas y tapas planas

ConeGeometry no es una clase distinta con su propio generador: es un cilindro con el radio superior a cero.

new THREE.ConeGeometry( radius = 1, height = 1, radialSegments = 32,
                        heightSegments = 1, openEnded = false,
                        thetaStart = 0, thetaLength = Math.PI * 2 )

Aplicando las fórmulas: el torso pierde radialSegments triángulos por la punta degenerada, y solo se genera una tapa. Con los valores por defecto, 2·32·1 − 32 = 32 triángulos de torso más 32 de tapa: 64 triángulos.

CapsuleGeometry sí tiene generador propio y en r184 extiende directamente BufferGeometry:

new THREE.CapsuleGeometry( radius = 1, height = 1, capSegments = 4,
                           radialSegments = 8, heightSegments = 1 )

height es la longitud de la parte cilíndrica, sin contar los casquetes, así que la altura total es height + 2·radius. Los tres parámetros de segmentos se combinan en una sola rejilla vertical:

filas       = 2 · capSegments + heightSegments
vertices    = (filas + 1) · (radialSegments + 1)
triangulos  = filas · radialSegments · 2

Con los valores por defecto: 9 filas, 90 vértices y 144 triángulos. Es de las geometrías más baratas del catálogo, y por eso las cápsulas son la primitiva de colisión favorita de los motores de física: aproximan bien un cuerpo humano con muy poco.

Los clamps son generosos: capSegments mínimo uno, radialSegments mínimo tres, heightSegments mínimo uno. Con capSegments a uno los casquetes son conos y la silueta se rompe; el valor por defecto de cuatro es el mínimo razonable.

Y las dos tapas planas del catálogo, para completar:

new THREE.CircleGeometry( radius = 1, segments = 32, thetaStart = 0, thetaLength = Math.PI * 2 )
// triangulos = segments;  vertices = segments + 2

new THREE.RingGeometry( innerRadius = 0.5, outerRadius = 1, thetaSegments = 32,
                        phiSegments = 1, thetaStart = 0, thetaLength = Math.PI * 2 )
// triangulos = thetaSegments · phiSegments · 2;  vertices = (thetaSegments+1)·(phiSegments+1)

El círculo es un abanico, con la misma duplicación del centro. El anillo es una rejilla y por eso es más barato por triángulo, además de admitir subdivisión radial con phiSegments para desplazar u ondular.

TorusGeometry: cuidado con los nombres

new THREE.TorusGeometry( radius = 1, tube = 0.4, radialSegments = 12, tubularSegments = 48,
                         arc = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI * 2 )

Los siete parámetros son los de r184; las versiones antiguas solo tenían cinco. Y aquí está la trampa de nombres que hace perder tiempo a todo el mundo:

  • radialSegments son las divisiones de la sección del tubo, es decir, cuántos lados tiene el círculo pequeño.
  • tubularSegments son las divisiones alrededor del anillo, cuántos pasos da la sección al recorrer el círculo grande.

Es exactamente al revés de lo que sugiere la intuición, donde “radial” evoca el radio grande y “tubular” el tubo. Por eso el valor por defecto de tubularSegments es cuarenta y ocho y el de radialSegments doce: el anillo es más largo y necesita más pasos.

vertices   = (radialSegments + 1) · (tubularSegments + 1)
triangulos = 2 · radialSegments · tubularSegments

Con los valores por defecto, 637 vértices y 1152 triángulos. Es de las geometrías más caras del catálogo por defecto, casi el doble que la esfera, y casi nadie se da cuenta.

Los tres parámetros angulares también son fáciles de confundir. arc recorta el anillo, produciendo un trozo de rosquilla. thetaStart y thetaLength recortan la sección del tubo, produciendo un canal o una media caña:

const canal = new THREE.TorusGeometry( 3, 0.5, 8, 64, Math.PI * 2, 0, Math.PI );
// Media caña circular: media sección de tubo barrida por todo el anillo.

Un aviso sobre serialización: el método estático fromJSON de TorusGeometry en r184 solo pasa cinco argumentos al constructor, así que thetaStart y thetaLength se pierden al deserializar. Si guardas y recuperas escenas con toJSON, un toro con sección recortada vuelve completo.

El nudo y la tabla de costes

TorusKnotGeometry traza una curva anudada y barre una sección circular a lo largo de ella. Su firma tiene el mismo par de parámetros de segmentos en el orden contrario al del toro:

new THREE.TorusKnotGeometry( radius = 1, tube = 0.4, tubularSegments = 64,
                             radialSegments = 8, p = 2, q = 3 )

Tercero tubularSegments, cuarto radialSegments. En TorusGeometry es al revés. No hay ninguna razón de diseño, es historia acumulada, y es una de esas inconsistencias que solo se aprenden equivocándose.

p y q son los números de vueltas alrededor del eje de simetría y del centro del toro. Con p y q coprimos sale un nudo de verdad; si comparten factor, sale un enlace de varios componentes. El valor por defecto, dos y tres, es el nudo trébol.

vertices   = (tubularSegments + 1) · (radialSegments + 1)
triangulos = 2 · tubularSegments · radialSegments

Por defecto, 585 vértices y 1024 triángulos.

La tabla completa del catálogo con los valores por defecto, que es la referencia que conviene tener a mano:

Geometría Triángulos Vértices Índice
PlaneGeometry() 2 4
BoxGeometry() 12 24
IcosahedronGeometry() 20 60 no
CircleGeometry() 32 34
ConeGeometry() 64 130
RingGeometry() 64 66
CylinderGeometry() 128 196
CapsuleGeometry() 144 90
SphereGeometry() 960 561
TubeGeometry() 1 024 585
TorusKnotGeometry() 1 024 585
TorusGeometry() 1 152 637

Y el criterio para elegir los segmentos de una silueta curva, que es la única decisión que de verdad importa aquí. El error de una silueta poligonal frente al círculo real es la sagita: para un círculo de radio r dividido en n lados, la desviación máxima es r · (1 − cos(π/n)). Lo que hay que garantizar es que esa desviación quede por debajo de un píxel en pantalla.

function segmentosNecesarios( radioMundo, radioPantallaEnPixeles, toleranciaPx = 0.5 ) {
  // Desviación relativa admisible: tolerancia en píxeles sobre el radio en píxeles.
  const relativa = toleranciaPx / radioPantallaEnPixeles;
  return Math.max( 3, Math.ceil( Math.PI / Math.acos( 1 - relativa ) ) );
}

// Un cilindro que ocupa 100 px de radio en pantalla:
segmentosNecesarios( 1, 100 );   // 32
segmentosNecesarios( 1, 25 );    // 16
segmentosNecesarios( 1, 8 );     // 9

Los treinta y dos segmentos por defecto de CylinderGeometry corresponden, con esta cuenta, a un objeto que ocupa unos cien píxeles de radio. Para algo que se ve a lo lejos, doce sobran; para algo que llena la pantalla, sesenta y cuatro se quedan cortos. Ese es el número que hay que ajustar, y ajustarlo bien puede dividir por tres el coste de una escena llena de tuberías sin que nadie note la diferencia.

El coste de una geometría paramétrica es cuadrático, y por eso los valores por defecto engañan

Hay un patrón en todas las fórmulas de esta lección que conviene ver de golpe: el número de triángulos es siempre el producto de dos parámetros de segmentos. Duplicar uno duplica el coste; duplicar los dos lo cuadruplica. Eso significa que subir la calidad de una superficie curva es una operación cuadrática, mientras que el beneficio visual —la reducción de la sagita— decrece como el cuadrado inverso del número de segmentos. Combina las dos cosas y sale la consecuencia práctica: cada duplicación de segmentos cuesta cuatro veces más y mejora cuatro veces menos, así que la curva de rendimiento decreciente es brutalmente pronunciada. Pasar de ocho a dieciséis segmentos en un cilindro es transformador y cuesta el doble; pasar de sesenta y cuatro a ciento veintiocho es literalmente invisible y cuesta el doble otra vez. La trampa es que los valores por defecto de Three.js están calibrados para que un objeto solo, ocupando media pantalla, se vea perfecto, que es exactamente el caso de una demo. En una escena real, con cincuenta objetos de los cuales cuarenta y cinco ocupan menos de cincuenta píxeles, esos mismos valores son entre cuatro y dieciséis veces más caros de lo necesario. Por eso la primera optimización de casi cualquier escena hecha con primitivas no es cambiar de material, ni activar instancing, ni tocar las sombras: es recorrer las geometrías y bajar los segmentos de todo lo que se vea pequeño. Es aburrido, cuesta veinte minutos, y en escenas hechas a base de primitivas suele quitar más de la mitad de los triángulos sin que nadie sea capaz de señalar qué ha cambiado.

⚔️ Ajusta los segmentos
  1. Verifica las fórmulas del cilindro para tres combinaciones, incluida una con openEnded.
  2. Comprueba que un ConeGeometry genera exactamente los triángulos que predice la fórmula del cilindro con radio superior cero.
  3. Construye una media caña con thetaStart y thetaLength en un toro y comprueba que arc hace otra cosa.
  4. Implementa segmentosNecesarios y aplícalo a todas las geometrías de una escena según su tamaño en pantalla.
  5. Mide con renderer.info.render.triangles cuánto baja el total tras ese ajuste.