e.blog

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

World SpaceのCanvasにWorld SpaceからRaycastする

概要

ARやVRなどの開発を行っているとGUIも3D空間に配置する必要があります。
しかしUnityのuGUIは2Dで扱うことを想定しており、通常のGraphicRaycastはスクリーンスペースの位置から判定を行うものになっています。

つまり、3D空間に置かれた(World Spaceな)Canvasに対して、3D空間上にあるポインタ(例えば指やコントローラなど)からuGUIに対してRaycastしようとすると簡単には行きません。

そこで、ARやVRでも利用できるRaycastの仕組みを作らないとなりません。
以前、VRTKというフレームワークを参考に自分でEventSystemを拡張したのですが、その際に利用したWorld SpaceなCanvasへの3DオブジェクトからのRaycastをするために必要な部分について、(改めて必要になったので)メモとして残しておきたいと思います。

実際に動かしたやつはこんな感じです↓

処理フロー

まずはざっくり概観してみましょう。

  1. Canvasに配置されているGraphicすべてを判定対象にする
  2. Rayの方向とGraphicforward方向が同じかチェックする
  3. Rayの位置とGraphicの位置の距離を計算する
  4. Ray.GetPointを利用してワールドの位置を求める
  5. 求めたワールド位置をカメラのスクリーンスペース位置に変換する
  6. 変換した位置を元に、RectTransformがそれを含んでいるかチェックする
  7. 含んでいたらRaycast成功

コード

さて、全体の流れを説明したところでコードを見てみます。
コード自体はそこまで長くありません。

private void Raycast(Canvas canvas, Camera eventCamera, Ray ray)
{
    if (!canvas.enabled)
    {
        return;
    }

    if (!canvas.gameObject.activeInHierarchy)
    {
        return;
    }

    IList<Graphic> graphics = GraphicRegistry.GetGraphicsForCanvas(canvas);
    for (int i = 0; i < graphics.Count; i++)
    {
        Graphic graphic = graphics[i];

        if (graphic.depth == -1 || !graphic.raycastTarget)
        {
            continue;
        }

        Transform graphicTransform = graphic.transform;
        Vector3 graphicForward = graphicTransform.forward;

        float dir = Vector3.Dot(graphicForward, ray.direction);

        // Return immediately if direction is negative.
        if (dir <= 0)
        {
            continue;
        }

        float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin) / dir;

        Vector3 position = ray.GetPoint(distance);
        Vector2 pointerPosition = eventCamera.WorldToScreenPoint(position);

        // To continue if the graphic doesn't include the point.
        if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera))
        {
            continue;
        }

        // To continue if graphic raycast has failed.
        if (!graphic.Raycast(pointerPosition, eventCamera))
        {
            continue;
        }

        Debug.Log($"Raycast hit at {graphic.name}", graphic.gameObject);
    }
}

すべてのGraphicを評価する

IList<Graphic> graphics = GraphicRegistry.GetGraphicsForCanvas(canvas);によって対象CanvasにあるGraphicオブジェクトのリストを得ることが出来ます。

ワールド空間の位置からスクリーンスペースの位置を求める

ワールド空間からスクリーンスペースの位置を求める方法です。
今回は特定オブジェクトがポインタの役割を果たして、そのオブジェクトからのRayを使いたいわけなので、少しだけ計算が必要となります。

コード部分は以下。

Transform graphicTransform = graphic.transform;
Vector3 graphicForward = graphicTransform.forward;
float dir = Vector3.Dot(graphicForward, ray.direction);

// Return immediately if direction is negative.
if (dir <= 0) { continue; }

float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin) / dir;

まず、対象Graphicforwardray.direction内積を取りその値で分岐します。
これはuGUIのオブジェクトのforward方向はこちらを向いている面の反対側になります。
そのため、Rayの方向と同じ場合を向いている場合(つまり内積が0より上の場合)だけ処理すればいいことになります。

なので内積結果が0以下だった場合は無視しているわけです。

そして同じ方向を向いていた場合はレイの位置とレイがぶつかった位置の距離を計算します。
コードにすると以下の部分。(分かりやすいように改行を入れています)

float distance = Vector3.Dot(graphicForward, graphicTransform.position - ray.origin);
distance /= dir;

Graphicforward方向」と「Graphicオブジェクトの位置からレイの位置を引いたベクトル」の内積を計算し、dirで割っています。

本来はVector3.Distance(from, to);で求めてもいいのですが、距離計算は負荷が高めなのと、上記計算で内積と、すでに計算済みのdirとの除算のみで求まるためそちらを利用しています。(dirを求める必要があるため一石二鳥、というわけです)

なぜこれで距離が求まるのかと言うと、差分ベクトルとの内積によってGraphicforward方向、すなわちGraphicが存在する平面との最短距離が求まります。

そしてそれを実際にGraphicが存在する位置の長さとするためには三角関数を利用します。
つまり、半径 / cos(θ)が実際に求めたい距離です。

そして実はdir内積の結果はまさにこのcos(θ)の値となっているため、それで割ることで距離が求まっていた、というわけです。

図にすると以下です。

f:id:edo_m18:20191220104604j:plain

RectTransformUtilityを利用して当たり判定

そして最後に、取得した全Graphicオブジェクトに対して当たり判定をしてやればOKなわけです。
当たり判定自体は関数が用意されていてRectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera)を使います。

引数には対象となるGraphicRectTransform、スクリーンスペースのposition、そして判定対象となるCameraです。

対象オブジェクトにイベントを送る

今回は主にuGUIのオブジェクトをワールド空間から把握するためのもののメモなので、ちゃんとしたイベント周りの構築については以下の過去の記事を参考にしてみてください。

edom18.hateblo.jp

ただ、ざっくりでいいからイベントを送りたい、というケースもあるかと思います。
その場合は以下のように、Rayがヒットしたオブジェクトに対してイベントを送ってやればOKです。

// graphic object is detected by ray casting.
ExecuteEvents.Execute(graphic.gameObject, new BaseEventData(eventSystem), ExecuteEvents.submitHandler);
````