大萌喵的著色器特效第二發---相交高亮(掃描效果)

喵嗚 ~ 在切換到嚴肅臉之前, 首先感謝大家對大萌喵的支持>_<!!!

大萌喵會繼續努力把Shader講得蠢萌蠢萌的^_^

大萌喵現在還只是學生黨一枚, 對圖形學和渲染技術的理解依然比較膚淺, 如果文章中有不明確甚至出錯的地方, 煩請前輩們斧正啦 ~

OK, 我們進入Warming Up環節.

相交高亮(Intersection Highlight)是個啥

相交高亮, 是一種附加在Mesh上的著色器特效, 其功能是將所有其他穿過該Mesh表面的截面輪廓繪製出來, 產生一種類似於掃描一樣的效果. 多用於科幻類遊戲中.

在這裡大萌喵要檢討下 ... 這種相交高亮的特效出現的頻次真心不低, 我能確切想起來的實際應用遊戲便有殺戮地帶系列, 質量效應系列, 泰坦隕落系列死亡空間系列. 不過大萌喵在youtube上找了好幾圈也沒有找到一個包含了這個效果的視頻 ... 所以說只能貼上這張圖啦. (喵, 下次玩遊戲要邊玩邊截圖了哈哈哈哈)

所以說我們要幹啥?

根據攝像機的CameraDepthTexture(深度紋理? 不知道這麼翻譯對不對)繪製相交區域的高亮顏色.

想看懂這篇文章, 我得知道啥?

對著色器的混合模式, 深度測試, 點元著色器和片元著色器有一定了解. 寫作過程中我也會貼上一些可供查閱的資料.

我順帶著也打算講解下一個坐標轉換的原理, 如果想要看懂的話需要一定的線性代數基礎.

看完了這篇文章, 我能得到啥?

你會知道一種優雅地使用DepthBuffer的方法.

以及, 說好的源代碼.

大萌喵.不正常模式.SetStatus(false);

大萌喵.SetFace (Face.嚴肅臉);

相交高亮著色器工作原理

獲取當前攝像機渲染的場景的DepthBuffer, 在渲染當前模型的時候判斷每一個經過坐標變換的片元的世界坐標Z是否和DepthBuffer的對應點深度足夠接近. 如果足夠接近, 則將其渲染成另一種顏色.

首先, 假設我們什麼都不知道

還是那個熟悉的水壺, 只不過加上了一台優雅的Macbook(不過為毛那個桌面長得有點像OpenSUSE)和一個凌空飛舞放蕩不羈的鍵盤. 當然了這不關鍵, 關鍵的是後面的那個黃色的正方體.

我們要實現的就是那個正方體的材質. 從圖中我們清楚的看到, 水壺, 電腦和鍵盤在正方體外面的部分是非常正常的, 在正方體內部的部分蒙上了一層黃色. 但是和正方體的相交截面的外輪廓被繪製成了藍顏色.

到此, 我們能夠推斷出來的事實有:

  1. Blend Mode為: Blend SrcAlpha OneMinusSrcAlpha 原因很簡單, 因為我們能夠通過正方體看到其後面的物體, 這說明正方體本身的顏色和原本的ColorBuffer的Alpha值被"平分秋色"後進行了混合. 依然看不懂的童鞋請參見Unity官方文檔.
  2. RenderQueue為Transparent 很明顯, 我們當然是希望這個正方體在Geometry後渲染出來, 這樣才能透過它看到優先渲染的Opaque Materials. 關於Render Order的詳細描述可以看Unity官方文檔.
  3. 正因為我們的正方體是被後渲染出來的, 所以我們可以通過當前的ColorBuffer或者是DepthBuffer等資源來以某種方式處理相交截面.
  4. 但是不管截面到底是怎麼被處理出來的, 我們必須得知道屏幕上某點的世界坐標相對於正方體某個片元的世界坐標的相對關係.

很明顯, 我們要做的就是優雅地解決第四個問題.

如何優雅地比較坐標

可以通過DepthBuffer, 攝像機Near Clip Plane, Far Clip Plane, Field of View來計算出屏幕上每一個點的世界坐標, 但是傳統的在片元著色器中計算世界坐標的方式是處理後依次乘以世界-視圖矩陣的逆, 效率堪憂. 就算利用點元著色器預先計算視椎體射線, 效率有了些許提升, 也遠遠達不到"優雅"的水準.

(PS: 我會在後面的文章中詳細介紹Global Fog後期處理特效, 其中會對在片元著色器中通過DepthBuffer計算世界坐標的方法展開討論. )

說了這麼多, 我們發現直接求世界坐標這種套路最直接, 最好理解, 但似乎並不太可取. 那麼我們就要思考一個問題: 我們真的必須得知道具體的世界坐標嘛?

通過觀察上面的那張圖, 我們發現為了確定如何渲染並混合顏色, 我們只需要知道相對於攝像機來講, 正方體的片元和原本場景中對應位置的像素誰離得更遠就行了. 也就是說, 我們只需要知道兩個三維向量的長度, 也就是兩個實數, 而並不需要知道這兩個三維向量的xyz都分別是什麼.

所以說, 求世界坐標的話有點兒殺雞用牛刀了.

如何獲取一個片元所在屏幕位置的DepthBuffer

如果你不知道DepthBuffer, 或者是Unity的CameraDepthTexture, 那麼強烈建議你谷歌下, 要不然接下來的東東就都GG了.

我們是如何知道一個片元的投影坐標的呢? 恐怕下面這段代碼你都看爛了:

o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );n

我甚至不用說出o和v的變數聲明以及這段代碼出自何處, 你就知道我在說什麼了. (語氣頗像 ... 額, 專欄還是要辦下去的, 打住)

所以說我們要怎麼通過世界坐標來將其xy映射到[0, 1]區間呢? 畢竟只有這樣我們才能採樣DepthBuffer啊! 不用擔心, Unity都替我們做好了: 在UnityCG.cginc中, 有這麼個函數:

inline float4 ComputeNonStereoScreenPos(float4 pos) {ntfloat4 o = pos * 0.5f;n#if defined(UNITY_HALF_TEXEL_OFFSET)nto.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w * _ScreenParams.zw;n#elsento.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;n#endifnto.zw = pos.zw;ntreturn o;n}n

當然了並不是要調用它, 而是要使用ComputeScreenPos函數:

inline float4 ComputeScreenPos (float4 pos) {ntfloat4 o = ComputeNonStereoScreenPos(pos);n#ifdef UNITY_SINGLE_PASS_STEREOnto.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);n#endifntreturn o;n}n

吶, 帶著編譯器指令原封不動地搬過來, 大家看起來肯定有點方. 其實大多數情況下, ComputeScreenPos函數可以重寫成以下形式:

inline float4 ComputeScreenPos (float4 pos)n{n float4 o = pos * 0.5;n o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;n o.zw = pos.zw;n return o;n}n

傳入Clip Space坐標pos, 最終輸出xy在[0, 1]之間, z值為Camera Space深度的結果.

不過 ... 這個函數什麼鬼 ... 拿到了Clip Space坐標先乘個0.5 ... 然後 ... 還要加上w分量的二分之一?

大萌喵接下來打算稍微介紹下這個函數的原理. 如果你之前對坐標轉換不是很了解, 大萌喵提供了幾個鏈接, 可供參考:

  • learnOpenGL(神啟蒙教程)

  • scratchapixel.com

  • 神書

如果你覺得Math = Mental Abuse To Human(對人類的精神侮辱), 那暫時略過也無所謂. ComputeScreenPos當成黑盒子使用也沒什麼問題.

=============================================================

如無需要可略過

首先上一張圖:

在Unity中, mul ( UNITY_MATRIX_MVP, v.vertex )和UnityObjectToClipPos(float4 ( v.vertex.xyz, 1.0 ) )乾的差不多都是一回事兒, 就是將模型坐標轉換到攝像機的Homogeneous Clip Space. 詳情參加官方文檔.

但是, 一般渲染管線不會立刻將Clip後的坐標標準化(也就是除以w分量. 不知道w分量代表什麼的童鞋 ... 請先補課), 而是在點元著色函數結束以後將其標準化. 這個地方有點坑.

(原文摘錄如下: Once all the vertices are transformed to clip space a final operation called perspective division is performed where we divide the x, y and z components of the position vectors by the vectors homogeneous w component; perspective division is what transforms the 4D clip space coordinates to 3D normalized device coordinates. This step is performed automatically at the end of each vertex shader run.

)

所以, 可以認為我們現在得到的是已經經過Clipping, 但是還沒有標準化的投影坐標. 我們的目的是要將這個坐標轉化為xy在[0, 1]之間, 而z反映深度的屏幕坐標.

既然是[0, 1]之間, 那麼我們自然就不用向上圖一樣乘以ViewPort寬高了. 同時要注意ViewPort坐標原點的問題: Unity中是左下角, 而上圖採用的是左上角. 所以具體到我們的情況下y和x的處理方式應該是相同的.

為了將[-1, 1]映射到[0, 1]上, 將原坐標加1然後除2是顯而易見的. 但是要注意我們的x和y都比人家多乘著一個w分量. 因為運算到這個時候我們依然在點元著色器函數中, 因此最終的標準化過程還沒有執行.

所以, 我們就得到了下面這段代碼(其實也是上面那段)

float4 o = pos * 0.5;no.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;n

如果沒想清楚是怎麼轉過這個彎來的 ... 對比上圖倒數第二個框框和上面的文字就OK了.

=============================================================

千呼萬喚始出來的點元著色器函數

v2f vert ( appdata_base v )nt{nttv2f o;nntto.pos = UnityObjectToClipPos ( v.vertex );nntto.projPos = ComputeScreenPos ( o.pos );nnttreturn o;nt}n

(如果我不是手賤翻了下ComputeScreenPos的源代碼, 也就沒這麼多麻煩事兒了哈哈哈)

實際上非常簡單的片元著色器函數

在片元著色器中, 我們只需要提取出對應屏幕位置的深度信息, 然後和點元著色器的輸出深度信息作比較, 根據相差結果進行插值即可.

fixed4 frag ( v2f i ) : SV_TARGETnt{nttfloat4 finalColor = _MainColor;n float sceneZ = LinearEyeDepth (tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));n float partZ = i.projPos.z;n ttfloat diff = min ( (abs(sceneZ - partZ)) / _Threshold, 1);n finalColor = lerp(_HighlightColor, _MainColor, diff);n return finalColor;nt}n

不知道那個插值是怎麼回事兒的童鞋, 請動用C語言的思考模式, 寫個if出來, 然後想辦法消除掉這個if.

雖然話說回來就算這有個if也不算動態分支, 對性能的影響不太大 ... 但還是養成良好的習慣吧.

最終成果(其實一般再加上個Texture糊到Quad上實現掃描的特效)

後記

其實這個特效的原理真心一點也不複雜, 只是用到了DepthTexture來獲取屏幕中每個像素的深度信息來進行比對以決定模型最終的顏色. 但是UnityCG.cginc裡面的ComputeScreenPos函數那個奇怪的外觀引發了我極大的好奇心.

FIN

大萌喵是個學生黨, 非常熱切地希望能和諸位前輩們交流! 如果文章中存在任何疏漏, 不足, 或錯誤之處, 希望您能批評指正! 謝謝!

Preview: 大萌喵最近對DepthTexture有點著迷呀, 下幾次打算講講Global Fog, Volumetric Light Scattering和Edge Detection. 不過中間也會夾雜一些小的好玩兒的著色器效果 ~

推薦閱讀:

優化筆記:C# 從 List<T> 移除空元素
聊聊那些不常見的Shader
基於物理的景深效果
手游內存佔用過高?如何快速定位手游內存問題
從零開始學基於ARKit的Unity3d遊戲開發系列2

TAG:着色器 | Unity游戏引擎 | 计算机图形学 |