wandres.dev
TEXTURAS III · Espacios de color y compresión

KTX2 y Basis Universal: el ahorro real de VRAM

Por qué un JPEG de 400 kilobytes ocupa 22 megabytes en la tarjeta gráfica, qué hace la compresión de bloques, y cómo se carga KTX2 en Three.js con números concretos.

⏱ 19 min

Un JPEG comprime para la red y se descomprime para usarse: en memoria de vídeo ocupa lo mismo que un BMP. La compresión de bloques comprime para la GPU y no se descomprime nunca: el hardware lee directamente los bloques comprimidos en cada muestreo. Son dos problemas distintos, se resuelven con formatos distintos, y confundirlos es la razón por la que tantas escenas web con texturas “optimizadas” se quedan sin memoria en móvil.

🎯 Al terminar esta lección sabrás
  • Calcular la memoria de vídeo que ocupa una textura en cada formato.
  • Distinguir compresión de transporte de compresión de bloques.
  • Explicar qué resuelve Basis Universal y por qué existe el transcodificado.
  • Montar KTX2Loader solo y junto a GLTFLoader.

La cuenta que nadie hace

Una textura de 2048 por 2048 píxeles en RGBA de ocho bits ocupa:

2048 x 2048 x 4 bytes = 16.777.216 bytes = 16,8 MB

Con la cadena de mipmaps, que añade un tercio, son 22,4 MB. Y eso vale igual si el fichero de origen era un PNG de 3 MB o un JPEG de 400 KB: los dos se decodifican a píxeles sueltos antes de subirse.

Un modelo con siete texturas de ese tamaño, que es lo normal en un objeto PBR completo con color, normales, ORM, emisivo y alguna capa más, ocupa 157 MB de memoria de vídeo. Un móvil de gama media con 4 GB de RAM compartida no aguanta muchos de esos, y el navegador no avisa: simplemente el contexto WebGL se pierde y la pantalla se queda en negro.

Compresión de transporte y compresión de bloques

JPEG, PNG, WebP y AVIF comprimen para el transporte. Su algoritmo mira la imagen entera, o bloques grandes con dependencias entre ellos, y para leer un píxel hay que haber decodificado su contexto. Eso los hace excelentes para la red y absolutamente inservibles para la GPU, que necesita leer texels arbitrarios en cualquier orden, millones de veces por fotograma.

BC1 a BC7, ETC1, ETC2, ASTC y PVRTC comprimen para la GPU. Dividen la imagen en bloques independientes, típicamente de 4 por 4 téxeles, y guardan cada bloque en un número fijo de bits con un esquema que el hardware sabe descomprimir en un ciclo. Como cada bloque es independiente y de tamaño fijo, la GPU puede saltar directamente al bloque que contiene el téxel que necesita.

Las tasas típicas:

formato bits por téxel 2048 x 2048 con mipmaps
RGBA8 sin comprimir 32 22,4 MB
BC7 o ASTC 4x4 8 5,6 MB
BC1 o ETC1 4 2,8 MB

Es decir, entre cuatro y ocho veces menos memoria, y además menos ancho de banda en cada lectura, lo que suele traducirse en una mejora medible de fotogramas por segundo en escenas limitadas por texturas.

El problema de la fragmentación, y Basis

Si los formatos de bloques son tan buenos, ¿por qué no se usan siempre? Porque ninguno funciona en todas partes. El escritorio soporta la familia BC, casi todo el móvil soporta ETC2, ASTC está muy extendido pero no es universal, y PVRTC es cosa de hardware antiguo de Apple. Servir un fichero por formato significa mantener tres o cuatro variantes de cada textura y detectar el soporte en el cliente.

Basis Universal resuelve eso con un formato intermedio. El fichero guarda la textura en una representación propia que no es ninguno de los formatos de GPU, pero que está diseñada para poder convertirse a cualquiera de ellos muy rápido y sin descomprimir a píxeles. Esa conversión se llama transcodificado, ocurre en el cliente, la hace un módulo WebAssembly, y produce directamente los bloques del formato que soporte esa GPU concreta.

KTX2 es el contenedor estandarizado que envuelve todo eso: guarda la carga útil, la cadena de mipmaps, el espacio de color, el formato y los metadatos. Admite dos modos de Basis, ETC1S, muy comprimido y con pérdida notable, orientado a texturas de color; y UASTC, de más calidad y ficheros más grandes, orientado a mapas de normales y de datos donde la pérdida se nota mucho más. También admite formatos ya comprimidos en un formato de GPU concreto, y formatos sin comprimir que se cargan como DataTexture.

Sobre el tamaño de fichero: un ETC1S de 2048 por 2048 suele quedar en un rango parecido al de un JPEG de calidad media, así que la descarga no empeora. Lo que cambia radicalmente es lo que ocurre después: ese fichero se transcodifica a bloques y ocupa entre 2,8 y 5,6 MB en la tarjeta, en lugar de 22,4.

Cargarlo en Three.js

KTX2Loader vive en los addons y necesita tres cosas: la ruta al transcodificador WebAssembly, que se distribuye con Three.js en examples/jsm/libs/basis/; y el renderer, para detectar qué formatos soporta la GPU.

import * as THREE from 'three';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });

const ktx2 = new KTX2Loader()
  .setTranscoderPath('/jsm/libs/basis/')
  .setPath('/texturas/')
  .detectSupport(renderer);

const albedo = await ktx2.loadAsync('caja_color.ktx2');
const material = new THREE.MeshStandardMaterial({ map: albedo });

El detectSupport(renderer) es obligatorio: sin él, el cargador no sabe a qué formato transcodificar. Y el espacio de color no hay que ponerlo a mano, porque el contenedor KTX2 lo lleva declarado y el cargador lo aplica.

Con modelos glTF, la integración es una línea. La extensión que declara texturas Basis en glTF se llama KHR_texture_basisu:

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const ktx2 = new KTX2Loader()
  .setTranscoderPath('/jsm/libs/basis/')
  .detectSupport(renderer);

const gltf = new GLTFLoader().setPath('/modelos/');
gltf.setKTX2Loader(ktx2);

gltf.load('coche.glb', (resultado) => scene.add(resultado.scene));

Para producir los ficheros, la herramienta del ecosistema es gltf-transform, que reempaqueta un glTF entero convirtiendo sus texturas a KTX2 con el modo adecuado para cada una. La regla al configurarla: ETC1S para color y emisivo, UASTC para normales, ORM y cualquier mapa de datos.

La compresión de bloques con pérdida arruina los mapas de normales, y arruina más los de datos

La compresión de bloques es con pérdida, y esa pérdida no es uniforme: cada bloque de 4 por 4 se aproxima con dos colores extremos y una interpolación entre ellos. En una textura de color, el error queda enmascarado por el propio detalle de la imagen. En un mapa de normales, ese mismo error mueve vectores, y el resultado son facetas de 4 por 4 píxeles perfectamente visibles en los reflejos de una superficie pulida.

Por eso existen dos modos. ETC1S es agresivo y barato, correcto para color. UASTC conserva mucho más y es lo que hay que usar para normales, aunque el fichero pese varias veces más. Usar ETC1S para todo es el error clásico de la primera migración a KTX2: el color queda bien, las normales quedan facetadas, y como el aspecto general “casi funciona”, la causa tarda en localizarse.

Con los mapas de datos empaquetados hay un problema añadido y más grave. Un ORM guarda tres señales independientes en los tres canales, y los formatos de bloques asumen que los canales están correlacionados, porque en una imagen natural lo están. Al comprimir un ORM, el error de un canal se filtra a los otros: la rugosidad contamina la metalicidad. En un mapa de metalicidad que debería ser binario, esa contaminación produce exactamente los valores intermedios que sabes que no deben existir.

La consecuencia operativa: comprueba siempre visualmente el resultado de la compresión de un ORM antes de darlo por bueno, y si el material tiene zonas metálicas y no metálicas contiguas, plantéate separar la metalicidad en su propia textura o subir la calidad. Un ahorro de VRAM que introduce materiales físicamente imposibles no es una optimización, es un cambio de aspecto.

Cuándo no compensa

Tres casos donde KTX2 no es la respuesta.

Pocas texturas y pequeñas. Si tu escena tiene tres texturas de 512, la memoria total son 4 MB y el transcodificador WebAssembly pesa más que lo que ahorras.

Texturas generadas en tiempo de ejecución. Un CanvasTexture o un render target no pasan por ningún fichero, así que no hay nada que comprimir por adelantado.

Interfaz y elementos con texto. La compresión de bloques destroza los bordes de alto contraste, y un texto comprimido con ETC1S se ve sucio. Para eso, PNG y filtrado sin mipmaps.

Fuera de esos tres, en cualquier escena con modelos reales, la conversión a KTX2 es de las optimizaciones con mejor relación entre esfuerzo y resultado que existen: divide la memoria de vídeo entre cuatro u ocho, no toca el código de la escena, y la única decisión de diseño es elegir el modo correcto para cada tipo de mapa.

⚔️ Reto práctico

Toma un modelo glTF con texturas PNG, mide la memoria de vídeo que consume con la información de renderer.info.memory, y conviértelo a KTX2 con ETC1S para el color y UASTC para las normales. Vuelve a medir y comprueba que la reducción está en el rango de cuatro a ocho veces. Después convierte también las normales a ETC1S y busca las facetas de 4 por 4 en una superficie pulida.