夜風のMixedReality

xRと出会って変わった人生と出会った技術を書き残すためのGeekなHoloRangerの居場所

HoloLensのリモートデバッグアプリを作る HoloLens アドベントカレンダー1日目

本日はHoloLens枠です。

またHololensアドベントカレンダー20231日目の記事になります。

〇HoloLensアドベントカレンダー2023とは?

HoloLensアドベントカレンダーとは毎年12月に私の師である、がちもとさんを中心に開催されているアドベントカレンダー企画になります。

qiita.com

〇HoloLensのリモートデバッグアプリを作る

23年もいろいろなアプリの開発や実験を行いましたが、最も強く思ったことがHoloLensもといXRアプリケーションのデバッグが大変ということでした。

 特にUnityEditor上と挙動が違う場合やバグの発生個所など、正直開発時間の大半をデバッグでとられていたといっても過言ではないと思います。

 その理由として初めて実装する技術やパッケージも多く、筆者自身の経験と知識の不足もあるのですが、いずれにせよログの出力が学習する上にもデバッグするうえでも大切だと気付かされた1年でした。

 またiOSアプリの開発やBlenderModeling支援ツールとして現在開発しているMixedRealityModelingToolsにより通信周りの知識も若干ではありますが身に着けたため今回はその知識をいかしてHoloLensアプリのデバッグアプリを作っていきます。

〇イメージ

今回はスマートフォンアプリ側にサーバーを立て、デザリングなどでHoloLensとローカルネットに接続しているという環境でHoloLnesからスマートフォンに対してDebug.Logの送信をはじめ最終的にスマートフォンからシーンの切り替えを行うリモコンアプリを想定しています。

〇環境

・Unity2022.3.5f1 URP

・Windows11PC

Microsoft HoloLens 2

iPhone(12ProMax)

Mac mini(M2)

・MessagePack

〇サーバーの実装

今回はMessagePackを使用して通信を行います。

MessagePackはデータを送受信する際のシリアライズ(データーの変換)を行います。

他にもパッケージはありますがMessagePackを使用することでJsonに感覚が近いデータとしてキー、バリューでデータをシリアライズしてバイトに変換、ソケット通信を行います。

〇サーバー側

基本的に筆者自身のツールであるMixedRealityModerlingToolsの通信部分を流用しています。(本来だったらもう少しシンプルにできそう)

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;

public class Server : MonoBehaviour
{
    private TcpListener _tcpListener;
    private Thread _tcpListenerThread;
    private TcpClient _connectedTcpClient;

    [Tooltip("Port number")] public int _port = 9991; // Default is 9991

    private void Start()
    {
        _tcpListenerThread = new Thread(new ThreadStart(ListenForIncomingRequests));
        _tcpListenerThread.IsBackground = true;
        _tcpListenerThread.Start();
    }

    private void ListenForIncomingRequests()
    {
        try
        {
            _tcpListener = new TcpListener(IPAddress.Any, _port);
            _tcpListener.Start();

            Debug.Log($"Server is listening on port {_port}");

            while (true)
            {
                // blocks until a client has connected to the server
                _connectedTcpClient = _tcpListener.AcceptTcpClient();

                // create a thread to handle communication with the connected client
                Thread clientThread = new Thread(new ParameterizedThreadStart(HandleClientComm));
                clientThread.Start(_connectedTcpClient);
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"Error: {e.Message}");
        }
    }

    private void HandleClientComm(object clientObj)
    {
        TcpClient tcpClient = (TcpClient)clientObj;
        NetworkStream clientStream = tcpClient.GetStream();

        byte[] message = new byte[4096];
        int bytesRead;

        while (true)
        {
            bytesRead = 0;

            try
            {
                bytesRead = clientStream.Read(message, 0, 4096);
            }
            catch (Exception e)
            {
                Debug.LogError($"Error reading from client: {e.Message}");
                break;
            }

            if (bytesRead == 0)
                break;

            // Process received data based on header
            ProcessReceivedData(message, bytesRead);
        }

        tcpClient.Close();
    }

    private void ProcessReceivedData(byte[] data, int length)
    {
        string header = Encoding.ASCII.GetString(data, 0, 3); // 0から3バイトを取得
        Debug.Log($"Received header: {header}");

        // Only the part related to processing received data has been kept
        if (header == "LOG")
        {
            // Extract the log message from the received data
            string logMessage = Encoding.ASCII.GetString(data, 3, length - 3); //  3から(length - 3)バイトを取得
            Debug.Log($"Received log message: {logMessage}");
        }
        else
        {
            Debug.Log($"Received unknown data with header: {header}");
        }
    }

    private void OnDestroy()
    {
        if (_tcpListener != null)
        {
            _tcpListener.Stop();
        }
    }
}

〇クライアント

using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using JetBrains.Annotations;
using UnityEngine;


public class Client : MonoBehaviour
{
    [CanBeNull] private TcpClient _client;
    private NetworkStream _stream;

    [Tooltip("Port number")] public int _port = 9991; // Default is 9991
    [Tooltip("Address of the server")] public string _ipAddress = "localhost";


    public void StartConnection()
    {
        _client = new TcpClient(_ipAddress, _port);
        _stream = _client.GetStream();

        // Start a new thread to listen for incoming messages
        new Thread(() =>
        {
            var responseBytes = new byte[536870912];
            while (true)
            {
                var bytesRead = _stream.Read(responseBytes, 0, responseBytes.Length);
                if (bytesRead == 0) break;

                // Process received data based on header
                ProcessReceivedData(responseBytes, bytesRead);
            }
        }).Start();
    }

    private void ProcessReceivedData(byte[] data, int length)
    {
        string header = Encoding.ASCII.GetString(data, 9, 4);
        Debug.Log(header);

        // Only the part related to processing received data has been kept
        if (header == "LOG")
        {
            // Extract the log message from the received data
            string logMessage = Encoding.ASCII.GetString(data, 13, length - 13);
            Debug.Log($"Received log message: {logMessage}");
        }
        else
        {
            Debug.Log($"Received unknown data with header: {header}");
        }
    }

    void Start()
    {
        StartConnection();
    }

    void Update()
    {
        // Update logic, if needed
    }

    public void RequestDebugLog(string logMessage)
    {
        byte[] logBytes = Encoding.ASCII.GetBytes(logMessage);
        byte[] dataToSend = new byte[logBytes.Length + 13];
        Encoding.ASCII.GetBytes("LOG").CopyTo(dataToSend, 0);
        logBytes.CopyTo(dataToSend, 3); // 修正: 3からコピーするように変更
        _stream.Write(dataToSend, 0, dataToSend.Length);
    }

    private void OnDestroy()
    {
        _stream.Close();
        _client.Close();
    }
}

このコードではサーバー側を起動しているデバイスにCliant側のデバイスがコネクトするようになっています。

〇ログの送信

では次にログの送信についてです。

クライアント側のRequestDebugLog(string logMessage)を呼ぶことでデータを送信できます。

次にログの取得ですがApplication.logMessageReceivedを使用します。

これはLogが発光された際にコールバックを取得することができます。

注意点としてメインスレッドの処理のみが取得されます。

コルーチンやサブスレッドで発行されたログは取得されない点に注意です。

using UnityEngine;

public class LogMessageListener : MonoBehaviour
{
    public Client client; // クライアントスクリプトがアタッチされたGameObjectにアタッチして、InspectorでClientを設定してください。

    private void OnEnable()
    {
        Application.logMessageReceived += HandleLog;
    }

    private void OnDisable()
    {
        Application.logMessageReceived -= HandleLog;
    }

    private void HandleLog(string logString, string stackTrace, LogType type)
    {
        if (type == LogType.Log)
        {
            client.RequestDebugLog(logString);
        }
    }
}

以上で実装が完了しました。

〇ネットワークの接続とUnity上の動作

スクリプトの動作を2つのデバイスを使用して検証します。

今回はWindows11PCとMacを使用しています。

2台は同一のネットワークもしくはVPNなどに接続し、セキュアな通信ができることを確認しておきます。

Mac側のUnityプロジェクトで適当なオブジェクトにServer.csをアタッチします。

Windows側のUnityプロジェクトで適当なオブジェクトにCliant.csをアタッチします。

③IP Addressをサーバー側のデバイスのものに設定します。 今回はiPhoneのIPを指定しています。

Log MessageListenerをアタッチしClientにClient.csをアタッチします。

またLogを出力するためのサンプルスクリプトも配置します。

これはDebug.Log()の関数が任意のタイミングに発行されていればOKです。

ただし、前述の通りメインスレッドでの処理のみが監視されているためコルーチンやサブスレッドでのログが発光されている場合は監視対象外になります。

⑤実機にビルドします。

〇HoloLensとiPhoneでの動作

HoloLensとiPhoneを同一のネットワークに接続します。

ルーターの設定によってはポート開放など設定を変更する必要があるため今回はiPhoneのデザリング機能を使用してiPhoneをサーバーとすることでローカルネットにつないでいます。

iPhone側ではログをテキストに変換し表示させています。

今回はテストとしてボタンを押した際にタイムスタンプが出力されるようにしました。

www.youtube.com

以上でHoloLensのデバッグログをiPhoneで確認できました。

明日のアドベントカレンダーも筆者が担当します。