learn opengl(3)

高级光照

Blinn-Phong光照模型

之前使用的光照模型是普通Phong模型,在计算镜面光时通过反射光向量和观察向量的夹角确定镜面光强度. 当角度大于90°时取0. 但这样可能会噪声在90°边缘出现明显断层.

img

当物体的反光度非常小时,它产生的镜面高光半径足以让这些相反方向的光线对亮度产生足够大的影响。在这种情况下就不能忽略它们对镜面光分量的贡献了. 也就是说,当pow()中的幂律比较小的时候,当反射光与观察向量夹角比较小的时候镜面光就非常强,在夹角接近90°时就接近0了,会出现这种尖锐的断层.

James F. Blinn在风氏着色模型上加以拓展,引入了Blinn-Phong着色模型。Blinn-Phong模型与风氏模型非常相似,但是它对镜面光模型的处理上有一些不同,让我们能够解决之前提到的问题。Blinn-Phong模型不再依赖于反射向量,而是采用了所谓的半程向量(Halfway Vector),即光线与视线夹角一半方向上的一个单位向量。当半程向量与法线向量越接近时,镜面光分量就越大。

当视线正好与(现在不需要的)反射向量对齐时,半程向量就会与法线完美契合。所以当观察者视线越接近于原本反射光线的方向时,镜面高光就会越强。

现在,不论观察者向哪个方向看,半程向量与表面法线之间的夹角都不会超过90度(除非光源在表面以下)。它产生的效果会与风氏光照有些许不同,但是大部分情况下看起来会更自然一点,特别是低高光的区域。Blinn-Phong着色模型正是早期固定渲染管线时代时OpenGL所采用的光照模型。

获取半程向量的方法很简单,只需要将光线的方向向量和观察向量加到一起,并将结果正规化(Normalize)就可以了,镜面光分量的实际计算只不过是对表面法线和半程向量进行一次约束点乘(Clamped Dot Product),让点乘结果不为负,从而获取它们之间夹角的余弦值,之后我们对这个值取反光度次方:

1
2
3
4
5
vec3 lightDir   = normalize(lightPos - FragPos);
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;

Blinn-Phong与风氏模型唯一的区别就是,Blinn-Phong测量的是法线与半程向量之间的夹角,而风氏模型测量的是观察方向与反射向量间的夹角。除此之外,风氏模型与Blinn-Phong模型也有一些细微的差别:半程向量与表面法线的夹角通常会小于观察与反射向量的夹角。所以,如果你想获得和风氏着色类似的效果,就必须在使用Blinn-Phong模型时将镜面反光度设置更高一点。通常我们会选择风氏着色时反光度分量的2到4倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main()
{
[...]
float spec = 0.0;
if(blinn)
{
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 16.0);
}
else
{
vec3 reflectDir = reflect(-lightDir, normal);
spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
}

Gamma矫正

一旦我们计算出场景的最终像素颜色,我们便需要在显示器上显示它们。在数字成像的早期,大多数显示器都是阴极射线管(CRT)显示器。这些显示器具有一个物理特性,即输入电压加倍并不会导致亮度加倍。输入电压加倍会导致亮度等于大约 2.2 的指数关系,这被称为显示器的gamma。巧合的是,这也与人测量亮度的方法非常接近,因为亮度也是以类似的(倒数)功率关系显示的。

因为人眼更习惯看暗色的变化,相同的变化在亮色中对于人眼来说不那么明显(非线性的增长),所以显示器(至今仍)使用功率关系来显示输出颜色,以便将原始的物理亮度颜色映射到顶部刻度中的非线性亮度颜色

这种显示器非线性映射确实为我们眼睛提供了更令人满意的亮度结果,但在渲染图形时存在一个问题:我们在应用程序中配置的所有颜色和亮度选项都是基于我们从显示器上感知到的,因此所有选项实际上都是非线性亮度/颜色选项

Gamme curves

点线表示线性空间中的颜色/光值,实线表示显示器显示的颜色空间。如果我们在线性空间中加倍一个颜色,其结果确实是值的两倍。例如,取一个光的颜色向量(0.5,0.0,0.0),它代表半暗红色光。如果我们在线性空间中加倍这种光,它就会变成(1.0,0.0,0.0),如图所示。然而,原始颜色在显示器上显示为(0.218,0.0,0.0),如图所示。问题从这里开始出现:一旦我们在线性空间中加倍深红色光,它在显示器上的亮度实际上会超过 4.5 倍

我们一直假设我们在线性空间中工作,但实际上我们一直在监视器的输出空间中工作,所以我们配置的所有颜色和光照变量都不是物理上正确的,而只是在我们的显示器上看起来(某种程度上)正确。因此,我们通常将光照值设置得比应有的要亮得多(因为显示器会使其变暗),这导致大多数线性空间计算都是不正确的。请注意,显示器(CRT)和线性图表都是从同一位置开始和结束的;是显示过程中变暗的中间值。因为颜色是根据显示器的输出配置的,所以在线性空间中的所有中间(照明)计算在物理上都是不正确的。随着更先进的照明算法的实施,这一点变得更加明显

Gamma校正(Gamma Correction)的思路是在最终的颜色输出上应用监视器Gamma的倒数。回头看前面的Gamma曲线图,你会有一个短划线,它是监视器Gamma曲线的翻转曲线。我们在颜色显示到监视器的时候把每个颜色输出都加上这个翻转的Gamma曲线,这样应用了监视器Gamma以后最终的颜色将会变为线性的。我们所得到的中间色调就会更亮,所以虽然监视器使它们变暗,但是我们又将其平衡回来了。

场景应用伽玛校正有两种方法:

  • 通过使用 OpenGL 内置的 sRGB 帧缓冲区支持。
  • 通过在片段着色器中自行进行gamma校正。

第一个选项可能是最简单的,但也给了你更少的控制。通过启用 GL_FRAMEBUFFER_SRGB,你告诉 OpenGL 每个后续的绘制命令应该在将颜色存储在颜色缓冲区之前,首先对颜色进行伽玛校正(从 sRGB 颜色空间)。sRGB 是一种颜色空间,大致对应于 2.2 的伽玛值,并且是大多数设备的标准。启用 GL_FRAMEBUFFER_SRGB 后,OpenGL 会在每次片段着色器运行后自动对所有后续帧缓冲区执行伽玛校正,包括默认帧缓冲区

开启GL_FRAMEBUFFER_SRGB简单的调用glEnable就行:

1
glEnable(GL_FRAMEBUFFER_SRGB);

从现在起,您的渲染图像将进行伽玛校正,这是通过硬件完成的。在使用这种方法(以及另一种方法)时,您应该记住的是,伽玛校正(同样)将颜色从线性空间转换为非线性空间,因此您只应在最后和最终步骤进行伽玛校正非常重要。如果您在最终输出之前对颜色进行伽玛校正,所有后续对这些颜色的操作都将基于错误值。例如,如果您使用多个帧缓冲区,您可能希望中间结果在帧缓冲区之间保持在线性空间中,并且只有最后一个帧缓冲区在发送到显示器之前应用伽玛校正。

第二种方法需要做更多的工作,但同时也让我们完全控制伽玛操作。我们在每个相关片段着色器运行结束时应用伽玛校正,以确保最终颜色在发送到显示器之前得到伽玛校正:

1
2
3
4
5
6
7
8
void main()
{
// do super fancy lighting in linear space
[...]
// apply gamma correction
float gamma = 2.2;
FragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

这种方法的问题在于,为了保持一致性,你必须对每个对最终输出有贡献的片段着色器应用伽玛校正。如果你有十几个用于多个对象的片段着色器,你必须将这些伽玛校正代码添加到每个着色器中。一个更简单的解决方案是在你的渲染循环中引入一个后处理阶段,并在后处理的四边形上作为最后一步应用伽玛校正,你只需做一次。

sRGB纹理

一些纹理创作者基于屏幕/monitor修改颜色,这种颜色就是在非线性空间sRGB下被gamma2.2修改过的,如果对这些纹理进行gamma矫正就可能会导致过亮. 所以必须确保纹理的颜色空间和最终需要的颜色空间一致.

个解决方案是重校,或把这些sRGB纹理在进行任何颜色值的计算前变回线性空间。我们可以这样做:

1
2
float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse, texCoords).rgb, vec3(gamma));

为每个sRGB空间的纹理做这件事非常烦人。幸好,OpenGL给我们提供了另一个方案来解决我们的麻烦,这就是GL_SRGB和GL_SRGB_ALPHA内部纹理格式。

1
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

如果还打算在你的纹理中引入alpha元素,必究必须将纹理的内部格式指定为GL_SRGB_ALPHA。

因为不是所有纹理都是在sRGB空间中的所以当你把纹理指定为sRGB纹理时要格外小心。比如diffuse纹理,这种为物体上色的纹理几乎都是在sRGB空间中的。而为了获取光照参数的纹理,像specular贴图和法线贴图几乎都在线性空间中,所以如果你把它们也配置为sRGB纹理的话,光照就坏掉了。指定sRGB纹理时要当心。

将diffuse纹理定义为sRGB纹理之后,你将获得你所期望的视觉输出,但这次每个物体都会只进行一次gamma校正

衰减

在使用了gamma校正之后,另一个不同之处是光照衰减(Attenuation)。真实的物理世界中,光照的衰减和光源的距离的平方成反比。

1
float attenuation = 1.0 / (distance * distance);

然而,当我们使用这个衰减公式的时候,衰减效果总是过于强烈,光只能照亮一小圈,看起来并不真实。出于这个原因,我们使用在基本光照教程中所讨论的那种衰减方程,它给了我们更大的控制权,此外我们还可以使用双曲线函数:

1
float attenuation = 1.0 / distance;

双曲线比使用二次函数变体在不用gamma校正的时候看起来更真实,不过但我们开启gamma校正以后线性衰减看起来太弱了,符合物理的二次函数突然出现了更好的效果

这种差异产生的原因是,光的衰减方程改变了亮度值,而且屏幕上显示出来的也不是线性空间,在监视器上效果最好的衰减方程,并不是符合物理的。想想平方衰减方程,如果我们使用这个方程,而且不进行gamma校正,显示在监视器上的衰减方程实际上将变成(1.0/distance2)2.2(1.0/distance2)2.2。若不进行gamma校正,将产生更强烈的衰减。这也解释了为什么双曲线不用gamma校正时看起来更真实,因为它实际变成了(1.0/distance)2.2=1.0/distance2.2(1.0/distance)2.2=1.0/distance2.2。这和物理公式是很相似的。双曲线比使用二次函数变体在不用gamma校正的时候看起来更真实,不过但我们开启gamma校正以后线性衰减看起来太弱了,符合物理的二次函数突然出现了更好的效果。

在基础光照教程中讨论的更高级的那个衰减方程在有gamma校正的场景中也仍然有用,因为它可以让我们对衰减拥有更多准确的控制权(不过,在进行gamma校正的场景中当然需要不同的参数)。

总而言之,gamma校正使你可以在线性空间中进行操作。因为线性空间更符合物理世界,大多数物理公式现在都可以获得较好效果,比如真实的光的衰减。你的光照越真实,使用gamma校正获得漂亮的效果就越容易。这也正是为什么当引进gamma校正时,建议只去调整光照参数(使用线性)的原因

阴影映射

阴影是由于遮挡导致的光的缺失。当光源的光线因为被其他物体遮挡而没有照射到物体上时,该物体处于阴影中。阴影为有光照的场景增添了极大的真实感,并使观众更容易观察物体之间的空间关系。它们为我们场景和物体提供了更深的立体感。

大多数视频游戏使用的一种既有效又易于实现的技术是阴影映射。阴影映射不难理解,对性能的影响不大,并且很容易扩展到更高级的算法(如全向阴影映射和级联阴影映射)

阴影映射背后的思想非常简单:我们从光源的角度渲染场景,从光源的角度看到的一切都是被照亮的,而我们看不到的一切都必须处于阴影中。想象一个地板部分和光源之间有一个大箱子的情况。由于光源在朝这个方向看时会看到这个箱子而不是地板部分,所以那个特定的地板部分应该处于阴影中

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道