🎥

PixiJS x Spineでキャラクターアニメーションを実装してみる【夏の夜】

2023/11/14に公開

2連チャンの Spine 記事です。
今回も PixiJS / pixi-spine を利用して、キャラクターアニメーションを作ってみました。

成果物

画面キャプチャ

成果物のURLはコチラです。
> pixijs spine night-fire-fly page

ページを表示すると着物姿の女性と、周りを漂うホタルのアニメーションが再生されます。
(SP最適化は未実装のため、PCで確認してみてください。)

ベースは PixiJS です。
着物姿の女性のアニメーションは Spine で作成しており、PIXI.Assets でエクスポートファイルを読み込んで PixiJS の stage に配置しています。
ホタルが点滅し、フワフワ漂うアニメーションはすべて TypeScript で記述しています。PixiJS の PIXI.ParticleContainer を利用しました。
イラストは Stable Diffusion で生成して、Photoshop + ペンタブで書き足したりして、調整しました。

主な npm パッケージとバージョン

パッケージ バージョン
lil-gui ^0.18.2
pixi-spine ^4.0.4
pixi.js ^7.3.1
typescript ^4.8.4

ソースコードの解説

リポジトリはコチラです。
> t-tonyo-maru/pub_web_pixijs_night-fire-fly

前回と同様、主要なファイルは以下の main.ts のみです。
> /src/ts/main.ts

Spine アニメーション

基本的に前回と同様のコードで、PIXI.Assets で Spine エクスポートファイルを読み込んでいます。
1 つ異なる点として、トラック:1 に close-eye アニメーションとアルファを設定しています。(※)
トラックとアルファについては、後述します。

import 'pixi-spine'
import { Spine } from 'pixi-spine'

// Spine エクスポートファイルの読み込み
const spineAnimation = await PIXI.Assets.load(
  `path/to/spine-data/model.json`
)
  .then((res) => {
    // 読み込み成功時
    // pixi-spine より Spine Animation インスタンスを生成
    const animation = new Spine(res.spineData)

    // Spine Animation の位置をセット
    animation.x = app.screen.width / 2
    animation.y = app.screen.height

    // トラック:0 に idle アニメーションを追加
    animation.state.setAnimation(0, 'idle', true)
    // トラック:1 に close-eye アニメーションを追加…(※)
    animation.state.setAnimation(1, 'close-eye', false)
    // トラック:1 のアルファを 0 にする
    animation.state.tracks[1].alpha = 0

    return animation
  })
  .catch((err) => {
    console.log(err)
  })

トラックとアルファ

Spine アニメーションには、トラックアルファというモノがあります。
トラックとはアニメーションを格納するレイヤーのようなモノです。トラックはより上位のトラックに設定されたアニメーションで上書きされます。
アルファは、トラック同士の上書きの度合いです。0 〜 100% までの値を指定します。
> トラック | Spine

今回のアニメーションを例にとって解説します。

下記リンク先の動画キャプチャは、今回作成したアニメーションを Spine のプレビューパネルで表示したモノです。
> Spine プレビューパネル
トラック:0 に待機アニメーション(idle)。トラック:1 に目を閉じるアニメーション(close-eye)をセットしています。
画面右下にある「アルファ」から、トラック:1 のアルファを 0 から 100 に変更してみます。
そうすると、待機アニメーションは再生したまま、目を閉じるアニメーションが上書きされているのが分かります。

このように、Spine はトラックとアルファを駆使して、アニメーションをレイヤーのように合成し、より複雑なアニメーションを再生できます。

もちろん、トラックとアルファは JavaScript(TypeScript) で制御できます。
成果物ページ右上のコントロールパネルの Spine > blink チェックボックスをクリックしてみてください。
チェックを入れている間は、目を閉じるアニメーションが適用されます。

これは、app.ticker.add() 内でトラック:1 のアルファを動的に更新しているためです。

app.ticker.add(() => {
  // spine animation
  if (spineAnimation) {
    if (guiObject.spineParameters.blink) {
      // Spine > blink にチェックが入っている場合は、トラック:1 の alpha を更新する
      spineAnimation.state.tracks[1].alpha = (Math.sin(time * 2.5) + 1) / 2
    } else {
      spineAnimation.state.tracks[1].alpha = 0
    }
  }
})

ちなみに、Spine ランタイムのトラックは配列であるため、トラックのインデックスは「0」始まりです。

ホタルのアニメーション

ホタルのアニメーションは TypeScript で記述しています。
PIXI.ParticleContainer を使って、ホタルを管理しています。

import * as PIXI from 'pixi.js'

// ホタルの型
type Firefly = {
  sprite: PIXI.Sprite
  phase: number // 点滅をずらすための周期
  speed: number // スピード
  direction: number // 方向
}

// ホタルの総数
const MAX_PARTICLE_COUNT = 16
// ホタルの点滅アニメーションで利用する係数。適当なさじ加減で決めてます。
const BLINKING_TIME = 2.5 // 2 ~ 3
// ホタル管理用の配列
const fireflies: Firefly[] = []
// ホタルを詰め込む PIXI.ParticleContainer
const particleContainer = new PIXI.ParticleContainer(MAX_PARTICLE_COUNT, {
  scale: true,
  position: true,
  rotation: true,
  uvs: true,
  alpha: true
})

// ホタルの生成
for (let i = 0; i < MAX_PARTICLE_COUNT; i++) {
  // ホタルの画像を読み込み PIXI.Sprite を生成
  const fireflySprite = PIXI.Sprite.from(`path/to/images/fire-fly.png`)
  // アンカーを中心にセット
  fireflySprite.anchor.set(0.5)
  // ランダムな縮尺率をセット
  fireflySprite.scale.set(0.4 + Math.random() * 0.3)
  // ホタルをランダムな位置に配置
  fireflySprite.position.set(
    Math.random() * app.screen.width,
    Math.random() * app.screen.height
  )
  // ホタルを不透明度にランダムな値をセット
  fireflySprite.alpha = Math.random()

  // 管理用配列と PIXI.ParticleContainer にホタルを詰め込む
  fireflies.push({
    sprite: fireflySprite,
    phase: Math.random() * 2 * Math.PI,
    speed: Math.random(),
    direction: Math.random() * 2 * Math.PI
  })
  particleContainer.addChild(fireflySprite)
}

// アニメーション
app.ticker.add(() => {
  const time = Date.now() / 1000
  for (let i = 0; i < MAX_PARTICLE_COUNT; i++) {
    // ホタルの不透明度を調整。これにより点滅しているように見えます。
    fireflies[i].sprite.alpha = (Math.sin(time + fireflies[i].phase) + 1) / BLINKING_TIME

    // ホタルの位置を更新
    fireflies[i].sprite.x += fireflies[i].speed * Math.cos(fireflies[i].direction)
    fireflies[i].sprite.y += fireflies[i].speed * Math.sin(fireflies[i].direction)

    // ホタルが画面外に出てしまったときに、画面内の位置に戻す
    if (fireflies[i].sprite.x < 0) {
      fireflies[i].sprite.x += app.screen.width
    } else if (fireflies[i].sprite.x > app.screen.width) {
      fireflies[i].sprite.x -= app.screen.width
    }
    if (fireflies[i].sprite.y < 0) {
      fireflies[i].sprite.y += app.screen.height
    } else if (fireflies[i].sprite.y > app.screen.height) {
      fireflies[i].sprite.y -= app.screen.height
    }
  }
})

まとめ

今回はトラックとアルファを使って、アニメーションの分離とランタイム上での合成に挑戦してみました。
次はもうちょっと複雑なアニメーションに挑戦してみたいところです。
では、また!

Discussion