内存优化: 纹理压缩技术
楚奕2022-03-22

相比普通格式图片,纹理压缩可以节省大量显存和 CPU 解码时间,且对 GPU 友好。

背景

游戏开发中纹理是内存占用大户,移动设备因为内存有限,问题更加明显。据统计,淘宝互动小程序性能卡口 70% 以上都是因为内存超标,而内存超标的主要原因则是图片素材过多、过大等。

我们知道传统的图片文件格式有 PNG 、 JPEG 等,这种类型的图片格式无法直接被 GPU 读取,需要先经过 CPU 解码后再上传到 GPU 使用,解码后的数据以 RGB(A) 形式存储,无压缩。

而纹理压缩顾名思义是一种压缩的纹理格式,它通常会将纹理划分为固定大小的块(block)或者瓦片(tile),每个块单独进行压缩,整体显存占用更低,并且能直接被 GPU 读取和渲染(无需 CPU 解码),举例来说,一张1024x1024 的 JPEG 图片,使用RGBA格式,显存占用在 4M~5.3M 左右,而如果采用 ASTC_4x4 纹理压缩格式后,理论内存占用约在1.3M左右,相比普通纹理,可以减少70%+内存,具体数据见本文第三部分。

除此之外纹理压缩支持随机访问,随机访问是很重要的特性,因为纹理访问的模式高度随机,只有在渲染时被用到的部分才需要访问到,且无法提前预知其顺序。而且在场景中相邻的像素在纹理中不一定是相邻的 ,因此图形渲染性能高度依赖于纹理访问的效率。综上,相比普通格式图片,纹理压缩可以节省大量显存和 CPU 解码时间,且对 GPU 友好。

在WebGL上,我们可以通过相关 Extension 使用纹理压缩。纹理压缩的格式有很多种(详见下文),并且不同的厂商和机型支持的格式也不完全一致,因此使用压缩纹理前,需要判断设备是否支持。实际开发中,一般不会直接使用WebGL API加载压缩纹理,而是使用游戏引擎,目前主流的游戏引擎如 pixi.js 等均支持纹理压缩,开发者可以不用关心其中细节,只需要跟普通图片一样传入素材地址,剩下的都交给引擎来做。

纹理压缩不是银弹,虽然优势很多,但是其自身也有一些使用限制,主要有:

  • 有损压缩。所有的压缩纹理均为有损压缩,因此需要开发者 or 设计师验证压缩效果是否符合预期;
  • 尺寸要求。部分压缩纹理要求宽高相等(PVRTC),或者宽高必须是2的幂次方,使用有些不便;
  • 体积。压缩纹理虽然显存占用小,但是文件体积通常会比 JPEG 更大(看具体压缩格式),IO时间会更长;
  • 格式&兼容性问题。压缩纹理格式多样,需要针对不同平台选用不同格式,意味着同一份素材可能需要存储多份格式;

因此,是否需要使用压缩纹理需要开发者进行权衡,比如,游戏首帧资源我们通常希望越快约好,这时可以使用普通纹理,而对于非首帧资源或者出现内存瓶颈时则可以考虑使用纹理压缩。

主流纹理压缩格式、原理及兼容性情况

纹理压缩格式

格式

WebGL扩展名

简介

ETC1

WEBGL_compressed_texture_etc1

ETC(Ericsson Texture Compression)是Khronos 开放标准,专利来自于瑞典爱立信公司,它在移动平台中广泛采用,是一种为感知质量设计的有损算法,它基于人眼对亮度而不是色度更敏感这一事实,在每个block定义四种不同的亮度偏移,即四种不同的颜色可用,可以认为这些颜色就是一个局部调色板,ETC会把4x4的像素块压缩成一个64或128位的数据块。

ETC有两种压缩格式:ETC1和ETC2。纹理长宽必须是2的幂次方;

  • ETC1基本所有Android机型都支持,但是缺陷是不支持Alpha通道;
  • ETC2 是ETC1的扩展,向下兼容ETC1。支持Alpha,但是需要开启OpenGL ES 3.x。

ETC2

WEBGL_compressed_texture_etc

ASTC

WEBGL_compressed_texture_astc

ASTC(Adaptive Scalable Texture Compression)是目前最强大的纹理压缩格式,由ARM & AMD研发。ASTC同样是基于block的压缩方式,但块的大小却较支持多种尺寸,比如从基本的4x4到12x12;每个块内的内容用128bits来进行存储,因而不同的块就对应着不同的压缩率;相比ETC,ASTC不要求长宽是2的幂次方。

PVRTC

WEBGL_compressed_texture_pvrtc

PVRTC(PowerVR Texture Compression),专为 PowerVR 图形核心系列设计,IOS平台都支持,它使用2张双线性放大的低分辨率图,根据精度和每个像素的权重,融合到一起来呈现纹理;PVRTC 2-bpp把一个8×4的像素单元组压成一个64位的数据块,压缩效果比较差;PVRTC 4-bpp把一个4×4的像素单元组压成一个64位的数据块;PVRTC压缩要求图片的大小必需是正方形而且边长必需是2的幂次方。

S3TC

WEBGL_compressed_texture_s3tc

S3TC(S3 Texture Compression)基本思想是把4x4的像素块压缩成一个64或128位的数据块,有损压缩。S3TC算法有五种变化DXT1-DXT5,一般在桌面设备上面使用,详见wiki

压缩纹理素材生产

如上所示,设计师产出png/jpeg等素材后,可以通过工具生成.ktx格式的压缩纹理素材,随后就可以在项目中直接使用了。不同格式的压缩纹理生产工具也不一样(PVETextTool、Adreno Texture Tool、...),而为了在各个平台中都能使用,通常需要生成不同格式的压缩纹理,社区有一些工具做了二次封装,可以生成多种格式的压缩纹理,如texture-compressor等。

KTX文件格式

KTX(Khronos texture)是一种通用的纹理压缩存储格式,OpenGL(ES)、Vulkan等均支持,KTX文件中包含了纹理加载所需的所有参数及数据,比如format 、type、宽高等等,更多信息见wiki。

如下是一个ktx文件的内容:

基于这个格式,可以实现KTX Loader,用于解析KTX资源,生成纹理(通常游戏引擎会自带)。如下所示,读取KTX文件到ArrayBuffer然后解析拿到元信息:

// KhronosTextureContainer
constructor(arrayBuffer, facesExpected, baseOffset = 0) {
  this.arrayBuffer = arrayBuffer;
  this.baseOffset = baseOffset;

  // Test that it is a ktx formatted file, based on the first 12 bytes, character representation is:
  // '´', 'K', 'T', 'X', ' ', '1', '1', 'ª', '\r', '\n', '\x1A', '\n'
  // 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A
  const identifier = new Uint8Array(this.arrayBuffer, this.baseOffset, 12);
  if (identifier[0] !== 0xAB
      || identifier[1] !== 0x4B
      || identifier[2] !== 0x54
      || identifier[3] !== 0x58
      || identifier[4] !== 0x20
      || identifier[5] !== 0x31
      || identifier[6] !== 0x31
      || identifier[7] !== 0xBB
      || identifier[8] !== 0x0D
      || identifier[9] !== 0x0A
      || identifier[10] !== 0x1A
      || identifier[11] !== 0x0A) {
      return;
  }

  // load the reset of the header in native 32 bit uint
  const dataSize = Uint32Array.BYTES_PER_ELEMENT;
  const headerDataView = new DataView(this.arrayBuffer, this.baseOffset + 12, 13 * dataSize);
  const endianness = headerDataView.getUint32(0, true);
  const littleEndian = endianness === 0x04030201;

  this.glType = headerDataView.getUint32(1 * dataSize, littleEndian); // must be 0 for compressed textures
  this.glTypeSize = headerDataView.getUint32(2 * dataSize, littleEndian); // must be 1 for compressed textures
  this.glFormat = headerDataView.getUint32(3 * dataSize, littleEndian); // must be 0 for compressed textures
  this.glInternalFormat = headerDataView.getUint32(4 * dataSize, littleEndian); // the value of arg passed to gl.compressedTexImage2D(,,x,,,,)
  this.glBaseInternalFormat = headerDataView.getUint32(5 * dataSize, littleEndian); // specify GL_RGB, GL_RGBA, GL_ALPHA, etc (un-compressed only)
  this.pixelWidth = headerDataView.getUint32(6 * dataSize, littleEndian); // level 0 value of arg passed to gl.compressedTexImage2D(,,,x,,,)
  this.pixelHeight = headerDataView.getUint32(7 * dataSize, littleEndian); // level 0 value of arg passed to gl.compressedTexImage2D(,,,,x,,)
  this.pixelDepth = headerDataView.getUint32(8 * dataSize, littleEndian); // level 0 value of arg passed to gl.compressedTexImage3D(,,,,,x,,)
  this.numberOfArrayElements = headerDataView.getUint32(9 * dataSize, littleEndian); // used for texture arrays
  this.numberOfFaces = headerDataView.getUint32(10 * dataSize, littleEndian); // used for cubemap textures, should either be 1 or 6
  this.numberOfMipmapLevels = headerDataView.getUint32(11 * dataSize, littleEndian); // number of levels; disregard possibility of 0 for compressed textures
  this.bytesOfKeyValueData = headerDataView.getUint32(12 * dataSize, littleEndian); // the amount of space after the header for meta-data

  ...
}

使用压缩纹理(WebGL)

在 WebGL 上使用纹理压缩主要有如下步骤:

  1. 下载纹理压缩素材;
  2. 解析ktx文件;
  3. 判断设备支持的纹理压缩格式;
  4. 通过getExtension获取纹理压缩扩展;
  5. 上传纹理压缩数据到GPU;

其中上传纹理主要指compressedTexImage2DcompressedTexImage3D两个API,其入参均可以从KTX文件中拿

var ext = gl.getExtension('WEBGL_compressed_texture_etc');

var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

gl.compressedTexImage2D(gl.TEXTURE_2D, 0, ext.COMPRESSED_RGBA8_ETC2_EAC, 512, 512, 0, textureData);

兼容性情况

  1. Android平台: Android平台由于机型、厂商众多,纹理压缩的支持情况较为复杂;其中ETC1支持的最为广泛,但是由于ETC1不支持Alpha通道,导致其使用场景有限,ETC2覆盖度也挺高但是需要启用OpenGL es 3.x;据google play统计,Android中高端机型对ASTC的支持度覆盖度有77%以上(具体到GPU型号上,高通骁龙415及以上(2015),ARM Mali T624(2012)及以上,NVIDIA Tegra k1(2014)及以上)
  2. iOS平台: iOS平台PVRTC格式支持最广泛,苹果也推荐使用此格式;在2013 A7芯片发布后,开始支持(ETC/ETC2)格式,2014 A8芯片及以上,开始支持ASTC格式;

综上,Android平台选用ETC + ASTC,iOS平台高版本使用ASTC、低版本PVRTC兜底即可覆盖所有设备。开发者运行时可以通过API glgetString(GL_EXTENSIONS)获取当前设备支持的压缩纹理格式,WebGL通过getSupportedExtensions()API获得相同信息。

纹理压缩性能表现

素材大小1024x1024:

结论:

  1. 相比jpeg等图片格式,纹理压缩通常体积会更大,这会导致IO时间变长;
  2. 不同格式的纹理压缩体积也不一样,压缩率高体积虽然降下来,但是素材质量会降低,使用时需要权衡;
  3. 纹理压缩格式GZip压缩效果不明显;

下载时间 & 内存

测试机型: pixel4、iPhone11ProMax

游戏引擎: pixi.js

压缩纹理在小程序实际场景中的性能表现

批量加载JPEG纹理内存增长情况

批量加载ASTC纹理内存增长情况

结论:纹理压缩格式相比普通纹理内存优势巨大,可以减少50%以上内存占用,但与此同时,素材下载时间会延长;

纹理上传GPU时间

不同纹理格式GPU上传时间

结论:纹理压缩格式GPU上传时间几乎可以忽略不记,相比普通纹理具有巨大的优势,也可以抵消一部分压缩纹理下载的耗时;

小程序Canvas纹理压缩实现方案

小程序下,我们是基于 OpenGL ES API 封装 WebGL API,纹理压缩也不例外,由于WebGL扩展中支持的纹理压缩格式在OpenGL ES中都有对应实现,比如 WEBGL_compressed_texture_astc扩展对应到GL的扩展名为 GL_KHR_texture_compression_astc_ldr等,因此只需要根据扩展名称映射到OpenGLES实现即可,比较简单,这里不再展开。

总结

纹理压缩在现代计算机图形中占据重要定位,现如今主流移动设备GPU都已支持纹理压缩,在实际场景中可以充分利用此能力优化游戏应用以带来更好的用户体验。

参考

  1. http://sv-journal.org/2014-1/06/en/index.php?lang=en#8
  2. https://developer.android.com/guide/playcore/asset-delivery/texture-compression
  3. https://docs.unity3d.com/es/2019.4/Manual/class-TextureImporterOverride.html
  4. https://blog.imaginationtech.com/pvrtc-the-most-efficient-texture-compression-standard-for-the-mobile-graphics-world/
  5. https://cesium.com/blog/2017/02/06/texture-compression/