Saltar al contenido

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.

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:

JS
textura.wrapS = THREE.RepeatWrapping;          // se repite en horizontaltextura.wrapT = THREE.ClampToEdgeWrapping;     // el último téxel se estira// también existe THREE.MirroredRepeatWrappingtextura.repeat.set( 4, 4 );                    // multiplica las UVstextura.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:

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

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

JS
const ESCALA = 2;   // dos unidades de mundo por repetición de la texturauv[ 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:

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

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

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

GLSL
// Proyección triplanar: mezcla de tres proyecciones según la normal.vec3 mezcla = abs( vNormalMundo );mezcla = pow( mezcla, vec3( 4.0 ) );        // endurece la transiciónmezcla /= ( 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.