为模糊处理添加动画效果

模糊处理是转移用户注意力的绝佳方式。让某些视觉元素看起来模糊不清,同时让其他元素保持清晰,自然会引导用户的注意力。用户会忽略模糊处理的内容,而专注于可读的内容。例如,一个图标列表,当用户将鼠标悬停在某个图标上时,系统会显示该图标的详细信息。在此期间,系统可能会模糊处理剩余的选项,以便将用户重定向到新显示的信息。

要点

模糊处理动画效果的速度非常慢,因此不太适合。而是预先计算一系列越来越模糊的版本,并在这些版本之间进行交叉淡化处理。我的同事 Yi Gu 编写了一个,可为您处理所有事宜!请查看我们的演示版

不过,如果不设置任何过渡期就应用此方法,可能会造成很大的反差。为模糊处理添加动画(从未模糊处理过的图片转变为模糊处理过的图片)似乎是一个合理的选择,但如果您曾尝试在网络上执行此操作,可能发现动画效果并不流畅,如此演示所示,如果您没有性能强大的机器,我们能否做得更好?

问题

标记由 CPU 转换为纹理。纹理会上传到 GPU。GPU 使用着色器将这些纹理绘制到帧缓冲区。模糊处理是在着色器中进行的。

目前,我们无法高效地实现模糊处理动画。不过,我们可以找到一个看起来还不错的解决方法,但从技术上讲,它并不是动画模糊处理。首先,我们来了解一下动画模糊处理速度缓慢的原因。如需在网页上模糊处理元素,可以使用以下两种方法:CSS filter 属性和 SVG 滤镜。由于支持范围更广且易于使用,因此通常使用 CSS 过滤器。很遗憾,如果您需要支持 Internet Explorer,则只能使用 SVG 滤镜,因为 IE 10 和 11 支持 SVG 滤镜,但不支持 CSS 滤镜。好消息是,我们用于为模糊处理添加动画的权宜解决方法适用于这两种技术。因此,我们来看看开发者工具,看看能否找到瓶颈。

如果您在开发者工具中启用“绘制闪烁”,则不会看到任何闪烁。似乎没有进行任何重绘。从技术层面来说,这确实是正确的,因为“重新绘制”是指 CPU 必须重新绘制提升的元素的纹理。每当某个元素同时提升模糊处理时,GPU 都会使用着色器应用模糊处理。

SVG 滤镜和 CSS 滤镜都使用卷积滤镜来应用模糊处理。卷积滤波器的开销相当高,因为对于每个输出像素,都必须考虑多个输入像素。图片越大或模糊处理半径越大,效果的开销就越高。

问题就出在这里,我们每帧都在运行一项非常耗时的 GPU 操作,导致帧预算耗尽,最终帧速率远低于 60fps。

深入探究

那么,我们该如何顺利完成此操作?我们可以使用手法!我们不是对实际模糊处理值(模糊处理半径)进行动画处理,而是预先计算几个模糊处理的副本,其中模糊处理值呈指数级增加,然后使用 opacity 在这些副本之间进行交叉淡化。

交叉淡出是一系列重叠的透明度淡入和淡出。例如,如果我们有四个模糊处理阶段,则会同时淡出第一个阶段并淡入第二个阶段。当第二阶段达到 100% 不透明度且第一阶段达到 0% 时,我们会淡出第二阶段,同时淡入第三阶段。完成后,我们最终淡出第三个阶段,并淡入第四个也是最后一个版本。在这种情况下,每个阶段将占总所需时长的 ¼。从视觉上看,这与真实的动画模糊处理非常相似。

在我们的实验中,按阶段以指数方式增加模糊半径可获得最佳视觉效果。示例:如果我们有四个模糊处理阶段,则会对每个阶段应用 filter: blur(2^n),即阶段 0:1 像素、阶段 1:2 像素、阶段 2:4 像素和阶段 3:8 像素。如果我们使用 will-change: transform 将每个经过模糊处理的副本强制推送到各自的图层(称为“提升”),则这些元素的透明度应该会非常快地发生变化。从理论上讲,这让我们可以提前执行耗时的模糊处理工作。事实证明,该逻辑存在缺陷。如果您运行此演示,会发现帧速率仍然低于 60fps,而且模糊程度实际上比之前更糟糕

开发者工具,其中显示了 GPU 长时间处于繁忙状态的轨迹。

快速查看 DevTools 后发现,GPU 仍然非常繁忙,并且每帧延长到大约 90 毫秒。但为什么?我们不再更改模糊处理值,只更改不透明度,那么会发生什么情况呢?问题再次出在模糊处理效果的性质上:如前所述,如果元素同时提升了优先级并进行了模糊处理,则 GPU 会应用该效果。因此,即使我们不再为模糊值添加动画效果,纹理本身仍未模糊处理,需要由 GPU 在每一帧中重新模糊处理。帧速率比之前更糟糕的原因在于,与简单实现相比,GPU 实际上要比以前做更多的工作,因为大多数情况下,系统会显示两个需要单独模糊处理的纹理。

我们想出的解决方案并不美观,但可以让动画的速度非常快。 我们回过头来,提升要模糊处理的元素,而是提升父级封装容器。如果某个元素同时经过模糊处理和提升,则 GPU 会应用该效果。这正是导致演示速度缓慢的原因。如果元素经过模糊处理但未提升,则模糊处理会改为光栅化到最近的父纹理。在本例中,就是提升后的父级封装容器元素。经过模糊处理的图片现在是父元素的纹理,可供所有未来帧重复使用。之所以能这样做,是因为我们知道模糊处理的元素不会呈现动画效果,并且缓存它们实际上是有益的。下面是一个实现此技术的演示。我想知道 Moto G4 对此方法的看法。剧透警告:它认为自己很棒:

开发者工具,其中显示了 GPU 有大量空闲时间的轨迹。

现在,GPU 有充足的余量,并且可以流畅地以 60 fps 运行。我们成功了!

正式发布

在我们的演示中,我们多次复制了 DOM 结构,以便让内容的副本以不同的强度进行模糊处理。您可能想知道这在生产环境中会如何运作,因为这可能会对作者的 CSS 样式甚至 JavaScript 产生一些意外的副作用。您是对的。进入 Shadow DOM!

虽然大多数人认为 Shadow DOM 是一种将“内部”元素附加到自定义元素的方法,但它也是一种隔离和性能基元!JavaScript 和 CSS 无法穿透 Shadow DOM 边界,这让我们能够复制内容,而不会干扰开发者的样式或应用逻辑。我们已经为每个副本创建了用于光栅化的 <div> 元素,现在将这些 <div> 用作阴影主机。我们使用 attachShadow({mode: 'closed'}) 创建 ShadowRoot,并将内容的副本附加到 ShadowRoot 而不是 <div> 本身。我们还必须确保将所有样式表复制到 ShadowRoot,以确保我们的副本采用与原始副本相同的样式。

某些浏览器不支持 Shadow DOM v1,对于这些浏览器,我们会回退到仅复制内容,并希望一切顺利无碍。我们可以将 Shadow DOM polyfillShadyCSS 搭配使用,但我们并未在库中实现此功能。

就这样。在深入了解 Chrome 的渲染流水线后,我们发现了如何在各个浏览器中高效地为模糊处理添加动画效果!

总结

请勿轻易使用此类特效。由于我们会复制 DOM 元素并将其强制放置到自己的层上,因此可以突破低端设备的限制。将所有样式表复制到每个 ShadowRoot 也是潜在的性能风险,因此您应决定是调整逻辑和样式以不受 LightDOM 中的副本影响,还是使用我们的 ShadowDOM 方法。但有时,我们的技术可能值得投资。请查看我们的 GitHub 代码库中的代码以及演示。如果您有任何问题,欢迎通过 Twitter 与我联系!