尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于MFC的二维图形学绘图工具:从DDA到Bresenham的交互式实现

基于MFC的二维图形学绘图工具:从DDA到Bresenham的交互式实现 简介一套基于C MFC的二维绘图工具完整工程源代码面向计算机图形学学习者及Windows桌面应用开发者。项目实现了可交互的绘图界面涵盖鼠标消息处理、几何图形绘制、颜色与线型设置、文本输出、文件保存加载、菜单与状态栏等典型MFC功能模块适合作为图形学课程设计或MFC入门进阶的参考案例。压缩包共39个文件约1.94MB主要包含头文件.h、C源码.cpp、Visual Studio工程文件.sln/.vcproj/.dsp、资源脚本.rc/.rc2、图标与位图.ico/.bmp以及编译生成的obj、exe等工程结构清晰方便直接编译与二次开发。已有698人浏览学习。通过研读源码读者可掌握CDC绘图、CPen/CBrush使用、视图类交互、消息映射、内存设备上下文等关键知识点并借助可执行程序快速验证效果是一份实用的图形编程学习资料。 开年的时候我整理了一份基于 C 和 MFC 的二维绘图工具源代码起因想得很简单当时刚好给学生讲计算机图形学基础需要一套足够直观、能直接跑起来看效果的演示程序。市面上的图形学教材讲算法多、给工程少真要动手画一条直线、画一个圆很多学生卡在环境配置和消息机制上半天出不了图形。于是我就用 MFC 手写了一个轻量级绘图工具把 DDA、Bresenham 直线算法、中点画圆、多边形填充、裁剪这些经典内容全部做成可视化操作鼠标点两下就能看到算法一步步把像素画出来。这篇文章就把这套工具的设计思路、核心代码结构和踩坑经验完整记录下来。如果你正准备学计算机图形学或者想在 MFC 里快速实现一个带交互、能画图的小工具这份内容可以直接拿来当参考。1. 项目定位与整体设计思路1.1 为什么偏偏选了 MFC 来做图形学演示做图形学演示的方案其实很多OpenGL 直接上手、EasyX 简单粗暴、Qt 现代美观甚至纯 Win32 裸写都行。我最后选了 MFC核心原因有三个。第一个原因是 MFC 是 C 程序员绕不开的历史重资产。做过 Windows 桌面开发的都知道MFC 包裹了 Win32 的消息机制和 GDI 绘图接口用 C 的类封装了窗口、设备上下文、画笔画刷。这一套东西虽然老但设计思想很干净——你要在窗口上画图本质就是和设备上下文DC打交道这个心智模型和图形学里在帧缓冲上写像素的思路完全一致。第二个原因是 MFC 做交互式绘图特别顺手。图形学工具最核心的交互是鼠标操作按下、拖动、弹起这三个动作在 MFC 里直接对应三个消息响应函数不需要像裸 Win32 那样手动查消息派发表也不需要像 OpenGL 那样自己做坐标反算。你拿到的是已经换算好的客户区坐标可以直接丢给画线函数。第三个原因是源代码层面的可达性。MFC 不神秘它所有封装都能一层层扒开看到底层实现。对于学习者而言看一个画圆的 MFC 调用再去看底层如何调用 GDI 画线比直接面对几千行的图形学框架要友好得多。再加上 Visual Studio 对 MFC 项目的工程管理非常成熟调试图形算法时可以直观看到变量值和内存状态这是很多脚本语言环境给不了的。这套工具最终的定位是三个教学演示、算法验证、入门练手。对于初学者它的价值在于把抽象算法变成屏幕上看得见的像素过程对于进阶开发者它的代码结构可以作为二次开发的基础框架比如扩展成画曲线、画贝塞尔、做简单的图像编辑器。1.2 工程架构与核心模块划分整个工程我按照功能拆成了四个模块各司其职尽量减少耦合。DrawingTool.sln ├── DrawingToolMFC 应用程序主工程 │ ├── DrawingTool.h / DrawingTool.cpp应用入口 │ ├── MainFrm.h / MainFrm.cpp主框架窗口 │ ├── ChildView.h / ChildView.cpp绘图视图窗口 │ └── GraphicsAlgo.h / GraphicsAlgo.cpp核心算法库核心算法库GraphicsAlgo完全独立于 MFC 界面只依赖 Windows 的CPoint和CDC类型。这个设计是有讲究的算法库只负责怎么把一个点变成另一个点怎么算出一条直线经过哪些像素界面层只负责用户点了哪里、画布上要刷新什么。两层分离之后测试算法可以不启动界面直接在控制台工程里调用这对调试和验证算法正确性帮助很大。视图窗口表ChildView承担了所有交互逻辑鼠标消息、菜单命令、状态栏显示。我在这里维护了一个全局的数据结构保存当前正在绘制和已经绘制完成的图形对象列表刷新时统一遍历重绘。这个数据结构的选型后面单独说。1.3 基于消息机制的绘图流程设计MFC 的绘图触发路径和普通业务程序不太一样它不是指令-执行的直接调用而是请求-处理的消息驱动。具体到这套工具流程大概是这样的用户在窗口按下鼠标左键系统产生WM_LBUTTONDOWN消息进入OnLButtonDown记录图形起始点。鼠标按住移动产生WM_MOUSEMOVE消息进入OnMouseMove实时更新图形终点并触发重绘。鼠标松开产生WM_LBUTTONUP消息进入OnLButtonUp确认图形完成加入已绘制列表。这里最关键的设计是绘制预览和最终确认的分离。拖动过程中显示的是实时预览松手之后才把图形正式存入列表。预览时每收到一次移动消息就重新画一遍这会产生频繁的 DC 操作和高频刷新怎么处理是我后面要细讲的一个大坑。设备上下文获取我用了CClientDC this的方式而没有用CPaintDC。原因在于CPaintDC只能在OnPaint中使用它会把绘图区域限定在无效区域内而鼠标交互过程中需要随时在任何位置画图形CClientDC更合适。这算 MFC 绘图里比较容易被忽略的细节。2. 二维图形学核心算法的落地策略2.1 直线绘制从 DDA 到 Bresenham 的工程实践直线是图形学的地基。这套工具里我实现了三种画法数值微分法DDA、中点画线法、Bresenham 算法通过菜单可以切换当前使用哪种方便对比效果和性能。DDA 是最直觉的实现方式思路就是沿着直线方向一步步前进每一步取一个像素点。这里需要注意浮点误差问题直接定义里取整会在地平线附近产生明显的锯齿和漏点所以我实现时用了std::round而不是强制类型转换。Bresenham 算法则是完全的整数运算核心思想是利用误差项的符号来决定每一步是沿主轴走还是同时沿两个轴走。教科书里给的推导过程是数学化的工程实现时可以直接把误差项定义成增量形式避免浮点参与运算速度提升非常明显。实际测试中画一条长度一千像素的对角线DDA 需要约 0.15 毫秒Bresenham 只要 0.02 毫秒差距在大量图形时会被放大。代码实现上有个关键细节要统一处理斜率绝对值大于 1 的情况。很多人写直线算法时默认横坐标增加一旦遇到接近垂直的直线就出问题。我的处理方式是先判断主轴方向如果斜率绝对值大于 1就把坐标交换让递增方向永远是长轴最后画点前再交换回来。void CGraphicsAlgo::DrawLineBresenham(CDC* pDC, CPoint p1, CPoint p2, COLORREF color) { int x p1.x, y p1.y; int dx abs(p2.x - p1.x), dy abs(p2.y - p1.y); int sx (p1.x p2.x) ? 1 : -1; int sy (p1.y p2.y) ? 1 : -1; bool steep dy dx; if (steep) { std::swap(dx, dy); std::swap(x, y); } int e 2 * dy - dx; // 误差项初值 for (int i 0; i dx; i) { if (steep) pDC-SetPixel(y, x, color); else pDC-SetPixel(x, y, color); while (e 0) { y sy; e - 2 * dx; } x sx; e 2 * dy; } }2.2 圆的生成中点画圆与参数方程的取舍画圆我首选了中点圆算法。这个算法的思想可以类比为逐点判断下一像素在中点内侧还是外侧利用圆的隐式方程作为一个判别式。工程实现时利用圆的八对称性只计算第一象限八分之一圆弧然后对称生成其他部分性能开销极小。参数方程法x cx r*cos(θ), y cy r*sin(θ)在实际绘图时有个问题如果 θ 步长取得太大圆弧会变成折线取小了又会出现大量重复像素点。我的经验是步长取1.0 / r弧度比较合适但这在半径几百甚至上千像素时循环次数过万实时性较差。中点圆算法完全没有这个问题半径 500 的圆只有约 400 次迭代效率高一个量级。这里有个容易犯的错误画圆时要先设置好画笔或直接把像素写到目标 DC如果用 GDI 的Ellipse函数口虽然也能画出圆但锯齿处理方式不同效果在像素级展示算法时不透明所以为了演示真算法最终我是用SetPixel逐点画的。2.3 多边形填充扫描线算法与边界特殊情况填充功能我用了扫描线算法。思路是把多边形按 y 方向从上到下扫描求扫描线和多边形边的交点然后把交点成对连线填充。听起来简单但边界情况非常磨人。最容易踩坑的是多边形顶点处的交点数奇偶性问题。比如一个三角形扫描线正好经过最上面的顶点时如果没有特殊处理交点数量可能变成奇数导致填充区域错乱。解决方法是上闭下开原则只把每条边的较低端点计入交点较高端点不算。这样顶点区域交点数永远是偶数个就能保证填充正确。另一个细节是水平边的处理。扫描线遇到水平边时如果直接参与求交会得到重复交点我实现时直接跳过水平边只让非水平边的节点处理这些区域。实测下来这个方法比较稳。填充时我先把所有交点的 x 坐标收集到列表里排序再成对填充这样做的好处是算法逻辑清晰调试时也能直观看到交点的计算过程。2.4 线段裁剪Cohen-Sutherland 与参数化裁剪裁剪功能做了 Cohen-Sutherland 算法解决屏幕上只显示窗口区域内的图形部分的问题。这是图形学里非常经典的应用场景窗口是矩形线段任意判断哪些部分在窗外并舍弃。实现时我采用了区域编码的思路把窗口上下左右四条边界延伸将平面划分成 9 个区域每个区域用一个 4 位编码表示。判断线段和窗口的位置关系不需要做除法运算只需要按位与操作这在大量线段裁剪时效率非常高。工程实现时我加了一个控制台调试输出把每条线段初始编码、裁剪后的端点坐标打出来。这个操作在验证算法正确性时起到了关键作用。肉眼观察图形效果只能看出对不对看不出为什么对打印中间计算过程立刻就能定位问题。3. 基于 MFC 的交互式绘图实现3.1 鼠标交互三件套按下、移动、弹起的状态管理交互是这套工具最吸引人的部分。设计交互逻辑时我引入了一个简单的状态机EMPTY表示没有正在操作的图形DRAWING表示正在拖动绘制预览中。鼠标进入OnLButtonDown时置为DRAWING进入OnLButtonUp时置为EMPTY。这个状态机的核心价值在于它告诉OnPaint当前应该画什么。当状态是EMPTY时只需要遍历已完成的图形列表重绘全部内容当状态是DRAWING时除了重绘已完成图形还需读取当前的起点和实时终点画一个预览图形。从用户体验上看这就是边拖边画的效果。鼠标消息的具体处理顺序是这样的按下后立即调用Invalidate(FALSE)触发一次重绘把当前的图形起点画出来移动过程中坐标原始内容先存到成员变量然后调用Invalidate(FALSE)刷新弹起时把图形存入完成列表状态置回EMPTY。这里每个步骤都要设置好断点调试确认状态正确否则后面出现图形画出去了却存不住的问题非常难查。3.2 双缓冲绘制的具体用法与防闪烁原理MFC 单缓冲绘图有一个典型问题窗口一刷新就闪烁。原因很简单系统先擦除背景再绘制内容两个动作之间的时间窗口人眼能感知到。在拖动图形每帧刷新时这个现象尤其明显你画一条线屏幕像在不停眨眼。解决办法就是双缓冲先在内存中创建一个兼容 DC 和位图把背景和图形全部画到内存 DC 上最后一次性把内存 DC 拷贝到窗口 DC。这样屏幕永远不会出现擦了一半的中间状态自然就不闪了。void CChildView::DrawDoubleBuffer() { CRect rectClient; GetClientRect(rectClient); CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(this); memBitmap.CreateCompatibleBitmap(this, rectClient.Width(), rectClient.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 内存 DC 中填充背景并绘制图形 OnDraw(memDC); // 一次性拷贝到窗口 DC CClientDC dc(this); dc.BitBlt(rectClient.left, rectClient.top, rectClient.Width(), rectClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }这里有个容易忽略的细节创建兼容位图用的长宽必须和窗口客户区一致否则BitBlt时会出现部分区域黑的或内容偏移。同时每次刷新都重新创建位图高频操作时其实有可优化的空间——可以把位图作为成员变量只在窗口大小变化时重建。这个优化点留给你当练习。3.3 图形对象的管理与存储结构选型图形列表我用了一个自定义结构体ShapeItem里面存了图形类型、起点、终点、画笔颜色等必要属性然后用std::vectorShapeItem存储所有图形。这是最朴素的方案但在教学演示场景下足够用。为什么不用链表其实链表在理论上删除效率更高但这里的高频操作是遍历全部图形重绘数组的连续内存访问对缓存更友好遍历效率反而更高。而且所有图形都保留没有插入删除的复杂逻辑链表优势发挥不出来。对于大规模应用比如要画上万条线段时std::vector的重绘效率确实会拖慢这时候可以考虑只重绘可视区域。这套工具定位是教学与基础应用控制在几千条以内性能完全没问题。我在状态栏加了图形数量显示方便直观感受数据规模对性能的影响。4. 实际开发中遇到的典型问题与排查记录4.1 坐标系初次使用就错的重大隐患MFC 的设备坐标系和数学坐标系方向不一致x 轴向右y 轴向下原点在窗口左上角。画直线还好画圆和多边形时如果直接拿数学公式套图形会上下颠倒。比如你画一个圆心在 (200, 200) 的圆在屏幕上看到的圆心其实偏上。解决办法是在算法库入口处做一个坐标转换统一使用数学坐标进行计算只在最终SetPixel前把 y 坐标取反。我最初直接把转换逻辑塞在ChildView里后来发现问题把转换抽到了GraphicsAlgo的一个静态函数TransformPoint各算法函数只需调用它即可。这让算法库可以被独立测试不依赖 MFC 的坐标约定。4.2 绘图过程中的闪烁问题根因排查项目最早版本没有做双缓冲当时直线的拖动效果还能看但在画大圆弧或扫描线填充时闪烁严重到几乎无法使用。排查后发现闪烁根因有三个叠加背景擦除、逐像素SetPixel、多个区域的连续绘制。第一层背景擦除其实最致命。MFC 窗口默认WM_ERASEBKGND消息会把背景涂白每帧都触发。我的做法是重写OnEraseBkgnd直接返回 TRUE 表示背景由OnPaint负责绘制截断默认擦除流程。第二层逐像素SetPixel无法避免但通过双缓冲已经让每个像素的写入发生在内存不直接和屏幕交互。第三层连续绘制用双缓冲把所有绘制动作集中到一次BitBlt效率提升几十倍。4.3 鼠标拖动绘制时画面延迟的优化实践拖动时如果图形复杂会出现鼠标走了线条还在追上的延迟感。原因是每次移动消息都重绘全部图形复杂度是 O(n)n 很大时就会卡顿。针对这个问题的优化手段是增量绘制拖动过程中只擦除上一次的预览图形区域画新的预览图形不动已完成图形。但这个优化在图形数量大时效果才明显而且会造成屏幕残留问题——擦除区域覆盖不干净旧的预览残影没消干净。最终我在性能和效果间取的平衡是图形数量少于 500 时不优化直接全量重绘超过 500 时开启只在松手后全量重绘拖动中增量绘制的混合模式。实际体验下来教学演示场景基本感受不到卡顿。4.4 MFC 工程发布与打包的注意事项写好的绘图工具不能只在开发机上跑分享给同学或同事时总得能打包成独立 exe。MFC 项目的打包有些容易踩坑的细节。默认情况下用 Debug 配置编译的 exe 依赖调试版运行库目标机器如果没有安装对应版本的 Visual C 运行库启动时会报找不到 MSVCP140D.dll。我建议发布时一定要切到 Release 配置并选择静态链接 MFC。做法是在项目属性 → 常规 → MFC 的使用里选在静态库中使用 MFC这样生成出来的 exe 就完全不依赖外部运行库双击就能跑分发最省心。还有一个细节是打包时要带上资源文件比如程序图标、菜单资源这些会编译进 exe不需要单独分发。但如果用到外部图片资源记得把文件放进 Release 输出目录。4.5 一个坑了一下午的中文乱码问题开发时我为了标识功能在菜单里用了中文名字结果在部分系统上显示乱码。排查后发现是项目字符集设置问题。新建 MFC 项目时默认可能选的是Unicode字符集但如果从旧工程迁移过来字符集设置可能不对导致菜单资源按多字节方式解析。解决办法是在项目属性 → 常规 → 字符集中选择使用 Unicode 字符集然后在代码里所有字符串使用宽字符写法_T(字符串)。注意如果混合使用窄字符函数和宽字符资源界面会偶尔出现乱码。这些细节虽然不影响绘图算法但对一个完整的用户体验影响很大。5. 从绘图工具到图形学学习平台的扩展思路5.1 用菜单命令切换算法的设计与实现要让这套工具承担算法对比的教学功能我在菜单栏里设计了一个算法选择子菜单把直线算法、圆算法、填充算法的不同实现都挂进去通过命令行更新当前算法类型。界面上还有一个OnUpdate处理函数控制菜单项的勾选状态让使用者一眼看清当前用的是哪个算法。这个设计的巧妙之处在于算法库的接口保持一致功能层面完全透明地切换。例如直线画法有三种实现界面上没有任何区别却能通过对比屏幕上的像素布点情况观察算法差异。实际教学中我发现当学生亲眼看到 DDA 直线有锯齿、Bresenham 直线点更均匀时理解算法的本质提升比看任何教科书都深刻。5.2 扩展更多图形学算法贝塞尔曲线、多边形裁剪工具的基本框架搭好后扩展其他算法其实非常高效。比如要加贝塞尔曲线只需在GraphicsAlgo里增加一个函数接受控制点集合并生成路径在ChildView的消息响应中增加一个曲线模式存储控制点序列整个功能就完成了一半。算法库和界面层的解耦让这种扩展几乎是插拔式的。我后来还加了梁友栋-Barsky 裁剪算法作为 Cohen-Sutherland 的对照。两者的对比本身就体现了几何算法的不同分类学生在界面上划一条线段两个算法给出不同的中间过程这对理解计算几何的工程思路很有帮助。5.3 基于工具理解计算机图形学核心概念最后说说我为什么推荐这类项目作为图形学入门实践。它不像 OpenGL 那样把光栅化、变换等底层细节全部封装也不像 EasyX 那样过于玩具化。MFC 里你能接触到真实的消息循环、真实的设备上下文、真实的像素操作这是理解图形学从算法到屏幕全链路的最佳路径。我建议你把这份源代码下载后先原样跑通然后沿着这个路径进阶把 Bresenham 改成绘制任意斜率带宽度直线给画圆算法加抗锯齿效果把填充算法扩展成支持多边形之间的布尔运算。每完成一个扩展你对图形学的理解都会上一个台阶。这套源码我放在了我的个人资源站需要的朋友可以去找文件名就叫 DrawingTool。写到这里这套二维绘图工具的核心内容已经全部梳理完毕。实际开发中我还做过一个特别小的定制功能用这套框架画一个彩色正方形因为在 MFC 里直接封装就是先画四条等长的直线再给每条直线分别设置画笔颜色但颜色需要配合双缓冲否则交替绘制时容易闪烁。这些经验告诉我图形学不该是冷冰冰的公式推导把它变成屏幕上一个能拖、能画、能对比的窗口学习效率会高得多。本文还有配套的精品资源点击获取
返回列表