资讯动态

Unity动态打图集:运行时纹理合批与Draw Call优化实战

发布时间:2026/10/14 8:23:12 来源:尧图企业网站定制
不知道现在还有多少同事是“所有美术资源都要提前进图集”的思路。前段时间做了个小需求运行时动态下发一堆“头像、表情、小徽章”贴图尺寸不一、数量不定还必须同时在 UI 和世界空间场景里大量渲染。如果每张贴图独立一个纹理Draw Call 分分钟爆炸但资源又都是运行期才出现编辑器里根本没法提前打图集。最后我用“动态打图集”解决了问题顺手做了个 Demo 验证。这篇文章就是把这个需求从原理、代码到踩坑的过程完整拆一遍想自己实现类似“动态拼图”方案的可以直接参考我这套思路。先说明白我说的“动态打图集”是指程序运行期间把内存里若干张小纹理图片合成一张大纹理再用这张大纹理生成对应的 Sprite交给场景或 UI 使用。它和 Unity 编辑器的 SpriteAtlas 不一样——那是构建期预处理在导入阶段就锁死了哪些图片进哪个图集。而动态方案适合的是“内容无法静态确定”的场景比如服务器下发表情包、用户上传的头像、程序化生成为小部件这类资源你在打包阶段根本不知道会有哪些图。篇文章没有假定你已经熟悉图集原理但也不会从头讲材质球怎么挂。只要你写过脚本能看懂 C# 和基础 Unity API就能跟下来。代码部分我用的是 Unity 2021 以上版本的接口老版本稍微改改 API 也能跑。1. 为什么放着 SpriteAtlas 不用反而要运行时拼图集很多刚接触这个需求的人第一反应是Unity 不是自带 Sprite Atlas 吗干嘛费劲自己写像素拷贝这问题问得好答案在于“静态图集有自己的边界”。1.1 静态图集解决不了的三类场景第一类是内容不在本地 Asset 目录里的场景。比如运营在后台配置了一组新的活动图标走热更新把图片下载到本地沙盒游戏启动后再加载。静态图集必须要编辑器阶段把图片拖进图集资源这种“启动时才知道有哪些图”的需求静态方案直接没辙。第二类是内容需要频繁替换的场景。假设你做一个拍照类的编辑器用户上传了 20 张图片拼成一个相册墙每张图大小不一样。如果用静态图集每次用户换图都得重新走一遍编辑器资源流程这在客户端工具型产品里不可接受。动态拼图可以随来随拼用完重建整条链路都在运行时闭环。第三类是“碎片化资源”特别多的场景比如一个战斗结算界面里堆了几十个技能图标。静态图集做起来也能用但如果这些图标来自不同美术小组、不同分包协调打包周期会拖慢迭代。动态打图集不挑资源来源只要运行时能拿到图片数据就能扔进同一个图集。1.2 图集的本质是为了合批而合批的前提是共用纹理这里得把渲染底层那点事讲透。GPU 渲染一个物体时CPU 需要提交绘制指令简单说一次 Draw Call 就是一条“把绑定好的纹理贴到某个几何体上”的命令。切换纹理、切换材质、切换 Shader都会触发 CPU 和 GPU 之间的状态切换这就是 Draw Call 变贵的核心原因。图集做的事就是把几十张独立纹理合并成一张大纹理。渲染时这几十个 Sprite 使用的是同一张纹理状态切换没了GPU 就能在一次合批里把它们全画出来。拿我那批头像来说100 张 128x128 的小图如果各自独立纹理至少要 100 次 Draw Call合成一张图集后按同一批 SpriteRenderer 的排序大概只需要 2~3 次 Draw Call。这个提升在移动端尤其明显CPU 时间直接省下不少。1.3 什么情况下不应该动态打图集反过来说动态打图集也不是银弹。如果你的资源集合是固定的、可控的而且数量和大小都稳定老老实实用 SpriteAtlas 构建期打包性能和加载效率更好。动态方案要承担运行时的像素合并、刷新上传、内存重建等开销处理不好反而比不打包更卡。还有一种场景也不建议动态打动态变化的图片非常频繁比如每秒都在往图集里塞新图又不停删除旧图。这个情形下频繁重建图集的开销很高我更推荐“分层缓存”——常用图打进静态图集临时图走独立纹理或者单独维护一个小图集只重建小图集。2. 动态打图集的核心原理从像素到 UV 的完整链路做动态打图集必须先想清楚一条数据链路图片解码 → 纹理对象 → 装箱排布 → 像素拷贝 → 生成 Sprite → 渲染。每一步背后都有一个关键概念搞懂它代码怎么写都不会歪。2.1 Texture2D、Sprite、图集的关系先厘清这三个东西。Texture2D 是一块真实存在于显存或 CPU 内存里的像素数据只管“有多少像素、什么格式”Sprite 是一个渲染用的可交互对象它持有一个 Texture2D 的引用并且通过一个矩形区域说明“我从这块纹理的哪个位置取像素”图集本质上就是“一张 Texture2D 一组矩形区域描述”。所以动态打图集的本质就一句话把我们手头的多个 Texture2D 的像素值拷进一块新建的大 Texture2D 上然后记录每个小图对应的矩形位置最后用矩形位置创建 Sprite。这个描述听起来简单但实现里藏着两个最容易翻车的点坐标系怎么对齐、矩形怎么排布才不浪费空间。2.2 坐标系为什么图片会上下颠倒这是动态打图集最经典的坑。Unity 的纹理坐标系原点 (0,0) 在左下角从左下往右上增长而 PNG、JPG 图片的像素数据排列第一行通常对应图片的最上面一行。你从一张普通图片里 GetPixels32 拿到像素数组数组里第 0 个像素是图片左上角的颜色。当你把这些像素直接塞进图集的某个矩形区域时如果你的写入起点的 y 计算方式用了“自上而下”的逻辑就会出现整张小图上下颠倒的效果。正确做法是拷贝时不要自己引入坐标系转换而是把源纹理的像素行按顺序拷进目标矩形区域再配合 Unity 的 uv 采样约定结果就自然正确。简单说你只管“从源纹理第 0 行开始逐行写入目标矩形第 0 行”unity 会自动把纹理底部映射到 Sprite 的 uv 原点。2.3 装箱算法怎么把小矩形塞进大矩形把小图看成若干个小矩形把图集看成一个大矩形问题就变成了“如何尽量紧密地把小矩形放进大矩形里”。这是个经典的装箱问题。最基础的做法是把所有小图按高度降序排列然后从左往右铺一行铺不下就换下一行这种算法叫 shelf 算法简单、稳定适合绝大多数动态图集场景。更高级的 MaxRects 算法会尝试寻找最优放置位置利用率更高但实现复杂度也高。我的建议是先实现 shelf 算法跑通流程后再评估要不要优化。对 Demo 来说高度排序 shelf 已经能把 90% 的空间利用率做到。2.4 底层有三种实现路径动态打图集在 Unity 里不是只有一种写法。常见的三条路直接调用Texture2D.PackTextures()或SpriteAtlas的动态接口手动创建一张大纹理用SetPixels/Graphics.CopyTexture把小块像素拷进去用SpriteAtlasManager.atlasRequested事件挂接动态加载的图集资源。这三条路里第一条最简单但控制力差只能得到打包结果不方便记录每个小图的位置、不方便做 padding第二条最灵活是这篇文章采用的办法第三条适合配合 AssetBundle 加载预先打好的图集资源严格说不算“拼图集”而是“动态注册图集”。如果你只需要把 AB 里的图集加载后挂给现有 Sprite第三条很合适如果跟我一样要完全动态合成就走第二条。3. 核心实现一个可复用的动态图集管理器下面进入直接能抄的代码部分。我会先给整体设计再拆开讲每个关键函数最后说明如何接入你的项目。3.1 先定义一个基础数据结构每个被打进图集的小图需要记录自己的基本信息和最终位置using UnityEngine; public class DynAtlasEntry { public Texture2D source; // 原始小图纹理 public Rect uvRect; // 归一化 UV0~1范围 public Vector2Int position; // 在最终图集中的像素位置 public Vector2Int size; // 小图尺寸 public Sprite sprite; // 打包完成后生成的 Sprite } public class DynamicAtlasResult { public Texture2D atlasTexture; // 最终图集纹理 public ListDynAtlasEntry entries; // 每张小图的信息 public Dictionarystring, DynAtlasEntry nameToEntry; // 按名字查 }为什么要把 uvRect、position、size 都存下来因为后面创建 Sprite 时Sprite.Create需要的是像素矩形Rect单位是像素而 UI 渲染时如果你想自己写 Shader 或者用自定义 Mesh就可能要归一化 UV。两个都存Debug 时也不用猜。3.2 装箱排布的实现shelf 算法先做最简单的 shelf 装箱。我建议把装箱逻辑单独放到一个类里不要跟像素拷贝混在一起方便以后换成 MaxRects。public class ShelfBinPacker { private int binWidth; private int binHeight; private int currentX; private int currentY; private int rowHeight; private int padding; public ShelfBinPacker(int width, int height, int padding 2) { binWidth width; binHeight height; this.padding padding; currentX 0; currentY 0; rowHeight 0; } public bool TryPack(int w, int h, out Rect rect) { rect new Rect(); if (currentX w padding binWidth) { // 当前行放不下换下一行 currentX 0; currentY rowHeight padding; rowHeight 0; } if (currentY h padding binHeight) { // 图集已经放不下 return false; } rect new Rect(currentX, currentY, w, h); rowHeight Mathf.Max(rowHeight, h); currentX w padding; return true; } }关键点是“换行”的判断当前行右侧放不下时就把 currentX 归零y 增加整行的高度否则一直往右铺。这种行式排布虽然简单但对同批次高度差不多的图片特别友好比如头像墙场景效果就很好。打包前把小图按高度降序排序能明显提升空间利用率。为什么要降序高的图先放会把行高顶起来后续矮图可以填进剩余高度里反过来先放矮图行高被矮图撑起来后面高的图根本放不进当前行行空间就全浪费了。3.3 动态图集生成像素拷贝与 Sprite 创建装箱完成后就到了真正的“拼”图阶段。这一步最核心的是创建大纹理、拷贝像素、生成 Sprite。using System.Collections.Generic; using UnityEngine; public static class DynamicAtlasBuilder { public static DynamicAtlasResult Build( ListTexture2D textures, int atlasWidth, int atlasHeight, int padding 2) { // 排序大的先放空间利用率更高 var sorted new ListTexture2D(textures); sorted.Sort((a, b) b.height.CompareTo(a.height)); var packer new ShelfBinPacker(atlasWidth, atlasHeight, padding); var atlasTexture new Texture2D( atlasWidth, atlasHeight, TextureFormat.RGBA32, false); var result new DynamicAtlasResult { atlasTexture atlasTexture, entries new ListDynAtlasEntry(), nameToEntry new Dictionarystring, DynAtlasEntry() }; var clearColor new Color32(0, 0, 0, 0); var clearPixels new Color32[atlasWidth * atlasHeight]; for (int i 0; i clearPixels.Length; i) clearPixels[i] clearColor; atlasTexture.SetPixels32(clearPixels); foreach (var tex in sorted) { if (!packer.TryPack(tex.width, tex.height, out var rect)) { Debug.LogWarning($图集放不下跳过 {tex.name} ({tex.width}x{tex.height})); continue; } int destX Mathf.FloorToInt(rect.x); int destY Mathf.FloorToInt(rect.y); // 把源纹理像素拷进大纹理 var pixels tex.GetPixels32(); atlasTexture.SetPixels32(destX, destY, tex.width, tex.height, pixels); var entry new DynAtlasEntry { source tex, position new Vector2Int(destX, destY), size new Vector2Int(tex.width, tex.height), uvRect new Rect( (float)destX / atlasWidth, (float)destY / atlasHeight, (float)tex.width / atlasWidth, (float)tex.height / atlasHeight), sprite null }; result.entries.Add(entry); result.nameToEntry[tex.name] entry; } atlasTexture.Apply(false, false); // 这一步可以同步创建 Sprite也可以留到 UI 阶段再逐张创建 foreach (var entry in result.entries) { var rect new Rect(entry.position.x, entry.position.y, entry.size.x, entry.size.y); entry.sprite Sprite.Create( atlasTexture, rect, new Vector2(0.5f, 0.5f), 100f); } return result; } }这段代码有几个地方值得解释。首先是new Texture2D(..., TextureFormat.RGBA32, false)里的最后一个参数表示不使用 mipmap。图集通常不需要 mipmap开了反而白占内存还会让采样逻辑变复杂。然后是SetPixels32(clearPixels)为什么要先清一遍因为新创建的纹理默认内容不确定如果某个小图漏拷了一部分或者纹理里出现未初始化区域渲染时就可能出现花屏或者脏色先清成透明色更安全。SetPixels32(destX, destY, width, height, pixels)是 Unity 默认支持的重载它把一块矩形区域写进目标纹理。这里不用手动处理 Y 轴翻转就是因为像素行的顺序是直接映射的。理论上如果源纹理的读取方向与 Unity 纹理坐标一致这个操作就是完全正确的。最后调用Apply(false, false)把 CPU 端像素数据真正上传到 GPU 端。第一个参数是“不重建 mipmap”第二个参数是“不阻塞”。这里有个细节如果场景里同一帧多个图集连续 Apply会在主线程产生较大开销。你可以用异步上传机制但 Demo 阶段用同步 Apply 已经足够。3.4 图集容量预估与扩容策略动态打图集最怕的一件事就是“图集建小了装不下”。装不下怎么办先把源图跳过并给警告是 Demo 做法但生产环境不能这么干。建议一开始就做一个容量预估把所有小图的面积相加求平方根再乘 1.3 的余量系数得到一个初始边长然后取不超过SystemInfo.maxTextureSize的二次幂。private static int EstimateAtlasSize(ListTexture2D textures) { long totalArea 0; foreach (var tex in textures) { totalArea tex.width * tex.height; } int suggested Mathf.CeilToInt(Mathf.Sqrt(totalArea) * 1.3f); int size 256; while (size suggested size SystemInfo.maxTextureSize) { size * 2; } return size; }如果预估完发现面积超过了maxTextureSize * maxTextureSize说明一张图集放不下就需要分片。我的习惯是第一批小图按高度排序后先装进去装不下的存进一个“剩余列表”等第一张图集生成完再对这些剩余列表递归调用同一个 Build 方法从而生成第二张图集。这比硬把一张图撑到 4096 甚至更大要稳妥得多移动端 4096 纹理上限不是所有机型都支持。3.5 用 Graphics.CopyTexture 代替 SetPixels32 的提速方案上面用GetPixels32 SetPixels32是通用但偏慢的方案它涉及 CPU 上的两次拷贝先读出来再写进去。如果小图数量特别多可以改用Graphics.CopyTexture它能把 GPU 纹理直接拷到另一张 GPU 纹理速度快很多但限制也严格——源和目标纹理的格式、尺寸对齐要求很苛刻。// 前提src 和 atlas 的 TextureFormat 完全相同 Graphics.CopyTexture( src, 0, 0, 0, 0, src.width, src.height, atlas, 0, 0, destX, destY);如果你处理的所有小图都是同一格式比如统一用 RGBA32Graphics.CopyTexture是更好的选择。但如果你混用了不同格式比如一边是 RGBA32一边是 ASTCCopyTexture 会直接报错那就退回SetPixels32。写代码前先想清楚自己的资源格式是否统一不要盲目上 CopyTexture。4. 小 Demo从加载本地图片到渲染墙的完整闭环原理和核心代码讲完了现在把整个流程串成一个可以运行的小 Demo。我这里模拟一个“本地加载头像墙”场景应用启动后从沙盒里的某个目录读取一批 PNG动态打图集再平铺到屏幕上。4.1 Demo 场景的整体设计场景里只放两个对象一个 Canvas用于 UI 渲染一个空节点挂演示脚本。演示脚本负责用Directory.GetFiles读取指定目录下的所有图片用File.ReadAllBytes Texture2D.LoadImage把图片解码为纹理调用DynamicAtlasBuilder.Build拼出图集把生成的 Sprite 挂到若干Image/RawImage上用帧调试器查看 Draw Call 数量变化。这套流程不需要美术资源随便准备几十张小 PNG丢进对应目录就能跑。4.2 异步读取图片的关键代码读文件和解码最好不要都堆在主线程尤其是文件多、尺寸大的时候。读文件可以放线程池解码必须回主线程Texture2D.LoadImage不是线程安全接口所以更稳妥的写法是用协程配合UnityWebRequestTextureusing System.Collections; using System.IO; using UnityEngine; using UnityEngine.Networking; public class DemoImageLoader : MonoBehaviour { public string folderPath; IEnumerator LoadAllTextures() { if (!Directory.Exists(folderPath)) { Debug.LogError($目录不存在: {folderPath}); yield break; } var files Directory.GetFiles(folderPath, *.png); var loadedTextures new ListTexture2D(); foreach (var file in files) { string path file; var request UnityWebRequestTexture.GetTexture(file:// path); yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogWarning($加载失败: {path} - {request.error}); continue; } var tex DownloadHandlerTexture.GetContent(request); // 小图一般不需要 mipmap也不需要进入崩溃性 API loadedTextures.Add(tex); } // 打到下一阶段动态图集生成 BuildAndDisplay(loadedTextures); } }注意DownloadHandlerTexture.GetContent会直接返回一个可用的 Texture2D。如果你更习惯自己去读文件字节也可以用new Texture2D(2,2)后调用LoadImage(byte[])但要注意 LoadImage 会覆盖纹理的宽高不需要你先指定尺寸。4.3 处理 UI 合批的细节图集生成后接下来的问题是怎么摆放不会打断合批。在 Canvas 下想合批要满足几个条件所有 Image 都挂在同一个 Canvas 下、层级相邻、材质相同。所以如果你把头像一个个 Instantiate 出来并随机设置位置很可能因为层级插入了奇怪的对象导致批处理被拆开。我推荐的做法是把所有头像挂在同一个容器节点下按 GridLayoutGroup 布局并且保证容器节点下面只有Image不要插入任何带Canvas子节点的对象。网格布局的排序越规整合批越稳定。用世界空间的 SpriteRenderer 同理保持它们使用同一个材质球别中途换材质。4.4 性能对比与验证方法跑通后怎么证明动态图集有效最直观的是看 Frame Debugger。打开 Window → Analysis → Frame Debugger再点 Enable左侧能看到当前帧的 Draw Call 列表。如果所有头像都用同一张图集纹理在这个面板里它们会很整齐地合并为同一个批次。我实测的参考数字是48 张 256x256 的 PNG独立纹理渲染时 Draw Call 至少 50 次合成一张 2048x2048 图集后Draw Call 掉到 3 次左右UI 下可能还有一次 Canvas 的额外批次。当然这个数字不是绝对的你的布局、重叠、材质都会影响但量级不会变——这就是图集的核心收益。内存这块也要算一下48 张 256x256 RGBA32 独立纹理每张 256KB总共约 12MB合成一张 2048x2048 图集纹理也是 16MB 左右因为 2048x2048 RGBA32 正好 16MB。但关键是独立纹理方案在切换纹理时 CPU 开销高而图集方案只占一份 GPU 资源对象实际渲染时更干净。5. 实测中的翻车记录与优化心得最后这部分全是动手过程中真正踩过的坑。每条都是文档里不会详细写但做动态图集几乎必踩的坎。5.1 图集纹理格式不统一引发的 CopyTexture 报错我最初想用Graphics.CopyTexture加速结果发现一个很常见的问题从 UnityWebRequest 下载的 PNG 纹理有些平台解码出来是 RGBA32有些是 RGBA4444有些设备上甚至变成 ASTC 压缩格式。于是 CopyTexture 直接报错“源与目标不匹配”。解决办法是进图集前先统一格式。最保险的做法是不依赖下载解码的原始格式而是在Build函数里先对每张小图做一次tex.filterMode FilterMode.Bilinear和tex.wrapMode TextureWrapMode.Clamp设置然后用SetPixels32方案而非 CopyTexture。如果你仍然想用 CopyTexture 加速就必须自己严格控制所有源的格式一致比如加载后先转一次RGBA32。5.2 非二次幂纹理和过滤模式的视觉问题小图尺寸如果不凑巧比如宽 100、高 66这种非二次幂纹理放进图集后渲染时采样边界可能会出问题。表现为 Sprite 边缘出现半透明黑边或者相邻图集的像素“渗”过来。解决思路有两步。第一打包时给每张小图四周留 padding我的默认值是 2 像素代价是空间利用率稍微降一点第二图集纹理采样模式设为Clamp不要用Repeat防止边缘采样跑到图集另一侧。如果你连这都解决不了还有一种更暴力的方式拷像素时把每张小图四周额外扩一圈透明像素类似出血边从视觉上消除采样硬边。5.3 Texture2D.Apply 卡主线程当图集尺寸特别大比如 4096x4096Apply()会把十几 MB 数据传入 GPU主线程可能卡上几十毫秒。这在 Editor 里感觉不明显真机上很容易一帧掉到 50ms。我的优化策略是把大图集拆成多个 1024 或 2048 的小图集并行生成另外生成动作不要放在 Update 高潮期最好集中在一帧开始前或加载界面跳转前。再往前一步可以手动调用AsyncGPUReadback配合命令流但那是进阶玩法Demo 阶段用不到。5.4 动态图集生命周期管理别让旧纹理一直占内存图片打进了图集原始小图纹理理论上可以释放了。但如果你的代码里还持有Texture2D引用GC 不会回收它。必须显式Destroy()掉每张小图的 Texture2D或者在打图集前先统一管理生命周期。常见做法是给图集打一个“标签”同一个批次的所有原图类实例归一个容器管理。图集完成后调用ClearSourceTextures()把原图引用清空并销毁重新生成图集时整块销毁旧图集纹理再新建。千万别只Destroy图集纹理而不销毁原图引用那会留下一堆 CPU 端纹理副本内存翻倍。5.5 SpriteRenderer 合批被非相邻对象打断代码全写对了结果看 Frame Debugger 发现批次没有被合起来通常是因为场景里不同 SpriteRenderer 之间插入了其他渲染对象比如一个粒子系统、一个 3D 模型甚至一个带不同材质的 Sprite。Unity 的合批是按渲染顺序走的中间只要隔一个不同状态前后就合不上。排查方向就是调整层级顺序有依赖顺序的对象放前面同图集的所有 SpriteRenderer 尽量连续排。对于 UI还要注意 Canvas 的数量一个 UI 里如果嵌了子 Canvas子 Canvas 会把批次强制拆开。5.6 增量更新如何避免频繁全量重建动态图集的另一个现实问题如果图集已经生成了运行过程中又需要新增一张小图怎么办你最省事的做法可能是重跑一遍整图集但这样做成本高也容易在帧中间造成闪烁。我的习惯是图集不做到 100% 满留出约 10% 的空白区域作为“预留区”。新增小图时先在预留区找位置放得下就直接调用SetPixels32 Apply局部更新然后创建新 Sprite放不下再扩展图集尺寸如果maxTextureSize允许或整体重建。配合局部 Apply 时要注意Unity 的 Apply 没有“只更新某区域”的重载但你可以通过先SetPixels32写局部数据再Apply整张纹理本质上整张上传开销还在只是逻辑上只改了局部。想要真正局部上传需要走底层图形接口那个成本就不是这篇能承载的了。结语动态打图集这东西不复杂但细节非常多。只要把“坐标系对齐”和“装箱算法”这两个核心想明白剩下的其实就是纹理生命周期管理。我现在的做法是如果内容量少而固定优先考虑静态图集如果运行期资源不可预期就套上这套动态图集管理器并严格控制图集数量、尺寸上限和重建频率。做新项目时你也可以试试这个套路从一个小 Demo 开始把批量图片的渲染批次压下去顺便摸清纹理在运行时是怎么流转的。日后真接到“服务器下发贴图堆成墙”这种需求至少不用临时抓瞎。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑