Win32 アプリでスプリッターを動かしたとき、オーバーレイの赤い「X」マークだけが何重にも残ってしまう――そんな描画トラブルは、典型的な「背景を消していない」ことが原因です。本記事では、WS_EX_LAYERED を使ったレイヤードウィンドウでの残像問題の正体と、最小限の修正から本格的な設計見直しまで、実践的な対策を詳しく解説します。
スプリッター移動で起きるレイヤードオーバーレイの残像問題とは
今回のケースは、Win32 アプリで上下分割(スプリッター)された UI の上ペインに、半透明のオーバーレイウィンドウを重ね、その上に赤い「X」印を描画している状況です。オーバーレイには WS_EX_LAYERED が指定されており、マウス操作などでスプリッターをドラッグして上下サイズを変更すると、次のような現象が発生します。
- オーバーレイ自体の位置やサイズは動いている
- しかし、古い位置に描かれていた「X」が消えず、何重にも残像が残る
- InvalidateRect などで再描画を促しても、期待通りに消えてくれないように見える
まずは現象を整理しておきましょう。
| 項目 | 内容 |
|---|---|
| 対象アプリ | Win32 アプリケーション(C / C++) |
| UI 構成 | 上下スプリッターで分割されたペイン構成 |
| オーバーレイ | 上ペイン上に重ねられた WS_EX_LAYERED ウィンドウ |
| 描画内容 | 赤い「X」印(クロスマーク)と枠線など |
| 問題 | スプリッターをドラッグすると古い「X」が消えず残像化する |
実際には GDI の基本ルールを1つ守るだけで解決できますが、レイヤードウィンドウや UpdateLayeredWindow を併用していると原因が見えにくくなりがちです。次の章で「なぜ消えないのか」を分解して見ていきます。
原因:WM_PAINT で背景を一度も消していない
残像の直接的な原因は、WM_PAINT の中で過去フレームの描画内容を消していないことにあります。
典型的な問題の組み合わせは次の通りです。
- ウィンドウクラスの
hbrBackgroundがNULLに設定されている WM_ERASEBKGNDをハンドルしていないWM_PAINT内でもFillRect等による背景クリアを行っていない
この状態で InvalidateRect(hwnd, NULL, TRUE) のように「背景も消してね(第3引数 TRUE)」と指定しても、実際には OS が使うべき背景ブラシが存在しないため、何も消えないままになってしまいます。
| 状態 | 期待される動き | 実際に起きていること |
|---|---|---|
InvalidateRect(..., TRUE) | 背景が塗りつぶされてから再描画される | hbrBackground = NULL のため、何も消されずに前フレームの内容が残る |
WM_ERASEBKGND 未実装 | OS が背景を自動で消すか、アプリ側で消す | どちらも行われないのでオーバーレイのバッファに古い内容が積み重なる |
WM_PAINT 内の描画 | 新しいフレームだけが表示される | 古いフレームの上に新しい「X」が描かれるだけで、過去の線が残り続ける |
レイヤードウィンドウは内部的にメモリ上のビットマップを持っています。背景をクリアしないまま「X」の線だけを毎回描き足していくと、そのビットマップ上に線が積み重なっていき、結果としてスプリッターを動かした経路に沿って X が無数に残るように見えるわけです。
最小の解決策:WM_PAINT の最初で全面 FillRect する
もっとも簡単で確実な対処は「WM_PAINT の冒頭でクライアント領域を全て塗りつぶしてから X を描く」ことです。
オーバーレイのウィンドウプロシージャが OverlayProc だとすると、WM_PAINT を次のように修正します。
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
// 背景クリア(任意の色でOK)
HBRUSH hBrush = CreateSolidBrush(RGB(55, 55, 55)); // 例:濃いグレー
FillRect(hdc, &rc, hBrush);
DeleteObject(hBrush);
// 赤い「X」を描画
HPEN hPen = CreatePen(PS_SOLID, 1, RGB(255, 0, 0));
HPEN hOldPen = (HPEN)SelectObject(hdc, hPen);
MoveToEx(hdc, rc.left, rc.top, nullptr);
LineTo (hdc, rc.right, rc.bottom);
MoveToEx(hdc, rc.right, rc.top, nullptr);
LineTo (hdc, rc.left, rc.bottom);
// 必要なら枠線などもここで描画
SelectObject(hdc, hOldPen);
DeleteObject(hPen);
EndPaint(hwnd, &ps);
return 0;
}
ポイントは、「X」を描く前に必ず FillRect などで全面を塗りつぶしていることです。こうすることで、毎フレーム、前のフレームの内容は完全に消され、残像は発生しなくなります。
| 修正前 | 修正後 |
|---|---|
| 過去の「X」の上に新しい「X」を描いているだけ | 毎回背景をクリアしてから「X」を描き直す |
| スプリッター移動の軌跡に沿って多重表示 | 常に最新位置の「X」だけが表示される |
InvalidateRect の TRUE に期待している | アプリ側で確実に背景クリアを実行 |
「とにかく今すぐ残像を消したい」という局面では、この修正だけでも十分実用的です。
WM_ERASEBKGND を実装して背景消去を任せる方法
より「Win32 的な作法」に沿うなら、WM_ERASEBKGND を自前で処理して、そこに背景クリア処理を集約する方法がおすすめです。hbrBackground を使わない場合でも、WM_ERASEBKGND をハンドルすれば OS に「背景はもう消したよ」と伝えることができます。
例としては次のようになります。
case WM_ERASEBKGND: {
HDC hdc = (HDC)wParam;
RECT rc;
GetClientRect(hwnd, &rc);
// 背景を任意色で塗りつぶす
FillRect(hdc, &rc, (HBRUSH)GetStockObject(BLACK_BRUSH));
return 1; // 背景消去は完了したと OS に伝える
}
この場合、WM_PAINT 側では「図形だけ描く」ようにしておけば、描画処理が役割分担されて分かりやすくなります。
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
// 背景は WM_ERASEBKGND 側で既に塗りつぶされている想定
HPEN hPen = CreatePen(PS_SOLID, 1, RGB(255, 0, 0));
HPEN hOldPen = (HPEN)SelectObject(hdc, hPen);
MoveToEx(hdc, rc.left, rc.top, nullptr);
LineTo (hdc, rc.right, rc.bottom);
MoveToEx(hdc, rc.right, rc.top, nullptr);
LineTo (hdc, rc.left, rc.bottom);
SelectObject(hdc, hOldPen);
DeleteObject(hPen);
EndPaint(hwnd, &ps);
return 0;
}
この構成にしておくと、他のウィンドウでも流用しやすくなり、ちらつき対策としても有効です。
| 方法 | メリット | デメリット |
|---|---|---|
| WM_PAINT 内で FillRect | 実装が簡単で既存コードに少し足すだけ | 背景クリアと描画が混在しやすい |
| WM_ERASEBKGND で背景クリア | 描画の役割分担が明確、他のウィンドウにも移植しやすい | メッセージ処理がやや増える |
UpdateLayeredWindow と WM_PAINT の二重描画を整理する
今回のように WS_EX_LAYERED を使っていると、多くのコードでは次のような構成になりがちです。
OverlayProc::WM_PAINTで GDI 描画を行う- 親側の
UpdateOverlayPositionのような関数で、UpdateLayeredWindowによる全面更新を行う
このような二重の描画経路は、残像や表示崩れの温床になります。理想的には、片方に処理を寄せて整理するのがおすすめです。
A案:レイヤード専用にして WM_PAINT では描かない
WS_EX_LAYERED を使うなら、いっそ「UpdateLayeredWindow だけで描画する」設計に振り切る方法があります。
- オフスクリーンの ARGB DIB(メモリ DC)を毎回クリアし、「X」を描画
- 描いた DIB を
UpdateLayeredWindowに渡してウィンドウ全体を更新 WM_PAINTは単にBeginPaint/EndPaintするだけ、もしくは何もしない
この構成にすると、「描画=DIB に描く」「表示=UpdateLayeredWindow で反映する」という二段階に分かれ、動作が非常に読みやすくなります。
B案:通常の子ウィンドウに戻し、WM_PAINT のみで描画
もしレイヤードウィンドウを使う理由が「半透明にしたいだけ」であれば、オーバーレイを通常の子ウィンドウに戻し、スプリッター側で透過風の描画をする構成も候補になります。
- オーバーレイから
WS_EX_LAYEREDを外して、普通の子ウィンドウとして扱う - 親ペインの背景色に合わせてオーバーレイの背景も塗ることで「一体感」を出す
- 描画はすべて
WM_PAINT内で完結させる
レイヤードウィンドウが不要になれば、UpdateLayeredWindow の複雑な取り回しから解放され、GDI の基礎だけで安定した描画が行えます。
| 構成 | 特徴 | 向いているケース |
|---|---|---|
| A案:レイヤード専用 | ピクセル単位のアルファが使える、視覚表現がリッチ | 半透明アイコンや影付きオーバーレイなどを使いたい場合 |
| B案:通常子ウィンドウ | 実装がシンプル、デバッグしやすい | シンプルなガイド線や単色オーバーレイで十分な場合 |
SetLayeredWindowAttributes と UpdateLayeredWindow の併用に注意
レイヤードウィンドウでは、次の2つの API をよく目にします。
SetLayeredWindowAttributes(LWA_ALPHA / LWA_COLORKEY)UpdateLayeredWindow(ULW_ALPHA / ULW_COLORKEY)
今回のコードでも、おそらく次のような形で全体の半透明度を指定しているはずです。
SetLayeredWindowAttributes(hwndOverlay, 0, 128, LWA_ALPHA);
一方、UpdateLayeredWindow に ULW_ALPHA を指定すれば、DIB 内のピクセルごとのアルファ値を使った描画もできます。しかし、固定アルファ(LWA_ALPHA)とピクセルアルファ(ULW_ALPHA)を同時に使うと、どのアルファが最終結果に効いているのかが分かりづらくなります。
| 組み合わせ | 特徴 |
|---|---|
| 固定アルファのみ(LWA_ALPHA) | ウィンドウ全体が一定の不透明度。実装が単純でデバッグしやすい |
| ピクセルアルファのみ(ULW_ALPHA) | 影やアンチエイリアスなど、リッチな表現が可能 |
| 両方を併用 | 合成結果が直感的でなくなりやすく、見え方の差異の原因になる |
今回の「残像」自体はアルファの組み合わせが直接原因ではありませんが、描画経路が複雑だとバグの切り分けが難しくなります。まずは描画経路をシンプルにし、その上で必要に応じてアルファの扱いを決めるのがトラブルシューティングの近道です。
ちらつき・パフォーマンスを抑える描画パターン
レイヤードウィンドウは内部的にはダブルバッファリングされていますが、WM_PAINT での描画パターンによっては、スプリッター移動時にちらつきや CPU 負荷が気になる場合があります。ここでは、より堅牢な描画パターンをいくつか紹介します。
メモリ DC でまとめて描画してから BitBlt する
レイヤードでない場合にも一般的なテクニックですが、「メモリ DC 上で全部描画 → 最後に 1 回だけ画面にコピー」というパターンは、ちらつき防止に非常に効果的です。
CreateCompatibleDCでメモリ DC を作成CreateCompatibleBitmapでオフスクリーン用ビットマップを作成- そのビットマップをメモリ DC に
SelectObject - メモリ DC 上で
FillRect→ 線描画などをすべて行う BitBltで画面 DC に転送
レイヤードウィンドウを使っている場合、同様のことを ARGB DIB で行い、描画完了後に UpdateLayeredWindow に渡すイメージです。
InvalidateRect の呼び出し頻度を調整する
スプリッターをドラッグしている最中は、かなり高頻度でウィンドウ位置やサイズが変わります。そのたびに InvalidateRect を多重に呼び出すと、無駄な再描画が増えてしまいます。
- スプリッターのドラッグ中は、タイマで一定間隔ごとに再描画する
- もしくはドラッグイベントごとに位置を更新しつつ、内部では「再描画要求済みフラグ」を見て 1 フレーム分だけ描く
UI の軽さを保つには、「必要なときにだけ描く」「同じフレームで何度も描かない」工夫が効果的です。
実装チェックリスト:レイヤードオーバーレイで確認すべきポイント
ここまでの内容を、チェックリストの形で整理しておきます。既存プロジェクトを点検するときの参考にしてください。
- 背景クリア
WM_PAINTの冒頭でFillRectなどによる全面クリアを行っているか- または
WM_ERASEBKGNDを実装し、背景を自前で塗りつぶしているか(return 1しているか)
- 描画経路の整理
UpdateLayeredWindowとWM_PAINTで同じ内容を二重に描いていないか- レイヤード専用として
UpdateLayeredWindowに一本化するか、通常子ウィンドウ+WM_PAINTにするか、どちらかに寄せているか
- アルファの扱い
SetLayeredWindowAttributesの LWA_ALPHA とUpdateLayeredWindowの ULW_ALPHA を同時に使っていないか- 固定アルファだけにするか、ピクセルアルファだけにするか方針を決めているか
- パフォーマンスとちらつき
- メモリ DC / DIB を使ったまとめ描き + 一括転送になっているか
- スプリッター移動中の再描画頻度が過剰になっていないか
簡単なサンプル:残像が出ないレイヤードオーバーレイ
最後に、今回のポイントを押さえた最小構成に近いサンプルコードのイメージを示します。実際のアプリに組み込む際の参考にしてください。
LRESULT CALLBACK OverlayProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg) {
case WM_CREATE:
// レイヤード属性の設定(固定アルファ 128)
SetLayeredWindowAttributes(hwnd, 0, 128, LWA_ALPHA);
return 0;
case WM_ERASEBKGND:
{
HDC hdc = (HDC)wParam;
RECT rc;
GetClientRect(hwnd, &rc);
// 背景クリア(ここでしっかり消す)
HBRUSH hBrush = CreateSolidBrush(RGB(55, 55, 55));
FillRect(hdc, &rc, hBrush);
DeleteObject(hBrush);
}
return 1; // 背景消去済み
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT rc;
GetClientRect(hwnd, &rc);
// WM_ERASEBKGND で背景は消えている前提
// ここでは「X」だけを描く
HPEN hPen = CreatePen(PS_SOLID, 1, RGB(255, 0, 0));
HPEN hOldPen = (HPEN)SelectObject(hdc, hPen);
MoveToEx(hdc, rc.left, rc.top, nullptr);
LineTo (hdc, rc.right, rc.bottom);
MoveToEx(hdc, rc.right, rc.top, nullptr);
LineTo (hdc, rc.left, rc.bottom);
SelectObject(hdc, hOldPen);
DeleteObject(hPen);
EndPaint(hwnd, &ps);
}
return 0;
case WM_SIZE:
// サイズ変更時は全体を再描画
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
case WM_DESTROY:
return 0;
}
return DefWindowProc(hwnd, msg, wParam, lParam);
}
このサンプルでは、背景クリアは WM_ERASEBKGND、図形描画は WM_PAINT、と役割を分けた上で、必ず全面クリアしてから「X」を描くようになっています。この構造をベースに、必要に応じて UpdateLayeredWindow を使った ARGB DIB 描画に発展させていけば、スプリッター付きの複雑なレイアウトでも安定したオーバーレイ表示が実現できます。
まとめ:Win32 レイヤードウィンドウの残像は「消していない」だけ
スプリッター移動時に WS_EX_LAYERED のオーバーレイ上の「X」が多重に表示される問題は、派手な現象に見えても、本質はシンプルです。
- 背景を一度も消していなかったため、過去フレームの描画がそのまま残っていた
hbrBackground = NULLかつWM_ERASEBKGND未実装の状態では、InvalidateRect(..., TRUE)でも何も消えない- 解決策は、WM_PAINT で全面 FillRect するか、WM_ERASEBKGND を実装して自前で背景クリアすること
- あわせて、
UpdateLayeredWindowとWM_PAINTの描画経路を整理し、アルファの扱いをシンプルにすることで、バグの温床を減らせる
レイヤードウィンドウは便利な反面、描画経路が複雑になりがちです。今回のような残像問題に遭遇したときは、まず「このウィンドウのフレームバッファは、どこでどうクリアされているのか?」を意識してコードを読み解いてみてください。1行の FillRect で、見違えるほど素直な動作になるはずです。

コメント