
做 UI 的同学大概都遇到过这种需求策划指着屏幕说这个按钮上的字要加一圈描边最好颜色还能跟着状态变。你打开 Unity第一反应可能是给 TextMeshPro 加个 Outline 组件结果一看效果——尖角断裂、笔画粗的地方根本看不出描边、稍微放大一点边缘就散架。于是你开始搜 unity shader 描边翻了半天发现绝大多数教程讲的都是 3D 模型的法线外扩套到 UI 文字上完全跑不通。这篇东西就是来解决这个问题的。我会从方案选型一路讲到完整代码做一个真正通用的描边 unity shader它同时能吃下 UI Image、Sprite Renderer 和 TextMeshPro 的文字描边需求靠的不是法线也不是顶点外扩而是屏幕空间的 Alpha 扩散采样。不管你是刚接触 Shader 的新手还是已经写过几个 UI Shader 想找一套更稳的方案都能从里面抄到可以直接落地的东西。整篇的代码我会给全参数怎么调的推导过程也会写清楚最后再把我踩过的几个坑摊开讲省得你重复走一遍。1. 描边方案选型与整体设计思路1.1 三种主流描边路线对比在动手写代码之前先把能走的路捋一遍。目前 Unity 里做描边主流就三条路每条路的适用场景完全不同选错了再怎么优化都是白费力气。第一条是顶点法线外扩也就是常说的 Backface Outline。做法是把模型顶点沿法线方向往外推一段距离再反转面片渲染一遍用纯色填充。这条路对 3D 模型非常有效尤其是角色、道具这类有明确法线方向的物体。但它对文字和 UI 完全没用因为文字的网格是一堆扁平的四边形法线全部朝屏幕外往外推的结果是整块面片变胖而不是沿轮廓扩张。第二条是后处理描边用深度图或者法线图在屏幕空间做边缘检测。这条路渲染阶段分离得干净一个相机挂一个后处理就管全场。代价是它只能处理不透明物体透明队列里的 UI 和文字根本进不了深度图而且很难做到每个物体独立的描边颜色和宽度。你要给某个特定按钮的字单独加红色描边这条路会很别扭。第三条就是本文要做的贴图 Alpha 扩散采样。思路很朴素在片元着色器里以当前 UV 为中心向四周发射若干条射线每条射线上取几个采样点读 alpha 值只要周围存在不透明的像素当前这个本来透明的像素就被判定为描边输出描边色。这个方案不关心你渲染的是模型、图片还是文字只要对象有一张带 alpha 的贴图就能描出来。方案适用对象单物体独立描边主要代价顶点法线外扩3D 模型支持文字/UI 完全失效后处理边缘检测不透明场景很麻烦需深度图透明物体排除Alpha 扩散采样UI、Sprite、SDF 文字原生支持片元采样次数开销1.2 为什么最终落在屏幕空间 Alpha 扩散选第三条路的核心理由是它天然满足通用这两个字。项目里的需求往往是散装的主界面按钮需要描边背包里的道具图标需要描边战斗飘字需要描边甚至某个 2D 场景里的精灵物件也需要描边。如果每个需求都单独写一套 Shader维护成本会爆炸。而 Alpha 扩散采样这套逻辑只依赖一张贴图和它的 TexelSize对数据源没有额外要求。另一个关键点是它能和 Unity UI 的整套渲染流程无缝对接。Unity 内置的 UI/Default Shader 已经处理好了 Stencil、ColorMask、RectMask2D 裁剪、AlphaClip 这些 UI 特有的东西我们只要在这套骨架上加一段采样逻辑就行不用自己去重新发明一遍 UI 的渲染规则。这一点很重要——很多人自己搓 UI Shader最后发现 Mask 裁切失效、UI 层级错乱问题就出在把这些内置配置丢掉了。还有个隐性好处是它不依赖字体格式。TextMeshPro 用的是 SDF 字体传统 Text 用的是位图字体Sprite 就是一张普通贴图三种东西的数据结构天差地别但它们的共同点是最终渲染出来的 alpha 通道都能表达形状。只要抓住这个共同点一套 Shader 就能覆盖全部。注意Alpha 扩散采样解决的是轮廓描边不是发光或模糊。想做外发光采样方向数和半径都要往上加性能曲线会陡增后面第 6 章会讲怎么权衡。1.3 通用化的边界在哪里任何方案都有它管不到的地方提前说清楚比事后返工强。第一描边宽度受限于贴图的透明边距。UI 图集打包时如果两个 Sprite 之间只留了 2 像素的间距而你把描边宽度设成 5 像素采样就会跨过边界把隔壁 Sprite 的像素读进来表现为多余的色块。这个坑非常隐蔽后面第 5 章会专门讲。第二跑马灯滚动、Tiled 平铺、九宫格拉伸这几种 UI 模式会让 UV 超出 [0,1] 范围采样坐标跑到贴图外面结果不可预测。这类需求建议用 MaterialPropertyBlock 或者干脆改回后处理方案。第三如果目标对象的 alpha 是纯 0 或纯 1没有任何过渡像素比如一张硬边的纯色图那么描边边缘会呈现明显的锯齿因为没有中间灰度可以平滑。这时候需要在 Shader 里加一点点软化处理让最终 alpha 做一次光滑过渡。搞清楚了能力边界接下来的实现就不会跑偏。2. 核心原理拆解Alpha 扩散与距离场2.1 从贴图 UV 出发的采样半径计算很多人卡在第一步描边宽度到底怎么换算成 UV 偏移量。答案藏在_MainTex_TexelSize里。Unity 对任何声明为 Texture 类型的属性都会自动填充一个同名加_TexelSize后缀的 float4它的四个分量分别是(1/width, 1/height, width, height)。前两个分量就是我们需要的单位偏移量——沿 U 方向移动一个纹素UV 应该加_MainTex_TexelSize.x。所以描边宽度为 N 个纹素时采样偏移就是float2 offset dir * _MainTex_TexelSize.xy * N其中 dir 是单位方向向量。这样写的好处是宽度单位与贴图分辨率解耦同一份 Shader 用在 128×128 的图标和 2048×2048 的立绘图上参数含义一致都表示多少个源贴图纹素。这里有个容易被忽略的细节采样偏移应该按贴图纹素算而不是按屏幕像素算。有人喜欢用_ScreenParams做偏移结果同一张图在不同分辨率下描边粗细差出一倍上线后一堆人反馈我在手机上描边怎么这么细。用 TexelSize 就绕过了这个问题代价是下面要讲的 Canvas 缩放适配两害相权取其轻。提示_MainTex_TexelSize是 Unity 自动注入的不需要在 Properties 里声明。但如果你想在 Properties 面板里看到它方便调试可以声明一个同名的 Vector 属性Unity 同样会覆盖它不会冲突。2.2 阈值二值化把半透明像素变成硬边拿到采样值之后不能直接把它当描边强度用。原因在于原图的 alpha 分布很复杂TMP 的 SDF 字体在笔画边缘有一段几十个像素的渐变带Sprite 的抗锯齿边缘同样有半透明像素如果直接把这些值当强度描边会呈现出一圈雾蒙蒙的灰边完全不是想要的那种干脆的描边。解决办法是做一次阈值判定。给定一个阈值 Talpha 大于 T 的算实心小于 T 的算空中间用 smoothstep 做一小段过渡避免硬切产生锯齿float mask smoothstep(_AlphaThreshold, _AlphaThreshold _EdgeSoftness, alpha);_AlphaThreshold一般取 0.1 到 0.2_EdgeSoftness取 0.05 左右。这样处理之后采样得到的最大 alpha 经过同一套判定就能得到一个干净的描边遮罩。这里有个经验值可以省下你不少调试时间对 TMP 的 SDF 字体阈值建议设到 0.3 以上因为 SDF 在字形外部的 alpha 衰减很慢阈值太低会让描边向外扩出一大圈虚影对普通 Sprite0.1 到 0.2 就够了。参数面板上留一个滑杆美术同学调两次就能找到手感。2.3 描边与本体如何正确合成描边和本体的合成顺序不能反。正确顺序是三层先铺最外层描边再叠第二层描边最后把本体盖上去。这样做的原因是本体的形状是确定的、有抗锯齿的必须由它来主导最终画面描边只是垫在底下的色块。合成公式大致是这样fixed4 col; col.rgb _OutlineColor.rgb; col.a outerMask; col.rgb lerp(col.rgb, _SecondOutlineColor.rgb, secondMask); col.a max(col.a, secondMask); col.rgb lerp(col.rgb, bodyCol.rgb, bodyMask); col.a lerp(col.a, bodyCol.a, bodyMask);注意最后一步本体合成用的是lerp而不是max原因是max会让本体在抗锯齿区域的颜色被描边色抢走看起来像是文字边缘被染了色。用lerp加上本体的 mask可以完整保留本体在边缘处的半透明过渡视觉上最干净。色彩空间也要留意。如果项目用的是 Linear 色彩空间_OutlineColor从材质面板传进来时是 sRGB 值Unity 会自动做转换不用手动处理。但如果你的描边色是代码里算出来的就要确定输入的是线性值还是 sRGB 值否则颜色会偏。3. 通用描边 Shader 完整实现3.1 属性面板设计属性划分上我按必调和进阶两组分开。必调的是颜色和宽度进阶的是阈值、柔化、第二层描边和偏移。偏移这个参数容易被低估它其实能实现很多玩法设成 (1, -1) 就是右下方向的投影阴影和描边叠加就是立体字效果只填偏移不填描边宽度就是纯阴影。_AlphaThreshold和_EdgeSoftness都给了 Range 约束前者限制在 0.01 到 0.99后者限制在 0.001 到 0.5。之所以给_EdgeSoftness设下限是因为 smoothstep 的第二个参数等于第一个参数时会触发除零在某些驱动上会返回 NaN表现为整块面片消失。这个坑我在一个安卓机型上排查了大半天最后发现是美术把柔化参数拖到了 0。Stencil 那一组属性是直接从 Unity 内置 UI/Default 抄过来的一个都不能少。_ColorMask也是必须的ColorMask 在 UI 上主要用于某些特殊遮罩效果缺了它 Mask 组件在部分场景下会失效。3.2 顶点阶段与 UI 裁剪适配顶点阶段基本沿用内置 UI Shader 的写法关键点是保留worldPosition这个插值变量。它的名字有误导性实际上存的是模型的局部坐标配合_ClipRect做 RectMask2D 的二维裁剪。如果你把它删掉或者只传裁剪空间的坐标RectMask2D 和 ScrollView 的裁切就会失效这是自己写 UI Shader 最常见的翻车点之一。v2f vert(appdata_t v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.worldPosition v.vertex; o.vertex UnityObjectToClipPos(v.vertex); o.texcoord v.texcoord; o.color v.color * _Color; return o; }同样地顶点里要加UNITY_SETUP_INSTANCE_ID和UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO这两个宏前者支持 GPU Instancing后者支持单眼渲染。少了它们在某些打包配置下 UI 会直接渲染不出来或者只在一只眼睛里有画面排查起来非常痛苦。裁剪相关的两个关键字UNITY_UI_CLIP_RECT和UNITY_UI_ALPHACLIP要声明成multi_compile_local这样 Unity 可以按需生成变体不用的项目不会带上多余代码对小游戏包体也友好。3.3 片元阶段环采样与合成采样策略上我用的是八方向三层采样也就是沿八个方向射线每条射线上等距取三个点。为什么是三层而不是一层因为单层采样在描边宽度较大时会呈现出明显的花瓣状锯齿——八个方向覆盖面有限方向之间的空隙没被采到。三层采样通过在径向补齐能把断裂压下去代价是采样次数从 8 次涨到 24 次。八个方向向量直接用常量数组写死不要在片元里现算 sin/cos。三角函数在移动端 GPU 上开销不低而方向向量是固定的没必要每次片元都重算一遍。static const float2 kDirs[8] { float2( 1.0, 0.0), float2(-1.0, 0.0), float2( 0.0, 1.0), float2( 0.0, -1.0), float2( 0.7071, 0.7071), float2(-0.7071, 0.7071), float2( 0.7071, -0.7071), float2(-0.7071, -0.7071) }; // 返回一圈采样里最大的 alpha float SampleOutlineMask(float2 uv, float2 texel, float radius) { float maxAlpha 0.0; [unroll] for (int k 0; k 8; k) { float2 dir kDirs[k] * texel * radius; float a0 tex2D(_MainTex, uv dir).a; float a1 tex2D(_MainTex, uv dir * 0.66).a; float a2 tex2D(_MainTex, uv dir * 0.33).a; maxAlpha max(maxAlpha, max(a0, max(a1, a2))); } return maxAlpha; }[unroll]这个修饰符很关键它让编译器把循环展开成 24 条独立指令省掉了循环变量的维护开销在移动端能实打实省下几个百分点的 GPU 时间。3.4 完整代码清单Shader Custom/UniversalOutline { Properties { [PerRendererData] _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) [Header(Outline)] _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width (Texel), Range(0, 20)) 2 _OutlineOffset (Outline Offset (Texel), Vector) (0,0,0,0) _AlphaThreshold (Alpha Threshold, Range(0.01, 0.99)) 0.2 _EdgeSoftness (Edge Softness, Range(0.001, 0.5)) 0.05 [Header(Second Outline)] _SecondOutlineColor (Second Outline Color, Color) (0,0,0,0) _SecondOutlineWidth (Second Outline Width (Texel), Range(0, 20)) 0 [Header(UI Settings)] _StencilComp (Stencil Comparison, Float) 8 _Stencil (Stencil ID, Float) 0 _StencilOp (Stencil Operation, Float) 0 _StencilWriteMask (Stencil Write Mask, Float) 255 _StencilReadMask (Stencil Read Mask, Float) 255 _ColorMask (Color Mask, Float) 15 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip (Use Alpha Clip, Float) 0 } SubShader { Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent PreviewTypePlane CanUseSpriteAtlasTrue } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { Name UNIVERSAL_OUTLINE CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 #include UnityCG.cginc #include UnityUI.cginc #pragma multi_compile_local _ UNITY_UI_CLIP_RECT #pragma multi_compile_local _ UNITY_UI_ALPHACLIP struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPosition : TEXCOORD1; UNITY_VERTEX_OUTPUT_STEREO }; sampler2D _MainTex; float4 _MainTex_TexelSize; fixed4 _Color; fixed4 _OutlineColor; float _OutlineWidth; float4 _OutlineOffset; float _AlphaThreshold; float _EdgeSoftness; fixed4 _SecondOutlineColor; float _SecondOutlineWidth; float4 _ClipRect; static const float2 kDirs[8] { float2( 1.0, 0.0), float2(-1.0, 0.0), float2( 0.0, 1.0), float2( 0.0, -1.0), float2( 0.7071, 0.7071), float2(-0.7071, 0.7071), float2( 0.7071, -0.7071), float2(-0.7071, -0.7071) }; float SampleOutlineMask(float2 uv, float2 texel, float radius) { float maxAlpha 0.0; [unroll] for (int k 0; k 8; k) { float2 dir kDirs[k] * texel * radius; float a0 tex2D(_MainTex, uv dir).a; float a1 tex2D(_MainTex, uv dir * 0.66).a; float a2 tex2D(_MainTex, uv dir * 0.33).a; maxAlpha max(maxAlpha, max(a0, max(a1, a2))); } return maxAlpha; } v2f vert(appdata_t v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.worldPosition v.vertex; o.vertex UnityObjectToClipPos(v.vertex); o.texcoord v.texcoord; o.color v.color * _Color; return o; } fixed4 frag(v2f i) : SV_Target { float2 texel _MainTex_TexelSize.xy; float2 uv i.texcoord _OutlineOffset.xy * texel; fixed4 bodyCol tex2D(_MainTex, uv) * i.color; float bodyMask smoothstep(_AlphaThreshold, _AlphaThreshold _EdgeSoftness, bodyCol.a); float outerA SampleOutlineMask(uv, texel, _OutlineWidth) * _OutlineColor.a; float outerMask smoothstep(_AlphaThreshold, _AlphaThreshold _EdgeSoftness, outerA); fixed4 col; col.rgb _OutlineColor.rgb; col.a outerMask; if (_SecondOutlineWidth 0.001) { float secondA SampleOutlineMask(uv, texel, _SecondOutlineWidth) * _SecondOutlineColor.a; float secondMask smoothstep(_AlphaThreshold, _AlphaThreshold _EdgeSoftness, secondA); col.rgb lerp(col.rgb, _SecondOutlineColor.rgb, secondMask); col.a max(col.a, secondMask); } col.rgb lerp(col.rgb, bodyCol.rgb, bodyMask); col.a lerp(col.a, bodyCol.a, bodyMask); #ifdef UNITY_UI_CLIP_RECT col.a * UnityGet2DClipping(i.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(col.a - 0.001); #endif return col; } ENDCG } } }这段代码可以直接存成Assets/Shaders/UniversalOutline.shader使用不需要任何额外依赖。4. 接入不同渲染目标的实操步骤4.1 UI Image 与 Sprite Renderer 接入给 UI Image 换上这个 Shader最直接的方式是创建一个 Material 资产Shader 选 Custom/UniversalOutline然后把 Material 拖到 Image 组件的 Material 槽位上。这种做法简单但有个隐患多个 Image 共用同一个 Material 时任何一处修改颜色或宽度都会影响到所有引用者。如果项目里有部分按钮需要统一描边风格共用 Material 反而是优点能省下不少 DrawCall。但如果是每个按钮独立配置就必须走 Material 实例化的路。注意用new Material(source)创建实例会让这个 Image 脱离合批UI 元素多的时候 DrawCall 会明显上涨。折中方案是用MaterialPropertyBlock但它对 UI 系统支持有限实际项目中更常见的是接受 DrawCall 上涨或者干脆让美术多出几张预烘好的图。Sprite Renderer 的接入几乎一样Material 拖到 Renderer 的 Material 槽位即可。区别是 Sprite Renderer 默认会有 Sorting Layer 和 Order in Layer 的排序设置描边是待在本体材质里渲染的不会额外产生一层所以排序逻辑不用改。提示如果 Sprite 参与了 Sprite Atlas 打包务必确认图集开启了 Tight Packing 之外的常规矩形打包模式。紧贴打包会让相邻 Sprite 之间没有间隙采样直接串图。4.2 TextMeshPro 接入与材质实例隔离TMP 的接入方式和 UI Image 不一样它不是通过 Graphic 的 Material 槽而是通过字体材质。每个 TMP_Text 组件在 Awake 时会创建一份自己的fontMaterial实例这份实例是组件私有的修改它不会影响其他文本这一点非常方便。接入代码大概是这样using TMPro; using UnityEngine; [RequireComponent(typeof(TMP_Text))] public class TMPOutlineSetter : MonoBehaviour { static readonly int OutlineColorId Shader.PropertyToID(_OutlineColor); static readonly int OutlineWidthId Shader.PropertyToID(_OutlineWidth); [SerializeField] Color outlineColor Color.black; [SerializeField] float outlineWidth 2f; void Start() { var text GetComponentTMP_Text(); var mat text.fontMaterial; mat.shader Shader.Find(Custom/UniversalOutline); mat.SetColor(OutlineColorId, outlineColor); mat.SetFloat(OutlineWidthId, outlineWidth); } }这里有个必须交代的前提TMP 默认的 SDF 字体图集在渲染时_MainTex绑定的是字体图集本身_MainTex_TexelSize也是按图集尺寸填充的。所以描边宽度参数的单位变成了字体图集纹素而不是屏幕像素。图集分辨率通常在 1024 到 4096 之间同样设成 2在大图集上的视觉效果会比小图集细很多。这也是为什么后面要做 Canvas 缩放适配。另外TMP 的顶点色承载的是文字颜色如果你在代码里改了文字颜色描边色不会跟着变。需要同步的话监听颜色变化手动同步到_OutlineColor就行。注意给 TMP 换 Shader 会丢掉 TMP 自带的一些特性比如 Underlay、Glow 这些基于 TMP 专用 Shader 的效果会失效。如果项目里同时需要这些建议保留 TMP 原生 Shader 做文字主体另起一个同位置的次要 Renderer 用描边 Shader 只渲染描边。代价是多一层渲染。4.3 动态调整描边宽度与 C# 封装前面提过描边宽度以纹素为单位会随 Canvas Scale Factor 变化。举个具体例子设计分辨率是 1080×1920Canvas 设为 Scale With Screen SizeMatch 为 0.5。在 720×1280 的设备上Scale Factor 大约是 0.67如果你不处理描边会比设计稿细三分之一在 1440×2560 的设备上则会粗三分之一。这种同一份 UI 在不同机型上胖瘦不一的反馈是上线前最常见的美术问题之一。解决办法是在 C# 侧把宽度乘以当前 Canvas 的 Scale Factorusing UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(Graphic))] public class UIOutlineController : MonoBehaviour { static readonly int OutlineColorId Shader.PropertyToID(_OutlineColor); static readonly int OutlineWidthId Shader.PropertyToID(_OutlineWidth); static readonly int OutlineOffsetId Shader.PropertyToID(_OutlineOffset); [SerializeField] Color outlineColor Color.black; [SerializeField] float outlineWidth 2f; [SerializeField] Vector2 outlineOffset Vector2.zero; Graphic graphic; Material runtimeMat; Canvas canvas; void Awake() { graphic GetComponentGraphic(); canvas GetComponentInParentCanvas(); } void OnEnable() Apply(); public void SetWidth(float width) { outlineWidth width; Apply(); } public void SetColor(Color color) { outlineColor color; Apply(); } void Apply() { if (graphic null) return; if (runtimeMat null) { runtimeMat new Material(graphic.material); runtimeMat.name graphic.material.name (Outline Instance); graphic.material runtimeMat; } float scale canvas ! null ? canvas.scaleFactor : 1f; runtimeMat.SetColor(OutlineColorId, outlineColor); runtimeMat.SetFloat(OutlineWidthId, outlineWidth * scale); runtimeMat.SetVector(OutlineOffsetId, new Vector4(outlineOffset.x * scale, outlineOffset.y * scale, 0f, 0f)); } void OnDestroy() { if (runtimeMat ! null) Destroy(runtimeMat); } }OnDestroy里手动销毁材质是必须的。用new Material创建的实例不受 Unity 的自动回收管理一个界面上有几十个描边元素每次进出界面都 new 一份而不销毁内存会持续上涨在低端机上最终触发 OOM。至于 Scale Factor如果项目用的是 Constant Pixel Size 模式它就是 1乘不乘都一样。所以统一乘上不会出错可以放心当成通用写法。5. 常见问题与排查技巧实录5.1 描边断裂、缺角、采样到隔壁图元描边断裂是最常见的现象表现是轮廓的某些方向缺一块像被啃掉了一口。根因基本都在采样方向上。八个方向在圆上均匀分布如果描边宽度设到 10 个纹素以上相邻两条射线之间的弧长就超过了采样覆盖范围中间的像素被漏掉了。这个问题的解决办法有两个。一是增加射线数量从 8 条提到 16 条但采样次数会翻倍二是增加每条射线上的采样层数从 3 层提到 5 层同样增加开销。我一般建议优先加层数原因是层数增加对断角的修补更均匀而且很多情况下你只需要在描边特别宽的元素上开高配普通按钮用 8 方向 3 层完全够。缺角还有一种特殊形态只在某个特定角度出现一个方形的空缺。这种情况几乎可以断定是采样跨到了图集边界外。判断方法很简单把描边宽度调到 1如果缺口消失那就是边界问题。修复方式是去 Sprite Atlas 设置里把 Padding 调大到超过最大描边宽度通常设 8 像素能覆盖绝大多数需求。采样串图则更明显会在描边外侧出现一小块不规则的彩色区域颜色和相邻的图元一致。这也是 Padding 不足导致的处理方式同上。如果项目用的是图集自动打包建议在打包设置里把 Padding 固定为 8不要用默认值。现象最可能的原因处理方式轮廓呈花瓣状缺角方向数不足增加采样层数某方向出现方形缺口图集边界越界图集 Padding 调到 8描边外侧出现无关色块采样串到相邻图元同上或拆分图集描边整圈虚化发灰阈值设得太低阈值提到 0.3 以上边缘出现彩色毛刺抗锯齿像素被误判加大 Edge Softness5.2 描边粗细随分辨率漂移前面提过 Canvas Scale Factor 的影响但还有一种更隐蔽的漂移在运行时动态修改 Canvas 的 referenceResolution或者在不同界面之间切换不同的 CanvasScale Factor 变了但材质参数没更新。表现出来就是同一个按钮在主界面描边正常进了战斗界面就变细了。处理办法是在 Canvas 的 scaleFactor 变化时重新 Apply 一次。可以监听 Canvas 的 willRenderCanvases 事件或者在界面的 OnEnable 里统一刷一遍。后者更简单代价是如果 Scale Factor 在界面显示期间发生变化需要手动再调一次。还有一个坑是 Screen Space - Overlay 和 Screen Space - Camera 两种渲染模式的差异。Camera 模式下如果相机是正交且 Size 有缩放Scale Factor 的计算基准会跟着变描边宽度也会受影响。做适配时最好直接读取canvas.scaleFactor而不是自己按分辨率算一遍后者很容易漏掉参数。5.3 性能问题与排查表采样开销是这个方案的主要成本。一次绘制调用里24 次带依赖的纹理采样在移动端中低端机型上是能感知到的。如果屏幕上同时有十几个描边文字GPU 时间会明显上升。排查方式是用 Unity 的 Frame Debugger 或者平台的 GPU 分析工具看 UI 渲染阶段的时间占比。如果发现某个界面在打开时帧率掉了 5 帧以上逐一关闭描边元素的 Shader 就能定位。优化的方向按优先级排第一只在真正需要的元素上开描边不要图省事给整个 Text 组件群统一挂第二把_SecondOutlineWidth保持为 0Shader 里的条件分支虽然会执行但可以进一步用 shader_feature 把第二层做成可选变体让不用的项目彻底不带这段代码第三把描边宽度控制在 6 个纹素以内超过之后视觉收益递减而采样开销不变第四对静态的、不常变化的描边元素考虑预烘成带描边的图运行时直接用普通 UI Shader。提示如果项目要发小游戏平台Shader 变体数量是硬约束。把_SecondOutlineWidth改成#pragma shader_feature_local _SECOND_OUTLINE只在使用第二层描边的材质上启用能省下将近一半的变体。6. 进阶玩法与性能优化6.1 双层描边与描边发光双层描边在很多游戏 UI 里都能见到内层是一圈细的浅色描边外层是一圈粗的深色描边视觉上层次分明比单层描边耐看得多。实现方式就是代码里那段_SecondOutlineWidth的逻辑把外层宽度设成内层的两到三倍颜色设成更深的同色系就行。需要注意顺序第二层必须比第一层宽否则会被完全盖住。设置上内层宽度 2、外层宽度 5 是比较稳的搭配。如果外层宽度和内层太接近比如 2 和 3视觉上会糊成一块看起来像描边没对齐。描边发光则是另一种玩法思路是把采样得到的遮罩再做一次高斯近似模糊。具体做法是在算outerMask之后再以同样的环采样逻辑取一次更远的采样得到glowMask然后用glowMask和_OutlineColor做一次加法混合同时把 alpha 乘上一个衰减系数。这样描边外围会有一圈柔和的光晕。代价是采样次数再翻一倍建议只在关键的大标题上使用。还有一种取巧的做法把发光做成第二个 Renderer同一个位置叠一层用不同的采样半径。这样每个 Renderer 各自开销适中也方便用不同的 Sorting Order 控制层次。6.2 移动端降级策略移动端的降级不能靠运行时判断机型来切 Shader那样会引入额外的分支和材质管理复杂度。更稳的做法是准备两套 Shader 变体在构建时按平台选择。降级的第一档是把八方向三层降成四方向两层也就是 8 次采样。四方向就是上下左右采样缺口会比较明显但对于小巧的图标描边宽度 2 以内基本看不出来。第二档是把_EdgeSoftness加大到 0.1用柔化掩盖方向不足带来的锯齿。第三档是直接去掉描边退回原始 UI Shader只在性能最紧张的场景用。判断依据是描边宽度而不是设备型号。宽度小于等于 2 用低配2 到 6 用中配大于 6 才需要高配因为宽描边本来就更少出现可以针对性处理。这个策略在实际项目里比旗舰机高配、低端机低配更有效因为它把优化和实际视觉需求绑定了。另外如果项目用的是 URPUI 的渲染走的是 2D RendererShader 里的ZTest [unity_GUIZTestMode]依然有效但如果你把 Canvas 切成 World Space 并参与后处理就要确认描边元素的 Render Queue 和相机的渲染顺序匹配否则可能出现描边被后处理吃掉的情况。6.3 与 TMP 原生 Outline 的选择建议TMP 自带的 Outline 组件到底还要不要用我的建议是分场景。如果描边宽度很细1 像素以内、文字内容固定、字号也不大TMP 原生 Outline 的性能更好因为它是纯顶点操作不增加采样。问题是它在笔画交叉和尖角处会露怯尤其是米字旁、田字这类结构复杂的汉字尖角处会出现明显的凸起或断裂。如果描边宽度超过 2 像素、或者文字需要频繁更换颜色和大小那就该用本文这套 Shader。它是像素级判定不依赖网格结构任何字形都能描得干净。代价是采样开销和材质管理。还有一种混合方案用 TMP 原生 Outline 做内层的细描边用本文 Shader 做外层的粗描边。两者叠加时要注意颜色混合内层描边应该用比外层更亮的颜色否则会显得脏。这套组合在卡牌游戏的标题文字上效果非常好两个字叠起来有很强的手绘感。7. 一些参数经验值和使用体会参数这块我直接给一组实测下来比较稳的配置省得你从零开始摸。对普通 UI 图标和按钮文字_OutlineWidth设 2_OutlineColor用纯黑或深灰_AlphaThreshold设 0.15_EdgeSoftness设 0.05这是最通用的组合覆盖 80% 的场景。对 TMP 的中文大标题宽度设 3 到 4阈值提到 0.35柔化提到 0.08这样描边不会糊成一团。对 2D 场景里的精灵物件宽度可以放到 5 到 8但一定要确认图集 Padding 足够。我在一个项目里做过统计同一个界面上的描边元素超过 15 个时中端安卓机的 UI 渲染耗时会上涨大约 30%。后来把静态的标题文字全部预烘成带描边的 Sprite只保留按钮上的动态文字走 Shader整体耗时回到了正常水平。这个经验可以推广凡是不变的内容都不要在运行时算。_OutlineOffset这个参数其实比想象中好用。设成 (1, -1) 配合纯黑描边色就是一个带一点硬阴影的立体字效果比单纯描边更有设计感。做成动画时让 offset 在 (0,0) 和 (2,-2) 之间来回插值还能做出文字抖动的效果用在限时提示或者警告文字上很合适。最后提一句代码层面的建议把_OutlineColor、_OutlineWidth这些 ID 用Shader.PropertyToID缓存下来不要在 Update 里用字符串取。字符串哈希的开销虽然单次很小但在一个每帧刷新的文本列表里累积起来是能测出来的。这个习惯在很多地方都通用不止描边这一件事。现在这套方案我已经在三个不同类型的项目里跑过从纯 UI 的休闲游戏到带 2D 场景的中度项目都适配良好。真正需要你反复调的其实就那么几个参数剩下的坑——图集 Padding、Canvas 缩放、材质实例回收——只要提前知道基本不会翻车。