wandres.dev
GEOMETRÍA PROPIA · Construir vértices a mano

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.

⏱ 19 min

computeVertexNormals() es una de esas llamadas que uno escribe sin pensar y que casi siempre hace lo correcto. Casi. Su algoritmo tiene una decisión implícita que no aparece en la documentación —promedia ponderando por el área de cada triángulo— y esa decisión es la causa de artefactos de iluminación en mallas con triángulos de tamaños muy distintos. Y en muchos casos ni siquiera hace falta: cuando la superficie viene de una fórmula, la normal exacta se obtiene derivando, sale más rápida y sale mejor.

🎯 Al terminar esta lección sabrás
  • Explicar por qué una normal debe ser unitaria y qué pasa si no lo es.
  • Calcular la normal de una cara respetando el sentido de recorrido.
  • Reimplementar computeVertexNormals y ver dónde está la ponderación por área.
  • Derivar la normal analítica de un campo de alturas y de una esfera.

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:

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:

// 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:

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:

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:

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:

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:

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:

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.

Promediar normales es una interpolación, y como toda interpolación, oculta lo que no sabe

computeVertexNormals no calcula la normal de la superficie: calcula un promedio de las normales de una aproximación poligonal, y esa distinción tiene consecuencias que van más allá de la ponderación por área. La más importante es que el resultado depende de la teselación, no de la forma. Dos mallas que representan exactamente la misma superficie con distinta densidad de triángulos producen normales distintas, y por tanto se iluminan distinto. Eso es lo que hace que un objeto con varios niveles de detalle cambie de sombreado al saltar de nivel, con un parpadeo que la gente atribuye a la geometría cuando en realidad viene de las normales. Es también lo que hace que simplificar una malla degrade su iluminación más de lo que degrada su silueta. La solución que usan los motores serios es no recalcular nunca: hornear las normales del modelo de alta resolución en un mapa de normales y aplicárselo al de baja, de modo que el sombreado deja de depender de la teselación por completo. Esa técnica —que es la razón de que exista el flujo de trabajo de alta a baja resolución en toda la industria— tiene su origen exactamente en esta limitación. Y en el otro extremo del espectro está la lección práctica más inmediata: si tu superficie viene de una fórmula, no promedies, deriva. La normal analítica es independiente de la teselación por construcción, así que puedes bajar los segmentos de tu malla a la mitad y el sombreado no cambia en absoluto, solo la silueta. Es una de las pocas optimizaciones que mejora la calidad y baja el coste a la vez.

⚔️ Calcula normales de tres maneras
  1. Reimplementa computeVertexNormals y comprueba que da exactamente el mismo resultado que el método de Three.js.
  2. Construye una malla con un triángulo grande y cuatro pequeños en un vértice y observa la desviación de la normal promediada.
  3. Modifica tu implementación para ponderar por ángulo y compara el resultado.
  4. Deforma un plano con la onda del ejemplo, calculando las normales de las dos formas, y mide el tiempo de cada una.
  5. Baja los segmentos del plano a la mitad y comprueba cuál de los dos sombreados se degrada.