news 2026/10/21 2:43:56

MFC+OpenGL读取OBJ模型并贴图的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MFC+OpenGL读取OBJ模型并贴图的完整实现

简介:面向计算机图形学学习者和使用微软基础类库的C++开发者,这一示例工程演示了如何在MFC窗口程序中结合OpenGL图形接口读取OBJ三维模型文件,并完成纹理贴图与最终渲染输出。资源包共123个文件,约35.13MB,内部包含一套完整的Visual Studio工程:cpp与h源代码负责模型解析和渲染逻辑,bmp、ico、rc等提供界面资源,exe、pdb、obj等为编译生成文件,另有mtl材质定义和docx实验报告,目录分区清晰,适合按模块对照学习。工程代码完整覆盖了读取OBJ、解析顶点与纹理坐标、配置VAO/VBO缓冲、加载并绑定纹理、调用绘制函数等核心环节,同时结合MFC完成窗口与消息处理,能帮助读者把图形学原理落到实际项目里。目前已有1337人浏览学习,对于正在学习MFC与OpenGL协同编程,或需要完成相关课程实验报告的学生开发者来说,是一份可直接运行、调试和修改的实践模板。

1. 读取obj文件并贴图:一套可直接复现的MFC+OpenGL模型查看器

接手某个工业离线编程工具的预览功能时,我翻遍了手头资源,发现一个尴尬的现实:能跑的OBJ查看器多半基于Qt或纯控制台,真正嵌进MFC老工程的模块要么编译不过,要么只显示线框模型,贴图功能形同虚设。这份资源恰好弥补了缺口——它用MFC文档视图架构搭起外壳,用OpenGL固定管线完成渲染,把OBJ文件的顶点、纹理坐标、材质信息完整读取出来并贴到模型表面。你不需要重写业务框架,把其中的视图类和解析器拷贝进现有工程,就能立刻获得一个带鼠标旋转、滚轮缩放、实时贴图预览的3D查看模块。适合做上位机模型预览、逆向建模辅助,以及想拿MFC+OpenGL练手却没完整参照的开发者。

我按照源码、资源文件、文档的排列把整个工程拆开读了一遍,发现它的设计并不复杂,但有几处处理非常老道:解析器没有用流式读取硬扛长文件,而是先按行分类再二次扫描;纹理映射没有走难调的GLSL,而是用固定管线完成,这让它能在各种老显卡环境里跑起来。下面从OBJ解析开始,一层层剥到渲染和贴图,最后给出可以照着改的二次开发建议。

2. OBJ文件格式与解析器设计:从顶点到材质的完整流水线

2.1 OBJ语法与解析器的分步扫描策略

OBJ是一种极其直白的文本格式,每一行以关键字开头,后面跟着数值或文件名。最常见的几类:

  • v x y z:顶点坐标,三个float。
  • vt u v:纹理坐标,两个float。
  • vn x y z:顶点法线。
  • f v1/vt1/vn1 v2/vt2/vn2 ...:面索引,每个顶点由“顶点索引/纹理索引/法线索引”三元组构成,索引从1开始。
  • mtllib filename.mtl:引用材质文件。
  • usemtl material_name:指定从下一个面开始使用的材质。

这套引擎的解析器没有用逐行split这种偷懒写法,而是采用两趟扫描。第一趟把文件按行读入一个std::vector<std::string>,同时统计v、vt、vn、f各自的数量,提前分配容器容量;第二趟才真正解析数据。这样做的好处是减少动态扩容带来的内存搬移,对于包含几十万顶点的模型性能提升明显。

bool LoadOBJ(const std::string& path, std::vector<Vertex>& outVertices, std::vector<unsigned int>& outIndices) { std::ifstream in(path); if (!in.is_open()) return false; std::string line; std::vector<std::string> lines; lines.reserve(1024 * 64); // 预估计行数,减少realloc while (std::getline(in, line)) { lines.push_back(line); } std::vector<glm::vec3> positions; std::vector<glm::vec2> texcoords; std::vector<glm::vec3> normals; positions.reserve(1024 * 8); texcoords.reserve(1024 * 8); normals.reserve(1024 * 8); for (const auto& s : lines) { if (s.empty() || s[0] == '#') continue; std::istringstream iss(s); std::string key; iss >> key; if (key == "v") { glm::vec3 p; iss >> p.x >> p.y >> p.z; positions.push_back(p); } else if (key == "vt") { glm::vec2 uv; iss >> uv.x >> uv.y; texcoords.push_back(uv); } else if (key == "vn") { glm::vec3 n; iss >> n.x >> n.y >> n.z; normals.push_back(n); } else if (key == "f") { // 面解析单独处理,见下一节 } } return true; }

这段代码把三种基础数据分别存入独立的vector,没有立刻合并成顶点结构。原因是OBJ的索引体系允许三角形三个角各自引用不同的v/vt/vn,如果直接合并,每个面都需要临时构建,反而浪费内存。第二趟扫描再根据面索引从三个容器中取出数据,生成OpenGL可用的紧凑顶点数组。

2.2 面索引重组:从OBJ稀疏索引到OpenGL的顺序数组

OpenGL的VBO要求顶点数据紧凑排列,而OBJ的f行允许每个顶点的位置、纹理坐标、法线索引完全独立。如果不做处理,直接按f里的三元组提交数据,会导致大量重复顶点,甚至因为索引不连续造成渲染错乱。这套代码的做法是维护一个从“OBJ索引三元组”到“OpenGL顶点索引”的映射表,中央是一个std::unordered_map,键为三元组编码,值为新生成的顺序索引。

using TripletKey = std::tuple<int, int, int>; void ParseFace(const std::string& faceStr, const std::vector<glm::vec3>& positions, const std::vector<glm::vec2>& texcoords, const std::vector<glm::vec3>& normals, std::vector<Vertex>& outVertex, std::vector<unsigned int>& outIndex, std::unordered_map<TripletKey, unsigned int>& cache) { std::istringstream iss(faceStr); std::string token; std::vector<int> posIdx, texIdx, nIdx; while (iss >> token) { std::replace(token.begin(), token.end(), '/', ' '); std::istringstream tok_stream(token); int pi = 1, ti = 1, ni = 1; tok_stream >> pi; if (tok_stream >> ti) {} else { ti = 1; } if (tok_stream >> ni) {} else { ni = 1; } posIdx.push_back(pi); texIdx.push_back(ti); nIdx.push_back(ni); } // OBJ索引从1开始,转换为从0开始 for (size_t i = 0; i < posIdx.size(); ++i) { TripletKey key = std::make_tuple(posIdx[i], texIdx[i], nIdx[i]); auto it = cache.find(key); unsigned int vertexIndex; if (it != cache.end()) { vertexIndex = it->second; } else { Vertex vert; vert.position = positions[posIdx[i] - 1]; vert.texCoord = texcoords[texIdx[i] - 1]; vert.normal = normals[nIdx[i] - 1]; outVertex.push_back(vert); vertexIndex = static_cast<unsigned int>(outVertex.size() - 1); cache.emplace(key, vertexIndex); } outIndex.push_back(vertexIndex); } }

关键逻辑在于std::replace把v/vt/vn格式中的斜杠替换为空格,这样7/3/1就能被流式解析成三个整数。需要特别小心的是,有些OBJ文件省略纹理或法线索引,写成7//1或7/3,代码里通过tok_stream >> ti的返回值判断是否需要设置默认值。实际解析时,我遇到过文件的f行混合了三种写法,这套以默认值组合三元组的方式能兼容不完整的索引。最后生成的outVertex和outIndex可以直接喂给glDrawElements。

2.3 mtl材质文件与纹理路径的关联

OBJ的材质信息存放在MTL文件中,解析器需要单独处理。这套工程在加载OBJ时记录mtllib和usemtl,随后根据材质名配对纹理文件。MTL中常见的map_Kd指令表示漫反射贴图路径,Kd表示基础颜色。处理材质时我一般会把MTL文件所在目录拼接到贴图路径前,因为很多OBJ文件里的贴图路径是相对路径,直接读取会失败。

struct Material { std::string name; glm::vec3 ambient = glm::vec3(0.2f); glm::vec3 diffuse = glm::vec3(0.8f); glm::vec3 specular = glm::vec3(0.0f); float shininess = 0.0f; std::string texturePath; bool hasTexture = false; }; bool ParseMTL(const std::string& mtlPath, std::map<std::string, Material>& materials) { std::ifstream in(mtlPath); if (!in.is_open()) return false; std::string line; Material* current = nullptr; while (std::getline(in, line)) { // 跳过空行和注释 if (line.empty() || line[0] == '#') continue; std::istringstream iss(line); std::string key; iss >> key; if (key == "newmtl") { std::string matName; iss >> matName; materials[matName] = Material(); current = &materials[matName]; current->name = matName; } else if (current) { if (key == "Kd") { iss >> current->diffuse.x >> current->diffuse.y >> current->diffuse.z; } else if (key == "map_Kd") { iss >> current->texturePath; current->hasTexture = true; } } } return true; }

这段代码把每个材质作为一个独立对象保存,并区分“有无贴图”两种状态。渲染时,没有贴图的模型直接使用Kd颜色,带贴图的则生成纹理对象。这样做的好处是,即使某个OBJ文件只有颜色没有贴图,视图模块也能正常显示,不会因为纹理内容为空而崩溃。

3. MFC + OpenGL 渲染框架:让模型动起来的关键绑定

3.1 在MFC文档视图架构中放置OpenGL窗口

MFC默认的CView是GDI窗口,要使用OpenGL必须做三件事:设置像素格式、创建渲染上下文、在窗口消息处理中释放上下文。这套工程里,作者在CView的OnCreate里完成像素格式设置,在OnDraw里绑定当前设备上下文与渲染上下文,并主动触发RedrawWindow,把MFC的GDI绘制完全交给OpenGL接管。

int CMyGLView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; m_hDC = GetDC(); static PIXELFORMATDESCRIPTOR pfd = { sizeof(PIXELFORMATDESCRIPTOR), 1, PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER, PFD_TYPE_RGBA, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; int pixelFmt = ChoosePixelFormat(m_hDC, &pfd); SetPixelFormat(m_hDC, pixelFmt, &pfd); m_hRC = wglCreateContext(m_hDC); wglMakeCurrent(m_hDC, m_hRC); return 0; } void CMyGLView::OnDraw(CDC* pDC) { // 确保当前线程持有RC wglMakeCurrent(m_hDC, m_hRC); RenderScene(); SwapBuffers(m_hDC); }

这里有个容易被忽略的问题:MFC的OnDraw既会在窗口首次显示时调用,也会在Invalidate后调用,如果每次重新生成RC,渲染上下文会泄漏。正确做法是把wglCreateContext放在OnCreate里只执行一次,OnDraw中只做wglMakeCurrent和SwapBuffers。窗口销毁时,必须在OnDestroy里先wglMakeCurrent(NULL, NULL),再删除RC和释放DC,否则程序退出时会触发断言。

3.2 相机控制与渲染循环:鼠标拖拽旋转、滚轮缩放

这套工程没有使用复杂的轨迹球算法,而是用欧拉角加距离实现相机控制。鼠标按下时记录上一帧光标坐标,拖拽过程中增量计算绕X轴的俯仰角和绕Y轴的方位角,滚轮消息直接改变相机与模型中心的距离。渲染时通过gluLookAt把相机放到极坐标方程计算出来的位置上。

void CMyGLView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging) { float dx = static_cast<float>(point.x - m_lastPoint.x); float dy = static_cast<float>(point.y - m_lastPoint.y); m_yaw += dx * 0.5f; m_pitch += dy * 0.5f; if (m_pitch > 89.0f) m_pitch = 89.0f; if (m_pitch < -89.0f) m_pitch = -89.0f; m_lastPoint = point; Invalidate(FALSE); } CView::OnMouseMove(nFlags, point); } BOOL CMyGLView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { m_distance -= zDelta / 120.0f * 2.0f; if (m_distance < 2.0f) m_distance = 2.0f; if (m_distance > 100.0f) m_distance = 100.0f; Invalidate(FALSE); return TRUE; } void CMyGLView::RenderScene() { glClearColor(0.2f, 0.2f, 0.25f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0, (double)GetClientRect_cx / GetClientRect_cy, 0.1, 500.0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); float cx = m_distance * cos(glm::radians(m_pitch)) * sin(glm::radians(m_yaw)); float cy = m_distance * sin(glm::radians(m_pitch)); float cz = m_distance * cos(glm::radians(m_pitch)) * cos(glm::radians(m_yaw)); gluLookAt(cx, cy, cz, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f); glEnable(GL_DEPTH_TEST); DrawModel(); }

关键点是视角的俯仰角限制在正负89度,避免万向锁导致画面翻转。如果省略这个限制,鼠标连续上下拖拽会看到模型突然颠倒,造成“相机翻车”的错觉。另外滚轮步长zDelta / 120是标准Windows鼠标的一格滚动量,如果客户用的是高精度触摸板,这个步长可以改小。

3.3 固定管线还是着色器

现在的教程普遍推GLSL着色器,但MFC工程往往要兼容工控机上的老旧显卡驱动,固定管线反而是更稳妥的选择。这套代码用glEnable(GL_LIGHTING)、glLightfv设置光源,用glBegin(GL_TRIANGLES)提交顶点,阅读门槛极低,你不需要单独编译着色器文件。如果未来要升级,VBO数据部分可以复用,只需把glBegin/glEnd替换为glDrawArrays或glDrawElements。

固定管线最大的坑是必须在提交顶点前设置好纹理和材质状态,glBegin之后调glTexCoord2f是可以的,但调glEnable之类会直接导致绘制中断甚至崩溃。我实际调试时遇到过一次,在glBegin里调了glColor3f,结果只有第一个三角形被绘制,后面全部丢失。所以我会把所有的状态设置都放在glBegin之前。

4. 贴图加载与纹理映射:把UV坐标变成屏幕上的真实质感

4.1 从Windows位图到OpenGL纹理的转换桥

MFC环境下加载贴图最常见的方式是CImage或LoadImage。这套工程选择CImage,因为它能直接访问像素数据,且支持PNG、JPG,而OpenGL原生的glTexImage2D需要的是未压缩的RGBA数据。加载后需要把CImage的上下方向反转,因为Windows位图存储从底部开始,而OpenGL纹理坐标原点在左下角。

GLuint LoadTextureFromFile(const std::string& path) { CImage image; HRESULT hr = image.Load(path.c_str()); if (FAILED(hr)) return 0; image.FlipY(); // 关键:反转Y轴,否则贴图上下颠倒 int width = image.GetWidth(); int height = image.GetHeight(); int pitch = image.GetPitch(); int bpp = image.GetBPP(); GLenum format = (bpp == 32) ? GL_RGBA : GL_RGB; unsigned char* data = (unsigned char*)image.GetBits(); // 注意:GetPitch可能为负,需要统一为正向步长 std::vector<unsigned char> rgba(width * height * (bpp / 8)); for (int y = 0; y < height; ++y) { memcpy(rgba.data() + y * width * (bpp / 8), data + y * abs(pitch), width * (bpp / 8)); } GLuint texId = 0; glGenTextures(1, &texId); glBindTexture(GL_TEXTURE_2D, texId); glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, rgba.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_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); glGenerateMipmap(GL_TEXTURE_2D); glBindTexture(GL_TEXTURE_2D, 0); return texId; }

这里处理了GetPitch可能为负的情况。CImage在内存中每行字节数与理论宽度并不总是相等,某些情况下GetPitch会多出几个字节的对齐填充,直接传给OpenGL会拉伸变形。统一拷贝到紧密数组是一个保底做法,代价是多一次内存复制,但换来稳定。每次glTexImage2D前我都习惯检查width和height是否为2的幂,虽然现代GPU不要求,但老驱动对非2幂纹理会出现黑边或性能下降。

4.2 纹理坐标与光照的协同:混合颜色和贴图

模型带贴图后,如果同时启用GL_LIGHTING,最终颜色是材质颜色与光照的乘积。直接把纹理颜色乘以光照结果,会让贴图变暗。这套工程的处理方式是把纹理环境模式设置为GL_MODULATE,让纹理与当前材质颜色混合,同时设置足够亮的漫反射分量。

glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); GLfloat ambient[] = {0.2f, 0.2f, 0.2f, 1.0f}; GLfloat diffuse[] = {1.0f, 1.0f, 1.0f, 1.0f}; GLfloat lightPos[] = {100.0f, 200.0f, 300.0f, 1.0f}; glLightfv(GL_LIGHT0, GL_AMBIENT, ambient); glLightfv(GL_LIGHT0, GL_DIFFUSE, diffuse); glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glEnable(GL_COLOR_MATERIAL); glColorMaterial(GL_FRONT, GL_AMBIENT_AND_DIFFUSE);

绘制带纹理的三角形时,先绑定贴图,设置glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE),然后对每个顶点调用glTexCoord2f和glNormal3f。如果某个模型没有贴图,就关闭纹理绑定,用glColor3f直接指定材质颜色。这样处理让同一个绘制函数同时兼容有贴图和没贴图的OBJ文件,因为OBJ中usemtl切换材质时,贴图状态也可能变化。

4.3 重复纹理、非幂次尺寸与透明贴图

工业模型经常出现UV坐标超过1.0的情况,比如一个长方体侧面展开后,纹理坐标会跨越多个重复周期。注释里把GL_TEXTURE_WRAP_S/T设为GL_REPEAT,这样UV为2.0时依然能正确平铺。如果模型自带透明贴图,比如树叶或格栅,需要启用混合:

glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

但混合开启后深度排序问题也随之而来,同一模型内部透明面相互遮挡会出现闪烁。我一般只对特定材质启用混合,渲染完透明物体立刻关闭。另外,非2幂纹理尺寸在旧显卡上会被拉伸,导致贴图边缘模糊。这套工程没有强制缩位图,而是依赖glGenerateMipmap生成完整纹理链条,明显改善了缩小显示时的锯齿。

5. 避坑与常见问题排查:我在这份代码里踩过的五个坑

5.1 模型渲染出来只有线框,没有表面填充

  • 现象:加载OBJ后,视图窗口显示三角形网格线条,按住旋转时能看到背面的线,但没有实体面。
  • 原因:glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)被打开后没关闭,或者OBJ中面索引的顶点绕序不一致,导致背面剔除把所有面都剔除了。
  • 解决:绘制前强制设置glPolygonMode(GL_FRONT_AND_BACK, GL_FILL),检查glDisable(GL_CULL_FACE),并查看OBJ中是否混用了顺时针和逆时针的顶点序。

5.2 贴图全部颠倒且上下镜像

  • 现象:模型贴图显示完全正确,但纹理图像像是水平翻转后再上下颠倒,文字都反了。
  • 原因:CImage::FlipY只在部分情况下作用,某些BMP的DIB头里高度字段为负,FlipY操作重复执行反而加剧反转。
  • 解决:统一按GetPitch和GetBPP手动拷贝,并在渲染前用一张带方向文字的测试纹理验证,确认Y轴反转方向,而不是无脑调用FlipY。

5.3 模型加载崩溃或乱码,内存地址访问错误

  • 现象:OBJ文件中存在面索引为负值或超出顶点数组范围,解析时直接按索引取positions[i-1]导致越界。
  • 原因:很多网格简化的工具会导出错误索引,或者OBJ内包含不连续的面数据,例如f 1/2 3/4中间缺少顶点。
  • 解决:从三元组映射中取值前做一次边界检查,超出范围就跳过该索引,并在调试输出中打印警告。核心代码把容错放在解析层,渲染层永远只接收完整索引。

5.4 高DPI显示器下模型比例失调,窗口拉伸变形

  • 现象:在4K屏幕或缩放150%的Windows环境下,模型渲染区域被截断,鼠标旋转位置也不准。
  • 原因:MFC的OnDraw中获取客户区尺寸没有进行DPI缩放,导致glViewport的宽高比例与物理像素不一致。
  • 解决:在OnCreate后调用SetProcessDPIAware,或在GetClientRect后手动乘以GetDpiForWindow获取的缩放系数,并同步修正鼠标坐标计算。

5.5 光照开启后模型变成一团黑

  • 现象:启用GL_LIGHTING后模型颜色全黑,只有轮廓可见。
  • 原因:忘记提供法线,或者法线长度没有归一化。OBJ里的法线是单位向量居多,但部分文件法线长度不等于1,固定管线计算光照时会把颜色压到接近0。
  • 解决:在把法线拷贝到顶点数组时统一调用normalize(),同时确认光源位置在相机前方,另外检查是否在glBegin之前启用了GL_NORMALIZE。

这些坑如果自己从零踩,每个都得花半天时间。我拆完这套代码后,把所有解析函数的入参都做了assert,并在Release版本里保留日志输出,这样客户环境出问题时能快速定位是哪个OBJ文件异常。

6. 二次开发改造思路:从查看器到可编辑的轻量建模工具

6.1 把解析器抽成独立类并添加格式扩展

源码里的OBJ解析还和MFC界面耦合在一起,比如材质管理器直接用了CImage。如果要复用,我建议把LoadOBJ、ParseMTL、纹理生成封装成独立库,对外只暴露ModelData结构体和bool LoadFromFile(path)接口。以后加PLY或STL格式,只需新增一个IFormatReader实现类,返回同一个ModelData。我改造时习惯用std::variant或抽象基类统一网格数据,可以避免上层渲染代码写多套分支。

class IModelReader { public: virtual ~IModelReader() = default; virtual bool Read(const std::string& path, ModelData& data) = 0; }; class ObjReader : public IModelReader { public: bool Read(const std::string& path, ModelData& data) override { // 复用解析逻辑,输出ModelData } };

这样改造后,新增格式的代价被限制在单独类里,渲染模块完全无感知。常见做法是先为OBJ做一套完整测试用例,确保回归,再把PLY实现挂进去。

6.2 增加网格编辑与模型导出能力

如果不想只做查看器,可以在现有渲染交互基础上加“选中面”功能。方法是为每个三角形生成一个全局索引,在鼠标点击时反算射线与三角形交点,命中后记录该三角形索引并高亮显示。做这个改造时需要注意:当前代码是直接调用glDrawElements,如果要精确选中,需要维护m_indices到三角形ID的映射。导出功能则把ModelData中的顶点数组写回OBJ或STL即可。

6.3 性能验证与渲染优化

改完代码后,我的验证习惯是加载一个包含500万顶点的测试模型,用性能分析工具观察DrawModel的耗时。如果出现卡顿,优先检查是否误用了glBegin/glEnd循环,这套代码已经用了存储索引数组,所以可以平滑迁移到glDrawElements+ VBO。具体做法是把ModelData中的顶点和索引上传到GPU缓冲,渲染时只绑定VBO并调用一次绘制,这样能获得近千倍的性能提升。

从那以后,我每次改造完渲染模块,都强制自己走一遍“加载无材质模型→加载带贴图模型→旋转缩放→导出再导入”的完整流程,确保没有破坏原工程的基础功能。这次拆解让我对MFC与OpenGL的协作有了更清晰的把握,也希望帮到在做类似模型查看工具的你。

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

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

UG二次开发实战:NXOpen C++环境配置与自动化建模指南

简介&#xff1a;面向UG二次开发学习者与工程师的完整资料包&#xff0c;定位为从UG_OPEN API入门、GRIP脚本到工程落地的系统化合集&#xff0c;既有概念讲解&#xff0c;也有可运行的示例工程&#xff0c;适用于UG用户、C程序员及机械、航空航天领域需要定制CAD/CAM/CAE功能的…

作者头像 李华
网站建设 2026/10/21 2:40:20

PHP-FPM调优实战:QPS从800飙至2400

一、写在前面&#xff1a;网站为什么突然变慢了前阵子接手一个老项目&#xff0c;用的是典型的 LNMP 架构。业务量其实不算大&#xff0c;但一到下午高峰期&#xff0c;用户就开始反馈页面转圈、接口超时。一开始怀疑是数据库慢查询&#xff0c;排查了一圈发现 SQL 都挺正常&am…

作者头像 李华