Saltar al contenido

CPU o GPU: la pregunta que decide todo lo demás

El método para determinar dónde está el cuello de botella, las pruebas concretas que lo distinguen, el árbol de decisión completo, y por qué optimizar el lado equivocado no produce ninguna mejora.

Dos procesadores en tubería

En cada frame la CPU prepara trabajo y la GPU lo ejecuta, y lo hacen solapados: mientras la GPU dibuja el frame n, la CPU ya está preparando el n+1. Si la CPU tarda 8 ms y la GPU 14, el frame dura 14 y la CPU pasa 6 ms esperando. Si la CPU tarda 20 y la GPU 5, el frame dura 20 y la GPU está ociosa tres cuartas partes del tiempo.

De ahí sale la consecuencia que hay que interiorizar: optimizar el lado que no es el cuello no produce ninguna mejora medible. Cero. Puedes reducir tus draw calls a la mitad y ver exactamente el mismo framerate, porque la GPU seguía siendo el freno. Es la causa número uno de tiempo perdido optimizando escenas 3D.

Lo que hace difícil el diagnóstico es que las herramientas cómodas —el perfilador del navegador, Stats— solo ven la CPU. La GPU es una caja negra que devuelve píxeles con retraso. Así que el método no consiste en medir la GPU, sino en provocar cambios y observar qué pasa.

Las pruebas de aislamiento

Son cuatro y cada una aísla una variable. Todas se hacen en menos de un minuto y no requieren ninguna herramienta.

Prueba 1: bajar la resolución. Es la prueba decisiva y la primera que hay que hacer.

JS
// Renderizar a un cuarto de los pixeles.renderer.setPixelRatio( 0.5 );// O, de forma mas agresiva:renderer.setSize( window.innerWidth / 2, window.innerHeight / 2, false );

El número de píxeles procesados cae a la cuarta parte. La CPU hace exactamente el mismo trabajo: los mismos objetos, los mismos draw calls, los mismos uniforms. Por tanto:

  • Si el framerate mejora mucho, estás limitado por la GPU, y concretamente por el trabajo por píxel: fragment shaders, overdraw, post-proceso, transparencias.
  • Si no cambia nada, la GPU no era el freno en la parte de fragmentos. Sigue investigando.

El tercer argumento false de setSize evita que Three.js toque el estilo CSS del canvas, así que el lienzo sigue ocupando lo mismo en pantalla y solo cambia la resolución interna. Es lo que quieres para que la prueba sea limpia.

Prueba 2: quitar la geometría. Deja un solo objeto en escena o pon scene.visible = false un momento.

  • Si el framerate se dispara, el coste está en dibujar. Falta saber si en draw calls o en vértices, y eso lo dice renderer.info.
  • Si sigue igual, el coste está fuera del render: tu lógica de JavaScript, la física, el raycasting, la actualización de datos.

Prueba 3: material trivial. Sustituye todos los materiales por uno básico con scene.overrideMaterial.

JS
const trivial = new THREE.MeshBasicMaterial( { color: 0x808080 } );// Un interruptor para comparar en caliente.scene.overrideMaterial = pruebaActiva ? trivial : null;

Se dibujan los mismos objetos con los mismos vértices, pero el fragment shader pasa a ser trivial y desaparecen todas las texturas y luces.

  • Si mejora mucho, el coste está en el sombreado por píxel: materiales caros, demasiadas luces, mapas pesados.
  • Si no cambia, el coste está en el procesado de vértices o en la CPU.

Prueba 4: duplicar la carga. A veces el diagnóstico se confirma mejor añadiendo que quitando. Duplica el número de objetos manteniendo el tamaño en pantalla, y observa si el tiempo crece proporcionalmente. Si duplicar objetos duplica el tiempo pero duplicar píxeles no lo cambia, el diagnóstico es inequívoco.

El árbol de decisión

Cada hoja lleva a un conjunto de soluciones distinto y no intercambiable:

Limitado por GPU en fragmentos. Bajar el pixelRatio, reducir el overdraw ordenando y recortando transparencias, simplificar el post-proceso, renderizar los efectos caros a media resolución, quitar alphaTest donde se pueda.

Limitado por CPU en preparación de comandos. Fusionar geometría estática, usar InstancedMesh, compartir materiales, reducir el número de objetos del grafo, reducir los pases de sombra.

Limitado por CPU fuera del render. Perfilar con el flame chart: aquí sí es la herramienta correcta. Suele ser física, raycasting por evento, recolección de basura, o actualizaciones de matrices innecesarias.

Limitado por GPU en vértices. LOD, simplificación, culling más agresivo, y revisar si el mapa de sombras está procesando geometría que no proyecta nada.

Limitado por GPU en sombreado. Materiales más simples, menos luces dinámicas, hornear iluminación, mapas de menor resolución.

Medir el tiempo de GPU de verdad

Las pruebas de aislamiento dan el diagnóstico y no dan el número. Para el número hacen falta consultas de marca temporal, que la GPU emite entre comandos y que se leen de forma asíncrona.

En WebGPURenderer, Three.js lo expone a través del renderer:

JS
// Requiere que el backend tenga habilitadas las marcas temporales.if ( renderer.hasFeature( 'timestamp-query' ) ) {	renderer.backend.trackTimestamp = true;}async function medirGPU() {	await renderer.resolveTimestampsAsync( 'render' );	console.log( 'GPU render:', renderer.info.render.timestamp.toFixed( 2 ), 'ms' );	await renderer.resolveTimestampsAsync( 'compute' );	console.log( 'GPU compute:', renderer.info.compute.timestamp.toFixed( 2 ), 'ms' );}

resolveTimestampsAsync( tipo ) recoge los resultados pendientes y actualiza info.render.timestamp o info.compute.timestamp. Como la lectura es asíncrona, los valores corresponden a frames anteriores, no al actual; para monitorización continua eso da igual, y para medidas puntuales hay que promediar sobre varios frames.

Ese es exactamente el mecanismo sobre el que se apoya el Inspector de Three.js, que activa trackTimestamp y avisa por consola si el dispositivo no soporta la característica. Lo verás en la lección de inspección de frames.

En WebGL la funcionalidad existe a nivel de extensión —EXT_disjoint_timer_query_webgl2— pero Three.js no la expone en WebGLRenderer, así que hay que usarla a mano contra el contexto o quedarse con las pruebas de aislamiento. En la práctica, para WebGL, las pruebas de aislamiento son suficientes.