Construir una malla vértice a vértice
Del triángulo mínimo a un generador de rejilla completo, escribiendo cada posición, cada normal, cada UV y cada índice a mano, y comprobando después que lo que has construido está bien.
Todo lo que hace PlaneGeometry cabe en veinticinco líneas, y escribirlas una vez enseña más sobre geometría en tiempo real que usar el catálogo entero. No porque generar planos a mano sea útil —no lo es—, sino porque en el momento en que tengas que generar algo que Three.js no trae, y ese momento llega siempre, la diferencia entre saber montar una rejilla indexada con sus normales y sus UVs y no saberlo es la diferencia entre media hora y una tarde.
- Construir una
BufferGeometrycompleta desde arrays sin usar ninguna geometría integrada. - Escribir un índice a mano respetando el sentido de recorrido.
- Generar una rejilla parametrizada con posiciones, normales, UVs e índice.
- Verificar una geometría propia con los ayudantes de depuración.
El triángulo mínimo
Un BufferGeometry recién construido está vacío. Lo único que necesita para dibujarse es un atributo position:
import * as THREE from 'three';
const geometria = new THREE.BufferGeometry();
const posiciones = new Float32Array( [
0.0, 0.5, 0.0, // vértice 0
-0.5, -0.5, 0.0, // vértice 1
0.5, -0.5, 0.0 // vértice 2
] );
geometria.setAttribute( 'position', new THREE.BufferAttribute( posiciones, 3 ) );
const malla = new THREE.Mesh( geometria, new THREE.MeshBasicMaterial( { color: 0xa6e3a1 } ) );
scene.add( malla );
El orden de los tres vértices no es arbitrario. Vistos desde el eje Z positivo, es decir, desde donde está la cámara por defecto, van en sentido antihorario, y eso es lo que hace que el triángulo se considere de cara. Si los escribieras en el otro orden, el triángulo existiría y sería invisible. Lo veremos con detalle enseguida.
Con MeshBasicMaterial basta la posición. En cuanto quieras iluminación necesitas normales, y en cuanto quieras textura necesitas UVs:
const normales = new Float32Array( [
0, 0, 1,
0, 0, 1,
0, 0, 1
] );
const uvs = new Float32Array( [
0.5, 1.0,
0.0, 0.0,
1.0, 0.0
] );
geometria.setAttribute( 'normal', new THREE.BufferAttribute( normales, 3 ) );
geometria.setAttribute( 'uv', new THREE.BufferAttribute( uvs, 2 ) );
Los tres arrays tienen que ser paralelos: la entrada i de cada uno describe el mismo vértice. Si position tiene tres elementos y normal cuatro, no hay ningún aviso, simplemente el atributo más largo sobra y el más corto lee basura.
Hay un atajo para el caso de solo posiciones:
geometria.setFromPoints( [
new THREE.Vector3( 0, 0.5, 0 ),
new THREE.Vector3( - 0.5, - 0.5, 0 ),
new THREE.Vector3( 0.5, - 0.5, 0 )
] );
Con una trampa que conviene conocer: si la geometría ya tiene un atributo position, setFromPoints no crea uno nuevo, sobrescribe el existente en el sitio, hasta donde llegue. Si le pasas más puntos de los que caben, avisa por consola y descarta el resto sin redimensionar nada. Es coherente con la imposibilidad de redimensionar buffers, pero sorprende la primera vez.
Del triángulo al cuadrilátero
Un cuadrilátero son dos triángulos que comparten una arista, y es el primer sitio donde el índice tiene sentido. Sin índice, seis vértices:
const sinIndice = new Float32Array( [
- 1, - 1, 0, 1, - 1, 0, 1, 1, 0, // primer triángulo
- 1, - 1, 0, 1, 1, 0, - 1, 1, 0 // segundo, repitiendo dos
] );
Con índice, cuatro vértices y seis referencias:
import * as THREE from 'three';
const geometria = new THREE.BufferGeometry();
geometria.setAttribute( 'position', new THREE.Float32BufferAttribute( [
- 1, - 1, 0, // 0: abajo izquierda
1, - 1, 0, // 1: abajo derecha
1, 1, 0, // 2: arriba derecha
- 1, 1, 0 // 3: arriba izquierda
], 3 ) );
geometria.setIndex( [
0, 1, 2, // primer triángulo, antihorario visto desde +Z
0, 2, 3 // segundo, también antihorario
] );
geometria.setAttribute( 'normal', new THREE.Float32BufferAttribute( [
0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1
], 3 ) );
geometria.setAttribute( 'uv', new THREE.Float32BufferAttribute( [
0, 0, 1, 0, 1, 1, 0, 1
], 2 ) );
Fíjate en el orden de los dos triángulos: 0, 1, 2 y 0, 2, 3. El vértice 2 aparece en ambos y el sentido se mantiene antihorario en los dos. Este es el patrón que hay que interiorizar, porque es el mismo que se repite en cada celda de cualquier rejilla.
setIndex con un array de números elige el tipo por ti: entero de dieciséis bits si todos los valores son menores que 65535, de treinta y dos si alguno lo supera.
Un generador de rejilla completo
Ahora lo mismo parametrizado. Esta función hace lo que PlaneGeometry con rotateX, y su estructura —dos bucles para los vértices, dos bucles para los índices— es el patrón que sirve para terrenos, para mallas de agua, para cualquier superficie regular:
import * as THREE from 'three';
function rejilla( ancho, fondo, segX, segZ ) {
const posiciones = [];
const normales = [];
const uvs = [];
const indices = [];
// Primer par de bucles: los vértices, incluyendo la fila y la columna de cierre.
for ( let iz = 0; iz <= segZ; iz ++ ) {
const v = iz / segZ;
for ( let ix = 0; ix <= segX; ix ++ ) {
const u = ix / segX;
posiciones.push( ( u - 0.5 ) * ancho, 0, ( v - 0.5 ) * fondo );
normales.push( 0, 1, 0 );
uvs.push( u, 1 - v );
}
}
// Segundo par: las celdas, dos triángulos cada una.
const porFila = segX + 1;
for ( let iz = 0; iz < segZ; iz ++ ) {
for ( let ix = 0; ix < segX; ix ++ ) {
const a = iz * porFila + ix;
const b = a + 1;
const c = a + porFila;
const d = c + 1;
indices.push( a, c, b );
indices.push( b, c, d );
}
}
const geometria = new THREE.BufferGeometry();
geometria.setAttribute( 'position', new THREE.Float32BufferAttribute( posiciones, 3 ) );
geometria.setAttribute( 'normal', new THREE.Float32BufferAttribute( normales, 3 ) );
geometria.setAttribute( 'uv', new THREE.Float32BufferAttribute( uvs, 2 ) );
geometria.setIndex( indices );
geometria.computeBoundingSphere();
return geometria;
}
const suelo = new THREE.Mesh(
rejilla( 20, 20, 40, 40 ),
new THREE.MeshStandardMaterial( { color: 0x89b4fa } )
);
scene.add( suelo );
Cuatro detalles del código merecen atención.
Los bucles van hasta <= y no hasta <. Una rejilla de cuarenta celdas tiene cuarenta y un nudos por lado. Confundirlo produce una malla a la que le falta la última fila, y el síntoma —un plano que no llega hasta donde debería— es fácil de diagnosticar precisamente porque es tan visible.
El orden de los índices es a, c, b y no a, b, c. Como el plano está en horizontal y las normales miran hacia arriba, el sentido antihorario visto desde arriba requiere ese orden. Si lo escribes al revés, el suelo se ve desde abajo y no desde arriba. Es la comprobación que hay que hacer siempre al generar una superficie con una orientación concreta.
La coordenada V se invierte. uvs.push( u, 1 - v ) en lugar de u, v. La convención de las texturas es que el origen está abajo a la izquierda, mientras que la rejilla la recorremos desde el fondo hacia el frente. Sin la inversión, la textura sale del revés.
computeBoundingSphere() al final. Sin ella, el renderer la calcula solo la primera vez que la necesita, lo cual también funciona, pero llamarla explícitamente deja claro que la geometría está terminada y evita el sobresalto de un aviso de NaN a mitad de la primera pasada.
Comprobar que está bien
Una geometría generada a mano casi nunca sale bien a la primera, y hay cuatro comprobaciones que localizan el noventa por ciento de los fallos.
El material doble cara. Si no ves nada, lo primero es descartar que sea el sentido de recorrido:
malla.material.side = THREE.DoubleSide;
Si con esto aparece, el problema es el winding. Si no aparece, es otra cosa: posiciones mal, índices fuera de rango o NaN.
El material de normales. MeshNormalMaterial colorea cada fragmento según su normal, así que las caras que miran en direcciones distintas salen de colores distintos. Es la forma más rápida de ver si las normales están donde deben:
malla.material = new THREE.MeshNormalMaterial( { side: THREE.DoubleSide } );
Las normales dibujadas. El ayudante las pinta como segmentos:
import { VertexNormalsHelper } from 'three/addons/helpers/VertexNormalsHelper.js';
scene.add( new VertexNormalsHelper( malla, 0.2, 0xf38ba8 ) );
Si apuntan hacia dentro, hacia direcciones aleatorias o tienen longitudes distintas, el problema está ahí.
El alambre. wireframe: true en el material dibuja las aristas de los triángulos y revela inmediatamente índices mal formados: triángulos cruzados, celdas que faltan, la clásica diagonal que va al vértice equivocado por haber sumado mal el ancho de fila.
Y un aviso de consola que vale su peso en oro: si tus posiciones contienen algún NaN, computeBoundingSphere lo dice. Un NaN en una posición hace que toda la malla desaparezca, porque el volumen envolvente sale inválido y el culling la descarta siempre. Si has generado una geometría a partir de una fórmula y no se ve nada de nada, esa es la primera sospecha, y la causa suele ser una división por cero o una raíz de un número negativo en algún caso límite del bucle.
Todos los generadores que has visto —el plano, la esfera, el cilindro, el toro, el torno— tienen exactamente la misma estructura, y verla explícitamente ahorra mucho tiempo. Hay dos bucles independientes que no se pueden fusionar: el primero muestrea la superficie y produce vértices; el segundo describe la conectividad y produce índices. El primero recorre n+1 puntos por eje porque muestrea los extremos; el segundo recorre n celdas porque cada celda está entre dos puntos. Esa diferencia de uno es el origen de la mitad de los errores al generar geometría a mano, y la tentación de escribir un solo bucle que haga las dos cosas es fortísima, porque parece que ahorra trabajo. No lo ahorra: obliga a razonar simultáneamente sobre puntos y sobre intervalos, que son objetos distintos, y produce código en el que nadie sabe si un índice se refiere a un nudo o a una celda. La disciplina que funciona es rígida y merece la pena: primero todos los vértices, después todos los índices, nunca mezclados, y con el número de puntos por fila guardado en una variable con nombre en lugar de escribir segX + 1 en cinco sitios. Cuando llegue el momento de generar algo con topología irregular —un terreno con agujeros, una malla que se cose consigo misma, una superficie con un borde recortado— esa separación es lo único que hace el problema manejable, porque te permite cambiar la conectividad sin tocar el muestreo y viceversa. Es el mismo principio que separa datos de estructura en cualquier otra parte de la ingeniería, y aquí es especialmente literal, porque los dos bucles producen dos buffers distintos que la GPU consume por caminos distintos.
- Monta el triángulo y comprueba que invertir el orden de sus tres vértices lo hace invisible.
- Construye el cuadrilátero indexado y verifica que tiene cuatro vértices y dos triángulos.
- Escribe la función
rejillay compárala conPlaneGeometryrotado: mismo número de vértices e índices. - Cambia
a, c, bpora, b, cy describe exactamente qué pasa. - Introduce un
NaNa propósito en una posición y confirma que la malla entera desaparece.