WebRTC+WebXR でVRリモートデスクトップ


実際にOculus Questで使用しているときのスクリーンショット：

![screenshot-xr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58515/088e623a-034f-3e63-a40f-20f57aee4dba.png)

# 何が嬉しいのか

- ブラウザ(Chrome)だけあれば動く(※１)
- 起動や接続が速い
- 大きなスクリーンをたくさん配置できる
- 画面の映像やマウス操作が中継サーバを通らないので安心


※１：ブラウザ上のJavaScriptからはマウスやキーボードを制御できないので，マウス操作のためには別プロセスが必要です(後述)

他のOculus Questで動作するリモートデスクトップアプリは起動時に待たされるものが多く，特にWebXRコンテンツをOculus Questをかぶったまま開発したいとき，動作確認のたびにアプリを切り替えるのは現実的ではありませんでした．ブラウザ上で動作すれば，キャッシュ済みであればほとんど待たされることなく起動しますし，タブの切り替えだけなら一瞬です．

# デモ

PCの画面をOculus Questで表示するだけなら，アプリのインストールもアカウント登録もサーバの用意も不要です．ブラウザで[デモページ](https://binzume.github.io/webrtc-rdp/)にアクセスすれば試せます．

リポジトリ： https://github.com/binzume/webrtc-rdp

# 基本構成

![rdp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58515/2e5c6589-53c9-a64d-8c34-7aa0d6b8f277.png)


### ホスト

- Screen Capture APIでデスクトップやウインドウをキャプチャ
- キャプチャした映像をWebRTCで送信
- WebRTC DataChannel で受信したイベントに従いマウスを操作(※)

※ブラウザからはマウスを制御できないので，[別プロセス](https://github.com/binzume/inputproxy)とWebSocketで通信．
別プロセスを起動しなくても良いように，[Electron](https://www.electronjs.org/)を使ってスタンドアローンで動作するアプリもつくリました．
Githubの[Releases](https://github.com/binzume/webrtc-rdp/releases/latest)からダウンロードできます。バイナリを用意しているのはWindowsだけですが、macOS等でも動作すると思います。

### クライアント

- WebRTCで映像を受信
- VideoTextureに映像を流して，WebXRシーン内にレンダリング
- マウスイベント等をWebRTC DataChannelで送信

デバッグ用に2DのUIも用意しましたが，共有されたファイルの表示などはWebXR版だけで実装されています．

# WebRTC

WebRTCを使ってP2Pでブラウザ間で通信します．P2Pで動作しますが接続のためにSignaling Serverを用意する必要があります．
今回はGoで書かれたWebRTC Signaling Serverの[OpenAyame/ayame](https://github.com/OpenAyame/ayame)を使いました．

デモの実装では[Ayame Labo](https://ayame-labo.shiguredo.jp/)に接続するので，サーバ等を用意しなくても試すことができます．本格利用する場合は後述するセキュリティ上の理由で自分の環境でAyameを動かすことをお勧めします．

# WebXR

VRモードは[A-Frame](https://aframe.io/)のコンポーネントとして実装しました．A-FrameはWebXRのシーンをDOMで表現できるので，HTMLを編集すれば簡単にウインドウの数や配置カスタマイズできます．

[単体](https://binzume.github.io/webrtc-rdp/webxr/)でも動きますが，[binzume/vr-workspace](https://binzume.github.io/vr-workspace/#app:app-webrtc-rdp)からアプリとして読み込む前提の作りになっています．

現状の残念な点として，高解像度のデスクトップをVRモードでレンダリングしたときに，スクリーンのエイリアシングひどいです．VideoTextureなのでフレームごとにミップマップを作るのは負荷が高いですし，異方性フィルタリングで改善しないか試しましたが，微妙な結果でした．
試した中では，ホスト側で映像の解像度を動的に変更するのが一番良さそうでした．映像のエンコード・デコード負荷や帯域も減るのも良いです．~~レンダリング面積に合わせて自動的に解像度を変更する予定ですが，まだ未実装です．~~ (レンダリング面積に応じてストリームの解像度を自動調整するようにしました)

# マウス/キーボード

ブラウザのJavaScriptからはマウスやキーボードの制御はできないので，元々はワイヤレスキーボード・マウス等の手段を使う予定でした．
しかし，この後に書いたウインドウ単位の共有をした場合に，頻繁にマウスカーソルが行方不明になって困ったので，マウス操作をブラウザから行う手段を用意しました．

ホスト側のPCで https://github.com/binzume/inputproxy を起動して表示されたURLを入力ボックスに入れるとマウスカーソルが動くようになります．(バイナリ配布はしてないので，ビルドするにはGoが必要です)

以下のような構成になってます．

```
クライアントブラウザ → (WebRTC DataChannel) → ホストブラウザ → (WebSocket) → inputproxy → マウス/キーボード
```

~~キー入力も中途半端に実装しましたが，Oculus QuestにBluetoothキーボードをつなぐよりPCのワイヤレスキーボードを使うほうが快適だったので，一旦対応をやめました．~~(対応しました)

# ウインドウ単位の共有

ChromeやFirefoxでは，Screen Capture APIでキャプチャするウインドウを選択できます．

ウインドウ単位で画面を共有できると，VR環境内にPCのデスクトップにあるウインドウをそのまま持ってこれるので楽しいです．実用性の観点からも，PCのモニタの解像度が高いとデスクトップ全体の共有は，Oculus Quest 2でも解像度が足りなくてしんどいのが，ウインドウ単位なら任意のサイズにできるので扱いやすいです．また，ウインドウが他のウインドウに重なっていてもキャプチャはできるので，共有先では元々のデスクトップより広く使えます．

共有されたウインドウをクリックした場合，ウインドウの座標を調べてマウス操作をする必要がありますが，Screen Capture APIで取得したMediaStreamにはウインドウの座標などは入っていません．

ただし，Chromeの場合はビデオトラックのラベルにウインドウハンドルが埋め込まれていた(！)ので，その値を使ってウインドウを特定して操作できます．

Firefoxの場合はウインドウのタイトルが入っているようなので，ウインドウを見つけるヒントにはなりますが常に一意に識別できるようにするのは無理そうに見えます．Chromeの場合も将来動作が変わるかもしれないので，使えなくなるかもしれません．

# セキュリティ

WebRTC P2Pなので映像などは中継サーバを経由しませんし，同じネットワーク内の端末同士であればインターネットにも出ていかないので安全です．マウス操作などもWebRTCのデータチャネル上で行うので同様です．

ただし，公開しているデモページでは接続時にSignaling Serverとして，インターネット上に公開されている[Ayame Labo](https://ayame-labo.shiguredo.jp/)を使っています．何らかの理由でAyameのRoomIdが漏洩すると他の人が接続できるかもしれません．RoomIdは`binzume@rdp-room-TkCUjlJdeILRhxWd`のような文字列をランダムに生成してPINの確認時に共有しています．Roomには二人しか入れない仕様なので，接続完了後は比較的安全ですが，接続を待機した状態で長時間放置しないほうが良いでしょう．

できれば，Ayameを自身の管理する環境で起動するか，Ayame Laboを使い続ける場合も，signalingKeyを自分で取得したものに置き換えるのが良いと思います．

追記：ファイルを共有する機能を付けるときに、少し気になったので事前に共有したトークンとDTLSのfingerprintを使って認証する仕組みを入れました。これによりRoomIdを知っているだけではファイルアクセスできないようになっています。(ファイル送受信用のdata channelのみで画面のストリームの再生には入れていないです)

# その他色々

面倒なことは，ほとんどGoogle Chromeと時雨堂のAyameがやってくれるので，複雑なことは何もしていません．

元々，VNCをWebSocketでproxyしてnoVNCでCanvasにレンダリングしたものをWebGLのVideo Textureとして扱う実装をして使っていたのですが，WebRTCにしたらとてもシンプルになりました．

作り始める前に似たようなものを探した感じでは，https://virtc.app/ が同じ動機で作られているようです．こちらはシンプルさが追求されていて簡単に使えるのが良さそうでした．今回作ったものもPINだけで接続できるようにするあたりはViRTCを真似ています．

ホスト側のChromeでページを開いておくのも面倒なので，Chromeをheadlessモードで起動しておこうかと思ったのですが，headlessモード時にgetDisplayMedia()時のMediaStreamの選択の方法が分からず……．→ Electron を使ってタスクトレイに常駐するスタンドアローンなアプリも作りました．

この記事も作ったリモートデスクトップ経由で書いています．

2022-05-05追記：

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Oculus Quest用のリモートデスクトップ環境，デスクトップからドラッグしてウインドウを取り出せるようにしたら少し快適になった <a href="https://t.co/4KFTiRbVDR">https://t.co/4KFTiRbVDR</a> <a href="https://t.co/zWjjaIM0Ai">pic.twitter.com/zWjjaIM0Ai</a></p>&mdash; Kousuke Kawahira (@binzume) <a href="https://twitter.com/binzume/status/1522136657784610816?ref_src=twsrc%5Etfw">May 5, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>