MFC中集成OpenGL的CAD示例源码解析:从渲染链路到二次开发

📅 发布时间:2026/10/20 6:43:20
MFC中集成OpenGL的CAD示例源码解析:从渲染链路到二次开发
简介这是王清辉、李静蓉合著《CAD应用程序开发详解——Visual C与OpenGL综合应用》一书的配套源码DEMO合集面向CAD二次开发入门者及需要将OpenGL集成到MFC界面的VC工程师帮助解决从基础几何建模、三维渲染到交互控制等实际开发中的常见难点。压缩包共710个文件、约4.04MB以C/C源代码为主体142个.h与131个.cpp同时包含dll、lib、def等链接库sln、vcproj等工程与项目配置以及rc/rc2界面资源和bmp/ico图标素材方便完整还原书中示例并跟踪调试。已有653人学习下载。通过STLViewer、GeomCalc等DEMO可以直观看到模型加载、几何计算、光照与纹理映射等关键模块的实现流程适合对照书中章节逐段理解OpenGL绘图命令、MFC事件响应与CAD数据结构的衔接在网格显示、材质设置、用户交互响应上也有清晰示范还能从源码的注释与函数划分中学习CAD数据管理、视图矩阵变换等进阶技巧能大幅节省环境搭建与排错时间。1. 从光盘源码到能跑的 CAD Demo这本书给你的不止是代码如果你跟我一样当初为了吃透那本经典的《CAD应用程序开发详解》把配套光盘里的 Visual C 与 OpenGL 综合应用 Demo 翻来覆去折腾过大概率记得第一次跑起来时那种“又激动又懵”的感觉——窗口能转、能画、能拾取但 MFC 消息循环和 OpenGL 渲染状态之间的边界到底划在哪书上画得清楚工程里却要自己摸半天。这套源码解决的正是这个接缝问题一个基于 MFC 单文档框架、用 OpenGL 做三维渲染的完整 CAD 演示程序从建窗口、设像素格式、搭场景、画图元到鼠标交互拾取链路完整且代码量克制。它适合两类人一类是想学 VC 图形编程、但不想从零写窗口代码的初学者另一类是要在自家 MFC 项目里接入 OpenGL 做二次开发的在职开发者拿来当能跑通的参照架子比翻文档快得多。这份源码不堆营销话术也不靠截图忽悠人值不值一读跑一遍就有答案。2. 架构解析MFC 管交互OpenGL 管渲染2.1 一份“老但经典”的技术底料这套源码的技术栈看起来年代感十足Visual C 6.0 时代的工程结构、MFC 的文档/视图架构、OpenGL 1.x 固定管线。但恰恰是这种“老”让它成为学习 CAD 图形编程的好样本。固定管线意味着没有 VAO、没有着色器、没有矩阵压栈的现代封装一次绘制调用就是 glBegin / glEnd 里的一串 glVertex3f代码和三维坐标的对应关系一目了然。MFC 负责的是窗口、消息、菜单项和鼠标键盘交互。视图类承载渲染文档类存储图形数据两者通过经典的 UpdateAllViews / OnUpdate 机制联动。把交互和渲染分开是 MFC 这套骨子里自带的分层思想。很多新手把 OpenGL 集成进 MFC 时总感觉“玄学”其实拆开看只有三件事给窗口设置像素格式、创建渲染上下文、在重绘消息里执行绘制命令。2.2 光盘里的文件与类一个典型的分层结构不同批次的光盘文件命名略有差异但工程结构基本是同一套经典分法。我拆过的这份源码核心文件大概长这样文件职责CADDemo.dsw / .dspVC6 工作区与工程文件双击 .dsw 打开整个项目MainFrm.cpp / .h主框架窗口负责工具栏、状态栏和菜单命令路由CADDemoDoc.cpp / .h文档类管理图形实体的存储、序列化和数据操作CADDemoView.cpp / .h视图类集中了 OpenGL 渲染、鼠标交互与拾取Resource.h / .rc菜单、工具栏图标、快捷键等资源定义StdAfx.cpp / .h预编译头文件MFC 标准头都在这里统一引入从面向对象设计角度看文档类和视图类的职责切得非常干净文档类不关心一个实体怎么被画到屏幕上它只维护一个实体链表视图类不关心实体数据怎么存储它拿到链表遍历一遍逐个调用 OpenGL 绘制函数。这个划分哪怕放到今天的软件架构里也不过时工程模式上就是最早期的 MVVM 雏形。2.3 一次完整的渲染链路从数据到像素理解这套源码的渲染链路只需要盯住一条主线。当窗口需要重绘时框架调用视图类的 OnDraw视图在这里把 OpenGL 渲染上下文绑定到当前窗口 DC清空颜色缓冲和深度缓冲设置相机视角然后遍历文档类里的实体链表把每个实体的坐标翻译成 OpenGL 绘制指令最后交换前后缓冲把画面呈现到屏幕上。这里有一个初学者容易误解的点为什么不直接在 OnDraw 里用 GDI 的 MoveTo / LineTo 画线因为 GDI 是立即模式的 2D 绘制接口而 OpenGL 是一套独立的状态机它需要先完成像素格式匹配、深度缓冲启用、投影矩阵设置这些初始化之后才能谈绘制。这套源码把初始化放在视图创建阶段把绘制放在每一帧的 OnDraw 阶段两个时机的分工就是整份源码的骨架。3. 把 Demo 跑起来工具链选择与编译步骤3.1 别急着上最新版 Visual Studio拿到这盘源码的人十个里有六七个第一反应是用最新版 VS 打开工程然后被一堆兼容性报错砸得晕头转向。VC6 的 .dsp 工程在 VS2010 之后会经历一次较大的格式转换MFC 头文件里的不少旧接口也已经被标记为过时或移除直接转换的结果通常是 CImageList::Create 报错、GetBufferLength 找不到符号这类问题。我的建议是想最快跑通用 VC6 或 VS2008 打开这是这套源码的“原生气质”。如果手头确实只有新版本编译器也有应对办法在 VS2019 / VS2022 里选择“打开项目”允许它执行一次工程转换然后把平台工具集切到兼容模式再手动补上丢失的库引用。这个过程不保证零成本第 5 章我会把最常见的链接错误单独拿出来说这里先不展开。3.2 打开工程的三步走跑通这套源码的步骤非常固定我一般按下面的顺序操作用 VC6 的 File - Open Workspace 打开 CADDemo.dsw如果弹出转换提示选“否”或“只读”先以原始工程格式加载。打开 Project - Settings切到 Link 选项卡在 Object/Library Modules 里补上 opengl32.lib、glu32.lib、glaux.lib 三个库。按 F7 编译再按 F5 运行。如果编译通过但没有窗口弹出检查一下工程是否把 CADDemoView 设成了视图运行时类缺少这一步窗口会白屏。那三个库的用途需要分清opengl32.lib 是 OpenGL 的核心库对应 glClear、glVertex3f 这一系列函数glu32.lib 是实用库提供 gluPerspective、gluLookAt 和后面的 gluUnProjectglaux.lib 是早期配套的辅助库部分版本的光盘示例会用到 auxInitDisplayMode 这类函数。链接器找不到符号时九成是这几个库没加全。3.3 跑起来后先验证三件事程序成功运行后不要急着看代码先做三组验证。第一拖动窗口边缘改变大小场景中的坐标轴和实体不应该变形拉伸这验证 OnSize 里有没有正确处理视口重设。第二在菜单里切换“画线”或“画矩形”命令在客户区按下鼠标左键拖动再松开应该能画出一个图形实体这验证橡皮筋交互和文档类存储是否正常。第三按住鼠标中键拖动或按菜单里的旋转命令场景应该能绕视角中心旋转这验证相机矩阵的更新链路。三个验证全过这份源码在你的环境下就是健康可用的。4. 核心模块拆解从鼠标按下到屏幕上一条线的完整链路4.1 像素格式与渲染上下文没有它一切都是黑屏OpenGL 在 Windows 上工作的前提有两个像素格式描述符和渲染上下文。像素格式告诉系统窗口 DC 需要什么样的颜色缓冲、深度缓冲和双缓冲配置渲染上下文则保存 OpenGL 的全部状态。这套源码在视图创建时完成这两件事核心代码是这个套路BOOL SetupPixelFormat(HDC hdc) { PIXELFORMATDESCRIPTOR pfd { sizeof(PIXELFORMATDESCRIPTOR), // 结构体大小 1, // 版本号 PFD_DRAW_TO_WINDOW | // 支持窗口绘制 PFD_SUPPORT_OPENGL | // 支持 OpenGL PFD_DOUBLEBUFFER, // 启用双缓冲 PFD_TYPE_RGBA, // RGBA 颜色模式 24, 0, 0, 0, 0, 0, 0, 0, // 24 位颜色忽略移位位 0, 0, 0, 0, 0, // 不涉及累积缓冲 16, // 16 位深度缓冲 0, 0, // 无模板缓冲 PFD_MAIN_PLANE, // 主图层 0, 0, 0 }; int pixelFormat ChoosePixelFormat(hdc, pfd); if (pixelFormat 0) return FALSE; return SetPixelFormat(hdc, pixelFormat, pfd); }这段代码的每个字段都有讲究。PFD_DOUBLEBUFFER 决定了 OpenGL 是否会在合适时机为我们交换前后缓冲它是消除画面闪烁的关键24 位颜色配合 16 位深度缓冲是当时三维图形应用最通用的组合深度缓冲负责处理实体间的前后遮挡关系。ChoosePixelFormat 返回的整数值是系统匹配出的最佳像素格式索引如果它返回 0大概率是 DC 类型不对或请求了硬件不支持的能力。4.2 渲染上下文的生命周期管理像素格式设置好后下一步是创建渲染上下文。源码里一般放在视图的 OnCreate 中int CADDemoView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) -1) return -1; HDC hdc ::GetDC(m_hWnd); if (!SetupPixelFormat(hdc)) { ::ReleaseDC(m_hWnd, hdc); return -1; } m_hRC wglCreateContext(hdc); ::ReleaseDC(m_hWnd, hdc); return 0; }两个细节值得注意。第一GetDC 和 ReleaseDC 必须成对出现中间夹着像素格式设置和上下文创建这个窗口 DC 是临时借用的用完立即归还。第二wglCreateContext 把某个 DC 作为参考创建上下文但之后 wglMakeCurrent 支持绑定到任意兼容 DC所以后续绘制时的 DC 不一定是创建时的那一个。生命周期收尾则是在 OnDestroy 里调用 wglDeleteContext 释放渲染上下文忘掉这一步每次窗口关闭都会泄漏一块显存状态程序反复打开关闭后会越来越卡。4.3 场景初始化光照、深度与背景创建好渲染上下文之后视图类里通常还有一次场景初始化我见过的源码多是把这段独立成一个 InitGL 函数在 OnCreate 末尾调用一次。它负责设置 OpenGL 状态机里那些“每帧都一样”的东西void CADDemoView::InitGL() { glClearColor(0.90f, 0.92f, 0.95f, 1.0f); // 浅灰背景RGB 对应三通道值 glEnable(GL_DEPTH_TEST); // 开启深度测试实体前后遮挡正确 glShadeModel(GL_SMOOTH); // 平滑着色模式避免色块断层 GLfloat lightPos[] { 1.0f, 1.0f, 1.0f, 0.0f }; glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); glLightfv(GL_LIGHT0, GL_POSITION, lightPos); }glClearColor 四个参数分别对应红、绿、蓝、透明度这里 0.9、0.92、0.95 混出来是接近白纸的浅灰适合作为 CAD 软件的绘图背景。GL_LIGHT0 的位置被定义成无穷远处的方向光第四个分量 0 表示这是方向光源而非点光源。初学者最容易漏掉 GL_DEPTH_TEST漏掉的直接后果是后画的实体永远盖住先画的实体三维场景看起来像一张透明塑料片。这块没有设置投影矩阵是因为投影和视口强相关通常放到 OnSize 里根据窗口宽高比动态计算。4.4 OnDraw 里的渲染主循环这一节对应源码里最核心的绘制函数。很多初学者以为 OnDraw 里只要调用 OpenGL 画图就行实际上还要自己完成上下文绑定和缓冲交换void CADDemoView::OnDraw(CDC* pDC) { HDC hdc pDC-GetSafeHdc(); wglMakeCurrent(hdc, m_hRC); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清空颜色与深度 DrawAxis(); // 绘制参考坐标轴 GetDocument()-DrawAll(); // 遍历实体链表逐个绘制 SwapBuffers(hdc); // 交换前后缓冲 wglMakeCurrent(NULL, NULL); // 解绑渲染上下文 }wglMakeCurrent 把渲染上下文和当前窗口 DC 绑定之后的 OpenGL 调用才会作用到这个窗口上。绘制结束后 SwapBuffers 会把已经画好的后缓冲显示到屏幕上这是双缓冲机制的关键一步。DrawAll 放在文档类里是这套源码设计上比较聪明的点——视图只关心渲染实体数据的遍历逻辑留给了文档类将来若要支持多视图同时观察同一个模型每个视图调用一次 DrawAll 就能各自绘制数据源只有一份。贴一下文档类里 DrawAll 大概的样子void CADDemoDoc::DrawAll() { POSITION pos m_entityList.GetHeadPosition(); while (pos ! NULL) { CEntity* pEntity m_entityList.GetNext(pos); pEntity-Draw(); // 每个实体自己知道怎么画 } }这种把绘制行为下沉到实体类自己的做法是典型的面向对象重塑。新增一种实体类型时不需要改动视图和文档的遍历逻辑只要让新类实现 Draw 方法然后加进链表即可。4.5 橡皮筋交互鼠标消息三兄弟有经验的用户应该已经猜到画线功能是靠橡皮筋效果支撑的——按下鼠标时固定起点拖动时不断画临时线段松开时正式提交实体。MFC 下实现橡皮筋交互绕不开三个鼠标消息响应函数void CADDemoView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing TRUE; m_startPt point; SetCapture(); // 捕获鼠标拖动移出窗口也能收到消息 } void CADDemoView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_endPt point; Invalidate(FALSE); // 触发重绘但不擦除背景减少闪烁 } } void CADDemoView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; ReleaseCapture(); CPoint3D start ScreenToWorld(m_startPt); CPoint3D end ScreenToWorld(point); GetDocument()-AddEntity(new CLineEntity(start, end)); GetDocument()-UpdateAllViews(NULL); } }SetCapture / ReleaseCapture 是这套交互里容易被忽略的细节。没有它鼠标拖出客户区后窗口就收不到 WM_MOUSEMOVE橡皮筋会在窗口边缘中断有了它整个拖动过程中鼠标消息始终发给视图直到松开左键。Invalidate(FALSE) 里布尔参数的意思是“重绘但不擦除背景”它让橡皮筋的临时线条可以覆盖在上一次的绘制结果上肉眼看起来连续且不闪。这里还有一层关键设计鼠标移动期间代码并没有直接调用 OpenGL 画线而是把当前的 m_startPt 和 m_endPt 更新后交给重绘机制去画。这样画出的临时线条和正式实体的渲染路径完全一致将来要改线条颜色、线型都只需改一处不会出现临时预览和最终结果风格不一致的尴尬。4.6 屏幕坐标到三维世界gluUnProject 拾取CAD 交互里最反直觉的一步是把鼠标的平面坐标还原成三维空间里的点。屏幕上的点只有 x 和 y但场景里的实体是立体的OpenGL 的做法是沿着鼠标位置发射一条射线把射线和某一投影平面的交点当作三维坐标。这套源码里最常见的是取深度值 z0 的位置代码如下CPoint3D CADDemoView::ScreenToWorld(CPoint pt) { GLdouble modelView[16], projection[16]; GLint viewport[4]; GLdouble wx, wy, wz; glGetDoublev(GL_MODELVIEW_MATRIX, modelView); glGetDoublev(GL_PROJECTION_MATRIX, projection); glGetIntegerv(GL_VIEWPORT, viewport); // Windows 窗口坐标 y 轴向下OpenGL 视口坐标 y 轴向上必须翻转 wy viewport[3] - (GLdouble)pt.y; gluUnProject( (GLdouble)pt.x, wy, 0.0, modelView, projection, viewport, wx, wy, wz); return CPoint3D(wx, wy, wz); }gluUnProject 的六个输入参数分为两组鼠标位置的 x、y、z以及当前的模型视图矩阵、投影矩阵、视口参数。这里 z 取 0.0 表示取近裁剪面上的点这个约定保证了拾取到的点落在用户可见的场景范围内。百分之九十的拾取错位源头都是那一行视口翻转——Windows 的 CPoint 以客户区左上角为原点而 OpenGL 的世界坐标以视口左下角为原点忘记翻转 y 轴拾取点就会跑到对称的位置上。这套源码里几乎每个交互功能都绕不开这个函数值得反复看。5. 避坑与排查跑 Demo 最常见的五个翻车现场5.1 窗口拉伸后图形变形拉伸现象窗口被拖大或拖小后场景里的坐标轴和图形实体变得扁了或胖了不再是原本的比例。原因OpenGL 的视口和投影矩阵没有跟着窗口尺寸变化。源码里虽然有 OnSize 处理函数但常见的翻车是只改了 glViewport没有同步更新透视投影矩阵的宽高比导致图形按旧宽高比被拉伸到新视口上。解决在 OnSize 里同时设置视口和投影矩阵。先拿新窗口宽高计算 aspect width / height然后清空投影矩阵用 gluPerspective(45.0, aspect, 0.1, 1000.0) 重建透视矩阵最后把模型视图矩阵复位。注意 height 为 0 时要做个保护判断窗口最小化时会触发到这个边界值不处理程序直接崩溃这个坑我踩过不止一次。5.2 画面闪烁、拖影或者什么都看不到现象拖动鼠标时橡皮筋线条疯狂闪烁或者整个窗口重绘后留下一堆残留线条严重的时候画面直接黑屏。原因闪烁通常是双缓冲没启用或 SwapBuffers 没有被调用。新手把 OpenGL 绘制代码塞进 OnPaint 但忘记调 SwapBuffers结果画面画在了后缓冲里永远不显示前缓冲显示的还是上一次的旧内容。拖影问题则往往出在 OnEraseBkgnd 上MFC 默认会先擦白背景再重绘擦除和重绘之间有一帧的空白间隔人眼捕捉到的就是闪烁。解决确认像素格式设置了 PFD_DOUBLEBUFFER确认 OnDraw 末尾调用了 SwapBuffers。然后在视图类里重写 OnEraseBkgnd 并直接返回 TRUE告诉系统背景由 OpenGL 自己负责绘制不需要 MFC 擦除。这是那个年代干掉窗口闪烁的通用三板斧缺一不可。5.3 编译链接报错 unresolved external symbol glClear现象编译通过链接阶段报错一堆错误形如 unresolved external symbol _glClear4上百个 OpenGL 函数全部无法解析。原因工程没有正确链接 OpenGL 的导入库。VC6 里新建工程默认不会带上 opengl32.lib、glu32.lib 和 glaux.lib如果源码里用了这些库的函数但工程配置里没写链接器就找不到符号。有时也会遇到 glaux.lib 缺失的情况那是 SDK 版本不完整导致的。解决检查三个库是否正确加入。Project - Settings - Link - Object/Library Modules 里追加 opengl32.lib glu32.lib glaux.lib。在较新版本的 VS 里路径可以替换为 #pragma comment(lib, opengl32.lib) 这类指令效果一样。这里还有个隐藏点如果源码同时用到了旧式 OpenGL 函数一定要保证头文件的引入顺序里 gl.h 在 windows.h 之前或至少不冲突顺序反了会出现几十个类型重定义报错极其折磨。5.4 拾取点偏了画出来的线不在鼠标位置现象鼠标点在一个位置画出的图形实体落在另一个位置且偏差方向受窗口大小影响。原因两个常见来源。第一个是窗口坐标和 OpenGL 坐标的 y 轴方向没翻转上一章代码里已经单独强调过。第二个是拾取用的投影矩阵和绘制时的投影矩阵不一致——OnSize 里用 gluPerspective 设置了透视投影但拾取代码里读取的矩阵是旧的或者拾取代码在错误的时机比如没有先 wglMakeCurrent调用了 glGetDoublev拿到的矩阵数据不完整。解决先检查 ScreenToWorld 里有没有做 viewport[3] - pt.y 的翻转确认拾取调用发生在渲染上下文已经绑定的状态下然后把投影矩阵的设置逻辑封装成一个函数OnSize 和拾取前都调用同一个函数保证两处矩阵永远一致。拾取问题的血泪经验就一句绘制的矩阵状态和拾取的矩阵状态必须完全同源任何一点分叉都会让坐标飘到莫名其妙的地方。5.5 窗口反复重绘后越来越卡进程内存只增不减现象程序刚启动很流畅反复切换视图、重画几次后光标开始迟钝打开任务管理器发现 GDI 对象数量一路攀升。原因生成矩形、圆形等实体的显示列表时用了 glGenLists但在文档清空或者删除某个实体时没有调用 glDeleteLists 释放。显示列表是显存资源泄露一次两次看不出来循环重绘几十次后就把显存或 GDI 堆占满了。另一个常见来源是渲染上下文在窗口关闭时没有被 wglDeleteContext 释放资源被握到进程退出才回收。解决给实体类加上析构逻辑在析构中检查自己是否分配了显示列表有则调用 glDeleteLists 释放。销毁窗口时在 OnDestroy 里补上 wglDeleteContext。方便的习惯是给文档类加 ClearAll 方法在新建、清空时统一走这个入口避免资源释放逻辑散落在各处。处理完这两处反复重绘后的 GDI 对象数量应当保持平稳不再单边攀升。6. 二次开发把这份 Demo 改造成你自己的 CAD 基础框架6.1 三步走换数据、换渲染、换交互第一步把文档类里的实体链表从简单的结构体升级成多态类体系。原始源码里实体数据结构以线段、矩阵这类基本图元为主改造时我一般会抽出 CEntity 基类派生 CLineEntity、CCircleEntity、CArcEntity把 Draw、Pick、GetBoundingBox 设计成虚函数。文档类只维护 CEntity 链表新增实体类型时不影响既有代码这是面向对象重塑的核心收益。第二步给视图类增加渲染模式控制。加一个枚举成员表示当前是线框模式还是着色模式在 OnDraw 的遍历循环里按模式用 glPolygonMode 切换填充策略。CAD 软件常见的“实体着色 / 隐藏线消除 / 线框显示”三种视图模式本质上只是状态切换OpenGL 固定管线里一次 glPolygonMode 调用就能完成。第三步扩展交互命令。MFC 里新增命令的标准套路是在资源编辑器里加一个菜单项和快捷键在视图类里挂一个命令处理函数函数内部像 OnLButtonDown 那样处理消息即可。想要尽快出新功能建议先拍照前一个可以框选多个实体的交互这个功能不需要理解 OpenGL 的复杂机制理解上一章的 ScreenToWorld 拾取函数就够用了。6.2 快速加一个功能点选高亮作为改造练手我建议从“点选实体并高亮”开始。它要复用已经跑通的拾取链路改动范围小成就感却很强。void CADDemoView::OnLButtonDblClk(UINT nFlags, CPoint point) { CEntity* pEntity GetDocument()-PickEntity(ScreenToWorld(point)); if (pEntity ! NULL) { pEntity-SetHighlight(!pEntity-IsHighlight()); Invalidate(FALSE); } CView::OnLButtonDblClk(nFlags, point); }PickEntity 需要你在文档类里实现一个遍历逻辑调用每个实体的拾取检测接口返回命中的实体指针。高亮本身更简单在实体的 Draw 方法里判断高亮标志为真时把颜色临时换成亮黄色画完再恢复。这个功能加完之后你就拥有了一个所有 CAD 软件都有的基本交互点同时也验证了第 4 章拾取链路的正确性。从那以后我每次接手别人写的老 MFC 图形项目都会强制先跑通一遍“像素格式 — 渲染上下文 — OnDraw — 鼠标拾取”这条最小链路确认链路通了我才敢动业务代码。这套源码给我的不是一个可以直接商用的三维 CAD而是一条完整的、带注释的、出问题能定位的工程路径。希望帮到你。本文还有配套的精品资源点击获取