目次
今回の内容
Unityクライアントエンジニアの大川です。
前回の記事に引き続き今回の記事では3D空間に配置しているスクリーン内の世界に対してのレイキャスト判定の仕方を書いていきます。
サンプル動画
サンプルプロジェクト
今回作成したプロジェクトはこちらです。
https://github.com/k-okawa/QuestSandBox/tree/master/Assets/Sample/HitTest
実装手順
前回の記事との変更点
前回の記事ではUICanvasをWorld空間に配置してRawImageで別世界のスクリーンを映し出すようにしていましたが、座標計算が複雑になるため3DObjectのQuadで映すように変更しました。
手順も一応載せておきます。
構成

Screen空オブジェクトの中にQuadのLeftScreenとRightScreenが入っています。


スクリーンの解像度が1920*1080のためXのスケールを1.777778にしています。
こちらのスケールは固定して親オブジェクトのScreenで位置とスケールを調整します。
マテリアル
左目・右目用どちらも同じ設定のマテリアルを用意していて、
テクスチャのみ設定を変えています。

スクリーン座標の求め方
最初にワールド空間上の点の座標を求める必要があります。
計算を簡単にするため、スクリーンはXY平面上にあって、Z軸は回転しないことを前提にしています。
必要な点はスクリーンの角P0~P3,レイが当たっている点Pになります。

P0~P3の求め方
QuadMeshのローカル頂点座標をまず知る必要があります。
MeshFilterComponentのmesh.verticesから取得できます。
| 
					 1  | 
						_meshFilter.mesh.vertices  | 
					
配列格納順はP0~P3の順番と一致しています。
次にローカル座標からワールド座標に変換する必要があります。
実際行列の計算が必要ですがUnityにメソッドが用意されているのでこちらを使います。
| 
					 1  | 
						transform.TransformPoint(localPoint)  | 
					
こちらのふたつを合わせてLinqでまとめて配列を作成します。
| 
					 1  | 
						_meshVertices = _meshFilter.mesh.vertices.Select(pos => _meshFilter.transform.TransformPoint(pos)).ToList();  | 
					
_meshVertices[0] = P0
_meshVertices[1] = P1
_meshVertices[2] = P2
_meshVertices[3] = P3
P0~P3のワールド座標が求まりました!
Pの求め方
P0~P3の値は固定で変わりませんが、Pはレイが飛んでいる方向によって変わります。
平面と線分の交点を求めることで導き出せますが、Unityではすでにこちらの計算が用意されているので利用して求めます。
計算の詳細はこちらのサイトが分かりやすいです。
http://marupeke296.com/COL_main.html
LeftScreenまたはRightScreenにアタッチされているMeshColliderの関数を利用することで求められます。
| 
					 1 2 3 4 5 6 7 8 9 10  | 
						Ray screenRay = new Ray() {     origin = _handTrans.position,     direction = _handTrans.forward }; if (_screenMeshCollider.Raycast(screenRay, out var screenHitInfo, _rayCastDistance)) {     // スクリーンとレイが交わっている場合     var p = screenHitInfo.point; }  | 
					
Pが求まりました!
ワールド座標Pをスクリーン座標に変換する
Pはワールド座標なのでスクリーン上の座標に変換する必要があります。
スクリーンの角のスクリーン座標をSP0~SP3,Pのスクリーン座標をSP(x,y)とすると以下のようになります。

ここまでわかれば後はワールド座標の比率からスクリーン座標を求めることができます。
(XY平面上・Z軸回転なしの前提がなければz座標も考慮してしっかりベクトルの計算で求める必要があります)
プログラムの計算だと以下のようになります。
| 
					 1 2 3 4 5 6 7  | 
						float xDistance = p.x - p0.x; float yDistance = p.y - p0.y; float xRatio = xDistance / (p1.x - p0.x); float yRatio = yDistance / (p2.y - p0.y); Vector3 sp = Vector3.zero; sp.x = _screenWidth * xRatio; sp.y = _screenHeight * yRatio;  | 
					
スクリーン空間のオブジェクトとレイキャスト判定
スクリーン座標からレイに変換
スクリーン座標が分かれば、カメラからのレイを求めることができます。
| 
					 1  | 
						var cameraRay = _hitTestCamera.ScreenPointToRay(sp);  | 
					
余談ですがこちらの計算がどうなっているのか気になり調べてみたところ、
Cocos2dxのソースコードに近い計算が書いてあったのでこちらを読み解けば分かりそうです。
https://github.com/cocos2d/cocos2d-x/blob/v4/cocos/2d/CCCamera.cpp#L314
Cocos2dxでのScreenPointをRayに変換する方法が書かれたフォーラム
https://discuss.cocos2d-x.org/t/raycast-from-touch-location-to-3d-space/34775
スクリーンを映しているカメラからレイを飛ばして交点を求める
最後に別空間のカメラからレイを飛ばして最初に交わるGameObjectとその点が求まれば今回やりたいことは達成です。
| 
					 1 2 3 4 5 6  | 
						var cameraRay = _hitTestCamera.ScreenPointToRay(sp); if (Physics.Raycast(cameraRay, out var cameraHitInfo)) {     GameObject hitGo = cameraHitInfo.collider.gameObject;     Vector3 hitPoint = cameraHitInfo.point; }  | 
					
サンプルコード
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89  | 
						using System.Collections.Generic; using System.Linq; using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class HitTest : MonoBehaviour {     // QuadのMesh     [SerializeField] private MeshFilter _meshFilter;     // レイを飛ばす手のTransform     [SerializeField] private Transform _handTrans;     // スクリーンを映しているカメラ     [SerializeField] private Camera _hitTestCamera;     // QuadにアタッチされているMeshCollider     [SerializeField] private MeshCollider _screenMeshCollider;     // レイが当たっている位置に表示するオブジェクト     [SerializeField] private GameObject _hitPointPrefab;     // スクリーンとのレイキャストの最大距離     [SerializeField] private float _rayCastDistance = 100f;     // スクリーンの解像度横     [SerializeField] private float _screenWidth = 1920f;     // スクリーンの解像度縦     [SerializeField] private float _screenHeight = 1080f;     // レイを表示する用のLineRenderer     private LineRenderer _lineRenderer;     // Meshの4頂点をワールド座標に変換して格納するためのリスト     private List<Vector3> _meshVertices;     // Quadメッシュの幅と高さ     private float _meshWidth, _meshHeight;     void Start()     {         _lineRenderer = GetComponent<LineRenderer>();         _lineRenderer.positionCount = 2;         // 頂点を取得しワールド座標に変換         _meshVertices = _meshFilter.mesh.vertices.Select(pos => _meshFilter.transform.TransformPoint(pos)).ToList();         _meshWidth = _meshVertices[1].x - _meshVertices[0].x;         _meshHeight = _meshVertices[2].y - _meshVertices[0].y;     }     void Update()     {         SetRay();         if (OVRInput.GetDown(OVRInput.RawButton.RIndexTrigger))         {             // 右手コントローラーの人差し指のボタンが押されたときに判定             RayCastTest();         }     }     void SetRay()     {         var start = _handTrans.position;         var end = start + _handTrans.forward * _rayCastDistance;         _lineRenderer.SetPosition(0, start);         _lineRenderer.SetPosition(1, end);     }     void RayCastTest()     {         Ray screenRay = new Ray()         {             origin = _handTrans.position,             direction = _handTrans.forward         };         if (_screenMeshCollider.Raycast(screenRay, out var screenHitInfo, _rayCastDistance))         {             var p = screenHitInfo.point;             float xDistance = p.x - _meshVertices[0].x;             float yDistance = p.y - _meshVertices[0].y;             float xRatio = xDistance / _meshWidth;             float yRatio = yDistance / _meshHeight;             Vector3 sp = Vector3.zero;             sp.x = _screenWidth * xRatio;             sp.y = _screenHeight * yRatio;             var cameraRay = _hitTestCamera.ScreenPointToRay(sp);             if (Physics.Raycast(cameraRay, out var cameraHitInfo))             {                 var hitGo = Instantiate(_hitPointPrefab);                 // Zファイティングが発生するためヒットした位置から少し浮かして配置                 hitGo.transform.position = cameraHitInfo.point + cameraHitInfo.normal * 0.01f;                 hitGo.transform.LookAt(cameraHitInfo.point + cameraHitInfo.normal);             }         }     } }  | 
					
最後に
今回は簡易的な計算で実装してみました。
また、サンプルでは右スクリーン・右カメラでレイキャスト判定を行っていますが、効き目が左の場合は左スクリーン・左カメラで判定した方が良いです。
スクリーン内のオブジェクトにレイが当たったことが重要で当たった位置が重要な情報でない場合は左右で2回判定しても良いかもしれません。
3Dスクリーンを使えば効き目を調べることにも使えそうですね。
次回はVRの話ではなくなりますが、別空間のカメラを移動させる方法について書いていきたいと考えています。