HologramsっぽくTransformを操作する

投稿者: | 2017-03-22

2017/3/31 追記
本記事の内容をもとに作成したprefabをgithubにアップしました。
https://github.com/dykarohora/HologramsLikeController


HoloLens用のアプリを作るにあたって、プリインアプリのHologramsのようにオブジェクトのTransformを操作したかったのですが、ネットやHoloToolKitのサンプルを調べた限りでは該当しそうなものがなかったので作ってみました。

まだ最終的にどのようなコンポーネント構成にするか、汎用性を高める工夫など、練り切れてないところもあるのですが、Transformの変形処理については一通り出来上がったので一度まとめておきます。

現在の構成と機能概要

動画の通り、操作したいオブジェクトをタップするとCube状のワイヤーフレームと各頂点にCubeが、各辺にSphereが表示されます。

ワイヤーフレーム表示されたCubeをHoldしながら手を動かすと、オブジェクトの位置を動かすことができます。同様に、辺のSphereの場合はオブジェクトが回転し、頂点のCubeの場合はオブジェクトが拡大/縮小されます。

構成としては、操作したいオブジェクトの配下に「TransformController」としてコンポーネント一式をまとめています。さらに一段下にてPosition(位置)、Rotation(回転)、Scale(拡大/縮小)とグループ分けし、その下にUIとなる各種3Dオブジェクトを格納しています。それぞれの3Dオブジェクトに各種操作を行うためのスクリプトがアタッチされています。(実際にはTransformControllerが有効化されたときに、各ControlManager配下のオブジェクトに自動でスクリプトがアタッチされるようにしています)

InputManagerを使う

今回はHoloLensへの入力を取り扱うために、HoloToolKitのInputManagerプレファブを使用しました。このInputManagerプレファブとCursorプレファブをシーンに追加するだけで、HoloLensにて手(指)の上げ下げ状態の検出や、ジェスチャーの検出ができるようになります。入力情報の取り扱い方についてですが、InputManagerが各種入力を検出するとその入力に応じたイベントが発行されます。イベントをキャッチするには、HoloToolKitで用意されているインタフェースを実装し、イベントハンドラコードを定義する必要があります(Unityのイベントシステムとほぼ同じ書き方です)。
HoloToolKitで用意されているインタフェースは以下の通りです。

IFocusable
GazeがオブジェクトにEnter/Exitしたときのハンドリング用
IHoldHandler
Holdの開始/完了したときのハンドリング用
IInputHandler
指が倒された/上げられたときのハンドリング用
IInputClickHandler
クリックされたときのハンドリング用
IManipulationHandler
マニピュレーションジェスチャーを検出したときのハンドリング用
INavigateionHandler
ナビゲーションジェスチャーを検出したときのハンドリング用
ISourceStateHandler
手を検出/ロストしたときのハンドリング用
ISpeechHandler
音声コマンド認識時のハンドリング用

また、InputManagerの中身を見てみると3つのオブジェクトで構成されていることがわかります。

EditorHandsInputはUnityエディタ上で入力をシミュレートするためのオブジェクトです。実機で開発している場合など、不要ならば無効化しておいたほうがいいと思います。

他2つのオブジェクトは、以前の記事でも触れたGestureRecognizerとInteractionManagerをラップしたようなコンポーネント(内部実装を読み切ったわけではないのですが、厳密には違います)を持つオブジェクトで、HoloToolKit内では「InputSource」と定義されています。この2つのオブジェクトにてジェスチャーと低レイヤの入力の検出を行っています。

Positionの操作

それでは本題のTransformを操作するスクリプトの中身を見ていきたいと思います。コードはgistにアップしています。まずはPosition(位置)を変更するオブジェクトです。
【PositionController】

オブジェクトの位置を操作するスクリプトは、HoloToolKitにあるHandDraggableとほぼ同一ですが、HoloLensにおけるHold操作(マウスでいうところのドラッグ操作)を実装する上で参考になったので、ここでも少し解説したいと思います。

public class PositionController : MonoBehaviour, 
                                  IInputHandler, 
                                  ISourceStateHandler
{
    // 操作対象のオブジェクト
    public GameObject target;
    // オブジェクトを動かすとき、Lerpさせるためのヘルパークラス(HoloToolKit製)
    private Interpolator interpolator;
    // Hold操作を許可するかどうかのフラグ
    public bool IsDraggingEnable = true;
    // Hold中かどうかを示すフラグ
    private bool isDragging;

~中略~
    // Hold開始時にコール
    StartDragging() { ~中略~ }
    // Hold中の場合、Update()からコール
    UpdatedDragging() { ~中略~ }
    // Hold終了時にコール
    StopDragging() { ~中略~ }
}   

Hold操作を実現するために、IInputHandlerとISourceStateHandlerを実装し、低レイヤでの入力を取り扱うようにしています。Hold系のジェスチャーイベントをキャッチできるIManipulationHandlerやINavigateionHandlerもありますが、今回は使用しませんでした(理由は後述)。

それではイベントハンドラの中身を見てみましょう。

private IInputSource currentInputSource = null;
private uint currentInputSourceId;

#region IInputHandler
// 指が倒されたときのハンドラ
public void OnInputDown(InputEventData eventData) {
    // すでにHold中の場合は終了
    if (isDragging)
        return;
    // InputSourceから手の位置を取得できるか確認する
    // 取得できない場合は終了
    if (!eventData.InputSource.SupportsInputInfo(eventData.SourceId, SupportedInputInfo.Position))
        return;
    // このHold操作のInputSourceと入力ソースIDを記録しておく
    currentInputSource = eventData.InputSource;
    // InputManagerでは、手やジェスチャーを検出するたびに、その検出対象に対してユニークなIDを発行します
    currentInputSourceId = eventData.SourceId;
    // Hold開始処理をコール
    StartDragging();
}

// 指が持ち上げられたときのハンドラ
public void OnInputUp(InputEventData eventData) {
    // Hold開始時(OnInputDown)に記録した入力ソースIDと、イベントデータのIDが一致していることを確認する
    if (currentInputSource != null && eventData.SourceId == currentInputSourceId)
        // Hold終了処理をコール
        StopDragging();
}
#endregion

#region ISourceStateHandler
// HoloLensが手を検出したときのハンドラ
public void OnSourceDetected(SourceStateEventData eventData) {
    // Nothing to do.
}

// HoloLensが手をロストしたときのハンドラ
public void OnSourceLost(SourceStateEventData eventData) {
    // Hold終了処理をコール
    if (currentInputSource != null && eventData.SourceId == currentInputSourceId)
        StopDragging();
}
#endregion

// Hold中の場合は毎フレーム更新処理を行う
private void Update() {
    if (IsDraggingEnable && isDragging)
        UpdatedDragging();
}

指が倒されたらHold開始、Hold中は毎フレーム処理を行い、指が持ち上げられる、もしくはHoloLensが指をロストしたらHold終了、といった流れとなっています。

ここで特筆すべきは12行目のif文の中身です。InputSourceには入力を検出したときのイベントデータから、入力の発生元(ここでは手)についてどのような情報が取得できるのかを確認できるメソッド、SupportsInputInfoが用意されています。ここでは手の位置についての情報が取得できるかを確認しています。低レイヤの入力を取り扱うInputSourceであるRawInteractionSourcesは手の位置情報を取得できますが、ジェスチャー系の入力を取り扱うGesturesInputからは取得できません。今回は手の位置情報を使ってオブジェクトの移動先を計算する必要があるため、ジェスチャー検出系のインタフェース(IManipulationHandler, INavigationHandler)は使用しませんでした。

ジェスチャー入力でも、Hold開始時点を基準とした、相対的な手の位置を、Hold中にVector3形式で取得できるのですが、各成分の値域が-1m~1mとなっており、1m以上移動した場合でも取得できる値は1となってしまいます。1m以上手を動かして操作するユースケースはあまりないような気がしますが、思わぬバグを作り込んでしまいそうなので、今回は使用を控えました。

続いてHold開始時の処理を見てみます。

private float objRefDistance;
private float handRefDistance;
private Vector3 objRefGrabPoint;
private Quaternion gazeAngularOffset;

private Vector3 draggingPosition;

private IInputSource currentInputSource = null;
private uint currentInputSourceId;

// Hold開始処理
public void StartDragging() {
    if (!IsDraggingEnable)
        return;
    if (isDragging)
        return;
    // 入力イベントをキャッチする対象をこのオブジェクトに固定する
    InputManager.Instance.PushModalInputHandler(gameObject);
    // Hold中フラグを立てる
    isDragging = true;
    // Gazeの衝突箇所を取得する
    Vector3 gazeHitPosition = GazeManager.Instance.HitInfo.point;
    // Hold開始時の手の位置を取得する
    Vector3 handPosition;
    currentInputSource.TryGetPosition(currentInputSourceId, out handPosition);
    // 首の位置を取得する
    Vector3 pivotPosition = GetHandPivotPosition();
    // 手と首の距離を計算する
    handRefDistance = Vector3.Magnitude(handPosition - pivotPosition);
    // Gazeの衝突箇所と首の距離を計算する
    objRefDistance  = Vector3.Magnitude(gazeHitPosition - pivotPosition);
    // Gazeの衝突箇所から移動対象の中心へのベクトルを計算する
    objRefGrabPoint = mainCamera.transform.InverseTransformDirection(target.transform.position - gazeHitPosition);
    // 首からGazeの衝突箇所への方向ベクトルを計算する
    Vector3 objDirection = Vector3.Normalize(gazeHitPosition - pivotPosition);
    // 首から手への方向ベクトルを計算する
    Vector3 handDirection = Vector3.Normalize(handPosition - pivotPosition);
    // カメラ空間系に変換する
    objDirection = mainCamera.transform.InverseTransformDirection(objDirection);
    handDirection = mainCamera.transform.InverseTransformDirection(handDirection);
    // 手からGazeの衝突箇所への回転量を計算する
    gazeAngularOffset = Quaternion.FromToRotation(handDirection, objDirection);
    
    draggingPosition = gazeHitPosition;
}

// 首の位置を取得する
private Vector3 GetHandPivotPosition() {
    // HoloLensの位置からざっくりと算出する
    return mainCamera.transform.position + new Vector3(0, -0.2f, 0) - mainCamera.transform.forward * 0.2f;
}

18行目についてですが、InputManagerで入力を扱うときはGazeでフォーカスを当てているオブジェクトに対してイベントが通知されます。しかしこのままだと、例えばオブジェクトをHoldしているときにオブジェクトに対するフォーカスが外れると、指を持ち上げていないにも関わらすHoldが終了してしまいます(Cancelイベントが通知される)。今回の場合は、そのような挙動ですと非常に操作しづらいので、Holdしている最中はイベントをキャッチするオブジェクトを固定しておく必要があります。そういった場合はInputManagerが持つあるスタックに固定したいオブジェクトを入れておくことによって、フォーカスが外れてもイベントをキャッチできるようにすることができます。それがInputManager.Instance.PushModalInputHandlerメソッドです。

続いて、オブジェクトの移動後の位置を計算するため、各種基準値を計算しています。ここで重要なのはgazeAngularOffsetです。オブジェクトを移動させるとき、手とオブジェクトの回転量がこのクォータニオンと常に一致するようにスクリプトでオブジェクトを動かします。実際の位置の変更処理はUpdatedDragging()で行われます。

public void UpdatedDragging() {
    // Hold中、動かした後の手の位置を取得する
    Vector3 newHandPosition;
    currentInputSource.TryGetPosition(currentInputSourceId, out newHandPosition);
    // 首の位置を取得する
    Vector3 pivotPosition = GetHandPivotPosition();
    // 首から移動後の手の位置への方向ベクトルを計算する
    Vector3 newHandDirection = Vector3.Normalize(newHandPosition - pivotPosition);
    newHandDirection = mainCamera.transform.InverseTransformDirection(newHandDirection);
    // 計算した方向ベクトルを、StartDraggig()で取得した回転量だけ回転したベクトルを計算する
    // これが移動後のオブジェクトの方向ベクトルとなる
    Vector3 targetDirection = Vector3.Normalize(gazeAngularOffset * newHandDirection);
    targetDirection = mainCamera.transform.TransformDirection(targetDirection);
    

targetDirectionの計算内容のイメージは以下の通りです。(簡単のため2D表現にしています)

手と対象のオブジェクトの位置関係を最初に取得しておき、その位置関係をベースに移動後のオブジェクトの位置を決めていくものとなっています。

続いて、移動後のオブジェクトがtargetDirection上のどこに位置するのかを計算します。

~UpdatedDragging()の続き~

    // 移動後の手の位置と首との距離を計算する
    float currentHandDistance = Vector3.Magnitude(newHandPosition - pivotPosition);
    // 移動前の距離との比を計算する
    float distanceRatio = currentHandDistance / handRefDistance;

    // distanceRatio>1の場合は手の位置が最初と比べて奥に移動したということなので、オブジェクトは遠ざかる
    // distanceRatio<1の場合は手の位置が最初と比べて手前に移動したということなので、オブジェクトが近づく // distanceRatio=1の場合は、オブジェクトはz軸方向には移動しない float distanceOffset = distanceRatio > 0 ? (distanceRatio - 1f) * TransformController.distanceScale : 0;
    float targetDistance = objRefDistance + distanceOffset;

    // なめらかに移動するよう、補間しながら移動させる
    draggingPosition = pivotPosition + (targetDirection * targetDistance);
    interpolator.SetTargetPosition(draggingPosition + mainCamera.transform.TransformDirection(objRefGrabPoint));
}

distanceScaleは移動量のスケール値で、別途パラメータ用のスクリプトTransformControllerを用意し、一括で調整できるようにしています。
【TransformController】

Rotationの操作

続いてはRotation(回転)の操作部分です。ここからは独自に実装したコードになります。
【RotationController】

回転については以下の図の通り、HoldしたSphere上の辺に平行で、かつ対象のオブジェクトの中心を通る軸、すなわち対象オブジェクトのローカルx-y-z軸を中心に回転させるようにしています。

コードの中身についてですが、実装したインタフェース、イベントハンドラに定義したHoldの判定処理ついてはPositionのときと同様なので割愛し、StartDragging()から見ていきます。

// 回転軸を表す列挙体とその変数
public enum RotationAxis {
    x,
    y,
    z
}

public RotationAxis axis;

// Hold開始時の手の位置
private Vector3 startHandPosition;
// 回転軸に直行するベクトル
private Vector3 orthogonalRotationAxisVect;

public void StartDragging() {
    if (!IsDraggingEnable)
        return;
    if (isDragging)
        return;

    InputManager.Instance.PushModalInputHandler(gameObject);
    isDragging = true;

    // Hold開始時の手の位置を取得する
    currentInputSource.TryGetPosition(currentInputSourceId, out startHandPosition);
    
    // 変数の内容を元にオブジェクトの回転軸を決定する
    Vector3 rotaitonAxisVect;
    switch (axis) {
        case RotationAxis.x:
            rotaitonAxisVect = target.transform.right;
            break;
        case RotationAxis.y:
            rotaitonAxisVect = target.transform.up;
            break;
        case RotationAxis.z:
            rotaitonAxisVect = target.transform.forward;
            break;
        default:
            Debug.LogError("Parameter 'axis' is not set.");
            return;
    }
    // 回転軸をHoloLensの視線方向(ローカルの+z軸)を法線とする平面に投影する
    Vector3 projectionVect = Vector3.ProjectOnPlane(rotaitonAxisVect, mainCamera.transform.forward);
    projectionVect.Normalize();
    // 上記の平面上で、回転軸と垂直なベクトルを計算する
    orthogonalRotationAxisVect = Vector3.Cross(mainCamera.transform.forward, projectionVect);
    orthogonalRotationAxisVect.Normalize();
}

オブジェクトのローカルx-y-z軸のどれを回転の軸とするかはSphere毎にパラメータ(axis)を設定して指定します。

続いてベクトルを平面に投影する等の操作を行っていますが、これは手の上下左右の動きだけでオブジェクトを回転させるためです。Hologramsを触っていて思ったのですが、オブジェクトを回転させるときは手の上下左右の動きだけで完結させたほうが手触りよく感じ、反対に前後の動きを操作に加えると不自然・直感的ではない操作感でした。そのために視線前面に回転軸を投影し、前後方向の手の動きを無視するようにしました。イメージの助けになればと思い、以下の図を用意しました。

続いて、実際にオブジェクトを回転させる部分のコードです。

public void UpdatedDragging() {
    // 移動後の手の位置を取得する
    Vector3 newHandPosition;
    currentInputSource.TryGetPosition(currentInputSourceId, out newHandPosition);
    // Hold開始時からの移動を表すベクトルを計算する
    Vector3 moveVect = newHandPosition - startHandPosition;
    // 計算したベクトルを、回転軸に垂直なベクトルに投影する
    Vector3 projectMoveVect = Vector3.Project(moveVect, orthogonalRotationAxisVect);
    // 投影したベクトルと回転軸に垂直なベクトルとの内積をとり、正負を考慮した回転量を計算する
    float rotationVal = Vector3.Dot(projectMoveVect, orthogonalRotationAxisVect) * TransformController.rotationSpeed;
    // パラメータが表す軸で回転する
    target.transform.Rotate(
        axis == RotationAxis.x ? rotationVal : 0,
        axis == RotationAxis.y ? rotationVal : 0,
        axis == RotationAxis.z ? rotationVal : 0
        );
}

こちらも図を用意してみました。

回転量の計算の最後に内積の計算をしていますが、これはコメントに書いてある通り、回転の正負を考慮してのことです。回転量を表すprojectMoveVectは、orthogonalRotationAxisVectと並行なベクトルです。2つのベクトルa,bの内積は|a||b|cosとなりますので、平行なベクトルの場合はcosが1か-1となります。また、orthogonalRotationAxisVectはStartDragging()にて正規化されていますので、その大きさは1となります。したがって上記の内積値は(projectMoveVectの大きさ) * (1 or -1)となります。これによって順方向、逆方向への回転が1つの式で表現できます。

Scaleの操作

最後はScale(拡大/縮小)の操作です。
【ScaleController】

Scaleの操作は頂点のCubeをHoldして動かすとオブジェクトが拡大/縮小されるのですが、ここで手触りを良くするためにひと手間加えておりまして、大きさが変更されたとき掴んだCubeの対角線上に位置するCubeのワールドpositionは動かない様に制御しています。

これによって、ただオブジェクトを拡大/縮小するよりもずっと直感に即した動きになりました(と私は思っています)。

さて、コードの中身についてですが、こちらもインタフェースやイベントハンドラは他のスクリプトと同様ですので、StartDragging()から見ていきたいと思います。

public void StartDragging() {
    if (!IsDraggingEnable)
        return;
    if (isDragging)
        return;

    InputManager.Instance.PushModalInputHandler(gameObject);
    isDragging = true;

    // Hold開始時の手の位置を取得
    currentInputSource.TryGetPosition(currentInputSourceId, out startHandPosition);
    // 操作対象オブジェクトの中心から、掴んだCubeへのベクトルを計算する
    // このベクトル方向に拡大/縮小していく
    scaleAxisVect = transform.position - target.transform.position;
    scaleAxisVect.Normalize();
    // Hold開始時の対象オブジェクトのScale値を取得
    targetBaseScale = target.transform.localScale;

    // 拡大/縮小時に位置の補正をするために、以下の値を取得しておく
    // Hold開始時の操作対象オブジェクトの中心と掴んだCubeとの距離
    startDistance = Vector3.Distance(target.transform.position, transform.position);
    // Hold開始時の対象オブジェクトの位置を取得
    startTargetPosition = target.transform.position;
}

手の移動量から拡大/縮小の変化量を計算するため、基準となるベクトルscaleAxisVectを計算しています。なお、Scaleの操作についてはRotationとは違い、手の前後の動きがあったほうが操作感が良かったので、平面への投影は行っていません。

続いてUpdatedDragging()です。

public void UpdatedDragging() {
    // 移動後の手の位置を取得する
    Vector3 newHandPosition;
    currentInputSource.TryGetPosition(currentInputSourceId, out newHandPosition);
    // 手の移動ベクトルを計算する
    Vector3 moveVect = newHandPosition - startHandPosition;
    // 移動ベクトルを、StartDragging()で計算した基準のベクトルに投影する
    Vector3 projectMoveVect = Vector3.Project(moveVect, scaleAxisVect);
    // 拡大/縮小の変化量を計算する
    float scaleValue = Vector3.Dot(projectMoveVect, scaleAxisVect) * TransformController.scaleMagnification;

    // 変化させた結果、scale値が設定した下限を下回らないように制御する
    if (targetBaseScale.x + scaleValue > TransformController.scaleLowerLimit) {
        target.transform.localScale = targetBaseScale + new Vector3(scaleValue, scaleValue, scaleValue);
    } else {
        target.transform.localScale = new Vector3(TransformController.scaleLowerLimit, TransformController.scaleLowerLimit, TransformController.scaleLowerLimit);
    }

    // 位置の補正
    //オブジェクトの大きさを変化させた後の、操作対象オブジェクトの中心と掴んだCubeとの距離を計算する
    float scaledDistance = Vector3.Distance(target.transform.position, transform.position);
    // Hold開始時の距離との差分を計算する
    float difference = scaledDistance - startDistance;
    // 補正値を計算し、補正する
    Vector3 correctionVect = positionAxis * difference;
    target.transform.position = startTargetPosition + correctionVect;
}

拡大/縮小の変化量の計算のアイデアは基本的にRotationと同じで、手の移動を表すベクトルを基準となる拡大/縮小方向のベクトルに投影し、投影したベクトルと方向ベクトルの内積を取って正負を考慮して変化量を計算しています。なお、あまりにも小さくし過ぎると以後操作できなくなってしまったり、オブジェクトが反転してしまうので、下限を設定しそれを超えないように制御しています。

また、初めに書いた通り、処理の最後に拡大/縮小後にオブジェクトの位置を補正していますが、そのイメージは以下の図の通りとなります。(やはり簡単のために2D表現にしています)

コードの解説は以上です。

まとめ

HologramsっぽくオブジェクトをTransformさせるコードを解説しました。今回開設したコードのgistを再掲します。

【PositionController】
【RotationController】
【ScaleController】
【TransformController】

現時点では各3Dオブジェクトを手動で調整しなくてはいけないのですが、ゆくゆくはスクリプトで対象のオブジェクトの形状にあわせて自動で配置を決められるように改良したいと思います。ある程度その実装ができたらGitHubに一式アップしたいと思います。


© UTJ/UCL