继续上篇的内容,根据OBJ文件格式载入模型,并利用OpenGL的Indexed VBO技术进行渲染。本文所在的载入类ZWModelOBJ,如果阁下发现有什么BUG或者有什么好的建议,请多指教。作者地址是——http://www.ZwqXin.com
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
原文地址:http://www.zwqxin.com/archives/opengl/obj-model-format-import-and-render-2.html
2. OBJ,从读入到渲染
对一个模型来说,初始化的时候调用导入函数进行“读入”,渲染时调用渲染函数进行渲染,这是最基本的步骤了:
- //导入模型
- bool ImportModel(wchar_t *strFileName);
- //渲染模型
- void RenderModel();
其中,导入函数读入obj文件,然后开始存取数据:
- //ImportModel函数part1
- bool ZWModelOBJ::ImportModel(wchar_t *strFileName, GLuint usage)
- {
- ..............
- // 打开文件
- _wfopen_s(&m_FilePointer, szPathFileName, L"rb");
- ...............
- // 读入文件信息
- ProcessFileInfo(&m_ModelOBJ); //m_ModelOBJ即我们的模型对象t3DModel
- m_ModelOBJ.bIsTextured = true;
- // 关闭打开的文件
- fclose(m_FilePointer);
- ....................
在上篇[OBJ模型文件的结构、导入与渲染Ⅰ]末尾放出的对象数据的导入结构体如下:
- // 对象信息结构体
- typedef struct tag3DObject
- {
- int nMaterialID; // 纹理ID
- bool bHasTexture; // 是否具有纹理映射
- bool bHasNormal; // 是否具有法线
- std::vector<Vector3> PosVerts; // 对象的顶点
- std::vector<Vector3> Normals; // 对象的法向量
- std::vector<TexCoord> Texcoords; // 纹理UV坐标
- std::vector<unsigned short> Indexes; // 对象的顶点索引
- unsigned int nNumIndexes; // 索引数目
- GLuint nPosVBO;
- GLuint nNormVBO;
- GLuint nTexcoordVBO;
- GLuint nIndexVBO;
- }t3DObject;
很明显地,对于模型里面的每一个网格对象,分别用三个vector保存它的顶点属性:位置、法线、纹理坐标(注意,如之前所述,只有位置属性是必须的),用一个vector来储存顶点索引,另加一个unsigned int来储存索引总数,另用四个unsigned int来保存vertex-VBO、normal-VBO、texcoord-VBO、Index-VBO对象。这里产生了一串问题:
- 怎么划分这些网格对象(t3DObject)?——在obj文件里用组(g)来划分对象(另外,有时在顶点数据区头部也有一个g,不产生对象,应忽略),这固然是合情合理。但是,想想为什么我们要划分对象,而不是整个模型一次过地放入一个结构体里呢?3DS的话那是按chunk来的没什么问题,可是OBJ呢?忽略组(g)信息,把一个个面按顺序导入效果有什么不一样吗?没有——如果你没有材质信息的话。是的,导入模型之所以要区分对象(object),最主要的目的不为其他,而是应用材质的问题。想一想纹理坐标,它必定对应于某个纹理,但是一个模型很多时候都是带多张同用途的纹理的:某个部分使用这张纹理和这套纹理坐标(或者还有颜色信息、光照度等其他材质属性),另一部分使用别的纹理和纹理坐标——这些“部分”就是obj中的组(g)的概念,每个组只含一种材质。
所以说,真正划分的依据是“材质”。如果像前文所述多个组共用一个材质的情况,我们完全可以在程序中把这些组划分为单一的网格对象(t3DObject),这样,我们应该忽略的是g标识,而根据usemtl标识来生成新的t3DObject结构。(考虑一种特别情况:想让模型支持分体。就像Ogre中的Entity概念,它包含多个SubEntity,并可以在程序中获取这个SubEntity进行特别的加工,譬如熟悉的ogre.mesh,就可以分别对模型的眼睛、牙齿、耳环、面皮肤等设置材质,甚至分体、运动——如果OBJ模型也想实现这种效果,就必须按建模软件给出的组[g]来划分对象了,同时应该保存组的名字。不过这种扩展对我自己而言应用场合很少,有需要的时候再扩展ZWModelOBJ好了。)
- 顶点属性数据是全局的,每个Object怎么获取?——策略比较简单,在obj文件前半部读入这些数据的时候,存入全局的支持随机存取的容器里(3种属性都有的情况下就给3个vector容器,见清单②处):文件后半部读入usemtl的时候生成一个新的t3DObject对象,根据usemtl的指示查找tMatInfoVec(在此之前读入mtllib标识【清单①处】的时候这个vector已经被填充好了),把索引记入nMaterialID成员(见清单③处);接下来会读入一串f标识的面信息(若读入f时尚无对象生成则生成一个,有些无材质模型是会这样的),根据其格式判断该对象是否包含纹理坐标和法线信息,并根据索引查找前面保存了顶点数据的容器(要注意的是这些索引时从1开始的,所以容器里对应元素的的下标应该是此索引减1后的数值)……直到下个usemtl标识(见下清单的④处)。
- //读数据
- void ZWModelOBJ::ProcessFileInfo(t3DModel *pModel)
- {
- char strBuff[MAX_LINE] = {0};
- char chKeyword = 0;
- while(EOF != fscanf_s(m_FilePointer, "%s", strBuff, MAX_LINE))
- {
- // 获得obj文件中的当前行的第一个字符
- chKeyword = strBuff[0];
- switch(chKeyword)
- {
- case 'm': //判断读入mtllib, 指示材质库文件 【①】
- {
- if(0 == strcmp(strBuff, "mtllib"))
- {
- wchar_t wszPath[MAX_PATH] = {0};
- if(m_szResourceDirectory)
- {
- wcscpy_s(wszPath, sizeof(wszPath) / sizeof(wchar_t), m_szResourceDirectory);
- }
- size_t nCurPathLen = wcslen(wszPath);
- fscanf_s(m_FilePointer, "%s", strBuff, MAX_LINE);
- memset(&wszPath[nCurPathLen], 0, (MAX_PATH - nCurPathLen) * sizeof(wchar_t));
- MultiByteToWideChar(CP_ACP, 0, strBuff, -1, &wszPath[nCurPathLen], (MAX_PATH - nCurPathLen));
- ProcessMtlFileInfo(pModel, wszPath);
- }
- fgets(strBuff, MAX_LINE, m_FilePointer);
- }
- break;
- case 'u'://判断读入usemtl, 指示新的对象(可能包含多个组g), 指示材质 【③ 】
- {
- if(0 == strcmp(strBuff, "usemtl"))
- {
- t3DObject newObject = {0};
- newObject.bHasTexture = false;
- newObject.bHasNormal = false;
- fscanf_s(m_FilePointer, "%s", strBuff, MAX_LINE);
- newObject.nMaterialID = FindMtlID(pModel, strBuff);
- pModel->t3DObjVec.push_back(newObject);
- ++m_nCurObjectCount;
- m_VObjectIndexMap.clear();
- }
- fgets(strBuff, MAX_LINE, m_FilePointer);
- }
- break;
- case 'v':// 读入的是'v' (后续的数据可能是顶点/法向量/纹理坐标)【②】
- {
- // 读入点的信息 - 顶点 ("v")、法向量 ("vn")、纹理坐标 ("vt")
- ProcessVertexInfo(strBuff[1]);
- }
- break;
- case 'f': // 读入的是'f'(面的信息) 【④】
- {
- if(0 == m_nCurObjectCount) //创建一个无材质物件
- {
- t3DObject newObject = {0};
- pModel->t3DObjVec.push_back(newObject);
- ++m_nCurObjectCount;
- }
- ProcessFaceInfo(pModel);
- }
- break;
- default:
- // 略过该行的内容
- fgets(strBuff, MAX_LINE, m_FilePointer);
- break;
- }
- }
- }
- 怎么根据f标识数据的格式判断该对象是否具有纹理坐标、法线信息?——这是些读文件技巧了,关键是知道一个顶点的属性格式有4种:%d、%d/%d、%d//%d、%d/%d/%d,分别表示只有位置数据、只有位置和纹理坐标数据、只有位置和法线数据、3种数据都有。有时候(很少)会遇到有些顶点是一种格式,有些顶点是别的格式的情况,按数据最多的格式为主来设置对象(t3DObject)的bHasTexture和bHasNormal标签。
- 怎么储存对象的索引?——这应该是最重要的一点。要考虑渲染的方法。如果用传统的打点方式(glVertex)来渲染的话,模型数据结构没那么麻烦,只需要一个索引数组去获取在清单②中储存到全局容器里的数据就够了。但是,毕竟为了效率,我们要摒弃这种落后于时代的方法。用VBO【学一学,VBO】首先有两个必须考虑的要点:1)一份VBO(包括各顶点属性VBO)应该只对应一份材质,这点在上面已经考虑到了,所以一份VBO也对应一份网格对象(t3DObject),至少在这里这点是必须的;2)一份VBO里的各顶点属性VBO(不包括索引VBO)的大小应该一致。当使用索引VBO的时候每个索引在各属性VBO里必须有有效值。
我说的所谓OBJ格式重储存不重读写的原因也正在此。全局容器里可能有300组位置信息,250组纹理坐标信息,100组法线信息,本身就不会相等。OBJ格式文件通过面索引去取值,这样就可以避免相同数据的重复储存,提高了储存效率(但是导出数据文件的时候就必定会要花费更多)。另一方面,这种索引是全局的,这就不为VBO所容(一份VBO对应一份Object,是局部的),所以顶点属性数据应该转换为局部储存(t3DObject里的vector),索引也应该转化为局部的数值。这种转换导致了模型读入过程的花费。
- 非三角面的面片怎么读取?——划分成三角面片咯,譬如f标识读到第4个顶点属性索引组的时候,就把第1、3、4个属性索引组作为一个新面(不该花CPU时间去考虑可能的共线问题)。因为我们的Indexed VBO用顺序的索引来构成GL_TRIANGLES,所以不需另外保存面的信息。
- void ZWModelOBJ::ProcessFaceInfo(t3DModel *pModel)
- {
- ......
- fscanf_s(m_FilePointer, "%s", strBuff, MAX_LINE);
- .....
- if(2 == sscanf_s(strBuff, "%d/%d", &vIdx, &tIdx)) // 格式v/t
- {
- if(!pCurObj->bHasTexture)
- {
- pCurObj->bHasTexture = true;
- }
- int nCounter = 0;
- do
- {
- ++nCounter;
- if(nCounter > 3)
- {
- //Type - 123 134
- pCurObj->Indexes.push_back(pCurObj->Indexes[pCurObj->Indexes.size() - 3]);
- pCurObj->Indexes.push_back(pCurObj->Indexes[pCurObj->Indexes.size() - 2]);
- nCounter = 3;
- }
- std::map<tVertInfo, unsigned short>::iterator pFindPos
- = m_VObjectIndexMap.find(tVertInfo(m_VPositionVec[vIdx - 1], m_VTexcoordVec[tIdx - 1], vNormal));
- if(m_VObjectIndexMap.end() != pFindPos)
- {
- pCurObj->Indexes.push_back(pFindPos->second);
- }
- else
- {
- pCurObj->PosVerts.push_back(m_VPositionVec[vIdx - 1]);
- pCurObj->Texcoords.push_back(m_VTexcoordVec[tIdx - 1]);
- pCurObj->Normals.push_back(vNormal); //UNIT_Y
- pCurObj->Indexes.push_back(pCurObj->PosVerts.size() - 1);
- m_VObjectIndexMap.insert(std::pair<tVertInfo, unsigned short>(tVertInfo(m_VPositionVec[vIdx - 1], m_VTexcoordVec[tIdx - 1], vNormal), pCurObj->PosVerts.size() - 1));
- }
- } while (2 == fscanf_s(m_FilePointer, "%d/%d", &vIdx, &tIdx));
- }
- ..........
- }
上面的代码展示其中一种格式的面片读入。判断格式后,不断读入该行的索引组,若超过3个,则按上所述增加面片。对每个索引组,在t3DObject的容器中顺序push入全局容器中对应的实体数据,index-vector则插入当前顶点所在容器的索引(假如该数据重复出现则只插入对应索引)。假如缺少法线则插入单位正Y法线,毕竟要考虑一些索引组有法线一些没有的情况(纹理坐标同理,用空坐标)。也许你会问是否不用索引VBO,把顶点属性一一保存,最后单纯使用glDrawArray渲染一般的VBO如何,那你是太小看数据重复(共顶点的面)的厉害了,现在700多的顶点位置数据,就有机会提供给5000多个索引……
对于数据重复的判断,就需要在当前Object的数据堆里进行查找。这里我额外使用一个map(m_VObjectIndexMap)来储存顶点的索引,并以点的顶点属性为这些索引的“索引(键值)”(定义一个顶点属性结构体tVertInfo,并重载<操作符使之能够排序)。STL的map内部数据存放方式是类似AVL的红黑查找树,对于字典式查找效率比遍历vector的STL函数find要高效许多,但这样做相比下会在导入期产生一个颇大的map,内存紧张则宜改为单用vector(顶点位置)为键(因为对大部分正常的模型这样就足够了,对一些纹理相邻的模型则加上纹理坐标,因为事实上这两者相等但法线不等的情况真是有点猎奇)。注意生成新的网格对象t3DObject时清空这个map,导入完成后也清空之。
好了,现在数据读完,ImportModel函数的下一个任务就是生成VBO。在此之前可以清除掉全局的顶点属性容器内数据,在此之后可以清除各个Object里面的(局部)容器数据。因为glDrawElement需要索引数量为参数,因此清除前把该值保存到t3DObject结构的nNumIndexes里就可以了,glBufferData已经把数据传输给VBO了(GPU)。CPU这里留下VBO的ID够了~~
- //ImportModel函数part2
- bool ZWModelOBJ::ImportModel(wchar_t *strFileName, GLuint usage)
- {
- ........
- //清除全局顶点属性数据
- m_VPositionVec.clear();
- m_VNormalVec.clear();
- m_VTexcoordVec.clear();
- m_VObjectIndexMap.clear();
- //绑定VBO
- for(unsigned int i = 0; i < m_ModelOBJ.t3DObjVec.size(); i++)
- {
- if(!m_ModelOBJ.t3DObjVec[i].PosVerts.empty())
- {
- glGenBuffers(1, &m_ModelOBJ.t3DObjVec[i].nPosVBO);
- glBindBuffer(GL_ARRAY_BUFFER, m_ModelOBJ.t3DObjVec[i].nPosVBO);
- glBufferData(GL_ARRAY_BUFFER, m_ModelOBJ.t3DObjVec[i].PosVerts.size() * sizeof(Vector3),
- (GLvoid*)&m_ModelOBJ.t3DObjVec[i].PosVerts[0], usage);
- }
- if(!m_ModelOBJ.t3DObjVec[i].bHasNormal)
- {
- // 计算顶点的法向量
- ComputeNormals(&m_ModelOBJ.t3DObjVec[i]);
- m_ModelOBJ.t3DObjVec[i].bHasNormal = true;
- }
- if(!m_ModelOBJ.t3DObjVec[i].Normals.empty())
- {
- //normal -VBO
- }
- if(m_ModelOBJ.t3DObjVec[i].bHasTexture && !m_ModelOBJ.t3DObjVec[i].Texcoords.empty())
- {
- //Texcoord VBO
- }
- if(!m_ModelOBJ.t3DObjVec[i].Indexes.empty())
- {
- glGenBuffers(1, &m_ModelOBJ.t3DObjVec[i].nIndexVBO);
- glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ModelOBJ.t3DObjVec[i].nIndexVBO);
- glBufferData(GL_ELEMENT_ARRAY_BUFFER, m_ModelOBJ.t3DObjVec[i].Indexes.size() * sizeof(unsigned short),
- (GLvoid*)&m_ModelOBJ.t3DObjVec[i].Indexes[0], usage);
- m_ModelOBJ.t3DObjVec[i].nNumIndexes = m_ModelOBJ.t3DObjVec[i].Indexes.size();
- }
- }
- CleanImportedData();
- return true;
- }
vector是顺序储存的,所以获得数据区指针用&vec[0]就可以了(不要用迭代器)。如果没有法线数据就自己计算一下好了。记住纹理坐标数据如果没有的话可以不生成该VBO,但这时渲染的时候千万别启用(GL_TEXTURE_COORD_ARRAY),不然坐等崩溃。
渲染函数RenderModel()就8贴出来了,我在[学一学,VBO] [索引顶点的VBO与多重纹理下的VBO]里说得很清楚了。最后给出整个导入类ZWModelOBJ的代码,有些API和使用法会在下篇文章一起讲。本文可能日后会被转帖,再说在下的博客:http://www.ZwqXin.com,还是开头那句话:如果阁下发现有什么BUG或者有什么好的建议,请多指出,请多指教。
本文来源于 ZwqXin (http://www.zwqxin.com/), 转载请注明
原文地址:http://www.zwqxin.com/archives/opengl/obj-model-format-import-and-render-2.html