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色が左から右へスライドインするアニメーションの終了前、終了後で、合成部分の色が若干変わります。
今回のサンプルでは起きていませんが、実際にこの事象に出会ったときは、ジャギーっぽい何かも発生して明らかにおかしいことが分かりました。
See the Pen mix-blend-mode and the stacking context by heeroo-ymsw (@heeroo-ymsw) on CodePen.
問題のソースコード
サンプルのHTML・CSS・JSは以下の通りです。
※説明に不要な部分は削っています。
<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>
.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;
}
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以外の値を持つ要素transformfilterperspectiveclip-path-
mask/mask-image/mask-border
-
isolationの値がisolateである要素 -
-webkit-overflow-scrollingの値がtouchである要素 -
will-changeの値が、初期値以外で重ね合わせコンテキストを作成するプロパティを指定している要素 -
containの値がlayoutまたはpaintであるか、これらのどちらかを含む複合値を持つ要素
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)で止まるということです。
なるほどこの動きが分かったことで、今回の事例の「重ねられる下の要素の左から右へのアニメーションが終了した時点で色が変わる」という事象の説明がつきます。
ハードウェアアクセラレーションを使う際、そのレンダリングのためにレイヤーを用います。
GPUレイヤー内でその要素はアクセラレーションをかけていない他の要素とは別にレンダリングされ、後から画面に合成されます。
ハードウェアアクセラレーションが効いた要素に対して何かしらの変更処理が働いたとき、GPUレイヤーのみレンダリングを行えば良いため、描画処理の高速化が期待できるわけです。賢い。
よって、スタックコンテキストが別階層に生成されていたため、うまく重なりが表現できていなかったということが分かります。
原因のまとめ
ここまでをまとめると、
- GSAPはハードウェアアクセラレーションによって高速化を図るために
translate3dを使う - 下の要素はGSAPで動かしていたため、ハードウェアアクセラレーションがかかっている
- 上から重ねた要素はハードウェアアクセラレーションがかかっていない
- スタックコンテキストが別階層となったため、
mix-blend-modeで正しく合成できなかった
となります。
対処
原因を踏まえると、スタックコンテキストの階層を正しく合わせれば良いということが分かります。
そこで、対処法としては2つあります。
- GSAPで動かしていない方の要素にもハードウェアアクセラレーションをかける
- GSAPで動かす要素を
translate()で動かすようにする
GSAPで動かしていない方の要素にもハードウェアアクセラレーションをかける
やり方は以下の二通りでしょう。
.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()を使わないようにするという方法もあります。
+ 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でレンダリングの処理が何かしら違うのでしょうが、そこまで調べる余裕はありません(バッサリ)
知見ある方がいらっしゃいましたらお教えいただきたいです。