0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】 Animation Chip を利用せずに、キャラチップを表示する

0
Posted at

Animation Clip 不要!
キャラチップ専用コントローラーで2Dキャラアニメーションを爆速化します。

はじめに

Unity では、3D キャラクターを利用することが多いですが、2D キャラを表示してスプライトで動作を行いたいニーズが発生します。

スプライトを動かしたい場合、Unity標準機能による一般的な方法は以下のとおりです。

  1. 画像からスプライトを作成
    • 画像を選択してスプライト化
  2. Animation Chip の作成
    • 画像を選択して、アニメーション化
  3. Animator の作成
    • アニメーターを作成して、遷移条件を追加

これは、3D キャラでも利用されるもので、非常に汎用性が高いです。

しかし、キャラチップの要件に対し、Unity標準機能は手間が多く、非効率的です。
キャラチップの特性と、なぜ面倒となるのか、まとめます。

キャラチップの特性

キャラチップは、『特定のルール』のもと並べられた画像データです。
1枚目は右足を出す、2枚目は直立、3枚目は左足を出すなど、明確なルールに従い配置されています。
1206_0.png

再生する側は、特定の時間間隔で、 2 > 1 > 2 > 3 > ... と表示されることが求められます。

また、キャラの向きに関する情報も含まれており、現在の向きに従い、正面、側面の切り替えも必要です。

特に方向転換と移動アニメーションのブレンド(割り込み)は、Animator Controllerの遷移設定が複雑化し、意図しないアニメーションの途切れやバグの原因になりやすいです。

Unity 組み込みの機能で実現する場合、以下の課題が出てきます。

  • スプライト化で位置情報が失われる
    • Sprite Editor を利用して手動でスプライト化する場合、個々のスプライトに変換される過程で位置情報が消失し、バグが発生しやすい
  • 膨大な Animation Clip の作成が必要
    • 8方向 x 2状態 (移動/停止) x キャラ数分、個別の Animation Chip が必要
    • アニメーションフレームの割り当てが手作業になり、ミスが発生しやすい
  • Animator Controller が上手くいかない
    • 方向転換や状態遷移設定に起因するバグが起こりやすい
  • ファイル数が多い
    • 1キャラごとに作成するため、ファイル数が爆発的に増加
    • 個々のファイルは別ファイルのため、バグチェックも個々のファイルごとに必要となる

専用コントローラー化

結局のところ、上記は標準の機能では上手くいかないため、専用コントローラーをアタッチすることが最も効率的だと考えました。

求められる機能は以下のとおりです。

  1. テクスチャ画像の事前変換を排除
    • スプライト化すると、切り分けの順番が保存されないことから重要です
    • テクスチャから、キャラチップのルールに従ってスプライト化を実現します
  2. 移動モーションの自動化
    • Animation Clip / Animation Controller 相当を行います
    • 向きによるスプライトの切り替えも必要です
  3. ビルボード化
    • キャラチップのため、常に画面に対して正面を向くことが必要です
    • 3D 空間内に配置された 2D キャラが、どの角度から見ても自然な向きを保ちます

これらの機能を実現するために以下のスクリプトを作成しました。(ソースコード全文は記事下部)

このコントローラーをアタッチすることで、簡易にキャラチップアニメーションを実現可能となります。

このコントローラーは、以下の機能を実現しました。

  1. テクスチャ画像として画像を渡せる
    • スプライト化は、キャラチップルールに従いスクリプト内で実現
    • コントローラーに渡すのは無加工のテクスチャデータ
    • 手間を極限まで減らせます
  2. 移動、停止の計算
    • スプライト内で、向きと速度を検出して、スプライト画像を変更
    • 移動時は、移動モーション順に順次画像を切り替え
    • 事前の準備は不要です
  3. ビルボード化
    • 子 GameObject を追加して、ビルボード化
    • ビルボードにスプライトを表示
    • これにより、3D 空間内で 2D キャラが動くような表現を実現します

詳細の解説

Awake 関数

Awake は、ロード時に1度だけ読み込まれるメソッドで、スプライト化をここで行っています。
ここでは、キャラ数とテクスチャサイズに基づき、Sprite.Create メソッドを使ってテクスチャの矩形(Rect)を実行時に切り出し、Spriteアセットを動的に生成しています。
これにより、事前のスプライト化作業が不要になります。

Sprite を表示するための GameObject を作成し、コンポーネントの取得もここで行っています。

Start 関数

Start は、最初のレンダリング前に 1度だけ実行されます。

ここでは、デフォルトスプライト(正面直立) を暫定で置いています。

Update 関数

Update は、毎フレーム実行されます。
経過時間を取得し、フレーム計算、足踏みの状態を検索しています。

var frame = Mathf.FloorToInt(elapsedTime * frameRate) % 4; で 4段階のフレームを生成し、if (frame == 1) { index = 0; }if (frame == 3) { index = 2; } のロジックにより、フレームアニメーションを実現しています。

FixedUpdate 関数

FixedUpdate は、定期時間ごとに呼び出されます。
歩き状態を確認し、経過時間を初期化する処理を入れています。

LateUpdate 関数

LateUpdate は、フレームの計算後に呼び出されます。
ビルボードの方向修正を行っています。

GetDirection 関数

カメラの向きと、キャラの向きから、スプライトの表示方向を計算しています。

Vector3.SignedAngle を使用し、カメラとキャラクターの Z軸(transform.forward)がなす角度から、4D または 8D の方向を判定しています。
これにより、3D空間におけるカメラの相対角度から適切な 2D スプライトを選択します。

GetWalk 関数

キャラの移動速度を参照して、歩き状態かを計算しています。
速度は、NavMesh Agent から取得していますが、 Rigidbody や独自の移動処理を利用する場合は、GetWalk 関数を書き換えることで簡単に対応可能です。

まとめ

これらの機能により、キャラシート利用上の問題点を解決しました。
スクリプトは MIT License としますので、自由に改変、利用してください。

謝辞

記事中のスプライト画像は モンスターキャラチップ素材 のものです。
キャラチップのルールは RPGツクールMV 歩行グラフィックについて を参考にしました。

ソースコード全文

/// Copyright 2025 Shirousa
/// Licensed under the [MIT License](https://opensource.org/license/mit)

using UnityEngine;

public enum CharChipDirection
{
    Direction_4D,
    Direction_8D_Type1,
}



public class CharChipController : MonoBehaviour
{
    protected enum CharacterDirection
    {
        Front,
        FrontLeft,
        Left,
        BackLeft,
        Back,
        BackRight,
        Right,
        FrontRight,
    }

    [SerializeField] private Texture2D texture;
    [SerializeField] private Vector2Int format = new Vector2Int(4,2);
    [SerializeField] private CharChipDirection direction;
    [SerializeField] private int characterIndex;

    [SerializeField] private float pixelsPerUnit = 100f;
    [SerializeField] private float thresholdWalk = 0.5f;
    [SerializeField] private float frameRate = 10f;
    [SerializeField] private Vector2 pivot = new Vector2(0.5f, 0.5f);

    protected GameObject spriteObject;
    protected Sprite[,,] sprites = new Sprite[8, 4, 3]; // 8 char x 4 line x 3 chip

    protected UnityEngine.AI.NavMeshAgent navmesh;
    protected SpriteRenderer spriteRenderer;
    protected float startAt;
    protected bool isWalk;

    void Awake()
    {
        var limY = format.y;
        var limX = format.x;
        var height = texture.height / (4 * format.y);
        var width = texture.width / (3 * format.x);

        // create sprites
        for (var y = 0; y != limY; y++)
        {
            for (var x = 0; x != limX; x++)
            {
                for (var l = 0; l != 4; l++)
                {
                    for (var s = 0; s != 3; s++)
                    {
                        var rect = new Rect((x * 3 + s) * width, (y * 4 + l) * height, width, height);
                        var sprite = Sprite.Create(texture, rect, pivot, pixelsPerUnit);
                        sprites[y * 4 + x, l, s] = sprite;
                    }
                }
            }
        }

        // create gameobject
        spriteObject = new GameObject("Sprite", typeof(SpriteRenderer));
        spriteObject.transform.SetParent(transform, false);
        spriteRenderer = spriteObject.GetComponent(typeof(SpriteRenderer)) as SpriteRenderer;

        // others
        navmesh = GetComponent(typeof(UnityEngine.AI.NavMeshAgent)) as UnityEngine.AI.NavMeshAgent;
    }

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        spriteRenderer.sprite = sprites[characterIndex, 0, 1];
    }

    // Update is called once per frame
    void Update()
    {

        var direction = GetDirection();

        var index = 1;
        if (isWalk)
        {
            var elapsedTime = Time.time - startAt;
            var frame = Mathf.FloorToInt(elapsedTime * frameRate) % 4;

            if (frame == 1) { index = 0; }
            if (frame == 3) { index = 2; }
        }

        switch (direction)
        {
            case CharacterDirection.Front:
                spriteRenderer.sprite = sprites[characterIndex, 0, index];
                break;
            case CharacterDirection.Left:
                spriteRenderer.sprite = sprites[characterIndex, 1, index];
                break;
            case CharacterDirection.Right:
                spriteRenderer.sprite = sprites[characterIndex, 2, index];
                break;
            case CharacterDirection.Back:
                spriteRenderer.sprite = sprites[characterIndex, 3, index];
                break;
            case CharacterDirection.FrontLeft:
                spriteRenderer.sprite = sprites[characterIndex + 1, 0, index];
                break;
            case CharacterDirection.FrontRight:
                spriteRenderer.sprite = sprites[characterIndex + 1, 1, index];
                break;
            case CharacterDirection.BackLeft:
                spriteRenderer.sprite = sprites[characterIndex + 1, 2, index];
                break;
            case CharacterDirection.BackRight:
                spriteRenderer.sprite = sprites[characterIndex + 1, 3, index];
                break;
        }
    }

    void FixedUpdate()
    {
        // update walk and time
        var walk = GetWalk();
        if (isWalk != walk)
        {
            startAt = Time.time;
        }
        isWalk = walk;
    }



    void LateUpdate()
    {
        // billboard control
        spriteObject.transform.rotation = Camera.main.transform.rotation;
    }

    protected CharacterDirection GetDirection()
    {
        // camera vector
        var Vcam = Camera.main.transform.forward;
        Vcam.y = 0;

        // charactor vector
        var Vchr = transform.forward;
        Vchr.y = 0;

        var angle = Vector3.SignedAngle(Vcam, Vchr, Vector3.up);
        switch (direction)
        {
            case CharChipDirection.Direction_4D:
                if (angle <= -135f) { return CharacterDirection.Back; }
                else if (angle <= -45f) { return CharacterDirection.Right; }
                else if (angle <= 45f) { return CharacterDirection.Front; }
                else if (angle <= 135f) { return CharacterDirection.Left; }
                else { return CharacterDirection.Back; }
            default:
                if (angle <= -157.5f) { return CharacterDirection.Back; }
                else if (angle <= -112.5f) { return CharacterDirection.BackRight; }
                else if (angle <= -67.5f) { return CharacterDirection.Right; }
                else if (angle <= -22.5f) { return CharacterDirection.FrontRight; }
                else if (angle <= 22.5f) { return CharacterDirection.Front; }
                else if (angle <= 67.5f) { return CharacterDirection.FrontLeft; }
                else if (angle <= 112.5f) { return CharacterDirection.Left; }
                else if (angle <= 157.5f) { return CharacterDirection.BackLeft; }
                else { return CharacterDirection.Back; }
        }
    }

    protected bool GetWalk()
    {
        var speed = navmesh.velocity.magnitude;
        return speed >= thresholdWalk;
    }
}


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?