Help us understand the problem. What is going on with this article?

空中に図形を描いて物体を生成するアプリを作ってみた

はじめに

OculusQuest によって、(正しくはもっともっと前からでしょうけども)VR空間に「手」が持ち込めるようになりました。
OculusQuest購入以前はGearVR開発しかまともにしたことのなかった僕からするとそれはとんでもない衝撃です。
何でもできる!なんでもできるじゃないか!(何でもではない)

感動のあまり、何番煎じかわかりませんが、このようなアプリを作ってみました。

というわけで、実行ファイル(APK)と解説になります。
APKファイルはこちらです。 https://github.com/divide-by-zero/divide-by-zero.github.io/releases
OculusQuestFirstStep.apk をダウンロードして、よしなにお手元のOculusQuestに入れてください。
なお、野良APKファイルをインストールするのには

①OculusQuestを開発者モードにする
②ADB.exeの準備
③APKファイルをADBコマンドで直接インストール

等々が必要です。要らぬハードル・・・!

あんまりお勧めして良いかわかりませんが、 sidequest (参考: https://vr-maniacs.com/entry/sidequest/ )なんかが全部をカバーしてるんじゃないかなぁ。どうかなぁ(モニョモニョ


ここから↓は技術解説になります。 興味がない方はお疲れ様でした。

流れ

ハンドコントローラが作る軌跡の頂点生データを加工していって、最終的に図形を判断できるぐらいまで情報を加工・そぎ落としていきます。

①コントローラーの毎フレームの3次元位置(Vector3[])をShapePointListに追加(Add)

②手ブレを加味した3次元位置(ShapePoint)に

③ShapePointが角かどうか判断(isCorner)

④角(ShapeCorner)のリスト作成

⑤各ShapeCornerの数や前後関係から図形認識情報返却(ResolveShapeInfo:位置・向き(法線)・横幅縦幅)

⑥図形認識情報(ResolverShapeInfo)に従い、prefab生成などを行う

こんな流れです。

①コントローラーの毎フレームの3次元位置(Vector3[])

まず
トリガーを押している間だけ、コントローラの位置を取得していきます。

shapeDetect.cs
            if (OVRInput.Get(OVRInput.RawButton.RIndexTrigger)) //GETがHOLD、GETDOWNが押した瞬間GetUpが離した瞬間
            {
                if (list.AddPoint(_targetTransform.position))
                {

そして、人間、何の指標も無しに空中でキレイに四角とか描けないので、実際にはLineRendererを使って実線を引いてます。

②手ブレを加味した3次元位置(ShapePoint)を集めたリスト(ShapePointList)作成

やってみると分かるのですが、人間、空中でピタっと手を止めているつもりでも実際のところ数センチレベルで手がプルプルしちゃいます。手ブレですね。
なので、「前回位置から、今回位置の移動(currentVecがごく小さい場合は、頂点とみなさない」ルールにしました。

ShapePointList.cs
                var currentVec = (p - lastAddPoint.Pos);
                if (currentVec.sqrMagnitude <= _nearPointDetectValue)
                {
                    lastAddPoint.AddNearPointCount();
                    return false;
                }

なお、デバッグ情報の意味を込めて頂点を追加しない場合は前回ShapePointのNearCountを足していくようにしています。
何かに利用できるかな。と思って作ったプロパティですが、今の所出番がありません。不憫な子。

③図形認識の頂点(角)の3次元位置(ShapeCorner)認識

さて。コントローラーが描く軌跡の頂点を若干削りましたが、本当に欲しいのは「角」です。
「角」とは「急に進行方向が変わった所」です。
今までの頂点の並びから、軌跡がどの方向に進んでいるかを求め(lineVec)
それに対して、今回の線(前回頂点から今回頂点のベクトル:currentVec)はどれぐらい沿っているかを求めます。

この二つのベクトルがどれぐらい似通っているかを表すのは。そう 内積です。

ベクトルの内積は直角(一番似通っていない)場合は0
全く同じ場合は+1
全く逆方向の場合は-1です

「なんとなくまっすぐっぽかったらまっすぐだとみなす」なので、具体的な数値は要調整ですが今回のアプリ内では "0.8f" にしました。一応角度に直すと 36.8698976°です。 結構曲がってても「直線」と見なし、それ以上に曲がっている場合は「角」としています。

ShapePointList.cs
                var isCorner = false;

                //前回の角からのベクトル足しこみ
                lineVec += currentVec;

                //角度チェック
                var dot = Vector3.Dot(lineVec.normalized, currentVec.normalized);

                if (dot <= _cornerDetectDot)
                {

よし、これで良いじゃないか。
と思うんですが、もう一つ問題があります。

ここで有名な標語を貴方に
「危ないよ車は急に止まれない」

例えば手を左から右に水平に動かして、あるポイントで急に真下に振ろうとしても、大体真下にはいきません
というか、大体の人はそこで一旦ブレーキをかけます。 今まで水平に動かしていたべクトルに真反対のベクトルをぶつけ、相殺させて一瞬止め、そこから手を下におろしているはずです(大抵は無意識なはず)

なので、例えば先ほどの「左から右に水平に動かし、90度真下へ」の角近辺のベクトルの流れは
①→
②←
③↓
の3つがグチャグチャします。
しかもブレーキかけすぎたり(戻し過ぎたり)ブレーキが足りなかったり、そして無意識にそれを補正したりするので、「角」と認識した後しばらくは数値上は「角」だとしても意図してないことがほとんどです。
なので、もう一つ条件
「前の頂点(Point)からの距離が想定より短い場合は角とみなさない」
を追加しています。(今回は5cmにしました。)

ShapePointList.cs
                    //長さが足りない場合は、角近辺でごちゃごちゃしてる可能性
                    if (lineVec.sqrMagnitude > _lineDetectValue)
                    {
                        isCorner = true;
                        lineVec = currentVec;
                    }

なお、そのままベクトル間の距離を取得して5cm(Unityは単位メートルなので、0.05f)と比較しても良いですが、ベクトルの長さ(magnitude)は内部的には平方根が使われます(三平方の定理より)そのためそれなりに計算コストがかかるため、sqrMagnitudeを使って比較を行っています。
 こっちのプロパティは、ベクトル間の距離の二乗が入っており(平方根を使っていない)大小比較だけなら同じように比較対象である5cm(0.05f)を二乗してあげれば良いです。若干の高速化小細工でした。

以下、ハンドコントローラの頂点情報を毎フレーム渡して、角検出している所全体像(抜粋)です

ShapePointList.cs
    public class ShapePointList
    {
        private float _nearPointDetectValue;
        private float _lineDetectValue;
        private float _cornerDetectDot;

        private ShapePoint lastAddPoint;
        private Vector3 lineVec;//現在描いてる線の偏向
        private List<ShapePoint> _points = new List<ShapePoint>();

        public ShapePointList()
        {
            _nearPointDetectValue = 0.01f * 0.01f; //1cm以下は手の震えとみなす
            _lineDetectValue = 0.05f * 0.05f;   //直線は5cmぐらいは欲しい
            _cornerDetectDot = 0.8f;
        }

        public bool AddPoint(Vector3 p)
        {
            if (lastAddPoint == null)
            {
                lastAddPoint = new ShapePoint(p, true);
                lineVec = Vector3.zero;
            }
            else
            {
                var currentVec = (p - lastAddPoint.Pos);
                if (currentVec.sqrMagnitude <= _nearPointDetectValue)
                {
                    lastAddPoint.AddNearPointCount();
                    return false;
                }

                var isCorner = false;

                //前回の角からのベクトル足しこみ
                lineVec += currentVec;

                //角度チェック
                var dot = Vector3.Dot(lineVec.normalized, currentVec.normalized);

                if (dot <= _cornerDetectDot)
                {
                    //長さが足りない場合は、角近辺でごちゃごちゃしてる可能性
                    if (lineVec.sqrMagnitude > _lineDetectValue)
                    {
                        isCorner = true;
                        lineVec = currentVec;
                    }
                }

                lastAddPoint = new ShapePoint(p, isCorner);
            }
            _points.Add(lastAddPoint);
            return true;
        }
    }

紆余曲折あり、大分時間をかけた箇所なんですが、こう見るとシンプルですね。

④角(ShapeCorner)のリスト作成

自分の納得のいく線が描けたら、トリガーを離します。このタイミングで一旦ShapeListを締めます(Finalized())

ShapeDetect.cs
            if (OVRInput.GetUp(OVRInput.RawButton.RIndexTrigger))
            {
                //頂点を確定
                list.Finalized();

Finalized() では、収集した頂点(ShapePoint)の角(isCorner)フラグが立っているものから、ShapeCorner の配列を作っています。

ShapePointList.cs
        private bool isFinalize = false;
        private ShapeCorner[] corners;
        public void Finalized()
        {
            var corners = _points.Where(point => point.IsCorner).ToArray();
            if (corners.Length <= 2) return;    //Shape作りようがない
            isFinalize = true;
            //配列範囲外が巡回するように小細工
            Corners = corners.Select((point, i) => new ShapeCorner(corners[(i + corners.Length - 1) % corners.Length], point, corners[(i + 1) % corners.Length])).ToArray();
        }

角(ShapeCorner)は、「前の角、今の角、次の角」を持たせています。これにより、角の向き(法線)と、角度が求められます。

なお、仮に4角形の場合ShapeCornerは、0,1,2 で一つ、 1,2,3 で一つ、 2,3,0 で一つ、3,0,1 で一つです。
このように配列が巡回する場合、if文書いたり、三項演算子使ったりしますが、補数余算によって、プラス方向にもマイナス方向にも巡回するようにしています。(暇な人はどうなっているかじっくり見ても面白いかもです)

⑤各ShapeCornerの数や前後関係から図形認識情報返却(ResolveShapeInfo:位置・向き(法線)・横幅縦幅)

ここから後は大分力業です。
ShapeCornerの配列を見て(PointListがShapeCornerの配列を持っているので、それを渡す)、それがどんな形なのか四角形なのか、星型なのか、リボン型なのかを泥臭く検出します(円は特殊)。

例えば、4角形の検出は

  • 角が4つであること
  • すべての角が90度 と言いたいが、遊びを持たせて90±20度 であること

で、検出してます。

BoxShapeResolver.cs
        public ResolveShapeInfo Check(ShapePointList pointList)
        {
            if (pointList.Corners == null) return null;
            if (pointList.Corners.Length != 4) return null; //そもそも頂点が足りない

            //角度が90度±20のものだけを配列化
            var corners = pointList.Corners.Where(corner => Math.Abs(corner.Angle - 90) < angleThreshould).ToArray();

            //4つ以外なら4角形ではない
            if (corners.Length != 4) return null;

            //中心点計算
            var centerPos = corners.Select(corner => corner.CurrentPoint.Pos).Average();

            //向き(法線)計算
            var normal = corners.Select(corner => corner.Normal).Sum().normalized;

            //四角形のどこから描き出すかわからないし、ユーザーがどの方向を向いてい描いたかもわからないので、ともかく決め打ちで(0-1,3-2)を右方向と(3-0,2-1)を上方向のベクトルと見なす
            var rightVec = ((corners[1].CurrentPoint.Pos - corners[0].CurrentPoint.Pos) + (corners[2].CurrentPoint.Pos - corners[3].CurrentPoint.Pos)) * 0.5f;
            var upVec = ((corners[0].CurrentPoint.Pos - corners[3].CurrentPoint.Pos) + (corners[1].CurrentPoint.Pos - corners[2].CurrentPoint.Pos)) * 0.5f;

            var scaleX = rightVec.magnitude;
            var scaleY = upVec.magnitude;

            return new ResolveShapeInfo(
                centerPos,                                                  //中心
                new Vector3(scaleX, scaleY, Mathf.Min(scaleX , scaleY) ),   //奥行きは適当。今回は横幅と縦幅の小さいほうにしている
                normal,                                                     //描いた図形の大体の向き
                Quaternion.LookRotation(normal, upVec.normalized)           //法線方向に向かうQuaternion取得 上方向ベクトルは ↑ の決め打ちで作ったupVec
            );
        }

なお、認識させたい図形はこれから増えていくかもしれません。 なので、

IShapeResolver.cs
    public interface IShapeResolver
    {
        /// <summary>
        /// 名前
        /// </summary>
        string Name { get; }

        /// <summary>
        /// 判定 解決出来ない場合はnullを返却
        /// </summary>
        /// <returns></returns>
        ResolveShapeInfo Check(ShapePointList set);
    }

こんな感じでinterfaceを作っておいて、認識したい図形毎にこのinterfaceを実装したクラスを作っています。
既出の四角形検出も実際にはIShapeResolverを実装してますし、
星(五芒星)型検出クラスもこんな感じです。

StarShapeResolver.cs
    public class StarShapeResolver : IShapeResolver
    {
        public string Name => "星";

        private float angleThreshould = 20;

        public ResolveShapeInfo Check(ShapePointList pointList)
        {
            if (pointList.Corners == null) return null;
            if (pointList.Corners.Length < 5) return null; //そもそも頂点が足りない
            if (Math.Abs(pointList.Corners.Sum(corner => corner.Angle) - 180) > angleThreshould) return null;

            var corners = pointList.Corners.ToArray();

            var centerPos = corners.Select(corner => corner.CurrentPoint.Pos).Average();
            var normal = corners.Select(corner => corner.Normal).Sum().normalized;

            return new ResolveShapeInfo(centerPos, Vector3.one, normal, Quaternion.identity);
        }
    }

ちなみに「五芒星」は内角の和が180度になるので、

  • 角が5つであること
  • すべての角の和が180度 と言いたいが、遊びを持たせて180±20度 であること

で、検出してます。
(星はメテオなので、モデルの向きやサイズを取る必要がなかったのでちょっと手抜きです)


後はこの IShapeResolver を実装した複数クラスをまとめた ShapeDetectService クラスを作ってあげて(Facadeパターン??)、検出されたResolveShapeInfo に従いPrefabをInstantiateしているだけです。

ShapeDetectService.cs
    public class ShapeDetectService
    {
        private Dictionary<ShapeType, IShapeResolver> _resolvers;

        public ShapeDetectService()
        {
            _resolvers = new Dictionary<ShapeType, IShapeResolver>{
                {ShapeType.BOX, new BoxShapeResolver()},
                {ShapeType.STAR, new StarShapeResolver()},
                {ShapeType.RIBBON, new RibbonShapeResolver()},
                {ShapeType.CROSS, new CrossShapeResolver()},
                {ShapeType.CIRCLE, new CircleShapeResolver()}
            };
        }

        public bool Check(ShapePointList pointList, Action<ShapeType, IShapeResolver, ResolveShapeInfo> callback)
        {
            ResolveShapeInfo shape = null;
            foreach (var shapeResolver in _resolvers)
            {
                shape = shapeResolver.Value.Check(pointList);
                if (shape != null)
                {
                    callback?.Invoke(shapeResolver.Key,shapeResolver.Value,shape);
                    return true;
                }
            }
            return false;
        }
    }

簡単ですね!

おまけ

複数の Vector3合計平均が取りたい場合、Linqで行けるかと思いきや出来ないんですよね。(加算オペレータがオーバーライドされているかどうかは知る由もないため???)
まぁ合計は .Aggregate(Vector3.zero, (v1, v2) => v1 + v2); ってやっても良いと言えば良いんですが。直感的ではないので、こんな拡張クラスを作っておくとちょっと便利です。

Vector3Extensions.cs
    public static class Vector3Extensions
    {
        public static Vector3 Average(this IEnumerable<Vector3> vectors)
        {
            return vectors.Sum() / vectors.Count();
        }

        public static Vector3 Sum(this IEnumerable<Vector3> vectors)
        {
            var sum = Vector3.zero;
            foreach (var v in vectors)
            {
                sum += v;
            }
            return sum;
        }
    }

あとがき

本当はソースコードもgithubでさらそうかとも思ったんですが、生成時の演出用にうっかりDoTween Pro入れてしまって、抜くの面倒なのでソース公開は無しで・・・。
まぁコアな部分は大体書いてしまったし、需要もそんなにないでしょう。
もし要望があれば直接私までどうぞ。

しかし。こんな素敵デバイスが400$って言うんだからすごいですよね!(日本円を避ける事で安く見せる作戦)
今年こそ、今年こそ真のVR元年かもしれません。 

ではでは。

スペシャルサンクス

今回の検出処理実装にあたり、Unityゲーム開発者ギルド( https://scrapbox.io/unity-game-dev-guild-pr/Unity%E3%82%B2%E3%83%BC%E3%83%A0%E9%96%8B%E7%99%BA%E8%80%85%E3%82%AE%E3%83%AB%E3%83%89 )の皆様(naichi様・ドリフターズ / 神の詰将棋様)のご協力を頂きました。 まったく最高のギルドだぜ。

divideby_zero
プログラマやったり、専門学校教員やったり、ゲーム業界叩いてみたりしましたが、結局またプログラマやってます。 XamarinとUnityが好き。というよりC#が好きっぽい。
unity-game-dev-guild
趣味・仕事問わずUnityでゲームを作っている開発者のみで構成されるオンラインコミュニティです。Unityでゲームを開発・運用するにあたって必要なあらゆる知見を共有することを目的とします。
https://unity-game-dev-guild.github.io/
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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした