2Dミニマップのための座標変換

導入

Clip Studio Paint ナビゲーターパレットに搭載されているイメージプレビューのようなUIは、お絵描きソフトに限らず、ゲームでもしばしば見かけます

イメージしやすいのでお絵描きソフトで考えると、ユーザーの操作として、移動だけでなく拡大縮小も行われている可能性があることから、「いま画面に表示している領域は、全体領域のうちのどの部分領域か?」を計算するためには、タテヨコの移動量に加えて、拡大率も考慮する必要があります。

そんな時、便利なツールとなるのが、アフィン変換です。

アフィン変換を利用するメリット

アフィン変換を利用すると、下記のメリットが得られ、それによってある程度アフィン変換に丸投げできるようになるため、そのぶん自分で考える必要のある計算が減ったりシンプル化できるようになると考えられます。

1. 変換の合成ができる

2. 変換の逆変換ができる

1. ですが、ある座標に対して、適用したいアフィン変換が複数存在する場合に、それらを予め合成しておくことで、合成されたアフィン変換が得られるという意味になります。複数の変換を、合成されたアフィン変換を一度適用するだけで対応できるようになるため、順番に連続でそれぞれの変換を適用する場合に比べて、処理負荷を抑えられるようになります。

2. ですが、アフィン変換を適用した (という体の) 座標を、アフィン変換を適用する前の座標に戻すことが出来るという意味です。これによって、マウスカーソルの指し示す座標がワールド座標のどこか計算できるようになります。(逆も然り) ワールド座標の一部の領域にマウスホバー出来るようにする、などの活用ができます。

計算実例

実際に、アフィン変換を利用して、あるワールド座標をスクリーン座標に換算する計算の一例として、下記のような形が考えられます。

引数

- スクリーン枠のサイズ (vw, vh)
- カメラのワールド座標 (cp1, cp2)
 - cp1: カメラが表示しているワールド座標領域を示す矩形が持つ角のうち一つの角の座標
 - cp2: cp1 の対角の座標
- ワールド座標 np

計算 (noteで数式が書きづらいため、下記はPIXI.Matrixを用いた例になります)

const cw = cp2.x - cp1.x; // NOTE: カメラが表示しているワールド座標領域を示す矩形の幅
const ch = cp2.y - cp1.y; // NOTE: カメラが表示しているワールド座標領域を示す矩形の高さ
return new PIXI.Matrix(
 vw / cw, // xの拡大量
 0,
 0,
 vh / ch, // yの拡大量
 -cp1.x * (vw / cw), // xの移動量
 -cp1.y * (vh / ch) // yの移動量
).apply(np);

解説

拡大量については、例えばカメラの幅よりもスクリーンの幅が大きい場合、想定挙動は「ワールド座標におけるカメラに映る景色を、スクリーンの幅が大きいぶんだけカメラの幅を横に引き伸ばした結果の景色」となるため、スクリーン幅 / カメラ幅 という計算になっています。(例えば カメラ幅 === 100スクリーン幅 === 200 とした場合、200 / 100 = 2 なので、横に2倍引き伸ばされます)

移動量については、まず単純化のためズームのことは一旦忘れてタテヨコの移動だけ考えたとき、カメラが右に動くと、カメラに映る景色は左にズレると考えられます (自分の首を右に回して試してみてください。カメラ (スマホ) の画面が左の方に移動しているはず)。これを表現したのが -cp1.x および -cp2.x です。カメラと逆方向に動かすという意味で、マイナスがついています。

拡大率を考慮しなければ、移動量の話はこれでお終いなのですが、拡大率を考慮する場合、上記の景色のズレ量 (横方向であれば、cp1.x の部分) は、ワールド座標における量の表現となっている (引数の cp1 と cp2 がワールド座標における数値であるため) ため、スクリーン座標における移動量に変換するプロセスが必要です。それは、先ほど算出した拡大量を使うことで実現できます。

先ほど、拡大量を計算しました。そしてその実態は、スクリーンのサイズがカメラのサイズに対して何倍であるか? でした。つまり、その何倍をワールド座標のベクトルに掛けると、スクリーン座標におけるベクトルに置き換えることが出来るはずです。ということで、-cp1.x に (vw / cw) を掛けています。

変換の合成

例えば、Web は左上が原点ですが、座標の見え方を左下原点に変更したい、というケースを考えます。

先ほどの項でワールド座標をスクリーン座標に換算する行列が得られました。これを変数に入れて

const matTransformWorldToScreen = new PIXI.Matrix().set(...);

とします。この変換の適用は

matTransformWorldToScreen.apply(p)

で実現でき、逆変換は

matTransformWorldToScreen.applyInverse(p)

とすることで実現できます。

さて、原点の変換もアフィン変換で表現することができます。左上原点と左下原点の違いは、Y軸の正負の方向が逆ということなので、Y軸のスケールを -1 するアフィン変換を適用すれば実現できます。PIXI.Matrix で表現すると下記のようになります。X のスケールは変更せずとしたいため、1 を入れています。(注: すみません、後で気づきましたが、この方法でいけるときといけないときがあります。この方法を使う場合、その前提で ワールド座標 -> スクリーン座標 のアフィン変換を組む必要がありますね)

const matTransformChangeOriginToLeftBottom = new PIXI.Matrix(1, 0, 0, -1, 0, 0);

これで左上原点を左下原点に変換する行列が得られました。この行列を、実際にはワールド座標をスクリーン座標に変換する行列とセットで使っていきたいですが、シンプルには、単にそれぞれの行列を順番に変換対象のワールド座標へ適用することで実現が可能です。

const matTransformArr = [
  matTransformWorldToScreen,
  matTransformChangeOriginToLeftBottom
]

// 変換の場合
matTransformArr.reduce((acc, mat) => mat.apply(acc), p);

// 逆変換の場合
matTransformArr.reverse().reduce((acc, mat) => mat.applyInverse(acc), p);

が、行列が2個になれば計算も2回に、とどんどん重くなってしまいます。この問題を回避するために、これらの行列を1つの行列にまとめてしまい、まとめた行列で1回の計算を行うのみで済ませることができます。

const matTransform = matTransformWorldToScreen.append(matTransformChangeOriginToLeftBottom);

//変換の場合
matTransform.apply(p);

// 逆変換の場合
matTransform.applyInverse(p);

これなら、どれだけ多くの変換が必要でも、引数更新時に合成行列を更新すれば、計算量は行列一個分で済みます。記述もシンプルになりました。

おわりに

仕組みを知らなくてもうまく使えてしまうアフィン変換ですが、なんとなく気持ち悪い&色々と応用が効きそうなため、行列をはじめ線形代数についてはもう少し学習してみたいなと感じました。

また、こういった計算処理を実装する際の開発効率を損なわないために、その計算の意味や結果を図としてイメージしながら理解するように努めることが非常に大切と感じました。頭の中で暗算できるキャパシティを秒で超えました。想像力の補助として console.log で要所要所を調べることも勿論有効です。また連続したアフィン変換を組む際は、それぞれ1ステップずつ実行されるのだ、と意識すると個人的には動作イメージが掴みやすいと感じました。

こういったロジカルなものもサクサク作れるよう、数学的な知識やアルゴリズム、データ構造などの知識をより深めていきたいなと思います。






この記事が気に入ったらサポートをしてみませんか?