土鍋で雑多煮

UnityでXR・ゲーム開発をしています。学んだことや備忘録、趣味の記録などを書いていきます。

【Meta Quest3】パススルーの部屋の天井の先にバーチャル空間を表示する【URP対応】

どうも、土鍋です。

今回はQuest3のパススルーで部屋の天井をバーチャル空間と繋げる表現を実装しました。

ARコンテンツとして現実とバーチャルをつなぐポータルはよくありますが、それをQuest3固有のシーン把握の機能と組み合わせてやってみました。

おおむねこちらの記事を参考にさせていただきました。ありがとうございます。

knowledge-swimmer.com

また、イワケンラボの方々にもアドバイスを頂いて、うまく動作するものができました。

Meta XR All-in-One SDKの導入とBuilding Blocks

今回、Building BlocksやSceneMeshなどの機能を使用するので以下の記事を参考に導入してください。

MR開発ではMeta XR Simulatorを使うとビルドする必要がなく、作業効率が上がるのでそちらも試してみてください。

donabenabe.hatenablog.com

マテリアルを変更する

上の記事でBuilding BlocksのCamera Rig、Passthrough、Room Modelを追加するところまでを行ってください。

ここまででパススルーされた部屋の壁や物に青いマテリアルが上書きされる状態になると思います。

ここからバーチャル空間を見えるようにしていきます。

壁のマテリアル

OVR Scene ManagerのPlane Prefabにセットされているオブジェクトのマテリアルを変更する必要があります。

Packages/com.meta.xr.mrutilitykit/Core/Materials/InvisibleOccluder.mat

このマテリアルに変更することで、透明なマテリアルですが、実際の部屋のパススルーのみを映し、バーチャル側のものは遮蔽されて見えなくなります。

補足すると、このマテリアルはRenderQueueが1998なので、デフォルト2000の多くのオブジェクトより先に描画されるということになるようです。

天井のPlane

天井は次のPrefabを用います。

Packages/com.meta.xr.mrutilitykit/Editor/BuildingBlocks/AnchorColliderSpawner/Prefabs/[BB] InvisiblePlane.prefab

このPlaneはMeshが無くSceneAnchorのみがあるので、バーチャル側を見ることができるようになります。(つまり見た目がない)

次にInvisiblePlane PrefabをOVR Scene ManagerのPrefab Overridesにセットします。
今回は天井をバーチャル空間にしたいので"CEILING"を指定します。

ここまでで、このようにバーチャル側の物体が天井に描画されるようになります。
が、Skyboxが見えません。

疑似Skyboxを作る

CenterEyeAnchorのCameraのBackground TypeをSkyboxにすればSkyboxが描画されますが、今度はパススルー映像が見えなくなってしまうので、擬似的なSkyboxを作ることで対応しました。

booth.pm

こちらを用いて、巨大な球体内にSkyboxTextureを描画して、擬似的なSkyboxを作ることができました。

これによって、画像のように天井の先にバーチャル空間を表示することができました!

感想

ここまで読んでいただきありがとうございます。
意外とシンプルな実装でしたが、URPかつQuest3での実装となると、なかなか記事が見つからず様々な手法を試してやっと実現できた感じではあります。
具体的には、Render Texture、ステンシル、カスタムShaderなどなど…
知らない領域の知識が多少ついたので、まあ回り道だったけど良かったかなと思います。

この記事が参考になれば幸いです。

参考記事
- Meta Quest の パススルー表示とSkyboxの共存 | Knowledge Swimmer メモ
- Meta Quest の パススルーとオクルージョン | Knowledge Swimmer メモ
- MRTKではじめるPortal表現 | ドクセル

2023年の振り返りと2024年の抱負

どうも、土鍋です。

もう2023年が終わってしまいますね。今年はなかなか起伏のある年になった気がします。ということで、例年通り振り返り記事を書いていきます。

2023年の振り返り

1月

インターン

MyDearestでゲームデザイナーインターンとしてディスクロニア三章に携わりました。

dyschroniaca.com

Flamersも12月から開始して引き続き働かさせていただきました。この頃からUI部分の設計などより深い部分に関わることができ、エンジニアとしての力を試されてるなと感じ、楽しかったです。

Flamers週2日、MyDearest週3日というスケジュールで働いていたので、休日はほぼだらだらして終わっていた気がします。もうインターン以外何もできませんでした。

2月

インターン

MyDearest
仕事を覚えてきて、ようやくまともにゲームデザイナーとして仕事になってきた感じでした。中途でゲーム会社から来た方も多いので、様々な業界の話を聞けたのも楽しかったです。

Flamers
六本木ヒルズにオフィスが移って(一年限定)、数日だけでしたが、ヒルズに出勤してきれいなオフィスで働く経験ができて最高でした。(コーヒータダ最高!)

初めての新聞掲載

福島民友さまに「自宅でも遭難がしたい!」を取材していただいた。初めての取材に緊張しましたが、とても良い記事仕上げていただいてめちゃくちゃ嬉しかったですね。

3月

インターン

GREE Camp
プランナー、デザイナー、エンジニアという構成で2日間でゲームを作り上げるインターンでした。どこまでインターン内容を明かしていいか分からないのですが、本当に貴重な体験ができたと同時に自分の力不足を感じました。短期間ですばやくゲームモックを作り上げる経験の無さが如実に影響しました。また、チームメンバーからのフィードバックで心境や悩みを開示してほしいというのをいただきました。言われて初めて気づきました。もともと弱音を吐くのは苦手なタチなのでこういうときは開示すべきなんだなと学びました。

MyDearest
VRゲーム会社で働くという稀有な経験をできたと思います。大学院試験勉強のためにインターンをやる余裕はないと思い、三ヶ月という短い期間になってしまいましたが辞めさせていただきました。ほんとにわがままを聞いていただいてありがたい限りです。

4月

院試の勉強

1月から3月もうっすらやってましたが、インターンにほぼ平日毎日行っていたので、まるで進んでいませんでした。インターン行くのを中断し、院試対策のみに注力することにしました。大学一年時の参考書もまるで理解できないようになっていたのであらためて、復習からはじめました。

5月

全国数社の地方紙に記事掲載

共同通信社様に「自宅でも遭難がしたい!」を取材していただいた。その記事は全国数社の地方新聞に掲載されました。

mainichi.jp

技術書典

ブログを雑に書くことは今までにやってきましたが、ちゃんとした本を書くという経験はなかったので貴重な経験をさせていただきました。ちゃんと"技術書"って感じだ~!と感動したのを覚えてます。

院試の勉強

研究計画を立て始めましたが、めちゃくちゃ沼りました。そもそもまともに研究をしたことがなかったので、書き方から手探りでした。最終的に多くの人に添削していただいて、ひとまず形にはなりました。

6月

テレビ出演

人生で初めて自分たちをテレビで取り上げてもらうという経験をしました。

www3.nhk.or.jp

院試の勉強

ここらへんでやっとまともに問題集の基礎が理解できるようになりました。といっても試験問題を解けるレベルではなかったので、ひたすら問題集やってました。

7月

院試

某奈良の大学院を受験したのですが不合格でした。おそらく研究計画の拙さが一番大きいと振り返ると思います。かなり自信を失いました。この後も受験が控えているのにも関わらず、数日全然集中できなくなりました。ここらへんが一番気分的に落ちていたと思います。

8月

院試

筑波大大学院を受験しました。不合格が出た後&推薦でかなり合格者がいたという噂があって、とにかく不安でしかなかったです。自分がやってきたすべてに自信を失いかけましたが、なんとかモチベを取り戻し受験の日を迎えました。当日ははっきり言ってあんまりできた感触がなかったので、不安で不安で仕方なかったです。

9月

院試合格

とにかく嬉しかったです。数ヶ月の努力とこれまでやってきたことの経験が生きた気がして報われた気持ちになりました。また、多くの人に支えられた結果でもあります。本当にありがとうございました。

院試対策(研究室見学など)は早いに越したことはないので、数日後に合格体験記をすぐに書き上げ公開しました。

donabenabe.hatenablog.com

10月

Flamersインターン再開

院試が終わったら戻ると言ってインターンを"中断"という形にさせていただいていました。こんな使いづらい人材切ってくれても普通なのに再び働かさせていただけることに感謝です。

Quest3発売

発表後すぐに予約して購入しました。これがあれば絶対面白いことができると確信していたので届いて早速、ライブラリのMR新機能を触ってみました。

donabenabe.hatenablog.com

11月

Quest3特化ゲームプロジェクト発足

Quest3のMR機能をフルに生かしたゲームを作りたくて、サークルで募集してプロジェクトを発足しました。マルチプレイやらSpatialAnchorやらが普通にゲーム作るのとはかなり違う開発体験で、なかなか苦労しています。コミケを目標にしていましたが、間に合わず、今は3月頃に公開できることを目指しています。

12月

国学VRハッカソン

23、24(イブ?なにそれ)でハッカソンがありました。徹夜で作業してなんとか動くものが完成しました。

全国学生VRハッカソン チーム墜落したってダイジョウV - YouTube

感想としては、もう少し提示した審査基準に準拠したものをつくることが賞につながるんだろうなと感じました(当たり前ではある)
優勝準優勝がプラットフォームやら基盤システム的なものだったので、一つのものにフォーカスしたものより拡張性があるものが事業性あるとして評価された感覚を受けました。
でも審査員以外の人二人から優勝すると思ってたって言ってもらえたのは嬉しかったですね。点数も1点差くらいで接戦だったらしい。

来年の抱負

やはり一番大きい環境変化は大学院進学です。大学院でも引き続きXR関連の研究ができる先生のもとで研究するので、今までの知識を活かしつつ、最新の領域を切り開く経験をできると確信しています。また、学ぶ環境も人間関係も新しく構築され、広がります。そして絶対に自分の成長を加速させる環境だと思います。それを享受しつつ、圧倒的成長を遂げたいです。

またもう一つ挑戦していきたいのは、コンテンツを完成させて世に出すことを成し遂げたいです。今まで多くの場合作って満足、コミケで配布して満足くらいの感覚で作品を作ってきましたが、完成度高くしっかりとストアなどにアップして人の手に回っても恥のない作品を作る経験をすべきだと最近は思っています。これは今後の仕事や研究においても、クオリティを妥協しないというのは生きてくると思うので挑戦していきたいです。

今年も多くの人に支えられて生きてこれました。ありがとうございました。2024年の土鍋も引き続きよろしくお願いします。

Quest3のMesh APIやDepth APIでMR開発を始めよう

どうも、土鍋です。

本記事は、Iwaken Lab.アドベントカレンダー 17日目の記事です。
昨日はソラシドさんの 毎日モデリングやってみた でした。毎日チャレンジした人は圧倒的成長しているイメージあるんですよね~。毎日続けるってそれだけでもすごいので、私も見習わなきゃですね…

さて、今回はQuest3に搭載された深度センサーを活用するMesh APIやDepth APIを紹介します。ついでに最近のSDK周りのアプデにも触れてます。

新しいMetaのSDK

今までMeta Questの開発ではOculus Integrationが使用されてきましたが、v59より非推奨になり、新しくUPM対応したSDKが登場しました。Oculus Integrationは普段使わないものもあってかなり重いパッケージの印象がありましたが、新SDKからはそれぞれの機能別にインポートできるようになりました。
特にMeta XR Core SDKVR開発の基礎機能のみに絞られ、これだけで簡単なVR開発はできます。
Oculus Integrationの後継という意味ではMeta XR All-in-One SDKがそれにあたると思います。これをインポートすることで、複数のパッケージが自動的にインポートされる仕組みになっています。

assetstore.unity.com

Meta XR Simulator

MR開発において、面倒なのはデバッグです。Unity'上には現実のパススルーは表示されないのでそのままではビルドしないとデバッグできません。 そこで便利なのが「Meta XR Simulator」です。

assetstore.unity.com

インポートすると、Oculus → Meta XR SimulatorからActivateすることでPlayしたときに起動するようになります。 さらに、Synthetic Environment Serverからパススルーで見える現実空間をシミュレートできるようになります。これによって相当デバッグが行いやすくなるのでぜひ使ってみてください。

↑UnityのGameViewではパススルー映像は見ることができないが、
↓Meta XR Simulatorでは確認できる。

Building Blocks

Oculus Integrationでは用意されたPrefabを置くことでVRのPlayerをセットアップしていましたが、v57からはBuilding Blocksという機能が追加され、簡単に必要な機能を選んでセットアップできるようになりました。

Mesh API

Mesh APIは深度センサーで取得した部屋のデータからメッシュを生成するものです。

Scene Mesh | Oculus Developers

サンプルとして面白いのはPhanto。

github.com

使用方法

Building BlocksのCamera Rig、Passthrough、Room Modelを追加してください。これだけでMesh APIを試せます。

PlaneとVolumeというものがOVR Scene Managerにセットされています。
Planeは壁や床、天井といった部屋の形状を形作る平面を示します。
Volumeは机や椅子といった家具を示すCubeのことを示します。

Meta XR SimulatorのRoomでテストするとPlaneとVolumeの動作が確認できます。※後述のGlobal Meshの動作確認は現時点ではできなかった。

Global Mesh

Global Meshは部屋の3Dスキャンデータをそのままメッシュ化できるものです。現実世界に仮想のオブジェクトが干渉するようなものを制作する際に必要になる機能です。Volumeだけでは机や椅子など用意された大きい家具にしか対応できません。しかし、 スキャンデータそのままであるGlobal Meshを使うことでコップやモニターといった小物にも対応できるようになります。

セットアップ

空のGameObjectを作りコンポーネントを次のようにつけてください。

OVRSceneManagerのPrefab Overridesに項目を追加し、先程のGameObjectを選択します。そして上のプルダウンはGLOBAL_MESHを選択します。このようにすることで、部屋のスキャンデータをメッシュとして利用することができます。

分かりづらいですが、壁だけでなく雑に置かれた雑貨にもメッシュが貼られています。

※2024/03/28追記
Meta XR SimulatorではGlobal Meshは機能しないので、実機にビルドして確認する必要があります。

Depth API

Depth APIは家具や腕の後ろに仮想の3Dオブジェクトが遮蔽されるオクルージョンを可能にする機能です。MRにおいてオクルージョンがないと没入感が損なわれますのでこれは重要です。 Mesh APIとの違いはこちらはリアルタイムに処理が可能であるというのが大きいでしょう。

Depth API for Unity | Oculus Developers

サンプルプロジェクト

github.com

家具や腕に3Dオブジェクトが遮蔽されているのが分かると思います。 Hard Occlusionは境界がカクカクになり、Soft Occlusionでは境界がぼやけます。個人的に動かしたときに違和感がなかったのはSoft Occlusionでした。

導入方法

公式導入方法解説↓
https://github.com/oculus-samples/Unity-DepthAPI?tab=readme-ov-file#using-the-commetaxrdepthapi-package

Packages/Depth API/Runtime/Core/PrefabsにあるEnvironmentDepthOcclusionをシーンに設置し、オクルージョンを適用させたいオブジェクトにオクルージョンシェーダーをセットすることでオクルージョンさせることができます。

シェーダーに関してはBuilt-inとURPで差があるので注意してください。
リンクから飛んだ先にカスタムシェーダーでのオクルージョンの適用方法も書いてあるので参考にしてください。

まとめ

本当はShered Spatial Anchorsの記事書きたかったんですが、まだ理解が浅い&記事書く時間が足りなかったので、Quest3のMesh APIやDepth APIを紹介しました。Quest3はめちゃめちゃポテンシャルあると思っているので、Quest3の機能をフルに生かしたMRゲームの開発を始めました(いつか公開したいなぁ…)

明日18日はユーゴさんの記事です。お楽しみに!

参考

Mesh API と Depth API による Meta Quest 3 のMRエクスペリエンスの構築|npaka

Quest3MR開発入門|jig.jp engineers

Shader GraphをQuestで使用する際に詰まった点

どうも、土鍋です。

Quest向けの開発をしていたところ、Shader Graphを使用しオブジェクトのアウトラインを描こうとしたところ、全く反映されませんでした。一時間近く試行錯誤したのですが、あっさり解決したので、備忘録として残します。

前提

こちらの記事を参考にさせて頂き、アウトラインを描こうとしました。 qiita.com

URP-HighFidelity-RendererのRender Featuresに作成したマテリアルをセットし、反映するレイヤーを設定しました。シーンビュー、ゲームビューともに正しく描画されています。

しかし、Questにビルドすると反映されませんでした。

結論

Project Settings → Qualityを開き、Levelsを確認します。
初期設定ではHigh Fidelityになっているのですが、AndroidではBalancedでビルドされます。
そのため、URPのSettingファイルはBalancedをセットアップする必要がありました。

かなりしょーもないミスでしたが、同じように気づかない方もいると思いますので、どなたかの参考になれば幸いです。

UnityからStable Diffusion web UI APIを叩いてテクスチャを生成する

どうも、土鍋です。
UnityからプロンプトをStableDiffusion web UI APIに送信して生成した画像をUnityで表示するということを一連でやっている記事を見かけなかったので書きました。

実装したもの

プロンプトを打ってボタンを押すと、画像を生成し、テクスチャに反映するものを実装しました。

Stable Diffusion web UIの起動

github.com

stable-diffusion-webuiディレクトリで.\webui-user.batを実行することで起動できます。
初回起動時はそれなりに時間がかかります。

※ここではStable Diffusion web UIの詳しい解説は書きません。

APIの起動

webui-user.batのset COMMANDLINE_ARGS=--apiを追加することでweb UIとAPIを起動できます。

Text To Image

非同期処理部分はUniTaskを使用して実装してあります。

書いたコード

とりあえず、今回のコード全体を見たい方はこちら

今回のコード

Jsonを受け取ってAPI叩いて生成されたテクスチャを返すコード

using System;
using System.Text.RegularExpressions;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public class TextToImage
{
    private string url = "http://127.0.0.1:7860";

    /// <summary>
    /// Text to ImageのAPIリクエスト
    /// </summary>
    /// <param name="json"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public async UniTask<Texture2D> PostT2I(string json, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();

        byte[] postData = System.Text.Encoding.UTF8.GetBytes(json);
        var request = new UnityWebRequest(url + "/sdapi/v1/txt2img", "POST");
        request.uploadHandler = (UploadHandler)new UploadHandlerRaw(postData);
        request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        // APIリクエストを送信
        Debug.Log("Send Prompt");
        await request.SendWebRequest().WithCancellation(cancellationToken);

        if (request.result == UnityWebRequest.Result.Success)
        {
            Debug.Log("Request Success");
                
            // レスポンスのJsonを取得
            var response = request.downloadHandler.text;

            // 「"」で囲まれた文字列を抽出
            var matches = new Regex("\"(.+?)\"").Matches(response);

            // 画像データを取得
            var imageData = matches[1].ToString().Trim('"');

            // Base64をbyte型配列に変換
            byte[] data = Convert.FromBase64String(imageData);

            // byte型配列をテクスチャに変換
            Texture2D texture = new Texture2D(1, 1);
            texture.LoadImage(data);

            return texture;
        }
        else
        {
            Debug.Log("Error:" + request.result);
            
            Texture2D texture = new Texture2D(1, 1);
            return texture;
        }
    }
}

InputFieldにプロンプトを入力され、ボタンが押されると、画像を生成するコード

using UnityEngine;
using UnityEngine.UI;

public class Test2ImageTest : MonoBehaviour
{
    [SerializeField] private Image image;
    [SerializeField] private InputField inputField;
    [SerializeField] private Button sendButton;
    
    private TextToImage _t2I = new TextToImage();

    [System.Serializable]
    public class RequestData{ 
        public string prompt;
    }

    void Start()
    {
        sendButton.onClick.AddListener(()=> 
        {
            SendPrompt(inputField.text);
        });
    }

    async void SendPrompt(string prompt)
    {
        // Jsonに変換
        RequestData requestData = new RequestData();
        requestData.prompt = prompt;
        var json = JsonUtility.ToJson(requestData);
        
        // リクエスト
        var result = await _t2I.PostT2I(json);
        
        // Texture2DからSpriteに変換
        image.sprite = Sprite.Create(result, new Rect(0, 0, result.width, result.height), Vector2.zero);
    }
}

解説

http://127.0.0.1:7860/docs にアクセスすることでFastAPIのドキュメントページを確認できます。

今回はTextToImageなので/sdapi/v1/txt2imgを確認します。

リクエスJSON

何も記述しなければデフォルトのものが使われるので、とりあえずPromptだけのJsonにします。 UnityにはオブジェクトをJsonに変換するJsonUtilityがあるので、まずはJsonに対応したクラスを書きます。

[System.Serializable]
public class RequestData{ 
    public string prompt;
}

JsonUtility.ToJson()でオブジェクトをJsonに変換します。

RequestData requestData = new RequestData();
requestData.prompt = prompt;
var json = JsonUtility.ToJson(requestData);

POSTリクエス

Jsonをbyte配列に変換し、UnityWebRequestをPOST形式で生成し、もろもろ設定。

byte[] postData = System.Text.Encoding.UTF8.GetBytes(json);
var request = new UnityWebRequest(url + "/sdapi/v1/txt2img", "POST");
request.uploadHandler = (UploadHandler)new UploadHandlerRaw(postData);
request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");

リクエストを投げる。

await request.SendWebRequest().WithCancellation(cancellationToken);

レスポンスJSON

上記のようなJsonレスポンスが返ってくるので、imageデータを抽出。

// レスポンスのJsonを取得
var response = request.downloadHandler.text;

// 「"」で囲まれた文字列を抽出
 var matches = new Regex("\"(.+?)\"").Matches(response);

 // 画像データを取得
var imageData = matches[1].ToString().Trim('"');

imageはBase64で返ってくるのでbyte配列に変換してあげる。

byte[] data = Convert.FromBase64String(imageData);

byte配列をテクスチャに変換

Texture2D texture = new Texture2D(1, 1);
texture.LoadImage(data);

最後にテクスチャからSpriteを生成して、画面に表示する。

image.sprite = Sprite.Create(result, new Rect(0, 0, result.width, result.height), Vector2.zero);

まとめ

今回はプロンプトのみのリクエストでしたが、テクスチャサイズによって生成サイズを変わるようにしたり、ChatGPTと組み合わせれば、面白いことができそうです。

参考

筑波大学大学院 理工情報生命学術院 システム情報工学研究群 知能機能システム学位プログラム 一般入試 合格体験記(外部進学)

知能機能システム学位プログラム - www.imis.tsukuba.ac.jp

筑波大学大学院 理工情報生命学術院 システム情報工学研究群 知能機能システム学位プログラムに合格したので、その体験を残しておこうと思います。
外部進学で一般入試受験の記事が見当たらなかったので、誰かの参考になれば嬉しいです。

はじめに

当たり前ですが 外進で一番大変なことは情報を集めること です。

外進の場合、研究室の雰囲気、院試の詳細を知るのは気軽にはできないです。

そのため、動き出しは速い方がいいと思います。
研究計画を書く以上、自分が一番興味があってスムーズにアイデアを思いつく研究領域の研究室を見つける必要があります。オープンキャンパスや見学会にいって様々な研究室を見るべきです。
外進のメリットは選択肢が豊富なことですしね!
また研究室が決まっても研究計画は迷走するので、時間を取られると思っておいた方がいいです。

私の経験を書きますが、人それぞれ合う方法があると思いますので、あくまで参考程度にしてください。

外部大学院進学を決めるまで

大学ではVRサークルに加入し、VRゲームを開発していました。
1,2年の頃は就活と院進半々で考えてインターンなどにも行っていました。
学部3年のときはIVRCという学生VRコンテストに向けて開発を行い、最終的に審査委員会特別賞を頂きました。

この頃にはこの領域を大学院でも研究したいと思うようになったのですが、私の所属研究室の先生が私の代を最後に退職するということで、必然的に研究室を探すことになりました。 しかし、その研究室以外私の大学にはXR関連の研究室がなかったので外部進学を志すようになりました。

志望研究室決定

2022年12月までIVRCの開発やコミケに向けたゲーム開発をしていたので、志望研究室を探し始めたのは年が明けてからでした。 年明け以降平日は毎日長期インターンに行きながら、ひたすらVR・AR・MR関連で志望研究室を探しました。

そんな中で筑波大の知能機能システム学位プログラムでは興味のある研究室が複数あると思い、3月のオープンキャンパスに行くことを決めました。

オープンキャンパス

オープンキャンパスは多くの大学院の場合11月~翌年6月くらいにかけて開いているところが多いです。
研究室独自で見学会を開いているところもあるので情報収集大事です。
もし見学会がなくてもメールを送って見学できないか聞いてみると受け入れてくれるケースもあるみたいです。
また、夏休みなどにインターンやサマースクールのようなものを開催している研究室もあります。

知能機能システム学位プログラム

私は3月の対面のオープンキャンパスに参加しました。 オープンキャンパスは対面とオンラインがありますが、個人的には対面が良いと思います。 デモを見ることができますし、教授の方々と直接お話できる機会は大変貴重なのでおすすめです。
研究室の学生の方々から院試の情報を聞くことができるのが外進の人的には重要かもしれません。

今年のオープンキャンパスアーカイブ

志望指導教員決定・指導教員の許諾

志望指導教員はオープンキャンパスで出会った先生と直接お話させていただいた際に、お話頂いた研究テーマがとても興味深く、また、話していてこの人のもとで研究したい!絶対楽しい!とビビビッと感じたので、かなり直感で決めました。

その後、メールで志望研究室の先生に第一志望にする旨を伝え、許諾を頂きました。 オープンキャンパスで先生との面談も済ませていたのでスムーズに話が進みました。

試験対策

一般入試(8月)試験内容

博士前期課程 募集要項 | 筑波大学大学院 募集要項

200点は成績なので、他の大学院と比べると学部時代の成績重視されているんでしょうか…?
ちなみに私の成績はさほど高くないです。(高かったら推薦受けている)

外部英語試験はTOEICを使用する人が多いようです。入試と違って何度も挑戦できるので、一年前くらいから挑戦しておくと試験直前で焦らずにすみます。 (私はギリギリまで焦っていた人です)

専門科目は数学だけですし、内容も線形代数と解析と複素解析だけなので、比較的受験はし易いと思います。

研究計画

私の場合は自分である程度書き上げて、志望指導教員の方に見てもらいました。 ただこれは研究室によっては先生と考えていく形の場合もあるかもしれないです。

ちなみに研究計画はめちゃくちゃ迷走しました。
志望先の研究分野に近いものというのは当たり前ですが、それでもまだまだ広いのでなかなか手をつけられませんでした。 そこからアイデアを大量に出して一番書きやすいものを選び、それに関する関連論文を探し、手法を検討し、書き上げていきました。

しかし、研究目的を失って手法ばかり検討してしまったり、矛盾が発生したり、無限に迷走しました。
これに関しては一人じゃ解消が難しいです。
私は志望研究室の先輩や大学の友人、開発者コミュニティの方々に添削してもらいました。本当にいろんな人にお世話になりました。

研究計画はあくまで"計画"なので、背景と目的はしっかり書いたほうが良いです。なぜ研究する必要があるのか、新規性はなんなのか、それが伝わるように書くと良いと思います。
具体的にどのように研究計画を書くと良いのかは私も最良の手法を知らないので、ここには書きません。

TOEIC

2022年秋ごろからちょくちょく受験したのですが、なかなか点数は上がりませんでした。
さすがにまずいと思い2023年からはスタディサプリを契約し、勉強しました。
最終的に受験者の平均くらいにはなったのかなと思います。

数学

私は学部一年のときに線形代数と解析をやってからまるで勉強していなかったので、初めは一問も解けませんでした。 後述する参考書・問題集を解きながら、自分なりのまとめノートを作りながら勉強していました。

スケジュール

4,5月 ほとんど忘れている線形代数と解析を総復習。
6,7月 応用問題、複素解析
7,8月 過去問と関連する問題

使用した参考書・問題集

自分の使った参考書・問題集を貼っておきます。

明解演習 線形代数


かなり細かい説明があって問題数が多いです。個人的には少し難易度は高かったので、別のやつでざっとやってからのほうが良いかもです。

1冊でマスター 大学の微分積分


大学一年の時にやってから、ほとんど勉強していなかったので、これで思い出しました。
問題は簡単めですが、説明が分かりやすかったです。

編入数学徹底研究


一番良くまとまっていて、試験範囲もカバーされています。これがスムーズに解けるのであれば、試験は問題ないと思います(おそらく)。 問題数が少ないので、別の問題集でカバーする必要があると思います。

編入数学過去問特訓


上の本で足りない分を補えると思います。

その他参考になったサイト

当日

前日からつくば駅近くのホテルに泊まりました。当日の朝は臨時バスが出ていたのでバスが混みすぎて乗れないということはないと思います。

服装についてですが、事前に電話したところ何を着ても良いとの話だったので、私服で行ったのですが、9割スーツでした…
しかし、合格しているので合否には関係ないです。ただ精神的にやらかした気持ちになるので、スーツの方が無難な気はします…

数学

試験内容は口外しないでくださいと注意書きに書いてあったような気がするので、具体的には書けません。
大きめの部屋でまず問題を解き、口頭試問のために小さめの別室に移動し、プロジェクターに写しながら先生方からの質問に回答するという形です。

プレゼン

志望理由、これからの研究計画、卒業研究のテーマと内容についてのプレゼンがあります。 時間は5分間で、4分のときにベルが鳴ります。書画カメラに映すとプロジェクターで表示されるという形なので、PCは使用できません。 プレゼン後、その内容について質疑応答があります。

プレゼンの練習は紙を置き換える時間や自分がテンパることを予想して4分45秒くらいでできるようにしていたので、時間が足りないということはありませんでした。
質疑応答はある程度予想していた内容でしたが、緊張で言葉が出てこなくなりました。なんとか答えましたが、日本語喋れていたか記憶がありません。 自分の研究計画に関する周辺知識や自分の研究計画がどのようなものなのか、しっかり事前に考えていたほうが良いと思います。そうすれば、ある程度詰まることがあっても質問に回答できるはずです。

終わりに

まわりはみんな就職なり内部進学なりで進路が決まっていく中、一人だけ決まらないのはめちゃくちゃつらいし、孤独です。 成績もっと取っておけば…就活のほうが良かったのか…などひたすら後悔や不安が付きまとい、自己評価もめちゃくちゃ下がっていました。

ちなみに7月にNAISTも受験したのですが、落ちました。それもあって相当焦りと後悔で7月後半はかなり精神的に辛かったです。頭痛が治まらないなど体にもかなり影響が出ました。

しかし、諦めずに頑張って本当に良かったです。
大学院でのXR領域の研究が楽しみです。

具体的な話(試験内容等)はあまり書きませんでしたが、研究室見学に行けば教えてもらえますし、DM等で質問頂ければ私も答えられる範囲で答えます。

ここまでご覧いただき、ありがとうございました。

私の受験を助けてくれた方々、応援してくれた方々、本当にありがとうございました。

この記事がどなたかの参考になったのであれば嬉しいです。

XR Interaction toolkit の XR Hands で手のポーズを取得する

どうも、土鍋です。

昨日、XR Handsのサンプルを触ってある程度挙動が分かったので、コンテンツを作ろうと思ったのですが、Oculus Integrationと違ってデフォルトでいい感じに手のポーズ取得できるものが現状ないので、自分で書いてみました。

XR Handsのサンプル触ったりメリットを書いた昨日の記事はこちら

donabenabe.hatenablog.com

各関節のデータを取得する

XR HandsのHandVisualizerのサンプルコードを見てみるとある程度の使い方はわかると思いますが、拡張性皆無なので自分で書いていきます。

XR Handsの詳しい仕様が書いてあるスクリプトリファレンスはこちら
docs.unity3d.com

XRHandSubsystemの取得

ハンドトラッキングのデータにアクセスするにはXRHandSubsystemを使いますので、取得する必要があります。

subSystem = XRGeneralSettings.Instance?.Manager?.activeLoader?.GetLoadedSubsystem<XRHandSubsystem>();

で取得できます。

左手右手の取得

左右の手のデータを取ってきます。

// 左手
hand = subSystem.leftHand

// 右手
hand = subSystem.rightHand

で取得できます。

各関節のポーズデータの取得

片手のデータを取得したら、指の各関節のポーズを取得します。

hand.GetJoint(xrHandJointID).TryGetPose(out Pose pose);

これで各関節のPose(positionとrotation)を取得できます。

ここでXRHandJointIDというものが出てきましたが、これはどこの関節かを示すIDです。

XRHandJointID

これが最初ちょっとややこしくて把握するのに時間がかかりました。

namespace UnityEngine.XR.Hands
{
  public enum XRHandJointID
  {
    Invalid = 0,
    BeginMarker = 1,
    Wrist = 1,
    Palm = 2,
    ThumbMetacarpal = 3,
    ThumbProximal = 4,
    ThumbDistal = 5,
    ThumbTip = 6,
    IndexMetacarpal = 7,
    IndexProximal = 8,
    IndexIntermediate = 9,
    IndexDistal = 10, // 0x0000000A
    IndexTip = 11, // 0x0000000B
    MiddleMetacarpal = 12, // 0x0000000C
    MiddleProximal = 13, // 0x0000000D
    MiddleIntermediate = 14, // 0x0000000E
    MiddleDistal = 15, // 0x0000000F
    MiddleTip = 16, // 0x00000010
    RingMetacarpal = 17, // 0x00000011
    RingProximal = 18, // 0x00000012
    RingIntermediate = 19, // 0x00000013
    RingDistal = 20, // 0x00000014
    RingTip = 21, // 0x00000015
    LittleMetacarpal = 22, // 0x00000016
    LittleProximal = 23, // 0x00000017
    LittleIntermediate = 24, // 0x00000018
    LittleDistal = 25, // 0x00000019
    LittleTip = 26, // 0x0000001A
    EndMarker = 27, // 0x0000001B
  }
}

これだけ見てるとなんのこっちゃわからないのですが、 図にすると分かりやすいかなと思います。

Enumとintの変換

XRHandJointIDUtilityを使って変換しないと配列が一個ずれたりするので注意が必要です。(これに気がつかなくて結構時間使った…)

Enumからintへの変換

XRHandJointID.IndexTip.ToIndex()

intからEnumへの変換

XRHandJointIDUtility.FromIndex(jointID)

手のポーズを取得する

ここまでで各関節のデータを取得できたので、手のポーズを推定していきます。

各指の開き具合を取得する

このメソッドでは人差し指の開き具合を0から1の間で取得できます。実装的には単純で、人差し指の先と根本の距離で開き具合を計算してるだけです。(だいぶ無理やりですがとりあえず)
その他の指にも同じようなメソッドを作ってあげることで手のポーズをだいたい把握できます。

public float RatioIndex(bool isLeft)
{
    JointPoseData[] jointPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
    return Mathf.InverseLerp(0.04f, 0.13f, Vector3.Distance(
        jointPoseData[XRHandJointID.IndexMetacarpal.ToIndex()].Pose.position,
        jointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position));
}

※JointPoseDataは関節ごとのデータを保持してる自作のクラス。

Mathf.InverseLerpを使うことで指を閉じてる際と開いてる際の距離を0から1の間に丸めてます。

取得したデータを表示

0から1で指の開き具合を取得しているので、そのままSliderに流し込んであげれば、いい感じにデータを可視化できます。

完成形はこちら

まとめ

今後もっと使いやすいものが公式から出てくるかもしれませんが、とりあえずこれで手のポーズが取得できるようになりました。手の向きや加速度も取得できるのでもっと拡張すれば色々遊べそうですね。

以下に一部省略してますが今回書いたスクリプトを貼っておきます。 ちょっと雑な実装なのでお恥ずかしいですが、大まかな使い方は分かると思います。

今回書いたスクリプト

public class JointPoseData
{
    public Pose Pose;

    public XRHandJointID XRHandJointID => handJointID;

    private XRHandSubsystem m_Subsystem;
    private XRHand hand;
    private XRHandJointID handJointID;

    public JointPoseData(bool isLeft, int jointID)
    {
        handJointID = XRHandJointIDUtility.FromIndex(jointID);

        m_Subsystem = XRGeneralSettings.Instance?.Manager?.activeLoader?.GetLoadedSubsystem<XRHandSubsystem>();
        if (m_Subsystem != null)
        { 
            hand = isLeft ? m_Subsystem.leftHand : m_Subsystem.rightHand;
        }
    }

    public void UpdatePose()
    {
        hand.GetJoint(handJointID).TryGetPose(out Pose pose);
        Pose = pose;
    }
}
public class HandDataPresenter : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private GameObject ball;

    private JointPoseData[] _leftJointPoseData = new JointPoseData[XRHandJointID.EndMarker.ToIndex()];
    private JointPoseData[] _rightJointPoseData = new JointPoseData[XRHandJointID.EndMarker.ToIndex()];

    private List<GameObject> leftJointObj = new List<GameObject>();
    private List<GameObject> rightJointObj = new List<GameObject>();

    private float timeElapsed;

    private void Start()
    {
        for (int i = 0; i < XRHandJointID.EndMarker.ToIndex(); i++)
        {
            _leftJointPoseData[i] = new JointPoseData(true, i);
            GameObject leftobj = Instantiate(prefab);
            leftobj.GetComponent<HandDataView>().HandJointID = _leftJointPoseData[i].XRHandJointID;
            leftobj.name = "Left" + _leftJointPoseData[i].XRHandJointID;
            leftJointObj.Add(leftobj);

            _rightJointPoseData[i] = new JointPoseData(false, i);
            GameObject rightobj = Instantiate(prefab);
            rightobj.GetComponent<HandDataView>().HandJointID = _rightJointPoseData[i].XRHandJointID;
            rightobj.name = "Right" + _rightJointPoseData[i].XRHandJointID;
            rightJointObj.Add(rightobj);
        }
    }

    private void Update()
    {
        for (int i = 0; i < XRHandJointID.EndMarker.ToIndex(); i++)
        {
            _leftJointPoseData[i].UpdatePose();
            leftJointObj[i].transform.position = _leftJointPoseData[i].Pose.position;
            leftJointObj[i].transform.rotation = _leftJointPoseData[i].Pose.rotation;
            if (RatioIndex(true) > 0.95f && RatioMiddle(true) < 0.1f)
            {
                timeElapsed += Time.deltaTime;
                if (timeElapsed >= 30)
                {
                    Instantiate(ball, _leftJointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position,
                            _leftJointPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.rotation)
                        .GetComponent<Rigidbody>()
                        .AddRelativeForce(0, 0, 500);

                    timeElapsed = 0.0f;
                }
            }

            _rightJointPoseData[i].UpdatePose();
            rightJointObj[i].transform.position = _rightJointPoseData[i].Pose.position;
            rightJointObj[i].transform.rotation = _rightJointPoseData[i].Pose.rotation;
        }
    }

    public float RatioIndex(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.04f, 0.13f, Vector3.Distance(
            handPoseData[XRHandJointID.IndexMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.IndexTip.ToIndex()].Pose.position));
    }

    public float RatioMiddle(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.04f, 0.14f, Vector3.Distance(
            handPoseData[XRHandJointID.MiddleMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.MiddleTip.ToIndex()].Pose.position));
    }

    public float RatioRing(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.035f, 0.13f, Vector3.Distance(
            handPoseData[XRHandJointID.RingMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.RingTip.ToIndex()].Pose.position));
    }

    public float RatioLittle(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.032f, 0.11f, Vector3.Distance(
            handPoseData[XRHandJointID.LittleMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.LittleTip.ToIndex()].Pose.position));
    }

    public float RatioThumb(bool isLeft)
    {
        JointPoseData[] handPoseData = isLeft ? _leftJointPoseData : _rightJointPoseData;
        return Mathf.InverseLerp(0.075f, 0.083f, Vector3.Distance(
            handPoseData[XRHandJointID.ThumbMetacarpal.ToIndex()].Pose.position,
            handPoseData[XRHandJointID.ThumbTip.ToIndex()].Pose.position));
    }
}