对应《WebGL编程指南》第八章 36-LightedCube、37-LightedCube_animation、38-LightedCube_ambient
要点:光源类型(平行光、点光源光)、反射类型(漫反射、环境反射)、根据光线和表面的方向计算入射角、法向量
知识点
一、光照原理
1.1 引入——着色与阴影
现实世界中的物体被光纤照射时,会反射一部分光。只有当反射光线进入你的眼睛时,你才能够看到物体并辨认出它的颜色。
在现实世界中,当光线照射到物体上时,发生了两个重要的现象:
- 根据光源和光线方向,物体不同表面的明暗程度变得不一致。
- 根据光源和光线方向,物体向地面投下影子。
明暗差异给了物体立体感。上图中的立方体是纯白的,但是它每个面受到光照程度不同所以能够辨认。
着色:根据光照条件重建“物体各表面明暗不一的效果”的过程。
阴影:物体向地面投下影子的现象,又被成为阴影。
在讨论着色过程之前,考虑两件事:
-
发出光线的光源的类型
-
物体表面如何反射光线
1.2 光源类型
真实世界的光主要有两种类型:
- 平行光,类似于自然中的太阳光
- 点光源光,类似于人造灯泡的光
- 此外,我们还用环境光来模拟真实世界中的非直射光(也就是由光源发出后经过墙壁或其他物体反射后的光)
三维图形学还使用一些其他类型的光,比如用聚合灯光来模拟电筒,车前灯等。
平行光
顾名思义,平行光的光线是互相平行的,平行光具有方向。平行光可以看做是无限远处的光源(比如太阳)发出的光。因为太阳距离地球很远,所以阳光到达地球时可以认为是平行的。平行光很简单,可以用一个方向和一个颜色来定义。
本章节提到的“光的颜色”,实际上已包含光的强度信息。比如标准的白光为(1, 1, 1),那么两倍于其强度的白光就表示为(2, 2, 2)。
点光源光
点光源光是从一个点向周围的所有方向发出的光。点光源光可以用来表示现实中的灯泡、火焰等。我们需要指定点光源的位置和颜色。光线的方向将根据点光源的位置和被照射之处的位置计算出来,因为点光源的光线的方向在场景内的不同位置是不同的。
实际上点光源的光会衰减,本章教程中为了程序简单并未进行点光源光强的衰减。
环境光
环境光(间接光)是指那些经光源(点光源或平行光源)发出后,被墙壁等物体多次反射,然后照到物体表面上的光。环境光从各个角度照射物体,其强度都是一致的。比如说,在夜间打开冰箱的门,整个厨房都会有些微微亮,这就是环境光的作用。环境光不用指定位置和方向,只需要指定颜色即可。
1.3 反射类型
物体向哪个方向反射光,反射的光是什么颜色,取决于以下两个因素:
- 入射光
- 物体表面的类型
入射光的信息包括入射光的方向和颜色,而物体表面的信息包括表面的固有颜色和反射特性。
物体表面反射光线的方式由两种:漫反射和环境反射。本节的重点是如何很据上述两种信息(入射光和物体表面特性)来计算出反射光的颜色。本节会涉及一些简单的数学计算。
漫反射
漫反射是针对平行光或点光源而言的。漫反射的反射光在各个方向上是均匀的,如下图所示。
如果物体表面像镜子一样光滑,那么光线就会以特定的角度反射出去;但是现实中的大部分材质,比如纸张、岩石、塑料等,其表面都是粗糙的,在这种情况下反射光就会以不固定的角度反射出去。漫反射就是针对后一种情况而建立的理想反射模型。
在漫反射中,反射光的颜色取决于入射光的颜色、表面的基底色、入射光与表面形成的入射角。
入射角定义:入射光与表面的法线形成的夹角,用 θ 表示。
漫反射光的颜色可以根据下式计算得到:
<漫反射光颜色> = <入射光颜色> x <表面基底色> x cosθ
式子中,<入射光颜色>
指的是点光源或平行光的颜色,乘法操作是在颜色矢量上逐分量(R、G、B)进行的。因为漫反射光在各个方向上都是”均匀“的,所以从任何角度看上去其强度都相等。
环境反射
环境反射是针对环境光而言的。
在环境反射中,反射光的方向可以认为就是入射光的反方向。由于环境光照射物体的方式就是各方向均匀的、强度相等的,所以反射光也是各向均匀的,如下图所示。我们可以这样来描述它:
<环境反射颜色> = <入射光颜色> x <表面基底色>
这里的
<入射光颜色>
实际上也就是环境光的颜色。
漫反射+环境反射
当漫反射和环境反射同时存在时,将两者加起来,就会得到物体最终被观察到的颜色:
<表面的反射颜色> = <漫反射光颜色> + <环境反射光颜色>
注意,两种反射光并不一定总是存在,也并不一定要按照上述公式来计算。渲染三维模型时,你可以修改这些公式以达到想要的效果。
下面来建立一个示例程序,在合适的位置放置一个光源,对场景进行着色。首先实现平行光下的漫反射。
二、 平行光下的漫反射
如前所述,漫反射的反射光,其颜色与入射光在入射点的入射角θ
有关。平行光入射产生的漫反射光的颜色很容易计算,因为平行光的方向唯一的,对于同一个平面上的所有点,入射角是相同的,根据式子计算平行光入射的漫反射光颜色:
<漫反射光颜色> = <入射光颜色> x <表面基底色> x cosθ
上式用到了三项数据:
- 平行入射光的颜色
- 表面的基底色
- 入射光与表面形成的入射角 θ
颜色可以用RGB
值来表示,比如标准强度的白光颜色就是(1.0, 1.0, 1.0)
。物体表面的基底色其实就是”物体本来的颜色“(或者说是”物体在标准白光下的颜色“),按照式子计算反射光颜色时,我们对 RGB 值的三个分量逐个相乘。
假设入射光是白色(1.0, 1.0, 1.0)
,而物体表面的基底色是红色(1.0, 0.0, 0.0)
,而入射角 θ 为 0.0(即入射光垂直入射),根据式子,入射光的红色分量 R 为 1.0,基底色的红色分量 R 为1.0,入射角余弦值 cos θ 为 1.0,那么反射光的红色分量 R 就可以由如下计算得到:
R = 1.0 * 1.0 * 1.0 = 1.0
类似的,我们可以算出绿色分量 G 和蓝色分量 B:
G = 1.0 * 0.0 * 1.0 = 0.0
B = 1.0 * 0.0 * 1.0 = 0.0
根据上面的计算,当白光垂直入射到红色物体的表面时,漫反射光的颜色就变成了红色(1.0, 0.0, 0.0)
。而如果是红光垂直入射到白色物体的表面时,漫反射光的颜色也会是红色。这两种情况下,物体在观察者看来就是红色的。
那么如果入射角 θ 是 90 度,也就是说入射光与表面平行,一点都没有照射到表面上,在这种情况下会怎样呢?根据我们在现实世界中的经验,物体表面应该完全不反光,看上去是黑的。验证一下:当 θ 是 90 度,cos θ 的值是0,那么根据上面的式子,不管入射光的颜色和物体表面基底色是什么,最后得到的漫反射光颜色都为(0.0, 0.0, 0.0),也就是黑色。
同样,如果 θ 是60度,也就是斜射平行光斜射到物体表面上,那么该表面应该还是红色的,只不过比垂直入射时暗一些。根据上式,cos θ 是0.5,漫反射光颜色为 (0.5, 0.0, 0.0),即暗红色。
但是我们并不知道入射光 θ 是多少,只知道光线的方向。下面我们就来通过光线和物体表面的方向来计算入射角 θ,将式子中的 θ 换成我们更加熟悉的东西。
1.5 根据光线和表面的方向计算入射角
原理:根据入射光的方向和物体表面的朝向,即法线方向来计算出入射角。
在创建三维建模的时候,我们无法预先确定光线将以怎样的角度照射到每个表面上。但是,我们可以确定每个表面的朝向。在指定光源的时候,再确定光的方向,就可以用这两项信息来计算处入射角了。
点积运算
通过计算两个矢量的点积,来计算这两个矢量的夹角余弦值 cosθ
。点积运算的使用非常频繁,GLSL ES 内置了点积运算函数。在公式中,我们使用点符号 .
来表示点积运算。这样,cosθ 就可以通过下式计算出来:
cosθ = <光线方向> · <法线方向>
进而计算反射光颜色:
<漫反射光颜色> = <入射光颜色> x <表面基底色> x (<光线方向> · <法线方向>)
注意:
-
光线方向矢量和表面法线矢量的长度必须为1,否则反射光的颜色就会过暗或过亮将一个矢量的长度调整为1,同时保持方向不变的过程称之为归一化。GLSL ES 提供了内置的归一化函数,你可以直接使用。
-
这里所谓的“光线方向”,实际上是入射方向的反方向,即从入射点指向光源方向,如下图所示。
归一化
设矢量n为$(nx, ny, nz)$,则其长度为 $|n| = \sqrt {n_x^2 + n_y^2 + n_z^2}$ ;
对矢量n进行归一化后的结果是 $(nx/m, ny/m, nz/m)$,式中m
为n
的长度。如矢量(2, 2, 1)的长度为 |n| = sqrt(3),那么归一化后就是(2/3, 2/3, 1/3);
补充:矢量的点乘与叉乘
向量的点乘,也叫向量的内积、数量积,对两个向量执行点乘运算,就是对这两个向量对应位一一相乘之后求和的操作,点乘的结果是一个标量。
点乘公式:
设$a =
\begin{bmatrix}
a_1, & a_2, & a_3
\end{bmatrix}
$,$b =
\begin{bmatrix}
b_1, & b_2, & b_3
\end{bmatrix}
$,则 $a · b =
\begin{bmatrix}
a_1b_1, & a_2b_2, & a_3b_3
\end{bmatrix}
$
(要求一维向量a和向量b的行列数相同。)
点乘几何意义:
点乘的几何意义是可以用来表征或计算两个向量之间的夹角,以及在b向量在a向量方向上的投影,有公式:$a · b = |a| |b| cosθ$;
两个向量的叉乘,又叫向量积、外积、叉积,叉乘的运算结果是一个向量而不是一个标量。并且两个向量的叉积与这两个向量组成的坐标平面垂直。
叉乘几何意义:
在三维几何中,向量a和向量b的叉乘结果是一个向量,更为熟知的叫法是法向量,该向量垂直于a和b向量构成的平面。
一般点乘用来判断两个向量是否垂直,也可以用来计算一个向量在某个方向上的投影长度,就像定义一样。
叉乘更多的是判断某个平面的方向。从这个平面上选两个不共线的向量,叉乘的结果就是这个平面的法向量。
更多关于叉乘与点乘知识点,请自行复习数学教材或百度。
1.6 法线:表面的朝向
定义
物体表面的朝向,即垂直于表面的方向,又称法线或法向量。
表示
法向量有三个分量,向量(nx, ny, nz)
表示从原点(0, 0, 0)指向点(nx, ny, nz)的方向。比如,向量(1, 0, 0)
表示 x 轴正方向,向量(0, 0, 1)表示 z 轴正方向。设计到表面和法向量的问题时,必须考虑以下两点:
- 一个表面具有两个法向量
- 平面的法向量唯一
一个表面具有两个法向量
每个表面都有两个面,”正面“和”背面“。两个面各自具有一个法向量。比如,垂直与z 轴的 xy平面,其正面的法向量为z负半轴,即(0, 0, -1),而正面的法向量为 x 负半轴,即(0, 0, 1)。
在三维图形学中,表面的正面和背面取决与绘制表面时的顶点顺序。当你按照v0, v1, v2, v3 的顶点顺序绘制了一个平面(该平面由两个三角形组成,各自绘制顺序是v0,v1,v2和v2,v3,v0),那么当你从正面观察这个表示时,这4个顶点是顺时针的,而你从背面观察该表面,这4个顶点就是逆时针的。如上图,该平面正面的法向量是(0, 0, -1)。
平面的法向量唯一
由于法向量表示的是方向,与位置无关,所以一个平面只有一个法向量。换句话说,平面的任意一点都具有相同的法向量。
进一步来说,即使有两个不同的平面,只要其朝向相同(也就是两个平面平行),法向量也相同。比如说,有一个经过点(10, 98, 9)的平面,只要垂直与 Z 轴,它的法向量仍然是(0, 0, 1)和(0, 0, -1),和经过原点并垂直与z轴的平面一样。
下图左显示了示例程序中的立方体及每个表面的法向量。比如立方体表面上的法向量表示为 n(0, 1, 0)
。
一旦计算好每个平面的法向量,接下来的任务就是将数据传给着色器程序。以前程序把颜色作为“逐顶点数据”存储在缓冲区中,并传给着色器。对法向量数据也可以这样做。如上图右所示,每个顶点对应3个法向量,就像之前每个顶点都对应3个颜色值一样。
由于立方体各表面垂直相交,所以每个顶点对3个法向量(同时在缓冲区中被拆成3个顶点)。但是,一些表面光滑的物体,通常其每个顶点只对应1个法向量。
1.7 程序分析
1.7.1 顶点着色器
顶点着色器实现了:
<漫反射光颜色> = <入射光颜色> x <表面基底色> x (<光线方向> · <法线方向>)
由[1.5](#1.5 根据光线和表面的方向计算入射角)可知计算漫反射光颜色需要:
- 入射光颜色
- 表面基底色
- 入射光方向(归一化)
- 表面法线方向(归一化)
主要代码如下:
1 | var VSHADER_SOURCE = |
变量分析
变量名 | 描述 |
---|---|
a_Color | 表面基底色 |
a_Normal | 表面法线方向 |
u_LightColor | 入射光颜色 |
u_LightDirection | 归一化的世界坐标(入射光方向) |
注意,入射光方向 u_LightDirection 是在世界坐标下的,而且在传入着色器前已经在 JS 中归一化了。这样,我们就可以避免在顶点着色器每次执行时都对它进行归一化。
关于世界坐标系和本地坐标系参考。
本章节中,光照效果是在世界坐标系下计算的。
计算a_Normal
有了这些信息,就可以开始在顶点着色器中进行计算了。
首先对 a_Normal
进行归一化。严格地说,本例通过缓冲区传入的法向量都是已经归一化过的,所以实际上这一步可以略去。但是顶点着色器可不知道传入的矢量是否经过了归一化,而且这里没有节省开销的理由,所以,有这一步总比没有要好:
1 | // 对法向量进行归一化 |
a_Normal
变量是 vec4 类型的,使用前三个分量x、y和 z 表示法线法相,所以我们将这三个分量提取出来进行归一化。对vec3 类型的变量进行归一化不必这样做。本例使用 vec4 类型的 a_Normal 变量是为了方便对下一个示例程序进行扩展。GLSL ES 提供了内置函数normalize()
对矢量参数进行归一化。归一化的结果赋给了 vec3类型的 normal 变量,供之后使用。
计算点积
接下来,计算点积
。光线方向存储在 u_LightDirection变量
中,而且已经被归一化了,可以直接使用。法线方向存储在之前进行归一化后的结果 normal变量
中。使用 GLSL ES 提供的内置函数 dot()
计算两个矢量的点积,该函数接受两个矢量作为参数,返回它们的点积。
1 | // 计算光线方向和法向量的点积 |
如果点积大于0,就将点积赋值给
nDotL
变量,如果其小于0,就将0赋给该变量。使用内置函数max()
完成这个任务,将点积和0两者中的较大者赋值给 nDotL。点积值小于0,意味着
cosθ
中的θ
大于90度。θ
是入射角,也就是入射反方向(光线方向)与表面法向量的夹角 。θ
大于90度说明光线照射在表面的背面上,此时,将nDotL 赋为 0.0。如下图:
计算漫反射颜色
注意 a_Color 变量即顶点的颜色,被从vec4对象转成了 vec3 对象,因为其第4个分量与式子无关。
实际上,物体表面的透明度确实会影响物体的外观。但这时光照的计算较为复杂,现在暂时认为物体都是不透明的,这样就计算出了漫反射光的颜色 diffuse
:
1 | //计算漫反射光的颜色 |
然后,将 diffuse
的值赋给 v_Color变量
。v_Color
是 vec4
对象,而 diffuse
是 vec3
对象,需要将第4个分量补上为1.0
:
1 | ' v_Color = vec4(diffuse, a_Color.a);\n' + |
顶点着色器运行的结果就是计算出了 v_Color变量
,其值取决于顶点的颜色、法线方向、平行光的颜色和方向。v_Color 变量将被传入片元着色器并赋值给 gl_FragColor变量
。
本例中的光是平行光,所以立方体上同一个面的颜色是一致的,没有之前出现的颜色渐变效果。
1.7.2 JS 程序流程
JS 将光的颜色 u_LightColor
和方向 u_LightDirection
传给顶点着色器。首先用 gl.uniform3f()
函数将 u_LightColor 赋值为(1.0, 1.0, 1.0),表示入射光是白光:
1 | gl.uniform3f(u_LightColor, 1.0, 1.0, 1.0); // 设置光线颜色为白色 |
下一步是设置光线方向,注意光线方向必须被归一化。cuon-matrix.js 为 vector3 类型提供了 normalize()
函数,以实现归一化。该函数的用法非常简单:在你想要进行归一化的 Vector3 对象上调用即可。
1 | var lightDirection = new Vector3([0.5, 3.0, 4.0]);//设置光线方向(世界坐标系下) |
注意 JS 和 GLSL ES 对矢量进行归一化的不同之处。
归一化后的光线方向以 Float32Array
类型的形式存储在 lightDirection
对象的 elements属性
中,使用gl.uniform3fv()
将其分配给着色器中的 u_LightDirection 变量。
最后,在initVertexBuffers()
函数中为每个顶点定义法向量。法向量数据存储在 normals 数组中,然后被 initArrayBuffer()函数传给了顶点着色器的 a_Normal 变量。
1 | var normals = new Float32Array([ // Normal |
initArrayBuffer()
函数的作用是将第3个参数指定的数组分配给第2个参数指定的着色器中的变量。
三、环境光下的漫反射
3.1 平行光下漫反射的缺陷
现在,我们已经成功实现了平行光下的漫反射光。但是结果图和显示中的立方体还是有点不大一样,特别是右侧表面是全黑的,仿佛不存在一样。如果这个立方体动起来,你也许就能看的更清楚一些,试着运行程序 Lighted_Cube_animation,如图所示:
虽然程序是严格按照式子对场景进行光照的,但经验告诉我们肯定有什么对方不对劲。在现实世界中,光照下物体的各表面的差异不会如此分明:那些背光的面虽然会暗一些,但决不至于黑到看不见的程度。
3.2 环境光颜色计算
实际上,那些背光的面是被非直射光照亮的(即其他物体,如墙壁的反光等),前面提到的环境光就起到了这部分非直射光的作用,它使场景更加逼真。因为环境光均匀地从各个角度照在物体表面,所以由环境光反射产生的颜色只去取决与光的颜色和表面基底色,使用式子计算后我们再来看一下:
<环境反射光颜色> = <入射光颜色> x <表面基底色>
接下来,向示例程序中加入上式中的环境光所产生的反射光颜色:
<表面的反射颜色> = <漫反射光颜色> + <环境反射光颜色>
环境光是由墙壁等其物体反射产生的,所以环境光的强度通常比较弱。假设环境光是较弱的
白光(0.2, 0.2, 0.2
),而物体表面是红色(1.0, 1.0, 1.0)
。根据式子,由环境光反射的光颜色就是暗红色(0.2, 0.0, 0.0)
。同样,在蓝色的空间中,环境光为(0.0, 0.0, 0.2),有一个白色的物体,即表面基底色为(1.0, 1.0, 1.0),那么由环境光产生的漫反射光颜色就是淡蓝色(0.0, 0.0, 0.2)。
3.3 程序分析
示例程序 LightedCube_ambient 实现了环境光漫反射的效果,如下图左所见。可见,完全没有被平行光照到的表面也不是全黑,而是呈现较暗的颜色,与真实世界更加相符。


顶点着色器
1 | var VSHADER_SOURCE = |
顶点着色器中新增加了 u_AmbientLight变量
用来接收环境光的颜色。使用该变量和表面的基底色 a_Color 计算出反射光的颜色,将其存储在 ambient 变量中。这样我们就即由环境光反射产生的颜色 ambient,又有了由平行光漫反射产生的颜色 diffuse。最后根据式子计算物体的最终颜色并存储在 v_Color 变量中,作为物体表面最终显示处的颜色,和 LightCube 一样。
1 | var u_AmbientLight = gl.getUniformLocation(gl.program, 'u_AmbientLight'); |
实例
代码1-LightedCube
1 | //LightedCube.js |
1 | <!DOCTYPE html> |
效果
代码2-LightedCube_animation
1 | //LightedCube_animation.js |
[效果:3.1 平行光下漫反射的缺陷](#3.1 平行光下漫反射的缺陷)
代码3-LightedCube_ambient
1 | //LightedCube_ambient.js |
效果
Tips: Please indicate the source and original author when reprinting or quoting this article.