ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

1. 贴图融合到底在解决什么问题

做Unity项目的人迟早会撞上这个需求:场景里有一堆模型,每个模型都带着自己的贴图,显存占用蹭蹭往上涨,DrawCall也压不下来。尤其是做移动端或者WebGL项目的时候,一张2048x2048的贴图就是16MB显存,十张就是160MB,还没算Mipmap。这时候贴图融合(Texture Atlas / Texture Merging)就成了绕不开的优化手段。

说白了,贴图融合就是把多张小贴图拼成一张大贴图,然后让多个模型共享这一张图。好处很直接:DrawCall可以从N降到1(前提是材质参数一致),显存占用也能通过合理的排布大幅降低。但这件事不是把图拼起来就完事了,里面涉及到UV重映射、边缘溢色处理、图集排布算法、压缩格式选择等一系列细节。我见过太多人图省事直接用在线工具拼图,结果模型边缘出现黑边或者颜色渗漏,排查半天找不到原因。

这篇文章适合谁看?如果你正在做Unity项目优化,或者准备面试被问到DrawCall优化相关的问题,又或者你只是好奇贴图融合背后的原理,那这篇内容应该能给你一些可以直接抄作业的方案。我会从原理讲到实操,包括代码层面的UV重映射、图集排布的具体算法选择、以及我在实际项目中踩过的坑。

2. 贴图融合的核心原理与方案选型

2.1 为什么贴图融合能降低DrawCall

Unity的渲染流程里,每次切换材质(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 Dictionary<Texture2D, List<Material>> CollectTextures(string folderPath) { var result = new Dictionary<Texture2D, List<Material>>(); var materialGuids = AssetDatabase.FindAssets("t:Material", new[] { folderPath }); foreach (var guid in materialGuids) { var path = AssetDatabase.GUIDToAssetPath(guid); var material = AssetDatabase.LoadAssetAtPath<Material>(path); var texture = material.mainTexture as Texture2D; if (texture != null) { if (!result.ContainsKey(texture)) result[texture] = new List<Material>(); result[texture].Add(material); } } return result; } }

这段代码会返回一个字典,Key是贴图,Value是引用该贴图的所有材质。有了这个数据,你就能知道哪些贴图需要被融合,以及融合后需要更新哪些材质的UV。

3.2 图集排布的具体实现

我选用了MaxRects算法的一个简化版本,核心思路是维护一个空闲矩形列表,每次插入新贴图时,从列表中找到最合适的矩形进行分割。这里的关键是"最合适"的评判标准,我一般用Best Short Side Fit(BSSF),也就是选择分割后短边浪费最少的矩形。

public class MaxRectsPacker { private List<RectInt> freeRects = new List<RectInt>(); 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高质量,支持Alpha
WebGLDXT5兼容性好,体积小

4. 常见问题与排查技巧实录

4.1 模型边缘出现黑边或杂色

这是最常见的问题,原因通常是Padding不够或者边缘扩展没做。排查步骤:先检查图集中贴图之间的间距是否足够(建议至少4像素),然后检查是否做了边缘扩展。如果都没问题,那可能是Mipmap导致的,可以尝试关闭Mipmap或者增加Mipmap的Padding。

我遇到过一个特殊情况:贴图的Wrap Mode是Clamp,但图集生成后边缘采样到了图集的其他区域。这是因为UV重映射后,贴图边缘的UV可能会超出贴图区域。解决方案是在重映射时对UV做一次Clamp,确保不会超出贴图范围。

4.2 图集尺寸过大导致显存溢出

图集不是越大越好。我见过有人把几十张贴图拼成一张8192x8192的图集,结果显存直接爆了。一般来说,移动端图集尺寸不要超过2048x2048,PC端不要超过4096x4096。如果贴图太多,可以分成多个图集,每个图集对应一组材质。

另外,图集的尺寸最好是2的幂次方,这样压缩和采样效率最高。如果排布后尺寸不是2的幂,可以向上取整到最近的2的幂。

4.3 UV重映射后模型显示错乱

这个问题通常是UV坐标系搞反了。Unity的UV坐标系原点在左下角,而有些贴图编辑器的坐标系原点在左上角。如果你从其他工具导出贴图信息,要注意坐标系转换。

还有一个可能是Mesh的UV通道不对。有些模型有多个UV通道,你重映射的可能是UV1而不是UV0。检查一下Mesh.uv和Mesh.uv2的区别,确保你修改的是正确的通道。

4.4 合批不生效的排查思路

贴图融合后合批不生效,原因可能有很多。我一般按这个顺序排查:

  1. 检查材质是否完全相同(包括Shader和所有参数)
  2. 检查顶点数是否超过动态合批限制(默认300)
  3. 检查是否开启了静态合批(Static Batching)
  4. 检查是否有Lightmap或Shadowmask导致材质变体不同
  5. 检查Shader是否支持合批(有些自定义Shader会禁用合批)

如果以上都没问题,可以用Frame Debugger查看具体的合批情况,它会告诉你为什么某个DrawCall没有被合并。

4.5 常见问题速查表

问题现象可能原因解决方案
边缘黑边Padding不足增加Padding至4像素以上
颜色渗漏边缘扩展未做生成图集后做2像素边缘扩展
显存溢出图集尺寸过大拆分图集,单张不超过2048
UV错乱坐标系不一致检查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/Write,SetPixels会失败。我一般会在收集贴图时就把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() { GetWindow<TextureAtlasWindow>("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 Dictionary<Texture2D, 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的图集,压缩格式用BC7,Mipmap开启,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技术,看看能不能在更大规模的场景里替代传统的图集方案。不过那些就是另一个话题了,有机会再聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进