VC++ GDI实现DDA与Bresenham画线、中点画圆算法详解

📅 2026/7/14 15:29:18
VC++ GDI实现DDA与Bresenham画线、中点画圆算法详解
1. 项目概述最近在整理一些老项目的代码翻出来一个大学时期用VC做的计算机图形学课程作业。这个项目虽然不大但麻雀虽小五脏俱全它完整地实现了从底层像素操作到经典画线、画圆算法的全过程。现在回想起来当时为了搞明白为什么一个简单的画圆算法要分八分之一象限来算还有那个看起来有点绕的决策参数可没少花功夫。今天我就把这个项目的核心实现思路、代码细节以及那些年踩过的坑系统地梳理一遍。无论你是正在学习图形学的学生还是想用VC的GDI接口重温一下基础算法的开发者这篇内容应该都能给你一些直接的参考。这个项目的核心目标很明确不依赖任何高级图形库如OpenGL、DirectX纯粹利用VC这里特指Visual C配合MFC框架的Windows GDI接口在窗口的客户区上通过手动计算每个像素点的坐标来实现直线和圆的绘制。我们会重点剖析两个最经典的算法用于画线的DDA算法和Bresenham算法以及用于画圆的中点画圆算法它本质上是Bresenham算法在圆上的变种。你会发现这些几十年前的算法思想至今在嵌入式图形、低层渲染优化中依然闪烁着智慧的光芒。2. 环境搭建与项目创建在开始敲代码之前得先把舞台搭好。我们选择经典的Visual Studio 2019配合MFCMicrosoft Foundation Classes来创建项目。为什么是MFC而不是控制台程序因为我们需要一个窗口来作为画布直观地看到像素点一个个被“点亮”的过程MFC提供了现成的文档-视图架构和消息处理机制能让我们更专注于算法本身而不是去折腾窗口创建和消息循环。2.1 创建MFC应用程序项目打开Visual Studio 2019选择“创建新项目”在搜索框里输入“MFC”选择“MFC应用程序”点击下一步。给项目起个名字比如“GraphicsPrimitives”。在“应用程序类型”中选择“单个文档”这样我们就有一个主框架窗口和一个与之关联的视图类。在“项目样式”里选择“Visual Studio”风格即可“文档模板属性”和“数据库支持”都用默认设置无。最后在“用户界面功能”中确保“使用经典菜单”和“使用工具栏”是选中的这样我们就有地方添加自己的菜单命令了。创建完成后解决方案资源管理器里会出现一系列文件。对我们来说最关键的是CGraphicsPrimitivesView类它继承自CView负责处理视图的绘制。所有的绘图代码最终都会写在这个类的OnDraw函数或者我们自定义的绘图函数里。2.2 理解GDI与设备上下文在Windows下绘图离不开一个核心概念设备上下文。你可以把它想象成画家的画布、调色板和画笔的集合体它是一个包含了绘图属性如当前画笔、画刷、颜色、字体和绘图方法的抽象对象。在MFC中CDC类封装了设备上下文。当我们想在窗口上画一个点时步骤是这样的获取当前视图的设备上下文。在OnDraw函数中会传入一个pDC指针它就是我们的“画笔”。设置像素点的颜色。通过pDC-SetPixel(x, y, color)函数在坐标(x, y)处用指定颜色画一个点。我们的所有算法最终目标就是计算出一系列连续的(x, y)坐标然后循环调用SetPixel从而在视觉上形成一条线或一个圆。这里有一个非常重要的概念客户区坐标。窗口的客户区是指除去标题栏、菜单栏、边框之后我们可以自由绘制的区域。它的原点(0,0)在客户区的左上角x轴向右为正y轴向下为正。这一点和数学上常见的坐标系不同在实现算法时需要特别注意y坐标的处理。注意直接使用SetPixel进行大量绘制效率很低因为它每次调用都会涉及系统调用。对于这个教学项目为了清晰展示算法过程这是可以接受的。在实际应用中我们会使用双缓冲技术或直接操作位图数据来提升性能。3. 直线生成算法详解与实现直线是构成图形世界最基本的图元。如何在离散的像素网格上用最合适的像素点来逼近一条连续的数学直线就是直线生成算法要解决的问题。3.1 DDA数值微分算法DDA算法的思想非常直观利用直线的微分方程。假设直线的起点是(x0, y0)终点是(x1, y1)。那么直线的斜率k (y1 - y0) / (x1 - x0)。算法步骤计算dx x1 - x0,dy y1 - y0。确定步进次数steps取dx和dy中绝对值较大的那个。这是为了保证在像素网格上每一步至少移动一个像素让画出的线连续。计算每一步x和y的增量xIncrement dx / steps,yIncrement dy / steps。设置初始点x x0,y y0。循环steps次每次将x和y分别加上xIncrement和yIncrement并对结果进行四舍五入取整得到当前像素坐标然后画点。C实现代码void CGraphicsPrimitivesView::DrawLine_DDA(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF color) { // 计算差值 int dx x1 - x0; int dy y1 - y0; // 确定步数取绝对值大的 int steps abs(dx) abs(dy) ? abs(dx) : abs(dy); // 避免除零错误 if (steps 0) { pDC-SetPixel(x0, y0, color); return; } // 计算每一步的增量 float xIncrement static_castfloat(dx) / steps; float yIncrement static_castfloat(dy) / steps; // 初始点使用浮点数以保证精度 float x static_castfloat(x0); float y static_castfloat(y0); // 开始画点 for (int i 0; i steps; i) { // 四舍五入取整得到像素坐标 pDC-SetPixel(static_castint(x 0.5f), static_castint(y 0.5f), color); x xIncrement; y yIncrement; } }DDA算法的特点与局限优点逻辑简单易于理解和实现适合斜率绝对值小于1的平缓直线。缺点每一步都需要进行浮点数加法和四舍五入操作效率较低。在斜率绝对值大于1的陡直线上由于步数由dx决定可能导致画出的点不连续除非步数取dy的绝对值但通用代码需要判断。因此它更多是作为一种教学算法。3.2 Bresenham画线算法Bresenham算法是图形学中里程碑式的算法它完全消除了浮点运算和取整只使用整数加减法和位操作效率极高。其核心思想是通过一个决策参数来判断下一个像素点应该选在当前位置的正右方还是右上方。我们以斜率在0到1之间的直线为例即0 k 1。假设当前已确定的像素点为P(xp, yp)那么下一个候选像素点有两个正右方的E(xp1, yp)和右上方的NE(xp1, yp1)。我们需要判断理想直线与x xp1这条垂直网格线的交点Q更靠近E还是NE。算法推导与决策参数设直线方程为y kx b其中k dy/dx且dy, dx 0dx dy。 定义决策参数pp 2 * dy * (xp1) - 2 * dx * yp (2*b*dx dx - 2*dy)为了消除浮点数Bresenham的关键技巧是只判断p的符号。经过化简减去一个常数项不影响符号判断我们可以得到递推公式初始决策参数p0 2dy - dx如果p 0说明中点M在直线下方直线更靠近E点。选择E且下一个决策参数为p_next p 2dy如果p 0说明中点M在直线上方直线更靠近NE点。选择NE且下一个决策参数为p_next p 2dy - 2dx通用Bresenham算法实现处理所有八分圆在实际编码中我们不会为每种斜率情况写一遍代码而是通过计算dx和dy的符号和绝对值将任意直线都转换到第一象限的0k1情况来处理最后再通过对称映射回原坐标。void CGraphicsPrimitivesView::DrawLine_Bresenham(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方向的步进符号 int sy (y0 y1) ? 1 : -1; // y方向的步进符号 int err dx - dy; // 初始决策参数这里是 p dx - dy 的一种变形 while (true) { pDC-SetPixel(x0, y0, color); if (x0 x1 y0 y1) break; int e2 2 * err; if (e2 -dy) { // 判断是否向x方向移动对应原算法中p0的判断形式不同但本质一致 err - dy; x0 sx; } if (e2 dx) { // 判断是否向y方向移动对应原算法中p0的判断 err dx; y0 sy; } } }这段代码是Bresenham算法的一个非常精炼且通用的实现。它通过一个误差项err同时控制x和y方向的移动能够处理所有方向的直线。e2 -dy和e2 dx这两个条件共同决定了下一个像素点的选择。实操心得很多教科书上的Bresenham算法只给出了第一象限0k1的推导。上面这个通用版本虽然看起来简短但理解其如何通过sx、sy处理方向以及err的更新逻辑需要对照着基本推导多画图理解。这是从“知道”到“会用”的关键一步。4. 圆的生成算法详解与实现圆的方程是x² y² R²。如何在像素网格上选择点来逼近这个圆最经典的算法是中点画圆算法它同样是Bresenham思想在圆上的应用。4.1 中点画圆算法原理算法的核心是利用圆的八分对称性。我们只需要计算出第一象限中从(0, R)到(R/√2, R/√2)这八分之一圆弧上的像素点然后通过对称性就能得到整个圆。考虑第一象限的八分之一圆弧。假设当前已确定的像素点为P(xp, yp)那么下一个像素点有两个候选正右方的E(xp1, yp)和右下方的SE(xp1, yp-1)。我们需要判断这两个候选点的中点M(xp1, yp-0.5)是在圆内还是圆外。定义圆函数F(x, y) x² y² - R²如果F(M) 0则中点M在圆内说明圆弧更靠近E点。如果F(M) 0则中点M在圆外或圆上说明圆弧更靠近SE点。决策参数的推导设当前决策参数为d对应于F(M)。若d 0选中点E。下一个中点M的坐标是(xp2, yp-0.5)。可以推导出新的决策参数d_new d (2*xp 3)若d 0选中点SE。下一个中点M的坐标是(xp2, yp-1.5)。可以推导出新的决策参数d_new d (2*xp - 2*yp 5)初始决策参数从点(0, R)开始第一个中点是(1, R-0.5)所以初始d0 F(1, R-0.5) 1.25 - R。为了消除浮点数我们可以令D 4 * d这样初始D0 5 - 4*R并且递推公式中的系数也相应乘以4。4.2 算法实现与八分对称绘制void CGraphicsPrimitivesView::DrawCircle_MidPoint(CDC* pDC, int centerX, int centerY, int radius, COLORREF color) { int x 0; int y radius; int d 1 - radius; // 初始化决策参数 D 1 - R (由 5/4 - R 的4倍取整而来) // 绘制初始的八个对称点 Plot8Points(pDC, centerX, centerY, x, y, color); while (x y) { x; if (d 0) { // 选择E点中点在内 d 2 * x 1; } else { // 选择SE点中点在外或上 y--; d 2 * (x - y) 1; } // 每确定一个点就绘制其八个对称点 Plot8Points(pDC, centerX, centerY, x, y, color); } } // 辅助函数根据第一象限的一个点(x,y)绘制圆的八个对称点 void CGraphicsPrimitivesView::Plot8Points(CDC* pDC, int cx, int cy, int x, int y, COLORREF color) { // 确保坐标在客户区内避免绘制到窗口外可选但更健壮 CRect rect; GetClientRect(rect); auto SafeSetPixel [](int px, int py) { if (rect.PtInRect(CPoint(px, py))) { pDC-SetPixel(px, py, color); } }; SafeSetPixel(cx x, cy y); // 第一象限 SafeSetPixel(cx y, cy x); // 第一象限对称 SafeSetPixel(cx y, cy - x); // 第四象限 SafeSetPixel(cx x, cy - y); // 第四象限对称 SafeSetPixel(cx - x, cy - y); // 第三象限 SafeSetPixel(cx - y, cy - x); // 第三象限对称 SafeSetPixel(cx - y, cy x); // 第二象限 SafeSetPixel(cx - x, cy y); // 第二象限对称 }代码解析与注意事项决策参数初始化代码中d 1 - radius是对d0 1.25 - R取整后的结果。因为d0的分数部分是0.25在整数算法中我们只关心d的符号这个0.25的偏移不影响第一个像素的选择d0为负时选E为正时选SE取整后算法依然正确。递推公式d 2*x 1对应4*(2*xp3)化简并除以4d 2*(x - y) 1对应4*(2*xp - 2*yp 5)化简并除以4。注意这里的x和y是已经更新后的值。循环条件while (x y)确保了只计算从(0,R)到(R/√2, R/√2)这八分之一圆弧。当x y时到达45度角圆弧结束。对称绘制Plot8Points函数是算法的关键优化。计算一个点利用圆的对称性画出八个点极大地减少了计算量。绘制顺序涵盖了所有八个象限。踩坑记录在实现Plot8Points时最容易出错的就是八个对称点的坐标正负号。一个有效的检查方法是先在纸上画一个圆标出圆心(cx,cy)和第一象限的点(cxx, cyy)然后想象这个点关于x轴、y轴、原点以及yx直线的对称点就能准确写出其他七个点的坐标。另外添加SafeSetPixel进行边界检查是个好习惯能防止因圆心坐标或半径过大导致程序尝试在窗口外绘图虽然GDI的SetPixel对越界坐标通常有容错但严谨的代码能避免潜在问题。5. 在MFC视图中的集成与交互算法函数写好了我们需要把它们集成到MFC的视图类中并通过菜单或鼠标交互来触发绘制。5.1 修改视图类OnDraw函数首先我们可以在CGraphicsPrimitivesView::OnDraw函数中直接调用我们的算法函数以便在窗口重绘时能显示图形。void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { CGraphicsPrimitivesDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 示例在视图中心画一条线和一个圆 CRect rect; GetClientRect(rect); int centerX rect.Width() / 2; int centerY rect.Height() / 2; // 画一条从左上到右下的对角线 DrawLine_Bresenham(pDC, 50, 50, centerX 100, centerY 100, RGB(255, 0, 0)); // 红色直线 // 画一个半径为80的圆 DrawCircle_MidPoint(pDC, centerX, centerY, 80, RGB(0, 0, 255)); // 蓝色圆 }5.2 添加菜单命令实现交互为了让用户能动态选择绘制不同的图形我们添加菜单项。编辑菜单资源在资源视图中打开IDR_MAINFRAME菜单在“绘图”菜单下如果没有就新建一个添加子菜单项例如ID_DRAW_LINE_DDA使用DDA算法画线ID_DRAW_LINE_BRES使用Bresenham算法画线ID_DRAW_CIRCLE使用中点算法画圆为菜单项添加消息处理在CGraphicsPrimitivesView类上右键选择“类向导”。在“命令”页选择ID_DRAW_LINE_BRES等对象ID在“消息”框选择COMMAND点击“添加处理程序”。这会自动生成消息映射和空的处理函数。实现消息处理函数在这些函数中我们需要记录用户的操作状态例如开始点、结束点并触发视图重绘。一种常见的做法是在视图类中添加成员变量如m_nDrawMode绘制模式、m_ptStart起点、m_ptEnd终点。在菜单命令处理函数中设置m_nDrawMode。重写OnLButtonDown和OnLButtonUp消息处理函数。在OnLButtonDown中记录起点在OnLButtonUp中记录终点并调用Invalidate()强制窗口重绘。在OnDraw函数中根据m_nDrawMode、m_ptStart、m_ptEnd的值调用相应的算法函数进行绘制。示例代码片段// GraphicsPrimitivesView.h class CGraphicsPrimitivesView : public CView { ... protected: int m_nDrawMode; // 0:无1:DDA线2:Bres线3:圆 CPoint m_ptStart; CPoint m_ptEnd; ... }; // GraphicsPrimitivesView.cpp void CGraphicsPrimitivesView::OnDrawLineBres() { m_nDrawMode 2; // 设置为Bresenham画线模式 // 可以在这里设置光标形状提示用户开始画线 } void CGraphicsPrimitivesView::OnLButtonDown(UINT nFlags, CPoint point) { if (m_nDrawMode ! 0) { m_ptStart point; m_ptEnd point; // 初始化终点 SetCapture(); // 捕获鼠标确保在窗口外也能收到消息 } CView::OnLButtonDown(nFlags, point); } void CGraphicsPrimitivesView::OnMouseMove(UINT nFlags, CPoint point) { if (m_nDrawMode ! 0 GetCapture() this) { // 实时更新终点可以实现橡皮筋效果 m_ptEnd point; Invalidate(FALSE); // 请求重绘FALSE表示不擦除背景减少闪烁 } CView::OnMouseMove(nFlags, point); } void CGraphicsPrimitivesView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_nDrawMode ! 0 GetCapture() this) { ReleaseCapture(); // 释放鼠标捕获 m_ptEnd point; Invalidate(); // 最终绘制 // m_nDrawMode 0; // 可选完成一次绘制后重置模式 } CView::OnLButtonUp(nFlags, point); } void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { ... // 原有的示例绘制代码可以保留或移除 // 根据交互模式绘制 switch (m_nDrawMode) { case 1: // DDA线 DrawLine_DDA(pDC, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(255, 0, 0)); break; case 2: // Bresenham线 DrawLine_Bresenham(pDC, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0, 255, 0)); break; case 3: // 圆 { int radius static_castint(sqrt(pow(m_ptEnd.x - m_ptStart.x, 2) pow(m_ptEnd.y - m_ptStart.y, 2))); DrawCircle_MidPoint(pDC, m_ptStart.x, m_ptStart.y, radius, RGB(0, 0, 255)); } break; } }5.3 实现橡皮筋绘制效果上面的OnMouseMove函数中已经预留了实现橡皮筋效果的基础。但直接调用Invalidate(FALSE)会导致整个客户区重绘如果图形复杂会有闪烁。更高级的做法是使用异或绘图模式。在OnMouseMove中将设备上下文设置为R2_NOTXORPEN模式。这种模式下用同一种颜色画两次第一次画上图形第二次会擦除它恢复原状。首先用上次的起点和终点旧图形画一次擦除旧的橡皮筋。然后用起点和当前鼠标位置新图形画一次显示新的橡皮筋。记得在绘制前后保存和恢复设备上下文的原始状态。void CGraphicsPrimitivesView::OnMouseMove(UINT nFlags, CPoint point) { if (m_nDrawMode ! 0 GetCapture() this) { CClientDC dc(this); // 获取当前视图的设备上下文 int nOldMode dc.SetROP2(R2_NOTXORPEN); // 设置异或模式并保存旧模式 // 擦除旧的橡皮筋线 switch (m_nDrawMode) { case 1: case 2: DrawLine_Bresenham(dc, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0,0,0)); // 颜色在异或模式下不重要 break; case 3: // 画圆擦除稍复杂需要计算旧半径 break; } // 更新终点并绘制新的橡皮筋线 m_ptEnd point; switch (m_nDrawMode) { case 1: case 2: DrawLine_Bresenham(dc, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0,0,0)); break; case 3: // 绘制新圆 break; } dc.SetROP2(nOldMode); // 恢复原始的绘图模式 } CView::OnMouseMove(nFlags, point); }注意事项异或绘图非常高效且无闪烁但它对颜色有特殊要求且在某些复杂背景下可能效果不佳。对于画圆的橡皮筋实现起来比画线复杂因为需要擦除一个旧圆再画一个新圆计算量稍大。在实际项目中可以根据需求选择是否使用。6. 算法对比、常见问题与优化思考实现了基本功能后我们可以从几个维度对比一下这些算法并思考实际应用中可能遇到的问题。6.1 算法性能与效果对比我们可以编写一个简单的测试函数在视图上绘制大量图形来直观感受差异。void CGraphicsPrimitivesView::TestAlgorithms(CDC* pDC) { const int NUM_LINES 1000; const int LENGTH 200; CRect rect; GetClientRect(rect); DWORD startTime, endTime; // 测试DDA算法 startTime GetTickCount(); for (int i 0; i NUM_LINES; i) { int x0 rand() % rect.Width(); int y0 rand() % rect.Height(); DrawLine_DDA(pDC, x0, y0, x0 LENGTH, y0 LENGTH, RGB(255, 0, 0)); } endTime GetTickCount(); CString strMsg; strMsg.Format(_T(DDA绘制 %d 条线用时%d ms), NUM_LINES, endTime - startTime); // 可以用TRACE输出或绘制到屏幕上 // 测试Bresenham算法 startTime GetTickCount(); for (int i 0; i NUM_LINES; i) { int x0 rand() % rect.Width(); int y0 rand() % rect.Height(); DrawLine_Bresenham(pDC, x0, y0, x0 LENGTH, y0 LENGTH, RGB(0, 255, 0)); } endTime GetTickCount(); strMsg.Format(_T(Bresenham绘制 %d 条线用时%d ms), NUM_LINES, endTime - startTime); // 测试中点画圆算法 startTime GetTickCount(); for (int i 0; i NUM_LINES/10; i) { // 圆计算量更大少画一些 int cx rand() % rect.Width(); int cy rand() % rect.Height(); DrawCircle_MidPoint(pDC, cx, cy, 50, RGB(0, 0, 255)); } endTime GetTickCount(); strMsg.Format(_T(中点画圆绘制 %d 个圆用时%d ms), NUM_LINES/10, endTime - startTime); }对比结论速度Bresenham画线算法远快于DDA算法因为它只有整数加减和位运算。中点画圆算法也很快。精度两者在视觉上几乎没有区别都能生成光滑的直线/圆。Bresenham算法由于是纯整数运算没有浮点误差累积问题。适用范围DDA算法在斜率绝对值很大接近垂直时由于步长由x决定y方向增量很大可能导致点不连续。Bresenham算法是通用的。中点画圆算法是画圆的标准算法。6.2 常见问题与排查画出的线不连续、有断点原因最常见的原因是坐标计算错误尤其是在处理不同斜率象限时方向步进sx,sy设置错误或者决策参数更新逻辑写反了。排查用最简单的水平线(0,0)-(10,0)和垂直线(0,0)-(0,10)测试。如果水平线正常垂直线画不出来或只有起点说明算法没有正确处理dx为0或dy为0的情况。检查循环条件和步进逻辑。圆看起来像椭圆或者不对称原因像素在屏幕上的宽高比通常不是1:1。在大多数显示设备上像素是矩形而不是正方形。我们的算法假设坐标系是均匀的。解决如果需要绘制正圆需要根据设备上下文获取逻辑坐标到设备坐标的映射比例并在计算y坐标时进行补偿。更简单的方法是使用GDI的Ellipse函数它会自动处理比例。我们这个项目旨在理解算法原理所以通常忽略宽高比。绘制大量图形时窗口闪烁严重原因直接在OnDraw中频繁调用SetPixel每次绘制都立即更新屏幕且OnDraw被调用时会先用背景色擦除整个客户区。解决使用双缓冲技术。原理是先在内存中创建一个与屏幕画布兼容的位图CBitmap获取其设备上下文CDC然后所有的绘图操作都先在这个内存DC上进行。绘制完成后一次性将这个内存位图拷贝到屏幕DC上。这能有效消除闪烁。代码片段void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); // 创建内存DC和兼容位图 CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 用背景色填充内存位图模拟擦除背景 memDC.FillSolidRect(rect, RGB(255, 255, 255)); // 白色背景 // 在内存DC上调用你的绘图算法 DrawLine_Bresenham(memDC, ...); DrawCircle_MidPoint(memDC, ...); // 将内存位图一次性拷贝到屏幕DC pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 清理资源 memDC.SelectObject(pOldBitmap); memBitmap.DeleteObject(); memDC.DeleteDC(); }菜单点击后之前画的图形消失了原因MFC的文档-视图架构中视图的OnDraw函数在需要重绘时如窗口被覆盖后恢复、调整大小会被调用。如果你只在菜单命令处理函数中画图而没有将图形数据保存下来那么重绘时OnDraw函数就不知道要画什么。解决正确的做法是将图形的数据如起点、终点、类型、颜色保存在文档类CDocument派生类的成员变量中如一个CArray或std::vector。在菜单命令或鼠标事件中将新的图形数据添加到文档的集合中然后调用UpdateAllViews(NULL)通知所有视图更新。在视图的OnDraw函数中从文档获取所有图形数据并循环绘制。这才是MFC的标准数据流。6.3 扩展与优化思考抗锯齿上述算法生成的都是“锯齿状”的图形。工业级的图形库会使用抗锯齿技术如Wu反走样算法。它的基本思想是在直线经过的像素及其邻近像素上根据覆盖面积设置不同的灰度值使边缘看起来更平滑。你可以尝试实现一个简单的Wu算法作为挑战。绘制其他图元掌握了直线和圆可以举一反三实现椭圆中点椭圆算法、矩形、多边形填充扫描线填充算法、边界填充算法等。算法泛化Bresenham算法思想可以推广到绘制其他二次曲线甚至三次曲线。核心都是利用决策参数和递推关系避免乘法和浮点运算。性能极限即使使用Bresenham算法每个像素调用一次SetPixel也是瓶颈。在需要极高性能的场景如软件渲染器通常会直接操作帧缓冲区的内存数据。在Windows下可以通过CreateDIBSection获取指向位图数据的指针直接修改内存最后用BitBlt显示这比调用数万次SetPixel要快几个数量级。这个基于VC的图形学算法实现项目虽然界面简陋但它触及了计算机图形学最基础也是最核心的部分如何用离散的像素去表现连续的几何图形。理解这些算法不仅是为了完成作业更是为了在日后遇到更复杂的图形问题时能够洞察其底层原理知道性能瓶颈可能在哪里以及优化的方向在哪里。当你以后使用OpenGL或DirectX绘制一个光滑的3D模型时或许会想起这一切绚丽的起点不过是在一个像素一个像素地计算如何画出一条完美的直线。