【MFC】OnPaintのちらつき:背景消去とダブルバッファを切り分ける

【MFC】OnPaintのちらつき:背景消去とダブルバッファを切り分ける

MFCのアプリで、アニメーションや頻繁な再描画のある画面に、ちらつきが出ることがあります。OnPaintで描いているなら、OnEraseBkgndでTRUEを返す対策をよく見かけますね。

たしかに効きます。ただ、効くのは原因の片方だけで、代わりに前のフレームが画面に残ります。

目次

ちらつきの原因は、一度単色になることと途中経過が見えること

Invalidate(TRUE)で再描画を要求すると、更新領域の背景はBeginPaintが呼ばれた時点で消去されます。消去を行うのは既定処理で、ウィンドウクラスのブラシを使います。この時点で画面はいったん背景色一色になり、そのあとのWM_PAINTで絵が描き直されます。見えるのは「一瞬消えて、また出る」の繰り返しです。これが1つ目の原因です。

もう1つは、WM_PAINTの中で画面のDCへ直接描いていることです。間にバッファがありません。完成した絵を1回で出すのではなく、図形を描くたびに画面へ触ります。組み立て途中の絵が、そのまま画面に出ることもあります。CPaintDCがどれなのか迷ったら、CPaintDC vs CClientDC vs CWindowDCを見てください。

OnEraseBkgndでTRUEを返す対策が効くのは、1つ目に対してだけです。2つ目は残ります。そのうえ、背景を塗る処理が消えます。前のフレームで描いた画素のうち、次のフレームで描き直されない場所は、誰にも上書きされません。実際どうなるのか、数えてみました。

同じ絵を120フレーム、描き方だけを変えて比べる

題材は、設備の炉温を描き続ける監視画面です。目盛りの縦線12本と横線5本、上限しきい値の線1本、直近78点のトレンド(線分77本)で、線は95本です。そこへ最新値のマーカー1個が加わって図形96個、さらに現在値の読み取り行が1行つきます。更新はタイマーで16ミリ秒ごとに走らせていて、120フレームは約2秒ぶんにあたります。大きさも中身も同じ表示面を3つ並べ、同じ座標計算で描きました。3面ともInvalidate(TRUE)で同じ再描画要求を出します。WM_ERASEBKGNDは3面すべてに届きます。違うのは、その扱い方と、画面へ転送する回数だけです。

  • A未対策:背景消去は既定のまま、画面のDCへ直接描く。
  • B背景消去だけ止めた:OnEraseBkgndでTRUEを返す。描画は画面のDCへ直接。
  • CメモリDCで組み立てて1回転送:背景消去を止め、メモリDCに背景から組み立ててから、1回だけ画面へ転送する。

測ったのは、画面のDCへ発行した描画呼び出しの回数、WM_ERASEBKGNDの受信数と既定処理をした数、画面へ触っていた時間、そして画面が最新のフレームを表しているかどうかです。時間はQueryPerformanceCounterで計り、区間の終わりでGdiFlushを呼んで発行を確定させています。最後の項目は、最新フレームだけを描いた参照画像を別に用意し、実際の画面の画素と4画素おきに突き合わせて、一致しない点を数えました。数だけでは理由が分かりません。一致しなかった点に出ていた色を多い順に集計し、図形がまったく来ない隅の色と参照画像の背景色も並べて記録しています。

炉温の監視グラフを描く3つの表示面を並べ、画面への描画呼び出し回数と最新フレームとの不一致点数を比較したMFCサンプルの実行結果
同じ絵を同じ回数だけ描いても、画面へ触る回数と画面に残るものはまったく違います。中央のB面は120フレームぶんのトレンドが重なって青い帯になり、左上の読み取り行も潰れて読めなくなっています。
測った項目(120フレーム)A未対策B背景消去だけ止めたCメモリDCで1回転送
WM_ERASEBKGND受信/既定の塗りつぶし120回/120回120回/0回120回/0回
画面へ発行した描画呼び出し(1フレーム)11760回(98.0回)11640回(97.0回)120回(1.0回)
画面へ直接触っていた時間いちばん長いAより短いいちばん短い
最新フレームだけを描いた絵との不一致0/3080点2717/3080点0/3080点
 うち前フレームのトレンドの跡/背景が塗られていない白0点/0点1417点/1296点0点/0点

見てほしいのはB列です。背景消去を止めただけの面は、突き合わせた3080点のうち2717点が最新フレームの絵と一致しませんでした。ちらつきの原因の片方は消えています。それでも画面は最新のフレームを表していません。

不一致の内訳を見ると、この2717点には性質の違う2つの失敗が混ざっています。いちばん多いのは前フレームのトレンドの色(40,90,200)で1417点、これが本来の意味での残像です。次に多いのが白(255,255,255)の1296点で、これは図形がまったく来ない隅の色と同じでした。TRUEを返した時点で誰も背景を塗らなくなり、その面の背景は参照画像の背景色(250,250,252)に一度もなっていません。上位2色だけで2713点を占めます。つまり「前のフレームが消えない」と「背景が初期化されない」が同時に起きています。

AとBで画面への描画呼び出しが98.0回と97.0回に分かれるのは、既定の背景消去1回ぶんの差です。図形96個と読み取り値1行で97回。OnEraseBkgndを止めても、画面へ触る回数はほとんど減っていません。組み立て途中が見えるという2つ目の原因は、そのまま残っています。

Cは1フレームあたり1.0回です。97回ぶんの描画はすべてメモリ上のビットマップへ向かい、画面へ触るのは完成した絵を転送する1回だけです。画面へ直接触っていた時間も、3面のうちいちばん短くなっています。そして背景を自分で塗ってから図形を重ねています。最新フレームとの不一致も0点でした。

この構成は何度か測り直しています。受信数、描画呼び出しの回数、不一致の点数、色の内訳は、どの回も1桁も違いません。動いたのは時間だけで、絶対値は実行ごとに変わります。時間はA>B>Cの関係として読んでください。

ちらつきは動きの見え方です。静止画には写りません。この実測で確認できるのは、画面へ触る回数と時間、そしてBの画面が最新フレームを表していないことまでです。「ちらつきが消えた」ことそのものを数値で示したわけではありません。

メモリDCへ切り替える:ヘッダーとcppの2ファイルだけ

Cの実装は小さなヘルパークラス1つで済みます。画面DCと同じ大きさのメモリビットマップを用意し、背景を塗り、描画がすべて終わってから一度だけ転送します。バッファはCDCとCBitmapに持たせてnewとdeleteを使いません。転送を呼び忘れても、デストラクタが同じ転送をしてくれます。作成に失敗したら画面DCをそのまま返すので、描画そのものは止まりません。

// MemoryPaintDC.h : header file
//

#pragma once

/*============================================================================*/
// CMemoryPaintDC

class CMemoryPaintDC
{
// Construction
public:
	CMemoryPaintDC(CDC* pScreenDC, const CRect& rectClient, COLORREF crBackColor);
	CMemoryPaintDC(const CMemoryPaintDC&) = delete;
	CMemoryPaintDC& operator=(const CMemoryPaintDC&) = delete;

// Attributes
public:
	BOOL IsBuffered() const;   // バッファを用意できたか
private:
	CDC* m_pScreenDC;
	CRect m_rectClient;
	CDC m_dcMem;
	CBitmap m_bmpMem;
	CBitmap* m_pOldBitmap;
	BOOL m_bBuffered;
	BOOL m_bPresented;

// Operations
public:
	CDC* GetPaintDC();   // 描画先。バッファが無いときは画面DCを返す
	BOOL Present();      // 組み立てた絵を画面へ転送する。2回目以降は何もしない

// Implementation
public:
	~CMemoryPaintDC();
};
// MemoryPaintDC.cpp : implementation file
//

#include "pch.h"
#include "MemoryPaintDC.h"

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

/////////////////////////////////////////////////////////////////////////////
// CMemoryPaintDC

CMemoryPaintDC::CMemoryPaintDC(CDC* pScreenDC, const CRect& rectClient, COLORREF crBackColor)
{
	ASSERT_VALID(pScreenDC);

	m_pScreenDC = pScreenDC;
	m_rectClient = rectClient;
	m_pOldBitmap = NULL;
	m_bBuffered = FALSE;
	m_bPresented = FALSE;

	if (rectClient.Width() <= 0 || rectClient.Height() <= 0)
		return;

	if (!m_dcMem.CreateCompatibleDC(pScreenDC))
		return;

	if (!m_bmpMem.CreateCompatibleBitmap(pScreenDC, rectClient.Width(), rectClient.Height()))
	{
		m_dcMem.DeleteDC();
		return;
	}

	m_pOldBitmap = m_dcMem.SelectObject(&m_bmpMem);

	// 背景は自分で塗る。WM_ERASEBKGND を止める以上、ここを省くと前の絵が残る。
	m_dcMem.FillSolidRect(CRect(0, 0, rectClient.Width(), rectClient.Height()), crBackColor);

	m_bBuffered = TRUE;
}

BOOL CMemoryPaintDC::IsBuffered() const
{
	return m_bBuffered;
}

CDC* CMemoryPaintDC::GetPaintDC()
{
	return m_bBuffered ? &m_dcMem : m_pScreenDC;
}

BOOL CMemoryPaintDC::Present()
{
	if (!m_bBuffered || m_bPresented)
		return FALSE;

	m_bPresented = TRUE;

	return m_pScreenDC->BitBlt(0, 0, m_rectClient.Width(), m_rectClient.Height(),
		&m_dcMem, 0, 0, SRCCOPY);
}

CMemoryPaintDC::~CMemoryPaintDC()
{
	// 転送を呼び忘れても、ここで同じ転送を行う(絵が出ないままにならない)。
	Present();

	if (m_bBuffered && m_pOldBitmap != NULL)
		m_dcMem.SelectObject(m_pOldBitmap);
}

使う側で書くのはOnEraseBkgndとOnPaintです。OnEraseBkgndはTRUEを返すだけ。OnPaintではCPaintDCをヘルパーに渡し、描画先をGetPaintDC()から受け取ります。下は、そのOnPaintが呼んでいる、C面の描画そのものです。

void CPaintPane::PaintBuffered(CDC* pScreenDC, const CRect& rectClient)
{
	CMemoryPaintDC dcBuffer(pScreenDC, rectClient, SceneRenderer::BackColor());
	if (!dcBuffer.IsBuffered())
		m_stats.m_bBufferFailed = TRUE;

	const int nDrawn = SceneRenderer::DrawScene(dcBuffer.GetPaintDC(), rectClient, m_nFrame);

	// ここまでは画面に何も出ていない。画面へ触るのは次の転送1回だけ。
	const double dStart = NowMicros();
	const BOOL bPresented = dcBuffer.Present();
	::GdiFlush();

	// 以下の3行は測定用。転送できなければ画面へ直接描いたのと同じ回数を数える(縮退動作)。
	m_stats.m_dScreenMicros += NowMicros() - dStart;
	m_stats.m_nScreenCalls += bPresented ? 1 : nDrawn;
	m_bTouched = FALSE;
}

移植するときに要るのは、ヘルパーを作ってGetPaintDC()へ描き、Present()で転送する3行だけです。NowMicrosとm_statsが出てくる行は、上の表の数値を取るための計測用です。SelectObjectで選んだビットマップを戻す処理も、DCの解放もデストラクタ側です。途中でreturnしても後始末が漏れません。GDIオブジェクトを解放し忘れるとどうなるかは、GDIハンドル上限:CreatePenがNULLを返し始めた瞬間のほうに書きました。

OnEraseBkgndでTRUEを返すことと、メモリDC側で背景を塗ることは対で扱ってください。片方だけにすると、Bの実測と同じ状態になります。転送に使っているBitBltの引数や、拡大縮小がいるときのStretchBltとの違いが気になったら、BitBlt vs StretchBlt: 画像転送の基本と拡大縮小もどうぞ。

ウィンドウスタイルでは埋まらない差

ヘルパーの中身はCreateCompatibleDCとCreateCompatibleBitmap、そしてBitBltだけです。特別なライブラリもフレームワーク側のクラスも要りません。自前で書いておくと、確保に失敗したときの動作を自分で決められます。転送の回数や時間を数える計測も差し込めます。上のサンプルでは、バッファを確保できなかった面は画面へ直接描いたのと同じ回数として数えています。

ウィンドウスタイルのWS_CLIPCHILDRENも、ちらつきの話題でよく挙がります。公式の定義は、親ウィンドウの中で描画が行われるときに子ウィンドウが占めている領域を除外する、というものです。裏を返せば、付けていない親ダイアログでは、親自身の背景消去が子ウィンドウ(3つの表示面)の領域にも及びます。今回の実測は、このスタイルを付けた状態で取りました。ただし公式が述べているのは、親の描画から子の領域を除外するかどうかまでです。各表示面がWM_PAINTの中で画面へ何回触ったかは、面の側で数えています。3面はどれも同じダイアログを親として作られた子ウィンドウで、親のスタイルは1つしかありません。それでも98.0回と1.0回に分かれました。この差を生んでいるのは、面ごとの描き方のほうです。

まとめ

  • ちらつきの原因は、背景消去で一度単色になることと、WM_PAINTの中で画面へ直接描いて途中経過が見えることです。
  • OnEraseBkgndでTRUEを返すだけでは前者しか消えません。実測では画面への描画呼び出しは98.0回から97.0回にしか減りませんでした。
  • 背景を塗る処理が無くなります。最新フレームの絵と3080点中2717点が一致せず、内訳は前フレームの跡が1417点、一度も塗られていない白が1296点でした。
  • メモリDCに背景から組み立てて1回だけ転送すると、画面へ触る回数は1.0回、不一致は0点になりました。OnEraseBkgndでTRUEを返すことと、バッファ側で背景を塗ることは必ず対にします。

この記事の数値は、Visual Studio 2026 / Debug / x64 / Unicode / MFC共有DLLで、287×183ピクセルの表示面3つを120フレーム動かしたときの実測です。ほかの構成や寸法で同じ数字が出るとは限りません。比べるべきは絶対値ではなく、同じ条件で描き方だけを変えたときの差です。

参考資料

目次