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

Un pipeline real de optimización, con los números

Un modelo de 68 MB llevado a 3.4 MB paso a paso, con el comando exacto de cada etapa, lo que ahorra cada uno y cómo verificar que no has roto nada.

⏱ 21 min

Todo lo anterior son piezas sueltas. Esto es el orden en que se aplican, con un caso concreto: un modelo de interior descargado de una biblioteca, 68 MB en el fichero original, que acaba en 3.4 MB sin que se note la diferencia en pantalla. Cada etapa lleva su comando exacto y su ahorro medido, y el orden importa: aplicar los mismos comandos al revés da un resultado peor y en algunos casos ni funciona.

🎯 Al terminar esta lección sabrás
  • Diagnosticar un modelo con gltf-transform inspect y decidir dónde está el peso.
  • Ejecutar el pipeline completo de ocho etapas con los comandos exactos.
  • Justificar por qué cada etapa va donde va.
  • Verificar que la optimización no ha roto nada, con criterios objetivos.

El punto de partida

npm install --global @gltf-transform/cli
gltf-transform inspect salon.glb

La salida, resumida:

scenes      1
meshes      214 primitives, 486k vertices, 892k triangles
materials   47
textures    31   (18x 4096², 9x 2048², 4x 1024²)   -- 61.2 MB
animations  0
extensions  KHR_materials_specular, KHR_texture_transform
total       68.4 MB

El diagnóstico está en la tercera línea: las texturas son el 89 % del peso. Dieciocho texturas a 4K en un interior que se ve desde tres metros. Y 214 primitivas, es decir 214 draw calls como suelo. La geometría, 7.2 MB, es el problema menor.

El pipeline, etapa por etapa

Etapa 1: quitar lo que no se usa

gltf-transform prune salon.glb p1.glb --keep-attributes false
gltf-transform dedup p1.glb p2.glb

prune elimina nodos vacíos, materiales huérfanos, texturas no referenciadas y atributos de vértice que ningún material lee —el --keep-attributes false es lo que activa esto último y suele quitar segundos juegos de UVs y colores de vértice que nadie usa—. dedup fusiona recursos idénticos: dos texturas byte a byte iguales pasan a ser una.

68.4 MB → 51.1 MB. Un 25 % sin tocar nada visible. En modelos de biblioteca este primer paso casi siempre da un ahorro grande, porque acumulan restos de la exportación.

Etapa 2: redimensionar texturas

La pregunta previa: ¿cuántos píxeles de pantalla ocupa la textura más grande en la vista más cercana? En este interior, ninguna superficie ocupa más de 900 píxeles de alto. Con 1024 sobra.

gltf-transform resize p2.glb p3.glb --width 1024 --height 1024

51.1 MB → 12.8 MB. El ahorro más grande de todo el pipeline, y viene de un solo comando. Cada reducción a la mitad divide por cuatro.

💡
Redimensiona por slot cuando haga falta

--slots permite tratar cada tipo de mapa por separado. Un mapa de normales tolera peor la reducción que un ORM, y un mapa de rugosidad casi siempre se puede bajar a 512 sin que nadie lo note: gltf-transform resize entrada.glb salida.glb --slots "metallicRoughnessTexture" --width 512 --height 512.

Etapa 3: reducir primitivas

gltf-transform palette p3.glb p4.glb --min 5
gltf-transform join p4.glb p5.glb

palette detecta materiales que solo se diferencian en el color base, los sustituye por uno solo con una textura de paleta diminuta y remapea las UVs. join fusiona primitivas que comparten material y no tienen transformación propia.

214 primitivas → 23 primitivas. 47 materiales → 9 materiales. El peso apenas cambia —12.8 MB → 12.6 MB—, pero los draw calls bajan un 89 %, que en móvil es lo que decide si la escena va a treinta o a sesenta.

Esta etapa va antes de comprimir por una razón concreta: fusionar mallas ya comprimidas obligaría a descomprimir y recomprimir, y join no lo hace.

Etapa 4: comprimir texturas

# Color y ORM en ETC1S
gltf-transform etc1s p5.glb p6.glb --slots "!normalTexture" --quality 200

# Normales en UASTC, que tolera mal ETC1S
gltf-transform uastc p6.glb p7.glb --slots "normalTexture" --level 4 --zstd 18

12.6 MB → 4.9 MB. Y lo que no se ve en el peso: la VRAM de texturas pasa de 112 MB a 28 MB.

Etapa 5: optimizar y comprimir geometría

gltf-transform weld p7.glb p8.glb
gltf-transform reorder p8.glb p9.glb
gltf-transform meshopt p9.glb p10.glb --level high

weld fusiona vértices duplicados dentro de una tolerancia, lo que restaura el indexado que muchos exportadores destruyen. reorder reordena vértices e índices para la caché post-transformada de la GPU. meshopt cuantiza y codifica.

El orden de estos tres no es negociable: weld tiene que ir antes de reorder porque reordenar una malla sin indexar no sirve de nada, y los dos antes de meshopt porque la compresión congela el orden.

4.9 MB → 3.4 MB. Con Brotli en el servidor, el fichero que viaja son unos 2.9 MB.

El resultado

Etapa Comando Tamaño Δ
Original 68.4 MB
Limpieza prune + dedup 51.1 MB -25 %
Redimensionado resize 1024 12.8 MB -75 %
Draw calls palette + join 12.6 MB -2 %
Texturas etc1s + uastc 4.9 MB -61 %
Geometría weld+reorder+meshopt 3.4 MB -31 %

De 68.4 a 3.4 MB: un factor de 20. Y las métricas que no están en la tabla:

Antes Después
Draw calls 214 23
VRAM de texturas 448 MB 28 MB
Tiempo hasta el primer frame, portátil 9.4 s 1.1 s
Tiempo hasta el primer frame, móvil 4G 71 s 5.8 s

La última fila es el argumento entero. Un modelo de 68 MB en una conexión móvil no es un modelo lento: es un modelo que nadie va a ver, porque el usuario se ha ido.

El script completo y la verificación

Encadenarlo todo

Encadenado, para meterlo en el package.json:

#!/usr/bin/env bash
set -e
IN="$1"; OUT="$2"; TMP=$(mktemp -d)

gltf-transform prune   "$IN"          "$TMP/1.glb" --keep-attributes false
gltf-transform dedup   "$TMP/1.glb"   "$TMP/2.glb"
gltf-transform resize  "$TMP/2.glb"   "$TMP/3.glb" --width 1024 --height 1024
gltf-transform palette "$TMP/3.glb"   "$TMP/4.glb" --min 5
gltf-transform join    "$TMP/4.glb"   "$TMP/5.glb"
gltf-transform etc1s   "$TMP/5.glb"   "$TMP/6.glb" --slots '!normalTexture' --quality 200
gltf-transform uastc   "$TMP/6.glb"   "$TMP/7.glb" --slots 'normalTexture' --level 4 --zstd 18
gltf-transform weld    "$TMP/7.glb"   "$TMP/8.glb"
gltf-transform reorder "$TMP/8.glb"   "$TMP/9.glb"
gltf-transform meshopt "$TMP/9.glb"   "$OUT"       --level high

gltf-transform inspect "$OUT"
rm -rf "$TMP"

Existe también gltf-transform optimize, que ejecuta una secuencia parecida en un solo comando. Es un buen punto de partida, pero no acierta con la resolución de las texturas —no puede saber a qué distancia se ve tu modelo— ni separa ETC1S de UASTC por slot. Para producción, el script explícito da mejor resultado.

Verificar que no has roto nada

Tres comprobaciones, en este orden:

Cuenta de triángulos idéntica. weld puede reducir vértices pero nunca triángulos. Si bajan, algo ha eliminado geometría y hay que revisar prune.

Comparación visual de capturas. Renderiza el antes y el después desde la misma cámara con la misma iluminación y compara las dos imágenes. Los tres fallos que aparecen aquí son facetado por cuantización agresiva, bandas en el sombreado por normales en ETC1S, y texturas borrosas por redimensionado excesivo.

Extensiones requeridas. gltf-transform inspect sobre el resultado tiene que listar EXT_meshopt_compression y KHR_texture_basisu en las extensiones, y tu cargador tiene que tener conectados los decodificadores correspondientes. Si te falta uno, el modelo no carga y el error es explícito.

El pipeline es determinista, así que tiene que estar en el repositorio, no en tu portátil

La forma en que muere un pipeline de optimización es siempre la misma y no tiene nada que ver con la técnica. Alguien optimiza el modelo a mano, sube el resultado, y los comandos exactos viven en el historial de su terminal. Seis meses después llega una versión nueva del modelo, esa persona ya no está, y nadie sabe si las texturas iban a 1024 o a 2048, ni si las normales llevaban UASTC o ETC1S. El resultado práctico es que la versión nueva se sube sin optimizar «provisionalmente», y ahí se queda. La disciplina que lo evita es tratar los modelos exactamente igual que el código fuente: el .glb sin optimizar es el fuente, el optimizado es el artefacto de construcción, y el artefacto no se versiona. El repositorio guarda el modelo crudo o —mejor, porque pesa mucho— un puntero a él en Git LFS o en un bucket, más el script de optimización con sus parámetros. La construcción ejecuta el script. Con eso, la respuesta a «¿por qué esta textura está borrosa?» pasa de ser arqueología a ser una línea de un fichero que se puede leer, discutir en una revisión y cambiar. Y hay un beneficio secundario que aparece enseguida: cuando los parámetros están en un fichero, es trivial generar varias variantes del mismo modelo con una sola línea más —una versión a 512 para móvil, otra a 2048 para escritorio— y servir la que toque según el dispositivo. Ese nivel de adaptación es imposible si la optimización fue un acto manual e irrepetible, y es exactamente lo que separa una web 3D que funciona en todo de una que solo funciona en el portátil del que la hizo.