Half-Edge结构即半边结构是图形学中一种十分重要的数据结构,许多算法都是基于这种结构实现的,从数学角度看这种结构十分容易,很好理解,而实际编码的时候似乎不是很轻松,之前写过两次都感觉写的不是很好,而网上也没有太多讲解实际编码实现的文章,所以记录一下简易的实现的步骤。
本文基于三角网格实现简易的Half-Edge,并能够读取.obj的模型数据
一、结构特点
最质朴的顶点+索引方式存储网格数据不能实现高效地完成一些功能的需求,例如:访问某个点的所有相邻点、访问某条边的所有邻边、访问包含某个点的所有面,而这些需求往往是一些图形算法的基础,所以很有必要寻求一种更加优秀的数据结构来实现功能。所谓的半边结构就是将每一条边拆成两条有方向的半边,如图所示。如果对其结构不是很熟悉可以参看其他的文章。
可知对于一个半边网格,需要实现三种结构:顶点、边、面。
struct HE_Vertex {
HE_Edge * v_edge; //以该顶点为首顶点的出边(任意一条)
HE_Face * v_face; //包含该顶点的面(任意一张)
float x, y, z;
};
struct HE_Edge {
HE_Edge * e_pair; //对偶边
HE_Edge * e_succ; //后继边
HE_Vertex * e_vert; //首顶点
HE_Face * e_face; //右侧面
};
struct HE_Face {
std::vector<HE_Vertex*> f_verts;
HE_Edge * f_edge;
};
这里的实现简化了一些内容,譬如顶点的纹理坐标、法线等,如果有需求可以自行添加。
二、构建过程
这里的构建完全基于.obj的模型数据。
void HE_Mesh::LoadFromObj( const char * file )
{
ifstream fs;
fs.open(file);
while (!fs.eof())
{
char line[100];
fs.getline(line, 100);
if (line[0] == 'v' && line[1] == ' ')
{
char * str = strtok(line, " ");
str = strtok(NULL," ");
float x = atof(str);
str = strtok(NULL , " ");
float y = atof(str);
str = strtok(NULL, " ");
float z = atof(str);
InsertVertex(x, y, z);
}
if (line[0] == 'f' && line[1] == ' ')
{
char * str = strtok(line, " ");
verts fverts;
char * v1s = strtok(NULL, " ");
char * v2s = strtok(NULL, " ");
char * v3s = strtok(NULL, " ");
int v1 = atoi(v1s);
int v2 = atoi(v2s);
int v3 = atoi(v3s);
fverts.push_back(m_verts[v1 - 1]);
fverts.push_back(m_verts[v2 - 1]);
fverts.push_back(m_verts[v3 - 1]);
InsertFace(fverts);
}
}
fs.close();
}
可以看到,首先先读取所有的顶点数据,这里只读取其坐标信息,读入时用的是InsertVertex函数,
HE_Vertex* HE_Mesh::InsertVertex(float x, float y, float z)
{
HE_Vertex * vert = new HE_Vertex();
vert->x = x;
vert->y = y;
vert->z = z;
m_verts.push_back(vert);
return vert;
}
这一步很容易,直接创建添加就行了,不涉及任何逻辑上的连接操作。
之后,读取面的信息创建面,创建边的操作被包含在了创建面的函数中。实现半边结构中遇到最大的一个困难可能就是如何高效地实现一个边与它的对偶边的链接关系?因为在obj模型数据中,所有的面的信息都不是按照相邻的关系存放的,每次读取一张面创建三条半边,那么这些半边的对偶边是什么?我们读取是按照数据的顺序读取的,因此在已有的数据中没有任何提供对偶信息的数据。最质朴的一个想法可能是:先把所有的面都创建,不管对偶边的问题,等所有的面、半边都创建完之后再对所有的边统一进行一次操作,来找到每一条半边的对偶边,即一次两重循环,这一方法的复杂度是o(n^2) ,在数据量很大的情况下,并不实用。那么另一种思想就是,一张面有3条半边,在每次创建这3条半边的时候,提前将其对偶边也创建了,并且直接链接对偶关系,那么这个时候问题就是如何填补这些对偶边的其他数据信息?这些对偶边的信息目前都是未知的,只有之后才能补充,那么怎么才能知道之后要创建的边已经提前创建了呢?这里就要用到哈希的思想,在创建完原边和对偶边之后,将这对偶边的指针存到哈希表中,在此后创建边时,如果这条边的首尾顶点和哈希表中的某条边的首尾顶点相同,那么这条边肯定是未填充的对偶边,直接填充其数据即可,不用再创建一条新的边。这样即可保证这一操作在o(n)的时间完成。具体的思路可以看下边的代码。
HE_Face* HE_Mesh::InsertFace(verts & face_v)
{
if (face_v.empty())
{
return NULL;
}
HE_Edge * e1 = InsertEdge(face_v[0] , face_v[1]);
HE_Edge * e2 = InsertEdge(face_v[1] , face_v[2]);
HE_Edge * e3 = InsertEdge(face_v[2] , face_v[0]);
if ( !e1 || !e2 || !e3 )
{
return NULL;
}
HE_Face * face = new HE_Face;
e1->e_succ = e2;
e2->e_succ = e3;
e3->e_succ = e1;
e1->e_face = e2->e_face = e3->e_face = face;
face_v[0]->v_face = face_v[1]->v_face = face_v[2]->v_face = face;
face->f_edge = e1;
face->f_verts = face_v;
this->m_faces.push_back(face);
}
HE_Edge* HE_Mesh::InsertEdge(HE_Vertex * v1, HE_Vertex * v2)
{
if ( v1 == NULL || v2 == NULL )
{
return NULL;
}
if ( m_emap[vert_pair(v1,v2)] != NULL )
{
return m_emap[vert_pair(v1, v2)];
}
//提前建立对偶边 并建立好点 边关系
HE_Edge * edge = new HE_Edge;
edge->e_vert = v1;
v1->v_edge = edge;
HE_Edge * p_edge = new HE_Edge;
p_edge->e_vert = v2;
v2->v_edge = p_edge;
//建立对偶关系
edge->e_pair = p_edge;
p_edge->e_pair = edge;
m_emap[vert_pair(v1, v2)] = edge;
m_emap[vert_pair(v2, v1)] = p_edge;
m_edges.push_back(edge);
return edge;
}
三、 一些高效操作的方法
1. 访问以某个点为首顶点的所有半边
在顶点结构中,每个顶点都存储了以其为首顶点的半边中的一条,假设其为e,那么e的对偶边的后继边一定也是以其为首顶点的半边,结合图形很容易看出来。
std::vector<HE_Edge*> HE_Mesh::GetEdgesFromVertex(HE_Vertex * vert)
{
std::vector<HE_Edge*> edges;
HE_Edge * base_edge = vert->v_edge;
HE_Edge * tmp = base_edge;
do{
tmp = tmp->e_pair;
if (IsBoundary(tmp))
{
break;
}
tmp = tmp->e_succ;
edges.push_back(tmp);
} while (tmp != base_edge);
edges.push_back(base_edge);
return edges;
}
2.访问某个顶点的所有相邻点
这些相邻点一定位于以这个点为首顶点的半边上,于是很容易结合1的函数得到。
std::vector<HE_Vertex*> HE_Mesh::GetVertsFromVertex(HE_Vertex * vert)
{
edges edge = GetEdgesFromVertex(vert);
verts vertices;
for ( int i = 0 ; i < edge.size() ; i ++ )
{
vertices.push_back(edge[i]->e_succ->e_vert);
}
return vertices;
}
3.访问包含某个顶点的所有面
这些面一定是以这个点为首顶点的半边的右侧的面的并集。
std::vector<HE_Face*> HE_Mesh::GetFacesFromVertex(HE_Vertex * vert)
{
edges edge = GetEdgesFromVertex(vert);
faces faces;
for (int i = 0; i < edge.size(); i++)
{
faces.push_back(edge[i]->e_face);
}
return faces;
}
4.访问某条半边的所有相邻半边
结合图形,相邻半边一定是以这条半边的首顶点为尾顶点的边和以这条半边的尾顶点为首顶点的边的并集。于是结合1,很容易得出如下算法
std::vector<HE_Edge*> HE_Mesh::GetEdgesFromEdge(HE_Edge * edge)
{
edges e_edge = GetEdgesFromVertex(edge->e_succ->e_vert);
edges p_edge = GetEdgesFromVertex(edge->e_vert);
for (int i = 0 ; i < p_edge.size() ; i ++ )
{
e_edge.push_back(p_edge[i]->e_pair);
}
return e_edge;
}
至于其他的访问操作都相对容易,在此不再赘述。
以上便是简易的半边结构的构建,如果有更多的需求可以进一步实现,当然也可以使用已经成熟的相关库,不过自己手写一遍简单的实现可以加深对其理解。