wandres.dev
PRODUCCIÓN · Carga, accesibilidad y fallback

La pantalla de carga honesta: LoadingManager y progreso real

Por qué el contador de LoadingManager retrocede, cómo medir el progreso en bytes en lugar de en ficheros, y por qué el cien por cien llega antes de que la escena esté lista.

⏱ 19 min

Una barra de progreso falsa es peor que no tener barra. El usuario aprende en dos visitas que ese noventa por ciento no significa nada, y a partir de ahí la barra solo transmite que la página es lenta. Hacerla honesta exige entender tres cosas que el LoadingManager no te va a contar: que su denominador crece mientras carga, que contar ficheros no es contar bytes, y que cuando el último fichero termina todavía falta el trabajo más caro, que es compilar los shaders y subir las texturas a la GPU.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el progreso del LoadingManager puede retroceder y cuándo ocurre.
  • Medir el progreso ponderado por bytes usando los tamaños conocidos de antemano.
  • Incluir la compilación de shaders y la subida de texturas en el cómputo del cien por cien.
  • Diseñar la transición de la pantalla de carga a la escena sin el parón del primer fotograma.

Qué es el LoadingManager y qué cuenta

LoadingManager es un contador compartido entre loaders. Cada loader le avisa cuando empieza un ítem y cuando lo termina, y el gestor mantiene dos números: cuántos ítems ha terminado y cuántos conoce en total. Sus cuatro devoluciones de llamada exponen ese estado.

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const gestor = new THREE.LoadingManager();

gestor.onStart = (url, cargados, total) => {
  console.log('empieza', url, cargados, 'de', total);
};

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

gestor.onLoad = () => {
  console.log('todo terminado');
};

gestor.onError = (url) => {
  console.warn('fallo al cargar', url);
};

const loader = new GLTFLoader(gestor);
loader.load('/modelos/escena.glb', (gltf) => scene.add(gltf.scene));

El problema está en total. No es el número de ficheros que se van a cargar: es el número de ficheros que el gestor conoce en este instante. Y un glTF referencia texturas que solo se descubren al parsear el propio glTF, que ocurre cuando ya se ha descargado.

La secuencia real es esta: empieza con un ítem, el glTF, así que el total es uno. Al terminar, el progreso llega al cien por cien y se dispara onLoad. Entonces el parser descubre ocho texturas y las encola, con lo que el total pasa a nueve y el progreso cae al once por ciento. La barra ha ido a cien, ha vuelto a once, y va a subir otra vez.

⚠️
Cuidado

onLoad se dispara cada vez que el contador de terminados alcanza al total, no una sola vez al final. Con un glTF con texturas, se dispara al menos dos veces. Usarlo como “la escena está lista” oculta el bug hasta que alguien carga un modelo con dependencias, y entonces la pantalla de carga desaparece y vuelve.

Progreso por bytes, no por ficheros

Contar ficheros supone que todos pesan lo mismo, y no es cierto ni de lejos: un glTF con geometría comprimida puede pesar cuatro megabytes y su textura de normales otros ocho, mientras que el fichero de configuración pesa dos kilobytes. Con el contador de ficheros, terminar el JSON de configuración mueve la barra un tercio.

La solución honesta usa el tamaño conocido de antemano. Un manifiesto generado en el paso de construcción, con la ruta y el tamaño de cada recurso, convierte el progreso en una media ponderada real.

/**
 * Progreso ponderado por bytes con tamanos conocidos de antemano.
 */
export function crearProgreso(manifiesto, alCambiar) {
  // manifiesto: { '/modelos/escena.glb': 4_200_000, '/tex/albedo.ktx2': 8_100_000 }
  const totalBytes = Object.values(manifiesto).reduce((a, b) => a + b, 0);
  const avance = new Map();   // url -> bytes descargados

  function emitir() {
    let suma = 0;
    for (const b of avance.values()) suma += b;
    alCambiar(Math.min(1, suma / totalBytes));
  }

  return {
    /** Loader que informa de bytes: usa el tercer callback de load() */
    progresoDe(url) {
      return (evento) => {
        if (evento.lengthComputable) {
          avance.set(url, evento.loaded);
        } else {
          // Sin Content-Length: estima con el tamano del manifiesto
          avance.set(url, Math.min(evento.loaded, manifiesto[url] ?? 0));
        }
        emitir();
      };
    },
    completar(url) {
      avance.set(url, manifiesto[url] ?? 0);
      emitir();
    },
  };
}

El tercer argumento de loader.load es la devolución de llamada de progreso, y recibe un ProgressEvent con loaded, total y lengthComputable. Es la única fuente de progreso real por bytes que existe.

const progreso = crearProgreso(manifiesto, (f) => {
  barra.style.setProperty('--avance', String(f));
  etiqueta.textContent = `${Math.round(f * 100)} %`;
});

const url = '/modelos/escena.glb';
loader.load(
  url,
  (gltf) => { progreso.completar(url); scene.add(gltf.scene); },
  progreso.progresoDe(url),
  (e) => console.warn(e),
);

lengthComputable merece atención. Es false cuando el servidor no envía Content-Length, cosa habitual con compresión en tránsito por trozos. En ese caso total vale cero y dividir por él da Infinity. Por eso el manifiesto sirve de red: si el servidor no dice el tamaño, tú ya lo sabías.

💡
Tip

Genera el manifiesto en el paso de construcción, no a mano. Un script que recorre la carpeta de recursos y escribe un JSON con rutas y tamaños son diez líneas, y garantiza que los números no se queden obsoletos. Si además lo generas con el hash del contenido en el nombre, resuelves de paso la invalidación de caché.

Lo que falta después del cien por cien

Aunque todos los bytes hayan llegado, la escena no está lista. Quedan tres trabajos que ocurren en el hilo principal o en la GPU y que juntos suelen costar más que la descarga en una conexión decente.

Parsear y descomprimir. Un glTF con Draco descomprime geometría en workers, lo que es bueno, pero el resultado hay que ensamblarlo. Una textura KTX2 se transcodifica al formato que soporte la GPU, también en workers. Y una textura PNG se decodifica en el hilo principal salvo que uses ImageBitmapLoader.

Subir a la GPU. Cada textura y cada geometría se sube al usarse por primera vez, no al cargarse. Ocho texturas de 2048 son unos ciento setenta megabytes de transferencia por el bus, y eso no es instantáneo.

Compilar los shaders. Es lo más caro y lo que produce el parón más visible, como se vio en compartir materiales.

Los tres se resuelven con la misma llamada, que hay que hacer antes de ocultar la pantalla de carga:

export async function prepararEscena(renderer, scene, camera, objeto) {
  // compileAsync compila los programas y fuerza la subida de las texturas
  // que esos programas usan, sin bloquear el hilo principal.
  await renderer.compileAsync(objeto, camera, scene);
  scene.add(objeto);
}

Y el flujo completo de la carga queda así:

async function arrancar() {
  const progreso = crearProgreso(manifiesto, pintarBarra);

  // 1. Descarga: del 0 al 85 % del indicador
  const [gltf, entorno] = await Promise.all([
    cargarModelo('/modelos/escena.glb', progreso),
    cargarHDR('/entornos/estudio.hdr', progreso),
  ]);

  scene.environment = entorno;
  pintarBarra(0.85);
  etiqueta.textContent = 'Preparando materiales…';

  // 2. Compilacion y subida: del 85 al 100 %
  await renderer.compileAsync(gltf.scene, camera, scene);
  scene.add(gltf.scene);
  pintarBarra(1);

  // 3. Un fotograma real antes de retirar la pantalla
  await new Promise((r) => requestAnimationFrame(r));
  ocultarPantallaDeCarga();

  renderer.setAnimationLoop(frame);
}

Reservar el último quince por ciento para la preparación no es un truco cosmético: es lo que hace que el porcentaje corresponda al tiempo. Si la descarga es el ochenta y cinco por ciento del indicador y ocupa aproximadamente el ochenta y cinco por ciento del tiempo, la barra avanza a velocidad constante y el usuario percibe la espera como más corta de lo que realmente es.

El await sobre un requestAnimationFrame antes de ocultar es el detalle final. Garantiza que ya se ha pintado un fotograma real de la escena por debajo antes de retirar la capa de carga, y elimina el destello de fondo vacío que se ve al hacerlo al revés.

Nivel dios

Una barra que se queda quieta en el mismo número más de dos segundos es peor que una barra que va a saltos, porque el usuario asume que la carga se ha colgado y recarga la página, lo que reinicia todo. La solución no es mentir con una animación falsa, sino cambiar el mensaje. Un texto que va contando lo que ocurre (“descargando el modelo”, “descomprimiendo texturas”, “preparando materiales”) mantiene la sensación de progreso incluso cuando el número no se mueve, y además es información verdadera. Los estudios de percepción de espera coinciden en algo que va contra la intuición del desarrollador: la gente tolera mucho mejor una espera larga y explicada que una corta y opaca. Y si además muestras el peso total que se está descargando, das al usuario con conexión medida la información que necesita para decidir.

Errores y degradación

Una pantalla de carga honesta también tiene que contemplar el fallo. onError del gestor recibe la URL que falló, pero no distingue entre un 404 y una conexión caída, y sobre todo no detiene nada: el resto de la carga continúa.

const fallos = [];

gestor.onError = (url) => {
  fallos.push(url);
};

gestor.onLoad = () => {
  if (fallos.length === 0) return continuar();

  const criticos = fallos.filter((u) => manifiestoCritico.has(u));
  if (criticos.length > 0) {
    mostrarFallback('No se ha podido cargar la escena.');
  } else {
    // Faltan texturas secundarias: la escena funciona degradada
    continuar();
  }
};

La distinción entre recursos críticos y secundarios hay que declararla, no adivinarla. Un mapa de normales que falta produce una escena más plana pero usable; el modelo que falta produce una escena vacía. Marcar cuáles son cuáles en el manifiesto cuesta un campo y convierte un fallo total en una degradación aceptable.

Y hay que poner un límite de tiempo. Una petición que no responde deja la barra congelada indefinidamente, porque el navegador puede tardar minutos en darla por perdida.

function conLimite(promesa, ms, mensaje) {
  return Promise.race([
    promesa,
    new Promise((_, rechazar) =>
      setTimeout(() => rechazar(new Error(mensaje)), ms),
    ),
  ]);
}

const gltf = await conLimite(
  loader.loadAsync('/modelos/escena.glb'),
  30000,
  'La descarga del modelo ha tardado demasiado',
);

Treinta segundos es un límite razonable para un recurso principal en una conexión mala. Pasado ese punto, ofrecer al usuario la alternativa estática es mejor experiencia que dejarle mirando una barra parada.

⚔️ Reto práctico

Instrumenta la carga de una escena con un modelo glTF que tenga al menos cuatro texturas y registra en consola cada llamada a onProgress con sus tres argumentos. Observa el momento exacto en que total da el salto y el progreso retrocede. Después sustituye el contador de ficheros por el ponderado por bytes con manifiesto y compara las dos curvas de avance frente al tiempo real: la primera tiene escalones y retrocesos, la segunda es casi una recta.