wandres.dev
PRODUCCIÓN · Carga, accesibilidad y fallback

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.

⏱ 20 min

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.

🎯 Al terminar esta lección sabrás
  • 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.

⚠️
Cuidado

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.

Nivel dios

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.

⚔️ Reto práctico

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.