InputCompositeBindingのTouchscreenでの応用例の紹介

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

前回、Unityの(New) Input Systemでデバイス入力をカスタマイズできるInputCompositeBindingを紹介しました。

synamon.hatenablog.com

こちらの記事ではInputCompositeBindingをそもそも知らない方が多いと思い、基本的な説明から行ったため、あまり応用の具体的な話ができませんでした。

今回はまだ低レベルなAPIしか用意されていないTouchscreenの入力の具体的な応用例を2つ紹介しながら、読者の方がよりInputCompositeBindingを理解し自身で実装ができるイメージを持てることをゴールにしたいと思います。

それではさっそく見ていきましょう。

1. TouchscreenでのTapした位置の入力

スマートフォン向けのアプリで、指でタップした位置にある3D空間内のオブジェクトになにかのインタラクションをしたい、というケースを想定しましょう。

それを実現するための一つの例として、以下のようなフローが考えられます。

指でタップしたスクリーン位置を取得する

→ スクリーン位置をCamera.ScreenPointToRay(...)で3D空間のRayに変換する

→ Rayを使って3D空間内でRaycastなどをしてオブジェクトを検索する

→ 見つかったオブジェクトになにかのインタラクションをする

ところがUnity標準のTouchscreenのBindingでは、Touch入力は0~9の10個のTouchControlが提供されていて、指定したTouchのTapやPositionは取得できますが、「TapしたTouchのPosition」を取るのは素直にはできません。

docs.unity3d.com

このような時にはInputCompositeBindingの出番です。

実際にソースコードを見ながら解説しますが、基本的な部分は前回の記事で丁寧に紹介したため、ここではカスタマイズのコアな部分のみ紹介します。

自作したクラスの内部に以下のような処理を記述します。

...
        // Please input path:"<Touchscreen>/*touch/tap" to update value.
        [InputControl(layout = "Button")] public int anyTap;

        public override Vector2 ReadValue(ref InputBindingCompositeContext context)
        {
            var touches = Touchscreen.current.touches;
            for (var index = 0; index <= 9; index++)
            {
                var touch = touches[index];
                if (IsEffectiveTap(touch))
                {
                    return touch.position.ReadValue();
                }
            }

            return Vector2.zero;
        }

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

        private static bool IsEffectiveTap(TouchControl touch)
        {
            return touch.tapCount.ReadValue() > 0;
        }
...

最も重要な入力の加工処理は、public override T ReadValue(...) で行います。

今回はTapした位置を取得したいため、TにはVector2を指定してクラスを定義しています。

ReadValue(...)では、はじめにTouchscreen.current.touchesで現在のTouchControl10個の配列を取得します。

それからfor文で順番にTouchControlを見て、IsEffectiveTapがtrue、つまりTapをしているTouchを判定して、それが見つかった際にTouchControl.position.ReadValue()でPositionを返すようにしています。

ロジックとしてはとてもシンプルかと思います。

ただ注意しなければならないのが、ReadValue(...)以外の点です。

EvaluateMagnitude(...)のoverrideの実装ですが、これはReadValue(...)の結果を流用しているだけなのですが、この実装とセットでActionに設定する際にInteractionsのTapを設定することでTap操作としての振る舞いをさせることができるようになります。

次に最初から用意されているTouchControl.tap.ReadValue()というTap判定が(不具合なのか?設定が悪いのか?)正常に取得できませんでした。

そのため厳密には正しくはないのですが、Tapすると数が増えていくTouchControl.tapCount.ReadValue()を見てTapしているかどうかの判定としています。

ここの部分は不具合の可能性もあるため今後は改善されるかもしれません。

最後に、本来ReadValue(...)内部で使用すべきものですがここでは使用していない[InputControl(layout = "Button")] public int any;を説明します。

ご覧のとおり今回の処理では特にInputActionAsset上でのBindingsのセットアップをすることなく入力の取得ができるように見えると思います。

しかしこのBindingsを一つも定義しない場合、InputActionAsset上で自作したInputCompositeBindingにControlSchemeの指定欄がないため、ControlSchemeの指定ができない、つまりどのデバイス入力に対応して入力を流すのか指定できないこととなり、そもそも入力がまったく入らなくなってしまいます。

では適当に定義しておくとして何でも良いかというとそうではなく、そこで指定したBindingsの入力が更新されるタイミングで、自作したBindingsの入力も更新される仕様となっているため、自作した入力を利用したいタイミングと同じタイミングで値が更新されるBindingsを指定する必要があります。

今回は何かしらのTap操作のタイミングで入力が渡せられればよいので、ActionAssetのEditorで <Touchscreen>/*touch/tap をBindingsに設定します。

Tのアイコンを押すとテキストで指定できます、* はワイルドカードです。

ここは動かしてみないと分からない点なので気を付けていただきたいのと、後から見ても何が目的なのか分からないためコメントを残しておくことをおすすめします。

...

以上がTouchscreenでのTapした位置の入力のBindingのカスタマイズ例になります。

後半で説明したやや複雑な部分もありはしますが、ロジック自体は簡潔かと思います。

ちなみにこのようなCustom Bindingを作ると、例えばPCでのマウスのクリックとスマホでのタップ操作の切り替えをInput System側だけで完結させることができ、入力を利用する側ではデバイスの差異を気にすることなる処理をすることができるようになります。

2. Touchscreenの仮想Button入力

次はスクリーン上の仮想ボタンを押して何かのアクションをさせたいケースを想定しましょう。

スクリーン上のどこかに円形のボタンがあり、その領域内を指で押す判定を取りたいのですが、既存のBindingsでは領域を絞った入力を取ることは難しいです。

そのため、やはりこのケースでもInputCompositeBindingで自作をする必要があります。

1と同じくコアな部分の実装例だけ見ていきましょう。

...
        // Please input path:"<Touchscreen>/touch*/press" to update value
        [InputControl(layout = "Button")] public int any;

        public Vector2 centerPosition = Vector2.zero;
        public float radius = 1f;

        public override float ReadValue(ref InputBindingCompositeContext context)
        {
            var touches = Touchscreen.current.touches;
            for (var index = 0; index <= 9; index++)
            {
                var touch = touches[index];
                if (IsEffectiveTouch(touch))
                {
                    return touch.press.ReadValue();
                }
            }

            return 0f;
        }

        private static bool IsEffectiveTouch(TouchControl touch)
        {
            return touch.isInProgress
                   && IsInsideOfCircle(touch.position.ReadValue(), centerPosition, radius);
        }

        public static bool IsInsideOfCircle(Vector2 touchPosition, Vector2 centerPosition, float radius)
        {
            return Vector2.SqrMagnitude(touchPosition - centerPosition) <= radius * radius;
        }

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

まず一番重要な ReadValue(...) から見ていきます。

TouchControlの配列を取得して、0から順番に見ているのは変わりません。

そして IsEffectiveTouch(...) がtrueになるTouchControlを探して、その TouchControl.press の値を返すという処理をしています。

基本的な考え方はTap位置の取得の時と変わりませんね。

大事なのは、private static bool IsEffectiveTouch(TouchControl touch)の判定部分です。

一行目の TouchControl.isInProgress はそのTouchが今何かしらの入力状態にあるかどうか(厳密にはTouchPhaseがBegan/Moved/Stationaryのいずれかにあるかどうか)をチェックしています。

二行目の IsInsideOfCircle(...) でTouch位置が円形のボタン位置の内側にあるかどうかをチェックしています。

ちなみに意味合いとしては Vector2.Distance(...) のほうが直感的なのですが、内部で平方根の計算をしているため処理が少し重いため、代わりに Vector2.SqrMagnitude(...) を使用するほうがパフォーマンスが良いです。

[InputControl(layout = "Button")] public int any;EvaluateMagnitude(...) の実装は1の場合と同じですね。

注意事項もいくつかあります。

まずさせたい振る舞いがButtonなのでboolを入力として取り扱いたくなるのですが、あくまで 0~1の値を取る float を指定し、Actionに設定する際にInteractionsにPressを設定することでButtonの振る舞いをさせることができます。

また、Buttonの位置をcenterPositionradiusで指定していますが、GUIを出したい場合uGUI×EventSystemで扱う場合と違ってGUIの配置と一致しているとは限らないため、Binding側での判定領域とGUIの配置の整合性を自分で保障してあげる必要があります。

決め打ちなら何も問題はないのですが、実行時にSafeAreaを考慮するなどしたい場合はBinding側に位置の計算ロジックを持たせて、GUI側がそれを見てuGUIを動かすなどしてあげるとよいかもしれません。

また他の入力操作との重複を避ける必要もあると思いますのでご注意ください。

...

以上がスクリーン上での仮想Button入力の取得の実装例とその説明になります。

こちらも1でお作法が分かってしまえばシンプルなロジックで実装できることが分かるかと思います。

また、このBindingを使用すると、PCでのキー入力とスマホでのボタン操作の切り替えを容易に実装できることが分かるかと思います。

実装の注意点のまとめ

ロジック以外の部分で実装時に注意すべき項目を整理しておきます。

  • 基本的なお作法に則っているか?
    • これは前回の記事で紹介した範囲です
  • <T>の指定は適切か?
    • ActionAssetで思った通りに設定できるか試してみれば分かると思いますが、Actionの取り扱う型とCompositeBindings<T><T>を一致させる必要があります
    • 特にButtonはboolではなくfloatです
  • [InputControl] public int ***を定義し、ActionAssetで適切に設定できているか?
    • ロジックで使用しなくても必要です
    • 普通に利用する際は、型の指定がButtonなどの文字列である点に注意してください
  • EvaluateMagnitude(...)のoverrideは実装しているか?
    • 実装の有無で振る舞いが変わります
    • なぜか実装しないほうが良いケースもあるため注意してください
  • GUIとの整合性は取れているか?
    • EventSystemを経由しないため、実行時に調整をする際には気を付けてあげる必要があります

おわりに

InputCompositeBindingは特に低レベルのAPIしかないTouchまわりでは有効なのですが、まだまだ初見では分かりづらいポイントや落とし穴があり、とっつきづらい部分も多いです。

そもそも他デバイスとの共通化をまったく考えなくてもいいのであれば、Actionをただの入力として扱い、Actionを利用する側に入力処理のロジックを持たせればわざわざこのような実装はしなくても済みます。

ですが何度も説明しているように、逆にうまく使いこなすことができればデバイスの差異をうまくInputSystem側で吸収することができ、アプリケーション全体の設計として綺麗にまとまりやすくなると思います。

現在はこうやって自作する必要がありますが、将来的にTouchのジェスチャー系の実装の予定もあるとか聞きますし*1、もっと手軽に扱えるようになるかもしれませんね。

それを待たずともこのあたりの処理を汎用化してライブラリにしてあげるといいのかもしれませんが、特に入力領域を指定する場合に入力処理同士の重複を避ける処理がなかなか汎用化しづらいのが難点です。

今回はTouchでの応用例のみ紹介しましたが、もちろんそれ以外でも標準のBindingsでは欲しい形で入力を取得できない場合には有用ですので、困ったときには使用を検討してみるとよいかと思います。

以上、前回の記事と併せてInputCompositeBindingを使ってみたい方の参考になれば幸いです。

*1:> 新しいInput Systemは現時点では基本的なタッチ入力しかサポートしていませんが、将来的にはジェスチャー入力等より高度な表現にも対応する予定とのことです。> https://forpro.unity3d.jp/unity_pro_tips/2021/05/20/1957/