Saltar al contenido

Calcular normales a mano o con computeVertexNormals

Qué hace exactamente computeVertexNormals, por qué su promedio está ponderado por área sin que nadie lo diga, y cuándo sale mejor calcular la normal analíticamente.

Qué es una normal y por qué debe ser unitaria

La normal es la dirección perpendicular a la superficie. Toda la iluminación difusa se apoya en un solo producto escalar entre la normal y la dirección hacia la luz, que da el coseno del ángulo entre ambas: uno cuando la luz incide de frente, cero cuando es rasante.

Ese coseno solo es el coseno si los dos vectores son unitarios. Una normal de longitud dos multiplica por dos el término difuso, y como no hay ninguna comprobación, el resultado es una superficie que se ilumina el doble sin motivo aparente. Es un fallo especialmente escurridizo porque no rompe nada: produce una imagen plausible pero incorrecta, y solo se detecta comparando.

Por eso hay un método específico para arreglarlo:

JS
geometria.normalizeNormals();   // divide cada normal por su módulo

El otro punto: la normal se transforma con la matriz normal, la traspuesta de la inversa de la parte lineal de la matriz de modelo y vista, no con la matriz directamente. Bajo una escala no uniforme, transformar la normal como si fuera una dirección la desorienta. Three.js la calcula por objeto y la expone en el shader:

JS
// Lo que hace el renderer por cada objeto, y lo que tú puedes replicar:objeto.normalMatrix.getNormalMatrix( objeto.modelViewMatrix );

La normal de una cara

Para un triángulo A, B, C recorrido en sentido antihorario, la normal que apunta hacia fuera es el producto vectorial de dos de sus lados:

JS
import * as THREE from 'three';const ab = new THREE.Vector3().subVectors( B, A );const ac = new THREE.Vector3().subVectors( C, A );const normal = new THREE.Vector3().crossVectors( ab, ac ).normalize();

La regla de la mano derecha garantiza que, con el sentido antihorario, el resultado sale por la cara frontal. Si el triángulo estuviera recorrido al revés, la normal saldría por detrás, que es la coherencia que hace que el sentido de recorrido y las normales sean dos formas de decir lo mismo.

Un detalle que se aprovecha constantemente: el módulo de ese producto vectorial es el doble del área del triángulo. No es una curiosidad; es el mecanismo de la ponderación de la que hablaremos ahora.

computeVertexNormals por dentro

El problema que resuelve es que un vértice pertenece a varios triángulos y cada uno tiene su propia normal de cara. Hay que combinarlas, y la respuesta de Three.js es sumarlas todas y normalizar al final. Reimplementarlo cabe en treinta líneas y merece la pena escribirlo una vez:

JS
import * as THREE from 'three';function normalesPropias( geometria ) {  const pos = geometria.getAttribute( 'position' );  const index = geometria.getIndex();  const acumulado = new Float32Array( pos.count * 3 );  const pA = new THREE.Vector3();  const pB = new THREE.Vector3();  const pC = new THREE.Vector3();  const cb = new THREE.Vector3();  const ab = new THREE.Vector3();  const total = index !== null ? index.count : pos.count;  for ( let i = 0; i < total; i += 3 ) {    const ia = index !== null ? index.getX( i )     : i;    const ib = index !== null ? index.getX( i + 1 ) : i + 1;    const ic = index !== null ? index.getX( i + 2 ) : i + 2;    pA.fromBufferAttribute( pos, ia );    pB.fromBufferAttribute( pos, ib );    pC.fromBufferAttribute( pos, ic );    cb.subVectors( pC, pB );    ab.subVectors( pA, pB );    cb.cross( ab );   // NO se normaliza: su módulo es el doble del área    for ( const idx of [ ia, ib, ic ] ) {      acumulado[ idx * 3 + 0 ] += cb.x;      acumulado[ idx * 3 + 1 ] += cb.y;      acumulado[ idx * 3 + 2 ] += cb.z;    }  }  geometria.setAttribute( 'normal', new THREE.BufferAttribute( acumulado, 3 ) );  geometria.normalizeNormals();}

El comentario del medio es la clave de toda la lección. El producto vectorial no se normaliza antes de acumularlo, así que cada triángulo contribuye a la suma con un peso proporcional a su área. Un triángulo grande empuja la normal del vértice mucho más que uno pequeño.

En una malla regular eso da igual, porque todos los triángulos son parecidos. En una malla irregular, no. El caso patológico clásico: un vértice donde concurren un triángulo enorme y cuatro pequeños. Geométricamente, la superficie en ese punto está determinada por los cinco a partes iguales; la normal calculada estará casi alineada con el grande. El síntoma es una banda de iluminación que se desvía justo donde cambia la densidad de la malla, y aparece constantemente en terrenos con niveles de detalle mezclados y en modelos con topología heredada de una simplificación automática.

La alternativa académicamente correcta es ponderar por el ángulo que el triángulo abarca en el vértice, que da un resultado independiente de la teselación. Three.js no la implementa, y adaptar la función de arriba para hacerlo es un ejercicio de veinte minutos: en lugar de acumular el producto vectorial crudo, acumular el normalizado multiplicado por el ángulo en ese vértice.

Hay una limitación mayor de computeVertexNormals que conviene enunciar aquí y que se desarrolla en la lección sobre normales duras: suaviza todo lo que comparte vértice, sin excepción. No hay concepto de grupo de suavizado ni de ángulo de arista viva. Si quieres que un cubo tenga aristas duras, computeVertexNormals sobre un cubo indexado con ocho vértices te da un cubo redondeado y no hay parámetro que lo evite.

La herramienta que sí existe para eso vive en los addons:

JS
import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';const conAristas = toCreasedNormals( geometria, Math.PI / 3 );   // 60 grados

Divide los vértices allí donde el ángulo entre caras adyacentes supera el umbral, y suaviza el resto. Es el equivalente al ángulo de suavizado de cualquier herramienta de modelado, y devuelve una geometría sin índice, porque la duplicación lo exige.

Normales analíticas

Cuando la superficie viene de una fórmula, hay una respuesta mejor que promediar caras: derivar.

La esfera es el caso trivial. La normal en un punto de una esfera centrada en el origen es la propia posición normalizada:

JS
const pos = geometria.getAttribute( 'position' );const normales = new Float32Array( pos.count * 3 );const v = new THREE.Vector3();for ( let i = 0; i < pos.count; i ++ ) {  v.fromBufferAttribute( pos, i ).normalize();  v.toArray( normales, i * 3 );}geometria.setAttribute( 'normal', new THREE.BufferAttribute( normales, 3 ) );

Eso es exactamente lo que hace IcosahedronGeometry con detail mayor que cero: llama a normalizeNormals() sobre las posiciones, y como están sobre la esfera unidad, el resultado son normales de esfera perfecta. Es más rápido y más preciso que promediar caras, y por eso la icosfera se ve perfectamente lisa con muy pocos triángulos.

Un campo de alturas es el caso general y el más útil. Si la superficie es y = f(x, z), sus dos vectores tangentes son (1, ∂f/∂x, 0) y (0, ∂f/∂z, 1), y su producto vectorial da la normal:

Texto
normal = normalizar( −∂f/∂x, 1, −∂f/∂z )

Los dos signos negativos son lo que la gente se equivoca. Con la fórmula delante, el código sale solo:

JS
import * as THREE from 'three';const K = 0.35;const AMPLITUD = 1.2;function ondaYNormal( x, z, t, salida ) {  const fx = Math.sin( x * K + t );  const fz = Math.cos( z * K * 1.3 - t * 0.7 );  const y = AMPLITUD * fx * fz;  const dydx = AMPLITUD * K * Math.cos( x * K + t ) * fz;  const dydz = - AMPLITUD * K * 1.3 * fx * Math.sin( z * K * 1.3 - t * 0.7 );  salida.set( - dydx, 1, - dydz ).normalize();  return y;}function deformar( geometria, t ) {  const pos = geometria.getAttribute( 'position' );  const nor = geometria.getAttribute( 'normal' );  const n = new THREE.Vector3();  for ( let i = 0; i < pos.count; i ++ ) {    const x = pos.getX( i );    const z = pos.getZ( i );    pos.setY( i, ondaYNormal( x, z, t, n ) );    nor.setXYZ( i, n.x, n.y, n.z );  }  pos.needsUpdate = true;  nor.needsUpdate = true;  geometria.computeBoundingSphere();}

Compáralo con la alternativa. computeVertexNormals() sobre esa misma malla recorre todos los triángulos, hace tres restas y un producto vectorial por cada uno, acumula en tres vértices y luego normaliza todo: aproximadamente tres veces más trabajo que la deformación misma. La versión analítica calcula la normal en el mismo bucle en el que ya estabas y con las derivadas ya evaluadas. Para una malla de cuarenta mil vértices animada a sesenta fotogramas por segundo, esa diferencia es la que decide si el efecto entra en el presupuesto.

Y hay un beneficio de calidad además del de coste: la normal analítica es la de la superficie real, no la de su aproximación poligonal. Con pocos segmentos, una malla con normales analíticas se ve notablemente más suave que la misma malla con normales promediadas, porque el sombreado describe la curva ideal y no la faceta.