20220829 更新了一下git库,修复了一些我也不知道的bug
代码基于LearnOpenGL的模型导入篇章的代码模板实现。
OpenGL 3.3
VS2019
代码:https://github.com/LoserFight/LoopSubdivision
参考:
半边结构参考:【图形学】Loop细分算法及半边结构实现(C++)
原理部分:GAMES101
仅显示顶点,边或者面的切换,空格键切换
loop subdivision 细分,键盘右键→
细分后保存同目录下的obj文件,按下q键
支持二进制stl和obj格式的导入
wasd移动摄像机,配合鼠标移动视角
按住键盘上的X Y或Z,上下移动鼠标可以旋转模型
./xxx.exe 模型的目录/模型 可以打开指定模型
细分:
导出的模型:
仅显示顶点:
因为Loop细分需要一个顶点周围点的信息,所以选择了能较容易得到周围点、边、面的半边数据结构存储存储三角形网格。
一般的3D模型文件会包含里面所有点或者面的相关信息,半边数据结构很巧妙,每个三角形可以用用三个顺时针或逆时针的向量边表示,因为是单向所以是半边。
一个半边数据结构的结构体应该包含
基本思路是先导入需要的顶点数据,再用三角形的三个顶点构造面和半边。
如下所示的face构造,已经完成了必要顶点vertexs[3]的导入,在这个函数中将三条半边用next连接起来,实际上这个操作才是确定了一个面的三个面和三个顶点的所有信息,这步操作后,只需要一个半边或一个顶点就能得到所有关于三角形的数据。
//输入一个三角形的三个顶点数据,生成半边和面
Face* TriMesh::createFace(Vert* vertexs[3]) {
Face* face = new Face();
HalfEdge* edges[3];
//建立一个三角形的半边
for (int i=0; i < 3; i++) {
edges[i] = createEdge(vertexs[i % 3], vertexs[(i + 1) % 3]);
if (edges[i] == NULL) {
std::cout << "Create Face went wrong" << std::endl;
return NULL;
}
}
//将对边通过next连接起来,并和该face绑定
for (int i = 0; i < 3; i++) {
edges[i]->next = edges[(i + 1) % 3];
edges[i]->face = face;
m_edges.push_back(edges[i]);
}
//给面赋值任意一个半边
face->halfEdge = edges[0];
m_faces.push_back(face);
return face;
}
举个例子
下面是一个输入顶点地址,返回一个从顶点出发的半边vector
std::vector<HalfEdge*> TriMesh::getEdgesFromVertex(const Vert* vertex) {
std::vector<HalfEdge*> result;
HalfEdge* he = vertex->halfEdge;//he为从vertex出发的半边
HalfEdge* temp = he;
do {
if (temp->isBoundary) {
result.push_back(temp);
break;
}
result.push_back(temp);
} while (temp != he);
if (temp->isBoundary) {
temp = he->next->next;
//换个方向
std::reverse(result.begin(), result.end());
do {
if (temp->isBoundary || temp == NULL) {
break;
}
result.push_back(temp->opposite);
temp = temp->opposite->next->next;
} while (temp->vert->id == vertex->id);//true
//换回来
std::reverse(result.begin(), result.end());
}
return result;
}
可以看这张图,输入P,应该输出V0,V1,V2,V3.
obj格式导入较容易,因为文件内就是用点的引索表示面。
stl就有点复杂,因为它表示 面时候是直接给出了三个顶点的坐标,有些点是重复的。
所以我建立了一个顶点坐标到顶点引索的表。当这个顶点不存在表内,说明是新的点,构造半边结构的点,否则返回已经存在表内对应顶点的索引值。
//model.h
//哈希操作
struct VKeyHashFuc
{
std::size_t operator()(const glm::vec3& key) const
{
return std::hash<float>()(key.x) +
std::hash<float>()(key.y)+
std::hash<float>()(key.z);
}
};
int loadStl(string const& path) {
ifstream targetStl(path,ios::in|ios::binary);
string line;
char name[80];
int v_id = 0;
unsigned int triangles;
//float normal;
//声明一个哈希表
std::unordered_map<glm::vec3, int, VKeyHashFuc> hashmap_vid;
if (!targetStl) {
cout << "Open .stl went wrong" << endl;
return 0;
}
targetStl.read(name, 80);
targetStl.read((char*)&triangles, sizeof(unsigned int));
if (triangles == 0)
return -1;
for (unsigned int i = 0; i < triangles; i++) {
float XYZ[12];//4*3
int face[3];
targetStl.read((char*)XYZ, 12 * sizeof(float));
for (int j = 1; j < 4; j++) {
glm::vec3 v = { XYZ[j * 3],XYZ[j * 3 + 1],XYZ[j * 3 + 2] };
if (hashmap_vid.find(v) != hashmap_vid.end()) {
//顶点已经存在
face[j - 1] = hashmap_vid[v];
}
else {//全新顶点数据,更新哈希表,将索引Vid存入面j,这样通过哈希表和face[j]就能寻找到对应的顶点的xyz坐标。
hashmap_vid[v] = v_id;
face[j - 1] = v_id;
TMeshOri->createVertex(v, v_id++);
}
}
//生成面
Vert* ve[3];
auto& mV = TMeshOri->Vertexs();
ve[0] = mV[face[0]];
ve[1] = mV[face[1]];
ve[2] = mV[face[2]];
TMeshOri->createFace(ve);
targetStl.read((char*)XYZ, 2);
}
targetStl.close();
TMeshOri->createBoundary();
TMeshOri->calculateNormal();
this->meshes.push_back(TriToMesh(TMeshOri));
return 0;
}
这样就可以像obj一样构建半边结构了。
先对旧点遍历,得到每个点周围的顶点,乘上相应系数得到新的顶点位置,加入新的半边结构内
在对旧边遍历,得到新增点的位置,加入新的半边结构中
对旧面遍历,用之前已经插入的新顶点将每个面拆成4个小面。
这时候就能体现出半边数据结构的优点了,很轻松就能得到周围点的信息。
不过本文中,关于Old Vertex 用的是Loop最初提出用三角函数的系数,
如果是边节上的新点或者旧点,计算方法完全不一样
...
//以旧点为例
if (!oriVertexs[i]->isBoundary) {
auto neighborV = ori->getNeighborVertexs(oriVertexs[i]);
int n = neighborV.size();
float beta = (5.0 / 8.0 - pow(3.0 / 8.0 + 1.0 / 4.0 * std::cos(2.0 * My_PI / n), 2.0)) / n;
for (int j=0; j < neighborV.size(); j++) {
newPos += neighborV[j]->vcoord;
}
//计算even points
newPos = newPos * beta;
newPos += oriVertexs[i]->vcoord * (1 - n * beta);
}
else {
auto BoundaryNV=ori->getBoundaryNeighborVertexs(oriVertexs[i]);
newPos = 0.125f * (BoundaryNV[0]->vcoord + BoundaryNV[1]->vcoord) + 0.75f * oriVertexs[i]->vcoord;
}
...
再生成所有新点和更新所有旧点时,这些新生成本质上是新加入的点,可以像半边数据结构的建立一样构造loop Subdivision后的点。
https://pbr-book.org/3ed-2018/Shapes/Subdivision_Surfaces
生成新的法线用的方式是从这个网站得到,细分也很全,前文基本上也参照了其中内容
每次细分后, 都要根据新的顶点和面生成法线,这个法线和原格式的法线很有可能不一样。
不过我算的时候S X T得到的刚好相反,改成了T X S。
对mesh类和model类,增加了相应的图元绘制,见model.h和mesh.h。
同时还要把半边结构转化成对应的数据格式,如网格画线,只需要传入线段对应顶点。如下
//Line
static Mesh TriToLine(TriMesh* T) {
vector<Vertex> vertexs;
vector<unsigned int> ind;
auto& mv = T->Vertexs();
for (int i = 0; i < mv.size(); i++) {
Vertex newP;
newP.Position = mv[i]->vcoord;
newP.Normal = mv[i]->ncoord;
vertexs.push_back(newP);
}
auto& mf = T->HalfEdges();
for (int i = 0; i < mf.size(); i++) {
HalfEdge* he = mf[i];
ind.push_back(he->vert->id);
ind.push_back(he->next->next->vert->id);
}
vector<Texture> tex;
return Mesh(vertexs, ind, tex);
}
//Mesh
static Mesh TriToMesh(TriMesh* T) {
vector<Vertex> vertexs;
vector<unsigned int> ind;
auto& mv = T->Vertexs();
for (int i = 0; i < mv.size(); i++) {
Vertex newP;
newP.Position=mv[i]->vcoord;
newP.Normal = mv[i]->ncoord;
vertexs.push_back(newP);
}
// unsigned int vi = 0;
auto& mf = T->Faces();
for (int i = 0; i < mf.size(); i++) {
Face* f = mf[i];
HalfEdge* he = f->halfEdge;
do {
ind.push_back(he->vert->id);
he = he->next;
} while (he != f->halfEdge);
}
vector<Texture> tex;
return Mesh(vertexs, ind, tex);
}
//不需要画点,因为如果画点,只需要不加索引就可以了