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