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

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.

⏱ 17 min

Un triángulo no tiene dos caras: tiene un sentido de recorrido, y de ese sentido la GPU deduce cuál es la delantera. Es una de las decisiones de diseño más rentables del hardware gráfico, porque permite descartar la mitad de los triángulos de un objeto cerrado con una resta y una multiplicación, antes de sombrear un solo píxel. Y es también la causa del síntoma más desconcertante para quien genera geometría por primera vez: una malla que existe, está bien colocada y es completamente invisible.

🎯 Al terminar esta lección sabrás
  • Calcular el área con signo de un triángulo proyectado y deducir su orientación.
  • Cuantificar lo que ahorra el descarte de caras traseras en una malla cerrada.
  • Elegir entre FrontSide, BackSide y DoubleSide sabiendo el coste de cada uno.
  • Invertir el sentido de recorrido de una geometría indexada y de una suelta.

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:

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:

material.side = THREE.FrontSide;   // 0, por defecto
material.side = THREE.BackSide;    // 1
material.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.

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:

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:

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:

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

El descarte de caras traseras es correcto solo si tu malla es cerrada, y eso es un contrato que nadie verifica

El descarte de caras traseras funciona por una razón geométrica muy concreta: en un sólido cerrado y sin autointersecciones, cualquier cara orientada hacia el otro lado está necesariamente tapada por otra cara del mismo sólido, así que no dibujarla no cambia ni un píxel. Esa condición es un contrato, y el hardware lo asume sin comprobarlo. En cuanto tu malla no lo cumple, el descarte deja de ser una optimización transparente y se convierte en un cambio visual. Y las mallas dejan de cumplirlo constantemente: un modelo escaneado con agujeros, un objeto al que le han borrado la base porque “no se ve”, una malla exportada con caras duplicadas, un plano que representa una pared que en el mundo real tendría grosor. En todos esos casos alguien acaba poniendo DoubleSide y el problema se va, con el coste doblado, sin que nadie registre que la malla estaba mal. La consecuencia de fondo es que la calidad de tu geometría determina qué optimizaciones puedes activar, y eso vale mucho más allá del culling: un sólido cerrado y bien orientado admite descarte de caras traseras, admite sombras sin artefactos de acné en las caras traseras, admite pruebas de contención punto-dentro-del-sólido, admite booleanas, admite simplificación sin agujeros. Un sólido abierto no admite ninguna de esas cosas y obliga a un parche en cada una. Por eso los equipos que trabajan con 3D en serio tienen validadores de malla en su cadena de importación —comprobar que cada arista pertenece a exactamente dos triángulos, que las orientaciones son consistentes, que no hay vértices sueltos— y los que no los tienen acaban con una escena llena de DoubleSide y sin saber por qué va a la mitad de velocidad de lo que debería.

⚔️ Domina el sentido
  1. Genera una esfera, invierte su índice y comprueba que se ve como un interior.
  2. Invierte solo el índice sin tocar las normales y describe cómo se ilumina.
  3. Monta un cielo con BackSide y compáralo con invertir la geometría a mano.
  4. Mide los fotogramas por segundo de una escena con muchos objetos cerrados en FrontSide y en DoubleSide.
  5. Escala un objeto por menos uno en un eje y verifica que Three.js compensa el sentido de recorrido.