参考教程
【中文版】基础光照
【英文版】Basic Lighting
学习心得
终于是开始接触一些较为复杂的东西了,这篇主要是讲解光照实现的原理(漫反射光和镜面光)以及他们的计算方式。
首先,我们需要作出一个适合检测光照的模型,在此我实现了一个有着自转与公转的光源——正方体模型,最终效果如下:
今天我们需要实现的是漫反射光,和镜面反射光。漫反射光是什么呢,首先我们需要介绍这几个概念,环境光,漫反射光,镜面反射光。
环境光,我们生活中很少有完全漆黑的时候,大部分情况下我们都可以模糊的看到物体,这个时候我们没有明确的光源,我们称之为环境光。在环境光下,我们只是能够勉强的看到物体,但并不是很清楚,例如:
忽略掉那个没有产生作用的光源,我们可以模糊的看到一坨东西,但是并不能很清楚的看到颜色(因为我注释了光源生效的代码)。
当漫发射光生效后,我们已经可以清楚的看到物体,以及辨别他的颜色(请注意,不管我们能否看到,物体是存在颜色的):
那么漫反射到底是什么呢,当一束光照到一个物体上时,因为物体本身颜色决定了他能吸收某个色的光和反射某个的光,同时我们就可以看到他的颜色。而漫反射重要的地方在于判断一束光是否照射到一个物体上,在这里我们需要拿出初中物理的两个概念,入射角,法线。
途中P为法线,A为入射光线,A与P的夹角即是入射角。看到这里想必你已经大概猜出来了,我们用来判断是否光照射到上边的依据就是入射角。当入射角大于90°时,说明光线没有照射到这里。而根据入射角的角度,光的强度也是不一样的。
明白了原理,我们便可以用OpenGL中Shader提供好的方法来模拟漫反射来对物体进行着色。首先我们需要得到物体的法线,我们在给出正方体坐标的时候一并给出。
1 | float vertices[] = { |
这个数组中,每六个值为一组,前三个存储坐标,后三个存储法线向量,可以看出由于我们的长方体有六个边,所以总共有六个不同的法线向量。
1 | // normal attribute |
将这些发现向量绑定到顶点数组里,然后可以在顶点着色器中操作,
1 | ... |
可以看到,我们将顶点坐标中的法线向量读出来,然后进行通过一个 inverse(矩阵的逆) 操作和 transpose (矩阵的转置)操作得到的三级矩阵与其相乘并传值给片段着色器。而做这多余的操作也是有原因的:
首先,法向量只是一个方向向量,不能表达空间中的特定位置。同时,法向量没有齐次坐标(顶点位置中的w分量)。这意味着,位移不应该影响到法向量。因此,如果我们打算把法向量乘以一个模型矩阵,我们就要从矩阵中移除位移部分,只选用模型矩阵左上角3×3的矩阵(注意,我们也可以把法向量的w分量设置为0,再乘以4×4矩阵;这同样可以移除位移)。对于法向量,我们只希望对它实施缩放和旋转变换。
其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了。因此,我们不能用这样的模型矩阵来变换法向量。下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:
每当我们应用一个不等比缩放时(注意:等比缩放不会破坏法线,因为法线的方向没被改变,仅仅改变了法线的长度,而这很容易通过标准化来修复),法向量就不会再垂直于对应的表面了,这样光照就会被破坏。
修复这个行为的诀窍是使用一个为法向量专门定制的模型矩阵。这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响。如果你想知道这个矩阵是如何计算出来的,建议去阅读这个文章。
法线矩阵被定义为「模型矩阵左上角的逆矩阵的转置矩阵」。真是拗口,如果你不明白这是什么意思,别担心,我们还没有讨论逆矩阵(Inverse Matrix)和转置矩阵(Transpose Matrix)。注意,大部分的资源都会将法线矩阵定义为应用到模型-观察矩阵(Model-view Matrix)上的操作,但是由于我们只在世界空间中进行操作(不是在观察空间),我们只使用模型矩阵。
在顶点着色器中,我们可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效。注意我们还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以
vec3
的法向量。在漫反射光照部分,光照表现并没有问题,这是因为我们没有对物体本身执行任何缩放操作,所以并不是必须要使用一个法线矩阵,仅仅让模型矩阵乘以法线也可以。可是,如果你进行了不等比缩放,使用法线矩阵去乘以法向量就是必不可少的了。
即使是对于着色器来说,逆矩阵也是一个开销比较大的运算,因此,只要可能就应该避免在着色器中进行逆矩阵运算,它们必须为你场景中的每个顶点都进行这样的处理。用作学习目这样做是可以的,但是对于一个对效率有要求的应用来说,在绘制之前你最好用CPU计算出法线矩阵,然后通过uniform把值传递给着色器(像模型矩阵一样)。
传给片段着色器之后,我们就获得了最后计算着色的法线,那么还需要计算入射向量,这个也比较简单,通过对片段位置和光源的位置相减就可以获得,而片段位置可以通过模型矩阵与定点坐标相乘获得:
1 | ... |
在顶点着色器里完成这些操作并传值给片段着色器,便可以获得入射向量。
最后,通过 dot 方法得到入射角,并使其不会产生负数(使用max,当dot得到负数时给他赋值0),如下:
1 | vec3 norm = normalize(Normal); //单位化 |
当计算光照时我们通常不关心一个向量的模长或它的位置,我们只关心它们的方向。所以,几乎所有的计算都使用单位向量完成,因为这简化了大部分的计算(比如点乘)。所以当进行光照计算时,确保你总是对相关向量进行标准化,来保证它们是真正地单位向量。忘记对向量进行标准化是一个十分常见的错误。
最后我们将漫反射后计算的颜色加上环境光颜色(一般为光源颜色的几十分之一倍,看具体情况)赋值给片段着色器的输出值即可。
1 | FragColor = vec4((diffuse + 0.1) * lightColor),1.0f); |
处理完漫反射后我们可以看看镜面光照,和漫反射光照一样,镜面光照也是依据光的方向向量和物体的法向量来决定的,但是它也依赖于观察方向,例如玩家是从什么方向看着这个片段的。镜面光照是基于光的反射特性。如果我们想象物体表面像一面镜子一样,那么,无论我们从哪里去看那个表面所反射的光,镜面光照都会达到最大化。你可以从下面的图片看到效果:
我们通过反射法向量周围光的方向来计算反射向量。然后我们计算反射向量和视线方向的角度差,如果夹角越小,那么镜面光的影响就会越大。它的作用效果就是,当我们去看光被物体所反射的那个方向的时候,我们会看到一个高光。
观察向量是镜面光照附加的一个变量,我们可以使用观察者世界空间位置和片段的位置来计算它。之后,我们计算镜面光强度,用它乘以光源的颜色,再将它加上环境光和漫反射分量。
我们使用摄像机坐标作为观察者坐标,在片段着色器中设立一个Uniform变量,并在OpenGL代码里将摄像机坐标传入进去
1 | uniform vec3 viewPos; |
定义一个镜面的反射强度,并用片段坐标减去观察点坐标得到我们的视线方向,通过 reflect 方法得到反射向量(传入参数为入射向量和法线向量,注意入射向量的方向是从光源到片段点)
1 | float specularStrength = 0.5; |
最后计算镜面分量,并将其与之前的漫反射光分量和环境光分量加起来乘光源颜色,就得到了最后要赋值的片段颜色:
1 | float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32); |