:直線、圓與多邊形填充算法詳解)
簡介這份資源是面向計算機(jī)圖形學(xué)初學(xué)者與C Windows開發(fā)者的MFC二維繪圖工具完整源代碼幫助讀者通過一個可運(yùn)行的實例掌握圖形界面編程與幾何圖形繪制。項目基于MFC的消息驅(qū)動模型涵蓋自定義CView視圖類、鼠標(biāo)與鍵盤消息映射、CDC繪圖接口、CBrush與CPen顏色線條管理、CFont文本輸出、CFile與CFileDialog文件讀寫以及狀態(tài)欄、菜單和內(nèi)存設(shè)備上下文等核心知識點并配有異常捕獲與ASSERT調(diào)試示例。壓縮包共39個文件約1.94MB包含7個h頭文件、6個cpp源文件及obj、ico、rc、vcproj、sln等工程與資源文件覆蓋從源碼到可執(zhí)行程序的完整結(jié)構(gòu)。目前已有699人學(xué)習(xí)下載適合作為課程設(shè)計、圖形學(xué)實驗或MFC入門練手項目讀者可據(jù)此理解繪圖工具從界面搭建到圖形編輯、保存的完整實現(xiàn)思路。1. 從一份 C MFC 二維繪圖源碼說起它到底能解決什么問題很多做計算機(jī)圖形學(xué)課程設(shè)計或者 Windows 桌面工具開發(fā)的人都會卡在同一個地方算法原理都懂直線插補(bǔ)、圓生成、多邊形填充、二維變換這些概念背得滾瓜爛熟但真要交出一個能跑、能畫、能交互的完整程序就不知道從哪下手了。這份 C MFC 源代碼二維繪圖工具就是沖著這個痛點來的——它把計算機(jī)圖形學(xué)里最核心的二維圖元生成與變換算法用 MFC 的單文檔/多文檔框架封裝成了一個可編譯、可運(yùn)行、可繼續(xù)擴(kuò)展的工程。它適合三類人一是正在做圖形學(xué)課設(shè)、需要一份能跑通的參考實現(xiàn)來對照自己代碼的在校開發(fā)者二是想用 MFC 快速搭一個繪圖類工具原型、不想從 CDC 繪圖消息映射開始啃的 Windows 客戶端開發(fā)者三是想通過讀源碼理解光柵化算法怎么落到像素上的自學(xué)者。源碼里通常包含直線、圓、橢圓、多邊形填充、二維平移旋轉(zhuǎn)縮放這些模塊配合鼠標(biāo)交互完成繪制。下面我按“先看懂結(jié)構(gòu)、再動手編譯、然后逐個算法拆解、最后避坑和進(jìn)階”的順序把這份資源拆開講。2. 工程結(jié)構(gòu)與編譯環(huán)境把源碼跑起來要過的幾道關(guān)2.1 先認(rèn)清 MFC 工程的骨架拿到一份 MFC 繪圖源碼別急著點編譯。先看目錄結(jié)構(gòu)典型的 MFC 單文檔工程會有這么幾類文件解決方案文件.sln、項目文件.vcxproj、應(yīng)用程序類App、主框架窗口類MainFrm、文檔類Doc、視圖類View以及資源文件.rc和資源頭文件resource.h。繪圖邏輯絕大多數(shù)落在 View 類的OnDraw和各個鼠標(biāo)消息處理函數(shù)里因為視圖類才是真正持有CDC*繪圖設(shè)備上下文的地方。文檔類和視圖類的分工要理清文檔類負(fù)責(zé)存數(shù)據(jù)比如你畫了哪些圖元、每個圖元的參數(shù)視圖類負(fù)責(zé)把數(shù)據(jù)畫出來。很多初學(xué)者把圖元數(shù)據(jù)直接塞在視圖類里小工程能跑但一旦要做撤銷重做、序列化保存就會很痛苦。這份源碼如果結(jié)構(gòu)規(guī)范圖元數(shù)據(jù)應(yīng)該在 Doc 類里以某種容器常見是CArray或std::vector保存View 類只負(fù)責(zé)遍歷繪制。2.2 編譯環(huán)境的版本匹配MFC 對 Visual Studio 版本和工具集比較敏感。源碼如果是用某個版本的 VS 創(chuàng)建的換一個版本打開時項目屬性里的“平臺工具集”和“Windows SDK 版本”很可能對不上直接報“找不到 vXXX 工具集”或者一堆a(bǔ)fxwin.h找不到的錯誤。處理辦法是右鍵項目 → 屬性 → 常規(guī)把平臺工具集改成你本機(jī)裝了的版本W(wǎng)indows SDK 版本也選本機(jī)存在的。另外要確認(rèn)安裝 VS 時勾選了“使用 C 的桌面開發(fā)”里的 MFC 組件這個組件默認(rèn)不一定裝。檢查方法是在 VS Installer 里看單個組件搜“MFC”把對應(yīng)版本勾上。# 如果不想開 VS可以用命令行確認(rèn) MFC 頭文件是否存在 # 以 VS2022 為例路徑隨版本變化按實際安裝調(diào)整 dir C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\*\atlmfc\include\afxwin.h這條命令的作用是確認(rèn)afxwin.h這個 MFC 核心頭文件在不在。如果返回“找不到文件”說明 MFC 組件沒裝編譯必然失敗。參數(shù)上注意*是通配工具集版本號因為 MSVC 目錄名帶具體版本用通配能避免手寫錯。2.3 字符集與預(yù)編譯頭MFC 工程常見兩個編譯坑字符集和預(yù)編譯頭。字符集分 Unicode 和多字節(jié)老源碼很多是多字節(jié)字符集寫的用char*和strcpy這類函數(shù)如果項目屬性設(shè)成了 Unicode就會報一堆const char*不能轉(zhuǎn)LPCWSTR的錯誤。解決方式是把項目屬性 → 高級 → 字符集改成“使用多字節(jié)字符集”或者反過來把源碼里的字符串都加_T()宏適配。預(yù)編譯頭pch.h老版本叫stdafx.h必須放在每個 .cpp 文件的第一行包含否則報“預(yù)編譯頭文件來自編譯器的早期版本”或者直接找不到。如果源碼里某個 cpp 忘了加補(bǔ)上即可。提示改字符集和工具集之后最好執(zhí)行一次“生成 → 清理解決方案”再重新生成避免舊的中間文件干擾。3. 核心算法拆解直線、圓與多邊形的像素級實現(xiàn)3.1 DDA 與 Bresenham 直線算法二維繪圖工具里畫直線看著簡單但落到像素上就是另一回事。屏幕像素是離散的你給兩個端點中間那些點怎么選就是直線生成算法要解決的。源碼里一般會實現(xiàn)兩種DDA 和 Bresenham。DDA 的思路是增量法按 x 或 y 方向步進(jìn)每步算一次浮點坐標(biāo)再取整。實現(xiàn)直觀但涉及浮點運(yùn)算。Bresenham 全程整數(shù)運(yùn)算效率更高是實際工程里更常用的。下面這段是 Bresenham 的典型實現(xiàn)我按源碼里常見的寫法整理// Bresenham 直線算法假設(shè) |dx| |dy|即斜率絕對值不超過 1 void DrawLineBresenham(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF color) { int dx abs(x1 - x0); int 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; // 誤差項初值 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是決策變量代表當(dāng)前像素位置到理想直線的偏差。e2 2*err是為了避免浮點比較把判斷放大兩倍。兩個if分別決定 x 和 y 是否步進(jìn)這樣能覆蓋所有八分圓方向不用像教科書那樣分八種情況寫。參數(shù)上x0,y0是起點x1,y1是終點color是像素顏色。注意SetPixelV比SetPixel快因為它不返回實際繪制顏色繪圖工具里優(yōu)先用它。3.2 中點圓算法與橢圓生成圓生成常用中點圓算法本質(zhì)是 Bresenham 思想在圓上的推廣。利用八對稱性只算八分之一圓弧其余七個對稱點直接映射效率翻八倍。核心是決策變量判斷下一個像素取正右方還是右下方。// 中點圓算法只畫八分之一再對稱 void DrawCircleMidpoint(CDC* pDC, int xc, int yc, int r, COLORREF color) { int x 0, y r; int d 1 - r; // 決策變量初值 while (x y) { // 八對稱畫點 pDC-SetPixelV(xc x, yc y, color); pDC-SetPixelV(xc - x, yc y, color); pDC-SetPixelV(xc x, yc - y, color); pDC-SetPixelV(xc - x, yc - y, color); pDC-SetPixelV(xc y, yc x, color); pDC-SetPixelV(xc - y, yc x, color); pDC-SetPixelV(xc y, yc - x, color); pDC-SetPixelV(xc - y, yc - x, color); if (d 0) { d 2 * x 3; } // 取正右方點 else { d 2 * (x - y) 5; y--; } // 取右下方點 x; } }邏輯說明d是決策變量d 0說明中點在圓內(nèi)選正右方像素否則選右下方。d的更新公式2x3和2(x-y)5是推導(dǎo)后的整數(shù)形式避免每次開方。參數(shù)xc,yc是圓心r是半徑。橢圓生成思路類似但對稱性只有四對稱決策變量推導(dǎo)更復(fù)雜源碼里如果實現(xiàn)了橢圓通常單獨一個函數(shù)參數(shù)多兩個半軸長。3.3 多邊形填充掃描線算法的邊界處理多邊形填充是繪圖工具里最容易出 bug 的部分。掃描線算法的思路是對每條掃描線求它與多邊形各邊的交點排序后兩兩配對配對區(qū)間內(nèi)的像素填充。聽起來簡單但頂點相交、水平邊、奇偶性判斷這幾個地方全是坑。// 掃描線填充核心片段求交點并排序 struct Edge { double x; int ymax; double dxdy; }; // 活性邊表 AET按 x 排序 std::sort(aet.begin(), aet.end(), [](const Edge a, const Edge b) { return a.x b.x; }); // 兩兩配對填充 for (size_t i 0; i 1 aet.size(); i 2) { int xStart (int)ceil(aet[i].x); int xEnd (int)floor(aet[i 1].x); for (int x xStart; x xEnd; x) pDC-SetPixelV(x, y, color); }邏輯說明aet是活性邊表存當(dāng)前掃描線相交的邊每條邊記錄交點 x、邊的最大 yymax和斜率倒數(shù)dxdy。排序后相鄰兩個交點構(gòu)成一個填充區(qū)間。參數(shù)上ceil和floor是為了處理交點非整數(shù)的情況保證填充不越界。頂點相交的處理原則是如果頂點是局部極值點算兩個交點如果不是算一個。水平邊直接跳過不參與交點計算否則會多算。注意掃描線算法對自相交多邊形和帶孔多邊形需要額外處理源碼如果只支持簡單凸/凹多邊形遇到復(fù)雜圖形會填充異常這是算法本身的邊界不是代碼 bug。4. 二維變換與交互鼠標(biāo)消息怎么驅(qū)動圖形4.1 平移、旋轉(zhuǎn)、縮放的矩陣表示二維變換用齊次坐標(biāo)的 3x3 矩陣統(tǒng)一表示平移、旋轉(zhuǎn)、縮放都能寫成矩陣乘法。源碼里如果做了變換功能通常會有一個變換矩陣類或者直接用數(shù)組存。旋轉(zhuǎn)要繞某點轉(zhuǎn)標(biāo)準(zhǔn)做法是“平移到原點 → 旋轉(zhuǎn) → 平移回去”三步矩陣連乘。// 二維點繞 (cx,cy) 旋轉(zhuǎn) angle 弧度 void RotatePoint(double x, double y, double cx, double cy, double angle) { double rad angle * 3.14159265358979 / 180.0; // 角度轉(zhuǎn)弧度 double cosA cos(rad), sinA sin(rad); double tx x - cx, ty y - cy; // 平移到原點 x cx tx * cosA - ty * sinA; // 旋轉(zhuǎn)后平移回去 y cy tx * sinA ty * cosA; }邏輯說明先把點相對旋轉(zhuǎn)中心平移旋轉(zhuǎn)完再加回中心坐標(biāo)。參數(shù)angle是角度制內(nèi)部轉(zhuǎn)弧度。cosA和sinA提前算好避免重復(fù)計算。如果要對整個圖形變換需要對每個頂點都調(diào)用一次變換完重繪。4.2 鼠標(biāo)消息映射與交互流程MFC 里鼠標(biāo)交互靠消息映射。在 View 類的消息映射表里加ON_WM_LBUTTONDOWN、ON_WM_MOUSEMOVE、ON_WM_LBUTTONUP然后實現(xiàn)對應(yīng)的OnLButtonDown、OnMouseMove、OnLButtonUp函數(shù)。典型流程是按下左鍵記錄起點移動時如果處于繪制狀態(tài)就畫橡皮筋預(yù)覽抬起時確定終點并把圖元存入文檔。void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing true; // 進(jìn)入繪制狀態(tài) m_ptStart point; // 記錄起點 m_ptEnd point; CView::OnLButtonDown(nFlags, point); } void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd point; // 更新終點 Invalidate(); // 觸發(fā)重繪畫預(yù)覽 } CView::OnMouseMove(nFlags, point); }邏輯說明m_bDrawing是繪制狀態(tài)標(biāo)志m_ptStart和m_ptEnd存起點終點。Invalidate()會觸發(fā)OnDraw重繪在OnDraw里根據(jù)當(dāng)前狀態(tài)決定畫不畫預(yù)覽線。參數(shù)point是客戶區(qū)坐標(biāo)注意如果用了滾動條要轉(zhuǎn)成邏輯坐標(biāo)否則滾動后坐標(biāo)會偏。4.3 雙緩沖消除閃爍MFC 直接OnDraw里畫圖拖動時閃爍會很嚴(yán)重。標(biāo)準(zhǔn)解法是雙緩沖先在內(nèi)存 DC 里畫好整幅圖再一次性 BitBlt 到屏幕 DC。void CMyView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; memDC.CreateCompatibleDC(pDC); // 創(chuàng)建兼容內(nèi)存 DC CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(bmp); // 選入位圖 memDC.FillSolidRect(rect, RGB(255,255,255)); // 背景 // 在這里用 memDC 畫所有圖元而不是 pDC DrawAllShapes(memDC); pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); }邏輯說明memDC是內(nèi)存設(shè)備上下文bmp是兼容位圖。所有繪制操作先落到memDC最后BitBlt一次性拷貝到屏幕。參數(shù)SRCCOPY表示直接拷貝源像素。這樣屏幕只刷新一次閃爍基本消除。注意bmp要選入memDC后再畫否則畫到默認(rèn)的 1x1 位圖上什么都看不到。5. 避坑與排查編譯、繪圖、交互里的血淚經(jīng)驗5.1 編譯報錯“無法打開 afxwin.h”現(xiàn)象一編譯就報找不到afxwin.h或afxext.h。原因VS 安裝時沒勾 MFC 組件或者項目工具集和本機(jī) MFC 版本不匹配。解決打開 VS Installer修改安裝在單個組件里搜 MFC勾選對應(yīng)版本然后確認(rèn)項目屬性里的平臺工具集和本機(jī)一致。5.2 畫出來的線是斷的或者位置偏移現(xiàn)象直線畫出來中間有斷點或者整體偏移幾個像素。原因一是用了SetPixel但坐標(biāo)算錯二是客戶區(qū)坐標(biāo)和邏輯坐標(biāo)沒轉(zhuǎn)換三是OnDraw里沒考慮滾動偏移。解決檢查坐標(biāo)轉(zhuǎn)換用DPtoLP和LPtoDP做設(shè)備坐標(biāo)和邏輯坐標(biāo)互轉(zhuǎn)確認(rèn)OnDraw里pDC的原點設(shè)置正確。5.3 拖動繪制時閃爍嚴(yán)重現(xiàn)象鼠標(biāo)拖動預(yù)覽線時屏幕狂閃。原因每次Invalidate都直接往屏幕 DC 畫沒有雙緩沖。解決按 4.3 節(jié)改成內(nèi)存 DC 雙緩沖所有繪制先落內(nèi)存位圖再 BitBlt。如果還閃檢查是不是在OnEraseBkgnd里做了重繪可以返回 TRUE 屏蔽背景擦除。5.4 多邊形填充出現(xiàn)漏填或過填現(xiàn)象凹多邊形填充時某些區(qū)域沒填上或者填到了多邊形外面。原因頂點相交處理不對或者水平邊沒跳過導(dǎo)致交點個數(shù)是奇數(shù)。解決按 3.3 節(jié)的規(guī)則處理頂點——局部極值算兩個交點非極值算一個水平邊直接跳過。另外交點排序后要檢查配對數(shù)量奇數(shù)說明有邊沒處理對。5.5 內(nèi)存 DC 用完沒釋放導(dǎo)致資源泄漏現(xiàn)象程序跑久了變卡或者反復(fù)重繪后報 GDI 資源耗盡。原因每次OnDraw都CreateCompatibleDC和CreateCompatibleBitmap但沒DeleteDC和DeleteObject。解決把內(nèi)存 DC 和位圖做成 View 類的成員變量只在窗口大小變化時重建OnDraw里復(fù)用或者在函數(shù)結(jié)束前確保memDC.DeleteDC()和bmp.DeleteObject()被調(diào)用。提示GDI 對象泄漏在任務(wù)管理器里看不出來要用任務(wù)管理器的“GDI 對象”列或者專門的工具查數(shù)字持續(xù)上漲就是泄漏。6. 進(jìn)階玩法把繪圖工具改成算法演示器源碼跑通之后別急著交差。我一般會做一件事把它改成一個算法對比演示器同一個圖形用不同算法畫直觀看出差異。具體做法是加一個算法選擇下拉框或者菜單項在OnDraw里根據(jù)當(dāng)前選中的算法分支調(diào)用不同的繪制函數(shù)。比如畫圓可以同時用中點圓算法和直接用EllipseAPI對比像素級差異。再進(jìn)一步可以加一個“顯示像素網(wǎng)格”的開關(guān)把每個像素畫成小方格這樣 Bresenham 的決策過程一目了然。實現(xiàn)上就是在OnDraw里先畫網(wǎng)格線再畫圖形網(wǎng)格間距按縮放比例算。這個功能對理解光柵化特別有用也是課設(shè)答辯時的加分項。還有一個實用技巧是加撤銷重做。文檔類里維護(hù)一個操作棧每次添加圖元就壓棧撤銷就彈棧并重繪。MFC 的CDocument有Serialize函數(shù)配合CArchive還能把圖元存成文件下次打開直接恢復(fù)。這樣繪圖工具就從“能畫”升級到“能用”了。// 簡單的撤銷棧存圖元列表的快照 std::vectorstd::vectorShape m_undoStack; void PushUndo(const std::vectorShape shapes) { m_undoStack.push_back(shapes); // 壓入當(dāng)前狀態(tài)快照 if (m_undoStack.size() 50) // 限制棧深度防止內(nèi)存漲太快 m_undoStack.erase(m_undoStack.begin()); } void Undo() { if (!m_undoStack.empty()) { m_shapes m_undoStack.back(); // 恢復(fù)上一個狀態(tài) m_undoStack.pop_back(); Invalidate(); } }邏輯說明m_undoStack存的是圖元列表的快照每次操作前壓入當(dāng)前狀態(tài)。PushUndo里限制棧深度 50避免長時間使用內(nèi)存無限增長。Undo彈出最后一個快照恢復(fù)并重繪。參數(shù)上快照方式簡單但費(fèi)內(nèi)存圖元特別多時可以改成存操作記錄而不是全量快照。從那以后我每次拿到一份圖形學(xué)源碼都強(qiáng)制自己先跑通、再改一個算法、最后加一個交互功能三步走完才算真正吃透。希望幫到你。本文還有配套的精品資源點擊獲取