Unity 渲染教程(五):多个光源

对每个物体渲染多个光源的光照效果。

支持不同的光源类型。

使用光源cookie。

计算顶点光照。

在光照计算中添加球面谐波函数(spherical harmonics)的光照。

这是关于渲染基础的系列教程的第五部分。这个系列教程的上一部分讲的使用单一方向光的光照效果。现在我们将要添加对多光源光照的支持。

系列回顾:

Unity 渲染教程(一):矩阵

Unity 渲染教程(二):着色器基础

Unity 渲染教程(三):使用多张纹理贴图

Unity 渲染教程(四):第一个光源

这个系列教程是使用Unity 5.4.0开发的,这个版本目前还是开放测试版本。我使用的是build 5.4.0b21版本。

使用多光源对简单的白色球体进行照明的有趣效果。

导入文件

要为我们的着色器添加对多个光源的支持,我们必须向这个着色器添加更多的渲染通道。这些渲染通道最终将包含几乎相同的代码。为了防止代码重复,我们将把着色器代码移动到导入文件之中。

Unity没有菜单选项来创建着色器导入文件。因此,你必须通过操作系统的文件浏览器手动转到项目的资源文件夹。在与光照着色器相同的文件夹中创建一个名为My Lighting.cginc的纯文本文件。你可以复制我们的着色器文件内容,重命名它,然后清除它里面的内容。

你的第一个导入文件。

将我们的光照着色器的所有代码复制到这个文件里面,从#pragma语句的正下方直到ENDCG。因为这个代码不再直接出现在着色器通道之中,我不再缩进它。

我们现在可以在我们的着色器中导入这个文件,来替换以前的代码。因为这个着色器文件和导入文件在同一个文件夹中,我们可以按文件名直接引用它。

避免重定义错误

正如你已经知道的那样,导入文件本身可以导入其他导入文件。当你导入一些文件,而这些文件又导入相同的其他文件的时候,那么最终会在你的代码里面出现重复的代码。这将导致关于代码重定义的编译器错误。

为了防止这种重定义,通常使用定义检查来保护导入文件。这是一个预处理器检查,看看是否已经做出了某个定义。这个定义仅仅是与导入文件的名称相对应的唯一标识符。你可以定义它是什么,甚至没有。在我们的例子中,我们将使用标识符MY_LIGHTING_INCLUDED。

现在我们可以把我们的导入文件的全部内容放在一个预处理器if块中。条件是MY_LIGHTING_INCLUDED尚未定义。

通常,这个导入文件定义检查的里面的代码不进行缩进。

第二个光源

我们的第二个光源还是一个方向光源。复制主光源并改变它的颜色和旋转,这样的话你可以把这两个光源分开。此外,减少它的强度滑块,比如说是到0.8。Unity将会根据光源的强度来确定这两个光源的哪一个光源是主光源。

两个方向光。

虽然我们的场景中有两个方向光光源,但是场景中物体的渲染表现并没有因为引入了新的光源而产生变化。我们可以通过enable的开关来每次只激活一个光源。在只有一个光源激活的情况下,我们可以看到两个不同光源的不同的光照效果。但是当两个光源都激活的状态下,只有原来的主光源起作用,添加的第二个方向光不起作用。

一次只有一个起效果,不能两个同起作用。

第二个渲染通道

前面的实验中,我们只能看到只能有一个方向光光源生效的照明效果。产生这一结果的原因是,我们的Shader代码中只对一个光源进行了计算(第二个光源被忽略了)。前向基本渲染通道是用于主方向光源。为了渲染一个额外的光源,我们需要一个额外的渲染通道。

复制我们的着色器代码,并将新的光照模式设置为ForwardAdd。Unity将使用这个渲染通道来渲染额外的光源。

我们现在看到第二个光源的效果了,但是没有主光源的效果。Unity会渲染这两个光源,但是在后面执行的加法渲染通道的渲染结果会覆盖在它之前执行的基础渲染通道得到的渲染结果。这是错误的。为第二个方向光进行渲染计算的加法渲染通道应该将其渲染结果累加到原有的由基础渲染通道完成的主方向光的渲染结果之上,而不是替换原有的主方向光源的渲染结果。我们可以通过改变加法渲染通道的混合模式来让图形处理器将加法渲染通道的渲染结果加到基础渲染通道的渲染结果之上。

新旧像素数据的混合方法由两个因子确定。新旧数据与对应因子相乘,然后相加到一起来得到最终的混合结果。默认的混合模式是不对新旧像素数据进行混合,这相当于 One Zero的混合模式(混合结果 = 0x旧的颜色+1x新的颜色)。使用默认混合模式的渲染通道会使用新的渲染结果替换帧缓存中的当前像素中的任何数据。为了把当前的渲染结果累加到当前像素的帧缓存上,我们需要将混合模式设置为One One。这种混合模式被称为添加模式。

两个光源叠加到一起的效果。

在一个物体第一次被渲染的时候,图形处理器会检查当前的这个物体片段(fragment)是否被其他物体的片段遮挡。这个片段遮挡判断所使用的距离信息存在图形处理器的深度缓冲区中,这个深度缓冲区也称作Z缓冲区。因此,每个像素会有其对应的颜色和深度信息。这个深度信息表示每个像素上当前绘制的最前面的物体距摄像机的距离。这个机制有点像声纳。

如果我们想要渲染的片段没有被其他片段遮挡,那么它当前是最接近相机的表面的片段。图形处理器继续运行片段着色程序。它产生的结果会覆盖当前像素的颜色,并在深度缓冲中记录当前的物体深度。

对于一个像素,如果当前像素上当前准备绘制的物体片段的深度比深度缓存中保存的深度更远,那么在这个像素上,当前物体被其他物体遮挡。在这种情况下,当前这个像素上,我们不会看到正在准备渲染的这个物体,这个物体不会被渲染。

那么如何处理半透明对象呢?

深度缓冲方法仅适用于完全不透明的对象。半透明对象需要不同的方法。 我们将在未来的教程中处理这些半透明的对象。

这个过程会对第二个光源重复一遍,除了那些现在我们添加的已经存在的东西以外。再次,片段程序只有在没有什么东西在我们正在渲染的片段前面的情况下运行。如果是这样的话,我们最终得到的深度与之前的一样,因为它是对同一个对象进行渲染得到的信息。因此,我们最终记录完全相同的深度值。

因为写入深度缓冲区两次是没有必要的,让我们禁用它。这是使用ZWrite Off语句来关闭着色器。

绘制调用的批次

要更好地了解发生了什么,你可以启用游戏视图右上角的状态统计面板。我们关心的是绘制批次数量(Batches)和合批节省的批次数量(Saved By Batching)。这两个数字反映了当前场景的绘制调用情况。

我们先在只有主光源被激活的情况下观察。

五个渲染批次,一共七个。

因为我们有六个物体,你会期望有六个渲染批次。但是,启用动态批次处理之后,所有三个立方体都会合并为一个批次。所以在动态批次处理节省了两个批次之后,你认为我们将会有4个渲染批次。但是显示仍有5个渲染批次。

额外的批次处理是由动态阴影引起的。让我们通过完全禁用质量设置中的阴影,通过“编辑/项目设置/质量”来消除它。 请确保你调整了当前在编辑器中使用的质量设置。

不再有阴影,那么只有四个渲染批次。

为什么我还有一个额外的渲染批次?

你可能是在渲染环境的立方体贴图。这是另一个绘制调用。我们在前面的教程中已经禁用了它。

关闭阴影与立方体贴图的设置之后,你可能需要触发一下统计信息更新(比如通过点击游戏视图)来查看最新的批次信息。现在绘制批次应该是4,节省的批次应该是2。接着,我们激活第二个方向光光源来看看有什么变化。

两个光源,一共十二次批次。

因为每个物体现在被渲染两次,我们最终有十二个渲染批次,而不是六个渲染批次。这是预期得到结果。你可能不会期望到的是,动态批次处理不再工作。 不幸的是,unity只能对最多受到一个方向光影响的物体进行动态合批的优化处理。使用第二个方向光光源会使动态合批这个优化失效。

帧调试器

要更好地了解场景的渲染方式,现在可以使用帧调试器。通过窗口/帧调试器打开它。

帧调试器窗口。

当帧调试器启用的时候,帧调试器允许你逐步浏览每个绘制调用。窗口本身显示的是每个绘制调用的详细信息。游戏视图将显示所渲染的内容,并包括所选的绘制调用。

首先绘制的是靠近相机的不透明物体。这种从前到后的绘制顺序是高效的,因为由于深度缓冲区的缘故,隐藏的片段将被跳过。如果我们按着从后到前的顺序绘制物体,我们将会一直覆盖更远的物体的像素。这被称为过度绘制,应该尽可能的避免。

Unity从前到后排列物体,但这不是决定绘制顺序的唯一因素。更改图形处理器状态的开销也很大,进行渲染的时候需要尽量降低图形处理器渲染状态的变化次数。可以通过将相似的物体在渲染的顺序上排列在一起来减少渲染状态的变化。。举个简单的例子来说,Unity喜欢以组的形式渲染球体和立方体,因为它不必频繁地在网格之间进行切换。同样,Unity喜欢对使用相同材质的物体进行分组。

点光源

除了方向光源,还有其他类型的光源。让我们通过GameObject / Light / Point Light来添加一个点光源。

一个点光源。

要好好看下这个点光源的话,请先禁用两个方向光源。然后将点光源移动一点。(点击阅读原文,可查看效果视频)

光源表现得很奇怪。这是怎么回事? 当你使用帧调试器的时候,你会注意到,我们的物体首先被渲染为纯黑色,然后再次用奇怪的光进行渲染。

第一个渲染通道是基础渲染通道。它总是进行渲染,即使没有处于激活状态的方向光。所以我们最终得到一个黑色的轮廓。

第二个渲染通道是我们的加法渲染通道。这一次,我们的加法渲染通道使用的是点光源而不是方向光源。但我们的代码仍然假设使用的是一个方向光源。我们必须解决这个问题。

光源函数

因为我们的光将变得更复杂,让我们将创建它的代码移动到一个单独的函数里面。将这个函数直接放在MyFragmentProgram函数的上方。

现在我们可以简化MyFragmentProgram函数。

光源的位置

_WorldSpaceLightPos0变量包含当前光源的位置。但在方向光源的情况下,它实际上保持的是朝向光源的方向。现在我们使用的是点光源,变量确实包含了它的名字所暗示的数据。所以我们必须自己计算光的方向。这可以通过减去片段着色器里面的世界位置并对结果进行归一化来完成。

从位置信息中算出方向

光源强度的衰减

在方向光源的情况下,知道光源的方向就足够了。方向光源被假定为无限远。但点光源有一个明确的位置。这意味着光源到物体表面距离对光照的效果也有影响。距离光源位置更远,光照强度衰减得更弱。这被称为光的衰减。

在方向光源的情况下,假定衰减变化如此缓慢,使得我们可以将其视为常数。所以我们不用烦恼这个问题。但是,点光源的衰减是什么样子的?

想象一个场景中的一个点,在各个点上我们发射一次光子爆炸。这些光子均匀分布,向场景的各个方向直线运动。这些光子脉冲在所有方向上进行移动。随着时间的推移,光子脉冲进一步远离该点。由于这些光子都以相同的速度进行运动,这些光子在场景中会分布在一个以光源位置为中心的球面上。这个球的半径随着光子的移动而增加。随着球体的生长,其表面也随之增长。但是这个表面总是包含相同数量的光子。因此,随着球体的半径增大,单位表面积上光子的密度会减少。。这决定了观察到的光的亮度。

球形衰减。

半径r的球体的表面积等于4 pi r ^ 2。为了确定光子密度,我们可以除以球体的表面积。我们可以忽略常数“4 pi”,因为我们可以假设它被纳入光的强度之中。这导致了“1 / d ^ 2”的衰减因子,其中“d”是到光源的距离。

因为距离过近,造成的亮度过亮。

这产生了接近光源的非常明亮的结果。这是因为当距离接近零的时候,衰减因子变为无穷大。为了确保光的强度在零距离处达到其最大值,我们将衰减公式改为“1 /(1 + d ^ 2)”。

不再太亮的效果。

光的范围

在现实生活中,光子继续移动,一直到他们碰到什么。这意味着光的范围可能是无限的,即使它变得如此虚弱,我们也看不到它。但我们不想浪费时间渲染我们看不到的光。所以我们必须在某一点停止渲染它们。

点光源和聚光灯有一定的范围。位于此范围内的物体将使用这个光源进行绘制调用。而位于此范围外的其他对象不会。默认范围是10。这个范围越小,被这个光源照着到的物体越少,为实现这个光源照明效果的额外的绘制调用(drawcall)越少,从而会有更高的帧率。设置我们的光源范围为1,并移动它。

范围为1的光源

你会清楚地看到物体进入和离开光源的范围,因为他们会突然在照亮和熄灭之间切换。这是因为光线仍然可以超出我们选择的范围。为了解决这个问题,我们必须确保衰减和范围是同步的。

实际上,光没有最大范围。所以我们设定的范围大小是艺术自由。我们的目标是确保当物体移出范围的时候没有突然的明暗转换。这要求衰减因子在最大范围的时候达到零。

Unity通过将片段着色器里面的世界位置变换到光照空间的位置来确定点光源的衰减。这是光照局部空间中的点,通过其衰减来进行缩放。在这个空间中,点光源位于原点。任何超过一个单位的东西就会超出光照范围。因此,到原点的距离的平方可以用来定义缩放衰减因子。

Unity在此基础上进行了更进一步的优化。Unity通过距离的平方值来对衰减纹理进行采样,将采样结果作为衰减的值。这样做是为了确保光源在光照范围边缘上更早的衰减为0。不使用这一方法,你仍然能够在物体进出光照影响范围时,感受到轻微的光照跳跃效果。

这个技术的实现代码位于AutoLight导入文件之中。让我们使用它,而不是自己写。

AutoLight导入文件层次结构。

我们现在可以访问UNITY_LIGHT_ATTENUATION宏。这个宏插入代码以计算正确的衰减因子。它有三个参数。第一个是包含衰减的变量的名称。我们将使用衰减混合多个光源的效果。第二个参数与阴影有关。因为我们目前不实现阴影相关的内容,所以将这个值设置为0。 第三个参数是当前物体表面在世界空间中的位置。

请注意,宏定义了当前范围中的变量。所以我们不应该再自己声明它了。

UNITY_LIGHT_ATTENUATION是什么样子的?

这里是相关的代码。 #ifdef POINT语句是#if defined(POINT)的简写形式。

阴影坐标的类型在其他地方进行定义。它们是全精度或半精度浮点数。

点积产生单个值。 rr swizzle简单地复制它,所以你最终得到一个float2类型的结果。然后将这个结果用于对衰减纹理进行采样。 由于纹理数据是一维的,它的第二个坐标无关紧要。

UNITY_ATTEN_CHANNEL是r或者是a,这具体取决于目标平台。

因为我们不支持阴影,SHADOW_ATTENUATION宏变为1,可以忽略。

使用这个宏后,看起来衰减不再工作了。这是因为它有多个版本,每个光源类型都有一个。在默认情况下,它是为方向光源准备的版本,根本没有衰减。

正确的宏只有在知道我们正在处理点光源的时候才会被定义。为了表示这一点,我们必须在包括AutoLight之前#define POINT。 因为我们只在我们的加法渲染通道中处理点光源,在我们导入My Lighting之前对它进行定义。

混合多个光源的效果

关闭点光源并再次激活我们的两个方向光源。

不正确的方向光源与正确的方向光源的效果对比图。(右图为正确方向光示例)。

这里面有一些问题。我们将他们的光线方向解释为位置。并且第二个方向光源-由加法渲染通道进行渲染-被完全处理为好像它是一个点光源一样。 为了解决这个问题,我们必须为不同的光源类型创建不同的着色器变体。

着色器的变体

在检查器中检查我们的着色器。编译和显示代码按钮下面的下拉菜单包含一个部分,告诉我们当前有多少个着色器变体。单击显示按钮以获取它们的概述。

目前有两个变体。

打开的文件告诉我们,我们有两个片段,每个片段有一个着色器变体。这些是我们的基础渲染通道和加法渲染通道。

我们想为我们的加法渲染通道创建两个着色器变体。一个着色器变体用于方向光源,一个着色器变体用于点光源。我们通过在着色器变体中添加多编译编译指令语句来实现。这个语句定义了关键字列表。Unity将为我们创建多个着色器变体,每个定义了一个关键字。

每个变体是一个单独的着色器。它们单独编译。它们之间的唯一区别是定义了哪些关键字。

在这种情况下,我们需要DIRECTIONAL和POINT,我们不应该再自己定义POINT。

再次打开着色器变体的概述。这一次,第二个片段将包含两个着色器变体,正如我们所要求的那样。

场景中使用的2个关键词变量:

DIRECTIONAL

POINT

使用关键字

我们可以检查哪些关键字存在,就像AutoLight对POINT所做的一样。在我们的例子中,如果定义了POINT,那么我们必须自己计算光照的方向。否则,我们有一个方向光源,那么_WorldSpaceLightPos0是方向。

这适用于我们两个加法渲染通道的变量。它也适用于基本渲染通道的变量,因为它不定义POINT。

Unity根据当前光源和着色器变量的关键字决定使用哪个变量。当渲染方向光的时候,它使用的是DIRECTIONAl变量。当渲染点光源的时候,它使用的是POINT变量。当没有一个合适匹配的时候,它只是从列表中选择第一个着色器变体。

聚光灯

除了方向光源和点光源,unity还支持聚光灯。聚光灯有点像点光源,除了它们的范围被限制为锥形,而不是照射在所有方向。

一个聚光灯光源。

那么区域光源呢?

这些仅支持静态的光照。我们将在以后的教程中介绍这个主题。

为了支持聚光灯,我们必须将SPOT添加到我们的多重编译语句的关键字列表中。

#pragma multi_compile DIRECTIONAL POINT SPOT

我们的加法着色器现在有三个变体。

场景中使用3个关键词变量:

DIRECTIONAL

POINT

SPOT

聚光灯有位置,就像点光源一样。因此,当定义POINT或SPOT的时候,我们必须计算光的方向。

照射范围为60°角的聚光灯效果。

这已经足够让聚光灯工作了。它们最终有一个不同的UNITY_LIGHT_ATTENUATION宏,这个宏会处理锥形范围。

衰减方法开始与点光源的衰减方法相同。转换到光照空间,然后计算衰减因子。然后,对于位于原点后面的所有点,将衰减强制为零。这将光的范围限制在位于聚光灯前的物体。

然后,光照空间中的X和Y坐标用作UV坐标以对纹理进行采样。这种纹理用于对光进行掩码。纹理只是一个具有模糊边缘的圆。这产生光的圆柱体。为了将其变成锥形,到光照空间的转换实际上是透视变换,并且使用齐次坐标。

UNITY_LIGHT_ATTENUATION在聚光灯下的效果如何?

这里是它的代码。注意在采样掩码纹理的时候所做的从齐次坐标到欧几里得坐标的转换。在转换之后向uv坐标加上0.5,来确保(0,0)点在纹理坐标中央。

聚光灯的Cookie

默认的聚光灯蒙版纹理是模糊的圆。但是你可以使用任何方形纹理,只要它的边缘能够下降到零。这些纹理称为聚光灯cookie。这个名字源自cucoloris,它指的是一个电影、剧院或摄影道具,用来给光增加阴影。

cookie的透明度通道用于掩码光线。其他通道无关紧要。下面是一个纹理示例,其中所有四个通道都被设置为相同的值。

聚光灯的cookie。

导入纹理的时候,可以选择Cookie作为其类型。然后,你还必须设置其光源的类型,在这种情况下为聚光灯。 Unity将为你处理大多数其他设置。

导入的纹理。

你现在可以使用这张纹理作为你的聚光灯自定义cookie。

使用聚光灯cookie以后的效果。

更多Cookie

方向光源也可以有cookie。这cookie是平铺的。 所以他们不需要在他们的边缘变化为零。相反,他们必须无缝地进行平铺。

一个方向光源的cookie。

方向光源的cookie具有尺寸。这决定了Cookie的视觉上的大小,这个值越大,单位面积上,Cookie的重复频率越低。默认值为10,但是一个小的场景需要一个更小的比例值,比如说是 1。

主要方向光源带有cookie。

带有cookie的方向光源还必须执行到光照空间的转换。因此,它有自己的UNITY_LIGHT_ATTENUATION宏。因此,Unity将它作为不同的光源类型,而不是没有cookie的方向光源。所以他们总是使用DIRECTIONAL_COOKIE关键字通过加法渲染通道渲染。

带有cookie的方向光源。

在这种情况下,UNITY_LIGHT_ATTENUATION是什么样子的?

由于没有衰减,只有cookie被采样。

带有cookie的点光源

点光源也可以有cookie。在这种情况下,光线在所有的方向上传播,因此cookie必须环绕球体。这是通过使用立方体贴图完成的。

你可以使用各种纹理格式创建点光源的cookie,Unity会将其转换为立方体贴图。你必须指定映射方式,这样Unity才知道如何解释你的图像。最好的方法是自己提供一个立方体贴图,在这种情况下,你可以使用自动映射模式。

点光源cookie的立方体贴图。

点光源的cookie没有任何其他设置。

带有cookie的点光源。

此时,我们必须将DIRECTIONAL_COOKIE关键字添加到我们的多编译语句中。这是一个很长的名单。因为它是这样一个共同的列表,Unity为我们提供了一个简化的编译指令语句,我们可以改用。

#pragma multi_compile_fwdadd

//          #pragma multi_compile DIRECTIONALDIRECTIONAL_COOKIE POINT SPOT

你可以验证这确实产生了我们需要的五个变种。

场景中使用的5个关键词变量:

POINT

DIRECTIONAL

SPOT

POINT_COOKIE

DIRECTIONAL_COOKIE

并且不要忘记使用cookie来计算点光源的光照方向。

带有cookie的点光源照射场景的效果。

在这种情况下,UNITY_LIGHT_ATTENUATION是什么样子的?

它相当于常规点光源的宏,除了它也采样cookie以外。由于在这种情况下cookie是一个立方体贴图,所以它使用texCUBE做采样。

顶点光照

每个可见对象总是使用其基本渲染通道进行渲染。这个渲染通道主要考虑的是主方向光。每个额外的光源将添加一个额外的加法渲染通道。因此,许多光源将导致许多次的绘制调用。场景有许多光源,并且在光源的范围内有许多物体的将导致大量的绘制调用。

让我们举个简单的例子,比如说是一个具有四个点光源和六个物体的场景。所有物体都在四个光源的范围内。这需要对每个物体进行五次绘制调用。一次绘制调用用于基本渲染通道,在加上四个加法渲染通道。总共有30个绘制调用。需要注意的是,你可以添加一个单一的方向光,而不会增加绘制调用的次数。

四个点光源,六个物体,30次绘制调用。

为了保持绘制调用的数量以便检测,你可以通过质量设置限制像素光的数量。这定义了每个物体使用的光源的最大数量。当每个片段计算着色的时候,涉及到的光被称为像素光。

更高的质量水平允许更多的像素光源。默认的最高质量级别是四个像素光源。

每个物体的像素光源从0到4。

对每个对象决定渲染哪些光源是不同的。Unity根据这些光源的相对强度和到物体的距离来从最重要的到最不重要的光源进行排序。预期贡献最少的光源首先被丢弃。

实际上,还有更多的事情发生,但我们会在以后介绍这方面的内容。

因为不同的物体受不同的灯光影响,你会得到不一致的光照。当物体运动的时候,这会变得更糟,因为它可能导致光照的突然变化。

这个问题是那么的糟糕,因为光源完全关闭。幸运的是,有另外一种方法来更便宜的渲染光源,没有完全把他们关闭。我们可以按照每个顶点来渲染,而不是按照每个片段来渲染。

渲染每个顶点的光源意味着你在顶点程序中执行光照计算。然后将所得到的颜色内插值并传递给片段程序。这是很廉价的计算,所以Unity在基础渲染通道里面包括了渲染每个顶点的光源。当发生这种情况的时候,Unity会使用VERTEXLIGHT_ON关键字查找基础渲染通道的着色器变量。

顶点光照仅支持点光源这一种类型。所以定方向光源和聚光灯不能是顶点光源。

要使用顶点光源,我们必须在我们的基础渲染通道中添加一个多重编译语句。它只需要一个关键字VERTEXLIGHT_ON。另一个选项根本就是没有关键字。 为了表示没有关键字,我们必须使用_。

一个顶点光源

要将顶点光源的颜色传递给片段程序,我们必须将它添加到Interpolators结构体中。这当然只有在定义VERTEXLIGHT_ON关键字的时候才需要。

让我们创建一个单独的函数来计算这种颜色。它从内插值器里面读取和写入内结果,因此成为一个inout参数。

现在,我们将简单地传递第一个顶点光源的颜色。我们只能在光源存在的时候做到这一点。否则我们什么都不做。UnityShaderVariables定义了顶点光源颜色的数组。这些是RGBA颜色,但我们只需要RGB部分。

在片段程序中,我们必须将这个颜色添加到我们在那里计算的所有其他光源之中。我们可以通过将顶点光源的颜色作为间接光来实现。将间接光照数据的创建移动到它自身的函数里。在那里,将顶点光源的颜色分配给间接漫反射分量(如果存在的话)。

将像素光源的数量设置为零。每个物体现在应该被渲染为具有单个光源颜色的轮廓。

给每个物体加上第一个顶点光的颜色。

Unity以这种方式支持多达四个顶点光源。这些光源的位置存储在四个float4变量中,每个坐标一个。它们是unity_4LightPosX0、unity_4LightPosY0和unity_4LightPosZ0,它们在UnityShaderVariables中定义。这些变量的第一个分量包含了第一个顶点光源的位置。

接下来,我们计算光照矢量、光的方向和ndotl因子。我们无法在此处使用UNITY_LIGHT_ATTENUATION宏,因此我们再次使用`1 /(1 + d ^ 2)`。 这样就得到了最终的颜色。

注意,这只是一个漫反射项。虽然我们也可以计算镜面高光项,但是当它在大三角形进行内插的时候看起来效果很糟糕。

实际上,UnityShaderVariables提供了另一个变量unity_4LightAtten0。它包含了帮助近似像素光源衰减的因子。使用它,我们的衰减变为1 /(1 + d^ 2 a。

每个物体一个顶点光源。

四个顶点光源

要包括Unity支持的所有四个顶点光源,我们必须执行相同的顶点光照计算四次,并将结果加在一起。要代替我们自己编写所有代码的话,我们可以使用在UnityCG中定义的Shade4PointLights函数。我们必须给它传递位置矢量、光的颜色、衰减因子再加上顶点位置和法线。

Shade4PointLights看起来是什么样子?

它真的只是执行与我们相同的计算四次。而操作顺序略有不同。使用rsqrt在点积之后执行标准化。这个函数计算倒数平方根“1 / sqrt x”。

四个顶点光源的效果。

如果一个物体在结束的时候有比像素光源更多的光照需要计算的时候,会有多达四个光源将被包括进来作为像素光源。实际上,Unity尝试通过导入一个既可以作为像素光源又可以作为顶点光源的光源来隐藏像素光源和顶点光源之间的过渡。这个既可以作为像素光源又可以作为顶点光源的光源会被导入两次,其顶点光源的版本和像素光源的版本的强度不同。

当少于四个顶点光源的时候会发生什么?

你仍然要计算四个顶点光源。其中一些光源将只是黑色。所以你总是要付出计算四个光源的代价。

在顶点光源和像素光源之间进行切换。

默认情况下,Unity决定哪些光源成为像素光源。你可以通过更改光源的渲染模式来对此进行覆盖。重要的光源总是渲染为像素光源,无论限制。不重要的光源不会渲染成为像素光源。

光源的渲染模式。

球面谐波

当我们用完了所有的像素光源和所有的顶点光源以后,我们可以回到另一种方法来渲染光源的效果。我们可以使用球谐函数。这种方法支持所有三种类型的光源。

球面谐波背后的想法是,你可以用一个函数描述某点的所有入射光。这个函数定义在球体的表面上。

通常,该函数用球面坐标描述。但是你也可以使用三维坐标。这允许我们使用我们的物体的法线向量来采样函数。

要创建这样一个函数,你必须在所有方向上对光强度进行采样,然后弄清楚如何把它变成一个单一的连续函数。为了完美,你必须对每个物体的表面上的每一个点执行这样的操作。这当然是不可能的。我们将选择一个足够的近似。

首先,我们将只从对象的本地原点的角度定义函数。这对于沿着对象的表面不改变太多的光照条件来说是足够精细的。这对于小物体以及强度弱的或远离物体的光源是正确的。幸运的是,这通常是不符合像素光源或是顶点光源状态的光源的情况。

第二,我们还必须近似函数本身。你可以将任何连续函数分解为不同频率的多个函数。这些被称为波段。对于任意函数,你可能需要无限数量的波段来做到这一点。

一个简单的例子是组成正弦曲线。让我们从基本正弦波开始。

正弦波,`sin 2pix`。

这是第一个波段。对于第二个波段,使用具有双倍频率和一半幅度的正弦波。

双倍频率,一半的振幅,(sin 4pix)/ 2。

当加在一起的时候,这两个波段描述了更复杂的函数

两个波段,sin 2pix +(sin 4pix)/2。

你可以继续添加这样的波段,使频率加倍,同时每一步的幅度减半。

第三个和第四个波段。

你添加的每个波段都使得函数更加复杂。

四个正弦波带,

这个例子使用的是具有固定模式的常规正弦波。要用正弦波描述任意函数。你你必须调整每个波段的频率、幅度和偏移,直到获得完美匹配。

如果你使用比完美匹配所需的更少的波段,最终得到的是原始函数的近似值。使用的波段越少,近似值的准确度就越低。这种技术被用来压缩很多东西,比如声音和图像数据。在我们的例子中,我们将使用它来近似三维的光照。

具有最低频率的波段对应于该函数的最大特征。我们绝对要保留那些大的特征。因此,我们将丢弃具有更高频率的波段。这意味着我们丢失了我们的光照功能的细节。其实这没有什么问题,如果光照不快速变化的话,所以我们将不得不限制自己只对漫反射项的计算使用球谐光照。

球面高斯的波段

最简单的光照近似是使用均匀的颜色。光照在各个方向都是一样的。这是第一个波段,我们将识别为

。 它由单个子函数进行定义,它只是一个常量值。

第二个波段引入线性的方向光。对于每个轴,它描述的是大多数光来自哪里。因此,它被分成三个函数,用“Y_1 ^ -1”,“Y_1 ^ 0”和“Y_1 ^ 1”标识。 每个函数包括我们的法线坐标的一个分量,并乘以常数。

第三个波段变得更复杂。它包括五个函数,`Y_2 ^ -2` 。。。`Y_2 ^ 2`。这些函数是二次函数,这意味着它们包含我们两个法线坐标的乘积。

我们可以继续推下去,但Unity只使用前三个波段。 我们在这里把它们都整理在一个表中。 所有术语都应乘以

这真的是一个单一的函数,我们把它分开,所以你可以识别其子函数。最后的结果是把所有九个项加在一起。通过用附加因子调制九个项中的每一个来产生不同的光照条件。

什么决定了这个函数的形状?

球面谐波是拉普拉斯方程在球体的上下文中的解。这里面牵扯了大量的数学相。函数部分的定义是

。其中

项是勒让德多项式,并且

项是归一化后的常数。

这是复数形式的定义,使用复数'i'和球面坐标,

。 你也可以使用它的实数版本,它使用的是三维坐标。这就是我们现在使用的函数。

幸运的是,我们不需要知道如何导出这个函数。我们甚至不需要知道具体的数字。

因此,我们可以用九个因子来表示任何光照条件的近似。因为这些是RGB颜色,所以我们最终有27个数字。我们可以将函数的常数部分也合并到这些因子之中。这就导致了我们的最终函数,“a + by + cz + dx + exy + fyz + gz ^ 2 + hxz + i(x ^ 2 - y ^ 2)”,其中“a”到“i”是因子。

你可以可视化这些坐标,以了解这些项所表示的方向。举个简单的例子来说,这里有一种方法来将正坐标表示为白色,以及将负坐标表示为红色。

然后,你可以使用i.normal.x和i.normal.x * i.normal.y来可视化每个项。

1,y, z, x,xy, yz, zz, xz, xx - yy.

使用球面谐波

每个由球面谐波近似的光必须被分解成27个数字。幸运的是,Unity可以很快的做到这一点。基础渲染通道可以通过一组七个float4变量来访问它们,这些变量在UnityShaderVariables中进行定义

UnityCG包含ShadeSH9函数,该函数基于球面谐波数据和正常参数来计算光照。它期望的是一个float4参数,其第四个分量设置为1。

ShadeSH9是什么样子的?

这个函数使用两个子函数,一个子函数用于前两个波段,另一个子函数用于第三个波段。这是因为Unity的着色器可以在顶点程序和片段程序之间拆分计算。这是我们将来考虑的优化内容。

此外,是在线性空间中执行球谐函数计算。如果需要的话,ShadeSH9函数将结果转换为伽马空间中的结果。

为了看看最终的近似值,直接返回片段程序中ShadeSH9的结果。

现在关闭所有光源。

环境颜色。

惊喜!我们的物体不再是黑色。他们现在受到了环境色的影响。Unity使用球面谐波将场景的环境颜色添加到物体之上。

现在激活一组光源。确保有足够的光源数量让所有像素光源和顶点光源都被用完。其余的光源被添加到球面谐波之中。再次说明,Unity会分裂一个光源以融合过渡。

通过球面谐波实现的光源。

与顶点光源一样,我们将球面谐波光源的数据添加到漫反射间接光。此外,让我们确保它从来不贡献任何负的光能量。毕竟这是一个近似。

但是我们只能在基础渲染通道中这样做。由于球面谐波独立于顶点光源,我们不能依赖同一个关键词。相反,我们将检查是否定义了FORWARD_BASE_PASS。

这样的话会消除球面谐波,因为FORWARD_BASE_PASS没有定义。 如果像素光源的计数设置为零,则只有顶点光源是可见的。

只有四个顶点光源的效果。

在导入My Lighting.之前,在基础渲染通道中定义FORWARD_BASE_PASS。 现在我们的代码知道什么时候我们在基础渲染通道之中。

顶点光源和球面谐波。

我们的着色器最终包括了顶点光源和球面谐波。如果你确保像素光源的数量大于零,你会看到所有三种光照方法的组合效果。

带有额外的四个像素光源的效果。

天空盒

如果球面谐波包括了立体环境颜色,那么它还可以与环境天空盒一起工作么?是的! Unity将近似天空盒与球面谐波。要尝试这个操作,请关闭所有光源,然后选择默认天空盒来做环境光照。新的场景默认使用这个天空盒,但我们在之前的教程中删除了这个默认的天空盒。

默认的天空盒,没有方向光源的效果。

Unity现在在后台渲染天空盒。它是一个程序生成的天空盒,基于主要的方向光源。因为我们现在没有激活的光源,它的行为就像太阳坐在地平线上。你可以看到物体已经拾取了天空盒的一些颜色,这导致一些微妙的阴影。这一切都是通过球谐函数实现的。

打开主方向光源。这将改变天空盒很多。你可能会注意到球面谐波比天空盒的变化要稍微晚一点。这是因为Unity需要一些时间来近似天空盒。这只是发生突然改变时才会真正明显。

天空盒与主光源,在有球面谐波和没有球面谐波时候的效果对比。

物体突然变得很亮了!环境光的贡献非常强。程序化天空盒代表了一个完美的阳光灿烂的日子。在这些条件下,完全白色的表面将显得非常明亮。当在伽马空间中进行渲染的时候,这种效果最强烈。在现实生活中没有很多完美的白色表面,它们通常更暗。

带有纹理的场景,在有球面谐波和没有球面谐波时候的效果对比。

推荐阅读更多精彩内容

  • 光源的着色器 我们在《渲染13:延迟渲染》中添加了对延迟渲染路径的支持。我们所要做的只是填补G缓冲区。光源在后面的...
    LeiLv阅读 1,377评论 0 5
  • 平行光阴影 虽然我们的光照着色器现在能够产生相当真实的结果,但是它孤立地评估每个表面片段。它假定来自每个光源的光线...
    LeiLv阅读 4,234评论 0 9
  • 光照贴图 执行光照计算的开销是非常昂贵的。 延迟渲染允许我们使用很多光源,但阴影的开销仍然是一个限制因素。如果我们...
    LeiLv阅读 1,408评论 0 6
  • 将法线从物体空间转换到世界空间。 使用方向光。 计算漫反射和镜面高光反射。 实现能量守恒。 使用金属的工作流程。 ...
    LeiLv阅读 1,319评论 0 4
  • 另一个渲染路径 到目前为止,我们一直使用的是Unity的前向渲染路径。但这不是Unity支持的唯一渲染方法。还有延...
    LeiLv阅读 1,747评论 0 6