ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity SpriteAtlas图集:2D合批、Draw Call与内存权衡

Unity SpriteAtlas图集:2D合批、Draw Call与内存权衡 1. 从一次Draw Call暴涨说起SpriteAtlas真正解决的到底是什么问题前阵子接手一个2D项目做性能优化打开Profiler的Frame Debugger一看一个看起来干干净净的背包界面Draw Call稳定在80多。美术用的全是零散的小图每个道具图标一张64×64的PNG二三十个格子排下来几乎每一张图都单独占了一次渲染批次。当时第一反应是把这些图打成一张大图不就行了于是引入了SpriteAtlas。打完图集之后同一个界面的Draw Call掉到了个位数。这件事让我重新梳理了一遍SpriteAtlas的定位它从来不是什么高级功能而是2D渲染里最基础的合批手段核心价值就一句话——把多张纹理合并成一张纹理让原本无法合批的渲染指令能够合批。要理解它为什么有效得先回到渲染批次的基本规则。GPU在状态切换上是极其昂贵的Unity的SpriteRenderer也好UGUI的Image也好它们最终都落在同一个判断逻辑上如果两次绘制用的是同一个材质Material和同一张纹理Texture并且中间没有打断合批的操作那么它们就可以被塞进同一个Draw Call里一次性提交。反过来纹理一换批次就断新的Draw Call就得重新走一遍状态设置。所以当你的场景里有20张不同的小图时即便它们用的都是默认的Sprites-Default材质只要纹理不同最坏情况下就是20次Draw Call。SpriteAtlas做的事情就是把这些分散的小纹理在打包阶段拼进一张大纹理并为每个原始Sprite记录它在图集里的位置UV矩形。运行时引擎读到的仍然是你引用的那个Sprite对象但底层采样的是同一张大图。纹理一致了合批条件自然成立。这是用空间换批次的经典思路。不过这里有个容易被忽略的点合批能不能成还取决于这些Sprite在渲染顺序上是不是连续的。如果你的UI层级里图标A、图标B、一张来自另一张图集的图C、然后又是图标A的兄弟节点那么批次还是会被打断。图集只能解决纹理不一致这一层问题解决不了渲染顺序交叉的问题。这一点很多人第一次用图集时都会踩明明打了图集Draw Call却没怎么降最后发现是层级穿插导致的。再往深一层说SpriteAtlas对内存的影响其实是双向的。好处是减少了纹理切换带来的开销也让纹理采样更集中坏处是图集本身是一张大纹理一旦加载就要整张驻留内存。一张2048×2048的RGBA32图集就是16MB如果里面只放了一个你当前需要的小图标剩下15MB多都是陪跑的。所以图集不是越少越好、越大越好它是一道需要根据项目实际使用模式去算的账。这也是为什么我后面会花专门一节讲图集粒度和分组的问题。总结一下这一节想表达的核心判断SpriteAtlas的收益主要来自减少Draw Call次要收益是降低纹理切换和状态切换开销它的成本是内存驻留粒度变粗。理解了这两端的权衡后面所有的配置项、分组策略、运行时加载方式才有判断依据而不是照着教程抄一遍参数了事。2. 建图集这件事编辑器里的配置项比你想的要多第一次创建SpriteAtlas很简单在Project窗口右键Create 2D Sprite Atlas然后往Objects for Packing列表里拖文件夹或单张Sprite就行。但真正决定这个图集能不能用、好不好用的是Inspector里那一堆参数。我见过太多项目建完图集之后从来没看过这些选项结果打包出来要么边缘有黑线要么压缩格式选错要么图集体积离谱。下面按我的实际使用顺序捋一遍。2.1 TypeMaster和Variant的分工Type只有两个值Master和Variant。Master是主图集正常打包内容就是它。Variant是变体它不包含自己的Sprite列表而是引用某个Master然后用自己的缩放比例和压缩格式重新生成一份。典型用法是做高清/低清两套资源Master用2048尺寸、ASTC 6x6Variant用0.5的Scale、ASTC 8x8低端机走Variant省显存。变体的Master Atlas字段必须指到一个Master上Scale字段控制缩放这个值会影响最终图集的尺寸。用Variant有个前提你运行时要能在合适的时候切换到Variant这通常和资源加载策略绑在一起。如果只是想简单省内存把Master的压缩格式和各平台Override调好绝大多数情况够用不必强上Variant增加复杂度。2.2 Objects for Packing能放什么有没有优先级这里可以放文件夹、单张Sprite/Texture也可以放另一个SpriteAtlas作为Variant的引用。放文件夹是最省心的做法但要注意文件夹里被引用的Sprite会以整个文件夹为单位纳入如果你往里塞了一个不该进来的图它会静默打包进去不会报错。如果同一个Sprite被多个图集同时包含Unity的处理是有优先级的而且是先命中先算。这就会带来一个隐蔽问题你以为某张图在A图集实际它被B图集先吃掉了运行时绑定可能就找不到。我的习惯是绝对不让同一张Sprite出现在两个Master里靠命名规范和目录结构来保证唯一归属。2.3 Packing相关Padding、Tight Packing、Rotation这三个参数直接决定图集打包的松紧度和边缘质量。PaddingSprite之间的像素间隔。默认值偏小遇到采样溢出bilinear过滤在边缘多采了一点时就会出现相邻图的颜色串到边缘的黑边、亮边。我的经验是至少给2像素如果图集里有很多带描边、带半透明渐变的图给到4像素更保险。Tight Packing开启后按Sprite的不透明区域紧贴打包能省空间。但它和某些Sprite的Mesh设置配合不好容易在运行时出现边缘被裁切的现象。如果Sprite的Mesh Type是Tight又开了图集Tight Packing请务必实测边缘尤其是做挤压动画的Sprite。Allow Rotation允许旋转Sprite来省空间。对静态UI图标无所谓但如果Sprite需要按UV做精确变换、或者代码里有基于纹理方向的操作开了会出问题。参数我常用的取值理由Padding2~4 px防止边缘采样溢出串色Tight PackingUI图集关闭场景道具可考虑开启UI对边缘最敏感Allow Rotation一般关闭避免UV方向相关的隐性bugRead/Write关闭除非运行时要做像素级处理2.4 Read/Write、Generate Mip Maps和Filter ModeRead/Write Enabled打开后CPU能访问纹理数据代价是内存翻倍CPU和GPU各存一份。图集基本没有运行时CPU读写纹理的需求除非你在做像素级碰撞或截图分析否则一律关闭。我见过有人因为图集Read/Write开着整个包体显存直接爆掉。Generate Mip Maps对2D UI来说通常关闭因为界面缩放不会缩到需要mip的程度开了反而增加约1/3内存。但如果你的Sprite会随相机远近缩放的2D场景比如横版卷轴里的远景装饰开启能显著减少远处闪烁。这是个场景判断不是一刀切。Filter Mode默认Bilinear就行。Point只在你做像素风、需要硬边缘时才用。2.5 Platform Overrides与压缩格式体积和质量的取舍这是真正影响包体和显存的地方。图集的压缩格式按平台分别设置Android优先ASTC。6x6是质量与体积的平衡点图标细节要求高用4x4纯色块图标可以放到8x8。老设备不支持ASTC时要考虑ETC2回退。iOSASTC同样可用4x4或6x6。iPhone上ASTC的支持很稳。PC/StandaloneDXT5BC3适合带Alpha的图DXT1BC1适合无Alpha图。Default这个是所有平台的兜底通常选一个通用格式。Max Texture Size会限制最终图集的最大边长。如果图集内容超出了这个尺寸Unity会缩小图集来塞进去图就糊了。所以图集内容多的时候务必留意这个值别让引擎在背后偷偷缩图。判断方法是打完包看生成图集的实际尺寸或者开Sprite Packer窗口观察。提示压缩格式选完最好在目标机型或者至少是近似的设备上验证一遍。因为ASTC在不同设备上的解码表现会有差异编辑器里看着清晰真机上发糊的情况是存在的。3. 运行时把图集里的Sprite取出来LateBinding与atlasRequested的完整链路图集配置好、打包正常编辑器里你引用的Sprite能正常显示一切看起来都对了。但一旦进到真机或者打包后的Player很多人的第一反应是Sprite怎么全没了这几乎必然和**Late Binding延迟绑定**机制有关这也是SpriteAtlas最容易栽跟头、又最难排查的部分。理解这条链路的来龙去脉比记住某个API重要得多。3.1 编辑器能显示、打包后不能显示问题出在哪编辑器里的Sprite Packer模式默认是Always Enabled意思是编辑器会在后台自动帮你把图集建好并绑定。所以你在编辑器里根本感受不到绑定这个环节的存在。但到了运行时情况变了如果你用了延迟绑定引擎在图集真正被需要之前根本不知道这个图集对应的是哪个Asset它只知道我需要某个Sprite但它的图集还没加载。于是Unity会通过一个事件来问你你要不要自己把这个图集加载进来这个事件就是SpriteAtlasManager.atlasRequested。如果你没订阅它也没用Addressables之类的系统自动处理那么请求无人应答图集就不会被绑定Sprite渲染出来就是空的或者一个白块。3.2 用SpriteAtlasManager.atlasRequested手动接管先看这段最小可用的绑定代码using UnityEngine; using UnityEngine.U2D; public class AtlasLoader : MonoBehaviour { private void OnEnable() { SpriteAtlasManager.atlasRequested OnAtlasRequested; } private void OnDisable() { SpriteAtlasManager.atlasRequested - OnAtlasRequested; } private void OnAtlasRequested(string tag, System.ActionSpriteAtlas callback) { // tag 就是图集资源的名字取决于你资源加载时使用的key var request Resources.LoadAsyncSpriteAtlas(tag); request.completed _ { var atlas request.asset as SpriteAtlas; if (atlas ! null) { callback(atlas); // 必须回调否则绑定不会发生 } else { Debug.LogError($Atlas not found: {tag}); } }; } }这里有几个关键点任何一个疏忽都会导致绑定失败第一tag从哪来。回调里的tag是参数它不是随便来的。在默认情况下它对应图集资源的名字或路径如果你用Addressables它会走Addressables的注册流程。不要想当然认为tag就是文件名最好在回调里先把tag打出来看看再决定用哪种方式加载。第二必须调用callback。这个ActionSpriteAtlas是引擎等你回话用的。你加载完图集必须把atlas传进去否则引擎就一直等着图集就一直不绑定。我在排查时遇到过有人加载成功但因为忘了调用callbackSprite照样是空的。第三必须在合适的时机订阅。OnEnable订阅是比较稳的做法但要保证这个脚本在第一个需要图集的Sprite渲染之前就已经启用。如果启用的时机晚了早期渲染请求就丢了。第四记得在OnDisable里退订。事件不退订脚本销毁后回调还会执行指向已经销毁的对象轻则报错重则内存泄漏。3.3 搭配Addressables时绑定其实是自动的如果你项目已经上了Addressables做资源管理那么恭喜Addressables会自动接管atlasRequested事件。你只要保证图集资源是Addressable的、并且被正确加载。但这里有个坑图集资源本身要被加载它包含的Sprite所属的图集才会进入已绑定状态。如果图集一直没被加载即使Sprite被Addressables实例化了引擎也找不到它对应的图集渲染结果还是空。常见的做法是在进入某个界面前显式预加载该界面用到的所有图集。我通常会在界面打开流程里加一步预加载// 伪代码示意预加载图集的时机 async Task PreloadAtlasFor(string panelKey) { var atlasKeys AtlasConfig.GetAtlasesFor(panelKey); foreach (var key in atlasKeys) { await Addressables.LoadAssetAsyncSpriteAtlas(key).Task; } }预加载的好处不只是保证绑定成功还能提前触发图集的纹理上传避免打开界面时卡一下。这一步在低端机上效果特别明显。3.4 Late Binding开关和GetSprite接口Edit Project Settings Editor Sprite Packer里有个Sprite Packer Mode其中包含Enabled for Builds这样的选项以及Player Settings里有一处关于延迟绑定Late Binding的开关。要不要开延迟绑定取决于你的加载策略如果图集数量少、可以一次性全部加载不开延迟绑定更简单图集跟着构建直接进包Sprite直接引用就能用。如果图集很多、需要按界面或关卡动态加载以控制内存就必须开延迟绑定走上面的atlasRequested流程。运行时获取图集内的Sprite也可以用代码主动取SpriteAtlas atlas ...; // 已加载的图集 Sprite s atlas.GetSprite(icon_sword); // 按Sprite名取 Sprite[] all atlas.GetSprites(); // 取全部GetSprite这个接口在做通用的图标管理器时很好用可以直接按名字从图集取图不用一个个引用。4. 图集打包后丢Sprite的排查链路从引用丢失到平台差异这一节我是专门写给图集在编辑器里好好的一打包就出问题的人的。这类问题症状相似——Sprite显示为空、白块、错图、边缘异常——但根因各不相同。我把实际排查中用得最多的顺序整理成一个链路按这个顺序走绝大多数问题都能定位到。4.1 第一步确认Sprite是否真的进了图集打开Window 2D Sprite Atlas或者Sprite Packer窗口看目标图集里到底有没有这张Sprite。如果不在那就是打包归属问题可能Sprite没被任何图集的Objects for Packing覆盖到也可能被另一个图集抢走了。最省事的判断方式在Project里选中这张Sprite看它的Inspector底部有没有一个Packed的提示显示它被打进了哪张图集。没有的话就是没进任何图集。这里有个经典陷阱Sprite是通过代码动态创建或从AssetBundle加载的没有出现在任何图集的打包列表里。这种图永远进不了图集合批也就无从谈起。要么把它加到图集里要么接受它单独占一个批次。4.2 第二步确认图集有没有包含进构建图集Inspector上有个Include in Build勾选项。如果你用延迟绑定手动/Addressables加载这个选项通常要取消勾选避免图集被强制打进包体造成双份。但如果你没走延迟绑定又把它取消了运行时就找不到图集了。这两个配置是一对必须成对设置不能只动一半。我排查过一个案例图集放在了Resources目录下同时又勾了Include in Build结果包体里出现了两份图集数据浪费了十几MB。图集要么走Resources/AssetBundle的加载路径要么直接随构建进包不要让两条路同时生效。4.3 第三步确认延迟绑定的回调有没有被触发在atlasRequested的回调里加一句Debug.Log看看运行时到底有没有收到请求、收到的tag是什么。如果日志根本没打印说明延迟绑定没开启或者图集已经在构建里引擎不需要向你请求。如果打印了但Sprite还是空的多半是回调里没调callback或者加载失败。4.4 第四步平台差异导致的显示异常以上都确认无误但只在某个平台出问题那就要往压缩格式和平台Override上想。症状可能原因排查方向真机Sprite模糊ASTC块大小过大8x8换4x4或6x6重新出包边缘有黑边/亮边Padding太小、采样溢出加大Padding到4px图突然变大发暗压缩格式不匹配、Alpha通道处理错误检查平台Override部分图能显示、部分不能部分Sprite未进图集或重复归属检查Packed状态图集加载后内存暴涨Read/Write开启、Mip开启、图集过大关闭Read/Write、拆分图集平台Override的优先级容易搞错如果某平台单独设了Override它就会覆盖Default的设置。所以Default配好后别忘了Android和iOS这两栏也要单独确认否则可能出现PC正常、Android发糊的情况。4.5 一个容易被忽略的坑Sprite引用被运行时替换有些项目为了做图集切换高清/低清会在运行时把Image的sprite换掉。如果换的时候引用了错误的图集或错误的Sprite名就会显示成别的图。这类bug很难从图集打包这个角度找到因为图集本身没问题是代码用错了。排查时建议在替换Sprite的地方打日志输出新旧sprite的名字和所属图集。5. 图集粒度、分组与内存占用的权衡什么时候该拆什么时候该合图集用久了团队里一定会出现两种声音一种是图集越少越好省Draw Call另一种是图集太大内存吃不住拆细一点。这两种说法都对但都不完整。真正要回答的问题是在你的加载和使用模式下图集的粒度和分组应该按什么维度切。这一节讲讲我总结的判断方法。5.1 按同时使用分组而不是按资源类型分组最常见的错误分组是按美术资源的类别分所有图标一个图集、所有背景一个图集、所有特效一个图集。问题是一个界面可能同时用到图标和背景特效可能在另一个界面才出现按类型分会导致图集之间的穿越使用既没有省内存还打断了合批。我更推荐按使用场景/界面分组背包界面的图进背包图集战斗界面的图进战斗图集主界面的图进主界面图集。这样加载一个界面时需要的图集是大致确定的内存和批次都比较可控。缺点是同一个通用图标可能出现在多个界面里这时可以把通用图标单独放一个公共图集常驻内存。5.2 图集尺寸不是越大越好图集尺寸由Max Texture Size和内容量决定直接对应内存。一张2048×2048的RGBA32是16MB4096×4096是64MB。移动端的显存是很宝贵的64MB的单张图集在低端机上几乎是灾难。但图集拆得太细也有代价每个图集是一次纹理切换多个小图集之间的Sprite交替渲染会破坏合批。经验数值是单个图集控制在1024~2048是比较稳的范围具体看目标机型和资源量。4096作为上限要谨慎且只在确实需要容纳大量同场景资源时使用。5.3 图集内部的Sprite排列与碎片化图集打包本质是个二维装箱问题Unity的打包算法会尽量塞满。但如果你频繁增删Sprite每次打包的排布都可能变化。这本身不是bug但会带来一个副作用图集资源内容变化会导致下游引用它的资源哈希变化进而触发更多的热更下载量。所以图集内容最好相对稳定不要今天加一张明天删一张。如果一定要频繁改动考虑把易变的图标单独放一个热更图集。5.4 用Variant做清晰度分级但要算好成本前面提过Variant。它在同一份Sprite列表、不同清晰度这件事上确实好用。但Variant是独立的一份图集资源它会额外占用编译时间和包体。如果你的项目本来就只有一个清晰度需求上Variant是纯浪费。只有当你的目标用户覆盖从高端到低端的巨大差异并且有明确的按设备切换策略时Variant才值得。5.5 动态读写的Sprite无法被打包再强调一个实际会遇到的情况运行时用Sprite.Create从Texture2D动态创建的Sprite不会进入任何图集。如果你的头像、二维码、截图这类动态内容也想要合批得自己维护一张运行时可写图集用SpriteAtlas配合RenderTexture之类的方案来实现但这套方案复杂度很高除非确实有大量动态图需要合批否则不建议轻易上。6. 版本管理与自动化让图集变更不再把美术和程序都拖下水图集这个环节一旦项目规模上来最烦人的其实不是技术本身而是协作和版本管理。图集内容变了、引用丢了、某个Sprite被人从图集里删了这些问题在多人协作里几乎是必然会出现的。所以最后一节聊聊怎么用编辑器脚本和流程把这些问题挡在前面。6.1 用编辑器脚本在打包前做一次扫描我习惯在项目里放一个预打包校验脚本用IPreprocessBuildWithReport接口在构建前扫描整个工程找出有问题的Spriteusing UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; using System.Linq; using System.Collections.Generic; public class AtlasPreBuildCheck : IPreprocessBuildWithReport { public int callbackOrder 0; public void OnPreprocessBuild(BuildReport report) { var missing new Liststring(); var guids AssetDatabase.FindAssets(t:Sprite); foreach (var guid in guids) { var path AssetDatabase.GUIDToAssetPath(guid); var sprite AssetDatabase.LoadAssetAtPathSprite(path); if (sprite null) continue; // 简单判断如果Sprite所属图集为空且不是运行时动态图 if (string.IsNullOrEmpty(sprite.texture.name) || !IsInAnyAtlas(sprite)) { missing.Add(path); } } if (missing.Count 0) { Debug.LogWarning($以下 {missing.Count} 张Sprite未被打进任何图集可能导致Draw Call偏高\n string.Join(\n, missing.Take(20))); } } private bool IsInAnyAtlas(Sprite sprite) { var atlases AssetDatabase.FindAssets(t:SpriteAtlas) .Select(AssetDatabase.GUIDToAssetPath) .Select(AssetDatabase.LoadAssetAtPathSpriteAtlas); foreach (var atlas in atlases) { if (atlas null) continue; var packed new SpriteAtlasUtility(); var sprites new Sprite[atlas.spriteCount]; atlas.GetSprites(sprites); if (sprites.Any(s s ! null s.name.StartsWith(sprite.name))) return true; } return false; } }上面这段代码里SpriteAtlasUtility那行是个示意真实项目里更稳妥的方式是维护一份图集配置表把每个图集包含哪些目录、哪些Sprite记录清楚扫描时对表检查而不是去反射运行时内部的GetSprites结果那个结果受打包状态影响不一定准。重点是把这个校验跑在构建前让问题在出包之前就暴露出来而不是等到真机上才发现。6.2 坐标与命名规范比脚本更重要脚本能兜底但真正省事的是约定。我们团队现在坚持两条约定第一所有需要被打包进图集的Sprite都放在约定好的目录下目录结构即图集归属。谁新增资源就往对应目录放图集自动带上不需要手动维护列表。这样最大的好处是新增资源不会漏打包因为目录是图集的Objects for Packing。第二Sprite命名全局唯一。因为GetSprite是按名字取的如果两张图重名取到的可能不是你要的那张。命名用模块_功能_变体这种结构比如icon_bag_sword可读性好也基本不会撞名。6.3 图集内容的变更要纳入Code Review这条听起来有点重但确实有效。图集资源本身是二进制diff看不出来但图集新增/删除Sprite是可以从图集配置的变化里看出来的。我们在提交规范里要求改动图集归属或删除图集内容必须在MR描述里说明原因并附带受影响的界面。这样能拦住很多顺手删了一张图结果某个界面变白块的意外。6.4 热更场景下的额外注意如果项目走热更图集变更会直接产生AB包变化。这里有个经验尽量把图集资源粒度做小、做稳定避免一张图集里塞了几百张图改一张就重下整包。反过来如果你的图集本来就小且稳定热更的下载量会小很多。这两者在分组策略上是矛盾的需要根据项目实际权衡——我一般会优先保证热更的包体增量可控因为这是真金白银的下载成本而Draw Call的优化可以通过别的手段比如合批、动静分离去补。6.5 我实际用下来最省心的一套组合最后说一套我在多个项目里复用下来比较稳的组合资源按界面/场景分目录目录即图集图集Type全部用Master不轻易上Variant延迟绑定开启图集资源走Addressables按界面预加载Padding统一2像素起Android用ASTC 6x6iOS同理构建前跑一次未打包Sprite扫描。这套组合不是最优解但它在内存可控、Draw Call可观、协作省心这三者之间取得了不错的平衡尤其是对中小团队来说维护成本很低。真要说还有什么建议那就是别在项目末期才开始整理图集。图集归属一旦定下来改动成本是递增的越早规范越省事。我见过太多项目前期美术自由发挥末期为了优化临时拆图集结果把一堆引用拆坏返工量比一开始就规范大得多。这件事早做早轻松。
返回列表