基于Spine的2D形象换装换动做实现

2D商城换装业务不一样于广泛业务场景,存在大量换装和切换动做的需求,但业界内现有运行库缺乏对应的API实现与支持。所以,本课程将带领你们深刻Spine 2D渲染底层原理,深刻Spine源码运行库,分析其核心模块和渲染层的调用和处理流程,并基于此介绍如何基于PIXI-spine实现换装和换动做功能,以及功能实现过程当中的难点。

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

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

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

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

实例对象有许多属性与数据对象相同。数据对象中的属性表明装配姿式,一般不会改动。实例对象中的相同属性表示播放动画时该实例的当前姿式。每一个实例对象保有一个其数据对象参考,用于将实例对象重置回装配姿式。数组

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

2、Spine渲染总体流程图

image.png

3、设计思路

业务背景:缓存

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

以接下来这个挥镰刀的动做为例,将被拆分为一下几部分:
image.png
插拔思想:动画

基于前面的拆分,为了让人物能够方便的进行装扮的替换,动做的切换,咱们须要将装扮和动做都设计成可"插拔"的形式,本质上都是基于初始的基础骨骼,而后继续往骨架上新增装扮附件,或者新增骨骼信息,而这些新增的骨骼和装扮在将来某一个一样能够从当前骨架中"摘除"。实现装扮的替换或者动做的切换。webgl

渲染库选型:ui

渲染层\比较项 兼容性 封装程度 可拓展性
canvas 不支持网格附件、着色
webgl
threejs 不支持两种颜色着色和混合模式 通常
pixijs

在针对渲染层采用的技术方案的比较中,canvas和threejs仍存在一些兼容性问题;因为canvas和webgl都用浏览器原始画布来作渲染,所以封装程度较低,若是要投入到业务中须要进行二次封装;考虑到2D动画渲染以及将来的业务功能的可拓展性上,pixijs相对更有优点,基于pixijs的封装可让咱们很方便的对实例进行管理。

最终采用pixijs + pixi-spine 插件做为厘米秀2D渲染技术方案。

总体分层设计:

不管是哪一种渲染层方案,目前都未实现换装换动做功能,仅支持一份素材的消费,而因为业务自己的须要和特殊性须要咱们自行扩展实现这一个功能,总体设计以下:
!image.png

顶层的业务调用层:暴露给业务使用,经过建立Role实例能够方便的进行addDress、removeDress以及addAction和removeAction等操做,下层的处理逻辑对上层透明。

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

换装扩展插件:在pixi-spine插件的基础上再扩展,因为pixi-spine上缺少新增和修改附件,新增骨骼插槽等API,所以须要扩展底层方法供给上层调用。

pixi-spine和pixijs:最下层的渲染库提供渲染支持。

4、换装功能实现

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

skin是附件查询的映射表,所以只须要到当前的skin中(厘米秀只有默认的default skin),去更新对应的附件,便可以实现换装功能。

以下图所示:
image.png

正如以前渲染流程所介绍的,渲染层会遍历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即可以解析后构造出对应的附件。

这里咱们须要作如下几件事:

一、自定义loader加载素材资源,处理成对应的资源格式给下层消费;

二、仿造pixi-spine处理流程,构造AtlasAttachmentLoader给skeletonJson调用;

三、扩展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;
  };

5、换动做功能实现

动做的处理,相比之下会比装扮要复杂一些,由于动做包含的信息更多,骨骼信息、插槽信息、附件信息和动画信息。

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

答案是否认的,实际渲染的实例对象最初来源于数据对象,可是居然实际渲染的是实例对象为啥还要去维护数据对象的更新呢?

这里出于两点考虑,一个是实际在建立附件的时候仍须要用到数据对象上的信息,一个是保持数据对象和实例对象的数据关系同步,防止二者割裂不利于后续维护。

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

一、更新boneData 以及 更新bone。
image.png
如上图所示,首先咱们须要在skeletonData加入新增的boneData,接下来利用boneData构造出新的bone实例,新增到skeleton的bones中,因为bone的先后顺序并无严格要求,只须要父骨骼在子骨骼以前被解析便可,所以新增的bone也能够直接push到数组后面便可。

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

插槽信息的更新相比骨骼会复杂点,除了插槽自己的信息,还有插槽相关联的信息,且因为插槽顺序有严格限制,所以每一个信息的更新都要按照插槽所在的index来插入。
image.png

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

然而除了以上信息之外,slot还会影响两个地方,drawOrder以及container:
image.png

drawOrder在初始化时,是slots的浅复制,当有控制slot层次变化的动画存在时,会调整drawOrder中的顺序,改变当前的渲染层级,所以咱们须要从新对drawOrder进行浅复制初始化,以保证slot数据一致。

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

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

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

四、更新动画对象信息:

最后一步,咱们须要更新动画对象信息,须要咱们新建动画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;
};

6、遇到的坑

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

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();
      ...

二、slot index影响多个地方 要多个地方同步

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

三、drawOrder是新的数组对象 不能直接复用slots

由前面介绍的可知,drawOrder是slots的浅复制,所以,咱们不能简单粗暴直接进行赋值操做,而是要老老实实复制一下slots数组。

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

四、拔出skins的时候要从大index开始

因为skins最初是没有对应的检索附件对象的,所以咱们建立了新的空对象,可是在拔出的时候,为了保证顺序不被交叉影响,所以在拔出skin中对象的时候,咱们须要从后往前拔除。

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

7、总结

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

感谢观看~

相关文章
相关标签/搜索