PLY全名为多边形档案(Polygon File Format),主要用以储存立体扫描结果的三维数值,透过多边形片面的集合描述三维物体,与其他格式相较之下这是较为简单的方法。它可以储存的信息包含颜色、透明度、表面法向量、材质座标与资料可信度,并能对多边形的正反两面设定不同的属性。
在档案内容的储存上PLY有两种版本,分别是纯文字(ASCII)版本与二元码(binary)版本,其差异在储存时是否以ASCII编码表示元素信息。
每个PLY档都包含头部信息(header)(类似于计算机网络中各种报文的头部信息),用以指示网格模型的“元素”与“属性”,以及在档头之后接着一连串的元素“数值资料”(类似于计算机网络中各种报文的真正承载的内容)。
一般而言,网格模型的“元素”就是顶点(vertex)、面(face)等元素。无论是纯文字与二元码的PLY档,header都是以ASCII编码编写,接续其后的数值资料才有编码之分。
以例子讲解具体格式
ply
format ascii 1.0
comment zipper output
element vertex 11184
property float x
property float y
property float z
property float nx
property float ny
property float nz
element face 3732
property list uchar uint vertex_indices
end_header
0.163313 0.540615 -0.268688 0.241919 -0.961129 0.133063
0.000000 0.498178 -0.278300 0.241919 -0.961129 0.133063
0.144773 0.521976 -0.369613 0.241919 -0.961129 0.133063
0.144773 0.521976 -0.369613 0.408356 -0.800423 0.438826
ply
作为PLY格式的识别。ascii
, binary_little_endian
(二进制小端), binary_big_endian
(二进制大端)是档案储存的编码方式,而1.0是遵循的标准版本(现阶段仅有PLY 1.0版)。format ascii 1.0
format binary_little_endian 1.0
format binary_big_endian 1.0
第三行是注释,使用’comment’作为一行的开头以编写注解
第四行和十三行用于申明元素,使用’element’关键字,紧接着是元素名称,如vertex
,faces
;在其下一行接着就要描述元素的属性,使用’property’关键字,它不仅定义了属性的类型,其出现顺序亦定义了属性在之后的信息部分的顺序。
上例中,表示要描述一个有11184个顶点的物体,每个顶点使用3个float数(x,y,z)代表顶点坐标,再使用3个float数(nx,ny,nz)代表顶点法向量,如果还有颜色值等信息,可以在property float nz
之后加上property float blue
,property float red
,property float green
等信息。
另一个常使用的元素是face面。由于一个面是由3个以上的顶点所组成,因此使用一个“顶点列表”即可描述一个面, PLY格式使用一个特殊关键property list
定义之,表示该元素face的特性是由一行的顶点列表来描述。
上例中,property list uchar uint vertex_indices
: 列表开头以uchar
类型的数值表示列表的项目数,后面接着是类型为uint
的顶点索引值(vertex_indices),顶点索引值从0开始。
最后,标头必须以此行结尾:
档头后接着的是各属性的具体值,上例中那些数字部分。在ASCII格式中各个端点与面的属性都是以独立的一行描述,而二元编码格式则连续储存这些资料,加载时须以element
定义的元素数目以及property
中设定的属性格式计算各字段的长度。
经过分析ply模型的格式,我们可以使用文件操作,从ply文件中获取到3D图像的顶点坐标,法向量,颜色以及构成3D图像的各个面的3个顶点的坐标。注意每个ply文件的具体格式可能不同,代码也略有差异,适用于上面的ply格式的读取实例代码如下:(当然你也可以写一个类来将所有的情况考虑进去,增强程序的适用性)
int vertexNum;//顶点个数
int faceNum;//面个数
GLfloat* vertices;//各个顶点坐标数组,大小为3*vertexNum
GLuint* indices;//组成各个面的顶点的索引数组
void loadPLY(const char * filename)
{
fstream file;
file.open(filename, ios::in);
string str;
if (!file)
{
cout << "Cannot open the file: " << str << endl;
}
while (!file.eof())
{
file >> str;
if (str == "ply" || str == "comment" || str == "format" || str == "property")
{
getline(file, str, '\n');
}
else if (str == "element")
{
file >> str;
if (str == "vertex")
{
file >> vertexNum;
cout << "First we get " << vertexNum << "vertexs\n";
vertices = new GLfloat[vertexNum * 6];
getline(file, str, '\n');
}else if (str == "face")
{
file >> faceNum;
cout << "Second we get " << faceNum << " faces\n";
indices = new GLuint[faceNum * 3];
getline(file, str, '\n');
}
}
else if (str == "end_header")
{
getline(file, str, '\n');
int count;
for (int i = 0; i < vertexNum * 6; )
{
file >> vertices[i];
file >> vertices[i+1];
file >> vertices[i+2];
file >> vertices[i+3];
file >> vertices[i+4];
file >> vertices[i+5];
i += 6;
}
for (int i = 0; i < faceNum * 3; )
{
file >> count;
file >> indices[i++];
file >> indices[i++];
file >> indices[i++];
}
break;
}
}
file.close();
}
为什么ply能表示出一个三维模型,其实它将一个3维模型认为是多个三角形组合在一起构成的,所以本质上,我们就是在绘制一系列三角形,我们当然可以用很简单的glBegin(GL_TRIANGLES)
等方式画出这所有的三角形,但这未免也太过繁琐了,而且这里有很多共享顶点,如果按这种方法,一个顶点可能要处理很多次,增加渲染时间,所以我们就要使用到顶点数组来进行渲染了。
启用数组
第一个步骤是调用glEnableClientState(GLenum array)
函数激活选择的数组。目前OpenGL中可激活的数组:
GL_VERTEX_ARRAY
:顶点坐标数组GL_NORMAL_ARRAY
:法线数组GL_COLOR_ARRAY
:RGB颜色数组GL_INDEX_ARRAY
:索引颜色数组GL_TEXTURE_COORD_ARRAY
:纹理坐标数组GL_EDGE_FLAG_ARRAY
:边标志数组绑定数组的数据
我们需要将数据放入数组中,然后通过内存地址进行访问。
void glVertexPointer(GLint size, GLenum type, GLsizei stride, const GLvoid* pointer)
;
size
:每个顶点的坐标数量,一般为3;type
:指定了数组中数据的类型,比如GL_SHORT
,GL_INT
, GL_FLOAT
或GL_DOUBLE
;stride
:连续两个顶点之间的字节偏移量,如果顶点紧密相联,则为0;pointer
:数组中包含第一个顶点的第一个坐标的内存地址。glNormalPointer(GLenum type, GLsizei stride, const GLvoid* pointer)
:
type
:数组中数据的类型;stride
:连续两个法线间的字节偏移量;pointer
:法线数组指针。void glColorPointer(GLint size,GLenum type,GLsizei stride, const GLvoid* pointer)
同上。渲染
数组中的内容需要发送到图形处理管线进行渲染,在这里有多种方式进行解引用。
glDrawElements (GLenum mode, GLsizei count, GLenum type, const GLvoid *indices)
mode
:要绘制的图元类型,如GL_TRAINGLES
;count
:索引数组的大小;type
:索引数组的数据类型;indices
:索引数组地址。实例:
void display(void)
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(0.0f, 0.0f, 10.0f, 0.0f, 0.0f, 0.0f, 0, 1, 0);
glLightfv(GL_LIGHT0, GL_POSITION, LightPosition);
glPushMatrix();
glTranslatef(0.0f, -1.0f, 0.0f);
glRotatef(mousePosX, 0, 1, 0);
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
//顶点坐标和法线都存放在vertices里,所以偏移量为6*sizeof(GLfloat),vertex在前,normal在后,所以法线的头指针为(GLfloat *)(vertices + 3)
glVertexPointer(3, GL_FLOAT, 6 * sizeof(GLfloat), vertices);
glNormalPointer(GL_FLOAT, 6 * sizeof(GLfloat), (GLfloat *)(vertices + 3));
glDrawElements(GL_TRIANGLES, faceNum*3, GL_UNSIGNED_INT, indices);
glPopMatrix();
glutSwapBuffers();
}
另一种绘制ply模型的方式是使用顶点缓冲对象(Vertex Buffer Objects, VBO)
在绘制图像之前,我们会给OpenGL输入一些顶点数据,把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。VBO就是用来管理这个内存,它会在GPU内存中储存大量顶点。使用VBO好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
VBO是OpenGL中的一个缓冲对象,有一个独一无二的ID,所以我们可以使用glGenBuffers
函数和一个缓冲ID生成一个VBO对象,VBO对象的缓冲类型是GL_ARRAY_BUFFER
,使用glBindBuffer
函数把新创建的缓冲绑定到GL_ARRAY_BUFFER
目标上:
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER
目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData
函数,它会把之前定义的顶点数据(也就是从ply模型中读取到的顶点数据)复制到缓冲的内存中:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData
是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。
GL_ARRAY_BUFFER
目标上;sizeof
计算出顶点数据大小就行;GL_STATIC_DRAW
:数据不会或几乎不会改变。
GL_DYNAMIC_DRAW
:数据会被改变很多。
GL_STREAM_DRAW
:数据每次绘制时都会改变。
例如:模型的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW
。如果说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW
或GL_STREAM_DRAW
,这样就能确保显卡把数据放在能够高速写入的内存部分。
现在我们已经把顶点数据储存在显卡的内存中,用VBO这个顶点缓冲对象管理。
(之前有介绍过OpenGL渲染管道和着色器的编写与编译链接,这里主要介绍glsl 3.3.0的着色器语法)
//顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
out vec3 FragPos;
out vec3 Normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
FragPos = vec3(model * vec4(aPos, 1.0));
Normal = mat3(transpose(inverse(model))) * aNormal;
gl_Position = projection * view * vec4(FragPos, 1.0);
}
#version 330 core
:版本申明,使用glsl 3.3.0,并且使用核心模式。layout (location = 0) in vec3 aPos;
:in
关键字,表明aPos
是输入的数据,它的类型为vec3
(即顶点的3维坐标),通过layout (location = 0)
设定了输入变量的位置值(Location
)你 后面会看到为什么我们会需要这个位置值。layout (location = 1) in vec3 aNormal;
类型为vec3
的输入数据aMormal
(顶点的法线向量),位置值为1。out vec3 FragPos;out vec3 Normal;
:out
关键字,表明这两个是输出变量,传递给渲染管道的下一阶段(片段着色器)。uniform ...;
:uniform
是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform
和顶点属性有些不同。首先,uniform
是全局的,意味着uniform
变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform
值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。uniform
变量需要我们在OpenGL程序中为其赋值:使用glGetUniformLocation
函数可以找到着色器中uniform
变量的位置值,并使用glUniform...
函数为不同类型的uniform
变量赋值。gl_Position
:为了设置顶点着色器的输出,我们必须把位置数据赋值给预定义的gl_Position
变量,它在幕后是vec4
类型的。由于FragPos
是一个3分量的向量,必须转换为4分量的。
//片段着色器
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 FragPos;
uniform vec3 lightPos; // 在OpenGL程序代码中设定这个变量
uniform vec3 viewPos;
uniform vec3 lightColor;
uniform vec3 objectColor;
void main()
{
// ambient
float ambientStrength = 0.3;
vec3 ambient = ambientStrength * lightColor;
// diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
// specular
float specularStrength = 0.6;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = specularStrength * spec * lightColor;
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
}
in vec3 FragPos;in vec3 Normal;
:承接从顶点着色器中输出的变量Normal;FragPos
,注意他们要名称相同、类型相同。out vec4 FragColor;
:片段着色器输出一个类型为vec4
的变量FragColor
,供下一渲染阶段使用(测试与混合使用)。main
中代码就是在根据光照模型计算顶点的颜色。上述着色器是Per-Pixel shading
顶点着色器允许我们指定任何以顶点属性为形式的输入,所以我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。
我们之前绑定在VBO中数据vertices
是从ply中读入的,它其实是一个GLfloat
类型的数组,依次表示第一个顶点的x、y、z、nx、ny、nz,第二个顶点的x、y、z、nx、ny、nz…。但顶点着色器并不知道是他们代表什么,只是二进制字符串罢了,所以使用glVertexAttribPointer
函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)了:
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// normal attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
layout(location = 0)
定义了aPos
顶点属性的位置值为0,layout(location = 1)
定义了aNormal
顶点属性的位置值为1吗?当我们要把数据传递到哪一个顶点属性中,就在此写入哪一个顶点属性的位置值。vec3
,它由3个值组成,所以大小是3。vec*
都是由浮点数值组成的)。float
之后,我们把步长设置为6 * sizeof(float)
。aPos
数据在数组的开头,所以这里是0,而aNormal
数据的起始位置便宜了三个单位,即为3 * sizeof(float)
。glEnableVertexAttribArray
,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。注:每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVertexAttribPointer
时绑定到GL_ARRAY_BUFFER
的VBO决定的。
在OpenGL中绘制一个物体,代码会像是这样:
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();
每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢(这其实并不罕见)。绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?这就用到了VAO。
顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用编写一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中
注意:OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。
一个顶点数组对象会储存以下这些内容:
glEnableVertexAttribArray
和glDisableVertexAttribArray
的调用。glVertexAttribPointer
设置的顶点属性配置。glVertexAttribPointer
调用与顶点属性关联的顶点缓冲对象VBO。VAO 的创建与VBO很类似,绑定使用glBindVertexArray
:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
从绑定之后起,我们应该绑定和配置对应的VBO和顶点属性指针,然后解绑VAO供之后使用。当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把绑定我们要用的VAO就行了。
索引缓冲对象(Element Buffer Object,EBO),就像在之前所讲的,一个物体通常是由三角形来组成的(OpenGL主要处理三角形)。举个例子,绘制两个三角形来组成一个矩形,这会生成下面的顶点的集合:
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
可以看到,有几个顶点叠加了。我们指定了右下角
和左上角
两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。当我们有包括上千个三角形的模型之后这个问题会更糟糕,这会产生一大堆浪费。更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。这样子我们只要储存4个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。
索引缓冲对象的工作方式正是这样的。ply格式的模型中刚好提供这些三角形的顶点索引,读取到indices数组中。和顶点缓冲对象一样,EBO也是一个缓冲,专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。
EBO的创建和绑定与VBO也类似,只不过这次我们把缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER
:
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
在最后绘制的使用也是调用glDrawElements
,它会使用当前绑定的索引缓冲对象中的索引直接进行绘制,所以与之前不同的是最后一个参数只用写0,不用在传入索引数组了。
glDrawElements(GL_TRIANGLES, faceNum*3, GL_UNSIGNED_INT, 0);