【Unity】実行後のゲームウィンドウを制御する方法【WinAPI】
概要
当記事はUnity、C#、Windows APIを組み合わせることにより、ゲームエンジンの処理領域外に存在すると一見考えられているゲーム実行後のゲームウィンドウを制御する方法を整理したものです。以下のキーワードに興味・関心があれば参考になるかと思います。
- デスクトップマスコット
- デスクトップアクセサリー
- ゲームウィンドウの一部、もしくは背景にあたる部分を透過させたい
- 最大化、最小化ボタンなどの表示を消したい
- ゲームウィンドウを常に最前面へ持ってきたい
- ゲームの枠を超えた演出がしたい
動作環境
筆者は以下の環境下で動作確認を行っています。
- Unity6(ver6000.0.26f1)
- Windows11
ゴール
今回はサンプルとして、デスクトップアクセサリーを想定したストップウォッチアプリを組んでみました。背景に素敵なPixelArtの森、山、月夜が照らし出されていますが、月夜の部分を透過させたり、枠を消すことでデスクトップ上でのコンパクトさを上げてみます。
事前知識
UnityEditor上で設定できるゲームウィンドウの制御
Unityから出力されるゲームの実行画面、ゲームウィンドウについての制御や動作については、UnityのProject Settingsからプラットフォーム別に用意されているものが複数存在します。例えば、WindowsなどのPC向けであれば次のようなものが存在します。
- フルスクリーンモードにするか否か
- デフォルトのウィンドウサイズ
- ウィンドウサイズの可変を可能とするか否か
ゲームの表現として挑戦したいことがこの設定の収まる範囲内であればこちらを変更してもらう形でよいでしょう。ただ、その範疇を超えるのであれば、当記事がその助けになるかもしれません。
Windows API
早い段階で結論を書いてしまうと、実行後のゲームウィンドウを制御する方法の答えは、「Windows APIを利用する」ことです。Windows APIはMicrosoftの提供するC言語で定義されたWindowsオペレーティングシステムの機能にアクセスするためのインターフェースで、現在では様々なプログラミング言語や開発環境からも利用が可能となっています。Windows APIを使えば、例えば次のようなことを行うことが可能です。
- ウィンドウの生成、制御、設定変更
- メッセージボックスを生成、制御、設定変更
- システム情報の取得
詳しくは後述しますが、今回はC#スクリプトからプラットフォーム呼び出し機能(Platform Invoke。以下、P/Invoke機能)を使ってウィンドウの制御や設定変更を行う関数を呼び出し、有効活用します。
UnityEditor上では処理を回避する
この後紹介する各種処理は、Build後のexe化したゲームであれば問題ない処理がほとんどですが、UnityEditor上でデバッグを行ってしまうと、ゲームそのものではなく「UnityEditor」の枠が消えたり、「UnityEditor」が透過したりと困ったことを引き起こします。UnityEditorでの実行を回避するために関数の記述の前後に次のように、プラットフォーム依存コンパイルと呼ばれる記述を行うことを推奨します。
#if UNITY_STANDALONE_WIN
Debug.Log("この行の処理はWindowsビルドのみで実行される");
#endif
#if UNITY_EDITOR
Debug.Log("この行の処理はUnityEditorによるデバッグのみで実行される");
#endif
実装の準備:関数の再定義
Windows APIは本来C#(ないしはUnity開発環境)に標準で組み込まれている機能ではありません。そこで、.Net framework上で動作するC#では「P/Invoke機能を使って.Net frameworkの外部にあるWindows APIを呼び出す」ということを行います。(Unityの場合であっても.Net frameworkと互換性のあるMonoで動いており、同じ方法が利用可能です)
具体的には、DllImport属性と呼ばれるものを使用します。次のようなサンプルをご覧ください。
[DllImport("user32.dll", EntryPoint = "GetActiveWindow")]
private static extern IntPtr GetCurrentWindow();
これは、「user32.dllファイルに含まれるGetActiveWindowという名称の関数をGetCurrentWindowという名称のメソッドとして呼び出せるようにC#で再定義する」プログラムです。
user32.dllは、Windows APIの中でもウィンドウの制御、インターフェースに関連するシステムを操作する関数を含んだファイルです。これを読み込み、C#のプログラム上で使えるように「再定義」を行うことで「実行後のゲームウィンドウを制御する」プログラムが利用可能となります。
結果として、「実行後のゲームウィンドウを制御する方法」や「実行後のゲームウィンドウを操作するプログラムの作り方」はどんなものであるか?と、端的に述べるのであれば、使用したいWindows APIの機能をどんどん再定義していき、あとはしかるべきタイミングで再定義されたメソッドを呼び出すこととなります。当記事ではここから、いくつかのシチュエーションをピックアップし、それを実現するために利用できる関数を再定義するサンプルをご紹介いたします。
シチュエーション別実装例
最大化ボタン、最小化ボタン、閉じるボタンの表示を変更する
ゲームを実行しているウィンドウの各種ボタンを調整・変更するための工程は次のとおりです。
- ゲームを実行しているウィンドウを取得する
- ウィンドウからウィンドウスタイルを取得する
- ウィンドウスタイルのスタイルフラグを変更する
- ウィンドウスタイルを更新する
これを実現するために、以下の関数郡を再定義し、組み合わせます。
// 実行ウィンドウを取得する関数の再定義
[DllImport("user32.dll", EntryPoint = "GetActiveWindow")]
private static extern IntPtr GetCurrentWindow();
// 指定されたウィンドウの情報を取得する関数の再定義
[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
// 指定されたウィンドウの情報を更新する関数の再定義
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
以下は、再定義した関数を使ってスタイルフラグを変更する(最大化ボタン、最小化ボタン、閉じるボタンの表示を消す)処理を収めたメソッドです。
const int GWL_STYLE = -16;
const int WS_MAXIMIZEBOX = 0x00010000;
const int WS_MINIMIZEBOX = 0x00020000;
const int WS_SYSMENU = 0x00080000;
public void ClearWindowButtons()
{
IntPtr hWnd = GetCurrentWindow();
int style = GetWindowLong(hWnd, GWL_STYLE);
style &= ~( WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU );
SetWindowLong(hWnd, GWL_STYLE, style);
}
実行前後の比較
GetWindowLong関数は第1引数hWndで指定されたウィンドウを対象に、第2引数nIndexで指定された整数に合わせてウィンドウの特定の情報を取得するという関数です。-16を指定すると指定のウィンドウの枠やボタンなどなど、「ウィンドウスタイル」の情報を取得できます。
ウィンドウスタイルの設定はスタイルフラグ内の特定のビットのオン/オフで成り立っており、例えば、最大化ボタン(MIXIMIZEBOX)は16進数表記で0x00010000(10進数の65536)の値がスタイルフラグの中に含まれているか否かで判断されます。ということで表示を消したい場合は、そのビットを0にしてしまえばOKです(が、実際のところは該当する値を否定(~)して論理積(&)を行って上書き(=)してます)。詳しくはコチラ。
ウィンドウの枠を消す
ウィンドウ枠を消す工程は次の通りです。基本的には、前述のボタンを消す方法とほぼ同じで、必要な関数の再定義も全く同じ、スタイルフラグが異なる形です。
- ゲームを実行しているウィンドウを取得する
- ウィンドウからウィンドウスタイルを取得する
- ウィンドウスタイルのスタイルフラグを変更する
- ウィンドウスタイルを更新する
関数郡の再定義は割愛して、スタイルフラグを更新することで枠を消す処理のサンプルは次のとおりです。
const int GWL_STYLE = -16;
const int WS_CAPTION = 0x00C00000;
public void ClearWindowFrame()
{
IntPtr hWnd = GetCurrentWindow();
int style = GetWindowLong(hWnd, GWL_STYLE);
style &= ~WS_CAPTION;
SetWindowLong(hWnd, GWL_STYLE, style);
}
定数WS_CAPTIONはボーダー(枠)とタイトルバー(アプリ名や閉じるボタンの表示されるウィンドウ上部のエリア)の2要素を含んだフラグです。セットで管理されてしまっていることから、WIndows APIの仕様上タイトルバーを残してボーダーを消す手段は用意されていないようです。
背景を透過させる
ウィンドウを透過させる工程は次のとおりです。
- ゲームを実行しているウィンドウを取得する
- ウィンドウから拡張ウィンドウスタイルを取得する
- 拡張ウィンドウスタイルのスタイルフラグを変更する
- 拡張ウィンドウスタイルを更新する
- ウィンドウの透明度を変更する
工程が前述の2つに似ており、再定義する関数もほぼ一緒ですが、1つだけ関数が追加されます。
// 実行ウィンドウを取得する関数の再定義
[DllImport("user32.dll", EntryPoint = "GetActiveWindow")]
private static extern IntPtr GetCurrentWindow();
// 指定されたウィンドウの情報を取得する関数の再定義
[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
// 指定されたウィンドウの情報を更新する関数の再定義
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
// 指定されたレイヤードウィンドウの透明度を変更する関数の再定義
[DllImport("user32.dll")]
private static extern bool SetLayeredWindowAttributes(IntPtr hwnd, int crKey, byte bAlpha, int dwFlags);
以上を定義後に、例えば次のサンプルのような処理を実行します。
const int GWL_EXSTYLE = -20;
const int WS_EX_LAYERD = 0x080000;
const int LWA_COLORKEY = 1;
public void MakeWindowTransparent()
{
IntPtr hWnd = GetCurrentWindow();
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
exStyle |= WS_EX_LAYERD;
SetWindowLong(hWnd,GWL_EXSTYLE, exStyle);
SetLayeredWindowAttributes(hWnd, 0x00FF00, 0, LWA_COLORKEY);
}
今回は、GetWindowLong関数の第2引数nIndexに-20を指定することで、「拡張ウィンドウスタイル」の情報を取得します。詳しくはコチラ。
「拡張ウィンドウスタイル」では、続くSetLayeredWindowAttributes関数を実行するための事前準備として取得したウィンドウをレイヤードウィンドウに変更するフラグをONにし、SetWindowLong関数で「拡張ウィンドウスタイル」を更新します。
最後にSetLayeredWindowAttributes関数を使用します。基本は透明度を変更する役割をもつ関数になりますが、次の2通りの透過方法が提供されており、第4引数dwFlagsにてどちらの透過方法を使うかを指定します。
- 第3引数bAlphaで指定した透明度(αブレンド、α値、A値、0~255で指定)を使う方法。第4引数dwFlagsに2を指定
- 第2引数crKeyで指定した1色を透過させる方法。第4引数dwFlagsに1を指定
前者の透明度を指定するタイプはウィンドウ全体が指定した透明度でぼやける挙動をします。後者は透過対象の色を指定し、それを透過させる方法です。映像編集のクロマキーといえばわかりやすいでしょうか。
前者と後者の透明化の違い
今回のサンプルでは、背景の一部を透明化したかったので、後者(つまり定数LWA_COLORKEYで示した1)を採用しています。なお後者を採用する場合は、後述する注意点に気を付けながら、透明化する部分をUnityEditorで事前に準備しておきましょう。
事前に透明化予定の部分に特別な色を設定する
なお、この透過処理によって透明になった部分はマウスクリックも貫通するようになります。
背景を指定の色で透過させる際の注意点
色にまつわる注意点をいくつかご紹介します。
- 第2引数crKeyはRGB色空間ではなく、BGR色空間で指定すること
- RGB色空間からBGR色空間への単純な変換では、すべてのケースで透過が保証されないこと(例:ティザリング、色深度の変化)
SetLayeredWindowAttributes関数で指定する色は、BGR色空間(かつint)で指定する必要があります。例えば、次のようにUnityで普段通りに設定している色の作り方とは若干異なります。変換を間違えないように注意しましょう
Unityでの色指定(RGB色空間) | WindowsAPIに渡す色情報(BGR色空間,int) |
---|---|
FF0000, new Color(1.0f,0.0f,0.0f,1.0f) …他 | 0x0000FF |
00FF00, new Color(0.0f,1.0f,0.0f,1.0f) …他 | 0x00FF00 |
0000FF, new Color(0.0f,0.0f,1.0f,1.0f) …他 | 0xFF0000 |
つづけて、一度透過させるための色を決めた後もまだ安心できません。最終的に透過される色はUnityでの設定やパラメーターなど様々な影響を受けて、BGR空間への単純変換だけでは上手く透過が成功しない時があります。例えば、筆者環境下ではUnityのデフォルトカメラ背景色314D79をBGR色空間に変換して実行したところ、うまく透過されませんでした。また、UIとの重なり方で色が若干変わってしまい透明化の対象からはずれるなんていう場合もあります。
プログラムは完全に同一で、色指定だけが異なるのに透過を失敗するケース
Unity側のuGUI設計、位置の兼ね合いで透過されない部分が生まれるケース(縁に若干残る緑)
このことから、色を指定して透過させる場合には極端な1色を決めることをお勧めします。例えば、UnityのMain Cameraゲームオブジェクト上のCameraコンポーネントにて、次のように設定してみるのが良いかもしれません。
- Background TypeをSolid
- Backgroundを単色(例:FF0000,00FF00,FF0000など)で塗りつぶし
満足いくまで調整を行ってみましょう。
ウィンドウの位置、サイズ、Z順序を変更する
ゲームウィンドウのサイズや位置も変更可能です。またこの手続きには、他のウィンドウとの前後関係であるZ順序も変更可能なため、ゲームウィンドウを最前面にもってくることなども可能です。次の工程を取ります。
- ゲームを実行しているウィンドウを取得する
- ウィンドウのサイズ、位置、Z順序を変更する
これを実現するために、以下の関数郡を再定義し、組み合わせます。
// 実行ウィンドウを取得する関数の再定義
[DllImport("user32.dll", EntryPoint = "GetActiveWindow")]
private static extern IntPtr GetCurrentWindow();
// 指定のウィンドウの位置、サイズ、z順序を変更する関数の再定義
[DllImport("user32.dll")]
private static extern int SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, int uFlags);
次は、再定義した関数を利用した処理のサンプルです。このサンプルを実行すると、ゲームウィンドウが他のアプリの前面に表示されます。
const int HWND_TOPMOST = -1;
const int SWP_NOSIZE = 0x0001; // ウィンドウサイズを変更しない(cx,cyの変更を無視)
const int SWP_NOMOVE = 0x0002; // ウィンドウ位置を変更しない。(X,Yの変更を無視)
public void BringWindowToTop()
{
IntPtr hWnd=GetCurrentWindow();//ウィンドウを取得
SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
}
第2引数hWndInsertAfterは変更先のZ順序です。値を指定すると複数のウィンドウの重なりに対して、指定された順番のひとつ後ろ(例:ウィンドウが5つ重なっているところに3と指定すると、3番目の後ろ・・・つまり、4番目)に重なります。この引数には特別な指定があり、サンプルと同様の定数HWND_TOPMOSTで示した-1を指定することで最前面に表示されるようになります。
第3引数X、第4引数Yはウィンドウの位置を指定します。なお、Xは左端、Yについては上端が0となります。
第5引数cx、第6引数cyはウィンドウのサイズを指定します。cxが幅、cyが高さです。この2つのパラメータに関しては、Unity内部でScreen.widthやScreen.heightなどのScreenクラスでも参照できることから無理に利用する必要はないかもしれません。
第7引数uFlagsは、SetWindowPos関数に用意されたいくつかの特殊なフラグを好きに設定できるというものです。詳しくはコチラ。今回のサンプルで用いた変更値の制御のほか、Windowの表示、非表示(最小化ではない)なども行うことが可能です。
常にゲームウィンドウを最前面にもってくる
前述の処理は、「ウィンドウのサイズや位置だけでなくz順序の変更も行える」というものでしたが、瞬間的、1次的な変更が主な使い道になります。ゲームウィンドウを永続的に最前面に持っていきたい場合は、次のように処理を書き足す必要が生じます。(再定義は割愛します)
const int HWND_TOPMOST = -1;
const int SWP_NOSIZE = 0x0001;
const int SWP_NOMOVE = 0x0002;
const int GWL_EXSTYLE = -20;
const int WS_EX_TOPMOST = 0x00000008;
public void BringWindowToTopAlways()
{
IntPtr hWnd = GetCurrentWindow();
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
exStyle |= WS_EX_TOPMOST;
SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle);
}
完成稿
ここまでの内容をひとつのファイルにまとめ、若干の手を加えたものを以下に掲載します。Hierarchy上の任意のGameObjectにコンポーネントとしてアタッチして色を指定することで、ゲーム開始時に即刻次の処理を行います。なお、枠の表示を消してしまう都合上、タイトルバーのボタン操作は含んでいません。
- ウィンドウの枠とタイトルバーを非表示
- 指定された色を透明化
- 常に最前面に表示する
public class GameWindowController : MonoBehaviour
{
[DllImport("user32.dll", EntryPoint = "GetActiveWindow")]
private static extern IntPtr GetCurrentWindow();
[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
private static extern bool SetLayeredWindowAttributes(IntPtr hwnd, int crKey, byte bAlpha, int dwFlags);
[DllImport("user32.dll")]
private static extern int SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, int uFlags);
const int GWL_STYLE = -16;
const int WS_CAPTION = 0x00C00000;
const int GWL_EXSTYLE = -20;
const int WS_EX_LAYERD = 0x00080000;
const int WS_EX_TOPMOST = 0x00000008;
const int LWA_COLORKEY = 1;
const int HWND_TOPMOST = -1;
const int SWP_NOSIZE = 0x00000001;
const int SWP_NOMOVE = 0x00000002;
[SerializeField] private Color rgbColor;
public void Awake()
{
ClearWindowFrame();
MakeWindowTransparent();
BringWindowToTopAlways();
}
public void ClearWindowFrame()
{
IntPtr hWnd = GetCurrentWindow();
int style = GetWindowLong(hWnd, GWL_STYLE);
style &= ~WS_CAPTION;
SetWindowLong(hWnd, GWL_STYLE, style);
}
public void MakeWindowTransparent()
{
int r = Mathf.RoundToInt(rgbColor.r * 255);
int g = Mathf.RoundToInt(rgbColor.g * 255);
int b = Mathf.RoundToInt(rgbColor.b * 255);
int bgrColor = (b << 16) | (g << 8) | r;
IntPtr hWnd = GetCurrentWindow();
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
exStyle |= WS_EX_LAYERD;
SetWindowLong(hWnd,GWL_EXSTYLE, exStyle);
SetLayeredWindowAttributes(hWnd, bgrColor, 0, LWA_COLORKEY);
}
public void BringWindowToTopAlways()
{
IntPtr hWnd = GetCurrentWindow();
int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE);
exStyle |= WS_EX_TOPMOST;
SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
SetWindowLong(hWnd, GWL_EXSTYLE, exStyle);
}
}
終わりに
ゲームウィンドウの制御はUnityとC#から飛び出す部分も多く、UnityEditor上でデバッグしにくいことも重なり、情報を精査するのになかなか難儀しました。当記事で同じようなことにチャレンジする方の不安やわからないが少しでも減れば幸いです。トラップ多すぎぃ・・・。
そういえば、このウィンドウ制御処理に触れた時期自体は2024年の11月~12月位だったのですが、タイムリーなことにインフィニットループ様からDesktop Mateなどの発表があり、デスクトップアクセサリーの作り方やウィンドウの透過処理などに偶然注目が集まっていてとても面白かったです。
皆さんも是非デスクトップアクセサリー作ってみてください☝️
Discussion