CSS GPU动画(译文)

802 阅读28分钟

本文原作者是Sergey Chikuyonok,发布于2016年12月9日,感兴趣可以对照原文

本文旨在帮助您更好地了解浏览器如何使用GPU进行渲染,以便您可以在所有设备上创建快速运行且令人印象深刻的网站。

现在大多数人都知道现代网络浏览器使用GPU来渲染网页的某些部分,尤其是带有动画的网页。例如,使用transform属性的CSS动画看起来比使用lefttop属性的更流畅。但如果你问,“我如何从GPU获得流畅的动画?” 在大多数情况下,您会听到“使用transform: translateZ(0)will-change: transform”等类似的声音。

gpu5.png

针对准备GPU动画或合成(浏览器供应商喜欢这样称呼它)而言,这些属性就像我们在Internet Explorer 6中使用zoom: 1一样(如果你明白我的意思)。

但有时候,在简单的demo中运行流畅的动画,在真实网站上运行非常缓慢,引入视觉伪影甚至使浏览器崩溃。为什么会这样?我们如何解决它?让我们试着理解。

免责声明

在我们深入研究GPU合成之前,我想告诉你的最重要的事情是:这是一个巨大的hack。在W3C的规范中,你找不到任何关于合成如何工作、如何显式地将元素放在合成层上、甚至关于合成本身的任何内容。这只是浏览器用于执行某些任务的优化,每个浏览器供应商都以自己的方式实现。

在本文中,您将学到的所有内容都不是对工作原理的官方解释,而是我自己实验的结果,并附有一些不同浏览器子系统如何工作的知识和常识。有些事情可能是完全错误的,有些事情可能会随着时间而改变——希望你已经知道这个警告!

合成的工作原理

要准备一个GPU动画的页面,我们必须了解浏览器中的工作原理,而不仅仅是遵循本文或网络中的一些随机建议。

假设我们有一个页面包含A和B元素,每个元素应用了position: absolute和不同的z-index。浏览器将使用CPU绘制它,然后发送生成的图像给GPU,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>

截屏2022-09-21 上午10.42.03.png

我们决定使用left属性和CSS animation移动A元素来设置动画:

<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>

4x171-jypzk.gif

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

在每一步动画中,整个页面的重排和重绘听起来真的很慢,尤其是对于复杂的大型布局。只绘制两张单独的图像会更有效——一张A元素,一张没有元素A的整个页面,然后简单地相对于彼此偏移这些图像。换句话说,组合缓存元素的图像会更快。而这正是GPU的亮点所在:它能够以亚像素的精度,非常快速地合成图像,这为动画增添了惊人的平滑度。

为了优化合成,浏览器必须确保产生动画的CSS属性:

  • 不影响文档流,
  • 不依赖文档流,
  • 不会导致重绘。

有人可能会认为topleft属性以及position:absolutefixed不依赖元素的环境,但事实并非如此。例如,left属性可能会接受相对于.offsetParent尺寸的百分比值;此外, 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。假设您想使用网站访问者在Web表单中输入的数据来注册。您不能只告诉远程服务器,“嘿,只需从这些输入字段和那个JavaScript变量中获取数据并将其保存到数据库中。” 因为远程服务器无权访问用户浏览器中的内存。相反,您必须收集页面中的数据,封装成具备简单、易解析数据格式(例如JSON)的负载中,并将其发送到远程服务器。

在合成过程中会发生非常相似的事情。因为GPU就像一个远程服务器,浏览器必须先创建一个负载,然后将其发送到设备上。当然,GPU离CPU也不是几千公里。它就在那里。然而,虽然在许多情况下远程服务器请求和响应在2秒以内是可以接受的,但对于GPU数据传输,额外的3到5毫秒就会导致动画卡顿。

GPU负载是什么样的?在大多数情况下,它由图层图像以及附加指令(例如图层大小、偏移量、动画参数等)组成。以下是封装负载和传输数据到GPU的大致情况:

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

如您所见,每次将魔法般的transform: translateZ(0)will-change: transform属性添加到元素时,都会开始相同的过程。尽管重绘非常耗费性能,而且它在重叠时甚至更慢。在大多数情况下,浏览器无法增量重绘。它必须绘制被新创建的合成层覆盖的旧区域:

gpu3.png

隐式合成

让我们回到A和B元素的示例。早些时候,我们对A元素进行了动画处理,它位于页面上所有其他元素之上的。这生成了两个合成层:一个包含A元素,一个包含B元素和页面背景。

现在,让我们为B元素设置动画:

zihla-8smvx.gif

我们遇到了一个逻辑问题。元素B应该在单独的合成层上,屏幕的最终页面图像应该在GPU上合成。但是该A元素应该出现在B元素之上,并且我们没有对元素A指定任何内容,要它提升自己的层。

记住这个重要的免责声明:特定的GPU合成模式不是CSS规范的一部分;这只是浏览器内部应用的优化。正如定义的那样,我们必须定义z-index,让元素A按照顺序出现在元素B的顶部。浏览器会做什么?

你猜对了!它将强制为元素A创建一个新的合成层——当然,还会添加其他的重绘工作:

xo31n-y0xuj.gif

这称为隐式合成:一个或多个非合成元素按堆叠顺序出现在合成元素之上时,会被提升为合成层——即绘制成单独的图像,然后发送到GPU。

我们在隐式合成上跌倒的次数比您想象的要多得多。浏览器会出于多种原因将元素提升为合成层,其中有几个:

  • 3D变换:translate3dtranslateZ等等;
  • <video><canvas><iframe>元素;
  • 通过Element.animate()驱动transformopacity动画;
  • 通过СSS驱动transformopacity的过渡和动画;
  • position: fixed;
  • will-change;
  • filter;

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

似乎GPU动画的主要问题是意外重绘。但事实并非如此。更大的问题是……

内存消耗

再次温馨提醒,GPU是一台单独的计算机:它不仅需要将渲染层图像发送到GPU,还需要存储它们以供以后在动画中重复使用。

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

一个典型的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×600像素。我们决定在用户交互(例如拖动)时,在图像之间平滑过渡,因此我们为每个图像添加will-change: transform。这将提前将图像提升为复合层,以便在用户交互时立即开始转换。现在,计算显示这样一个轮播需要多少额外的内存:800×600×4×10≈19MB

渲染单个控件需要19MB的额外内存!如果您是现代Web开发人员,正在创建SPA网站,具有大量动画控件、视觉效果、高分辨率图像和其他视觉增强功能,那么每页要额外增100到200MB起步。将隐式合成添加到一起(承认这一点,你以前从未想过它),你最终会得到一个填满设备所有可用内存的页面。

此外,在许多情况下,这些内存会被浪费,显示完全相同的结果:

gpu4.png

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

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

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

优点和缺点

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

优点

  • 动画快速流畅,每秒60帧。
  • 正确制作的动画在单独的线程中工作,并且不会被繁重的JavaScript计算阻塞。
  • 3D变换比较廉价。

缺点

  • 需要进行额外的重新绘制才能将元素提升为复合层。有时这很慢(当我们需要全层重绘,而不是增量重绘时)。
  • 必须将绘制的图层传输到GPU。根据这些层的数量和尺寸,传输也可能非常缓慢。这可能会导致中低端的市场设备上的元素闪烁。
  • 每个复合层都会消耗额外的内存。内存是移动设备上的宝贵资源。过多的内存使用可能会使浏览器崩溃。
  • 如果不考虑隐式合成,那么重绘速度慢、内存占用过多和浏览器崩溃的可能性非常高。
  • 我们会看到视觉伪影,例如:Safari中的文本渲染,以及某些情况下,页面内容消失或变形。

正如您所看到的,尽管有一些非常有用和独特的优势,但GPU动画有一些非常讨厌的问题。最重要的是重绘和过度的内存使用;因此,下面介绍的所有优化技术都将解决这些问题。

浏览器设置

在开始优化之前,我们需要了解可以帮助我们检查页面上的复合层,并提供清晰反馈优化效果的工具。

Safari

Safari的Web Inspector有一个很棒的“图层”侧边栏,可以显示所有合成图层及其内存消耗,以及合成的原因。要查看这个边栏:

  1. 在Safari中,使用⌘ + ⌥ + I。 如果这不起作用,请打开“首选项”→“高级”,打开“在菜单栏中显示开发菜单”选项,然后重试。
  2. 当Web Inspector打开时,选择“元素”选项卡,然后在右侧栏中选择“图层”。
  3. 现在,当您单击“元素”窗口中的DOM节点时,您将看到所选元素的层信息(如果它使用合成)和所有后代合成层的信息。
  4. 单击子层以查看其合成的原因。浏览器会告诉你为什么它决定把这个元素移动到它自己的合成层。

safari.png

Chrome

Chrome的DevTools有一个类似的面板,但你必须先启用一个标志:

  1. 在Chrome中,转到chrome://flags/#enable-devtools-experiments并启用“开发者工具实验”标志。
  2. 在PC上,使用Ctrl + Shift + I打开开发者工具,在Mac上,使用⌘ + ⌥ + I打开开发者工具 ,然后单击右上角图标并选择“设置”菜单项。
  3. 转到“实验”窗口,然后启用“图层”面板。
  4. 重新打开开发者工具。您现在应该看到“图层”面板。

chrome.png

此面板将以树结构展示当前页面所有活动的合成层。当您选择一个图层时,您会看到其大小、内存消耗、重绘次数和合成原因等信息。

优化提示

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

避免隐式合成

这是最简单、最明显的建议,但也非常重要。让我提醒您,所有非合成的DOM元素在某个具有明确合成原因(例如,position: fixed视频、CSS动画等)的合成层上时,将被强制提升到它们自己的层,用于GPU上的最终图像合成。在移动设备上,这可能会导致动画开始非常缓慢。

我们举一个简单的例子:

HTML

<div class="a"></div>
<div class="b"></div>
<button onclick="document.body.classList.toggle('animate')">Play</button>

CSS

.a, .b {
    position: absolute;
    width: 90px;
    height: 90px;
    transition: transform 1s;
    font-size: 90px;
    line-height: 90px;
    text-align: center;
    color: #fff;
    font-family: sans-serif;
}
.a {
    z-index: 1;
    background: red;
    left: 30px;
    top: 110px;
}
.b {
    z-index: 2;
    background: blue;
}
.a::before {
    content: 'A';
}
.b::before {
    content: 'B';
}
.animate .a {
    transform: translateX(100px);
}
button {
    position: relative;
    top: 210px;
}

A元素应在用户交互时进行动画处理。如果您在“图层”面板中查看此页面,您会发现没有额外的图层。但是在单击“播放”按钮后,您会看到更多图层,这些图层将在动画完成后立即删除。如果您在“时间轴”面板中查看此过程,您会看到动画的开始和结束伴随着大面积的重绘:

gpu6.png

这是浏览器每一步所做的:

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

为了摆脱隐式合成问题并减少视觉伪影,我建议以下内容:

  • 尽量将动画对象保持在较高的z-index上。理想情况下,这些元素应该是body元素的直接子元素。当然,动画元素可能嵌套在DOM树的深处并依赖正常文档流时,这在文档结构中并不一定可能。在这种情况下,您可以克隆这个元素,并将其放在body中实现动画。
  • 您可以向浏览器暗示,使用will-change的CSS属性进行合成。在元素上设置此属性后,浏览器将(但不总是!)提前将其提升为合成层,以便动画可以平滑地开始和停止。但是不要滥用这个属性,否则你最终会导致内存消耗的增加很大!

仅使用transform和opacity属性的动画

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>

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

减小复合层的大小

看看下面的图片。注意到有什么不同吗?

截屏2022-09-21 01.42.34.png

这两个合成层在视觉上是相同的,但第一个有40,000字节 (39KB),第二个只有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×100像素(100×100×4=40,000字节),而元素#b只有10×10像素(10×10×4=400字节),但使用了transform: scale(10)。因为元素#b是合成层,由于will-change属性的原因,在最终图像绘制期间,transform完全发生在GPU上。

诀窍很简单:使用widthheight属性减小合成层的物理尺寸,然后使用transform: scale(…)放大这个纹理。当然,这个技巧可以显着减少简单纯色图层的内存消耗。但例如,如果您想为一张大照片制作动画,您可以将其缩小5%到10%,然后再放大;用户可能看不到任何区别,您将节省几兆字节的宝贵内存。

尽可能使用CSS过渡和动画

我们已经知道transformopacity属性的transitionanimation动画,会自动创建一个合成层并在GPU上工作。我们也可以通过JavaScript制作动画,但我们必须先添加transform: translateZ(0)will-change: transform/opacity,以确保元素获得自己的合成层。

每次在requestAnimationFrame回调中手动计算,可以实现JavaScript动画。通过Element.animate()实现的动画是声明式CSS动画的一种变体。

一方面,通过CSS过渡或动画创建简单且可重用的动画非常容易;另一方面,使用JavaScript动画比CSS动画更容易创建具有花哨轨迹的复杂动画。此外,JavaScript是与用户输入交互的唯一方式。

哪一个更好?我们可以只使用一个通用的JavaScript库来为所有东西制作动画吗?

基于CSS的动画有一个非常重要的特性:它完全在GPU上运行。因为您声明了动画应该如何开始和结束,所以浏览器可以在动画开始之前,准备所有必需的指令并将它们发送到GPU。在命令式JavaScript的情况下,浏览器只知道当前帧的状态。为了获得流畅的动画,我们必须在主浏览器线程中计算新帧,并将其发送GPU每秒至少60次。除了这些计算和发送数据比CSS动画慢得多之外,它们还取决于主线程的工作量:

240sg-5q7gl.gif

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

所以,尽量使用基于CSS动画,尤其是加载和进度指示器。它不仅速度更快,而且不会被繁重的JavaScript计算阻塞。

一个真实的优化示例

这篇文章是我为Chaos Fighters开发网页的调查和实验的结果。这是一个带有大量动画的手机游戏响应式促销页面。当我开始开发它时,我唯一知道的就是如何制作基于GPU的动画,但我不知道它是如何工作的。结果,第一个阶段的页面加载后,导致iPhone 5(当时最新的Apple手机)在几秒钟内崩溃。现在,即使在功能较弱的设备上,此页面也可以正常工作。

在我看来,让我们想想这个网站最有趣的优化。

页面最上方是对游戏的描述,背景像红色的太阳光线一样在旋转。它是一个无限循环的非交互式旋转动作——简单的CSS动画是最佳候选项。第一次尝试是保存太阳光线的图像,将其作为一个img元素放在页面上,并使用无限的CSS动画:

HTML

<img src="//sergeche.github.io/gpu-article-assets/images/sun-full.png" class="sun" />

CSS

html, body {
  overflow: hidden;
  background: #a02615;
  padding: 0;
  margin: 0;
}
.sun {
  position: absolute;
  top: -75px;
  left: -75px;
  animation: sun-spin 10s linear infinite;
}
@keyframes sun-spin {
  from { transform: rotate(0); }
  to   { transform: rotate(360deg); }
}

似乎一切都按预期工作。但是太阳图片的相当大。移动用户不会高兴。

仔细看看图像。基本上,它只是从图片中心发出的几条光线。光线是相同的,因此我们可以保存单个光线的图像并重新使用它来创建最终图像。我们最终会得到一个单射线图像,它比初始图像小一个数量级。

对于这种优化,我们将不得不稍微复杂化标记:.sun是射线图像元素的容器。每条光线都会以特定的角度旋转。

HTML

<div class="sun">
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
  <div class="sun-ray"></div>
</div>

CSS

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=36MB!这只是页面上的一个动画元素。

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

而且,无用的数据比有用的数据要大得多!这不是使用我们非常有限的内存资源的最佳方式。

这个问题的解决方案与我们对网络传输的优化相同:只将有用的数据(即光线)发送到GPU。我们可以计算我们将节省多少内存:

  • 整个sun容器:500×500×4≈977KB
  • 仅12条光线:250×40×4×12≈469KB

内存消耗将减少两倍。为此,我们必须分别为每条射线设置动画,而不是为容器设置动画。因此,只有光线的图像会被发送到GPU;他们之间的间隙不会占用任何资源。

我们必须稍微复杂化我们的标记,才能独立地为光线设置动画,而CSS在这里会成为更大的障碍。我们已经使用transform为光线设置初始旋转,我们必须从完全相同的角度开始动画并进行360度旋转。基本上,我们必须为每条光线创建一个单独的@keyframes,大量代码需要用于网络传输。

编写一个简短的JavaScript来处理光线的初始位置,并允许我们微调动画、光线数量等,这会容易得多。

HTML

<div class="sun"></div>

CSS

html, body {
  overflow: hidden;
  background: #a02615;
  padding: 0;
  margin: 0;
}
.sun {
  position: absolute;
  top: -75px;
  left: -75px;
  width: 500px;
  height: 500px;
}
.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%;
  /* tell browser to properly optimize compositing */
  will-change: transform;
}

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;
}

新动画看起来与之前的动画相同,但内存消耗少了一倍。

但还不是全部。在布局合成方面,这个太阳动画不是主要元素,而是一个背景元素。而且光线没有任何清晰的对比元素。这意味着我们可以将较低分辨率的光线纹理发送到GPU,并在之后对其进行升级,这使我们能够稍微减少内存消耗。

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

我们在代码中添加一行——即.sun-ray增加background-size: cover,以便背景图像自动调整为元素的大小,我们再添加transform: scale(1.111)用于射线的动画。

HTML

<div class="sun"></div>

CSS

html, body {
  overflow: hidden;
  background: #a02615;
  padding: 0;
  margin: 0;
}
.sun {
  position: absolute;
  top: -75px;
  left: -75px;
  width: 500px;
  height: 500px;
}
.sun-ray {
  width: 250px;
  height: 40px;
  background: url(//sergeche.github.io/gpu-article-assets/images/ray.png) no-repeat;
  background-size: cover;
  /* align rays with sun center */
  position: absolute;
  left: 50%;
  top: 50%;
  margin-top: -20px;
  transform-origin: 0 50%;
  /* tell browser to properly optimize compositing */
  will-change: transform;
}

JavaScript

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≈380KB(原来是469KB)。我们将内存消耗减少了19%,并获得了非常灵活的代码,我们可以在其中进行缩减以获得最佳的质量与内存比率。结果,开始看起来很简单的动画增加了复杂性,但我们将内存消耗减少了977÷380≈2.5倍!

我猜你已经注意到这个解决方案有一个重大缺陷:动画现在是在CPU上运行,并且可能会被大量的JavaScript计算阻塞。如果你想更熟悉优化GPU动画,我建议做一点功课。复制太阳光线的Codepen,使太阳光线动画完全在GPU上工作,但与原始示例一样具有内存效率和灵活性。在评论中发布您的示例以获得反馈。

经验教训

我对优化Chaos Fighters页面的研究让我彻底重新思考了开发现代网页的过程。以下是我的主要原则:

  • 始终与客户和设计师就网站上的所有动画和效果进行协商。它可能会极大地影响页面的标记并有助于更好地合成。
  • 从一开始就注意复合层的数量和大小——尤其是通过隐式合成创建的层。浏览器开发工具中的“图层”面板是您最好的朋友。
  • 现代浏览器大量使用合成,不仅用于动画,还用于优化页面元素的绘制。例如:position: fixed元素、iframe元素、video元素也使用合成。
  • 合成层的大小可能比层数更重要。在某些情况下,浏览器会尝试减少合成层的数量(参见“ GPU Accelerated Compositing in Chrome ”的“Layer Squashing”部分);这可以防止所谓的“层爆炸”并减少内存消耗,尤其是当层具有较大的交集时。但有时,这种优化会产生负面影响,例如当一个非常大的纹理比几个小层消耗更多的内存时。为了绕过这种优化,我为每个元素添加了一个很小的唯一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进行渲染的,这样你就可以创建在任何设备上都快速运行的、令人印象深刻的网站。