Por qué deformar en la CPU casi siempre es la opción equivocada
El desglose del coste de una deformación por frame con números concretos, el presupuesto real de dieciséis milisegundos, y los cuatro casos en que la CPU sí es la respuesta correcta.
La deformación en CPU tiene una propiedad que la hace especialmente traicionera: escala mal, pero escala mal de golpe. Con un plano de treinta segmentos va perfecta y el ejemplo del tutorial funciona; con uno de doscientos, la misma línea de código consume la mitad del presupuesto del frame y nadie sabe qué ha cambiado. Poner números al desglose lleva diez minutos y explica de dónde sale ese salto, que no es lineal en los segmentos sino en su cuadrado.
- Desglosar el coste por frame de una deformación en CPU en sus cuatro componentes.
- Estimar el orden de magnitud de cada uno para una malla concreta.
- Identificar los cuatro casos en que la CPU es la respuesta correcta.
- Medir el reparto real entre CPU y GPU en lugar de suponerlo.
El presupuesto de un frame
A sesenta fotogramas por segundo hay 16,6 milisegundos para todo. A ciento veinte, 8,3. Y ese “todo” incluye mucho más que tu deformación: la recolección de basura, los manejadores de eventos, el recálculo de estilos si hay HTML por encima, la construcción de la lista de render de Three.js, la subida de uniformes, y el tiempo que el hilo principal pasa esperando a que el navegador presente el frame anterior.
La regla práctica de la industria es que el trabajo propio de la aplicación no debería pasar de la mitad de ese presupuesto: unos ocho milisegundos a sesenta hercios. Todo lo que se acerque a esa cifra desde un solo efecto es un problema.
Y hay una asimetría que conviene tener presente antes de mirar los números: el hilo principal es uno y la GPU tiene miles de núcleos. La misma operación aritmética repetida cuarenta mil veces es un problema serio en JavaScript y es ruido en la GPU. No es que la GPU sea “más rápida”: es que ese trabajo concreto es exactamente para lo que está construida.
Dónde se va el tiempo
Tomemos un plano de doscientos por doscientos segmentos, que es una resolución perfectamente razonable para una superficie de agua: 40 401 vértices y 80 000 triángulos. La deformación de la lección anterior, con su ola de un seno y un coseno.
El bucle de deformación. Cuarenta mil iteraciones con dos funciones trigonométricas y unas cuantas operaciones aritméticas cada una, más dos lecturas y una escritura en arrays tipados. En un motor moderno eso es del orden de uno a tres milisegundos, y depende bastante de si el motor consigue mantener el bucle en su forma optimizada.
La subida del buffer. El atributo de posición son 40 401 × 3 × 4 = 484 812 bytes, casi medio megabyte. Subirlo entero cada frame son 29 megabytes por segundo de tráfico hacia la GPU, más el coste fijo de la llamada. La transferencia en sí es rápida, pero introduce un punto de sincronización: si la GPU todavía está leyendo ese buffer para el frame anterior, el driver tiene que esperar o duplicar el buffer.
El recálculo de normales. Aquí está el grueso, y casi nadie lo cuenta. computeVertexNormals() recorre los 80 000 triángulos haciendo dos restas de vectores, un producto vectorial y tres acumulaciones en cada uno, y luego normaliza los 40 401 vértices, lo que incluye una raíz cuadrada por vértice. Son del orden de tres a ocho milisegundos, entre tres y cuatro veces el coste de la deformación misma.
El volumen envolvente. computeBoundingSphere() recorre los vértices dos veces: una para la caja y otra para el radio máximo. Entre medio milisegundo y uno.
Sumando: entre cinco y trece milisegundos por frame, para un solo objeto. En el extremo bueno te queda algo de presupuesto; en el malo, ya has perdido los sesenta fotogramas por segundo antes de dibujar nada más.
Y el detalle que hace que el problema aparezca de golpe: el número de vértices de una rejilla crece con el cuadrado de los segmentos. Pasar de cien a doscientos segmentos no duplica el coste, lo cuadruplica. De ahí que el ejemplo del tutorial con sesenta y cuatro segmentos —4 225 vértices, unos 0,7 milisegundos— vaya sobrado, y la versión “en buena calidad” no.
Compáralo con la alternativa. La misma ola en el vertex shader son 40 401 invocaciones de un programa de veinte instrucciones, repartidas entre los núcleos de la GPU. Incluso en gráficos integrados modestos, eso son decenas de microsegundos. Y no hay subida de buffer, porque lo único que viaja por el bus es un flotante con el tiempo. La diferencia es de dos a tres órdenes de magnitud.
Un consejo aparte sobre la basura: si dentro del bucle creas objetos —new THREE.Vector3() por vértice— añades cuarenta mil asignaciones por frame que el recolector tendrá que limpiar. El resultado no es tanto tiempo perdido en el bucle como pausas periódicas que se ven como tirones cada pocos segundos. Todos los vectores temporales van fuera del bucle, reutilizados.
Cuándo la CPU es la respuesta correcta
Hay cuatro casos, y son casos de verdad, no consuelos.
Cuando el resultado tiene que volver a JavaScript. Es el criterio decisivo. Si algo más necesita conocer la posición deformada —un rayo que la golpea, un personaje que camina encima, un motor de física que necesita la malla de colisión, un cálculo de volumen o de área, una exportación— la GPU no sirve, porque el resultado del vertex shader no vuelve. Aquí no hay elección.
Cuando la deformación no es por frame. Generar un terreno al cargar, deformar una malla una vez al colocarla, aplicar una variación aleatoria a cada instancia de un objeto en la inicialización: todo eso ocurre una vez y el coste no entra en el presupuesto del frame. Cinco milisegundos durante la carga no los nota nadie.
Cuando el número de vértices es pequeño. Por debajo de unos dos mil vértices, el coste es de décimas de milisegundo y la simplicidad de tenerlo en JavaScript gana a cualquier consideración de rendimiento. Una bandera, una cuerda, un tentáculo, un cable: geometrías de decenas o cientos de vértices donde el debate no existe.
Cuando la deformación cambia la topología. Añadir vértices, quitarlos, partir la malla, coserla. El vertex shader puede mover vértices pero no puede crearlos ni destruirlos: la topología está fijada por el buffer de índices antes de que el shader se ejecute. Cualquier deformación que cambie la conectividad es necesariamente trabajo de CPU.
Fuera de esos cuatro casos, la respuesta es el vertex shader, y en el caso concreto de interpolar entre poses fijas, los morph targets.
Medir en lugar de suponer
Las estimaciones de arriba son órdenes de magnitud. Los números de tu máquina se miden, y medir es fácil si sabes qué estás midiendo.
const marcas = { deformar: 0, normales: 0, esfera: 0 };
function frame( t ) {
let m = performance.now();
deformar( t );
marcas.deformar = performance.now() - m;
m = performance.now();
geometria.computeVertexNormals();
marcas.normales = performance.now() - m;
m = performance.now();
geometria.computeBoundingSphere();
marcas.esfera = performance.now() - m;
renderer.render( scene, camera );
}
Con esos tres números en pantalla, la decisión deja de ser una opinión. Lo habitual es que la sorpresa esté en normales, que casi siempre es el mayor de los tres y el que nadie sospechaba.
Dos advertencias sobre qué no mide esto. La primera: renderer.render() no espera a que la GPU termine, así que el tiempo que midas alrededor de esa llamada es solo el trabajo de CPU de preparar y enviar comandos. El tiempo real de la GPU no se puede medir con performance.now().
La segunda, más útil: la señal de que el cuello está en la CPU es que el tiempo total del hilo principal se acerca al tiempo de frame. Si tu bucle tarda doce milisegundos de CPU y el frame dura dieciséis, eres tú. Si tu bucle tarda dos y el frame dura treinta y tres, el problema está en la GPU o en la sincronización, y optimizar el bucle no cambiará nada.
Y una prueba de diagnóstico que vale más que cualquier medición: quita la deformación y mira. Si los fotogramas por segundo no cambian, no era eso. Es obvio y casi nadie lo hace antes de pasar una tarde optimizando el bucle equivocado.
Hay una razón por la que ocho milisegundos de deformación en el hilo principal son mucho peores que ocho milisegundos de trabajo en la GPU, y no tiene que ver con la aritmética. El hilo principal del navegador es un recurso compartido y no expropiable. Mientras tu bucle recorre cuarenta mil vértices, no se procesan eventos de puntero, no se ejecutan las animaciones de CSS, no responde el desplazamiento de la página, y cualquier interacción del usuario se encola. La GPU, en cambio, trabaja en paralelo con todo eso. Por eso el mismo presupuesto gastado en un sitio o en otro produce experiencias distintas: una escena que renderiza a cuarenta y cinco fotogramas por segundo porque la GPU va justa se siente aceptable; una que renderiza a cuarenta y cinco porque el hilo principal está saturado se siente rota, porque además el cursor va a saltos y la página no responde. Esa asimetría es la que explica por qué la web insiste tanto en mover trabajo fuera del hilo principal, y por qué existen los web workers, los offscreen canvas y la mitad de las APIs modernas del navegador. Aplicado a la geometría, la consecuencia es una jerarquía de preferencia muy clara que conviene tener interiorizada: primero el vertex shader, después una textura de datos que el shader consulte, después un worker que prepare buffers, y solo al final el hilo principal. Y cuando no haya más remedio que usar el hilo principal, repartir el trabajo entre varios frames —deformar la mitad de los vértices en los frames pares y la otra mitad en los impares— suele ser preferible a hacerlo todo de golpe, porque a treinta hercios efectivos con la interfaz fluida se percibe mejor que a sesenta con tirones.
- Instrumenta los tres bloques con
performance.now()y anota el reparto. - Sube los segmentos del plano de 64 a 128 y a 256, y comprueba que el coste se cuadruplica cada vez.
- Crea un
Vector3dentro del bucle y observa el patrón de tirones periódicos en el panel de rendimiento. - Quita la deformación por completo y mira si los fotogramas por segundo cambian.
- Reparte la deformación entre dos frames y compara la sensación con hacerla entera.