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 outros elementos são direcionados direciona naturalmente o foco do usuário. Os usuários ignoram o conteúdo desfocado e, em vez disso, se concentram no conteúdo que podem ler. Um exemplo seria uma lista de ícones que mostram detalhes sobre os itens individuais ao passar o cursor. Durante esse período, as opções restantes podem ser desfocadas para redirecionar o usuário para as 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é-compute uma série de versões cada vez mais borradas e faça a transição 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 desagradável quando aplicada sem qualquer período de transição. Animar um desfoque, ou seja, passar de sem desfoque para desfocado, parece uma escolha razoável, mas se você já tentou fazer isso na Web, provavelmente descobriu que as animações não são fluidas, como nesta 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.

Por enquanto, não é possível fazer a animação de um desfoque funcionar de maneira eficiente. No entanto, podemos encontrar uma solução que pareça bem o suficiente, mas que, tecnicamente falando, não seja um desfoque animado. Para começar, primeiro vamos entender por que o desfoque animado é lento. Para desfocar elementos na Web, há duas técnicas: a propriedade CSS filter e os filtros de SVG. Graças ao maior suporte e à facilidade de uso, os filtros CSS normalmente são usados. Infelizmente, se for necessário oferecer suporte ao Internet Explorer, você não poderá usar filtros SVG, já que o IE 10 e 11 oferecem suporte a eles, mas não aos filtros de 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 na DevTools.

Se você ativar a opção "Paint Flashing" no DevTools, não haverá nenhum flash. Parece que não há repinturas. E isso está tecnicamente correto, porque a CPU 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, é necessário considerar alguns 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 muito cara em cada frame, ultrapassando nosso orçamento de frames de 16 ms e, portanto, resultando bem abaixo de 60 fps.

Na toca do coelho

O que podemos fazer para que tudo corra bem? Podemos usar truques! Em vez de animar o valor real do desfoque, ou seja, o raio dele, pré-calculamos algumas cópias desfocadas em que o valor aumenta exponencialmente e, em seguida, fazemos a transição cruzada entre elas usando opacity.

O cross-fade é uma série de esmaecimentos e esmaecimentos de opacidade sobrepostos. Se temos quatro fases de desfoque, por exemplo, o primeiro estágio é esmaecido e, ao mesmo tempo, o segundo é esmaecido. Quando a segunda fase atinge 100% de opacidade e a primeira atinge 0%, o esmaecimento do segundo estágio é esmaecido na terceira. Depois disso, finalmente esmaecemos o terceiro estágio e a quarta e última versão. Nesse cenário, cada fase levaria 1⁄4 da duração total desejada. Visualmente, isso é muito semelhante a um desfoque animado real.

Nos nossos experimentos, aumentar o raio de desfoque exponencialmente por estágio rendeu os melhores resultados visuais. Exemplo: se tivéssemos quatro estágios de desfoque, aplicaríamos filter: blur(2^n) a cada estágio, ou seja, estágio 0: 1 px, estágio 1: 2 px, estágio 2: 4 px e estágio 3: 8 px. Se forçarmos cada uma dessas cópias desfocadas na própria camada (chamada "promoção") usando will-change: transform, a mudança da opacidade desses elementos será muito rápida. Em teoria, isso nos permitiria carregar antecipadamente o caro trabalho de desfoque. Acontece que a lógica é falha. Se você executar esta demonstração, vai notar que o frame rate ainda está abaixo de 60 QPS e, na verdade, o desfoque é pior do que antes.

DevTools
  mostrando um rastro 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 aumenta cada frame em 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 na natureza do efeito de desfoque: como explicado anteriormente, se o elemento for promovido 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 desfocado novamente a cada frame pela GPU. A razão para o frame rate ser 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 pensamos não é bonito, mas torna a animação incrivelmente rápida. Voltamos a não promover o elemento a ser desfocado, mas promovemos um wrapper pai. Se um elemento estiver desfocado e promovido, o efeito será aplicado pela GPU. Isso deixou nossa demonstração lenta. Se o elemento estiver desfocado, mas não for promovido, o desfoque será rasterizado para a textura pai mais próxima. No nosso caso, é o elemento 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 o armazenamento em cache é realmente benéfico. Veja uma demonstração que implementa essa técnica. O que será que o Moto G4 acha dessa abordagem? Dica: acho que está ótimo:

DevTools
  mostrando um rastro em que a GPU tem muito tempo de inatividade.

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

Como colocar em produção

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

A maioria das pessoas pensa no Shadow DOM como uma maneira de anexar elementos "internos" aos elementos personalizados, mas ele também é um isolamento e um desempenho primitivo. O JavaScript e o CSS não podem cruzar os limites do Shadow DOM, o que nos permite duplicar o conteúdo sem interferir nos estilos ou na lógica do aplicativo. Já temos um elemento <div> para cada cópia 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 no ShadowRoot para garantir que nossas cópias sejam estilizadas da mesma maneira que a original.

Alguns navegadores não são compatíveis com o Shadow DOM v1. Para eles, vamos apenas duplicar o conteúdo e esperar o melhor de tudo. Poderíamos usar o polyfill do Shadow DOM com ShadyCSS, mas não implementamos isso na nossa biblioteca.

Pronto. Após nossa jornada pelo pipeline de renderização do Chrome, descobrimos como podemos animar os desfoques em diferentes 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 aumentar os limites de dispositivos mais simples. Copiar todas as folhas de estilo para cada ShadowRoot também é um possível risco de desempenho. Portanto, decida se prefere ajustar a lógica e os estilos para não serem afetados por cópias no LightDOM ou usar nossa técnica ShadowDOM. Mas, às vezes, nossa técnica pode valer a pena. Confira o código no nosso repositório do GitHub, bem como a demonstração. Entre em contato comigo no Twitter se tiver alguma dúvida.