Unity Shader实战:基于UV坐标与距离场绘制可动态调节的2D圆环

📅 2026/7/8 16:20:55
Unity Shader实战:基于UV坐标与距离场绘制可动态调节的2D圆环
1. 项目概述从UV坐标到动态圆环的Shader之旅在Unity的图形世界里Shader是赋予物体灵魂的画笔。今天我们不谈那些复杂的PBR光照模型也不去深究屏幕后处理就从一个最基础、也最核心的概念出发——UV坐标用它来亲手绘制一个可以实时调节的2D圆环。这个项目听起来简单但它却是理解Shader图形编程的绝佳敲门砖。很多朋友学Shader卡在矩阵变换和复杂光照上其实回过头来把UV这个“画布”玩明白了很多复杂效果就有了根基。这个“基于UV坐标绘制可动态调节的2D圆环”项目本质上是在片元着色器Fragment Shader中进行的一场数学与艺术的游戏。我们利用每个像素点自带的UV坐标一个二维向量通常范围在[0,1]之间通过距离计算、步进函数等数学方法来决定这个像素是该显示为圆环还是显示为背景。它的核心价值在于你将彻底理解如何在Shader中描述和绘制一个基本的几何形状并学会如何通过参数Properties来动态控制这个形状的粗细、大小和颜色实现与材质检视器Inspector的实时交互。无论你是想为UI元素制作一个精致的发光边框还是为技能图标添加一个可变的冷却指示圈亦或是单纯想深入理解片元着色器的工作原理这个实战案例都再合适不过。它不需要复杂的模型和贴图一张简单的2D Sprite甚至一个Quad面片足矣让我们把注意力完全集中在Shader代码的逻辑本身。2. 核心原理与数学拆解圆环是如何“算”出来的在CPU端的世界里我们画一个圆可能需要调用图形API指定圆心、半径然后填充。但在GPU的片元着色器里我们拥有的只有当前像素的坐标信息一切图形都得靠“算”。理解这个过程是写好Shader的关键。2.1 UV坐标系统你的专属画布首先必须彻底搞懂UV坐标。你可以把一张贴图或一个模型表面想象成一张画布UV坐标就是给这张画布建立的二维坐标系。通常左下角是(0,0)右上角是(1,1)。在Shader中我们通过v.uv或i.uv来获取当前片元可以粗略理解为当前正在处理的像素点在这张画布上的位置。注意UV坐标的(0,0)点具体在哪个角落取决于模型的UV展开方式和贴图的导入设置。对于Unity默认的Quad或Sprite通常是左下角为(0,0)。但为了代码的健壮性我们有时需要将坐标原点平移到画布中心即让坐标范围从[0,1]变为[-0.5, 0.5]或[-1,1]这样计算距离时会更直观。2.2 距离公式判断像素与圆心的关系圆环的核心是“圆”。在数学上圆被定义为到定点圆心距离等于定长半径的所有点的集合。在Shader中我们要判断当前像素点由UV坐标定义到圆心的距离。假设我们将UV坐标原点移到画布中心坐标范围映射为[-0.5, 0.5]。设圆心为center float2(0.5, 0.5)如果原点在左下角或center float2(0.0, 0.0)如果原点已移到中心。当前像素坐标为p i.uv。那么该点到圆心的距离d可以通过二维向量的长度计算得出float d length(p - center);length()函数内部做的就是sqrt((p.x-center.x)^2 (p.y-center.y)^2)即标准的欧几里得距离。2.3 构建圆环利用步进与平滑函数得到一个距离d后如何用它来画一个圆环呢一个实心圆很简单如果d radius则显示圆的颜色否则显示背景色。这可以用step(radius, d)函数来实现它返回d radius时为1否则为0。但我们需要的是环即一个具有内半径和外半径的环形区域。设内半径为innerRadius外半径为outerRadius且innerRadius outerRadius。那么圆环区域就是满足innerRadius d outerRadius的所有点。最直接的方法是使用两个step函数的组合float ring step(innerRadius, d) - step(outerRadius, d);这里step(innerRadius, d)在d innerRadius时返回1即内圆之外的区域。step(outerRadius, d)在d outerRadius时返回1即外圆之外的区域。两者相减就恰好得到了内圆之外、外圆之内的环形区域值为1其他区域为0。然而这样得到的圆环边缘是锯齿状的因为step函数是二值跳变。为了获得平滑的边缘抗锯齿我们更常用smoothstep函数。它会在两个阈值之间进行平滑插值。float ring smoothstep(innerRadius, innerRadius smoothWidth, d) - smoothstep(outerRadius, outerRadius smoothWidth, d);这里smoothWidth是一个很小的值用于控制边缘过渡的宽度。smoothstep会在[innerRadius, innerRadiussmoothWidth]这个区间内从0平滑地过渡到1。这样圆环的内外边缘都会有一个柔和的渐变视觉效果更佳也更能适应不同分辨率。3. Shader框架搭建与属性定义理解了数学原理我们就可以开始动手编写Shader了。一个完整的Unity Shader通常包含Properties块、SubShader包含Pass等部分。我们将从最基础的Unlit Shader模板开始改造。3.1 定义可调节的属性PropertiesShader与材质检视器交互的桥梁就是Properties块。这里我们将所有需要动态调节的参数都定义出来。Properties { _MainTex (Texture, 2D) white {} // 基础纹理可能用于混合 _Center (Center (XY), Vector) (0.5, 0.5, 0, 0) // 圆心位置基于UV坐标 _OuterRadius (Outer Radius, Range(0, 0.5)) 0.4 // 外半径最大不超过0.5画布一半 _InnerRadius (Inner Radius, Range(0, 0.5)) 0.3 // 内半径 _RingColor (Ring Color, Color) (1, 0.5, 0, 1) // 圆环颜色 _BackgroundColor (Background Color, Color) (0.1, 0.1, 0.1, 1) // 背景颜色 _SmoothWidth (Edge Smoothness, Range(0, 0.05)) 0.01 // 边缘平滑宽度 _AspectRatio (Aspect Ratio Correction, Float) 1.0 // 宽高比校正防止圆变椭圆 }参数详解与避坑指南_Center这里用Vector类型但只使用其xy分量。默认(0.5,0.5)表示画布中心。如果你希望圆环可以偏移就在这里调整。注意UV坐标原点在左下角。_OuterRadius 与 _InnerRadius使用Range属性方便在检视器中拖拽滑块。最大值设为0.5是经验值因为从画布中心到边缘的最大距离是0.5当圆心在中心时。如果你设置的圆心不在中心可能需要根据情况调整最大范围否则圆环可能画不出来。_SmoothWidth这个值不宜过大通常0.005到0.02之间视觉效果较好。过大会导致圆环“变胖”视觉粗细不准确过小则失去抗锯齿效果边缘会有锯齿。_AspectRatio宽高比校正这是一个关键但容易被忽略的参数我们的UV坐标空间如果应用在一个非正方形的Sprite或Quad上其x和y方向的实际像素比例与UV的1:1比例是不符的。直接计算距离画出来的会是一个椭圆。因此我们需要在计算距离前对UV坐标进行校正。通常我们传入Sprite或屏幕的宽高比。例如对于一张512x256的图片其宽高比为2。校正方式为p.x * _AspectRatio;或者p.x / _AspectRatio;取决于你的坐标空间定义然后再计算length(p)。这样才能保证画出来的是正圆。3.2 顶点着色器与数据结构传递顶点着色器vert的任务相对简单完成模型空间到裁剪空间的顶点变换并将UV坐标等数据传递给片元着色器。struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 标准顶点变换 o.uv v.uv; // 传递UV坐标 return o; }这里我们使用了最简单的结构。如果你需要进行复杂的UV动画或屏幕空间计算可以在这里添加更多的数据如世界坐标、视图方向等。4. 片元着色器核心逻辑实现片元着色器frag是整个Shader的灵魂所有关于圆环的计算和绘制都在这里发生。4.1 坐标变换与距离计算首先我们需要获取并处理UV坐标。fixed4 frag (v2f i) : SV_Target { // 1. 获取并可选地中心化UV坐标 float2 uv i.uv; // 将原点从左下角移动到中心范围变为[-0.5, 0.5] uv - _Center.xy; // 2. 应用宽高比校正确保绘制的是正圆 uv.x * _AspectRatio; // 3. 计算当前像素到新原点的距离 float d length(uv);实操心得uv - _Center.xy这行代码非常灵活。如果_Center是(0.5,0.5)那么原点就被移到了画布中心。如果你想做一个跟随鼠标的圆环需要将鼠标位置转换为UV空间传入只需将_Center设置为鼠标的UV坐标即可。宽高比校正_AspectRatio的计算通常可以在C#脚本中获取SpriteRenderer.sprite.bounds.size或屏幕宽高比然后通过Material.SetFloat传递给Shader。如果图元是正方形则设为1.0。4.2 圆环形状计算与颜色混合接下来使用smoothstep计算圆环的遮罩alpha值。// 4. 使用smoothstep计算圆环区域值为0到1的平滑过渡 float ringMask smoothstep(_InnerRadius, _InnerRadius _SmoothWidth, d); ringMask - smoothstep(_OuterRadius, _OuterRadius _SmoothWidth, d); // ringMask现在在圆环区域接近1在背景和圆孔区域接近0边缘有平滑过渡。smoothstep的妙处在于它不仅仅输出0或1而是在阈值区间内进行平滑的Hermite插值。这省去了我们自己写插值函数的麻烦并且是GPU硬件优化的函数效率很高。得到ringMask后我们就可以混合颜色了。// 5. 混合颜色 fixed4 finalColor lerp(_BackgroundColor, _RingColor, ringMask); // 6. 可选与基础纹理混合 fixed4 texColor tex2D(_MainTex, i.uv); finalColor lerp(finalColor, texColor, texColor.a); // 例如根据纹理的alpha进行叠加 return finalColor; }颜色混合技巧lerp(a, b, t)函数是线性插值当t0时返回at1时返回bt在中间时返回混合值。这里我们用ringMask作为t就自然实现了圆环颜色与背景色的平滑混合包括边缘的平滑过渡。与纹理混合的方式多种多样。上面的例子是简单的根据纹理alpha叠加。你也可以用乘法混合finalColor.rgb * texColor.rgb来实现纹理着色或者用屏幕空间叠加等更复杂的模式。这完全取决于你的美术需求。5. 动态调节功能的扩展实现一个“可动态调节”的圆环不仅仅是在材质面板里拖拖滑块。我们更希望它能与游戏逻辑联动比如表示技能冷却进度、血量百分比等。这就需要从C#脚本向Shader传递参数。5.1 通过脚本控制Shader参数假设我们有一个UIRingController脚本挂载在拥有此材质的对象上。using UnityEngine; public class UIRingController : MonoBehaviour { public Material ringMaterial; // 拖拽赋值 [Range(0, 1)] public float fillProgress 0.8f; // 填充进度0到1 // 对应Shader中的_OuterRadius属性名 private readonly string _propOuterRadius _OuterRadius; private readonly string _propInnerRadius _InnerRadius; void Start() { if (ringMaterial null) { // 尝试从当前物体的Renderer获取材质 var renderer GetComponentRenderer(); if (renderer ! null) ringMaterial renderer.material; } // 注意直接修改renderer.material会创建新的材质实例适合动态对象。 // 如果大量使用应考虑使用MaterialPropertyBlock来优化。 } void Update() { if (ringMaterial ! null) { // 示例根据进度动态计算外半径内半径固定 float baseOuterRadius 0.4f; float currentOuterRadius baseOuterRadius * fillProgress; // 确保外半径大于内半径 float innerRadius ringMaterial.GetFloat(_propInnerRadius); if (currentOuterRadius innerRadius) { currentOuterRadius innerRadius 0.01f; } ringMaterial.SetFloat(_propOuterRadius, currentOuterRadius); // 也可以动态改变颜色例如进度低时变红高时变绿 Color targetColor Color.Lerp(Color.red, Color.green, fillProgress); ringMaterial.SetColor(_RingColor, targetColor); } } }性能优化要点如果场景中有成百上千个使用此Shader的UI元素每个都通过material.SetFloat来修改属性会产生大量的材质实例Material Instances增加Draw Call和内存开销。对于这种情况应使用MaterialPropertyBlock。它允许你修改材质的属性而无需创建新的材质实例。MaterialPropertyBlock _propBlock; Renderer _renderer; void Awake() { _propBlock new MaterialPropertyBlock(); _renderer GetComponentRenderer(); } void UpdateRingProperties() { _renderer.GetPropertyBlock(_propBlock); _propBlock.SetFloat(_propOuterRadius, currentOuterRadius); _propBlock.SetColor(_RingColor, targetColor); _renderer.SetPropertyBlock(_propBlock); }5.2 实现圆环进度填充效果上面例子是整体缩放圆环。更常见的需求是像进度条一样圆环根据一个百分比值从无到有地填充。这需要一点技巧因为圆环是360度的。实现思路是除了距离判断我们还要增加一个角度判断。我们计算当前像素相对于圆心的角度使用atan2函数然后根据填充进度决定在这个角度上的像素是否应该显示为圆环。步骤分解计算角度float angle atan2(uv.y, uv.x);。atan2返回的是弧度值范围在-π到π之间。将角度归一化为了方便与进度0到1比较我们将角度映射到0到1之间float normalizedAngle (angle 3.14159265359) / (2.0 * 3.14159265359);。这样角度0正右方对应0.0逆时针旋转一周对应到1.0。根据进度裁剪如果normalizedAngle大于当前的_FillProgress那么即使这个像素在圆环的距离范围内我们也不显示它即将其ringMask置为0。// 在计算ringMask之后进行角度裁剪 float angle atan2(uv.y, uv.x); float normalizedAngle (angle 3.14159265359) / (2.0 * 3.14159265359); float angleMask step(normalizedAngle, _FillProgress); // 进度内的角度返回1否则返回0 ringMask * angleMask; // 结合距离遮罩和角度遮罩这样随着_FillProgress从0增长到1圆环就会像时钟一样逐渐绘制出来。你可以通过调整角度的起始位置比如加上一个偏移_AngleOffset来控制填充从哪里开始。6. 常见问题、优化与进阶技巧在实际应用和性能优化中你会遇到一些典型问题。这里记录下我踩过的坑和解决方案。6.1 锯齿Aliasing与边缘闪烁即使使用了smoothstep在圆环边缘非常细或者摄像机快速移动时仍可能看到锯齿或边缘闪烁特别是动画时。这是因为smoothstep的过渡区间是固定的在极端情况下采样不足。解决方案使用fwidth进行自适应平滑fwidth函数可以估算当前像素在屏幕空间中的变化率。在边缘陡峭的地方fwidth(d)值会较大。我们可以用它来动态调整_SmoothWidth。float distanceChange fwidth(d); // 估算距离在屏幕空间的变化率 float adaptiveSmooth distanceChange * 2.0; // 乘以一个系数 float ringMask smoothstep(_InnerRadius, _InnerRadius adaptiveSmooth, d); ringMask - smoothstep(_OuterRadius, _OuterRadius adaptiveSmooth, d);这样在远处或边缘很陡的地方平滑区域会自动变宽有效抑制锯齿。开启硬件抗锯齿MSAA对于UI或后处理效果确保摄像机和项目的抗锯齿设置是打开的。这是最直接有效的方法。使用更高分辨率的渲染纹理如果圆环是渲染到一张RenderTexture上再显示可以考虑使用比屏幕分辨率更高的RenderTexture然后缩放到屏幕大小这是一种超采样抗锯齿SSAA的思路比较消耗性能。6.2 性能考量与移动端适配这个Shader本身计算量很小一次length两次smoothstep一些加减乘除在移动端通常是安全的。但仍有优化空间避免在片段着色器中使用atan2atan2是一个相对昂贵的函数。如果不需要进度填充功能就不要加它。即使需要也可以考虑在顶点着色器中计算角度相关值并插值到片元但这样精度会有所损失。使用dot代替length进行距离比较length(p)内部有开方运算sqrt(dot(p,p))。如果我们只是比较距离的大小完全可以直接比较距离的平方省去开方。float d_sqr dot(uv, uv); // 距离的平方 float outerRadius_sqr _OuterRadius * _OuterRadius; float innerRadius_sqr _InnerRadius * _InnerRadius; // 注意smoothstep的阈值也需要用平方值 float ringMask smoothstep(innerRadius_sqr, innerRadius_sqr smoothWidth_sqr, d_sqr); ringMask - smoothstep(outerRadius_sqr, outerRadius_sqr smoothWidth_sqr, d_sqr);这样优化在需要处理大量片元时能带来可观的性能提升。精度选择在移动端可以尝试将float改为half或fixed来提升性能。但对于uv坐标和距离计算使用half可能因精度不足导致边缘出现 banding条带。建议先使用float如果性能测试成为瓶颈再尝试局部变量使用half。6.3 与UI系统的深度结合UGUI如果你在Unity的UGUI系统中使用这个Shader比如在Image组件上需要注意几点材质设置将写好的Shader赋给一个材质球然后将这个材质球拖给Image组件的Material属性而不是修改其默认的材质。UGUI的Image默认使用UI/Default Shader。Masking与RectMask2D圆环Shader本身不处理遮罩。如果你的圆环只需要在某个矩形区域内显示可以将其父物体置于一个RectMask2D组件下这是UGUI自带的、效率很高的矩形遮罩方案。顶点数据UGUI的顶点数据流可能和标准的Mesh不同。但幸运的是UV0TEXCOORD0通常仍然是可用的并且对应着Image的原始UV。我们的Shader基本无需修改即可工作。Overdraw如果圆环区域是部分透明的比如边缘平滑区域且UI元素多层叠加可能会造成Overdraw过度绘制。在移动端需要注意UI的层级管理尽量减少半透明UI的重叠。6.4 进阶效果延伸掌握了基础圆环你可以轻松扩展出许多酷炫效果发光外晕在圆环外侧再叠加一层或多个使用更大半径、更低透明度的smoothstep计算出的环颜色可以设置为发光色如_RingColor的亮色版本并赋予一个随时间变化的强度或脉冲效果。纹理化圆环不再使用纯色_RingColor而是用ringMask作为UV的其中一个分量去采样一张纹理。例如可以采样一张环形渐变贴图或噪声图让圆环的颜色或透明度产生丰富的变化。扭曲效果在计算距离d之前对uv坐标进行扰动。比如加上一些基于时间的正弦波uv.x sin(uv.y * 10.0 _Time.y) * 0.02;。这样画出来的圆环边缘就会产生流动的扭曲感非常适合制作能量护盾或魔法效果。多圆环混合计算多个圆心、半径、颜色不同的圆环的ringMask然后使用不同的混合模式如max(ringMask1, ringMask2)取并集或ringMask1 * ringMask2取交集进行叠加可以创造出复杂的几何图案。这个基于UV坐标绘制动态圆环的项目就像学习绘画时练习画圆一样是Shader创作中最基础的训练但其中蕴含的坐标处理、距离场、平滑过渡和参数化思想是构建一切复杂视觉效果的基石。我自己的经验是每当需要实现一个新的屏幕空间效果时都会先试着用距离场和步进函数去描述它这往往能带来最清晰、最高效的实现思路。