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

はじめに

エンジニアのうぃすきーです! この記事は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

AR Foundationでユニティちゃん召喚スマホアプリを作ってみた

AR Foundation完全に理解した

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

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

Unityで簡単なARスマホアプリを作るためにAR Foundationというフレームワークを使ってみたので、その概要と具体的なアプリ開発の手順をご紹介したいと思います。

youtu.be

この記事を見ながらAR Foundationを完全に理解すると、以下動画のようなユニティちゃん召喚スマホアプリが誰でも簡単に作れちゃいます。

youtu.be

AR Foundationとは

AR Foundationとは、Unity公式が提供しているAR(拡張現実)開発専用のフレームワークです。

unity.com

Unityに組み込むことで、Android、iOSモバイル端末のほか、Magic Leap、HoloLensといった装着型ARデバイス向けアプリのビルド、デプロイが可能となっています。

このAR Foundationを利用すると、異なるプラットフォーム間の差異を意識せず開発を行うことができます。

AR Foundation では AR プラットフォームを切り替えるときに現在利用できない機能を持って行くことができます。ある機能が一方のプラットフォームで有効になっているがもう一方で有効になっていない場合、後で準備ができたときのためにそれにフックを付けておきます。新しいプラットフォームでその機能が有効になったら、アプリを完全に一から再構築する代わりに、パッケージを更新することで簡単に統合できます。

Unity の AR Foundation フレームワーク | クロスプラットフォーム対応の拡張現実(AR)開発ソフトウェア | Unity

フレームワークが各プラットフォームの機能を吸収してくれるので、AndroidならARCore、iOSならARKit、といった別々の実装をする必要がなくなり、共通実装のみで完結できちゃうという最高なやつですね。

各プラットフォームごとの機能サポートについては、以下の表をご参照ください。

ARCore ARKit Magic Leap HoloLens
Device tracking
Plane tracking
Point clouds
Anchors
Light estimation
Environment probes
Face tracking
2D Image tracking
3D Object tracking
Meshing
2D & 3D body tracking
Collaborative participants
Human segmentation
Raycast
Pass-through video
Session management
Occlusion

About AR Foundation | AR Foundation | 4.1.7

環境

今回使用するUnityおよびAR Foundationのバージョンは以下のとおりです。

WindowsのUnity上でAndroid&iOSビルドができる状態、および実機スマホで動作確認ができる環境を前提に解説します。

動作確認端末

  • Android:Pixel 5(Android 12)
  • iOS:iPhone 12 Pro(iOS 14.8)

それではさっそく、AR Foundationを使ったスマホ向けARアプリ開発の手順を紹介していきます!

Unityプロジェクトの作成

まずはUnityを起動。3Dテンプレートを選択し、新規プロジェクトを作成します。

f:id:unsoluble_sugar:20211206230413j:plain

AR Foundationと関連プラグインの導入

PackageManagerを開き、以下プラグインをインストールしましょう。

  • AR Foundation
  • ARCore XR Plugin
  • ARKit XR Plugin

f:id:unsoluble_sugar:20211206230635p:plain

Project Settingsの設定

Project SettingsのXR Plug-in Managementにて、iOS&Androidビルド用に、各タブのARKitARCore項目にチェックを入れます。

f:id:unsoluble_sugar:20211206230725j:plain

f:id:unsoluble_sugar:20211206230749j:plain

Sceneの設定

Hierarchyで右クリックし、 XR > AR Session 、 XR > AR Session Origin を選択してオブジェクトを追加します。

f:id:unsoluble_sugar:20211206233341j:plain

f:id:unsoluble_sugar:20211206231354p:plain

オブジェクトの追加

AR Session OriginAR CameraというCameraコンポーネントが存在するため、Main Cameraは削除します。

f:id:unsoluble_sugar:20211206230910j:plain

動作確認用にCubeを追加。

f:id:unsoluble_sugar:20211206230934j:plain

Scaleがデフォルトの1ではデカ過ぎるので、10分の1の0.1にしておきます。

f:id:unsoluble_sugar:20211206230958j:plain

ここまでできたら、一旦スマホ実機でどんなもんか見てみましょう。

Androidのビルド設定

まずはAndroidから。

Build Settingsを開き、PlatformでAndroidを選択してSwitch Platformボタンをクリック。プラットフォーム切り替えには少し時間がかかるので、Twitterでも眺めながら待ちましょう。

f:id:unsoluble_sugar:20211206231556p:plain

Add Open ScenesボタンをクリックしてSampleSceneを追加します。

f:id:unsoluble_sugar:20211206231635j:plain

Minimum API LevelをAndroid 7.0 'Nougat' (API level 24)に設定します。これはARCoreがサポートしているOSバージョンが、Android 7.0以上1だからです。

f:id:unsoluble_sugar:20211206231657j:plain

Player Settings > Other Settings でAuto Graphics APIにチェックを入れます。

f:id:unsoluble_sugar:20211206231714j:plain

Androidアプリの64bit対応2のため、Scripting BackendをIL2CPPに変更し、Target ArchitecturesでARM64にチェックを入れましょう。

f:id:unsoluble_sugar:20211206232114j:plain

f:id:unsoluble_sugar:20211206232137j:plain

これでビルドが通るようになったので、試しに実機で動作確認してみます。

f:id:unsoluble_sugar:20211206233159j:plain

現実世界に豆腐が出現しました!

iOSのビルド設定

iOS向けにビルドする場合は、以下の設定を行ないます。

Build SettingsのPlatformでiOSを選択してSwitch Platformボタンをクリック。

f:id:unsoluble_sugar:20211207160233j:plain

そのままビルドすると「ARKitのCamera Usage Descriptionが設定されてないよ!」的なエラーが出ます。

f:id:unsoluble_sugar:20211207155550j:plain

Require ARKit Supportにチェックを入れると、連動してCamera Usage Descriptionにコメントが設定されビルドが通るようになります。

f:id:unsoluble_sugar:20211207160039j:plain

あとは出力されたXcodeプロジェクトをMacで開いて「ビルド実行どーん!」でAndroidと同じものが再現できます(Xcode上でのビルドエラー発生時は何やかんや対応入れて修正しましょう)。

平面検出機能の実装

とりあえずAndroid、iOSで簡単な実行確認ができたので、続いて平面検出の機能(Plane tracking)を試してみましょう。

Hierarchy右クリックでAR Defalut Planeを追加してPrefab化しておきます。

f:id:unsoluble_sugar:20211206233400j:plain

AR Session OriginAR Plane Managerを追加。Plane PrefabにPrefab化したAR Default Planeをアタッチします。

f:id:unsoluble_sugar:20211206233044p:plain

ビルド実行してスマホを動かしてみると、平面検出されることがわかります。

f:id:unsoluble_sugar:20211206233102j:plain

めちゃ簡単ですね。

タップでオブジェクト生成する

これだけだと簡単過ぎてつまらないので、平面領域をタップするとその場所にオブジェクトを生成するようなスクリプトを組んでみます。

AR Session OriginAR Raycast Managerを追加します。

f:id:unsoluble_sugar:20211206235533p:plain

ARManager.csというファイルを作成。以下のようなコードを書きます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;
public class ARManager : MonoBehaviour
{
    [SerializeField] GameObject objectPrefab;
    private ARRaycastManager raycastManager;
    private List<ARRaycastHit> raycastHitList = new List<ARRaycastHit>();
    void Start()
    {
        raycastManager = GetComponent<ARRaycastManager>();
    }

    void Update()
    {
        if (Input.touchCount > 0)
        {
            Touch touch = Input.GetTouch(0);
            if (touch.phase != TouchPhase.Ended)
            {
                return;
            }
            if (raycastManager.Raycast(touch.position, raycastHitList, TrackableType.All))
            {
                Instantiate(objectPrefab, raycastHitList[0].pose.position, raycastHitList[0].pose.rotation);
            }
        }
    }
}

作成したARManager.csAR Session Originに追加し、Object PrefabにCubeのPrefabをアタッチ。Hierarchy上のCubeはお役御免なので削除しておきます。

f:id:unsoluble_sugar:20211207003431p:plain

この状態でビルド実行。平面検出された場所をタップすると、タップした場所に豆腐が出現するようになります。

f:id:unsoluble_sugar:20211207002643j:plain

何だかARアプリっぽくなってきましたね。

SDユニティちゃんを召喚する

豆腐を出現させるだけでは味気ないので、生成するオブジェクトをユニティちゃんに置き換えてテンションを上げましょう。今回はSDユニティちゃんの3Dモデル3をお借りします。 unity-chan.com

ユニティちゃん公式サイトからダウンロードしたSDユニティちゃんのUnityパッケージをimportします。

f:id:unsoluble_sugar:20211207003804j:plain

Assets > UnityChan > SD_unitychan > Prefabs > SD_unitychan_generic.prefab を使います。そのままだとデカ過ぎるので、Cube作成時と同じくScaleは0.1にしておきます。

ただしScale変更したものをそのまま使用すると、ユニティちゃんの髪が逆立ったり揺れものがめくれ上がったりしてしまうため、以下のような制御スクリプト4を書いてPrefabにアタッチします。

using UnityEngine;
using System.Collections;
using UnityChan;

public class Extention_SpringForUnityChan : MonoBehaviour
{
    const float resizeMag = 0.1f; //!< Resize number

    void ChangeRadius_Spring(GameObject go)
    {
        SpringCollider[] cs_sc = go.GetComponentsInChildren<SpringCollider>();
        for (int i = 0; i < cs_sc.Length; i++)
        {
            cs_sc[i].radius = cs_sc[i].radius * resizeMag;
        }
        SpringBone[] cs_sb = go.GetComponentsInChildren<SpringBone>();
        for (int i = 0; i < cs_sb.Length; i++)
        {
            cs_sb[i].radius = cs_sb[i].radius * resizeMag;
        }
    }

    void Awake()
    {
        ChangeRadius_Spring(gameObject);
    }
}

f:id:unsoluble_sugar:20211207010556j:plain

その他、ユニティちゃんPrefabにはポーズや表情変更用のUI表示スクリプトが付いています。今回はUI表示は不要なので、該当スクリプトの Is GUI チェックを外しておきましょう。

f:id:unsoluble_sugar:20211207010526j:plain

そんな感じで色々ごにょごにょしたユニティちゃんPrefabを、先ほどARManager.csのObject PrefabにアタッチしたCubeと差し替えます。

f:id:unsoluble_sugar:20211207010623j:plain

これでビルド実行すると、タップした場所にSDユニティちゃんが現れるARアプリの完成です!

f:id:unsoluble_sugar:20211207011545j:plain

若干検知が怪しい部分があり、変なところにユニティちゃんが出現することもありますが可愛いので許しましょう。

youtu.be

ダンスモーションやエフェクトなどをつけてあげると、より楽しくなりそうですね。

公式サンプルも参考に

Unity公式のAR Foundationサンプルも公開されているので、他の機能を試す場合は参考にしてみると良いでしょう。 github.com

これでAR Foundationを完全に理解しましたね。

皆さんも色々なアイディアを盛り込んで、オリジナルのARアプリを作ってみてください♪(๑•̀ㅂ•́)و✧

unity.com

最後に

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

twitter.com

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

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

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

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

qiita.com

だれでもできる!Nreal Lightアプリ作成入門

この記事はSynamon Advent Calendar 2021の7日目の記事になります。

Nreal Lightは個人が使用できる数少ないARグラスで、非常に扱いやすくSynamonでも日々モック作りなどに活用しています。

そんなNrealLightについて他社様と勉強会を行った際に「開発について話をして欲しい」とオーダーを受けたものの参加者全体のスキルセットが分からなく、それならばとUnityのインストールから資料化しました。

普段用意しないような導入部からスクリーンキャプチャをしていったため意外と枚数が多く、何かに活用できればと思い今回ブログにて公開することにしました。

と言ってもUnityでのNreal Lightアプリの開発はSDK公開当初に比べてとても楽な状態になっていて、要約すると以下のような数ステップの内容となります。

  1. Unity(2018.2.x or Later)でプロジェクトを作成し、unitypakageのNRSDKをインポート
  2. Android用にSwitch Platform
  3. Project TipsウインドウにてAccept Allボタンを押下
  4. NRCamera プレハブと NRInput プレハブをシーンに配置
  5. Unity上で任意のコンテンツを作成
  6. ビルドして作成したapkをAndroid端末にインストールしてNreal Lightより実行

簡単ですね。

それでは順を追って見ていきたいと思います。

構成と注意点

今回資料作成にあたり使用した環境は以下の通りです

  • Nreal Light Devkit(Red)
  • Nreal SDK 1.7.0
  • Unity 2020.3.31f1 LTS

Nreal LightのDevkitを使いました為、スマートフォンをお使いの場合は開発者モードへの移行など資料上にない操作が必要になるかもしれませんのでご注意ください。

準備

UnityプロジェクトにてインポートするNreal Light用のSDK(NRSDK)を、Nrelの開発者向けサイトよりダウンロード

developer.nreal.ai

f:id:Q1J011:20211203140912p:plain

Unityのインストール

Unityとプロジェクトを管理するためのツール・Unity Hubをダウンロード、

ダウンロード後にインストール、実行します。f:id:Q1J011:20211203141101p:plain

Unity HubよりUnityをインストール

インストールするバージョンは2018.2以降を選択してください。

当社では2020にて動作することを確認しています。

f:id:Q1J011:20211203144419p:plain

f:id:Q1J011:20211203144444p:plain

プロジェクトの新規作成

Unityのインストールが完了したら、同じくUnity Hubからプロジェクトを新規作成

f:id:Q1J011:20211203144619p:plain

この時に使用するUnityのバージョンを選択

はじめてUnityを使う場合は先ほどインストールしたバージョンのみ選択できる状態だと思いますが、既にUnity HubとUnityを使用していて複数バージョン表示される場合は、2018.2以降のバージョンを選択してください。

f:id:Q1J011:20211203144637p:plain

Unityのバージョンを選択後、プロジェクト名を決めて作成を実施

f:id:Q1J011:20211203144645p:plain

少し待つとUnityエディタが開き作成したプロジェクト画面が表示されます。

f:id:Q1J011:20211203150015p:plain

Nreal Light用のSDK(NRSDK)のインポート

作成したプロジェクトにNreal Light用のSDK(NRSDK)を取り込みます。

f:id:Q1J011:20211203150050p:plain

インポート用のダイアログが表示されるのですべてチェックされていることを確認してImportを実施

f:id:Q1J011:20211203150101p:plain

f:id:Q1J011:20211203150111p:plain

インポートが完了するとNRSDKのProject Tipsウインドウが開き、設定のエラーや警告を伝えてきますが気にせず先に進めます。

f:id:Q1J011:20211203150120p:plain

Nreal LightはAndroidで動くのでUnityのビルド設定をAndroid向けに変更

f:id:Q1J011:20211203150139p:plain

f:id:Q1J011:20211203150151p:plain

ビルドの設定変更(Switch Platfrom)が完了したらNRSDKの Project Tips ウインドウにもどり Accept All ボタンを押すことでNreal Light向けの設定が完了しエラーや警告が消えます。

f:id:Q1J011:20211203150229p:plain

f:id:Q1J011:20211203150245p:plain

 

f:id:Q1J011:20211203150527p:plain

Nreal Light用のカメラとコントローラーの配置

シーン上にNreal Light用のカメラとコントローラーを用意します。

SDKに用意されている NRCamera プレハブと NRInput プレハブをHierarchyにドラッグ&ドロップしてシーンに追加します。

f:id:Q1J011:20211203150537p:plain

この状態でプロジェクトをビルドすればNreal Lightで動くapkファイルが出力されますが、背景は透過状態となるのでARグラスには何も映りません。

なのでここではNreal Light上での動作確認用にシーンにNrealのロゴを配置します。

f:id:Q1J011:20211203150546p:plain

f:id:Q1J011:20211203150556p:plain

ついでにスクリプト使って回転させます。

先ずはC#Scriptファイルを作成

f:id:Q1J011:20211203150607p:plain

Y軸回りで1フレームに1度回転するようUpdateメソッドにスクリプトを追加

f:id:Q1J011:20211203150617p:plain

作成したスクリプトをシーン上のNrealロゴにAdd Componentすることで実行時に回転するようになります。

f:id:Q1J011:20211203150651p:plain

Nreal Light用のapkファイルを作成(ビルド)

シーンの準備が出来たらNreal Light用の実行ファイルとなるapkファイルを作成します。

Unityのメニューより File > Build Serttings.. を選択してBuild Serttingsダイアログを開き、Buildボタンをクリック

f:id:Q1J011:20211203150703p:plain

apkファイル名を設定して保存ボタンを押したらapkファイルが出力されます。

f:id:Q1J011:20211203150710p:plain

Android端末へのインストール

Nreal Lightに接続するAndroid端末にはNreal公式でも推奨しているScrcpyを使うと便利です。

お使いのOSに合わせて公式より実行ファイルを取得することが出来ます。

github.com

パソコンとAndroid端末をUSBで繋いでScrcpyを実行するとAndroid端末の画面がミラーリング表示されます。

f:id:Q1J011:20211203150736p:plain

この状態でインストールしたいapk(ビルドしたapk)をドラッグ&ドロップすることでインストールできます。

f:id:Q1J011:20211203150745p:plain

一覧にアプリケーションが表示されたらインストールできました。

f:id:Q1J011:20211203150754p:plain

実機での確認

Nrealのメニューより実行して回転するNrealロゴが見えたら完成です。

おつかれさまでした。

f:id:Q1J011:20211203150803p:plain

おまけ.1 実行時の動画キャプチャ機能追加

Nreal Lightには残念ながら体験者が見ている映像を共有する機能が備わっていないのですが、SDKを使って体験者視点で映像キャプチャすることが可能です。

NrealのSDK(NRSDK)にも動画キャプチャ機能のサンプルプロジェクトがあり、簡単に自プロジェクトに流用することが出来ますので紹介します。

 

先ずProjectウインドウにて NDSDK > Demos に含まれる RGBCamera-Record シーンを開きます。

f:id:Q1J011:20211205203436p:plain

シーン上に動画キャプチャをするためのUI、VideoCaptureExample ゲームオブジェクトが置いてあるので、Hierarchy ウインドウから Project ウインドウにドラッグ&ドロップしてプレハブ化します。

f:id:Q1J011:20211205203528p:plain

動画キャプチャ機能を追加するシーンを開き、Project ウインドウよりVideoCaptureExample プレハブを Hierarchy ウインドウへドラッグ&ドロップしてシーン上にゲームオブジェクトとして設置します。

f:id:Q1J011:20211205203539p:plain

f:id:Q1J011:20211205203547p:plain

シーン上の VideoCaptureExample ゲームオブジェクトの位置を調整してUIを操作しやすい位置に移動します。

f:id:Q1J011:20211205203554p:plain

アプリケーションが動画キャプチャをファイルに保存できるよう書き込み権限の設定を行います。

Unityのメニューより Edit > Project Serttings.. を選択してProject Serttingsダイアログを開き、Player > Write Permission をExternal(SDCard)に変更します。

f:id:Q1J011:20211205203618p:plain

(必須ではないのですが)キャプチャした動画の保存されるディレクトリを変更しておくとAndroid端末で確認できるようになり便利です。

f:id:Q1J011:20211205203610p:plain

f:id:Q1J011:20211205203638p:plain

保存した動画ファイルは adb pull コマンドで取り出すことが出来ます。

f:id:Q1J011:20211205203645p:plain

おまけ.2 コントローラーをハンドトラッキングに変更

Nreal Lightは通常スマホを3DoFコントローラーとして利用しますが、SDK1.6.0からハンドトラッキングによる操作が可能となっています。

ここでは簡単にハンドトラッキングの使用方法を説明します。

 

Nreal Light のコントローラーを制御するNRInputコンポーネントの Input Source Type を Hnads に変更

f:id:Q1J011:20211205203652p:plain

NRSDKに含まれている HRHnad_R プレハブと HRHnad_L プレハブをHierarchyにドラッグ&ドロップしてシーンに追加していきます。

 

HRHnad_R プレハブをHierarchyウインドウの NRInput > Right ゲームオブジェクトの下にドラッグ&ドロップ

f:id:Q1J011:20211205203706p:plain

HRHnad_L プレハブをHierarchyウインドウの NRInput > Left ゲームオブジェクトの下にドラッグ&ドロップ

f:id:Q1J011:20211205203714p:plain

ビルドを行い作成されたapkファイルをAndroid端末にインストール、実行後

Nreal Lightのグラス正面についているカメラに見える位置に手をかざすことで、両手をを認識させることが出来ます。

f:id:Q1J011:20211205203721p:plain

Nreal Lightのハンドトラッキングは実験的な機能の為かオブジェクトの操作などについては自分で処理を用意する必要があります。

ただし掴み操作(Grab)だけは予め NR Grabable Object というコンポーネントが用意されていおり、これをコライダーを持つオブジェクトに設定することでオブジェクトを掴み・移動することが可能となっています。

f:id:Q1J011:20211205203729p:plain

その他にジェスチャー機能などがあります。

詳しくはSDK公式のハンドトラッキング項目を参照ください。

nrealsdkdoc.readthedocs.io

参考にさせて頂いたサイト様

本記事の元のスライド資料を作成するにあたり以下の記事を参考にさせて頂きました。

ありがとうございます。

www.slideshare.net

akihiro-document.azurewebsites.net