renderer.info: qué significa cada contador
El objeto de estadísticas de Three.js, la diferencia entre el de WebGLRenderer y el de WebGPURenderer, qué cuenta cada campo exactamente y cuál de ellos delata cada tipo de problema.
Antes de tocar nada hay que saber qué está pasando, y el primer sitio donde mirar es un objeto que Three.js mantiene actualizado sin que se lo pidas y que casi nadie consulta. renderer.info no dice cuánto tarda nada —para eso hacen falta otras herramientas— pero dice cuánto trabajo se está pidiendo, que es la mitad del diagnóstico. Y sus campos no son los mismos en los dos renderers, lo que produce código que falla en silencio al migrar.
- Leer los contadores de render y de memoria y saber qué representa cada uno.
- Distinguir la estructura de
infoenWebGLRenderery enWebGPURenderer. - Controlar el ciclo de reinicio con
autoResetyreset(). - Relacionar cada contador con la clase de problema que delata.
La estructura en WebGLRenderer
console.log( renderer.info );
// {
// memory: { geometries, textures },
// render: { frame, calls, triangles, points, lines },
// programs: [ ... ],
// autoReset: true
// }
render.calls es el número de draw calls del frame. Cada llamada a drawArrays o drawElements cuenta una, y en la práctica hay una por cada objeto renderizable con su material, más las de las sombras, más las de cada pase de post-proceso. Es el contador más informativo de todos.
render.triangles cuenta triángulos enviados, no dibujados. Se calcula multiplicando el número de instancias por el número de índices dividido entre tres, sin saber nada de si esos triángulos se descartan por estar fuera de pantalla, por mirar hacia atrás o por quedar detrás de otra cosa. Un millón de triángulos aquí significa que has pedido procesar un millón de vértices, aunque solo se vean cien.
render.points y render.lines hacen lo propio para las primitivas correspondientes.
render.frame es un contador acumulado de frames desde el arranque. No se reinicia. Sirve para etiquetar mediciones y para hacer cosas cada N frames.
memory.geometries y memory.textures son los recursos activos, no los creados. Suben al subir un recurso a la GPU y bajan al llamar a dispose(). Son el detector de fugas más directo que existe: si crece monótonamente mientras navegas por una aplicación, tienes recursos sin liberar.
programs es un array de los programas de shader compilados. Su longitud importa mucho más de lo que parece: cada programa es una compilación del driver que puede costar decenas o cientos de milisegundos la primera vez que se usa. Cien programas para veinte materiales es señal de que estás generando variantes sin darte cuenta —normalmente por clonar materiales en lugar de compartirlos, o por cambiar propiedades que fuerzan recompilación.
La estructura en WebGPURenderer
Aquí está la trampa de la migración. WebGPURenderer tiene un objeto info mucho más rico y con nombres distintos:
// {
// render: { calls, frameCalls, drawCalls, triangles, points, lines, timestamp },
// compute: { calls, frameCalls, timestamp },
// memory: { geometries, textures, attributes, indexAttributes,
// storageAttributes, indirectStorageAttributes, readbackBuffers,
// programs, renderTargets, total,
// texturesSize, attributesSize, ... },
// frame, calls, autoReset
// }
La diferencia que rompe código es esta: el equivalente de render.calls de WebGL es render.drawCalls en WebGPU. En el renderer moderno, render.calls cuenta llamadas a renderer.render() acumuladas desde el arranque, y render.frameCalls las de este frame. El código que imprime renderer.info.render.calls esperando draw calls no falla: muestra un número que crece indefinidamente y que no significa lo que crees.
// Una funcion que sirve para los dos.
function drawCalls( renderer ) {
const r = renderer.info.render;
return r.drawCalls !== undefined ? r.drawCalls : r.calls;
}
Lo que gana WebGPURenderer a cambio es información que en WebGL no existe:
memory.total y los campos con sufijo Size dan el consumo en bytes de texturas, atributos, buffers de lectura y programas. Es una estimación —calculada a partir del formato y las dimensiones, con una aproximación para los mipmaps y un valor fijo para las texturas comprimidas— pero es infinitamente más útil que un simple recuento. Saber que tus texturas ocupan 380 MB es accionable; saber que hay 47 texturas no lo es.
compute cuenta los despachos de cómputo, separando los acumulados de los del frame.
timestamp contiene el tiempo de GPU medido con consultas de marca temporal, cuando están disponibles y activadas.
autoReset y el ciclo
Por defecto, info.autoReset está a true y los contadores del frame se ponen a cero al empezar cada render(). Eso significa que si lees renderer.info antes de renderizar obtienes los datos del frame anterior, y si lo lees después obtienes los de este.
function animate() {
renderer.render( scene, camera );
// Aqui los contadores corresponden al frame que acabamos de dibujar.
panel.textContent = `${ drawCalls( renderer ) } dc / ${ renderer.info.render.triangles | 0 } tri`;
}
El problema aparece cuando renderizas varias veces por frame: un pase de sombras, un render target para reflejos, una cadena de post-proceso. Con el reinicio automático, cada render() borra lo del anterior y solo ves las estadísticas del último.
// Acumular el frame completo.
renderer.info.autoReset = false;
function animate() {
renderer.info.reset(); // una vez, al principio del frame
renderer.setRenderTarget( objetivoReflejo );
renderer.render( scene, camaraReflejo );
renderer.setRenderTarget( null );
renderer.render( scene, camera );
composer.render();
// Ahora info contiene la suma de los tres.
medir( renderer.info );
}
Ese patrón es lo primero que hay que montar en cualquier escena con post-proceso, porque sin él estás midiendo un pase y creyendo que mides el frame. Es habitual descubrir así que el mapa de sombras cuesta más draw calls que la escena principal.
WebGLRenderer y WebGPURenderer cuentan de formas ligeramente distintas —el segundo, por ejemplo, cuenta triángulos también para los sprites— y el mismo objeto puede requerir un número de draw calls distinto en cada uno. Comparar los contadores entre backends no es una comparación de rendimiento. Compara siempre contra ti mismo, en el mismo backend, antes y después de un cambio.
Qué delata cada contador
Esta tabla es el atajo que conviene tener memorizado:
Síntoma en info |
Qué suele significar | Dónde mirar |
|---|---|---|
| Draw calls por encima de 1000 | cuello en CPU por preparación de comandos | fusionar geometría, instancing |
| Draw calls estable, triángulos en millones | cuello en GPU por vértices | LOD, simplificar, culling |
programs.length muy alto |
variantes de material sin control | compartir materiales, no clonar |
memory.geometries que sube sin bajar |
fuga de geometría | dispose() al descargar |
memory.textures que sube sin bajar |
fuga de texturas | caché con recuento de referencias |
memory.total cercano al límite del dispositivo |
presión de VRAM | comprimir con KTX2, reducir mipmaps |
| Draw calls y triángulos bajos y sigue lento | cuello en fragmentos o fuera del render | resolución, shaders, lógica de JS |
La última fila es la más importante y es donde renderer.info deja de servir. Los contadores describen el trabajo enviado, no el tiempo consumido. Una escena con doscientos draw calls y trescientos mil triángulos puede ir a diez frames por segundo si el material es un raymarching pesado o si estás rellenando la pantalla cinco veces con transparencias. Para eso hacen falta las herramientas de las lecciones siguientes.
De todos los campos de renderer.info, el que más problemas reales delata y el que menos se mira es programs. La razón es que no afecta al framerate medio en absoluto —una vez compilados, tener diez o doscientos programas cuesta prácticamente lo mismo por frame— y afecta muchísimo a lo que el usuario percibe, que son los tirones. Cada programa nuevo se compila la primera vez que Three.js lo necesita, de forma síncrona, en el hilo principal, y esa compilación en un driver puede costar entre veinte y trescientos milisegundos. Un solo programa nuevo es un frame perdido; cinco seguidos, al girar la cámara hacia una parte de la escena que aún no se había dibujado, es un congelado de un segundo. El usuario no lo describe como “compilación de shaders”: lo describe como “se traba al mirar hacia allá”. Y lo insidioso es que las variantes se generan por combinaciones que no parecen tocar el shader: el número de luces que afectan al objeto, si tiene sombras, si hay niebla, si la geometría lleva color por vértice, si el material se ha clonado, si tiene alphaTest distinto de cero, si hay morph targets, si hay skinning. Un mismo MeshStandardMaterial puede generar una docena de programas según a qué objetos lo apliques. Por eso el diagnóstico correcto no es mirar programs.length una vez al arrancar, sino vigilar cuándo crece. Instrumenta el frame en el que cambia, imprime qué objeto entró en escena en ese momento, y tendrás la lista exacta de tus fuentes de tirones ordenada por el orden en que el usuario las va a encontrar. Con esa lista en la mano, compileAsync durante la carga deja de ser un adorno y se convierte en la diferencia entre una experiencia fluida y una que se atasca cada pocos segundos durante los primeros minutos.
- Muestra en pantalla draw calls, triángulos, geometrías, texturas y número de programas.
- Añade post-proceso y comprueba que con
autoResetactivado solo ves el último pase. - Desactívalo, llama a
reset()una vez por frame, y compara los números. - Carga y descarga un modelo veinte veces y comprueba que
memory.geometriesvuelve al valor inicial. - Registra el frame exacto en el que
programs.lengthcrece y anota qué apareció en escena.