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實現逐幀動畫的方案比較:
待選方案
- 直接2D紋理逐幀載入PNG
- 使用ETC壓縮紋理替代PNG
- 使用ETC2壓縮紋理替代PNG
- 使用PVRTC壓縮紋理替代PNG
- 使用S3TC壓縮紋理替代PNG
檔案大小對比
- PNG圖片大小與其內容有關,透明區域越多,大小越小。
- ETC1圖片每個畫素佔0.5byte,720*720png變為ETC後大小為720*720*2*0.5+16(alpha通道導致檔案高度增加一倍,16個位元組為檔案頭部資訊),約507KBytes。
- ETC2大小與設定相關,不包含A通道,大小與ETC1不保留A通道相同,包含A通道的,與ETC1保留A通道相同。
- S3TC 相對於24位原圖,DXT1壓縮比例為6:1,DXT2-DXT5壓縮比例為4:1。
- PVRTC4 壓縮比為6:1,PVRTC2壓縮比為12:1(PVRTC圖片寬高為2的冪數)
檔案支援對比
- PNG通用
- ETC1是OpenGL2.0支援標準,基本上所有支援OpenGLES2.0,版本不低於2.2的Android裝置都能使用。
- ETC2是OpenGL3.0支援標準,基本上所有支援OpenGLES3.0,版本不低於4.3的Android裝置都能使用。
- S3TC廣泛用於Windows平臺上,DirectX中使用較多。在Android上支援率很低,主要是NVIDIA Tegra2晶片的手機。
- 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大小差不多。先直接說在實現過程中踩到的坑吧。
存在的坑
- 在Mali 官網工具中提供的三個方法中,方法一紋理拼圖最簡單,但是有的圖片在邊界處會出現奇怪的線條。這是因為紋理取樣的時候,RGB和Alpha壓縮在一個檔案中,在邊界處取樣會取樣過界,導致顏色不對。方法三雖然使用上步會出什麼問題,但是單獨的Alpha通道依舊會佔用更多空間和記憶體頻寬。所以選方法二。
- ZIP打包所有的ETC壓縮紋理時,命名上保證順序,圖片數字前要補0,比如有100張圖片,變成了200個pkm檔案,最後一個為p100alpha.pkm,倒數第二個為p100.pkm。那麼第一個應該為p001.pkm,而不是p1.pkm。其他的類似。這個是遍歷資料夾、ZIP包的順序紋理。
- 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);
其他地方就和之前渲染圖片差不多了。