ARTICLE DETAIL

资讯详情

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

Unity UI圆角组件开发:基于SDF着色器的高性能实现方案

Unity UI圆角组件开发:基于SDF着色器的高性能实现方案 1. 项目概述为什么Unity UI需要专门的圆角组件在Unity里做UI尤其是想做出点现代感圆角几乎是绕不开的设计元素。从手机App到游戏内的弹窗圆润的边角比直角看起来更友好、更精致。但Unity内置的UI系统无论是UGUI还是最新的UI Toolkit对圆角的原生支持都相当有限。你可能会说用带圆角的图片Sprite不就行了这确实是初期最直接的办法但问题马上就来了适配性差。一张512x512的圆角按钮图在1080p屏幕上看着挺圆润一到4K屏上要么模糊要么圆角边缘出现锯齿。更别提你需要不同尺寸的按钮时要么让美术切一堆图要么用九宫格拉伸但九宫格拉伸圆角很容易变形特别是非均匀缩放的时候。动态调整圆角半径想都别想。这就是为什么我们需要一个基于着色器Shader的、可编程的圆角组件。它不依赖纹理分辨率可以实时、动态地调整圆角大小、边框、甚至渐变一个材质搞定所有尺寸和屏幕这才是现代UI开发该有的效率。最近在社区里基于有符号距离场SDF的圆角着色器方案讨论热度很高因为它完美解决了上述问题。这个教程我就带你从零开始不依赖任何付费插件亲手打造一个高性能、高自由度的Unity UI圆角组件。我们会深入SDF着色器的原理并一步步实现一个功能完备的RoundedRect组件让你彻底掌握这套技术。2. 核心原理有符号距离场SDF是如何工作的要造轮子先懂原理。我们抛弃传统的“像素贴图”思维进入“数学定义形状”的世界。有符号距离场Signed Distance Field, SDF就是一个用来描述形状的神奇工具。2.1 什么是SDF你可以把SDF理解为一个“距离地图”。对于屏幕上的每一个像素点SDF函数会计算这个点到目标形状比如我们想要的圆角矩形最近边界的距离。这个距离是有符号的正值表示该点在形状外部。零表示该点刚好在形状的边界上。负值表示该点在形状内部。对于圆角矩形这个计算过程可以分解。想象一个直角矩形我们定义其四个角为需要被“切圆”的区域。SDF函数会先计算像素点到矩形四条直角边的距离再计算到四个“虚拟圆角”的圆心距离最后通过一个min-max的平滑取舍操作通常是smoothstep或max函数得到最终到圆角矩形边界的距离。注意这里说的“计算”是着色器中每个像素并行执行的数学运算而不是CPU端的几何计算因此效率极高。2.2 SDF实现圆角的优势无限分辨率形状由数学公式定义与纹理分辨率无关。无论你的UI放大到多少倍圆角边缘都始终是光滑的完美解决高清屏下的锯齿问题。动态可调圆角半径、边框粗细、甚至是每个角的半径如iOS风格左上右下不同半径都可以通过着色器属性MaterialProperty实时调整无需重新生成纹理。效果丰富基于距离信息我们可以轻松衍生出其他效果。比如距离值可以用来做边框在特定距离范围着色、内发光对内部距离做处理、甚至是平滑的渐变遮罩。性能优异现代GPU非常擅长进行这类简单的标量数学计算。一个精心优化的SDF片段着色器其性能开销远低于采样一张纹理并处理透明混合。理解了SDF我们就有了制造圆角组件的“图纸”。接下来我们需要在Unity的渲染框架下把这张图纸变成可用的零件。3. 组件整体设计与架构拆解我们的目标是创建一个名为RoundedRect的MonoBehaviour组件挂载到任何Image或RawImage对象上就能自动将其渲染为圆角矩形。整个架构分为三层1. 数据层C#脚本RoundedRect.cs职责提供编辑器面板让开发者可以方便地调整圆角半径、边框宽度、颜色等参数。核心继承MonoBehaviour使用Serializable字段定义属性如_radius_borderWidth。关键点在于它需要将C#中的属性值同步到材质球Material的对应着色器属性Shader Property上。2. 着色器层Unity ShaderRoundedRect.shader职责实现核心的SDF圆角矩形渲染算法。核心一个Surface Shader或Unlit Shader接收来自RoundedRect脚本传递的参数如_Radius在片段着色器frag函数中为每个像素计算SDF值并根据此值决定最终输出颜色透明、边框色、填充色。3. 渲染适配层职责确保我们的材质能正确集成到Unity UI的渲染流程中。核心UI元素默认使用CanvasRenderer。我们需要确保RoundedRect组件在Awake或OnEnable时能创建或找到一个使用了我们自定义Shader的Material并赋值给Image.material。同时在属性变化时OnValidate或Update需要将变化同步到Material。这个架构清晰地将用户界面Inspector配置、逻辑参数管理与渲染GPU计算分离是Unity组件开发的常见模式。下面我们进入最核心的着色器实现环节。4. 核心着色器代码实现与逐行解析这里我将采用一个简化但功能完整的Unlit Shader作为示例它兼容内置渲染管线也易于迁移到URP。我们重点看片段着色器中的SDF计算。Shader UI/RoundedRect { Properties { [PerRendererData] _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) // 我们的自定义属性 _Radius (圆角半径, Range(0, 500)) 32 _BorderWidth (边框宽度, Range(0, 100)) 4 _BorderColor (边框颜色, Color) (0,0,0,1) _InnerColor (内部颜色, Color) (1,1,1,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 } SubShader { Tags { QueueTransparent RenderTypeTransparent } Cull Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ UNITY_UI_ALPHACLIP #include UnityCG.cginc #include UnityUI.cginc struct appdata_t { float4 vertex : POSITION; float2 texcoord : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 texcoord : TEXCOORD0; float2 localPos : TEXCOORD1; // 关键传递本地坐标 float4 color : COLOR; }; sampler2D _MainTex; fixed4 _Color; float _Radius; float _BorderWidth; fixed4 _BorderColor; fixed4 _InnerColor; v2f vert(appdata_t v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.texcoord v.texcoord; o.color v.color * _Color; // 将顶点坐标从物体空间转换到本地中心为(0,0)范围约为(-0.5, 0.5)的坐标方便SDF计算 o.localPos v.vertex.xy * float2(1, _ScreenParams.y / _ScreenParams.x); // 简单示例更精确需考虑RectTransform尺寸 return o; } // 核心圆角矩形的SDF函数 float sdRoundedRect(float2 p, float2 b, float r) { // p: 当前像素点本地坐标 // b: 矩形半宽高 (即RectTransform的rect.width/2, rect.height/2) // r: 圆角半径 float2 q abs(p) - b r; return min(max(q.x, q.y), 0.0) length(max(q, 0.0)) - r; } fixed4 frag(v2f i) : SV_Target { // 获取RectTransform的实际尺寸这里需要从脚本传递为简化先使用固定值或纹理尺寸 float2 rectHalfSize float2(0.5, 0.5); // 假设本地坐标已归一化 // 计算当前像素到圆角矩形的有符号距离 float d sdRoundedRect(i.localPos, rectHalfSize, _Radius / 1000.0); // 半径需根据实际尺寸缩放 // 利用smoothstep实现抗锯齿的边缘平滑过渡 float borderAlpha smoothstep(-0.5, 0.5, d); // d0为内部d0为外部 float innerAlpha smoothstep(-0.5, 0.5, d _BorderWidth); // 向内偏移边框宽度 // 混合颜色 fixed4 col _InnerColor; col lerp(_BorderColor, col, innerAlpha); // 绘制边框 col.a * borderAlpha; // 应用外部透明度 // 与原始纹理和顶点颜色混合可选 fixed4 texCol tex2D(_MainTex, i.texcoord) * i.color; col.rgb lerp(col.rgb, texCol.rgb, texCol.a); col.a * texCol.a; #ifdef UNITY_UI_ALPHACLIP clip(col.a - 0.001); #endif return col; } ENDCG } } }代码关键点解析localPos的计算这是最容易出错的地方。顶点着色器中我们需要将像素点转换到一个以UI元素中心为原点、便于计算的坐标系。上述简化代码使用了顶点坐标但更准确的做法是从脚本传递RectTransform的实际rect.size到着色器然后在着色器中将localPos归一化或直接换算。否则矩形不是正方形时圆角会变形。sdRoundedRect函数这是SDF的经典实现之一。q abs(p) - b r这一步相当于把矩形向中心“收缩”了半径r的长度然后计算到收缩后矩形的距离最后再减去r。min(max(q.x, q.y), 0.0)处理内部区域length(max(q, 0.0))处理外部区域。抗锯齿处理直接根据d大于或小于0来硬切边缘会有锯齿。我们使用smoothstep函数在距离0附近的一个很小范围内如-0.5到0.5进行平滑插值这样边缘在像素级别会有透明度渐变视觉上非常光滑。边框实现计算两个距离值d和d _BorderWidth。d决定整体形状边界d _BorderWidth决定内部填充的边界。两者之间的区域就是边框。实操心得在编写SDF函数时建议先在Shadertoy这类在线工具上验证数学公式的正确性再移植到Unity中可以节省大量调试时间。5. C#组件脚本与材质动态管理有了Shader我们需要一个脚本来驱动它。下面是RoundedRect.cs的核心框架using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(Image))] [ExecuteAlways] // 在编辑模式下也执行方便预览 public class RoundedRect : MonoBehaviour { [SerializeField, Range(0, 500)] private float _radius 32f; [SerializeField, Range(0, 100)] private float _borderWidth 4f; [SerializeField] private Color _borderColor Color.black; [SerializeField] private Color _innerColor Color.white; private Material _material; private Image _image; private static readonly int RadiusProperty Shader.PropertyToID(_Radius); private static readonly int BorderWidthProperty Shader.PropertyToID(_BorderWidth); private static readonly int BorderColorProperty Shader.PropertyToID(_BorderColor); private static readonly int InnerColorProperty Shader.PropertyToID(_InnerColor); private static readonly int RectSizeProperty Shader.PropertyToID(_RectSize); public float Radius { get _radius; set { _radius value; UpdateMaterialProperties(); } } // ... 其他属性的getter/setter private void OnEnable() { _image GetComponentImage(); ValidateAndCreateMaterial(); UpdateMaterialProperties(); } private void OnDisable() { // 清理材质实例防止内存泄漏 if (_image ! null Application.isPlaying) { _image.material null; if (_material ! null) DestroyImmediate(_material); } } private void OnValidate() { // 在Inspector中修改值时立即更新 if (isActiveAndEnabled) { UpdateMaterialProperties(); } } private void ValidateAndCreateMaterial() { if (_image null) return; // 使用Shader.Find动态查找避免硬编码路径问题 Shader roundedRectShader Shader.Find(UI/RoundedRect); if (roundedRectShader null) { Debug.LogError(找不到‘UI/RoundedRect’着色器请确保着色器已正确放置并编译。); return; } // 如果已有材质但不是我们的Shader或者没有材质则创建新的材质实例 if (_image.material null || _image.material.shader ! roundedRectShader) { // 重要使用new Material(roundedRectShader)创建实例确保每个UI元素材质独立 _material new Material(roundedRectShader); _material.hideFlags HideFlags.HideAndDontSave; // 编辑模式下不保存到场景 _image.material _material; } else { _material _image.material; } } private void UpdateMaterialProperties() { if (_material null) return; // 将C#属性值传递给Shader _material.SetFloat(RadiusProperty, _radius); _material.SetFloat(BorderWidthProperty, _borderWidth); _material.SetColor(BorderColorProperty, _borderColor); _material.SetColor(InnerColorProperty, _innerColor); // 传递RectTransform的实际尺寸确保SDF计算正确关键 RectTransform rectTransform (RectTransform)transform; _material.SetVector(RectSizeProperty, new Vector4(rectTransform.rect.width, rectTransform.rect.height, 0, 0)); // 强制UI重绘 _image.SetMaterialDirty(); } // 在RectTransform尺寸变化时也更新可选通过LayoutGroup或代码改变大小时需要 private void OnRectTransformDimensionsChange() { if (isActiveAndEnabled) { UpdateMaterialProperties(); } } }脚本关键点解析材质实例管理这是重中之重。绝对不能直接将Shader赋值给Image.material这会导致场景中所有使用该Shader的UI共享同一个材质实例修改一个会影响到所有。必须使用new Material(shader)为每个RoundedRect组件创建独立的材质实例Material Instance。属性同步在OnValidate和属性的setter中调用UpdateMaterialProperties确保Inspector中修改参数能实时反馈到UI上。使用Shader.PropertyToID缓存属性ID比传递字符串更高效。尺寸传递UpdateMaterialProperties中传递了RectTransform的实际尺寸到着色器_RectSize。着色器中的sdRoundedRect函数需要根据这个真实尺寸来计算b半宽高参数否则圆角会因UI拉伸而变形。这是很多自制圆角组件忽略的关键一步。编辑模式支持添加[ExecuteAlways]特性并妥善处理OnDisable中的材质清理注意区分播放模式和编辑模式可以让组件在Scene视图和Prefab模式下也能正常预览极大提升开发体验。6. 高级功能扩展与性能优化基础功能实现后我们可以让它更强大、更实用。6.1 独立控制每个角的半径现代设计常需要类似iOS组件那样每个角半径不同。实现思路是修改SDF函数传入一个float4类型的参数分别代表左上、右上、右下、左下的半径。在计算距离时需要根据像素点所在的象限选择对应的半径值进行计算。这会使SDF函数稍复杂但原理相通。// 伪代码概念 float sdRoundedRectUneven(float2 p, float2 b, float4 r) { // r (rTopLeft, rTopRight, rBottomRight, rBottomLeft) // 1. 判断像素p位于哪个象限 // 2. 根据象限选择r中对应的一个半径值 // 3. 使用选定的半径进行SDF计算 // 4. 需要考虑两个不同半径的角相邻区域的平滑过渡这通常是最大的挑战 }6.2 添加内阴影与渐变填充SDF的距离信息d是个宝库。例如内阴影内部阴影可以理解为在形状内部根据到边界的距离即-d因为内部d为负来叠加一个渐变的暗色。可以在片段着色器中计算float shadow 1.0 - smoothstep(0.0, _ShadowSoftness, -d);然后将shadow乘以一个暗色系数叠加到最终颜色上。线性渐变或径向渐变同样可以利用localPos的坐标信息来轻松实现。6.3 性能优化要点批处理Batching破坏者Unity UI的合批Batch要求材质相同。我们的每个RoundedRect组件都创建了独立的材质实例这会打断合批。对于大量静态圆角UI可以考虑材质属性块MaterialPropertyBlock。但注意UI系统对MaterialPropertyBlock的支持有限主要通过Graphic的SetMaterialBlock且可能会影响Masking等功能的兼容性需要充分测试。Shader优化避免在片段着色器中使用分支if-else尽量用step、smoothstep、lerp等内置函数代替。复杂的SDF计算如独立四角半径会比标准圆角更耗性能酌情使用。确保所有计算都在一个Pass内完成。动静分离对于运行时从不改变的UI元素如背景板可以在初始化时配置好材质参数后将组件禁用enabled false避免每帧调用UpdateMaterialProperties。但需确保OnRectTransformDimensionsChange不会被触发。7. 常见问题、排查技巧与实战心得在实际项目中使用自研UI组件总会遇到一些坑。这里记录几个典型问题和我的解决方案。7.1 圆角在屏幕空间变形不是正圆问题描述明明设置了圆角半径Radius为30但显示出来的圆角看起来像椭圆或者在屏幕拉伸时变形。根本原因SDF计算中使用的像素坐标localPos没有考虑UI元素本身的宽高比Aspect Ratio。我们的sdRoundedRect函数默认假设计算空间是正方形即b.x和b.y的缩放一致。解决方案在将localPos传递给SDF函数前或是在SDF函数内部需要进行宽高比校正。方法一推荐在C#脚本中将RectTransform的尺寸rect.size传递给着色器。在着色器顶点阶段将localPos除以_RectSize.xy将其归一化到一个基于自身尺寸的比例空间。这样无论UI是长方形还是正方形SDF计算都在一个“逻辑正方形”中进行圆角就是正圆。方法二在SDF函数内部传入尺寸比例。修改函数为sdRoundedRect(float2 p, float2 b, float r, float2 aspectRatio)在计算距离前先将p的各个分量乘以或除以aspectRatio。7.2 圆角边缘出现锯齿或闪烁问题描述在特定分辨率或摄像机角度下圆角边缘能看到明显的锯齿或者边缘像素在闪烁。原因与解决抗锯齿AA不足我们虽然用了smoothstep但过渡范围如-0.5, 0.5是固定的像素值。当UI被极大缩放时这个范围可能不够。可以将过渡范围与一个基于屏幕空间导数fwidth(d)的动态值挂钩实现更精确的抗锯齿。float aaf fwidth(d); // 估算距离场在屏幕空间的变化率 float borderAlpha smoothstep(-aaf, aaf, d);深度测试/Z-Fighting如果多个半透明UI层叠且深度值非常接近可能会产生闪烁。确保UI的Canvas设置正确检查Z值。对于UI通常ZWrite是关闭的依赖排序Sort Order。精度问题在移动设备上如果使用float精度可能不足尤其是在计算length等函数时。可以尝试使用mediump精度但需测试效果。7.3 与Unity UI Mask、RectMask2D等组件冲突问题描述给一个带了RoundedRect组件的Image加上Mask或RectMask2D圆角效果消失了或者Mask区域外本应裁剪掉的部分仍然显示。根本原因Unity UI的Masking系统依赖于Stencil Buffer模板缓冲。我们自定义的Shader默认的Stencil操作可能与Mask系统不兼容。解决方案我们需要让Shader支持模板测试。观察Unity内置的UI/Default Shader会发现它有一系列Stencil开头的Properties和相应的CG指令。最简单可靠的方法是将我们Shader中Properties块和Pass开头关于Stencil的部分完全复制自UI/DefaultShader见前面示例代码中已包含的部分。确保我们的Shader的渲染队列QueueTransparent和混合模式Blend SrcAlpha OneMinusSrcAlpha与标准UI Shader一致。这样我们的自定义Shader就能无缝接入Unity UI的裁剪体系了。7.4 在滚动视图ScrollRect中性能下降问题描述在包含大量RoundedRect元素的ScrollRect中滚动时感觉卡顿。排查与优化检查Draw Call使用Frame Debugger工具查看滚动时是否产生了大量额外的Draw Call。每个独立的材质实例都会导致合批失败。如果元素众多且静态考虑是否真的需要每个都有独立可动态调整的圆角参数或许可以共用少数几个预设材质的变体。Canvas重绘Unity UI的Canvas在检测到其中任何元素需要重绘如位置、顶点数据变化时会标记整个Canvas为脏并重新生成网格Rebuild。ScrollRect滚动时内容的位置在变化会触发频繁的重建。确保将频繁变化的滚动内容放在一个独立的Canvas或Sub-Canvas中与静态UI分离。禁用不可见项对于超长列表实现一个简单的视锥裁剪Frustum Culling或基于ScrollRect位置的激活/禁用逻辑只渲染可视范围内的项。7.5 编辑器下修改参数无实时反馈问题描述在Inspector中调整Radius等参数Scene视图中的UI没有立即更新。解决确保脚本添加了[ExecuteAlways]特性。确保在OnValidate方法中调用了属性更新方法UpdateMaterialProperties。确保Image组件的Raycast Target选项如果不需要可以关闭但这通常不影响显示。有时需要手动点击一下Scene视图或Game视图来触发刷新。更可靠的做法是在UpdateMaterialProperties中不仅设置材质属性还调用Graphic.SetVerticesDirty()来强制重绘。最后分享一个我个人的实战心得在项目中期引入这样一个自定义UI组件一定要做好回归测试。用这个组件替换掉项目中所有旧的圆角图片后要全面检查各种场景不同分辨率适配、叠加在多种背景上、与动画系统结合、在WebGL等目标平台的显示效果等。曾经我就遇到过在某个安卓设备上因为GPU精度差异圆角边缘出现了一条极细的白线最后是通过稍微加大smoothstep的过渡范围解决的。自研组件给了我们最大的灵活性但也意味着需要承担起跨平台、跨设备稳定性的全部责任。
返回列表