教程 27
公告牌技术与几何着色器
原文: http://ogldev.atspace.co.uk/www/tutorial27/tutorial27.html
CSDN完整版专栏: https://blog.csdn.net/cordova/article/category/9266966
背景
从最初的一系列教程我们已经应用过了顶点着色器和片段着色器,但事实上我们还忽略了一个非常重要的着色阶段,叫做几何着色器(GS)。几何着色器在微软的DirectX 10之后被引入,之后也加入到了核心的OpenGL 3.2中。顶点着色器中是按照顶点一个一个执行的,片段着色器则是一个像素一个像素执行,而几何着色器是以图元为单位执行,这意味着在我们绘制三角形时,每次调用几何着色器接收到的就是一个三角形,而绘制直线每次调用收到的就是一条直线,等等。这就给几何着色器提供了一个看待模型的独特的角度,开发者可以知道顶点和顶点之间拓扑关系,从而可以基于此开发一些新的技术。
顶点着色器总是以一个顶点作为输入,并对应一个顶点作输出(不可以自行增加或减少顶点),而几何着色器却有着特殊的功能,它可以改变经过它的图元,这种改变包括:
- 改变新传递进来的图元的拓扑结构。几何着色器可以接收任何拓扑类型的图元,但只能输出顶点列表(point lists)、折线(line strip)和三角带(triangle strips);
- 几何着色器接受一个图元作为输入,在处理过程中它可以将这个图元全部丢弃或者输出一个或更多的图元(也就是说它可以产生比它得到的更多或更少的顶点)。这个能力被叫做几何增长(growing geometry)。这一章我们将会利用几何着色器的这种能力。
几何着色器是可选择的。如果我们编译程序的时候不使用几何着色器,图元会直接的从顶点着色器进入片元着色器。这就是为什么我们之前并没有使用顶点着色器,却可以直接跳过该阶段正常绘制出图形。
三角形列表中的三角形图元是以每三个顶点一组构建的,例如,0-2前三个顶点构建起第一个三角形,3-5三个顶点构建起第二个三角形,以此类推。为了计算已有顶点所组成的三角形个数,只要用顶点数除以3即可(多余的顶点直接抛弃)。但事实上,使用三角形带构建更有效,不需要每个三角形都用专门的三个顶点构建,而是在使用三个顶点将第一个三角形构建完成后,重复利用其中的两个顶点,然后再添加一个顶点即可构建第二个三角形。例如0-2三个顶点构建起一个三角形后,再添加一个顶点3,1-3三个顶点构成第二个三角形,这样0-3四个顶点即可紧密拼接构建起两个三角形,以此类推,再添加顶点4,2-4三个顶点又构成一个三角形等等。也就是说,在第一个三角形使用三个顶点构建完成后,每天添加一个顶点即可再构成一个三角形。这里有一个例子如下图:
可以看到,三角带中7个三角形只用了9个顶点,要是在三角形列表中,9个顶点就只能构建3个三角形。
三角带有一个有关三角形内部环绕顺序的重要性质:奇数三角形的环绕顺序是反向的。这就意味着如下的顺序:[0,1,2],[1,3,2], [2,3,4], [3,5,4],以此类推。下面的图片显示了这个顺序:
了解了几何着色器之后,现在看如何利用几何着色器来实现一种非常有用的热门技术:公告板技术(billboarding)。公告板是一个始终朝向相机的四边形,当相机在场景中转动的时候,公告板也会随着相机转动保证相机方向向量始终垂直于公告板的正面。这个和现实中公路边的公告板类似,公告板的放置方向会让尽可能多路过的开车司机看到。有了这种面向相机的四边形之后,我们就很容易将怪物角色、树木等任何场景中重复性高、数量多的物体以纹理贴图的形式直接贴到四边形公告板上(而不需要复杂的计算和渲染实际的3d模型),始终朝向相机。公告板常常用来创建需要大量树木的森林效果,由于公告板始终朝向相机,玩家会误以为看到的物体是有实际深度的,而事实上单纯就是个平面而已。每一个公告板只需要4个顶点,因此比起使用大量实际的3d模型代价就小得多了。
在这个教程中,我们创建一个顶点缓冲器,并为公告板存放顶点的世界空间坐标,每一个坐标就是一个单独的3维坐标点。我们会将这些顶点坐标传送到几何着色器,然后构建相应的四边形作为公告板。这意味着几何着色器会输入顶点列表,然后输出三角形带。利用三角带的优点我们可以使用四个顶点创建出一个四边形。
几何着色器会负责调整四边形始终朝向相机,并为每一个输出的顶点附加纹理坐标,这样片段着色器只需要直接从纹理上采样就可以得到最终的颜色信息。
现在看怎样让公告板总是朝向相机。在下图中黑点表示相机,红点表示公告板的位置。虽然图中位置看上去好像在一个平面上,但实际两个点都在世界空间下,世界空间中任意两个点都是可能的。
这里创建一个从公告板到相机的向量:
然后添加一个向量(0,1,0):
对这两个向量做叉乘,结果得到一个垂直于这两个向量所在平面的向量,然后就要沿着这个结果向量的方向来扩展创建我们的四边形,保证四边形平面和相机朝向垂直,这样才符合我们想要的结果。同样的场景下我们可以得到下面这样(黄色向量是叉乘的结果向量):
一个容易让开发者疑惑的事情是关于向量做叉积的顺序问题(A叉积B,还是B叉积A?),两种情况会得到两个反向的结果向量。事先知道具体的结果向量是很关键的,因为我们要这样输出顶点,使从相机的视角看时三角形组成的四边形呈顺时针方向。这里要用到左手法则了:
如果站在公告板的位置(红点),食指指向相机,中指指向上方的天空,然后你的大拇指将会沿着“食指”和“中指”叉乘的结果的方向(这里剩下的两个手指保持握紧)。此教程中,我们将叉乘的结果称为“右”向量,因为从相机位置看我们的手,‘右’向量是指向右侧的。反过来,“中指”叉乘“食指”又可以产生一个反向的“左”向量。(这里我们使用左手定则的原因是因为我们使用的是左手坐标系(Z轴指向屏幕内))。在右手坐标系中情况就相反了,那样就该选用右手坐标系了。
源代码详解
(billboard_list.h:27)
class BillboardList
{
public:BillboardList();~BillboardList();bool Init(const std::string& TexFilename);void Render(const Matrix4f& VP, const Vector3f& CameraPos);private:void CreatePositionBuffer();GLuint m_VB;Texture* m_pTexture;BillboardTechnique m_technique;
};
BillboardList类封装了创建公告板需要的所有东西,初始化函数Init()的参数为一个文件名,文件就是那个作为纹理贴图贴到公告板上的图像。Render()渲染函数在主渲染循环中被调用,负责设置状态和渲染公告板。这个函数需要两个参数:一个是视图和投影组合矩阵,一个是相机的世界坐标位置。由于公告板的位置也是定义在世界空间中的,所以我们才直接到了视图和投影阶段,跳过世界空间变换的部分。这个类有三个私有属性:一个存储公告板位置的顶点缓冲器,一个指向公告板纹理贴图的指针,和一个包含相关着色器的BillboardTechnique公告板类。
(billboard_list.cpp:80)
void BillboardList::Render(const Matrix4f& VP, const Vector3f& CameraPos)
{m_technique.Enable();m_technique.SetVP(VP);m_technique.SetCameraPosition(CameraPos);m_pTexture->Bind(COLOR_TEXTURE_UNIT);glEnableVertexAttribArray(0);glBindBuffer(GL_ARRAY_BUFFER, m_VB);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vector3f), 0); // position glDrawArrays(GL_POINTS, 0, NUM_ROWS * NUM_COLUMNS);glDisableVertexAttribArray(0);
}
这个函数启用了BillboardTechnique公告板类,并设置了OpenGL中一些必要的状态,绘制顶点且这些顶点之后会在几何着色器阶段转化成四边形面。在这个Demo中,公告板位置是按照严格的行列顺序排列的,因此我们可以行列数相乘得到顶点的数量。需要注意,我们在绘制的时候使用的绘制模式为点模式(GL_POINTS),在几何着色器中要与其对应。
(billboard_technique.h:24)
class BillboardTechnique : public Technique
{
public:BillboardTechnique();virtual bool Init();void SetVP(const Matrix4f& VP);void SetCameraPosition(const Vector3f& Pos);void SetColorTextureUnit(unsigned int TextureUnit);private:GLuint m_VPLocation;GLuint m_cameraPosLocation;GLuint m_colorMapLocation;
};
这里是BillboardTechnique的类接口,它只需要三个参数来完成下面的任务:视图和投影组合矩阵、相机世界空间位置和与公告板绑定的纹理单元的数量。
(billboard.vs)
#version 330
layout (location = 0) in vec3 Position;void main()
{gl_Position = vec4(Position, 1.0);
}
这是公告板类的顶点着色器,由于主要工作都将在几何着色器中完成,因此在这里顶点着色器就不要太简单了哈哈。顶点缓冲器只包含顶点坐标向量,而且这些坐标已经在世界空间中定义了,所以可以直接将它们传给几何着色器,即可。
(billboard.gs:1)
#version 330layout (points) in;
layout (triangle_strip) out;
layout (max_vertices = 4) out;
公告板技术的核心就在几何着色器了,我们分解开一步步来看。开始我们先使用‘layout’关键字声明一些全局缓冲器。我们要先告诉渲染管线输入来的参数结构是点列表,输出的是三角带,并且说明输出的顶点个数最多为4个。这些关键词也会提示图形驱动器从几何着色器输出顶点的最大个数,提前知道顶点个数上限可以给驱动器机会来优化几何着色器在某些特定情况下的动作。我们知道对于每一个输入的顶点要输出的是一个扩展的四边形,因此我们设置最大顶点数为4。
(billboard.gs:7)
uniform mat4 gVP;
uniform vec3 gCameraPos;out vec2 TexCoord;
几何着色器得到了世界空间坐标,因此他只需要一个视图和投影组合变换矩阵即可。另外还需要知道相机的位置来计算如何让公告板始终朝向它。几何着色器为片段着色器创建出了纹理坐标,因此我们也要声明纹理坐标变量。
(billboard.gs:12)
void main()
{vec3 Pos = gl_in[0].gl_Position.xyz;
上面一行代码是针对几何着色器独有的。由于在几何着色器中执行的是一个完整的图元,因此事实上我们可以访问组成图元的每一个顶点,这个通过内置的‘gl_in’变量实现。这个变量是一个结构体数组,每个结构体都包含了写入到顶点着色器gl_Position中的位置信息。为了访问顶点信息,我们可以使用要访问的顶点在图元中的索引找到。在这个特定的例子中,参数的输入结构为点列表,所以每个图元只有一个可访问的单独的点,可以使用'gl_in[0]'获取它。如果输入结构是个三角形,我们可能还会使用'gl_in[1]'和'gl_in[2]'来访问其他点。我么只需要使用顶点位置向量的前三个xyz分量,通过本地变量'.xyz'提取。
vec3 toCamera = normalize(gCameraPos - Pos);vec3 up = vec3(0.0, 1.0, 0.0);vec3 right = cross(toCamera, up);
这里我们利用文章开始背景部分结尾的原理实现让公告板朝向相机。我们将当前公告板的位置点到相机位置的向量和垂直向上的方向向量做叉积,得到从相机看公告板视角的‘右’向量,然后我们要使用这个向量围着公告板的位置扩展一个四边形面。
Pos -= (right * 0.5);gl_Position = gVP * vec4(Pos, 1.0);TexCoord = vec2(0.0, 0.0);EmitVertex();Pos.y += 1.0;gl_Position = gVP * vec4(Pos, 1.0);TexCoord = vec2(0.0, 1.0);EmitVertex();Pos.y -= 1.0;Pos += right;gl_Position = gVP * vec4(Pos, 1.0);TexCoord = vec2(1.0, 0.0);EmitVertex();Pos.y += 1.0;gl_Position = gVP * vec4(Pos, 1.0);TexCoord = vec2(1.0, 1.0);EmitVertex();EndPrimitive();
}
顶点缓冲器中的点可以被认为是四边形底边的中点,我们要从中点创建两个面朝相机的正面三角形。开始先用中点减去‘右’向量的一半,从而得到四边形的左下角。然后通过乘以视图和投影组合变换矩阵计算该点在裁剪空间的位置,并设置该点的纹理坐标为(0,0),便于将整个纹理完整贴到这个平面上。为了将新产生的顶点传递到管线的下一个阶段,我们需要调用内置的EmitVertex()函数。这个函数调用后,我们之前写入gl_Position的数据就无效了,因此我们要为其设置新值。和左下角点产生方法类似的,我们继续创建出四边形左上角和右下角的点,这样三个点就构建出了第一个正面三角形。由于几何着色器的输出是三角带,之后我们只需要另外一个顶点即可构建第二个三角形,使用前面三角形的后两个顶点(四边形的对角线)和新顶点构建。第四个新顶点也是最后一个顶点,即四边形的右上角。结束三角带的构建要调用内置的EndPrimitive()函数。
(billboard.fs)
#version 330uniform sampler2D gColorMap;in vec2 TexCoord;
out vec4 FragColor;void main()
{FragColor = texture2D(gColorMap, TexCoord);if (FragColor.r == 0 && FragColor.g == 0 && FragColor.b == 0) {discard;}
}
片段着色器很简单,它的主要工作是使用几何着色器创建的纹理坐标进行纹理采样。这里有一个新特性:内置的关键字'discard'用于在某些情况下将某些像素片元完全丢弃。这个教程中我们用了Doom中地狱骑士的图片,展示黑色背景下的怪物场景,但是直接贴上整张图片会有黑色的背景,就是说公告板内容比怪物要大,怪物背景不是透明的,我们不希望这样,因此我们可以检测文素的颜色,如果是黑色就直接抛弃该像素,这样就会只显示怪物了。可以尝试注释掉'discard'语句看效果有什么不同。