今日看点程序员首页投稿(暂停使用,暂停投稿)

11. web | GPU 动画:正确的打开方式

2016-12-22  本文已影响526人  smilewalker

译者序:原文GPU Animation: Doing It Right,发表于2016年12月6日,本文是对该篇的中文翻译,如有帮助,作为译者,也深感欣慰。
附原文链接:https://www.smashingmagazine.com/2016/12/gpu-animation-doing-it-right/

目前,大部分人都知道现代浏览器是使用GPU来渲染web的部分页面,尤其是带有动画的。举个例子,一个使用transform的css动画看起来会比使用lefttop属性的更为流畅。但是如果你问,“我是如何从GPU获得平滑的动画?”多数情况下,你可能会听到比如“使用 transform: translateZ(0) 或者 will-change: transform。”的回答。

这些属性好比如我们在IE6使用zoom:1(如果你懂我的意思),用于准备GPU的动画——或者合成(compositing),浏览器供应商喜欢这么称它。

但有时,简单演示中运行的很好很流畅的动画,在真实网站却很慢,引起视觉错误甚至导致浏览器崩溃。为什么会产生这种现象?我们如何修复它?接下来一起试着理解吧。

免责声明

在我们深入GPU的合成前,我想告诉你一件重要的事:这是一个巨大的hack。你不会在W3C的规范里(至少目前来说)找到任何关于合成(compositing )如何工作的资料,如何显式地在合成层上放置元素,甚至于合成本身。它只是浏览器用于执行确定任务的优化,并且每个浏览器供应商以自己的方式实现。

你在这篇文章学到的一切,不是官方说明文档,而是我个人实验的结果,夹杂着一点常识和不同浏览器子系统运行原理的知识。部分可能绝对是错的,部分可能随着时间而变化——这个要事先说明!

合成(Compositing )的工作原理

为了准备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>
图1

现在用left属性和css的animation,来移动A元素:

<style>
#a, #b { position: absolute; }
#a {
  left: 30px; 
  top: 30px; 
  z-index: 2;
  animation: move 1s linear;
}
#b { z-index: 1; }
@keyframes move { 
  from { left: 30px; } 
  to { left: 100px; }
}
</style>
<div id="a">A</div>
<div id="b">B</div>
图2

在这种情况下,对于每个动画帧,浏览器都会重新计算元素的几何形状(即回流reflow),渲染页面新状态的图像 (即重绘repaint),然后再次将其发给GPU以显示在屏幕。我们知道重绘是很耗性能成本的,每个现代浏览器都足够快速的来重绘页面改变的部分,而不是整个页面。浏览器在多数情况下能都很快地重绘,但我们的动画依旧不平滑。

在动画的每一步(甚至递增)进行回流和重绘整个页面,听起来真的很慢,特别是对于一个庞大复杂的布局。而绘制两个独立的图像会更有效——一个是A元素,一个是没有A元素的整个页面——然后简单的相对彼此偏移那些图像。换句话来说,合成(composing)缓存的元素图像会更快。这也是GPU闪光的地方:它能快速合成带有亚像素精度的图像,为动画添加“性感“的平滑度。

为了优化合成,浏览器得确保css的动画属性:

有人会认为带有position: absolute以及fixedtopleft属性,不依赖于其环境,但事实并非如此。比如说,值为百分比的left属性,会取决于.offsetParent的大小;同样,em, vh以及其他单位也会取决于自身环境。而transformopacity是css唯一会满足上述情况的属性。
下面用transform代替left来动画:

<style>
#a, #b { position: absolute; }
#a {
  left: 30px; 
  top: 30px; 
  z-index: 2;
  animation: move 1s linear;
}
#b { 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的更新属性。因为浏览器如果知道没有任何属性会导致回流或重绘,它可以应用合成优化:绘制合成层(compositing layers)的图像并发给GPU。

这种优化的优点在哪?

一切看起来如此的清楚和简单,对吧,那会遇到什么问题呢?一起来看看这种优化方式是如何工作的。

它可能会让你震惊,GPU竟是一个独立的计算机。是的,每个现代设备的重要部分通常都是独立单元,有自己的处理器,自己的内存和数据处理模块。就像其他任何应用程序或者游戏一样,浏览器需要用外部设备跟GPU通信。

为了更好的理解它是怎么工作的,想想AJAX吧。假使你要提交用户输入的数据,你不会告诉远程服务器,“嗨,过来获取这些输入框的数据和JS变量,并保存到数据库。”远程服务器不能访问用户浏览器的内存。取而代之的是,你需要从页面保存这些数据到可以轻松解析的简单数据格式(如JSON)的有效内容中,并发送给远程服务器。

合成也是如此。GPU就像远程服务器,浏览器需要首先创建一个有效载荷,然后发送到设备。当然,GPU没有距离CPU数千米长;它就在旁边。然而,鉴于多数情况,远程服务器请求和返回允许2秒,对于GPU的数据转换的额外3~5毫秒会导致糟糕的动画。

什么是GPU的有效载荷?多数情况下,它包含了层图像,以及附加的说明比如大小,偏移量,以及动画参数。下面大致的写了有效负载及GPU传输的数据:

如你所见,每当为元素添加transform: translateZ(0)或者will-change: transform,你会开始同样的过程。而重绘是很耗性能成本的,这里它会更慢。多数情况下,浏览器不能进行递增的重绘,它会去绘制之前覆盖了新合成层的区域。

Paste_Image.png

隐式合成(Implicit Compositing)

回到我们刚才AB的例子。之前,我们动画处于所有元素上层的A,导致有两个合成层:一是A元素,另一个是B元素和整个页面背景(也就是没有A)。
现在,我们让B动画。

初始状态 移动状态

我们陷入了逻辑问题。B元素应该是一个独立的合成层,最终的层图像应该在GPU被合成。但是A元素应该出现在B的上面,我们并没有定义关于A的任何东西来推动它在自己层。

记住那个大的声明:特殊的GPU-合成(GPU-compositing)模式并不是CSS规范的一部分;它只是浏览器内部应用的优化。因为定义了z-indexA肯定是在B上方。而浏览器会做些什么呢?

它将会强制创建一个包含A的新合成层,当然,添加了另一个重绘:

图例

它被称为隐式合成 implicit compositing:以堆叠顺序应当出现在合成上的一个或多个非合成元素被提升为复合层 —— 即,被绘制为分离的图像,然后将其发送到GPU。

我们在隐式合成里犯的错远比你想象的还要多。浏览器提升元素为合成层是有很多原因的,下面列了几条:

可以看“CompositingReasons.h” 的文章,有更多关于谷歌浏览器的解释。

看起来GPU动画的主要问题似乎是意想不到的重绘,事实上并不是,最大的问题是……

内存消耗

再一次温馨提醒,GPU是独立式计算机:它不仅要将渲染的层图像发送给GPU,而且要存储它们便于在以后动画的重用。

那么单个合成层需要多少内存?举个例子,猜想下,保存一个填充色为#FF0000的320*240的矩形,需多少内存。

Paste_Image.png

典型的web开发者会去想,“这是一个纯色图。我会把它作为png来保存,再检查大小,应该比1KB小。”毫无疑问,他们是正确的,这种图片作为png是104字节(byte)。

问题是PNG,或JPEG,GIF等用来存储以及传输图像数据。为了将图像绘制到显示器上,计算机需要分析图像格式,然后表示为像素数组/矩阵。所以,我们的示例图片将会占320 × 240 × 3 = 230,400 bytes的计算机内存。也就是说,我们要将图像的宽乘高来获取图片的像素数。然后,我们再乘3,因为每个像素由3个字节(RGB)描述。如果图像包含透明区域,我们需要乘4,因为需要额外的字节来描述透明度:(RGBa):320 × 240 × 4 = 307,200 bytes

浏览器总是将合成层绘制为RGBa图像,看起来似乎没有有效的方法来判断元素是否包含透明区域。

举个更可能的例子:10张图片的轮播效果,每张800*600像素。我们需要在用户交互(如拖动)时让图片之间进行平滑的切换,因此我们为每个图片添加了will-change: transform。这会事先将图片提升为合成层,以便在用户交互时立即转换。如此一来,计算机显示轮播图需要的内存是: 800 × 600 × 4 × 10 ≈ 19 MB

19MB的额外内存被用来渲染单个控件!如果你是现代web开发者,正在创建单页面网站,并有很多动画控件、视差效果、高分辨率图像以及其他视觉增强,那么每页额外的100~200MB才刚开始。添加隐式合成到混合(承认吧——你以前从没想过这个),那你将会结束掉设备的所有可用内存。

此外,多数情况下,这些显示相同结果的内存将会被浪费。

图例

这对桌面客户端来说可能不是一个问题,但它真的会损害移动端用户。首先,现代的很多设备有高密度屏幕:合成层图像的权重要乘4~9。其次,移动设备并没有台式机那么大的内存。例如,现在的iphone 6有1GB的共有内存(即内存既用于RAM,也用于VRAM)。考虑到至少1/3的内存被用于操作系统和后台进程,另1/3被用于浏览器和现在的页面(对于高度优化的没有大量框架的页面),我们最后会有大约200~300MB留给GPU效果。而iphone 6 是相当昂贵的高端设备;很多手机的内存会更少。

你可能会问,“在GPU存储PNG图片来减少内存空间可能吗?”技术上说,有可能。问题是GPU是逐像素地绘制屏幕,意味着要将完整的PNG图像解码成一个一个的像素。我怀疑这种情况下的动画比每秒1帧更快。

值得一提的是,针对GPU的 图像压缩格式是存在的,但是在压缩比方面不如PNG或JPEG,而且功能会受硬件的影响。

优缺点

现在我们学到一些GPU动画的基础,一起总结下它的优缺点吧。

优点:

缺点

如你所见,GPU动画虽然有着实用独特的优点,但也有不好的问题。其中最重要的是重绘和过度的内存使用;而下面涵盖的所有优化技术将解决这些问题。

浏览器设置

在优化前,我们需要了解那些能帮助检查页面合成层,以及提供有关优化效果的明确反馈的工具。

SAFARI

Safari的web检查器(Web Inspector)有个“layers”边栏,来显示所有合成层及内存消耗,合成原因。来看这个边栏:

  1. 在Safari中,利用⌘ + ⌥ + I打开web检查器,如果没用,选择左上角的“preferences”——> “Advanced” ,勾选“Show Develop Menu in menu bar”选项,然后重试。
  2. web检查器打开后,选择“Elements”选项,并在右侧边栏选择“Layers”。
  3. 现在点击“Elements”主面板的DOM节点,你将会看到选中元素的layers信息(如果它用了合成)以及派生的层。
  4. 单击派生层查看合成原因。浏览器将告诉你为什么将该元素移动到自己的合成层。
图例
(查看大图)
CHROME

chrome的DevTools有类似的面板,但要先启动标记:

  1. 在chrome中,前往chrome://flags/#enable-devtools-experiments,启动 “Developer Tools experiments”(开发者工具实验性功能) 的标记。
  2. Mac利用⌘ + ⌥ + I打开DevTools,PC利用Ctrl + Shift + I,后点击右上角的如下图标,选择“Settings”选项。
  3. 转入“Experiments” 面板,勾选 “Layers”选项。
  4. 重新打开DevTools,你将看到“Layers”面板。
PC PC Mac
(查看大图)

该面板将当前页面的所有活动合成层显示为树。选择某个层,你会看到相关信息如大小(size),内存消耗(memory consumption),重绘次数(repaint count)以及合成原因(reason for being composited)。

优化建议

已经设置好环境后,我们开始优化合成层。之前确定合成的主要两个问题:额外的重绘(造成GPU的数据传输问题),以及额外的内存消耗。因此,下面的所有优化建议将针对上述问题:

避免隐式合成

这是最简单也最重要的建议,是的,很重要。再次提醒,所有非合成的DOM元素带有显示合成原因(如position: fixed, video,css animation)将会被强制提升为自己层,便于GPU的最终图像合成。在移动端,这可能会导致动画非常缓慢。

举个例子(查看代码链接,戳此进):

html css

A元素要在用户交互时进行动画。如果在“Layers”面板看这页面,你会看到,它并没有多余的层。而点击“play”按钮后,你会看到多层,这些图层在动画完成后立即删除。如果在“TimeLine”面板看该过程,你会看到动画的开始和结束都伴随着大面积的重绘。

图例.png

(查看大图)

浏览器是这么一步步做的:

  1. 当页面加载好后,浏览器若找不到合成原因,它会选取最佳策略:在单个背景层上绘制页面所有内容。
  2. 当点击“play”按钮时,我们显然看到增加了元素A的合成——因为transform属性。当浏览器确定堆叠顺序的元素A是在元素B的下面,所以它提升B为自己的合成层(隐式合成)。
  3. 提升至合成层总会造成重绘:浏览器必须为元素创建新的纹理,并将其从之前的层删除。
  4. 新的图层必须传输给GPU,便于用户在屏幕上看到最终的图像合成。根据层数,纹理大小和内容复杂度,需大量时间来执行重绘和数据传输。这也就是为什么我们有时会看到动画开始或结束时元素在闪烁。
  5. 动画完成后,我们去除了元素A合成的原因,那么,浏览器看到已经不需要合成了,就会回退到最佳策略:页面所有内容都在一个层,这也就意味着背景层需重新绘制AB(另一个重绘),并将新的纹理发给GPU。上述的步骤也就导致了闪烁。

为了摆脱隐式合成问题和减少视觉差异,我建议以下方法:

动画用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属性,我们在顶部添加一个层来变化它的opacity:

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

这个动画更快更平滑,但是记住它会导致隐式合成和需要额外内存。这种情况大大减少内存消耗。

减少合成层的大小

看下面的图片,有发现不同么?

图例.png

这两个合成层视觉上是一样的,但第一个40000字节(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=40000字节),而#b只有10*10像素大小(10*10*4=400字节),利用transform: scale(10)放大成100*100像素。#b是合成层,由于带了will-change属性,在最终图像绘制期间,transform完全是在GPU上发生。

技巧很简单:利用widthheight属性减少物理大小,利用transform: scale(…)升级其纹理。当然,对于非常简单的纯色层来说,这个技巧极大地减少了内存的消耗。举个例子,如果你想动画一张大照片,你可以缩小它到5%到10%,然后放大它;用户可能看不出任何差别,你也节省出几兆的宝贵内存。

如果可以的话,利用CSS的transitions和animations

我们已经知道,通过transform以及opacity会自动创建合成层,并在GPU上运行。我们同样可以通过JavaScript来动画,但需要添加transform: translateZ(0)will-change: transform, opacity来保证元素获得自己的合成层。

requestAnimationFrame回调计算每个步骤,发生JavaScript动画,通过Element.animate()
是一个有效的css动画声明。

一方面,通过css的transition或animation来创建简单重用的动画是很容易的;而另一方面,JS创建复杂的动画比css简单的多。此外,JavaScript是与用户交互的唯一的路径。

哪种方式更好?我们可以利用通用JavaScript库来动画元素么?

基于CSS的动画有个重要的特征:它是完全在GPU上运行的。因为你声明了动画应该怎么开始和结束,浏览器可以在动画开始前准备所有命令,并发给GPU。在命令式JavaScript的情况下,浏览器需要当前所有帧的状态。为了实现平滑的动画,我们需要在主浏览器线程计算新帧,然后每秒发送给GPU至少60次。除了计算和发送数据比css慢的多的事实外,它们还依赖于主进程的工作负载。

图例.png

上述的范例里,你可以当主进程会被强化的JavaScript计算阻塞时会发生什么,而css动画不会被影响,因为新帧是在单独的线程里计算的,但JavaScript的动画需要等大量计算完成后才计算新帧。

因此,尽可能使用基于css的动画,特别是加载和进度指示,不仅更快,而且不会被大量JavaScript计算所阻塞。

现实的优化实例

这篇文章是我在开发 Chaos Fighters页面过程中调查和试验的结果。这是一个有着很多动画的手机游戏的响应推广页面。当开始开发时,我只知道如何产生基于GPU的动画,但并不知道它的工作原理。结果,第一个里程碑页面导致iphone5 —— 当时最新的Apple手机——在加载完页面后几秒内崩溃。而现在,即使是不太高级的手机,这个页面依然正常运行。

一起考虑这个页面的有趣优化。

页面最顶部是游戏介绍,类似红色的光线在背景中旋转。毫无疑问是个无限循环,没有交互,一个很好的css动画范例。第一个(误导)的尝试是保存光线图像作为img元素置于页面上,并使用无限的css动画。链接:[http://codepen.io/sergeche/pen/gwBjqG]

Paste_Image.png

一切都看起来很正常,但是光线的图片是很大的,移动端用户并不会高兴。

仔细观察图像。基本上,它只是来自图像中心的几条光线,而光线是相同的,所以我们可以保存单个光线图像,并反复利用达到最终效果。最终得到单光线图像,这远比初始图小的多。

针对这种优化,我们必须将.sun的标记复杂化,它是光线图像元素的容器。每一光线都有特定的旋转角度。(代码链接)[http://codepen.io/sergeche/pen/qaJraq]

图例.png

视觉效果一样,但网络传输的数据量会少很多。合成层的尺寸都为500×500×4≈977KB。

弄的简单些,示例的光线图片很小,只有500*500像素。在真实的网站,设备的大小及像素分辨率并不相同(手机,平板,电脑),最终的图片大约是3000*3000*4=36MB!而这仅仅是页面上的一个动画元素。

再看下“Layers”面板的页面元素。我们已经让整个太阳旋转变得简单。因此,这个容器会被提升至合成层,被绘制成单一的大纹理图像,然后发给CPU。正因为我们的简化,纹理中包含了无用的数据:之间的缝隙。

更多来说,无用的数据比有用的还多!占据有限的内存资源并不是一个最好的方式。

这个问题的解决方案跟网络传输的优化相同:发送有用的数据(即光线)给GPU,我们可以计算节约了多大内存:

内存消耗减少2倍。要做到这一点,我们分别动画每条线,替换整个容器。这样一来,只有光线图片会被发给GPU,之间的间隙不会占据任何资源。

我们不得不使标签复杂,以便单独对光线进行动画处理,而css的干扰也会更多。我们已经对线条初始旋转动画用了transform,然后开始每个动画一样的效果,旋转360度。基本上,我们需要创建一个单独的@keyframes部分,有很多传输的代码。

编写一个简短的JavaScript来处理光线初始放置,并允许对动画,光线数量等进行微调,这将变得更容易。见代码 [http://codepen.io/sergeche/pen/bwmxoz]

图例

新动画看起来跟之前一样,但内存消耗上少了2倍。

而且,在布局组成上,动画的太阳不是主要元素,而是背景元素。光线没有清晰的对比元素。这意味着,我们可以发略低分辨的光线图给GPU,随后将其升级,这帮我们减少一点内存消耗。

尝试将纹理大小减小10%。光线的物理大小是250*0.9*40*0.9=255*36像素。为了使光线看起来像250*20,我们将其放大250÷225≈1.111。

我们将添加一行代码background-size: cover.sun-ray,便于背景图片自动调整,然后添加transform: scale(1.111)给光线。代码http://codepen.io/sergeche/pen/YGJOva

图例

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

太阳光线在GPU的合成大小是225×36×4×12≈380 KB(之前是469KB)。我们减少了大概19%的内存,并获得更灵活的代码,通过缩减来得到更佳质量——内存比。因此,增加简单动画的复杂性,减少了977÷380≈2.5倍的内存!

我想你已经注意到,这个解决方案有个重大的缺点:动画运行在CPU上会被JavaScript计算阻塞。如果你想更熟悉GPU操作动画,我提个作业。codepen上fork下Codepen of the sun rays,使其完全运行在GPU上,就像先前的例子一样高效灵活。在评论中发布你的代码以获得反馈。

收获

对于Chaos Fighters 页面的优化使我重新思考开发现代网页的过程。这里列了几条我的主要原则:

请允许我再次提醒:这不是GPU合成的官方规范,每个浏览器解决同一问题方式是不同的。本文某些内容在几个月后可能就过时了。例如,谷歌开发者正在探索如何减少CPU到GPU数据传输的开销,包括零复制开销的特殊共享内存的使用。此外,Safari已经能够将简单元素的绘制(比如说有background-color的空DOM元素)委托给GPU,而不是在CPU上创建图像。

无论如何,我希望这篇文章能帮助你更好地理解浏览器是如何使用GPU渲染的,以便您创建能在各设备下快速运行的令人印象深刻的网站了。

###词汇介绍

1. 纹理(texture)

这里的纹理是 GPU 的一个术语:可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmap image)。一旦它被移动到 GPU 中,你可以将它匹配成一个网格几何体(mesh geometry),在 Chrome 中使用纹理来从 GPU 上获得大块的页面内容。[参考源自http://web.jobbole.com/85993/]

2. 回流(reflow)

当渲染树(render Tree)中的一部分(或全部)因为元素的规模尺寸,布局,隐藏等改变而需要重新构建。这就称为回流(reflow),也就是重新布局(relayout)。
每个页面至少需要一次回流,就是在页面第一次加载的时候。在回流的时候,浏览器会使渲染树中受到影响的部分失效,并重新构造这部分渲染树,完成回流后,浏览器会重新绘制受影响的部分到屏幕中,该过程成为重绘。[参考源自http://web.jobbole.com/85993/]

3. 重绘(repaint)

当render tree中的一些元素需要更新属性,而这些属性只是影响元素的外观,风格,而不会影响布局的,比如 background-color 。则就叫称为重绘。
值得注意的是,回流必将引起重绘,而重绘不一定会引起回流。
明显,回流的代价更大,简单而言,当操作元素会使元素修改它的大小或位置,那么就会发生回流。[参考源自http://web.jobbole.com/85993/]

4. 亚像素精度(subpixel precision)

亚像素精度是指相邻两像素之间细分情况。输入值通常为二分之一,三分之一或四分之一。这意味着每个像素将被分为更小的单元从而对这些更小的单元实施插值算法。例如,如果选择四分之一,就相当于每个像素在横向和纵向上都被当作四个像素来计算。因此,如果一张5x5像素的图像选择了四分之一的亚像素精度之后,就等于创建了一张20x20的离散点阵,进而对该点阵进行插值。[来自百度百科]

外文原文:
https://www.smashingmagazine.com/2016/12/gpu-animation-doing-it-right/?utm_source=CSS-Weekly&utm_campaign=Issue-243&utm_medium=email#one-big-disclaimer

上一篇下一篇

猜你喜欢

热点阅读