尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity Shader Graph实现动态椭圆环UI:GPU驱动的高性能进度条方案

Unity Shader Graph实现动态椭圆环UI:GPU驱动的高性能进度条方案 1. 项目概述一个“套圈”UI背后的技术拆解最近在做一个休闲小游戏里面有个核心玩法是“套圈”——玩家需要控制一个不断缩放的椭圆环在合适的时机套住移动的目标。为了把这个交互做得既直观又有趣我花了些时间折腾了一套完整的UI方案。核心就是一个椭圆环它的缩放、进度填充、颜色变化都需要实时、流畅地反馈给玩家。直接用Sprite序列帧或者Unity的Image组件做缩放效果生硬且性能开销大特别是当需要高频更新时。所以我决定用Shader来驱动整个视觉效果把计算从CPU转移到GPU实现丝滑的动态效果。这个方案的核心就是标题里提到的“椭圆环 Shader 动态进度”。它本质上是一个高度定制化的UI Shader运行在Unity的Canvas渲染体系下。Shader负责绘制一个参数化的椭圆环并接受脚本控制的参数如进度、环宽、颜色来实时改变外观。而C#脚本则负责游戏逻辑比如计算当前套圈的“完成度”或“能量值”并将其映射为Shader的进度参数。这样做的好处非常明显视觉效果极度灵活一个材质球就能实现无数种状态性能开销极低所有图形变换都在GPU端完成与游戏逻辑解耦美术和程序可以并行工作。这个实现不仅适用于套圈游戏任何需要环形进度条、能量槽、瞄准圈、技能冷却指示器的地方都可以复用。比如一个带有扭曲效果的能量吸收环或者一个随着时间收缩的倒计时圈。接下来我就把这个从Shader编写到UI集成的完整过程以及中间踩过的坑详细分享一下。2. 核心思路与方案选型为什么是Shader Graph当决定用技术方案实现这个动态椭圆环时摆在我面前的有几条路传统的UGUI Image组件配合遮罩、使用粒子系统、或者编写Shader。我们来快速分析一下方案一UGUI Image 遮罩/填充这是最直接的想法。用一个环状Sprite通过Image组件的Fill Amount属性来控制进度或者用RectTransform的缩放来模拟环的大小变化。但问题很快暴露锯齿与变形Fill Amount是沿固定方向如顺时针填充对于椭圆这种非圆形的环填充的起始点和结束点处理起来很别扭视觉上不精确。缩放整个Image则会导致环的宽度不均匀椭圆会变形。效果单一很难实现内外双描边、动态渐变色、扭曲等高级效果。每个新效果都可能需要叠加多个UI元素Draw Call飙升。性能瓶颈频繁修改UI属性如缩放、旋转、颜色是在CPU端进行的虽然Unity做了优化但在低端移动设备上大量UI动画仍是性能杀手。方案二粒子系统Unity的Particle System非常强大可以做出很炫的发光、流动效果。我曾考虑用粒子“拼”出一个环。但它的缺点在于控制精度不足让粒子严格排列成一个平滑的、参数可调的椭圆环需要复杂的发射器形状和力场控制代码复杂。UI集成度低粒子系统默认在世界空间渲染要集成到UI层需要设置Render Mode为Screen Space - Overlay或使用Particle System Renderer的Sorting Layer管理起来比纯粹的UI组件麻烦。过度设计对于“一个参数化几何图形”这种需求用粒子系统属于杀鸡用牛刀不必要的性能开销Overdraw会很大。方案三自定义ShaderShader Graph这是最终选择的方案。使用Unity的Shader Graph可视化工具编写一个Unlit Shader专门用于在UI上绘制椭圆环。极致灵活与性能所有顶点和片元计算都在GPU上。我们只需要在C#脚本中通过MaterialPropertyBlock或直接修改Material的属性就能驱动整个环的变化CPU开销几乎为零。效果无限可能在Shader Graph里我们可以用数学公式精确控制椭圆的形状、环的宽度、进度填充的算法支持顺/逆时针、任意起点、内外描边的颜色与宽度甚至可以加入噪声图来实现边缘的“能量波动”效果。完美UI集成生成的Shader可以创建一个Material直接赋给一个Raw Image或Image需选择正确的Shader。它完全遵循UI的RectTransform布局、锚点和层级关系管理起来和普通UI毫无二致。注意这里有一个关键选择点。对于UI Shader通常推荐使用UI/Unlit或Sprites/Default这类模板因为它们默认处理了UI的Alpha混合和颜色空间。在Shader Graph中创建时应选择Blank Shader Graph然后在Graph Inspector里将Graph Settings中的Material设置为UnlitSurface设置为Transparent。这能确保它在Canvas上正确渲染。所以结论很清晰为了获得高性能、高自由度、且易于集成的动态椭圆环基于Shader Graph的自定义Shader方案是最优解。它把视觉表现的复杂度封装在材质内部对外只暴露几个简单的参数接口完美实现了关注点分离。3. Shader Graph 核心节点网络详解确定了方案我们就进入Shader Graph的具体实现。我不会贴出整个庞大的节点图而是拆解成几个功能模块来讲解其原理和节点连接。假设你已经创建了一个新的Shader Graph。3.1 基础椭圆环形状生成椭圆环的数学本质是所有到两个焦点距离之和为常数的点的集合。但在Shader里我们用更简单的距离场方法来近似。一个常见的技巧是利用Ellipse节点或者用Length节点对缩放后的UV进行计算。步骤与节点逻辑UV坐标归一化首先使用Sample Texture 2D节点的UV输入或者直接用UV节点但我们需要将其从[0,1]范围映射到[-1,1]的中心对称坐标系。使用Tiling And Offset节点将Tiling设为2Offset设为-1即可实现。我们称这个结果为Centered UV。计算椭圆距离场椭圆的公式是(x/a)² (y/b)² 1。在Shader中我们可以用Length节点来模拟。将Centered UV的x和y分量分别除以椭圆的半长轴RadiusX和半短轴RadiusY这两个是Vector1类型的属性暴露给材质然后用Length节点计算这个缩放后向量的长度。这个长度值dist就是当前像素点到椭圆中心经过椭圆变形后的“归一化距离”。如果dist 1像素在椭圆内部。如果dist 1像素在椭圆边界上。如果dist 1像素在椭圆外部。生成圆环我们需要的是一个环而不仅仅是椭圆轮廓。定义两个属性InnerRadius内半径比和OuterRadius外半径比范围在[0,1]。它们决定了环的粗细。例如InnerRadius0.7,OuterRadius1.0表示环占据从中心点距离0.7到1.0的区域。环的“厚度”thickness OuterRadius - InnerRadius。环的“中间线”midRadius (InnerRadius OuterRadius) / 2。平滑阶梯函数直接用Step节点对比dist和InnerRadius/OuterRadius会产生锯齿严重的硬边。我们需要Smoothstep函数。节点连接如下Smoothstep(InnerRadius - fuzz, InnerRadius fuzz, dist)这个值在环的内部区域为0在外部区域为1在边界处平滑过渡。fuzz是一个很小的平滑因子比如0.01。Smoothstep(OuterRadius - fuzz, OuterRadius fuzz, dist)这个值在环的内部区域为1在外部区域为0。将这两个结果相乘就得到了一个在环体区域为1在环内外区域为0且边界平滑的蒙版ringMask。// 节点运算的伪代码概念 vec2 centeredUV uv * 2.0 - 1.0; vec2 scaledUV vec2(centeredUV.x / RadiusX, centeredUV.y / RadiusY); float dist length(scaledUV); float innerSoft smoothstep(InnerRadius - Fuzz, InnerRadius Fuzz, dist); float outerSoft 1.0 - smoothstep(OuterRadius - Fuzz, OuterRadius Fuzz, dist); // 注意这里用1减 float ringMask innerSoft * outerSoft;这个ringMask就是我们绘制任何效果的基础。白色值1代表环所在的像素区域。3.2 动态进度填充的实现这是“套圈”感觉的核心。我们需要根据一个Progress属性0到1让环的一部分显示另一部分隐藏。并且要支持顺时针、逆时针以及从任意角度开始。步骤与节点逻辑计算像素角度我们需要知道当前像素在归一化中心坐标系下相对于中心点的角度。使用Arctangent节点atan2函数。输入是Centered UV.y和Centered UV.x。注意atan2(y, x)的返回值范围是[-π, π]。我们通常将其映射到[0, 1](atan2Result / (2 * PI)) 0.5。得到angle01。进度比较进度填充的本质是所有角度小于Progress * 360°的像素显示其余隐藏。但由于起点可能不是0度比如从顶部开始我们需要引入一个StartAngle属性范围0-1。计算adjustedAngle frac(angle01 - StartAngle 1.0)确保角度差在[0,1]内。生成进度蒙版将adjustedAngle与Progress比较。如果adjustedAngle Progress则该像素在进度范围内。使用Step节点即可progressMask step(adjustedAngle, Progress)。如果需要平滑的边缘可以再用Smoothstep。方向控制逆时针怎么办很简单将比较反过来或者用1.0 - adjustedAngle作为角度值。我们可以添加一个Boolean属性Clockwise。节点逻辑可以是一个分支If Clockwise is True, use step(adjustedAngle, Progress); Else, use step(Progress, adjustedAngle)。更巧妙的做法是direction lerp(-1, 1, Clockwise)然后计算angleForCompare 0.5 direction * (adjustedAngle - 0.5)再与0.5比较这样可以统一处理。最终环蒙版将基础的ringMask与进度蒙版progressMask相乘得到最终的finalMask。只有既在环的几何区域内又在当前进度角度内的像素才会被最终渲染。实操心得这里最容易出问题的是角度的周期性和起点。一定要用frac函数处理角度差确保它始终在[0,1)范围内否则在StartAngle不为0时环上会出现一条难看的接缝。另外测试时要分别用0、0.5、1等极端进度值以及切换顺逆时针观察填充是否正确。3.3 内外描边与颜色混合有了finalMask我们就可以上色了。但一个漂亮的环通常有内外两层描边以及环体本身的颜色可能还有渐变。步骤与节点逻辑分离内外边缘我们可以利用之前计算的距离场dist。内边缘是dist接近InnerRadius的区域外边缘是dist接近OuterRadius的区域。用Smoothstep可以分别提取出这两个区域的软蒙版比如innerEdgeMask和outerEdgeMask。它们的宽度由另一个属性EdgeWidth控制。颜色分层混合定义颜色属性InnerEdgeColor,OuterEdgeColor,RingColor。我们可以采用“从底到上”的混合方式 a. 首先用finalMask乘以RingColor作为基底颜色baseColor。 b. 然后用innerEdgeMask去线性混合InnerEdgeColor到baseColor上。因为边缘蒙版也是基于finalMask区域的所以不会超出环的范围。混合公式可以是color lerp(baseColor, InnerEdgeColor, innerEdgeMask)。 c. 同理再用outerEdgeMask去混合OuterEdgeColor。由于内外边缘不重叠顺序影响不大。动态颜色与渐变让颜色随着进度变化会更有动感。可以将Progress或angle01作为输入连接到Gradient节点然后用Sample Gradient节点获取颜色。将这个动态颜色与之前混合好的颜色再进行一次混合或相乘。Alpha通道处理最终输出的颜色Alpha通道应该由finalMask决定。但为了更好的抗锯齿我们可以对finalMask本身做一次轻微的平滑处理或者直接用它作为Alpha。确保在Shader Graph的Master Stack的Alpha端口连接的是经过处理的finalMask值。// 颜色混合的伪代码概念 float4 baseColor RingColor * finalMask; float4 withInnerEdge lerp(baseColor, InnerEdgeColor, innerEdgeMask); float4 withBothEdges lerp(withInnerEdge, OuterEdgeColor, outerEdgeMask); // 可选添加径向渐变 float radialFactor dist; // 或 remap(dist, InnerRadius, OuterRadius, 0, 1) float4 gradientColor SampleGradient(GradientAsset, radialFactor); float4 finalColor withBothEdges * gradientColor; return float4(finalColor.rgb, smoothstep(0, 0.01, finalMask)); // Alpha平滑至此Shader Graph的主体网络就搭建完成了。我们需要将RadiusX,RadiusY,InnerRadius,OuterRadius,Progress,StartAngle,Clockwise, 各种Color以及EdgeWidth等属性都暴露出来方便在材质面板中调节更重要的是让C#脚本可以动态控制。4. Unity UI 集成与C#动态控制Shader写好了生成材质球。接下来就是把它用起来。4.1 UI组件设置在UI Canvas下创建一个Raw Image组件。为什么不直接用Image因为Image组件对Sprite有特定的优化和网格生成而我们的Shader可能需要对整个矩形区域进行像素计算Raw Image更通用它只是显示一个纹理或纯色。当然Image也可以只需在Material设置时注意。将创建好的材质球拖拽到Raw Image的Material属性上。此时你应该能看到椭圆环显示出来。调整Raw Image的RectTransform的宽高比。因为我们的Shader里RadiusX和RadiusY是独立参数为了显示一个正圆环你可以将RectTransform设为正方形或者通过调整RadiusX/Y的比例来适配任意矩形区域。我推荐后者因为它更灵活。例如你可以将RectTransform的宽高设为200100然后将RadiusX设为0.4RadiusY设为0.8这样就能得到一个充满整个200100矩形的椭圆环。4.2 C#脚本编写与参数驱动创建一个脚本EllipseRingController挂载到Raw Image所在的GameObject上。using UnityEngine; using UnityEngine.UI; public class EllipseRingController : MonoBehaviour { [Header(环控制参数)] [Range(0f, 1f)] public float progress 0.0f; [Range(0f, 1f)] public float startAngle 0.0f; // 0-1对应0-360度 public bool clockwise true; [Range(0f, 0.5f)] public float innerRadius 0.7f; [Range(0f, 0.5f)] public float outerRadius 0.9f; public Color ringColor Color.cyan; public Color innerEdgeColor Color.white; public Color outerEdgeColor Color.blue; [Header(动画设置)] public float progressChangeSpeed 0.5f; // 进度变化速度 private float targetProgress 0f; private Material ringMaterial; private int propProgress; private int propStartAngle; private int propClockwise; // ... 其他属性ID void Start() { var image GetComponentRawImage(); if (image ! null image.material ! null) { // 重要为了支持多个UI实例独立控制材质参数必须实例化材质 ringMaterial new Material(image.material); image.material ringMaterial; // 预计算Shader属性ID提升性能 propProgress Shader.PropertyToID(_Progress); propStartAngle Shader.PropertyToID(_StartAngle); propClockwise Shader.PropertyToID(_Clockwise); propInnerRadius Shader.PropertyToID(_InnerRadius); propOuterRadius Shader.PropertyToID(_OuterRadius); propRingColor Shader.PropertyToID(_RingColor); propInnerEdgeColor Shader.PropertyToID(_InnerEdgeColor); propOuterEdgeColor Shader.PropertyToID(_OuterEdgeColor); } else { Debug.LogError(EllipseRingController: 未找到Raw Image或Material); enabled false; } } void Update() { // 示例平滑过渡到目标进度 progress Mathf.MoveTowards(progress, targetProgress, progressChangeSpeed * Time.deltaTime); UpdateMaterialProperties(); } // 外部调用此方法来改变目标进度 public void SetTargetProgress(float target) { targetProgress Mathf.Clamp01(target); } void UpdateMaterialProperties() { if (ringMaterial ! null) { ringMaterial.SetFloat(propProgress, progress); ringMaterial.SetFloat(propStartAngle, startAngle); ringMaterial.SetFloat(propClockwise, clockwise ? 1.0f : 0.0f); // Shader中Bool通常用Float传递 ringMaterial.SetFloat(propInnerRadius, innerRadius); ringMaterial.SetFloat(propOuterRadius, outerRadius); ringMaterial.SetColor(propRingColor, ringColor); ringMaterial.SetColor(propInnerEdgeColor, innerEdgeColor); ringMaterial.SetColor(propOuterEdgeColor, outerEdgeColor); } } // 在Inspector中修改参数时实时更新预览 void OnValidate() { // OnValidate在编辑器模式下运行且可能早于Start // 因此需要安全地获取材质 if (Application.isPlaying ringMaterial ! null) { UpdateMaterialProperties(); } else { // 非运行模式下直接修改共享材质会影响所有使用该材质的对象需谨慎 // 通常编辑器预览可以通过其他方式这里不展开 } } }关键点解析材质实例化在Start中我们使用new Material(image.material)创建了材质的一个实例。这是至关重要的一步。如果不实例化直接修改image.material的属性将会改变这个材质球资产本身导致场景中所有使用该材质的UI元素都一起改变。实例化后每个UI对象都有自己的材质副本可以独立控制。属性ID缓存Shader.PropertyToID将属性名称字符串转换为一个整数ID。在Update中频繁调用SetFloat(“_Progress”, value)会因为字符串查找而带来开销。缓存ID后使用SetFloat(id, value)效率更高。参数驱动在Update中我们可以根据游戏逻辑比如套圈目标的距离、能量积累的速度来计算progress然后调用UpdateMaterialProperties更新Shader。所有的视觉变化都由GPU完成非常高效。4.3 在“套圈”游戏中的应用逻辑在具体的套圈游戏里这个控制器脚本可以这样工作进度计算progress可以映射为“套圈的准备程度”或“瞄准精度”。例如当玩家按住屏幕时一个椭圆环开始收缩outerRadius减小当环的尺寸与目标物尺寸匹配时progress开始从0向1增长。progress的增长速度可以与匹配的完美程度挂钩。视觉反馈当progress很低时环的颜色可以是红色ringColor随着progress增加可以通过脚本动态地将颜色插值到绿色。这可以通过在UpdateMaterialProperties中计算并设置颜色来实现而不是暴露给Inspector。成功与失败当progress达到1时表示套圈成功可以触发一个动画比如让环的innerEdgeColor和outerEdgeColor快速闪烁白色然后播放一个粒子特效。如果失败松开按钮时progress未满可以让环快速抖动通过小幅随机改变startAngle并变成灰色。5. 性能优化与常见问题排查虽然GPU方案性能很好但在移动端或大量使用时仍需注意一些细节。5.1 性能优化要点Draw Call与材质实例每个使用了不同参数的Raw Image因为材质实例化都会产生一个独立的Draw Call。如果屏幕上需要同时显示几十个动态环Draw Call会激增。优化方法合并如果多个环的动画逻辑完全相同或相似可以考虑将它们合并到一个大的纹理图集Atlas中然后通过修改UV在单个Draw Call内绘制多个环。但这需要更复杂的Shader将位置、大小、进度等作为每个“实例”的属性传入通常需要用到UnityEngine.UI的Mesh修改或者URP的Shader Graph的Custom Function节点结合StructuredBuffer实现难度较高。简化Shader检查你的Shader Graph节点是否过于复杂。避免在片元着色器中使用过多的高开销节点如复杂的噪声纹理采样、多次Sample Gradient、嵌套的Branch节点。尽量将计算移到顶点着色器阶段如果与UV无关。使用MaterialPropertyBlock对于大量相同材质、仅参数不同的对象使用MaterialPropertyBlock来设置属性而不是实例化材质。但请注意UI系统Graphic组件默认不支持MaterialPropertyBlock。你需要编写自定义的Graphic派生类来支持它或者考虑使用Graphics.DrawMesh直接绘制但这会脱离UI系统的自动合批和布局管理。Overdraw我们的Shader在环之外的区域Alpha为0但GPU仍然会处理这些片元。确保Raw Image的矩形区域不要过大刚好包裹住环即可。可以通过调整RadiusX/Y和RectTransform的大小来最小化透明区域。属性更新频率不要在每一帧更新所有属性。如果某些属性如颜色、描边宽度在运行时不变就在初始化时设置一次。只更新变化中的属性如progress。5.2 常见问题与解决方案问题1环的边缘有锯齿Aliasing。原因在Smoothstep的过渡区域太窄或者屏幕分辨率低。解决方案适当增加Smoothstep的过渡区间fuzz值比如从0.01增加到0.02。在Shader Graph的Master Stack中开启MSAA多重采样抗锯齿支持。确保你的Canvas和项目的抗锯齿设置已打开。可以考虑在最后对finalMask做一次轻微的模糊处理但这会增加计算量。问题2进度填充的起点StartAngle改变时环上出现一条明显的接缝。原因角度计算没有正确处理周期性问题。当adjustedAngle frac(angle01 - StartAngle 1.0)中的angle01 - StartAngle接近-1或1时frac函数可能在0和1边界处产生不连续。解决方案这是Shader中处理角度周期性的经典问题。确保你的progressMask计算在边界处是连续的。一个更健壮的方法是不使用step而使用比较角度差的绝对值float angleDiff abs(adjustedAngle - 0.5) * 2.0; // 映射到[0,1]表示与起点的“距离” float progressMask 1.0 - smoothstep(Progress - 0.01, Progress 0.01, angleDiff);但这会改变填充方式从起点向两边扩散。对于严格的顺时针/逆时针填充必须处理好frac。一个技巧是在计算adjustedAngle后如果Progress接近1则对progressMask在0和1边界处进行特殊混合。问题3在编辑器里调节参数有效但运行时没反应。原因最常见的原因是脚本中没有正确实例化材质导致修改了共享的材质资产但可能被UI系统或其他机制覆盖。排查步骤在Start方法中打Debug.Log确认ringMaterial是否被成功创建且不等于null。在UpdateMaterialProperties方法中打Debug.Log输出你设置的属性值确认它们被正确调用。在Unity编辑器的Game视图选中你的UI对象查看其Raw Image组件上的Material是否显示为“(Instance)”。如果不是说明材质没有被实例化。检查Shader中暴露的属性名称是否与C#脚本中Shader.PropertyToID里使用的字符串完全一致包括大小写和下划线。问题4在UI构建或滚动时环的显示异常如闪烁、错位。原因UI Canvas的渲染顺序、重叠裁剪Mask可能影响了我们的自定义Shader。解决方案确保Shader的渲染队列Queue设置正确。对于UI通常使用Transparent队列Queue Transparent。检查是否在带有Mask或RectMask2D的组件内部。我们的Shader可能需要支持模板测试Stencil Test。在Shader Graph中可以在Graph Inspector的Active Targets下找到Stencil选项根据父级Mask的设置进行配置。一个简单的做法是继承UI默认Shader的模板设置。如果环在滚动视图里确保Raw Image的Raycast Target属性根据需求设置如果不需要交互可以取消勾选以减少性能开销。问题5想实现更复杂的动态效果比如环上的光点流动。思路这需要在Shader中引入时间Time节点和更复杂的纹理采样。光点流动可以添加一个Noise或Gradient纹理根据angle01 _Time.y * speed作为UV的U方向进行采样然后将采样结果与环的颜色相乘或叠加。这样就能得到一个沿着环运动的光带。环的扭曲可以在计算距离场dist之前对Centered UV进行扰动。例如采样一个噪声图用其影响RadiusX和RadiusY产生不规则的椭圆形状。这些高级效果的关键在于将效果限制在finalMask定义的区域内并且确保性能可控。对于移动平台复杂的噪声纹理采样需要谨慎评估。这套“椭圆环Shader 动态进度”的方案从构思到实现再到优化和问题排查基本涵盖了UI特效开发的核心流程。它最大的优势在于将表现层逻辑完全交给了可编程、高性能的Shader让UI动画摆脱了传统组件的束缚。当你掌握了这套方法后完全可以举一反三创造出各种复杂的动态UI元素比如动态多边形进度条、扭曲的文字标签、具有景深效果的图标等等。
返回列表