1. 项目概述:从“贴图”到“动态世界”的跨越
很多刚接触Unity的朋友,包括我自己刚入门那会儿,都容易陷入一个误区:认为让场景“动起来”主要靠动画师做的骨骼动画,或者程序控制的物理模拟。直到我在一个需要动态水面的项目里卡了壳,才真正意识到UV动画这个看似基础的技术,其威力和灵活性远超想象。简单来说,UV动画就是通过代码动态修改模型顶点上的纹理坐标(UV),让贴图在模型表面“流动”起来。它不改变模型的顶点位置,却能创造出水面波光粼粼、岩浆缓缓蠕动、传送带不停运转、瀑布飞流直下等丰富的动态视觉效果。相比使用粒子系统或复杂的着色器,用C#脚本控制UV动画,对于程序员而言逻辑更清晰,性能开销也更可控,尤其适合需要与游戏逻辑(比如水流速度受环境影响)紧密联动的场景。
这个项目的核心,就是彻底搞懂如何用纯C#脚本来驱动UV动画,并实现两个经典案例:持续流动的水面和无限滚动的地面。我会带你从UV坐标的数学原理开始,一步步拆解到完整的、可复用的脚本代码,最后分享一些只有踩过坑才知道的优化技巧和排查方法。无论你是想为你的开放世界游戏加入一条小河,还是为跑酷游戏制作一个自动移动的跑道,这套方法都能直接拿来用。
2. UV动画的核心原理与数学基础
2.1 纹理坐标(UV)到底是什么?
在3D图形学里,一个模型有了形状(Mesh),还需要皮肤(Texture)。如何把一张2D的图片“贴”到3D模型上呢?这就需要UV坐标。你可以把UV坐标想象成模型表面每个顶点在纹理图片上的“身份证地址”。U代表水平方向(通常范围0~1),V代表垂直方向(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_ST(ST代表Scale和Translation,即缩放和平移)这个属性来控制。在C#脚本中,我们可以通过修改材质属性_MainTex_ST(一个Vector4)来实现。这个Vector4的xy分量代表缩放(Tiling),zw分量代表偏移(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后,创建一个新的材质(Material),Shader选择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 = GetComponent<Renderer>(); // 关键:创建材质实例 // 确保每个物体修改的是自己的材质,不影响其他使用相同材质的物体 _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 = GetComponent<Renderer>(); _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 = GetComponent<Renderer>(); // 同样,确保使用材质实例 _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].GetComponent<Renderer>().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覆盖),这些物体仍然可以被合批。这是处理大量同质动画物体的高级优化手段。
- 解决方案:考虑使用Shader Graph或编写Shader,将时间(
材质实例化开销:在
Start()或Awake()中调用renderer.material会创建新的材质实例。创建成百上千个材质实例会消耗大量内存。- 解决方案:对于大量使用相同基础材质但需要不同参数的物体,考虑使用
MaterialPropertyBlock。MaterialPropertyBlock允许你修改渲染器的特定属性而不创建新的材质实例。
private Renderer _renderer; private MaterialPropertyBlock _propBlock; private Vector2 _offset; void Start() { _renderer = GetComponent<Renderer>(); _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 = GetComponent<Renderer>(); 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动画就是其中成本极低但效果极佳的一种魔术手法。