来自于:GuyaWeiren

前言

大扎好,我系狗猥。当大家都以为我鸽了的时候,我又出现了,这也是一种鸽。创业两年失败后归来,今天想给大家分享一个我最近研究出来的好康的,比游戏还刺激,还可以教你登dua郎喔(大误
这次给大家带来的是基于Shader实现的UGUI描边,也支持对Text组件使用。
首先请大家看看最终效果(上面放了一个Image和一个Text):
基于Shader实现的UGUI描边解决方案 - 图1
(8102年了怎么还在舰
接下来,我会向大家介绍思路和具体实现过程。如果你想直接代到项目里使用,请自行跳转到本文最后,那里有完整的C#和Shader代码。
本方案在Unity 2018.4.0f1下测试通过。
本文参考了http://blog.sina.com.cn/s/blog_6ad33d350102xb7v.html
转载请注明出处:https://www.cnblogs.com/GuyaWeiren/p/9665106.html


为什么要这么做

就我参加工作这些年接触到的UI美术来看,他们都挺喜欢用描边效果。诚然这个效果可以让文字更加突出,看着也挺不错。对美术来说做描边简单的一比,PS里加个图层样式就搞定,但是对我们程序来说就是一件很痛苦的事。
UGUI自带的Outline组件用过的同学都知道,本质上是把元素复制四份,然后做一些偏移绘制出来。但是把偏移量放大,瞬间就穿帮了。如果美术要求做一个稍微宽一点的描边,这个组件是无法实现的
基于Shader实现的UGUI描边解决方案 - 图2
然后有先辈提出按照Outline实现方式,增加复制份数的方法。请参考https://github.com/n-yoda/unity-vertex-effects。确实非常漂亮。但是这个做法有一个非常严重的问题:数量如此大的顶点数,对性能会有影响。我们知道每个字符是由两个三角形构成,总共6个顶点。如果文字数量大,再加上一个复制N份的脚本,顶点数会 分分钟炸掉。
以复制8次为例,一段200字的文本在进行处理后会生成200 6 (8+1) = 10800 个顶点,多么可怕。并且,Unity5.2以前的版本要求,每一个Canvas下至多只能有65535个顶点,超过就会报错。
TextMeshPro能做很多漂亮的效果。但是它的做法类似于图字,要提供所有会出现的字符。对于字符很少的英语环境,这没有问题,但对于中文环境,把所有字符弄进去是不现实的。还有最关键的是,它是作用于TextMesh组件,而不是UGUI的Text。
于是乎,使用Shader变成了最优解。
概括讲,这个实现就是在C#代码中对UI顶点根据描边宽度进行外扩,然后在Shader的像素着色器中对像素的一周以描边宽度为半径采N个样,最后将颜色叠加起来。通常需要描边的元素尺寸都不大,故多重采样带来的性能影响几乎是可以忽略的。


在Shader中实现描边

创建一个OutlineEx.shader。对于描边,我们需要两个参数:描边的颜色和描边的宽度。所以首先将这两个参数添加到Shader的属性中:

  1. _OutlineColor("Outline Color", Color) = (1, 1, 1, 1)
  2. _OutlineWidth("Outline Width", Int) = 1

采样坐标用圆的参数方程计算。在Shader中进行三角函数运算比较吃性能,并且这里采样的角度是固定的,所以我们可以把坐标直接写死。在Shader中添加采样的函数。因为最终进行颜色混合的时候只需要用到alpha值,所以函数不返回rgb:

  1. fixed SampleAlpha(int pIndex, v2f IN)
  2. {
  3. const fixed sinArray[12] = { 0, 0.5, 0.866, 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5 };
  4. const fixed cosArray[12] = { 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5, 0, 0.5, 0.866 };
  5. float2 pos = IN.texcoord + _MainTex_TexelSize.xy * float2(cosArray[pIndex], sinArray[pIndex]) * _OutlineWidth;
  6. return (tex2D(_MainTex, pos) + _TextureSampleAdd).w * _OutlineColor.w;
  7. }

然后在像素着色器中增加对方法的调用。

  1. fixed4 frag(v2f IN) : SV_Target
  2. {
  3. fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
  4. half4 val = half4(_OutlineColor.x, _OutlineColor.y, _OutlineColor.z, 0);
  5. // 注意:这里为了简化代码用了循环
  6. // 尽量不要在Shader中使用循环,多复制几次代码都行
  7. for (int i = 0; i < 12; i++)
  8. {
  9. val.w += SampleAlpha(i, IN);
  10. }
  11. color = (val * (1.0 - color.a)) + (color * color.a);
  12. return color;
  13. }

接下来,在Unity中新建一个材质球,把Shader赋上去,挂在一个UGUI组件上,然后调整描边颜色和宽度,可以看到效果:
基于Shader实现的UGUI描边解决方案 - 图3
可以看到描边已经出现了,但是超出图片范围的部分被裁减掉了。所以接下来,我们需要对图片的区域进行调整,保证描边的部分也被包含在区域内。


在C#层进行区域扩展

要扩展区域,就得修改顶点。Unity提供了BaseMeshEffect类供开发者对UI组件的顶点进行修改。
创建一个OutlineEx类,继承于BaseMeshEffect类,实现其中的ModifyMesh(VertexHelper)方法。参数VertexHelper类提供了GetUIVertexStream(List)和AddUIVertexTriangleStream(List)方法用于获取和设置UI物件的顶点。
这里我们可以把参数需要的List提出来做成静态变量,这样能够避免每次ModifyMesh调用时创建List对象。

  1. public class OutlineEx : BaseMeshEffect
  2. {
  3. public Color OutlineColor = Color.white;
  4. [Range(0, 6)]
  5. public int OutlineWidth = 0;
  6. private static List<UIVertex> m_VetexList = new List<UIVertex>();
  7. protected override void Awake()
  8. {
  9. base.Awake();
  10. var shader = Shader.Find("TSF Shaders/UI/OutlineEx");
  11. base.graphic.material = new Material(shader);
  12. var v1 = base.graphic.canvas.additionalShaderChannels;
  13. var v2 = AdditionalCanvasShaderChannels.Tangent;
  14. if ((v1 & v2) != v2)
  15. {
  16. base.graphic.canvas.additionalShaderChannels |= v2;
  17. }
  18. this._Refresh();
  19. }
  20. #if UNITY_EDITOR
  21. protected override void OnValidate()
  22. {
  23. base.OnValidate();
  24. if (base.graphic.material != null)
  25. {
  26. this._Refresh();
  27. }
  28. }
  29. #endif
  30. private void _Refresh()
  31. {
  32. base.graphic.material.SetColor("_OutlineColor", this.OutlineColor);
  33. base.graphic.material.SetInt("_OutlineWidth", this.OutlineWidth);
  34. base.graphic.SetVerticesDirty();
  35. }
  36. public override void ModifyMesh(VertexHelper vh)
  37. {
  38. vh.GetUIVertexStream(m_VetexList);
  39. this._ProcessVertices();
  40. vh.Clear();
  41. vh.AddUIVertexTriangleStream(m_VetexList);
  42. }
  43. private void _ProcessVertices()
  44. {
  45. // TODO: 处理顶点
  46. }
  47. }

现在已经可以获取到所有的顶点信息了。接下来我们对它进行外扩。
我们知道每三个顶点构成一个三角形,所以需要对构成三角形的三个顶点进行处理,并且要将它的UV坐标(决定图片在图集中的范围)也做对应的外扩,否则从视觉上看起来就只是图片被放大了一点点。
于是完成_ProcessVertices方法:

  1. private void _ProcessVertices()
  2. {
  3. for (int i = 0, count = m_VetexList.Count - 3; i <= count; i += 3)
  4. {
  5. var v1 = m_VetexList[i];
  6. var v2 = m_VetexList[i + 1];
  7. var v3 = m_VetexList[i + 2];
  8. // 计算原顶点坐标中心点
  9. //
  10. var minX = _Min(v1.position.x, v2.position.x, v3.position.x);
  11. var minY = _Min(v1.position.y, v2.position.y, v3.position.y);
  12. var maxX = _Max(v1.position.x, v2.position.x, v3.position.x);
  13. var maxY = _Max(v1.position.y, v2.position.y, v3.position.y);
  14. var posCenter = new Vector2(minX + maxX, minY + maxY) * 0.5f;
  15. // 计算原始顶点坐标和UV的方向
  16. //
  17. Vector2 triX, triY, uvX, uvY;
  18. Vector2 pos1 = v1.position;
  19. Vector2 pos2 = v2.position;
  20. Vector2 pos3 = v3.position;
  21. if (Mathf.Abs(Vector2.Dot((pos2 - pos1).normalized, Vector2.right))
  22. > Mathf.Abs(Vector2.Dot((pos3 - pos2).normalized, Vector2.right)))
  23. {
  24. triX = pos2 - pos1;
  25. triY = pos3 - pos2;
  26. uvX = v2.uv0 - v1.uv0;
  27. uvY = v3.uv0 - v2.uv0;
  28. }
  29. else
  30. {
  31. triX = pos3 - pos2;
  32. triY = pos2 - pos1;
  33. uvX = v3.uv0 - v2.uv0;
  34. uvY = v2.uv0 - v1.uv0;
  35. }
  36. // 为每个顶点设置新的Position和UV
  37. //
  38. v1 = _SetNewPosAndUV(v1, this.OutlineWidth, posCenter, triX, triY, uvX, uvY);
  39. v2 = _SetNewPosAndUV(v2, this.OutlineWidth, posCenter, triX, triY, uvX, uvY);
  40. v3 = _SetNewPosAndUV(v3, this.OutlineWidth, posCenter, triX, triY, uvX, uvY);
  41. // 应用设置后的UIVertex
  42. //
  43. m_VetexList[i] = v1;
  44. m_VetexList[i + 1] = v2;
  45. m_VetexList[i + 2] = v3;
  46. }
  47. }
  48. private static UIVertex _SetNewPosAndUV(UIVertex pVertex, int pOutLineWidth,
  49. Vector2 pPosCenter,
  50. Vector2 pTriangleX, Vector2 pTriangleY,
  51. Vector2 pUVX, Vector2 pUVY)
  52. {
  53. // Position
  54. var pos = pVertex.position;
  55. var posXOffset = pos.x > pPosCenter.x ? pOutLineWidth : -pOutLineWidth;
  56. var posYOffset = pos.y > pPosCenter.y ? pOutLineWidth : -pOutLineWidth;
  57. pos.x += posXOffset;
  58. pos.y += posYOffset;
  59. pVertex.position = pos;
  60. // UV
  61. var uv = pVertex.uv0;
  62. uv += pUVX / pTriangleX.magnitude * posXOffset * (Vector2.Dot(pTriangleX, Vector2.right) > 0 ? 1 : -1);
  63. uv += pUVY / pTriangleY.magnitude * posYOffset * (Vector2.Dot(pTriangleY, Vector2.up) > 0 ? 1 : -1);
  64. pVertex.uv0 = uv;
  65. return pVertex;
  66. }
  67. private static float _Min(float pA, float pB, float pC)
  68. {
  69. return Mathf.Min(Mathf.Min(pA, pB), pC);
  70. }
  71. private static float _Max(float pA, float pB, float pC)
  72. {
  73. return Mathf.Max(Mathf.Max(pA, pB), pC);
  74. }

然后可以在编辑器中调整描边颜色和宽度,可以看到效果:
基于Shader实现的UGUI描边解决方案 - 图4
OJ8K,现在范围已经被扩大,可以看到上下左右四个边的描边宽度没有被裁掉了。


UV裁剪,排除不需要的像素

在上一步的效果图中,我们可以注意到图片的边界出现了被拉伸的部分。如果使用了图集或字体,在UV扩大后图片附近的像素也会被包含进来。为什么会变成这样呢?(先打死)
因为前面说过,UV裁剪框就相当于图集中每个小图的范围。直接扩大必然会包含到小图邻接的图的像素。所以这一步我们需要对最终绘制出的图进行裁剪,保证这些不要的像素不被画出来。
裁剪的逻辑也很简单。如果该像素处于被扩大前的UV范围外,则设置它的alpha为0。这一步需要放在像素着色器中完成。如何将原始UV区域传进Shader是一个问题。对于Text组件,所有字符的顶点都会进入Shader处理,所以在Shader中添加属性是不现实的。
好在Unity为我们提供了门路,可以看UIVertex结构体的成员:

  1. public struct UIVertex
  2. {
  3. public static UIVertex simpleVert;
  4. public Vector3 position;
  5. public Vector3 normal;
  6. public Color32 color;
  7. public Vector2 uv0;
  8. public Vector2 uv1;
  9. public Vector2 uv2;
  10. public Vector2 uv3;
  11. public Vector4 tangent;
  12. }

而Unity默认只会使用到position、normal、uv0和color,其他成员是不会使用的。所以我们可以考虑将原始UV框的数据(最小x,最小y,最大x,最大y)赋值给tangent成员,因为它刚好是一个Vector4类型。
当然,你想把数据分别放在uv1和uv2中也是可以的。
这里感谢真木网友的指正,UI在缩放时,tangent的值会被影响,导致描边显示不全甚至完全消失,所以应该赋值给uv1和uv2。经测试,Unity 5.6自身有bug,uv2和uv3无论怎么设置都不会被传入shader,但在2017.3.1p1和2018上测试通过。如果必须要使用低版本Unity,可以考虑使用uv1和tangent.zw存储原始UV框的四个值,但要求UI的Z轴不能缩放,且Canvas和摄像机必须正交。
需要注意的是,在Unity5.4(大概是这个版本吧,记不清了)之后,UIVertex的非必须成员的数据默认不会被传递进Shader。所以我们需要修改UI组件的Canvas的additionalShaderChannels属性,让uv1和uv2成员也传入Shader。

  1. var v1 = base.graphic.canvas.additionalShaderChannels;
  2. var v2 = AdditionalCanvasShaderChannels.TexCoord1;
  3. if ((v1 & v2) != v2)
  4. {
  5. base.graphic.canvas.additionalShaderChannels |= v2;
  6. }
  7. v2 = AdditionalCanvasShaderChannels.TexCoord2;
  8. if ((v1 & v2) != v2)
  9. {
  10. base.graphic.canvas.additionalShaderChannels |= v2;
  11. }

将原始UV框赋值给uv1和uv2成员

  1. var uvMin = _Min(v1.uv0, v2.uv0, v3.uv0);
  2. var uvMax = _Max(v1.uv0, v2.uv0, v3.uv0);
  3. vertex.uv1 = new Vector2(pUVOrigin.x, pUVOrigin.y);
  4. vertex.uv2 = new Vector2(pUVOrigin.z, pUVOrigin.w);
  5. private static Vector2 _Min(Vector2 pA, Vector2 pB, Vector2 pC)
  6. {
  7. return new Vector2(_Min(pA.x, pB.x, pC.x), _Min(pA.y, pB.y, pC.y));
  8. }
  9. private static Vector2 _Max(Vector2 pA, Vector2 pB, Vector2 pC)
  10. {
  11. return new Vector2(_Max(pA.x, pB.x, pC.x), _Max(pA.y, pB.y, pC.y));
  12. }

然后在Shader的顶点着色器中获取它:

  1. struct appdata
  2. {
  3. // 省略
  4. float2 texcoord1 : TEXCOORD1;
  5. float2 texcoord2 : TEXCOORD2;
  6. };
  7. struct v2f
  8. {
  9. // 省略
  10. float2 uvOriginXY : TEXCOORD1;
  11. float2 uvOriginZW : TEXCOORD2;
  12. };
  13. v2f vert(appdata IN)
  14. {
  15. // 省略
  16. o.uvOriginXY = IN.texcoord1;
  17. o.uvOriginZW = IN.texcoord2;
  18. // 省略
  19. }

判定一个点是否在给定矩形框内,可以用到内置的step函数。它常用于作比较,替代if/else语句提高效率。它的逻辑是:顺序给定两个参数a和b,如果 a > b 返回0,否则返回1。
添加判定函数:

  1. fixed IsInRect(float2 pPos, float2 pClipRectXY, float2 pClipRectZW)
  2. {
  3. pPos = step(pClipRectXY, pPos) * step(pPos, pClipRectZW);
  4. return pPos.x * pPos.y;
  5. }

然后在采样和像素着色器中添加对它的调用:

  1. fixed SampleAlpha(int pIndex, v2f IN)
  2. {
  3. // 省略
  4. return IsInRect(pos, IN.uvOriginXY, IN.uvOriginZW) * (tex2D(_MainTex, pos) + _TextureSampleAdd).w * _OutlineColor.w;
  5. }
  6. fixed4 frag(v2f IN) : SV_Target
  7. {
  8. // 省略
  9. if (_OutlineWidth > 0)
  10. {
  11. color.w *= IsInRect(IN.texcoord, IN.uvOriginXY, IN.uvOriginZW);
  12. // 省略
  13. }
  14. }

最终代码

那么现在就可以得到最终效果了。在我的代码中,对每个像素做了12次采样。如果美术要求对大图片进行比较粗的描边,需要增加采样次数。当然,如果字本身小,也可以降低次数。
由于这个Shader是给UI用的,所以需要将UI-Default.shader中的一些属性和设置复制到我们的Shader中。

  1. //————————————————————————————————————————————
  2. // OutlineEx.cs
  3. //
  4. // Created by Chiyu Ren on 2018/9/12 23:03:51
  5. //————————————————————————————————————————————
  6. using UnityEngine;
  7. using UnityEngine.UI;
  8. using System.Collections.Generic;
  9. namespace TooSimpleFramework.UI
  10. {
  11. /// <summary>
  12. /// UGUI描边
  13. /// </summary>
  14. public class OutlineEx : BaseMeshEffect
  15. {
  16. public Color OutlineColor = Color.white;
  17. [Range(0, 6)]
  18. public int OutlineWidth = 0;
  19. private static List<UIVertex> m_VetexList = new List<UIVertex>();
  20. protected override void Start()
  21. {
  22. base.Start();
  23. var shader = Shader.Find("TSF Shaders/UI/OutlineEx");
  24. base.graphic.material = new Material(shader);
  25. var v1 = base.graphic.canvas.additionalShaderChannels;
  26. var v2 = AdditionalCanvasShaderChannels.TexCoord1;
  27. if ((v1 & v2) != v2)
  28. {
  29. base.graphic.canvas.additionalShaderChannels |= v2;
  30. }
  31. v2 = AdditionalCanvasShaderChannels.TexCoord2;
  32. if ((v1 & v2) != v2)
  33. {
  34. base.graphic.canvas.additionalShaderChannels |= v2;
  35. }
  36. this._Refresh();
  37. }
  38. #if UNITY_EDITOR
  39. protected override void OnValidate()
  40. {
  41. base.OnValidate();
  42. if (base.graphic.material != null)
  43. {
  44. this._Refresh();
  45. }
  46. }
  47. #endif
  48. private void _Refresh()
  49. {
  50. base.graphic.material.SetColor("_OutlineColor", this.OutlineColor);
  51. base.graphic.material.SetInt("_OutlineWidth", this.OutlineWidth);
  52. base.graphic.SetVerticesDirty();
  53. }
  54. public override void ModifyMesh(VertexHelper vh)
  55. {
  56. vh.GetUIVertexStream(m_VetexList);
  57. this._ProcessVertices();
  58. vh.Clear();
  59. vh.AddUIVertexTriangleStream(m_VetexList);
  60. }
  61. private void _ProcessVertices()
  62. {
  63. for (int i = 0, count = m_VetexList.Count - 3; i <= count; i += 3)
  64. {
  65. var v1 = m_VetexList[i];
  66. var v2 = m_VetexList[i + 1];
  67. var v3 = m_VetexList[i + 2];
  68. // 计算原顶点坐标中心点
  69. //
  70. var minX = _Min(v1.position.x, v2.position.x, v3.position.x);
  71. var minY = _Min(v1.position.y, v2.position.y, v3.position.y);
  72. var maxX = _Max(v1.position.x, v2.position.x, v3.position.x);
  73. var maxY = _Max(v1.position.y, v2.position.y, v3.position.y);
  74. var posCenter = new Vector2(minX + maxX, minY + maxY) * 0.5f;
  75. // 计算原始顶点坐标和UV的方向
  76. //
  77. Vector2 triX, triY, uvX, uvY;
  78. Vector2 pos1 = v1.position;
  79. Vector2 pos2 = v2.position;
  80. Vector2 pos3 = v3.position;
  81. if (Mathf.Abs(Vector2.Dot((pos2 - pos1).normalized, Vector2.right))
  82. > Mathf.Abs(Vector2.Dot((pos3 - pos2).normalized, Vector2.right)))
  83. {
  84. triX = pos2 - pos1;
  85. triY = pos3 - pos2;
  86. uvX = v2.uv0 - v1.uv0;
  87. uvY = v3.uv0 - v2.uv0;
  88. }
  89. else
  90. {
  91. triX = pos3 - pos2;
  92. triY = pos2 - pos1;
  93. uvX = v3.uv0 - v2.uv0;
  94. uvY = v2.uv0 - v1.uv0;
  95. }
  96. // 计算原始UV框
  97. //
  98. var uvMin = _Min(v1.uv0, v2.uv0, v3.uv0);
  99. var uvMax = _Max(v1.uv0, v2.uv0, v3.uv0);
  100. var uvOrigin = new Vector4(uvMin.x, uvMin.y, uvMax.x, uvMax.y);
  101. // 为每个顶点设置新的Position和UV,并传入原始UV框
  102. //
  103. v1 = _SetNewPosAndUV(v1, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin);
  104. v2 = _SetNewPosAndUV(v2, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin);
  105. v3 = _SetNewPosAndUV(v3, this.OutlineWidth, posCenter, triX, triY, uvX, uvY, uvOrigin);
  106. // 应用设置后的UIVertex
  107. //
  108. m_VetexList[i] = v1;
  109. m_VetexList[i + 1] = v2;
  110. m_VetexList[i + 2] = v3;
  111. }
  112. }
  113. private static UIVertex _SetNewPosAndUV(UIVertex pVertex, int pOutLineWidth,
  114. Vector2 pPosCenter,
  115. Vector2 pTriangleX, Vector2 pTriangleY,
  116. Vector2 pUVX, Vector2 pUVY,
  117. Vector4 pUVOrigin)
  118. {
  119. // Position
  120. var pos = pVertex.position;
  121. var posXOffset = pos.x > pPosCenter.x ? pOutLineWidth : -pOutLineWidth;
  122. var posYOffset = pos.y > pPosCenter.y ? pOutLineWidth : -pOutLineWidth;
  123. pos.x += posXOffset;
  124. pos.y += posYOffset;
  125. pVertex.position = pos;
  126. // UV
  127. var uv = pVertex.uv0;
  128. uv += pUVX / pTriangleX.magnitude * posXOffset * (Vector2.Dot(pTriangleX, Vector2.right) > 0 ? 1 : -1);
  129. uv += pUVY / pTriangleY.magnitude * posYOffset * (Vector2.Dot(pTriangleY, Vector2.up) > 0 ? 1 : -1);
  130. pVertex.uv0 = uv;
  131. // 原始UV框
  132. pVertex.uv1 = new Vector2(pUVOrigin.x, pUVOrigin.y);
  133. pVertex.uv2 = new Vector2(pUVOrigin.z, pUVOrigin.w);
  134. return pVertex;
  135. }
  136. private static float _Min(float pA, float pB, float pC)
  137. {
  138. return Mathf.Min(Mathf.Min(pA, pB), pC);
  139. }
  140. private static float _Max(float pA, float pB, float pC)
  141. {
  142. return Mathf.Max(Mathf.Max(pA, pB), pC);
  143. }
  144. private static Vector2 _Min(Vector2 pA, Vector2 pB, Vector2 pC)
  145. {
  146. return new Vector2(_Min(pA.x, pB.x, pC.x), _Min(pA.y, pB.y, pC.y));
  147. }
  148. private static Vector2 _Max(Vector2 pA, Vector2 pB, Vector2 pC)
  149. {
  150. return new Vector2(_Max(pA.x, pB.x, pC.x), _Max(pA.y, pB.y, pC.y));
  151. }
  152. }
  153. }

Shader

  1. Shader "TSF Shaders/UI/OutlineEx"
  2. {
  3. Properties
  4. {
  5. _MainTex ("Main Texture", 2D) = "white" {}
  6. _Color ("Tint", Color) = (1, 1, 1, 1)
  7. _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1)
  8. _OutlineWidth ("Outline Width", Int) = 1
  9. _StencilComp ("Stencil Comparison", Float) = 8
  10. _Stencil ("Stencil ID", Float) = 0
  11. _StencilOp ("Stencil Operation", Float) = 0
  12. _StencilWriteMask ("Stencil Write Mask", Float) = 255
  13. _StencilReadMask ("Stencil Read Mask", Float) = 255
  14. _ColorMask ("Color Mask", Float) = 15
  15. [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
  16. }
  17. SubShader
  18. {
  19. Tags
  20. {
  21. "Queue"="Transparent"
  22. "IgnoreProjector"="True"
  23. "RenderType"="Transparent"
  24. "PreviewType"="Plane"
  25. "CanUseSpriteAtlas"="True"
  26. }
  27. Stencil
  28. {
  29. Ref [_Stencil]
  30. Comp [_StencilComp]
  31. Pass [_StencilOp]
  32. ReadMask [_StencilReadMask]
  33. WriteMask [_StencilWriteMask]
  34. }
  35. Cull Off
  36. Lighting Off
  37. ZWrite Off
  38. ZTest [unity_GUIZTestMode]
  39. Blend SrcAlpha OneMinusSrcAlpha
  40. ColorMask [_ColorMask]
  41. Pass
  42. {
  43. Name "OUTLINE"
  44. CGPROGRAM
  45. #pragma vertex vert
  46. #pragma fragment frag
  47. sampler2D _MainTex;
  48. fixed4 _Color;
  49. fixed4 _TextureSampleAdd;
  50. float4 _MainTex_TexelSize;
  51. float4 _OutlineColor;
  52. int _OutlineWidth;
  53. struct appdata
  54. {
  55. float4 vertex : POSITION;
  56. float2 texcoord : TEXCOORD0;
  57. float2 texcoord1 : TEXCOORD1;
  58. float2 texcoord2 : TEXCOORD2;
  59. fixed4 color : COLOR;
  60. };
  61. struct v2f
  62. {
  63. float4 vertex : SV_POSITION;
  64. float2 texcoord : TEXCOORD0;
  65. float2 uvOriginXY : TEXCOORD1;
  66. float2 uvOriginZW : TEXCOORD2;
  67. fixed4 color : COLOR;
  68. };
  69. v2f vert(appdata IN)
  70. {
  71. v2f o;
  72. o.vertex = UnityObjectToClipPos(IN.vertex);
  73. o.texcoord = IN.texcoord;
  74. o.uvOriginXY = IN.texcoord1;
  75. o.uvOriginZW = IN.texcoord2;
  76. o.color = IN.color * _Color;
  77. return o;
  78. }
  79. fixed IsInRect(float2 pPos, float2 pClipRectXY, float2 pClipRectZW)
  80. {
  81. pPos = step(pClipRectXY, pPos) * step(pPos, pClipRectZW);
  82. return pPos.x * pPos.y;
  83. }
  84. fixed SampleAlpha(int pIndex, v2f IN)
  85. {
  86. const fixed sinArray[12] = { 0, 0.5, 0.866, 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5 };
  87. const fixed cosArray[12] = { 1, 0.866, 0.5, 0, -0.5, -0.866, -1, -0.866, -0.5, 0, 0.5, 0.866 };
  88. float2 pos = IN.texcoord + _MainTex_TexelSize.xy * float2(cosArray[pIndex], sinArray[pIndex]) * _OutlineWidth;
  89. return IsInRect(pos, IN.uvOriginXY, IN.uvOriginZW) * (tex2D(_MainTex, pos) + _TextureSampleAdd).w * _OutlineColor.w;
  90. }
  91. fixed4 frag(v2f IN) : SV_Target
  92. {
  93. fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
  94. if (_OutlineWidth > 0)
  95. {
  96. color.w *= IsInRect(IN.texcoord, IN.uvOriginXY, IN.uvOriginZW);
  97. half4 val = half4(_OutlineColor.x, _OutlineColor.y, _OutlineColor.z, 0);
  98. val.w += SampleAlpha(0, IN);
  99. val.w += SampleAlpha(1, IN);
  100. val.w += SampleAlpha(2, IN);
  101. val.w += SampleAlpha(3, IN);
  102. val.w += SampleAlpha(4, IN);
  103. val.w += SampleAlpha(5, IN);
  104. val.w += SampleAlpha(6, IN);
  105. val.w += SampleAlpha(7, IN);
  106. val.w += SampleAlpha(8, IN);
  107. val.w += SampleAlpha(9, IN);
  108. val.w += SampleAlpha(10, IN);
  109. val.w += SampleAlpha(11, IN);
  110. val.w = clamp(val.w, 0, 1);
  111. color = (val * (1.0 - color.a)) + (color * color.a);
  112. }
  113. return color;
  114. }
  115. ENDCG
  116. }
  117. }
  118. }

最终效果:
基于Shader实现的UGUI描边解决方案 - 图5


优化点

可以看到在最后的像素着色器中使用了if语句。因为我比较菜,写出来的颜色混合算法在描边宽度为0的时候看起来效果很不好。
如果有大神能提供一个更优的算法,欢迎在评论中把我批判一番。把if语句去掉,可以提升一定的性能。
还有一点是,如果将图片或文字本身的透明度设为0,并不能得到镂空的效果。如果美术提出要这个效果,请毫不犹豫打死(误
最后一点,仔细观察上面最终效果的Ass,可以发现它们的字符本身被后一个字符的描边覆盖了一部分。使用两个Pass可以解决,一个只绘制描边,另一个只绘制本身。
Pass1

  1. fixed4 frag(v2f IN) : SV_Target
  2. {
  3. // 省略
  4. val.w = clamp(val.w, 0, 1);
  5. return val;
  6. }

Pass2

  1. fixed4 frag(v2f IN) : SV_Target
  2. {
  3. fixed4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
  4. color.w *= IsInRect(IN.texcoord, IN.uvOriginXY, IN.uvOriginZW);
  5. return color;
  6. }

改动很简单,具体实现就留给读者了。


后记

首先要感谢提供这个思路的原作者。不然我还真想不出可以这么做。看来我毕竟还是图样。
希望这篇博文能帮到需要的朋友,因为网上几乎没有这个的教程。之前在别人的博客看到一句话:人生就是水桶,前三十年大家给你灌水,后三十年你给大家灌水。感觉挺有意思。今后会继续分享一些自己搞出的、网上少有的东西(虽然我还没到30)。
最近倒是没有特别在做什么,不过有在学习Shader,进入了未知♂领域。买了一些书,想给大家推荐冯乐乐的《Unity Shader入门精要》(博客https://blog.csdn.net/candycat1992/),对入门挺有帮助。知道该书作者是比我小一岁但是比我牛逼太多的美女程序媛(不要YY了,有对象的)的时候我真的受到了极大刺激。一个妹子都能钻得这么深,我应该更加努力啊。学习是从摇篮到坟墓的过程,希望大家不管学什么都要坚持。
还有一点就是创业真的要谨慎。最近了解到国家出了条例要对国产游戏限量发行,对各个游戏公司想必都是一记闷锤。加之统一征收社保,引起的连锁反应必然会波及到游戏行业。唯一欣慰的是我们还能做游戏,还能在这条路上继续走。那么就继续走下去吧,不要停下来啊!(指加班)
很惭愧,就做了一点微小的工作,谢谢大家!