Edited at

will-changeで目指す60fpsのぬるぬるCSSアニメーション

こんにちは、CSSとVue.jsでアニメーション使いまくりのポートフォリオ作ったりシューティングゲーム作ったりして遊んでいるゆきです。

今日はCSSアニメーションで無茶しすぎてMacBookがカイロになった反省からの「負荷をかけずにぬるぬるのCSSアニメーションを実現するための試行錯誤」の顛末をまとめます。それでもCSSでアニメーションしたいんだ


今回の目的とサンプルケース

この記事では、WebでCSSを使ってゲームやアート的な表現にゴリゴリのアニメーションを使いたい!というケースを想定します。

全体を通してCSSのwill-changeプロパティを使ったGPUレンダリングによる最適化のお話です。will-changeってなに?って方はこの後でてくる参考記事リストを先に見ていただくのが良いと思います。

ezgif.com-optimize (1).gif

https://css-anime.firebaseapp.com/

今回検証するアニメーションがこちらです:


  • CSSで描いた花が1000個、順次開花する

  • 個々の花はシンプルなdiv要素(今回は画像やSVGは使っていません)

  • 新しい花は32msごとに回転・拡大しながら現れる

  • 開花後の花はそのまま残る

  • 上記に加え、画面全体も回転し続ける

一般化すると、アニメーションしながら要素が段々増えていく + 常時全てが動いているわけではないけれど画面からは消せないという結構辛いシチュエーションです。

実験環境は下記の通り


  • MacBookPro 2017Late / 8GB RAM(よく「梅」って言われるProの一番下のスペック)

  • MacOSX Mojave

  • Chrome 74

SafariやiOSについては後ろの方で少しだけ言及します。

環境による差異の大きい部分なので、ブラウザやOSが変わると大きく結果が変わってくる可能性があることはご了承ください。


神々の叡智:一緒に読んでおくと良いかもしれないもの

今回の試行錯誤で参照したありがたい記事のリストです。


実験0: 基本となる要素とアニメーションの構築

まずは手始めに普通に書いてみます。どんなものを動かしているのか示すためにソースを載せていますが、流し見していただければ十分かと思います。

お花のhtml部分はこんな感じ。楽するために&個人的趣味でVue.jsを使っていますが1フレームワークはなんでも良いです。


Flower.vue

  <div class="flower-root" 

:class="{animate: visible}"
@animationend="onEndAnim">
<!-- 花びら -->
<div class="petal" v-for="(petal, index) in petals" :key="index"
:style="{
transform: `rotate(${petal.r}deg)`,
'background-color': petal.col
}"
>
</div>
<!-- まんなか -->
<div class="center"></div>
</div>

花びらの枚数だけdivを並べます。角度と色だけ動的にテンプレート内で指定して他のスタイルは以下の通り<style>で一律に定義します。開花のアニメーションもここで指定します(つまり、アニメーションそのものはVueもJavaScriptも関係のないただのCSSアニメーションです)。


Flower.vue

<style lang="scss" scoped>

.flower-root {
position: absolute;
transform: rotate(0deg) scale(0);
animation: rotate 2s ease-out 0s 1 normal forwards;
}
.petal {
position: absolute;
width: 70px;
height: 20px;
top: -10px;
left: 0;
transform-origin: left center;
border-radius: 50px;
background-color: #ffb7aa;
}
.center {
position: absolute;
width: 30px;
height: 30px;
left: -15px;
top: -15px;
border-radius: 30px;
background-color: #ffe683;
}
// 回転・拡大しながら現れるアニメーション
@keyframes rotate {
0% { transform: rotate(0deg) scale(0); }
100% { transform: rotate(360deg) scale(1); }
}
</style>

いささかしょぼいですが、こんな感じでdivだけでお花ができました。

image.png

このFlowerコンポーネントを一定時間ごとに画面に追加しつつ、画面全体をぐるぐる回します。


実験1: そのまま実行(will-changeなし)

まずはwill-changeをつけずにこのままアニメーションを実行してみます。Chromeのパフォーマンス表示に加えて、アクティビティモニタで左側にCPUとGPUの利用率を表示しています。

お花100個目

Pasted Graphic 1.jpg

CPUが結構頑張ってますが、60fpsをキープしてます。意外といけるか?

Pasted Graphic 2.jpg

500個。400個前後でCPUが限界を迎えた模様。フレームレートが一気に落ちていきます。ブラウザが気を利かせてGPUに助けを求めているようですがフレームレートは安定しません。



Pasted Graphic 3.jpg

終盤になると、平均フレームレートは20fps前後まで落ち込む結果に。ファンが回りだしてバッテリーがガンガン減っていきます。

Pasted Graphic 4.jpg

すべての開花が終わって、ただ静止した1000個の花が回っているだけになりました。ここまでくるとようやく60fpsに復帰します。


実験1の結論


  • CSSアニメーションは対象要素数に比例してCPU負荷が上がる

  • CPUが限界になると一気にフレームレートが落ちる


実験2: じゃあもう全部will-changeつけちゃおう

教科書的にはやるべきではない誤った最適化とされているものです。でもまあ、試してみないことにはわからないのでやってみましょう。変更点はスタイルではじめっからwill-change: transformをつけるだけです。


Flower.vue

.flower-root {

position: absolute;
transform: rotate(0deg) scale(0);
animation: rotate 2s ease-out 0s 1 normal forwards;
will-change: transform; // 追加
}

レッツ・スタート!

Pasted Graphic 5.jpg

100個目。とってもスムーズです。CPU負荷も少なめ。

Pasted Graphic 6.jpg

500個。苦しさが見えてきました。GPUの負荷がうなぎのぼりでCPUにも負荷がかかってます。

Pasted Graphic 7.jpg

終盤にはCPUもGPUも悲鳴をあげています。パフォーマンス以前にクラッシュの危険がある状態。

Pasted Graphic 8.jpg

すべての開花アニメーションが終わっても負荷は一向に下がりません。Will-changeをつけるということはいつ動いてもいいように最適化の維持を命じることなので、アニメーションが終わったとしても(終わったと私たちは理解していても)負荷は下がらない、ということですね。

教科書的に書いてあることそのままですが、思っていた以上に影響が大きいです。


実験2の結論


  • will-changeをつけることでGPUを働かせることはできる

  • will-changeをつける要素が多いとGPUとCPUの両方に負荷がかかる

  • will-changeをつけたままだとアニメーションが終わっても、負荷は下がらない


実験3: アニメーションが終わったらwill-changeを取る

実験2の問題は「開花アニメーションはすぐ終わるのに、終わった後もwill-changeをつけっぱなしにしたこと」でした。よって実験3では、開花アニメーションが終わった時点でwill-changeを外します。

will-changeを動的に変更するために、プロパティをテンプレート内に移動して変数でON/OFFできるようにします(ここではvue側に自分で定義したisMoving変数に連動させています)。アニメーションの終了はanimationendで拾えるため、このイベントハンドラでスイッチをOFFに切り替えます。


Flower.vue

  <div class="flower-root" 

:style="{
'will-change': isMoving ? 'transform' : 'auto',
}"

@animationend="onEndAnim">
...


Flower.vue

private onEndAnim() {

this.isMoving = false
}

前置き長くなりましたが、アニメーションの終了を検知してwill-changeを初期値のautoに変更しているだけですね。

では、スタート!

Pasted Graphic 9.jpg

100個目。いい感じ。

Pasted Graphic 10.jpg

500個。あれ?CPUが高いです。フレームレートも300個超えたあたりから落ちだしました。アニメーションが終わったものからwill-changeを外してGPU処理から下ろしているので、GPU側は軽快です。でもCPUがこれだと...

Pasted Graphic 11.jpg

終盤。もう無理。

Pasted Graphic 12.jpg

すべてのアニメーションが終わって、ようやく60fpsに復帰しました。


何がおきているのか?

おかしいです。明らかに期待しない結果になってしまいました。

教科書通りに「アニメーションが終わったら速やかにwill-changeを外す」を実践しただけです。何が起きているのでしょうか?

D67rlG4UEAA0Sv6.jpg

Chromeのパフォーマンスモニタでキャプチャをとってみると、Update Layer Treeという謎の処理が一定時間ごとに走っています。

Chrome内の処理なのであまり具体的なことはわからなかったのですが、

DevToolsのTimelineパネルを見ながら、レンダリングの仕組みを理解する


Update Layer Tree

GPUが処理を行うレイヤを更新しています。


ということで、GPUが処理を行うために対象の要素をレイヤーに昇格したり、不要になったレイヤーを通常の描画に降格させるための再構成を行なっているようです。

今回試行錯誤した結果、どうやら現行のChromeでは以下の傾向があるようです


  • 昇格/降格する処理対象の要素数ではなく、全体のレイヤー数に依存してUpdate Layer Treeが重くなる

  • レイヤーへの昇格よりも降格の方が重い。つまり、will-changeを外す処理は重い

ソースは読めていないので、これが正しい挙動なのかはわかりません...


実験3の結論


  • GPUの負荷を抑える観点でアニメーションが終わったらwill-changeを外すという原則は正しい

  • GPUの負荷が高い状態ではwill-changeをつけたり外したりする処理は重い。特に外す方が重くなる

  • 高負荷状態でアニメーションが終わったからといってパラパラとwill-changeを外すのは致命的


実験4: ある程度まとめてwill-changeを外す

実験3で、アニメーションが終わったからといってバラバラにwill-changheを外すのはヤバい、ということがわかりました。なんかもうこの時点でChromeにはがっかりだよ!という気持ちなのですが2、なんとか回避策を探します。

実験4では、「1個will-changhe外すだけでUpdate Layer Treeが実行されて処理が重くなる」なら「1個でも100個でも同じじゃね?」の発想でアニメーションが終わった要素のwill-changeを一定個数まとめて外してみます。

Flowerインスタンスを格納する配列を用意して


Flower.vue

const queueLimit = 100

const stopedFlowers: Flower[] = []

アニメーション終了時に配列に追加して、100個溜まったらisMoving = falseに設定してwill-changeをはずします。


Flower.vue

private onEndAnim() {

stopedFlowers.push(this)
if (stopedFlowers.length === queueLimit) {
stopedFlowers.forEach(fl => fl.isMoving = false)
stopedFlowers.length = 0
}
}

なんだか汚いやり方だなぁ...と思いつつ。実験スタート!

Pasted Graphic 13.jpg

100個目。まだ100個目のアニメーションが始まったところなのでwill-changeは全部付けっ放し。つまり、実験2と同じ状態ですね。

Pasted Graphic 14.jpg

500個目。100個単位でwill-changeを外しているので、定期的に一瞬だけフレームレートが落ち込みます。

Pasted Graphic 15.jpg

終盤までこの傾向は変わらず。一瞬カクッとフレームレートが落ちるのが悲しいですが、平均的な負荷は安定しています。

Pasted Graphic 17.jpg

無事完走。最後の100個の解放が終わった時点ですべてwill-changeが解除され、実験1,3と同じ状態になりました。


実験4の結論


  • will-changeはアニメーション終了後にある程度まとめて解除するのがよい

  • will-change解除時の負荷は(今回の実験の範囲では)完全には回避できない

  • 重いアニメーションでは「隙を見てwill-changeを外す」ような工夫がアプリ側に必要


ちなみにSafariではどうなの?(オチ)

ここまで試行錯誤しておいてあれなのですが、MacOS/iOSのSafariでは実験3でヌルサクです。

Pasted Graphic 18.jpg

...つまりこれはChromeのバグないしは仕様上の問題なのでは?と思うのですが確定的なソースを見つけられず。

詳しい方いらっしゃったらコメントをいただけると嬉しいです。


まとめ


  • GPUを使ってぬるぬるCSSアニメーションしたいならwill-changeを設定しよう

  • will-changはアニメーションが終わったらちゃんと解除しよう

  • Chromeではwill-changeの解除はUpdate Layer Treeによるレイヤーの再構成を起こして重くなる。
アプリ側で「隙を見てまとめてwill-changeを外す」ことができれば影響を最低限にできる

  • パフォーマンスのチェックは複数ブラウザ・複数環境で。Chromeが正義だと思い込まないこと

  • アクティビティモニタ等、OS側のモニター機能もチェックしよう。60fps出ていてもマシンは悲鳴をあげてるかも?





  1. Vue + CSSアニメーションはインタラクティブなWebコンテンツ作るのにおすすめ。 



  2. 自分の無知は棚に上げるスタイル