Android OpenGLES2.0(十三)——流畅的播放逐帧动画

在当前很多直播应用中,拥有给主播送礼物的功能,当用户点击赠送礼物后,视频界面上会出现比较炫酷的礼物特效。这些特效,有的是用粒子效果做成的,但是更多的时用播放逐帧动画实现的,本篇博客将会讲解在Android下如何利用OpenGLES流畅的播放逐帧动画。在本篇博客中的动画素材,是从花椒直播中“借”出来的。

逐帧动画的实现方案分析

有些朋友看到逐帧动画可能会想,逐帧动画还不容易吗?Android中的动画本来就支持逐帧动画啊,不是分分钟就能实现么?没错,用Android的Animation的确很容易就实现了逐帧动画。但是用Android的Animation实现动画,当图片要求较高时,播放会比较卡。为什么呢?
Png图片并不能在被直接用来播放动画,它需要先被解码成Bitmap,才能被绘制到屏幕上。而这个解码是一个比较耗时的工作。而且解码时间与手机、CPU工作状态、Png图片内容都有很大的关系。当图片较小时,播放出来的逐帧动画效果还不错,但是当图片较大时,比如720720,解码时间就往往需要100多ms,甚至会达到200ms以上。这个时间让我们很难以接受。
那么怎么办呢?限制动画的是PNG解码时间,而不是渲染时间,用OpenGL做渲染又有什么用呢?是的,用OpenGL来播放PNG逐帧动画,虽然比用Animation会有一些改善,但是并不能解决动画播放卡顿的问题。(当初天真的以为Animation播放动画是因为Animation用CPU绘制导致卡顿,然后改成用GPU来做,发现然并卵,这才把视线放到PNG解码上了。)
既然是PNG解码占用时间,那么能不能直接用BMP格式存储图片,来做动画呢?这样解码的时间就基本可以忽略了。那么问题又来了,BMP是不进过压缩的,一张720
720的PNG图片大小转成BMP就为7207204/1024=2025kb,那么一秒25帧动画,就要二十四五兆了。显然是难以让人接受的。那么怎么办呢?以下为Android下OpenGLES实现逐帧动画的方案比较:

待选方案

1. 直接2D纹理逐帧加载PNG
2. 使用ETC压缩纹理替代PNG
3. 使用ETC2压缩纹理替代PNG
4. 使用PVRTC压缩纹理替代PNG
5. 使用S3TC压缩纹理替代PNG

文件大小对比

1. PNG图片大小与其内容有关,透明区域越多,大小越小。
2. ETC1图片每个像素占0.5byte,720*720png变为ETC后大小为720*720*2*0.5+16(alpha通道导致文件高度增加一倍,16个字节为文件头部信息),约507KBytes。
3. ETC2大小与设置相关,不包含A通道,大小与ETC1不保留A通道相同,包含A通道的,与ETC1保留A通道相同。
4. S3TC 相对于24位原图,DXT1压缩比例为6:1,DXT2-DXT5压缩比例为4:1。
5. PVRTC4 压缩比为6:1,PVRTC2压缩比为12:1(PVRTC图片宽高为2的幂数)

文件支持对比

1. PNG通用
2. ETC1是OpenGL2.0支持标准,基本上所有支持OpenGLES2.0,版本不低于2.2的Android设备都能使用。
3. ETC2是OpenGL3.0支持标准,基本上所有支持OpenGLES3.0,版本不低于4.3的Android设备都能使用。
4. S3TC广泛用于Windows平台上,DirectX中使用较多。在Android上支持率很低,主要是NVIDIA Tegra2芯片的手机。
5. PVRTC只有PowerVR的显卡支持。在苹果系中使用广泛。

方案选择

根据上述分析,在Android中使用OpenGLES加载动画:

  • 方案4和方案5由于支持问题,直接排除了。
  • 方案1可以使用
  • 当前Android市场Android2.2以下设备基本不没有了,Android2.2及以上到Android4.3下,占比15%左右。所以方案2与方案3之中,取方案2。

选择方案1与方案2进行对比。

方案1和方案2数据

针对测试用的60张png烟花图片动画进行量化分析(图片大小为720*720,手机360F4):

  • PNG图片总大小为4.88M,ETC总大小29.6M。
  • PNG IO+解码耗时为15-40ms之间,与单张图片大小有关。ETC不在CPU中解码,只有IO时间,为4-10ms之间。(IO及解码时间与CPU能力及状态有关)
  • 渲染时间二者基本一致。

针对方案2的补充方案

方案2文件总大小太大,针对这个问题,可采用zip压缩纹理,加载时直接加载zip中的纹理文件。数据如下:

  • 总大小7.05M
  • IO+解码时间为4-16ms。
  • 渲染时间同不进行压缩的ETC

注:不同手机不同环境时间数据不同,此数据仅为PNG加载和压缩纹理方式加载的对比。

播放ZIP包下的ETC1压缩纹理逐帧动画

这种方式,主要是针对PNG透明区域比较多的图片,这样压缩纹理会比PNG大很多,ZIP压缩一下可以压缩的和PNG大小差不多。先直接说在实现过程中踩到的坑吧。

存在的坑

  1. 在Mali 官网工具中提供的三个方法中,方法一纹理拼图最简单,但是有的图片在边界处会出现奇怪的线条。这是因为纹理采样的时候,RGB和Alpha压缩在一个文件中,在边界处采样会采样过界,导致颜色不对。方法三虽然使用上步会出什么问题,但是单独的Alpha通道依旧会占用更多空间和内存带宽。所以选方法二。
  2. ZIP打包所有的ETC压缩纹理时,命名上保证顺序,图片数字前要补0,比如有100张图片,变成了200个pkm文件,最后一个为p100alpha.pkm,倒数第二个为p100.pkm。那么第一个应该为p001.pkm,而不是p1.pkm。其他的类似。这个是遍历文件夹、ZIP包的顺序纹理。
  3. Android提供的ETC1Util工具类的 ETC1Util.createTexture(InputStream in)方法有坑。具体问题,后面贴代码的时候说。

实现

压缩纹理的加载,OpenGLES 提供了GLES10.glCompressedTexImage2D(int target,int level,int internalformat,int width,int height, int border,int imageSize,java.nio.Buffer data) 方法,但是在Android中,可以用工具类ETC1Util提供的loadTexture(int target, int level, int border,int fallbackFormat, int fallbackType, ETC1Texture texture)方法来更简单的使用。
这样,我们就需要先得到一个ETC1Texture,而ETC1Util又提供了创建ETC1Texture的方法,上面说过,这个方法在使用中有点小坑,其源码为:

public static ETC1Texture createTexture(InputStream input) throws IOException {
    int width = 0;
    int height = 0;
    byte[] ioBuffer = new byte[4096];
    {
        if (input.read(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE) != ETC1.ETC_PKM_HEADER_SIZE) {
            throw new IOException("Unable to read PKM file header.");
        }
        ByteBuffer headerBuffer = ByteBuffer.allocateDirect(ETC1.ETC_PKM_HEADER_SIZE)
            .order(ByteOrder.nativeOrder());
        headerBuffer.put(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE).position(0);
        if (!ETC1.isValid(headerBuffer)) {
            throw new IOException("Not a PKM file.");
        }
        width = ETC1.getWidth(headerBuffer);
        height = ETC1.getHeight(headerBuffer);
    }
    int encodedSize = ETC1.getEncodedDataSize(width, height);
    ByteBuffer dataBuffer = ByteBuffer.allocateDirect(encodedSize).order(ByteOrder.nativeOrder());
    for (int i = 0; i < encodedSize; ) {
        int chunkSize = Math.min(ioBuffer.length, encodedSize - i);
        if (input.read(ioBuffer, 0, chunkSize) != chunkSize) {
            throw new IOException("Unable to read PKM file data.");
        }
        dataBuffer.put(ioBuffer, 0, chunkSize);
        i += chunkSize;
    }
    dataBuffer.position(0);
    return new ETC1Texture(width, height, dataBuffer);
}

修改为:

ETC1Util.ETC1Texture createTexture(InputStream input) throws IOException {
    int width = 0;
    int height = 0;
    byte[] ioBuffer = new byte[4096];
    {
        if (input.read(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE) != ETC1.ETC_PKM_HEADER_SIZE) {
            throw new IOException("Unable to read PKM file header.");
        }
        if(headerBuffer==null){
            headerBuffer = ByteBuffer.allocateDirect(ETC1.ETC_PKM_HEADER_SIZE)
                .order(ByteOrder.nativeOrder());
        }
        headerBuffer.put(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE).position(0);
        if (!ETC1.isValid(headerBuffer)) {
            throw new IOException("Not a PKM file.");
        }
        width = ETC1.getWidth(headerBuffer);
        height = ETC1.getHeight(headerBuffer);
    }
    int encodedSize = ETC1.getEncodedDataSize(width, height);
    ByteBuffer dataBuffer = ByteBuffer.allocateDirect(encodedSize).order(ByteOrder.nativeOrder());
    int len;
    while ((len =input.read(ioBuffer))!=-1){
        dataBuffer.put(ioBuffer,0,len);
    }
    dataBuffer.position(0);
    return new ETC1Util.ETC1Texture(width, height, dataBuffer);
}

这个方法,是通过InputStream得到一个ETC1Texture,所以我们直接读取Zip下的文件生成ETC1Texture就算完成了一大半工作了。读取Zip下的文件代码网上很容易找到,这里直接贴出Demo中的ZipPkmReader:

public class ZipPkmReader {

    private String path;
    private ZipInputStream mZipStream;
    private AssetManager mManager;
    private ZipEntry mZipEntry;
    private ByteBuffer headerBuffer;

    public ZipPkmReader(Context context){
        this(context.getAssets());
    }

    public ZipPkmReader(AssetManager manager){
        this.mManager=manager;
    }

    public void setZipPath(String path){
        Log.e("wuwang",path+" set");
        this.path=path;
    }

    public boolean open(){
        Log.e("wuwang",path+" open");
        if(path==null)return false;
        try {
            if(path.startsWith("assets/")){
                InputStream s=mManager.open(path.substring(7));
                mZipStream=new ZipInputStream(s);
            }else{
                File f=new File(path);
                Log.e("wuwang",path+" is File exists->"+f.exists());
                mZipStream=new ZipInputStream(new FileInputStream(path));
            }
            return true;
        } catch (IOException e) {
            Log.e("wuwang","eee-->"+e.getMessage());
            e.printStackTrace();
            return false;
        }
    }

    public void close(){
        if(mZipStream!=null){
            try {
                mZipStream.closeEntry();
                mZipStream.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
            if(headerBuffer!=null){
                headerBuffer.clear();
                headerBuffer=null;
            }
        }
    }

    private boolean hasElements(){
        try {
            if(mZipStream!=null){
                mZipEntry=mZipStream.getNextEntry();
                if(mZipEntry!=null){
                    return true;
                }
                Log.e("wuwang","mZip entry null");
            }
        } catch (IOException e) {
            Log.e("wuwang","err  dd->"+e.getMessage());
            e.printStackTrace();
        }
        return false;
    }

    public InputStream getNextStream(){
        if(hasElements()){
            return mZipStream;
        }
        return null;
    }

    public ETC1Util.ETC1Texture getNextTexture(){
        if(hasElements()){
            try {
                ETC1Util.ETC1Texture e= createTexture(mZipStream);
                return e;
            } catch (IOException e1) {
                Log.e("wuwang","err->"+e1.getMessage());
                e1.printStackTrace();
            }
        }
        return null;
    }

    private ETC1Util.ETC1Texture createTexture(InputStream input) throws IOException {
        int width = 0;
        int height = 0;
        byte[] ioBuffer = new byte[4096];
        {
            if (input.read(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE) != ETC1.ETC_PKM_HEADER_SIZE) {
                throw new IOException("Unable to read PKM file header.");
            }
            if(headerBuffer==null){
                headerBuffer = ByteBuffer.allocateDirect(ETC1.ETC_PKM_HEADER_SIZE)
                    .order(ByteOrder.nativeOrder());
            }
            headerBuffer.put(ioBuffer, 0, ETC1.ETC_PKM_HEADER_SIZE).position(0);
            if (!ETC1.isValid(headerBuffer)) {
                throw new IOException("Not a PKM file.");
            }
            width = ETC1.getWidth(headerBuffer);
            height = ETC1.getHeight(headerBuffer);
        }
        int encodedSize = ETC1.getEncodedDataSize(width, height);
        ByteBuffer dataBuffer = ByteBuffer.allocateDirect(encodedSize).order(ByteOrder.nativeOrder());
        int len;
        while ((len =input.read(ioBuffer))!=-1){
            dataBuffer.put(ioBuffer,0,len);
        }
        dataBuffer.position(0);
        return new ETC1Util.ETC1Texture(width, height, dataBuffer);
    }

}

Shader直接使用Mali 官网上方法2提供的Shader即可,然后在开启一个定时器,定时requestRender,加载下一帧压缩纹理。动画播放就基本完成了。为了简便,Demo中直接在在GL线程中Sleep然后requestRender的。

这里也贴上Shader的代码吧。
顶点Shader:

attribute vec4 vPosition;
attribute vec2 vCoord;
varying vec2 aCoord;
uniform mat4 vMatrix;

void main(){
    aCoord = vCoord;
    gl_Position = vMatrix*vPosition;
}

片元Shader:

precision mediump float;
varying vec2 aCoord;
uniform sampler2D vTexture;
uniform sampler2D vTextureAlpha;

void main() {
    vec4 color=texture2D( vTexture, aCoord);
    color.a=texture2D(vTextureAlpha,aCoord).r;
    gl_FragColor = color;
}

可以看到,在片元着色器中,我们需要两个Texture,一个包含着原来PNG图片的RGB信息,一个包含着原PNG图片的Alpha信息。这些信息并不是完全和原PNG信息相同的,压缩纹理在色彩上会有一些损失。
片元着色器中用到了两个采样器,纹理传入的代码为:

GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture[0]);
ETC1Util.loadTexture(GLES20.GL_TEXTURE_2D,0,0,GLES20.GL_RGB,GLES20
   .GL_UNSIGNED_SHORT_5_6_5,t);
GLES20.glUniform1i(mHTexture,0);

GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture[1]);
ETC1Util.loadTexture(GLES20.GL_TEXTURE_2D,0,0,GLES20.GL_RGB,GLES20
   .GL_UNSIGNED_SHORT_5_6_5,tAlpha);
GLES20.glUniform1i(mGlHAlpha,1);

其他地方就和之前渲染图片差不多了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容