Unityで作ったゲームで表示ディスプレイを切り替える

 作業スペースを広げる為PCに複数台のディスプレイを接続している人は沢山いますよね。僕もその一人です。そういう環境でUniryで作ったゲームを表示する場合「どのディスプレイで表示するか?」という問題が生じます。

 市販ゲームを幾つか確認するとオプションで切り替えられる仕様にしているのが多いようです。なので「実装方法は普通にネットに転がってるだろうなぁ~ふふ~ん♪」と鼻歌交じりで調べてみたら、あれ~意外と少ない(^-^;

 という事でUnityで作成したゲームで表示ディスプレイを切り替える方法をまとめてみました。尚、今回はWindows限定です。

やりたい事

  • 接続されているディスプレイを列挙する

  • フルスクリーン表示を指定のディスプレイに切り替える

ディスプレイ切り替えの原理

 Unityでビルドしたゲームを初回実行するとデフォルトではフルスクリーン表示になります。これはWindowsの[ディスプレイ設定]で指定されているメインディスプレイが対象になります。より具体的には仮想デスクトップ座標で(0,0)を所持するディスプレイに表示されます。

 仮想デスクトップというのは複数のディスプレイの表示領域がレイアウトされている文字通り仮想的なデスクトップの事で、複数のディスプレイを統合して一つの広いデスクトップ画面があるように見せてくれます:

 例えば上図のように3つのディスプレイが接続されている場合、すべてのディスプレイを囲う青線の領域が仮想ディスプレイ空間で、メインディスプレイ(1番)の左上位置が原点となります。

 Unityで作成したWindowsのゲームはフルスクリーンであってもメインウィンドウの位置座標を内部に保持しています。そしてフルスクリーン表示はこのメインウィンドウの左上座標が所属しているディスプレイが対象になるんです:

よって「フルスクリーン中にメインウィンドウの表示位置を指定のディスプレイ内に移動する」とディスプレイを切り替えられます。

Unityの設定

 今回の方法は上のPlayerセッティングにあるFullscreen Modeが「Fullscreen Window」でないとうまく行きません。Exclusive Window(排他的ウィンドウモード)にするとフルスクリーン表示がメインディスプレイに固定されてしまうため動作しませんのでご注意ください。

Unity2021.3以降の場合

 Unity2021.3以降だとScreenクラスに追加されたメインディスプレイを扱うメソッドを利用出来ます。

Screen.GetDisplayLayout()でディスプレイ情報を列挙

 接続されているディスプレイの情報を得るにはScreen.GetDisplayLayoutメソッドを用います。例えば以下のように呼び出すと

// ディスプレイ情報を列挙
var list = new List<DisplayInfo>();
Screen.GetDisplayLayout( list );

DisplayInfo構造体のリストを得られます。

 DisplayInfo構造体は以下のメンバ構成になっています:

public struct DisplayInfo : IEquatable<DisplayInfo> {
    public int width;
    public int height;
    public RefreshRate refreshRate;
    public RectInt workArea;
    public string name;
}

 width、heightはウィンドウ全体の幅と高さです。refreshRateはそのディスプレイのリフレッシュレートの情報、workAreaはそのディスプレイの表示領域(Windowsならタスクバーを除いた、MacOSならDock領域を除いた領域)です。タスクバーが上にある場合はworkArea.yがその分下に下がります。nameにはそのディスプレイの名前が格納されます。

 ちなみにこのメソッドでは仮想ディスプレイ座標の絶対値は取れません。が、以下のメソッドでうまく切り替える事が出来ます。

Screen.MoveMainWindowTo()でメインウィンドウを移動

 メインウィンドウはScreen.MoveMainWindowToメソッドで移動する事が出来ます:

public static AsyncOperation MoveMainWindowTo( in DisplayInfo display, Vector2Int position );

このメソッドの第1引数にはDisplayInfo構造体を渡す事になっていて、上で列挙した構造体をそのまま使えます。第2引数のpositionはそのディスプレイの左上座標を原点とする相対位置を渡します。

 これらを用いると、例えばセカンドディスプレイの左上位置にメインウィンドウを移動するには、

var list = new List<DisplayInfo>();
Screen.GetDisplayLayout( list );
Screen.MoveMainWindowTo( list[ 1 ], list[ 1 ].workArea.position );

このようにします。

 フルスクリーン時の切り替えも上の実装で可能です。Unity2021.3以降だとこのように切り替え作業はとっても簡単になりました。

Unity2021.3以前の場合

 Unity2021.3以前の場合、Screen.GetDisplayLayout等が用意されていません。メインウィンドウを操作するためのメソッド自体が無いんですね…。その為Windows APIを直接叩いて対応するしか(おそらく)無いです。

FindWindow関数でウィンドウハンドルを取得

 Windows APIでウィンドウを操作するにはメインウィンドウのウィンドウハンドル(HWND)を得る必要があります。HWNDはウィンドウに与えられている一意のIDです。C++であればウィンドウ作成時にHWNDが返されるのでそれを使うんですが、Unityはその辺りを隠蔽しているためゲーム起動後にAPIの力を借りてゲームのウィンドウを検索しないといけません。それを行うのがFindWindow関数です。C#版の宣言は以下の通りです:

using System.Runtime.InteropServices;
using System

[DllImport( "user32.dll", CharSet = CharSet.Unicode )]
public static extern IntPtr FindWindow( string lpClassName, string lpWindowName );

 これを何らかのクラス内に宣言します。

 FindWindow関数は引数の名前の情報からウィンドウを検索してそのウィンドウハンドルを返してくれます。
 第1引数のlpClassNameというのは「ウィンドウクラス」というウィンドウのグループ名のようなものを指定します。が、これはC++ネイティブで実装していないと普通分かりません。分からない場合はnullを指定出来ます。
 第2引数のlpWindowNameには検索したいウィンドウのキャプションに表示されている文字列を指定します。Unityの場合キャプション名にはプロダクト名が入ります。プロダクト名はUnityのプロジェクトセッティングのPlayer設定にあります:

これはApplication.productNameに格納されているので、これを渡せば良いわけです。

 DllImportについて軽く触れておきます。user32.dllというWindows標準のDLLがWindows APIへのアクセス関数を提供してくれています。CharSetにUnicodeを指定していますが、これ実は大事です。これを指定しないとデフォルト(多分Anci)になるんですが、その場合プロダクト名に日本語等のマルチバイト文字が入っている場合に検索に失敗します。嵌ったので強調しておきますww

EnumDisplayMonitors関数でディスプレイを列挙

 ディスプレイを列挙するにはEnumDisplayMonitor関数を使います:

delegate bool MonitorEnumProc( IntPtr hMonitor, IntPtr hdc, IntPtr rect, IntPtr dwData );

[DllImport( "user32.dll" )]
static extern bool EnumDisplayMonitors( IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData );

 第1、第2引数は列挙するディスプレイを絞る情報ですが、今回は使わないので説明は割愛します。第3引数が超大事。ここにはコールバックを指定します。関数を呼び出すと関数内部でディスプレイが検索され、その情報をここで渡したコールバックに返してくれます。コールバックの型はMonitorEnumProcデリゲータとして上で定義しています。第4引数にはコールバックに渡したい値を指定するのですが、これもC#だとあんまり役に立たないので今回は使いません。

 適当なクラスの中に上の宣言を入れて、コールバックメソッドをこんな感じで実装します:

[StructLayout( LayoutKind.Sequential )]
struct DisplayRect {
    public int left;
    public int top;
    public int right;
    public int bottom;
}

bool monitorEnumProc( IntPtr hMonitor, IntPtr hdc, IntPtr rect, IntPtr dwData ) {
    var dispRect = Marshal.PtrToStructure<DisplayRect>( rect );
    displayRects_.Add( dispRect );
    return true;
}

List<DisplayRect> displayRects_ = new List<DisplayRect>();

 monitorEnumProcメソッドがコールバックです。接続されているディスプレイの個数分だけこのコールバックが呼ばれます。第3引数のrectにはそのディスプレイの仮想ディスプレイ座標のポインタが返ります。C#の場合はIntPtrがポインタを担ってくれますが、そのままでは扱えないのでMarshal.PtrToStructureメソッドで構造体の実体に変換します。変換するDisplayRect構造体はサイズやメンバ変数の並びが厳格で無ければならないためStructLayoutでLayoutKind.Sequentialを指定して並びを固定します。後はリストに格納して完了です。ぬーめんどくさい(^-^;

 これで以下のように呼び出せばディスプレイの位置を得る事が出来ます:

// ディスプレイ情報を列挙
EnumDisplayMonitors( IntPtr.Zero, IntPtr.Zero, monitorEnumProc, IntPtr.Zero );

SetWindowPos関数でウィンドウを移動

 移動したいディスプレイの仮想ディスプレイ座標を得る事が出来れば、あとはメインウィンドウをそこに移動するだけです。これはSetWindowPos関数を使います:

[DllImport( "user32.dll" )]
static extern bool SetWindowPos(
 IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flag );

 hWndは動かしたいウィンドウのウィンドウハンドルです。第2引数のhWndInsertAfterはオプション情報ですが今回は使わないので割愛します。x,y,cx,cyはそれぞれ移動先のデスクトップ座標(x,y)、移動後のウィンドウサイズ(cx,cy)です。この関数は移動とサイズ変更を兼ねますが、サイズについては最後のflagでフラグを指定すると無視(既存のをそのまま使用)する事が可能です。

 flagには移動時の挙動を指定するフラグを指定します。今回は以下のフラグを指定します:

// 指定ディスプレイに移動
const int SWP_NOSIZE = 0x0001;
const int SWP_NOZORDER = 0x0004;

SWP_NOSIZEは移動後にウィンドウの大きさを変更しません。これを指定するとcx, cyの値が無視されます。SWP_NOZORDERは移動後にウィンドウのZオーダー(表示順番)を無視します。これにより第2引数のhWndInsertAfterが無視され、移動後にそのディスプレイのトップウィンドウになります。

指定のディスプレイに表示を切り替えるクラス

 という事でWindows APIを叩くと色々面倒くさいわけです(^-^;。こういうのはクラスに機能を分離集中させてしまいましょう:

using UnityEngine;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System;


// 表示ディスプレイを切り替える
public static class DisplayChanger {

// ウィンドウを検索
[DllImport( "user32.dll", CharSet = CharSet.Unicode )]
static extern IntPtr FindWindow( string lpClassName, string lpWindowName );

// ディスプレイを列挙
[DllImport( "user32.dll" )]
static extern bool EnumDisplayMonitors( IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData );

// ウィンドウを移動
[DllImport( "user32.dll" )]
static extern bool SetWindowPos( IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flag );

// ディスプレイ範囲
[StructLayout( LayoutKind.Sequential )]
public struct DisplayRect {
    public int left;
    public int top;
    public int right;
    public int bottom;
}

delegate bool MonitorEnumProc( IntPtr hMonitor, IntPtr hdc, IntPtr rect, IntPtr dwData );

// モニター列挙コールバック
static bool monitorEnumProc( IntPtr hMonitor, IntPtr hdc, IntPtr rect, IntPtr dwData ) {
    displayRects_.Add( Marshal.PtrToStructure<DisplayRect>( rect ) );
    return true;
}

// 指定のディスプレイ番号に表示を切り替える
//  displayIdx: ディスプレイ番号
//  x, y      : 表示位置オフセット
//  recheck   : ディスプレイの再チェックをする場合はtrue
static public bool changeDisplay( int displayIdx, int x, int y, bool recheck = true ) {
    if ( hWnd_ == IntPtr.Zero ) {
        // ウィンドウハンドルを取得
        hWnd_ = FindWindow( null, Application.productName );
        if ( hWnd_ == IntPtr.Zero )
            return false;

        // ディスプレイ情報を列挙
        EnumDisplayMonitors( IntPtr.Zero, IntPtr.Zero, monitorEnumProc, IntPtr.Zero );
    }

    if ( recheck ) {
        // ディスプレイ情報を再取得
        displayRects_.Clear();
        EnumDisplayMonitors( IntPtr.Zero, IntPtr.Zero, monitorEnumProc, IntPtr.Zero );
    }

    if ( displayIdx < 0 || displayIdx >= displayRects_.Count )
        return false;

    // 指定ディスプレイに移動
    const int SWP_NOSIZE = 0x0001;
    const int SWP_NOZORDER = 0x0004;
    int left = displayRects_[ displayIdx ].left + x;
    int top = displayRects_[ displayIdx ].top + y;
    SetWindowPos( hWnd_, IntPtr.Zero, left, top, 0, 0, SWP_NOSIZE | SWP_NOZORDER );

    return true;
}

// ディスプレイの位置情報を取得
static DisplayRect[] getDisplayInfo( bool recheck = true ) {
    if ( recheck ) {
        // ディスプレイ情報を再取得
        displayRects_.Clear();
        EnumDisplayMonitors( IntPtr.Zero, IntPtr.Zero, monitorEnumProc, IntPtr.Zero );
    }
    return displayRects_.ToArray();
}

static List<DisplayRect> displayRects_ = new List<DisplayRect>();
static IntPtr hWnd_ = IntPtr.Zero;
}

 DisplayChanger.changeDisplayメソッドを呼び出す事で表示ディスプレイを切り替えられます。recheckは毎回しておいた方が良いかと思います。ユーザーがディスプレイの仮想ディスプレイ座標を変更しているかもしれませんので。

終わりに

 今回はUnityで表示ディスプレイを切り替える方法について見てきました。Unity2021.3以降であれば追加されたScreenメソッドを通した方が絶対に楽です。後MacOS等でも動作するはずです。Windows APIを叩く方はWindows限定になってしまいます。手元にMacの開発環境が無い為Mac版を検討する事は今回出来ませんでした。方法がありましたら是非コメントで教えて下さい。

ではまた(^-^)/

この記事が気に入ったらサポートをしてみませんか?