乐趣区

关于前端:基于Spine的2D形象换装换动作实现

2D 商城换装业务不同于广泛业务场景,存在大量换装和切换动作的需要,但业界内现有运行库短少对应的 API 实现与反对。因而,本课程将率领大家深刻 Spine 2D 渲染底层原理,深刻 Spine 源码运行库,剖析其外围模块和渲染层的调用和解决流程,并基于此介绍如何基于 PIXI-spine 实现换装和换动作性能,以及性能实现过程中的难点。

一、Spine 基本概念及其原理介绍

Spine 中比拟重要的几个基本概念,可参照笔者之前分享的一篇文章,包含对骨架、骨骼、附件、插槽以及皮肤的概念了解。

这里还须要补充强调一个概念: 数据对象和实例对象的关系与区别

数据对象是无状态的,可在任意数量的骨架实例间共用。有对应实例数据的数据对象类名称以“Data”结尾,没有对应实例数据的数据对象则没有后缀,如附件、皮肤及动画。

实例对象有许多属性与数据对象雷同。数据对象中的属性代表拆卸姿态,通常不会改变。实例对象中的雷同属性示意播放动画时该实例的以后姿态。每个实例对象保有一个其数据对象参考,用于将实例对象重置回拆卸姿态。

例如,SkeletonData 是数据对象,而 Skeleton 是实例对象。同样的,Bone 实例对象会有对应的 BoneData,Slot 实例对象会有对应的 SlotData 等。

二、Spine 渲染整体流程图

三、设计思路

业务背景:

公司外部业务存在大量换装和切换动作的需要,因而 Spine 编辑器导出的素材,也须要进行拆分,将 ” 头饰 ”、” 发型 ”、” 上衣 ” 等一一归类拆分为一个个 dress;同样的,因为动作繁多且多变,动作也需独自拆分为一个个 action,而动作内又可能产生打扮的替换,因而拆分进去的动作素材内可能同时含有骨骼信息和打扮信息。

以接下来这个挥镰刀的动作为例,将被拆分为一下几局部:

插拔思维:

基于后面的拆分,为了让人物能够不便的进行打扮的替换,动作的切换,咱们须要将打扮和动作都设计成可 ” 插拔 ” 的模式,实质上都是基于初始的根底骨骼,而后持续往骨架上新增打扮附件,或者新增骨骼信息,而这些新增的骨骼和打扮在将来某一个同样能够从以后骨架中 ” 摘除 ”。实现打扮的替换或者动作的切换。

渲染库选型:

渲染层 \ 比拟项 兼容性 封装水平 可拓展性
canvas 不反对网格附件、着色
webgl
threejs 不反对两种色彩着色和混合模式 个别
pixijs

在针对渲染层采纳的技术计划的比拟中,canvas 和 threejs 仍存在一些兼容性问题;因为 canvas 和 webgl 都用浏览器原始画布来做渲染,因而封装水平较低,如果要投入到业务中须要进行二次封装;思考到 2D 动画渲染以及将来的业务性能的可拓展性上,pixijs 绝对更有劣势,基于 pixijs 的封装能够让咱们很不便的对实例进行治理。

最终采纳 pixijs + pixi-spine 插件作为厘米秀 2D 渲染技术计划。

整体分层设计:

无论是哪种渲染层计划,目前都未实现换装换动作性能,仅反对一份素材的生产,而因为业务自身的须要和特殊性须要咱们自行扩大实现这一个性能,整体设计如下:
!

顶层的业务调用层:裸露给业务应用,通过创立 Role 实例能够不便的进行 addDress、removeDress 以及 addAction 和 removeAction 等操作,上层的解决逻辑对下层通明。

业务适配层:针对厘米秀业务场景下的适配逻辑,加载厘米秀素材资源,解压并解析资源,同时在这一层调用换装扩大插件所提供的办法,批改渲染实例上的数据,包含骨骼、插槽、附件等来实现切换打扮和切换动作。

换装扩大插件:在 pixi-spine 插件的根底上再扩大,因为 pixi-spine 上不足新增和批改附件,新增骨骼插槽等 API,因而须要扩大底层办法供应下层调用。

pixi-spine 和 pixijs:最上层的渲染库提供渲染反对。

四、换装性能实现

依据笔者之前的文章所介绍的,每次插槽渲染的时候,都会依据以后 slot 的 attachmentName,去以后 skin 中获取到对应的附件。

skin 是附件查问的映射表,因而只须要到以后的 skin 中(厘米秀只有默认的 default skin),去更新对应的附件,即能够实现换装性能。

如下图所示:

正如之前渲染流程所介绍的,渲染层会遍历 slots 进行一一渲染,实质读取的是 slot 上挂载的 attachment 实例,因而咱们须要明确附件实例的构建流程,以及如果更新这些实例。

slot1 来源于 slotData1,初始化的时候会读取 slotData1 中的 attachmentName,去 skin.attachments 中查找对应的附件实例,这里每一项的 index 都是一一对应的,skin.attachments 数组中的第一项对应 slotData1,检索到对应的附件实例后赋值给对应的 slot 实例,期待被渲染层渲染。

从这里咱们能够晓得,咱们须要更新的是 slot 中的对应附件,而附件检索来自于 skin,因而咱们实际上须要更新的是 skin 上对应的附件查问表,将对应层级的附件实例更新为新的打扮生成的实例。

接下来,须要明确的第二个问题是,咱们如何生成对应的生产素材资源,生成对应的附件实例,skeletonJson 是 spine 外围库定义的用于解析 JSON 的解析器,生成对应的 skeletonData,这一过程中就包含结构 skin。

因而,咱们须要通过定义 loader 加载厘米秀素材资源,解决成对应的资源格局,通过 textureAtlas 的解决,结构出 AtlasAttachmentLoader 给 skeletonJson 调用,有了 loader,提供 json,这时候 skeletonJson 便能够解析后结构出对应的附件。

这里咱们须要做以下几件事:

1、自定义 loader 加载素材资源,解决成对应的资源格局给上层生产;

2、仿造 pixi-spine 解决流程,结构 AtlasAttachmentLoader 给 skeletonJson 调用;

3、扩大 skeletonJson 底层原型链办法,生成附件实例,将新的附件实例更新到以后 skin 对应的层级地位上。

自定义 loader 解决以及 skeletonJson 调用如下:

    // 资源加载 解析 预处理
    const filesParsing = await parsingFiles(this.src);
    const result = await loadAndDealDressFiles(this.dressId, filesParsing);
    result.json = JSON.parse(result.json);
    result.png = {[result.pngid]: result.pngContent,
    };
    const renderResource = await getRenderRes(result);
    this.renderResource = renderResource;

    ...

    // 资源生产 结构 AtlasAttachmentLoader 调用扩大 API updateAttachment
    const {renderResource} = this;
    const that = this;
    const adapter = PIXI.spine.staticImageLoader(renderResource.metadata.images);
    new PIXI.spine.core.TextureAtlas(renderResource.metadata.atlasRawData, adapter, spineAtlas => {
      let attachmentLoader;
      if (spineAtlas) {attachmentLoader = new PIXI.spine.core.AtlasAttachmentLoader(spineAtlas);
      }
      const skeletonJsonParser = new PIXI.spine.core.SkeletonJson(attachmentLoader);
      const updateSlotList = skeletonJsonParser.updateAttachment(
        that.sprite.spineData,
        renderResource.data.attachments,
      );
      ...
      that.sprite.skeleton.setToSetupPose();});

扩大 skeletonJson 底层原型链办法外围逻辑如下:

 core.SkeletonJson.prototype.updateAttachment = function(
    skeletonData,
    skinMap,
    skinName = 'default',
  ) {
    ...
    Object.keys(skinMap).forEach(slotName => {const slotIndex = skeletonData.findSlotIndex(slotName);
      if (slotIndex === -1) throw new PluginError(`Slot not found: ${slotName}`);
      const slotMap = skinMap[slotName];
      Object.keys(slotMap).forEach(entryName => {
        ...
        const attachment = this.readAttachment(slotMap[entryName],
          skin,
          slotIndex,
          entryName,
          skeletonData,
        );

        if (attachment !== null) {skin.addAttachment(slotIndex, entryName, attachment);
        }
      });
      
    });
    ...
    return updateSlotList;
  };

五、换动作性能实现

动作的解决,相比之下会比打扮要简单一些,因为动作蕴含的信息更多,骨骼信息、插槽信息、附件信息和动画信息。

在开始实现之前,须要思考一个问题:数据对象是否须要更新?间接更新实例对象可行不?

答案是否定的,理论渲染的实例对象最后来源于数据对象,然而居然理论渲染的是实例对象为啥还要去保护数据对象的更新呢?

这里出于两点思考,一个是理论在创立附件的时候仍须要用到数据对象上的信息,一个是保持数据对象和实例对象的数据关系同步,避免两者割裂不利于后续保护。

针对动作,首先咱们要更新骨骼信息:

1、更新 boneData 以及 更新 bone。

如上图所示,首先咱们须要在 skeletonData 退出新增的 boneData,接下来利用 boneData 结构出新的 bone 实例,新增到 skeleton 的 bones 中,因为 bone 的前后程序并没有严格要求,只须要父骨骼在子骨骼之前被解析即可,因而新增的 bone 也能够间接 push 到数组前面即可。

2、更新插槽信息以及关联信息:

插槽信息的更新相比骨骼会简单点,除了插槽自身的信息,还有插槽相关联的信息,且因为插槽程序有严格限度,因而每个信息的更新都要依照插槽所在的 index 来插入。

如上图所示,咱们须要在 skeletonData 中数组对应的 index 地位插入新增的 slotData,同时创立 slot 实例插入到 skeleton 实例中的正确地位,因为以后属于新增插槽阶段,因而 attachment 为 null,而 slot 最终渲染也是要检索 skin 的,因而 skin 中须要在对应地位新建一个空对象插入,因为 slotData 中自身记录了 index 信息,而新增的 slotData 会导致这些信息发生变化,以图中为例,newSlotData4 中的 index 为 4,slotData4 的 index 更新为 5,以此类推。

然而除了以上信息以外,slot 还会影响两个中央,drawOrder 以及 container:

drawOrder 在初始化时,是 slots 的浅复制,当有管制 slot 档次变动的动画存在时,会调整 drawOrder 中的程序,扭转以后的渲染层级,因而咱们须要从新对 drawOrder 进行浅复制初始化,以保障 slot 数据统一。

container 是 pixijs 上屏渲染每个 slot 中精灵对象的容器,更新 container 容器对象实质是为了用于上屏渲染,这种映射关系也是一一对应并且依照 index 程序,因而须要在 container 数组中对应地位插入新的 container 对象。

3、更新 skin 上的附件实例映射:

相似的,咱们须要更新 skin 上的附件实例映射,检索 skin 上对应的附件更新,基于后面两步,咱们曾经新建好了骨骼插槽等信息插入到正确的地位,接下来须要注册新的附件实例进 skin 中,这一步骤其实和切换打扮原理以及处理过程是相似的,这里不再赘述。

4、更新动画对象信息:

最初一步,咱们须要更新动画对象信息,须要咱们新建动画 state 对象,更新与原有的 skeleton 实例的绑定关系。

在底层扩大好更新办法,内部传入解决好的数据对象数据即可。

外围逻辑如下:

   ...
    const skeletonData = skeletonJsonParser.updateAnimation(
      this.sprite.spineData,
      renderResource.data.animations,
    );
    this.sprite.updateAnimationState(skeletonData);
    this.sprite.actionNames = Object.keys(renderResource.data.animations); 
Spine.prototype.updateAnimationState = function(skeletonData) {this.stateData = new core.AnimationStateData(skeletonData);
    this.state = new core.AnimationState(this.stateData);
    return this;
};

六、遇到的坑

1、渲染数据走缓存 要清空缓存

slot 再每次渲染的时候,都会查看 attachment,而每次渲染的时候都会判断 attachmentName 是否发生变化,以及检索附件缓存 hash,然而咱们须要更新同一个插槽的同名打扮,因而,须要咱们手动清空缓存,触发渲染更新。

     ...
      updateSlotList.forEach(({slotIndex, attachmentName}) => {that.sprite.skeleton.slots[slotIndex].data.attachmentName = attachmentName;
        // 从新设置为空触发更新
        that.sprite.skeleton.slots[slotIndex].currentSpriteName = '';
        that.sprite.skeleton.slots[slotIndex].sprites = {};
        that.sprite.skeleton.slots[slotIndex].currentMeshName = '';
        that.sprite.skeleton.slots[slotIndex].meshes = '';
      });
      that.sprite.skeleton.setToSetupPose();
      ...

2、slot index 影响多个中央 要多个中央同步

正如第五点所介绍的,在更新动作过程中,因为插槽信息关联多个信息,须要咱们去同步更新,且 index 地位严格依照地位关系解决,不可打乱。因而咱们须要更新 slotData、slotData 中的 index、slots、drawOrder、查问映射的 skin 以及 container。

3、drawOrder 是新的数组对象 不能间接复用 slots

由后面介绍的可知,drawOrder 是 slots 的浅复制,因而,咱们不能简略粗犷间接进行赋值操作,而是要老老实实复制一下 slots 数组。

this.sprite.skeleton.drawOrder = this.sprite.skeleton.slots.map(slot => slot); 

4、插入 skins 的时候要从大 index 开始

因为 skins 最后是没有对应的检索附件对象的,因而咱们创立了新的空对象,然而在插入的时候,为了保障程序不被穿插影响,因而在插入 skin 中对象的时候,咱们须要从后往前拔除。

5、flipX、flipY 不兼容、mesh 兼容问题
6、默认取首个附件为默认附件渲染

七、总结

本文章总结了 Spine 渲染整体流程,并基于以后 Spine 运行库,针对性地实现换装换动作性能,在原有 pixi-spine 上进行扩大,以满足业务须要,同时,深入分析了换装换动作性能的具体实现以及实现过程中的坑点。

感激观看~

退出移动版