一种UGUI的Outline描边优化方案
1.起因
我们最近的项目使用了UGUI作为UI系统,在使用UGUI的过程中遇到了不少问题,其中一个问题是:描边的文字与美术同学出的效果图有不小的差异。效果图的描边效果连贯而且均匀,而UGUI的Outline组件的效果仅仅只是解决了“温饱问题”,Better Than Not而已,并且这种实现方式带来了其他问题,比如顶点数量的大幅增加。
图1.美术同学的效果图与实际实现的对比(放大至300%)
2.原因分析
UGUI已经部分开源,通过研究Outline的源码可以看到,Outline组件的原理是在原顶点的基础上,在effectDistance.(x,y),
(x, -y), (-x,y), (-x,-y)这4个偏移上ApplyShadowZeroAlloc实现的。亦即Outline组件是在Shadow的基础上实现的,Outline相当于4个不同偏移方向上的Shadow。
图2. UGUI Outline组件的核心源码
进一步查看Shadow的源码,发现Shadow的原理实际上是将原始的顶点数据复制一份, 根据设置的偏移量计算复制后的新顶点的位置,并为新的顶点颜色赋值为设置的颜色值。
图3. Shadow组件的核心源码
顺便吐槽一下Shadow组件的ApplyShadow方法和ApplyShadowZeroAlloc方法,恕我愚钝没有看出这样设计的好处。推测可能是为了跟老版本兼容。
我们可以简单地将Outline的参数EffectDistance调整到一个较大的值即可观察到4次复制。实际上Outline在处理1像素以上的描边时就会出现较大瑕疵,超过1.5像素就已经不可用了。
图4.Outline偏移参数设置为(10,10)时观察到的4次复制
3.业界的一般做法
通过请教其他同样使用UGUI的团队以及Google可用的方案,一般有如下几种解决方案:
①基于Shadow
Outline的实现是在原始顶点的4个方向ApplyShadow,为了描边效果更好,可以在8个方向甚至16个方向ApplyShadow来实现饱满的描边效果。实现原理与Outline类似。
②TextMeshPro
看了TextMeshPro官方视频的介绍是令人热血沸腾的,基本解决了策划和美术对UI字体的各种要求。
然而TextMeshPro使用时需要制作字体文件,即FontAsset。对于英文及数字来说,只需要针对ASCII字符集制作FontAsset即可,但对于中文需要动态生成的文字来说,需要生成的字体文件相对较大,对于手游项目来说,对包量及内存的影响在目前来看都是不能接受的。
但TextMeshPro用在可确定的中文字符及ASCII字符上的效果还是不错的,比如用在登录、主界面这类文字不需要动态生成的界面上。
③基于Mesh
innogames的一位先驱在Blog上发了一篇通过调整Mesh来对字体描边的思路,奈何2017年4月左右innogames的Blog禁止匿名登录了,通过转载的残缺信息整理实现思路如下:
a.提取文字原始UV区域,扩大文字绘图区域
b.对文字纹理的每个像素点周围多次采样,应用描边RGBA作为新的点的颜色
c.将原始纹理及采样后的点进行融合。
4.第一次优化
采用基于Shadow的实现方式,多次ApplyShadow以使描边效果饱和。
Outline的实现方式是在(x,-y),(x,y),(-x,y),(-x,-y)4个方向ApplyShadow。模仿Outline的实现有了新的BoldOutline,在原有的4个方向之外增加(x,0),(-x,0),(0,y),(0,-y)4个方向共计8个方向的描边。描边效果较Outline有了一定提升。
图5.Outline和BoldOutline效果对比
当把这样的描边效果应用于场景中人物头顶信息展示时,美术勉强接受了这样的实现效果。然而我却被老大约谈了……原因是渲染数据统计窗口(Rendering
Statistic Window)中摄像机视野内渲染的顶点总数异常,准确地说是顶点总数爆炸了。
理论计算Unity中字符与三角形及顶点数量的关系理应如下表所示:
表1.理论计算的各效果数值表
从理论上计算,Outline对于1个字符是绘制了5次,BoldOutline是绘制了9次,顶点总数及三角形总数相对于无效果的字符分别应该是5倍和9倍的关系。
但是实际运行时却啪啪啪的推翻了这个理论值。
表2.实际运行时的各效果数值表
图6. 各效果运行时的数据截图
之所以会出现理论与实际的偏差,就得继续从这些效果实现的原理说起了。
在UGUI中Text是由TextGenerator产生顶点数据,通过顶点数据与字体贴图渲染到屏幕上的。Text组件继承自MaskableGraphic,MaskableGraphic又继承自Graphic,Text组件实现了protected virtual void OnPopulateMesh(VertexHelper vh)方法为所需的数据赋值。实现这些文字效果的基础是Unity为外部提供的接口IMeshModifier,如果Text组件所在的GameObject中存在实现了IMeshModifier接口的组件,就会调用对应组件的ModifyMesh方法。这样就可以在外部修改Text的顶点数据了。
图7. 典型的顶点修改组件结构
了解了各种文字效果实现的原理,继续来看关键函数ModifyMesh(VertexHelper vh)的具体实现。Text组件生成的顶点数据通过参数类型VertexHelper传入后,需要通过
public void GetUIVertexStream(List<UIVertex> stream)
这个方法获取具体数据。Verts/Tris比值的变化就发生在这个帮助函数中。这个函数将原本共享的三角形顶点做了拆分,按照1个三角形对应3个顶点的数据输出了。引用一位国外Unity开发者的话讲:
GetUIVertexStream
takes a nice optimized mesh with shared verts and completely ruins it. Dont
call it. Ever.
Unity这样实现的出发点不得而知,在我们这种应用场景中进一步导致了顶点数的增长。
我们是一个MMORPG手游项目,部分副本及城战玩法需要支持大量玩家及怪物同屏。抛开遮挡裁剪这部分优化不谈——因为那是另一个优化方向了——就描边效果来说,一个字符对应54个顶点,一段简短的200字的文字轻轻松松顶点数破10K。如果说应用于位置固定的UI面板上还勉强可以接受的话,那么应用于相对位置时刻都在发生变更的人物头顶信息上就是不可接受的了。
事实上,如果同一个Canvas下的顶点数过多,Unity会报错的。Canvas
element contains more than 65535 vertices. This is not supported 。
图8. 顶点数超限Unity报错
5.第二次优化
简单的基于Shadow的实现方式会引起顶点数量的暴涨,第二次优化将方向调整为了基于Mesh。实现思路如下:
(1)在字符原UV边界uvOrigin的基础上,通过描边大小fSize计算描边后的UV边界uvAdd.
(2)将原UV边界uvOrigin,扩展后的UV边界uvAdd,描边大小fSize以及描边颜色color传入Shader.
将顶点信息传入Shader时需要注意,UIVertex结构体的成员如下:
图9.UIVertex结构体
没有被默认Shader使用的参数是uv1,normal及tangent。这些需要传给Shader的值就塞进这些参数里就可以了,在Shader中只要逆向解出一一对应即可。
(3)对字符贴图像素处理时,从当前像素向四周做8次采样以确定当前像素点的值。
采样时需要判断当前点是否是在uvOrigin的范围内,否则的话可能会出现采样到隔壁字符的情况……
判断当前点是否在uvOrigin的方法如下,在范围内返回1,不在范围内返回0.
图10.使用Step方法判断范围
沿当前像素采样的8个方向偏移,数组已经被解开,避免需要在Shader中用循环来实现。
图11.采样偏移矩阵
将所有采样点采样的像素值求和,然后除以采样的数量。
图12. 对(-1,-1)偏移采样
(4)在接下来的pass中将字符的原始贴图信息与刚刚计算得到的数据进行融合。
需要注意的是,不在uvOrigin中的像素点的color值直接置0.避免uvOrigin之外,uvAdd之内的脏点混进来了。
于是我们就得到了这样的结果:
图13. 描边2像素、15像素效果及对应数据
表3. MeshOutline与其他效果对比数据
使用MeshOutline后Batches数量为2,而Outline因为是复制的原顶点信息,所以无论复制多少份Batches数量都为1.总的来说,这种优化方式带来的是采样次数的增加及顶点数量的相对减少。
1.起因
我们最近的项目使用了UGUI作为UI系统,在使用UGUI的过程中遇到了不少问题,其中一个问题是:描边的文字与美术同学出的效果图有不小的差异。效果图的描边效果连贯而且均匀,而UGUI的Outline组件的效果仅仅只是解决了“温饱问题”,Better Than Not而已,并且这种实现方式带来了其他问题,比如顶点数量的大幅增加。
图1.美术同学的效果图与实际实现的对比(放大至300%)
2.原因分析
UGUI已经部分开源,通过研究Outline的源码可以看到,Outline组件的原理是在原顶点的基础上,在effectDistance.(x,y), (x, -y), (-x,y), (-x,-y)这4个偏移上ApplyShadowZeroAlloc实现的。亦即Outline组件是在Shadow的基础上实现的,Outline相当于4个不同偏移方向上的Shadow。
图2. UGUI Outline组件的核心源码
进一步查看Shadow的源码,发现Shadow的原理实际上是将原始的顶点数据复制一份, 根据设置的偏移量计算复制后的新顶点的位置,并为新的顶点颜色赋值为设置的颜色值。
图3. Shadow组件的核心源码
顺便吐槽一下Shadow组件的ApplyShadow方法和ApplyShadowZeroAlloc方法,恕我愚钝没有看出这样设计的好处。推测可能是为了跟老版本兼容。
我们可以简单地将Outline的参数EffectDistance调整到一个较大的值即可观察到4次复制。实际上Outline在处理1像素以上的描边时就会出现较大瑕疵,超过1.5像素就已经不可用了。
图4.Outline偏移参数设置为(10,10)时观察到的4次复制
3.业界的一般做法
通过请教其他同样使用UGUI的团队以及Google可用的方案,一般有如下几种解决方案:
①基于Shadow
Outline的实现是在原始顶点的4个方向ApplyShadow,为了描边效果更好,可以在8个方向甚至16个方向ApplyShadow来实现饱满的描边效果。实现原理与Outline类似。
②TextMeshPro
看了TextMeshPro官方视频的介绍是令人热血沸腾的,基本解决了策划和美术对UI字体的各种要求。
然而TextMeshPro使用时需要制作字体文件,即FontAsset。对于英文及数字来说,只需要针对ASCII字符集制作FontAsset即可,但对于中文需要动态生成的文字来说,需要生成的字体文件相对较大,对于手游项目来说,对包量及内存的影响在目前来看都是不能接受的。
但TextMeshPro用在可确定的中文字符及ASCII字符上的效果还是不错的,比如用在登录、主界面这类文字不需要动态生成的界面上。
③基于Mesh
innogames的一位先驱在Blog上发了一篇通过调整Mesh来对字体描边的思路,奈何2017年4月左右innogames的Blog禁止匿名登录了,通过转载的残缺信息整理实现思路如下:
a.提取文字原始UV区域,扩大文字绘图区域
b.对文字纹理的每个像素点周围多次采样,应用描边RGBA作为新的点的颜色
c.将原始纹理及采样后的点进行融合。
4.第一次优化
采用基于Shadow的实现方式,多次ApplyShadow以使描边效果饱和。
Outline的实现方式是在(x,-y),(x,y),(-x,y),(-x,-y)4个方向ApplyShadow。模仿Outline的实现有了新的BoldOutline,在原有的4个方向之外增加(x,0),(-x,0),(0,y),(0,-y)4个方向共计8个方向的描边。描边效果较Outline有了一定提升。
图5.Outline和BoldOutline效果对比
当把这样的描边效果应用于场景中人物头顶信息展示时,美术勉强接受了这样的实现效果。然而我却被老大约谈了……原因是渲染数据统计窗口(Rendering Statistic Window)中摄像机视野内渲染的顶点总数异常,准确地说是顶点总数爆炸了。
理论计算Unity中字符与三角形及顶点数量的关系理应如下表所示:
表1.理论计算的各效果数值表
从理论上计算,Outline对于1个字符是绘制了5次,BoldOutline是绘制了9次,顶点总数及三角形总数相对于无效果的字符分别应该是5倍和9倍的关系。
但是实际运行时却啪啪啪的推翻了这个理论值。
表2.实际运行时的各效果数值表
图6. 各效果运行时的数据截图
之所以会出现理论与实际的偏差,就得继续从这些效果实现的原理说起了。
在UGUI中Text是由TextGenerator产生顶点数据,通过顶点数据与字体贴图渲染到屏幕上的。Text组件继承自MaskableGraphic,MaskableGraphic又继承自Graphic,Text组件实现了protected virtual void OnPopulateMesh(VertexHelper vh)方法为所需的数据赋值。实现这些文字效果的基础是Unity为外部提供的接口IMeshModifier,如果Text组件所在的GameObject中存在实现了IMeshModifier接口的组件,就会调用对应组件的ModifyMesh方法。这样就可以在外部修改Text的顶点数据了。
图7. 典型的顶点修改组件结构
了解了各种文字效果实现的原理,继续来看关键函数ModifyMesh(VertexHelper vh)的具体实现。Text组件生成的顶点数据通过参数类型VertexHelper传入后,需要通过
public void GetUIVertexStream(List<UIVertex> stream)
这个方法获取具体数据。Verts/Tris比值的变化就发生在这个帮助函数中。这个函数将原本共享的三角形顶点做了拆分,按照1个三角形对应3个顶点的数据输出了。引用一位国外Unity开发者的话讲:
GetUIVertexStream takes a nice optimized mesh with shared verts and completely ruins it. Dont call it. Ever.
Unity这样实现的出发点不得而知,在我们这种应用场景中进一步导致了顶点数的增长。
我们是一个MMORPG手游项目,部分副本及城战玩法需要支持大量玩家及怪物同屏。抛开遮挡裁剪这部分优化不谈——因为那是另一个优化方向了——就描边效果来说,一个字符对应54个顶点,一段简短的200字的文字轻轻松松顶点数破10K。如果说应用于位置固定的UI面板上还勉强可以接受的话,那么应用于相对位置时刻都在发生变更的人物头顶信息上就是不可接受的了。
事实上,如果同一个Canvas下的顶点数过多,Unity会报错的。Canvas element contains more than 65535 vertices. This is not supported 。
图8. 顶点数超限Unity报错
5.第二次优化
简单的基于Shadow的实现方式会引起顶点数量的暴涨,第二次优化将方向调整为了基于Mesh。实现思路如下:
(1)在字符原UV边界uvOrigin的基础上,通过描边大小fSize计算描边后的UV边界uvAdd.
(2)将原UV边界uvOrigin,扩展后的UV边界uvAdd,描边大小fSize以及描边颜色color传入Shader.
将顶点信息传入Shader时需要注意,UIVertex结构体的成员如下:
图9.UIVertex结构体
没有被默认Shader使用的参数是uv1,normal及tangent。这些需要传给Shader的值就塞进这些参数里就可以了,在Shader中只要逆向解出一一对应即可。
(3)对字符贴图像素处理时,从当前像素向四周做8次采样以确定当前像素点的值。
采样时需要判断当前点是否是在uvOrigin的范围内,否则的话可能会出现采样到隔壁字符的情况……
判断当前点是否在uvOrigin的方法如下,在范围内返回1,不在范围内返回0.
图10.使用Step方法判断范围
沿当前像素采样的8个方向偏移,数组已经被解开,避免需要在Shader中用循环来实现。
图11.采样偏移矩阵
将所有采样点采样的像素值求和,然后除以采样的数量。
图12. 对(-1,-1)偏移采样
(4)在接下来的pass中将字符的原始贴图信息与刚刚计算得到的数据进行融合。
需要注意的是,不在uvOrigin中的像素点的color值直接置0.避免uvOrigin之外,uvAdd之内的脏点混进来了。
于是我们就得到了这样的结果:
图13. 描边2像素、15像素效果及对应数据
表3. MeshOutline与其他效果对比数据
使用MeshOutline后Batches数量为2,而Outline因为是复制的原顶点信息,所以无论复制多少份Batches数量都为1.总的来说,这种优化方式带来的是采样次数的增加及顶点数量的相对减少。