e.blog

主にUnity/UE周りのことについてまとめていきます

UnityのCamera#ScreenToWorldPointを自前で計算してみる

概要

Screen Spaceの座標をシーンのワールド座標に変換して、その位置になにかする、というのはよくある処理だと思います。
(例えば画面をタップしたらその先にレイを飛ばしてなにかする、とか)

そのあたりは当然、Unityは準備してくれているのだけど、中でなにをしているか知らずに使うのは(毎度のことならが)気持ち悪いので色々やってみたメモです。

Camera#ScreenToWorldPointで簡単に変換

まず、Unityの機能を使う場合であればCamera#ScreenToWorldPointを利用することで簡単に座標を求めることができます。

こんな感じ↓

Camera cam = Camera.main;

Vector2 mousePos = new Vector2();
mousePos.x = Input.mousePosition.x;
mousePos.y = Input.mousePosition.y;

Vector3 point = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, cam.nearClipPlane));

こうすると、Z値にnearClipPlaneを渡しているので、つまりはクリックした位置の表示されるぎりぎりのところの座標を得ることができます。

実際に実行するとこんな感じで、クリックした位置+カメラのnearClipPlane位置にSphereが生成されているのが分かるかと思います↓
f:id:edo_m18:20190106110308g:plain

今回はこれと同じ値を自前で算出するのを目的としています。

座標変換の過程を知る

さて、今回の話は主に座標変換の話となります。
3D空間に配置されたオブジェクトを、いくつもの座標変換行列によって変換し、最終的にスクリーン座標系に移動させるのが一連の座標変換です。

まずはこれを理解しないことには始まりません。

どういう座標変換が必要かは以下のようになります。

  1. モデル座標変換
  2. ビュー座標変換
  3. プロジェクション座標変換
  4. 正規化デバイス系座標変換
  5. スクリーン座標変換

ひとつのオブジェクトを表示するために、実に5回もの座標変換を行っているわけなんですね。

そして大半の変換には「行列」を用います。

座標変換のための行列の掛け算

座標変換には行列を使うと書きました。
各座標変換にはそれぞれ行列があり、それをベクトルに掛け算していくことで指定した座標へ変換していくことになります。

具体的には以下のような感じです。

$$ \vec{V_s} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p $$

  • \(\vec{V_s}\) ... スクリーン座標での位置(ベクトル)
  • \(\vec{v}\) ... ローカルの位置ベクトル
  • \(M\) ... モデル座標変換行列
  • \(V\) ... ビュー座標変換行列
  • \(P\) ... プロジェクション座標変換行列
  • \(V_p\) ... ビューポート座標変換行列

正規化デバイス座標系については、プロジェクション座標変換後(同次座標系)のベクトルのw要素で除算することで得られる変換のため、行列は存在しません。

こうしてはるばる変換の旅をしたローカルの位置ベクトルが最終的に画面の特定の位置に表示される、というわけです。

座標変換を「さかのぼる」には逆行列を使う

そして座標変換されたベクトルに対して、逆順にそれぞれの座標変換で用いた行列の「逆行列」を掛けることで変換をもとに戻すことができます。

行列の使い方、座標変換の細かい挙動などについてはマルペケさんの以下の記事がとても参考になります。
特に「③ 検証3:あるモデルの世界へ連れ込む」の節が座標変換について詳しく書かれています。

その60 変換行列A×BとB×Aの違いを知ろう

上で説明したスクリーン座標まで旅をしたベクトル\(\vec{v}\)を、再びワールド空間に戻すには以下のようにします。

$$ \vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} $$

※ ちなみにUnityの行列では「列オーダー」のメモリレイアウトを採用しているため、行列の掛ける順番が左右反転することに注意してください。

ちなみに行列のオーダーや掛ける順番などについては前回の記事でまとめたのでそちらをご覧ください。

edom18.hateblo.jp

とある行列に、その逆行列を掛けると単位行列となります。
つまり、上の計算はそれぞれの逆行列を順番に掛けているのですべてが単位行列\(E\)となり、結果的にもとのベクトルだけが残る、というわけです。

$$ \begin{eqnarray} \vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} &=& \vec{v} \cdot M \cdot V \cdot P \cdot E \cdot P^{-1} \cdot V^{-1} \\ &=& \vec{v} \cdot M \cdot V \cdot E \cdot V^{-1} \\ &=& \vec{v} \cdot M \cdot E \\ &=& \vec{v} \cdot M \end{eqnarray} $$

最後、\(M\)行列が残っていますが、(今回は)ワールド座標に変換するのが目的なのでワールド変換より前には戻らないためです。
(\(M\)を戻してしまうと、該当オブジェクトのローカル空間にまで戻ってしまうためです)

行列自体の話題ではないので、これ以上の細かい話は割愛します。

ビューポート行列はグラフィクスAPIで異なる

上記の\(V_p\)はビューポート行列を表しています。
そしてこのビューポート行列はグラフィクスAPIによって異なります。

詳細については以下の記事が参考になりました。

blog.natade.net

今回はMacで試していたのでOpenGLでの行列でテストしました。
具体的には以下の形の行列です。

$$ \begin{vmatrix} \frac{Screen Width}{2} &0 &0 &0 \\ 0 &\frac{Screen Height}{2} &0 &0 \\ 0 &0 &\frac{MaxZ - MinZ}{2} &0 \\ Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &\frac{MaxZ + MinZ}{2} &1 \end{vmatrix} $$

ちなみにDirextXでは以下のようになるようです。

$$ \begin{vmatrix} \frac{Screen Width}{2} &0 &0 &0 \\ 0 &-\frac{Screen Height}{2} &0 &0 \\ 0 &0 &MaxZ - MinZ &0 \\ Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &MinZ &1 \end{vmatrix} $$

このあたりは、正規化デバイス座標系でのZ値の取る値が違う点によるものだと思います。

C#での実装

さて、OpenGL版のものをC#で表すと以下のようになります。

Camera cam = Camera.main;

Matrix4x4 viewportInv = Matrix4x4.identity;
viewportInv.m00 = Screen.width / 2f;
viewportInv.m03 = Screen.width / 2f;
viewportInv.m11 = Screen.height / 2f;
viewportInv.m13 = Screen.height / 2f;
viewportInv.m22 = (cam.farClipPlane - cam.nearClipPlane) / 2f;
viewportInv.m23 = (cam.farClipPlane + cam.nearClipPlane) / 2f;

そして、生成した行列の逆行列を求めて最終的な結果を得ます。

実際に同じ値を算出したコードは以下のようになります。

// pointはスクリーンの位置
private Vector3 ApplyProjectionMatrix(Vector2 point)
{
    if (_cam == null)
    {
        _cam = Camera.main;
    }

    Matrix4x4 viewportInv = Matrix4x4.identity;
    viewportInv.m00 = viewportInv.m03 = Screen.width / 2f;
    viewportInv.m11 = Screen.height / 2f;
    viewportInv.m13 = Screen.height / 2f;
    viewportInv.m22 = (_cam.farClipPlane - _cam.nearClipPlane) / 2f;
    viewportInv.m23 = (_cam.farClipPlane + _cam.nearClipPlane) / 2f;
    viewportInv = viewportInv.inverse;

    Matrix4x4 viewMatInv = _cam.worldToCameraMatrix.inverse;
    Matrix4x4 projMatInv = _cam.projectionMatrix.inverse;
    Matrix4x4 matrix = viewMatInv * projMatInv * viewportInv;

    Vector3 pos = new Vector3(point.x, point.y, _cam.nearClipPlane);

    float x = pos.x * matrix.m00 + pos.y * matrix.m01 + pos.z * matrix.m02 + matrix.m03;
    float y = pos.x * matrix.m10 + pos.y * matrix.m11 + pos.z * matrix.m12 + matrix.m13;
    float z = pos.x * matrix.m20 + pos.y * matrix.m21 + pos.z * matrix.m22 + matrix.m23;
    float w = pos.x * matrix.m30 + pos.y * matrix.m31 + pos.z * matrix.m32 + matrix.m33;

    x /= w;
    y /= w;
    z /= w;

    return new Vector3(x, y, z);
}

ここで行っている計算は、ビューポート行列を生成したあと、ビューポート行列、ビュー行列、プロジェクション行列の逆行列を求め、それを合算し、最後にスクリーン座標位置のベクトルにその行列を適用しているところです。

そして後半のx, y, z, wは同次座標の計算を行っている部分です。
通常のプロジェクション座標変換ではこのwで除算することで遠くものは小さく、近くのものは大きく、というパースが効いた自然な形に変換するための処理です。

そして今回は逆行列を用いているため、その逆変換、つまり「小さいものも大きいものも通常のサイズに直す」という処理になります。

あとは算出された値をオブジェクトの位置ベクトルに設定してやれば、冒頭の動画のように、スクリーンをタップした位置にオブジェクトが移動します。