【乐府】突破动态合图-你真的把动态合图用对了吗?

原文地址:突破动态合图

1. 动态合图的默认规则

引擎中对动态合图的描述如下:
它能在项目运行时动态的将贴图合并到一张大贴图中。当渲染一张贴图的时候,动态合图系统会自动检测这张贴图是否已经被合并到了图集(图片集合)中,如果没有,并且此贴图又符合动态合图的条件,就会将此贴图合并到图集中。动态合图是按照 渲染顺序 来选取要将哪些贴图合并到一张大图中的,这样就能确保相邻的 DrawCall 能合并为一个 DrawCall(又称“合批”)。

若希望强制打开动态合图,只需要增加以下代码:
cc.macro.CLEANUP_IMAGE_CACHE = false;
cc.dynamicAtlasManager.enabled = true;

限制条件:默认只有贴图宽高都小于 512 的贴图才可以进入到动态合图系统。用户可以根据需求修改这个限制:
cc.dynamicAtlasManager.maxFrameSize = 512;

支持动态合图的渲染组件:Sprite、Label(BITMAP模式)。

注意事项:在场景加载前,动态合图系统会进行重置,SpriteFrame 贴图的引用和 uv 都会恢复到初始值。

查看方法:通过以下代码可以在游戏中看到所有动态合图会被加到一个scrollview上,便于实时查看合图效果。
cc.dynamicAtlasManager.showDebug(true);

其它规则(文档里未直接说明的):
a> 动态合图最大张数为5张,使用完后会强制重建。
b> 单张合图的大小为2048*2048。
c> 贴图的多个属性设置为非默认值会影响合批(FilterMode,genMipmaps,premultiplyAlpha,flipY,wrapS,wrapT,pixelFormat等)
d> 如果参与合图的两个渲染组件A和B被另一个未合图的渲染组件C隔开,那么这两个A和B并不会在一个drawcall里渲染。
e> 如果一直不切换场景,那么随着动态合图的数量增长,渲染效率可能会降低,适得其反。

2. 通过实例来理解动态合图的使用

这里使用官方文档中给到一个实例(暗黑斩https://github.com/cocos-creator/tutorial-dark-slash)来测试动态合图的效果。

注意事项:
由于该项目中把所有贴图的FilterMode设置为Point模式了,因此首先我们把这个全部改为Bilinear。

首先关闭动态合图,查看运行一内个场景时的drawcall数量分别是:
StartGame:37
PlayGame:38

然后打开动态合图,我们在两个场景下分别查看合图效果如下:

(图1)

(图2)

drawcall数量分别如下:
StartGame:9
PlayGame:25

可以看到在不同的场景下,参与合图的纹理都被合到一张大图上了,drawcall确实提高了很多。

3. 增加需求

上面这个示例只是一个非常简单的DEMO,实际游戏中的使用需求远比上面这个复杂,主要有以下几个实际问题是我们要去考虑的:

a> 游戏中只有一个场景,也就是说动态合图不会被重建。
b> 已经合图的资源被释放后,下次图集上的纹理还能否使用?
c> 如果在重建前出现多图集的情况下会有哪些影响?

那么我们分别来尝试满足以上条件时,动态合图还能否保持高效率。

3.1 保持动态合图不被重建

由于DEMO中有两个场景,为了不改动游戏逻辑,我们直接改引擎,在切换场景时直接不要重建动态合图。代码如下:

// manager.js
function beforeSceneLoad () {
    //dynamicAtlasManager.reset();
}

然后测试此时的动态合图效果:

(图3)

如上图所示:所有符合合图条件的纹理都合并到一张图集上,drawcall依次为9和25。

3.2 增加资源释放

在DEMO中切换场景时,增加资源释放代码:

// HomeUI.js
playGame: function () {
        cc.eventManager.pauseTarget(this.btnGroup, true);
        let self = this
        cc.director.preloadScene('PlayGame', null, function(error, newScene){
            res.cleanRes(self.node, newScene)
            cc.director.loadScene('PlayGame')
        })
        // cc.director.loadScene('PlayGame', function(error, newScene){
        //     res.cleanRes(self.node, newScene)
        // });
    }

// Game.js: 战斗结束时,点取消按钮回到HomeUI场景
gameOver: function () {
        this.deathUI.hide();
        this.gameOverUI.show();

        let self = this
        // cc.director.loadScene('StartGame', function(error, sceneAsset){
        //     res.cleanRes(self.node, sceneAsset)
        // });
        cc.director.preloadScene('StartGame', null, function(error, newScene){
            res.cleanRes(self.node, newScene)
            cc.director.loadScene('StartGame')
        })
    },

// res.js
let res = {
    cleanRes:function( oldScene, newScene ) {
        let scene = cc.director.getRunningScene()
        let oldDeps = scene.dependAssets
        let newDeps = cc.loader.getDependsRecursively(newScene)
        let length = oldDeps.length
        for (let index = length - 1; index >= 0; --index) {
            let dep = oldDeps[index]
            if (newDeps.indexOf(dep) >= 0) {
                oldDeps.splice(index, 1)
            }
        }

        cc.loader.release(oldDeps)
    },
}

module.exports = res;

运行游戏,执行StartGame->PlayGame->StartGame->PlayGame->StartGame的这个流程后,再看动态合图的效果如下:

(图4)

注意:这时候,动态合图里面出现了两张动态图集:
第一张被使用完全,第二张使用到了一部分。

此时的drawcall相比没有释放资源时,有时会增加了1。分别是10和26。

很明显,此时drawcall增加的原因是,部分资源在第一个图集上,部分资源在第二个图集上,相比只有一张图集时,drawcall就增加了1个。

3.3 出现多张图集时的影响

正如3.2所示,出现多张图集时,主要有以下影响:

a> 图集数量的增加消耗了内存。
b> 图集数量的增加导致drawcall可能会升高,图集数量越多,影响越大。

4. 优化思路

综合以上DEMO中的尝试,我们可以得出结论:

如果要在实际项目中从效率和性能兼顾的方向来使用动态合图,显然当前的这个机制是不符合要求的,那么主要解决哪些问题?我认为有以下两个:

a> 只要原始贴图参与过合图,不论后来是否被释放,下次能直接使用合图中已经有的纹理来渲染,而不必再占用新的图集空间。
b> 控制总的动态图集数量,最好不能超过3张,最好在2张(含)以内。

4.1 重复利用合图空间

为了能够重复利用合图的空间,我们需要明白为什么同一个资源被释放后,它在合图空间中的纹理不能再次被使用了。我们通过阅读源码可以找到答案:

// atlas.js
insertSpriteFrame (spriteFrame) {
    let rect = spriteFrame._rect,
    texture = spriteFrame._texture,
    // 合图记录是通过纹理的_id值来查找的。
    info = this._innerTextureInfos[texture._id];

    ......
}

// CCTexture2D.js
ctor () {
    // 生成id的方法
    this._id = idGenerater.getNewId();

    ......
}

// id-generate.js
function IdGenerater (category) {
    // init with a random id to emphasize that the returns id should not be stored in persistence data
    this.id = 0 | (Math.random() * 998);
    
    this.prefix = category ? (category + NonUuidMark) : '';
}

IdGenerater.prototype.getNewId = function () {
    return this.prefix + (++this.id);
};

通过以上代码,我们可以知晓,所有的纹理加载时生成的_id都是唯一的且其中的数字部分是自增的。因此即使是同一个资源,释放之后再加载到内存时,它的_id与之前不一样。因此参与合图时即使已经合过,也找不到记录了。

既然找到了症结所在,那么解决办法也很简单,把_id改成一个能与当前贴图唯一对应的标识即可,显然,这个标识就是texture._uuid(这里不做解释,阅读引擎源码能找到答案)。

把insertSpriteFrame方法中texture._id改为texture._uuid后,测试执行StartGame->PlayGame->StartGame->PlayGame->StartGame的这个流程后,动态合图效果如下:

(图5)

drawcall依然保持是9和25。

4.2 提高图集利用率

从图5可以看到,虽然所有纹理都集中合到一张图集上,但是这个图集里面有太多的空白区域,不会被利用到。如果能利用到一整张图集的所有空间,那么可以提高单张图集上同时合并的纹理数量,间接也提高了drawcall。

因此我们可以想到,如果能按照TexturePacker的合图方式来做动态图集的合图,那么无疑是比较理想的。当然这是可以的,我们可以自己写一个算法来计算合图时的位置和空间。也可以找第三方的算法。这里我推荐网上一个MaxRect的开源代码(c++版本,直接翻译成js版本即可)。代码我就不贴了,直接看使用这个算法的结果:

(图6)

4.3 控制图集数量

客观来说,一个游戏用几张动态图集是不确定的。但是可以确定的是,我们需要哪些贴图被合并进动态图集。这要从我们的目的来着手。动态图集解决的是降低drawcall的目的。因此以下情况是考虑是否要让贴图参与动态合图的重要因素:

a> 贴图size较大,接近512*512或者你设置的最大size。

b> 贴图符合合图要求,但使用频次较低。

c> 贴图是否会打断需要批量渲染的组件。

d> 贴图参与合图后能否显著提高当前界面drawcall。

相反,我们真正需要动态合图的需求是:

a> 复用结构中的小尺寸贴图。

b> 高频次使用的小尺寸贴图。

c> 常用列表上的公共背景贴图。

d> 高频次使用的TTF的小图集。

最后,一定不要参与合图的是:

a> BITMAP模式的低频次Label:纯粹浪费合图空间。

b> 单一使用的背景框。

c> 非主角相关的icon贴图。

d> 其它低频次的贴图。

通过以上的一些限制条件来控制游戏中参与合图的贴图后,如果能把图集控制在3张以内。那么drawcall可以在稳定的较高的水平。同时,渲染过程中合图的工作可以省掉大部分。

TIPS:想获得最新的文章推荐,请关注公众号“乐府札记”


关于乐府互娱

乐府互娱成立于2019年,是一家专注于精品移动游戏研发和运营的明星初创企业。

公司核心团队是《少年三国志》《少年西游记》系列作品的原班人马,其中制作人、策划、技术、美术、UI、发行等模块核心成员已共事多年,拥有成熟的研发产品体系和管理体系。

团队长期深耕卡牌手游等品类,具备敏锐的嗅觉和高效的研运能力,以长线游戏研发运营著称,打造过数款月流水过亿的产品,包括《少年三国志》《少年西游记》等,游戏累计流水近100亿元。

公司在成立之初即获得资本市场高度认可,目前已完成天使轮和A轮融资。短期内,乐府将结合自身优势,继续致力于中轻度卡牌手游的深耕细作,保持在该领域的头部地位。

在长期,乐府会重点聚焦于“游戏工业化”之路,在自研自发的前提下,继续打造经典游戏IP。

19赞

大佬V587

真香系列···nice···

乐府牛逼~~~~~~~~·

乐府牛逼~~~~~~~~·

乐府每篇都好硬核啊

强无敌!!!

引擎这么改,每次升级版本,都得再改一遍,很难瘦

把改动过的做好标签,单独放在一个项目目录做版本管理,升级引擎时,直接拷贝过去覆盖即可!

牛逼 动态河图的原理又过了一遍脑子

NB,乐府的文章确实硬核,学到了。

nb, 太硬核了, 都是干货, 大佬666

大佬你好,请教一下你们在原生平台有用动态合图吗?目前引擎的设定是如果贴图本身有压缩,比如etc2之类的,那么就不会进行动态合图。这个问题你们是怎么处理的?

js 版本我找到了一个这个库
https://github.com/soimy/maxrects-packer

这个可以用,我仿照这个写了个简化版,效果挺好

先赞后MARK.

动态合图合出来的图在哪里可以查看呀

在chrome的console里面直接输入

cc.dynamicAtlasManager.showDebug(true)

好的 多谢

你好,可以分享一下你的改法吗