1. 程式人生 > >Unity Shader-熱空氣扭曲效果

Unity Shader-熱空氣扭曲效果

簡介

千等萬等終於等到了《恥辱2》打折,本以為可以爽一發了,然而各種出問題,先是steam下載速度奇慢無比,下了三天晚上好不容易下完的遊戲,第一次開啟給彈了個3D11CreateDeviceAndSwapChain Failed,折騰半天裝了個補丁算是能開啟遊戲了,然而過完新手教學顯示卡驅動就崩了,崩了!崩了,連崩三回,差點想把坑爹的A卡從機箱掏出來順著窗戶扔出去,後來想想,為了樓下同學的生命安全,我還是忍了。好在AMD有專門為《恥辱2》R9380崩潰打了個補丁,算是拯救我於水火之中了。《恥辱2》用了ID Tech5衍生的Void引擎,看起來畫面比《恥辱1》用的虛幻3好了不少。先來張帥帥噠截圖,最近每天沉迷於殺殺殺,感覺自己好頹廢:

一時間差點忘了自己是個程式設計師,差點變成遊戲鑑賞部落格,尷尬...下面步入正題,今天打遊戲的時候路過了一個火爐,看到了火爐旁邊的熱空氣扭曲的效果,感覺做的還是蠻逼真的,今天打算自己實現一發玩一玩:

實現原理

扭曲效果是遊戲裡面經常有的一個效果,說道扭曲效果,一般就是當前的畫面發生了扭曲,在現實世界中一般是折射導致的,但是在圖形學中,我們要模擬這種效果,原理就大不一樣了。首先,我們並不會真正影響光線的傳播,只是用uv的偏移來模擬扭曲的效果。有一種全屏的扭曲效果,這種是基於屏幕後處理的,可以參考前面的一篇文章螢幕水波紋效果,但是,往往我們並不希望全螢幕都發生扭曲,而是隻希望某些地方發生了扭曲,比如上面的火爐的做法,拼關的同學肯定是希望在火爐的上方放一個特效片,就能夠出扭曲的效果。那麼,我們的這個片就需要是一個可以顯示後面所有物體的片,換句話說,我們需要在這個面片上渲染面片後面所有的東西,這樣,面片看起來就是透明的了。然後我們在取樣uv的時候將uv進行偏移,就能夠得到扭曲的效果了。恩,聽起來很簡單的樣子,但是我們要怎麼得到面片後面的所有東西呢?其實Unity已經為我們提供了這樣的一個功能,GrabPass。下面看一下Grabpass的使用。

GrabPass

GrabPass是Unity為我們提供的一個很方便的功能,可以直接將當前螢幕內容渲染到一張貼圖上,我們可以直接在shader中使用這張貼圖而不用自己去實現渲染到貼圖這樣的一個過程,大大的方便了我們的shader編寫。GrabPass的使用非常簡單,我們在寫vertex fragment shader的時候都需要寫一個pass,GrabPass也是一個pass,只不過是Unity為我們實現好的一個pass。我們只需要在我們正常的Pass前面加一個GrabPass{}就可以了。 官方文件上有兩種GrabPass的寫法,第一種是直接GrabPass{}的寫法,這種寫法抓屏的圖片就直接存到_GrabTexture這個系統預定義的貼圖變數中了,我們可以直接訪問該貼圖,但是這種寫法會導致每個使用GrabPass的物體進行一次這種曠日持久的抓屏操作!如果用這種shader的物體多了的話,想想就很可怕。另一種是GrabPass{"TextureName"}的寫法,其中TextureName是我們自定義的一個貼圖名稱,這種寫法,Unity每幀只會為第一個使用了該名稱的物體進行抓屏操作,之後的就可以複用這張貼圖了。所以,我們還是使用第二種方式更好一點。下面附上一份最簡單的抓屏程式碼:
//Grabpass shader
//by: puppet_master
//2017.4.23
Shader "ApcShader/GrabPass" 
{
	SubShader
	{
		ZWrite Off
		//GrabPass
		GrabPass
		{
			//此處給出一個抓屏貼圖的名稱,抓屏的貼圖就可以通過這張貼圖來獲取,而且每一幀不管有多個物體使用了該shader,只會有一個進行抓屏操作
			//如果此處為空,則預設抓屏到_GrabTexture中,但是據說每個用了這個shader的都會進行一次抓屏!
			"_GrabTempTex"
		}

		Pass
		{
			Tags
			{ 
				"RenderType" = "Transparent"
				"Queue" = "Transparent+1"
			}

			CGPROGRAM
			sampler2D _GrabTempTex;
			float4 _GrabTempTex_ST;
			#include "UnityCG.cginc"
			struct v2f
			{
				float4 pos : SV_POSITION;
				float4 grabPos : TEXCOORD0;
			};

			v2f vert(appdata_base v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//計算抓屏的位置,其中主要是將座標從(-1,1)轉化到(0,1)空間並處理DX和GL紋理反向的問題
				o.grabPos = ComputeGrabScreenPos(o.pos);
				return o;
			}

			fixed4 frag(v2f i) : SV_Target
			{
				//根據抓屏位置取樣Grab貼圖,tex2Dproj等同於tex2D(grabPos.xy / grabPos.w)
				fixed4 color = tex2Dproj(_GrabTempTex, i.grabPos);
				return 1 - color;
			}

			#pragma vertex vert
			#pragma fragment frag
			ENDCG
		}
	}
}
我們找個面片,附上這個shader的材質。為了更方便的看一下效果,我們就參照官網的寫法,直接將最終輸出的顏色反向,也就是1-原顏色作為輸出(這個顏色不禁讓我想起了宇智波鼬的月讀........)
看一下這個shader用到的幾個函式,第一個是ComputeGrabScreenPos這個函式,我們從UnityCG.cginc中可以找到這個函式的實現:
inline float4 ComputeGrabScreenPos (float4 pos) {
	#if UNITY_UV_STARTS_AT_TOP
	float scale = -1.0;
	#else
	float scale = 1.0;
	#endif
	float4 o = pos * 0.5f;
	o.xy = float2(o.x, o.y*scale) + o.w;
#ifdef UNITY_SINGLE_PASS_STEREO
	o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
	o.zw = pos.zw;
	return o;
}
我們傳遞進來的引數是經過mvp變換後的頂點座標,傳入之後這個函式主要做了兩件事情,第一個是處理DX和OpenGL紋理座標差異導致的問題,這個之前的文章有記錄過。第二件事主要就是將轉化到標準裁剪空間(-1,1)區間的頂點轉化到(0,1)區間。按照Unity的寫法,本人推測,這個GrabPass獲取的螢幕貼圖應該是基於視空間的,而在這個資訊傳遞到fragment shader後,用了tex2Dproj函式進行取樣,tex2Dproj(i.xy)應該等同於tex2D(i.xy/i.w),也就是說這個取樣點座標進行了一次投影變換。


扭曲效果的實現

準備工作完成,下面步入正題,來看看扭曲效果的實現。首先,要扭曲,就肯定要動,這個shader還是得需要Time系列的變數進行驅動。不過這只是其中一個條件,由於shader是高度並行化的計算,我們沒有辦法區分每個畫素到底需要偏移多少。在螢幕水波紋效果中,我們是通過計算當前畫素點到螢幕中心位置的距離作為偏移值的,對於後處理這樣做可能比較方便,但是對於普通物體上使用的shader就沒有那麼簡單了。比如,我們同樣是讓取樣座標按照sin值進行偏移:
fixed4 frag(v2f i) : SV_Target
			{
				i.grabPos.x += _DistortStrength * sin(_Time.y * 10);
				i.grabPos.y += _DistortStrength * sin(_Time.y);
				fixed4 color = tex2Dproj(_GrabTempTex, i.grabPos);
				return 1 - color;
			}
那麼所有的頂點就都會按照一致的方向進行偏移: 為了讓偏移變得隨機,我們就要引入一個能夠隨機化輸出的東東,也就是噪聲圖。比如我們找到了一張這個樣子的噪聲圖:

然後,只需要用一個連續變化的值去採這個噪聲圖,就可以得到不連續的隨機輸出偏移值。下面附上扭曲效果的實現:
//Distort shader
//by: puppet_master
//2017.4.24
Shader "ApcShader/Distort" 
{
	Properties
	{
		_DistortStrength("DistortStrength", Range(0,1)) = 0.2
		_DistortTimeFactor("DistortTimeFactor", Range(0,1)) = 1
		_NoiseTex("NoiseTexture", 2D) = "white" {}
	}
	SubShader
	{
		ZWrite Off
		Cull Off
		//GrabPass
		GrabPass
		{
			//此處給出一個抓屏貼圖的名稱,抓屏的貼圖就可以通過這張貼圖來獲取,而且每一幀不管有多個物體使用了該shader,只會有一個進行抓屏操作
			//如果此處為空,則預設抓屏到_GrabTexture中,但是據說每個用了這個shader的都會進行一次抓屏!
			"_GrabTempTex"
		}

		Pass
		{
			Tags
			{ 
				"RenderType" = "Transparent"
				"Queue" = "Transparent + 100"
			}

			CGPROGRAM
			sampler2D _GrabTempTex;
			float4 _GrabTempTex_ST;
			sampler2D _NoiseTex;
			float4 _NoiseTex_ST;
			float _DistortStrength;
			float _DistortTimeFactor;
			#include "UnityCG.cginc"
			struct v2f
			{
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
				float4 grabPos : TEXCOORD1;
			};

			v2f vert(appdata_base v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				o.grabPos = ComputeGrabScreenPos(o.pos);
				o.uv = TRANSFORM_TEX(v.texcoord, _NoiseTex);
				return o;
			}

			fixed4 frag(v2f i) : SV_Target
			{
				//首先採樣噪聲圖,取樣的uv值隨著時間連續變換,而輸出一個噪聲圖中的隨機值,乘以一個扭曲快慢係數
				float4 offset = tex2D(_NoiseTex, i.uv - _Time.xy * _DistortTimeFactor);
				//用取樣的噪聲圖輸出作為下次取樣Grab圖的偏移值,此處乘以一個扭曲力度的係數
				i.grabPos.xy -= offset.xy * _DistortStrength;
				//uv偏移後去取樣貼圖即可得到扭曲的效果
				fixed4 color = tex2Dproj(_GrabTempTex, i.grabPos);
				return color;
			}

			#pragma vertex vert
			#pragma fragment frag
			ENDCG
		}
	}
}
為了更加應景,我搜颳了一下我的資源庫,找到了一個火把,2333:
然後在火把附近放一個面片,用上我們的扭曲shader:
最終效果如下圖所示:

基於後處理的優化效果

GrabPass非常耗時,在安卓平臺也會有問題,雖然對於安卓機的效能,用shader lod直接幹掉扭曲效果也是一個不錯的選擇,不過這個畢竟是下策,首先還是要解決這個問題。正常渲染是往frame buffer中渲染,但是grabpass應該是從當前的frame buffer中將內容再讀出來,從視訊記憶體往記憶體中拷貝,應該是一個阻塞的過程,我記得之前一幀渲染過3000ms,簡直可怕。PS:這種情況在兩個(或多個)相機渲染,後面的相機沒有Clear並且在後面的相機上掛了後處理的時候也會出現這種情況,猜測原因也是因為在後面的相機進行後處理時需要上一個相機的內容,然而這個東東已經在frame buffer中了,所以後處理如果要在上層相機運用,最好還是慎重考慮一下。關於用後處理卡的問題,這篇文章解釋得很好。文章中給了幾種解決方案,一種是關抗鋸齒,一個是用GL3.0,最後一個是直接改為用渲染到紋理。記得以前還看過一個帖子,不過忘記連結了,這個做法比較極端,就是最終渲染的結果都不走frame buffer,而是都渲染到一個紋理上。然後所有的後處理都在這個紋理上進行,完全繞開了OnRenderImage。額,不小心扯遠了,只是希望能給和我遇到一樣問題的倒黴蛋一點參考,下面進行正題。 既然GrabPass比較費,那麼最簡單的,我們可能會想直接用另外一個相機去渲染這個場景到一個RenderTarget上,然後用這個RenderTarget代替我們上面用的GrabTexture。不過這種做法會導致DrawCall翻倍,如果我們的場景中內容較少,比較適合用這種方法。或者我們可以設定另一個相機的層級,使之只渲染某些內容,這樣也可以降低一些開銷。不過這裡就不用這種方式了。之前看到了一篇文章,作者給了這樣的一個思路,感覺非常巧妙。簡而言之,這個方法作扭曲的部分是用全屏後處理進行的,但是全屏都扭曲了,我們其實只需要扭曲一部分地方,所以我們需要一個Mask圖來控制,而這張Mask圖我們就可以直接用另一個相機渲染出來,其實就是我們上面用到的特效片,渲染到一個RT上就可以了。相比於用另一個攝像機把場景中的東西都渲染一遍,這種方式只是需要額外渲染一個片外加一次全屏後處理操作,兩者各有千秋,視具體情況而定。 我們先寫一個全屏扭曲的shader,首先,需要後處理,我們繼承這個已經用了無數次的PostEffectBase類,實現後處理的C#部分程式碼:
/********************************************************************
 FileName: DistortEffect.cs
 Description: 螢幕扭曲效果
 Created: 2017/04/27
 by: puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DistortEffect : PostEffectBase {

    //扭曲的時間係數
    [Range(0.0f, 1.0f)]
    public float DistortTimeFactor = 0.15f;
    //扭曲的強度
    [Range(0.0f, 0.2f)]
    public float DistortStrength = 0.01f;
    //噪聲圖
    public Texture NoiseTexture = null;

    public void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (_Material)
        {
            _Material.SetTexture("_NoiseTex", NoiseTexture);
            _Material.SetFloat("_DistortTimeFactor", DistortTimeFactor);
            _Material.SetFloat("_DistortStrength", DistortStrength);
            Graphics.Blit(source, destination, _Material);
        }
        else
        {
            Graphics.Blit(source, destination);
        }
    }
}
然後shader部分,扭曲的原理與上面一樣,只是處理的物件變了一下,直接處理OnRenderImage傳來的MainTex即可:
//全螢幕扭曲Shader
//by:puppet_master
//2017.4.28

Shader "Custom/DistortPostEffect"
{
	Properties
	{
		_MainTex("Base (RGB)", 2D) = "white" {}
		_NoiseTex("Base (RGB)", 2D) = "black" {}//預設給黑色,也就是不會偏移
	}

	CGINCLUDE
	#include "UnityCG.cginc"
	uniform sampler2D _MainTex;
	uniform sampler2D _NoiseTex;
	uniform float _DistortTimeFactor;
	uniform float _DistortStrength;

	fixed4 frag(v2f_img i) : SV_Target
	{
		//根據時間改變取樣噪聲圖獲得隨機的輸出
		float4 noise = tex2D(_NoiseTex, i.uv - _Time.xy * _DistortTimeFactor);
		//以隨機的輸出*控制係數得到偏移值
		float2 offset = noise.xy * _DistortStrength;
		//畫素取樣時偏移offset
		float2 uv = offset + i.uv;
		return tex2D(_MainTex, uv);
	}

	ENDCG

	SubShader
	{
		Pass
		{
			ZTest Always
			Cull Off
			ZWrite Off
			Fog{ Mode off }

			CGPROGRAM
			#pragma vertex vert_img
			#pragma fragment frag
			#pragma fragmentoption ARB_precision_hint_fastest 
			ENDCG
		}
	}
	Fallback off
}
這樣,整個螢幕就都扭曲了,動圖如下(趕腳好像來到了沙漠一樣.....):
這裡我把扭曲的強度設定得高一些,感覺也可以直接當一些全屏後處理的樣子,比如扭曲,水幕效果:
我們有了全屏的扭曲效果之後,下面我們考慮要怎麼把需要扭曲的部分摳出來。那麼,第一個想到的就是Mask圖,我們可以給一個Mask圖,作為權重,白色為需要偏移的權重,黑色為無偏移的權重,這樣,我們就可以控制哪個地方需要扭曲。但是,這裡,我們的Mask圖需要是一個動態的Mask圖,因為相機會移動,所以,我們需要實時地生成這張Mask圖。在描邊效果這篇文章中,我們用過類似的方法。這裡,我們故技重施,將需要扭曲的部分,也就是上面我們用的面片渲染到一張RenderTarget上,首先,我們還是建立一個新的攝像機,然後通過在OnPreRender函式中用RenderWithShader,將面片渲染到一張RT上(這個RT可以多降低一些解析度),渲染的shader就用一個純白色的shader就可以了。比如下面的這個Shader:
//Mask圖生成shader
//by:puppet_master
//2017.5.3

Shader "ApcShader/MaskObjPrepass"
{
	//子著色器	
	SubShader
	{
		Pass
		{	
			Cull Off
			CGPROGRAM
			#include "UnityCG.cginc"
			
			struct v2f
			{
				float4 pos : SV_POSITION;
			};
			
			v2f vert(appdata_full v)
			{
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target
			{
				//這個Pass直接輸出顏色
				return fixed4(1,1,1,1);
			}
			
			//使用vert函式和frag函式
			#pragma vertex vert
			#pragma fragment frag
			ENDCG
		}
	}
}
下面附上扭曲效果的C#指令碼:
/********************************************************************
 FileName: DistortEffect.cs
 Description: 螢幕扭曲效果
 Created: 2017/04/27
 by: puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DistortEffect : PostEffectBase {

    //扭曲的時間係數
    [Range(0.0f, 1.0f)]
    public float DistortTimeFactor = 0.15f;
    //扭曲的強度
    [Range(0.0f, 0.2f)]
    public float DistortStrength = 0.01f;
    //噪聲圖
    public Texture NoiseTexture = null;
    //渲染Mask圖所用的shader
    public Shader maskObjShader = null;
    //降取樣係數
    public int downSample = 4;

    private Camera mainCam = null;
    private Camera additionalCam = null;
    private RenderTexture renderTexture = null;

    public void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (_Material)
        {
            _Material.SetTexture("_NoiseTex", NoiseTexture);
            _Material.SetFloat("_DistortTimeFactor", DistortTimeFactor);
            _Material.SetFloat("_DistortStrength", DistortStrength);
            _Material.SetTexture("_MaskTex", renderTexture);
            Graphics.Blit(source, destination, _Material);
        }
        else
        {
            Graphics.Blit(source, destination);
        }
    }

    void Awake()
    {
        //建立一個和當前相機一致的相機
        InitAdditionalCam();
    }

    private void InitAdditionalCam()
    {
        mainCam = GetComponent<Camera>();
        if (mainCam == null)
            return;

        Transform addCamTransform = transform.FindChild("additionalDistortCam");
        if (addCamTransform != null)
            DestroyImmediate(addCamTransform.gameObject);

        GameObject additionalCamObj = new GameObject("additionalDistortCam");
        additionalCam = additionalCamObj.AddComponent<Camera>();

        SetAdditionalCam();
    }

    private void SetAdditionalCam()
    {
        if (additionalCam)
        {
            additionalCam.transform.parent = mainCam.transform;
            additionalCam.transform.localPosition = Vector3.zero;
            additionalCam.transform.localRotation = Quaternion.identity;
            additionalCam.transform.localScale = Vector3.one;
            additionalCam.farClipPlane = mainCam.farClipPlane;
            additionalCam.nearClipPlane = mainCam.nearClipPlane;
            additionalCam.fieldOfView = mainCam.fieldOfView;
            additionalCam.backgroundColor = Color.clear;
            additionalCam.clearFlags = CameraClearFlags.Color;
            additionalCam.cullingMask = 1 << LayerMask.NameToLayer("Distort");
            additionalCam.depth = -999;
            //解析度可以低一些
            if (renderTexture == null)
                renderTexture = RenderTexture.GetTemporary(Screen.width >> downSample, Screen.height >> downSample, 0);
        }
    }

    void OnEnable()
    {
        SetAdditionalCam();
        additionalCam.enabled = true;
    }

    void OnDisable()
    {
        additionalCam.enabled = false;
    }

    void OnDestroy()
    {
        if (renderTexture)
        {
            RenderTexture.ReleaseTemporary(renderTexture);
        }
        DestroyImmediate(additionalCam.gameObject);
    }

    //在真正渲染前的回撥,此處渲染Mask遮罩圖
    void OnPreRender()
    {
        //maskObjShader進行渲染
        if (additionalCam.enabled)
        {
            additionalCam.targetTexture = renderTexture;
            additionalCam.RenderWithShader(maskObjShader, "");
        }
    }
}
還是上面的測試場景,我們將面片改為Distort層級,然後可以直接給這個面片設定一個透明的材質,比如最簡單的粒子的shader,讓它正常渲染不可見即可:
通過上面的指令碼,我們臨時將這個Mask圖輸出到螢幕上(為了效能好一些,降取樣比較多,已經有鋸齒了,不過在正式使用的時候是看不出來的):
有了Mask圖,我們就可以根據Mask圖的權重進行修改了,白色的地方是需要扭曲的,黑色的地方不需要扭曲,我們將上面的shader中的offest用這個mask取樣圖進行修正就能夠得到最終的扭曲效果了。後處理版本的shader如下:
//全螢幕扭曲Shader
//by:puppet_master
//2017.5.3

Shader "Custom/DistortPostEffect"
{
	Properties
	{
		_MainTex("Base (RGB)", 2D) = "white" {}
		_NoiseTex("Noise", 2D) = "black" {}//預設給黑色,也就是不會偏移
		_MaskTex("Mask", 2D) = "black" {}//預設給黑色,權重為0
	}

	CGINCLUDE
	#include "UnityCG.cginc"
	uniform sampler2D _MainTex;
	uniform sampler2D _NoiseTex;
	uniform sampler2D _MaskTex;
	uniform float _DistortTimeFactor;
	uniform float _DistortStrength;

	fixed4 frag(v2f_img i) : SV_Target
	{
	
		//根據時間改變取樣噪聲圖獲得隨機的輸出
		float4 noise = tex2D(_NoiseTex, i.uv - _Time.xy * _DistortTimeFactor);
		//以隨機的輸出*控制係數得到偏移值
		float2 offset = noise.xy * _DistortStrength;
		//取樣Mask圖獲得權重資訊
		fixed4 factor = tex2D(_MaskTex, i.uv);
		//畫素取樣時偏移offset,用Mask權重進行修改
		float2 uv = offset * factor.r + i.uv;
		return tex2D(_MainTex, uv);
	}

	ENDCG

	SubShader
	{
		Pass
		{
			ZTest Always
			Cull Off
			ZWrite Off
			Fog{ Mode off }

			CGPROGRAM
			#pragma vertex vert_img
			#pragma fragment frag
			#pragma fragmentoption ARB_precision_hint_fastest 
			ENDCG
		}
	}
	Fallback off
}
扭曲效果動態圖如下: 通過後處理製作的熱空氣扭曲效果與GrabPass的效果大致相同,雖然多了全屏後處理操作,但是能夠避免安卓機上GrabPass讀幀快取卡死的問題,而且也不需要DrawCall翻倍,對於複雜的場景來說相對效率更高一些。如果場景比較簡單,也可以使用另一個相機渲染場景到RT上的方法進行製作。