みかづきメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

SteamVR でレーザーポインターを実装する

VR ゲームで操作するとき、レーザーポインター形式の操作方法をよく見かけます。
それを Unity + SteamVR で実装してみます。


実行環境は以下の通りです:

  • Windows 10
  • Unity 2019.2.17f1 Personal
  • Valve Index

この記事では以下のアセットを使用します:

まずは、雑にレーザーポインターっぽいものを作ります。
AvatarLaserPointer.cs を作成し、以下のように記述します。

public class AvatarLaserPointer : LaserPointerRaycastReceiver
{
    private GameObject _laser;
    private GameObject _pointer;

    [SerializeField]
    private Material LaserMaterial;

    // ReSharper disable once FieldCanBeMadeReadOnly.Local
    [SerializeField]
    private float LaserThickness = 0.001f;

    [SerializeField]
    private Material PointerMaterial;

    // ReSharper disable once FieldCanBeMadeReadOnly.Local
    [SerializeField]
    private float PointerRadius = 0.05f;

    public override void OnUpdate(RaycastResult raycast)
    {
        if (raycast.gameObject)
        {
            // Ray が Hit したところまで描画してあげる
            _laser.transform.localScale = new Vector3(LaserThickness * 4f, LaserThickness * 4f, raycast.distance);
            _laser.transform.localPosition = new Vector3(0f, 0f, raycast.distance / 2f);

            _pointer.transform.position = raycast.worldPosition;
            _pointer.SetActive(true);
        }
        else
        {
            _laser.transform.localScale = new Vector3(LaserThickness, LaserThickness, 0f);
            _laser.transform.localPosition = new Vector3(0f, 0f, 0f);

            _pointer.SetActive(false);
        }
    }

    private void Start()
    {
        if (LaserMaterial == null)
            Debug.LogWarning("No Laser Material found on this component", this);
        if (PointerMaterial == null)
            Debug.LogWarning("No Pointer Material found on this component", this);

        _laser = GameObject.CreatePrimitive(PrimitiveType.Cube);
        _laser.transform.parent = transform;
        _laser.transform.localScale = new Vector3(LaserThickness, LaserThickness, 100f);
        _laser.transform.localPosition = new Vector3(0f, 0f, 50f);
        _laser.transform.localRotation = Quaternion.identity;
        _laser.GetComponent<MeshRenderer>().material = LaserMaterial;

        _pointer = GameObject.CreatePrimitive(PrimitiveType.Sphere);
        _pointer.transform.parent = transform;
        _pointer.transform.localScale = new Vector3(PointerRadius, PointerRadius, PointerRadius);
        _pointer.transform.localPosition = new Vector3(0f, 0f, 0f);
        _pointer.GetComponent<MeshRenderer>().material = PointerMaterial;
        _pointer.SetActive(false);
    }
}

やっていることは単純で、アタッチされた Object の子としてレーザーとポインター
作成し、 OnUpdate で降ってきた RaycastResult があればポインターを表示、
なければレーザー+ポインターを非表示にしているだけです。
LaserPointerRaycastReceiver は後述する Input Module にでてきます。
そして、このスクリプトを各コントローラーオブジェクトにアタッチします。

設定値はお好きなものを設定してください。
オススメはそのままの値がちょうど良い感じです。
Material はこんな感じのものを設定しておくと、他のゲームと似た雰囲気になります。

f:id:MikazukiFuyuno:20200307193223p:plain:w250

アタッチしたら、次に Input Module を作成します。
Input Module は uGUI とかを作ると追加される EventSystem にアタッチされているもので、
マウスやタッチなどの入力を、各 Object に対してイベントとして送信する役割があります。
デフォルトだと StandaloneInputModule がアタッチされています。
今回は、 VR 空間内でのレーザーポインターを入力としたいので、自作します。

まずは、 BaseInputModule を継承した SteamVRInputModule クラスを作ります。
SteamVRInputModule では以下の処理を行います。

長いので初めにコード全文を貼ります。

public class SteamVRInputModule : BaseInputModule
{
    private List<RaycastResult> _raycastResultsCache;
    private Camera _uiCamera;

    [SerializeField]
    private InputSource InputSourceLeft;

    [SerializeField]
    private InputSource InputSourceRight;

    // ReSharper disable once FieldCanBeMadeReadOnly.Local
    [SerializeField]
    private SteamVR_Action_Boolean InteractUI = SteamVR_Input.GetBooleanAction("InteractUI");

    private List<InputSource> Poses => new List<InputSource> { InputSourceLeft, InputSourceRight };

    protected override void Start()
    {
        base.Start();

        if (InteractUI == null)
            Debug.LogError("No UI interaction action has been set on this component", this);
        InputSourceLeft.Initialize(eventSystem);
        InputSourceRight.Initialize(eventSystem);

        // Ray 照射用のカメラを作成する、実際に内容が描画されることはない
        _uiCamera = new GameObject("UI Camera").AddComponent<Camera>();
        _uiCamera.clearFlags = CameraClearFlags.Nothing;
        _uiCamera.cullingMask = 0;
        _uiCamera.enabled = false;
        _uiCamera.fieldOfView = 1;
        _uiCamera.nearClipPlane = 0.01f;

        // シーン上の全ての Canvas を引っ張ってきて、 UI Camera を判定に使用する
        foreach (var canvas in Resources.FindObjectsOfTypeAll<Canvas>())
            canvas.worldCamera = _uiCamera;
    }

    protected override void Awake()
    {
        base.Awake();

        _raycastResultsCache = new List<RaycastResult>();
    }

    public override void Process()
    {
        if (!InputSourceLeft.Validate() || !InputSourceRight.Validate())
            return;

        Poses.ForEach(ProcessEvents);
    }

    private void ProcessEvents(InputSource source)
    {
        // Controller がある位置にカメラを移動させる
        UpdateCameraPositionTo(source.Pose.transform);

        source.EventData.Reset();
        source.EventData.position = new Vector2(_uiCamera.pixelWidth * 0.5f, _uiCamera.pixelHeight * 0.5f);

        // Ray を照射して、一番手前にあるものを引っ張ってくる
        eventSystem.RaycastAll(source.EventData, _raycastResultsCache);
        source.EventData.pointerCurrentRaycast = FindFirstRaycast(_raycastResultsCache);
        // Receiver が設定されていたら、 Receiver に Raycast 情報を渡す
        source.Receiver?.OnUpdate(source.EventData.pointerCurrentRaycast);
        _raycastResultsCache.Clear();

        HandlePointerExitAndEnter(source.EventData, source.EventData.pointerCurrentRaycast.gameObject);

        // トリガーボタンが押されていれば
        if (InteractUI.GetState(source.Pose.inputSource))
        {
            // 前の値がなければ新規で押された場合なので、
            if (source.PreviousContactObject == null)
            {
                // 新規オブジェクトに Click 等を送信
                HandlePress(source);
            }
            // 別のオブジェクトへと判定が移動した場合は、
            else if (source.PreviousContactObject != source.EventData.pointerCurrentRaycast.gameObject)
            {
                // 古いオブジェクトはリリースし、新しいオブジェクトに Click 等を送信
                HandleRelease(source);
                HandlePress(source);
            }
            // 前の値と同じであれば、
            else
            {
                // ドラッグイベントを発行してあげる
                source.EventData.pointerPressRaycast = source.EventData.pointerCurrentRaycast;
                ExecuteEvents.Execute(source.EventData.pointerDrag, source.EventData, ExecuteEvents.dragHandler);
                ExecuteEvents.Execute(source.PreviousContactObject, source.EventData, ExecuteEvents.dragHandler);
            }

            return;
        }

        // ボタンが放されたらリリース
        if (source.PreviousContactObject)
            HandleRelease(source);
    }

    // ReSharper disable once ParameterHidesMember
    private void UpdateCameraPositionTo(Transform transform)
    {
        _uiCamera.transform.position = transform.position;
        _uiCamera.transform.rotation = transform.rotation;
    }

    private void HandlePress(InputSource source)
    {
        // press
        source.PreviousContactObject = source.EventData.pointerCurrentRaycast.gameObject;
        source.EventData.pointerPressRaycast = source.EventData.pointerCurrentRaycast;

        var pressed = ExecuteEvents.ExecuteHierarchy(source.PreviousContactObject, source.EventData, ExecuteEvents.pointerDownHandler);
        if (pressed == null)
        {
            // Button などの場合、重なっている Label などが取得されてしまうので、 Button 本体を引っ張ってくる
            pressed = ExecuteEvents.GetEventHandler<IPointerClickHandler>(source.PreviousContactObject);
            ExecuteEvents.Execute(source.PreviousContactObject, source.EventData, ExecuteEvents.pointerClickHandler);
            ExecuteEvents.Execute(source.PreviousContactObject, source.EventData, ExecuteEvents.beginDragHandler);
        }
        else
        {
            // 直接 Button など動きがあるものが取れていたならそれにそのままイベントを投げる
            ExecuteEvents.Execute(pressed, source.EventData, ExecuteEvents.pointerClickHandler);
            ExecuteEvents.Execute(pressed, source.EventData, ExecuteEvents.beginDragHandler);
            ExecuteEvents.Execute(source.PreviousContactObject, source.EventData, ExecuteEvents.pointerClickHandler);
            ExecuteEvents.Execute(source.PreviousContactObject, source.EventData, ExecuteEvents.beginDragHandler);
        }

        if (pressed != null)
        {
            source.EventData.pressPosition = pressed.transform.position;
            eventSystem.SetSelectedGameObject(pressed);
        }

        source.EventData.pointerPress = pressed;
        source.EventData.pointerDrag = pressed;
        source.EventData.rawPointerPress = source.PreviousContactObject;
    }

    private void HandleRelease(InputSource source)
    {
        // release
        ExecuteEvents.Execute(source.EventData.pointerPress, source.EventData, ExecuteEvents.pointerUpHandler);
        ExecuteEvents.Execute(source.EventData.pointerDrag, source.EventData, ExecuteEvents.endDragHandler);

        eventSystem.SetSelectedGameObject(null);

        source.EventData.pressPosition = Vector2.zero;
        source.EventData.pointerPress = null;
        source.EventData.pointerDrag = null;
        source.EventData.rawPointerPress = null;
        source.PreviousContactObject = null;
    }

    [Serializable]
    private class InputSource
    {
        public PointerEventData EventData { get; private set; }
        public GameObject PreviousContactObject { get; set; }

        public void Initialize(EventSystem eventSystem)
        {
            if (Pose == null)
                Debug.LogError("No SteamVR_Behaviour_Pose component found on this component");

            EventData = new PointerEventData(eventSystem);
            PreviousContactObject = null;
        }

        public bool Validate()
        {
            return Pose != null;
        }

        #region Pose

        [SerializeField]
        private SteamVR_Behaviour_Pose _pose;

        public SteamVR_Behaviour_Pose Pose
        {
            get => _pose;
            set => _pose = value;
        }

        #endregion

        #region Receiver

        [SerializeField]
        private LaserPointerRaycastReceiver _receiver;

        public LaserPointerRaycastReceiver Receiver
        {
            get => _receiver;
            set => _receiver = value;
        }

        #endregion
    }

    public abstract class LaserPointerRaycastReceiver : MonoBehaviour
    {
        public abstract void OnUpdate(RaycastResult raycast);
    }
}

基本的には、ソース内に記述しているコメントの通りです。
ソース中にある Receiver.OnUpdate は、先ほど作成したポインタークラスへと
処理を投げています。
FindFirstRaycastGameObject が有効なモノが無ければ空の RaycastResult を返すので、
それでなにかしらかに当たっているかどうかを判定することが出来ます。

あとはこれを EventSystem にアタッチし、 StandaloneInputModule を外せば完了です。
実行すると、 World Space に設定されている uGUI をポインターで操作できるようになります。

ということで、レーザーポインターの実装でした。

参考 :