性能优化1-列表渲染优化|社区征文

开头

去年底的时候,由于希望在性能优化方面做一些研究,在论坛到处找文章,找到了一些很有启发的思路,其中包括江南百景图的技术分享文章。目前均已实现,正在尝试产出文章。后期也会运用到目前的项目中。
性能优化系列会有多篇文章,目前规划如下(不分先后):

  • 地图物件多图渲染合批
  • 地图图层Share Culling
  • 材质颜色去除
  • 分帧寻路
  • 列表渲染优化

其中,除了列表渲染优化以外,都源于江南百景图的分享文章。
本文提及的技术优化未投入实际开发环境,使用前请自行斟酌。

词语缩写对照

层级渲染:指本文提出的渲染方式。
层级优先:指本文提出的子节点遍历顺序。

优化简介

问题

关于列表优化的常见方法是循环列表,此方法可以大量减少对象数量以及渲染数量,已经可以达到非常不错的优化效果了。但如果列表项内容比较复杂,图片还是不同图集,一个列表可能要占掉几十个dc,突然就觉得不能忍了。
为什么会这样呢?cocos creator的渲染流程中,会先渲染父节点,然后逐个渲染子节点。简单讲:深度优先。这在渲染列表的时候有个问题,设列表子项的节点为N1、N2,每个列表项有三个子节点,子节点各有一个Sprite组件,设子节点们分别为C1_1、C1_2、C1_3、C2_1、C2_2、C2_3。
image
按照默认的渲染顺序是这样的:N1->C1_1->C1_2->C1_3->N2->C2_1->C2_2->C2_3。
image
若Sprite的SpriteFrame分别归属不同图集,则需要6次DC(2 * 3),即若有n个子项,需要3n次DC。但是C1_1和C2_1明明是一个图集,却被其他子节点打断了。

如何处理

如果能够跨列表项,将同层级的子节点一起渲染,不就可以避免这个问题了吗?能不能渲染顺序改成N1->N2->C1_1->C2_1->C1_2->C2_2->C1_3->C2_3,这样就只要3次DC,重要的是,不论有多少列表项,都只需要3次DC!
image
即按照蓝->棕->浅蓝->绿的顺序渲染。
可以做到吗?(废话不然写屁文章)

优化效果



如图,创建一千个列表项,每个列表项包含三个子节点,每个子节点一个Sprite。
默认渲染下需要1001次dc,多的1次dc是左下角的监视器。优化后只需要4次dc。
当然,实际情况下并不会有一千个列表项。但这里是为了明显地体现出差距。

开发环境

浏览器:Chrome
开发语言:JavaScript
引擎版本:CocosCreator 2.4.3

实现思路

最简单的做法:替换引擎中渲染流相关代码,将深度优先遍历改为层级优先遍历即可。
但这样会影响游戏中所有节点的渲染。所以我们需要一个稍稍复杂一点的方案。
稍稍复杂一点:标记出特殊的列表节点,修改引擎中的相关代码,支持层级优先遍历。
在进行层级遍历时,需要分类每个层级的节点,才能在遍历完之后,进而遍历子节点。这里有两个方案:

  1. 一开始就记住所有的节点(包括子节点),根据层级分类,渲染时直接读取即可。
  2. 在渲染时才进行遍历。

各有优缺点,方案1由于缓存在渲染时会更快速,但在列表项进行了增加/删除时,需要同步更新列表,相反地,方案2慢一些但不需要维护列表。
本文采用方案1。

代码

代码分为两部分:

  1. 创建一个RenderList组件,用于标识层级渲染节点,并缓存子节点数组。
  2. 修改渲染流函数children,支持层级渲染。

RenderList.js

cc.Class({
    extends: cc.Component,
    
    properties: {
    },
    
    onEnable () {
        this.refreshChildNodeInfo();
    },
    
    /**
     * 将节点的所有子节点按层级优先展开,并缓存到this.node._renderChildren
     */
    refreshChildNodeInfo () {
        const levels = [];
        let level = 0;
        
        const dfs = function (node) {
            levels[level] = levels[level] || [];
            levels[level].push(node);
            if (node.children.length) {
                for (let child of node.children) {
                    dfs(child, ++level);
                }
            }
        }
        
        for (let child of this.node.children) {
            dfs(child);
            level = 0;
        }
        
        this.node._renderChildren = [].concat(...levels);
    },
});

render-flow.js

_proto._children = function (node) {
    let cullingMask = _cullingMask;
    let batcher = _batcher;
    
    let parentOpacity = batcher.parentOpacity;
    let opacity = (batcher.parentOpacity *= (node._opacity / 255));
    
    let worldTransformFlag = batcher.worldMatDirty ? WORLD_TRANSFORM : 0;
    let worldOpacityFlag = batcher.parentOpacityDirty ? OPACITY_COLOR : 0;
    let worldDirtyFlag = worldTransformFlag | worldOpacityFlag;
    
    // 修改开始
    // 子节点列表 是否为层级渲染
    let children, isHierarchyRenderList;
    if (node._renderChildren) {
        isHierarchyRenderList = true;
        children = node._renderChildren;
    } else {
        children = node._children;
    }
    // 修改结束
    
    for (let i = 0, l = children.length; i < l; i++) {
        let c = children[i];
        
        // 修改开始
        // 取消子节点的chilren渲染 重新计算透明度值
        if (isHierarchyRenderList) {
            c._renderFlag &= ~CHILDREN;
            opacity = c.parent._opacity / 255;
        }
        // 修改结束
        
        // Advance the modification of the flag to avoid node attribute modification is invalid when opacity === 0.
        c._renderFlag |= worldDirtyFlag;
        if (!c._activeInHierarchy || c._opacity === 0) continue;
        
        _cullingMask = c._cullingMask = c.groupIndex === 0 ? cullingMask : 1 << c.groupIndex;
        
        // TODO: Maybe has better way to implement cascade opacity
        let colorVal = c._color._val;
        c._color._fastSetA(c._opacity * opacity);
        flows[c._renderFlag]._func(c);
        c._color._val = colorVal;
        
        // 修改开始
        // 恢复子节点的children渲染
        if (isHierarchyRenderList) {
            c._renderFlag |= CHILDREN;
        }
        // 修改结束
    }
    
    batcher.parentOpacity = parentOpacity;
    
    this._next._func(node);
};

当节点是层级渲染节点时,获取缓存的子节点列表,然后遍历就完事了。
需要注意的是,当子节点层级超过1的时候,如:列表项/图片1/图片2,此时图片2的透明度值是不准确的,因为此时透明度计算法是列表项的透明度图片2的透明度,跳过了图片1。将其改为图片2的父节点透明度图片2的透明度就好了。
至于要取消子节点的children渲染的原因,是因为我们会在这里将所有子节点全部遍历一遍,所以也不需要本来的children渲染了。

另外

由于render-flow.js代码位于core文件夹内,所以编译起来有点麻烦,我其实没有做这一步,只修改了cocos2d-js-for-preview.js验证。理论上,你可以找到render-flow.js的位置,并修改对应函数代码如下:

_proto._children = function(node) {
    var cullingMask = _cullingMask;
    var batcher = _batcher;
    var parentOpacity = batcher.parentOpacity;
    var opacity = batcher.parentOpacity *= node._opacity / 255;
    var worldTransformFlag = batcher.worldMatDirty ? WORLD_TRANSFORM : 0;
    var worldOpacityFlag = batcher.parentOpacityDirty ? OPACITY_COLOR : 0;
    var worldDirtyFlag = worldTransformFlag | worldOpacityFlag;
    var children, isRenderList;
    
    // 修改开始
    if (node._renderChildren) {
        isRenderList = true;
        children = node._renderChildren;
    } else {
        children = node._children;
    }
    // 修改结束
    
    for (var i = 0, l = children.length; i < l; i++) {
        var c = children[i];
        // 修改开始
        if (isRenderList) {
            c._renderFlag &= ~CHILDREN;
            opacity = c.parent._opacity / 255;
        }
        // 修改结束
        c._renderFlag |= worldDirtyFlag;
        if (!c._activeInHierarchy || 0 === c._opacity) continue;
        _cullingMask = c._cullingMask = 0 === c.groupIndex ? cullingMask : 1 << c.groupIndex;
        var colorVal = c._color._val;
        c._color._fastSetA(c._opacity * opacity);
        flows[c._renderFlag]._func(c);
        c._color._val = colorVal;
        // 修改开始
        if (isRenderList) {
            c._renderFlag |= CHILDREN;
        }
        // 修改结束
    }
    batcher.parentOpacity = parentOpacity;
    this._next._func(node);
};

总结

本文提供的方案仍然有一些缺点,比如:

  1. 列表项的子节点的数量、节点树位置需要一致。

也有一些进一步优化的空间,比如:

  1. 方案可以改成:新建一个渲染流函数(如flow._children2),避免在普通节点的children函数中还要一直去判断是否有层级渲染的标记。

注:本文思路源自于论坛中某个帖子,但写文章时没找到= =…该文章中实现了一个新的渲染流函数。没法附链接只好写在这里表示感谢。
本文章也发布于本人语雀文档

7赞

:rofl:
翻自己的浏览记录找到了那个帖子!
UI批量渲染优化
还有另一个也不错的帖子:
【分享】利用PostRender实现分层合批渲染(附 Demo 和引擎源码解读)

给大佬点赞!

大佬流弊,Mark一下

MARKK!

您好,我改了cocos2d-js-for-preview.js但是为什么运行起来没反应呢

你好。可能需要描述的更详细些我才好了解问题。
比如有没有按照流程修改其他内容?是否在浏览器运行?运行起来状态是什么?没有渲染任何东西?报错?或者其他之类的

已经成功处理了,一开始是改错地方了 :+1:

mark.

你好,使用这种方式,子节点修改opacity以及scale这种都会无效了是吗?

不会的,根据你的处理方式兼容原本的透明度计算就可以了。
比如上面的代码有提到,将透明度改为取渲染节点的父节点。
缩放我想,应该和这部分的代码没有关系,是出问题了吗?

我是后来在代码中动态修改透明度,会出现没反应,需要active变为false再变为true才有变化,然后有些子节点的scale缩放也会出现问题

子节点不渲染,然后导致父节点增加点击事件带缩放效果的时候,子节点没有缩放效果

我测试了一下,用代码修改透明度是没有问题的。可能需要检查一下你的相关逻辑。
不过缩放会有问题,父节点缩放的时候,子节点不会跟着缩放,这个部分我再研究一下源码。

我确认问题了。是由于改了渲染流程导致的。

  1. TRANSFORM(包含缩放)的更新是优先于CHILDREN的
  2. children的逻辑中,有一个变量(worldTransformFlag),当存在batcher.worldMatDirty时,变量值为true,此时子节点会执行_worldTransform。

原本的渲染流是parent->child。父节点执行_worldTransform函数的时候,会把batcher.worldMatDirty+1,等执行完所有后续渲染流之后,再-1。后续渲染流中包含了children,所以只要父节点执行了_worldTransform,子节点也会执行

修改之后的渲染流,一样是parent->child,但是,我们不是dfs了,父节点增加的worldMatDirty值,在渲染子节点的时候,已经扣掉了

确认问题后,一下子没想到好的解决办法。目前我的处理方式是,重写Node里的setScale函数,当节点是列表渲染节点时,遍历所有子节点,为他们增加_worldTransform渲染流程。例:

let func = cc.Node.prototype.setScale;

cc.Node.prototype.setScale = function () {

    func.call(this, ...arguments);

    if (this._isRenderListNode) {

        setChildWorldTransformDirty(this);

    }

}

let setChildWorldTransformDirty = function (node) {

    for (let child of node.children) {

        child._renderFlag |= cc.RenderFlow.FLAG_WORLD_TRANSFORM;

        setChildWorldTransformDirty(child);

    }

}

我这边测试是透明度有问题,我后来改成在改透明度时当前节点开启Children渲染,过一会再按层级渲染,只是active一变,当前节点透明度没问题,但是子节点不会透明

我目前的方式也是有属性改变时临时开起来子节点渲染,后续再关闭,按照层级渲染,我试一试这种方式