感谢
- https://www.163.com/dy/article/F7UI0ODL0511G03U.html
- https://blog.csdn.net/bill_man/article/details/43884095
- https://blog.csdn.net/u011004567/article/details/79156899
- https://www.jianshu.com/p/3eb844c9d8df
- https://zhuanlan.zhihu.com/p/106333994
- https://docs.cocos.com/cocos2d-x/manual/zh/advanced_topics/optimizing.html
- https://blog.csdn.net/shimazhuge/article/details/78222023
- https://forum.cocos.org/t/topic/90248
- https://www.codercto.com/a/21132.html
- https://www.233tw.com/cocos/6512
- https://forum.cocos.org/t/creator/77721
- https://forum.cocos.org/t/topic/95043
https://forum.cocos.org/t/topic/41774/2
一、黄金法则
2/8原则:20%的代码消耗80%的性能,我们应致力于寻找这20%的代码,而不是见一个优化一个,这很可能会“吃力不讨好”。
- 够用原则:使用可接受的最低资源精度。比如RGBA4444代替RGBA8888,音频单声道代替多声道、采样率降低等。
数据说话原则:通过数据分析问题,切勿主观臆断。借助工具跟踪GPU、CPU、内存占用情况。
二、数据分析
1、开发测试阶段
GPU跟踪
- Xcode OpenGL ES Tools
- ARM Mali GPU: mali-graphics-debugge
- Imagination PowerVR GPU: pvrtune
- Qualcomm Adreno GPU: adreno-gpu-profiler
- CPU跟踪
- Xcode-Instrument
- VS CPU profiler
- Android monitor/Profiler
- Memory Monitor:内存分配、内存泄露
- Network Monitor:App网络请求
- CPU Monitor
- GPU Monitor
- GT APP:对App进行快速的性能测试(CPU、内存、流量、电量、帧率/流畅度等)、开发日志的查看、Crash日志查看、网络数据包的抓取、App内部参数的调试、真机代码耗时统计等。
- Emmagee:Andorid APP,监控CPU、内存、网络流量、电池电流和状态(某些设备不受支持)。此外,它还支持自定义收集数据的时间间隔,在浮动窗口中呈现实时进程状态等。
- Soloπ:APP,支付宝在移动端上实现的一套无线化、非侵入、免Root的Android专项测试方案。
- Testin
- Mi:OneAPM针对移动设备上App推出的移动应用性能监控工具
- 听云App:监控真实用户使用过程中的崩溃、错误、卡顿、网络性能差等问题。
2、运营阶段
统计数据:统计移动端版本情况、Android/IOS版本、CPU/GPU版本。
统计工具:友盟、openinstall、matomo、bugly(异常监控)
三、优化手段
程序优化的根本是为达到用户满意的体验,而在(计算)时间与(内存)空间上进行的权衡,这两者是矛盾关系,更多的计算可以换取空间上的减少,更多的空间也可以换取计算量的减少,具体如何权衡就看哪种做法可以更有效地改善体验。
1、内存优化(CPU)
缓存
根据2/8原则,我们要找出游戏中占据内存较多的模块。包含但不限于以下:
- 引擎缓存
- AudioCache
- SpriteFrameCache
- GLProgramStateCache
- TextureCache
- ActionTimelineCache
- AnimationCache
- FontAtlasCache
- GLProgramCache
- GLProgramStateCache
- 游戏(业务)缓存
- 各种文件配置数据、视频数据等等。
如果不加以管理,很可能导致内存浪费,我们可以在此基础上再做一套针对应用层的资源管理模块,参考TextureCache模式,采用引用计数的方式跟踪全部类型的缓存资源,至于在合适何时进行加载和清理,可以再建立一套UI栈(Android Activity),建立一个界面基类,制定好生命周期函数,规范好有序的创建/退出界面,自然地我们也就可以做到有序地加载/卸载资源。
Lua缓存
这部分可以好好研究大做文章,日后再仔细研究。
collectgarbage("setpause", 100 )
collectgarbage("setstepmul", 5000)
对象池
有些对象可能会被频繁的create/delete,add/remove,可以利用池机制,避免频繁的内存分配。
示意代码如下:
#include "Singleton.h"
// 多多使用C++的模板技术。
template<class T>
class ObjectPool : public Singleton<ObjectPool<T>>
{
public:
ObjectPool();
~ObjectPool();
//get a bullet
T* getObject();
void freeObject(T *obj);
private:
std::list<T*> objects;
const int INITALCAPACITY = 256;
};
等等(待添加)
tableView列表缓存,类似Android的ListView,缓存那个出到列表之外的Item,留给即将进入的那个Item使用,防止卡顿。
……
2、显存优化(GPU)
OpenGL对象创建之后一般都常驻于显存之中,需要及时的手动删除。
GLuint objHandle = glCreateCreate(......); // 创建对象。
......; // 可能会为这个对象开辟由它管理的内存,并填充数据。
glDeleteObject(objHandle); // 删除对象。
// ************************************
// 常用到的OpenGL对象类型。
// ************************************
glGenBuffers(...); // Buffer缓冲类型,如VBO、EBO
glGenSamplers(...); // Sampler采样器
glGenTextures(...); // Texture纹理对象
glGenFrameBuffers(...); // 帧缓冲对象
glGenVertexArrays(...); // VAO
glGenRenderBuffers(...); // 渲染缓冲对象类型
glGenProgramPipelines(...); // 管线对象
glGenTransformFeedback(...); // TransformFeedback
如果我们自己编写了GL代码,比如CustomCommand自定义绘制,别忘记glDelete*()。
上面我们讲到的引擎中的各种Cache,比如TextureCache缓存Texture2D其实封装了一个GL Texture纹理对象(虽然只是一个GLuint),因此我们只需要管理好Texture2D对象的生命周期,在析构的时候就会glDeleteTexture清除这个纹理对象。
压缩纹理
压缩纹理,指上传至GL显存中的纹理数据就是一种压缩的格式,这种格式的数据可以直接被GPU读取采样(GPU芯片支持的压缩格式),这既可以减少压缩纹理的文件大小,也可以减少它的内存占用和CPU至GPU的带宽占用,所以我们应该大大地使用压缩纹理。
- Android
- ETC1:POT,GLES 2.0及以上支持,不支持Alpha Channel,cocos已有支持。
- ETC2:NPOT,支持Alpha Channel,GLES 3.0及以上支持。
- ASTC:Android 5.0/OpenGLES 3.1及以上支持,市面上98.5%的Android手机支持。
- IOS
- ASTC:iphone 6以上支持。
- PVRTC2:POT,iphone都支持。
注意POT纹理像素宽高必须是2的次幂,美术设计的就要注意,否则会被压缩工具自主拉伸。
字体选择
性能Charmap>BMFont>TTF,打死不用systemFont,每个SystemFont都是一个纹理,内容改变就要生成新的纹理。
- TTF:根据显示字符,动态生成纹理,计算量最大,纹理会随着显示的字符类型数目增大而增大,如果是英文版那就舒服了,字符类型就那么几个,可以就用charmap/bmfont,好处是TTF可以支持各种字符(各种语言、符号)。
- BMFont:折中。
- Charmap:等宽的纹理组成的大纹理,根据偏移量显示指定纹理区域,性能最好,但是字符固定,也没有特效。
字符的特效酌情使用:
- outline,两个drawcall完成文本渲染
- shadow,两个drawcall
- glow,, 一个drawcall
- shadow + outline, 三个drawcall。
- 没有特效时,TTF自动合并drawcall。
等等(待添加)
骨骼动画代替帧数多的帧动画。
多多使用九宫格图。
设置默认纹理转化格式。
// 设置好加载纹理文件时,默认转化成的纹理格式。
// 默认是RGBA888,可以考虑RGBA4444
Texture2D::setDefaultAlphaPixelFormat(PixelFormat::RGBA4444);
enum class PixelFormat
{
//! auto detect the type
AUTO,
//! 32-bit texture: BGRA8888
BGRA8888,
//! 32-bit texture: RGBA8888
RGBA8888,
//! 24-bit texture: RGBA888
RGB888,
//! 16-bit texture without Alpha channel
RGB565,
//! 8-bit textures used as masks
A8,
//! 8-bit intensity texture
I8,
//! 16-bit textures used as masks
AI88,
//! 16-bit textures: RGBA4444
RGBA4444,
//! 16-bit textures: RGB5A1
RGB5A1,
//! 4-bit PVRTC-compressed texture: PVRTC4
PVRTC4,
//! 4-bit PVRTC-compressed texture: PVRTC4 (has alpha channel)
PVRTC4A,
//! 2-bit PVRTC-compressed texture: PVRTC2
PVRTC2,
//! 2-bit PVRTC-compressed texture: PVRTC2 (has alpha channel)
PVRTC2A,
//! ETC-compressed texture: ETC
ETC,
//! S3TC-compressed texture: S3TC_Dxt1
S3TC_DXT1,
//! S3TC-compressed texture: S3TC_Dxt3
S3TC_DXT3,
//! S3TC-compressed texture: S3TC_Dxt5
S3TC_DXT5,
//! ATITC-compressed texture: ATC_RGB
ATC_RGB,
//! ATITC-compressed texture: ATC_EXPLICIT_ALPHA
ATC_EXPLICIT_ALPHA,
//! ATITC-compressed texture: ATC_INTERPOLATED_ALPHA
ATC_INTERPOLATED_ALPHA,
//! Default texture format: AUTO
DEFAULT = AUTO,
NONE = -1
};
3、计算优化(CPU)
draw call
一帧的draw call次数最好在50以内。
看下面一段OpenGL伪代码,理解何为draw call(绘制)以及batch draw call(批绘制)。
// ******************************************
// 下面代码为绘制2个Sprite的cocos2dx整体OpenGL伪代码
// ******************************************
void main()
{
intGLLib(); // 初始化GL库
auto window = glCeateWindow(......); // 创建GL窗口
initGLState(); // 初始化OpenGL的初始State
while(!window->shouldClose()){ // 一个循环就是一帧
......;
// ********************
// 普通方法绘制两个Sprite
// ********************
for(......) drawSprite(); // 循环两次,2个draw call
// ********************
// 批绘制绘制两个Sprite
// ********************
drawSpriteBatch(); // 一个draw call搞定。
......;
}
}
// ******************************************
// 绘制一个Sprite
// ******************************************
void drawCall(){
// 第一步、切换至绘制这个Sprite的GL State
glBindBuffer(...); // 绑定VBO、EBO(顶点数据、索引数据)
glBindVertexArray(...); // 绑定VAO(shader的attribs)
glUseProgram(...); // 设置shader
glUniform(...); // 设置shader的uniforms(矩阵、texture sampler等)
glBindTexture(...); // 绑定Texture纹理
glBlendFunc(...); // 混合,如果有开启的话
// 第二步、执行draw call
glDrawElements(GL_TRIANGLES,...); // 顶点数据开始进入渲染管线,绘制Sprite,这就是一个draw call
}
// ******************************************
// 批绘制,一个draw call 绘制两个Sprite
// ******************************************
void drawCallBatch(){
// 第一步、切换至绘制这两个Sprite的GL State(代价高昂的)
// 在这个VBO顶点数据中包含了这两个Sprite的顶点数据,EBO根据这个VBO动态生成
glBindBuffer(...); // 绑定VBO、EBO(顶点数据、索引数据)
// 两个Sprite的shader必须相同,这是批绘制的前提条件(1/3)
glBindVertexArray(...); // 绑定VAO(shader的attribs)
glUseProgram(...); // 设置shader4
// 两个Sprite的纹理是同一张,这是批绘制的前提条件(2/3)
// 在顶点数据中已经指定了每个Sprite的采样区域
glUniform(...); // 设置shader的uniforms(矩阵、texture sampler等)
glBindTexture(...); // 绑定Texture纹理
// 两个Sprite的混合必须一样,这是批绘制的前提条件(3/3)
glBlendFunc(...); // 混合,如果有开启的话
// 第二步、执行draw call
glDrawElements(GL_TRIANGLES,...); // 绘制真正绘制Sprite
}
GL State的切换是代价高昂的,第二个批绘制少了一次GL State的切换,将明显提高绘制速度。这就是draw call优化(尽量减少每帧的draw call次数),实际上就是draw call的合并。需要注意的是,满足以下条件的两个draw call才能进行合并:
- 这两个draw call必须是先后连续执行的。
- 两个绘制之间的shader必须相同
- 两个绘制之间的texture必须相同
- 两个绘制之间的混合(blending)必须相同
我们要做的draw call优化,就从如何促成这4个条件下手。
将同一图层的图像TP打包成大图(texture相同),根据够用原则,还可以用RGBA4444+抖动代替RGBA8888
将相同类型的Node尽量放一起add(shader相同)
-
I/O操作
一般就指文件加载(纹理、字体、配置、音频、视频),比较耗时。文件加载其实也包含在上面的资源管理方案中:
- 预加载,在进入指定界面前预加载,比如界面的生命周期函数中执行。
- 异步加载(多线程)
- 分帧加载,避免峰值。
这里注意下大图的异步加载:
auto textureCache = TextureCache::getInstance();
auto spFrameCache = SpriteFrameCache::getInstance();
spFrameCache->addSpriteFramesWithFile(plist); // 这个加载并不是异步加载。
textureCache->addImageAsync(plistPNG, [](Texture2D* texture){
spFrameCache->addSpriteFramesWithFile(plist, texture); // 这样就可以异步加载。
});
visit和draw
Node的visit和draw方法是每帧调用的,见下面GL伪代码:
Scene* runningScene = createMainScene(); // 当前UI树的根节点
void main() {
while(!window->shouldClose()){ // 一个循环一帧
......;
runningScene->visit(); // 每帧执行draw
......;
}
}
// UI树的遍历是左->根->右的深度遍历顺序。
void Scene::visit(){
for(...) child->visit(); // localZOrder<0的child
this->draw(); // 绘制本节点
for(...) child->visit(); // localZOrder>0的child
}
因此当我们要重写这两个方法时,最好将他们的代码优化到极致,在这里面非常值得用空间换时间:
- 避免繁重计算
- CC_USE_CULLING,遮盖剔除,默认是开启的 ,可以考虑关闭。
尽量少破坏UI树的结构,减少UI树的深度
四、其他优化
使用 armabi-v7a构建Android工程,这会有更好的性能表现,因为在此架构下面 Cocos2d-x 会启用 neon指令集,矩阵运算的效率会大大提高。
等等(待添加)。