Como animar um desfoque

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

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 um crossfade 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 perturbadora quando aplicada sem nenhum período de transição. Animar um desfoque, fazendo a transição de desfocado para focado, 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
desenha essas texturas no framebuffer usando sombreadores. O desfoque acontece no
sombreador.

No momento, não é possível fazer a animação de um desfoque funcionar de maneira eficiente. No entanto, podemos encontrar uma solução alternativa que parece boa o suficiente, mas que, tecnicamente falando, não é 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 aumento do suporte e à facilidade de uso, os filtros CSS são normalmente usados. Se você precisar oferecer suporte ao Internet Explorer, não terá outra escolha a não ser usar filtros SVG, já que o IE 10 e o 11 oferecem suporte a eles, mas não aos 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 "Paint Flashing" nas Ferramentas do desenvolvedor, não vai aparecer nenhum flash. Parece que não há repintagens acontecendo. Isso é tecnicamente correto, já que uma "repintura" se refere à CPU que precisa repintar a textura de um elemento promovido. Sempre que um elemento é promovido e desfocado, o desfoque é aplicado pela GPU usando um sombreador.

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.

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

No fundo do poço

O que podemos fazer para que isso funcione bem? Podemos usar um truque! Em vez de animar o valor de desfoque real (o raio do desfoque), pré-calculamos algumas cópias desfocadas em que o valor de desfoque aumenta exponencialmente e, em seguida, fazemos a transição entre elas usando opacity.

O cross-fade é uma série de transições de opacidade sobrepostas. Se temos quatro estágios de desfoque, por exemplo, desfocamos o primeiro e ativamos o segundo ao mesmo tempo. Quando o segundo estágio atinge 100% de opacidade e o primeiro atinge 0%, o segundo desaparece enquanto o terceiro aparece. Depois disso, finalmente desfocamos a terceira etapa e desfocamos na quarta e última versão. Nesse cenário, cada etapa levaria ¼ da duração total desejada. Visualmente, isso se parece muito com um desfoque animado real.

Em nossos experimentos, aumentar o raio de desfoque exponencialmente por fase gerou os melhores resultados visuais. Exemplo: se tivermos quatro estágios de desfoque, aplicaremos filter: blur(2^n) a cada um deles, ou seja, estágio 0: 1px, estágio 1: 2px, estágio 2: 4px e estágio 3: 8px. Se forçarmos cada uma dessas cópias desfocadas na própria camada (chamada de "promoção") usando will-change: transform, a mudança de opacidade nesses elementos será super-rápida. Em teoria, isso nos permitiria carregar antecipadamente o trabalho caro de desfoque. Acontece que a lógica está errada. 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.

As ferramentas do desenvolvedor
  mostrando um rastro em que a GPU tem longos períodos de tempo ocupado.

Uma rápida olhada nas Ferramentas do desenvolvedor revela que a GPU ainda está extremamente ocupada e esticando cada frame para cerca de 90 ms. Mas por quê? Não estamos mais mudando o valor de desfoque, apenas a opacidade. O que está acontecendo? O problema está, mais uma vez, na natureza do efeito de desfoque: como explicado anteriormente, se o elemento for promovado e desfocado, o efeito será aplicado pela GPU. Portanto, mesmo que não estejamos mais animando o valor de desfoque, a textura ainda não está desfocada e precisa ser desfocada novamente em cada frame pela GPU. A razão pela qual a taxa de frames está ainda pior do que antes vem do fato de que, em comparação com a implementação simples, a GPU tem mais trabalho do que antes, já que a maior parte do tempo duas texturas estão visíveis e precisam ser desfocadas de forma independente.

O que criamos não é bonito, mas deixa a animação extremamente rápida. Voltamos a não promover o elemento a ser desfocado, mas promovemos um wrapper pai. Se um elemento for 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, esse é o elemento de wrapper pai promovido. A imagem desfocada agora é a textura do elemento pai e pode ser reutilizada para todos os frames futuros. Isso só funciona porque sabemos que os elementos desfocados não são animados e armazená-los em cache é realmente benéfico. Confira uma demonstração que implementa essa técnica. O que o Moto G4 acha dessa abordagem? Alerta de spoiler: ele acha que é ótimo:

As ferramentas do desenvolvedor
  mostrando um rastro em que a GPU tem muito tempo ocioso.

Agora temos muito espaço na GPU e 60 fps. Conseguimos!

Como colocar em produção

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

Embora a maioria das pessoas pense no Shadow DOM como uma maneira de anexar elementos "internos" aos elementos personalizados, ele também é uma primitiva de isolamento e desempenho. O JavaScript e o CSS não podem perfurar os limites do DOM Shadow, o que nos 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 rasterizar 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 <div>. Precisamos copiar todas as folhas de estilo para o ShadowRoot para garantir que nossas cópias tenham o mesmo estilo que o original.

Alguns navegadores não oferecem suporte ao Shadow DOM v1. Para esses casos, duplicamos o conteúdo e torcemos para que nada quebre. Poderíamos usar o polyfill de DOM de sombra com ShadyCSS, mas não implementamos isso na nossa biblioteca.

Pronto. Depois de nossa jornada pelo pipeline de renderização do Chrome, descobrimos como podemos animar desfoque de forma eficiente em todos os navegadores.

Conclusão

Esse tipo de efeito não deve ser usado de forma leviana. Como copiamos elementos DOM e os forçamos na própria camada, 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. Portanto, decida se você prefere ajustar sua lógica e estilos para não serem afetados por cópias no LightDOM ou usar nossa técnica ShadowDOM. No entanto, às vezes, nossa técnica pode ser um investimento valioso. Confira o código no nosso repositório do GitHub e a demonstração. Entre em contato comigo no Twitter se tiver dúvidas.