在当前很多直播应用中,拥有给主播送礼物的功能,当用户点击赠送礼物后,视频界面上会出现比较炫酷的礼物特效。这些特效,有的是用粒子效果做成的,但是更多的时用播放逐帧动画实现的,本篇博客将会讲解在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实现逐帧动画的方案比较:
根据上述分析,在Android中使用OpenGLES加载动画:
选择方案1与方案2进行对比。
针对测试用的60张png烟花图片动画进行量化分析(图片大小为720*720,手机360F4):
方案2文件总大小太大,针对这个问题,可采用zip压缩纹理,加载时直接加载zip中的纹理文件。数据如下:
注:不同手机不同环境时间数据不同,此数据仅为PNG加载和压缩纹理方式加载的对比。
这种方式,主要是针对PNG透明区域比较多的图片,这样压缩纹理会比PNG大很多,ZIP压缩一下可以压缩的和PNG大小差不多。先直接说在实现过程中踩到的坑吧。
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);
其他地方就和之前渲染图片差不多了。
所有的代码全部在一个项目中,托管在Github上——Android OpenGLES 2.0系列博客的Demo
欢迎转载,转载请保留文章出处。湖广午王的博客[http://blog.csdn.net/junzia/article/details/53872303]