Unity UV动画实战:C#脚本实现水面流动与无限地面滚动
1. 项目概述从“贴图”到“动态世界”的跨越很多刚接触Unity的朋友包括我自己刚入门那会儿都容易陷入一个误区认为让场景“动起来”主要靠动画师做的骨骼动画或者程序控制的物理模拟。直到我在一个需要动态水面的项目里卡了壳才真正意识到UV动画这个看似基础的技术其威力和灵活性远超想象。简单来说UV动画就是通过代码动态修改模型顶点上的纹理坐标UV让贴图在模型表面“流动”起来。它不改变模型的顶点位置却能创造出水面波光粼粼、岩浆缓缓蠕动、传送带不停运转、瀑布飞流直下等丰富的动态视觉效果。相比使用粒子系统或复杂的着色器用C#脚本控制UV动画对于程序员而言逻辑更清晰性能开销也更可控尤其适合需要与游戏逻辑比如水流速度受环境影响紧密联动的场景。这个项目的核心就是彻底搞懂如何用纯C#脚本来驱动UV动画并实现两个经典案例持续流动的水面和无限滚动的地面。我会带你从UV坐标的数学原理开始一步步拆解到完整的、可复用的脚本代码最后分享一些只有踩过坑才知道的优化技巧和排查方法。无论你是想为你的开放世界游戏加入一条小河还是为跑酷游戏制作一个自动移动的跑道这套方法都能直接拿来用。2. UV动画的核心原理与数学基础2.1 纹理坐标UV到底是什么在3D图形学里一个模型有了形状Mesh还需要皮肤Texture。如何把一张2D的图片“贴”到3D模型上呢这就需要UV坐标。你可以把UV坐标想象成模型表面每个顶点在纹理图片上的“身份证地址”。U代表水平方向通常范围0~1V代表垂直方向0~1。(0,0)点对应纹理图片的左下角(1,1)点对应右上角。当我们在Shader或脚本里修改这个“地址”时模型表面采样纹理的位置就变了视觉效果上就是贴图在移动、旋转或拉伸。这就是UV动画的底层逻辑动态偏移UV坐标。2.2 实现滚动的数学运算Offset与Tiling最基础的UV动画是滚动Scrolling。其数学表达非常简单newUV originalUV (scrollSpeed * time)这里有几个关键点originalUV模型原始的UV坐标是Mesh数据的一部分我们通常通过Mesh.uv获取。scrollSpeed一个二维向量Vector2。scrollSpeed.x控制U方向水平的滚动速度scrollSpeed.y控制V方向垂直的滚动速度。值为正时纹理向坐标轴正方向移动视觉上通常向左或向上滚值为负时则相反。time通常是Time.time从游戏开始经过的时间或Time.deltaTime上一帧到当前帧的时间间隔。使用Time.time可以实现持续累加的偏移形成无限滚动的效果在Update中每帧累加Time.deltaTime * scrollSpeed也能达到同样目的。除了偏移我们经常还需要控制纹理的平铺Tiling。在Shader中这通常通过_MainTex_STST代表Scale和Translation即缩放和平移这个属性来控制。在C#脚本中我们可以通过修改材质属性_MainTex_ST一个Vector4来实现。这个Vector4的xy分量代表缩放Tilingzw分量代表偏移Offset。公式为finalUV originalUV * _MainTex_ST.xy _MainTex_ST.zw通过每帧修改_MainTex_ST.zw我们就能实现滚动效果。注意直接修改_MainTex_ST会影响材质的平铺和偏移属性如果场景中多个物体使用同一材质实例它们会共享这个变化。如果希望每个物体独立动画需要确保它们使用独立的材质实例Material。2.3 C#脚本 vs Shader Graph为何选择脚本控制Unity提供了多种实现UV动画的方式比如在Shader Graph里用Time节点驱动Tiling and Offset节点或者在顶点片元着色器中编写代码。那么为什么我还要强调用C#脚本呢逻辑控制灵活脚本可以轻松读取游戏状态。比如水面流速可以根据风力大小动态调整scrollSpeed baseSpeed windStrength * factor地面滚动速度可以随着玩家速度提升而加快。这种与游戏逻辑的实时交互在Shader中实现起来就麻烦得多。动态参数调节在Play模式下你可以直接拖拽脚本组件上的ScrollSpeed变量实时观察效果变化快速迭代。这对于美术和策划调整视觉效果非常友好。性能考量细分对于大量需要简单滚动的物体比如远处成片的草地在CPU端用脚本统一计算并设置材质属性可能比每个物体都运行一段复杂的Shader指令更高效尤其是当你可以通过脚本进行合批优化时。学习曲线与团队协作对于主要由程序员构成的团队维护和调试C#脚本比调试Shader代码更熟悉。脚本的逻辑流程也更容易被其他非图形程序员理解。当然对于极度追求视觉效果、需要复杂数学变形如正弦波叠加模拟复杂水波的场景编写Shader仍然是更专业的选择。但脚本控制UV滚动是掌握动态纹理的基石且能满足项目中80%的常见需求。3. 实战一用C#脚本实现流动水面3.1 场景与材质准备首先我们需要一个代表水面的模型。一个简单的Quad平面或经过细分的Plane就足够了。在场景中创建一个Plane将其Scale调整到合适的大小比如(10, 1, 10)。接下来是材质。你需要一张水纹理贴图。为了有良好的连续滚动效果这张贴图最好是无缝衔接的Seamless Texture。你可以使用Photoshop、GIMP或一些在线工具制作。将贴图导入Unity后创建一个新的材质MaterialShader选择Universal Render Pipeline/Lit如果你使用URP或Standard内置管线。将水纹理拖拽到材质的Base Map上。关键一步在材质的Inspector面板中找到纹理的平铺Tiling设置。将X和Y值都调大例如设为(4,4)。这样做的目的是让纹理在模型表面重复更多次单个纹理单元变小。在后续滚动时纹理移动一个单位0-1范围对应的视觉移动距离会变短从而让水流的“波纹”看起来更密集、更自然避免出现大块纹理缓慢滑动的尴尬感。将这个材质赋给刚才创建的Plane。3.2 编写基础水面滚动脚本创建一个C#脚本命名为WaterFlowController.cs。下面是完整的代码及逐行解析using UnityEngine; [RequireComponent(typeof(Renderer))] public class WaterFlowController : MonoBehaviour { // 公开变量方便在Inspector中调节 [Header(滚动速度)] public Vector2 scrollSpeed new Vector2(0.05f, 0.05f); [Header(纹理缩放)] public Vector2 textureTiling new Vector2(4, 4); // 私有变量 private Renderer _renderer; private Material _materialInstance; private Vector2 _currentOffset Vector2.zero; void Start() { // 获取Renderer组件 _renderer GetComponentRenderer(); // 关键创建材质实例 // 确保每个物体修改的是自己的材质不影响其他使用相同材质的物体 _materialInstance _renderer.material; // 初始化纹理缩放 _materialInstance.mainTextureScale textureTiling; } void Update() { // 计算当前帧应累积的偏移量 // Time.deltaTime 确保动画速度与帧率无关 _currentOffset scrollSpeed * Time.deltaTime; // 应用偏移到材质实例 // mainTextureOffset 对应着 _MainTex_ST 的zw分量偏移 _materialInstance.mainTextureOffset _currentOffset; } // 可选在编辑器模式下当脚本值改变时实时预览 void OnValidate() { if (Application.isPlaying _materialInstance ! null) { _materialInstance.mainTextureScale textureTiling; } } }代码解析与注意事项RequireComponent(typeof(Renderer))这个属性确保脚本所在的GameObject上一定有Renderer组件如MeshRenderer避免空引用错误。_renderer.materialvs_renderer.sharedMaterial这是最重要的一个坑。material属性会返回一个该渲染器独有的材质实例你对它的所有修改都不会影响其他物体。而sharedMaterial是引用的原始材质资产修改它会影响到场景中所有使用该原始材质的物体。我们这里使用material来获取实例确保水面动画独立。Time.deltaTime使用它乘以速度是实现帧率无关动画Frame-rate Independent Animation的标准做法。无论游戏是30帧还是120帧水流的视觉速度都是恒定的。OnValidate方法这个方法在Inspector中修改了textureTiling或scrollSpeed值后会立即调用仅在编辑器模式下。Application.isPlaying判断确保只在游戏运行时更新材质避免编辑模式下误操作。将脚本挂载到你的水面Plane上运行游戏你应该能看到纹理开始流动了。调整scrollSpeed的X和Y值可以改变水流的方向和速度。例如(0.1f, 0)是水平向右流(0, -0.05f)是垂直向下流。3.3 进阶模拟更自然的水波效果单纯朝一个方向滚动看起来还像“滑动的贴纸”不够像水。真实的水面运动是复杂的通常可以模拟为两个不同方向、不同速度的纹理层叠加。思路我们使用两个纹理层可以使用同一张纹理但平铺和速度不同。一层模拟大尺度、缓慢移动的波浪基础形状另一层模拟小尺度、快速移动的波光粼粼的细节。在Shader中这很常见但在脚本中我们如何实现答案是使用多重纹理或者通过脚本控制Shader的多个纹理属性。我们升级一下脚本假设我们的水面材质使用了两个纹理属性_MainTex基础波浪和_DetailTex细节波纹。using UnityEngine; public class AdvancedWaterFlow : MonoBehaviour { public Vector2 mainScrollSpeed new Vector2(0.02f, 0.01f); public Vector2 detailScrollSpeed new Vector2(0.05f, -0.03f); // 细节层反向增加随机感 public Vector2 mainTiling new Vector2(2, 2); public Vector2 detailTiling new Vector2(8, 8); // 细节层平铺更密 private Material _matInstance; private Vector2 _mainOffset; private Vector2 _detailOffset; void Start() { var renderer GetComponentRenderer(); _matInstance renderer.material; // 初始化平铺 _matInstance.SetTextureScale(_MainTex, mainTiling); _matInstance.SetTextureScale(_DetailTex, detailTiling); } void Update() { _mainOffset mainScrollSpeed * Time.deltaTime; _detailOffset detailScrollSpeed * Time.deltaTime; // 使用SetTextureOffset方法避免直接操作mainTexture它只针对_MainTex _matInstance.SetTextureOffset(_MainTex, _mainOffset); _matInstance.SetTextureOffset(_DetailTex, _detailOffset); } }为了让这个脚本工作你的Shader必须支持_DetailTex这个纹理属性并且在Shader中将其与_MainTex以某种方式如叠加、屏幕混合进行混合。这通常需要你自定义一个Shader或使用支持Detail Map的标准Shader变体。实操心得对于快速原型或移动端性能敏感的项目我经常采用一种“取巧”的方法依然只用一个纹理但在UV偏移计算上加入一些扰动。例如让偏移量随时间做正弦波变化而不是匀速直线运动。这能在单层纹理上创造出更有机的流动感性能开销极小。// 在Update中替换匀速计算 float time Time.time; float offsetX Mathf.Sin(time * 0.5f) * 0.01f time * 0.02f; float offsetY Mathf.Cos(time * 0.3f) * 0.005f time * 0.01f; _currentOffset new Vector2(offsetX, offsetY); _materialInstance.mainTextureOffset _currentOffset;4. 实战二用C#脚本实现无限滚动地面滚动地面的需求在跑酷、无尽关卡、横向卷轴游戏中非常普遍。其核心逻辑与水面滚动类似但有一个关键区别视觉上的无限循环。我们不能让纹理一直偏移下去因为浮点数精度问题和巨大的偏移值可能带来性能或渲染问题。4.1 实现无缝循环滚动的关键技巧解决方案是利用UV坐标的取模运算Modulo。纹理坐标在0到1之间循环。当偏移量超过1时我们将其对1取余重置回0-1的范围内。由于纹理是平铺的从1回到0的瞬间纹理接缝是完全吻合的前提是无缝纹理视觉上就形成了无限循环的假象。using UnityEngine; public class ScrollingGround : MonoBehaviour { public Vector2 scrollDirection new Vector2(-1, 0); // 方向向量通常x为负表示向左滚 public float speed 0.5f; private Renderer _renderer; private Vector2 _currentOffset Vector2.zero; void Start() { _renderer GetComponentRenderer(); // 同样确保使用材质实例 _renderer.material new Material(_renderer.sharedMaterial); } void Update() { // 计算本帧增量 Vector2 offsetDelta scrollDirection.normalized * speed * Time.deltaTime; // 累加偏移 _currentOffset offsetDelta; // 关键对1取模实现循环 _currentOffset.x Mathf.Repeat(_currentOffset.x, 1); _currentOffset.y Mathf.Repeat(_currentOffset.y, 1); // 应用偏移 _renderer.material.mainTextureOffset _currentOffset; } }代码解析scrollDirection.normalized将方向向量标准化长度为1确保speed参数代表的就是每秒移动的纹理单位数与方向向量的长度无关。Mathf.Repeat(current, length)Unity提供的便捷方法实现current % length的效果但确保结果始终在[0, length)范围内非常适合用于循环UV偏移。4.2 多段地面拼接与视觉连续性单个地面网格的长度是有限的。当玩家向前移动时我们很快会看到地面的尽头。在无尽关卡中通常的做法是准备几段相同的地面模型让它们首尾相接并随着玩家移动进行循环回收。此时UV动画需要与物体的世界位置变换协同工作以确保视觉连续性。一个常见的方案是准备3段地面Ground_A, Ground_B, Ground_C。将它们排成一条直线。写一个脚本控制它们整体向后移动或相机向前移动。当某一段地面完全移出屏幕后方时立即将其位置重置到最前方接上最后一段地面。关键点在重置地面位置时不要重置其材质的UV偏移。这样即使物体被“传送”到了前面其纹理的滚动状态是连续的玩家完全看不出接缝。// 这是一个简化的地面管理器脚本片段展示核心思路 public class EndlessGroundManager : MonoBehaviour { public Transform[] groundSegments; // 按顺序排列的地面段 public float globalScrollSpeed 5f; private float segmentLength; void Start() { segmentLength groundSegments[0].GetComponentRenderer().bounds.size.z; } void Update() { // 1. 所有地面段向后移动 foreach (var segment in groundSegments) { segment.Translate(Vector3.back * globalScrollSpeed * Time.deltaTime); } // 2. 检查并回收最末尾的一段 Transform firstSegment groundSegments[0]; if (firstSegment.position.z -segmentLength) // 假设相机在原点看向正前方 { // 将其移动到队列最后 Vector3 newPos groundSegments[groundSegments.Length - 1].position; newPos.z segmentLength; firstSegment.position newPos; // 将数组重新排序 for (int i 0; i groundSegments.Length - 1; i) { groundSegments[i] groundSegments[i 1]; } groundSegments[groundSegments.Length - 1] firstSegment; } } }在这个方案中每一段地面上的ScrollingGround脚本独立运行持续滚动其UV。当地面段被回收重置位置时它的UV滚动动画从未中断因此从玩家视角看地面纹理是无限、平滑、连续地滚动的。5. 性能优化与常见问题排查5.1 性能优化要点合批Batching中断动态修改材质属性如mainTextureOffset会导致Unity无法对使用该材质的物体进行动态合批Dynamic Batching或GPU Instancing。如果场景中有大量需要独立UV动画的物体比如一片随风摇摆的草地这会显著增加Draw Call。解决方案考虑使用Shader Graph或编写Shader将时间_Time变量和每个物体独有的速度参数_ScrollSpeed传入Shader在顶点或片元着色器中进行UV偏移计算。这样只要材质球相同属性用MaterialPropertyBlock覆盖这些物体仍然可以被合批。这是处理大量同质动画物体的高级优化手段。材质实例化开销在Start()或Awake()中调用renderer.material会创建新的材质实例。创建成百上千个材质实例会消耗大量内存。解决方案对于大量使用相同基础材质但需要不同参数的物体考虑使用MaterialPropertyBlock。MaterialPropertyBlock允许你修改渲染器的特定属性而不创建新的材质实例。private Renderer _renderer; private MaterialPropertyBlock _propBlock; private Vector2 _offset; void Start() { _renderer GetComponentRenderer(); _propBlock new MaterialPropertyBlock(); _offset Vector2.zero; } void Update() { _offset scrollSpeed * Time.deltaTime; _renderer.GetPropertyBlock(_propBlock); // 获取现有的属性块 _propBlock.SetTextureOffset(_MainTex, _offset); // 设置偏移 _propBlock.SetTextureScale(_MainTex, tiling); // 设置平铺 _renderer.SetPropertyBlock(_propBlock); // 应用属性块 }使用MaterialPropertyBlock后所有物体可以共享同一个材质资产极大节省内存且不影响合批前提是其他渲染状态也相同。更新频率对于远处或不重要的动态物体没必要每帧更新UV。可以降低更新频率比如每2帧或每3帧更新一次。private int _updateInterval 2; private int _frameCount 0; void Update() { _frameCount; if (_frameCount % _updateInterval 0) { // 更新UV偏移的逻辑 UpdateUVOffset(); } }5.2 常见问题排查表问题现象可能原因解决方案纹理不动1. 脚本未启用。2.scrollSpeed设置为 (0,0)。3. 脚本中修改的是sharedMaterial但材质未设置为可读写。1. 检查Inspector中脚本组件复选框是否勾选。2. 在Inspector中调整速度值。3. 确保使用renderer.material或MaterialPropertyBlock。纹理滚动方向相反scrollSpeed的正负号理解有误。尝试调换速度分量的正负号。记住UV坐标原点(0,0)在纹理左下角增加U值纹理向右移动视觉可能向左。多试几次找到感觉。纹理滚动有跳跃感1. 未使用Time.deltaTime动画帧率依赖。2. 纹理本身不是无缝纹理在边界处有接缝。1. 确保速度计算乘以Time.deltaTime。2. 使用Photoshop等工具制作无缝纹理或启用纹理导入设置中的“Wrap Mode”为“Repeat”。多个物体动画不同步使用了sharedMaterial所有物体共享同一个材质状态。为每个需要独立动画的物体使用renderer.material创建实例或使用MaterialPropertyBlock。游戏卡顿Draw Call很高大量物体动态修改材质破坏了合批。考虑使用MaterialPropertyBlock或 将动画逻辑移至Shader中并利用GPU Instancing。WebGL等平台无效可能涉及着色器精度问题或材质属性名在移动端/WebGL的简化Shader中不一致。使用Shader.PropertyToID获取属性ID而不是传递字符串。int mainTexOffsetID Shader.PropertyToID(_MainTex_ST);然后用ID进行SetVector操作。这更高效且可靠。5.3 一个健壮且可配置的通用脚本最后分享一个我项目中常用的增强版脚本它集成了上述的许多技巧使用MaterialPropertyBlock、支持无缝循环、暴露参数给设计师调节并包含了简单的编辑器预览功能。using UnityEngine; [ExecuteAlways] // 在编辑模式下也执行方便预览 public class UVScroller : MonoBehaviour { [Tooltip(纹理滚动速度 (U, V)。)] public Vector2 scrollSpeed new Vector2(0.05f, 0f); [Tooltip(纹理平铺倍数 (U, V)。)] public Vector2 tiling new Vector2(1, 1); [Tooltip(是否启用无缝循环。)] public bool enableWrap true; private Renderer _renderer; private MaterialPropertyBlock _propBlock; private Vector2 _currentOffset; private static readonly int MainTexST Shader.PropertyToID(_MainTex_ST); void OnEnable() { _renderer GetComponentRenderer(); if (_propBlock null) _propBlock new MaterialPropertyBlock(); _currentOffset Vector2.zero; ApplyUVProperties(); } void Update() { if (scrollSpeed Vector2.zero) return; // 计算偏移 _currentOffset scrollSpeed * Time.deltaTime; // 如果启用循环则取模 if (enableWrap) { _currentOffset.x Mathf.Repeat(_currentOffset.x, 1); _currentOffset.y Mathf.Repeat(_currentOffset.y, 1); } ApplyUVProperties(); } void ApplyUVProperties() { if (_renderer null) return; _renderer.GetPropertyBlock(_propBlock); // _MainTex_ST 是一个Vector4: (Tiling.x, Tiling.y, Offset.x, Offset.y) _propBlock.SetVector(MainTexST, new Vector4(tiling.x, tiling.y, _currentOffset.x, _currentOffset.y)); _renderer.SetPropertyBlock(_propBlock); } // 在编辑器中修改参数时实时预览 void OnValidate() { if (isActiveAndEnabled) { ApplyUVProperties(); } } }这个脚本可以直接挂载到任何需要UV滚动的物体上。它性能良好不会创建多余的材质实例并且参数调节即时可见。通过这个实战项目你不仅学会了实现两种常见的UV动画效果更重要的是掌握了通过脚本动态控制材质属性的核心方法这为你将来实现更复杂的动态视觉效果打下了坚实的基础。记住图形效果往往是“欺骗”的艺术UV动画就是其中成本极低但效果极佳的一种魔术手法。