移动端Spine骨骼动画和捏脸换装技术探索

移动端Spine骨骼动画和捏脸换装技术探索

首页动作格斗Spine手游更新时间:2024-04-11
一. 前言

随着“元宇宙”概念的兴起,各个互联网大厂都开始了自己的“元宇宙”探索之路,而在“元宇宙”的概念中,最吸引人的莫过于在虚拟世界中,创造一个自己独有的虚拟形象了,也正因如此,“捏脸”就成了所有“元宇宙”概念的产品中,不可或缺的一个功能。接下来,本篇文章就将介绍我们是如何基于Spine这个框架来在移动端探索和实现一个“捏脸”功能的。

二. Spine介绍2.1 什么是Spine?

Spine 是一款针对游戏开发的 2D 骨骼动画编辑工具。Spine为我们提供了高效简洁的工作流程,在 Spine 中,通过将图片绑定到骨骼上,然后再控制骨骼实现动画。

相比于传统的帧动画方案,Spine骨骼动画有以下优势:

更多Spine的介绍,可以查看Spine官方网站:http://zh.esotericsoftware.com

2.2 Spine VS 龙骨

目前市面上常用的2D骨骼动画引擎除了Spine之外,还有一个就是龙骨(DragonBones)了,经过调研我们列出了二者的优劣对比如下:

虽然龙骨在费用方面比Spine更有优势,但是Spine在其他方面比龙骨更加的完善和成熟,出于稳定性和易用性的考虑,我们最终还是选择了Spine。

2.3 Spine中的基本概念

不管是设计还是开发,在我们开始使用Spine大干一番之前,都必须先了解Spine中的一些基本概念和术语,如此才能更好地使用它。

经过上面的介绍,我们知道了在Spine中每个人物的基本结构就是: 骨骼->插槽->附件。在Spine编辑软件中,我们可以清晰的看到Spine的这种结构设计:

动画设计同学在Spine编辑软件上完成人物动画设计后,会导出一份动画资源,而我们需要将这份资源存在客户端本地,并在运行时加载这份资源以渲染人物动画,下面是Spine的导出资源示例:

Spine导出的资源主要包含以下3个部分:

2.4 Spine代码架构

了解了Spine的基本概念后,我们再来看下在代码中Spine的各个部分是如何组织在一起的。

以下是Spine运行库的类图:

实例对象与数据对象:

三. 实现捏脸换装功能

为了满足捏脸形象的个性化和多样化,每一种商品类型都可以随机搭配,并且可以修改任意颜色。下面我们就来介绍下捏脸换装的实现方案和一些遇到的问题及解决方案。

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中:attachmentNamecolor这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<Animation.AttachmentTimeline> 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引擎去渲染。

但是很快我们就发现了这个方案的弊端:

  1. Spine导出的这个Json文件记录了所有的骨骼动画数据,所以文件体积非常大,达到了2MB之多,这无疑会造成严重的流量消耗。
  2. 这个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编辑软件导出的资源文件是存在客户端本地的,但是在我们的捏脸功能中,用户可以选择的商品并不是一成不变的,可能会新增商品样式,也可能会下线一些老的商品,这样的话资源文件存在本地就有问题了:

  1. 资源更新不及时,新的商品上线依赖客户端发版。
  2. 版本兼容问题,在老版本客户端上,无法展示新版本客户端上创造的人物形象。

所以我们需要实现Spine资源动态更新的功能:在Spine动画设计同学更新一版资源后,将资源文件打包,然后由服务端更新版本号并下发资源包到客户端。

至于资源下发的方式,一种是“推”的方式,即服务端更新后通过向客户端推送一条消息告知客户端下载新包,另一种就是“拉”的方式,即客户端在某些特定时机主动检查版本号,然后更新资源包。我们现在选择的就是“拉”的方式,具体方案如下:

四. 移动端接入Spine的问题和优化4.1 单实例问题4.1.1 问题由来

Spine本身是一款为游戏开发提供支持的软件,因此Spine动画的渲染必须通过接入游戏引擎来完成,在Android端我们选用的是GDX引擎,在iOS端则是Cocos2d引擎。

然而,传统的移动端App开发和游戏开发有很大不同,不论是GDX还是Cocos2d,或者是其他的游戏引擎,在移动端使用时都必须采用“单例模式”开发,也就是全局只能有一个View负责渲染全部Spine内容。

如果是开发一个纯粹的手机游戏,那么“单例模式”完全没问题,但是如果在一个以原生页面为主体的传统App中使用Spine,“单例模式”就带来一些棘手的问题:

  1. 无法同时使用多个View渲染:
  2. 当同一屏中出现多个Spine人物时,我们的理想状态是每一个View负责渲染一个单独的人物,这样我们就可以任意摆放这些View的位置,但是在“单例模式”下这是不可能的。而如果用一个View渲染多个人物,代码逻辑就会非常复杂,且难以和原生界面结合。
  3. 页面切换时需要动态的添加/移除这个单例View:
  4. 由于全局只有一个View,当我们从一个有Spine人物展示的页面进入另一个页面时,需要把单例View从老的页面上移除并添加到新的页面,当退回上一页时又需要把单例View从新页面移除添加到老页面。如此就使得代码变得复杂和难以维护。

鉴于上述“单例模式”导致的诸多问题,为了更好的支持后续功能的开发,我们决定优化引擎源码,目标是使展示Spine的View从单实例变为支持多实例。接下来便是具体介绍在Android,iOS平台我们是如何解决这个问题的。

4.1.2 Android端优化方案

在Android端,我们选择的是GDX引擎来渲染Spine动画的,优化之前,我们先看下GDX引擎原本的设计是怎样的?

GDX原始设计:

  1. GDX定义了App接口,是GDX引擎和Android原生交互的接口,一般使用一个单例的Activity实现。
  2. 应用启动后,将App接口的实例对象注册到GDX的全局变量。
  3. SpineView渲染动画时,每一次的渲染通过GDX引擎完成,GDX引擎内部又会通过之前注册的GDX全局变量调用App接口的方法。由于GDX全局变量是单实例的,导致SpineVIew也必须是单实例的。

优化方案:

  1. 使SpineView直接实现App接口,并且创建一个适配器类也实现App接口,这个适配器类主要用来管理多个实现了App接口的实例,也就是SpineView实例。
  2. 适配器内部维护一个Map,每当一个SpineView创建后,使用SpineView的渲染线程的线程ID作为Key存到Map中。
  3. 将适配器注册到GDX全局变量中,而不是单例的Activity。
  4. 当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中的贴图资源也变得庞大起来,使我们不得不开始考虑内存性能问题。

经过一番研究,我们决定通过以下几个方案优化内存占用问题:

优化前后对比:

优化前:

优化后:

可以看到,在使用了上面介绍的优化方案后,测试发现内存占用降低了55%(Android平台测试),说明我们的优化方案是有效的。

遗留问题和后续改进计划:

在使用了上面所说的优化方案后,虽然使内存占用降低了,但是如果商品样式持续增加,内存占用依旧会不断增长。为了解决这个问题,我们计划后续从以下几个方面改进:

五. 结语

经过了这一番的探索,我们积累了不少关于Spine,关于实现捏脸换装功能的技术经验,同时也拓宽了自己的技术视野,但更重要的是,为后续整个“元宇宙”之旅的探索开了头,未来的路还很长,需要我们不忘初心,砥砺前行。

作者:刘昊源、马岩

来源:微信公众号:网易传媒技术团队

出处:https://mp.weixin.qq.com/s/S6ByoK-KP9qJ_Qq7s0LiTQ

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved