本文重点:
1、使用自定义的灯光着色器 2、解码LDR颜色 3、把灯光添加到独立的pass 4、支持方向光源、聚光灯、点光源 5、手动采样阴影贴图
(温馨提示:本系列知识是循序渐进的,推荐第一次阅读的同学从第一章看起)
这是关于渲染的系列教程的第15部分。在上一部分中,我们添加了雾。现在,我们将创建自己的延迟光照。
从现在开始,渲染教程使用Unity 5.6.0制作了。这个Unity版本在编辑器和着色器的一些设置都进行了某些更改,但是你仍然应该能够用自己的方式找到它们。
(我们自己的延迟光照玩法)
1 灯光着色器
我们在“第13章,延迟着色”中添加了对延迟渲染路径的支持。我们要做的只是填充G缓冲区,让灯光稍后渲染。而本教程简要说明了Unity如何添加这些灯光。这次,我们将自己渲染这些灯光。
为了测试灯光,我会使用一个简单的场景,将其环境强度设置为零。使用延迟的HDR摄像机渲染。
(测试场景,有和没有方向光)
场景中的所有对象都使用我们自己的着色器渲染到G缓冲区。但是灯光是使用Unity的默认延迟着色器渲染的,该着色器名为Hidden Internal-DefferedShader。你可以通过“EditProject Settings / Graphics”进入图形设置,然后将“Deferredshader”模式切换到“Custom shader”,以验证这一点。
(默认的延迟光照着色器)
1.1 使用自定义Shader
每个延迟的灯光都在单独的通道中渲染,从而影响图像的颜色。实际上,它们就是图像效果(Image Effect),例如上一教程中的延迟雾着色器。我们从一个简单的着色器开始,先用黑色覆盖所有内容。
指示Unity在渲染延迟光源时使用此着色器。
(使用自己的着色器)
1.2 第二个通道
切换到我们的着色器后,Unity报错说它没有足够的通道数量。显然,它需要第二个pass。我们只复制已经拥有的pass,看看会发生什么。
现在,Unity接受我们的着色器,并使用它来渲染定向光。结果,一切都变黑了。唯一的例外是天空。把模板缓冲区用作遮罩以避免在此处进行渲染,因为定向光不会影响背景。
(自定义着色器 受光和不受光)
但是为什么要使用第二个pass呢?请记住,禁用HDR后,灯光数据将会进行对数编码。最后的pass需要转换此编码。那就是第二个pass的目的。因此,如果你为相机禁用了HDR,那么我们着色器的第二个pass也要被用一次。
1.3 避开天空
在LDR模式下渲染时,你可能还会看到天空也变黑了。这可以在场景视图或游戏视图中发生。如果天空变黑,则转换过程将无法正确使用模板缓冲区作为遮罩。要解决此问题,请显式配置第二个Pass的模板设置。仅在处理不属于背景的片段时才应该渲染。通过_StencilNonBackground提供适当的模板值。
我们可以调试模板缓冲区吗?
不行,帧调试器没有显示有关模板缓冲区的任何信息,也没有显示其内容以及通过的方式。也许它将在将来的版本中添加。
1.4 转换颜色
为了使第二个pass工作正常,必须转换灯光缓冲区中的数据。像我们的雾着色器一样,使用UV坐标绘制全屏四边形,可用于对缓冲区进行采样。
可以通过_LightBuffer变量将灯光缓冲区本身提供给着色器。
(不受光的原始LDR数据)
使用公式
对LDR颜色进行对数编码。要对此进行解码,我们必须使用公式
。
(解码不受光的LDR图像)
现在我们知道它可以工作了,那就再次启用HDR吧。
2 方向光
第一个pass负责渲染灯光,因此它会相当复杂。让我们为其创建一个包含文件,名为MyDeferredShading.cginc。将所有代码从pass中复制到此文件。
然后在第一个pass中包括MyDeferredShading。
因为我们需要为图像添加光照信息,所以必须确保不擦除已经渲染的图像。可以通过更改混合模式以将全部源色和目标色组合在一起来实现。
我们需要所有可能的灯光配置的着色器变体。multi_compile_lightpass编译器指令创建我们需要的所有关键字组合。唯一的例外是HDR模式。为此,我们必须添加一个单独的多编译指令。
尽管此着色器应该用于所有三种光源类型,但首先将它限定于定向光源。
2.1 G-Buffer UV 坐标
我们需要UV坐标才能从G缓冲区采样。不幸的是,Unity不提供具有方便的纹理坐标的灯光pass。相反,必须从剪辑空间位置间接获取它们。可以使用在UnityCG中定义的ComputeScreenPos,该函数产生齐次坐标,就像剪辑空间坐标一样,因此需要使用float4来存储它们。
在片段程序中,我们可以计算最终的2D坐标。如渲染第七章,阴影中所述,这必须在插值之后发生。
2.2 世界坐标
创建延迟的雾效果时,我们必须找出片段与相机的距离。这个实现过程是通过从相机发射穿过每个片段到远平面的射线,然后按片段的深度值缩放这些光线。我们可以在此处使用相同的方法来重建片段的世界位置。
在定向光的情况下,将四边形的四个顶点的光线作为法线矢量提供。因此,我们可以将它们传递给顶点程序并进行插值。
可以通过采样_CameraDepthTexture纹理并将其线性化来在片段程序中找到深度值,就像我们对雾化效果所做的那样。
但是,最大的不同是我们将到达远平面的光线提供给了雾的着色器。这时,我们会获得到达近平面的射线。需要按比例缩放它们,以便获得到达远平面的射线。通过缩放射线使其Z坐标变为1并将其乘以远平面距离来完成。
按深度值缩放此射线可得到一个位置。因为所提供的光线在视图空间中定义的,所以得到的空间也是相机的局部空间。因此,我们现在也以片段在视图空间中的位置作为终点。
从相机空间到世界空间的转换是通过在ShaderVariables中定义的unity_CameraToWorld矩阵完成的。
2.3 读取 G-Buffer数据
接下来,我们需要访问G缓冲区以检索表面属性。通过三个_CameraGBufferTexture变量可以使用这些缓冲区。
我们在“渲染13,延迟着色器”教程中填充了相同的缓冲区。现在我们开始向他们读取。需要反照率,镜面反射色,平滑度和法线。
2.4 计算BRDF
BRDF函数在UnityPBSLighting中定义,因此我们必须包含该文件。
现在只需要三位数据就可以在片段程序中调用BRDF函数。首先是视图方向,与往常一样找到。
其次是表面反射率。我们从镜面色彩中得出。它只是最强的颜色成分。我们可以使用SpecularStrength函数提取它。
第三,我们需要灯光数据。让我们从虚拟灯开始。
最后,我们可以使用BRDF函数计算该片段的光贡献。
2.5 配置灯光
间接光不适用于该功能,因此保持黑色。另外需要配置直接光,使其与当前正在渲染的光匹配。对于定向光,我们需要一种颜色和一个方向。这些可以通过_LightColor和_LightDir变量使用。
创建一个单独的功能来设置灯光。只需将变量复制到一个轻型结构中并返回它。
在片段程序中使用此功能。
(光来自错误的方向)
终于有光照了,但它似乎来自错误的方向。这是因为_LightDir设置的是灯光传播的方向。为了进行计算,我们需要从表面到光线的方向,取反它。
(方向光 没有阴影)
2.6 阴影
在“My Lighting”中,我们依靠AutoLight中的宏来确定由阴影引起的光衰减。遗憾的是,该文件在编写时并没有考虑到延迟光照的情况。因此,我们需要自己进行阴影采样。通过_ShadowMapTexture变量可以访问阴影贴图。
但是,不能随意声明此变量。我们已经间接地在UnityShadowLibrary中为点和聚光灯阴影定义了它。因此,不应该再自己定义它,除非是使用定向光的阴影。
要应用定向阴影,只需要采样阴影纹理并使用它来减弱光色即可。在CreateLight中执行此操作意味着必须将UV坐标添加为参数。
在片段程序中将UV坐标传递给它。
(方向光带阴影)
当然,这仅在定向光启用了阴影时才有效。如果不是,则阴影衰减始终为1。
2.7 淡入阴影
阴影贴图是有限的。它无法覆盖整个世界。它覆盖的面积越大,阴影的分辨率越低。Unity具有绘制阴影的最大距离。超出之后,就没有实时阴影了。可以通过“Edit/ Project Settings / Quality”来调整此距离。
(阴影距离设置)
当阴影接近此距离时,它们会淡出。至少,Unity的着色器是这么做的。因为我们是手动采样阴影贴图,所以到达贴图的边缘时,阴影会被截断。结果是阴影被锐利地截断,或者超出了淡入淡出的距离。
(阴影距离,大VS小)
要淡化阴影,必须先知道应完全消除阴影的距离。该距离取决于方向阴影的投影方式。在“Stable Fit”模式下,衰落是球形的,居中于地图中间。在“Close Fit”模式下,它基于视图深度。
UnityComputeShadowFadeDistance函数可以为我们找出正确的指标。它以世界位置和视图深度为参数。返回距阴影中心的距离或未修改的视图深度。
阴影在接近淡入距离时应开始淡入,一旦到达阴影就完全消失。UnityComputeShadowFade函数计算适当的淡入淡出因子。
这些函数是什么样的?
它们在UnityShadowLibrary中定义。unity_ShadowFadeCenterAndType变量包含阴影中心和阴影类型。_LightShadowData变量的Z和W分量包含用于淡入的比例和偏移。
阴影淡入因子是从0到1的值,它指示阴影应淡出多少。可以通过简单地将此值添加到阴影衰减并将其钳位为0–1来完成实际的衰落。
然后,请在片段程序中为CreateLight提供世界位置和视图深度。视图深度是片段在视图空间中位置的Z分量。
(淡出阴影)
2.8 光Cookies
我们必须支持的另一件事是轻型Cookie。可通过_LightTexture0使用cookie纹理。除此之外,还必须从世界空间转换为灯光空间,以便可以对纹理进行采样。可以通过unity_WorldToLight矩阵变量来进行此转换。
在CreateLight中,使用矩阵将世界位置转换为灯光空间坐标。然后使用它们来采样cookie纹理。我们使用一个单独的衰减变量来跟踪cookie的衰减。
(方向光 带cookie)
除非你特别的去关注几何图形边缘,不然结果看起来还不错。
(边缘失真)
当相邻片段的cookie坐标之间存在较大差异时,会出现这些失真。应对这样的情况,GPU选择的mipmap级别对于最近的表面而言太低。ArasPranckevičius为Unity解决了这一点(http://aras-p.info/blog/2010/01/07/screenspace-vs-mip-mapping/)。
Unity使用的解决方案是在对Mip贴图进行采样时施加偏差,因此我们也将这样做。
(偏移后的Cookie采样)
2.9 支持LDR
到目前为止,我们只能在HDR模式下正确渲染定向光。对于LDR,这是错误的。
(不正确的LDR颜色)
首先,必须将编码的LDR颜色乘以光缓冲区,而不是相加。我们可以通过将着色器的混合模式更改为Blend DstColor Zero来实现。但是,如果这样做,HDR渲染将出错。相反,我们必须使混合模式变量。Unity为此使用_SrcBlend和_DstBlend。
(和前面的不一样,但是仍然是错误的)
当未定义UNITY_HDR_ON时,我们还必须在片段程序的末尾应用
转换。
3 聚光灯
由于定向光会影响所有内容,因此它们将被绘制为全屏四边形。相反,聚光灯仅影响场景中位于其圆锥体内的部分。通常无需为整个图像计算聚光灯照明,取而代之的是绘制一个与聚光灯的影响区域匹配的金字塔。
3.1 绘制金字塔
禁用定向光,改用聚光灯。因为我们的着色器仅对定向光源正常工作,所以结果将会是错误的。但是它允许你查看金字塔的哪些部分被渲染了。
(金字塔的一部分)
事实证明,金字塔被渲染为常规3D对象。它的背面被剔除,因此我们看到了金字塔的正面。而且只有当前面没有东西时才绘制它。除此之外,还添加了一个通道,该通道设置了模板缓冲区,以将图形限制为位于金字塔体内部的片段。你可以通过帧调试器验证这些设置。
(绘制流程)
这意味着我们的着色器的剔除和z测试设置被否决了。因此,将其从着色器中删除。
当聚光灯的体积距离相机足够远时,此方法适用。但是,当光线离摄像机太近时,它会失败。发生这种情况时,相机可能会进入该体积内。甚至有可能一部分近平面位于其内部,而其余部分位于其外部。在这些情况下,模板缓冲区就不能再用于限制渲染。
仍然渲染光线的技巧是绘制金字塔的内表面,而不是金字塔的外表面。这是通过渲染其背面而不是其正面来完成的。同样,仅当这些表面最终位于已经渲染的表面之后时才渲染它们。这种方法还涵盖了聚光灯体积内的所有片段。但这最终会渲染出太多的片段,因为通常金字塔的隐藏部分现在也被渲染了。所以,仅在必要时执行。
(靠近相机时绘制背面)
如果将摄像机或聚光灯移动到彼此附近,则会看到Unity根据需要在这两种渲染方法之间切换。一旦我们的着色器对聚光灯正常工作,两种方法之间就不会有视觉差异。
3.2 支持多灯光类型
当前,CreateLight仅适用于定向光源。让我们确保仅在适当的情况下使用特定于定向灯的代码。
尽管阴影衰减基于定向阴影贴图起作用,但其他光源类型的阴影也会衰减。这样可以确保所有阴影以相同的方式淡入淡出,而不仅仅是某些阴影。因此,只要有阴影,阴影淡入淡出代码便适用于所有灯光。所以,将该代码移到特定于光源的块之外。
不定向的灯光具有位置。通过_LightPos可以使用它。
现在我们可以确定聚光灯的光向量和光方向。
3.3 再次涉及世界位置
光线方向似乎不正确,结果为黑色。发生这种情况是因为聚光灯的世界位置计算不正确。当我们在场景中的某个地方渲染金字塔时,没有一个方便的全屏四边形,其光线存储在正常通道中。相反,MyVertexProgram必须从顶点位置获取射线。这是通过将点转换为视图空间来完成的,为此,我们可以使用UnityObjectToViewPos函数。
但是,这会产生方向错误的光线。我们必须取反它们的X和Y坐标。
(正确的世界坐标)
UnityObjectToViewPos如何工作?
该功能在UnityCG中定义。它首先将点转换为世界空间,然后使用视图矩阵将其转换为相机空间。
当在场景中渲染灯光几何时,此替代方法有效。当使用全屏四边形时,我们应该只使用顶点法线。Unity通过_LightAsQuad变量告诉我们正在处理哪种情况。
如果将其设置为1,将处理四边形,并且可以使用法线。否则,我们必须使用UnityObjectToViewPos。
3.4 Cookie衰减
聚光灯的圆锥衰减是通过cookie纹理创建的,无论它是默认圆还是自定义cookie。我们可以从复制定向灯的cookie代码开始。
但是,聚光灯Cookie越远离你的灯光位置,它就会变得越大。这是通过透视变换完成的。因此,矩阵乘法会产生4D齐次坐标。为了得到规则的2D坐标,我们必须将X和Y除以W。
(Cookie衰减)
这实际上导致了两个光锥,一个向前,一个向后。向后的圆锥体通常会终止于渲染区域的外部,但这不是必然的。因此,需要与一个负W坐标相对应的正向圆锥。
3.5 距离衰减
聚光灯发出的光也会根据距离而衰减。该衰减存储在查询纹理中,该纹理可通过_LightTextureB0使用。
设计纹理时,必须使用四边形的光线距离(根据光线的范围进行缩放)对它进行采样。该范围存储在_LightPos的第四个通道中。每个平台应使用哪个纹理通道由UNITY_ATTEN_CHANNEL宏定义。
(cookie和距离衰减)
3.6 阴影
当聚光灯具有阴影时,定义SHADOWS_DEPTH关键字。
聚光灯和定向光使用相同的变量来采样其阴影贴图。对于聚光灯,可以使用UnitySampleShadowmap来处理对硬阴影或软阴影进行采样的细节。需要为其提供阴影空间中的片段位置。unity_WorldToShadow数组中的第一个矩阵可用于将世界转换为阴影空间。
(聚光灯 带阴影)
4 点光源
点光源与聚光灯使用相同的光矢量,方向和距离衰减。这样他们就可以共享该代码。其余的Spotlight代码仅应在定义SPOT关键字时使用。
这已经足以使点光源工作。它们的渲染与聚光灯相同,不同之处在于,它们使用icosphere而不是金字塔。
(高强度的点光源)
4.1 阴影
点光源的阴影存储在立方体贴图中。UnitySampleShadowmap为我们处理采样。在这种情况下,我们必须为其提供从光到表面的向量,以对立方体贴图进行采样。这与光向量相反。
(点光源 带有阴影)
4.2 Cookies
还可以通过_LightTexture0提供点光源cookie。但是,在这种情况下,我们需要一个立方体贴图而不是常规纹理。
要对Cookie进行采样,请将片段的世界位置转换为浅色空间,然后使用该采样对立方体贴图进行采样。
(点光源带有cookie)
点光源cookie纹理不起作用?
如果你最初使用较旧的Unity版本导入了cookie的立方体贴图纹理,则可能具有错误的导入设置。这仅在立方体贴图中发生。确保其“Texture Type”为“ Cookie”,“Mapping”设置为“Auto”,“Light Type”为“Point”。
(Point cookie 纹理导入设置)
4.3 跳过阴影
现在,我们可以使用自己的着色器渲染所有动态光源。尽管我们目前并未对优化进行过多关注,但仍有一项潜在的大型优化值得考虑。
最终超出阴影淡入距离的片段不会被阴影化。但是,我们仍在采样它们的阴影,这可能会很耗时。可以通过基于阴影淡入因子进行分支来避免这种情况。它接近1,那么我们可以完全跳过阴影衰减。
但是,分支操作本身也可能很昂贵。这只是一个改进,因为这是一个连贯的分支。除了靠近阴影区域的边缘,所有片段都落在阴影区域的内部或外部。但这仅在GPU可以利用此优势的情况下才重要。在这种情况下,HLSLSupport定义UNITY_FAST_COHERENT_DYNAMIC_BRANCHING宏。
即使这样,仅当阴影需要多个纹理样本时才真正值得。对于柔和的聚光灯和点光源阴影,就是这种情况,用SHADOWS_SOFT关键字指示。定向阴影始终需要单个纹理样本,因此很便宜。
下一章,介绍静态光照。