Shader篇 — 景深

Posted by Xun on Monday, April 11, 2022

游戏中常常会使用多种后处理效果来增强表现力,而景深是常见的后处理效果之一。

简介

  • 对于一个画面,我们常常想要突出某个对象,因此会将对象以外的其他区域进行模糊处理,只有对象保持清晰,这种效果就是景深。在游戏中,我们要实现景深效果有很多方式,如
    • 分离背景和目标对象,将背景模糊后再和目标对象组合。
    • 指定固定的区域,区域外的进行模糊处理,区域内的保持清晰。
    • 指定一个距离范围,在此距离范围外的进行模糊处理,范围内的保持清晰。
  • 当目标对象前面有其他物体产生遮挡时,使用第三种方案能较好地实现景深效果,后面将以第三种方案实现景深效果。

实现

  • 景深的主要步骤为:
    • 获取深度纹理。
    • 比对深度值。
    • 输出颜色。

获取深度纹理

  • 为了要知道每个对象和相机的距离,我们需要获取当前渲染的深度纹理,通过采样深度纹理,就可以定位每个片元的深度值,即可以知道哪些片元是属于背景或前景,需要模糊,哪些片元是属于目标对象,需要保持原状。
  • Unity中提供了获取深度纹理的方法,当我们在 CSharp 中设置相机的深度模式后,在Shader就能获得对应的深度纹理。CSharp 中的设置逻辑为:
    ...
    Camera cam = GetComponent<Camera>();
    cam.depthTextureMode = DepthTextureMode.Depth;
  • 有三种可能的深度纹理模式:
    • DepthTextureMode.Depth :深度纹理。
    • DepthTextureMode.DepthNormals :深度和视图空间法线打包到一个纹理中。
    • DepthTextureMode.MotionVectors :当前帧的每个屏幕纹素的每像素屏幕空间运动。包装成 RG16 纹理。
  • 设置完成后,在 Shader 中使用对应的变量 _CameraDepthTexture(对应 DepthTextureMode.Depth)即可以获取深度纹理。
Shader "XXXXXXXX"
{
    Properties
    {
        ...
    }

    SubShader
    {
        Pass
        {
            ...

            sampler2D _CameraDepthTexture;
            
            ...
        }
    }

    ...
}
  • 渲染进深度纹理的对象,需要具备以下条件:
    • 渲染队列 ≤ 2500 。
    • Shader 或者 FallBack 的 Shader 中有 ShadowCaster 的 Pass ,一般情况下FallBack 设置为 “Diffuse” 即可。
      Pass 
      {
          Name "ShadowCaster"
          Tags { "LightMode" = "ShadowCaster" }
      }
    

比对深度值

  • 通过设置并声明深度图后,就得到了当前帧的深度纹理 _CameraDepthTexture 。为了得到深度信息,需要采样深度图。
      float4 frag(v2f i) : SV_Target
      {
          float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv[0]);
          ...
      }
...

  • SAMPLE_DEPTH_TEXTURE 是 Unity 封装好的采样方法,具体实现为

#   define SAMPLE_DEPTH_TEXTURE(sampler, uv) (tex2D(sampler, uv).r)

  • 一般情况下,也可以自己使用 tex2D 方法采样,获取 r 分量作为深度值。而使用 Unity 封装的方法,则在某些平台上,就不需要自己再进行一些特殊处理。
  • 采样出来的深度值 d ,往往是非线性的(透视投影),所以需要变换到线性空间下,Unity 提供了变换的方法,即 Linear01Depth 和 LinearEyeDepth 。

// Z buffer to linear 0..1 depth
inline float Linear01Depth( float z )
{
    return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
    return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}

  • 这里我们使用 LinearEyeDepth ,能比较直观地对比深度值。
      float4 frag(v2f i) : SV_Target
      {
          float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv[0]);
          float z = LinearEyeDepth(d);
          ...
      }
  • 此时, z 值即为当前距离相机的深度值。当设置当前的目标范围为 (_Near, _Far) 时,如果在目标范围内,用 1 表示,不在范围内,则用 0 表示。
      float4 frag(v2f i) : SV_Target
      {
          float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv[0]);
          float z = LinearEyeDepth(d);
      
          float near = sign(z - _Near);
          float far = sign(_Far - z);
      
          ...
      }
  • 当 z > _Near 时, near 为 1,表示满足最小值,否则为 0 。同样,当 z < _Far 时,表示满足最大值,否则为 0 。
  • 因此,当 near * far 为 1 ,表示当前 z 处于 (_Near, _Far) 中,不需要模糊处理,即得到当前深度值的比对结果。

输出颜色

  • 当知道每个片元是否属于目标范围后,就可以输出最终的目标颜色。
    • 属于目标范围,直接采样纹理输出对应颜色值。
    • 不属于目标范围,需要进行模糊处理,输出处理后的颜色值。
  • 模糊处理有很多种方式,如高斯模糊等。这里使用简单的均值模糊来实现。

      v2f vert(a2v i)
      {
      	  v2f o;
      	  o.pos = UnityObjectToClipPos(i.position);
      	  o.uv[0] = i.uv;
      	  o.uv[1] = i.uv + _MainTex_TexelSize.xy * float2(-1, -1) * _BlurSize;
      	  o.uv[2] = i.uv + _MainTex_TexelSize.xy * float2(0, -1) * _BlurSize;
      	  o.uv[3] = i.uv + _MainTex_TexelSize.xy * float2(1, -1) * _BlurSize;
      	  o.uv[4] = i.uv + _MainTex_TexelSize.xy * float2(0, -1) * _BlurSize;
      	  o.uv[5] = i.uv + _MainTex_TexelSize.xy * float2(0, 1) * _BlurSize;
      	  o.uv[6] = i.uv + _MainTex_TexelSize.xy * float2(1, -1) * _BlurSize;
      	  o.uv[7] = i.uv + _MainTex_TexelSize.xy * float2(1, 0) * _BlurSize;
      	  o.uv[8] = i.uv + _MainTex_TexelSize.xy * float2(1, 1) * _BlurSize;
      	  return o;
      }
      
      float4 frag(v2f i) : SV_Target
      {
      	  float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv[0]);
      	  float z = LinearEyeDepth(d);
      	  float near = sign(z - _Near);
      	  float far = sign(_Far - z);
      
      	  float blur = (1 - near * far) * 8 ;
      	  float count = blur + 1;
      	  float4 sum = tex2D(_MainTex, i.uv[0]) / count;
      	  // 中心像素占0.3,周围像素各占0.0875,避免均值模糊导致变暗
      	  sum *= 1 + 2 * blur / 8; 
      	  for(int j = 1; j < count; j++)
      	  {
      	  	  sum += tex2D(_MainTex, i.uv[j]) * 0.0875;
      	  }
      	  float4 color = sum;
      
      	  return color;
      }

  • 如果在目标范围内,则只采样当前像素的颜色。如果不在目标范围内,则会额外采样周围的8个像素颜色,然后进行混合模糊。
  • _BlurSize 用来控制模糊采样的像素位置取值,越大则取间隔越远的像素,使得模糊效果更加明显。
    Normal.png
    常规效果
    DepthTexture.png
    景深效果

性能分析

  • 当我们使用深度图来实现景深效果时,增加了一个获取深度图的流程,通过 Unity 的 FrameDebugger 工具,渲染过程如下:
    DepthTexture.png
    左:获取深度图 右:不获取深度图
  • 可以看到,当获取深度图时,增加了一个 UpdateDepthTexture 的流程,即更新深度图。在此过程中,会做一次 Clear 操作,然后对符合条件的对象进行一次渲染。当前示例中有 4 个对象,所以多了 5 个 drawcall ,即每个符合的对象都会被渲染两次。当场景比较复杂时,符合条件的对象会有很多,那么 drawcall 会接近翻倍,性能上会有较大的影响。
  • 因此,如果在场景本身已经存在深度图的时候,使用这种方式实现景深效果则不会额外增加渲染压力,否则需要进行斟酌。

总结

  • 使用深度图实现景深效果,对于目标区间比较容易控制,能较方便控制需要突出的区间。然而,由于其性能上的限制,很多情况下,往往会舍弃一些表现效果,选择其他性能消耗较低的方案来实现,需要开发者根据项目需求进行抉择。
  • Demo示例工程: https://github.com/FallingXun/ShaderDemo/tree/main/DepthOfField