一. 前言
随着“元宇宙”概念的兴起,各个互联网大厂都开始了自己的“元宇宙”探索之路,而在“元宇宙”的概念中,最吸引人的莫过于在虚拟世界中,创造一个自己独有的虚拟形象了,也正因如此,“捏脸”就成了所有“元宇宙”概念的产品中,不可或缺的一个功能。接下来,本篇文章就将介绍我们是如何基于Spine这个框架来在移动端探索和实现一个“捏脸”功能的。
二. Spine介绍 2.1 什么是Spine?
Spine 是一款针对游戏开发的 2D 骨骼动画编辑工具。Spine为我们提供了高效简洁的工作流程,在 Spine 中,通过将图片绑定到骨骼上,然后再控制骨骼实现动画。
相比于传统的帧动画方案,Spine骨骼动画有以下优势:
最小的体积:传统的动画需要提供每一帧图片。而 Spine 动画只保存骨骼的动画数据,它所占用的空间非常小,并能为你的游戏提供独一无二的动画。
美术需求:Spine 动画需要的美术资源更少,能为您节省出更多的人力物力更好地投入到游戏开发中去。
流畅性:Spine 动画使用差值算法计算中间帧,这能让你的动画总是保持流畅的效果。
装备附件:图片绑定在骨骼上来实现动画。如果你需要可以方便地更换角色的装备满足不同的需求。甚至改变角色的样貌来达到动画重用的效果。
混合:动画之间可以进行混合。比如一个角色可以开枪射击,同时也可以走、跑、跳或者游泳。
程序动画:可以通过代码控制骨骼,比如可以实现跟随鼠标的射击,注视敌人,或者上坡时的身体前倾等效果。
更多Spine的介绍,可以查看Spine官方网站:http://zh.esotericsoftware.com
2.2 Spine VS 龙骨
目前市面上常用的2D骨骼动画引擎除了Spine之外,还有一个就是龙骨(DragonBones)了,经过调研我们列出了二者的优劣对比如下:
费用方面:Spine是商用收费软件,需要按使用人数购买,而龙骨是完全免费的。
功能支持方面:对于主要的功能,如IK,网格等,Spine和龙骨都是支持的,但是Spine拥有的功能特性更加丰富,如重复图片合并,路径约束等。
稳定性:Spine作为一款成熟的商用软件,软件自身的稳定性还是比较高的,而龙骨相比之下还略显“年轻”,在开发过程中可能遇到的“坑”会多一些。
平台支持力度:Spine提供了非常丰富的运行库,支持Unreal,Unity,cocos2d等几乎所有主流游戏引擎,且导入资源方便,而龙骨支持的游戏引擎则相对较少。
虽然龙骨在费用方面比Spine更有优势,但是Spine在其他方面比龙骨更加的完善和成熟,出于稳定性和易用性的考虑,我们最终还是选择了Spine。
2.3 Spine中的基本概念
不管是设计还是开发,在我们开始使用Spine大干一番之前,都必须先了解Spine中的一些基本概念和术语,如此才能更好地使用它。
骨骼(Bone):所谓骨骼动画,顾名思义,便是通过驱动骨骼来实现人物动作。在Spine中,每一个人物都是由若干个骨骼(Bone)组合起来的,每个骨骼都可以有它的父骨骼和子骨骼,而每个人物都会有一个根骨骼,根骨骼是所有其他骨骼的“祖先”,从根骨骼开始向下延伸,便形成了一个树状结构。
插槽(Slot):插槽(Slot)是骨骼上的一个占位符,一个插槽上可以添加一个附件(Attachment),也可以通过代码在运行时修改或移除插槽上的附件,并且每个插槽可以定义自己的颜色。
附件(Attachment):附件是放置在插槽中的一块贴图,我们可以通过替换附件来更新不同骨骼上的贴图。
经过上面的介绍,我们知道了在Spine中每个人物的基本结构就是:骨骼->插槽->附件。在Spine编辑软件中,我们可以清晰的看到Spine的这种结构设计:
动画设计同学在Spine编辑软件上完成人物动画设计后,会导出一份动画资源,而我们需要将这份资源存在客户端本地,并在运行时加载这份资源以渲染人物动画,下面是Spine的导出资源示例:
Spine导出的资源主要包含以下3个部分:
贴图文件(.png):1个或多个Png图片,Spine会将我们用到的所有贴图都合并在一张大图上,当一张图装不下的时候便会再生成一张大图。之所以这样做是因为动画用的贴图往往很多,把多个贴图合并到一张大图上,这样在运行时就可以直接加载一张大图资源到内存即可,而不用每个贴图都加载一次,避免了频繁的I/O操作,大大提高了读取效率。
图集文件(.atlas):Spine将多个贴图合并到了一个大图上,那么在渲染某个特定的贴图时就必须知道这个贴图在大图中的位置以及它的大小,这样才能把这个贴图从大图中截取出来,而图集文件就是用来实现这个功能的,它描述了每个贴图在大图中的位置,其结构如下:
骨骼数据文件(.json):这个文件描述了人物动画的所有基本信息,包括人物的骨骼结构,插槽,附件,以及这个人物的所有动画的信息。如下所示:
了解了Spine的基本概念后,我们再来看下在代码中Spine的各个部分是如何组织在一起的。
以下是Spine运行库的类图:
实例对象与数据对象:
数据对象:类名以“Data”结尾,只包含骨架数据,用来创建骨架的实例对象,数据对象是无状态的,因而可以在多个骨架实例之间共用。
实例对象:通常与数据对象名称相同但不含“Data”结尾,实例对象是有状态的,一个实例对象就代表了一个具体的人物骨架。
为了满足捏脸形象的个性化和多样化,每一种商品类型都可以随机搭配,并且可以修改任意颜色。下面我们就来介绍下捏脸换装的实现方案和一些遇到的问题及解决方案。
3.1 传统实现方案
对于换装功能,官方或者是业内通用的做法是使用“皮肤”功能,将几种类型的商品归类于一种皮肤,由设计同学将多种皮肤的组合数据添加到设计项目中,然后将导出的 spine 文件提供给开发使用。具体实现方法可以参考 Spine 官方示例:http://zh.esotericsoftware.com/spine-demos
传统实现方案的局限性:
在绝大部分的游戏工程中,这样做完全没有问题,因为人物角色不会有很多皮肤,并且每套皮肤中各个元素固定。但是在我们的项目中,各个商品可以随机搭配,如果使用提前录入皮肤的这种方案,只要有一种商品不同,就需要创建一套新的皮肤。这样做可能使 Spine 文件中包含几万甚至十几万种皮肤,不止对于设计同学来说工作量巨大,而且在程序中创建一个 Spine 对象后导致内存的开销成本也是难以估计。
3.2 动态替换实现方案
既然更换商品突出了随机性,那么我们可以尝试在运行时,动态地修改 Spine 对象的数据来实现换装功能。
3.2.1 更改样式
Spine的人物是按照骨骼->插槽->附件这个结构组织起来的,所以我们的实现思路就是根据用户选择的商品样式,动态的替换人物对应部位的贴图资源,而替换贴图其实就是替换附件。
具体方案如下:
代码实现示例:
public void changeStyle(String part, String style) {
//找到部位对应的Spine的插槽
String slotName = findSlotNameByPart(part);
Slot slot = skeleton.findSlot(slotName);
//根据插槽名和样式编号找到对应的附件
Attachment attachment = findAttachment(slotName, style);
//替换插槽的附件
slot.setAttachment(attachment);
3.2.2 更改颜色
某些部位是支持用户自己调整颜色的,比如头发,肤色,瞳孔的颜色等。在Spine中,部位的颜色是可以通过修改插槽的颜色来实现的,所以更改颜色的方案和更改样式的方案是类似的,具体方案如下:
代码实现示例:
public void changeColor(String part, String color){
//找到部位对应的Spine的插槽
String slotName = findSlotNameByPart(part);
Slot slot = skeleton.findSlot(slotName);
//给插槽设置新的颜色
slot.getColor().set(Color.valueOf(color));
3.2.3 实现效果演示
3.3 解决播放动画产生的问题
在我们实现换装功能后,发现在播放动画时,人物形象会恢复到 Spine 文件中默认的样子:
产生这个问题的原因是,Spine在播放动画前必须调用下面的重置方法,重新装配人物姿势:
skeleton.setToSetupPose();
调用此方法后,可以将资源、颜色、变换等信息重置为初始状态,那么我们之前设置好的捏脸换装和颜色也会因为重置而恢复默认。
在查看Spine的源码后发现,这个重置方法是通过数据对象SlotData中:attachmentName
和color
这2个字段重置附件和颜色的。那么我们就可以在更新附件和颜色的同时,将SlotData中记录的值一并修改:
public void changeStyle(String part, String style) {
String slotName = findSlotNameByPart(part);
Slot slot = skeleton.findSlot(slotName);
Attachment attachment = findAttachment(slotName, style);
slot.setAttachment(attachment);
// 修改SlotData的值
slot.getData().setAttachmentName(attachment.getName());
public void changeColor(String part, String color){
String slotName = findSlotNameByPart(part);
Slot slot = skeleton.findSlot(slotName);
slot.getColor().set(Color.valueOf(color));
// 修改SlotData的值
slot.getData().getColor().set(Color.valueOf(color));
修改后的效果:
如上图所示,播放动画前恢复默认的问题已经解决,但是细心的朋友可能发现,眼睛资源在播放动画的过程中恢复了默认,并且动画结束后也没有复原。
通过查看Spine源码发现,眼睛的关键帧是根据数据对象:Animation
中记录的信息取到的,所以在修改样式时,光是修改上文所说的SlotData
是不够的,还需要修改Animation
中的关键帧信息:
public void changeAnimationAttachment(String slotName, String style) {
SkeletonData skeletonData = skeleton.getData();
// 遍历数据对象中的所有Animation
for (Animation animation : skeletonData.getAnimations()) {
// 找到Animation中在这个插槽上的所有Timeline
Array timelines = findAttachmentsTimelinesInAnimation(animation, slotName);
// 遍历所有的Timeline,更改Timeline中关键帧信息
for (Animation.AttachmentTimeline timeline : timelines) {
String[] oldAttachments = timeline.getAttachmentNames();
float[] frameTimes = timeline.getFrames();
for (int frameIndex = 0; frameIndex < oldAttachments.length; frameIndex++) {
String oldAttachment = oldAttachments[frameIndex];
String newAttachment = replaceAttachmentStyle(oldAttachment, style);
timeline.setFrame(frameIndex, frameTimes[frameIndex], newAttachment);
这样问题就完美解决了,可以看下最终效果:
3.4 保存形象数据
在实现捏脸功能后,接下来我们还需要把用户创造的形象数据保存下来,并上传到服务端,这样才能让其他人也看到你精心创作的形象。
前面我们介绍过,Spine的资源中就有一个用来记录人物形象数据的Json文件,所以一个最直接的方案就是:在保存形象时,直接在这个文件基础上修改,然后上传服务器,当渲染人物形象时,因为这个Json的数据结构和Spine导出的Json是完全一致的,所以可以直接送到Spine引擎去渲染。
但是很快我们就发现了这个方案的弊端:
Spine导出的这个Json文件记录了所有的骨骼动画数据,所以文件体积非常大,达到了2MB之多,这无疑会造成严重的流量消耗。
这个Json文件中有些数据是通用的,但是每个形象都记了一份,就导致了数据冗余和流量浪费。
鉴于以上所说的问题,我们就需要自己设计形象数据的结构了。而对于捏脸功能而言,其实我们只需要记录这个形象的每个部位选择了哪个样式,选择了什么颜色就可以了,既然如此,我们可以这样设计:
// 资源版本
"version": "1.0",
"items": [{
// 部位名称
"part": "hair",
// 选择了什么样式
"style": "002",
// 选择了什么颜色
"color": "AABBCCFF"
},
"part": "nose",
"style": "005",
"color": "AABBCCFF"
这样一来,我们就大大减少了数据的体积,Json文件大小只有4KB了。
当然,按照这种格式保存形象数据后,我们就无法将这个数据直接送到Spine引擎渲染了,所以在每次渲染形象时,我们先加载Spine的默认形象数据,然后再根据我们的Json文件里记录的数据,逐个修改每一个部位的样式就可以了。
3.5 Spine资源动态更新
默认情况下,Spine编辑软件导出的资源文件是存在客户端本地的,但是在我们的捏脸功能中,用户可以选择的商品并不是一成不变的,可能会新增商品样式,也可能会下线一些老的商品,这样的话资源文件存在本地就有问题了:
资源更新不及时,新的商品上线依赖客户端发版。
版本兼容问题,在老版本客户端上,无法展示新版本客户端上创造的人物形象。
所以我们需要实现Spine资源动态更新的功能:在Spine动画设计同学更新一版资源后,将资源文件打包,然后由服务端更新版本号并下发资源包到客户端。
至于资源下发的方式,一种是“推”的方式,即服务端更新后通过向客户端推送一条消息告知客户端下载新包,另一种就是“拉”的方式,即客户端在某些特定时机主动检查版本号,然后更新资源包。我们现在选择的就是“拉”的方式,具体方案如下:
四. 移动端接入Spine的问题和优化 4.1 单实例问题 4.1.1 问题由来
Spine本身是一款为游戏开发提供支持的软件,因此Spine动画的渲染必须通过接入游戏引擎来完成,在Android端我们选用的是GDX引擎,在iOS端则是Cocos2d引擎。
然而,传统的移动端App开发和游戏开发有很大不同,不论是GDX还是Cocos2d,或者是其他的游戏引擎,在移动端使用时都必须采用“单例模式”开发,也就是全局只能有一个View负责渲染全部Spine内容。
如果是开发一个纯粹的手机游戏,那么“单例模式”完全没问题,但是如果在一个以原生页面为主体的传统App中使用Spine,“单例模式”就带来一些棘手的问题:
无法同时使用多个View渲染:
当同一屏中出现多个Spine人物时,我们的理想状态是每一个View负责渲染一个单独的人物,这样我们就可以任意摆放这些View的位置,但是在“单例模式”下这是不可能的。而如果用一个View渲染多个人物,代码逻辑就会非常复杂,且难以和原生界面结合。
页面切换时需要动态的添加/移除这个单例View:
由于全局只有一个View,当我们从一个有Spine人物展示的页面进入另一个页面时,需要把单例View从老的页面上移除并添加到新的页面,当退回上一页时又需要把单例View从新页面移除添加到老页面。如此就使得代码变得复杂和难以维护。
鉴于上述“单例模式”导致的诸多问题,为了更好的支持后续功能的开发,我们决定优化引擎源码,目标是使展示Spine的View从单实例变为支持多实例。接下来便是具体介绍在Android,iOS平台我们是如何解决这个问题的。
4.1.2 Android端优化方案
在Android端,我们选择的是GDX引擎来渲染Spine动画的,优化之前,我们先看下GDX引擎原本的设计是怎样的?
GDX原始设计:
GDX定义了App接口,是GDX引擎和Android原生交互的接口,一般使用一个单例的Activity实现。
应用启动后,将App接口的实例对象注册到GDX的全局变量。
SpineView渲染动画时,每一次的渲染通过GDX引擎完成,GDX引擎内部又会通过之前注册的GDX全局变量调用App接口的方法。由于GDX全局变量是单实例的,导致SpineVIew也必须是单实例的。
优化方案:
使SpineView直接实现App接口,并且创建一个适配器类也实现App接口,这个适配器类主要用来管理多个实现了App接口的实例,也就是SpineView实例。
适配器内部维护一个Map,每当一个SpineView创建后,使用SpineView的渲染线程的线程ID作为Key存到Map中。
将适配器注册到GDX全局变量中,而不是单例的Activity。
当SpineView请求GDX引擎渲染时,GDX全局变量调用的是适配器的方法,而适配器则根据当前的线程ID,将回调分发给真正的App接口实例,这样多个SpineView的渲染回调就可以区分开了,也就可以同时渲染了。
其实这个优化方案一言以蔽之,就是利用适配器模式,将原本的单行线拓展成了多线并行,就好比一个插座原本只能插一个插头,但是我们在插座上先插一个插排,然后在插排上就可以同时插多个插头了。
4.1.3 iOS端优化方案
iOS 端使用的渲染引擎是 cocos2d-objc,同样存在单实例的问题。单例管理器 CCDirector 中持有全局唯一渲染视图 CCGLView,如果我们想改造成多视图渲染,首先要了解整个渲染流程,从中判断在何处拆分合适。每一帧的渲染从 CCDirector 的drawScene
绘制方法开始,通过visit:parentTransform:
遍历当前场景的所有 UI 树,在排序后,绘制到单例视图 CCGLView 中。那么我们可以在遍历节点树之前,遍历当前场景下的所有一级节点,将其分开绘制到不同的自定义视图中:
这种方案的好处是,不用修改 cocos2d 的源码,通过 swizzle 以下几个类,并且根据 CCGLView 创建自定义渲染视图就可以实现:
4.2 内存占用问题
由于移动设备的硬件资源和机能有限,所以我们在开发移动端App时对于内存占用往往十分敏感。目前我们设计了大量不同样式的商品供用户选择,这大大丰富了捏脸换装功能的趣味性,但是大量的商品意味着Spine中的贴图资源也变得庞大起来,使我们不得不开始考虑内存性能问题。
经过一番研究,我们决定通过以下几个方案优化内存占用问题:
降低单个贴图的分辨率:
对于移动端而言,不需要太过精细的贴图,因此可以降低贴图的分辨率来减少内存占用。
合并重复贴图:
默认情况下,每个附件对应一个单独的贴图,但是有些附件其对应的贴图在内容上可能是一样的,这些贴图可以认为是“重复贴图”。我们可以在导出Spine资源时开启Spine软件自带的贴图合并功能,将这些不同的附件ID映射到同一个贴图上。
在运行时复用OpenGL纹理:
在默认情况下,多个SpineView各自持有一份纹理资源,这就导致每有一个SpineView开始渲染,纹理占用的内存就多加一倍。我们可以在每次创建一个新的SpineView时,使其共用同一个OpenGL上下文,这样就可以使多个不同SpineView之间共享纹理资源,也就是说全局只需要保存一份纹理资源即可。
优化前后对比:
优化前:
优化后:
可以看到,在使用了上面介绍的优化方案后,测试发现内存占用降低了55%(Android平台测试),说明我们的优化方案是有效的。
遗留问题和后续改进计划:
在使用了上面所说的优化方案后,虽然使内存占用降低了,但是如果商品样式持续增加,内存占用依旧会不断增长。为了解决这个问题,我们计划后续从以下几个方面改进:
分步加载纹理:现在渲染一个人物时会一次性加载一个由全部贴图合并成的大贴图,这导致了当前人物形象没用到的贴图也被加载到内存,比如当前人物是002号样式的鼻子,其他样式的鼻子贴图也会一并加载到内存。可以改进为只加载当前人物形象用到的贴图,而不是全部贴图。
改进动画的实现方式:现在有些动画是通过类似序列帧的方式实现的(如嘴型的变化),每一帧都是一个贴图。可以改为只通过骨骼形变实现动画,这样就不需要多个贴图了。
经过了这一番的探索,我们积累了不少关于Spine,关于实现捏脸换装功能的技术经验,同时也拓宽了自己的技术视野,但更重要的是,为后续整个“元宇宙”之旅的探索开了头,未来的路还很长,需要我们不忘初心,砥砺前行。
热门跟贴