OpenGL ES 2 第二章:定义顶点和着色器
本章介绍我们的第一个项目:一个简单的空中曲棍球游戏。在我们进行这个项目的过程中,我们将了解OpenGL的一些主要构建块。我们将从学习如何使用一组称为顶点(vertices)的独立点来构建对象开始,然后我们学习如何使用着色器来绘制这些对象,所谓的着色器,是告诉OpenGL如何绘制对象的小程序。这两个概念非常重要,因为几乎每个对象都是通过将顶点连接成点、线和三角形来构建的,这些基本体都通过使用着色器绘制。
我们将首先了解顶点,这样我们就可以建立我们的空气曲棍球桌,并使用OpenGL的坐标空间将其定位在世界上。接下来,我们将创建一组非常基本的着色器,在屏幕上绘制这个空气曲棍球桌。在下一章中,我们还将学习如何将顶点绘制为屏幕上的点、线和三角形,在后面的章节中,我们将学习颜色、平滑着色、纹理和触摸交互,以及平行投影和透视投影。
为什么用空气曲棍球作为示例
空气曲棍球是一种简单、受欢迎的运动,经常在保龄球馆和酒吧里进行。虽然很简单,但它也会令人难以置信地上瘾。Android应用市场Google Play的一些畅销产品都是基于这款令人愉悦的游戏的一种或另一种变体。在我们开发空气曲棍球游戏的过程中,我们将学习很多OpenGL概念。我们将学习如何定义和绘制一张桌子,以及如何使用颜色、色调和纹理添加细节。我们还将学习如何通过触摸屏事件响应用户。
游戏规则
要打一场空气曲棍球,我们需要一张长方形的长桌子,上面有两个球(两端各一个),一个冰球和两个用来击打冰球的木槌。每一轮都是从桌子中间的冰球开始的。然后,每个玩家都试图将冰球击入对手的球门,同时阻止对手这样做。第一个进七球的球员赢得比赛。作为我们游戏计划的一部分,我们需要做的第一件事是学习如何定义我们的曲棍球桌的结构,以及如何编写将在屏幕上绘制该桌的代码。当我们这样做的时候,我们将建立一个框架,作为未来章节的基础。
我们现在要让事情变得更简单些,因此我们先将桌子定义为一个矩形。我们还需要在桌子中间定义一条分界线来分隔每个球员的阵营,然后需要以某种方式代表冰球和守门员,目前我们简单将冰球和守门员定义为单独的点。到本章结束时,我们将把我们需要的代码框架准备到位。
不要从头开始
让我们重新使用第一章定义的项目。我们在其基础上扩展。
先创建两个类AirGLSurfaceView
和AirHockeyRenderer
,然后把第一章节写的大部分内容拷贝进去,如下所示:
class AirHockeyRenderer: GLSurfaceView.Renderer {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(1F, 0F, 0F, 1F)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
}
}
class AirGLSurfaceView
@JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
): GLSurfaceView(context, attrs) {
init {
setEGLContextClientVersion(2)
setRenderer(AirHockeyRenderer())
}
}
然后,MainActivity.kt
中修改为使用AirGLSurfaceView
:
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(object : DefaultLifecycleObserver{
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
lifecycleState = Lifecycle.Event.ON_RESUME
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
lifecycleState = Lifecycle.Event.ON_PAUSE
}
})
setContent {
AndroidView(
factory = {
// here
AirGLSurfaceView(it)
},
update = {
when(lifecycleState) {
Lifecycle.Event.ON_PAUSE -> {
it.onPause()
}
Lifecycle.Event.ON_RESUME -> {
it.onResume()
}
else -> {}
}
}
)
}
}
}
定义我们的空中曲棍球桌的结构
在我们将桌子绘制到屏幕之前,我们需要告诉OpenGL要绘制什么。第一步是以OpenGL能够理解的形式定义桌子的结构。在OpenGL中,一切的结构都是从一个顶点开始的。
什么是顶点
顶点是表示几何对象一个角(corner)的点,具有与该点关联的各种属性。最重要的属性是位置,它表示该顶点在空间中的位置。
用顶点构建桌子
我们说过现在会从更简单的角度考虑桌子的结构,那么我们可以用什么最基本的形状来代表我们的曲棍球桌的结构呢?我们可以用长方形。因为矩形有四个角,所以我们需要四个顶点。矩形是二维对象,因此每个顶点都需要一个位置,每个维度都有一个坐标。如果我们在一张图表纸上画出来,我们可能会得到类似下图的结果:
在代码中定义顶点
让我们继续写一些代码来存储这些顶点。我们将顶点表示为浮点数列表;因为我们是在二维中工作的,所以每个顶点使用两个浮点数:一个用于x位置,一个用于y位置。因为每个顶点有两个分量,所以我们首先创建一个常数来包含这个事实。打开AirHockeyRenderer
并将以下常量添加到伴生类:
companion object {
private const val POSITION_COMPONENT_COUNT = 2
}
现在添加一个属性来存储我们得到的坐标:
private val tableVertices: FloatArray = floatArrayOf(
0f, 0f,
0f, 14f,
9f, 14f,
9f, 0f
)
我们使用浮点数的顺序列表来定义顶点数据,这样我们就可以存储带有小数点的位置。我们将把这个数组称为顶点属性数组。我们现在只存储位置,但以后的章节我们还将使用这里看到的相同概念存储颜色和其他属性。
点、线和三角形
还记得我说过最简单的曲棍球桌表示法是长方形吗?但问题是,在OpenGL中,我们只能绘制点、线和三角形。
三角形是你周围最基本的几何形状。我们在世界各地都能看到它,比如在一座桥的结构部件中,因为它是如此坚固的形状。它有三条边与三个顶点相连。如果我们去掉一个顶点,我们会得到一条直线,如果我们再去掉一个顶点,我们就会得到一个点。
点和线可以用于某些效果,但只有三角形可以用于构建复杂对象和纹理的整个场景。在OpenGL中,我们需要先将单独的顶点进行分组,然后告诉OpenGL如何连接这些顶点来构建三角形。我们想要构建的所有东西都需要用这些点、线和三角形来定义,如果我们想要构建更复杂的形状,比如拱门,那么我们需要使用足够的点来近似曲线。
那么,如果我们不能使用矩形,我们如何定义我们的空中曲棍球桌呢?事实证明,我们可以把这张桌子想象成两个三角形连接在一起,如下图所示:
让我们更改代码以反映这样一个事实,即我们现在将使用两个三角形而不是一个矩形:
private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
// 三角形1
0f, 0f,
9f, 14f,
0f, 14f,
// 三角形2
0f, 0f,
9f, 0f,
9f, 14f
)
我们的数组现在包含六个顶点,它们将被用来表示两个三角形。第一个三角形以(0,0)、(9,14)和(0,14)处的点为界。第二个三角形共享其中两个位置,并以(0,0)、(9,0)和(9,14)为边界。每当我们想要在OpenGL中表示一个对象时,我们都需要考虑如何用点、线和三角形组合它。
添加中心线和两个木槌
我们几乎完成了顶点的定义。我们只需要为中心线和两个木槌添加几个顶点。我们希望最终得到一些类似下图的东西:
我们将使用一条线作为中心线,每个木槌使用一个点。现在我们的顶点数组就像下面这样:
private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
// 三角形1
0f, 0f,
9f, 14f,
0f, 14f,
// 三角形2
0f, 0f,
9f, 0f,
9f, 14f,
// 中线
-0.5F, 0F,
0.5F, 0F,
// 顶点
0F, -0.25F,
0F, 0.25F,
)
使数据可供OpenGL访问
我们已经完成了顶点的定义,但在OpenGL能够访问它们之前,我们还需要多做一些工作。主要问题是,我们的代码运行的环境和OpenGL运行的环境所使用的语言不同。我们需要了解两个主要概念:
- 当我们在模拟器或设备上编译和运行Java代码时,它不会直接在硬件上运行。相反,它运行在一个称为Dalvik虚拟机的特殊环境中。在此虚拟机中运行的代码除了通过特殊的API之外,无法直接访问本机环境
- Dalvik虚拟机自动管理内容并及时回收垃圾。这意味着,当虚拟机检测到某个变量、对象或某个其他内存块不再被使用时,它将释放该内存,以便可以重用。它还可以移动内存所占的内容,以便更有效地利用空间。native环境的工作方式不同,它不希望内存块被自动移动和释放。
Android就是这样设计的,这样开发者就可以开发应用程序,而不用担心特定的CPU或机器架构,也不用担心低级别的内存管理。在我们需要与本机系统(如OpenGL)接口之前,这通常都很有效。OpenGL作为本机系统库直接在硬件上运行。没有虚拟机,也没有垃圾收集或内存压缩。
在Java中访问Native code
如果我们的代码在虚拟机中,那么我们如何与OpenGL通信呢?有两个方法。第一个方法是使用Java本机接口(JNI),Android SDK的人已经为我们完成了这个技巧。当我们在android中使用android.opengl.GLES20 包的方法时,SDK实际上是在幕后使用JNI来调用本机系统库。
将内存从Java的内存堆复制到本机内存堆
第二个方法是改变我们分配内存的方式。我们可以访问Java中的一组特殊类,这些类将分配一块native的内存,并将数据复制到该内存中。native内存将可供native环境访问,不会由垃圾收集器管理。我们需要传输数据,如下图所示。让我们再添加一些常量和类变量:
private val vertexData: FloatBuffer
companion object {
private const val BYTES_PER_FLOAT = 4
}
我们添加了一个常量,BYTES_PER_FLOAT
和一个FloatBuffer
。Java中的浮点有32位,而一个字节有8位。也就是说每个浮点占用4个字节。在今后的许多地方,我们都需要提到这一点。FloatBuffer
将用于在native内存中存储数据。让我们再添加一些代码:
// allocateDirect()不使用JVM堆栈而是通过操作系统来创建内存块用作缓冲区,它与当前操作系统能够更好的耦合,因此能进一步提高I/O操作速度。但是分配直接缓冲区的系统开销很大,因此只有在缓冲区较大并长期存在,或者需要经常重用时,才使用这种缓冲区
private val vertexData: FloatBuffer = ByteBuffer
.allocateDirect(tableVerticesWithTriangles.size * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(tableVerticesWithTriangles)
让我们看看上面的方法调用。首先,我们使用ByteBuffer.allocateDirect()
分配了一块本机内存;此内存将不由垃圾收集器管理。我们需要告诉这个方法内存块应该有多大,以字节为单位。因为我们的顶点存储在一个浮点数组中,每个浮点有4个字节,所以我们传递tableVerticesWithTriangles.length * BYTES_PER_FLOAT
。下一行告诉字节缓冲区,它应该按native顺序组织字节。当涉及跨越多个字节的值时,例如32位整数,字节可以从最高有效到最低有效或从最低到最高排序(所谓的字节序)。你可以把两种字节序想象成从左到右或从右到左写一个数字。对我们来说,这个顺序并不重要,重要的是我们必须使用与平台相同的顺序。我们通过调用order(ByteOrder.nativeOrder())
来实现这一点。最后,我们不希望直接处理单个字节。我们想要使用浮点,所以我们调用asFloatBuffer()
来获得一个反映底层字节的FloatBuffer
。然后我们通过调用vertexData.put(tableVerticesWithTriangles)
将数据从Dalvik的内存复制到native内存。当进程被destroy时,这一块内存将被释放,所以我们通常不需要担心这一点。如果您最终编写的代码会创建大量字节缓冲区,并且会随着时间的推移而这样做,那么您可能需要了解堆碎片和内存管理技术。
将数据从Dalvik传输到OpenGL需要几个步骤,但在继续之前了解其工作原理至关重要。正如各国的文化和习俗不同,我们也必须意识到当我们跨越边境进入native代码时的变化。
介绍OpenGL管线
我们现在已经定义了曲棍球桌的结构,并将数据复制到本机内存中,OpenGL将能够在其中访问它。在将曲棍球桌绘制到屏幕上之前,我们需要通过OpenGL管线(Pipeline)发送它,为此我们需要使用称为着色器(shader)的小型子程序(参见下图,OpenGL管线概述)。这些着色器告诉图形处理单元(GPU)如何绘制数据。有两种类型的着色器,在将任何内容绘制到屏幕之前,我们需要定义这两种着色器。
-
顶点着色器(vertex shader)生成每个顶点的最终位置,每个顶点运行一次。一旦最终位置已知,OpenGL将获取可见的顶点集,并将它们组装成点、线和三角形。
-
片元着色器(fragment shader)生成点、线或三角形的每个片元的最终颜色,并对每个fragment运行一次。fragment是单一颜色的小矩形区域,类似于计算机屏幕上的像素。
一旦生成了最终的颜色,OpenGL会将它们写入一个名为帧缓冲区(Frame Buffer)的内存块中,然后Android会在屏幕上显示这个帧缓冲区。
有关OpenGL和着色器的快速参考,请参阅khronos.org,有一张很好的快速参考卡,可以打印出来放在你身边。
创建我们的第一个顶点着色器
让我们创建一个简单的顶点着色器,它将按照我们在代码中定义的位置来指定位置。为此,我们首先需要通过以下步骤为着色器创建一个新文件:
-
首先我们需要在res文件夹下新建raw文件夹
-
在raw文件夹下创建新文件simple_vertex_shader.glsl
现在已经创建了着色器的新文件,让我们添加以下代码:
// attribute修饰符表示只读的顶点数据,只用在顶点着色器中。数据来自当前的顶点状态或者顶点数组。它必须是全局范围声明的,不能在函数内部。一个attribute可以是浮点数类型的标量,向量,或者矩阵。不可以是数组或则结构体
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
}
这些着色器是使用OpenGL的着色语言GLSL定义的。这种着色语言的语法结构类似于C。有关更多信息,请参阅完整规范。(快速预览GLSL基本语法)
这个顶点着色器将为我们定义的每个顶点调用一次。调用时,它将接收a_Position
属性中当前顶点的位置,该属性类型定义为vec4
。
vec4
是由四个分量组成的向量。在描述位置的时候,我们可以将这四个分量看作该位置的x、y、z和w坐标。x、 y和z对应于一个3D位置,而w是一个特殊的坐标,我们将在第6章第95页“进入三维”中详细介绍。如果未指定,OpenGL的默认行为是将向量的前三个坐标设置为0,最后一个坐标设置为1。
还记得我们讨论过顶点可以具有多个属性,例如颜色和位置吗?attribute
关键字指定了我们将这些属性输入着色器的方式。(在下一章你就会明白“指定了将这些属性输入着色器的方式”是什么意思。)
然后我们定义main()
,着色器的主要入口点。它所做的只是将我们定义的位置复制到特殊的输出变量gl_Position
。我们的着色器必须写入gl_Position
。OpenGL将使用存储在gl_Position
中的值作为当前顶点的最终位置,并开始将顶点组装成点、线和三角形。
创建我们的第一个片元着色器
现在我们已经创建了顶点着色器,我们有了一个子程序来生成每个顶点的最终位置。我们仍然需要创建一个子程序来生成每个片元的最终颜色。在编写代码之前,让我们花一些时间来回答两个问题:片元(fragment)是什么以及片元是如何生成的。
光栅化的艺术
你的移动显示屏由数千到数百万个被称为像素的独立小组件组成。这些像素中的每一个似乎都能够显示数百万种不同颜色中的一种颜色。然而,这实际上是一个视觉技巧:大多数显示器实际上无法创建数百万种不同的颜色,因此每个像素通常只由三个独立的子组件组成,分别发出红光、绿光和蓝光,因为每个像素都很小,我们的眼睛混合了红光、绿光和蓝光,和蓝光一起创造了一个巨大的可能的颜色范围。把足够多的像素放在一起,我们就可以显示一页文字或蒙娜丽莎。
OpenGL创建了一个图像,我们可以通过一个称为光栅化(rasterization)的过程将每个点、线和三角形分解成一堆fragments ,将其映射到移动显示器的像素上。这些fragments类似于移动显示屏上的像素,并且每个fragment也由单一的纯色组成。为了表示这种颜色,每个fragment有四个组成部分:红色、绿色和蓝色表示颜色,alpha表示透明度。我们将在“OpenGL颜色模型”小节中介绍该颜色模型的工作原理。
在下图“光栅化:生成片元”中,我们可以看到OpenGL如何将一条线光栅化为一组片元的示例。显示系统通常将这些fragment直接映射到屏幕上的像素,令一个fragment对应一个像素。然而,这并不总是正确的:超高分辨率设备可能希望使用更大的fragments,这样可以减轻GPU的负担。
片元着色器代码
片元着色器的主要目的是告诉GPU每个片元的最终颜色应该是什么。对于图元(primitive)的每个fragment,片元着色器将被调用一次,因此如果一个三角形映射到10000个片元,那么片元着色器将被调用10000次。
让我们继续编写片元着色器。在项目的资源文件夹/res/raw/中创建一个新文件simple_fragment_shader.glsl,并添加以下代码:
precision mediump float;
uniform vec4 u_Color;
void main() {
gl_FragColor = u_Color;
}
精度限定符
文件顶部的第一行定义片元着色器中所有浮点数据类型的默认精度。这就像在Java代码中选择float和double一样。我们可以选择lowp
、mediump
和highp
,它们分别对应于低精度、中精度和高精度。然而,highp
仅在某些实现的片元着色器中受支持。
我们为什么不为顶点着色器这样做呢?顶点着色器也可以更改其默认精度,但由于精度在顶点位置时更为重要,OpenGL设计师决定在默认情况下将顶点着色器设置为最高设置highp
。(所以一般不需要改小这个精度)
正如您可能已经猜到的,精度更高的数据类型更准确,但它们以降低性能为代价。对于我们的片元着色器,我们将选择mediump
以获得最大的兼容性,并在速度和质量之间进行权衡。
生成fragment的颜色
片元着色器的其余部分与我们前面定义的顶点着色器类似。这一次,我们传递了一个叫u_Color
的uniform
变量,或者你可以把uniform
变量称之为统一变量。与在每个顶点上设置的属性不同,在我们更改它的值之前,uniform
变量对所有顶点保持相同的值。与我们在顶点着色器中用于位置的属性一样,u_Color
也是一个四分量的向量,在颜色的上下文中,它的四个分量对应于红色、绿色、蓝色和透明度。然后我们定义main()
,着色器的主要入口点。它将我们在uniform
中定义的颜色复制到特殊输出变量gl_FragColor
。我们的着色器必须向gl_FragColor
写入内容。OpenGL将使用此颜色作为当前fragment的最终颜色。
OpenGL颜色模型
OpenGL使用加性(additive)RGB颜色模型,该模型仅适用于三种原色:红色、绿色和蓝色。通过将这些原色按不同比例混合在一起,可以产生许多颜色。例如,红色和绿色一起创造黄色,红色和蓝色一起创造洋红色,蓝色和绿色一起创造青色。加上红色、绿色和蓝色,你会得到白色。
这个模型的工作原理与你在学校可能学过的减法绘画模型不同:在减法绘画模型中,加上蓝色和黄色会变成绿色,加上一堆颜色会变成深棕色或黑色。这是因为油漆不发光;它吸收了它。我们使用的颜料颜色越多,吸收的光线越多,颜色越深。
加性RGB模型遵循光本身的特性:当两束不同颜色的光混合在一起时,我们不会得到更暗的颜色;我们得到了更亮的颜色。当我们在大雨后观察天空中的彩虹时,我们实际上看到了可见光光谱中所有不同的颜色,这些颜色可以组合成白色。
将颜色映射到显示器
OpenGL假设这些颜色彼此之间都有线性关系:红色值0.5的亮度应该是红色值0.25的两倍,红色值1的亮度应该是红色值0.5的两倍。这些原色被限制在[0,1]范围内,0表示没有该特定原色,1表示该颜色的最大强度。
这种颜色模型可以很好地映射到手机和计算机显示器所使用的显示硬件(然而,后面我们会提到显示器的非线性特性,我们将了解到这种映射并不完全是一对一的)。这些显示器几乎总是使用红色、绿色和蓝色三种原色(有些可能包括黄色作为附加原色,用于“纯黄色”),0映射到未发光的像素组件,1映射到该颜色的全亮度。使用这种颜色模型,我们的眼睛可以看到的几乎所有颜色都可以在OpenGL中渲染并显示在屏幕上。
我们将在第四章“添加颜色和阴影”中了解有关使用颜色的更多信息。
本章小结
我们花了本章的大部分时间学习如何定义我们的数据以及将会沿着OpenGL管线而移动这些数据的着色器。让我们花点时间回顾一下我们在本章学到的关键概念:
- 我们首先学习了如何定义顶点属性数组,并将该数组复制到本机内存中,以便OpenGL能够访问它。
- 然后我们编写了一个顶点和一个片元着色器。我们了解到着色器只是在GPU上运行的一种特殊类型的程序。
在下一章中,我们将继续在本章工作的基础上继续发展;到那一章结束时,我们将能够看到我们的空气曲棍球桌,我们也将准备好继续进行进一步的练习。我们将从学习如何读入(read)和编译(compile)我们定义的着色器开始。因为顶点着色器和片元着色器总是一起使用,所以我们还将学习如何将这些着色器链接(link)到OpenGL程序中。一旦我们编译并链接了着色器,我们就可以把所有东西放在一起,并告诉OpenGL将我们的空气曲棍球桌的第一个版本绘制到屏幕上。