1. 项目概述从静态到动态的纹理魔法在Unity里做特效或者场景交互想让一片水面流动起来或者让火焰燃烧得更真实新手和老手的第一反应可能都是去动画编辑器里K帧或者写脚本去控制材质参数。但如果你想让性能开销降到最低同时实现丝滑无缝的循环动画那么直接在Shader里对纹理动手脚几乎是唯一的“正道”。今天要聊的就是利用Shader中最基础、也最强大的两个纹理坐标变换属性——Tilling平铺和Offset偏移来实现从简单的消融到复杂的流体模拟等一系列纹理动画效果。这听起来可能有点“老生常谈”毕竟Tilling和Offset是Shader入门就会接触的概念。但正是这种基础操作组合起来却能产生意想不到的化学效应。很多炫酷的“体积光”、“全息投影”、“能量护盾”效果其核心动画驱动可能就藏在这两个参数的动态变化里。理解并玩转它们你就能摆脱对复杂粒子系统和序列帧动画的过度依赖用极低的性能成本换取高质量的视觉反馈。这篇文章会从原理拆解开始一步步带你实现几个经典案例并附上可以直接“抄作业”的完整Shader代码无论是想优化项目性能的TA还是想深入理解Shader机制的程序员都能从中找到实用的干货。2. 核心原理纹理坐标、Tilling与Offset的数学本质要玩转纹理动画首先得彻底明白我们在操作什么。这不是玄学而是清晰的数学变换。2.1 纹理坐标系统UV是什么在3D图形学中模型表面的每个顶点除了位置Position、法线Normal等信息外通常还包含一对或多对纹理坐标俗称UV坐标。你可以把UV坐标想象成模型表面展开后对应到一张2D图片上的“地址”。U代表水平方向通常0在最左1在最右V代表垂直方向通常0在最下1在最上。Shader采样纹理时就是根据这个UV“地址”去纹理图片上取对应的颜色值。一个常见的误解是UV坐标的范围只能是0到1。实际上它可以超出这个范围而Tilling和Offset正是用来处理和解释这个“超出”部分的行为的。2.2 Tilling平铺的缩放本质在Shader中Tilling通常是一个二维向量(TilingX, TilingY)。它的作用是对原始的UV坐标进行乘法缩放。// 在Shader代码中的常见操作 float2 tiledUV uv * _MainTex_ST.xy; // _MainTex_ST.xy 存储了Tilling值计算过程假设原始UV是(0.5, 0.5)图片中心Tilling设置为(2, 2)。那么变换后的UV就变成了(0.5*2, 0.5*2) (1.0, 1.0)即原纹理的右下角。视觉影响当Tilling值大于1时相当于把纹理“缩小”了因为原本0-1的UV范围现在只能覆盖纹理的一部分。为了让纹理填满模型表面纹理就会在超出0-1的UV区域重复出现形成平铺效果。反之小于1时纹理被“放大”但通常配合特定的Wrap Mode环绕模式在0-1范围内只显示纹理的一部分边缘可能被拉伸或截断。关键点Tilling改变的是UV坐标本身进而影响采样位置。它决定了“一个模型表面单位面积内纹理重复了多少次”。2.3 Offset偏移的平移本质Offset同样是一个二维向量(OffsetX, OffsetY)。它的作用是对缩放后的UV坐标进行加法平移。// 在Shader代码中的常见操作通常与Tilling一起使用 float2 finalUV uv * _MainTex_ST.xy _MainTex_ST.zw; // _MainTex_ST.zw 存储了Offset值计算过程接上例缩放后的UV是(1.0, 1.0)Offset设置为(0.2, -0.1)。那么最终UV是(1.00.2, 1.0-0.1) (1.2, 0.9)。视觉影响Offset会让整个纹理采样“窗口”发生移动。比如OffsetX为正纹理就向右滚动为负则向左滚动。这是实现流动效果最直接的手段。关键点Offset不改变纹理的密度那是Tilling的事它只改变纹理的“起始位置”。注意在Unity的标准Shader如Standard Standard Surface中纹理的Tilling和Offset通常被封装在一个四维向量_MainTex_ST中ST代表Scale-平移但实际xy是Tillingzw是Offset。这是Unity为了材质面板的易用性做的封装理解其本质是(TilingX, TilingY, OffsetX, OffsetY)至关重要。2.4 纹理环绕模式Wrap Mode的角色Tilling和Offset产生的效果严重依赖于纹理导入设置中的Wrap Mode。Repeat重复这是最常用的模式。当UV坐标超出[0,1]范围时纹理会像瓷砖一样无限重复。这是实现无缝平铺和循环流动动画的基础。例如UV为1.2时实际采样的是纹理UV为0.2的位置即frac(1.2) 0.2。Clamp钳制当UV超出[0,1]范围时会被强制钳制在0或1。这会导致纹理边缘像素被拉伸。在动画中通常会产生僵硬的不连续边界除非特意追求这种效果否则在流动动画中应避免使用。理解了这些我们就掌握了制作纹理动画的“画笔”和“画布”。接下来我们看看如何用这支画笔画出不同的“作品”。3. 实战案例一基础滚动与流光效果这是最直接的应用通过随时间变化的Offset来模拟运动。3.1 单向滚动如横幅、传送带目标是让纹理沿着U方向水平匀速滚动。Shader核心代码块Shader Custom/ScrollingTexture { Properties { _MainTex (Texture, 2D) white {} _ScrollSpeed (Scroll Speed, Float) 1.0 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; // 自动关联材质面板的Tiling/Offset float _ScrollSpeed; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 核心在顶点着色器或片元着色器中计算动态UV o.uv TRANSFORM_TEX(v.uv, _MainTex); // 先应用材质面板的静态Tiling/Offset // 叠加基于时间的偏移。_Time.y是自加载以来的时间秒 o.uv.x _Time.y * _ScrollSpeed; return o; } fixed4 frag (v2f i) : SV_Target { // 直接采样动态计算后的UV fixed4 col tex2D(_MainTex, i.uv); return col; } ENDCG } } }实现解析与注意事项TRANSFORM_TEX宏这个宏简化了uv * _MainTex_ST.xy _MainTex_ST.zw的过程它首先应用了你在材质球Inspector面板上设置的Tilling和Offset。这是一个好习惯因为它允许美术同学在材质层面进行基础的静态调整。时间变量_TimeUnity提供了内置时间变量_Time它是一个float4各分量如下_Time.y(_Time[1]): 自场景加载以来的时间秒适用于大多数连续动画。_Time.x(_Time[0]): 时间的1/20倍变化较慢。_Time.z(_Time[2]): 时间的2倍变化较快。_Time.w(_Time[3]): 时间的3倍变化更快。 根据动画快慢需求选择。这里用_Time.y保证匀速。计算位置选择我们在顶点着色器vert函数中修改UV。这意味着动态偏移的计算每顶点执行一次然后由光栅化插值到每个片元。这比在片元着色器frag函数中计算性能更高因为片元执行次数远多于顶点。只要动画不是需要基于屏幕空间等每像素计算都应放在顶点阶段。速度参数_ScrollSpeed将其暴露为属性方便调节滚动快慢。值为正时向右滚动为负时向左。实操心得对于简单的单向滚动将计算放在顶点着色器性能最优。但如果你需要让滚动速度与纹理颜色或另一个纹理采样结果相关联例如水流中间快两边慢则必须在片元着色器中计算UV。3.2 双向滚动与对角线流动让纹理同时沿U和V方向运动可以模拟更自然的流动如斜向的流光、岩浆的涌动。只需修改UV计算部分// 在Properties中添加 _VScrollSpeed (Vertical Scroll Speed, Float) 0.0 // 在顶点着色器中 o.uv TRANSFORM_TEX(v.uv, _MainTex); o.uv.x _Time.y * _ScrollSpeed; o.uv.y _Time.y * _VScrollSpeed;通过独立控制_ScrollSpeed和_VScrollSpeed你可以实现水平、垂直或任意方向的直线流动。3.3 结合Tilling制作无缝小动画火焰、云层单纯的滚动可能显得单调。结合Tilling我们可以让一小块纹理如一团火焰、一朵云在模型表面重复运动形成局部动画循环。思路将火焰纹理的Tilling设置得较大如(4,4)这样一小块火焰纹理就会在模型上平铺成许多份。对这些平铺的所有小火焰施加一个全局的、缓慢的Offset。视觉上就像是无数团相同的火焰在缓慢飘动。虽然每团火焰动画完全一样缺乏变化但得益于平铺整体看起来是连续的运动场。进阶技巧 - 添加随机性为了让每团“火焰”运动略有不同我们需要为每个平铺的格子引入一个随机的起始偏移。这需要用到每个格子的ID。一种常见技巧是使用世界坐标或物体坐标来生成伪随机数。// 在片元着色器中 float2 worldUV i.worldPos.xz * 0.1; // 使用世界XZ坐标缩放系数控制随机粒度 float2 tileID floor(uv * _TilingSize); // 假设_TilingSize是平铺倍数 float randomOffset frac(sin(dot(tileID, float2(12.9898, 78.233))) * 43758.5453); // 简单的伪随机函数 float2 finalUV uv; finalUV.x _Time.y * _ScrollSpeed randomOffset * 0.5; // 添加随机偏移量 fixed4 col tex2D(_MainTex, finalUV);这种方法计算量稍大但能显著提升视觉效果的真实感避免出现明显的重复图案。4. 实战案例二动态消融与溶解效果消融效果常见于角色死亡、物体被腐蚀等场景。其核心是根据一个随时间变化的阈值动态地“丢弃”片元。而Offset可以用来控制这个阈值变化的“方向”或“波前”。4.1 基于噪声纹理的消融我们通常使用一张噪声纹理Noise Texture作为消融的图案。噪声纹理的特点是灰度值变化随机且连续。实现步骤采样噪声值用模型的UV可能经过平铺和偏移去采样一张噪声纹理得到一个0-1之间的随机值。这个值代表了该片元位置的“固有溶解顺序”。设定溶解阈值创建一个从0到1随时间线性增加的_DissolveThreshold。比较与裁剪如果噪声采样值小于当前阈值则该片元被“溶解”掉。我们可以使用clip()函数直接丢弃该片元或者将其渲染为透明。利用Offset控制溶解方向如果我们希望溶解从一侧开始向另一侧推进可以不对噪声纹理的UV做动态偏移而是对阈值应用一个与位置相关的偏移。更直观的方法是将噪声纹理的采样UV的某一分量如U加上一个基于世界空间位置或模型空间位置的权重再减去时间。这样阈值相同的区域就会形成一条移动的“前线”。Shader核心代码块片元着色器部分Properties { _MainTex (Albedo (RGB), 2D) white {} _NoiseTex (Dissolve Noise, 2D) white {} _DissolveThreshold (Dissolve Threshold, Range(0,1)) 0.0 _DissolveEdgeWidth (Edge Width, Range(0,0.2)) 0.05 _DissolveEdgeColor (Edge Color, Color) (1,0.5,0,1) _DissolveDirection (Direction (XY), Vector) (1,0,0,0) // 溶解方向向量 } // ... 顶点着色器传递世界坐标 ... fixed4 frag (v2f i) : SV_Target { fixed4 albedo tex2D(_MainTex, i.uv); // 1. 采样噪声纹理 fixed noiseValue tex2D(_NoiseTex, i.uv).r; // 2. 计算方向性偏移因子 // 将世界坐标投影到溶解方向上得到该点在方向上的标量位置 float directionFactor dot(i.worldPos.xyz, _DissolveDirection.xyz); // 将方向因子归一化并加上时间形成移动的阈值基线 float movingBase directionFactor * 0.1 _Time.y * 0.5; // 3. 计算最终阈值 // 噪声值加上移动基线再与全局阈值比较 float dissolveFactor noiseValue movingBase - _DissolveThreshold; // 4. 裁剪如果 dissolveFactor 0则丢弃片元 clip(dissolveFactor); // 5. 渲染溶解边缘可选 fixed4 finalColor albedo; if (dissolveFactor _DissolveEdgeWidth) { // 在边缘宽度内进行颜色混合 float edgeLerp dissolveFactor / _DissolveEdgeWidth; finalColor lerp(_DissolveEdgeColor, albedo, edgeLerp); // 通常还会在这里增加边缘自发光 } return finalColor; }参数解析_DissolveDirection: 一个三维向量表示溶解推进的世界空间方向。例如(1,0,0)表示沿世界X轴正方向溶解。directionFactor: 通过点积计算当前片元世界位置在溶解方向上的投影长度。这决定了该片元在溶解序列中的“基础位置”。movingBase: 将directionFactor缩放后加上时间。这样随着时间的推移movingBase会增加使得noiseValue movingBase整体增大溶解从directionFactor小的区域即与方向向量点积小的区域向大的区域推进。dissolveFactor: 最终的判断因子。当其值小于0时片元被丢弃。注意事项使用clip()会真正丢弃片元这意味着被丢弃的区域不会写入深度和颜色缓冲。对于需要显示背后物体的透明消融这种方式是高效的。但如果需要更复杂的边缘效果如逐渐透明过渡则需要改用透明度混合Blending并利用smoothstep函数来平滑边缘过渡而不是直接clip。4.2 结合顶点动画增强效果纯纹理消融可能显得有点“平”。我们可以结合顶点偏移让即将溶解的区域产生扭曲、膨胀或收缩的顶点动画增强体积感。思路是在顶点着色器中根据同样的噪声采样和阈值逻辑或一个提前计算的溶解因子沿法线方向偏移顶点位置。// 在顶点着色器中 v2f vert (appdata v) { v2f o; // 提前计算用于顶点偏移的噪声因子可以用一套更简单的UV或世界坐标 float preNoise tex2Dlod(_NoiseTex, float4(v.uv * _VertexNoiseTiling, 0, 0)).r; float preDissolve preNoise dot(mul(unity_ObjectToWorld, v.vertex).xyz, _DissolveDirection) * 0.1 - _DissolveThreshold; // 如果该顶点所在区域即将被溶解preDissolve值很小则进行偏移 float offsetStrength saturate(1 - preDissolve * 10); // 将溶解因子映射为偏移强度 float3 vertexOffset v.normal * offsetStrength * _VertexOffsetPower; v.vertex.xyz vertexOffset; o.vertex UnityObjectToClipPos(v.vertex); // ... 传递其他数据 ... return o; }这里使用了tex2Dlod在顶点着色器中进行纹理采样需要声明sampler2D为uniform并引入_VertexOffsetPower控制偏移强度。这样在溶解边缘模型表面就会产生扭曲变形效果立刻生动起来。5. 实战案例三模拟流动水体与能量场这是Tilling和Offset组合拳的高级应用目标是模拟非定向的、复杂的流动如河水、岩浆、能量护盾表面的流光。5.1 双层纹理混合扰动单一纹理的滚动很容易被看出重复 pattern。高级的流体模拟通常采用多层不同速度、不同方向、不同平铺大小的纹理进行混合和扰动。经典双层流动算法准备两张噪声或流动纹理_FlowMap1和_FlowMap2。它们可以是Perlin噪声、Flow Map或简单的湍流纹理。分别动画化对两张纹理使用不同的Tilling和Offset速度。float2 uv1 i.uv * _Tiling1 float2(_Time.y * _Speed1X, _Time.y * _Speed1Y); float2 uv2 i.uv * _Tiling2 float2(_Time.y * _Speed2X, _Time.y * _Speed2Y); fixed4 tex1 tex2D(_FlowMap1, uv1); fixed4 tex2 tex2D(_FlowMap2, uv2);混合将两张纹理采样结果以某种方式混合。最简单的是相加或相乘更复杂的是用一张纹理的RG通道去扰动另一张纹理的UV。// 简单叠加 fixed4 flow (tex1 tex2) * 0.5; // 或用tex1的r通道扰动tex2的UV float2 distortedUV2 uv2 tex1.rg * _DistortionStrength; tex2 tex2D(_FlowMap2, distortedUV2);应用到底色将混合后的流动纹理作为亮度、自发光或法线贴图的调制源。fixed4 finalColor albedo * flow.r; // 或用于自发光 o.Emission albedo.rgb * flow.r * _EmissionStrength;5.2 使用Flow Map流向图驱动Flow Map是一张RGB纹理其中R和G通道编码了该像素点流动的方向向量通常映射到[-1, 1]范围B通道可能编码流速或其他信息。这是影视和AAA游戏中模拟复杂流体的常用技术。实现流程制作Flow Map可以在Substance Designer、Houdini或专门的Flow Map绘制工具中生成。它定义了流体在每个像素点的流动方向。在Shader中解码方向fixed4 flowMap tex2D(_FlowMap, i.uv); // 将(0,1)范围的RG映射到(-1,1)的方向向量 float2 flowVector (flowMap.rg * 2.0 - 1.0) * _FlowStrength;基于Flow Vector进行UV偏移核心思想是用流动方向向量乘以时间作为UV的偏移量。但直接加会很快移出纹理范围。更高级的方法是使用“相位”技术。双相位采样避免跳变float phase0 frac(_Time.y * _FlowSpeed); float phase1 frac(_Time.y * _FlowSpeed 0.5); // 相位相差0.5半周期 float2 uvOffset0 flowVector * phase0; float2 uvOffset1 flowVector * phase1; fixed4 texSample0 tex2D(_MainTex, i.uv uvOffset0); fixed4 texSample1 tex2D(_MainTex, i.uv uvOffset1); // 使用两个相位采样结果的插值确保循环时平滑过渡 float blend abs((phase0 - 0.5) * 2); // 当phase0为0.25或0.75时blend为0.5为0或0.5时blend为1或0。 blend smoothstep(0, 1, blend); // 平滑过渡 fixed4 finalColor lerp(texSample0, texSample1, blend);这种方法通过两个交替渐入渐出的采样完美解决了UV偏移循环时的突然“跳回”问题实现了无限平滑的流动。5.3 能量护盾实例扭曲与折射对于能量护盾、热浪扭曲效果我们通常不是改变颜色纹理而是用流动的噪声去扰动屏幕空间UV或法线信息从而产生折射扭曲感。基于GrabPass的屏幕空间扭曲使用GrabPass在SubShader中声明GrabPass { }捕获当前屏幕渲染到一张纹理_GrabTexture。计算扰动UV在片元着色器中用动画化的噪声纹理采样结果生成一个扰动向量。float2 noiseUV i.uv * _DistortTiling _Time.y * _DistortSpeed; fixed4 noise tex2D(_NoiseTex, noiseUV); float2 distort (noise.rg * 2 - 1) * _DistortPower;扰动抓屏采样坐标将片元的屏幕空间坐标通过ComputeGrabScreenPos计算加上扰动向量然后用这个坐标去采样_GrabTexture。float4 grabPos i.grabPos; // 从顶点着色器传递过来 grabPos.xy distort * grabPos.w; // 注意乘以w进行透视校正 fixed4 grabColor tex2Dproj(_GrabTexture, grabPos);输出最终颜色主要来自扭曲后的背景grabColor再混合一层护盾本身的颜色和发光。这种方法性能消耗相对较大因为需要全屏抓取但效果非常出色能真实模拟光线穿过不均匀介质时的扭曲。6. 性能优化与常见问题排查即使是最基础的Tilling和Offset动画如果不加注意也可能引发性能或视觉问题。6.1 性能优化要点计算阶段选择顶点着色器 vs 片元着色器如前所述将UV动画计算放在顶点着色器是首选能极大减少计算量。除非动画需要基于每像素信息如屏幕空间坐标、深度图。避免全屏片元操作对于全屏后处理效果的扭曲如热浪虽然计算在片元着色器但应严格控制采样次数和计算复杂度。纹理采样优化纹理压缩与格式用于动画的噪声图、Flow Map应使用合适的压缩格式如RGBA DXT5 for Flow Map并关闭Mipmap以减少带宽和缓存抖动因为动态UV常导致Mipmap层级计算不准确。纹理尺寸在满足视觉效果的前提下尽量使用小尺寸纹理如512x512甚至256x256。流动效果通常不需要高清细节。平铺Tilling与纹理环绕过度平铺如Tilling值设为100会导致纹理采样频率极高在视角靠近时可能出现摩尔纹或闪烁。可以考虑使用程序化生成的噪声代替超大平铺的纹理。确保纹理的Wrap Mode设置为Repeat否则动画在边界会出错。6.2 常见视觉问题与修复纹理闪烁或游泳Swimming问题当UV动画速度很快且纹理本身包含高频细节时在运动过程中细节会剧烈闪烁。排查检查_Time的使用。_Time.y是浮点数精度足够但如果你错误地使用了_Time.x等变化过慢的分量可能导致动画不连贯。确保速度参数_ScrollSpeed设置合理。解决使用更低频的噪声纹理或者对高频细节进行模糊。也可以尝试使用frac()函数确保UV在合理范围内但通常纹理的Repeat模式会自动处理。明显的重复图案Tiling Artifacts问题平铺的纹理接缝处能看到重复的pattern破坏沉浸感。解决使用无缝纹理这是根本确保纹理左右、上下边缘能够完美衔接。多层混合如5.1节所述使用两层以上不同速度、不同平铺尺度的纹理进行叠加混合能有效打破重复感。UV变形在应用Offset之前对基础UV进行一次简单的非线性变换如使用正弦波扰动uv.x sin(uv.y * 频率) * 幅度可以让平铺的格子不那么规整。动画速率不随游戏时间缩放问题使用_Time.y时动画速度在游戏使用Time.timeScale 0暂停时也会停止这通常是期望的行为。但如果你希望动画不受时间缩放影响比如UI元素应使用_UnscaledTime。解决Unity没有内置的_UnscaledTime需要自己在C#脚本中传递一个float _MyUnscaledTime Time.unscaledTime;到Shader的全局属性Shader.SetGlobalFloat。移动设备上的精度问题问题在低精度浮点数的设备上如某些移动GPU大世界坐标下的UV计算可能导致精度丢失产生带状或块状瑕疵。解决对于基于世界坐标的UV计算如worldPos.xz * 0.01考虑使用相机相对坐标或者对世界坐标取模将其限制在一定范围内。float2 worldUV frac(i.worldPos.xz * 0.001); // 使用frac确保UV始终在[0,1)6.3 调试技巧可视化UV在调试阶段可以直接将最终的float2 uv作为颜色输出return float4(i.uv, 0, 1);检查UV的分布和动画是否正确。分离变量如果效果不对尝试在Shader中只输出时间变量return _Time.y;、只输出噪声采样值、只输出偏移量等逐步定位问题所在。检查材质球参数确保材质球上Tilling和Offset的初始值设置正确Shader中的_MainTex_ST能正确接收到这些值。有时在代码中动态修改材质属性时会覆盖材质球上的设置。掌握这些优化和排查技巧就能确保你的纹理动画不仅在视觉上出众在性能上也足够健壮能够适应从PC到移动端的各种平台。归根结底Tilling和Offset是工具理解其原理结合具体需求灵活运用和调试才是创造出精彩效果的关键。