上节回顾

上一节学习了如何从一张原始图片中,获取生成纹理所需要的信息,然后根据这些信息,通过OpenGL ES API在GPU内存中生成了一张纹理,并且还介绍了纹理属性,知道了如何通过纹理坐标将纹理映射到绘制buffer上。在了解了纹理重要性的同时,我们还知道了在应用程序中,纹理的大小影响着应用程序包的大小,也占据了应用程序的大部分内存,在游戏开发后期,对纹理进行各个层面的优化是一项非常重要的工作。所以,下面我们要对纹理进行优化。本节课,将重点讲述各种压缩纹理格式的处理办法,如何在内存中管理纹理,以及总结了一些使用纹理的最佳方式。


压缩纹理

通过上节课的学习,我们知道了纹理其实也就是一块buffer,这块buffer储存了纹理的宽乘以高,那么多个像素点的信息,比如宽高为100*100的纹理,那么这块buffer就保存了这么多像素点的信息,然而每个像素点的信息所占的空间,是由纹理格式决定的,比如format为GL_RGBA,type为GL_UNSIGNED_BYTE的纹理,它在CPU中每个像素点需要占用4个byte的空间,然而如果format为GL_RGB,type同样是GL_UNSIGNED_BYTE的纹理,在CPU中每个像素点需要占用3个byte的空间,同样的,把这两张图片的信息传入GPU中生成纹理之后,在GPU中也会是第一张纹理占用的空间比较大。所以纹理占用空间的大小,跟纹理的尺寸大小,以及纹理格式都有关系。格式相同的情况下,纹理尺寸越大,占用的空间越大。而相同纹理尺寸的情况下,有的格式占用空间大,有的格式占用空间小。当然,占用空间大的纹理能表达的信息更多一些,比如GL_RGBA就比GL_RGB多了一个alpha通道。但是如果两种格式都能表达足够的信息,那么我们会尽量选择占用空间小的格式来节省内存空间。而之前我们说到的纹理格式都是普通格式,在这节课,我们要介绍一种新的纹理格式,就是压缩纹理格式,可想而知,这种纹理格式会把纹理信息进行压缩,压缩的时候虽然会根据压缩比损失一定的纹理信息,导致纹理精度变低,但是如果对纹理图片精度要求不是特别高的话,使用压缩纹理可以节省大量的内存空间。

纹理非常占用空间,进而导致会有三方面影响:1.如果纹理比较多的话,而且没有做任何处理的话,那么游戏的包会比较大,用户安装起来会比较麻烦,这样不太好;2.纹理比较大,而纹理需要从客户端CPU传给GPU,这样会比较占带宽,而且传输数据也是非常耗电的。3.在CPU的时候占主内存,而传给GPU之后,也会占用GPU内存。目前手机虽然发展比较快,但是依然有一些1-2G内存的手机在市面上,如果占用过大内存,可能会导致游戏闪退等不好的用户体验。

所以,针对这个又耗电,又影响用户体验,又占用内存,却又不得不使用的纹理,有几种优化方式,第一个优化方式就是压缩纹理。下面,我们将介绍压缩纹理的概念、特点、使用方法。

我们知道,传统的压缩方案,比如JPG、PNG图片本身都是压缩图片,都已经做了一定的压缩,但是这些格式的图片,能够减小资源的大小,减小包体大小,但在把信息传给GPU的时候,需要进行解包,在GPU中占用一块内存用于存储纹理,所以这些压缩图片和普通图片传到GPU后,所占用的GPU内存是一样的。具体步骤就是:当一个图形数据在被传输到GPU内存时,需要通过glTexImage2D这个API传到GPU,而这个API只认识GL_RGBA等格式,所以需要先将这些压缩图片解压缩到GL_RGBA或者GL_RGB等未压缩的格式,才能传入GPU。所以这些压缩图片意义不是很大,最多也只是让游戏包变小。而且虽然让游戏包变小了,但是还增加了一个解压缩的过程,传输到GPU的时候,还是按照正常格式传输。1996年,斯坦福的三位教授发表了一篇论文叫做《基于已压缩纹理的渲染》,提出了基于GPU的纹理压缩方法,该方法使GPU可以直接从压缩纹理中采样并进行渲染,这也就使压缩纹理有了可能。这种压缩纹理,在CPU端需要使用特殊的原图片,也就是比如PVR或者ETC格式的图片,这些格式的图片保存着生成压缩纹理的信息,这些文件的大小要小于JPG、PNG等图片,而且在传输给GPU的时候,不需要通过glTexImage2D这个API,而是通过压缩纹理的专用API glCompressedTexImage2D,通过这种API使得图片信息不需要经过解压缩,直接可以传给GPU,然后把信息保持压缩状态保存在GPU中,这种方法不仅能减小资源的大小,还可以节省CPU往GPU传输数据的带宽,以及减少GPU的内存占用。下面我以PVR格式来说明压缩纹理,PVR格式是imagenation公司定义并提供授权的一种纹理压缩格式,是目前在游戏开发领域使用比较广泛的一种压缩格式,imagenation公司生成的GPU叫做powervr GPU,完全支持pvr格式,powervr这个GPU是苹果的御用GPU,所以苹果的设备,比如iphone等,基本都是支持PVR这种压缩纹理的。pvr格式的图片以pvr作为后缀名。压缩纹理有如下几点好处:

  • pvr图片原本就比jpg、png图片小,这样游戏包体会比较小。
  • OpenGL ES API glCompressedTexImage2D识别PVR的格式,所以不需要解压缩,就可以直接把pvr图片的数据传入GPU,再加上原本PVR格式的数据就少,所以这样需要传输的数据也就很少,比较省带宽。
  • 传入GPU之后,也不需要解压缩,所以也比较省内存。

压缩纹理相比较于普通纹理以及我们已知的压缩技术,需要有具备如下4个特点:

  • 虽然刚传入GPU的时候不用解压缩,但是使用的时候还是需要解压缩的,为了不影响渲染系统的性能,压缩纹理需要解压比较快,这样才能保证使用它的时候能很快解压出来。而我们平时所使用的压缩技术,主要针对存储或者文件传输进行设计的,不具备较快的解压速度。
  • 支持精确读取,根据上节课的内容,我们知道,我们会根据纹理坐标去读取纹理上的某些像素点的信息,而纹理中的任何位置都可能会被使用,所以我们需要先能快速精确定位到纹理中任何位置的纹素,然后把这些像素点解压出来才能使用,所以要求压缩纹理的技术必须能够被快速精确的定位到。传统的压缩技术为了保证最大的压缩比例,一般都是使用可变的压缩比例,这样的话,如果想读取某个像素的信息很难做到快速精准定位,往往需要解压很大一部分相关的像素信息才可以读取到某个像素的信息。而压缩纹理技术,则一般采用固定比例压缩,固定比例只要知道偏移,压缩比例系数,访问纹素的时候,可以根据索引块迅速定位到某一块,然后获取所需要的纹素信息。比如原本10000个数据压缩成100个数据,那么如果像读取中间那个点,它的位置就从第5000个变成了第50个,这样根据这个比例,就能精确定位到所需要的像素点。PVR等格式都是固定比例压缩的,而JPG、PNG等格式都是非固定比例压缩的。由于压缩纹理的精准读取特性与压缩纹理的实现机制有关,在这里我们展开说明一下压缩纹理是如何被实现的。压缩纹理是按照一个固定的压缩比例进行压缩的,压缩算法会按照这个比例先将纹理分成多个像素块,比如刚才说到的100*100的纹理,也就是有10000个点,这10000个点会被分成块,而我们假设压缩比例为4,也就是4个点为一个块,那么这个图片也就被分为了2500块,每个块即包含了这4个点的信息,然后以块为单位来进行压缩,将这4个点的信息进行压缩,每个被压缩的像素信息存储在一个像素集合中,这样就获得了2500个压缩后的像素集合,然后对这2500个像素集合制作一个块索引图,用于存储每个像素块的索引位置,这样可以方便的找到对应的块文件。然后将压缩纹理传入GPU,等到要使用的时候,可以根据纹理坐标,确定要去纹理中的哪个点,然后计算出这个点属于哪个块,以及在这个块中的偏移量,然后根据块索引图找到这一个像素块,并对这一块进行解压缩,这里的解压缩只需要将这一小块解压缩即可,所以不会占用很多的内存,然后再根据偏移量从解压缩之后的内容中读取到我们需要的纹理值。这样的话就实现了精确读取,而这种压缩算法,被称为基于块的压缩算法,它是以块进行压缩和解压缩的。在实际操作中,对一个像素块的数据,还可以根据实际情况进行缓存。这种快速的解压使得图形渲染管线可以不在CPU中对图片进行解压,而是将压缩纹理直接保存在GPU内存中,这样既减少了资源对磁盘的占用,也减少了纹理在传输过程中所占用的带宽,并且还大大节省了对GPU内存的占用。这种基于块的压缩算法在压缩纹理中比较常见,然而还有一些其他的压缩纹理格式,比如第一代的PVR格式PVRTC就使用了不同的纹理压缩和读取算法,在这里就不继续展开说了。
  • 传统的图像压缩技术,大多要考虑图像的质量。而对于压缩纹理的技术,每个纹理只是场景中的一部分,整体场景渲染质量的重要性高于单个图片,压缩纹理不用太在意压缩质量,首要是先保证游戏性能,所以压缩纹理通常使用有损压缩。
  • 不用太在意编码速度,因为压缩纹理的压缩过程是发生在应用成之外,在线下进行的,并非在用户玩游戏的时候进行的,所以不需要较高的编码速度。Imagination公司提供了一个叫做PVRTextureTool的工具,供开发者在线下进行PVR图片的生成,确实比较慢,但是由于不会影响用户体验,所以压缩速度无所谓。使用压缩纹理的核心制约因素是解压速度。

满足上述特点的纹理压缩方法不仅可以减小原图片资源的大小,减少了应用程序客户端向GPU传输纹理数据的带宽,减少了移动设备对电量的消耗,还大大减少了对GPU内存的占用,并能够配合GPU进行高效渲染。另外,在使用方式上,在OpenGL ES中,压缩纹理与其他纹理的使用方式基本一样,同样的通过纹理坐标进行采样,支持多级纹理,应用程序除了通过特殊的API glCompressedTexImage2D传输纹理之外,其他方面和普通纹理几乎没有区别了。说到多级纹理,其实压缩纹理比普通纹理更高级一些,我们上节课也说过,因为普通图片比如JPG图片一般不包含多级纹理信息,而压缩纹理格式比如PVR图片是可以直接通过PrvTexTool之类的工具将多级纹理包含在一个图像文件中,这样直接通过一个文件就可以给一个纹理的多级进行直接赋值了,而普通图片可能需要多张才能对一个纹理的多级进行赋值。而通过赋值的方式生成多级纹理,虽然占用了更多的存储空间,但是不需要再在使用的时候去生成了。

以上就是压缩纹理的基本概念。生成纹理、设置纹理属性等代码不区分压缩纹理还是非压缩纹理,唯一不同的就是压缩纹理使用的是glCompressedTexImage2D,而非压缩纹理使用的是glTexImage2D。下面我们来详细介绍一下glCompressedTexImage2D。

void glCompressedTexImage2D(GLenum target, GLint level, GLenum internalformat, GLsizei width, GLsizei height, GLint border, GLsizei imageSize, const GLvoid * data);

glCompressedTexImage2D的功能和glTexImage2D一样,都是把准备好的数据,从CPU端传递给GPU端,保存在指定的texture object中。唯一的区别就是glTexImage2D传输的是用于生成普通纹理的数据,在GPU中生成普通纹理,而glCompressedTexImage2D传输的是用于生成压缩纹理的数据,在GPU中生成压缩纹理。

这个函数的输入参数与glTexImage2D的输入参数差别不大。第一个输入参数的意思是指定texture object的类型,可以是GL_TEXTURE_2D,又或者是cubemap texture6面中的其中一面,通过GL_TEXTURE_CUBE_MAP_POSITIVE_X, GL_TEXTURE_CUBE_MAP_NEGATIVE_X, GL_TEXTURE_CUBE_MAP_POSITIVE_Y, GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, GL_TEXTURE_CUBE_MAP_POSITIVE_Z, or GL_TEXTURE_CUBE_MAP_NEGATIVE_Z来指定,我们说了cubemap的texture其实也就是由6个2D texture组成的,所以这个函数实际上也就是用于给一张2D texture赋值。如果传入其他的参数,就会报INVALID_ENUM的错误。第二个是指给该texture的第几层赋值。多级纹理的概念上节课我们已经说过了,这里的level也就是指定给纹理的第几层进行赋值,第0层为base level,刚才说了,一张PVR图片可以包含多级纹理所需要的数据,也就是从一张PVR原始图片读出的数据,可以通过这个API给一张纹理的若干级进行赋值。如果level小于0,则会出现GL_INVALID_VALUE的错误。而且level也不能太大,如果level超过了log2(max),则会出现GL_INVALID_VALUE的错误。这里的max,当target为GL_TEXTURE_2D的时候,指的是GL_MAX_TEXTURE_SIZE,而当target为其他情况的时候,指的是GL_MAX_CUBE_MAP_TEXTURE_SIZE。第三个参数internalformat,就是指定原始数据传入GPU之后,在GPU中的格式。可以通过glGet这个API,传入参数 GL_NUM_COMPRESSED_TEXTURE_FORMATS,可以查询到当前设备支持压缩纹理格式的个数,传入参数 GL_COMPRESSED_TEXTURE_FORMATS,可以查询到当前设备支持哪些压缩纹理格式,如果传入了当前设备不支持的internalformat,则会出现 GL_INVALID_ENUM的错误。第四个参数width和第五个参数height,就是原始图片的宽和高,也是新生成纹理的宽和高,因为两者是一样的。图片信息以数据的形式从CPU传到GPU,可能每个像素点格式和包含的信息会发生变化,但是图片的大小,也就是像素点的数量,每行多少个像素点,一共多少行,这个信息是不会发生变化的。width和height不能小于0,也不能当target为GL_TEXTURE_2D的时候,超过GL_MAX_TEXTURE_SIZE,或者当target为其他情况的时候,超过GL_MAX_CUBE_MAP_TEXTURE_SIZE否则,就会出现GL_INVALID_VALUE的错误。第6个参数border,代表着纹理是否有边线,在这里必须写成0,也就是没有边线,如果写成其他值,则会出现GL_INVALID_VALUE的错误。第七个参数和最后一个输入参数的意思是:data是CPU中一块指向保存实际数据的内存,而imagesize指定这块内存中从data位置开始,保存压缩纹理信息的大小,单位是unsigned byte。如果data不为null,那么将会有imagesize个unsigned byte的data从CPU端的data location开始读取,然后会被从CPU端传输并且更新格式保存到GPU端的texture object中。如果image size与压缩纹理的格式、宽高、data中实际保存的数据不匹配,则会出现GL_INVALID_VALUE的错误。这里要注意,因为pvr图片有可能一张图片中包含多级纹理的信息,那么每生成一级纹理的信息,就需要调用一次该函数,每次调用的时候第二个参数level,最后两个参数,size和数据位置都会相应做出变化。

这个函数没有输出参数,但是有以下几种情况会出错,除了刚才说的那些参数输入错误之外,还有虽然OpenGL ES main spec没有对压缩纹理格式进行规定,但是OpenGL ES有一些extension对这些压缩纹理的使用进行了限制,比如有些压缩纹理格式要求纹理的宽高必须是4的倍数(pvr),如果参数组合没有按照extension中的规定,则会出现GL_INVALID_OPERATION的错误。而如果data中保存的数据没有按照extension的规定保存正确的数据,那么会出现比如undefine的结果,或者是程序终止的情况。

void glCompressedTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const GLvoid * data);

这个API的功能和刚才的glCompressedTexImage2D类似,顾名思义,刚才那个API是给texture object传入数据,这个glCompressedTexSubImage2D是给texture object的一部分传入数据。这个命令不会改变texture object的internalformat、width、height、参数以及指定部分之外的内容。

这个函数的第一个和第二个输入参数和glCompressedTexImage2D的一样,用于指定texture object的类型,以及该给texture的第几层mipmap赋值。错误的情况也与glCompressedTexImage2D一样,target传入不支持的值,则会出现GL_INVALID_ENUM的错误,level传入了错误的数字,则会出现GL_INVALID_VALUE的错误。 第三个、第四个、第五个和第六个输入参数的意思是:以texture object的开始为起点,宽度进行xoffset个位置的偏移,高度进行yoffset个位置的偏移,从这个位置开始,宽度为width个单位高度为height的这么一块空间,使用data指向的一块CPU中的内存数据,这块内存数据的format为第七个参数,大小为第八个参数imageSize,对这块空间进行覆盖。这里的format和glCompressedTexImage2D的第三个参数internalformat是一个意思,如果传入了当前设备不支持的format,则会出现 GL_INVALID_ENUM的错误。如果image size与压缩纹理的格式、宽高、data中实际保存的数据不匹配,则会出现GL_INVALID_VALUE的错误。如果xoffset、yoffset、width、height其中有一个为负,或者xoffset+width大于texture的宽,或者yoffset+height大于texture的高,那么就会出现INVALID_VALUE的错误。如果width和height都为0也没关系,只是这样的话这个命令就没有效果了。

这个函数没有输出参数。除了刚才那些因为参数问题导致的错误,还有,如果target指定的这个texture还没有被glCompressedTexImage2D,以对应于format的压缩纹理格式internalformat分配好空间。则会出现GL_INVALID_OPERATION的错误。如果参数组合没有按照压缩纹理格式format对应的extension中的规定,比如部分压缩格式不允许只替换部分内容,那么通过这个API,只能传入xoffset=yoffset=0,以及width和height为纹理的实际宽高,否则会出现GL_INVALID_OPERATION的错误。而如果data中保存的数据没有按照extension中的规定保存正确的数据,那么会出现比如undefine的结果,或者是程序终止的情况。

下面我们来介绍一下压缩纹理的格式,刚才我们都是以pvr这种压缩纹理格式来进行串讲的,那么其实还是有很多种压缩纹理格式的,不同的GPU支持不同的压缩纹理格式。比如各种格式的PVR和ETC,所以使用压缩纹理格式之前,还是需要确认一下当前设备是否支持。在OpenGL ES2.0中,虽然定义了glCompressedTexImage2D这个API供应用程序上传压缩纹理,但是OpenGL ES2.0并没有定义任何纹理压缩格式,所以压缩纹理的格式通常由图形硬件厂商或者一些第三方组织定义和实现的,而且每个GPU支持的压缩格式format基本都是不同的,比如刚才说的,苹果公司使用的是powerVR的GPU,支持的比较好的是PVR,iOS全系列设备都支持PVR的压缩纹理。android手机手机是按照khronos的标准,基本大部分android设备都支持ETC格式。khronos我们之前介绍过,是制定OpenGL ES等等一系列Spec的权威组织。这些都是大家默认的,但是其他一些压缩格式则需要查询具体的硬件支持信息,所以如果想知道GPU具体支持哪些压缩格式,需要使用API glGetString传入GL_EXTENSIONS,这样获取到设备支持的所有的extension,然后在这些extension列表中查询到支持哪些压缩纹理格式对应的extension,从而获悉该设备支持哪些压缩纹理格式。所以在写游戏的时候用到了压缩纹理,最好还是通过这些函数来确认下支持哪些压缩纹理格式。说一些后话,其实在OpenGL ES3.0中,提供了一个压缩纹理的标准,也就是glCompressedTexImage2D必须支持某些格式,那么当使用这些格式的压缩纹理的时候,就不需要check了。

下面我来介绍一下PVR和ETC这两种压缩格式。

首先是PVR,PVR分为PVRTC和PVRTC2这两种压缩格式,PVRTC2相对比与PVRTC作出的升级有:1.pvrTC2开始支持NPOT的纹理,2.增强了图像质量,尤其表现在一些高对比度,大面积颜色不连续的部分,或者纹理的边沿,3.更好的支持alpha预乘,alpha预乘是一种优化算法,我们会在之后的课程再介绍alpha预乘。

PVRTC和PVRTC2的都支持2bpp和4bpp这两种压缩比例,也就是说一个像素点的信息,可以压缩到2位或者4位,我们知道未压缩的一个像素点的信息可能有16位或者32位,所以压缩比例是相当高的,PVR支持alpha通道。alpha通道还是很有用的,上节课我们说到的纹理格式中很多格式都是带alpha的,比如GL_RGBA。虽然PVR支持alpha,但是如果用不到alpha通道,尽量还是不要用。举个例子,一个RGBA32的图片,每个像素占32位,4byte,压缩成pvr 4bpp,而一个RGB24的图片,每个像素占24位,压缩成pvr 4bpp。压缩后的大小一样,那么RGBA32图片的损耗率一定比RGB24图片的损耗率高,才能达到压缩后同样大小的效果,也就是说RGB24压缩后质量更好一些。

Imagination公司提供了一个叫做PVRTextureTool的工具用于处理PVR格式的图片,通过这个工具可以创建cubemap、字体纹理、生成多级纹理等,但是最主要还是用于生成pvr格式的压缩纹理。PVRTextureTool除了支持pvr格式,还支持png、etc等格式的转换。常用的转换工具还有图形学的texturepacker,以及iOS系统中Xcode自带的texturetool等。

然后再说下ETC,ETC格式是爱立信公司于2005年提出的压缩格式,ETC和PVR都是有损压缩纹理格式,支持4bpp的压缩比例,不支持alpha通道。从这里看出ETC还是不如PVR的。而由于ETC不支持alpha通道,但是很多纹理是需要alpha信息的,那么有如下两种解决办法:1.比如原本是GL_RGBA的图片图片,制作成ETC的时候,首先,先把RGB三个通道的信息压缩成一张ETC图片,然后把这张ETC图片的高度增加一倍,多出来的这一块空间宽和高就和原图片宽高一致,这样,把原图片的alpha值压缩到这块空间中,做成一张灰度图即可。最终结果就是得到一张ETC的图片,但是这张图片的高度是原来两倍,下面的一半是原图片的RGB信息,上面的一半是原图片的alpha信息转成的灰度图。这样应用程序中需要在PS中对纹理多做一次采样,每个点的RGB采样一次,A采样一次。但是这种解决方案是有限制的,因为它增加了纹理的尺寸,而纹理的尺寸在GPU中是有限制的,宽高不能超过GL_MAX_TEXTURE_SIZE,是有最高高度的,一般这个最高限制是2048。那么假如原图片高度超过1024,那么扩大一倍,就超过2048了,就超出限制了,就会报错了。所以使用这种解决方案,原图片的高度需要在GL_MAX_TEXTURE_SIZE的一半以下。2.比如还是原本是GL_RGBA的图片图片,制作成ETC的时候,制作两张ETC图片,一张用于保存RGB信息,另外一张用于保存alpha信息,这样就生成了两张压缩纹理,而这两张压缩纹理使用多重纹理的方案,把它们同时传入同一个shader,然后再在这个shader就可以使用到原图片的RGBA所有信息了。多重纹理上节课说过了,这里就不再进行解释说明了。Arm公司提供的工具Mali texture compression tool可以很好的处理这两种解决方案。

ETC2现在已经支持alpha通道了,但是RGBA压缩成ETC2后为8bpp,RGB压缩成ETC2为4bpp,RGB压缩成ETC1也是4bpp,所以还是很占空间,不过ETC2的压缩损耗率已经比ETC1好了,虽然还是不如PVR。Unity中提供一种压缩格式叫做etc2 RGB + 1bit alpha 4bpp,也就是如果alpha只有1bit,那么可以压缩成这种4bpp的etc2格式,这样内存终于省下来了。

其他压缩纹理格式的使用方式与这两种格式相似,这样压缩纹理部分就说完了。尽管OpenGL ES3.0提供了压缩纹理标准,使各个平台都可以使用同一种压缩纹格式,但是目前市面上的设备还需要很长时间才能完全普及OpenGL ES3.0。因此我们仍然需要对不同设备使用不同的压缩纹理格式。通过压缩纹理, 游戏包变小了,传输纹理的时候带宽也节省了,在GPU中的内存占用量也变小了。这是纹理优化的第一步,下面,我们我们将来说一下纹理优化的第二步,纹理缓存,texture cache。

纹理缓存

OpenGL ES 2.0知识点中关于纹理优化的API只有压缩纹理,我们可以通过选择适当的图片格式,或者使用压缩纹理来减少纹理对内存的占用,但是由于纹理优化太重要了,所以在这里说另外几种纹理优化的办法。压缩纹理只是针对单一纹理的,而游戏中需要使用到大量的纹理,所以需要有个机制对纹理进行管理,去管理纹理的加载,使用和销毁,尽量使同一张纹理不被重复加载,也尽量把不用的纹理删除,这样可以节约内存。那么下面,我们将介绍一下纹理缓存机制,去管理场景中每个纹理的生命周期。

纹理缓存的目的是:虽然通过压缩纹理,可以对一张纹理进行了优化,但是在游戏中需要使用大量的纹理,而且很多纹理会被使用不止一次,假如每次使用纹理的时候都要对纹理进行重新上传,比如游戏植物大战僵尸,里面有10个相同向日葵,每个向日葵都使用相同纹理,那么假如每次画向日葵我都要重新传上传一次纹理,那么肯定是不好的,最好的办法就是我只上传一个texture,可以提供给这10个向日葵使用。而且如果是在画向日葵的时候才调用glTexImage2D或者glCompressedTexImage2D去上传纹理,那么也是不太好的,因为这两个API都是需要耗费一定时间的,可能会导致游戏变卡。另外如果下一帧需要使用某个纹理,但是由于内存不够,在上一帧刚把该纹理从内存中删除掉,那么也是不好的。换句话来表达,就是:纹理缓存系统的主要目标是使当前场景需要显示的纹理驻留在内存中,而开发者的职责则是定义哪些是当前场景需要使用的资源,我们始终应该在进入一个场景时预加载相关纹理,这是一个耗时的过程,而这个过程是在主线程中完成的,不适合在游戏进行的过程中读取和加载,所以要避免动态加载纹理。另外,在纹理的使用期间,它应该只被创建1次。 所以解决方案是需要实现三个功能:

  • 在进入场景的时候就把这个场景中要用到的所有的texture load好。
  • 所有的向日葵使用同一个纹理。
  • 当这个场景不再需要向日葵也不会在绘制新的向日葵的时候,把向日葵的纹理删掉,以节省内存。这些解决方案就是通过texture cache实现的。

我们先来看一下纹理的生命周期,texture2D在被创建时就会从磁盘中加载数据并上传至GPU内存中,然后在Texture2D没有被销毁之前,GPU会一直缓存这个纹理对象,可以通过glDeleteTexture销毁这个texture2D对象。如果直接通过控制texture2D对象,来管理纹理的话,我们需要小心的处理texture2D的生命周期,必须在纹理不再被使用之后删除,而且我们还要注意有可能纹理只是暂时不被使用,如果不小心删除了,当下次使用的时候还需要重新创建。所以综上所述,通过这种方式来管理非常复杂,所以我们一般不会直接去创建texture2D对象,而是通过texturecache来创建和销毁texture2D对象。texturecache提供了对Texture2D对象更好的管理方式。

texture cache是单例,通过这个texture cache去创建纹理的话,会先查看一下原本纹理缓存中是否有存放这个原始图片对应的纹理,如果有的话则直接拿出来使用,如果没有的话,就创建一个纹理,保存在纹理缓存中,供这次以及以后如果需要的时候使用。每次创建的时候,把texture引用计数加1。所以如果创建了N个使用相同纹理,那么纹理引用计数则为N+1,而即使当切换场景的时候,由于纹理缓存是单例,不会把纹理release的,所以虽然切换场景的时候会把所有的纹理清除,那么纹理的引用计数还为N + 1 - N = 1。所以,无论如何切换场景,加入纹理缓存的纹理将一直存在着,这样也就实现了刚才我们说到的纹理缓存的第二个功能,无论绘制多少个向日葵,哪怕是不同场景中的向日葵,都使用同一张纹理。

我们刚才说到的第三个功能,当确定不再使用该纹理之后,希望能把该纹理删除。但是根据刚才我们对引用计数的计算,只要将纹理加入texture cache,那么除非texture cache没了,不然纹理引用计数至少为1,也就是通过系统是无法自动删除这个texture了。但是没关系,texture cache可以提供了一些remove纹理的函数,比如removeUnusedTextures函数,通过刚才对引用计数的分析,我们也就知道了假如纹理没有被使用,那么引用计数就是1,而removeUnusedTextures这个函数,就是将所有不在被使用的,引用计数为1的纹理进行release。但是这个函数有一点不好,比如当前场景中没有向日葵也没有豌豆射手了,我就把向日葵和豌豆射手的纹理全部清除了,但是我一会可能还要创建豌豆射手,那样我又要重新创建纹理了。所以texture cache需要有直接removeTexture和removeTextureForKey的函数,这样就直接只把向日葵的纹理删除掉,因为开发者知道这个时候向日葵的可以删除了,但是豌豆射手的还有用,所以先把向日葵的纹理单独删除掉。但是有可能这个时候场景还有向日葵,纹理的引用计数不为1,那么通过这个函数,就将其引用计数减一,比如场景还有2个向日葵,那么引用计数就从3变成2,当场景中这两个向日葵也销毁后,引用计数就变成0,该纹理将被销毁。另外还需要removeAlltexture,就是假如游戏快结束了,之后再也不会使用这些纹理了,那么对所有的纹理进行release,纹理缓存不再保存和关心这些纹理的信息了,等场景结束的时候,这些纹理将被彻底销毁了。所以texture cache是通过这些函数,可以让开发者根据游戏的逻辑,进行纹理的删除。

这样,就还剩下第一个功能没有解决掉,就是在游戏刚开始的时候就把所有要使用到的texture都load进来,这个是需要开发者自己来控制的,但是当然texture cache也应该提供了一些相关的函数来实现。举个例子,比如我在第一个场景中需要用到300个texture,那么根据这个功能要求,我需要在游戏一开始就预load 300个texture,但是假如一次性预load这么多texture是会非常卡的,如果全部放在第一帧去处理,那么第一帧估计要等很久。然而texture cache需要有函数addImageAsync,这个函数会把300个texture分为300帧,每一帧去预load一个texture。这样分摊下去,就不会很卡了。这个是texture cache为预load提供的方法。

而这样还不够,因为虽然它实现了预load,实现了多个使用相同纹理的sprite使用同一张纹理,但是什么时候将纹理从纹理缓存中删除,实际还需要开发者自己来控制,刚才我们只是简单的说了一句,开发者需要根据游戏逻辑来对纹理进行删除,但是如果全部纹理都需要开发者来控制逻辑会有些复杂。其实在这里我们也会介绍一些机制来帮助开发者来控制。

我们先来看一下在实际的游戏中对资源管理的需求。通常在游戏循环中只应做一些逻辑计算,以更新各种游戏对象的状态。为了不影响游戏循环,我们应该在进入场景时,或者其他一些异步时间,预加载所有需要的资源文件,将它们缓存起来,并在适当的时候删除缓存以减少对内存的占用。然而每个资源对生命周期有不同需求,举例如下:

  • 有些资源在游戏开始时就需要载入,并且驻留在内存中,直至游戏结束,比如每个场景通用的一些按钮等元素。
  • 有些资源的生命周期对应于特定的场景,如某个boss只在某个关卡出现,属于这个关卡特需的资源,别的关卡并不需要。
  • 还有一些资源很难定义生命周期,比如跑酷游戏中的资源和玩家跑动的距离有关,这时则需要小心的进行动态预加载。

我们并不能简单通过texturecache去完美解决这个纹理,这个时候需要开发者要思考一下,本张场景到什么阶段,哪些纹理可以被删除了,以及切换场景的时候,还要判断一下,哪些纹理在下个场景还会被用到。所以有这么一种纹理管理机制,可以供开发者使用,用于管理当一个纹理在连续两个场景都会用到的情况。比如第一个场景用到了纹理123,而第二个场景用到了纹理234,那么假如在切换场景的时候,把纹理123通过removeAlltexture全部删掉,那么在进入场景2,的时候,234又要被load,所以其中23这两个纹理刚被删掉,又要被load进来,就会造成一种浪费。那么可以这样,利用引用计数,在切换场景的时候,先不执行removeAlltexture函数,这样,当当前场景结束的时候,这个场景的剩余纹理的引用计数均为1,然后进入下一个场景,将下一个场景需要用到的纹理引用计数加一,那么根据刚才的例子,纹理1的引用计数继续保持为1,纹理23的引用计数变成2,纹理4的引用计数为1,然后再把刚才那个场景用到的纹理引用计数减一,这样纹理1的引用计数变成0,纹理23的引用计数变成1,纹理4的引用继续保持为1,这个时候删除引用计数为0的纹理,就把纹理1删除掉了,纹理23留在了场景2,没有被删掉。然后对引用计数不为0的进行加载,而纹理23由于没有被删掉,所以不会再次被加载了,只需要加载纹理4即可。这样,切换了两个场景,一共只需要load 4张纹理,其中纹理23只load了一次,而之前一共需要load 6张,纹理23需要load两次,这种解决方案会比较省资源一些。这样,我们便能够灵活的处理资源中在多个场景之间的共享,并有效的在每个场景仅保留需要的资源。对于每个场景或者关卡,我们只需要定义其所需要的所有资源列表,资源管理器就会在进入场景或者关卡之前预加载所需的数据,并在离开场景或者关卡的时候删除不再被使用的资源。

在这里展开说一下,其实并非只有纹理才能通过这种机制来管理,资源信息除了纹理,还有很多,比如音频数据等。这些资源也可以通过这种机制来管理场景或者关卡层面的资源。通过cache对资源进行缓存,通过引用计数解决场景之间资源的重用。

还有一个开发者需要自己控制的地方,刚才也提到了,就是大的游戏,比如跑酷,用到的纹理很多,虽然在进入场景的时候使用了异步加载的方式,但是还是不太好,最好还是开发者控制一下,分阶段进行load。也就是动态预加载,动态预加载不是在场景开始的时候加载资源,也并非在需要的时候加载资源,而应该小心的估计那些资源即将被使用,提前动态的按需加载相关资源,比如跑酷就可以在刚进入场景的时候加载前50KM要使用到的资源,然后到50KM再加载一次资源,依次类推,每50KM加载一次新的资源,总之,我们需要根据游戏的特点,在一个场景或者关卡内定义很多异步预加载方案,实现巨大场景下游戏的无缝平滑体验。

总结

纹理时每个图形应用程序中的重要内容,对其使用不当就容易导致很严重的性能、内存、耗电等问题。然而,纹理在应用程序中,并不是一个独立的部分,它和各个系统都有着紧密的联系。

硬件层面

  • 提升纹理传输速度。在底层,它可以和GPU进行交互,通过对GPU进行优化可以实现更快的传输、像素读取等数据操作。所以,在硬件层面,游戏引擎公司,比如cocos、unity,都会和硬件公司打交道,看看是否可以提高硬件的加载速度,这样从CPU往GPU传输纹理的时候可以更快。
  • 增加内存,尽量缓存纹理。除了提高数据传输速度,还有一个办法是提高内存,我们在上面也说过,如果下一帧需要使用一张纹理,但是在使用之前,刚刚由于内存不足,导致这张纹理刚被删除掉,那么就需要对这张纹理进行重新加载,那么这种情况是非常糟糕的,所以,如果能在GPU中尽量缓存多一些纹理,这样的话会提高纹理缓存的命中率,这样的话也是很好的。
  • 特殊的压缩纹理格式。硬件层面还有最重要的一个方面,就是支持特殊的压缩纹理,这样,游戏引擎公司通过对硬件的了解,就可以针对硬件的不同,提供和硬件更匹配的解决方案。

当然,以上这三个方面的提升,即使游戏引擎公司不要求,硬件公司也会去做的,几乎所有的硬件厂商公司都会针对自己的硬件产品提供一些特定的优化,这些优化可以用来提升纹理的传输速度、获得更快的数据读取速度、针对某些数据类型的计算进行优化。而这些通常会设计到一些特殊的GL extension,当然,它带来的性能提升也是很明显的。所以,游戏公司只需要去和这些硬件公司合作即可。

软件层面

  • 纹理预加载。在应用程序方面,主要是基于引擎提供的功能,使用一些更好的方式管理和使用纹理。所以,在软件层面,首先是始终要提前预加载纹理,避免在游戏运行中动态加载资源。通常我们应该在进入一个关卡或者其他时机提前加载资源,并让它们驻留在内存中,直到不再使用该资源为止。这样做能改善应用程序的渲染性能,不会造成类似卡顿的糟糕体验。上面我们也介绍过texturecache提供的预加载函数,并且,还介绍了一种基于引用计数的方式来管理资源,这样,就可以很好的处理各种资源的预加载,以及在场景、关卡之间的过渡。
  • 删除不用纹理。减少不用纹理的占用量,发现不再使用的纹理资源,则将其remove掉,这要求开发者必须清楚的定义每个资源的生命周期。texturecache可以提供的一些函数,以及基于引用计数的资源管理方式,可以帮助开发者方便的管理资源的生命周期,及时释放不再使用的资源,开发者只需要描述纹理使用的生命周期即可。另外,我们也可以手动计算纹理所占用内存的大小,以手动清理一些资源。以上这两点,上面已经详细介绍过了,在这里就不再展开解释了。
  • 纹理合并。将小纹理合并成大纹理来减少绘制次数。虽然现在DC已经不对性能产生太大影响了,但是两次DC之间切换渲染状态依然是引起CPU功耗的大户,所以合并纹理,合并VBO、IBO,进而减少DC,是节省了CPU大量时间的,只需要注意一点,由于合并纹理,导致带宽压力变大(一次传入过多的BO信息和纹理),所以要避免性能bound在带宽上,做一下平衡
  • 使用多级纹理。使用多级纹理,可以来减少GPU内存的占用,如今的智能设备具有不同的分辨率,并且差异极大,应用程序通常都按照比较高的分辨率来设计。这样的话,针对分辨率比较小的机型,使用大的纹理是没有意义的。举个例子,假如一个手机的分辨率为800*600,但是我们原本纹理的尺寸为1024*1024,那么其实整个手机的屏幕也没有那么大,这张纹理对应在手机上的尺寸可能只有512*512,那么如果没有多级纹理,而保持使用大分辨率纹理的话,需要从1024*1024的纹理中,采样出来512*512个像素值信息,所以,最终的结果还是从这个大纹理中只取出来了512*512个像素点信息。所以,针对一些分辨率低的设备,仅上传相应级别的多级纹理即可。我们可以通过计算设备的分辨率和资源的分辨率来决定要使用哪一级的多级纹理,从而减少对低端设备内存的浪费。而且使用多级纹理,可以减少对内存带宽的占用,因为只需要上传低级别的mipmap,也就是上传给GPU的信息少了,这些内存带宽的使用也就少了。还有,使用多级纹理,在GPU对纹理进行采样的时候也更快,因为原本需要从1024*1024个像素点分析获取512*512个像素,现在只需要从512*512个像素点分析获取数据了,这样采样的效率会大大提高,今儿提升渲染的性能。但是多级纹理会占用更多的CPU内存,大概多1/3左右的内存,所以是一个用CPU内存换带宽和GPU内存的方法。
  • 使用多重纹理。目的也是为了减少draw call,举个例子,我们想要绘制一个太阳和一间房子,这两个物体个对应一个纹理,如果没有使用多重纹理,那么我们会先使用太阳的纹理以及VBO和IBO绘制出来太阳,然后同样再根据房子的纹理以及VBO和IBO绘制出来房子,这样就需要绘制两次,且由于两次绘制是串行执行,所以,占用了大量的时间。然而,如果我们使用多重纹理,一次性把太阳和房子的纹理都传入渲染管线,然后把太阳和房子的VBO和IBO合并,在shader中做好逻辑,使用两套uv坐标,多重纹理可以充分利用GPU并行执行的能力,减少管线的切换等,从而能够有效的提升渲染的性能,这样,也就可以通过一次draw call,将太阳和房子都绘制出来了。所以我们应该尽量在一次绘制命令中,传入更多的纹理来代替多次绘制。
  • 最后一种方法,是使用alpha预乘,使用alpha预乘可以减少透明纹理在场景混合时的计算量。

资源层面

在资源方面,使用的纹理格式、大小等,均对应用程序的性能有直接的影响。在开始讲这一块的优化之前,我先讲述一下如何计算纹理所占据的内存大小。

虽然纹理的大小可以使用一些工具来查看纹理的内存占用,比如Xcode自带的OpenGL ES Analytics等,但是,在应用程序中,计算纹理的内存占用仍然非常有必须。一方面,我们可以打印出来游戏进行中各个时间点内存的大小,并将这些内存占用关联到具体的纹理上,这样我们就能够更好的对纹理的使用进行优化,比如有些资源过大,或者有些资源占用时间过长等。另外一个方面,在运行过程中,计算内存的大小可以给应用程序设定一些警戒值,从而及时对纹理资源进行释放,以达到最佳的性能。

下面我们来说如何计算纹理所占据的内存大小,通过前面讲解的与纹理格式相关的知识,我们很容易对纹理所占内存进行计算,其计算公式为:纹理所占内存大小=纹理宽*纹理高*bpp。这里所计算出来的结果的单位为bit,而bpp的全称为bit per pixel,也就是每个像素占据多少位。比如RGBA8888的格式,每个像素占据32位,那么分辨率为1024*1024的纹理,所占据的内存空间为1024*1024*32 = 4MB。所以,只要知道纹理的格式,就可以计算出纹理占据的内存大小。

所以,在资源层面,我们可以通过一些方式来减小资源的大小。

  • 使用合理格式的纹理。比如使用GL_RGBA8888和GL_RGBA4444的bpp分别为32和16,那么使用16位纹理格式比32位的纹理格式要少占用一半的内存,这样针对精度要求不高的纹理,使用GL_RGBA4444就可以省去很多内存。再比如一些用于背景等非透明图象的纹理,就可以使用比如GL_RGB565之类的格式,由于不需要alpha通道,也就不使用带alpha通道的纹理格式,也能大大减少对内存的占用。还有对应alpha通道要求简单的图像,则可以使用GL_RGBA5551的纹理格式。
  • 第二个方法是,使用压缩纹理,比如PVR格式的bpp为2或者4,压缩比非常高,上面已经详细介绍过这个方案了,这里也就不再展开说明了。

纹理是游戏开发的重要内容。纹理的重要性往往要在游戏开发的后期才会暴露出来,早期程序和美工往往会肆无忌惮的使用纹理、特效、动画,到临近发布的时候才会突然面临内存、耗电等极其严重,甚至影响游戏发布的纹理。所以,这节课和上一节课,从各个角度深入讲述纹理的相关知识,希望大家可以明确与纹理相关的各个方面的细节,以实现各个方面的优化,从而使得游戏具有更好的性能和体验。

本节教程就到此结束,希望大家继续阅读我之后的教程。

谢谢大家,再见!


原创技术文章,撰写不易,转载请注明出处:电子设备中的画家|王烁 于 2017 年 7 月 19 日发表,原文链接(http://geekfaner.com/shineengine/blog11_OpenGLESv2_10.html)