🧐

Spineの活用方法(メルモンでの事例)

2025/01/15に公開

「フィーリアのモンスターといっしょ!」(通称メルモン)の開発に関する内容です。
https://mercstoria.happyelements.co.jp/merumon/

メルモンではキャラクターをSpineで作っています。
ちょっと変わった対応も入れているので、参考までにいくつか紹介します。

目次

1. 色を変える方法
2. 低fpsで再生する方法
3. イベントの取得
4. モーションに追従するオブジェクトを作る方法
5. slotの間にオブジェクトを挟んで表示させる方法
6. 特定のslotを非表示にする方法
7. 当たり判定
8. 物理演算

環境

  • Unity 2022.3.26f1
  • spine-unity Runtime 4.2.67
  • Spine Universal RP Shaders 4.2.31

1. 色を変える方法

昼と夜の色違いについて、SpineのキャラクターたちはShaderによって対応しています。
ここでは、その方法を紹介します。

※ 背景などは描き下ろしていただいています。

色を乗算で乗せる

slot毎に色を変える方法やマテリアルを上書きするなど、様々な方法が用意されているようです。
手っ取り早く色を乗算で乗せてみるとこのようになります。

// ColorのSerializeFieldを用意して、変更がかかった時に反映する
void OnValidate()
{
    SkeletonAnimation.skeleton.SetColor(color);
}

ベタッとした色の乗り方になってしまいますね。

カスタムシェーダーを適用する

メルモンでは、より細かい調整をしたかったため、カスタムシェーダーを用意しました。
多少のShaderの知識は必要ですが、コピーしてきて参照先を変更してまわっているだけなので難しい話ではありません。

対応方法

参考:
https://ja.esotericsoftware.com/forum/d/17216-slot-setcolor-in-different-blend-mode

  1. Universal Render Pipeline/Spine/Skeleton をコピーしてカスタムシェーダー(custom.shader)を作成します。

  2. Spine-Skeleton-ForwardPass-URP.hlsl もコピーしてきます(custom.hlsl)。

  3. custom.shader 内のincluldeファイルのパスをPackagesからのパスへ変更します。
    ex) Packages/com.esotericsoftware.spine.urp-shaders/Shaders/Include/Spine-Input-URP.hlsl

  4. custom.shader 内に Spine-Skeleton-ForwardPass-URP.hlsl もincludeされているので、コピーしてきたcustom.hlslへ変更します。

  5. custom.hlslのフラグメントシェーダーをfrag_customなどに変更し、custom.shaderのpass1から呼び出している#pragma fragment frag#pragma fragment frag_custom へ変更します。

  6. ShaderShared.cgincの必要な機能をcustom.hlslへ移植します。
    移植した処理をfrag_customから呼び出します。
    ※ 今回はadjustColor_OverlayColor関連の処理を移植しました。

    half4 frag_custom(VertexOutput i) : SV_Target{
        float4 texColor = tex2D(_MainTex, i.uv0);
        texColor = adjustColor(texColor);
        texColor.rgb = lerp(texColor.rgb, _OverlayColor.rgb, _OverlayColor.a * texColor.a);
    
    #if defined(_TINT_BLACK_ON)
        return fragTintedColor(texColor, i.darkColor, i.color, _Color.a, _Black.a);
    #else
        #if defined(_STRAIGHT_ALPHA_INPUT)
        texColor.rgb *= texColor.a;
        #endif
        return (texColor * i.color);
    #endif
    }
    
  7. プロパティをcustom.shaderに定義します。変数の定義もincludeファイルで設定されており触れないので、直接定義します。

    _OverlayColor("Overlay Color", Color) = (0,0,0,0)
    _Hue("Hue", Range(-0.5,0.5)) = 0.0
    _Saturation("Saturation", Range(0,2)) = 1.0
    _Brightness("Brightness", Range(0,2)) = 1.0
    
  8. これでInspectorから変更できるようになります。

デフォルトで使うShaderを変更する

常にカスタムシェーダーを使いたい時は、以下の設定を変更しておくとImport時の手間が減ります。
Preferences > Spine > Auto-Import Settings > Default Shader

2. 低fpsで再生する方法

メルモンは60fpsのゲームですが、世界観の雰囲気に合わせ、Spineを使ったキャラクターは全て15fpsで動かしています。
Spineは補間が働くため、Spine上で15fpsで作られたモーションでもUnity上ではヌルっと動いてしまいます。
そのため、補間を切りつつ更新タイミングをプログラムで調整する必要がありました。

参考:
https://ja.esotericsoftware.com/forum/d/17687-is-it-possible-to-change-the-framerate-of-animations
https://ja.esotericsoftware.com/forum/d/11025-changing-refresh-rate-at-runtime-to-mimic-no-interpolation

更新タイミングを調整する

Spineには更新タイミングを調整できる設定があります。
デフォルトでは InUpdate が指定されています。

public enum UpdateTiming {
    ManualUpdate = 0,
    InUpdate,
    InFixedUpdate,
    InLateUpdate
}

今回は、fpsを自由に調整したかったため、ManualUpdateを指定しました。
ManualUpdate にした上で、15fpsで更新されるよう対応しています。

対応方法

private float fps = 1 / 15f; // 15fpsで動かす
private int intervalFrame => (int)(60f / 15f);
private int frameCount = 1;
private float time = 0;

void Awake()
{
    skeletonAnimation.UpdateTiming = UpdateTiming.ManualUpdate;
}

private void Update()
{
    time += Time.deltaTime;

    if (frameCount % intervalFrame == 0)
    {
        // フレーム間の時間の誤差を考慮しつつSpineのUpdateを行う
        var step = time - (time % fps);
        time -= step;
        skeletonAnimation.Update(step);
        frameCount = 1;
    }
    else
    {
        frameCount++;
    }
}

モーション間の補間を切る方法

Spineの補間は、このような「食べているモーション」(地面スレスレ)から「待機モーション」(飛んでいる)など、
位置が変わる時に綺麗に繋げてくれるため、一部で使用しています。

ただ、15fpsで作られていてモーションとモーションの差が大きいこともあってか、
補間により意図しない動きになることが多くありました。

そのため、基本は補間を切っておき、必要に応じて有効にするという方法をとっています。

void Awake()
{
    // Blendの時間指定
    AnimationState.Data.DefaultMix = 0f;
}

public void SetAnimations(Data.Animation[] animations, bool blend)
{
    var defaultMix = AnimationState.Data.DefaultMix;

    if (blend is false)
    {
        //前のアニメを消去(残っていると再生終了後におかしくなる事がある)
        AnimationState.ClearTrack(trackIndex);
    }
    else
    {
        // Blendを有効にする
        AnimationState.Data.DefaultMix = 0.5f;
    }

    // モーションの再生を行う
    // (略)

    // Blendの設定を戻す
    AnimationState.Data.DefaultMix = defaultMix;
}

デフォルトのBlend時間の指定方法

Preferences > Spine > Auto-Import Settings > Default Mix

フレーム間の補間(?)を切る方法

Spineのフレームの概念は以下のように記載されており、フレームとフレームの間が存在することがわかります。

https://ja.esotericsoftware.com/spine-keys#フレーム

通常のSetupPoseから始まるようなモーションであれば問題ないと思いますが、
パーツの差し替えなどの対応をしていることもあり、
(おそらく)フレームとフレームの間を踏んだ時に意図しない挙動になることがありました。

必要に応じてフレームスナップで、フレーム間の補間を切るような指定を入れていだきました。

3. イベントの取得

Spineからのイベント起因で処理を行う方法です。
Timelineでは大まかなタイミングしか調整できないため、タイミングを完全に合わせたいものはイベントを使用しています。

サウンドやごはんをあげる時のTimeline、育成画面でのカエル起因でモンスターの挙動やモンスター起因でフィーリアの挙動など、様々なところで使用しています。

Spine上の指定方法

SkeletonData上で確認(紫の吹き出し)もできます。

プログラム上で受け取る方法

public void Initialize()
{
    skeletonAnimation.AnimationState.Event += OnEvent;
}

protected virtual void OnEvent(TrackEntry trackEntry, Event e)
{
    // イベント名は e.Data に入るが、値は e.xxx に入る
    Debug.Log($"Event {e.Data.Name},str:{e.String},int:{e.Int.ToString()}");
}

4. モーションに追従するオブジェクトを作る方法

BoneFollower

例えば、「ごはんを手に持つ」ために、手のボーンにごはんのGameObjectを追従させるBoneFollowerを使っています。

このGameObject配下にごはんの画像を配置することで、手のボーンに追従してくれるようになります。

BoneFollowerはフィーリアが山盛りのごはんを持って現れる時にも使っており、
ごはんを地面に置く時に追従先を変えたり切ったり細かい制御を入れたりしています。

追従先を変えたい時

gohanBoneFollower.SetBone("gohan_carry");
// gohanBoneFollower.boneName = "xxx" ではInspector上はそれっぽく切り替わるけれど、正しく動かないので注意。

追従を切りたい時

gohanBoneFollower.enabled = false;

PointFollower

位置を取得したいけれどボーンに追従する必要はない場合は、PointFollowerを使っています。
モンスターの大きさやモーションによって顔の位置が変わるため、顔から出るエモーションの出現位置などです。

PointFollowerはボーンの追従ではなくスロットの追従となります。
このようにすることで、マスターデータなどで指定するのではなく、アニメーターさんが指定してくれた位置にGameObjectを出現させることが可能です。

ただ、エモーションは、モンスターの動きに合わせて上下すると忙しないので、
BoneFollowerと同様にDisableにする方法で、エモーションを表示した後に追従を切っています。

5. Slotの間にオブジェクトを挟んで表示させる方法

ごはん等のオブジェクトを挟んで動かしたい時、SkeletonRenderSeparatorを使って実装しています。

SkeletonRenderSeparator

SkeletonRenderSeparatorを使って作られたGameObjectは

  • 指定したSlotより奥に描画するGameObject
  • 指定したSlotと手前に描画するGameObject

に分割されます。

※ 手前に描画するものをそれぞれ指定するのではなく、分割する境目となるSlotを指定します。

作られたGameObjectにSkeletonPartsRendererのComponentが付いており、
SortingLayerとOrderInLayerを指定して描画優先度を調整することが可能となります。


これらは、事前に作っておくこともできますが、プログラムから動的に分割することも可能です。

分割を切る方法

画面によっては分割が不要もしくは困ることがあります。
SkeletonRenderSeparatorをdisableにすることでいつでも分割を切ることができます。

動的なOrderInLayerの変更方法

動的にOrderInLayerを変更したい時、分割前に変更してから動的に分割する方法で実装できます。
ただ、さんぽ画面などのキャラクターが複雑に重なっている画面で動的に分割するのは難しかったため、
分割は事前にやっておき、OrderInLayerだけを動的に変更する方法をとっています。

public void Initialize()
{
    foreach (var skeletonPartsRenderer in skeletonRenderSeparator.partsRenderers)
    {
        _separatorSortingOrderDictionary.Add(skeletonPartsRenderer, skeletonPartsRenderer.MeshRenderer.sortingOrder);
    }
}

void SetOrderInLayer(int order)
{
    // Separate前の元のMeshRenderer
    MeshRenderer.sortingOrder = order;

    // SeparateされたMeshRenderer
    foreach (var (skeletonPartsRenderer, defaultSortingOrder) in _separatorSortingOrderDictionary)
    {
        skeletonPartsRenderer.MeshRenderer.sortingOrder = defaultSortingOrder + MeshRenderer.sortingOrder;
    }
}

ちょっと無理矢理感がありますが、きちんと動きます。

6. 特定のslotを非表示にする方法

ゲーム内のトランジションで、モンスターのSpineデータを使い回したい時、影を非表示にする必要がありました。

以下の方法で、slot毎に非表示にすることができます。

public async UniTask Instantiate(IMonsterData monsterData, Monster.Size monsterSize)
{
    SpineComponent.SkeletonAnimation.UpdateLocal += AfterUpdateLocal;
}

private void AfterUpdateLocal(ISkeletonAnimation anim)
{
    anim.Skeleton.FindSlot("shadow").Attachment = null;
}

public void Dispose()
{
    SpineComponent.SkeletonAnimation.UpdateLocal -= AfterUpdateLocal;
}

参考:https://ja.esotericsoftware.com/forum/d/15137-スロット表示非表示の制御について/5

7. 当たり判定

メルモンのモンスターのタップ判定はかなり厳密に設定されていて、動きに合わせて判定位置が調整されています。
タップ判定には、SpineのBoundingBoxを使用しています。

BoundingBox

アニメーターさんにBoundingBoxのSlotを追加してもらい、
BoundingBoxFollowerのComponentを使うことで当たり判定を設定することができます。

Boneに追従する必要があり場合は、AddBoneFollowerのボタンを押下することで、BoneFollowerが追加されます。
これらの設定により、大きさや位置がスロットやボーンに合わせて動くようになります。

8. 物理演算

Spineのバージョン4.2以降では組み込みの物理演算が使用でき、
Spine側でつければUnity側では特に何もすることなく動いてくれます。

一部のモンスターで使用しており、Unityの物理演算を使わずともTransformを変更することで反応して動きます。

※ 腕の黄色の部分などで使用しています


ただ、モンスターの読み込みから配置する時など、大きく動かす時は物理演算が暴走しがちなので切っておくようにしています。

物理演算を切る方法

PhysicsPositionInheritanceFactorが物理演算のPositionに対する係数のようです。
PhysicsRotationInheritanceFactorなら回転方向です。

// 元の値を保存しておく
var factor = skeletonAnimation.PhysicsPositionInheritanceFactor;

// 物理演算を切る
skeletonAnimation.PhysicsPositionInheritanceFactor = Vector2.zero;

// 大きく動かす
//(略)

// もとに戻す
skeletonAnimation.PhysicsPositionInheritanceFactor = factor;

あと、必要に応じてこれらも使用する機会があるかもしれません。

SkeletonAnimation.PhysicsMovementRelativeTo : 物理演算の基点となるTransform
ResetLastPositionAndRotationResetLastRotationResetLastPositionAndRotation:移動距離をリセットする


以上です。

メルモンの開発ではエンジニアがほぼソロかつSpine初使用ということもあり、どうなることやら…と思いながらの開発でしたが、Spineの痒い所に手が届く感じや、公式のフォーラムを見ればだいたい先駆者がいてくれるので、非常に助かりました。

とはいえ、検証が足りないことがあるかもしれません。
他に良い方法があればコメントをいただければ幸いです。

Happy Elements

Discussion