Skip to content

我们已经学会了给物体增加环境光,但现实世界中一个物体展示出来的颜色除了受环境光的影响,还要看该物体是否被光源直接照射,以及物体本身的材质。如果物体被光源直接照射,它会比没有光源直接照射的物体更亮一些。物体本身如果是光滑的,那么在被光源照射时会显得更加亮,甚至刺眼,比如一面镜子,不锈钢等。假设物体粗糙不平,那么它给人的感觉就平和一些。

除了物体本身的因素会对最终进入人眼的颜色产生影响,人眼、物体、光源之间的位置也会决定进入人眼的颜色。一个很常见的例子就是光线照射在镜面时,当反射出来的光线没有进入人眼的时候,人眼看到的镜子是正常的。当我们移动自身位置,正好能够让镜子的反射光线进入人眼,此时看到的镜面就会很刺眼。

现实生活中的光照效果如此复杂,而且受到很多因素的影响,即使在计算机硬件飞速发展的今天,也依然会消耗很大的算力,无法精确模拟这种效果,所以需要一种能够近似现实光照效果的简化模型。业界比较著名的是冯氏光照模型(Phong Lighting Model)。

冯氏光照模型

冯氏光照模型模拟现实生活中的三种情况,分别是环境光(Ambient)、漫反射(Diffuse)和镜面高光(Specular)。

  • 环境光:环境光在上节已经讲过了,主要用来模拟晚上或者阴天时,在没有光源直接照射的情况下,我们仍然能够看到物体,只是偏暗一些,通常情况我们使用一个较小的光线因子乘以光源颜色来模拟。
  • 漫反射:漫反射是为了模拟平行光源对物体的方向性影响,我们都知道,如果光源正对着物体,那么物体正对着光源的部分会更明亮,反之,背对光源的部分会暗一些。在冯氏光照模型中,漫反射分量占主要比重。
  • 镜面高光:为了模拟光线照射在比较光滑的物体时,物体正对光源的部分会产生高亮效果。该分量颜色会和光源颜色更接近。

有了冯氏光照模型,我们就可以通过这三个分量模拟出相对真实的光照效果了。

环境光分量我们上节已经讲过了,本节将跳过,不再赘述,本节主要讲解漫反射分量。

计算漫反射光照

我们知道,当一束光线照射到物体表面时,光线的入射角越小,该表面的亮度就越大,看上去也就越亮。反之,该表面的亮度就越小,看上去越暗。

这种现象我们该如何在计算机中表示呢?

关键在于入射角的表示光线强度的计算

入射角的表示与计算

我们需要定义一个类似法线的概念,即法向量,法向量垂直于物体表面,并且朝向平面外部,如下图:

有了法向量,我们还需要光线照射方向,光线照射方向根据光源的不同有两种表示方法:

  • 平行光线
    • 光线方向是全局一致的,与照射点的位置无关,不会随着照射点的不同而不同,不是很真实。
  • 点光源。
    • 向四周发射光线,光线方向与照射点的位置有关,越靠近光源的部分越亮,光照效果比较真实。

接下来我们用这二种方式来演示。

有了法向量以及光线照射方向,我们也就知道了入射角,有了入射角,那么反射光强度的计算就轻而易举了。

计算反射光强度

因为入射角的大小与反射光的亮度成反比,所以我们使用入射角的余弦值来表示漫反射的光线强度。

法向量

法向量是垂直于顶点所在平面,指向平面外部的向量,只有方向,没有大小,类比光学现象中的法线,如下所示:

法向量存储在顶点属性中,为了便于计算入射角的余弦值,法向量的长度通常设置为 1。

除了法向量,我们还需要知道光线的入射角,即光源的照射方向向量和法向量的夹角,上图中 $\theta$ 即是入射角。

光源照射方向向量的计算

光源位置坐标是基于世界坐标系的,所以我们在计算光源入射方向向量的时候,需要将照射点的坐标也转换到世界坐标系中。

在世界坐标系中,假设有一光源 p0 (x0, y0, z0)。

glsl
vec3 p0 = vec3(10, 10, 10);

光线照射到物体表面上的一点 p1 (x1, y1, z1)。

glsl
vec3 p1 = vec3(20, 25, 30);

那么光线照射在该点的方向向量为:

glsl
vec3 light_Direction = p1 - p0。

GLSL中的 +-*/ 操作符的左右两个数如果是向量的话,得出的新向量的各个分量等于原有向量逐分量的相减结果。

这样我们就得出了光源的照射方向向量。

计算漫反射光照

有了入射角,我们的漫反射光照分量就可以求出来了。

  • 漫反射光照 = 光源颜色 * 漫反射光照强度因子
  • 漫反射光照强度因子 = 入射角的余弦值

通常我们如果要求入射角的余弦值,需要首先知道入射角,然后再求入射角的余弦值。不过由于我们使用的是向量,根据向量的运算规则,我们可以使用向量之间的点积,再除以向量的长度之积,就可以得出余弦值。

我们首先将两个向量归一化,转换成单位向量,然后进行点积计算求出夹角余弦。

归一化向量的实质是将向量的长度转换成 1,得出的一个单位向量。

所以我们需要两个数学方法来操作他们

  • dot
    • 求出两个向量的点积。
  • normalize
    • 将向量转化为长度为 1 的向量。

所幸的是,GLSL 内置了这两个函数方便我们计算,一些有名的 3D 框架中也包含这两个方法。

所以,我们的入射角余弦值就可以这样求出了:

glsl
//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 维向量来表示法向量,完整的顶点着色器如下:

glsl
// 顶点坐标
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

glsl
// 片元法向量
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部分

着色器的程序完成了,接下来我们需要给着色器传递数据了。和之前的例子相比,我们多了两个全局变量光照颜色光照位置,以及一个顶点属性法向量

首先我们给顶点增加法向量:

javascript
var normalInput = [
    [0, 0, 1],  //前平面
    [0, 0, -1], //后平面
    [-1, 0, 0], //左平面
    [1, 0, 0], //右平面
    [0, 1, 0], //上平面
    [0, -1, 0] //下平面
];

各个平面的法向量准备好后,我们就可以为组成平面的顶点设置法向量属性了,限于篇幅,此处不再展示源码,大家可以在此处查看完整源代码 光照演示源码

接下来,创建立方体的顶点数据:

javascript
var cube = createCube(10, 10, 10);

此处创建一个长、宽、高各位 10 的立方体,坐标原点在立方体中心。

接下来,我们设置光源位置,我们希望将光源放在立方体前面 z 轴坐标正方向 10 的位置。

javascript
gl.uniform3f(u_LightPosition, 0, 0, 10);

设置光源颜色为白色:

javascript
gl.uniform3f(u_LightColor, 1, 1, 1);

按照这种放置,光源在立方体的正前方,它始终照亮前面。我们看下演示效果:

可以看到我们设置的白色光源把立方体的前平面(红色面)照亮了。

等等,好像有些不对劲。

一个很大的问题是:立方体在转动时,转动到正对光源方向的平面并没有被照亮。

大家考虑下为什么?

动态计算法向量

其实是因为在转动的时候,各个顶点的法向量还是初始值,并没有随着物体的转动而更新,所以即使有平面转动到正对光源的位置,它的法向量还是原先的法向量,计算出来的漫反射光照仍然是 0。所以,我们需要在物体发生变换的时候,让法向量也跟着发生变换。

我们来修正这个问题,解决办法很简单,只要将立方体的模型变换矩阵传递给顶点着色器,然后与顶点的法向量相乘,即可得到变换后的法向量。

此处又提到了矩阵,可见矩阵的重要性非同一般,在后面的矩阵章节大家一定要认真学习。

顶点着色器

顶点着色器需要做些改动,用来接收模型矩阵,然后将模型矩阵与法向量相乘,得到变换后的法向量,传递给片元着色器。

glsl
// 顶点坐标
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的值,那么,模型矩阵如何计算呢?还好矩阵库为我们解决了这个问题。

javascript
var modelMatirx = matrix.identity();
modelMatrix = matrix.rotateX(modelMatrix, Math.PI / 180 * (uniforms['xRotation']));

这里利用了矩阵库的两个方法identityrotateX

  • identity 用来初始化一个 4 维矩阵,对角线分量均为1。
  • rotateX 将原来的矩阵沿着 X 轴旋转,得到一个新的矩阵。

关于矩阵的变换细节在后面章节有详细介绍,此处只讲如何使用。

改造完成,我们看下效果:

可以看到,立方体正对光源的平面都能够被照亮了。

点光源的漫反射

前面的平行光漫反射可以模拟遥远的光源,比如太阳光,由于太阳距离地球过于遥远,所以光线照射在物体各个点的方向还是可以近似平行的。

但现实生活中还有很多人造光源,这些光源距离物体比较近,照在物体不同点时,入射角也会不一样,所以光照强度也有差别,在一个平面上产生距离光源近的部分比较亮,距离光源远的部分比较暗的效果。

接下来我们模拟这种情况。

我们在之前平行光漫反射的基础上进行改造,大家可以看到,之前的平行光漫反射计算入射角余弦时,是根据光源位置和世界坐标系的原点计算的入射角,只要我们不改变光源位置,那么光线方向就始终一致。

但是,点光源需要根据光源位置和入射点位置计算入射角,所以我们需要计算出入射点的世界坐标系坐标。

入射点的世界坐标系坐标的求法也比较简单,只需要左乘模型矩阵就可以了。

我们对上面的例子加以升级。

顶点着色器

顶点着色器需要定义一个入射点位置,插值化后传给片元着色器计算入射角的余弦。

glsl
...略
varying vec3 v_Position;
void main(){
	...略
	v_Position = vec3(u_ModelMatrix * vec4(a_Position, 1));
}
片元着色器

片元着色器部分的改变只有在计算光源入射方向时,用光源位置减去入射点位置:

glsl
...略
// 光源照射方向向量
vec3 lightDirection = u_LightPosition - v_Position;
...略

JavaScript部分不需要改动,我们看下演示效果:

可以看到,在点光源的作用下,平面上的不同点也产生了明暗效果。

物体缩放时的表现。

结束了吗?当然没有,我们还有一个问题没有解决。

假设有一物体表面被光线照射:

当对物体执行非等比缩放时,顶点法向量也会执行非等比缩放,但是执行缩放后的法向量却不再垂直于顶点所在平面了,如下图:

法向量不正确带来的后果是光照计算不准,表现如下:

可以看到,当我们队球体执行纵向放大的时候,放大的部分虽然正对着光源,但是没有光照。

因此,我们不能使用简单的模型矩阵来变换顶点法向量了。为了解决这个问题,我们需要专门为法向量的变换定义一个单独的矩阵法线矩阵,法线矩阵可以用「模型矩阵左上角的3维矩阵的逆矩阵的转置矩阵」来代替。听起来比较复杂,其实很简单。

  • 1、对模型矩阵执行逆矩阵操作。
  • 2、对上一步得出的矩阵执行转置矩阵。
  • 3、取上一步得出的矩阵的前三阶矩阵。

我们修改一下程序,顶点着色器和片元着色器部分不用改变,我们需要修改 JavaScript 部分。

我们使用矩阵库的两个方法 transpose 和 inverse 来对模型矩阵执行转置操作和求逆操作。

javascript
var normalMatrix = matrix.transpose(matrix.inverse(modelMatrix));

然后将该矩阵传递给顶点着色器即可,我们看下修改后的效果:

回顾

至此,冯氏光照模型的漫反射部分就讲解完了,原理比较简单,但是计算量比较多,尤其是涉及到的一些矩阵运算知识,大家可能有些蒙,这是正常现象。大家只要会使用矩阵就可以了,至于为什么要用矩阵表示变换,之后的章节再向大家揭开这个谜团。

下一节,我们学习冯氏光照模型的第三个分量,镜面高光