2. 光照¶
约 2344 个字 358 行代码 预计阅读时间 16 分钟
基础光照¶
颜色¶
现实生活中的物体颜色由该物体表面反射的光线决定。之前的例子中我们都默认照射光源为白光,它包含所有的颜色值。
在图形学上,光的反射定律决定的颜色值可以很简单地写作:
因此,我们可以简单修改片段着色器,使其最终输出的颜色符合这个定律:
接下来,我们打算创建一个可见的光源,为了便于对光源进行调整,我们为其设置一个单独的 VAO(VBO 和物体相同,因为我们打算用一个较小的箱子代替光源):
本例的顶点数据,为一个箱子
在渲染循环中,我们每帧都对物体和光源应用不同的变换矩阵:
理想的结果如下:
光照模型¶
光照模型基于我们对现实中的光的理解,例如,Phong Lighting Model 将光分为三个分量:
- 环境光照 ambient: 我们定义一个环境光照常量,它会永远给物体一些颜色
- 漫反射 diffuse: 物体某一部分越是正对光源,它就会越亮
- 镜面反射 specular: 模拟有光泽物体上的亮点
其中,漫反射和镜面反射光线强度的计算需要该点的法线方向。由于我们使用的是一个简单的 3D 立方体,可以简单地将法线数据加入顶点数据中。
根据 带法线的顶点数据 ,我们重新修改顶点属性指针使得着色器能够正确识别数据:
我们需要将某顶点的法线数据和变换后的位置从顶点着色器传输给片段着色器,因此顶点着色器的代码修改为:
矩阵求逆
矩阵求逆是一个相当复杂的运算,我们应该尽可能避免在着色器中进行求逆运算。对于一个高效应用来说,最好在外部(CPU)中求出逆矩阵,然后通过 uniform 传入着色器。为了方便学习理解,此处仍然把求逆操作放于着色器内。
而 Phong Lighting Model 下的片段着色器代码为:
Gouraud 着色和 Phong 着色
早期开发者曾经在顶点着色器中实现光照模型的计算,相比于在片段着色器中计算,光照计算频率要低得多,因此更加高效。但是最终得到的颜色值仅仅有周围几个顶点的颜色线性插值计算,效果不够真实,这种着色称为 Gouraud 着色。而片段着色器运行于光栅化之后,因此 Phong 着色会对每个 Pixel 都计算颜色。
最终得到效果如下:
材质¶
材质结构体¶
在现实世界中,不同材质的物体对光有不同的效果。为了在 OpenGL 中模拟多种类型的物体,我们需要针对每种表面定义不同类型的材质。
当描述一个表面时,我们可以分别为三个光照分量定义一个材质颜色(Material Color):环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和镜面光照(Specular Lighting)。通过为每个分量指定一个颜色,我们就能够对表面的颜色输出有细粒度的控制了。现在,我们再添加一个反光度(Shininess)分量,结合上述的三个颜色,我们就有了全部所需的材质属性了:
镜面反射的 \(p=128 * shininess\)
由这四个元素定义的物体材质,我们可以模拟出很多现实世界物体的材质,http://devernay.free.fr/cours/opengl/materials.html中有表格展示了材质对立方体的影响:
以图中的 Emerald(翡翠) 为例,我们仍然可以通过 setVec3
来设置材质:
我们还需要对光的"材质"进行细化,要求光对于环境反射、漫反射和镜面反射的分量大小不同。定义光的结构体如下:
一种示例用的光线强度设置如下:
也可以将其称为漫反射率、镜面反射率等
最终可以得到一个较好的效果。
光照贴图¶
现实世界的物体往往并不包含一个材质,一个物体的不同部件往往需要不同的材质属性。
本节我们将拓展之前的系统,引入漫反射和镜面光贴图(Map),这允许我们对物体的光分量有着更精确的控制。
在光照场景中,纹理就相当于是一张漫反射贴图(Diffuse Map),它表现了物体所有的漫反射颜色的纹理图像。
为此,我们需要将之前定义的 vec3
类型的漫反射颜色向量更换为 sampler2D
类型的贴图:
此处也移除了环境光颜色向量,因为环境光颜色一般等于漫反射颜色,不需要分开存储
为了方便日后代码的编写,此处开始将读取图片的步骤装入一个独立的函数中:
而镜面光贴图需要额外一个纹理数据来存储,其中,黑色向量 vec3(0.0)
代表不会反射光。一个像素越'白',物体的镜面反射光越大。
这样,我们就能得到一个更真实的箱子:
记得修改对应片段着色器和顶点数据
投光物¶
现实世界有着很多种表现不同的光源,将光投射到物体的光源叫做投光物(Light Caster)。
1. 平行光
当一个光源位于很远的地方,来自光源的每条光线可以视为相互平行。当我们使用一个假设光源处于无限远处的模型时,它就被称为定向光,它与光源的位置是没有关系的。
对于平行光,我们不需要额外定义光源的位置来计算 lightDir
,可以直接在 Light 结构体中维护其 direction
:
2. 点光源
点光源是处于世界某一位置的光源,它会朝向所有方向发射光,但光线强度随距离递减。
一般来讲,光的衰减公式为:
其中常数项 \(K_c\),一次项 \(K_l\),二次项 \(K_q\) 都是可配置的。
- 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
- 一次项会与距离值相乘,以线性的方式减少强度。
- 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了。
由于二次项的存在,光线在大部分时候以线性衰退,直到距离变得足够大时,光强会以更快的速度下降。
正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等。在大多数情况下,这都是经验的问题,以及适量的调整。
下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。第一列指定的是在给定的三项时光所能覆盖的距离。这些值是大多数光源很好的起始点,它们由Ogre3D的Wiki所提供:
距离 | 常数项 | 一次项 | 二次项 |
---|---|---|---|
7 | 1.0 | 0.7 | 1.8 |
13 | 1.0 | 0.35 | 0.44 |
20 | 1.0 | 0.22 | 0.20 |
32 | 1.0 | 0.14 | 0.07 |
50 | 1.0 | 0.09 | 0.032 |
65 | 1.0 | 0.07 | 0.017 |
100 | 1.0 | 0.045 | 0.0075 |
160 | 1.0 | 0.027 | 0.0028 |
200 | 1.0 | 0.022 | 0.0019 |
325 | 1.0 | 0.014 | 0.0007 |
600 | 1.0 | 0.007 | 0.0002 |
3250 | 1.0 | 0.0014 | 0.000007 |
在我们的环境中,32到100的距离对大多数的光源都足够了
我们将这三个值放置在 Light 结构体中:
在片段着色器中,我们通过 GLSL 内置的 length
函数计算距离:
3. 聚光
聚光是位于环境某一位置的光源,它只朝某一特定方向范围发射光线,例如路灯、手电筒等。
OpenGL 中,聚光通过一个世界空间位置、一个方向、一个切光角(Cutoff Angle)来定义。
LightDir
:从片段指向光源的向量。SpotDir
:聚光所指向的方向。Phi
\(\phi\) :指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。Theta
\(\theta\) :LightDir向量和SpotDir向量之间的夹角。在聚光内部的话 \(\theta\) 值应该比 \(\phi\) 值小。
同时,为了实现边缘平滑软化,我们还额外维护一个 outerCutOff
成员变量,最终聚光灯的构造如下:
光从内圆锥边界逐渐减暗到外圆锥边界
对于一个手电筒光源,我们要求手电筒的位置以及朝向和相机相同,因此在 render loop 中需要进行如下设置:
我们希望将所有光源照射的结果结合起来,那么片段着色器中渲染的部分应该有如下形式:
各个函数具体的实现可以查看 https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/6.multiple_lights/6.multiple_lights.fs