很长时间没有写博客了,一方面Sebastian大佬正在更新程序生成星球的教程,所以想等到大佬更新接近尾声的时候开始那个教程的分享。最近主要是在工作之余补一补Unityshader的基础知识,然后正好在项目中遇到这么一个问题,需要实现2D图片的描边和内发光。找了很多资料一直都没有找到实现起来比较不错的方法,而且搜出来的大部分都是3D的描边,3D描边的思想很容易掌握,但是一直没有2D的外轮廓的描边,所以就寻思着自己想一个办法,运气不错,刚好在看冯乐乐的《UnityShader入门精要》的时候她的书里讲了一章节的图片的描边shader,但是她的这个shader的描边并不是我想要的图片的外轮廓的描边,她的方法是通过卷积来求得像素的亮度,通过对比像素的亮度差来获取图片内容的中的边界区分,虽然目的不同,但是她的思想我觉得是可以学习一下的,所以就使用卷积实现了2D图片外轮廓的描边和内发光的实现,下面就进入正题。
首先讲一下冯乐乐这本书中的边缘检测的方法。她的边缘检测的原理是利用一些边缘检测算子对图像进行卷积操作,卷积的操作指的是使用一个卷积核对一张图像中的每个像素进行一系列的操作,而卷积核通常是一个四方形网格结构。这本书中用的卷积核是
Gx
-1 | 0 | 1 |
-2 | 0 | 2 |
-1 | 0 | 1 |
Gy
-1 | 2 | -1 |
0 | 0 | 0 |
1 | 2 | 1 |
表格中的每个数字都代表周围像素的权重,通过获取每个像素的亮度乘以权重的和来获取梯度G,通过一个梯度的阈值来获取边缘。具体的实现可以去看一下书,这里就跳过不赘述了。
那么我就使用这样的卷积的思想,但是我不使用刚才所讲的卷积核,我使用一个3×3的权重都是1的表格,然后计算获得的是一个像素本身的颜色的透明度和周围8个其他像素的颜色的透明度的和Sum,然后设定一个阈值来与Sum作对比获取图片的外轮廓。下面贴出代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "Custom/ShaderForTest"
{
Properties
{
_MainTex("Main Texture", 2D) = "white"{} //主纹理
_EdgeAlphaThreshold("Edge Alpha Threshold", Float) = 1.0 //边界透明度和的阈值
_EdgeColor("Edge Color", Color) = (0,0,0,1) //边界颜色
_EdgeDampRate("Edge Damp Rate", Float) = 2 //边缘渐变的分母
_OriginAlphaThreshold("OriginAlphaThreshold", range(0.1, 1)) = 0.2 //原始颜色透明度剔除的阈值
[Toggle(_ShowOutline)] _DualGrid ("Show Outline", Int) = 0 //Toggle开关来控制是否显示边缘
}
SubShader
{
Tags{ "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
Ztest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma shader_feature _ShowOutline
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeAlphaThreshold;
fixed4 _EdgeColor;
float _EdgeDampRate;
float _OriginAlphaThreshold;
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv[9] : TEXCOORD0;
};
half CalculateAlphaSumAround(v2f i)
{
half texAlpha;
half alphaSum = 0;
for(int it = 0; it < 9; it ++)
{
texAlpha = tex2D(_MainTex, i.uv[it]).w;
alphaSum += texAlpha;
}
return alphaSum;
}
v2f vert(appdata_img v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
#if defined(_ShowOutline)
half alphaSum = CalculateAlphaSumAround(i);
float isNeedShow = alphaSum > _EdgeAlphaThreshold;
float damp = saturate((alphaSum - _EdgeAlphaThreshold) * _EdgeDampRate);
fixed4 orign = tex2D(_MainTex, i.uv[4]);
float isOrigon = orign.a > _OriginAlphaThreshold;
fixed3 finalColor = lerp(_EdgeColor.rgb, orign.rgb, isOrigon);
return fixed4(finalColor.rgb, isNeedShow * damp);
#endif
return tex2D(_MainTex, i.uv[4]);
}
ENDCG
}
}
}
变量的意义都写在注释里面了。由于大部分的图片都是带有透明度的所以需要透明混合(Blend SrcAlpha OneMinusSrcAlpha)我们在结构体v2f中定义了一个二维向量的数组,长度是9,是为了拿到像素周围的其他像素点的uv偏移,然后我们就在顶点函数中去计算uv的偏移。half4 _MainTex_TexelSize;定义一个这个变量就能获取到该纹理的纹素,指的就是一个像素占uv的比,比如512*512的图片,纹素就是(1/512,1/512)。然后在片元函数中先计算获得到当前像素的透明度加上周围八个像素的透明度的和alphaSum,如果大于一个边界透明度的阈值说明当前像素点就是一个边界上的点。float damp = saturate((alphaSum - _EdgeAlphaThreshold) * _EdgeDampRate);这段代码的意义是为了在边界的外边缘做缓冲的处理,这样看一起来会柔和一点不会很生硬。然后获取原像素的颜色信息并且剔除透明度过低的边缘。然后输出颜色。
参数的设置:
实现出来的效果图:
放大可以看到边界的渐变处理:
其实内发的光的思想和外轮廓的描边的思想是差不多的,但是我这里实现的时候并没有再使用上面的权重相同的3×3的表格,而是通过精度来切分一个圆,然后通过圆心和半径来算出uv的偏移,由于内发光是要找到边界靠内的一部分地方,所以算出来的透明度的和一样要大于一个阈值,但是这个时候会发现其实不做限制的话会跟外轮廓冲突,所以这里要加一个条件就是要剔除超出原图的像素。
下面就直接贴代码,并且将过程中的原理和解释全部放在注释中:
Shader "Custom/ShaderForTest2"
{
Properties
{
//2D描边
_MainTex("Main Texture", 2D) = "white"{} //主纹理
_EdgeAlphaThreshold("Edge Alpha Threshold", Float) = 1.0 //边界透明度的阈值
_EdgeColor("Edge Color", Color) = (0,0,0,1) //边界的颜色
_EdgeDampRate("Edge Damp Rate", Float) = 2 //渐变的分母
_OriginAlphaThreshold("OriginAlphaThreshold", range(0.1, 1)) = 0.2 //原像素剔除的阈值
[Toggle(_ShowOutline)] _ShowOutline ("Show Outline", Int) = 0 //开启外轮廓的Toggle
//2D内发光
_InnerGlowWidth("Inner Glow Width", Float) = 0.1 //内发光的宽度
_InnerGlowColor("Inner Glow Color", Color) = (0,0,0,1) //内发光的颜色
_InnerGlowAccuracy("Inner Glow Accuracy", Int) = 2 //内发光的精度
_InnerGlowAlphaSumThreshold("Inner Glow Alpha Sum Threshold", Float) = 0.5 //内发光的透明度和的阈值
_InnerGlowLerpRate("Inner Glow Lerp Rate", range(0, 1)) = 0.8 //内发光颜色和原颜色的差值
[Toggle(_ShowInnerGlow)] _ShowInnerGlow ("Show Inner Glow", Int) = 0 //开启外轮廓的Toggle
}
SubShader
{
Tags{ "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
Ztest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma shader_feature _ShowOutline
#pragma shader_feature _ShowInnerGlow
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeAlphaThreshold;
fixed4 _EdgeColor;
float _EdgeDampRate;
float _OriginAlphaThreshold;
float _InnerGlowWidth;
fixed4 _InnerGlowColor;
int _InnerGlowAccuracy;
float _InnerGlowAlphaSumThreshold;
float _InnerGlowLerpRate;
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv[9] : TEXCOORD0;
};
half CalculateAlphaSumAround(v2f i)
{
half texAlpha;
half alphaSum = 0;
for(int it = 0; it < 9; it ++)
{
texAlpha = tex2D(_MainTex, i.uv[it]).w;
alphaSum += texAlpha;
}
return alphaSum;
}
float CalculateCircleSumAlpha(float2 orign, float radiu, int time)
{
//通过精度来划分一个圆,然后通过一个for循环来计算偏移,然后采样机上透明度。
//我本来使用的精度的平方,但是这样unityshader会报一个错误,说迭代的次数太长,不能超过1024次,
//但是实际上我并没有用那么多次,但是没法解决就手动输入精度
float sum = 0;
float perAngle = 360 / time;
for(int i = 0; i < time; i ++)
{
float2 newUV = orign + radiu * float2(cos(perAngle * i), sin(perAngle * i));
sum += tex2D(_MainTex, newUV).a;
}
return sum;
}
v2f vert(appdata_img v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 innerGlow = fixed4(0,0,0,0);
fixed4 outline = fixed4(0,0,0,0);
fixed4 orignColor = tex2D(_MainTex, i.uv[4]);
//2D图片外轮廓
#if defined(_ShowOutline)
half alphaSum = CalculateAlphaSumAround(i);
float isNeedShow = alphaSum > _EdgeAlphaThreshold;
float damp = saturate((alphaSum - _EdgeAlphaThreshold) * _EdgeDampRate);
float isOrigon = orignColor.a > _OriginAlphaThreshold;
fixed3 finalColor = lerp(_EdgeColor.rgb, fixed3(0,0,0), isOrigon);
float finalAlpha = isNeedShow * damp * (1 - isOrigon);
outline = fixed4(finalColor.rgb, finalAlpha);
#endif
//2D图片的内发光
#if defined(_ShowInnerGlow)
//计算透明度的和
float alphaCircleSum = CalculateCircleSumAlpha(i.uv[4], _InnerGlowWidth, _InnerGlowAccuracy) / _InnerGlowAccuracy;
float innerColorAlpha = 0;
//这里获取到内发光的的透明度,并且做了渐变,让靠近边界的颜色更亮一些,原理的透明度的会越来越低
innerColorAlpha = 1 - saturate(alphaCircleSum - _InnerGlowAlphaSumThreshold) / (1 - _InnerGlowAlphaSumThreshold);
//剔除超出原图的像素的颜色。
if(orignColor.a <= _OriginAlphaThreshold)
{
innerColorAlpha = 0;
}
fixed3 innerColor = _InnerGlowColor.rgb * innerColorAlpha;
innerGlow = fixed4(innerColor.rgb, innerColorAlpha);
//return innerGlow;
#endif
//将外轮廓和内发光元颜色叠加输出。
#if defined(_ShowOutline)
float outlineAlphaDiscard = orignColor.a > _OriginAlphaThreshold;
orignColor = outlineAlphaDiscard * orignColor;
//乘2是为了更加突出外发光
return lerp(orignColor ,innerGlow * 2, _InnerGlowLerpRate * innerGlow.a) + outline;
#endif
return lerp(orignColor ,innerGlow * 2, _InnerGlowLerpRate * innerGlow.a);
//return tex2D(_MainTex, i.uv[4]) + innerGlow + outline;
}
ENDCG
}
}
}
参数设置:
实现效果:
总结一下:
看起来效果至少是我试过的好几种方法中最好的了,但是这个2D描边存在一个缺陷,就是不能服务于2D骨骼动画,因为2D骨骼动画是由一张张的图片构成,所以每张图片都做了描边就会显得非常奇怪。再说内发光,内发光的缺点也很明显,内发光的宽度不能一直放大,所以宽度的增加内发光的效果会直接将整个图片变成内发光的颜色,但是至少目前的参数设置和效果我觉得还是蛮不错的。但是我在实现过程中并没有很好的考虑性能的问题,所以其他大佬看完之后有什么批评建议希望可以告诉。
期待大佬的程序生成行星的系列视频能早点更新,期待与大家分享。如果代码有什么不理解或者不合理的都可以联系我哦~