
1. 内容整体设计与思路拆解1.1 图集是什么为什么游戏里到处都是图集在Unity项目里图集Atlas几乎是绕不开的东西。把很多张小图合并到一张大纹理上一次Draw Call就能渲染一大堆UI或角色碎片省掉了频繁切换材质的开销。美术交付资源的时候也很方便一张图点开能看到一整套图标或动画帧比几百个小文件好管理。Unity工程里最常见的图集来源有三个美术手工拼好的一张PNG、SpriteAtlas打包产物、AssetBundle里加载出来的纹理。但“方便”到了真正要用的时候就会变成另一个问题我只想拿里面的一张小图标怎么办如果不做任何处理直接把整张图拖进场景要么显示成一整块大图要么出现一大片透明区域根本没法用。我第一次遇到切割图集需求是在接一个外包UI项目的时候。对方很热情地给了一套“已经优化过的界面资源”打开一看是一张4096×4096的图里面有几十个按钮、背景框、小图标全都是手工排好版的没有任何预设的切割信息。同事的第一反应是“让美术重出一套”但我当时的想法是这个活儿能不能自己在Unity里解决后来的经验告诉我大部分情况真的可以而且不管是编辑器中切好再导出还是运行时动态切出来都有成熟的套路。1.2 什么时候才真正需要“切割”先泼一盆冷水不是所有图集都需要切。Unity自带的SpriteAtlas本来就是为了让Sprite保持子图引用的你拖进UI的时候只要把Sprite指定到对应小图运行时Unity会替你从Atlas里取正确的纹理区域根本不用拆分。但下面这几种场景切割就变成了刚需美术交付的是一张PNG加一份JSON/坐标文件但项目团队没有接入TexturePacker或别的工具链直接用原图没法被Sprite引用。从AssetBundle或者其他引擎的项目迁移资源原来的图集是“一整张图”但Unity侧没有给每个子图生成独立Sprite资源。服务端下发的热更资源里只有一张大图需要运行时按坐标切出多个图标比如活动奖励道具、头像框不能每次都让客户端重新打包。拿别人开源项目里的图集练习只有一张合成好的图没有源文件你想把这些子图单独存成PNG或序列帧动画。判断标准就一句话你有没有办法安全地拿到每个子图的位置信息。有位置信息就能切没有位置信息就先得去做“自动切分”或者接受只能整图使用的方案。1.3 先花两分钟检查手头这张图到底能不能切拿到一张图之后我先不管你用什么方案先做三件事看Texture Type选中贴图在Inspector里把Texture Type从Default改成Sprite (2D and UI)。不改的话后面所有切割逻辑都会失败。看Read/Write Enabled运行时用GetPixels之前这个必须勾上。编辑器里脚本切的话不强制但为了保险也建议打开。看是否有透明通道如果图集是JPG格式或者被压缩成了不带Alpha的格式切出来的“透明底”小图会变成黑底或白底这个后面会专门讲。这三项都正常才进入下一步选型。2. 切割前必须搞明白的纹理与导入配置2.1 一个容易被忽略的关键点导入类型决定一切很多人以为“切割图集”就是写个循环遍历像素把区域复制出来。其实Unity里最优雅的做法不是直接操作像素而是利用TextatreImporter的SpriteMetaData。每个Sprite子图在Asset里本质上是一条记录包含Rect、Pivot、Alignment等信息。Unity的图集资源或者多Sprite模式的贴图在导入时已经把这些metadata写入到了TextureImporter里你要做的事其实是把这些记录读出来然后“导出”或“重新生成”成独立资源。所以切割方案的选择第一步要看你的图集是“Unity官方多Sprite模式”还是“外部打包后的整图”。前者直接用SpriteMetaData切分效率最高后者如果只有坐标没有Unity资源就要从像素层面硬切。这里有个常见的坑很多人把图集拖进Unity直接把Texture Type设成Sprite然后看到Sprite Mode是Single就开始怀疑“是不是切不了”。其实Sprite Mode必须改成Multiple并点击Sprite Editor去把每个子图框选出来这样Unity才会在内部生成多个Sprite。如果你已经有一张外部图集但没有SpriteMetaData也可以用Unity自带的自动切片功能去生成这些框操作路径是Window 2D Sprite Editor然后在左上角Slice菜单里选Automatic或By Grid。2.2 Max Size、Compression与sRGB的影响再强调一遍这些选项看起来跟“切割”没关系但实际切出来的图片质量、边缘效果、透明通道都会受它们影响Max Size如果原始图集是4096而导入设置里Max Size只有2048贴图会被Unity压缩缩小像素坐标全部错位切出来的子图会模糊甚至裁剪错误。拿到图集先看原始分辨率把Max Size改成足够大。Compression编辑器里切分并写入Asset的时候建议临时把Compression改成None或RGBA32。压缩格式如ASTC、ETC2、DXT5会对像素做有损处理切出来的边缘色值会发生偏移尤其是半透明区域。sRGB(Color Texture)UI图集一般保持勾上。但如果你做的是数据纹理比如光照贴图、地形数据贴图勾了sRGB会让颜色产生伽马后再切切完颜色不对。实际项目里大部分图集都是UI或序列帧默认勾着没问题。这个道理有点像你想把一块蛋糕切开分给同事切之前先得把包装盒拆开、确认蛋糕没有冻成硬块。压缩格式就是那层“包装盒”你不拆刀会钝切出来全是碎渣。2.3 从SpriteMetaData里读取每个子图的位置信息如果你的图集已经是Multiple模式并且有Sprite Editor里框选过的记录用脚本读取非常直接string assetPath AssetDatabase.GetAssetPath(texture); TextureImporter importer AssetImporter.GetAtPath(assetPath) as TextureImporter; SpriteMetaData[] metas importer.spritesheet;每个SpriteMetaData都有一个rect就是该子图在图集中的像素坐标范围。有了rect就可以做两件事一是直接编程批量导出子图PNG二是运行时用这个rect从原生纹理里裁剪。需要注意的是SpriteMetaData里的rect是在“原始像素坐标”下的跟你在Sprite Editor里看到的位置一致不会考虑Packing后的Padding。如果你用的是SpriteAtlas打包出来的图集那情况不一样SpriteAtlas里每个Sprite的实际像素位置是由打包器决定的而且默认有边框Padding。此时必须通过SpriteAtlas.GetSprite拿到Sprite的textureRect而不是直接在原始纹理上按格子像素切。3. 编辑器内批量切割图集一次搞定3.1 为什么优先推荐在编辑器里切如果你只是“偶尔用一次”那用Sprite Editor手动框一下也行。但如果是几十上百个子图手动操作会让人崩溃。而且人工框选出来的坐标可能有几像素偏差之后还要逐个检查。我自己的习惯是能用编辑器脚本批量处理的绝不用鼠标点。编辑器切图最大的好处是结果可以落盘。切完的子图会成为项目里的独立PNG资源后续任何人打开项目都能直接用美术也能方便地替换某个子图而不影响其他部分。对于从别的项目迁移资源来说这一步很重要因为你不会希望每次打开项目都要跑一遍“运行时切割”。3.2 Sprite Editor自带的自动切片够用吗Unity自带的Slice功能里有两个选项By Grid和Automatic。By Grid适合那种排列非常规整、每个格子大小固定的图集Automatic会识别透明区域自动框选适合不规则的图标集合。我实际用下来的体验是Automatic对边缘透明的处理不错但如果子图之间有较多间距或者有的图集包含细长阴影自动框选出来的Rect可能偏大或偏小。这时候我一般先用Automatic切一遍再手动微调几个不对劲的框。这种“半自动”的效率远高于纯手动。如果你只是需要“目前能用”不必非要写脚本Sprite Editor里搞定就已经达到了目的。但脚本的价值在于可重复项目里以后每次更新图集、美术调了某个图标位置跑一下菜单就能把所有子图重新导出一次。3.3 写一个编辑器菜单脚本把子图全部切成PNG这里直接给一个我项目里在用的简化版本功能是选中一张Multiple模式的图集右键菜单一键导出所有子图为PNG到同目录下一个新文件夹并按子图名称命名。using System.IO; using UnityEditor; using UnityEngine; public static class AtlasSlicer { [MenuItem(Assets/Split Atlas to PNGs)] private static void SplitAtlas() { Texture2D texture Selection.activeObject as Texture2D; if (texture null) return; string assetPath AssetDatabase.GetAssetPath(texture); TextureImporter importer AssetImporter.GetAtPath(assetPath) as TextureImporter; if (importer null || importer.spriteImportMode ! SpriteImportMode.Multiple) { Debug.LogError(请先将 Texture Type 设为 SpriteSprite Mode 设为 Multiple。); return; } SpriteMetaData[] metas importer.spritesheet; if (metas.Length 0) { Debug.LogError(没有找到任何 SpriteMetaData请在 Sprite Editor 里先切分出子图。); return; } string outputDir Path.Combine(Path.GetDirectoryName(assetPath), texture.name _split); if (!Directory.Exists(outputDir)) Directory.CreateDirectory(outputDir); string tmpPath assetPath .tmp.png; Texture2D readableTex null; string oldFormat importer.textureFormat.ToString(); bool oldReadable importer.isReadable; TextureImporterFormat oldCompressedFormat (TextureImporterFormat)0; // 临时改为可读、无压缩确保像素值准确 importer.isReadable true; importer.textureCompression TextureImporterCompression.Uncompressed; AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); byte[] bytes File.ReadAllBytes(assetPath); readableTex new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!readableTex.LoadImage(bytes)) { Debug.LogError(读取纹理失败。); return; } for (int i 0; i metas.Length; i) { SpriteMetaData meta metas[i]; Rect rect meta.rect; int x Mathf.FloorToInt(rect.x); int y Mathf.FloorToInt(rect.y); int w Mathf.FloorToInt(rect.width); int h Mathf.FloorToInt(rect.height); Color[] pixels readableTex.GetPixels(x, y, w, h); Texture2D subTex new Texture2D(w, h, TextureFormat.RGBA32, false); subTex.SetPixels(pixels); subTex.Apply(); string safeName string.IsNullOrEmpty(meta.name) ? sprite_ i : meta.name; string outPath Path.Combine(outputDir, safeName .png); File.WriteAllBytes(outPath, subTex.EncodeToPNG()); } // 还原导入设置 importer.isReadable oldReadable; AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); AssetDatabase.Refresh(); Debug.Log(切割完成输出目录 outputDir); } }这段代码里最有用的不是最后导出那步而是“临时改成Uncompressed isReadable”这一段。很多人在编辑器脚本里直接用AssetDatabase.LoadAssetAtPath取出来的Texture2D发现GetPixels报错“Texture is not readable”就是因为在导入设置里没开Read/Write。临时改导入参数再重新ImportAsset有点慢但对于一次性切割任务来说完全能接受。切割完成之后记得去AssetDatabase.Refresh()否则你虽然写入了PNG文件Unity文件系统里还看不到。另外一个细节是我把meta.name作为导出文件名如果子图是中文名输出的文件也保留中文项目内引用没问题但打包到某些平台的AssetBundle里可能会遇到名称兼容问题建议项目里sprite命名统一用英文。3.4 切完之后的恢复与资源命名细节脚本运行完后图集的TextureImporter配置会被还原成之前的isReadable状态。但textureCompression我代码里没还原因为这种工具脚本通常只执行一次执行完后你就很少再使用这张大图了。如果你希望跑完之后图集仍然作为普通纹理供其他逻辑使用最好把textureCompression也一并还原成原值。代码里可以先用importer.textureCompression读取结束后再赋回去这样更严谨。命名这件事挺影响后续效率。我见过很多项目用sprite_0、sprite_1这样的名字导出结果等拼UI的时候所有人都得对着坐标表查“sprite_17是哪个按钮”。所以我建议如果美术给的图集里已经包含子图ID或命名尽量直接在Sprite Editor里重命名好如果没有命名信息就按功能和位置命名比如btn_start_normal、icon_bag_gold这类宁可规范一点也不要图省事。4. 运行时动态切割的三种实现4.1 为什么有时必须在运行时切编辑器切割适合“一次性把资源准备好”。但有些项目根本做不到预先把所有子图切出来比如服务端下发的每日活动奖励列表奖励图标是服务端给的一张大图里面排列着所有活动道具图标客户端要按配置实时取某一块区域。玩家自定义头像上传后服务端返回一张拼接了缩略图的图集客户端要裁剪出头像区域。数字孪生/监控大屏场景里运行时要根据设备状态动态替换纹理区域。这种场景下你不能让玩家等编辑器脚本跑一遍再进游戏。你需要的是运行时裁剪Texture2D并生成Sprite整个过程要足够快、足够稳。4.2 方式一GetPixels SetPixels最直观但性能一般思路就是利用读取到的像素区域生成一张新Texture2D再转成Spritepublic static Sprite CutSprite(Texture2D source, Rect rect) { Color[] pixels source.GetPixels( Mathf.FloorToInt(rect.x), Mathf.FloorToInt(rect.y), Mathf.FloorToInt(rect.width), Mathf.FloorToInt(rect.height)); Texture2D newTex new Texture2D( Mathf.FloorToInt(rect.width), Mathf.FloorToInt(rect.height), TextureFormat.RGBA32, false); newTex.SetPixels(pixels); newTex.Apply(); return Sprite.Create(newTex, new Rect(0, 0, newTex.width, newTex.height), new Vector2(0.5f, 0.5f)); }这个方案的坑有三个第一source必须开启Read/Write Enabled这会让图集在内存里多一份CPU可读副本图集越大内存占用越翻倍第二GetPixels在主线程调用对512×512以上的区域执行一次还行如果一次性要切几十块每块都调用一次帧率会明显掉第三新生成的纹理没有Mipmap也没做压缩如果用途只是UI图标问题不大但如果是3D场景里的贴图需要额外处理。我的建议是尽量在初始化或加载阶段做别放在每帧的Update里。上面的代码简单归简单但它不是高性能方案。4.3 方式二Graphics.CopyTexture快很多但有限制如果你手头的Unity版本是2019.2以上可以考虑Graphics.CopyTexture。它是GPU侧的纹理复制直接从GPU内存里拷贝指定区域不会把图集回读到CPU性能比GetPixels快一个数量级。public static Texture2D CopyRegion(Texture2D source, RectInt region) { Texture2D dest new Texture2D(region.width, region.height, source.format, false); Graphics.CopyTexture(source, 0, 0, region.x, region.y, region.width, region.height, dest, 0, 0, 0, 0); return dest; }但这里有一个很容易踩的坑Graphics.CopyTexture要求两边的Texture2D使用相同的GraphicsFormat而且如果有压缩区域复制会按block对齐导致裁剪位置不精确。所以在实际操作里如果图集是ASTC或ETC2这类有损压缩格式CopyTexture出来的像素块会跟原始Rect对不齐。稳妥的做法是把源Texture的导入格式改成RGBA32取消压缩然后再用CopyTexture。这个限制意味着它并不能完全替代GetPixels只是在“不压缩纹理”的前提下性能更好。4.4 方式三RenderTexture Blit适合需要shader处理的场景如果你的“切割”还附带一些额外需求比如旋转、缩放、加描边、调色那直接用GetPixels就不合适了。用RenderTexture配合Graphics.Blit把源纹理以指定UV区域绘制到一张新的RenderTexture上再ReadPixels读回Texture2D这套方案虽然代码量多一点但扩展性最强。public static Texture2D BlitRegion(Texture2D source, Rect uvRect, int width, int height) { RenderTexture rt RenderTexture.GetTemporary(width, height, 0); RenderTexture.active rt; Graphics.Blit(source, rt); Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(); RenderTexture.ReleaseTemporary(rt); return result; }注意这里的uvRect如果你的源图有大块透明区域直接Blit会把透明区域也算进去导致目标纹理尺寸偏大、内容缩得小。实际项目中需要额外用Material来做UV裁剪或者干脆在Blit之前先计算好目标矩形。我自己一般用这个方案做“序列帧旋转”或者“局部特效合成”纯静态裁剪还是用GetPixels更省事。4.5 运行时切割该不该做缓存不管你用哪种方式运行时切割出来的纹理都建议做缓存。同一个图集、同一个Rect如果被多次调用每次都重新GetPixels创建一张新纹理内存会飞速上涨。我一般用一个Dictionarystring, Spritekey用“图集名_Rect”拼接这样同一个区域只会切一次后续全部复用。要注意的是切出来的纹理和Sprite生命周期要跟场景绑定场景切换时记得统一清掉不然一张图集如果很大几十个Sprite缓存下来内存也要几十MB。5. 常见问题与排查技巧实录5.1 切出来是黑图、白图图标是透明的却成了色块这个问题90%出在导入格式上。如果图集本身是不透明的JPG或者PS导出时没有保留Alpha那切出来的子图自然没有透明通道。先检查原始图集有没有Alpha通道在Photoshop或者其他看图工具里把背景图层关掉如果周围还是白色说明这张图本身就是白底。处理方式是让美术重新提供带透明通道的PNG同时Unity里确保Compression不要设为ASTC尤其是Android发布时的ETC2它不一定保留完整Alpha要看具体选型。否则你在Editor里看到切出来正常打包到手机上就发现半透边缘发黑。5.2 边缘出现白边或黑边这个在切割图集后生成Sprite特别常见。原因是图集打包时相邻子图之间是有Padding的而且很多引擎在生成mipmap时会把相邻像素【渗入】到当前子图边缘。Unity里表现为小图本身没问题但运行时缩小显示时边缘有一圈比图本身颜色更浅或更暗的边。解决办法切割后生成的新纹理把Texture Wrap Mode设为Clamp不要用Repeat否则UV采样时会跨到相邻像素。创建Sprite时把Pivot设置好但关键是外圈的半透明像素在切出来的子图上手动把最外圈像素改成完全透明或者用shader里做Alpha Bevel处理。对于动效UI来说最有效的做法是切割后再把每个Sprite的边框Padding值统一设为4像素在Sprite Editor里调整。如果你的子图是要做九宫格拉伸的按钮切割后一定要到Sprite Editor里把Border设置好不然拉伸时边缘会严重变形。5.3 SpriteMetaData读出来的rect是对的但切出的PNG偏了这类问题大多是因为坐标系不一致。Unity的Texture坐标原点在左下角但美术给的坐标文件比如TexturePacker导出的JSON很多是以左上角为原点。你拿到JSON里的x、y直接当Unity像素坐标用切出来的图当然会上下颠倒或者偏移。解决办法是把美术坐标先做一次Y轴翻转texY textureHeight - spriteY - spriteHeight然后再用。另外还要注意一张图集的缩放因子如果你在Sprite Editor里的Pixels Per Unit设置不是1运行时用rect做像素定位时也要换算。5.4 切割后原图引用全部失效UI上的Sprite变成了“Missing”这是因为你把图集从Multiple切分成独立PNG之后旧的Sprite引用已经指向图集内嵌Sprite而图集一旦被移除或改变原来挂在UI上的引用就断了。如果项目里很多界面已经用图集子图做UI一定要先把引用检查清楚再切。我一般会先做一次“查询引用”确认没有UI依赖后再跑切分脚本如果有依赖可以改成运行时从图集用SpriteAtlas引用这样就不需要物理切分了。5.5 常见的运行时报错速查表报错信息原因解决方案Texture xxx is not readable源Texture没有开启Read/Write导入设置里勾选Read/Write Enabled后重新导入The requested texture size is too largeMax Size限制或GPU单纹理上限调整Max Size或改为切割后重新生成小纹理IndexOutOfRangeException: Array index is out of rangeRect越界或图集坐标与元数据不匹配核对SpriteMetaData里的rect是否在纹理范围内Failed to create sprite because texture is compressed创建Sprite时Texture是压缩格式不可读设置textureCompression为Uncompressed后再创建GetPixels is not allowed to be called on a non-ARGB32 texture纹理格式不是RGBA32导入设置改为RGBA32或24位如果你遇到的不在上表里建议在切割前先打印一张图集的textureFormat、isReadable、width和height很大概率能一眼定位问题。6. UGUI下SpriteAtlas的切割与普通图集的区别6.1 SpriteAtlas不一定需要物理切割UGUI项目里很多人会把资源打包进SpriteAtlas然后在Image组件的Source Image里指定Sprite。如果在面板上拖Sprite拖不了因为整个Atlas被当成一个资源有人会想“是不是要先把图集切了”。其实SpriteAtlas本来就是用来打包Sprite的你不需要切只需要给Image指定一个打包进去的Sprite子资源引用。如果你是在代码里动态指定可以用Sprite icon atlas.GetSprite(icon_skill_double);SpriteAtlas的GetSprite方法会根据name返回对应的Sprite引用。压力当多个界面都要用同一个图标时这个Sprite引用是共享的也不需要你自己做缓存。6.2 什么时候SpriteAtlas也需要“切”有一种情况你拿到的是运行中的SpriteAtlas内置的texture而不是Sprite引用。比如通过AssetBundle加载出来的SpriteAtlas有时候你想把里面某个子图转成独立的Texture2D去做特效贴图这时还是得走像素级切割。但注意SpriteAtlas里的textureRect跟原始图集的坐标不同需要使用sprite.textureRect来定位而不是拿整个Atlas纹理按从左到右的格子算。Sprite s atlas.GetSprite(targetIcon); Rect r s.textureRect; // 从 atlasTexture 中按 r 裁剪即可这里的r是Sprite在Atlas纹理中的像素区域直接用这个rect做GetPixels就是准确的。6.3 一个实用小技巧用切割结果生成图集预览图我不知道你是不是也遇到过这种场景美术发来一张图集项目里引用到的Sprite全都散落在各个组件里你想看“整个图集长什么样”只能双击打开纹理。后来我喜欢把切割脚本多写一步在切割的同时生成一张“索引图”用每个子图的Sprite本身在图集里对应的名字标注到预览图上这样美术或策划沟通的时候直接看索引图就能对号入座效率高很多。实现也不复杂就是在切割时把每个Sprite的UI界面截图保存成PNG或者在编辑器Window里用OnGUI逐行绘制出子图名称和缩略图。最后再分享一个实际工作中的小技巧我自己的项目里其实最常用的反而不是“把图集切成PNG”而是“把图集切完重新打小图”。很多美术图集是整张交付的但在实际UI搭建过程中只需要其中几个图标如果整张图集都打包到AssetBundle里不管用不用都会占资源。我一般先用切割脚本把需要的子图导出来然后用Unity自带的打包工具把这几个小图重新打包成一个小图集这样既能保证运行时Draw Call合并又不会加载整张大图。这个过程看似多了一步但在移动端特别是小游戏平台上收益很明显少加载几MB大图对内存和启动速度都有帮助。同理如果你在做类似“Unity数字孪生”这种需要频繁更新设备状态图标的项目这个方法值得一开始就设计进去不要等到界面卡了再回头补课。自己在项目里踩过几次坑之后我现在处理任何图集都会先问一句这个资源是一次性用还是长期维护一次性用就直接Sprite Editor手动切长期维护就一定写编辑器脚本宁可多花半小时写脚本也不要每个版本手动重切一遍。