Saltar al contenido

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.

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.

JS
/** * 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 MBconsole.log(bytesEnGPU(2048, 2048));           // ~22,4 MBconsole.log(bytesEnGPU(1024, 1024));           // ~5,6 MBconsole.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.

JS
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 GPUconst 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.

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.

JS
/** 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.

JS
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.

JS
/** 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.

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.

JS
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.