OculusQuestで動くリモートデスクトップアプリを作る

はじめに

OculusQuestでWebRTCを使えるらしいという情報をキャッチしたので、実際に使えるかどうかOculusQuestで動作するリモートデスクトップアプリを作って確かめてみましょう!

成果物のソースコードは ここ に公開しています。

近いうちにリファクタリングしたいので文中のコードが古くなりそうですが、あしからず

WebRTCとは

WebRTCはクライアント端末同士で動画、音声、バイナリなどの様々なデータをP2Pで送受信できるようにするブラウザ発の技術です。ブラウザ発の技術ではあるものの、様々なネイティブ環境向けの実装が開発されています。

今回は、Unity向けのWebRTCプラグインである、webrtc_unity_plugin を使って開発を進めていきます。 WebRTCについて詳しく知りたい方はこの記事が参考になると思います。本記事ではあまり詳しく説明しません。

WebRTCプラグインのビルド

Unity用のWebRTCプラグインはアセットストア上でも販売されていますが、それらはおそらくwebrtc_unity_pluginがベースとなっています(買ってないのでわかりません!)。自分の場合はお金をかけたくなかったので、自分でソースコードからビルドしました。ビルド方法はこのブログが詳しいです。このブログの筆者がwebrtc_unity_pluginのビルドした成果物も配布しているのでそれを使うのもありでしょう(私のリポジトリ内にあるやつを持っていてもらっても構いません)。 ちなみに今回はデバッグをしやすくするために、android用とwindows用のwebrtc_unity_pluginを併用しています。windows用のdllはソースコードのリポジトリに同梱しています。

構成

今回作成したリモートデスクトップシステムの構成を示します。

  • 配信側(Windows/Linux/Mac) : Electron + WebSocket + WebRTC
  • クライアント側(OculusQuest,OculusRift) : Unity + WebSocket + WebRTC

本システムでは、スクリーンキャプチャをする配信側と、映像を受信し、マウス操作やキーボード操作をするクライアント側に別れます。

WebRTCではP2P接続において発生するうまく接続できない問題を回避するためのNAT越えの手法をの一種であるICEを利用しています。ICEにおいてP2P接続を開始するための作業をシグナリングと呼びます。シグナリングではWebRTCプラグインから渡されるSDPという文字列を何らかの方法で相手のWebRTCプラグインに渡すことでP2P接続が完了します。今回はWebSocketを用いてSDP文字列の交換を行いシグナリングします。

シグナリング

シグナリングの様子を見るために実際にソースコードを追っていきましょう。

まずは、プラグインの初期化を行います。

クライアント(C#)

#if UNITY_EDITOR
#elif UNITY_ANDROID
        AndroidJavaClass playerClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
        AndroidJavaObject activity = playerClass.GetStatic<AndroidJavaObject>("currentActivity");
        AndroidJavaClass utilityClass = new AndroidJavaClass("org.webrtc.UnityUtility");
        utilityClass.CallStatic("InitializePeerConncectionFactory", new object[1] { activity });
#endif

WebRTCに触る前に、プラグインの初期化処理が必要です。EditorやWindowsには不要でAndroid向けのの作業です。

 void InitPeer()
        {
            List<string> servers = new List<string>();
            servers.Add("stun: stun.l.google.com:19302");
            peer = new PeerConnectionM(servers, "", "");
            peer.OnLocalSdpReadytoSend += OnLocalSdpReadytoSend;
            peer.OnIceCandiateReadytoSend += OnIceCandidate;
            peer.OnLocalDataChannelReady += Connected;
            peer.OnDataFromDataChannelReady += Received;
            peer.OnRemoteVideoFrameReady += OnI420RemoteFrameReady;
            peer.AddDataChannel();
        }

ここでは、stunサーバの設定やプラグインのデリゲートへのコールバック登録、それと後で説明するDataChannelの初期化を行っています。コールバックの中身を見ていきましょう。

シグナリングを行う際に、いずれかのノードがOffer、もう一方のノードがAnswerという役割を担います。今回は、配信側がOffer、クライアント側がAnswerの役割をすることとします。

SDPの発行は必ず、Offerが先なので配信側がOfferSDPを発行する様子を見てみましょう。 配信側の実装は Electron + TypeScript で行っています。

配信側(TypeScript)

import WebRTC from "webrtc4me";
import { observer, action } from "../../server/signaling";

export const create = (stream: MediaStream) =>
  new Promise<WebRTC>(resolve => {
    const rtc = new WebRTC({ trickle: true, stream });

    observer.subscribe(({ type, payload }) => {
      switch (type) {
        case "join":
          rtc.makeOffer();
          break;
        case "sdp":
          rtc.setSdp(payload);
          break;
      }
    });

    rtc.onSignal.subscribe(({ type, sdp, ice }) => {
      if (sdp) {
        const payload = type + "%" + sdp;
        action.execute({ type: "offer", payload });
      } else if (ice) {
        const { candidate, sdpMLineIndex, sdpMid } = ice;
        const payload =
          type + "%" + candidate + "%" + sdpMLineIndex + "%" + sdpMid;
        action.execute({ type: "ice", payload });
      }
    });

    rtc.onConnect.once(() => resolve(rtc));
  });

ブラウザのWebRTC APIを何度も何度も生のまま触るのは面倒くさいので、WebRTC APIをラップしたユーティリティライブラリが世の中には存在します。有名なのは simple-peer ですが、いまいち使いづらいので、自作したユーティリティライブラリ webrtc4me をここでは使っています。 6行目辺りのconst rtc = new WebRTC({ trickle: true, stream })ではWebRTCのpeerの初期化を行っていますstreamというのがデスクトップの映像のことです。これでシグナリングが完了したら、クライアント側にstreamの映像が流れます。

次に

observer.subscribe(({ type, payload }) => {
      switch (type) {
        case "join":
          rtc.makeOffer();
          break;
        case "sdp":
          rtc.setSdp(payload);
          break;
      }
    });

では、websocketから流れて来るデータを扱っています。クライアント側がこんな感じ{type:"join"}のJSONをwebsocketに送ってきたらrtc.makeOffer()が実行され、OfferSDPの生成が始まります。生成されたSDPは次の

rtc.onSignal.subscribe(({ type, sdp, ice }) => {
      if (sdp) {
        const payload = type + "%" + sdp;
        action.execute({ type: "offer", payload });
      } else if (ice) {
        const { candidate, sdpMLineIndex, sdpMid } = ice;
        const payload =
          type + "%" + candidate + "%" + sdpMLineIndex + "%" + sdpMid;
        action.execute({ type: "ice", payload });
      }
    });

rtc.onSignal.subscribeで受け取れます。SDPを加工してaction.execute({ type: "offer", payload: data })を実行してwebsocket経由でクライアント側に送り込みます。

クライアント側へと戻ります。

クライアント側 (C#)

public void SetSdp(string s)
        {
            var arr = s.Split('%');

            switch (arr[0])
            {
                case "offer":
                    peer.SetRemoteDescription(arr[0], arr[1]);
                    peer.CreateAnswer();
                    break;
                case "answer":
                    peer.SetRemoteDescription(arr[0], arr[1]);
                    break;
                case "ice":
                    peer.AddIceCandidate(arr[1], int.Parse(arr[2]), arr[3]);
                    break;
            }
        }

websocket経由で配信側から受け取ったSDPをpeer.SetRemoteDescriptionでプラグインに渡し、peer.CreateAnswer()でAnswerSDPを生成します。生成されたSDPは先程、デリゲートに登録していたコールバックを実行します

peer.OnLocalSdpReadytoSend += OnLocalSdpReadytoSend;
  void OnLocalSdpReadytoSend(int id, string type, string sdp)
        {
            var data = new SendSdpJson
            {
                type = "sdp",
                payload = new Sdp { type = type, sdp = sdp },
            };
            var json = JsonSerializer.ToJsonString(data);
            Debug.Log("OnLocalSdpReadytoSend " + json);
            OnSdpMethod(json);
        }

ここで加工したSDPをWebSocket経由で配信側に送ります。

配信側にもどります。

配信側 (TypeScript)

observer.subscribe(({ type, payload }) => {
      switch (type) {
        case "join":
          rtc.makeOffer();
          break;
        case "sdp":
          rtc.setSdp(payload);
          break;
      }
    });

クライアント側から{type:"sdp",payload:sdp}こんな感じのJSONが届くのでこれをrtc.setSdp(payload)でWebRTCライブラリにSDPを渡します。するとまた

rtc.onSignal.subscribe(({ type, sdp, ice }) => {
      if (sdp) {
        const payload = type + "%" + sdp;
        action.execute({ type: "offer", payload });
      } else if (ice) {
        const { candidate, sdpMLineIndex, sdpMid } = ice;
        const payload =
          type + "%" + candidate + "%" + sdpMLineIndex + "%" + sdpMid;
        action.execute({ type: "ice", payload });
      }
    });

rtc.onSignal.subscribe が発火するのでこれまでの作業が繰り返されます。何サイクルか繰り返すと接続が完了します。接続が完了すると、接続完了のコールバックが走ります。

  • 配信側
rtc.onConnect.once(() => resolve(rtc));
  • クライアント側
 void InitPeer()
        {
            ~~~
            peer.OnLocalDataChannelReady += Connected;
            ~~~
        }

 public void Connected(int id)
        {
            var data = new RoomJson();
            data.type = "connect";
            data.roomId = roomId;
            var json = JsonUtility.ToJson(data);
            OnConnectMethod(json);
        }

これでシグナリングは完了しました。長かったですね。説明はかなり端折ってるので、詳しい部分はソースコードを見ていただけたらと思います。 シグナリングが終わったらようやく次はリアルタイム映像を流します。

##映像 配信側でPeerを作るときに映像(stream)を予めnew WebRTC({ trickle: true, stream })こんな感じで設定していたので、接続が完了したら、配信側はクライアント側へ映像と音を送り始めます。 映像はYUV420形式でフレーム情報を受け取れるそうですが、このへんは全く詳しくないので、ほとんどこのブログの内容をなぞっただけです。バッファリングしてる部分が遅延に影響していたので、そこだけ消して使いました。

    void Start()
    {
        tex = new Texture2D(2, 2);
        tex.SetPixel(0, 0, Color.blue);
        tex.SetPixel(1, 1, Color.blue);
        tex.Apply();
        GetComponent<Renderer>().material.mainTexture = tex;
        connect.OnRemoteVideo += OnI420RemoteFrameReady;
    }

    public void OnI420RemoteFrameReady(int id,
     IntPtr dataY, IntPtr dataU, IntPtr dataV, IntPtr dataA,
     int strideY, int strideU, int strideV, int strideA,
     uint width, uint height)
    {
        FramePacket packet = frameQueue.GetDataBufferWithoutContents((int)(width * height * 4));
        if (packet == null)
        {
            Debug.LogError("OnI420RemoteFrameReady: FramePacket is null!");
            return;
        }
        CopyYuvToBuffer(dataY, dataU, dataV, strideY, strideU, strideV, width, height, packet.Buffer);
        packet.width = (int)width;
        packet.height = (int)height;
        framePacket = packet;
    }

    void Update()
    {
        ProcessFrameBuffer(framePacket);
    }

    private void ProcessFrameBuffer(FramePacket packet)
    {
        if (packet == null)
        {
            return;
        }

        if (tex == null || (tex.width != packet.width || tex.height != packet.height))
        {
            Debug.Log("Create Texture. width:" + packet.width + " height:" + packet.height);
            tex = new Texture2D(packet.width, packet.height, TextureFormat.RGBA32, false);
        }

        tex.LoadRawTextureData(packet.Buffer);

        tex.Apply();
        GetComponent<Renderer>().material.mainTexture = tex;
    }

OnI420RemoteFrameReadyをデリゲートに登録して、OnI420RemoteFrameReadyでフレーム情報を加工して、ProcessFrameBufferでテクスチャにしています。

マウス、キーボード操作

最初の方で言及したDataChannelはここで使います。DataChannelはWebRTCを介して任意のデータをやり取りするWebRTCの機能のことです。本システムでは、VR内のレーザポインタ、バーチャルキーボードの値をDataChannelを介して配信側(PC)へと送信します。

  • クライアント側

マウス

 public void SendMove(float x, float y)
    {
        var data = new MouseMove
        {
            type = "move",
            payload = new MouseMovePayload
            {
                x = x,
                y = y
            }
        };
        var json = JsonSerializer.ToJsonString(data);
        connect.Send(json);
    }

キーボード

class Input
    {
        public string type;
        public string payload;
    }

    void OnInput(string s)
    {
        var data = new Input
        {
            type = "key",
            payload = s
        };
        var json = JsonSerializer.ToJsonString(data);
        connect.Send(json);
    }

  • 配信側
import robotjs from "robotjs";
const load = (window as any).require;
const robot: typeof robotjs = load("robotjs");

export default function remote() {
  const screenSize = robot.getScreenSize();
  const height = screenSize.height;
  const width = screenSize.width;

  moveMouse.subscribe(p => {
    robot.moveMouse(width * p.x, height * p.y);
  });

  clickMouse.subscribe(() => {
    robot.mouseClick("left");
  });

  keyTap.subscribe(s => {
    robot.keyTap(s);
  });
}

マウスとキーボードの操作はrobotjsというNode.js用のライブラリを用いて実現しています。

まとめ

OculusQuestでWebRTCを使えるらしいという情報をキャッチしたので、実際に使えるかどうか、リモートデスクトップアプリを実装して確かめてみました。結果としては十分使えそうな気がしました。

あとがき

花譜、”ファーストライブ不可解” すごかったですね!今度VRライブに出るらしいので楽しみです!!