性能优化2-TiledMap地图优化-裁剪区域共享(Share Culling)

前言

地图在许多游戏中都是一个重要的部分,在我的项目中跑图更是一个重要的玩法。一张超级大的地图在渲染的时候毫无疑问是个大问题,当然,Cocos已经帮我们给出了解决方案:裁剪渲染,只渲染可视范围内的图块。即根据当前屏幕的可视范围进行裁剪,算出需要渲染的格子,避免每次都是全地图渲染,以此提升性能。此功能是默认开启的,相关说明可查看文档

去年底的时候,由于希望在性能优化方面做一些研究,在论坛找到了江南百景图研发负责人的技术分享文章,其中提到:

2. Share Culling: 《江南百景图》共有三层 TiledMap 地图层, 勾选时 将只对 TiledMap 的第一个地图层进行处理判断可视区域的范围,而其他的地图层将直接照搬第一个地图层的处理结果,这样能够节约不少性能。

本文参照文章中的思路实现了这个优化。

开发环境

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

词语缩写对照

分享文章:《江南百景图》的分享文章
裁剪函数:指TiledLayer中的_updateCulling函数。
Share Culling:不同TiledLayer间,共享可视区域的裁剪计算结果。

研究过程

研究相关源码后,发现其实TiledMap不是渲染组件,TiledMap的渲染其实是通过TiledLayer实现的,对应的渲染器是TmxAssembler。

渲染时,渲染流会逐个调用TmxAssembler的fillBuffers函数进行渲染数据填充,此函数中会调用CCTiledLayer的_updateCulling函数进行可视范围,只有可视范围发生改变时才进行渲染,这也能节约性能。

问题在哪里呢?

每一帧,每一个TiledLayer都需要执行_updateCulling函数来计算可视范围。

一般来说,我们可能会移动地图的位置,但不会单独移动某个图层的位置,此时图层的可视范围是一致的。但实际上,每个图层都需要重新计算,加上裁剪函数每帧都要调用,会产生没有必要的性能浪费,多个图层其实可以只计算一次

从实现难度上来看,只执行第一个图层的计算是比较简单的。

实现思路

综上,实现Share Culling可拆分为如下步骤:

  1. 记录首个TiledLayer。
  2. 修改TiledLayer裁剪函数,增加判断是否为首个TiledLayer。
    1. 是:进行裁剪计算。
    2. 否:取首个TiledLayer的计算结果。

实现代码解析

看完方案,肯定是要修改源码了。但由于改动的代码都在cc.TiledMap和cc.TiledLayer中,我们除了修改引擎源码,还可以通过Cocos提供的继承功能实现自己的TiledMap和TiledLayer组件,重写对应的函数实现Share Culling。由于源代码较长,以下代码中只保留了做出修改的部分。

demo中,继承TiledMap和TiledLayer的自定义组件分别命名为ShareCullingTiledMap和ShareCullingTiledLayer。

ShareCullingTiledMap

TiledMap的_buildLayerAndGroup函数用于创建TiledLayer,所以我们需要重写该函数,实现创建自定义的ShareCullingTiledLayer,并记录首个TiledLayer的功能。

_buildLayerAndGroup () {
    // 此处修改
    let firstTmxLayer = null;
    // 修改结束

    if (layerInfos && layerInfos.length > 0) {
        for (let i = 0, len = layerInfos.length; i < len; i++) {
            if (layerInfo instanceof cc.TMXLayerInfo) {
                // 此处修改 改为创建ShareCullingTiledLayer
                let layer = child.getComponent(ShareCullingTiledLayer);
                if (!layer) {
                    layer = child.addComponent(ShareCullingTiledLayer);
                }
                // 修改结束

                // 此处修改 传递firstTmxLayer 记录firstTmxLayer
                layer._init(layerInfo, mapInfo, tilesets, textures, texGrids, firstTmxLayer);
                firstTmxLayer = firstTmxLayer || layer;
                // 修改结束

                // tell the layerinfo to release the ownership of the tiles map.
                layerInfo.ownTiles = false;
                layers.push(layer);
            }
        }
    }
},

ShareCullingTiledLayer

我们需要重写TiledLayer的_init函数,来保存firstTmxLayer

// 此处修改 增加firstTmxLayer参数
_init (layerInfo, mapInfo, tilesets, textures, texGrids, firstTmxLayer) {
// 修改结束
    this._cullingDirty = true;
    this._layerInfo = layerInfo;
    this._mapInfo = mapInfo;
    // 此处修改 保存firstTmxLayer参数
    this._firstTmxLayer = firstTmxLayer;
    // 修改结束
}

最后重写TiledLayer的裁剪函数,实现复用裁剪区域的功能。

_updateCulling () {
    // 此处修改 若不为首个TiledLayer 直接复用firstLayer的结果
    // this._firstTmxLayer不为空时 表示当前layer不是首个layer
    let firstTmxLayer = this._firstTmxLayer;
    if (!!firstTmxLayer) {
        this._cullingRect = firstTmxLayer._cullingRect;
        this._cullingDirty = firstTmxLayer._cacheCullingDirty;
        return;
    }
    // 修改结束

    this.node._updateWorldMatrix();
    cc.Mat4.invert(_mat4_temp, this.node._worldMatrix);
    let rect = cc.visibleRect;
    let camera = cc.Camera.findCamera(this.node);
    if (camera) {
        _vec2_temp.x = 0;
        _vec2_temp.y = 0;
        _vec2_temp2.x = _vec2_temp.x + rect.width;
        _vec2_temp2.y = _vec2_temp.y + rect.height;
        camera.getScreenToWorldPoint(_vec2_temp, _vec2_temp);
        camera.getScreenToWorldPoint(_vec2_temp2, _vec2_temp2);
        cc.Vec2.transformMat4(_vec2_temp, _vec2_temp, _mat4_temp);
        cc.Vec2.transformMat4(_vec2_temp2, _vec2_temp2, _mat4_temp);

        this._updateViewPort(_vec2_temp.x, _vec2_temp.y, _vec2_temp2.x - _vec2_temp.x, _vec2_temp2.y - _vec2_temp.y);
        // 此处修改 若为首个TiledLayer 缓存_cullingDirty。
        // _cullingDirty会在填充渲染数据后被改为false 所以需要缓存这里的结果
        if (!firstTmxLayer) {
            this._cacheCullingDirty = this._cullingDirty;
        }
        // 修改结束
    }
}

结束。Share Culling实现起来并不麻烦,但效果是显著的

属性面板开关实现

在分享文章中,Share Culling功能被做成了面板上的一个开关,可以选择是否开启。由于并不是复杂的样式,实现起来其实非常简单。
image
首先在ShareCullingTiledMap中声明shareCulling属性。

properties: {
    shareCulling: false,
}

之后在_buildLayerAndGroup函数中增加对应的判断即可。

_buildLayerAndGroup () {
    if (layerInfos && layerInfos.length > 0) {
        if (layerInfo instanceof cc.TMXLayerInfo) {
            // 此处修改 传递firstTmxLayer 记录firstTmxLayer
            layer._init(layerInfo, mapInfo, tilesets, textures, texGrids, firstTmxLayer);
            if (this.shareCulling) {
                firstTmxLayer = firstTmxLayer || layer;
            }
            // 修改结束
        }
    }
}

Cocos提供了开放的接口,我们可以简单地实现多种面板样式,甚至可以直接自定义属性面板界面,根据自己的需求做出各种特殊功能组件。自定义面板相关内容可以查阅Cocos文档

优化效果

测试用例


一张有四个图层的地图。2020网格,图块大小6464。

效果对比


输出中,“===”每帧打印一次。截图中是游戏开始的前几帧的输出,不含地图进行拖动时的情况。

每帧中,通过console.timeEnd()依序输出四个图层的_updateCulling函数耗时。

运行耗时并不是准确可信的,但还是可以看到优化后,图层2、3、4的耗时显著减少。结论是可想而知的,因为原本需要运算的代码被直接跳过了

总结

  1. Share Culling适用于大部分项目,且在图层数量增加时提升明显 O(n) -> O(1)。
  2. Share Culling只对图块图层生效,对象图层需要其他优化方式,比如分享文章中提到的场景剔除

性能优化系列会有多篇文章,目前规划如下(不分先后):

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

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

相关链接

demo源码地址
如何重绘「江南百景图」?近300页 PPT 免费分享!
TiledMap 组件参考
CCClass 进阶参考
使用 cc.Class 声明类型
属性参数参考
扩展 Inspector

19赞

赞大佬 :grinning:

先赞再看,是好习惯!

mark!!

大佬, 你说提供cocos的继承功能可以不用修改引擎代码,我重写完CCTiledMap.js 和 CCTiledLayer.js 两个文件后,怎么使用呢。直接将自己写的脚本挂载成组件吗

是的。如果你继承了TiledMap组件,那就在项目里用你的组件替换原本的TiledMap组件。
TiledLayer本身并不会在项目里直接挂载为组件,只要修改对应的创建逻辑就可以。

牛啊牛啊 大佬

太强了 :+1: :+1:

引擎组看了都开心到起飞了

mark一下