使用深度和法线纹理 -- Shader入门精要学习(12)

13 使用深度和法线纹理

很多时候我们不仅需要当前屏幕的颜色信息,还希望得到深度和法线信息。例如,在进行边缘检测时,直接利用颜色信息会使检测到的边缘信息受物体纹理和光照等外部因素的影响,得到很多我们不需要的边缘点。一种更好的方法是,我们可以在深度纹理和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。

1 获取深度和法线纹理

1.1 背后的原理

深度纹理:实际是一张渲染纹理,里面存储的像素值不是颜色值,而是一个高精度的深度值。由于被存储在一张纹理中,深度纹理的深度值范围是 [0, 1],而且通常是非线性分布的。

深度值的获取:深度值来自于顶点变换后得到的归一化的设备坐标(Normalized Device Coordinates, NDC)。一个模型要想最终被绘制在屏幕上,需要把它的顶点从模型空间变换到齐次裁剪坐标系下,通过顶点着色器中乘以MVP矩阵变换得到的。在变换到最后一步,需要使用一个投影矩阵来变换顶点。

当我们使用的是透视类型的摄像机时,这个投影矩阵就是非线性的。如下图所示:

在这里插入图片描述

最左侧的图显示了投影变换前,观察空间下视锥体的结构及相应的顶点位置;中间的图显示了应用透视裁剪矩阵后的变换结果,即顶点着色器阶段输出的顶点变换结果;最右侧的图则是底层硬件进行了透视除法后得到的归一化的设备坐标。(需要注意的是,这里的投影是建立在 Unity 对坐标系的假定上的,即观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换到 NDC 后 z 分量范围将在 [-1, 1] 之间的情况,而在类似 DirectX这样的图形接口中,变换后 z 分量范围将在 [0, 1] 之间。)

当我们使用的是正交摄像机时,使用的变换矩阵是线性的,同样,变换后会得到一个范围为 [-1, 1] 的立方体

在这里插入图片描述

得到 NDC 坐标后,深度纹理中的像素值就可以很方便地计算得到了,这些深度值就对应了 NDC 中顶点坐标的 z 分量的值。由于 NDC 中 z 分量的范围在 [-1, 1],为让这些值能够存储在一张图像中,我们需要使用下面的公式对齐进行映射:
d=0.5×zndc+0.5 d = 0.5\times z_{ndc} + 0.5 d=0.5×zndc+0.5
其中,d 对应了深度纹理中的像素值,zndcz_{ndc}zndc 对应了 NDC 坐标中 z 分量的值。

Unity 中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的 Pass 渲染而得,这取决于使用的渲染路径和硬件。

  • 使用延迟渲染时:深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到 G-buffer 中。

  • 不适用延迟渲染时:深度和法线纹理是通过一个单独的 Pass 渲染而得的。

    具体实现是:Unity 会使用着色器替换(Shader Replacement)技术选择那些渲染类型(即 SubShader 的 RenderType 标签)为 Opaque 的物体,判断它们使用的渲染队列是否小于等于 2500(内置的Background、Geometry 和 AlphaTest 渲染队列均在此范围内),如果满足条件,就把它渲染到深度和法线纹理中。因此,要想让物体能够出现在深度和法线纹理中,就必须在 Shader 中设置正确的 RenderType 标签。

Unity 中,我们可以选择让一个摄像机生成一张深度纹理或是一张深度 + 法线纹理。

  • 深度纹理:即只需要一张单独的深度纹理时,Unity 会直接获取深度缓存或是按之前讲到的着色器替换技术,选取需要的不透明物体,并使用它投射阴影时使用的 Pass (即 LightMode 设置为 ShaowCaster 的 Pass)来得到深度纹理。如果 Shader 中不包含这样一个 Pass,那么这个物体就不会出现在深度纹理中(当然,它也不能向其他物体投射阴影)。深度纹理的精度通常是 24 位或 16 位,这取决于使用的深度缓存的精度。
  • 深度 + 法线纹理:Unity 会创建一张和屏幕分辨率相同、 精度为 32 位(每个通道为8位)的纹理,其中观察空间下的法线信息会被编码进纹理的 R 和 G 通道,而深度信息会被编码进 B 和 A 通道。法线信息的获取在延迟渲染中是非常容易得到的,Unity 只需要合并深度和法线缓存即可。而在前向渲染中,默认情况下是不会去创建法线缓存的,因此 Unity 底层使用了一个单独的 Pass 把整个场景再次渲染一遍来完成。这个 Pass 被包含在 Unity 的内置的一个 Unity Shader 中,我们可以在内置的 build_shaders-xxx/DefaultResources/Camera-DepthNormalTexture.shader 文件中找到这个用于渲染深度和法线信息的的 Pass。

1.2 如何获取

Unity 中,获取深度纹理是非常简单的,我们只需要告诉 Unity:“嘿,把深度纹理给我!”,然后再在 Shader 中直接访问特定的纹理属性即可。这个与 Unity 沟通的过程是通过脚本中设置摄像机的 depthTextureMode 来完成的,例如我们可以通过下面的代码来获取深度纹理:

camera.depthTextureMode = DepthTextureMode.Depth;

一旦设置好了上面的摄像机模式后,我们就可以在 Shader 中通过声明 _CameraDepthTexture 变量来访问它。这个过程非常简单,但我们需要知道这个两行代码的背后,Unity 帮我们做了许多工作。

同理,如果想要获取深度 + 法线纹理,我们需要在代码中这样设置:

camera.depthTextureMode = DepthTextureMode.DepthNormals;

然后在 Shader 中通过声明 _CameraDepthNormalsTexture 来访问它。

我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度 + 法线纹理

camera.depthTextureMode |= DepthTextureMode.Depth
camera.depthTextureMode |= DepthTextureMode.DepthNormals

在 Unity5 中,我们还可以在摄像机的 Camera 组件上看到当前摄像机是否需要渲染深度或深度 + 法线纹理,当在 Shader 中访问到深度纹理 _CameraDepthTexture 后,我们就可以使用当前像素的纹理坐标对它进行采样。绝大多数情况下,我们直接使用 tex2D 函数采样即可,但在某些平台(例如 PS3 和 PS2)上,我们需要一些特殊处理。Unity为我们提供了一个统一的宏 SAMPLE_DEPTH_TEXTURE,用来处理这些由于平台差异造成的问题。而我们只需要在 Shader 中使用 SAMPLE_DEPTH_TEXTURE对深度纹理进行采样,得到深度值,例如

float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)

其中,i.uv 是一个 float2 类型的变量,对应了当前像素的纹理坐标。类似的宏还有 SAMPLE_DEPTH_TEXTURE_PROJ 和 SAMPLE_DEPTH_TEXTURE_LOD。SAMPLE_DEPTH_TEXTURE_PROJ 宏同样接收两个参数 – 深度纹理和一个 float3 或 float4 类型的纹理坐标,它内部使用了 tex2Dproj 这样的函数进行纹理采样,纹理坐标的前两个分量首先会除以最后一个分量,再进行纹理采样。如果提供了第四个分量,还会进行一次比较,通常用于阴影的实现中。SAMPLE_DEPTH_TEXTURE_PROJ 的第二个参数通常是由顶点着色器输出插值而得的屏幕坐标,例如:

float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTetxure, UNITY_PROJ_COORD(i.scrPos));

其中,i.scrPos 是在顶点着色器中通过调用 ComputeScreenPos(o.pos) 得到的屏幕坐标,上述这些宏的定义都可以在 Unity 内置的 HLSLSupport.cingc 文件中找到。

当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使用的裁剪矩阵。然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。实际上我们只需要倒推顶点变换的过程即可得到线性的深度值。

下面我们以透视矩阵为例,推导如何由深度纹理中的深度信息计算得到视角空间下的深度值:

第四章中讲过,当我们使用透视投影的裁剪矩阵 PclipP_{clip}Pclip 对视角空间下的一个顶点进行变换后,裁剪空间下顶点的 z 和 w 分量为:
zclip=−zviewFar+NearFar−Near−2⋅Near⋅FarFar−Nearwclip=−zview z_{clip} = -z_{view}\frac{Far + Near}{Far - Near} - \frac{2 \cdot Near \cdot Far}{Far - Near} \\ w_{clip} = -z_{view} zclip=zviewFarNearFar+NearFarNear2NearFarwclip=zview
其中,Far 和 Near 分别是远近裁剪平面的距离。然后,我们通过齐次除法就可以得到 NDC 下的 z 分量:
zndc=zclipwclip=Far+NearFar−Near+2Near⋅Far(Far−Near)⋅zview z_{ndc} = \frac{z_{clip}}{w_{clip}} = \frac{Far + Near}{Far - Near} + \frac{2Near \cdot Far}{(Far - Near) \cdot z_{view}} zndc=wclipzclip=FarNearFar+Near+(FarNear)zview2NearFar
深度纹理中的深度值是通过下面的公式由 NDC 计算而得的:
d=0.5⋅zndc+0.5 d = 0.5 \cdot z_{ndc} + 0.5 d=0.5zndc+0.5
由上面的这些式子,我们可以推导出用 d 表示而得的 zviewz_{view}zview 表达式:
zview=1Far−NearNear⋅Fard−1Near z_{view} = \frac{1}{\frac{Far - Near}{Near \cdot Far}d - \frac{1}{Near}} zview=NearFarFarNeardNear11
由于在 Unity 中使用的视角空间中,摄像机正向对应的 z 值均为负值,因此为了得到深度值的正数表示,我们需要对上面的结果取反,最后得到的结果如下:
zview′=1Near−FarNear⋅Fard+1Near z_{view}^{'} = \frac{1}{\frac{Near - Far}{Near \cdot Far}d + \frac{1}{Near}} zview=NearFarNearFard+Near11
它的取值范围就是视锥体深度范围,即 [Near, Far]。如果我们想得到范围在 [0, 1] 之间的深度值,只需要把上面的结果除以 Far 即可。这样,0 就表示该点与摄像机位于同一位置,1 表示该点位于视锥体远裁剪平面上,结果如下:
z01=1Near−FarNeard+FarNear z_{01} = \frac{1}{\frac{Near - Far}{Near}d + \frac{Far}{Near}} z01=NearNearFard+NearFar1
幸运的是,Unity 提供了两个辅助函数来为我们进行上述的计算过程——LinearEyeDepth 和 Linear01Depth。LinearEyeDepth 负责把深度纹理的采样结果转换到视角空间下的深度值,也就是我们上面得到的 zview′z_{view}^{'}zview 。而Linear01Depth 则会返回一个范围在 [0, 1] 的线性深度值,也就是我们上面得到的 z01z_{01}z01,这两个函数内部使用了内置的 _ZBufferParams 变量来得到远近裁剪平面的距离。

如果我们需要获取深度 + 法线纹理,可以直接使用 tex2D 函数对 _CameraDepthNormalsTexture 进行采样,得到里面存储的深度和法线信息。Unity 提供了辅助函数来为我们对这个采样结果进行解码,从而得到深度值和法线方向。这个函数是 DecodeDepthNormal,它在 UnityCG.cginc 被定义:

inline void DecodeDepthNormal(float4 enc, out float depth, out float3 normal)
{
	depth = DecodeFloatRG(enc.zw);
	normal = DecodeViewNormalStereo(enc);
}

DecodeDepthNormal 的第一个参数是对深度 + 法线纹理的采样结果,这个采样结果是 Unity 对深度和法线信息编码后的结果,它的 xy 分量存储的是视角空间下的法线信息,而深度信息被编码进了 zw 分量。通过调用 DecodeDepthNormal 函数对采样结果编码后,我们就可以得到编码后的深度值和法线,这个深度值是范围在 [0,1] 的线性空间下的法线方向。同样,我们也可以通过调用 DecodeFloatRG 和 DecodeViewNormalStereo 来解码 + 深度法线纹理中的深度和法线信息。

1.3 查看深度和法线纹理

很多时候,我们希望可以查看和生成深度和法线纹理,以便对 Shader 进行调试。Unity5 提供了一个方便的方法来查看摄像机生成的深度和法线纹理,这个方法就是利用帧调试器(Frame Debugger)

使用帧调试器查看到的深度纹理是非线性空间的深度值,而深度 + 法线纹理都是由 Unity 编码后的结果。有时,显示出线性空间下的深度信息或解码后的法线方向会更加有用。此时,我们可以自行在片元着色器中输出转换或解码后的深度和法线值,我们可以使用类似下面的代码来输出线性深度值

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float linearDepth = Linear01Depth(depth);
return fixed4(linearDepth, linearDepth, linearDepth, 1.0);

或是输出法线方向

fixed3 normal = DecodeViewNormalStereo(tex2D(_CamearaDepthNormalsTexture, i.uv).xy);
return fixed4(normal * 0.5 + 0.5, 1.0);

1.3 查看深度和法线纹理

很多时候,我们希望可以查看生成的深度和法线纹理,以便对 Shader 进行调试。Unity5 提供了一个方便的方法来查看摄像机生成的深度和法线纹理,这个方法就是使用帧调试器

使用帧调试器查看到的深度纹理是非线性空间的深度值,而深度 + 法线纹理都是由 Unity 编码后的结果。有时,显示出线性空间下的深度信息或解码后的法线方向会更加有用。此时,我们可以自行在片元着色器中输出转换或解码后的深度和法线值。我们可以利用下面的代码来输出线性深度值

float depth = SAMPLE_DEPTH_TEXTURE(_CamearDepthTexture, i.uv);
float linearDepth = Linear01Depth(depth);
return fixed4(linearDepth, linearDepth, linearDepth, 1.0)

或是输出法线方向

fixed3 normal = DecodeViewNormalStereo(tex2D(_CameraDepthNormalTexture, i.uv).xy);
return fixed4(normal * 0.5 + 0.5)

在查看深度纹理时,读者得到的画面有可能几乎是全黑或全白的。这个时候读者可以把摄像机的远裁剪平面的距离(Unity 默认为 100)调小,使视锥体的范围刚好覆盖场景的所在区域。这是因为,由于投影变换时需要覆盖从近裁剪平面到远裁剪平面的所有深度区域,当远裁剪平面的距离过大时,会导致离摄像机较近的距离被映射到非常小的深度值,如果一个场景是封闭的区域,那么这就会导致画面看起来几乎是全黑的。相反,如果场景是一个开放的区域,且物体距离摄像机距离较远,就会导致画面几乎全白。

2 再谈运动模糊

12.6节中采用了混合多张屏幕图像来模拟运动模糊效果。但是,另一种应用更加广泛的技术则是使用速度映射图。速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有多种方法,一种方法是把场景中所有物体的速度渲染到一张纹理中,但这种方法的去点在于需要修改场景中所有物体的 Shader 代码,使其添加计算速度的代码并输出到一个渲染纹理中。

还有一种生成速度映射图的方法是利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角 * 投影矩阵的逆矩阵对 NDC 下的顶点坐标进行变换得到的。当得到世界空间中的顶点坐标后,我们使用前一帧和当前帧的位置差,生成该像素的速度。优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。

新建 MotionBlurWithDepthTexture.cs 脚本,将此脚本拖拽到摄像机上,搭建场景,再为摄像机建立一个移动的脚本(注意运动模糊是摄像机运动产生的)

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MotionBlurWithDepthTexture : PostEffectsBase
{
    public Shader motionBlurShader;
    private Material _motionBlurMaterial = null;
    
    public Material material
    {
        get
        {
            // motionBlurShader是我们指定的Shader,对应了后面将会实现的MotionBlurWithDepthTexture.shader,_motionBlurMaterial是创建的材质,我们提供了名为Material的材质来访问它 
            _motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, _motionBlurMaterial);
            return _motionBlurMaterial;
        }
    }

    [Range(0.0f, 1.0f)] public float blurSize = 0.5f;
    private Camera _myCamera;
    public Camera camera
    {
        get
        {
            if (_myCamera == null)
            {
                _myCamera = GetComponent<Camera>();
            }
            return _myCamera;
        }
    }

    private Matrix4x4 _previousViewProjectionMatrix;  // 保存上一帧摄像机的视角 * 投影矩阵

    /// <summary>
    /// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
    /// </summary>
    /// <param name="src">源纹理</param>
    /// <param name="dest">最终渲染纹理</param>
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (material != null)
        {
            // 计算和传递运动模糊使用的各个属性
            material.SetFloat("_BlurSize", blurSize);
            material.SetMatrix("_PreviousViewProjectionMatrix", _previousViewProjectionMatrix);
            Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;  // 分别得到当前摄像机的视角和投影矩阵
            Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
            material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
            _previousViewProjectionMatrix = currentViewProjectionMatrix;
            
            Graphics.Blit(src, dest, material);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }

    private void OnEnable()
    {
        camera.depthTextureMode |= DepthTextureMode.Depth;
        _previousViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
    }
}

新建 MotionBlurWithDepthTexture.shader

Shader "MyShader/13-DepthAndNormalTexture/MotionBlurWithDepthTexture"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _BlurSize ("Blur Size", Float) = 1.0
    }
    SubShader
    {
        CGINCLUDE
        #include "UnityCG.cginc"

        sampler2D _MainTex;
        half4 _MainTex_TexelSize;                      // 主纹理的纹素大小
        sampler2D _CameraDepthTexture;                 // Unity传递的深度纹理
        float4x4 _CurrentViewProjectionInverseMatrix;  // 脚本传递来的矩阵
        float4x4 _PreviousViewProjectionMatrix;        // 脚本传递来的矩阵
        half _BlurSize;

        struct v2f
        {
            float4 pos : SV_POSITION;
            half2 uv : TEXCOORD0;
            half2 uv_depth : TEXCOORD1;            
        };

        v2f vert(appdata_img v)
        {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex);

            o.uv = v.texcoord;
            o.uv_depth = v.texcoord;
            #if UNITY_UV_STARTS_AT_TOP
            // 处理平台差异导致的图像翻转问题
            if (_MainTex_TexelSize.y < 0)
            {
               o.uv_depth.y = 1 - o.uv_depth.y;
            }
            #endif

            return o;
        }

        fixed4 frag(v2f i) : SV_Target
        {
            float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);    // 获取深度纹理(深度纹理采样)
            float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);    // 由于深度纹理d是NDC下的坐标映射而来的,我们想要构建像素的NDC坐标H,就需要把这个深度值重新映射回NDC,机像素的NDC坐标
            float4 D = mul(_CurrentViewProjectionInverseMatrix, H);             // 利用视角*投影矩阵对其进行变换,得到世界空间下的坐标
            float4 worldPos = D / D.w;

            float4 currentPos = H;
            float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);  // 得到前一帧的世界空间坐标
            previousPos /= previousPos.w;

            float2 velocity = (currentPos.xy - previousPos.xy) / 2.0f;          // 计算速度值
            float2 uv = i.uv;
            float4 c = tex2D(_MainTex, uv);
            uv += velocity * _BlurSize;
            // 利用速度值对领域像素进行采样,利用_BlurSize控制采样距离
            for(int it = 1; it < 3; it++)
            {
                float4 currentColor = tex2D(_MainTex, uv);
                c += currentColor;
                uv += velocity * _BlurSize;
            }
            c /= 3;

            return fixed4(c.rgb, 1.0);
        }

        
        ENDCG

        Pass
        {
            ZTest Always Cull Off ZWrite Off
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
        }
    }
    Fallback Off
}

将此 shader 拖拽到 MotionBlurWithDepthTexture.cs 上的 motionBlurShader 中,观察运动模糊效果

3 全局雾效

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

在本节中,我们将会学习一种基于屏幕后处理的全局雾效的实现。使用这种方法,我们不需要更改场景内渲染到物体所使用的 Shader 代码,而仅仅依靠一次屏幕后处理的步骤即可。这种方法的自由行很高,我们可以方便地模拟各种雾效,例如均匀的雾效、基于距离的线性/指数雾效、基于高度的雾效等。

基于屏幕后处理的全局雾效的关键是,根据深度纹理来重建每个像素在世界空间下的位置。我们在模拟运动模糊时已经实现了这个要求,即构建出当前像素的 NDC 坐标,再通过当前摄像机中的视角 * 投影矩阵的逆矩阵来得到世界空间下的像素坐标,但是,这样的实现需要在片元着色器中进行矩阵乘法的操作,而这通常会影响游戏性能。

本节中,我们将会学习一个快速从深度纹理中重构世界坐标的方法。这种方法首先对于图像空间下的视锥体射线(从摄像机出发,指向图像上的某点的射线)进行插值,这条射线存储了该像素在世界空间下到摄像机的方向信息。然后,我们把该射线和线性化后的视角空间下的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间下的位置。当我们得到世界坐标后,就可以轻松的使用各个公式来模拟全局雾效了。

3.1 重建世界坐标

坐标系中的一个顶点坐标可以通过它相对于另一个顶点坐标的偏移量来求得。重建像素的世界坐标也是基于这种思想。我们只需要知道摄像机在世界空间下的位置,以及世界空间下该像素相对于摄像机的偏移量,把它们相加就可以得到该像素的世界坐标。

float4 worldPos = _WorldSpaceCamearaPos + linearDepth * interpolatedRay;
  • _WorldSpaceCamearaPos:摄像机在是世界空间下的位置,这可以由 Unity 的内置变量直接访问得到。而 linearDepth * interpolatedRay 则可以计算得到该像素相对于摄像机的偏移量,

  • linearDepth:由深度纹理得到的线性深度值

  • interpolatedRay :由顶点着色器输出并插值后得到的射线,它不仅包含了该像素到摄像机的方向,也包含了距离信息。linearDepth 的获取我们已经在前面解释过了,因此这里着重解释 interpolatedRay 的求法。

interpolatedRay 来源于对近裁剪平面的 4 个角的某个特定向量的插值,这 4 个向量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、FOV、横纵比计算而得。图 13.6 显示了计算时使用的一些辅助向量。为了方便计算,我们可以先计算两个向量 —— toTop 和 toRight,它们是起点位于近裁剪平面中心、分别指向摄像机正上方和正右方的向量,它们的计算公式如下:
halfHeight=Near×tan(FOV2)toTop=camera.up×halfHeighttoRight=cameara.right×halfHeight⋅aspect halfHeight = Near \times tan(\frac{FOV}{2}) \\ toTop = camera.up \times halfHeight \\ toRight = cameara.right \times halfHeight\cdot aspect halfHeight=Near×tan(2FOV)toTop=camera.up×halfHeighttoRight=cameara.right×halfHeightaspect
在这里插入图片描述

其中,Near 是近裁剪平面的距离,FOV 是竖直方向的视角范围,camera.up、camera.right 分别对应了摄像机的正上方和正右方。

当得到这两个辅助向量后,我们就可以计算 4 个角相对于摄像机的方向了。我们以左上角为例,它的计算公式如下:
TL=camera.forward⋅Near+toTop−toRight TL = camera.forward \cdot Near + toTop - toRight TL=camera.forwardNear+toToptoRight
读者可以依靠基本的矢量运算验证上面的结果。同理,其他 3 个角的计算也是类似的:
TR=camera.forward⋅Near+toTop+toRightBL=camera.forward⋅Near−toTop−toRightBR=camera.forward⋅Near−toTop+toRight TR = camera.forward \cdot Near + toTop + toRight \\ BL = camera.forward \cdot Near - toTop - toRight \\ BR = camera.forward \cdot Near - toTop + toRight \\ TR=camera.forwardNear+toTop+toRightBL=camera.forwardNeartoToptoRightBR=camera.forwardNeartoTop+toRight
注意,上面求得的 4 个向量不仅包含了方向信息,它们的模对应了 4 个点到摄像机的空间距离。由于我们得到的线性深度值并非是点到摄像机的欧式距离,而是在 z 方向上的距离,因此,我们不能直接使用深度值和 4 个角的单位方向的乘积来计算它们到摄像机的偏移量。想要把深度值转换成到摄像机的欧式距离也很简单,我们以 TL 点为例,根据相似三角形原理,TL 所在的射线上,像素的深度值和它到摄像机的实际距离的比等于近裁剪平面的距离和 TL 向量的模的比,即
depthdist=NearTL \frac{depth}{dist} = \frac{Near}{TL} distdepth=TLNear
由此可得,我们需要的 TL 距离摄像机的欧氏距离 dist:
dist=TLNear×depth dist = \frac{TL}{Near} \times depth dist=NearTL×depth
由于 4 个点相互对称,因此其他 3 个向量的模和 TL 相等,即我们可以使用同一个因子和单位向量相乘,得到它们对应的向量值:
scale=∣TL∣∣Near∣RayTL=TL∣TL∣×scale,RayTR=TR∣TR∣×scaleRayBL=BL∣BL∣×scale,RayBR=BR∣BR∣×scale scale = \frac{|TL|}{|Near|} \\ Ray_{TL} = \frac{TL}{|TL|} \times scale,Ray_{TR} = \frac{TR}{|TR|}\times scale \\ Ray_{BL} = \frac{BL}{|BL|} \times scale,Ray_{BR} = \frac{BR}{|BR|} \times scale \\ scale=NearTLRayTL=TLTL×scale,RayTR=TRTR×scaleRayBL=BLBL×scale,RayBR=BRBR×scale
屏幕后处理的原理就是使用特定的材质去渲染一个刚好填充整个屏幕的四边形面片。这个四边形面片的 4 个顶点就对应了近裁剪平面的 4 个角。因此,我们可以把上面的计算结果传递给顶点着色器,顶点着色器根据当前的位置选择它所对应的向量,然后将其输出,经插值后传递给片元着色器得到 interpolatedRay,我们就可以直接利用本节一开始提到的公式重建该像素在世界空间下的位置了。

3.2 雾的计算

在简单的雾效实现中,我们需要计算一个雾效系数 f,作为混合原始颜色和雾的颜色的混合系数:

float3 afterFog = f * fogColor + (1 - f) * origColor

这个雾效系数 f 有很多计算方法。在 Unity 内置的雾效实现中,支持三种雾的计算方式 —— 线性(Linear)、指数(Exponential)以及指数的平方(Exponential Squared)。当给定距离 z 后,f 的计算公式分别如下:

  • Linear:dmax−∣z∣dmax−dmin\frac{d_{max} - |z|}{d_{max}-d_{min}}dmaxdmindmaxzdmind_{min}dmindmaxd_{max}dmax 分别表示受雾影响的最小距离和最大距离
  • Exponential:f=e−d⋅∣z∣f = e^{-d \cdot |z|}f=edz,d 是控制雾的浓度的参数
  • Exponenial Squared:f=e−(d−∣z∣)2f = e^{-(d - |z|)^2}f=e(dz)2,d 是控制雾的浓度的参数

在本节中,我们将使用类似线性雾的计算方式,计算基于高度的雾效。具体方法是,当给定一点在世界空间下的高度 y 后,f 的计算公式为:

f=Hend−yHend−Hstartf = \frac{H_{end} - y}{H_{end} - H_{start}}f=HendHstartHendyHstartH_{start}HstartHendH_{end}Hend 分别表示受雾影响的起始高度和终止高度

3.3 实现

新建 MotionBlurWithDepthTexture.cs 脚本,将此脚本拖拽到摄像机上,搭建场景

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FogWithDepthTexture : PostEffectsBase
{
    public Shader fogShader;
    private Material _fogMaterial;
    
    public Material material
    {
        get
        {
            // briSatConShader是我们指定的Shader,对应了后面将会实现的BrightnessSaturationAndContrast.shader,_briSatConMaterial是创建的材质,我们提供了名为Material的材质来访问它 
            _fogMaterial = CheckShaderAndCreateMaterial(fogShader, _fogMaterial);
            return _fogMaterial;
        }
    }

    private Camera _myCamera;

    public Camera camera
    {
        get
        {
            if (_myCamera == null)
            {
                _myCamera = GetComponent<Camera>();
            }
            return _myCamera;
        }
    }

    private Transform _myCameraTransform;
    public Transform cameraTransform
    {
        get
        {
            if (_myCameraTransform == null)
            {
                _myCameraTransform = camera.transform;
            }
            return _myCameraTransform;
        }
    }

    [Range(0.0f, 3.0f)] public float fogDensity = 1.0f;  // 控制雾的浓度
    public Color fogColor = Color.white;                 // 控制雾效的颜色
    public float fogStart = 0.0f;                        // 控制雾效的起始高度
    public float fogEnd = 2.0f;                          // 控制雾效的终止高度

    /// <summary>
    /// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
    /// </summary>
    /// <param name="src"></param>
    /// <param name="dest"></param>
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (material != null)
        {
            Matrix4x4 frustumCorners = Matrix4x4.identity;
            float fov = camera.fieldOfView;
            float near = camera.nearClipPlane;
            float far = camera.farClipPlane;
            float aspect = camera.aspect;                                        // 纵横比 

            float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);  // 获取到一半的高度
            Vector3 toRight = cameraTransform.right * halfHeight * aspect;       // 获取到toRight向量
            Vector3 toTop = cameraTransform.up * halfHeight;                     // 获取到toLeft向量

            Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
            float scale = topLeft.magnitude / near;
            topLeft.Normalize();
            topLeft *= scale;

            Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
            topRight.Normalize();
            topRight *= scale;

            Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
            bottomLeft.Normalize();
            bottomLeft *= scale;

            Vector3 bottomRight = cameraTransform.forward * near + topRight - toTop;
            bottomRight.Normalize();
            bottomRight *= scale;
            
            // 将四个角向量设置进矩阵中
            frustumCorners.SetRow(0, bottomLeft);
            frustumCorners.SetRow(1, bottomRight);
            frustumCorners.SetRow(2, topRight);
            frustumCorners.SetRow(3, topLeft);
            
            material.SetMatrix("_FrustumCornersRay", frustumCorners);
            material.SetMatrix("_ViewProjectionInverseMatrix", (camera.projectionMatrix * camera.worldToCameraMatrix).inverse);  // 设置透视*投影矩阵
            
            material.SetFloat("_FogDensity", fogDensity);
            material.SetColor("_FogColor", fogColor);
            material.SetFloat("_fogStart", fogStart);
            material.SetFloat("_fogEnd", fogEnd);
            
            Graphics.Blit(src, dest, material);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }

    private void OnEnable()
    {
        camera.depthTextureMode |= DepthTextureMode.Depth;
    }
}

新建 FogWithDepthTexture.shader

Shader "MyShader/13-DepthAndNormalTexture/FogWithDepthTexture"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _FogDensity ("Fog Density", Float) = 1.0
        _FogColor ("Fog Color", Color) = (1, 1, 1, 1)
        _FogStart ("Fog Start", Float) = 0.0
        _FogEnd ("Fog End", Float) = 1.0
    }
    SubShader
    {
        CGINCLUDE
        #include "UnityCG.cginc"
        float4x4 _FrustumCornersRay;    // 虽然没有在Properties中声明,但是还是可以通过脚本传递给Shader
        sampler2D _MainTex;
        half4 _MainTex_TexelSize;
        sampler2D _CameraDepthTexture;  // Unity会把深度纹理传给该值 
        half _FogDensity;
        fixed4 _FogColor;
        float _FogStart;
        float _FogEnd;

        struct v2f
        {
            float4 pos : SV_POSITION;
            half2 uv : TEXCOORD0;
            half2 uv_depth : TEXCOORD1;
            float4 interpolatedRay : TEXCOORD2;
        };

        v2f vert(appdata_img v)
        {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex);

            o.uv = v.texcoord;
            o.uv_depth = v.texcoord;

            #if UNITY_UV_STARTS_AT_TOP // 处理平台差异
            if(_MainTex_TexelSize.y < 0)
                o.uv_depth.y = 1 - o.uv_depth.y;
            #endif

            // 利用纹理坐标判断该点对应了四个角中的哪个角(个人认为这里世界坐标的获取不够准确)
            // 这里是将整个屏幕看作一个贴图,texcoord里面记录的是每个点对应的屏幕纹理
            int index = 0;
            if(v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
            {
                index = 0;
            }
            else if(v.texcoord.x > 0.5 && v.texcoord.y < 0.5)
            {
                index = 1;
            }
            else if(v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
            {
                index = 2;
            }
            else
            {
                index = 3;
            }

            #if UNITY_UV_STARTS_AT_TOP  // 处理平台差异
            if(_MainTex_TexelSize.y < 0)
                index = 3 - index;
            #endif
            o.interpolatedRay = _FrustumCornersRay[index];

            return o;
        }

        fixed4 frag(v2f i) : SV_Target
        {
            float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));  // 对深度纹理进行采样后得到视角空间下的线性深度值
            float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;               // 得到世界空间下的位置
            // 实现居于高度的雾效模拟
            float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);                          // 计算像素高度对应的雾效系数(采用线性的方法,个人认为这里计算出来的像素高度是粗略的)
            fogDensity = saturate(fogDensity * _FogDensity);                                            // 将雾效系数与设置的雾效强度进行混合,并把结果截取在[0, 1]范围内
            fixed4 finalColor = tex2D(_MainTex, i.uv);                                                  // 对纹理进行采样,得到颜色值
            finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);                           // 将雾的颜色与原颜色值进行混合
            return  finalColor;
        }
        
        ENDCG
        
        Pass
        {
            ZTest Always Cull Off ZWrite Off
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
        }
    }
    FallBack Off
}

将此 shader 拖拽到 FogWithDepthTexture.cs 上的 fogShader中,观察雾效效果

4 再谈边缘检测

12.3 节中,我们曾介绍如何使用 Sobel 算子对屏幕图像进行边缘检测,实现描边效果。但是,直接利用颜色进行进行边缘检测的方法会产生很多我们不希望得到的边缘线。

在这里插入图片描述

可以看出,物体的纹理、阴影等位置也被描上黑边,而这往往不是我们希望看到的。在本节中,我们将学习如何在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这种方式检测出来的边缘更加可靠。

与 12.3 节中使用 Sobel 算子不同,本节将使用 Roberts 算子来进行边缘检测,它使用的卷积核如下:

在这里插入图片描述

Roberts 算子的本质就是计算左上角和右上角的差值,乘以右上角和左下角的差值,作为评估边缘的依据。在下面的实现中,我们也会按照这样的方式,取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阈值,就认为它们之间存在一条边。

新建 EdgeDetectNormalAndDepth.cs 脚本,将此脚本拖拽到摄像机上,搭建场景

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EdgeDetectNormalAndDepth : PostEffectsBase
{
    public Shader edgeDetectShader;
    private Material _edgeDetectMaterial;
    
    public Material material
    {
        get
        {
            // edgeDetectShader是我们指定的Shader,对应了后面将会实现的EdgeDetectNormalAndDepth.shader,_edgeDetectMaterial是创建的材质,我们提供了名为Material的材质来访问它 
            _edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, _edgeDetectMaterial);
            return _edgeDetectMaterial;
        }
    }

    [Range(0.0f, 1.0f)] public float edgesOnly = 0.0f;
    public Color edgeColor = Color.black;
    public Color backgroundColor = Color.white;
    public float sampleDistance = 1.0f;                 // 控制对深度 + 法线纹理采样时,使用的采样距离(视觉上看,sampleDistance值越大,描边越宽)
    public float sensitivityDepth = 1.0f;               // 邻域深度值相差值
    public float sensitivityNormals = 1.0f;             // 邻域法线值相差值

    /// <summary>
    /// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
    /// </summary>
    /// <param name="src"></param>
    /// <param name="dest"></param>
    [ImageEffectOpaque] private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        // 这里需要添加[ImageEffectOpaque]属性,表示只在非透明Shader执行完之后调用,而不对透明Shader起效果
        if (material != null)
        {
            material.SetFloat("_EdgeOnly", edgesOnly);
            material.SetColor("_EdgeColor", edgeColor);
            material.SetColor("_BackgroundColor" ,backgroundColor);
            material.SetFloat("_SampleDistance", sampleDistance);
            material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));
            Graphics.Blit(src, dest, material);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }

    private void OnEnable()
    {
        GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
    }
}

新建 EdgeDetectNormalAndDepth.shader

Shader "MyShader/13-DepthAndNormalTexture/EdgeDetectNormalAndDepth"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _EdgeOnly ("Edge Only", Float) = 1.0
        _EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
        _BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
        _SampleDistance ("Sample Distance", Float) = 1.0
        _Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)  // xy分量分别对应了法线和深度检测的灵敏度
    }
    SubShader
    {
        CGINCLUDE
        
        #include "UnityCG.cginc"
        sampler2D _MainTex;
        half4 _MainTex_TexelSize;
        fixed _EdgeOnly;
        fixed4 _EdgeColor;
        fixed4 _BackgroundColor;
        float _SampleDistance;
        half4 _Sensitivity;
        sampler2D _CameraDepthNormalsTexture;  // 获取深度+法线纹理

        struct v2f
        {
            float4 pos : SV_POSITION;
            half2 uv[5] : TEXCOORD0;
        };

        v2f vert(appdata_img v)
        {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex);
            half2 uv = v.texcoord;
            o.uv[0] = uv;

            #if UNITY_UV_STARTS_AT_TOP
            if (_MainTex_TexelSize.y < 0)
                uv.y = 1 - uv.y;
            #endif

            // 存储一个维数为5的纹理坐标数组。存储屏幕颜色图像的采样纹理以及使用Roberts算子时需要采样的纹理坐标,利用_SampleDistance控制采样距离
            // 把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能
            o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1, 1) * _SampleDistance;
            o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1, -1) * _SampleDistance;
            o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 1) * _SampleDistance;
            o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1, -1) * _SampleDistance;

            return o;
        }

        // 计算对角线上两个纹理值的差值,要么返回0,要么返回1
        half CheckSum(half4 center, half4 sample)
        {
            // 分别得到两个采样点的法线和深度值
            half2 centerNormal = center.xy;
            float centerDepth = DecodeFloatRG(center.zw);
            half2 sampleNormal = sample.xy;
            float sampleDepth = DecodeFloatRG(sample.zw);

            // 计算两个法线之间的差值,并和一个阈值进比较
            half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
            int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
            // 计算两个深度值之间的差值,并和一个阈值进行比较
            float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
            int isSameDepth = diffDepth < 0.1 * centerDepth;

            // 1:法线和深度都很接近  0:法线和深度值不接近,说明是边缘
            return  isSameNormal * isSameDepth ? 1.0 : 0.0;
        }

        fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target
        {
            // 对深度/法线纹理进行采样
            half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
            half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
            half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
            half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

            half edge = 1.0;

            edge *= CheckSum(sample1, sample2);
            edge *= CheckSum(sample3, sample4);

            // 将最终的边缘颜色进行混合,edge为1就是不是边缘,不混合,为0就是为边缘,混合
            fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
            fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);

            // _EdgeOnly更接近1就更接近设置的backgroundColor
            return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
        }
        
        ENDCG
        
        Pass
        {
            ZTest Always Cull Off ZWrite Off
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment fragRobertsCrossDepthAndNormal
            ENDCG
        }
    }
    Fallback Off
}

将此 shader 拖拽到 EdgeDetectNormalAndDepth.cs 上的 edgeDetectShader 中,观察描边效果

本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但是有时,我们只希望特定物品进行描边,例如当玩家选中场景中某个物体后,我们想要在该物体周围添加一层描边效果。这时,我们可以使用 Unity 提供的 Graphics.DrwaMesh 或 Graphics.DrwaMeshNow 函数把需要描边的物体再次渲染一遍,然后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阈值,如果是,就在 Shader 中使用 clip() 函数将该像素剔除掉,从而显示出原来的物体颜色。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值