Saltar al contenido

Winding order y el descarte de caras traseras

Cómo decide la GPU si un triángulo mira hacia ti calculando un área con signo, qué cuesta de verdad DoubleSide, y cómo invertir el sentido de recorrido de una geometría.

Cómo decide la GPU

Después de la proyección, el rasterizador tiene los tres vértices del triángulo en coordenadas de pantalla. Calcula entonces el doble del área con signo:

Texto
2·A = (x1 − x0)·(y2 − y0) − (y1 − y0)·(x2 − x0)

Es la componente Z del producto vectorial de los dos lados. Si sale positiva, los tres vértices se recorren en sentido antihorario en pantalla y el triángulo se considera de cara. Si sale negativa, horario, y es una cara trasera. Si sale cero, el triángulo es degenerado y se descarta.

La convención de que antihorario significa frontal es configurable en WebGL y es el valor por defecto. Three.js no la cambia.

Lo importante de esta definición es que el sentido se evalúa en pantalla, no en el espacio del modelo. El mismo triángulo, visto desde el otro lado, se recorre en sentido contrario. No hay ningún dato almacenado que diga “esta es la cara frontal”: la orientación se deduce en cada frame a partir de la proyección. Por eso rotar un objeto no cambia qué caras se ven, y por eso mirar un plano desde detrás lo hace desaparecer.

Lo que ahorra es sustancial. En una malla cerrada, exactamente la mitad de los triángulos, en media, están orientados hacia el otro lado. Descartarlos antes de rasterizar elimina la mitad del trabajo de fragmentos, y eso ocurre en una etapa temprana del pipeline donde el coste es de unas pocas operaciones aritméticas. Con las sombras, la proporción se mantiene en cada pasada. Es una optimización gratuita de un factor de dos que solo funciona si tu geometría tiene el sentido de recorrido correcto y la malla es cerrada.

material.side y lo que cuesta

Tres valores, uno de ellos con un coste que la gente subestima:

JS
material.side = THREE.FrontSide;   // 0, por defectomaterial.side = THREE.BackSide;    // 1material.side = THREE.DoubleSide;  // 2

FrontSide descarta las traseras. Es lo correcto para cualquier sólido cerrado y hay que dejarlo así.

BackSide descarta las frontales. Su uso canónico es ver un objeto desde dentro: una caja que hace de cielo, una esfera con una panorámica, un túnel. En lugar de invertir la geometría, inviertes qué caras se descartan.

JS
const cielo = new THREE.Mesh(  new THREE.SphereGeometry( 500, 60, 40 ),  new THREE.MeshBasicMaterial( { map: panoramica, side: THREE.BackSide } ));

DoubleSide desactiva el descarte. Es necesario para superficies abiertas de una sola capa —una hoja, una tela, un cartel— y tiene tres costes acumulados.

El primero es obvio: se rasterizan el doble de triángulos.

El segundo es el sobredibujado. En un objeto cerrado con DoubleSide, cada píxel se sombrea al menos dos veces, una por la cara delantera y otra por la trasera, y la prueba de profundidad solo decide cuál gana después de haber ejecutado ambos fragment shaders. Con un MeshPhysicalMaterial con transmisión y varias luces, eso es duplicar la parte más cara del frame.

El tercero es sutil y explica un artefacto frecuente. Una cara trasera tiene la normal apuntando en dirección contraria a la cámara, así que la iluminación saldría invertida. Three.js lo corrige en el fragment shader consultando si el fragmento pertenece a una cara frontal y volteando la normal cuando no:

GLSL
float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;vec3 normal = normalize( vNormal );#ifdef DOUBLE_SIDED  normal *= faceDirection;#endif

Funciona, y tiene un efecto colateral: con DoubleSide no puedes distinguir el interior del exterior en el sombreado, porque las dos caras se iluminan igual. Si querías que el reverso de una hoja se viera distinto, no basta con DoubleSide, hay que consultar gl_FrontFacing tú mismo en un shader propio.

La regla operativa: DoubleSide es correcto para superficies que de verdad son una lámina sin volumen, y es un parche para todo lo demás. Si estás poniendo DoubleSide a un objeto cerrado, lo que tienes es un problema de sentido de recorrido y estás pagando el doble para no arreglarlo.

Invertir el sentido

Para una geometría indexada, invertir el sentido es intercambiar dos de los tres índices de cada triángulo:

JS
function invertirCaras( geometria ) {  const index = geometria.getIndex();  if ( index !== null ) {    const a = index.array;    for ( let i = 0; i < a.length; i += 3 ) {      const t = a[ i + 1 ];      a[ i + 1 ] = a[ i + 2 ];      a[ i + 2 ] = t;    }    index.needsUpdate = true;  } else {    // Sin índice: hay que intercambiar los vértices en todos los atributos.    for ( const nombre of Object.keys( geometria.attributes ) ) {      const attr = geometria.attributes[ nombre ];      const s = attr.itemSize;      const a = attr.array;      for ( let i = 0; i < a.length; i += s * 3 ) {        for ( let k = 0; k < s; k ++ ) {          const t = a[ i + s + k ];          a[ i + s + k ] = a[ i + s * 2 + k ];          a[ i + s * 2 + k ] = t;        }      }      attr.needsUpdate = true;    }  }  // Las normales siguen apuntando al lado antiguo: hay que voltearlas también.  const normal = geometria.getAttribute( 'normal' );  if ( normal !== undefined ) {    const a = normal.array;    for ( let i = 0; i < a.length; i ++ ) a[ i ] = - a[ i ];    normal.needsUpdate = true;  }}

El último bloque es el que se olvida. Invertir el sentido de recorrido cambia qué cara se dibuja, pero las normales almacenadas siguen siendo las de antes, así que la iluminación queda al revés: la cara que ahora ves se ilumina como si mirase en dirección opuesta. Hay que voltearlas también, o recalcularlas.

Un caso relacionado que conviene conocer: una escala negativa invierte el sentido proyectado. Si pones objeto.scale.x = -1 para hacer una versión espejada, todos sus triángulos pasan a recorrerse al revés en pantalla. Three.js lo compensa: detecta que el determinante de la matriz de mundo es negativo y cambia la convención de cara frontal para ese objeto. Así que el espejado funciona sin que hagas nada, y merece la pena saber por qué, porque si alguna vez escribes tu propio bucle de render con WebGL directo, ese es un detalle que tendrás que replicar.

Los síntomas y el diagnóstico

Los tres síntomas del sentido de recorrido equivocado son inconfundibles una vez que los has visto.

La malla no se ve en absoluto. Todos sus triángulos están al revés. Confirmación: con DoubleSide aparece.

La malla se ve “del revés”, como vuelta hacia dentro. Ves el interior del objeto en lugar del exterior, con las partes de atrás dibujadas delante. Es el aspecto característico de una esfera con las caras invertidas.

Se ve a trozos, con partes que faltan. Algunos triángulos están bien y otros no, típicamente porque el generador emite el índice en un orden en una rama y en el contrario en otra. Es el fallo clásico de las tapas de un sólido de revolución, donde el abanico de arriba y el de abajo tienen que ir en sentidos opuestos.

El protocolo de diagnóstico son tres pasos en este orden:

JS
// 1. ¿Es el winding? Si con esto aparece, sí.material.side = THREE.DoubleSide;// 2. ¿Hacia dónde miran las normales?material = new THREE.MeshNormalMaterial( { side: THREE.DoubleSide } );// 3. ¿Y los triángulos, están donde creo?material.wireframe = true;

El segundo paso es el más informativo. MeshNormalMaterial mapea la normal a color, así que una superficie que mira hacia arriba sale verdosa y una que mira hacia el observador, azulada. Si el color de una cara es el complementario del de la cara opuesta del mismo objeto, las normales están bien y el problema es solo el índice. Si todas salen del mismo color, has generado normales constantes donde no tocaba.