はじめに
本エントリは Unity Advent Calendar 2016 4 日目の記事になります。先日は srndpty さんによる「uGUIのScrollViewを使いこなす7つのTips - Programming Serendipity」でした。スクロール系は私も昨年の Advent Calendar で書いた uREPL を作成した時に頭を悩ませたところなので、機会があったらまとめてみたいです。
さて、本編に入りたいと思います。今年のはじめに、以下のエントリを投稿しました。
ここでは Windows 8.1 以降に対応している Desktop Duplication API(DDA)を利用して Unity 上に非常に低負荷で Windows のデスクトップ画面を描画するというネイティブプラグインの実装の原理実験を行いました。1 ヶ月ほど前、@GOROman さんが作成されている Mikulus に本機能を搭載いただいたのですが、あくまで原理実験のものだったため、エラー処理もしておらず不安定かつ機能も少ないものでした。そこで、そのタイミングでもっと安定かつマルチモニタ対応やカーソル描画、その他機能に対応するためにプラグイン開発を再開し、uDesktopDuplication(uDD)という名前でリリースすることにしました。
そこからおおよそ 1 ヶ月かかりましたが、ようやく VR 向けでも十分なパフォーマンスが出る状態で安定し、一通り機能も揃いましたので、この場を借りて機能解説と苦労した点などをまとめたいと思います。以下の解説は 2016/12/03 現在最新のバージョンの v1.4.1 を元に解説を行います。将来的に変更される可能性があることにご留意ください。
デモ
Unity 上で軽量(4K 2枚とかでも OK)でデスクトップテクスチャを表示します。
ダウンロード
GitHub で MIT ライセンスで公開しています。リリースページから最新のバージョンの .unitypackage
をダウンロードし、インポートしてください。
「uDesktopDuplication > Examples > Scenes」に入っている各シーンにいくつかサンプルが格納されています。
ディスプレイ表示と Texture コンポーネント
ディスプレイ表示
まずは、最も単純なサンプルである、プライマリの画面を一枚表示するシーンの Primary Monitor
を開いてみてください。
実行して上図のように画面が表示されれば正常に動作しています。モニタを表示している GameObject を見ると uDesktopDuplication.Texture
コンポーネントがついています。
このコンポーネントでは、表示しているディスプレイの設定を行うことが出来ます。
- Monitor
- Monitor
- 認識されているモニタをプルダウンで選択可能
- ID
- モニタの ID
- Is Primary
- Windows の設定からモニタがメインディスプレイに設定されているか
- Rotation
- モニタの向き
- Resolution
- 解像度
- DPI
- DPI(解像度と DPI からモニタの物理サイズが計算可能、取得できない場合は 100)
- Monitor
- Invert
- Invert X
- UV 反転(水平方向)
- Invert Y
- UV 反転(垂直方向)
- Invert X
- Clip
- Use Clip
- デスクトップの一部領域を拡大するかどうか(後述)
- Clip Pos
- クリップする位置
- Clip Scale
- クリップする範囲
- Use Clip
- Material
基本的にはこのコンポーネントをアタッチすると、マテリアルのメインのテクスチャがデスクトップ画面に置き換えられます。ただ、デスクトップ画面の描画には UV 反転やモニタの回転などを考慮する必要があるため、専用のシェーダを利用しています。本シーンでは uDD_Unlit.shader
を使用したシェーダを利用していますが、4 つのシェーダが用意されています。
- uDD_Unlit
- ライティングをしない不透明なシェーダ
- uDD_Unlit_Transparent
- ライティングをしない半透明なシェーダ
- uDD_Unlit_BlackMask
- ライティングをしない黒が透けるシェーダ(
Mask
値を変更することで透け具合を調整可能)
- ライティングをしない黒が透けるシェーダ(
- uDD_Unlit_Standard
- スタンダードサーフェスシェーダ
これらは Shaders
シーンから確認できます。
1 点、注意があり、アプリが実行されていない時は Texture
コンポーネントから Material
の値を変更すると、sharedMaterial
の値が共通して変更されます(Invert
と Clip
は個別にシリアライズされます)。一方実行時は、開始時にマテリアルのクローンを生成しこちらを変更するため、個別に変更できます。
曲げ・厚み変更
Thickness
を変更すると厚みのあるメッシュの場合は厚みを変更することができます。また、Use Bend
をチェックした状態で Bend Radius
を変更するとカーブスクリーンの曲率を変更することが出来ます。
これら変形用に、uDD_Board
というメッシュが用意されているので、基本的にはこちらを使うのをおすすめします(Mesh Forward Direction
が Z
)。通常の Plane
でも Mesh Forward Direction
を Y
にすれば可能ですが、分割数が足りず、曲げ利用時にカクカクしてしまうので注意が必要です。
頂点シェーダによる変形なので、コライダは追従しません。
複数のモニタ表示
Multiple Monitors
シーンと Moltiple Monitors Roundly
シーンにて複数のモニタのサンプルを紹介しています。
Multiple Monitors
直線状に横にモニタを並べるサンプルです。
複数のモニタのゲームオブジェクトを作成・管理する MultipleMonitorCreator
コンポーネントと、それをレイアウトする MultipleMonitorLayouter
コンポーネントが一つのゲームオブジェクト(Multiple Monitor Creator
)にアタッチされています。
MultipleMonitorCreator
は次のとおりです。
- Monitor Prefab
- 生成するモニタの Prefab
- Scale Mode
- Real
- DPI を考慮した実寸サイズで表示
- Fixed
- 全てのモニタの長辺を Scale で指定した固定サイズで表示
- Pixel
- DPI は考慮しないで Pixel に応じて表示
- Real
- Scale
- Fixed 時のスケールを指定
- Mesh Forward Direction
- メッシュ方向を指定
- Remove If Unsupported
- サポート外のモニタ(後述のエラーの項参照)があった場合、そのゲームオブジェクトを一定時間後に消す
- Remove Wait Duration
- サポート外モニタを消すまでの時間(秒)
- Remove Children When Clear
- 再初期化時に本ゲームオブジェクト下のゲームオブジェクトを全て消すかどうか
ここで生成したゲームオブジェクトを MultipleLayouter
で横に並べます。
- Update Every Frame
- 毎フレームレイアウトを更新します
- Margin
- モニタ間の間隔
- Thickness
- モニタの厚みを一括変更
まとめてモニタサイズを変えたい時は親のゲームオブジェクトのスケールを変えれば良い形になっています。
モニタの設定が変更された場合、極力自動で検知するようにしていますが、まだ取りこぼす時があるようです。なるべく早めに全てのケースに対応するようにしますが、今のところは、適当な UI などから手動で再更新が出来る MultipleMonitorCreator.Reinitialize()
を叩ける口を用意しておくことをおすすめします。
Mutlple Monitors Roundly
一方、こちらは MultipleMonitorCreator
は同じですが、MultipleMonitorRoundlyLayouter
により、モニタが円弧状に配置されます。
- Debug Draw
- 円弧をシーンビューに描画するか否か
- Radius
- 円弧の半径
- Offset Angle
- 円弧のオフセット角(ピッチ、ヨー、ロール)
レイアウトは随時増やしていこうと思います。
ルーペ機能
前述した Clip
の機能を利用すると一部分を切り出すことが出来ます。そのサンプルが Loupe
コンポーネントとなっており、Zoom
シーンから見ることが出来ます。
マウスの中心領域付近を拡大するサンプルになります。Loupe
コンポーネントは以下のような形になります。
zoom
と aspect
を指定すれば自動的にマウス中心付近を aspect
比を考慮して zoom
倍した画になります。例では描画するメッシュが正方形なので aspect
は 1 となっています。
現状、様々な要因で移動時にガクガクしてしまうので、修正を検討中です。
解析
DDA は Move Rects 及び Dirty Rects という(内部での)描画高速化のため情報(メタデータ)を提供してくれています。
これを利用してユーザーが着目していそうなエリアを判定するサンプルを作成してみました。
Meta Data
Meta Data
シーンでは Texture
コンポーネントと組み合わせて GazePointAnalyzer
というコンポーネントがついています。これはメタデータとカーソル位置をみて、ユーザがどの付近を見ていそうか適当に推定するものです。
Calc Average Pos
にチェックが入っていると、GazePointAnalyzer.averagePos
から推定したワールド座標を確認することが出来ます。
Multiple Monitors Roundly
先程も紹介した Multiple Monitors Roundly
シーンで、デフォルトではオフになっている MultipleMonitorAnalyzer
コンポーネントにチェックを入れれば使用できます。
全てのモニタに GazePointAnalyzer
を取り付け一括管理し、マウスが乗っているモニタの位置を優先的に適当にフィルタを掛けながら位置を教えてくれるものです。MultipleMonitorAnalyzer.gazePoint
から解析結果のワールド座標にアクセスできます。
本当は、あるしおうねさんの画像解析のアルゴリズムをコンピュートシェーダで実装したものを使いたかったのですが、コンピュートシェーダから結果を転送してくるところが想定していたよりも重かったため、現在は手元で保留にしてあります...。
ディスプレースメントマッピング
uDD_Unlit_Displacement
というシェーダが v1.4.0 から追加されました。まだテスト用ですが、Displacement
シーンから見ることが出来ます。
隣の画面をディスプレースメントマップとして使うの出来ました:https://t.co/G6ZDPvz6CK pic.twitter.com/d5qOUZXWMN
— 凹 (@hecomi) 2016年11月26日
シェーダ内では距離ベースのテッセレーションを使用しており、この分割に応じて滑らかに曲がったメッシュの法線方向に、ディスプレースメントマップに指定されたテクスチャを参照しながら凸凹を作ります。DisplacementMapping
コンポーネントを使用すると、自身または前後のディスプレイのテクスチャをディスプレースメントマップとして与えることが出来ます。
- Target
- Self(自身)、Prev(前の ID のディスプレイ)、Next(次)を指定
- Displacement Factor
- 凹凸の度合い
- Tessellation Min Dist
- 距離ベースのテッセレーションの最小距離
- Tessellation Max Dist
- 距離ベースのテッセレーションの最大距離
- Tessellation Factor
- テッセレーションの分割量
API
スクリプトから操作するプロパティやメソッドは以下のとおりです。
Texture
public class Texture : MonoBehaviour { // 対象となるモニタ public Monitor monitor { get; set; } // 対象となるモニタ(ID で指定) public int monitorId { get; set; } // UV 反転(水平) public bool invertX { get; set; } // UV 反転(垂直) public bool invertY { get; set; } // モニタ回転 public MonitorRotation rotation { get; } // クリップするか public bool useClip { get; set; } // クリップ位置 public Vector2 clipPos { get; set; } // クリップ範囲 public Vector2 clipScale { get; set; } // カーブするか public bool bend { get; set; } // メッシュ方向 public MeshForwardDirection meshForwardDirection { get; set; } // カーブ半径 public float radius { get; set; } // メッシュ幅 public float width { get; set; } // メッシュ厚み public float thickness { get; set; } // カリング public Culling culling { get; set; } // マテリアル public Material material { get; } // メッシュのワールドスケール(横幅) public float worldWidth { get; set; } // メッシュのワールドスケール(縦幅) public float worldHeight { get; set; } // デスクトップ上の座標をワールド座標へ変換(カーブ考慮) public Vector3 GetWorldPositionFromCoord(int u, int v); }
Monitor
using UnityEngine; public class Monitor { // モニタ ID public int id { get; } // モニタのステート(内部で利用) public MonitorState state { get; } // モニタの名前 public string name { get; } // メインディスプレイか public bool isPrimary { get; } // モニタの左座標 public int left { get; } // モニタの右座標 public int right { get; } // モニタの上座標 public int top { get; } // モニタの下座標 public int bottom { get; } // モニタの横幅 public int width { get; } // モニタの縦幅 public int height { get; } // モニタの DPI(横) public int dpiX { get; } // モニタの DPI(縦) public int dpiY { get; } // モニタの横幅(実寸) public float widthMeter { get; } // モニタの縦幅(実寸) public float heightMeter { get; } // モニタの向き public MonitorRotation rotation { get; } // モニタのアスペクト比 public float aspect { get; } // モニタが横向きか public bool isHorizontal { get; } // モニタが縦向きか public bool isVertical { get; } // カーソルが描画されているか(更新がない時は false になる) public bool isCursorVisible { get; } // DDA から提供されるカーソル座標(X) public int cursorX { get; } // DDA から提供されるカーソル座標(Y) public int cursorY { get; } // システムから提供されるカーソル座標(X) public int systemCursorX { get; } // システムから提供されるカーソル座標(Y) public int systemCursorY { get; } // カーソルテクスチャ横幅 public int cursorShapeWidth { get; } // カーソルテクスチャ縦幅 public int cursorShapeHeight { get; } // カーソルテクスチャの種類 public CursorShapeType cursorShapeType { get; } // カーソルテクスチャを取得(ネイティブポインタ経由) public void GetCursorTexture(System.IntPtr ptr); // Move Rect の数 public int moveRectCount { get; } // Move Rects public DXGI_OUTDUPL_MOVE_RECT[] moveRects { get; } // Dirty Rect の数 public int dirtyRectCount { get; } // Dirty Rects public RECT[] dirtyRects { get; } // 一度でも更新されたか(テクスチャがあるか) public bool hasBeenUpdated { get; } // 次フレームのテクスチャを更新するか public bool shouldBeUpdated { get; set; } // テクスチャ public Texture2D texture { get; } // GetPixels() をするための GPU -> CPU テクスチャ転送をするか public bool useGetPixels { get; set; } // GetPixels() public Color32[] GetPixels(int x, int y, int width, int height); // GetPixels()(事前配列確保版) public bool GetPixels(Color32[] colors, int x, int y, int width, int height); // GetPixel() public Color32 GetPixel(int x, int y); }
Manager
public class Manager : MonoBehaviour { // マネージャのインスタンス public static Manager instance { get; } // 全モニタのリスト static public List<Monitor> monitors { get; } // モニタの数 static public int monitorCount { get; } // カーソルがあるモニタの ID static public int cursorMonitorId { get; } // メインディスプレイのモニタ static public Monitor primary { get; } // 再初期化時のイベントハンドラ public delegate void ReinitializeHandler(); public static event ReinitializeHandler onReinitialized; // 指定した ID のモニタを得る public static Monitor GetMonitor(int id) // 再初期化 public void Reinitialize() }
Texture.GetWorldPositionFromCoord()
引数にデスクトップの座標を与えると、カーブスクリーンも考慮した形で該当するワールド座標が返ってきます。逆のレイキャストは現在準備中です。
Monitor.GetPixel() / Monitor.GetPixels()
GetPixels()
を行うには GPU 側から CPU 側で利用できるようテクスチャを転送しなければなりません。このコストを避けるために、必要な時だけ取ってこれる仕組みとして、Monitor.useGetPixels
プロパティを用意しています。これを true
にセットしたときのみ GetPixel()
および GetPixels()
が使用可能になります。ユースケースに応じて使用してください。事前配列確保版も含め、以下の 3 つの API を用意してあります。
bool Monitor.GetPixels(Color32[] target, int x, int y, int width, int height)
Color32[] Monitor.GetPixels(int x, int y, int width, int height)
Color32 Monitor.GetPixel(int x, int y)
また、サンプルシーンとして GetPixels
シーンを用意しています。以下のようなコードで、取ってきたピクセルを SetPixels()
、またマウスカーソル下のピクセルを Debug.Log()
するものとなっています。
using UnityEngine; public class GetPixelsExample : MonoBehaviour { [SerializeField] uDesktopDuplication.Texture uddTexture; [SerializeField] int x = 100; [SerializeField] int y = 100; const int width = 64; const int height = 32; public Texture2D texture; Color32[] colors = new Color32[width * height]; void Start() { texture = new Texture2D(width, height, TextureFormat.ARGB32, false); GetComponent<Renderer>().material.mainTexture = texture; } void Update() { // must be called (performance will be slightly down). uDesktopDuplication.Manager.primary.useGetPixels = true; var monitor = uddTexture.monitor; if (!monitor.hasBeenUpdated) return; if (monitor.GetPixels(colors, x, y, width, height)) { texture.SetPixels32(colors); texture.Apply(); } Debug.Log(monitor.GetPixel(monitor.cursorX, monitor.cursorY)); } }
エラー
サポート外の環境
サポート外の環境では以下のようなエラーテクスチャが表示されます。
動作には Windows 8.1 以降の環境が必要です。また、Microsoft Hybrid System という組み込み GPU と dGPU を両方使うような環境で、dGPU 側で使えない、という制約があるようです。具体的にはゲーミング用の多くのラップトップで動作しないようです。
- Using cross-adapter resources in a hybrid system - Windows drivers | Microsoft Docs
- Error generated when Desktop Duplication API-capable application is run against discrete GPU
一部の dGPU のみを使用出来るような設定が可能なラップトップでは設定を変更することで改善できるようです。一方、Optimus 対応のようなラップトップだと駄目なようです...(私の Alienware 17 inch も駄目でした、dGPU 側で動く外付けディスプレイでは動作します)。
追記(2018/10/31)
申し訳ありません、上記内容は英文だと逆で、ワークアラウンドとしては dGPU を使う設定の代わりに Intel 側の統合グラフィックスチップを使ってアプリを実行してください、と書いてあります。ただ文言とは反対に一部の Optimus 対応のゲーミングノートではハードウェア的に dGPU のみを使う設定が可能なものもあり、この設定を使えば動く、との報告もありました。
エラーログ
ネイティブ側で予期しないエラーが起きると落ちることが多いです。エラーハンドリングはしているのですが、完全ではないので、なるべくエラーログを出力するようにしています。通常、Unity がクラッシュすると exe と同階層に日付つきで Unity のエラーログおよび dump ファイルが出力されています。これとは別に、本プラグインでは exe と同階層に uDesktopDuplication.log
を出力するようにしていますので、クラッシュした際などはこちらのログも合わせて GitHub などに貼り付けて頂けると助かります(エラーログの出力の有無は Manager
コンポーネントから変更可能です、次の項で説明します。)。
Manager
Texture
コンポーネントが 1 つでもあると実行時に Manager
コンポーネントのアタッチされたゲームオブジェクトが生成されます。これは全モニタの更新を一括して担う役割を負っています。全体の構成としては、Texture
コンポーネント自体はテクスチャを持っておらず、Manager
が管理する各モニタに対応した Monitor
クラスのインスタンスがテクスチャを保持していて、Texture
クラスがそのモニタの参照を持っていたら毎フレーム dirty flag(shouldBeUpdated
)がセットされ、テクスチャが Low-Level Native Plugin Interface を通じてアップデートされます。
この Manager
コンポーネントはシーンにただ一つ存在すれば良く、実行時に生成させる代わりに自分で予めシーンに追加しておいても問題ありません。この場合、Manager
コンポーネントを通じて全体の設定が行なえます。
- Debug Mode
- File
- exe 同階層の
uDesktopDuplication.log
ファイルにログを出力
- exe 同階層の
- UnityLog
- Unity の
Debug.Log()
に出力(Unity Editor 終了時にバグ有り...?)
- Unity の
- None
- ログを出力しない
- File
- Desktop Duplication Api Timeout
- Retry Reinitialization Duration
- UAC など
ACCESS_LOST
状態になった時に復帰のために再初期化するインターバル
- UAC など
苦労した点
苦労してバグを直してたところです...、まだバグがあるかもしれません。
モニタの回転
DDA ではテクスチャはモニタの回転が Landscape の状態のものがやってきます。なのでモニタの状態に応じて適切に回転してやらなければなりません。余分な計算をさせないために、uDD ではシェーダ側で回転を行うようにしています。ただ、世の中にはいろいろな設定をされている環境があり、NVIDIA Surround など予期しないケースもあったので対応に時間がかかってしまいました。まだ想定外の環境もあるかもしれませんので、動かない場合はご報告頂けると助かります。
カーソル
カーソルは、カラー、モノクロマスク、カラーマスクと 3 種類あり、マスクの場合はカーソル下のデスクトップのイメージを持ってきてマスク演算をしないとなりません。モノクロはビット単位で取り出したりとややこしく、更にデスクトップのイメージは回転しているのに、カーソルは正対している画像なので注意深くインデックスアクセスしないと落ちます。
幸い、DDA のサンプルプロジェクトがキレイにまとまっているので参考になるのですが、それでも自分の実装に落とし込むのは大変でした...。
カーソルは、はじめは別メッシュ描画、次は 2 Pass のシェーダ側での合成、今は取り回しの楽さと安定性の面から CPU 側で直接デスクトップイメージに描き込む、という形式を取っています(おそらく上手くやればシェーダ側で 2 Pass で合成が一番速い)。テクスチャを取ってきたり、その結果をカーソルと合成する処理はレンダリングスレッド内で行っているため、モニタの枚数が増えたりして重くなってくるとレンダリングスレッドが原因でブロッキングが起きてしまうかもしれません。
やっぱりネイティブの開発は DLL の再更新のために Unity の再起動が必要なのがツライですね...。更に描画周りだと DX11 の内部で落ちたりして、プロセスをアタッチしていても落ちた場所が遅延呼び出し側になってしまい全く分からず大変でした...。
今後の展望
オフスクリーンレンダリングした各ウィンドウのテクスチャも取ってこれたら面白そうなので、なんとか出来ないか調べてみたいです。また、ネットワーク経由で共有したりする機能も出来たらつけたいと思っていますが、まずは Oculus Touch が来たときを想定して、デスクトップ操作機能周り(マルチタッチ対応)を進めていきたいです。
おわりに
今年も無事書き終えられて安心しました...。明日の Unity Advent Calendar 2016 は adarapataadarapata による記事になります。