Apple Watch(+α)のxR開発で使う座標系を調べた

エンジニアの岡村です。

先月、Synamon初のアドベントカレンダー企画を行いました(無事完走!)。その中で自分は仕事で触っていない技術へのチャレンジを行い、ほぼ初めてのSwiftとApple系デバイスを使った開発を行いました。

synamon.hatenablog.com

自分はUnityやDirectX以外の座標系をあまり触ったことがなく、開発の際に座標系に少し戸惑ったので、この記事に軽く調べてみた情報を纏めておきます。

座標系の種類

座標系には大きく分けて右手系と左手系が存在します。それぞれの座標系は、親指をX軸、人差し指をY軸、中指をZ軸とし、各指が90度で直交する形を作り、回転することでその形と一致するかどうかで確かめることができます。これらの座標系が異なる場合、回転により一致させることが出来ず、相互変換する際に座標の解釈を変更する必要があります。

例えば、Unityの座標系は左手系であり、Blenderの座標系は右手系です。BlenderとUnityを使ってコンテンツを作成している方は、いつもこの座標系の違いを乗り越えているんですね。

また、座標系にはどの軸を上方向とするのか(Y-up、Z-up)という基準もあります。しかし、デバイスの座標系を考えたとき、机に置いた状態の上方向と、立てて使用している時の上方向など、その瞬間におけるデバイスの状態によって解釈が異なる為、この基準を簡単に適用することはできません。代わりに、デバイスのどの方向が何軸に対応しているのかを明示しておきます。

Apple系デバイスの座標系

Apple系デバイスのネイティブアプリを開発する際、モーションセンサーの値を取得するにはCoreMotionというフレームワークを利用します。このフレームワーク内において利用されている座標系は右手系となっており、これは公式ドキュメント内のこちらのページで確認することが出来ます。

https://developer.apple.com/documentation/coremotion/getting_processed_device-motion_data/understanding_reference_frames_and_device_attitude

また、この図によると、ディスプレイ正面方向がZ+ディスプレイ上方向がY+となっており、これが画面が付いてるデバイスにおける標準となっているようです。

f:id:Sokuhatiku:20220117181330p:plain:w300

Apple Watch

Apple Watchでも基本的には同じです。ただし、Apple Watchは左右どちらの手首に装着するか、Digital Clownの向きを左右どちらにするかで4通りの装着方法が考えられます。

f:id:Sokuhatiku:20220117191956p:plain:w300

ですが、どの向きでもディスプレイ正面がZ+Digital Clownのある方向がX+となっています。Digital Clownの向きを変えると画面の上下が反転しますが、CoreMotionにおける軸方向は変化しません。つまり左にDigital Clownが来るような状態にした場合、腕を下に下げるとデバイスのY+方向に加速度がかかることになります。

f:id:Sokuhatiku:20220117193118p:plain

ちなみに

今まで画面が付いているデバイスの話をしていましたが、画面がついていないAirPodsもCoreMotionでモーションセンサーの値を取得出来ます。ただし、AirPodsの場合は、回転量の差分のみが取得可能なようです(3DoF)*1。この場合はデバイスの面と軸の対応を考えることは出来ません。

おまけ(Android)

ついでにAndroidにおけるセンサーの座標系も調べてみました。公式情報はこちらのページにあります。 https://developer.android.com/guide/topics/sensors/sensors_overview?hl=ja#sensors-coords

f:id:Sokuhatiku:20220117193512p:plain:w300

座標系はAppleと同様に右手系ディスプレイ正面がZ+ディスプレイ上がY+ となっているようです。

おわりに

基本的にUnity等のゲームエンジンを使っている限り、それ用に用意されたライブラリが座標系を合わせてくれるのが凄く有難いです。しかし、何らかの事情でネイティブ開発をする必要が出てきた場合、座標系の違いは(特にドキュメントが充実していない場合)大変になるでしょう。それに備えて知識だけは持っておきたいと思います。

Unity2021でVRテンプレートを使ってOculusQuest2の開発環境を構築してみる

こんにちは、Synamon のエンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

自分はエンジニアではあるのですが、肩書きにもあるようにここ1~2年くらいは開発以外の仕事(EM、採用)をすることがほとんどで、実は最近のバージョンのUnityを触れていませんでした。

ですのでリハビリも兼ねて、最新のUnity正式版であるUnity 2021の環境でVRテンプレートを使用して、VRの開発環境をゼロから構築してみたいと思います。

読者の対象としては、以下を想定しています。

  • Unityは触ったことあるが、VRの開発は初心者の方
  • VRの開発をここ1~2年くらい触っていない方

今回の開発環境は以下で構築していますが、バージョンに依って内容に変更があり得ることにご注意ください。

  • Windows 10
  • Unity 2021.2.7f1

ちなみに今回はデバッグのしやすいOculus Linkも使用するのでWindowsを使用していますが、 Quest2(Android)のみで動作確認する場合はMac等でも開発可能かと思います。

またUnity自体の基本的な説明、Unity以外のソフトウェアの設定、ハードウェアに関する説明などは割愛させていただきます。

Unityのインストール

まずはUnity Hubを使ってUnityのインストールをします。

unity3d.com

Unity Hubでは、インストール > インストール(右上の青いボタン) > バージョンを選択 > 追加するモジュールを選択 します。

ここで使用するUnityのバージョンは、2022/01/11で最新の正式版である Unity 2021.2.7f1 とします。(LTSではないです)

念のためですが、実際に製品開発をされる方は (LTS) の付くバージョンを使用することが推奨されていますのでご注意ください。

f:id:mochinekos:20220111104604p:plain
Unityのインストール

追加するモジュールでは以下を選択しておきます。

  • Android Build Support
    • Android SDK & NDK Tools
    • OpenJDK
  • Windows Build Support (IL2CPP)

特に前者のAndroid向けのモジュールは最終的にOculus Quest 2単体で動作させたい場合には必須になります。

f:id:mochinekos:20220111105505p:plain

プロジェクトの作成

Unity Hubでプロジェクトの新規作成を行います。

プロジェクト > 新規作成(右上の青いボタン、の右の▼のボタン) > 2021.2.7f1

右の▼からバージョンを指定するのにご注意ください。

プロジェクト名、保存先は好きに変えてください。

ここで、テンプレートでVRを選択しましょう。(初めての場合はダウンロードが必要です)

f:id:mochinekos:20220111110841p:plain

このVRのテンプレートはVR開発向けに必要なUnity Packageの追加やプロジェクトの設定が適用されているものになります。

これを使用することでそれらの初期設定の手間を省いて、手軽にプロジェクトの作成ができるようになっています。

f:id:mochinekos:20220111111820p:plain

Unityパッケージの確認

画面上のタブ > Window > Package Manager

からPackage Managerを開きます。

VRのテンプレートではXR開発向けのパッケージがデフォルトで追加されているようです。

  • Oculus XR Plugin
  • Windows XR Plugin
  • XR Plugin Management

f:id:mochinekos:20220111112051p:plain

それぞれのバージョンは最新に更新しておいて基本的には問題ないと思いますので、以下のバージョンを使用します。

  • Oculus XR Plugin > 1.11.2
  • Windows XR Plugin > 4.4.1
  • XR Plugin Management > 4.2.1

プロジェクトの設定

今回使用するOculus Quest 2向けに設定を少しだけします。

画面上のタブ > Project Settings > XR Plug-in Management

を開きます。

Oculus Linkを使用するためには、PC向けの設定で Oculus にチェックを入れます。

f:id:mochinekos:20220111113339p:plain

Quest2単体で動かすためには、Android向けの設定で Oculus にチェックを入れます。

f:id:mochinekos:20220111113507p:plain

Editor上で動作確認

Oculus Linkを使って、Editor上で動作確認してみましょう。

Oculus Linkのセットアップはまず公式のこちらをご覧ください。

support.oculus.com

使用するOculus Quest 2は開発者モードに設定しておきましょう。

https://developer.oculus.com/documentation/native/android/mobile-device-setup/?locale=ja_JP

VRテンプレートにはサンプルである SampleScene があり、初回起動時に展開されているのでこちらを使ってみましょう。

Linkを起動した状態で、Unityで再生をします。

うまくつながっている場合、このように白い空間で頭と両手のトラッキングがされているのが分かります。

f:id:mochinekos:20220111140855p:plain

ビルド

同じサンプルシーンが正常にビルドでき、実機で動作することを確認してみましょう。 今回は2通りの方法で動作確認します。

  • Windows向け(Oculus Link)
  • Android向け(Quest2単体)

Windows向け(Oculus Link)

画面上のタブ > File > BuildSettings を開き、Add Open Scenes を押して SampleScene を追加して、 Windowのプラットフォームになっていることを確認してから、Build を押します。

エラーが発生しなければビルド成功です。

指定したフォルダの プロジェクト名.exe のファイルを起動すると、先ほどのEditorと同じ動作をするアプリケーションが立ち上がるかと思います。

Android向け(Quest2単体)

同じくBuild Settingsから、一度PlatformをAndroidに変更します。

Platform > AndroidTexture Compression を推奨の ASTC に変更しておき、それから画面下の Switch Platform を押します。

それから Build もしくは Build And Run を押します。

確認方法はいくつかあるのですがどれでも構いません。

  • Build And Run でビルド後に直接実機で動かす
  • Build してできた .apk ファイルを
    • Side Questでインストールする
    • Android Studioを入れてADBコマンドでインストールする

サンプルがちゃんと動いてトラッキングがうまくされている、画面がちゃんと表示されているのが確認できればOKです。

コントローラーの入力の取得方法

SampleScene を見ると、XR Rig というオブジェクトの下に Camera OffsetRightControllerLeftControllerの3つの子オブジェクトがあり、これらが頭・両手のコントローラーのトラッキングを再現していることが分かります。

つまり、既にトラッキングの情報は取得できています。

後はQuest2のコントローラーの入力の取得さえできれば、最低限基本的な開発ができるでしょう。

こちらの記事

synamon.hatenablog.com

にもあるように、Unityの(New)Input SystemではOculus Quest 2のコントローラーの入力も取得することができます。

詳しくはこちらの記事を参考にしてください。

jmpelletier.com

ちなみに 入力イベントを取得する Player Input でのイベントの戻り値は UnityEngine.InputSystem.InputAction.CallbackContext のようです。

まとめ

以上でできるようになったことをまとめます。

  • 動作
    • UnityのEditor上で、Oculus Linkで動かす
    • Windowビルドして、Oculus Linkで動かす
    • Androidビルドして、Quest2単体で動かす
  • 入力
    • 頭と両手のトラッキングができる
    • コントローラーのボタン等の入力が取れる

ここまで来れば、後は作りたいコンテンツを作るだけですね。

さらっとここまで来ていますが、1~2年前までは他にも

  • Oculus IntegrationなどのSDKをインストールする
  • Android SDKまわりを手動でセットアップしてビルドを通す
  • Oculus Quest 2向けのビルド設定を一つずつ修正する

などをしながら実機で動かすところまでやる必要があり、地味に大変でした。

それが今では、

  • XR Plugin Management & New Input System → OculusのSDKまわりをうまくラップしてくれている
  • Android向け追加モジュール → Android SDKのセットアップまわりをうまくやってくれている
  • VR Template → Quest2向けの細かいセットアップを自動でやってくれている

のように整備されていて、開発環境を構築するまでのハードルがかなり下がっていることが分かります。

また、基本的にQuest2向けに設定したのは XR Plugin Management の部分と、コントローラーの取得の部分だけなので、XR Plugin Managementが対応している範囲なら他のHMDもほぼ同じように対応できると思います。(確認はしてませんがOpenXRの設定欄もありますし)

あと今回はOculus Integrationを自分では入れていないので、このままGitHubに上げてもライセンス上問題ないはずで、GitHubでPublicに開発するハードルも下がるかと思います。

その他、注意事項としては以下を挙げておきます。

  • Unity2021のバージョンはまだLTSではないので、今プロダクションで使用するならUnity2020.3が推奨
  • ちゃんと確認したわけではないのですが、Oculusの最新のSDKを使用したいなら、おそらく手動でOculus Integrationを入れる必要がありそう
  • これも時間がなくて確認できていないのですが、Unityの提供しているUnity Package Oculus XR Plugin がOculusの公式のSDKである Oculus Integration とどうバージョン対応しているのかわからず
    • (2022/01/17追記)新しめのバージョンでは、Oculus Integrationのv33には対応しているようなので、それ以上新しいバージョンを使用したい場合のみ手動でどうにかする必要がありそうです

以上、これからOculus Quest 2を使って新しく開発を始めてみたい方に参考になれば幸いです。

Synamon Advent Calendar 2021完走しました!

あけましておめでとうございます! 🎍
エンジニアリングマネージャーの佐藤(@unsoluble_sugar)です。

新年1記事目ということで、年の瀬のアドベントカレンダーについて振り返ってみます。

アドベントカレンダー戦績発表

2021年12月に実施したSynamon Advent Calendar 2021ですが、見事に完走いたしました!!

f:id:unsoluble_sugar:20220102155436j:plain

なんと、Synamonとしてのアドベントカレンダー初挑戦にもかかわらず、全25記事すべて埋まるという快挙…!

参加していただいたエンジニア、デザイナーメンバー、そして記事のシェアに協力いただいた皆さんの素晴らしい成果です!👏👏👏

記事一覧

せっかくなので本記事でもアドベントカレンダーに投稿された25記事の一覧を列挙しておきます。気になる記事があればぜひ読んでください!

  1. テックブログ運用を回すための取り組み 〜黄金の回転編〜
  2. Unityでのマルチプレイアプリ開発を便利にしてくれるEditor拡張-ParrelSyncを深ぼってみる
  3. Oculus GoのアンロックOSビルドを入れてみた
  4. Netcode for GameObjectsの機能紹介 NetworkTransformコンポーネントについて
  5. Blenderでオブジェクトを統合するとテクスチャが表示されなくなる問題
  6. ラブライブに学ぶリーダーシップ
  7. だれでもできる!Nreal Lightアプリ作成入門
  8. VR企業はこうやって引っ越しをする!!
  9. AR Foundationでユニティちゃん召喚スマホアプリを作ってみた
  10. HubsCloudをDocker環境で動かしてみる
  11. Netcode for GameObjectsでプレイヤー間で同期する物体の生成 / 破棄
  12. NaughtyAttributesの実装を読み解く
  13. VRoid Studioでメタバース時代のオリジナルアバターを自作したい!
  14. HaritoraX完全に理解した
  15. AppleWatchをハンドトラッキング補助デバイスとして使えないか試してみた
  16. .NET 6 MAUI(Preview)をWindowsとMacで触ってみたかった
  17. インフラ周りで直面した課題と対応策について登壇しました
  18. Netcode for GameObjectsでMobをスポーンさせる
  19. 3Dモデリング未経験のエンジニアがBlender入門してみた
  20. PlayMakerとBehaviourTreeを使ってステートマシンにイベントハンドリングをできるようにしてみた
  21. Addressable Asset System入門
  22. Bledner上での確認にLooking Glass Portraitを使う
  23. Houdini Engine For Unityを色々試してみる
  24. SIer経験者がXR業界に入って感じたこと
  25. 修正に強いモデリング方法

「ネタ記事でも大丈夫ですよ!」とは言っていたものの、皆さんわりと真面目な記事を書いてくださった印象ですw

皆さんの得意領域や今回の機会を利用したチャレンジを見ることができて個人的にも嬉しいです!

掲載媒体の内訳について

今回はアドベントカレンダー初挑戦かつ、まだテックブログに書いたことのないメンバーも居たこともあり「Qiitaやnoteなど他媒体でも書いてOK」という体制を取りました。純粋にテックブログ向きではないテーマもありますからね。

投稿媒体の内訳は以下のとおり。

  • テックブログ:15記事
  • note:6記事
  • Qiita:3記事
  • Zenn:1記事

Synamon公式noteにはデザイナーマガジンがあるため、デザイナーさんはこちらに掲載いただいたケースもありました。エンジニア以外に向けた情報発信はnoteが適していますね。

note.synamon.jp

それでも25記事中、15記事もテックブログで書かれたのは好成績です。12月のPV数も年間最高値が出ていました。

f:id:unsoluble_sugar:20220102183154j:plain

右肩上がりで気持ちの良いグラフですね!
公開した記事数自体が多く、目にしていただける機会も一段と多かったようで何よりです。

2022年もよろしくお願いします

昨年の後半から体制を整えだしたテックブログ運営ですが、アドベントカレンダーも無事に完走を果たすことができました。参加いただいた皆さん全員がバリューを体現したと言えるでしょう。

f:id:unsoluble_sugar:20220102171135j:plain

今回書かれた記事はストック型の技術情報として、今後も長く読まれることを期待しています。

Synamonで「技術的にどのような取り組みを行なっているか」「どんなことに興味のあるメンバーが在籍しているか」アドベントカレンダーの記事を通して、少しでも多くの方に伝われば幸いです。

synamon.hatenablog.com

2022年もSynamon’s Engineer blogをよろしくお願いいたします!

最後に

本テックブログやnote記事のお知らせは、Synamon公式Twitterで発信しています。弊社の取り組みに興味を持っていただけましたらぜひフォローお願いします!

twitter.com

カジュアル面談も実施中ですので「詳しく話を聞いてみたい!」という方はチェックいただけると嬉しいです。

▼カジュアル面談はMeetyから   meety.net  

▼エントリーはこちら   herp.careers

UnityのTimelineを利用してGUIのトランジション効果を作ってみる

f:id:fb8r5jymw6fd:20211226110009p:plain

はじめに

UnityでGUIを構築する際、uGUIや新しい機能であるUI Toolkitが利用できますが、画面遷移のアニメーションのふるまいなどはユーザー側に任せられています。ユーザーで用意するぶん、いくらでもカスタマイズできますが、ギミックやアニメーションの処理を用意するのに手間な部分でもあります。

私はUnityのTimelineについて触れる機会が少なく、これまであまり詳しいことが分かっていませんでしたが、Timelineはスクリプトからの操作やプロパティの変更ができる箇所も多く、カットシーンやイベントシーン以外の用途でもTimelineが利用できそうということが分かりました。今回その一例としてGUIの画面遷移(トランジション)の処理をTimelineを使って行うことを記事に書きました。

今回紹介する内容は個人のGitHubにサンプルのプロジェクトファイルを置いていますので、触っていただけると嬉しいです。

事前準備

今回の記事では以下のUnityのバージョンとパッケージを利用しています。

  • Unity 2020.3.25f1
  • Timeline 1.4.8
  • Input System 1.2.0
  • Unity UI 1.0.0

対象のGUIパネルの作成とAnimatorコンポーネントの追加

トランジション効果を持たせたいパネルを作成し、それぞれAnimatorコンポーネントを追加します。これはAnimationとして動かす際に必要になります。Animator ControllerやAvatarはNoneのままにしておきます。

f:id:fb8r5jymw6fd:20211226095217p:plain

 

また、以下のセクションで登場するAnimationはTransformを持つGameObjectから作成する必要があり、RectTransformとTransformの原点座標を揃える必要があります。GUIパネルとして利用したいGameObjectのRectTransformの以下のように設定します。

Anchor Presetsは下図のように[Middle Center]にします。(AnchorsのPivotは x: 0.5, y: 0.5に設定)他設定は上図のAnchorsを参考にしてください。

f:id:fb8r5jymw6fd:20211226095030p:plain

 

Animationを作成する

まずは遷移元になるAnimationと遷移先になるAnimationをそれぞれ作成します。Projectパネルから任意のディレクトリで右クリック、 Create > Animation から2つほどAnimationを作成します。

f:id:fb8r5jymw6fd:20211225173855p:plain

 

それぞれのAnimationに適当な名前を付けたら、Hierarchyに新しくGameObjectを2つ分用意し、先ほど作成したAnimationをD&Dで追加します。

f:id:fb8r5jymw6fd:20211226112331p:plain

 

Projectパネルから作成したAnimationの一つをダブルクリックしてアニメーションを編集します。

f:id:fb8r5jymw6fd:20211225175957p:plain

 

ここで一度Hierarchyから対象のAnimationが追加されているGameObjectを選択します。この操作をしないと右側のAdd Propertyのボタンが押せないので注意してください。

続けてAdd Propertyを押します。

f:id:fb8r5jymw6fd:20211225180325p:plain


追加するプロパティは Transform > Position で、Transformの「▷」マークを押すことで候補が出てきます。右側の「+」マークを押して追加します。

f:id:fb8r5jymw6fd:20211225180711p:plain

 

デフォルトでキーフレームが0:00の箇所と1:00(1秒相当)の箇所に入っていますので、末尾のキーフレームのマーカーをドラッグしてを0:30の位置に動かします。(ここは任意の時間でも大丈夫です)

f:id:fb8r5jymw6fd:20211225180905p:plain

 

キーフレームを上書き編集するため、Recordingボタンを押します。

f:id:fb8r5jymw6fd:20211225181057p:plain

 

一つの目のアニメーションは画面外にスライドするアニメーションを作るので、最後のキーフレームを選択します。

f:id:fb8r5jymw6fd:20211225181328p:plain

 

横の数値はCanvas Scalerの画面幅を基準に設定します。左側へのスライドのため、マイナスの数値が入っています。下記数値と異なる場合は自身のCanvasScalerの数値を基準に入れてください。

f:id:fb8r5jymw6fd:20211225181529p:plain

f:id:fb8r5jymw6fd:20211225181800p:plain

 

最初のキーフレームの値と最後のキーフレームの値が以下のようになっていればOKです。

f:id:fb8r5jymw6fd:20211225182301p:plain

 

もう一方の方は右側の画面外からフレームインするように、終わりのキーフレームの位置を0にするように設定します。

f:id:fb8r5jymw6fd:20211225184140p:plain

 

編集が終わったら、使用したGameObjectと、自動生成されたAnimator Controllerは不要になりますので削除します。

f:id:fb8r5jymw6fd:20211225184415p:plain

f:id:fb8r5jymw6fd:20211225184548p:plain

 

Timelineを作成する

次にTimelineを作成します。Projectパネル上で右クリックし、 Create > Timelineを選択します。

f:id:fb8r5jymw6fd:20211225184649p:plain

 

名前をわかりやすいものに変更しておきます。

f:id:fb8r5jymw6fd:20211226103050p:plain

 

Hierarchyに空のGameObjectを作成し、そのGameObjectにPlayableDirectorのコンポーネントを追加します。

f:id:fb8r5jymw6fd:20211226065048p:plain

 

Inspectorから先ほど作成したTimelineをPlayableDirectorのPlayableの箇所にD&Dします。ついでにPlay On Awakeのチェックも外して、実行時自動的にTimelineが再生されないようにします。

f:id:fb8r5jymw6fd:20211226065423p:plain

 

さきほど作成したTimelineをダブルクリックしてTimelineのパネルを開きます。

f:id:fb8r5jymw6fd:20211226064821p:plain

 

Timelineのパネルが開いたら、一度HierarchyのPlayableDirectorをアタッチしたGameObjectを選択し、Timelineパネルをロックしておきます。

f:id:fb8r5jymw6fd:20211226065725p:plain

f:id:fb8r5jymw6fd:20211226065855p:plain

 

Animation Trackを作成する

Timelineの左側のところで右クリックし、メニューからAnimation Trackを二つ用意します。それぞれ遷移元のGUIパネルのアニメーション、遷移先のGUIパネルのアニメーション用に利用します。

f:id:fb8r5jymw6fd:20211226065944p:plain

 

作成したAnimation Trackをリネームします。Timelineの画面上に追加された項目の、枠の部分もしくは、下図のようにアイコンの部分をクリックすることで選択できます。

f:id:fb8r5jymw6fd:20211226070353p:plain

 

リネームはInspectorから行います。ついでにApply Avatar Maskの箇所のチェックを外します。

f:id:fb8r5jymw6fd:20211226070547p:plain

 

もう一方のAnimation Trackも同じようにリネームします。

f:id:fb8r5jymw6fd:20211226070649p:plain

 

AnimationTrackにAnimationを追加する

それぞれのAnimationTrackに作成していたAnimationを追加します。Animationは任意の位置に追加すればよいですが、今回の画面遷移の仕組みではアニメーションの開始と終了がそろっていた方が良いです。

マウスホイールを使ってTimelineの縮尺を変えることができるので、追加しづらいときは縮尺を変えてみてください。

f:id:fb8r5jymw6fd:20211226071107p:plain

 

追加したTimelineパネル上のAnimationをクリックし、Inspectorから操作を行います。

f:id:fb8r5jymw6fd:20211226071359p:plain

 

Animation Playable Asset内のにある、Remove Start Offsetの項目のチェックを外します。Remove Start Offsetのチェックを外しておくことで、Animationに設定したPositionのアニメーションが絶対位置として反映されるようになります。今回の仕組みには必須です。

Remove Start Offsetの項目は通常のTransformを持ったGameObjectから作ったAnimationのみ利用できます。GUIのようにRectTransformを持ったものからAnimationを作るとこの項目は表示されないので注意してください。*1

f:id:fb8r5jymw6fd:20211226072507p:plain

もう一方のTimeline上に配置したAnimationも同じようにRemove Start Offsetのチェックを外しておきます。

 

アニメーションをチェックする

一度アニメーションの挙動を確認してみます。HierarchyのPlayableDirectorをアタッチしたGameObjectを選択し、Inspectorを操作します。
f:id:fb8r5jymw6fd:20211226065725p:plain

 

PlayableDirectorコンポーネントのBindingsの項目にAnimationTrackが追加されています。それぞれの箇所にHierarhy側のトランジションさせたいGUIを持つGameObjectをセットします。

f:id:fb8r5jymw6fd:20211226073320p:plain

 

Timelineパネルから再生ボタンを押すか、数字目盛の箇所をドラッグして動きを確認します。

f:id:fb8r5jymw6fd:20211226074032p:plain

f:id:fb8r5jymw6fd:20211226134942g:plain

 

うまく動かないときは、Animationの確認、AnimationTrackの確認、Previewのボタンを押したり、パネルのロックを外して再選択してみるなどの操作を行ってください。

f:id:fb8r5jymw6fd:20211226073846p:plain

 

確認が終わったらPlayableDirectorコンポーネントのBindingsから先ほどセットしたGameObjectを外し、Noneに戻しておきます。

f:id:fb8r5jymw6fd:20211226074955p:plain

 

SignalAssetを作成する

次にSignalAssetを作成します。このSignalAssetはTimeline上での再生位置、再生終了の検出のために利用します。Projectパネル上で右クリックし、 Create > Signal を選択しSignalAssetを作成します。

f:id:fb8r5jymw6fd:20211226075220p:plain

 

二つぶん作り、それぞれ 「Start~」、「End~」という感じに名前を付けます。これはトランジション開始、終了をそれぞれ表すためにつけています。このルールは後で必要になってきます。

f:id:fb8r5jymw6fd:20211226075637p:plain

 

次にTimelineにこのSignalAssetをマーカーとして登録します。

マーカーを見えるようにするため、Timelineの数字目盛の箇所を右クリックし、 Show makersを選択し、Markersの項目を表示します。

f:id:fb8r5jymw6fd:20211226082002p:plain

 

SignalAssetを追加し、マーカーとして登録します。それぞれ遷移アニメーションの開始位置、終了位置にマーカーを配置します。

f:id:fb8r5jymw6fd:20211226082517p:plain

 

SignalReceiverコンポーネントを追加する

PlayableDirectorのComponentが追加されているGameObjectにSignalReceiverコンポーネントを追加しておきます。SignalReiverコンポーネントは追加するのみで、このコンポーネントに対してはInspectorからは何も操作しません。(後でスクリプトで操作できるようにするためです)

f:id:fb8r5jymw6fd:20211226082937p:plain

 

トランジションを行うためのスクリプトを追加する

さらに以下のようなスクリプトを作成し、PlayableDirector、SignalReceiverを持っているGameObjectにコンポーネントに追加します。サンプルではMonoBehaviour継承クラスで、TimelineTransitionという名前にしています。

f:id:fb8r5jymw6fd:20211226091726p:plain

 

ソースコード全体はこんな感じです。今回本文が長いため、ソースコードの解説は割愛させて頂きます。StartTransition()のメソッドからトランジション効果を呼び出すことができます。

ギミック自体はとても面白いので、Playable APIを含めて今後別の記事で紹介できるかもしれません。

#nullable enable

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[RequireComponent(typeof(PlayableDirector), typeof(SignalAsset))]
public class TimelineTransition : MonoBehaviour
{
[SerializeField]
private PlayableDirector? playableDirector;
[SerializeField]
private string startMarkerPrefix = "Start";
[SerializeField]
private string stopMarkerPrefix = "End";
[SerializeField]
private string sourceTrackName = "TransitionA";
[SerializeField]
private string destinationTrackName = "TransitionB";

private readonly Dictionary<string, double> startMarkerTimes = new Dictionary<string, double>();

private TrackAsset? sourceTrackAsset;
private TrackAsset? destinationTrackAsset;

public IReadOnlyList<string>? StartMarkers => startMarkerTimes.Keys.Any() ? startMarkerTimes.Keys.ToList() : null;

private GameObject? targetObjectToDisable;

public void StartTransition(
Animator? sourceAnimator,
Animator? destinationAnimator,
string transitionName,
bool disableSourceAfterTransition = false,
bool enableDestinationBeforeTransition = false)
{
if (playableDirector == null) return;
if (playableDirector.state == PlayState.Playing) return;

if (sourceTrackAsset != null && sourceAnimator != null)
{
targetObjectToDisable = disableSourceAfterTransition ? sourceAnimator.gameObject : null;
playableDirector.SetGenericBinding(sourceTrackAsset, sourceAnimator);
}

if (destinationTrackAsset != null && destinationAnimator != null)
{
if (enableDestinationBeforeTransition)
{
destinationAnimator.gameObject.SetActive(true);
}
playableDirector.SetGenericBinding(destinationTrackAsset, destinationAnimator);
}

StartAnimationAtMarker(transitionName);
}

private void StartAnimationAtMarker(string markerName)
{
if (startMarkerTimes.ContainsKey(markerName))
{
if (playableDirector == null) return;
playableDirector.time = startMarkerTimes[markerName];
playableDirector.Play();
}
}

#region MonoBehaviour implements

private void Reset()
{
playableDirector = gameObject.GetComponent<PlayableDirector>();
}

private void Awake()
{
if (playableDirector == null) return;

var stopTimelineEvent = new UnityEvent();
stopTimelineEvent.AddListener(() =>
{
targetObjectToDisable?.SetActive(false);
targetObjectToDisable = null;
playableDirector?.Stop();
});

var signalReceiver = gameObject.GetComponent<SignalReceiver>();

var timelineAsset = playableDirector?.playableAsset as TimelineAsset;
foreach (var marker in timelineAsset!.markerTrack.GetMarkers())
{
var signalEmitter = marker as SignalEmitter;
if (signalEmitter == null) continue;

var signalAsset = signalEmitter.asset;
if (signalAsset.name.Contains(startMarkerPrefix))
{
startMarkerTimes.Add(signalAsset.name, marker.time);
}

if (signalAsset.name.Contains(stopMarkerPrefix))
{
if (!signalReceiver.GetRegisteredSignals().Contains(signalAsset))
{
signalReceiver.AddReaction(signalAsset, stopTimelineEvent);
}
}
}

foreach (var trackAsset in timelineAsset!.GetRootTracks())
{
if (trackAsset as AnimationTrack == null) continue;
if (trackAsset.name.Contains(sourceTrackName))
{
sourceTrackAsset = trackAsset;
}

if (trackAsset.name.Contains(destinationTrackName))
{
destinationTrackAsset = trackAsset;
}
}
}

#endregion
}

 

それぞれのプロパティは下図のような関係持っています。

Start Maker Prefixはアニメーション開始のマーカーを識別するためのキーワード(接頭辞)、Stop Marker Prefixはアニメーション停止のマーカーを識別するためのキーワードとして設定します。このマーカーはSignalAssetの名前を基準にして行っています。

SourceTrackNameとDestinationTrackNameはそれぞれアニメーションをさせたいAnimationTrackの名前を文字列で指定しています。

f:id:fb8r5jymw6fd:20211226094152p:plain

 

トランジション効果を呼び出す処理を追加する

各GUIのボタンに相当するGameObjectにスクリプトを追加します。DestinationAnimatorに切り替え先のパネルをアタッチします。

f:id:fb8r5jymw6fd:20211226104146p:plain

 

TransitionControllerのソースコードはこんな感じです。現在ボタンに設定されている親のGameObjectのAnimatorをsourceAnimatorに指定しています。GUIボタンからはOnButtonClicked()メソッドから呼び出すように定義しています。

timelineTransition?.StartTransition()でトランジション効果を呼び出します。transitionNameのプロパティに指定されている"StartTransition"はTimelineのマーカー名で、アニメーション開始位置として設定しているSignalAssetの名前を入れています。

#nullable enable

using UnityEngine;

public class TransitionController : MonoBehaviour
{
[SerializeField]
private Animator? destinationAnimator;

[SerializeField]
private string transitionName = "StartTransition";

private Animator? sourceAnimator;

private TimelineTransition? timelineTransition;

public void OnButtonClicked()
{
if (timelineTransition != null && sourceAnimator != null && destinationAnimator != null)
{
if (sourceAnimator == destinationAnimator) return;

StartTransition(sourceAnimator, destinationAnimator);
}
}

private void StartTransition(in Animator source, in Animator destination)
{
timelineTransition?.StartTransition(source, destination, transitionName);
}

#region MonoBehaviour implements

private void Start()
{
timelineTransition = FindObjectOfType<TimelineTransition>();

var currentTransform = transform;
while (currentTransform.parent != null)
{
var parent = currentTransform.parent.gameObject;
var animator = parent.GetComponent<Animator>();
if (animator != null)
{
sourceAnimator = animator;
break;
}

currentTransform = currentTransform.parent;
}
}

#endregion
}

ButtonコンポーネントのOnClickイベントに上記スクリプトのメソッドを忘れず追加しておきます。

f:id:fb8r5jymw6fd:20211226105424p:plain

 

他のボタンコンポーネントも同じように、遷移先のGUIパネルを設定します。

 

できたもの

以下の動画が今回できたものになります。

f:id:fb8r5jymw6fd:20211226125314g:plain

 

また、今回の記事には解説していませんが、GitHubサンプルには他の例も入れています。

f:id:fb8r5jymw6fd:20211226125358g:plain

 

終わりに

以前からUnityのTimelineはPlayableTrack をはじめとした独自拡張が可能で、いろいろな用途に応用できそうなのは分かっていましたが、今回改めてその拡張性や新たな可能性を感じることができました!

TimelineそのものはPlayable APIの機能を使っているようなので、こちらへの理解も進めるとさらに広く応用が利きそうです。

お知らせ

弊社では現在様々な職種を募集しています。興味がある方は是非、以下のページを覗いてみてください!

twitter.com

meety.net

herp.careers

 

付録

*1:RectTransformからAnimationを作ろうとする場合、Add Propertyの項目がTransformではなく、RectTransformになっています。

f:id:fb8r5jymw6fd:20211226101751p:plain

Addressable Asset System入門

エンジニアの小松(@vtuber_watch)です。Synamon Advent Calendar 2021の21日目です。

UnityのAddressable Asset Systemの入門記事になります。Addressablesの紹介記事は多くありますが情報が古かったり必要な説明が抜けていたりするので最新のバージョンにおける情報をまとめています。

この記事では基本的な使い方を紹介します。アセットバンドルをビルドしてローカルからロードするまでをやります。これだけでもAddressablesの恩恵を受けられます。記事が長くなりすぎたのでアセットバンドルをサーバに配置する使い方は別記事に分割します。

Addressable Asset Systemとは

Addressablesを使うとUnityのアセットをアセットバンドルにまとめていい感じに読み込みできるようになります。

Addressablesによって得られるメリットはたくさんありますがここではメモリ使用量の削減とアセットバンドルの扱いの2点から紹介します。

メリット1. メモリ使用量の削減

Addressablesを使うとメモリ使用量を減らせます。この点については以下のUnity Blogの記事で紹介されています。

記事の中では3つのプレハブ(Sword, Boss Sword, Shiled)をランタイムに生成する例を使って説明されています。

public class SampleMonoBehaviour : MonoBehaviour
{
    // Addressablesを使わない場合
    [SerializeField] private GameObject prefab;

    // Addressablesを使う場合
    [SerializeField] private AssetReferenceT<GameObject> prefabReference;
}

Addressablesを使わない場合、MonoBehaviourのフィールドにプレハブをGameObjectとして持ち、必要なときにインスタンス化します。
この方法はメモリ効率が悪いです。プレハブを直接参照しているためこのMonoBehaviourが存在する限りプレハブ全体がメモリに載ってしまいます。

Addressablesを使う場合、MonoBehaviourのフィールドに持つのはGameObjectではなくAssetReferenceTになります。AssetReferenceTはGameObjectそのものではなくGameObjectへの参照です。必要に応じてプレハブを取得してインスタンス化します。
これによって必要なときだけプレハブがメモリに載るようになります。不要になったときにアンロードすることもできます。

メリット2. アセットバンドルを簡単に使える

Addressables以前は、アセットバンドルを実用するには自前で管理するためのシステムを用意する必要がありました。アセットバンドルの構成設定とビルド、ロードとアンロード、依存関係の解決などかなりの実装が必要です。

Addresssablesではこれらの機能がすでに用意されており、簡単にアセットバンドルを使い始められます。多くの機能が拡張できるように作られているので必要な部分だけカスタマイズして使えます。

パッケージのインポートと初期設定

Addressablesを使い始めるにはまずパッケージをインストールします。

1. メニューのWindow/Package ManagerからPackageManagerを開き、Addressablesを選んでInstallボタンを押します。
f:id:sakanox:20211221161925p:plain

2. インストールが完了したらメニューのWindows/Asset Management/Addressables/Groupsを押します。
f:id:sakanox:20211221162152p:plain

3. Create Addressables Settingsボタンを押します。
f:id:sakanox:20211221162534p:plain

以上でAddressablesがインストールされ、必要な設定ファイルが生成されました。

Addressablesの設定ファイルはAssets/AddressableAssetsDataフォルダに配置されます。

その中のAddressableAssetSettingsが全体の設定になります。
それぞれの設定の説明はマニュアルを見てください。今はデフォルトのままで大丈夫です。
f:id:sakanox:20211221163237p:plain

使ってみる

初期設定は出来たのでとりあえず使ってみます。

プレハブをアドレス可能(Addressable)にする

まずはサンプルとして使うプレハブを作ります。何でもいいですがここではデフォルトのCubeだけのプレハブを作ります。

作成したプレハブを選択すると、インスペクタの一番上に「Addressable」というチェックボックスがあります。ここにチェックを入れるとそのアセットはアドレス可能(Addressable)になります。アドレス可能なアセットがAddressablesシステムでロードできます。

チェックボックス隣のテキストはこのアセットのアドレスです。デフォルトではアセットのパスになっていますが自由に変更できます。このアドレスを指定してアセットをロードします。
f:id:sakanox:20211221164443p:plain

プレハブをインスタンス化する

作成したプレハブをロードしてみます。

ロードする方法はいくつかありますがここでは3つ紹介します。

  1. Addressables.LoadAssetAsyncはアドレスを指定してアセットをロードできます
  2. Addressables.InstantiateAsyncはアドレスを指定してプレハブをインスタンス化できます。
  3. AssetReferenceTはインスペクタでアセットを設定してアセットのロードやインスタンス化ができます。
1. Addressables.LoadAssetAsyncを使う

以下のスクリプトをシーンに配置して実行してください。プレハブがロードされてインスタンス化されます。

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class SpawnerByLoadAssetAsync : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> prefabHandle;
    private GameObject spawnedGameObject;

    private async void Start()
    {
        // Addressables.LoadAssetAsyncで読み込む
        prefabHandle = Addressables.LoadAssetAsync<GameObject>("Assets/Prefabs/Cube.prefab");

        // .Taskで読み込み完了までawaitできる
        GameObject prefab = await prefabHandle.Task;

        // 読み込んだプレハブをインスタンス化する
        spawnedGameObject = Instantiate(prefab);
        spawnedGameObject.name = "Spawned Game Object";
    }

    private void OnDestroy()
    {
        // インスタンス化したGameObjectを破棄する
        Destroy(spawnedGameObject);

        // 使い終わったらhandleをリリースする
        Addressables.Release(prefabHandle);
    }
}

アドレス可能なアセットはAddressables.LoadAssetAsyncでロードします。ここにロードしたいアセットのアドレスを指定します。

LoadAssetAsyncの戻り値はAsyncOperationHandleになっています。Addressablesの操作はほとんど非同期になっており、AsyncOperationHandleで操作の状態を表します。.Taskを付けてawaitすれば簡単に操作の完了を待てます。

また、Addressablesでロードしたアセットは使い終わったら必ずリリースする必要があります。Addressables.Releaseを使います。これを忘れるとプレハブが読み込まれたままになってしまうので注意してください。リリース漏れは後で紹介するEvent Viewerで確認できます。

内部では参照カウンタ方式でそのアセットが何ヵ所から使われているかをカウントしています。リリースするたびにカウントが減り、0になったらアセットがアンロードされます。

2. Addressables.InstantiateAsyncを使う

次のスクリプトも先のSpawnerByLoadAssetAsyncと同じような動作をします。

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class SpawnerByInstantiateAsync : MonoBehaviour
{
    private GameObject spawnedGameObject;

    private async void Start()
    {
        // Addressables.InstantiateAsyncでプレハブをインスタンス化する
        AsyncOperationHandle<GameObject> handle = Addressables.InstantiateAsync("Assets/Prefabs/Cube.prefab");

        // .Taskでインスタンス化完了までawaitできる
        spawnedGameObject = await handle.Task;

        spawnedGameObject.name = "Spawned Game Object";
    }

    private void OnDestroy()
    {
        // 使い終わったらインスタンスをリリースする
        Addressables.ReleaseInstance(spawnedGameObject);
    }
}

Addressables.LoadAssetAsyncの代わりにAddressables.InstantiateAsyncを使っています。名前の通り、ロードする代わりに直接インスタンス化できます。

リリースでもAddressables.ReleaseではなくAddressables.ReleaseInstanceを使います。AsyncOperationHandleではなくインスタンス化されたGameObjectを渡してリリースできます。

Addressables.LoadAssetAsyncはGameObject以外のアセットもロードできますがAddressables.InstantiateAsyncはプレハブ専用です。プレハブをインスタンス化したい場合はAddressables.InstantiateAsyncの方が簡単に書けます。

3. AssetReferenceTを使う

先ほどの2つの方法はアドレスを指定してロードする方法でしたが、AssetReferenceTを使うとインスペクタでアセットを設定してロードできます。*1

次のコードをシーンに配置して、prefabReferenceにCubeプレハブを設定します。

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class SpawnerByAssetReferenceT : MonoBehaviour
{
    [SerializeField] private AssetReferenceT<GameObject> prefabReference;

    private GameObject spawnedGameObject;

    private async void Start()
    {
        // InstantiateAsyncでプレハブをインスタンス化する
        AsyncOperationHandle<GameObject> handle = prefabReference.InstantiateAsync();

        // .Taskでインスタンス化完了までawaitできる
        spawnedGameObject = await handle.Task;

        spawnedGameObject.name = "Spawned Game Object";
    }

    private void OnDestroy()
    {
        // 使い終わったらインスタンスをリリースする
        prefabReference.ReleaseInstance(spawnedGameObject);
    }
}

ここではInstantiateAsyncを使ってプレハブをインスタンス化していますが、LoadAsyncもあるのでアセットのロードにも使えます。

AssetReferenceTはインスペクタで参照を設定できるのが強みです。特に理由がなければAssetReferenceTを使うのをお勧めします。

f:id:sakanox:20211221174854p:plain

Addressables Groups

全てのアドレス可能なアセットはグループにわけて管理されます。

メニューのWindows/Asset Management/Addressables/Groupsでグループを管理できます。

f:id:sakanox:20211221180242p:plain

画像のPrefabsやMaterialsがグループで、その下のAssets/Prefabs/Cube.prefabなどがグループに入っているアセットです。

グループは右クリックで新しく作成できます。グループにアセットをドラッグ&ドロップしてアセットを振り分けられます。

同じグループのアセットは1つのアセットバンドルとしてビルドされます。*2つまり、グループ単位でロードやアンロードが行われるということです。同じタイミングで使われるアセットを同じグループにしておくと効率が良くなります。

Play Mode Script

Play Mode Scriptメニューでエディタ上で実行したときの動作を設定できます。

f:id:sakanox:20211221185214p:plain

「Use Asset Database」ではアセットバンドルを使わずにアセットを直接読み込みます。動作が速いので開発中にはこれを使います。

「Simulate Groups」ではアセットバンドルは使われませんが、グループの設定に従ってアセットバンドルの動きをシミュレートします。これによってEventViewerでアセットバンドルのロードやアンロードを監視できるようになります。
エディタ上でグループの設定を試すのに使えます。

「Use Existing Build」では実際にビルドされたアセットバンドルが使われます。あらかじめアセットバンドルをビルドしておく必要があります。

アセットバンドルのビルド

Build/New Build/Default Build Scriptでグループをアセットバンドルにビルドできます。ビルドはLibrary/com.unity.addressables/aaフォルダに出力されます。
f:id:sakanox:20211221190757p:plain

ビルドされたアセットバンドルは、プロジェクトのビルド時には自動的にStreamingAssetsにコピーされます。これによって実機でも正しくアセットバンドルをロードできます。

グループの設定

Addressables Groupsでグループを選択するとグループごとの設定ができます。

f:id:sakanox:20211221180740p:plain

ここではBuild & Load Pathsについて説明します。他の項目についてはマニュアルを見てください。

Build & Load Paths

一番上の「Build & Load Paths」でアセットバンドルをどこにビルドしてどこからロードするかを設定できます。

f:id:sakanox:20211221182117p:plain
「Local」ではLibraryフォルダ内にビルドされ、プロジェクトのビルド時には自動的にStreamingAssetsにコピーされます。
ロードもEditor実行時はLibraryフォルダ、ビルドではStreamingAssetsから自動的に行われます。

f:id:sakanox:20211221182131p:plain
「Remote」ではServerDataフォルダ内にビルドされます。ビルドしたアセットバンドルは手動でサーバなどにアップロードする必要があります。
ロード時にサーバからダウンロードされます。

f:id:sakanox:20211221182458p:plain
「custom」ではパスを自由に設定できます。

Remoteに設定したときの詳しい使い方はまた別の記事で説明します。

Addressables Analyze

アセットバンドルには、そのグループに振り分けたアセットだけでなく、それらのアセットが依存するアセットも含めてビルドされます。

依存先のアセットがどこかのグループに入っていればそこへの参照が張られます。どこのグループにも入っていない場合は同じアセットバンドルに直接含まれます。実際にどのアセットがどのアセットバンドルに含まれるかをAddressables Analyzeで確認できます。

Addressables AnalyzeはAddressable GroupsのTools/Window/Analyzeメニューで表示できます。
f:id:sakanox:20211221193405p:plain

「Analyze Select Rules」を押すとグループ設定を検証できます。

使ってみる

実際に以下のグループ設定で検証してみます。Cube.prefabとCube2.prefabはどちらもGreen.matというマテリアルに依存しています。
f:id:sakanox:20211221193604p:plain

この状態で「Analyze Select Rules」を押すと以下のような結果になります。
f:id:sakanox:20211221193806p:plain

アセットバンドルに含まれるアセット一覧はBundle Layout Previewに表示されています。

ここでは2つのアセットバンドルにGreen.matが暗黙的(Implicit)に含まれてしまっています。同じアセットが別々のアセットバンドルに含まれると、同じアセットが2重にロードされて効率が悪くなります。Analyze結果でも黄色い三角で警告として表示されています。

これを解決するにはGreen.matをどこかのグループに入れてしまえばOKです。「Fix Selected Rules」を押すと自動的に重複するアセットを新しいグループに隔離してくれます。実行するとGreen.matがDuplicate Asset Isolationというグループに入りました。
f:id:sakanox:20211221194402p:plain

Addressables Event Viewer

Event Viewerを使うとアセットバンドルのロードやアンロードを監視できます。アセットのリリース漏れなどがないかを確認できます。

Event Viewerを使うには設定を変更する必要があります。

  1. AddressableAssetsData/AddressableAssetSettingsを選び、Send Profiler Eventsにチェックを入れます。
  2. Addressables GroupsでPlay Mode ScriptをSimulate GroupsまたはUse Existing Buildにします。

EventViewerはAddressable GroupsのTools/Window/Event Viewerで表示できます。エディタ上で実行開始すると以下のように現在の状態を見られます。
f:id:sakanox:20211221195520p:plain
ここではCube.prefabが1つロードされていることがわかります。

まとめ

  • グループに割り振られたアセットがアドレス可能となり、Addressablesシステムでロードできます。
  • グループに割り振られたアセットがアセットバンドルとしてひとまとめにビルドされます。
  • Addressables Analyzeを使ってアセットの重複を検査・修正できます。
  • Addressables Event Veiwerを使ってアセットのリリース漏れを検査できます。

*1:Unity2019以前ではインスペクタでGenericsを使えないためAssetReferenceT<GameObject>ではなくAssetReferenceGameObjectになります

*2:グループ設定で別のビルド方法もできますが、ビルド時間が延びたり管理しづらくなったりするのでお勧めしません

3Dモデリング未経験のエンジニアがBlender入門してみた

3Dモデリングについて最低限の知識を得る

こんにちは!エンジニアリングマネージャーの佐藤(@unsoluble_sugar)です!

この記事はSynamon Advent Calendar 2021、19日目の記事です。

qiita.com

弊社SynamonではVR/ARのプロダクト開発を行なっており、基本的にどのプロジェクトでもBizDev、エンジニア、デザイナーがタッグを組んで仕事をしています。企画提案や工数見積もり、開発においても、チームメンバーで議論を重ねながらプロジェクトを進行していきます。

note.synamon.jp

各作業分野でそれぞれの領域のプロが力を発揮してくれるものの、自分のバックグラウンド的にどうしてもエンジニア寄りの視点に偏りがちなことに課題を感じていました。デザイナーさんたちが使う用語やツール、3D空間への実装時におけるパフォーマンスに対する懸念点など、まだまだキャッチアップしきれていない要素が多いのが事実です。

f:id:unsoluble_sugar:20211219173708j:plain

自分なりに少しずつ勉強は進めていますが、やはり3Dモデリング経験の有無による共通認識の溝は深いことでしょう。

そこで今回のアドベントカレンダーをきっかけに、これまで足踏みしていた3Dモデリング領域への門を叩くことにしたという次第です。

Blenderを選択

3Dモデリングツールは様々なものがありますが、今回は身の回りに愛用者の多い「Blender」を選択することにしました。書籍やネット上の情報量も多く、何かしら困った際にも知人に助けを求められるなどのメリットが大きいだろうとの判断です。

www.blender.org

ちょうど本記事を書き始める少し前、2021年12月3日にBlender 3.0がリリースした模様。実に21年ぶりのメジャーアップデートだとか。

www.itmedia.co.jp

巷の書籍やネット上の情報では、Blender 2.82.9台が主流でしたが、未経験者の僕からすれば画面の見た目は大きく変わっていないように見受けられたため、最新版の3.0を使ってみることにしました。

Blenderの歴史的背景や基本機能を把握するには、公式マニュアルを参照すると良いでしょう。

docs.blender.org

自分は「Blenderについて」項目をザッと読んでから、実際にインストールして触ることにしました。

インストール

今回はWindows版を使用したキャプチャを載せていきます。

Blender公式のダウンロードページから、インストーラをダウンロードして実行。ウィザードに従ってインストールしましょう。

f:id:unsoluble_sugar:20211219133042j:plain

インストールが完了したらBlenderを起動します。

f:id:unsoluble_sugar:20211219133129j:plain

とりあえずインターフェースを日本語にします。

f:id:unsoluble_sugar:20211219133150j:plain

チュートリアル動画見ながら完全に理解する

まずは公式のチュートリアル動画を見ながら基本操作を学んでいきます。

youtu.be

デフォルトの3D Viewportを見てみると、Cubeオブジェクト、Camera、Lightが配置されていることがわかります。Unityエンジニアにとっては、実家のような安心感ですね。

f:id:unsoluble_sugar:20211219142223j:plain

Viewport Gizmosのアイコンをぽちぽちクリックするか、マウスのホイール中央ボタンを押し込みながらのドラッグで視点をぐるぐる動かせます。ホイールをグリグリ転がすとズームイン/ズームアウトですね。Unityを触っていれば、大体の操作感が同じなのですぐに馴染めるでしょう。

f:id:unsoluble_sugar:20211219134641g:plain

ハンドアイコンをクリックしてドラッグ、もしくはShiftキー押しながらマウスのホイール中央ボタン押し込みで画面の平行移動ができます。

f:id:unsoluble_sugar:20211219135025g:plain

オブジェクトを左クリックで選択状態に。その状態でビューメニューから「選択を表示」をクリックすると、対象オブジェクトを中心とした視点に移動します。

f:id:unsoluble_sugar:20211219135226g:plain

Shiftキー押しながらオブジェクトをクリック、または3Dビュー内で左クリックしながらのドラッグで複数オブジェクトを選択できます。オブジェクトを選択した状態でdeleteキー、またはXキーを押して削除ができます。

f:id:unsoluble_sugar:20211219143409j:plain

オブジェクト選択状態でGキー(Grab)を押すと自由に移動。オブジェクトを選択しで移動アイコンクリックで表示されるギズモの矢印ドラッグでXYZ方向への平行移動ができます。

f:id:unsoluble_sugar:20211219144927g:plain

オブジェクトを選択してSキー(Scale)を押すと、拡大・縮小ができます。

f:id:unsoluble_sugar:20211219145034g:plain

オブジェクトを選択してRキー(Rotate)を押すと、回転ができます。

f:id:unsoluble_sugar:20211219145652g:plain

Shfit + Aキー で表示されるメニューの「メッシュ」からオブジェクトを追加。

f:id:unsoluble_sugar:20211219150330g:plain

オブジェクトを選択してtabキーで編集モードに切り替え。頂点を選択してGキーを押すと、頂点を軸としてオブジェクトを変形できます。

f:id:unsoluble_sugar:20211219151643g:plain

辺選択、面選択アイコンをクリックすると、辺・面を変形させることができます。オブジェクトに対する基本操作はこんなところでしょうか。

f:id:unsoluble_sugar:20211219151852g:plain

その他の画面操作については、カメラビューや

f:id:unsoluble_sugar:20211219135340g:plain

ワークスペース切り替え

f:id:unsoluble_sugar:20211219135505g:plain

シーンの新規作成&切り替え

f:id:unsoluble_sugar:20211219135806g:plain

シェーディング切り替え

f:id:unsoluble_sugar:20211219140400j:plain

アウトライナー

f:id:unsoluble_sugar:20211219140438j:plain

モディファイアー

f:id:unsoluble_sugar:20211219140450j:plain

f:id:unsoluble_sugar:20211219140811j:plain

物理演算プロパティ

f:id:unsoluble_sugar:20211219140911j:plain

マテリアルなどの設定が紹介されていました。

f:id:unsoluble_sugar:20211219140933j:plain

軽く2時間ほど触ってみて、大まかな画面構成と基本操作については何となく把握できました。

簡単な3Dモデリングに挑戦してみた

基本操作がわかったので、さっそく簡単な3Dモデル作成にも挑戦してみることにしました。

YouTubeで見かけた、初心者向けの「世界一やさしいBlender入門!」を謳った動画を見ながらのチャレンジです。Blender 2.9を使った解説動画でしたが、3.0でも大きな差異はなく非常にわかりやすかったです。

youtu.be

以下、実際に動画を見ながら手順を真似してみた様子の抜粋です。簡単な机と椅子の3Dモデルを作っていきます。

まずはCubeを縮小・拡大、メッシュの追加や複製を駆使して机の基礎部分を用意していきます。

f:id:unsoluble_sugar:20211219165013j:plain

シェーディング切り替えでワイヤーフレーム表示すると、オブジェクトの位置関係が確認しやすいです。

f:id:unsoluble_sugar:20211219175317j:plain

オブジェクトを選択して編集モードにし、ctrl + B で「ベベル(面取り)」を設定していきます。角をなめらかにする加工処理ですね。

f:id:unsoluble_sugar:20211219165035j:plain

机と同じ要領で椅子を作ります。

f:id:unsoluble_sugar:20211219165114j:plain

平面メッシュを追加して床を設置。

f:id:unsoluble_sugar:20211219165137j:plain

机と椅子の位置(Z軸)を床に合わせます。

f:id:unsoluble_sugar:20211219165147j:plain

コレクションで各オブジェクトのグルーピングを行います。机、椅子、床の調整が管理しやすくなります。

f:id:unsoluble_sugar:20211219180003j:plain

オブジェクトにマテリアルを追加して色をつけていきます。

f:id:unsoluble_sugar:20211219165228j:plain

マテリアルに名前をつけると、別のオブジェクトにも同じものを適用できます。マテリアルでは表面をメタリックにしたり粗さなど質感調整ができるので、表現したい素材の再現を行いたい場合は、細かいパラメータ調整をする必要がありそうです。

f:id:unsoluble_sugar:20211219165246j:plain

ライトは「ポイント」「サン」「スポット」「エリア」毎の調整が可能です。照明の強さや角度、範囲など陰影を考慮した配置はセンスが問われますね。

f:id:unsoluble_sugar:20211219165312j:plain

カメラも透視投影と平行投影、視点の角度や位置調整、被写界深度など様々なパラメータがあります。これもこだわりだすと時間がかかりそうなポイントです。

f:id:unsoluble_sugar:20211219165346j:plain

こんな感じでしょうか。

f:id:unsoluble_sugar:20211219230443g:plain

画像レンダリングしてみる

ライトやカメラの調整が済んだら画像のレンダリングを行います。Blenderには3つのレンダリングエンジンが用意されており、それぞれ以下のような特徴があります。

レンダリングエンジン 特徴
Eevee(標準) 高速、高品質
Workbench 超高速、低品質
Cycles 低速、超高品質

メニューの [レンダー] - [画像をレンダリング] を選択。

f:id:unsoluble_sugar:20211219165355j:plain

レンダーウインドウにて [画像] - [名前を付けて保存] でレンダリングした画像が出力されます。

f:id:unsoluble_sugar:20211219165414j:plain

以下画像が、今回作成した3DモデルをCyclesでレンダリングしたものになります。

f:id:unsoluble_sugar:20211219165433p:plain

綺麗に出力されていますね!

fbxファイルのエクスポート&Unityへインポート

画像レンダリングしただけではエンジニアとしての名がすたるので、fbx形式でエクスポートしてUnityへのインポートを試みます。

対象オブジェクトを選択した状態で [ファイル] - [エクスポート] - [FBX(.fbx)] をクリック。

f:id:unsoluble_sugar:20211219190321j:plain

「選択したオブジェクト」「トランスフォームを適用」にチェックを付けてエクスポートします。

f:id:unsoluble_sugar:20211219190514j:plain

これで先ほど作成した椅子のオブジェクトがfbxファイルとして出力されました!

f:id:unsoluble_sugar:20211219190606j:plain

喜び勇んでUnityプロジェクトに持っていくと…なぜかオブジェクトの一部がスケスケに/(^o^)\

f:id:unsoluble_sugar:20211219190650j:plain

Blenderのビューポートオーバーレイで確認すると、どうやらオブジェクトの面の向きが内側に向いていたようです。青色はメッシュの向きが外側、赤色は内側に向いている状態を表しています。

f:id:unsoluble_sugar:20211219190751j:plain

スケスケになってしまっていたオブジェクトを選択し、編集モードで [メッシュ] - [ノーマル] - [面の向きを外側に揃える] をクリックします。

f:id:unsoluble_sugar:20211219190917j:plain

ビューポートオーバーレイでオブジェクト表面がすべて青色になったことを確認し、再びfbxファイルをエクスポート。Unityへ持っていきます。

f:id:unsoluble_sugar:20211219191141j:plain

無事にUnity上に椅子が召喚できました!

f:id:unsoluble_sugar:20211219191236j:plain

3Dモデリングの知識が皆無だったため、トラブル対応も一苦労でした。

See the Pen blender-model-viewer by unsoluble_sugar (@unsolublesugar) on CodePen.

Blender完全に理解した

というわけで…

  • Blenderに入門
  • 簡単な3Dモデルを作成
  • 画像のレンダリング
  • fbxファイルへエクスポート
  • Unityプロジェクトへインポート

という一連の流れを経験いたしました。

たかが数時間触った程度の初心者ではありますが、3DモデリングにおけるHello worldは学べたのではないでしょうか。実際に弊社デザイナーさんが担っている業務には程遠いものの、Blenderをきっかけに3Dモデリングの知見をより一層深めていきたいところです。

何より動画を見ながら手順をなぞっただけでも、3Dモデリングの楽しさを実感できたのは大きな収穫でした。自分の手で何かを生み出す感覚というのは、プログラミングとも近しい印象を受けました。

そのうち自分のオリジナルアバターも作れるくらいになりたいので、今後も趣味の一環としてBlenderを触っていこうと思います。

f:id:unsoluble_sugar:20211219195357p:plain

最後に

本テックブログやnote記事のお知らせは、Synamon公式Twitterで発信しています。弊社の取り組みに興味を持っていただけましたらぜひフォローお願いします!

twitter.com

カジュアル面談も実施中ですので「詳しく話を聞いてみたい!」という方はチェックいただけると嬉しいです。

▼カジュアル面談はMeetyから   meety.net  

▼エントリーはこちら   herp.careers

Synamonアドベントカレンダーの他の記事もぜひご覧ください!

qiita.com

現場からは以上です。

Netcode for GameObjectsでMobをスポーンさせる

f:id:fb8r5jymw6fd:20211218054333p:plain

はじめに

エンジニアの松原です。この記事はSynamon Advent Calendar 2021の18日目の記事になります。

以前の記事(その1その2その3)、では基本となるNetcode for GameObjectsの紹介をしてきました。これまではPlayerそのものやPlayer所有の共有オブジェクトを使ってサーバーとゲームクライアント間の同期処理について扱ってきました。

今回はゲームロジックの一環として、サーバー側でMobを作成する方法の紹介と、よくあるギミックとしてMobがPlayerを追跡するギミックについて紹介したいと思います。

 

前回に引き続き、今回の内容について個人のGitHubリポジトリにアップロードしておりますので、もしよろしければ触ってみてください!

 

今回の機能を反映したサンプルシーン名は SampleScene_NetworkMobSpawn.unityになります。

f:id:fb8r5jymw6fd:20211217095338p:plain

 

サーバー実行時にのみ特定のギミックを呼び出せるようにする

これまでの方法ではPlayer Prefabが追加されている状態を起点として処理していました。つまりクライアントやホスト、またはサーバーで実行するコードを取り扱っていました。今回はサーバー側でのみ実行される処理が必要、かつクライアント側からは実行されない導線が必要になります。

そこで NetworkManager.OnServerStarted というイベントを利用します。このイベントはクライアントとして実行されているアプリケーションでは呼び出されず、アプリケーションをサーバーとして実行するときのみに呼び出されるイベントです。

以下の処理はサンプルコードの ServerObjectsSupplierという名前のMonobehaviour継承クラスで実装されているコードから一部抜粋したものになります。このコードはサーバーとして実行されたアプリケーションに対して、シリアライズされているPrefabをInstantiate()してHierarchyに登録する簡単な実装になっていますが、クライアントでは呼びされれないため、サーバーのみの処理を追加したい場合に利用できます。

    [SerializeField]
private NetworkManager? networkManager;

[SerializeField]
private List<GameObject>? supplyingObjects;

private void Start()
{
if (networkManager != null)
{
networkManager.OnServerStarted += SupplyObjects;
     ...
}
}

private void SupplyObjects()
{
if (supplyingObjects == null) return;

foreach (var supplyingObject in supplyingObjects)
{
if (supplyingObject != null)
{
Instantiate(supplyingObject);
}
}
}

 

ServerObjectsSupplierクラスがアタッチされているGameObjectはHierarchyにあります。

f:id:fb8r5jymw6fd:20211217102945p:plain

 

このGameObjectのInspectorを見てみると、MobSpawnerというPrefabがアサインされています。このPrefabにアタッチされているMobSpawnerクラスからMobが生成されるようになっています。

f:id:fb8r5jymw6fd:20211217100551p:plain

 

Mobを呼び出す

続いて、Mobを呼び出すMobSpawnerについて説明します。MobSpawnerのPrefabにはMobSpawnerという、こちらもMonobehaviour継承クラスがアタッチされています。

f:id:fb8r5jymw6fd:20211217101720p:plain

 

MobSpawner (MobSpawner.cs)は開始時、特定のNetworkObjectを持っているPrefabをスポーンさせます。ここではMobとして登場させたいPrefabがアタッチされています。

7秒毎にMob.prefabをInstantiate()でインスタンス化し、NetworkObject.Spawn()を呼び出しています。

public class MobSpawner : MonoBehaviour
{

[SerializeField]
private float spawnDelay = 7f;

[SerializeField]
private GameObject? mobPrefab;

private float elapsedTime;

void Update()
{
elapsedTime += Time.deltaTime;

if (elapsedTime > spawnDelay)
{
if (mobPrefab != null)
{
var mob = Instantiate(mobPrefab);
mob?.GetComponent<NetworkObject>().Spawn();
}
elapsedTime -= spawnDelay;
}
}
}

 

この Mob.prefab はNetworkManagerからNetworkPrefabsとして登録されており、前回の記事で紹介した弾丸オブジェクトと同様、プレイヤー間で同期可能なオブジェクトとして利用できます。

f:id:fb8r5jymw6fd:20211217101956p:plain

 

MobがPlayerを追いかけるギミックを追加する

ここからはMobがPlayerを追いかけるギミックについて説明します。今回の動作サンプルはNavMeshでMobの動作範囲を定め、 Mobの移動処理はNavMeshAgent + NetworkTransformで実現しています。以下に軽くNavMeshの作り方とNavMeshAgentを使って、一番近くにいるPlayerを追いかけるギミックについて解説します。

 

NavMeshを作成する

NavMeshの作り方についてはUnity公式のNavMeshのチュートリアルに従っています。

NavMeshのベイクに必要なNavigationのWindowはUnity2020.3ではWindow > AI > Navigation に入っています。(場所が分かりづらかったので念のため図を付けています)

f:id:fb8r5jymw6fd:20211217193702p:plain

 

基本的なところは割愛しますが、NavMeshが動く範囲の地面として書き出したい対象はStaticオブジェクトにしておく必要があります。

f:id:fb8r5jymw6fd:20211217193903p:plain

 

NavigationのBakeタブのBakeを押してNavMeshを作成します。

f:id:fb8r5jymw6fd:20211217194118p:plain

 

これでMobが動く範囲を作成できたので、次のパートでMobのPrefabの中身について説明していきます。

 

必要なコンポーネントを追加する

いくつか他のギミック用にコンポーネントがアタッチされています。ここでは最低限必要になるコンポーネントについて説明します。

まず同期可能オブジェクトとして登録するためのNetworkObjectが第一に必要になります。そしてNavMeshAgentコンポーネント、NavMeshAgentから直接Transformの操作を可能にするためのNetworkObject(またはClientNetworkObject)、そしてNavMeshAgentのふるまいを決定するためのNetworkBehaviour継承のクラス(ここではMobAISyncBehaviourクラス)が必要になります。

f:id:fb8r5jymw6fd:20211217194305p:plain

 

MobAISyncBehaviourクラスについてはまた後で説明しますので、次は各Playerの位置を取得する方法について解説します。

 

各プレイヤーの位置を取得する

各プレイヤーのNetworkObjectを取得する

NetworkMangerにはプレイヤーの位置情報を取得するために重要なプロパティが含まれています。NetworkManager.SpawnManagerで取得できるNetworkSpawnManagerクラスです。

このクラスは現在NetworkObjectとして利用しているすべてのオブジェクトの管理を行っているクラスです。このクラスによって様々な種類のNetworkObjectへの操作を行えるようになります。

このクラスを利用してPlayerとして登録されているNetworkObjectを取得します。

先ほど登場したServerObjectsSupplierクラスからNetworkManagerクラスを利用し、NetworkManagerクラスのコールバックイベントである NetworkManager.OnClientConnectedCallback イベントを利用します。(ついでに NetworkManager.OnClientDisconnectCallback イベントも受け取れるようにしています)

    private void Start()
{
if (networkManager != null)
{
...
networkManager.OnClientConnectedCallback += OnClientConnected;
networkManager.OnClientDisconnectCallback += OnClientDisconnected;
}
}

 

このコールバックの受け取り先の関数は以下のようになっています。このイベントではPlayerがネットワークに接続した際、そのPlayerを識別するClientIDを引数に受け取れるため、それを利用してNetworkSpawnManager.GetPlayerNetworkObject() のメソッドを使ってPlayerのNetworkObjectを取得し、Dictionaryに保持しています。

    private void OnClientConnected(ulong clientId)
{
if (networkManager == null) return;

var networkObject = networkManager.SpawnManager.GetPlayerNetworkObject(clientId);
if (networkObject != null)
{
playerNetworkObjects.Add(clientId, networkObject);
}
}

 

また、このDictonaryのKeyにClientIDを指定しており、ServerObjectsSupplierクラスのプロパティとして参照できるようにしています。

public IReadOnlyList<ulong> PlayerClientIds => playerNetworkObjects.Keys.ToList();

 

他、各プレイヤーに割り振られているClientIDを使ってDictonaryからNetworkObjectを取得できるメソッドも用意しています。

public NetworkObject? GetPlayerNetworkObject(ulong clientId) => playerNetworkObjects[clientId];

 

この仕組みによって、Mob側の処理で各ユーザーの位置を取得できるようになります。

 

NetworkObjectからPlayerの位置情報を取り出してNavMeshAgentに処理させる

NetworkObject.Spawn() で作成された同期可能オブジェクトはGameObjectの実体を取得できます。これを利用してNetworkObjectのコンポーネントがアタッチされているGameObjectの位置を取得できます。

MobAISyncBehaviourクラスのUpdateで先ほどのServerObjectsSupplierの公開プロパティやメソッドを使って各PlayerのNetworkObjectを取得しています。そしてそのNetworkObjectのGameObjectの位置情報を取得します。

取得したGameObjectの位置情報を利用し、NavMesh.CaluculatePath() メソッドを使って、そのPlayerまでの通路が確保されているかをチェックしています。

チェックがOKだった場合、Playerの位置とMobの位置を Vector3.Distance() で距離の長さを測り、最も近いPlayerの位置がどれかを計算します。

そして最後に NavMeshAgent.SetDestination() のメソッドを使って、NavMeshAgentを使ってMobをPlayerに向かって動かすようにしています。

また、このUpdate処理はサーバー側でのみ実行されるようにしています。


private void Update()
{
       if (!IsServer) return;
if (serverObjectsSupplier == null || navMeshAgent == null) return;
elapsedTime += Time.deltaTime;

if (elapsedTime > updateRate)
{
NavMeshPath path = new NavMeshPath();

float minDistance = 1000f;
Vector3 targetPosition = transform.position;
bool foundTarget = false;

foreach (var clientId in serverObjectsSupplier.PlayerClientIds)
{
var networkObject = serverObjectsSupplier.GetPlayerNetworkObject(clientId);
if (networkObject == null) continue;

var source = transform.position;
var target = networkObject.transform.position;

if (NavMesh.CalculatePath(source, target, navMeshAgent.areaMask, path))
{
float distance = Vector3.Distance(source, target);
if (distance < minDistance)
{
minDistance = distance;
targetPosition = target;
foundTarget = true;
}
}
}

if (foundTarget)
{
navMeshAgent.SetDestination(targetPosition);
}

elapsedTime -= updateRate;
}
}

 

その他今回紹介しきれなかったギミック

前回作った弾丸のオブジェクトをMobにぶつけた時、Mobが消滅するギミックも追加しています。ただし、クライアント側で若干挙動がいまいちなので、ここはいつか作り直したいかと思っています。

 

今回作ったものの動画

f:id:fb8r5jymw6fd:20211217224226g:plain f:id:fb8r5jymw6fd:20211217224257g:plain

最後に

これまでのNetcode for GameObjects関連の記事と比べ、今回は少し複雑なギミックを取り扱いました。UnityのNetcode for GameObjectsではサーバー側でのみ処理を行うことで、クライアントの処理負荷を減らしたり、クライアント側に処理ギミックを隠蔽することに応用できそうです。

実際にゲームとして成立させるなら、Mobが障害物の迂回したり、ロコモーションをつけたり、もっと複雑なAIをつける必要はありそうです。

今後はAIについても今後考えていくのも良いかもしれません。

 

宣伝

今年のSynamonはアドベントカレンダーを実施しております、よろしければ他の記事もお読みいただけると嬉しいです!

qiita.com

 

また、弊社ではUnityエンジニアを募集しています。興味がある方は是非以下のページを覗いてみてください!

twitter.com

meety.net

herp.careers