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

Generar coordenadas de textura

Proyección planar, cilíndrica y esférica escritas a mano, por qué la costura de una esfera produce una franja de textura comprimida, y qué hacer cuando no hay desdoblado bueno.

⏱ 19 min

Una geometría generada por código no tiene coordenadas de textura hasta que se las das, y darlas bien es más difícil que generar las posiciones. La razón es que un desdoblado es un aplanamiento, y aplanar una superficie curva sin deformarla es imposible: hay que decidir dónde se corta y dónde se estira. Las tres proyecciones básicas resuelven los casos regulares en veinte líneas cada una, y sus fallos —la costura, los polos, el estiramiento en las paredes verticales— enseñan exactamente dónde están los límites del problema.

🎯 Al terminar esta lección sabrás
  • Generar coordenadas de textura planares a partir de la caja envolvente.
  • Invertir la parametrización esférica de Three.js para producir UVs equirrectangulares.
  • Diagnosticar y coser la costura de una proyección cilíndrica o esférica.
  • Reconocer cuándo la respuesta correcta es no tener UVs y proyectar en el shader.

Qué es una UV y qué pasa fuera del intervalo

Una coordenada de textura es un par de números que dice qué punto de la imagen corresponde a ese vértice. La convención es que el intervalo de cero a uno cubre la imagen entera, con el origen abajo a la izquierda.

Fuera de ese intervalo, lo que ocurre lo decide la textura, no la geometría:

textura.wrapS = THREE.RepeatWrapping;          // se repite en horizontal
textura.wrapT = THREE.ClampToEdgeWrapping;     // el último téxel se estira
// también existe THREE.MirroredRepeatWrapping
textura.repeat.set( 4, 4 );                    // multiplica las UVs
textura.offset.set( 0.25, 0 );                 // las desplaza

repeat y offset operan sobre las UVs que ya tiene la geometría, así que son la forma barata de ajustar un mosaico sin regenerar nada. Y ojo con un detalle: repeat mayor que uno solo tiene efecto visible si el modo de envoltura es de repetición; con el modo por defecto se ve el borde estirado.

Para comprobar un desdoblado no hay nada mejor que un damero, y generarlo cuesta diez líneas:

import * as THREE from 'three';

function texturaDamero( casillas = 8, tamano = 512 ) {
  const c = document.createElement( 'canvas' );
  c.width = c.height = tamano;
  const ctx = c.getContext( '2d' );
  const paso = tamano / casillas;

  for ( let y = 0; y < casillas; y ++ ) {
    for ( let x = 0; x < casillas; x ++ ) {
      ctx.fillStyle = ( x + y ) % 2 ? '#cdd6f4' : '#45475a';
      ctx.fillRect( x * paso, y * paso, paso, paso );
    }
  }

  const t = new THREE.CanvasTexture( c );
  t.colorSpace = THREE.SRGBColorSpace;
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  return t;
}

Con un damero, la deformación se lee de un vistazo: casillas que dejan de ser cuadradas indican estiramiento, casillas de tamaños distintos indican densidad desigual, y una franja donde el patrón se comprime brutalmente indica una costura mal resuelta.

Proyección planar

La más simple: se ignora un eje y los otros dos se normalizan por la caja envolvente.

import * as THREE from 'three';

function uvPlanarXZ( geometria ) {
  geometria.computeBoundingBox();
  const min = geometria.boundingBox.min;
  const max = geometria.boundingBox.max;

  const du = ( max.x - min.x ) || 1;
  const dv = ( max.z - min.z ) || 1;

  const pos = geometria.getAttribute( 'position' );
  const uv = new Float32Array( pos.count * 2 );

  for ( let i = 0; i < pos.count; i ++ ) {
    uv[ i * 2 + 0 ] = ( pos.getX( i ) - min.x ) / du;
    uv[ i * 2 + 1 ] = ( pos.getZ( i ) - min.z ) / dv;
  }

  geometria.setAttribute( 'uv', new THREE.BufferAttribute( uv, 2 ) );
}

El || 1 evita la división por cero cuando la geometría es plana en ese eje, que ocurre más de lo que parece.

Funciona perfectamente para suelos, terrenos y cualquier superficie que sea aproximadamente un mapa de alturas. Y falla exactamente donde cabe esperar: en las paredes verticales. Un acantilado tiene la misma coordenada UV a lo largo de toda su altura, porque su proyección sobre el plano horizontal es una línea, así que la textura sale infinitamente estirada. Es el artefacto más reconocible de la proyección planar y no tiene solución dentro de ella.

Si lo que quieres es un mosaico a escala constante en lugar de un desdoblado normalizado, divide por un tamaño fijo en lugar de por la caja:

const ESCALA = 2;   // dos unidades de mundo por repetición de la textura
uv[ i * 2 + 0 ] = pos.getX( i ) / ESCALA;
uv[ i * 2 + 1 ] = pos.getZ( i ) / ESCALA;

Con eso, dos objetos de tamaños distintos muestran la textura al mismo tamaño aparente, que casi siempre es lo que quieres para materiales como madera, hormigón o hierba.

Proyección esférica y la costura

Para envolver una esfera hay que invertir la parametrización. La convención de Three.js en SphereGeometry sitúa el meridiano de origen en el eje X negativo y avanza hacia el Z positivo, así que la inversión exacta es esta:

import * as THREE from 'three';

function uvEsferica( geometria ) {
  const pos = geometria.getAttribute( 'position' );
  const uv = new Float32Array( pos.count * 2 );
  const v = new THREE.Vector3();

  for ( let i = 0; i < pos.count; i ++ ) {
    v.fromBufferAttribute( pos, i ).normalize();

    let u = Math.atan2( v.z, - v.x ) / ( Math.PI * 2 );
    if ( u < 0 ) u += 1;

    uv[ i * 2 + 0 ] = u;
    uv[ i * 2 + 1 ] = 0.5 + Math.asin( v.y ) / Math.PI;
  }

  geometria.setAttribute( 'uv', new THREE.BufferAttribute( uv, 2 ) );
}

Y ahora el problema, que es inevitable y hay que saber reconocer. La coordenada horizontal da la vuelta: pasa de cero a casi uno recorriendo la esfera. Hay un meridiano donde salta de 0,99 a 0,00, y los triángulos que lo cruzan tienen dos vértices a un lado y uno al otro. El rasterizador interpola linealmente entre esos valores, así que en lugar de recorrer la última centésima de la textura, recorre las noventa y nueve restantes al revés, comprimidas en la anchura de un triángulo.

El síntoma visual es inconfundible: una franja vertical estrecha donde la textura entera aparece aplastada y espejada. Es el mismo artefacto que se ve al aplicar una panorámica a una geometría sin costura preparada.

La solución tiene dos partes. La geometría tiene que estar sin índice, porque el arreglo consiste en dar valores distintos a vértices coincidentes; y luego se recorre triángulo a triángulo empujando los valores pequeños por encima de uno:

function coserCostura( geometria ) {
  // Exige geometría sin índice y textura con RepeatWrapping.
  const uv = geometria.getAttribute( 'uv' );

  for ( let i = 0; i < uv.count; i += 3 ) {
    const u0 = uv.getX( i );
    const u1 = uv.getX( i + 1 );
    const u2 = uv.getX( i + 2 );

    if ( Math.max( u0, u1, u2 ) - Math.min( u0, u1, u2 ) > 0.5 ) {
      if ( u0 < 0.5 ) uv.setX( i, u0 + 1 );
      if ( u1 < 0.5 ) uv.setX( i + 1, u1 + 1 );
      if ( u2 < 0.5 ) uv.setX( i + 2, u2 + 1 );
    }
  }

  uv.needsUpdate = true;
}

El umbral de 0.5 es la heurística estándar: si el rango horizontal de un triángulo supera media textura, es que cruza la costura, porque ningún triángulo razonable cubre medio ecuador. Y RepeatWrapping es obligatorio, porque los valores por encima de uno tienen que envolver.

SphereGeometry no necesita nada de esto porque genera una columna extra de vértices al cerrar el meridiano, con la coordenada exactamente en uno. Es la misma solución aplicada en el momento de generar en lugar de después.

Los polos tienen un problema análogo y menos grave: allí la coordenada horizontal es indeterminada, porque todos los meridianos convergen. SphereGeometry asigna a cada vértice del polo la coordenada del meridiano al que pertenece su triángulo, lo cual reparte la textura en abanico y produce la convergencia radial que se ve en cualquier mapa aplicado a una esfera.

La proyección cilíndrica es la misma con la coordenada vertical tomada directamente de la altura, y tiene exactamente la misma costura y la misma solución:

let u = Math.atan2( v.z, - v.x ) / ( Math.PI * 2 );
if ( u < 0 ) u += 1;
const w = ( pos.getY( i ) - min.y ) / ( max.y - min.y || 1 );

Cuando no hay desdoblado bueno

Para geometría irregular —una roca, un terreno con cuevas, una estructura orgánica generada por código— ninguna de las tres proyecciones funciona, y desdoblar automáticamente es un problema difícil que Three.js no resuelve. La salida práctica es no tener UVs en absoluto y proyectar en el fragment shader, mezclando tres proyecciones planares según hacia dónde mire la normal:

// Proyección triplanar: mezcla de tres proyecciones según la normal.
vec3 mezcla = abs( vNormalMundo );
mezcla = pow( mezcla, vec3( 4.0 ) );        // endurece la transición
mezcla /= ( mezcla.x + mezcla.y + mezcla.z );

vec3 color =
    texture2D( uTextura, vPosMundo.yz / uEscala ) * mezcla.x
  + texture2D( uTextura, vPosMundo.xz / uEscala ) * mezcla.y
  + texture2D( uTextura, vPosMundo.xy / uEscala ) * mezcla.z;

Cada término es una proyección planar sobre uno de los tres planos coordenados, y los pesos vienen del cuadrado —o de una potencia mayor— de las componentes de la normal, normalizados para sumar uno. El resultado no tiene costuras, no tiene estiramiento y no necesita ninguna coordenada de textura.

Lo que cuesta es concreto y hay que sopesarlo: tres muestreos de textura en lugar de uno por cada mapa. Con un material de color, normal y rugosidad, son nueve muestreos por fragmento en lugar de tres. Es caro y a menudo vale la pena, porque la alternativa es un desdoblado que no existe.

Y hay una limitación conceptual: la proyección triplanar mapea desde el espacio, no desde la superficie, así que si el objeto se mueve, la textura se desliza sobre él. Para terreno estático y roca es perfecto; para un objeto que se anima, hay que proyectar en espacio local en lugar de en espacio de mundo, y entonces vuelve el estiramiento bajo escalas no uniformes.

Desdoblar es cortar, y decidir dónde cortar es un problema de diseño, no de algoritmo

Cuando ves un desdoblado profesional en un modelo de producción, lo que estás viendo no es la salida de un algoritmo: es una serie de decisiones humanas sobre dónde se puede permitir una costura. Y la razón de que no se automatice bien es que el criterio no es geométrico sino perceptual. Una costura sobre la línea de la columna de un personaje es invisible porque la mirada no va allí; la misma costura cruzando la mejilla arruina el modelo. Un algoritmo que minimiza la distorsión —que es lo que optimizan casi todos los desdobladores automáticos— pone las costuras donde la curvatura es mayor, que es exactamente donde más se notan. Por eso los buenos desdobladores dan control sobre las aristas marcadas en lugar de decidir ellos, y por eso el desdoblado sigue siendo trabajo manual treinta años después. La consecuencia para quien genera geometría por código es directa y liberadora: si tu geometría es procedural, probablemente no deberías desdoblarla, deberías texturizarla proceduralmente también. El triplanar, el ruido evaluado en el shader a partir de la posición, los patrones calculados a partir de la altura o la pendiente: todos evitan el problema en lugar de resolverlo, y todos escalan a geometría arbitraria sin ninguna intervención. El desdoblado tiene sentido cuando la textura es una imagen pintada a mano que tiene que caer en un sitio concreto, y ese caso, en geometría generada por código, es raro. Reconocer cuál de los dos mundos habitas ahorra semanas.

⚔️ Desdobla a mano
  1. Aplica un damero a una geometría generada por ti y localiza dónde se deforma.
  2. Genera UVs planares y comprueba el estiramiento en una pared vertical.
  3. Genera UVs esféricas sobre una icosfera y encuentra la franja de la costura.
  4. Cose la costura con el algoritmo del umbral y verifica que desaparece.
  5. Implementa el triplanar en un ShaderMaterial y compara el coste con el desdoblado planar.