↔️

無駄な描画処理を行わずにcanvasをスクロールさせる

2024/10/14に公開

Webブラウザー上で動作するオーディオプレイヤーを作成する際に,オーディオ波形をキャンバスで表示させるコンポーネントが必要になりました.そこで以前のリサイズできるcanvasを発展させて,スクロール操作のできるcanvasを作成しました.

作成したコンポーネントの動作例はこちらです.

https://rerrahkr.github.io/voice-analyzer2/

要件

作成したキャンバスの要件は以下のとおりとしました.

  • ビューポート領域は自動リサイズさせる.
  • コンテンツ領域がビューポート領域よりも大きい場合はキャンバスをスクロール可能にする.
  • 画面に表示されないコンテンツ領域は保持しない.

3つ目はパフォーマンス面を考えて設定した要件です.例えばコンテンツ領域いっぱいに描画すべきオブジェクトがあるとします.しかし,このオブジェクトは常にすべて描画すべきとは限りません.キャンバスのビューポート外にある部分は実際には画面上に表示されないため,見えない部分の描画は無駄な処理となります.
これはキャンバスでアニメーション処理を行う際には重要な観点となります.描画するオブジェクトが静的なものであれば,全体を一度に描画してもそこまでパフォーマンスに影響を与えません.しかしオブジェクトが動的に変化する場合,アニメーションのフレームごとに全体を再描画していると,コンテンツ領域が大きくなるほどアニメーションの更新処理の負荷は高くなります.そのため,ビューポート領域にあたる部分のみ再描画することで処理コストを抑えるといった方法がとられます.

参考

同じようなことをやっている人がいないか調べてみると,以下の記事を見つけました.

https://zenn.dev/iemong/articles/eb37653cdebc41

https://qiita.com/solt9029/items/92d458917e5b8fb62032

これらの方法のようにtransform: translate()を使った実装も試したのですが,スクロール直後からキャンバスの平行移動が実行されるまでに微妙な遅延があるため,一瞬キャンバスがビューポート領域とずれて表示されてしまいました.

またこれらの実装では,ビューポート領域の大きさが固定値であり,レイアウトの変更などによるリサイズには対応していません.そのため今回の要件を満たすには,ビューポート領域の変化にあわせてキャンバスの大きさも自動的に変更するような実装が追加で必要となります.

2つのレイヤー構成による実装

オーディオプレーヤーのプロジェクトにScrollableCanvasというカスタムコンポーネントとして,要件に従ったキャンバスの実装を行いました.

https://github.com/rerrahkr/voice-analyzer2/blob/main/src/components/ScrollableCanvas.tsx

このコンポーネントはcanvas要素を保持するキャンバスレイヤーとスクロール処理を扱うスクローラーレイヤーを重ね合わせた構造となっています.

構成図

キャンバスレイヤー

キャンバスレイヤーはビューポート領域と同じ大きさを持つcanvasと,canvasを子要素に持ち,ダミーのスクロールバーを表示するコンテナー要素で構成されます.これらはビューポートの大きさが変更されるたびにリサイズされます.

canvasの部分には以前の記事のリサイズできるcanvasを再利用しました.

ダミーのスクロールバーは,後述するスクロールレイヤーで表示するスクロールバーの表示幅に合わせてcanvasのサイズを調整するために表示しているものです.したがってこのスクロールバーを含むコンテナーでは,子要素のcanvasをスクロールさせないようにします.

スクローラーレイヤー

スクローラーレイヤーでは,キャンバスの本来の大きさと同じサイズのコンテンツ要素と,それをスクロールさせる親コンテナー要素で構成されます.

スクローラーレイヤーはキャンバスレイヤーよりも上のレイヤーで表示されます.そのため,ユーザーのマウスなどによるスクロール操作は,こちらのレイヤーの要素のイベントとして処理します.

なお,キャンバス本来の大きさであるコンテンツ要素の大きさは,プロパティとして明示的に設定する必要があります (scrollableCanvasStyleWidth, scrollableCanvasStyleHeight).

スクロールイベントの伝播

スクローラーによるスクロール処理とキャンバスレイヤーによるスクロール後の位置に合わせた再描画は,スクロールイベントの伝播によって行います.

まずスクローラーレイヤーでスクロールイベントを受け取ると,コンテンツ要素がスクロールされます.しかしこのコンテンツ要素は何も子要素を持たないため,ただ単に空の領域をスクロールしたような動作になります.

次にスクローラーレイヤーのコンテナー要素は,onScrollイベントハンドラーを呼び出します.このイベントハンドラーでキャンバスレイヤーのcanvas要素に対して再描画処理を実行します.スクロール後にcanvas要素が描画すべき領域は,スクローラーレイヤーのコンテナー要素に対するビューポート領域の位置オフセットを取得することで計算できます.

まとめ

今回はスクロールイベントを検知するレイヤーと描画処理を行うレイヤーの2層構造にすることで,自動リサイズを行いつつ,必要な描画範囲のみ更新を行うキャンバスコンポーネントを作成しました.

キャンバスレイヤーで本来不要であるダミーのスクロールバーを表示していることは,無駄にレンダリングが発生しているため,キャンバスレイヤーのサイズの設定方法は改善の余地がありそうです.また,今回はスクロール後にcanvas要素の表示領域をすべて更新するようになっていますが,水平移動した際の差分のみ描画するようにすることで,より効率的な再描画が行えると思います.

Discussion