Saltar al contenido

dispose de verdad: la fuga de memoria de GPU que mata la pestaña

Qué libera y qué no libera cada llamada, por qué scene.remove no toca la GPU, y una función de limpieza completa que recorre geometrías, materiales, texturas, render targets y esqueletos.

Por qué el recolector no puede ayudarte

Cuando creas una BufferGeometry y la usas en un render, el renderer pide a WebGL un buffer, sube los datos y guarda la correspondencia en un mapa interno. En JavaScript tienes un objeto BufferGeometry. En el driver hay un bloque de memoria de vídeo. Los dos están unidos por un número.

El recolector de basura de JavaScript sabe cuándo el objeto BufferGeometry deja de ser alcanzable, y libera sus arrays tipados. Lo que no sabe es que había un bloque de VRAM asociado, porque el recolector no tiene ninguna visibilidad sobre el driver ni forma de llamarlo. Ese bloque queda huérfano: nadie lo referencia y nadie lo libera. Se recupera solo cuando el contexto WebGL entero desaparece.

Esta es la razón de que Three.js tenga métodos dispose explícitos. No son una optimización: son la única vía de comunicación para decir “este recurso de GPU ya no hace falta”. Internamente, geometry.dispose() dispara un evento que el renderer escucha para llamar a gl.deleteBuffer sobre los buffers correspondientes.

JS
import * as THREE from 'three';const g = new THREE.BoxGeometry();const m = new THREE.MeshStandardMaterial({ map: unaTextura });const malla = new THREE.Mesh(g, m);scene.add(malla);renderer.render(scene, camera);scene.remove(malla);renderer.render(scene, camera);// El objeto ya no se dibuja, pero:console.log(renderer.info.memory.geometries); // sigue contando la geometriaconsole.log(renderer.info.memory.textures);   // sigue contando la textura

renderer.info.memory es la forma directa de comprobarlo. Si esos dos contadores no bajan al retirar objetos, tienes una fuga. Y como el contador es acumulativo, la prueba definitiva es montar y desmontar la escena veinte veces seguidas: si los números crecen linealmente, la limpieza está incompleta.

Qué libera cada dispose

El mapa de responsabilidades no es intuitivo, y las omisiones son siempre las mismas.

Los dos bloques rojos son los que se olvidan. Los recursos que no cuelgan de ningún objeto del grafo no aparecen si recorres la escena con traverse, porque traverse visita objetos y ellos no lo son. Y lo que está completamente fuera del grafo, como los workers de descompresión, ni siquiera es memoria de GPU: son hilos vivos que siguen consumiendo si no los cierras.

El punto crítico, el que hay que memorizar: material.dispose() no libera las texturas del material. Es deliberado, porque una textura se comparte entre materiales con frecuencia y liberarla desde uno rompería a los demás. La consecuencia práctica es que hay que recorrer las propiedades del material buscando texturas.

La función de limpieza completa

Esta es la implementación que hay que tener en el proyecto. Recorre un subárbol, libera todo lo que encuentra, y lleva un registro de lo ya liberado para no llamar dos veces sobre recursos compartidos.

JS
import * as THREE from 'three';/** * Libera todos los recursos de GPU de un subarbol y lo desconecta del padre. * @param {THREE.Object3D} raiz * @param {Set<object>} [vistos] recursos ya liberados, para compartidos */export function liberarSubarbol(raiz, vistos = new Set()) {  function liberarTextura(t) {    if (!t || !t.isTexture || vistos.has(t)) return;    vistos.add(t);    t.dispose();  }  function liberarMaterial(material) {    if (!material || vistos.has(material)) return;    vistos.add(material);    // Todas las propiedades del material que sean texturas:    // map, normalMap, roughnessMap, metalnessMap, aoMap, emissiveMap,    // alphaMap, envMap, lightMap, displacementMap, bumpMap, ...    for (const clave of Object.keys(material)) {      liberarTextura(material[clave]);    }    // ShaderMaterial y NodeMaterial guardan texturas en uniforms    if (material.uniforms) {      for (const nombre of Object.keys(material.uniforms)) {        const v = material.uniforms[nombre]?.value;        if (!v) continue;        if (v.isTexture) liberarTextura(v);        else if (v.isWebGLRenderTarget) v.dispose();        else if (Array.isArray(v)) v.forEach((x) => liberarTextura(x));      }    }    material.dispose();  }  raiz.traverse((objeto) => {    if (objeto.geometry && !vistos.has(objeto.geometry)) {      vistos.add(objeto.geometry);      objeto.geometry.dispose();    }    if (objeto.material) {      const materiales = Array.isArray(objeto.material)        ? objeto.material        : [objeto.material];      for (const m of materiales) liberarMaterial(m);    }    // SkinnedMesh: el esqueleto tiene su propia textura de huesos    if (objeto.isSkinnedMesh && objeto.skeleton) {      objeto.skeleton.dispose();    }    // InstancedMesh y BatchedMesh tienen dispose propio para sus buffers    if (typeof objeto.dispose === 'function' && objeto !== raiz) {      objeto.dispose();    }  });  raiz.removeFromParent();  return vistos;}

El Set de recursos vistos no es una optimización, es corrección. Llamar dos veces a dispose sobre la misma textura no rompe nada en Three.js, pero cuando la limpieza abarca varios subárboles que comparten una textura de entorno, el Set te deja pasar el mismo conjunto entre llamadas y garantizar que la textura compartida solo se libera cuando has terminado con todos.

El bucle sobre Object.keys(material) funciona porque los materiales de Three.js declaran todas sus propiedades en el constructor, incluidos los mapas que valen null. No hay que enumerar los nombres a mano ni mantener una lista que se queda obsoleta cada vez que sale una versión con un mapa nuevo.

Lo que queda fuera del subárbol

Con la función anterior tienes el grafo cubierto. Falta el resto, y aquí es donde una limpieza a medias sigue filtrando.

JS
import * as THREE from 'three';export function destruirEscena({ renderer, scene, composer, pmrem, loaders }) {  // 1. Parar el bucle antes que nada: un frame tardio sobre recursos  //    liberados produce errores de WebGL dificiles de rastrear.  renderer.setAnimationLoop(null);  // 2. El grafo completo  liberarSubarbol(scene);  // 3. Fondo y entorno: no son hijos de nadie  if (scene.background?.isTexture) scene.background.dispose();  if (scene.environment?.isTexture) scene.environment.dispose();  scene.background = null;  scene.environment = null;  scene.clear();  // 4. Post-procesado: cada pass puede tener render targets propios  if (composer) {    for (const pass of composer.passes) {      if (typeof pass.dispose === 'function') pass.dispose();    }    composer.dispose();  }  // 5. El generador de PMREM guarda su propio render target  if (pmrem) pmrem.dispose();  // 6. Los loaders con workers: DRACOLoader y KTX2Loader arrancan hilos  for (const l of loaders ?? []) {    if (typeof l.dispose === 'function') l.dispose();  }  // 7. La cache de ficheros descargados vive en un modulo global  THREE.Cache.clear();  // 8. El renderer: libera programas, listas de render y estado interno  renderer.dispose();  // 9. Y por ultimo, el contexto WebGL en si  renderer.forceContextLoss();  renderer.domElement.remove();}

Los tres puntos que la mayoría de los proyectos se saltan son el cuatro, el seis y el nueve.

El post-procesado es una fuente enorme de fuga porque cada EffectComposer guarda al menos dos render targets del tamaño de la pantalla. En una pantalla de retina eso son varias decenas de megabytes por composer. Montar y desmontar una ruta con post-procesado diez veces sin limpiar acerca la pestaña al límite de memoria de vídeo del dispositivo.

Los workers de los loaders comprimidos no son memoria de GPU, pero son hilos. DRACOLoader y KTX2Loader arrancan un pool de workers al primer uso y lo mantienen vivo. Su método dispose los termina. Sin él, cada montaje deja un puñado de hilos ociosos que ni el recolector ni el navegador cierran mientras la página viva.

El contexto es el punto más sutil. renderer.dispose() libera lo que el renderer gestiona, pero no cierra el contexto WebGL: el canvas sigue teniendo uno vivo. forceContextLoss() invoca la extensión que lo pierde deliberadamente, y solo entonces el navegador libera la asignación completa y descuenta uno del límite de contextos simultáneos. Después de llamarlo, el renderer queda inservible: es la última operación, no un reinicio.

Verificar que la limpieza funciona

Confiar en que la función está bien escrita no basta, porque el fallo típico es olvidar un recurso concreto de un modelo concreto. La verificación es objetiva y cabe en unas líneas.

JS
function instantanea(renderer) {  return {    geometrias: renderer.info.memory.geometries,    texturas: renderer.info.memory.textures,    programas: renderer.info.programs.length,  };}// En consola, tras montar y desmontar la escena N veces:console.table([instantanea(renderer)]);

El criterio es simple: los tres números tienen que volver al valor que tenían antes del montaje. Si vuelven a cero cuando la escena está desmontada, perfecto. Si se quedan en una cifra constante, tienes recursos compartidos legítimos y basta con comprobar que la cifra no crece. Si crecen con cada ciclo, hay fuga y la diferencia te dice de qué tipo.

Para localizar cuál es el recurso que se escapa, el truco es instrumentar temporalmente el prototipo:

JS
// Solo en desarrollo: registra quien crea geometrias y no las liberaconst vivas = new Set();const crearOriginal = THREE.BufferGeometry.prototype.dispose;THREE.BufferGeometry.prototype.dispose = function () {  vivas.delete(this);  return crearOriginal.call(this);};// Marcar en el momento de creacion es mas util con la traza de pilafunction marcar(g, etiqueta) {  g.userData.origen = etiqueta;  vivas.add(g);  return g;}// Tras desmontar:console.log([...vivas].map((g) => g.userData.origen));

Etiquetar en el punto de creación y listar lo que queda vivo tras el desmontaje convierte una búsqueda a ciegas en una lista concreta de culpables.

El resumen operativo

Cuatro reglas que evitan el noventa y nueve por ciento de los casos.

remove quita del grafo, dispose libera de la GPU. Son operaciones independientes y hacen falta las dos.

Un material no libera sus texturas. Recorre sus propiedades.

Lo que no cuelga del grafo (fondo, entorno, render targets, workers) no lo alcanza ningún traverse. Llévalo en una lista aparte desde el principio: un array de recursos a liberar al que se apunta todo lo que creas fuera del grafo cuesta una línea por recurso y elimina la categoría entera de olvidos.

Y comprueba con renderer.info después de montar y desmontar veinte veces. Es la única prueba que distingue una limpieza correcta de una que parece correcta.