Holographic Academy 101と210をやってみた

投稿者: | 2017-02-14

遊んでますよ、HoloLens。
これ、すっごく楽しいです、未来が未来じゃなくなりました。

HoloLensってなに?という方は以下の記事を見てみるといいと思います。
日本でHoloLens関連の会社を立ち上げた、第一人者とも言える中島さんの記事です。

HoloLens向けアプリも少しずつ充実しており、私も何かしらコンテンツを作りたいなあ、と思っています。Microsoftではそんな開発者向けにドキュメントやチュートリアルを公開しております。

Windows Holographic Document
Holographic Academy

特にチュートリアルは初心者向けにわかりやすく作られており、HoloLens向けアプリを作る際に有用なテクニックが多く盛り込まれています。私はまだすべて実施したわけではないのですが、今回はHolograms 101Holograms 210を実施した感想?的なものを記事としてまとめたいと思います。

なお、Holographic Academyの日本語解説についてはRiftupさん(@WheetTweet)の記事がおすすめです。

とてもわかりやすい解説になっています。ですので本記事では私がAcademyを読んでいて疑問に思ったこと、特に3Dプログラミングは初心者もいいところなので、ソースを読んでわからなかった部分を調べた結果の備忘録、落穂拾い的な内容になっています。是非ともRiftupさんの記事を読んでいただいてから見ていただくのがよいかと思います。(何という丸投げ…)

101 Chapter3:GestureRecognizerの再初期化ってなんで必要なの?

101 Chapter 3はジェスチャー操作についてのチュートリアルになります。
Unity5.5からGestureRecognizerというAPIが実装されました。そのAPIを通じてジェスチャー認識、認識時の動作を実装しています。

using UnityEngine;
using UnityEngine.VR.WSA.Input;

public class GazeGestureManager : MonoBehaviour
{
    public static GazeGestureManager Instance { get; private set; }

    public GameObject FocusedObject { get; private set; }

    GestureRecognizer recognizer;

    void Awake()
    {
        Instance = this;

        recognizer = new GestureRecognizer();
        recognizer.TappedEvent += (source, tapCount, ray) =>
        {
            if (FocusedObject != null)
            {
                FocusedObject.SendMessageUpwards("OnSelect");
            }
        };
        recognizer.StartCapturingGestures();
    }

    void Update()
    {
        GameObject oldFocusObject = FocusedObject;

        var headPosition = Camera.main.transform.position;
        var gazeDirection = Camera.main.transform.forward;

        RaycastHit hitInfo;
        if (Physics.Raycast(headPosition, gazeDirection, out hitInfo))
        {
            FocusedObject = hitInfo.collider.gameObject;
        }
        else
        {
            FocusedObject = null;
        }

        if (FocusedObject != oldFocusObject)
        {
            recognizer.CancelGestures();
            recognizer.StartCapturingGestures();
        }
    }
}

さて、私が気になったのは44~48行目の部分です。

        if (FocusedObject != oldFocusObject)
        {
            recognizer.CancelGestures();
            recognizer.StartCapturingGestures();
        }

注視しているオブジェクトが変化したら、いったんGestureRecognizerをリセット(CancelGesture)して、すぐさま認識を再開(StartCapturingGesture)をしています。どうしてこのようなことをしているのでしょうか?

まずは公式リファレンスから、CancelGesturesの内容を確認してみたところ、以下のように書かれていました。

“Cancels any pending gesture events. Additionally this will call StopCapturingGestures.”

どうやらpending gestureというのがキーになりそうです。いろいろと実験してみて何となく推測したのですが、pending gestureというのはジェスチャーの種別が判定できていない状態を指しているのだと思います。

例えば、AirTapが「指を倒してから0.5秒以内に指を持ち上げる」、Holdが「指を1.0秒以上倒す」と定義されていた場合、「指を倒してから0.3秒が経過した」状態は、AirTapなのかHoldを判定することができません。この状態がpending gestureです。この状態をクリアし、いったんジェスチャー認識を停止するのがCancelCapturingGestureです。

CancelCapturingGestureの動作がわかったところで、仮に44~48行目の処理が実装されていない場合、どのような動作になるのかを考えてみます。

シーン上に2つのオブジェクト、AとBが存在しており、これらのオブジェクトをAirTapするとオブジェクトが動き出すものとします。正常に使っている限りは特に問題はありませんが、少しイレギュラーなジェスチャーをすると、不自然な動作となります。イレギュラーなジェスチャーについてですが、Aを注視している状態で指を倒し、注視をすぐにBに切り替えて指を持ち上げるとどうなるでしょう。この場合、BがAirTapされたと認識されBが動き出します。これは少し不自然な動作だと感じる方が多いのではないでしょうか。

これはGestureRecognizerはジェスチャー操作の対象となるオブジェクトは保持しておらず、あくまでもジェスチャーの認識だけの役割をもったクラスです。ですので、例え注視しているオブジェクトが変化したとしても、GestureRecognizerは関知しないので、そのままジェスチャーの認識、判定を継続します。したがってジェスチャー操作の対象はスクリプト側で管理してやる必要があります。(今回の場合だとFocusedObjectで管理している)

上記のような不自然な動作にならないように、注視オブジェクトが変化した場合はいったんジェスチャー認識の状態をクリアする必要があるため、44~48行目のような実装になっているのだと思います。

210 Chapter4:Directional Indicatorのpositionとrotationはどうやって計算しているの?

Academy 210 GazeのChapter4では、ユーザに見てもらいたいオブジェクトが空間上のどこにあるのか、注視カーソルの周りにインジケーターが表示されます。

この青いインジケーターは円錐形の3Dオブジェクトなのですが、アプリからみると円錐の頂点は常に見てもらいたいオブジェクトを示し、またカメラには円錐の側面が常に映るように位置と回転具合が制御されています。これはどのように制御されているのか気になったので調べてみました。

インジケーターはDirectionIndicator.csによって制御されています。このスクリプトを見てもらいたいオブジェクト(=ターゲットオブジェクト)にアタッチすることによって、インジケーターが動作します。まずはUpdate周りを確認し、私の解釈をコメントに記載しました。

public void Update()
{
    if (DirectionIndicatorObject == null)
    {
        return;
    }
    // カメラとターゲットオブジェクト間の距離を表すベクトルを計算する
    Vector3 camToObjectDirection = gameObject.transform.position - Camera.main.transform.position
    // 計算したベクトルを正規化する
    camToObjectDirection.Normalize();
    // インジケーターを表示するかどうかのフラグをセットする
    isDirectionIndicatorVisible = !IsTargetVisible();
    // フラグが立っていればインジケーターを表示する
    directionIndicatorRenderer.enabled = isDirectionIndicatorVisible;

    if (isDirectionIndicatorVisible)
    {
        Vector3 position;
        Quaternion rotation;
        // インジケーターの位置と回転を計算する
        GetDirectionIndicatorPositionAndRotation(
            camToObjectDirection,
            out position,
            out rotation);
        // 計算した位置と回転をインジケーターにセットする
        DirectionIndicatorObject.transform.position = position;
        DirectionIndicatorObject.transform.rotation = rotation;
     }
}

private bool IsTargetVisible()
{
    // ターゲットオブジェクトの位置をワールド座標系からカメラのビューポート座標系に変換する
    Vector3 targetViewportPosition = Camera.main.WorldToViewportPoint(gameObject.transform.position);
    // ターゲットオブジェクトがカメラの撮影範囲内に存在する場合はtrueを返す
    return (targetViewportPosition.x > TitleSafeFactor && targetViewportPosition.x < 1 - TitleSafeFactor &&
        targetViewportPosition.y > TitleSafeFactor && targetViewportPosition.y < 1 - TitleSafeFactor &&
        targetViewportPosition.z > 0);
}

インジケーターの位置と回転を決めているのはGetDirectionIndicatorPositionAndRotationメソッドであることがわかります。

private void GetDirectionIndicatorPositionAndRotation(
    Vector3 camToObjectDirection,
    out Vector3 position,
    out Quaternion rotation)
{
    float metersFromCursor = 0.3f;

    Vector3 origin = Cursor.transform.position;
    // カメラの正面方向(カメラのオブジェクト空間のz軸方向=Camera.main,transform.forward)に
    // 垂直な平面に、カメラ-ターゲットオブジェクト間の距離ベクトルを投影する
    Vector3 cursorIndicatorDirection = Vector3.ProjectOnPlane(camToObjectDirection, -1 * Camera.main.transform.forward);
    // 投影したベクトルを正規化する
    cursorIndicatorDirection.Normalize();

    if (cursorIndicatorDirection == Vector3.zero)
    {
        cursorIndicatorDirection = Camera.main.transform.right;
    }
    // インジケーターの位置を計算する
    // インジケーターはカーソルオブジェクトを中心とした、半径0.3(=metersFromCursor)の円周上に位置することとなります
    // 前述の投影ベクトルがカーソルの位置座標に加算されているので、
    // 円周上で最もターゲットオブジェクトとの距離が近くなる場所に配置されます
    position = origin + cursorIndicatorDirection * metersFromCursor;

    // インジケーターの回転を計算する
    // インジケーターのオブジェクト空間上のz軸が常にカメラのz軸方向と並行になるように回転情報を計算する
    // また、インジケーターのy軸(=円錐の頂点)は常にターゲットオブジェクトを指すように回転情報を計算する
    rotation = Quaternion.LookRotation(
    Camera.main.transform.forward,
    cursorIndicatorDirection) * directionIndicatorDefaultRotation;
}

3Dプログラミングになじみがまだあまりない私にとってはとても勉強になりました。
このあたりから自分の数学力の足りなさを痛感し始めて、オライリーの3D数学本ゲームアプリ数学の本を読みつつサンプルを咀嚼しました。

210 Chapter6:SimpleTagalongってどういう仕組みで動いているの?

Chapter6ではユーザの視線方向に勝手についてくるオブジェクトの実装方法について解説されています。HoloLensシェルのスタート画面(という表現であっているのか?)のに近しい動作のことですね。この動作はSimpleTagalong.csで制御されています。どのような仕組みで制御されているか気になったのでソースを確認してみました。

SimpleTagalongによって制御されているオブジェクトは、常にユーザの視界の前に移動してきます。ということは毎フレームに処理が走っているはずなので、まずはFixedUpdateを見てみます。

protected virtual void FixedUpdate()
{
    // メインカメラの視錐台を取得します
    frustumPlanes = GeometryUtility.CalculateFrustumPlanes(Camera.main);
    // オブジェクトの移動先の座標
    Vector3 tagalongTargetPosition;

    if (CalculateTagalongTargetPosition(transform.position, out tagalongTargetPosition))
    {
        // CalculateTagalongTargetPositionの結果、移動が必要と判断された場合は、interpolatorに移動処理を依頼します
        interpolator.PositionPerSecond = PositionUpdateSpeed;
        interpolator.SetTargetPosition(tagalongTargetPosition);
    }
    else if (!interpolator.Running && EnforceDistance)
    {
        Ray ray = new Ray(Camera.main.transform.position, transform.position - Camera.main.transform.position);
        transform.position = ray.GetPoint(TagalongDistance);
    }
}

CalculateTagalongTargetPositionがキーのようです。メソッド名やoutパラメータから察するにオブジェクトの移動先を計算するメソッドのようです。さらにifで制御していることから、移動の要否判定も戻り値で行えるようです。それではCalculateTagalongTargetPositionの一部を見てみます。

protected virtual bool CalculateTagalongTargetPosition(Vector3 fromPosition, out Vector3 toPosition)
{
    // オブジェクトのバウンディングボックスが、カメラ視錐台の外にいるかどうか判定します
    // 外部にいる場合はneedsToMoveにtrueがセットされます
    bool needsToMove = !GeometryUtility.TestPlanesAABB(frustumPlanes, tagalongCollider.bounds);

    // オブジェクトの移動先を計算するため、まずは基準値をセットします
    // 基準値はカメラの前方方向(オブジェクト空間のz軸上)にTagaloneDistanceで設定した値だけ進んだ位置です
    toPosition = Camera.main.transform.position + Camera.main.transform.forward * TagalongDistance;

    // オブジェクトがカメラ視錐台の内部に存在する(=カメラの撮影範囲内にオブジェクトが存在する)場合はfalseを返します
    if (!needsToMove)
    {
        return false;
    }

    // 基準値を起点としたRayを生成します
    Ray ray = new Ray(toPosition, Vector3.zero);
    Plane plane = new Plane();
    float distanceOffset = 0f;

    // カメラ視錐台の左側平面とオブジェクトとの距離を計算します
    // カメラ視錐台の各平面は、視錐台内部の方向が表になります
    // したがって、左側平面との距離が負の数だった場合は、オブジェクトは平面の左側に位置しています
    // ということはカメラの撮影範囲内にオブジェクトを移動するには、オブジェクトを右側に移動しなくてはいけません
    // そこでmoveRightをtrueにセットします
    bool moveRight = frustumPlanes[frustumLeft].GetDistanceToPoint(fromPosition) < 0;
    // カメラ視錐台の右側平面についても同様の処理を行います
    bool moveLeft = frustumPlanes[frustumRight].GetDistanceToPoint(fromPosition) < 0;
    if (moveRight)
    {
       // planeに左側平面をセットし基準値を起点としたRayのdirectionを
       // カメラのオブジェクト空間の-x軸にセットします
       plane = frustumPlanes[frustumLeft];
       ray.direction = -Camera.main.transform.right;
    }
    else if (moveLeft)
    {
         plane = frustumPlanes[frustumRight];
         ray.direction = Camera.main.transform.right;
    }
    if (moveRight || moveLeft)
    {
        // Rayと平面を交差させ、交差した地点のx座標をオブジェクトの移動先のx座標とします
        plane.Raycast(ray, out distanceOffset);
        toPosition.x = ray.GetPoint(distanceOffset).x;
    }

~以下、省略~

なるほど、カメラ視錐台を使って、移動の必要があれば画面の端っこに移動させています。この後、視錐台の上下の平面についても同様の処理を行い、オブジェクトの移動先のy座標を計算しています。
(視錐台についてはUnityのリファレンスを参照したりググったりしてください)

あとはz座標だけです。

~中略~

  // z座標についてはインスペクタから設定できる値(TagalongDistance)を使用します
    ray = new Ray(Camera.main.transform.position, toPosition - Camera.main.transform.position);
    toPosition = ray.GetPoint(TagalongDistance);

    return needsToMove;
}

これでオブジェクトの移動先の位置が計算できました。

まとめ

Holographic Academy 101と210をやってみて自分が気になったところを色々試したりソースを読んで確認しました。特にオブジェクトの位置と回転の計算周りは3D数学弱者の私からすると非常に参考になる内容でした。これまで逃げていたクォータニオンやらベクトル演算と向き合うこともできました。今後もHolographic Academyをやっていく予定ですが、興味のあるGesture、Voice、Spatial mappingに絞りたいと思います。sharingはHoloLens2台ないと検証できなさそうだし…。