Win32レイヤードウィンドウの残像対策|WS_EX_LAYEREDとWM_PAINTでスプリッター移動時のオーバーレイ「X」を正しく再描画する方法

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 回だけ画面にコピー」というパターンは、ちらつき防止に非常に効果的です。

  1. CreateCompatibleDC でメモリ DC を作成
  2. CreateCompatibleBitmap でオフスクリーン用ビットマップを作成
  3. そのビットマップをメモリ DC に SelectObject
  4. メモリ DC 上で FillRect → 線描画などをすべて行う
  5. 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 で、見違えるほど素直な動作になるはずです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次