ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FGUI字体描边Shader实战:Alpha膨胀与性能优化全解析

FGUI字体描边Shader实战:Alpha膨胀与性能优化全解析 给FGUI里的文字加一圈描边看起来是个再普通不过的需求但真做起来能踩出一连串让人头皮发麻的坑。美术丢过来一张效果图字要清晰又不能糊成一片放到不同分辨率下粗细还得一致这时候FGUI自带的那个“描边”组件就有点带不动了。我之前第一次做这个功能时直接在FGUI里把描边色值填上小字号看着还行一旦把字号放大或者用动态字体描边把笔画直接盖住细一点的文字几乎没法看。后来在Unity端反复试了几种方案才总结出一套在FGUI里做字体描边相对稳的实践。这篇文章就当踩坑记录加实现思路来写Shader部分会讲得比较细主要面向Unity方向的UI程序员前端跑微信小游戏和App的同学同样可以参考。1. 方案选型为什么自带的描边组件不够用1.1 FGUI自带描边的原理和瓶颈FGUI编辑器里给文本设置“描边”在Unity运行时并不是一个独立的组件而是走内部的图形系统生成额外的渲染指令。它的实现思路和NGUI的Outline差不多把一个字符的网格复制出多个副本分别朝上下左右以及斜向偏移再用描边色渲染一遍最后把正常的字符颜色盖在上面。这样做的好处是简单编辑器里勾一下填个色就有描边坏处也很明确顶点数和渲染命令会成倍增加。一个字符本来只需要一个quad加了描边之后直接变成5个quad如果是斜向插值还更多。动态字体场景下这个问题会被放大。FGUI的动态字体是把需要显示的字符实时放进一张统一的字体图集里每个字符对应一个独立的quad本身合批条件就严格。一旦每个字符都复制出好几份网格顶点数暴涨低端手机上那种上百字的聊天面板很容易出现掉帧。而且这种方式做出来的描边边缘非常锐利想让描边带一点柔边过渡或者让描边粗细更精细地控制几乎做不到。还有一个更隐蔽的问题笔画密集的汉字在粗字号下内部空隙也会被描边色填充看起来像字本身被染色了。因为顶点复制的思路是把整个字形轮廓向外平移一圈遇到“口”“日”这类封闭结构内圈也会被填上。效果图上只是外圈有一层描边运行起来却变成了双层描边美术那边验收基本过不去。1.2 常见替代方案的横向对比既然自带方案不够那能选的路线大概有四种美术预烘焙描边贴图、在FGUI外部挂引擎级Outline组件、用Shader做边缘处理、以及最粗暴的阴影偏移方案。我做过一轮对比直接看表方案效果可控性性能动态文本实现成本美术预烘焙贴图字体最好高不友好高引擎Outline组件中中低友好低Shader边缘处理好高友好中阴影偏移差中友好低美术预烘焙适合那些标题字、活动弹窗字字号和文案都固定可以让美术直接做一张带描边的位图字体运行效果最接近原画。但聊天、飘字、排行榜这种动态内容不可能每出现一个组合就烘焙一张图所以它只适合特定场景。引擎级Outline组件在U3D里就是给Text挂一个UnityEngine.UI.Outline理论上也能生效。但FGUI不依赖UGUI的组件树它的文本是显示列表里的一个图片节点没法直接挂UGUI的Behaviour硬接的话需要自己桥接消息而且底层还是顶点复制性能问题没有解决。Shader方案是这几个里面性价比最高的字符的网格结构不用变一个quad还是两个三角面描边宽度、颜色、柔边全部交给Shader控制动态文本天然支持而且不会增加顶点数。代价是需要懂一点ShaderLab和FGUI的材质挂接方式下面几节重点讲这个方案。2. 字体描边Shader的核心实现原理2.1 字体渲染的本质Alpha通道就是字形想自己写描边Shader第一步要搞清楚字体在FGUI里到底是怎么画出来的。FGUI的文本不管是动态字体还是位图字体最终输出都是把字符放进一张纹理图集然后用若干个quad采样对应的UV区域。一个字符的网格上通常只有UV和颜色两个关键数据渲染时Shader采样纹理取到RGBA再用这个RGBA和顶点色做运算。对大多数字体图集来说真正代表字形形状的是Alpha通道。纹理的RGB很多时候并不是最终颜色而是字体渲染时用来和顶点色相乘的白色基底比如纯白字体就是RGB(255,255,255)A通道才是笔画轮廓。所以描边的一切判断都要围绕Alpha来做而不是直接看RGB。如果你写Shader时采样了_MainTex之后用col.rgb去判断边缘很多字体会直接失效。理解这一点之后描边的思路就清晰了我们想找到“字形边界”这一圈像素把它们的颜色从字色替换成描边色。这里的边界不一定是1像素的锯齿线而是一条带宽度、可以有软边的带状区域。这个区域要能沿字形轮廓向外部扩展不能往笔画内部蔓延。2.2 邻域采样边缘检测最简单的可落地做法最容易理解的实现是邻域采样。对当前像素点采样它四周若干像素的Alpha值如果周围某个方向的Alpha远大于当前Alpha说明当前处在字形边界外侧应该被描边。代码写起来非常直接fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float2 texel _MainTex_TexelSize.xy * _OutlineWidth; float maxAlpha 0; maxAlpha max(maxAlpha, tex2D(_MainTex, i.uv float2(texel.x, 0)).a); maxAlpha max(maxAlpha, tex2D(_MainTex, i.uv - float2(texel.x, 0)).a); maxAlpha max(maxAlpha, tex2D(_MainTex, i.uv float2(0, texel.y)).a); maxAlpha max(maxAlpha, tex2D(_MainTex, i.uv - float2(0, texel.y)).a); float outline saturate(maxAlpha - col.a); fixed3 rgb lerp(col.rgb, _OutlineColor.rgb, outline); float a max(col.a, outline * _OutlineAlpha); return fixed4(rgb, a); }这段代码的思路是如果当前像素透明但上下左右存在不透明的字形像素那它一定是字形外圈就把它的颜色改成描边色。用maxAlpha和当前Alpha做差得到的outline值在接近字形边缘的像素上最高越往外越低。这种方式实现成本极低四个采样就能得到一圈描边。但它的局限也很明显只能处理四个正方向的边缘斜向45度的地方会显得薄描边宽度本质上是采样偏移距离偏移太大会把描边“切”成一段一段。另外如果你把当前Alpha也纳入判断比如maxAlpha取了包含当前像素本身的值那文字内部的透明区域也可能被误判成外圈因为文字内部的空洞周围也有一圈字形Alpha。要避免这种情况只能让maxAlpha在一开始排除当前像素同时描边宽度的偏移方向应该只向外取而判断条件加到“周围Alpha高且当前Alpha低”的差值上。2.3 更进阶的Alpha膨胀方案邻域检测的锯齿问题在字号较大的标题字上会非常明显。我更推荐另一种实践更成熟的方案Alpha膨胀。它的核心逻辑是采样周围一定范围内的最大Alpha用这个膨胀后的Alpha减去当前Alpha得到的就是“比当前字形更大一圈”的轮廓带。代码和上面的邻域检测有点类似但采样范围从4方向扩展到8方向甚至更多并且会做多次迭代float AlphaBlur(sampler2D tex, float2 uv, float2 pixelOffset, int samples) { float result 0; for (int x -samples; x samples; x) { for (int y -samples; y samples; y) { float d tex2D(tex, uv float2(x, y) * pixelOffset).a; result max(result, d); } } return result; }膨胀之后得到的是带宽度可调的“字形外扩区域”再用外扩区域和原始Alpha做差就能得到描边带。这个方案比四邻域检测稳定得多原因是max取最大值天然会把文字内部的空洞一起膨胀掉但做差之后原始Alpha会把这些区域重新遮回来描边只保留在原字形外侧的部分。原理上和我们用Photoshop给文字选区扩展再描边是一回事。真正落地时需要注意采样次数。如果_OutlineWidth设置得比较大比如轮廓宽度5像素每个方向循环5次一个像素可能要做上百次纹理采样这在低端移动设备上压力很大。实用做法是不要直接用宽范围逐像素采样而是做两次模糊式采样先降采样再膨胀或者用Mipmap做近似。纯UI场景下大多数描边宽度在1到3像素之间两层循环的采样量还能接受。2.4 SDF距离场终极方案但成本也最高如果对描边质量有极致要求比如要做到笔画转角圆润、边缘半透明过渡、宽窄切换完全平滑那邻域采样和膨胀的方案都还不够。真正能一劳永逸的是SDF距离场方案TextMeshPro在Unity里之所以能做出很漂亮的字体描边、阴影、外发光底层就是SDF。SDF的思路是给每个字形预计算一张距离场图图中每个像素不是存储“颜色”而是存储“该点到最近字形轮廓的距离”。距离为0的地方代表轮廓负数在字形内部正数在字形外部。渲染时用一个smoothstep就可以把距离场转成干净平滑的边缘而且边缘宽度可以独立控制缩放变换也不会产生明显锯齿。但问题在于FGUI默认的动态字体图集不提供SDF数据它只有普通的Alpha图。想用SDF方案得走FGUI的位图字体路线用工具把TTF字体烘焙成SDF纹理图集再定义成FGUI的BitmapFont。这个过程做起来不复杂但每一步都有细节字形在SDF纹理里的间距要留够符号的管脚位置要对齐动态文本换字时替换SDF资源也得写一套逻辑。如果是团队项目还得让UI高层理解为什么某些字体要额外带一套图集。所以我的建议是先评估项目对描边质量的真实需求标题字、品牌字可以考虑SDF普通运行文本用膨胀方案就够了。3. 在FGUI中接入自定义Shader并调参3.1 FGUI图形挂材质的两种方式Shader写好了接下来要想办法让它作用到FGUI的文本对象上。FGUI在Unity中的显示对象不是UGUI的Graphic而是NGraphics文本渲染由内部组装Mesh完成。想要替换Shader常用的做法有两种。第一种是在FGUI编辑器里给文本的“描边”属性指定一个非透明的颜色然后修改FGUI内置的字体描边Shader变体让它在绘制描边时使用我们的自定义逻辑。FGUI的Unity SDK里默认带了一套Shader集合里面有针对FGUI的普通图片、字体描边、灰化、渐变等变体。你复制一份内置的字体描边Shader改完再通过UIPackage注册进去FGUI在加载包时就能认出这个变体。这样好处是编辑器预览和运行时一致不用在代码里到处找显示对象。第二种是运行时手动替换材质拿到文本的displayObject修改它的graphics.material。例如在Unity里拿到GTextField之后通过gTextField.displayObject访问底层显示对象然后把material换成我们自己用Shader创建的材质。这种方式灵活不同文本可以挂不同的描边参数缺点是需要自己在合适时机处理生命周期文本重新赋值、面板销毁时要记得释放材质不然容易出现资源泄漏。不管用哪种方式都要注意FGUI的图集机制。FGUI会把同包内的纹理合并到图集字体也是图集的一部分所以你的Shader里采样主纹理时用的就是包含字体字形的图集UV。千万别只对单个字体图片生效就完事一定要放到FGUI包里做一次完整测试。3.2 描边宽度的像素校准描边宽度这个参数最容易出现的问题是不同分辨率下粗细不一致。Shader采样时如果用绝对像素值作为偏移量在低分辨率屏幕上看着合适的描边放到高分辨率屏幕上会变细一倍。因为屏幕物理分辨率高同样一个偏移量覆盖的物理像素变少。处理办法是引入一个分辨率相关的缩放比例让偏移量相对屏幕坐标归一化。在Unity的ShaderLab里可以用屏幕空间的导数来做自校准通过fwidth函数估算当前像素在屏幕上的变化率再把描边宽度乘上去。伪代码如下float2 coord i.uv * _MainTex_TexelSize.zw; float2 pf fwidth(coord); float pixelScale max(pf.x, pf.y); float offset _OutlineWidth * pixelScale;这样描边宽度就变成了“以屏幕像素计”的数值字号放大缩小或者分辨率变化时描边粗细能保持视觉一致。这个问题的坑在于如果你只在FGUI编辑器里拖动文本查看效果编辑器视口的缩放比例和真机屏幕往往不一样所以预览时觉得刚刚好的宽度到真机往往会完全不是一回事。建议把文本放大两倍再对着效果图微调参数更接近真机结果。还有个细节是字重。如果字体本身是粗体或者加了加粗效果再叠加描边边缘的像素信息已经很少描边很容易把笔画黏在一起。这种场景适合做内描边而不是外描边。内描边的思路就是让描边色覆盖在字形内侧边缘通过原始Alpha裁切掉外侧部分实际代码就是在最后输出颜色时用原始Alpha作为遮罩让描边色和字色做混合。3.3 颜色、柔边和混合模式调节描边色的混合方式也要特别注意。FGUI默认的字体描边在混合上用的是普通Alpha混合也就是Blend SrcAlpha OneMinusSrcAlpha。如果你的字体纹理本身是预乘Alpha格式需要改成Premultiplied模式否则描边边缘会出现暗边或者白边。判断方法很简单在FGUI里放一个纯黑背景让字体显示成白色并加描边如果描边边缘有一圈半透明的灰色说明预乘没有处理好。柔边参数用一个类似smoothstep的方式处理即可在“是否是描边区域”的判断结果上再套一层渐变。我们项目里给描边加了三个可调参数描边色、描边宽度像素值、柔边大小。柔边值取0时是硬边取1时描边会呈现从实到虚的过渡。用于飘字、战斗伤害数字这种高频出现的文本柔边开大一点可以掩盖边缘锯齿反而比硬边描边视觉效果更舒服。4. 性能、合批与多平台适配的那些坑4.1 自定义材质最容易引发的合批打断在FGUI里最影响性能的操作就是给一屏文本各自创建不同的材质。FGUI的合批机制会把连续使用相同材质的显示对象合并成一次绘制但材质一多每个文本都会成为一个独立的绘制批次。比如一个排行榜界面有20个名字如果每个名字都按自身状态换了一张带不同参数的材质那就可能产生20个DrawCall直接抹平了Shader省下来的性能优势。解决思路是尽量复用材质。如果所有文本的描边参数都一样就把它们共享同一个材质实例FGUI会自然合批。如果要在不同文本上体现不同描边颜色比如玩家名字有蓝名、红名、紫名建议把颜色参数挪到顶点色里传入而不是给每种颜色单独建材质。这样同一个Shader同一个材质Batch通过顶点色区分颜色合批就不会被打断。FGUI对纹理图集的合批还有一个特性它允许多个图集合并在同一批绘制。但这只针对FGUI内部管理的图集。如果你的自定义Shader采样了自己单独加载的外部纹理请确保该纹理也被打进同图集不然就会产生额外批次。我在实际项目里曾经为了一个描边素材额外加载了一张外部贴图结果每次打开主城界面多出3个DrawCall排查了半天才定位到是图集身份不一致导致的。4.2 移动端纹理采样带宽和精度移动端GPU的纹理采样带宽是稀缺资源。膨胀型描边Shader每个像素要采样多次纹理文字又多的时候采样压力会直接体现在发热和掉帧上。优化方向有两个一是降低采样循环次数将描边宽度拆成两次两级偏移第二次偏移基于第一次的结果而不是暴力九宫格采样二是适当降低字体图集的分辨率字体本来就靠Alpha承载信息图集过大时采样压力会指数上升。另外要注意半浮点和中等精度的问题。Shader中计算描边偏移和Alpha差值时如果使用half精度部分低端ARM GPU上会出现抖动尤其是描边色偏亮、字色偏暗的对比场景。保守做法是把关键数据用float精度渲染性能损失可以忽略。早期我们项目在部分安卓机上出现过描边有横条纹排查到最后就是精度定义问题。桌面上还有Shader版本兼容的坑。FGUI发布到微信小游戏环境时底层是WebGL1的OpenGL ES 2.0实现部分高级特性不可用。我在小游戏端就遇到过fwidth不可用的情况那时候只能退回去用传入的屏幕缩放Uniform代替。所以跨平台之前先确认目标平台支持的Shader特性集不要把一个依赖DX11特性的Shader直接跑到所有端上这点对UI这种共享代码库尤为关键。5. 常见问题与排查实录5.1 文字内部被描边填充了这个是新手最容易踩的坑。打开描边后“口”“日”“田”这类封闭结构的汉字内侧空隙全部变成描边色字像被涂改过一样。原因是顶点复制的自动描边或者邻域检测法会把内轮廓也当成外轮廓处理。排查思路是看你用的是哪种方案如果用的是FGUI自带的描边属性那内侧填充几乎无法避免只能换Shader方案如果Shader膨胀法也出现说明在取轮廓带时没有用原始Alpha做减法或者膨胀范围过大把内空洞和外轮廓连在了一起。我处理这个问题时用了一个万能的遮罩思路最后合成描边色之前把原始字形Alpha作为蒙版凡是原来字形覆盖的区域一律不显示描边色只显示字色。这样哪怕内部被误判成描边区域蒙版也会把它挡回去。5.2 描边边缘发虚和锯齿并存按理说发虚和锯齿是矛盾的但在FGUI字体描边里它们经常一起出现。发虚是因为描边带的采样范围过大Alpha差值太小边缘透明度梯度太大锯齿是因为采样方向太少斜向45度的位置覆盖不到。这两个问题的根源都在于采样策略不够精细而不是参数没调好。解决时建议先用可控的邻域范围配合8方向对角采样再把结果做一次平滑处理最后用fwidth修正边缘的透明度梯度。如果锯齿还是很明显就把描边宽度缩小0.5像素左右让描边带的边缘尽可能贴合字形原始边缘锯齿感会大幅下降。5.3 BMFont和动态字体表现不一致同一个Shader用FGUI的动态字体时效果正常切到位图字体后描边粗了一圈或者干脆不显示。这是两个原因导致的一是位图字体的图集规范不同有些工具导出的BMFont纹理中字形Alpha存在RGB通道有些把Alpha存在A通道二是位图字体图集的边距不足采样超过UV范围会读到相邻字符形成杂色。排查时先在Shader里固定输出纹理原始Alpha比如直接返回fixed4(0,0,0,tex2D().a)看一下字形Alpha是否完整。如果完整说明是描边的偏移计算没有按当前图集的texelSize做归一化如果不完整说明需要检查BMFont纹理的格式设置尽量统一成标准Alpha通道。5.4 字体颜色和描边色互相覆盖文本设置成白字配黑描边但运行时描边把白字完全盖住或者字色透出到描边外面。这个问题的根源是混合顺序和输出Alpha不对。很多描边Shader只计算了“当前像素是不是描边区域”却忘了在描边区域里也要保留原始字形的Alpha信息。正确的输出应该分两层判断当前像素属于原始字形区域时输出字色和原始Alpha属于描边区域但不在字形内时输出描边色和描边Alpha。两者重叠的部分以字形优先。写成伪代码就是float textMask col.a; float outlineMask saturate(expandAlpha - textMask); fixed3 finalRGB lerp(outlineColor, textColor, saturate(textMask * strength)); float finalA max(textMask * textAlpha, outlineMask * outlineAlpha); return fixed4(finalRGB, finalA);这样能让字色永远盖在描边色上层描边只负责给文字外圈增加一圈轮廓。我们在做飘字系统时用这套逻辑同时支持了描边、阴影、外发光三种效果它们互相叠加也不会产生奇怪的遮罩关系。6. 我最后的几点经验之谈描边这个东西做完一个项目之后回头看最重要的不是Shader写得有多花哨而是先想清楚你要的是哪种效果。如果只是让聊天文字在复杂背景下更可读加一层投影比描边更省资源效果也更柔和。如果必须做硬核描边优先考虑用FGUI自带属性配合简单调参能不能应付不行再上Shader。我见过不少项目在描边上过度设计最后性能掉了、兼容性出了问题美术还不满意。如果团队有能力我建议把描边能力封装成一套FGUI扩展组件暴露出描边色、宽度、柔边三个参数内部统一用膨胀法渲染然后用一个全局UIPackage注册Shader。这样UI同学在FGUI编辑器里或者代码里都能便捷使用不需要每个人都会ShaderLab。我们团队就是这么做的后期换皮和性能优化都省了很多事。最后分享一个小技巧如果只是个别UI界面需要特殊描边可以在FGUI里放两层文本底层文本用描边色并略微放大位置上层文本正常显示通过FGUI自身的变体或蒙版关系也能实现简易描边。这个方法不需要任何Shader知识效果也完全够用适合快速出临时版本验证效果。有更好的思路也欢迎一起交流。
返回列表