三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity ShaderGraph UV中心缩放原理与实战:从数学公式到节点实现

Unity ShaderGraph UV中心缩放原理与实战:从数学公式到节点实现

1. 项目概述:从“左下角”到“正中心”的思维跃迁

在Unity的Shader开发,尤其是使用ShaderGraph进行可视化编程时,处理纹理UV的缩放是一个高频操作。乍一看,这似乎是个简单问题——不就是调整Tiling值吗?但当你真正动手,想让一张贴图以其自身中心点进行缩放,而不是默认的UV空间原点(左下角(0,0)点)时,就会发现事情没那么简单。很多刚接触ShaderGraph的开发者,包括一些有经验的美术同学,都会在这里卡壳,做出来的效果总是“跑偏”,纹理一缩放就朝着屏幕角落飞走了。这背后,其实是对UV空间坐标系、纹理采样原理以及变换顺序理解不透彻导致的。

我自己在项目里就遇到过好几次,比如做一个UI界面的高亮呼吸效果,或者一个技能图标被选中时的脉冲放大,如果缩放中心不对,视觉效果就会非常廉价和怪异。所以,今天我们就来彻底拆解“UV的中心缩放”这个需求。这不仅仅是连接几个节点,更是理解Shader底层逻辑的一次绝佳实践。无论你是想实现一个精致的UI动效,还是一个需要精准控制纹理变化的特效,掌握这个技巧都能让你的作品质感提升一个档次。接下来,我会从最基础的UV概念讲起,一步步推导出中心缩放的完整方案,并分享几个实战中容易踩的坑和性能优化的小技巧。

2. 核心原理:UV空间、采样器与变换的数学本质

要理解中心缩放,我们必须先抛开ShaderGraph的节点,回到最根本的图形学概念上。很多人操作失误,是因为对“我们在操作什么”这件事本身是模糊的。

2.1 UV坐标系与纹理采样器

UV坐标,简单来说,就是一张2D贴图上的“地址系统”。U代表水平方向,V代表垂直方向,它们的取值范围通常被归一化到[0, 1]区间。左下角是(0,0),右上角是(1,1)。当我们把一个UV坐标喂给Texture2D Sample节点时,采样器就会根据这个地址,去纹理上取出对应的颜色。

在ShaderGraph里,当我们从UV节点拉出一根线,这个节点输出的就是当前片元(可以粗略理解为屏幕上的一个像素点)所对应的、在模型表面的UV坐标。默认情况下,这个坐标是直接映射的。如果我们不做任何处理,一个覆盖了(0,0)到(1,1)UV范围的四边形,就会完整地显示整张纹理。

2.2 Tiling与Offset的本质是线性变换

ShaderGraph中的Tiling And Offset节点,或者代码Shader中的tex2D(_MainTex, uv * _Tiling + _Offset),其本质是对输入的UV坐标施加了一个线性变换。

  • Tiling(缩放)uv * _Tiling。当_Tiling为(2,2)时,UV坐标范围从[0,1]被放大到[0,2]。这意味着采样器会在纹理上“行走”更长的距离,由于纹理默认是重复(Repeat)寻址模式,结果就是纹理在模型表面被缩小显示了(因为原来一个单位的UV空间现在要容纳纹理重复两次)。反过来,_Tiling为(0.5, 0.5)时,UV坐标范围被压缩到[0,0.5],采样器只读取了纹理的左下四分之一区域,并将其拉伸到整个模型表面,看起来就是放大了。
  • Offset(偏移)uv + _Offset。这很简单,就是将整个采样网格进行平移。

这里有一个至关重要的思维定势需要打破:我们通常说“缩放纹理”,但Shader中操作的对象永远是UV坐标,而不是纹理本身。缩放UV坐标,会导致采样行为的变化,从而在视觉上产生纹理缩放的效果。所有的变换(缩放、旋转、平移)都是作用在UV坐标这个“寻址指针”上。

2.3 默认缩放中心为何是原点(0,0)?

这是线性变换的数学性质决定的。缩放变换的公式uv' = uv * scale,其缩放中心(不动点)是坐标系原点(0,0)。因为当uv=(0,0)时,无论scale是多少,uv'始终等于(0,0)。你可以想象用两根手指捏住UV空间坐标系的(0,0)点,然后向外拉或向内推,整个坐标网格都以这个被捏住的点为中心进行缩放。

所以,要实现以任意点(比如中心点(0.5, 0.5))为中心的缩放,我们的思路就不能是简单的uv * scale。我们需要一个“变换三部曲”:

  1. 平移:将整个坐标系平移,使得我们期望的缩放中心(例如(0.5,0.5))移动到原点(0,0)。对于中心点,就是uv - float2(0.5, 0.5)
  2. 缩放:在原点进行缩放操作。(uv - 0.5) * scale
  3. 平移回去:将坐标系平移回原来的位置。(uv - 0.5) * scale + 0.5

合并一下公式:uv' = (uv - center) * scale + center。当center为(0.5, 0.5)时,就是uv' = (uv - 0.5) * scale + 0.5

实操心得:把这个公式刻在脑子里。它不仅是中心缩放的核心,也是以任意点进行旋转、以及组合复杂变换的基础。在ShaderGraph中,我们就是通过节点连线来“计算”出这个公式。

3. ShaderGraph节点级实现拆解

理解了原理,我们到ShaderGraph里动手实现。我会用两种最常用的方法来构建这个功能,并对比它们的优劣。

3.1 方法一:使用“Tiling And Offset”节点(经典直观法)

这是最贴近上述数学公式的实现方式,非常利于理解。

步骤分解:

  1. 准备基础节点

    • 拉出一个UV节点,选择Tiling and Offset通道(通常就是默认的)。
    • 创建两个Vector2类型的属性(或直接使用Vector2节点),分别命名为_Tiling(缩放)和_Center(中心点,默认值设为(0.5, 0.5))。
    • 拉出一个Tiling And Offset节点。
  2. 实现变换公式

    • 我们需要计算(UV - _Center) * _Tiling + _Center
    • 首先,计算UV - _Center:使用一个Subtract(减法)节点,A端口连UV,B端口连_Center
    • 然后,计算缩放:使用一个Multiply(乘法)节点,A端口连上一步的减法结果,B端口连_Tiling
    • 最后,加回中心点:使用一个Add(加法)节点,A端口连上一步的乘法结果,B端口连_Center
    • 将最终Add节点的输出,连接到Tiling And Offset节点的UV输入端。而Tiling And Offset节点本身的Tiling和Offset输入端口留空不连(或者都设为(1,0))。我们只是借用这个节点来保持功能一致性,实际的变换已由前面的节点网络完成。
  3. 连接采样器

    • Tiling And Offset节点的输出,连接到Texture2D Sample节点的UV输入端。
    • 关键一步:务必取消勾选Texture2D节点属性面板中的Use Tiling and Offset选项。因为我们已经在外部实现了完整的UV变换,如果这里再启用,就会发生两次变换,导致错误。

节点网络示意图(文字描述):

[UV] --> [Subtract] (B: [_Center]) | V [Subtract Result] --> [Multiply] (B: [_Tiling]) | V [Multiply Result] --> [Add] (B: [_Center]) | V [Add Result] --> [Tiling And Offset.UV] | V [Tiling And Offset] --> [Texture2D Sample.UV] (且关闭Sample节点的Use Tiling and Offset)

优点

  • 逻辑清晰,完全对应数学公式,易于理解和调试。
  • _Center属性可以动态调整,轻松实现以任意点为中心的缩放。

缺点

  • 节点数量稍多,图面看起来有点复杂。
  • 对于仅需要纯中心缩放的场景,有更简洁的写法。

3.2 方法二:直接计算偏移量(优化简洁法)

如果我们确定缩放中心就是纹理中心(0.5, 0.5),并且Tiling值_Tiling的x和y分量相等(即等比例缩放),我们可以推导出一个优化公式。

由公式uv' = (uv - 0.5) * tiling + 0.5,可以展开为uv' = uv * tiling + 0.5 * (1 - tiling)

观察这个新公式uv * tiling + offset,其中offset = 0.5 * (1 - tiling)。这正是Tiling And Offset节点的标准输入形式!

步骤分解:

  1. 准备节点

    • UV节点。
    • Vector1Vector2属性_Tiling(如果等比例缩放,用Vector1就够了)。
    • Tiling And Offset节点。
  2. 计算动态Offset

    • 我们需要计算Offset = 0.5 * (1 - _Tiling)
    • 使用一个Subtract节点,计算1 - _Tiling。可以创建一个Float常量1,或者直接用Vector1节点输入1。
    • 使用一个Multiply节点,将上一步的结果乘以0.5
  3. 连接节点

    • _Tiling同时连接到Tiling And Offset节点的Tiling端口(如果是Vector1,需要用到Combine节点转换成Vector2,或直接复制到x和y)。
    • 将计算好的Offset值连接到Tiling And Offset节点的Offset端口。
    • UV节点连接到Tiling And Offset节点的UV端口。
    • 输出连接到纹理采样器,同样关闭采样器的Use Tiling and Offset

节点网络示意图:

// 假设_Tiling是Vector1,表示统一缩放系数 [Float(1)] --> [Subtract] (B: [_Tiling]) | V [1-Tiling] --> [Multiply] (B: [Float(0.5)]) | V [Offset] ------> [Tiling And Offset.Offset] | [UV] ----------------> [Tiling And Offset.UV] | [_Tiling] -----------> [Tiling And Offset.Tiling] (需转换为Vector2) | V [Tiling And Offset] --> [Texture2D Sample.UV]

优点

  • 节点更精简,只用了1个减法、1个乘法和1个Tiling And Offset节点。
  • 直接利用了Tiling And Offset节点的两个功能端口,意图明确。

缺点

  • 仅适用于以固定点(0.5,0.5)为中心的缩放。
  • 当Tiling的x和y值不同时,计算会稍复杂,Offset需要分别计算x和y分量:Offset.x = 0.5 * (1 - Tiling.x),Offset.y = 0.5 * (1 - Tiling.y)

注意事项:无论哪种方法,关闭纹理采样器的“Use Tiling and Offset”是必须的。这是新手最容易忽略的一步,会导致缩放和偏移效果加倍,完全失控。这个选项的本意是让你使用材质球面板上的Main Tex的Tiling/Offset参数,但当我们自己在ShaderGraph中计算时,就必须禁用它,否则两套系统会冲突。

4. 在URP渲染管线中的实战应用与封装

在实际项目中,我们很少会为每一个需要中心缩放的效果都从头搭建节点网络。封装成可复用的Sub-graph(子图)或者Custom Function节点,是提升开发效率的关键。

4.1 创建“UV Scale Around Point”子图

  1. 创建Sub-graph:在ShaderGraph中右键 ->Create->Sub-graph,命名为UVScaleAroundPoint
  2. 定义输入端口
    • UV(Vector2): 默认连上UV节点。
    • Scale(Vector2): 缩放系数,默认值(1,1)。
    • Pivot(Vector2): 缩放中心点,默认值(0.5, 0.5)。
  3. 定义输出端口
    • Out(Vector2): 变换后的UV坐标。
  4. 内部实现:在子图内部,按照3.1节方法一的节点连接方式搭建网络。将内部的UV_Tiling_Center分别替换为子图的输入端口UVScalePivot
  5. 使用:在主ShaderGraph中,像使用普通节点一样拖入这个UVScaleAroundPoint子图,连接输入,输出到采样器,一气呵成。

这样做的好处是:

  • 一次构建,到处使用:UI高亮、技能图标、背景纹理动画等都可以调用。
  • 参数集中管理:缩放中心和比例在子图输入口清晰可见,易于调节。
  • 维护方便:如果需要优化或修改算法,只需改动子图内部,所有使用它的Shader自动更新。

4.2 结合Time节点制作动画

静态的缩放意义不大,中心缩放通常用于动态效果。这就需要引入Time节点。

制作一个呼吸式缩放效果:

  1. 生成周期性的缩放系数

    • 拉出Time节点,使用Sine(正弦)或Cosine(余弦)节点。公式可以是:scale = 1.0 + sin(Time * frequency) * amplitude
    • 例如:Scale = 1.0 + Sin(Time * 3.14159) * 0.2。这会产生一个以1为中心,在0.8到1.2之间周期性变化的值。
    • 使用Combine节点将单个的scale值组合成Vector2(scale, scale),以保证等比例缩放。
  2. 驱动UV变换

    • 将上面计算出的动态ScaleVector2,输入到我们封装好的UVScaleAroundPoint子图的Scale端口。
    • Pivot端口可以设为常量(0.5, 0.5),也可以连接另一个参数,实现动态中心点移动的复杂效果。
  3. 进阶:非均匀缩放与旋转

    • 中心缩放的原理同样适用于旋转。旋转的公式是uv' = rotate(uv - center, angle) + center,其中rotate是旋转矩阵运算。在ShaderGraph中,可以使用Rotate节点,它本身就包含了Center输入端口,完美支持中心旋转。
    • 你可以将中心缩放中心旋转的子图串联或并联使用,创造出更丰富的纹理动画。例如,先以中心点缩放,再以同一个中心点旋转。

4.3 性能考量与优化建议

在移动端或需要大量实例化的场景中,Shader的每一点性能都至关重要。

  1. 精度选择:对于UV计算,float精度通常足够。在ShaderGraph中,确保你的Vector端口没有不必要的Half精度转换(除非你明确需要)。对于简单的缩放动画,Half精度可能也够用,但用float最保险。

  2. 计算复用:如果你的Shader中,同一套UV变换要用于多张纹理采样(如Albedo、Normal、Roughness),务必只计算一次变换后的UV,然后将其输出连接到多个采样器。绝对不要为每张纹理都复制一套完整的变换节点网络。

  3. 避免在片元着色器中进行复杂分支判断:我们的中心缩放公式是统一的数学运算,没有if-else分支,这对GPU的SIMD架构非常友好,性能损耗极低。

  4. Sub-graph的代价:使用Sub-graph在逻辑上很清晰,但ShaderGraph在编译时可能会将其展开。对于极其简单的操作(比如只有一个公式),有时直接在主图中用几个基础节点实现,可能比调用一个Sub-graph的指令更少。但这属于微优化,在绝大多数情况下,可读性和可维护性带来的好处远大于这点性能差异。建议先追求清晰正确的实现,在性能分析(Profiler)确实指出这里是瓶颈时再考虑内联优化。

5. 常见问题排查与深度调试技巧

即使理解了原理,实操中还是会遇到各种诡异的问题。下面是我总结的几个典型“坑”及其解决方案。

5.1 纹理采样错误或出现“镜像”、“重复”边缘

现象:缩放后,纹理边缘出现不正常的颜色条纹、镜像内容或重复的纹理块。原因与排查

  1. 纹理Wrap Mode设置:这是最常见的原因。在Unity中导入纹理时,其Wrap Mode默认是Repeat。当我们的UV坐标经过(uv-0.5)*scale+0.5变换后,如果scale > 1(视觉上是缩小纹理),变换后的UV值域可能仍在[0,1]内,没问题。但如果scale < 1(视觉上是放大纹理),变换后的UV值域会小于[0,1](例如从0.25到0.75)。这本身也没问题。然而,如果在计算过程中有误差,或者后续叠加了其他效果导致UV值略微超出[0,1],在Repeat模式下,超出的部分就会采样纹理的另一边,造成“接缝”或重复。
  2. 精度问题:Shader计算存在浮点数精度误差。一个理论上应该是0.999999的值,可能变成1.000001。

解决方案

  • 针对问题1:如果你的效果不需要纹理重复(比如UI图标、角色贴图),将纹理的Wrap Mode改为Clamp。这样,UV值超出[0,1]范围时,会自动钳制到边界颜色,通常能消除奇怪的边缘。
  • 针对问题2:在关键计算后,可以添加一个轻微的钳制。虽然不推荐随意钳制UV,因为它可能破坏数学连续性,但对于最终传入采样器的UV,可以做一个安全限制:uv = clamp(uv, -0.001, 1.001)。这个范围略大于[0,1],是为了避免因精度问题在边界处采样到错误Mipmap级别,同时又不会实际采样到重复区域。

5.2 缩放中心点“飘忽不定”或与预期不符

现象:明明设置了中心点(0.5,0.5),但缩放时感觉中心在动,或者不在图像正中央。原因与排查

  1. 模型UV本身非标准:你使用的模型(比如一个复杂的角色或场景道具)其UV展开可能不是规整的从(0,0)到(1,1)。我们的计算是基于UV空间的(0.5,0.5)是纹理中心。如果模型UV只占用了纹理图集的一小块,那么这个“中心点”在纹理上的实际位置就不是图像内容的中心了。
  2. 输入的中心点参数错误:检查连接Pivot端口的数值。确保它是Vector2类型,并且值在预期的范围内。
  3. 变换顺序冲突:如果你在中心缩放节点之后,又连接了其他会修改UV的节点(比如另一个平移或扭曲),那么后续变换会改变已经校正好的中心。

解决方案

  • 针对问题1:对于使用UV图集的模型,要实现“纹理内容中心”缩放非常复杂,通常需要额外的纹理(如一张存储了内容区域信息的遮罩图)来动态计算真实中心。对于UI Image或全屏Quad等UV规整的模型,则不存在此问题。在动手前,务必确认你的模型UV是否符合预期。可以在Shader中先用一个简单的颜色输出UV值(R通道输出U,G通道输出V)来可视化检查。
  • 针对问题2和3:使用ShaderGraph的Preview预览功能,将最终变换后的UV直接输出为颜色,观察其变化是否规律。或者,分阶段预览:先预览(uv - center)的结果,再预览缩放后的结果,最后预览加回center的结果。通过分段调试,能快速定位问题节点。

5.3 与材质球Inspector面板上的Tiling/Offset冲突

现象:在ShaderGraph里调好了,但运行时通过材质球面板修改Tiling/Offset,效果乱了,或者没反应。原因:我们之前提到要关闭采样器的Use Tiling and Offset。这导致材质球面板上的_MainTex_ST(ST代表Scale-Offset)参数不再起作用。我们的ShaderGraph接管了所有UV变换。

解决方案

  • 方案A(推荐,控制权在Graph):如果希望所有控制都在ShaderGraph内部,那么就在Graph中创建对应的Vector2属性(如_MyTiling,_MyOffset)来暴露给材质面板。这样更清晰,避免混淆。
  • 方案B(兼容原有参数):如果你希望保留使用标准_MainTex_ST的习惯,可以在ShaderGraph中采样_MainTex_ST这个内置变量。它是一个Vector4,其中xy是Tiling,zw是Offset。然后,将我们的中心缩放计算融入到这个标准变换中。公式变为:uv' = ((uv - center) * scale + center) * _MainTex_ST.xy + _MainTex_ST.zw这相当于先做中心缩放,再做一次标准的Tiling和Offset。这种方式的控制逻辑稍显复杂,但保持了与标准Unity Shader的兼容性。

5.4 性能热点识别与优化

如果你在Profiler中发现GPU耗时异常高,并且定位到使用了此Shader的材质:

  1. 检查计算频率:确保UV变换计算在顶点着色器阶段完成。在ShaderGraph中,默认的UV节点输出的是经过插值后的片元UV。对于网格不变形的简单动画(如全屏、UI),可以将整个中心缩放网络放在一个Custom Function节点中,并将该节点的Node Setting中的Type设置为Vertex。这样变换只在每个顶点计算一次,然后在片元间插值,性能远优于每个片元都计算一次。不过,对于变形剧烈的效果,可能仍需在片元着色器中计算。
  2. 简化公式:如果确定是等比例缩放且中心固定(0.5,0.5),优先使用3.2节的优化方法,它使用的运算节点更少。
  3. 减少属性变化:频繁通过脚本修改_Tiling_Center属性会导致Shader变体切换或常量缓冲区更新,带来开销。尽量将动画逻辑写在Shader内部(用Time驱动),或者批量更新。

6. 扩展应用:超越简单的纹理缩放

掌握了UV中心缩放,你就掌握了2D纹理空间变换的钥匙。它可以衍生出许多高级和有趣的应用。

6.1 实现动态遮罩与扫描效果

结合一张渐变贴图(Ramp)或噪声贴图,以中心缩放的方式驱动遮罩范围。

  • 原理:将计算出的、经过中心缩放的UV的某个分量(比如长度length(uv - 0.5))作为采样渐变贴图的坐标。
  • 示例:做一个圆形扫描雷达效果。scale从0逐渐增加到1。用length(uv - 0.5)与这个动态的scale值比较,小于scale的区域显示雷达纹理,大于的区域隐藏。由于缩放中心固定,这个圆形扫描波就会从中心向外扩散。

6.2 构建伪3D旋转与翻页效果

通过分别控制U方向和V方向的缩放中心与比例,可以模拟出一些伪3D效果。

  • 卡牌翻页:将缩放中心设在卡牌的一条边上(如Pivot = (0.5, 0.0))。然后让Scale.y从1逐渐变为0,同时可能配合Scale.x的轻微变化(如从1到1.2再回到1),就能模拟出卡牌沿底边向上翻起的效果。虽然这不是真正的3D旋转,但在2D UI中足以以假乱真。

6.3 与屏幕后处理结合

在URP的Renderer Feature中编写全屏后处理Shader时,这个技巧同样适用。

  • 场景:做一个全屏的“镜头中心缩放”失真效果,模拟角色受伤或爆炸冲击波。
  • 实现:在后处理Shader中,屏幕UV本身就是规整的(0,0)到(1,1)。直接应用uv' = (uv - 0.5) * scale + 0.5公式。当scale短暂大于1时,画面会以屏幕中心为原点“收缩”(因为UV被放大,采样更中心的区域);当scale短暂小于1时,画面会“扩张”(采样到更多边缘区域,可能还需要处理边缘填充问题)。这比简单的屏幕震动更有视觉冲击力。

6.4 在UI Shader中创造高级交互反馈

这是中心缩放最直接、最实用的应用场景之一。

  • 按钮高亮:鼠标悬停时,按钮背景纹理以中心为原点轻微放大(scale略小于1,如0.95),产生“吸入”感。
  • 按下效果:鼠标按下时,快速缩小再弹回(scale先变为0.9,再通过弹性动画回到1),模拟物理按压。
  • 冷却倒计时:一个圆形的技能图标,其遮罩根据冷却时间比例,以图标中心进行缩放(从scale=0scale=1),形成平滑的圆形填充效果,比简单的扇形填充更现代。

实现这些UI效果的关键在于,将scale参数与C#脚本中的动画曲线(AnimationCurve)或插值函数(如Mathf.Lerp,DOTween)绑定,驱动其变化。Shader只负责接收这个scale值并执行变换运算,实现逻辑与表现的分离。

最后,我个人在实际项目中的体会是,UV操作是Shader的基石之一。中心缩放这个看似简单的需求,是检验你对UV空间理解是否透彻的试金石。把它吃透,不仅能解决眼前的问题,更能为你打开一扇门,去理解和实现更复杂的纹理动画、扭曲效果和动态遮罩。下次当你再遇到纹理变换的需求时,不妨先在心里默念一遍那个万能公式:uv' = (uv - center) * scale + center,然后从容地打开ShaderGraph,开始你的创作。

← 返回列表