1、概述
前面几篇关于OpenGLES的文章:
现实世界中有无数种颜色,每一个物体都有它们自己的颜色。开发中需要使用(有限的)数值来模拟真实世界中(无限)的颜色,所以并不是所有现实世界中的颜色都可以用数值来表示的。但仍能通过数值来表现出非常多的颜色,甚至你可能都不会注意到与现实的颜色有任何的差异。颜色可以数字化的将颜色由红色、绿色和蓝色三个分量组成,它们通常被缩写为RGB。仅仅用这三个值就可以组合出任意一种颜色。
在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的颜色。换句话说,那些不能被物体所吸收的颜色就是肉眼能够感知到的物体的颜色。例如,太阳光能被看见的白光其实是由许多不同的颜色组合而成的。如果将白光照在一个蓝色的玩具上,这个蓝色的玩具会吸收白光中除了蓝色以外的所有子颜色,不被吸收的蓝色光被反射到眼中,让这个玩具看起来是蓝色的。下图显示的是一个珊瑚红的玩具,它以不同强度反射了多个颜色。
可以看到,白色的阳光实际上是所有可见颜色的集合,物体吸收了其中的大部分颜色。它仅反射了代表物体颜色的部分,被反射颜色的组合就是所感知到的颜色。
这些颜色反射的定律被直接地运用在图形领域。当在OpenGL ES中创建一个光源时,希望给光源一个颜色。比如可以将光源设置成光源设置为白色。当把光源的颜色与物体的颜色值相乘,所得到的就是这个物体所反射的颜色,也就是所感知到的颜色。
vec3 lightColor = vec3(1.0f, 1.0f, 1.0f);
vec3 toyColor = vec3(1.0f, 0.5f, 0.31f);
vec3 result = lightColor * toyColor; // = (1.0f, 0.5f, 0.31f);
可以看到玩具的颜色吸收了白色光源中很大一部分的颜色,但它根据自身的颜色值对红、绿、蓝三个分量都做出了一定的反射。这也表现了现实中颜色的工作原理。由此,可以定义物体的颜色为物体从一个光源反射各个颜色分量的大小 。如果用绿色光源(0,1,0)来照射玩具,那么只有绿色分量能被反射和感知到,红色和蓝色都不能被感知到。这样做的结果是,一个珊瑚红的玩具突然变成了深绿色。或者一个深橄榄绿色的光源(0.33f, 0.42f, 0.18f) 打到上面的玩具上最后会产生如下颜色:
vec3 lightColor = vec3(0.33f, 0.42f, 0.18f);
vec3 toyColor = vec3(1.0f, 0.5f, 0.31f);
vec3 result = lightColor * toyColor; // = (0.33f, 0.21f, 0.06f);
这和现实世界中的颜色规律是相符合的。
2、光照场景
这一节主要创建一个之后要用的光照场景,方便后面的讨论。首先需要一个物体来作为被投光的对象,将使用前面文章中提到的立方体箱子。还需要一个物体来代表光源在3D场景中的位置。简单起见,依然使用一个立方体来代表光源。
这里创建一个光源类,与前面的箱子基本类似。首先需要一个顶点着色器来绘制箱子。与之前的顶点着色器相比,容器的顶点位置是保持不变的,但这边为了突出要讨论的内容,把与之无关的纹理坐标去掉,将会使用前面文章讨论的顶点着色器的精简版,同时新建:
// Ligjt.kt
private val vertexShaderCode =
"#version 300 es \n" +
" layout (location = 0) in vec3 aPos;" +
"uniform mat4 model;" +
"uniform mat4 view;" +
"uniform mat4 projection;" +
"void main() {" +
" gl_Position = projection * view * model * vec4(aPos, 1.0);" +
"}"
片段着色器直接输出一个光源即箱子的颜色。
// Ligjt.kt
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"void main() {" +
" FragColor = vec4(1.0) ;" +
"}")
这边需要额外注意的是这边开放两个属性,一个是光源位置,一个是光源颜色。用来提供给其他物体进行参考。
// Ligjt.kt
var mLightColor: Array<Float> = arrayOf(1f, 1f, 1f)
var mLightPosition: Array<Float> = arrayOf(0.2f,0.3f, -1f)
同时要注意, 在draw()方法中清除颜色和深度的指令要拿出来,在MyGLRenderer.kt中进行调用。不然刷新时候 Ligjt类的draw()方法会把其他物体渲染的对象给清除掉。
// Ligjt.kt
fun draw() {
//GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT or GLES30.GL_DEPTH_BUFFER_BIT)
}
//MyGLRenderer.kt
override fun onDrawFrame(unused: GL10) {
...
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT or GLES30.GL_DEPTH_BUFFER_BIT)
mLight!!.draw()
mTriangle!!.draw()
}
同时创建另外一个普通物体类Triangle,来进行之后的光影效果实现。其需要传入Light类来进行之后的使用,同时物体的位置通过view矩阵来进行表示,在(0.0,0.0,-4.0)上。
//Triangle.kt
class Triangle(context: Context, light: Light) {
...
private val mLight: Light
...
init {
mLight = light
...
}
...
fun draw() {
...
Matrix.setIdentityM(mViewMatrix, 0)
Matrix.translateM(mViewMatrix, 0, 0f,0f,-4.0f)
...
}
}
3、光照模型
现实世界的光照是极其复杂的,而且会受到诸多因素的影响,在有限的计算能力所无法模拟的。因此OpenGL ES的光照使用的是简化的模型,对现实的情况进行近似。这些光照模型都是基于对光的物理特性的理解。其中一个模型被称为冯氏光照模型(Phong Lighting Model)。冯氏光照模型的主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。下面这张图展示了这些光照分量看起来的样子:
① 环境光照:即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
② 漫反射光照:模拟光源对物体的方向性影响(Directional Impact)。它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
③ 镜面光照:模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
4、环境光照
光通常都不是来自于同一个光源,而是来自于物体周围分散的很多光源,即使它们可能并不是那么显而易见。光的一个属性是,它可以向很多方向发散并反弹,从而能够到达不是非常直接临近的点。所以,光能够在其它的表面上反射,对一个物体产生间接的影响。考虑到这种情况的算法叫做全局照明算法,但是这种算法既开销高昂又极其复杂。鉴于移动端的性能有限,将会使用一个简化的全局照明模型,即环境光照。正如在上一节所讨论的,使用一个很小的常量(光照)颜色,添加到物体片段的最终颜色中,这样子的话即便场景中没有直接的光源也能看起来存在有一些发散的光。
把环境光照添加到场景里就可以用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色:
// Triangle.kt
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"uniform vec3 lightColor; " +
"uniform vec3 objectColor; " +
"void main() {" +
// ambient
" float ambientStrength = 0.2;" +
" vec3 ambient = ambientStrength * lightColor;"
" vec3 result = ambient * objectColor;" +
" FragColor = vec4(result, 1.0);" +
"}")
环境光照效果如下图:
5、漫反射光照
环境光照是周围环境对物体的统一的光照影响。漫反射光照使物体上与光线方向越接近的片段能从光源处获得更多的亮度。原理如下图:
图左上方有一个光源,它所发出的光线落在物体的一个面上。需要测量这个光线是以什么角度接触到这个面的。如果光线垂直于物体表面,这束光对物体的影响会最大化也就是说更亮。为了测量光线和片段的角度,使用一个叫做法向量(Normal Vector)的东西,它是垂直于所要表示的面的一个向量,上图中黄色箭头。而这两个向量之间的角度能够通过点乘计算出来。
在前面讨论关于变换那一篇文章里,讨论过两个单位向量的夹角越小,它们点乘的结果越倾向于1。当两个向量的夹角为90度的时候,点乘会变为0。这同样适用于,越大,光对片段颜色的影响就应该越小。注意,为了得到两个向量夹角的余弦值,使用的是单位向量即长度为1的向量,所以需要确保所有的向量都是标准化的,否则点乘返回值还要用两个向量的长度进行修正才能得到向量夹角的余弦值。
点乘返回一个标量,可以用它计算光线对片段颜色的影响。不同片段朝向光源的方向的不同,这些片段被照亮的情况也不同。所以,计算漫反射光照需要两个向量
①法向量:一个垂直于顶点表面的向量。
②定向的光线:作为光源的位置与物体各个表面位置之间向量差的方向向量。为了计算这个光线,需要光的位置向量和片段的位置向量。
5.1 法向量
法向量是一个垂直于顶点表面的单位向量。由于顶点本身并没有表面,它只是空间中一个独立的点,但可以利用它周围的顶点来计算出这个顶点的表面。这边使用一个小技巧,使用叉乘对立方体所有的顶点计算法向量,但是由于3D立方体不是一个复杂的形状,所以可以简单地把法线数据手工添加到顶点数据中。更新后的顶点数据数组如下:
// Triangle.kt
var vertices3D = floatArrayOf(
// ---- 位置 ---- ---- 顶点法向量 ----
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f, -0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f,
0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f,
0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f,
0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f)
5.2 计算漫反射光照
由于向顶点数组添加了额外的数据,所以需要更新光照的顶点着色器:
// Triangle.kt
private val vertexShaderCode =
"#version 300 es \n" +
"layout (location = 0) in vec3 aPos;" +
"layout (location = 1) in vec3 aNormal;" +
"uniform mat4 model;" +
"uniform mat4 view;" +
"uniform mat4 projection;" +
"out vec3 FragPos; " +
"out vec3 Normal; " +
"void main() {" +
" gl_Position = projection * view * model * vec4(aPos, 1.0);" +
" FragPos = vec3(view * vec4(aPos, 1.0));" +
" Normal = aNormal;" +
"}"
其中aNormal是前面顶点矩阵传入的每个顶点的原始法向量。 由于需要在世界空间中进行所有的光照计算,因此需要一个在世界空间中的顶点位置。可以通过把顶点位置属性乘以视图矩阵来把它变换到世界空间坐标。这个在顶点着色器中完成,然后传输给片段着色器,这个数据就是上面的FragPos。
下面需要对片段着色器进行修改:
// Triangle.kt
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"in vec3 Normal;" +
"in vec3 FragPos;" +
"out vec4 FragColor; " +
"uniform vec3 lightPos; " +
"uniform vec3 lightColor; " +
"uniform vec3 objectColor; " +
"void main() {" +
...
// diffuse
" vec3 norm = normalize(Normal);" +
" vec3 lightDir = normalize(lightPos - FragPos);" +
" float diff = max(dot(norm, lightDir), 0.0);" +
" vec3 diffuse = diff * lightColor;" +
...
" vec3 result = ( diffuse) * objectColor;" +
" FragColor = vec4(result, 1.0);" +
"}")
首先需要获取标准化后的法向量norm和光线向量lightDir。下一步,对norm和lightDir向量进行点乘,计算光源对当前片段实际的漫发射影响。结果值再乘以光的颜色,得到漫反射分量。两个向量之间的角度越大,漫反射分量就会越小。如果两个向量之间的角度大于90度,点乘的结果就会变成负数,这样会导致漫反射分量变为负数。为此,使用max函数返回两个参数之间较大的参数,从而保证漫反射分量不会变成负数。负数颜色的光照是没有定义的,所以最好避免它。有了环境光分量和漫反射分量,把它们相加,然后把结果乘以物体的颜色,来获得片段最后的输出颜色。
其输出效果如下:
5.3 法向量矫正
现在已经把法向量从顶点着色器传到了片段着色器。可是,目前片段着色器里的计算都是在世界空间坐标中进行的。所以,应该把法向量也转换为世界空间坐标。但是这不是简单地把它乘以一个模型矩阵就能搞定的。首先,法向量只是一个方向向量,不能表达空间中的特定位置。同时,法向量没有齐次坐标,即顶点位置中的w分量。这意味着,位移不应该影响到法向量。因此,如果打算把法向量乘以一个模型矩阵,就要从矩阵中移除位移部分,只选用模型矩阵左上角3×3的矩阵对于法向量,只希望对它实施缩放和旋转变换。其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了。因此,不能用这样的模型矩阵来变换法向量。下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:
每当应用一个不等比缩放时,不会破坏法线,因为法线的方向没被改变,仅仅改变了法线的长度,而这很容易通过标准化来修复,但是法向量不会再垂直于对应的表面,这样光照就会被破坏。修复这个问题需要使用一个为法向量专门定制的模型矩阵。这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响。
法线矩阵被定义为——观察矩阵左上角的逆矩阵的转置矩阵。
在顶点着色器中,可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效。注意还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以vec3的法向量。
// Triangle.kt
private val vertexShaderCode =
...
"void main() {" +
...
" Normal = mat3(transpose(inverse(view))) * aNormal;" +
"}"
在漫反射光照部分,光照表现并没有问题,这是因为没有对物体本身执行任何缩放操作,所以并不是必须要使用一个法线矩阵,仅仅让模型矩阵乘以法线也可以。可是,如果进行了不等比缩放,使用法线矩阵去乘以法向量就是必不可少的了。即使是对于着色器来说,逆矩阵也是一个开销比较大的运算,因此,只要可能就应该避免在着色器中进行逆矩阵运算,它们必须为场景中的每个顶点都进行这样的处理。这边为了方便实现效果直接在GPU中进行处理,但是对于一个对效率有要求的应用来说,在绘制之前最好用CPU计算出法线矩阵,然后通过uniform把值传递给着色器。
6、镜面光照
和漫反射光照一样,镜面光照也是依据光的方向向量和物体的法向量来决定的,但是它也依赖于观察方向。镜面光照是基于光的反射特性。如果反射光直射入眼此时的光照强度就是最大的,而随着角度的偏离,光照强度渐渐变小。
通过反射法向量周围光的方向来计算反射向量。然后计算反射向量和视线方向的角度差,如果夹角越小,那么镜面光的影响就会越大。它的作用效果就是,去看光被物体所反射的那个方向的时候,会看到一个高光。
观察向量是镜面光照附加的一个变量,可以使用观察者世界空间位置和片段的位置来计算它。之后,计算镜面光强度,用它乘以光源的颜色,再将它加上环境光和漫反射分量。选择在世界空间进行光照计算,也可以在观察空间进行光照计算。在观察空间计算的好处是,观察者的位置总是(0,0,0),所以这样直接就获得了观察者位置。为了得到观察者的世界空间坐标,简单地使用摄像机对象的位置坐标代替,即它就是观察者,由于这边没有修改相机位置,所以视角位置是世界坐标的原点(0,0,0)。所以把另一个uniform viewPos添加到片段着色器,把相应的摄像机位置(0,0,0)坐标传给片段着色器:
private val fragmentShaderCode = (
...
"uniform vec3 viewPos; " +
"void main() {" +
...
// specular
" float specularStrength = 0.5;" +
" vec3 viewDir = normalize(viewPos - FragPos);" +
" vec3 reflectDir = reflect(-lightDir, norm);" +
// pow函数第二个参数一定要带小数位,不然会崩溃
" float spec = pow(max(dot(viewDir, reflectDir), 0.0f), 128.0);" +
" vec3 specular = specularStrength * spec * lightColor; " +
" vec3 result = ( ambient + diffuse + specular ) * objectColor;" +
" FragColor = vec4(result, 1.0);" +
"}")
specularStrength 是镜面强度变量,给镜面高光一个中等亮度颜色,让它不要产生过度的影响。
viewDir是视角向量,reflectDir是反射光向量。其中lightDir是光线向量,需要注意的是对lightDir向量进行了取反。reflect函数要求第一个向量是从光源指向片段位置的向量,但是lightDir当前正好相反,是从片段指向光源,由先前计算lightDir向量时,减法的顺序决定。为了保证得到正确的reflect向量,通过对lightDir向量取反来获得相反的方向。第二个参数要求是一个法向量,即前面提供的已标准化的norm向量。之后的两行代码是在计算镜面光强分量。先计算视线方向与反射方向的点乘,并确保它不是负值,然后取它的32次幂。这个128是高光的反光度。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小。在下面的图片里,是不同反光度的视觉效果影响:
最后是将3个光的分量累加起来作用与物体中的最终形态。