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

 

 

 

インフラ周りで直面した課題と対応策について登壇しました

はじめに

エンジニアのうぃすきーです! この記事はSynamon Advent Calendar 2021の17日目です。 先日登壇してきたのでそれのレポート記事になります。

登壇したのはこちらのイベントです。定期的に開催されており今後も弊社メンバーが登壇しますのでよろしければ登録ください!

startup-issue-gym.connpass.com

イベントの様子はこちらで全編公開されています。

www.youtube.com

登壇資料はこちらになります!

speakerdeck.com

登壇内容「ネットワーク同期システムの置き換えで直面した課題」

弊社の製品であるNEUTRANSでは元々ネットワークエンジンとしてPhoton Cloudを使用していましたがDiarkisに置き換えられないか実験する機会が有りました。その際に直面した課題と対応策について話しました。

f:id:Whisky_shusuky:20211216175728p:plain

Diarkisではk8sがサポートされていますが業務要件の問題でそのまま使えなかったという話をしました。IPとポートが頻繁に変わってしまうためホワイトリスト設定が困難だったためです。

f:id:Whisky_shusuky:20211216180907p:plain

f:id:Whisky_shusuky:20211216180923p:plain

そのため対応策としてEC2上でDiarkisサーバーを動作させるように変更しました。Route53でドメインを法則性があるものに設定してポートを指定することでホワイトリスト設定が可能になるようにしました。

f:id:Whisky_shusuky:20211216181526p:plain

f:id:Whisky_shusuky:20211216181536p:plain

AWS SAの方からの提案

今回のイベントはインフラ関係の登壇だったということでAWSのSAであるmats16kさんがゲストで参加されていました。 そこで私の話を聴いたmats16kさんからGlobal Acceleratorを使用すればk8sのままで構築できたのではないかと提案頂けました。グローバルレベルの協力Load Balancerのようなものでありportを範囲指定してipが固定できて今回の要件にマッチするのではないかとのことでした。全く知らなかったサービスなので大変勉強になりました。

aws.amazon.com

感想

今回登壇の場でAWS SAの方と相談することができて大変勉強になりました。専業のインフラエンジニアを雇うことは難しい組織の小さいスタートアップではインフラ面で課題を感じた場合一人で悩まずにプロ中のプロであるAWSのサポートに相談するのも良い手であるとの学びを得ました。

.NET 6 MAUI(Preview)をWindowsとMacで触ってみたかった

はじめに

こんにちは、エンジニアの庭山(@rkoubou_jp)です。

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

前回書いた記事 「いろいろな種類がある .NET の違いとは」で.NETのことを書かせて頂きました。

synamon.hatenablog.com

今回は先日リリースされた .NET6 の MAUI について書きました。

この記事では新規プロジェクト作成~ビルド、実行までの流れを記載しています。

*Visual Studio、MAUIのプレビュー版を使用しているため、GAリリースまでの間で発生するアップデートで挙動が変わる場合もあります。ご了承ください。


目次

タイトルが「触ってみたかった」という過去形の理由

結論から書くと、

想定以上に正式バージョン前のMAUIは手軽に動作確認環境を作ったり、ビルドや実行確認するのはちょっと大変、と感じました。

GAリリース前の物なのでそういうものなのかもしれません or または私の .NET 歴がそこまで長くなくて勝手が分かっていない説もあります。

試行錯誤の末、何とかビルド・実行はできた…

csprojファイルの TargetFrameworks値 を一旦目的のターゲットのみに書き換えるなどの多少強引な回避策でなんとかビルド、実行ができました。(WindowsUIのみ解決できず)

当初はビルドエラーや実行もできないというトラブルにハマり、一筋縄ではいきませんでしたが、せっかくなので、遭遇したトラブルや動かすところまでを残そうと思います。

.NET6は正式版(GA)になったけど、MAUIのGAは2022年度の2Qに計画変更が入っていた

「MAUIは.NET6のGAリリースと同時にPreviewが取れず、2022年の2QでGAリリースというアナウンス」をこの記事を書き始めた頃はまだそれを知らず、全ての .NET SDKやVSを完全にクリーンアップしたりなどをしてハマっていました。

検索を繰り返すうち、以下のフォーラムのやり取りを見てVisual Studio 2022 プレビュー版を手元の環境で試行錯誤してみました。

devblogs.microsoft.com

devblogs.microsoft.com

環境

今回は以下の環境で試しました。

MAUIとは

Multi-platform App UI の略称。

.NET マルチプラットフォームアプリ UI (.NET MAUI) は、C# および XAML を使用してネイティブのモバイルアプリとデスクトップアプリを作成するためのクロスプラットフォームフレームワークです。

f:id:niwayama-synamon:20211211234901p:plain

引用元:.NET MAUI とは - .NET MAUI | Microsoft Docs

いわゆるGUIフレームワークで、デスクトップアプリケーションからモバイルアプリケーションまで対応しています。Xamarinの後継とも言われています。

MAUI GAリリースまでの間はプレビュー版を使う

2021/12/13時点の Visual Studio 2022 GA版 (17.0.x) では新規プロジェクト作成からテンプレートを選択・ソリューションを作成しても正常に動作しません。

f:id:niwayama-synamon:20211212225713j:plain

このプロジェクトは、Visual Studio の現在のエディションと互換性がありません。

Windows

Visual Studio Preview のダウンロードとインストール

docs.microsoft.com

Visual Studio Installer で追加でインストールしたワークロードは

f:id:niwayama-synamon:20211213015820j:plain

  • .NET によるモバイル開発
  • .NET デスクトップ開発
  • C++によるデスクトップ開発

です。

プレビュー版 Visual Studio で新規プロジェクトの作成が出来るようにはなりました

f:id:niwayama-synamon:20211213013719j:plain

新規作成直後の状態でビルドが出来ない…

f:id:niwayama-synamon:20211213021017j:plain

XA0003 VersionCode 1.0 is invalid. It must be an integer value. C:\***HelloWorld\HelloWorld\obj\Debug\net6.0-android\android\AndroidManifest.xml

AndroidのVersionCode値が整数ではなく小数で記述されている、という指摘を受けました。多分AndroidManifest.xml生成のバグでしょうか。

f:id:niwayama-synamon:20211213021202j:plain

エラーメッセージ部分の箇所をダブルクリックすると該当ファイルが開かれるので 1.01 に直し、再度ビルドを試み、ビルドが成功しました。

Windows アプリケーション (WindowsUI) の起動が出来ない

実行ボタンをクリックしても以下のエラーが出て実行できませんでした。

f:id:niwayama-synamon:20211213021450p:plain

binディレクトリ以下にある exe ファイルを直接実行しても何も表示されず…。

Android アプリケーションは実行出来た

Visual Studio の実行プロファイルを切り替えAndroid向けの実行を行ったところインストール、実行が出来ました。

ただ、前述のVersionCodeの問題があるため、再ビルドの度に1.0に巻き戻されたら1に書き換える必要がありました。

f:id:niwayama-synamon:20211213210646p:plain

macOS

上記のプロジェクトファイルをMacに持ってきてビルドを試してみます。

Visual Studio 2022 for Mac Preview のダウンロードとインストール

docs.microsoft.com

ソリューションファイルを開いたところ。(プロジェクトファイルの読む込みは問題無さそうです。)

f:id:niwayama-synamon:20211213174652p:plain

が、やはり一発でビルドを成功させてもらえません…エラー内容を読んで原因を追ってみます。 MacCatalyst用のInfo.plistの内容に問題があるようです

f:id:niwayama-synamon:20211213175533p:plain

HelloWorld/MacCatalyst/Info.plist: Error: The LSMinimumSystemVersion value in the Info.plist (10.15) does not match the SupportedOSPlatformVersion value (15.0) in the project file (if there is no SupportedOSPlatformVersion value in the project file, then a default value has been assumed). Either change the value in the Info.plist to match the SupportedOSPlatformVersion value, or remove the value in the Info.plist (and add a SupportedOSPlatformVersion value to the project file if it doesn't already exist).

Info.plist の LSMinimumSystemVersion値と プロジェクト(*.csproj)の SupportedOSPlatformVersion値 が一致しないので合せるか Info.plist 内のLSMinimumSystemVersionを削除してSupportedOSPlatformVersion値を記述する必要がありそうです。

Info.plist内の LSMinimumSystemVersion を削除、 csproj ファイルに SupportedOSPlatformVersion 値を追記しました。(一部抜粋)

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <!-- iOS, Android, MacCatalyst -->
        <TargetFrameworks>net6.0-ios;net6.0-android;net6.0-maccatalyst</TargetFrameworks>
        <OutputType>Exe</OutputType>
        <SingleProject>true</SingleProject>
        <RootNamespace>HelloWorld</RootNamespace>
        <!-- ##### 追記 ##### -->
        <SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
        :
        :
        :
    </PropertyGroup>

指定する数値ですが、おそらく MacCatalyst のバージョンと推察されます。

再びビルドを試みます。

f:id:niwayama-synamon:20211213202909p:plain

ビルド(macOS)に成功しました!

Macでの実行

f:id:niwayama-synamon:20211213182742p:plain

f:id:niwayama-synamon:20211213182510p:plain

iPhone (シミュレーター) での実行

ビルドエラー...

Error NETSDK1047: 資産ファイル 'HelloWorld/obj/project.assets.json' に 'net6.0-ios/iossimulator-x64' のターゲットがありません。 復元が実行されたこと、および 'net6.0-ios' がプロジェクトの TargetFrameworks に含まれていることを確認してください。 プロジェクトの RuntimeIdentifiers に 'iossimulator-x64' を組み込む必要が生じる可能性もあります。 (NETSDK1047)

TargetFrameworksiossimulator-x64 を加えましたが NuGetの復元で Invalid restore input. Invalid target framework 'unsupported' と怒られてしまいました…。

Web検索を駆使したところ、ほぼタイムリーで同じ問題に関するディスカッションを見つけました。

github.com

が、手元では根本的な解決には至らなかったため、ここは初心に帰り「最小構成」に削ぎ落としてみます。具体的には TargetFrameworks を ios のみにします。

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <!-- 他のプラットフォームは一旦無視し、iOSのみにした -->
        <TargetFrameworks>net6.0-ios</TargetFrameworks>
        :
        :
        :

Visual Studio のビルドターゲットも ios のみしか選択できません

f:id:niwayama-synamon:20211213182915p:plain

再びビルド。動きました!

f:id:niwayama-synamon:20211213191549p:plain

Android (エミュレーター)での実行

f:id:niwayama-synamon:20211213201123p:plain

TargetFrameworksnet6.0-android を設定し実行

Windowsの時と同様に VersionCode が整数ではなく小数になっているためビルドエラーが出ますが、ダブルクリックして該当箇所を 1 に書き換えることで回避できます。

f:id:niwayama-synamon:20211213201727p:plain

コマンドライン

csprojファイルの TargetFrameworks を書き換えるのではなく、以下のようにコマンドで実行すれば同じかもしれません。

dotnet msbuild -t:build -p:TargetFrameworks=net6.0-ios
dotnet msbuild -t:build -p:TargetFrameworks=net6.0-android
dotnet msbuild -t:build -p:TargetFrameworks=net6.0-maccatalyst

さいごに

.NET6とGAリリースと同時にMAUIもGAリリースされたと思いこんでて、実はGAリリースされていないことを知った時は「締め切り近いのに、どうしたものかな…」と半日ほど悩みました。

が、せめてビルド・実行だけでもどうにかしておきたいな、と思い続けましたが何とかたどり着けました。

本当はこの記事内でGUIを色々いじって試したかったのですが、GAリリースまでは様子見をしようかなと思います。

AppleWatchをハンドトラッキング補助デバイスとして使えないか試してみた

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

エンジニアの岡村です。先月、Apple Watch Series7を買いました。

www.apple.com

スマートウォッチは以前から欲しいなと思っていたのですが、せっかくiPhone使ってるんだから時計もApple Watchにしたい気持ちと、太陽光発電でメンテナンスフリーの腕時計に比べるとスタミナが……という気持ちがせめぎ合い、中々買えずにいました。ところが最近持っていた腕時計の調子が悪くなってしまい、S7の発売タイミングの良さもあったため買ってしまいました。現在一番使っている機能は睡眠管理です。

せっかくApple Watchを買ったので、このアドベントカレンダーという機会に、XR的な活用ができないか考えてみました。

最近のAppleはAirPodでの空間オーディオや、AirTagによる物を探す機能など、デバイス同士の位置関係を利用した機能を幾つかリリースしています。このような機能はARととても相性が良いため、Appleが発売されると噂されているARHMDも既存のAppleデバイスと連携する機能が付くんじゃないかと自分は想像していました。

もしApple Watchがそのような機能の一端を担うとしたらハンドトラッキングがじゃないかというのは多くの人が思い至るところではないでしょうか。丁度手首に付いているので手の位置を認識するにはおあつらえ向きです。現時点でも手を握る等によるジェスチャコントロールが出来るので、もしかしたらApple Watchを付けていたら指の動きまでトラッキング出来るようになるのではないでしょうか。

指の動きはともかく、Apple Watchの座標を使ったハンドトラッキングは頑張れば出来そうな気がするので、ちょっと作ってみることにします。

構想

丁度手元にどこのご家庭にもあるNreal Lightがあったため、これとApple Watchを連携させてハンドトラッキングを拡張してみる事にします。 f:id:Sokuhatiku:20211215183426j:plain www.nreal.ai

Nrealには元々ハンドトラッキング機能が付いており、両手を使って物を掴んだりジェスチャで操作したりといった事が可能です。が、デバイス前面にあるカメラを使ってのトラッキングである為、手を下ろしたり後ろに回したりした状態では機能を使えません。この制約をApple Watchを使うことで突破できないでしょうか。

Apple Watchのモーションセンサーの値をNrealにリアルタイムで送信し、ハンドトラッキング情報と組み合わせる事でNreal内の座標空間におけるApple Watchの位置を推定し、記録しておきます。 f:id:Sokuhatiku:20211214233958j:plain

ハンドトラッキングをロストしたら、Apple Watchのトラッキング情報を使って手の位置を推定し、トラッキングを続けます。 f:id:Sokuhatiku:20211214234517j:plain

これくらいならAppleWatch側でのモーションデータの取得、及びそのデータのNreal側へのリアルタイム送信さえ出来れば実現できそうな気がします。ただし、推定を真面目にやるとものすごく大変そうなので、その辺りは今回力を入れないようにします。

実装

Apple Watch(それどころかApple系全部)の開発は全くの未経験だった為、どのような機能が使えるのか調べながらの実装です。まず、デバイスのモーション情報はどうやらCoreMotionというAPIを利用して取得できるようです。

Core Motion | Apple Developer Documentation

そして、通信処理に関してはNWConnectionというAPIを使って普通にソケット通信が出来るようです。よってAndroidとの通信にはUDPソケット通信を使うことにします。

www.radical-dreamer.com

また、SwiftではこちらのMessagePackライブラリが簡単に使えそうだったのでMessagePackも採用しました。

github.com

Nreal側は安心と信頼のMessagePack for C#を使います。

github.com

データをサーバー(Nreal)に送る

とにもかくにもCoreMotionの情報をNreal側に送ることが出来なければ何も始まらないので、その辺りのコードを実装し、動かしてみます。

Apple Watch側のソースコードはこんな感じです。(Swift)

class MotionTrackerClient : ObservableObject {
    
    let motionManager = CMMotionManager()
    let connection:NWConnection!
    
    @Published var accelX:Double = 0
    @Published var accelY:Double = 0
    @Published var accelZ:Double = 0
    
    init(){
        // UDP接続
        let udpParams = NWParameters.udp;
        let endpoint = NWEndpoint.hostPort(host: "***.***.***.***", port:3000)
        connection = NWConnection(to:endpoint, using: udpParams)
        let connectionQueue = DispatchQueue(label:"WatchPositionTracker")
        connection.start(queue: connectionQueue)
        
        startMotionSensor()
    }

    func startMotionSensor(){
        //モーションセンサーの利用開始
        if (motionManager.isDeviceMotionAvailable ){
            motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
            motionManager.showsDeviceMovementDisplay = true
            
            motionManager.startDeviceMotionUpdates(
                to: OperationQueue.current!,
                withHandler: {(motion:CMDeviceMotion?,error: Error?) in self.updateMotion(motion: motion!)})
        }
    }
    
    func sendMotionDataManually(){
        //UIから叩いて現在のデータを送信
        sendMotionData(motion: motionManager.deviceMotion!)
    }

    func sendMotionData(motion:CMDeviceMotion){
        if connection.state == NWConnection.State.cancelled{
            let connectionQueue = DispatchQueue(label:"WatchPositionTracker")
            connection.start(queue: connectionQueue)
        }
        if connection.state != NWConnection.State.ready {
            return
        }
        
        //モーションセンサーの値の送信
        var writer = MessagePackWriter()
        let timestamp = motion.timestamp
        writer.pack(timestamp)
        
        let accel = motion.userAcceleration
        writer.pack(accel.x)
        writer.pack(accel.y)
        writer.pack(accel.z)
        
        let attitude = motion.attitude.quaternion
        writer.pack(attitude.x)
        writer.pack(attitude.y)
        writer.pack(attitude.z)
        writer.pack(attitude.w)
        
        let completion = NWConnection.SendCompletion.contentProcessed{(error:NWError?) in
        }
        connection.send(content: writer.data, completion: completion)
    }
    
    func updateMotion(motion:CMDeviceMotion){
        //モーション更新時のコールバック
        accelX = motion.userAcceleration.x
        accelY = motion.userAcceleration.y
        accelZ = motion.userAcceleration.z
        
    }
}

そして受信側はこんな感じです。(C#)

public class TrackingDataServer : MonoBehaviour
{
    private UdpClient _server;
    private readonly CancellationTokenSource _receiveLoopCanceller = new CancellationTokenSource();
    private readonly object _lockObject = new object();

    void Start()
    {
        // サーバーの初期化
        _server = new UdpClient(3000);
        NRInput.SetInputSource(InputSourceEnum.Hands);
        Task.Run(() => ReceiveLoop(_receiveLoopCanceller.Token));
    }

    private async Task ReceiveLoop(CancellationToken cancelToken)
    {
        // データ受信ループ
        while (!cancelToken.IsCancellationRequested)
        {
            var result = await _server.ReceiveAsync();

            var data = DeserializeTrackingData(result.Buffer);

            lock (_lockObject)
            {
                _lastTrackingData = data;
            }

            Debug.Log(
                $"time:{data.Timestamp}, accX:{data.Acceleration.x}, accY:{data.Acceleration.y}, accZ:{data.Acceleration.z}");
        }
    }

    private TrackingData DeserializeTrackingData(byte[] serializedBuffer)
    {
        // asyncメソッド内ではref構造体を使えないので別メソッド内でデシリアライズ
        var reader = new MessagePackReader(serializedBuffer);
        return TrackingData.Deserialize(ref reader);
    }

    private void OnDestroy()
    {
        _receiveLoopCanceller.Cancel();
    }
}

public readonly struct TrackingData
{
    // モーションデータをデシリアライズする為の構造体
    public readonly double Timestamp;
    public readonly Vector3 Acceleration;
    public readonly Quaternion Attitude;

    private TrackingData(double timestamp, Vector3 acceleration, Quaternion attitude)
    {
        Timestamp = timestamp;
        Acceleration = acceleration;
        Attitude = attitude;
    }

    public static TrackingData Default => new TrackingData(0d, Vector3.zero, Quaternion.identity);

    public static TrackingData Deserialize(ref MessagePackReader reader)
    {
        var timestamp = reader.ReadDouble();

        var accX = reader.ReadDouble();
        var accY = reader.ReadDouble();
        var accZ = reader.ReadDouble();

        var attX = reader.ReadDouble();
        var attY = reader.ReadDouble();
        var attZ = reader.ReadDouble();
        var attW = reader.ReadDouble();
      
        return new TrackingData(timestamp,
            new Vector3((float)accX, (float)accY, (float)accZ),
            new Quaternion((float)attX, (float)attY, (float)attZ, (float)attW));
    }
}

f:id:Sokuhatiku:20211215154543j:plain

動いた!

上のコードでは再接続に難があったり、Nreal側のアドレスが決め打ち等の問題はありますが、最低限今回の記事でやりたい画は実現できそうなので、後回しして先に進みます。

サーバーコードをNreal(コンピューティングユニット)に乗せ、ハンドトラッキングも付けてみましょう。

NrealとApple Watchの座標空間を合わせる

CoreMotionの値とNRSDKの値は全く異なる座標系を利用している為、片方をもう片方に合わせて変換してやらなくてはなりません。今回はNrealが主なのでNrealの採用している座標系、つまりUnityの座標系に合わせてCoreMotionの値を変換します。

Nrealのドキュメントによると、手首は掌方向がZ+、中指方向がY+となっているので、これを参考にApple Watchの座標系を変換します。

nrealsdkdoc.readthedocs.io

また、Apple公式のドキュメントによるとどうやら手前がZ+の右手座標系を採用しているようです。Unityは手前がZ-なので、Zの値を反転してやる必要があります。

Understanding Reference Frames and Device Attitude | Apple Developer Documentation

Apple WatchについてはどこがY軸でどこがZ軸でという情報は見つからなかったのですが、iPhoneに準拠するのであればディスプレイが向いている方向がZ+、そしてディスプレイの右がX+、上がY+だと思われます。

纏めると、座標系の関係は次の写真の様になると思われます。左下がApple Watchの座標系、もう一つがNrealにおける手首の座標系です。

f:id:Sokuhatiku:20211215045751p:plain

手首ボーンと時計本体の位置は画像の通り少し離れているのですが、今回その差は無視することにします。Z軸を反転したうえで90度回転させた座標補正を組み込み、加速度を取り出してNrealで可視化出来るようにしてみました。Apple Watchがあるであろう座標にオブジェクトを置き、位置が一目で分かるようにしています。

f:id:Sokuhatiku:20211215161637p:plain

成果物が以下のようなものになります。

結果

youtu.be

……ロケットパンチが出来上がりました。

何となく手を動かした方向に動いてはくれるのですが、すぐに加速してどこかに飛んで行ってしまいます。考えられる原因は通信経由で加速度を渡していることによる誤差の蓄積でしょうか。HWにも詳しくはないですが、そもそも加速度センサーはこういう用途で使うには誤差が大きいのかもしれません。もしこんな簡単な実装で実用に耐えうるものが出来るのなら既に公式がAPI作ってそうです。反省点、せめて移動量の推定値を出すくらいまではApple Watch側でやったほうが良かったですね。しかし真面目にやると無限に時間がかかるため今回はここまでです。ごめんなさい。

とりあえず最低限、Apple WatchからAndroidへ通信すること、モーションセンサの値を送信することが出来る事が分かっただけでも個人的には大きな収穫でした。Apple Watchは開発するには制約が厳しそうなデバイスだという印象だったのですが、思ったより色々遊べそうな感じです。

あと、AppleWatchは時間を表示するデバイスなので、画像を出すと何時ごろ作業してたのかバレバレですね。

ちなみに、Nrealでの開発や、結果で出したような動画の撮影方法は、同じアドベントカレンダーに参加している、こちらの記事に詳しく載っていますよ!(宣伝) synamon.hatenablog.com

Netcode for GameObjectsでプレイヤー間で同期する物体の生成 / 破棄

f:id:fb8r5jymw6fd:20211210202730p:plain

はじめに

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

前々回の記事前回の記事では Netcode for GameObjects について紹介してきました。今回は以前の内容を元に、プレイヤーが生成でき、かつプレイヤー間で同期処理を行うことができる物体の生成 / 破棄について紹介したいと思います。

 

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

 

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

f:id:fb8r5jymw6fd:20211210085018p:plain

 

NetworkManagerに同期可能オブジェクトを登録する

Netcode for GameObjects では Prefab 化したオブジェクトを下図のように NetworkManager の NetworkPrefabs の箇所に追加することで、対象のオブジェクトをプレイヤー間で同期できるオブジェクトとして登録されます。

f:id:fb8r5jymw6fd:20211210085203p:plain

 

このPrefabの中身は最低 NetworkObject が含まれていればよいですが、単純に 生成 / 破棄 しかできないため、 生成後に同期オブジェクトの操作を行う場合は NetworkBehaviour を継承したクラス内に手続きを記述し、Prefab にアタッチします。

下図は今回サンプルに用意した Bullet_NetworkTransform.prefab で、プレイヤーが射撃ボタンを押した際に、この Prefab を生成するように作られています。

必須の NetworkObject コンポーネントの他、弾丸のようにまっすぐ飛び続ける処理が実装されている BulletSyncBehaviour (NetworkBehaviour 継承クラス) をアタッチしており、動きの補完処理を行うための ClientNetworkTransform もアタッチしています。ユーザーからの操作が無い場合であれば、 NetworkTransform でも良いかもしれません。 (ClientNetworkTransform は前回の記事で紹介しています。)

f:id:fb8r5jymw6fd:20211210101437p:plain

BulletSyncBehaviour の実装に関しては同期オブジェクトの削除のパートで解説したいと思います。

 

スクリプトから同期可能オブジェクトの生成を行う

同期可能オブジェクトを実際に生成するには、 NetworkBehaviour 継承クラスから呼び出す必要があります。その呼び出し方法について紹介していきます。

下図は今回用意された Player Prefab として、 Player_NetworkShooting.prefab のコンポーネントになります。緑の枠で囲まれているのは、 NetworkBehaviour を継承した PlayerSyncShootingBehaviour のクラスになります。

このクラスのシリアライズされた Prefab に、先ほど登場した Bullet_NetworkTransform.prefab がアタッチされています。

f:id:fb8r5jymw6fd:20211210090655p:plain

 

同期オブジェクト生成のきっかけになるトリガーを作る

今回はガンシューティングのように、特定のトリガーが押されたときに同期オブジェクトを生成するように実装しています。キーボードのスペースか、ゲームパッドの右トリガーに入力が割り当てられています。

f:id:fb8r5jymw6fd:20211211123028p:plain

 

こちらは以前の記事で登場した PlayerInputHelper のクラスに HasFireInput のプロパティを定義し、トリガーボタンが押されているかを検出する処理を追加しています。

[SerializeField]
private float triggerDeadZone = 0.4f;

public float Fire { get; private set; }

public bool HasFireInput => Fire > triggerDeadZone;

public void OnFire(InputValue value) => Fire = value.Get<float>();

 

シリアライズ可能なプロパティとして triggerDeadZone が追加されています。

f:id:fb8r5jymw6fd:20211211124346p:plain

ゲームパッドなどの左右トリガーはアナログ入力になっており、0~1の範囲でトリガーの引き具合(押し込み量)が変化します。指でどれぐらい引けばボタンとして反応するかのしきい値をこのプロパティに設定しています。

余談になりますが、下図のように InputActionAsset で入力に対しての ActionType を Button にしている場合、トリガーなどのアナログ入力のしきい値は0.5がデフォルトになるようです。

f:id:fb8r5jymw6fd:20211211125109p:plain

 

トリガー発火時に同期オブジェクトを生成する処理を行う

以下のコードは PlayerSyncShootingBehaviour から抜粋したもので、 GameObject.Update() から呼び出されています。ここに先ほどのトリガー入力判定 (PlayerInputHelper.HasFireInput プロパティの読み取り)を行っています。この入力を元に同期オブジェクトを生成するギミックを呼び出します。

同期可能オブジェクトの生成はサーバー側で行う必要があります。

自分がサーバーの場合は直接生成するメソッド (GenerateBulletOnServer) を呼び出すようにしています。自分がクライアント端末の場合は GenerateBulletOnServer() のメソッドをサーバー側で呼び出すためのRPCメソッドを指定しています。

private void UpdatePlayerInputs(float delta)
{
if (playerInputHelper == null || gunRoot == null) return;

fireDelta += delta;

if (fireDelta > fireRate && playerInputHelper!.HasFireInput)
{
fireDelta = 0;
if (IsServer)
{
GenerateBulletOnServer(gunRoot.transform.position, gunRoot.transform.rotation);
}
else
{
SubmitGeneratingBulletRequestServerRpc(gunRoot.transform.position, gunRoot.transform.rotation);
}
}
}

 

以下はそれぞれ上記のメソッドの実装部分になります。

最初に GameObject.Instantiate() メソッドを使い、同期可能オブジェクトの Prefab を GameObject として生成しています。

次に、この Prefab には NetworkObject のコンポーネントが含まれているので、このコンポーネントを GetComponent<NetworkObject>() メソッドで取得します。

そして NetworkObject.Spawn() メソッドを呼び出すことにより、プレイヤーに生成した GameObject を同期可能オブジェクトとして生成することができます。

private void GenerateBulletOnServer(Vector3 position, Quaternion rotation)
{
var bullet = Instantiate(
bulletPrefab,
position,
rotation
);
bullet?.GetComponent<NetworkObject>().Spawn();
}

[ServerRpc]
private void SubmitGeneratingBulletRequestServerRpc(Vector3 position, Quaternion rotation)
{
GenerateBulletOnServer(position, rotation);
}

 

上記の記事についてはUnity公式の Object Spawning のページで紹介されています。

 

同期可能オブジェクトの破棄

一度生成した同期可能オブジェクトは、ライフタイムが用意されていないので、オーナー権限を持っているクライアントがログアウトしない限り残り続けます。

今回のサンプルでは同期可能オブジェクト (Bullet_NetworkTransform.prefab) 側にライフタイムを設定しており、指定した時間経過した時に削除する処理を実装しています。

以下のコードはサンプルの同期可能オブジェクトにアタッチしている BulletSyncBehaviour (NetworkBehaviour 継承のクラスになります) のコード一部になります。

UpdateLifeTime() はUpdateから呼び出されます。 elapsedTime に経過した時間が保持されており、任意の経過時間が過ぎた場合、サーバーであれば RemoveBulletOnServer() のメソッドが直接呼び出され、クライアントであれば SubmitRemoveBulletRequestServerRPC() が呼び出され、間接的にサーバーから上記のメソッドが同じように呼ばれます。

同期オブジェクトの破棄は NetworkObject.Despawn() で行います。このメソッドが呼び出されると サーバーからこの同期可能オブジェクトの破棄が行われます。

private void UpdateLifeTime(float delta)
{
if (elapsedTime > lifeTime)
{
if (IsServer)
{
RemoveBulletOnServer();
}
else
{
SubmitRemovingBulletRequestServerRpc();
}
}
}

private void RemoveBulletOnServer()
{
gameObject.GetComponent<NetworkObject>().Despawn();
}

[ServerRpc]
private void SubmitRemovingBulletRequestServerRpc()
{
RemoveBulletOnServer();
}

 

NetworkObject.Despawn()の注意点

NetworkObject.Despawn() を呼び出すことにより、同期可能オブジェクトはクライアント側では実際にインスタンスそのものが破棄されるようですが、サーバー側ではインスタンスがメモリ上に残っているようです。

その理由として、 Netcode for GameObjects では同期可能オブジェクトは Object Pooling の管理下にあるようで、同期オブジェクトの 生成 /破棄 によるメモリ操作のオーバーヘッドを減らすために Object Pool の仕組みが組み込まれているようです。

詳しいふるまいをカスタマイズできるようなので、ここもいつか深く追ってみてみたいと思います。

 

今回できたもの

f:id:fb8r5jymw6fd:20211210201404g:plain

 

最後に

実際にはゲームロジックやスコアボードなど、マルチプレイに必要な機能はたくさんありますが、今回でマルチプレイを行うための必須の機能がだいたい解説できたと思います。

ただし、実際にリモートサーバー上で実行した場合のパフォーマンスのチューニングなどまではできていないので、今後はそのあたりを考慮した実装も必要になってくるかもしれません。

 

宣伝

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

qiita.com

 

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

twitter.com

meety.net

herp.careers