站在Cocos Creator的肩膀上做优化
据扯,游戏江湖上曾流传过一句名言:“三流的游戏做功能,二流的游戏做表现,一流的游戏做优化”。虽然有点扯,但并非全无道理。至少印证了优化在做游戏中的重要性。不论对于引擎方还是开发团队,优化的重视程度都不言而喻。本文结合项目中的实例分享如何站在CocosCreator的肩膀上做更深入的加载优化。
一、原音重现
先了解一下CocosCreator(本文使用2.4.6版本)的加载流程。
-
Cocos Creator的加载流程
以上是loadRes的加载流程,其中的关键步骤说明如下:
url tranform: 主要是将工程路径地址/uuid转换成对应的实际资源地址。
load res: 主要是文件的io过程,并把加载后的资源转成对应的Json对象或二进制数组。
parse: 主要是把加载到的资源解析成对应的对象。
depends: 获取当前资源的依赖,然后继续调用开始的步骤加载。
-
剖析Prefab的加载流
以上流程左侧清晰地展示了Cocos Creator的加载管线,从引擎源码获知从url transform至depends前的流程都可以插入自定义管线,具备较好的灵活性和扩展性。
右侧部分为cc.Spriteframe资源的加载流程,这里为了展示区别,我们将其与Cocos2dx中的CCSprite加载做对比:
不难看出在Cocos Creator中创建一个Sprite会比Cocos2d-x时多两个流程。而从IO次数上对比,单张贴图的加载上Cocos Creator比Cocos2d-x多2次IO(SpriteFrame配置和Texture2d配置)。那么这两个配置是否是必要的?
答案还得从Cocos Creator本身的特性说起:
- SpriteFrame配置文件(下文简称【配置1】):一个独立的json文件,用来存储一九宫,以及纹理大小偏移等信息。可以使纹理自定义修改九宫图等更灵活。对应的就是下面属性面板中的信息:
TIPS : Cocos2d-x时期的配置是保存在对应ui编辑器生成的配置文件里,其他没有被界面引用的资源,需要在代码中指定配置。
- Texture2d的配置(下文简称【配置2】):主要定义纹理相关属性。
上图显示,有两个属性配置(WarpMode, FilterMode)会使我们使用图片和修改配置上更灵活。
综上,CocosCreator加载流程多出的两个配置是必要的,那么效率上是否有优化空间?
二、选A还是选C
官方的构建发布界面上有关于贴图配置的合并选项:
官网的解释如下:
内联所有 SpriteFrame
自动合并资源时,将所有 SpriteFrame 与被依赖的资源合并到同一个包中。建议网页平台开启,启用后会略微增大总包体,多消耗一点点网络流量,但是能显著减少网络请求数量。建议原生平台关闭,因为会增大热更新时的体积。
合并图集中的 SpriteFrame
将图集中的全部 SpriteFrame 合并到同一个包中。默认关闭,启用后能够减少热更新时需要下载的 SpriteFrame 文件数量,但如果图集中的 SpriteFrame 数量很多,则可能会稍微延长原生平台上的启动时间。
如果项目中图集较多,有可能会导致 project.manifest
文件过大,建议勾选该项来减小 project.manifest
的体积。
注意 :在热更新时,需要确保新旧项目中该功能的开启/关闭状态保持一致,否则会导致热更新之后出现资源引用错误的情况。
通俗的解释就是:
内联: 将SpriteFrame对应的json文件【配置1】合并到了prefab中。
合并图集: 把自动图集中所有SpriteFrame合并到同一个文件中,类似TexturePacker的plist文件。
各自的优缺点,在官方文档中有详细描述。
那么有没有一种解决方案,即能提高加载效率,又不影响启动速度呢?
三、90分答案
这里先给出本项目所采用的解决办法:
- 合并所有的SpriteFrame的配置,减少IO。
- 将合并后的配置转成二进制文件,加快启动速度。
具体步骤如下:
-
SpriteFrame配置优化
下面是SpriteFrame配置信息,只有"e8Ueib+qJEhL6mXAHdnwbi"(依赖)和中间的数据区是不同的
[
1,
[
"e8Ueib+qJEhL6mXAHdnwbi"
],
[
"_textureSetter"
],
[
"cc.SpriteFrame"
],
0,
[
{
"name": "default_btn_normal",
"rect": [
0,
0,
40,
40
],
"offset": [
0,
0
],
"originalSize": [
40,
40
],
"capInsets": [
12,
12,
12,
12
]
}
],
[
0
],
0,
[
0
],
[
0
],
[
0
]
]
解决方案:
1.1 相同的部分作为模板定义在代码中(减少冗余数据),提取所有的差异部分合并到同一个文件中,组成如下配置:
{[
{
"name": "default_btn_normal",
"rect": [
0,
0,
40,
40
],
"offset": [
0,
0
],
"originalSize": [
40,
40
],
"capInsets": [
12,
12,
12,
12
],
"depend": "e8Ueib+qJEhL6mXAHdnwbi" // 额外加入字段
},
...
],
[uuid1,uuid2,...] // 额外加入字段为文件的uuid,与上面的顺序保持一致
}
1.2 将文件转成二进制格式(可以有效降低文件大小,提高初始化速度,并且减少数据和字段冗余)。
二进制方案,推荐使用 flatbuffers。 (关于flatbuffers使用的文档可以参考网上教程或官方文档。)
1.3 接管游戏下载流程,保证文件正常读取。
- 接管IO,修改builtin/jsb-adapter/engine/
jsb-fs-utils.js
文件中添加如下:(这里是原生端的修改部分,网页端可以通过自定义加载管线的方式处理)
setJsonReadHandler(handler) {
fsUtils._customJsonLoadHandler = handler
},
readJson (filePath, onComplete) {
let jsonLoadhandler = fsUtils._customJsonLoadHandler
if (jsonLoadhandler && jsonLoadhandler(filePath, onComplete)) {
return
}
fsUtils.readFile(filePath, 'utf8', function (err, text) {
var out = null;
if (!err) {
try {
out = JSON.parse(text);
}
catch (e) {
cc.warn(`Read json failed: path: ${filePath} message: ${e.message}`);
err = new Error(e.message);
}
}
onComplete && onComplete(err, out);
});
},
- 数据还原:通过模板数据和二进制数据对SpriteFrame格式做还原,是这里的数据区存为flatbuffers对象即可,用到的地方再去解析。
[
1,
[
"e8Ueib+qJEhL6mXAHdnwbi"
],
[
"_textureSetter"
],
[
"cc.SpriteFrame"
],
0,
[
// flatbuffer对象
],
[
0
],
0,
[
0
],
[
0
],
[
0
]
]
- 修改CCSpriteframe.js文件,修改解析:
_deserialize: function (data, handle) {
if (!CC_EDITOR && data.bb) {
this._deserializeWithFlatbuffers(data);
return;
}
...
}
-
Texture2d配置优化
Texture2d的配置如下:
[
1,
0,
0,
[
"cc.Texture2D"
],
0,
[
"0,9729,9729,33071,33071,0,0,1",
-1
],
[
0
],
0,
[],
[],
[]
]
与SpriteFrame配置相比,Texture2d的配置简单多了,里面的属性值主要是与属性面板和文件扩展名有关。如果图片的属性都是默认的,并且扩展名是相同的情况下。Texture2d配置是完全相同的。(即项目中有200张图片资源,那200个图片的配置文件就是完全相同的)
解决方案: 通过md5比对所有的Texture2d配置文件,提取不同的文件,生成对应的配置映射以便快速读取。(以我当先的项目为例:有9000+图片资源,最终比对下来也就只有5种类型。所以就直接把这5种配置在代码中写死。同样在上面的接管流程中返回对应的配置信息。)
优化前后数据对比:(数据为iphone6的数据) 加载速度提升43%左右
-
Texture2d加载流程优化
原生的纹理加载的流程,把纹理数据转换成ArrayBuffer传给js,然后在js层再重新组装返回C++层,这里存在两次数据传递的过程。流程如下:
优化的方向:在加载完成后,原生层一步到位。直接创建成Texture2d对象返回,减少中间的数据传入过程。修改后的流程如下:(红框部分为省略的部分)
注: 修改为如上流程后,原生端的动态合图将无法使用。但是大多数的原生开发都会使用压缩纹理,并且压缩纹理也是不支持动态合图的。所以动态合图的问题大家完全可以忽略。
代码修改如下:
c++部分: (cocos2d-x/cocos/scripting/js-bindings/manual/jsb_global.cpp)
...
if (loadSucceed)
{
se::Object* retObj = se::Object::createPlainObject();
retObj->root();
refs.push_back(retObj);
cocos2d::renderer::Texture2D* cobj = new (std::nothrow) cocos2d::renderer::Texture2D();
auto obj = se::Object::createObjectWithClass(__jsb_cocos2d_renderer_Texture2D_class);
obj->setPrivateData(cobj);
cocos2d::renderer::Texture::Options options;
options.bpp = imgInfo->bpp;
options.width = imgInfo->width;
options.height = imgInfo->height;
options.glType = imgInfo->type;
options.glFormat = imgInfo->glFormat;
options.glInternalFormat = imgInfo->glInternalFormat;
options.compressed = imgInfo->compressed;
options.hasMipmap = false;
options.premultiplyAlpha = imgInfo->hasPremultipliedAlpha;
std::vector<cocos2d::renderer::Texture::Image> images;
cocos2d::renderer::Texture::Image image;
image.data = imgInfo->data;
image.length = imgInfo->length;
images.push_back(image);
options.images = images;
cobj->initWithOptions(options);
retObj->setProperty("texture", se::Value(obj));
retObj->setProperty("width", se::Value(imgInfo->width));
retObj->setProperty("height", se::Value(imgInfo->height));
seArgs.push_back(se::Value(retObj));
imgInfo = nullptr;
}
...
JS 代码修改:
builtin/jsb-adapter/builtin/jsb-adapter/HTMLImageElement.js
文件修改:
set src(src) {
this._src = src;
jsb.loadImage(src, (info) => {
if (!info) {
this._data = null;
return;
} else if (info && info.errorMsg) {
this._data = null;
var event = new Event('error');
this.dispatchEvent(event);
return;
}
this.width = this.naturalWidth = info.width;
this.height = this.naturalHeight = info.height;
if (info.texture) {
info.texture._ctor()
this.texture = info.texture
}
else {
...
}
this.complete = true;
var event = new Event('load');
this.dispatchEvent(event);
});
}
engine/cocos2d/core/assets/CCTexture.js
_nativeAsset: {
get () {
// maybe returned to pool in webgl
return this._image;
},
set (data) {
if (data.texture) {
this.initWithTexture(data.texture, data.width, data.height)
return
}
...
}
},
// 添加如下函数
initWithTexture (texture, pixelsWidth, pixelsHeight) {
this._texture = texture
this.width = pixelsWidth;
this.height = pixelsHeight;
// 通知原生端更新配置,如果没有修改texture属性的,代码基本跑不到。
// _updateNative标志在当前对象序列化的时候记录如果配置中的信息和默认值不一致时为true
if (this._updateNative) {
var opts = _getSharedOptions();
opts.minFilter = FilterIndex[this._minFilter];
opts.magFilter = FilterIndex[this._magFilter];
opts.wrapS = this._wrapS;
opts.wrapT = this._wrapT;
texture.update(opts, true) // 这里需要在原生端添加一个简易的更新函数。就拿原来的更新函数提出纹理数据就好了,这里就不贴了。
}
this.loaded = true;
this.emit("load");
return true;
},
优化前后数据对比:数据来源iphone6测试结果。 加载提升(12%-15%)
并且上面的数据表中统计的数据是统计Prefab加载前后的数据,包含了异步加载纹理的时间。所以会有比较长的时间的情况。但是同步耗时的地方基本没了。并且在iphone6上,已经感受不到明显的卡顿了。
四、附加题
- spine加载优化。
由于spine的骨骼动画是在原生端单独加载的,所以在js加载的时候可以移除spine骨骼加载,减少一次IO,修改文件如下:
deserialize.js
function deserialize (json, options) {
...
// 不是原生端或者不是骨骼文件,spine原生端不加载骨骼文件
asset._native && (asset.__nativeDepend__ = !CC_JSB || !(asset instanceof sp.SkeletonData));
pool.put(tdInfo);
return asset;
}
- 路径搜索(即fullPathForFilename)
由于第一次路径填充的时候,需要从所有的路径里去查找。从小米5上测试发现每次路径检查需要消耗2ms左右。正常我们会有两个路径:一个更新路径,一个是当前包路径。所以小米5上一个文件检索至少要4ms+。
解决方案 :自己生成一个路径映射表。因为打包和更新的时候文件有哪些都是确定的。这样就可以使文件查找的速度降到50μs一下。