ウェブアプリのアニメーションを強化する
要約: アニメーション ワークレットを使用すると、デバイスのネイティブ フレームレートで実行される命令型アニメーションを作成できます。これにより、アニメーションの滑らかさが向上し、メインスレッドのジャンクに対するアニメーションの耐性が高まり、時間を基準とするのではなくスクロールにリンクできるようになります。Animation Worklet は Chrome Canary に実装されており(「試験運用版のウェブ プラットフォームの機能」のフラグの背後)、Chrome 71 でのオリジン トライアルを予定しています。プログレッシブ エンハンスメントとして今すぐ使用できます。
別のアニメーション API?
いいえ、実際には、既存のものを拡張したものです。それには十分な理由があります。最初からやり直しましょう。現在、ウェブ上の DOM 要素をアニメーション化するには、2 つ半の選択肢があります。単純な A から B へのトランジションには CSS トランジション、潜在的に循環する、より複雑な時間ベースのアニメーションには CSS アニメーション、ほぼ任意に複雑なアニメーションには Web Animations API(WAAPI)を使用します。WAAPI のサポート マトリックスはかなり厳しい状況ですが、改善に向かっています。それまでは、polyfill があります。
これらのメソッドに共通するのは、ステートレスで時間駆動型であることです。ただし、デベロッパーが試している効果の中には、時間駆動型でもステートレスでもないものがあります。たとえば、悪名高いパララックス スクロールは、名前のとおり、スクロール駆動です。パフォーマンスの高いパララックス スクロールをウェブに実装するのは、驚くほど難しいことです。
ステートレスはどうでしょうか?たとえば、Android の Chrome のアドレスバーについて考えてみましょう。下にスクロールすると、ビューからスクロール アウトします。ただし、ページを少しでも上にスクロールすると、ページの中間までスクロールしていても、すぐに戻ってきます。アニメーションはスクロール位置だけでなく、以前のスクロール方向にも依存します。ステートフルです。
もう 1 つの問題は、スクロールバーのスタイル設定です。スタイリングが難しいことで知られています。少なくとも、十分なスタイリングができません。スクロールバーをニャンコにしたい場合はどうすればよいですか?どの手法を選択しても、カスタム スクロールバーの構築はパフォーマンスに優れておらず、簡単でもありません。
これらのすべてを効率的に実装するのは、困難を極めるか、不可能に近いということです。そのほとんどはイベントや requestAnimationFrame
に依存しており、画面が 90 fps、120 fps 以上で実行できる場合でも、60 fps に制限される可能性があります。また、貴重なメインスレッドのフレーム バジェットの一部しか使用されません。
Animation Worklet は、ウェブのアニメーション スタックの機能を拡張して、このような効果を簡単に実現できるようにします。詳細に入る前に、アニメーションの基本事項を復習しましょう。
アニメーションとタイムラインの基礎
WAAPI と Animation Worklet は、タイムラインを広範囲に利用して、アニメーションと効果を思いどおりにオーケストレートできるようにしています。このセクションでは、タイムラインとアニメーションの仕組みについて簡単に復習または紹介します。
各ドキュメントには document.timeline
があります。ドキュメントが作成されたときに 0 から始まり、ドキュメントが存在し始めてからのミリ秒数をカウントします。ドキュメントのすべてのアニメーションは、このタイムラインを基準に動作します。
もう少し具体的な例として、次の WAAPI スニペットを見てみましょう。
const animation = new Animation(
new KeyframeEffect(
document.querySelector('#a'),
[
{
transform: 'translateX(0)',
},
{
transform: 'translateX(500px)',
},
{
transform: 'translateY(500px)',
},
],
{
delay: 3000,
duration: 2000,
iterations: 3,
}
),
document.timeline
);
animation.play();
animation.play()
を呼び出すと、アニメーションはタイムラインの currentTime
を開始時間として使用します。アニメーションの遅延は 3, 000 ミリ秒です。つまり、タイムラインが `startTime` に達すると、アニメーションが開始(または「アクティブ」になる)します。
- 3000
. After that time, the animation engine will animate the given element from the first keyframe (
translateX(0)), through all intermediate keyframes (
translateX(500px)) all the way to the last keyframe (
translateY(500px)) in exactly 2000ms, as prescribed by the
durationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline's
currentTimeis
startTime + 3000 + 1000and the last keyframe at
startTime + 3000 + 2000` となります。重要なのは、タイムラインによってアニメーションのどの時点にいるのかを制御できることです。
アニメーションが最後のキーフレームに達すると、最初のキーフレームに戻り、アニメーションの次のイテレーションが開始されます。iterations: 3
を設定したため、このプロセスは合計 3 回繰り返されます。アニメーションを停止させたくない場合は、iterations: Number.POSITIVE_INFINITY
と記述します。上記のコードの結果は次のとおりです。
WAAPI は非常に強力で、イージング、開始オフセット、キーフレームの重み付け、塗りつぶし動作など、この記事の範囲を超える多くの機能がこの API にあります。詳細については、CSS Tricks の CSS アニメーションに関する記事をご覧ください。
アニメーション ワークレットの作成
タイムラインの概念を理解できたので、Animation Worklet と、それを使ってタイムラインを操作する方法を見ていきましょう。Animation Worklet API は WAAPI に基づいているだけでなく、拡張可能なウェブという意味で、WAAPI の機能の仕組みを説明する低レベルのプリミティブでもあります。構文は非常に似ています。
アニメーション ワークレット | WAAPI |
---|---|
new WorkletAnimation( 'passthrough', new KeyframeEffect( document.querySelector('#a'), [ { transform: 'translateX(0)' }, { transform: 'translateX(500px)' } ], { duration: 2000, iterations: Number.POSITIVE_INFINITY } ), document.timeline ).play(); |
new Animation( new KeyframeEffect( document.querySelector('#a'), [ { transform: 'translateX(0)' }, { transform: 'translateX(500px)' } ], { duration: 2000, iterations: Number.POSITIVE_INFINITY } ), document.timeline ).play(); |
違いは最初のパラメータです。これは、このアニメーションを駆動する ワークレットの名前です。
特徴検出
Chrome はこの機能を搭載した最初のブラウザであるため、コードが AnimationWorklet
の存在を前提としていないことを確認する必要があります。そのため、ワークレットを読み込む前に、簡単なチェックでユーザーのブラウザが AnimationWorklet
をサポートしているかどうかを検出する必要があります。
if ('animationWorklet' in CSS) {
// AnimationWorklet is supported!
}
ワークレットを読み込む
ワークレットは、Houdini タスクフォースによって導入された新しいコンセプトで、多くの新しい API を簡単に構築してスケーリングできるようにします。ワークレットの詳細については後ほど説明しますが、ここでは、ワークレットを安価で軽量なスレッド(ワーカーなど)と考えるとわかりやすいでしょう。
アニメーションを宣言する前に、「passthrough」という名前のワークレットが読み込まれていることを確認する必要があります。
// index.html
await CSS.animationWorklet.addModule('passthrough-aw.js');
// ... WorkletAnimation initialization from above ...
// passthrough-aw.js
registerAnimator(
'passthrough',
class {
animate(currentTime, effect) {
effect.localTime = currentTime;
}
}
);
ここで何が起きているのかAnimationWorklet の registerAnimator()
呼び出しを使用して、クラスをアニメーターとして登録し、「passthrough」という名前を付けています。これは、上記の WorkletAnimation()
コンストラクタで使用したのと同じ名前です。登録が完了すると、addModule()
から返された Promise が解決され、そのワークレットを使用してアニメーションの作成を開始できます。
インスタンスの animate()
メソッドは、ブラウザがレンダリングするすべてのフレームで呼び出され、アニメーションのタイムラインの currentTime
と、現在処理中のエフェクトが渡されます。効果は 1 つ(KeyframeEffect
)のみで、currentTime
を使用して効果の localTime
を設定しています。このため、このアニメーターは「パススルー」と呼ばれます。このワークレットのコードを使用すると、上記の WAAPI と AnimationWorklet はまったく同じように動作します。デモで確認できます。
時間
animate()
メソッドの currentTime
パラメータは、WorkletAnimation()
コンストラクタに渡したタイムラインの currentTime
です。前の例では、その時間をエフェクトに渡しただけでした。しかし、これは JavaScript コードであり、時間を歪めることができます。💫
function remap(minIn, maxIn, minOut, maxOut, v) {
return ((v - minIn) / (maxIn - minIn)) * (maxOut - minOut) + minOut;
}
registerAnimator(
'sin',
class {
animate(currentTime, effect) {
effect.localTime = remap(
-1,
1,
0,
2000,
Math.sin((currentTime * 2 * Math.PI) / 2000)
);
}
}
);
NaN
を処理できるため(入力の 1 つが NaN
の場合、NaN
を返します)、ここでは問題ありません。currentTime
の Math.sin()
を取得し、その値を [0; 2000] の範囲に再マッピングします。これは、効果が定義されている時間範囲です。キーフレームやアニメーションのオプションを変更していないのに、アニメーションの見た目が大きく変わりました。ワークレット コードは任意に複雑にすることができ、どのエフェクトをどの順序でどの程度再生するかをプログラムで定義できます。
オプションのオプション
ワークレットを再利用して数値を変更したい場合があります。このため、WorkletAnimation コンストラクタでは、ワークレットにオプション オブジェクトを渡すことができます。
registerAnimator(
'factor',
class {
constructor(options = {}) {
this.factor = options.factor || 1;
}
animate(currentTime, effect) {
effect.localTime = currentTime * this.factor;
}
}
);
new WorkletAnimation(
'factor',
new KeyframeEffect(
document.querySelector('#b'),
[
/* ... same keyframes as before ... */
],
{
duration: 2000,
iterations: Number.POSITIVE_INFINITY,
}
),
document.timeline,
{factor: 0.5}
).play();
この例では、両方のアニメーションが同じコードで駆動されていますが、オプションが異なります。
ローカルの状態を教えて!
前述のとおり、アニメーション ワークレットが解決を目指す主な問題の 1 つは、ステートフル アニメーションです。アニメーション ワークレットは状態を保持できます。ただし、ワークレットのコア機能の 1 つは、別のスレッドに移行したり、リソースを節約するために破棄したりできることです。この場合、状態も破棄されます。状態の損失を防ぐため、アニメーション ワークレットには、ワークレットが破棄される前に呼び出されるフックが用意されています。このフックを使用して、状態オブジェクトを返すことができます。このオブジェクトは、ワークレットが再作成されるときにコンストラクタに渡されます。初期作成時、このパラメータは undefined
になります。
registerAnimator(
'randomspin',
class {
constructor(options = {}, state = {}) {
this.direction = state.direction || (Math.random() > 0.5 ? 1 : -1);
}
animate(currentTime, effect) {
// Some math to make sure that `localTime` is always > 0.
effect.localTime = 2000 + this.direction * (currentTime % 2000);
}
destroy() {
return {
direction: this.direction,
};
}
}
);
このデモを更新するたびに、正方形が回転する方向が 50/50 の確率で決まります。ブラウザがワークレットを破棄して別のスレッドに移行すると、作成時に別の Math.random()
呼び出しが行われ、方向が突然変わる可能性があります。これを防ぐため、アニメーションのランダムに選択された方向を state として返し、提供された場合はコンストラクタで使用します。
時空連続体へのフック: ScrollTimeline
前のセクションで説明したように、AnimationWorklet を使用すると、タイムラインの進行がアニメーションの効果にどのように影響するかをプログラムで定義できます。しかし、これまでのところ、タイムラインは常に時間を追跡する document.timeline
でした。
ScrollTimeline
は、新しい可能性を切り開き、時間ではなくスクロールでアニメーションを駆動できるようにします。このデモでは、最初の「パススルー」ワークレットを再利用します。
new WorkletAnimation(
'passthrough',
new KeyframeEffect(
document.querySelector('#a'),
[
{
transform: 'translateX(0)',
},
{
transform: 'translateX(500px)',
},
],
{
duration: 2000,
fill: 'both',
}
),
new ScrollTimeline({
scrollSource: document.querySelector('main'),
orientation: 'vertical', // "horizontal" or "vertical".
timeRange: 2000,
})
).play();
document.timeline
を渡す代わりに、新しい ScrollTimeline
を作成しています。お察しのとおり、ScrollTimeline
は時間ではなく scrollSource
のスクロール位置を使用して、ワークレットの currentTime
を設定します。一番上(または左)までスクロールすると currentTime = 0
になり、一番下(または右)までスクロールすると currentTime
が timeRange
に設定されます。このデモのボックスをスクロールすると、赤いボックスの位置を制御できます。
スクロールしない要素を含む ScrollTimeline
を作成すると、タイムラインの currentTime
は NaN
になります。レスポンシブ デザインを念頭に置いて、常に currentTime
として NaN
を想定しておく必要があります。多くの場合、デフォルト値は 0 にするのが妥当です。
アニメーションとスクロール位置のリンクは長年求められてきましたが、このレベルの忠実度で実現されたことはありませんでした(CSS3D を使用したハック的な回避策は除きます)。Animation Worklet を使用すると、これらの効果を高性能で簡単に実装できます。たとえば、このデモのような視差スクロール効果では、スクロール駆動型アニメーションを定義するのに数行のコードしか必要ないことがわかります。
仕組み
ワークレット
ワークレットは、スコープが分離され、API サーフェスが非常に小さい JavaScript コンテキストです。API サーフェスが小さいため、ブラウザによるより積極的な最適化が可能になり、特にローエンド デバイスで効果を発揮します。また、ワークレットは特定のイベントループにバインドされず、必要に応じてスレッド間で移動できます。これは、AnimationWorklet では特に重要です。
Compositor NSync
特定の CSS プロパティはアニメーション化が速く、他のプロパティはそうでないことをご存じかもしれません。一部のプロパティは GPU で処理するだけでアニメーション化できますが、一部のプロパティはブラウザにドキュメント全体の再レイアウトを強制します。
Chrome(他の多くのブラウザと同様)にはコンポジタと呼ばれるプロセスがあります。このプロセスは、レイヤとテクスチャを配置し、GPU を利用して画面をできるだけ定期的に更新する(理想的には画面の更新速度と同じくらい速く、通常は 60 Hz)という役割を担っています。アニメーション化する CSS プロパティによっては、ブラウザはコンポジターに処理を任せるだけで済みますが、他のプロパティではレイアウトを実行する必要があります。レイアウトはメインスレッドでのみ実行できるオペレーションです。アニメーション化するプロパティに応じて、アニメーション ワークレットはメインスレッドにバインドされるか、コンポジターと同期して別のスレッドで実行されます。
軽い罰
GPU は競合の激しいリソースであるため、通常、コンポジタ プロセスは 1 つのみで、複数のタブで共有される可能性があります。コンポジタが何らかの理由でブロックされると、ブラウザ全体が停止し、ユーザー入力に応答しなくなります。これは、どのような場合でも回避する必要があります。では、ワークレットがコンポジタが必要とするデータをフレームのレンダリングに間に合うように配信できない場合はどうなるのでしょうか?
この場合、ワークレットは仕様に従って「スリップ」することが許可されます。コンポーザーに遅れをとると、コンポーザーは最後のフレームのデータを再利用して、フレームレートを維持できます。視覚的にはジャンクのように見えますが、ブラウザはユーザー入力に引き続き応答するという大きな違いがあります。
まとめ
AnimationWorklet には多くの側面があり、ウェブに多くのメリットをもたらします。明らかなメリットは、アニメーションの制御性が向上し、アニメーションを駆動する新しい方法が提供されることで、ウェブの視覚的な忠実度が向上することです。また、API の設計により、アプリのジャンクに対する耐性を高めながら、同時にすべての新機能を利用できるようになります。
Animation Worklet は Canary にあり、Chrome 71 でオリジン トライアルを実施する予定です。皆様の素晴らしい新しいウェブ エクスペリエンスと、改善点についてのご意見をお待ちしております。同じ API を提供するが、パフォーマンスの分離は提供しない polyfill もあります。
CSS トランジションと CSS アニメーションは依然として有効なオプションであり、基本的なアニメーションにははるかに簡単に使用できます。ただし、凝ったアニメーションが必要な場合は、AnimationWorklet を使用できます。