之前看了一本小說叫《副本》,裡面有個描寫就是拷問犯人的時候進入虛擬空間,進入的時候顯示基礎的線條,然後在變得越來越精細,最後女主角為男主角遞上一根煙,正好全部加載完成,之後一直在腦中想像了這個效果,當然最終的實現還是和想像的有點差距,下面我們先來看看最終的效果:
第一步首先先根據模型創建線框,對於這一步我一開始聯想到了Unity的warframe,但是卻不知道如何繪製兩點之間的直線,
然後去找它的類似效果的實現方式,卻意外的發現了Geometry Shader,它介於頂點和片元之間,可以在這一階段通過圖元來修改頂點,下面的實現參考:(文章底部擴展閱讀)
下面來開始編寫我們的shader:
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
現在我們從原先的頂點-》像素 變成 頂點--》幾何--》像素的方式 同時頂點階段也要變為輸出數據給幾何階段
struct appdata
{
float4 vertex: POSITION;
float2 uv: TEXCOORD0;
};
struct v2g
{
float2 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
};
struct g2f
{
float2 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2g vert(appdata v)
{
v2g o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream < g2f > triStream)
{
g2f OUT;
OUT.vertex = IN[0].vertex;
OUT.uv = IN[0].uv;
triStream.Append(OUT);
OUT.vertex = IN[1].vertex;
OUT.uv = IN[1].uv;
triStream.Append(OUT);
OUT.vertex = IN[2].vertex;
OUT.uv = IN[2].uv;
triStream.Append(OUT);
}
fixed4 frag(g2f i): SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
下面繪製線框,繪製線框的原理是這樣的,在幾何階段,我們得到一個三角形,那麼對於這個三角形內部的點來說,該點距離其他三條邊的距離是0的話,說明這個點在這個三角形的邊上,我們可以使用一個變量來控制最小的距離來實現控制線框的寬度。
[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream < g2f > triStream)
{
float2 p0 = IN[0].vertex.xy / IN[0].vertex.w;
float2 p1 = IN[1].vertex.xy / IN[1].vertex.w;
float2 p2 = IN[2].vertex.xy / IN[2].vertex.w;
float2 v0 = p2 - p1;
float2 v1 = p2 - p0;
float2 v2 = p1 - p0;
//triangles area
float area = abs(v1.x * v2.y - v1.y * v2.x);
// //到三條邊的最短距離
g2f OUT;
OUT.vertex = IN[0].vertex;
OUT.uv = IN[0].uv;
OUT.dist = float3(area / length(v0), 0, 0);
triStream.Append(OUT);
OUT.vertex = IN[1].vertex;
OUT.uv = IN[1].uv;
OUT.dist = float3(0, area / length(v1), 0);
triStream.Append(OUT);
OUT.vertex = IN[2].vertex;
OUT.uv = IN[2].uv;
OUT.dist = float3(0, 0, area / length(v2));
triStream.Append(OUT);
}
首先P0,P1,P2三個點根據世界坐標除以w得到視口坐標,v0,v1,v2則是對應的三個邊的方向,area則利用叉積的幾何意義得到三角形的面積(應該是平行四邊形),那麼對於每個點而言,這個點到對著邊的距離根據面積公式反推可以得到 平行四邊形的面積除以對邊的長度,我們將距離記錄下來。
fixed4 frag(g2f i): SV_Target
{
fixed4 col_Wire;
float d = min(i.dist.x, min(i.dist.y, i.dist.z));
col_Wire.rgb = d < _WireWidth?_WireColor: _FillColor;
col_Wire.a = 1;
return col_Wire;
}
到了像素階段,我們就可以比較這個點到三條邊的最短距離,得到一個最短距離,然後就可以通過這個最短距離來決定如何渲染,這裡我們添加了一個變量_WireWidth 來判斷距離,另外添加了兩種顏色,分別取渲染線和其他區域
到這裡線框處理好了,下面我們再來實現斷層的效果
struct v2g
{
float3 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
};
struct g2f
{
float3 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
float3 dist: TEXCOORD1;
};
..
v2g vert(appdata v)
{
v2g o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
o.uv.z = _Clip + v.vertex.x;
return o;
}
....
fixed4 frag(g2f i): SV_Target
{
clip(i.uv.z);
....
我們新加入了一個變量_Clip來對模型進行clip,將z的高度信息暫時保存在uv.z中,然後進行clip
模型已經被裁剪掉了,下面再來實現對原有材質的漸變
half blendValue = smoothstep(_Lerp - _WireLerpWidth, _Lerp + _WireLerpWidth, i.uv.z);
fixed4 col_Tex = tex2D(_MainTex, i.uv);
//return blendValue * col_Tex;
return lerp(col_Wire, col_Tex, blendValue);
_Lerp用於控制離線框的遠近,_WireLerpWidth用於控制邊緣寬度
完成!
完整代碼如下
Shader "Unlit/Wireframe2"
{
Properties
{
_MainTex ("Texture", 2D) = "white" { }
_WireColor ("WireColor", Color) = (1, 0, 0, 1)
_FillColor ("FillColor", Color) = (1, 1, 1, 1)
_WireWidth ("WireWidth", Range(0, 0.005)) = 1
_Clip ("Clip", Range(-2, 2.5)) = 1
_Lerp ("Lerp", Range(0, 1)) = 0.5
_WireLerpWidth ("WireLerpWidth", Range(0, 1)) = 0.1
}
SubShader
{
Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
LOD 100
Pass
{
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex: POSITION;
float2 uv: TEXCOORD0;
};
struct v2g
{
float3 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
};
struct g2f
{
float3 uv: TEXCOORD0;
float4 vertex: SV_POSITION;
float3 dist: TEXCOORD1;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float4 _FillColor, _WireColor;
float _WireWidth, _Clip, _Lerp, _WireLerpWidth;
v2g vert(appdata v)
{
v2g o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
o.uv.z = _Clip + v.vertex.x;
return o;
}
[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream < g2f > triStream)
{
float2 p0 = IN[0].vertex.xy / IN[0].vertex.w;
float2 p1 = IN[1].vertex.xy / IN[1].vertex.w;
float2 p2 = IN[2].vertex.xy / IN[2].vertex.w;
float2 v0 = p2 - p1;
float2 v1 = p2 - p0;
float2 v2 = p1 - p0;
//triangles area
float area = abs(v1.x * v2.y - v1.y * v2.x);
// //到三條邊的最短距離
g2f OUT;
OUT.vertex = IN[0].vertex;
OUT.uv = IN[0].uv;
OUT.dist = float3(area / length(v0), 0, 0);
triStream.Append(OUT);
OUT.vertex = IN[1].vertex;
OUT.uv = IN[1].uv;
OUT.dist = float3(0, area / length(v1), 0);
triStream.Append(OUT);
OUT.vertex = IN[2].vertex;
OUT.uv = IN[2].uv;
OUT.dist = float3(0, 0, area / length(v2));
triStream.Append(OUT);
}
fixed4 frag(g2f i): SV_Target
{
clip(i.uv.z);
fixed4 col_Wire;
float d = min(i.dist.x, min(i.dist.y, i.dist.z));
col_Wire = d < _WireWidth?_WireColor: _FillColor;
//return col_Wire;
//顏色差值
half blendValue = smoothstep(_Lerp - _WireLerpWidth, _Lerp + _WireLerpWidth, i.uv.z);
fixed4 col_Tex = tex2D(_MainTex, i.uv);
//return blendValue * col_Tex;
return lerp(col_Wire, col_Tex, blendValue);
}
ENDCG
}
}
}
來源知乎專欄:Unity開發學習
擴展閱讀:
GeometryShader這個概念,已經出現很久了,但由於性能不佳,所以使用的並不多。甚至移動平臺根本就不支持。移動平臺的硬體更新速度也是越來越快,GS的應用普及應該不會太遠。就現階段而言,GS來做一些輔助效果也是有一定用武之地的。就像本文要提到的這個線框渲染的效果(如下圖)。在Unity編輯模式中,偶爾有時候希望能有這種效果, 我在AssetStore裡找到了一個叫UCLA Wireframe Shader的資源,裡面有Shader源碼。發現它是利用GS來實現的,本文就以它的源碼為例來說明一下它是如何利用GeometryShader來實現這種線框渲染效果(WireFrame)的。
在具體看代碼之前,需要先對幾何著色器階段有個初步了解,大概需要知道需要下面幾兩個概念:
1.圖元(graphics primitive)
幾乎所有的圖形渲染入門書籍裡,都要提到這個概念,我們知道,所有的幾何模型都是有點,線,三角形等基本單元組成的(這裡以三角形為例),每個圖元又是由若干個頂點構成。在渲染管線的開始,GPU處理的是每一個頂點,但是GPU是知道每一個頂點是屬於哪個三角形的。所有頂點經過頂點著色器處理後輸出的結果會經過一個圖元裝配(Primitive Assembly)的階段,這個階段就是把這些處理後的頂點組裝成成一個個三角形。為什麼這麼做呢?因為之後的無論是光柵化和頂點信息插值過程,以及視椎體的裁剪,都是以圖元為單位進行的(如果你對這個過程不是非常了解,可以查查資料,或者去看一下劉鵬翻譯的《計算機圖形學—基於3D圖形開發技術》),經過上述的這些階段後再到達我們熟悉的片元著色階段,也就離最終渲染結果不遠了。
2.幾何著色器(Geometry Shader)
對於VS,FS我們都比較熟悉,那GS出現在哪呢?從下面這種圖中我們可以看到GS是位於VS和FS之間的。並且是虛線連接,即是可選的。GeometryShader所接收的實際是對VS輸出的圖元進行添加,刪除,或修改,然後輸出新的圖元信息。再之後的流程就和之前的一樣了。
進行線框渲染,一個比較困擾的地方就是我們不知道一個頂點是屬於哪一個圖元的。但是有了GS的參與之後,這一切就迎刃而解了。後面解釋代碼時會具體說。
代碼解釋 Shader "UCLA Game Lab/Wireframe/Single-Sided"
{
Properties
{
_Color ("Line Color", Color) = (,,,)
_MainTex ("Main Texture", 2D) = "white" {}
_Thickness ("Thickness", Float) =
}
SubShader
{
Pass
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
LOD
CGPROGRAM
#pragma target 5.0
#include "UnityCG.cginc"
#include "UCLA GameLab Wireframe Functions.cginc"
#pragma vertex vert
#pragma fragment frag
#pragma geometry geom
// Vertex Shader
UCLAGL_v2g vert(appdata_base v)
{
return UCLAGL_vert(v);
}
// Geometry Shader
[maxvertexcount()]
void geom(triangle UCLAGL_v2g p[], inout TriangleStream<UCLAGL_g2f> triStream)
{
UCLAGL_geom( p, triStream);
}
// Fragment Shader
float4 frag(UCLAGL_g2f input) : COLOR
{
return UCLAGL_frag(input);
}
ENDCG
}
}
}
可以看到這個文件裡調用了很多定義在"UCLA GameLab Wireframe Functions.cginc"這個文件中的函數。後面用到時候再看。
第26行:指定了用於幾何著色器執行的函數。與vs,fs一樣。另外21行中指定了要是用的ShaderModel(SM定義了著色器代碼的一些規範和能力),這裡必須是4.0以上的版本。
29~32行:vs的代碼沒有什麼特殊的,下面貼出的UCLAGL_vert和UCLAGL_v2g的代碼也沒啥不一樣的。唯一值得提一下就是UCLAGL_v2g中pos後面的語意是POSITION而非SV_POSITION,我試了一下這樣沒啥問題。可以看出vs的代碼和以前的意義,進行完頂點處理之後,GPU會進行圖元裝備,接下來就進入到GS階段了。
// DATA STRUCTURES //
// Vertex to Geometry
struct UCLAGL_v2g
{
float4 pos : POSITION; // vertex position
float2 uv : TEXCOORD0; // vertex uv coordinate
};
// Vertex Shader
UCLAGL_v2g UCLAGL_vert(appdata_base v)
{
UCLAGL_v2g output;
output.pos = mul(UNITY_MATRIX_MVP, v.vertex);
output.uv = TRANSFORM_TEX (v.texcoord, _MainTex);//v.texcoord;
return output;
}
35~39行:這段就是GS的執行函數了,其中第35行[maxvertexcount(3)]是用來限制GS輸出的最大頂點數,這裡必須理解清楚,前面說過GS可以對輸入的圖元進行刪除,添加,修改,也就是進來一個圖元,可能輸出0~n個圖元,不論圖元是以何種形式組織的,它都是由頂點構成的,這個maxvertexcount就是用來限定這個頂點數量的,記住它只是限定最大數量,也就是你提供小於等於這個數量的頂點就可以。
36行:估計你第一次看到這行代碼的時候應該和我一樣感到奇怪。這怎麼還有點模板的意思,還有剛才的那個[maxvertexcount],這個語法看上去有點C#的Attribute的意思啊。平常寫的UnityShader不是說CG語言(C for Graphics),這可和C不太一樣啊。其實Unity的Shader代碼是基於自己的ShaderLab結構的,他用的CG也並不是和Nvidia的CG一模一樣。我查了下OpenGL和官方CG的GS用法,大家的意思都是差不多,但是語法細節上是不一樣的。無論怎麼樣最終Unity會負責對ShaderLab進行編譯,轉化成對於平臺的GLSL或者HLSL語言。
回到這個函數聲明,第一個參數triangle UCLAGL_v2g p[3],GS接收的是圖元,那這個圖元是以什麼樣的形式傳遞進來的呢?就是以頂點結構數組的形式,比如一個三角形圖元由三個頂點構成,那麼數組大小就是3,相應的點和線就是1和2。這個參數最前面的triangle就是用來表示這個的,還有兩個就是point和line。要記住這個標識符必須和後面數組大小相配。還有一點就是你填寫的圖元標識符類型和Unity原始模型資源的頂點組織方式不要求一定匹配,比如Unity默認組織模型資源是三角圖元的,你在這裡可以用point接收,但這樣的結果就是本來這個圖元有三個頂點,但是你只能接收到第一個了。
第二個參數inout TriangleStream<UCLAGL_g2f> triStream,這裡的inout就和C#的inout是一樣的,CG本身就有這個關鍵字。而TriangleStream決定了輸出的圖元是三角形圖元。對應的還有LineStream和PointStream。UCLAGL_g2f的內容如下:
// Geometry to UCLAGL_fragment
struct UCLAGL_g2f
{
float4 pos : POSITION; // fragment position
float2 uv : TEXCOORD0; // fragment uv coordinate
float3 dist : TEXCOORD1; // distance to each edge of the triangle
};
這個結構定義了構成GS輸出圖元的頂點結構。這裡有個dist,後面再解釋。可以想像,GS就是把第一個參數的信息拿過來經過處理後把結果填充到第二個參數中去。需要額外說明一下,這裡的Stream類型和上面的maxvertexcount是有一些關聯的,上面代碼輸出圖元類型是三角形,構成三角形最少需要三個頂點,如果我們最終在GS中像triStream提供了小於三個頂點,則GS將放棄這個片元,當然你也可以提供多餘三個頂點,但是超過了maxvertexcount的部分也會被拋棄掉。總之他們兩個是有聯繫的,這裡只是提一下,使用時候要注意。
現在來看一下UCLAGL_geom函數,也是這個Shader的核心部分。
// Geometry Shader
[maxvertexcount()]
void UCLAGL_geom(triangle UCLAGL_v2g p[], inout TriangleStream<UCLAGL_g2f> triStream)
{
//points in screen space
float2 p0 = _ScreenParams.xy * p[].pos.xy / p[].pos.w;
float2 p1 = _ScreenParams.xy * p[].pos.xy / p[].pos.w;
float2 p2 = _ScreenParams.xy * p[].pos.xy / p[].pos.w;
//edge vectors
float2 v0 = p2 - p1;
float2 v1 = p2 - p0;
float2 v2 = p1 - p0;
//area of the triangle
float area = abs(v1.x*v2.y - v1.y * v2.x);
//values based on distance to the edges
float dist0 = area / length(v0);
float dist1 = area / length(v1);
float dist2 = area / length(v2);
UCLAGL_g2f pIn;
//add the first point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(dist0,,);
triStream.Append(pIn);
//add the second point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(,dist1,);
triStream.Append(pIn);
//add the third point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(,,dist2);
triStream.Append(pIn);
}
先說一下開頭處,p0,p1,p2處的計算,因為GS接收的頂點信息都是VS處理過的,在VS中頂點輸出的pos信息已經是其在投影空間下的坐標了。現在p[x].pos.xy/p[x].ps.w實際就是手動進行透視除法,得到視口坐標。_ScreenParams.xy是一個Unity為我們提供的內置變量,表示屏幕的解析度。那麼這兩者相乘就得到了p[x]在屏幕空間的坐標(單位像素)。這裡分別對當前三角圖元的三個頂點進行了計算。
再分別講頂點兩兩相減,得到三角形的三個邊向量。之後利用叉積的幾何意義得到三角形的面積,這裡實際是平行四邊形面積,注意這裡用的是v1和v2,思考一下為什麼。得到面積後,利用四邊形面積公式(底邊長X高)來得到當前頂點的對角邊的距離。
計算出這三個距離之後就可以進行輸出了。前面我們提到UCLAGL_g2f結構中有一個dist,現在可以解釋一下了。他是個float3類型,他的xyz分量分別代表了當前頂點到達三角圖元三邊的距離,你可能會奇怪為什麼要用float3,明明一個頂點到其中兩條邊的距離都是0,之後另外一條邊才不是0。要知道我們GS最後輸出的依然是圖元,還沒有進行光柵化插值。最終我們要進行渲染的是片元,這些片元可不一定是正好在圖元的三個頂點上。所以用float3是為了能夠正確的插值,將來光柵化時候能得到片元距離圖元三邊的距離。進行輸出時候先定義了一個UCLAGL_g2f類型的pIn變量。可以看到後面賦值就沒什麼說的,注意dist的賦值。每當構造完一個UCLAGL_g2f變量以後,就調用Append方法把它添加到輸出結構中。
可見這裡的GS並沒有刪除或者添加圖元,它只是對輸入圖元進行修改再輸出。進來的是三角片元的三個頂點,出去的還是三角片元三個頂點。但是經過GS處理,現在每個頂點都知道他距離他所在的三角圖元三條邊的距離了。正式這個值讓我們在fs中能完成線框渲染的效果。
第42~45行:fs我們主要看UCLAGL_frag這個函數,代碼如下:
// Fragment Shader
float4 UCLAGL_frag(UCLAGL_g2f input) : COLOR
{
//find the smallest distance
float val = min( input.dist.x, min( input.dist.y, input.dist.z));
//calculate power to 2 to thin the line
val = exp2( -/_Thickness * val * val );
//blend between the lines and the negative space to give illusion of anti aliasing
float4 targetColor = _Color * tex2D( _MainTex, input.uv);
float4 transCol = _Color * tex2D( _MainTex, input.uv);
transCol.a = ;
return val * targetColor+ ( - val ) * transCol;
}
要知道現在input參數裡的所有信息,都是經過插值了,它代表的是片元,而不是頂點了。先選出該片元到其所在三角圖元三邊的最短距離val.看最後的幾行代碼,可以知道是利用一個混合因子來進行blend,來達到距離圖元邊越近的片元,可見程度越高。那麼混合因子值的計算就決定了後面這個融合步驟的好壞。如果混合因子選取的不好,最終線框渲染的效果就不好。
最後來看看混合因子的計算:val = exp2( -1/_Thickness * val * val );這行代碼可以從最外層開始理解,exp2是CG的內置函數,代表2位底的指數函數。你去看看指數函數的圖像就知道,當x小於0的時候y值是在0~1區間內的。而且不會為負。而我們的混合因子也是要在0~1區間的。上式括號中的-1就決定了exp2的參數一定為負,因為_Thickness是大於等於0,val的平方也是大於等於0的。那為什麼要用val的平方的。看一下y=x^2的函數圖像。val是距離,他是大於等於0的,那麼隨著x的增大,y的增大幅度越來越大(y的導函數是個上升函數),也就是說隨著val的增大val*val的取值跨度越來越大。你會問,這又有什麼用呢?你現在把兩個函數圖像結合起來,先不看_Thickness,得到下圖3.
從圖像上看出當x在0~2範圍內,函數曲線急劇下降。並在之後無限趨近於0。這樣計算後的混合因子大概只在片元距離三角圖元邊線0~2個像素的距離內,可見性才明顯一些,這樣就能畫出比較明顯的邊線效果。 之所以又加上了_Thickness,可以看到把曲線的最終結果除以一個正數,這個數越大,那麼就會越明顯的減緩圖像三曲線的下降效果,也就是圖像不會太快的趨近於0,那麼新的混合因子在表現的時候邊線顯得更寬了。
總結這個方法只是視覺上達到了WireFrame的效果,實際上還是以三角圖元的方式來渲染的,只是利用透明度的混合來達到效果。如果真正的想要按照線框模式來渲染,應該修改GS讓他輸出的圖元是LineStream。也就完全不需要像這種方法計算這麼多中間變量了。如果感興趣,不妨寫寫試試。只是想透過這個例子來說明一下Unity中GeometryShader的基本原理和語法。
貌似Unity的GS好像還只能在DX11API上用,OpenGL上還不行。OpenGL ES目前還不支持GeometryShader。
利用GS確實能做出很多以前做不出或者很難做出的效果,其中一個關鍵點就是頂點能知道它所在圖元的一些信息,這是以前的VS-FS結構做不到的。
擴展閱讀來源:cnblog作者- esfog