Paint y rasterizado: dónde se generan los píxeles
Por qué pintar no produce imágenes, cómo se convierte la lista de dibujo en píxeles por baldosas, y qué propiedades cuestan por área en vez de por elemento.
Entre la geometría y la pantalla hay dos etapas que casi siempre se citan juntas y que tienen naturalezas opuestas. Pintar es construir una lista de instrucciones, es barato y ocurre en el hilo principal. Rasterizar es ejecutar esa lista para producir píxeles, es caro y ocurre en otro sitio. Confundirlas lleva a optimizar la etapa equivocada, y explica por qué a veces reducir el número de elementos no mejora nada mientras que reducir el tamaño de uno solo lo cambia todo.
- Distinguir entre la lista de dibujo y los píxeles, y saber dónde se produce cada cosa.
- Explicar por qué el coste de rasterización se mide en área y no en elementos.
- Calcular el coste de una propiedad que afecta a un área difusa.
- Reducir el área de repintado sin cambiar el resultado visual.
Pintar es escribir una receta
La etapa de pintado recorre el árbol ya con geometría y produce una lista de operaciones de dibujo, ordenada según el orden de pintado de CSS: fondos, bordes, contenido en flujo, elementos flotantes, contenido en línea, elementos posicionados. Cada operación es una entrada como “rellena el rectángulo (12, 40, 300, 80) con este color” o “traza esta cadena con esta fuente en esta posición”.
Que el resultado sea una estructura de datos y no una imagen es la decisión de arquitectura que hace posible todo lo demás. Una lista se puede serializar, mandar a otro hilo, guardar, reutilizar parcialmente y ejecutar a distintas resoluciones. Una imagen no.
El coste de pintar es proporcional al número de operaciones, es decir aproximadamente al número de elementos visibles y a su complejidad. Suele ser pequeño. Cuando el panel de rendimiento muestra que “Paint” ocupa mucho tiempo, casi siempre lo que ocupa tiempo es la rasterización que viene detrás, no la construcción de la lista.
Hay un matiz que se le escapa a mucha gente: el orden de pintado depende de la estructura de contextos de apilamiento, y crear uno nuevo tiene consecuencias. opacity distinta de 1, transform distinto de none, filter, will-change con ciertos valores, isolation: isolate o z-index sobre un elemento posicionado: todos crean un contexto de apilamiento, y con él una unidad que se pinta de una pieza. Eso a veces ayuda —permite aislar un subárbol— y a veces perjudica, porque impide que el motor entrelace elementos como haría de otro modo.
Rasterizar es ejecutar la receta
La rasterización toma la lista y produce píxeles. Ocurre por baldosas, típicamente de 256 por 256 píxeles, en hilos dedicados y a menudo con aceleración por GPU. El troceado en baldosas tiene tres consecuencias prácticas.
El coste es por área. Rasterizar es escribir píxeles, y el número de píxeles que hay que escribir es el área en píxeles de dispositivo de la región invalidada. Un cambio en un elemento pequeño invalida una baldosa; un cambio que afecta a una franja horizontal completa invalida toda una fila de baldosas.
El factor de escala multiplica. Una pantalla con devicePixelRatio de 2 tiene cuatro veces más píxeles que una de 1 para la misma superficie CSS. Una de 3, nueve veces. El mismo código que va sobrado en un monitor externo puede no llegar en un portátil de alta densidad.
La invalidación se redondea a la baldosa. Cambiar un píxel en la esquina de una baldosa invalida la baldosa entera. Por eso mover un elemento pequeño a través de una superficie grande sin capa propia puede invalidar dos baldosas por fotograma en lugar de una: la que abandona y la que ocupa.
El cálculo del coste es directo y merece la pena hacerlo a mano una vez:
// Pixeles de dispositivo que hay que rasterizar por fotograma
// para una region CSS dada.
function pixelesPorFrame(anchoCss, altoCss) {
const dpr = window.devicePixelRatio;
return Math.round(anchoCss * dpr) * Math.round(altoCss * dpr);
}
// Una tarjeta de 320x200 en una pantalla dpr 2
console.log(pixelesPorFrame(320, 200)); // 256 000
// Un panel de 1200x800 en la misma pantalla
console.log(pixelesPorFrame(1200, 800)); // 7 680 000
Siete millones y medio de píxeles por fotograma, sesenta veces por segundo, son 460 millones de píxeles por segundo. Un móvil de gama media no está cerca de sostener eso mientras hace cualquier otra cosa. Y ese es el coste de animar cualquier propiedad que invalide el pintado de un panel grande, sea background-color, border-radius o width.
Las propiedades que cuestan por área difusa
Hay una familia de propiedades cuyo coste es peor que proporcional al área del elemento, porque afectan a una región mayor que el elemento y además requieren un cálculo por píxel que no es una simple escritura.
box-shadow con desenfoque. Una sombra con radio de desenfoque de 40 píxeles afecta a una banda de 40 píxeles alrededor de la caja, y calcularla requiere una convolución. Duplicar el radio de desenfoque más que duplica el coste. Animar el radio de una sombra grande es de las cosas más caras que se pueden hacer sin tocar layout.
filter: blur(). Igual, pero sobre todo el contenido del elemento en lugar de sobre una silueta. Un desenfoque grande sobre un elemento grande es cientos de veces más caro que un cambio de color.
backdrop-filter. El peor caso, porque tiene que leer lo que hay detrás del elemento, aplicar el filtro y componer. Implica una lectura de la superficie de destino, que en muchas configuraciones rompe la posibilidad de mantener todo en la GPU sin sincronizar.
border-radius grande combinado con recorte. Redondear obliga a recortar, y recortar con una forma no rectangular exige una máscara.
La corrección para todas es la misma y es sorprendentemente eficaz: no animes la propiedad cara, anima la opacidad de una capa que ya la tiene calculada.
/* Caro: recalcula el desenfoque en cada fotograma. */
.tarjeta { box-shadow: 0 2px 4px rgb(0 0 0 / 0.2); transition: box-shadow 200ms; }
.tarjeta:hover { box-shadow: 0 12px 32px rgb(0 0 0 / 0.3); }
/* Barato: dos sombras fijas en pseudoelementos, se cruza la opacidad. */
.tarjeta { position: relative; }
.tarjeta::before,
.tarjeta::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
transition: opacity 200ms ease-out;
pointer-events: none;
}
.tarjeta::before { box-shadow: 0 2px 4px rgb(0 0 0 / 0.2); opacity: 1; }
.tarjeta::after { box-shadow: 0 12px 32px rgb(0 0 0 / 0.3); opacity: 0; }
.tarjeta:hover::before { opacity: 0; }
.tarjeta:hover::after { opacity: 1; }
Las dos sombras se rasterizan una sola vez, al principio. Después, cada fotograma solo cambia dos opacidades, que es trabajo de composición. El resultado visual no es matemáticamente idéntico —una interpolación de sombras no es lo mismo que un cruce de opacidades— pero es indistinguible en movimiento, y el coste pasa de cientos a uno.
El error de diagnóstico más caro de esta etapa es asumir que el repintado se limita al elemento que cambia. No se limita: se invalida la región de pantalla afectada, y en esa región se vuelve a pintar todo lo que la ocupa, esté relacionado o no con tu cambio. Si un indicador de carga de 24 píxeles gira encima de una tabla de mil filas y ninguno de los dos tiene capa propia, cada fotograma invalida el rectángulo del indicador, y para rellenarlo hay que volver a ejecutar las operaciones de dibujo de todo lo que cae dentro de ese rectángulo: el fondo, los bordes de las celdas, el texto de las filas que asoman. El coste no lo paga tu animación de 24 píxeles, lo paga el contenido que hay debajo, y por eso el mismo indicador va perfecto en una página vacía y hunde el fotograma en una tabla densa. Hay dos formas de verlo antes de que sea un problema y las dos están en las herramientas: activar el destello de repintado, que colorea las regiones invalidadas y hace evidente que la mancha verde es diez veces mayor que tu elemento, y mirar el panel de capas para comprobar si el elemento animado tiene la suya. Y hay una sola forma de arreglarlo: dar al elemento animado su propia capa de composición, con lo que su repintado deja de tocar el contenido de debajo. Eso es exactamente lo que hace will-change: transform y no es magia ni “acelerar por hardware”: es sacar al elemento de la superficie compartida para que su invalidación no arrastre a nadie. El nivel siguiente explica el mecanismo y también por qué hacerlo con todo es peor que no hacerlo con nada.
Reducir el área sin cambiar el resultado
Tres técnicas cubren la mayoría de los casos y ninguna cambia lo que el usuario ve.
Recortar la propagación con contain: paint. Garantiza que nada del elemento se dibuja fuera de sus límites, lo que permite al motor limitar la región invalidada a la caja en lugar de tener que considerar desbordamientos y sombras que se salen.
Sustituir cambios de área por cambios de transformación. Un elemento que “crece” cambiando width invalida la banda que ocupa; el mismo elemento que crece con scale no invalida nada, porque la textura ya está rasterizada y solo cambia la matriz. La diferencia de resultado es que el escalado interpola los píxeles ya rasterizados, así que el texto se ve borroso durante el movimiento si el factor es grande. Para escalados pequeños es invisible; para grandes hay que combinar con un repintado al final.
Separar lo que cambia de lo que no. Si dentro de un panel grande solo se mueve una barra, esa barra debe estar en su propia capa. El panel se rasteriza una vez y la barra se compone encima. Es el mismo principio que usa cualquier sistema de ventanas desde hace cuarenta años.
- Activa el destello de repintado en las herramientas de desarrollo y anima el radio de una sombra en una tarjeta dentro de una lista densa. Anota el tamaño de la región que destella frente al de la tarjeta.
- Sustituye la animación de sombra por el cruce de opacidades de esta lección y comprueba qué destella ahora.
- Con la función
pixelesPorFrame, calcula los píxeles por segundo de las dos versiones en tu pantalla y en una hipotética dedevicePixelRatio3.