神奇的深度圖:復雜的效果,不復雜的原理


0x00 前言

本文是《有趣的深度圖》的第二篇文章,上一篇文章《有趣的深度圖:可見性問題的解法》中已經和大家介紹了深度圖在解決可見性問題中的應用。其實,利用深度信息我們可以實現很多有趣而又顯得“高大上”的效果。
不過這些效果雖然看上去高大上,但是一旦了解了原理就會發現實現這種效果其實是十分簡單的。
那么本文會包括以下四個有趣的效果在Unity中的實現:

  • 有點科幻的掃描網
  • 透過牆壁繪制背后的“人影”
  • 護盾/能量場效果
  • 邊緣檢測

0x01 獲取深度信息

為了利用深度信息來實現若干效果,我們首先需要獲取場景的深度信息。在移動游戲開發中常用的前向渲染路徑(Forward Rendering)下,我們需要手動設置相機,讓它提供場景的深度信息。

camera.depthTextureMode = DepthTextureMode.Depth;

如果在延遲渲染路徑(Deferred Lighting)下,由於延遲渲染需要場景的深度信息和法線信息來做光照計算,所以並不需要我們手動設置相機。

這樣我們就可以在shader中訪問_CameraDepthTexture來獲取保存的場景的深度信息,之后再利用UNITY_SAMPLE_DEPTH這個宏來處理_CameraDepthTexture的值,這樣我們就獲取了某個像素的深度值。

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

但是正如上一篇文章中所說,此時的深度值並非是線性的,因此我們常常需要利用另一個內建的方法Linear01Depth將結果轉化為線性的。這樣,我們就能將場景的深度信息渲染為一張灰度圖。

float linear01Depth = Linear01Depth(depth);

QQ截圖20170619232147.png

0x02 有點科幻的掃描網

不知道有沒有小伙伴玩過《無人深空》這款游戲,當初ps4版預售時我就用行動支持了這款看上去很有吸引力的沙盒游戲,當然第二天掛閑魚就是后話了。雖然這款游戲讓人感到有些失望,但是其中的一些畫面效果還是很有趣的,而且也和這篇文章的主題相關——利用場景的深度信息來實現一些科幻效果——比如說,在星球上用掃描儀進行掃描的效果。

nomansky.gif

我們也可以在Unity中實現類似的效果,關鍵就是利用場景的深度信息。

scaneffect.gif

因此如果項目使用了前向渲染路徑,我們就必須在腳本中手動將相機的depthTextureMode 設置為DepthTextureMode.Depth,如果是延遲渲染則不需要我們手動設置。

camera.depthTextureMode = DepthTextureMode.Depth;

其次,這種全屏效果常常作為屏幕特效(image effect)來實現,也就是說我們需要攝像機先將場景渲染成一副圖片,之后對這張圖片的像素做處理。設想一下如果不這樣做的話,我們不僅要計算場景內所有被渲染對象和攝像機的距離,還需要至少兩個pass,其中一個返回被渲染物體的正常顏色,另一個則來實現和掃描顏色的疊加。如果場景內被渲染的對象很多的話,這樣的操作效率就變得十分低下了。
所以,在cs腳本中我們還會用到OnRenderImage這個回調以獲取攝像機渲染的場景圖像。

void OnRenderImage(RenderTexture src, RenderTexture dst)
{
     //TODO
}

再次,隨着時間的流逝掃描網逐漸掃描整個場景顯然是一個動態的效果。因此我們還需要把時間這個因子也引入,時間影響了掃描網和起點的距離。當然,我們既可以在shader文件中考慮時間的影響,也能在cs文件中考慮時間的影響。

如果我們要直接在shader中獲取時間的信息的話,就需要用到unity的內置變量**float4 _Time : Time (t/20, t, t*2, t*3) **了。它的4個分量分別表示了t/20、t、t*2、t*3。因此,在shader中我們使用_Time.y就可以獲取當前的時間了,根據時間我們就能算出掃描網當前移動的大概距離了。

除此之外,我們當然也可以在cs文件中直接利用Time類和Update方法直接計算掃描網的移動距離,然后再將結果傳入shader。這樣,我們就完成了一個超級簡單的cs腳本:

 /*
 * Created by Chenjd
 * http://www.cnblogs.com/murongxiaopifu/
 */ 
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?那就更簡單了,現在我們獲取了相機渲染之后的場景圖,這樣圖上的每個像素只需要獲取自己的深度信息:

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

然后再和掃描網現在的位置做個對比——當然我們還可以加入掃描網的寬度這個概念——符合條件的像素顏色和掃描網的顏色進行疊加就可以了。最后為了更完美一點,我們還需要判斷一下深度值是否比1小,因為深度值在[0,1]這個區間內,而1對應的是遠裁切面,因此如果不判斷1的話,整個遠方最后都會被掃描網的顏色進行疊加。

if (linear01Depth < _ScanDistance && linear01Depth > _ScanDistance - _ScanWidth && linear01Depth < 1)
{
    float diff = 1 - (_ScanDistance - linear01Depth) / (_ScanWidth);
    _ScanColor *= diff;
    return col + _ScanColor;
}

完整的項目可以到這里到這里下載:UnitySpecialEffectWithDepth

0x03 透過牆壁繪制背后的“人影”

透過障礙物看到障礙物后的高亮目標,國內外很多游戲都會用到類似的效果。
刺客信條梟雄

這個看上去很有高大上的視覺效果,其實從創建一個unity的Unlit shader文件到最后完成這個效果只需要大概30s。

原理很簡單,即根據目標是否被遮擋返回不同的顏色即可。目標被障礙物遮住的部分其深度值必然要大於障礙物,因此我們可以用一個pass處理當深度值大於障礙物的時也就是目標被障礙物遮住的部分的顏色——例如我們返回紅色。

    Pass
    {
        ZTest Greater

        ...

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

再用另一個pass處理目標未被遮擋住的部分,也就是深度值小於障礙物時返回目標的正常顏色。

    Pass
    {
        ZTest Less 

        ...

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

不過牆后的敵人如果只是顯示一個紅色是否有點太單調了呢?還有很多游戲,它的透視效果是下面這樣的:目標身上多了一些描邊。
殺出重圍3人類革命
這個效果的實現其實也很簡單。我們可以根據觀察方向和目標多邊形的法線方向的夾角來判斷目標的邊緣——畢竟目標面向我們的面的法線和我們觀察方向的夾角相對較小,而邊緣部分的面的法線和我們的觀察方向的夾角顯然更大——這里的邊緣判斷用到了觀察方向,下文我們還會聊聊跟觀察方向無關的邊緣檢測。
QQ截圖20170625232518.png

所以,給牆后的目標描邊這件事就又變得十分簡單了。我們只需要在處理被遮擋部分的那個pass中返回的紅色變為與法線和觀察方向的夾角相關的一個值就好了。
為了實現這個目標,我們首先要獲取法線和觀察方向的信息。

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

之后再計算法線和觀察方向的夾角信息:

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

最后,只需要把這個值當作影響最后顏色輸出的因素就好了。

return _EdgeColor * NdotV;

seethewall4.gif
完整的項目可以到這里到這里下載:UnitySpecialEffectWithDepth

0x04 護盾/能量場效果

Winston_overwatch_gamemela.jpg

很多科幻游戲也有這種能量場或者護盾的效果。例如暴雪的守望先鋒中的猩猩溫斯頓的屏障發射器、光環系列的聖堂防衛者的能量護盾甚至一些手游中也有類似的效果,比如網易的光明大陸。
maxresdefault.jpg
這個效果的實現和原理其實也並不復雜。簡單的說可以分為以下這幾個部分:

  • 半透明效果
  • 相交高亮,主要指能量場和別的物體相交的地方是高亮顯示
  • 表面扭曲
  • 一個和觀察方向相關的描邊效果

首先我們要開啟透明混合並指定渲染隊列為透明。

SubShader
{
	ZWrite Off
	Cull Off
	Blend SrcAlpha OneMinusSrcAlpha

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

	...
}

之后像上一個例子那樣,根據觀察方向繪制能量場的邊緣。

//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)));

這樣,我們就得到了一個半透且帶有描邊效果球體,能量場已經初具雛形了。

unitytip3.gif

接下來,我們就要實現相交高亮的效果了。所謂的相交高亮指的是能量場和別的物體相交時,在相交處繪制出高亮效果。這時我們就要用到深度信息了。當能量場和某個物體相交時,二者的深度信息應該一致,基於這個對比深度信息,我們可以用來估計一個像素的“相交程度”。

需要注意的是,能量場的shader在執行時_CameraDepthTexture中只保存了場景中不透明物體的深度信息,因此這個時候無法從CameraDepthTexture中獲取能量場的深度信息,所以要在vert中計算頂點的深度,這里我利用了COMPUTE_EYEDEPTH這個內置的宏。在之后的frag內就可以很方便的獲取場景和能量場當前片元的深度了。

//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就得到了一個“相交程度”。

float diff = sceneZ - partZ;

float intersect = (1 - diff) * _IntersectPower;

unitytip4.gif

最后我們還需要實現一個能量場的扭曲效果。扭曲效果是游戲里面經常有的一個效果,其實也很簡單,我們只需要一張渲染能量場之前的場景的渲染圖,之后隨時間調整uv的偏移就可以模擬扭曲的效果了。

GrabPass
{
	"_GrabTempTex"
}

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

...

unitytip901.gif
完整的項目可以到這里到這里下載:UnitySpecialEffectWithDepth

0x05 邊緣檢測

邊緣檢測的目的是標識數字圖像中屬性顯著變化的點。圖像屬性中的顯著變化通常反映了屬性的重要變化。這些包括:

  • 深度上的不連續
  • 表面法線方向不連續
  • 顏色不連續
  • 亮度不連續

QQ截圖20170623191104.png

需要注意的是邊緣可能與觀察方向有關——也就是說邊緣可能隨着觀察方形的不同而變化,例如上文中的描邊實現;也可能與觀察方向無關——這通常反映被觀察物體的屬性如表面紋理和表面形狀。在這個部分,我們的關注點主要是后者。

因此,根據不同的屬性變化也有很多種策略來處理邊緣檢測,例如利用深度、利用法線、利用深度+法線、利用顏色等等。邊緣是灰度值不連續的結果,這種不連續常可利用求導數方便地檢測到,一般常用一階和二階導數來檢測邊緣。其中一階導數的幅度值來檢測邊緣的存在,幅度峰值一般對應邊緣位置。
11.png
不過為了簡化計算,在實際中常用小區域模板卷積來近似計算偏導數。對Gx和Gy各用1個模板,所以需要2個模板組合起來以構成1個梯度算子。最簡單的梯度算子是羅伯特交叉(Roberts cross)算子。

Roberts-Cross-convolution-filter.png

其實在unity的image effect中就包含了描邊這個效果,而其中又有5種不同的方式,其中的一種叫做RobertsCrossDepthNormals便是利用了羅伯特算子,各位如果有興趣的話可以參考。

0x06 小結

以上便是常見的幾種利用深度信息來實現的視覺效果。
完整的項目可以到這里到這里下載:UnitySpecialEffectWithDepth
各位如果覺得有趣的話,歡迎點個贊。

ref:

【1】Siggraph2011_SpecialEffectsWithDepth_WithNotes。“Special Effects with Depth” talk at SIGGRAPH – Unity Blog

【2】Unity Shaders - Depth and Normal Textures (Part 2)

【3】題圖來自《殺手5:赦免》

-華麗的分割線-
最后打個廣告,歡迎支持我的書《Unity 3D腳本編程》

歡迎大家關注我的公眾號慕容的游戲編程:chenjd01


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM