
簡介這份資源是面向計算機圖形學(xué)初學(xué)者與C MFC開發(fā)者的二維繪圖工具完整源代碼基于MFC框架實現(xiàn)圖形繪制、編輯與文件管理功能適合課程設(shè)計、圖形學(xué)實驗或Windows界面編程練手。壓縮包共39個文件約1.94MB包含7個h頭文件與6個cpp源文件承載視圖、文檔、主框架等核心邏輯另有vcproj、sln、dsp等工程配置ico、bmp、rc等界面資源以及obj、exe等編譯產(chǎn)物結(jié)構(gòu)完整可直接打開運行。項目覆蓋窗口與視圖、消息映射、CDC繪圖、CBrush與CPen樣式設(shè)置、CFont文本輸出、CFile文件讀寫、狀態(tài)欄菜單及內(nèi)存DC優(yōu)化等知識點并配有錯誤處理與調(diào)試宏示例。目前已有699人學(xué)習(xí)下載讀者可借此理解MFC消息驅(qū)動機制掌握DrawLine、DrawCircle、DrawPolygon等圖形繪制方法并在此基礎(chǔ)上擴展出屬于自己的繪圖軟件。1. 從一份 C MFC 二維繪圖工具源碼說起它到底能畫什么如果你正在學(xué)計算機圖形學(xué)或者需要在一個 Windows 桌面項目里快速嵌入二維繪圖能力這份 C MFC 源代碼值得花時間跑一遍。它把直線、圓、橢圓、多邊形、Bezier 曲線這些基礎(chǔ)圖元的生成算法連同鼠標(biāo)交互、橡皮筋預(yù)覽、顏色填充、圖形變換一起塞進了一個完整的 MFC 單文檔/多文檔框架里。不是那種只貼幾行 DDA 算法就結(jié)束的課堂示例而是一個能編譯、能點、能拖、能改的繪圖工具。適合兩類人一類是圖形學(xué)課程設(shè)計需要交一個像樣作品的另一類是想在 MFC 老項目里加繪圖模塊、又不想從 CDC 畫線開始造輪子的。下面按“能跑起來 → 看懂結(jié)構(gòu) → 改得動”的順序拆。2. 工程結(jié)構(gòu)與技術(shù)選型MFC 文檔視圖框架怎么承載繪圖邏輯2.1 為什么是 MFC 而不是 Qt 或 EasyX這份源碼選 MFC核心原因是它天然適合講清楚“圖形數(shù)據(jù)與顯示分離”這件事。MFC 的文檔/視圖Document/View架構(gòu)把數(shù)據(jù)存儲和渲染拆成兩個類CDocument派生類管圖形對象列表CView派生類管鼠標(biāo)消息和OnDraw渲染。對圖形學(xué)教學(xué)來說這個結(jié)構(gòu)比 Qt 的 QGraphicsScene 更“裸”你能清楚看到每個圖元是怎么被存下來、怎么被遍歷畫出來的。技術(shù)棧上它依賴 Visual Studio 的 MFC 組件注意不是默認(rèn)安裝項需要手動勾選“MFC 生成工具”使用 GDI 作為底層繪圖 API。GDI 雖然老但CDC類的MoveTo/LineTo/Ellipse/Polygon這些接口足夠覆蓋二維圖元而且雙緩沖只需要自己建一個CDC兼容位圖就能實現(xiàn)不引入額外依賴。2.2 目錄結(jié)構(gòu)每個文件負(fù)責(zé)什么拿到源碼后先別急著編譯花兩分鐘把文件按職責(zé)分個類。典型結(jié)構(gòu)如下文件/目錄職責(zé)改動頻率XxxApp.h/cpp應(yīng)用入口初始化幾乎不改MainFrm.h/cpp主框架窗口工具欄/狀態(tài)欄加按鈕時改XxxDoc.h/cpp圖形對象容器序列化加圖元類型時改XxxView.h/cpp鼠標(biāo)交互、OnDraw 渲染改交互和繪制時改Shape.h/cpp圖元基類與派生類核心擴展點Algo.h/cpp直線/圓/曲線生成算法換算法時改這個分法的好處是算法、數(shù)據(jù)、渲染三層不混。很多同學(xué)寫課程設(shè)計時把所有代碼堆在 View 里結(jié)果加一個圖元就要改五處這份源碼把圖元抽象成類之后新增一種圖形只需要加一個派生類并在 Doc 的創(chuàng)建分支里注冊。2.3 圖元基類的設(shè)計一個虛函數(shù)撐起整個體系圖形對象的核心抽象通常長這樣// Shape.h —— 所有圖元的基類 class CShape { public: CShape() : m_color(RGB(0,0,0)), m_penWidth(1) {} virtual ~CShape() {} // 純虛函數(shù)每個圖元自己決定怎么畫 virtual void Draw(CDC* pDC) 0; // 命中測試判斷鼠標(biāo)點是否落在圖形上用于選中/刪除 virtual bool HitTest(CPoint pt, int tolerance 5) 0; // 序列化存盤和讀盤 virtual void Serialize(CArchive ar) 0; COLORREF m_color; // 線條顏色 int m_penWidth; // 線寬 };這里三個虛函數(shù)分別對應(yīng)三件事Draw負(fù)責(zé)渲染HitTest負(fù)責(zé)交互Serialize負(fù)責(zé)持久化。把這三個能力放進基類意味著視圖層遍歷圖形列表時不需要switch(type)直接調(diào)虛函數(shù)就行。參數(shù)tolerance是命中容差因為鼠標(biāo)不可能精確點在一條 1 像素寬的線上通常給 5 像素左右。2.4 文檔類如何管理圖形列表CDocument派生類里一般維護一個CArrayCShape*, CShape*或std::vectorstd::unique_ptrCShape。用 MFC 的CArray是因為它和Serialize配合更順但現(xiàn)代寫法更推薦std::vector加智能指針避免手動delete。// XxxDoc.h class CXxxDoc : public CDocument { DECLARE_DYNCREATE(CXxxDoc) public: std::vectorstd::shared_ptrCShape m_shapes; // 所有圖元 virtual void Serialize(CArchive ar); // 存盤/讀盤 void AddShape(std::shared_ptrCShape s) { m_shapes.push_back(s); SetModifiedFlag(TRUE); // 標(biāo)記文檔已修改關(guān)閉時提示保存 UpdateAllViews(nullptr); // 通知所有視圖重繪 } };UpdateAllViews(nullptr)是 MFC 文檔/視圖架構(gòu)的關(guān)鍵數(shù)據(jù)一變所有關(guān)聯(lián)視圖自動收到OnUpdate進而觸發(fā)OnDraw。這樣你不需要手動調(diào)Invalidate數(shù)據(jù)流是單向的。2.5 視圖類的鼠標(biāo)交互從按下到松開畫出一條線交互邏輯集中在CView派生類的三個消息處理函數(shù)里OnLButtonDown、OnMouseMove、OnLButtonUp。以畫直線為例// XxxView.cpp —— 鼠標(biāo)拖拽畫直線的核心邏輯 void CXxxView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing true; // 進入繪制狀態(tài) m_ptStart point; // 記錄起點 m_ptEnd point; // 終點先等于起點 SetCapture(); // 捕獲鼠標(biāo)防止拖出窗口丟失消息 CView::OnLButtonDown(nFlags, point); } void CXxxView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd point; // 橡皮筋效果只重繪受影響區(qū)域避免全屏閃爍 InvalidateRect(GetRubberBandRect(), FALSE); UpdateWindow(); } CView::OnMouseMove(nFlags, point); } void CXxxView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing false; ReleaseCapture(); // 創(chuàng)建圖元并交給文檔管理 auto line std::make_sharedCLineShape(m_ptStart, point, m_currentColor); GetDocument()-AddShape(line); } CView::OnLButtonUp(nFlags, point); }SetCapture和ReleaseCapture這一對很容易被忽略但少了它鼠標(biāo)拖出窗口再松開時OnLButtonUp收不到消息圖形就“卡”在繪制狀態(tài)。InvalidateRect只重繪橡皮筋矩形區(qū)域比Invalidate()全屏重繪流暢得多這是避免閃爍的第一道防線。2.6 雙緩沖解決 GDI 繪圖閃爍的標(biāo)配即使只重繪局部OnDraw里直接往屏幕 DC 畫多個圖元時仍可能閃。標(biāo)準(zhǔn)做法是在內(nèi)存 DC 里畫完再一次性貼到屏幕void CXxxView::OnDraw(CDC* pDC) { CXxxDoc* pDoc GetDocument(); CRect rect; GetClientRect(rect); // 1. 創(chuàng)建內(nèi)存 DC 和兼容位圖 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 先填充背景 memDC.FillSolidRect(rect, RGB(255,255,255)); // 3. 遍歷所有圖元畫到內(nèi)存 DC for (auto shape : pDoc-m_shapes) { shape-Draw(memDC); } // 4. 正在拖拽的橡皮筋也畫上 if (m_bDrawing) { CPen pen(PS_DOT, 1, RGB(128,128,128)); CPen* pOldPen memDC.SelectObject(pen); memDC.MoveTo(m_ptStart); memDC.LineTo(m_ptEnd); memDC.SelectObject(pOldPen); } // 5. 一次性貼到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }這段代碼是 GDI 繪圖里最值得抄的部分。注意SelectObject之后一定要恢復(fù)舊對象否則 GDI 對象泄漏畫到幾百個圖元后程序會變慢甚至崩潰。CreateCompatibleBitmap必須用屏幕 DC 創(chuàng)建不能用內(nèi)存 DC否則位圖是單色的——這是新手最常踩的坑之一。3. 核心算法模塊拆解直線、圓、Bezier 曲線怎么落地3.1 DDA 與 Bresenham 直線算法圖形學(xué)課講直線生成繞不開 DDA 和 Bresenham。這份源碼里通常兩個都實現(xiàn)了方便對比。DDA 用浮點步進代碼直觀但慢Bresenham 全整數(shù)運算是實際項目該用的。// Algo.cpp —— Bresenham 直線算法全整數(shù)無浮點 void DrawLineBresenham(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF color) { int dx abs(x1 - x0), dy abs(y1 - y0); int sx (x0 x1) ? 1 : -1; // x 方向步進 int sy (y0 y1) ? 1 : -1; // y 方向步進 int err dx - dy; // 誤差項 while (true) { pDC-SetPixelV(x0, y0, color); // 畫當(dāng)前點 if (x0 x1 y0 y1) break; int e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } // x 方向走一步 if (e2 dx) { err dx; y0 sy; } // y 方向走一步 } }err是決策變量e2是它的兩倍用來避免除法。SetPixelV比SetPixel快因為它不返回顏色值。實際項目里逐像素畫線效率不高更常見的是用CDC::MoveTo/LineTo直接交給 GDI但作為算法教學(xué)逐像素實現(xiàn)才能看清每一步。3.2 中點畫圓算法圓用八分對稱性只算 1/8 弧其余七個對稱點直接映射// Algo.cpp —— 中點畫圓只算 45 度弧 void DrawCircleMidpoint(CDC* pDC, int cx, int cy, int r, COLORREF color) { int x 0, y r; int d 1 - r; // 決策變量初值 while (x y) { // 八個對稱點一次畫完 pDC-SetPixelV(cx x, cy y, color); pDC-SetPixelV(cx - x, cy y, color); pDC-SetPixelV(cx x, cy - y, color); pDC-SetPixelV(cx - x, cy - y, color); pDC-SetPixelV(cx y, cy x, color); pDC-SetPixelV(cx - y, cy x, color); pDC-SetPixelV(cx y, cy - x, color); pDC-SetPixelV(cx - y, cy - x, color); if (d 0) { d 2 * x 3; // 選正右方像素 } else { d 2 * (x - y) 5; // 選右下方像素 y--; } x; } }決策變量d的更新公式來自中點誤差推導(dǎo)d 0時中點在圓內(nèi)選正右方否則選右下方。八分對稱把計算量降到 1/8這是中點畫圓比直接解方程快的原因。3.3 Bezier 曲線的 de Casteljau 遞推二次和三次 Bezier 曲線用 de Casteljau 算法最穩(wěn)不會像直接代入 Bernstein 多項式那樣在參數(shù)接近端點時數(shù)值不穩(wěn)定// Algo.cpp —— 三次 Bezierde Casteljau 遞推 CPoint BezierPoint(CPoint p0, CPoint p1, CPoint p2, CPoint p3, double t) { // 第一層插值 CPoint a Lerp(p0, p1, t); CPoint b Lerp(p1, p2, t); CPoint c Lerp(p2, p3, t); // 第二層 CPoint d Lerp(a, b, t); CPoint e Lerp(b, c, t); // 第三層曲線上的點 return Lerp(d, e, t); } CPoint Lerp(CPoint p, CPoint q, double t) { return CPoint( (int)(p.x (q.x - p.x) * t 0.5), // 0.5 四舍五入 (int)(p.y (q.y - p.y) * t 0.5) ); }調(diào)用時讓t從 0 到 1 步進比如 0.01把相鄰兩個BezierPoint用MoveTo/LineTo連起來就是一條平滑曲線。步進越小越平滑但 0.01 在 800 像素寬的畫布上已經(jīng)看不出折線感??刂泣c用鼠標(biāo)拖拽調(diào)整時每次OnMouseMove重算整條曲線性能完全夠。3.4 圖形變換平移、縮放、旋轉(zhuǎn)的矩陣寫法二維變換用 3x3 齊次矩陣統(tǒng)一處理比分別寫平移縮放函數(shù)更清晰// 3x3 變換矩陣最后一列固定 (0,0,1) struct Matrix2D { double m[3][3]; static Matrix2D Translate(double tx, double ty) { Matrix2D r Identity(); r.m[0][2] tx; r.m[1][2] ty; return r; } static Matrix2D Scale(double sx, double sy) { Matrix2D r Identity(); r.m[0][0] sx; r.m[1][1] sy; return r; } static Matrix2D Rotate(double rad) { Matrix2D r Identity(); r.m[0][0] cos(rad); r.m[0][1] -sin(rad); r.m[1][0] sin(rad); r.m[1][1] cos(rad); return r; } static Matrix2D Identity() { Matrix2D r {}; r.m[0][0] r.m[1][1] r.m[2][2] 1; return r; } };對圖元的每個頂點左乘這個矩陣就完成變換。旋轉(zhuǎn)要繞圖形中心而不是原點所以順序是平移到原點 → 旋轉(zhuǎn) → 平移回中心。這個順序?qū)懛戳藞D形會飛出去是圖形學(xué)作業(yè)里最常見的翻車點。4. 避坑與常見問題編譯、閃爍、GDI 泄漏排查4.1 編譯報錯“無法打開包括文件 afxwin.h”現(xiàn)象新建項目或打開源碼后一編譯就提示找不到afxwin.h。原因Visual Studio 安裝時沒有勾選 MFC 組件MFC 頭文件不在默認(rèn)包含路徑里。解決打開 Visual Studio Installer點“修改”在“單個組件”里搜索 MFC勾選“適用于最新 v143 生成工具的 C MFC”并安裝。裝完重啟 VS項目屬性里“使用 MFC”設(shè)為“在共享 DLL 中使用 MFC”或“在靜態(tài)庫中使用 MFC”。4.2 繪圖時屏幕瘋狂閃爍現(xiàn)象拖動鼠標(biāo)畫橡皮筋時整個窗口閃圖形多了以后更明顯。原因OnDraw里直接往屏幕 DC 畫每次重繪都先擦背景再畫圖元擦和畫之間被看到。解決按 2.6 節(jié)的雙緩沖寫法全部畫到內(nèi)存 DC 再BitBlt。另外OnEraseBkgnd里直接return TRUE阻止系統(tǒng)擦背景因為背景已經(jīng)在內(nèi)存 DC 里填過了。4.3 畫了幾百個圖形后程序變卡甚至崩潰現(xiàn)象連續(xù)畫圖一開始流暢畫到幾百個后越來越慢最后報 GDI 資源耗盡。原因OnDraw里每次new CPen或CreatePen后沒有恢復(fù)舊對象GDI 對象句柄泄漏。Windows 每個進程默認(rèn)只有 10000 個 GDI 句柄。解決所有SelectObject必須配對恢復(fù)或者把畫筆、畫刷作為圖元成員只創(chuàng)建一次OnDraw里直接SelectObject復(fù)用。用任務(wù)管理器的“GDI 對象”列可以實時觀察句柄數(shù)。4.4 鼠標(biāo)拖出窗口后圖形“粘”在繪制狀態(tài)現(xiàn)象按住左鍵拖到窗口外松開回來發(fā)現(xiàn)還在畫線點一下才結(jié)束。原因沒有調(diào)用SetCapture鼠標(biāo)移出客戶區(qū)后OnLButtonUp收不到消息。解決OnLButtonDown里SetCapture()OnLButtonUp里ReleaseCapture()。注意SetCapture后如果程序異常退出沒釋放會影響其他程序所以最好在OnCancelMode里也補一個ReleaseCapture。4.5 存盤后重新打開圖形位置偏移現(xiàn)象保存的圖形文件重新加載后所有圖形整體偏移或縮放不對。原因Serialize里存的是邏輯坐標(biāo)但OnDraw里用了設(shè)備坐標(biāo)兩者原點或縮放不一致。解決統(tǒng)一坐標(biāo)系要么全用邏輯坐標(biāo)SetMapMode設(shè)MM_ANISOTROPIC并設(shè)窗口/視口范圍要么全用設(shè)備坐標(biāo)。存盤時把GetDocument()-m_shapes里每個圖元的坐標(biāo)原樣寫入讀盤時原樣恢復(fù)不要在Serialize里做任何坐標(biāo)轉(zhuǎn)換。5. 二次開發(fā)改造從能跑到好用我一般會動哪幾刀拿到這份源碼跑通之后真正讓它變成自己項目的一部分通常要做三件事加圖元、換交互、接數(shù)據(jù)。加圖元是最直接的擴展。在Shape.h里派生一個新類實現(xiàn)Draw、HitTest、Serialize三個虛函數(shù)然后在視圖的鼠標(biāo)松開處理里加一個case創(chuàng)建它在工具欄加一個按鈕切換當(dāng)前繪制模式。比如加一個“自由畫筆”圖元內(nèi)部存一個std::vectorCPointDraw里遍歷點用LineTo連起來HitTest判斷鼠標(biāo)點到任意線段距離是否小于容差。整個過程不需要改文檔類和渲染循環(huán)這就是基類抽象帶來的好處。換交互是第二個改造點。默認(rèn)的“按下-拖拽-松開”適合畫規(guī)則圖形但畫多邊形需要“點一下加一個頂點雙擊結(jié)束”。我一般會在視圖類里加一個狀態(tài)機IDLE、DRAWING_RUBBER_BAND、DRAWING_POLYGON。OnLButtonDown根據(jù)當(dāng)前狀態(tài)決定是記錄起點還是追加頂點OnLButtonDblClk結(jié)束多邊形。狀態(tài)機用enum加一個成員變量就夠不需要引入復(fù)雜框架。接數(shù)據(jù)是第三個改造點也是最能體現(xiàn)工程價值的地方。很多實際需求不是手動畫而是把外部數(shù)據(jù)比如 CSV 里的坐標(biāo)點、傳感器采集的軌跡批量導(dǎo)入后渲染。我通常會在文檔類里加一個ImportFromCSV(const CString path)方法用CStdioFile逐行讀按逗號拆分創(chuàng)建對應(yīng)圖元塞進m_shapes最后UpdateAllViews。這樣繪圖工具就從一個“畫板”變成了“數(shù)據(jù)可視化終端”。驗證改造是否成功我習(xí)慣用三個檢查一是畫 500 個隨機圖元后 GDI 對象數(shù)是否穩(wěn)定任務(wù)管理器看二是存盤再讀盤圖形數(shù)量和坐標(biāo)是否完全一致三是窗口從最小化恢復(fù)、被其他窗口遮擋再露出時圖形是否完整重繪。這三個過了基本就能交付。從那以后我每次改繪圖相關(guān)代碼都會先把 GDI 對象數(shù)盯一遍再提交這個習(xí)慣幫我省了至少三次線上崩潰的排查時間。希望這份拆解能幫你少走點彎路源碼跑起來之后大膽改圖形學(xué)的東西不動手畫幾遍是記不住的。本文還有配套的精品資源點擊獲取