🎥

新Input Systemでナビゲーション機能を実装する

2022/06/19に公開

新Input Systemによるナビゲーション機能

Unityのバージョン

名前 バージョン
Unity 2021.3.4f1 (2021 LTS)
Input System 1.3.0
UniTask 2.3.1

キー・コンビネーション

Blenderを参考に以下のような操作ができるようにします.

操作 キー
Drag Shif + MMB + Drag
Zoom Mouse Wheel
Orbit MMB + Drag

したかったのですが, 現状Shiftだけ違うというような処理ができないようです. よって以下のようにしておきました.

操作 キー
Drag Shif + MMB + Drag
Zoom Mouse Wheel
Orbit RMB + Drag

Input Systemの設定

PlayerInputコンポーネント

Main CameraにPlayerInputコンポーネントを追加します.

Create Actionで適当な場所にアクション・アセットを保存します. このアセットをクリックすると以下のようなエディタが開きます.

Cameraというアクション・マップにZoom, Orbit, そしてDragをいうアクションを用意します. これをDefault Mapに指定しましょう.

もう一つアクションの発生の通知方法を指定する必要があります. BehaviorをInvoke Unity Eventを指定します.

それぞれのアクションに対応するC#スクリプトを用意してMain Cameraに追加しておきましょう.

これをEvents -> Cameraで以下のように対応させます. アクション実行時に指定したスクリプトに定義されたメソッドがコールバックとして呼び出されます.

ナビゲーションの実装

Zoom In/Out

おそらく一番簡単な操作です.

Pass-Throughタイプ

アクション・タイプはPass-Throughタイプを指定します. このタイプはコントロールを特定することはせず流れてきた値を垂れ流すようです. [1] あまり良く分かっていませんが, このタイプじゃないとScroll/Y [Mouse]が指定できなかったのでとりあえずこれにしておきます.

コントロール・タイプはAxisにしましたが, Vector2を指定してC#スクリプトでy値だけを利用する方法もあります.

実装

カメラに以下のスクリプトを付けた上でPlayerInputコンポーネントでOnZoomをコールバックとして指定しておきます.

Zoom.cs
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Camera))]
public class Zoom : MonoBehaviour {

    [SerializeField]
    private new Transform camera;
    private float scroll = 0.0f;

    public void OnZoom(InputAction.CallbackContext context) {
        this.scroll = context.ReadValue<float>() / 120f;
    }

    void Start() {
        this.camera = GetComponent<Camera>().transform;
    }

    void LateUpdate() {
        this.camera.transform.position += this.camera.transform.forward * this.scroll;
    }
}

コールバックとして登録されるメソッドはInputAction.CallbackContextを引数として取りますので注意してください.

OnZoomでscrollの値を120で割っているのは, 取得した値が120, 240といった120の倍数になっていたからです. 環境によってはうまく動かないかもしれませんので確認が必要です.

後はスクロールされた値に応じてカメラを前方向に前進させています. forwardプロパティとはカメラのローカル座標のZ+方向のベクトルです. またカメラの位置がUpdateの最後に更新されるようにLateUpdateを使用します.

For example a follow camera should always be implemented in LateUpdate because it tracks objects that might have moved inside Update.

Drag

Zoomに比べると難易度は高めでした. [2]

  • スクリーン上の位置をワールド座標の適切な位置に変換する
  • アクション・フェーズの使い分け
カスタム・コンポジット

あるアクションを複数のキーで表現する場合コンポジット・バインディングを使います. 例えばショートカットの修飾キーならAdd Binding With One Modifierというのを指定します.

問題はマウスのボタンが指定できませんでした. Add Binding With Two ModifiersがあったのでShiftと中ボタンを修飾キーとしてマウス・ドラッグさせようと思ったのですが, そうするとPosition [Mouse]というパスが指定できませんでした.

そのため Actions with modifiers?を参考にカスタム・バインディングを作りました.

Shift + MMB + ドラッグに割り当てるDrag Compositeです.

実装

マウスのドラッグに合わせてカメラを移動させます. PlayerInputコンポーネントではDrag.OnDragというメソッドをコールバックとして登録します. また今回はValue型のアクションなので複数のフェーズがあります. started, performed, canceledというフェーズがあり, 大体JavaScriptのmousedown, mousemove, mouseupに対応していると考えて良いでしょう.

public void OnDrag(InputAction.CallbackContext context) {
    if (context.started) {}
    
    if (context.performed) {}
    
    if (context.canceled) {}
}

contextを通じてアクション・タイプに合わせた値を取得することができます. 今回はマウスの座標なのでVector2です.

Vector2 mousePosition = context.ReadValue<Vector2>();

このスクリーン上のポイントを三次元空間に変換します. 変換方法はいろいろありますが今回はカメラの視線方向をと同じ方向逆向きの法線を持つ平面との交点を取得することで求めることにします.

この面の中心とカメラの視線方向の交点がフォーカス位置になります. [3]

Drag.cs
using UnityEngine;
using UnityEngine.InputSystem;

using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;

using Comix.Navigation;

[RequireComponent(typeof(Camera))]
public class Drag : MonoBehaviour {

    public GameObject DragPoint;

    // private Camera draggableCamera;
    private Transform cameraTransform;

    private bool dragging = false;

    private DragArea dragArea;

    private Vector3 intersectPoint = Vector3.zero;

    private Vector3 start = Vector3.zero;

    private Vector3 delta = Vector3.zero;

    private Vector3 destination = Vector3.zero;

    private float fieldOfView = 0.0f;

    readonly private float focalLength = 50.0f;

    public void OnDrag(InputAction.CallbackContext context) {
        if (context.started) {
            this.dragging = true;
            Vector2 handle = context.ReadValue<Vector2>();
            this.start = this.dragArea.Intersect(handle);
            this.intersectPoint = this.start;
        }

        if (context.performed) {
            if (!dragging) return;
            Vector2 handle = context.ReadValue<Vector2>();
            Vector3 current = this.dragArea.Intersect(handle);
            this.intersectPoint = current;
            this.delta = current - this.start;

            var worldDelta = this.GetWorldPosition(this.delta, this.fieldOfView, this.focalLength);
            this.destination = this.cameraTransform.position - worldDelta;

        }

        if (context.canceled) {
            this.dragging = false;
        }
    }

    private void UpdateMouseCameraPosition(Vector3 newPosition) {
        this.cameraTransform.position = newPosition;
    }

    private void UpdateDragPoint(Vector3 currentPosition) {
        this.DragPoint.transform.position = currentPosition;

    }

    private Vector3 GetWorldPosition(Vector3 mouseDelta, float fieldOfView, float focalLength) {
        float halfFOV = fieldOfView / 2.0f * Mathf.Deg2Rad;
        float aperture = focalLength * Mathf.Tan(halfFOV);
        float screenToWorld = (2 * aperture) / Screen.height;
        return mouseDelta * screenToWorld;
    }

    async UniTaskVoid Start() {
        var draggableCamera = GetComponent<Camera>();
        this.cameraTransform = draggableCamera.transform;
        this.cameraTransform.LookAt(Vector3.zero);
        this.fieldOfView = draggableCamera.fieldOfView;
        this.dragArea = new(draggableCamera, this.focalLength);

        var rorateChanged = UniTaskAsyncEnumerable.EveryValueChanged(this.transform, t => t.rotation, PlayerLoopTiming.LastUpdate);
        await foreach (var _ in rorateChanged.WithCancellation(this.GetCancellationTokenOnDestroy())) {
            this.dragArea = this.dragArea.Update();
        }
        this.UpdateDragPoint(this.dragArea.center);
    }

    void LateUpdate() {
        if (!this.dragging) return;
        this.UpdateMouseCameraPosition(this.destination);
        this.UpdateDragPoint(this.intersectPoint);
    }
}

UniTaskを使ってカメラの位置が変わるたびにドラッグ用の面の位置が正面になるように調整しています.

その面を表すDragArea型は以下のようなクラスです. コンストラクタで平面の位置をカメラから見て50としていますが適当です.

DragArea.cs
using UnityEngine;

namespace Comix {
    namespace Navigation {
        internal class DragArea {
            readonly private Camera camera;

            readonly private Plane area;

            readonly public Vector3 center;

            public DragArea(in Camera camera) {
                this.camera = camera;
                // Place a plane in front of camera
                var distance = 30;
                this.center = this.camera.transform.position + this.camera.transform.forward * distance;
                this.area = new(-this.camera.transform.forward, this.center);
            }

            public Vector3 Intersect(in Vector2 screenPosition) {
                // Ray casted from the screen point
                Ray ray = this.camera.ScreenPointToRay(screenPosition);

                if (area.Raycast(ray, out float enter)) {
                    Vector3 intersect = ray.GetPoint(enter);
                    return intersect;
                }

                return Vector3.zero;
            }
        }
    }
}

Orbit

最後は回転です. 非選択時はワールドの原点を中心にし, 選択している場合は選択オブジェクトを中心に据えます. ただオブジェクト選択アルゴリズムは実装していないのでとりあえず原点中心の回転を実装します.

といっても内容は[Tutorial] How to rotate the camera around an object in Unity3Dの内容そのままです.

マウスの移動量を回転に対応させるというものです. 今回はスクリーンを端から端までマウスが移動した場合180度回転するようになっています. 回転は原点で行い, カメラと原点の距離(offset)を元に再び距離を取ります.

Orbit.cs
using UnityEngine;
using UnityEngine.InputSystem;

using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;

[RequireComponent(typeof(Camera))]
public class Orbit : MonoBehaviour {

    public GameObject target;

    private new Camera camera;

    private bool orbiting = false;

    private Vector2 start = Vector2.zero;

    private Vector2 current = Vector2.zero;

    private Vector2 direction = Vector2.zero;

    private Vector3 offset = Vector3.zero;

    private Vector2 GetMousePosition(InputAction.CallbackContext context) {
        return context.ReadValue<Vector2>();
    }

    public void OnOrbit(InputAction.CallbackContext context) {
        if (context.started) {
            this.orbiting = true;
            Vector2 mousePosition = this.GetMousePosition(context);
            this.start = this.camera.ScreenToViewportPoint(mousePosition);
        }

        if (context.performed) {
            if (!this.orbiting) return;
            Vector2 mousePosition = this.GetMousePosition(context);
            this.current = this.camera.ScreenToViewportPoint(mousePosition);
            this.direction = this.start - this.current;
            this.start = this.current;
        }

        if (context.canceled) {
            this.orbiting = false;
        }
    }

    async UniTaskVoid Start() {
        this.camera = GetComponent<Camera>();
        // distance from world center to camera
        this.offset = Vector3.zero - this.transform.position;

        IUniTaskAsyncEnumerable<Vector3> cameraPositionUpdate = UniTaskAsyncEnumerable.EveryValueChanged(this.transform, t => t.position, PlayerLoopTiming.PreLateUpdate);
        await foreach (var _ in cameraPositionUpdate.WithCancellation(this.GetCancellationTokenOnDestroy())) {
            this.offset = Vector3.zero - this.transform.position;
        }
    }

    // Update is called once per frame
    void LateUpdate() {
        if (!this.orbiting) { return; }
        float rotationAroundYAxis = -this.direction.x * 180; // camera moves horizontally
        float rotationAroundXAxis = this.direction.y * 180; // camera moves vertically

        float offsetRight = Vector3.Dot(this.offset, this.transform.right);
        float offsetForward = Vector3.Dot(this.offset, this.transform.forward);
        float offsetUp = Vector3.Dot(this.offset, this.transform.up);

        this.transform.position = Vector3.zero;

        this.transform.Rotate(new Vector3(1, 0, 0), rotationAroundXAxis);
        this.transform.Rotate(new Vector3(0, 1, 0), rotationAroundYAxis, Space.World);


        // take distance from the target
        this.transform.position -= offsetRight * transform.right;
        this.transform.position -= offsetForward * transform.forward;
        this.transform.position -= offsetUp * transform.up;
    }
}

なおドラッグやズームでカメラの位置が変わった場合UniTaskでカメラと原点のオフセットを更新しています.

まとめ

かなり使いやすかったです. 少し覚えなければいけないこともありましたが, とりあえずドキュメントとにらめっこすれば何とかなりました. 各ナビゲーションの操作とコードの関係もすっきり書くことができました(UniTaskの値監視のおかげですが・・・).

補足

アクション・マップ

アクション・エディタの左端にはAction Mapの列があります. これは複数のアクションとそれに紐づけたバインディング(実際のデバイスの制御)を表すマップです.

アクションは実際のゲーム画面上で起きる現象の名前という感じでしょうか. 発砲ならFire, ジャンプならJumpといったアクションを用意します. デフォルトのPlayerマップを見てみましょう.

FireというのがありこれはButtonというアクション・タイプが指定されています. バインディングは実際のデバイスの入力を指定します. Fireの場合マウスなら左ボタンと結び付けられています.

画面上のアクションとマウスというデバイスの制御(ボタンとかスクロール, ドラッグ)とを結び付けて管理するのがアクション・マップです. 当然アクションが細かくどのようなロジックを呼び出す必要があるのかはC#スクリプトで実装します. これがコールバックです.

アクション・アセットの関係をまとめると以下のようになるかなと思います.

コンポジット・バインディング

キー・コンビネーションで修飾キーというのがあります. あるキーの前提となるキーです. ShiftやAlt, Ctrlなどが良く使われます. こうすることで実際は何らかのUI操作が必要な処理をキーボードのショートカットとして表現することができて便利です. WASDも通常マウスやコントローラーで行う移動をキーボードでできるようにしています.

このような複数のキーをコントロールに対応させる場合をコンポジット・バインディングと呼びます.

Drag Composite

DragComposite.cs
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.Utilities;

#if UNITY_EDITOR
using UnityEditor;
using UnityEngine.InputSystem.Editor;
#endif


#if UNITY_EDITOR
[InitializeOnLoad]
#endif

[DisplayStringFormat("{LeftShift}+{MouseButton}+{MousePosition}")]
public class DragComposite : InputBindingComposite<Vector2> {
    [InputControl(layout = "Key")]
    public int LeftShift;

    [InputControl(layout = "Button")]
    public int MiddleMouseButton;

    [InputControl(layout = "Vector2")]
    public int HandlePoint;

    public override Vector2 ReadValue(ref InputBindingCompositeContext context) {
        bool isMMBPressed = context.ReadValueAsButton(MiddleMouseButton);
        bool isLeftShiftPressed = context.ReadValueAsButton(LeftShift);
        if (isMMBPressed && isLeftShiftPressed)
            return context.ReadValue<Vector2, Vector2MagnitudeComparer>(HandlePoint); //hack

        return default;
    }

    public override float EvaluateMagnitude(ref InputBindingCompositeContext context) {
        return ReadValue(ref context).magnitude;
    }

    static DragComposite() {
        InputSystem.RegisterBindingComposite<DragComposite>();
    }

    [RuntimeInitializeOnLoadMethod]
    static void Init() { } // Trigger static constructor.
}

スクリーン座標をワールド座標に変換する

計算の話はMath Notes: Ray-Plane Intersectionが分かりやすいです. 簡単なベクトルの計算です. 実装はy方向から見下ろす固定視点ですがDrag 3D camera with mouse cursorがも参考になります.

Unityの場合はCamera.ScreenToWorldPointを使うか, Camera.ScreenPointToRayというメソッドが用意されています. z軸方向の値を任意に決める必要はありますが簡単に取得できます.

今回はPlaneとCamera.ScreenPointToRayを組み合わせて3D空間内での移動とスクリーン上の移動の関係を変換しています.

焦点距離との関係

Three.jsのOrbitControlsでは, 以下のような関係でスクリーン座標とワールド座標の移動を変換している.

const position = scope.object.position;
offset.copy( position ).sub( scope.target );
let targetDistance = offset.length();

// half of the fov is center to top of screen
targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 );

// we use only clientHeight here so aspect ratio does not distort speed
panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );

【解決!】スマホ向けUnity 異なる画面サイズ対応3D版によるとFocal Lengthを使うと任意の長さのオブジェクトを画面いっぱいに表示できる. そうなるときのtargetDistance(apertureとも)とclientHeight(あるいはclientWidth)は一対一で変換できると考えられる.

一方deltaYはマウスの動きから得られる. これに対応するワールド座標でのdyは比率から求めることができる. カメラの性質をコントロールしたいわけではなくあくまでワールド座標とスクリーン座標の関係を定式化したいだけなので任意のoffsetを設定すればちょうどよい移動が実現できるはず・・・.

修飾キーのバグについて

Unityの新入力システムではShift + MMBとMMBの違いはバージョン1.3.0では認識できません. Known Limitationsにも以下のような記述があります.

A common scenario is having, for example, a binding for "A" on one action and a binding for "SHIFT+A" on another action. Currently, pressing "SHIFT+A" will trigger both actions.

1.4.0では修正されるのではということが以下のフォーラムで期待されているようです.

Using ButtonWithOneModifier without triggering another action using the same binding?

どうしてもそのような機能が不可欠という場合にはサードパティー製のパッケージがあるようです.

AginerianInput

Reference

  1. Strategy Game Camera: Unity's New Input System
  2. Part 1: How to make a configurable camera with the new Unity Input System
  3. GameDevTutorials/tutorials/
  4. New Input System - Mouse Press and Hold - Drag And Move
  5. UnityでSketchfabのようなカメラ操作を実装する
  6. UnityでSceneビューのような視点移動ができるカメラを作る
  7. RotateAround an object with using Quaternion
  8. [Tutorial] How to rotate the camera around an object in Unity3D

メモ

warning CS0108: 'GrapplingGun.camera' hides inherited member 'Component.camera'. Use the new keyword

脚注
  1. 他のタイプはdisambiguation(曖昧性除去?)でコントロールを特定した状態でアクションに値が渡されてきます. ↩︎

  2. Zoomが簡単すぎただけですが. ↩︎

  3. 焦点がどこかで画面移動が速くなったり遅くなったりするので調整が必要です. 何かほかのやり方があるのかもしれませんが良く分かりませんでした. ↩︎

Discussion