多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity图片循环滚动优化:从Wrap Mode到Shader的丝滑性能实践

Unity图片循环滚动优化:从Wrap Mode到Shader的丝滑性能实践 1. 项目概述为什么图片滚动值得深究在Unity项目里尤其是移动端游戏或者UI界面丰富的应用中实现背景、云层、河流或者公告板的循环滚动效果简直是家常便饭。SpriteRenderer和RawImage是承载这类图片滚动任务的两个主力组件前者常用于2D游戏场景中的精灵后者则是UGUI体系下显示纹理的利器。乍一看实现滚动无非就是每帧修改一下材质或RectTransform的UV偏移量代码可能就一两行。但就是这个看似简单的操作如果处理不当在低端移动设备上或者WebGL平台很容易成为性能瓶颈引发卡顿、发热甚至崩溃。我自己就踩过不少坑。早期做一个跑酷游戏背景用了多层SpriteRenderer做视差滚动在部分安卓机上帧率直接掉到30以下Profile一开发现大量的Draw Call和材质属性设置。后来做一款信息流应用用RawImage做新闻列表的无限滚动列表一长滚动起来就一顿一顿的GC垃圾回收频繁触发。这些问题追根溯源往往不是算法有多复杂而是细节没做到位。比如你是否考虑过纹理的导入设置是否在每帧都创建了新的Vector2你的滚动逻辑是在Update还是FixedUpdate里这些选择在PC上可能毫无感觉但在资源受限的移动端差别就大了。所以今天我们就来深挖一下在Unity里用SpriteRenderer和RawImage做图片循环滚动时有哪些从原理到实践的优化技巧。目标很明确让滚动效果如丝般顺滑同时把CPU和GPU的开销降到最低。无论你是做2D手游、UI界面还是需要类似滚动效果的展示项目这些经验都能直接拿来用。2. 核心原理无缝滚动的基石与性能开销分析在动手优化之前我们必须搞清楚两件事第一循环滚动的视觉原理是什么第二这个过程中Unity底层到底在做什么哪里可能产生性能开销2.1 无缝滚动与Wrap Mode的魔法循环滚动视觉上就是一张图片不断向某个方向移动当它移出屏幕时另一张相同的图片或者说同一张图片的另一部分从相反方向进入衔接得天衣无缝形成无限循环的错觉。从技术实现上讲无论是SpriteRenderer还是RawImage其核心都是通过修改纹理坐标UV的偏移量来实现的。UV坐标范围通常是(0,0)到(1,1)对应纹理的整个区域。当我们把UV的U水平方向或V垂直方向进行持续的偏移比如让U从0增加到1图片就会完成一次完整的滚动。那么如何实现“无缝”和“循环”呢关键就在于纹理的Wrap Mode环绕模式。这就是网络片段中提到的核心设置。在Unity编辑器中选中一张图片在Inspector面板的导入设置里你能找到这个选项。默认可能是Clamp钳制这意味着当UV坐标超过1时它会一直被“钳制”在1也就是边缘像素被无限拉伸这显然无法实现无缝滚动。我们需要将其设置为Repeat重复。这个设置是给GPU的指令当UV坐标超过1时不要停也别拉伸直接“回头”从0开始重新采样纹理。比如U坐标从0.9偏移到1.1在Repeat模式下1.1就等价于0.1。这样一来当图片的一部分移出屏幕右侧时它的“重复”部分正好从屏幕左侧进入视觉上就形成了完美的无缝衔接。注意Wrap Mode是纹理资产本身的属性必须在导入时或通过代码在加载前设置好。如果在运行时动态修改一个已加载纹理的Wrap Mode可能会触发纹理重新上传至GPU造成卡顿。2.2 SpriteRenderer与RawImage的渲染路径差异理解了原理我们再来看看两位“主角”的区别这直接决定了我们的优化策略。SpriteRenderer属于Unity的2D渲染系统。一个SpriteRenderer对应一个Draw Call绘制调用。它的材质通常是Sprites/Default是一个简单的Unlit无光照着色器主要操作就是采样纹理并输出颜色。它的UV偏移是通过修改材质属性_MainTex_STST代表Scale和Offset中的Offset偏移量来实现的。由于2D渲染通常按层级排序如果多个SpriteRenderer使用相同的材质和纹理Unity的Dynamic Batching动态合批有可能将它们合并为一个Draw Call但这有很多限制条件如缩放一致、不使用不同的材质实例等。RawImage属于UGUIUnity GUI系统。UGUI有一套自己的网格重建和合批逻辑。一个RawImage本质上是一个Canvas下的UI元素它显示一个Texture而不是Sprite。RawImage的滚动是通过修改其uvRect属性来实现的这个属性直接定义了纹理在UI矩形内的采样区域包括位置和大小。UGUI会自动将同一个Canvas下、材质相同、深度相邻的UI元素合批以减少Draw Call。但是频繁修改uvRect会导致该UI元素的网格需要重建进而可能触发Canvas的批量重建这是UGUI的主要性能开销点之一。性能开销分析CPU开销逻辑计算每帧计算新的UV偏移量。如果计算本身复杂或每帧创建新的Vector2/Rect对象会产生GC Alloc垃圾回收分配。属性设置对于SpriteRenderer是设置material.mainTextureOffset对于RawImage是设置uvRect。后者如果引起网格重建开销更大。Draw Call这是渲染的瓶颈。过多的SpriteRenderer或未能有效合批的UI元素会导致Draw Call激增。GPU开销纹理采样简单的纹理平移对现代GPU来说压力很小。Overdraw过度绘制如果滚动的图片层级很多且重叠严重会导致同一个像素被绘制多次增加GPU片段着色器的负担。这在移动设备上尤其需要注意。3. 优化技巧实战从导入设置到代码细节掌握了原理我们就可以针对性地进行优化了。优化是一个系统工程我们从资产准备开始一直到运行时代码。3.1 资产准备阶段防患于未然很多性能问题在资源导入时就已经埋下了种子。3.1.1 纹理导入设置优化这是最基础也最重要的一步对应网络片段中的核心提示。Wrap Mode设置为Repeat如前所述这是实现无缝循环的前提。在Project面板选中纹理在Inspector中设置Wrap Mode为Repeat。关闭Mipmaps对于用于2D滚动背景或UI的纹理通常摄像机或Canvas是正交投影或者纹理始终以接近原始大小的方式显示不会因为距离而产生大幅缩放。在这种情况下Mipmaps多级渐远纹理不仅会增加约33%的内存占用GPU在采样时还需要判断使用哪一级增加开销。除非你的纹理需要用于3D场景或有显著的动态缩放否则建议关闭Generate Mip Maps。选择合适的压缩格式根据平台选择。Android (ASTC)如果设备支持ASTC格式在质量和压缩比上表现很好。iOS (PVRTC)苹果设备的原生格式。通用 (ETC2)对于支持OpenGL ES 3.0的安卓和iOS设备ETC2是不错的选择。对于UI纹理有时为了绝对精确和避免压缩瑕疵可以考虑使用RGBA32这样的无压缩格式但会显著增加内存和包体大小需权衡。Max Size合理设置纹理尺寸绝不是越大越好。根据图片在屏幕上显示的最大像素尺寸来设置Max Size。一个全屏的背景图尺寸设置为设备最大分辨率即可如1080p对应2048。过大的纹理会浪费内存和带宽。3.1.2 精灵图集 (Sprite Atlas) 的使用如果你的场景中有多个SpriteRenderer需要滚动并且它们使用的是同一张图集里的不同精灵那么使用Sprite Atlas可以带来巨大的性能提升。原理将多个小纹理打包成一张大图集。这样所有使用该图集精灵的SpriteRenderer只要材质相同就极有可能被Unity静态或动态合批从而将数十个甚至上百个Draw Call减少到个位数。操作通过Window - 2D - Sprite Atlas创建并配置图集。将需要滚动的精灵拖入图集。确保SpriteRenderer使用的Sprite来自该图集。注意合批要求精灵的材质实例完全相同。避免在代码中动态修改SpriteRenderer的material属性如color这会打断合批。如果需要修改颜色使用sharedMaterial要极其小心会影响到所有使用该材质的对象更推荐通过修改顶点颜色如果着色器支持或使用MaterialPropertyBlock。3.2 代码实现优化每一帧都很珍贵资产准备好后就到了关键的代码环节。这里面的门道最多。3.2.1 对于SpriteRenderer的优化实现using UnityEngine; public class OptimizedSpriteScroller : MonoBehaviour { public float scrollSpeedX 0.1f; public float scrollSpeedY 0f; private SpriteRenderer _spriteRenderer; private MaterialPropertyBlock _propertyBlock; private Vector2 _offset Vector2.zero; void Start() { _spriteRenderer GetComponentSpriteRenderer(); // 使用MaterialPropertyBlock来修改材质属性避免创建新的材质实例从而不打断合批。 _propertyBlock new MaterialPropertyBlock(); // 初始获取一次当前的纹理偏移 _spriteRenderer.GetPropertyBlock(_propertyBlock); // 假设材质使用_MainTex_ST我们需要获取当前的Scale和Offset // 这里我们只关心OffsetScale通常保持为(1,1) Vector4 st _propertyBlock.GetVector(_MainTex_ST); _offset new Vector2(st.z, st.w); // ST的zw分量是Offset } void Update() { // 1. 使用Time.unscaledDeltaTime还是Time.deltaTime // 如果滚动效果需要和游戏逻辑时间同步受Time.timeScale影响用Time.deltaTime。 // 如果希望滚动速度恒定不受游戏暂停影响如背景装饰用Time.unscaledDeltaTime。 float deltaTime Time.deltaTime; // 2. 更新偏移量。使用取模运算(%)确保数值不会无限增大避免精度问题。 _offset.x Mathf.Repeat(_offset.x scrollSpeedX * deltaTime, 1.0f); _offset.y Mathf.Repeat(_offset.y scrollSpeedY * deltaTime, 1.0f); // 3. 通过MaterialPropertyBlock设置新的Offset _spriteRenderer.GetPropertyBlock(_propertyBlock); // 先获取当前块 // 设置_MainTex_ST: x,y分量为Tiling(Scale)z,w分量为Offset _propertyBlock.SetVector(_MainTex_ST, new Vector4(1, 1, _offset.x, _offset.y)); _spriteRenderer.SetPropertyBlock(_propertyBlock); // 应用块 } }关键优化点解析使用MaterialPropertyBlock这是本方案的核心优化。直接修改spriteRenderer.material.mainTextureOffset会导致Unity为该SpriteRenderer创建一个新的材质实例Material Instance这会立即打断动态合批。而MaterialPropertyBlock允许我们直接向GPU传递属性不改变材质本身从而保持了材质的同一性合批得以维持。Mathf.Repeat替代累加直接累加偏移量数值会变得非常大如几千几万虽然Wrap Mode为Repeat时视觉上没问题但过大的浮点数可能在着色器计算中带来精度问题。使用Mathf.Repeat将偏移量限制在[0, 1)区间更加稳健。DeltaTime的选择明确你的需求。对于游戏背景通常使用Time.deltaTime与游戏世界同步。对于UI装饰性滚动Time.unscaledDeltaTime可能更合适。3.2.2 对于RawImage的优化实现RawImage的优化思路不同核心是避免不必要的网格重建。using UnityEngine; using UnityEngine.UI; public class OptimizedRawImageScroller : MonoBehaviour { public float scrollSpeedX 0.1f; public float scrollSpeedY 0f; private RawImage _rawImage; private Rect _uvRect; void Start() { _rawImage GetComponentRawImage(); // 初始化uvRect避免每帧new一个Rect _uvRect _rawImage.uvRect; } void Update() { float deltaTime Time.deltaTime; // 更新uvRect的x和y即偏移量 _uvRect.x Mathf.Repeat(_uvRect.x scrollSpeedX * deltaTime, 1.0f); _uvRect.y Mathf.Repeat(_uvRect.y scrollSpeedY * deltaTime, 1.0f); // 直接赋值Unity会检查值是否改变只有改变时才触发网格重建。 _rawImage.uvRect _uvRect; } }关键优化点解析缓存uvRect在Start中缓存_rawImage.uvRect避免在Update中反复调用getter虽然getter开销不大但养成好习惯。修改后赋值直接修改缓存的_uvRect的字段然后一次性赋值给_rawImage.uvRect。UGUI内部会检查新值是否与旧值相等只有真正发生变化时才会标记该UI元素为“脏”触发网格重建。这比每帧new Rect(...)然后赋值要高效。合批考量确保这个RawImage所在的Canvas下材质相同的UI元素尽可能在层级上相邻以促进UGUI的合批。避免频繁改变父节点或兄弟节点的顺序。3.3 高级策略与架构优化当你的滚动元素非常多时比如成百上千个星星背景即使每个单体优化得很好总量也可能成为负担。3.3.1 使用Shader实现超高效滚动终极优化方案是将滚动逻辑完全转移到GPU。我们可以编写一个简单的自定义着色器。// 这是一个简化的Unlit Shader支持UV偏移 Shader Custom/ScrollingTexture { Properties { _MainTex (Texture, 2D) white {} _ScrollSpeed (Scroll Speed, Vector) (0.1, 0, 0, 0) // x, y速度 } 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; float2 _ScrollSpeed; float _TimeAtLoad; // 可以通过脚本传递一个起始时间实现同步 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 在顶点着色器中计算滚动UV比在片段着色器更高效 float2 scrollUV v.uv; float time _Time.y; // 使用着色器内置时间 scrollUV _ScrollSpeed * time; // 应用纹理的Tiling和Offset并处理Repeat o.uv TRANSFORM_TEX(scrollUV, _MainTex); // 手动处理Repeat因为TRANSFORM_TEX可能不包含Repeat逻辑 // 更简单的做法确保纹理Wrap Mode为Repeat然后直接使用 frac(scrollUV) 或 scrollUV - floor(scrollUV) o.uv frac(o.uv); // frac函数返回小数部分自动实现Repeat return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); return col; } ENDCG } } }使用此Shader的脚本public class ShaderBasedScroller : MonoBehaviour { public Vector2 scrollSpeed new Vector2(0.1f, 0); private Renderer _renderer; private MaterialPropertyBlock _propBlock; void Start() { _renderer GetComponentRenderer(); _propBlock new MaterialPropertyBlock(); _renderer.GetPropertyBlock(_propBlock); // 将初始时间传入Shader如果需要多个物体同步滚动可以传同一个时间戳 _propBlock.SetFloat(_TimeAtLoad”, Time.time); _propBlock.SetVector(_ScrollSpeed”, scrollSpeed); _renderer.SetPropertyBlock(_propBlock); } // Update函数可以完全为空滚动由GPU每帧自动计算。 void Update() { } }优势零CPU开销CPU端不再需要每帧计算和设置UV偏移。只需要在开始时设置一次速度参数。极致性能滚动计算在顶点着色器或片段着色器中完成GPU并行处理效率极高。易于同步所有使用此材质的物体只要_Time一致滚动就是完全同步的。注意事项需要一定的Shader编写知识。滚动速度_ScrollSpeed是相对于纹理坐标的可能需要根据纹理大小和屏幕空间进行换算。确保纹理的Wrap Mode仍然是Repeat。3.3.2 对象池与动态启用/禁用对于大量重复的滚动背景元素如星空可以使用对象池管理。只激活视口范围内的对象离开视口的对象回池并重置位置实现“无限”滚动。这常用于2D横版卷轴游戏。这更多是一种游戏逻辑优化能大幅减少同时渲染的对象数量。4. 性能分析与常见问题排查优化之后如何验证效果出了问题怎么查4.1 使用Unity Profiler进行深度分析CPU模块查看Update和Canvas.SendWillRenderCanvases针对UI的耗时。优化后你的滚动脚本CPU耗时应该极低0.1ms。关注GC Alloc垃圾回收分配。在CPU Usage面板的顶部确保你的滚动代码在Update中不会产生任何GC Alloc即每帧不分配新内存。MaterialPropertyBlock的new操作应该在Start/Awake中完成而不是在Update中。Rendering模块查看Batches批次数和SetPass calls。优化目标是让使用相同滚动材质/纹理的对象批次数越少越好。观察Dynamic Batching和Static Batching的计数了解合批情况。Memory模块检查Texture内存占用确认纹理尺寸和压缩格式是否符合预期。检查Materials数量确认没有因为不当操作产生大量材质实例。4.2 常见问题与解决方案速查表问题现象可能原因解决方案滚动边缘有接缝或拉伸纹理Wrap Mode未设置为Repeat在导入设置中将Wrap Mode改为Repeat移动设备上滚动卡顿1. 每帧产生GC Alloc2. Draw Call过高3. 纹理尺寸过大/压缩不当1. 检查代码避免在Update中new对象如Vector2, Rect。使用缓存变量和Repeat。2. 使用Sprite Atlas合批或使用Shader方案。3. 优化纹理导入设置Max Size, 压缩格式关闭Mipmaps。RawImage滚动时UI整体卡顿频繁修改uvRect导致Canvas批量重建1. 确保uvRect值真正改变时才赋值。2. 将频繁滚动的RawImage放在独立的Canvas下与其他静态UI隔离避免牵连重建。3. 考虑使用Shader方案替代。多个背景滚动不同步每个对象使用独立的计时器或速度计算有浮点误差使用一个全局的时间管理器来控制速度或使用基于Shader的方案所有对象共享_Time变量。WebGL平台性能不佳WebGL到JavaScript的调用开销、内存限制1. 尽可能使用Shader方案将计算放在GPU。2. 减少每帧的C#到Native的调用如减少GetComponent、Find等。3. 严格管理纹理内存。滚动速度不稳定忽快忽慢使用Time.deltaTime但Time.timeScale被改变明确需求如果希望滚动不受游戏暂停影响使用Time.unscaledDeltaTime。使用MaterialPropertyBlock后颜色等属性修改无效MaterialPropertyBlock会覆盖材质属性但需要正确设置确保在设置PropertyBlock时包含了所有需要修改的属性如颜色_Color。通常做法是先GetPropertyBlock修改所需属性再SetPropertyBlock。4.3 一个真实的排查案例GC导致的卡顿我曾经遇到一个情况一个看似优化过的SpriteRenderer滚动脚本在低端安卓机上每隔几秒就会卡一下。用Profiler抓取发现CPU使用率有规律的尖峰并且伴随着GC Alloc。仔细检查代码发现虽然使用了MaterialPropertyBlock但偏移计算是这样的_offset new Vector2(scrollSpeedX, scrollSpeedY) * Time.deltaTime; _propertyBlock.SetVector(_MainTex_ST, new Vector4(1, 1, _offset.x, _offset.y)); // 这里new了Vector4问题在于new Vector4(...)这个操作在Update中每帧执行产生了GC Alloc。虽然一个Vector4很小但几十上百个对象每帧都new累积起来GC就会频繁触发。修复方法预定义一个Vector4变量用于存储ST信息只修改其z、w分量。private Vector4 _stVector new Vector4(1, 1, 0, 0); // 初始化 void Update() { // ... 计算_offset ... _stVector.z _offset.x; _stVector.w _offset.y; _propertyBlock.SetVector(_MainTex_ST, _stVector); // 没有new操作 }这个改动彻底消除了该脚本的每帧GC Alloc卡顿消失。这个案例告诉我们性能优化必须用数据说话Profiler是你的最佳伙伴任何微小的内存分配在移动端都可能被放大。
返回列表