Cómo animar un desenfoque

El desenfoque es una excelente manera de redireccionar la atención del usuario. Hacer que algunos elementos visuales aparezcan desenfocados mientras se mantienen otros elementos enfocados dirige de forma natural la atención del usuario. Los usuarios ignoran el contenido difuminado y, en su lugar, 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 se pueden desenfocar para redireccionar al usuario a la información recién mostrada.

A modo de resumen

Animar un desenfoque no es una opción, ya que es muy lento. En su lugar, precalcula una serie de versiones cada vez más desenfocadas y realiza una transición entre ellas. Mi colega Yi Gu escribió una biblioteca para encargarse de todo por ti. Consulta nuestra demo.

Sin embargo, esta técnica puede ser bastante discordante cuando se aplica sin ningún período de transición. Animar un desenfoque (la transición de un elemento enfocado a uno desenfocado) parece una opción razonable, pero si alguna vez intentaste hacerlo en la Web, es probable que hayas descubierto que las animaciones son todo menos fluidas, como se muestra en esta demo si no tienes una máquina potente. ¿Podemos hacerlo mejor?

El problema

La CPU convierte el marcado en texturas. Las texturas se suben a la GPU. La GPU dibuja estas texturas en el búfer de trama con sombreadores. 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 alternativa que se vea suficientemente bien, pero que, técnicamente hablando, no sea un desenfoque animado. Para comenzar, primero debemos comprender 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, por lo general, se usan los filtros de CSS. Lamentablemente, si debes admitir Internet Explorer, no tienes más remedio que usar filtros SVG, ya que IE 10 y 11 admiten esos filtros, pero no los filtros CSS. La buena noticia es que nuestra solución para animar un desenfoque funciona con ambas técnicas. Así que intentemos encontrar el cuello de botella con DevTools.

Si habilitas "Paint Flashing" en DevTools, no verás ningún flash. Al parecer, no se están realizando repintados. Y eso es técnicamente correcto, ya que una "nueva pintura" se refiere a que la CPU debe volver a pintar la textura de un elemento promovido. Cada vez 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 para 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.

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

Down the rabbit hole

Entonces, ¿qué podemos hacer para que esto funcione sin problemas? Podemos usar la prestidigitación. 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 de desenfoque aumenta de forma exponencial y, luego, realizamos una transición entre ellas con opacity.

La compaginación es una serie de atenuaciones y atenuaciones de opacidad superpuestas. Si, por ejemplo, tenemos cuatro etapas de desenfoque, 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 lo hagas, finalmente atenuarás la tercera etapa y atenuarás la cuarta y última versión. En esta situación, cada etapa tomaría ¼ de la duración total deseada. Visualmente, se ve muy similar a un desenfoque animado real.

En nuestros experimentos, aumentar el radio de desenfoque de forma exponencial por etapa produjo los mejores resultados visuales. Ejemplo: Si tenemos cuatro etapas de desenfoque, aplicaríamos filter: blur(2^n) a cada una, 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 difuminadas en su propia capa (llamada “promoción”) con will-change: transform, cambiar la opacidad en estos elementos debería ser muy rápido. En teoría, esto nos permitiría cargar en primer plano 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 por debajo de 60 fps y que el desenfoque es peor que antes.

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

Un vistazo rápido a DevTools revela que la GPU sigue estando muy ocupada y estira cada fotograma a alrededor de 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í no está desenfocada y la GPU debe volver a desenfocarla en cada fotograma. El motivo por el que la velocidad de fotogramas es aún peor que antes se debe al hecho de que, en comparación con la implementación ingenua, 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 promocionar el elemento que se debe desenfocar, sino que promocionamos un wrapper superior. Si un elemento está desenfocado y promovido, 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 renderiza en la textura superior más cercana. En nuestro caso, ese es el elemento del wrapper superior promovido. La imagen difuminada ahora es la textura del elemento superior y se puede volver a usar para todos los fotogramas futuros. Esto solo funciona porque sabemos que los elementos difuminados no están animados y almacenarlos en caché es realmente beneficioso. Aquí hay una demostración que implementa esta técnica. Me pregunto qué opina el Moto G4 sobre este enfoque. Alerta de spoiler: cree que es genial:

DevTools muestra un registro en el que la GPU tiene mucho tiempo inactivo.

Ahora tenemos mucho margen en la GPU y una velocidad de 60 fps fluida. ¡Lo logramos!

Producción

En nuestra demostración, duplicamos una estructura de DOM varias veces para tener copias del contenido que se desenfoque con diferentes intensidades. Es posible que 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 código JavaScript. Tienes razón. ¡Entramos en el 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 de la DOM sombreada, lo que nos permite duplicar el contenido sin interferir en los estilos del desarrollador ni en la lógica de la aplicación. Ya tenemos un elemento <div> para cada copia que se rasterizará y ahora usamos estos <div> como hosts en la sombra. Creamos un ShadowRoot con attachShadow({mode: 'closed'}) y adjuntamos una copia del contenido a ShadowRoot en lugar de a <div>. Debemos asegurarnos de copiar todos los diseños de página en ShadowRoot para garantizar que nuestras copias tengan el mismo diseño que el original.

Algunos navegadores no admiten Shadow DOM v1 y, para ellos, solo duplicamos el contenido y esperamos que no se produzca ningún error. Podríamos usar el polyfill de Shadow DOM con ShadyCSS, pero no lo implementamos en nuestra biblioteca.

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

Conclusión

Este tipo de efecto no debe usarse a la ligera. Debido a que copiamos los elementos del DOM y los forzamos en su propia capa, podemos superar los límites de los dispositivos de gama baja. Copiar todos los diseños de página 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 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 demo, y comunícate conmigo en Twitter si tienes alguna pregunta.