news 2026/10/23 18:22:38

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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派生类里一般维护一个CArray<CShape*, CShape*>或std::vector<std::unique_ptr<CShape>>。用 MFC 的CArray是因为它和Serialize配合更顺,但现代写法更推荐std::vector加智能指针,避免手动delete。

// XxxDoc.h class CXxxDoc : public CDocument { DECLARE_DYNCREATE(CXxxDoc) public: std::vector<std::shared_ptr<CShape>> m_shapes; // 所有图元 virtual void Serialize(CArchive& ar); // 存盘/读盘 void AddShape(std::shared_ptr<CShape> 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_shared<CLineShape>(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 —— 三次 Bezier,de 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::vector<CPoint>,Draw里遍历点用LineTo连起来,HitTest判断鼠标点到任意线段距离是否小于容差。整个过程不需要改文档类和渲染循环,这就是基类抽象带来的好处。

换交互是第二个改造点。默认的“按下-拖拽-松开”适合画规则图形,但画多边形需要“点一下加一个顶点,双击结束”。我一般会在视图类里加一个状态机:IDLE、DRAWING_RUBBER_BAND、DRAWING_POLYGON。OnLButtonDown根据当前状态决定是记录起点还是追加顶点,OnLButtonDblClk结束多边形。状态机用enum加一个成员变量就够,不需要引入复杂框架。

接数据是第三个改造点,也是最能体现工程价值的地方。很多实际需求不是手动画,而是把外部数据(比如 CSV 里的坐标点、传感器采集的轨迹)批量导入后渲染。我通常会在文档类里加一个ImportFromCSV(const CString& path)方法,用CStdioFile逐行读,按逗号拆分,创建对应图元塞进m_shapes,最后UpdateAllViews。这样绘图工具就从一个“画板”变成了“数据可视化终端”。

验证改造是否成功,我习惯用三个检查:一是画 500 个随机图元后 GDI 对象数是否稳定(任务管理器看);二是存盘再读盘,图形数量和坐标是否完全一致;三是窗口从最小化恢复、被其他窗口遮挡再露出时,图形是否完整重绘。这三个过了,基本就能交付。

从那以后我每次改绘图相关代码,都会先把 GDI 对象数盯一遍再提交,这个习惯帮我省了至少三次线上崩溃的排查时间。希望这份拆解能帮你少走点弯路,源码跑起来之后,大胆改,图形学的东西不动手画几遍是记不住的。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 18:20:12

日期切片复盘方法论:用结构化日志实现高效项目归档与问题排查

1. 一个日期标题背后的内容复盘方法论看到"2021-04-29"这个标题&#xff0c;很多人的第一反应是&#xff1a;这不就是个普通日期吗&#xff1f;没错&#xff0c;它确实是一个日期&#xff0c;但在我十多年的内容运营和项目复盘经历里&#xff0c;用日期做标题的项目往…

作者头像 李华
网站建设 2026/10/23 18:18:03

Java学生信息管理系统课程设计:从选题到答辩的完整实战解析

简介&#xff1a;一份面向Java初学者的课程设计实践源码包&#xff0c;围绕学生信息管理系统的完整实现展开。项目覆盖学生信息的增加、删除、修改与查询&#xff0c;同时融入Swing图形界面、事件监听、异常处理、文件序列化及JDBC数据库连接等关键Java技术&#xff0c;并应用了…

作者头像 李华
网站建设 2026/10/23 17:56:17

MATLAB 16QAM星座图与误码率仿真:链路实现与避坑

简介&#xff1a;面向通信原理与数字调制课程学习者的16QAM仿真代码包&#xff0c;聚焦星座图绘制与误码率评估两个核心实验环节&#xff0c;适合需要理解QAM映射、解调判决与AWGN信道性能的本科生或自学者动手复现。包内共8个文件&#xff0c;均为MATLAB的.m脚本与函数&#x…

作者头像 李华
网站建设 2026/10/23 17:41:23

CUPS 2.3.3 源码编译、IPP 链路架构与打印排错

简介&#xff1a;CUPS 2.3.3 源代码压缩包面向 Linux、BSD、macOS 等类 Unix 平台的系统管理员与打印系统开发者&#xff0c;用于在本地编译、定制和二次开发通用 Unix 打印系统&#xff0c;解决跨平台打印作业管理、驱动适配与打印服务部署问题。压缩包共 1294 个文件、约 7.7…

作者头像 李华
网站建设 2026/10/23 17:40:30

试用 no-ai-slop 一周,我对“去 AI 味“这个风口的三个不服

试用 no-ai-slop 一周&#xff0c;我对"去 AI 味"这个风口的三个不服 【免费下载链接】no-ai-slop Removes 20 patterns of AI slop from any piece of writing. 项目地址: https://gitcode.com/gh_mirrors/no/no-ai-slop 上周刷 GitHub 热榜&#xff0c;一个…

作者头像 李华