前言
最近项目中需要使用到OpenGL对3D模型进行渲染。
已有数据为:
- 带纹理的3D模型
- 模型上的关键点。
需要实现的功能:
- 读取和保存 带纹理的3D模型、读取模型的关键点
- 对模型进行渲染,保存设定角度的渲染图片、以及关键点在相同角度的2D坐标
- 在渲染图片中模型上任意一个2D点,反向计算到该点空间的3D位置
开发环境:
- VS2015、三方库 OpenGL、OpenCV。
OpenGL渲染模型 || 1. OpenCV、OpenGL的环境配置
OpenGL渲染模型 || 2. 3D模型的读取与保存
OpenGL渲染模型 || 3. opengl 将模型成渲染图片
1 常用格式简介
经常使用的3D模型的格式:obj、stl、ply。本篇博客介绍前两种的格式的读取。
- 模型文件中的元素:顶点 v、纹理坐标 vt、法向量 vn、面片 f。
- obj中记录的是顶点的法向量。stl中记录的是三角面片的法向量。无论哪种法向量,都可以通过顶点+面片的信息计算出来
1.1 OBJ 模型
对于obj的文件,内部数据保存格式为:
v -3.727097 2.102915 -1.141425
v -3.545685 1.435699 -1.619487
…
vt 0.131240 0.785100
vt 0.133460 0.788660
…
vn 0.599266 0.516880 -0.611323
vn 0.599266 0.516880 -0.611323
…
f 34440/33467/42595 42314/33468/42596 16284/33469/42597
f 30281/33470/42598 16288/33471/42599 23351/33472/42600
- v: 表示顶点,即组成图的点,如立方体有8个顶点,每个顶点有x,y,z三个值。
- vt: 纹理坐标,其值为u, v。纹理应该是指某个面的粗糙程度(我暂且没看出这个变化),那纹理为什么有坐标呢?想象一块可以自由拉伸的布,坐标表示这个布的一角要放在这个位置,这就是纹理坐标。我们知道一个面最少由三个顶点才能构成,如果此时将一块布拉伸到这三个顶点上,则这块布会变成一个三角形。
- vn:顶点法向量,其值为x,y,z,你可能好奇为什么顶点有法向量,其实这个法向量是表示顶点的朝向。如果有三个顶点组成一个面,面是有两个朝向,向里或向外,所以可以通过顶点的朝向来确定面的朝向。而且这三个顶点的法向量是一样的。
- f:表示一个面,由三个v/vt/vn的索引形式组成。比如obj文件中f 5/15/7 4/14/6 6/16/8 ,表示由第5、第4、第6这三个顶点组成了一个三角平面,平面的纹理由第15、第14、第16这三个纹理坐标形成,这个平面的朝向是第7、第6、第8这三个顶点的法向量求平均值
1.2 STL 模型
STL文件有两种格式,ASCII STL、Binary STL。
ASCII STL:每一个face由7行数据组成,outer loop后面三个顶点的顺序沿法线矢量逆时针排序,格式如下:solid name // 文件名及文件路径
facet normal ni nj nk // 三角形法向量的三个分量
outer loop
vertex v1x v1y v1z // 第一个顶点坐标
vertex v2x v2y v2z // 第二个顶点坐标
vertex v3x v3y v3z // 第三个顶点坐标
endloop
endfacet // 完成一个三角形的定义
endsolid name // 整个文件结束Binary STL:
- 起始有80个字节文件头用于存储文件名,
- 紧接4个字节表示三角面片的数量,
- 每个三角面片占用固定的50个字节:法向矢量–3个4字节浮点数、第一个顶点–3个4字节浮点数、第二个顶点–3个4字节浮点数、第三个顶点–3个4字节浮点数、接着2个字节描述三角形基本属性
UINT8[80] – Header // 文件头
UINT32 – Number of triangles // 三角形数量
// foreach triangle
REAL32[3] – Normal vector // 法线矢量
REAL32[3] – Vertex 1 // 第一个顶点坐标
REAL32[3] – Vertex 2 // 第二个顶点坐标
REAL32[3] – Vertex 3 // 第三个顶点坐标
UINT16 – Attribute byte count // 文件属性
end
2 代码实现
2.1 ModelMesh.h
该脚本定义了所需的数据结构,以及实现模型的读取与保存的类Meshcnn。
#pragma once #include<vector> #include<string> #include<opencv2/opencv.hpp> #include<fstream> #include<sstream> #include<iostream> using namespace std; /* 数据结构----顶点、法向、三角形 */ typedef cv::Point3f Vertex; typedef cv::Point3f Normal; struct Tri { int v1; int v2; int v3; Tri() :v1(0.0), v2(0.0),v3(0.0){} Tri(int x, int y, int z) { v1 = x; v2 = y; v3 = z; } }; struct TextCoodIndex { int v1; int v2; int v3; TextCoodIndex(int x, int y, int z) { v1 = x; v2 = y; v3 = z; } }; struct TextureCood { float tx; float ty; TextureCood(float x, float y) { tx = x; ty = y; } }; // 在STL模型读取后,该结构体在顶点的去重复中使用 struct VtxIdxSortItem { int i; Vertex value; };
下面是class ModelMesh
的定义,
主要实现功能是:obj、stl模型的保存和读取;关键点的保存和读取;模型点、面的法向的计算class ModelMesh { public: ModelMesh(); ~ModelMesh(); /* 读取关键点 */ bool readJoint(string name); /* 解析obj模型 */ bool readObj(std::string fileName); void saveObj(std::string fileName); /* 读取stl模型 */ bool readSTL(std::string cfilename); bool saveSTL(const char *filename, std::vector<Vertex>&vtx, std::vector<Tri> &tris, int format); public: /* 计算面法向 */ void getFaceNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm); /* 计算点法向 */ void getVertexNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm); Normal cross(const Vertex &a, const Vertex &b, const Vertex &c, bool isnormal); /* 去除重复的顶点 */ void UnifyDuplicatedVertices(vector<Vertex> &vtx, vector<Tri> &tris); bool VtxIdxSortItem_LessSecond(const VtxIdxSortItem & m1, const VtxIdxSortItem & m2); std::vector<Vertex> vtx; /// 模型转换过后的大小和方向 std::vector<Tri> tris; /// 三角形 std::vector<Normal> vtxNrm; /// 顶点法向 std::vector<Normal> faceNrm; /// 面法向 std::vector<TextureCood> texcodNrm; std::vector<TextCoodIndex> textures; cv::Point3f center; /// 网格中心 float radius; /// 网格半径 vector<cv::Point3f> keypoint; string modelname; string textureFile; };
2.2 ModelMesh.cpp
#include"ModelMesh.h" #define LINE_IS(text) begins_with(buf, text) // 在obj 模型的读取时,会用到。 ModelMesh::ModelMesh() { } ModelMesh::~ModelMesh() { } bool begins_with(const char *s1, const char *s2) { using namespace ::std; return !_strnicmp(s1, s2, strlen(s2)); }
2.2.1 法向量的计算
obj模型中,不一定会提供顶点法向量;stl 只提供了面片的法向量。但无论是面片的法向量,还是顶点的法向量,都可以通过顶点和面片信息计算出来。
- 面的法向量:只需获取三角面片三个顶点;由三个顶点可以组成面上的两个向量;两个向量的叉乘,就是面的法向量。
- 顶点的法向量:该顶点所在的多个面的法向量之和。
// 计算面的法向量 void ModelMesh::getFaceNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm) { for (int i = 0; i < tris.size(); i++) { Normal norm = cross(vtx[tris[i].v1], vtx[tris[i].v2], vtx[tris[i].v3], 1); nrm.push_back(norm); } } // 计算顶点的法向量 void ModelMesh::getVertexNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm) { nrm = vector<Normal>(vtx.size(), Normal()); for (int i = 0; i < tris.size(); i++) { Normal norm = cross(vtx[tris[i].v1], vtx[tris[i].v2], vtx[tris[i].v3], 0); nrm[tris[i].v1] += norm; nrm[tris[i].v2] += norm; nrm[tris[i].v3] += norm; } // unitary for (int n = 0; n < nrm.size(); n++) { float s = sqrt(nrm[n].dot(nrm[n])); if (s != 0) { nrm[n] = nrm[n] / s; } } } // 已知三个顶点,计算三个点构成的平面的法向量 Normal ModelMesh::cross(const Vertex &a, const Vertex &b, const Vertex &c, bool isnormal) { Vertex Vac = a - c; Vertex Vbc = b - c; Normal faceN = Vac.cross(Vbc); float s = sqrt(faceN.dot(faceN)); if (!isnormal) return faceN; else { if (s != 0) return faceN / s; else return Normal(); } }
2.2.2 Obj模型的读取与保存
这里读取的obj是具有纹理信息的,这样的模型渲染出来才是彩色的效果。如果没有纹理,稍加修改代码也可跑通程序,完成渲染。
流程:
- 获取obj模型的信息: v、vt、vn、f
- 计算模型中心位置已经半径,为后面的相机参数做准备
bool ModelMesh::readObj(std::string fileName) { FILE *f = fopen(fileName.c_str(), "rb"); if (f == NULL) { printf(" %s ", " can not open the file "); return false; } vector<int> thisface; float cx = 0.0f, cy = 0.0f, cz = 0.0f; while (1) { //cout << "====4" << endl; if (feof(f)) { printf("============="); break; } char buf[1024]; fgets(buf, 1024, f); if (LINE_IS("v ") || LINE_IS("v\t")) { //cout << "====5" << endl; float x, y, z; float a, b, c; if (sscanf(buf + 1, "%f %f %f", &x, &y, &z) != 3) { printf("v error-------------"); return false; } cx += x; cy += y; cz += z; vtx.push_back(Vertex(x, y, z)); } else if (LINE_IS("vn ") || LINE_IS("vn\t")) { //cout << "====6" << endl; float x, y, z; if (sscanf(buf + 2, "%f %f %f", &x, &y, &z) != 3) { printf("vn error-------------"); return false; } vtxNrm.push_back(Normal(x, y, z)); } else if (LINE_IS("vt ") || LINE_IS("vt\t")) { //cout << "====7" << endl; float x, y; if (sscanf(buf + 2, "%f %f", &x, &y) != 2) { printf("vt error-------------"); return false; } texcodNrm.push_back(TextureCood(x, y)); } else if (LINE_IS("f ") || LINE_IS("f\t")) { //cout << "====8" << endl; int v1, vn1, vt1; int v2, vn2, vt2; int v3, vn3, vt3; if (sscanf(buf + 1, "%d/%d/%d %d/%d/%d %d/%d/%d", &v1, &vt1, &vn1, &v2, &vt2, &vn2, &v3, &vt3, &vn3) != 9) { printf("f error-------------"); return false; } tris.push_back(Tri(v1 - 1, v2 - 1, v3 - 1)); faceNrm.push_back(Normal(vn1 - 1, vn2 - 1, vn3 - 1)); textures.push_back(TextCoodIndex(vt1 - 1, vt2 - 1, vt3 - 1)); } else if (LINE_IS("mtllib ")) { char str[64]; if (sscanf(buf + 6, "%s", str) == 0) { printf("mtllib error-------------"); return false; } //cout << "====9" << endl; printf("%s", str); int idx = fileName.find_last_of('/');// fileName.lastIndexOf('/'); string fn = fileName.substr(0, idx + 1) + string(str); cout << str << endl; cout << idx << endl; cout << fn << endl; //cout << "====10" << endl; ifstream ifs(fn); if (!ifs.is_open()) { break; } while (!ifs.eof()) { string line; std::getline(ifs, line); if (!line.empty()) { std::stringstream ss(line); string s1, s2; ss >> s1 >> s2; int index = s1.find("map_Kd"); if (index == 0) { cout << endl << s2 << endl; textureFile = fileName.substr(0, idx + 1) + s2; } } } ifs.close(); } } // 计算中心位置 center.x = cx / vtx.size(); center.y = cy / vtx.size(); center.z = cz / vtx.size(); // 计算半径 radius = 0; for (int i = 0; i<vtx.size(); i++) { //cout << "点数: " << vtx.size() << endl; float lens; lens = sqrt((vtx[i] - center).dot((vtx[i] - center))); if (lens>radius) { radius = lens; } } cout << center << endl; cout << radius << endl; return true; }
保存模型时,只需按照obj的格式保存即可。这里保存obj模型,是为了代码运行过程中,保存查看模型的方向。
void ModelMesh::saveObj(std::string path) { std::ofstream fout(path.c_str()); double scale = 1.0f; fout << "mtllib test.mtl" << endl;; for (int i = 0; i<vtx.size(); i++) { fout << "v " << scale*vtx[i].x << " " << scale * vtx[i].y << " " << scale*vtx[i].z << std::endl; } for (int i = 0; i<texcodNrm.size(); i++) { fout << "vt " << texcodNrm[i].tx << " " << texcodNrm[i].ty << std::endl; } for (int i = 0; i<vtxNrm.size(); i++) { fout << "vn " << vtxNrm[i].x << " " << vtxNrm[i].y << " " << vtxNrm[i].z << std::endl; } for (int i = 0; i<tris.size(); i++) { fout << "f " << tris[i].v1 + 1 << "/" << textures[i].v1 + 1 << "/" << faceNrm[i].x + 1 << " " << tris[i].v2 + 1 << "/" << textures[i].v2 + 1 << "/" << faceNrm[i].y + 1 << " " << tris[i].v3 + 1 << "/" << textures[i].v3 + 1 << "/" << faceNrm[i].z + 1 << std::endl; } fout.close(); }
2.2.3 STL模型的读取与保存
根据前面陈述的STL的格式,即可正确的读取模型。STL的面片法向量的获取,可以直接从模型中读取,也可以自行计算。
bool ModelMesh::readSTL(std::string fileName) { // 这里只记录了Binary STL的读取代码 ifstream ifs(fileName.c_str(), ios::binary); if (!ifs) { ifs.close(); cout << "无法正确读取文件,请检查文件路径是否正确" << endl; return false; } vtx.clear(); tris.clear(); int intSize = sizeof(int); int floatSize = sizeof(float); ifs.ignore(80); // 面的个数 int num_tris; ifs.read((char*)(&num_tris), intSize); float tn0, tn1, tn2; float v0, v1, v2; float cx = 0.0, cy = 0.0, cz = 0.0; for (int i = 0; i < num_tris; i++) { ifs.read((char*)(&tn0), floatSize); ifs.read((char*)(&tn1), floatSize); ifs.read((char*)(&tn2), floatSize); faceNrm.push_back(Normal(tn0, tn1, tn2)); /*如果模型进行了坐标交换,法向尽量不用原本提供的, 方向上容易出错,用后面计算出来的法向*/ ifs.read((char*)(&v0), floatSize); ifs.read((char*)(&v1), floatSize); ifs.read((char*)(&v2), floatSize); vtx.push_back(Vertex(v0, v1, v2)); cx += v0; cy += v1; cz += v2; ifs.read((char*)(&v0), floatSize); ifs.read((char*)(&v1), floatSize); ifs.read((char*)(&v2), floatSize); vtx.push_back(Vertex(v0, v1, v2)); cx += v0; cy += v1; cz += v2; ifs.read((char*)(&v0), floatSize); ifs.read((char*)(&v1), floatSize); ifs.read((char*)(&v2), floatSize); vtx.push_back(Vertex(v0, v1, v2)); cx += v0; cy += v1; cz += v2; Tri tri; tri.v1 = i * 3 + 0; tri.v2 = i * 3 + 1; tri.v3 = i * 3 + 2; tris.push_back(tri); ifs.ignore(2); } ifs.close(); // 计算三角面片的法向; if (0) { faceNrm.clear(); getFaceNormals(vtx, tris, faceNrm); } // 计算中心位置 center.x = cx / (num_tris * 3); center.y = cy / (num_tris * 3); center.z = cz / (num_tris * 3); //计算半径 radius = 0; for (int i = 0; i < vtx.size(); i++) { float lens; lens = sqrt((vtx[i] - center).dot(vtx[i] - center)); if (lens > radius) { radius = lens; } } return true; }
STL模型保存时,选择一种方式,按照其格式正确保存即可。
bool ModelMesh::saveSTL(const char *filename, std::vector<Vertex>&vtx, std::vector<Tri> &tris, int format) { vector<Normal> nrm; getFaceNormals(vtx, tris, nrm); if (format == 0) { ofstream fs(filename, ios::binary); if (!fs) { fs.close(); return false; } int i; int intSize = sizeof(int); int floatSize = sizeof(float); // 文件头 char* fileHead = "ROC"; fs.write(fileHead, sizeof(char) * 3); // 附加信息 char fileInfo[77]; for (i = 0; i<77; i++) fileInfo[i] = ' '; fs.write(fileInfo, sizeof(char) * 77); // 面的个数 int num_tris = int(tris.size()); fs.write((char*)(&num_tris), intSize); // 点列表,面列表 char a[2]; streamsize a_size = sizeof(char) * 2; for (i = 0; i<num_tris; i++) { int PIndex0 = tris[i].v1; int PIndex1 = tris[i].v2; int PIndex2 = tris[i].v3; Vertex P0 = vtx[PIndex0]; Vertex P1 = vtx[PIndex1]; Vertex P2 = vtx[PIndex2]; Normal N0 = vtx[PIndex0]; Normal N1 = vtx[PIndex1]; Normal N2 = vtx[PIndex2]; Normal tn = nrm[i]; fs.write((char*)(&(tn.x)), floatSize); fs.write((char*)(&(tn.y)), floatSize); fs.write((char*)(&(tn.z)), floatSize); fs.write((char*)(&(P0.x)), floatSize); fs.write((char*)(&(P0.y)), floatSize); fs.write((char*)(&(P0.z)), floatSize); fs.write((char*)(&(P1.x)), floatSize); fs.write((char*)(&(P1.y)), floatSize); fs.write((char*)(&(P1.z)), floatSize); fs.write((char*)(&(P2.x)), floatSize); fs.write((char*)(&(P2.y)), floatSize); fs.write((char*)(&(P2.z)), floatSize); fs.write(a, a_size); } fs.close(); return true; } if (format == 1) { int n, i; ofstream fs(filename); if (!fs) { fs.close(); return false; } fs << "solid ods\n"; n = int(tris.size()); Vertex tn; for (i = 0; i<n; i++) { int PIndex0 = tris[i].v1; int PIndex1 = tris[i].v2; int PIndex2 = tris[i].v3; Normal P0 = vtx[PIndex0]; Normal P1 = vtx[PIndex1]; Normal P2 = vtx[PIndex2]; Normal tn = nrm[i]; fs << "facet normal " << tn.x << " " << tn.y << " " << tn.z << "\nouter loop\n" << "vertex " << P0.x << " " << P0.y << " " << P0.z << "\n" << "vertex " << P1.x << " " << P1.y << " " << P1.z << "\n" << "vertex " << P2.x << " " << P2.y << " " << P2.z << "\n" << "endloop\nendfacet\n"; } fs << "endsolid"; fs.close(); return true; } return false; };
2.2.4 STL读取后,顶点的去重复
STL模型的存储信息,是以面片的数量为单位存储的。这样读取出来的顶点,必然存在较多的重复性。为了后续的算法的高效,需要对顶点数量去重复。
bool VtxIdxSortItem_LessSecond(const VtxIdxSortItem & m1, const VtxIdxSortItem & m2) { bool b = false; if (m1.value.x > m2.value.x) b = true; else if (m1.value.x == m2.value.x) if (m1.value.y > m2.value.y) b = true; else if (m1.value.y == m2.value.y) if (m1.value.z > m2.value.z) b = true; else b = false; return b; }; // 排序分块计算 void UnifyDuplicatedVertices(vector<Vertex> &vtx, vector<Tri> &tris) { if (vtx.size() == 0) return; vector<Vertex> vtxNew; vector<int> vtxIdxMap(vtx.size(), -1); vector<int> vtxIdx; for (int i = 0; i<int(vtx.size()); i++) { vtxIdx.push_back(i); } // 去冗余运算 vector<VtxIdxSortItem> vtxIdxSortItemList; VtxIdxSortItem item; for (int i = 0; i<int(vtx.size()); i++) { item.i = vtxIdx[i]; item.value = vtx[i]; vtxIdxSortItemList.push_back(item); } sort(vtxIdxSortItemList.begin(), vtxIdxSortItemList.end(), VtxIdxSortItem_LessSecond); vtxNew.push_back(vtxIdxSortItemList[0].value); vtxIdxMap[vtxIdxSortItemList[0].i] = int(vtxNew.size() - 1); for (int i = 1; i<int(vtxIdxSortItemList.size()); i++) { if (vtxNew[vtxNew.size() - 1] != vtxIdxSortItemList[i].value) { vtxNew.push_back(vtxIdxSortItemList[i].value); } vtxIdxMap[vtxIdxSortItemList[i].i] = int(vtxNew.size() - 1); } vtx = vtxNew; // 将更新点列表和更新三角形点序号 for (int i = 0; i<int(tris.size()); i++) { tris[i].v1 = vtxIdxMap[tris[i].v1]; tris[i].v2 = vtxIdxMap[tris[i].v2]; tris[i].v3 = vtxIdxMap[tris[i].v3]; } };
2.2.5 读取关键点
3D关键点的存储在txt文本中,保存的格式为:每行代表一个点,每行内容为 x,y,z。读取的函数也是非常简单
bool ModelMesh::readJoint(string name) { std::ifstream fout1(name.c_str()); if (!fout1.is_open()) { printf("无法正确读取文件"); return false; } while (!fout1.eof()) { string line; getline(fout1, line); std::stringstream data(line); float a1, b1, c1; data >> a1 >> b1 >> c1; keypoint.push_back(cv::Point3f(a1, b1, c1)); } return true; }