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