Qué es una capa de composición y qué cuesta
Cómo el motor decide crear capas, qué memoria consume cada una, y por qué promover todo lo que se mueve es peor que no promover nada.
Una animación solo puede vivir en el compositor si el elemento que anima tiene su propia capa. Esa condición se enuncia en una línea y esconde la decisión con más consecuencias de todo el rendimiento de animaciones: quién crea las capas, cuándo, y qué se paga por cada una. La respuesta ingenua —promover todo lo que se mueve— es la causa más frecuente de que una página vaya peor después de “optimizarla”, y el mecanismo por el que ocurre no es misterioso: es aritmética de memoria de vídeo.
- Explicar qué es una capa de composición y qué contiene.
- Calcular la memoria que consume una capa concreta.
- Enumerar los disparadores de creación de capa y distinguir los explícitos de los implícitos.
- Justificar por qué
will-changeaplicado a muchos elementos degrada el rendimiento.
Qué contiene una capa
Una capa de composición es una superficie rasterizada independiente, con sus propias baldosas de píxeles en memoria, más un conjunto de parámetros con los que el compositor la dibuja: matriz de transformación, opacidad, rectángulo de recorte, máscara y efectos.
Lo importante es la palabra independiente. Que un elemento tenga su propia capa significa que su contenido se rasteriza aparte del de sus vecinos, y por tanto que cambiarlo no obliga a repintar a nadie más, y que moverlo no obliga a repintar nada en absoluto.
Ese aislamiento es la ventaja. El coste es igual de concreto: cada capa consume memoria de textura, que es memoria de la GPU, y esa memoria es finita y compartida con todas las pestañas y con el sistema.
// Memoria aproximada de una capa, en bytes.
// 4 bytes por pixel: RGBA de 8 bits por canal.
function memoriaCapa(anchoCss, altoCss, dpr = devicePixelRatio) {
return Math.round(anchoCss * dpr) * Math.round(altoCss * dpr) * 4;
}
const mb = (b) => (b / 1024 / 1024).toFixed(1) + ' MB';
console.log(mb(memoriaCapa(320, 200))); // tarjeta pequena, dpr 2
console.log(mb(memoriaCapa(1440, 900))); // panel a pantalla completa
Una tarjeta de 320 por 200 en una pantalla de densidad doble ocupa un megabyte. Cuarenta tarjetas de una lista, cuarenta megabytes. Eso, en un móvil de gama media con un presupuesto de textura de unos pocos cientos de megabytes compartidos con el resto del sistema, es una fracción muy grande, y cuando se agota el navegador empieza a descartar baldosas y a rasterizarlas otra vez cuando hacen falta. El síntoma es el peor posible: parpadeos de contenido no rasterizado durante el scroll y fotogramas perdidos en la operación más barata.
Quién crea las capas
Hay dos vías, y confundirlas es la fuente de la mayoría de las sorpresas.
Vía explícita. Tú lo pides con will-change, o usas una propiedad que en la práctica lo garantiza. La lista de disparadores habituales:
| Disparador | Nota |
|---|---|
will-change: transform u opacity |
La forma correcta y explícita |
transform: translateZ(0) o translate3d(0,0,0) |
El truco histórico, hoy innecesario |
position: fixed |
En muchos casos, para poder desplazarse independiente |
video, canvas, iframe |
Contenido con su propia fuente de píxeles |
backdrop-filter |
Necesita leer la superficie de debajo |
| Una animación compuesta en curso | El motor promueve mientras dura |
Ese último caso merece énfasis: el motor crea la capa por sí solo cuando detecta una animación compuesta y la retira cuando termina. Para animaciones que empiezan por un cambio de estado —una transición al pasar el ratón— esto suele bastar, y añadir will-change de forma permanente es contraproducente.
Vía implícita. Y aquí está la trampa que produce explosiones de memoria sin que nadie haya escrito will-change. Si un elemento tiene una capa, y otro elemento se solapa con él y aparece por encima en el orden de pintado, ese segundo elemento también necesita capa. Si no la tuviera, el compositor no podría dibujarlo encima del primero: la capa promovida se dibuja al final, así que taparía a quien debería estar encima.
Esto se llama promoción implícita y tiene un efecto en cascada. Promover un elemento que está detrás de una lista larga puede promover la lista entera. El síntoma es haber añadido un will-change a un elemento y ver aparecer treinta capas en el inspector.
La corrección casi siempre es de orden: si el elemento que promueves está por encima de todo lo demás en el orden de pintado, no arrastra a nadie. Un z-index alto sobre un elemento posicionado, o simplemente ser el último hermano, resuelve el problema sin tocar la promoción.
will-change está mal entendido en un punto que cambia por completo cuándo hay que usarlo: no es una optimización, es un aviso previo. Le estás diciendo al motor “prepárate, porque esta propiedad va a cambiar pronto”, para que haga el trabajo de promoción antes de que empiece la animación en lugar de en el primer fotograma. El beneficio real es evitar el tirón inicial que se produce cuando la capa se crea a la vez que arranca el movimiento. Fuera de esa ventana, will-change no aporta nada y sí cuesta: mantiene reservada la memoria de la capa, impide algunas optimizaciones que el motor haría sobre contenido estático, y en el caso de will-change: transform fuerza además un contexto de apilamiento y un bloque contenedor nuevo, lo que puede romper el posicionamiento de descendientes con position: fixed de una forma que nadie relaciona con la causa. La consecuencia es que el patrón correcto tiene dos pasos, y el segundo es el que todo el mundo se salta: se pone antes de que haga falta y se quita en cuanto deja de hacer falta. Ponerlo en una regla base de un componente para “que vaya siempre suave” es exactamente lo contrario de lo que la propiedad hace, y en una lista con muchos componentes es la vía más rápida a un agotamiento de memoria de textura. La especificación llega a decirlo de forma explícita: no lo apliques a demasiados elementos y no lo dejes puesto. Si no puedes escribir la línea que lo quita, probablemente no deberías escribir la que lo pone.
El patrón completo, con las dos mitades:
// Promocion acotada a la duracion real de la animacion.
async function desplegar(panel) {
panel.style.willChange = 'transform, opacity';
// Deja que el motor cree la capa antes de empezar a mover.
await new Promise(requestAnimationFrame);
const anim = panel.animate(
[{ translate: '0 -12px', opacity: 0 }, { translate: '0 0', opacity: 1 }],
{ duration: 240, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }
);
try {
await anim.finished;
} finally {
panel.style.willChange = ''; // siempre, tambien si se cancela
}
}
El finally no es un adorno defensivo: si la animación se cancela porque el usuario cerró el panel a mitad, sin él la capa se queda promovida para siempre.
En CSS, cuando la animación la dispara un estado, la forma limpia es acotar will-change al estado previo al cambio, no a la regla base:
.tarjeta { transition: translate 200ms ease-out; }
/* La promocion se pide justo antes de que el usuario pueda disparar
la animacion, y desaparece cuando el puntero se va. */
.lista:hover .tarjeta { will-change: translate; }
.tarjeta:hover { translate: 0 -4px; }
Cuántas capas son demasiadas
No hay un número mágico, pero sí un procedimiento. El presupuesto está en memoria, no en cantidad, así que la pregunta correcta es cuántos megabytes de textura estás pidiendo.
Suma el área de todas las capas, multiplica por el cuadrado del factor de densidad y por cuatro bytes. Si el resultado se acerca a las decenas de megabytes en escritorio o supera unos pocos en móvil, tienes un problema aunque el número de capas sea pequeño. Una sola capa a pantalla completa en un móvil de densidad 3 son más de treinta megabytes.
Y hay una asimetría que ayuda a decidir: una capa grande es peor que muchas pequeñas de la misma área total, porque la grande no se puede descartar parcialmente con la misma granularidad y porque su rasterización inicial es un pico. Si tienes que promover una zona grande, promueve solo la parte que se mueve.
- Abre el panel de capas de las herramientas de desarrollo en una página tuya y anota cuántas hay y su tamaño.
- Calcula la memoria total con la función de esta lección y compárala con lo que informa el propio panel.
- Añade
will-change: transforma un elemento que esté por debajo de otros en el orden de pintado y cuenta cuántas capas aparecen. Explica la cascada. - Muévelo al final del orden de pintado y comprueba que la cascada desaparece.