轉自:http://m.blog.csdn.net/puppet_master/article/details/70199330
簡介
千等萬等終于等到了《恥辱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();
if (mainCam == null)
return;
Transform addCamTransform = transform.FindChild("additionalDistortCam");
if (addCamTransform != null)
DestroyImmediate(addCamTransform.gameObject);
GameObject additionalCamObj = new GameObject("additionalDistortCam");
additionalCam = additionalCamObj.AddComponent();
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上的方法進行制作。