方向光
撰写时间:2026-07-04
修订时间:2026-07-26
方向光的概念及作用
方向光,也称平行光,是指因光源位置非常远,其照射至场景中的任意一个位置的方向是平行的。典型例子为太阳光。
由此决定了方向光的特点是:没有位置,只有方向且方向固定(因此不需要位置变换),强度恒定(无需衰减)。但我们需要向着色器传入方向光的方向(基于世界空间)和颜色强度。
方向光照射到物体表面后,将产生漫反射(diffuse reflection)及高光反射(specular reflection)。我们需将这些光照效果与上一章中的环境光进行融合。
漫反射原理及其计算公式
想象一束光打在一面粗糙的墙壁上。
墙面上有无数细小的坑洼,光线打上去后,会像弹珠一样被这些坑洼弹向四面八方。不管你的眼睛站在哪个角度(正对着看、斜着看),接收到的反射光强度都差不多。这就是漫反射 —— 它让物体看起来是“哑光”的,没有刺眼的高光点。
漫反射的强度与光源的位置有关系,如下图所示。
取太阳光为例。中午12点,当太阳正好位于头顶上时,其照射强度最猛;下午2点,太阳朝西边稍落,照射强度有所下降;当日落西山,此时已经没有任何阳光照射,大地一片黑暗。取太阳垂直照射目标物体时为角度起始点,设此时阳光强度为1。下午2点时,太阳照射目标物体射线的角度向西旋转了Θ度,阳光强度有所减弱。当太阳落山后,太阳照射目标物体射线的角度向西旋转了Θ'度,阳光强度为0。
这种特性与cos(Θ)的特性一致:
故此,计算漫反射的Lambert公式为:
\[I_d = K_d \cdot L_d \cdot cosΘ\]
其中:
- 结果\(I_d\):漫反射颜色值。
- 因子\(K_d\):材质对漫反射光的反射系数。值域为[0.0, 1.0]。
- 因子\(L_d\):光照颜色。
- 因子\(cosΘ\):受光源倾斜度影响的调节系数。在(0o, 90o)的范围内,随着角度值的增大,此调节系数值在(1, 0)的范围内变小。
但此公式有个问题,即当角度大于90o时,其余弦值小于0:
当余弦值为0时,已经没有任何光照效果;若余弦值小于0时,在视觉上没有任何意义。且在数学上,如果将值为负数的该余弦值参与其它数学运算,则可能会导致其它数值变小,从而导致意外的漏光
的结果。为避免因此而带来的副作用,当余弦值小于0时,我们取用这两者之间的最大值0:
\[I_d = K_d \cdot L_d \cdot max(0, cosΘ)\]
求角度的余弦值涉及到较多的数学运算。如下图所示,该角其实也是法线方向与光源方向这两个向量的夹角。
因此,如果若能转换为向量的计算,则较为简单。两向量的点积 (dot product) 公式为:
\[A \cdot B = |A| \times |B| \times cosΘ\]
其中\(|A|\)表示向量\(A\)的长度,\(|B|\)表示向量\(B\)的长度。若我们能人为地将\(|A|\)与\(|B|\)均归一化,让它们的长度值均为1,则上面公式变为:
\[A \cdot B = |A| \times |B| \times cosΘ = 1 \times 1 \times cosΘ = cosΘ\]
将\(|A|\)代入为归一化的法线向量\(\vec{N}\),将\(|B|\)代入为归一化的光源方向向量\(\vec{L}\),则上面Lambert公式可转换为:
\[I_d = K_d \cdot L_d \cdot max(0, \vec{N} \cdot \vec{L})\]
下面我们将看到如下的代码:
其数学原理正基于此。
这也说明了在调用dot函数时,要求其各个参数必须先经归一化的原因:只有参数均归一化,dot函数的返回值才能完全等同于\(cosΘ\)的值。
顶点着色器
顶点着色器代码与环境光的代码一致,只需向顶点属性变量uNormalMatrix及vNormal分别传入法线矩阵及法线数据。其余光照属性在片断着色器中处理。
与顶点位置、顶点颜色的属性一样,对应于顶点属性aNormal的法线数据也是基于顶点的数据,即每个顶点都应有此属性。当网格物体的位置因平移、旋转、缩放而改变时,其各个顶点的法线数据也应随之而改变。因此,代码:
让每个顶点的aNormal自动随着uNormalMatrix的改变而改变。剩下的问题,就是客户端中如何计算对应于uNormalMatrix的法线变换矩阵并向顶点着色器传入该值。具体详见下节。
之后,代码:
将经过矩阵运算后的aNormal,向vNormal赋值,准备传递至片断着色器进行下一步的处理。
顶点属性uModelMatrix在方向光中用不上,但这里预留。
法线变换矩阵
为何需要法线变换矩阵?
一开始在我们手工指定法线(或通过计算得出法线)时,法线是垂直于表面的。但如果模型在各个轴向上进行了非均匀缩放或剪切 (shear),例如,将一个球体沿X轴拉伸,则球体中上部的各个表面从原来的竖直方向慢慢转变为躺平的水平方向,因此这些表面的法线朝向也应由原来的方向趋向于朝向顶部。故此,这种情况下,我们需要重新计算各表面的法线。
当网格物体仅有平移、或均匀缩放、均匀剪切时,各个顶点上的法线作为向量,它们的朝向并未发生改变,无需重新计算法线朝向。但当旋转时,顶点法线朝向发生改变。此时也需要重新计算法线朝向。
而当我们需要改变一个向量时,最便捷的方法就是使用一个专门的矩阵与其相乘,这个矩阵即为法线变换矩阵。在下节中,我们将看到如何根据网格物体的模型矩阵来构建出其法线变换矩阵。
法线变换矩阵的数学原理
在三维空间中,顶点位置可以用模型矩阵M直接变换。且法线须始终垂直于表面。假设表面上有一条切线向量T,其法线为N,在模型变换前满足\(N \cdot T = 0\)(两者相互垂直)。设T'为变换后的切线,则变换后:
\[T' = M \times T\]
我们想找到一个法线变换矩阵G,使得变换后的法线\(N' = G \times N\)依然与T'垂直,即:
\[(G \times N) \cdot (M \times T) = 0\]
通过数学推导(点积转写成矩阵乘法),可得出:
\[G = (M^{-1})^T \]
在此公式中,\(M^{-1}\)是指存在另一矩阵\(M^{-1}\),使得\(M \times M^{-1} = I\),其中\(I\)为单位矩阵。意义为,只要矩阵\(M\)与矩阵\(M^{-1}\)相乘,结果为单位矩阵。其结果的实际意义为:只要两个矩阵相乘,将撤销该模型之前的所有模型变换。而矩阵\(M^{-1}\)称为矩阵\(M\)的逆矩阵。
逆矩阵在算法上是严格地反向倒序执行矩阵抵消。例如矩阵\(M\)往往是由缩放、旋转、平移按顺序叠加而成(\(M = T \times R \times S\)),则逆矩阵先执行平移的逆转,再执行旋转的逆转,最后执行缩放的逆转,从而使得矩阵\(M\)回到原来的单位矩阵的状态。
glMatrix中的mat3模块的invert函数可用于直接求出一个模型矩阵的逆矩阵:
由于仅是平移变换无需生成法线变换矩阵,因此glMatrix只有mat3模块有invert函数,mat4模块没有该函数。上面代码先将4阶矩阵转换为3阶矩阵再求出其逆矩阵。而在4阶矩阵转换为3阶矩阵的过程中,只提取原矩阵的左上角3 × 3子矩阵,而平移数据存储在原矩阵的第4列,故这样的转换将直接丢弃平移数据,以提高应用程序效率。
逆矩阵的本意是仅抵消缩放,尤其是非均匀缩放,但它还有一副作用,即将模型的旋转也同时抵消了。例如,如果模型围绕Y轴旋转了45o,则在求逆矩阵过程中,逆矩阵将围绕Y轴旋转-45o,以抵消原来的旋转变换。很明显,我们希望保留模型的旋转矩阵。如何从逆矩阵中取出原来的旋转矩阵的信息?
旋转矩阵有个特性:
\[R^{-1} = R^T \]
即,如果将上面的逆矩阵视为一个旋转矩阵R,则我们只需对其进行转置 (transpose),逆矩阵就可以旋转至原来的旋转度数。
因此,上面推导出来的公式:
\[G = (M^{-1})^T \]
变量G本质上就是一个抵消模型非均匀缩放后的旋转矩阵。将其传入着色器,与各顶点的法线向量相乘后,可得出修正后的法线向量。
需强调指出的是,在矩阵语义中,符号\(M^{-1}\)不是指对矩阵\(M\)进行-1次幂的数学运算。正确的解读是,\(M^{-1}\)是一个逆矩阵,它是矩阵\(M\)进行逆转后所得到的逆矩阵。同样,符号\((M^{-1})^T\)不是指对矩阵\(M^{-1}\)进行T次幂的数学运算,而是指对矩阵\(M^{-1}\)进行转置。
在客户端的应用
在SolidMesh的基类AbstractMesh中,有以下定义:
即在网格物体发生旋转变换时,立即算出其法线变换矩阵normalMatrix:
再次重申,仅当网格物体发生了非均匀的缩放或非均匀的剪切时,才需要重新生成法线变换矩阵。目前应用框架尚未实现对网格物体非均匀的缩放及非均匀的剪切功能,因此上面代码在旋转时重新生成法线变换矩阵是一种针对将来的未雨绸缪的防御性编码。
glMatrix的mat3的normalFromMat4函数,即是上面固定公式的高效版本。
而在渲染网格物体时,renderInViewport方法负责计算总的变换矩阵,并统一更新顶点着色器中的相应属性变量。
法线变换空间一致性的问题
当进行法线变换时,有2种选择。一是变换至世界空间 (world space),二是变换至视图空间 (view space)。
所谓变换至世界空间,是指法线变换矩阵normalMatrix仅根据modelMatrix来求出。上面的代码即是采用了这种方案:
其配置要求是以后光源方向、观察方向均也须在世界空间中定义。
这种方案的性能红利是,只要模型不动(modelMatrix 不变),这个normalMatrix就永远不用更新。即使相机(viewMatrix)疯狂旋转、移动,法线矩阵纹丝不动,CPU零开销。
所谓变换至视图空间,是指normalMatrix根据modelViewMatrix来求出。
其配置要求是以后光源方向、观察方向均也须在视图空间中定义。
这种方案的致命弱点是,如果相机一动(viewMatrix 变化),则必须在客户端每帧重新计算normalMatrix。随着以后加入骨骼动画或变形,开销将进一步加大。
鉴于此,本教程采用变换至世界空间的方案。
片断着色器
代码
出于实用考虑,片断着色器可以选择是否需要渲染法线。main函数的第一部分代码即实现此目的:
当需要渲染法线时,只需将片断着色器中uIsRenderingNormal的值置为true,将vColor向fragColor赋值后立即退出片断着色器主干代码流程。
AbstractMesh的renderInViewport方法相应代码为:
此时,只需构建并渲染normalVAO即可。
片断着色器main函数的其余代码负责渲染光照效果。
uLightDir代表方向光的方向,uLightColor代表方向光的颜色,uLightIntensity代表方向光的强度。
GPU性能与渲染质量博弈问题
在编写着色器代码时,在哪里调用生成法线变换矩阵的函数、在哪里进行归一化,对应用程序的效能及渲染质量影响很大,需仔细考虑、适当安排。
首先,绝对不要在着色器中生成法线变换矩阵,而应放在客户端(或称CPU端)生成。
求逆矩阵的算法涉及大量除法及高斯消元法,计算量极大。如果放在顶点着色器,则每个顶点都要算一遍,完全属于重复计算。
正确做法:在CPU端提前算出法线变换矩阵,然后通过uniform传入到着色器中。这样整个模型只需计算一次,效率提升上万倍。
其次,对法线进行归一化,应放在片断着色器中进行。
这是计算机图形学中的黄金准则
。原因在于,顶点着色器输出的法线,在传递到片断着色器之前,GPU将根据三角形面积进行线性插值。若在顶点着色器先归一化,则插值基于单位向量进行,因此插值后得到的向量长度依然不为1,光照计算后颜色变暗。
而若将法线归一化放在片断着色器中、且在光照计算前进行,则包括插值法线在内的任一法线,其长度均能为1。如此每个像素的光照强度才是绝对精准的。
再次,下面的代码:
暗藏了较多的玄机。
第一,GLSL的dot函数要求其参数必须是长度值为1的单位向量。而这些单位向量,按图形学的命名潜规则,一般使用大写单字母来标识。有以下几种:
- N: 法线
- L: 光源方向
- V: 观察方向
- H: 半角方向
第二,上面代码先将变量命名为uLightDir,这样,在CPU端中既能清晰地知晓uLightDir的作用,而在片断着色器中按图形学中的命名潜规则重新命名:
以命名L来提醒该变量必须先经归一化。因光线方向是恒定的,因此它将在CPU端指定或计算后在CPU端进行归一化,再传进片断着色器。以潜规则重新命名,足以让开发人员警醒复查该变量是否已经归一化。
第三,GLSL编译器对多出的赋值语句:
将足够聪明地在编译阶段进行别名优化,上面代码经编译后将优化为:
也即,所多出的这一行代码,性能损失为0。
因此,上面的编码方式是非常值得推荐的,既保留了CPU预计算的性能优势,又利用命名规范(大写L)提升了代码的健壮性和可读性,且与社区规范高度兼容。
光照计算
首先,根据均处于世界空间中的vNormal及uLightDir求出漫反射diffuse。
其次,将diffuse与光的颜色及光的强度相乘后,再与环境光uAmbient融合,赋值于scatteredLight。
最后,再将结果融合进网格物体的基本颜色中。
客户端主要代码
需要强调指出的是,所谓光源的方向,是指从物体表面指向光源的方向向量。它与光线传播方向(photon direction)正好相反。后者是指光子从光源出发,射向物体。这两者很容易混淆。
尽管方向光确实没有位置之说,但我们可以根据任意两点来构建一个向量来代表光的方向,而这两点,可以分别是光源位置及其光源所要照向的目标位置。只要用代表光源位置的向量减去代表照射目标的向量,其结果就是光源的方向。鉴于此,上面代码设计了calcLightDirection函数:
这样,只要调节lightPos及target的值,我们就很容易、且绝不会出错地得到代表光源的方向的值。
在该函数参数中,形参outLightDir表示该参数用于存储输出结果。因为实参lightDirWorld是一个对象,因此它能承载函数内部的运算结果。这样做,有一个巨大的好处,我们可以将calcLightDirection函数安全地放置于动画循环内部,只要实参lightDirWorld位于动画循环外部,在动画过程中就不会因产生过多的中间变量而导致频繁地触发垃圾回收,从而降低应用程序的性能。
运行
运行应用。
两个网格物体,一个平躺,另一个竖立,从(0.5, 0.5, 0.5)照向(0.0, 0.0, 0.0)的方向光能将它们同时照亮。
两个模型的法线数据均是一样的。但注意第一个模型。它围绕X轴旋转了-90o,但上面所谈到的代码:
能让其normalMatrix自动随之而改变,从而确保了其法线总是处于正确的状态。
按N键,可切换显示/隐藏法线。
法线颜色为青色,且不受环境光或其它光源影响,如此在较暗的场景下仍可清晰地看到法线。唯一例外是,当场景渲染模式为框线模式时,法线将不可见。
通过调节lightPos及target的值,改变光线的方向,将会看到截然不同的光照效果,从而验证我们的法线是否设置正确。
自动求出平面中的法线
为处于一个平面中的各个顶点手工指定法线是一件既烦人又容易出错的事情。本节中我们可对位于一个平面内的各个顶点自动算出它们的法线。
运行应用。
两个平面,一个平躺,另一个围绕X轴进行了任意角度的旋转。从运行结果来看,两个平面所自动生成的法线均能垂直于各自的平面,且光照效果正常。
Geometries的GenNormalsInFaces方法根据形参facesIndices,自动求出各个面中所有顶点的法线:
对于一个平面内的各个点,无论其数量是多少,只取最前面3个相邻的顶点,构建两条边,只要这两条边不共线,对这两条边进行叉乘并归一化后,就能求出垂直于该平面的一条法线,最后,将该法线的值向平面内所的顶点赋值。
代码:
创建一条从p1指向p2的向量。
同样,代码:
创建一条从p1指向p3的向量。
在计算法线时,为确保法线方向正确,有一个最简单且直观的规则:一角共点射线。上例中,p1为角的起始点,分别创建了从p1到p2、从p1到p3的两条射线。只要遵循这个规则,则按逆时针方向指定的各点所生成的法线朝向观察者;则按顺时针方向指定的各点所生成的法线背向观察者。
模拟光源
运行应用。
使用两个小圆球,分别用于模拟方向光光源的位置及光源所照射的目标点。由此可较为方便地检查各个网格物体各个面的受光情况。
DirVectorMesh
基本实现
本节实现一个带有箭头的DirVectorMesh,可用于辅助显示任意向量的方向。
运行应用。
一个带有箭头的白色向量,从顶点(1, 0, -1)指向(2, 1, -2)。本应用的重点及难点在于如何根据任意向量的方向来动态地确定箭头的朝向。
客户端代码:
DirVectorMesh的构造方法中,fromVertex代表起点,toVertex代表终点。其源代码如下:
Geometry的GetArrowVertices方法,根据起点与终点自动生成与箭头有关的数据。返回的数据中,有一arrowVertices属性,存储了箭头水平方向上的数据。将其与代表起点与终点的两个顶点拼接后,用作所有顶点以构建一个WireframeMesh。
从程序运行效果可以看到,在坐标系原点位置,有黄色及紫色2条线段,它们代表了垂直于带箭头向量的2条正交方向向量,其中,黄色向量代表向上方向,紫色向量代表向右方向。因为在一般的应用中很少会渲染这两条线段,因此DirVectorMesh构造方法中的最后两行,将它们的值存储为类的属性值,以满足在需要时可予以渲染的需求。
应用程序的客户端使用它们构建两个WireframeMesh来渲染:
Geometry的GetArrowVertices方法的代码如下:
getPerpendRightDir函数先取Y轴上的向量(0, 1, 0)作为引导向量,通过vec3的cross函数,求出forward与up的叉积,则right即为垂直于forward与up所构成平面的向量。如果right的值非常小,小于阈值1e-8,说明up平行于forward,不能用作引导向量,代码中将另选X轴上的向量(1, 0, 0)作为引导向量。作为最后的选项,Z轴上的向量(0, 0, 1)将永远不会被选到,因为3D坐标系中,不存在一条向量同时平行于Y轴与X轴。但作为防御性编码,在代码中一并列出。
以上代码可见,cross函数具体以下特点:
- 根据两个不平行的向量所构成的平面来求出一个垂直于平面的一条向量。
- 如果返回的向量的模长太短(几乎为0值),则说明用以计算的两条向量趋近于平行。可据此判断两条向量是否平行。
上面代码中的isParallel函数封装了此逻辑。
但在函数getPerpendRightDir中,因已经直接求出right向量,故此直接判断其模长的值即可,无需再调用isParallel函数。 - 若改变函数中参数forward及参数up的次序,则结果向量的方向翻转。
- 参数可无需先经归一化。但通常对其返回的结果向量进行归一化以获取单位向量。
而在GetArrowVertices方法中,所求出rightDir是一个垂直于forward的第一个基础轴。正如它们的命名,如果forward表示向前的向量,则rightDir是一个垂直于forward且方向向右的向量。
然后,根据rightDir及forward这两个向量,再求出垂直于forward且方向向上的第二个基础轴upDir。
现在,我们有了3个正交的轴向。forward是向量自身的方向,rightDir是水平轴向,upDir是向上轴向。此时,vec3的scaleAndAdd函数就可派上大用场了。它可以在各个轴向上直接计算偏移值。根据此特点,horzLeftArrowStart代表了箭头水平翼展的左顶点,horzRightArrowStart代表了箭头水平翼展的右顶点。将它们与forward的终点相连,即为箭头的水平顶点数据。
同理,vertUpArrowStart代表了箭头垂直翼展的上顶点,vertDownArrowStart代表了箭头垂直翼展的下顶点。将它们与forward的终点相连,即为箭头的垂直顶点数据。尽管有此数据,但我觉得渲染水平方向的箭头足矣,同时渲染垂直方向上的箭头反倒不好看。因此方法返回的对象中,属性arrowVertices只包含了箭头水平方向上的数据。
同时,将rightDir及upDir也打包进返回对象的perpendPlane属性中。上面客户端代码即是利用它们在原点位置渲染出这两个方向向量。
上面使用的方法,是基于局部正交基 (ONB, Orthonormal Basis)的算法。rightDir及upDir正分别对应于ONB中的right及up成分。RenderMan及Unreal等著名3D渲染引擎均使用了ONB类。
将正交平面移至方向向量的开始位置
rightDir及upDir均为表示方向的向量,而向量的特点是有方向及长度,但无位置之说。因此上面的应用程序,这两个向量均定位于原点,渲染时与dirVectorMesh的起点相分离。
若要将它们所构成的平面平移至dirVectorMesh的起点,需另行构建相应的顶点。
在attach函数中,将原点值(0, 0, 0)与dir的值分别与fromVertex的值相加,可得到平移至fromVertex处、且保留模长及方向的两个顶点。
在3D编程中,我们可以使用向量来表示顶点。但在数学上,向量与顶点有着本质区别。向量的本质在于运动,也即顶点朝着哪个方向运动了多长距离。而我们经常混淆这两者。此例可看清这一点。在编程实践中,一般较好的规范做法是,使用带有dir
字样的名称来命名方向向量。
运行应用。
正交平面平移至方向向量的起点处。由此可更清晰地看到3条向量相互垂直的效果。
修改方向向量的起点与终点,核查自动生成的平面及箭头的朝向是否正确。
同时渲染模拟光源与光线
现在,可以同时渲染模拟光源的网格物体及其光线的效果了。
运行应用。
从代表光源的网格物体射出一条光线,穿透左边的立方体,指向立方体背光部位的某处。
通过这种将光源及光照射线可视化的方式,光照规律不再是黑匣子。
高光反射原理及其公式
当光线照在一个比较光滑的物体表面上时,既有漫反射效果,也有高光 (specular) 效果。下面使用Canvas 2D来渲染出一个有高光效果的球体。
经验告诉我们,高光效果与材质的对光照的反射率、表面光滑度、光源位置、观察位置等诸多因素相关。
当光源位置固定,改变观察位置时,高光的强度及范围均会发生显著的变化。下图可说明此原因。
射入光线与法线N的夹角为Θ,则在同一平面上以相等的角度值、围绕着N反射至R。
此时,如果相机的位置正好处于R的射线上,则入眼光照效果最强,且高光范围集中为一个点。上图中将相机的位置稍微往下移动,则相机方向与R构成角度φ。随着角度φ的增大,高光强度减弱、且高光面积增大,也即,高光从尖锐的一个亮点转变为高光面积弥散开来的柔和。
与漫反射原理一样,可先用cosφ来为高光强度建立起数学模型。但此数学模型只能反映高光强度的线性变化,而不能反映高光面积的变化。
若要同时反映高光面积,可再引入另一变量,对cosφ取α次幂,即\(cos^αφ\)。这里的α称为光泽系数 (shininess coefficient)。此时数学模型转变为能反映高光面积的幂函数,下图为幂函数图像:
光泽系数α数值越大,高光强度稍微变小,但高光面积急剧向X轴中心靠拢而变小。上图中当α值为500时,高光面积围绕原点急剧收缩而变小。此值与金属的高光反射相符合,因此对金属等材质,α可在[100, 500]的值域取值。
α值越小,高光强度稍微变大,而高光面积变得较大,在视觉上体现出高光在大范围内弥散开来。因此,对塑料等需在较大范围内反射高光的材质,它们的α值可在[1, 99]范围内取值。
由此,Phong模型的计算高光的公式如下:
\[I_s = K_s \cdot L_s \cdot cos^α𝜑\]
其中:
- 结果\(I_s\):高光颜色值。
- 因子\(K_s\):所反射的高光系数,值域为[0, 1]。
- 因子\(L_s\):光源强度及颜色值。
- 因子\(𝜑\):相机方向与反射矢量R的夹角。
- 因子\(α\):光泽系数,反映高光的扩散范围。
同样,如果使用矢量的点积来计算,且先将R及V均归一化:
则高光公式可转化为:
\[I_s = K_s \cdot L_s \cdot max(0, (\vec{R} \cdot \vec{V})^α)\]
但该公式需我们手工计算R反射向量,仍不大方便。能否仅使用L, N及V这3个因子来计算角度φ?
利用向量相加的特性,我们可引入另一条新的向量。
运行应用。我们可在顶视图中看到两向量相加的效果。
两个向量的相加,即是初中物理所学到的合力。结果向量为两条向量所构成的平行四边形的对角线。当两个向量的模长相等时,结果向量的射线可平分两向量所构成的角度。
上图中第一个向量的值为[1, 0, -2],第二个向量的值为[3, 0, -1],则结果向量的值等于两条向量各分量分别相加,即[4, 0, -3]。
回到Phong模型上,若将L与V相加,我们可得到一条新的向量H。
因L及V均为单位向量,故H能平分L及V所构成的角度。故此H常称为半角向量 (half vector)。
通过将L及V相加而得来的H的模长不一定为1,因此我们需记得对其进行归一化。
引入半角向量H后,原来的\((\vec{R} \cdot \vec{V})^𝛼\)可近似地由\((\vec{N} \cdot \vec{H})^𝛼\)所代替。此模型称为Blinn-Phong模型。
实际上,Blinn-Phong模型有较大的弊端:一是只能近似地接近于Phong模型的效果;二是除要求各个方向向量共面外,还需满足:光照视角接近法线方向(小角度),且材质足够光滑(指数𝛼的值需大于10)。三是在效果上,当表面背对光源 (\(N \cdot L < 0\)) 时,反射光线R仍有可能因朝向视线而产生掠射高光,但在Blinn-Phong模型中,当 \(N \cdot L < 0\),其值直接被钳制为0,因此失去了掠射高光的特性。
使用Blinn-Phong模型的原因是,在上世纪的90年代,GPU固定管线若要计算反射向量\(R = 2(N \cdot L)N - L\)时,需额外的乘法、加法及归一化操作。而当时的Blinn-Phong模型只需对\(L + V\)取模,极大地提高了性能。
而现代的GPU,计算反射向量R只是调用几条指令的事情,性能开销微乎其微。例如,在着色器代码中:
因此,现在编程,若需在Blinn-Phong模型或Phong模型两者中进行选择,直接选择后者即可。
实际上,现在应最好选择微表面PBR模型。在当今的硬件算力条件下,它才是真正的终极王者
。
渲染高光
运行应用。
一束方向光从各个网格物体的右上角照射,相机从左边稍微俯瞰场景。通过鼠标或键盘旋转、平移场景,则相机位置发生变化,高光也随之而变化。
顶点着色器代码:
vWorldPos是各个网格物体的各个顶点在世界空间中的位置,在片断着色器中将用于计算相机方向。
片断着色器代码:
使用Phong模型计算较为逼真的高光效果。核心代码:
首先,在渲染时,Mesh的renderInViewport方法将取出当前激活视口的相机的世界空间的位置,并向片断着色器的uCameraPos传递:
然后,将uCameraPos减去vWorldPos,并归一化,得到从顶点位置指向相机位置的相机方向V。
接着,求出反射向量R:
再次,使用上一节的高光公式计算出高光值:
上面的spec主要反映高光光晕面积,可再引入另一变量strength,专门用以调节高光的亮度:
最后,环境光与漫反射光需与材质颜色相融合,但高光不需要。融合后,两者相加,并恢复材质的Alpha通道值。
从此例中看出,即使光源位置不动,但随着相机位置的变化,高光的位置也随之而变化。旋转场景,发现并找出掠射高光的位置,这是只在Phong模型中才存在的特性。
本例中的光泽系数取值为500,则各个网格物体都体现出反射性较强的的金属特性。
