2D商城換裝業務不一樣於廣泛業務場景,存在大量換裝和切換動做的需求,但業界內現有運行庫缺乏對應的API實現與支持。所以,本課程將帶領你們深刻Spine 2D渲染底層原理,深刻Spine源碼運行庫,分析其核心模塊和渲染層的調用和處理流程,並基於此介紹如何基於PIXI-spine實現換裝和換動做功能,以及功能實現過程當中的難點。
Spine中比較重要的幾個基本概念,可參照筆者以前分享的一篇文章,包括對骨架、骨骼、附件、插槽以及皮膚的概念理解。web
這裏還須要補充強調一個概念:數據對象和實例對象的關係與區別。json
數據對象是無狀態的,可在任意數量的骨架實例間共用。有對應實例數據的數據對象類名稱以「Data」結尾,沒有對應實例數據的數據對象則沒有後綴,如附件、皮膚及動畫。canvas
實例對象有許多屬性與數據對象相同。數據對象中的屬性表明裝配姿式,一般不會改動。實例對象中的相同屬性表示播放動畫時該實例的當前姿式。每一個實例對象保有一個其數據對象參考,用於將實例對象重置回裝配姿式。數組
例如,SkeletonData是數據對象,而Skeleton是實例對象。一樣的,Bone實例對象會有對應的BoneData,Slot實例對象會有對應的SlotData等。瀏覽器
業務背景:緩存
公司內部業務存在大量換裝和切換動做的需求,所以Spine編輯器導出的素材,也須要進行拆分,將"頭飾"、"髮型"、"上衣"等逐個歸類拆分爲一個個dress;一樣的,因爲動做繁多且多變,動做也需單獨拆分爲一個個action,而動做內又可能發生裝扮的替換,所以拆分出來的動做素材內可能同時含有骨骼信息和裝扮信息。編輯器
以接下來這個揮鐮刀的動做爲例,將被拆分爲一下幾部分:
插拔思想:動畫
基於前面的拆分,爲了讓人物能夠方便的進行裝扮的替換,動做的切換,咱們須要將裝扮和動做都設計成可"插拔"的形式,本質上都是基於初始的基礎骨骼,而後繼續往骨架上新增裝扮附件,或者新增骨骼信息,而這些新增的骨骼和裝扮在將來某一個一樣能夠從當前骨架中"摘除"。實現裝扮的替換或者動做的切換。webgl
渲染庫選型:ui
渲染層\比較項 | 兼容性 | 封裝程度 | 可拓展性 |
---|---|---|---|
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即可以解析後構造出對應的附件。
這裏咱們須要作如下幾件事:
一、自定義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; };
動做的處理,相比之下會比裝扮要複雜一些,由於動做包含的信息更多,骨骼信息、插槽信息、附件信息和動畫信息。
在開始實現以前,須要思考一個問題:數據對象是否須要更新?直接更新實例對象可行不?
答案是否認的,實際渲染的實例對象最初來源於數據對象,可是居然實際渲染的是實例對象爲啥還要去維護數據對象的更新呢?
這裏出於兩點考慮,一個是實際在建立附件的時候仍須要用到數據對象上的信息,一個是保持數據對象和實例對象的數據關係同步,防止二者割裂不利於後續維護。
針對動做,首先咱們要更新骨骼信息:
一、更新boneData 以及 更新bone。
如上圖所示,首先咱們須要在skeletonData加入新增的boneData,接下來利用boneData構造出新的bone實例,新增到skeleton的bones中,因爲bone的先後順序並無嚴格要求,只須要父骨骼在子骨骼以前被解析便可,所以新增的bone也能夠直接push到數組後面便可。
二、更新插槽信息以及關聯信息:
插槽信息的更新相比骨骼會複雜點,除了插槽自己的信息,還有插槽相關聯的信息,且因爲插槽順序有嚴格限制,所以每一個信息的更新都要按照插槽所在的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對象。
三、更新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; };
一、渲染數據走緩存 要清空緩存
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兼容問題
六、默認取首個附件爲默認附件渲染
本文章總結了Spine渲染總體流程,並基於當前Spine運行庫,針對性地實現換裝換動做功能,在原有pixi-spine上進行擴展,以知足業務須要,同時,深刻分析了換裝換動做功能的具體實現以及實現過程當中的坑點。
感謝觀看~