OpenGL ES 2.0 for Android教程(四):添加颜色和阴影

OpenGL ES 2 第四章:添加颜色和阴影

文章传送门

OpenGL ES 2.0 for Android教程(一)

OpenGL ES 2.0 for Android教程(二)

OpenGL ES 2.0 for Android教程(三)

OpenGL ES 2.0 for Android教程(五)

OpenGL ES 2.0 for Android教程(六)

OpenGL ES 2.0 for Android教程(七)

OpenGL ES 2.0 for Android教程(八)

OpenGL ES 2.0 for Android教程(九)

在现实世界中,对象具有不同的颜色和阴影。如果我们看看家里的墙,可以看到它被涂成了单一颜色。同时,墙的某些部分看起来会更暗或更亮,这取决于朝向周围光线的角度。我们的大脑利用这些细微的阴影差异作为主要的视觉线索之一,帮助理解我们所看到的东西;艺术家们从一开始就一直在利用这些线索来欺骗我们的大脑。在本章中,我们将向艺术家学习,并使用不同的颜色和阴影使我们的桌子看起来不那么平坦,更逼真。

在上一章中,要把我们的曲棍球桌画到屏幕上需要做很多工作,包括两个木槌和桌子中间的一条分界线。有很多准备工作,包括编写代码来加载和编译着色器,并将它们链接到OpenGL程序对象中。

OpenGL ES 2.0比较好的一点就是很多开销都是从一开始就产生的,从现在开始情况会有所好转。虽然还会添加更多的代码,但我们将能够在接下来的每一章中重用所有之前写的那些基本代码。事实上,现在我们有了一个基本的框架,为场景添加不同的颜色和阴影会容易得多。

以下是本章的游戏计划:

  • 首先,我们学习如何将每个点的颜色定义为顶点属性,而不是对整个对象使用单一颜色。
  • 然后我们将学习如何在组成对象的不同顶点之间平滑地混合这些颜色。

我们可以在原先的AirHockeyRender进行修改,也可以新建一个新的AirHockeyRender2类并把AirHockeyRender原来的内容拷贝进去。

平滑着色

在第2章“定义顶点和着色器”中,我们学习了如何以统一的颜色绘制对象,如下图所示:

我们已经知道,我们只能绘制点、线和三角形,而我们必须从这些基本元素开始构建一切。由于我们仅限于这三种基本元素,我们如何用不同的颜色和色调来表示复杂的场景?

一种方法是使用一百万个小三角形,每个三角形都有不同的颜色。如果我们使用足够多的三角形,我们可以骗过观众,让他们看到一个美丽、复杂、色彩变化丰富的场景。虽然这在技术上是可行的,但性能和内存开销也会非常糟糕。

如果有一种方法可以在同一个三角形上混合不同的颜色,而不是绘制一组平面三角形呢?如果我们在三角形的每个顶点上使用不同的颜色,并将这些颜色混合到整个三角形的表面上,我们最终会得到一个平滑着色的三角形,就像下面这样:
在这里插入图片描述

在顶点之间进行平滑着色

OpenGL为我们提供了一种方法,可以平滑地混合(blend)直线或三角形平面上每个顶点的颜色。我们将使用这种类型的阴影使我们的桌子在中间显得更亮,朝向边缘变暗,好像有一盏灯在桌子中间盘旋。不过,在完成这项工作之前,我们需要更新桌子的结构。以前我们用两个三角形绘制桌子,如下图所示:

在这里插入图片描述

我们怎么才能让它在中间看起来更亮?现在的桌子设计中,中间并不存在顶点,所以没有什么东西可以混合或变暗(颜色混合以顶点为基础)。我们需要在中间添加一个点,这样我们就可以在桌子中间和边缘之间混合颜色。我们的桌子结构如下所示:

在这里插入图片描述

我们需要做的第一件事是更新我们的数据以匹配这个新结构。

介绍三角扇形(Triangle Fans)

在表格中间有一个新的点,我们将用四个三角形代替两个三角形。我们将把新的点放在(0, 0)。让我们更新AirHockeyRenderer类,如下所示:

private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
    // 三角形扇形,注意第二个顶点和第六个顶点的坐标完全一样
    0F, 0F,
    -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重用这些顶点。我们可以把这些顶点画成一个三角形扇形。三角形扇形看起来像下图:
在这里插入图片描述

三角形扇形绘制选项实际上是从一个中心顶点(0,0)开始,使用下两个顶点(-0.5,-0.5)、(0.5,-0.5)来创建第一个三角形。最后的顶点(0.5,-0.5)将参与创建下一个三角形,围绕原始中心点呈扇形展开。为了完成扇形,我们只需在最后重复编号为2的点(或者给个新的编号6)。

我们需要更新draw调用,以便OpenGL知道这个数据代表一个三角形扇形。在onDrawFrame()中,更新对第一个glDrawArrays()的调用,使用下面提供的这一行代码来替换:

glDrawArrays(GL_TRIANGLE_FAN, 0, 6)

巧合的是,上述代码其实只修改了glDrawArrays传入的GL_TRIANGLE_FAN,数据的数目完全没有改变。

这将要求OpenGL使用我们定义的六个新点绘制一个三角形扇形。让我们快速运行一下应用程序,桌子看上去应该和以前一样,看不出来它是借助三角形扇形画出来的,如下图所示:

在这里插入图片描述

现在我们已经用一个中心点重新定义了桌子,让我们学习如何将颜色作为第二个属性添加到每个顶点。

添加新的颜色属性

我们已经通过在表格中心添加一个额外的顶点来更新桌子的结构,现在我们可以为每个点添加一个颜色属性。让我们更新整个数据数组,如下所示:

private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
    // 属性的顺序: X, Y, R, G, B 
    // 三角形扇形
    0f, 0f, 1f, 1f, 1f,
    -0.5f, -0.5f, 0.7f, 0.7f, 0.7f,
    0.5f, -0.5f, 0.7f, 0.7f, 0.7f,
    0.5f, 0.5f, 0.7f, 0.7f, 0.7f,
    -0.5f, 0.5f, 0.7f, 0.7f, 0.7f,
    -0.5f, -0.5f, 0.7f, 0.7f, 0.7f,
    // 中线
    -0.5f, 0f, 1f, 0f, 0f,
    0.5f, 0f, 1f, 0f, 0f,
    // 两个木槌
    0f, -0.25f, 0f, 0f, 1f,
    0f, 0.25f, 1f, 0f, 0f
)

如你所见,我们为每个顶点增加了三个额外的数字。这些数字代表红色、绿色和蓝色,它们一起构成特定顶点的颜色。

可以使用Android的Color类来获取你想要的颜色比例:

val parseColor = Color.parseColor("#BB86FC")
val red: Float = Color.red(parseColor) / 255F
val green: Float = Color.green(parseColor) / 255F
val blue: Float = Color.blue(parseColor) / 255F

将颜色属性添加到着色器

下一步将从着色器中移除uniform颜色变量,并用属性替换它。然后,我们将更新Kotlin代码以反映新的着色器代码。打开simple_vertex_shader.glsl并且更新:

attribute vec4 a_Position;
attribute vec4 a_Color;

varying vec4 v_Color;

void main() {
    v_Color = a_Color;
    
    gl_Position = a_Position;
    gl_PointSize = 10.0;
}

我们添加了一个新的attributea_Color,还添加了一个新的varying变量v_Colorvarying代表什么意思?还记得我们说过我们希望我们的颜色在三角形平面上变化吗?这是通过使用一种特殊的varying变量类型来实现的。为了更好地理解变量的作用,让我们回顾一下OpenGL如何将顶点组合在一起创建对象。(第二章的内容)

正如我们在“介绍OpenGL管道”章节中了解到的,当OpenGL构建一条线时,它会获取构成该线的两个顶点并为其生成fragment。当OpenGL构建一个三角形时,它一样会使用三个顶点来生成fragment,构建一个三角形。然后,将为生成的每个fragment运行fragment shader。

varying是一种特殊类型的变量,它混合给定给它的值,并将这些值发送到Fragment Shader。以画直线为例,如果a_Color在顶点0处为红色,在顶点1处为绿色,那么如果将a_Color赋值给v_Color,我们实际上相当于在告诉OpenGL,我们希望每个fragment接收一个混合颜色(blended color)。在顶点0附近,混合的颜色大部分为红色,随着fragments接近顶点1,颜色将开始变为绿色。

在我们详细介绍如何进行这种混合之前,让我们也向Fragment Shader添加varying变量。打开simple_fragment_shader.glsl并更新如下:

precision mediump float;
varying vec4 v_Color;

void main() {
    gl_FragColor = v_Color;
}

我们用varying变量v_Color替换了以前的uniform变量。如果这些片元属于某条直线,那么OpenGL将使用组成该线的两个顶点来计算混合颜色。如果片元属于三角形,则OpenGL将使用组成该三角形的三个顶点来计算混合颜色。

现在我们已经更新了着色器,我们还需要更新Kotlin代码,以便将新的颜色属性的值传递给顶点着色器中a_Color。在此之前,让我们花一些时间来了解OpenGL如何从一个点到另一个点平滑地混合颜色。

Varying变量如何为每个片元计算混合颜色

我们刚刚了解到,我们可以使用varying 变量在直线或三角形的每个片元上产生混合颜色。我们可以混合的不仅仅是颜色,我们可以将任何值发送到一个varying 变量,OpenGL将获取属于一条直线的两个值,或属于一个三角形的三个值,并在图元(primitive)中平滑地混合这些值,每个片元使用不同的值。这种混合是使用线性插值完成的。为了了解它是如何工作的,我们先举一个直线的例子:

沿着直线的线性插值

假设我们有一条带有红色顶点和绿色顶点的直线,我们想把颜色从一个混合到另一个。混合的颜色看起来像这样:
在这里插入图片描述

在线条的左侧,每个片元的颜色大部分是红色。当我们向右移动时,片元变得不那么红了,在中间,它们在红色和绿色之间。当我们接近绿色顶点时,片元变得越来越绿。

我们可以看到,每种颜色沿线条的长度呈线性缩放。由于直线的左顶点为红色,右顶点为绿色,因此直线的左端应为100%红色,中间应为50%红色,右侧应为0%红色:

在这里插入图片描述

绿色同理。由于左顶点为红色,右顶点为绿色,因此直线的左端为0%绿色,中间为50%绿色,右侧为100%绿色:
在这里插入图片描述
一旦我们将两者相加,我们就得到了一条混合线:

在这里插入图片描述

总而言之这就是线性插值。每种颜色的强度取决于每个片元与包含该颜色的顶点之间的距离。

为了计算这个,我们可以取顶点0处的值和顶点1处的值,然后计算当前片元的距离比。距离比只是一个介于0%和100%之间的比率,0%是左顶点,100%是右顶点。当我们从左向右移动时,距离比将从0线性增加到100%。以下是几个距离比的示例:

在这里插入图片描述

要使用线性插值计算实际混合值,我们可以使用以下公式:

blended_value = (vertex_0_value * (100% – distance_ratio)) + (vertex_1_value * distance_ratio)

这个计算针对每个分量进行,因此如果我们处理颜色值,线性插值计算将分别针对红色、绿色、蓝色和alpha分量进行,并将结果组合成一个新的颜色值。使用不同的颜色时,我们可以将任意两种颜色混合在一起,但不仅限于颜色,我们也可以对其他属性进行插值。

现在我们知道了线性插值是如何处理直线的,接下来我们来看看它是如何处理三角形的。

在三角形平面上进行混合

线性插值在三角形平面内使用类似的数学原理,但现在有三个点和三种颜色需要处理。让我们看一个直观的例子:

在这里插入图片描述

这个三角形有三种颜色:上顶点是青色,左顶点是品红,右顶点是黄色。让我们将三角形分解为每个顶点衍生的颜色:

在这里插入图片描述

就像线条一样,每种颜色在其顶点附近最强,并向其他顶点淡出。我们依然使用比例来确定每种颜色的相对权重,但这次我们使用的是面积比例而不是长度:

在这里插入图片描述

在三角形内部的任何给定点,通过从该点到每个顶点绘制一条线,可以创建三个内部三角形。这些内三角形的面积比决定了该点每种颜色的权重。例如,黄色在该点的强度由黄色顶点对面的内三角形的面积决定。点离黄色顶点越近,三角形越大,该点的片元越黄。

就像线一样,这些比例加起来总是100%。我们可以使用以下公式计算三角形内任意点的颜色:

blended_value = (vertex_0_value * vertex_0_weight) + (vertex_1_value * vertex_1_weight) + (vertex_2_value * (100% – vertex_0_weight – vertex_1_weight))

使用新颜色属性进行渲染

现在我们已经向数据中添加了一个颜色属性,并更新了顶点和片段着色器以使用该属性,接下来的步骤是删除通过uniform变量输入颜色的Kotlin代码,并告诉OpenGL将颜色作为顶点属性读取。

更新常量

AirHockeyRenderer内添加一个成员和一些常量:

/**
 * 缓存a_Color的位置
 */
private var aColorLocation = 0
companion object {
    
    
    private const val A_COLOR = "a_Color"
    private const val COLOR_COMPONENT_COUNT = 3
    private const val STRIDE =
            (POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT) * BYTES_PER_FLOAT
}

我们现在可以删除与u_Color相关的旧常量和变量。你注意到我们增加了一个特殊的常数,叫做STRIDE吗?由于我们现在在同一个数据数组中有一个位置和一个颜色属性,OpenGL不能再假设下一个位置紧跟在前一个位置之后。一旦OpenGL读取了一个顶点的位置,如果它想读取下一个顶点的位置,就必须跳过当前顶点的颜色。我们将使用STRIDE来告诉OpenGL每个位置数据之间有多少字节,以便它知道需要跳过多远。我们可以在下图中看到一个可视化的例子,展示我们的顶点数组当前如何存储数据。

STRIDE(步幅)告诉OpenGL每个位置或每个颜色之间的间隔。另外,我们还可以为每个属性使用多个顶点数组,如下图所示。虽然将所有内容打包到单个数组通常更高效,但如果我们需要定期更新所有颜色或所有位置,使用多个数组可能更好。

在这里插入图片描述

更新onSurfaceCreated()的代码

下一步将更新onSurfaceCreated(),以反映新的颜色属性。我们首先需要获得新属性的位置,所以让我们删除与u_Color相关的代码,并添加以下代码:

aColorLocation = glGetAttribLocation(programId, A_COLOR)

然后我们还需要更新对glVertexAttribPointer()的调用:

// 传输顶点数组
glVertexAttribPointer(
    aPositionLocation, POSITION_COMPONENT_COUNT, GL_FLOAT, false, STRIDE, vertexData
)

现在,我们可以添加代码,告诉OpenGL将顶点数据与着色器中的颜色相关联。将以下代码添加到onSurfaceCreated()的末尾:

// 重置position,使其对准第一个color数据的开头
vertexData.position(POSITION_COMPONENT_COUNT)
glVertexAttribPointer(
    aColorLocation, COLOR_COMPONENT_COUNT, GL_FLOAT, false, STRIDE, vertexData
)
glEnableVertexAttribArray(aColorLocation)

这是一段重要的代码,所以让我们花时间仔细理解每一行:

  1. 首先我们将vertexData的位置设置为POSITION_COMPONENT_COUNT,也就是2。我们为什么要这样做?当OpenGL开始读取颜色属性时,我们希望它从第一个颜色属性开始,而不是第一个位置属性。我们将将缓冲区的位置设置为第一个颜色属性的位置,如果我们将位置设置为0,OpenGL将试图从该位置开始读取颜色信息,这很容易导致错误:坐标可以是负的,但颜色不能为负数。

  2. 然后调用glVertexAttribPointer()将颜色数据与着色器中的a_Color相关联。步幅STRIDE告诉OpenGL每种颜色之间有多少字节,因此当它读取所有顶点的颜色时,它知道需要跳过多少字节才能读取下一个顶点的颜色。以字节为单位指定步幅是非常重要的。

    即使OpenGL中的颜色有四个分量(红色、绿色、蓝色和alpha),我们也不必全部指定它们。与uniform变量不同,OpenGL将用默认值替换attribute中未指定的分量:前三个分量将设置为0,最后一个分量将设置为1。

  3. 最后,我们为颜色属性启用顶点属性(we enable the vertex attribute for the color attribute),就像我们为位置属性所做的那样。

更新onDrawFrame()代码

我们还有一件事要做:更新onDrawFrame()内的代码。我们需要做的就是删除对glUniform4f()的调用,因为我们不再需要设置uniform变量的值。我们已经将我们的顶点数据与一种颜色相关联,所以我们只需要调用glDrawArrays(),OpenGL就会自动从我们的顶点数据中读取颜色属性。

目前达到的效果

在这里插入图片描述

我们的桌面曲棍球桌看起来比以前更漂亮了,我们可以清楚地看到桌面中间的颜色比边缘的颜色更亮。然而,我们也能辨认出每个三角形的形状。这是因为线性插值的方向是沿着三角形的,所以虽然三角形内部看起来很平滑,但我们有时仍然可以发现两个三角形之间的交界处很显眼。

为了减少或消除这种影响,我们可以使用更多的三角形来达到更平滑的效果,或者我们可以使用照明算法,并在每个片段的基础上计算颜色值。我们将在以后学习更多关于照明算法的内容。

本章小结

既然我们已经有了一个基本的框架,给每个顶点添加颜色并不是那么糟糕。为此,我们为顶点数据和顶点着色器添加了一个新属性,我们还告诉OpenGL如何使用STRIDE读取这些数据。然后,我们学习了如何通过使用varying变量在三角形平面内对数据进行插值。

需要记住的重要一点是,当我们传入属性数据时,我们需要确保传入正确的组件计数和步幅值。如果我们做错了这些,我们可能会导致屏幕乱码甚至崩溃。

练习

看看你能不能给横穿屏幕中间的线条添加一些颜色插值。对于一个更具挑战性的练习,你会如何改变组成空中曲棍球台的三角形,使其边缘不那么显眼?提示:你可以尝试向三角形扇形添加更多三角形。

完成这些练习后,我们将开始学习向量和矩阵,并学习如何解决从纵向旋转到横向时出现的棘手问题。

猜你喜欢

转载自blog.csdn.net/chong_lai/article/details/123653862