仮想と物理とエトセトラ

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

Unityアプリ + Holographic Remotingでクライアント(HoloLens2)からサーバ(PC)に向けて接続する(OpenXR)

今回は、以前できなかったUnityアプリでHolographic Remotingの接続待ち(Listen)ができるようになったので、方法をメモします。

xr-physics-work-etc.hatenablog.com

Holographic RemotingがOpenXR対応されることで、Unityで使用できるAPIが増えました。

docs.microsoft.com

これを使って、PC側のHolographic RemotingアプリをListen状態で起動し、Hololens2側のHolographic Remoting PlayerからPCに繋ぎに行きます。

前提

Unity 2020.3.16f1
MRTK 2.7.3 (導入、OpenXR設定済み)

準備

まずはOpenXRを用いたHolographic RemotingのUnityアプリを作成します。
今回はMRTKのHandInteractionExamplesシーンをベースに使用します。
PackageManagerからDemos - HandTrackingをImportし、Assets/Samples/Mixed Reality Toolkit Examples/2.7.3/Demos - HandTracking/Scenes/HandInteractionExamples.unityにあるHandInteractionExamplesシーンをダブルクリックし、表示します。

下記URLからHolographic Remotingアプリ作成のためのサンプルを取得し、Unityプロジェクトに導入します。

github.com

Assets/MRTK.Tutorials.PCHolographicRemoting/Scripts/HolographicRemoteConnect.csを参考に下記スクリプトを作成します。

アプリ起動とともに、Holographic RemotingをListen状態で接続待ちにします。

  • HandTrackingSharing.cs
クリックで展開
using UnityEngine;

public class HolographicRemoteListen : MonoBehaviour
{
    private Microsoft.MixedReality.OpenXR.Remoting.RemotingListenConfiguration remotingListenConfiguration 
        = new Microsoft.MixedReality.OpenXR.Remoting.RemotingListenConfiguration 
        { 
            ListenInterface = "0.0.0.0", 
            HandshakeListenPort = 8265, 
            TransportListenPort = 8266, 
            MaxBitrateKbps = 20000 
        };
    
    // Start is called before the first frame update
    void Start()
    {
        Listen();
    }


    public void Listen()
    {

        StartCoroutine(Microsoft.MixedReality.OpenXR.Remoting.AppRemoting.Listen(remotingListenConfiguration));
    }
}

空のGameObjectにHolographic Remotingと名付け、先ほど作成したHolographicRemotingListen.csをアタッチします。

これで、Sceneのセッティングができました。
次に、Projectの設定をします。
ProjectSettings -> XR Plug-in ManagementからHolographic Remoting remote app feature gorupにチェックを入れます。
OPenXR右側に赤いビックリマークが表示されたら、マークをダブルクリックし、表示されるウィンドウのFix AllをIssuesがなくなるまで押下します。

次にOpenXRの項目のHolographic Remoting remote appにチェックがついていることを確認します。

Player SettingsCapabilitiesInternetClient, InternetClientServer, PrivateNetworkClientServerにチェックを入れます。

これで準備ができたので、Build SettingsからUWPビルドをし、Visual Studioでx64でビルドします。

動作確認

下記URLを参考にHoloLens2からURIを使用してHolographic Remoting PlayerをConnectモードで起動すると、Listen状態のUnityアプリに接続できました。
PC側でアプリを起動しておくだけで、何も操作せずにHoloLens2とHolographic Remotingの接続を行うことができました。

bluebirdofoz.hatenablog.com

xr-physics-work-etc.hatenablog.com

PCアップデート その1(Palit Geforce RTX 3080Ti Gaming Proへの換装)

今回はPC構成を更新したので更新前後のベンチマーク結果を記事にします。
VRChatを触っていた際、フレームレート低下によるVR酔いが発生していたため、GPUのアップデートを実施しました。
Palit Geforce RTX 3080Ti Gaming Proは、台湾のPalit社が発売しているGPUです。
日本ではドスパラのみ、代理店をしており他GPUよりマージンがとられていないため(?)安いことが特徴です。

www.dospara.co.jp


PC構成

PCケース:コルセア Carbide 275R
CPU:Ryzen 9 5950X
CPUクーラ:コルセア iCUE H150i ELITE CAPELLIX メモリ:32 GB (G.Skill F4-3200C16D-16GSXWB)
ストレージ:Samsung SSD 980 PRO 1TB 
※Cドライブではないですが、ベンチマークはこっちにインストールしました。
マザーボードMSI MEG X570 UNIFY
GPU
旧: Geforce RTX 2070 SUPER(ASUS DUAL-RTX2070S-O8G-EVO)
新: Geforce RTX 3080 Ti(PalitNED308T019KB-132AA)

GPUを付け替える

まずはPCの電源を完全に落とします。
また、感電や破損を防ぐため電源背面のスイッチを切り、ケーブルを取り外します。

次に、右側四角で示したGPUについている8ピンケーブルを取り外します。
また、左側四角に示す、ケースとの接合ネジを取り外します。

マザーボードからGPUを取り外します。
PCI Expressスロットのフックをゆっくり下げ、まっすぐGPUを引き抜きます。
(表面がホコリで汚いのは気にしない。。。)

次に先ほどと逆の手順で新しいGPUを取り付けます。
GPUを矢印の方向にまっすぐPCI Expressスロットに差し、フックが立ち上がるまで差し込みます。

RTX3080Tiは重く長いGPUのため、たわまないようにする支えがついています。
GPUと合わせ、支えをケースに取り付けます。

最後にGPUに先ほど外した8ピンケーブルを取り付け、電源背面のスイッチを入れ取付完了です。
GPUのファンと干渉を防ぐため、ケーブルの取り回しはこの画像から支え下を通すように変更しました。

PC起動後は、下記からドライバをダウンロードし、インストールしました。

www.palit.com

ベンチマーク結果

3DMARK

3DMARKは定番の3Dベンチマークです。一部の機能は無料で使用できます。

forest.watch.impress.co.jp

  • 2070 SUPER

  • 3080 Ti

[総合]
1.65倍(向上)

[Graphics score]
1.85倍 (向上)

SteamVR Performance Test

SteamVR Performance Testでは、システムがVR Redyかどうか調べることができます。

store.steampowered.com

  • 2070 SUPER
  • 3080 Ti

1.36倍(向上)
※3080Tiは頭打ちしているように見える

FF15ベンチマーク

このベンチマークは負荷が高く、高性能ゲーミングPCの評価によく使用されています。
benchmark.finalfantasyxv.com

  • 2070 SUPER
    1980×1080

    3840×2160

  • 3080 Ti
    1980×1080

    3840×2160

[1980×1080]
1.45倍(向上)

[3840×2160]
1.82倍(向上)

VRMARK

さきほどの3DMARKVR版です。こちらも一部の機能は無料で使用できます。

store.steampowered.com

  • 2070 SUPER

  • 3080 Ti

1.10倍(向上)
※負荷が小さく、あまり差が出ていないように見える。

Ezbench

こちらは最近リリースされたレイトレーシングを重視したベンチマークです。

store.steampowered.com

  • 2070 SUPER
    1980×1080

    3440×1440

  • 3080 Ti
    1980×1080

    3440×1440

[1980×1080]
1.90倍(向上)

[3440×1440]
2.03倍(向上)

こちらはかなり大きく差が出ました。
3080Tiになって、レイトレーシング性能が大幅に向上したようです。
※それでも1980×1080でも平均46FPSですが。

総評

結果としては負荷の大きいベンチマークについて、大幅な性能向上を実現できました。
実際にアプリを使用した場合とは異なる場合もあると思いますが、ある程度の目安になりそうです。
また、ノイズが気になるというレビューの多いPalitですが、私の環境ではそこまでひどく感じませんでした。

これからVRで実際に使用してみて、VR関係の探求に生かそうと思います。
とりあえずは、VRChat, Neos VRに行ってみようかな。

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を取り扱います。