MFC与OpenGL结合的CAD源码剖析:从图元管理到拾取交互
发布时间:2026/10/20 6:42:22 锦皓数字建站

简介《CAD应用程序开发详解——Visual C与OpenGL综合应用》一书的配套源码DEMO专为学习和从事CAD系统开发的工程师准备重点演示如何在Visual C与MFC框架中集成OpenGL实现三维几何建模、视图交互与渲染。资源为ZIP压缩包共710个文件、约4.04MB核心包含h/cpp源代码、dll/lib库文件、sln/vcproj项目工程及rc资源脚本同时附带bmp/ico图标、exe运行程序和示例文档目录划分清晰便于按功能模块查阅。已有653人学习下载资料覆盖从基础图元绘制、光照纹理映射到模型数据管理、用户交互处理等典型环节。通过实际编译运行这些DEMO可直观理解OpenGL绘图命令与MFC消息机制的配合流程掌握CAD系统中的图形算法、碰撞检测及性能优化方法为独立开发实用CAD工具提供扎实的参考。1. 这套CAD随书CD源码值得你动手跑一遍再决定扔不扔看到老旧教学资源很多人第一反应是“过时了没用”。但一套Visual C和OpenGL组合的CAD开发DEMO核心价值不在界面好看而在交互架构的底子还在MFC文档视图管数据OpenGL做渲染图元类、坐标变换、鼠标拾取、橡皮筋绘制这些CAD应用的基本骨架全部露在外面可以一行一行改。这套CD不是封装好的黑盒演示而是分章节组织的真实工程源码能跑、能断点、能翻出每一个类的实现属于没有忽悠的实打实教学资源。适合两类人一类是拿图形学课程设计或CAD二次开发入门练手的新手另一类是想在成熟老代码里提取图元管理和拾取逻辑的开发者。后面我会把工程结构、核心图元代码、移植步骤和踩坑经验全部拆开讲。2. 工程骨架MFC文档视图 OpenGL渲染这套源码怎么组织2.1 按章节拆工程每章一个独立工程公共代码单独成目录拿到CD第一件事是看目录结构。这套源码的组织方式和现在GitHub上单仓多工程有点类似每一章对应一个独立的VC工程越往后的章节工程功能越全公共的图元类、坐标系变换和OpenGL封装抽到共享目录各章工程直接引用源文件不搞静态库依赖。这样学习路径很清楚——每一章的工程只包含到当章为止的代码断点跟进去不会迷路。典型的源码目录会长这样chapter4/ src/ CADView.cpp / CADView.h # 视图类鼠标交互 渲染入口 CADDoc.cpp / CADDoc.h # 文档类图元数据管理 MainFrm.cpp / MainFrm.h # 主框架窗口 chapter6/ src/ # 加入图层、线型后的完整工程 shared/ graphic/ GraphicObject.h / .cpp # 图元基类 Line.h / Circle.h / ... # 派生图元 render/ OpenGLContext.h / .cpp # 渲染上下文封装 utils/ MathUtils.h / .cpp # 坐标变换、求交、距离计算实际拿到的CD里目录命名可能略有出入但按“章节工程 共享代码”这个思路去找几分钟就能把核心源码定位出来。我一般拿到这类教学代码先看shared目录因为二次开发时优先动的就是它而不是某个章节工程里的局部代码。2.2 OpenGL进视图像素格式和渲染上下文的绑定MFC的CView默认画的是GDI要让OpenGL画进视图窗口必须先完成三件事给窗口DC设置像素格式、创建OpenGL渲染上下文、把渲染上下文绑定到当前线程。这套代码用的就是最经典的wgl三件套。初始化代码通常长这样// OpenGLContext.cpp —— 在视图OnCreate阶段调用 BOOL COpenGLContext::Create(CWnd* pWnd) { CClientDC dc(pWnd); // 像素格式描述符RGBA 双缓冲 32位颜色 24位深度 static PIXELFORMATDESCRIPTOR pfd { sizeof(PIXELFORMATDESCRIPTOR), // 结构体大小 1, // 版本号恒为1 PFD_DRAW_TO_WINDOW | // 绘制到窗口 PFD_SUPPORT_OPENGL | // 支持OpenGL PFD_DOUBLEBUFFER, // 双缓冲避免闪烁 PFD_TYPE_RGBA, // RGBA颜色模式 32, // 颜色缓冲区位数 0,0,0,0,0,0,0,0,0,0,0,0,0, // 颜色位忽略 24, // 深度缓冲区位数 0, 0, PFD_MAIN_PLANE, 0, 0, 0, 0 }; // ChoosePixelFormat 根据描述符匹配最接近的像素格式 int nPixelFormat ChoosePixelFormat(dc.GetSafeHdc(), pfd); // 没匹配上就是显卡驱动或窗口样式有问题 if (nPixelFormat 0) return FALSE; SetPixelFormat(dc.GetSafeHdc(), nPixelFormat, pfd); // 创建并绑定渲染上下文 HGLRC hRC wglCreateContext(dc.GetSafeHdc()); wglMakeCurrent(dc.GetSafeHdc(), hRC); m_hRC hRC; return TRUE; }这里有几个参数值得注意。PFD_DOUBLEBUFFER 一定要保留丢了它画面会闪到没法看cColorBits 给 32 是追求ARGB颜色精度老显卡不行就降到 24cDepthBits 给 24 是因为CAD图形虽然大多是二维线框但后续加了三维模型或消隐功能时深度缓冲是必需的。整个绑定过程里最容易翻车的不是OpenGL本身而是CView的窗口类样式缺了 CS_OWNDC这会导致每次拿到的DC都会被系统重置后文避坑章节我会细说。2.3 文档视图分工图元数据放文档渲染放视图这套源码沿用MFC的Document/View架构来分责任。文档类持有图形数据是所有图元对象的容器视图类负责两件事——把文档里的图元画出来、把鼠标消息翻译成图元操作。文档类里的数据结构很简单一个数组就够// CADDoc.h —— 自定义文档类 class CCADDoc : public CDocument { public: // 所有图元对象的统一容器 CArrayCGraphicObject*, CGraphicObject* m_objects; // 新增图元统一走这个入口便于后续加撤销日志 void AddObject(CGraphicObject* pObj); // 按顺序遍历所有图元交给视图绘制 void RenderAll(); };为什么把图元数据全部挂在文档类而不是视图类因为MFC里文档是数据层视图是展示层切换视图比如分屏、多个视图看同一张图时文档数据不重建。后续做“打开图纸文件”功能时只需要在文档类里加一个序列化函数整个图元数组就能随工程文件存盘读盘。这套代码的设计者把这个分工守住了没把图元数组挪到视图里图省事这也是它适合二次开发的原因之一。3. 核心模块拆解图元类继承、坐标换算和拾取判定的实现3.1 图元基类把“画出来”和“选中它”定义成统一接口这套代码里图元体系是最值得抄的部分。基类不直接实例化只定义接口直线、圆、圆弧各自实现。基类大致长这样// GraphicObject.h —— 图元基类 class CGraphicObject { public: virtual ~CGraphicObject() {} // 每个图元负责自己的绘制 virtual void Draw() 0; // 平移变换dx/dy是世界坐标增量 virtual void Translate(double dx, double dy) 0; // 拾取判定point为世界坐标tolerance为容差 virtual BOOL Pick(const CPoint2D point, double tolerance) 0; protected: COLORREF m_Color; // 图元颜色 int m_Layer; // 所属图层 BOOL m_bVisible; // 是否可见 };三个纯虚函数分别对应“绘制、变换、拾取”三类操作是CAD图元的三个基本面。后面想扩展椭圆、样条、尺寸标注只需要继承这个基类并实现三个方法渲染层和交互层完全不用动。直线类的Draw实现和Pick实现是这套代码里的经典// Line.cpp —— 直线图元的绘制与拾取 void CLine::Draw() { if (!m_bVisible) return; glColor3ub(GetRValue(m_Color), GetGValue(m_Color), GetBValue(m_Color)); glLineWidth(m_nLineWidth); // 线宽按像素设置 glBegin(GL_LINES); // 绘制两个端点 glVertex2d(m_ptStart.x, m_ptStart.y); glVertex2d(m_ptEnd.x, m_ptEnd.y); glEnd(); } BOOL CLine::Pick(const CPoint2D pt, double tolerance) { // 点到线段距离先算投影点在线段上的比例t double dx m_ptEnd.x - m_ptStart.x; double dy m_ptEnd.y - m_ptStart.y; double len2 dx*dx dy*dy; double t ((pt.x - m_ptStart.x)*dx (pt.y - m_ptStart.y)*dy) / len2; if (t 0.0) t 0.0; // 投影超出起点直接算到起点距离 if (t 1.0) t 1.0; // 投影超出终点算到终点距离 double px m_ptStart.x t*dx; double py m_ptStart.y t*dy; double dist sqrt((pt.x-px)*(pt.x-px) (pt.y-py)*(pt.y-py)); return dist tolerance; }tolerance 是拾取容差屏幕上一般给 68 像素换算到世界坐标的距离。Pick 里用投影点再做距离判定比直接用直线方程求距离更稳因为竖直线、水平线这些斜率无穷大的情形不会除零。这套代码能一直被人翻出来复用靠的就是这种经得起推敲的小细节。3.2 屏幕坐标转世界坐标视图变换的逆推CAD里鼠标拿到的是像素坐标而图元存的是世界坐标。这套代码在视图类里做了一个两坐标系换算函数核心是逆推OpenGL的视图管线// CADView.cpp —— 像素坐标转世界坐标 CPoint2D CCADView::ConvertToWorld(CPoint ptScreen) { CPoint2D ptWorld; CRect rcClient; GetClientRect(rcClient); // 视图缩放比例世界窗口 / 客户区像素数 double scaleX m_viewWidth / rcClient.Width(); double scaleY m_viewHeight / rcClient.Height(); // 屏幕像素减半后乘比例再平移到世界窗口中心 ptWorld.x (ptScreen.x - rcClient.Width()/2.0) * scaleX m_viewCenter.x; ptWorld.y (m_viewHeight/2.0 - (ptScreen.y - rcClient.Height()/2.0) * scaleY) m_viewCenter.y; return ptWorld; }这里有两个容易犯的错。一个是Y轴方向OpenGL默认原点在左下屏幕原点在左上必须把像素的Y倒过来再算否则鼠标往上移图形却往下跑。另一个是缩放比例要用“世界窗口尺寸除以客户区尺寸”而不是图省事固定一个比例否则窗口拉伸后图元坐标和鼠标就对不上了。m_viewWidth 和 m_viewHeight 是当前可见世界区域的大小配合滚轮缩放动态变化是视图控制里最核心的一对参数。3.3 橡皮筋交互按下、移动、弹起三段式状态画直线用的是经典的橡皮筋交互。视图类维护一个“当前是否在绘制”的布尔状态配合三个鼠标消息void CCADView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing TRUE; m_ptStart ConvertToWorld(point); // 记录起点世界坐标 SetCapture(); // 捕获鼠标移出窗口也能收到消息 CView::OnLButtonDown(nFlags, point); } void CCADView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { // 临时图元只负责显示松开鼠标才会加入文档 m_ptTempEnd ConvertToWorld(point); Invalidate(FALSE); // 触发重绘绘制临时直线 } CView::OnMouseMove(nFlags, point); } void CCADView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; ReleaseCapture(); m_ptEnd ConvertToWorld(point); // 终点和起点太近就丢弃避免画出零长度线段 if (两点距离大于某个阈值) { CCADDoc* pDoc GetDocument(); pDoc-AddObject(new CLine(m_ptStart, m_ptEnd)); } Invalidate(FALSE); } CView::OnLButtonUp(nFlags, point); }橡皮筋的关键在于临时直线不进文档数组只在视图里画MouseMove 每次都重画一次。这样实现最简单但图元数量上来后每帧全量重绘会卡。想要优化可以把当前正在画的线单独用一个显示列表缓存或者走双缓冲直接操作后缓冲区。这套代码的教学价值恰恰在这里——它把最朴素的方案摆在明面上让你知道瓶颈在哪也知道从哪下手改。3.4 视图变换与缩放平移、缩放走OpenGL矩阵全套代码的视图控制集中在视图类的OnDraw里用 glTranslatef 和 glScaled 完成世界坐标到屏幕坐标的变换而不是逐个图元改坐标。这样设计的好处是图元数据保持原始世界坐标缩放只是改矩阵不会积累误差void CCADView::OnDraw(CDC* pDC) { COpenGLContext* pGL m_pGLContext; if (pGL pGL-MakeCurrent()) { glClearColor(1.0f, 1.0f, 1.0f, 1.0f); // 白色背景 glClear(GL_COLOR_BUFFER_BIT); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); // 平移到视图中心再按缩放比例整体放大 glTranslated(-m_viewCenter.x, -m_viewCenter.y, 0.0); glScaled(m_viewScale, m_viewScale, 1.0); // 遍历文档图元逐个调用Draw GetDocument()-RenderAll(); // 如果正在画橡皮筋补画临时直线 if (m_bDrawing) { /* 绘制临时图元 */ } SwapBuffers(pGL-GetDC()); // 双缓冲交换 } }m_viewScale 是当前缩放倍数m_viewCenter 是视图中心点位置这两个变量是视图交互控制的核心。滚轮缩放只需要把 m_viewScale 乘一个因子然后 Invalidate(FALSE)鼠标中键拖拽平移只需要改 m_viewCenter。整套代码的交互手感都在这一小段里改造优先级最高。4. 跑起来老VC工程移植到现代Visual Studio的流程4.1 导入工程dsp/dsw旧工程文件的新建替代方案这套CD里的源码工程文件是VC6时代的 .dsp/.dsw 格式新版本Visual Studio基本不支持直接打开。我一般不走“转换向导”因为向导经常把资源文件ID改乱而是直接新建一个空MFC工程把源码目录里的 .cpp/.h/.rc 文件全部拷进去再逐个添加进项目。操作分三步新建“MFC应用程序”工程选“单个文档”因为这份源码是单文档视图结构。把 shared 和 src 目录下的源文件拖进解决方案资源管理器.rc资源文件也一并加进来。项目属性里设置“使用多字节字符集”这是老代码编译过的最关键一步。这套流程比任何自动转换都稳虽然手工步骤多一点但你能完全控制工程配置遇到问题也知道问题出在哪。4.2 依赖处理链接OpenGL系统库而不是第三方库教材配套代码用的OpenGL基本上是系统内置的部分opengl32.lib、glu32.lib、gdi32.lib。在VS的项目属性“链接器 → 输入 → 附加依赖项”里手动加上这三个库即可。注意书中个别章节如果用到glut这套CD源码不一定带上glut库文件Windows原版glut早就没人维护建议直接换成freeglut的lib和dll代码兼容性很高。链接完库之后注意编译目标平台选 x86Win32不要选 x64。老代码里很多句柄和指针直接强转int在x64下编译会有截断警告甚至错误没必要在移植阶段给自己找麻烦。如果项目默认平台是x64在配置管理器里新建一个Win32平台目标。4.3 编译、运行、验证三步走编译通过不代表功能正常运行起来之后至少要做三组验证视图功能窗口能正常打开、白底没有黑屏闪烁画一条直线和圆能看到完整的橡皮筋交互。拾取功能鼠标移动到线上图元能高亮或选中检查容差大小容差大容易误选相邻图形。变换功能缩放、平移之后图元位置不漂移Y轴方向和鼠标一致。验证中如果发现白屏大概率是渲染上下文没创建成功回到前面 OpenGLContext::Create 里加断言检查每一步返回值。这套代码是无忽悠的真实工程能跑通的地方基本都能一次通过真出问题多集中在渲染上下文和字符集这两块。5. 避坑实录老CAD代码从编译到交互的典型排查记录5.1 打开工程就报C2664/C2440错误现象编译时报大量句柄转换错误比如 wglCreateContext 的参数类型不匹配。原因VC6时代很多头文件里的句柄类型是 void*新版SDK改成了更严格的类型定义传参时不允许隐式转换。解决把参数显式强转为 HDC / HGLRC或者在项目属性里关闭“将警告视为错误”然后逐个处理类型转换。老代码里这类转换大约有几十处强转即可不要大面积改接口。5.2 报错fatal error C1083: Cannot open include file: glaux.h现象某些章节代码引用了 glaux.h。原因glaux 是早期随Windows SDK发布的OpenGL辅助库Windows 2000之后就不再维护新系统SDK里直接没有这个头文件更链接不到 glaux.lib。解决把 glaux 相关函数替换成等价实现——纹理加载用 CImage 或 GDI简单几何绘制用 freeglut 替代 auxSolidSphere 这类辅助函数。注意不要从网上找所谓的老版 glaux.lib 强行编译把一份已经不安全的库引进来得不偿失。5.3 运行后窗口一片白连网格都看不到现象启动后窗口正常显示但客户区全白没有绘制任何图元。原因要么渲染上下文没创建成功要么 OnSize 里没更新视口。老代码大多把视口设置在 OnSize 里用 glViewport 实现如果 OnSize 没被触发或没调用OpenGL 只在默认视口里画图画到窗口外就白屏了。解决在 OnCreate 返回前强制调用一次 OnSize 更新视口同时检查 OpenGLContext::Create 的每步返回值用断言卡住 ChoosePixelFormat 失败的情况。5.4 画线时窗口疯狂闪烁图形拖影明显现象鼠标拖动时视图区域频闪橡皮筋线条有残影。原因MFC 默认在 OnEraseBkgnd 里用 GDI 背景擦除每次 Invalidate 都会先刷白背景再重绘OpenGL 双缓冲被背景擦除破坏了。解决在视图类里重写 OnEraseBkgnd 直接 return TRUE禁止系统擦除背景所有重绘交给 OpenGL 的 glClear 和 SwapBuffers 完成。这是 MFCOpenGL 项目最常见的闪烁根源十次有八次是这里的问题。5.5 拾取时总是选不中图形现象鼠标点到直线上程序判定没选中。原因拾取容差 tolerance 是像素单位还是世界单位没换算对或者是坐标换算函数里的 Y 方向没反转导致拾取坐标偏差几像素。解决在 Pick 调用前打好断点把鼠标世界坐标打印出来对照图元端点坐标检查偏差方向。Y 轴反了就检查 ConvertToWorld 里的 y 方向处理偏差恒定就说明 tolerance 没乘 scale。6. 二次开发把教学Demo改造成可扩展CAD框架的两个切入点6.1 从立即模式迁到显示列表或VBO老代码的 Draw 全部是 glBegin/glEnd 立即模式图元上千之后每帧都重新上传顶点数据性能会直线下降。改造思路在绘图基类里加一个“编译显示列表”的接口直线、圆第一次绘制时把 glBegin/glEnd 调用序列存进显示列表之后每次重绘只调用一次 glCallList。进阶一点就直接上 VBO glDrawArrays顶点数据一次性上传显存。这步改造对原架构没有侵入性只是在 Draw 内部换个实现。6.2 交互层改命令模式为撤销重做铺路编辑类CAD功能必须有撤销而这套教学代码的 OnLButtonUp 直接操作文档数组没有命令痕迹。改造思路定义命令基类每次交互生成一个“添加直线命令”对象命令对象保存图元指针和执行/撤销两个方法文档类维护两个命令栈。这样撤销不仅好做后续的删除、移动、旋转也都能复用同一套栈。从那以后我每次拿到老代码都会强制走一遍“数据访问是否统一入口”的检查确认所有修改都经过命令层否则后面接任何高级功能都是越改越乱。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。