仮想と物理とエトセトラ

xRや物理とかごった煮の備忘録的技術ブログ

UE4でHoloLens2に豆腐を表示する

今回は久々にUnreal Engineの記事です。
前回:

xr-physics-work-etc.hatenablog.com

最近Unreal Engine 5が正式リリースされましたが、まだMRTKが対応していなかったり、マーケットプレイスで取得したMicrosoft OpenXRではパッケージ作成時にエラーが出たりするため、今回はUnreal Engine 4.27での豆腐表示を試します。
公式ドキュメントもあったりしますが、あくまで自分用の備忘録としてまとめておきます。

docs.unrealengine.com

前提

Unreal Engine 4.27.2

準備

Microsoft OpenXRの取得

HoloLens2でOpenXRを使用するために、マーケットプレイスからMicrosoft OpenXRを取得します。
「アセットを検索」から「Microsoft OpenXR」を検索すると目的のアセットが表示されます。
「無料」ボタンを押してアセットを取得した後、「エンジンにインストール」から4.27.2のUnreal Engineを選択してインストールします。

ビルド用設定の準備

まずはUnreal EngineでHoloLens用にビルドできるようにします。
Engineバージョンの「4.27.2」の下向き三角をクリックするとメニューが表示されます。
表示されたメニューから「オプション」を選択します。

インストールオプションから、「対応プラットフォーム」にHoloLens2を追加します。
HoloLens2の項目にチェックを入れ、「適用」ボタンを押します。

HoloLens2向けビルドツールのダウンロード/インストールが始まるので、しばらく待つとインストールが終わります。

プロジェクトを作る

次に、Unreal Engineを起動してプロジェクトを作成します。
4.27.2のバージョンのUnreal Engineを選択し、「起動」ボタンを押します。

Unreal Engineが起動したら、新規プロジェクトのテンプレートを選択します。
まずは「ゲーム」を選択し、「次へ」を選択します。

次に「Blank」を選択し、「次へ」を選択します。

次にプロジェクトの詳細設定を行います。
今後のため、C++も使用できるように「C++」を設定します。
端末種別は「モバイル/タブレット」、パフォーマンス特性は「スケーラブルな3D・2D」、「スターターコンテンツなし」、「レイトレーシング無効」を選択します。
プロジェクト名と保存場所を設定し、問題なければ「プロジェクト作成」ボタンを押します。

プロジェクトの初期設定

プラグイン設定

プロジェクトが開いたら、初期設定としてプラグインを導入します。
上部メニューの「編集」→「プラグイン」からプラグインウィンドウを表示します。

検索ウィンドウで「hololens」と検索すると出てくる、「HoloLens」、「Microsoft OpenXR」プラグインの有効にチェックを入れ有効化した後、「今すぐ再起動」ボタンからエディタを再起動してプラグインを有効化します。

プロジェクト設定

次にプロジェクトの設定を変更します。
上部メニューの「編集」→「プロジェクト設定」からプロジェクト設定ウィンドウを表示します。

まずは「説明」の項目を変更します。
「プロジェクト名」にHoloLensでのアプリ名、「企業名」は任意の値、「企業識別名」にはCN=の後に企業名で入力した値を入力します。
「企業識別名」の値は、この後の「署名証明書」で使用します。
最後に「設定」項目の「VRで開始」にチェックを入れます。

次にプラットフォーム「HoloLens」の項目を確認します。
まず、「Build for HoloLens Device」の項目にチェックがついていることを確認します。
次に、「署名証明書」の新規作成ボタンを押します。

秘密鍵を作成するウィンドウが表示されます。
今回はパスワードを設定しないので、「None」を設定します。

正常に証明書が作成されると、さきほど「説明」で設定した「企業識別名」が表示されます。

これでプロジェクト設定ができました。

レベルの設定

レベル初期設定

次にレベル(シーン)の設定を行います。
現在のレベルには不要なものが多いので新規でシーンを作成します。
上部メニューの「ファイル」→「新規レベル」から新しくレベルを作成します。
新規レベルウィンドウから「空のレベル」を選択します。

これで、まっさらなレベルが作成できます。
今のうちに作成されたレベルを保存します。
上部メニューの「ファイル」→「現在のレベルを保存」から保存できます。
「コンテンツ」ディレクトリ配下に「Maps」ディレクトリを作成し、その中に「Main」という名前でレベルを保存します。
各設定が終わったら「保存」ボタンからレベルを保存します。
これで空のレベルを保存できました。

豆腐を配置する

次にレベル中に豆腐を配置します。
「アクタを配置」から「基本」→「キューブ」を選び、ドラッグアンドドロップでビューポート中に配置します。
位置、姿勢は下記を参考に設定します。

次に光源を設定します。
「ライト」→「ディレクショナルライト」をドラッグアンドドロップでビューポート中に配置します。
位置、姿勢は下記を参考に設定します。

これで豆腐を配置できました。

HoloLens用設定をする

次にHoloLens用にプロジェクトを構成します。

AR Session Config

コンテンツブラウザで右クリックし、「その他」→「データアセット」を選択します。

データアセット選択ウィンドウが表示されるため、「ARSessionConfig」を選択します。
データアセット名も「ARSessionConfig」とします。

次に、ARSessionConfigを有効化するため、レベルブループリントに設定します。 「ブループリント」→「レベルブループリントを開く」をクリックします。

レベルブループリントを設定します。
イベントBeginPlay(起動時に実行されるイベント)にStart AR Sessionを設定し、Session Configに先ほど作成したARSessionConfigを設定します。

Start AR Sessionは右クリックすると見つかるノードです。

イベント EndPlayStop AR Sessionも同様に設定します。
保存後、「コンパイル」ボタンから再コンパイルを行います。

これでAR Sessionの設定ができました。

ポーン、ゲームモードベースを設定する

次にポーンとゲームモードベースを設定します。
ポーンはプレイヤーを表す実体、ゲームモードベースはゲームモードの基本クラスです。
docs.unrealengine.com

docs.unrealengine.com

ポーンを作成するため、コンテンツ配下に「Blueprints」フォルダを作成し、右クリックをします。
右クリック後、「ブループリントクラス」を選択します。

「親クラスを選択」ウィンドウで下部の「すべてのクラス」からdefaultPawnを検索し、選択します。

DefaultPawnを親クラスとして設定したポーンを作成できるので、名前を設定します。
今回はxR_Pawnとしました。

作成したxR_Pawnをダブルクリックし、ウィンドウを開きます。
コンポーネントを追加」からcameraを検索し、カメラコンポーネントを追加します。

Collision ComponentMesh Componentコリジョンの設定をNoCollisionに設定します。

設定が終わったらコンパイルを実施し、保存します。

次にゲームモードベースを作成します。
コンテンツブラウザ中で右クリックし、ブループリントクラスを選択します。

「親クラスを選択」ウィンドウから「すべてのクラス」でGameModeBaseを検索し、選択します。
ブループリントの名前はxRGameModeBaseとしました。

作成したxRGameModeBaseをダブルクリックし、右の詳細ウィンドウを編集します。
Default Pawn Classに先ほど作成したxR_Pawnを設定します。

設定したらコンパイルを実行し、保存します。
最後に、「編集」→「プロジェクト設定」を開き「マップ&モード」を開きます。
ゲームモードには、xRGameModeBaseを、マップにはMainを設定します。

パッケージを作成する

最後にプロジェクトからパッケージを作成します。
「ファイル」→「プロジェクトをパッケージ化」→「HoloLens」を選択します。
パッケージを保存する場所を選び(多くはBuild配下)決定すると、パッケージ化が開始されます。

パッケージ化が成功すると、先ほど出力先に指定したディレクトリにアプリパッケージが作成されます。
Device PortalからインストールすることでHoloLensでアプリが使用できるようになります。

docs.microsoft.com

動作確認

作成したアプリパッケージを実機で動かしてみました。
問題なくお豆腐が表示されています。

影の表現が真っ黒なため、実機だと透明に見えてしまっています。対応が必要そうです。
次回以降は、UnrealEngineのMRTKを触ってみようと思います。

docs.microsoft.com

Neos VRにVroid Studioのモデルをインポートする

たまにはMRではなく、VRの記事でも。
昨日、Neos VRというソーシャルVRプラットフォームを触ってみました。
neos.com

store.steampowered.com

その際、Vroid StudioのモデルをインポートしようとしたらWikiの情報だけでは作業が詰まってしまったのでメモしておきます。
概要は以下です。

  • Blenderでのモデルインポート時、テクスチャフォルダが作成されなかったため、FBXファイル自体にテクスチャデータを含めた。

参考:

neosvrjp.memo.wiki

前提

今回はVroid Studioでモデル作成済み、Neos VRのアカウント作成済みの状態から始めます。
基本的にはwikiの情報とほぼ同じですが、一覧性確保のため一連の流れをまとめておきます。

使用バージョン

Vroid Studio 1.6.0
Blender 2.9.3
Neos VR Beta 2022.1.28.1310

Vroid Studioからモデルをエクスポートする

まずは、Vroid Studioで作成したモデルをエクスポートします。
Vroid Studioで、エクスポートしたいモデルを選択し、開きます。

f:id:napo909:20220410191110p:plain

モデルの読み込みが終わったら、右上の真ん中のボタンをマウスオーバーします。
するとメニューが表示されるので、一番上の「VRMエクスポート」を選択します。
f:id:napo909:20220410191430p:plain

エクスポートの画面が表示されると、モデルのポリゴン数、マテリアル数などが合わせて表示されます。
行う作業は以下の2つです。
行うことでPCの負荷を下げるのに寄与します。

  • PCスペックに不安がある人は「ポリゴン数の削減」からモデルのポリゴン数を削減する。
    スライダから数値を増やすとポリゴン数が削減されるので、見た目が悪くならない程度に削減します。

  • マテリアル数は特にこだわりがなければ2、解像度は2048×2048にする。

設定が終わったら右下のエクスポートボタンを押して、アバター情報を入力した後、任意の場所にVRMファイルを保存します。
f:id:napo909:20220410192320p:plain
f:id:napo909:20220410192609p:plain

VRMモデルをNeos VRで読み込めるFBXに変換する

次に、出力したVRMモデルをFBXに変換します。
Blenderを使用するので、インストールしていない人はインストールします。

www.blender.org

今回は2.9.3を使用します。
Blenderを起動すると、立方体などがすでにシーン上に存在します。
不要なので、右上のアウトライナーウィンドウ(シーン中のオブジェクト一覧がある部分)で全オブジェクトを選択して「Deleteキー」を押し削除します。
f:id:napo909:20220410193511p:plain

アドオンの導入

次に、2つのアドオンを導入します。
アドオンを導入するために、以下からzipファイルをダウンロードします。

zipファイルをダウンロード出来たらBlender上部メニューの「編集」→「プリファレンス」を選択します。
※英語の場合「Edit」→「Preferences」
f:id:napo909:20220410194257p:plain

左メニューから「アドオン」を選択します。 アドオンメニューの「コミュニティ」を選択した状態で「インストール」ボタンを押します。
ファイル選択画面が現れるので、先ほどダウンロードしたアドオンのzipファイルを選択します。
f:id:napo909:20220410194713p:plain

アドオンが正常にインポートされると、インポートしたアドオンが表示されます。
チェックを入れて、アドオンを有効にします。
f:id:napo909:20220410194858p:plain
一度に1つずつしかインポートできないので、もう一方も同様に行います。
インポート後、以下の2つにチェックがついていることを確認してください。
f:id:napo909:20220410195000p:plain
f:id:napo909:20220410195020p:plain

モデル変換

準備ができたので、モデルを読み込みます。
右上のアウトライナーウィンドウの端の「<」ボタンを押します。
タブがいくつか出てくるので、「CATS」を選択します。
f:id:napo909:20220410195654p:plain
f:id:napo909:20220410195756p:plain

「CATS」タブを開いたら、「モデルインポート」ボタンからVroid StudioでエクスポートしたVRMファイルを読み込みます。
f:id:napo909:20220410214540p:plain

しばらくすると、モデルがBlender中に読み込まれます。
なお、wikiには以下の記述がありますが、私の環境では画像フォルダ(テクスチャファイル)が作成されることはありませんでした。

VRMファイルを選択して、インポートします。
暫く待つとモデルがインポートされます。
インポートすると、VRMがあるフォルダに画像フォルダが生成されます。

neosvrjp.memo.wiki

モデル読み込みが終わったら、「モデル修正」ボタンを押し、モデルを最適化します。
モデルが最適化されると、ボーンの位置などが修正され、テクスチャが適用された状態で表示されます。
f:id:napo909:20220410200523p:plain
f:id:napo909:20220410200624p:plain

この後、wikiではFBXファイルに出力していますが、Neos VR中で使用するテクスチャファイルが出力されていないので真っ白なモデルになってしまいます。
そのため、設定からファイル自体にマテリアルデータを含めるようにします。
「設定と更新」の項目をクリックし、メニューを表示します。
設定項目の「エクスポート時にテクスチャを埋め込む」にチェックを入れます。
これで、出力するモデル自体にテクスチャファイルを持たせることができます。
f:id:napo909:20220410223010p:plain

準備ができたらモデルをエクスポートします。
「モデル」項目の「モデルのエクスポート」を選択します。
ファイルエクスプローラーが開くので、「FBXのエクスポート」となっていることを確認し、任意の場所に保存します。
f:id:napo909:20220410201338p:plain

少し待つとFBXファイルが出力されます。
windows純正の3Dビューワで確認すると、テクスチャが適用されていることを確認できます。
f:id:napo909:20220410201544p:plain

Noes VRにモデルを取り込む

先ほど作成したモデルをNeos VRに取り込みます。
まずはNeos VRを起動します。VRモードで入ったほうが操作しやすいです。
アプリ内で、ダッシュメニューを開き、下メニューの右から3番目のファイルブラウザを選択します。
すると、ローカルデータを確認できるので先ほど出力したFBXファイルを選択し、トリガーを2回押します。(ダブルクリックの要領)
f:id:napo909:20220410211639p:plain

正しくFBXが読み込まれるとウィンドウが現れます。
以下の順で選択していきます。
* 3Dモデル
f:id:napo909:20220410213322p:plain

  • レギュラー/アバター
    f:id:napo909:20220410213445p:plain

  • ヒューマノイドの身長に自動設定
    f:id:napo909:20220410213526p:plain

  • 高度な設定
    f:id:napo909:20220410214642p:plain

  • マテリアルを「XiexeToon」へ変更
    f:id:napo909:20220410213632p:plain

  • 「アセットをオブジェクト内に入れる」にチェックを入れ「インポート実行」を押す
    f:id:napo909:20220410213821p:plain

以上の操作でモデルを空間上に表示することができます。
FBXファイル自体にマテリアルを含めているので、モデルにもマテリアルが適用されています。
f:id:napo909:20220410214746j:plain

次にアバターとして使用するために設定します。
ダッシュメニューのホームから「アバタークリエイター」を選択します。
f:id:napo909:20220410212334p:plain

選択すると、ウィンドウとHMD、左右の手が表示されます。
f:id:napo909:20220410214915j:plain

HMDと左右の手ををグラブでつかみ、頭、手の位置に配置していきます。
拡縮することで、大小を調整することもできます。
f:id:napo909:20220410215102j:plain

ある程度調整できたら、「頭の前方向をそろえる」から順番にボタンを押していきます。
おそらく手の位置は「手の位置を合わせる」を押してからでないと正しい位置に左右の手を配置できないので、「手の位置を合わせる」を押してから調整します。
f:id:napo909:20220410215140p:plain
f:id:napo909:20220410215321j:plain

調整が終わったら他人に着られたくない場合は「プロテクトアバター」にチェックを入れ「作成」ボタンを押します。 作成が終わったアバターはたいてい目が白目になっています。
これは後程設定を変えれば治せます。
f:id:napo909:20220410215353j:plain

アバターの頭部分にポインタを当て、トリガーを押すと「アバターを着る」メニューが現れます。
実行するとアバターを着ることができます。
これでアバター自体の作成ができました。
f:id:napo909:20220410215356j:plain

微調整

目の位置があんまりなので修正します。
ダッシュメニューのインベントリを開き、「Essential Tools」から「DevToolTip」をダブルトリガーし、空間に取り出します。
f:id:napo909:20220410220855p:plain

取り出した「DevToolTip」をグラブし、アバターの頭部分にポインタ当てて選択(Oculus Questコントローラの場合は、スティック押し込み)を行います。
アバターの頭の周りに座標軸とBounding Boxが現れたらセカンダリでギズモを開き、「インスペクターを開く」を選択します。
f:id:napo909:20220410220950p:plain

インスペクターが開いたら、「Eye Manager」を選択します。
f:id:napo909:20220410221447p:plain

「EyeRotationDriver」コンポーネントを探し、「MaxSwing」で目の動く範囲を変更します。
5~7程度にするとよいようです。

neosvrjp.memo.wiki

f:id:napo909:20220410221543p:plain

設定が終わったら、×ボタンでインスペクターを閉じます。
f:id:napo909:20220410222005p:plain

これでアバターの目の動きを抑えることができました。

アバターを保存する

せっかく作成したアバターですが、保存しないと次回ログイン時に使用することができません。
保存するためには、ダッシュメニューのインベントリで保存したいディレクトリを開いた状態で、モデルをグラブし、「インベントリーに保存」を選択します。
f:id:napo909:20220410222501p:plain

アバターがインベントリに追加されていれば保存成功です。
アバターを選択した状態で右上の「稲妻マーク」のボタンを選択すると着ることが、「ハートマーク」のボタンを選択することでデフォルトのアバターとして設定することができます。
f:id:napo909:20220410222526p:plain

これでアバターの保存ができました。

影周りの設定は追加で必要な場合もありますが、「JP チュートリアルワールド」に説明が書いてあったはずなのでそちらを参照してください。
neosvrjp.memo.wiki

Neos VR触って実質1日なので固有名詞など誤りなどあるかもしれませんが、気が付き次第後日修正するということで。

これからもちょくちょくNeos VR触っていこうと思います。
こちらでもラジコン飛行機とか作れたらいいな(既にありそうな気もするけど)

Photon(PUN2)でハンドトラッキングを共有する(簡易版)

今回も前回に続きPhotonに関する試作です。
ハンドトラッキングの情報をほかのユーザに知らせるために手のジョイント情報を共有します。
今回は手にメッシュは張らず、各jointを表示することで手を再現します。

今回は下記を一部参考にしました。

akihiro-document.azurewebsites.net

準備

まずはジョイント情報を共有するためのクラスを作成します。
流れは以下です。

  1. MRTKのAPIから各ジョイントのTransform(position, rotation)を取得する
  2. 自分のプレイヤーの手のアバターを各ジョイントのTransformに合わせる
  3. 各ジョイントの相対位置をほかのユーザに送信する
  4. 他のユーザは相対位置に沿って特定ユーザのハンドジョイントを再現する

この流れをスクリプトにすると以下の様になります。

  • HandTrackingSharing.cs
クリックで展開
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Microsoft.MixedReality.Toolkit.Input;
using Microsoft.MixedReality.Toolkit.Utilities;
using Photon.Pun;

/// <summary>
/// ジョイント情報格納用クラス
/// </summary>
[System.Serializable]
public class JointInfo
{
    public TrackedHandJoint targetJoint;
    public Transform targetTransform = null;

    public JointInfo(TrackedHandJoint targetJoint, Transform targetTransform)
    {
        this.targetJoint = targetJoint;
        this.targetTransform = targetTransform;
    }
}

/// <summary>
/// ジョイント情報共有用クラス
/// </summary>
public class HandTrackingSharing : MonoBehaviourPunCallbacks, IPunObservable
{
    /// <summary>
    /// 手の種類(左右)
    /// </summary>
    [SerializeField]
    Handedness whichHand = Handedness.Right;

    /// <summary>
    /// Transform未設定箇所にtmpJointPrefabを使用するか否か
    /// </summary>
    [SerializeField]
    bool useTmpJoint2UnconfigJoint = false;

    /// <summary>
    /// Transform未設定箇所に生成されるPrefab
    /// </summary>
    [SerializeField]
    GameObject tmpJointPrefab;

    /// <summary>
    /// 各ジョイント情報(ジョイント名, Transform)
    /// </summary>
    /// <typeparam name="JointInfo"></typeparam>
    /// <returns></returns>
    [SerializeField]
    private List<JointInfo> fingerJointInfos = new List<JointInfo>()
    {
        new JointInfo(TrackedHandJoint.Wrist, null),
        new JointInfo(TrackedHandJoint.Palm, null),
        new JointInfo(TrackedHandJoint.ThumbMetacarpalJoint, null),
        new JointInfo(TrackedHandJoint.ThumbProximalJoint, null),
        new JointInfo(TrackedHandJoint.ThumbDistalJoint, null),
        new JointInfo(TrackedHandJoint.ThumbTip, null),
        new JointInfo(TrackedHandJoint.IndexMetacarpal, null),
        new JointInfo(TrackedHandJoint.IndexKnuckle, null),
        new JointInfo(TrackedHandJoint.IndexMiddleJoint, null),
        new JointInfo(TrackedHandJoint.IndexDistalJoint, null),
        new JointInfo(TrackedHandJoint.IndexTip, null),
        new JointInfo(TrackedHandJoint.MiddleMetacarpal, null),
        new JointInfo(TrackedHandJoint.MiddleKnuckle, null),
        new JointInfo(TrackedHandJoint.MiddleMiddleJoint, null),
        new JointInfo(TrackedHandJoint.MiddleDistalJoint, null),
        new JointInfo(TrackedHandJoint.MiddleTip, null),
        new JointInfo(TrackedHandJoint.RingMetacarpal, null),
        new JointInfo(TrackedHandJoint.RingKnuckle, null),
        new JointInfo(TrackedHandJoint.RingMiddleJoint, null),
        new JointInfo(TrackedHandJoint.RingDistalJoint, null),
        new JointInfo(TrackedHandJoint.RingTip, null),
        new JointInfo(TrackedHandJoint.PinkyMetacarpal, null),
        new JointInfo(TrackedHandJoint.PinkyKnuckle, null),
        new JointInfo(TrackedHandJoint.PinkyMiddleJoint, null),
        new JointInfo(TrackedHandJoint.PinkyDistalJoint, null),
        new JointInfo(TrackedHandJoint.PinkyTip, null),
    };

    /// <summary>
    /// 有効なジョイント数
    /// </summary>
    private int currentActiveJointNum = 0;

    /// <summary>
    /// 手がトラッキングされているか
    /// </summary>
    private bool isHandTracked = true;

    // Start is called before the first frame update
    void Start()
    {
        SetTmpJointObj();
        currentActiveJointNum = GetActiveJoint();
    }

    // Update is called once per frame
    void Update()
    {
        if(photonView.IsMine)
        {
            isHandTracked = HandJointUtils.TryGetJointPose(fingerJointInfos[0].targetJoint, whichHand, out MixedRealityPose jointPose);
            GetJointInfo();
        }
        else
        {
            SetVisible();
        }
        
    }

    /// <summary>
    /// 各ジョイント情報取得関数
    /// </summary>
    private void GetJointInfo()
    {
        foreach(var fingerJointInfo in fingerJointInfos)
        {
            if (HandJointUtils.TryGetJointPose(fingerJointInfo.targetJoint, whichHand, out MixedRealityPose jointPose))
            {
                fingerJointInfo.targetTransform.position = jointPose.Position;
                fingerJointInfo.targetTransform.rotation = jointPose.Rotation;
            }
        }
    }

    /// <summary>
    /// 仮のジョイントを適用する関数
    /// </summary>
    private void SetTmpJointObj()
    {
        if (useTmpJoint2UnconfigJoint)
        {
            foreach (var fingerJointInfo in fingerJointInfos)
            {
                if (fingerJointInfo.targetTransform == null)
                {
                    if (tmpJointPrefab != null)
                    {
                        var jointObject = Instantiate(tmpJointPrefab, Vector3.zero, Quaternion.identity, this.transform);
                        fingerJointInfo.targetTransform = jointObject.transform;
                    }
                }
            }
        }

    }

    /// <summary>
    /// トラッキング状態によって表示/非表示を切り替え
    /// </summary>
    private void SetVisible()
    {
        foreach (var fingerJointInfo in fingerJointInfos)
        {
            if(fingerJointInfo.targetTransform != null)
            {
                fingerJointInfo.targetTransform.gameObject.SetActive(isHandTracked);
            }
        }
    }

    /// <summary>
    /// 有効なジョイント数取得
    /// </summary>
    /// <returns></returns>
    private int GetActiveJoint()
    {
        int jointNum = 0;
        foreach (var fingerJointInfo in fingerJointInfos)
        {
            if (fingerJointInfo.targetTransform != null) jointNum++;
        }
        return jointNum;
    }

    /// <summary>
    /// ジョイント情報共有関数
    /// </summary>
    /// <param name="stream"></param>
    /// <param name="info"></param>
    void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            // 自オブジェクトの場合
            stream.SendNext(isHandTracked);
            stream.SendNext(currentActiveJointNum);
            foreach (var fingerJointInfo in fingerJointInfos)
            {
                if (fingerJointInfo.targetTransform != null)
                {
                    stream.SendNext(fingerJointInfo.targetTransform.localPosition);
                    stream.SendNext(fingerJointInfo.targetTransform.localRotation);
                }

            }

        }
        else
        {
            // 他ユーザのオブジェクトの場合
            isHandTracked = (bool)stream.ReceiveNext();
            int activeJointNum = (int)stream.ReceiveNext();
            if (activeJointNum == currentActiveJointNum)
            {
                // 有効なジョイント数が同じ場合は情報を受け取る
                foreach (var fingerJointInfo in fingerJointInfos)
                {
                    if (fingerJointInfo.targetTransform != null)
                    {
                        fingerJointInfo.targetTransform.localPosition = (Vector3)stream.ReceiveNext();
                        fingerJointInfo.targetTransform.localRotation = (Quaternion)stream.ReceiveNext();
                    }

                }
            }
        }

    }
}

次にAssets/Resources配下のPlayer用Prefabに左右の手用に空オブジェクトを追加します。

次に先ほど作成したスクリプトコンポーネントとして左右それぞれの空オブジェクトにアタッチします。
今回はUseTmpJoint2UnconfigJointにチェックを入れ、TmpJointPrefabを設定し、ジョイント位置に仮のオブジェクトを表示させます。

設定するPrefabのCubeは下記の設定で問題ありません。

これで簡易的ですが、ハンドトラッキングの情報を共有する準備ができました。

動作確認

単純な仕組みですが、無事ジョイント位置の共有を行うことができました。
移動に補間を入れるとなお見やすそうです。
次回以降で、手にメッシュを張ってより手らしく見えるようにします。

Photon(PUN2)でルーム情報を表示する

今日は久しぶりにPhotonについての記事です。

これまでの実装では1つのルームに対して入室していましたが、複数のルームに分けて情報共有することも可能です。
これを実施することで、同じPhoton CloudまたはPhoton Serverで複数組の情報共有が可能になります。

今回は公式の下記記事を参考にしています。

doc.photonengine.com

準備

アプリ起動時ロビーに入室させる

まずはこれまで作成したPhotonManager.csを編集し、Photon Cloud/Serverに接続後、ロビーに入室するようにします。
ルームリストはロビー入室時にしか確認することができません。
今回、ロビーで取得するルームリストは公式のUpdateCachedRoomListを流用しています。
doc.photonengine.com

  • PhotonManager.cs
クリックで展開
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using Photon.Pun;
using Photon.Realtime;
using Photon.Voice.PUN;

namespace MRTKPhotonTest
{
    /// <summary>
    /// Photon接続用クラス
    /// </summary>
    public class PhotonManager : MonoBehaviourPunCallbacks
    {

        /// <summary>
        /// 自インスタンス
        /// </summary>
        public static PhotonManager instance;

        /// <summary>
        /// ユーザ用プレハブ
        /// </summary>
        [SerializeField]
        private GameObject UserPrefab;

        /// <summary>
        /// 共有基準点
        /// </summary>
        [SerializeField]
        public Transform SharingBaseTransform;

        /// <summary>
        /// ルーム情報
        /// </summary>
        /// <typeparam name="string"></typeparam>
        /// <typeparam name="RoomInfo"></typeparam>
        /// <returns></returns>
        private Dictionary<string, RoomInfo> cachedRoomList = new Dictionary<string, RoomInfo>();   // 2022/03/21 追加

        void Awake()
        {
            // シングルトン化
            if (instance == null)
            {
                // 未作成の場合作成
                instance = this;
                DontDestroyOnLoad(this.gameObject);
            }
            else
            {
                // すでに作成されている場合削除
                Destroy(this.gameObject);
            }
        }
        void Start()
        {
            // PhotonCloudに接続
            Connect();
        }
        public void Connect()
        {
            if (!PhotonNetwork.IsConnected)
            {
                // 未接続の場合PhotonServerSettings.assetに従って接続
                PhotonNetwork.ConnectUsingSettings();
            }
        }

        private void UpdateCachedRoomList(List<RoomInfo> roomList)   // 2022/03/21 追加
        {
            for (int i = 0; i < roomList.Count; i++)
            {
                RoomInfo info = roomList[i];
                if (info.RemovedFromList)
                {
                    cachedRoomList.Remove(info.Name);
                }
                else
                {
                    cachedRoomList[info.Name] = info;
                }
            }
        }


        /// <summary>
        /// サーバ接続した場合のCallBack
        /// </summary>
        public override void OnConnectedToMaster()
        {
            PhotonNetwork.JoinLobby();   // 2022/03/21 編集
        }

        public void JoinRoom(string roomName)   // 2022/03/21 追加
        {
            // ルーム名roomNameに参加する。なければ作る。
            PhotonNetwork.JoinOrCreateRoom(roomName, new RoomOptions(), TypedLobby.Default);
        }

        public void LeaveRoom()   // 2022/03/21 追加
        {
            if(PhotonNetwork.InRoom)
            {
                PhotonNetwork.LeaveRoom();
            }
        }

        /// <summary>
        /// ルーム入室した際のCallBack
        /// </summary>
        public override void OnJoinedRoom()
        {
            Debug.Log("Entered Photon Room!");
            if (UserPrefab != null)
            {
                // ユーザオブジェクトを生成する
                GameObject userObject = PhotonNetwork.Instantiate(this.UserPrefab.name, new Vector3(0f, 0.5f, 0.8f), Quaternion.identity, 0);

                if (SharingBaseTransform != null)
                {
                    // 共有基準点の子オブジェクトにする
                    userObject.transform.parent = SharingBaseTransform;
                }
            }
        }

        /// <summary>
        /// ルームリスト更新時処理
        /// </summary>
        /// <param name="roomList"></param>
        public override void OnRoomListUpdate(List<RoomInfo> roomList)   // 2022/03/21 追加
        {
            UpdateCachedRoomList(roomList);
        }

        /// <summary>
        /// ロビー退出時処理
        /// </summary>
        public override void OnLeftLobby()   // 2022/03/21 追加
        {
            cachedRoomList.Clear();
        }

        /// <summary>
        /// 接続切断時処理
        /// </summary>
        /// <param name="cause"></param>
        public override void OnDisconnected(DisconnectCause cause)   // 2022/03/21 追加
        {
            cachedRoomList.Clear();
        }

        /// <summary>
        /// ルーム情報取得メソッド
        /// </summary>
        /// <returns></returns>
        public Dictionary<string, RoomInfo> GetRoomInfo()   // 2022/03/21 追加
        {
            return cachedRoomList;
        }

    }
}

これで、アプリ起動時に自動でPhoton Cloud/Serverに接続し、ロビーに入室する処理が作成できました。

ルーム名を指定して入室する

今回は別スクリプトでルームリスト情報の表示や、ルーム入室を行う必要があります。
次にその処理を作成します。

まずは、入室したいルーム名を入力するためのInputFieldを用意します。
Hierarchy上で右クリックしてInput Filed - TextMeshProを選択します。
f:id:napo909:20220321151146p:plain

作成したオブジェクトのうち、Canvasを選択し、Convert to MRTK Canvasを選択します。
f:id:napo909:20220321151414p:plain

これでCanvasサイズをシーン中で自由に変更できるようになるので、使いやすい大きさに変更します。
次に、Input Fieldの隣に入室用のボタンを2つ、TextMeshProを1つ追加します。
Hierarchy上で右クリックし、TextMeshProを選択します。 f:id:napo909:20220321153657p:plain
ボタンは1つは入室用、もう一つは退室用です。
f:id:napo909:20220321160442p:plain f:id:napo909:20220321160725p:plain 下記スクリプトをアタッチし、入室、退室ボタンにそれぞれメソッドを設定します。

  • EnterRoom.cs
クリックで展開
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

using Photon.Pun;
using Photon.Realtime;
using Photon.Voice.PUN;

namespace MRTKPhotonTest
{
    public class EnterRoom : MonoBehaviour
    {
        [SerializeField]
        private TMP_InputField inputFiled;

        [SerializeField]
        private TextMeshPro logText;
        
        // Start is called before the first frame update
        void Start()
        {

        }

        // Update is called once per frame
        void Update()
        {

        }

        public void LeaveRoom()
        {
            PhotonManager.instance.LeaveRoom();
            logText.text = $"Left room";
        }

        public void EnterNamedRoom()
        {
            string roomName = inputFiled.text;
            if(!string.IsNullOrEmpty(roomName))
            {
                PhotonManager.instance.JoinRoom(roomName);
                logText.text = $"Entered {roomName} room";
            }
        }
    }
}

f:id:napo909:20220321160521p:plain

f:id:napo909:20220321153511p:plain

これで、ロビー入室後のルーム入退室の準備ができました。

ルーム情報を表示する

最後にルーム情報を表示する設定を行います。
今回はテキストベースで既存のルーム名と、入室人数を表示する簡単なものにしています。
Hierarchy上で右クリックし、TextMeshProを選択します。 f:id:napo909:20220321153657p:plain

任意の大きさ、位置に設定して下記スクリプトをアタッチします。
f:id:napo909:20220321153820p:plain

  • ShowRoomInfo.cs
クリックで展開
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using Photon.Pun;
using Photon.Realtime;
using Photon.Voice.PUN;


namespace MRTKPhotonTest
{
    public class ShowRoomInfo : MonoBehaviour
    {
        /// <summary>
        /// 更新間隔(秒)
        /// </summary>
        [SerializeField]
        private float timeInterval = 5f;

        /// <summary>
        /// 更新カウンタ
        /// </summary>
        private float timeCount = 0f;

        /// <summary>
        /// ルーム情報表示テキスト
        /// </summary>
        [SerializeField]
        private TextMeshPro infoText;

        // Start is called before the first frame update
        void Start()
        {

        }

        // Update is called once per frame
        void Update()
        {
            timeCount += Time.deltaTime;
            if (timeCount > timeInterval)
            {
                ShowRoomInfomation();
                timeCount = 0;
            }

        }

        /// <summary>
        /// ルーム情報表示メソッド
        /// </summary>
        private void ShowRoomInfomation()
        {
            infoText.text = "";
            if (PhotonNetwork.InLobby)
            {
                Dictionary<string, RoomInfo> roomInfoDict = PhotonManager.instance.GetRoomInfo();

                foreach (var info in roomInfoDict)
                {
                    infoText.text += $"RoomName:{info.Key}, Num of User:{info.Value.PlayerCount}\n";
                }
            }

        }
    }
}

f:id:napo909:20220321154018p:plain

これで存在するルーム情報を表示する準備ができました。

動作確認

HoloLens2側で先にルーム「test」に入室した後に、PC側でアプリを起動すると、ルーム「test」の情報が表示されました。
また、ルーム「test」に入室すると、HoloLens2側のユーザが表示され、別のルーム「test2」の場合はルームを新規作成しているので、誰もいない新規ルームに入室できました。

MRTK(2.7.2)のExampleを試す その5 (Demos - HandCoach)

今回は下記の続きです。

xr-physics-work-etc.hatenablog.com

前提

今回の前提は以下です。
Unity 2019.4.17f1
MRTK 2.7.2

準備

いつものようにPackage Managerからデモをインポートします。
すでにImport済みの場合は、Input againで再度インポートできます。
f:id:napo909:20220227143711p:plain

HandCoachExample

このシーンはユーザに手の動きを指示するための、基本的な動きを確認することができます。
タップや選択などの基本的な操作を仮想の手で、ヒント表示することができます。
f:id:napo909:20220227144512p:plain

HandInteractionHint.cs

このスクリプトでは、手の動きを指示する動きのアニメーションの設定を行うことができます。
使用する際、アタッチしたオブジェクトの子オブジェクトにHandRigを設定して使用します。
f:id:napo909:20220227145811p:plain

github.com

例 : StaticHandCoachRoot_R
f:id:napo909:20220227145855p:plain

  • Hint Display Delay
    手がトラッキングされていない状態で、手を表示するまでの遅延時間(秒)。

  • Hide If Hand Tracked
    bool。Trueの場合、手がトラッキングされている際にはヒントが表示されない。

  • Tracked Hand Hint Dispaly Delay
    手がトラッキングされている状態で、手を表示するまでの遅延時間(秒)。

  • Auto Activate
    bool。Trueの場合、コンポーネントがActiveになるたびにヒントが表示される。

  • Animation State
    ループ中に再生するアニメーション名を文字列で設定。 f:id:napo909:20220227171106p:plain
    右手の場合。
    ※ 子オブジェクトのAnimatorを使用。

  • Repeat Delay
    ヒントの1ループが終わって次が始まるまでの時間(秒)。

MoveToTarget.cs

このスクリプトでは、手のヒントをある位置から目標位置まで移動させることができます。

github.com

例:InteractionHint_R
f:id:napo909:20220227153256p:plain

  • Tracking Object
    移動前の位置を示すGameObject

  • Target Object
    移動後の位置を示すGameObject

  • Root Object
    ルートオブジェクト。相対位置を算出するために使用。
    Tracking ObjectとTarget Objectと共通の親オブジェクトを選択する。

  • Duration
    Traking ObjectからTarget Objectまでの移動時間

  • Target Offset
    目標位置座標の調整用オフセット。

  • Animation Curve
    Tracking ObjectからTarget Objectに移動する際のLerp曲線。

public関数のFollow()とMoveToTargetPosition()は、アニメーションイベントとして登録され、アニメーションが実施されるタイミングで実行されます。
これらを外部のスクリプトから実行すれば、HandInteractionHint.csと切り離して移動用の関数として使用できます。

docs.unity3d.com

f:id:napo909:20220227175328p:plain

RotateAroundPoint.cs

このスクリプトでは、ある点を中心に手のヒントを回転させることができます。

github.com

例:RotatingHandCoachRoot_R
f:id:napo909:20220227165611p:plain

  • Centered Parent
    回転中心を中心とした親オブジェクト

  • Inverse Parent
    ハンドの向きを同じにするために、Centerd Parentと逆に回転させる親オブジェクト

  • Pivot Position
    動作の開始点。

  • Duration
    CenteredParentを中心とした回転の継続時間(秒)。

  • Animation Curve
    回転を時間軸で制御するLerp曲線。

  • Rotation Vector
    各軸を何度回転させるか。

public関数のResetAndDeterminePivot()とRotateToTarget()はアニメーションイベントとして登録され、アニメーションが実施されるタイミングで実行されます。

f:id:napo909:20220227175355p:plain

# 変数名と用途が異なるのは、私の英語力が低くて間違って認識しているだけ。。?

キリが良いので今回はここまで。
次回はDeos-HandTrakingを取り扱います。

HoloLens2でxboxのコントローラを使う(Input System)

今回は、HoloLens2でxboxコントローラを使用する方法をメモします。
UnityのInput Systemで簡単に使用できるようなので試します。
Input SystemはこれまでのUnityの入力系と比べ新しく、拡張、カスタマイズが可能なものであると説明されています。

docs.unity3d.com

せっかくなので、コントローラを用いて下記で使用したVRMを歩かせることを目標とします。

xr-physics-work-etc.hatenablog.com

前提

今回の環境は以下です。

Unity 2019.4.31
MRTK 2.7.3

MRTKは既に導入済みとします。

準備

Input Systemを導入する

まずはInput Systemを導入します。
Input SystemはPackage Managerからインポートできます。
上部メニューのWindow → Pacakge managerを選択します。
f:id:napo909:20220213141934p:plain

Input Systemを見つけ、右下のInstallをクリックします。
f:id:napo909:20220213142006p:plain

Installを行うと再起動を求められるため、Yesをクリックします。
f:id:napo909:20220213142146p:plain

再起動が終わるとUnityの入力が新しいInput Systemに置き換わります。
しかし、MRTKのInput Simulatorでは古い入力方式を使用しているため、このままではPlayモードで動作確認が十分できません。
そのため、新旧両方使えるようにする必要があります。

上部メニューからFile → Build Settings...を選択し、Build Settingsウィンドウを表示します。
f:id:napo909:20220213142616p:plain

Build Settingsウィンドウ左下のPlayer Settings ...ボタンを押して、Project Settingsウィンドウを開きます。
f:id:napo909:20220213142643p:plain

Project SettingウィンドウのPlayer Setting、Other SettingsのActive Input Hnadlingを確認します。
Input Systemを導入したことでInput System Package (New)がアクティブになっていると思います。
MRTKではInput Manager(Old)を使用しているので、一番下のBothを選択します。
f:id:napo909:20220213142918p:plain

再度ウィンドウが表示され再起動を促されるので、Applyを押して再起動します。
f:id:napo909:20220213144012p:plain

これでInput Systemの導入ができました。

VRMモデルを歩かせる

今回はStarter Assets - Third Person Character Controllerを使用します。

assetstore.unity.com

Add to My Assetsを実施した後、Unity Projectに導入します。
f:id:napo909:20220213145059p:plain

導入したら、VRMモデルのAnimatorコンポーネントにAnimation Controllerとして、Assets -> StarterAssets -> ThirdPersonController -> Character -> Animations -> StarterAssetsThirdPersonをアタッチします。
f:id:napo909:20220213145543p:plain

次にThirdPerosonContollerコンポーネントをアタッチします。
すると、Character Controller, Player Inputコンポーネントが一緒にアタッチされます。
次にStarter Assets Inputsもアタッチします。
f:id:napo909:20220213150914p:plain

Player InputコンポーネントのActionsにStarterAssets、Defauilt SchemeXbox Controllerを設定します。
f:id:napo909:20220213150056p:plain

キャラクターのColliderを調整するために、Character ControllerのCenter, Radius, Heightを調整します。
f:id:napo909:20220213151522p:plain f:id:napo909:20220213151530p:plain

次にProject SettingsウィンドウのInput System PackageからSupported Devicesを選択します。
今回は使用するXbox Contorllerを選択します。
f:id:napo909:20220213150540p:plain

このままではキャラクターが無限に落ちていくので、Ground LayersにSpatial Awarenessを設定した上で、Spatial Awarenessを有効にします。
f:id:napo909:20220213151104p:plain f:id:napo909:20220213155700p:plain

また、初期位置として、Spatial Awarenessタグを付与したPlaneを配置し、アプリ起動時に落ちないようにします。
f:id:napo909:20220213152014p:plain

最後にそれでも落ちてしまった場合に復帰できるようにリセットボタンを用意します。
MRTK標準ボタンに下記スクリプトをアタッチし、初期位置に戻せるようにします。

  • ResetPos.cs
クリックで展開
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ResetPos : MonoBehaviour
{
    Vector3 pos = new Vector3(0, 0, 0);
    Quaternion rot = new Quaternion(0, 0, 0, 1);
    Vector3 scale = new Vector3(1, 1, 1);

    [SerializeField]
    Transform targetTransform;
    // Start is called before the first frame update
    void Start()
    {
        pos = targetTransform.position;
        rot = targetTransform.rotation;
        scale = targetTransform.localScale;
    }

    // Update is called once per frame
    public void ResetPosFunc()
    {
        targetTransform.position = pos;
        targetTransform.rotation = rot;
        targetTransform.localScale = scale;
    }
}

f:id:napo909:20220213153506p:plain

HoloLensにxboxコントローラをつなぐ

今回はBluetoothで接続しました。
xboxコントローラをペアリングモードにしたのち、HoloLensの設定からBluetoothを選択します。
バイスの追加からBluetoothまたはその他のデバイスを追加するを選択します。 f:id:napo909:20220213162504j:plain

「デバイスを追加する」画面からBluetoothを選択します。
f:id:napo909:20220213162614j:plain

今回接続したいXbox Wireless Controllerを選択すると、HoloLensに接続することができます。 f:id:napo909:20220213162822j:plain f:id:napo909:20220213162835j:plain

動作確認

無事コントローラでVRMモデルを操作することができました。
Spatialをつかっているので、現実空間の物体がColliderになり、そこにいるような感覚を得ます。
Starter AssetsのAnimatorを使っているためか肩の位置がややおかしいため、微調整するとなお見た目が良くなりそうです。

Input Systemを用いると、HoloLensでxboxコントローラの利用が簡単にできることがわかったので、次回以降バーチャルなラジコンなど実装してみようと思います。

PCにHoloLens2の映像を投影する(Miracast)

今回は小技的な手法をメモっておきます。
HoloLens2では、Device PortalのMixed Reality Captureでの映像共有のほかにMiracastでの映像共有が可能です。
最近のWi-Fi搭載のWindows PCでは、外部からの映像をPCで受信する「このPCへのプロジェクション」機能が有効です。
この機能ではWi-Fi機能を使用していますが、Wi-Fiルータへの接続は必要ないため外出先でのデモやほかの人との情報共有等に最適です。
また、後述しますが遅延も少ないので、オンラインミーティング等での画面共有でも使用できます。

docs.microsoft.com

「このPCへのプロジェクション」機能を用いることで、HoloLens2からの映像をPC側で表示することが簡単に実施できます。

準備

PC側の準備

まずは、PC側で「このPCへのプロジェクション」機能を有効にします。
f:id:napo909:20220206145020p:plain
Windowsの「設定」から「システム」を選択します。

f:id:napo909:20220206145911p:plain 「システム」メニューから「このPCへのプロジェクション」を選択します。
最初は各項目がグレーアウトしていると思います。
PCにオプション機能を追加する必要があるので、「オプション機能」をクリックします。

f:id:napo909:20220206150033p:plain
「オプション機能」ウィンドウが表示されたら「機能の追加」ボタンを押します。

f:id:napo909:20220206150150p:plain 「オプションを追加する」の検索窓に「ワイヤレス ディスプレイ」を入力するとプロジェクション機能に必要な機能が見つかります。
「ワイヤレス ディスプレイ」にチェックを入れ「インストール」ボタンを押します。

f:id:napo909:20220206150354p:plain しばらく待つとインストールが完了します。

f:id:napo909:20220206150903p:plain 「このPCへのプロジェクション」の設定に戻るとグレーアウトしていた項目が設定できるようになります。
一番上の項目を「セキュリティで保護されたネットワーク上のどこでも利用可能」か、「どこでも使える」に設定すればHoloLens2からの接続を受け入れることができるようになります。
f:id:napo909:20220206151034p:plain 最後に、「このPCへのプロジェクション」の設定画面上部の「このPCへのプロジェクション用の接続アプリを起動します」を選択します。
すると、「接続」ウィンドウが開き、接続待ち状態にできます。

HoloLens2側の準備

HoloLens2側で接続準備をします。

f:id:napo909:20220206152152j:plain 手首に表示されるボタンを押します。
表示されるホーム画面の右下のボタンを押下します。

f:id:napo909:20220206152808j:plain ワイヤレスディスプレイとして、接続先のPC名が表示されるのでタップして選択します。
これでPCへ画面送信が開始されます。

動作確認

PC側、HoloLens2側での準備を終えるとPCの画面にHoloLens2で閲覧している内容を表示できます。
f:id:napo909:20220206152934p:plain Device PortalのMixed Reality Captureと比べると少ない遅延で映像を共有することができます。
それぞれの端末同士のWi-Fi接続状況にもよりますが、あまり途切れることもなく快適に共有できます。

f:id:napo909:20220206153214j:plain HoloLens2側で接続中はホーム画面右下のボタンの表示が変わります。
映像共有をやめる場合は再度ボタンを押します。

これで手軽にHoloLens2中で閲覧している内容を、外から確認できました。