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

こんにちは、CSSとVue.jsで[アニメーション使いまくりのポートフォリオ作ったり](https://qiita.com/yuneco/items/b14a695a060c96309239)、[シューティングゲーム作ったり](https://qiita.com/yuneco/items/34daf063075d03c4eede)して遊んでいるゆきです。
今日はCSSアニメーションで無茶しすぎてMacBookがカイロになった反省からの「負荷をかけずにぬるぬるのCSSアニメーションを実現するための試行錯誤」の顛末をまとめます。**それでもCSSでアニメーションしたいんだ** 

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

この記事では、Webで**CSSを使ってゲームやアート的な表現にゴリゴリのアニメーションを使いたい！**というケースを想定します。
全体を通してCSSのwill-changeプロパティを使ったGPUレンダリングによる最適化のお話です。will-changeってなに？って方はこの後でてくる参考記事リストを先に見ていただくのが良いと思います。

![ezgif.com-optimize (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/60f1d001-eb46-b80a-7c4e-101bc421f749.gif)
https://css-anime.firebaseapp.com/

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

* CSSで描いた花が1000個、順次開花する
* 個々の花はシンプルなdiv要素（今回は画像やSVGは使っていません）
* 新しい花は32msごとに回転・拡大しながら現れる
* 開花後の花はそのまま残る
* 上記に加え、画面全体も回転し続ける

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

実験環境は下記の通り

* MacBookPro 2017Late / 8GB RAM（よく「梅」って言われるProの一番下のスペック）
* MacOSX Mojave
* Chrome 74 

SafariやiOSについては後ろの方で少しだけ言及します。
環境による差異の大きい部分なので、ブラウザやOSが変わると大きく結果が変わってくる可能性があることはご了承ください。

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

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

* [CSS Will Change Module Level 1（日本語訳）](https://triple-underscore.github.io/css-will-change-ja.html)
* [CSS: will-change指定時の挙動, パフォーマンスへの影響と考察](https://qiita.com/damele0n/items/71352757d0e6fdf5b184)
* [ハイパフォーマンスCSS3アニメーション——60fpsを実現するベストプラクティス](https://www.webprofessional.jp/achieve-60-fps-mobile-animations-with-css3/)
* [コンポジタ専用プロパティの優先使用、およびレイヤー数の管理](https://developers.google.com/web/fundamentals/performance/rendering/stick-to-compositor-only-properties-and-manage-layer-count?hl=ja)

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

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

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

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

```vue: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アニメーションです）。

```scss: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/d9826cc9-f47f-6b8c-09e2-fe3404f5e08a.png)

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

## 実験1: そのまま実行（will-changeなし）

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

お花100個目
￼![Pasted Graphic 1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/d02c257e-f076-8b05-e9eb-68022ca6d542.jpeg)

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

![Pasted Graphic 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/2e3d4c05-2c8d-0f8b-b342-2cffe11d0b3a.jpeg)
500個。400個前後でCPUが限界を迎えた模様。フレームレートが一気に落ちていきます。ブラウザが気を利かせてGPUに助けを求めているようですがフレームレートは安定しません。
￼
![Pasted Graphic 3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/216022fa-691f-48a8-1a3d-72491c82291e.jpeg)
終盤になると、平均フレームレートは20fps前後まで落ち込む結果に。ファンが回りだしてバッテリーがガンガン減っていきます。

![Pasted Graphic 4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/11bd8e6f-946e-0bf1-35b5-cfd3515cede7.jpeg)
すべての開花が終わって、ただ静止した1000個の花が回っているだけになりました。ここまでくるとようやく60fpsに復帰します。

### 実験1の結論
* CSSアニメーションは対象要素数に比例してCPU負荷が上がる
* CPUが限界になると一気にフレームレートが落ちる

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

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

```scss: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/a2c299b3-6c10-37ad-9f7b-545774a5d3e1.jpeg)
100個目。とってもスムーズです。CPU負荷も少なめ。

￼![Pasted Graphic 6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/3fd09d07-943e-6e4f-9d28-1bf2f9961d6d.jpeg)
500個。苦しさが見えてきました。GPUの負荷がうなぎのぼりでCPUにも負荷がかかってます。

￼![Pasted Graphic 7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/7ffcc089-ebb0-ffe2-90ab-4466ffcc2ddf.jpeg)
終盤にはCPUもGPUも悲鳴をあげています。パフォーマンス以前にクラッシュの危険がある状態。

![Pasted Graphic 8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/f1e6120b-a7ab-c8e3-6ad4-f7a3e2d9ed08.jpeg)
すべての開花アニメーションが終わっても負荷は一向に下がりません。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に切り替えます。

```vue:Flower.vue
  <div class="flower-root" 
    :style="{
      'will-change': isMoving ? 'transform' : 'auto',
    }"
    @animationend="onEndAnim">
    ...
```

```typescript:Flower.vue
private onEndAnim() {
  this.isMoving = false
}
```

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

![Pasted Graphic 9.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/500d20d5-209c-fe1f-9858-5d6800508949.jpeg)￼
100個目。いい感じ。

![Pasted Graphic 10.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/14074ed8-c3f6-046c-e8b2-05987ecc9240.jpeg)
500個。あれ？CPUが高いです。フレームレートも300個超えたあたりから落ちだしました。アニメーションが終わったものからwill-changeを外してGPU処理から下ろしているので、GPU側は軽快です。でもCPUがこれだと...

![Pasted Graphic 11.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/f86036ac-d513-d82e-89e2-07402551aab5.jpeg)
終盤。もう無理。

￼![Pasted Graphic 12.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/5f62f381-260c-29df-d535-a7080e160cb9.jpeg)
すべてのアニメーションが終わって、ようやく60fpsに復帰しました。

### 何がおきているのか？

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

￼![D67rlG4UEAA0Sv6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/d8f07924-3a40-d350-2210-5e6c5f647bbc.jpeg)
Chromeのパフォーマンスモニタでキャプチャをとってみると、`Update Layer Tree`という謎の処理が一定時間ごとに走っています。

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

[DevToolsのTimelineパネルを見ながら、レンダリングの仕組みを理解する](https://qiita.com/cy-mitsuki/items/51a0a4c17b89154a7af2)
> 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-changeを外すのはヤバい、ということがわかりました。なんかもうこの時点でChromeにはがっかりだよ！という気持ちなのですが[^1]、なんとか回避策を探します。

[^1]: 自分の無知は棚に上げるスタイル

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

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

```typescript:Flower.vue 
const queueLimit = 100
const stopedFlowers: Flower[] = []
```

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

```typescript:Flower.vue
private onEndAnim() {
  stopedFlowers.push(this)
  if (stopedFlowers.length === queueLimit) {
    stopedFlowers.forEach(fl => fl.isMoving = false)
    stopedFlowers.length = 0
  }
}
```

なんだか汚いやり方だなぁ...と思いつつ。実験スタート！
![Pasted Graphic 13.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/77f39ca5-9bec-ff1d-b30d-adf9b4fa9b0c.jpeg)￼
100個目。まだ100個目のアニメーションが始まったところなのでwill-changeは全部付けっ放し。つまり、実験2と同じ状態ですね。

￼![Pasted Graphic 14.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/7d46925a-0116-9d3a-df58-2b2a5bdbcd2c.jpeg)
500個目。100個単位でwill-changeを外しているので、定期的に一瞬だけフレームレートが落ち込みます。

![Pasted Graphic 15.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/9f391057-c20f-a5dd-c8e3-6720aa5261a6.jpeg)
終盤までこの傾向は変わらず。一瞬カクッとフレームレートが落ちるのが悲しいですが、平均的な負荷は安定しています。

![Pasted Graphic 17.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/b8248297-9598-a484-a361-f0fecd7f4d64.jpeg)
無事完走。最後の100個の解放が終わった時点ですべてwill-changeが解除され、実験1,3と同じ状態になりました。

### 実験4の結論
* will-changeはアニメーション終了後にある程度まとめて解除するのがよい
* will-change解除時の負荷は（今回の実験の範囲では）完全には回避できない
* 重いアニメーションでは「隙を見てwill-changeを外す」ような工夫がアプリ側に必要

## ちなみにSafariではどうなの？（オチ）

ここまで試行錯誤しておいてあれなのですが、MacOS/iOSのSafariでは実験3でヌルサクです。
![Pasted Graphic 18.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/8909c55d-0b3d-3e32-1142-e5d817983364.jpeg)
...つまりこれはChromeのバグないしは仕様上の問題なのでは？と思うのですが確定的なソースを見つけられず。
詳しい方いらっしゃったらコメントをいただけると嬉しいです。

## まとめ

* GPUを使ってぬるぬるCSSアニメーションしたいならwill-changeを設定しよう
* will-changeはアニメーションが終わったらちゃんと解除しよう
* Chromeではwill-changeの解除はUpdate Layer Treeによるレイヤーの再構成を起こして重くなる。 アプリ側で「隙を見てまとめてwill-changeを外す」ことができれば影響を最低限にできる
* パフォーマンスのチェックは複数ブラウザ・複数環境で。Chromeが正義だと思い込まないこと
* アクティビティモニタ等、OS側のモニター機能もチェックしよう。60fps出ていてもマシンは悲鳴をあげてるかも？