spine-flutter 运行时文档

Licensing

将官方的Spine Runtime整合到你的应用程序中前请阅读 Spine 运行时许可.

开始使用

spine-flutter运行时是基于spine-cpp运行时, 以Flutter FFI插件的形式实现的. 它支持Flutter所支持的所有平台(桌面、Android、iOS和Web), 并支持除tint black外的全部Spine功能.

安装

spine-flutter支持Flutter 3.10.5之后的所有版本. 要在Flutter项目中使用spine-flutter, 请在项目的pubspec.yaml文件中添加以下依赖关系(dependency):

yaml
dependencies:
...
spine_flutter: ^4.1.1

应确保spine-flutter的major.minor版本与导出资产的Spine编辑器中的major.minor版本一致. 更多信息请参阅Spine 版本控制指南.

在你的main()函数的开头添加以下两行, 以初始化 spine-flutter 运行时:

dart
void main() async {
   WidgetsFlutterBinding.ensureInitialized();
   await initSpineFlutter(enableMemoryDebugging: false);
   ...
}

注意: main()方法必须为async.

Web部署依赖于CanvasKit, 截止本文撰写时, 将其加入Web部署的额外开销约 2MB. 您可以像这样为Web部署添加Canvaskit:

flutter build web --web-renderer canvaskit

有关 Web 渲染器 的更多信息, 请参阅 Flutter 文档.

运行时示例

spine-flutter 运行时包括几个展示其功能的示例.

若你的 spine_flutter 软件包是直接从 pub.dev 下载的, 那么按照以下步骤运行位于 example/ 目录下的示例项目即可:

bash
cd path/to/downloaded/spine_flutter
cd example
flutter run

如若不然, 则需要按照以下步骤运行示例项目:

  1. 安装 Flutter SDK, 然后运行 flutter doctor 命令, 它会指示你安装其他依赖项.
  2. 克隆spine-runtimes仓库: git clone https://github.com/esotericsoftware/spine-runtimes
  3. 运行 spine-flutter/ 文件夹中的 setup.sh 脚本. 在Windows下, 可以使用Git for Window内置的 Git Bash 来运行 setup.sh Bash脚本.

然后, 你可以在支持Flutter的集成开发环境或编辑器, 如 IntelliJ IDEA/Android StudioVisual Studio Code 中打开 spine-flutter 项目以查看并运行示例.

当然, 也可以用 命令行 直接运行示例项目.

运行时中包含了以下示例:

更新spine-flutter运行时

在更新项目的中的spine-flutter运行时前, 请先阅读 Spine 编辑器和运行时版本管理指南.

要更新spine-flutter运行时, 只需修改 pubspec.yaml 中的 spine_flutter 软件包版本号即可.

注意: 若更改了 spine_flutter 软件包的 major.minor 版本, 则必须使用同 major.minor 版本的Spine编辑器重新导出Spine skeleton资产!

使用spine-flutter

spine-flutter运行时是基于通用 spine-cpp 运行时的 Dart FFI封装器(wrapper), 可以加载、回放并修改用Spine创建的动画. spine-flutter运行时将几乎全部的spine-cpp API都公开为原生化的Dart类代码, 并提供了Flutter和 Flame 的专用类, 以便轻松显示Spine skeleton并与之交互.

Spine-flutter运行时支持除tint black外的所有Spine功能.

管理资产

为spine-flutter导出资产

请参照Spine用户指南中的说明以了解如何:

  1. 导出 skeleton & 动画数据
  2. 导出包含skeleton图像的texture atlases

导出skeleton数据和skeleton texture atlas将生成以下文件:

  1. 包含skeleton和动画数据的 skeleton-name.jsonskeleton-name.skel 文件.
  2. skeleton-name.atlas, 包含texture atlas信息.
  3. 一个或多个 .png 文件, 每个文件代表texture atlas中的一页, 每页中都打包了skeleton将使用的图像.

注意: 与 JSON 导出相比, 你也许会更喜欢二进制skeleton导出, 因为它们体积更小, 加载速度更快.

可通过如 AtlasSkeletonDataSkeletonDrawableSpineWidget 等spine-flutter类来加载文件.

注意: 由于Flutter的技术限制, spine-flutter运行时目前不支持使用预乘alpha导出的atlas. 但Flutter的渲染引擎可确保普通的非预乘Alpha资产不会出现伪影.

更新Spine资产

在开发过程中, 你可能会经常更新Spine skeleton数据和texture atlas文件. 只需从Spine编辑器重新导出并替换Flutter项目中的已有文件, 即可覆盖这些源文件(.json, .skel, .atlas, .png)以实现资产更新.

应确保spine-flutter的 major.minor 版本与用于导出资产的Spine编辑器的 major.minor 版本一致. 更多信息请参阅 Spine 版本控制 一节.

核心类

spine-flutter API 基于通用 spine-cpp 运行时, 它提供了与平台无关的核心类和算法, 用于加载、查询、修改 Spine skeleton并呈现skeleton动画. 核心类通过 Dart FFI 封装, 并作为原生化的 Dart 类对外公开.

我们将在本节简要讨论你在日常使用 spine-flutter 时会遇到的最重要的核心类. 请查阅 Spine 运行时指南 详细了解 Spine 运行时架构、核心类和 API 用例.

Atlas类存储从 .atlas 文件加载的数据及其对应的 .png 图像文件.

SkeletonData类存储从 .json.skel 文件加载的数据. Skeleton数据包含有关骨骼层次结构、槽位、附件、约束、皮肤和动画信息. SkeletonData实例在加载时通常也会内含一个 Atlas 类, 从这个类中可以获取skeleton要使用的图像. SkeletonData 是创建 Skeleton 实例的蓝图. 可以通过相同的atlas和skeleton数据实例化多个skeleton来共享已载入的数据, 从而最大限度地降低运行时的加载时延和内存消耗.

Skeleton 类存储了一个skeleton实例, 该skeleton由 SkeletonData 实例创建. Skeleton存储其当前pose, 即当前配置的骨骼位置以及槽位、附件和活动皮肤. 当前pose可以通过手动修改骨骼层次结构来计算得出, 更正常的做法是通过 AnimationState 应用动画来得到当前pose.

AnimationState 类负责跟踪哪些动画应该应用于Skeleton, 根据上一帧和当前渲染帧的间隔时间推进或混合(mix)这些动画, 并将动画应用到skeleton实例, 从而设置其当前pose. AnimationState 会查询 AnimationStateData 实例来获取动画间的混合时间, 若一对动画间没有混合时间, 则会采用默认混合时间.

spine-flutter运行时正是构建于这些核心类之上.

SpineWidget

/img/spine-runtimes-guide/spine-flutter/simple-animation.png

SpineWidget是一个StatefulWidget, 它负责加载和显示Spine Skeleton. 该部件至少需要知道从何处加载skeleton和atlas文件, 并且必须接收一个SpineWidgetController实例, 该实例负责修改小部件(widget)的状态, 比如设置动画或更改Skeleton的皮肤.

最简单的用例是, 在另一个小部件的build()方法中实例化SpineWidget:

dart
@override
Widget build(BuildContext context) {
   final controller = SpineWidgetController(onInitialized: (controller) {
    // Set the walk animation on track 0, let it loop
    controller.animationState.setAnimationByName(0, "walk", true);
   });

   return Scaffold(
    appBar: AppBar(title: const Text('Simple Animation')),
    body: SpineWidget.fromAsset("assets/spineboy.atlas", "assets/spineboy-pro.skel", controller)
   );
}

实例化后, SpineWidget 将异步加载指定文件并根据文件构建底层核心类实例, 即AtlasSkeletonDataSkeletonAnimationStateDataAnimationState实例.

加载完成后将调用SpineWidgetController, 通过它来修改小部件的状态, 如设置一个或多个动画、操作骨骼层次结构或修改骨骼的皮肤. 请参阅下文 SpineWidgetController 的章节.

SpineWidget类提供了多个静态工厂方法, 用于从不同数据源加载skeleton和atlas文件:

  • SpineWidget.fromAsset()从根包(bundle)或指定的包中加载文件.
  • SpineWidget.fromFile() 从文件系统加载文件.
  • SpineWidget.fromHttp() 从 URL 加载文件.
  • SpineWidget.fromDrawable()SkeletonDrawable构造一个小部件. 当skeleton数据需要在SpineWidget实例间预载、缓存和/或共享时请使用它. 请参阅下文的 "预载和共享Skeleton数据" 一节.

所有工厂方法均包含可选参数, 可进一步定义 Spine skeleton在小部件中的组合和放置方式, 以及小部件的尺寸:

  • fit, BoxFit, skeleton被置为适配小部件内的尺寸.
  • alignment, Alignment, 在小部件内对齐skeleton.
  • BoundsProvider, 将会计算适配和对齐时skeleton边界框尺寸. 默认情况下会使用骨骼的setup pose边界框. 请参阅SetupPoseBoundsRawBoundsSkinAndAnimationBounds 以获取更多信息.
  • sizedByBounds, 可定义是根据 BoundsProvider 计算出的边界框来调整小部件尺寸, 还是根据其父级小部件来调整尺寸.

Skeleton数据的预载与共享

如果你希望在多个 SpineWidget 实例间共享Skeleton数据, 你可以手动预载这些资产:

final atlas = await Atlas.fromAsset("assets/test.atlas");
final skeletonData = await SkeletonData.fromAsset("assets/test.json", atlas);

然后便能用这套数据实例化数个 SpineWidget 实例了, 这样做能节省不少加载时间和内存开销:

SpineWidget.fromDrawable(SkeletonDrawable(skeletonData, atlas));

当没有任何 SpineWidget(或 SkeletonDrawable) 再引用atlas和skeleton数据后, 你需要手动销毁这些数据.

skeletonData.dispose();
atlas.dispose();

SpineWidgetController

SpineWidget控制器控制着SpineWidget的skeleton动画和渲染方式. 该控制器提供了一组可选的回调作为构造函数参数, 这些参数会在 SpineWidget 生命周期的特定时刻被调用.

控制器通过返回Spine运行时API对象(如Atlas, SkeletonData, SkeletonAnimationState)的获取器公开skeleton状态, 可通过这些对象对状态进行操作. 更多信息请参阅Spine 运行时指南类代码.

在初始化 SpineWidget 时会调用一次控制器的 onInitialized() 回调方法. 该方法可用于设置要播放的初始动画或设置skeleton皮肤.

初始化完成后, 将以屏幕刷新率连续渲染SpineWidget. 每帧都会根据当前队列的动画更新 AnimationState 并应用于 Skeleton.

接下来, 会调用可选的onBeforeUpdateWorldTransforms()回调, 它能在使用Skeleton.updateWorldTransform()计算当前pose前修改skeleton.

计算了当前pose后, 会调用可选的onAfterUpdateWorldTransforms()回调, 它能在渲染skeleton前进一步修改当前pose. 这是手动放置骨骼的好时机.

SpineWidget渲染skeleton之前, 会调用可选的onBeforePaint()回调, 它能在Canvas上渲染背景或其他应位于skeleton后面的对象.

SpineWidgetCanvas渲染了skeleton的当前pose后, 会调用可选的onAfterPaint()回调, 它能在skeleton之上渲染其他对象.

默认情况下, 小部件每帧都会更新和渲染skeleton. 可以使用 SpineWidgetController.pause() 方法暂停更新和渲染skeleton, 而SpineWidgetController.resume()方法可恢复skeleton的更新和渲染. SpineWidgetController.isPlaying() 获取器(getter)会报告当前的播放状态. 用例请参阅 example/lib/animation_state_events.dart 示例.

SkeletonDrawable

SkeletonDrawableSkeleton的加载、存储、更新和渲染及其关联的AnimationState封装到了一个简单易用的类里. 该类是实现自定义小部件的基础. SpineWidget通过一个SkeletonDrawable实例封装了它所呈现的skeleton的状态.

使用fromAsset()fromFile()fromHttp()方法可从文件资产构建SkeletonDrawable. 要在多个 SkeletonDrawable 实例间共享AtlasSkeletonData, 可通过构造函数实例化drawables类, 并向每个实例传入相同的atlas和skeleton数据.

SkeletonDrawable会公开SkeletonAnimationState, 以便查询、修改skeleton和播放skeleton动画. 它还公开了AtlasSkeletonData, skeleton和动画状态就是根据这些数据构造的.

要让skeleton动起来, 可通过 AnimationState API(如 AnimationState.setAnimation()AnimationState.addAnimation()) 在一个或多个轨道上队列动画来实现.

要更新动画状态、将其应用到skeleton并更新当前skeletonpose, 请调用 SkeletonDrawable.update() 方法, 并为其提供一个以秒为单位的延迟时间来推进动画.

要渲染skeleton的当前pose, 请使用渲染方法 SkeletonDrawable.render()SkeletonDrawable.renderToCanvas()SkeletonDrawable.renderToPictureRecorder()SkeletonDrawable.renderToPng()SkeletonDrawable.renderToRawImageData().

SkeletonDrawable存储在本地(native)堆上分配的对象. 若不再需要SkeletonDrawable, 则需通过调用SkeletonDrawable.dispose()手动销毁(dispose)本地对象, 否则将导致本地内存泄漏.

注意: 使用SpineWidget时不必手动销毁部件使用的 SkeletonDrawable. 小部件将在其销毁自身时自动销毁SkeletonDrawable.

应用动画

SpineWidget渲染的skeleton应用动画是靠SpineWidgetController回调中的AnimationState完成的.

注意: 请参阅《Spine 运行时指南》中的应用动画一章了解更多深入信息, 特别是关于动画轨道和队列动画的信息.

要在轨道 0 上设置某个动画, 请调用 AnimationState.setAnimation():

dart
final controller = SpineWidgetController(onInitialized: (controller) {
// Set the walk animation on track 0, let it loop
controller.animationState.setAnimationByName(0, "walk", true);
});

第一个参数指定轨道号, 第二个参数是动画名称, 第三个参数指定是否循环播放动画.

你也可以队列多个动画:

dart
controller.animationState.setAnimationByName(0, "walk", true);
controller.animationState.addAnimationByName(0, "jump", false, 2);
controller.animationState.addAnimationByName(0, "run", true, 0);

addAnimationByName() 的第一个参数是轨道号. 第二个参数是动画名称. 第三个参数指定了延迟时间(以秒为单位), 延迟后该动画会替换该轨道的前一个动画. 最后一个参数定义了是否循环播放动画.

在上文的示例中, 首先播放的是 "walk" (行走)动画. 2 秒后, 播放一次 "jump" (跳跃)动画, 然后过渡到 "run" (跑动)动画, 最后循环播放 "run" 动画.

从一个动画过渡到另一个动画时, AnimationState 会在一段时间内混合(mix)播放动画. 混合时长定义在 AnimationStateData 实例中, AnimationState将从中获取混合时长.

AnimationStateData实例也可通过控制器调用. 你可以设置默认混合时长, 也可以单独设置某对动画的混合时长:

dart
controller.animationStateData.setDefaultMix(0.2);
controller.animationStateData.setMixByName("walk", "jump", 0.1);

设置或添加动画时, 会返回一个 TrackEntry 对象, 通过该对象可以进一步定制动画的播放. 例如可以反转动画播放:

dart
final entry = controller.animationState.setAnimationByName(0, "walk", true);
entry.setReverse(true);

更多选项请参阅 TrackEntry类文档.

注意: 请勿在您使用它们的函数外保存 TrackEntry 实例. 轨道条目会在内部重复使用, 因此一旦其代表的动画完成播放, 轨道条目就会失效.

您可以在动画轨道上设置空动画或队列空动画, 以便将skeleton重置回setup pose:

dart
controller.animationState.setEmptyAnimation(0, 0.5);
controller.animationState.addEmptyAnimation(0, 0.5, 0.5);

setEmptyAnimation() 的第一个参数指定了轨道号. 第二个参数指定混合持续时长(以秒为单位), 用于淡出之前的动画并淡入"空"动画.

addEmptyAnimation() 的第一个参数也是指定轨道号. 第二个参数是混合持续时长. 第三个参数是延迟时间(以秒为单位), 空动画延迟后将通过混合取代轨道上的前一个动画.

通过 AnimationState.clearTrack() 可以立即清空轨道上的所有动画. 使用 AnimationState.clearTracks() 则会一次性清空所有轨道. 这将使skeleton保持最后一个pose.

要将skeleton的pose重置为setup pose, 请使用 Skeleton.setToSetupPose() 函数:

dart
controller.skeleton.setToSetupPose();

这将把骨骼和槽位都重置为setup pose中的设置. 使用 Skeleton.setSlotsToSetupPose() 可只将槽位重置为setup pose设置.

AnimationState 事件

一个 AnimationState 会在正在播放的动画的生命周期中触发事件. 你可以监听这些事件, 以便根据需要做出响应. Spine运行时 API 定义了以下几种 事件类型:

  • Start: 动画开始时触发.
  • Interrupted: 动画轨道被清空或设置了新动画时触发.
  • Completed: 动画播放完成了一个循环时触发.
  • Ended: 不再应用该动画时触发.
  • Disposed: 该动画的轨道条目被销毁时触发.
  • Event: 用户自定义事件触发时触发.

注册一个 AnimationStateListener 回调便可接收事件, 这个回调可以是接收所有动画事件的 AnimationState 监听器, 也可以是订阅了队列中某个待播动画的 TrackEntry 监听器:

dart
final entry = controller.animationState.setAnimationByName(0, "walk", true);
entry.setListener((type, trackEntry, event) {
if (type == EventType.event) {
    print("User defined event: ${event?.getData().getName()}");
}
});

controller.animationState.setListener((type, trackEntry, event) {
print("Animation state event $type");
});

请参见 example/lib/animation_state_events.dart 示例.

皮肤

/img/spine-runtimes-guide/spine-flutter/skins.png

许多应用和游戏都允许用户用头发、眼睛、裤子或耳环、包包等配饰等多个部件(item)创建自定义形象. Spine中可通过 皮肤混搭 功能来实现.

基于其他皮肤来创建自定义皮肤的做法是:

dart
final data = controller.skeletonData;
final skeleton = controller.skeleton;
final customSkin = Skin("custom-skin");
customSkin.addSkin(data.findSkin("skin-base")!);
customSkin.addSkin(data.findSkin("nose/short")!);
customSkin.addSkin(data.findSkin("eyelids/girly")!);
customSkin.addSkin(data.findSkin("eyes/violet")!);
customSkin.addSkin(data.findSkin("hair/brown")!);
customSkin.addSkin(data.findSkin("clothes/hoodie-orange")!);
customSkin.addSkin(data.findSkin("legs/pants-jeans")!);
customSkin.addSkin(data.findSkin("accessories/bag")!);
customSkin.addSkin(data.findSkin("accessories/hat-red-yellow")!);
skeleton.setSkin(customSkin);
skeleton.setSlotsToSetupPose();

使用 Skin() 构造函数创建自定义皮肤.

接下来, 从控制器中获取 SkeletonData. 然后通过 SkeletonData.findSkin() 来按名称查找皮肤.

Skin.addSkin()可将所有要合并的皮肤添加到新的自定义皮肤中.

最后, 在 Skeleton 上设置新皮肤, 并调用 Skeleton.setSlotsToSetupPose()来确保没有将以前的皮肤和/或动画附件遗留在skeleton上.

注意: Skin封装了一个底层的 C++ 对象. 当它不再使用时需调用 Skin.dispose() 手动销毁它.

详见 example/lib/dress_up.dart示例, 它也演示了如何通过SkeletonDrawable来渲染皮肤的缩略图预览.

设置骨骼变换

/img/spine-runtimes-guide/spine-flutter/simple-animation.png

在 Spine 编辑器中创建skeleton时, skeleton是定义于skeleton坐标系中的. 该坐标系可能与渲染skeleton的 SpineWidget 坐标系不一致. 因此 SpineWidget 的鼠标和触摸操作的坐标值需要转换到skeleton坐标系中——比如用户可能需要通过触控来移动骨骼的时候.

SpineWidgetController提供了toSkeletonCoordinates()方法, 该方法接相对于skeleton所在SpineWidget的坐标偏移量(Offset), 并将其转换为skeleton坐标.

请参阅 example/lib/ik_following.dart 示例.

Flame集成

/img/spine-runtimes-guide/spine-flutter/flame.png

spine-flutter 包含了一个示例, 演示了如何在 Flame Engine 中加载和渲染 Spine skeleton. 请参见源文件 example/lib/flame_example.dart .

该示例中的 SpineComponent 类是对Flame中 PositionComponent 类的扩展. 通过静态方法 SpineComponent.fromAsset() 和构造函数均可实例化 SpineComponent 类.

当无需与其他组件共享skeleton和atlas数据时, 可将静态方法当作快速、一次性的加载机制使用. 该示例中包含一个名为 SimpleFlameExampleFlameGame 实现, 它演示了如何以这种简单方式将Spine skeleton作为Flame游戏的部件显示在屏幕中.

在构造函数创建了 SpineComponent 后, 就可以使用 SkeletonDrawable 对数据加载和数据共享进行更精细地控制. 例如你可以预加载skeleton数据和atlas, 然后在多个 SpineComponent 实例间共享. 这样既能提高内存使用率又能提高渲染性能, 由于是共享的数据所以渲染也可以合批处理. 有关示例, 请参阅名为 PreloadAndShareSpineDataExampleFlameGame 实现.

虽然Flame无法确知某个组件何时达到生命周期终点, 但 SpineComponent 会处理在其生命周期结束时需要释放的本地资源. 因此如果不再使用某个 SpineComponent, 你需要手动调用 SpineComponent.dispose() 来销毁它. 如果 SpineComponent 是通过 SkeletonDrawable 构建的, 你可能还需要按 PreloadAndShareSpineDataExample 示例中所示的做法, 手动地销毁由其构建的 SkeletonDataAtlas.

访问Spine运行时API

spine-flutter将几乎所有的Spine运行时API都映射为了Dart代码. 如 Skeleton 或者 AnimationState 这样由 SpineWidgetControllerSkeletonDrawable 返回的对象, 均为spine-cpp API到Dart的1:1直译. 因此你可以将 Spine运行时指南 中几乎所有通用操作以Dart代码实现.

由于 spine-cpp 与 Dart FFI 桥接的实现方式会产生一些限制:

  • 返回的数组(array)或映射(map)均为内部数组的副本. 修改它们不会有任何效果. 然而返回的 Float32ListInt32List 实例是底层原生内存值的封装, 因此可以用来修改底层原始(native)数据.
  • 不能直接创建、添加或删除骨骼、槽位和其他 Spine 对象.
  • 时间轴的 C++ 类层次结构没有暴露给Dart.