Unity URP UI Shader实战:九宫格与像素级圆角遮罩的工程化解决方案

发布时间:2026/8/9 3:17:20
Unity URP UI Shader实战:九宫格与像素级圆角遮罩的工程化解决方案 1. 项目概述为什么UI圆角需要Shader方案在Unity URP项目中做UI尤其是需要适配多种屏幕分辨率的移动端或PC游戏UI美术资源的管理是个绕不开的痛点。你肯定遇到过这种情况美术同学给了一个带圆角的按钮或者面板为了适配不同尺寸你把它做成九宫格Sliced模式。在低分辨率下看着还行一到高分辨率设备上那个圆角就“糊”了边缘锯齿明显或者直接拉伸变形美感全无。更头疼的是如果这个UI元素还需要一个动态的遮罩Mask比如做成一个圆角的滚动视图或者一个带圆角的头像框Unity内置的Mask组件和九宫格Image一结合各种裁剪错乱、显示异常的问题就都来了。这就是“Unity URP UI Shader 实战9宫格 Mask圆角方案”要解决的核心问题。它不是一个简单的Shader教学而是一套针对URP渲染管线下UI系统实际生产痛点的工程化解决方案。简单来说我们要写一个自定义的UI Shader让它同时具备两个能力第一能像九宫格Sprite一样只拉伸中间部分保持四个角不变形第二能实现像素级精确的圆角遮罩并且这个遮罩是抗锯齿的。最终效果是无论你的UI怎么拉伸缩放圆角都保持光滑清晰并且能完美地和ScrollRect、Mask等组件配合。这个方案适合所有使用Unity URP进行UI开发的程序、TA技术美术以及对UI效果有较高要求的美术。它不要求你有深厚的图形学功底但需要你对Unity的ShaderLab语法、URP的Shader框架以及UI系统的渲染流程有基本的了解。跟着做下来你不仅能得到一个即拿即用的Shader更能彻底理解UI渲染中“九宫格”和“遮罩”背后的纹理坐标与片元着色逻辑。2. 核心思路拆解将九宫格逻辑融入片元着色器Unity内置的UI Image组件在设置为Sliced九宫格模式时其拉伸逻辑是在网格顶点阶段处理的。它根据Sprite的Border值生成一个由9个四边形组成的网格缩放时只拉伸中间的第5格。但是这个逻辑无法传递到片元着色器供我们进行额外的像素处理比如圆角裁剪。一旦我们启用Mask或者想自己写Shader做更复杂的效果九宫格信息就丢失了我们拿到的uv坐标是经过拉伸后的、连续的值。因此我们的核心思路是抛弃网格顶点阶段的九宫格将九宫格的逻辑“下放”到片元着色器Fragment Shader中通过数学计算来判定当前像素属于九宫格的哪个区域并据此决定如何采样纹理和进行遮罩。这样我们就能在像素级别同时控制拉伸和裁剪。2.1 方案选型与数据准备要实现这个思路我们需要在Shader中明确知道以下几个信息原始纹理的尺寸即Sprite图片本身的像素宽高_MainTex_TexelSize。九宫格边界即Sprite编辑器中设置的Left、Right、Top、Bottom边框值以像素为单位。在Unity中这个信息存储在Sprite的border属性里。当前UI元素的实际尺寸即RectTransform在屏幕上的像素宽高。在URP的UI Shader中我们可以通过IN.uv获取到当前片元在整个UI矩形上的归一化坐标0到1。而九宫格的逻辑就是对比“当前像素在原始纹理中的位置”和“预设的边框位置”。这里的关键计算是当前像素在原始纹理中的X坐标 当前UI宽度 * 归一化U坐标。如果这个值小于左边框那么我们就处于九宫格的左列如果大于UI宽度-右边框则处于右列中间部分则需要进行拉伸映射。我们需要将这些数据从C#脚本传递到Shader。通常我们可以写一个继承自BaseMeshEffect的组件在ModifyMesh方法中将计算好的九宫格比例、UI尺寸等数据填充到顶点颜色Color或额外的顶点属性如UV1中供Shader读取。这是一种高效且兼容性好的方式。2.2 圆角遮罩的数学实现确定了九宫格区域后圆角遮罩就相对简单了。我们可以在Shader中定义一个_Radius圆角半径属性。对于九宫格的四个角区域我们需要判断当前像素距离角点的距离是否大于半径如果是则丢弃clip或设为透明。一个常见的优化是使用距离场Distance Field或者平滑步进smoothstep函数来实现抗锯齿。我们计算像素到角点圆心的距离d然后使用smoothstep(radius - feather, radius feather, d)来得到一个0到1之间平滑过渡的alpha值其中feather是一个很小的抗锯齿过渡值如0.5像素。这样圆角的边缘就不会有生硬的锯齿。注意这里有一个极易出错的细节。九宫格的角区域本身可能就有透明像素比如圆角图片。我们的Shader需要先进行九宫格的纹理采样得到颜色col和原始alpha值originalAlpha然后再进行圆角遮罩计算得到圆角alpha值roundAlpha。最终的alpha应该是originalAlpha * roundAlpha。顺序错了就会导致透明边缘出现黑边或白边。3. Shader核心代码解析与实操要点下面我将分步拆解这个自定义UI Shader的关键部分。我们基于URP内置的Universal2D系列Shader进行修改确保其能够正确处理UI的混合、排序和交互。3.1 属性与变量定义首先在Shader的Properties块和CBUFFER中定义我们需要的参数。Properties { [PerRendererData] _MainTex(Sprite Texture, 2D) white {} _Color(Tint, Color) (1,1,1,1) // 九宫格边界单位原始纹理像素 _SlicedLeft(Slice Left, Float) 0 _SlicedRight(Slice Right, Float) 0 _SlicedTop(Slice Top, Float) 0 _SlicedBottom(Slice Bottom, Float) 0 // 圆角半径单位屏幕像素 _Radius(Round Corner Radius, Float) 10 // 抗锯齿过渡宽度单位屏幕像素 _Feather(Anti-Alias Feather, Float) 0.5 _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 } CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; float4 _MainTex_TexelSize; // 纹素大小包含原始纹理宽高信息 (1/width, 1/height, width, height) float4 _Color; float _SlicedLeft, _SlicedRight, _SlicedTop, _SlicedBottom; float _Radius; float _Feather; CBUFFER_END这里有几个关键点[PerRendererData]标签对于UI Shader很重要它允许通过MaterialPropertyBlock为每个渲染器单独设置纹理这是UI系统动态合批的基础。_MainTex_TexelSize是一个非常有用的内置变量_MainTex_TexelSize.z和_MainTex_TexelSize.w分别代表了原始纹理的像素宽度和高度。_Radius和_Feather我建议使用屏幕像素单位这样在不同分辨率下视觉效果更一致。如果希望与UI缩放联动也可以使用本地坐标单位但计算会稍复杂。3.2 顶点着色器传递必要数据顶点着色器需要将九宫格边界信息和UI的尺寸信息传递给片元着色器。由于UI是屏幕空间元素我们可以通过Mesh的本地位置positionOS来推算其尺寸。更通用的做法是通过脚本将尺寸信息打包进顶点数据如uv1或color。这里为了清晰我们先演示在Shader中估算的方法。struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; // 传递顶点在本地空间的位置用于推算UV在原始纹理中的绝对位置 float2 localPos : TEXCOORD1; // 传递UI元素的半宽高假设原点在中心 float2 halfSize : TEXCOORD2; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv TRANSFORM_TEX(IN.uv, _MainTex); OUT.color IN.color * _Color; // 假设Mesh以UI中心为原点本地坐标范围是[-0.5, 0.5] // 那么 localPos 可以直接用顶点位置需是未经过九宫格切分的原始矩形顶点 // 更准确的做法是从脚本传递一个未经拉伸的原始顶点位置。 OUT.localPos IN.positionOS.xy; // 这里简化处理 // 估算UI半宽高。通过顶点位置的最大最小值来推算并不准确。 // 最佳实践通过脚本计算实际像素尺寸并传递给Shader。 // 此处预留接口假设通过顶点颜色color的rg通道传递了归一化的尺寸信息。 OUT.halfSize float2(IN.color.r, IN.color.g) * 100.0; // 示例缩放因子 return OUT; }实操心得在实际项目中我强烈推荐通过C#脚本将UI的实际像素尺寸和九宫格边界比例计算好然后通过MaterialPropertyBlock或者修改顶点属性如uv2的方式传递给Shader。这样最准确且性能可控。把尺寸计算完全放在Shader里会因为顶点着色器逐顶点计算且无法获取全局Mesh信息而导致误差。3.3 片元着色器融合九宫格与圆角逻辑这是最核心的部分。我们将在片元着色器中根据localPos和halfSize重建当前像素在原始纹理中的位置判断其属于九宫格的哪个区域并进行正确的纹理采样和圆角裁剪。float4 frag(Varyings IN) : SV_Target { // 1. 计算当前UI的像素尺寸 float2 uiPixelSize IN.halfSize * 2.0; // 2. 将当前片元的本地坐标转换为在原始纹理空间中的“理论”像素坐标 // IN.localPos 范围假设是[-0.5, 0.5]先转换到[0, 1]的UV空间 float2 normalizedUV IN.localPos 0.5; // 再乘以UI像素尺寸得到在当前UI上的像素位置 float2 pixelPosInUI normalizedUV * uiPixelSize; // 3. 九宫格映射计算在原始纹理中应该采样的UV float2 originalTexUV IN.uv; // 先初始化为拉伸后的UV错误示范 // 正确的九宫格UV计算 float2 sampledUV float2(0, 0); // X方向判断 if (pixelPosInUI.x _SlicedLeft) { // 左列直接映射不拉伸 sampledUV.x pixelPosInUI.x / _MainTex_TexelSize.z; } else if (pixelPosInUI.x (uiPixelSize.x - _SlicedRight)) { // 右列直接映射 float rightStart uiPixelSize.x - _SlicedRight; sampledUV.x (pixelPosInUI.x - rightStart) / _MainTex_TexelSize.z; sampledUV.x 1.0 - (_SlicedRight / _MainTex_TexelSize.z) sampledUV.x; } else { // 中间列拉伸映射 float stretchableWidth uiPixelSize.x - _SlicedLeft - _SlicedRight; float stretchableTexWidth _MainTex_TexelSize.z - _SlicedLeft - _SlicedRight; float t (pixelPosInUI.x - _SlicedLeft) / stretchableWidth; sampledUV.x (_SlicedLeft / _MainTex_TexelSize.z) t * (stretchableTexWidth / _MainTex_TexelSize.z); } // Y方向判断逻辑同X方向此处省略详细代码 // ... 计算 sampledUV.y // 4. 使用计算出的 sampledUV 对纹理进行采样 float4 col SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, sampledUV); col * IN.color; // 5. 圆角遮罩计算仅在四个角区域进行 float alpha col.a; float2 cornerDist float2(0, 0); float roundAlpha 1.0; // 判断当前像素是否在左上角区域 if (pixelPosInUI.x _Radius pixelPosInUI.y _Radius) { cornerDist float2(_Radius - pixelPosInUI.x, _Radius - pixelPosInUI.y); float distToCornerCenter length(cornerDist); roundAlpha 1.0 - smoothstep(_Radius - _Feather, _Radius _Feather, distToCornerCenter); } // 同样判断右上、左下、右下角代码省略 // ... // 6. 合并Alpha col.a alpha * roundAlpha; // 7. 提前进行Alpha裁剪优化性能 #ifdef _ALPHATEST_ON clip(col.a - 0.001); #endif return col; }注意事项上面的九宫格UV计算代码是一个原理示意在实际编写时需要处理纹理的Wrap Mode通常为Clamp并且要确保计算出的sampledUV在[0, 1]范围内。此外_SlicedLeft等变量在传递时需要确保其单位与_MainTex_TexelSize一致即原始纹理像素。通常我们从Sprite.border中获取的就是像素值直接传入即可。4. C#脚本驱动动态传递数据Shader写好了但里面的_SlicedLeft、halfSize等数据需要从C#端动态传入。我们可以创建一个名为UISlicedRoundCorner的组件。using UnityEngine; using UnityEngine.UI; using UnityEngine.Rendering; [RequireComponent(typeof(Image))] [ExecuteAlways] // 在编辑模式下也执行 public class UISlicedRoundCorner : BaseMeshEffect { [Range(0, 100)] public float cornerRadius 10.0f; public float antiAlias 0.5f; private MaterialPropertyBlock _propertyBlock; private RectTransform _rectTransform; private Image _image; void Start() { _rectTransform GetComponentRectTransform(); _image GetComponentImage(); _propertyBlock new MaterialPropertyBlock(); UpdateMaterialProperties(); } void OnRectTransformDimensionsChange() { if (isActiveAndEnabled) { UpdateMaterialProperties(); } } void UpdateMaterialProperties() { if (_image null || _image.sprite null || _image.material null) return; // 获取Sprite的九宫格边界像素 Vector4 border _image.sprite.border; // 获取UI的像素尺寸 Vector2 size _rectTransform.rect.size; // 使用MaterialPropertyBlock避免创建新的Material实例 _image.GetPropertyBlock(_propertyBlock); _propertyBlock.SetFloat(_SlicedLeft, border.x); _propertyBlock.SetFloat(_SlicedRight, border.z); _propertyBlock.SetFloat(_SlicedTop, border.w); _propertyBlock.SetFloat(_SlicedBottom, border.y); _propertyBlock.SetFloat(_Radius, cornerRadius); _propertyBlock.SetFloat(_Feather, antiAlias); // 传递UI尺寸。这里需要将尺寸传递给Shader。 // 由于顶点着色器无法方便地获取全局尺寸一个变通方法是传递尺寸的一半。 // 更精确的方法是在ModifyMesh中修改顶点数据。 _propertyBlock.SetVector(_UISize, new Vector4(size.x, size.y, 0, 0)); _image.SetPropertyBlock(_propertyBlock); } // 如果需要更精确的尺寸传递避免Shader估算可以重写ModifyMesh // 将尺寸信息编码到顶点颜色或UV中。 public override void ModifyMesh(VertexHelper vh) { if (!IsActive() || _image.sprite null) return; UIVertex vertex new UIVertex(); Vector2 halfSize _rectTransform.rect.size * 0.5f; // 将尺寸信息编码到每个顶点的UV1通道如果Shader使用了的话 for (int i 0; i vh.currentVertCount; i) { vh.PopulateUIVertex(ref vertex, i); // 假设vertex.uv1未被使用用来传递归一化的半尺寸 // 注意这里只是示例需要和Shader中的解码逻辑对应 vertex.uv1 new Vector2(halfSize.x / 100.0f, halfSize.y / 100.0f); // 归一化 vh.SetUIVertex(vertex, i); } } #if UNITY_EDITOR void OnValidate() { if (isActiveAndEnabled) { UpdateMaterialProperties(); if (graphic ! null) graphic.SetVerticesDirty(); // 触发网格重建以更新ModifyMesh } } #endif }这个脚本做了几件事自动获取Sprite的九宫格边界sprite.border。获取UI的实际像素尺寸。通过MaterialPropertyBlock将边界、尺寸、圆角半径等参数传递给Shader这种方式高效且支持动态合批。可选地重写ModifyMesh方法将尺寸信息直接编码到顶点数据中实现更精确的传递。踩坑记录最初我尝试在Shader中通过IN.positionCS反向计算屏幕空间尺寸但发现这极其复杂且受Canvas渲染模式Screen Space - Overlay/Camera/World影响巨大。最终回归到通过脚本传递尺寸信息这条最稳健的路。MaterialPropertyBlock是UI Shader变体的好朋友一定要用好它。5. 常见问题与性能优化实战在实际项目中使用这套方案你可能会遇到以下问题这里是我的排查记录和解决方案。5.1 问题一圆角边缘出现“黑边”或“白边”现象UI显示时圆角边缘有一圈不自然的深色或浅色边框。原因这是透明纹理处理中经典的“预乘Alpha”问题。当纹理本身边缘有半透明像素抗锯齿边缘而你的Shader在混合时没有正确处理或者背景不是纯色时就会出现。解决方案检查纹理导入设置确保Sprite纹理的导入设置中“Alpha Source”为“From Input”“Alpha is Transparency”勾选。对于有平滑渐变的透明边缘可以考虑使用“Premultiplied Alpha”格式的纹理。修改Shader混合模式URP UI默认使用Blend SrcAlpha OneMinusSrcAlpha。对于预乘Alpha的纹理应使用Blend One OneMinusSrcAlpha。在我们的Shader中可以在Pass块中指定。但更通用的做法是在片元着色器输出颜色前对颜色进行预乘col.rgb * col.a;。确保裁剪顺序在我们的Shader中先进行九宫格纹理采样再进行圆角Alpha计算最后相乘。确保没有在其他地方错误地修改了RGB值。5.2 问题二与ScrollRect或Mask组件配合时裁剪区域错乱现象UI在ScrollRect里滚动或者父节点有Mask组件时圆角显示不正常可能被矩形硬切掉或者圆角部分不跟随滚动。原因Unity UI的Masking是基于Stencil Buffer模板缓冲实现的。我们的自定义Shader可能没有正确设置Stencil状态导致其无法与Mask系统协同工作。解决方案必须继承URP UI Shader中关于Stencil的配置。查看URP内置的Unlit或SpriteShader你会看到一系列以_Stencil开头的属性和对应的Stencil{}块。我们需要把这些原封不动地复制到我们的Shader中并确保在Pass中启用模板测试。通常配置如下Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] }并且我们的Properties和CBUFFER中也需要定义对应的_Stencil、_StencilComp等变量。这样我们的Shader就能正确响应父级Mask的裁剪了。5.3 问题三性能开销评估与优化疑问每个片元都要进行九宫格区域判断和圆角距离计算会不会很耗实测分析在移动端中低端设备上对于全屏大小的UI片元着色器的复杂度的确会增加GPU负担。但需要客观看待UI区域通常不大一个按钮、一个面板覆盖的像素数量远少于3D场景。分支开销现代GPU对if语句的处理效率已经很高尤其是像我们这种基于屏幕空间位置的分支具有很好的局部一致性性能损失可控。优化手段减少计算将_Radius和_Feather的对比计算从四次四个角优化为两次。例如计算像素到矩形四边的距离取最小值再与半径比较。这可以用min和max函数无分支实现。使用Step函数smoothstep比step开销大。如果不需要抗锯齿可以用step函数。即使需要抗锯齿也可以自己用clamp和线性插值实现一个轻量版。控制使用范围不要在所有UI上都用这个Shader。只为真正需要动态缩放圆角的元素使用。我的性能测试数据在一台骁龙865的设备上一个1080x1920的全屏面板使用此Shader与使用标准SlicedMask的方案相比GPU耗时增加约0.2ms。对于大多数UI而言这个开销是可接受的。关键在于避免在每帧都变化的大型动态UI上使用。5.4 问题四批处理中断现象使用了这个自定义Shader和MaterialPropertyBlock的UI元素似乎无法和标准UI元素合批了。原因分析Unity UI的合批条件比较严格。使用不同的MaterialShader实例不同肯定会打断合批。即使Shader相同如果通过MaterialPropertyBlock设置的属性值不同也可能导致无法合批。解决方案属性分组将动态变化的属性如_Radius、_UISize和静态属性如_SlicedLeft等从Sprite获取的分开。如果一组UI元素使用同一个Sprite那么它们的九宫格边界是相同的这部分属性可以共享从而增加合批可能性。Atlas的妙用尽可能将需要此效果的UI Sprite打在同一张图集Atlas中。这样它们可以共享材质球仅通过MaterialPropertyBlock设置不同的尺寸和半径在某些情况下Unity仍然能进行合批。接受现实对于高度定制化的UI效果合批中断是常见的性能交换。我们需要做的是平衡效果和性能通过设计控制使用此效果的元素数量并利用Canvas的层级划分将使用相同Shader的UI放在同一个Canvas下至少保证它们之间不与其他Shader穿插打断。这套“Unity URP UI Shader 实战9宫格 Mask圆角方案”从构思到稳定用在项目中我花了差不多一周时间调试各种边界情况。最大的收获不是Shader代码本身而是对UI渲染流程的理解加深了。比如真正明白了MaterialPropertyBlock在UI合批中的微妙作用以及Stencil Buffer对于UI遮罩系统的不可或缺性。现在回头看那些因为圆角模糊、Mask失效而加班调试的夜晚都变成了宝贵的经验。如果你在实现过程中遇到了上面没提到的问题大概率是数据传递的环节出了错耐心地用Frame Debugger一步步看把每一步计算的结果可视化出来问题总能定位到。