书之国中需要一个人物捏脸系统,要求可以让用户自由选择身体不同部位的形象,比如头发、眼睛、眉毛、上衣、裤子等。
由于spine动画的结构为bone→slot→attachment(即附件、图片),我们可以在动画文件中针对同一个部位(同一个slot)下做多个attachment,然后根据用户的选择进行切换attachment就行。
优点:web、native等多端统一代码。
缺点:随着可换装的部位越多、同一个部位皮肤越多,动画文件变得越来越大,由于spine动画文件是一次性加载进内存等,导致占用内存较多,实例化速度变慢。
主要代码如下:
// 局部换装 skinname一般默认为default
changePartialCloth(skeleton: sp.Skeleton, slotName: string, targetSkinName:string, targetAttaName:string) {
// console.log('change cloth:', slotName, targetSkinName, targetAttaName);
const slot = skeleton.findSlot(slotName);
const skeletonData = skeleton.skeletonData.getRuntimeData();
const skin = skeletonData.findSkin(targetSkinName);
const slotIndex = skeletonData.findSlotIndex(slotName);
const attachment = skin.getAttachment(slotIndex, targetAttaName);
if (!slot || !attachment) {
cc.error(slot && attachment, "slots: " + slotName + ", attach: " + targetAttaName + " not exists!");
return;
}
slot.setAttachment(attachment);
// 如果spine使用了private或者shared等缓存模式,则需要更新缓存。
skeleton.invalidAnimationCache();
}
由于attachemnt即是图片资源在spine内的表达,我们可以通过加载一张外部图片来更新attachment达到局部换装功能。
优点:spine动画每个部位可以只做一个attachment,这样动画文件结构简单,体积较小,内存占用较小加载速度也较快。
缺点:一是由于引擎本身不提供此功能,需要自己动手实现,而且web端和native端需要两套代码,必须修改引擎代码并重新编译引擎。二是动画在使用realtime模式时修改一个动画会影响使用同一个动画文件创建的其他动画,这个问题还需要研究。
updatePartialSkin(ani: sp.Skeleton, tex2d: cc.Texture2D, slotsName: string) {
let slot: sp.spine.Slot = ani.findSlot(slotsName);
let attachment: sp.spine.RegionAttachment = slot.getAttachment() as sp.spine.RegionAttachment;
if (!slot || !attachment) {
cc.error('error...');
return;
}
let region: sp.spine.TextureAtlasRegion = attachment.region as sp.spine.TextureAtlasRegion;
let skeletonTexture = new sp.SkeletonTexture();
skeletonTexture.setRealTexture(this.tex2d);
region.u = 0;
region.v = 0;
region.u2 = 1;
region.v2 = 1;
region.width = tex2d.width;
region.height = tex2d.height;
region.originalWidth = tex2d.width;
region.originalHeight = tex2d.height;
region.rotate = false;
region.texture = skeletonTexture;
region.page = null;
attachment.width = region.width;
attachment.height = region.height;
attachment.setRegion(region);
// mark: 不需要创建新的sp.spine.TextureAtlasRegion, 直接更新原attachment下的region即可。
// let region: sp.spine.TextureRegion = this.createRegion(tex2d);
// attachment.setRegion(region);
// attachment.width = region.width;
// attachment.height = region.height;
attachment.updateOffset();
slot.setAttachment(attachment);
// skeleton如果使用了缓存模式则需要刷新缓存
ani.invalidAnimationCache();
}
createRegion(tex: cc.Texture2D): sp.spine.TextureAtlasRegion {
cc.log('创建region');
let skeletonTexture = new sp.SkeletonTexture();
skeletonTexture.setRealTexture(tex);
// mark: 可以不设置page
// let page = new sp.spine.TextureAtlasPage();
// page.name = tex.name;
// page.uWrap = sp.spine.TextureWrap.ClampToEdge;
// page.vWrap = sp.spine.TextureWrap.ClampToEdge;
// page.texture = skeletonTexture;
// page.texture.setWraps(page.uWrap, page.vWrap);
// page.width = tex.width;
// page.height = tex.height;
let region = new sp.spine.TextureAtlasRegion();
// region.page = page;
region.width = tex.width;
region.height = tex.height;
region.originalWidth = tex.width;
region.originalHeight = tex.height;
region.rotate = false;
region.u = 0;
region.v = 0;
region.u2 = 1;
region.v2 = 1;
region.texture = skeletonTexture;
return region;
}
native端我们需要分别修改C++实现和jsb-adapter, C++实现我们要分别在 SkeletonRenderer.cpp和SkeletonCacheAnimation.cpp 添加对应的方法。C++代码在cocos2d-x目录下,我们可以git上下载对应版本的最新代码。
SkeletonRenderer.cpp
void SkeletonRenderer::updateRegion(const std::string &slotName, cocos2d::middleware::Texture2D *texture) {
// auto skeletonData = _skeleton->getData();
// auto slotIndex = skeletonData->findSlotIndex(String(slotName.c_str()));
// auto skin = skeletonData->findSkin(String("default"));
// RegionAttachment * attachment = (RegionAttachment *)skin->getAttachment(slotIndex, String("cap_1"));
Slot *slot = _skeleton->findSlot(slotName.c_str());
RegionAttachment *attachment = (RegionAttachment *)slot->getAttachment();
// Texture *texture2D = texture->getNativeTexture();
// float width = texture2D->getWidth();
// float height = texture2D->getHeight();
float wide = texture->getPixelsWide();
float high = texture->getPixelsHigh();
attachment->setUVs(0, 0, 1, 1, false);
attachment->setRegionWidth(wide);
attachment->setRegionHeight(high);
attachment->setRegionOriginalWidth(wide);
attachment->setRegionOriginalHeight(high);
attachment->setWidth(wide);
attachment->setHeight(high);
// attachment->setRegionOffsetX(0);
// attachment->setRegionOffsetY(15);
// texture->setPixelsWide(width);
// texture->setPixelsHigh(height);
// texture->setRealTextureIndex(1);
AttachmentVertices *attachV = (AttachmentVertices *)attachment->getRendererObject();
if (attachV->_texture == texture) {
return;
}
CC_SAFE_RELEASE(attachV->_texture);
attachV->_texture = texture;
CC_SAFE_RETAIN(texture);
V2F_T2F_C4B *vertices = attachV->_triangles->verts;
for (int i = 0, ii = 0; i < 4; ++i, ii += 2)
{
vertices[i].texCoord.u = attachment->getUVs()[ii];
vertices[i].texCoord.v = attachment->getUVs()[ii + 1];
}
attachment->updateOffset();
slot->setAttachment(attachment);
}
SkeletonCacheAnimation.cpp
void SkeletonCacheAnimation::updateRegion(const std::string &slotName, cocos2d::middleware::Texture2D *texture)
{
_skeletonCache->updateRegion(slotName, texture);
}
修改C++代码后我们需要重新跑一般自动绑定脚本,生成js绑定接口,目录在cocos2dx/tools/tojs/genbindings.py,绑定成功后我们需要修改jsb adapter以提供给js层调用,
adapter在引擎安装目录下/Resources/builtin/jsb-adapter/engine/jsb-spine-skeleton.js ,添加如下方法:
skeleton.updateRegion = function (slotsName, jsbTex2d) {
if (this._nativeSkeleton) {
this._nativeSkeleton.updateRegion(slotsName, jsbTex2d);
return true;
}
return false;
};
全部修改完成后我们需要在creator引擎中自定义cocos2d-x引擎,指向我们刚修改的cocos2d-x目录。如果想要在模拟器预览效果我们还需要重新编译模拟器,具体教程可以在https://docs.cocos.com/creator/manual/zh/查看。
以上全部修改完成后,我们可以在js/ts代码中这样使用了:
changeClouth() {
if (cc.sys.isNative) {
cc.log('native 换肤.');
let jsbTex = new middleware.Texture2D();
jsbTex.setPixelsHigh(this.tex2d.height);
jsbTex.setPixelsWide(this.tex2d.width);
jsbTex.setNativeTexture(this.tex2d.getImpl());
this.player.updateRegion("cap", jsbTex);
} else {
cc.log('web 换肤.');
this.updatePartialSkin(this.player, this.tex2d, 'cap');
}
// 缓存模式下需要刷新缓存
this.player.invalidAnimationCache();
}
Spine挂点功能是cocoscreator 2.3版本开始提供的,初衷是为了动态给动画添加部分节点,比如武器等,这里也可以非常规使用来做局部换皮。具体流程为生成挂点——>获取指定节点——>给该节点添加对应的子节点。
优点: 引擎提供的功能,三端表现统一,无需hack源码
缺点: 如果需要换装的图集过多无法合并到一张图集上,则每增加一个挂载节点都会增加一个drawcall,这里要特别注意。
// this.ani: sp.Skeleton
let node = new cc.Node();
let sp = node.addComponent(cc.Sprite);
sp.spriteFrame = this.spf;
let attachUtil = this.ani.attachUtil;
// attachUtil.generateAttachedNodes("hair");
attachUtil.generateAllAttachedNodes();
let bones = attachUtil.getAttachedNodes('hair');
bones[0].destroyAllChildren();
bones[0].addChild(node);
bones = attachUtil.getAttachedNodes('left_hand_a');
let node2 = cc.instantiate(node);
bones[0].destroyAllChildren();
bones[0].addChild(node2);
bones = attachUtil.getAttachedNodes('right_hand_a');
let node3 = cc.instantiate(node);
bones[0].destroyAllChildren();
bones[0].addChild(node3);
// attachUtil.destroyAttachedNodes('hair');
// attachUtil.destroyAllAttachedNodes();
/**
* !#en Traverse all bones to generate the minimum node tree containing the given bone names, NOTE that make sure the skeleton has initialized before calling this interface.
* !#zh 遍历所有插槽,生成包含所有给定插槽名称的最小节点树,注意,调用该接口前请确保骨骼动画已经初始化好。
* @method generateAttachedNodes
* @param {String} boneName
* @return {Node[]} attached node array
*/
generateAttachedNodes (boneName) {
let targetNodes = [];
if (!this._inited) return targetNodes;
let rootNode = this._prepareAttachNode();
if (!rootNode) return targetNodes;
let res = [];
let bones = this._skeleton.bones;
for (let i = 0, n = bones.length; i < n; i++) {
let bone = bones[i];
let boneData = bone.data;
if (boneData.name == boneName) {
res.push(bone);
}
}
let buildBoneTree = function (bone) {
if (!bone) return;
let boneData = bone.data;
let boneNode = this._getNodeByBoneIndex(boneData.index);
if (boneNode) return boneNode;
boneNode = this._buildBoneAttachedNode(bone, boneData.index);
let parentBoneNode = buildBoneTree(bone.parent) || rootNode;
boneNode.parent = parentBoneNode;
return boneNode;
}.bind(this);
for (let i = 0, n = res.length; i < n; i++) {
let targetNode = buildBoneTree(res[i]);
targetNodes.push(targetNode);
}
this._sortNodeArray();
return targetNodes;
},
/**
* !#en Destroy attached node which you want.
* !#zh 销毁对应的挂点
* @method destroyAttachedNodes
* @param {String} boneName
*/
destroyAttachedNodes (boneName) {
if (!this._inited) return;
let nodeArray = this._attachedNodeArray;
let markTree = function (rootNode) {
let children = rootNode.children;
for (let i = 0, n = children.length; i < n; i++) {
let c = children[i];
if (c) markTree(c);
}
rootNode._toRemove = true;
}
for (let i = 0, n = nodeArray.length; i < n; i++) {
let boneNode = nodeArray[i];
if (!boneNode || !boneNode.isValid) continue;
let delName = boneNode.name.split(ATTACHED_PRE_NAME)[1];
if (delName === boneName) {
markTree(boneNode);
boneNode.removeFromParent(true);
boneNode.destroy();
nodeArray[i] = null;
}
}
this._rebuildNodeArray();
},
/**
* !#en Traverse all bones to generate a tree containing all bones nodes, NOTE that make sure the skeleton has initialized before calling this interface.
* !#zh 遍历所有插槽,生成包含所有插槽的节点树,注意,调用该接口前请确保骨骼动画已经初始化好。
* @method generateAllAttachedNodes
* @return {cc.Node} root node
*/
generateAllAttachedNodes () {
if (!this._inited) return;
// clear all records
this._boneIndexToNode = {};
this._attachedNodeArray.length = 0;
let rootNode = this._prepareAttachNode();
if (!rootNode) return;
let bones = this._skeleton.bones;
for (let i = 0, n = bones.length; i < n; i++) {
let bone = bones[i];
let boneData = bone.data;
let parentNode = null;
if (bone.parent) {
let parentIndex = bone.parent.data.index;
parentNode = this._boneIndexToNode[parentIndex];
} else {
parentNode = rootNode;
}
if (parentNode) {
let boneNode = parentNode.getChildByName(ATTACHED_PRE_NAME + boneData.name);
if (!boneNode || !boneNode.isValid) {
boneNode = this._buildBoneAttachedNode(bone, boneData.index);
parentNode.addChild(boneNode);
} else {
this._buildBoneRelation(boneNode, bone, boneData.index);
}
}
}
return rootNode;
},
/**
* !#en Destroy all attached node.
* !#zh 销毁所有挂点
* @method destroyAllAttachedNodes
*/
destroyAllAttachedNodes () {
this._attachedRootNode = null;
this._attachedNodeArray.length = 0;
this._boneIndexToNode = {};
if (!this._inited) return;
let rootNode = this._skeletonNode.getChildByName(ATTACHED_ROOT_NAME);
if (rootNode) {
rootNode.removeFromParent(true);
rootNode.destroy();
rootNode = null;
}
}
综合对比,如果动画不大所需替换部位不太多的话可以考虑第一种多attachment切换,否则的话考虑第三种利用挂点功能,至于第二种需要修改引擎代码并进行充分测试。无论哪种解决方案都需要和动画师进行沟通协调确定制作方案。