神奇的深度图:复杂的效果,不复杂的原理

作者: koo叔 分类: Shader与效果 发布时间: 2017-08-21 15:41 编辑

0x00 前言

本文是《有趣的深度图》的第二篇文章,上一篇文章《有趣的深度图:可见性问题的解法》中已经和大家介绍了深度图在解决可见性问题中的应用。其实,利用深度信息我们可以实现很多有趣而又显得“高大上”的效果。
不过这些效果虽然看上去高大上,但是一旦了解了原理就会发现实现这种效果其实是十分简单的。

那么本文会包括以下四个有趣的效果在Unity中的实现:

有点科幻的扫描网
透过墙壁绘制背后的“人影”
护盾/能量场效果
边缘检测

0x01 获取深度信息

为了利用深度信息来实现若干效果,我们首先需要获取场景的深度信息。在移动游戏开发中常用的前向渲染路径(Forward Rendering)下,我们需要手动设置相机,让它提供场景的深度信息。

[C#] 纯文本查看 复制代码
?

camera.depthTextureMode = DepthTextureMode.Depth;

如果在延迟渲染路径(Deferred Lighting)下,由于延迟渲染需要场景的深度信息和法线信息来做光照计算,所以并不需要我们手动设置相机。

这样我们就可以在shader中访问_CameraDepthTexture来获取保存的场景的深度信息,之后再利用UNITY_SAMPLE_DEPTH这个宏来处理_CameraDepthTexture的值,这样我们就获取了某个像素的深度值。

[C#] 纯文本查看 复制代码
?

float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, uv));

但是正如上一篇文章中所说,此时的深度值并非是线性的,因此我们常常需要利用另一个内建的方法Linear01Depth将结果转化为线性的。这样,我们就能将场景的深度信息渲染为一张灰度图。

[C#] 纯文本查看 复制代码
?

float linear01Depth = Linear01Depth(depth);

0x02 有点科幻的扫描网

不知道有没有小伙伴玩过《无人深空》这款游戏,当初ps4版预售时我就用行动支持了这款看上去很有吸引力的沙盒游戏,当然第二天挂闲鱼就是后话了。虽然这款游戏让人感到有些失望,但是其中的一些画面效果还是很有趣的,而且也和这篇文章的主题相关——利用场景的深度信息来实现一些科幻效果——比如说,在星球上用扫描仪进行扫描的效果。

我们也可以在Unity中实现类似的效果,关键就是利用场景的深度信息。

因此如果项目使用了前向渲染路径,我们就必须在脚本中手动将相机的depthTextureMode 设置为DepthTextureMode.Depth,如果是延迟渲染则不需要我们手动设置。

[C#] 纯文本查看 复制代码
?

camera.depthTextureMode = DepthTextureMode.Depth;

其次,这种全屏效果常常作为屏幕特效(image effect)来实现,也就是说我们需要摄像机先将场景渲染成一副图片,之后对这张图片的像素做处理。设想一下如果不这样做的话,我们不仅要计算场景内所有被渲染对象和摄像机的距离,还需要至少两个pass,其中一个返回被渲染物体的正常颜色,另一个则来实现和扫描颜色的叠加。如果场景内被渲染的对象很多的话,这样的操作效率就变得十分低下了。

所以,在cs脚本中我们还会用到OnRenderImage这个回调以获取摄像机渲染的场景图像。

[C#] 纯文本查看 复制代码
?

1
2
3
void OnRenderImage(RenderTexture src, RenderTexture dst)
{

 //TODO

}

再次,随着时间的流逝扫描网逐渐扫描整个场景显然是一个动态的效果。因此我们还需要把时间这个因子也引入,时间影响了扫描网和起点的距离。当然,我们既可以在shader文件中考虑时间的影响,也能在cs文件中考虑时间的影响。

如果我们要直接在shader中获取时间的信息的话,就需要用到unity的内置变量float4 _Time : Time (t/20, t, t2, t3) 了。它的4个分量分别表示了t/20、t、t2、t3。因此,在shader中我们使用_Time.y就可以获取当前的时间了,根据时间我们就能算出扫描网当前移动的大概距离了。

除此之外,我们当然也可以在cs文件中直接利用Time类和Update方法直接计算扫描网的移动距离,然后再将结果传入shader。这样,我们就完成了一个超级简单的cs脚本:

[C#] 纯文本查看 复制代码
?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/*

using UnityEngine;
using System.Collections;

public class ScannerEffect : MonoBehaviour
{

#region 字段

public Material mat;
public float velocity = 5;
private bool isScanning;
private float dis;

#endregion
#region unity 方法

void Start()
{
    Camera.main.depthTextureMode = DepthTextureMode.Depth;
}

void Update()
{
    if (this.isScanning)
    {
        this.dis += Time.deltaTime * this.velocity;
    }

    //无人深空中按c开启扫描
    if (Input.GetKeyDown(KeyCode.C))
    {
        this.isScanning = true;
        this.dis = 0;
    }

}
void OnRenderImage(RenderTexture src, RenderTexture dst)
{
    mat.SetFloat("_ScanDistance", dis);
    Graphics.Blit(src, dst, mat);
}

#endregion

}

至于shader?那就更简单了,现在我们获取了相机渲染之后的场景图,这样图上的每个像素只需要获取自己的深度信息:

[C#] 纯文本查看 复制代码
?

1
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);

float linear01Depth = Linear01Depth(depth);

然后再和扫描网现在的位置做个对比——当然我们还可以加入扫描网的宽度这个概念——符合条件的像素颜色和扫描网的颜色进行叠加就可以了。最后为了更完美一点,我们还需要判断一下深度值是否比1小,因为深度值在[0,1]这个区间内,而1对应的是远裁切面,因此如果不判断1的话,整个远方最后都会被扫描网的颜色进行叠加。

[C#] 纯文本查看 复制代码
?

1
2
3
4
5
if (linear01Depth < _ScanDistance && linear01Depth > _ScanDistance - _ScanWidth && linear01Depth < 1)
{

float diff = 1 - (_ScanDistance - linear01Depth) / (_ScanWidth);
_ScanColor *= diff;
return col + _ScanColor;

}

完整的项目可以到这里到这里下载:UnitySpecialEffectWithDepth
https://github.com/chenjd/UnitySpecialEffectWithDepth

0x03 透过墙壁绘制背后的“人影”

透过障碍物看到障碍物后的高亮目标,国内外很多游戏都会用到类似的效果。

这个看上去很有高大上的视觉效果,其实从创建一个unity的Unlit shader文件到最后完成这个效果只需要大概30s。

原理很简单,即根据目标是否被遮挡返回不同的颜色即可。目标被障碍物遮住的部分其深度值必然要大于障碍物,因此我们可以用一个pass处理当深度值大于障碍物的时也就是目标被障碍物遮住的部分的颜色——例如我们返回红色。

[C#] 纯文本查看 复制代码
?

01
02
03
04
05
06
07
08
09
10
11
Pass

{
    ZTest Greater

    ...

    fixed4 frag (v2f i) : SV_Target
    {
        fixed4 col = fixed4(1, 0, 0, 1);
        return col;
    }
}

再用另一个pass处理目标未被遮挡住的部分,也就是深度值小于障碍物时返回目标的正常颜色。

[C#] 纯文本查看 复制代码
?

01
02
03
04
05
06
07
08
09
10
11
Pass

{
    ZTest Less 

    ...

    fixed4 frag (v2f i) : SV_Target
    {
        fixed4 col = tex2D(_MainTex, i.uv);
        return col
    }
}

不过墙后的敌人如果只是显示一个红色是否有点太单调了呢?还有很多游戏,它的透视效果是下面这样的:目标身上多了一些描边。

这个效果的实现其实也很简单。我们可以根据观察方向和目标多边形的法线方向的夹角来判断目标的边缘——毕竟目标面向我们的面的法线和我们观察方向的夹角相对较小,而边缘部分的面的法线和我们的观察方向的夹角显然更大——这里的边缘判断用到了观察方向,下文我们还会聊聊跟观察方向无关的边缘检测。

所以,给墙后的目标描边这件事就又变得十分简单了。我们只需要在处理被遮挡部分的那个pass中返回的红色变为与法线和观察方向的夹角相关的一个值就好了。
为了实现这个目标,我们首先要获取法线和观察方向的信息。

[C#] 纯文本查看 复制代码
?

1
o.normal = UnityObjectToWorldNormal(v.normal);
o.viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);

之后再计算法线和观察方向的夹角信息:

[C#] 纯文本查看 复制代码
?

float NdotV = 1 - dot(i.normal, i.viewDir) ;

最后,只需要把这个值当作影响最后颜色输出的因素就好了。

[C#] 纯文本查看 复制代码
?

return _EdgeColor * NdotV;

完整的项目可以到这里到这里下载:UnitySpecialEffectWithDepth
https://github.com/chenjd/UnitySpecialEffectWithDepth

0x04 护盾/能量场效果

很多科幻游戏也有这种能量场或者护盾的效果。例如暴雪的守望先锋中的猩猩温斯顿的屏障发射器、光环系列的圣堂防卫者的能量护盾甚至一些手游中也有类似的效果,比如网易的光明大陆。

这个效果的实现和原理其实也并不复杂。简单的说可以分为以下这几个部分:

半透明效果
相交高亮,主要指能量场和别的物体相交的地方是高亮显示
表面扭曲
一个和观察方向相关的描边效果

首先我们要开启透明混合并指定渲染队列为透明。

[C#] 纯文本查看 复制代码
?

01
02
03
04
05
06
07
08
09
10
11
12
13
SubShader
{

ZWrite Off
Cull Off
Blend SrcAlpha OneMinusSrcAlpha

Tags
{
    "RenderType" = "Transparent"
    "Queue" = "Transparent"
}

...

}

之后像上一个例子那样,根据观察方向绘制能量场的边缘。

[C#] 纯文本查看 复制代码
?

1
2
3
4
5
6
7
//vert
o.normal = UnityObjectToWorldNormal(v.normal);

o.viewDir = normalize(UnityWorldSpaceViewDir(mul(unity_ObjectToWorld, v.vertex)));

//frag
float rim = 1 - abs(dot(i.normal, normalize(i.viewDir)));

这样,我们就得到了一个半透且带有描边效果球体,能量场已经初具雏形了。

接下来,我们就要实现相交高亮的效果了。所谓的相交高亮指的是能量场和别的物体相交时,在相交处绘制出高亮效果。这时我们就要用到深度信息了。当能量场和某个物体相交时,二者的深度信息应该一致,基于这个对比深度信息,我们可以用来估计一个像素的“相交程度”。

需要注意的是,能量场的shader在执行时_CameraDepthTexture中只保存了场景中不透明物体的深度信息,因此这个时候无法从CameraDepthTexture中获取能量场的深度信息,所以要在vert中计算顶点的深度,这里我利用了COMPUTE_EYEDEPTH这个内置的宏。在之后的frag内就可以很方便的获取场景和能量场当前片元的深度了。

[C#] 纯文本查看 复制代码
?

1
2
3
4
5
6
7
//vert
o.screenPos = ComputeScreenPos(o.vertex);
COMPUTE_EYEDEPTH(o.screenPos.z);

//frag
float sceneZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
float partZ = i.screenPos.z;

两者相减就是深度的差异diff,再用1 - diff就得到了一个“相交程度”。

[C#] 纯文本查看 复制代码
?

1
2
float diff = sceneZ - partZ;

float intersect = (1 - diff) * _IntersectPower;

最后我们还需要实现一个能量场的扭曲效果。扭曲效果是游戏里面经常有的一个效果,其实也很简单,我们只需要一张渲染能量场之前的场景的渲染图,之后随时间调整uv的偏移就可以模拟扭曲的效果了。

[C#] 纯文本查看 复制代码
?

01
02
03
04
05
06
07
08
09
10
11
12
GrabPass
{

"_GrabTempTex"

}

...

//frag
float4 offset = tex2D(_NoiseTex, i.uv - _Time.xy) * _DistortTimeFactor;
i.grabPos.xy -= offset.xy * _DistortStrength;
fixed4 color = tex2Dproj(_GrabTempTex, i.grabPos);

...

完整的项目可以到这里到这里下载:UnitySpecialEffectWithDepth
https://github.com/chenjd/UnitySpecialEffectWithDepth

0x05 边缘检测

边缘检测的目的是标识数字图像中属性显著变化的点。图像属性中的显著变化通常反映了属性的重要变化。这些包括:

深度上的不连续
表面法线方向不连续
颜色不连续
亮度不连续

需要注意的是边缘可能与观察方向有关——也就是说边缘可能随着观察方形的不同而变化,例如上文中的描边实现;也可能与观察方向无关——这通常反映被观察物体的属性如表面纹理和表面形状。在这个部分,我们的关注点主要是后者。

因此,根据不同的属性变化也有很多种策略来处理边缘检测,例如利用深度、利用法线、利用深度+法线、利用颜色等等。边缘是灰度值不连续的结果,这种不连续常可利用求导数方便地检测到,一般常用一阶和二阶导数来检测边缘。其中一阶导数的幅度值来检测边缘的存在,幅度峰值一般对应边缘位置。

不过为了简化计算,在实际中常用小区域模板卷积来近似计算偏导数。对Gx和Gy各用1个模板,所以需要2个模板组合起来以构成1个梯度算子。最简单的梯度算子是罗伯特交叉(Roberts cross)算子。

其实在unity的image effect中就包含了描边这个效果,而其中又有5种不同的方式,其中的一种叫做RobertsCrossDepthNormals便是利用了罗伯特算子,各位如果有兴趣的话可以参考。

0x06 小结

以上便是常见的几种利用深度信息来实现的视觉效果。
完整的项目可以到这里到这里下载:UnitySpecialEffectWithDepth
https://github.com/chenjd/UnitySpecialEffectWithDepth

各位如果觉得有趣的话,欢迎点个赞。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

发表评论

你的email不会被公开。必填项已用*标注

更多阅读
标签云