# OpenGL使用Laplacian进行网格光顺
题目要求使用OpenGL+Laplacian算子对obj文件进行网格光顺,这篇算是作业的记录吧
什么是网格光顺?网格光顺在不改变顶点之间连接关系、减小曲率变化的前提下移动顶点,达到减小噪声、改善网格三角形形状的效果。该善三角形形状->趋向于正三角形,你可以观察下面的三角形网格变化。
先用gif图演示一下网格光顺的效果,直观感受一下
上一次接触Laplacian算子 (opens new window)还是在数字图像处理的课程上,使用Laplacian算子处理图像来获得边缘增强的图像,再与原来的图像进行结合,实现图像的锐化。
# obj文件的数据结构
开始先使用MeshLab看看我们要处理的obj文件
经典小兔,搞图形学的都不陌生。用记事本打开obj文件可以看到这个obj文件里面只有
v:几何体顶点(Geometry Vertice)
vn:顶点法线(Vertex Normal)
f:面(Face)
三种数据
截取部分:
其中v和vn后面跟的都是xyz
usemtl(null)表示没有贴图
面f的数据格式是
f 顶点索引//顶点法向索引 顶点索引//顶点法向索引 顶点索引//顶点法向索引
比如 “f 1//4 2//5 3//6” 就是这个面由第1、2、3个点构成,这三个顶点又分别对应第4、5、6个顶点法向
(如果有贴图的话就是 顶点索引/纹理索引/顶点法向索引)
注意,obj文件的索引都是从1开始的而不是0
读取obj文件 代码参考了这位博主的代码:非常简单,教你用OpenGL读入obj模型_旧时光 | YoungChen's 博客-CSDN博客
在其基础上进行了改进:
1、原po的顶点法向是计算出来的,而我们的obj文件中已经有顶点法向的数据,添加了读取顶点法向的操作
2、同时存储顶点xyz坐标的vset中还记录了包含该顶点的face,将face索引存入顶点的vector中是为了便于之后Laplacian的相关计算,我不知道obj文件的顶点之间是否有什么特殊的排列或者什么办法能够快速地找到周围的点。
objLoader::objLoader(string filename)
{
drawmode = GL_LINE;//默认是绘制轮廓
string line;//读取每一行
fstream f;
f.open(filename, ios::in);//读文件
if (!f.is_open()) {
cout << "file cannot open" << endl;
}
else {
cout << "file open successful" << endl;
}
while (!f.eof()) {
getline(f, line);
char breakmarker = ' ';//obj中每行各部分以空格隔开
string little_tail = " ";
line = line.append(little_tail);//为line末尾添加一个分隔符
//将当前行根据空格分开
string part = "";
vector<string> partofline;//当前行根据空格拆分成的各个string部分
for (int i = 0; i < line.length(); i++) {
char nowchar = line[i];
if (nowchar == breakmarker) {
//如果当前char为空格,将前面的部分存入partofline
partofline.push_back(part);
part = "";
}
else {
part += nowchar;
}
}
//当前行拆分完毕
//根据不同的数据进行相应操作
if (partofline.size() == 4) {
//我们的网格是三角形网格,如果不是拆分成4部分那就没必要检查
if (partofline[0] == "v") {
//当前行是顶点,存储顶点坐标
vector<GLdouble> v;
for (int n = 1; n < 4; n++) {
//GLdouble xyz = atof(partofline[n].c_str());//转换成double
GLdouble xyz = atof(partofline[n].c_str());//转换成double
v.push_back(xyz);
}
vset.push_back(v);
}
if (partofline[0] == "vn") {
//当前行是顶点向量,存储向量
vector<GLdouble> vn;
for (int n = 1; n < 4; n++) {
//GLdouble xyz = atof(partofline[n].c_str());//转换成double
GLdouble xyz = atof(partofline[n].c_str());//转换成double
vn.push_back(xyz);
}
vnset.push_back(vn);
}
if (partofline[0] == "f") {
//当前行是面,将顶点索引和对应的顶点法向索引存储起来
//因为在执行到面的时候所有顶点已经存储完成,将顶点对应的面存储到顶点中
vector<GLint> f;
//因为数据格式是 顶点索引//顶点法向索引
//要将中间两道斜线去掉
for (int n = 1; n < 4; n++) {//处理三个v//vn
vector<string> vvn;//存放处理好的v和vn
string v_and_vn = partofline[n];// v//vn
v_and_vn = v_and_vn.append("/");
string part = "";
for (int c = 0; c < v_and_vn.length(); c++) {//将v//vn拆开
char nowcharofpart = v_and_vn[c];
//因为2个/,vvn[0]存储v,vvn[1]存储"",vvn[2]存储vn
if (nowcharofpart == '/') {
vvn.push_back(part);
part = "";
}
else {
part += nowcharofpart;
}
}
//v和vn已经拆分好并存入了vvn,现在存入对应的set
GLint vindex = atof(vvn[0].c_str());
GLint vnindex = atof(vvn[2].c_str());
//f的结构为 { v0 , vn0 , v1 , vn1 , v2 , vn2 }
f.push_back(vindex);
f.push_back(vnindex);
//cout << "v:" << vindex << endl;
//cout << "vn:" << vnindex << endl;
//既然已经知道了顶点索引,将面索引存入到对应的顶点中去
//obj文件的索引是从1开始的而不是0
//这时候f还没有添加到fset中去,f的序号(从0开始)应该为fset.size()
vset[vindex - 1].push_back(fset.size());
//cout << "face number:" << fset.size()<<endl;
}
fset.push_back(f);//将顶点索引和顶点向量索引存入fset
}
}
/*
//输出当前行的初步拆分结果
cout<<"line:" << line << endl;
cout << "part number:" << partofline.size()<<endl;
for (int j = 0; j < partofline.size(); j++) {
cout << partofline[j] << endl;
}
*/
}
vset_begin.assign(vset.begin(), vset.end());//将vset备份
f.close();
/*
//输出最终结果
cout << "输出vset" << endl;
for (int m = 0; m < vset.size(); m++) {
for (int n = 0; n < vset[m].size(); n++) {
cout << vset[m][n] << ",";
}
cout << endl;
}
cout << "输出vnset" << endl;
for (int m = 0; m < vnset.size(); m++) {
for (int n = 0; n < vnset[m].size(); n++) {
cout << vnset[m][n] << ",";
}
cout << endl;
}
cout << "输出fset" << endl;
for (int m = 0; m < fset.size(); m++) {
for (int n = 0; n < fset[m].size(); n++) {
cout << fset[m][n] << ",";
}
cout << endl;
}
*/
}
# 绘制模型
glNormal和glVertex将读取到的顶点法向与对应的顶点绑定
void objLoader::drawobj() {
int vindex1, vindex2, vindex3,
vnindex1,vnindex2,vnindex3;
glPolygonMode(GL_FRONT_AND_BACK, drawmode);
for (int i = 0; i < fset.size(); i++) {
//索引从1开始,set从0开始
//顶点索引
vindex1 = fset[i][0] - 1;
vindex2 = fset[i][2] - 1;
vindex3 = fset[i][4] - 1;
//顶点法向索引
vnindex1 = fset[i][1] - 1;
vnindex2 = fset[i][3] - 1;
vnindex3 = fset[i][5] - 1;
glBegin(GL_TRIANGLES);
if (reverseVertexNormal) {
glNormal3d(-vnset[vnindex1][0], -vnset[vnindex1][1], -vnset[vnindex1][2]);//设置顶点法向
glVertex3d(vset[vindex1][0], vset[vindex1][1], vset[vindex1][2]);
glNormal3d(-vnset[vnindex2][0], -vnset[vnindex2][1], -vnset[vnindex2][2]);
glVertex3d(vset[vindex2][0], vset[vindex2][1], vset[vindex2][2]);
glNormal3d(-vnset[vnindex3][0], -vnset[vnindex3][1], -vnset[vnindex3][2]);
glVertex3d(vset[vindex3][0], vset[vindex3][1], vset[vindex3][2]);
}
else {
glNormal3d(vnset[vnindex1][0], vnset[vnindex1][1], vnset[vnindex1][2]);//设置顶点法向
glVertex3d(vset[vindex1][0], vset[vindex1][1], vset[vindex1][2]);
glNormal3d(vnset[vnindex2][0], vnset[vnindex2][1], vnset[vnindex2][2]);
glVertex3d(vset[vindex2][0], vset[vindex2][1], vset[vindex2][2]);
glNormal3d(vnset[vnindex3][0], vnset[vnindex3][1], vnset[vnindex3][2]);
glVertex3d(vset[vindex3][0], vset[vindex3][1], vset[vindex3][2]);
}
glEnd();
}
}
glPolygonMode()的第二个参数通过在GL_LINE和GL_FILL切换可以达到不同的绘制效果,左为GL_LINE,右为GL_FILL
题外话:这里面的reverseVertexNormal是我自己加的一个布尔量,因为这里的顶点法向是从文件中读取的,如果是自己计算顶点法向有可能得到的结果与实际结果相反。于是我就想试试如果把顶点法向反过来会是什么样子,试验结果也贴一下:
为了使看到的模型有亮有暗我调整了一下光源的位置,且环境光几乎为0,demo中应该看不到这样明显的效果,可能需要手动调整一下光源位置并关闭环境光
左为原顶点法向的模型,右为将顶点法向变为相反方向之后的模型,可以看到原来亮的部分变暗了,暗的部分变亮了。
# Laplacian光顺
我们在进行网格光顺时并没有考虑权重对新坐标的影响,如果引入权重光顺的效果会更好一些。这里只是浅显地实验一下
光顺过程中不能直接用新的顶点去替换别的顶点,因为可能还有别的顶点要使用这个顶点来计算。
容易出错的一处地方是不要忘记将旧顶点的face序号存入新顶点中,否则只能进行一次Laplacian光顺,第二次光顺时就会因为找不到face序号而无法计算。
//计算拉普拉斯算子进行网格光顺后的点
void objLoader::LaplacianCal() {
for (int i = 0; i < vset.size(); i++) {
//找到顶点周围的所有点的序号
vector<GLint> v_near;//存放周围点的序号
for (int j = 3; j < vset[i].size(); j++) {
//遍历包含该顶点的所有face
//因为vset存储的是double,得到序号需要转换成int
//vset[012]存放顶点,之后存放的是face的索引(从0开始)
int faceindex = (int)vset[i][j];
for (int k = 0; k < 5; k = k+2) {//遍历face的三个顶点索引,注意fset { v0 , vn0 , v1 , vn1 , v2 , vn2 }
//检查是否与当前顶点是一个顶点,face中存储的序号从1开始
if (fset[faceindex][k]-1!=i ) {
//不是的话加入v_near
v_near.push_back(fset[faceindex][k]-1);
}
}
}
//计算拉普拉斯分量乘以参数
double xsum = 0,
ysum = 0,
zsum = 0;
for (int m = 0; m < v_near.size(); m++) {
int vindex = v_near[m];
xsum += vset[vindex][0];
ysum += vset[vindex][1];
zsum += vset[vindex][2];
}
int numofv = v_near.size();
double newx = xsum / numofv*para_ofLaplacian + (1-para_ofLaplacian)*vset[i][0],
newy = ysum / numofv * para_ofLaplacian + (1 - para_ofLaplacian)*vset[i][1],
newz = zsum / numofv * para_ofLaplacian + (1 - para_ofLaplacian)*vset[i][2];
vector<GLdouble> newv;
newv.push_back(newx);
newv.push_back(newy);
newv.push_back(newz);
for (int n = 3; n < vset[i].size(); n++) newv.push_back(vset[i][n]);//不要忘记将face序号也存储进去,准备下一次拉普拉斯算子的计算
//存储新顶点
vset_afterLaplacian.push_back(newv);
}
vset.clear();
vset.assign(vset_afterLaplacian.begin(), vset_afterLaplacian.end());
vset_afterLaplacian.clear();
}
看一下Laplacian算子进行光顺之后的效果(左为原模型,右为Laplacian系数设置为0.5,光顺3次之后的结果)
# Demo
Demo还实现了使用鼠标左键拖拽,右键旋转、滚轮缩放的功能,支持绘制模式在GL_LINE和GL_FILL之间切换,反转顶点法向。交互做的比较糙,虽然拖拽只能在XOZ平面上拖拽,旋转只能绕Z轴和Y轴旋转,但结合起来基本上模型哪个部分都能看到(可能旋转之后有些别扭就是了),关于使用main文件里面有说明