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
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/dracocp -r node_modules/three/examples/jsm/libs/basis public/basisCargarlos 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.
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: {}}