🤡

Spine v4.2 で登場した物理演算に触れてみる

2024/04/23に公開

久しぶりの Spine 記事です!
表題の通り、今回は Spine v4.2 の目玉機能である物理演算に触れてみます!

Spine の物理演算は v4.2 beta 版にて試用できる状態が続いていました。
…が、2024.04.16 に満を持して Spine v4.2 が登場しました。🎉
「beta」がとれた事によって、エディタでもランタイムでも物理演算をガシガシ使える状況になりました。

成果物

今回の成果物はコチラです。
> Spine v4.2 物理演算テスト サイト
> Spine v4.2 物理演算テスト リポジトリ

物理演算を設定したビックリ箱
物理演算を設定したビックリ箱

ページを表示すると、画面中央にビックリ箱、右上にコントロールパネルが表示されます。
ビックリ箱のバネ・舌・目に物理演算を適用しています。

コントロールパネルの「x」の値を変更すると、ビックリ箱の x 座標が変化します。
この x 座標の変化に合わせて、バネ・舌・目がヌルヌルと動きます。
また、「wind」の値を 0 から変更すると、ビックリ箱が横風が当たるようなポーズをとります。
「wind」が正数であれば右向きの風があたり、負数であれば左向きの風があたります。

Spine エディタ上での物理演算

さっそく、物理演算を使っていきます。
…といっても、使い方は簡単でボーンに「物理コンストレイント」を適用するだけです。

任意のボーンを選択した後に、Spine エディタのツリービュー下部にある「新規」をクリックします。
その後に表示される選択肢のうち、「物理コンストレイント」をクリックします。
コレで下準備は完了です!

物理コンストレイント
物理コンストレイント

「物理コンストレイント」を付与すると、物理演算の様々なパラメータを設定できます。
さらに、「シミュレート」が ON であれば、アニメーションを作成せずともボーンを動かすだけで、物理演算の動きを確認できます。
物理コンストレイント パラメータ
物理コンストレイントのパラメータ一覧。スクショ左下に「シミュレート」UIがあります

より詳しい使い方や情報は、新Twitch配信シリーズ:Spineでの物理演算の探求をご確認ください。

Spine ランタイム上での物理演算

さて、v4.2 の TypeScript ランタイムも使ってみました。
パッと見たところでは、v4.2 になってもランタイムの使い方に大きな変更点はないようです。
(…とはいえ、公式 TypeScript ランタイムの example を見た程度ですので、実際に使っていくと諸々の更新点が見つかると思います。随時、キャッチアップしていきます。)

ソースコードの解説

以下は今回作成したプロジェクトの肝となるsrc/main.tsです。

基本的に、公式ランタイムリポジトリの spine-webgl 版の example/mix-and-match.htmlexample/physics.html を参考にしています。
私が独自に書き加えたのは lil-gui の導入くらいですね。

src/main.ts
import './reset.css'
import * as Spine from '@esotericsoftware/spine-webgl'
import GUI from 'lil-gui'

class SpineApp implements Spine.SpineCanvasApp {
  private skeleton: unknown // type: Spine.Skeleton
  private state: unknown // type: Spine.AnimationState

  loadAssets(canvas: Spine.SpineCanvas): void {
    // spine export ファイルを取得します。
    canvas.assetManager.loadTextureAtlas('model.atlas')
    canvas.assetManager.loadJson('model.json')
  }

  initialize(canvas: Spine.SpineCanvas): void {
    // assetManager を使って、Spine.Skeleton と Spine.AnimationState を作成していきます。
    const assetManager = canvas.assetManager

    const atlas = canvas.assetManager.require('model.atlas')
    const atlasLoader = new Spine.AtlasAttachmentLoader(atlas)
    const skeletonJson = new Spine.SkeletonJson(atlasLoader)
    const skeletonData = skeletonJson.readSkeletonData(
      assetManager.require('model.json')
    )

    this.skeleton = new Spine.Skeleton(skeletonData)

    if (this.skeleton instanceof Spine.Skeleton) {
      this.skeleton.scaleX = 0.5 * devicePixelRatio
      this.skeleton.scaleY = 0.5 * devicePixelRatio
      this.skeleton.x = 0
      this.skeleton.y =
        (-1 * Math.floor(this.skeleton.data.height * 0.5 * devicePixelRatio)) /
        2
      this.skeleton.setToSetupPose()
      this.skeleton.update(0)
      // ※updateWorldTransform() 関数に、以下の 4 つのうちのいずれかを引数として適用します。数値をそのまま指定しても OK です。
      // Spine.Physics.none   = 0
      // Spine.Physics.reset  = 1
      // Spine.Physics.update = 2
      // Spine.Physics.pose   = 3
      this.skeleton.updateWorldTransform(Spine.Physics.update) // …※1

      // lil-gui
      const spineFolder = gui.addFolder('spine')
      // x
      spineFolder.add(
        this.skeleton,
        'x',
        -200 * devicePixelRatio ** 2,
        200 * devicePixelRatio ** 2,
        1 / devicePixelRatio
      )
      // wind
      const windContoller = spineFolder.add({ wind: 0 }, 'wind', -20, 20, 1)
      windContoller.onChange((value: number) => {
        if (!(this.skeleton instanceof Spine.Skeleton)) return
        // Spine.Skeleton の中身を見て、すべての物理コンストレイントの「風」パラメータを更新します。
        // このパラメータを更新することで、まるで"横風が吹いているような"ポーズになります。
        this.skeleton.data.physicsConstraints.map((c) => {
          c.wind = value // …※2
        })

        // コントロールパネルから wind を更新した際に、setToSetupPose() 関数を呼び出します。
        // setToSetupPose() を呼び出さないと物理演算が適用されなくなってしまいます。
        this.skeleton.setToSetupPose()
        // WARING: skeleton.update() と skeleton.updateWorldTransform() は、本当に必要か要確認です。
        // skeleton.setToSetupPose() だけで動作するので、以降の関数 2 つは不要かもしれません。
        // …が、念のために記述しています。
        this.skeleton.update(0)
        this.skeleton.updateWorldTransform(Spine.Physics.update)
      })
    }

    const stateData = new Spine.AnimationStateData(skeletonData)
    this.state = new Spine.AnimationState(stateData)
    if (this.state instanceof Spine.AnimationState) {
      this.state.setAnimation(0, 'animation', true) // …※3
    }
  }

  update(_canvas: Spine.SpineCanvas, delta: number): void {
    if (!(this.skeleton instanceof Spine.Skeleton)) return
    if (!(this.state instanceof Spine.AnimationState)) return

    // update() 関数でも initialize() 関数と同じく、updateWorldTransform() 関数に引数を適用します。
    this.state.update(delta)
    this.state.apply(this.skeleton)
    this.skeleton.update(delta)
    this.skeleton.updateWorldTransform(Spine.Physics.update) // …※1
  }

  render(canvas: Spine.SpineCanvas): void {
    if (!(this.skeleton instanceof Spine.Skeleton)) return

    const renderer = canvas.renderer
    renderer.resize(Spine.ResizeMode.Expand)
    canvas.clear(0.8, 0.8, 0.8, 1)
    renderer.begin()
    renderer.drawSkeleton(this.skeleton, true)
    renderer.end()
  }

  error(_canvas: Spine.SpineCanvas, errors: Spine.StringMap<string>): void {
    console.error('Error!')
    console.error(errors)
  }
}

const devicePixelRatio = window.devicePixelRatio || 1
const app = document.querySelector<HTMLDivElement>('#app')!
const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.width = '100%'
canvas.style.height = '100%'
app.appendChild(canvas)

const gui = new GUI()

new Spine.SpineCanvas(canvas, {
  pathPrefix: 'assets/spine-data/',
  app: new SpineApp()
})

ソースコードのうち、ポイントとなるのは※1〜3の箇所です。

updateWorldTransform() 関数の引数

Spine.Skeleton の updateWorldTransform() 関数には、Spine.Physics.update = 2 を引数として指定しています。(※1)
ランタイム上で物理演算を継続して適用したい場合、この引数の指定が必須になります。

「風(wind)」パラメータの更新

コントロールパネルから、物理コンストレイントの 「風(wind)」パラメータを更新できるようにしています。(※2)
コントロールパネルの「wind」の数値を変えると、ビックリ箱が"横風に吹かれているような"ポーズをとります。

下記が Spine エディタ上の風パラメータのUIです。
Spine エディタ上で設定もできますし、今回のようにランタイム上で上書きすることも可能です。

物理コンストレイント パラメータの風
物理コンストレイント パラメータの風

風パラメータ以外の「慣性」や「重力」などのパラメータも、ランタイム上で上書きできます。
こういった細かい設定値をプログラムから制御できるのはありがたいですね。

アニメーションを適用せずとも滑らかに動く

今回ですが、アニメーションをいっさい作成していません。
Spine データ作成時に初期設定されている「animation」を、そのまま state で指定しています。(※3)

それにも関わらず、x 座標を移動させたり、パラメータを更新するだけで、イラストを滑らかに動かせます!
これはすごい…!

まとめ

今回は Spine ver4.2 に追加された物理演算に触れてみました。
初めて触れたので、他にどんな機能があるのか・どんな表現が可能なのか、まだまだ把握できていません。
どんどんと使い倒していきたいなと思っています〜!

では、また!

Discussion