首页 / 资讯中心 / 文章详情

Unity贴图融合实战:从原理到DrawCall优化与显存降低

Unity贴图融合实战:从原理到DrawCall优化与显存降低 ★ FEATURED ARTICLE
1. 贴图融合到底在解决什么问题做Unity项目的人迟早会撞上这个需求场景里有一堆模型每个模型都带着自己的贴图显存占用蹭蹭往上涨DrawCall也压不下来。尤其是做移动端或者WebGL项目的时候一张2048x2048的贴图就是16MB显存十张就是160MB还没算Mipmap。这时候贴图融合Texture Atlas / Texture Merging就成了绕不开的优化手段。说白了贴图融合就是把多张小贴图拼成一张大贴图然后让多个模型共享这一张图。好处很直接DrawCall可以从N降到1前提是材质参数一致显存占用也能通过合理的排布大幅降低。但这件事不是把图拼起来就完事了里面涉及到UV重映射、边缘溢色处理、图集排布算法、压缩格式选择等一系列细节。我见过太多人图省事直接用在线工具拼图结果模型边缘出现黑边或者颜色渗漏排查半天找不到原因。这篇文章适合谁看如果你正在做Unity项目优化或者准备面试被问到DrawCall优化相关的问题又或者你只是好奇贴图融合背后的原理那这篇内容应该能给你一些可以直接抄作业的方案。我会从原理讲到实操包括代码层面的UV重映射、图集排布的具体算法选择、以及我在实际项目中踩过的坑。2. 贴图融合的核心原理与方案选型2.1 为什么贴图融合能降低DrawCallUnity的渲染流程里每次切换材质Material都会产生一次SetPass Call而DrawCall的数量直接受材质数量影响。如果十个模型用十张不同的贴图那就是十个材质至少十次DrawCall。但如果这十个模型共用一张图集和同一个材质Unity的合批系统动态合批或静态合批就有机会把它们合并成一次DrawCall。这里有个关键点合批的前提是顶点数不能超过限制动态合批默认300个顶点而且材质必须完全相同。所以贴图融合只是第一步后面还要配合合批策略才能达到最优效果。我一般会先做贴图融合再检查材质的Shader是否一致最后确认合批是否生效。2.2 图集排布算法的选择图集排布不是随便把图摆进去就行不同的排布算法对空间利用率影响很大。常见的算法有这几种简单网格排布把图集分成固定大小的格子每张图占一个或多个格子。优点是实现简单缺点是空间浪费严重尤其是贴图尺寸差异大的时候。MaxRects算法维护一个可用矩形列表每次插入时选择最合适的矩形进行分割。空间利用率高但实现复杂度也高。Skyline算法沿着底部轮廓线排布适合高度差异大的贴图。实现难度中等空间利用率不错。我在实际项目中一般用MaxRects的变体因为它的空间利用率能到90%以上。如果你不想自己写Unity官方有个Sprite Atlas功能但那个主要针对2D Sprite对于3D模型的贴图融合不太适用。所以大多数情况下还是得自己写工具或者用第三方库。2.3 边缘溢色问题的成因与预防这是贴图融合里最容易被忽略的问题。当你把一张贴图缩小后放进图集如果两张贴图挨得太近采样的时候可能会采到旁边贴图的像素导致边缘出现杂色。这个问题在Mipmap开启后会更明显因为低级别的Mipmap会把相邻区域的颜色混在一起。解决方案有两个一是给每张贴图留出足够的Padding通常2-4像素二是在生成图集后做一次边缘扩展Edge Padding把贴图边缘的像素向外复制几圈。我一般两个都做Padding留4像素然后边缘再扩展2像素这样基本不会出现溢色问题。3. 实操过程从零实现一个贴图融合工具3.1 准备工作与数据收集在开始写代码之前你需要先收集所有需要融合的贴图信息。我一般会写一个Editor脚本遍历指定文件夹下的所有材质提取它们的贴图引用。这里要注意同一个贴图可能被多个材质引用所以要去重。// 收集所有材质的贴图信息 public class TextureCollector { public DictionaryTexture2D, ListMaterial CollectTextures(string folderPath) { var result new DictionaryTexture2D, ListMaterial(); var materialGuids AssetDatabase.FindAssets(t:Material, new[] { folderPath }); foreach (var guid in materialGuids) { var path AssetDatabase.GUIDToAssetPath(guid); var material AssetDatabase.LoadAssetAtPathMaterial(path); var texture material.mainTexture as Texture2D; if (texture ! null) { if (!result.ContainsKey(texture)) result[texture] new ListMaterial(); result[texture].Add(material); } } return result; } }这段代码会返回一个字典Key是贴图Value是引用该贴图的所有材质。有了这个数据你就能知道哪些贴图需要被融合以及融合后需要更新哪些材质的UV。3.2 图集排布的具体实现我选用了MaxRects算法的一个简化版本核心思路是维护一个空闲矩形列表每次插入新贴图时从列表中找到最合适的矩形进行分割。这里的关键是最合适的评判标准我一般用Best Short Side FitBSSF也就是选择分割后短边浪费最少的矩形。public class MaxRectsPacker { private ListRectInt freeRects new ListRectInt(); private int atlasWidth; private int atlasHeight; public MaxRectsPacker(int width, int height) { atlasWidth width; atlasHeight height; freeRects.Add(new RectInt(0, 0, width, height)); } public RectInt Insert(int width, int height) { RectInt bestRect new RectInt(); int bestShortSideFit int.MaxValue; int bestIndex -1; for (int i 0; i freeRects.Count; i) { var freeRect freeRects[i]; if (freeRect.width width freeRect.height height) { int leftoverHoriz freeRect.width - width; int leftoverVert freeRect.height - height; int shortSideFit Mathf.Min(leftoverHoriz, leftoverVert); if (shortSideFit bestShortSideFit) { bestRect new RectInt(freeRect.x, freeRect.y, width, height); bestShortSideFit shortSideFit; bestIndex i; } } } if (bestIndex -1) return new RectInt(-1, -1, 0, 0); // 放不下 // 分割空闲矩形 SplitFreeRects(bestRect); return bestRect; } private void SplitFreeRects(RectInt usedRect) { for (int i freeRects.Count - 1; i 0; i--) { var freeRect freeRects[i]; if (usedRect.Overlaps(freeRect)) { freeRects.RemoveAt(i); // 上方 if (usedRect.yMax freeRect.yMax) freeRects.Add(new RectInt(freeRect.x, usedRect.yMax, freeRect.width, freeRect.yMax - usedRect.yMax)); // 下方 if (usedRect.yMin freeRect.yMin) freeRects.Add(new RectInt(freeRect.x, freeRect.y, freeRect.width, usedRect.yMin - freeRect.y)); // 左方 if (usedRect.xMin freeRect.xMin) freeRects.Add(new RectInt(freeRect.x, freeRect.y, usedRect.xMin - freeRect.x, freeRect.height)); // 右方 if (usedRect.xMax freeRect.xMax) freeRects.Add(new RectInt(usedRect.xMax, freeRect.y, freeRect.xMax - usedRect.xMax, freeRect.height)); } } } }这个实现虽然简单但已经能处理大部分场景了。如果你需要更高的空间利用率可以加入旋转功能也就是允许贴图旋转90度后再放入图集。3.3 UV重映射的关键步骤图集生成后每个模型的UV坐标都需要重新计算。原始UV是相对于单张贴图的现在要映射到图集上的对应区域。计算公式很简单newUV.x (originalUV.x * textureWidth offsetX) / atlasWidth newUV.y (originalUV.y * textureHeight offsetY) / atlasHeight其中offsetX和offsetY是贴图在图集中的位置textureWidth和textureHeight是贴图的原始尺寸atlasWidth和atlasHeight是图集的总尺寸。但这里有个坑如果贴图有Wrap Mode为Repeat的情况UV重映射后可能会出问题。因为图集上的贴图区域是有限的Repeat模式会采样到图集的其他区域。所以对于需要Repeat的贴图我一般不建议融合或者单独处理。// UV重映射示例 public void RemapUV(Mesh mesh, RectInt atlasRect, int originalWidth, int originalHeight, int atlasWidth, int atlasHeight) { var uvs mesh.uv; for (int i 0; i uvs.Length; i) { uvs[i].x (uvs[i].x * originalWidth atlasRect.x) / atlasWidth; uvs[i].y (uvs[i].y * originalHeight atlasRect.y) / atlasHeight; } mesh.uv uvs; }3.4 图集生成与压缩格式选择图集生成的时候我一般用Texture2D.SetPixels或者Graphics.CopyTexture来把每张贴图拷贝到图集的对应位置。这里要注意拷贝之前要确保图集的格式和源贴图一致否则可能会出现颜色偏差。压缩格式的选择也很关键。如果是移动端我一般用ASTC 6x6或者ETC2如果是PC端用DXT5或者BC7。但要注意图集里的贴图如果包含Alpha通道压缩格式要选带Alpha的否则透明区域会变成黑色。平台推荐格式适用场景AndroidASTC 6x6通用场景质量与体积平衡iOSASTC 6x6同上PCBC7高质量支持AlphaWebGLDXT5兼容性好体积小4. 常见问题与排查技巧实录4.1 模型边缘出现黑边或杂色这是最常见的问题原因通常是Padding不够或者边缘扩展没做。排查步骤先检查图集中贴图之间的间距是否足够建议至少4像素然后检查是否做了边缘扩展。如果都没问题那可能是Mipmap导致的可以尝试关闭Mipmap或者增加Mipmap的Padding。我遇到过一个特殊情况贴图的Wrap Mode是Clamp但图集生成后边缘采样到了图集的其他区域。这是因为UV重映射后贴图边缘的UV可能会超出贴图区域。解决方案是在重映射时对UV做一次Clamp确保不会超出贴图范围。4.2 图集尺寸过大导致显存溢出图集不是越大越好。我见过有人把几十张贴图拼成一张8192x8192的图集结果显存直接爆了。一般来说移动端图集尺寸不要超过2048x2048PC端不要超过4096x4096。如果贴图太多可以分成多个图集每个图集对应一组材质。另外图集的尺寸最好是2的幂次方这样压缩和采样效率最高。如果排布后尺寸不是2的幂可以向上取整到最近的2的幂。4.3 UV重映射后模型显示错乱这个问题通常是UV坐标系搞反了。Unity的UV坐标系原点在左下角而有些贴图编辑器的坐标系原点在左上角。如果你从其他工具导出贴图信息要注意坐标系转换。还有一个可能是Mesh的UV通道不对。有些模型有多个UV通道你重映射的可能是UV1而不是UV0。检查一下Mesh.uv和Mesh.uv2的区别确保你修改的是正确的通道。4.4 合批不生效的排查思路贴图融合后合批不生效原因可能有很多。我一般按这个顺序排查检查材质是否完全相同包括Shader和所有参数检查顶点数是否超过动态合批限制默认300检查是否开启了静态合批Static Batching检查是否有Lightmap或Shadowmask导致材质变体不同检查Shader是否支持合批有些自定义Shader会禁用合批如果以上都没问题可以用Frame Debugger查看具体的合批情况它会告诉你为什么某个DrawCall没有被合并。4.5 常见问题速查表问题现象可能原因解决方案边缘黑边Padding不足增加Padding至4像素以上颜色渗漏边缘扩展未做生成图集后做2像素边缘扩展显存溢出图集尺寸过大拆分图集单张不超过2048UV错乱坐标系不一致检查UV原点位置做坐标系转换合批失败材质参数不同统一材质参数检查Shader变体压缩后模糊压缩格式不当改用ASTC或BC7高质量格式5. 进阶技巧与性能优化建议5.1 按需加载与图集拆分策略不是所有贴图都适合融合。我一般会把贴图按使用场景分组场景A的贴图放一个图集场景B的贴图放另一个图集。这样切换场景时可以卸载不用的图集减少显存占用。另外对于UI贴图和模型贴图我建议分开处理。UI贴图通常需要更高的分辨率和更精确的像素对齐而模型贴图可以接受一定程度的压缩。5.2 运行时动态融合的可行性分析有些项目需要在运行时动态生成图集比如玩家可以自定义贴图的情况。这种场景下我一般用RenderTexture来做运行时融合。具体做法是创建一个RenderTexture然后用Graphics.Blit把每张贴图绘制到RenderTexture的指定区域。但运行时融合的性能开销比较大尤其是图集尺寸大的时候。所以如果能在Editor阶段预处理尽量在Editor阶段做。运行时融合只适合贴图数量少、更新频率低的场景。5.3 与Addressable系统的配合使用如果你的项目用了Addressable资源管理系统贴图融合后需要更新资源的引用关系。我一般会把图集作为一个独立的Addressable资源然后让所有使用该图集的材质引用这个资源。这样加载图集时所有相关材质都能正确获取到贴图。这里有个细节Addressable的依赖关系是基于Asset的所以图集生成后需要重新导入并更新依赖。我一般写一个Editor脚本在生成图集后自动调用AssetDatabase.Refresh和Addressable的更新接口。5.4 性能对比数据参考我在一个实际项目中做过对比测试场景中有50个模型每个模型一张512x512的贴图。融合前后的数据如下指标融合前融合后DrawCall503显存占用50MB18MB帧率45fps60fps加载时间2.3s1.1s这个提升还是很明显的尤其是DrawCall从50降到3几乎是指数级的优化。当然具体数据会因项目而异但大方向是一致的。5.5 一些踩过的坑和心得第一个坑不要盲目追求大图集。我曾经为了减少DrawCall把整个场景的贴图都拼成一张8192的图集结果低端机上直接闪退。后来改成按区域拆分每个区域一张2048的图集问题就解决了。第二个坑图集的Mipmap设置要谨慎。如果图集里的贴图尺寸差异很大Mipmap可能会导致小贴图在远处变得模糊。我一般会对小贴图单独处理或者关闭Mipmap。第三个坑UV重映射后要记得更新Mesh的Bounds。有些模型的Bounds是根据原始UV计算的重映射后Bounds可能不准确导致视锥体剔除出错。这个问题比较隐蔽但影响很大。第四个坑图集生成后要检查一下贴图的Read/Write设置。如果源贴图没有开启Read/WriteSetPixels会失败。我一般会在收集贴图时就把Read/Write打开生成完图集后再关掉。第五个坑如果项目用了Lightmap贴图融合后Lightmap的UV也需要重映射。这个比较麻烦因为Lightmap的UV通常和主贴图UV不一样。我一般建议Lightmap贴图不要融合单独处理。6. 工具化与自动化流程搭建6.1 Editor工具的一键化设计手动做贴图融合太累了我一般会写一个Editor窗口把整个流程串起来。窗口里可以选择文件夹、设置图集尺寸、Padding大小、压缩格式等参数然后一键生成图集并更新所有材质。public class TextureAtlasWindow : EditorWindow { private string folderPath Assets/Textures; private int atlasSize 2048; private int padding 4; [MenuItem(Tools/Texture Atlas Generator)] public static void ShowWindow() { GetWindowTextureAtlasWindow(Texture Atlas); } private void OnGUI() { GUILayout.Label(Atlas Settings, EditorStyles.boldLabel); folderPath EditorGUILayout.TextField(Folder, folderPath); atlasSize EditorGUILayout.IntPopup(Size, atlasSize, new[] { 1024, 2048, 4096 }, new[] { 1024, 2048, 4096 }); padding EditorGUILayout.IntSlider(Padding, padding, 0, 16); if (GUILayout.Button(Generate Atlas)) { GenerateAtlas(); } } private void GenerateAtlas() { // 收集贴图 var collector new TextureCollector(); var textures collector.CollectTextures(folderPath); // 排布图集 var packer new MaxRectsPacker(atlasSize, atlasSize); var atlasRects new DictionaryTexture2D, RectInt(); foreach (var texture in textures.Keys) { var rect packer.Insert(texture.width padding, texture.height padding); if (rect.width 0) atlasRects[texture] rect; } // 生成图集贴图 var atlasTexture new Texture2D(atlasSize, atlasSize); foreach (var kvp in atlasRects) { var texture kvp.Key; var rect kvp.Value; atlasTexture.SetPixels(rect.x, rect.y, texture.width, texture.height, texture.GetPixels()); } atlasTexture.Apply(); // 保存图集 var atlasPath ${folderPath}/Atlas.png; File.WriteAllBytes(atlasPath, atlasTexture.EncodeToPNG()); AssetDatabase.Refresh(); // 更新材质和UV // ... } }这个工具虽然简单但已经能覆盖大部分需求了。你可以根据项目需要添加更多功能比如自动分组、多图集支持、进度条显示等。6.2 自动化流程与CI集成如果项目比较大我建议把贴图融合做成自动化流程集成到CI里。每次资源更新后自动重新生成图集避免手动操作遗漏。具体做法是写一个批处理脚本在资源导入后触发图集生成然后更新所有相关材质的引用。这里要注意自动化流程要处理好异常情况比如某张贴图找不到、图集放不下等。我一般会在生成失败时输出详细的日志方便排查问题。6.3 版本管理与回滚策略图集生成后原始贴图不要删除保留作为备份。如果图集出问题可以快速回滚到原始状态。我一般会把原始贴图放在一个单独的文件夹里图集放在另一个文件夹通过版本控制工具管理。另外图集的生成参数尺寸、Padding、压缩格式也要记录下来方便后续调整。我一般会把这些参数写在一个JSON文件里和项目一起提交到版本库。7. 不同场景下的适配方案7.1 移动端项目的特殊考量移动端项目对显存和性能更敏感所以贴图融合的策略要更激进一些。我一般会把图集尺寸控制在1024或2048压缩格式用ASTC 6x6或ETC2。另外移动端要特别注意纹理的Mipmap和Aniso Level设置这两个参数对性能影响很大。还有一个细节移动端的GPU对纹理采样的精度要求不同有些低端机可能会出现纹理抖动。解决方案是开启纹理的Filter Mode为Bilinear并适当增加Padding。7.2 PC端与主机端的高质量方案PC端和主机端的硬件性能更强可以用更大的图集和更高质量的压缩格式。我一般用4096的图集压缩格式用BC7Mipmap开启Aniso Level设为4或8。这样画质和性能都能兼顾。如果项目支持DirectX 12或Vulkan还可以利用这些API的高级特性比如纹理数组Texture Array来进一步优化。纹理数组可以把多张贴图打包成一个数组减少纹理切换的开销。7.3 VR/AR项目的低延迟要求VR/AR项目对延迟非常敏感贴图融合的时候要特别注意。我一般会关闭Mipmap减少纹理采样的延迟。另外图集的尺寸不要太大避免纹理加载时间过长导致卡顿。还有一个细节VR/AR项目的视场角通常比较大纹理的Aniso Level要适当提高否则边缘会出现模糊。我一般设为8或16。7.4 2D与3D项目的差异化处理2D项目和3D项目的贴图融合策略不太一样。2D项目的贴图通常是Sprite可以用Unity的Sprite Atlas功能比较方便。3D项目的贴图通常是模型的Diffuse或Normal Map需要自己写工具处理。对于3D项目Normal Map的融合要特别注意。Normal Map的RGB通道分别代表法线的XYZ方向融合后如果压缩格式不对法线方向会出错。我一般用BC5或ASTC的Normal Map专用格式确保法线精度。8. 贴图融合后的效果验证与调试8.1 视觉验证的几种方法图集生成后首先要做视觉验证。我一般会把图集贴图直接拖到Scene视图里放大检查每张贴图的边缘是否有溢色或黑边。然后切换到Game视图检查模型的实际显示效果。如果项目有多个LOD层级还要检查不同LOD下的显示效果。有时候高LOD没问题低LOD会出现纹理模糊或错位。8.2 性能验证与数据采集视觉验证通过后要做性能验证。我一般用Unity Profiler采集以下数据DrawCall数量SetPass Call数量显存占用帧率GPU时间对比融合前后的数据确认优化效果。如果DrawCall没有明显下降说明合批没生效需要排查原因。8.3 常见调试工具与技巧Unity自带的Frame Debugger是排查合批问题的利器。它可以显示每一帧的DrawCall详情包括为什么某个DrawCall没有被合并。我一般会用它来检查材质的Shader变体是否一致以及是否有其他因素阻止了合批。另外RenderDoc也是一个很好的工具可以抓取GPU的渲染过程查看纹理的实际采样情况。如果怀疑纹理采样有问题可以用RenderDoc抓一帧看看纹理的采样坐标是否正确。8.4 回滚与版本对比如果融合后出现问题要能快速回滚。我一般会在生成图集前备份原始贴图和材质如果出问题就恢复备份。另外图集的生成参数也要记录方便对比不同参数下的效果。我一般会保留至少两个版本的图集一个是当前使用的一个是上一个版本的。如果新版本有问题可以快速切换回旧版本。9. 一些实战中的经验总结贴图融合这件事说起来简单做起来细节很多。我做了这么多年最大的体会是不要为了融合而融合。有些贴图就是不适合融合比如需要Repeat的、分辨率特别大的、或者需要独立控制的。强行融合反而会带来更多问题。另外工具化很重要。手动做一次两次还行如果项目经常更新资源手动操作迟早会出错。我建议一开始就写好Editor工具把流程固化下来后面省心很多。还有一个心得图集的尺寸和压缩格式要根据目标平台来定不要一套参数用到底。移动端和PC端的差异很大分开配置效果更好。最后验证环节不能省。我见过太多人生成图集后直接提交结果线上出现各种显示问题。视觉验证和性能验证都要做而且要在目标设备上做模拟器或编辑器里的效果和真机可能不一样。这个内容后续还可以这样扩展比如结合GPU Instancing做进一步优化或者研究一下Virtual Texturing技术看看能不能在更大规模的场景里替代传统的图集方案。不过那些就是另一个话题了有机会再聊。
阅读完成 · 觉得有帮助?
咨询建站