mix-blend-modeを適用した要素周りをGSAPで動かすと表示がおかしくなった

:::note warn
GSAPによる問題ではありません。筆者の力不足です。
:::

## 事象

GSAPでmix-blend-modeを適用した要素、もしくは、合成する要素（mix-blend-mode要素の下のレイヤー）を`transform: translate3d()`で動かすと、正常に合成されません。

サンプルを用意しましたので、確認してみてください。
赤・オレンジ・黄の要素の上に、緑の要素が`mix-blend-mode: saturation`で乗っかっています。modeは関係ありませんが、違いが分かりやすそうだったので`saturation`にしています。

私は、MacBook Pro (16-inch, 2019)の本体ディスプレイで表示したChrome 108で確認すると、表示がおかしいことに気付きました。（Safari 16.1は外部ディスプレイでも発生、Firefox 108では確認できず）
もしかしたら、環境によっては違いが分からないかもしれません。

表示がおかしいとは具体的には、上3色が左から右へスライドインするアニメーションの終了前、終了後で、**合成部分の色が若干変わります**。

今回のサンプルでは起きていませんが、実際にこの事象に出会ったときは、ジャギーっぽい何かも発生して明らかにおかしいことが分かりました。

<p class="codepen" data-height="300" data-theme-id="dark" data-default-tab="result" data-slug-hash="QWBWYoG" data-preview="true" data-user="heeroo-ymsw" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/heeroo-ymsw/pen/QWBWYoG">
  mix-blend-mode and the stacking context</a> by heeroo-ymsw (<a href="https://codepen.io/heeroo-ymsw">@heeroo-ymsw</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

### 問題のソースコード

サンプルのHTML・CSS・JSは以下の通りです。
※説明に不要な部分は削っています。

```html:HTML
<div class="wrapper">
  <div class="parent-1">
    <div class="child child-1">
      <div></div>
    </div>
    <div class="child child-2">
      <div></div>
    </div>
    <div class="child child-3">
      <div></div>
    </div>
  </div>
  <div class="parent-2"></div>
</div>
```

```scss:SCSS
.wrapper {
  position: relative;
}

.parent-1 {
  display: flex;
  position: relative;
  z-index: 1;
}

.child {
  overflow: hidden;
  position: relative;
  z-index: 1;
}

.child-1 {
  > div {
    background-color: red;
  }
}

.child-2 {
  > div {
    background-color: orange;
  }
}

.child-3 {
  > div {
    background-color: yellow;
  }
}

.parent-2 {
  position: relative;
  z-index: 2;
  top: -30px;
  background-color: darkgreen;
  mix-blend-mode: saturation;
}
```

```javascript:JavaScript
const tl = gsap.timeline();

tl.set(".child div", { x: -50, autoAlpha: 0 })
  .set(".parent-2", { autoAlpha: 0 })
  .to(".child div", {
    stagger: 0.3,
    x: 0,
    autoAlpha: 1,
    duration: 1,
    ease: "power3.out"
  })
  .to(".parent-2", { autoAlpha: 1 }, "<0.8");
```

childクラスの下の`div`は画像の想定です。
childクラスには`overflow: hidden;`を適用し、枠内でその`div`に対してGSAPで`x: -50`から`x: 0`に移動するようにして、スライドインさせています。

## 結論

**GSAPで動かした要素が`mix-blend-mode`を適用した要素とは別の新しいスタックコンテキストを生成し、正常に合成できていなかった。**

## 原因

### スタックコンテキスト - Stacking Context

スタックコンテキストは、ブラウザ上のZ軸、つまり奥行き・要素の重なりの概念です。

スタックコンテキストは様々なプロパティによって生成されます。

- 文書のルート要素 (`<html>`)
- `position`の値が`absolute`または`relative`、かつ`z-index`の値が`auto`以外の要素
- `position`の値が`fixed`または`sticky`の要素
- フレックスコンテナの子であり、`z-index`の値が`auto`以外の要素
- グリッドコンテナの子であり、`z-index`の値が`auto`以外の要素
- `opacity`の値が`1`未満である要素
- `mix-blend-mode`の値が`normal`以外の要素
- 以下のプロパティの何れかが`none`以外の値を持つ要素
  - `transform`
  - `filter`
  - `perspective`
  - `clip-path`
  - `mask` / `mask-image` / `mask-border`
- `isolation`の値が`isolate`である要素
- `-webkit-overflow-scrolling`の値が`touch`である要素
- `will-change`の値が、初期値以外で重ね合わせコンテキストを作成するプロパティを指定している要素
- `contain`の値が`layout`または`paint`であるか、これらのどちらかを含む複合値を持つ要素

> [重ね合わせコンテキスト MDN Web Docs](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context)より

`mix-blend-mode`を使う際は、このスタックコンテキストを意識しないとうまく反映されません。

今回の例ですと、`parent-1`クラスを持った要素と、`parent-2`クラスを持った要素は`wrapper`クラス要素に内包される兄弟要素ですから、`z-index`を見ると`parent-1`、`parent-2`という順番で重なり合っている、つまり、`parent-2`が手前に来るはずです。

### ハードウェアアクセラレーション

さて、話は少し変わり、今回のアニメーションはGSAPで実装しています。

実はGSAPでのアニメーション無効にすると、何も問題なく表示されます。
つまり、GSAPによって新たなスタックコンテキストが生成されており、重なりが意図したものではなくなっていることが分かります。

また、`.child > div`に`will-change: transform;`を与えると、GSAPでアニメーションさせなくても同様の現象が発生します。

つまり、ハードウェアアクセラレーションによってZ軸の重なりが意図したものではなくなっていることが分かります。

ちなみに、GSAPはGPUによってパフォーマンス最適化をするため、**2Dでの動きしかない場合でも移動は`translate3d()`によって表現するのがデフォルト**の動きです。その設定はconfigによって変更することが可能です[^1]。
もっと細かく言えば、パフォーマンスを最大化するために、**アニメーションが終了した段階で2Dにスイッチバック**します。
`translate3d(X, Y, Z)`で動かした後は、`translate(0, 0)`で止まるということです。

なるほどこの動きが分かったことで、今回の事例の「重ねられる下の要素の左から右へのアニメーションが終了した時点で色が変わる」という事象の説明がつきます。

[^1]: https://greensock.com/docs/v3/GSAP/gsap.config

ハードウェアアクセラレーションを使う際、そのレンダリングのために**レイヤー**を用います。
GPUレイヤー内でその要素はアクセラレーションをかけていない他の要素とは別にレンダリングされ、後から画面に合成されます。
ハードウェアアクセラレーションが効いた要素に対して何かしらの変更処理が働いたとき、GPUレイヤーのみレンダリングを行えば良いため、描画処理の高速化が期待できるわけです。賢い。

よって、スタックコンテキストが別階層に生成されていたため、うまく重なりが表現できていなかったということが分かります。

### 原因のまとめ

ここまでをまとめると、

- GSAPはハードウェアアクセラレーションによって高速化を図るために`translate3d`を使う
- 下の要素はGSAPで動かしていたため、ハードウェアアクセラレーションがかかっている
- 上から重ねた要素はハードウェアアクセラレーションがかかっていない
- スタックコンテキストが別階層となったため、`mix-blend-mode`で正しく合成できなかった

となります。

## 対処

原因を踏まえると、スタックコンテキストの階層を正しく合わせれば良いということが分かります。

そこで、対処法としては**2つ**あります。

1. GSAPで動かしていない方の要素にもハードウェアアクセラレーションをかける
2. GSAPで動かす要素を`translate()`で動かすようにする

### GSAPで動かしていない方の要素にもハードウェアアクセラレーションをかける

やり方は以下の二通りでしょう。

```diff_scss:SCSS
.parent-2 {
  position: relative;
  z-index: 2;
  top: -30px;
  background-color: darkgreen;
  mix-blend-mode: saturation;
+ will-change: transform;
  // もしくは
+ transform: translateZ(0);
}
```

### GSAPでアニメーションさせる要素を`translate()`で動かすようにする

または、GSAPで`translate3d()`を使わないようにするという方法もあります。

```diff_javascript:JavaScript
+ gsap.config({ force3D: false });
const tl = gsap.timeline();

tl.set(".child div", { x: -50, autoAlpha: 0 })
  .set(".parent-2", { autoAlpha: 0 })
  .to(".child div", {
    stagger: 0.3,
    x: 0,
    autoAlpha: 1,
    duration: 1,
    ease: "power3.out"
  })
  .to(".parent-2", { autoAlpha: 1 }, "<0.8");
```

## まとめ

ちょっとハマりそうでしたが、GSAPの動きを知っていたため大事には至りませんでした。

解決はしましたが、結局なぜこの不具合が確認できる環境・できない環境があるのかは調べきれませんでした。
Chrome、Safari、Firefoxでレンダリングの処理が何かしら違うのでしょうが、そこまで調べる余裕はありません（バッサリ）
知見ある方がいらっしゃいましたらお教えいただきたいです。