30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Unity(C#),PUN2】OculusQuestのハンドトラッキング同期実装

Last updated at Posted at 2020-06-29

##デモ

まずはデモです。
SyncHand2.gif

やってることは見たまんまの位置同期ですが、
ハンドトラッキングの実装はOculusIntegration内に存在するOVR系のコンポーネントを
理解する必要があり、私のレベルでは非常に面倒でした。

しかし、一度理解してしまえば使い回すだけなので、
同じ苦労をする人が一人でも減るようにメモしておきます。

##バージョン情報
Unity2019.3.10f1
Oculus Integration 1.49
PUN2 Version 2.19.1

##ハンドトラッキングの流れ

まずはOculusIntegration内に存在するOVR系のコンポーネントがどのような役割を持ち、
どのような流れでハンドトラッキングを行っているかを理解していきます。

簡単に言うと下記です。
①手を認識
②ボーンとなるオブジェクトを生成
③手のメッシュを作成
④手のメッシュにボーンを設定
⑤生成したボーンを認識した手の関節の座標に合わせる

おおざっぱに理解したにすぎないので、間違いがあったらコメントください。


###①OVRHandが手を認識
OVRHandが手を認識しているというのは詳細を言うと少し誤った表現です。

もう少し正確に言うと、
OVRHandが認識した手のデータを受け取って様々なクラスに
インタフェース経由でデータを渡している
という説明になるかと思います。

もっと辿っていくとOVRPluginというクラスが存在しており、
デバイスが手を認識した際のデータを
C#で利用できるようにするラッパークラスとしての役割を担っています。

###②OVRSkeletonがボーンとなるオブジェクトを生成
OVRSkeletonのコードを見ていくと手のボーンを生成するコードを見つけました。

OVRSkeleton内のボーン生成箇所
virtual protected void InitializeBones(OVRPlugin.Skeleton skeleton)
{
	_bones = new List<OVRBone>(new OVRBone[skeleton.NumBones]);
	Bones = _bones.AsReadOnly();

	if (!_bonesGO)
	{
		_bonesGO = new GameObject("Bones");
		_bonesGO.transform.SetParent(transform, false);
		_bonesGO.transform.localPosition = Vector3.zero;
		_bonesGO.transform.localRotation = Quaternion.identity;
	}

	// pre-populate bones list before attempting to apply bone hierarchy
	for (int i = 0; i < skeleton.NumBones; ++i)
	{
		BoneId id = (OVRSkeleton.BoneId)skeleton.Bones[i].Id;
		short parentIdx = skeleton.Bones[i].ParentBoneIndex;
		Vector3 pos = skeleton.Bones[i].Pose.Position.FromFlippedXVector3f();
		Quaternion rot = skeleton.Bones[i].Pose.Orientation.FromFlippedXQuatf();

		var boneGO = new GameObject(id.ToString());
		boneGO.transform.localPosition = pos;
		boneGO.transform.localRotation = rot;
		_bones[i] = new OVRBone(id, parentIdx, boneGO.transform);
	}

	for (int i = 0; i < skeleton.NumBones; ++i)
	{
		if (((OVRPlugin.BoneId)skeleton.Bones[i].ParentBoneIndex) == OVRPlugin.BoneId.Invalid)
		{
			_bones[i].Transform.SetParent(_bonesGO.transform, false);
		}
		else
		{
			_bones[i].Transform.SetParent(_bones[_bones[i].ParentBoneIndex].Transform, false);
		}
	}
}

この処理によって、動的にボーンとなるオブジェクトが生成されます。
OQBones.PNG

このBonesの子階層にあるオブジェクトはEditor上で確認すると
手の動きに追従して回転しているのが確認できます。
(InspectorでShould Update Boneにチェックを入れた場合)

【参考リンク】:【Unity】Oculus Link使ってEditor上でデバッグ


###③OVRMeshが手のメッシュを生成
OVRMeshのコード内で手のメッシュを生成しています。
BoneWeightの設定も行っています。

OVRMesh内の手のメッシュ生成箇所
private void Initialize(MeshType meshType)
{
	_mesh = new Mesh();

	var ovrpMesh = new OVRPlugin.Mesh();
	if (OVRPlugin.GetMesh((OVRPlugin.MeshType)_meshType, out ovrpMesh))
	{
		var vertices = new Vector3[ovrpMesh.NumVertices];
		for (int i = 0; i < ovrpMesh.NumVertices; ++i)
		{
			vertices[i] = ovrpMesh.VertexPositions[i].FromFlippedXVector3f();
		}
		_mesh.vertices = vertices;

		var uv = new Vector2[ovrpMesh.NumVertices];
		for (int i = 0; i < ovrpMesh.NumVertices; ++i)
		{
			uv[i] = new Vector2(ovrpMesh.VertexUV0[i].x, -ovrpMesh.VertexUV0[i].y);
		}
		_mesh.uv = uv;

		var triangles = new int[ovrpMesh.NumIndices];
		for (int i = 0; i < ovrpMesh.NumIndices; ++i)
		{
			triangles[i] = ovrpMesh.Indices[ovrpMesh.NumIndices - i - 1];
		}
		_mesh.triangles = triangles;

		var normals = new Vector3[ovrpMesh.NumVertices];
		for (int i = 0; i < ovrpMesh.NumVertices; ++i)
		{
			normals[i] = ovrpMesh.VertexNormals[i].FromFlippedXVector3f();
		}
		_mesh.normals = normals;

		var boneWeights = new BoneWeight[ovrpMesh.NumVertices];
		for (int i = 0; i < ovrpMesh.NumVertices; ++i)
		{
			var currentBlendWeight = ovrpMesh.BlendWeights[i];
			var currentBlendIndices = ovrpMesh.BlendIndices[i];

			boneWeights[i].boneIndex0 = (int)currentBlendIndices.x;
			boneWeights[i].weight0 = currentBlendWeight.x;
			boneWeights[i].boneIndex1 = (int)currentBlendIndices.y;
			boneWeights[i].weight1 = currentBlendWeight.y;
			boneWeights[i].boneIndex2 = (int)currentBlendIndices.z;
			boneWeights[i].weight2 = currentBlendWeight.z;
			boneWeights[i].boneIndex3 = (int)currentBlendIndices.w;
			boneWeights[i].weight3 = currentBlendWeight.w;
		}
		_mesh.boneWeights = boneWeights;

		IsInitialized = true;
	}
}

OVRMeshは生成したメッシュをSkinnedMeshrendererに設定します。
PlayModeを押すとMeshが動的に生成、設定されているのがわかります。
SyncHandForQiita1.gif


###④OVRMeshRendererが手のメッシュにボーンを設定
先ほど生成したMeshにボーンを設定します。
正確に言うと、SkinnedMeshrendererの持つMeshのボーン情報に設定します。

OVRMeshRenderer内のボーンを設定箇所
private void Initialize()
{
	_skinnedMeshRenderer = GetComponent<SkinnedMeshRenderer>();
	if (!_skinnedMeshRenderer)
	{
		_skinnedMeshRenderer = gameObject.AddComponent<SkinnedMeshRenderer>();
	}

	if (_ovrMesh != null && _ovrSkeleton != null)
	{
		if (_ovrMesh.IsInitialized && _ovrSkeleton.IsInitialized)
		{
			_skinnedMeshRenderer.sharedMesh = _ovrMesh.Mesh;
			_originalMaterial = _skinnedMeshRenderer.sharedMaterial;

			int numSkinnableBones = _ovrSkeleton.GetCurrentNumSkinnableBones();
			var bindPoses = new Matrix4x4[numSkinnableBones];
			var bones = new Transform[numSkinnableBones];
			var localToWorldMatrix = transform.localToWorldMatrix;
			for (int i = 0; i < numSkinnableBones && i < _ovrSkeleton.Bones.Count; ++i)
			{
				bones[i] = _ovrSkeleton.Bones[i].Transform;
				bindPoses[i] = _ovrSkeleton.BindPoses[i].Transform.worldToLocalMatrix * localToWorldMatrix;
			}
			_ovrMesh.Mesh.bindposes = bindPoses;
			_skinnedMeshRenderer.bones = bones;
			_skinnedMeshRenderer.updateWhenOffscreen = true;
#if UNITY_EDITOR
			_ovrSkeleton.ShouldUpdateBonePoses = true;
#endif
			IsInitialized = true;
		}
	}
}

このコード内ではメッシュのデフォルトの位置となるbindposesも設定しています。
Meshの持つボーンのデフォルトの位置を設定することで、
ボーンの移動した値とデフォルト値の差分から計算が可能になるそうです。

###⑤OVRSkeletonが生成したボーンを認識した手の関節の座標に合わせる
最後に再度OVRSkeletonの登場です。

自身で生成したBonesを認識した手の関節情報にそれぞれ追従させます。

OVRSkeleton内の生成したボーンを認識した手の関節の座標に合わせる箇所
void Update()
{
    //~省略~
    
	var data = _dataProvider.GetSkeletonPoseData();

	IsDataValid = data.IsDataValid;
	if (data.IsDataValid)
	{
		IsDataHighConfidence = data.IsDataHighConfidence;

		if (_updateRootPose)
		{
			transform.localPosition = data.RootPose.Position.FromFlippedZVector3f();
			transform.localRotation = data.RootPose.Orientation.FromFlippedZQuatf();
		}

		if (_updateRootScale)
		{
			transform.localScale = new Vector3(data.RootScale, data.RootScale, data.RootScale);
		}

		for (var i = 0; i < _bones.Count; ++i)
		{
			if (_bones[i].Transform != null)
			{
				_bones[i].Transform.localRotation = data.BoneRotations[i].FromFlippedXQuatf();
				if (_bones[i].Id == BoneId.Hand_WristRoot)
				{
					_bones[i].Transform.localRotation *= wristFixupRotation;
				}
			}
		}
	}
}

##同期実装の流れ
ハンドトラッキングの一連の流れが明らかになったので、
いよいよ同期処理を考えていきます。

オブジェクト同期と呼ばれる手法を用います。
流れとしては下記イメージです。


###①各クライアントが手の位置情報を保持
VRGroup1.png


###②手の見た目のみの役割を持つオブジェクトを用意し、各自の手の位置情報に追従させる
VRGroup2.png


###③手の見た目のみの役割を持つオブジェクトを双方のクライアントに生成
VRGroup3.png


###④お互いの手の位置情報を送り合い、生成した手の位置情報を更新
VRGroup4.png

おおざっぱではありますが、こんな感じです。

##ハンドトラッキングでの同期方法
ここまでの理解でもかなり骨が折れましたが、本当に大変なのはここからでした。

一連の流れを見ればわかりますが、
手の見た目のみの役割を果たす同期用オブジェクト
用意する必要があります。

やり方としては、2つの選択肢があります。
1つは、事前にボーンとなるオブジェクト及び、手のメッシュを用意することです。

実際に下記の海外勢のサンプルでは、この手法を用いていました。
【参考リンク】:SpeakGeek-Normcore-Quest-Hand-Tracking

(サンプルではPUN2とは別のNormcoreというライブラリを使用して同期の実装を行っています)

下記GIFのように、あらかじめ同期するオブジェクトの中に
Bone及びBindPoseのオブジェクトがびっしりと用意されています。
SyncHandForQiita2.gif

ボーンの役割を担うオブジェクトはBindPoseも合わせると片手だけで全部で48個あります。
しかも、それぞれの座標が生成時に(0,0,0)ではないデフォルト値を持つので
自前で事前に用意するとなると、
生成時のすべての値を48×2回メモして一つずつ手打ち、、、もしくは
プレイモードで生成されたオブジェクトをそのまま保存できるスクリプトを用意する、、、
などなかなかの手間となります。

さらに、プロジェクトを跨いで利用する際には
毎回オブジェクトをインポートする必要があるので少々効率が悪いです。

そこで、もう一つのやり方として、
OVR系コンポーネントと同様に手の見た目のオブジェクトを動的に生成する方法を用います。

しかし、既存のOVR系コンポーネントは切り離すことが困難な密結合な状態になっています。
ですので、手の見た目の役割を果たすオブジェクトを生成する処理を
OVR系コンポーネントから拝借して自前で用意する必要がありました。

##同期実装のコード
ここから実装の核心となるコードの説明です。
先ほどの同期実装の流れと合わせて見ていきます。


###①各クライアントが手の位置情報を保持
VRGroup1.png

この処理に関しては完全にOVR系コンポーネントに担ってもらいます。
OVRHandOVRSkeletonをアタッチしたオブジェクトを各手のAnchorの子階層に配置します。
OVRSyncHand.PNG


###②手の見た目のみの役割を持つオブジェクトを用意し、各自の手の位置情報に追従させる
VRGroup2.png

この処理に関しては長くなるので
・手の見た目のみの役割を持つオブジェクトを用意
・各自の手の位置情報に追従

の二つに分けて説明していきます。


####手の見た目のみの役割を持つオブジェクトを用意

メッシュの生成に関してはOVRMeshをそのまま利用します。
生成するオブジェクトにSkinnedMeshrendererと共にアタッチしておきます。
SyncAvatarHand.PNG

ボーンの役割を担うオブジェクトの生成に関してはOVRSkeletonでの処理をほぼ丸パクリです。

/// <summary>
/// Bonesを生成
/// </summary>
/// <param name="skeleton">あらかじめ用意されたボーンの情報</param>
/// <param name="hand">左右どちらかの手</param>
private void InitializeBones(OVRPlugin.Skeleton skeleton, GameObject hand)
{
    _bones = new List<OVRBone>(new OVRBone[skeleton.NumBones]);

    GameObject _bonesGO = new GameObject("Bones");
    _bonesGO.transform.SetParent(hand.transform, false);
    _bonesGO.transform.localPosition = Vector3.zero;
    _bonesGO.transform.localRotation = Quaternion.identity;

    for (int i = 0; i < skeleton.NumBones; ++i)
    {
        OVRSkeleton.BoneId id = (OVRSkeleton.BoneId) skeleton.Bones[i].Id;
        short parentIdx = skeleton.Bones[i].ParentBoneIndex;
        Vector3 pos = skeleton.Bones[i].Pose.Position.FromFlippedXVector3f();
        Quaternion rot = skeleton.Bones[i].Pose.Orientation.FromFlippedXQuatf();

        GameObject boneGO = new GameObject(id.ToString());
        boneGO.transform.localPosition = pos;
        boneGO.transform.localRotation = rot;
        _bones[i] = new OVRBone(id, parentIdx, boneGO.transform);
    }

    for (int i = 0; i < skeleton.NumBones; ++i)
    {
        if (((OVRPlugin.BoneId) skeleton.Bones[i].ParentBoneIndex) == OVRPlugin.BoneId.Invalid)
        {
            _bones[i].Transform.SetParent(_bonesGO.transform, false);
        }
        else
        {
            _bones[i].Transform.SetParent(_bones[_bones[i].ParentBoneIndex].Transform, false);
        }
    }
}

次にMeshの生成を行います。
ついでにMesh、SkinnedMeshRendererにBindPose、Boneの登録もそれぞれ行います。

/// <summary>
/// 手のボーンのリストを作成
/// 後にOculusの持つボーン情報のリストと照らし合わせて値を更新するので順番に一工夫して作成
/// </summary>
/// <param name="hand">子にボーンを持っている手</param>
/// <param name="bones">空のリスト</param>
private void ReadyHand(GameObject hand, List<Transform> bones)
{
    //'Bones'と名の付くオブジェクトからリストを作成する
    foreach (Transform child in hand.transform)
    {
        _listOfChildren = new List<Transform>();
        GetChildRecursive(child.transform);

        //まずは指先以外のリストを作成
        List<Transform> fingerTips = new List<Transform>();
        foreach (Transform bone in _listOfChildren)
        {
            if (bone.name.Contains("Tip"))
            {
                fingerTips.Add(bone);
            }
            else
            {
                bones.Add(bone);
            }
        }

        //指先もリストに追加
        foreach (Transform bone in fingerTips)
        {
            bones.Add(bone);
        }
    }

    //動的に生成されるメッシュをSkinnedMeshRendererに反映
    SkinnedMeshRenderer skinMeshRenderer = hand.GetComponent<SkinnedMeshRenderer>();
    OVRMesh ovrMesh = hand.GetComponent<OVRMesh>();

    Matrix4x4[] bindPoses = new Matrix4x4[bones.Count];
    Matrix4x4 localToWorldMatrix = transform.localToWorldMatrix;
    for (int i = 0; i < bones.Count; ++i)
    {
        bindPoses[i] = bones[i].worldToLocalMatrix * localToWorldMatrix;
    }

    //Mesh、SkinnedMeshRendererにBindPose、Boneを反映
    ovrMesh.Mesh.bindposes = bindPoses;
    skinMeshRenderer.bones = bones.ToArray();
    skinMeshRenderer.sharedMesh = ovrMesh.Mesh;
}

/// <summary>
/// 子のオブジェクトのTransformを再帰的に全て取得
/// </summary>
/// <param name="obj">自身の子を全て取得したいルートオブジェクト</param>
private void GetChildRecursive(Transform obj)
{
    if (null == obj)
        return;

    foreach (Transform child in obj.transform)
    {
        if (null == child)
            continue;

        if (child != obj)
        {
            _listOfChildren.Add(child);
        }

        GetChildRecursive(child);
    }
}

Bonesの子階層、すなわち指のボーンとなるオブジェクトから謎のリストを作成している理由は
次の 各自の手の位置情報に追従 で説明します。


####各自の手の位置情報に追従
先ほど作成した謎の順番整理を行ったリストですが、各自の手の位置情報に追従させる際に
利用する上で都合が良いです。

というのも、IOVRSkeletonDataProviderから渡ってきたボーン情報の順番が少し複雑だからです。
"Tip"と名の付く指先以外のボーンの位置情報が親指から順に列挙して送られてきたのち、
"Tip"と名の付く指先の情報が親指から順に送られてきます。

下記コードで見るとより理解しやすいと思います。

ミニサンプル(左手のみ)
[SerializeField] private GameObject _leftHandVisual;

private readonly List<Transform> _bonesL = new List<Transform>();
private List<Transform> _listOfChildren = new List<Transform>();
private Quaternion _wristFixupRotation

void Start()
{
	OVRSkeleton ovrSkeletonL = GameObject.Find("OVRHandL").GetComponent<OVRSkeleton>();
	OVRSkeleton.IOVRSkeletonDataProvider dataProviderL =
                ovrSkeletonL.GetComponent<OVRSkeleton.IOVRSkeletonDataProvider>();

	//ボーンの情報をC#で利用可能にするラッパークラス
 	OVRPlugin.Skeleton skeleton = new OVRPlugin.Skeleton();

 	//ボーンの元データを生成
 	OVRPlugin.GetSkeleton((OVRPlugin.SkeletonType) dataProviderL.GetSkeletonType(), out skeleton);
 	InitializeBones(skeleton, _leftHandVisual);
 	
 	//正しい順序で生成したボーンのリストを作成
    ReadyHand(_leftHandVisual, _bonesL);
    
    _wristFixupRotation = new Quaternion(0.0f, 1.0f, 0.0f, 0.0f);
}

void Update()
{
	//左手
	if (_dataL.IsDataValid && _dataL.IsDataHighConfidence)
	{
	    //ルートのローカルポジションを適用
	    _leftHandVisual.transform.localPosition = _dataL.RootPose.Position.FromFlippedZVector3f();
	    _leftHandVisual.transform.localRotation = _dataL.RootPose.Orientation.FromFlippedZQuatf();

	    _leftHandVisual.transform.localScale =
	        new Vector3(_dataL.RootScale, _dataL.RootScale, _dataL.RootScale);

	    //ボーンのリストに受け取った値を反映
	    for (int i = 0; i < _bonesL.Count; ++i)
	    {
	        _bonesL[i].transform.localRotation = _dataL.BoneRotations[i].FromFlippedXQuatf();

	        if (_bonesL[i].name == OVRSkeleton.BoneId.Hand_WristRoot.ToString())
	        {
	            _bonesL[i].transform.localRotation *= _wristFixupRotation;
	        }
	    }
	}
}

順番を整理したおかげで、for文を利用したボーンの情報をリストに順番通り取得してくる処理
が容易になっています。

ここまでの処理で
手の見た目のオブジェクト
だけを同期オブジェクトとして実装することが可能となりました。

###③手の見た目のみの役割を持つオブジェクトを双方のクライアントに生成

VRGroup3.png

この処理に関しては非常に簡単です。

PhotonNetwork.Instantiateを使えばPUN2が自動で生成してくれます。

コードに落とし込むと下記です。

適当なオブジェクトにアタッチ
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class PunConnect : MonoBehaviourPunCallbacks
{
    [SerializeField] private GameObject _avatar;
   
    private const int _PLAYER_UPPER_LIMIT = 2;
    
    //ルームオプションのプロパティー
    private RoomOptions _roomOptions = new RoomOptions()
    {
        MaxPlayers = _PLAYER_UPPER_LIMIT, //人数制限
        IsOpen = true, //部屋に参加できるか
        IsVisible = true, //この部屋がロビーにリストされるか
    };

    private void Start()
    {
        //PhotonServerSettingsに設定した内容を使ってマスターサーバーへ接続する
        PhotonNetwork.ConnectUsingSettings();
    }

    //マスターサーバーへの接続が成功した時に呼ばれるコールバック
    public override void OnConnectedToMaster()
    {
        // "Test"という名前のルームに参加する(ルームが無ければ作成してから参加する)
        PhotonNetwork.JoinOrCreateRoom("Test", _roomOptions, TypedLobby.Default);
    }
    
    //部屋への接続が成功した時に呼ばれるコールバック
    public override void OnJoinedRoom()
    {
        //アバターを生成
        GameObject avatar = PhotonNetwork.Instantiate(
            _avatar.name,
            Vector3.zero, 
            Quaternion.identity);

        avatar.name = _avatar.name;
    }
}

_avatarはPrefabをアタッチする必要があり、そのPrefabはAssets/Photon/PhotonUnityNetworking/Resources
に配置する必要があります。

##④お互いの手の位置情報を送り合い、生成した手の位置情報を更新

VRGroup4.png

最後に同期オブジェクトの位置情報を共有する実装です。

PUN2のIPunObservableを経由して送受信します。

ミニ同期処理サンプル(左手のみ)
/// <summary>
/// Transformをやり取りする
/// </summary>
/// <param name="stream">値のやり取りを可能にするストリーム</param>
/// <param name="info">タイムスタンプ等の細かい情報がやり取り可能</param>
void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    //自身のクライアントから相手クライアントの同期オブジェクトに送る情報
    if (stream.IsWriting)
    {
        stream.SendNext(_leftHandVisual.transform.localPosition);
        stream.SendNext(_leftHandVisual.transform.localRotation);

        //ボーンのリストに受け取った値を反映
        for (var i = 0; i < _bonesL.Count; ++i)
        {
            stream.SendNext(_bonesL[i].transform.localRotation);
        }
    }
    //相手のクライアントから自身のクライアントの同期オブジェクトに送られてくる情報
    else
    {
        _leftHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext();
        _leftHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext();

        //ボーンのリストに受け取った値を反映
        for (var i = 0; i < _bonesL.Count; ++i)
        {
            _bonesL[i].transform.localRotation = (Quaternion) stream.ReceiveNext();
        }
    }
}

これでようやく同期が完了しました。

##最後に
ここまでの理解ですらかなりの時間を要しましたが、
最適化やUI/UXの面からまだまだ課題は多いです。

今後も引き続きハンドトラッキング含め、同期に関して
調査しようと思っています。


2021/03/22
プロジェクト公開しました。お役に立てば幸いです。→HandSync

##参考リンク
SkinnedMeshとBoneWeightについてメモ
PUN2で始めるオンラインゲーム開発入門【その5】

30
15
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?