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 はこんな感じのものを設定しておくと、他のゲームと似た雰囲気になります。
アタッチしたら、次に 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
は、先ほど作成したポインタークラスへと
処理を投げています。
FindFirstRaycast
は GameObject
が有効なモノが無ければ空の RaycastResult
を返すので、
それでなにかしらかに当たっているかどうかを判定することが出来ます。
あとはこれを EventSystem
にアタッチし、 StandaloneInputModule
を外せば完了です。
実行すると、 World Space に設定されている uGUI をポインターで操作できるようになります。
ということで、レーザーポインターの実装でした。
参考 :