Win32/C++のウィンドウリサイズで残像が出る原因と対策:WM_ERASEBKGNDと背景ブラシの使い方完全ガイド

Win32/C++で作ったアプリのウィンドウを左上隅からリサイズすると、クライアント領域に古い描画が残って「残像」が見える――この現象は珍しくありません。原因は難解なバグではなく、ほぼ例外なく「背景の消去タイミングと手段」が適切でないことにあります。本稿では、発生メカニズムを体系的に分解し、最小の修正で直す方法から、再描画品質を高める実践的な設計までを一気に解説します。

目次

現象の正体:残像は「未消去の背景」

Win32の通常の描画フローでは、WM_ERASEBKGND→WM_PAINTの順に処理が進みます。WM_ERASEBKGNDで背景が適切に塗りつぶされないと、前フレームのビットマップ(古い迷路や線)がそのままスクリーンに残り、サイズ変更で新たに露出した領域に「古い画面のコピー」が見えてしまいます。これが俗に言う残像(アフターイメージ)です。

このとき重要な役割を果たすのが「ウィンドウクラスに登録した背景ブラシ」です。既定動作(DefWindowProc)はWM_ERASEBKGNDでクラス背景ブラシを使ってクライアント領域を塗りつぶします。クラス背景ブラシが未設定(NULL相当)だと既定動作でも背景は塗られず、新しく露出したピクセルに古い内容が残ってしまいます。

項目状態結果
クラス背景ブラシ設定あり(例:(HBRUSH)(COLOR_WINDOW+1))WM_ERASEBKGNDで背景が確実に塗られる。残像が消える。
クラス背景ブラシ未設定(NULL)背景が消去されない。新しく露出した領域に古い描画が残る。
WM_ERASEBKGND(自前処理)塗りつぶしを実装しreturn 1;背景消去を自力で完了。残像は出ない。

なぜ「左上隅からのリサイズ」で目立つのか

左上隅をドラッグしてリサイズすると、ウィンドウの原点(クライアント座標の(0,0))がスクリーン上を移動しつつサイズも変わります。このときウィンドウマネージャは画面の一部を一時的にコピー(BitBlt様の挙動)し、未描画領域はアプリ側の再描画に委ねます。背景消去が漏れていると、新しく露出したピクセルに前フレームのビットがそのまま見えやすく、特に左上起点の拡縮では「新規露出ピクセルの量」が多いため残像が顕著になります。

最短で直す:ウィンドウクラスに背景ブラシを設定する

もっとも簡潔で堅牢な解決策は、ウィンドウクラス登録時に背景ブラシを指定することです。

WNDCLASSEX wc = { sizeof(wc) };
wc.style         = CS_HREDRAW | CS_VREDRAW;  // ※後述の注意参照
wc.lpfnWndProc   = WndProc;
wc.hInstance     = hInstance;
wc.hCursor       = LoadCursor(NULL, IDC_ARROW);
wc.hIcon         = LoadIcon(NULL, IDI_APPLICATION);
wc.hIconSm       = wc.hIcon;
wc.lpszClassName = TEXT("MazeWindow");

// これだけでOK(システム既定の白)
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);

// 任意の単色にしたい場合(ストックオブジェクト)
// wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH);

RegisterClassEx(&wc);

この設定により、DefWindowProcがWM_ERASEBKGNDでクライアント領域を自動的に塗りつぶし、リサイズ時の残像が消えます。

ポイント

  • ストックブラシは基本的にDeleteObjectしない:GetStockObjectで取得したブラシや(HBRUSH)(COLOR_*+1)は、OS管理のリソースです。DeleteObjectは呼ばないでください。
  • 自作ブラシは破棄が必要:CreateSolidBrush等で生成したブラシは、使い終えたらDeleteObjectが必要です。最適なのはアプリ開始時に1つ作り、終了時に1回だけ破棄することです。
背景ブラシの指定例用途破棄の必要
(HBRUSH)(COLOR_WINDOW+1)一般的なアプリの既定背景不要
(HBRUSH)GetStockObject(BLACK_BRUSH)黒背景のデモ等不要
CreateSolidBrush(RGB(30,30,30))ダークテーマ調の単色背景必要(終了時)

背景ブラシを使わない流儀:WM_ERASEBKGNDを自前で処理

ゲームやグラフィクスアプリでは、WM_ERASEBKGNDの塗りつぶしを避け、WM_PAINTでフル描画してフリッカを抑える手法もよく使われます。その場合は自分で背景を消去したうえでreturn 1;(処理済み)を返します。

case WM_ERASEBKGND:
{
    HDC hdc = (HDC)wParam;
    RECT rc;
    GetClientRect(hWnd, &rc);

    // 必要なら任意色で塗りつぶし
    // HBRUSH hbr = CreateSolidBrush(RGB(255,255,255));
    // FillRect(hdc, &rc, hbr); DeleteObject(hbr);

    // システム既定の窓色で良いなら:
    FillRect(hdc, &rc, (HBRUSH)(COLOR_WINDOW + 1));

    return 1; // 背景消去は完了した、とOSへ通知
}

この方式を採る場合の鉄則は「WM_PAINTで必ずクライアント全域を塗る」ことです。部分描画が残っていると、どのみち残像や未描画領域が発生します。迷路描画のように全域を毎フレーム描くアプリなら、この方式とダブルバッファリングの併用が最もなめらかです(後述)。

最小再現から完成形へ:小さなサンプル

以下は「残像が出る→出ない」までを1ファイルに収めた最小サンプルです。肝はクラス背景ブラシの指定と、WM_PAINTでの全域描画です。

#include <windows.h>

LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);

int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow)
{
    WNDCLASSEX wc = { sizeof(wc) };
    wc.style         = CS_HREDRAW | CS_VREDRAW; // ※必要に応じて外してもOK
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInst;
    wc.hCursor       = LoadCursor(NULL, IDC_ARROW);
    wc.hIcon         = LoadIcon(NULL, IDI_APPLICATION);
    wc.hIconSm       = wc.hIcon;
    wc.lpszClassName = TEXT("MazeWindow");
    wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); // ★これが最短の解決策

    if (!RegisterClassEx(&wc)) return 0;

    HWND hWnd = CreateWindowEx(
        0, wc.lpszClassName, TEXT("Maze"),
        WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, CW_USEDEFAULT, 800, 600,
        NULL, NULL, hInst, NULL);

    ShowWindow(hWnd, nCmdShow);
    UpdateWindow(hWnd);

    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (int)msg.wParam;
}

static void DrawMaze(HDC hdc, const RECT& rc)
{
    // ダミーの迷路描画(実際には任意の描画に置き換え)
    HPEN pen = CreatePen(PS_SOLID, 1, RGB(0,0,0));
    HGDIOBJ oldPen = SelectObject(hdc, pen);
    for (int x = rc.left; x < rc.right; x += 20) {
        MoveToEx(hdc, x, rc.top, NULL);
        LineTo(hdc, x, rc.bottom);
    }
    for (int y = rc.top; y < rc.bottom; y += 20) {
        MoveToEx(hdc, rc.left, y, NULL);
        LineTo(hdc, rc.right, y);
    }
    SelectObject(hdc, oldPen);
    DeleteObject(pen);
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
    case WM_SIZE:
        // サイズ変更のたびに再描画を要求
        InvalidateRect(hWnd, NULL, FALSE); // 背景消去はクラスブラシに任せる
        break;

    case WM_ERASEBKGND:
        // クラス背景ブラシに任せるならデフォルトへ
        // return 1; とせず 0(未処理)でOK
        return 0;

    case WM_PAINT:
    {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hWnd, &ps);
        RECT rc; GetClientRect(hWnd, &rc);

        // ここでクライアント全域を描く
        DrawMaze(hdc, rc);

        EndPaint(hWnd, &ps);
        return 0;
    }
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hWnd, msg, wParam, lParam);
}

フリッカ最小化:ダブルバッファリング(推奨)

残像の解消は「背景を確実に消す」ことですが、塗って→線を描く、という2段階が見えてしまうとフリッカ(ちらつき)が出ることがあります。これを避けるにはオフスクリーンに全て描いてから一度にBitBltします。

static void PaintToBuffer(HWND hWnd, HDC hdcTarget)
{
    RECT rc; GetClientRect(hWnd, &rc);
    int w = rc.right - rc.left, h = rc.bottom - rc.top;

    HDC mem = CreateCompatibleDC(hdcTarget);
    HBITMAP bmp = CreateCompatibleBitmap(hdcTarget, w, h);
    HGDIOBJ oldBmp = SelectObject(mem, bmp);

    // 背景消去(単色)
    HBRUSH bk = (HBRUSH)(COLOR_WINDOW + 1);
    FillRect(mem, &rc, bk);

    // ここで全ての描画(迷路など)を完了させる
    DrawMaze(mem, rc);

    // 一括転送
    BitBlt(hdcTarget, 0, 0, w, h, mem, 0, 0, SRCCOPY);

    // 後片付け
    SelectObject(mem, oldBmp);
    DeleteObject(bmp);
    DeleteDC(mem);
}

case WM_PAINT:
{
    PAINTSTRUCT ps;
    HDC hdc = BeginPaint(hWnd, &ps);
    PaintToBuffer(hWnd, hdc);
    EndPaint(hWnd, &ps);
    return 0;
}

この方式ではWM_ERASEBKGNDで何もしない(return 1;)か、クラス背景ブラシを残したままでも構いません。いずれにせよ「転送前にバッファ内の背景を塗ってから描く」ことが重要です。

メッセージ順序を理解する:どこで何が起きるのか

リサイズ時の代表的な流れは概ね次のようになります(簡略)。

WM_WINDOWPOSCHANGING / WM_NCCALCSIZE
WM_SIZE                       // 新しいクライアント領域サイズが確定
WM_ERASEBKGND                 // 背景消去(クラス背景ブラシ or 自前)
WM_PAINT                      // 実描画(全域を描くのが基本)

この順を踏まえると、背景消去を保証する場所は2つだけです。「クラス背景ブラシ」または「自前WM_ERASEBKGND」。どちらかを確実に実装すれば残像は出ません。

補助テクニックと注意点

CS_HREDRAW | CS_VREDRAWの是非

これらのクラススタイルは、ウィンドウサイズの変化により幅/高さが変わったときに全体再描画を促します。残像対策というより「再描画漏れ」を避ける目的で有効ですが、頻繁なInvalidateRectを招くので描画コストは上がります。迷路のように全域描画が安価なら付けても構いません。描画が重いなら、WM_SIZEでInvalidateRect(hWnd, NULL, FALSE)する方が制御しやすいケースがあります。

InvalidateRectの第3引数

第3引数(bErase)にTRUEを渡すと、無条件でWM_ERASEBKGNDが呼ばれます。クラス背景ブラシに任せたいならFALSEでも問題ありません(直後のWM_PAINTまでに既定動作で呼ばれるため)。ダブルバッファ方式でWM_ERASEBKGNDを「何もしないでreturn 1;」にしているなら、bErase=FALSEでOKです。

子ウィンドウが多い場合はWS_CLIPCHILDREN

親が背景を塗る際、子ウィンドウ領域まで塗ると上書き/フリッカの原因になります。親にWS_CLIPCHILDRENを付けると、WM_ERASEBKGNDやWM_PAINTで子領域が自動的に除外され、ちらつきを抑えられます。

GDIオブジェクトの寿命管理

  • ストックオブジェクト(GetStockObjectや(HBRUSH)(COLOR_*))は破棄しない。
  • 自作ブラシ/ペン/ビットマップは、Create*したら必ず対応するDeleteObjectで解放。
  • HDCはCreateCompatibleDCならDeleteDC、BeginPaintならEndPaint、GetDCならReleaseDC。

ダークモードやテーマ対応

背景色をテーマで切り替えたい場合、クラス背景ブラシを単色に固定せず、WM_ERASEBKGNDで動的に塗りつぶすと柔軟です。あるいはクラス背景ブラシを自作ブラシで差し替える設計でも構いません(その際はアプリ終了時に1回だけ破棄)。

「直す」までの実践手順(再現→診断→修正)

  1. 現象を確定:ウィンドウ左上をドラッグして拡大/縮小。新規露出領域に古い迷路や線が残るか確認。
  2. 診断の仮説:WM_ERASEBKGNDが背景を塗っていない、またはクラス背景ブラシ未設定。
  3. 最短修正:クラス背景ブラシを設定((HBRUSH)(COLOR_WINDOW+1))。
  4. 検証:同じ操作で残像が消えるか確認。
  5. 品質向上:ちらつきが気になるならダブルバッファリングを導入。WM_SIZEでInvalidateRect。
  6. 最終確認:子ウィンドウとの干渉があるならWS_CLIPCHILDRENを検討。

やりがちな落とし穴と回避策(対比表)

よくある誤り症状正しい対処
クラス背景ブラシ未設定新規露出領域に前フレームが残るwc.hbrBackgroundにブラシを設定する
WM_ERASEBKGNDでreturn 1するが、背景を塗らない残像/未描画が出る自前で全域塗りつぶすか、WM_PAINTで全域を完全に描く
ストックブラシをDeleteObject不定動作・描画崩れストック/システムブラシは破棄しない
重い描画をWM_SIZEで実行リサイズが引っかかるWM_SIZEではレイアウト更新+InvalidateRect、描画はWM_PAINTに集約
親が子の上まで背景を塗る子コントロール周辺がちらつく親にWS_CLIPCHILDREN、必要なら子にWS_CLIPSIBLINGS

迷路アプリでの具体的な設計指針

  • クラス背景ブラシ:まずは設定。これだけで大半の残像は解消。
  • 描画の一本化:迷路の全描画はWM_PAINTにまとめる。WM_TIMERや入力で更新が必要ならInvalidateRectで再描画を促す。
  • ダブルバッファリング:BeginPaint→オフスクリーン描画→BitBlt→EndPaint。背景もバッファ側で塗る。
  • サイズ変更:WM_SIZEで迷路のセルサイズやレイアウトを再計算し、InvalidateRect(bErase=FALSE)を呼ぶ。
  • GDI管理:ペン/ブラシ/ビットマップは初期化時に作る・終了時に破棄。描画ごとの大量生成は避ける。

FAQ

Q. WM_ERASEBKGNDを完全に無効化しても良い?

A. 背景を必ず自分で塗る(または全域を完全に上書きする)なら構いません。多くのアプリではダブルバッファリングと組み合わせてWM_ERASEBKGNDはreturn 1;にします。ただし一部ウィンドウ構成(子ウィンドウやテーマ描画)では、既定動作との兼ね合いがあるため、まずはクラス背景ブラシ方式が堅実です。

Q. CS_HREDRAW/CS_VREDRAWは必須?

A. 必須ではありません。付けるとサイズ変更で全域が再描画されやすくなり、再描画漏れの心配は減りますが、重い描画ではオーバーヘッドになります。アプリの性質に合わせて有無を決めてください。

Q. それでも残像が出る場合は?

A. 次を順に確認してください:

  1. クラス背景ブラシが正しく設定されているか。
  2. WM_ERASEBKGNDでreturn 1;しておきながら背景を塗っていない、という矛盾がないか。
  3. WM_PAINTがクライアント全域を上書きしているか。
  4. 子ウィンドウとの境界で親が塗っていないか(WS_CLIPCHILDREN)。
  5. ダブルバッファリングで一枚絵として転送しているか。

コード断片(良い例/悪い例)

悪い例:背景を塗らないWM_ERASEBKGND

case WM_ERASEBKGND:
    // 何もしないのに return 1; はNG(背景が消されない)
    return 1;

良い例:クラス背景ブラシを指定して既定動作に任せる

// WNDCLASSEX 登録時
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);

// WndProc
case WM_ERASEBKGND:
return 0; // 既定動作に任せる(= クラス背景ブラシで塗る) 

良い例:自前で背景を消してから描く(ダブルバッファ)

case WM_ERASEBKGND:
    return 1; // 背景消去は自分で行う

case WM_PAINT:
// バッファ上で FillRect → 迷路描画 → まとめて BitBlt 

チェックリスト(最終確認)

  • クラス背景ブラシを設定している、またはWM_ERASEBKGNDで背景を塗ってreturn 1;している。
  • WM_PAINTはクライアント全域を上書きする。
  • サイズ変更でInvalidateRect(hWnd, NULL, FALSE)を呼び、描画はWM_PAINTでのみ行う。
  • ダブルバッファリングでフリッカを抑える。
  • 子ウィンドウがある親はWS_CLIPCHILDREN。
  • ストックオブジェクトは破棄しない。自作オブジェクトは確実に破棄。

まとめ

リサイズ時の残像は、難しい不具合ではなく「背景の消去が適切に行われていない」ことの副作用です。まずはウィンドウクラスに背景ブラシ(例:(HBRUSH)(COLOR_WINDOW+1))を設定する。あるいはWM_ERASEBKGNDを自前で処理して背景を塗る。これだけで現象は解消します。さらにダブルバッファリングとInvalidateRect運用を組み合わせれば、なめらかなリサイズと安定した描画を両立できます。迷路描画のような全域更新型のアプリでは特に相性が良く、少ない変更で即効性のある改善が得られます。


付録:迷路描画アプリ向けテンプレート(抜粋)

// 初期化時(WinMain)
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
// ... RegisterClassEx, CreateWindowEx ...

// WndProc
case WM_SIZE:
// セルサイズ再計算などレイアウトだけ更新
InvalidateRect(hWnd, NULL, FALSE);
return 0;

case WM_ERASEBKGND:
// クラス背景ブラシに任せるなら 0
// 自前でやるなら FillRect(...) & return 1
return 0;

case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// 推奨:ダブルバッファで一気に描く
PaintToBuffer(hWnd, hdc);
EndPaint(hWnd, &ps);
return 0;
} 

以上の方針に従えば、左上隅からのリサイズでも残像は発生せず、視覚品質とパフォーマンスのバランスがとれた描画が実現できます。

この記事を書いた人

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

コメント

コメントする

目次