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
以外の値を持つ要素transform
filter
perspective
clip-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でレンダリングの処理が何かしら違うのでしょうが、そこまで調べる余裕はありません(バッサリ)
知見ある方がいらっしゃいましたらお教えいただきたいです。