spine-c 运行时文档

Licensing

将官方的Spine运行时整合到你的应用程序之前, 请仔细阅读 Spine运行时许可页面.

开始使用

spine-c是一个通用运行时, 可将Spine动画集成到游戏引擎和使用C、C++、Swift、Rust、Objective-C的原生接口的框架中.

spine-c 提供了如下功能

由于spine-c运行时是一个通用、独立于引擎的运行时, 用户需自行实现一组函数, 以便为spine-c运行时提供特定引擎所需的文件i/o和图像加载功能, 还需要它们通过引擎的渲染系统来渲染spine-c运行时生成的数据. 若有更高级的使用场景(如布娃娃系统), 还可以将运行时生成的数据与引擎的物理系统整合起来.

spine-c运行时基于C99标准编写, 以保证与各种平台和编译器的兼容性.

其他的官方Spine运行时是基于spine-c编写的, 因此亦可作为引擎集成的示例以供研究:

以下章节简要介绍了引擎无关的spine-c运行时及其使用方式. 大多数基于spine-c的官方Spine运行时都会将spine-c的API(部分)封装于其更易使用的自有API中. 因此对更底层一些的spine-c运行时有基础了解仍然是有益无害的.

注意: 本指南已假定你了解基本的运行时架构和Spine的术语. 也请查阅API 参考文档以探索运行时的更多高级功能.

导出spine-c适用的Spine资产

请按照Spine用户指南中的操作步骤, 了解如何:

  1. skeleton & 动画数据 导出为JSON或二进制格式
  2. 导出包含skeleton图像的texture atlases

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

  1. skeleton-name.jsonskeleton-name.skel 文件, 包含了skeleton和动画数据.
  2. skeleton-name.atlas, 包含了texture atlas的相关信息.
  3. 一张或多张 .png 文件, 每一个文件表示texture atlas中的一页, 每个页则指打包成页的skeleton所引用的图像.

注意: 也可以不用每个skeleton就生成一个texture atlas的打包方式, 而是将多个skeleton的图像打包成一个texture atlas. 关于此请参考texture 打包指南.

加载 Spine 资产

spine-c提供了加载texture atlases, Spine skeleton数据(骨骼、槽位、附件、皮肤、动画)和通过动画状态数据定义动画间mix时间的API. 这三种类型的数据, 也被称为setup pose数据, 通常加载一次后就能被每个游戏对象(game object)共享. 共享机制是通过赋予每个游戏对象其不同的Skelton和动画状态来实现的, 亦可称其为实例数据.

注意: 关于全局加载架构的更详细描述, 请参考更通用的 Spine 运行时文档.

加载 texture atlases

Texture atlas数据以自有的 atlas格式存储, 它描述了atlas页中各图像的位置. atlas页本身以普通的.png文件的形式存在atlas文件旁边.

spine-c为该任务提供了spAtlas_createFromFilespAtlas_create 函数. 前者从文件加载atlas, 而后者从内存加载. 若atlas加载失败, 函数将返回0.

// Load the atlas from a file. The last argument is a void* that will be
// stored in atlas->rendererObject.
spAtlas* atlas = spAtlas_createFromFile("myatlas.atlas", 0);

// Load the atlas from memory, giving the memory location, the number
// of bytes and the directory relative to which atlas page textures
// should be loaded. The last argument is a void* that will be stored
// in atlas-rendererObject.
spAtlas* atlas = spAtlas_create(atlasInMemory, atlasDataLengthInBytes, dir, 0);

加载 skeleton 数据

Skeleton数据(骨骼、槽、约束、附件、皮肤、动画)可导出为人类可读的JSON文件或自有的二进制格式. spine-c在spSkeletonData结构体中存储Skelton数据.

为加载从JSON导出的Skelton数据, 应创建一个spSkeletonJson实例来接收先前加载的spAtlas, 设置skeleton的比例, 最后再从文件中读取skeleton数据:

// Create a spSkeletonJson used for loading and set the scale
// to make the loaded data two times as big as the original data
spSkeletonJson* json = spSkeletonJson_create(atlas);
json->scale = 2;

// Load the skeleton .json file into a spSkeletonData
spSkeletonData* skeletonData = spSkeletonJson_readSkeletonDataFile(json, filename);

// If loading failed, print the error and exit the app
if (!skeletonData) {
   printf("%s\n", json->error);
   spSkeletonJson_dispose(json);
   exit(0);
}

// Dispose the spSkeletonJson as we no longer need it after loading.
spSkeletonJson_dispose(json);

加载二进制格式的skeleton数据的工作流程是相同的, 只是需使用spSkeletonBinary来代替spSkeletonJson:

// Create a spSkeletonJson used for loading and set the scale
// to make the loaded data two times as big as the original data
spSkeletonBinary* binary = spSkeletonBinary_create(atlas);
binary->scale = 2;

// Load the skeleton .skel file into a spSkeletonData
spSkeletonData* skeletonData = spSkeletonBinary_readSkeletonDataFile(binary, filename);

// If loading failed, print the error and exit the app
if (!skeletonData) {
   printf("%s\n", binary->error);
   spSkeletonBinary_dispose(json);
   exit(0);
}

// Dispose the spSkeletonBinary as we no longer need it after loading.
SkeletonBinary_dispose(json);

准备动画状态数据

当从一个动画切换到另一个动画时, Spine可进行平滑过渡(淡入淡出). 淡入淡出(crossfades)是通过在指定mix时间内将一个动画与另一个动画混合(mix)来实现的. spine-c运行时提供了结构体spAnimationStateData来定义这些mix时间:

// Create the spAnimationStateData
spAnimationStateData* animationStateData = spAnimationStateData_create(skeletonData);

// Set the default mix time between any pair of animations in seconds.
spAnimationStateData_setDefaultMix(animationStateData, 0.1f);

// Set the mix time between from the "jump" to the "walk" animation to 0.2 seconds,
// overwriting the default mix time for this from/to pair.
spAnimationStateData_setMixByName(animationStateData, "jump", "walk", 0.2f);

spAnimationStateData 中定义的mix时间也可以在应用动画时被显式覆盖(见下文).

Skeletons

Setup pose数据(skeleton数据, texture atlases等)应该在游戏对象间共享. spine-c中的共享由spSkeleton结构体来实现. 每个游戏对象都会收到自己的spSkeleton实例, 它反过来引用了spSkeletonDataspAtlas实例作为数据源.

spSkeleton可以自由修改, 例如通过程序化修改skeleton、应用动画或设置游戏对象的特定附件和皮肤, 而底层skeleton数据和texture atlas保持不变. 这种机制和设定可以让任意数量的游戏对象来共享spSkeletonDataspAtlas实例.

创建 skeletons

可通过调用 spSkeleton_create 来创建 spSkeleton 实例:

spSkeleton* skeleton = spSkeleton_create(skeletonData);

每个游戏对象都需要其私有的spSkeleton实例. 大部分数据储存在spSkeletonDataspAtlas中, 并由所有spSkeleton实例共享, 以最大程度减少内存消耗和texture切换开销. 因此一个spSkeleton的生命周期与其对应的游戏对象的生命周期几近同步.

骨骼(Bones)

Skeleton包含了骨骼的层次结构, 槽位附着于骨骼, 而附件附加于槽位.

查找骨骼

Skeleton中的所有骨骼均以其唯一名称命名, 可通过该名称从skeleton中获取骨骼:

// returns 0 if no bone of that name could be found
spBone* bone = spSkeleton_findBone("mybone");

本地变换(local transform)

一根骨骼受其父骨骼的影响, 该限制可一直追溯自根骨骼. 例如, 当旋转一块骨骼时, 其全部子骨骼和子骨骼的全部子骨骼也会被旋转. 为了完成这些层次化变换, 每块骨骼都存储了相对于其父骨骼的本地变换, 包括:

  • 相对于父骨骼的xy 位置.
  • rotation 角度.
  • scaleXscaleY.
  • shearXshearY 角度.

骨骼的本地变换可随程序代码或应用动画而改变. 前者允许实现动态行为, 比如让骨骼指向鼠标的光标位置, 让脚部骨骼跟随地形移动等等. 对本地变换的程序性更改和应用动画可以同时进行. 最终的结果将是组合出的单一本地变换.

世界变换(World transform)

一旦设置了所有的本地变换, 无论是通过程序还是通过应用动画来修改骨骼的本地变换, 最终都需要每块骨骼的世界变换来进行渲染和物理计算.

计算从根骨骼开始, 然后递归地计算所有子骨骼的世界变换. 该计算同时计入了你的美术同事在Spine编辑器中定义的IK变换(transform)路径(path)约束.

计算世界变换, 需要首先要对物理运动更新Skeleton的帧时间, 再计算实际变换:

spSkeleton_update(deltaTime);
spSkeleton_updateWorldTransform(skeleton, SP_PHYSICS_UPDATE);

deltaTime 指定当前帧和上一帧间经过的时间, 单位为秒. spSkeleton_updateWorldTransform 的第二个参数指定是否以及如何应用物理运动. SP_PHYSICS_UPDATE 已经是一个较好的默认值.

计算结果将存储在每块骨骼上, 包含如下部分:

  • a, b, c, 和 d: 一个2x2的列主序矩阵(矩阵中同列元素在内存中相邻), 其编码了骨骼的旋转量、缩放量和剪切量.
  • worldX, worldY: 骨骼的世界位置.

注意worldXworldY是相对于skeleton->xskeleton->y的偏移量. 后两个属性用于在游戏引擎的世界坐标系中定位skeleton.

一般来说不应直接修改骨骼的世界变换. 且它们应仅通过调用spSkeleton_updateWorldTransform从skeleton的本地变换中获取. 本地变换可以通过程序设置, 例如设置骨骼旋转量使其指向鼠标光标, 或者通过应用动画(详见下文)来设置, 两者亦可同时生效. 一旦应用了(程序性)动画, 就会调用spSkeleton_updateWorldTransform, 并根据本地变换及全部用于该骨骼的约束来重新计算产生的世界变换.

坐标系转换

在世界坐标系中操作骨骼通常比较容易, 因为这些坐标通常是由其他实体或输入事件给出. 然而由于亦不应直接更改世界变换, 我们需要将基于世界坐标系计算的任何骨骼变化应用于该骨骼的本地变换.

spine-c运行时提供了一些函数, 用于从骨骼的2x2世界变换矩阵中提取旋转和缩放信息, 并将位置和旋转从本地空间变换到世界空间, 或进行其逆变换. 所有这些函数都假定骨骼的世界变换已经通过调用spSkeleton_updateWorldTransform经过了计算更新:

spBone* bone = spSkeleton_findBone("mybone");

// Get the rotation of a bone in world space relative to the world space x-axis in degrees
float rotationX = spBone_getWorldRotationX(bone);

// Get the rotation of a bone in world space relative to the world space y-axis in degrees
float rotationY = spBone_getWorldRotationY(bone);

// Get the scale of a bone in world space relative to the world space x-axis
float scaleX = spBone_getWorldScaleX(bone);

// Get the scale of a bone in world space relative to the world space y-axis
float scaleY = spBone_getWorldScaleY(bone);

// Transform a position given in world space to a bone's local space
float localX = 0, localY = 0;
spBone_worldToLocal(bone, worldX, worldY, &localX, &localY);

// Transform a position given in a bone's local space to world space
float worldX = 0, worldY = 0;
spBone_localToWorld(bone, localX, localY, &worldX, &worldY);

// Transform a rotation given in the bone's world transform relative to the world space x-axis to // a rotation given in local space relative to the local space x-axis in degrees.
float localRotationX = spBone_worldToLocalRotationX(bone)

// Transform a rotation given in the bone's world transform relative to the world space y-axis to // a rotation given in local space relative to the local space y-axis in degrees.
float localRotationY = spBone_worldToLocalRotationY(bone)

注意: 在下一次调用spSkeleton_updateWorldTransform后, 对骨骼(及其所有子骨骼)的本地变换的修改才会反映在骨骼的世界变换上.

定位(Positioning)

默认情况下, 一个skeleton默认位于游戏中世界坐标系的原点. 要在游戏的世界坐标系中定位skeleton可以使用成员变量xy:

// make a skeleton follow a game object in world space
skeleton->x = myGameObject->worldX;
skeleton->y = myGameObject->worldY;

注意: 在下一次调用spSkeleton_updateWorldTransform后, 对skeleton的xy属性的修改才会反映在skeleton的世界变换上.

翻转(Flipping)

一个skeleton可以进行垂直或水平翻转. 这样就可以把为某个方向制作的动画用于相反的方向, 或者在Y轴朝下的坐标系中开展工作(Spine默认为Y轴向上):

// flip vertically around the x-axis
skeleton->scaleY = -1;

// flip horizontally around the y-axis
skeleton->scaleX = -1;

注意: 在下一次调用spSkeleton_updateWorldTransform后, 对skeleton的scaleXscaleY属性的修改才会反映在skeleton的世界变换上.

设置皮肤

创建Spine skeleton的美术同事可能为skeleton添加了多个皮肤, 以丰富同一个skeleton的视觉变化, 例如一个包含不同性别的skeleton. spine-c运行时将皮肤存储在spSkin实例中.

运行时中的一个皮肤本质是一个映射, 它定义了哪个附件进入skeleton的哪个槽位. 每个skeleton至少有一个皮肤, 它定义了哪个附件在skeleton的setup pose中的哪个槽上. 此外, 皮肤也有独自的名称来互相区分.

用spine-c在一个skeleton上设置一个皮肤可以这么写:

// set a skin by name
spSkeleton_setSkin(skeleton, "my_skin_name");

// set the default setup pose skin by passing 0
spSkeleton_setSkin(skeleton, 0);

注意: 设置皮肤会影响到之前已经设置的皮肤和附件. 请参考通用运行时指南以了解更多关于如何更改皮肤的信息.

设置附件

spine-c允许在skeleton的槽位上直接设置一个附件, 比如可以用来切换武器. 运行时将先在活动皮肤中搜索该附件, 如果搜索失败, 则在默认的setup pose皮肤中搜索:

// Set the attachment called "sword" on the "hand" slot
spSkeleton_setAttachment(skeleton, "hand", "sword");

// Clear the attachment on the slot "hand" so nothing is shown
spSkeleton_setAttachment(skeleton, "hand", 0);

着色(Tinting)

你可以通过设置skeleton的颜色来给该skeleton中的所有附件着色:

spSkeleton* skeleton = ...

// tint all attachments with red and make the skeleton half translucent.
skeleton->r = 1.0f;
skeleton->g = 0.0f;
skeleton->b = 0.0f;
skeleton->a = 0.5f;

注意: spine-c中的颜色是以RGBA的形式给出的, 各通道的取值范围为[0-1].

当渲染一个skeleton时, 渲染器会遍历skeleton上的槽位的绘制顺序, 并在每个槽位上渲染当前活动的附件. 除了skeleton的颜色之外, 每个槽位也有自己的颜色属性可供运行时操作:

spSlot* slot = skeleton->findSlotByName("mySlot");
slot->r = 0.0f;
slot->g = 1.0f;
slot->b = 0.0f;
slot->a = 1.0f;

请注意槽位的颜色也可以被动画影响. 若你手动更改了一个槽位的颜色, 然后应用了一个key了该槽位颜色的动画, 那么你手动设置的颜色将被覆盖.

应用动画

美术同事可通过Spine编辑器创建多个命名唯一的动画. 一个动画是一组时间轴. 每条时间轴指定了在哪一帧, 骨骼或skeleton的什么属性应该改变成哪个值. 时间轴有许多不同的类型, 从定义骨骼随时间变化的时间轴, 到改变绘制顺序的时间轴都有. 时间轴是skeleton数据的一部分, 存储在spine-c中的spSkeletonData实例中.

时间轴 API

如果需要直接处理时间轴, spine-c提供了一个时间轴API. 这个底层功能让你可以自由定制美术同事捣鼓出的动画以何种方式应用于skeleton.

动画状态 API

在几乎所有情况下, 都建议使用动画状态API而非时间轴API. 与底层的时间轴API相比, 动画状态API会让诸如在某段时间内应用动画、排队动画、mix动画、以及同时应用多个动画等任务变得更加容易. 动画状态API内部使用的也是时间轴API, 因此可以将其看作是时间轴API的封装器.

spine-c用 spAnimationState 表示动画状态. 就像skeleton一样, 每个游戏对象都要实例化一个spAnimationState. 一般来说, 你游戏中每个游戏对象都有一个spSkeleton和一个spAnimationState实例. 与spSkeleton最类似的是, spAnimationState也与其他全部 spAnimationState 实例共享 spSkeletonData(存储了动画及其时间轴)和spAnimationStateData(存储了mix时间), 它们均源自同一组skeleton数据.

创建动画状态

调用spAnimationState_create即可创建spAnimationState实例:

spAnimationState* animationState = spAnimationState_create(animationStateData);

该函数接收一个通常在加载skeleton数据时创建的spAnimationStateData实例, 它定义了默认的mix时间以及特定动画间淡入淡出的mix时间.

轨道 & 队列

一个动画状态实例管理着一个或多个轨道. 每条轨道本质是一个动画的列表, 这些动画按它们被添加到轨道中的顺序来进行回放. 这一行为被称为队列. 轨道的索引始于0.

你可以像这样在一条轨道上队列一个动画:

// Add the animation "walk" to track 0, without delay, and let it loop indefinitely
int track = 0;
int loop = 1;
float delay = 0;
spAnimationState_addAnimationByName(animationState, track, "walk", loop, delay);

可以一次队列多个动画, 以一种射后不管(fire and forget)的方式创建动画序列:

// Start walking (note the looping)
spAnimationState_addAnimationByName(animationState, 0, "walk", 1, 0);

// Jump after 3 seconds
spAnimationState_addAnimationByName(animationState, 0, "jump", 0, 3);

// Once we are done jumping, idle indefinitely
spAnimationState_addAnimationByName(animationState, 0, "idle", 1, 0);

也可以清空一条轨道中队列着的所有动画:

// Clear all animations queued on track 0
spAnimationState_clearTrack(animationState, 0);

// Clear all animations queued on all tracks
spAnimationState_clearTracks(animationState);

你可以调用spAnimationState_setAnimationByName来清空队列并添加新动画到轨道上. 该操作将清空所有轨道, 但请记住清空前最后播放的动画是什么, 再淡出到新设置的动画. 这样你才可以顺利地从一个动画序列过渡到下一个. 在调用spAnimationState_setAnimationByName后, 你仍可以通过调用spAnimationState_addAnimationByName向轨道添加更多的动画:

// Whatever is currently playing on track 0, clear the track and crossfade
// to the "shot" animation, which should not be looped (last parameter).
spAnimationState_setAnimationByName(animationState, 0, "shot", 0);

// After shooting, we want to idle again
spAnimationState_addAnimationByName(animationState, 0, "idle", 1, 0);

要从一个动画过渡切换到skeleton的setup pose, 可以使用spAnimationState_setEmptyAnimation, spAnimationState_addEmptyAnimation函数, 前者清空当前轨道并淡出到skeleton, 后者将入队一个到setup pose的淡入作为轨道上动画序列的一部分:

// Whatever is currently playing on track 0, clear the track and crossfade
// to the setup pose for 0.5 seconds (mix time) with a delay of 1 second.
spAnimationState_setEmptyAnimation(animationState, 0, 0.5f, 1);

// Add a crossfade to the setup pose for 0.5 seconds as part of the animation
// sequence in track 0
spAnimationState_addEmptyAnimation(animationState, 0, 0.5f)

规模不大的游戏使用一条轨道通常就足够. 更复杂的游戏可能希望在不同的轨道上队列动画, 例如在射击时同时播放一个步行动画. 而这才是Spine展示其真正实力之处:

// Apply the "walk" animation on track 0 indefinitely.
spAnimationState_setAnimationByName(animationState, 0, "walk", 1);

// Simultaniously apply a "shot" animation on track 1 once.
spAnimationState_setAnimationByName(animationState, 1, "shot", 0);

请注意, 若如上文所写的方式同时应用多个动画, 高轨道上的动画将覆盖低轨道上的动画, 因为这两个动画都key进了每个值. 因此动画师应当注意, 要确保两个要同时播放的动画不会在skeleton中key进相同的值, 例如相同的骨骼、附件或颜色等. 加性动画融合(Additive animation blend)可以把时间轴上不同轨道但影响同一Skelton属性的结果加在一起.

你可以通过轨道条目(Track Entries)控制不同轨道上的动画mix

轨道条目(Track Entries)

每当你在一个动画状态的轨道上入队一个动画, 相应函数将返回一个spTrackEntry实例. 这个轨道条目实例可以进一步定制队列动画, 以及它与同一或不同轨道上的动画的mix行为. 完整的API请参见TrackEntry 文档.

举个例子, 让我们假设 spAnimationStateData中包含了"walk"和"run"两个动画, 这两个动画间的mix时间对于某个游戏对象现在是过长的. 你可以临时修改"walk"和"run"之间的mix时长, 只特别针对这两个队列中的动画作出设置:

// Walk indefinitely
spAnimationState_setAnimationByName(animationState, 0, "walk", 1);

// At some point, queue the run animation. We want to speed up the mixing
// between "walk" and "run" defined in the `spAnimationStateData` (let's say 0.5 seconds)
// for this one specific call to be faster (0.1 seconds).
spTrackEntry* entry = spAnimationState_addAnimationByName(animationState, 0, "run", 1, 0);
entry->mixDuration = 0.1f;

你可以一直保存spTrackEntry来在不同时机对其进行修改. 只要该轨道上还队列着动画, spTrackEntry实例就会一直有效. 只有动画播放完成, spTrackEntry才会失效. 后续对其进行任何访问都是非法的, 并可能导致存储器段错误(segfault). 你可以注册一个监听器, 以便在动画和轨道条目失效时获得及时的通知.

事件

一个动画状态实例在播放队列动画时将产生事件, 以通知监听器如下状态更改:

  • 动画播放 开始(started).
  • 动画播放 中断(interrupted), 例如清空了一条轨道.
  • 动画播放 完成(completed), 如果循环播放动画则该事件会多次触发.
  • 动画播放 结束(ended), 既可能缘于动画播放中断亦可能是非循环动画播放完成.
  • 动画及其对应TrackEntry已被 释放(disposed) 且不再可用.
  • 触发了 用户自定义的 事件(event).

你可以注册一个函数来监听这些事件, 这个函数可以注册到动画状态,也可以注册到动画状态返回的spTrackEntry实例上.

// Define the function that will be called when an event happens
void myListener(spAnimationState* state, spEventType type, spTrackEntry* entry, spEvent* event) {
   switch (type) {
   //
   case SP_ANIMATION_START:
      printf("Animation %s started on track %i\n", entry->animation->data->name, entry->trackIndex);
      break;
   case SP_ANIMATION_INTERRUPT:
      printf("Animation %s interrupted on track %i\n", entry->animation->data->name, entry->trackIndex);
      break;
   case SP_ANIMATION_END:
      printf("Animation %s ended on track %i\n", entry->animation->data->name, entry->trackIndex);
      break;
   case SP_ANIMATION_COMPLETE:
      printf("Animation %s completed on track %i\n", entry->animation->data->name, entry->trackIndex);
      break;
   case SP_ANIMATION_DISPOSE:
      printf("Track entry for animation %s disposed on track %i\n", entry->animation->data->name, entry->trackIndex);
      break;
   case SP_ANIMATION_EVENT:
      printf("User defined event for animation %s on track %i\n", entry->animation->data->name, entry->trackIndex);
      printf("Event: %s: %d, %f, %s\n", event->data->name, event->intValue, event->floatValue, event->stringValue);
      break;
   default:
      printf("Unknown event type: %i", type);
   }
}

// Register the function as a listener on the animation state. It will be called for all
// animations queued on the animation state.
animationState->listener = myListener;

// Or you can register the function as a listener for events for a specific animation you enqueued
spTrackEntry* trackEntry = spAnimationState_setAnimationByName(animationState, 0, "walk", 1);
trackEntry->listener = myListener;

用户自定义的事件可以完美地匹配动画中应该播放声音的时刻, 例如播放脚步声.

在监听器中对动画状态的改变, 比如设置一个新的动画, 只有直到下一次调用spAnimationState_apply时才会应用于skeleton. 你也可以立即在监听器中应用这些更改:

void myListener(spAnimationState* state, spEventType type, spTrackEntry* entry, spEvent* event) {
   if (somecondition) {
      spAnimationState_setAnimation(state, 0, "run", 0);
      spAnimationState_update(0);
      spAnimationState_apply(skeleton);
   }
}

应用动画状态

动画状态本身是基于时间的. 你需要每隔一段时间就更新一次来推进其状态, 只需提供自上次更新以来的时间间隔, 以秒为单位:

spAnimationState_update(deltaTime);

这将推进每个轨道上的动画播放, 协调淡入淡出, 并调用任何你可能已注册的监听器.

在更新动画状态后, 要把它应用到skeleton上以更新其骨骼的本地变换、附件、槽位颜色、绘制顺序和任何其他可以被动画化的数值:

spAnimationState_apply(skeleton);

With the skeleton posed and animated, you finally update its frame time and bones' world transforms to prepare it for rendering or physics: 摆好了skeleton的姿势并播放了动画后, 最后你需要更新其骨骼的帧时间和世界变换, 为渲染或物理运动做好准备:

spSkeleton_update(deltaTime);
spSkeleton_updateWorldTransform(skeleton);

内存管理

我们试图让spine-c的内存管理尽可能的简单明了. 任何通过spStructName_create分配内存的结构体都需要通过相应spStructName_dispose函数来释放掉. 结构体的生命周期取决于它是哪种结构体. 经验上应遵循如下一般原则:

  • 在游戏或关卡启动时创建由实例数据(spAtlas, spSkeletonData, spAnimationStateData)共享的setup pose数据, 在游戏或关卡结束时释放掉它.
  • 在创建相应的游戏对象时创建实例数据 (spSkeleton, spAnimationState), 在销毁游戏对象时释放它.

轨道条目(spTrackEntry)从调用一个入队动画状态函数(spAnimationState_setAnimationByName, spAnimationState_addAnimationByName, spAnimationState_setEmptyAnimation, spAnimationState_addEmptyAnimation)到SP_ANIMATION_DISPOSE事件被发送到你的监听器的这段时间里一直有效. 在此事件触发后再访问轨道条目则会导致存储器段错误.

在创建结构体时, 经常将其他结构体作为引用传入. 而引用它的结构体将永远不会释放被引用的结构体. 例如spSkeleton引用了 spSkeletonData, 而后者又引用了spAtlas.

  • 释放spSkeleton不会释放spSkeletonDataspAtlas. 该设计有其缘由, 盖因spSkeletonData可能正被其他spSkeleton实例所共享.
  • 释放spSkeletonData将不会释放AtlspAtlasas. 这该设计亦有其缘由, 因为spAtlas可能正被其他spSkeletonData实例共享, 例如: 一个atlas包含了多个skeleton的图像.

如果你使用了一个自定义的内存分配器, 你也可以覆盖Spine的分配方式(改变 [extension.h](/git/spine-runtimes/blob/spine-c/spine-c/include/spine/extension.h#L65-L67) 中定义的malloc, reallocfree即可.)

结合以上全部功能

下面是一个简单示例, 展示了如何将上述全部内容组合在一起, 包括加载和为应用动画实例化(滚动鼠标滚轮以查看全部代码):

// Setup pose data, shared by all skeletons
spAtlas* atlas;
spSkeletonData* skeletonData;
spAnimationStateData* animationStateData;

// 5 skeleton instances and their animation states
spSkeleton* skeleton[5];
spAnimationState* animationState[5];
char* animationNames[] = { "walk", "run", "shot" };

void setup() {
   // setup your engine so textures can be loaded for atlases, create a window, etc.
   engine_setup();

   // Load the texture atlas
   atlas = spAtlas_createFromFile("spineboy.atlas", 0);
   if (!atlas) {
      printf("Failed to load atlas");
      exit(0);
   }

   // Load the skeleton data
   spSkeletonJson* json = spSkeletonJson_create(atlas);
   skeletonData = spSkeletonJson_readSkeletonDataFile(json, "spineboy.json");
   if (!skeletonData) {
      printf("Failed to load skeleton data");
      spAtlas_dispose(atlas);
      exit(0);
   }
   spSkeletonJson_dispose(json);

   // Setup mix times
   animationStateData = spAnimationStateData_create(skeletonData);
   animationStateData->defaultMix = 0.5f;
   spAnimationStateData_setMixByName("walk", "run", 0.2f);
   spAnimationStateData_setMixByName("walk", "shot", 0.1f);
}

void mainLoop() {
   // Create 5 skeleton instances and animation states
   // representing 5 game objects
   for (int i = 0; i < 5; i++) {
      // Create the skeleton and put it at a random position
      spSkeleton* skeleton = spSkeleton_create(skeletonData);   
      skeleton->x = random(0, 200);
      skeleton->y = random(0, 200);

      // Create the animation state and enqueue a random animation, looping
      spAnimationState animationState = spAnimationState_create(animationStateData);
      spAnimationState_setAnimation(animationState, 0, animationNames[random(0, 3)], 1);
   }

   while (engine_gameIsRunning()) {      
      engine_clearScreen();

      // update the game objects
      for (int i = 0; i < 5; i++) {
         spSkeleton* skeleton = skeletons[i];
         spAnimationState* animationState = animationStates[i];

         // First update the animation state by the delta time
         spAnimationState_update(animationState, engine_getDeltaTime());

         // Next, apply the state to the skeleton
         spAnimationState_apply(animationState, skeleton);

         // Update the skeleton's frame time for physics
         spSkeleton_update(engine_getDeltaTime());

         // Calculate world transforms for rendering
         spSkeleton_updateWorldTransform(skeleton, SP_PHYSICS_UPDATE);

         // Hand off rendering the skeleton to the engine
         engine_drawSkeleton(skeleton);
      }
   }

   // Dispose of the instance data. Normally you'd do this when
   // a game object is disposed.
   for (int i = 0; i < 5) {
      spSkeleton_dispose(skeleton);
      spAnimationState_dispose(animationState);
   }
}

void dispose() {
   // dispose all the shared resources
   spAtlas_dispose(atlas);
   spSkeletonData_dispose(skeletonData);
   spAnimationStateData_dispose(animationStateData);
}

int main(int argc, char* argv) {
   setup();
   mainLoop();
   dispose();
}

请注意setup pose数据(spAtlas, spSkeletonData, spAnimationStateData)和实例数据(spSkeleton, spAnimationState)间的区别及其不同的生命周期.

将 spine-c 整合进自研引擎

集成源代码

sspine-c由一组C头文件和实现文件组成, 源码可在运行时的Git仓库中spine-c/spine-c文件夹下找到. 你可以将源码复制到项目中, 也可以使用 CMake 的 FetchContent.

  1. 克隆Spine运行时仓库或使用适配于 Spine 编辑器的 分支版本.
  2. spine-c/spine-c/src/spine 文件夹中的源代码复制到你项目的源代码文件夹中, 并确保它们被纳入了你项目的编译流程中.
  3. 将包含头文件的spine文件夹从spine-c/spine-c/include复制到项目的头文件文件夹中, 并确保它们包含在编译器查找头文件的路径中. 请务必保留spine文件夹, 因为spine-cpp源代码通过#include "spine/xxx.h"引入头文件.

CMake的 FetchContent

从 Spine 4.2 开始, 还支持使用 CMake 的 FetchContent 功能轻松地将 spin-c 运行时集成到项目中, 如本例中的 CMakeLists.txt 文件所示.

cmake_minimum_required(VERSION 3.14)
project(MyProject C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(FETCHCONTENT_QUIET NO)

# Fetch the spine-runtimes repository and make the spine-c library available
include(FetchContent)
FetchContent_Declare(
spine-runtimes
GIT_REPOSITORY https://github.com/esotericsoftware/spine-runtimes.git
GIT_TAG 4.2
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(spine-runtimes)
FetchContent_GetProperties(spine-runtimes)
if(NOT spine-runtimes_POPULATED)
   FetchContent_Populate(spine-runtimes)
endif()
add_subdirectory(${spine-runtimes_SOURCE_DIR}/spine-c ${CMAKE_BINARY_DIR}/spine-runtimes)


# Create a simple C executable
file(GLOB SOURCES "src/*.c")
add_executable(MyExecutable ${SOURCES})
target_include_directories(MyExecutable PRIVATE src/)

# Link the spine-c library
target_link_libraries(MyExecutable spine-c)

实现扩展函数

若spine-c运行时期望你实现的函数未被实现, 那么项目编译时会报链接器(linker)错误. 以下是一个Clang编译器的示例错误输出:

Undefined symbols for architecture x86_64:
"__spAtlasPage_createTexture", referenced from:
    _spAtlas_create in libspine-c.a(Atlas.c.o)
"__spAtlasPage_disposeTexture", referenced from:
    _spAtlasPage_dispose in libspine-c.a(Atlas.c.o)
"__spUtil_readFile", referenced from:
    _spAtlas_createFromFile in libspine-c.a(Atlas.c.o)
    _spSkeletonBinary_readSkeletonDataFile in libspine-c.a(SkeletonBinary.c.o)
    _spSkeletonJson_readSkeletonDataFile in libspine-c.a(SkeletonJson.c.o)
ld: symbol(s) not found for architecture x86_64

这3个链接器找不到的函数被称为扩展函数. spine-c运行时希望用你的自研引擎提供的API来实现这些函数. 这些扩展函数的定义在extension.h里可以找到.

注意: 若你使用的是任一基于spine-c运行时的官方Spine运行时, 则这些函数和渲染流程均已实现. 你大可忽略本节.

实现 _spUtil_readFile 函数

spine-c使用_spUtil_readFile函数将Spine编辑器导出的全部JSON和二进制skeleton文件(.json/.skel),以及texture atlas文件(.atlas)读入内存. 请使用你的C/C++ IDE的"Find usages"或类似的功能来查看_spUtil_readFile在何处被调用.

_spUtil_readFile的函数签名如下所示:

char* _spUtil_readFile (const char* path, int* length);

该函数接收一个UTF-8编码的文件路径, 以及一个int指针, 函数将该文件已读取的字节数存于其中. 如果文件读取失败, 函数将返回0, 反之则返回一个内存指针, 其中存有已读入文件的所有字节.

调用_spUtil_readFile的代码应该在处理完数据后将返回的内存空间释放掉. spine-c中所有使用这个函数的代码都会确保内存的正确释放, 所以你不必为此担心.

内存应该用extension.h中定义的MALLOC或CALLOC宏来分配. 然后spine-c代码将使用FREE宏去释放内存. 你可以重新定义MALLOCCALLOCFREE, 如是可使spine-c运行时使用你自定义的内存分配方案.

_spUtil_readFile的最简实现如下:

char* _spUtil_readFile (const char* path, int* length){
   return _readFile(path, length);
}

它使用了一个叫做_readFile的函数, 这个函数由spine-c定义在extension.c中. _readFile使用fopen来读取当前工作目录下的文件。

如果你的引擎使用更复杂的方式来管理文件, 例如将它们打包成一个具有自定义文件结构的压缩文件, 你可以使用你引擎的文件i/o API来实现对应的_spUtil_readFile.

实现 _spAtlasPage_createTexture 和 _spAtlasPage_disposeTexture 函数

spine-c使用_spAtlasPage_createTexture函数加载从Spine编辑器中为Spine Skelton导出的texture atlas单页并在引擎中显示texture. 该函数作为spAtlas_createspAtlas_createFromFile的一部分被调用, 这些代码负责读取Spine texture atlas文件(.atlas)和构成atlas页的对应图像文件(通常是.png).

_spAtlasPage_createTexture的签名如下:

void _spAtlasPage_createTexture (spAtlasPage* self, const char* path);

该函数将atlas页图像文件加载到自研引擎规定的texture中, 并将其存储于传入给该函数的spAtlasPage实例里. spAtlasPage结构体有一个特殊字段, 名为rendererObject, 类型为void*, 其中存储了适用于自研引擎的texture. 这个rendererObject中的texture 随后将被引擎API用于渲染Spine skeleton.

该函数还会根据引擎加载的texture文件来设置spAtlasPage的宽度和高度(单位为像素). spine-c计算texture坐标需要这些数据.

path参数是atlas页图像文件的路径, 可以是相对于传入给spAtlas_createFromFile.atlas文件的路径, 也可以是相对于传给spAtlas_createdir参数的路径. 这两个函数一个用于从文件加载texture atlas, 另一个则从内存加载.

这里假定你的引擎提供了如下的API来处理texture:

typedef struct Texture {
   // ... OpenGL handle, image data, whatever ...
   int width;
   int height;
} Texture;

Texture* engine_loadTexture(const char* file);
void engine_disposeTexture(Texture* texture);

那么实现_spAtlasPage_createTexture就很简单了:

void _spAtlasPage_createTexture (AtlasPage* self, const char* path){
   Texture* texture = engine_loadTexture(path);

   // if texture loading failed, self->rendererObject, self->width and
   // self->height remain 0 and we simply return.
   if (!texture) return;

   // store the Texture on the rendererObject so we can
   // retrieve it later for rendering.
   self->rendererObject = texture;

   // store the texture width and height on the spAtlasPage
   // so spine-c can calculate texture coordinates for
   // rendering.
   self->width = texture->width;
   self->height = texture->height;
}

第二个需要实现的函数是_spAtlasPage_disposeTexture. 在调用spAtlas_dispose后, 它会在spAtlas释放时释放对应的spAtlasPage. _spAtlasPage_disposeTexture的函数签名如下所示:

void _spAtlasPage_disposeTexture (spAtlasPage* self);

若上文假设的引擎API不变, 则其函数实现将如下所示:

void _spAtlasPage_disposeTexture (spAtlasPage* self) {
   // if the rendererObject is not set, loading failed
   // so we do not need to dispose of anything.
   if (!self->rendererObject) return;

   // Dispose the texture
   Texture* texture = (Texture*)self->rendererObject;
   engine_disposeTexture(texture);
}

实现渲染

渲染Spine skeleton意味着以当前的绘制顺序渲染所有当前活动的附件. 绘制顺序本质是skeleton上的一个槽位数组.

可绘制附件(区域(regions), (可变形) 网格)定义了UV映射、顶点着色、三角形网格. 可绘制附件上的rendererObject存储了对texture atlas区域的引用, 该区域附件的三角形已被映射到了该texture上.

假设你已做好了skeleton的动画, 程序控制的动画和用动画状态播放的均可. 并且你已经通过调用spSkeleton_updateWorldTransform更新了skeleton的世界变换, 那么你可以按以下步骤渲染skeleton:

  • 对于skeleton的绘制顺序数组中的每个槽位
    • 从槽位中获取当前活动的附件(如果没有活动附件, 可以为空)
    • 从槽位中获取blend模式, 并将其翻译为你引擎的API
    • 根据skeleton和槽位颜色计算出着色颜色
    • 检查附件的类型
      • 若它是一个区域附件
        • 调用spRegionAttachment_computeWorldVertices计算其世界顶点
        • 从附件的渲染对象中获取atlas页texture
        • 用skeleton乘上(multiplying)槽位颜色来计算附件的着色颜色, 每通道的RGBA范围均为[0-1]
        • 将世界空间的顶点、UV和颜色并入一个三角形网格
        • 调用你引擎里的API来绑定texture
        • 提交网格用以渲染
      • 若它是一个网格附件
        • 调用spVertexAttachment_computeWorldVertices来计算其世界顶点
        • 从附件的渲染对象中获取atlas页texture
        • 用skeleton乘上(multiplying)槽位颜色来计算附件的着色颜色, 每通道的RGBA范围均为[0-1]
        • 将世界空间的顶点、UV和颜色并入一个三角形网格
        • 调用你引擎里的API来绑定texture
        • 提交网格用以渲染

若你的引擎支持渲染UV映射、顶点着色的三角形网格, 那么你的自研引擎应该很容易实现该逻辑. 为使阐述准确, 可假设引擎的API如下所记:

// A single vertex with UV
typedef struct Vertex {
   // Position in x/y plane
   float x, y;

   // UV coordinates
   float u, v;

   // Color, each channel in the range from 0-1
   // (Should really be a 32-bit RGBA packed color)
   float r, g, b, a;
} Vertex;

enum BlendMode {
   // See http://esotericsoftware.com/git/spine-runtimes/blob/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/BlendMode.java#L37
   // for how these translate to OpenGL source/destination blend modes.
   BLEND_NORMAL,
   BLEND_ADDITIVE,
   BLEND_MULTIPLY,   
   BLEND_SCREEN,
}

// Draw the given mesh.
// - vertices is a pointer to an array of Vertex structures
// - start defines from which vertex in the vertices array to start
// - count defines how many vertices to use for rendering (should be divisible by 3, as we render triangles, each triangle requiring 3 vertices)
// - texture the texture to use
// - blendMode the blend mode to use
void engine_drawMesh(Vertex* vertices, int start, int count, Texture* texture, BlendMode blendmode);

渲染过程的实现则如下所示:

#define MAX_VERTICES_PER_ATTACHMENT 2048
float worldVerticesPositions[MAX_VERTICES_PER_ATTACHMENT];
Vertex vertices[MAX_VERTICES_PER_ATTACHMENT];

// Little helper function to add a vertex to the scratch buffer. Index will be increased
// by one after a call to this function.
void addVertex(float x, float y, float u, float v, float r, float g, float b, float a, int* index) {
   Vertex* vertex = &vertices[*index];
   vertex->x = x;
   vertex->y = y;
   vertex->u = u;
   vertex->v = v;
   vertex->r = r;
   vertex->g = g;
   vertex->b = b;
   vertex->a = a;
   *index += 1;
}

void drawSkeleton(spSkeleton* skeleton) {
   // For each slot in the draw order array of the skeleton
   for (int i = 0; i < skeleton->slotsCount; ++i) {
      spSlot* slot = skeleton->drawOrder[i];

      // Fetch the currently active attachment, continue
      // with the next slot in the draw order if no
      // attachment is active on the slot
      spAttachment* attachment = slot->attachment;
      if (!attachment) continue;

      // Fetch the blend mode from the slot and
      // translate it to the engine blend mode
      BlendMode engineBlendMode;
      switch (slot->data->blendMode) {
         case SP_BLEND_MODE_NORMAL:
            engineBlendMode = BLEND_NORMAL;
            break;
         case SP_BLEND_MODE_ADDITIVE:
            engineBlendMode = BLEND_ADDITIVE;
            break;
         case SP_BLEND_MODE_MULTIPLY:
            engineBlendMode = BLEND_MULTIPLY;
            break;
         case SP_BLEND_MODE_SCREEN:
            engineBlendMode = BLEND_SCREEN;
            break;
         default:
            // unknown Spine blend mode, fall back to
            // normal blend mode
            engineBlendMode = BLEND_NORMAL;
      }

      // Fill the vertices array depending on the type of attachment
      Texture* texture = 0;
      int vertexIndex = 0;
      if (attachment->type == ATTACHMENT_REGION) {
         // Cast to an spRegionAttachment so we can get the rendererObject
         // and compute the world vertices
         spRegionAttachment* regionAttachment = (spRegionAttachment*)attachment;

         // Calculate the tinting color based on the skeleton's color
         // and the slot's color. Each color channel is given in the
         // range [0-1], you may have to multiply by 255 and cast to
         // and int if your engine uses integer ranges for color channels.
         float tintR = skeleton->color.r * slot->color.r * regionAttachment.color.r;
         float tintG = skeleton->color.g * slot->color.g * regionAttachment.color.g;
         float tintB = skeleton->color.b * slot->color.b * regionAttachment.color.b;
         float tintA = skeleton->color.a * slot->color.a * regionAttachment.color.a;

         // Computed the world vertices positions for the 4 vertices that make up
         // the rectangular region attachment. This assumes the world transform of the
         // bone to which the slot (and hence attachment) is attached has been calculated
         // before rendering via spSkeleton_updateWorldTransform
         spRegionAttachment_computeWorldVertices(regionAttachment, slot->bone, worldVerticesPositions, 0, 2);

         // Our engine specific Texture is stored in the spAtlasRegion which was
         // assigned to the attachment on load. It represents the texture atlas
         // page that contains the image the region attachment is mapped to
         texture = (Texture*)((spAtlasRegion*)regionAttachment->rendererObject)->page->rendererObject;

         // Create 2 triangles, with 3 vertices each from the region's
         // world vertex positions and its UV coordinates (in the range [0-1]).
         addVertex(worldVerticesPositions[0], worldVerticesPositions[1],
                regionAttachment->uvs[0], regionAttachment->uvs[1],
                tintR, tintG, tintB, tintA, &vertexIndex);

         addVertex(worldVerticesPositions[2], worldVerticesPositions[3],
                regionAttachment->uvs[2], regionAttachment->uvs[3],
                tintR, tintG, tintB, tintA, &vertexIndex);

         addVertex(worldVerticesPositions[4], worldVerticesPositions[5],
                regionAttachment->uvs[4], regionAttachment->uvs[5],
                tintR, tintG, tintB, tintA, &vertexIndex);

         addVertex(worldVerticesPositions[4], worldVerticesPositions[5],
                regionAttachment->uvs[4], regionAttachment->uvs[5],
                tintR, tintG, tintB, tintA, &vertexIndex);

         addVertex(worldVerticesPositions[6], worldVerticesPositions[7],
                regionAttachment->uvs[6], regionAttachment->uvs[7],
                tintR, tintG, tintB, tintA, &vertexIndex);

         addVertex(worldVerticesPositions[0], worldVerticesPositions[1],
                regionAttachment->uvs[0], regionAttachment->uvs[1],
                tintR, tintG, tintB, tintA, &vertexIndex);
      } else if (attachment->type == ATTACHMENT_MESH) {
         // Cast to an spMeshAttachment so we can get the rendererObject
         // and compute the world vertices
         spMeshAttachment* mesh = (spMeshAttachment*)attachment;

         // Calculate the tinting color based on the skeleton's color
         // and the slot's color. Each color channel is given in the
         // range [0-1], you may have to multiply by 255 and cast to
         // and int if your engine uses integer ranges for color channels.
         float tintR = skeleton->color.r * slot->color.r * regionAttachment.color.r;
         float tintG = skeleton->color.g * slot->color.g * regionAttachment.color.g;
         float tintB = skeleton->color.b * slot->color.b * regionAttachment.color.b;
         float tintA = skeleton->color.a * slot->color.a * regionAttachment.color.a;

         // Check the number of vertices in the mesh attachment. If it is bigger
         // than our scratch buffer, we don't render the mesh. We do this here
         // for simplicity, in production you want to reallocate the scratch buffer
         // to fit the mesh.
         if (mesh->super.worldVerticesLength > MAX_VERTICES_PER_ATTACHMENT) continue;

         // Computed the world vertices positions for the vertices that make up
         // the mesh attachment. This assumes the world transform of the
         // bone to which the slot (and hence attachment) is attached has been calculated
         // before rendering via spSkeleton_updateWorldTransform
         spVertexAttachment_computeWorldVertices(SUPER(mesh), slot, 0, mesh->super.worldVerticesLength, worldVerticesPositions, 0, 2);

         // Our engine specific Texture is stored in the spAtlasRegion which was
         // assigned to the attachment on load. It represents the texture atlas
         // page that contains the image the mesh attachment is mapped to
         texture = (Texture*)((spAtlasRegion*)mesh->rendererObject)->page->rendererObject;

         // Mesh attachments use an array of vertices, and an array of indices to define which
         // 3 vertices make up each triangle. We loop through all triangle indices
         // and simply emit a vertex for each triangle's vertex.
         for (int i = 0; i < mesh->trianglesCount; ++i) {
            int index = mesh->triangles[i] << 1;
            addVertex(worldVerticesPositions[index], worldVerticesPositions[index + 1],
                   mesh->uvs[index], mesh->uvs[index + 1],
                   tintR, tintG, tintB, tintA, &vertexIndex);
         }

      }

      // Draw the mesh we created for the attachment
      engine_drawMesh(vertices, 0, vertexIndex, texture, engineBlendMode);
   }
}

这个朴素的实现将助你快速上手运行时集成. 然而仍有几个显而易见的缺陷可供优化:

  • engine_drawMesh会立即提交网格进行渲染. 这意味着skeleton上的每个附件都会触发一个绘制调用. 一个生产级可用的实现应该将所有的网格合批成一个网格. 理想情况下, 如果所有附件都使用相同的texture atlas页和blend模式, 绘制一个skeleton将只需要触发一次绘制调用. 若一个场景中的所有skeleton都使用相同的texture atlas页和blend模式, 你甚至可以将所有的skeleton合批成一个网格, 从而用一次绘制调用就完成所有绘制.
  • 这套设置未使用索引网格(indexed meshes), 而是为每个三角形提交3个顶点.
  • 这套设置不支持双色着色(tinting).
  • 这套设置不支持剪裁(clipping). 具体详见spine-sfml运行时示例, 以了解如何实现剪裁功能.