Como animar um desfoque

O desfoque é uma ótima maneira de redirecionar o foco de um usuário. Fazer com que alguns elementos visuais apareçam desfocados enquanto outros permanecem em foco direciona naturalmente a atenção do usuário. Os usuários ignoram o conteúdo desfocado e se concentram no que podem ler. Um exemplo seria uma lista de ícones que mostram detalhes sobre os itens individuais quando o cursor passa por cima deles. Durante esse período, as opções restantes podem ficar desfocadas para redirecionar o usuário às informações recém-mostradas.

Texto longo, leia o resumo

Animar um desfoque não é uma opção porque é muito lento. Em vez disso, pré-calcule uma série de versões cada vez mais desfocadas e faça uma transição gradual entre elas. Meu colega Yi Gu escreveu uma biblioteca para cuidar de tudo para você. Confira nossa demonstração.

No entanto, essa técnica pode ser bastante chocante quando aplicada sem um período de transição. Animar um desfoque (transição de sem desfoque para com desfoque) parece uma escolha razoável, mas se você já tentou fazer isso na Web, provavelmente descobriu que as animações não são nada suaves, como mostra esta demonstração se você não tiver uma máquina potente. Podemos melhorar?

O problema

A marcação é
transformada em texturas pela CPU. As texturas são enviadas para a GPU. A GPU
renderiza essas texturas no framebuffer usando sombreadores. O desfoque acontece no sombreador.

No momento, não é possível animar um desfoque de maneira eficiente. No entanto, podemos encontrar uma solução alternativa que pareça boa o suficiente, mas que, tecnicamente, não seja um desfoque animado. Para começar, vamos entender por que o desfoque animado é lento. Para desfocar elementos na Web, há duas técnicas: a propriedade CSS filter e os filtros SVG. Graças ao maior suporte e facilidade de uso, os filtros CSS são usados com frequência. Infelizmente, se você precisar oferecer suporte ao Internet Explorer, não terá outra opção a não ser usar filtros SVG, já que o IE 10 e 11 são compatíveis com eles, mas não com filtros CSS. A boa notícia é que nossa solução alternativa para animar um desfoque funciona com as duas técnicas. Vamos tentar encontrar o gargalo usando o DevTools.

Se você ativar a opção "Pintar intermitente" no DevTools, não vai ver nenhum flash. Parece que não há repinturas acontecendo. Isso está tecnicamente correto, já que um "repintura" se refere à CPU ter que repintar a textura de um elemento promovido. Sempre que um elemento é promovido e desfocado, o desfoque é aplicado pela GPU usando um shader.

Os filtros SVG e CSS usam filtros de convolução para aplicar um desfoque. Os filtros de convolução são bastante caros, já que para cada pixel de saída é preciso considerar um número de pixels de entrada. Quanto maior a imagem ou o raio de desfoque, mais caro será o efeito.

É aí que está o problema. Estamos executando uma operação de GPU bastante cara a cada frame, excedendo nosso orçamento de 16 ms e, portanto, ficando bem abaixo de 60 fps.

Na toca do coelho

O que podemos fazer para que isso funcione sem problemas? Podemos usar truques de mágica! Em vez de animar o valor real do desfoque (o raio do desfoque), pré-calculamos algumas cópias desfocadas em que o valor do desfoque aumenta exponencialmente e fazemos uma transição gradual entre elas usando opacity.

O crossfade é uma série de entradas e saídas de opacidade sobrepostas. Se tivermos quatro estágios de desfoque, por exemplo, vamos diminuir o primeiro estágio enquanto aumentamos o segundo ao mesmo tempo. Quando a segunda etapa atinge 100% de opacidade e a primeira chega a 0%, diminuímos a segunda etapa enquanto aumentamos a terceira. Depois disso, vamos diminuir a terceira etapa e aumentar a quarta e última versão. Nesse cenário, cada etapa levaria ¼ da duração total desejada. Visualmente, isso é muito parecido com um desfoque real e animado.

Nos nossos experimentos, aumentar o raio de desfoque exponencialmente por estágio gerou os melhores resultados visuais. Exemplo: se tivermos quatro etapas de desfoque, vamos aplicar filter: blur(2^n) a cada etapa. Ou seja, etapa 0: 1 px, etapa 1: 2 px, etapa 2: 4 px e etapa 3: 8 px. Se forçarmos cada uma dessas cópias desfocadas para a própria camada (chamada de "promoção") usando will-change: transform, a mudança de opacidade nesses elementos será muito rápida. Em teoria, isso nos permitiria fazer o trabalho caro de desfoque antecipadamente. Acontece que a lógica é falha. Se você executar esta demonstração, vai notar que a taxa de frames ainda está abaixo de 60 fps e que o desfoque está pior do que antes.

O DevTools
  mostrando um rastreamento em que a GPU tem longos períodos de tempo ocupado.

Uma rápida olhada no DevTools revela que a GPU ainda está extremamente ocupada e estende cada frame para ~90 ms. Mas por quê? Não estamos mais mudando o valor de desfoque, apenas a opacidade. Então, o que está acontecendo? O problema está, mais uma vez, na natureza do efeito de desfoque: como explicado antes, se o elemento for promovido e desfocado, o efeito será aplicado pela GPU. Portanto, mesmo que não estejamos mais animando o valor do desfoque, a textura em si ainda está sem desfoque e precisa ser desfocada novamente a cada frame pela GPU. A taxa de frames ainda pior do que antes ocorre porque, em comparação com a implementação simples, a GPU tem mais trabalho do que antes, já que na maioria das vezes duas texturas ficam visíveis e precisam ser desfocadas de forma independente.

O que criamos não é bonito, mas torna a animação extremamente rápida. Voltamos a não promover o elemento a ser desfocado, mas sim um wrapper pai. Se um elemento estiver desfocado e promovido, o efeito será aplicado pela GPU. Isso é o que deixou nossa demonstração lenta. Se o elemento estiver desfocado, mas não promovido, o desfoque será rasterizado para a textura mãe mais próxima. No nosso caso, é o elemento wrapper pai promovido. A imagem desfocada agora é a textura do elemento pai e pode ser reutilizada em todos os frames futuros. Isso só funciona porque sabemos que os elementos desfocados não são animados e que o armazenamento em cache deles é realmente benéfico. Confira uma demonstração que implementa essa técnica. O que será que o Moto G4 acha dessa abordagem? Alerta de spoiler: ele acha que é ótimo:

O DevTools
  mostrando um rastreamento em que a GPU tem muito tempo ocioso.

Agora temos muita margem de manobra na GPU e 60 fps suaves. Conseguimos!

Como colocar em produção

Na nossa demonstração, duplicamos uma estrutura DOM várias vezes para ter cópias do conteúdo a ser desfocado em diferentes intensidades. Talvez você esteja se perguntando como isso funcionaria em um ambiente de produção, já que pode ter alguns efeitos colaterais não intencionais com os estilos CSS ou até mesmo o JavaScript do autor. Você tem razão. Entre no Shadow DOM!

Embora a maioria das pessoas pense no Shadow DOM como uma forma de anexar elementos "internos" aos elementos personalizados, ele também é uma primitiva de isolamento e desempenho. JavaScript e CSS não podem ultrapassar os limites do Shadow DOM, o que permite duplicar conteúdo sem interferir nos estilos ou na lógica do aplicativo do desenvolvedor. Já temos um elemento <div> para cada cópia a ser rasterizada e agora usamos esses <div>s como hosts de sombra. Criamos um ShadowRoot usando attachShadow({mode: 'closed'}) e anexamos uma cópia do conteúdo ao ShadowRoot em vez do próprio <div>. Também precisamos copiar todas as folhas de estilo para o ShadowRoot para garantir que nossas cópias sejam estilizadas da mesma forma que o original.

Alguns navegadores não são compatíveis com o Shadow DOM v1. Para eles, voltamos a duplicar o conteúdo e esperamos que nada seja interrompido. Poderíamos usar o polyfill do Shadow DOM com o ShadyCSS, mas não implementamos isso na nossa biblioteca.

E pronto. Depois da nossa jornada pelo pipeline de renderização do Chrome, descobrimos como animar desfoques com eficiência em todos os navegadores.

Conclusão

Esse tipo de efeito não deve ser usado de forma leviana. Como copiamos elementos do DOM e os forçamos a ter uma camada própria, podemos ultrapassar os limites de dispositivos de baixo custo. Copiar todas as folhas de estilo em cada ShadowRoot também é um possível risco de desempenho. Por isso, decida se prefere ajustar sua lógica e estilos para não serem afetados por cópias no LightDOM ou usar nossa técnica ShadowDOM. Mas, às vezes, nossa técnica pode ser um investimento que vale a pena. Confira o código no nosso repositório do GitHub, a demonstração e me mande uma mensagem no Twitter se tiver alguma dúvida.