Stats y el panel de rendimiento del navegador
Por qué los fotogramas por segundo son una métrica engañosa, cómo se instala y se lee Stats, y cómo se interpreta el flame chart del navegador para separar tu código del trabajo del motor.
“Va a sesenta” no significa nada. Sesenta es un techo impuesto por la pantalla, así que una escena que va sobrada y otra que va justa dan el mismo número, y una escena que cae a cincuenta y cinco puede estar perdiendo un frame de cada doce sin que la media lo delate. La métrica que sirve es el tiempo por frame en milisegundos, y las dos herramientas que hay que tener siempre a mano son un panel diminuto que lo muestra en vivo y un perfilador que dice en qué se ha ido.
- Explicar por qué los fotogramas por segundo comprimen la información útil.
- Instalar
Statsy leer sus tres paneles. - Medir el tiempo de tu propio código separándolo del tiempo total del frame.
- Interpretar un flame chart del panel de rendimiento y localizar el trabajo del render.
Milisegundos, no fotogramas
El problema de los fotogramas por segundo es que son el inverso de lo que quieres medir, y esa inversión comprime justo la zona interesante.
| Tiempo por frame | Fotogramas por segundo | Diferencia con el anterior |
|---|---|---|
| 16,7 ms | 60 | — |
| 20,0 ms | 50 | 10 fps por 3,3 ms |
| 33,3 ms | 30 | 20 fps por 13,3 ms |
| 50,0 ms | 20 | 10 fps por 16,7 ms |
Los mismos 10 fps de caída cuestan 3,3 ms en el rango bueno y 16,7 ms en el malo. Si optimizas dos milisegundos partiendo de 60 fps no ves ningún cambio en el contador —sigue a 60, porque hay techo— y si los optimizas partiendo de 20 fps ves un salto enorme. Con milisegundos, dos milisegundos son dos milisegundos siempre, y puedes hacer aritmética: “he quitado 3 ms del culling y 2 del post-proceso, luego he ganado 5”.
Además, el promedio esconde lo que más molesta. Un frame de 200 ms cada dos segundos apenas mueve la media y es exactamente lo que el usuario percibe como que la aplicación “se traba”. Por eso la métrica seria no es la media sino el percentil 99 o directamente el peor frame de la sesión.
const tiempos = [];
function registrar( ms ) {
tiempos.push( ms );
if ( tiempos.length > 600 ) tiempos.shift();
}
function percentil( p ) {
const orden = [ ...tiempos ].sort( ( a, b ) => a - b );
return orden[ Math.floor( orden.length * p ) ];
}
// p50 es la sensacion habitual, p99 es la sensacion de que algo falla.
console.log( percentil( 0.5 ).toFixed( 1 ), percentil( 0.99 ).toFixed( 1 ) );
Stats
El panel clásico de Three.js son treinta líneas de código y sigue siendo lo más práctico para tener el número delante mientras trabajas.
import Stats from 'three/addons/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild( stats.dom );
function animate() {
stats.begin();
actualizar();
renderer.render( scene, camera );
stats.end();
}
renderer.setAnimationLoop( animate );
Tiene tres paneles y se rota entre ellos haciendo clic:
FPS. Fotogramas por segundo, promediados sobre un segundo. Útil de un vistazo, engañoso por lo que acabas de ver.
MS. Milisegundos transcurridos entre begin() y end(). Este es el panel que hay que mirar. Ojo con lo que mide exactamente: solo el tiempo entre las dos llamadas, es decir tu código y las llamadas a la API de gráficos que hace el renderer. No incluye lo que la GPU tarda en ejecutar esos comandos, que ocurre de forma asíncrona y después.
MB. Memoria del montón de JavaScript, y solo aparece si performance.memory existe, que es cosa de Chromium. No mide VRAM: las texturas y geometrías subidas a la GPU no cuentan aquí. Un valor que crece sin parar indica fuga de objetos JavaScript, que es un problema distinto del de recursos de GPU sin liberar.
La alternativa moderna es stats.update(), que llama a end() y usa su resultado como nuevo begin(), midiendo así el frame completo incluyendo el tiempo de espera del navegador:
function animate() {
actualizar();
renderer.render( scene, camera );
stats.update(); // mide de un frame al siguiente
}
La diferencia entre los dos modos es informativa por sí misma: si begin/end da 6 ms y update da 16,7 ms, significa que tu código ocupa 6 ms y el resto es espera —el navegador esperando el vsync, o esperando a la GPU—. Si los dos dan 16 ms, tu código está saturando el frame.
Se pueden añadir paneles propios, lo que es la forma más barata de vigilar cualquier métrica:
const panelDC = stats.addPanel( new Stats.Panel( 'DC', '#ff8', '#221' ) );
function animate() {
stats.begin();
renderer.render( scene, camera );
panelDC.update( renderer.info.render.calls, 500 );
stats.end();
}
El segundo argumento de update es el valor máximo de la escala del gráfico, no un límite: solo afecta a cómo se dibuja.
El panel de rendimiento del navegador
Stats dice cuánto; el perfilador dice en qué. Una grabación de tres o cuatro segundos con la escena en el estado problemático da el reparto completo.
Lo que hay que mirar, en orden:
La franja de frames. Arriba del todo, con cada frame coloreado según su duración. Los frames largos aparecen en rojo o amarillo. Antes de mirar nada más, busca si el problema es un frame malo repetido o una degradación uniforme: son dos investigaciones distintas.
El flame chart del hilo principal. Cada frame aparece como una torre bajo una tarea de requestAnimationFrame. Lo que hay debajo es tu código, y con los nombres de tus funciones si no las has minificado. Aquí es donde localizas el bucle que cuesta ocho milisegundos.
El resumen inferior. La vista de árbol invertido —bottom-up— agrupa por función y ordena por tiempo propio. Es la forma más rápida de encontrar la función que más cuesta cuando se llama desde muchos sitios.
Tres cosas que hay que saber leer para no llegar a conclusiones erróneas.
El tiempo dentro de render() no es tiempo de GPU. Lo que ves en el flame chart bajo WebGLRenderer.render es el trabajo de CPU: recorrer la escena, hacer culling, ordenar, actualizar uniforms y emitir comandos. La GPU ejecuta esos comandos después y su tiempo no aparece ahí. Una escena limitada por GPU puede mostrar un render() de dos milisegundos y aun así ir a treinta frames por segundo.
Los huecos son información. Si entre el final de tu código y el siguiente frame hay un hueco largo y vacío, no estás limitado por CPU: estás esperando. Esperando al vsync si la escena va sobrada, o esperando a la GPU si va justa. Ese hueco es la señal más clara de que el cuello está al otro lado.
Las tareas largas. El panel marca con un triángulo rojo las tareas de más de cincuenta milisegundos. Cualquiera de ellas es un frame perdido garantizado. Suelen ser carga de assets, parseo de JSON grandes o compilación de shaders.
Para orientarte dentro de la grabación, marca tus propias regiones:
function animate() {
performance.mark( 'inicio-logica' );
actualizarMundo();
performance.mark( 'fin-logica' );
performance.measure( 'logica', 'inicio-logica', 'fin-logica' );
performance.mark( 'inicio-render' );
renderer.render( scene, camera );
performance.mark( 'fin-render' );
performance.measure( 'render', 'inicio-render', 'fin-render' );
}
Las medidas aparecen en la pista de Timings del panel, alineadas con el flame chart. Es la diferencia entre buscar a ciegas y saber exactamente dónde mirar.
El panel permite simular una CPU cuatro o seis veces más lenta. Es la forma más rápida de aproximar un móvil de gama media desde el escritorio: los cuellos de CPU se amplifican y se hacen visibles, mientras que los de GPU se mantienen. Esa asimetría, por sí sola, ya es una prueba diagnóstica útil.
Hay un malentendido que hace perder días enteros y que conviene desmontar del todo: el flame chart del navegador no puede mostrarte el tiempo de GPU, y no es una limitación de la herramienta sino de cómo funciona el sistema gráfico. Cuando tu código llama a drawElements, esa llamada no dibuja nada: escribe un comando en un búfer. El driver acumula comandos y los envía a la GPU cuando le conviene, y la GPU los ejecuta cuando le toca, con un desfase que suele ser de uno a tres frames. El resultado es que el tiempo que ves bajo render() es el tiempo de escribir la lista de la compra, no el de hacer la compra. Ahora la parte que sí es contraintuitiva: ese desacoplamiento es asimétrico y por eso el perfilador engaña en una dirección concreta. Cuando la GPU va sobrada, la CPU escribe comandos y sigue, y todo el tiempo que ves es tiempo real de CPU. Pero cuando la GPU se retrasa, la cola de comandos se llena, y en algún punto una llamada aparentemente inocente —a menudo la del render() del frame siguiente, o getContext, o incluso requestAnimationFrame— se bloquea esperando a que haya sitio. Entonces el perfilador te muestra un pico enorme en una función que no tiene nada que ver con el problema. He visto perder tardes optimizando un bucle de actualización de uniforms que aparecía en rojo en el perfilador y que en realidad solo estaba absorbiendo la espera de una GPU saturada por un shader de post-proceso. La consecuencia práctica es la regla de oro de todo este nivel: el perfilador del navegador es la herramienta correcta si y solo si has confirmado antes que el cuello está en la CPU. Confirmarlo es una prueba de treinta segundos —bajar la resolución y ver si mejora— y es la que estructura toda la lección siguiente. Hacerla primero convierte el perfilador en un instrumento preciso; saltársela lo convierte en una fuente de pistas falsas.
- Instala
Statsy compara lo que marca el modobegin/endcon el deupdate. - Añade un panel propio con los draw calls y observa cómo cambia al girar la cámara.
- Graba tres segundos con el panel de rendimiento y localiza el hueco de espera al final de cada frame.
- Marca tu lógica y tu render con
performance.measurey comprueba que aparecen alineados. - Registra los tiempos de 600 frames y compara la media con el percentil 99.