这样使用GPU动画

大多数人知道现代网络浏览器使用GPU来渲染部分网页,特别是具有动画的部分。 例如,使用transform属性的CSS动画看起来比使用lefttop属性的动画更平滑。 但是如果你问,“我如何从GPU获得平滑的动画?”在大多数情况下,你会听到像“使用transform:translateZ(0)will-change:transform这样的建议。

这些属性已经成为像我们如何在Internet Explorer 6下使用zoom:1(如果你明白我的意思的话)在准备GPU的动画或说合成加速,浏览器厂商喜欢这样叫它。

但有时,在简单demo中运行的又好又平滑的动画,放在一个真实的网站上运行的时候却很慢,会造成视觉假象,甚至导致浏览器崩溃。 为什么会发生这种情况?** 我们如何解决它?** 让我们试着去了解。

一个免责声明

在我们深入GPU加速之前,我想告诉你最重要的事:这是一个 giant hack。 你不会在(至少现在)W3C的规范中找到任何关于合成加速是如何运作,关于如何在合成层上显式地放置一个元素,甚至是关于合成加速本身。 它只是浏览器应用在执行某些任务时的优化,而且各个浏览器厂商都通过自己的方式去实现这种优化。

在本文中你将学到的一切并不是对合成加速是如何运作的官方解释,而是我用自己的一些常识和不同浏览器系统工作原理的知识去实验的结果。可能会有一些小错误,有些过段时间可能会改变 —— 我已经提醒过你了哦!

合成加速是如何运作

要准备一个GPU动画的页面,我们必须了解其在浏览器中如何工作,而不只是随便的去遵循从网上或从这篇文章中得到的建议。

假设我们有一个包含AB元素的页面,每个元素都有position:absolute和一个不同的z-index。 浏览器将从CPU绘制它,然后将生成的图像发送到GPU,最后将显示在屏幕上。

<style>
    #a, #b {
        position: absolute;
    }

    #a {
        left: 30px;
        top: 30px;
        z-index: 2;
    }

    #b {
        z-index: 1;
    }
</style>

<div id="#a">A</div>
<div id="#b">B</div>

我们已经决定通过A元素的left属性和CSS动画来使其运动起来:

<style>
    #a, #b {
        position: absolute;
    }

    #a {
        left: 10px;
        top: 10px;
        z-index: 2;
        animation: move 1s linear;
    }

    #b {
        left: 50px;
        top: 50px;
        z-index: 1;
    }

    @keyframes move {
        from { left: 30px; }
        to { left: 100px; }
    }
</style>

<div id="#a">A</div>
<div id="#b">B</div>

在这种情况下,对于每个动画帧,浏览器必须重新计算元素的几何形状(即重排),和渲染页面新状态下的图像(即重绘),然后再次发送到GPU将其显示在屏幕上.我们都知道重新绘制是非常耗性能的,但每个现代浏览器都非常聪明地只重绘页面中改变的区域,而不是整个页面。 虽然浏览器在大多数情况下可以非常快速地重绘,但我们的动画仍然不够平滑。

在动画的每个步骤(甚至递增)重排和重绘整个页面听起来真的很慢,特别是对于一个大且复杂的布局。比较有效的方法是绘制两个单独的图像 —— 一个用于A元素,一个用于没有A元素的整个页面 —— 然后简单地让这些图片相对于彼此偏移,换句话说,合成缓存元素的图像将会加速。 这正是GPU的亮点所在:它能够以亚像素精度快速构图,为动画增添了平滑感。

要优化合成,浏览器必须确保动画的CSS属性:

  • 不影响文档流,
  • 不依赖于文档流,
  • 不会造成重绘。

大家可能会认为absolutefixedtopleft属性不依赖于元素环境,但事实并非如此。例如,left属性可以接收取决于定位父级大小的百分比值; 同样的,emvh和其他单位取决于他们的环境。 相反,transformopacity是唯一满足上述条件的CSS属性。

让我们通过transform来替换left实现动画效果:

<style>
    #a, #b {
        position: absolute;
    }

    #a {
        left: 10px;
        top: 10px;
        z-index: 2;
        animation: move 1s linear;
    }

    #b {
        left: 50px;
        top: 50px;
        z-index: 1;
    }

    @keyframes move {
        from { transform: translateX(0); }
        to { transform: translateX(70px); }
    }
</style>

<div id="#a">A</div>
<div id="#b">B</div>

在这里,我们以声明的方式描述了动画:它的开始位置,结束位置,持续时间等。这会告诉浏览器提前更新CSS属性。 因为浏览器没有看到任何会导致重排或重绘的属性,它可以通过合成优化:将两个图像绘制为合成图层并将其发送到GPU。

这种优化的优点是什么?

  • 我们可以通过亚像素精度得到一个运行在特殊优化过的单位图形任务上的平滑动画,并且运行非常快。
  • 动画不再绑定到CPU。 即使你运行一个非常复杂的JavaScript任务,动画仍然会很快运行。

一切似乎都很清楚和容易,对吧? 但我们可能遇到什么问题? 让我们看看这个优化是如何工作的.

GPU是一个单独的计算机,这可能会让你感到惊讶。但这是正确的:每个现代设备的一个重要部分实际上是一个独立的单元,有自己的处理器和自己的内存和数据处理模型。 和任何其他应用程序或游戏一样,浏览器需要与GPU交谈。

为了更好地了解这是如何工作的,想想AJAX。 假设你想通过他们在网络表单中输入的数据去计算网站访问者数量。 你不能只告诉远程服务器,“嘿,从这些输入字段和JavaScript变量中获取数据并将其保存到数据库。”远程服务器不能访问用户浏览器中的内存。 相反,您必须将页面中的数据收集后转化为可轻松解析的简单数据格式(如JSON),并将其发送到远程服务器。

在合成过程中也会发生类似的情况。 因为GPU就像一个远程服务器,浏览器必须首先创建一个有效负载,然后将其发送到设备。 当然,GPU不是距离CPU几千公里远; 它就在那里。 但是,尽管远程服务器请求和响应所需的2s在多数情况下是可接受的,但是一个GPU数据传输额外耗费的35毫秒将导致"janky"动画。

什么是GPU有效负载? 在大多数情况下,它包括层图像,以及它附加的数据,如图层的大小,偏移量,动画参数等。这里的GPU有效负载和传输数据大致像是:

  • 将每个合成图层绘制为单独的图像
  • 准备图层数据(大小,偏移,不透明度等)
  • 准备动画的着色器(如果适用)
  • 将数据发送到GPU

正如你可以看到的,每次你给元素添加transform:translateZ(0)will-change:transform属性,你都启动了相同进程。 重绘成本是非常高昂的,运行甚至更慢。 在大多数情况下,浏览器无法增量重绘。 它必须用新建的复合层去绘制之前被覆盖的区域:

隐式合成

让我们回到我们的AB元素的例子。 之前,我们让A元素在页面上其他所有元素之上动起来了。 这导致有两个合成层:一个是A元素所在的层和一个B元素所在的页面背景层。 现在,让我们来让B元素动起来:

我们遇到了一个逻辑问题。 元素B应该在单独的合成层上,并且屏幕的最终页面图像应该在GPU上组成。 但是A元素应该出现在元素B的顶部,而且我们没有指定任何关于提升A元素自身层级的东西。

请记住这个提醒:特殊的GPU合成模式不是CSS规范的一部分; 它只是一个浏览器在内部应用的优化。 我们通过定义z-indexA必须按照顺序出现在B的顶部。 那么浏览器会做什么?

你猜到了! 它将强制为元素A创建一个新的合成图层 — 并添加另一个重绘图,当然:

这被称为隐式合成:一个或多个非合成元素应该出现在层叠顺序中被提升的复合层之上 —— 即绘制为分离的图像,然后发送到GPU。

我们偶然发现隐式合成比你想象的更频繁。 浏览器会将元素提升为合成层的原因有很多,其中包括:

  • 3D transforms: translate3d, translateZ等等;
  • <video>,<canvas><iframe> 元素;
  • 通过Element.animate()而有transform动画和opacity属性的元素;
  • 通过СSS transitions 和 animations而有transform动画和opacity属性的元素;
  • position: fixed;
  • will-change;
  • filter;

更多原因描述在Chromium项目的“CompositingReasons.h”文件中。

看起来GPU动画的主要问题好像是意想不到的重绘。但其实不然,更大的问题其实是...

内存消耗

另一个温柔的提醒,GPU是一个单独的计算机:它不仅需要发送渲染层图像到GPU,而且还需存储它们,以便稍后在动画中重用。

单个复合层需要多少内存? 让我们举个简单的例子。 尝试猜测需要多少内存来存储一个用纯#FF0000颜色填充的320 × 240px矩形。

一个典型的Web开发人员会认为,“嗯,这是一个纯色的图像。 我将它保存为PNG并检查其大小。 它应该小于1KB。“他们是绝对正确的:这个图像作为PNG的大小是104字节。

问题是PNG,以及JPEG,GIF等,用于存储和传输图像数据。 为了将这样的图像绘制到屏幕上,计算机必须解压图像格式,然后将其表示为像素阵列。 因此,我们的样本图像将需要320×240×3 = 230,400字节的计算机内存。 也就是说,我们将图像的宽度乘以其高度以获得图像中的像素数。 然后再乘以3,因为每个像素由三个字节(RGB)描述。 如果图像包含透明区域,我们将其乘以4,因为需要额外的字节来描述透明度:(RGBa):320×240×4 = 307,200字节

浏览器总是将合成图层绘制为RGBa图像。 似乎没有有效的方法来确定一个元素是否包含透明区域。

让我们举一个可能的例子:一个轮播有10张照片,每张是800 × 600px。 我们来实现一个图片间平滑过渡的交互,比如拖拽,于是我们为每个图像添加了will-change:transform。 这将提前将图像提升为复合层,以便在用户交互时立即开始过渡。 现在来计算下需要多少额外的内存来显示这样的轮播:800×600×4×10≈19MB

需要19 MB的额外内存来渲染单个控件! 如果你是一个在制作一个单页面应用程序网站的现代Web开发人员的话,需要很多动画控制,视差效果,高分辨率图像和其他视觉增强功能,那么每页额外100到200 MB 只是开始,还需要添加隐式合成去混合,你最终会以用尽设备上的可用内存结束。

此外,在许多情况下,这个内存将被浪费,并显示非常相同的结果:

对桌面客户端用户还好,但对于使用移动设备的用户来说是很坑的。 首先,大多数现代设备具有高密度屏幕:将复合层图像的权重乘以4到9。其次,移动设备没有台式机那么多的内存。 例如,一个不是很旧的iPhone 6附带1 GB的共享内存(即用于RAM和VRAM的内存)。 考虑到这个内存的至少三分之一被操作系统和后台进程使用,另外三分之一被浏览器和当前页面使用(高度优化的页面没有大量框架的最佳情况),我们最多还剩下大约200到300 MB内存给GPU效果。 iPhone 6是一个相当昂贵的高端设备;比它便宜的手机内存更少。

你可能会问,“有可能在GPU中存储PNG图像以减少内存占用吗?”从技术上来说,是的,这是可能的。 唯一的问题是GPU逐像素地绘制屏幕,这意味着它必须一次又一次地为每个像素解码整个PNG图像。 我怀疑在这种情况下的动画会比每秒1帧更快。

值得一提的是,GPU特定的图像压缩格式确实存在,但是它们在压缩比方面甚至还比不上PNG或JPEG,并且它们的使用受硬件支持的限制。

优点和缺点

现在我们已经学习了一些GPU动画的基础知识,让我们总结它的优点和缺点。

优点

  • 动画快速,流畅,每秒60帧。
  • 一个正确制作的动画在单独的线程中运作,并且不会被大量JavaScript计算阻止。
  • 3D变换是“便宜的”。

缺点

  • 添加重绘是需要提升元素层级到复合层。 有时这是非常慢的(即我们得到一个全层重绘,而不是一个增量)。
  • 绘图层必须传输到GPU。 根据这些层的数量和尺寸,转移也可能非常慢。 这可能导致元素在低端和中端市场设备上闪烁。
  • 每个复合层都消耗额外的内存。 内存是移动设备上的宝贵资源。 过多的内存使用可能会导致浏览器崩溃。
  • 如果你不考虑隐式合成,而使用慢速重绘,除了额外的内存使用,浏览器崩溃的几率也非常高。
  • 我们会有视觉假象,例如在Safari中的文本渲染,在某些情况下页面内容将消失或变形。

正如你可以看到,GPU动画不仅有一些非常有用和独特的优势,也有一些非常讨厌的问题。最主要是重画和过度的内存占用; 因此,下面涵盖的所有优化技术都将解决这些严重的问题。

浏览器设置

在我们开始优化,我们需要了解将帮助我们检查页面上的复合层,和提供关于优化效率反馈的工具。

SAFARI

Safari的Web Inspector有一个很棒的“Layers”边栏,用来显示所有复合层及其内存消耗,以及合成的原因。 查看此侧边栏:

  • 在Safari中,使用⌘+⌥+ I打开Web Inspector。如果不起作用,请打开“Preferences”→“Advanced”,打开“Show Develop Menu in menu bar”选项,然后重试。
  • 当Web Inspector打开时,选择“Elements”选项,然后在右侧边栏中选择“Layers”。
  • 现在,当你单击一个在主“Elements”窗中的DOM节点时,您将看到所选元素(如果使用合成)和所有后代复合图层的图层信息。
  • 单击一个后代图层以查看其合成的原因。 浏览器会告诉你为什么将这个元素移动到自己的合成图层上。

CHROME

Chrome的开发者工具有一个类似的面板,但你必须先启用标志:

  • 在Chrome中,前往chrome:// flags /#enable-devtools-experiments,并启用"Developer Tools experiments"标记。
  • 使用⌘+⌥+ I(在Mac上)或Ctrl + Shift + I(在PC上)打开开发者工具,然后单击右上角的图标并选择“Settings”菜单项。
  • 转到“Experiments”窗格,然后启用“Layers”面板。
  • 重新打开开发者工具。 你现在应该看到“Layers”面板。

此面板将当前页面的所有活动合成图层显示为树。 选择图层时,您将看到相应的信息,例如其大小,内存消耗,重绘数量和合成原因。

优化技巧

现在我们已经设置了我们的环境,我们可以开始优化合成层。 我们已经确定了合成的两个主要问题:额外的重绘,这也会使数据传输到GPU,以及额外的内存消耗。 因此,下面所有的优化技巧都主要针对这个问题。

避免隐式合成

这是最简单和最显而易见的技巧,也是非常重要的技巧。 让我提醒你,所有非合成的DOM元素具有显式合成原因(例如, position: fixedvideo,CSS动画等))将被强制提升到自己的图层,只是为了在GPU上合成最后的图像。 在移动设备上,这可能会导致动画开始非常缓慢。

让我们举个简单的例子:

A元素会在用户交互时动起来。 如果你在“Layers”面板中查看此页面,你看不到额外的图层。 但是在点击“播放”按钮后,你会看到更多的图层,这些图层将在动画完成后立即删除。 如果你在“Timeline”面板中查看此过程,你会看到动画的开始和结束进行了大面积的重绘:

浏览器做了以下几步:

  • 在页面加载后,浏览器找不到任何合成理由,因此它选择最佳策略:在单个背景图层上绘制页面的整个内容。
  • 通过点击“播放”按钮,我们明确地添加了合成给元素A —— 一个具有transform属性的过渡动画。 但是浏览器确定元素A在层叠顺序中低于元素B,所以它也将B提升到自己的合成层(隐式合成)。
  • 提升到合成层总是会导致重绘:浏览器必须为元素创建新的纹理,并将其从上一层中删除。
  • 新图层必须传输到GPU,以便用户在屏幕上看到的最终图像合成。 根据层数,纹理的大小和内容的复杂性,重新绘制和数据传输可能需要大量的时间来执行。 这就是为什么我们有时会看到一个元素在动画开始或结束的时候闪烁。
  • 在动画完成后,我们从A元素中删除合成的理由。浏览器看到它不需要浪费资源去合成,所以它回到最佳策略:保持页面的整个内容在一个单一的层,这意味着它必须在背景上绘制AB 层(另一个重绘),并将更新的纹理发送到GPU。 如上面的步骤,这可能会导致闪烁。

为了摆脱隐式合成问题和减少视觉假象,我建议如下:

  • 尽可能在z-index中保持动画对象。 理想情况下,这些元素应该是body元素的直接子元素。 当然,当动画元素嵌套在DOM树内部并且依赖于正常流时,这在标记中是不一定的。 在这种情况下,您可以克隆元素并将其放在body中仅用于动画。
  • 你可以给浏览器一个提示,你将要去合成使用与具有will-changeCSS属性的元素。 通过在元素上设置此属性,浏览器将(但不总是)提前将其提升到合成层,以便动画可以平滑地开始和停止。 但是不要滥用这个属性,否则你的内存消耗会大大增加!

只有动画TRANSFORMOPACITY属性

transformopacity属性保证既不影响也不受正常流或DOM环境的影响(即,它们不会导致重排或重绘,因此其动画可以完全卸载到GPU)。 基本上,这意味着你可以有效地动画实现移动,缩放,旋转,不透明度和仿射变换。 有时你可能想要模拟具有这些属性的其他动画类型。

以一个很常见的例子:一个背景颜色转换。 基本方法是添加一个transition属性:

<div id="bg-change"></div>

<style>
    #bg-change {
        width: 100px;
        height: 100px;
        background: red;
        transition: background 0.4s;
    }

    #bg-change:hover {
        background: blue;
    }
</style>

在这种情况下,动画将完全在CPU上工作,并在动画的每个步骤中重绘。 但是我们可以使这样的动画在GPU上工作:代替动画的background-color属性,我们在顶部添加一个图层和给它的不透明度添加动画:

<div id="bg-change"></div>

<style>
    #bg-change {
        width: 100px;
        height: 100px;
        background: red;
    }

    #bg-change::before {
        background: blue;
        opacity: 0;
        transition: opacity 0.4s;
    }

    #bg-change:hover::before {
        opacity: 1;
    }
</style>

这个动画会更快更流畅,但请记住,它可能导致隐式合成,并需要额外的内存。 但在这种情况下,可以大大减少存储器消耗。

减小复合层的尺寸

看看下面的图片。 注意任何差异?

这两个复合层在视觉上是相同的,但第一个重40,000字节(39 KB),第二个只有400字节 —— 小100倍。 为什么? 看看代码:

<div id="a"></div>
<div id="b"></div>

<style>
    #a, #b {
        will-change: transform;
    }

    #a {
        width: 100px;
        height: 100px;
    }

    #b {
        width: 10px;
        height: 10px;
        transform: scale(10);
    }
</style>

不同之处在于#a的物理大小是100×100px100×100×4 = 40,000字节),而#b只有10×10px10×10×4 = 400字节), 使用transform:scale(10)缩放到100×100px。 因为#b是一个复合层,由于will-change属性,`transform'会在最终图像绘制期间完全出现在GPU上。

这个诀窍很简单:使用widthheight属性减少复合层的物理大小,然后使用transform:scale(...)'扩展它的纹理。当然,这个技巧只简单粗暴地减少了实色层的内存消耗。如果你想让一张大照片动起来,你可以把它缩小5%10%`,然后把它缩放一级; 用户可能看不到任何差异,你还将节省几兆字节的宝贵内存。

尽可能使用 CSS TRANSITIONS和动画

我们已经知道动画的transformopacity是通过CSS transitions 或animations 自动创建一个合成层,并在GPU上工作。 我们也可以通过JavaScript添加动画,但是我们必须首先添加transform:translateZ(0)will-change:transform,`opacity',以确保元素获得自己的合成层。

JavaScript animation happens when each step is manually calculated in a requestAnimationFrame callback. Animation via Element.animate() is a variation of declarative CSS animation.

JavaScript动画发生在requestAnimationFrame的每一次回调手动计算时。 通过“Element.animate()”实现的动画是变量声明的CSS动画变体。

一方面,通过CSS transitionanimation 创建一个简单且可重用的动画是很容易的; 另一方面,在创建复杂的动画时,使用JavaScript动画比使用CSS动画更容易。 此外,JavaScript是与用户输入交互的唯一方式。

哪一个更好? 我们可以只使用一个通用JavaScript库来实现一切动画吗?

基于CSS的动画有一个非常重要的功能:它完全在GPU上工作。 因为你声明了动画应该如何开始和结束,浏览器可以在动画开始之前准备好所有需要的指令,并将它们发送到GPU。 而在JavaScript的情况下,浏览器要确认所有当前帧的状态。 为了平滑的动画,我们必须在主浏览器线程中计算新帧,并且将其发送到GPU每秒至少60次。 除了计算和发送数据比CSS动画慢得多外,它们还依赖于主线程的工作负载:

在上面的图中,你可以看到当主线程被密集的JavaScript计算阻塞时会发生什么。 然而CSS动画是不受影响的,因为新帧是在单独的线程中计算的,而JavaScript动画必须等待大量计算完成,然后再计算新的帧。

所以,尽量使用基于CSS的动画,特别是加载和进度指示条。因为它不仅是快,而且不会被大量的JavaScript计算阻止。

一个优化实例

本文是关于Chaos Fighters网页的调查和实验开发结果, 这是一个有着很多动画的手机游戏促销页面。 当我开始开发时,我只知道如何制作基于GPU的动画,但我不知道它的工作原理。 结果,第一个里程碑式的页面导致iPhone 5 —— 当时最新的苹果手机 —— 在页面加载后几秒钟内崩溃。 现在这个页面可以在即使不是那么强大的设备上正常运行。

我认为,我们应该要考虑下这个网站的有趣优化。

在页面开始是游戏的介绍,有类似红色光线在背景中旋转, 它是一个无限循环、非交互式旋转器—是简易CSS动画的最佳选择。 第一个(误导)尝试是保存太阳光线的图像,将其作为img元素放在页面上,并使用无限CSS动画:

看起来貌似没有问题。 但是太阳图片非常大。 移动用户使用起来会很不高兴。

仔细看看图像,基本上它只是来自图像中心的几条光线。 光线是相同的,所以我们可以保存单个光线的图像,并重新利用它来创建最终的图像.最终我们将得到比初始图像小一个数量级的单射线图像。

对于这种优化,我们必须使标记复杂化:.sun将是一个元素与射线图像的容器。 每个射线将以特定角度旋转。

html, body {
  overflow: hidden;
  background: #a02615;
  padding: 0;
  margin: 0;
}

.sun {
  position: absolute;
  top: -75px;
  left: -75px;
  width: 500px;
  height: 500px;
  animation: sun-spin 10s linear infinite;
}

.sun-ray {
  width: 250px;
  height: 40px;
  background: url(//sergeche.github.io/gpu-article-assets/images/ray.png) no-repeat;

  /* align rays with sun center */
  position: absolute;
  left: 50%;
  top: 50%;
  margin-top: -20px;
  transform-origin: 0 50%;
}

$rays: 12;
$step: 360 / $rays;

@for $i from 1 through $rays {
  .sun-ray:nth-of-type(#{$i}) { transform: rotate(#{($i - 1) * $step}deg); }
}

@keyframes sun-spin {
  from { transform: rotate(0); }
  to   { transform: rotate(360deg); }
}

视觉结果将是相同的,但网络传输的数据量将更低。然而,复合层的尺寸保持相同:500×500×4≈977KB

为了达到简化,我们的例子中的太阳光线相当小,只有500×500像素。 在真实的网站上,投放不同尺寸(移动,平板电脑和台式机)和像素密度的设备,最终得到的图片大约是3000×3000×4 = 36 MB! 而这只是页面上的一个动画元素。

在“图层”面板中再次查看网页的标记。 我们可以更容易旋转整个太阳容器。 因此,这个容器被提升为一个合成层,并被绘制成一个单一的大纹理图像,然后发送到GPU。 但是由于我们的简化,纹理现在包含无用的数据,即光线之间的间隙。

此外,无用的数据在大小上比有用的数据大得多! 但这不是我们合理利用内存资源的最好方式。

这个问题的解决方案与我们网络传输的优化相同:仅将有用数据(即光线)发送到GPU。 我们可以计算出我们要保存的内存量:

  • 整个太阳容器:500×500×4≈977 KB
  • 仅十二个光线:250×40×4×12≈469 KB

内存消耗将减少两倍。 要做到这一点,我们必须将每个射线的动画分开,而不是动画的容器。 因此,只有光线的图像将被发送到GPU; 它们之间的差距不会占用任何资源。

我们必须使我们的标记复杂化,以便独立地对光线进行动画处理,此时CSS将成为障碍。 我们已经对光线的初始旋转使用了transform,而且我们必须从完全相同的角度开始动画,并进行360deg转动。 基本上,我们必须为每个射线创建一个单独的@keyframes部分,这是很多网络传输的代码。

编写一个简短的JavaScript来处理光线的初始放置,并且允许我们对动画,光线数量等进行微调.

const container = document.querySelector('.sun');
const raysAmount = 12;
const angularVelocity = 0.5;
const rays = createRays(container, raysAmount);

animate();

function animate() {
    rays.forEach(ray => {
        ray.angle += angularVelocity;
        ray.elem.style.transform = `rotate(${ray.angle % 360}deg)`;
    });
    requestAnimationFrame(animate);
}

function createRays(container, amount) {
    const rays = [];
    const rotationStep = 360 / amount;
    while (amount--) {
        const angle = rotationStep * amount;
        const elem = document.createElement('div');
        elem.className = 'sun-ray';
        container.appendChild(elem);
        rays.push({elem, angle});
    }
    return rays;
}

新动画看起来与前一个相同,但是实际上比上一个少了2倍的内存消耗。

不仅仅是这样, 在布局组成方面,这个动画太阳不是主要元素,而是一个背景元素。 光线没有任何清晰的对比元素。 这意味着我们可以向GPU发送较低分辨率的光线纹理并随后将其升级,这使得我们减少了一点内存消耗。

让我们尝试将纹理的大小减少10%。 光线的物理尺寸将为250×0.9×40×0.9 = 225×36像素。 为了使光线看起来像250×20,我们必须将它升级250 ÷ 225 ≈ 1.111.

我们将为我们的代码添加一行代码 —— 给.sun-ray添加background-size:cover—以便背景图片自动调整为元素的大小,我们将为射线的动画添加transform: scale(1.111)

const container = document.querySelector('.sun');
const raysAmount = 12;
const angularVelocity = 0.5;
const downscale = 0.1;
const rays = createRays(container, raysAmount, downscale);

animate();

function animate() {
    rays.forEach(ray => {
        ray.angle += angularVelocity;
        ray.elem.style.transform = `rotate(${ray.angle % 360}deg) scale(${ray.scale})`;
    });
    requestAnimationFrame(animate);
}

function createRays(container, amount, downscale) {
    const rays = [];
    const rotationStep = 360 / amount;
    while (amount--) {
        const angle = rotationStep * amount;
        const elem = document.createElement('div');
        elem.className = 'sun-ray';
        container.appendChild(elem);

        let scale = 1;
        if (downscale) {
            const origWidth = elem.offsetWidth, origHeight = elem.offsetHeight;
            const width = origWidth * (1 - downscale);
            const height = origHeight * (1 - downscale);
            elem.style.width = width + 'px';
            elem.style.height = height + 'px';
            scale = origWidth / width;
        }

        rays.push({elem, angle, scale});
    }
    return rays;
}

注意,我们只改变了元素的大小; PNG图像的大小保持不变。 由DOM元素创建的矩形将呈现为GPU的纹理,而不是PNG图像。

太阳射线在GPU上的新组成大小现在是225×36×4×12≈380 KB(它是469 KB)。 我们将内存消耗降低了19%,并且得到了非常灵活的代码,我们可以通过缩减来获得最佳的质量 - 内存比。 因此,通过增加动画的复杂性,看起来这么简单,我们已经减少了内存消耗977 ÷ 380≈2.5倍!

我想你已经注意到这个解决方案有一个重大的缺陷:动画现在在CPU上工作,而且会被大量JavaScript计算阻止。 如果你想更熟悉如何优化GPU动画,我留个小作业。在这个demo中 Codepen of the sun rays,让太阳射线动画完全在GPU上工作,还要保证像原来的例子中的内存效率和弹性。 在评论中发布你的示例以获取反馈。

课程学习

  • 优化 Chaos Fighters页面的研究使我完全重新思考现代网页的开发过程。 以下是我的主要原则:
  • 始终与客户和设计师讨论网站上的所有动画和效果。 它会很大程度上影响页面的标记,以便于更好的合成。
  • 从一开始就注意复合层的数量和大小 —— 特别是通过隐式合成创建的层。 浏览器开发工具中的“Layers”面板是你最好的朋友。
  • 现代浏览器不仅将合成大量地用于动画,而且还用于优化绘制页面元素。 例如,position:fixediframevideo元素使用合成。
  • 合成层的尺寸可能比层的数量更重要。 在某些情况下,浏览器会尝试减少复合层的数量(请参阅“GPU加速复合在Chrome中的”图层压缩“部分); 这防止了所谓的“层爆炸”并且减少了存储器消耗,特别是当层具有大的交叉点时。 但是有时,这种优化具有负面影响,例如当非常大的纹理比几个小的层消耗更多的存储器时。 为了绕过这个优化,我向每个元素添加一个小的,唯一的translateZ()值,例如translateZ(0.0001px)translateZ(0.0002px)等。浏览器将确定元素位于3D空间中的不同平面 并因此跳过优化。
  • 你不能只靠为任何随机元素添加transform:translateZ(0)will-change:transform,来虚拟地提高动画性能或摆脱视觉假象。 GPU合成有许多缺点和要权衡的地方。 当不使用时,合成会降低整体性能,甚至会导致浏览器崩溃。

请允许我提醒大家:GPU合成没有官方规范,每个浏览器解决的问题也不同。 本文的某些部分可能在几个月后就过时了。 例如,Google Chrome开发人员正在探索如何减少CPU到GPU数据传输的开销,包括使用零复制开销的特殊共享内存。 并且Safari已经能够将简单元素(例如有background-color的空DOM元素)的绘图委托给GPU,而不是在CPU上创建它的图像。

无论如何,我希望这篇文章能帮助你更好地了解浏览器如何使用GPU来渲染,这样你就可以创作能在所有设备上快速运行的令人印象深刻的网站。

本文根据@Sergey Chikuyonok的《GPU Animation: Doing It Right》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://www.smashingmagazine.com/2016/12/gpu-animation-doing-it-right/

Bibi

前端程序媛,追求有趣的技术,学习有趣的事物,我享受把灵感通过代码具象化的过程,认为前端开发更像是一门优美的艺术。I'm a "Creator" in my code.

如需转载,烦请注明出处:http://www.w3cplus.com/animation/gpu-animation-doing-it-right.html

返回顶部