来自于此贴( 如何重绘「江南百景图」?近300页 PPT 免费分享! )分享的MultiTexture的思路,论坛已经有很多人给了实现思路和源码了,就不在此赘述,这里主要谈一谈我在以我的需求实现此功能时遇到的一些问题。
我的需求
在做这个功能时,我希望能达到以下几点目标
- 使用体验与Sprite一致,支持Sprite的simple、sliced、tiled、filled渲染类型
- 使用时不需要关注渲染组件的纹理在材质属性中的textureIdx
- 兼容web与native
- 支持Cocos自动图集与动态合图的纹理
- 支持动态修改合批的纹理
使用体验与Sprite一致,支持Sprite的simple、sliced、tiled、filled渲染类型
目前论坛里给出的源码都只写了simple类型,其实要支持其他几种类型也很简单。
下图为引擎的js源码中对不同类型的Sprite的顶点数据处理的代码,复制一份稍作修改即可。
然后仿照引擎注册顶点数据处理类
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的顶点数据做修改的代码
先看看simple.js覆盖了哪些代码
其中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)
使用
- 完全不需要关注渲染组件上正在使用的纹理的textureIdx,你唯一可能需要调用的就是MultiTextureManager.setTexture(idx: number, tex: cc.Texture2D)
- 如果不需要运行时动态修改需要合批的纹理,比如只使用TexturePacker打包的图集纹理,那么只需要绑定上MultiSprite组件,再在编辑器内将纹理拖到材质对应的属性上即可。
- 如果需要运行时动态修改,通常情况下也只需要调用此接口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