Extrusión, torno y tubo: geometría a partir de curvas
Cómo se dibuja una forma en 2D con Shape y Path, todas las opciones de ExtrudeGeometry con sus valores reales, y cómo generan LatheGeometry y TubeGeometry su superficie.
Las cuatro geometrías de esta lección comparten un principio: en lugar de barrer una sección alrededor de un eje fijo, parten de una curva que tú defines. Eso las convierte en las únicas del catálogo que producen formas arbitrarias, y también en las más caras y las más fáciles de disparar por error: un chaflán con los valores por defecto puede multiplicar por cinco el número de triángulos de una letra sin que nadie se dé cuenta hasta que hay doscientas letras en pantalla.
- Dibujar formas cerradas con agujeros usando
ShapeyPath. - Enumerar las opciones de
ExtrudeGeometrycon sus valores por defecto reales y sus interacciones. - Generar un sólido de revolución arbitrario con
LatheGeometry. - Recorrer una curva con
TubeGeometryy usar sus marcos de Frenet.
Shape y Path: dibujar en dos dimensiones
Path es un contorno abierto y Shape lo extiende añadiendo una lista de agujeros. Su API imita a la del lienzo 2D del navegador, lo cual no es casualidad: es la misma abstracción de contornos.
import * as THREE from 'three';
function rectanguloRedondeado( ancho, alto, radio ) {
const s = new THREE.Shape();
const x = - ancho / 2;
const y = - alto / 2;
s.moveTo( x + radio, y );
s.lineTo( x + ancho - radio, y );
s.quadraticCurveTo( x + ancho, y, x + ancho, y + radio );
s.lineTo( x + ancho, y + alto - radio );
s.quadraticCurveTo( x + ancho, y + alto, x + ancho - radio, y + alto );
s.lineTo( x + radio, y + alto );
s.quadraticCurveTo( x, y + alto, x, y + alto - radio );
s.lineTo( x, y + radio );
s.quadraticCurveTo( x, y, x + radio, y );
return s;
}
const forma = rectanguloRedondeado( 3, 2, 0.4 );
// Un agujero circular en el centro, recorrido en sentido contrario.
forma.holes.push( new THREE.Path().absarc( 0, 0, 0.4, 0, Math.PI * 2, true ) );
Los agujeros van en el array holes y deben recorrerse en sentido contrario al contorno exterior. Es la convención de todos los sistemas de relleno de polígonos y también lo que el triangulador de Three.js espera. Un agujero con el sentido equivocado no se resta: se dibuja encima.
Una forma no es geometría todavía: es un conjunto de curvas continuas. La conversión a segmentos la hace curveSegments, y ese parámetro se pasa a la geometría, no a la forma. Con doce, un arco completo se divide en doce tramos, lo cual para un círculo grande es visiblemente poligonal.
ShapeGeometry y ExtrudeGeometry
ShapeGeometry triangula la forma y produce una superficie plana en el plano XY:
new THREE.ShapeGeometry( shapes, curveSegments = 12 )
Es indexada y solo tiene position, normal —todas apuntando a más Z— y uv. Sirve para siluetas, iconos, máscaras y suelos con forma. Su coste depende de la complejidad del contorno, no de ningún parámetro de rejilla: el triangulador emite el mínimo de triángulos necesario para cubrir el polígono.
ExtrudeGeometry toma la misma forma y le da profundidad:
new THREE.ExtrudeGeometry( shapes, options = {} )
Estas son todas las opciones con sus valores por defecto reales:
| Opción | Por defecto | Qué hace |
|---|---|---|
curveSegments |
12 |
Subdivisión de las curvas del contorno |
steps |
1 |
Divisiones a lo largo de la extrusión |
depth |
1 |
Profundidad de la extrusión |
bevelEnabled |
true |
Activa el chaflán |
bevelThickness |
0.2 |
Cuánto sobresale el chaflán en profundidad |
bevelSize |
bevelThickness - 0.1 |
Cuánto se retrae en el plano de la forma |
bevelOffset |
0 |
Desplazamiento del inicio del chaflán |
bevelSegments |
3 |
Divisiones del chaflán |
extrudePath |
sin valor | Curva a lo largo de la cual extruir |
UVGenerator |
interno | Objeto que genera las coordenadas de textura |
Tres interacciones hay que conocer. bevelEnabled está activado por defecto, con un grosor de 0,2 unidades: si tu forma mide dos unidades, eso es un chaflán del diez por ciento del tamaño, enorme. Casi todo el mundo que extruye por primera vez obtiene una forma hinchada y no sabe por qué. bevelSize se deriva de bevelThickness, así que cambiar uno cambia el otro salvo que fijes ambos. Y extrudePath fuerza bevelEnabled a falso, porque el chaflán no está soportado en extrusión a lo largo de una curva.
Cuando el chaflán está desactivado, el generador pone a cero los cuatro parámetros relacionados, así que no hay riesgo de residuos.
const geometria = new THREE.ExtrudeGeometry( forma, {
depth: 0.4,
curveSegments: 16,
steps: 1,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.05,
bevelOffset: 0,
bevelSegments: 3
} );
geometria.center(); // ExtrudeGeometry no centra nada por su cuenta
ExtrudeGeometry es no indexada y llama a computeVertexNormals() al terminar, lo que da normales suaves en todas partes, incluidas las aristas donde no las quieres. Emite dos grupos: el cero para las tapas y el uno para los laterales, lo cual permite darles materiales distintos, que es lo que se hace con letras metálicas o botones.
El coste del chaflán merece un cálculo. Con bevelSegments a tres, el generador añade tres anillos de vértices por cada extremo, es decir, seis anillos adicionales alrededor de todo el contorno. Para una forma de doscientos puntos de contorno, eso son mil doscientos vértices más y unos dos mil cuatrocientos triángulos, solo por el chaflán. Con bevelSegments a uno el chaflán sigue existiendo visualmente y cuesta un tercio.
steps solo importa si extruyes a lo largo de una curva o si vas a deformar el resultado. Para una extrusión recta, steps a uno es correcto y subirlo solo añade anillos intermedios inútiles.
LatheGeometry: el torno
Toma un perfil en dos dimensiones y lo hace girar alrededor del eje vertical:
new THREE.LatheGeometry( points, segments = 12, phiStart = 0, phiLength = Math.PI * 2 )
Los puntos son Vector2 donde la coordenada X es la distancia al eje y la Y es la altura. Los valores de X deben ser no negativos; un cero cierra la forma sobre el eje.
import * as THREE from 'three';
// Perfil de una copa: radio variable a lo largo de la altura.
const perfil = [];
for ( let i = 0; i <= 24; i ++ ) {
const t = i / 24;
const radio = 0.15 + 0.55 * Math.pow( Math.sin( t * Math.PI * 0.85 ), 1.6 );
perfil.push( new THREE.Vector2( radio, t * 2 ) );
}
const copa = new THREE.LatheGeometry( perfil, 48 );
Las fórmulas, con N como número de puntos del perfil:
vertices = (segments + 1) · N
triangulos = 2 · segments · (N − 1)
Con veinticinco puntos y cuarenta y ocho segmentos: 1 225 vértices y 2 304 triángulos. Sube deprisa, porque las dos dimensiones se multiplican como en todos los sólidos de revolución.
phiLength se acota internamente al intervalo de cero a un giro completo, así que no puedes dar dos vueltas. Para una sección, phiLength menor que un giro deja la geometría abierta por los lados: no genera tapas y se ve el interior, con la advertencia habitual sobre FrontSide.
Una nota sobre las normales: LatheGeometry las calcula analíticamente a partir del perfil, no con computeVertexNormals, y hace un tratamiento especial en el primer y último punto para que las tapas planas no salgan redondeadas. Es una de las geometrías mejor resueltas del catálogo en ese aspecto.
TubeGeometry y los marcos de Frenet
Barre una sección circular a lo largo de una curva tridimensional arbitraria:
new THREE.TubeGeometry( path, tubularSegments = 64, radius = 1, radialSegments = 8, closed = false )
import * as THREE from 'three';
const curva = new THREE.CatmullRomCurve3( [
new THREE.Vector3( - 4, 0, 0 ),
new THREE.Vector3( - 1, 2, 1 ),
new THREE.Vector3( 2, - 1, - 1 ),
new THREE.Vector3( 4, 1, 0 )
] );
const tubo = new THREE.TubeGeometry( curva, 128, 0.15, 12, false );
Las fórmulas son las mismas que las del nudo:
vertices = (tubularSegments + 1) · (radialSegments + 1)
triangulos = 2 · tubularSegments · radialSegments
Lo interesante es cómo decide la orientación de la sección en cada paso. Una curva en el espacio no tiene un “arriba” natural, así que hay que construir un sistema de referencia local que gire lo mínimo posible al avanzar. Eso son los marcos de Frenet, y TubeGeometry los deja accesibles:
console.log( tubo.tangents ); // Array de Vector3, uno por segmento
console.log( tubo.normals );
console.log( tubo.binormals );
Sirven para colocar objetos a lo largo del tubo con la orientación correcta —señales en una carretera, luces en un cable, vagones en una vía— sin recalcular nada:
const marcos = curva.computeFrenetFrames( 64, false );
const punto = curva.getPointAt( 0.35, new THREE.Vector3() );
const indice = Math.round( 0.35 * 64 );
objeto.position.copy( punto );
objeto.matrix.makeBasis( marcos.normals[ indice ], marcos.binormals[ indice ], marcos.tangents[ indice ] );
objeto.quaternion.setFromRotationMatrix( objeto.matrix );
Una distinción que importa aquí: getPoint( t ) recorre el parámetro de la curva, que no avanza a velocidad constante; getPointAt( t ) recorre la longitud de arco, y sí lo hace. Para colocar objetos equiespaciados, o para animar algo a velocidad uniforme, hay que usar el segundo. La diferencia se nota mucho en curvas con tramos de curvatura muy distinta.
El parámetro closed cierra el tubo sobre sí mismo, y para que el resultado sea limpio la curva tiene que ser realmente cerrada. Si no lo es, se ve la costura. Y hay un fenómeno inevitable con curvas cerradas: al dar la vuelta completa, el marco de Frenet puede no coincidir consigo mismo y aparece un giro residual. Three.js lo corrige repartiendo el error a lo largo del recorrido, que es la solución estándar y hace que la costura sea imperceptible salvo con texturas muy direccionales.
Hay una diferencia estructural entre las geometrías paramétricas de las lecciones anteriores y las de esta, y explica por qué son las que más problemas de rendimiento causan en producción. En un cilindro, el número de triángulos lo fijas tú con dos números y lo sabes antes de crearlo. En una extrusión, el número de triángulos depende de cuántos puntos tiene el contorno después de subdividir sus curvas, y eso lo determina la forma, que a menudo viene de un SVG que ha diseñado otra persona. Un logotipo con veinte curvas de Bézier, subdividido a doce segmentos cada una, son doscientos cuarenta puntos de contorno; con el chaflán por defecto activado eso son unos siete mil triángulos por un logotipo plano. Nadie escribió ese número en ninguna parte, y multiplicado por los treinta iconos de una interfaz son doscientos mil triángulos que el desarrollador no sabe que existen. La disciplina que hace falta es concreta y poca gente la aplica: después de crear cualquier geometría derivada de curvas, imprime su cuenta de triángulos. Si es un icono de interfaz y pasa de mil, algo está mal calibrado, y lo que está mal calibrado casi siempre es una de estas tres cosas: curveSegments demasiado alto para el tamaño en pantalla, bevelSegments en su valor por defecto de tres cuando uno bastaría, o un SVG con contornos de una densidad de nodos absurda que nadie ha simplificado antes de importar. Los tres se arreglan en un minuto, y los tres son invisibles hasta que alguien mira el número.
- Extruye una forma con las opciones por defecto y cuenta sus triángulos; luego desactiva el chaflán y vuelve a contarlos.
- Comprueba qué pasa si defines un agujero con el mismo sentido de recorrido que el contorno.
- Baja
curveSegmentshasta que la poligonalización se note y anota el valor límite para tu tamaño en pantalla. - Modela una copa con
LatheGeometryy verifica la fórmula de triángulos. - Coloca diez objetos equiespaciados a lo largo de un
TubeGeometryusandogetPointAty los marcos de Frenet, y compáralo con usargetPoint.