Unity 2D Spine 外发光实现思路
前言
对于3D骨骼,要做外发光可以之间通过向法线方向延申来实现。
但是对于2D骨骼,各顶点的法线没有向3D骨骼那样拥有垂直于面的特性,那我们如何做2D骨骼的外发光效果呢?
理论基础
我们要知道,要实现外发光效果,首先得先实现外描边效果。对于2D图片的描边实现有很多种方案。
内描边:
思路:对于任意像素,如果其四周存在透明像素,则说明是边缘。
简单实现的效果如下图:
这样的边缘会非常锯齿化,因为这样做非常绝对地判断了是或不是边缘来进行上色。
如果我们不那么绝对,采取以下这种策略来进行上色:
对于任意像素,其四周的像素alpha值之积越小,则说明越靠近边缘。根据计算出的积,来使原像素颜色和边缘颜色做个线性插值(Lerp函数),以作为最后的输出颜色。
简单实现的效果如下图:
这样的边缘会比上面的更加柔和。
可以看得出来,这样的策略会占用图片的非透明像素,也就是人们所说的内描边。
外描边:
思路:对于透明像素,如果四周存在不透明像素,则说明是边缘。
和内描边一样,如果采用非常绝对的边缘判断方式,那么绘制出来的边缘就会非常锯齿化。
这里我们可以采用另一种思路:对于透明像素,如果四周像素的alpha之和越小,则说明离边缘越远。最终的边缘像素的alpha为周围像素alpha的平均值。
简单实现的效果如下图:
这样绘制出来的边缘,离原图像内容越远,越透明。有了alpha的渐变,也就有了初步的外发光效果了。
可以看得出来,这样的策略不会占用图片的非透明像素,也就是人们所说的外描边。但是却会受到图片本身绘制区域大小的影响。
图像膨胀和腐蚀:
实际上,上面的外描边和内描边的思想,就是图像的膨胀和腐蚀。
外描边说高深了,就是图像膨胀;内描边说高深了,就是图像腐蚀。
这里做个简单的科普介绍,感兴趣的小伙伴自行深入研究。
膨胀算法:
所谓膨胀算法,即使用一个n*n的矩阵去扫描图像中的每一个像素。用矩阵每一个值与其覆盖的周围一圈像素值做“与”操作,只要有任意1,那么该像素值为1。(“与”操作中都是1才是1)
膨胀之后,图像边界会向外扩大。
例子:
原图像:
0 | 0 | 0 | 0 | 0 |
0 | 0 | 0 | 0 | 0 |
0 | 0 | 1 | 0 | 0 |
0 | 0 | 0 | 0 | 0 |
0 | 0 | 0 | 0 | 0 |
膨胀算子:
0 | 1 | 0 |
1 | 1 | 1 |
0 | 1 | 0 |
最终结果:
0 | 0 | 0 | 0 | 0 |
0 | 0 | 1 | 0 | 0 |
0 | 1 | 1 | 1 | 0 |
0 | 0 | 1 | 0 | 0 |
0 | 0 | 0 | 0 | 0 |
腐蚀算法:
所谓腐蚀算法,即使用一个n*n的矩阵去扫描图像中的每一个像素。矩阵每一个值与其覆盖的周围一圈像素值做“或”操作,只要有任意0,那么该像素值为0。(“或”操作中都是0才是0)
腐蚀之后,图像边界会向内收缩。
腐蚀算子例子:
1 | 0 | 1 |
0 | 0 | 0 |
1 | 0 | 1 |
卷积:
具体定义请参考百度百科 - 卷积,这里做个简单的科普介绍,感兴趣的小伙伴自行深入研究。
简单来说就是分别乘加,最终输出各乘积之和。
实际上,上面说到的对周围像素的alpha求和取平均和后面会说到的模糊效果,说高深了都是卷积的思想。图像领域常用的边缘检测方式还包括利用Sobel算子对图像进行卷积。
上图就是4x4的矩阵应用3x3的卷积核,在步长为1的情况下,不做边缘扩展策略,最终输出为2x2的矩阵。具体计算原理及过程过程可参考Convolutional Neural Networks - Basics · Machine Learning Notebook。
遇到问题
有了上述的理论基础之后,我们再来看如何实现2D骨骼外发光,以及实现过程中需要注意和会面临的问题。
- 2D骨骼是由多张图片组成的,这意味着每张图片骨骼在渲染流程中会分别进行绘制,并且每张图片都存在绘制区域的限制。
- 要达到美术的发光效果,不仅要有描边,还要有光晕效果。
- 对每个像素进行操作,需要时刻考虑计算量,性能和美术效果会存在制衡。
初步方案
一开始打算在Shader直接实现外发光效果。
对于上述问题1,分别绘制的图片骨骼来说,我们可以采用多个Pass来避免对每个图片骨骼都进行了描边。
但是受困于每张图片骨骼存在绘制区域限制,导致最终效果光晕无法延展过长,不然会出现被图片大小截断的现象。
于是,为了扩展绘制区域,解决该问题,我们尝试使用后处理。
中间方案
既然采用图像后处理,那么肯定就需要先获得渲染出来的图像,之后再对图像逐像素进行先前的策略。
一开始想到的是用相机单独渲染目标,然后获取其渲染的RenderTexture,对它进行逐像素处理。
这里没有使用Shader,而是直接在C#中读取像素,并修改颜色。关键代码如下:
private Sprite ProcessTexture()
{
tempColors = tempTexture.GetPixels(); // 读取像素,这一步操作非常耗时,尽可能减少像素数量
for (i=0;i<textureSize;i++)
{
if (tempColors[i].a <= ALPHA_LIMIT)
{
showColors[i] = new Color(0.95f, 1f, 0.17f, GetAlpha(tempColors, i));
}
else
{
showColors[i] = tempColors[i];
}
}
tempTexture.SetPixels(showColors); // 设置像素颜色
tempTexture.Apply();
return Sprite.Create(tempTexture, rect, new Vector2(0.5f, 0.5f));
}
private float GetAlpha(Color[] colors, int index)
{
alpha = 0;
num = 0;
for (p = -LENGTH; p <= LENGTH; p++) // 步长过长计算量也会非常大,非常耗时,但是效果会更好
{
for (q = -LENGTH; q <= LENGTH; q++)
{
thisIndex = index + p + (int)rect.width * q;
if (thisIndex >= 0 && thisIndex < textureSize)
{
alpha += colors[thisIndex].a;
num++;
}
}
}
alpha /= num;
return alpha;
}
这种方式是使用Texture2D的接口来进行像素遍历,虽然能实现想要的效果,但是如果要每帧都渲染的话,计算会非常非常非常耗!最终简单实现效果图如下:
进阶方案
后来我发现可以使用Shader做一个后处理,把相机渲染出来的图像再经过这个后处理的Shader渲染一次,把结果绘制在最终的屏幕上。
使用了Shader之后,考虑到更好的光晕效果,我们可以很轻易地利用多个Pass对外描边做一个Bloom处理。
Bloom的原理是什么?
本质上就是渲染两张图。首先,我们在第一张图里像平常一样正常地渲染场景。然后,把明亮的区域渲染到第二张图里。在这之后我们把第二张图模糊,并且加到第一张图上,就得到了最终的结果。
具体实现思路,就是先绘制出外描边部分,然后对外描边部分做一个模糊效果,这样就得到了一个Bloom图。把这个Bloom图和原图进行叠加,得到最后的效果图。
下面是后处理C#部分的关键代码:
void OnRenderImage(RenderTexture source, RenderTexture dest)
{
RenderTexture rtTemp = RenderTexture.GetTemporary(1000, 1000, 0); // 中间RenderTexture
rtTemp.filterMode = FilterMode.Bilinear;
Graphics.Blit(source, rtTemp, bloomMaterial, 0); // 第一个Pass,绘制外描边
bloomMaterial.SetTexture("_BloomTex", rtTemp); // 把渲染出的外描边传到第二个Pass中
Graphics.Blit(source, dest, bloomMaterial, 1); // 第二个Pass,Bloom效果以及最终成像
RenderTexture.ReleaseTemporary(rtTemp);
img.texture = dest;
}
后处理Shader第一个Pass的片段着色器:
fixed4 frag(OutoutVertex i) : COLOR
{
fixed4 col = tex2D(_MainTex, i.uv);
float alphaAdd = 0;
// 采样周围8个点
float2 up_uv = i.uv + float2(0, 1) * _lineWidth * _MainTex_TexelSize.xy;
float2 down_uv = i.uv + float2(0, -1) * _lineWidth * _MainTex_TexelSize.xy;
float2 left_uv = i.uv + float2(-1, 0) * _lineWidth * _MainTex_TexelSize.xy;
float2 right_uv = i.uv + float2(1, 0) * _lineWidth * _MainTex_TexelSize.xy;
float2 upleft_uv = i.uv + float2(-1, 1) * _lineWidth * _MainTex_TexelSize.xy;
float2 upright_uv = i.uv + float2(1, 1) * _lineWidth * _MainTex_TexelSize.xy;
float2 downleft_uv = i.uv + float2(-1, -1) * _lineWidth * _MainTex_TexelSize.xy;
float2 downright_uv = i.uv + float2(1, -1) * _lineWidth * _MainTex_TexelSize.xy;
// 累加alpha
alphaAdd += tex2D(_MainTex, up_uv).a + tex2D(_MainTex, down_uv).a + tex2D(_MainTex, left_uv).a + tex2D(_MainTex, right_uv).a
+ tex2D(_MainTex, upleft_uv).a + tex2D(_MainTex, upright_uv).a + tex2D(_MainTex, downleft_uv).a + tex2D(_MainTex, downright_uv).a
+ col.a;
if (alphaAdd > 0 && col.a <= _alphaThreshold) // 只要周围存在非透明像素,且自身透明度小于阈值
{
col.rgb = _lineColor;
col.a = 1;
}
else
{
col = float4(0, 0, 0 ,0); // 这个Pass只需要获得描边
}
return col;
}
第一个Pass的作用是为了绘制描边,渲染出来的图如下:
后处理Shader第二个Pass的片段着色器:
fixed4 frag(OutoutVertex i) : COLOR
{
fixed4 col = tex2D(_MainTex, i.uv);
fixed4 bloomCol = tex2D(_BloomTex, i.uv); // 上一个Pass传入的_BloomTex
// 采样周围8个点
float2 up_uv = i.uv + float2(0, 1) * _bloomWidth * _MainTex_TexelSize.xy;
float2 down_uv = i.uv + float2(0, -1) * _bloomWidth * _MainTex_TexelSize.xy;
float2 left_uv = i.uv + float2(-1, 0) * _bloomWidth * _MainTex_TexelSize.xy;
float2 right_uv = i.uv + float2(1, 0) * _bloomWidth * _MainTex_TexelSize.xy;
float2 upleft_uv = i.uv + float2(-1, 1) * _bloomWidth * _MainTex_TexelSize.xy;
float2 upright_uv = i.uv + float2(1, 1) * _bloomWidth * _MainTex_TexelSize.xy;
float2 downleft_uv = i.uv + float2(-1, -1) * _bloomWidth * _MainTex_TexelSize.xy;
float2 downright_uv = i.uv + float2(1, -1) * _bloomWidth * _MainTex_TexelSize.xy;
fixed4 color = tex2D(_BloomTex, up_uv) + tex2D(_BloomTex, down_uv) + tex2D(_BloomTex, left_uv) + tex2D(_BloomTex, right_uv) +
tex2D(_BloomTex, upleft_uv) + tex2D(_BloomTex, upright_uv) + tex2D(_BloomTex, downleft_uv) + tex2D(_BloomTex, downright_uv) +
bloomCol;
color /= 9; // 均值模糊
return bloomCol + color + col * _weight; // 模糊结果与原像素一定比例求和
}
第二个Pass对之前获得的描边做了一次简单的模糊,之后再与原图像颜色进行叠加,就实现了一个简单的描边Bloom效果。渲染出来的图如下:
颜色叠加之后,最终效果如下图:
这种策略在开销相对较小的情况下实现了较好的效果,整体性价比比较高。如果想要更宽的光晕效果,而且还要做到合理不穿帮,可以增加计算量或者优化策略。
小结
总的来说,上面提到的几种方式,并不是一套完完整整的项目代码,只是一系列解决问题的思路和策略,相当于是抛砖引玉,一旦带入到项目中就需要具体问题具体分析了。当然,肯定还有各种各样的优化方法,以及一些更好计算方法。但无论采用何种策略,最终都会是性能和美术效果的平衡。
参考
Convolutional Neural Networks - Basics · Machine Learning Notebook
百度百科 - 卷积
【Unity学习心得】Sprite外发光的制作
Unity实现bloom效果
Created a Spine Edge Shader
https://developer.unity.cn/projects/cel-shading-trick