【方案】Cocos Creator 的 web/原生多平台 Spine 换装方案解析,附 Demo 源码

需求

在2d/3d游戏中,动画换装是一种常见的需求, 而2d游戏中,Spine是一个强大且应用广泛的骨骼动画工具,那么spine换装则是很多开发者避不开的话题了。
按照羽毛的理解,根据需求和实现方式,可以将换装区分为

  • 整体换装
  • 局部换装

整体换装以及局部换装有不同的实现方案,其中局部换装可分为:

  • spine皮肤附件替换换肤
  • 外部贴图换肤
  • socket挂点换肤

具体选择哪种方案更为合适,需要根据项目需求,从程序以及美术维护便利性、性能瓶颈等方面综合考虑。

首先,羽毛将在本文基于Cocos Creator 3.5.2,介绍自己对各种方案的利弊思考、选择以及实现方式与避坑过程。

其次,在实现过程中,还需要考虑web平台以及原生平台的差异,进行适配。羽毛也将向大家分享在不同平台(web、小游戏、原生)实现适配过程中遇到的困难以及解决方式。

原理

spine基本概念

Spine是2d骨骼动画的一种实现方案,类似的方案方案还用DragonBone。Spine简单概况来说,就是通过设计骨骼运动关键帧运动信息、蒙皮信息,在运行时关键帧之间的数据将由spine自动计算完成,由骨骼(bone)驱动插槽(slot),插槽驱动附件(attachment,附件的一项重要信息则是贴图)进行移动、旋转、显示、形变等表现。

相比帧动画,骨骼动画能够在运行时根据数据对象进行动画插值,一般来说,具有体积更小、更少美术资源要求、更好的动画流畅效果、动画混合、可程序控制骨骼等优点。

换装,本质上,就是换插槽上的附件贴图。贴图来源可以是spine导出文件中带有的贴图,也可以是Cocos Creator中的texture。文中的整体换装以及皮肤附件换装即是使用自带贴图进行换装。而外部贴图顾名思义就是使用cocos creator中的texture进行换装。

至于挂点,简单理解则是选用一根骨骼作为挂点A,将挂点A作为欲挂cocos中节点(Node)B的父节点,B将随着A的移动、旋转等(暂时已知有这些属性,更多的属性不确定,可查询文档,文档未说明的可阅读引擎源码及测试验证)进行变化。挂点常用于武器的更换,或一些不便于使用外部贴图却可以使用挂点完成的局部换装需求。

spine简单示意图:

(来源:作者:曾培森)

spine渲染流程图:

(来源:作者:曾培森)

分析与实现

整体换装

  • 整体换装

    • 优点

      相对来说实现比较简单,无需修改引擎,三端统一。在spine编辑软件中设置好皮肤,运行时基于运行库调用api即可,运行库的集成cocos creator也已帮我们完成了,支持版本如下,需要自行升级的同学也可以到spine官方仓库下载进行替换适配。

    • 缺点

      无法满足局部换肤的需求,如果存在m个需要局部换肤的部位,同时每个部位有n种皮肤,通过命名组合的方式切换皮肤以实现局部换肤,则需要mxn个完整皮肤才能完整覆盖所有的皮肤,当m、n稍微有点大,日常的维护将会非常的麻烦,成本较大,同时随着m与n的增大,图集的大小也将不受控制,耗费内存,降低加载速度。

      因此,整体换肤比较适合只需要整体换装或者只有两三个部位两三个皮肤进行组合的情况。

    • 实现:

      1. 在spine动画编辑软件中创建皮肤并选择生效

      2. 并且在需要动态换肤的部件上设置皮肤占位符,在占位符下放入生效皮肤所需要显示的贴图。这部分内容应该是动画制作的美术同学需要更多的关注,但开发同学也需要大概了解一下机制。动作工程内容制作这块更多的内容请参考spine官方说明:spine用户指南-皮肤

      3. 运行时程序控制,调用setSkin(skin_name:string) 即可

    @property({
        type: sp.Skeleton
    })
    role: sp.Skeleton;

    cur_skin_name = "full-skins/girl-spring-dress"
    start() {
        this.role.setSkin(this.cur_skin_name);
    }
    onSetFullSkin(event: TouchEvent, data: string) {
        if (data != "") {
            this.role.setSkin(data);
            this.cur_skin_name = data;
        }
    }    

同时,整体换装的实现也可参考官方文档的介绍,也写得非常详细了,这里个写文档的同学点个赞!
Spine Skeleton 组件参考

局部换装

局部换装——附件换装

spine附件换装则是指在spine工程内针对某一部位插槽SlotA创建皮肤(记为SkinPart)并记录皮肤占位符,运行时通过查询局部皮肤SkinPart中的附件(记为attachmentPart),使用局部皮肤对应位置的附件attachmentPart替换全身皮肤中SlotA下的附件(attachmentFull)

  • 优点:

    1. 可以实现皮肤组合,相对全局换装可以实现局部换装组合

    2.换装效果在工程中所见即在运行中所得,视觉效果更多的交由美术处理,效果更加可控。

    3.相对外部贴图的局部换装方式,部件的图集可参与spine的图集合图,减少drawcall.

    4.无需修改引擎,native与web端表现统一,在小游戏端也不会因为修改引擎而无法使用分离引擎功能,从而导致加长了游戏加载的时间成本。

    至于现有的外部贴图换装的drawcall局限性原因将在后续内容介绍。

  • 缺点

    1. 当前不使用的皮肤贴图造成不必要的内存浪费,当spine数量上升时问题尤其明显。
    2. 皮肤的贴图与cocos creator场景中的节点sprite无法共用,在需要共用的场景下,造成一份内存浪费。同时实例化速度也会变慢。
    3. 当部件与皮肤数量上升后,spine工程逐渐变得臃肿,难于管理且不利于多人协作。
  • 实现

    1、在spine工程中创建全身皮肤、对每一个需要换肤的部件出n个局部皮肤SkinParts,导出spine


    2、运行时查询某一局部皮肤SkinPart中的附件(记为attachmentPart),使用替换全身皮肤中SlotA下的附件(attachmentFull)

  /**
    * @param skinName 要替换的部件皮肤名称
    * @param slotName 要替换的部件的插槽名称
    * @param targetAttaName  Spine中皮肤占位符的名字
     */
    changeSlot(skinName: string, slotName: string, targetAttaName: string) {
        //查找局部皮肤
        let skeletonData = this.role.skeletonData.getRuntimeData();
        let targetSkin: sp.spine.Skin = skeletonData.findSkin(skinName);

        //查找局部皮肤下的插槽与附件
        let targetSkinSlotIndex = skeletonData.findSlotIndex(slotName);
        let atta = targetSkin.getAttachment(targetSkinSlotIndex, targetAttaName);

        //查找全身皮肤下的插槽
        let curSlot = this.role.findSlot(slotName);

        //替换全身皮肤插槽的附件
        curSlot && curSlot.setAttachment(atta);
    }

局部换装——外部贴图


局部换装——外部贴图,顾名思义,就是不使用spine导出图集中贴图,而是使用cocos creator的texture资源进行局部换装。当前一个部位使用的图片和将要换上去的图片都没有蒙皮变形,或者两张图片可以使用同一个蒙皮,就可以直接使用外部图片进行换装。

  • 优点

    • spine工程管理方便,不会有大量的贴图需要在spine动画编辑软件中进行附件绑定工作,可以大大减少工作量,提升工作效率,少掉几根头发!
    • 更加灵活,可以结合程序逻辑使用cocos creator中的任意texture进行替换
    • 节省内存,不会重复加载大量不需要显示的贴图资源。在同一画面texture与spine需要交替显示的场景下,不需要spine与texture各自加载一份内存。
  • 缺点

    • 非所见即所得,不过这个缺点可以通过指定较为严格的美术规范进行比较完美的规避。
    • 目前所用方案,进行局部换装的texture不可合批,否则会显示异常,在spine实例较多的场景下,drawcall较会快速上升,达到一个比较惊人的数值。不过这一点若开发者自行研究,通过定制引擎与spine ts运行库,应该也是可以解决的。
    • 需要修改引擎,native端也需要维护,同时因修改引擎小游戏端无法使用分离引擎功能,牺牲加载时间。

    即使存在上述缺点,在确实需要大量局部换装的场景下,使用外部贴图的局部换装方案仍然是十分值得考虑的。

实现

cocos官方技术团队提供了基于Cocos Creator3.4.2的外部贴图局部换肤方案,git地址如下:
3.4.0Spine局部换肤,方案满足了web(含小游戏)以及原生端的适配,可自行下载食用,风味适佳。

由于初始化流程的改变,官方demo提供的绑定方式无法正常运行,3.5.1以及3.5.2的适配羽毛已经完成,
同时考虑日常项目开发的便利需求,对ts层接口进行了封装,完整demo也会在文末放出链接,欢迎大家下载,喜欢的同学可以在git主页给羽毛加根鸡腿,有条件的话也会上cocos store。

static updatePartialSkin(ani: sp.Skeleton, tex2d: Texture2D, slotChange: sp.spine.Slot, slotsName: string = "") {
        let slot!: sp.spine.Slot;
        if (slotChange) {
            slot = slotChange;
        }
        else {
            slot = ani.findSlot(slotsName) as sp.spine.Slot;
        }
        if (!slot) {
            error('updatePartialSkin:', slotsName)
            return;
        }
        slot.color.a = 1;
        const attachment: sp.spine.RegionAttachment = slot.getAttachment() as sp.spine.RegionAttachment;
        if (tex2d == null) {
            error('tex2d null:', slotsName)
            return;
        }
        if (!attachment) {
            error('updatePartialSkin attachment null:', slotsName)
            return;
        }
        if (JSB) {
            // @ts-ignore
            let skeleton = cc.internal.SpineSkeleton.prototype;

            // @ts-ignore
            let spineSkeletonData = cc.internal.SpineSkeletonData.prototype;

            // 局部换装
            skeleton.updateRegion = function (attachment: any, tex2d: any) {
                // @ts-ignore
                var jsbTex2d = new middleware.Texture2D();
                jsbTex2d.setRealTextureIndex(spineSkeletonData.recordTexture(tex2d));
                jsbTex2d.setPixelsWide(tex2d.width);
                jsbTex2d.setPixelsHigh(tex2d.height);
                // @ts-ignore
                sp.spine.updateRegion(attachment, jsbTex2d);
            };
            (<any>ani).updateRegion(attachment, tex2d);
        }
        else {
            const skeTexture = new sp.SkeletonTexture({ width: tex2d.width, height: tex2d.height } as ImageBitmap);
            if (tex2d) {
                skeTexture.setRealTexture(tex2d);
            }

            const region = new sp.spine.TextureAtlasRegion();
            if (tex2d) {
                region.width = tex2d.width;
                region.height = tex2d.height;
                region.originalWidth = tex2d.width;
                region.originalHeight = tex2d.height;
            }
            region.rotate = false;
            region.u = 0;
            region.v = 0;
            region.u2 = 1;
            region.v2 = 1;
            region.texture = skeTexture;
            region.renderObject = region;

            attachment.region = region;
            if (tex2d) {
                attachment.width = tex2d.width;
                attachment.height = tex2d.height;
            }

            if (attachment instanceof sp.spine.MeshAttachment) {
                attachment.updateUVs();
            } else {
                attachment.setRegion(region);
                attachment.updateOffset();
            }
        }
    }
  • 方案存在的问题:

    当场景内存在同一个spine资源的多个示例,对一个实例进行换装后,其余实例也会同时发生变化,这个是不满足预期的。

  • 解决方式:

    对每一个spine实例,copy SkeletonData,代码如下:

  copySkeletonData(spine: sp.Skeleton, data: sp.SkeletonData, is_set: boolean = true) {
        let date = new Date();
        // 记录当前播放的动画
        const animation = spine.animation
        const spdata = data;
        let copy = new sp.SkeletonData();
        js.mixin(copy, spdata);
        // @ts-ignore
        copy._uuid = spdata._uuid + "_" + date.getTime() + "_copy";
        let old = copy.name;
        let newName = copy.name + "_copy";
        copy.name = newName;
        copy.atlasText = copy.atlasText.replace(old, newName);
        // @ts-ignore
        copy.textureNames[0] = newName + ".png";
        // @ts-ignore
        copy.init && copy.init();

        if (is_set) {
            spine.skeletonData = copy;
            // 继续播放的动画,不然会停止
            spine.setAnimation(0, animation, true);
        }
    }

局部换装——挂点

在使用骨骼动画时,经常需要在骨骼动画的某个部位上挂载节点,以实现节点与骨骼动画联动的效果。这里的联动,基本概括为跟随挂载点的移动、旋转等属性进行同步(暂时已知有这些属性,更多的属性不确定,可查询文档,文档未说明的可测试验证)进行变化。

挂点的设计一般被用来用于武器等物品的跟随,但是另一个角度,如果项目需求上不在意挂点带来的一些问题,那么用来解决一些换装问题也是可以的,因此羽毛认为挂点也是局部换装的方案之一。

挂点的实现,这部分内容官方文档也介绍的比较详细,可仔细观摩。这里做一下比较简单的概括,以需要挂载logo节点为例:

  • 编辑器实现
    选中spine skeleton组件后,选择需要跟随的骨骼,拖动挂载的logo节点的父节点Target即可,相当easy。

    官方提醒的注意:
    不要直接将 logo 节点设置为 Target,这样会让 logo 节点自身的 UITransform 无效。请新建空节点作为 Target,并将要挂载的节点logo作为 Target 节点的子节点。

  • 通过代码实现
    适合需要在运行时动态改变挂载骨骼点的需求。

    1. 确定需要挂载的骨骼路径
    2. 设置挂载节点

    代码:

  paths: Map<string, string> = new Map();
  onChangeSocket(e: EventTouch, boneName: string) {
      this.paths["hand-front"] = 'root/skeleton-control/hips/body-down/body-up/arm-front-control/arm-front-up/arm-front-down/hand-front';
      this.paths["leg-front-4"] = 'root/skeleton-control/hips/leg-control-front/leg-front-1/leg-front-2/leg-front-3/leg-front-4';

      let sockets = this.role.sockets;
      let socket = sockets.find((value, index) => {
          return (value.target == this.socketTestNode)

      });
      if (!socket) {
          let newSocket: sp.SpineSocket = new sp.SpineSocket(this.paths[boneName]);
          this.role.sockets.push(newSocket);
      }
      else {
          socket.path = this.paths[boneName];
      }
      this.role.sockets = this.role.sockets;
  }
  
  • 注意点:

    由于挂载的节点只能为spine的子节点,因此挂载的节点渲染顺序只能位于spine节点的最上层,。但如果不太在意一帧的顺序,这里有一个比较鸡贼的方法,可以实现挂载节点logo渲染顺序处于spine节点的下方。我们可以不将logo点作为target的子节点,而位于spine的下层,再在update中获取target节点的position与rotation属性进行刷新。

    注意如果存在多个欲挂载的节点,欲挂载节点之间的顺序是可以随意调整。

    但是,这仍然无法解决logo节点无法与spine中其他附件进行渲染顺序混排的问题。因为工作繁忙,暂时无暇研究如何解决?如果有同学有更好的办法解决这个问题,欢迎探讨。
    2、根据

小结

1、对于简单需求,整体换装能满足的话就直接使用
2、对于局部换装,如果需求简单替换部位较少,可以使用附件换装或者挂点换装,具体根据项目取舍
3、对于比较复杂、附件较多、部位较多的局部换装,综合来说还是外部贴图换装最适合,dc的问题有能力的同学花点时间理论上也可以处理好。
4、结合项目内容,从维护成本,性能消耗等方面考虑,可以结合以上几种方案,混合使用,解决项目需求。

无论采取哪种方案,spine这块都需要与美术同学进行充分的沟通,对齐信息,进而确定方案。羽毛同学由于自己拥有spine专业付费版设计软件,以及以前也做过一点简单的spine动画,对spine的工程有一定的了解,这对于后续涉及spine的开发以及沟通有不少的帮助。

福利

demo源码,请至公众号文章末尾获取:

https://mp.weixin.qq.com/s/FXCxNERcTIsw-uzL-tS-Eg

更多

基于creator3.0的3D换装

CocosCreator3.4.2原生二次开发的正确姿势——手把手教你接SDK

在编辑器上声明自定义数据数组

包体优化指南

不规则3D地形行走

快速实现3d抛物线绘制

奇形怪状-不规则按钮实现

一点叨叨

我是羽毛,一名野生游戏研发工程师,一名野生摄影同学。我的公众号主要分享自己的一些游戏项目开发过程中的功能总结及日常开发笔记。也希望能通过平台的交流,与更多有想法的同学交流认识,共同成长。

欢迎大家在日常开发过程中,如果觉得有需要讨论解决、分享或者探讨的内容,在公众号后台或者文章留言处给我反馈,提供写作的方向,从另一个角度也尽量让写作内容更贴近大家的需求以及痛点,在此谢谢各位同学

今日技能你学废了吗?

如果对您有帮助,可以给羽毛加鸡腿哦

更多精彩欢迎关注微信公众号

21赞

图片是不是挂了?

嗯,已经处理好了

谢大佬!!demo在2.3.3版本稍微修改下就能运行,目前还在试原生的外部贴图-局部换装的可行性。

很高兴能够帮助到你 :stuck_out_tongue:

mark!!!

mark!

这个外部图片换装方案,用3.5.2打包apk运行报错,cpp文件已经替换了

给的demo工程经过3.5.2实测h5/native没有问题后直接打包的,麻烦再对比demo工程看看哪里漏了哦

3.6版本以上,原生平台有问题吗?

嗯,是使用了自己的spine,还没确定是spine哪里的问题

一直都是每个部件做一个spine 直接替换插槽 非常完美

使用了mesh动画的部件,换肤时setUV会报错

原生吗?我看他的git,原生只写了RegionAttachment 没有 MeshAttachment

是的,原生上的,mesh动画换装会有问题

我的做法和版本都不一致,你可以参考下

void SkeletonRenderer::updateRegion(const std::string &slotName, cocos2d::middleware::Texture2D *texture) {
    Slot *slot = _skeleton->findSlot(slotName.c_str());
    if(nullptr == slot){
        return;
    }
    Attachment *attachment = (Attachment *)slot->getAttachment();
    if(nullptr == attachment){
        return;
    }
    float wide = texture->getPixelsWide();
    float high = texture->getPixelsHigh();
    if(attachment->getRTTI().isExactly(RegionAttachment::rtti)) {
        RegionAttachment *_attachment = (RegionAttachment *) attachment;
        _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);

        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);
    } else if(attachment->getRTTI().isExactly(MeshAttachment::rtti)) {
        MeshAttachment *_attachment = (MeshAttachment *) attachment;
        _attachment->setRegionU(0);
        _attachment->setRegionV(0);
        _attachment->setRegionU2(1);
        _attachment->setRegionV2(1);
//        _attachment->setRegionRotate(degrees!=0);
//        _attachment->setRegionDegrees(degrees);

        _attachment->setRegionWidth(wide);
        _attachment->setRegionHeight(high);
        _attachment->setRegionOriginalWidth(wide);
        _attachment->setRegionOriginalHeight(high);
//        _attachment->setRegionOffsetX(offset.x);
//        _attachment->setRegionOffsetY(offset.y);
        _attachment->updateUVs();
        AttachmentVertices *attachmentVertices = (AttachmentVertices *)_attachment->getRendererObject();
        if (attachmentVertices->_texture == texture) {
            return;
        }
        CC_SAFE_RELEASE(attachmentVertices->_texture);
        attachmentVertices->_texture = texture;
        CC_SAFE_RETAIN(texture);
        V2F_T2F_C4B* vertices = attachmentVertices->_triangles->verts;
        for (size_t i = 0, ii = 0, nn = _attachment->getWorldVerticesLength(); ii < nn; ++i, ii += 2) {
            vertices[i].texCoord.u = _attachment->getUVs()[ii];
            vertices[i].texCoord.v = _attachment->getUVs()[ii + 1];
        }
    }
}
2赞

mark一下

2.x的有吗^_^

2.x论坛有同学亲测改改就能用,上面有楼层回复