简介:这份资源面向学习计算机图形学与C++编程的学生及开发者,聚焦在MFC框架下结合OpenGL读取obj三维模型并完成纹理贴图这一典型实验场景,帮助读者打通模型文件解析、GPU数据组织与纹理映射的完整链路。压缩包共123个文件、约35.13MB,以obj模型与bmp贴图资源、h头文件与cpp源码为主体,辅以mtl材质文件、ico图标、sln工程与vcxproj项目配置及exe可执行文件,构成可直接编译运行的完整工程。资源已有1338人学习下载,具备一定的参考热度。读者可从中获得obj顶点、法线与纹理坐标的解析思路,VAO、VBO、IBO的创建与顶点属性配置方法,以及纹理加载绑定和glDrawElements绘制的实践代码,并借助MeshRender等关键类的组织方式理解渲染流程,适合作为课程实验参考与图形编程入门练手素材。
1. 从一份 obj 模型说起:这个项目到底解决什么问题
手里拿到一个 obj 文件,双击打不开,拖进建模软件又嫌重,想快速看看它长什么样、贴图有没有对上,这时候一个几十 KB 的小工具就比装一整套三维软件实在得多。这个项目干的就是这件事:用 MFC 搭一个 Windows 窗口程序,把 obj 文件里的顶点、法线、纹理坐标读进来,再用 OpenGL 把模型画出来,并把配套的贴图贴上去。它适合两类人:一类是刚学计算机图形学、想搞明白「模型文件到底存了什么」的学生;另一类是要在 Windows 上做小工具、需要把 obj 预览能力嵌进自己程序里的开发者。obj 加 MFC 加 OpenGL 这个组合看着老派,但胜在依赖少、链路短,从读文件到屏幕上出画面,中间没有黑匣子,每一步都能自己改。
2. 工程结构与技术选型:MFC 壳子加 OpenGL 渲染
2.1 为什么是 MFC 而不是别的窗口框架
这个项目的定位是 Windows 桌面小工具,MFC 是最省事的选择。它随 Visual Studio 一起装,不需要额外拉 Qt 那一套几百 MB 的运行时,生成的 exe 也小。MFC 负责的是窗口、菜单、文件对话框这些「壳」上的事,真正画模型的部分交给 OpenGL。两者分工很清楚:MFC 的CWnd提供一个窗口句柄,OpenGL 通过wglCreateContext在这个句柄上建渲染上下文,之后所有绘制都走 OpenGL 的 API。
常见做法是把主窗口类继承自CView或者CWnd,在OnCreate里初始化 OpenGL,在OnPaint或者OnDraw里调绘制函数,在OnSize里同步视口。这样窗口缩放、重绘都由 MFC 的消息机制驱动,不用自己写消息循环。
// 主视图类头文件,继承 CView 拿到绘图区域 class CObjViewerView : public CView { protected: HGLRC m_hRC; // OpenGL 渲染上下文 CDC* m_pDC; // 设备上下文,用来建 RC CModel m_model; // 模型数据容器 virtual BOOL PreCreateWindow(CREATESTRUCT& cs); afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg void OnDestroy(); afx_msg void OnSize(UINT nType, int cx, int cy); afx_msg void OnPaint(); afx_msg void OnFileOpen(); // 菜单里的「打开 obj」 DECLARE_MESSAGE_MAP() };这段是视图类的骨架。m_hRC是 OpenGL 上下文句柄,m_pDC是 MFC 的设备上下文,两者配对使用。m_model是自定义的模型容器,后面读 obj 的结果都往它里面塞。消息映射里挂了创建、销毁、尺寸变化、重绘、打开文件五个入口,基本覆盖了一个预览工具需要的全部交互。
2.2 OpenGL 上下文的建立与像素格式
MFC 窗口默认的像素格式不带深度缓冲,直接画三维模型会出现前后遮挡错乱。所以建上下文之前必须先设置PIXELFORMATDESCRIPTOR,把深度位、双缓冲、OpenGL 支持都打开。
int CObjViewerView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; m_pDC = new CClientDC(this); // 像素格式:双缓冲 + 深度缓冲 + OpenGL PIXELFORMATDESCRIPTOR pfd = { sizeof(pfd), 1 }; pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; pfd.iPixelType = PFD_TYPE_RGBA; pfd.cColorBits = 32; pfd.cDepthBits = 24; // 深度缓冲,缺了它模型会穿模 pfd.iLayerType = PFD_MAIN_PLANE; int pf = ChoosePixelFormat(m_pDC->GetSafeHdc(), &pfd); SetPixelFormat(m_pDC->GetSafeHdc(), pf, &pfd); m_hRC = wglCreateContext(m_pDC->GetSafeHdc()); wglMakeCurrent(m_pDC->GetSafeHdc(), m_hRC); glEnable(GL_DEPTH_TEST); // 开启深度测试 glClearColor(0.15f, 0.15f, 0.18f, 1.0f); return 0; }cDepthBits = 24是关键参数,它决定深度缓冲精度,24 位对一般模型足够。PFD_DOUBLEBUFFER开双缓冲,避免画面撕裂。wglMakeCurrent把上下文绑定到当前线程,之后所有gl*调用才生效。glEnable(GL_DEPTH_TEST)必须开,否则后画的三角形会盖住先画的,模型看起来像纸片。
2.3 目录结构
工程按 Visual Studio 的 MFC 应用模板组织,核心文件不多,读起来不费劲:
ObjViewer/ ├── ObjViewer.sln ├── ObjViewer/ │ ├── ObjViewer.vcxproj │ ├── MainFrm.h / .cpp // 主框架窗口 │ ├── ObjViewerDoc.h / .cpp // 文档类,持有模型数据 │ ├── ObjViewerView.h / .cpp // 视图类,OpenGL 渲染 │ ├── Model.h / .cpp // 模型容器与 obj 解析 │ ├── Texture.h / .cpp // 贴图加载 │ └── res/ // 图标、菜单资源 └── models/ ├── sample.obj └── sample.png文档类持有数据、视图类负责渲染,这是 MFC 文档视图架构的标准分工。把 obj 解析单独抽到Model里,是为了让解析逻辑和窗口代码解耦,将来换渲染后端也不用动解析部分。
3. obj 解析与贴图加载:从文本到顶点数组
3.1 obj 文件里到底有什么
obj 是纯文本格式,一行一条记录,常用的前缀就那么几个:v是顶点坐标,vt是纹理坐标,vn是法线,f是面。面那一行写的是索引,格式是顶点索引/纹理索引/法线索引,索引从 1 开始而不是 0,这是新手最容易翻车的地方。
v 0.0 1.0 0.0 v -1.0 0.0 1.0 v 1.0 0.0 1.0 vt 0.5 1.0 vn 0.0 0.0 1.0 f 1/1/1 2/1/1 3/1/1上面这段描述了一个三角形。三个v是顶点,一个vt是纹理坐标,一个vn是法线,f那一行把三者按索引组合起来。解析器要做的就是把这些行读进三个数组,再按面的索引展开成渲染用的顶点序列。
3.2 解析器的实现
解析部分用std::ifstream逐行读,按前缀分流。这里用std::stringstream拆字段,比sscanf好维护。
bool CModel::LoadObj(const std::string& path) { std::ifstream fin(path); if (!fin.is_open()) return false; std::vector<Vec3> positions; // 原始顶点 std::vector<Vec2> texcoords; // 原始纹理坐标 std::vector<Vec3> normals; // 原始法线 std::string line; while (std::getline(fin, line)) { if (line.empty() || line[0] == '#') continue; std::stringstream ss(line); std::string tag; ss >> tag; if (tag == "v") { Vec3 p; ss >> p.x >> p.y >> p.z; positions.push_back(p); } else if (tag == "vt") { Vec2 t; ss >> t.u >> t.v; texcoords.push_back(t); } else if (tag == "vn") { Vec3 n; ss >> n.x >> n.y >> n.z; normals.push_back(n); } else if (tag == "f") { // 面行:每个 token 形如 v/vt/vn std::string token; while (ss >> token) { int vi = 0, ti = 0, ni = 0; ParseFaceToken(token, vi, ti, ni); // obj 索引从 1 开始,转成 0 基 m_vertices.push_back(positions[vi - 1]); if (ti > 0) m_uvs.push_back(texcoords[ti - 1]); if (ni > 0) m_normals.push_back(normals[ni - 1]); } } } return !m_vertices.empty(); }positions、texcoords、normals三个数组存的是文件里的原始数据,m_vertices等是展开后的渲染数据。ParseFaceToken负责把1/1/1这种 token 拆成三个整数,遇到1//1(没有纹理坐标)时ti保持 0。索引减 1 是必须的,obj 规范从 1 开始计数,忘了减就会越界或者错位,模型会变成一团乱麻。
提示:面不一定是三角形,四边形甚至多边形都合法。上面的写法把每个面按顶点顺序展开成三角形扇,对凸多边形有效;遇到凹多边形要额外做三角化,常见做法是引入耳切算法。
3.3 贴图加载与纹理坐标
贴图用 stb_image 加载最省事,单头文件,不引入额外依赖。加载完生成 OpenGL 纹理对象,绑定到GL_TEXTURE_2D。
bool CTexture::Load(const std::string& path) { int w, h, channels; stbi_set_flip_vertically_on_load(true); // obj 的 v 轴和图片行序相反 unsigned char* data = stbi_load(path.c_str(), &w, &h, &channels, 0); if (!data) return false; glGenTextures(1, &m_id); glBindTexture(GL_TEXTURE_2D, m_id); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); GLenum fmt = (channels == 4) ? GL_RGBA : GL_RGB; glTexImage2D(GL_TEXTURE_2D, 0, fmt, w, h, 0, fmt, GL_UNSIGNED_BYTE, data); stbi_image_free(data); return true; }stbi_set_flip_vertically_on_load(true)这行是血泪经验。obj 的纹理坐标原点在左下角,图片文件的行序是从上往下,不翻转的话贴图会上下颠倒,模型脸上的贴图跑到脚上。GL_REPEAT让纹理坐标超出 0 到 1 时平铺,适合地面、墙面这类重复贴图。
3.4 渲染循环与相机
渲染放在OnPaint里,每帧清屏、设投影、设视图、画模型、交换缓冲。相机用一个简单的轨道模型:鼠标拖动改方位角和俯仰角,滚轮改距离。
void CObjViewerView::OnPaint() { CPaintDC dc(this); wglMakeCurrent(m_pDC->GetSafeHdc(), m_hRC); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 投影矩阵:45 度视角,近远裁剪面按模型尺寸调 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0, (double)m_width / m_height, 0.1, 1000.0); // 视图矩阵:轨道相机 glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(m_camDist * sinf(m_yaw) * cosf(m_pitch), m_camDist * sinf(m_pitch), m_camDist * cosf(m_yaw) * cosf(m_pitch), 0, 0, 0, 0, 1, 0); m_model.Draw(); // 内部绑定纹理、提交顶点 SwapBuffers(m_pDC->GetSafeHdc()); }gluPerspective的第四个参数是远裁剪面,设太小模型会被裁掉一半,设太大深度精度下降,常见做法是按模型包围盒对角线长度乘 2 到 3 倍来定。gluLookAt的前三个参数是相机位置,中间三个是看向的点,最后三个是上方向。轨道相机的思路是把相机位置用球坐标算出来,鼠标拖动改m_yaw和m_pitch,滚轮改m_camDist。
4. 跑起来与调试:编译、依赖与常见问题排查
4.1 编译环境与依赖
工程用 Visual Studio 打开,需要装「使用 C++ 的桌面开发」工作负载,里面包含 MFC 和 Windows SDK。OpenGL 的头文件和库系统自带,opengl32.lib和glu32.lib在链接器里加上即可。stb_image 是单头文件,直接放进工程目录,在一个 cpp 里定义STB_IMAGE_IMPLEMENTATION再包含它。
// 只在一个 cpp 文件里定义实现,避免重复符号 #define STB_IMAGE_IMPLEMENTATION #include "stb_image.h"链接器设置里加opengl32.lib;glu32.lib;,字符集建议用多字节,省得std::ifstream打开中文路径时出问题。
4.2 常见问题排查
现象:窗口一片黑,什么都不显示。原因通常是渲染上下文没建成功,或者wglMakeCurrent没调。解决:在OnCreate里检查m_hRC是否为空,ChoosePixelFormat的返回值是否为 0,失败时用GetLastError看错误码。
现象:模型显示但贴图全白。原因多半是纹理没绑定,或者纹理坐标没传。解决:确认glEnable(GL_TEXTURE_2D)开了,绘制前glBindTexture绑了正确的 id,顶点数组里纹理坐标的偏移量对得上。
现象:模型穿模,背面盖住正面。原因是没有深度缓冲或者没开深度测试。解决:检查PIXELFORMATDESCRIPTOR的cDepthBits是否大于 0,glEnable(GL_DEPTH_TEST)是否调用,glClear是否带了GL_DEPTH_BUFFER_BIT。
现象:模型比例不对,要么巨大要么看不见。原因是没有按模型包围盒调整相机距离。解决:解析完顶点后算一遍包围盒,把m_camDist初始化为包围盒对角线长度的 2 倍左右。
现象:obj 能读但面是乱的。原因通常是索引没减 1,或者面 token 解析时把v//vn这种空纹理位当成了 0 号纹理。解决:ParseFaceToken里对空字段单独判断,索引统一减 1 后再取数组。
注意:obj 文件里可能有多个
o或g分组,简单解析器会把它们全塞进一个数组,对预览够用;要做分组显示就得按o切分,每组单独存。
5. 二次开发:把预览器改成能用的工具
5.1 加一个模型信息面板
预览器跑通之后,最自然的改造是显示模型统计信息:顶点数、面数、包围盒尺寸。在文档类里加几个成员,解析完顺手算出来,视图类里用CDC::TextOut画到角落。
struct ModelStats { int vertexCount = 0; int faceCount = 0; Vec3 bboxMin{ 1e9f, 1e9f, 1e9f }; Vec3 bboxMax{ -1e9f, -1e9f, -1e9f }; }; void CModel::ComputeStats() { m_stats.vertexCount = (int)m_vertices.size(); for (auto& v : m_vertices) { m_stats.bboxMin.x = min(m_stats.bboxMin.x, v.x); m_stats.bboxMin.y = min(m_stats.bboxMin.y, v.y); m_stats.bboxMin.z = min(m_stats.bboxMin.z, v.z); m_stats.bboxMax.x = max(m_stats.bboxMax.x, v.x); m_stats.bboxMax.y = max(m_stats.bboxMax.y, v.y); m_stats.bboxMax.z = max(m_stats.bboxMax.z, v.z); } }包围盒除了显示,还能用来做自动缩放:加载完模型后把相机距离设成包围盒对角线长度乘 2,模型就不会一上来跑出视野。
5.2 换渲染后端时的解耦点
如果将来想换成现代 OpenGL(着色器加 VBO),改动集中在CModel::Draw和上下文初始化两处。解析部分输出的是m_vertices、m_uvs、m_normals三个扁平数组,正好可以直接喂给 VBO。把Draw里的立即模式调用换成glDrawArrays,其余代码不动。这也是当初把解析和渲染分开的原因:数据格式稳定,渲染方式随便换。
| 改造方向 | 改动位置 | 工作量 |
|---|---|---|
| 现代 OpenGL 着色器 | Model::Draw、上下文初始化 | 中 |
| 加模型信息面板 | Model::ComputeStats、View::OnPaint | 小 |
| 支持 mtl 材质 | Model::LoadObj 增加 mtl 解析 | 中 |
| 鼠标拾取选中面 | View 的鼠标消息、射线求交 | 大 |
5.3 一个容易忽略的验证习惯
改完解析器之后,别急着看画面,先把读出来的顶点数和面数打印出来,和 obj 文件里v、f的行数对一遍。数字对不上,说明解析逻辑有问题,这时候看画面只会被渲染效果误导。我一般会在LoadObj末尾加一行日志,把三个数组的大小和面数打出来,跑几个不同来源的 obj 文件对比。从那以后我每次改解析代码都强制走一遍这个对数流程,比盯着屏幕猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取