koturnの日記

普通の人です.ブログ上のコードはコピペ自由です.

Unityのシェーダで原点・XZコンパスを実装した

TL;DR

  • 常に原点を指す,またX軸,Z軸を指す針も含むシェーダを作った.
  • VRChatのアバターに付けるとベンリかもしれない

背景

シェーダで取得できるモデル行列 unity_ObjectToWorld から平行移動成分,すなわちワールド座標を取得して,表示するシェーダは簡単にできた. そこで,ワールド座標に対するコンパス(羅針儀)が作れないかと考えて実装することにした.

現実世界でのコンパスといえば北を指すものである. だが,VRにおいては北は存在しないので,原点を指すものを作ることにした. また,ベンリだと思ったので,X軸も指すようにした.

原理

ワールド座標の取得

シェーダで利用できるモデル行列は unity_ObjectToWorld である. この行列はローカル座標をワールド座標に変換する行列で,拡大・回転・平行移動を行う同次変換行列である.

端的には,ヒエラルキのトップにオブジェクトを持っていったときのTransformのPosition,Rotation,Scaleを反映する行列と言える(はず).

具体的には下記のようになっている.

\begin{equation} \boldsymbol{M} = \begin{pmatrix} a & b & c & T_{x} \\ d & e & f & T_{y} \\ g & h & i & T_{z} \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{equation}

$a$ ~ $i$ が拡大・回転の成分であり,$T_{x}$ ~ $T_{z}$ が平行移動の成分である. $a$ ~ $i$ はX軸まわり,Y軸まわり,Z軸まわりの回転が絡んだものになっているので,$\sin$,$\cos$ を使って表現するとごちゃごちゃした形になる.その中身の成分それぞれを求める必要はないので,拡大・回転成分が入っている,とだけ覚えておく.

$n$ 次元のものを扱う際,$n + 1$ 次元の行列,ベクトルを導入すると定数項を積に組み込んでまとめて扱えるのでベンリである(大学の画像処理やロボティクス,多変量解析や機械学習の授業でも習う算数のテクニックである).

unity_ObjectToWorld からワールド座標成分 $\boldsymbol{v}_{T}$ を抜き出すには,

\begin{equation} \boldsymbol{v}_{T} = \begin{pmatrix} a & b & c & T_{x} \\ d & e & f & T_{y} \\ g & h & i & T_{z} \\ 0 & 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} 0 \\ 0 \\ 0 \\ 1 \end{pmatrix} = \begin{pmatrix} T_{x} \\ T_{y} \\ T_{z} \\ 1 \end{pmatrix} \end{equation}

とすればよい.

X軸方向の取得

XZ平面において,X軸を始点としたときのXZ平面上のベクトル($ \boldsymbol{v} = (v_{x}, v_{z}) $)の角度 $\theta$ は

\begin{equation} \theta = \arctan \left( \frac{v_{z}}{v_{x}} \right) \end{equation}

である.

X軸方向を取得するには,X軸が対象のオブジェクトがワールド座標系において,どれだけ回転しているかを知ることができればよい.

まず,X軸のベクトル $\boldsymbol{v}_{X} = (1, 0, 0)^{T}$ (とりあえず単位ベクトルで良いだろう) に対し,モデル行列の回転・拡大の要素のみを作用させ,それがどれだけ元のX軸のベクトルから回転しているかを考える. モデル行列の平行移動成分をゼロにする手もあるが,実装としてはバーテックスシェーダで予め計算した値を用いる方がよいと思うので,モデル行列を作用させたベクトルから,ワールド座標(平行移動成分)を引く手段を取る.

すなわち,

\begin{equation} \boldsymbol{v}_{M} = \boldsymbol{M} \boldsymbol{v}_{X} - \boldsymbol{v}_{T} \end{equation}

である.

ここで,XZ平面(Y軸からの視点)で考える.

$\boldsymbol{v}_{M}$ と $\boldsymbol{v}_X$ (X軸)のなす角 $\theta_{M}$ はまさしく逆正接の値なので,

\begin{equation} \theta_{M} = \arctan \left( \frac{v_{M_{z}}}{v_{M_{x}}} \right) \end{equation}

となる.

UV座標

テクスチャとしてはX軸の正方向とX軸から反時計周りに $\frac{\pi}{2}$ に回転させた位置に矢印があるだけのものを用意した.

f:id:koturn:20210420191014p:plain
XZ軸画像

UV座標としては,X軸矢印は中心 $(0.5, 0.5)$ から $\theta_{M}$ だけ回転した位置にもってくるのではなく,$0$ の位置にそのままあればよいので,UV座標の回転角 $\phi_{M}$ は,

\begin{equation} \phi_{M} = \theta_{M} \end{equation}

となる.

サンプリングUV座標を正の方向(反時計周り)に回転させた場合,第三者の目線としては,マイナス方向(時計周り)にテクスチャ画像が回転しているように見える. これがどの向きでも常にX軸を指すという挙動である.

原点方向の取得

モデル行列が平行移動成分のみで構成されている($a, e, i = 1; ~ b, c, d, f, g, h = 0$)のであれば,オブジェクトのワールド座標ベクトルとX軸となす角は

\begin{equation} \theta_{T} = \arctan \left( \frac{v_{T_{z}}}{v_{T_{x}}} \right) \end{equation}

であるので,原点方向はその反対向きの

\begin{equation} \theta_{O}' = \theta_{T} + \pi \end{equation}

である.しかし,モデル行列に回転・拡大成分があるならば,その分を加味する必要がある.

\begin{equation} \theta_{O} = \theta_{T} - \theta_{M} + \pi \end{equation}

UV座標

テクスチャとしてはX軸の正方向に矢印があるだけのものを用意した.

f:id:koturn:20210420191139p:plain
原点矢印画像

UV座標としては,中心 $(0.5, 0.5)$ から $\theta_{O}$ だけ回転した位置に目的のテクセルがなければならないので,$\theta_{O}$ の正負を反転させたものが,UV座標の回転角 $\phi_{O}$ となる.

\begin{equation} \phi_{O}' = -\theta_{O} = -(\theta_{T} - \theta_{M} + \pi) = \theta_{M} - \theta_{T} - \pi \end{equation}

角度なので $2\pi$ を加えてもよく,

\begin{equation} \phi_{O} = \phi_{O}' + 2\pi = \theta_{M} - \theta_{T} + \pi \end{equation}

として,$\pi$ の項を正にしておくと個人的にスッキリする(気持ちの問題).

まとめ

モデル行列 unity_ObjectToWorld を利用すればワールド座標だけでなく,原点の方向,軸の方向を取得することができる.

やっていることは高校生レベルの算数であるが,回転角の正負に関してはエイヤッとシェーダを変更して,Unity上で確認という行き当たりばったりに実装していったため,ある程度言語化するためにこの記事を書いた.

まだ不完全であるため,随時加筆修正すると思う.

シェーダで原点・XZコンパスを実装したモチベーションは,単に実装できそうだからという理由でしかないが,もしかしたらVRChatの広大なワールド散策で役に立つこともあるかもしれない.