最近看到一篇關(guān)于GPU動(dòng)畫(huà)的神文,原文地址:https://www.smashingmagazine.com/2016/12/gpu-animation-doing-it-right/。
特此翻譯出來(lái),供自己以及他人學(xué)習(xí)和查看。轉(zhuǎn)載請(qǐng)標(biāo)明出處,謝謝。
另外,由于簡(jiǎn)書(shū)和GitHub的markdown均不支持內(nèi)嵌iframe。而文中的大多數(shù)效果圖是嵌在iframe中的,可以移步github,下載后在支持內(nèi)嵌iframe的markdown環(huán)境下查看。
如今,絕大多數(shù)人知道現(xiàn)代瀏覽器采用GPU來(lái)渲染網(wǎng)頁(yè)的部分內(nèi)容,尤其是動(dòng)畫(huà)部分。比如,采用transform屬性的CSS動(dòng)畫(huà)看起來(lái)比使用left和top屬性的動(dòng)畫(huà)更流暢。但如果你要問(wèn):“我如何利用GPU實(shí)現(xiàn)流暢的動(dòng)畫(huà)?”絕大多數(shù)情況下,你會(huì)聽(tīng)到如下回答:“使用transform: translateZ(0)或者will-change: transform?!?/h3>
在開(kāi)始GPU動(dòng)畫(huà)-或者合成(compositing,瀏覽器廠商喜歡這么稱呼)之前,從某種意義上來(lái)說(shuō),這些個(gè)屬性就有點(diǎn)像IE6中使用的zoom:1一樣。(譯者注:有點(diǎn)拗口,意思應(yīng)該是許多人只知道這樣設(shè)置就行了,但是并不知道具體的原因)
但有時(shí)候,簡(jiǎn)單demo中絲滑流暢的動(dòng)畫(huà),在實(shí)際網(wǎng)站中運(yùn)行非常慢,造成視覺(jué)假象,甚至讓瀏覽器崩潰。為什么會(huì)這樣?如何修復(fù)?讓我們來(lái)了解一下。
免責(zé)聲明
在深入研究GPU合成之前,我想告訴你們一件十分重要的事:這是一個(gè)大大的hack。至少到目前為止,合成的工作原理,如何明確地將元素置于合成層,或者合成本身,關(guān)于這些問(wèn)題,你在W3C規(guī)范上找不到任何答案。它只是瀏覽器執(zhí)行特定任務(wù)時(shí)的一種優(yōu)化操作,每個(gè)瀏覽器廠商有自己的實(shí)現(xiàn)方式。
本篇文章中你學(xué)到的所有東西,并不是合成原理的官方解釋,而是我實(shí)驗(yàn)的結(jié)果,加上一點(diǎn)對(duì)瀏覽器子系統(tǒng)差異的常識(shí)和理解。有些東西可能是錯(cuò)的,有些可能隨著時(shí)間而變化——提醒過(guò)你了。
合成如何工作
在開(kāi)始GPU動(dòng)畫(huà)頁(yè)面之前,我們得知道瀏覽器是如何工作的,不要簡(jiǎn)單地聽(tīng)從網(wǎng)上的或者本篇文章中的一些隨意的建議。
假設(shè)我們有一個(gè)頁(yè)面,其中有A和B兩個(gè)元素,每一個(gè)都設(shè)置了position: absolute和不同的z-index值。瀏覽器會(huì)用CPU繪制,之后把完成的圖像發(fā)送給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>
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/example1.html" height="280" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
我們決定采用left屬性和CSS動(dòng)畫(huà),讓A元素動(dòng)起來(lái):
<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>
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/example1.html#.a:anim-left" height="280" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
此種情況下,對(duì)于每個(gè)動(dòng)畫(huà)幀,瀏覽器都必須重新計(jì)算元素的位置(即reflow),渲染頁(yè)面新?tīng)顟B(tài)的圖像(即repaint),之后再發(fā)送給GPU顯示到屏幕上。我們知道,重繪是非常消耗性能的,但是每個(gè)現(xiàn)代瀏覽器都足夠智能,只重繪頁(yè)面中變化的部分,而不是整個(gè)頁(yè)面。盡管絕大多數(shù)情況下,瀏覽器可以很快地重繪,但我們的動(dòng)畫(huà)仍然不是太流暢。
在動(dòng)畫(huà)的每個(gè)階段回流、重繪整個(gè)頁(yè)面(即便是增量繪制),聽(tīng)起來(lái)就很慢,尤其是又大又復(fù)雜的布局。僅繪制兩個(gè)獨(dú)立的圖像可能更高效——一個(gè)為A元素,另一個(gè)為A元素以外的整個(gè)頁(yè)面——之后簡(jiǎn)單地偏移兩個(gè)圖片的相對(duì)位置。也就是說(shuō),合成緩存元素的圖像可能更快。這就是GPU的優(yōu)勢(shì)所在:它能夠以亞像素精度快速合成圖像,使得動(dòng)畫(huà)如絲般順滑。
為了優(yōu)化合成,瀏覽器必須確保添加動(dòng)畫(huà)的CSS屬性:
- 不會(huì)影響文檔流,
- 不依賴文檔流,
- 不會(huì)造成重繪。
有人可能會(huì)以為,top和left屬性,輔之以position為absolute或fixed,不依賴元素的環(huán)境,但其實(shí)并不是這樣。例如,left屬性可能是個(gè)百分比值,其依賴于.offsetParent的尺寸;另外,em,vh和其它單位依賴于它們的環(huán)境。相反,transform和opacity是僅有的滿足上述條件的CSS屬性。
讓我們用transform而不是left來(lái)實(shí)現(xiàn)動(dòng)畫(huà):
<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>
此處,我們以聲明的方式描述動(dòng)畫(huà):起始位置,結(jié)束位置,持續(xù)時(shí)間等。這等于提前告訴瀏覽器哪些CSS屬性會(huì)更新。因?yàn)闉g覽器發(fā)現(xiàn)沒(méi)有屬性會(huì)造成回流或者重繪,它就會(huì)采用合成優(yōu)化:畫(huà)兩幅圖像作為合成層,之后發(fā)送到GPU。
這種優(yōu)化的優(yōu)點(diǎn)是什么呢?
- 我們獲得了一個(gè)亞像素精度的、如絲般順滑的動(dòng)畫(huà),運(yùn)行在專門(mén)為圖形任務(wù)優(yōu)化的單元上。并且運(yùn)行得非???。
- 動(dòng)畫(huà)再也不受限于CPU。即使運(yùn)行繁重的JavaScript任務(wù),動(dòng)畫(huà)依然很快。
一切聽(tīng)起來(lái)似乎簡(jiǎn)單明了,不是嗎?我們會(huì)遇到哪些問(wèn)題?讓我們看看這種優(yōu)化的工作原理。
GPU是一個(gè)獨(dú)立的計(jì)算機(jī),這可能讓你覺(jué)得吃驚。確實(shí)如此:每個(gè)現(xiàn)代設(shè)備必不可缺的部分是一個(gè)獨(dú)立的單元,它有自己的處理器和內(nèi)存、數(shù)據(jù)處理模塊。如同其它應(yīng)用和游戲一樣,瀏覽器必須與GPU進(jìn)行通信,好像和外設(shè)一樣。
為了更好地理解其工作原理,想像下Ajax。假設(shè)你想用填寫(xiě)的表單數(shù)據(jù)注冊(cè)網(wǎng)站用戶。你不能簡(jiǎn)單地告訴遠(yuǎn)端的服務(wù)器,“嗨,把這些表單數(shù)據(jù)和JavaScript變量保存到數(shù)據(jù)庫(kù)中。”遠(yuǎn)端數(shù)據(jù)庫(kù)無(wú)法訪問(wèn)用戶瀏覽器的內(nèi)存。反而,你必須把頁(yè)面中的數(shù)據(jù)以易解析的格式(比如JSON),收集在一個(gè)payload中,然后發(fā)給遠(yuǎn)端服務(wù)器。
合成的過(guò)程也差不多。GPU就像個(gè)遠(yuǎn)端的服務(wù)器,瀏覽器必須先創(chuàng)建一個(gè)payload,之后再發(fā)送給GPU。顯然,GPU不是遠(yuǎn)離CPU千里之外;它就在那。在很多情況下,對(duì)遠(yuǎn)端服務(wù)器的請(qǐng)求和響應(yīng)間隔時(shí)間在2S內(nèi)是可以接受的。而對(duì)于GPU,3到5毫秒的延遲卻能導(dǎo)致動(dòng)畫(huà)卡頓。
GPU payload長(zhǎng)什么樣?一般由層圖像組成,還有一些附加的說(shuō)明,比如層的尺寸,偏移,動(dòng)畫(huà)參數(shù)等。以下是GPU的payload生成和傳輸?shù)拇蟾胚^(guò)程:
- 將每個(gè)合成層繪制為獨(dú)立的圖像
- 準(zhǔn)備層數(shù)據(jù)(尺寸,偏移,不透明度等)
- 為動(dòng)畫(huà)準(zhǔn)備著色器(如果可用的話)
- 發(fā)送數(shù)據(jù)給GPU
如你所見(jiàn),每次給元素添加神奇的transform: translateZ(0)或者will-change: transform屬性時(shí),都開(kāi)啟了同樣的過(guò)程。然而重繪是非常耗性能的,此時(shí)會(huì)變得更慢。多數(shù)情況下,瀏覽器無(wú)法增量重繪。它必須用新創(chuàng)建的合成層繪制之前覆蓋的區(qū)域:
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/before-after-compositing.html" height="270" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
隱式合成
讓我們回到之前A B元素的例子。早先,我們將A做成動(dòng)畫(huà),它處在頁(yè)面所有元素之上。這會(huì)生成兩個(gè)合成層:A元素一個(gè),B元素和頁(yè)面背景一個(gè)。
現(xiàn)在,我們讓B元素動(dòng)起來(lái):
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/example3.html#.b:anim-translate" height="280" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
我們遇到了一個(gè)邏輯問(wèn)題。元素B應(yīng)該在一個(gè)獨(dú)立的合成層,屏幕最終呈現(xiàn)的圖像應(yīng)該在GPU中合成。但是A元素應(yīng)該出現(xiàn)在B元素上面,并且我們沒(méi)有指定A提升到自己的層。
記得之前的免責(zé)聲明:GPU合成模式并不是CSS規(guī)范的一部分;它只是瀏覽器內(nèi)部使用的一種優(yōu)化策略。如z-index定義的那樣,我們強(qiáng)制A出現(xiàn)在B的上面。那么,瀏覽器會(huì)怎么做呢?
猜對(duì)了!瀏覽器會(huì)強(qiáng)制為A創(chuàng)建新的合成層——當(dāng)然,增加了一次繁重的重繪:
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/example4.html#.b:anim-translate" height="280" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
這稱為隱式合成:按照棧順序,一個(gè)或多個(gè)非合成元素出現(xiàn)在合成元素上面時(shí),會(huì)被提升到合成層——即被繪制成獨(dú)立的圖像發(fā)送到GPU中。
我們遇到隱式合成的情況比你想象的要頻繁的多。瀏覽器會(huì)因很多原因?qū)⒁粋€(gè)元素提升為合成層,比如:
- 3D 變換:
translate3d,translateZ等等; -
<video>、<canvas>和<iframe>元素; - 通過(guò)
Element.animate()實(shí)現(xiàn)的transform和opacity動(dòng)畫(huà); - 通過(guò)CSS transition animation實(shí)現(xiàn)的
transform和opacity動(dòng)畫(huà); -
position: fixed; -
will-change; -
filter;
更多情況請(qǐng)參考Chromium項(xiàng)目的“CompositingReasons.h”文件。
似乎GPU動(dòng)畫(huà)的主要問(wèn)題是意想不到的大量重繪。但并不是。最大的問(wèn)題是。。。
內(nèi)存消耗
再一次溫馨提示:GPU是獨(dú)立的計(jì)算機(jī):它不僅需要發(fā)送渲染好的圖片給GPU,而且需要對(duì)其進(jìn)行存儲(chǔ),以便后續(xù)動(dòng)畫(huà)復(fù)用。
一個(gè)合成層需要消耗多少內(nèi)存?讓我們看個(gè)簡(jiǎn)單點(diǎn)的例子。猜猜存儲(chǔ)一個(gè)320×240像素,填滿#FF0000顏色的長(zhǎng)方形需要多少內(nèi)存。
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/rect.html" height="270" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
一個(gè)標(biāo)準(zhǔn)的web開(kāi)發(fā)者這樣想:“嗯,這是個(gè)純色的圖像,我會(huì)將其保存為PNG然后查看其大小。應(yīng)該小于1KB”。沒(méi)錯(cuò),這個(gè)PNG圖片大概104字節(jié)。
問(wèn)題是,PNG以及JPEG,GIF等,用來(lái)存儲(chǔ)和傳輸圖像數(shù)據(jù)。為了將這樣的圖像繪制到屏幕上,計(jì)算機(jī)必須解壓圖像數(shù)據(jù),然后表示成像素?cái)?shù)組。因此,我們的樣圖會(huì)消耗320 × 240 × 3 = 230,400 bytes的內(nèi)存。也就是,圖片寬度乘以高度獲得圖片的像素?cái)?shù)。之后再乘以3,因?yàn)槊總€(gè)像素由3個(gè)字節(jié)描述(RGB)。如果圖片包含透明通道,就得乘以4,因?yàn)楦郊拥囊粋€(gè)字節(jié)用來(lái)描述透明度(RGBa):320 × 240 × 4 = 307,200 bytes。
瀏覽器總是按照RGBa圖像的形式繪制合成層。似乎沒(méi)有行之有效的方法來(lái)確定圖片是否包含了透明通道。
再看一個(gè)更常見(jiàn)的例子:一個(gè)有10張圖的旋轉(zhuǎn)盤(pán),每張圖800×600像素。我們希望用戶交互,比如拖拽時(shí),圖片之間能夠平滑過(guò)渡,因此,我們?yōu)槊糠鶊D添加will-change: transform。這會(huì)提前將圖片提升到合成層,因此,用戶一開(kāi)始交互時(shí),過(guò)渡就會(huì)開(kāi)始?,F(xiàn)在計(jì)算下僅僅展示這一旋轉(zhuǎn)盤(pán)需要多少額外內(nèi)存:800 × 600 × 4 × 10 ≈ 19 MB。
僅僅一個(gè)控制點(diǎn)就需要額外19MB內(nèi)存!如果你是一個(gè)單頁(yè)應(yīng)用的WEB開(kāi)發(fā)者,頁(yè)面中有多個(gè)動(dòng)畫(huà)控制點(diǎn),視差效果,高分辨率圖像和其它視覺(jué)增強(qiáng)效果,那么每個(gè)頁(yè)面多增加100到200MB僅僅是個(gè)開(kāi)始。再考慮上隱式合成的話(承認(rèn)吧——你之前根本沒(méi)想過(guò)這個(gè)),最終頁(yè)面會(huì)耗盡設(shè)備的內(nèi)存。
此外,多數(shù)情況下,這些內(nèi)存會(huì)被浪費(fèi)掉,用來(lái)顯示同樣的結(jié)果:
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/example5.html" height="620" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
對(duì)于桌面客戶端來(lái)說(shuō),這可能不是個(gè)問(wèn)題,但會(huì)深深刺痛移動(dòng)用戶的心。首先,絕大多數(shù)現(xiàn)代設(shè)備擁有高分辨率的屏幕:這就將合成層圖片的體量乘以4到9。其次,移動(dòng)設(shè)備不像桌面設(shè)備那樣有那么大的內(nèi)存。比如,不是太舊的iPhone6僅搭載1GB共享內(nèi)存(即,內(nèi)存同時(shí)用于RAM和VRAM)??紤]到至少三分之一的內(nèi)存用于操作系統(tǒng)和后臺(tái)進(jìn)程,另外的三分之一用于瀏覽器和當(dāng)前頁(yè)面(最好的情況是高度優(yōu)化的頁(yè)面,沒(méi)有太多的framework),我們至多剩下200到300MB供GPU渲染。并且iPhone6是個(gè)相當(dāng)昂貴的高端設(shè)備,更多平價(jià)的手機(jī)所搭載的內(nèi)存更少。
你也許會(huì)問(wèn):“有可能在GPU上存儲(chǔ)PNG圖片來(lái)減少內(nèi)存占用嗎?”技術(shù)上是可行的。唯一的問(wèn)題是GPU在屏幕上是逐像素繪制的,這意味著它必須一次次地解碼整個(gè)PNG圖片來(lái)獲取每個(gè)像素?cái)?shù)據(jù)。我懷疑這種情況下的動(dòng)畫(huà)比每秒一幀快點(diǎn)。
GPU特定的圖像壓縮格式確實(shí)存在,但毫無(wú)意義。從壓縮比來(lái)看,根本比不上PNG或者JPEG,并且使用上也缺乏硬件支持。
優(yōu)缺點(diǎn)
既然學(xué)了些GPU動(dòng)畫(huà)的基本原理,讓我們總結(jié)下它的優(yōu)缺點(diǎn):
優(yōu)點(diǎn)
- 動(dòng)畫(huà)既快又流暢,達(dá)到每秒60幀。
- 精心制作的動(dòng)畫(huà)在獨(dú)立的線程中運(yùn)行,不會(huì)被繁重的JavaScript計(jì)算阻塞
- 3D變換很“廉價(jià)”
缺點(diǎn)
- 需要附加的重繪來(lái)將元素提升到合成層。有時(shí)這個(gè)過(guò)程很慢(比如,進(jìn)行全層重繪,而不是增量重繪)。
- 繪制的層必須傳到GPU中。依據(jù)層的大小和數(shù)量,傳輸可能很慢。這可能導(dǎo)致中低端設(shè)備上元素閃爍。
- 每個(gè)合成層消耗額外的內(nèi)存。在移動(dòng)設(shè)備上,內(nèi)存是寶貴的資源。內(nèi)存超標(biāo)使用會(huì)使瀏覽器崩潰。
- 如果你不考慮隱式合成,重繪緩慢、額外內(nèi)存使用和瀏覽器崩潰的可能性會(huì)很高。
- 我們會(huì)看到視覺(jué)假象,比如Safari上文本渲染,在某些情況下,頁(yè)面內(nèi)容會(huì)消失或者混亂。
如你所見(jiàn),盡管有些獨(dú)特的優(yōu)勢(shì),GPU動(dòng)畫(huà)仍然有些令人討厭的問(wèn)題。最重要的是重繪和大量的內(nèi)存消耗;因此,以下所有的優(yōu)化策略都是處理這些問(wèn)題的。
瀏覽器設(shè)置
在開(kāi)始優(yōu)化之前,我們得學(xué)習(xí)一些工具,來(lái)幫助我們檢查頁(yè)面的合成層,并且提供合理的優(yōu)化反饋。
Safari
Safari的web 監(jiān)視器有個(gè)很棒的“Layers”邊條,它顯示所有的層以及內(nèi)存消耗,以及合成的原因。來(lái)看看這個(gè)邊條:
- 在Safari中,按
? + ? + I打開(kāi)web監(jiān)視器。如果不起作用,打開(kāi)“Preferences” → “Advanced”,開(kāi)啟“Show Develop Menu in menu bar”選項(xiàng),再試一次。 - 當(dāng)web監(jiān)視器打開(kāi)后,選擇“Elements”面板,選擇右邊條的“Layers”。
- 現(xiàn)在,當(dāng)你在主“Elements”上點(diǎn)擊一個(gè)DOM元素時(shí),你會(huì)看到一個(gè)關(guān)于選擇元素以及所有后代合成層的信息層(如果使用了合成的話)。
-
點(diǎn)擊一個(gè)后代層,查看其合成原因。瀏覽器會(huì)告訴你為什么決定把這個(gè)元素遷移至它自己的合成層。
Chrome
Chrome的開(kāi)發(fā)者工具欄有個(gè)類似的面板,但你必須首先激活它:
- 在Chrome中,訪問(wèn)
chrome://flags/#enable-devtools-experiments,之后啟用“Developer Tools experiments”項(xiàng)。 - 用
? + ? + I(Mac)或者Ctrl + Shift + I(PC)打開(kāi)工具欄,之后點(diǎn)擊右上角的如下圖標(biāo),選擇“Setting”菜單項(xiàng):
- 回到“Experiments”面板,啟用“Layers”面板。
-
重新打開(kāi)開(kāi)發(fā)者工具欄?,F(xiàn)在,你就能看到“Layers”面板了。
這個(gè)面板以樹(shù)的形式展示當(dāng)前頁(yè)面所有活動(dòng)的合成層。當(dāng)選擇一個(gè)層的時(shí)候,你會(huì)看到諸如尺寸,內(nèi)存消耗,重繪次數(shù)和合成原因。
優(yōu)化建議
現(xiàn)在我們已經(jīng)設(shè)置好環(huán)境,可以開(kāi)始優(yōu)化合成層了。我們已經(jīng)確定了合成的兩個(gè)主要問(wèn)題:額外的重繪,也會(huì)造成數(shù)據(jù)數(shù)據(jù)傳送到GPU,還有額外的內(nèi)存消耗。因此,以下所有的優(yōu)化建議都是針對(duì)這兩個(gè)問(wèn)題的。
避免隱式合成
這是最簡(jiǎn)單明了的建議,同樣也十分重要。提醒你一下,處在一個(gè)顯式合成層(比如position: fixed,視頻,CSS動(dòng)畫(huà)等)之上的所有非合成DOM元素,會(huì)被強(qiáng)制提升到自己的層,僅僅為了最后的GPU圖像合成。在移動(dòng)設(shè)備上,可能會(huì)導(dǎo)致動(dòng)畫(huà)啟動(dòng)緩慢。
舉個(gè)簡(jiǎn)單的例子:
<iframe height="305" scrolling="no" src="https://codepen.io/sergeche/embed/jrZZgL/?height=305&theme-id=light&default-tab=result&embed-version=2" frameborder="no" allowtransparency="true" allowfullscreen="true" style="width: 100%;"></iframe>
A元素是個(gè)需要用戶交互啟動(dòng)的動(dòng)畫(huà)。如果你在“Layers”面板中查看這個(gè)頁(yè)面,你會(huì)發(fā)現(xiàn)沒(méi)有多余的層。但當(dāng)點(diǎn)擊“Play”按鈕后,你會(huì)看到更多的層,這些層在動(dòng)畫(huà)結(jié)束后立即被移除。如果看下“Timeline”面板,會(huì)發(fā)現(xiàn)動(dòng)畫(huà)的開(kāi)始和結(jié)束位置充斥大片區(qū)域的重繪:
以下是瀏覽器所做的,一步接一步:
- 頁(yè)面加載完成后,瀏覽器找不到任何合成的理由,因此選擇了最優(yōu)的策略:在單個(gè)背景層上繪制整個(gè)頁(yè)面內(nèi)容。
- 點(diǎn)擊“Play”按鈕,我們給元素
A顯式增加了合成層——transfrom屬性的一個(gè)變換。但是瀏覽器發(fā)現(xiàn)按照棧順序,元素A在元素B下面,因此也將B提升到自己的合成層(隱式合成)。 - 提升到合成層總會(huì)造成一次重繪:瀏覽器必須為元素創(chuàng)建一個(gè)新的紋理,然后從前一個(gè)層中移除掉。
- 新層圖像必須發(fā)送到GPU中,用來(lái)合成用戶最終看到圖像。依層的數(shù)量、紋理尺寸和內(nèi)容復(fù)雜度的不同,重繪和數(shù)據(jù)傳輸可能花許多時(shí)間。這就是許多動(dòng)畫(huà)在開(kāi)始和結(jié)束時(shí)出現(xiàn)元素閃爍的原因。
- 動(dòng)畫(huà)結(jié)束一剎那,我們從元素
A上移除了合成的原因。此時(shí),瀏覽器發(fā)現(xiàn)不需要浪費(fèi)資源來(lái)進(jìn)行合成,因此很快回到最優(yōu)策略:將頁(yè)面的整個(gè)內(nèi)容繪制在一個(gè)背景層當(dāng)中,這意味著必須把A和B重繪回背景層當(dāng)中(另一次重繪),之后把更新的紋理發(fā)送給GPU。如上步驟,可能造成閃爍。
為了免受隱式合成問(wèn)題的困擾,減少視覺(jué)假象,有如下建議:
- 給予動(dòng)畫(huà)元素盡可能高的
z-index。理想情況下,這些元素應(yīng)該是body元素的直接子元素。當(dāng)然,動(dòng)畫(huà)元素在DOM樹(shù)中嵌入很深、并且依賴常規(guī)流時(shí),這是不大可能的。在此種情況下,你可以克隆該元素,將其放置到body中僅作動(dòng)畫(huà)之用。 - 你可以利用
will-changeCSS屬性給瀏覽器一個(gè)提示,表明你要使用合成。將這個(gè)元素設(shè)置在元素上,瀏覽器會(huì)(并不總是)將其提前提升到一個(gè)合成層中,因此動(dòng)畫(huà)能夠流暢地啟動(dòng)和停止。但別濫用這個(gè)屬性,否則最終會(huì)導(dǎo)致內(nèi)存的急劇消耗!
僅將tranform和opacity屬性動(dòng)畫(huà)化
tranform和opacity屬性能夠確保既不影響也不會(huì)被常規(guī)流或者DOM環(huán)境影響(也就是說(shuō),它們不會(huì)造成回流或者重繪,因此動(dòng)畫(huà)完全交由GPU渲染)?;旧希@意味著你可以高效地實(shí)現(xiàn)移動(dòng)、縮放、旋轉(zhuǎn)、透明變換動(dòng)畫(huà),并且只有仿射變換。有時(shí),你可以用這些屬性模擬其它動(dòng)畫(huà)類型。
舉個(gè)非常常見(jiàn)的例子:背景色變換?;痉椒ㄊ翘砑右粋€(gè)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>
在這個(gè)例子中,動(dòng)畫(huà)完全運(yùn)行在CPU中,動(dòng)畫(huà)的每個(gè)階段都會(huì)重繪。但我們可以讓動(dòng)畫(huà)運(yùn)行在GPU上。我們可以在上面添加一層,將其不透明度動(dòng)畫(huà)化,而不是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>
這個(gè)動(dòng)畫(huà)會(huì)更快、更流暢。但記住,可能會(huì)引起隱式合成和額外的內(nèi)存消耗。然而此種情況下,可以極大減少內(nèi)存消耗。
減少合成層的大小
看下面兩張圖,看到區(qū)別了嗎?
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/layer-size.html" height="130" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
這兩個(gè)合成層從視覺(jué)上來(lái)看是一樣的,但第一個(gè)有40,000字節(jié)(30KB),第二個(gè)僅僅400字節(jié)——小了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=40000bytes),而#b僅為10×10像素(10×10×4=400bytes),但通過(guò)transform: scale(10)縮放到100×100像素。因?yàn)?code>#b是一個(gè)復(fù)合層,由于will-change屬性,transform在最終的圖像繪制過(guò)程中,將完全在GPU中進(jìn)行。
手法很簡(jiǎn)單:通過(guò)width和height屬性減少合成層的物理大小,之后通過(guò)transform: scale(…)放大紋理。當(dāng)然,這種把戲只能減少非常簡(jiǎn)單的、純色層的內(nèi)存消耗。但是,舉個(gè)例子,如果你想為一個(gè)大的照片創(chuàng)建動(dòng)畫(huà),可以減少5%到10%的大小,之后放大;用戶可能看不出任何差別,而你可以節(jié)省好幾兆寶貴的內(nèi)存。
盡可能地使用CSS 變換和動(dòng)畫(huà)
我們知道,通過(guò)CSS transform和animation的transform和opacity動(dòng)畫(huà)會(huì)自動(dòng)創(chuàng)建合成層,并且運(yùn)行在GPU上。我們也可以通過(guò)JavaScript實(shí)現(xiàn)動(dòng)畫(huà),但為了元素獲取自己的合成層,必選先添加transform: translateZ(0)或will-change: transform, opacity。
JavaScript動(dòng)畫(huà)的每一步是在requestAnimationFrame回調(diào)函數(shù)中手動(dòng)計(jì)算的。通過(guò)Element.animate()實(shí)現(xiàn)的動(dòng)畫(huà)是聲明式CSS動(dòng)畫(huà)的變體。
一方面,通過(guò)CSS transition和animation創(chuàng)建簡(jiǎn)單可復(fù)用的動(dòng)畫(huà)非常容易;另一方面,創(chuàng)建包含漂亮軌跡的復(fù)雜動(dòng)畫(huà)時(shí),JavaScript動(dòng)畫(huà)又比CSS動(dòng)畫(huà)容易實(shí)現(xiàn)。另外,JavaScript是和用戶輸入交互的唯一方式。
哪一個(gè)更好?我們可以只用一個(gè)通用的JavaScript動(dòng)畫(huà)庫(kù)來(lái)實(shí)現(xiàn)所有動(dòng)畫(huà)嗎?
基于CSS的動(dòng)畫(huà)有個(gè)很重要的特性:完全在GPU上運(yùn)行。因?yàn)槟?strong>聲明了動(dòng)畫(huà)如何開(kāi)始和結(jié)束,瀏覽器可以趕在動(dòng)畫(huà)開(kāi)始之前,準(zhǔn)備好所需要的所有指令,之后發(fā)送給GPU。在必須使用JavaScript的情況下,瀏覽器所知的只有當(dāng)前幀的狀態(tài)。對(duì)一個(gè)流暢動(dòng)畫(huà)而言,我們必須以每秒60次的速度在瀏覽器主線程中計(jì)算好新幀,然后發(fā)送給GPU。這些計(jì)算和數(shù)據(jù)發(fā)送不僅比CSS動(dòng)畫(huà)慢,同時(shí)也依賴于主線程的工作負(fù)載:
<iframe src="https://sergeche.github.io/gpu-article-assets/examples/js-vs-css.html" height="180" frameborder="no" allowtransparency="true" style="width: 100%;"></iframe>
在上面的例子當(dāng)中,當(dāng)主線程被繁重的JavaScript任務(wù)阻塞的時(shí)候,你會(huì)看到發(fā)生了什么。CSS動(dòng)畫(huà)不受影響,因?yàn)樾聨窃讵?dú)立的線程上計(jì)算的,而JavaScript動(dòng)畫(huà)必須等到繁重的計(jì)算完成,之后才計(jì)算新幀。
因此,試著盡可能使用基于CSS的動(dòng)畫(huà),尤其是加載和進(jìn)度指示條。不僅更快,而且還不會(huì)被大量的JavaScript計(jì)算阻塞。
現(xiàn)實(shí)世界中優(yōu)化的例子
本篇文章是我在為 Chaos Fighters開(kāi)發(fā)頁(yè)面時(shí)的研究和實(shí)驗(yàn)結(jié)果。這是個(gè)響應(yīng)式的手機(jī)游戲促銷頁(yè)面,有大量的動(dòng)畫(huà)。當(dāng)開(kāi)始開(kāi)發(fā)的時(shí)候,我唯一所知的就是如何實(shí)現(xiàn)基于GPU的動(dòng)畫(huà),但我并不知其工作原理。因此,在最初的里程碑頁(yè),就造成了iPhone5——當(dāng)時(shí)最新的Apple手機(jī)——在頁(yè)面加載完幾秒鐘后崩潰了?,F(xiàn)在,這個(gè)頁(yè)面運(yùn)行良好,即使是在性能稍弱的設(shè)備上。
按照我的觀點(diǎn),讓我們考慮下這個(gè)網(wǎng)站中最有趣的優(yōu)化部分。
頁(yè)面的最頂端是游戲的介紹,有個(gè)像太陽(yáng)光線東西在背景上旋轉(zhuǎn)。這是個(gè)無(wú)線循環(huán)、非交互的旋轉(zhuǎn)盤(pán)——正適合用簡(jiǎn)單的CSS動(dòng)畫(huà)實(shí)現(xiàn)。首先想到的方案(錯(cuò)誤嘗試)是保存太陽(yáng)光線的圖片,將它放在img中,之后使用無(wú)限CSS動(dòng)畫(huà):
<iframe width="350" height="402" scrolling="no" src="https://codepen.io/sergeche/embed/gwBjqG/?height=402&theme-id=light&default-tab=result&embed-version=2" frameborder="no" allowtransparency="true" allowfullscreen="true"></iframe>
似乎如預(yù)期的那樣萬(wàn)事大吉。但是太陽(yáng)的圖片相當(dāng)大。移動(dòng)用戶可能不開(kāi)心了。
再仔細(xì)看下圖片。只是從圖片中心發(fā)出來(lái)幾道光線而已。光線是一樣的,因此我們可以保存單個(gè)光線,復(fù)用它來(lái)實(shí)現(xiàn)最終的圖片。最后,我們僅用了一個(gè)單光線的圖片,相比剛開(kāi)始的圖片,大小少了一個(gè)數(shù)量級(jí)。
對(duì)于這種優(yōu)化,我們的標(biāo)記語(yǔ)言就必須復(fù)雜一點(diǎn)了:.sun是光線圖片元素的容器。每條光線在特定的角度旋轉(zhuǎn)。
<iframe width="350" height="402" scrolling="no" src="https://codepen.io/sergeche/embed/qaJraq/?height=402&theme-id=light&default-tab=css&embed-version=2" frameborder="no" allowtransparency="true" allowfullscreen="true"></iframe>
視覺(jué)效果是一樣的,但是網(wǎng)絡(luò)傳輸?shù)臄?shù)據(jù)量會(huì)少得多。另外,合成層的大小保持不變:500 × 500 × 4 ≈ 977 KB。
為了保證簡(jiǎn)單,例子中太陽(yáng)光線的大小是相當(dāng)小的,只有500 × 500像素。在實(shí)際網(wǎng)站中,服務(wù)于不同尺寸的設(shè)備(手機(jī)、平板和桌面電腦)和不同分辨率,最終圖片的大小大約000 × 3000 × 4 = 36 MB!而這僅僅是頁(yè)面中的一個(gè)動(dòng)畫(huà)元素。
在“Layers”面板中再看下頁(yè)面的元素。通過(guò)旋轉(zhuǎn)整個(gè)太陽(yáng)容器,使得動(dòng)畫(huà)實(shí)現(xiàn)更容易。因此,這個(gè)容器被提升到一個(gè)合成層,被繪制進(jìn)一個(gè)大的紋理圖像中,之后發(fā)送給GPU。但是由于我們的簡(jiǎn)化,現(xiàn)在紋理中包含無(wú)用的數(shù)據(jù):光線之間的間隔。
此外,無(wú)用的數(shù)據(jù)比有用的數(shù)據(jù)大很多!這不是利用有限內(nèi)存資源的最佳方式。
解決辦法和我們優(yōu)化網(wǎng)絡(luò)傳輸時(shí)一樣:只發(fā)送有用的數(shù)據(jù)(即光線)給GPU。我們可以計(jì)算下節(jié)約多少內(nèi)存:
- 整個(gè)太陽(yáng)容器:500 × 500 × 4 ≈ 977 KB
- 12個(gè)太陽(yáng)光線:250 × 40 × 4 × 12 ≈ 469 KB
內(nèi)存消耗可以減少一倍,為實(shí)現(xiàn)這一方案,我們必須為每個(gè)光線單獨(dú)實(shí)現(xiàn)動(dòng)畫(huà),而不是整個(gè)容器。因此,只有光線圖像會(huì)被發(fā)送到GPU當(dāng)中;它們之間的間隔不會(huì)占用任何資源。
為了實(shí)現(xiàn)獨(dú)立的光線動(dòng)畫(huà),標(biāo)記語(yǔ)言已經(jīng)有點(diǎn)復(fù)雜了,此處的CSS更是一個(gè)障礙。我們已經(jīng)為光線的初始旋轉(zhuǎn)使用了transform,必須從同樣的角度啟動(dòng)動(dòng)畫(huà),然后旋轉(zhuǎn)360度?;旧希覀兊脼槊總€(gè)光線分別實(shí)現(xiàn)一個(gè)@keyframes,這是個(gè)不小的網(wǎng)絡(luò)傳輸。
光線的初始放置和微調(diào)動(dòng)畫(huà),光線數(shù)量等,寫(xiě)個(gè)簡(jiǎn)短的JavaScript來(lái)處理這些問(wèn)題會(huì)更容易。
<iframe width="350" height="402" scrolling="no" src="https://codepen.io/sergeche/embed/bwmxoz/?height=402&theme-id=light&default-tab=js&embed-version=2" frameborder="no" allowtransparency="true" allowfullscreen="true"></iframe>
新的動(dòng)畫(huà)看起來(lái)和前一個(gè)一樣,但是內(nèi)存消耗只有一半。
還沒(méi)結(jié)束。從布局合成的角度來(lái)說(shuō),這個(gè)太陽(yáng)動(dòng)畫(huà)不是主元素,而是一個(gè)背景元素。并且光線沒(méi)有鮮明的對(duì)比元素。這意味著我們可以發(fā)送一個(gè)低分辨率的光線紋理給GPU,之后放大它,這可以節(jié)省一部分內(nèi)存。
我們?cè)囍鴾p少10%的紋理大小。光線的物理尺寸為50 × 0.9 × 40 × 0.9 = 225 × 36 像素。為了使它看起來(lái)和250 × 20一樣,我們需要放大250 ÷ 225 ≈ 1.111倍。
我們會(huì)在代碼中加一行:給.sun-ray加上background-size: cover——這樣背景圖就會(huì)自動(dòng)調(diào)整到元素的大小,并且為光線的動(dòng)畫(huà)添加transform: scale(1.111)。
<iframe width="350" height="402" scrolling="no" src="https://codepen.io/sergeche/embed/YGJOva/?height=402&theme-id=light&default-tab=js&embed-version=2" frameborder="no" allowtransparency="true" allowfullscreen="true"></iframe>
注意,我們只改變了元素的大?。籔NG圖片的大小仍然一樣。由DOM元素創(chuàng)建的矩形被渲染成紋理供GPU使用,而不是PNG圖片。
在GPU中,太陽(yáng)光線的新合成大小現(xiàn)在為225 × 36 × 4 × 12 ≈ 380 KB(原來(lái)是469KB)。我們已經(jīng)減少了19%的內(nèi)存消耗,并且實(shí)現(xiàn)了非常靈活的代碼,可以通過(guò)縮放來(lái)實(shí)現(xiàn)最優(yōu)的質(zhì)量?jī)?nèi)存比。因此,通過(guò)提高動(dòng)畫(huà)(起先看起來(lái)很簡(jiǎn)單)的復(fù)雜度,我們減少了內(nèi)存使用量977 ÷ 380 ≈ 2.5 倍!
我想你已經(jīng)發(fā)現(xiàn)了這個(gè)方法的缺陷:動(dòng)畫(huà)現(xiàn)在工作在CPU上,可能被大量的JavaScript計(jì)算阻塞。如果你想更熟悉優(yōu)化GPU動(dòng)畫(huà),我留個(gè)小小的家庭作業(yè)。ForkCodepen of the sun rays,然后將太陽(yáng)光線完全轉(zhuǎn)移到GPU上運(yùn)行,然而還要和初始的例子一樣節(jié)省內(nèi)存和靈活。將你的例子提交到注釋中,我會(huì)回復(fù)你的。
獲得的教訓(xùn)
優(yōu)化Chaos Fighters頁(yè)面的研究使我完全重新思考開(kāi)發(fā)現(xiàn)代web頁(yè)面的過(guò)程。以下是我的主要原則:
- 一定要和客戶端和設(shè)計(jì)者溝通網(wǎng)站上所有的動(dòng)畫(huà)和效果。這可能極大地影響頁(yè)面的標(biāo)記語(yǔ)言,并且也有利于更好地合成。
- 從一開(kāi)始就要注意合成層的大小和數(shù)量——尤其是隱式合成層。瀏覽器開(kāi)發(fā)者工具中的“Layers”面板是你最好的朋友。
- 現(xiàn)代瀏覽器大量運(yùn)用合成,不僅僅是動(dòng)畫(huà),還有優(yōu)化頁(yè)面元素的繪制。比如,
position: fixed和iframe、video元素也使用合成。 - 合成層的大小可能比數(shù)量更重要。在某些情況下,瀏覽器會(huì)試圖減少合成層的數(shù)量(參見(jiàn)“GPU Accelerated Compositing in Chrome”中的“Layer Squashing”一節(jié));這會(huì)阻止所謂的“層爆炸”和減少內(nèi)存消耗,尤其是當(dāng)層有大量的交集時(shí)。但有時(shí),這種優(yōu)化有副作用,比如當(dāng)一個(gè)很大的紋理消耗的內(nèi)存比多個(gè)小層多時(shí)。為了避免這種優(yōu)化,我給每個(gè)元素加了個(gè)小的、唯一的
translateZ()值,比如translateZ(0.0001px),translateZ(0.0002px)等。瀏覽器會(huì)認(rèn)為處在3D空間的不同層,從而跳過(guò)優(yōu)化。 - 為了從視覺(jué)上提高動(dòng)畫(huà)的性能或者避免視覺(jué)假象,你不能只是簡(jiǎn)單地給任何元素添加
transform: translateZ(0)或者will-change: transform。GPU合成有許多缺點(diǎn)和權(quán)衡需要考慮。使用不當(dāng)時(shí),可能會(huì)降低整體的性能,甚至導(dǎo)致瀏覽器崩潰。
請(qǐng)?jiān)试S我再提醒下免責(zé)聲明:關(guān)于GPU合成,沒(méi)有任何官方規(guī)范,每個(gè)瀏覽器廠商解決同一個(gè)問(wèn)題的方案不盡相同。本篇文章中的某些部分幾個(gè)月后可能就過(guò)時(shí)了。比如,Google Chrome 開(kāi)發(fā)者正在想方法減少CPU和GPU之間數(shù)據(jù)傳輸?shù)拈_(kāi)銷,包括使用特殊的共享內(nèi)存,這樣就沒(méi)有開(kāi)銷了。另外,Safari已經(jīng)能夠?qū)⒑?jiǎn)單元素的繪制(比如具有background-color的空DOM元素)代理到GPU,而不是在CPU上為其創(chuàng)建圖像。
無(wú)論如何,我希望本篇文章已經(jīng)幫助你更好地理解瀏覽器采用GPU渲染的原理,從而幫你創(chuàng)建在各種設(shè)備上都能快速運(yùn)行的令人難忘的網(wǎng)站。