MetaQuest3ではじめる、モグラ叩きMR

この記事はTech KAYAC Advent Calendar 2023の6日目の記事です。

面白プロデュース事業部 技術部の藤澤です。主にUnityを扱った案件を担当しています。 この記事では、UnityとMetaQuest3を用いたMRコンテンツの作り方の一例をご紹介します。

10月10日に、待望のMetaQuest3が発売されましたね。VRはもちろん、MR機能がすごい!とXでも話題になっていた印象です。

この盛り上がりに乗じて私もMR開発したい!ということで、モグラ叩きを遊べるMRコンテンツを作ってみました。

youtube.com

Unityバージョンとレンダリングパイプライン

Unityのバージョンは2022.3.12f1、レンダリングパイプラインはUniversal RP 14.0.9を採用しました。

UnityでMetaQuestを動かすための環境構築

まずはMetaQuestが使えるようにするために、SDKを導入していきます。

Meta XR Core SDK

Unity AssetStoreからMeta XR Core SDKというSDKが取得できるので、Unityプロジェクトにインポートしましょう。

assetstore.unity.com

少し前まではMeta XR Utilities SDKというSDKを入れる必要がありましたが、詳細を見るとdeprecatedと書いてあるので、今後はMeta XR Core SDKを使いましょう。

そこから、色々プロジェクトの設定をする必要があるのですが、SDKにProject SetUp Toolという便利な機能が含まれており(Project Settings -> Oculusで該当画面)、Fix Allで自動で設定してくれます。

これで最低限のセットアップは完了です。

Meta XR Interaction SDK

コントローラーやハンドトラッキングの機能も使えるようにします。

Unity AssetStoreからMeta XR Interaction SDKというSDKが取得できるので、Unityプロジェクトにインポートしましょう。

assetstore.unity.com

Meta XR Interaction SDK OVR Integration

後述しますが、Meta XR Interaction SDK OVR Integrationを導入すると、Building Blocksからモノを掴んだり、投げたりするようなインタラクションを選択できるようになります。

Unity AssetStoreからMeta XR Interaction SDK OVR IntegrationというSDKが取得できるので、Unityプロジェクトにインポートしましょう。

assetstore.unity.com

以上でSDKの導入は完了です。

Building Blocksで必要な機能を導入する

モグラ叩きMRでは、以下のようなMR、インタラクション機能が必要でした。

  • パススルー機能(hmdのカメラから撮影された現実空間の映像を表示する機能)
  • スペースを参照する機能(Quest3のスペース設定で検出した現実空間の情報をUnity上で参照する機能)
  • 現実空間の手に3Dの手をOverlayする機能
  • 3Dオブジェクトを手で掴む機能

以上の機能を、Building Blocksというツールで導入しました。

エディタのヘッダーから、Oculus -> Tools -> Building Blocksでツールを開くことができ、実装したい機能を選択すると、コンポーネントのプリセットがHierarchyに展開されます。 これだけで機能を実現できてしまいます。

今回はBuilding Blocksから以下を導入しました。

  • Camera Rig
  • Background Passthrough
  • Room Model
  • Synthetic Hands
  • Hand Tracking
  • Grabble Item

これで必要な機能は使えるようになりました。ここからは機能群を使ってモグラ叩きを作っていきます。

床にモグラが出てくるようにする

コンテンツの主役であるモグラが、現実空間の床を元気に移動する機能を実装します。

現実空間(スペース)のメッシュを生成する

現実空間の床を移動させるには、現実空間の情報(スペース)がUnityで使える必要があります。スペースはQuest3の設定から事前に登録することができます。

www.meta.com

公式のリファレンスを見ると、OVRSceneManagerから現実空間の情報を参照できるようです。(SDKの方ではスペースじゃなくSceneと呼んでいるようです)

Unity Scene Overview: Unity | Oculus Developers

Building BlocksからRoom Modelを導入していれば、OVRSceneManagerをアタッチしたGameObjectがHierarchy上に存在するはずなので、OVR Scene AnchorOVR Scene Volume Mesh FilterMesh FilterあたりをアタッチしたPrefabを作成し、それをOVRSceneManagerPrefab Overrideにアタッチする(Scene TypeはGLOBAL_MESH)して実行すると...

あらかじめ登録しておいたスペースのメッシュ情報が生成されるようになります。Coliderをつければ当たり判定も可能です。

また、GlobalMesh(空間全体のメッシュ)以外にも、天井や床、ドア等、特定の空間だけメッシュ化することも可能なようです。

モグラが地面に隠れるようにする

モグラには、移動中は地面の中に、移動が完了するとヒョコッと顔を出して欲しいです。前述でスペースのメッシュは表示できるようになったので、カメラに対してモグラがメッシュよりも奥側にいる時にオクルージョンしてくれるShaderを用意します。

Shader "Unlit/Depth"
{
    SubShader
   {
        Tags {"Queue" = "Background+10" }

        ColorMask 0
        ZWrite On

        Pass 
       {
       }
    }
}

RGBAチャンネルへの書き込みを行わず、深度バッファにのみ書き込みを行なうことで、現実空間の映像でオクルージョンができるようにします。

このShaderをGlobal Meshに設定することで、モグラが地面に隠れるような表現ができるようになります。

現実空間のランダムな座標を選択する

次はスペースの範囲内でランダムな座標が選択できるようにします。

ランダムな座標を決めるにあたって、GlobalMesh(上記で可視化したスペース)の端の座標を知りたいわけですが、どうやらOVRSceneManagerから生成される天井や床のメッシュは、GlobalMeshをすっぽり覆い隠すような大きさと位置で生成されるようです。つまり、天井の頂点となる座標が参照できれば、やりたいことができそうです。

ということで、天井や床に関する情報を参照できないかなと調べたところ、OVRSceneRoomというクラスからOVRScenePlaneというクラスで天井や床が参照できそうでした。

Reference

OVRScenePlaneのドキュメントをさらにみてみると、Boundaryというプロパティがそれっぽい名前をしています。

Reference

IReadOnlyList<Vector2> OVRScenePlane.Boundary

The vertices of the 2D plane boundary.
The vertices are provided in clockwise order and in plane-space (relative to the plane's local space). The X and Y coordinates of the 2D coordinates are the same as the 3D coordinates. To map the 2D vertices (x, y) to 3D, set the Z coordinate to zero: (x, y, 0).
  • Boundaryには時計回りの順番で頂点群が格納されているよ
  • 平面上のローカル座標になっているよ
  • 3次元座標にマッピングする場合は、z軸を0にしてね

とのことなので、Boundaryの座標をOVRScenePlaneからのワールド座標に変換します。

OVRSceneRoom room = FindAnyObjectByType<OVRSceneRoom>();
OVRScenePlane ceiling = room.Ceiling;

// 例として1頂点抽出
Vector2 localPoint = ceiling.Boundary[0];

// これが天井のワールド頂点座標
Vector3 worldPoint = ceiling.transform.TransformPoint(localPoint);

これで天井の頂点座標が分かったので、以下のようなロジックでモグラの移動先を決定しました。

  1. Boundary[0]からBoundary[1]の範囲でランダムな座標を決定

  2. Boundary[1]からBoundary[2]の範囲でランダムな座標を決定

  3. 2.の座標からBoundary[2]からBoundary[1]の放線ベクトルを持つPlaneを生成し、1.の座標からPlaneと逆の法線ベクトルでRaycastを打つ。(XZ軸の位置の決定)

  4. Planeにヒットした座標から、床のOVRScenePlaneと逆の法線ベクトルでRaycastを打ち、GlobalMeshにヒットした座標をモグラの移動位置とする。(Y軸の位置の決定)

モグラが移動する高さを4.の方法で決定している理由は、モグラが床以外にもベッドの上や障害物の上に移動すると、より面白そうと思ったので、GlobalMeshのColliderを利用しました。

選択した座標にいい感じにモグラを移動させる

モグラの移動先を決められるようになったので、次は実際に移動してもらいます。

前述した通り、モグラには床以外にも、ベッドや障害物の上など様々な場所を移動してもらう必要があります。この時、道中で障害物を突き抜けたり高さを無視するような移動をしてしまうと、没入感を損ねてしまいます。

以上の理由から、NavMeshを使ってモグラがいい感じに移動してくれるようにします。

現実空間を表現しているGlobalMeshはランタイムで生成されるので、ランタイム中にNavMeshをベイクできる必要があります。NavMesh Surfaceを使うと、ランタイムでベイクが可能なので、UPMを使ってPackageをインポートします。

docs.unity3d.com

ランタイムでベイクを実行するには、GlobalMeshが生成されるタイミングも必要になります。ドキュメントをみてみると、 SceneModelLoadedSuccessfullyというコールバックがOVRSceneManagerから参照できるようで、このタイミングであればGlobalMeshが確実に参照できそうです。

Reference

this.sceneManager.SceneModelLoadedSuccessfully += this.OnLoadedScene;

private void OnLoadedScene()
{
    OVRSceneRoom room = FindAnyObjectByType<OVRSceneRoom>();
    NavMeshSurface navMesh = room.gameObject.AddComponent<NavMeshSurface>();

    // NavMeshをGlobalMeshにベイク
    navMesh.collectObjects = CollectObjects.Children;
    this.navmesh.BuildNavMesh();
}

ベイクできました。また、前述の方法で決定した目的地が、NavMeshでベイクしたエリアから到達可能か判断することも可能なので、ルートが存在しない場合は、再度目的地を調整するようにしていました。 NavMeshについては、こちらの方の記事がとても分かりやすく、参考にさせて頂きました。

zenn.dev

これで高さや障害物を考慮した移動ができるようになりました。

モグラの影を落とす

モグラが地面から顔を出した際、地面に影が落ちるとより没入感が増しそうです。

現実空間を表現しているGlobalMeshに対してモグラの影だけを描画するようにすれば、現実世界の床にモグラの影が落ちる絵が実現できそうです。

こちらの記事を参考に、以下のようなShaderを実装することで実現しました。

brownbot.com

#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED

void MainLight_float(float3 WorldPos, out float3 Direction, out float3 Color, out float DistanceAtten, out float ShadowAtten)
{
#ifdef SHADERGRAPH_PREVIEW
    Direction = float3(0.5, 0.5, 0);
    Color = 1;
    DistanceAtten = 1;
    ShadowAtten = 1;
#else
    float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);

    Light mainLight = GetMainLight(shadowCoord);
    Direction = mainLight.direction;
    Color = mainLight.color;
    DistanceAtten = mainLight.distanceAttenuation;

      ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
      float shadowStrength = GetMainLightShadowStrength();
      ShadowAtten = SampleShadowmap(shadowCoord, TEXTURE2D_ARGS(_MainLightShadowmapTexture,
      sampler_MainLightShadowmapTexture),
      shadowSamplingData, shadowStrength, false);
#endif
}
#endif

天井から穴が開く演出

HoloLensのRoboRaidが大好きだった私としては、MR上で壁が突き抜ける演出は絶対入れたいと思いました。

穴が空いて見える表現

こちらの方の記事を参考に、オブジェクトに対して穴が開いているように見せるShaderとRendererFeatureを実装しました。

anogame.net

主に2つのオブジェクトと、RendererObjectを用いて、空洞を表現しました。

  • 空洞オブジェクトを描画するための結合部分となるオブジェクト

    • Stencilが有効で、Stencil = 1で、Compare FunctionAlwaysPassReplaceな RenderObjectを紐づけることで、このオブジェクトが描画される部分にStencil = 1の内容が描画されるようにする。
  • 空洞部分を表現するためのオブジェクト

    • 以下のShaderGraphのような、メッシュの内側を描画するShaderをアサインしている。
    • Depthが有効で、Depth TestAlwaysなRenderObjectを紐づけることで、Global Meshに隠れても描画されるように設定。
    • Stencil = 1なRenderObjectを紐づけることで、結合部分のオブジェクトに空洞が描画されるように設定。

より穴が開いて見えるように

穴を開けらえるようになりましたが、丸い穴だと現実味に欠けます。ヒビが入って、天井が割れるように穴が開くようにブラッシュアップしていきます。

前述の方法は、Cylinderのような厚みのある3Dモデルであれば代用できるので、こんな感じの3Dモデルを作って

砂埃っぽいParticleと破片が飛ぶように調整して、最終的にこちらのような感じになりました。

まとめ

MetaQuest3を使って、モグラ叩きゲームを遊べるMRコンテンツを作ってみました。豊富な機能がSDKから提供されており、面白いコンテンツを作るための可能性に満ちているなと感じました。またARFoundationと連携するためのOpenXRパッケージが公開されたりと、さらにアップデートが進んでいるようなので、今後が楽しみです。

blog.unity.com

カヤックではMR技術に興味のあるエンジニアも募集しています!

hubspot.kayac.com