首页 / 资讯中心 / 文章详情

OBJ模型加载与OpenGL贴图渲染全链路解析

OBJ模型加载与OpenGL贴图渲染全链路解析 ★ FEATURED ARTICLE
简介本资源是一份面向计算机图形学初学者与C实践者的MFCOpenGL综合实验项目聚焦于3D模型加载与纹理渲染这一核心图形编程任务。项目完整实现了OBJ格式模型的解析、顶点/法线/纹理坐标提取、VAO/VBO/IBO构建、外部MTL材质与BMP纹理加载及OpenGL管线渲染全流程特别适合课程实验、课程设计或图形学入门实战。压缩包共123个文件包含17个头文件h与14个源文件cpp构成主体逻辑16个BMP纹理贴图、1个MTL材质定义及1个DOCX实验说明文档辅以VCXPROJ工程配置与EXE可执行文件总大小35.13MB结构清晰、即开即用。目前已有1337人学习下载读者可直接复现带贴图的OBJ模型渲染效果深入理解MFC窗口集成OpenGL上下文、OBJ语法解析规则、纹理坐标映射机制及常见渲染调试要点是掌握3D图形基础开发能力的典型实操范例。1. 读取 OBJ 文件并贴图不是“加载模型”四个字就能跑通的 OpenGL 渲染闭环你写完glBegin(GL_TRIANGLES)把顶点坐标从.obj文件里逐行fscanf出来编译通过、窗口弹出、三角面片也动了——但贴图是黑的、UV 是拧的、法线一开就崩甚至模型直接缩成一个点。这不是代码没写完是 OBJ 加载贴图渲染这个看似基础的环节藏着至少五层隐性依赖文件解析顺序、索引映射一致性、纹理坐标翻转逻辑、OpenGL 坐标系与 OBJ 约定的冲突、以及 MFC 消息循环下资源生命周期管理。这不是教科书里的“调用库函数”而是一条必须亲手踩平每颗碎石的路径。本资源包提供一套经某跨平台系统实测验证的完整实现含 C 源码非第三方库封装、MFC 对话框集成模板、OBJ 解析器核心类支持v/vt/vn/f全字段、OpenGL 纹理绑定与采样配置、以及关键的 UV 坐标归一化修复逻辑。适合正在用 MFC OpenGL 做三维可视化、CAD 辅助模块或教学 Demo 的一线开发者尤其当你已经卡在“模型能画、贴图不显”超过两小时。2. OBJ 文件结构解析为什么不能用fscanf(%f %f %f, x, y, z)无脑读OBJ 是纯文本格式但它的语义远比“顶点坐标列表”复杂。一个典型.obj文件包含多类行前缀v,vt,vn,f且f行中的索引是分离引用而非连续数组下标。例如f 1/2/3 4/5/6 7/8/9表示第1个顶点位置 第2个纹理坐标 第3个法线向量组成第一个角第4个位置 第5个纹理 第6个法线组成第二个角……这种三元组索引若直接映射到 OpenGL 的glVertexPointer/glTexCoordPointer必然导致顶点数据错位。更隐蔽的是OBJ 默认 Y 向上而 OpenGL 纹理坐标原点在左下但多数图像加载库如 stb_image默认将第一行像素读为纹理顶部——这造成 UV 垂直翻转。不处理这个贴图就是倒的。2.1 核心解析逻辑分离存储 索引重组OBJ 解析器必须将v/vt/vn分别存入独立容器再对每个f行做三重索引解析最终生成展平的顶点数组每个三角形角 1 个位置 1 个 UV 1 个法线。这是绕不开的步骤也是绝大多数“能画模型但贴图错乱”的根源。// ObjLoader.h 关键结构定义 struct Vertex { float pos[3]; // x, y, z float tex[2]; // u, v (注意OBJ 的 vt v 值需翻转) float norm[3]; // nx, ny, nz }; class ObjLoader { private: std::vectorglm::vec3 m_vertices; // v std::vectorglm::vec2 m_texCoords; // vt std::vectorglm::vec3 m_normals; // vn std::vectorVertex m_finalVertices; // 展平后供 OpenGL 使用 public: bool Load(const char* filename); const std::vectorVertex GetVertices() const { return m_finalVertices; } };提示这里用glm::vec3/glm::vec2是为了后续与 OpenGL 顶点属性指针对齐glVertexAttribPointer要求连续内存布局避免手动计算偏移。若不用 GLM需确保Vertex结构体sizeof(Vertex) 32字节3×float 2×float 3×float 32并用#pragma pack(1)强制紧凑排列。2.2f行解析索引合法性校验与负数索引处理OBJ 支持负数索引如f -1/-2/-3表示从末尾向前计数。很多简易解析器忽略这点直接atoi()后当正数用结果越界访问。正确做法是// ObjLoader.cpp 片段解析单个 f 行 void ObjLoader::ParseFaceLine(const std::string line) { std::istringstream iss(line.substr(2)); // 跳过 f std::string token; std::vectorstd::arrayint, 3 faceIndices; while (iss token) { std::arrayint, 3 idx {-1, -1, -1}; // pos/tex/norm size_t slashPos token.find(/); if (slashPos std::string::npos) { // 格式: f v1 v2 v3 idx[0] std::stoi(token); } else { // 格式: f v1/vt1/vn1 或 f v1/vt1 std::vectorstd::string parts; std::stringstream ss(token); std::string part; while (std::getline(ss, part, /)) { if (!part.empty()) parts.push_back(part); } idx[0] std::stoi(parts[0]); // 顶点索引 if (parts.size() 1 !parts[1].empty()) idx[1] std::stoi(parts[1]); // UV 索引 if (parts.size() 2 !parts[2].empty()) idx[2] std::stoi(parts[2]); // 法线索引 } // 处理负数索引-1 表示最后一个元素 if (idx[0] 0) idx[0] static_castint(m_vertices.size()) idx[0] 1; if (idx[1] 0) idx[1] static_castint(m_texCoords.size()) idx[1] 1; if (idx[2] 0) idx[2] static_castint(m_normals.size()) idx[2] 1; // 索引范围检查关键防止崩溃 if (idx[0] 0 || idx[0] (int)m_vertices.size() || (idx[1] 0 (idx[1] (int)m_texCoords.size() || idx[1] 0)) || (idx[2] 0 (idx[2] (int)m_normals.size() || idx[2] 0))) { // 记录警告跳过该角点但不中断整个加载 OutputDebugStringA((Warning: Invalid face index in line \n).c_str()); continue; } faceIndices.push_back(idx); } // 将 faceIndices 转为三角形扇假设为凸多边形转为 v0,v1,v2; v0,v2,v3; ... for (size_t i 1; i faceIndices.size() - 1; i) { AddVertex(faceIndices[0], faceIndices[i], faceIndices[i 1]); } }AddVertex函数负责根据三个角点索引从m_vertices/m_texCoords/m_normals中取出对应值填入m_finalVertices。注意此处faceIndices[0]是第一个角点faceIndices[i]是第 i1 个角点三者构成一个三角形。这是 OBJ 多边形面3 顶点的标准三角化方式。2.3 UV 坐标翻转OBJ 的vt u v与 OpenGL 纹理坐标的生死线OBJ 文件中vt u v的v值0 在底部1 在顶部符合数学坐标系习惯但 OpenGL 的纹理坐标(0,0)在左下角(1,1)在右上角与图像数据内存布局一致。问题在于当你用stb_image加载 PNG 时它把文件第一行像素存入内存起始地址即pixels[0]是图像顶部一行。因此若直接将 OBJ 的vt v值传给 OpenGL纹理就会被垂直镜像。解决方案是在解析vt行时立即对v值做1.0f - v变换// 解析 vt 行时 if (line.substr(0, 2) vt) { float u, v; sscanf_s(line.c_str(), vt %f %f, u, v); // 关键修复翻转 v 坐标适配 OpenGL 纹理坐标系 m_texCoords.push_back(glm::vec2(u, 1.0f - v)); }这个操作必须在解析阶段完成而不是在 Shader 里用v_uv.y 1.0 - v_uv.y—— 因为法线插值、光照计算都依赖正确的 UV 插值方向延迟翻转会破坏所有基于 UV 的计算如法线贴图采样。3. MFC 与 OpenGL 上下文集成为什么CView里wglCreateContext总失败MFC 不是原生 OpenGL 宿主它用CWnd封装 Windows 窗口但 OpenGL 渲染上下文RC必须绑定到有效的设备描述表DC上。常见错误是在OnInitialUpdate()里直接GetDC()创建 RC却未设置像素格式PixelFormat或在OnDestroy()里忘记释放 RC 和 DC。更隐蔽的是MFC 的CView默认使用CS_HREDRAW | CS_VREDRAW风格导致窗口大小变化时频繁重绘若 OpenGL 初始化未完成就触发OnDraw()必 crash。3.1 正确的 OpenGL 初始化流程MFC 对话框版本资源采用CDialog派生类承载 OpenGL 视图比CView更易控制生命周期关键步骤如下在对话框资源中添加Static控件ID 设为IDC_GL_STATICStyle 设为SS_OWNERDRAW允许自绘并取消WS_GROUP。在OnInitDialog()中获取该 Static 的 HWND并创建兼容 DCBOOL CObjViewDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 获取 Static 控件句柄 m_hWndGL GetDlgItem(IDC_GL_STATIC)-GetSafeHwnd(); if (!m_hWndGL) return FALSE; // 获取设备上下文 m_hDC ::GetDC(m_hWndGL); if (!m_hDC) return FALSE; // 设置像素格式必须否则 wglCreateContext 失败 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, 8, 0, PFD_MAIN_PLANE, 0,0,0,0 }; int pixelFormat ChoosePixelFormat(m_hDC, pfd); if (!pixelFormat) { MessageBox(_T(ChoosePixelFormat failed!)); return FALSE; } if (!SetPixelFormat(m_hDC, pixelFormat, pfd)) { MessageBox(_T(SetPixelFormat failed!)); return FALSE; } // 创建并激活渲染上下文 m_hRC wglCreateContext(m_hDC); if (!m_hRC) { MessageBox(_T(wglCreateContext failed!)); return FALSE; } if (!wglMakeCurrent(m_hDC, m_hRC)) { MessageBox(_T(wglMakeCurrent failed!)); return FALSE; } // 初始化 OpenGL 状态启用深度测试、背面剔除等 glEnable(GL_DEPTH_TEST); glEnable(GL_CULL_FACE); glCullFace(GL_BACK); // 加载模型此时 OpenGL 上下文已就绪 m_objLoader.Load(model.obj); return TRUE; }注意PIXELFORMATDESCRIPTOR中cDepthBits24是关键它告诉系统需要 24 位深度缓冲区。若设为 0ChoosePixelFormat可能返回不支持深度测试的格式后续glClear(GL_DEPTH_BUFFER_BIT)无效。3.2 渲染循环OnPaint与InvalidateRect的陷阱MFC 的OnPaint()不是每帧调用而是响应WM_PAINT消息通常只在窗口暴露、移动后触发。要实现持续动画必须手动驱动重绘// 在 OnInitDialog() 末尾启动定时器 SetTimer(1, 33, nullptr); // ~30 FPS void CObjViewDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { // 触发重绘不直接调用 OnDraw由系统调度 InvalidateRect(NULL, TRUE); UpdateWindow(); // 确保立即刷新避免延迟 } CDialogEx::OnTimer(nIDEvent); } void CObjViewDlg::OnPaint() { CPaintDC dc(this); // device context for painting // 必须先调用基类否则 Static 控件背景不更新 CDialogEx::OnPaint(); // 绑定 OpenGL 上下文每次 OnPaint 都需确保 if (!wglMakeCurrent(m_hDC, m_hRC)) { OutputDebugStringA(wglMakeCurrent failed in OnPaint!\n); return; } // 清屏 glClearColor(0.2f, 0.2f, 0.2f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 设置视图矩阵模拟相机 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0f, 1.0f, 0.1f, 100.0f); // fov, aspect, near, far glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(0, 0, 5, 0, 0, 0, 0, 1, 0); // eye, center, up // 绘制模型 RenderModel(); // 交换双缓冲 SwapBuffers(m_hDC); }SwapBuffers(m_hDC)是双缓冲关键它把后台缓冲区内容提交到前台。若忘记此步画面永远静止。3.3 资源清理OnDestroy中的三重释放MFC 生命周期结束时必须按严格顺序释放 OpenGL 资源否则下次启动可能因句柄泄漏失败void CObjViewDlg::OnDestroy() { CDialogEx::OnDestroy(); // 1. 解绑当前上下文 if (wglGetCurrentContext() m_hRC) { wglMakeCurrent(nullptr, nullptr); } // 2. 删除渲染上下文 if (m_hRC) { wglDeleteContext(m_hRC); m_hRC nullptr; } // 3. 释放设备上下文 if (m_hDC) { ::ReleaseDC(m_hWndGL, m_hDC); m_hDC nullptr; } }提示wglMakeCurrent(nullptr, nullptr)是安全解绑操作确保没有其他线程仍在使用该 RC。若省略wglDeleteContext可能失败且不报错。4. 贴图加载与绑定为什么glTexImage2D后还是黑屏OBJ 本身不存纹理图像只存.mtl材质文件路径和map_Kd texture.jpg这样的引用。因此“贴图”实际是两步① 解析.mtl获取纹理文件名② 加载图像数据到 OpenGL 纹理对象。常见黑屏原因有四纹理路径错误、图像加载失败、纹理参数未设置、Shader 中采样器未绑定。本资源使用stb_image.h单头文件加载器因其无需额外 DLL编译即用。4.1.mtl文件解析与纹理路径拼接OBJ 文件首行常为mtllib material.mtl需单独解析该文件。.mtl格式示例newmtl default Ka 0.2 0.2 0.2 Kd 0.8 0.8 0.8 Ks 0.0 0.0 0.0 map_Kd wood.jpg关键字段是map_Kd漫反射贴图。解析时需注意路径可能是相对路径如textures/wood.jpg需与.obj文件所在目录拼接// ObjLoader.cpp 中解析 mtl bool ObjLoader::LoadMaterial(const char* mtlPath) { std::string dir GetDirectoryFromPath(mtlPath); // 提取 obj 所在目录 std::ifstream file(mtlPath); if (!file.is_open()) return false; std::string line; while (std::getline(file, line)) { if (line.substr(0, 7) map_Kd ) { std::string texName line.substr(7); // 去除首尾空格和引号 texName.erase(0, texName.find_first_not_of( \t\r\n\)); texName.erase(texName.find_last_not_of( \t\r\n\) 1); std::string fullPath dir \\ texName; // 加载纹理 GLuint texID LoadTextureFromFile(fullPath.c_str()); if (texID ! 0) { m_textureID texID; } } } return true; }GetDirectoryFromPath是一个辅助函数提取C:\models\car.obj中的C:\models。若路径拼错stb_image返回nullptr后续glTexImage2D传入空指针OpenGL 不报错但纹理为黑。4.2stb_image加载与 OpenGL 纹理上传stb_image默认按 RGB 顺序加载但 OpenGL 的GL_RGB要求内存布局为 R-G-B-R-G-B...。若图像为 RGBA带 Alpha需用GL_RGBAGLuint ObjLoader::LoadTextureFromFile(const char* path) { int width, height, channels; unsigned char* data stbi_load(path, width, height, channels, 0); if (!data) { OutputDebugStringA((Failed to load texture: std::string(path) \n).c_str()); return 0; } GLuint textureID; glGenTextures(1, textureID); glBindTexture(GL_TEXTURE_2D, textureID); // 设置纹理过滤与包裹方式必须否则边缘模糊或重复 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 根据通道数选择内部格式 GLenum format channels 3 ? GL_RGB : GL_RGBA; glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data); glGenerateMipmap(GL_TEXTURE_2D); stbi_image_free(data); return textureID; }注意glGenerateMipmap是启用GL_LINEAR_MIPMAP_LINEAR的前提否则纹理会显示为纯色块。若图像尺寸非 2 的幂如 123x456glGenerateMipmap可能失败此时应改用GL_LINEAR作为MIN_FILTER。4.3 OpenGL 纹理单元绑定与 Shader 采样现代 OpenGL3.3需在 Shader 中声明uniform sampler2D u_Texture;并在 CPU 端指定其绑定的纹理单元// 渲染前 glActiveTexture(GL_TEXTURE0); // 激活纹理单元 0 glBindTexture(GL_TEXTURE_2D, m_textureID); // 绑定纹理到单元 0 glUniform1i(glGetUniformLocation(m_shaderProgram, u_Texture), 0); // 告诉 Shaderu_Texture 用单元 0若glUniform1i传入的值与glActiveTexture不匹配如glActiveTexture(GL_TEXTURE1)但glUniform1i(..., 0)Shader 就采样到空白纹理。这是黑屏最隐蔽的原因之一。5. 避坑五个让开发者重启三次电脑的 OBJ 贴图血泪经验这些坑不是理论问题是某跨平台系统开发中真实发生的、导致整周进度停滞的硬伤。每一条都附带现象、根因和可立即执行的修复命令。5.1 现象模型显示正常但贴图完全黑色glGetError()返回 0原因纹理图像加载成功但glTexImage2D传入的width/height为 0stb_image加载失败时返回nullptr但width/height未初始化为栈上随机值。解决在stbi_load后强制检查data是否非空并初始化width/heightint width 0, height 0, channels 0; unsigned char* data stbi_load(path, width, height, channels, 0); if (!data || width 0 || height 0) { // 双重检查 OutputDebugStringA(Invalid texture dimensions!\n); return 0; }5.2 现象贴图显示但 UV 拉伸严重某些区域像素糊成一片原因OBJ 文件中vt坐标超出[0,1]范围如vt 2.5 3.7而 OpenGL 默认GL_REPEAT包裹模式会将其 tile导致高频拉伸。解决在解析vt行时对 UV 值做截断或归一化根据需求选其一// 方案1截断到 [0,1]适合 UI 贴图 u fmaxf(0.0f, fminf(1.0f, u)); v fmaxf(0.0f, fminf(1.0f, v)); // 方案2归一化适合无缝 tile 纹理 u fmodf(u, 1.0f); v fmodf(v, 1.0f); if (u 0) u 1.0f; if (v 0) v 1.0f;5.3 现象模型旋转时贴图边缘出现明显锯齿或闪烁原因未启用各向异性过滤Anisotropic Filtering尤其在模型倾斜角度大时纹理采样质量骤降。解决在LoadTextureFromFile中添加// 启用各向异性过滤需先查询最大支持等级 GLfloat maxAniso; glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, maxAniso); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, maxAniso);注意GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT是扩展常量需在wglGetProcAddress获取或使用 GLEW/GLAD 加载。本资源已内置检测逻辑。5.4 现象MFC 对话框关闭后再次打开新对话框OpenGL 渲染崩溃原因wglDeleteContext后m_hRC句柄虽置nullptr但m_hDC仍被持有下次OnInitDialog()中GetDC返回同一句柄而旧 RC 已销毁wglMakeCurrent失败。解决在OnDestroy中不仅释放m_hRC还要确保m_hDC有效释放并在OnInitDialog开头加保护if (m_hDC) { ::ReleaseDC(m_hWndGL, m_hDC); m_hDC nullptr; } m_hWndGL GetDlgItem(IDC_GL_STATIC)-GetSafeHwnd(); m_hDC ::GetDC(m_hWndGL); // 重新获取5.5 现象OBJ 模型有多个材质多个usemtl但所有面都用同一个贴图原因解析器只记录最后一个map_Kd未按usemtl分组存储材质。OBJ 中usemtl name指定后续f行使用的材质需维护材质栈。解决修改ObjLoader增加std::mapstd::string, Material存储不同材质并在f行解析前根据当前m_currentMaterialName查找对应纹理 IDstruct Material { GLuint textureID; glm::vec3 Kd; // 漫反射颜色 }; std::mapstd::string, Material m_materials; std::string m_currentMaterialName; // 解析 usemtl 行 if (line.substr(0, 7) usemtl ) { m_currentMaterialName line.substr(7); } // 解析 f 行时用 m_currentMaterialName 查 m_materials auto it m_materials.find(m_currentMaterialName); if (it ! m_materials.end()) { currentTexID it-second.textureID; }6. 进阶技巧用 OBJ 的vn法线实现 Phong 着色让贴图真正“活”起来仅仅把贴图铺上去是 Flat Shading看着像纸片。真正的“贴图效果”需要法线参与光照计算而 OBJ 的vn字段正是为此存在。但直接用vn会导致马赫带Mach Band效应——因为vn是面法线per-face插值后过渡生硬。Phong Shading 的核心是用顶点法线per-vertex插值再在 Fragment Shader 中归一化从而获得平滑高光。这就要求 OBJ 解析器必须将vn按顶点索引“摊平”到每个Vertex中而非仅存面法线。6.1 顶点法线计算从面法线到平滑顶点法线OBJ 的vn是面法线但我们需要的是每个顶点的平均法线smooth normal。算法是对每个顶点索引v_idx遍历所有引用它的面累加这些面的法线最后归一化// ObjLoader.cpp 中 AddVertex 后追加 void ObjLoader::CalculateSmoothNormals() { // 初始化顶点法线数组全零 std::vectorglm::vec3 vertexNormals(m_vertices.size(), glm::vec3(0.0f)); // 遍历所有面m_finalVertices 每3个为一个三角形 for (size_t i 0; i m_finalVertices.size(); i 3) { const auto v0 m_finalVertices[i]; const auto v1 m_finalVertices[i 1]; const auto v2 m_finalVertices[i 2]; // 计算面法线叉积 glm::vec3 edge1 glm::vec3(v1.pos[0], v1.pos[1], v1.pos[2]) - glm::vec3(v0.pos[0], v0.pos[1], v0.pos[2]); glm::vec3 edge2 glm::vec3(v2.pos[0], v2.pos[1], v2.pos[2]) - glm::vec3(v0.pos[0], v0.pos[1], v0.pos[2]); glm::vec3 faceNormal glm::normalize(glm::cross(edge1, edge2)); // 累加到三个顶点的法线 int idx0 v0.pos[0]; // 实际应为原始顶点索引此处简化示意 int idx1 v1.pos[0]; int idx2 v2.pos[0]; if (idx0 0 idx0 (int)vertexNormals.size()) vertexNormals[idx0] faceNormal; if (idx1 0 idx1 (int)vertexNormals.size()) vertexNormals[idx1] faceNormal; if (idx2 0 idx2 (int)vertexNormals.size()) vertexNormals[idx2] faceNormal; } // 归一化并赋值给 m_finalVertices for (size_t i 0; i m_finalVertices.size(); i) { int origIdx GetOriginalVertexIndex(i); // 需实现映射函数 if (origIdx 0 origIdx (int)vertexNormals.size()) { glm::vec3 smoothNorm glm::normalize(vertexNormals[origIdx]); m_finalVertices[i].norm[0] smoothNorm.x; m_finalVertices[i].norm[1] smoothNorm.y; m_finalVertices[i].norm[2] smoothNorm.z; } } }6.2 OpenGL 顶点属性配置启用法线数组在OnPaint的RenderModel函数中必须为法线设置属性指针void CObjViewDlg::RenderModel() { const auto vertices m_objLoader.GetVertices(); if (vertices.empty()) return; glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_TEXTURE_COORD_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); // 启用法线数组 glVertexPointer(3, GL_FLOAT, sizeof(Vertex), vertices[0].pos); glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), vertices[0].tex); glNormalPointer(GL_FLOAT, sizeof(Vertex), vertices[0].norm); // 法线指针 glBindTexture(GL_TEXTURE_2D, m_objLoader.GetTextureID()); glDrawArrays(GL_TRIANGLES, 0, (GLsizei)vertices.size()); glDisableClientState(GL_VERTEX_ARRAY); glDisableClientState(GL_TEXTURE_COORD_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); }6.3 Shader 中的 Phong 计算片段级一个极简的 Phong Fragment Shader 示例phong.frag#version 120 uniform sampler2D u_Texture; varying vec2 v_UV; varying vec3 v_Normal; varying vec3 v_LightDir; varying vec3 v_ViewDir; void main() { vec3 normal normalize(v_Normal); vec3 lightDir normalize(v_LightDir); vec3 viewDir normalize(v_ViewDir); vec3 reflectDir reflect(-lightDir, normal); // 漫反射 float diff max(dot(normal, lightDir), 0.0); vec3 diffuse diff * texture2D(u_Texture, v_UV).rgb; // 高光Blinn-Phong vec3 halfwayDir normalize(lightDir viewDir); float spec pow(max(dot(normal, halfwayDir), 0.0), 32.0); vec3 specular vec3(0.5) * spec; // 白色高光 gl_FragColor vec4(diffuse specular, 1.0); }注意v_Normal、v_LightDir、v_ViewDir需在 Vertex Shader 中计算并varying传递。本资源包已提供完整 VS/FS 源码可直接编译链接。从那以后我每次解析 OBJ都强制走一遍CalculateSmoothNormals并用glNormalPointer验证法线是否生效——哪怕项目只要求 Flat Shading因为平滑法线是贴图质感的底层支点没有它再好的纹理也是塑料感。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站