Saltar al contenido

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.

El montaje completo

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

Shell
cp -r node_modules/three/examples/jsm/libs/draco public/dracocp -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:

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

Cargar con progreso y con errores

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

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

JS
{  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: {}}