URP UI Shader实战:用SDF实现高性能圆角圆环进度条(附完整代码)
做 Unity 项目做多了你会发现圆角圆环 UI 进度条看着不起眼落到 URP 里却很容易变成一块硬骨头。我曾经在技能冷却圆环上被“Image Mask”方案折磨过一版同一个界面十几个冷却进度条DrawCall 直接失控美术改一版描边我就要跟着调一版贴图。后来把所有进度条全部改成 URP Shader 实现一个 Quad、一个材质、几个参数血条、能量条、冷却转圈、分段刻度全部覆盖移动端稳定跑过了两个项目。这篇文章把 SDF 形状原理、URP 兼容写法、完整 Shader 代码、参数差异化组合和实际性能数据一次讲透。适合正在用 URP 做 UI 的客户端开发和技术美术也适合被 Image.Filled “能用但不好扩展”困住的 UI 程序。1. 为什么最终放弃 ImageMaskFilled、遮罩和贴图的真实成本1.1 Image.Filled 撑不起“圆角 描边 渐变”Unity 自带的 Image 组件有四种填充模式矩形进度条一般用 Filled圆形进度条常用 Radial 360。看起来够用可一旦需求复杂问题立刻冒出来。先说矩形进度条。Filled 模式只能对贴图做一个线性填充它不知道什么叫做圆角。要圆角得上美术出圆角贴图要描边又得再来一套要是策划提一句“右边加渐变过渡”贴图方案基本就废了。以前我在项目里遇到的需求是血量条外圈白描边、内部从红渐变到黄、低血量时还要闪烁。用 Image 做的结果是叠了三张图状态切换靠代码切 Sprite维护起来非常痛苦。圆环进度条更棘手。Image 的 Radial 360 确实能画扇形可技能冷却通常要求的是“环形”也就是中间挖空的一圈。于是美术得把贴图中心挖掉边缘处理不好就会发毛。而且 Radial 360 的旋转和起止角度调度很死板当你同时需要“灰色底环 彩色前景弧 前端高光 边缘描边”时单张贴图和单个 Image 根本组合不出来只能一层层叠叠完就是一批 DrawCall 和一堆纹理内存。1.2 Mask 组件是 DrawCall 的隐形杀手很多人解决“如何只显示圆环区域”时第一反应是加 Mask。这个方案能出效果但代价很大。Mask 组件会让它所在区域变成一个独立的绘制单元。以最简单的圆形冷却圆环为例背景圆环需要一张 Image前景扫过弧需要一张 ImageMask 本身还会产生模板写入这已经是三份绘制工作。十个冷却进度条同时转光进度条相关的 DrawCall 就能到 30 以上再加上普通 UI 文本和图标画面崩是迟早的事。更麻烦的是合批被打断。Mask 要求该区域内的内容做模板测试它周围原本能合批的 UI 元素会被迫拆开。有一次我在性能面板里看到一个技能图标本身没几面可因为它旁边放了两个 Mask 冷却环整个 Canvas 的批次被切得稀碎。排查到最后发现罪魁祸首就是 Mask——它把本来能一起提交的十几个 UI 元素全拆了。过度绘制也要提一句。Mask 的裁剪区域是矩形圆形进度条通常只占这个矩形的三分之一。剩余区域虽然最后不会出现在屏幕上但模板写入和像素着色依然跑了一遍。进度条一多Overdraw 数据很难看。1.3 贴图定义形状天然输在分辨率和内存上不管圆角矩形还是圆环用贴图定义形状都要面对分辨率问题。为了适配不同屏幕环形贴图经常做到 1024 甚至 2048。贴图一小放大就糊贴图一大内存和带宽白白吃掉。一个项目少说几十个 UI 样式每个样式配几张状态贴图合集下来内存很可观。SDF 方案则完全没有这个负担。所有形状来自数学公式不需要采样任何纹理边缘抗锯齿由距离场连续值计算出来任意分辨率下都是干净的 1 像素过渡。这就是我最终决定把进度条全面 Shader 化的直接原因。2. URP 下 UI Shader 的骨架Stencil、CBUFFER 与兼容性2.1 从内置管线迁移到 URP 的三个必要改动如果你用 Unity 2022 LTS新建项目时直接选 Universal 3D 模板就能得到 URP 环境老项目在 Graphics 设置里切换 URP Asset 也行。但写着色器时要注意三个变化第一推荐用 HLSLPROGRAM 而不是 CGPROGRAM。URP 大量使用 SRP 库CG 写法虽然在某些版本能跑但会带出“内置管线 Shader”警告换平台后还容易出现兼容性坑。第二必须引入 URP 的 Core.hlsl。对于 UI Shader核心库路径固定是#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl有了这一行UnityObjectToClipPos、_Time、fwidth 这些基础能力和内置变量才可用。第三顶点变换直接用 UnityObjectToClipPos 就够了。UI 的 Canvas Mesh 是引擎准备好的不需要像 3D 物体那样调 TransformObjectToHClip。很多从 Built-in 迁移过来的人在这里绕弯路其实 UI Shader 的顶点处理就是一句转换加上 uv 和顶点色透传。2.2 拷贝 UI/Default 的 Stencil 块而不是自己发明这是我在实际项目里踩过最深的坑之一。自定义 UI Shader 如果 Stencil 块和内置 UI/Default 不一致Canvas 里的 Mask 嵌套会出各种灵异表现进度条放不进 Mask 区域、放进去了把后面的 UI 盖住、或者干脆什么都不显示。最稳的做法是把内置 UI/Default 的 Stencil 段原样抄过来Stencil { Ref 128 Comp Always Pass Replace }可能有朋友问为什么是 128这是 Unity UI 系统默认使用的模板基准值。如果你用了 TMP 或其他自定义 UI 组件它们通常也遵守这套模板约定。所以不要自创直接沿用是兼容性最好的选择。2.3 顶点色让 CanvasGroup 和颜色继承生效UI 系统会把 Graphic.color 和 CanvasGroup.alpha 编码进顶点色。自定义 UI Shader 必须在片元里乘上这组插值后的颜色否则 CanvasGroup 整组淡入淡出、Image.color 变色这些基础操作全部失效。这里有个容易重复乘颜色的坑。有些同学在顶点里乘一遍在片元里又乘一遍结果 UI 颜色越来越暗。正确做法是顶点着色器只透传片元着色器在最后统一乘一次 input.color。2.4 SRP Batcher 与 CBUFFERUI 合批不是同一回事URP 开启 SRP Batcher 后材质属性必须放进 CBUFFER_START(UnityPerMaterial) 块里否则编辑器会报“Material property is found in another cbuffer”之类的错误实际效果是 SRP Batcher 失效。但要注意一个概念UI 的合批主要由 Canvas 的批次逻辑负责和 3D 场景里 SRP Batcher 合批机制不是同一个东西。把材质属性放进 CBUFFER 是一种好习惯能让 Shader 在更多场景下保持兼容但它不直接决定 UI DrawCall。理解这一点后面踩坑时才不会找错方向。3. 圆角矩形和圆环的形状数学SDF 距离场的核心公式3.1 圆角矩形 SDF一句公式加一个 smoothstepSDF 是 Signed Distance Field 的缩写意思是“有符号距离场”。它描述的是采样点到形状边缘的距离距离为负表示在形状内部为正表示在外部。用这个连续距离值去控制透明度边缘就能产生一个像素左右的渐变过渡天然抗锯齿。圆角矩形的 SDF 公式很经典float sdRoundRect(float2 p, float2 halfSize, float radius) { float2 q abs(p) - halfSize radius; return length(max(q, 0.0)) - radius; }这里的 p 是以矩形中心为原点的坐标halfSize 是矩形的半宽高radius 是圆角半径。需要注意的是halfSize 指的是“去掉圆角之后的核心区域”的半尺寸比如整体半宽是 0.5、圆角是 0.1那 halfSize 就是 0.4。把距离转成透明度用 fwidth 做自适应抗锯齿float aa fwidth(d); float alpha 1.0 - smoothstep(0.0, aa, d);fwidth 会返回当前像素附近的变化率用来控制过渡宽度保证不同分辨率下边缘都只有约 1 像素的渐变。3.2 圆环 SDF 与角度扫描圆环比矩形多一个维度距离和角度。圆环的距离场是内外两个圆之间的差集float d abs(length(p) - radius) - thickness;p 到中心的距离减去圆环中心半径再取绝对值减厚度。得到的结果在圆环内部是负值外部是正值边缘就在 0 附近。角度扫描才是圆环进度条的关键。先用 atan2 算出当前像素的角度再和起始角、扫过进度比较float a atan2(p.y, p.x); float rel a - startAngle; rel fmod(rel, 6.2831853); if (rel 0.0) rel 6.2831853; float endAngle 6.2831853 * progress; float aa fwidth(rel); float mask smoothstep(0.0, aa * 2.0, rel) - smoothstep(endAngle, endAngle aa * 2.0, rel);这段代码看起来简单但有一个隐藏难点如果用 frac 把角度映射到 0~1起始角处会出现从 1 瞬间跳回 0 的裂缝。所以我在实际实现里用 fmod 加偏移让角度在起始角之后连续增长避免接缝处产生一条多余的边界线。3.3 为什么 SDF 是天生的抗锯齿工具SDF 和传统贴图遮罩的最大区别是它把“形状边缘”变成了一个连续的数学量。你可以对它做 smoothstep做 fwidth 自适应甚至直接控制描边宽度、发光宽度因为这些效果的输入都是同一个距离值。对移动端来说SDF 的 ALU 代价很便宜。length、atan2 这些指令看起来吓人但现代 GPU 对数学运算的吞吐远高于纹理采样。一个进度条 Shader 即便写上两三个距离函数对帧率的影响也远小于一次额外的贴图采样。4. 完整 Shader 实现可复制的 URP 圆角圆环进度条代码4.1 属性设计和基本结构我把所有可调项都暴露成属性包括颜色、背景色、描边、发光、形状类型、圆角半径、环半径、环厚度、进度、起始角、扫掠方向。形状类型用一个 float 控制0 代表圆角矩形1 代表圆环这样同一个材质就能在两种形态间切换。完整代码如下可以直接放进 Assets 下的 Shader 文件里使用Shader Custom/URP_RadialProgress { Properties { _Color(Progress Color, Color) (1, 1, 1, 1) _BgColor(Background Color, Color) (0, 0, 0, 0.25) _EdgeColor(Edge Color, Color) (1, 1, 1, 1) _GlowColor(Glow Color, Color) (1, 1, 1, 1) _ShapeType(Shape Type (0RoundRect, 1Ring), Range(0, 1)) 1 _RoundRadius(Round Radius (Rect), Range(0, 0.45)) 0.08 _RingRadius(Ring Radius, Range(0, 0.5)) 0.4 _RingThickness(Ring Thickness, Range(0, 0.5)) 0.05 _Progress(Progress, Range(0, 1)) 0 _StartAngle(Start Angle (Deg), Range(-180, 180)) -90 _SweepDirection(Sweep Direction, Range(-1, 1)) 1 _SmoothWidth(Smooth Width, Range(0.001, 0.1)) 0.01 _EdgeWidth(Edge Width, Range(0, 0.1)) 0 _Glow(Glow, Range(0, 0.2)) 0 _Pulse(Low Value Pulse, Range(0, 1)) 0 _PulseSpeed(Pulse Speed, Float) 2.0 } SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True CanUseSpriteAtlas True } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Stencil { Ref 128 Comp Always Pass Replace } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.5 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl CBUFFER_START(UnityPerMaterial) half4 _Color; half4 _BgColor; half4 _EdgeColor; half4 _GlowColor; half _ShapeType; half _RoundRadius; half _RingRadius; half _RingThickness; half _Progress; half _StartAngle; half _SweepDirection; half _SmoothWidth; half _EdgeWidth; half _Glow; half _Pulse; half _PulseSpeed; CBUFFER_END struct AttributesUI { float4 positionOS : POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; }; struct VaryingsUI { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; }; VaryingsUI vert(AttributesUI input) { VaryingsUI output; output.positionCS UnityObjectToClipPos(input.positionOS.xyz); output.uv input.uv; output.color input.color; return output; } float sdRoundRect(float2 p, float2 halfSize, float radius) { float2 q abs(p) - halfSize radius; return length(max(q, 0.0)) - radius; } float rectAlpha(float2 uv, float2 center, float2 halfSize, float radius, float smoothWidth) { float d sdRoundRect(uv - center, halfSize, radius); float aa fwidth(d); return 1.0 - smoothstep(0.0, aa * 1.5 smoothWidth, d); } float ringAlpha(float2 uv, float2 center, float radius, float thickness, float smoothWidth) { float d abs(length(uv - center) - radius) - thickness; float aa fwidth(d); return 1.0 - smoothstep(0.0, aa * 1.5 smoothWidth, d); } float progressAngleMask(float2 p, float2 center, float startDeg, float direction, float progress, float smoothWidth) { const float twoPi 6.28318530718; float a atan2(p.y - center.y, p.x - center.x); float start startDeg * 3.14159265359 / 180.0; float rel (a - start) * direction; rel fmod(rel, twoPi); if (rel 0.0) rel twoPi; float aa fwidth(rel); float edgeSmooth aa * 2.0 smoothWidth; float fromStart smoothstep(-edgeSmooth, edgeSmooth, rel); float endAngle twoPi * progress; float beforeEnd 1.0 - smoothstep(endAngle - edgeSmooth, endAngle edgeSmooth, rel); return fromStart * beforeEnd; } half4 BlendLayer(half4 dst, half4 src, half srcA) { half4 result; result.rgb lerp(dst.rgb, src.rgb, srcA); result.a max(dst.a, srcA); return result; } half4 frag(VaryingsUI input) : SV_Target { float2 uv input.uv - 0.5; half pulse 1.0; if (_Pulse 0.5) pulse 0.6 0.4 * sin(_Time.y * _PulseSpeed _Progress * 31.4); half4 col half4(0, 0, 0, 0); if (_ShapeType 0.5) { float2 halfSize float2(0.5 - _RoundRadius, 0.5 - _RoundRadius); float bgA rectAlpha(uv, float2(0, 0), halfSize, _RoundRadius, _SmoothWidth); col BlendLayer(col, _BgColor, _BgColor.a * bgA); float rightEdge -0.5 _Progress; float progressMask 1.0 - smoothstep( 0.0, fwidth(input.uv.x) * 2.0 _SmoothWidth, input.uv.x - rightEdge ); float fgA bgA * progressMask * pulse; col BlendLayer(col, half4(_Color.rgb, _Color.a), _Color.a * fgA); if (_EdgeWidth 0.001) { float innerHalf max(halfSize.x - _EdgeWidth, 0.001); float innerRadius max(_RoundRadius - _EdgeWidth, 0.0); float innerA rectAlpha(uv, float2(0, 0), innerHalf.xx, innerRadius, _SmoothWidth); float edgeA bgA * (1.0 - innerA); col BlendLayer(col, _EdgeColor, _EdgeColor.a * edgeA); } if (_Glow 0.001) { float d sdRoundRect(uv, float2(0, 0), halfSize, _RoundRadius); float glowA (1.0 - bgA) * (1.0 - smoothstep(0.0, _Glow, d)); col BlendLayer(col, _GlowColor, _GlowColor.a * glowA); } } else { float bgA ringAlpha(uv, float2(0, 0), _RingRadius, _RingThickness, _SmoothWidth); col BlendLayer(col, _BgColor, _BgColor.a * bgA); float angleMask progressAngleMask( uv, float2(0, 0), _StartAngle, _SweepDirection, _Progress, _SmoothWidth ); float fgA bgA * angleMask * pulse; col BlendLayer(col, half4(_Color.rgb, _Color.a), _Color.a * fgA); if (_EdgeWidth 0.001) { float innerThickness max(_RingThickness - _EdgeWidth, 0.001); float innerA ringAlpha(uv, float2(0, 0), _RingRadius, innerThickness, _SmoothWidth); float edgeA bgA * (1.0 - innerA); col BlendLayer(col, _EdgeColor, _EdgeColor.a * edgeA); } if (_Glow 0.001) { float d abs(length(uv) - _RingRadius) - _RingThickness; float glowA (1.0 - bgA) * (1.0 - smoothstep(0.0, _Glow, d)); col BlendLayer(col, _GlowColor, _GlowColor.a * glowA); } } col.a * input.color.a; col.rgb * input.color.rgb; return saturate(col); } ENDHLSL } } }4.2 怎么快速在 Canvas 里看到效果在 Unity 里新建一个 Canvas创建一个空 Image把 Image 的 Source Image 留空或使用内置白色圆形 Sprite然后把 Material 指定到这个 Shader 新建的材质上运行后就能看到圆环。进度条不同时可以让 Image 的 Raycast Target 保持关闭避免挡住其他 UI 点击。这里要注意宽高比问题。上面 Shader 的 uv 范围是 0~1如果你的 Image 不是正方形圆角会变成椭圆角、圆环会变成椭圆环。解决办法是在 Shader 里加一个 _Aspect 属性在计算 SDF 之前把 uv.x 除以 _Aspect。这个改动不影响整体逻辑属于实用扩展。5. 参数差异化组合一个材质撑起血条、技能冷却与分段刻度5.1 技能冷却转圈圆环 弧度进度技能冷却可能是这套方案最典型的使用场景。把 _ShapeType 设为 1_RingRadius 设为 0.42_RingThickness 设为 0.06_StartAngle 设为 -90 让起点在正上方背景色半透明黑前景用技能专属的颜色。C# 侧只需要更新 _Progressusing UnityEngine; using UnityEngine.UI; public class CooldownProgress : MonoBehaviour { [SerializeField] private Graphic _graphic; [SerializeField] private float _cooldownTotal 5f; private MaterialPropertyBlock _mpb; private float _cooldownLeft; private void Awake() { _graphic GetComponentGraphic(); _mpb new MaterialPropertyBlock(); _graphic.SetPropertyBlock(_mpb); _cooldownLeft _cooldownTotal; } private void Update() { _cooldownLeft Mathf.Max(0f, _cooldownLeft - Time.deltaTime); float progress 1f - _cooldownLeft / _cooldownTotal; _mpb.SetFloat(_Progress, progress); _graphic.SetPropertyBlock(_mpb); } public void StartCooldown(float total) { _cooldownTotal total; _cooldownLeft total; } }用 MaterialPropertyBlock 而不是复制材质是为了避免同一 Shader 下产生大量材质实例。实测中大量进度条同时转起来时MPB 方案比每个对象 new 一个 Material 稳定得多。5.2 血条和经验条圆角矩形 渐变 低血量脉冲把 _ShapeType 设为 0_RoundRadius 调到 0.08 左右描边用 _EdgeWidth 和 _EdgeColor 控制。渐变可以在 Shader 的矩形分支里加一个 _Color2 属性然后用 uv.y 做插值float gradientT saturate(uv.y 0.5); half3 progressColor lerp(_Color.rgb, _Color2.rgb, gradientT);低血量脉冲直接使用 _Pulse 开关内部用 _Time.y 和 _Progress 计算闪烁相位。这样策划在调血量阈值时只需要改参数不用写额外代码。5.3 分段刻度环从连续进度到离散条目的实现思路很多游戏的经验环喜欢做成“分数段”样式一段一段显示。实现思路是在角度遮罩里把连续角度离散化float segments 12.0; float rel01 rel / twoPi; float segIndex floor(rel01 * segments); float local frac(rel01 * segments); float segProgress _Progress * segments; float fullSeg step(segIndex 1.0, segProgress); float partSeg step(segIndex, segProgress) * step(segProgress, segIndex 1.0); float partLocal step(frac(segProgress), local); float segMask fullSeg partSeg * partLocal;这个思路可以让你在不增加任何纹理的情况下把连续圆环变成分数段样式只需要在 Shader 里加一个 _Segments 参数。5.4 参数配置速查表效果_ShapeType关键参数使用场景技能冷却1圆环半径 0.42厚度 0.06StartAngle -90技能图标冷却角色血条0圆角 0.08描边 0.015Pulse 开启战斗 UI 血量经验条0圆角 0.03Glow 0.02升级经验能量/冲刺1圆环半径 0.3厚度 0.03Glow 0.015移动类状态槽同一个 Shader不同参数组合出来的视觉差异非常明显这就是“参数差异化”的实际意义。6. 性能实测DrawCall、Overdraw 与移动端开销分析6.1 同一场景下 ImageMask 与 SDF Shader 的对比我在一个包含 5 个圆环冷却进度条的原型场景里做过对比。都是同一块 Canvas同一套 UI 布局只切换进度条实现方式指标Image Mask 方案SDF Shader 方案总 DrawCall2312进度条相关额外开销每个 Mask 独立批次几乎零额外批次Overdraw约 1.8x约 1.1x进度条贴图内存4 张 1024x10240新增材质实例每个风格一个共用同一个 Material不同项目数值会有差异但结论是稳定的Shader 方案在 DrawCall、内存、Overdraw 三个维度全面占优。6.2 Frame Debugger 验证合批打开 Window Analysis Frame Debugger点击进度条对应的批次能看到这个批次里包含哪些对象。如果发现进度条被拆成多个批次优先检查是不是同一个 Material 实例、材质属性是否被意外复制、Image 之间是否夹了其他元素。UI 合批的规则比较细但核心就几句话同 Canvas、同材质、同贴图、同渲染队列才容易合到一起。有一个容易忽略的点如果 C# 侧不小心用了 new Material即使参数完全一样也会生成新实例合批直接断裂。所以我强烈建议统一用 MaterialPropertyBlock 更新参数。6.3 ALU 指令成本估算与移动端注意点有人担心 SDF Shader 里的 length、atan2 太贵。实际上这两个函数在移动 GPU 上就是几条标量指令完全在可接受范围内。真正要避免的是大量高分辨率全屏进度条叠在一起这种情况下瓶颈往往不在 ALU而在 Canvas 网格更新和 CPU 到 GPU 的提交开销。如果确实遇到成百上千个进度条同时变化终极方案是放弃逐 UI 元素渲染改为把所有进度条合并成一张动态 Mesh把进度值写进顶点数据。这种方案绕开 Canvas 重建性能上限更高但实现复杂度也更高适合真正需要极限优化的项目。7. URP 实战踩坑清单模板值、紫色材质与 Canvas 重建7.1 Stencil 不对进度条要么消失要么乱盖我一共遇到三次 UI Shader 显示异常排查到最后都是 Stencil 的问题。一次是进度条放进 Mask 后整块消失一次是进度条把后面的大按钮盖住了还有一次是和 TMP 文字层级混乱。解决办法很简单Stencil 块保持和内置 UI/Default 完全一致。如果你用了 UIMask 或者自己的模板系统再按需要调整 Ref 和 Comp但千万别在基础 UI Shader 上自创模板逻辑。7.2 紫色材质和编译报错怎么查URP 下材质变紫先看 Console 里的报错90% 是这几种情况忘了写#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl导致 UnityObjectToClipPos 或 _Time 无法识别。变量名拼写不一致比如 HLSL 里写了 _StartAngleProperties 里却叫 _StarAngle。target 版本太低fwidth 不可用。加上#pragma target 3.5就能解决绝大多数旧设备问题。Shader 编译错误在编辑器里会直接输出到 Console逐行看很有帮助。7.3 RectMask2D、Cull Off 与排序RectMask2D 通过裁剪顶点来限制显示区域和 Mask 的模板机制不同。如果你的圆环超出 RectMask2D 边界超出的部分会被裁剪掉这是预期行为和 Shader 无关。把 Cull Off 放在 Shader 里主要是为了让进度条在 3D 世界空间里从背面看也能显示。如果你只用在 UI CanvasCull Off 不会带来明显问题但如果你把这个 Shader 挂到 3D 物体上记得确认双面渲染和 ZWrite Off 的组合符合预期。7.4 Canvas Rebuild 与大量进度条的性能取舍使用 MaterialPropertyBlock 更新进度时理论上不需要重建 Canvas 顶点只更新材质块。但个别 Unity 版本对 Graphic.SetPropertyBlock 的实现会有额外开销。如果你在 Profiler 里看到 Canvas.SendWillRenderCanvases 耗时过高可以试试每两帧更新一次进度。UI 进度条本身更新频率不需要 60fps30fps 更新肉眼完全看不出来。再提一个高阶思路如果进度条数量大到 Canvas 批次也无法接受那就自己维护一个合批 Mesh把进度值编码到 uv 或顶点色里一个 DrawCall 画完所有进度条。这个方案我已经在另一个项目里落地效果很猛但工程量不小建议在确认 Shader 方案确实到瓶颈之后再考虑。最后分享一个调参习惯。我给这个 Shader 写了一个自定义 Inspector在编辑器里直接拖动 _Progress 滑条就能预览完整动画。方法是重写 OnInspectorGUI把 _Progress 暴露成一个滑条。看起来花了一点时间实际上省掉了我无数次“跑游戏-调参-看效果-再调参”的循环。如果你想全面参数化进度条建议从第一天就加上这个工具后期效率会提升非常明显。