Un protocolo de medición que no miente
Cómo se construye una medida repetible: escena de prueba determinista, calentamiento, número de repeticiones, qué percentiles registrar, y por qué el dispositivo de desarrollo es el peor sitio para decidir.
La escena de prueba
El requisito es que dos ejecuciones produzcan exactamente el mismo trabajo. Eso descarta mover la cámara a mano, descarta la física con paso variable, y descarta cualquier Math.random() sin semilla.
import { MathUtils } from 'three';// 1. Aleatoriedad reproducible.MathUtils.seededRandom( 12345 );function aleatorio() { return MathUtils.seededRandom();}// 2. Recorrido de camara guionizado por tiempo simulado, no por reloj real.const RECORRIDO = [ { t: 0, pos: [ 0, 12, 30 ], mira: [ 0, 0, 0 ] }, { t: 3, pos: [ 20, 4, 12 ], mira: [ 0, 2, 0 ] }, { t: 6, pos: [ 2, 1.6, 3 ], mira: [ 0, 1.6, - 10 ] }, { t: 9, pos: [ - 25, 30, - 25 ], mira: [ 0, 0, 0 ] }, { t: 12, pos: [ 0, 12, 30 ], mira: [ 0, 0, 0 ] }];function colocarCamara( camara, t ) { let i = 0; while ( i < RECORRIDO.length - 2 && RECORRIDO[ i + 1 ].t < t ) i ++; const a = RECORRIDO[ i ]; const b = RECORRIDO[ i + 1 ]; const k = MathUtils.clamp( ( t - a.t ) / ( b.t - a.t ), 0, 1 ); const s = k * k * ( 3 - 2 * k ); camara.position.set( MathUtils.lerp( a.pos[ 0 ], b.pos[ 0 ], s ), MathUtils.lerp( a.pos[ 1 ], b.pos[ 1 ], s ), MathUtils.lerp( a.pos[ 2 ], b.pos[ 2 ], s ) ); camara.lookAt( MathUtils.lerp( a.mira[ 0 ], b.mira[ 0 ], s ), MathUtils.lerp( a.mira[ 1 ], b.mira[ 1 ], s ), MathUtils.lerp( a.mira[ 2 ], b.mira[ 2 ], s ) );}El recorrido tiene que atravesar los regímenes que te importan: un plano general con muchos objetos pequeños, un primer plano que llena la pantalla de fragmentos, un punto de vista con mucha oclusión. Si tu recorrido solo mira al vacío, medirás el vacío.
El tiempo que avanza el recorrido es tiempo simulado, no el reloj. Cada frame avanza una cantidad fija:
const PASO_PRUEBA = 1 / 60; // avanzar siempre lo mismo, cueste lo que cuestelet tPrueba = 0;function frameDePrueba() { colocarCamara( camara, tPrueba ); actualizarEscena( PASO_PRUEBA ); const t0 = performance.now(); renderer.render( scene, camara ); const t1 = performance.now(); tPrueba += PASO_PRUEBA; return t1 - t0;}Con tiempo simulado, una máquina lenta y una rápida recorren exactamente las mismas posiciones y hacen exactamente el mismo trabajo. Con tiempo real, la máquina lenta recorre menos posiciones por segundo y acaba midiendo una escena distinta.
Calentamiento y muestras
Las primeras decenas de frames de cualquier medición son basura, y por tres razones acumuladas. El motor de JavaScript todavía está interpretando y no ha optimizado las funciones calientes. Los shaders se están compilando la primera vez que cada material aparece. Y las texturas y geometrías se están subiendo a la GPU.
async function medir( { calentamiento = 120, muestras = 600 } = {} ) { const tiempos = []; // Fase 1: calentar. Se recorre todo el circuito y se descarta. for ( let i = 0; i < calentamiento; i ++ ) { frameDePrueba(); await siguienteFrame(); } // Reiniciar el recorrido para que las muestras cubran lo mismo. tPrueba = 0; // Fase 2: medir. for ( let i = 0; i < muestras; i ++ ) { tiempos.push( frameDePrueba() ); await siguienteFrame(); } return resumir( tiempos );}function siguienteFrame() { return new Promise( ( r ) => requestAnimationFrame( () => r() ) );}Ciento veinte frames de calentamiento son dos segundos: suficiente para que el JIT se estabilice y para que se compile todo lo que aparece en el circuito, siempre que el calentamiento recorra el circuito entero. Seiscientas muestras son diez segundos, que da estadística sólida sin que la sesión se haga insoportable.
El resumen tiene que incluir percentiles:
function resumir( tiempos ) { const orden = [ ...tiempos ].sort( ( a, b ) => a - b ); const pct = ( p ) => orden[ Math.min( orden.length - 1, Math.floor( orden.length * p ) ) ]; const media = tiempos.reduce( ( a, b ) => a + b, 0 ) / tiempos.length; return { n: tiempos.length, media: + media.toFixed( 2 ), p50: + pct( 0.5 ).toFixed( 2 ), p95: + pct( 0.95 ).toFixed( 2 ), p99: + pct( 0.99 ).toFixed( 2 ), max: + orden[ orden.length - 1 ].toFixed( 2 ) };}El p50 describe la sensación habitual. El p99 y el máximo describen los tirones. Una optimización que baja el p50 de 12 a 10 ms y sube el máximo de 40 a 120 ms es una mala optimización, y con solo la media no te enterarías.
Medir donde importa
El equipo de desarrollo es el peor sitio del mundo para decidir sobre rendimiento, y por cuatro razones que se suman.
Tiene una GPU muy superior a la mediana. La diferencia entre una tarjeta de escritorio de gama media y el chip integrado de un portátil corriente es de un factor de diez, y con un móvil de gama media puede llegar a treinta.
Está enchufado. Un portátil con batería reduce las frecuencias, y un móvil con la pantalla al máximo entra en limitación térmica a los pocos minutos. Una escena que va a 60 fps durante veinte segundos puede ir a 25 al cabo de tres minutos, y eso solo se ve midiendo largo.
Tiene la caché caliente. Los assets están en disco local y en la caché del navegador. Tu primer usuario los descarga.
Tú sabes por dónde no mirar. Sin darte cuenta evitas el ángulo que dispara el problema.
El protocolo mínimo serio consiste en definir dispositivos de referencia con su presupuesto y medir contra ellos:
| Referencia | Objetivo | Presupuesto de frame |
|---|---|---|
| Escritorio con GPU dedicada | 60 fps estables | 16,6 ms, p99 por debajo de 25 |
| Portátil con gráficos integrados | 60 fps | 16,6 ms, p99 por debajo de 33 |
| Móvil de gama media | 30 fps estables | 33 ms, p99 por debajo de 50 |
| Móvil de gama baja | funcional | 50 ms, sin bloqueos largos |
Con --enable-features y la limitación de CPU del panel de rendimiento puedes aproximar desde el escritorio, pero no sustituye a medir en un dispositivo real: la limitación de CPU no simula ni la GPU ni el ancho de banda de memoria, que es justamente donde fallan los móviles.
Para que la medición sea reproducible entre sesiones, registra el contexto junto al número:
function contexto( renderer ) { const info = renderer.info; return { fecha: new Date().toISOString(), agente: navigator.userAgent, dpr: window.devicePixelRatio, resolucion: [ renderer.domElement.width, renderer.domElement.height ], drawCalls: info.render.drawCalls ?? info.render.calls, triangulos: info.render.triangles, geometrias: info.memory.geometries, texturas: info.memory.textures };}Un registro con esa forma, guardado en un fichero por cada medición, convierte una comparación de dos semanas después en algo posible.