PixiJS x Spineでキャラクターアニメーションを実装してみる【夏の夜】
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