メインコンテンツまでスキップ

Java でリモートデスクトップを実装する

· 約9分

ふと思いついたのですが、Java でリモートデスクトップ共有の機能を実装することはできるのでしょうか?

リモートデスクトップの類のツールは普段からよく使います。たとえば Sunlogin や TeamViewer などですが、その裏側が実際どう動いているのかを考えたことのある人は少ないでしょう。分解してみると、実はやることは 3 つだけです。画面の映像をキャプチャする、データを小さく圧縮する、ネットワーク越しに相手側へ送って復元する。この 3 ステップにはそれぞれ効率の問題があり、Java は最初のステップの時点で先天的に不利です。この記事では、私が Java でリモートデスクトップをいじってみて、この 3 つの段階でそれぞれどんな落とし穴を踏み、最終的にどんな方式を選んだかを記録します。

スクリーンキャプチャの効率が最初の関門

もちろん実装は可能です。どれもコードを走らせるだけの話ですから。ただ Java は効率が悪いのです。なぜかというと、Java のコード実行効率が低いからではなく、Java 標準のスクリーンキャプチャツールの効率が低すぎるからです。Java はデスクトップのキャプチャを実現する Robot クラスを提供していますが、2K 画面の JPG 1 枚のサイズは 200KB 程度で、私の RX 2700X CPU で 1 枚のキャプチャになんと 30 ミリ秒もかかりました。毎秒 30 フレームにはまだ距離があり、毎秒 60 フレームは言うまでもありません。仮に毎秒 60 フレーム出せたとしても、Java でどうやって各リモート端末へ効率よく転送するのでしょうか?

仕組みの面での原因を補足しておきます。java.awt.RobotcreateScreenCapture は AWT のネイティブ呼び出しを経由し、下層では画面全体のピクセルを VRAM からメモリへコピーし、それを BufferedImage にラップします。この過程は同期的なフルコピーであり、解像度が高いほどコピーするデータ量は大きくなります。2K 画面の 1 フレームの生の RGB データは十数 MB にもなり、このメモリ搬送 1 回だけで速くなりようがないことが決まってしまいます。そもそもの設計意図は GUI 自動テストでたまに 1 枚キャプチャするためのものであり、継続的な高頻度キャプチャのために用意されたものではないのです。

マルチスレッドでキャプチャしてフレームレートを上げればいい、と言う人もいるでしょう。しかしそのやり方は、個人的にはあまり合理的でないと思います。なぜなら CPU リソースの消費が大きすぎるからです。効率が高く、かつ CPU リソースの消費が少ない方法を探すべきです。

マルチスレッドにはもう一つ隠れた問題があります。キャプチャのボトルネックはシステムコールとメモリコピーにあり、複数のスレッドが同時にこの経路を奪い合っても、スループットが線形に伸びるとは限らない一方で、CPU 使用率は確実に倍増します。リモートデスクトップは常駐型のバックグラウンドツールであり、ホストマシンの CPU を食い潰すこと自体が受け入れられません。

画像の直接転送は成り立たない

さらに、画像の転送という方式は取れません。1 枚の画像が大きすぎるからです。200KB として、1 秒に 30 枚なら 6000KB となり、帯域幅を深刻に圧迫します。

計算してみればこの道が通らないことが分かります。毎秒 6000KB は帯域幅に換算すると 50Mbps 近くになり、家庭用の上り回線ではまったく耐えられません。ましてや 1 台のホストから複数のリモート端末へ同時に配信するなど論外です。しかも JPEG はフレームごとに独立して圧縮されるため、前後のフレームで 99% のピクセルが変わっていなくても、画像全体を丸ごとエンコードして送り直すことになり、大量の重複情報に無駄が費やされます。

ビデオストリームが本道

最良の方式はビデオストリームを使うことです。オープンソースの h.264 ビデオフォーマットを使います。さらに優れた h.265 もありますが、対応するオープンソースの jar は見つけられませんでした。h.265 の技術はまだ有償の段階にあります。h.264 ビデオフォーマットは、各フレームにおけるピクセルの変化に基づいてデータを記録します。画像全体の大部分のピクセル情報を記録する必要はありません。そのため h.264 フォーマットの動画の容量は、画像転送のデータ総量よりはるかに小さく、ネットワーク転送により適しています。

これこそがビデオエンコーディングにおけるフレーム間圧縮の考え方です。エンコーダーは一定間隔で完全なキーフレームを送り、その間のフレームは前のフレームに対する変化量だけを記録します。デスクトップの画面はまさにこの圧縮の理想的なシーンです——大半の時間、動いているのはマウスと一部のウィンドウだけで、背景は微動だにせず、フレーム間の冗長性が極めて高いため、圧縮率は自然と高くなります。

デスクトップを録画する方式でクライアントへ効率よく配信すれば、画像がもたらす帯域幅の問題とキャプチャの効率問題を大幅に軽減できます。ただしビデオストリームを使う場合、リアルタイムのトランスコードとトラフィックはどちらも小さくない課題です。

リアルタイムトランスコードの難しさは、遅延と計算力のバランスにあります。圧縮率を上げればエンコード時間が延びます。リモート操作で最も嫌なのは、画面が手の動きより半テンポ遅れることです。圧縮率を下げれば今度は帯域幅が持ちません。成熟したリモートデスクトップソフトは一般にハードウェアエンコーディングでこの矛盾を解いており、純粋な Java でソフトウェアレベルだけで追いつくのは、なかなか難易度が高いでしょう。

私が最終的に採った折衷案

最終的に私が採用したのはランレングス符号化で、Robot のキャプチャによる RGB ビットマップのうち変化のあったデータだけを転送する方式です。しかしランレングス符号化は、画面のピクセル変化が大きすぎる状況に遭遇すると、データ量の体積が急激に膨れ上がります。もし本当に画像でデスクトップ情報を転送するなら、Java が画面のピクセルデータを取得する効率と、転送データの体積圧縮の最小化を突破しなければ、リモートデスクトップのフレームレートを最大化することはできない、というのが私の考えです。

この方式をもう少し詳しく説明します。やり方は、現在のフレームと前のフレームの RGB 配列をピクセル単位で比較し、変化したピクセルだけを位置情報とともにエンコードして送り出し、相手側は受け取った差分データをローカルにキャッシュした画面に上書きするというものです。ランレングス符号化(RLE)自体は非常にシンプルで、連続する同じ値を「値 + 繰り返し回数」として記録します。静止画面では差分がほぼゼロになり、効果は上々です。しかしその弱点も明白で、ひとたび全画面スクロールや動画再生が起きると、ほぼすべてのピクセルが変化し、差分が全量に退化します。さらに符号化自体のオーバーヘッドが上乗せされ、データ量はかえって元画像をそのまま送るより大きくなることさえあります。

ハマりどころと注意点

1)Robot キャプチャの所要時間は解像度に直結します。テスト時に低解像度で見るとフレームレートはまずまずに見えても、2K 画面に切り替えた途端に馬脚を現します。方式を評価するときはターゲット解像度で測るべきです。

2)差分転送では最初のフレームとパケットロスをきちんと処理しなければなりません。相手側に基準となる画面がなければ差分は何の意味も持たないため、まず一度全量フレームを送る必要があります。転送の途中で差分フレームを 1 つ失うと、それ以降の画面はずっとずれ続けます。信頼性のある転送を使うか、定期的に全量フレームを強制送信して保険をかけるかのどちらかです。

3)相手側の復元コストも軽視できません。差分をビットマップにマージして画面に描画する処理も同様に CPU を消費します。受信側が非力だと、やはりカクつきます。

まとめ

今回の試みを通して、結論はかなり明確になりました。Java でリモートデスクトップを作る場合、ボトルネックは言語そのものではなく、Robot というスクリーンキャプチャ経路の効率と、純ソフトウェア圧縮のオーバーヘッドにあります。画像の直接転送は帯域幅が爆発し、ランレングス符号化の差分転送は静的な画面には向いていても激しい変化には耐えられず、ビデオストリームこそがエンジニアリング上の正解です。ただ純 Java のエコシステムには使い勝手のよい h.264 のオープンソース実装が欠けています。原理検証のおもちゃを作るだけなら差分転送で十分ですが、本当にプロダクトを作るなら、やはりネイティブのエンコードライブラリやハードウェアエンコーディングの力を借りる必要があるでしょう。

COMMENTS