这篇文章主要讲一下Unity中的贴图数据操作和内存计算方式以及相关优化

Unity中Texture中各种关系以及Apply()方法

先抛出一个我无意间浏览到的一个博主写的关于Texture Texture2D RendererTexture 之间的关系.

链接:http://fargesportfolio.com/unity-texture-texture2d-rendertexture/

这里说一下 Texture2DApply操作,当我们使用SetPixel``SetPixels ReadPixels定要调用Apply方法,不然显示的还是原来的图,这里有个链接具体看下:https://blog.csdn.net/carefreeq/article/details/52635524

赋值完后为什么要Apply

因为在贴图更改像素时并不是直接对显存进行更改,而是在另外一个内存空间中更改,这时候GPU还会实时读取旧的贴图位置。

当Apply后,CPU会告诉GPU你要换个地方读贴图了

所以图片存在内存中,CPU指向这个图片地址,GPU根据CPU拿过来的地址来显示,如果更改了图片数据,没用调用Apply. 那么GPU没用接收到CPU发来的指令就不会更改这个图的显示.下面的代码,当我没调用Apply的操作时,显示的还是原图,当我调用之后显示的就是纯红色了

1
2
3
4
5
6
7
8
9
10
11
12
private void BtnClick()
{
Texture2D tex = new Texture2D(0, 0);
tex.LoadImage(File.ReadAllBytes(desktopPath + "/111.jpg"));
Color[] colors = tex.GetPixels();
for (int i = 0; i < colors.Length; i++)
colors[i] = Color.red;

tex.SetPixels(colors);
//tex.Apply(); 这里更改
rawImg.texture = tex;
}

使用Apply是一个很昂贵的操作,所以官方建议我们在使用这个方法之前,尽可能把数据操作完毕!

Unity中Texture内存计算方式

任何贴图(Unity可识别的格式)导入到Unity中都有Unity自己的图片计算方法.而且可能一张100K大小的图片导入Unity就是好几M,同样一张图png和jpg两个格式假如一个100k一个50k,但是导入Unity中都是2M.那么Unity的计算格式是怎么样的呢?

我做了一个1024*1024的图片导入到Unity中,不勾选Generate Min Maps,然后设置格式RGBA32那么此时的格式大小

这里可以看到图片大小是4M,其计算方法–>这里格式设置的是RGBA32 bit,这个格式意思就是每一个像素用了32个bit去填充,而我们都知道一个byte等于8个bit,所以这里每个像素都占用了4个byte,而1m=1024kb,所以:

总的像素大小=1024x1024(宽度x高度)

那么总大小=总像素x每个像素大小=1024x1024x4=4M

如果我们将图片换成RGB24,那么大小应该是;

​ 1024x1024x3=3M

所以在Unity中图片的内存大小是根据其格式定义的,具体要看每个像素占用的字节大小.

Texture2D中的RawData

Unity中有tex.LoadRawTextureData()tex.GetRawTextureData()两个方法,Get是获取图片的原始数据Load是从原始数据中加载.

这在我项目中要频繁加载外部纹理(用完就处理掉)需要更快的加载纹理,这个方法加载速度要比IO读取和UnityWebRequest快很多

要获取图片的RawData,就必须导入让Unity识别一遍,所以在开始就可以对纹理进行处理,先用IO读取获取到Texture2D(这里new Texture2D可以不用设置宽高)

1
2
Texture2D tex = new Texture2D(0, 0,TextureFormat.RBGA32,false);
tex.LoadImage(File.ReadAllBytes("xxxx/1.png"));

这个时候我们已经得到了一个Unity Texture2D,接着我们使用代码获取RawData,然后保存

1
2
byte[] bs = tex.GetRawTextureData();
File.WriteAllBytes("xxxx/1.png.bytes", bs);

保存这个二进制文件之后,可以发现文件大小就是此图片在Unity看到的大小(在这用上面的图格式是RGBA32,那么这个二进制文件就是4M)

这里需要注意一点的是,当我们使用LoadRawTextureData的时候,必须要指定原始图片的Width,Height和TextureFormat(minchan可以不管),如下

1
2
Texture2D tex2 = new Texture2D(1920, 1080, TextureFormat.RBGA32, false);
tex.LoadRawTextureData(File.ReadAllBytes("xxxx/1.png.bytes"));

所以你在保存RawData的时候,应该记录一下图片的一些属性,可以把数据写到文件名,然后用字符串操作得到相应数据,我这里使用了二进制保存.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void WriteTexture2DToRawData(Texture2D texture, string filePath)
{
byte[] bs = texture.GetRawTextureData();
using (BinaryWriter bw = new BinaryWriter(File.OpenWrite(filePath)))
{
//提前把前三个写入
bw.Write(texture.width);
bw.Write(texture.height);
bw.Write((int)texture.format);
//bw.Write(texture.streamingMipmaps);
//------
bw.Write(bs);
}
}

然后在读取的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static Texture2D LoadTexture2DFromRawData(string rawdataPath)
{
using (BinaryReader br = new BinaryReader(File.OpenRead(rawdataPath)))
{
//先读取前三个宽 高 格式
int w = br.ReadInt32();
int h = br.ReadInt32();
int format = br.ReadInt32();

//在读取剩下的texture raw data
//此时的br.BaseStream.Position=12
byte[] bs = br.ReadBytes((int)(br.BaseStream.Length - br.BaseStream.Position));
TextureFormat textureFormat = (TextureFormat)format;
Texture2D texture = new Texture2D(w, h, textureFormat, false);
texture.LoadRawTextureData(bs);
texture.Apply();
return texture;
}
}

OK

2020年5月15日22点11分更新补充

问题:当把图片写入成二进制文件的时候实在是太大了

解决方案:把byte压缩

查了资料,C# 中已经有了相关类(We are so lucky 😍)

压缩类:https://docs.microsoft.com/zh-cn/dotnet/api/system.io.compression.gzipstream?view=netcore-3.1

开源地址:https://github.com/microsoft/referencesource/tree/master/System/sys/system/IO/compression

压缩有Gzip压缩(压缩快,但是稍大)和Brotli(压缩慢些,但是压缩后文件小)

新建一个Byte[]压缩类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System.IO;
using System.IO.Compression;

/// <summary>
/// 压缩和解压缩 本类使用的是Gzip压缩
/// https://docs.microsoft.com/zh-cn/dotnet/api/system.io.compression.gzipstream.-ctor?view=netcore-3.1
/// </summary>
public class CompressDecompressUtil
{
#region 压缩字节
/// <summary>
/// 压缩byte[](采用的GZip压缩 还有种是Brotli压缩)
/// </summary>
/// <param name="rawBytes">原始byte[]数据</param>
/// <returns>压缩后的bytes[]</returns>
public static byte[] CompressBytes(byte[] rawBytes)
{
//声明一个MemoryStream流用来存放压缩后的byte[]
using (MemoryStream compressStream = new MemoryStream())
{
//创建一个GZipStream 用来压缩
using (var conpressionGzipStream = new GZipStream(compressStream, CompressionMode.Compress))
{
conpressionGzipStream.Write(rawBytes, 0, rawBytes.Length);
//或者
// gzipStream.CopyTo(compressStream);
}
return compressStream.ToArray();
}
}
/// <summary>
/// 解压缩字节
/// </summary>
/// <param name="compressBytes">经过Gzip压缩后的byte[]</param>
/// <returns>返回原始的byte[]</returns>
public static byte[] DecompressFormBytes(byte[] compressBytes)
{
//声明一个compressStream表示原来的压缩流
using (var compressStream = new MemoryStream(compressBytes))
{
//从原来的压缩流 解压出 原始数据
using (GZipStream decompressiongzipStream = new GZipStream(compressStream, CompressionMode.Decompress))
{
using (var resultStream = new MemoryStream())
{
//用read比较麻烦
decompressiongzipStream.CopyTo(resultStream);
return resultStream.ToArray();
}
}
}
}
#endregion

//todo

#region 压缩文件

#endregion
}

然后上面的读取 和写入方法稍微修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/// <summary>
/// 从二进制文件中加载图片
/// 如果使用了Gzip压缩,文件后缀必须有".gz"
/// 配合WriteTexture2DToRawData方法来使用
/// </summary>
/// <param name="rawdataPath">图片rawdate路径</param>
/// <param name="useGzipDecompress">是否使用gzip解压缩,如果使用了gzip压缩的话</param>
/// <returns></returns>
public static Texture2D LoadTexture2DFromRawData(string rawdataPath, bool useGzipDecompress)
{
using (BinaryReader br = new BinaryReader(File.OpenRead(rawdataPath)))
{
//先读取前三个宽 高 格式
int w = br.ReadInt32();
int h = br.ReadInt32();
int format = br.ReadInt32();

//在读取剩下的texture raw data
//此时的br.BaseStream.Position=12
byte[] bs = br.ReadBytes((int)(br.BaseStream.Length - br.BaseStream.Position));
//用&& 不用 &
if (useGzipDecompress && Path.GetExtension(rawdataPath) == ".gz")
{
bs = CompressDecompressUtil.DecompressFormBytes(bs);
}

TextureFormat textureFormat = (TextureFormat)format;
Texture2D texture = new Texture2D(w, h, textureFormat, false);
texture.LoadRawTextureData(bs);
texture.Apply(false);
return texture;
}
}
/// <summary>
/// 将图片保存到二进制文件,
/// </summary>
/// <param name="texture">图片</param>
/// <param name="savePath">保存路径(例如:xxx.png.bytes)</param>
/// <param name="useGzipCompress">是否使用Gzip压缩</param>
public static void WriteTexture2DToRawData(Texture2D texture, string savePath, bool useGzipCompress)
{
byte[] bs = texture.GetRawTextureData();
if (useGzipCompress)
{
bs = CompressDecompressUtil.CompressBytes(bs);
savePath += ".gz"; //加个后缀名
}
using (BinaryWriter bw = new BinaryWriter(File.OpenWrite(savePath)))
{
//前12个byte 用来保存图片的宽度 高度 和格式
//提前把前三个写入
bw.Write(texture.width);
bw.Write(texture.height);
bw.Write((int)texture.format);
//bw.Write(texture.streamingMipmaps);
//------
bw.Write(bs);
}
}

食用

1
2
3
4
5
6
7
private void Instance_OnKeyDown1()
{
Texture2D tex2 = TextureUtility.LoadTexture2DFromIO(path + "222.png");
TextureUtility.WriteTexture2DToRawData(tex2, path + "222.png.bytes", true);
rawImage.texture = TextureUtility.LoadTexture2DFromRawData(path + "222.png.bytes.gz",false);
Debug.Log("end");
}

我在画图中画了一个1024*1024纯红图片,如果没有压缩那么是4M,压缩后直接成5kb!!! 压缩算法 就不多解读了,具体可以查看源码。

Texture 优化

2020年11.16更新

移动端考虑图片压缩方式使用ASTC方式压缩了,在ios和android可以通用该压缩方式.

ASTC 压缩

后面是每个像素占据的bit,例如ASTC4*4 每个像素占据8bit=1kb,对于1024x1024大小的图,在unity中压缩后大小就是1m

适配机型

  1. IOS

    苹果从A8处理器开始支持 ASTC,iPhone6及iPad mini 4以上iOS设备支持,2014年的iPhone 5s及iPad mini 3以前的设备不支持。

  2. Android

    所有支持OpenGL ES 3.1和部分支持OpenGL ES 3.0的GPU

就算部分机型不支持该压缩,也可以有fallback

使用ASTC压缩比率选择

总结:

  1. 如果图片没有透明通道,再导入的时候应该设置不导入,不然会影响ASTC压缩
  2. 压缩还是要具体打包看图片质量

导入设置

正确设置没有透明通道图片的导入格式

Alpha Is Transparency

​ 解释为Alpha 是否是半透明, Unity只处理全透明,如果开启了此选项,就是有半透明效果,如果图片没有透明通道可以关闭这个选项

聊聊 Unity 的 Alpha Is Transparency 有什么用 - 知乎 (zhihu.com)

Texture中的过滤模式

就是像素与像素之间过度

过滤模式会提供多种方案来使得纹理投影到物体表面的过程变得更为顺滑自然。一般来讲,Unity提供了三种模式:

Point(no filter)——Point模式为不过滤的采样方式.类似PS像素一块一块的,的使用最近点采样的方法,当UV坐标没有刚好对应Texture上的一个采样点时,它会选择最近的一个采样点作为该坐标的采样值。当纹理没有拉伸变形时,这样速度是最快的,且效果理想。但如果拉伸变形了,会出现马赛克现象。

Bilinear——双线性过滤模式。两个像素点之间平衡,看起来更平滑,简单来讲,它会对相邻的像素进行模糊化处理,使得像素之间的变化更为圆润平滑,不会有明显的锯齿感或者马赛克化。但这只涉及单个“平面”的操作,所以一旦涉及mipmap层级间的处理,双线性过滤就会有些“力不从心”,某些表现效果会大打折扣。

Trilinear——三线性过滤模式。和Bilinear模式类似,但是额外优化了mipmap层级间的转换效果,它会在mip层级间进行模糊处理,弥补了Bilinear模式的不足。

这里我们延伸讲一下关于“mipmap”的概念。简单来讲为了更好地应对纹理贴图在不同距离和大小情况下的表现效果,以提升渲染的速度和降低图像锯齿化的影响,Unity会以纹理原尺寸为基础,预设几个等比例缩放的“复制品”,在实际使用中会根据情况加载对应的mipmap贴图,从而提升渲染性能,放大缩小的过程也因为mipmap层级的选择而更为快捷。

回到规则本身,Trilinear模式对表现效果的提升,是以GPU的额外开销为代价的。同等条件下,三线性过滤的GPU占用是最高的。所以如果对纹理的细致程度不那么敏感(比如像素类游戏),或者不涉及mipmapping的应用(比如2D游戏),那么就没有必要去选择Trilinear模式。

所以建议开发团队对本条规则检测下的纹理资源,依据项目实际需求进行相关的过滤模式的修改

参考

  1. ASTC纹理压缩格式详解
  2. 纹理优化:不仅仅是一张图片那么简单