news 2026/7/8 16:20:43

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.uvi.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, innerRadius+smoothWidth]这个区间内,从0平滑地过渡到1。这样,圆环的内外边缘都会有一个柔和的渐变,视觉效果更佳,也更能适应不同分辨率。

3. Shader框架搭建与属性定义

理解了数学原理,我们就可以开始动手编写Shader了。一个完整的Unity Shader通常包含Properties块、SubShader(包含Pass)等部分。我们将从最基础的Unlit Shader模板开始改造。

3.1 定义可调节的属性(Properties)

Shader与材质检视器交互的桥梁就是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 // 宽高比校正,防止圆变椭圆 }

参数详解与避坑指南:

  1. _Center:这里用Vector类型,但只使用其xy分量。默认(0.5,0.5)表示画布中心。如果你希望圆环可以偏移,就在这里调整。注意,UV坐标原点在左下角。
  2. _OuterRadius 与 _InnerRadius:使用Range属性,方便在检视器中拖拽滑块。最大值设为0.5是经验值,因为从画布中心到边缘的最大距离是0.5(当圆心在中心时)。如果你设置的圆心不在中心,可能需要根据情况调整最大范围,否则圆环可能画不出来。
  3. _SmoothWidth:这个值不宜过大,通常0.005到0.02之间视觉效果较好。过大会导致圆环“变胖”,视觉粗细不准确;过小则失去抗锯齿效果,边缘会有锯齿。
  4. _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)函数是线性插值,当t=0时返回at=1时返回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 = GetComponent<Renderer>(); 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 = GetComponent<Renderer>(); } void UpdateRingProperties() { _renderer.GetPropertyBlock(_propBlock); _propBlock.SetFloat(_propOuterRadius, currentOuterRadius); _propBlock.SetColor("_RingColor", targetColor); _renderer.SetPropertyBlock(_propBlock); }

5.2 实现圆环进度填充效果

上面例子是整体缩放圆环。更常见的需求是像进度条一样,圆环根据一个百分比值从无到有地填充。这需要一点技巧,因为圆环是360度的。

实现思路是:除了距离判断,我们还要增加一个角度判断。我们计算当前像素相对于圆心的角度(使用atan2函数),然后根据填充进度,决定在这个角度上的像素是否应该显示为圆环。

步骤分解:

  1. 计算角度float angle = atan2(uv.y, uv.x);atan2返回的是弧度值,范围在π之间。
  2. 将角度归一化:为了方便与进度(0到1)比较,我们将角度映射到0到1之间:float normalizedAngle = (angle + 3.14159265359) / (2.0 * 3.14159265359);。这样,角度0(正右方)对应0.0,逆时针旋转一周对应到1.0。
  3. 根据进度裁剪:如果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的过渡区间是固定的,在极端情况下采样不足。

解决方案:

  1. 使用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);
    这样,在远处或边缘很陡的地方,平滑区域会自动变宽,有效抑制锯齿。
  2. 开启硬件抗锯齿(MSAA):对于UI或后处理效果,确保摄像机和项目的抗锯齿设置是打开的。这是最直接有效的方法。
  3. 使用更高分辨率的渲染纹理:如果圆环是渲染到一张RenderTexture上再显示,可以考虑使用比屏幕分辨率更高的RenderTexture,然后缩放到屏幕大小,这是一种超采样抗锯齿(SSAA)的思路,比较消耗性能。

6.2 性能考量与移动端适配

这个Shader本身计算量很小(一次length,两次smoothstep,一些加减乘除),在移动端通常是安全的。但仍有优化空间:

  1. 避免在片段着色器中使用atan2atan2是一个相对昂贵的函数。如果不需要进度填充功能,就不要加它。即使需要,也可以考虑在顶点着色器中计算角度相关值并插值到片元,但这样精度会有所损失。
  2. 使用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);
    这样优化在需要处理大量片元时,能带来可观的性能提升。
  3. 精度选择:在移动端,可以尝试将float改为halffixed来提升性能。但对于uv坐标和距离计算,使用half可能因精度不足导致边缘出现 banding(条带)。建议先使用float,如果性能测试成为瓶颈,再尝试局部变量使用half

6.3 与UI系统的深度结合(UGUI)

如果你在Unity的UGUI系统中使用这个Shader,比如在Image组件上,需要注意几点:

  1. 材质设置:将写好的Shader赋给一个材质球,然后将这个材质球拖给Image组件的Material属性,而不是修改其默认的材质。UGUI的Image默认使用UI/Default Shader。
  2. Masking与RectMask2D:圆环Shader本身不处理遮罩。如果你的圆环只需要在某个矩形区域内显示,可以将其父物体置于一个RectMask2D组件下,这是UGUI自带的、效率很高的矩形遮罩方案。
  3. 顶点数据:UGUI的顶点数据流可能和标准的Mesh不同。但幸运的是,UV0(TEXCOORD0)通常仍然是可用的,并且对应着Image的原始UV。我们的Shader基本无需修改即可工作。
  4. Overdraw:如果圆环区域是部分透明的(比如边缘平滑区域),且UI元素多层叠加,可能会造成Overdraw(过度绘制)。在移动端需要注意UI的层级管理,尽量减少半透明UI的重叠。

6.4 进阶效果延伸

掌握了基础圆环,你可以轻松扩展出许多酷炫效果:

  1. 发光外晕:在圆环外侧再叠加一层或多个使用更大半径、更低透明度的smoothstep计算出的环,颜色可以设置为发光色(如_RingColor的亮色版本),并赋予一个随时间变化的强度或脉冲效果。
  2. 纹理化圆环:不再使用纯色_RingColor,而是用ringMask作为UV的其中一个分量去采样一张纹理。例如,可以采样一张环形渐变贴图或噪声图,让圆环的颜色或透明度产生丰富的变化。
  3. 扭曲效果:在计算距离d之前,对uv坐标进行扰动。比如加上一些基于时间的正弦波:uv.x += sin(uv.y * 10.0 + _Time.y) * 0.02;。这样画出来的圆环边缘就会产生流动的扭曲感,非常适合制作能量护盾或魔法效果。
  4. 多圆环混合:计算多个圆心、半径、颜色不同的圆环的ringMask,然后使用不同的混合模式(如max(ringMask1, ringMask2)取并集,或ringMask1 * ringMask2取交集)进行叠加,可以创造出复杂的几何图案。

这个基于UV坐标绘制动态圆环的项目,就像学习绘画时练习画圆一样,是Shader创作中最基础的训练,但其中蕴含的坐标处理、距离场、平滑过渡和参数化思想,是构建一切复杂视觉效果的基石。我自己的经验是,每当需要实现一个新的屏幕空间效果时,都会先试着用距离场和步进函数去描述它,这往往能带来最清晰、最高效的实现思路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/8 16:20:23

C# WinForms实现逼真翻书动画:贝塞尔曲线与光影模拟详解

1. 项目概述与核心价值 最近在做一个电子阅读器的原型&#xff0c;需要实现一个流畅、自然的翻页动画效果。网上找了一圈&#xff0c;发现很多实现要么过于简单&#xff08;比如简单的图片切换&#xff09;&#xff0c;要么过于复杂&#xff08;直接上3D引擎&#xff09;。后来…

作者头像 李华
网站建设 2026/7/8 16:18:31

召回率-精度权衡:RAG 评测中的 NDCG 与 MRR 指标如何指导参数调优

召回率-精度权衡&#xff1a;RAG 评测中的 NDCG 与 MRR 指标如何指导参数调优 一、深度引言与场景痛点 大家好&#xff0c;我是赵咕咕。 你的 RAG 系统能返回相关文档了&#xff0c;但总觉得不够好。调大了 top_k&#xff0c;无关文档也混进来了&#xff1b;调小了 top_k&am…

作者头像 李华
网站建设 2026/7/8 16:17:24

3个核心技巧彻底掌握植物大战僵尸修改器

3个核心技巧彻底掌握植物大战僵尸修改器 【免费下载链接】pvztoolkit 植物大战僵尸 PC 版综合修改器 项目地址: https://gitcode.com/gh_mirrors/pv/pvztoolkit 你是否曾经在植物大战僵尸游戏中卡关&#xff0c;为阳光不足而烦恼&#xff1f;或者想要尝试一些有趣的玩法…

作者头像 李华
网站建设 2026/7/8 16:17:13

惠普OMEN游戏本终极性能解放指南:OmenSuperHub完全掌控手册

惠普OMEN游戏本终极性能解放指南&#xff1a;OmenSuperHub完全掌控手册 【免费下载链接】OmenSuperHub Control Omen laptop performance, fan speeds, and keyboard lighting, and unlock power limits. 项目地址: https://gitcode.com/gh_mirrors/om/OmenSuperHub 还在…

作者头像 李华