Skip to content

前两个章节我们讲述了冯氏光照模型的环境光和漫反射光,本节我们学习组成冯氏光照的最后一个因素:镜面高光。

镜面高光现象

大家小时候应该都做过这样的恶作剧,上课的时候拿一面镜子,对准某个同学,慢慢调整镜子的角度,直到反射的太阳光照在对方的脸上,然后引起该同学的极度不适。

有没有想过为什么会有这种现象?

有的同学答了,这是因为镜子的反射光正好进入了同学的眼睛里。

说的没错,那假如我拿一件衣服来反射太阳光,能不能达到同样的效果。

很多同学脱口而出:不能。

是的,可是大家有没有想过为什么不能?

也许大家会出于直觉回答,因为衣服不反光,镜子反光。其实也对,但不太严谨。事实上衣服也反光,只是衣服表面过于粗糙,光线被散射到了各个方向,不能集中射向一个方向,导致进入人眼的光线强度大大削弱。镜子就不同了,镜子比较光滑,光线反射方向比较统一,进入人眼的光线强度也就越多,从而产生刺眼的效果。

冯氏光照模型使用镜面高光分量来模拟这种现象。

镜面高光的表示与计算

与漫反射分量相同,镜面高光也是根据光线的入射方向向量和法向量来决定的,只不过镜面高光还需要依赖视线的观察方向,也就是眼睛是从什么方向观察的物体。

视线方向向量与反射光向量的之间的夹角越小,夹角余弦值就会越大,那么人眼感受到的光照就会越强,反之,光照越暗。因此,我们使用夹角的余弦值表示镜面高光因子,然后再用镜面高光因子乘以光线颜色即可求出镜面高光分量:

  • 1、首先需要求出反射光向量reflectDirection和人眼视线方向向量viewDirection
  • 2、归一化两个向量。
  • 3、求出两个归一化向量的点积,得到镜面高光因子。
  • 4、将上一步求出的高光因子乘以光线颜色,得到镜面高光分量。

本节我们使用 GLSL 内置的反射向量算法reflect(inVec, normal),其中 inVec 为入射向量,方向由光源指向入射点,normal 为入射点的法向量。

如何实现镜面高光

接下来,我们开始编码实现镜面高光效果。

计算反射光向量

反射光向量在片元着色器中实现,参照上一节漫反射分量的计算,我们已经有了光源位置u_LightPosition和入射点位置v_Position,所以可以求得入射光向量:

glsl
//求出入射光向量
vec3 lightDirection = v_Position - u_LightPosition

切记,在使用GLSL 的reflect 函数计算反射光向量时,一定要确保入射光向量的方向是从光源位置指向入射点位置。

有了入射光向量,我们还需要入射点的法向量v_Normal,这个值已经从顶点着色器中插值化后传到片元着色器了,所以我们可以直接拿来用:

glsl
vec3 reflectDirection =reflect(normalize(lightDirection), normalize(v_Normal));

这样就求出了反射光向量,接下来我们计算视线观察向量。

计算视线观察向量

我们将入射点到观察者的方向向量定义为视线观察向量,为了计算这个向量,我们需要知道入射点的位置以及观察者的位置,入射点的位置我们有了,现在需要观察者的位置,我们将人眼在世界坐标系下的坐标作为观察者位置,然后将其用 uniform 变量的形式传递到片元着色器中。

因此我们的片元着色器要增加一个 uniform 变量接收观察者坐标。

glsl
// 观察者坐标。
uniform vec3 viewPosition;

有了观察者坐标,我们就可以计算出视线观察向量了。

glsl
// 视线观察向量
vec3 viewDirection = viewPosition - v_Position;

计算镜面高光因子

前面求出了视线观察向量和反射光向量,接下来我们就可以计算镜面高光因子了。

首先,归一化视线观察向量反射光向量

glsl
viewDirection = normalize(viewDirection);
reflectDirection = normalize(reflectDirection);

然后计算这两个向量的点积,这里要注意一点,就是如果这两个向量的点积为负数,则说明视线观察向量和反射光向量大于 90 度,是没有反射光进入眼睛的,所以我们使用 max函数取点积和 0 之间的最大值。

glsl
// 镜面高光因子
float specialFactor = dot(viewDirection, reflectDirection);
// 如果为负值,一律设置为 0。
specialFactor = max(specialFactor, 0.0);

完整的片元着色器程序如下:

glsl
    precision mediump float;
    varying vec4 v_Color;
    uniform vec3 u_LightColor;
    uniform float u_AmbientFactor;
    uniform vec3 u_LightPosition;
    varying vec3 v_Position;
    varying vec3 v_Normal;
    uniform vec3 u_ViewPosition;
    void main(){
      // 环境光分量
      vec3 ambient = u_AmbientFactor * u_LightColor; //环境光分量
      // 光线照射向量
      vec3 lightDirection =  v_Position - u_LightPosition;
      // 归一化光线照射向量
      lightDirection= normalize(lightDirection);
      // 漫反射因子
      float diffuseFactor = dot(normalize(lightDirection), normalize(v_Normal));
      // 如果大于 90 度,则无光线进入人眼,漫反射因子设置为0。
      diffuseFactor = max(diffuseFactor, 0.0);
      // 漫反射光照
      vec3 diffuseLightColor =u_LightColor * diffuseFactor;
      
      // 归一化视线观察向量
      vec3 viewDirection = normalize(v_Position - u_ViewPosition);
		//反射向量
      vec3 reflectDirection = reflect(-lightDirection, normalize(v_Normal));
      
      // 初始化镜面光照因子
      float specialFactor = 0.0;
      // 如果有光线进入人眼。
      if(diffuseFactor > 0.0){
       	specialFactor = dot(normalize(viewDirection), normalize(reflectDirection));
        specialFactor = max(specialFactor,0.0);
     }
     // 计算镜面光照分量
      vec3 specialLightColor  = u_LightColor * specialFactor * 0.5;
      // 计算总光照
      vec3 outColor = ambient + diffuseLightColor + specialLightColor;
      // 将物体自身颜色乘以总光照,即人眼看到的物体颜色。
      gl_FragColor = v_Color * vec4(outColor, 1); 
    }

加入光照之后,我们的着色器代码就变多了,但其实并不复杂,仅仅是取值计算赋值操作而已。

JavaScript 部分

镜面光照我们需要为着色器传递一个人眼观察位置,所以我们的JavaScript 部分需要修改:

javascript
// 获取着色器全局变量 `u_ViewPosition`
var u_ViewPosition = gl.getUniformLocation(program, 'u_ViewPosition');

将人眼观察位置放置在 z 轴正方向 10 位置,即物体的前面。

javascript
var uniforms = {
	eyeX: 0,
	eyeY: 0,
	eyeZ: 10
};

gl.uniform3f(u_ViewPosition, uniforms['eyeX'], uniforms['eyeY'], uniforms['eyeZ']);

完整的代码大家可以参见这里,我们比较下加入镜面高光前后的效果。

无镜面高光时:

添加镜面高光后:

观察上面两幅图,我们能很直观地看到添加镜面高光后,球体正中央有一个明晃晃的光圈,符合真实世界中的场景。

反光度

但是,这个刺眼的光圈面积太大了,我们需要给它添加一个称为反光度(shininess)的参数约束光圈的大小,一个物体的反光度越大,反光率就越强,散射的光就越少,我们看到的高光面积就越小。

我们定义一个u_Shininess的变量表示物体的反光度,然后用前面求得的高光因子乘以 2 的shininess次幂作为最终的高光因子。这样就可以让我们的光圈变得小一些。

求幂计算可以通过GLSL 内置的公式 pow(2, shininess)求得。

glsl
specialFactor = max(specialFactor, 0.0);
specialFactor = pow(specialFactor, u_Shininess);

一般情况下,我们设置物体的反光度为 32 就可以了,但是特殊场景下,效果可能不理想,这时候,我们就需要根据实际情况调整反光度了。

我们将反光度设置成 32,看下增加反光度后的效果:

这会效果明显好多了,我们可以看到光圈点很小了,大家可以点击此处查看我做的 demo,调节反光度看下效果。

Blinn-Phong光照模型

冯氏光照模型不仅能够很好的近似真实光照,而且性能也相当高。但是 这种光照在某些场景下仍然有些缺陷,大家观察前面没有添加反光度时的图片,应该能发现高光光圈边缘有一圈很明显的暗灰色断痕,但大家再看一下增加反光度后的效果,却没发现这种现象。这是为什么呢?

产生这个问题的原因是,在高光边缘部位,由于人眼视线向量和反射光向量夹角大于90度,那么夹角的余弦值便小于 0,按照冯氏光照模型的镜面光照算法,夹角余弦值小于 0 时, 我们的镜面高光分子系数就会用 0 来代替。所以高光边缘部位及以外的部分就没有了镜面光照分量,试想一下,如果反光度越小,镜面高光区域就越大,那高光区域边缘部位漫反射光的分量所占比重就会比较小,在高光边缘部位就会产生一种较大的亮度差,给人一种暗灰色断痕的感觉。反之,反光度越小,光圈越小,相应地,光圈周围漫反射光的分量所占比重就比较大,所以不会在高光边缘产生过大的亮度差。

如下图,反射光线和视线观察向量之间的夹角γ 大于90度,所以此时镜面高光分量为 0。

其实,这种观察角度,镜面高光分量还是应该有的,只是值比较小而已。所以,出现了 Blin Phong 光照模型,这种光照模型不再利用反射向量,而是采用了半程向量 ,半程向量是视线和反射光之间夹角的一半方向上的单位向量,利用半程向量和法向量之间的夹角余弦来表示镜面高光因子,半程向量和法向量之间的夹角越小,镜面高光分量越大,如下图所示:

实现 Blin Phong 光照

我们在冯氏光照代码的基础上加以修改,实现 Blin 光照模型。与冯氏光照模型不同的是,我们需要半程向量,半程向量该如何求呢?

按照向量的计算规则,半程向量只需要我们将视线观察向量和反射向量相加,然后将得出的结果归一化就可以求出了。

glsl
// 计算半程向量
vec3 halfVector = normalize(reflectDirection + viewDirection);
// 计算高光因子
float specialFactor = dot(normalize(v_Nomral), halfVector);

利用 GLSL 的内置函数,我们就很容易的求出来了。

从冯氏光照模型进化成 Blin光照模型,我们只需要改动这么一处就可以了,是不是觉得很简单呢?

算法是很简单,但是我希望大家还是能够把算法背后的原理搞清楚。这才是大家学习的目的。

好了,我们比较一下反光度同时为 1 的时候,冯氏光照和 Blin 光照之间的差别。

冯氏光照效果:

Blin 光照效果:

可以看到,采用 Blin光照模型后, 镜面高光区域过度的更加自然。

回顾

至此,我们就讲完了冯氏光照模型,以及为了解决冯氏光照的缺陷而引入的 Blin 光照模型

本节也涉及到了许多向量矩阵之间的计算,大家多加练习,不要被这些计算搞晕了。

接下来我们进入下一个环节的学习,对 GLSL 的语法进行一个总结。