wandres.dev
TEXTURAS I · Cargar, mapear y repetir

TextureLoader y LoadingManager: cargar sin mentir

Cómo carga Three.js una imagen, por qué TextureLoader no informa de progreso, y cómo montar un gestor de carga que cuente la verdad.

⏱ 16 min

Cargar una textura son dos líneas y por eso casi nadie mira lo que hay debajo. Debajo hay una descarga asíncrona, una decodificación en el hilo principal, una subida a la GPU que ocurre en un momento que tú no eliges, y un objeto Texture que existe y es válido antes de que ninguna de esas tres cosas haya terminado. Entender ese desfase es la diferencia entre una barra de carga útil y una que llega al cien por cien y deja la página congelada un segundo.

🎯 Al terminar esta lección sabrás
  • Usar TextureLoader en sus dos formas, con callbacks y con promesa.
  • Montar un LoadingManager compartido y leer sus cuatro callbacks.
  • Explicar por qué TextureLoader no admite eventos de progreso.
  • Identificar el trabajo que ocurre después de que el gestor diga que ha terminado.

Las dos formas de cargar

TextureLoader hereda de Loader y expone la firma clásica de todos los cargadores de Three.js:

import * as THREE from 'three';

const loader = new THREE.TextureLoader();

// Forma clasica: devuelve la textura inmediatamente, ya rellenada o no.
const ladrillo = loader.load(
  '/texturas/ladrillo.jpg',
  (textura) => { console.log('lista', textura.image.width); },
  undefined,                       // onProgress: no se usa aqui
  (error) => { console.error('fallo la carga', error); }
);

// El material se puede crear ya, aunque la imagen no haya llegado.
const material = new THREE.MeshStandardMaterial({ map: ladrillo });

El detalle que confunde a todo el mundo: load devuelve la textura al instante, antes de que la imagen exista. Ese objeto es válido, se puede asignar a un material y el material se puede usar; simplemente se dibuja con la imagen por defecto hasta que la carga termine, momento en el que el cargador asigna texture.image y pone needsUpdate a true. Es un patrón deliberado que permite construir la escena entera de forma síncrona, y el precio es el destello inicial de textura ausente.

Cuando prefieras controlar ese destello, la forma con promesa es más limpia:

const [ladrillo, normal, rugosidad] = await Promise.all([
  loader.loadAsync('/texturas/ladrillo_color.jpg'),
  loader.loadAsync('/texturas/ladrillo_normal.jpg'),
  loader.loadAsync('/texturas/ladrillo_rough.jpg')
]);

ladrillo.colorSpace = THREE.SRGBColorSpace;   // solo la de color

const material = new THREE.MeshStandardMaterial({
  map: ladrillo,
  normalMap: normal,
  roughnessMap: rugosidad
});

loadAsync está en la clase base Loader, así que lo tienen todos los cargadores del ecosistema, no solo este. Y las tres texturas se descargan en paralelo porque Promise.all lanza las tres llamadas antes de esperar a ninguna.

El gestor de carga

Un LoadingManager es un contador compartido. Se lo pasas a los cargadores en el constructor y él lleva la cuenta de cuántos elementos se han pedido y cuántos han terminado, dispare quien dispare la petición.

const gestor = new THREE.LoadingManager();

gestor.onStart = (url, cargados, total) => {
  document.body.dataset.cargando = 'si';
};

gestor.onProgress = (url, cargados, total) => {
  const fraccion = cargados / total;
  barra.style.setProperty('--progreso', String(fraccion));
};

gestor.onLoad = () => {
  document.body.dataset.cargando = 'no';
};

gestor.onError = (url) => {
  console.error('no se pudo cargar', url);
};

const texturas = new THREE.TextureLoader(gestor);
const modelos = new GLTFLoader(gestor);

Hay un THREE.DefaultLoadingManager que usan todos los cargadores a los que no les pasas ninguno, así que puedes engancharte a él sin tocar el código que crea los cargadores. Es cómodo para prototipar y mala idea en una aplicación con varias fases de carga, porque no puedes distinguir la carga inicial de la de un nivel posterior.

El gestor tiene una capacidad menos conocida y muy útil: setURLModifier. Recibe una función que transforma cada URL antes de pedirla, lo que sirve para reescribir rutas, para servir texturas desde un blob que ya tienes en memoria, o para inyectar un prefijo de CDN sin tocar los ficheros del modelo.

gestor.setURLModifier((url) => {
  if (url.startsWith('textures/')) return `https://cdn.ejemplo.com/${url}`;
  return url;
});

Por qué no hay progreso por textura

El cuarto argumento de TextureLoader.load es onProgress y no se llama nunca. No es un bug: la documentación de r184 lo dice explícitamente, el soporte de eventos de progreso se retiró de este cargador en la revisión 84.

La razón es la implementación. TextureLoader no descarga la imagen con fetch ni con XMLHttpRequest: crea un elemento Image del DOM y le asigna el src. El navegador se encarga de la descarga, del caché y de la decodificación, y el elemento Image no expone bytes recibidos. A cambio obtienes el caché HTTP del navegador, la decodificación fuera del hilo principal en los navegadores modernos, y compatibilidad con cualquier formato que el navegador sepa leer.

La consecuencia práctica es que el progreso solo puede ser por fichero, no por bytes. Con veinte texturas de tamaños muy distintos, una barra basada en cargados / total avanza a saltos irregulares. Si necesitas progreso fino, la alternativa es descargar con fetch leyendo el ReadableStream, construir un Blob, y crear la textura desde ahí; es bastante más código y solo compensa cuando hay pocos ficheros muy grandes.

onLoad no significa que la escena esté lista

Cuando LoadingManager.onLoad se dispara, lo único garantizado es que los ficheros están descargados y decodificados en memoria de CPU. Faltan dos cosas, y las dos ocurren de golpe en el primer fotograma que dibuje esos objetos.

La primera es la subida a la GPU. Three.js sube una textura la primera vez que se usa en un render. Veinte texturas de 2048 por 2048 son unos 335 megabytes de tráfico hacia la tarjeta comprimidos en un solo fotograma, más la generación de mipmaps de cada una.

La segunda es la compilación de shaders. Cada combinación distinta de material y luces produce un programa que el driver compila y enlaza la primera vez que se usa, y eso puede costar decenas de milisegundos por programa en algunos drivers.

El resultado es el patrón que se ve en media web 3D: la barra llega al cien por cien, desaparece, y la página se queda congelada un segundo antes de que aparezca nada. La solución práctica no es esperar más, es repartir el trabajo: dibujar un fotograma con la escena montada mientras el cargador sigue visible, de forma que la subida y la compilación ocurran detrás del velo, y ocultar el cargador solo después. Si además puedes escalonar la aparición de los objetos en varios fotogramas, el tirón desaparece del todo.

Errores que no fallan

Un último apunte que ahorra depuración. Si la URL de una textura es incorrecta, onError se dispara pero la escena sigue funcionando: el material se dibuja con la textura por defecto, que es blanca, así que un map que falla produce un objeto blanco y un roughnessMap que falla produce un material completamente rugoso. Ninguno de los dos parece un error de carga; parecen decisiones de material.

Por eso conviene que onError haga algo más visible que un console.error. En desarrollo, sustituir la textura fallida por una de damero magenta generada por código es el truco clásico y funciona igual de bien aquí que en cualquier motor.