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

 

HubsCloudをDocker環境で動かしてみる

はじめに

こんにちは!サーバサイドエンジニアのクロ(@kro96_xr)です。
この記事はSynamon Advent Calendar 2021の10日目です。

弊社はUnityエンジニアが多く私自身も最近はUnityを触っていたりするのですが、せっかくのアドベントカレンダーということでサーバサイドの記事を書こうと思い筆を取りました。

テーマは「HubsCloudをDocker環境で動かしてみる」です!Elixir初体験だけどなんとかなるやろ!対戦よろしくお願いします。

※正直行き当たりばったりな部分も多く、自分の理解の浅さから改善できるところは多々あると思います。ご指摘などございましたらTwitterなどにいただけると嬉しいです。

Mozilla Hubs/HubsCloudとは?

Mozilla HubsとはFirefoxで有名なMozilla社が提供しているオープンソースのVRシステムです。ブラウザ上で動作するため、PCやスマートフォンを使ってVR空間に入ることが可能であり、起動自体も公式サイトからルームを作成するだけで可能です。

hubs.mozilla.com

HubsCloudとは上記のMozilla HubsをAWS上で動作させることができるものです。AWSのマーケットプレイスで公開されているので導入自体は比較的簡単に出来るかと思います。とはいえ常時起動だとそれなりにコストもかかるんですよね。

それならローカルで起動してみればよくない?

というわけで調べてみるといくつかローカルで起動している記事は見つかりましたが、Dockerで動かしている記事は見つけられませんでした。それなら自分で手を動かしてDockerの環境構築を試してみましょう!

なお、公式discordではDockerに関してプライベートチャンネルでやりとりされているようでした。後述しますがローカル環境構築のサポートはしていない旨の記載がありましたので開発者に絞っているのでしょうか。一応依頼すれば招待してもらえそうでしたが…。

注)以下全て記事公開時点での情報になります。

構成および名称について

まず初めにHubsのシステム構成と記事内で出てくる名称についてざっくり説明します。
詳細についてはこちらをご覧いただくと良いかと思います。

  • クライアント

    • Hubs クライアントはReact、Three.js、A-Frameを組み合わせて作られています。
      今回こちらもローカルで立ち上げますが、Dockerにはのせないこととします。
      リポジトリはこちら
  • サーバーサイド

    • Reticulum
      ビデオ・音声以外の部分はReticulumが使われています。
      Reticulum自体はElixir/Phoenixで作られています。
      指定バージョンはElixir v1.8 + Erlang v22となります。
      リポジトリはこちら

    • Dialog
      ビデオと音声についてはDialogというWebRTCサーバが担っています。
      Dialogは"mediasoup"というオープンソースのプロジェクトをベースとしています。
      リポジトリはこちら

  • データベース
    DBについてはPostgres DBが使われており、推奨バージョンは11.xです。

Docker環境構築

それでは早速環境構築に移りましょう。

Reticulumのリポジトリを見ると、「チーム規模が小さいからローカル環境構築のサポートはしてないよ。でも自分で設定することは可能だよ。やる場合はHubsとDialogもローカル実行が必要だよ。」(意訳)と書いてあります。

Due to our small team size, we don't support setting up Reticulum locally due to restrictions on developer credentials. Although relatively difficult and new territory, you're welcome to set up this up yourself. In addition to running Reticulum locally, you'll need to also run Hubs and Dialog locally because the developer Dialog server is locked down and your local Reticulum will not connect properly

というわけでdocker-composeを使ってReticulum, Dialog, DBを立ち上げていくことにします。
余談ですがreticulumリポジトリにあるdocker-compose.ymlの更新日が3年前でした。つらい。

ディレクトリ構成

ディレクトリ構成はざっくりこのような形にしました。dialogディレクトリとreticulumディレクトリの構成自体はクローンしたものとほぼ同じなので省略しています。 また、クライアント(hubs/)は任意の場所で構いません。

ReticulumTest/  
   ┠dialog/  
   ┃ ┠リポジトリからcloneされたファイル  
   ┃ ┗certs/ (後述)  
   ┃   ┠server.key  
   ┃   ┠server.pem  
   ┃   ┗pub.key  
   ┠reticulum/  
   ┃ ┠リポジトリからcloneされたファイル  
   ┃ ┗storage/  
   ┃   ┗dev/  
   ┠tmp/  
   ┃ ┗db/  
   ┠docker-compose.yml  
   ┗Dockerfile

Dockerの設定

Dialog用のDockerfileはリポジトリのものをそのまま使っているので割愛します。

  • reticulum/Dockerfile
# 指定バージョンのelixir/Erlangが入ったベースイメージを使用
# https://hub.docker.com/layers/hexpm/elixir/1.8.2-erlang-22.3.4.23-ubuntu-focal-20210325/images/sha256-825e3361145e2394690e2ef94d6cc4587a7e91519ad002526d98156466d63643?context=explore
FROM hexpm/elixir:1.8.2-erlang-22.3.4.23-ubuntu-focal-20210325

# ディレクトリの設定
ARG ROOT_DIR=/ret
RUN mkdir ${ROOT_DIR}
WORKDIR ${ROOT_DIR}

# ディレクトリのコピー
COPY ./reticulum ${ROOT_DIR}

# 依存するライブラリのインストール
RUN apt-get update && apt-get install -y git inotify-tools
RUN mix local.hex --force && mix local.rebar --force && mix deps.get

# キャッシュファイル用ディレクトリ作成
RUN mkdir -p /storage/dev && chmod 777 /storage/dev

EXPOSE 4000
  • reticulm/docker-compoes.yml
version: '3'
services:
  db:
    image: postgres:11
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
        - ./tmp/db:/var/lib/postgresql/data
    ports:
      - "5432:5432"
  ret:
    build:
      context: ./
      dockerfile: Dockerfile
    environment:
      - "MIX_ENV=dev"
      - "DB_HOST=db"
    volumes:
      - ./reticulum:/ret
      - ./storage/dev:/storage/dev
    tty: true
    ports:
      - "4000:4000"
    depends_on:
      - db
  dialog:
    build:
      context: ./dialog
      dockerfile: Dockerfile
    environment:
      - "HTTPS_CERT_FULLCHAIN=/app/certs/server.pem"
      - "HTTPS_CERT_PRIVKEY=/app/certs/server.key"
      - "AUTH_KEY=/app/certs/pub.key"
    tty: true
    volumes:
      - ./certs:/app/certs
    ports:
      - "4443:4443"
  • 各種環境変数について補足

    • MIX_ENV
      コンパイル時に使用される設定ファイルを指定しています。この場合reticulum/config/dev.exsが読み込まれます。

    • DB_HOST, POSTGRES_USER, POSTGRES_PASSWORD
      dev.exsにDB設定があるのですが、接続に使用するユーザー名とパスワードがdev環境ではpostgresで指定されています。本来であれば当然変えるべきなのですが、今回は環境構築がメインということでそのまま指定しています。また、DB_HOSTを指定しないとlocahostで接続しにいくのですが、コンテナ間通信では使えないのでコンテナ名であるdbを指定する必要があります。

    • HTTPS_CERT_FULLCHAIN, HTTPS_CERT_PRIVKEY, AUTH_KEY
      後述しますが、Dialogサーバの証明書ファイル秘密鍵Reticulumサーバの公開鍵を設定します。

env_db_host = "#{System.get_env("DB_HOST")}"

# Configure your database
config :ret, Ret.Repo,
  username: "postgres",
  password: "postgres",
  database: "ret_dev",
  hostname: if(env_db_host == "", do: "localhost", else: env_db_host),
  template: "template0",
  pool_size: 10

初回起動

  • ビルドとコンテナの起動 特に言うことはありませんね。

    docker-compose build
    docker-compoes up -d

  • DB生成
    reticulumのコンテナに入ります。

    docker-compose exec ret bash

    DB生成を行います。そこそこ時間がかかるので気長に待ちましょう。

    root@~:/ret# mix ecto.create

  • サーバ起動

    iex -S mix phx.server

ここで終わったと思ってhttps://localhost:4000/としても残念ながら動きません。

f:id:krocks96:20211209031048p:plain

ソースコードを追うとわかるのですが、クライアント(Hubs)側でwebpackでサーバを立ち上げており、そのhtmlを取得しています。 そのためクライアント側の設定を進めていく必要があります。
なお、接続先自体はdev.exsに設定があります。こちらも後で修正します。

config :ret, Ret.PageOriginWarmer,
  hubs_page_origin: "https://#{host}:8080",
  admin_page_origin: "https://#{host}:8989",
  spoke_page_origin: "https://#{host}:9090",
  insecure_ssl: true

また、それ以外にも多数躓いたポイントがあったのでひとつひとつ進めていきます!

動かすまでにやること

HOSTSファイルの修正

hubs.localhubs-proxy.local127.0.0.1を紐づけてください。(方法は割愛)

クライアント(Hubs)の起動

hubsリポジトリのREADMEに記載されている手順に沿ってクライアントの立ち上げを行いましょう。
まず、hubsのhubs-cloudリポジトリから任意の場所にクローンして依存関係のインストールを行います。

cd hubs
npm ci

インストールが完了したらWebpack Dev Serverを立ち上げます。  

npm run local

クライアント-管理画面(Hubs/admin)の起動

こちらもREADMEに従って依存関係のインストールとWebpack Dev Serverの起動を行います。

cd hubs/admin
npm install
npm run local

Dialogの設定

Dialogコンテナではdocker-compose.ymlで指定した通り証明書ファイル秘密鍵Reticulumサーバの公開鍵が必要になります。
また、Reticulumリポジトリにあったサーバ証明書は有効期限が切れているので新たにオレオレ証明書を生成して設定してください。(方法は各自でお願いしますmm)
ポイントとしては、SAN(Subject Alternative Name)のDNS Nameの値がhubs.localになっていないとChromeでエラーになります

Chromeがコモンネームの設定を非推奨化、そのエラー対策としての自己署名証明書のCSRの作り方

ちなみに各サーバの証明書ファイルの配置は下記の通りです。リネームして置き換えるか、設定ファイルをいじって参照先を変えるかご自由にどうぞ。

  • Reticulumではreticulum/priv/dev
  • Hubsではhubs/certs
  • Dialogではsialog/certs

置き換えた後、ブラウザにルートCAの証明書をインポートする必要がありますが、こちらも各自お願いします。

Google Chromeへ証明書ファイルをインポートするには | GMOグローバルサイン サポート

最後に、Reticulumサーバの環境変数に秘密鍵の内容を設定しておきます。

docker-compose exec ret bash
export PERMS_KEY={生成した秘密鍵の内容}

これがないとReticulumとDialog間の通信ができずルーム入室できません。
永続化できてないけど一旦このままで…

各ソースコードの修正

これで動くようになった…かと思いきやできません。

Dockerを使っていなければReticulum⇔Webpack Dev Server間の通信が出来るはずなのですが、 今回はコンテナ⇔ホスト間の通信のため、一工夫必要です。

誰だDocker使おうなんて言い出したの。

Reticulumの修正

dev.exsを開き、host_front = "host.docker.internal"を定義してhubs_page_originらを修正します。

host = "hubs.local"
host_front = "host.docker.internal"

略

config :ret, Ret.PageOriginWarmer,
  # hubs_page_origin: "https://#{host}:8080",
  # admin_page_origin: "https://#{host}:8989",
  # spoke_page_origin: "https://#{host}:9090",
  hubs_page_origin: "https://#{host_front}:8080",
  admin_page_origin: "https://#{host_front}:8989",
  spoke_page_origin: "https://#{host_front}:9090",
  insecure_ssl: true

これでやっと通信できる!…と思いきやもう少し修正が必要です。

こちらはクライアント側のhubs/webpack.config.jsですが、allowedHostshubs.localが指定されています。

    devServer: {
      https: createHTTPSConfig(),
      host: "0.0.0.0",
      public: `${host}:8080`,
      useLocalIp: true,
      allowedHosts: [host, "hubs.local"],
      headers: {
        "Access-Control-Allow-Origin": "*"
      },

ですので、reticulum/lib/ret/http_util.exを修正してリクエストヘッダでHOSTを指定してあげましょう。

  defp retry_until_success(verb, url, body, options) do
    default_options = [
      # headers: [],
      headers: [{"Host", "hubs.local"}],
      cap_ms: 5_000,
      expiry_ms: 10_000,
      append_browser_user_agent: false
    ]
略

続いてローカル環境のDialogとの通信のための修正です。
dev.exsを以下のように修正します。

# dev_janus_host = "dev-janus.reticulum.io"
dev_janus_host = "hubs.local"

# config :ret, Ret.JanusLoadStatus, default_janus_host: dev_janus_host, janus_port: 443
config :ret, Ret.JanusLoadStatus, default_janus_host: dev_janus_host, janus_port: 4443

続いてadd_csp.exを修正します。

    # default_janus_csp_rule =
    #   if default_janus_host != nil && String.length(String.trim(default_janus_host)) > 0,
    #     do: "wss://#{default_janus_host}:#{janus_port} https://#{default_janus_host}:#{janus_port}",
    #     else: ""
    default_janus_csp_rule =
      if default_janus_host,
          do: "wss://#{default_janus_host}:#{janus_port} https://#{default_janus_host}:#{janus_port} https://#{default_janus_host}:#{janus_port}/meta",
          else: ""

最後にiex -S mix phx.serverでサーバを立ち上げなおしましょう。

長い道のりでしたがこれでhttps://hubs.local:4000?skipadminにアクセスすればトップページが表示されるはずです。

f:id:krocks96:20211209034638p:plain

※開発者モードでコンソールを見るとわかるのですが、ロケールの問題で翻訳できない部分([@formatjs/intl Error MISSING_TRANSLATION])が出てきています。
これは本家mozilla hubsを見ても一部だけ日本語訳されているので仕方ないと思ってそのままにします。

ユーザー登録を試してみる

右上のサインインをクリックするとサインイン画面に遷移します。ここでメールアドレスを入力してNextを押すと…

f:id:krocks96:20211209040441p:plain

Reticulumを立ち上げているコンソールにリンクが表示されるのでURLをクリックして認証を完了します。
f:id:krocks96:20211209194819p:plain

f:id:krocks96:20211209040740p:plain

DBの確認

DBコンテナに入ってユーザーを確認します。

docker-compose exec db bash
root@~:/# psql -U postgres -d ret_dev
ret_dev=# ret_dev=# SELECT * FROM accounts;

ちゃんと登録されていますね。
f:id:krocks96:20211209041213p:plain

管理ポータルを試してみる

DBにアクセスしてis_admintrueに変更します。 f:id:krocks96:20211209041508p:plain

その後https://hubs.local:4000/adminにアクセスすると管理画面が表示されるはずです。 f:id:krocks96:20211209042108p:plain

ルームの作成を試してみる

Dialogコンテナに入りWebRTCサーバを起動します。この際、コンテナ内のIPアドレスが必要になります。

Start dialog with MEDIASOUP_LISTEN_IP=XXX.XXX.XXX.XXX MEDIASOUP_ANNOUNCED_IP=XXX.XXX.XXX.XXX npm start where XXX.XXX.XXX.XXX is the local IP address of the machine running the server. (In the case of a VM, this should be the internal IP address of the VM).

というわけでコンテナに入って下記のように立ち上げます。
本当はdocker-network使ってやればいいんでしょうが…それだとアドベントカレンダーに間に合わない

docker-compose exec dialog bash
root@~:/app# hostname -i
{ipアドレス}
root@~:/app# MEDIASOUP_LISTEN_IP={ipアドレス} MEDIASOUP_ANNOUNCED_IP={ipアドレス} npm start

トップページの部屋を作成するボタンから入室します。

f:id:krocks96:20211209192920p:plain

真っ暗な空間ではありますが、無事に入室することができました!

f:id:krocks96:20211209193005p:plain

リアクションなどもこの通り!不気味!

まだまだ実用には程遠いですがひとまず動くところまでいったので今回はこれでおしまいです。

終わりに

Dockerで環境構築出来たら遊び倒せるし、環境の共有も楽だなーと軽い気持ちで始めたら思った以上に大変で泣きそうでした。

しかし、苦しんだ分HubsCloudへの理解を少し深めることができたのではないかなと思います。

普段であればおそらくここまで記録に残すこともないでしょうし、あらためてアドベントカレンダーに参加して良かったです。

参考リンク

以下参考リンクです。ありがとうございました。

Home · gree/hubs-docs-jp Wiki · GitHub

Mozilla Hubsメモ - フレームシンセシス

Mozilla HubsのバックエンドサーバーReticulumを改造する方法

Reticulumをローカルで動かしてデプロイする - Qiita

告知

本テックブログやnote記事のお知らせは、Synamon公式Twitterで発信しています。

弊社の取り組みに興味を持っていただけましたらぜひフォローお願いします!

twitter.com

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

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

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

Synamonアドベントカレンダーはまだまだ続きますので、今後もご覧いただけると嬉しいです!

qiita.com