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