本文重点:
给物体应用雾 基于距离和深度的基础雾 创建图像效果(Image Effect) 支持延迟雾
这是渲染教程系列的第14篇文章。上一章我们介绍了延迟着色,这次我们把雾效果添加到场景中。
本教程 使用Unity5.5.0f3。
(随着距离增加,物体逐渐消退)
到目前为止,我们一直将光线视为通过真空传播。当场景设置在宇宙中时,这可能是准确的,否则,光就必须穿过大气层或液体。这时,光线不仅会撞击固体表面,而且会在空间中的任何地方被吸收,散射和反射。
准确绘制大气干扰需要昂贵的体积算法,而这通常是我们无法承受的。取而代之的是,用几个恒定的雾参数来进行近似。之所以称为雾,是因为该效果通常用于有雾的气氛。清晰的气氛所引起的视觉失真通常非常小,以至于在较短距离内可以忽略不计。
Unity的“Lighting”窗口包含具有场景雾设置的部分。默认情况下是禁用的。激活后,你将获得默认的灰色雾。但是,这仅适用于使用正向渲染路径渲染的对象。当延迟模式处于活动状态时,雾的状态在下面的白字部分有说明。
(开启默认雾)
稍后我们将处理延迟模式。现在,我们先集中讨论前向雾。为此,我们需要使用前向渲染模式。你可以更改全局渲染模式,或强制主相机使用所需的渲染模式。将相机的Rendering Path 设置为“Forward”。现在先暂时禁用HDR渲染。
(前向摄像机)
创建一个小的测试场景,例如在平面或立方体上的几个球体。使用Unity的默认白色材质。
(不明显的雾)
将环境照明设置为默认强度1时,你会看到一些非常明亮的对象,并且根本没有很明显的雾。
为了使雾更加明显,请将其颜色设置为纯黑色。它将代表一种吸收光而没有太多散射的气氛,例如浓浓的黑烟。
将Fog Mode设置为Linear。这样的效果并不真实,但易于配置。你可以设置雾影响开始的距离和完全变为雾的距离。他们之间会线性增加。这是以视距进行测量的。在雾开始之前,能见度是正常的。超过该距离,雾将逐渐遮挡物体。最终,除了雾的颜色,什么都看不到。
(线性雾)
线性雾化因子通过函数
进行计算,其中c 是雾化坐标,S和E 分别是起点和终点。然后将此因子钳制在0–1范围内,并用于在雾和对象的阴影颜色之间进行插值。
为什么雾不影响天空盒?
雾效果可调整正向渲染对象的片段颜色。因此,它仅影响这些对象,而不影响天空盒。
Unity支持的第二种雾模式是指数模式,这是雾的更逼真的近似。它使用函数
,其中d是雾的密度因子。与线性版本不同,该方程永远不会达到零。将强度提高到0.1,使雾气看起来更靠近相机。
(指数雾)
最后一种模式是指数平方雾。它就像指数雾一样工作,但是使用函数
,在近距离范围内雾量较小,但增加较快。
(指数平方雾)
现在我们知道了雾的表现了,那我们将对它的支持添加到自己的正向着色器中。为了让效果更容易比对,将一半的对象设置为使用我们的材质,而其余的则继续使用默认材质。
(左边是我们的材质,右边是标准材质)
雾模式由着色器关键字控制,因此我们必须添加多编译指令以支持它们。可以为此使用一个预定义的multi_compile_fog指令。这将为FOG_LINEAR,FOG_EXP和FOG_EXP2关键字带来额外的着色器变体。仅将此指令添加到两个前向pass中。
接下来,向“My Lighting”添加一个函数以将雾应用于片段颜色。它以当前颜色和插值器为参数,并应在应用雾的情况下返回最终颜色。
雾效果基于视距,该视距等于摄影机位置和片段的世界位置之间的矢量长度。我们可以访问两个位置,因此可以计算该距离。
然后,将其用作雾密度函数的雾坐标,该雾密度函数由UNITY_CALC_FOG_FACTOR_RAW宏计算得出。这个宏创建unityFogFactor变量,可以使用它在雾色和片段颜色之间进行插值。雾的颜色存储在unity_FogColor中,该颜色在ShaderVariables中定义。
UNITY_CALC_FOG_FACTOR_RAW如何工作?
宏在UnityCG中定义。定义哪个fog关键字确定要计算的内容。
还有一个UNITY_CALC_FOG_FACTOR宏,它使用此宏。它假定雾坐标是需要转换的特定类型,因此我们直接使用原始版本。
unity_FogParams变量在UnityShaderVariables中定义,并包含一些有用的预先计算的值。
由于雾度因子最终可能超出0–1范围,因此我们必须在插值之前对其进行钳位。
另外,由于雾不影响alpha分量,因此我们可以将其排除在插值之外。
现在,我们可以将雾应用于MyFragmentProgram中的最终的forward-pass颜色。
(线性雾 但是有区别)
我们自己的着色器现在包含雾了。但是,它与标准着色器计算的雾度不完全匹配。为了使差异更加清楚,请使用具有相同或几乎相同值的起点和终点的线性雾。它会导致突然从无雾过渡到全雾。
(曲线与直线过渡)
我们和标准着色器之间的差异是由于我们计算雾化坐标的方式所致。尽管使用世界空间视距是有意义的,但标准着色器使用了不同的度量标准。具体来说,它使用剪辑空间深度值。结果,视角不会影响雾坐标。同样,在某些情况下,距离会受到相机的接近剪辑平面距离的影响,这会将雾稍微推开。
(平面深度与距离)
使用深度而不是距离的优点是你不必计算平方根,因此速度更快。同样,虽然不太现实,但在某些情况下(例如,横向滚动游戏)可能需要基于深度的雾。不利之处在于,由于忽略了视角,因此相机的方向会影响雾。随着旋转,雾密度发生变化,而从逻辑上讲它不应发生改变。
(旋转会改变深度)
让我们向着色器添加对基于深度的雾的支持,以匹配Unity的方法。这需要对我们的代码进行一些更改。现在,我们必须将剪辑空间深度值传递给片段程序。因此,当其中一种雾化模式处于活动状态时,请定义FOG_DEPTH关键字。
我们必须包括一个用于深度值的插值器。但是,除了为其提供单独的插值器外,我们还可以将其作为第四部分搭载在世界坐标上。
为确保我们的代码正确无误,请将i.worldPos的所有用法替换为i.worldPos.xyz。之后,在需要时将片段空间深度值分配给片段程序中的i.worldPos.w。它只是同质剪辑空间位置的Z坐标,因此在将其转换为0–1范围内的值之前。
在ApplyFog中,使用插值深度值覆盖计算的视图距离。保留旧的计算,因为稍后我们将继续使用它。
(基于剪辑空间深度的雾)
现在,你很可能会获得与标准着色器相同的结果。但是,在某些情况下,剪辑空间的配置不同,从而产生了不正确的雾。为了弥补这一点,请使用UNITY_Z_0_FAR_FROM_CLIPSPACE宏转换深度值。
UNITY_Z_0_FAR_FROM_CLIPSPACE是做什么的?
最重要的是,它补偿了可能反转的剪辑空间Z尺寸。
请注意,宏代码提到OpenGL也需要转换,但我觉得这样做不值得。
UNITY_CALC_FOG_FACTOR宏仅将上述内容提供给其原始等效内容。
那么,我们应该对雾使用哪个度量呢?剪辑空间深度还是世界空间距离?那就都支持吧!但是,让它成为着色器功能并不划算。我们将使其成为着色器配置选项,例如BINORMAL_PER_FRAGMENT。假设基于深度的雾是默认设置,你可以通过在着色器顶部附近的CGINCLUDE部分中定义FOG_DISTANCE切换到基于距离的雾。
如果已经定义了FOG_DISTANCE,那么在My Lighting中要切换到基于距离的雾,要做的就是摆脱FOG_DEPTH定义。
当然,我们并不总是要使用雾。因此,仅在雾代码真正打开时才包括它。
我们的雾在单个灯光下可以正常工作,但是当场景中有多个灯光时,它的表现如何?当我们使用黑雾时,它看起来不错,但也可以尝试使用其他颜色。
(灰色雾 在1个和2个方向光下的表现)
结果太亮了。发生这种情况是因为我们为每个灯光都添加了一次雾色。当雾色为黑色时,这不是问题。因此解决方案是在附加通道中始终使用黑色。这样,雾就使附加光的作用减弱,而又不会使雾本身变亮。
(两个灯光下正确的灰色雾)
现在,我们在正向渲染路径上使用了雾,让我们切换到延迟路径。复制前向模式相机。将重复副本更改为延迟相机,然后禁用前向相机。这样,你可以通过更改启用的相机来快速在渲染模式之间切换。
你会注意到,使用延迟渲染路径时根本没有雾。这是因为在计算完所有光照之后必须应用雾。因此,我们无法在着色器的deferred pass中添加雾。
要比较同一图像中的延迟渲染和正向渲染,可以强制某些对象以正向模式渲染。例如,通过使用透明材质,同时使其完全不透明。
(不透明和透明材质)
当然,使用透明材质的物体会受到雾的影响。
为什么少了两个球?
右侧的对象使用透明的材质,即使它们是完全不透明的。结果,Unity在渲染它们时从后到前排序。最远的两个球体最终在它们下面的立方体之前渲染。由于透明对象不写入深度缓冲区,因此在这些球体前面绘制了立方体。
要将雾添加到延迟渲染中,我们必须等到所有灯光都渲染完毕后,再进行一次pass以将雾因素叠加。由于雾应用于整个场景,所以,可以像渲染定向光一样。
添加此类pass的一种简单方法是将自定义组件添加到相机。因此,创建一个DeferredFogEffect类从MonoBehaviour继承。因为在编辑模式下能够看到雾非常有用,所以请为其指定ExecuteInEditMode属性。将此组件添加到我们的延迟相机中。最终会让雾效果出现在游戏视图中。
(使用雾效果的延迟摄像机)
要向渲染过程添加其他full-screen pass,请为我们的组件提供一个OnRenderImage方法。Unity将检查相机是否具有使用此方法的组件,并在渲染场景后调用它们。这让你可以更改效果或将效果应用于渲染的图像。如果有多个这样的组件,则会按照它们连接到相机的顺序来调用它们。
OnRenderImage方法具有两个RenderTexture参数。第一个是源纹理,它包含了到目前为止的场景最终颜色。第二个参数是我们必须渲染到的目标纹理。它可能为null,这意味着它将直接进入帧缓冲区。
添加此方法后,游戏视图将无法渲染。我们必须确保要绘制一些东西。为此,请使用两个纹理作为参数调用Graphics.Blit方法。该方法将绘制一个带有着色器的全屏四边形,该着色器仅读取源纹理并输出未经修改的采样颜色。
场景再次像往常一样被渲染。但是,如果你检查帧调试器,则会看到为我们的图像效果添加了一个pass。
(绘制 image effect)
简单地复制图像数据是没有用的。我们必须创建一个新的自定义着色器,以将雾化效果应用于图像。从一个简单的着色器开始。因为我们只绘制一个应该覆盖所有内容的全屏四边形,所以应该忽略剔除和深度缓冲区,也不应该写入深度缓冲区。
我们的效果组件需要此着色器,因此为其添加一个公共字段,然后为其分配新的着色器。
(使用雾着色器)
我们还需要使用着色器进行渲染的材质。但仅在激活时才需要它,因此不需要资产。使用非序列化字段来保存对其的引用。
在OnRenderImage中,我们现在开始检查是否有材质实例。如果没有,请创建一个,并使用雾着色器。然后调用此材质的Graphics.Blit。
这会产生纯白色图像。必须创建自己的着色器通道以渲染有用的东西。从简单的顶点和片段程序开始,这些程序使用顶点位置和全屏四边形的UV数据从源纹理复制RGB颜色。另外,让我们包括雾模式的多重编译指令。
因为我们使用的是延迟渲染,所以我们知道有可用的深度缓冲区。毕竟,light pass需要它来工作。我们可以从中读取信息,这意味着我们可以使用它来计算基于深度的雾。
Unity通过_CameraDepthTexture变量使深度缓冲区可用,因此将其添加到我们的着色器中。
尽管确切的语法取决于目标平台,但我们可以对此纹理进行采样。HLSLSupport中定义的SAMPLE_DEPTH_TEXTURE宏为我们解决了这一问题。
这提供了来自深度缓冲区的原始数据,因此在从齐次坐标转换为0-1范围内的剪辑空间值之后。我们必须转换此值,使其成为世界空间中的线性深度值。首先,我们可以使用UnityCG中定义的Linear01Depth函数将其转换为线性范围。
Linear01Depth是什么样的?
它使用两个方便的预定义值执行简单的转换。
缓冲区参数在UnityShaderVariables中定义。
接下来,我们必须按远裁剪平面的距离缩放此值,以获得实际的基于深度的视图距离。剪辑空间设置可通过float4 _ProjectionParams变量获得,该变量在UnityShaderVariables中定义。它的Z分量包含远端平面的距离。
一旦我们有了距离,就可以计算雾化因子并进行插值。
(错误的雾)
不幸的是,我们的迷雾还是不正确。最明显的错误是我们在透明几何图形的顶部绘制了雾。为防止这种情况发生,我们必须在绘制透明对象之前应用雾化效果。可以将ImageEffectOpaque属性附加到我们的方法中,以指示Unity这样做。
(吴在不透明之后,透明之前)
另一个问题是雾色显然是错误的。当不使用HDR相机时,会发生这种情况,因为相机会弄乱颜色。这很简单,可以在我们的延迟摄像机上启用HDR。
(使用HDR相机)
最后,由于我们没有考虑近平面,因此可能再次在深度上有所不同。
(不同深度)
可以通过从视图距离中减去近平面距离来对此进行稍微补偿。它存储在_ProjectionParams的Y组件中。不幸的是,由于我们转换深度值的顺序,它不会完全匹配。但Unity的雾效果也会使用它来调整雾,所以我们也这样做。
(部分补偿深度)
延迟光的着色器从深度缓冲区重建世界空间位置,以便计算光照。我们也可以这样做。
透视相机的剪辑空间定义了一个梯形空间区域。如果我们忽略了近平面,那么将得到一个金字塔,其顶部位于相机的世界位置。它的高度等于相机的远平面距离。线性化的深度在其顶端为0,在其底端为1。
(金字塔的侧视角)
对于图像的每个像素,我们可以从顶部到金字塔底部的某个点发出光线。如果没有任何障碍物,则光线到达底部,即远平面。否则,它将击中渲染的任何对象。
(每个像素一条射线)
如果碰到某物,则相应像素的深度值小于1。例如,如果碰到一半,则该深度值将为1/2。这意味着,射线的Z坐标是未被遮挡时的尺寸的一半。由于射线的方向仍然相同,这意味着X和Y坐标也减半。通常,我们可以从一直延伸到远平面的光线开始,然后按深度值进行缩放来找到实际光线。
(射线缩放)
一旦有了该光线,就可以将其添加到摄影机的位置以找到渲染表面的世界空间位置。但是,由于我们只对距离感兴趣,所以我们真正需要的只是该射线的长度。
为了使它有效,必须知道每个像素从相机到平面的光线。实际上,我们只需要四束光线,金字塔的每个角一个。插值为我们提供介于两者之间所有像素的光线。
可以根据相机的远平面及其视场来构造光线。相机的方向和位置与距离无关紧要,因此我们可以忽略其变换。Camera.CalculateFrustumCorners方法可以为我们做到这些。它有四个参数。第一个是要使用的矩形区域,在我们的例子中是整个图像。第二个是投射光线的距离,必须与远平面相匹配。第三个参数涉及立体渲染。我们将只使用当前活动的眼睛。最终,该方法需要3D向量数组来存储射线。因此,我们必须缓存对摄像机的引用和向量数组。
接下来,必须将此数据传递给着色器。我们可以使用向量数组来实现。但是,不能直接使用frustumCorners。第一个原因是我们只能将4D向量传递给着色器。因此,还包括一个Vector4 []字段,并将其作为_FrustumCorners传递给着色器。
第二个问题是必须更改拐角的顺序。CalculateFrustumCorners将它们排序为左下,左上,右上,右下。但是,用于渲染图像效果的四边形的角顶点按左下,右下,左上,右上的顺序排列。因此,我们对它们进行重新排序以匹配四边形的顶点。
要访问着色器中的光线,请添加一个float数组变量。实际上,我们不需要为此添加属性,因为无论如何我们都不会手动对其进行编辑。尽管我们只能将4D向量传递给着色器,但在内部,我们仅需要前三个分量。所以float3类型就足够了。
接下来,定义FOG_DISTANCE,以表明我们希望将雾化基于实际距离,就像在其他着色器中一样。
当需要距离时,我们必须对光线进行插值并将其发送到片段程序。
在顶点程序中,我们可以简单地使用UV坐标来访问角点数组。坐标为(0,0),(1、0),(0,1)和(1,1)。所以索引是u + 2v。
最后,我们可以在片段程序中将基于深度的距离替换为实际距离。
(基于距离的雾)
除了深度缓冲区的精度限制外,前向和延迟方法都会产生相同的基于距离的雾。
实际上,前向雾和延迟雾之间仍然存在显着差异。你可能已经注意到,延迟的雾也会影响天空盒。它的作用就像是一个远方平面是一个固体屏障,受雾影响。
(雾化天空盒)
我们知道,当深度值接近1时,我们已经到达了远平面。如果不想对天空盒进行雾化,可以通过将雾化因子设置为1来防止这种情况。
(天空盒没有雾化)
如果确实要对整个图像应用雾化效果,则可以通过宏定义对其进行控制。定义FOG_SKYBOX后,请向天空盒添加雾,否则不要添加雾。
最后,我们必须考虑停用雾的情况。
(没有雾,但不正确)
当未定义任何雾气关键字时,可以通过将雾系数强制为1来完成此操作。这使我们的着色器只是进行纹理复制操作,而实际上,如果不需要它,最好停用或删除雾化组件。
下一章,介绍延迟光照。