wandres.dev
CARGAR Y OPTIMIZAR ASSETS · Draco, Meshopt y gltf-transform

GLTFLoader con sus tres decodificadores

Cómo se conectan DRACOLoader, KTX2Loader y MeshoptDecoder, dónde hay que copiar los binarios de cada uno, y el orden de inicialización que evita los tres errores clásicos.

⏱ 17 min

Un GLTFLoader sin configurar carga modelos sin comprimir y falla con todo lo demás. La configuración completa son tres objetos auxiliares, cada uno con sus binarios que hay que servir desde tu propio dominio, y cada uno con una peculiaridad de inicialización que produce un error distinto si te la saltas. Vale la pena montarlo una sola vez, bien, y reutilizar la instancia en todo el proyecto: son objetos caros que crean pools de workers.

🎯 Al terminar esta lección sabrás
  • Montar un GLTFLoader completo con los tres decodificadores conectados.
  • Colocar los binarios de Draco y de Basis en el sitio correcto del proyecto.
  • Explicar por qué KTX2Loader necesita el renderizador y qué pasa si no lo recibe.
  • Reutilizar y liberar correctamente los decodificadores en una aplicación con varias escenas.

El montaje completo

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

export function crearCargador( renderer ) {
  // Draco: binarios en /draco/, copiados de three/examples/jsm/libs/draco/
  const draco = new DRACOLoader()
    .setDecoderPath( '/draco/' )
    .preload();                  // arranca la descarga del wasm ya

  // KTX2: binarios en /basis/, copiados de three/examples/jsm/libs/basis/
  // detectSupport necesita el renderer para saber que formatos admite la GPU.
  const ktx2 = new KTX2Loader()
    .setTranscoderPath( '/basis/' )
    .detectSupport( renderer );

  const loader = new GLTFLoader()
    .setDRACOLoader( draco )
    .setKTX2Loader( ktx2 )
    .setMeshoptDecoder( MeshoptDecoder );

  return { loader, draco, ktx2 };
}

Tres detalles que no son opcionales.

Los binarios se sirven desde tu dominio. DRACOLoader necesita draco_wasm_wrapper.js y draco_decoder.wasm; KTX2Loader necesita basis_transcoder.js y basis_transcoder.wasm. Vienen dentro del paquete three, en examples/jsm/libs/draco/ y examples/jsm/libs/basis/. Hay que copiarlos a la carpeta pública en el paso de construcción:

cp -r node_modules/three/examples/jsm/libs/draco public/draco
cp -r node_modules/three/examples/jsm/libs/basis public/basis

Cargarlos desde un CDN de terceros funciona en desarrollo y es una mala idea en producción: añades un origen del que depende tu aplicación, y una versión que puede desincronizarse con la de tu three.

preload() sobre Draco. Sin él, el WASM se descarga la primera vez que aparece un modelo comprimido, es decir, en el momento crítico. Con él, la descarga arranca cuando montas el cargador y solapa con el resto de la carga.

detectSupport( renderer ) sobre KTX2. Es obligatorio y es el error más frecuente de los tres.

Por qué KTX2Loader necesita el renderizador

Un fichero KTX2 con Basis Universal no contiene una textura lista: contiene un formato intermedio que se transcodifica al formato de bloques que soporta la GPU concreta del usuario. Los candidatos son varios y ninguno es universal:

Formato Dónde
ASTC móviles modernos, casi todos
ETC2 Android, WebGL2 obligatorio
BC7 / BC3 escritorio, Windows y macOS
PVRTC iOS antiguo

detectSupport( renderer ) consulta las extensiones de WebGL disponibles y decide a cuál transcodificar. Sin esa llamada, el transcodificador no sabe a qué formato ir y la carga falla con THREE.KTX2Loader: Missing initialization with detectSupport() o produce texturas descomprimidas en RGBA, perdiendo toda la ventaja.

La consecuencia arquitectónica es que el cargador depende del renderizador, lo que obliga a crear el renderizador antes que el cargador. En una aplicación con framework, eso significa que la instanciación del cargador no puede vivir en un módulo de nivel superior: tiene que ocurrir cuando el canvas ya existe.

Reutilizar y liberar

Los tres decodificadores son caros de crear. DRACOLoader mantiene un pool de hasta cuatro Web Workers, cada uno con su copia del módulo WASM; KTX2Loader hace lo mismo con el transcodificador de Basis. Crear uno por modelo significa crear ocho workers por modelo, y el navegador los mata o se queda sin memoria.

El patrón correcto es un módulo singleton:

let cache = null;

export function obtenerCargador( renderer ) {
  if ( ! cache ) cache = crearCargador( renderer );
  return cache.loader;
}

export function liberarCargador() {
  if ( ! cache ) return;
  cache.draco.dispose();   // termina los workers y revoca los object URLs
  cache.ktx2.dispose();
  cache = null;
}

dispose() en ambos termina los workers y libera los ObjectURL que se crearon para los blobs de código. MeshoptDecoder no necesita liberación porque es un objeto estático sin estado por instancia.

⚠️
Un DRACOLoader no se puede reconfigurar después de decodificar

La documentación del método lo dice: “Configuration cannot be changed after decoding begins”. setDecoderPath y setDecoderConfig tienen que llamarse antes del primer modelo. Si necesitas cambiar de configuración, crea otro loader y desecha el anterior.

Cargar con progreso y con errores

GLTFLoader hereda de Loader y ofrece tanto la forma con callbacks como la de promesa:

const loader = obtenerCargador( renderer );

// Forma con promesa, sin progreso.
const gltf = await loader.loadAsync( '/modelos/coche.glb' );

// Forma con callbacks, con progreso.
loader.load(
  '/modelos/coche.glb',
  ( gltf ) => scene.add( gltf.scene ),
  ( evento ) => {
    // lengthComputable es false si el servidor no manda Content-Length.
    if ( evento.lengthComputable ) {
      console.log( ( evento.loaded / evento.total * 100 ).toFixed( 0 ) + '%' );
    }
  },
  ( error ) => console.error( 'Fallo al cargar', error )
);

El detalle de lengthComputable importa más de lo que parece: si tu servidor sirve el GLB con Transfer-Encoding: chunked y sin Content-Length —lo que ocurre por defecto con compresión gzip dinámica—, evento.total vale cero y tu barra de progreso no se mueve. La solución es servir los modelos precomprimidos con Content-Length correcto, o usar el número de bytes conocido de antemano.

El objeto gltf que devuelve el cargador tiene esta forma:

{
  scene: Group,          // la escena por defecto
  scenes: [ Group ],     // todas las escenas del fichero
  animations: [ AnimationClip ],
  cameras: [ Camera ],
  asset: { version, generator, copyright },
  parser: GLTFParser,    // acceso al JSON crudo y a la carga bajo demanda
  userData: {}
}
La descompresión va en workers, pero la subida a la GPU no, y ahí está el tirón que ves

Hay una creencia extendida de que activar Draco y KTX2 resuelve el problema del hilo principal porque «todo se hace en workers». La primera mitad es cierta: DRACOLoader tiene un pool de cuatro workers y KTX2Loader usa WorkerPool, así que la descompresión de vértices y la transcodificación de texturas ocurren fuera del hilo principal de verdad. Pero el trabajo no termina ahí, y lo que queda no se puede mover. Cuando el worker devuelve sus arrays tipados, el hilo principal tiene que construir los BufferAttribute, los BufferGeometry, los materiales y los Object3D, y sobre todo tiene que subirlo todo a la GPU, lo que ocurre en la primera llamada a render en la que ese objeto es visible. Las llamadas a gl.bufferData y gl.texImage2D son síncronas y bloqueantes en el hilo principal, y para un modelo de veinte megabytes de vértices y ocho texturas de 2K eso son entre 100 y 400 milisegundos de congelación en un portátil, y más de un segundo en un móvil. Encima, el mismo frame tiene que compilar los shaders de cada material nuevo, que es otro bloqueo del mismo orden. El síntoma es el clásico: la barra de progreso llega al cien por cien, y entonces la página se queda muerta un segundo antes de que aparezca el modelo. Las dos mitigaciones que funcionan de verdad son ambas de la API de Three.js y son poco conocidas. La primera es renderer.compileAsync( scene, camera, objeto ), que compila los shaders y sube los recursos de forma asíncrona, devolviendo una promesa, y permite hacer todo ese trabajo antes de añadir el objeto a la escena visible. La segunda es trocear: añadir el modelo por partes a lo largo de varios frames en lugar de todo a la vez, aceptando que aparezca progresivamente a cambio de no perder ni un frame. La regla que resume esto: el tiempo de carga percibido no termina cuando acaba la descarga, termina cuando se dibuja el primer frame con el modelo dentro, y la mitad de ese tiempo suele estar después del cien por cien de la barra.