Coordenadas UV: de dónde salen y por qué se tuercen
El atributo uv, la convención de origen que obliga a voltear las imágenes, cómo generan sus UV las geometrías integradas, y qué significa la densidad de téxeles.
Una textura es una imagen, es decir, una función de dos variables. Para pintarla sobre una superficie 3D hace falta una aplicación de la superficie al plano de la imagen, y esa aplicación no la puede inventar el motor: viene con la geometría, vértice a vértice, en un atributo llamado uv. Casi todos los problemas de textura que parecen problemas de textura son en realidad problemas de ese atributo.
- Describir el atributo
uvy la convención de origen que usa Three.js. - Explicar por qué
flipYexiste y en qué casos no funciona. - Predecir el desenvolvido de las geometrías integradas más habituales.
- Razonar sobre densidad de téxeles y costuras.
El atributo y la convención
uv es un BufferAttribute de dos componentes por vértice. La convención es que (0,0) es una esquina de la imagen y (1,1) la opuesta, pero nada obliga a quedarse en ese rango: valores fuera de él son perfectamente legales y lo que ocurre con ellos lo decide el modo de repetición.
El punto delicado es cuál esquina es cuál. OpenGL, y por herencia WebGL y Three.js, ponen el origen de la textura abajo a la izquierda, con la coordenada v creciendo hacia arriba. Los formatos de imagen del mundo web, en cambio, guardan las filas de arriba abajo. Si subieras el buffer de píxeles tal cual, todas las texturas saldrían del revés.
Three.js resuelve ese desajuste con texture.flipY, que vale true por defecto y voltea la imagen al subirla. Es una decisión pragmática que funciona el noventa y nueve por ciento de las veces y que tiene dos excepciones importantes, las dos documentadas en la propia clase Texture:
- No tiene efecto con
ImageBitmap. El volteo hay que pedirlo al crear el bitmap, con la opción correspondiente decreateImageBitmap. - No tiene efecto con texturas comprimidas. Un KTX2 llega en bloques ya comprimidos y voltear implicaría descomprimir. Por eso el ejemplo oficial de carga de KTX2 en el repositorio de Three.js reescribe las UV de la geometría en lugar de tocar la textura.
Esa segunda excepción es la causa de un síntoma muy concreto: migras las texturas de PNG a KTX2 para ahorrar memoria y de repente todos los modelos aparecen con las texturas invertidas verticalmente. No has roto nada; has salido del caso en el que flipY funciona.
Con modelos glTF no hay problema, porque el formato define su propia convención de origen arriba a la izquierda y GLTFLoader ya pone flipY a false en las texturas que carga. El conflicto solo aparece cuando mezclas geometría propia con texturas comprimidas.
Cómo desenvuelven las geometrías integradas
Las geometrías que trae Three.js generan sus UV proceduralmente, y conocer el patrón evita sorpresas.
PlaneGeometry es la más simple: mapeo directo, la esquina inferior izquierda es (0,0) y la superior derecha (1,1). Una textura cubre el plano entero una vez.
BoxGeometry asigna a cada una de las seis caras el rango completo de cero a uno. Es decir, la textura se repite entera en las seis caras. No hay un desenvolvido de cubo tipo cruz; si quieres eso, tienes que reescribir las UV o usar seis materiales.
SphereGeometry usa un mapeo equirectangular: u recorre la longitud y v la latitud. Esto tiene dos consecuencias inevitables. La primera es una costura en el meridiano donde u vuelve de uno a cero, visible como una línea si la textura no es continua en horizontal. La segunda es el apretado en los polos: todos los vértices del polo comparten la misma posición pero tienen u distintas, así que la textura se comprime radialmente y cualquier detalle se convierte en un remolino. Es la razón por la que las texturas de planetas se hacen en proyección equirectangular y aun así los polos siempre se ven raros.
CylinderGeometry y TorusGeometry tienen costuras equivalentes en la dirección de revolución.
Cuando necesites ver el desenvolvido real de cualquier geometría, la forma más rápida es pintar las UV como color:
import * as THREE from 'three';
// Textura de damero generada por codigo, util para auditar UV.
function damero(casillas = 8, lado = 512) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = lado;
const ctx = canvas.getContext('2d');
const paso = lado / casillas;
for (let y = 0; y < casillas; y++) {
for (let x = 0; x < casillas; x++) {
ctx.fillStyle = (x + y) % 2 ? '#cdd6f4' : '#585b70';
ctx.fillRect(x * paso, y * paso, paso, paso);
}
}
const textura = new THREE.CanvasTexture(canvas);
textura.colorSpace = THREE.SRGBColorSpace;
textura.wrapS = THREE.RepeatWrapping;
textura.wrapT = THREE.RepeatWrapping;
return textura;
}
const material = new THREE.MeshBasicMaterial({ map: damero(8) });
Un damero cuenta más que cualquier inspección numérica: las casillas deformadas indican estiramiento, las de distinto tamaño indican densidad de téxeles desigual, y las líneas rotas indican costuras.
Densidad de téxeles
La densidad de téxeles es cuántos píxeles de textura corresponden a cada unidad de superficie del modelo. Es la magnitud que decide si dos objetos de la misma escena se ven con el mismo nivel de detalle.
El problema típico: una pared de diez metros y una taza de diez centímetros, las dos con una textura de 1024 por 1024. La taza tiene diez mil veces más téxeles por centímetro cuadrado que la pared, así que la pared se ve borrosa y la taza nítida, y el conjunto parece de un motor mal calibrado aunque cada objeto por separado esté bien.
La solución estándar en producción es fijar una densidad objetivo para todo el proyecto, por ejemplo 512 téxeles por metro, y dimensionar las texturas y las UV de cada objeto para respetarla. En el caso de la pared, eso implica repetir una textura pequeña en lugar de estirar una grande, que es justo para lo que existe repeat.
Un vértice tiene una sola UV. En una costura de desenvolvido, el mismo punto del espacio necesita dos UV distintas, una para cada lado de la costura, así que el exportador duplica el vértice. Lo mismo pasa con las aristas duras, donde el mismo punto necesita dos normales.
El efecto acumulado sorprende: un cubo, que geométricamente tiene ocho vértices, se sube a la GPU con veinticuatro, porque cada esquina pertenece a tres caras con normales y UV distintas. Una esfera de 32 por 16 segmentos no tiene 512 vértices sino bastantes más, por la costura del meridiano y por los polos.
De ahí salen dos consecuencias medibles. La primera es de memoria y de caché de vértices: un desenvolvido con muchas islas pequeñas puede duplicar el número de vértices reales frente a uno con pocas islas grandes, y eso se paga en cada fotograma. La segunda es de calidad: en una costura, el filtrado bilineal de la textura no cruza al otro lado, así que aparece una línea de un píxel donde el color no coincide, y con mipmaps agresivos esa línea se ensancha. Por eso los desenvolvidos profesionales dejan margen alrededor de cada isla y rellenan ese margen con el color del borde, lo que se llama padding o dilatación. Si ves líneas finas en las costuras de tu modelo al alejarte, casi nunca es el modelo: es que la textura no tiene padding suficiente para los niveles de mipmap que se están usando.
El segundo juego de UV
Una malla puede tener más de un desenvolvido. Three.js soporta hasta cuatro, en los atributos uv, uv1, uv2 y uv3, y cada textura elige cuál usa con su propiedad channel, que vale 0 por defecto y se corresponde con uv.
La razón de existir del segundo juego es que hay dos requisitos incompatibles. Las texturas de detalle quieren repetirse y solaparse para aprovechar la resolución: un muro de ladrillo puede usar la misma baldosa cien veces. Pero una textura que guarda un valor calculado para cada punto concreto de la superficie, como una oclusión ambiental horneada o un mapa de luz, necesita que cada punto del modelo tenga su propio téxel, sin solapamientos ni repeticiones.
Por eso aoMap y lightMap requieren un segundo juego de UV no solapado, y por eso ese segundo desenvolvido se genera automáticamente en las herramientas de horneado en vez de a mano.