在 Web 中使用 Spine 动画(二)

1,865 阅读12分钟

上一篇介绍了 在 Web 中使用 Spine 动画,但是这仅仅是把 Spine 动画简单的展示在页面中,实际项目中,必然有一些交互控制动画的展示。对于这些,又有很多的挑战!记录这篇文章,主要是想吐槽 Spine Web 的文档,真的是让人想吐。虽然最终是短短几行代码,但是你要去找论坛帖子 + 不断的尝试。

使用上篇文章中的代码 SpinePlayer 组件,就可以直接展示动画。但是实际的需求可能是在某种条件下才会出现动画,而且动画不要循环播放。而且设计做的动画可能是把多个动画效果做进一个素材里,那么如何展示特定的动画?看起来好像很好控制很容易,实际上完全不是。

很多时候需要去看源码,所以我的了解不一定对,如果不对,欢迎指正!以下是探索的过程,前面的很多做法可能并不合适。

适配问题

动画的位置和大小:根据设计稿在编辑器世界坐标中的位置,可以知道动画区域在设计稿中的位置。以及大小。

container 设置为动画的大小,它随着屏幕大小改变自动适应。

位置也是,所以跟一般元素适配一样。

setEmptyAnimation

当你

<Spineboy
  ref="spineBoy"
  jsonPath="./spine/spine-boy.json"
  atlasPath="./spine/spine-boy.atlas"
  :animation="['walk']"
/>

animation 表示进行播放的动画名称,可以通过组件传参,如果我的需求是加载页面先不要播放动画,在需要的时候才播放动画。该如何实现呢?库的配置并没有默认播放或暂停的设置。

如果设置一个 animation,会发现加载完之后直接自动播放。如果不设置 animation,会显示静止的动画元素。

通过 setEmptyAnimation 可以设置一个空动画。

player.animationState?.setEmptyAnimation(0, 0)

所以可以在动画加载完成之后,通过 setEmptyAnimation 清空动画。

Track 通道

Track 是 Spine 动画的通道,不同通道可以叠加运用,我这里没有运用多个通道,所以通道默认为 0。

setEmptyAnimationaddAnimationsetAnimation 第一个参数都是 trackIndex

播放动画

addAnimation:添加一个待播放动画, 在某轨道的当前或最后一个排队动画之后播放. 若该轨道为空, 则相当于调用setAnimation.

所以可以实现类似点击按钮开始动画的效果:

player.setAnimation(animationName, false)
player.play()

这是用库源码中的方法:

这里有点不明白,从源码上看,play 方法 只是根据 showControls 控制播放器的显示,我这里 showControls 为 false。如果不 player.play(),则动画不正常,只在结束位置显示动画元素的半透明效果或者不显示(不同动画)。

(随着后面新的解决方法的更新,我也了解了 play 的原理,play 虽然只是改变 paused 状态,但是会影响动画的执行 drawFrame

知道这种用法是来自:en.esotericsoftware.com/forum/d/252…

success 回调

如果想要动画播放完之后做点什么,比如开始下一个动画,应该怎么做呢?

可以在 success 之后增加回调监听 addListener (AnimationStateListener listener): void

查看 AnimationStateListener 包含的方法。

success: (player) => {
  console.log('The skeleton and its assets have been successfully loaded.')
  // 监听动画完成事件
  player.animationState?.addListener({
    complete: function (entry) {
      console.log('complete: ', entry.animation.name)
    },
  })
},

这样可以在动画 complete 之后继续想要的操作,可以获取完成的动画名称。

改变 container 大小

如何做连续的动画呢?

一开始我没有详细看 viewport 的使用,viewport 的设置为:

viewport: {
  padTop: 0,
  padLeft: 0,
  padBottom: 0,
  padRight: 0,
}

动画师设计导出的素材中,包含多个动画,但是每一个动画的大小不同,比如 SpineBoy walk 的动画和 jump 的动画。为了适配展示角色的大小,在切换动画的时候,我需要改变 container 的大小,否则下一个动画角色的大小就会改变。比如,idle 大小 127 * 185,move 大小 295 * 222,在这样的尺寸大小下,其中的角色大小一致,如果一开始 container 大小为 127 * 185,idle 动画切换到 move,此时 move 动画在 127 * 185 的大小中展示,其中的角色缩小了。

所以我改变 container 大小来展示不同的 animation。

但是不同动画之间 container 的改变,,比如 move->idle,move 结束在视口的右边,改变 container,container 变小:295 * 222 -> 127 * 185,视口适应,此时角色变小了,然后开始 idle动画,视口在改变后的 container 中适应,也就是 127 * 185,角色变大(本来大小)。因此有这样一个变小变大的过程。

当 Web Player 在动画之间切换时, 它会将新旧动画的视口间进行0.2秒的插值. 这个视口转换时间可以通过transitionTime属性设置:

viewport: { transitionTime: 0 }

设置 transitionTime 可以减少过渡的时间,但还是有一闪的效果。

这个可能和后面遇到的一个问题相似

viewport 如何使用

视口可能是最难理解的了,视口跟世界坐标有关系,可能需要看一下 Spine Editor 中的操作才能明白。

首先有默认的 pad,为了减少干扰,设置 pad为 0。然后动画会在 container 中自适应,也就是要么高度撑满要么宽度撑满。

x、y 代表绿框左下角的位置,通过 x、y 来定位视口。意思是将视口的左下角放在世界坐标中心,此时世界坐标中展示区域就显示在视口中。

width、height 表示视口的宽高。

viewport: {
  debugRender: true,
  padTop: 0,
  padLeft: 0,
  padBottom: 0,
  padRight: 0,
  x: -225,
  y: -200,
  width: 450,
  height: 400,
},

通过 debugRender 可以方便的查看视口大小和位置。

在这样设置之后,所有的 animation 都在同一视口中显示,所以就不用改变 container 大小了。

clearTracks

clearTracks (): void

移除所有轨道上的所有动画, 使skeleton保持当前姿势.若需要将Skelton混合回setup pose而非当前姿势时可以使用 setEmptyAnimation.

在进行下一次动画之前 clearTracks,可以保持动画当前 pose:

player.animationState?.clearTracks()

right_move -> front

遇到一个很奇怪的问题,我的动画有不同方向的待机和移动,当 right_idle 和 right_move 之间相互切换时,一切正常,但是 right_move 切换到 front_idle 却不正常了,表现为角色没有显示在容器中或者显示一个影子:

image.png

如果去掉上面的在切换动画之前的 clearTracks,会解决这个不显示的问题,但是动画会回到初始 pose。

多个方向上的实例

这个问题对于我来说有点难,不知道是动画本身的问题还是我的用法不对。但是还是要解决问题,最终,我想到一种权宜之计,既然不同方向上的切换有问题,那创建多个方向上的实例,当切换方向时,显示当前方向上的实例进行动画不就可以了?

for (const animateBoy of Object.values(animateBoyRefs)) {
    animateBoy.value?.$el.classList.add('hidden')
    // 清空动画
    animateBoy.value?.setEmptyAnimation()
}
curAnimateBoy.value?.$el.classList.remove('hidden')


function switchDirection(position: number, direction?: string): void {
  const finalDirection = direction || coordinates[position].direction
  const refKey = `animateBoy${capitalizeFirstLetter(finalDirection)}`
  curAnimateSky = animateBoyRefs[refKey as keyof typeof animateBoyRefs]
}

function setEmptyAnimation(): void {
  if (player) {
    player.animationState?.setEmptyAnimation(0, 0)
  }
}

这样通过在切换方向时改变当前的动画实例,设置隐藏显示,清空动画,来实现不同方向上的动画衔接。

初始位置问题

然后又发现另外一个问题。

目前的实现是,right_idle -> right_move -> right_idle,move 的时候保持在结束时的 pose,所以 right_idle 时是在结束时的位置,而它的初始位置是在视口中间。当进行过 right_idle 之后,被隐藏以及 setEmptyAnimation 之后,最后再次显示、进行 right_idle 动画时,角色的位置还保留在上一次的位置(move 结束的位置),而不是 right_idle 初始位置。这样针对最后当前位置的显示就不对。

那么如何解决呢?详细分析问题之后,认为问题就出在 setEmptyAnimation 保留了最后 pose。问 ChatGPT 说 setEmptyAnimation 会让恢复初始位置。但是给出了重置骨骼到初始状态的方法:

player.animationState.clearTracks();
player.skeleton.setToSetupPose();

上面的 clearTracks 也提到,若需要将 Skelton 混合回 setup pose 而非当前姿势时可以使用 setEmptyAnimation.但是实际上却不是!

所以:

player.animationState?.setEmptyAnimation(0, 0)
player.skeleton?.setToSetupPose()

重新使用一个实例

既然 setToSetupPose 可以重置骨骼初始位置,那么之前使用一个实例时候的问题,是不是也可以解决?

确实可以解决。在转角的位置进行清除重置,同时需要设置位置。

但是在转角位置切换方向时,有一个比较明显的闪动,所以之前多个实例的方法还是有其优点。

新的解决方法

现在我们重新分析:

一个动画在播放结束时,会停留在结束位置;如果继续播放或者开始下一个动画,则会从上一个动画的开始位置开始。

defaultMix 可以设置两个动画之间的切换过渡时长,默认 0.25 秒。

transitionTime 可以设置视口之间的转换时间,默认 0.2 秒。

我的视口没有变化,跟 transitionTime 没有关系。

但是会在多个动画之间切换,所以能看到不同动画之间的切换过程,将 defaultMix 设为 0,会看到仍然有闪动的过程。

  1. 针对角色消失的问题,首先不要使用 clearTracks 清空轨道。
  2. 其次,我们不需要在对运动的最后一次 idle 的情况,不进行位置设置,而是所有的动画都进行位置设置,让它们在当前的位置运动。

这样之后,动画都在当前的位置上,但是下一次的动画都会从上一次动画的开始位置开始,同时进行位置的移动,但是问题是它们似乎不能重合。

image.png

假设视口如蓝框。角色如黄框。动画 move 在视口中从实框移动到虚框。

每一次运动:设置位置,播放动画。

setAnimatePosition(position)
player.setAnimation(0, 'move', false)

第一次 move,设置位置 1,角色从位置 1 运动到位置 2;第二次 move,设置位置 2,角色从位置 2 运动到位置 3。当第二次位置开始时,角色还在位置 3,因为有下一次运动,角色会从位置 3 回到 2 开始 2 -> 3。这个从 3 回到 2 的过程就是动画与动画之间的切换过渡时间。

(删除说明,不准确,参考动画切换混入时间)

设置 defaultMix 为 0,还是会有一闪的过程。因为设置位置(1->2)之后,player 还没开始新的渲染,当开始渲染时,会有从结束位置到开始位置的变化。

问题是,如果先开始动画后移动位置,那动画会回到初始位置开始,不是从 1 开始吗?不也应该有从 1->2 的闪动吗?

(以上删除说明,当设置 defaultMix = 0,移动和开始应该是同时进行)

按说,同时移动位置、开始动画,第二次 move 动画,开始 pose 应该就在位置 2。为什么会从 3 闪到 2 呢?

解决是:异步执行设置位置。

但是为什么呢?如果先执行动画,根据我们了解的,move 动画中,角色会回到 1,无论如何都有一个闪动的效果。

答案是 Spine 动画的渲染是异步的。所以动画并不是真的先开始,而是动画本来就是异步的。

这个要具体的分析源码,核心在于:github.com/EsotericSof…

其中渲染循环 drawFrame:

  • AnimationState.update(playDelta) : 根据时间差推进当前动画进度。

  • AnimationState.apply(skeleton) : 将当前动画状态应用到骨骼。

  • skeleton.updateWorldTransform() : 更新骨骼世界变换,计算骨骼的全局位置、旋转等。

  • renderer.drawSkeleton : 渲染骨骼动画。

动画切换混入时间

defaultMix 的含义是,是两个动画之间切换的时间。混入指的是当一个动画结束或被另一个动画替换时,两个动画会在某个时间段内同时播放,并根据过渡时间的长短进行线性插值,使得角色从一个动画状态逐渐过渡到另一个动画状态。

以官网例子看,jump -> walk 时,如果在 jump 中间切换 walk,如果 defaultMix = 0.5,那么会有 0.5s 时间淡出 jump 淡入 walk 的过程,如果 walk 动画时间 1s,则接下来进行后面 0.5s 的 walk 动画。如果 defaultMix = 0,则从 jump 直接切换到 walk,从 walk 初始位置开始动画。

关于运行时的选择

在第一篇我就写过,关于用哪个库,有点让人犯难,不过肯定先用一下官方的

image.png

因为这句,就用了 spine-player。

但是这次在做的过程中,不止一次怀疑是不是不应该用这个,正如它的名字,它只是一个播放器,复杂一点的交互是不是应该有别的选择。但是因为是第一次,随着不断的使用,其他的也不熟,用哪个都是从头开始,就坚持下来了。

最后得到官方的建议,做 game 还是需要 spine-phaser 或者 spine-pixi.

第一次,也算是踩坑了。技术选型,也要有相应的经验才能做啊。

不过,spine-player 实现简单的动画应该是更容易一些的,而且实现简单的交互也并不是不能。下一次看看 spine-phaser。

吐槽收集

image.png

image.png

参考链接