本文从下面链接翻译过来:
Android Lesson Two: Ambient and Diffuse Lighting
欢迎学习第二个教程。在本课中,我们将学习如何使用着色器实现Lambertian反射,也称为标准漫反射光。 在OpenGL ES2中,我们需要实现自己的光照算法,因此我们将要学习数学以及如何将它应用于场景。
假设和先决条件
本系列的每节课都以前面的课程为基础。在开始之前,请查看第一课,因为本课程将基于其中介绍的概念。
什么是光?
事实上,没有光照的世界将是一个昏暗的世界。 没有光照,我们甚至无法感知世界或我们周围的物体,除了通过声音和触觉等其他感官。 光照向我们展示了物体的明亮或暗淡,它有多近或多远,以及它所处的角度。
在现实世界中,我们所感知的光线实际上是数万亿微小粒子聚集在一起的光子,光子从光源中飞出,反射数千或数百万次,并最终到达我们的眼睛,我们将其视为光。
我们如何通过计算机图形模拟光照的效果? 有两种流行的方法:光线跟踪和光栅化。 光线跟踪的工作原理是通过数学方式跟踪实际的光线并查看它们的最终位置。 这种技术可以提供非常准确和逼真的结果,但缺点是模拟所有这些光线的计算成本非常高,而且通常对于实时渲染而言太慢。 由于这种限制,大多数实时计算机图形使用光栅化,而是通过近似结果来模拟光照。 鉴于游戏的真实性,光栅化也看起来非常好,即使在手机上也可以快速实现实时图形。 Open GL ES主要是一个光栅化库,因此这是我们将关注的方法。
不同种类的光
事实证明,我们可以抽象出光的工作方式,并提出三种基本的光:
环境光 | |
漫射光 这是直接从物体反射后到达您眼睛的光。物体的光照强度随着光照的角度而变化。 面向光源的物体比某个角度物体更亮。 此外,无论我们相对于物体的角度如何,我们都认为物体具有相同的亮度。 这也被称为Lambert’s cosine law。 漫射光或朗伯反射在日常生活中很常见,并且可以在室内光照亮的白墙上轻松看到。 | |
镜面光 与漫射光不同,当我们相对于物体移动时,镜面光照会发生变化。 这给物体带来“光泽”,并且可以在“更光滑”的表面上看到,例如玻璃和其他有光泽的物体。 |
模拟光源
正如3D场景中有三种主要类型的光一样,还有三种主要类型的光源:定向光源,点光源和聚光灯。 这些也可以在日常生活中轻松看到。
定向光源 定向光源通常来自一个很远的光源,它可以均匀地照亮整个场景并达到相同的亮度。这种光源是最简单的类型,因为无论您在场景中的哪个位置,光线都具有相同的强度和方向。 | |
点光源 点光源可以添加到场景中,以提供更多样化和逼真的照明。 点光的照射随距离而下降,并且其光线在所有方向上向外传播,点光在中心。 | |
聚光灯 除了点光源的特性之外,聚光灯还具有光衰减的方向,通常呈锥形。 |
数学
在本课中,我们将学习来自点光源的环境光和漫射光。
环境光
环境光实际上是间接漫射光,但它可以被认为是遍布整个场景低微的光。如果我们这样想,那么计算起来就很容易:
final color = material color * ambient light color
例如,假设我们的物体是红色的,我们的环境光线是暗淡的白色。假设我们使用RGB颜色模型将颜色存储为三种颜色的数组:红色,绿色和蓝色:
final color = {1, 0, 0} * {0.1, 0.1, 0.1} = {0.1, 0.0, 0.0}
物体的最终颜色将是暗红色,如果您想模拟一个昏暗的白光照亮的红色物体,这就是您所期望的。环境光除此之外没什么,除非你想进入更先进的光照技术,如光能传递。
漫射光 - 点光源
对于漫射光,我们需要增加衰减和光照位置。灯光位置将用于计算灯光与物体表面之间的角度,这将影响物体表面的整体光照水平。 它还将用于计算光与物体表面之间的距离,这决定了该点处光的强度。
第1步:计算朗伯因子(lambert factor)。
我们需要做的第一个主要计算是计算出表面和光线之间的角度。面向光直射的表面应该以全强度照射,而倾斜的表面应该得到较少的照射。计算这个的正确方法是使用Lambert的余弦定律。如果我们有两个向量,一个是从光到表面上的一个点,第二个是表面法线(如果表面是一个平面,那么表面法线是一个向上指向或正交于该表面的向量)然后我们可以通过首先对每个向量进行归一化使其长度为1,然后通过计算两个向量的点积来计算余弦。 这是一个可以通过OpenGL ES2着色器轻松完成的操作。
我们称之为朗伯因子,它的范围在0到1之间。
light vector = light position - object position
cosine = dot product(object normal, normalize(light vector))
lambert factor = max(cosine, 0)
这是一个在原点处的平面并且表面法线指向天空的示例。 灯的位置为{0,10,-10},我们想要计算光向量:
light vector = {0, 10, -10} - {0, 0, 0} = {0, 10, -10}
object normal = {0, 1, 0}
为了标准化向量,我们将每个分量除以向量长度:
light vector length = square root(0*0 + 10*10 + -10*-10) = square root(200) = 14.14
normalized light vector = {0, 10/14.14, -10/14.14} = {0, 0.707, -0.707}
然后我们计算点积:
dot product({0, 1, 0}, {0, 0.707, -0.707}) = (0 * 0) + (1 * 0.707) + (0 * -0.707) = 0 + 0.707 + 0 = 0.707
最后,我们限制范围:
lambert factor = max(0.707, 0) = 0.707
OpenGL ES2的着色器语言内置了对其中一些函数的支持,因此我们不需要手动完成所有数学运算,但它仍然有助于理解正在发生的事情。
第2步:计算衰减系数。
接下来,我们需要计算衰减。 来自点光源的实际光衰减遵循平方反比定律,其也可以表示为:
luminosity = 1 / (distance * distance)
回到我们的例子,因为我们的距离为14.14,这就是我们最终的luminosity
:
luminosity = 1 / (14.14*14.14) = 1 / 200 = 0.005
如您所见,平方反比定律可以导致距离的强烈衰减。这就是来自点光源的光在现实世界中的工作方式,但由于我们的图形显示器具有有限的范围,因此抑制此衰减系数可能非常有用,我们仍可获得逼真的照明,而不会看起来太暗。
第3步:计算最终颜色。
既然我们有余弦和衰减,我们就可以计算出最终的照度:
final color = material color * (light color * lambert factor * luminosity)
继续我们之前的红色材料和全白光源示例,这是最终的计算:
final color = {1, 0, 0} * ({1, 1, 1} * 0.707 * 0.005}) = {1, 0, 0} * {0.0035, 0.0035, 0.0035} = {0.0035, 0, 0}
回顾一下,对于漫射光,我们需要使用表面和光线之间的角度以及表面和光线之间的距离,以便计算最终的整体漫射光。 以下是步骤:
//Step one
light vector = light position - object position
cosine = dot product(object normal, normalize(light vector))
lambert factor = max(cosine, 0)
//Step two
luminosity = 1 / (distance * distance)
//Step three
final color = material color * (light color * lambert factor * luminosity)
将这一切都放入OpenGL ES 2着色器中
顶点着色器
final String vertexShader =
"uniform mat4 u_MVPMatrix; \n" // A constant representing the combined model/view/projection matrix.
+ "uniform mat4 u_MVMatrix; \n" // A constant representing the combined model/view matrix.
+ "uniform vec3 u_LightPos; \n" // The position of the light in eye space.
+ "attribute vec4 a_Position; \n" // Per-vertex position information we will pass in.
+ "attribute vec4 a_Color; \n" // Per-vertex color information we will pass in.
+ "attribute vec3 a_Normal; \n" // Per-vertex normal information we will pass in.
+ "varying vec4 v_Color; \n" // This will be passed into the fragment shader.
+ "void main() \n" // The entry point for our vertex shader.
+ "{ \n"
// Transform the vertex into eye space.
+ " vec3 modelViewVertex = vec3(u_MVMatrix * a_Position); \n"
// Transform the normal's orientation into eye space.
+ " vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0)); \n"
// Will be used for attenuation.
+ " float distance = length(u_LightPos - modelViewVertex); \n"
// Get a lighting direction vector from the light to the vertex.
+ " vec3 lightVector = normalize(u_LightPos - modelViewVertex); \n"
// Calculate the dot product of the light vector and vertex normal. If the normal and light vector are
// pointing in the same direction then it will get max illumination.
+ " float diffuse = max(dot(modelViewNormal, lightVector), 0.1); \n"
// Attenuate the light based on distance.
+ " diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance))); \n"
// Multiply the color by the illumination level. It will be interpolated across the triangle.
+ " v_Color = a_Color * diffuse; \n"
// gl_Position is a special variable used to store the final position.
// Multiply the vertex by the matrix to get the final point in normalized screen coordinates.
+ " gl_Position = u_MVPMatrix * a_Position; \n"
+ "} \n";
这里有很多事情要做。有我们在第一课中模型/视图/投影矩阵,但我们还添加了一个模型/视图矩阵。 为什么? 我们需要这个矩阵来计算光源位置和当前顶点位置之间的距离。对于漫射光,只要您可以计算适当的距离和角度,使用世界空间(模型矩阵)或眼睛空间(模型/视图矩阵)实际上无关紧要,我们课程是在眼睛空间完成计算。
我们传入顶点颜色和位置信息,以及法线。 我们将最终颜色传递给片元着色器,片元着色器将在顶点之间进行插值计算。 这也称为Gouraud shading。
让我们看看顶点着色器的每个部分发生了什么:
// Transform the vertex into eye space.
+ " vec3 modelViewVertex = vec3(u_MVMatrix * a_Position); \n"
由于我们在眼睛空间中传递光的位置,我们将当前顶点位置转换为眼睛空间中的坐标,以便我们可以计算适当的距离和角度。
// Transform the normal's orientation into eye space.
+ " vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0)); \n"
我们还需要改变法线的方向。 这里我们只是像位置那样进行常规矩阵乘法,但是如果模型或视图矩阵已经缩放或倾斜,这将不起作用:我们实际上需要通过将法线乘以原始矩阵的逆转置矩阵来消除倾斜或缩放的影响。这个网站最好地解释了为什么我们必须这样做。
关于为什么要使用原始矩阵的逆转置矩阵,可以参考我的另外一篇文章Android OpenGL ES 2.0(九)---法线矩阵
// Will be used for attenuation.
+ " float distance = length(u_LightPos - modelViewVertex); \n"
如前面数学部分所示,我们需要距离来计算衰减系数。
// Get a lighting direction vector from the light to the vertex.
+ " vec3 lightVector = normalize(u_LightPos - modelViewVertex); \n"
我们还需要光向量来计算朗伯反射系数。
// Calculate the dot product of the light vector and vertex normal. If the normal and light vector are
// pointing in the same direction then it will get max illumination.
+ " float diffuse = max(dot(modelViewNormal, lightVector), 0.1); \n"
这与数学部分中的数学运算相同,只是在OpenGL ES2着色器中完成。 最后的0.1只是一种非常便宜的环境照明方式(该值将被限制到最小值0.1)。
// Attenuate the light based on distance.
+ " diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance))); \n"
在数学部分,衰减数学与上述略有不同。我们将距离的平方缩放0.25以抑制衰减效果,并且我们还将1.0添加到修改的距离,以便当光非常接近物体时我们不会过渡饱和(否则,当距离小于1时,这个等式实际上会一直照亮而不会衰减它)。
// Multiply the color by the illumination level. It will be interpolated across the triangle.
+ " v_Color = a_Color * diffuse; \n"
// gl_Position is a special variable used to store the final position.
// Multiply the vertex by the matrix to get the final point in normalized screen coordinates.
+ " gl_Position = u_MVPMatrix * a_Position; \n"
一旦我们得到了最终的光照颜色,我们将它乘以顶点颜色以获得最终的输出颜色,然后我们将该顶点的位置投影到屏幕上。
片元着色器
final String fragmentShader =
"precision mediump float; \n" // Set the default precision to medium. We don't need as high of a
// precision in the fragment shader.
+ "varying vec4 v_Color; \n" // This is the color from the vertex shader interpolated across the
// triangle per fragment.
+ "void main() \n" // The entry point for our fragment shader.
+ "{ \n"
+ " gl_FragColor = v_Color; \n" // Pass the color directly through the pipeline.
+ "} \n";
因为我们在每个顶点的基础上计算光,我们的片元着色器看起来和第一课中的相同 - 我们所做的就是直接传递颜色。 在下一课中,我们将介绍每像素光照。
在本课中,我们专注于实现每顶点光照。 对于具有光滑表面的对象(例如地形)或具有许多三角形的对象的漫反射照明,这通常是足够好的。 但是,当您的对象不包含许多顶点(例如本示例程序中的立方体)或具有尖角时,顶点光照可能会导致伪影,因为光线在多边形上进行线性插值; 当镜面高光添加到图像时,这些伪影也变得更加明显。 更多关于Gouraud shading文章可以到Wiki看到。
解释程序的变化
除了添加每顶点光照外,该程序还有其他变化。 我们已经从显示几个三角形切换到几个立方体,我们还添加了实用程序函数来加载着色器程序。 还有一些新的着色器可以将光的位置显示为一个点,以及其他各种小的变化。
立方体的构造
在第一课中,我们将位置和颜色属性打包到同一个数组中,但OpenGL ES2还允许我们在单独的数组中指定这些属性:
// X, Y, Z
final float[] cubePositionData =
{
// In OpenGL counter-clockwise winding is default. This means that when we look at a triangle,
// if the points are counter-clockwise we are looking at the "front". If not we are looking at
// the back. OpenGL has an optimization where all back-facing triangles are culled, since they
// usually represent the backside of an object and aren't visible anyways.
// Front face
-1.0f, 1.0f, 1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, -1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
...
// R, G, B, A
final float[] cubeColorData =
{
// Front face (red)
1.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
...
新的OpenGL标志
我们还通过glEnable()调用启用了剔除和深度缓冲:
// Use culling to remove back faces.
GLES20.glEnable(GLES20.GL_CULL_FACE);
// Enable depth testing
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
作为优化,您可以告诉OpenGL消除对象背面的三角形。当我们定义立方体时,我们还定义了每个三角形的三个点,并设置逆时针为正面。当我们翻转三角形时,我们将会看到三角形的背面,三角形的3个顶点顺时针出现。你只能同时看到一个立方体的三个面,所以这个优化告诉OpenGL不要浪费时间绘制三角形的背面。
之后,当我们绘制透明对象时,我们可能希望将剔除变回,因为这样就可以看到对象的背面。
我们还启用了深度测试。如果你总是从后到前按顺序绘制物体,那么深度测试并不是绝对必要的,但通过启用它不仅不需要担心绘制顺序(尽管如果你先绘制更近的对象,渲染速度会更快),但是一些显卡也将进行优化,通过花费更少的时间绘制将被绘制的像素来加速渲染
加载着色器程序的更改
因为在OpenGL中加载着色器程序的步骤大致相同,所以这些步骤可以很容易地重构为单独的方法。 我们还添加了以下调用来检索调试信息,以防编译/链接失败:
GLES20.glGetProgramInfoLog(programHandle);
GLES20.glGetShaderInfoLog(shaderHandle);
光源位置的顶点和片元着色器程序
有一个新的顶点和片元着色器程序专门用于在屏幕上绘制代表灯光当前位置的点:
// Define a simple shader program for our point.
final String pointVertexShader =
"uniform mat4 u_MVPMatrix; \n"
+ "attribute vec4 a_Position; \n"
+ "void main() \n"
+ "{ \n"
+ " gl_Position = u_MVPMatrix \n"
+ " * a_Position; \n"
+ " gl_PointSize = 5.0; \n"
+ "} \n";
final String pointFragmentShader =
"precision mediump float; \n"
+ "void main() \n"
+ "{ \n"
+ " gl_FragColor = vec4(1.0, \n"
+ " 1.0, 1.0, 1.0); \n"
+ "} \n";
此着色器类似于第一课中的简单着色器。 有一个新属性gl_PointSize我们硬编码到5.0; 这是输出点大小(以像素为单位)。 当我们使用GLES20.GL_POINTS作为模式绘制点时使用它。 我们还将输出颜色硬编码为白色。
进一步练习
- 尝试删除“过渡饱和的保护”看会发生什么
- 光照方式存在缺陷。你能发现它是什么吗? 提示:我做环境光计算的方式的缺点是什么,以及alpha会发生什么?
- 如果将gl_PointSize添加到正方体着色器并使用GL_POINTS绘制它会发生什么?
可以从GitHub上的项目站点下载本课程的完整源代码。