Unity Shader入门精要 第十三章

  1. 1. 使用深度和法线纹理
  2. 2. 13.1 获取深度和法线纹理
    1. 2.0.1. 13.1.1 背后的原理
    2. 2.0.2. 13.1.2 如何获取
    3. 2.0.3. 13.1.3 查看深度和法线纹理
  • 3. 13.2 再谈运动模糊
  • 4. 13.3 全局雾效
    1. 4.0.1. 13.3.0
    2. 4.0.2. 13.3.1 全局雾效
    3. 4.0.3. 13.3.2 雾的计算
  • 5. 13.4 再探边缘检测

  • 使用深度和法线纹理


    13.1 获取深度和法线纹理

    13.1.1 背后的原理

    • 深度纹理实际上是一种渲染的纹理,只不过它里面存储的像素值不是颜色值,而是一个高精度的深度值,由于被存储在一张纹理上,深度纹理的深度范围就都是[0,1],而且是非线性分布的
    • 这些深度值来自于顶点变换后得到的归一化的设备坐标(Normalized Device Coordinates, NDC)
    • 一个模型要想最终被绘制在屏幕上,需要把它的顶点从模型空间变换到齐次裁剪坐标系下,这是通过在顶点着色器中乘以MVP变换矩阵得到的
    • 在变换的最后一步,我们需要使用一个投影矩阵来变换顶点,当我们使用的是透视投影类型的摄像机时,这个投影矩阵就是非线性的
    • 在得到了归一化的设备坐标也就是NDC之后,深度纹理中的像素值就可以很方便的计算得到,这些深度值就对应NDC中顶点坐标的z分量的值,由于NDC中z分量的范围在[-1,1],为了让这些值能够存储在一张图像中,就需要使用 d = 0.5z + 0.5 这样一个公式对其做一个映射,d对应了深度纹理中的像素值,z对应了NDC坐标的z分量值

    13.1.2 如何获取

    • 在Unity中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的Pass渲染得到,这取决于我们使用的渲染路径和硬件
    • 当使用延迟渲染路径,深度纹理也可以很方便的获取,因为延迟渲染会把这些信息渲染到GBuffer中,而当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的Pass渲染得到的
    • 具体而言,Unity会使用着色器替换技术,选择那些渲染类型为Opaque的物体,判断它们的渲染队列是否小于等于2500,如果满足条件就把它渲染到深度和法线纹理中
    • 因此,需要让物体出现在深度和法线纹理中,我们必须在Shader中设置正确的RenderType标签
    • 在Unity中,我们可以选择让一个摄像机生成一张深度纹理,或是一张深度加法线的纹理。选择前者只需要一张单独的深度纹理时,Unity会直接获取深度缓存,或是着色器替换技术选取需要的不透明物体,并使用它投射阴影时使用的Pass,也就是LightMode设置为ShadowCaster的Pass,来得到深度纹理,如果Shader中不包含这样一个Pass,那么这个物体就不会出现在深度纹理中,当然它也不能向其他物体投射阴影
    • 深度纹理的精度通常是24或16位的
    • 如果选择生成一张深度加法线纹理,Unity会创建一张和屏幕分辨率相同,精度为32位的纹理,也就是每个通道为8位,其中观察空间下的法线信息会被编码进纹理的R和G通道,而深度信息则会被编码进B和A通道
    • 法线信息的获取在延时队列中是可以非常容易得到的,Unity只需要合并深度和法线缓存即可,而在前向渲染中的默认情况下是不会创建法线缓存的,因此Unity底层使用了一个单独的Pass,把整个场景再次渲染一遍来完成,这个Pass包含在底层的一个内置Shader中

    • Unity中获取:
    • 只需要设置摄像机的DepthTextureMode就可以了,一旦设置好摄像机模式,就可以在Shader中通过声明的sampler2D _CameraDepthTexture变量来访问它
    • 要想获取深度加法线纹理,只需与上DepthNormals
    • Shader中获取后,使用SAMPLE_DEPTH_TEXTURE来采样
    • 使用这样的获取方式有个好处就是,可以处理由于平台差异造成的问题
    • 其中i.uv_depth是float类型的变量,它对应了当前像素的纹理坐标,是一个宏

    • 当通过纹理采样得到深度之后,深度值往往是非线性的,这种非线性是来自于透视空间使用的裁剪矩阵,然而在我们计算过程中通常需要线性的深度值,也就是说,需要把投影后的深度值变换到线性空间下,比如视角空间下的深度值
    • 这个过程比较复杂,不过可以直接使用Unity提供的两个辅助函数为我们进行上述的计算

    13.1.3 查看深度和法线纹理

    • 使用帧调试器查看到的深度纹理是非线性空间的深度值,而深度加法线纹理都是由Unity编码后的结果
    • 帧调试器(Frame Debug)、 深度纹理(UpdateDepthTexture)、 深度加法线纹理(UpdateDepthNormalsTexture)
    • 看到的画面可能是几乎全黑或全白的,这时可以把摄像机的裁剪平面距离调小,使视椎体范围刚好覆盖场景所在区域
    • 有时,显示出线性空间下的深度信息或解码后的法线方向会更加有用,此时,我们可以自行在片元着色器中输出转换或解码后的深度和法线值

    13.2 再谈运动模糊

    • 在12.6中学习了如何通过混合多张屏幕图像来模拟运动模糊的效果
    • 但是,另一种应用更加广泛的技术则是使用速度映射图
    • 速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小
    • 速度缓冲的生成有多种方法
      • 一种方法是把场景中所有物体的速度渲染到一张纹理中。这种方法的缺点在于需要修改场景中所有物体的Shader代码,使其添加计算速度的代码并输出到一个渲染纹理中
      • 另一种方法是利用深度纹理在片元着色器中,为每个像素其在世界空间下的位置,这是通过使用当前的视角乘以投影矩阵的逆矩阵,对NDC下的顶点坐标进行变换得到的,当得到世界空间下的顶点坐标后,就可以使用前一帧的视角乘以投影矩阵对其进行变换以得到该位置在前一帧中的NDC坐标,然后就可以计算前一帧和当前帧的位置差,并生成该像素的速度
      • 这种方法的优点就是可以在一个屏幕后处理步骤中完成整个效果,但缺点就是需要在片元着色器中进行两次矩阵乘法的操作,对性能有影响
    • 脚本:

    • Shader:

    • 首先利用深度纹理和当前帧的视角乘以投影矩阵逆矩阵来求得该像素在世界空间下的坐标
    • 这个过程一开始先使用内置的宏SAMPLE_DEPTH_TEXTURE对深度纹理进行采样得到深度值d,d是NDC下的坐标映射而来的,因此想要构建像素的NDC坐标H的话就需要把这个深度值重新映射回NDC,只需要使用原映射的函数就可以了,也就是 d * 2 - 1 ,这里NDC的xy分量可以由像素的纹理坐标映射而来。当得到NDC下的坐标H后,就可以使用当前帧的投影矩阵对其进行一个变换,并把它的结果除以它的w分量,能得到世界空间下的坐标表示worldPos,一旦得到它之后,就可以使用前一帧的视角,再乘以投影矩阵对它进行变换,以得到前一帧在NDC下的坐标previousPos,同样除以w分量,最后得到位置差,也就是该像素的速度velocity,得到后,就可以使用该速度值对它的邻域像素进行采样,相加后取平均值以得到模糊的效果
    • 之后定义了运动模糊所使用的Pass

    13.3 全局雾效

    13.3.0

    • Unity内置的雾效可以产生基于距离的线性或指数雾效
    • 如果想要在自己编写的顶点/片元着色器中实现这些雾效,需要在Shader中添加#pragma multi_compile_fog指令,同时还需要使用相关的内置宏,例如UNITY_FOG_COORDS、UNITY_TRANSFER_FOG和UNITY_APPLY_FOG等
    • 这种方法的缺点在于,我们不仅需要为场景中所有物体添加相关的渲染代码,而且能够实现的效果也非常有限
    • 当需要对雾效进行一些个性化操作时,例如使用基于高度的雾效等,仅仅使用Unity内置的雾效就变得不再可行

    13.3.1 全局雾效

    • 所以本节中将学习基于屏幕后处理的全局雾效,使用这种方法我们不需要更改场景中渲染物体所使用的Shader代码,而仅仅依靠一次屏幕后处理的步骤就可以了,这种方法的自由度很高,而且可以方便地模拟出各种雾效,比如均匀的雾效、基于距离的线性或者指数雾效、基于高度的雾效等等
    • 基于屏幕后处理的全局雾效的关键是,我们要根据深度纹理来重建每个像素在世界空间下的位置
    • 尽管在前面模拟运动模糊时就已经实现了这个要求,即构建出当前像素的NDC坐标,再通过当前摄像机的视角乘以投影矩阵逆矩阵,得到世界空间下的像素坐标,但是这样的实现需要在片元着色器上进行矩阵乘法的操作,这通常会影响游戏的性能
    • 在本节中,会学习一个快速从深度纹理中重建世界坐标的方法

    • 这种方法首先会对图像空间下的视椎体射线(这个射线是从摄像机出发,指向图像上某点的射线)进行一个插值,这条射线就存储该像素在世界空间下到摄像机的方向信息,然后把该射线和信息化后的视角框架的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间的位置
    • 当我们得到世界坐标后,就可以轻松地使用各个公式来模拟全局雾效了
    • 重建世界坐标的过程就是这行代码,只需要知道摄像机在世界空间的位置以及世界空间下该像素相对于摄像机的偏移量,把它们相减就可以得到该像素的世界坐标
    • 其中_WorldSpaceCameraPos是摄像机在世界空间下的位置,这可以由Unity内置变量直接返回得到
    • 而 linearDepth 乘以 interpolatedRay 则可以计算出该像素相当于摄像机的偏移量,linearDepth是由深度纹理得到的线性值,在这里是通过LinearEyeDepth来把深度纹理的采样结果转换到视角空间下的深度值,使用它可以很方便地将之前提到的非线性的结果给映射为线性的结果
    • interpolatedRay是前面计算得到的,它的来源是我们对近裁剪平面四个角的某个特定向量的插值,这四个量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、fov横坐标比来计算得到
    • _FrustumCornersRay的计算是在C#中定义的
    • 在一开始先计算得到两个分量toRight和toTop,它的起点位于裁剪平面的中心,分别指向摄像机的正右方和正上方的分量
    • 得到这两个辅助向量后,就可以计算四个角相对于摄像机的方向
    • 由于它们并非是点到摄像机的欧氏距离,而是在z方向上的距离,因此对其做一个归一化,然后乘上缩放因子

    13.3.2 雾的计算

    • 雾效系数f有很多计算方法,在Unity内置的雾效实现中,支持三种雾效的计算方式——线性(Linear)、指数(Exponential)以及指数的平方(Exponential Squared)
    • 雾效系数f作为混合原始颜色和雾的颜色的混合系数
    • 这里使用的是最简单的雾效计算方式,也就是线性的雾效的计算方式,它也是基于高度的雾效
    • 当给定一点在世界空间下的高度y之后,就可以对其计算
    • 脚本:
    • fogDensity用于控制雾的浓度、fogStart控制雾效的起始高度、fogEnd控制雾效的终止高度

    • Shader:

    • 在v2f的结构体中除了定义顶点位置、屏幕图像、深度纹理的坐标,还定义了interpolatedRay这个变量来存储插值后的像素向量
    • 顶点着色器中,对深度纹理的采样坐标进行了平台差异化的处理,同时决定了该点对应了四个角中的哪个角,采用的方法是判断它的纹理坐标,在Unity中纹理的(0,0)点对应了左下角,(1,1)点对应了右上角,据此来判断该顶点对应的索引,对应关系和脚本中对变量的赋值是完全一样的
    • 实际上不同平台的纹理坐标不一定是满足上面条件的,就比如DirectX中左上角对应(0,0)点,但大多数情况下Unity会把这些平台下的屏幕图像进行翻转,因此仍然可以使用这个条件。但如果在类似DirectX的平台上开启抗锯齿,Unity就不会进行这个翻转,为了此时还可以得到相应顶点位置的索引值,也对这个索引值做了平台差异化的处理
    • 尽管这里使用了很多判断语句,但由于屏幕后处理所用的模型是一个四边形的网格,只包含了四个顶点,所以它也不会对性能造成很大的影响
    • 接下来首先重建该像素在世界空间下的位置
    • 首先对深度纹理进行采样,再使用LinearEyeDepth得到视角空间下的线性深度值,之后与interpolatedRay相乘,再和世界空间下的摄像机位置相加就得到了世界空间下的位置
    • 得到世界坐标后,模拟雾效就非常容易,在本例中实现基于高度的雾效模拟,用计算公式得到fogDensity,再和定义的参数_FogDensity相乘,再截取到0~1范围内,就可以作为雾效系数
    • 使用这个雾效系数和原始颜色进行混合并返回

    • 也可以使用其他公式来定义其他种类的雾效
    • 需要注意的是,这里的实现是基于摄像机的投影类型是透视投影的前提下
    • 如果需要在正交投影的情况下去重建世界坐标,需要使用不同的公式

    13.4 再探边缘检测

    • 之前的做法是直接对颜色信息进行边缘检测的,可以看到,物体的纹理和阴影等位置也被描上了黑边
    • 本节将学习如何在深度和法线纹理上进行边缘检测
    • 使用Roberts算子来进行边缘检测

    • 定义了调整边缘线强度、描边颜色、背景颜色的参数,同时添加了控制采样距离,以及对深度和法线进行具体检测时的灵敏度参数等
    • sampleDistance用于控制对深度加法线纹理采样时,使用的采样距离,从视觉上来看,值越大,描的边越宽
    • sensitivityDepth和sensitivityNormals将会影响当邻域的深度值或法线值相差多少时,会被认为存在一条边界,如果把灵敏值调得很大,那么可能即使是深度或法线上很小的变化,也会形成一条线
    • 在OnRenderImage()上添加一个[ImageEffectOpaque],在默认情况下,OnRenderImage()函数在所有不透明和透明的Pass执行完毕后被调用,以便对场景中所有游戏对象产生影响,但有时我们希望在不透明Pass执行完毕后立即调用该函数,而不对透明物体产生影响,此时就可以添加这个特性
    • 本例中只希望对不透明物体进行描边,不希望对透明物体也描边,所以添加了这个特性
    • Shader:
    • 其中_Sensitivity的xy分量对应了法线和深度检测的灵敏度,zw分量并没有什么用途
    • 为了获取摄像机的深度加法线纹理,定义了_CameraDepthNormalsTexture
    • 同时由于需要对邻域像素进行纹理采样,也声明了存储纹理大小的变量_MainTex_TexelSize
    • 使用四个纹理坐标对深度加法线进行了采样,之后调用CheckSame()函数分别计算对角线上两个纹理值的差异
    • CheckSame()的返回值为0或1,返回0表明这两个点之间存在一条边界,反之返回1
    • 在CheckSame()函数中,首先对参数进行处理,得到两个采样点的法线和深度值,这里并没有解码得到真正的法线值,而是直接使用了xy分量,这是因为只需要比较两个深度之间的差异,而不需要真正的法线值。然后把两个采样点的对应值相减并取绝对值,再乘以灵敏度的参数,并把差异值的每个分量相加,和阈值进行比较,如果和小于0.1,返回1,说明差异不明显,不存在一条边界,否则返回0,说明差异比较明显
    • 最后把法线和深度的结果相乘,作为组合之后的结果
    • 有了边缘信息,就可以利用该值进行颜色混合,和之前做边缘检测是一致的