智慧 + 毅力 = 无所不能

正确性、健壮性、可靠性、效率、易用性、可读性、可复用性、兼容性、可移植性...

导航

AIR 3.0针对移动设备的高性能渲染方案

Posted on 2013-01-22 10:29  Bill Yuan  阅读(1806)  评论(0编辑  收藏  举报

转自:http://blog.domlib.com/articles/242.html

  当我们一边正在等待Stage3D的发布时,很多开发者似乎还停留在这个印象中:即使AIR 3.0也无法在移动设备上开发出高性能的游戏。

      而事实上,只需要做一点点的工作,高性能的GPU加速功能已经为我们敞开了大门!

      在这片文章中,我将为您展示跟传统显示列表编程方式相比,能提升5倍以上的性能的优化方案。最终成果将会是一个用IOS5硬件加速的性能更高的渲染引擎(这点在后文跟进…)。

本文说的啥

        Adobe的工程师们在为AIR3.0重新设计GPU渲染模式的任务中做了出色的工作。现在我们开发者只需做很少量的工作,就可以从这些小小的移动设备上哄骗出超乎想象的性能。那么,我们到底需要做些什么呢?

1.在你的app.xml配置文件里找到节点并设置成这样:<renderMode>gpu</renderMode>

2.尽可能地使用位图来呈现你的显示对象

3.在类或应用程序之间缓存共享你的bitmapData

      这看起来是不是有点太简单了?但是请相信我,它绝对不坑爹 。微笑 Bitmap()这个类现在就是被出奇地优化了。在此夸奖一下整个AIR3.0团队,做出了如此惊人的一步改进。

      最后,总结下来就是:首先使用bitmapData.draw()方法手动绘制你的显示对象,并把bitmapData数据保存在一个共享的缓存或静态属性上。然后显示一个bitmap来取代你原先的显示对象。实际上,这相当于写一个自定义的cacheAsBitmap功能,不同之处在于使用了共享缓存。

基准测试

      首先,让我们请出我们的好工具–图表!

      我们在一系列的设备上都运行了同一个压力测试程序,结果如下表。每次测试都由包含旋转,透明度和缩放这三种变换的共同部分组成,并在保持跑满30fps的情况下不断添加Sprite显示对象。我们在相同测试条件下比较CPU渲染模式和GPU渲染模式性能。

      您可以点击此处查看HTML版本的测试示例,就能明白我说的是什么了:http://esdot.ca/examples/canvasTests/

  您可以清晰地看到它们之间巨大的性能差距。同时,必须要注意到的一点,由于我们使用的仍然是共享BitmapData的位图优化技术,所以针对CPU的测试也是被较好的优化过了的。因此这并不是那种故意让CPU渲染显得性能低下的找茬的测试。

      [更新]后来有人问我关于使用copyPixels方法的性能。copyPixels的性能将会介于以上两种方式之间。它比传统的显示列表速度快,但是比共享位图数据方式性能低(而且使用非常不灵活)。随着新的设备出现,更高分辨率的显示屏的使用,copyPixels方法的性能将会越来越落后。

代码示例

      代码示例好吧,说的够多了,下面上代码!

      例子1,假设我有一个含有精美矢量雪球影片剪辑的FLA文件,已经导出了类名为SnowBallAsset。现在我想在GPU模式下以非常高的性能渲染它。

public class SnowBall extends Sprite
{
    //声明一个静态数据变量,这个类的所有实例都可共享它。
    protected static var data:BitmapData;
    public var clip:Bitmap;
     
    public function SnowBall()
    {
        if(!data)
        {
            var sprite:Sprite = new SnowBallAsset();
            data = new BitmapData(sprite.width, sprite.height, true, 0x0);
            data.draw(sprite, null, null, null, null, true);
        }
        clip = new Bitmap(data, "auto", true);
        addChild(clip);
        //优化鼠标子项
        mouseChildren = false;
    }
}

  现在我可以方便地生成尽可能多的SnowBall()了。它们本质上都是完全由GPU加速渲染的。在这个简单的例子里,为了让它看起来正常,要注意你的素材必须要设置一个相对于0,0点的内部坐标。(但是为了提高5倍的性能,增加的这几行代码是值得的吧?) 下一个例子里,我们将写一个相似的类。但它是可以被重用的,你只需要把你要使用的素材类名传入即可。然后,你有时候可能还希望能缩放素材,但同时保持它的显示质量。这可以很容易地在上传到GPU之前,通过多倍重新采样的方式实现。

public class CachedSprite extends Sprite
{
    //声明一个缓存数据的静态属性
    protected static var cachedData:Object = {};
    public var clip:Bitmap;
     
    public function CachedSprite(asset:Object, scale:int = 2)
    {
        //检测是否已经缓存过对应的素材了
        var data:BitmapData = cachedData[getQualifiedClassName(asset)];
        if(!data)
        {
            var instance:Sprite = new asset();
            var bounds:Rectangle = instance.getBounds(this);
            //可选的操作,使用matrix对素材进行矩阵变换后重新采样,
            //这样可以让它被缩放后质量还是看起来比较不错。
            var m:Matrix = new Matrix();
            m.translate(-bounds.x, -bounds.y);
            m.scale(scale, scale);
            data = new BitmapData(bounds.width * scale, bounds.height * scale, true, 0×0);
            data = new BitmapData(instance.width, instance.height, true, 0x0);
            data.draw(instance, m, null, null, null, true);
            cachedData[getQualifiedClassName(asset)] = data;
        }
         
        clip = new Bitmap(data, "auto", true);
        //对Bitmap进行反向缩放,让最终效果看起来尺寸与原始一致
        clip.scaleX = clip.scaleY = 1/scale;
        addChild(clip);
        //优化鼠标子项
        mouseChildren = false;
    }
}

  [更新] 后来因为一系列的留言评论,我已经更新了以上代码,让它也能适用于起始坐标不在(0,0)点的素材。这样能应该能避免任何截边现在了。

  现在我想创建这个类的多少实例就创建多少。第一次实例化这个类时,将会有一次绘制过程,紧接着还有一次将位图上传到GPU的过程。经过这一次实例化的过程,之后再创建的所有实例,性能上几乎都是免费的。我同样可以将它们scale到2倍大小而察觉不到任何质量损失。不知道我前面是否提到过?不止是scale,rotation和设置alpha在性能上也同样几乎是免费的。

  使用这个类的素材类型相同的所有实例将会共享同一份位图数据,只要位图数据保持缓存在GPU内,所有共享它的实例,都将运行地无比流畅,这真的就是这么简单!

  要将这项技术应用到SpriteSheet或者多帧的MovieClip上,复杂度实在是微不足道。我相信任何有两把刷子的AS程序员都不会有任何问题的。但我随后还是发一些辅助的类代码上来。

  注意:直接继承一个Bitmap作为素材显示类的方式确实非常诱人,并且还能减少显示列表多余的嵌套。但是我后来发现,在Bitmap外面包裹一层Sprite会更有好处。首先一点就是Sprite具有鼠标事件,而Bitmap没有(我也明白为什么…)。其次是,在外部包裹一层Sprite可以让你很容易地在内部重新采样位图数据,然后在内部设置Bitmap的Scale值,而父容器根本不需要关心。

内部细节

  那么,这种方式在底层究竟是怎么运作的?
  1.把renderMode设置为GPU后,当一个bitmapData对象被渲染时,它将会被上传到GPU内作为一个纹理贴图处理。
  2.只要你保持这份bitmapData数据在内存中,纹理贴图就会一直存储在GPU上。(这正是最本质的原因)

  3.当这个纹理贴图处于GPU上时,你将会获得3至5倍的渲染性能提高!(视具体的GPU性能而异)

  4.此时Scale, Alpha, Rotation等等操作都将极度节省开销。

这种方式的亮点之处就在于,你可以继续使用传统显示列表的强大接口。你可以让嵌套项目,缩放,旋转或者淡出都达到极高的渲染性能。这个的好处能让你非常容易地缩放你的应用或者游戏去同时适应各种尺寸屏幕的设备,同时使用标准的AS3布局逻辑。

一些陷阱
  现在还有一些要点需要注意的,GPU模式确实有一些怪癖:

  1.你应该尽可能地保持你的显示列表简洁,在每个地方都尽量减少显示列表嵌套层级数

  2.要完全避免使用blendMode,它们会影响性能。(如果你必须使用blendMode,可以把它先设置到你的显示列对象上,用draw()方法把它缓存下来,然后在bitmap中渲染这个缓存位图数据)

  3.同样的要避免使用滤镜,如果你需要使用一个滤镜,直接用applyFilter()把它应用到bitmapData上,或者应用到显示对象上,再绘制出来。

  在下一篇文章里,我们将会做更多的对比测试。这次会使用含有动画的素材。我们同样也会贴出匆忙中写的一个用于测试SpriteSheet的简单类。

  [更新]:关于SpriteSheet的文章链接在这: http://esdot.ca/site/2012/fast-rendering-in-air-cached-spritesheets

 在上一篇文章中,我展示了如何正确地使用AIR3.0的GPU加速模式,能让你在移动设备上的性能提高500%的方法。现在我们把目标设定为:怎样能以同一种方式,在影片剪辑动画播放上获取更大的性能提升。你感觉性能提升4000%这个数字咋样?

当我第一次看到这个运行结果时我都不敢相信自己的眼睛…我的IPad2竟然跑赢了我3.2Ghz的四核台式机CPU,并且是接近两倍的性能!欢迎来到移动设备GPU加速的世界…

如果您还没有阅读过之前的那篇文章,简单介绍下将会帮助您明确我们下面用到的技术是什么。总结一下就是:首先,最基本的是要为每种素材(导出类或者嵌入素材)使用单一的bitmapData实例。我们将bitmapData缓存在类静态属性上,然后这个素材类的所有实例都将共享同一个bitmapData数据。

以下代码最简洁地表达了它的形式:

public class MyClass { 
    protected static var cache:bitmapData; 
  
    public function MyClass(){ 
        if(!cache){ cache = createCache(); } //这将只会运行一次. 
        addChild(new Bitmap(cache)); //Bitmap包装器共享同一份bitmapData 
    } 
}

现在不同之处在于,我们要共享一个数组的bitmapData列表,而不是单一的bitmapData。再认真读一遍这句话的意思。好的,我们还要缓存下frameLabels数据,这样就可以使用gotoAndPlay()方法了。

现在实际上就有好几种实现方案了。你可以使用事先导出的PNG的位图序列,或者在运行时通过draw()和gotoAndPlay()方法动态地缓存影片剪辑的每一帧。两种方式各有利弊,但是为了简单起见,我将采用PNG位图序列实现的方式。

第一步:把MovieClip转换为SpriteSheet

第一步是要确定你的哪些素材要转换为SpriteSheet。任何重复出现的或者长时间在屏幕上呈现的素材,应该转换为SpriteSheet。而对于那些只短暂显示的,或只有一个实例的素材,你可以直接让普通的方式去渲染它。 这就是GPU渲染模式的好处之一,并不是所有素材都需要缓存为位图。你可以有很多方式取巧(比如直接嵌入导出的动画素材库),你只要优化重要的部分即可。
注意:在GPU渲染模式下调用Transfroms的相关变换,性能是极其高的,所以如果你只是缩放,旋转或者移动显示对象,是没必要将它的变换过程转换为SpriteSheet的,直接用Tween操作它即可。虽然会稍微慢一点,但是你可以为GPU节省一大块的内存(想想当年用copyPixels()方法的时候,你必须要提前绘制好素材旋转的每个角度,才能让它运行的非常快,现在是不是不蛋疼了?哈哈~)

一旦决定了哪些动画素材需要被加速,你就需要将它们转换PNG位图序列了。我们将使用来自gskinner.com的一个小工具Zoe来导出。Zoe会读取一个swf文件,并将它的每一帧都输出为PNG位图。它还将检索时间轴上所有标签,并存储信息到json文件里。
接下来要做的步骤如下:

将你的动画导入FLA文件里,并储存这个FLA到某个位置,然后导出SWF文件。

下载并安装Zoe::http://easeljs.com/zoe.html

在Zoe里打开你刚刚导出的SWF文件,Zoe会自动检测边界,点击“Export”即可。

 

注意:Zoe是通过测量主时间轴来确定你的SpriteSheet中一共有多少帧。如果你把动画嵌套在一个影片剪辑里是可以的,但是要确保你的主时间轴帧长度要足够包含影片剪辑里的帧长度。

如果一切顺利的话,你现在应该能在素材目录得到一个JSON文件和PNG文件了。转向第二步:

第二步:在Flash中以非常非常高的性能播放SpriteSheet
下一步是加载JSON和PNG文件到Flash里,循环播放它们。然后,我们希望确保所每个特定动画的所有实例都在内存中共享同一份SpriteSheet。正是这个能给我们带来完全的GPU加速。

嵌入JSON和Bitmap非常简单:

[Embed("assets/Animation.png")] 
public var AnimationImage:Class; 
   
[Embed("assets/Animation.json", mimeType="application/octet-stream")] 
public var AnimationData:Class;

接下来你需要一个类来操作这些对象,然后想办法播放它们。必不可少的一步是分析Zoe导出的JSON文件,然后从大位图里裁剪出校位图序列。你还需要设计一个API来播放这些帧序列,用每帧切换位图的方法,作为你MovieClip的基本API。
我写了一个简单的类来辅助实现这个功能,命名为SpriteSheetClip.

//传入从Zoe导出的数据... 
var mc:SpriteSheetClip = new SpriteSheetClip(AnimationImage, AnimationData); 
mc.gotoAndPlay("someLabel"); 
addChild(mc); 
   
//为了最大化性能,所有缓存的Sprite都必须要手动触发播放 
function onEnterFrame(event:Event):void { 
      cachedAnimation.step(); 
}

SpriteSheetClip直接继承于Bitmap,并模拟了MovieClip的接口,完整的整个类就不过一遍了,下面代码的核心功能是缓存并裁切传入的SpriteSheet。注意下这两部分:怎么通过JSON数据获得帧宽度与帧高度和利用getQualifiedClassName()方法获取素材的唯一标识符。剩下的都是简单的循环了。

public static var frameCacheByAsset:Object = {}; 
   
public function SpriteSheetClip(bitmapAsset:Class, jsonAsset:Class){ 
   
_currentStartFrame = 1; 
var assetName:String = getQualifiedClassName(bitmapAsset); 
//如果已经存在缓存数据就直接使用它,位图数据很可能已经存在于GPU了。 
if(frameCacheByAsset[assetName]){ 
    frameCache = frameCacheByAsset[assetName].frames; 
    frameLabels = frameCacheByAsset[assetName].labels; 
   
    _frameWidth = frameCache[0].width; 
    _frameHeight = frameCache[0].height; 
} 
//如果没有缓存过,从bitmap里剪切出帧序列并且转换JSON字符串为对象 
else { 
    //rip clip! 
    var data:Object = JSON.parse(new jsonAsset().toString()); 
    var bitmap:Bitmap = new bitmapAsset(); 
    var spriteSheet:BitmapData = bitmap.bitmapData; 
   
    _frameWidth = data.frames.width; 
    _frameHeight = data.frames.height; 
   
    frameLabels = data.animations; 
   
    var cols:int = spriteSheet.width/_frameWidth|0; 
    var rows:int = spriteSheet.height/_frameHeight|0; 
    var point = new Point(); 
   
    var l:int = cols * rows; 
    frameCache = []; 
   
    _currentStartFrame = 1; 
   
    var m:Matrix = new Matrix(); 
   
    //遍历所有帧... 
    for(var i:int = 0; i < l; i++){ 
        var col:int = i%cols; 
        var row:int = i/cols|0; 
   
        m.identity(); //Reset matrix 
        m.tx = -_frameWidth * col; 
        m.ty = -_frameHeight * row; 
        //绘制每一帧并缓存它 
        var bmpData:BitmapData = new BitmapData(_frameWidth, _frameHeight, true, 0x0); 
        bmpData.draw(spriteSheet, m, null, null, null, true); 
        frameCache = bmpData; 
    } 
   
    _currentEndFrame = i; 
    numFrames = _currentEndFrame; 
   
    _frameWidth *= scale; 
    _frameHeight *= scale; 

    //添加帧数据到静态缓存 
  frameCacheByAsset[assetName] = { 
        frames: frameCache, //Cache bitmapData's 
        labels: frameLabels //Cache frameLabels 
    }; 
} 
//显示第一帧Show frame 1 
this.bitmapData = frameCache[_currentStartFrame-1]; 

}

现在,利用这个类,我们能创建同一个动画的多个副本,然后以非常高的性能运行它们。你能够同屏播放100个动画,甚至在最古老的Android设备上。而在较新一些的设备比如IPad2或者Galaxy Nexus,你可以一次跑满500至800个动画。并且缩放,调整透明度,旋转这些操作性能都非常高。

也许你在代码里注意到了,出于性能考虑,我的类并不自己实现更新操作。所以如果你调用play()方法什么也不会发生。我让父级对象来负责统一调用它所有的子项的step()方法,来代替在子项上添加一系列的EnterFrame事件监听,这样一个事件监听就可以代替上百个。

从帧管理功能上来说,这个类仍然还有挺多东西需要完善,如果需要的可以的话可以从附件源码里检出看看。事先声明,代码有一点小bug。我认为这只是一个示例参考而不是能投入生产使用的代码,但是你可以随意使用。

注:在工作流方面,一旦安装,这是相当不错的。Zoe会记住所有项目的设置,然后只需话大约10秒钟就能够完成Fla动画的更新并重新导出。

接下来让我们运行一些基准测试,来看看我们到底能跑多少个动画…

基准测试!
在这次测试中,我将在保持帧率30fps的情况下不断添加动画数量,直到帧率下降。

您可以下载这个Flash Builder项目自己运行一下:MobileRenderTests.zip

我将SpriteSheetClip与普通影片剪辑以及CopyPixel方案做了对比测试。测试结果令人影响深刻,SpriteSheetClip运行的动画超过了普通影片剪辑40倍,并且超过CopyPixels方案接近10倍。这比之前的所谓的Flash中“最快的”渲染方案还高出数量级的倍数。测试结果如下表:

 

在这里你可以看到,即使更古老的Android设备比如Nexus One,都能获取非常不错的测试结果。在30fps的帧率下同时播放150动画的性能足够制作几乎所有的2D游戏。而对于较新的一些设备,测试结果变得令人印象非常深刻,在保持30fpsd的帧率下iPad 2跑满了735个动画?!

请下载上面提供的完整Flash Builder项目源码,以便在您自己的移动设备上测试运行一下,您可以通过留言评论让我知道运行结果。

关于内存管理的一些建议
最后一件我非常想要强调的事就是内存管理的重要性。因为现在你正在往GPU里载入东西。你需要时刻注意内存的使用。一旦你填满了GPU的内存,它将会强制切换图像纹理数据。如果这个持续发生,它将严重影响你的GPU性能。
这里有一点主要的改变是需要你注意的。这点即使你最终迁移到使用Stage3D也是要注意的。所有被渲染的对象都是一个图像纹理,而刷新纹理的性能消耗是巨大的。你必须清楚明白这个概念,然后以一个聪明的方式专注于管理你的纹理(也就是BitmapData)

好吧,我们只有有限的内存,但到底多有限呢?我曾看到过对于IOS设备的推荐纹理内存大小是24MB,这对于一个32位的PNG图片来说,结果就是4096×4096像素的一张大位图(我猜是这样)。所以,如果你能把当前场景用到的所有的bitmapData都填充到这张大位图里,并不超过4096×4096。你的程序应该能轻松运行在所有IOS设备上。

现在,你可能还注意到一些更古老的Android设备有甚至更低的内存使用限制。所以你要尽可能保持最低的内存占用。
尽力去不断优化你的图像纹理管理,这可能是一个会影响到你渲染性能的最大因素。如果你将花一些时间去优化你的应用,值得你花时间去优化的最大的地方就是最小化你的纹理内存占用。你可以这么做,使用网格重复的图像,或者在组件之间共享位图数据。

我在写SnowBomber游戏时,应用的一个技巧是根据设备来缩放我的缓存数据。为了让游戏能运行在GPU非常弱的Nexus One上,我把bitmapData都缩放到50%后再缓存它们。这让我即使在Nexus One上也达到了差不多25fps的帧率,这点在之前我是完全无法想象的。这似乎过于简单了,只需在绘制时传入一个简单的matrix对象大小的即可。瞧,我这就拥有一个运行时动态调整大小的图像纹理了…