Cómo animar un desenfoque

El desenfoque es una excelente manera de redirigir la atención del usuario. Hacer que algunos elementos visuales parezcan desenfocados y mantener otros en foco dirige naturalmente la atención del usuario. Los usuarios ignoran el contenido desenfocado y se enfocan en el contenido que pueden leer. Un ejemplo sería una lista de íconos que muestran detalles sobre los elementos individuales cuando se coloca el cursor sobre ellos. Durante ese tiempo, las opciones restantes podrían difuminarse para redireccionar al usuario a la información que se muestra recientemente.

Resumen

En realidad, animar un desenfoque no es una opción porque es muy lento. En su lugar, calcula previamente una serie de versiones cada vez más difuminadas y aplica fundido cruzado entre ellas. Mi colega Yi Gu escribió una biblioteca para ocuparse de todo por ti. Revisa nuestra demostración.

Sin embargo, esta técnica puede resultar bastante molesta cuando se aplica sin ningún período de transición. Animar un desenfoque (es decir, pasar de desenfocado a desenfocado) parece una opción razonable, pero si alguna vez intentaste hacerlo en la Web, es probable que hayas notado que las animaciones no son fluidas, ya que en esta demostración se puede ver si no tienes una máquina potente. ¿Podemos hacerlo mejor?

El problema

La CPU convierte el lenguaje de marcado en texturas. Las texturas se suben a la GPU. La GPU usa sombreadores para dibujar estas texturas en el búfer de fotogramas. El desenfoque ocurre en el sombreador.

Por el momento, no podemos hacer que la animación de un desenfoque funcione de manera eficiente. Sin embargo, podemos encontrar una solución que se vea suficientemente bien, pero que, técnicamente, no sea un desenfoque animado. Para comenzar, primero debes comprender por qué el desenfoque animado es lento. Para desenfocar elementos en la Web, hay dos técnicas: la propiedad filter de CSS y los filtros de SVG. Gracias a la mayor compatibilidad y la facilidad de uso, por lo general se utilizan filtros CSS. Lamentablemente, si es necesario que admitas Internet Explorer, no tienes más opción que usar los filtros SVG como si en IE 10 y 11 se admiten esos filtros, pero no CSS. La buena noticia es que nuestra solución para animar un desenfoque funciona con ambas técnicas. Intentemos encontrar el cuello de botella en Herramientas para desarrolladores.

Si habilitas "Paint Flashing" en Herramientas para desarrolladores, no verás ningún flash. Parece que no hay repeticiones de la pintura. Y eso es técnicamente correcto, ya que "volver a pintar" se refiere a que la CPU debe volver a pintar la textura de un elemento promovido. Cuando un elemento se promueve y se desenfoca, la GPU aplica el desenfoque a través de un sombreador.

Tanto los filtros de SVG como los de CSS usan filtros de convolución para aplicar un desenfoque. Los filtros de convolución son bastante costosos, ya que, por cada píxel de salida, se debe considerar una cantidad de píxeles de entrada. Cuanto más grande sea la imagen o el radio de desenfoque, más costoso será el efecto.

Ahí es donde radica el problema: estamos ejecutando una operación bastante costosa de la GPU en cada fotograma, lo que hace que nuestro presupuesto de fotogramas sea de 16 ms y, por lo tanto, queda bastante por debajo de los 60 FPS.

En la madriguera de los conejos

¿Qué podemos hacer para que esto funcione sin problemas? ¡Podemos usar la presunta! En lugar de animar el valor de desenfoque real (el radio del desenfoque), calculamos previamente un par de copias desenfocadas en las que el valor aumenta de forma exponencial y, luego, aplica un fundido cruzado entre ellas mediante opacity.

El fundido cruzado es una serie de fundido de entrada y de salida con opacidad superpuestas. Por ejemplo, si tenemos cuatro etapas de desenfoque, se aplica un fundido de salida en la primera etapa y, al mismo tiempo, en la segunda. Una vez que la segunda etapa alcanza el 100% de opacidad y la primera alcanza el 0%, se aplica un fundido de salida en la segunda etapa y, al mismo tiempo, la tercera. Una vez hecho esto, se atenúa la tercera etapa y la cuarta versión final. En este caso, cada etapa tomaría 1⁄4 de la duración total deseada. Visualmente, esto se ve muy similar a un desenfoque real y animado.

En nuestros experimentos, el aumento exponencial del radio de desenfoque por etapa generó los mejores resultados visuales. Ejemplo: Si tenemos cuatro etapas de desenfoque, aplicaríamos filter: blur(2^n) a cada etapa, es decir, etapa 0: 1 px, etapa 1: 2 px, etapa 2: 4 px y etapa 3: 8 px. Si fuerzamos cada una de estas copias desenfocadas a su propia capa (llamada "promoción") con will-change: transform, el cambio de opacidad de estos elementos debería ser muy rápido. En teoría, esto nos permitiría cargar el costoso trabajo de difuminado. Resulta que la lógica es defectuoso. Si ejecutas esta demostración, verás que la velocidad de fotogramas sigue siendo inferior a 60 FPS y que el desenfoque es realmente peor que antes.

Herramientas para desarrolladores que muestran un seguimiento en el que la GPU tiene largos períodos de tiempo de actividad.

Si buscas rápidamente Herramientas para desarrolladores, se revela que la GPU sigue siendo muy ocupada y se extiende cada fotograma a aproximadamente 90 ms. Pero ¿por qué? Ya no cambiamos el valor de desenfoque, solo la opacidad. ¿Qué sucede? Una vez más, el problema se encuentra en la naturaleza del efecto de desenfoque: como se explicó anteriormente, si el elemento está promovido y desenfocado, la GPU aplica el efecto. Por lo tanto, aunque ya no animamos el valor de desenfoque, la textura en sí misma sigue sin difuminarse y debe volver a difuminarla la GPU en cada fotograma. La razón por la que la velocidad de fotogramas es aún peor que antes se debe al hecho de que, en comparación con la implementación nueva, la GPU tiene más trabajo que antes, ya que la mayoría de las veces, dos texturas son visibles y deben difuminarse de forma independiente.

Lo que se nos ocurrió no es bueno, pero hace que la animación sea increíblemente rápida. Volvamos a no promover el elemento que se desenfocará, sino que promocionamos un wrapper superior. Si un elemento se desenfoca y se promueve, la GPU aplica el efecto. Esto fue lo que hizo que nuestra demostración fuera más lenta. En cambio, si el elemento está desenfocado, pero no se promueve, se rasteriza en la textura superior más cercana. En nuestro caso, es el elemento del wrapper superior promocionado. Ahora, la imagen desenfocada es la textura del elemento principal y se puede volver a usar en todos los fotogramas futuros. Esto solo funciona porque sabemos que los elementos desenfocados no están animados y que almacenarlos en caché es realmente beneficioso. En esta demostración, se implementa esta técnica. Me pregunto qué piensa el Moto G4 sobre este enfoque. Alerta de spoiler: piensa que es genial:

Herramientas para desarrolladores que muestran un seguimiento en el que la GPU tiene mucho tiempo de inactividad.

Ahora tenemos mucho margen en la GPU y 60 fps más fluidas. ¡Lo logramos!

Producción

En nuestra demostración, duplicamos una estructura del DOM varias veces para tener copias del contenido que se pueden difuminar en diferentes fortalezas. Quizás te preguntes cómo funcionaría esto en un entorno de producción, ya que podría tener algunos efectos secundarios no deseados en los estilos de CSS del autor o incluso su JavaScript. Tienes razón. ¡Entra al Shadow DOM!

Si bien la mayoría de las personas piensan en Shadow DOM como una forma de adjuntar elementos "internos" a sus elementos personalizados, también es un elemento primitivo de aislamiento y rendimiento. JavaScript y CSS no pueden atravesar los límites del Shadow DOM, lo que nos permite duplicar contenido sin interferir con los estilos ni la lógica de la aplicación del desarrollador. Ya tenemos un elemento <div> para cada copia en la que se rasterizará y ahora usamos estos <div> como hosts de sombra. Creamos un ShadowRoot con attachShadow({mode: 'closed'}) y adjuntamos una copia del contenido a ShadowRoot en lugar de <div>. También debemos asegurarnos de copiar todas las hojas de estilo en ShadowRoot para garantizar que nuestras copias tengan el mismo estilo que el original.

Algunos navegadores no son compatibles con Shadow DOM v1, y para ellos, recurrimos a solo duplicar el contenido y esperar lo mejor para que nada se rompa. Podríamos usar el polyfill Shadow DOM con ShadyCSS, pero no lo implementamos en nuestra biblioteca.

Y ahí lo tienes. Después de nuestro recorrido por la canalización de renderización de Chrome, descubrimos cómo podemos animar los desenfoques de forma eficiente en todos los navegadores.

Conclusión

Este tipo de efecto no debe usarse a la ligera. Debido al hecho de que copiamos los elementos del DOM y los forzamos a su propia capa, podemos ampliar los límites de los dispositivos de gama inferior. Copiar todas las hojas de estilo en cada ShadowRoot también es un riesgo potencial de rendimiento, por lo que debes decidir si prefieres ajustar tu lógica y tus diseños para que no se vean afectados por las copias en LightDOM o usar nuestra técnica ShadowDOM. Pero, a veces, nuestra técnica puede valer la pena. Revisa el código en nuestro repositorio de GitHub y la demostración. Si tienes alguna pregunta, escríbeme en Twitter.