分享MultiTexture实现过程中的心得(兼容web与native、支持Sprite不同渲染Type、支持Cocos自动图集与动态合图的纹理、支持动态修改合批的纹理)

来自于此贴( 如何重绘「江南百景图」?近300页 PPT 免费分享! )分享的MultiTexture的思路,论坛已经有很多人给了实现思路和源码了,就不在此赘述,这里主要谈一谈我在以我的需求实现此功能时遇到的一些问题。

我的需求

在做这个功能时,我希望能达到以下几点目标

  • 使用体验与Sprite一致,支持Sprite的simple、sliced、tiled、filled渲染类型
  • 使用时不需要关注渲染组件的纹理在材质属性中的textureIdx
  • 兼容web与native
  • 支持Cocos自动图集与动态合图的纹理
  • 支持动态修改合批的纹理

使用体验与Sprite一致,支持Sprite的simple、sliced、tiled、filled渲染类型

目前论坛里给出的源码都只写了simple类型,其实要支持其他几种类型也很简单。

下图为引擎的js源码中对不同类型的Sprite的顶点数据处理的代码,复制一份稍作修改即可。
image

然后仿照引擎注册顶点数据处理类

cc.Assembler.register(MultiSprite, {
    getConstructor(sprite) {
        let ctor: any = MultiAssemblerSimple;
        switch (sprite.type) {
            case cc.Sprite.Type.SLICED:
                ctor = MultiAssemblerSliced;
                break;
            case cc.Sprite.Type.TILED:
                ctor = MultiAssemblerTiled;
                break;
            case cc.Sprite.Type.FILLED:
                if (sprite._fillType === cc.Sprite.FillType.RADIAL) {
                    ctor = MultiAssemblerRadialFilled;
                } else {
                    ctor = MultiAssemblerBarFilled;
                }
                break;
        }
        return ctor;
    }
});

最后渲染组件继承CCSprite,并使用inspector装饰器

@inspector("packages://inspector/inspectors/comps/sprite.js")

使用时不需要关注渲染组件的纹理在材质属性中的textureIdx

重写_updateMaterial方法,修改spriteFrame和material时引擎内部会调用此方法,在其内部根据当前纹理获取材质上对应的纹理Idx即可。

    /**
     * 设置spriteFrame和material时引擎内部会调用,更新textureIdx,更新材质属性
     * @override
     */
    public _updateMaterial(): void {
        // make sure material is belong to self.
        let material = this.getMaterial(0);
        if (material) {
            let texture = null;
            let textureImpl = null;
            if (this.spriteFrame) {
                texture = this.spriteFrame.getTexture();
                textureImpl = texture && texture.getImpl();
            }
            if (material.name.indexOf("multiTexture") >= 0) {
                // 初始化纹理管理器
                MultiTextureManager.init(material["_material"]);
                // 更新textureIdx
                let idx = MultiTextureManager.getIdx(texture);
                if (idx >= 0) {
                    this.textureIdx = idx;
                }
                if (material.getProperty(`texture${this.textureIdx}`, 0) !== textureImpl) {
                    material.setProperty(`texture${this.textureIdx}`, texture);
                }
            } else {
                if (material.getProperty(`texture`, 0) !== textureImpl) {
                    material.setProperty(`texture`, texture);
                }
            }
        }

        cc.BlendFunc.prototype["_updateMaterial"].call(this);
    }

兼容web与native

引擎源码中有一个jsb-adapter目录,在打包native时,会用jsb-adapter目录中的js覆盖一部分web版js的代码,因为这部分实现放在c++层,需要以jsb的方式调用c++代码。

下图为jsb-adapter目录中对Sprite的顶点数据做修改的代码
image

先看看simple.js覆盖了哪些代码
image

其中nativeProto便是在c++层实现的,通过jsb注册到 JS 虚拟机中,供js层调用

不过这里不需要关心c++代码,只需要参考jsb-adapter目录中对应的代码加进去做一个native的兼容即可

支持Cocos自动图集与动态合图的纹理,支持动态修改合批的纹理

将这两点并列讲,是因为自动图集(Auto Atlas)的纹理需要打包后运行才能获取到,而动态合图(Dynamic Atlas)的纹理需要程序运行时才能获取到,所以都必须得动态修改材质上需要合批的纹理属性。
所以为了管理合批纹理和材质,就需要加入一个管理器去处理这些事。

/**
 * Multi-Texture 管理器
 */
export class MultiTextureManager {
    /** 纹理最大数量 */
    public static readonly MAX_TEXTURE_NUM = 8;

    private static _init: boolean = false;
    /** 共享材质 */
    private static _mat: cc.Material = null;
    private static _texMap: Map<number, cc.Texture2D> = new Map();
    private static _sprites: Set<MultiSprite> = new Set();

    /**
     * 初始化纹理管理器
     */
    public static init(mat: cc.Material): void {
        if (this._init || !(mat instanceof cc.Material) || mat instanceof cc.MaterialVariant) {
            return;
        }
        this._init = true;
        this._mat = mat;
        // 处理引用计数
        this._mat.addRef();
    }

    public static addSprite(sp: MultiSprite): void {
        this._sprites.add(sp);
    }

    public static removeSprite(sp: MultiSprite): void {
        this._sprites.delete(sp);
    }

    /**
     * 设置合批纹理
     * @param idx 纹理id
     * @param tex 纹理对象
     * @returns 
     */
    public static setTexture(idx: number, tex: cc.Texture2D): void {
        if (!this._init) {
            cc.error("[MultiSpriteManager.setTexture] 未初始化MultiSpriteManager");
            return;
        }

        if (!(tex instanceof cc.Texture2D)) {
            cc.error("[MultiSpriteManager.setTexture] 参数类型错误");
            return;
        }

        idx = cc.misc.clampf(idx, 0, MultiTextureManager.MAX_TEXTURE_NUM - 1);
        let oldTex = this._texMap.get(idx);
        if (oldTex === tex) {
            return;
        }

        // 处理引用计数
        if (oldTex) {
            oldTex.decRef();
        }
        tex.addRef();

        this._texMap.set(idx, tex);
        // 修改共享材质属性
        this._mat.setProperty(`texture${idx}`, tex);

        this._sprites.forEach((v) => {
            /**
             * @bug
             * 2.4.5之前材质hash计算在utils.js中serializeUniforms有bug, 里面for-in遍历材质属性顺序受k-v对插入顺序影响(即setProperty顺序), 即使属性完全一致, hash却不一定一致
             * 因此在此直接创建新的材质
             */
            // v.setMaterial(0, this._mat);

            // 材质变体中的属性必须完全一致, 材质的hash值计算才会一致
            let material = v.getMaterial(0);
            for (let i = 0; i < MultiTextureManager.MAX_TEXTURE_NUM; i++) {
                let texture = this._texMap.get(i);
                if (!texture) {
                    continue;
                }
                let textureImpl = texture.getImpl();
                if (material.getProperty(`texture${i}`, 0) !== textureImpl) {
                    material.setProperty(`texture${i}`, texture);
                }
            }
            // 修改共享材质属性后,必须手动设置材质变体的_effect._dirty,不然不会重新计算材质变体的hash值
            material["_effect"]._dirty = true;

            // 更新textureIdx与材质属性
            v._updateMaterial();
        });
    }

    public static getIdx(tex: cc.Texture2D): number {
        for (let i = 0; i < MultiTextureManager.MAX_TEXTURE_NUM; i++) {
            if (this._texMap.get(i) === tex || this._mat.getProperty(`texture${i}`, 0) === tex.getImpl()) {
                return i;
            }
        }
        return -1;
    }
}

需要注意的几个点

  • draw call是否能合批会根据材质的hash值来判断,一开始我使用的是2.4.5版本,发现材质hash值的计算代码有bug。源码是以for-in的形式遍历材质属性,然后计算hash值。但是for-in的遍历不能保证遍历顺序,这就导致即使两个材质变体属性完全相同,计算出的hash结果也有可能不同。后续经确认,此bug在2.4.5之后的版本已经修复
  • 材质变体调用getProperty时,会先从自身属性中找,若没有,就会从共享材质上查找
  • 共享材质setProperty时,不会更新材质变体的hash值。所以动态修改共享材质属性时,会出现两个材质变体即使通过getProperty发现所有属性完全相同,但仍然不能合批的情况,因为它们的hash值可能不同,这时需要通过设置_effect._dirty为true,重新计算材质变体的hash值,使其能够合批
  • 原生上会默认关闭动态合图功能
  • 如果需要合批动态合图的纹理,需要尤其自行注意纹理状态和释放时机
  • 进行过动态合图的spriteFrame上会有一个_original对象,记录着合图前的纹理信息

示例

下面分别有来自两个自动图集的纹理(打包后生效),以及一个进行过动态合图的纹理,三个渲染节点交错排列。

未使用Multi-Texture时

使用Multi-Texture时(多出来的draw call来自于下面的ui)

使用

  1. 完全不需要关注渲染组件上正在使用的纹理的textureIdx,你唯一可能需要调用的就是MultiTextureManager.setTexture(idx: number, tex: cc.Texture2D)
  2. 如果不需要运行时动态修改需要合批的纹理,比如只使用TexturePacker打包的图集纹理,那么只需要绑定上MultiSprite组件,再在编辑器内将纹理拖到材质对应的属性上即可。
  3. 如果需要运行时动态修改,通常情况下也只需要调用此接口MultiTextureManager.setTexture(idx: number, tex: cc.Texture2D),设置合批纹理与纹理id即可。内部代码会自动处理渲染组件正在使用的纹理id。
    如下图所示

实现源码

github: https://github.com/LeeYip/cocos-framework
gitee: https://gitee.com/liamyip/cocos-framework

  • 组件脚本路径 assets/scripts/common/cmpt/ui/MultiSprite/
  • 示例prefab路径 assets/resources/prefab/dialog/DlgMultiTexture.prefab
  • 材质路径 assets/res/shader/materials/multiTexture.mtl
15赞

3.x以后,如果改变hash的话,会导致渲染错乱
3.x以后如果给一个material设置了多张纹理 setProperty(‘texture’,n)
如果n超出了最大纹理数量之后如何操作呢?
复用这个material,会导致覆盖了前面的纹理

先mark后看

  1. 一般情况下不建议直接改hash值,我这里说的是某些情况下引擎不会及时更新hash值,需要让引擎内部重新计算一次hash值进行更新而已。
  2. 最大纹理数量这个是由具体运行设备决定的,是一定有一个上限的。如果是运行web环境下,WebGL至少支持8个纹理单元;如果是运行在native环境下,那么OpenGL至少支持16个纹理单元。

哥哥,我的想法是这样的。合批检测的时候,判断纹理大于8张了,就做合批。

小于8张就Material.setproperty(0—8,texture)

但是第九张的时候,执行合批
这时候,这个材质里占了8张,第九张设置纹理的时候,采样了第一张为什么呢。

是要做上传到哪里吗?

能说的更详细一点吗,我没有理解这句话的意思,你是怎么设置第9张纹理的

就是调用了aotumergebatches之后,我再设置
render.current material.setproperty(texture0,texture);

感谢大佬分享!!!

感谢大佬分享!!!

你能把遇到问题的部分整理一个demo出来吗,3.x这一块源码我没有细看

您好,如果一个项目图集数量大于8之后,只能做替换原来0-7的吗?

如果真的有必要合批到这种程度的话,你可以多分几组,每组设置不同的8张,然后同组的尽量放在相邻层级。

mark!!!

image
我将两张图集拖到一个材质上,发现material计算hash值时会执行如下代码,图集1的id=60,图集2的id=61,最终导致两者hash值不同无法合批,我写死一个hash是可以合批的。请教下怎么处理可以不用写死hash值呢?2.4.11引擎。