El coste de renderizado del desenfoque
Cómo se implementa de verdad una gaussiana en un rasterizador, por qué el coste no crece con el radio pero el área sí, y qué hacer con una lista de doscientas tarjetas con sombra.
La creencia habitual es que un desenfoque grande cuesta mucho más que uno pequeño, y con la implementación que usan los navegadores eso es falso: el coste por píxel es prácticamente el mismo. Lo que sí crece, y mucho, es el número de píxeles que hay que tocar, y ahí está la diferencia entre una lista que va fluida y una que se arrastra.
- Explicar cómo se aproxima una gaussiana con desenfoques de caja separables.
- Distinguir el coste por píxel del coste por área.
- Identificar cuándo una sombra se repinta y cuándo no.
- Aplicar las tres estrategias para listas largas con sombra.
Cómo se calcula un desenfoque
Una convolución gaussiana exacta en dos dimensiones con un núcleo de radio r cuesta del orden de r al cuadrado operaciones por píxel. Con radios grandes eso sería inasumible, y por eso nadie lo hace así.
Se usan dos propiedades para bajarlo. La gaussiana es separable: una convolución 2D equivale a una 1D horizontal seguida de una 1D vertical, con lo que el coste pasa de r al cuadrado a 2r por píxel. Y tres desenfoques de caja seguidos aproximan una gaussiana con un error visualmente despreciable, por el teorema del límite central; un desenfoque de caja se calcula con una suma acumulada que cuesta una operación por píxel independientemente del radio.
Combinando las dos, el desenfoque completo son seis pasadas —tres cajas por dos ejes— con coste constante por píxel. Es el algoritmo que usan los rasterizadores de todos los motores.
La consecuencia práctica va contra la intuición: el radio del desenfoque no es la variable que hay que vigilar. blur: 4px y blur: 40px cuestan aproximadamente lo mismo por píxel.
Lo que sí cuesta: el área
Lo que cambia con el radio es cuántos píxeles hay que procesar. La sombra de una caja de 300 por 200 con desenfoque de 8 píxeles ocupa unos 316 por 216. La misma con desenfoque de 64 ocupa unos 364 por 264, y si además tiene desplazamiento y spread, más. En números redondos, un desenfoque ocho veces mayor procesa un 40% más de píxeles en ese caso, no ochenta veces más.
Donde el área sí se dispara es en elementos grandes. Un panel a pantalla completa en una pantalla con factor de escala 2 son ocho millones de píxeles, y seis pasadas sobre ocho millones de píxeles sí se miden.
Y el multiplicador de verdad es el que ya conoces: cuántas veces por segundo. El desglose que importa:
| Situación | Coste |
|---|---|
| Sombra estática pintada una vez | Despreciable |
| Sombra en un elemento que cambia de tamaño | Repintado por fotograma |
transition: box-shadow entre dos valores |
Repintado por fotograma |
Sombra en un elemento que se mueve con transform |
Ninguno: el compositor mueve la textura |
| Doscientas sombras estáticas en una lista | Coste al pintar cada una, una vez |
La cuarta fila es la buena noticia y la que decide la arquitectura: una sombra que ya está rasterizada se mueve gratis. Trasladar, escalar o rotar un elemento con transform no vuelve a calcular su sombra, porque el compositor está moviendo una textura que ya contiene la sombra pintada.
Las tres estrategias
No animes box-shadow: cruza opacidades.
.tarjeta {
position: relative;
box-shadow: var(--elev-1);
}
.tarjeta::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--elev-3);
opacity: 0;
transition: opacity 200ms;
pointer-events: none;
}
.tarjeta:hover::after { opacity: 1; }
Las dos sombras se rasterizan una vez cada una al pintar la tarjeta. La transición mueve un alfa, que es trabajo de compositor. El resultado visual es indistinguible de animar la sombra y no repinta nada.
Fíjate en border-radius: inherit, que evita duplicar el radio, y en pointer-events: none, que impide que el pseudoelemento robe los eventos del contenido.
Comparte la sombra en vez de repetirla.
En una lista larga, doscientos elementos con sombra son doscientas rasterizaciones con desenfoque. Si todos los elementos tienen el mismo tamaño y la misma sombra, casi siempre se puede sustituir por una sola capa de fondo repetida en el contenedor, o por un borde y un separador. La pregunta que hay que hacerse es si la sombra de cada fila aporta información: si todas las filas están al mismo nivel, la sombra no dice nada y es puro coste.
Simplifica la sombra fuera del viewport.
Con content-visibility: auto en los elementos de una lista larga, el navegador se salta el pintado de lo que no está visible, sombras incluidas. Es la optimización de mayor efecto y la menos usada:
.fila {
content-visibility: auto;
contain-intrinsic-size: auto 72px;
}
contain-intrinsic-size es obligatorio para que la barra de desplazamiento no dé saltos: le dice al navegador qué tamaño suponer para lo que no ha pintado.
La diferencia de coste entre las dos no está en la gaussiana sino en lo que hay que preparar antes. box-shadow desenfoca una forma que el rasterizador genera al vuelo: un rectángulo redondeado. filter: drop-shadow() necesita el subárbol entero rasterizado a una textura aparte para poder leer su canal alfa, y esa textura hay que crearla, llenarla y después componerla. En un elemento con muchos hijos, ese paso previo domina el coste, y el desenfoque es la parte barata.
Cuando una interfaz con sombras va lenta, el instinto lleva a bajar los radios de desenfoque, y casi nunca es ahí donde está el problema. El coste real de un efecto de pintado se descompone en tres factores que se multiplican: el área en píxeles del dispositivo, el coste por píxel del algoritmo, y cuántas veces se invalida por segundo. De los tres, el que domina por órdenes de magnitud es el tercero, y es el único que casi nunca se mide. Una sombra enorme sobre un panel a pantalla completa que se pinta una vez al abrir un diálogo es gratis; una sombra minúscula sobre un elemento cuya caja cambia de tamaño sesenta veces por segundo porque está dentro de un contenedor que se anima con width es un desastre, y las dos aparecen igual en el CSS. De ahí sale el método de diagnóstico correcto, que no es leer la hoja de estilos sino grabar un perfil y mirar la columna de repintados: lo que hay que buscar no son las sombras grandes sino los rectángulos de invalidación grandes, que el panel de rendimiento dibuja sobre la página. Y de ahí sale también el criterio de arquitectura que resuelve la mayoría de los casos antes de que aparezcan: separa lo que cambia de lo que se pinta caro. Si un contenedor tiene una sombra y su contenido se anima, el contenido va en un hijo, no en el mismo elemento, porque así la invalidación del hijo no arrastra a la sombra del padre. Si un elemento tiene sombra y cambia de tamaño, lo que se anima es un transform: scale() sobre un tamaño fijo, no el ancho, porque escalar una textura no invalida nada. Es el mismo principio de siempre —trabaja sobre el parámetro, no sobre el resultado— aplicado al pintado, y una vez interiorizado hace que la pregunta “cuánto desenfoque me puedo permitir” desaparezca, porque la respuesta pasa a ser “todo el que quieras, mientras no lo invalides”.
- Anima una
box-shadowen un:hovery graba un perfil. Localiza los repintados. - Reescribe la misma animación como cruce de opacidades y vuelve a grabar.
- Mueve un elemento con sombra usando
transformy comprueba que no hay repintado. - Pon doscientas filas con sombra y añade
content-visibility: auto. Mide el tiempo de pintado antes y después. - Sustituye
box-shadowpordrop-shadow()en un subárbol complejo y compara los tiempos.