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.
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.