16
Help us understand the problem. What are the problem?

posted at

updated at

Organization

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

はじめに

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

バージョン情報

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

曲線を描くポインターの実装

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

以下に図解します。

まずは任意のUIにヒットしたポインターの状態です。
黄色がUI、黒がコントローラー、青がRayです。ヒットした箇所をAとします。
Group 16.png

次に、任意のUIからRayが外れた場面です。Rayの直線上のある地点をBとします。
Group 17.png

最後にAとBをベジェ曲線を用いてなだらかに補間します。緑が補間した線のイメージです。
Group 18.png

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

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

適当なオブジェクトにアタッチ
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に設定しています。

/// <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の終点
先ほどの自作ポインタークラスに渡すことです。

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

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か判定をしています。

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

IHoverStickyUIは下記です。

using UnityEngine;

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

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

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をベジェ曲線で描画する
【Unity(C#)】OculusIntegrationを使ってVR空間でSliderを操作

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
16
Help us understand the problem. What are the problem?