在上一篇中我们花了很大力气通过OpenGL画上了一个矩形,我们也做了很多的准备工作,包括编写着色器和编译,以及把它们连接起来成为一个OpenGL的程序对象。
物体在现实世界中会因光线的变化而展现出不同的颜色和亮度,这些细微的差别是视觉感知的重要线索。艺术家利用这些视觉线索来创造视觉上的错觉,使作品看起来更加立体。而本篇我们将通过渐变色使上一篇我们画出来的矩形更加现实。
平滑着色
上一篇中我们了解到如何在一个uniform里用单一的颜色绘制形状。而且我们也已经知道,我们只能画点、直线以及三角形,并且所有物体都以它们为基础构建。既然受限于这三个基本的图元,我们怎样用许多不同的颜色和着色表达一个复杂的场景呢?
我们能使用的一个方法就是使用大量的小三角形,每个三角形都有一个不同的颜色。如果使用足够多的三角形,我们就能欺骗观察者,让他们看到一幅美丽的、复杂的、有丰富颜色变化的场景。尽管这在技术上是可行的,但是性能和内存的开销也是非常恐怖的。
如果在一个三角形的每个点上都有一个不同的颜色,并在三角形的表面上混合这些颜色,我们最终将得到一个平滑着色的三角形,是不是会更好。
效果如下图:
渐变色是在顶点之间完成的
OpenGL提供了一种方法,可以在直线或三角形表面上平滑地混合每个顶点的颜色。我要使用这种方法目的是使图形中心看起来更明亮,而边缘显得较暗,模拟的效果就像是有一盏灯挂在矩形中心上方。而在做这些之前我们需要更新矩形的结构。我们现在使用的是用两个三角形绘制的矩形,如图所示。
我们如何才能让矩形的中心显现的更加明亮呢?中心并没有点,因此我们不知道从哪里开始或者向哪里出发去混合颜色。我们需要在矩形中心添加一个点,这样,就可以在桌子中间和边缘之间混合颜色。新的结构如下图:
三角扇形
随着矩形中间的新点加入,现在我们有了四个三角形,而不是两个。我们把这个新点放在了中心,坐标为(0,0)。现在我们接着上篇的demo,更新顶点坐标如下:
private var rectangleVertices = floatArrayOf(
0f , 0f
-0.5f , -0.5f,
0.5f , -0.5f,
0.5f , 0.5f,
-0.5f , 0.5f,
-0.5f , -0.5f,
)
可能有同学会问,我们4个三角形,不应该是12个点吗,为什么只有6个,讲到这里,我们就要了解下什么是三角扇形了。
三角扇形(Triangle Fan)是一种在计算机图形学中用来构建多边形表面的图元组合方式。它是一种高效的方法,用于通过较少的顶点数据来定义复杂的多边形形状。
在三角扇形中,第一个顶点(通常称为中心顶点或起始顶点)是固定不变的。从这个中心顶点开始,后续的顶点与中心顶点以及前一个顶点一起形成一个三角形。这样,每个新顶点都会与中心顶点和它前面的顶点形成一个三角形,从而创建出一个扇形的形状。
其特点是,三角扇形使用较少的顶点来定义多边形,这可以减少数据传输和处理的需要,提高渲染效率。通过改变中心顶点或后续顶点的位置,可以轻松调整多边形的形状和大小。三角扇形适用于创建具有共同顶点的多边形,例如圆形、扇形区域或其他不规则多边形。
了解完什么是三角扇形,那么接下来我们更新代码,以画出这个三角扇形,在Renderer的onDrawFrame()中更新画三角形的方法:
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN,0,6)
这样就会让OpenGL使用我们已经定义过的6个新点绘制一个三角扇形。
给顶点添加颜色属性
我们已经更新了矩形的数据结构,接下来我们就可以为每个顶点添加上对应的颜色属性了,我们再次更新下顶点数组:
private var rectangleVertices = floatArrayOf(
0f , 0f , 1f , 1f , 1f ,
-0.5f , -0.5f , 0.3f , 0.3f , 0.3f ,
0.5f , -0.5f , 0.3f , 0.3f , 0.3f ,
0.5f , 0.5f , 0.3f , 0.3f , 0.3f ,
-0.5f , 0.5f , 0.3f , 0.3f , 0.3f ,
-0.5f , -0.5f , 0.3f , 0.3f , 0.3f
)
正如所看到的,我们给每个顶点添加了三个额外的数字,这三个额外的数字分别代表红、绿、蓝三种三色分量,它们共同构成了这些顶点的特定颜色。
更新着色器
因为顶点数组为每个顶点添加了颜色属性,因此对应的顶点着色器更新如下:
attribute vec4 a_Position;
attribute vec4 a_Color;
varying vec4 v_Color;
void main(){
v_Color = a_Color;
gl_Position = a_Position;
}
我们添加了一个a_Color的属性,也加入了一个叫做v_Color的新varying。可能有同学会问varying是什么?
在OpenGL着色器编程中,varying 是一种特殊的变量类型,用于在顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)之间传递数据。varying 变量允许在顶点处理阶段计算的值在光栅化过程中被插值,并在片段处理阶段被使用。
varying 变量在顶点着色器中被赋值后,GPU会自动在顶点之间进行插值,为每个片段生成相应的值。它们通常用于传递需要在片段着色器中使用的数据,例如顶点的颜色、纹理坐标等。通过使用 varying 变量,可以实现平滑着色(Smooth Shading),即在三角形或多边形内部的颜色和属性的平滑过渡。
既然varying自动生成的过渡色最终生效于片段着色器,那么片段着色器代码更新如下:
precision mediump float;
varying vec4 v_Color;
void main(){
gl_FragColor = v_Color;
}
使用varying变量v_Color来替换原来的uniform变量,以便在着色过程中实现颜色的平滑过渡。OpenGL会根据构成几何图元(直线或三角形)的顶点的颜色来计算片段的颜色。对于直线,使用两个顶点的颜色进行混合;对于三角形,则使用三个顶点的颜色进行混合。
既然已经更新了着色器代码,那么我们也需要更新kotlin代码,以便我们传递新的颜色属性给顶点着色器中的a_Color。
在Renderer中新增如下常量:
private val A_COLOR = "a_Color"
private val COLOR_COMPONENT_COUNT = 3
private val STRIDE = (POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT) * BYTE_PER_FLOAT
同时再新增一个成员变量:
private var aColorLocation = 0
现在可以去掉那些旧的常量和u_Color相关的变量及代码了。
由于现在顶点数据数组中同时包含了位置和颜色属性,OpenGL需要一个STRIDE常量来确定数据的布局。这个常量告诉OpenGL在读取完一个顶点的位置后,需要跳过多少字节才能读取下一个顶点的位置。Stride告诉OpenGL每个顶点属性之间的字节数,这样OpenGL知道在读取顶点属性时需要跳过多少数据。
接下来就是在Renderer中的onSurfaceCreated()新增如下代码:
aColorLocation = GLES20.glGetAttribLocation(program,A_COLOR)
还需要继续更新glVertexAttribPointer()函数,添加Stride属性。
GLES20.glVertexAttribPointer(aPositionLocation,POSITION_COMPONENT_COUNT,GLES20.GL_FLOAT,false,STRIDE,vertices)
现在,我们就可以加入代码告诉OpenGL把顶点数据与着色器中的a_Color关联起来了。在onSurfaceCreated()中继续新增如下代码:
vertices.position(POSITION_COMPONENT_COUNT)
GLES20.glVertexAttribPointer(aColorLocation,COLOR_COMPONENT_COUNT,GLES20.GL_FLOAT,false,STRIDE,vertices)
GLES20.glEnableVertexAttribArray(aColorLocation)
这些代码比较重要,因此让我们花点时间仔细地理解每一行代码:
-
首先,我们把vertices的位置设为POSITION_COMPONENT_COUNT,这个值被设为2。为什么执行这一步呢?这是因为,当OpenGL开始读入颜色属性时,我们要它从第一个颜色属性开始,而不是第一个位置属性。当需要跳过第一个位置时,我们就要把位置分量的大小计算在内;把那个位置设置为POSITION_COMPONENT_COUNT,缓冲区的指针就被调整到第一个颜色属性的 位置了。相反,如果我们把位置设为0,OpenGL就会把位置当作颜色读进来了。
-
接下来,我们调用glVertexAttribPointer把颜色数据和着色器中的a_Color关联起来。那个STRIDE告诉OpenGL每个颜色之间有多少个字节,这样,当需要读入所有顶点的颜色时,它就知道要读取下一个顶点的颜色需要跳过多少个字节了。跨距以字节为单位是非常重要的。尽管OpenGL中的一种颜色有四个分量(红色、绿色、蓝色和阿尔法),我们并不必指定所有分量的值。不像uniform,OpenGL会用默认值替换属性中未指定值的分量:前三个分量会被设为0,而最后一个分量会被设为1。
-
最后,就像前面讲过的位置属性一样,我们要为颜色属性使能顶点属性数组。
varying是如何给每个片段设置颜色的
这里主要是作为一个了解项,不感兴趣的同学可以跳过。
直线或三角形上的每个片段混合后的颜色可以用一个varying生成。我们不仅能混合颜色,还可以给varying传递任何值,OpenGL会选取属于那条直线的两个值,或者属于那个三角形的三个值,并平滑地在那个基本图元上混合这些值,每个片段都会有一个不同的值。这种混合是使用线性插值(linear interpolation)实现的。要了解它是怎么工作的,让我们首先以一条直线为例开始讲解。
沿着一条直线做线性插值
假设有一条直线,它有一个红色顶点和一个绿色顶点,我们要从一个顶点向另外一个顶点混合颜色。混合后的颜色看上去如下图:
从左到右,红色分量从100%逐渐减少到0%。而从左到右,绿色分量从0%逐渐增加到100%。在直线的中间位置,红色和绿色分量各占50%。通过将红色和绿色分量叠加,形成一条混合后的颜色直线。
一旦我们把这两种颜色叠加在一起,最终就得到了一条混合后的直线。这就是线性插值的基本解释。每种颜色的强度依赖于每个片段与包含那个颜色的顶点距离。
为了计算这些,我们可以用顶点0和顶点1的值,计算出当前片段对应的距离比,距离比仅仅是0到100之间的百分比,0%是左边的顶点,而100%就是右边的顶点,当我们从左往右移动,这个距离比例也会从0%向100%线性增加。
要使用线性插值计算实际混合后的值,我们可以使用下面的公式:
blended_value=(vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio)
这个计算公式是应用于每个分量的,即分别应用于颜色值的红色、绿色、蓝色和阿尔法(透明度)分量。如果处理颜色值,计算就会分别应用在这些颜色分量上,计算结果合并成一个新的颜色值。
假如设定起始顶点(vertex_0_value)为红色,其RGB值为(1, 0, 0),设定结束顶点(vertex_1_value)为绿色,其RGB值为(0, 1, 0)。计算下这条线段上的几个位置的颜色。
位置 | 距离比 | 公式 |
---|---|---|
最左端 | 0% | (vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio) = (1,0,0)×(100%−0%)+(0,1,0)×0% = (1, 0, 0)(红色) |
直线的四分之一处 | 25% | (vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio) = (1,0,0)×(100%−25%)+(0,1,0)×25% = (0.75, 0, 0) + (0, 0.25, 0) = (0.75, 0.25, 0)(大红) |
中间 | 50% | (vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio) = (1,0,0)×(100%−50%)+(0,1,0)×50% = (0.5, 0, 0) + (0, 0.5, 0) = (0.5, 0.5, 0)(半红半绿) |
直线的四分之三处 | 75% | (vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio) = (1,0,0)×(100%−75%)+(0,1,0)×75% = (0.25, 0, 0) + (0, 0.75, 0) = (0.25, 0.75, 0)(大绿) |
最右端 | 100% | (vertex_0_value×(100%−distance_ratio))+(vertex_1_value×distance_ratio) = ((1,0,0)×0%)+((0,1,0)×100%) = (0,0,0)+(0,1,0) = (0,1,0)(绿色) |
要注意到,任何时候两个颜色的权重加起来都是100%。如果红色是100%,绿色就是0%;如果红色是50%,那绿色就是50%。
使用一个varying,我们就可以把任何两种颜色混合在一起。当然,这不只限于颜色:任何其他属性也可以应用插值技术。
既然我们知道了一条直线上的线性插值是如何工作的,让我们继续阅读,看看在一个三角形上它是如何工作的。
在一个三角形表面上混合
当我们只处理两个点的时候,阐明线性插值是怎么工作的并不困难;我们知道,从某个颜色的一个顶点到另外一个顶点,其比例从100%到0%缩减,所有按比例缩减的颜色合在一起就得到了最后的颜色。
在一个三角形上的线性插值也是一样的工作原理,但是现在需要处理三个点和三种颜色。
让我们看一个直观的例子:
这个三角形与三种颜色有关联:顶端顶点是青色,左端顶点是品红色,右端顶点是黄色。
让我们把这个三角形按每个顶点衍生出来的颜色进行分解:就像那条直线一样,每个颜色在接近它的顶点处都是最强的,向其他顶点移动就会变暗。我们同样用比例确定每种颜色的相对权重,但这次要使用面积的比例,而不是长度。
对于这个三角形内任何给定的点,从那个点向每个顶点所对应的点画一条直线就可以生成三个内部三角形。这三个内部三角形的面积的比例决定了那个点上每种颜色的权重。
比如,那个点上黄色的强度就取决于黄色顶点相对的那个内部三角形的面积。距离黄色顶点越近的点,它相对的三角形就越大,在那个点的片段就越显黄。
与直线一样,这些权重之和也总是等于100%。可以使用下面的公式计算三角形内任何一个点的颜色分量:
blended_value =
(vertex_0_value * vertex_o_weight)+(vertex_1_value *vertex_1_weight)+(vertex_2_value * (100% - vertex_0_weight -vertex_1_weight))
我们已经理解了它在直线上是怎么工作的,在这种情况下,我们就不再为此举出具体的例子了。原理是一样的,只是这次要处理三个点而不是两个。
最终的运行结果
运行这个程序,看一下我们得到了什么;它看起来应该如图所示。
这个矩形看起来比以前更好了,而且我们也的确看到了桌子中心比边缘处更加明亮。可是,我们也让每个三角形的形状变得突出了。之所以会这样,是因为线性插值的方向是顺着三角形的,因此当三角形内部看起来平滑的时候,有时候我们会看到在一个三角形结束的地方接上另外一个三角形了。
为了减少或消除这种效果,我们可以使用更多的三角形或者使用一个光照算法并以每个片段为基础计算颜色。我们会在后续的学习中学习更多关于光照算法的内容。
小结
因为我们前面两篇学习,已经有了一个基本的框架,给每个顶点增加颜色并不太困难。为此,我们给顶点数据和顶点着色器增加了一个新的属性,并且告诉OpenGL如何使用跨距读人数据。
接着我们学习了如何使用一个varying在三角形平面上进行插值。
还有一个需要记住的重要内容:当传递属性数据时,我们要确保给分量计数和跨距传递正确的值。如果它们错了,我们最终可能会看到一个混乱的屏幕,甚至崩溃。
附上关键类的完整代码
class GradientColorRenderer1:Renderer {
private val POSITION_COMPONENT_COUNT = 2
private val BYTE_PER_FLOAT = 4
private val COLOR_COMPONENT_COUNT = 3
private val STRIDE = (POSITION_COMPONENT_COUNT + COLOR_COMPONENT_COUNT) * BYTE_PER_FLOAT
private var context: Context
private var vertices:FloatBuffer
private var program = 0
private val A_COLOR = "a_Color"
private var aColorLocation = 0
private val A_POSITION = "a_Position"
private var aPositionLocation = 0
private var rectangleVertices = floatArrayOf(
0f , 0f , 1f , 1f , 1f ,
-0.5f , -0.5f, 0.3f , 0.3f , 0.3f ,
0.5f , -0.5f, 0.3f , 0.3f , 0.3f ,
0.5f , 0.5f, 0.3f , 0.3f , 0.3f ,
-0.5f , 0.5f, 0.3f , 0.3f , 0.3f ,
-0.5f , -0.5f, 0.3f , 0.3f , 0.3f ,
)
constructor(context: Context){
this.context = context
vertices = ByteBuffer.allocateDirect(rectangleVertices.size*BYTE_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
vertices.put(rectangleVertices)
}
override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {
GLES20.glClearColor(0f,0f,0f,1f)
var vertexShaderSource = TextResourceReader.readTextFromResource(context,R.raw.simple_vertex_shader)
var fragShaderSource = TextResourceReader.readTextFromResource(context,R.raw.simple_fragment_shader)
var vertexShader = ShaderHelper.compileVertexShader(vertexShaderSource)
var frgShader = ShaderHelper.compileFragmentShader(fragShaderSource)
program = ShaderHelper.linkProgram(vertexShader,frgShader)
if(LoggerConfig.ON){
ShaderHelper.validateProgram(program)
}
GLES20.glUseProgram(program)
aPositionLocation = GLES20.glGetAttribLocation(program,A_POSITION)
aColorLocation = GLES20.glGetAttribLocation(program,A_COLOR)
vertices.position(0)
GLES20.glVertexAttribPointer(aPositionLocation,POSITION_COMPONENT_COUNT,GLES20.GL_FLOAT,false,STRIDE,vertices)
GLES20.glEnableVertexAttribArray(aPositionLocation)
vertices.position(POSITION_COMPONENT_COUNT)
GLES20.glVertexAttribPointer(aColorLocation,COLOR_COMPONENT_COUNT,GLES20.GL_FLOAT,false,STRIDE,vertices)
GLES20.glEnableVertexAttribArray(aColorLocation)
}
override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {
GLES20.glViewport(0,0,width,height)
}
override fun onDrawFrame(p0: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN,0,6)
}
}