yuan 发表于 2020-11-24 08:02

Unity实现地图扫描效果

简介

游戏中许多地方会需要用到扫描效果,比如无人深空中的扫描,手雷扔出去前的预判范围,死亡搁浅等等,笔者在扫描的基础上作了一定个人发挥,使扫描支持自定义贴图,最终效果如下:

unity 地图扫描效果 scan shader
https://www.zhihu.com/video/1248720342061064192
同时再附上相关游戏的图示:


无人深空
死亡搁浅
全境封锁
基础概念

诚然,我们可以非常直接的使用单独物体并附上相关shader,在vertex阶段计算顶点位置,在frag阶段计算距离中心点位置的距离并显示相对应的颜色即可。但往往扫描这件事情是对整个地图进行的,中间扫描到的物体不能完全保证就是使用扫描材质,而且如果是大量物体,还会导致大量的多余计算。
换一种方式,我们使用屏幕后处理,只处理要显示的像素,那就可以省下大量的性能
那么怎样才能获取到对应的位置信息呢,这就可以利用到摄像机的深度贴图
关于深度图部分,建议优先阅读文章神奇的深度图:复杂的效果,不复杂的原理,同时这里参考了Shaders Case Study - No Man's Sky: Topographic Scanner视频中的实现,以上图片也是部分截取,视频对深度图以及效果的实现都有详细介绍,推荐观看。
了解完毕深度相关的知识后,我们可以利用以下相关代码来测试深度值的获取:
//cs部分
//保存深度图
scanCam.depthTextureMode |= DepthTextureMode.Depth;

//shader frag部分
//获取摄像机的深度图取样其深度值
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
//将深度归一化
float linearDepth = Linear01Depth(depth);
//转换为黑白色输出
return fixed4(linearDepth,linearDepth,linearDepth,linearDepth);
放下对比图:
原图
深度图
我们可以非常明显的看到,越往远处颜色越亮,也就代表深度值越接近1,像素点的距离也就越远,因此只要在frag阶段对比扫描深度和像素深度就可以非常快速的实现以相机为中心的扫描,核心代码如下
fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);

    //计算深度
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
    float linearDepth = Linear01Depth(depth);

    //通过像素深度是否在扫描的一定范围
    if(linearDepth<_ScanDepth&&linearDepth>_ScanDepth-_ScanWidth/_CamFar&&linearDepth<1)
    {
      //做一个渐变效果
      fixed scanPercent = 1 - (_ScanDepth-linearDepth)/(_ScanWidth/_CamFar);
      return lerp(col,fixed4(1,1,0,1),scanPercent);
    }

    return col;
}效果如下:
实际上做到这里,如果是做基于摄像机的扫描,我们的工作已经基本完成了,但是要实现类似死亡搁浅或全境封锁中的效果,我们还需要介绍怎么获取屏幕像素坐标以及像素法线。
获取屏幕像素的世界坐标

由于我们使用的是屏幕后处理,不能直接获取到屏幕坐标,需要使用一些trick。这里参考了Unity Shader笔记(三) 在片段着色器中获取世界坐标,文章讲的非常清晰,这里不多赘述,简单总结归纳如下:
使用矩阵方式,我们可以得到像素的屏幕坐标以及深度坐标,使用相机的投影逆矩阵就可以计算出像素位置,这个方法适用性较好,但在需要在frag阶段做矩阵计算,效率较低。由于屏幕后处理的特殊性,最终渲染阶段就是一个quad平面,而且是非常规整的矩形,其vert阶段实际只计算4次,可以在这一阶段把相机原平面相对于相机的坐标位置保存,到了frag阶段就会自动做插值,得到自相机到远平面的像素点的向量,用这个向量加上相机自身的世界坐标就能够得到像素的坐标,但需要注意相机是正交还是透视。
为了实现效率,我们使用透视相机加上第二种方案实现。
有了屏幕像素的世界坐标,我们就可以非常方便的计算除了相机之外其他点的对应距离,此时就能实现全境封锁中的手雷效果:
使用屏幕法线信息

此时的你觉得效果还是不够炫酷,想要给扫描做出点花样,加点花纹之类的,这时候就需要我们的法线图登场。
法线图,顾名思义就是记录了当前屏幕各个像素法线信息的图像,我们这时候需要将相机的depthTextureMode设置为DepthNormals模式,shader部分如下:
sampler2D _CameraDepthNormalsTexture;

fixed4 frag (v2f i) : SV_Target
{
    float tempDepth;
    half3 normal;
    //从法线深度图中获取对应的值
    DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), tempDepth, normal);
    //让法线由屏幕坐标转化为世界坐标
    normal = mul( (float3x3)_CamToWorld, normal);
    //对法线方向取绝对值
    return fixed4(abs( normal),1);
}我们可以得到如下效果:
从图中我们可以看到主要有三种颜色,红绿蓝,分别对应了rgb,对应到向量上就是xyz轴的值,举个例子,地面非常绿即g通道值非常大,符合(0,1,0)向量,由此可见,获取的向量是正确的。
此时我们拥有,像素点的世界坐标,像素点的法线,但还差非常关键的一点,uv映射关系,由于是后处理,我们无法得到整体mesh信息,所以我们需要构造一套计算uv的方式。
这里采用了法线投影的计算方式:
我们将整个世界划分为无数个等边的正方形,让每一个像素点归一化,每一个像素点相对于所在的正方形只要相对位置相同,获取到的颜色也相同,类似于体材质的概念颜色的计算方式为默认所在正方体周围都有贴图,然后分别在三个坐标平面里取值最后,将获取的各个平面颜色值与该像素点的法线在该平面的法线上的投影值相乘后相加,最终得到颜色
相关代码:
fixed4 frag (v2f i) : SV_Target
{
    float tempDepth;
    half3 normal;
    DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), tempDepth, normal);
    normal = mul( (float3x3)_CamToWorld, normal);
    normal = normalize(max(0, (abs(normal) - _Smoothness)));
    //return fixed4(abs( normal),1);

    fixed4 col = tex2D(_MainTex, i.uv);
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
    float linearDepth = Linear01Depth(depth);
    //return fixed4(linearDepth,linearDepth,linearDepth,linearDepth);

    float3 pixelWorldPos =_WorldSpaceCameraPos+linearDepth*i.interpolatedRay;
    float pixelDistance = distance(pixelWorldPos , _ScanCenter);

    //计算划分出的格子的坐标位置,归一化
    float3 modulo = pixelWorldPos - _MeshWidth*floor(pixelWorldPos/_MeshWidth);
    modulo = modulo/_MeshWidth;

    //在各个平面上计算对应的颜色吸收值
    fixed4 c_right = tex2D(_ScanTex,modulo.yz)*normal.x;
    fixed4 c_front = tex2D(_ScanTex,modulo.xy)*normal.z;
    fixed4 c_up = tex2D(_ScanTex,modulo.xz)*normal.y;
    //混合
    fixed4 scanMeshCol =saturate(c_up +c_right+c_front);

    //实现波纹扩散效果
    if(_ScanRange - pixelDistance > 0 && _ScanRange - pixelDistance <_ScanWidth &&linearDepth<1){
      fixed scanPercent = 1 - (_ScanRange - pixelDistance)/_ScanWidth;
      col = lerp(col,scanMeshCol,scanPercent);
    }

    return col;
}最终效果:
这个方案本身存在一定缺陷,无法达到完美实现,但在相关信息缺失的情况下,只能尽可能的去追求理想效果,如果有更好的方案,欢迎私信。
小结

这次做这个效果最主要的动机还是想要在shader圈试试水,再加上看到youtube上的实现,不禁就想做一次尝试,也希望在以后能够为读者们带来更多优秀的内容。
项目源码地址:https://github.com/cyclons/DepthShader
参考文章

神奇的深度图:复杂的效果,不复杂的原理
Shaders Case Study - No Man's Sky: Topographic Scanner
Unity Shader笔记(三) 在片段着色器中获取世界坐标
Unity Shader 深度值重建世界坐标
Unity3D 屏幕空间雪场景Shader渲染
页: [1]
查看完整版本: Unity实现地图扫描效果