1. 程式人生 > >Android OpenGLES2.0(十三)——流暢的播放逐幀動畫

Android OpenGLES2.0(十三)——流暢的播放逐幀動畫

在當前很多直播應用中,擁有給主播送禮物的功能,當用戶點選贈送禮物後,視訊介面上會出現比較炫酷的禮物特效。這些特效,有的是用粒子效果做成的,但是更多的時用播放逐幀動畫實現的,本篇部落格將會講解在Android下如何利用OpenGLES流暢的播放逐幀動畫。在本篇部落格中的動畫素材,是從花椒直播中“借”出來的(只做學習交流用,應該不構成侵權吧:-D)。

逐幀動畫的實現方案分析

有些朋友看到逐幀動畫可能會想,逐幀動畫還不容易嗎?Android中的動畫本來就支援逐幀動畫啊,不是分分鐘就能實現麼?沒錯,用Android的Animation的確很容易就實現了逐幀動畫。但是用Android的Animation實現動畫,當圖片要求較高時,播放會比較卡。為什麼呢?
Png圖片並不能在被直接用來播放動畫,它需要先被解碼成Bitmap,才能被繪製到螢幕上。而這個解碼是一個比較耗時的工作。而且解碼時間與手機、CPU工作狀態、Png圖片內容都有很大的關係。當圖片較小時,播放出來的逐幀動畫效果還不錯,但是當圖片較大時,比如720*720,解碼時間就往往需要100多ms,甚至會達到200ms以上。這個時間讓我們很難以接受。
那麼怎麼辦呢?限制動畫的是PNG解碼時間,而不是渲染時間,用OpenGL做渲染又有什麼用呢?是的,用OpenGL來播放PNG逐幀動畫,雖然比用Animation會有一些改善,但是並不能解決動畫播放卡頓的問題。(當初天真的以為Animation播放動畫是因為Animation用CPU繪製導致卡頓,然後改成用GPU來做,發現然並卵,這才把視線放到PNG解碼上了。)
既然是PNG解碼佔用時間,那麼能不能直接用BMP格式儲存圖片,來做動畫呢?這樣解碼的時間就基本可以忽略了。那麼問題又來了,BMP是不進過壓縮的,一張720*720的PNG圖片大小轉成BMP就為720*720*4/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);

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

原始碼