1. 项目概述为什么UI圆角不只是“好看”在Unity里做UI尤其是移动端或者现代风格的应用给按钮、面板加个圆角几乎是设计师的“标配”要求。乍一看这需求简单得不行——不就是把方形的四个角磨圆吗很多新手开发者第一反应可能就是“简单让美术切一张带透明通道的圆角图不就行了” 或者用Unity自带的Mask组件配合一个圆形的Sprite来裁剪。这些方法在项目初期、UI元素固定且数量少的时候确实能快速解决问题。但当你真正深入项目尤其是需要应对动态尺寸、复杂交互、性能优化和设计频繁迭代时你就会发现一张静态图片或者一个简单的Mask带来的麻烦远比解决的问题多。图片需要为不同尺寸准备多套资源或者拉伸导致圆角变形Mask会引入额外的Draw Call和Overdraw在滚动列表里大量使用时性能开销立竿见影。更别提设计师突然想调整圆角半径或者想要一个从直角到圆角的动态过渡动画用静态资源几乎无法优雅实现。这就是为什么我们需要深入到Shader层面亲手打造一个“圆角效果”的根本原因。它不仅仅是为了视觉上的“圆润”更是为了获得极致的灵活性、可控的性能以及动态调整的能力。一个写好的圆角Shader可以像使用Slider一样实时调整圆角大小可以轻松应对任何尺寸的UI元素而无需担心变形可以集成边框、内外发光、渐变色等高级特性且所有计算都在GPU上高效完成。这次我们就从最底层的Shader原理讲起手把手带你实现一个工业级可用的Unity UI圆角效果并探讨其各种高级应用场景。无论你是想优化现有项目的UI性能还是为下一个酷炫的UI系统打下基础这篇指南都能给你提供从原理到实战的完整路径。2. 核心原理拆解GPU是如何“画”出圆角的在动手写代码之前我们必须搞清楚一个核心问题在片段着色器Fragment Shader里我们如何判断一个像素点是在“圆角”之内从而决定是显示颜色还是透明2.1 距离场与符号距离函数这里的关键概念是距离场。简单来说距离场就是一个函数对于空间中的任意一点这个函数能告诉你该点到某个形状边界的最短距离。如果点在形状内部距离为负在外部距离为正恰好在边界上距离为零。对于我们想要的圆角矩形我们可以将其视为一个“膨胀”的过程。想象一个标准的矩形我们把它的每条边都向内“推”进一个距离r圆角半径然后在四个角上用半径为r的四分之一圆弧来连接这些被推进后的边。判断一个点p是否在这个圆角矩形内部最经典的算法如下坐标归一化与中心对齐首先我们需要将当前像素的UV坐标范围0到1转换到以UI元素中心为原点的坐标系并且让坐标范围在[-0.5, 0.5]之间。这样一个宽度为width高度为height的矩形其实际半宽半高就是(width/2, height/2)。计算到内部矩形的有符号距离我们定义一个“内部矩形”它的尺寸是原始矩形尺寸减去两倍的圆角半径r。即内部矩形的半宽半高为(halfWidth - r, halfHeight - r)。对于一个点p计算其到该内部矩形边界的有符号距离。一个高效的公式是d max(abs(p.x) - (halfWidth - r), abs(p.y) - (halfHeight - r))这个d如果小于等于0说明该点在内部矩形区域肯定在圆角矩形内部。如果d大于0说明该点在内部矩形之外但有可能在四个角的圆弧区域内。处理圆角区域对于在内部矩形之外的点即d 0我们需要进一步判断它是否在角部的圆弧内。我们取点p的绝对值坐标q abs(p)然后构造一个向量corner q - vec2(halfWidth - r, halfHeight - r)。将corner的每个分量限制在大于等于0max(corner, 0.0)然后计算这个向量的长度即点到半径为r的圆弧圆心的距离减去半径rroundDist length(max(corner, 0.0)) - r如果roundDist 0则该点在圆弧内属于圆角矩形的一部分。合并距离最终的“到圆角矩形边界的有符号距离”finalDist就是max(d, roundDist)。当finalDist 0时像素在形状内finalDist 0时像素在形状外。这个finalDist的值本身也非常有用它可以用来实现抗锯齿、描边、内外发光等效果。注意上述是原理性描述。在Shader中我们通常会使用一个更优化、更简洁的函数sdRoundedBoxSigned Distance to Rounded Box来一步到位地计算这个距离。网络上有很多现成的实现其核心思想是一致的。2.2 抗锯齿与平滑边缘直接使用上述距离进行“非0即1”的裁剪会产生非常生硬的锯齿边缘。因为一个像素要么被完全渲染要么完全透明在边界处会看到明显的“楼梯”状锯齿。为了解决这个问题我们必须引入抗锯齿。抗锯齿的本质是在边界处进行平滑过渡。我们利用计算得到的符号距离dist。假设dist 0是精确边界。当dist -aaWidth例如 -0.5像素我们认为该点完全在内部不透明度为1。当dist aaWidth例如 0.5像素我们认为该点完全在外部不透明度为0。当dist在[-aaWidth, aaWidth]这个区间内时我们使用一个平滑函数通常是smoothstep或简单的线性插值来计算一个介于0和1之间的不透明度alpha值。这个aaWidth通常与屏幕像素相关在UI Shader中我们可以根据fwidth(dist)函数来估算距离值在当前像素周围的变化率从而实现自适应的抗锯齿这样无论UI放大还是缩小边缘都能保持平滑。// 一个简单的抗锯齿函数示例 float ApplyAntiAliasing(float distance, float halfWidth) { // halfWidth 通常取 0.5 到 1.0 像素对应的距离值 // 使用 smoothstep 在边界附近进行平滑过渡 return smoothstep(halfWidth, -halfWidth, -distance); // 或者使用 clamp 的线性插值 // return clamp(0.5 - distance / (halfWidth * 2.0), 0.0, 1.0); }2.3 Unity UI 与 Shader 的对接Unity 的 UI 系统uGUI默认使用一个名为UI/Default的 Shader。我们要实现自定义圆角通常有两个主流路径继承并修改UI/DefaultShader这是最推荐的方式。我们复制一份 Unity 内置的 UI Shader 代码在其片段着色器阶段插入我们自己的圆角距离计算和裁剪逻辑。这样做的好处是能完全兼容 UI 系统的所有功能Mask、Raycast、Color Tint、Sprite Atlas 等。我们需要在frag函数中在最终输出颜色之前用我们计算出的 alpha 值去乘原有的 alpha。编写一个全新的 Unlit Shader这种方式更自由但需要自己处理顶点变换、UI 颜色叠加等基础工作并且要确保与 UI Canvas 的渲染顺序兼容。对于复杂的、需要与 UI 系统深度交互的效果不推荐新手使用。在我们的实战中我们将采用第一种方式这是最稳妥、兼容性最好的方案。3. 实战构建一个基础圆角UI Shader现在让我们把理论付诸实践。我们将创建一个名为UI Rounded Corner.shader的文件。3.1 Shader 结构与属性定义首先我们定义 Shader 所需的属性。除了标准 UI Shader 需要的_MainTex主纹理、_Color色调外我们还需要添加控制圆角的参数。Shader UI/Rounded Corner { Properties { [PerRendererData] _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) // 圆角控制参数 _Radius (Corner Radius, Range(0, 0.5)) 0.1 _AASmoothing (AA Smoothing, Range(0.01, 2.0)) 1.0 // 高级效果参数后续扩展用 _BorderWidth (Border Width, Range(0, 0.1)) 0.02 _BorderColor (Border Color, Color) (0,0,0,1) _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 }_Radius圆角半径。这里范围设为[0, 0.5]因为我们的UV坐标范围是0-1半径最大为0.5即变成圆形。在实际使用中我们更希望用像素单位来控制这需要在Shader中根据实际尺寸进行转换我们稍后处理。_AASmoothing抗锯齿平滑度系数用于控制边缘过渡的柔和程度。_BorderWidth和_BorderColor为后续实现描边效果预留的参数。3.2 顶点与片段着色器核心我们跳过标准的顶点着色器主要负责坐标变换和传递UV直接看片段着色器的核心逻辑。我们需要在片段着色器中添加一个函数来计算圆角矩形的有符号距离。// 在片段着色器之前定义计算函数 float sdRoundRect(float2 p, float2 b, float r) { // p: 当前点坐标中心为原点 // b: 矩形半尺寸width/2, height/2 // r: 圆角半径 float2 q abs(p) - b r; return min(max(q.x, q.y), 0.0) length(max(q, 0.0)) - r; } // 在片段着色器 (frag) 中 fixed4 frag(v2f IN) : SV_Target { half4 color (tex2D(_MainTex, IN.texcoord) _TextureSampleAdd) * IN.color; #ifdef UNITY_UI_CLIP_RECT color.a * UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip (color.a - 0.001); #endif // --- 核心圆角逻辑开始 --- // 1. 将UV从 [0,1] 转换到以中心为原点的 [-0.5, 0.5] 坐标系 float2 centeredUV IN.texcoord - 0.5; // 2. 获取当前UI元素的实际像素尺寸通过顶点色或其它方式传递这里简化处理 // 更佳实践通过脚本将 _RectSize 传递给Shader。 float2 halfSize float2(0.5, 0.5); // 假设UV空间尺寸为1 // 3. 将圆角半径从“比例”转换为“UV空间”长度。假设_Radius是比例比如0.1代表宽度的10%。 float pixelRadius _Radius; // 这里需要根据实际需求转换例如_Radius * min(halfSize.x, halfSize.y); // 4. 计算有符号距离 float distance sdRoundRect(centeredUV, halfSize, pixelRadius); // 5. 应用抗锯齿 // fwidth(distance) 估算当前像素周围距离的变化率用于自适应抗锯齿宽度 float aa fwidth(distance) * _AASmoothing; float alphaFactor smoothstep(aa, -aa, -distance); // 当distance从正变负时alpha从0平滑过渡到1 // 6. 将圆角Alpha应用到最终颜色 color.a * alphaFactor; // --- 核心圆角逻辑结束 --- return color; }实操心得上面代码中的halfSize直接用了(0.5, 0.5)这仅在UI Image的Preserve Aspect未开启且完全填充时准确。在实际项目中为了应对任何尺寸最佳实践是通过脚本将UI元素的实际宽高以像素为单位通过MaterialPropertyBlock传递给Shader或者在顶点着色器中计算并传递每个顶点的局部尺寸信息。这样可以实现“固定像素圆角半径”无论UI缩放多大圆角的物理像素大小保持不变视觉效果最统一。3.3 在Unity中的使用步骤创建Shader和材质将上述Shader代码需补全完整结构保存为.shader文件。在Project中右键创建MaterialShader选择UI/Rounded Corner。应用到UI元素在Unity场景中创建一个Image或Raw Image组件。将其Material属性拖拽为我们刚创建的圆角材质。调整参数在材质Inspector面板调整Corner Radius参数你就能立刻看到圆角效果。同时可以调整AA Smoothing来优化边缘锯齿感。常见问题一为什么我的圆角不对称或变形这几乎都是因为halfSize计算不准确导致的。请检查你的UI元素的Rect Transform是否非正方形以及UV是否正确。确保传递给sdRoundRect函数的b参数是(width/2, height/2)。使用脚本传递尺寸是最可靠的方法。常见问题二圆角边缘有锯齿或闪烁首先检查_AASmoothing参数适当调大。其次确保你的UI Canvas的Render Mode是Screen Space - Overlay或Camera并且Reference Pixels Per Unit设置合理。对于World Space模式的Canvas抗锯齿计算可能需要根据摄像机距离进行调整。最后检查是否开启了MSAA或多分辨率适配有时需要根据fwidth的计算进行微调。4. 高级应用与效果扩展一个基础的圆角Shader只是起点。基于距离场这个强大的工具我们可以轻松扩展出各种高级UI效果且性能开销极小。4.1 动态描边与发光效果有了到边界的距离distance实现描边易如反掌。描边可以看作是“在距离为0的边界两侧绘制一个特定宽度的颜色带”。// 在片段着色器中计算圆角Alpha之后 float borderAlpha 0; if (_BorderWidth 0) { // 计算到“外边界”的距离。将内部距离向外偏移_BorderWidth。 float borderDistance distance _BorderWidth; // 同样应用抗锯齿得到描边的Alpha。 float borderAA fwidth(borderDistance) * _AASmoothing; borderAlpha smoothstep(borderAA, -borderAA, -borderDistance); // 描边Alpha需要减去内部填充的Alpha避免叠加。 borderAlpha saturate(borderAlpha - alphaFactor); } // 最终颜色混合内部填充色 描边色 half4 finalColor color; half4 borderColor _BorderColor * borderAlpha; // 使用预乘Alpha混合或者简单的叠加 finalColor.rgb finalColor.rgb * finalColor.a borderColor.rgb * borderColor.a * (1 - finalColor.a); finalColor.a finalColor.a borderColor.a * (1 - finalColor.a);通过调整_BorderWidth你可以得到从细线到粗框的各种描边。更进一步你可以用smoothstep配合distance来制作内发光、外发光效果原理类似都是对距离值进行不同的映射和颜色混合。4.2 独立控制四个圆角半径设计师可能会要求“左上和右下是圆角右上和左下是直角”。我们需要扩展Shader属性接受四个不同的半径值。Properties { ... _RadiusTL (Radius Top-Left, Float) 10 _RadiusTR (Radius Top-Right, Float) 10 _RadiusBR (Radius Bottom-Right, Float) 10 _RadiusBL (Radius Bottom-Left, Float) 10 ... }在片段着色器中计算距离的函数会变得复杂。我们需要根据当前像素所在的象限选择对应的半径进行计算。一个常见的技巧是将坐标平移到第一象限即所有坐标取绝对值。根据原始的、未取绝对值的坐标符号来判断当前像素属于哪个角。使用该角对应的半径进行计算。 这需要重写sdRoundRect函数但核心距离场思想不变。网上有成熟的“四角独立半径圆角矩形”距离函数实现可供参考。4.3 与UI动画系统的结合这是Shader方案相比图片方案的最大优势之一所有参数都可以动态变化。你可以通过代码Material.SetFloat(“_Radius”, value)来实时改变圆角半径结合DOTween或UnityEngine.UI.CoroutineTween就能轻松实现圆角从0到某个值的平滑过渡动画用于制作非常现代的展开、收起、高亮反馈等效果。同样描边宽度、颜色、甚至是渐变色需要在Shader中增加渐变参数和计算都可以做动画。这为UI交互设计师提供了巨大的创意空间。4.4 性能优化与注意事项批处理与合批使用自定义Material会打断UI的合批。为了最小化性能影响应确保所有使用同一套圆角Shader参数的UI元素尽可能共享同一个材质实例而不是每个UI元素都有一份独立的材质拷贝。可以通过脚本动态创建并共享材质。Mask兼容性我们的Shader基于UI/Default修改通常能很好地与Mask和RectMask2D组件配合。但要测试在嵌套Mask下的表现。OverdrawShader本身不会增加Overdraw它只是在每个像素上做计算决定是否显示。性能开销主要在于每个像素多执行的几条指令。对于移动平台在低端设备上对大量全屏UI使用复杂Shader仍需谨慎。Fallback在Shader的SubShader块中记得添加Fallback指令例如Fallback “UI/Default”以确保在不支持的平台上能有基本的UI渲染。5. 常见问题排查与调试技巧即使按照指南操作你也可能会遇到一些棘手的问题。这里记录一些我踩过的坑和解决方法。问题一圆角在屏幕边缘或缩放时出现“切边”或闪烁。原因这通常是深度缓冲Z-fighting或Canvas渲染顺序问题。UI元素之间或与Canvas边界过于接近。解决检查UI元素的Rect Transform是否完全在Canvas边界内。稍微增加Canvas的Pixel Perfect设置或调整UI元素的Scale。确保使用相同的材质实例以减少渲染状态切换。对于World Space Canvas检查摄像机的近裁剪平面设置。问题二在滚动视图ScrollRect中圆角UI快速滚动时边缘出现撕裂或残留。原因可能与Canvas的渲染模式或UI元素的锚点有关。在Screen Space - Camera模式下如果UI移动过快抗锯齿计算依赖的屏幕空间导数 (fwidth) 可能采样不准。解决尝试将Canvas改为Screen Space - Overlay模式。如果必须用Camera模式可以尝试将_AASmoothing设为一个固定的较小值而不是完全依赖fwidth。确保UI元素的锚点设置正确避免在滚动时因锚点导致的意外拉伸。问题三圆角Shader在Unity编辑器里正常打包后尤其是移动端失效或变黑。原因Shader编译目标可能不支持某些指令或精度。移动端GLSL ES 2.0/3.0对函数和精度要求更严格。解决检查Shader中是否使用了ES 2.0不支持的函数如fwidth在ES 2.0中需要扩展GL_OES_standard_derivatives。在Shader开头添加#pragma require derivatives。明确指定精度。在片段着色器开头使用precision mediump float;。简化计算。将smoothstep替换为clamp的线性插值有时在低端设备上更稳定。在Player Settings的Graphics设置中检查Shader兼容性级别。问题四如何可视化调试距离场有时为了调整效果或排查问题需要看到距离场的分布。一个简单的调试方法是在片段着色器中将计算出的distance值直接映射为颜色输出。// 调试代码替换最终的颜色输出 float normalizedDist (distance 1.0) * 0.5; // 将大概[-1,1]的范围映射到[0,1] return fixed4(normalizedDist, normalizedDist, normalizedDist, 1.0);这样完全在形状内部的区域会是白色值接近1边界是灰色值接近0.5外部是黑色值接近0。通过观察这个灰度图你可以清晰地看到圆角的形状和抗锯齿过渡区域非常有助于理解Shader的行为和调试参数。从一张静态图片到一个可编程的、高性能的、动态的圆角效果这背后是图形学基础原理与引擎实践的结合。掌握这套方法后你收获的不仅仅是一个圆角UI更是一把打开自定义UI视觉效果大门的钥匙。距离场的概念可以延伸到制作波浪形边框、不规则形状裁剪、平滑过渡遮罩等无数效果。下次当设计师再提出一个“看似简单”的UI效果时你或许可以自信地问“这个效果你是想要静态切图还是我用Shader给你做一个可以动态调的”