TouchDesignerでのVR開発で頭部に追従するカーソルを実装する

# はじめに

「リアルタイム画像処理によるエフェクトをかけた全天球映像をHMDで見るシステム」を爆速で組みたい等の理由で，UnityではなくTouchDesignerでVRコンテンツ開発を行いたくなる場合が存在します．晴れて映像を見ることができたら，次は頭の先にカーソルを出してみたいですよね[^1]．

[^1]: VR開発始めたてにありそうなシチュエーションだ……

Unityであれば，Cameraの子オブジェクトにカーソルとなるオブジェクトを入れるなり，CameraからRayを飛ばすなりすれば実装できそうですが，TouchDesignerでこれを実装しようとしたときに手数がかかったので，本記事ではこの実装方法を紹介します．

# 環境

* Windows 10
* TouchDesigner 2019.18360 Educational版（Non-Commercial版でも球に貼り付けるequirectangular形式画像のサイズに気を付ければ問題ありません．）
* HTC Vive Pro

# 準備

まずは全天球映像をHMDで見るシステムを組みましょう．なおこの節に似た内容は，TouchDesigner使いなら一度は通る道：[Visual Thinking with TouchDesigner - プロが選ぶリアルタイムレンダリング&プロトタイピングの極意](http://www.bnn.co.jp/books/8842/)の3章11節「VR端末をTouchDesignerで使う」にも記載されています．

まずPaletteを開いて，`Derivative/Vive`から`viveSimple`を`project1`オペレータ内に配置します．これだけでトーラスが浮いた空間の映像をHMDで見れるはずですが，今回はトーラスの表示やコントローラ周りの実装は不要なので，これを消してしまいます．`viveSimple`オペレータの中に入り，`openVRRender`オペレータ・`parexec1`オペレータ・`text1`オペレータ・`window1`オペレータ以外を全て消してください．

<img width="1687" alt="viveSimple.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/ef86663e-efe9-1533-c7b7-32f638e3d88e.png">

次に，全天球映像を球に貼り付けて表示します．ここでは全天球映像はequirectangular（正距円筒図法）形式で渡されているものとします．`Geometry COMP`を`openVRRender`オペレータと同じ階層に置き，その中で以下の図のようにオペレータを組んでください．

<img width="1687" alt="equirectangular.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/abd1bd08-44b2-a48a-3834-b60b6cdb7fd4.png">

equirectangular形式画像にはTouchDesignerインストール時にデフォルトで入る画像を用いました[^2]．これを`Constant MAT`のColor Mapにして，`Sphere SOP`から繋いだ`Material SOP`のMaterialに指定します．この際，Sphereの半径は10にしました．オペレータ右下の紫色のフラグをONにしないと，HMDに映像が映らないので注意しましょう．`Constant MAT`のColor Mapによしなにエフェクトをかければ，いい感じの映像が出来上がります．

[^2]: バナナの画像と同じディレクトリにあるはず．

# カーソルの実装

## 方針

映像を無事に見ることができたので，いよいよカーソル表示を実装します．

カーソルとして，今回は小さな赤い球を使うことにします．これを頭の先から一定距離 $r$ だけ離れた位置に配置することで，カーソルとしての役割を果たせそうです．そこで頭部の位置 $\mathbf{a} \in \mathbb{R}^3$ と頭部の向きを表すベクトル $\mathbf{h} \in \mathbb{R}^3$ から，カーソルの位置 $\mathbf{c} \in \mathbb{R}^3$ を

```math
\mathbf{c} = \mathbf{a} + r\mathbf{h}
```

と求めることにします．

この $\mathbf{a}$ や $\mathbf{h}$ を求めるために，`OpenVR CHOP`を利用します．`openVRRender`オペレータ内の`sensor`オペレータを見てみると，`head:tx`, `head:ty`, `head:tz`, `head:rx`, `head:ry`, `head:rz`というチャンネルがあることが分かります．これはどうやらトラッキングされたHMDの位置・向きに対応しているようです．特に`head:rx`はHMDの上下方向の回転（ピッチ），`head:ry`はHMDの左右方向の回転（ヨー）に対応していそうです．したがってこの6つの値から $\mathbf{c}$ を計算し，カーソルを配置すれば良さそうです．

## 罠

……そう思っていた時代もありました．**この方針だと，泥沼にはまる可能性があります．**

この罠はHMDをグルグル回すと観察できます．グルグル回すと，`head:rx`, `head:ry`, `head:rz`の値が左右を向いたときに不連続に飛ぶことが分かると思います．これは，これらの値が定められた範囲に収まるようにオフセットが加減されるからです．例えば`head:ry`はHMDの左右の回転に対し，常に区間 [-270, 90] 内に収まるように調整されます．

これ自体は理解できる仕様なのですが，問題はこの値の飛び方です．HMDを正面からゆっくり左に回していくと，`head:ry`が90に届く前に（80くらい），突然負の値，それも-270ではない値（-260くらい）に飛んでしまうのです．したがって`head:ry`をそのまま左右の回転の大きさとして採用すると，左を向くたびにカーソルが謎の方向に飛んでしまうバグが生じます．これは，ロール軸の回転を表す`head:rz`の値の変動が原因だと考えられます．

ではどうすれば，この影響を取り除いた正しい $\mathbf{c}$ を求められるでしょうか．そのヒントは，HMDの映像は見回しても飛びが無い状態に既になっていることにあります．つまり`openVRRender`オペレータは，先述の6つのパラメータを使わずにHMDの位置や向きを計算し，頭の先の映像を正しく描画しているのです．したがってこの方法を真似すれば，正しく $\mathbf{c}$ を求めることができそうです．

この頭の先の映像を描画する計算は，`eyeLXform`オペレータ・`eyeRXform`オペレータが持つ16個のチャンネル情報が，`eyeL`オペレータ・`eyeR`オペレータのPre-Xformページ内のXform Matrix/CHOP/DATに渡されることでなされます．この16個のチャンネルは全体で1つの4×4行列を表現しています．この処理を理解して真似するためには，**同次座標系**について知る必要があります．

## 同次座標系

同次座標系とは，簡単に言えば3次元座標系での点 $(x,y,z)^{\top}$ を， $(x,y,z,1)^{\top}$ と同一視した座標系のことです．何故わざわざ第4成分に1を付け加えるかというと，上手く4次元行列を考えれば**回転移動と平行移動を1つの行列にまとめて表現することが可能**だからです．

今，点 $\mathbf{p} = (x,y,z)^{\top}$ を回転行列 $\mathbf{R} \in \mathbb{R}^{3 \times 3}$ で回転移動させた後， $\mathbf{d} \in \mathbb{R}^3$ を加えて平行移動させ，点 $\mathbf{p'} = (x',y',z')^{\top}$ にする操作を考えます．つまり

```math
\mathbf{R} = 
\begin{pmatrix}
m_{00} & m_{01} & m_{02}\\
m_{10} & m_{11} & m_{12}\\
m_{20} & m_{21} & m_{22}
\end{pmatrix}
, 
\mathbf{d} = 
\begin{pmatrix}
m_{03}\\
m_{13}\\
m_{23}
\end{pmatrix}
```

として，

```math
\mathbf{p'} = \mathbf{R} \mathbf{p} + \mathbf{d} 
```

です．このとき次が成立します：

```math
\begin{pmatrix}
\mathbf{p'} \\
1
\end{pmatrix}
=
\begin{pmatrix}
\mathbf{R} & \mathbf{d} \\
\mathbf{0} & 1
\end{pmatrix}
\begin{pmatrix}
\mathbf{p} \\
1
\end{pmatrix}.
```

実際この式の1行目（3成分まとめていることに注意）は，先ほどの回転移動の後に平行移動する操作を表現しています．また2行目は恒等的に成立します．これを各成分書き下すと，

```math
\begin{pmatrix}
x' \\
y' \\
z' \\
1
\end{pmatrix}
=
\begin{pmatrix}
m_{00} & m_{01} & m_{02} & m_{03} \\
m_{10} & m_{11} & m_{12} & m_{13} \\
m_{20} & m_{21} & m_{22} & m_{23} \\
0 & 0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
z \\
1
\end{pmatrix}
```

となります．つまりあるベクトルに第4成分1を加え，このような4×4行列を左からかければ，その結果の第1-3成分が移動先を表すことになります．

## 計算

この同次座標系が理解できると，`eyeLXform`オペレータ・`eyeRXform`オペレータが持つ16個のチャンネル情報の意味も分かります．このチャンネルの名前のインデックス`m_00, m_01, ..., m_33`は，4×4の変換行列の各成分 $m_{00}, m_{01}, \dots, m_{33}$ を表しています[^3]．

[^3]: 確かにHMDを動かしても，常に $(m_{30}, m_{31}, m_{32}, m_{33}) = (0,0,0,1)$ が保たれていると思います．

ここでは簡単のためHMDの位置は原点に固定し，そこから頭部の向き $\mathbf{h}$ 方向に10だけ離れた位置（つまり，映像が貼られた球面上の点） $\mathbf{c}$ にカーソルを出すことにします．すなわち

```math
\mathbf{c} = 10 \mathbf{h}
```

です．

まずHMDの位置を原点に固定します．これは`eyeLXform`オペレータ・`eyeRXform`オペレータで， $m_{03}, m_{13}, m_{23}$の値を0とすることに相当します．`Replace CHOP`を用いて3つの値を0にして，その結果を`eyeL`オペレータ・`eyeR`オペレータのPre-Xformページ内のXform Matrix/CHOP/DATに渡してやりましょう．

![fixed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/d5f1044f-1c11-8950-bd83-78edf0b8457a.png)

次に頭部の向き $\mathbf{h} = (h_x, h_y, h_z)^{\top}$ を求めます．`eyeL`オペレータ・`eyeR`オペレータ内のカメラは，回転行列をかけていない状態では $(0,0,-1)$ 方向を向いています．したがって先に紹介した同次座標を用いれば，回転後の頭部の向きは

```math
\begin{pmatrix}
h_x \\
h_y \\
h_z \\
1
\end{pmatrix}
=
\begin{pmatrix}
m_{00} & m_{01} & m_{02} & 0 \\
m_{10} & m_{11} & m_{12} & 0 \\
m_{20} & m_{21} & m_{22} & 0 \\
0 & 0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
0 \\
0 \\
-1 \\
1
\end{pmatrix}
=
\begin{pmatrix}
-m_{02} \\
-m_{12} \\
-m_{22} \\
1
\end{pmatrix}
```

と求められます． HMDを原点に固定するため， $m_{03} = 0, m_{13} = 0, m_{23} = 0$としていたことに注意してください．ここでは`Select SHOP`で3成分を抜き出した後，`Math CHOP`で $\mathbf{h}$ を求めました[^4]．

[^4]: この計算結果は，RとLで一致しているはずです．これはカメラの位置を原点に固定したことによるものです．

![direction.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/58d336a5-646e-a87f-fb88-8c9b7d9a7d1d.png)

最後に $\mathbf{c} = 10 \mathbf{h}$ にカーソルを配置します．全天球映像が貼られた`Geometry COMP`と同じ階層に半径を0.2くらいにした`Sphere SOP`を置き，centerの各成分を，$\mathbf{h}$の各成分を10倍した値にしてください．

![cursor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/a9b4a771-36d5-3372-da42-5c16272bdf5b.png)

この状態でHMDの映像を見ると次のようになります．

![VR_cursor.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212951/c25b2d34-646b-b1af-3530-33ed17dc4024.gif)

# まとめ

TouchDesignerでのVR開発で頭部に追従するカーソルを実装するためには，**`viveSimple/eyeRXMatrix`オペレータ・`viveSimple/eyeLXMatrix`オペレータの情報を利用すれば良い**ことがわかりました．

この方法はカーソルの表示に限らず，例えばHMDの向きに応じて何らかのイベントをトリガーしたい場合にも用いることができそうです．是非この実装方法を元にカスタマイズしてみてください．