備忘録 blog

Docker/Machine Learning/Linux

Konva をハックしたズームレイヤー実装に向けて

Konva は JavaScript 製ライブラリであり、 HTML5 Canvas上に矩形や円、画像などさまざまなオブジェクトを配置し、さらにアニメーションやズームレイヤーなどが整備されているので、ドラッグ・ドロップでオブジェクトを移動させられるなど、インタラクティブな2Dキャンバスを簡単に利用できるようにするライブラリである。

konvajs.org

本稿では、Konva 上で画像の拡大・縮小を実装し、更にズームした場合に高精細な画像に徐々に切り替えていく、という「ズームレイヤー」を実装する際に、いかにスムーズに各レイヤーの画像を切り替えるか、という実装を検討したい。具体的に言うと、以下の gif 画像のようなものを実現したいというわけである。

f:id:sharply:20210806175557g:plain

Konva では、 Stage という描画領域にオブジェクトを配置する。Stage には x,y 座標があるのでスクロールを反映できるほか、scale というパラメータがある。scale というパラメータは 1 を等倍として、x軸、y軸それぞれの方向に拡大・縮小をすることができる。例えば、 scale が 2 のときは2倍に画像を拡大していることに相当している。scale が 2 のときに、2倍に拡大したより解像度の高い画像と差し替えることで、ラスタ画像を拡大した際に生じるジャギーなどを緩和して、Google Maps のようなセマンティックズームを実現することができるはずである。

ここで、そのアーキテクチャを実現するための方法をいくつか検討する。まず、以下の4通りの実装方針について考える。以下では、scale=1のときに描画されている画像を元のレイヤーと呼び、拡大していって scale=2 になったときに、2倍になったのでより高画質な画像で書き直すことにしたい。高画質な画像は新しいレイヤーと呼ぶこととする。

  1. scale が 2 になったときに、改めてそのときの値を scale=1 として再定義し、その後に新しい画像を直接再レンダリングする。
    1. 利点: 実装が最もシンプルで容易
    2. 欠点: scale を調節したタイミングで表示されている画像のscaleがリセットされてしまうので、「scaleを調節する ⇒ 現在のzoomLayerの画像でscaleがずれた画像がいったん表示される ⇒ 新しい画像がその上に表示される」となるので、scale がずれた画像が表示される時間が生じてしまう。
    3. 欠点2: scale がずれた画像が表示されないローディング中に画像がまったく表示されないという問題も残っている。そのため、読み込みが遅い場合は、新しい画像が表示されるまでの間、何も表示されない。
  2. scale が 2 になったときに、全ての画像を予め全部消してから、改めてそのときの値を scale=1 として再定義し、元のレイヤーの画像を位置を合わせて再描画する。それから、新しいレイヤーの画像を描画する。
    1. 利点: 新しいレイヤーの画像はスムーズに表示される。
    2. 欠点: 新しいレイヤーの画像を表示する前に、画面を再描画する必要がある。
  3. scale が 2 になったときに、その上に新しいレイヤーの画像を描画する。その後、scale=1 として再定義し、新しいレイヤーの画像を描画する。
    1. 利点: 新しいレイヤーの画像はスムーズに表示される。
    2. 欠点: 新しいレイヤーの画像が全て表示されたあとで、scale を適切に設定するために、画面を再描画する必要がある。そのため、新しい画像のレイヤーの画像が全て表示されたという状況をフェッチする必要がある。
  4. scale が変わった時に常にリセットせず、scale が 2倍、4倍、8倍、……のときには新しいレイヤーの画像をサイズを調整してそのまま上に貼り付ける。
    1. 利点: scale をリセットしないので、画面の再描画なしに拡大・縮小することが可能。
    2. 欠点: 適切に画像を設定したとしても、画像がscaleに応じて拡大されてしまうので、拡大していくと画像が粗くなり読みにくくなる。テキストオブジェクトも同様に拡大されてしまう。

1の方法ではscaleを変更した際に scale がずれた画像が一瞬表示されてしまうので UX があまりよくない。2~3 の方法ではいずれも、 scale を変える前後で画像を一旦全消去し、それから再描画するという必要がある。そのため、下図のようなちらつきがみられる。

f:id:sharply:20210806220345g:plain

1~3の方法では、scale の値は一定の値域におさまる。これに比べて4の方法は、一番最初に表示していた画像に対する scale という絶対的な値をもとに描画するので、拡大していくと画像がだんだん粗くなってしまう。これは画像を再配置しなおしたとしても、 scale の設定によって勝手に拡大して表示されてしまうので、また配置しているテキストもズームされてしまう。下図が、4 のやり方でうまくいかなかった例である。

f:id:sharply:20210806195817g:plain

結論から言うと、Konva stage の scaling 機能を使っている限り、(a) scaling factor が大きくなりすぎると画像やテキストにジャギーが生じる か (b) ズームレイヤーを切り替えるときに一瞬全ての画像が消える といういずれかの問題から逃れることはできない。(b) の問題を緩和するために、2~3回のズームレイヤーの切り替えの間では 4 の方法を使って画像をスムーズに切り替えるようにして、2~3回ごとに全ての要素を書き換えるようにするという実装案もあるが、根本的な問題解決にはつながらない。

とここまでぐだぐだ書いたところ、そもそも同一のレイヤー上にズームレイヤーの異なる画像を画像を重ねて表示するという実装をしようとしたのがそもそも無理なのであって、ズームレイヤーごとに Konva の layer を配置するようにすればよいという発想に至った。

Konva では、 layer とよばれる、オブジェクトをグループとして扱う機構がある。この layer を、stage 上に複数配置することができる。このとき、ズームレイヤーが切り替わったタイミングで新しい layer を描画することにすれば、画像が読み込まれるのに従って新しい画像が描画されるという仕組みを実装することができる。

注意すべき点として、このレイヤーは、過去のレイヤーを描画時にすぐ削除してしまうと、新しい画像が描画される前に消えてしまう。そのため、拡大前の画像のレイヤーを残しておくと、よりスムーズに拡大することができる。複数のズームレイヤーをまたいだ拡大・縮小が起こることを考えると、レイヤーはたくさん残しておいても良いが、あまりレイヤー数が多くなるとパフォーマンスが落ちるとのワーニングメッセージが表示されるので、ガベコレ的に何世代か前の画像レイヤーを消去する、というような実装にすると良さそうである。

とはいえ、まだこれでもたまに画面のちらつきが見られることがある。結局のところフレームワークを使うというやり方で横着せず、適切なものがあるか不明だが他のライブラリを使うか、あるいは HTML5 Canvas を直接叩く実装をするのが、現時点では妥当そうである。というところで、あまりうまくいかない方法の調査結果となってしまったが、同じ轍を踏まないようにするための備忘録ということで記録しておきたい。