wandres.dev
RENDIMIENTO I · Medir antes de optimizar

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.

⏱ 18 min

Una medida que no se puede repetir no es una medida, es una anécdota. Y la mayoría de las mediciones de rendimiento en 3D web son anécdotas: se hacen moviendo la cámara a mano, sobre un portátil enchufado, con veinte pestañas abiertas, comparando un antes recordado con un después recién visto. El resultado es que se toman decisiones caras sobre ruido. Montar un protocolo cuesta una tarde, se reutiliza durante todo el proyecto, y convierte “creo que va mejor” en un número con intervalo de confianza.

🎯 Al terminar esta lección sabrás
  • Construir una escena de prueba determinista con recorrido de cámara reproducible.
  • Aplicar calentamiento y descartar las primeras muestras.
  • Registrar percentiles y no solo la media.
  • Establecer un presupuesto de frame por dispositivo objetivo y medir contra él.

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 cueste

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

⚠️
performance.now no mide la GPU

El intervalo alrededor de renderer.render() mide el tiempo de CPU en emitir comandos, no el de la GPU en ejecutarlos. Para un protocolo completo hay que medir también la duración total del frame, de un requestAnimationFrame al siguiente, que sí refleja el freno real venga de donde venga. Registra las dos series: su diferencia es la señal que te dice en qué régimen estás, tal como viste en el árbol de diagnóstico.

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.

La medida define lo que optimizas, así que elígela antes de empezar

Hay un fenómeno bien documentado en ingeniería y que en gráficos aparece con una claridad brutal: el equipo optimiza aquello que mide, y si la métrica está mal elegida, el equipo empeora el producto de forma diligente y sistemática. Los ejemplos son reconocibles porque todos los hemos visto. Si la métrica es el framerate medio, se acaba subiendo la media a costa de tirones, porque cargar todo de golpe al principio mejora el promedio del resto de la sesión, y el usuario recuerda exactamente el congelado que produjo. Si la métrica es el número de draw calls, se fusionan objetos que deberían estar separados y se destruye el frustum culling, con lo que la escena envía más triángulos y va peor mientras el contador vigilado mejora. Si la métrica es el tiempo del primer frame, se retrasa la carga de texturas y el usuario ve el mundo en gris durante diez segundos. Y si la métrica es la memoria, se descargan assets que van a volver a hacer falta y se produce un estado permanente de recarga. En los cuatro casos el número mejoró y la experiencia empeoró. La única defensa es elegir la métrica antes de empezar y elegirla lo bastante cerca de la experiencia real: no el framerate medio sino “porcentaje de frames por debajo del presupuesto”, no el número de draw calls sino “milisegundos de CPU por frame”, no el peso total de assets sino “segundos hasta que la escena es interactiva”. Y hay un último principio que cierra todo este nivel y que es la razón por la que existe. La primera pregunta ante una escena lenta nunca es “cómo la acelero”, es “cómo sé que he acelerado algo”. Si no puedes responder la segunda con un procedimiento repetible, todo lo que hagas después es fe, y la fe en optimización es indistinguible de la casualidad: a veces funciona, nunca sabes por qué, y no se puede repetir en el proyecto siguiente.

⚔️ Monta tu banco de pruebas
  1. Escribe un recorrido de cámara de doce segundos que atraviese al menos tres regímenes distintos.
  2. Sustituye toda la aleatoriedad de tu escena por MathUtils.seededRandom con semilla fija.
  3. Implementa el ciclo de calentamiento y medición y comprueba que dos ejecuciones dan resultados casi idénticos.
  4. Registra media, p50, p95, p99 y máximo, junto con el contexto del dispositivo.
  5. Aplica una optimización, vuelve a medir, y decide con los percentiles si la mantienes.