本文从降低drawcall角度来分析如何提升性能。
本文链接 CocosCreator游戏性能优化(2):合批渲染
相关链接 CocosCreator游戏性能优化(1):性能分析工具
CocosCreator游戏性能优化(3):GPU优化之降低计算分辨率
一、分析Drawcall性能瓶颈
首先,每次CPU向GPU提交渲染指令,都会消耗一系列性能。例如,CPU计算、数据传输IO、GPU申请创建、绑定VBO等对象、GPU程序编译链接损耗。如果多次执行,而GPU每次都处理不饱和,那么会造成性能热点和浪费。
我们看下CPU向GPU提交渲染会有哪些操作:
1、CPU遍历场景节点树,计算渲染指令。此过程中还会检测合批(如果开启了动态合批)或其他操作。
2、CPU提交渲染指令到渲染队列。CPU创建或查询需要使用的纹理数据和格式Texture;创建并绑定VertexBufferObject,调用gl接口解析特定格式的顶点数据VertexData;同时创建GPU程序,设定shader uniform值,编译链接shader代码。
/** 解析顶点数据 */
GLuint vbo; // 声明vbo变量
glGenBuffers(1, &vbo); // 生成1号vbo对象
glBindBuffer(GL_ARRAY_BUFFER, vbo); // 绑定并使用1号vbo对象
glBufferData(GL_ARRAY_BUFFER, sizeof(float) * M * N, nullptr, GL_STATIC_DRAW); // 向vbo对象中填充顶点数据
glBindBuffer(0, &vbo); // 解绑
/** 编译链接GPU程序 */
...
...
二、合批规则和原理
因此,为了减少上述的计算频率和冗余,我们需要进行渲染合批。单并不是所有纹理都能合批,目前了解至少需要满足以下条件。
1、使用同一张纹理
2、纹理颜色、透明度相同。(cocos高版本已经支持,不需要满足此条件)
3、纹理使用的材质相同
4、纹理GPU程序Shader相同。包括uniform参数一致
5、降低打断合批的情况。
原理是cocos通过上述多种对象和数据来计算最终的哈希值,根据这个哈希值来决定是否能够进行合批操作。
为了满足以上条件,我们要做的合图或者尽可能多地创造可以进行合图地条件(比如避免合批被打断)。
打断合批的原因主要有:
1、渲染顺序。CocosCreator访问节点树是深度优先的,也就是说不同父节点的纹理即使相同,也会被打断。
2、文字渲染。纹理中间插入了文字,那么也会被打断渲染批次。
3、材质参数不一致。
/** cc.Material 材质设置参数会重新计算hash值 */
getHash(){
if(!this._dirty)returnthis._hash;
this._dirty=false;
leteffect=this._effect;
lethashStr='';
if(effect){
hashStr+=utils.serializeDefines(effect._defines);
hashStr+=utils.serializeTechniques(effect._techniques);
hashStr+=utils.serializeUniforms(effect._properties);
}
returnthis._hash=murmurhash2(hashStr,666);
}
二、合批方式
目前CocosCreator支持多种合批方式。合成大图以后,程序用到的N张小图,如果都在这张大图中,那么提交的纹理地址都指向这张大图,属于同一个,也就利于合批。
1、静态合批
静态合批是指在游戏运行之前,就将图片打包成大图。
CocosCreator对大图操作比较友好。
1、提供了是否打大图的可选操作,并且提供预览。缺点是参数支持有限,比如小图间距不可调,不能保留透明边框等。
2、大图在编辑器中能够直接读取和操作小图,和合图之前操作差别不大,非常方便。
静态合图方式:
1、使用cocosCreator编辑器自带AutoAatlas合图。操作如下。
在需要合图的文件夹下新建一个自动图集配置即可。打包时会将该文件夹下的所有图片合成一张或多张大图。
2、使用TexturePacker打包大图。优点是功能很丰富,可以设定很多自定义参数。
下载地址: TexturePacker Download
2、动态合批
动态合批行为发生在程序运行中,程序会根据设定的规则,将小图写入大图目标纹理。在cocosCreator中主要有以下几种动态合批方式。
1、图片动态合批
(1)编辑器中的图片的属性检测面板中,勾选Packable选项,表明此图可以应用动态合批规则。
(2)程序中加入代码cc.dynamicAtlasManager.enabled = true;表明该程序允许进行动态合批操作。
执行以上操作后,程序就会在运行时将允许合图的小图按优化算法写入大图中。进而达到合批的目的。
但是要注意,动态合批有一定的规则,比如宽高不能超过一定值等。
2、自定义合批
除了cocosCreator自定义的合批方式外,我们还可以自己实现对任意节点树或关心的节点集进行自定义合图。
优点是可以突破不同层级、不同父节点的限制,缺点是会消耗一部分内存。
介绍两种比较简单的方式:
(1)渲染到纹理。利用OpenGL的FBO,存储到RenderTexture,然后使用RenderTexture代理原来节点的显示。在CocosCreator中,FBO的使用可以通过Camera的Render来实现。
(2)动态修改父节点和节点的顺序。理想的情况主要是将能够合批(见合批规则与原理)的节点放在一起,并将文字节点和图片节点分开放。满足引擎渲染队列广度遍历、以及避免文字会打断合批的情况。
在相对静态或对更改父节点不频繁、不影响游戏流程的情况下,可以采用。此过程可用Spector.js插件来查看实际渲染的内容是否符合预期。
(3)关于文字,CocosCreator已经提供了三种可选的动态合批模式,分别是None、BitMap、Char模式。其中None标识不合批,BitMap表示将使用到的每个Label转换成texture后,合到一张特定的大图中。Char表示使用到的单个文字转换成texture后,和到一张特定的大图中。由此可见,每个方式各有应用场景。
但是要注意的是,当文本数量或用到的单个字数量超过一定上限(即引擎内部大图不足以填充)的时候,就会出问题。这个可以通过修改该引擎内部的逻辑来优化这个问题。比如新文本texture替换未使用的文本位置的替换算法,复用大图空间。这个以后可以单独开专题讲。
下面重点看下渲染到纹理这种做法。
我们先实现基础截图组件FBOComponent类。
export class FBOComponent extends cc.Component {
/** 目标源节点 */
public _inTarget: cc.Node = null; // 摄像机会将该节点渲染到RenderTexture中
/** 缓存纹理对象 */
public _renderTexture: cc.RenderTexture = null; // RenderTexture对象
/** FBO摄像机 */
public _fboCamera: cc.Camera = null; // cocosFBO的API实现在相机中
/** 输出sprite */
public _outSprite: cc.Sprite = null; // 需要使用RenderTexture的精灵组件
/** 输出是否翻转Y */
public _isFlipY: boolean = true;
/** 输出目标的原始scaleY */
public _scaleY: number = 1;
/** 是否使用外部renderTexture */
public _useNewTexture: boolean = false;
/** 是否每帧绘制 */
public _frameDraw: boolean = true;
/** 是否采用late绘制 */
public _useLate: boolean = true; // 为了数据同步,需要在lateUpdate中进行Camera的Render操作
/** 仅绘制一帧时,是否可以绘制 */
public _canOnceDraw: boolean = false; // 某些情形只需要Render一次,提升性能(例如静态或更新不频繁的页面)
默认在lateUpdate中进行RenderTexture绘制。
lateUpdate(): void {
if (this._useLate) {
if (this._frameDraw) {
this.updateFBO();
}else if (this._canOnceDraw) {
this._canOnceDraw = false;
this.updateFBO();
}
}
}
updateFBO() {
this.onFBOUpdateBegin();
if (this._fboCamera) {
if (!this._renderTexture && !this._useNewTexture) {
this._renderTexture = new cc.RenderTexture();
this._renderTexture.initWithSize(this._inTarget.width, this._inTarget.height, cc["gfx"].RB_FMT_D24S8);
this._fboCamera.targetTexture = this._renderTexture;
}
if (!this._renderTexture) {
return;
}else {
if (Math.floor(this._renderTexture.width) != Math.floor(this._inTarget.width) || Math.floor(this._renderTexture.height) != Math.floor(this._inTarget.height)) {
this._renderTexture.updateSize(this._inTarget.width, this._inTarget.height);
}
}
this._fboCamera.enabled = true;
this._fboCamera.render(this._inTarget);
this._fboCamera.enabled = false;
if (this._outSprite) {
this._outSprite.spriteFrame = this._outSprite.spriteFrame || new cc.SpriteFrame();
this._outSprite.spriteFrame.setTexture(this._renderTexture);
this._outSprite.node.scaleY = this._isFlipY ? -this._scaleY : this._scaleY;
}
}
this.onFBOUpdateEnd();
}
以上关键代码实现了将任意节点树转换成RenderTexture并更新至目标显示Sprite组件的功能。可以用于需要频繁或每帧进行render的情况。
例如2d游戏中,在存在多障碍物的情况向,移动时,每帧绘制阴影区域的的黑色部分到一张texture中,就可以利用该FBOComponent组件来实现。
需要注意的是,这里没有同步目标节点位置、宽高等,默认是一样的不变的。
那么,我们现在注意到_canOnceDraw这个成员变量,这个成员变量是用来控制单次render功能的。也是用于一些仅需合批一次或按需render的情况。
例如很多行文字的任务列表。只有在收到新任务或完成任务等状态发生变化的时候,才需要重新render一次。
这个时候有可能位置宽高都发生了变化。我们来实现少量绘制和自同步的功能。
以下实现BitmapCacheComponent组件类。它继承于FBOComponent,且实现了drawOnece()方法和自动同步真正用于目标显示的大小、位置等属性。
还可以按自己shader需求可以实现自定义材质属性。
export class BitmapCacheComponent extends FBOComponent {
/** bitmap父节点 可选参数 */
public _bitmapParent: cc.Node = null; // 要将真正显示的Sprite放在哪个父节点
/** 是否启用修复 可选参数 */
public _enableFix: boolean = false;
/** 材质数组 可选参数 */
public _materials: { // 真正显示的Sprite 需要绑定的自定义材质
index: number,
url: string,
script?: typeof ShaderScript,
matParams: any,
}[] = [];
...
}
开始和结束render时的回调函数。在这里进行属性同步。
protected onFBOUpdateBegin() {
super.onFBOUpdateBegin();
this._outSprite = this._outSprite || this.initBitMapSprite(this._bitmapParent || this.node.parent);
this._outSprite.node.width = this.node.width;
this._outSprite.node.height = this.node.height;
this._outSprite.node.anchorX = this.node.anchorX;
this._outSprite.node.anchorY = this.node.anchorY;
this._outSprite.node.x = this.node.x;
this._outSprite.node.y = this.node.y;
this._outSprite.node.opacity = 0;
this.node.opacity = 255;
}
protected onFBOUpdateEnd() {
if (this._outSprite) {
let parent = this._outSprite.node.parent;
let wpos = this.node.convertToWorldSpaceAR(cc.Vec2.ZERO);
let lpos = parent.convertToNodeSpaceAR(wpos);
this._outSprite.node.x = lpos.x;
this._outSprite.node.y = lpos.y;
this._outSprite.node.width = this.node.width;
this._outSprite.node.height = this.node.height;
this._outSprite.node.anchorX = this.node.anchorX;
this._outSprite.node.anchorY = this.node.anchorY;
this.node.opacity = 0;
this._outSprite.node.opacity = 255;
}
if (this._enableFix) {
this.tryFixBitMap();
}
if (this._cacheCallback) {
this._cacheCallback();
}
super.onFBOUpdateEnd();
}
在外部,我们如下使用我们的BitMap组件。
// this.missionContent 任务文字列表容器cc.Layeout
let bitmapCacheComp = this.missionContent.node.addComponent(BitmapCacheComponent); // 创建Bitmap组件
this.bitmapCacheComp._bitmapParent = this.bitmapLayer; // 指定父节点
然后在合适的时机(例如初始化或任务状态变化时)进行render。
this.bitmapCacheComp?.drawOnce();
以上就实现了N行文本合成一张texture来显示的功能,达到了合批的目的。draw也从N变成了1个。
当然,不仅是文本,可以render任意可渲染节点。
关联文章链接:
CocosCreator游戏性能优化(1):性能分析工具
CocosCreator游戏性能优化(3):GPU优化之降低计算分辨率