C++ MFC 二维绘图工具源码解析:从直线圆到Bezier曲线与双缓冲

发布时间:2026/10/23 18:23:53
C++ MFC 二维绘图工具源码解析:从直线圆到Bezier曲线与双缓冲 简介这份资源是面向计算机图形学初学者与C MFC开发者的二维绘图工具完整源代码基于MFC框架实现图形绘制、编辑与文件管理功能适合课程设计、图形学实验或Windows界面编程练手。压缩包共39个文件约1.94MB包含7个h头文件与6个cpp源文件承载视图、文档、主框架等核心逻辑另有vcproj、sln、dsp等工程配置ico、bmp、rc等界面资源以及obj、exe等编译产物结构完整可直接打开运行。项目覆盖窗口与视图、消息映射、CDC绘图、CBrush与CPen样式设置、CFont文本输出、CFile文件读写、状态栏菜单及内存DC优化等知识点并配有错误处理与调试宏示例。目前已有699人学习下载读者可借此理解MFC消息驱动机制掌握DrawLine、DrawCircle、DrawPolygon等图形绘制方法并在此基础上扩展出属于自己的绘图软件。1. 从一份 C MFC 二维绘图工具源码说起它到底能画什么如果你正在学计算机图形学或者需要在一个 Windows 桌面项目里快速嵌入二维绘图能力这份 C MFC 源代码值得花时间跑一遍。它把直线、圆、椭圆、多边形、Bezier 曲线这些基础图元的生成算法连同鼠标交互、橡皮筋预览、颜色填充、图形变换一起塞进了一个完整的 MFC 单文档/多文档框架里。不是那种只贴几行 DDA 算法就结束的课堂示例而是一个能编译、能点、能拖、能改的绘图工具。适合两类人一类是图形学课程设计需要交一个像样作品的另一类是想在 MFC 老项目里加绘图模块、又不想从 CDC 画线开始造轮子的。下面按“能跑起来 → 看懂结构 → 改得动”的顺序拆。2. 工程结构与技术选型MFC 文档视图框架怎么承载绘图逻辑2.1 为什么是 MFC 而不是 Qt 或 EasyX这份源码选 MFC核心原因是它天然适合讲清楚“图形数据与显示分离”这件事。MFC 的文档/视图Document/View架构把数据存储和渲染拆成两个类CDocument派生类管图形对象列表CView派生类管鼠标消息和OnDraw渲染。对图形学教学来说这个结构比 Qt 的 QGraphicsScene 更“裸”你能清楚看到每个图元是怎么被存下来、怎么被遍历画出来的。技术栈上它依赖 Visual Studio 的 MFC 组件注意不是默认安装项需要手动勾选“MFC 生成工具”使用 GDI 作为底层绘图 API。GDI 虽然老但CDC类的MoveTo/LineTo/Ellipse/Polygon这些接口足够覆盖二维图元而且双缓冲只需要自己建一个CDC兼容位图就能实现不引入额外依赖。2.2 目录结构每个文件负责什么拿到源码后先别急着编译花两分钟把文件按职责分个类。典型结构如下文件/目录职责改动频率XxxApp.h/cpp应用入口初始化几乎不改MainFrm.h/cpp主框架窗口工具栏/状态栏加按钮时改XxxDoc.h/cpp图形对象容器序列化加图元类型时改XxxView.h/cpp鼠标交互、OnDraw 渲染改交互和绘制时改Shape.h/cpp图元基类与派生类核心扩展点Algo.h/cpp直线/圆/曲线生成算法换算法时改这个分法的好处是算法、数据、渲染三层不混。很多同学写课程设计时把所有代码堆在 View 里结果加一个图元就要改五处这份源码把图元抽象成类之后新增一种图形只需要加一个派生类并在 Doc 的创建分支里注册。2.3 图元基类的设计一个虚函数撑起整个体系图形对象的核心抽象通常长这样// Shape.h —— 所有图元的基类 class CShape { public: CShape() : m_color(RGB(0,0,0)), m_penWidth(1) {} virtual ~CShape() {} // 纯虚函数每个图元自己决定怎么画 virtual void Draw(CDC* pDC) 0; // 命中测试判断鼠标点是否落在图形上用于选中/删除 virtual bool HitTest(CPoint pt, int tolerance 5) 0; // 序列化存盘和读盘 virtual void Serialize(CArchive ar) 0; COLORREF m_color; // 线条颜色 int m_penWidth; // 线宽 };这里三个虚函数分别对应三件事Draw负责渲染HitTest负责交互Serialize负责持久化。把这三个能力放进基类意味着视图层遍历图形列表时不需要switch(type)直接调虚函数就行。参数tolerance是命中容差因为鼠标不可能精确点在一条 1 像素宽的线上通常给 5 像素左右。2.4 文档类如何管理图形列表CDocument派生类里一般维护一个CArrayCShape*, CShape*或std::vectorstd::unique_ptrCShape。用 MFC 的CArray是因为它和Serialize配合更顺但现代写法更推荐std::vector加智能指针避免手动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); // 标记文档已修改关闭时提示保存 UpdateAllViews(nullptr); // 通知所有视图重绘 } };UpdateAllViews(nullptr)是 MFC 文档/视图架构的关键数据一变所有关联视图自动收到OnUpdate进而触发OnDraw。这样你不需要手动调Invalidate数据流是单向的。2.5 视图类的鼠标交互从按下到松开画出一条线交互逻辑集中在CView派生类的三个消息处理函数里OnLButtonDown、OnMouseMove、OnLButtonUp。以画直线为例// XxxView.cpp —— 鼠标拖拽画直线的核心逻辑 void CXxxView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing true; // 进入绘制状态 m_ptStart point; // 记录起点 m_ptEnd point; // 终点先等于起点 SetCapture(); // 捕获鼠标防止拖出窗口丢失消息 CView::OnLButtonDown(nFlags, point); } void CXxxView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd point; // 橡皮筋效果只重绘受影响区域避免全屏闪烁 InvalidateRect(GetRubberBandRect(), FALSE); UpdateWindow(); } CView::OnMouseMove(nFlags, point); } void CXxxView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing false; ReleaseCapture(); // 创建图元并交给文档管理 auto line std::make_sharedCLineShape(m_ptStart, point, m_currentColor); GetDocument()-AddShape(line); } CView::OnLButtonUp(nFlags, point); }SetCapture和ReleaseCapture这一对很容易被忽略但少了它鼠标拖出窗口再松开时OnLButtonUp收不到消息图形就“卡”在绘制状态。InvalidateRect只重绘橡皮筋矩形区域比Invalidate()全屏重绘流畅得多这是避免闪烁的第一道防线。2.6 双缓冲解决 GDI 绘图闪烁的标配即使只重绘局部OnDraw里直接往屏幕 DC 画多个图元时仍可能闪。标准做法是在内存 DC 里画完再一次性贴到屏幕void CXxxView::OnDraw(CDC* pDC) { CXxxDoc* pDoc GetDocument(); CRect rect; GetClientRect(rect); // 1. 创建内存 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. 遍历所有图元画到内存 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之后一定要恢复旧对象否则 GDI 对象泄漏画到几百个图元后程序会变慢甚至崩溃。CreateCompatibleBitmap必须用屏幕 DC 创建不能用内存 DC否则位图是单色的——这是新手最常踩的坑之一。3. 核心算法模块拆解直线、圆、Bezier 曲线怎么落地3.1 DDA 与 Bresenham 直线算法图形学课讲直线生成绕不开 DDA 和 Bresenham。这份源码里通常两个都实现了方便对比。DDA 用浮点步进代码直观但慢Bresenham 全整数运算是实际项目该用的。// Algo.cpp —— Bresenham 直线算法全整数无浮点 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 方向步进 int sy (y0 y1) ? 1 : -1; // y 方向步进 int err dx - dy; // 误差项 while (true) { pDC-SetPixelV(x0, y0, color); // 画当前点 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是它的两倍用来避免除法。SetPixelV比SetPixel快因为它不返回颜色值。实际项目里逐像素画线效率不高更常见的是用CDC::MoveTo/LineTo直接交给 GDI但作为算法教学逐像素实现才能看清每一步。3.2 中点画圆算法圆用八分对称性只算 1/8 弧其余七个对称点直接映射// Algo.cpp —— 中点画圆只算 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) { // 八个对称点一次画完 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的更新公式来自中点误差推导d 0时中点在圆内选正右方否则选右下方。八分对称把计算量降到 1/8这是中点画圆比直接解方程快的原因。3.3 Bezier 曲线的 de Casteljau 递推二次和三次 Bezier 曲线用 de Casteljau 算法最稳不会像直接代入 Bernstein 多项式那样在参数接近端点时数值不稳定// 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); // 第三层曲线上的点 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) ); }调用时让t从 0 到 1 步进比如 0.01把相邻两个BezierPoint用MoveTo/LineTo连起来就是一条平滑曲线。步进越小越平滑但 0.01 在 800 像素宽的画布上已经看不出折线感。控制点用鼠标拖拽调整时每次OnMouseMove重算整条曲线性能完全够。3.4 图形变换平移、缩放、旋转的矩阵写法二维变换用 3x3 齐次矩阵统一处理比分别写平移缩放函数更清晰// 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; } };对图元的每个顶点左乘这个矩阵就完成变换。旋转要绕图形中心而不是原点所以顺序是平移到原点 → 旋转 → 平移回中心。这个顺序写反了图形会飞出去是图形学作业里最常见的翻车点。4. 避坑与常见问题编译、闪烁、GDI 泄漏排查4.1 编译报错“无法打开包括文件 afxwin.h”现象新建项目或打开源码后一编译就提示找不到afxwin.h。原因Visual Studio 安装时没有勾选 MFC 组件MFC 头文件不在默认包含路径里。解决打开 Visual Studio Installer点“修改”在“单个组件”里搜索 MFC勾选“适用于最新 v143 生成工具的 C MFC”并安装。装完重启 VS项目属性里“使用 MFC”设为“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”。4.2 绘图时屏幕疯狂闪烁现象拖动鼠标画橡皮筋时整个窗口闪图形多了以后更明显。原因OnDraw里直接往屏幕 DC 画每次重绘都先擦背景再画图元擦和画之间被看到。解决按 2.6 节的双缓冲写法全部画到内存 DC 再BitBlt。另外OnEraseBkgnd里直接return TRUE阻止系统擦背景因为背景已经在内存 DC 里填过了。4.3 画了几百个图形后程序变卡甚至崩溃现象连续画图一开始流畅画到几百个后越来越慢最后报 GDI 资源耗尽。原因OnDraw里每次new CPen或CreatePen后没有恢复旧对象GDI 对象句柄泄漏。Windows 每个进程默认只有 10000 个 GDI 句柄。解决所有SelectObject必须配对恢复或者把画笔、画刷作为图元成员只创建一次OnDraw里直接SelectObject复用。用任务管理器的“GDI 对象”列可以实时观察句柄数。4.4 鼠标拖出窗口后图形“粘”在绘制状态现象按住左键拖到窗口外松开回来发现还在画线点一下才结束。原因没有调用SetCapture鼠标移出客户区后OnLButtonUp收不到消息。解决OnLButtonDown里SetCapture()OnLButtonUp里ReleaseCapture()。注意SetCapture后如果程序异常退出没释放会影响其他程序所以最好在OnCancelMode里也补一个ReleaseCapture。4.5 存盘后重新打开图形位置偏移现象保存的图形文件重新加载后所有图形整体偏移或缩放不对。原因Serialize里存的是逻辑坐标但OnDraw里用了设备坐标两者原点或缩放不一致。解决统一坐标系要么全用逻辑坐标SetMapMode设MM_ANISOTROPIC并设窗口/视口范围要么全用设备坐标。存盘时把GetDocument()-m_shapes里每个图元的坐标原样写入读盘时原样恢复不要在Serialize里做任何坐标转换。5. 二次开发改造从能跑到好用我一般会动哪几刀拿到这份源码跑通之后真正让它变成自己项目的一部分通常要做三件事加图元、换交互、接数据。加图元是最直接的扩展。在Shape.h里派生一个新类实现Draw、HitTest、Serialize三个虚函数然后在视图的鼠标松开处理里加一个case创建它在工具栏加一个按钮切换当前绘制模式。比如加一个“自由画笔”图元内部存一个std::vectorCPointDraw里遍历点用LineTo连起来HitTest判断鼠标点到任意线段距离是否小于容差。整个过程不需要改文档类和渲染循环这就是基类抽象带来的好处。换交互是第二个改造点。默认的“按下-拖拽-松开”适合画规则图形但画多边形需要“点一下加一个顶点双击结束”。我一般会在视图类里加一个状态机IDLE、DRAWING_RUBBER_BAND、DRAWING_POLYGON。OnLButtonDown根据当前状态决定是记录起点还是追加顶点OnLButtonDblClk结束多边形。状态机用enum加一个成员变量就够不需要引入复杂框架。接数据是第三个改造点也是最能体现工程价值的地方。很多实际需求不是手动画而是把外部数据比如 CSV 里的坐标点、传感器采集的轨迹批量导入后渲染。我通常会在文档类里加一个ImportFromCSV(const CString path)方法用CStdioFile逐行读按逗号拆分创建对应图元塞进m_shapes最后UpdateAllViews。这样绘图工具就从一个“画板”变成了“数据可视化终端”。验证改造是否成功我习惯用三个检查一是画 500 个随机图元后 GDI 对象数是否稳定任务管理器看二是存盘再读盘图形数量和坐标是否完全一致三是窗口从最小化恢复、被其他窗口遮挡再露出时图形是否完整重绘。这三个过了基本就能交付。从那以后我每次改绘图相关代码都会先把 GDI 对象数盯一遍再提交这个习惯帮我省了至少三次线上崩溃的排查时间。希望这份拆解能帮你少走点弯路源码跑起来之后大胆改图形学的东西不动手画几遍是记不住的。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询