简介:一套基于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 ├── DrawingTool(MFC 应用程序主工程) │ ├── 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::vector<ShapeItem>存储所有图形。这是最朴素的方案,但在教学演示场景下足够用。
为什么不用链表?其实链表在理论上删除效率更高,但这里的高频操作是"遍历全部图形重绘",数组的连续内存访问对缓存更友好,遍历效率反而更高。而且所有图形都保留,没有插入删除的复杂逻辑,链表优势发挥不出来。
对于大规模应用,比如要画上万条线段时,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 里直接封装,就是先画四条等长的直线,再给每条直线分别设置画笔颜色,但颜色需要配合双缓冲,否则交替绘制时容易闪烁。这些经验告诉我,图形学不该是冷冰冰的公式推导,把它变成屏幕上一个能拖、能画、能对比的窗口,学习效率会高得多。
本文还有配套的精品资源,点击获取