引言
在做手机游戏时可能会遇到这些问题:
- UI同学天天抱怨iOS上一些透明贴图压缩后模糊不堪
- 一些古早的Android手机上同样的贴图吃内存超过其他手机数倍,游戏经常闪退
这篇文章给出了一种手机游戏项目中通用的解决方案:分离贴图alpha通道,及其基于Unity引擎的实现过程和细节。其中思路主要来自于https://zhuanlan.zhihu.com/p/32674470,本文是对该方法的实践和补充。
为什么要分离
1. 为什么会出现这些问题
要弄明白这些问题的由来,首先要简单解释一下贴图压缩格式的基础概念。
为了让贴图在手机中运行时占用尽可能少的内存,需要设置贴图的压缩格式,目前Unity支持的主要压缩格式有:android上的ETC/ETC2,iOS上的PVRTC,以及未来可能会使用的ASTC。这几个压缩格式有自己的特点:
- ETC:不支持透明通道,被所有android设备支持
- ETC2:支持透明通道,Android设备的GPU必须支持OpenGL es 3.0才可以使用,对于不支持的设备,会以未压缩的形式存在内存中,占用更多内存
- PVRTC:所有苹果设备都可以使用,要求压缩纹理长宽相等,且是2的幂次(POT,Power of 2)
- ASTC:高质量低内存占用,未来可能普遍使用的压缩格式,现在有一部分机型不支持
一般来说,目前Unity的手机游戏android上非透明贴图会使用RGB Compressed ETC 4bits,透明贴图可以使用RGBA Compressed ETC2 8bit,iOS非透明贴图使用RGB Compressed PVRTC 4bits,透明贴图使用RGBA Compressed PVRTC 4bits。
这里的bits概念的意思为:每个像素占用的比特数,举个例子,RGB Compressed PVRTC 4bits格式的1024x1024的贴图,其在内存中占用的大小 = 1024x1024x4 (比特) = 4M (比特) = 0.5M (字节)。
我们可以看到,在iOS上,非透明贴图和透明贴图都是4bpp(4bits per pixel)的,多了透明通道还是一样的大小,自然4bpp的透明贴图压缩出来效果就会变差,而实机上看确实也是惨不忍睹。这是第一个问题的答案。
一些古早的android机,由于不支持OpenGL es 3.0,因此RGBA Compressed ETC2 8bit的贴图一般会以RGBA 32bits的格式存在于内存中,这样内存占用就会达到原来的4倍,在老机器低内存的情况下系统杀掉也不足为奇了。这是第二个问题的答案。当然,需要说明的是,现在不支持OpenGL es 3.0的机器的市场占有率已经相当低了(低于1%),大多数情况下可以考虑无视。
更多的贴图压缩格式相关内容可以参考这里:https://zhuanlan.zhihu.com/p/113366420
2. 如何解决问题
要解决上面图片模糊的问题,可以有这些做法:
- 透明贴图不压缩,内存占用32bpp
- 分离alpha通道,内存占用4bpp+4bpp(或4bpp+8bpp)
不压缩显然是不可能的,毕竟32bpp的内存消耗对于手机来说过大了,尤其对于小内存的iOS设备更是如此。所以我们考虑分离alpha通道,将非透明部分和透明部分拆成两张图(如下所示)。
至于其内存占用,一般来说会把非透明部分拆成RGB Compressed PVRTC 4bits,而透明通道部分可以使RGB Compressed PVRTC 4bits,也可以是Alpha8格式(8bpp)。Alpha8格式似乎不同版本Unity对于Mali芯片的手机支持度不同,我没有做深入研究。测试中,我使用了RGB Compressed PVRTC 4bits格式来压缩透明通道贴图,效果已经完全可以接受了。
如何分离
1. 方案1
我们很自然而然的会想到,继承SpriteRenderer/Image组件去实现运行时替换材质来达到目的。这种方案有一些缺点,对于已经开发到后期的项目来说,要修改所有的组件成本非常高,更不用说在加入版本控制的项目中,修改prefab的合并成本也非常高了;另外对于已经使用自定义材质的组件来说也很不方便。
2. 方案2
直接修改Sprite的RenderData,让其关联的texture,alphaTexture等信息直接在打包时被正确打入包内。
这样做的好处就是不需要去修改组件了,只要整个打包流程定制化好以后就能够一劳永逸了。而对于大多数商业项目来说,定制打包流程基本是必须的,所以这个也就不算是什么问题了。
实现细节
首先说明一下,本方案在2017.4测试通过,其中打图集是采用已经废弃的Sprite Packer的方式,至于Sprite Atlas的方式,我没有研究过,但我觉得应该都可以实现,只是可能要改变不少流程。
下面说明一下具体实现,在打包之前大致流程如下:
// 刷新图集缓存 UpdateAtlases(buildTarget); // 找到所有要处理的项 FindAllEntries(buildTarget, m_spriteEntries, m_atlasEntries); // 生成alpha纹理 GenerateAlphaTextures(m_atlasEntries); // 保存纹理到文件 SaveTextureAssets(m_atlasEntries); // 刷新资源 AssetDatabase.Refresh(); // 从文件中加载alpha纹理 ReloadTextures(m_atlasEntries); // 修改所有sprite的Render Data WriteSpritesRenderData(m_atlasEntries); // 禁用SpritePacker准备打包 EditorSettings.spritePackerMode = SpritePackerMode.Disabled;
大致解释一下上面的流程:
- UpdateAtlases:强制刷新图集缓存(需要分离alpha通道的图集要修改其压缩格式为去掉A通道的)
- FindAllEntries:找到所有的sprite,检查其PackingTag,分类整理所有sprite和图集的信息
- GenerateAlphaTextures/SaveTextureAssets:根据图集的信息绘制alpha通道的纹理并保存文件
- AssetDatabase.Refresh():实践中如果不重新刷新的话,可能导致某个贴图无法找到
- ReloadTextures:从文件加载纹理,作为写入RenderData的数据
- WriteSpritesRenderData:最重要的一步,将texture,alphaTexture等信息写入Sprite的RenderData
- 最后,在打包前,禁用SpritePacker,避免其在打包时重写打了图集并覆写了Sprite的RenderData
其中,关于生成Alpha通道贴图,需要注意的是使用图集中的散图位置等信息,将压缩前的顶点信息直接渲染到贴图上,这样透明通道贴图就不会受到压缩的影响。
// 临时渲染贴图 var rt = RenderTexture.GetTemporary(texWidth, texHeight, 0, RenderTextureFormat.ARGB32); Graphics.SetRenderTarget(rt); GL.Clear(true, true, Color.clear); GL.PushMatrix(); GL.LoadOrtho(); foreach (var spriteEntry in atlasEntry.SpriteEntries) { var sprite = spriteEntry.Sprite; var uvs = spriteEntry.Uvs; var atlasUvs = spriteEntry.AtlasUvs; // 将压缩前sprite的顶点信息渲染到临时贴图上 mat.mainTexture = spriteEntry.Texture; mat.SetPass(0); GL.Begin(GL.TRIANGLES); var triangles = sprite.triangles; foreach (var index in triangles) { GL.TexCoord(uvs[index]); GL.Vertex(atlasUvs[index]); } GL.End(); } GL.PopMatrix(); // 最终的alpha贴图 var finalTex = new Texture2D(texWidth, texHeight, TextureFormat.RGBA32, false); finalTex.ReadPixels(new Rect(0, 0, texWidth, texHeight), 0, 0); // 修改颜色 var colors = finalTex.GetPixels32(); var count = colors.Length; var newColors = new Color32[count]; for (var i = 0; i < count; ++i) { var a = colors[i].a; newColors[i] = new Color32(a, a, a, 255); } finalTex.SetPixels32(newColors); finalTex.Apply(); RenderTexture.ReleaseTemporary(rt);
在将透明通道贴图写文件有一点需要注意的是:由于可能打的图集会产生多个Page,这些Page的贴图名都是相同的,如果直接保存可能造成错误覆盖,所以需要使用一个值来区分不同Page,这里我们使用了Texture的hash code。
// 支持多page图集 var hashCode = atlasEntry.Texture.GetHashCode(); // 导出alpha纹理 if (atlasEntry.NeedSeparateAlpha) { var fileName = atlasEntry.Name + "_" + hashCode + "_alpha.png"; var filePath = Path.Combine(path, fileName); File.WriteAllBytes(filePath, atlasEntry.AlphaTexture.EncodeToPNG()); atlasEntry.AlphaTextureAssetPath = Path.Combine(assetPath, fileName); }
接下来再说明一下最重要的写SpriteRenderData部分。
var spr = spriteEntry.Sprite; var so = new SerializedObject(spr); // 获取散图属性 var rect = so.FindProperty("m_Rect").rectValue; var pivot = so.FindProperty("m_Pivot").vector2Value; var pixelsToUnits = so.FindProperty("m_PixelsToUnits").floatValue; var tightRect = so.FindProperty("m_RD.textureRect").rectValue; var originSettingsRaw = so.FindProperty("m_RD.settingsRaw").intValue; // 散图(tight)在散图(full rect)中的位置和宽高 var tightOffset = new Vector2(tightRect.x, tightRect.y); var tightWidth = tightRect.width; var tightHeight = tightRect.height; // 计算散图(full rect)在图集中的rect和offset var fullRectInAtlas = GetTextureFullRectInAtlas(atlasTexture, spriteEntry.Uvs, spriteEntry.AtlasUvs); var fullRectOffsetInAtlas = new Vector2(fullRectInAtlas.x, fullRectInAtlas.y); // 计算散图(tight)在图集中的rect var tightRectInAtlas = new Rect(fullRectInAtlas.x + tightOffset.x, fullRectInAtlas.y + tightOffset.y, tightWidth, tightHeight); // 计算uvTransform // x: Pixels To Unit X // y: 中心点在图集中的位置X // z: Pixels To Unit Y // w: 中心点在图集中的位置Y var uvTransform = new Vector4( pixelsToUnits, rect.width * pivot.x + fullRectOffsetInAtlas.x, pixelsToUnits, rect.height * pivot.y + fullRectOffsetInAtlas.y); // 计算settings // 0位:packed。1表示packed,0表示不packed // 1位:SpritePackingMode。0表示tight,1表示rectangle // 2-5位:SpritePackingRotation。0表示不旋转,1表示水平翻转,2表示竖直翻转,3表示180度旋转,4表示90度旋转 // 6位:SpriteMeshType。0表示full rect,1表示tight // 67 = SpriteMeshType(tight) + SpritePackingMode(rectangle) + packed var settingsRaw = 67; // 写入RenderData so.FindProperty("m_RD.texture").objectReferenceValue = atlasTexture; so.FindProperty("m_RD.alphaTexture").objectReferenceValue = alphaTexture; so.FindProperty("m_RD.textureRect").rectValue = tightRectInAtlas; so.FindProperty("m_RD.textureRectOffset").vector2Value = tightOffset; so.FindProperty("m_RD.atlasRectOffset").vector2Value = fullRectOffsetInAtlas; so.FindProperty("m_RD.settingsRaw").intValue = settingsRaw; so.FindProperty("m_RD.uvTransform").vector4Value = uvTransform; so.ApplyModifiedProperties(); // 备份原数据,用于恢复 spriteEntry.OriginTextureRect = tightRect; spriteEntry.OriginSettingsRaw = originSettingsRaw;
需要修改的部分的含义,这里面的注释已经写的很清楚了,简单看一下能够大致理解。其中还有几个概念需要说明一下:
在Sprite的导入设置中,会被要求设置MeshType,默认的是Tight,其效果会基于alpha尽可能多的裁剪像素,而full rect则表示会使用和图片纹理大小一样的矩形。
这两个选项在达成图集时,如果你的散图周围的alpha部分比较多,使用full rect时就会看到图片分的很开,而使用tight,表现出来的样子就会很紧凑,效果为下面几张图:
上面这个散图原图,可以看到周围透明部分较多
上面这个是使用Tight的mesh type打成的图集,可以看到中间的间隔较少
上面这个是使用full rect的mesh type打成的图集,可以看到中间的间隔较大。
一般我们会使用Tight,那么我在上面代码中就需要对tight相关的一些数值做计算,具体如何计算直接看代码吗,应该不难理解。
其中还有一个获取计算散图(full rect)在图集中的rect的方法GetTextureFullRectInAtlas,代码如下:
private static Rect GetTextureFullRectInAtlas(Texture2D atlasTexture, Vector2[] uvs, Vector2[] atlasUvs) { var textureRect = new Rect(); // 找到某一个x/y都不相等的点 var index = 0; var count = uvs.Length; for (var i = 1; i < count; i++) { if (Math.Abs(uvs[i].x - uvs[0].x) > 1E-06 && Math.Abs(uvs[i].y - uvs[0].y) > 1E-06) { index = i; break; } } // 计算散图在大图中的texture rect var atlasWidth = atlasTexture.width; var atlasHeight = atlasTexture.height; textureRect.width = (atlasUvs[0].x - atlasUvs[index].x) / (uvs[0].x - uvs[index].x) * atlasWidth; textureRect.height = (atlasUvs[0].y - atlasUvs[index].y) / (uvs[0].y - uvs[index].y) * atlasHeight; textureRect.x = atlasUvs[0].x * atlasWidth - textureRect.width * uvs[0].x; textureRect.y = atlasUvs[0].y * atlasHeight - textureRect.height * uvs[0].y; return textureRect; }
最后,需要在自定义打图集规则,并在判断需要分离alpha通道的贴图,修改其对应压缩格式,如RGBA ETC2改RGB ETC,RGBA PVRTC改RGB PVRTC。这样做是为了打图集生成一份不透明贴图的原图。大致代码如下:
// 需要分离alpha通道的情况 if (TextureUtility.IsTransparent(settings.format)) { settings.format = TextureUtility.TransparentToNoTransparentFormat(settings.format); }
至于如何自定义打图集的规则,可以参考官方文档:https://docs.unity3d.com/Manual/SpritePacker.html
一些补充
1. 在手机上UI.Image显示的贴图为丢失材质的样子
原因在于Image组件使用这套方案时,使用了一个内置的shader:DefaultETC1,需要在Editor -> Project Settings -> Graphics中将其加入到Always Included Shaders中去。
2. 分离alpha通道的贴图的sprite资源打入包内的形式
通过AssetStudio工具看到,下图是没有分离alpha通道的散图的情况,可以看到每一个Sprite引用了一张Texture2D
下图是分离了Alpha通道的图集的情况,可以看到,这个AssetBundle包中只有数个Sprite,已经2张Texture2D(非透明贴图和透明通道贴图)。
3. 如何知道需要修改Sprite的哪些Render Data
在实践尝试的过程中,通过UABE工具来比较不分离alpha通道和分离alpha通道的两种情况下Sprite内的Render Data的不同,来确定需要修改哪些数据来达到目的。
从下图可以看出(左边是正常图集的数据,右边是我尝试模拟写入RenderData的错误数据),m_RD中的texture,alphaTexture,textureRect,textureRectOffset,settingsRaw,uvTransform这些字段都需要修改。因为我无法接触到源码,所以其中一些值的算法则是通过分析猜测验证得出的。
4. m_RD.settingsRaw的值的意义是什么
从AssetStudio源码中可以找到settingsRaw的一部分定义:
- 0位:packed。1表示packed,0表示不packed
- 1位:SpritePackingMode。0表示tight,1表示rectangle
- 2-5位:SpritePackingRotation。0表示不旋转,1表示水平翻转,2表示竖直翻转,3表示180度旋转,4表示90度旋转
- 6位:SpriteMeshType。0表示full rect,1表示tight
其中正常生成的图集的值67,表示SpriteMeshType(tight) + SpritePackingMode(rectangle) + packed。
5. 在Unity 2017测试通过,其他版本可以通过吗
并不确定。通过查看AssetStudio源码,可以看到序列化后有许多跟Unity版本相关的不同处理(下图),如果在不同版本出现问题,可以通过上面对比打好的AssetBundle包的Sprite的RenderData的方式来排查是否需要填写其他数据。
延伸思考
如果我们把一开始刷新图集缓存的操作更换成TexturePacker的话,是否可以使用TexturePacker中的一些特性来为图集做优化和定制呢?这是可能的,但是这也不是简单就能做到的东西,还是很繁琐的,不过的确是一个不错的思路,有需要的同学可以研究一下。
参考资料
- IOS下拆分Unity图集的透明通道(不用TP):https://zhuanlan.zhihu.com/p/32674470
- [2018.1]Unity贴图压缩格式设置:https://zhuanlan.zhihu.com/p/113366420
- (Legacy) Sprite Packer:https://docs.unity3d.com/Manual/SpritePacker.html
文中提到的工具
- AssetStudio,一个可以轻松查看AssetBundle内容的工具:https://github.com/Perfare/AssetStudio
- UABE,可以解包/打包AssetBundle,并查看其中详细数据的工具:https://github.com/DerPopo/UABE
代码仓库
以上的代码都会整理在代码仓库中,该demo包含了一个完整的测试实例
很受用,谢谢作者。