我们已经学会了给物体增加环境光,但现实世界中一个物体展示出来的颜色除了受环境光的影响,还要看该物体是否被光源直接照射,以及物体本身的材质。如果物体被光源直接照射,它会比没有光源直接照射的物体更亮一些。物体本身如果是光滑的,那么在被光源照射时会显得更加亮,甚至刺眼,比如一面镜子,不锈钢等。假设物体粗糙不平,那么它给人的感觉就平和一些。
除了物体本身的因素会对最终进入人眼的颜色产生影响,人眼、物体、光源之间的位置也会决定进入人眼的颜色。一个很常见的例子就是光线照射在镜面时,当反射出来的光线没有进入人眼的时候,人眼看到的镜子是正常的。当我们移动自身位置,正好能够让镜子的反射光线进入人眼,此时看到的镜面就会很刺眼。
现实生活中的光照效果如此复杂,而且受到很多因素的影响,即使在计算机硬件飞速发展的今天,也依然会消耗很大的算力,无法精确模拟这种效果,所以需要一种能够近似现实光照效果的简化模型。业界比较著名的是冯氏光照模型
(Phong Lighting Model)。
冯氏光照模型
冯氏光照模型模拟现实生活中的三种情况,分别是环境光(Ambient)、漫反射(Diffuse)和镜面高光(Specular)。
- 环境光:环境光在上节已经讲过了,主要用来模拟晚上或者阴天时,在没有光源直接照射的情况下,我们仍然能够看到物体,只是偏暗一些,通常情况我们使用一个
较小的光线因子乘以光源颜色
来模拟。 - 漫反射:漫反射是为了模拟
平行光源
对物体的方向性影响,我们都知道,如果光源正对着物体,那么物体正对着光源的部分会更明亮,反之,背对光源的部分会暗一些。在冯氏光照模型中,漫反射分量占主要比重。 - 镜面高光:为了模拟光线照射在
比较光滑
的物体时,物体正对光源的部分会产生高亮效果
。该分量颜色会和光源颜色更接近。
有了冯氏光照模型,我们就可以通过这三个分量模拟出相对真实的光照效果了。
环境光分量我们上节已经讲过了,本节将跳过,不再赘述,本节主要讲解漫反射分量。
计算漫反射光照
我们知道,当一束光线照射到物体表面时,光线的入射角越小,该表面的亮度就越大,看上去也就越亮。反之,该表面的亮度就越小,看上去越暗。
这种现象我们该如何在计算机中表示呢?
关键在于入射角的表示
与光线强度的计算
。
入射角的表示与计算
我们需要定义一个类似法线
的概念,即法向量
,法向量垂直于物体表面,并且朝向平面外部,如下图:
有了法向量,我们还需要光线照射方向,光线照射方向根据光源的不同有两种表示方法:
- 平行光线
- 光线方向是全局一致的,与照射点的位置无关,不会随着照射点的不同而不同,不是很真实。
- 点光源。
- 向四周发射光线,光线方向与照射点的位置有关,越靠近光源的部分越亮,光照效果比较真实。
接下来我们用这二种方式来演示。
有了法向量以及光线照射方向,我们也就知道了入射角,有了入射角,那么反射光强度的计算就轻而易举了。
计算反射光强度
因为入射角的大小与反射光的亮度成反比
,所以我们使用入射角的余弦值
来表示漫反射的光线强
度。
法向量
法向量是垂直于顶点所在平面,指向平面外部的向量,只有方向,没有大小,类比光学现象中的法线,如下所示:
法向量存储在顶点属性中,为了便于计算入射角的余弦值,法向量的长度通常设置为 1。
除了法向量
,我们还需要知道光线的入射角
,即光源的照射方向向量和法向量的夹角,上图中 $\theta$ 即是入射角。
光源照射方向向量的计算
光源位置坐标是基于世界坐标系的,所以我们在计算光源入射方向向量的时候,需要将照射点的坐标也转换到世界坐标系中。
在世界坐标系中,假设有一光源 p0 (x0, y0, z0)。
vec3 p0 = vec3(10, 10, 10);
光线照射到物体表面上的一点 p1 (x1, y1, z1)。
vec3 p1 = vec3(20, 25, 30);
那么光线照射在该点的方向向量为:
vec3 light_Direction = p1 - p0。
GLSL中的
+
、-
、*
、/
操作符的左右两个数如果是向量的话,得出的新向量的各个分量等于原有向量逐分量的相减结果。
这样我们就得出了光源的照射方向向量。
计算漫反射光照
有了入射角,我们的漫反射光照分量就可以求出来了。
- 漫反射光照 = 光源颜色 * 漫反射光照强度因子
- 漫反射光照强度因子 = 入射角的余弦值
通常我们如果要求入射角的余弦值,需要首先知道入射角,然后再求入射角的余弦值。不过由于我们使用的是向量,根据向量的运算规则,我们可以使用向量之间的点积
,再除以向量的长度之积,就可以得出余弦值。
我们首先将两个向量归一化
,转换成单位向量,然后进行点积计算求出夹角余弦。
归一化向量的实质是将向量的长度转换成 1,得出的一个单位向量。
所以我们需要两个数学方法来操作他们
- dot
- 求出两个向量的点积。
- normalize
- 将向量转化为长度为 1 的向量。
所幸的是,GLSL 内置了这两个函数方便我们计算,一些有名的 3D 框架中也包含这两个方法。
所以,我们的入射角余弦值就可以这样求出了:
//light_Direction表示光源照射方向向量。
//normal 代表当前入射点的法向量
vec3 light_Color = vec3(1, 1, 1);
float diffuseFactor = dot(normalize(light_Direction), normalize(normal))
vec4 lightColor = vec4(light_Color * diffuseFactor, 1);
这样我们就求出了漫反射光照的分量,接下来我们实际操作一下,比较一下物体在加入光照前后的效果。
平行光漫反射
前面讲了那么多理论,是时候上手实践一下了。我们按照 WebGL 的编码流程,看看各个阶段需要做何处理。
顶点着色器
顶点着色器需要接收顶点法向量,插值化
后传递给片元着色器,所以我们需要定义一个varying
类型的 3 维向量来表示法向量,完整的顶点着色器如下:
// 顶点坐标
attribute vec4 a_Position;
// 顶点颜色
attribute vec4 a_Color;
// 顶点法向量
attribute vec3 a_Normal;
// 传递给片元着色器的法向量
varying vec3 v_Normal;
// 传递给片元着色器的颜色
varying vec4 v_Color;
// 模型视图投影变换矩阵。
uniform mat4 u_Matrix;
void main(){
// 将顶点坐标转化成裁剪坐标系下的坐标。
gl_Position = u_Matrix * vec4(a_Position, 1);
// 将顶点颜色传递给片元着色器
v_Color = a_Color;
// 将顶点法向量传递给片元着色器
v_Normal = a_Normal;
}
细心的读者已经看到了,着色器中我们使用了 GLSL 中的矩阵容器类型 mat4
,4 * 4 矩阵,用来表示模型视图投影变换。我们将 4 阶矩阵左乘 4 维向量,即可表示对 4 维向量所表示的点执行 4 阶矩阵所表示的变换。
关于矩阵和向量的运算意义,我会在之后的章节讲解。这里大家只要了解了矩阵左乘向量的意义,并且学会使用就可以了。
片元着色器
漫反射光照分量在片元着色器中计算,按照上面的计算公式,我们需要接收顶点着色器传递过来的插值后的法向量v_Normal
和全局光源位置 u_LightPosition
,以及光线的颜色u_LightColor
。
// 片元法向量
varying vec3 v_Normal;
// 片元颜色
varying vec4 v_Color;
// 光线颜色
uniform vec3 u_LightColor;
// 光源位置
uniform vec3 u_LightPosition;
void main(){
// 环境光分量
vec3 ambient = u_AmbientFactor * u_LightColor;
// 光源照射方向向量
vec3 lightDirection = u_LightPosition - vec3(0, 0, 0);
// 漫反射因子
float diffuseFactor = dot(normalize(lightDirection), normalize(v_Normal));
// 如果是负数,说明光线与法向量夹角大于 90 度,此时照不到平面上,所以没有光照,即黑色。
diffuseFactor = max(diffuseFactor, 0.0);
// 漫反射光照 = 光源颜色 * 漫反射因子。
vec3 diffuseLightColor = u_LightColor * diffuseFactor;
// 物体在光照下的颜色 = (环境光照 + 漫反射光照) * 物体颜色。
gl_FragColor = v_Color * vec4((ambient + diffuseLightColor),1);
}
JavaScript部分
着色器的程序完成了,接下来我们需要给着色器传递数据了。和之前的例子相比,我们多了两个全局变量光照颜色
、光照位置
,以及一个顶点属性法向量
。
首先我们给顶点增加法向量:
var normalInput = [
[0, 0, 1], //前平面
[0, 0, -1], //后平面
[-1, 0, 0], //左平面
[1, 0, 0], //右平面
[0, 1, 0], //上平面
[0, -1, 0] //下平面
];
各个平面的法向量准备好后,我们就可以为组成平面的顶点设置法向量属性了,限于篇幅,此处不再展示源码,大家可以在此处查看完整源代码 光照演示源码。
接下来,创建立方体的顶点数据:
var cube = createCube(10, 10, 10);
此处创建一个长、宽、高各位 10 的立方体,坐标原点在立方体中心。
接下来,我们设置光源位置,我们希望将光源放在立方体前面 z 轴坐标正方向 10 的位置。
gl.uniform3f(u_LightPosition, 0, 0, 10);
设置光源颜色为白色:
gl.uniform3f(u_LightColor, 1, 1, 1);
按照这种放置,光源在立方体的正前方,它始终照亮前面。我们看下演示效果:
可以看到我们设置的白色光源把立方体的前平面(红色面)照亮了。
等等,好像有些不对劲。
一个很大的问题是:立方体在转动时,转动到正对光源方向的平面并没有被照亮。
大家考虑下为什么?
动态计算法向量
其实是因为在转动的时候,各个顶点的法向量还是初始值,并没有随着物体的转动而更新,所以即使有平面转动到正对光源的位置,它的法向量还是原先的法向量,计算出来的漫反射光照仍然是 0。所以,我们需要在物体发生变换的时候,让法向量也跟着发生变换。
我们来修正这个问题,解决办法很简单,只要将立方体的模型变换矩阵
传递给顶点着色器
,然后与顶点的法向量
相乘,即可得到变换后的法向量。
此处又提到了矩阵,可见矩阵的重要性非同一般,在后面的矩阵章节大家一定要认真学习。
顶点着色器
顶点着色器需要做些改动,用来接收模型矩阵,然后将模型矩阵与法向量相乘,得到变换后的法向量,传递给片元着色器。
// 顶点坐标
attribute vec4 a_Position;
// 顶点颜色
attribute vec4 a_Color;
// 顶点法向量
attribute vec3 a_Normal;
// 传递给片元着色器的法向量
varying vec3 v_Normal;
// 传递给片元着色器的颜色
varying vec4 v_Color;
// 模型视图投影变换矩阵。
uniform mat4 u_Matrix;
// 模型变换矩阵。
uniform mat4 u_ModelMatrix;
void main(){
gl_Position = u_Matrix * vec4(a_Position, 1);
v_Color = a_Color;
v_Normal = mat3(u_ModelMatrix) * a_Normal;
}
片元着色器不需要修改。
JavaScript部分
JavaScript 部分需要为顶点着色器传入模型矩阵u_ModelMatrix
的值,那么,模型矩阵如何计算呢?还好矩阵库为我们解决了这个问题。
var modelMatirx = matrix.identity();
modelMatrix = matrix.rotateX(modelMatrix, Math.PI / 180 * (uniforms['xRotation']));
这里利用了矩阵库的两个方法identity
和 rotateX
:
- identity 用来初始化一个 4 维矩阵,对角线分量均为1。
- rotateX 将原来的矩阵沿着 X 轴旋转,得到一个新的矩阵。
关于矩阵的变换细节在后面章节有详细介绍,此处只讲如何使用。
改造完成,我们看下效果:
可以看到,立方体正对光源的平面都能够被照亮了。
点光源的漫反射
前面的平行光漫反射可以模拟遥远的光源,比如太阳光,由于太阳距离地球过于遥远,所以光线照射在物体各个点的方向还是可以近似平行的。
但现实生活中还有很多人造光源,这些光源距离物体比较近,照在物体不同点时,入射角也会不一样,所以光照强度也有差别,在一个平面上产生距离光源近的部分比较亮,距离光源远的部分比较暗的效果。
接下来我们模拟这种情况。
我们在之前平行光漫反射的基础上进行改造,大家可以看到,之前的平行光漫反射计算入射角余弦时,是根据光源位置
和世界坐标系的原点
计算的入射角,只要我们不改变光源位置,那么光线方向就始终一致。
但是,点光源需要根据光源位置和入射点位置计算入射角,所以我们需要计算出入射点的世界坐标系坐标。
入射点的世界坐标系坐标的求法也比较简单,只需要左乘模型矩阵就可以了。
我们对上面的例子加以升级。
顶点着色器
顶点着色器需要定义一个入射点位置,插值化后传给片元着色器计算入射角的余弦。
...略
varying vec3 v_Position;
void main(){
...略
v_Position = vec3(u_ModelMatrix * vec4(a_Position, 1));
}
片元着色器
片元着色器部分的改变只有在计算光源入射方向时,用光源位置减去入射点位置:
...略
// 光源照射方向向量
vec3 lightDirection = u_LightPosition - v_Position;
...略
JavaScript部分不需要改动,我们看下演示效果:
可以看到,在点光源的作用下,平面上的不同点也产生了明暗效果。
物体缩放时的表现。
结束了吗?当然没有,我们还有一个问题没有解决。
假设有一物体表面被光线照射:
当对物体执行非等比缩放时,顶点法向量也会执行非等比缩放,但是执行缩放后的法向量却不再垂直于顶点所在平面了,如下图:
法向量不正确带来的后果是光照计算不准,表现如下:
可以看到,当我们队球体执行纵向放大的时候,放大的部分虽然正对着光源,但是没有光照。
因此,我们不能使用简单的模型矩阵来变换顶点法向量了。为了解决这个问题,我们需要专门为法向量的变换定义一个单独的矩阵法线矩阵
,法线矩阵可以用「模型矩阵左上角的3维矩阵的逆矩阵的转置矩阵」来代替。听起来比较复杂,其实很简单。
- 1、对模型矩阵执行逆矩阵操作。
- 2、对上一步得出的矩阵执行转置矩阵。
- 3、取上一步得出的矩阵的前三阶矩阵。
我们修改一下程序,顶点着色器和片元着色器部分不用改变,我们需要修改 JavaScript 部分。
我们使用矩阵库的两个方法 transpose 和 inverse 来对模型矩阵执行转置操作和求逆操作。
var normalMatrix = matrix.transpose(matrix.inverse(modelMatrix));
然后将该矩阵传递给顶点着色器即可,我们看下修改后的效果:
回顾
至此,冯氏光照模型的漫反射部分就讲解完了,原理比较简单,但是计算量比较多,尤其是涉及到的一些矩阵运算知识,大家可能有些蒙,这是正常现象。大家只要会使用矩阵就可以了,至于为什么要用矩阵表示变换,之后的章节再向大家揭开这个谜团。
下一节,我们学习冯氏光照模型的第三个分量,镜面高光
。