Texture atlas: unir texturas sin romper los bordes
Cómo se construye un atlas, por qué los mipmaps hacen sangrar los bordes, cuánto margen hace falta de verdad, y cuándo un texture array es mejor solución que un atlas.
Un atlas de texturas es una idea vieja y sencilla: si el cambio de textura enlazada es un cambio de estado y los cambios de estado cuestan, mete todas las texturas en una sola imagen y remapea las coordenadas. Funciona, ahorra estado y ahorra peticiones de red. Y trae consigo un problema que casi nadie anticipa hasta que ve la escena a lo lejos: cuando la GPU baja de nivel de mipmap, los píxeles de la textura vecina se cuelan dentro de la tuya. La solución no es “poner un poco de margen”, es calcular cuánto margen exige el número de niveles que vas a generar.
- Construir un atlas y remapear los UV de una geometría al subrectángulo correcto.
- Calcular el margen necesario a partir del número de niveles de mipmap.
- Decidir entre atlas y
DataArrayTexturesegún si las texturas se repiten o no. - Reconocer cuándo el atlas no aporta nada porque el cuello no estaba en el estado.
El remapeo de UV, que es todo el truco
Un atlas convierte las coordenadas de textura de cada objeto, que van de cero a uno sobre su imagen propia, en coordenadas dentro de un subrectángulo del atlas. La transformación es afín y trivial: escalar y desplazar.
import * as THREE from 'three';
/**
* Remapea el atributo uv de una geometria a un subrectangulo del atlas.
* @param {THREE.BufferGeometry} geometria
* @param {{x:number, y:number, w:number, h:number}} rect en unidades 0..1
*/
function remapearUV(geometria, rect) {
const uv = geometria.attributes.uv;
for (let i = 0; i < uv.count; i++) {
const u = uv.getX(i);
const v = uv.getY(i);
uv.setXY(i, rect.x + u * rect.w, rect.y + v * rect.h);
}
uv.needsUpdate = true;
}
const atlas = new THREE.TextureLoader().load('/texturas/atlas.png');
atlas.colorSpace = THREE.SRGBColorSpace;
const caja = new THREE.BoxGeometry(1, 1, 1);
remapearUV(caja, { x: 0, y: 0, w: 0.25, h: 0.25 });
const malla = new THREE.Mesh(caja, new THREE.MeshStandardMaterial({ map: atlas }));
Tres cosas se rompen en cuanto haces esto, y conviene tenerlas presentes desde el principio.
La primera es la repetición. texture.wrapS = THREE.RepeatWrapping deja de tener sentido, porque el envoltorio ocurre en el espacio del atlas completo, no en el de tu subrectángulo. Una textura pensada para embaldosarse una pared no puede vivir en un atlas sin trucos en el shader. Es la limitación estructural más importante: el atlas es para texturas únicas, no para texturas que se repiten.
La segunda es que texture.repeat y texture.offset ya están ocupados conceptualmente. Podrías usarlos en lugar de reescribir los UV, y de hecho es lo que hacen muchos ejemplos, pero solo funciona si cada objeto tiene su propio material, que es justo lo que el atlas quería evitar. Si vas a compartir el material, la transformación tiene que estar en la geometría o en un atributo por instancia.
La tercera es el filtrado en los bordes, y merece su propia sección.
El sangrado de bordes y cuánto margen hace falta
Cuando la GPU muestrea con filtrado bilineal, mezcla los cuatro téxeles vecinos. En el borde exacto de un subrectángulo, dos de esos vecinos pertenecen a la textura de al lado. En el nivel cero del mipmap el error es de un téxel y no se ve. En el nivel cinco, cada téxel del mipmap agrupa treinta y dos téxeles del original, y ese error se vuelve una franja visible del color del vecino.
La cuenta es directa. Si generas n niveles de mipmap, el nivel más pequeño reduce por 2^n. Para que ningún téxel de ningún nivel mezcle datos de dos subrectángulos, el margen en píxeles del nivel cero tiene que ser al menos 2^n, y el propio subrectángulo tiene que estar alineado a múltiplos de 2^n.
| Niveles de mipmap | Reducción | Margen mínimo | Alineación |
|---|---|---|---|
| 4 | 16x | 16 px | 16 px |
| 6 | 64x | 64 px | 64 px |
| Completos, atlas 2048 | 2048x | Imposible | — |
La última fila es la conclusión incómoda: con mipmaps completos no existe margen suficiente, porque el nivel más pequeño es de un téxel y ese téxel promedia el atlas entero. Por eso las tres soluciones reales son otras.
La primera es limitar los niveles. Un atlas no necesita bajar hasta un píxel; necesita bajar hasta que el subrectángulo más pequeño ocupe unos pocos téxeles. Puedes generar los mipmaps a mano y quedarte en el nivel que toque, o usar texture.minFilter = THREE.LinearMipmapLinearFilter sobre un atlas construido con niveles ya recortados.
La segunda es el margen por repetición del borde. En vez de dejar el margen vacío, se copia el píxel del borde hacia afuera. Eso hace que la mezcla en el borde sea con el mismo color, y el error visual desaparece aunque técnicamente siga habiendo contaminación.
La tercera, y la que resuelve el problema de raíz, es no usar un atlas.
Un atlas con canal alfa y alphaTest sangra el doble: el borde contamina también la máscara, y el resultado son píxeles semitransparentes que aparecen a distancia media y desaparecen de cerca. Si vas a mezclar geometría recortada por alfa dentro de un atlas, el margen tiene que ser generoso y el alfa del margen tiene que copiar el del borde, no ser cero.
Texture arrays: el atlas sin sus problemas
WebGL2 y WebGPU soportan texturas en array: un objeto de textura que contiene varias capas del mismo tamaño y formato, indexadas por un tercer parámetro al muestrear. En Three.js la clase es DataArrayTexture y el muestreo se hace con sampler2DArray en GLSL o con el nodo correspondiente en TSL.
import * as THREE from 'three';
const ANCHO = 256;
const ALTO = 256;
const CAPAS = 8;
// Un unico buffer con todas las capas consecutivas
const datos = new Uint8Array(ANCHO * ALTO * 4 * CAPAS);
for (let capa = 0; capa < CAPAS; capa++) {
const base = capa * ANCHO * ALTO * 4;
for (let i = 0; i < ANCHO * ALTO; i++) {
datos[base + i * 4 + 0] = (capa * 32) % 256;
datos[base + i * 4 + 1] = 128;
datos[base + i * 4 + 2] = 255 - (capa * 32) % 256;
datos[base + i * 4 + 3] = 255;
}
}
const arrayTex = new THREE.DataArrayTexture(datos, ANCHO, ALTO, CAPAS);
arrayTex.format = THREE.RGBAFormat;
arrayTex.type = THREE.UnsignedByteType;
arrayTex.needsUpdate = true;
Las ventajas frente al atlas son exactamente las tres pegas de antes. Cada capa mantiene sus coordenadas de cero a uno, así que RepeatWrapping funciona. Los mipmaps se generan por capa, así que no hay sangrado entre texturas. Y el índice de capa puede venir de un atributo por instancia, lo que encaja perfectamente con InstancedMesh y BatchedMesh.
La contrapartida es que todas las capas tienen que compartir tamaño y formato. Un atlas admite una textura de 512 y otra de 64 sin problema; un array no. En la práctica eso obliga a escalar todo al mismo tamaño, y para conjuntos muy heterogéneos el desperdicio de memoria puede superar el ahorro.
El shader que lee del array necesita el índice de capa. Con InstancedMesh se pasa como InstancedBufferAttribute:
const geometria = new THREE.PlaneGeometry(1, 1);
const CUENTA = 500;
const capas = new Float32Array(CUENTA);
for (let i = 0; i < CUENTA; i++) capas[i] = i % CAPAS;
geometria.setAttribute('aCapa', new THREE.InstancedBufferAttribute(capas, 1));
const material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
uniforms: { uArray: { value: arrayTex } },
vertexShader: /* glsl */ `
in float aCapa;
out float vCapa;
out vec2 vUv;
void main() {
vCapa = aCapa;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
precision highp sampler2DArray;
uniform sampler2DArray uArray;
in float vCapa;
in vec2 vUv;
out vec4 fragColor;
void main() {
fragColor = texture(uArray, vec3(vUv, vCapa));
}
`,
});
const malla = new THREE.InstancedMesh(geometria, material, CUENTA);
Nota el glslVersion: THREE.GLSL3, y sobre todo por qué está ahí, porque casi nunca es la razón que se cuenta. Un ShaderMaterial de Three.js ya se compila como GLSL ES 3.00 sin pedirlo: el prefijo que inyecta el renderer antepone #version 300 es de forma incondicional a todo lo que no sea un RawShaderMaterial. sampler2DArray, in, out y texture() los tenías disponibles de todas formas. Lo que obliga a declarar GLSL3 es la línea out vec4 fragColor; del fragment shader: sin ella, Three.js declara por su cuenta su propia salida en la localización cero, y las dos chocan con un error de enlazado sobre salidas duplicadas. Si te quedas con gl_FragColor, puedes quitar glslVersion y el array de texturas sigue funcionando igual. Es un detalle que rompe muchos ejemplos copiados de tutoriales antiguos, que describen cómo era esto antes de que se abandonara WebGL 1.
Antes de construir un atlas, mide cuánto te está costando de verdad el cambio de textura. En WebGL2 sobre hardware de la última década, enlazar una textura ya muestreada cuesta del orden de microsegundos, y una escena con doscientos cambios de textura por frame pierde décimas de milisegundo por ese concepto. El atlas rara vez se paga por el ahorro de estado; se paga por otras dos cosas que nadie menciona en el titular: reduce el número de peticiones HTTP en la carga, y hace posible compartir un material entre objetos que antes no podían compartirlo, lo que a su vez habilita InstancedMesh o BatchedMesh. Si no vas a aprovechar ninguna de esas dos, probablemente no necesitas un atlas.
El coste real y cómo medirlo
Un atlas también cambia el perfil de memoria, y no siempre a mejor. Cuatro texturas de 512 por 512 en RGBA ocupan cuatro megabytes sin mipmaps. Un atlas de 1024 por 1024 que las contenga ocupa exactamente lo mismo. Pero si las cuatro no encajan sin hueco y tienes que subir a 2048, has pasado a dieciséis megabytes para almacenar cuatro. El empaquetado importa.
// Coste aproximado de una textura en VRAM, con mipmaps
function bytesTextura(ancho, alto, bytesPorTexel = 4, conMipmaps = true) {
const base = ancho * alto * bytesPorTexel;
return conMipmaps ? Math.round(base * 4 / 3) : base;
}
console.log(bytesTextura(2048, 2048)); // ~22.4 MB
console.log(bytesTextura(1024, 1024)); // ~5.6 MB
El factor cuatro tercios es la suma de la serie geométrica de los mipmaps: cada nivel ocupa un cuarto del anterior, y la suma converge a un tercio extra. Es el número que hay que tener en la cabeza cuando alguien propone “un atlas de 4096 para todo”.
Y sobre la compresión: KTX2 con Basis funciona igual de bien sobre un atlas que sobre texturas sueltas, y multiplica el ahorro. Pero comprimir un atlas tiene un riesgo añadido, porque los formatos de bloque comprimen en bloques de cuatro por cuatro téxeles: si el borde de un subrectángulo cae dentro de un bloque, la compresión mezcla dos texturas en el mismo bloque y el sangrado aparece incluso en el nivel cero. Alinea los subrectángulos a múltiplos de cuatro como mínimo, y de dieciséis si además hay mipmaps.
Construye un atlas de cuatro texturas de 256 en una imagen de 512, sin margen ninguno, y aplícalo a un plano grande que se aleje hasta ocupar veinte píxeles en pantalla. Graba el momento en que aparecen las franjas de color del vecino y anota a qué distancia ocurre. Luego repite con dieciséis píxeles de margen por repetición del borde y comprueba que el artefacto desaparece hasta un nivel de mipmap mucho más profundo.