OpenGL ES 2 第三章:编译着色器并绘制到屏幕
本章将继续我们在上一章中开始的工作。作为本章的游戏计划,我们将首先加载并编译我们定义的着色器,然后将它们链接到一个OpenGL程序中,最后我们就可以使用这个着色器程序将我们的曲棍球桌绘制到屏幕上。
加载着色器
现在我们已经为着色器编写了代码,下一步是将它们加载到内存中。要做到这一点,我们首先需要编写一个方法,从资源文件夹中读取代码。
从raw资源中加载字符串
创建util包,并创建Utils.kt ,输入以下代码:
fun Context.readStringFromRaw(@RawRes resId: Int): String {
return runCatching {
val builder = StringBuilder()
val reader = BufferedReader(InputStreamReader(resources.openRawResource(resId)))
var nextLine: String? = reader.readLine()
while (nextLine != null) {
builder.append(nextLine).append("\n")
nextLine = reader.readLine()
}
reader.close()
builder.toString()
}.onFailure {
when(it) {
is IOException -> {
throw RuntimeException("Could not open resource: $resId", it)
}
is Resources.NotFoundException -> {
throw RuntimeException("Resource not found: $resId", it)
}
else -> {}
}
}.getOrThrow()
}
读取着色器代码
我们现在将添加调用,以实际读取着色器代码。切换到AirHockeyRenderer 。在onSurfaceCreated() 中调用glClearColor() 后添加以下代码:
val vertexShaderCode = context.readStringFromRaw(R.raw.simple_vertex_shader)
val fragmentShaderCode = context.readStringFromRaw(R.raw.simple_fragment_shader)
同时在AirHockeyRenderer 构造函数中添加context 的传入。
class AirHockeyRenderer(private val context: Context): GLSurfaceView.Renderer {
}
日志辅助
我们可能需要观察日志来辅助我们观察OpenGL ES的运行流程,以下给出一个简单的日志类:
import android.util.Log
object LogU {
const val TAG = "LogU"
var on = true
fun v(tag: String = TAG, throwable: Throwable): LogU {
if (on) {
Log.v(tag, Log.getStackTraceString(throwable))
}
return this
}
fun v(tag: String = TAG, message: Any?): LogU {
if (on) {
Log.v(tag, message.toString())
}
return this
}
fun d(tag: String = TAG, throwable: Throwable): LogU {
if (on) {
Log.d(tag, Log.getStackTraceString(throwable))
}
return this
}
fun d(tag: String = TAG, message: Any?): LogU {
if (on) {
Log.d(tag, message.toString())
}
return this
}
fun i(tag: String = TAG, throwable: Throwable): LogU {
if (on) {
Log.i(tag, Log.getStackTraceString(throwable))
}
return this
}
fun i(tag: String = TAG, message: Any?): LogU {
if (on) {
Log.i(tag, message.toString())
}
return this
}
fun w(tag: String = TAG, throwable: Throwable):LogU {
if (on) {
Log.w(tag, Log.getStackTraceString(throwable))
}
return this
}
fun w(tag: String = TAG, message: Any?):LogU {
if (on) {
Log.w(tag, message.toString())
}
return this
}
fun e(tag: String = TAG, throwable: Throwable): LogU {
if (on) {
Log.e(tag, Log.getStackTraceString(throwable))
}
return this
}
fun e(tag: String = TAG, message: Any?): LogU {
if (on) {
Log.e(tag, message.toString())
}
return this
}
fun set(b: Boolean): LogU {
on = b
return this
}
}
编译着色器
现在我们已经从文件中读取了着色器源代码,下一步是编译每个着色器。我们将创建一个新的Helper类,该类将创建一个新的OpenGL着色器对象,编译着色器代码,并返回该着色器代码的着色器对象。一旦我们有了这个样板代码,我们将能够在未来的项目中重用它。首先,创建一个新类ShaderHelper ,并在该类中添加以下代码:
object ShaderHelper {
private const val tag = "ShaderHelper"
fun compileVertexShader(shaderCode: String) = compileShader(GL_VERTEX_SHADER, shaderCode)
fun compileFragmentShader(shaderCode: String) = compileShader(GL_FRAGMENT_SHADER, shaderCode)
fun compileShader(type: Int, shaderCode: String): Int {
}
}
GL_VERTEX_SHADER 等常量位于android.opengl.GLES20 包内。
我们将使用它作为着色器辅助对象的基础。在下一节中,我们将逐步构建compileShader() 。
创建新的着色器对象
我们应该做的第一件事是创建一个新的着色器对象,并检查创建是否成功。将以下代码添加到compileShader()
fun compileShader(type: ShaderType, code: String): Int {
val shaderObjectId = glCreateShader(type)
if (type == 0) {
LogU.w(tag = tag, message = "Could not create new shader")
return 0
}
}
我们通过调用GLES20 中的静态方法glCreateShader() 创建一个新的着色器对象,并将该对象的ID存储在shaderObjectId 中。
如何创建对象并检查它是否有效,这种模板代码在OpenGL中随处可见:
- 我们首先使用
glCreateShader() 之类的调用创建一个对象。此调用将返回一个整数。 - 这个整数是对OpenGL对象的引用。将来每当我们想要引用这个对象时,我们都需要将相同的整数传递回OpenGL。
- 返回值0表示对象创建失败,类似于Java代码中的返回值null
如果对象创建失败,我们将向调用代码返回0。为什么我们要返回0而不是抛出异常?OpenGL实际上不会在内部抛出任何异常。而是以返回值为0的方式告诉我们发生了错误,可以通过调用glGetError() 询问OpenGL是否有任何API调用导致了错误。我们打算与OpenGL的行为保持一致。
上传和编译着色器源代码
让我们添加以下代码,将着色器源代码上传到着色器对象中:
glShaderSource(shaderObjectId, shaderCode)
创建了一个有效的着色器对象之后,我们调用glShaderSource(shaderObjectId,shaderCode) 来上传源代码。此方法告诉OpenGL读入字符串shaderCode 中定义的源代码,并将其与shaderObjectId 引用的着色器对象相关联。然后,我们可以调用glCompileShader(shaderObjectId) 来编译着色器。
glCompileShader(shaderObjectId);
这会告诉OpenGL编译之前上传到shaderObjectId 的源代码。
检索编译状态
让我们添加以下代码来检查OpenGL着色器是否编译成功:
val compileStatus: IntArray = IntArray(1)
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0)
为了检查编译成功或失败,我们首先使用Kotlin创建一个在Java层看来长度为1的新int数组,并将其称为compileStatus 。然后我们调用glGetShaderiv(shaderObjectId,GLES20.GL_COMPILE_STATUS,compileStatus,0) 。这会告诉OpenGL读取与shaderObjectId 关联的编译状态,并将其写入compileStatus 的第0个元素。
这是Android上OpenGL的另一种常见模式。为了检索值,我们通常使用长度为1的数组,并将该数组传递到OpenGL调用中。在同一个调用中,我们告诉OpenGL将结果存储在数组的第一个元素中。
检索着色器信息日志
当我们获取编译状态时,OpenGL会给我们一个简单的成功或失败的答案,但无法得知哪里出了问题。我们可以通过调用glGetShaderInfoLog(shaderObjectId) 获得一条可读的消息。如果OpenGL对我们的着色器有什么有趣的说法,它会将消息存储在着色器的信息日志中。
让我们添加以下代码以获取着色器信息日志:
LogU.v(tag = tag, message ="Results of compiling source:" +
"\n$shaderCode\n:${glGetShaderInfoLog(shaderObjectId)}")
验证编译状态并返回着色器对象ID
现在我们已经记录了着色器信息日志,我们可以检查编译是否成功。
我们需要做的就是检查在检索编译状态的步骤中返回的值是否为0。如果为0,则编译失败。在这种情况下,我们不再需要着色器对象,所以我们告诉OpenGL删除它,并返回0。如果编译成功,那么我们的着色器对象是有效的,我们可以在代码中使用它,因此可以返回新的着色器对象ID:
if (compileStatus[0] == 0) {
glDeleteShader(shaderObjectId)
LogU.w(tag, "Compilation of shader failed.")
return 0
}
return shaderObjectId
从ShaderHelper编译着色器
现在是充分利用我们刚刚创建的代码的时候了。切换到AirHockeyRenderer.kt 并将以下代码添加到onSurfaceCreated() :
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
glClearColor(0F, 0F, 0F, 0F)
val vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode)
val fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode)
}
让我们回顾一下我们在前面中所做的工作。首先,我们创建了一个新类ShaderHelper ,并添加了一个方法来创建和编译一个新的着色器对象。我们还创建了LogU ,这是一个帮助我们打印日志的类。
compileShader() 现在看起来就像这样:
object ShaderHelper {
fun compileShader(type: Int, shaderCode: String): Int {
val shaderObjectId = glCreateShader(type)
if (type == 0) {
LogU.d(tag = tag, message = "Could not create new shader")
return 0
}
glShaderSource(shaderObjectId, shaderCode)
glCompileShader(shaderObjectId)
val compileStatus = IntArray(1)
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0)
LogU.v(tag = tag, message ="Results of compiling source:" +
"\n$shaderCode\n:${glGetShaderInfoLog(shaderObjectId)}")
if (compileStatus[0] == 0) {
glDeleteShader(shaderObjectId)
LogU.w(tag, "Compilation of shader failed.")
return 0
}
return shaderObjectId
}
}
将着色器链接到OpenGL程序中
现在我们已经加载并编译了顶点着色器和片元着色器,下一步是将它们绑定到单个程序中(bind them together into a single program)。
理解OpenGL程序
可以将一个顶点着色器和一个片元着色器简单链接到一个对象中而构成一个OpenGL程序,但顶点着色器和片元着色器缺一不可。如果没有片元着色器,OpenGL将不知道如何绘制组成每个点、线和三角形的片元;如果没有顶点着色器,OpenGL就不知道在哪里绘制这些片元。
我们知道顶点着色器计算屏幕上每个顶点的最终位置。我们还知道,当OpenGL将这些顶点分组(groups)为点、线和三角形并将它们分解(breaks)为片元时,它会向片元着色器询问每个片元的最终颜色。顶点着色器和片元着色器协同工作,在屏幕上生成最终图像。
尽管顶点着色器和片元着色器总是一起使用,但它们不一定要保持单一配对(monogamous):我们可以在多个程序中使用同一个着色器。
让我们打开ShaderHelper,并在类的末尾添加以下代码:
fun linkProgram(vertexShaderId: Int, fragmentShaderId: Int) {
}
正如我们对compileShader() 所做的那样,我们将逐步构建这个方法。大部分代码在概念上与compileShader() 类似。
创建新程序对象并附加着色器
我们要做的第一件事是通过调用glCreateProgram() 创建一个新的程序对象,并将该对象的ID存储在programObjectId 中。让我们添加以下代码:
val programObjectId = glCreateProgram()
if (programObjectId == 0) {
LogU.w(tag, "Could not create new program")
return 0
}
语义与我们之前创建新着色器对象时相同:返回的整数是我们对程序对象的引用,如果对象创建失败,我们将得到0的返回值。
下一步是附加(attach)着色器,我们继续在glCreateProgram() 后面添加代码:
glAttachShader(programObjectId, vertexShaderId)
glAttachShader(programObjectId, fragmentShaderId)
使用glAttachShader() ,我们把顶点着色器和片元着色器附加到程序对象。
链接程序
我们现在准备好加入我们的着色器,我们将通过调用glLinkProgram(programObjectId) 来实现这一点。
要检查链接是否失败或成功,我们将按照编译着色器时的相同步骤进行操作。我们首先创建一个新的int数组来保存结果。然后我们调用glGetProgramiv(programObjectId, GLES20.GL_LINK_STATUS, linkStatus, 0) 将结果存储在这个数组中。我们还将检查程序信息日志,出现问题时,或者OpenGL对我们的程序有什么有趣的说法,都能在Android的日志输出中看到。
glLinkProgram(programObjectId)
val linkStatus = IntArray(1)
glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0)
LogU.v(tag, "Results of linking program:\n${glGetProgramInfoLog(programObjectId)}")
验证链接状态并返回程序对象ID
我们现在需要检查链接状态:如果是0,这意味着链接失败,我们不能使用这个程序对象,所以我们应该删除它并将0返回给调用代码。如果链接成功,直接返回程序对象id:
if (linkStatus[0] == 0) {
glDeleteProgram(programObjectId)
LogU.w(tag, "Linking of program failed.")
return 0
}
return programObjectId
将代码添加到我们的Renderer类中
链接之前得到的两个着色器,我们需要添加一个类变量来缓存程序id:
class AirHockeyRenderer{
private var programId = 0
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
programId = ShaderHelper.linkProgram(vertexShader, fragmentShader)
}
}
在下一节中,我们将开始进行最终连接,并将数据链接到OpenGL。
最后的连接
我们在上两章的大部分时间里为我们的应用奠定了一个基本的基础:我们学会了如何使用属性数组定义一个对象的结构,并且我们还学会了如何创建着色器、加载和编译它们,并将它们链接到一个OpenGL程序中。
现在是时候在这个基础上建立最后的连接了。在接下来的几个步骤中,我们将把这些代码组织在一起,然后准备在屏幕上绘制我们的空气曲棍球桌的第一个版本。
验证我们的OpenGL程序
在我们开始使用OpenGL程序之前,我们应该先验证它,看看该程序在当前OpenGL状态下是否有效。根据OpenGL ES 2.0文档,它为OpenGL提供了一种方法,让我们知道为什么当前程序可能效率低下、无法运行等等。
让我们向ShaderHelper 添加以下方法:
fun validateProgram(programObjectId: Int): Boolean {
glValidateProgram(programObjectId)
val validateStatus = IntArray(1)
glGetProgramiv(programObjectId, GL_VALIDATE_STATUS, validateStatus, 0)
LogU.v(tag, "Results of validating program: " + validateStatus[0]
+ "\nLog:" + glGetProgramInfoLog(programObjectId))
return validateStatus[0] != 0
}
我们调用glValidateProgram() 来验证程序,传入GL_VALIDATE_STATUS 作为参数,通过调用glGetProgramiv() 来检查结果。我们还通过调用glGetProgramInfoLog() 打印日志,如果OpenGL有什么有趣的话要说,它将出现在程序日志中。
我们应该在开始使用程序之前对其进行验证,也只应该在开发和调试应用程序时进行验证。因此,我们在Utils.kt 添加一个判断是否为debug版本的方法,然后将一些代码添加到onSurfaceCreated() 的末尾:
private fun Context.isDebugVersion(): Boolean =
runCatching {
(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}.getOrDefault(false)
if (context.isDebugVersion()) {
ShaderHelper.validateProgram(programId)
}
接下来我们应该做的是启用我们一直努力创建的OpenGL程序。将以下内容添加到onSurfaceCreated() 的末尾:
glUseProgram(programId)
我们调用glUseProgram() 告诉OpenGL,在屏幕上绘制某些东西时应当使用这里定义的程序。
获取Uniform的位置
下一步是获取我们之前在着色器中定义的uniform 变量的位置。当OpenGL将我们的着色器链接到一个程序中时,它实际上会将顶点着色器中定义的每个uniform 与一个位置编号相关联(it will actually associate each uniform defined in the vertex shader with a location number. )。这些位置编号用于将数据发送到着色器,我们需要u_Color 的位置,以便在绘制时设置颜色。
让我们快速回顾一下片元着色器的代码:
precision mediump float;
uniform vec4 u_Color;
void main() {
gl_FragColor = u_Color;
}
在我们的着色器中,我们定义了一个名为u_Color 的uniform 变量,并在main() 中将该uniform 变量指定给gl_FragColor 。我们将用这个uniform 变量来设定我们所画的东西的颜色。我们要用不同的颜色来画一张桌子,一条中心分界线,两个木槌。
让我们在AirHockeyRenderer 添加一些代码:
class AirHockeyRenderer(): GLSurfaceView.Renderer {
private var uColorLocation = 0
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
uColorLocation = glGetUniformLocation(programId, U_COLOR)
}
companion object {
private const val POSITION_COMPONENT_COUNT = 2
private const val BYTES_PER_FLOAT = 4
private const val U_COLOR = "u_Color"
}
}
首先,我们需要为uniform 变量的名称创建了一个常量,并创建一个变量来保存其在OpenGL程序对象中的位置。uniform 变量的位置无法事先指定,所以程序成功链接后,我们才需要去查询位置。uniform 的位置对于程序对象唯一:即使我们在两个不同的程序中包含相同的uniform 名称,它们也不会共享相同的位置。
然后我们调用glGetUniformLocation() 来获取uniform 的位置,并将该位置存储在uColorLocation 中,以便于使用它来更新uniform 的值。
获取属性的位置
与uniform 一样,我们也需要在使用属性(attribute)之前获得属性的位置。我们可以让OpenGL自动给这些属性分配位置编号,或者在将着色器链接到一起之前,我们可以通过调用glBindAttribLocation() 自行分配这些编号。目前我们让OpenGL自动分配属性位置,因为它使我们的代码更易于管理。
现在,我们只需要添加一些代码,以便在着色器链接在一起后获得属性位置。添加的代码的处理逻辑与uniform 变量类似,我们调用glGetAttriblLocation() 来获取属性的位置。有了这个位置,我们就可以告诉OpenGL在哪里找到这个属性的数据。
class AirHockeyRenderer(): GLSurfaceView.Renderer {
private var aPositionLocation = 0
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
aPositionLocation = glGetAttribLocation(programId, A_POSITION)
}
companion object {
private const val POSITION_COMPONENT_COUNT = 2
private const val BYTES_PER_FLOAT = 4
private const val U_COLOR = "u_Color"
private const val A_POSITION = "a_Position"
}
}
将顶点数据数组与Attribute关联
下一步是告诉OpenGL在哪里找到属性a_Position 的数据。将以下代码添加到onSurfaceCreated() 的末尾:
vertexData.position(0)
glVertexAttribPointer(aPositionLocation, POSITION_COMPONENT_COUNT, GL_FLOAT, false, 0,
vertexData)
这一章开始之前,我们创建了一个浮点值数组来表示组成空中曲棍球台的顶点的位置。我们在native内存中创建了一个名为vertexData 的缓冲区,并将这些位置复制到该缓冲区。
在我们告诉OpenGL从这个缓冲区读取数据之前,我们需要确保它从开始的位置读取数据,而不是从中间或结尾读取数据。每个缓冲区都有一个内部指针,可以通过调用position(int) 来移动,当OpenGL从我们的缓冲区读取时,它将在这个位置开始读取。为了确保它从一开始的位置读取,我们调用position(0) 将位置设置为数据的开头。(类似于flip ,可以使用vertexData.flip() 达到类似效果)
然后我们调用glVertexAttribPointer() 告诉OpenGL,它可以在缓冲区vertexData 中找到a_Position 的数据。这是一个非常重要的函数,所以让我们仔细看看每个参数传递的内容:
glVertexAttribPointer(int index, int size, int type, boolean normalized, int stride, Buffer ptr)
参数 | 介绍 |
---|
int index | 这是属性的位置,我们传入之前保存下来的aPositionLocation | int size | 这是每个attribute包含的数据的数目,或着说是与该attribute的每个顶点关联的分量的数目。我们在第二章决定每个顶点使用两个浮点值:一个x坐标和一个y坐标来表示位置。这意味着我们有两个分量,我们之前创建了常数的分量计数来包含这个事实,所以我们在这里传递这个常数。 请注意,每个顶点只传递了两个分量,但在着色器中,a_Position被定义为vec4,它有四个分量。如果未指定分量,OpenGL将默认将前三个分量设置为0,最后一个分量设置为1。 | int type | 这是数据的类型。我们将数据定义为一系列浮点值,所以我们传入GL_FLOAT。 | boolean normalized | 这只适用于使用整数数据的情况,因此我们现在可以安全地忽略它。 | int stride | 第五个参数的含义是步长,在单个数组中存储多个attribute时生效。在本章中,我们只有一个属性,所以我们可以忽略它,暂时传入0。我们后面将会更详细讨论stride。 | Buffer ptr | 这会告诉OpenGL在哪里读取数据。不要忘记,OpenGL将从缓冲区的当前位置开始读取,所以如果我们没有调用vertexData.position(0) 时,它可能会试图访问超出缓冲区末尾的地址,从而导致应用程序崩溃。 |
将不正确的参数传递给glVertexAttribPointer() 可能会导致奇怪的结果,甚至可能导致程序崩溃。这些类型的崩溃可能很难追踪,因此务必确保传入正确的参数。
启用顶点数组
现在我们已经将数据链接到属性,在开始绘制之前,需要通过调用glEnableVertexAttribArray() 来启用该属性。在调用glVertexAttribPointer() 后添加以下代码:
glEnableVertexAttribArray(aPositionLocation)
通过这最后一次调用,OpenGL现在知道在哪里可以找到它需要的所有数据。在本节中,我们检索了uniform 变量u_Color 和attribute 属性a_Position 的位置。每个变量都有一个位置,OpenGL使用这些位置来识别变量,而不是直接使用变量的名称。然后,我们调用glVertexAttribPointer() 告诉OpenGL,它可以从vertexData 中找到属性a_Position 的位置的数据。
现在的onSurfaceCreated 看起来就像这样:
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
glClearColor(0F, 0F, 0F, 0F)
val vertexShaderCode = context.readStringFromRaw(R.raw.simple_vertex_shader)
val fragmentShaderCode = context.readStringFromRaw(R.raw.simple_fragment_shader)
val vertexShader = ShaderHelper.compileVertexShader(vertexShaderCode)
val fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderCode)
programId = ShaderHelper.linkProgram(vertexShader, fragmentShader)
if (context.isDebugVersion()) {
ShaderHelper.validateProgram(programId)
}
glUseProgram(programId)
uColorLocation = glGetUniformLocation(programId, U_COLOR)
aPositionLocation = glGetAttribLocation(programId, A_POSITION)
vertexData.position(0)
glVertexAttribPointer(aPositionLocation, POSITION_COMPONENT_COUNT,
GL_FLOAT, false, 0, vertexData)
glEnableVertexAttribArray(aPositionLocation)
}
往屏幕绘制内容
最终连接就绪后,我们现在可以开始绘制屏幕了!我们先画桌子,然后画分界线和木槌。
画桌子
在onDrawFrame() 的glClear() 之后添加以下代码:
glUniform4f(uColorLocation, 1F, 1F, 1F, 1F)
glDrawArrays(GL_TRIANGLES, 0, 6)
首先,我们通过调用glUniform4f() 来更新着色器代码中u_Color 的值。与属性不同,uniform 的分量没有默认值,因此如果uniform 在着色器中定义为vec4 ,我们需要提供所有四个分量。我们想先画一张白色的桌子,所以我们将红色、绿色和蓝色设置为1F。alpha值无关紧要,但我们仍然需要指定它。
一旦指定了颜色,我们就可以通过调用glDrawArrays(GL_TRIANGLES, 0, 6) 来绘制桌子。第一个参数告诉OpenGL我们想要画三角形。要绘制三角形,我们需要在每个三角形中至少传递三个顶点。第二个参数告诉OpenGL从顶点数组的开头开始读取顶点(offset),第三个参数告诉OpenGL读取六个顶点。因为每个三角形有三个顶点,所以这个调用最终将绘制两个三角形。
当我们调用glVertexAttribPointer() 时,我们告诉OpenGL每个顶点的位置由两个浮点分量组成。我们对glDrawArrays() 的调用要求OpenGL使用前六个顶点绘制三角形,因此OpenGL将使用顶点数组的前12个浮点值绘制三角形。
绘制分界线
下一步是在桌子中间画一条中心分界线。将以下代码添加到onDrawFrame() 的末尾:
glUniform4f(uColorLocation, 1F, 0F, 0F, 1F)
glDrawArrays(GL_LINES, 6, 2)
我们把u_Color 修改为红色,然后要求OpenGL绘制线条。调用的方式类似于绘制三角形,所不同的是这次需要从第七个顶点开始绘制,一共需要绘制两个顶点。就像Java数组一样,我们在这里使用基于零的编号:0、1、2、3、4、5、6意味着数字6对应于第七个顶点。
把木槌画成点
绘制点的代码与上面的类似:
glUniform4f(uColorLocation, 0F, 0F, 1F, 1F)
glDrawArrays(GL_POINTS, 8, 1)
glUniform4f(uColorLocation, 1F, 0F, 0F, 1F)
glDrawArrays(GL_POINTS, 9, 1)
绘制点时需要传递的模式为GL_POINTS 。对于第一个木槌,我们将颜色设置为蓝色,从偏移8开始,并使用一个顶点绘制一个点。对于第二个木槌,我们将颜色设置为红色,从偏移9开始,并使用一个顶点绘制一个点。
到目前为止的效果图
让我们运行应用程序,看看屏幕上会显示什么。
嗯,有些地方看起来不太对劲!为什么我们只看到空中曲棍球桌的一角?
OpenGL如何将坐标映射到屏幕上
我们尚未解决的一个大问题是:OpenGL如何获取我们给定的坐标,并将其映射到屏幕上的实际物理坐标?
这个问题的答案很复杂,我们将在以后的章节中了解更多。现在,我们需要知道的是OpenGL将把屏幕映射到x和y坐标的范围[-1,1]。这意味着屏幕的左边缘将对应于x轴上的-1,而屏幕的右边缘将对应于+1。屏幕的下边缘将对应于y轴上的-1,而屏幕的上边缘将对应于+1。
无论屏幕的形状或大小如何,这个范围都保持不变,如果我们想让它显示在屏幕上,我们绘制的所有内容都需要在这个范围内。让我们根据范围重新给出tableVerticesWithTriangles 的坐标:
private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
-0.5F, -0.5F,
0.5F, 0.5F,
-0.5F, 0.5F,
-0.5F, -0.5F,
0.5F, -0.5F,
0.5F, 0.5F,
-0.5F, 0F,
0.5F, 0F,
0F, -0.25F,
0F, 0.25F,
)
让我们再次运行应用程序。我们现在应该看到类似下图的内容:
指定点的大小
让我们更新我们的代码,这样我们就可以告诉OpenGL这些点在屏幕上应该显示多大。在simple_vertex_shader.glsl中给gl_Position 赋值之后添加代码:
gl_PointSize = 10.0;
通过写入另一个特殊的输出变量gl_PointSize ,我们告诉OpenGL这些点的大小应该是10。你可能会问,赋值为10意味着什么?当OpenGL将点分解成fragments时,它将在一个以gl_Position 为中心的正方形中生成fragments,该正方形的边长将等于gl_PointSize 。gl_PointSize 越大,屏幕上绘制的点越大。
让我们再运行一次应用程序。我们现在应该看到如下图所示的木槌,每个木槌都渲染为一个点。
本章回顾
我们必须编写大量样板代码,才能最终显示我们的空气曲棍球桌的第一个版本,但好在,我们可以在未来的项目中重用这些代码。让我们花点时间回顾一下我们在本章学到的内容:
- 如何创建和编译着色器
- 顶点着色器和片元着色器总是一起使用,我们还学习了如何将它们链接到OpenGL程序对象中。
- 如何将顶点属性数组与顶点着色器中的属性变量关联
然后我们终于能够把所有的东西放在一起,这样我们就可以在屏幕上显示一些东西。
练习
试着在桌子中央画一个冰球。要了解更具挑战性的内容,请查看是否可以在桌子周围添加边框。你会怎么做?作为提示,看看你是否可以画两个矩形,每个都有不同的颜色。完成这些练习后,让我们进入下一章,学习如何让事情变得更加丰富多彩。
|