凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で Windows のデスクトップ画面をテクスチャとして表示するプラグインを作ってみた

はじめに

本エントリは 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)でデスクトップテクスチャを表示します。

f:id:hecomi:20161204121815g:plain

ダウンロード

GitHub で MIT ライセンスで公開しています。リリースページから最新のバージョンの .unitypackage をダウンロードし、インポートしてください。

「uDesktopDuplication > Examples > Scenes」に入っている各シーンにいくつかサンプルが格納されています。

ディスプレイ表示と Texture コンポーネント

ディスプレイ表示

まずは、最も単純なサンプルである、プライマリの画面を一枚表示するシーンの Primary Monitor を開いてみてください。

f:id:hecomi:20161203182139p:plain

実行して上図のように画面が表示されれば正常に動作しています。モニタを表示している GameObject を見ると uDesktopDuplication.Texture コンポーネントがついています。

f:id:hecomi:20161203223153p:plain

このコンポーネントでは、表示しているディスプレイの設定を行うことが出来ます。

  • Monitor
    • Monitor
      • 認識されているモニタをプルダウンで選択可能
    • ID
      • モニタの ID
    • Is Primary
      • Windows の設定からモニタがメインディスプレイに設定されているか
    • Rotation
      • モニタの向き
    • Resolution
      • 解像度
    • DPI
      • DPI(解像度と DPI からモニタの物理サイズが計算可能、取得できない場合は 100)
  • Invert
    • Invert X
      • UV 反転(水平方向)
    • Invert Y
      • UV 反転(垂直方向)
  • Clip
    • Use Clip
      • デスクトップの一部領域を拡大するかどうか(後述)
    • Clip Pos
      • クリップする位置
    • Clip Scale
      • クリップする範囲
  • Material
    • Mesh Forward Direction
      • メッシュ平面の方向(曲げ、厚み変更に使用)
    • Use Bend
      • カーブスクリーンにするか
    • Bend Width
    • Bend Radius
      • カーブの半径
    • Thickness
      • メッシュの厚み(厚みのあるメッシュ使用時)
    • Culling
      • カリングの設定

基本的にはこのコンポーネントをアタッチすると、マテリアルのメインのテクスチャがデスクトップ画面に置き換えられます。ただ、デスクトップ画面の描画には UV 反転やモニタの回転などを考慮する必要があるため、専用のシェーダを利用しています。本シーンでは uDD_Unlit.shader を使用したシェーダを利用していますが、4 つのシェーダが用意されています。

  • uDD_Unlit
    • ライティングをしない不透明なシェーダ
  • uDD_Unlit_Transparent
    • ライティングをしない半透明なシェーダ
  • uDD_Unlit_BlackMask
    • ライティングをしない黒が透けるシェーダ(Mask 値を変更することで透け具合を調整可能)
  • uDD_Unlit_Standard

これらは Shaders シーンから確認できます。

f:id:hecomi:20161203230034p:plain

1 点、注意があり、アプリが実行されていない時は Texture コンポーネントから Material の値を変更すると、sharedMaterial の値が共通して変更されます(InvertClip は個別にシリアライズされます)。一方実行時は、開始時にマテリアルのクローンを生成しこちらを変更するため、個別に変更できます。

曲げ・厚み変更

Thickness を変更すると厚みのあるメッシュの場合は厚みを変更することができます。また、Use Bend をチェックした状態で Bend Radius を変更するとカーブスクリーンの曲率を変更することが出来ます。

これら変形用に、uDD_Board というメッシュが用意されているので、基本的にはこちらを使うのをおすすめします(Mesh Forward DirectionZ)。通常の Plane でも Mesh Forward DirectionY にすれば可能ですが、分割数が足りず、曲げ利用時にカクカクしてしまうので注意が必要です。

f:id:hecomi:20161203233225g:plain

頂点シェーダによる変形なので、コライダは追従しません。

複数のモニタ表示

Multiple Monitors シーンと Moltiple Monitors Roundly シーンにて複数のモニタのサンプルを紹介しています。

Multiple Monitors

直線状に横にモニタを並べるサンプルです。

f:id:hecomi:20161203234443p:plain

複数のモニタのゲームオブジェクトを作成・管理する MultipleMonitorCreator コンポーネントと、それをレイアウトする MultipleMonitorLayouter コンポーネントが一つのゲームオブジェクト(Multiple Monitor Creator)にアタッチされています。

MultipleMonitorCreator は次のとおりです。

f:id:hecomi:20161203234808p:plain

  • Monitor Prefab
    • 生成するモニタの Prefab
  • Scale Mode
    • Real
      • DPI を考慮した実寸サイズで表示
    • Fixed
      • 全てのモニタの長辺を Scale で指定した固定サイズで表示
    • Pixel
      • DPI は考慮しないで Pixel に応じて表示
  • Scale
    • Fixed 時のスケールを指定
  • Mesh Forward Direction
    • メッシュ方向を指定
  • Remove If Unsupported
    • サポート外のモニタ(後述のエラーの項参照)があった場合、そのゲームオブジェクトを一定時間後に消す
  • Remove Wait Duration
    • サポート外モニタを消すまでの時間(秒)
  • Remove Children When Clear
    • 再初期化時に本ゲームオブジェクト下のゲームオブジェクトを全て消すかどうか

ここで生成したゲームオブジェクトを MultipleLayouter で横に並べます。

f:id:hecomi:20161204011734p:plain

  • Update Every Frame
    • 毎フレームレイアウトを更新します
  • Margin
    • モニタ間の間隔
  • Thickness
    • モニタの厚みを一括変更

まとめてモニタサイズを変えたい時は親のゲームオブジェクトのスケールを変えれば良い形になっています。

モニタの設定が変更された場合、極力自動で検知するようにしていますが、まだ取りこぼす時があるようです。なるべく早めに全てのケースに対応するようにしますが、今のところは、適当な UI などから手動で再更新が出来る MultipleMonitorCreator.Reinitialize() を叩ける口を用意しておくことをおすすめします。

Mutlple Monitors Roundly

一方、こちらは MultipleMonitorCreator は同じですが、MultipleMonitorRoundlyLayouter により、モニタが円弧状に配置されます。

f:id:hecomi:20161204014341g:plain

f:id:hecomi:20161204014340g:plain

f:id:hecomi:20161204020018p:plain

  • Debug Draw
    • 円弧をシーンビューに描画するか否か
  • Radius
    • 円弧の半径
  • Offset Angle
    • 円弧のオフセット角(ピッチ、ヨー、ロール)

レイアウトは随時増やしていこうと思います。

ルーペ機能

前述した Clip の機能を利用すると一部分を切り出すことが出来ます。そのサンプルが Loupe コンポーネントとなっており、Zoom シーンから見ることが出来ます。

f:id:hecomi:20161203232152g:plain

マウスの中心領域付近を拡大するサンプルになります。Loupe コンポーネントは以下のような形になります。

f:id:hecomi:20161204030003p:plain

zoomaspect を指定すれば自動的にマウス中心付近を aspect 比を考慮して zoom 倍した画になります。例では描画するメッシュが正方形なので aspect は 1 となっています。

現状、様々な要因で移動時にガクガクしてしまうので、修正を検討中です。

解析

DDA は Move Rects 及び Dirty Rects という(内部での)描画高速化のため情報(メタデータ)を提供してくれています。

f:id:hecomi:20161204014344g:plain

これを利用してユーザーが着目していそうなエリアを判定するサンプルを作成してみました。

Meta Data

Meta Data シーンでは Texture コンポーネントと組み合わせて GazePointAnalyzer というコンポーネントがついています。これはメタデータとカーソル位置をみて、ユーザがどの付近を見ていそうか適当に推定するものです。

f:id:hecomi:20161204124559p:plain

Calc Average Pos にチェックが入っていると、GazePointAnalyzer.averagePos から推定したワールド座標を確認することが出来ます。

Multiple Monitors Roundly

先程も紹介した Multiple Monitors Roundly シーンで、デフォルトではオフになっている MultipleMonitorAnalyzer コンポーネントにチェックを入れれば使用できます。

f:id:hecomi:20161204014342g:plain

f:id:hecomi:20161204124820p:plain

全てのモニタに GazePointAnalyzer を取り付け一括管理し、マウスが乗っているモニタの位置を優先的に適当にフィルタを掛けながら位置を教えてくれるものです。MultipleMonitorAnalyzer.gazePoint から解析結果のワールド座標にアクセスできます。

本当は、あるしおうねさんの画像解析のアルゴリズムをコンピュートシェーダで実装したものを使いたかったのですが、コンピュートシェーダから結果を転送してくるところが想定していたよりも重かったため、現在は手元で保留にしてあります...。

ディスプレースメントマッピング

uDD_Unlit_Displacement というシェーダが v1.4.0 から追加されました。まだテスト用ですが、Displacement シーンから見ることが出来ます。

f:id:hecomi:20161204120719g:plain

シェーダ内では距離ベースのテッセレーションを使用しており、この分割に応じて滑らかに曲がったメッシュの法線方向に、ディスプレースメントマップに指定されたテクスチャを参照しながら凸凹を作ります。DisplacementMapping コンポーネントを使用すると、自身または前後のディスプレイのテクスチャをディスプレースメントマップとして与えることが出来ます。

f:id:hecomi:20161204120322p:plain

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));
    }
}

エラー

サポート外の環境

サポート外の環境では以下のようなエラーテクスチャが表示されます。

f:id:hecomi:20161204000149p:plain

動作には Windows 8.1 以降の環境が必要です。また、Microsoft Hybrid System という組み込み GPU と dGPU を両方使うような環境で、dGPU 側で使えない、という制約があるようです。具体的にはゲーミング用の多くのラップトップで動作しないようです。

一部の 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 コンポーネントを通じて全体の設定が行なえます。

f:id:hecomi:20161204025113p:plain

  • Debug Mode
    • File
      • exe 同階層の uDesktopDuplication.log ファイルにログを出力
    • UnityLog
      • Unity の Debug.Log() に出力(Unity Editor 終了時にバグ有り...?)
    • None
      • ログを出力しない
  • Desktop Duplication Api Timeout
  • Retry Reinitialization Duration
    • UAC など ACCESS_LOST 状態になった時に復帰のために再初期化するインターバル

苦労した点

苦労してバグを直してたところです...、まだバグがあるかもしれません。

モニタの回転

DDA ではテクスチャはモニタの回転が Landscape の状態のものがやってきます。なのでモニタの状態に応じて適切に回転してやらなければなりません。余分な計算をさせないために、uDD ではシェーダ側で回転を行うようにしています。ただ、世の中にはいろいろな設定をされている環境があり、NVIDIA Surround など予期しないケースもあったので対応に時間がかかってしまいました。まだ想定外の環境もあるかもしれませんので、動かない場合はご報告頂けると助かります。

カーソル

カーソルは、カラー、モノクロマスク、カラーマスクと 3 種類あり、マスクの場合はカーソル下のデスクトップのイメージを持ってきてマスク演算をしないとなりません。モノクロはビット単位で取り出したりとややこしく、更にデスクトップのイメージは回転しているのに、カーソルは正対している画像なので注意深くインデックスアクセスしないと落ちます。

幸い、DDA のサンプルプロジェクトがキレイにまとまっているので参考になるのですが、それでも自分の実装に落とし込むのは大変でした...。

カーソルは、はじめは別メッシュ描画、次は 2 Pass のシェーダ側での合成、今は取り回しの楽さと安定性の面から CPU 側で直接デスクトップイメージに描き込む、という形式を取っています(おそらく上手くやればシェーダ側で 2 Pass で合成が一番速い)。テクスチャを取ってきたり、その結果をカーソルと合成する処理はレンダリングスレッド内で行っているため、モニタの枚数が増えたりして重くなってくるとレンダリングスレッドが原因でブロッキングが起きてしまうかもしれません。

やっぱりネイティブの開発は DLL の再更新のために Unity の再起動が必要なのがツライですね...。更に描画周りだと DX11 の内部で落ちたりして、プロセスをアタッチしていても落ちた場所が遅延呼び出し側になってしまい全く分からず大変でした...。

今後の展望

オフスクリーンレンダリングした各ウィンドウのテクスチャも取ってこれたら面白そうなので、なんとか出来ないか調べてみたいです。また、ネットワーク経由で共有したりする機能も出来たらつけたいと思っていますが、まずは Oculus Touch が来たときを想定して、デスクトップ操作機能周り(マルチタッチ対応)を進めていきたいです。

おわりに

今年も無事書き終えられて安心しました...。明日の Unity Advent Calendar 2016adarapataadarapata による記事になります。