Edited at

Windows上のwxWidgetsで半透明描画をする


はじめに

 Windows環境でwxWidgetsを使って半透明描画をするために、いくつか試行錯誤して知見を得たので、ここにまとめておきます。(もっといい方法があったら情報ください)1

 この記事で参照する wxWidgets のバージョンは現在の最新リリースバージョンである wxWidgets 3.1.2 です。環境は Windows 10 です。別のバージョンの wxWidgets や Windows を使用した場合は結果が異なる可能性があるのでご注意ください。

 さて、 wxWidgets には半透明描画の仕組みがいくつか用意されているのですが、現段階ではWindows 向けの実装はそのあたりのサポートがあまり十分ではありません。(一方、Mac向けの実装はそのあたりが十分サポートされていて、あまり難しく考える必要がないので楽です)

 Windows環境で半透明描画をするには、以下のように対処します。


Windows環境での半透明描画の基本

 wxWidgets では描画APIとして wxDC クラス(とその派生クラス)が用意されています。ただし公式のドキュメントにもある通り、一般的に wxDC クラスの派生クラスの多くは、半透明の描画処理をサポートしていません。

 アルファ値が設定された半透明な描画色で描画を行うには、 wxDC クラスよりもリッチな描画APIを提供する wxGraphicsContext クラスを利用するか、それを内部で保持する wxGCDC クラスを使う必要があります。wxGCDC クラスは wxDC クラスの派生クラスで、内部で wxGraphicsContext クラスを使用するように実装されています。このクラスを利用すると wxGraphicsContext クラス用のAPIを意識することなく、wxDCクラスのインターフェースで描画用のコードを書けるようになっています。

 wxGraphicsContext クラスは、Windows 環境では内部の描画エンジンに GDI+ を使用していて、それによって半透明描画をサポートしています。2


ウィンドウの透過

 上記のクラスを使うことで、Windows環境でも半透明描画ができるようになりますが、それだけではウィンドウの背景色については半透明にできません。つまり、ウィンドウの不透明な背景に半透明の図形を重ねて描画したりはできても、それをさらに別のウィンドウやデスクトップ上に透過させることはできません。

 Mac 環境では、ウィンドウの背景を透過する仕組みが wxWidgets でサポートされています。そのような環境では、SetBackgroundStyle() メンバ関数に wxBG_STYLE_TRANSPARENT をセットすることで、ウィンドウの背景を透明にして描画結果を透過させられます。 3 4

 一方で Windows 環境では、ウィンドウの背景を透過する仕組みが wxWidgets でサポートされていないため、この方法は使用できません。

 Windows 環境でウィンドウの背景を透過するには、以下のような手順で Win32 API を直接呼び出す必要があります。5 6


  1. Win32 APIの SetWindowLong() 関数を呼び出して、ウィンドウをレイヤードウィンドウにする


    1. 具体的にはウィンドウの拡張スタイルに WS_EX_LAYERED オプションを追加する



  2. Win32 API の UpdateLayeredWindow() 関数を呼び出して、描画結果をレイヤードウィンドウ上に反映する

 具体的な手順は下記のサンプルコードを参照してください。


サンプルコード

サンプルコードを以下に載せます。このサンプルは、デスクトップの中央に左右に揺れる水色の半透明な円を描画します。(数回往復したあとで自動で終了します)


transparent-top-level-window.cpp

#include <wx/wx.h>


class MyFrame
: public wxFrame
{
// オフスクリーンレンダリングを行うためのバッファ
// レイヤードウィンドウに対しては、wxDCクラスを利用して直接描画することはできないので、
// 一度ビットマップ画像として描画してから、その内容をレイヤードウィンドウに転送することになる
wxBitmap back_buffer_;

// 定期的に再描画を行うためのタイマー
wxTimer timer_;

// サンプル描画処理のパラメータ
double phase_ = 0;
int xpos_ = 0;
int ypos_ = 0;

public:
MyFrame(wxWindow *parent = nullptr, wxWindowID id = wxID_ANY,
wxPoint pos = wxDefaultPosition,
wxSize size = wxSize(400, 400)
)
{
#if defined(_MSC_VER)
Create(parent, id, "Transparent Sample", pos, size,
// ウィンドウ全体を透過させたいので、枠は表示しない。
wxBORDER_NONE);

// GetHWND()メンバ関数でWin32 APIのウィンドウハンドルを取得して
// ウィンドウの拡張スタイルにWS_EX_LAYEREDを追加する
DWORD const ex_style = GetWindowLong(GetHWND(), GWL_EXSTYLE);
SetWindowLong(GetHWND(), GWL_EXSTYLE, ex_style | WS_EX_LAYERED);
#else
// Mac環境では、バックグラウンドスタイルに `wxBG_STYLE_TRANSPARENT` を設定する
SetBackgroundStyle(wxBG_STYLE_TRANSPARENT);
Create(parent, id, "Transparent Sample", pos, size,
// ウィンドウ全体を透過させたいので、枠は表示しない
wxBORDER_NONE);
#endif

// PAINTイベントに対してOnPaint()メンバ関数を呼び出すように設定する。
Bind(wxEVT_PAINT, [this](wxPaintEvent &) { OnPaint(); });

CenterOnScreen();
xpos_ = size.x / 2;
ypos_ = size.y / 2;

// アルファ値を有効にして back_buffer_ を初期化する
// ここで wxImage を使用せずにwxBitmapを `wxBitmap(size, 32);` のようにして初期化すると、
// ビット深度は32bitになってもアルファ値が有効にならず、
// プラットフォームや描画方法によって半透明描画ができないケースがある。
// そのためここでは アルファ値を有効にしたwxImageからwxBitmapを構築している。
// あるいは、wxBitmap::UseAlpha()メンバ関数を使ってアルファ値を有効にするというシンプルな方法もあるが、
// これは undocumented な関数なので、これを使ってしまうのはあまり行儀が良くないかもしれない。
wxImage img = wxImage(size);
img.SetAlpha();
back_buffer_ = wxBitmap(img, 32);

// 再描画をタイマーでリクエストする
timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent &) { Refresh(); });
timer_.Start(10);
}

void OnPaint()
{
// 適当な図形を描画
Render();

// 描画結果からwxMemoryDCを作成
wxMemoryDC memory_dc(back_buffer_);
wxPaintDC pdc(this);

#if defined(_MSC_VER)
// wxMemoryDC上の半透明描画の内容を、レイヤードウィンドウに転送する設定
// 詳しい設定値はMSDNを参照すること。
BLENDFUNCTION bf;
bf.BlendOp = AC_SRC_OVER;
bf.BlendFlags = 0;
// この値を0に近づけると、描画結果がより透明になる。
// back_buffer_の透明度をそのまま加工せず扱う場合は、このは255のままにする。
bf.SourceConstantAlpha = 255;
bf.AlphaFormat = AC_SRC_ALPHA;

HWND hwnd = GetHWND();

// wxMemoryDCとwxPaintDCはそのまま使わずに、HDCを取得する
HDC src_hdc = memory_dc.GetHDC();
HDC dest_hdc = pdc.GetHDC();

POINT pt_src{0, 0};
SIZE size{GetClientSize().GetWidth(), GetClientSize().GetHeight()};

// wxBitmap上の半透明描画の内容を、レイヤードウィンドウに転送する
// (UpdateLayeredWindowを使わずにwxPaintDCのメソッドを
// 直接を使って描画しても、その内容は表示されない)
UpdateLayeredWindow(hwnd, dest_hdc, nullptr, &size, src_hdc, &pt_src, 0, &bf, ULW_ALPHA);
#else
pdc.Blit(wxPoint(), GetClientSize(), &memory_dc, wxPoint());
#endif
}

void Render()
{
// back_buffer_に書き込むためにwxMemoryDCを作成
wxMemoryDC memory_dc(back_buffer_);

// 実際の描画処理には、wxMemoryDCを直接使用するのではなく、
// そこから wxGCDC クラスを作成して使用する。
wxGCDC dc(memory_dc);

// 透明なブラシで back_buffer_ を上書きして、前回の描画内容をリセットする。
// ここで wxTRANSPARENT_BRUSH からブラシ取得して使用しても、以下の理由で描画内容はクリアされない。
// * wxTRANSPARENT_BRUSH から取得したブラシは、ブラシスタイルが wxBRUSHSTYLE_TRANSPARENT になる。
// * wxGCDCは、ブラシスタイルに wxBRUSHSTYLE_TRANSPARENT を設定したブラシを Clear() メンバ関数の中で無視する。
// そのためここでは色が透明でブラシスタイルが wxBRUSHSTYLE_SOLID なブラシを自分で作成して使用している。
// (ドキュメントによれば、wxTRANSPARENT_BRUSHのブラシスタイルは透明色で wxBRUSHSTYLE_SOLID になっているとのことだが、
// 実装側はそうなっていなくて、齟齬が生じている)
dc.SetBackground(wxBrush(wxTransparentColour));
dc.Clear();

// 以下、適当な内容を描画する
dc.SetPen(wxPen(wxColour(0, 255, 0, 128), 1));
dc.SetBrush(wxBrush(wxColour(0, 255, 255, 64)));

dc.DrawCircle(xpos_ + 40 * sin(phase_), ypos_, 50);
phase_ += 0.1;

// 適当な回数描画したら自動で終了するように
if(phase_ > 5 * 2 * 3.14) { Close(true); }
}
};

class MyApp : public wxApp
{
public:
MyApp() {}
~MyApp() {}

bool OnInit() override
{
MyFrame *frame = new MyFrame();
frame->Show(true);
this->SetTopWindow(frame);
return true;
}
};

wxIMPLEMENT_APP(MyApp);



終わりに

 現在の wxWidgets では、 Windows 環境での半透明サポートが不十分ですが、wxGraphicsContext/wxGCDC クラスと Win32 API を呼び出せば半透明描画が可能ということがわかりました。

 本当は公式でサポートが入ると嬉しいのですが、とりあえずはこれでなんとかなりそうです。





  1. ところで、最近はもう wxWidgets を使う人はあまりいないかもしれないですね。C++でリッチなクロスプラットフォームGUIフレームワークといえばQtがありますし、マルチメディアアプリケーション界隈では最近はJUCEというフレームワークがよく知られています。とはいえ、wxWidgetsはOSネイティブなUI要素が使いやすかったり、“wxWidgets Library Licence”というLGPLよりも緩いライセンスのもとで利用できるという特徴があるため、ユースケースによってはいまでも有用なものだと思います。 



  2. ところで、 Mac や GTK+ 3 などの環境では、 wxDC クラスの描画処理内部で自動的に wxGraphicsContext を使用する仕組みになっているので、これらの環境ではわざわざ wxGCDC クラスを使わなくても、 wxDC クラスをそのまま使うだけで半透明描画が可能です。とはいえ、 Windows 環境とそれ以外の環境でコードを共通化するのであれば、 wxGCDC クラスを中心に使うようにしておくのが楽そうです。 



  3. wxWidgets がいまの環境でウィンドウの背景を透過にする仕組みをサポートしているかどうかは、wxWindow::IsTransparentBackgroundSupported() メンバ関数を呼び出して判定できます。 



  4. ドキュメントにも記載があるように、wxBG_STYLE_TRANSPARENTを使用するときは、ウィンドウの作成処理は、バックグラウンドスタイルを変更したあとで行う必要があることに注意してください。 



  5. wxWidgets は Win32 API との親和性が高いので、wxWidgets を使ったコード上で Win32 API を透過的に扱うことが比較的簡単にできます。 



  6. 記事公開当初、「ウィンドウ作成時にwxTRANSPARENT_WINDOWフラグを渡す必要がある」と書いていたのですが、これは必要なさそうでした。(レイヤードウィンドウではこれを適用しなくても期待通り背景は塗りつぶされないようで、むしろこのフラグを指定してしまうと、このウィンドウに対するマウスイベントが完全に透過してしまうという弊害がありました)