Cocos Creator 支持ETC1 + Alpha 纹理压缩

欢迎关注我们的技术博客,聚焦 Cocos Creator、Flutter、React Native 跨平台开发技术实践。地址:https://oedx.github.io/

ABCmouse是使用Cocos Creator(后面统称CC)开发的App,图片内存占用巨大,在Android低内存机器上容易造成OOM,纹理压缩后的图片可以直接在GPU加载渲染,减少占用内存。而本文基于CC V1.10.x版本做相应的分析及其改造,使项目支持ETC1(Ericsson Texture Compression)+Alpha纹理压缩

1、背景

ABCmouse项目中用到的图片资源随着项目开发越来越多,手机Graphic内存占用越来越高,特别是在低端机器上容易Out Of Memory,而项目使用的CC版本是基于V1.10.x较低版本,官网已经升级到V2.0.9,版本差异大升级困难,故而需要在原版上进行支持纹理压缩。

2、what、why、how ETC纹理压缩

What:ETC是把4x4的像素块压缩成一个64或128位的数据块,是有损压缩,移动平台游戏比较常用的压缩方式之一(iOS设备中支持的是PVR压缩和ETC2,在Android中支持的是ETC压缩)。而ETC又分为ETC1和ETC2,ETC2是向下兼容ETC1,对RGB的压缩质量更好,并且支持透明通道,当然对软件硬件也是有一定的要求。而各种Android设备基本都支持ETC1,ETC1不支持透明通道

ETC1、ETC2对比如下:

参考OpenGL ES 版本Android占比分布:https://developer.android.com/about/dashboards/index.html#Screenshttps://developer.android.com/guide/topics/graphics/opengl.html

硬件方面:虽说ETC2支持Android版本4.3+,但并非所有的4.3机器都支持ETC2,这取决于手机厂商定制的GPU型号是否支持。

Why:而使用纹理压缩有什么好处呢?纹理压缩后的图片,不经过CPU解码,直接使用GPU加载渲染,大大提高了加载速度。
为此,很多游戏App采用ETC1+Alpha来解决ETC1不支持透明通道的问题。

How:那么,如何生成ETC1+Alpha呢?首先移动端是无法直接在移动端生成etc,需要额外的制作工具。这里推荐使用Mali Texture Compression Tool,这个工具可以生成ETC1和带透明通道的ETC1,下载地址:https://developer.arm.com/tools-and-software/graphics-and-gaming/graphics-development-tools/mali-texture-compression-tool/downloads

3、实现方案

基于我们项目中,目前Android配置的minSdkVersion是19,那么是否可以考虑直接使用ETC2呢?抱着测试的心态,先使用上面介绍的Mali工具,使用以下命令直接将图片压缩为ETC2格式:

etcpack srcfile outfile -c etc2 -f RGBA

具体CC编译后的Android包如何加载ETC2可以参看文章:https://forum.cocos.com/t/cocos-etc2/49061 这里不再详述。
然而前面已经提到的有些低端机器不支持ETC2的加载,这导致了渲染黑屏。通过CC引擎检测是否支持ETC2 log我们可以看到,该手机硬件不支持,具体log如下:

D/cocos2d-x: {
        gl.supports_OES_packed_depth_stencil: true
        gl.supports_vertex_array_object: true
        gl.supports_BGRA8888: false
        cocos2d.x.version: Cocos2d-x-lite v1.8.2
        gl.supports_discard_framebuffer: true
        cocos2d.x.compiled_with_profiler: false
        gl.supports_PVRTC: false
        cocos2d.x.build_type: DEBUG
        gl.renderer: Adreno (TM) 530
        gl.supports_OES_depth24: true
        gl.supports_ETC1: true
        gl.supports_OES_map_buffer: false
        cocos2d.x.compiled_with_gl_state_cache: true
        gl.version: OpenGL ES 3.2 V@313.0 (GIT@984b9a6, Ibe1bf21abc) (Date:06/04/18)
        gl.supports_NPOT: true
        gl.supports_ETC2: false
        gl.max_texture_units: 96
        gl.vendor: Qualcomm
        gl.max_texture_size: 16384
    }
cocos2d-x: cocos2d: Hardware ETC2 decoder not support.

考虑到需要兼容部分低端机型,这里不得不放弃ETC2的使用,转而尝试使用ETC1+Alpha。同样的,使用Mali工具对图片进行纹理压缩命令如下:(使用命令生成出来的纹理上半部分是原始图片(无alpha信息),下半部分是alpha信息图片)

etcpack srcfile outfile -c etc -aa

原始图:

ETC1+Alpha纹理压缩后预览图:

可以看到纹理压缩后的图片高度是原来图片高度的2倍,那么最关键的就是如何让其渲染成原始图片。答案是使用Shader对其进行渲染。
首先了解一下CCSprite、CCSpriteFrame、CCTexture2D之间的关系(图来自CC官网文档):

从图中可以看到,我们肉眼看到的是CCSprite渲染出来的图片,CCSpriteFrame为精灵的某一帧,CCTexture2D为图片纹理数据,也对应上从Cocos js加载图片到sprite中代码:

properties: {
   sprite: cc.Sprite,
}
...
cc.loader.load(path, function (err, texture) {
    this.sprite.spriteFrame = new cc.SpriteFrame(texture);
});

了解了以上概念后,纹理压缩后出来的文件就是对应的CCTexture2D,如何让其显示到CCSprite上就是我们要做的处理。
我们先看一下整体的实现流程图(从构建–>纹理压缩–>打包apk–>图片渲染):

4、开发过程遇到的问题

如果直接使用CC v1.10.x版本的代码直接加载etc1+alpha文件,那么将出现各种问题:

  • 图片移位
  • 渲染黑块
  • 图片遮罩不生效
  • 自定义Shader不生效
  • cocos js获取Texture的height变成实际显示高度的2倍

:heavy_check_mark: cc.loader.load动态加载的图片将出现移位
解决的最关键一步是获取Texture2D的pixelFormat为ETC格式时需要update SpriteFrame的几个属性,具体实现需要修改c++层的CCSpriteFrame.cpp进行适配:

void SpriteFrame::setTexture(Texture2D * texture)
{
    if( _texture != texture ) {
        CC_SAFE_RELEASE(_texture);
        CC_SAFE_RETAIN(texture);
        if(texture->getPixelFormat() == Texture2D::PixelFormat::ETC){
            int texHigh = texture->getPixelsHigh();
            if(texHigh == _rect.size.height){
                _rect.size.height *= 0.5;
                _rectInPixels = CC_RECT_POINTS_TO_PIXELS(_rect);
                Size size = CC_SIZE_POINTS_TO_PIXELS(texture->getContentSize());
                size.height *= 0.5;
                _originalSizeInPixels = size;
                _originalSize = CC_SIZE_PIXELS_TO_POINTS( _originalSizeInPixels);
            }
        }
        _texture = texture;
    }
}

可以看到我们将_rect.size.height和contentSize.height减半并更新_rect、_rectInPixels、_originalSizeInPixels、_originalSiz属性

:heavy_check_mark: 渲染黑块
解决渲染黑块,使用shader对etc纹理进行渲染,将遮罩部分作为Alpha值加到原图上
顶点着色器 ccShader_PositionTexture_Ect1Alpha.vert:

const char* ccPositionTexture_Ect1Alpha_vert = STRINGIFY(
attribute vec4 a_position;
attribute vec2 a_texCoord;
attribute vec4 a_color;
varying vec4 v_fragmentColor;
varying vec2 v_texCoord;
varying vec2 v_alphaCoord;
void main()
{
    gl_Position = CC_PMatrix * a_position;
    v_fragmentColor = a_color;
    v_texCoord = a_texCoord;
}
);

片段着色器 ccShader_PositionTexture_Ect1Alpha.frag:

const char* ccPositionTexture_Ect1Alpha_frag = STRINGIFY(

\n#ifdef GL_ES\n
precision lowp float;
\n#endif\n
varying vec4 v_fragmentColor;
varying vec2 v_texCoord;
varying vec2 v_alphaCoord;
void main()
{
    vec4 v4Colour = texture2D(CC_Texture0, v_texCoord);
    v4Colour.a = texture2D(CC_Texture0, vec2(0.0, 0.5) + v_texCoord).r;
    //v4Colour.rgb *= v4Colour.a;//Premultiply with Alpha channel
    gl_FragColor = v_fragmentColor * v4Colour;
}
);

具体的shader语法可参考:https://learnopengl-cn.github.io/01%20Getting%20started/04%20Hello%20Triangle/#_3

:heavy_check_mark: 图片遮罩不生效

CC上预览是可以,但由于mask上设置的SpriteFrame经过纹理压缩后,在app上就展示不出mask效果。需要找到mask设置SpriteFrame对应的c++层逻辑,对应CCClippingNode.cpp如下方法:

void ClippingNode::visit(Renderer *renderer, const Mat4 &parentTransform, uint32_t parentFlags)
{
    ……省略部分代码

    auto alphaThreshold = this->getAlphaThreshold();
    if (alphaThreshold < 1)
    {
#if CC_CLIPPING_NODE_OPENGLES
        // since glAlphaTest do not exists in OES, use a shader that writes
        // pixel only if greater than an alpha threshold
        //
        GLProgram *program = GLProgramCache::getInstance()->getGLProgram(GLProgram::SHADER_NAME_POSITION_TEXTURE_ALPHA_TEST_NO_MV);
        if(_stencil != nullptr){
            if (auto scale9sp = dynamic_cast<creator::Scale9SpriteV2*>(_stencil)){
                cocos2d::SpriteFrame* spriteFrame = scale9sp->getSpriteFrame();
                if(spriteFrame && spriteFrame->getTexture() && spriteFrame->getTexture()->getPixelFormat() == Texture2D::PixelFormat::ETC){
                    program = GLProgramCache::getInstance()->getGLProgram(GLProgram::SHADER_NAME_POSITION_TEXTURE_ETC_ALPHA_TEST_NO_MV);
                }
            }
        }
        GLint alphaValueLocation = glGetUniformLocation(program->getProgram(), GLProgram::UNIFORM_NAME_ALPHA_TEST_VALUE);
        // set our alphaThreshold
        program->use();
        program->setUniformLocationWith1f(alphaValueLocation, alphaThreshold);
        // we need to recursively apply this shader to all the nodes in the stencil node
        // FIXME: we should have a way to apply shader to all nodes without having to do this
        setProgram(_stencil, program);
#endif

    }

    ……省略部分代码
}

找到alphaThreshold小于1的地方,要应用GLProgram之前先判断PixelFormat格式如果是etc则重新设置shader,SHADER_NAME_POSITION_TEXTURE_ETC_ALPHA_TEST_NO_MV是我重新设置的shader,区别于SHADER_NAME_POSITION_TEXTURE_ALPHA_TEST_NO_MV只是关键的一行代码:

const char* ccPositionEtcTextureColorAlphaTest_frag = STRINGIFY(

\n#ifdef GL_ES\n
precision lowp float;
\n#endif\n

varying vec4 v_fragmentColor;
varying vec2 v_texCoord;
uniform float CC_alpha_value;

void main()
{
    vec4 texColor = texture2D(CC_Texture0, v_texCoord);
    texColor.a = texture2D(CC_Texture0, vec2(0.0, 0.5) + v_texCoord).r;//关键的一行:设置Alpha层叠加到原图上

\n// mimic: glAlphaFunc(GL_GREATER)
\n// pass if ( incoming_pixel >= CC_alpha_value ) => fail if incoming_pixel < CC_alpha_value\n

    if ( texColor.a <= CC_alpha_value )
        discard;

    gl_FragColor = texColor * v_fragmentColor;
}
);

:heavy_check_mark: 自定义Shader不生效
ABCmouse项目中的bookplayer使用了自定义Shader,加了纹理压缩后,发现翻书shader效果失效了,而翻书用的图片是网络图片(没有经过纹理压缩),理论上跟etc纹理没关系。

后面定位到是CCScale9Sprite.cpp下setSpriteFrame当spriteFrame为空时重新设置了默认shader(SHADER_NAME_POSITION_TEXTURE_COLOR_NO_MVP)导致,而bookplayer刚好就是有设置sprite.spriteFrame = null;的情况,解决方法去掉设置默认shader即可

bool Scale9SpriteV2::setSpriteFrame(cocos2d::SpriteFrame* spriteFrame)
{
    if(this->_spriteFrame == nullptr){
        if (spriteFrame && spriteFrame->getTexture()) {
            Texture2D::PixelFormat pixelFormat = spriteFrame->getTexture()->getPixelFormat();
            if (pixelFormat == Texture2D::PixelFormat::ETC) {
                this->setGLProgramState(GLProgramState::getOrCreateWithGLProgramName(GLProgram::SHADER_NAME_POSITION_TEXTURE_ETC1ALPHA));
            } else{//自己添加的下面这行导致shader被覆盖,去掉即可
                this->setGLProgramState(GLProgramState::getOrCreateWithGLProgramName(GLProgram::SHADER_NAME_POSITION_TEXTURE_COLOR_NO_MVP));
            }
        }
    }
   ......忽略其他代码
    return true;
}

那么还可以思考的是,要是etc纹理做自定义shader应该如何处理呢?项目中暂时没有这种暂时没支持,编码思路是判断是否有自定义纹理,有则使用自定义,没有则用etc shader

:heavy_check_mark: 经过ETC纹理压缩后,cocos js获取Texture的height变成实际显示高度的2倍
解决方法是在cc.Texture2D新增获取真正height的方法,如下:

cc.Texture2D.prototype.getWrapPixelHeight = function(){//Android本地图片使用etc+alpha纹理压缩,height、getPixelHeight()、pixelHeight是显示height的两倍,请使用这个wrap方法获取修复
        if(this.getPixelFormat() == 14){//etc+alpha 高度减半处理
            return this.height * 0.5;
        }
        return this.height;
    };

5、纹理压缩白名单

由于etc是有损压缩,对于一些设计师图片有质量要求(特别是alpha透明度有要求)的需要添加到白名单里,不进行压缩。

目前白名单主要有:AutoAtlas和骨骼动画、压缩后alpha有问题的图片

6、优化后的内存效果

Graphics:图形缓冲区队列向屏幕显示像素(包括 GL surfaces, GL textures等等)所使用的内存

使用Memory Profiler分析内存占用情况,在同一场景gc后稳定内存,然后分别加载png图片和etc图片,对比内存如下:



从图中可见,png加载占用了5.7M,而etc仅仅占用了1.8M

附加参考:

CC论坛:cocos实现对ETC2的支持:https://forum.cocos.com/t/cocos-etc2/49061
CC论坛:Creator使用压缩纹理:https://forum.cocos.com/t/creator/47206
cocos2d中添加自己的shader教程:http://www.cocoachina.com/bbs/read.php?tid=220630
OpenGL着色器语言(GLSL):https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/
关于cocos2d-x对etc1图片支持的分析:https://blog.csdn.net/langresser_king/article/details/9339313
Mali-Texture-Compression工具:https://developer.arm.com/tools-and-software/graphics-and-gaming/graphics-development-tools/mali-texture-compression-tool/downloads

以上如有错误疏漏,烦请指正
欢迎关注我们的技术博客,聚焦 Cocos Creator、Flutter、React Native 跨平台开发技术实践。地址:https://oedx.github.io/

12赞

干货!

牛逼!

mark

牛逼!

个人觉得ETC2没啥毛病 那么低端的机子玩个球游戏 现在都19年了 不是09年 ~ 15年出的手机基本都支持OpenGL3.0了 ~ 一些公司看着那一点已经闲置的手机份额不知道为了什么。。 ios 5s都支持 至今还用5s的真少之又少 ~ 更何况它还支持呢 近两年三年出的就红米手机都是支持的 ~

猴哥牛逼

我想知道ios楼主是怎么处理的,pvr+alpha?

pvr+etc2混合

厉害了