El presupuesto de peso y de memoria de una experiencia 3D
Cómo calcular lo que ocupa una textura en la GPU, por qué el peso descargado no es el peso en memoria, y cómo fijar un presupuesto que se pueda verificar en cada despliegue.
Un modelo de dos megabytes puede ocupar doscientos en la memoria de vídeo, y esa desproporción es la causa de que muchas experiencias que se descargan rápido se caigan en un móvil de gama media. El peso en disco y la ocupación en GPU son dos magnitudes distintas que se relacionan por factores que dependen del formato, de la compresión y de los mipmaps. Presupuestar bien exige llevar las dos cuentas, y verificarlas automáticamente, porque el peso siempre crece: nunca ha habido un proyecto en el que alguien quitara una textura.
- Calcular la ocupación en GPU de una textura a partir de sus dimensiones y su formato.
- Explicar por qué un PNG comprimido ocupa lo mismo que un BMP en la memoria de vídeo.
- Fijar presupuestos de bytes descargados y de VRAM con cifras defendibles.
- Verificar el presupuesto en el proceso de construcción para que no se degrade solo.
Los dos presupuestos y por qué no se parecen
El peso descargado es lo que viaja por la red. Se mide en kilobytes tras compresión de transporte, y determina el tiempo hasta que la escena aparece. Es lo que ve una auditoría de rendimiento web.
La ocupación en GPU es lo que hay que caber en la memoria de vídeo. Determina si la escena funciona o si el navegador mata la pestaña. No aparece en ninguna auditoría web y casi nadie la mide.
La relación entre ambos es la que sorprende. Un JPEG de doscientos kilobytes que contiene una imagen de 2048 por 2048 se descomprime en memoria a 2048 × 2048 × 4 bytes, es decir dieciséis megabytes y ocho décimas. Con mipmaps, unos veintidós. El factor entre lo descargado y lo ocupado es de más de cien.
La razón es que los formatos de imagen web comprimen para transporte, no para muestreo. La GPU necesita acceder a cualquier téxel en tiempo constante para muestrear, y un JPEG no lo permite: hay que decodificarlo entero a píxeles crudos antes de subirlo.
/**
* Bytes que ocupa una textura en memoria de video.
* @param {number} ancho
* @param {number} alto
* @param {number} bytesPorTexel 4 para RGBA de 8 bits, 8 para RGBA de 16 bits
* @param {boolean} conMipmaps
*/
export function bytesEnGPU(ancho, alto, bytesPorTexel = 4, conMipmaps = true) {
const base = ancho * alto * bytesPorTexel;
return conMipmaps ? Math.ceil(base * 4 / 3) : base;
}
console.log(bytesEnGPU(4096, 4096)); // ~89,5 MB
console.log(bytesEnGPU(2048, 2048)); // ~22,4 MB
console.log(bytesEnGPU(1024, 1024)); // ~5,6 MB
console.log(bytesEnGPU(2048, 2048, 8)); // ~44,7 MB en HDR de 16 bits
El factor cuatro tercios de los mipmaps sale de la serie geométrica: cada nivel ocupa un cuarto del anterior, y la suma infinita converge a un tercio adicional sobre el nivel base.
La conclusión operativa es que duplicar el lado de una textura cuadruplica su coste. Pasar de 1024 a 2048 no es “un poco más de calidad”, es 17 megabytes más de memoria de vídeo por textura. Con seis mapas por material y cuatro materiales, ese salto son cuatrocientos megabytes.
Los formatos comprimidos y lo que cambian
Los formatos de compresión de bloque (BC en escritorio, ASTC y ETC en móvil) se muestrean directamente comprimidos: la GPU descomprime cada bloque de cuatro por cuatro téxeles en el momento del acceso. Eso significa que la textura ocupa comprimida en memoria, y ahí está el ahorro real.
| Formato | Bits por téxel | 2048 × 2048 con mipmaps |
|---|---|---|
| RGBA sin comprimir | 32 | ~22,4 MB |
| BC7 / ASTC 4x4 | 8 | ~5,6 MB |
| BC1 / ETC1, sin alfa | 4 | ~2,8 MB |
| ASTC 8x8 | 2 | ~1,4 MB |
La forma de llegar a esos formatos en la web es KTX2 con Basis Universal: un contenedor que guarda la textura en una representación intermedia y la transcodifica en el cliente al formato que soporte esa GPU concreta. Un solo fichero sirve para todas las plataformas.
import * as THREE from 'three';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/') // los ficheros del transcodificador
.detectSupport(renderer); // consulta que formatos soporta la GPU
const loader = new GLTFLoader();
loader.setKTX2Loader(ktx2);
const gltf = await loader.loadAsync('/modelos/escena.glb');
detectSupport(renderer) es imprescindible: sin él, el transcodificador no sabe a qué formato convertir y cae a RGBA sin comprimir, con lo que pierdes todo el ahorro de memoria y encima pagas el coste de transcodificar. Es un fallo silencioso, porque la escena se ve igual.
Y dispose() sobre el KTX2Loader al desmontar, porque arranca workers, como se explica en dispose de verdad.
KTX2 con Basis suele pesar más en disco que el JPEG equivalente, porque la representación intermedia no está optimizada para compresión de transporte. Es normal ver un KTX2 de trescientos kilobytes donde el JPEG pesaba ciento cincuenta. El intercambio es correcto: pagas ancho de banda una vez y ahorras memoria de vídeo durante toda la sesión, además de evitar la decodificación en el hilo principal. Pero conviene saberlo antes de que alguien mire la pestaña de red y pregunte por qué las texturas han engordado.
La geometría, que también pesa
La geometría suele ser un orden de magnitud menor que las texturas, pero no es despreciable en escenas con muchos vértices.
/** Bytes que ocupa una BufferGeometry en la GPU */
export function bytesGeometria(geometria) {
let total = 0;
if (geometria.index) total += geometria.index.array.byteLength;
for (const nombre of Object.keys(geometria.attributes)) {
total += geometria.attributes[nombre].array.byteLength;
}
return total;
}
Un vértice con posición, normal y coordenadas de textura en flotantes de 32 bits ocupa treinta y dos bytes: doce más doce más ocho. Un millón de vértices son treinta y dos megabytes, más los índices. Si además hay tangentes y colores por vértice, se acerca a cincuenta bytes por vértice.
Dos reducciones que casi nadie aplica y que son gratis en calidad:
Cuantizar los atributos. Las normales no necesitan flotantes de 32 bits: caben perfectamente en enteros de 8 bits normalizados, con lo que pasan de doce bytes a cuatro. Las coordenadas de textura caben en 16 bits. La herramienta gltf-transform hace la cuantización en el paso de construcción y GLTFLoader lee el resultado sin cambios en tu código.
Indexar de verdad. Una geometría sin índice repite cada vértice compartido. mergeVertices de BufferGeometryUtils fusiona los duplicados y genera el índice, lo que en una malla típica reduce el conteo de vértices entre un cuarto y un tercio.
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
const indexada = BufferGeometryUtils.mergeVertices(geometria, 1e-4);
console.log(geometria.attributes.position.count, '->', indexada.attributes.position.count);
El segundo argumento es la tolerancia de la comparación. Demasiado alta fusiona vértices que debían estar separados y produce costuras suaves donde había aristas duras; demasiado baja no fusiona nada por errores de coma flotante. El valor por defecto es razonable para modelos en unidades de metros.
Fijar el presupuesto
Los números que siguen son puntos de partida defendibles para una experiencia web pensada para funcionar en un móvil de gama media de hace tres años, que es el dispositivo que hay que soportar si el proyecto no dice lo contrario.
| Concepto | Presupuesto | Nota |
|---|---|---|
| JavaScript de la ruta crítica | 150 KB comprimido | Sin contar el motor 3D |
| Motor 3D, cargado en diferido | 200 KB comprimido | Three con los addons usados |
| Modelos, total descargado | 3 MB | Con Draco o Meshopt |
| Texturas, total descargado | 5 MB | KTX2, cargadas por prioridad |
| VRAM total de texturas | 150 MB | El límite que de verdad importa |
| VRAM de geometría | 40 MB | |
| Render targets | 60 MB | Post-proceso y sombras |
| Triángulos visibles | 500 000 | Tras culling |
| Llamadas de dibujo | 150 |
El presupuesto de VRAM es el que hay que vigilar con más celo, porque es el que produce fallos duros: cuando se agota, el navegador no ralentiza, mata la pestaña. Y no hay ninguna API que te diga cuánta memoria de vídeo hay disponible; solo puedes contar lo que subes.
/** Suma la ocupacion en GPU de todo lo que hay en una escena */
export function auditarMemoria(scene) {
const texturas = new Set();
const geometrias = new Set();
scene.traverse((o) => {
if (o.geometry) geometrias.add(o.geometry);
const mats = o.material
? (Array.isArray(o.material) ? o.material : [o.material])
: [];
for (const m of mats) {
for (const clave of Object.keys(m)) {
const v = m[clave];
if (v && v.isTexture && v.image) texturas.add(v);
}
}
});
let bytesTex = 0;
for (const t of texturas) {
const w = t.image.width ?? t.image.videoWidth ?? 0;
const h = t.image.height ?? t.image.videoHeight ?? 0;
// Aproximado: no distingue formatos comprimidos
bytesTex += bytesEnGPU(w, h, 4, t.generateMipmaps);
}
let bytesGeo = 0;
for (const g of geometrias) bytesGeo += bytesGeometria(g);
return {
texturas: texturas.size,
geometrias: geometrias.size,
mbTexturas: +(bytesTex / 1048576).toFixed(1),
mbGeometria: +(bytesGeo / 1048576).toFixed(1),
};
}
Esta función es una estimación, no una medida exacta: no conoce el formato real tras la transcodificación y asume RGBA. Sirve perfectamente para lo que hace falta, que es detectar cuándo alguien ha metido una textura de 4096 sin darse cuenta.
El presupuesto que no se verifica automáticamente se incumple siempre, sin excepción y en pocas semanas. Nadie sube una textura de 4096 a propósito: la sube porque el modelo venía así, porque tenía prisa, o porque en su portátil con dieciséis gigas de memoria de vídeo no se nota. La única defensa que funciona es un paso en la integración continua que recorra la carpeta de recursos, calcule la ocupación estimada según las dimensiones de cada imagen y falle la construcción si supera el límite. Cuesta treinta líneas de script, se escribe una vez, y convierte una discusión recurrente sobre disciplina en un número objetivo que sale en el registro del despliegue. La segunda mejor defensa, si no puedes tocar la integración continua, es un panel en desarrollo que muestre la auditoría en pantalla y se ponga rojo al pasarse: no bloquea, pero la culpa visible funciona sorprendentemente bien.
Cargar por prioridad
Fijado el presupuesto, queda repartirlo en el tiempo. No todo hace falta en el primer fotograma, y cargar por prioridad es lo que permite que una experiencia con quince megabytes de recursos aparezca en dos segundos.
Tres niveles funcionan bien. El nivel cero es lo mínimo para que haya algo en pantalla: la geometría principal con una textura de baja resolución o un material plano. Debe caber en menos de un megabyte. El nivel uno son las texturas de resolución completa de lo que está en cámara. Y el nivel dos es todo lo demás, cargado en segundo plano mientras el usuario ya está interactuando.
async function cargarPorPrioridad(scene, camera) {
const base = await cargarModelo('/modelos/escena-base.glb');
scene.add(base);
arrancarBucle(); // el usuario ya ve e interactua
const visibles = objetosEnFrustum(base, camera);
await Promise.all(visibles.map((o) => subirTexturaCompleta(o)));
// El resto, sin bloquear y con baja prioridad
const resto = todosLosDemas(base, visibles);
for (const o of resto) {
await new Promise((r) => requestIdleCallback(r, { timeout: 500 }));
await subirTexturaCompleta(o);
}
}
requestIdleCallback entre cargas hace que el trabajo de fondo no compita con la interacción del usuario. El timeout garantiza que se ejecute aunque el navegador nunca esté ocioso, lo que ocurre si la escena está animando constantemente.
Este patrón tiene un efecto secundario valioso: si el usuario se va antes de que termine, nunca has descargado los megabytes que no le hacían falta. En una conexión medida eso es una cortesía real, y en tu factura de ancho de banda también.
Ejecuta la auditoría de memoria sobre una escena tuya y compara la cifra con el peso descargado que muestra la pestaña de red. Anota el factor entre ambos. Después convierte las texturas a KTX2 con detectSupport bien configurado y vuelve a auditar: el peso descargado probablemente suba un poco y la ocupación en GPU baje entre un setenta y cinco y un noventa por ciento. Ese contraste es el argumento que necesitas la próxima vez que alguien proponga juzgar una experiencia 3D solo por su peso descargado.