简介面向学习计算机图形学与C Windows应用开发的学生及开发者围绕在MFC框架中集成OpenGL、读取OBJ三维模型并完成纹理贴图这一典型课程实验场景提供可参考的完整工程与素材。压缩包共123个文件约35.13MB以h与cpp源码、sln和vcxproj工程配置、obj模型及mtl材质、bmp纹理贴图为主同时包含docx说明与exe等构建产物覆盖模型解析、网格渲染和纹理加载等模块。已有1340人学习下载。资源内含OBJ顶点、法线、纹理坐标与面索引的解析实现MeshRender渲染类OpenGL缓冲对象与纹理绑定代码以及配套模型和贴图素材便于对照理解MFC窗口、OpenGL上下文初始化、顶点属性配置和纹理映射流程也可作为课程实验报告撰写与调试排错的参考。1. 从一份 .obj 文件到屏幕上的贴图模型这套 MFC OpenGL 组合到底解决什么问题一个很常见的场景手里拿到一个 obj 模型文件想在 Windows 桌面上用 MFC 窗口框架配合 OpenGL 把它显示出来还要贴上漫反射纹理。这事听起来像三个独立的小作业——读文件、开窗口、画三角形——但真拼在一起时卡住人的往往是接缝处MFC 的设备上下文和 OpenGL 的渲染上下文谁先创建谁后释放、obj 里 v/vt/vn 三套彼此独立的索引怎么对齐成一份顶点数组、纹理图像的像素行怎么对齐到 GPU 要求的边界。这个练手工程把这几段链路串成了一条能直接跑通的路径适合刚接触图形学、要交一个能运行 Demo 的开发者也适合想把 obj MFC OpenGL 这套老组合重新捡起来做工具链的人。2. 工程骨架拆解MFC 视图窗口怎么把 OpenGL 渲染上下文挂上去MFC 和 OpenGL 的组合有个前提必须说清楚MFC 本身不认识 OpenGL它只负责给一个 HWNDOpenGL 要靠这个 HWND 拿到的 HDC 去创建自己的渲染上下文。所以整个工程骨架的核心不是怎么画三角形而是怎么让这两套生命周期对得上。这一段理顺了后面模型解析和贴图才有地方落地。2.1 目录结构与模块职责划分我一般会把这类工程切成四层避免所有东西堆进 View 类里。文件不多但边界清楚后面改起来才不至于牵一发动全身。ObjViewer/ ├── App/ 应用层 │ ├── ObjViewerApp.cpp CWinApp 派生程序入口 │ └── MainFrm.cpp 主框架窗口 ├── View/ │ ├── GLView.h/.cpp CView 派生持有 HDC / HRC负责整体调度 │ └── GLViewInput.cpp 鼠标键盘消息旋转、平移、缩放 ├── Render/ │ ├── ObjModel.h/.cpp obj 解析与顶点数据组织 │ ├── Texture.h/.cpp 纹理加载与 GL 纹理对象管理 │ └── SceneRenderer.h/.cpp 相机、光照、绘制调用 └── Res/ ├── model.obj └── brick.bmp这种切法的好处是把平台相关MFC 消息、窗口、HDC和图形相关顶点、纹理、矩阵彻底隔开。Render 目录下的代码理论上可以原样搬到 Win32 甚至控制台程序里只要外面给它一个有效的 HDC。血泪经验是一旦让ObjModel里出现CDC*或者AfxMessageBox这个模块就再也复用不了了。2.2 像素格式与渲染上下文的初始化顺序这是最容易翻车的一步。Windows 上创建 OpenGL 上下文必须先描述像素格式顺序错了会拿到一个看起来创建成功但画不出东西的 HRC非常玄学。// GLView.cpp —— 像素格式描述必须是窗口创建之后、HRC 创建之前 BOOL CGLView::PreCreateWindow(CREATESTRUCT cs) { // 兄弟窗口裁剪避免 OpenGL 画面被其他控件覆盖后留下残影 cs.style | WS_CLIPSIBLINGS | WS_CLIPCHILDREN; return CView::PreCreateWindow(cs); } BOOL CGLView::SetupPixelFormat(HDC hdc) { PIXELFORMATDESCRIPTOR pfd { sizeof(PIXELFORMATDESCRIPTOR), 1, PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER, PFD_TYPE_RGBA, 32, // 颜色缓冲位深24 位色 8 位 alpha 足够 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, // 深度缓冲 24 位模型贴图一定要开 8, // 模板缓冲暂时用不上但留着 0, PFD_MAIN_PLANE, 0, 0, 0, 0 }; int fmt ChoosePixelFormat(hdc, pfd); if (fmt 0) return FALSE; return SetPixelFormat(hdc, fmt, pfd); }PFD_DOUBLEBUFFER一定要有单缓冲在 60Hz 下画旋转模型会明显撕裂。cDepthBits填 0 是最常见的错误深度测试直接失效模型背面的三角形会穿透到前面来看起来像模型破了个洞。拿到 HDC 之后创建 HRC并且立刻wglMakeCurrent一次把上下文和当前线程绑定int CGLView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) -1) return -1; m_pDC new CClientDC(this); if (!SetupPixelFormat(m_pDC-GetSafeHdc())) return -1; m_hRC wglCreateContext(m_pDC-GetSafeHdc()); if (!m_hRC) return -1; if (!wglMakeCurrent(m_pDC-GetSafeHdc(), m_hRC)) return -1; // 上下文就绪后才能做 GL 相关初始化 glEnable(GL_DEPTH_TEST); glClearColor(0.13f, 0.15f, 0.18f, 1.0f); m_model.Load(Res\\model.obj); m_model.Normalize(2.0f); // 把模型缩放到统一大小防止出屏 m_texId Texture::LoadBmp24(Res\\brick.bmp); SetTimer(1, 16, nullptr); // 约 60fps 的定时刷新 return 0; }注意m_model.Normalize和纹理加载必须放在wglMakeCurrent之后。GL 的纹理对象是挂在上下文上的上下文没绑定就调glGenTextures函数不会报错但生成的 ID 是无效的等你真正绑定时画面就是纯色一片。这个坑我踩过不止一次后来养成了习惯所有 GL 调用前先确认wglGetCurrentContext()非空。2.3 消息映射里的三件套OnSize、OnDraw、OnDestroyMFC 的CView有三条消息决定了 OpenGL 画面的正确性缺一不可。void CGLView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); if (!m_hRC) return; // cy 为 0 时窗口最小化除法会得到 inf投影矩阵直接崩掉 if (cy 0) cy 1; if (cx 0) cx 1; wglMakeCurrent(m_pDC-GetSafeHdc(), m_hRC); glViewport(0, 0, cx, cy); glMatrixMode(GL_PROJECTION); glLoadIdentity(); // 视野角 45 度近裁剪面 0.1 远裁剪面 100长宽比跟随窗口 gluPerspective(45.0, (double)cx / (double)cy, 0.1, 100.0); glMatrixMode(GL_MODELVIEW); } void CGLView::OnDraw(CDC* pDC) { if (!m_hRC) return; wglMakeCurrent(m_pDC-GetSafeHdc(), m_hRC); RenderScene(); SwapBuffers(m_pDC-GetSafeHdc()); // 缺这一句就是黑屏 } void CGLView::OnDestroy() { KillTimer(1); if (m_texId) { wglMakeCurrent(m_pDC-GetSafeHdc(), m_hRC); glDeleteTextures(1, m_texId); } if (m_hRC) { wglMakeCurrent(nullptr, nullptr); // 先解绑再删 wglDeleteContext(m_hRC); } if (m_pDC) { delete m_pDC; m_pDC nullptr; } CView::OnDestroy(); }OnSize里的cy 0保护是最容易被忽略的一行。不写的话窗口最小化再还原整个视口会变成一片纯色或者模型被拉伸成一条线看起来像矩阵算错了实际就是除零产生了 inf。OnDraw里SwapBuffers忘了写现象是窗口一直是清屏色。新手第一反应是模型没加载成功其实模型早就在后缓冲里画好了只是没交换到前缓冲。判断方法很简单把SwapBuffers换成glFlush()再看一眼如果画面出来了就说明问题在这。3. OBJ 解析器拆解v/vt/vn 三套索引怎么对齐成一份顶点数组obj 是一种纯文本格式结构不复杂但它有个设计上和 GPU 格格不入的地方位置、纹理坐标、法线是三套独立索引。GPU 的顶点缓冲要求这三个属性打包在同一个顶点结构体里所以解析的核心工作是按面的引用把三套池子里的数据展开、去重、重排。3.1 顶点结构体与四类数据行的读取顶点结构体决定了内存布局和后面的glVertexPointer调用方式字段顺序要跟顶点属性数组的偏移一一对应。// ObjModel.h struct ObjVertex { float px, py, pz; // 位置对应 v 行 float u, v; // 纹理坐标对应 vt 行 float nx, ny, nz; // 法线对应 vn 行 }; class ObjModel { public: bool Load(const std::string path); void Normalize(float targetSize); const std::vectorObjVertex Vertices() const { return m_verts; } const std::vectorunsigned int Indices() const { return m_indices; } private: unsigned int ResolveVertex(const std::string token); std::vectorObjVertex m_verts; // 展开后的最终顶点 std::vectorunsigned int m_indices; // 三角形索引 std::vectorfloat m_posPool; // v 行原始数据每 3 个一组 std::vectorfloat m_uvPool; // vt 行每 2 个一组 std::vectorfloat m_nrmPool; // vn 行每 3 个一组 std::mapstd::string, unsigned int m_cache; // v/vt/vn - 顶点下标 };先按行把三类池子填满再在遇到f行的时候做展开。这种两遍结构的好处是读取逻辑非常线性不用在解析过程中反复回查文件。m_cache的键直接用原始的a/b/c字符串比拼一个结构体做 key 更快也不用写哈希函数。bool ObjModel::Load(const std::string path) { std::ifstream in(path); if (!in) return false; std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] #) continue; std::istringstream iss(line); std::string tag; iss tag; if (tag v) { float x, y, z; iss x y z; m_posPool.push_back(x); m_posPool.push_back(y); m_posPool.push_back(z); } else if (tag vt) { float u, v; iss u v; m_uvPool.push_back(u); m_uvPool.push_back(v); } else if (tag vn) { float x, y, z; iss x y z; m_nrmPool.push_back(x); m_nrmPool.push_back(y); m_nrmPool.push_back(z); } else if (tag f) { std::vectorunsigned int face; std::string tok; while (iss tok) face.push_back(ResolveVertex(tok)); // 扇形三角化n 边形拆成 n-2 个三角形 for (size_t i 1; i 1 face.size(); i) { m_indices.push_back(face[0]); m_indices.push_back(face[i]); m_indices.push_back(face[i 1]); } } } return !m_verts.empty(); }vt行有时会有第三个参数w 分量直接忽略即可绝大多数建模软件导出的都是二维坐标。f行拆多边形用扇形三角化就够了obj 里真正出现凹多边形的概率很低如果遇到错也是错在少数几个面上不影响整体调试。3.2 面片解析a/b/c 三元组的索引规则与边界ResolveVertex是整个解析器里最容易出 bug 的函数因为它要同时处理四种写法和负数索引。unsigned int ObjModel::ResolveVertex(const std::string token) { auto it m_cache.find(token); if (it ! m_cache.end()) return it-second; // token 有四种形态1、1/2、1//3、1/2/3 int idx[3] { 0, 0, 0 }; int part 0; size_t start 0; for (size_t i 0; i token.size() part 3; i) { if (i token.size() || token[i] /) { if (i start) // 空段如 1//3 的中间保持为 0 idx[part] atoi(token.substr(start, i - start).c_str()); part; start i 1; } } // obj 索引从 1 开始负数表示相对当前末尾的偏移 auto pick [](int i, size_t count) - int { return i 0 ? i - 1 : (int)count i; }; ObjVertex vtx {}; int pi pick(idx[0], m_posPool.size() / 3); vtx.px m_posPool[pi * 3 0]; vtx.py m_posPool[pi * 3 1]; vtx.pz m_posPool[pi * 3 2]; if (idx[1] ! 0) { int ti pick(idx[1], m_uvPool.size() / 2); vtx.u m_uvPool[ti * 2 0]; vtx.v m_uvPool[ti * 2 1]; } if (idx[2] ! 0) { int ni pick(idx[2], m_nrmPool.size() / 3); vtx.nx m_nrmPool[ni * 3 0]; vtx.ny m_nrmPool[ni * 3 1]; vtx.nz m_nrmPool[ni * 3 2]; } unsigned int out (unsigned int)m_verts.size(); m_verts.push_back(vtx); m_cache[token] out; return out; }这里没有用sscanf(%d/%d/%d)是因为遇到1//3这种中间为空的形式时sscanf在第二个%d处匹配失败就提前返回了第三个值拿不到。手动按/切分虽然丑一点但四种形态都能正确处理。pick函数里的负数分支同样重要。有些导出器会用-1表示当前池子的最后一个如果你只按i-1算会拿到一个负数下标在某些编译器上不崩读出来是垃圾内存模型上表现为若干个顶点飞到了奇怪的位置排查起来非常痛苦。还有一个隐含假设f行里三者必须成对出现位置索引不能缺。如果某个文件的f行只有v没有vt贴图会整片采样到 (0,0) 这个角落表现为模型整体被刷成一个纯色。这时候要么手工补 UV要么换一份带纹理坐标的模型。3.3 包围盒归一化让模型不出屏不同来源的 obj 尺度差异极大有的是 0.1 单位有的是 5000 单位直接塞进透视投影里要么小成一个点要么糊满整个屏幕。void ObjModel::Normalize(float targetSize) { if (m_verts.empty()) return; float mn[3] { FLT_MAX, FLT_MAX, FLT_MAX }; float mx[3] { -FLT_MAX, -FLT_MAX, -FLT_MAX }; for (const auto v : m_verts) { mn[0] min(mn[0], v.px); mx[0] max(mx[0], v.px); mn[1] min(mn[1], v.py); mx[1] max(mx[1], v.py); mn[2] min(mn[2], v.pz); mx[2] max(mx[2], v.pz); } float cx (mn[0] mx[0]) * 0.5f; float cy (mn[1] mx[1]) * 0.5f; float cz (mn[2] mx[2]) * 0.5f; float ex mx[0] - mn[0]; float ey mx[1] - mn[1]; float ez mx[2] - mn[2]; float extent max(ex, max(ey, ez)); // 退化模型所有点重合直接跳过缩放避免除零 float scale (extent 1e-6f) ? targetSize / extent : 1.0f; for (auto v : m_verts) { v.px (v.px - cx) * scale; v.py (v.py - cy) * scale; v.pz (v.pz - cz) * scale; } }先平移把模型中心挪到原点再统一按最长边缩放。如果按三条边分别缩放模型会被压扁变形那个错误比模型不在视野内更隐蔽因为画面里确实有东西只是比例不对。targetSize我一般取 2.0配合gluLookAt(0, 0, 3.2, ...)和 45 度视野角模型刚好占满视口的三分之二留出旋转时的边距。法线不需要跟着缩放因为这里是等比缩放方向不变如果是非等比缩放法线得用逆转置矩阵重新算这是个经典坑后面避坑章节还会提到。4. 纹理加载与贴图链路从磁盘图片到 GPU 采样模型能显示之后下一步是把它贴上图。这条链路包含三段CPU 侧解码图片成像素数组、用 GL 上传成纹理对象、在绘制时把 UV 和纹理绑定起来。三段里任何一段出错画面表现都是模型变成纯色不太好区分所以每段都要能单独验证。4.1 BMP 解码行跨度对齐与 BGR 顺序这里选 BMP 而不是 PNG/JPG纯粹是为了避免引入第三方解码库。BMP 的 24 位无压缩格式解析起来只要几十行但里面有两个坑必须处理。bool Texture::LoadBmp24(const char* path, int outW, int outH, std::vectorunsigned char rgba) { std::ifstream in(path, std::ios::binary); if (!in) return false; unsigned char hdr[54] { 0 }; in.read((char*)hdr, 54); if (hdr[0] ! B || hdr[1] ! M) return false; int dataOffset *(int*)(hdr 10); int width *(int*)(hdr 18); int height *(int*)(hdr 22); short bpp *(short*)(hdr 28); if (bpp ! 24 || width 0 || height 0) return false; // BMP 每一行按 4 字节对齐24 位时 3*width 要向上补到 4 的倍数 int rowStride ((width * 3) 3) ~3; std::vectorunsigned char row(rowStride); rgba.resize((size_t)width * height * 4); in.seekg(dataOffset, std::ios::beg); for (int y 0; y height; y) { in.read((char)row.data(), rowStride); // 存储顺序是 BGR且行序自下而上OpenGL 的 (0,0) 在左下角 int dstY height - 1 - y; for (int x 0; x width; x) { unsigned char b row[x * 3 0]; unsigned char g row[x * 3 1]; unsigned char r row[x * 3 2]; unsigned char* p rgba[((size_t)dstY * width x) * 4]; p[0] r; p[1] g; p[2] b; p[3] 255; } } outW width; outH height; return true; }第一个坑是rowStride。当宽度不是 4 的倍数时BMP 每行末尾有填充字节不跳过的话读出来的像素会随行偏移越错越多画面表现为斜向的彩色条纹像被剪切过一样。第二个坑是行序。BMP 从下往上存而 OpenGL 的纹理坐标原点在左下角两者一致但和直觉相反——很多人以为要翻转结果翻了一次反而上下颠倒。统一在这里翻成 GL 期望的顺序绘制时就不用再折腾 UV 的 v 分量了。输出格式我固定转成 RGBA 四通道是为了让后面的GL_UNPACK_ALIGNMENT恒为 4 的整数倍省掉对每行对齐的额外处理。多占的内存在这个量级下可以忽略。4.2 glTexImage2D 参数与环绕、过滤模式纹理上传的代码不长但每个参数都对应一个可见的画面问题。GLuint Texture::CreateFromRgba(int w, int h, const unsigned char* rgba) { GLuint id 0; glGenTextures(1, id); glBindTexture(GL_TEXTURE_2D, id); // 我们已经转成 RGBA天然 4 字节对齐这里显式声明避免依赖默认值 glPixelStorei(GL_UNPACK_ALIGNMENT, 4); // UV 超出 [0,1] 时重复平铺配合模型自带的多重 UV 使用 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); // 缩小用 mipmap 三线性放大用线性避免近处出现马赛克块 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // gluBuild2DMipmaps 会自动生成各层级的 mipmap gluBuild2DMipmaps(GL_TEXTURE_2D, GL_RGBA, w, h, GL_RGBA, GL_UNSIGNED_BYTE, rgba); return id; }GL_TEXTURE_MIN_FILTER默认值是GL_NEAREST_MIPMAP_LINEAR而 mipmap 默认没生成结果就是纹理完全不显示、模型变成纯白或纯黑。这个行为在固定管线下不报任何错误是典型的黑匣子问题。要么显式设成GL_LINEAR要么像上面这样生成 mipmap。GL_REPEAT适合砖墙、地板这类需要平铺的材质。如果贴的是角色皮肤这类不能重复的图应该换成GL_CLAMP_TO_EDGE否则模型边缘的 UV 稍微超出一点就会采到对侧像素形成一条颜色突兀的接缝。4.3 绘制阶段的 UV、法线与光照配合固定管线下光照和纹理是两套独立开关但它们会互相影响。void SceneRenderer::Render(const ObjModel model, GLuint texId) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(0.0, 0.0, 3.2, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0); glRotatef(m_rotX, 1.0f, 0.0f, 0.0f); glRotatef(m_rotY, 0.0f, 1.0f, 0.0f); // 一盏方向光从右前上方打过来 GLfloat lightPos[] { 1.0f, 1.0f, 1.0f, 0.0f }; GLfloat lightDiff[] { 0.9f, 0.9f, 0.9f, 1.0f }; GLfloat lightAmb[] { 0.25f, 0.25f, 0.25f, 1.0f }; glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glLightfv(GL_LIGHT0, GL_DIFFUSE, lightDiff); glLightfv(GL_LIGHT0, GL_AMBIENT, lightAmb); glEnable(GL_LIGHT0); glEnable(GL_LIGHTING); glEnable(GL_NORMALIZE); // 模型做过缩放法线要重新归一化 glEnable(GL_TEXTURE_2D); glBindTexture(GL_TEXTURE_2D, texId); // MODULATE纹理颜色与光照结果相乘既有明暗又有贴图 glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE); const auto verts model.Vertices(); const auto idx model.Indices(); const unsigned char* base (const unsigned char*)verts.data(); GLsizei stride sizeof(ObjVertex); glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glEnableClientState(GL_TEXTURE_COORD_ARRAY); glVertexPointer (3, GL_FLOAT, stride, base offsetof(ObjVertex, px)); glNormalPointer ( GL_FLOAT, stride, base offsetof(ObjVertex, nx)); glTexCoordPointer(2, GL_FLOAT, stride, base offsetof(ObjVertex, u)); glDrawElements(GL_TRIANGLES, (GLsizei)idx.size(), GL_UNSIGNED_INT, idx.data()); glDisableClientState(GL_TEXTURE_COORD_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); glDisableClientState(GL_VERTEX_ARRAY); }offsetof加stride的写法把顶点结构体和 GL 的指针调用绑在一起改结构体时只需要改这两处不用同步改三组硬编码偏移。GL_MODULATE是关键一行。默认的GL_REPLACE会直接用纹理颜色覆盖光照结果模型看起来是平的、没有立体感很多人以为是光照没生效其实是纹理环境模式把它盖掉了。glEnable(GL_NORMALIZE)也不能省。Normalize里对顶点做了缩放如果法线不重新归一化光照强度的计算会失真模型上会出现亮度不均匀的斑块看起来像贴图脏了。用GL_RESCALE_NORMAL性能更好但它只适用于均匀缩放这里其实也可以只是名字容易让人以为只处理了均匀缩放的一部分情况我倾向于直接用GL_NORMALIZE。5. 避坑与排查黑屏、花屏、纹理丢失的五条实战记录5.1 窗口打开就是一片纯色模型完全不见现象程序启动OnCreate全部返回成功Load也返回 true但窗口里只有清屏色。原因绝大多数情况是SwapBuffers没调用或者OnDraw被别的东西覆盖了。MFC 的CView在窗口被遮挡后重绘时OnDraw不一定每次都会走。解决先确认OnDraw里SwapBuffers(m_pDC-GetSafeHdc())存在再确认OnCreate里SetTimer触发的Invalidate(FALSE)真的在跑。Invalidate(FALSE)里的FALSE表示不擦背景如果传了TRUEGDI 会先用背景刷填一遍画面会闪得很厉害看起来像渲染失败。5.2 模型贴图整片是纯色一点纹理细节都没有现象模型形状正确光照也有明暗但表面只有一种颜色。原因三个可能——vt索引解析成了 0、纹理没绑上、GL_TEXTURE_MIN_FILTER设了 mipmap 但没生成 mipmap。解决按顺序排查。先把片段颜色强行改成 UV 本身的颜色固定管线下用glDisable(GL_TEXTURE_2D)加glColor3f(v.u, v.v, 0)逐顶点绘制如果能看到渐变说明 UV 是好的再检查glIsTexture(m_texId)是否为真最后看过滤模式。5.3 模型表面出现斜向彩色条纹现象贴图整体位置对但上面有规律的斜条带越靠下越明显。原因BMP 解码时没有按 4 字节对齐跳过行尾填充导致每一行的像素起点逐渐偏移。解决检查rowStride的计算必须是((width * 3) 3) ~3而不是width * 3。宽度是 4 的倍数时这个 bug 不会出现所以换一张图就复现很容易被误判成图片本身有问题。5.4 模型转到背面时内部结构穿到前面来了现象旋转模型本来应该被遮挡的面片显示在最前面看起来像模型透明了。原因像素格式里深度缓冲位数填了 0或者忘了glEnable(GL_DEPTH_TEST)或者每帧没清GL_DEPTH_BUFFER_BIT。解决三处都要查。PIXELFORMATDESCRIPTOR里cDepthBits填 24Render开头glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)并且glEnable(GL_DEPTH_TEST)要放在绘制之前。少任何一处深度测试都不生效。5.5 窗口最小化再还原后画面变形或全白现象拖拽改变窗口大小、或者最小化再还原之后模型被拉成长条或者整个视口变白。原因OnSize里cx或cy为 0 时直接做了除法投影矩阵出现 inf或者OnSize里没有重新wglMakeCurrentglViewport调用落到了无效上下文上。解决OnSize开头加if (cx 0) cx 1; if (cy 0) cy 1;并且在任何 GL 调用前先wglMakeCurrent。MFC 的消息可能在其他线程或上下文未绑定的时机触发这个习惯能省掉一堆偶发问题。6. 二次开发把写死的贴图改成可配置的材质表跑通之后再往里加东西最划算的改造是把硬编码的纹理路径抽成一份材质配置。原始代码里m_texId只有一个换个模型就得改代码重编译二次开发的第一步通常是让模型能同时挂多张图。改造思路是引入一个极简的材质表用文本描述哪个纹理文件、什么颜色、什么光泽加载时批量生成纹理对象绘制时按子网格切换。obj 本身不支持材质分组但usemtl行可以保留下来当作分段标记。struct Material { std::string name; std::string diffuseMap; // 相对 Res/ 的路径 GLuint texId 0; float specular 0.3f; }; // 加载材质配置格式name diffuseMap specular bool LoadMaterialTable(const char* cfgPath, std::vectorMaterial out) { std::ifstream in(cfgPath); if (!in) return false; std::string name, map; float spec; while (in name map spec) { Material m; m.name name; m.diffuseMap map; m.specular spec; out.push_back(m); } return true; }配套要在解析器里记录每个usemtl之后产生的索引区间绘制时按区间glDrawElements并绑定对应纹理。验证方法很直接给同一个模型配两张差异明显的图比如红砖和蓝砖如果两个区间显示的是不同纹理说明分组正确如果全是同一张说明usemtl的区间记录没生效。再往下走可以把固定管线换成着色器。顶点属性布局不用改ObjVertex的结构刚好能对上layout(location0) vec3 aPos、location1 vec2 aUV、location2 vec3 aNormal只要在初始化时把glVertexAttribPointer按同样的 stride 和 offsetof 设一遍其余代码几乎原样保留。这个迁移路径对熟悉固定管线的人很友好不用一上来就啃 VAO/VBO 的全套概念。从那以后我每次新建这类 MFC OpenGL 工程都会先把wglMakeCurrent、SwapBuffers、GL_DEPTH_BUFFER_BIT这三处写成模板跑通一个纯色三角形再去接模型。看起来多花十分钟实际上省掉了后面判断到底是没画出来还是画错了的大量时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?