基於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上進行擴展,以知足業務須要,同時,深刻分析了換裝換動做功能的具體實現以及實現過程當中的坑點。

感謝觀看~

相關文章
相關標籤/搜索