2022年05月08日

AR仮想分度器における操作性の工夫について

AR仮想分度器は、iOSのAR(拡張現実)技術を使って物体の表面に仮想分度器を置くことができるアプリです。
AR vProtractor
(実際の画面表示は日本語です)
水平方向の分度器は画面を横方向になぞると回転させることができ、垂直方向の分度器は縦方向になぞると傾けることができます。
2本指でなぞると空間内で前後左右に移動させることができ、ピンチイン・アウトで拡大縮小できます。
3本指で縦方向に画面をなぞると垂直方向に移動させることができます。
枠(グリッド)を表示させると机の端や部屋の端に方向を合わせるのに便利です。
分度器の位置はARアンカーという機能を使って空間上の位置を安定させていますが、他の部屋などに移動した時にARアンカーを置き直すためには「置き直す」ボタンを使います。

このアプリを作るにあたって、ARの操作性に注意を払いました。
例えば、ボタンを押す回数を最小限にする、なるべく直感的に使えるようにする、などです。
そのための技術的な工夫を以下に説明します。開発環境はUnityとAR Foundationです。そのため、以下の説明はUnity用のC#の説明になります。

まず、画面を縦横になぞった時に横は回転、縦は傾きと使い分けています。前のバージョンを作った時に、指を縦方向または横方向にだけ動かしているつもりでも、わずかにもう一つの方向も認識してしまい、操作性が悪いと感じていました。よくあるスワイプのC#スクリプトに少し手を加えることで、この問題を抑制することができました。
void Update () {
    if (Input.touchCount == 1) { //1本指でpanとtilt
          float dx = Input.touches[0].deltaPosition.x;
          float dy = Input.touches[0].deltaPosition.y;
          if (Mathf.Abs(dx) > Mathf.Abs(dy)){
                    dy = 0;
            } else {
                    dx = 0;
            }
          m_Protractor.transform.Rotate(new Vector3(0, 0, -0.1f*dy)); //Tilt
          PanAxis.transform.Rotate(new Vector3(0, -0.1f*dx, 0)); //Pan
  }

何をやったかと言うと、縦方向と横方向の動きのうち、小さい方を0にして無視するようにしました。この状態で画面を縦または横方向になぞると、思った方向に気持ちよく追従してくれます。
次に、縦方向の傾きは0の状態で使うことが多いのですが、いったん0以外の状態になった時にぴったり0に戻すのが面倒でした。これにも修正を加えました。
void Update () {
    if (Input.touchCount == 1) { //1本指でpanとtilt
          float dx = Input.touches[0].deltaPosition.x;
          float dy = Input.touches[0].deltaPosition.y;
          if (Mathf.Abs(dx) > Mathf.Abs(dy)){
               dy = 0;
        } else {
               dx = 0;
        }
          m_Protractor.transform.Rotate(new Vector3(0, 0, -0.1f*dy)); //Tilt
          PanAxis.transform.Rotate(new Vector3(0, -0.1f*dx, 0)); //Pan
          float tilt = Vector3.Angle(ProtractorOrigin.transform.up, m_Protractor.transform.up);
          if (Vector3.Angle(ProtractorOrigin.transform.up, m_Protractor.transform.right) > 90) { tilt = - tilt;}
          if (Mathf.Abs(tilt) < 1) { //ゆっくり動かすと0で止まる
               Vector3 tmp;
               tmp = m_Protractor.transform.eulerAngles;
               tmp.z = 0;
               m_Protractor.transform.eulerAngles = tmp; //成分は代入できないがVector3は代入できる
            }
          if (tilt > 90) {
               m_Protractor.transform.Rotate(new Vector3(0, 0, 0.1f*dy));
            }
          if (tilt < -90) {
               m_Protractor.transform.Rotate(new Vector3(0, 0, 0.1f*dy));
            }
    }
  }

傾きはZ軸まわりの回転なのですが、これが基準となる方向から±1以内になったときに自動的に0になるようにしました。画面をゆっくりなぞって0に近づくと、ピタッと吸い付くように0になります。この判定は1フレームごとに行われるため、ある程度の速度でくるくる回していると±1以内に止まる確率は低くなるため、0を通過しても止まりません。仮に0になったとしても1/30秒後には次の位置に移動してしまうので、気が付きもしないでしょう。結果として、画面を速くなぞった時とゆっくりなぞった時で応答が違うことになりますが、これがまた使いやすく手に馴染む感じで心地よい操作性になりました。

次に、2本指の操作です。2本指スクロールとピンチイン・アウトがありますが、当初は移動しようとすると大きさも変わってしまったり、大きさを変えようとすると位置も動いたりしてしまっていました。この二つの操作を見分けるためにはどうしたらいいでしょうか。移動するときは2本の指のタッチ位置はおおむね同じ方向に移動します。ピンチイン・アウトのときは、2本の指がおおむね逆の方向に移動します(ピンチインでもピンチアウトでも同じ)。この「おおむね」の閾値として90を採用しました。1本目の指と2本目の指の移動方向のベクトルが90度以上かどうかを判定するために、ベクトルの内積を使いました。ベクトルの内積はベクトルの各成分の積を足すだけなので、演算量が非常に少なくて済み、内積が正の値なら2つのベクトルの間の角は90度以内であり、おおむね同じ方向に移動しているとみなすことができます。
画面縦方向の指の移動を対象となるオブジェクトの移動に反映させるため、カメラの視線方向を利用します。つまりUnityのカメラの属性からCamera.main.transform.forwardを取り出し、これを地面に投影したベクトルを準備します。
Vector3.ProjectOnPlane (Camera.main.transform.forward, Vector3.up).normalized
これと垂直方向のベクトルも用意します。
Vector3.ProjectOnPlane (Camera.main.transform.right, Vector3.up).normalized
これに画面上の指の移動量をかけてやればよいことになります。
一方、ピンチイン・アウトと判定された場合には、2本の指の移動量の差をベクトルとして取得して、拡大縮小量を算出します。
Vector2 t0Prev = Input.touches[0].position - Input.touches[0].deltaPosition;
Vector2 t1Prev = Input.touches[1].position - Input.touches[1].deltaPosition;
float prevDist = Vector2.Distance(t0Prev, t1Prev);
float currDist = Vector2.Distance(Input.touches[0].position, Input.touches[1].position);
float deltaDist = currDist / prevDist;
float m_scale = ProtractorOrigin.transform.localScale.x * deltaDist;
ちょっと泥臭いのですが、間違ってはいないでしょう。もう少し省略できそうな気もしますが、演算量に大きな差はないはずです。
傾け角を上下90度以内、拡大縮小にも制限を付け、3本指で垂直方向(Unityの世界ではY軸方向)への移動を追加すると、最終的に次のようになります。
void Update () {
    if (Input.touchCount == 1) { //1本指でpanとtilt
          float dx = Input.touches[0].deltaPosition.x;
          float dy = Input.touches[0].deltaPosition.y;
          if (Mathf.Abs(dx) > Mathf.Abs(dy)){
               dy = 0;
        } else {
               dx = 0;
        }
          m_Protractor.transform.Rotate(new Vector3(0, 0, -0.1f*dy)); //Tilt
          PanAxis.transform.Rotate(new Vector3(0, -0.1f*dx, 0)); //Pan
          float tilt = Vector3.Angle(ProtractorOrigin.transform.up, m_Protractor.transform.up);
          if (Vector3.Angle(ProtractorOrigin.transform.up, m_Protractor.transform.right) > 90) { tilt = - tilt;}
          if (Mathf.Abs(tilt) < 1) { //ゆっくり動かすと0で止まる
               Vector3 tmp;
               tmp = m_Protractor.transform.eulerAngles;
               tmp.z = 0;
               m_Protractor.transform.eulerAngles = tmp; //成分は代入できないがVector3は代入できる
        }
          if (tilt > 90) {
               m_Protractor.transform.Rotate(new Vector3(0, 0, 0.1f*dy));
        }
          if (tilt < -90) {
               m_Protractor.transform.Rotate(new Vector3(0, 0, 0.1f*dy));
        }
    } else if (Input.touchCount == 2) { //2本指でmove folizontal
          float dx = Input.touches[0].deltaPosition.x + Input.touches[1].deltaPosition.x;
          float dy = Input.touches[0].deltaPosition.y + Input.touches[1].deltaPosition.y;
          if ((Input.touches[0].deltaPosition.x * Input.touches[1].deltaPosition.x + Input.touches[0].deltaPosition.y * Input.touches[1].deltaPosition.y) > 0) {//ベクトルの内積が正つまり90度以内
          Vector3 vz = Vector3.ProjectOnPlane (Camera.main.transform.forward, Vector3.up).normalized;
          Vector3 vx = Vector3.ProjectOnPlane (Camera.main.transform.right, Vector3.up).normalized;
          ProtractorOrigin.transform.position += vz * dy * ProtractorOrigin.transform.localScale.x * 0.0001f; //空間上のz軸は画面上のy方向
          ProtractorOrigin.transform.position += vx * dx * ProtractorOrigin.transform.localScale.x * 0.0001f; //空間上のx軸方向は画面上のx方向
        } else {
//ピンチイン・ピンチアウト
          Vector2 t0Prev = Input.touches[0].position - Input.touches[0].deltaPosition;
          Vector2 t1Prev = Input.touches[1].position - Input.touches[1].deltaPosition;
          float prevDist = Vector2.Distance(t0Prev, t1Prev);
          float currDist = Vector2.Distance(Input.touches[0].position, Input.touches[1].position);
          float deltaDist = currDist / prevDist;
          float m_scale = ProtractorOrigin.transform.localScale.x * deltaDist;
          if (m_scale < 0.2) {deltaDist = 1;}//最小は2cm
          if (m_scale > 20) {deltaDist = 1;}//最大は2m
          ProtractorOrigin.transform.localScale = ProtractorOrigin.transform.localScale * deltaDist;
        }
        } else if (Input.touchCount == 3) {
          float dy = Input.touches[0].deltaPosition.y;
          Vector3 vy = Vector3.up;
          ProtractorOrigin.transform.position += vy * dy * 0.0001f; //空間上のy軸は画面上のy方向
    }
  }

macOSやiOSの操作性はマウスの移動速度やタッチへの反応が直感的で快適ですが、それを実現するためには開発側の手間はかなりのものだろうなと思っていました。今回、自作のアプリを製作するにあたり、使っていて心地よい操作性を実現するために色々な工夫をしていました。ユーザーの入力をそのまま反映させるのではなく、ユーザーが何を意図して操作しているかを考慮して制御することで、使いやすさを実現したつもりです。使いやすいAR(拡張現実)アプリケーションの普及の一助となれば幸いです。
Download on the App Store
posted by MacLab. at 22:37| Comment(0) | TrackBack(0) | 技術情報
この記事へのコメント
コメントを書く
お名前: [必須入力]

メールアドレス:

ホームページアドレス:

コメント: [必須入力]

※ブログオーナーが承認したコメントのみ表示されます。
この記事へのトラックバックURL
http://blog.sakura.ne.jp/tb/189521119
※ブログオーナーが承認したトラックバックのみ表示されます。

この記事へのトラックバック