【Unity(C#)】選択項目に吸いつくポインターを実装する

## はじめに

こんなものを作りました。忘れないうちに実装方法をまとめます。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">選択項目に吸いつくポインターの検証🙆‍♂️ <a href="https://t.co/uGVOJgQzhf">pic.twitter.com/uGVOJgQzhf</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中49/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1475486398195331074?ref_src=twsrc%5Etfw">December 27, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## バージョン情報

| 諸々名前 | バージョン |
|:-:|:-:|
|  Unity | 2019.4.29f1  |
|  Oculus Integration | 35.0  |

## 曲線を描くポインターの実装
まずは曲線を描くポインターの実装です。
先ほどの例のように選択項目に対して曲線を描いて吸いつきます。
ベジェ曲線の計算式で実現します。

以下に図解します。

まずは任意のUIにヒットしたポインターの状態です。
黄色がUI、黒がコントローラー、青がRayです。ヒットした箇所をAとします。
![Group 16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/b523f908-2dc5-d1b6-31d6-65453a3fccc5.png)

次に、任意のUIからRayが外れた場面です。Rayの直線上のある地点をBとします。
![Group 17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/3a1422c8-3282-2f28-97db-3524f18801ef.png)

最後にAとBをベジェ曲線を用いてなだらかに補間します。緑が補間した線のイメージです。
![Group 18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/81b32299-d1b7-1260-e539-d6009c9243da.png)

補間をやめる閾値を定めることで、一定の範囲まで吸いつく表現が可能となります。
今回は`"任意のUIの中心"から、"現在のRayの直線上の任意の点"までの距離`を閾値とします。

ここまでの実装をコードに落とし込みます。
任意のUIの中心など、もろもろ必要な情報は別のクラスで取得する想定のコードになります。

```C#:適当なオブジェクトにアタッチ
using UnityEngine;

/// <summary>
/// 吸いつくポインター
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class Pointer : MonoBehaviour
{
    [SerializeField] private GameObject cursorVisual;
    [SerializeField] private float maxLength = 10.0f;
    [SerializeField,Range(10, 50)] private int middlePoints = 30;
    [SerializeField, Range(0, 1.0f)] private float distance = 0.15f;

    private LineRenderer lineRenderer;
    private Vector3 startPoint;
    private Vector3 forward;
    private Vector3 centerPoint;
    private Vector3 endPoint;
    private bool hitTarget;

    private void Start()
    {
        lineRenderer = GetComponent<LineRenderer>();
    }

    /// <summary>
    /// カーソルの位置を当たった位置に応じてセットする
    /// </summary>
    /// <param name="start">始点</param>
    /// <param name="center">ヒットしたUIの中心</param>
    /// <param name="end">終点</param>
    public void SetCursorStartCenterEnd(Vector3 start, Vector3 center, Vector3 end)
    {
        startPoint = start;
        centerPoint = center;
        endPoint = end;
        hitTarget = true;
    }
    
    /// <summary>
    /// Rayを基準としてカーソルの位置をセットする
    /// </summary>
    /// <param name="defaultTransform">Rayの基準となるTransform</param>
    public void SetCursorDefaultTransform(Transform defaultTransform)
    { 
        startPoint = defaultTransform.position;
        forward = defaultTransform.forward;
        hitTarget = false;
    }

    private void LateUpdate()
    {
        lineRenderer.SetPosition(0, startPoint);

        if (hitTarget)
        {
            //距離が離れたら吸いつきを解除
            if (Vector3.Distance(endPoint, centerPoint) > distance)
            {
                ResetPointerEndToStart(startPoint, endPoint);
                cursorVisual.transform.position = endPoint;
                return;
            }

            cursorVisual.transform.position = centerPoint;
            CalculateCurvePointer();
        }
        else
        {
            ResetPointerEndToStart(startPoint, startPoint + maxLength * forward);
        }
        
        //カーソルのアクティブ
        if (cursorVisual) cursorVisual.SetActive(hitTarget);
    }
    
    /// <summary>
    /// ベジェ曲線を計算(魔法)
    /// </summary>
    private Vector3 BezierCurve(Vector3 start, Vector3 end, Vector3 control, float t)
    {
        var Q0 = Vector3.Lerp(start, control, t);
        var Q1 = Vector3.Lerp(control, end, t);
        var Q2 = Vector3.Lerp(Q0, Q1, t);
        return Q2; 
    }

    /// <summary>
    /// 吸いつきのカーブを計算
    /// </summary>
    private void CalculateCurvePointer()
    {
        //曲がる位置の補間点は始点と終点の中間
        var control = (startPoint + endPoint) / 2;
        var totalPoints = middlePoints + 2;
        lineRenderer.positionCount = totalPoints;

        for (var i = 1; i <= middlePoints; i++)
        {
            var t = (float) i / (totalPoints - 1);
            var pos = BezierCurve(startPoint, centerPoint, control, t);
            lineRenderer.SetPosition(i, pos);
        }

        lineRenderer.SetPosition(totalPoints - 1, centerPoint);
    }

    /// <summary>
    /// 始点と終点だけに戻す
    /// </summary>
    /// <param name="start">始点</param>
    /// <param name="end">終点</param>
    private void ResetPointerEndToStart(Vector3 start, Vector3 end)
    {
        //開始地点と終了地点の2点のみ
        lineRenderer.positionCount = 2;
        lineRenderer.SetPosition(0, start);
        lineRenderer.SetPosition(1, end);
    }
}
```

下記の箇所でベジェ曲線の計算式により求めた補間値を使って
曲線を描く複数の座標をそれぞれLineRendererに設定しています。

```C#
/// <summary>
///吸いつきのカーブを計算
/// </summary>
private void CalculateCurvePointer()
{
    //曲がる位置の補間点は始点と終点の中間
    var control = (startPoint + endPoint) / 2;
    var totalPoints = middlePoints + 2;
    lineRenderer.positionCount = totalPoints;

    for (var i = 1; i <= middlePoints; i++)
    {
        var t = (float) i / (totalPoints - 1);
        var pos = BezierCurve(startPoint, centerPoint, control, t);
        lineRenderer.SetPosition(i, pos);
    }

    lineRenderer.SetPosition(totalPoints - 1, centerPoint);
}
```

## OVRInputModuleをカスタムする

次にOculusIntegrationの入力モジュールである、
`OVRInputModule`を継承した`CustomOVRInputModule`を作成します。

このクラスの役割は、`Rayの始点`、`ヒットしたUIの中心`、`Rayの終点`を
先ほどの自作ポインタークラスに渡すことです。

日本語でコメントを書いている箇所が役割を果たしている箇所です。

```C#
using UnityEngine;
using UnityEngine.EventSystems;

/// <summary>
/// OVRInputModuleを継承してカーソルの位置を都合よく返すモジュールを作る
/// </summary>
public class CustomOVRInputModule : OVRInputModule
{
    /// <summary>
    /// 自作ポインタークラス
    /// </summary>
    [SerializeField] private Pointer pointer;
    
    private readonly MouseState m_MouseState = new MouseState();
    
    Vector3 center = Vector3.zero;
    
    /// <summary>
    /// ほぼコピペ
    /// </summary>
    protected override MouseState GetGazePointerData()
    {
        OVRPointerEventData leftData;
        GetPointerData(kMouseLeftId, out leftData, true);
        leftData.Reset();
        
        leftData.worldSpaceRay = new Ray(rayTransform.position, rayTransform.forward);
        leftData.scrollDelta = GetExtraScrollDelta();
        
        leftData.button = PointerEventData.InputButton.Left;
        leftData.useDragThreshold = true;
        
        eventSystem.RaycastAll(leftData, m_RaycastResultCache);
        var raycast = FindFirstRaycast(m_RaycastResultCache);
        leftData.pointerCurrentRaycast = raycast;
        m_RaycastResultCache.Clear();
        
        //カーソルの位置をデフォルト(Rayの始点となるTransformの直線上)にセット
        pointer.SetCursorDefaultTransform(rayTransform);

        OVRRaycaster ovrRaycaster = raycast.module as OVRRaycaster;
        
        if (ovrRaycaster)
        {
            leftData.position = ovrRaycaster.GetScreenPosition(raycast);
            
            RectTransform graphicRect = raycast.gameObject.GetComponent<RectTransform>();

            if (graphicRect != null)
            {
                //吸いつくUIかどうかをインターフェースの有無で判定
                if(raycast.gameObject.TryGetComponent(out IHoverStickyUI stickyUI))
                {
                    center = stickyUI.GetCenter();
                }

                var end = raycast.worldPosition;
                //自作ポインタークラスに以下を渡す
                //Rayの始点、ヒットしたUIの中心、Rayの終点
                pointer.SetCursorStartCenterEnd(rayTransform.position, center,end);
            }
        }
        
        OVRPointerEventData rightData;
        GetPointerData(kMouseRightId, out rightData, true);
        CopyFromTo(leftData, rightData);
        rightData.button = PointerEventData.InputButton.Right;

        OVRPointerEventData middleData;
        GetPointerData(kMouseMiddleId, out middleData, true);
        CopyFromTo(leftData, middleData);
        middleData.button = PointerEventData.InputButton.Middle;
        
        m_MouseState.SetButtonState(PointerEventData.InputButton.Left, GetGazeButtonState(), leftData);
        m_MouseState.SetButtonState(PointerEventData.InputButton.Right, PointerEventData.FramePressState.NotChanged, rightData);
        m_MouseState.SetButtonState(PointerEventData.InputButton.Middle, PointerEventData.FramePressState.NotChanged, middleData);
        return m_MouseState;
    }
}
```

## インターフェースの有無で吸いつきを行うか判断

全てのUIに吸いつくように実装すると、選択項目ではないパネルを最背面に置いた際などに困ります。

そこで今回は、`RayがHitしたUI`に
`IHoverStickyUI`というインターフェースが実装されているかどうかで、
吸いつきを実装すべきUIか判定をしています。

```C#
//吸いつくUIかどうかをインターフェースの有無で判定
if(raycast.gameObject.TryGetComponent(out IHoverStickyUI stickyUI))
{
    center = stickyUI.GetCenter();
}
```

`IHoverStickyUI`は下記です。

```C#
using UnityEngine;

/// <summary>
/// Rayが衝突した際に吸いつくUIに継承するインターフェース
/// </summary>
public interface IHoverStickyUI
{
   Vector3 GetCenter();
}
```

`IHoverStickyUI`を継承した下記クラスを、
選択項目として吸いつきを実装したいUIにアタッチします。

```C#
using UnityEngine;

/// <summary>
/// 吸いつくUIにアタッチ
/// </summary>
public class StickyUI : MonoBehaviour, IHoverStickyUI
{
    private Vector3 center;

    private void Start()
    {
        center = GetComponent<RectTransform>().position;
    }

    /// <summary>
    /// 自身の中心を返す
    /// </summary>
    /// <returns></returns>
    public Vector3 GetCenter()
    {
        return center;
    }
}
```

このインターフェースの諸々の実装は各自お好みでという感じです。

## おわりに
やってみるとわかりますが、いろいろと課題はあります。
実装に試してベストプラクティスを探ってもらえると嬉しいです。

## 参考リンク
[LineRendererをベジェ曲線で描画する](https://scrapbox.io/oculusgogo/LineRenderer%E3%82%92%E3%83%99%E3%82%B8%E3%82%A7%E6%9B%B2%E7%B7%9A%E3%81%A7%E6%8F%8F%E7%94%BB%E3%81%99%E3%82%8B)
[【Unity(C#)】OculusIntegrationを使ってVR空間でSliderを操作](https://qiita.com/OKsaiyowa/items/5580dc23414bc3aff24e)