Unity Shader实现矩形无间隙描边:基于距离场的核心算法与工程实践
2026/8/6 10:12:26 网站建设 项目流程

1. 项目概述:为什么矩形描边是个“技术活”?

在Unity里给一个矩形UI或者Sprite加个描边,乍一听像是美术动动手指调个参数的事儿。但当你真正动手,尤其是在Shader层面去实现一个无间隙、高质量的描边时,就会发现这潭水比想象中深。很多新手,甚至是有经验的开发者,都曾卡在这个看似简单的需求上:描边要么断断续续,要么在拐角处糊成一团,要么在不同分辨率下粗细不一。这背后,其实是图形学中抗锯齿(Anti-Aliasing)像素对齐(Pixel Alignment)以及片元着色器(Fragment Shader)中距离计算等多个核心问题的集中体现。

我最初遇到这个问题,是在开发一个需要极致清晰UI的2D项目。美术提供的带描边素材在缩放或旋转后,边缘总会出现难看的锯齿或间隙,直接使用Unity UI自带的Outline组件又无法满足自定义颜色、渐变、内外描边等灵活需求。于是,转向Shader成了必然选择。一个自研的矩形无间隙描边Shader,不仅能完美解决视觉问题,更能成为你Shader学习路上一个绝佳的“练手项目”,因为它几乎涵盖了从基础向量运算到高级边缘检测的所有关键概念。

本文要实现的,就是一个基于Shader的、可完全自定义的矩形描边方案。它不依赖于任何第三方插件,核心思路清晰,代码可读性强,并且我会把实现过程中踩过的每一个坑、每一个参数调整的“所以然”都讲透。无论你是刚接触Shader的新手,想通过一个具体案例入门,还是已经有一定基础,想优化自己的渲染效果,这篇文章都能给你带来直接的、可复现的参考价值。

2. 核心思路拆解:从“描边”到“无间隙”

实现描边的Shader方法有很多,比如基于法线扩展、基于后处理(Post-Processing)的边缘检测、或者基于SDF(有向距离场)。但对于一个轴对齐的矩形(无论是UI Image还是2D Sprite),最高效、最清晰的方法是利用其几何特性进行基于距离的描边

2.1 传统描边方法的局限

在深入我们的方案前,先快速回顾几种常见方法为什么不适合“无间隙”这个高标准要求:

  1. 顶点膨胀法(Vertex Expansion):在顶点着色器中沿法线方向移动顶点。这对于平滑模型有效,但对于矩形这种只有四个顶点的简单模型,描边只会出现在四个边上,拐角处是绝对的直角,当描边较粗时,拐角会显得非常生硬且内部可能出现空洞,无法实现“无间隙”的饱满拐角。
  2. 多重Pass渲染法:用第一个Pass渲染填充色,第二个Pass用稍微放大的相同模型渲染描边色。这同样受限于矩形本身的顶点数量,放大的模型拐角处是拉伸的,描边不均匀,且难以控制精确的像素宽度。
  3. 后处理边缘检测:如Sobel算子。这能获得连续的边缘,但计算开销大,会影响全屏性能,并且难以针对单个UI元素进行独立控制,容易受到场景中其他物体的干扰。

2.2 基于片元距离场的方案

我们的核心方案是:在片元着色器中,为每一个像素计算其到矩形四条边的距离,然后根据这个距离来决定该像素是显示为内部填充色还是描边色。

这个方案的优越性在于:

  • 像素级精度:计算在每个像素上独立进行,与顶点数量无关,从根本上保证了描边在任意处的连续性。
  • 完美拐角:通过计算到最近边的距离,拐角处自然形成圆滑(或根据需求保持锐利)的过渡,实现“无间隙”。
  • 灵活控制:描边宽度、颜色、内外描边、甚至渐变描边,都可以通过简单的参数和计算实现。
  • 性能高效:计算仅涉及简单的减法和min/max操作,非常适合UI和2D Sprite这种每帧大量渲染的元素。

核心计算公式(思想): 对于一个中心在(0,0),半宽为_RectWidth,半高为_RectHeight的矩形,其内部区域可以用一个符号距离函数(SDF)来定义:点p到矩形四条边的带符号距离。 更简单直接的理解是:对于一个位于矩形内的像素,其到最近边的距离决定了它离“边缘”有多远。如果这个距离小于我们设定的描边宽度_OutlineWidth,那么它就应该被渲染为描边色。

3. Shader实现详解:从理论到代码

我们将编写一个Unlit Shader,它适用于SpriteUI/Default。这里以Sprite为例,因为其UV和像素空间的关系更直接。

3.1 Shader属性与变量定义

首先,在Properties块中定义我们需要的参数,这些参数可以在材质面板中调节。

Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) // 描边专属属性 _OutlineColor ("Outline Color", Color) = (1,0,0,1) // 默认红色描边 _OutlineWidth ("Outline Width", Range(0, 0.5)) = 0.05 // 宽度范围,相对于矩形尺寸的比例 _OutlineSharpness ("Outline Sharpness", Range(0.01, 10)) = 2.0 // 控制边缘锐利度 // 矩形尺寸(通常通过脚本传递,也可手动调节) _RectSize ("Rect Size (Width, Height)", Vector) = (1, 1, 0, 0) [Toggle(USE_PIXEL_SNAP)] _PixelSnap ("Pixel Snap", Float) = 0 [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1) [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1) }

参数解析

  • _OutlineWidth:这是最关键的一个参数。注意它的范围是[0, 0.5]。为什么是0.5?因为我们计算距离时,通常将矩形的半宽/半高归一化为1。描边宽度如果超过0.5(即半宽),就可能从矩形的一边“画”到另一边,这通常不是我们想要的。这个值是一个比例值,能保证在不同尺寸的矩形上,描边的视觉粗细比例是一致的。
  • _OutlineSharpness:用于在描边和填充色之间进行平滑插值(smoothstep)。值越大,描边到填充的过渡越生硬、锐利;值越小,过渡越平滑、有抗锯齿效果,但可能让描边看起来“发虚”。这个参数是调节视觉质量的关键。
  • _RectSize:传入矩形的实际宽高。我们将在顶点着色器或片元着色器中,用它来将像素坐标转换到矩形的局部空间,进行正确的距离计算。

3.2 顶点着色器:准备计算空间

顶点着色器的主要任务是完成标准的坐标变换,并将计算描边所需的关键数据传递给片元着色器。

v2f vert(appdata_t IN) { v2f OUT; // 标准坐标变换 OUT.vertex = UnityObjectToClipPos(IN.vertex); OUT.texcoord = IN.texcoord; OUT.color = IN.color * _Color * _RendererColor; // 关键步骤:将模型空间顶点坐标转换到矩形局部空间。 // 假设矩形中心在模型空间原点,且无旋转缩放(由Transform组件控制)。 // 我们需要矩形的“半尺寸”来进行距离计算。 // 更稳健的做法:将世界空间或模型空间的尺寸信息传递进来。 // 这里我们采用一种常见方法:利用UV和纹理的Tiling/Offset来隐式定义矩形范围。 // 但为了最清晰的理解,我们假设UV(0,0)到(1,1)正好对应矩形区域。 // 我们将UV中心化并映射到[-0.5, 0.5]的范围,然后根据_RectSize进行缩放。 float2 centeredUV = IN.texcoord - float2(0.5, 0.5); // UV中心化 OUT.localPos = centeredUV * _RectSize; // 得到在矩形局部空间中的位置,原点在中心 #ifdef USE_PIXEL_SNAP OUT.vertex = UnityPixelSnap(OUT.vertex); #endif return OUT; }

这里有一个非常重要的技巧和注意事项

注意:上面代码中通过UV和_RectSize来计算localPos是一种简化且有效的方法,但它依赖于一个关键假设:矩形的Mesh(比如一个Quad)的UV是完美从(0,0)铺满到(1,1)。对于Unity的默认Sprite或UI Image,这通常是成立的。但如果你使用自定义的Mesh,就需要确保这一点。另一种更通用的方法是在脚本中将矩形的实际尺寸(transform.lossyScale)通过MaterialPropertyBlock传递给Shader。为了教程清晰,我们先使用UV方法。

3.3 片元着色器:距离计算与颜色混合

这是整个Shader的灵魂。我们将在片元着色器中为每个像素计算它到矩形四条边的距离。

fixed4 frag(v2f IN) : SV_Target { // 采样主纹理颜色 fixed4 texColor = tex2D(_MainTex, IN.texcoord) * IN.color; // 获取当前像素在矩形局部空间的位置 float2 p = IN.localPos; // p.x, p.y 的范围大约是 [-RectWidth/2, RectWidth/2] // 计算矩形半宽半高(因为localPos已经是缩放后的,所以这里半宽半高就是_RectSize/2?) // 不对!我们需要重新思考。IN.localPos = centeredUV * _RectSize。 // 当UV为(0,0)时,centeredUV = (-0.5, -0.5), localPos = (-0.5, -0.5) * _RectSize。 // 当UV为(1,1)时,centeredUV = (0.5, 0.5), localPos = (0.5, 0.5) * _RectSize。 // 因此,矩形的实际边界在 x = ±(_RectSize.x/2), y = ±(_RectSize.y/2)。 // 所以,到右边界的距离是:(_RectSize.x/2) - abs(p.x)? 不对,p.x已经是包含了_RectSize缩放的。 // 我们来推导:p.x = (uv.x - 0.5) * _RectSize.x。 // 矩形右边界对应 uv.x=1,即 p.x = 0.5 * _RectSize.x。 // 所以,半宽 halfWidth = _RectSize.x / 2。 // 那么,点p到右边界的距离为:halfWidth - abs(p.x)。 // 但注意,p.x可能超过halfWidth吗?对于UV在[0,1]内的像素,不会。但我们的Shader可能会处理超出范围的像素(比如为了描边)。 float halfWidth = _RectSize.x * 0.5; float halfHeight = _RectSize.y * 0.5; // 计算当前像素到矩形四条边的距离(正值表示在内部) float distToRight = halfWidth - abs(p.x); float distToLeft = distToRight; // 因为矩形对称,实际上到左边界距离等于到右边界 // 更准确地说,到垂直方向最近边的距离是: float distToVerticalEdge = halfWidth - abs(p.x); float distToTop = halfHeight - abs(p.y); float distToBottom = distToTop; float distToHorizontalEdge = halfHeight - abs(p.y); // 关键!像素到矩形边缘的最近距离,是到垂直边和水平边距离的较小值。 // 想象一下矩形的拐角,最近的距离是到那个角的斜边吗?不,对于轴对齐矩形,内部一点到边缘的最近距离, // 就是 min(到左/右边的距离, 到上/下边的距离)。 float distanceToEdge = min(distToVerticalEdge, distToHorizontalEdge); // 现在,distanceToEdge 这个值: // > 0: 点在矩形内部 // = 0: 点正好在边上 // < 0: 点在矩形外部(因为abs(p.x)可能大于halfWidth) // 实现描边:如果到边缘的距离小于描边宽度,则显示描边色。 // 但_OutlineWidth是比例,我们需要将其转换为局部空间的实际距离单位。 // 定义:描边宽度 = 矩形半宽 * _OutlineWidth? 还是矩形半高?为了保持均匀,我们应该取一个基准。 // 一个常见的做法是使用矩形的“最小半轴”作为基准,或者使用一个统一的视觉比例。 // 这里我们使用一个更直观的方法:将_OutlineWidth视为相对于矩形**尺寸**的比例。 // 我们需要一个基准长度。通常使用矩形的半周长、或者min(halfWidth, halfHeight)。 // 但这样在不同长宽比的矩形上,描边视觉粗细会不一致。 // 更好的方法是:将_OutlineWidth直接解释为在局部空间中的绝对距离单位。 // 那么,我们让用户输入的_OutlineWidth范围是[0, 0.5],其实际距离 = _OutlineWidth * min(halfWidth, halfHeight) * 2? 太复杂。 // 经过实践,最稳定且直观的方案是: // 将用户输入的_OutlineWidth视为**相对于矩形最小边长度**的比例。 // 但计算时,我们将其转换到局部空间的距离。 // 假设我们约定:_OutlineWidth = 0.05 表示描边宽度是矩形最小边长的5%。 // 那么局部空间的实际描边距离 = _OutlineWidth * min(_RectSize.x, _RectSize.y)。 float baseLength = min(_RectSize.x, _RectSize.y); float outlineDistance = _OutlineWidth * baseLength; // 计算描边因子:当distanceToEdge小于outlineDistance时,进入描边区域。 // 使用smoothstep实现平滑过渡,_OutlineSharpness控制过渡平滑度。 // smoothstep(a, b, x): 当x<a时返回0, x>b时返回1,在a,b之间平滑过渡。 // 我们希望:当distanceToEdge > outlineDistance时,为0(完全填充色); // 当distanceToEdge < 0时,为1(完全描边色?不对,矩形外部我们可能不显示)。 // 实际上,我们只关心矩形内部和边缘附近。我们定义一个“描边区域”:从边缘向内outlineDistance宽度的区域。 // 所以描边因子 = 1.0 - smoothstep(0, outlineDistance, distanceToEdge) // 这样,在边缘处(distanceToEdge=0),因子为1;在向内outlineDistance处(distanceToEdge=outlineDistance),因子为0。 float outlineFactor = 1.0 - smoothstep(0, outlineDistance, distanceToEdge); // 但是,上面的计算只处理了“内描边”。如果点在矩形外部(distanceToEdge < 0),outlineFactor也会>1?需要clamp。 // 更完善的处理: // 我们只对矩形内部和边缘附近像素着色。对于明显在外部的像素,可以直接丢弃或显示透明。 // 这里我们简单处理:将distanceToEdge小于一个很小负值的像素直接丢弃(如果纹理有透明通道)。 // 或者,我们可以实现“外描边”,这需要修改距离计算。 // 我们先实现标准的内描边: // 重新定义:描边区域是 distanceToEdge 在 [0, outlineDistance] 之间。 // 使用smoothstep的逆运算: outlineFactor = smoothstep(outlineDistance, 0, distanceToEdge); // 当distanceToEdge >= outlineDistance, outlineFactor <= 0 // 当distanceToEdge <= 0, outlineFactor >= 1 // 我们需要clamp一下,并且只对distanceToEdge > -outlineDistance*0.5的像素进行处理(防止外部过远像素影响)。 outlineFactor = clamp(outlineFactor, 0, 1); // 混合颜色:在填充色和描边色之间根据outlineFactor插值 fixed4 finalColor = lerp(texColor, _OutlineColor, outlineFactor); // 还有一个重要问题:抗锯齿。我们现在的边缘在放大后看可能还是有锯齿。 // 我们可以利用distanceToEdge这个带符号的距离信息,在边缘进行亚像素级别的透明度混合。 // 这是一个高级技巧,简单版:在片元输出前,根据distanceToEdge做一个非常精细的alpha渐变。 // 但这可能会和纹理本身的alpha冲突。对于不透明UI,可以不做。对于需要平滑边缘的Sprite,可以: // float edgeAlpha = smoothstep(-0.5, 0.5, distanceToEdge); // 假设一个像素的过渡区间 // finalColor.a *= edgeAlpha; // 注意:这需要Shader Blend模式支持透明度。 return finalColor; }

这段代码是核心,但里面包含了几个需要深刻理解的点和潜在的“坑”。让我们停下来,深入剖析一下。

3.4 关键算法剖析与优化

1. 距离计算的理解distanceToEdge = min(halfWidth - abs(p.x), halfHeight - abs(p.y));这行代码是精髓。它计算的是点到矩形内部的最近距离。在矩形内部,这个值为正。在矩形外部(比如一个点x坐标的绝对值大于半宽),halfWidth - abs(p.x)就会变成负数,min操作会选取这个负值,所以distanceToEdge也变为负,表示点在外部。

2. 描边宽度的基准选择(大坑!): 这是实现“无间隙”且视觉均匀描边的最大挑战。如果我们简单地将_OutlineWidth乘以一个固定值(比如halfWidth),那么在一个宽高比很大的矩形(比如一个很宽的进度条)上,上下边的描边看起来会非常细,而左右边的描边看起来正常,这显然不是我们想要的。

解决方案:应该以屏幕像素局部空间归一化的某个基准来定义描边宽度。我们的代码中使用了min(_RectSize.x, _RectSize.y)作为基准,这保证了无论矩形形状如何,描边宽度都相对于其最小边,从而在视觉上保持相对均匀。但这还不是最完美的,因为当矩形旋转或非轴对齐时,_RectSize可能不准确。对于UI系统,更推荐的做法是将宽度定义为像素单位,然后在Shader中根据实际渲染尺寸和屏幕分辨率进行转换。这涉及到更复杂的_ScreenParams和矩阵变换,为了教程聚焦,我们暂用当前方案,它对于大多数轴对齐UI已经足够好。

3. 平滑过渡与抗锯齿smoothstep函数是我们的好朋友。outlineFactor = smoothstep(outlineDistance, 0, distanceToEdge);这行代码创建了一个从outlineDistance0的平滑过渡。第二个参数小于第一个参数,所以函数是递减的。这正好符合我们的需求:距离边缘越近(distanceToEdge越小),描边因子越大。_OutlineSharpness参数可以通过修改smoothstep的过渡范围或使用幂函数pow(outlineFactor, _OutlineSharpness)来实现更锐利的边缘,但直接修改smoothstep的边界不够直观。一个更好的方法是:outlineFactor = saturate((outlineDistance - distanceToEdge) / (outlineDistance * _OutlineSharpnessFactor));然后使用pow(outlineFactor, _OutlineSharpness)来控制曲线形状。这给了我们更大的控制权。

4. 处理矩形外部像素: 我们的Shader目前会处理所有传入的片元。如果矩形的Mesh没有覆盖描边区域,那么外描边就不会显示。为了实现外描边,我们需要扩大Mesh,或者更优雅地,在Shader中不丢弃外部像素,而是当distanceToEdge[-outlineDistance, 0]区间时也显示描边色。这只需要修改smoothstep的输入范围即可。

4. 完整Shader代码与使用指南

结合以上所有分析,这里给出一个优化后的、更健壮的完整Shader代码,并包含内外描边选项。

Shader "Custom/RectangleOutline" { Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _OutlineColor ("Outline Color", Color) = (1,0,0,1) _OutlineWidth ("Outline Width", Range(0, 0.2)) = 0.05 _OutlineSharpness ("Outline Sharpness", Range(0.1, 50)) = 10.0 [KeywordEnum(Inner, Outer, Both)] _OutlineType ("Outline Type", Float) = 0 _RectSize ("Rect Size (World Units)", Vector) = (1, 1, 0, 0) [Toggle] _PixelSnap ("Pixel Snap", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _PIXELSNAP_ON #pragma multi_compile _ _OUTLINETYPE_INNER _OUTLINETYPE_OUTER _OUTLINETYPE_BOTH #include "UnityCG.cginc" struct appdata_t { float4 vertex : POSITION; float2 texcoord : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 texcoord : TEXCOORD0; float2 localPos : TEXCOORD1; // 矩形局部空间位置 fixed4 color : COLOR; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; fixed4 _OutlineColor; float _OutlineWidth; float _OutlineSharpness; float2 _RectSize; v2f vert(appdata_t v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex); o.color = v.color * _Color; // 计算局部位置:从UV[0,1]映射到[-RectSize/2, RectSize/2] float2 centeredUV = v.texcoord - float2(0.5, 0.5); o.localPos = centeredUV * _RectSize; #ifdef _PIXELSNAP_ON o.vertex = UnityPixelSnap(o.vertex); #endif return o; } fixed4 frag(v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.texcoord) * i.color; float2 p = i.localPos; float halfWidth = _RectSize.x * 0.5; float halfHeight = _RectSize.y * 0.5; // 计算到四条边的距离(内部为正) float distX = halfWidth - abs(p.x); float distY = halfHeight - abs(p.y); // 到最近边的距离 float distanceToEdge = min(distX, distY); // 将描边宽度从比例转换为局部空间单位 // 使用矩形最小边作为基准,保证视觉均匀 float minSide = min(_RectSize.x, _RectSize.y); float outlineWidthLocal = _OutlineWidth * minSide; float outlineFactor = 0; // 根据描边类型计算因子 #if defined(_OUTLINETYPE_INNER) // 内描边:仅当点在内部且靠近边缘时 if(distanceToEdge > 0) { outlineFactor = 1.0 - smoothstep(0, outlineWidthLocal, distanceToEdge); } #elif defined(_OUTLINETYPE_OUTER) // 外描边:仅当点在外部且靠近边缘时 if(distanceToEdge < 0) { float distToEdgeAbs = abs(distanceToEdge); outlineFactor = 1.0 - smoothstep(0, outlineWidthLocal, distToEdgeAbs); } #else // _OUTLINETYPE_BOTH // 内外描边:无论内外,靠近边缘就显示描边 float distToEdgeAbs = abs(distanceToEdge); outlineFactor = 1.0 - smoothstep(0, outlineWidthLocal, distToEdgeAbs); #endif // 应用锐度控制:通过幂函数使过渡更锐利 outlineFactor = pow(outlineFactor, _OutlineSharpness * 0.1); // 缩放系数便于调节 // 颜色混合 fixed4 finalColor = lerp(texColor, _OutlineColor, outlineFactor); // 高级抗锯齿:在边缘处基于distanceToEdge微调alpha,实现亚像素级平滑 // 注意:这可能会与纹理alpha冲突,根据需求启用 // float edgeSoftness = 0.5; // 软化像素数 // float alphaAA = smoothstep(-edgeSoftness, edgeSoftness, distanceToEdge); // finalColor.a *= alphaAA; // 预乘Alpha混合的正确处理 finalColor.rgb *= finalColor.a; return finalColor; } ENDCG } } Fallback "Sprites/Default" }

使用指南

  1. 创建材质:在Project视图中右键 -> Create -> Material,将Shader选择为“Custom/RectangleOutline”。
  2. 应用到UI或Sprite:将材质拖拽到UI Image或Sprite Renderer的Material属性上。
  3. 参数调节
    • Outline Width:控制描边粗细。建议从0.02开始调节。
    • Outline Sharpness:值越大,描边与填充色的分界越锐利;值越小(如接近0.1),边缘越柔和,有抗锯齿效果,但可能“发虚”。通常设置在5-20之间能获得较好效果。
    • Outline Type:选择描边类型。Inner(内描边)、Outer(外描边)、Both(内外描边)。外描边需要确保Mesh足够大以容纳描边区域,或者使用Both并确保纹理透明区域足够。
    • Rect Size重要!这里需要输入矩形在世界空间或本地空间中的实际尺寸(宽和高)。你可以写一个简单的脚本,在Start()Update()中获取transform.lossyScalerectTransform.rect.size,并通过material.SetVector("_RectSize", size)传递给Shader。这是实现精确描边的关键一步。

5. 常见问题、排查技巧与性能优化

即使有了完整的代码,在实际使用中你仍然会遇到各种问题。下面是我在多个项目中总结出来的“避坑指南”。

5.1 描边显示不全或错位

  • 症状:描边只出现在矩形的某一条边,或者位置不对。
  • 排查
    1. 检查_RectSize参数:这是最常见的原因。确保你通过脚本传递的尺寸是正确的。对于UI元素,使用rectTransform.rect.widthrectTransform.rect.height。对于2D Sprite,可能需要使用sprite.bounds.size乘以transform.lossyScale。在Shader中添加一个调试输出,比如将float4(distanceToEdge, outlineFactor, 0, 1)作为颜色输出,可以直观看到距离场的可视化结果,帮助你判断_RectSize是否正确。
    2. 检查UV:确保你的矩形Mesh的UV是规整的从(0,0)到(1,1)。对于图集(Atlas)中的Sprite,UV不是全图,我们的计算会出错。此时需要另一种思路:在片元着色器中,通过ddx/ddy指令估算像素在纹理空间中的变化率来近似计算距离,但这复杂得多。对于图集Sprite,建议使用专门的Sprite外描边Shader,或者确保使用原始纹理而非图集。
    3. 检查Mesh边界:外描边(OuterBoth)需要Mesh本身覆盖描边区域。如果Mesh只是刚好覆盖填充区域,外描边就会被裁剪掉。解决方法是使用一个稍微放大的Mesh,或者在Shader中采用基于纹理Alpha测试的边缘检测方法(这超出了本文范围)。

5.2 描边粗细不均匀或拐角过圆/过尖

  • 症状:在长方形上,长边的描边比短边细;拐角处不是理想的90度。
  • 排查与解决
    1. 基准长度选择:我们代码中使用min(_RectSize.x, _RectSize.y)作为基准,这保证了在任意矩形上,描边相对于最小边是均匀的,但长边上的描边视觉上还是会显得细,因为它的绝对像素宽度是一样的。如果你希望描边在屏幕像素上绝对均匀,需要将_OutlineWidth转换为屏幕像素宽度。这需要在Shader中获取从局部空间到屏幕空间的导数,计算复杂度较高。对于大多数UI场景,当前方案可以接受。
    2. 拐角形状:基于min距离函数生成的拐角是圆角,这是数学上的必然(等距线)。如果你需要锐利的90度拐角,需要使用不同的距离函数,例如使用max(abs(p.x)-halfWidth, abs(p.y)-halfHeight)这种“无限范数”距离,但实现起来更复杂,且可能在其他地方产生不连续。我们的方案提供的圆角在视觉上通常是可接受的,甚至更美观。

5.3 性能考量与优化建议

  1. Overdraw:我们的Shader会处理Mesh覆盖的所有片元。如果矩形很小但Mesh很大(为了外描边),会造成不必要的Overdraw。在移动端,对于大量UI元素,这需要关注。优化方法是尽可能使用内描边,并确保Mesh大小精确。
  2. 分支语句:Shader中的if语句(如我们根据_OUTLINETYPE的判断)在有些GPU架构上可能导致性能波动。我们可以尝试使用step()lerp()函数来消除分支,但会牺牲一些可读性。对于UI Shader,通常压力不大,保持可读性更重要。
  3. 批处理(Batching):确保材质参数尽可能通过MaterialPropertyBlock来设置,而不是修改Material实例,这样可以保持动态合批(Dynamic Batching)或GPU Instancing的有效性,大幅提升渲染大量相同Shader不同参数UI元素的效率。
  4. 针对URP/HDRP:本文Shader基于内置渲染管线。如果要在URP(Universal Render Pipeline)中使用,需要将其转换为URP的Shader Graph或编写URP兼容的HLSL代码。核心算法不变,但需要遵循URP的库函数和渲染管线接口。

5.4 扩展思路

当你掌握了基础的无间隙描边后,可以尝试以下扩展,让你的效果更出彩:

  • 渐变描边:将_OutlineColor从一个颜色插值到另一个颜色。可以在计算outlineFactor的基础上,再根据distanceToEdgep的位置计算第二个插值因子。
  • 虚线描边:在片元着色器中,根据像素在边缘上的位置(可以通过frac函数实现)来决定是否显示描边色,从而创造出虚线效果。
  • 发光/外发光效果:在外描边的基础上,将smoothstep的过渡区间拉长,并使用一个从描边色到透明的渐变,可以模拟发光效果。
  • 与UI Mask/RectMask2D配合:注意,我们的Shader在遇到UI的Mask组件时,描边在Mask边界处可能会被生硬切断。如果需要完美的Masked描边,可能需要将距离计算与Mask的边界信息结合起来,这属于更高级的主题。

实现一个完美的矩形无间隙描边,就像打磨一件手工艺品,需要你对距离场、坐标空间转换和视觉表现有细腻的理解。它绝不是简单的调用一个API,而是一个融合了数学、图形学和实用技巧的综合性解决方案。希望这篇超详细的解析,能帮你彻底征服这个“熟悉的陌生人”,并在下次面对任何自定义形状的描边需求时,都能游刃有余。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询