Saltar al contenido

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.

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.

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

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.

JS
/** * 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.

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

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:

JS
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í:

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

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.

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

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