先睹为快
引入
这周学习了一种对模型的 Wireframe 做描边的着色器,其核心思想是:在几何着色器中,先将三维空间中每个顶点的坐标转换为屏幕坐标系下的平面坐标,然后求这个平面三角形的面积,继而求出各顶点到对边的高,并塞到一个另外两维用零补足的三维数组中。如此一来,经过几何着色器到片元着色器的插值后,这个三维数组中存的就变成了各像素点到临近三条边的距离。接下来只需让这三维中的任意一维小于某个值时画出 wireframe 的颜色,就完成了 wireframe 的描边。
它的效果如下:
它的几何着色器与片元着色器代码如下:
[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream<g2f> triStream)
{
float2 WIN_SCALE = float2(_ScreenParams.x / 2.0, _ScreenParams.y / 2.0);
//frag position
float2 p0 = WIN_SCALE * IN[0].pos.xy / IN[0].pos.w;
float2 p1 = WIN_SCALE * IN[1].pos.xy / IN[1].pos.w;
float2 p2 = WIN_SCALE * IN[2].pos.xy / IN[2].pos.w;
//barycentric position
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.pos = IN[0].pos;
OUT.uv = IN[0].uv;
OUT.dist = float3(area / length(v0),0,0);
triStream.Append(OUT);
OUT.pos = IN[1].pos;
OUT.uv = IN[1].uv;
OUT.dist = float3(0,area / length(v1),0);
triStream.Append(OUT);
OUT.pos = IN[2].pos;
OUT.uv = IN[2].uv;
OUT.dist = float3(0,0,area / length(v2));
triStream.Append(OUT);
}
half4 frag(g2f IN) : COLOR
{
//distance of frag from triangles center
float d = min(IN.dist.x, min(IN.dist.y, IN.dist.z));
//fade based on dist from center
float I = exp2(-4.0 * d * d);
return lerp(_Color, _WireColor, I);
}
进入正题
对于 wireframe 描边这个命题,咱首先想到的是另一种解法。现在想来这其实算上面这种解法的青春版。
既然从几何到片元的过程中会做插值,又正好要用到三个维度,那么直接把每个像素需要的信息存到 rgb 里岂不美哉?把每个面片的三个顶点设为红、绿、蓝,之后的插值就丢给着色器处理了。当然啦,本质也还是把 COLOR 当成三维数组来用,不过可视化的中间步骤更容易理解不是嘛。
代码如下:
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_FrameColor ("Wireframe Color", Color) = (1,0,0,1)
_Epsilon ("Wireframe Width", Range(0, 0.34)) = 0.005
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
CGPROGRAM
#include "UnityCG.cginc"
#pragma target 4.0
#pragma vertex vert
#pragma geometry geom
#pragma fragment frag
static const fixed4 COLOR[3] =
{
fixed4(1,0,0,1),
fixed4(0,1,0,1),
fixed4(0,0,1,1),
};
fixed4 _Color, _FrameColor;
float _Epsilon;
struct v2g
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct g2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
fixed4 color : COLOR;
};
v2g vert(appdata_base v)
{
v2g o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.normal = v.normal;
return o;
}
[maxvertexcount(3)]
void geom(triangle v2g v[3], inout TriangleStream<g2f> triStream)
{
g2f o;
for (uint i = 0; i < 3; i++)
{
o.vertex = v[i].vertex;
o.uv = v[i].uv;
o.normal = v[i].normal;
o.color = COLOR[i];
triStream.Append(o);
}
triStream.RestartStrip();
}
fixed4 frag(g2f i) : COLOR
{
//Raw RBG Color
return i.color;
}
ENDCG
}
}
效果就是这样:
显然地,这还没描边呢。所以我们再把片元着色器稍加修改,让它不要再显示闪瞎眼的 RGB 了:
fixed4 frag(g2f i) : COLOR
{
//return i.color;
float minColor = min(i.color.r, min(i.color.g, i.color.b));
if (minColor < _Epsilon)
return _FrameColor;
else
return _Color;
}
这几行的意思是,只要 rgb 中有一个分量小于设定好的 ε,就当描边线,否则就还是原本的颜色。效果如下:
你或许发现了,虽然也描出了边,但该效果与开头提到的人家的效果有重要的差别。
再放两张远景对比一下(均无光照)。这是我们的效果:
这是人家的效果:
出现这种立体感的差别,是因为我们的代码将描边画在了模型上,而人家的代码将描边画在了屏幕上。这意味着后者的线条在屏幕上永远是等粗的,所以在面片越密集的地方,线条占的份量也会越大。
看起来好像是我们的效果更廉价喔?其实我们的效率是更高的,比人家少了好几次除法、幂运算啥的,或者说实际上我们除了做了几次比较,其他什么运算也没有。而且这种算法可以更自由地调整线条宽度,所以还是看个人的需求来选择啦。
扩展应用
该算法不止能描 wireframe 边,还可以有以下多种变体效果。
在上色时用最小的颜色分量作 weight 进行插值:
在上色时用最大的颜色分量作 weight 进行插值:
再加上描边: