不知道现在还有多少同事是“所有美术资源都要提前进图集”的思路。前段时间做了个小需求:运行时动态下发一堆“头像、表情、小徽章”贴图,尺寸不一、数量不定,还必须同时在 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; // 归一化 UV,0~1范围 public Vector2Int position; // 在最终图集中的像素位置 public Vector2Int size; // 小图尺寸 public Sprite sprite; // 打包完成后生成的 Sprite } public class DynamicAtlasResult { public Texture2D atlasTexture; // 最终图集纹理 public List<DynAtlasEntry> entries; // 每张小图的信息 public Dictionary<string, 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( List<Texture2D> textures, int atlasWidth, int atlasHeight, int padding = 2) { // 排序:大的先放,空间利用率更高 var sorted = new List<Texture2D>(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 List<DynAtlasEntry>(), nameToEntry = new Dictionary<string, 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(List<Texture2D> 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);如果你处理的所有小图都是同一格式(比如统一用 RGBA32),Graphics.CopyTexture是更好的选择。但如果你混用了不同格式,比如一边是 RGBA32,一边是 ASTC,CopyTexture 会直接报错,那就退回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不是线程安全接口),所以更稳妥的写法是用协程配合UnityWebRequestTexture:
using 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 List<Texture2D>(); 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 卡主线程
当图集尺寸特别大,比如 4096x4096,Apply()会把十几 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 开始,把批量图片的渲染批次压下去,顺便摸清纹理在运行时是怎么流转的。日后真接到“服务器下发贴图堆成墙”这种需求,至少不用临时抓瞎。