El pipeline de píxeles: estilo, layout, paint y composición
Las ocho etapas que convierten un cambio de estado en píxeles nuevos, qué hilo ejecuta cada una, y por qué la frontera entre el hilo principal y el compositor es la decisión de diseño más importante del navegador.
Cada vez que algo cambia en la página, el navegador ejecuta una secuencia fija de etapas para producir el fotograma siguiente. La secuencia es siempre la misma y siempre en el mismo orden; lo único que varía es cuántas etapas se pueden saltar. Ese modelo mental —qué etapas hay, cuál depende de cuál, y cuál corre en qué hilo— es la base de todo lo que se puede decir sobre rendimiento de renderizado, y es sorprendentemente poco conocido incluso entre gente que lleva años optimizando interfaces.
- Enumerar las ocho etapas del pipeline en orden y describir qué produce cada una.
- Identificar qué etapas corren en el hilo principal y cuáles fuera de él.
- Explicar por qué una etapa solo se puede saltar si las anteriores no cambiaron nada.
- Justificar por qué una animación compuesta sigue funcionando con el hilo principal bloqueado.
Las ocho etapas
flowchart TB A[Entrada del usuario y devoluciones de fotograma] --> B[Recalculo de estilo] B --> C[Layout o calculo de geometria] C --> D[Pre paint y arboles de propiedades] D --> E[Paint que graba la lista de dibujo] E --> F[Commit al hilo compositor] F --> G[Rasterizado en mosaicos] G --> H[Dibujo de los quads en la GPU] H --> I[Fotograma presentado en pantalla] style A fill:#89b4fa,color:#11111b style B fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style D fill:#cba6f7,color:#11111b style E fill:#f9e2af,color:#11111b style F fill:#89b4fa,color:#11111b style G fill:#fab387,color:#11111b style H fill:#fab387,color:#11111b style I fill:#a6e3a1,color:#11111b
La frontera está entre la sexta y la séptima. De la primera a la sexta, todo ocurre en el hilo principal, el mismo que ejecuta tu JavaScript. De la séptima en adelante, el trabajo lo hacen el hilo compositor, los hilos de rasterizado y el proceso de la GPU. Esa separación es la decisión arquitectónica más importante del navegador moderno y es la razón de que ciertas animaciones sean inmunes a que tu código bloquee.
Etapa por etapa.
Entrada y devoluciones de fotograma. El fotograma empieza atendiendo los eventos de entrada pendientes y ejecutando las devoluciones registradas con requestAnimationFrame. Todo lo que hagas aquí es tiempo del presupuesto del fotograma.
Recálculo de estilo. Para cada elemento cuyo estilo se haya invalidado, resolver qué valor gana en cada propiedad: emparejar selectores, aplicar la cascada, heredar lo heredable, resolver unidades relativas. El resultado es un estilo calculado por elemento. El coste escala con cuántos elementos hay que reevaluar, no con cuántas propiedades tocaste: cambiar una clase que afecta a tres mil elementos cuesta tres mil recálculos aunque la clase solo defina un color.
Los motores no reevalúan el documento entero por cada cambio. Mantienen estructuras de invalidación que, dado un cambio concreto —esta clase, este atributo, esta propiedad personalizada— saben acotar el conjunto de elementos que pueden verse afectados. Esa maquinaria funciona muy bien y se degrada con selectores que dependen de relaciones amplias del árbol.
Layout. Calcular la geometría: el tamaño y la posición de cada caja. Es la etapa más cara y la que peor se acota, porque el resultado de una caja depende de sus hermanas, de sus ancestros y de sus descendientes a la vez. Cambiar la anchura de un elemento puede obligar a recalcular la mitad del documento.
Pre-paint. Una etapa que existe en los motores modernos y que la gente no suele conocer. Aquí se actualizan los árboles de propiedades —transformaciones, recortes, efectos, desplazamientos— que describen cómo se relacionan geométricamente las distintas partes del documento, y se decide qué hay que volver a pintar. Es la etapa que hace posible que muchas cosas se resuelvan después sin repintar nada.
Paint. Y aquí está el malentendido más extendido de todo el tema: pintar no produce píxeles. Paint genera una lista de dibujo: una secuencia de órdenes del tipo “rellena este rectángulo con este color”, “dibuja este texto con esta fuente en esta posición”, “aplica este degradado en esta caja”. Es una estructura de datos, no un mapa de bits. Por eso pintar es relativamente barato incluso en áreas grandes.
Commit. Se transfiere el árbol de capas con sus listas de dibujo al hilo compositor. A partir de aquí el hilo principal ha terminado su parte y puede ejecutar la tarea siguiente.
Rasterizado. Ahora sí aparecen los píxeles. Las listas de dibujo se ejecutan para producir mapas de bits, troceados en mosaicos, en hilos de rasterizado dedicados o aceleradas por la GPU. Es donde se paga de verdad el coste de una sombra grande, un desenfoque o un degradado complejo.
Dibujo. La GPU coloca las texturas resultantes en su sitio, con sus transformaciones y sus mezclas, y produce el fotograma que va al panel.
Saltarse etapas
La propiedad que hace útil todo este modelo: una etapa se puede omitir si ninguna de las anteriores produjo un cambio que la afecte. De ahí salen los tres caminos posibles.
El camino completo. Cambias algo geométrico, como la anchura de un elemento. Hay que recalcular estilo, rehacer el layout, actualizar las propiedades de pintura, volver a pintar, y todo lo demás. Es el caso caro.
El camino sin layout. Cambias algo que afecta al aspecto pero no a la geometría, como un color de fondo. Recálculo de estilo sí; layout no, porque ninguna caja ha cambiado de tamaño ni de posición; pintura sí, porque el contenido de la lista de dibujo cambia; y el resto también.
El camino solo de composición. Cambias algo que el compositor puede resolver él solo con la textura que ya tiene, como el desplazamiento de una capa o su opacidad. Recálculo de estilo mínimo, y después nada: no hay layout, no hay pintura, no hay rasterizado nuevo. El compositor coge la textura existente y la dibuja en otro sitio o con otra transparencia.
La diferencia de coste entre los tres caminos, para un cambio en un documento de tamaño medio:
| Camino | Coste típico en el hilo principal |
|---|---|
| Completo | 5-50 ms |
| Sin layout | 1-12 ms |
| Solo composición | Menos de 1 ms |
Esos son los órdenes de magnitud que hay detrás de toda la sabiduría convencional sobre animar transform en vez de left. No es una superstición: es que uno de los dos recorre tres etapas del hilo principal y el otro ninguna.
El razonamiento habitual para preferir transform sobre left es que sale más barato. Es cierto y se queda muy corto. La propiedad de verdad importante es otra y es cualitativa, no cuantitativa: una animación que corre en el compositor sigue produciendo fotogramas aunque el hilo principal esté completamente bloqueado.
Piensa en lo que eso significa. Tu aplicación ejecuta una tarea de 400 milisegundos: una hidratación, un cálculo, un renderizado grande. Durante esos 400 milisegundos el hilo principal no atiende a nadie. Y si tu indicador de carga está animado con transform y opacity, sigue girando con fluidez perfecta, porque el hilo compositor tiene la textura y la matriz de transformación y no necesita preguntarle nada a nadie. Si está animado con left o con width, se congela exactamente durante la operación en la que el usuario más necesita ver que algo está pasando.
Es la ironía más cara del rendimiento web: el indicador de progreso mal implementado deja de moverse justo cuando hace falta.
Lo mismo vale para el desplazamiento de la página. El scroll lo maneja el hilo compositor, y por eso una página con el hilo principal bloqueado se puede seguir desplazando. En cuanto añades un escuchador de scroll que el navegador no puede tratar como pasivo, obligas a consultar al hilo principal en cada fotograma de desplazamiento, y el scroll se convierte en rehén de cualquier cosa que esté ocurriendo ahí.
La comprobación práctica es un experimento de treinta segundos y merece hacerlo una vez para verlo con los propios ojos:
// Pega esto en la consola con una animacion visible en pantalla
function bloquear(ms) {
const fin = performance.now() + ms;
while (performance.now() < fin) { /* ocupar el hilo principal */ }
}
setTimeout(() => bloquear(2000), 500);Las animaciones compuestas siguen. Todo lo demás se queda congelado durante dos segundos, incluidos los cambios de estado, las transiciones basadas en layout y los efectos de pasar el ratón por encima.
De ahí salen tres reglas de diseño que valen más que cualquier micro-optimización.
Una: todo indicador de estado de carga debe animarse solo con propiedades compuestas. Giradores, barras indeterminadas, esqueletos con brillo. Si el brillo del esqueleto es un degradado que se mueve cambiando background-position, eso es pintura en cada fotograma y se congela; si es una capa con transform: translateX, no.
Dos: las transiciones que acompañan a una operación pesada tienen que ser compuestas. Un panel que se abre mientras se carga su contenido: si la apertura anima height, compite con la carga; si anima transform, no.
Tres: los escuchadores de scroll y de gestos táctiles van marcados como pasivos salvo que de verdad necesites cancelar el evento. Es una palabra en la llamada y devuelve el desplazamiento al compositor:
elemento.addEventListener('scroll', alDesplazar, { passive: true });
elemento.addEventListener('touchmove', alTocar, { passive: true });Y una advertencia sobre el límite de la propiedad, para no venderla de más: la animación compuesta sobrevive al bloqueo, pero no empieza sola. Si arrancas la animación desde JavaScript y el hilo principal ya está bloqueado, no arranca. Por eso los indicadores de carga deben existir y estar animándose antes de lanzar la operación pesada, no crearse en el mismo instante. El patrón correcto es tener el indicador oculto con opacity: 0 y una animación ya corriendo, y limitarse a cambiar la opacidad, que también es compuesta.
Lo que este modelo permite decidir
Con las ocho etapas en la cabeza, tres preguntas que antes eran vagas se vuelven concretas.
“¿Por qué esto va lento?” pasa a ser “¿en qué etapa se va el tiempo?”, que tiene respuesta medible.
“¿Cómo lo hago más rápido?” pasa a ser “¿puedo hacer que el cambio se salte alguna etapa?”, que tiene un repertorio finito de respuestas.
“¿Por qué a veces va bien y a veces mal?” pasa a ser “¿qué cambia el número de elementos que recorre cada etapa?”, que casi siempre apunta al tamaño del documento.
Las tres se desarrollan en las lecciones siguientes. Antes, hay que saber qué cambio dispara qué etapa, que es la pieza que falta.
Monta una página de prueba con un elemento animado con transform y otro con left, los dos moviéndose. Ejecuta la función de bloqueo de dos segundos y graba la pantalla. La comparación es la demostración más convincente que existe de por qué la frontera del compositor importa, y es un vídeo de cinco segundos que puedes enseñar en cualquier discusión sobre el tema.