Cómo animar un desenfoque

El desenfoque es una excelente manera de redirigir el enfoque del usuario. Hacer que algunos elementos visuales aparezcan desenfocados mientras que otros se mantienen enfocados dirige naturalmente la atención del usuario. Los usuarios ignoran el contenido borroso y, en cambio, 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 desplaza el cursor sobre ellos. Durante ese tiempo, las opciones restantes podrían desenfocarse para redireccionar al usuario a la información que se acaba de mostrar.

A modo de resumen

Animar un desenfoque no es una opción viable, ya que es muy lento. En su lugar, calcula previamente una serie de versiones cada vez más borrosas y realiza una transición gradual entre ellas. Mi colega Yi Gu escribió una biblioteca para que no tengas que preocuparte por nada. Consulta nuestra demostración.

Sin embargo, esta técnica puede ser bastante brusca si se aplica sin ningún período de transición. Animar un desenfoque (transición de desenfocado a enfocado) parece una opción razonable, pero si alguna vez intentaste hacerlo en la Web, probablemente descubriste que las animaciones no son fluidas, como se muestra en esta demostración 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 dibuja estas texturas en el búfer de fotogramas con sombreadores. El desenfoque se produce 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 alternativa que se vea suficientemente bien, pero que, técnicamente, no sea un desenfoque animado. Para comenzar, primero comprendamos por qué el desenfoque animado es lento. Para desenfocar elementos en la Web, existen dos técnicas: la propiedad filter de CSS y los filtros SVG. Gracias a la mayor compatibilidad y facilidad de uso, se suelen utilizar filtros CSS. Lamentablemente, si debes admitir Internet Explorer, no tienes otra opción que usar filtros SVG, ya que IE 10 y 11 los admiten, pero no los filtros CSS. La buena noticia es que nuestra solución alternativa para animar un desenfoque funciona con ambas técnicas. Así que intentemos encontrar el cuello de botella con las Herramientas para desarrolladores.

Si habilitas "Paint Flashing" en las Herramientas para desarrolladores, no verás ningún destello. Al parecer, no se están realizando repintados. Y eso es técnicamente correcto, ya que "repintar" se refiere a que la CPU debe repintar la textura de un elemento promocionado. Siempre que un elemento se promueve y se desenfoca, la GPU aplica el desenfoque con un sombreador.

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

Y ahí radica el problema: estamos ejecutando una operación de GPU bastante costosa en cada fotograma, lo que supera nuestro presupuesto de 16 ms por fotograma y, por lo tanto, terminamos muy por debajo de los 60 FPS.

Por el agujero de conejo

Entonces, ¿qué podemos hacer para que esto funcione sin problemas? Podemos usar juegos de manos. En lugar de animar el valor de desenfoque real (el radio del desenfoque), precalculamos algunas copias desenfocadas en las que el valor de desenfoque aumenta de forma exponencial y, luego, realizamos una transición gradual entre ellas con opacity.

El efecto de transición cruzada es una serie de fundidos de entrada y salida superpuestos de opacidad. Si tenemos cuatro etapas de desenfoque, por ejemplo, atenuamos la primera etapa mientras atenuamos la segunda al mismo tiempo. Una vez que la segunda etapa alcanza el 100% de opacidad y la primera alcanza el 0%, atenuamos la segunda etapa mientras atenuamos la tercera. Una vez que se completa ese paso, finalmente atenuamos la tercera etapa y atenuamos la cuarta y última versión. En este caso, cada etapa duraría 1/4 de la duración total deseada. Visualmente, se parece mucho a un desenfoque animado real.

En nuestros experimentos, aumentar el radio de desenfoque de forma exponencial en cada etapa produjo 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 forzamos cada una de estas copias borrosas a su propia capa (lo que se denomina "promoción") con will-change: transform, cambiar la opacidad de estos elementos debería ser muy rápido. En teoría, esto nos permitiría adelantar el trabajo costoso de desenfoque. Resulta que la lógica es defectuosa. Si ejecutas esta demostración, verás que la velocidad de fotogramas sigue siendo inferior a 60 FPS y que el desenfoque es, en realidad, peor que antes.

DevTools muestra un registro en el que la GPU tiene largos períodos de tiempo ocupado.

Un vistazo rápido a las Herramientas para desarrolladores revela que la GPU sigue extremadamente ocupada y extiende cada fotograma a aproximadamente 90 ms. Pero ¿por qué? Ya no cambiamos el valor de desenfoque, solo la opacidad. ¿Qué sucede? El problema radica, una vez más, en la naturaleza del efecto de desenfoque: como se explicó antes, si el elemento se promueve y se desenfoca, la GPU aplica el efecto. Por lo tanto, aunque ya no animamos el valor de desenfoque, la textura en sí sigue sin desenfocarse y la GPU debe volver a desenfocarla en cada fotograma. El motivo por el que la velocidad de fotogramas es incluso peor que antes se debe a que, en comparación con la implementación simple, la GPU tiene más trabajo que antes, ya que la mayoría de las veces se ven dos texturas que deben desenfocarse de forma independiente.

Lo que creamos no es bonito, pero hace que la animación sea increíblemente rápida. Volvemos a no promover el elemento que se desenfocará, sino que promovemos un wrapper principal. Si un elemento está desenfocado y destacado, la GPU aplica el efecto. Esto es lo que ralentizó nuestra demostración. Si el elemento está desenfocado, pero no se promueve, el desenfoque se rasteriza en la textura principal más cercana. En nuestro caso, es el elemento wrapper principal promocionado. La imagen borrosa ahora es la textura del elemento principal y se puede reutilizar para 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. Aquí tienes una demostración que implementa esta técnica. Me pregunto qué pensará el Moto G4 de este enfoque. Alerta de spoiler: Cree que es genial:

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

Ahora tenemos mucho espacio libre en la GPU y unos fluidos 60 FPS. ¡Lo logramos!

Producción

En nuestra demostración, duplicamos una estructura del DOM varias veces para tener copias del contenido que se desenfocará con diferentes intensidades. 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 con los estilos CSS del autor o incluso con su JavaScript. Tienes razón. ¡Ingresa Shadow DOM!

Si bien la mayoría de las personas piensan en el Shadow DOM como una forma de adjuntar elementos "internos" a sus elementos personalizados, también es una primitiva de aislamiento y rendimiento. JavaScript y CSS no pueden atravesar los límites del DOM de sombra, lo que nos permite duplicar contenido sin interferir en los estilos o la lógica de la aplicación del desarrollador. Ya tenemos un elemento <div> para cada copia que se rasteriza y ahora usamos estos <div>s como hosts de sombra. Creamos un ShadowRoot con attachShadow({mode: 'closed'}) y adjuntamos una copia del contenido al ShadowRoot en lugar del <div> en sí. 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 admiten Shadow DOM v1 y, en esos casos, simplemente duplicamos el contenido y esperamos que no se produzcan errores. Podríamos usar el polyfill de Shadow DOM con ShadyCSS, pero no implementamos esto en nuestra biblioteca.

Y listo. Después de nuestro recorrido por la canalización de renderización de Chrome, descubrimos cómo animar desenfoques de manera eficiente en todos los navegadores.

Conclusión

Este tipo de efecto no debe usarse a la ligera. Debido a que copiamos elementos del DOM y los forzamos a su propia capa, podemos superar los límites de los dispositivos de gama baja. Copiar todas las hojas de diseño en cada ShadowRoot también es un posible riesgo 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 el LightDOM o usar nuestra técnica de ShadowDOM. Sin embargo, a veces, nuestra técnica puede ser una inversión que vale la pena. Consulta el código en nuestro repositorio de GitHub, así como la demostración, y contáctame en Twitter si tienes alguna pregunta.