尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Unity图集优化实战:从DrawCall原理到Sprite Atlas踩坑指南

发布时间:2026/9/26 8:45:42

资讯中心
01
ARTICLE

Unity图集优化实战:从DrawCall原理到Sprite Atlas踩坑指南

Unity图集优化实战:从DrawCall原理到Sprite Atlas踩坑指南
atlas这个词第一次跑进我的Unity工程是在一个特别普通的下午。项目在低端测试机上跑得磕磕绊绊UI界面一跳到战斗结算FPS直接掉到20出头Profiler面板里DrawCall的数字红得刺眼。后来定位到罪魁祸首——十几个散装小图标各占一张纹理每次渲染都要触发额外的状态切换和提交。当时同事甩过来一句话“把图全打个atlas啊。”我才真正开始研究精灵图集这套东西从原理到落地踩了一遍。这篇文章就是把那段经历整理出来围绕“atlas到底怎么用、为什么能优化、有哪些坑”展开。适合正在做Unity 2D游戏、UGUI界面优化、或者被DrawCall问题折磨的开发者参考新手跟着操作也能上手老手可以重点看第三节以后的排查记录。1. 项目整体认知atlas解决的不是“减少图片”而是“减少提交”很多人第一次接触atlas以为它的作用就是“把一堆小图拼成一张大图”省内存、省空间。这句话对了一半但没说到点子上。atlas最核心的价值是让渲染器把多个物体当成一次绘制来提交也就是俗称的合批。拼图只是手段减DrawCall才是目的。1.1 从一次卡顿说起为什么会出现十几张散图在早期的项目里美术同学交付资源通常是一个图标一个文件比如技能图标、道具图标、按钮底图全放在Resources目录或者AssetBundle里。UI界面上一次性显示二十个图标就等于二十张纹理。渲染时每张纹理都要单独交给GPU一次GPU还要来回切换纹理状态这个开销在PC上不明显但放到手机端会被放大很多倍。尤其是低端安卓机上纹理切换和DrawCall高会让帧率变得极不稳定。我当时实测过一个界面二十五个散装IconDrawCall在三十上下浮动纯UI界面跑不到60帧。把所有Icon收进一张atlas后DrawCall直接降到四个左右帧率立刻回到满帧。这个对比给我的冲击很大也让我意识到atlas并不是什么官方宣传的“高级优化技巧”它其实是2D渲染的基础设施。1.2 Unity的Sprite Atlas与旧版图集工具的差异在Unity 2017之前大家做图集主要靠Sprite Packer或者直接用第三方工具比如TexturePacker手动拼图后导入。Sprite Packer在编辑器里能自动打包但运行时的加载、变体、动态添加对象这些能力都很弱而且它的工作流程和AssetBundle结合时容易出现引用丢失。Unity从2017.2开始主推Sprite Atlas也就是我们现在在Assets/Create/2D/Sprite Atlas里创建的那个东西它把打包逻辑和运行时API统一了。最大的优势有两个一是支持运行时通过SpriteAtlasManager动态加载图集不需要事先把引用钉死二是支持创建Variant变体一套原图可以派生低分辨率版本适配不同性能档位。这对中大型项目来说非常关键因为美术资源不可能为每个机型单独出一套。1.3 图集能解决的三个问题以及它解决不了的问题先说能解决的DrawCall过高合批后大幅减少GPU提交次数。纹理切换开销减少渲染管线中的贴图绑定和状态切换。运行时资源加载慢把多个小纹理合并成一次IO读取加载堆内存也更可控。但图集不是银弹它解决不了以下问题CPU逻辑耗时严重、GC过高、Update里频繁GetComponent这类脚本层问题。资源本身尺寸过大导致的内存占用问题图集反而可能助长这个问题。GPU填充率瓶颈也就是屏幕上像素密度太高导致的渲染压力。所以接入atlas前先分清项目慢在哪里。如果Profiler里CPU耗时低但GPU提交高图集是正解如果纯粹是脚本逻辑写崩了拼再多图集也没用。2. 核心原理拆解为什么“把图拼在一起”就能变快理解atlas为什么有效要从渲染器的工作方式讲起。很多人以为DrawCall就是“画一个物体调用一次”其实更准确的说法是“一次状态不变下的绘制提交”。2.1 GPU渲染与DrawCall的简化模型GPU处理一个物体需要先准备好顶点信息、绑定纹理、设置Shader参数然后发起绘制指令。这个指令队列里如果切换了纹理、切了Shader、改了混合模式GPU会认为“状态变了”需要重新做一堆准备工作。游戏引擎通常把“一次完整的状态设置绘制”记作一个DrawCall但实际耗时里状态切换往往比绘制本身更贵。打个比方你去餐厅吃饭一次点十个菜厨房可以一起备料一起炒这叫合批你每顿饭只点一个菜吃完再点下一个厨房每次都要重新起锅、洗锅、备料这叫DrawCall爆炸。atlas做的事情就是“让十个菜共用一口锅”虽然菜还是十个但备料和切换少了。2.2 图集为什么能让精灵合批Unity的Sprite渲染依赖MeshRenderer组件每个Sprite都有自己的Mesh。材质上使用同一个Sprite Atlas生成的纹理和相同Shader时Unity可以把多个Sprite的顶点数据合并成一个Mesh然后一次性提交给GPU。这个操作在2D游戏里叫Dynamic Batching或Static Batching核心前提就是“使用同一张纹理和同一材质”。如果每个Icon都引用独立的纹理哪怕Shader一模一样引擎也无法合批因为纹理绑定点不同。而一旦纹理变成同一张图集上的不同小格子引擎就有资格把这些顶点合并起来。这是atlas最底层的收益来源也是为什么“拼图”这个动作能提升性能的关键。2.3 为什么不做一张无敌大图集把所有元素全装进去这个问题几乎每个刚接触atlas的人都会问既然一张图集能合批那我做一个8192x8192的大图集把所有Sprite塞进去DrawCall岂不是秒变1理论上是但实际不可行原因有几个方面。首先是平台限制很多移动GPU对单张纹理的最大尺寸有限制常见是2048或4096超过这个尺寸直接无法上传或跑出未知行为。Unity编辑器里可以设置成8192但真机上会出问题这是硬件级别的约束。其次是内存利用率大图集里只要有一个区域被改动整个纹理都要重新上传而且为了填充率考虑图集里空白区域也要占显存。你把一堆小程序图标和几张全屏背景图拼到一张图集里光背景图就把图集占满了小图标全挤在旁边内存浪费非常严重。更重要的是压缩与Mipmap的矛盾不同Sprite对纹理压缩格式的需求不一样UI图标适合RGBA Compressed带有细腻渐变效果的角色立绘可能需要更高精度。硬塞进同一张图集只能全部使用同一种压缩设置最终结果就是要么图片质量被拉低要么内存白白变大。所以我后来养成的习惯是按使用频率和场景分图集而不是追求“一张图集跑天下”。2.4 图集打包的底层流程编辑器里到底做了什么了解了图集的边界后有必要知道Unity在Editor里创建Sprite Atlas后打包时执行了哪些步骤这能帮你理解为什么某些setting会带来副作用。打包流程大致是这样先收集所有需要打包的Sprite对象去掉重复项然后把这些Sprite的纹理像素数据拷贝到一张新纹理上拷贝时会在每个Sprite之间插入一定间距Padding接着根据配置生成Alpha裁剪、旋转、网格排列等元数据最后生成一个SpriteAtlas资产和一张纹理文件。比较重要的是“重新拷贝”这一步。也就是说不管你原来的图片是PNG、JPG还是TGA最终都要被解码成像素数据再重新编码到图集纹理里。所以源图格式对最终图集几乎没有直接影响真正起作用的是Sprite Atlas资产上的压缩设置和Filter Mode等参数。这也是很多人调了半天源图质量没改善的原因——真正的控制项在图集上不在源图。3. 实操过程与核心环节实现从创建图集到动态加载这一节我直接基于自己在项目里的操作流程来写。每个步骤都附上了当时踩坑后的调整逻辑按顺序执行就能得到一个可用的atlas。3.1 创建Sprite Atlas并配置关键参数在Unity里创建图集路径Assets/Create/2D/Sprite Atlas生成资产后选中它Inspector面板里会有一堆配置。下面这些参数是我每次必调的也是新手最容易忽略的TypeMaster 表示主图集Variant 表示变体图集。一个Master可以派生出多个VariantVariant按照百分比缩放着色器采样适合做低配机型资源。Texture Type默认是Sprite保持默认即可。Generate Mip Maps绝大多数2D UI和2D游戏不需要开启。Mipmap多级纹理适合3D场景的远景缩放UI贴图都是固定大小开了反而多占约33%内存。Allow Rotation允许旋转可以提升打包密度但对某些美术资源不友好运行时Sprite方向不对会引发Bug。如果不做特殊处理我建议关闭。Tight Packing开启后Unity会根据Sprite的实际Alpha轮廓来做紧密排布减少空白。但它要求每个Sprite使用独立Mesh对合批有负面影响所以UI项目里我通常关闭美术素材要求不高时才开。Padding控制每个Sprite之间的像素间距至少4推荐8到16。这个数值越大越不容易出现边缘漏色但会略微增加纹理尺寸。Read/Write Enabled如果开启图集纹理会在CPU内存里保留一份可读写副本。2D项目里绝大多数情况不需要关闭可以省大量内存。CompressionUI图集建议用Normal Quality或High Quality角色立绘类图集建议用High Quality甚至是None。压缩格式对图片画质影响直接这块值得逐个项目细调。Filter Mode一般保持Bilinear如果出现边缘模糊切换到Point如果出现边缘白边跟这个没关系通常是Padding不足或压缩格式问题。创建完资产后选择“Objects for Packing”把需要打包的Sprite或文件夹拖进去。注意这里添加的是Sprite资产不是Texture如果你把Texture拖进去Unity会提示你转换成Sprite格式一步做好能省很多后续排查。3.2 变体图集的取舍什么时候用Variant什么时候不要用Variant是Unity图集里一个很实用的功能操作方式是在Master图集上创建Variant然后在Variant的Scale里填一个0到1的数值比如0.5对应生成80x80版本的图集。手机上使用低分辨率版本可以省显存和带宽。但这里有个容易被忽视的点Variant并不是“共享原图的一部分”而是实实在在生成一张缩小的纹理所以内存是独立占用的。一套图集本体占10MB一个0.5的Variant至少再占2.5MB到4MB两套版本叠加起来反而可能比原来的单图集更占内存。我这边的经验是如果游戏需要支持高低端机共存或者美术素材本身分辨率极高比如2048的角色立绘降级到1024变体有收益如果原图本身就只有256x256再做一个0.5的Variant完全没意义CPU和内存白白多一份开销。所以不要无脑做Variant先看资源基准尺寸再决定。3.3 运行时动态加载图集atlasRequested回调与GetSprite现代Unity项目里图集不太可能把所有资源都在启动时全部加载尤其是用了AssetBundle或Addressables之后资产是异步加载的。SpriteAtlasManager.atlasRequested 这个事件就是为此设计的。用法分为两步。第一步在项目初始化时注册监听SpriteAtlasManager.atlasRequested OnAtlasRequested;第二步在回调里根据tagName加载图集并赋值private void OnAtlasRequested(string tagName, ActionSpriteAtlas callback) { // tagName 对应 SpriteAtlas 资产里设置的名称 StartCoroutine(LoadAtlas(tagName, callback)); } private IEnumerator LoadAtlas(string tagName, ActionSpriteAtlas callback) { var request Resources.LoadAsyncSpriteAtlas(tagName); yield return request; var atlas request.asset as SpriteAtlas; callback?.Invoke(atlas); }如果用的是Addressables就把Resources换成Addressables.LoadAssetAsync同样异步加载。需要注意一点atlasRequested的触发时机是在“场景里某个Sprite引用了图集但图集还没加载”的时候。如果你在编辑器里拖拽Sprite到Inspector上但没有把这个Sprite的图集作为依赖项打进包运行时就会走到这个回调。如果回调没注册或返回空Sprite会直接变成一个紫色方块。所以这个回调必须在任何UI加载前注册而且要确保tagName和图集资产名称严格一致。3.4 从图集里取名字对应的Sprite运行时动态加载图集后如何根据名字拿到Sprite用图集资产上的GetSprite接口。SpriteAtlas atlas Resources.LoadSpriteAtlas(MyAtlas); Sprite icon atlas.GetSprite(skill_icon_01);这里有个命名细节GetSprite的入参必须和源Sprite资产的文件名一致不是GameObject名字也不是路径名。如果你把原始的Sprite资产重命名过但图集里还残留旧数据会返回null。遇到这种情况先到图集资产里点“Pack Preview”查看生成的Sprite列表和名字再核对代码里的字符串。如果项目里大量使用“图集内Sprite名”作为配置表ID我建议在打包流水线上增加一个自动导出Name-Sprite映射表一次性生成代码常量或Json配置避免每次手抄字符串。这个方案我后来在项目里跑得特别舒服基本杜绝了改名带来的引用断裂。4. 常见问题与排查技巧实录我踩过的坑都在这图集看起来很简单真正用起来问题一堆。下面这些问题都是我实际项目中遇到过、并在社区群里看到别人反复问过的我把排查思路和解决方案一起列出来。4.1 图集里精灵之间出现黑边、白边、紫边现象是Sprite边缘出现一圈杂色看起来像描边或者色晕。很多人的第一反应是图片本身的问题但问题往往出在图集打包参数上。出现边缘杂色的原因主要有两个一是Padding过小相邻Sprite的颜色在压缩时发生串扰二是纹理压缩格式有损Alpha通道边缘的颜色被压缩到相邻像素上。解决方法按优先级排列先在Sprite Atlas资产上把Padding提高到8或16重新打包查看效果。如果还有把Compression从High Quality改成None确认是不是压缩导致。确认该Sprite在切割时是否使用了Tight Packing紧致打包模式下更依赖Alpha轮廓边缘更容易受相邻像素影响。UI类精灵建议关闭Tight Packing让它按矩形排布。如果上述都无效检查Sprite Mesh Type是否为Full Rect。Tight模式下的Mesh会贴合透明轮廓在透明值低的区域渲染时容易产生拉伸畸变换成Full Rect通常会消除。4.2 图集里的元素打不进包运行时紫方块紫方块意味着材质或纹理没有被正确加载原因通常是引用链断裂。最常见的三种情况第一种Sprite引用的是原始Texture而不是图集资产里的Sprite。检查方法在Inspector中选中Sprite资产看它的来源是否指向图集内部如果是独立Texture请先把它加入图集Objects for Packing并重新打包。第二种使用了AssetBundle或Addressables但Sprite的依赖图集没有被自动打包进去。Addressables在构建时会分析引用关系但如果图集是运行时动态加载的依赖分析就不一定包含它。解决方法是把图集也显式标记为Addressable或者在代码里提前加载后再创建UI。第三种atlasRequested回调没注册或者注册晚了。Unity在序列化时如果检测到Sprite引用的是图集内的子元素会自动在运行时触发atlasRequested如果没有回调响应Sprite就显示不出来。解决方式是全局创建时注册回调不要在一个UI界面加载时才注册。4.3 图集打包后内存不降反升遇到过几次图集优化后内存反而变大的情况排查下来基本是下面这些原因打开了Read/Write Enabled图集在GPU上传一次后CPU侧还保留一份完整副本。一个2048x2048 RGBA纹理大概就是16MB起步CPU和GPU各一份内存直接翻倍。开了Generate Mip Maps图集纹理多出一套缩放链内存增加大约33%如果场景完全不涉及3D远景关掉它是纯赚。图集尺寸设置太大比如一个界面里三十个小图标正常打包后应该是1024x1024但因为某张小图分辨率特别高比如一张4096x4096的源图也被拖进去整个图集被迫升到4096x4096内存陡增。这种要拆分图集把大面积背景图和图标分开打包。多张Master图集都包含同一个小图案因为打包粒度没规划好重复资源被拷进了多张图集。解决方式是建立统一的常驻UI图集把公共图标集中管理。4.4 排查工具和验证方法排查图集问题最直观的工具就是Inspector里的“Pack Preview”。点它会生成图集的实际排布预览能直接看到里面有没有重复元素、有没有大片空白、每个格子的尺寸是否符合预期。然后是Profiler的Memory模块。选择Texture分类可以直接看到图集纹理占了多少显存和内存。如果某张图集在场景未显示时依然占用内存检查是不是被全局表引用。通常你可以在Profiler里看到具体是哪张贴图、被谁引用。还有一个我自己养成的小习惯项目里加一个“图集信息统计”的调试界面把当前场景中加载的所有Sprite Atlas名称、纹理尺寸、内存占用打印出来打包后在真机上跑一遍才能拿到真实数据。编辑器里的数字和真机上的压缩设置往往不一样以真机为准。常见问题速查表现象可能原因解决办法精灵边缘出现黑边/白边/紫边Padding过小或压缩格式有损调大Padding关闭压缩关闭Tight Packing运行时显示紫方块依赖图集未加载或引用断裂检查引用链注册atlasRequested显式加载图集图集内存不降反升Read/Write或Mipmap开启关闭Read/Write和Mipmap拆分离大图精灵旋转方向不对图集的Allow Rotation开启关闭Allow Rotation重新打包GetSprite返回null名字与资产文件名不一致或未打包核对Pack Preview里的名字列表低端机卡顿图集过大导致纹理上传慢使用Variant或拆分图集粒度5. 图集粒度规划项目实战中怎么划分图集配置参数和API都掌握了以后真正决定项目性能上限的是“怎么划分图集”。这一节我把在项目中沉淀下来的划分原则分享出来。5.1 按界面拆分还是按功能模块拆分常见的划分方式有两种按UI界面拆比如一个主界面一个图集按功能模块拆比如所有技能图标、所有道具图标、所有按钮部件各一个图集。我个人的倾向是“按功能模块 代码生命周期划分”。因为按界面拆有一个明显的问题如果好几个界面都用到同一个技能图标这个图标要么被重复放进多张图集要么需要额外建立公共图集。重复放浪费内存建立公共图集又增加一次加载引用逻辑复杂度上升。按功能模块拆分则不同技能图标归技能图集道具图标归道具图集公共按钮归UICommon图集。某个界面加载时把需要用到的图集全部请求上来界面销毁时按引用计数释放。这样的好处是职责清晰、内存可控、扩展性好。5.2 图集的数量和尺寸要怎么控制图集数量不是越少越好也不是越多越好。太少了内存和加载压力大太多了DrawCall优化效果被削弱。一个比较实用的经验是单张图集尽量控制在1024x1024或2048x2048以内尽量不要超过4096。超过4096在低端机上有比较大的上传延迟而且四倍于2048内存压力很直接。数量上一个中型2D项目通常维护5到10张常驻图集再加上一些临时图集用于动态特效或活动资源。常驻图集的特点是生命周期长、界面切换频繁临时图集则是活动界面打开时加载、关闭时卸载。5.3 用代码维护图集的加载与释放在具体项目里我一般会写一个图集管理器核心逻辑是引用计数加载public class AtlasManager { private readonly Dictionarystring, SpriteAtlas _loadedAtlas new Dictionarystring, SpriteAtlas(); private readonly Dictionarystring, int _refCounts new Dictionarystring, int(); public SpriteAtlas LoadAtlas(string atlasName) { if (_loadedAtlas.TryGetValue(atlasName, out var atlas)) { _refCounts[atlasName]; return atlas; } var res Resources.LoadSpriteAtlas(atlasName); if (res ! null) { _loadedAtlas[atlasName] res; _refCounts[atlasName] 1; } return res; } public void ReleaseAtlas(string atlasName) { if (!_refCounts.ContainsKey(atlasName)) return; _refCounts[atlasName]--; if (_refCounts[atlasName] 0) { _loadedAtlas.Remove(atlasName); // 如果用Resources则调用Resources.UnloadUnusedAssets或直接销毁AssetBundle里的资源 } } }这只是一个简化版本真实项目里还要区分AssetBundle和Addressables的资源释放方式。但核心思路是一致的图集加载时增加引用计数关闭界面时减少引用计数归零才真正释放。这个模式能很有效地避免“界面关了图集还占用内存”和“内存抖动频繁加载卸载”这两个极端问题。个人经验收尾从最初被DrawCall逼着研究atlas到现在形成一套图集规划流程我最大的体会是图集不是一个“打包工具”它是渲染性能工程里的一环。真正用好它需要理解渲染器的工作方式规划好资源粒度还要把加载生命周期管起来。如果你只学会了点“打包按钮”那大概率会在内存和引用上栽跟头。最后再分享一个小技巧每次更新图集内容后记得看一遍Pack Preview确认没有重复资源和异常空白再提交到版本库。图集这东西出问题不太会立刻暴露往往等你发布版本之后才在用户真机上炸出来提前检查永远比事后补锅划算。这套流程和排查方法我后面在多个项目里都在沿用希望也能帮你少走点弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。