RGBA颜色

现实世界中有无数种颜色,然而我们使用计算机模拟颜色并不可能实现无数种。 颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,A(Alpha)表示透明度。缩写为RGBA。

  • 16位色: 有RGB 555,RGB 565两种分配方式。

  • RGB24: 一般RGB一种颜色用8位表示,即共有0-255,256级亮度。256级的RGB色彩总共能组合出约1678(256×256×256)万种色彩。也称为24位色。

  • RGB32: RGB32使用32位来表示一个像素,RGB分量各用去8位,剩下的8位用作Alpha通道或者不用。

  • 16进制表示: 颜色也可以用16进制表示,8位颜色对应2位16进制数。 如“#FF0000”为红色,FF就是255。

CMYK 颜色

除了RGB模式,还有CMY颜色模式。 CMY模式是指采用青色(Cyan)、品红色(Magenta)、黄色(Yellow)。K:定位套版色(黑色),有些地方解释K指代Black黑色。 每种颜色分量的取值范围为0~100。 在CMY模型中,显示的色彩不是直接来自于光线的色彩,而是光线被物体吸收掉一部分之后反射回来的剩余光线所产生的。因此,光线都被吸收时显示为黑色,当光线完全被反射时显示为白色。

RGB是相加混色模式,是一种发光的色彩模式,每种颜色分量越多,得到的颜色越亮,通常用于计算机显示。 CMY是相减混色模式,是一种依靠反光的色彩模式。多用在印刷品。

HSB模式

我们还常见HSB模式。其中,H(hues)表示色相,S(saturation)表示饱和度,B(brightness)表示亮度。 HSB模式对应的媒介是人眼。

色相(H,hue):在0~360°的标准色轮上,色相是按位置度量的。 饱和度(S,saturation):表示色彩的纯度,为0时为灰色。白、黑和其他灰色色彩都没有饱和度的。在最大饱和度时,每一色相具有最纯的色光。取值范围0~100%。 亮度(B,brightness或V,value):是色彩的明亮度。为0时即为黑色。最大亮度是色彩最鲜明的状态。取值范围0~100%。

OpenGL 颜色

现实中看到的物体颜色并不是这个物体的真实颜色,而是它所反射(Reflected)的颜色。换句话说,那些不能被物体吸收(Absorb)的颜色(被反射的颜色)就是我们能够感知到的物体的颜色。 例如,太阳光被认为是由许多不同的颜色组合成的白色光(如下图所示)。如果我们将白光照在一个蓝色的玩具上,这个蓝色的玩具会吸收白光中除了蓝色以外的所有颜色,不被吸收的蓝色光被反射到我们的眼中,使我们看到了一个蓝色的玩具。下图显示的是一个珊瑚红的物体,它以不同强度的方式反射了几种不同的颜色。

正如你所见,白色的阳光是一种所有可见颜色的集合,上面的物体吸收了其中的大部分颜色,它仅反射了那些代表这个物体颜色的部分,这些被反射颜色的组合就是我们感知到的颜色(此例中为珊瑚红)。

这些颜色反射的规律被直接地运用在图形领域。我们在OpenGL中创建一个光源时都会为它定义一个颜色。当我们把光源的颜色与物体的颜色相乘,所得到的就是这个物体所反射该光源的颜色(也就是我们感知到的颜色)。 我们来定义一个珊瑚红色的玩具,一个白色的光源,计算反射颜色来看看:

1
2
3
glm::vec3 lightColor(1.0f, 1.0f, 1.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (1.0f, 0.5f, 0.31f);

可以看到物体对红、黄、蓝都进行了不同程度的反射,这是由物体本身的颜色决定的。这也代表着现实中的光线原理。由此,我们可以定义物体的颜色为这个物体从一个光源反射各个颜色分量的多少。现在,如果我们使用一束绿色光源呢?

1
2
3
glm::vec3 lightColor(0.0f, 1.0f, 0.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (0.0f, 0.5f, 0.0f);

因为没有红色和蓝色的光让它吸收或反射,物体变成了深绿色。再把光源换个颜色:

1
2
3
glm::vec3 lightColor(0.33f, 0.42f, 0.18f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (0.33f, 0.21f, 0.06f);

如你所见,我们可以通过物体对不同颜色光的反射来的得到意想不到的不到的颜色,创作颜色变得非常简单。


参考: LearnOpenGL-CN 颜色

过渡坐标系

OpenGL希望在所有顶点着色器运行后,所有我们可见的顶点都变为标准化设备坐标(Normalized Device Coordinate, NDC)。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标转换为NDC。然后将这些NDC传入光栅器(Rasterizer),再将他们转换为屏幕上的二维坐标或像素。

这一过程通常是分步实现的,在流水线里面我们会将对象转换到多个坐标系统,即过渡坐标系(Intermediate Coordinate System)。将对象的坐标转换到几个过渡坐标系的优点在于,在这些特定的坐标系统中进行一些操作或运算更加方便和容易。比较重要的总共有5个不同的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

这些就是我们将所有顶点转换为片段之前,顶点需要处于的不同的状态。

概述

为了将坐标从一个坐标系转换到另一个坐标系,我们需要用到几个转换矩阵,最重要的几个分别是模型(Model)、视图(View)、投影(Projection)三个矩阵。下面的图示显示了整个流程及各个转换过程做了什么:

之所以将顶点转换到各个不同的空间的原因,是有些操作在特定的坐标系统中才有意义且更方便。例如,当修改对象时,如果在局部空间中则是有意义的;当对对象做相对于其它对象的位置的操作时,在世界坐标系中则是有意义的。我们本可以定义一个直接从局部空间到裁剪空间的转换矩阵,但那样会失去灵活性。接下来我们将要更仔细地讨论各个坐标系。

局部空间(Local Space)

局部空间是指对象所在的坐标空间,如对象最开始所在的地方。你的模型的所有顶点都是在局部空间:他们相对于你的对象来说都是局部的。

世界空间(World Space)

世界空间中的坐标是指顶点相对于(游戏)世界的坐标。物体变换到的最终空间就是世界坐标系,物体会在世界坐标中分散摆放。对象的坐标将会从局部坐标转换到世界坐标,该转换是由模型矩阵(Model Matrix)实现的。

模型矩阵是一种转换矩阵,它能通过对对象进行平移、缩放、旋转来将它置于它本应该在的位置或方向。如导入一个房子的模型,通过对它进行缩放、平移、旋转等转换将其置于 正好位于游戏场景中马路边街区的位置。

观察空间(View Space)

观察空间经常称为OpenGL的摄像机(Camera),所以有时也称为摄像机空间(Camera Space)或视觉空间(Eye Space)。 观察空间就是将对象的世界空间的坐标转换为观察者视野(摄像机)前面的坐标。而这通常是由一系列转换组合来平移和旋转场景从而使得特定的对象被转换到摄像机前面。这些组合在一起的转换通常存储在一个观察矩阵(View Matrix)里,用来将世界坐标转换到观察空间。

裁剪空间(Clip Space)

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个给定的范围内,且任何在这个范围之外的点都应该被裁剪(Clipped)

因为将所有可见的坐标都放置在-1.0到1.0的范围内不是很直观,所以我们会指定自己的坐标集(Coordinate Set)并将它转换回标准化设备坐标系,就像OpenGL期望它做的那样。

为了将顶点坐标从观察空间转换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了坐标的范围。投影矩阵接着会将在它指定的范围内的坐标转换到标准化设备坐标系中(-1.0,1.0)。

由投影矩阵创建的观察区域(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将一定范围内的坐标转化到标准化设备坐标系的过程被称之为投影(Projection),因为使用投影矩阵能将3维坐标投影(Project)到很容易映射的2D标准化设备坐标系中。

一旦所有顶点被转换到裁剪空间,最终的操作—透视划分(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视划分是将4维裁剪空间坐标转换为3维标准化设备坐标。这一步会在每一个顶点着色器运行的最后被自动执行。

在这一阶段之后,坐标经过转换的结果将会被映射到屏幕空间(由glViewport设置)且被转换成片段。

投影矩阵将观察坐标转换为裁剪坐标的过程采用两种不同的方式,每种方式分别定义自己的平截头体。我们可以创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)

  • 正射投影(Orthographic Projection) 正射投影矩阵定义了一个类似立方体的平截头体。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。它的平截头体看起来像一个容器:

    上面的平截头体定义了由宽、高、近平面和远平面决定的可视的坐标系。 正视平截头体直接将平截头体内部的顶点映射到NDC中,因为每个向量的w分量都是不变的;如果w分量等于1.0,则透视划分不会改变坐标的值。

    正射投影矩阵直接将坐标映射到屏幕的二维平面内,但这会产生不真实的结果,因为这个投影没有将透视(Perspective)考虑进去。所以我们需要透视投影矩阵来解决这个问题。

  • 透视投影(Perspective Projection) 我们都知道近大远小的现象,这个效果我们称之为透视。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:

    透视效果

    由于透视的原因,平行线似乎在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。 这个投影矩阵不仅将给定的平截头体范围映射到裁剪空间,同样还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被转换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的对象都会被裁剪掉)。一旦坐标在裁剪空间内,透视划分就会被应用到裁剪空间坐标:

    每个顶点坐标的分量都会除以它的w分量,得到一个距离观察者的较小的顶点坐标。这是也是另一个w分量很重要的原因,因为它能够帮助我们进行透射投影。最后的结果坐标就是处于标准化设备空间内的。

    透视平截头体的如下所示:

    fov表示的是视野(Field of View),设置了观察空间的大小。对于一个真实的观察效果,它的值经常设置为45.0。所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染。

把它们都组合到一起

我们为上述的每一个步骤都创建了一个转换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点的坐标将会根据以下过程被转换到裁剪坐标:

注意每个矩阵被运算的顺序是相反的(需要从右往左乘上每个矩阵)。最后的顶点应该被赋予顶点着色器中的gl_Position且OpenGL将会自动进行透视划分和裁剪。


参考: LearnOpenGL-CN 坐标系统(Coordinate System)

纹理

我们可以为每个顶点使用颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点和顶点颜色。

更多的时候是使用纹理。纹理是一个2D图片(也有1D和3D),它用来添加物体的细节。因为我们可以在一张图片上插入足够多的细节,这样物体就会拥有很多细节而不用增加额外的顶点。

纹理坐标

为了能够把纹理映射到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会有一个纹理坐标(Texture Coordinate),它指明从纹理图像的哪个地方采样(采集像素颜色)。之后在所有的其他的片段上进行片段插值(Fragment Interpolation)。

纹理坐标是x和y轴上0到1之间的范围(注意我们使用的是2D纹理图片)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0,0)也就是纹理图片的左下角,终结于纹理图片的右上角(1,1)。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。

我们为三角形准备了3个纹理坐标点。我们只要传递这三个纹理坐标给顶点着色器就行了,接着片段着色器会为每个片段生成纹理坐标的插值。 纹理采样有几种不同的插值方式。我们需要自己告诉OpenGL在纹理中采用哪种采样方式。

纹理环绕方式(Texture Wrapping)

纹理坐标通常的范围是从(0,0)到(1,1),如果我们把纹理坐标设置为范围以外,OpenGL默认的行为是重复这个纹理图像(我们简单地忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:

纹理环绕方式

效果如下:

纹理环绕方式的效果

纹理过滤(Texture Filtering)

纹理坐标不依赖于解析度,它可以是任何浮点数值,这样OpenGL需要描述出哪个纹理像素(Texture Pixel,也叫Texel)对应哪个纹理坐标。当你有一个很大的物体但是纹理解析度很低的时候这就变得很重要了。OpenGL也有一个叫做纹理过滤的选项。有多种不同的选项可用,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。

GL_NEAREST(Nearest Neighbor Filtering,邻近过滤)是一种OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL选择最接近纹理坐标中心点的那个像素。下图你会看到四个像素,加号代表纹理坐标。左上角的纹理像素是距离纹理坐标最近的那个,这样它就会选择这个作为采样颜色:

GL_NEAREST过滤方式

GL_LINEAR((Bi)linear Filtering,线性过滤)它会从纹理坐标的临近纹理像素进行计算,返回一个多个纹理像素的近似值。一个纹理像素距离纹理坐标越近,那么这个纹理像素对最终的采样颜色的影响越大。下面你会看到临近像素返回的混合颜色:

GL_LINEAR过滤方式

不同的纹理过滤方式有怎样的视觉效果呢?让我们看看当在一个很大的物体上应用一张地解析度的纹理会发生什么吧(纹理被放大了,纹理像素也能看到):

两种过滤方式的效果

可以看到,因为GL_NEAREST只选择一个临近的像素,所以产生了许多小格子,能够清晰看到纹理由像素组成。而GL_LINEAR因为进行了计算,会产生出更平滑的样式,看不出纹理像素。

纹理过滤可以为放大和缩小设置不同的选项,这样你可以在纹理被缩小的时候使用最临近过滤,被放大时使用线性过滤。

多级渐远纹理(Mipmaps)

想象一下,如果我们在一个有着上千物体的大房间,每个物体上都有纹理。距离观察者远的与距离近的物体的纹理的解析度是相同的。由于远处的物体可能只产生很少的片段,OpenGL从高解析度纹理中为这些片段获取正确的颜色值就很困难。这是因为它不得不为一个纹理跨度很大的片段取纹理颜色。在小物体上这会产生人工感,更不用说在小物体上使用高解析度纹理浪费内存的问题了。

OpenGL使用一种叫做 多级渐远纹理(Mipmap) 的概念解决这个问题,大概来说就是一系列纹理,后面的一个纹理是前一个的二分之一。多级渐远纹理背后的思想很简单:距离观察者更远距离的一段阈值,OpenGL会把最适合这个距离的物体的不同的多级渐远纹理纹理应用其上。由于距离远,解析度不高也不会被使用者注意到。同时,多级渐远纹理另一优点是执行效率不错。多级渐远纹理纹理:

Mipmap示例

OpenGL渲染的时候,两个不同级别的多级渐远纹理之间会产生不真实感的生硬的边界。就像普通的纹理过滤一样,也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。指定不同多级渐远纹理级别之间的过滤方式可以使用下面四种选项代替原来的过滤方式:


参考: LearnOpenGL-CN 纹理(Textures)

图形渲染管线(Pipeline)

图形渲染管线(Pipeline),指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。

OpenGL的大部分工作都是关于如何把3D坐标转变为适应屏幕的2D像素。这一处理过程是就由图形渲染管线管理的。它可以被划分为两个主要部分:第一个部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

注:2D坐标和像素也是不同的,2D坐标是在2D空间中的一个点的非常精确的表达,2D像素是这个点的近似值,它受到你的屏幕/窗口解析度的限制。

图形渲染管线可以被划分为几个阶段,每个阶段需要把前一个阶段的输出作为输入。在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

有些着色器允许开发者自己配置,这样我们就可以更细致地控制图形渲染管线中的特定部分了,因为它们运行在GPU上,所以它们会节约宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的。

下图是一个图形渲染管线的每个阶段的抽象表达。蓝色部分代表的是我们可以自定义的着色器。 渲染管线

顶点属性(Vertex Attributes):

顶点数据(Vertex Data)是一些顶点的集合。一个顶点是一个3D坐标(也就是x、y、z数据)。三个3D坐标组成一个三角形。而顶点数据是用顶点属性(Vertex Attributes)表示的,它可以包含任何我们希望用的数据。

基本图元(Primitives):

一维或二维实体(点、线、多边形)。这些实体用来在3D空间中创建3D实体。 OpenGL需要知道我们的坐标和颜色值构成的具体是什么,点、三角形还是线?构成的这些便是基本图元,任何一个绘制命令的调用都必须把基本图形类型传递给OpenGL。这是其中的几个:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP。

顶点着色器(Vertex Shader):

顶点着色器主要的目的是把3D坐标转为另一种3D坐标(后面会解释),同时允许我们对顶点属性进行一些基本处理。

基本图形装配(Primitive Assembly):

把顶点着色器的表示为基本图元的所有顶点作为输入,把所有点组装为特定的基本图元的形状。

几何着色器(Geometry Shader):

把基本图元形式的顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其他的)基本图元来生成其他形状。

细分着色器(Tessellation Shaders):

拥有把给定基本图元细分为更多小基本图形的能力。这样我们就能在物体更接近玩家的时候通过创建更多的三角形的方式创建出更加平滑的视觉效果。

光栅化(Rasterization,也译为像素化):

它会把基本图形映射为屏幕上相应的像素,生成供片段着色器使用的片段(Fragment)。在片段着色器运行之前,会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的那些像素,来提升执行效率。

片段着色器(Fragment Shader):

片段着色器的主要目的是计算一个像素的最终颜色,这也是OpenGL高级效果产生的地方。通常,片段着色器包含用来计算像素最终颜色的3D场景的一些数据(比如光照、阴影、光的颜色等等)。

在所有相应颜色值确定以后,最终会进入alpha测试和混合(Blending)阶段。这个阶段检测像素的相应的深度和Stencil值,来检查这个像素是否在另一个物体的前面或后面。也会检查alpha值(透明度值)和物体之间的混合(Blend)。

图形渲染管线非常复杂,然而,对于大多数场合,我们必须做的只是顶点和片段着色器(因为GPU中没有默认的顶点/片段着色器)。几何着色器和细分着色器是可选的,通常使用默认的着色器就行了。

顶点输入

标准化设备坐标(Normalized Device Coordinates,NDC):

开始绘制一些东西之前,我们必须给OpenGL输入一些顶点数据。只有当3个轴(x、y和z)在特定的-1.0到1.0的范围内时OpenGL才处理。所有在这个范围内的坐标叫做标准化设备坐标(Normalized Device Coordinates,NDC),会最终显示在你的屏幕上(所有出了这个范围的都不会显示)。

如果我们要渲染一个2D三角形,它的顶点可以如此定义:

1
2
3
4
5
GLfloat vertices[] = {
    -0.5f, -0.5f, 0.0f,
    0.5f, -0.5f, 0.0f,
    0.0f,  0.5f, 0.0f
};	  

NDC接着会变换为屏幕空间坐标(Screen-space Coordinates),这是通过glViewport函数提供的数据,进行视口变换(Viewport Transform)完成的。最后的屏幕空间坐标被变换为像素输入到片段着色器。

顶点缓冲对象(Vertex Buffer Objects, VBO)

VBO概念:

VBO为顶点缓冲区对象,用于存储顶点坐标/顶点uv/顶点法线/顶点颜色等数据信息。

比如前面我们有了顶点数据,就可以把它们作为输入数据传递给顶点着色器。通过VBO我们可以把需要渲染的图元的顶点信息,直接上传存储在GPU的显存中。使用这些缓冲对象的好处是可以一次性发送大批数据到显卡上,因为从CPU把数据发送到显卡相对较慢。当数据到了显卡内存中时,顶点着色器几乎立即就能获得顶点,这非常快。 VBO归根到底就是显卡存储空间里的一块缓存区(Buffer)而已,用于存储和顶点以及其属性相关的信息。这个Buffer有它的名字(VBO的ID),OpenGL在GPU的某处记录着这个ID和对应的显存地址(或者地址偏移,类似内存)。

VBO的创建、配置:

生成一个缓冲ID:

1
2
GLuint VBO;
glGenBuffers(1, &VBO);    

绑定新创建的缓冲:

1
glBindBuffer(GL_ARRAY_BUFFER, VBO);  	

OpenGL有很多缓冲对象类型,GL_ARRAY_BUFFER是其中一个顶点缓冲对象的类型。上面我们把缓冲绑定到了GL_ARRAY_BUFFER类型上,OpenGL允许同时绑定多个缓冲,只要它们类型不同。 绑定之后,我们使用的任何(GL_ARRAY_BUFFER目标上的)缓冲函数都会用来配置当前绑定的缓冲(VBO)。

接下来调用glBufferData函数:

1
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

它把用户定的义数据复制到当前绑定缓冲的内存中。
现在,顶点数据发送给了GPU,但还没结束,OpenGL还不知道如何解释内存中的顶点数据,以及怎样把顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。

链接顶点属性:

顶点着色器允许我们以任何想要的形式作为顶点属性(Vertex Attribute)的输入,它具有很强的灵活性,这意味着我们必须手动指定输入数据与顶点着色器顶点属性的对应关系。即必须在渲染前指定OpenGL如何解释顶点数据。

我们的顶点缓冲数据被格式化为下面的形式:

每个数据4个字节(32位),每个位置3个值,每个位置间没有间隙。

有这些信息我们就可以告诉OpenGL如何解释顶点数据了(每一个顶点属性),使用glVertexAttribPointer这个函数:

1
2
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);    

glVertexAttribPointer的参数解释:

  • 第一个指定顶点属性位置,与顶点着色器中 layout(location = 0) 对应。
  • 第二个指定顶点属性大小。
  • 第三个指定数据类型。
  • 第四个定义是否希望数据被标准化。
  • 第五个参数叫做步长(Stride),指定在连续的顶点属性之间间隔有多少。由于我们下个位置数据在3个GLfloat之后,所以设为3 * sizeof(GLfloat)。
  • 最后一个表示我们的位置数据在缓冲中起始位置的偏移量。

因为顶点属性默认是关闭的,所以之后要开启顶点属性,使用glEnableVertexAttribArray,把顶点属性位置值作为它的参数。

综合:

最终绘制一个物体,看起来会像这样:

// 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(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);  
// 2. 当我们打算渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();  
        

我们绘制一个物体的时候必须重复这件事。数据少时还好,但要绘制的顶点和物体很多时,绑定合适的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有的配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态?

顶点数组对象(Vertex Array Object, VAO)

概念:

VAO就是所有顶点数据的状态集合。它存储了顶点数据的格式以及顶点数据所需的缓存对象的引用。 VAO一样可以绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处是,当配置顶点属性指针时,你只用做一次,每次绘制一个物体的时候,我们绑定相应VAO就行了。

记住:VAO中并没有存储顶点的相关属性数据。我们把顶点数据存储在数组中,然后放进VBO,最后在VAO中存储相关的状态。它的定位是state-object(状态对象,记录存储状态信息)。与buffer-object明显不同。

VBO与VAO:

综上所述,VAO相当于保存了顶点相关的各种信息,记录了各种状态。当我们要修改它的状态时,我们先激活它(glBindVertexArray())。当我们要使用其中的状态时,如要绘图,也先激活它,然后调用相关的函数。

VBO在渲染阶段才指定数据位置和顶点信息,然后根据此信息去解析缓存区里的数据,联系这两者中间的桥梁是GL-Contenxt。GL-context整个程序一般只有一个,所以如果一个渲染流程里有两份不同的绘制代码,GL-context就负责在它们之间进行状态切换。这也是为什么要在渲染过程中,在每份绘制代码之中有glBindBuffer/glEnableVertexAttribArray/glVertexAttribPointer。

VAO生成、配置:

生成VAO与VBO类似:

1
2
GLuint VAO;
glGenVertexArrays(1, &VAO);

使用VAO要做的全部就是使用glBindVertexArray绑定VAO。自此我们就应该绑定/配置相应的VBO和属性指针。当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把VAO绑定到希望用到的配置就行了。

引用这个博客的内容解释一下: 这个过程就像一个中介人的作用,而中介人就是GL_ARRAY_BUFFER​。我们可以这么想,glBindBuffer​ 设置了一个全局变量,然后glVertexAttribPointer读取了这个全局变量并把它存储在VAO中,这个全局变量就是GL_ARRAY_BUFFER。当调用完glVertexAttribPointer后,顶点属性已经知道了数据来源就是VBO,它们之间就会直接联系,而不需要在通过GL_ARRAY_BUFFER。

绘制函数glDrawArrays:

glDrawArrays函数为我们提供了绘制物体的能力,它使用当前激活的着色器、前面定义的顶点属性配置和VBO的顶点数据(通过VAO间接绑定)来绘制基本图形。

索引缓冲对象(Element Buffer Objects,EBO):

索引缓冲对象简称EBO(或IBO)。举个例子:假设我们不再绘制一个三角形而是矩形。我们就可以绘制两个三角形来组成一个矩形(OpenGL主要就是绘制三角形)。这样我们就需要六个顶点集合,并且会有两个重合的顶点(左下角和右上角的点)。这样当顶点变多时,会产生很大的浪费。 所以最好的解决办法是每个顶点只存一次,当我们需要使用这些顶点时,只调用顶点的索引。这样我们只定义4个顶点就好。

索引缓冲的工作方式正是这样的。一个EBO是一个像顶点缓冲对象(VBO)一样的缓冲,它专门储存索引,OpenGL调用这些顶点的索引来绘制。我们先定义所有用到的独一无二的点,然后是绘制矩形的索引:

1
2
3
4
5
6
7
8
9
10
11
12
GLfloat 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   // 左上角
};
    
GLuint indices[] = { 
        // 起始于0!
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

下一步我们需要创建索引缓冲对象:

1
2
GLuint EBO;
glGenBuffers(1, &EBO);

与VBO相似,我们绑定EBO然后用glBufferData把索引复制到缓冲里。缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER。而且和VBO相似,我们把这些函数调用放在绑定和解绑函数调用之间。

1
2
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); 

要注意的是,我们现在用GL_ELEMENT_ARRAY_BUFFER当作缓冲目标。最后一件要做的事是用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染。当时用glDrawElements的时候,我们就会用当前绑定的索引缓冲进行绘制:

1
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

第一个参数指定了我们绘制的模式。第二个参数是我们打算绘制顶点的次数。第三个参数是索引的类型。最后一个参数里我们可以指定EBO中的偏移量。

glDrawElements函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER目标的EBO获取索引。这意味着我们必须在每次要用索引渲染一个物体时绑定相应的EBO,这还是有点麻烦。不过顶点数组对象仍可以保存索引缓冲对象的绑定状态。VAO绑定之后可以索引缓冲对象,EBO就成为了VAO的索引缓冲对象。再次绑定VAO的同时也会自动绑定EBO。

最终绘制三角形的代码请参考:这里


参考: LearnOpenGL-CN : 你好,三角形

我们参考 LearnOpenGL-CN,使用GLFW和GLEW库来创建第一个窗口程序。

要画出各种效果,首先需要一个OpenGL上下文(Context)和一个用于显示的窗口。我们知道,OpenGL是跨平台的,这些操作在每个系统上都是不一样的,所以OpenGL有目的的抽象(Abstract)这些操作。它并不会实现这些与硬件和系统相关的具体操作。所以我们需要依靠第三方库。

常见的第三方库GLUT,SDL,SFML,GLFW,GLEW等等。之前也介绍过了FreeGLUT和GLEW的安装方法。各个第三方库的使用方法都差不多,从它们官网上下头文件、Lib文件等等,放项目里设置好依赖关系。具体的可以参考这篇教程

GLFW

GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物件所需的最低限度的接口。它允许用户创建OpenGL上下文,定义窗口参数以及处理用户输入。

GLEW

因为OpenGL只是一个规范,具体的实现是由驱动开发商针对特定显卡实现的。由于显卡驱动版本众多,大多数函数都无法在编译时确定下来,需要在运行时获取。开发者需要运行时获取函数地址并保存下来供以后使用。Windows下类似这样:

// 定义函数类型
typedef void (*GL_GENBUFFERS) (GLsizei, GLuint*);
// 找到正确的函数并赋值给函数指针
GL_GENBUFFERS glGenBuffers  = (GL_GENBUFFERS)wglGetProcAddress("glGenBuffers");
// 现在函数可以被正常调用了
GLuint buffer;
glGenBuffers(1, &buffer);

对于每个函数都必须这样,简直无情。幸运的是,有一个针对此目的的库,GLEW,是目前最流行的做这件事的方式。

创建窗口

配置好GLFW和GLEW后,就可以开始创建我们的第一个程序了。VS新建项目啥的就不说了。 下面只是大概介绍,具体内容可以参考这里

头文件

先包含头文件。我们使用GLEW的静态链接库,所以定义了宏GLEW_STATIC。 (静态链接库就是.lib文件,编译时会把库中的代码合并到可执行文件里。动态链接库是指一个库通过.dll或.so的方式存在,它的代码与你的可执行文件是分离的。发布时必须带上这些dll。)

#define GLEW_STATIC  
#include <GL/glew.h>
#include <GLFW/glfw3.h>

//窗口大小
const GLuint WIDTH = 800, HEIGHT = 600;
//窗口对象
GLFWwindow* window;

初始化GLFW

接下来我们定义一个函数,执行初始化GLFW的操作:

int Init_GLFW()
{
    // Init GLFW
    glfwInit();
    // Set all the required options for GLFW
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); //主版本号
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); //副版本号
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); //核心模式
    glfwWindowHint(GLFW_RESIZABLE, GL_FALSE); //窗口不可调大小

    // Create a GLFWwindow object that we can use for GLFW's functions
    window = glfwCreateWindow(WIDTH, HEIGHT, "LearnOpenGL", nullptr, nullptr);
    //检测是否创建成功
    if (window == nullptr)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    
    glfwMakeContextCurrent(window);

    // 注册键盘回掉函数
    glfwSetKeyCallback(window, key_callback);

    return 0;
}

先调glfwInit初始化,然后一堆glfwWindowHint函数进行配置。然后创建了一个窗口对象GLFWwindow,这个窗口对象中具有和窗口相关的许多数据,而且会被GLFW的其他函数频繁地用到。glfwMakeContextCurrent通知GLFW给我们的窗口在当前的线程中创建我们等待已久的OpenGL上下文。

初始化GLEW

再来个函数,初始化GLEW:

int Init_GLEW()
{
    glewExperimental = GL_TRUE;
    // Initialize GLEW to setup the OpenGL Function pointers
    if (glewInit() != GLEW_OK)
    {
        std::cout << "Failed to initialize GLEW" << std::endl;
        return -1;
    }

    // 设置窗口大小
    glViewport(0, 0, WIDTH, HEIGHT);

    return 0;
}

glewExperimental设为True,能让GLEW在管理OpenGL的函数指针时更多地使用现代化的技术。设为GL_FALSE的话可能会在使用OpenGL的核心模式(Core-profile)时出现一些问题。

OpenGL使用glViewport定义的位置和宽高进行位置坐标的转换,将OpenGL中的位置坐标转换为你的屏幕坐标。例如,OpenGL中的坐标(0.5,0.5)有可能被转换为屏幕中的坐标(200,450)。注意,OpenGL只会把-1到1之间的坐标转换为屏幕坐标,因此在此例中(-1,1)转换为屏幕坐标是(0,600)。

main函数里先调用这俩函数。

循环

我们不希望窗口只绘制一个图像后就关闭。可以在程序中添加一个while循环,这样程序就能在我们让GLFW退出前保持运行了。

while(!glfwWindowShouldClose(window))
{
    glfwPollEvents();
    glfwSwapBuffers(window);
}

glfwWindowShouldClose,检查GLFW是否准备好要退出。 glfwPollEvents, 检查有没有触发什么事件(比如键盘有按钮按下、鼠标移动等)然后调用对应的回调函数。 glfwSwapBuffers,会交换缓冲区

最后,当游戏循环结束后我们需要释放之前的操作分配的资源,在main函数的最后加入如下代码:

glfwTerminate();
return 0;

这时候运行就会有个窗口了。。。没有的话去看前面介绍的原网站。。。

输入

GLFW中的键盘、鼠标等控制是通过回调函数,就是函数指针,我们注册好后,在事件触发后会调用该函数。跟C#委托和事件差不多。前面GLFW初始化时我们已经过一个键盘回调函数,它具体如下:

glfwSetKeyCallback(window, key_callback);  //注册函数
...
...

void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
{
    // 当用户按下ESC键,我们设置window窗口的WindowShouldClose属性为true
    // 关闭应用程序
    if(key == GLFW_KEY_ESCAPE && action == GLFW_PRESS){
        glfwSetWindowShouldClose(window, GL_TRUE);
    }
}    

渲染

我们要把所有的渲染操作放到循环中,因为我们想让这些渲染操作在每次循环迭代的时候都能被执行。 为了测试,我们让屏幕清空为一种颜色。可以通过调用glClear函数来清空屏幕缓冲区的颜色.

// 程序循环
while(!glfwWindowShouldClose(window))
{
    // 检查事件
    glfwPollEvents();
 
    // 在这里执行各种渲染操作
    ...
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
 
    //交换缓冲区
    glfwSwapBuffers(window);
}

参考: 你好,窗口