以前さらっと書いたNeonFrame(https://github.com/Pctg-x8/NeonFrame )の焼きまわしというか詳細解説です。前半はDirectCompositionとWindowsの半透明ウィンドウの歴史についてさらっと触れて、後半ではこんな感じのものを作る予定です。
ウィンドウ上部がわかりやすいかと思いますが枠線がうっすらと光っています。これを作ります。
DirectCompositionについて
DirectCompositionとは、先頭にDirectとついているとおりDirectXに関係したWindows 8(7 SP1)以降で利用できるAPI群です。これを使うことにより、GPUを利用してビットマップの合成処理を高速に行うことが可能となります。また、合成をするだけでなく、同時に回転や拡大などの処理も行うことが可能で、半透明合成ももちろん可能で、うまく使えばゲームのHUDオーバーレイをこれだけで作ることも可能です。Windows 10になってから同時にブラーやアフィン変形などといったビットマップエフェクトを同時に掛けることも可能になって表現力が上がりました。
DirectCompositionが強力である所以はビットマップの合成にとどまらず、高品質なアニメーション機能を有することにあります。DirectCompositionを使用することで、ネイティブアプリでもCSS3やXAMLで実現できるような高品質かつ複雑なアニメーションやトランジションを、タイマーや割り込みの管理など煩わしい手順を経ることなく実装することが可能です。
DirectCompositionはいわゆるRetained ModeのAPIであり、メインループなどで毎回関数を呼び出すなどといった処理は必要なく大変扱いやすくなっています。
DirectComposition以前の特殊ウィンドウ
DirectCompositionが登場してトップレベルウィンドウで半透明合成が可能になりましたが、以前はこういったウィンドウは作れなかったのかというと別にそういうわけでもありません。写真は矩形でしたが、このセクションでは少し拡大して非矩形ウィンドウについて歴史を見ていきます。
古くはWindows95、SetWindowRgn
という関数がありました。これはウィンドウにリージョンと呼ばれる、ある図形を表すオブジェクト(今で言うとsvgが近い存在かもしれません)を設定することでその範囲だけ見えるウィンドウにするといったものです。単純にクリッピングしているだけなので大昔のPC程度のスペックでもそこそこ動かせた半面、アンチエイリアスをはじめとする半透明の表現はできませんでした。
Windows2000になってから本格的に半透明なウィンドウのサポートが始まりました。公式にはレイヤードウィンドウと呼ばれています1。Windows2000およびXPではマウスカーソルに影がついていますが、あれがレイヤードウィンドウを応用したものとなっています。レイヤードウィンドウを取り扱うAPIは大きく二つあって、ひとつはSetLayeredWindowAttributes
、もう一つはUpdateLayeredWindow
です。前者は扱いやすい反面、カラーキー(透明色)かウィンドウ全体の透明度の設定しかできません。後者は透明度情報(アルファチャンネル)を持つDIBオブジェクトを作る必要があるものの、ピクセル単位での半透明合成を指定できるのでかなり近代的なウィンドウを作ることが可能です。
Windows Vistaではついにシステムにおけるウィンドウの合成処理がDirectX(GPU)ベースになりました2。これにより、ウィンドウフレームのガラス効果を高パフォーマンスで実現するなどといったことが可能になり見た目におけるUXが格段に向上しました。
Vistaにおけるもう一つ重要な導入がWPF(Windows Presentation Foundation)です。現在のWindows Store Appの礎であるものですが、内部の描画処理がGDIではなくDirectXによるものとなっています。といってもVista以降ではGDIもDirectXを経由するようになったので全部DirectXで書かれているものといっても変わらないのですが。というのも、Vista以降でウィンドウの合成を行うDWMがGPUベースの処理となっているため、各アプリケーションもウィンドウの内容をVRAMに置かなければならず、つまりDirectXでの描画がほぼ必須となっているためです。
さて、ウィンドウの合成がDWMによって一元管理されることとなりました。ここでDWMによる合成機能の一部をAPIとしてくくりだしたもの、それがDirectCompositionです。
DirectCompositionとUpdateLayeredWindow、欠点と利点
UpdateLayeredWindow
でもピクセル単位の半透明合成は行えるのですが、DirectCompositionを使ったほうがずっと軽量です。というのはDirectCompositionがDWMに対してネイティブだから、で説明がつくと思います(他にも理由はあります)。また、UpdateLayeredWindow
ではピクセル情報のみを渡すのに対して、DirectCompositionはビットマップをどこに配置して、どのくらい拡大して、みたいな細かい情報も渡せるため柔軟です。
ただしDirectCompositionにも欠点があって、それは「透明部分のヒットテストが透過しない」ことです。UpdateLayeredWindow
による半透明合成はウィンドウのヒットテストにも影響を及ぼし、つまり完全に透明と指定した部分はマウスでクリックしたとしても後ろにあるウィンドウにすり抜けます。DirectCompositionでは透明部分をクリックしてもテストがすり抜けません。これは機能面では明らかに欠点ではあるのですが、パフォーマンスの面では利点となります。UpdateLayeredWindow
の場合はヒットテストを行うピクセルが透明であるかどうかの情報が必要となりますが、そのためにはウィンドウのピクセルの内容をメインメモリにコピーしてくる必要があります。ご存知の通りGPUとCPU間のデータ転送はかなり遅いため、UpdateLayeredWindow
を使った合成は重くなります。DirectCompositionは合成する内容を指定したらあとはDWM/GPUに任せっぱなしにできるのでかなり軽量です。
Windowsの最高のデスクトップAPIを駆使してあまり手間をかけずに光るウィンドウを作ってみた
たぶん手間はかかってないはず。
環境はWindows 10 build 15063.726、言語はC++です。
メインはDirectCompositionなのでそれ以外の部分は飛ばし飛ばしで解説していきます。まずはコンポジションエンジンの初期化部分から
#include <windows.h>
#include <windowsx.h>
#include <stdexcept>
#include <memory>
#include <d3d11.h>
#include <d2d1_1.h>
#include <dcomp.h>
#pragma comment(lib, "d3d11")
#pragma comment(lib, "d2d1")
#pragma comment(lib, "dcomp")
#include <comdef.h>
#include <wrl.h>
template<typename T> using ComPtr = Microsoft::WRL::ComPtr<T>;
class RenderDevice final
{
ComPtr<ID3D11Device> d3;
ComPtr<ID2D1Device> d2;
public:
RenderDevice()
{
auto hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION,
&this->d3, nullptr, nullptr);
if (FAILED(hr)) throw _com_error(hr);
ComPtr<IDXGIDevice> pdx; hr = this->d3.As(&pdx);
if (FAILED(hr)) throw _com_error(hr);
const auto cp = D2D1::CreationProperties(D2D1_THREADING_MODE_SINGLE_THREADED, D2D1_DEBUG_LEVEL_ERROR, D2D1_DEVICE_CONTEXT_OPTIONS_ENABLE_MULTITHREADED_OPTIMIZATIONS);
hr = D2D1CreateDevice(pdx.Get(), cp, &this->d2);
if (FAILED(hr)) throw _com_error(hr);
}
auto& device2() const { return this->d2; }
};
class Composition final
{
ComPtr<IDCompositionDesktopDevice> d;
ComPtr<IDCompositionDevice3> fx_factory;
public:
Composition(const RenderDevice& rd)
{
auto hr = DCompositionCreateDevice3(rd.device2().Get(), __uuidof(*this->d.Get()), &this->d);
if (FAILED(hr)) throw _com_error(hr);
hr = this->d.As(&this->fx_factory);
if (FAILED(hr)) throw _com_error(hr);
}
auto& device() const { return this->d; }
auto& effect() const { return this->fx_factory; }
};
先頭のincludeはWindowsとDirectX、それからunique_ptr
やstd::exception
を使うためのものとなります。DirectXを使っている以上クロスプラットフォームなコードにはできないし、他に組み込まれることもないただのアプリケーションコードなのでガンガン例外を使う方針でいきます。
頭のほうにあるincludeからusingまでの3行はCOM用のスマートポインタとCOMの例外クラスを使うためのものです。ComPtr(=Microsoft::WRL::ComPtr)
と_com_error
が利用できるようになります。
DirectCompositionの初期化はComposition
クラスだけです。DirectCompositionではDCompositionCreateDevice3
を使ってデバイスを初期化します。ほかに無印と2もあるのですが、無印はまず使わないのと、今回はエフェクトも利用したいので最新バージョンの3を使用しています。バージョン3はWindows 10 Anniversary Update(build 14393)以降で利用できます。
インターフェイスの受け取りにはIDCompositionDesktopDevice
を使用します。そののち、IDCompositionDevice3
インターフェイスを取得します。前者はデスクトップにおけるコンポジションに必要なオブジェクトの生成を、後者はバージョン3で新規に追加されたエフェクトオブジェクトの生成を担います。
RenderDevice
クラスは何をしているかというと、描画用のDirect2Dデバイスを初期化しています。DirectCompositionはビットマップの合成のみを行うので描画は別の機構が必要になります。Direct2Dを利用してDirectCompositionが用意するサーフェイスに描くためにはID2D1Device
が必要になるのですが、それを作るにはIDXGIDevice
が必要、つまりDXGIが初期化済みである必要があります。DXGIを直接初期化する方法はないのでDirect3Dを経由して作ります。
つづいてウィンドウの外見に関するクラスです。
const unsigned Margin = 8;
class AppWindowAppearance final
{
ComPtr<IDCompositionTarget> target;
ComPtr<IDCompositionScaleTransform> backface_scaler;
ComPtr<IDCompositionVirtualSurface> border_surface;
public:
AppWindowAppearance(const Composition& comp, HWND target)
{
HRESULT hr;
ComPtr<IDCompositionVisual2> vroot, vbf, vbb, vbb_glow2, vbb_glow4;
ComPtr<IDCompositionSurface> sbf;
ComPtr<IDCompositionGaussianBlurEffect> blur2, blur4;
ComPtr<IDCompositionBlendEffect> ablend2, ablend4;
// construct a composition target and the base visual
hr = comp.device()->CreateTargetForHwnd(target, false, &this->target); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateVisual(&vroot); if (FAILED(hr)) throw _com_error(hr);
hr = this->target->SetRoot(vroot.Get()); if (FAILED(hr)) throw _com_error(hr);
// construct backface
hr = comp.device()->CreateVisual(&vbf); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateSurface(1, 1, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_ALPHA_MODE_PREMULTIPLIED, &sbf); if (FAILED(hr)) throw _com_error(hr);
hr = vbf->SetContent(sbf.Get()); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateScaleTransform(&this->backface_scaler); if (FAILED(hr)) throw _com_error(hr);
hr = vbf->SetTransform(this->backface_scaler.Get()); if (FAILED(hr)) throw _com_error(hr);
// construct border
hr = comp.device()->CreateVisual(&vbb); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateVisual(&vbb_glow2); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateVisual(&vbb_glow4); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->CreateVirtualSurface(128, 128, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_ALPHA_MODE_PREMULTIPLIED, &this->border_surface); if (FAILED(hr)) throw _com_error(hr);
hr = vbb->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow2->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow4->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);
// construct effects
hr = comp.effect()->CreateGaussianBlurEffect(&blur2); if (FAILED(hr)) throw _com_error(hr);
hr = comp.effect()->CreateGaussianBlurEffect(&blur4); if (FAILED(hr)) throw _com_error(hr);
hr = comp.effect()->CreateBlendEffect(&ablend2); if (FAILED(hr)) throw _com_error(hr);
hr = comp.effect()->CreateBlendEffect(&ablend4); if (FAILED(hr)) throw _com_error(hr);
hr = blur2->SetStandardDeviation(2.0f); if (FAILED(hr)) throw _com_error(hr);
hr = blur4->SetStandardDeviation(4.0f); if (FAILED(hr)) throw _com_error(hr);
hr = ablend2->SetMode(D2D1_BLEND_MODE_LINEAR_BURN); if (FAILED(hr)) throw _com_error(hr);
hr = ablend4->SetMode(D2D1_BLEND_MODE_LINEAR_BURN); if (FAILED(hr)) throw _com_error(hr);
hr = ablend2->SetInput(0, blur2.Get(), 0); if (FAILED(hr)) throw _com_error(hr);
hr = ablend4->SetInput(0, blur4.Get(), 0); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow2->SetEffect(blur2.Get()); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow4->SetEffect(blur4.Get()); if (FAILED(hr)) throw _com_error(hr);
// construct visual tree
hr = vroot->AddVisual(vbf.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
hr = vroot->AddVisual(vbb.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
hr = vroot->AddVisual(vbb_glow2.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
hr = vroot->AddVisual(vbb_glow4.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
hr = vbf->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbf->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
hr = vbb->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow2->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb_glow2->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
hr = vbb_glow4->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb_glow4->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
// initialize backface pixels
ComPtr<ID2D1DeviceContext> c; POINT o;
hr = sbf->BeginDraw(nullptr, IID_ID2D1DeviceContext, &c, &o); if (FAILED(hr)) throw _com_error(hr);
c->Clear(D2D1::ColorF(0x99ccff, 0.25f));
hr = sbf->EndDraw(); if (FAILED(hr)) throw _com_error(hr);
hr = comp.device()->Commit(); if (FAILED(hr)) throw _com_error(hr);
}
void resize(const SIZE& size)
{
HRESULT hr;
SIZE sz = { size.cx - Margin * 2, size.cy - Margin * 2 };
hr = this->backface_scaler->SetScaleX(sz.cx); if (FAILED(hr)) throw _com_error(hr);
hr = this->backface_scaler->SetScaleY(sz.cy); if (FAILED(hr)) throw _com_error(hr);
hr = this->border_surface->Resize(sz.cx, sz.cy); if (FAILED(hr)) throw _com_error(hr);
// update border pixels
ComPtr<ID2D1DeviceContext> c; POINT o;
ComPtr<ID2D1SolidColorBrush> b;
hr = this->border_surface->BeginDraw(nullptr, IID_ID2D1DeviceContext, &c, &o); if (FAILED(hr)) throw _com_error(hr);
c->Clear(D2D1::ColorF(0x00, 0.0f));
hr = c->CreateSolidColorBrush(D2D1::ColorF(0x66aaff), &b); if (FAILED(hr)) throw _com_error(hr);
c->DrawRectangle(D2D1::RectF(o.x, o.y, o.x + sz.cx, o.y + sz.cy), b.Get(), 4.0f);
hr = this->border_surface->EndDraw(); if (FAILED(hr)) throw _com_error(hr);
}
};
コンポジションには、最低でも「どこにどのビジュアルをコンポジションするのかを表すIDCompositionTarget
」「一番基礎となるIDCompositionVisual2
」が必要となります。ビジュアルとはサーフェイス(ビットマップ)をどのように合成するかを表すオブジェクトで、ツリーを構成します。子となるビジュアルは親のビジュアルのオフセットやその他変形情報に影響されます。
construct a composition target and the base visualの部分で生成とルートの登録を行っています。今回は背景のビジュアルを別途伸縮したいので別途用意していますが、ルートのビジュアルがサーフェイスを持っていてもかまいません。
背景は今回は単色なので、1pxのサーフェイスを作ってそれを拡大する実装にしています。後述するIDCompositionVirtualSurface
で毎回描き直してもいいですが、描き直す分のコストがかかるのと、VirtualSurfaceはタイルベースのサーフェイスでVRAM上で必ずしも連続であるとは限らないので、要するにキャッシュ効率が悪化します。またタイルの新規割り当てにもコストがかかるので、VirtualSurfaceの利用はそこそこ慎重であるべきです。
枠線は一つのサーフェイスを複数のビジュアル(通常、グロー、グロー+)で共有しています。コンポジション時にエフェクトをかけられるので、あらかじめエフェクトをかけたサーフェイスが必要ないというとこでメモリの大幅な節約ができます。
エフェクトとしてはガウスブラーと加算合成を用意しています。エフェクトは一種のツリーを形成することが可能で、複数のエフェクトをまとめて掛けることが可能です。
最後にビジュアルをルートに追加して位置を設定しておしまいです。
リサイズでは、背景は単純に拡大率を変更するだけでいいとして枠線は新たに描き直す必要があります。IDCompositionSurface::BeginDraw
からIDCompositionSurface::EndDraw
までがDirect2Dの描画コードです。ID2D1DeviceContext::EndDraw
は呼び出す必要はありません。
最後にエントリポイントとAppクラスです。
class App final
{
HWND w;
std::unique_ptr<Composition> comp;
std::unique_ptr<AppWindowAppearance> appearance;
SIZE currentSize;
void init()
{
WNDCLASSEX wce{};
wce.cbSize = sizeof wce;
wce.hInstance = GetModuleHandle(nullptr);
wce.lpszClassName = "com.cterm2.NeonFrame2.app";
wce.lpfnWndProc = App::WndProc;
wce.style = CS_OWNDC;
if (RegisterClassEx(&wce) == 0) throw std::exception("RegisterClassEx failed");
this->w = CreateWindowEx(WS_EX_APPWINDOW | WS_EX_NOREDIRECTIONBITMAP, wce.lpszClassName, "NeonFrame",
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, wce.hInstance, nullptr);
if (this->w == nullptr) throw std::exception("CreateWindowEx failed");
}
void resize(const SIZE& newsize)
{
this->currentSize = newsize;
this->appearance->resize(newsize);
auto hr = this->comp->device()->Commit(); if (FAILED(hr)) throw _com_error(hr);
}
LRESULT hittest(POINT p2)
{
MapWindowPoints(nullptr, this->w, &p2, 1);
const auto left = 0 <= p2.x && p2.x <= Margin, top = 0 <= p2.y && p2.y <= Margin;
const auto right = this->currentSize.cx - Margin <= p2.x && p2.x <= this->currentSize.cx;
const auto bottom = this->currentSize.cy - Margin <= p2.y && p2.y <= this->currentSize.cy;
if (top) return left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP;
if (bottom) return left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM;
return left ? HTLEFT : right ? HTRIGHT : HTCAPTION;
}
static LRESULT CALLBACK WndProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_DESTROY: PostQuitMessage(0); return 0;
case WM_SIZE: instance().resize({ LOWORD(lp), HIWORD(lp) }); break;
case WM_NCCALCSIZE: return 0;
case WM_NCHITTEST: return instance().hittest({ GET_X_LPARAM(lp), GET_Y_LPARAM(lp) });
}
return DefWindowProc(w, msg, wp, lp);
}
public:
static App& instance() { static App o; return o; }
int run()
{
this->init();
auto rd = RenderDevice();
this->comp = std::make_unique<Composition>(rd);
this->appearance = std::make_unique<AppWindowAppearance>(*this->comp, this->w);
ShowWindow(this->w, SW_SHOWNORMAL);
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg); }
return msg.wParam;
}
};
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { return App::instance().run(); }
WM_NCCALCSIZE
で何もせずに帰ることでウィンドウフレームを消しています。また、WM_NCHITTEST
およびApp::hittest
で非クライアント領域のヒットテストを上書きしています。コンポジション側でウィンドウの周囲をMargin
pxずつ開けるようにしてあり、そこでウィンドウサイズを変更できるようにしています。
DirectCompositionにおいて各種パラメータを変更したあとはIDCompositionDevice::Commit
を呼び出す必要があります。これによって加えられた変更がすべて適用されます。
ここで示したコードは解説用にベタめに書いているので少し汚いですが、本番ではCOMのエラー処理などを極力隠蔽した専用クラスを用意することをお勧めします。
おまけ: ウィンドウの背景をぼかす
Windows 10の隠しAPIです。詳しいことはRafael Rivera氏の記事(https://withinrafael.com/2015/07/08/adding-the-aero-glass-blur-to-your-windows-10-apps/ )を見てもらうことにして、とりあえず次のコードを追加することで動きます。
// AppWindowAppearanceに追加
void enable_behind_blur(HWND target)
{
struct AccentPolicy { DWORD state; LONG flags, gradient, animationid; };
struct WINCOMPATTRDATA { DWORD attribute; PVOID pData; ULONG dataSize; };
auto lib = LoadLibrary("user32.dll");
auto fptr = reinterpret_cast<BOOL(WINAPI *)(HWND, WINCOMPATTRDATA*)>(GetProcAddress(lib, "SetWindowCompositionAttribute"));
if (fptr == nullptr)
{
FreeLibrary(lib);
throw std::exception("SetWindowCompositionAttribute was not found.");
}
// ACCENT_ENABLE_BLURBEHIND = 3
AccentPolicy policy = { 3, 0, 0, 0 };
// ACCENT_POLICY = 19
WINCOMPATTRDATA params = { 19, reinterpret_cast<PVOID>(&policy), sizeof AccentPolicy };
fptr(target, ¶ms);
FreeLibrary(lib);
}
// ShowWindow前に追加
this->appearance->enable_behind_blur(this->w);
ただし、微妙に残念な結果になります。
上のほうにある2つのアイコンを見ていただければわかると思いますが、思っていたより外側にまでブラーが掛かってしまっています。まあこのエリアも同じウィンドウなので致し方ないのですが。これを解決するにはグロー部分を別のウィンドウにする以外に方法はないと思います。
出典など
- 現在Windowsの中の人による記事。サーフェイスに直接描画するのではなくてスワップチェーンを経由して高速に再描画できる方法を解説しています: https://msdn.microsoft.com/ja-jp/magazine/dn745861.aspx
- Visual Studio 2012はグローが別のウィンドウらしい: http://grabacr.net/archives/507
- かわいい猫ちゃんの壁紙はこちら: https://support.microsoft.com/en-us/help/13864/windows-desktop-themes-animals