Unityを触り始めた頃って、ついなんでもかんでも Update() に書いてしまいがちですよね。
プレイヤーの移動、入力処理、アニメーション、エフェクト…全部1つのスクリプトに詰め込むと、ちょっとした仕様変更でコード全体が壊れたり、コピペで増殖したりして、あっという間に「Godクラス」化してしまいます。

とくに「残像エフェクト」のような視覚効果は、
「プレイヤーの移動スクリプトの中に、それっぽく処理を足していく」
という実装になりがちです。

そこでこの記事では、「残像エフェクトだけ」を責務に持つ小さなコンポーネントとして、
AfterImage コンポーネントを作っていきます。
プレイヤーでも敵でも、スプライトを持っているオブジェクトならポン付けできるようにしておくことで、演出とロジックをきれいに分離できます。

【Unity】高速移動をドラマチックに!「AfterImage」コンポーネント

今回作る AfterImage コンポーネントは、ざっくりいうとこんなことをします。

  • 一定間隔で「今のスプライトのコピー」をその場に生成する
  • コピーされたスプライトは、時間経過でフェードアウトして自動的に消える
  • 移動速度が一定以上のときだけ残像を出す、といった条件も設定できる

これを1つのコンポーネントに閉じ込めておけば、
「残像を出したいオブジェクトに、このスクリプトを1つ追加するだけ」で使い回せます。


フルコード:AfterImage コンポーネント


using UnityEngine;

/// <summary>
/// 一定間隔でスプライトの残像を生成し、フェードアウトさせるコンポーネント。
/// スプライトを持つ任意のオブジェクトにアタッチして使う。
/// </summary>
[RequireComponent(typeof(SpriteRenderer))]
public class AfterImage : MonoBehaviour
{
    // ==== 参照系 ====
    [SerializeField]
    private SpriteRenderer targetSpriteRenderer;
    // 残像を生成する元となるスプライト。未指定なら自分の SpriteRenderer を使用。

    [SerializeField]
    private Transform targetTransform;
    // 残像を配置する座標の参照。未指定なら自分の Transform を使用。

    // ==== 残像の見た目設定 ====
    [SerializeField]
    private Color afterImageColor = new Color(1f, 1f, 1f, 0.7f);
    // 残像の初期カラー(アルファ含む)

    [SerializeField]
    private float afterImageLifetime = 0.4f;
    // 残像が完全に消えるまでの時間(秒)

    [SerializeField]
    private Material afterImageMaterial;
    // 残像用のマテリアル(未指定なら元の SpriteRenderer のマテリアルを使用)

    // ==== 生成頻度 ====
    [SerializeField]
    private float spawnInterval = 0.06f;
    // 残像を生成する間隔(秒)

    // ==== 速度によるON/OFF ====
    [SerializeField]
    private bool useVelocityThreshold = true;
    // 速度が一定以上のときだけ残像を出すかどうか

    [SerializeField]
    private float velocityThreshold = 5f;
    // useVelocityThreshold が true のとき、この速度以上で残像を出す

    [SerializeField]
    private Rigidbody2D targetRigidbody2D;
    // 2D移動の速度判定用。未指定なら同じ GameObject の Rigidbody2D を探す。

    [SerializeField]
    private Rigidbody targetRigidbody3D;
    // 3D移動の速度判定用。未指定なら同じ GameObject の Rigidbody を探す。

    // ==== 内部状態 ====
    private float spawnTimer;

    private void Reset()
    {
        // コンポーネントを追加したときに、よく使う参照を自動取得しておく
        targetSpriteRenderer = GetComponent<SpriteRenderer>();
        targetTransform = transform;

        targetRigidbody2D = GetComponent<Rigidbody2D>();
        targetRigidbody3D = GetComponent<Rigidbody>();
    }

    private void Awake()
    {
        // Inspector で未設定だった場合にフォールバック
        if (targetSpriteRenderer == null)
        {
            targetSpriteRenderer = GetComponent<SpriteRenderer>();
        }

        if (targetTransform == null)
        {
            targetTransform = transform;
        }

        if (targetRigidbody2D == null)
        {
            targetRigidbody2D = GetComponent<Rigidbody2D>();
        }

        if (targetRigidbody3D == null)
        {
            targetRigidbody3D = GetComponent<Rigidbody>();
        }
    }

    private void Update()
    {
        // 残像を出す条件を満たしていないなら何もしない
        if (!CanSpawnAfterImage())
        {
            return;
        }

        spawnTimer += Time.deltaTime;

        if (spawnTimer >= spawnInterval)
        {
            spawnTimer = 0f;
            SpawnAfterImage();
        }
    }

    /// <summary>
    /// 残像を生成してよい状態かどうかを判定する。
    /// ここに「ダッシュ中のみ」などの条件を追加してもよい。
    /// </summary>
    private bool CanSpawnAfterImage()
    {
        if (targetSpriteRenderer == null)
        {
            return false;
        }

        if (!targetSpriteRenderer.enabled)
        {
            // 非表示のときは残像を出さない
            return false;
        }

        if (!useVelocityThreshold)
        {
            // 速度条件を使わない場合は常にOK
            return true;
        }

        // 2D または 3D の Rigidbody から速度を取得
        float speed = 0f;

        if (targetRigidbody2D != null)
        {
            speed = targetRigidbody2D.velocity.magnitude;
        }
        else if (targetRigidbody3D != null)
        {
            speed = targetRigidbody3D.velocity.magnitude;
        }
        else
        {
            // Rigidbody がない場合は速度判定ができないので、常にfalse
            return false;
        }

        return speed >= velocityThreshold;
    }

    /// <summary>
    /// 現在のスプライトの状態をコピーして残像オブジェクトを生成する。
    /// </summary>
    private void SpawnAfterImage()
    {
        // 元スプライトの情報を取得
        Sprite sprite = targetSpriteRenderer.sprite;
        if (sprite == null)
        {
            return;
        }

        // 新しい GameObject を生成
        GameObject afterImageObj = new GameObject("AfterImage_Sprite");

        // 位置・回転・スケールをコピー
        afterImageObj.transform.position = targetTransform.position;
        afterImageObj.transform.rotation = targetTransform.rotation;
        afterImageObj.transform.localScale = targetTransform.lossyScale;

        // SpriteRenderer を追加して、各種プロパティをコピー
        SpriteRenderer sr = afterImageObj.AddComponent<SpriteRenderer>();
        sr.sprite = sprite;
        sr.flipX = targetSpriteRenderer.flipX;
        sr.flipY = targetSpriteRenderer.flipY;
        sr.sortingLayerID = targetSpriteRenderer.sortingLayerID;
        sr.sortingOrder = targetSpriteRenderer.sortingOrder - 1; // 本体より奥に描画する例

        // マテリアル設定(指定があれば優先)
        if (afterImageMaterial != null)
        {
            sr.material = afterImageMaterial;
        }
        else
        {
            sr.material = targetSpriteRenderer.sharedMaterial;
        }

        // カラー設定(アルファ含む)
        sr.color = afterImageColor;

        // フェードアウト用コンポーネントを追加して制御を委譲
        AfterImageFade fade = afterImageObj.AddComponent<AfterImageFade>();
        fade.Initialize(afterImageLifetime, afterImageColor);
    }
}

/// <summary>
/// 残像スプライトを時間経過でフェードアウトさせ、最後に自動破棄するコンポーネント。
/// </summary>
public class AfterImageFade : MonoBehaviour
{
    private SpriteRenderer spriteRenderer;
    private float lifeTime;
    private float elapsed;
    private Color startColor;

    /// <summary>
    /// 生成直後に呼び出してパラメータを設定する初期化用メソッド。
    /// </summary>
    public void Initialize(float lifeTime, Color color)
    {
        this.lifeTime = Mathf.Max(0.01f, lifeTime); // 0除算を避ける
        this.startColor = color;
    }

    private void Awake()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();
        if (spriteRenderer == null)
        {
            // 想定外だが、SpriteRenderer がない場合は即破棄
            Destroy(gameObject);
        }
    }

    private void Update()
    {
        if (spriteRenderer == null)
        {
            return;
        }

        elapsed += Time.deltaTime;
        float t = Mathf.Clamp01(elapsed / lifeTime);

        // アルファだけ線形に減衰させる
        Color c = startColor;
        c.a = Mathf.Lerp(startColor.a, 0f, t);
        spriteRenderer.color = c;

        // 寿命を過ぎたら自動破棄
        if (elapsed >= lifeTime)
        {
            Destroy(gameObject);
        }
    }
}

使い方の手順

ここでは「2Dアクションゲームのプレイヤー」に残像を付ける例で説明します。敵キャラやダッシュ床などにも同じ手順で使えます。

  1. プレイヤーに SpriteRenderer と Rigidbody2D を用意する
    すでにある場合はそのままでOKです。
    • Player オブジェクトに SpriteRenderer が付いていること
    • 物理移動をしているなら Rigidbody2D も付いていること
  2. AfterImage コンポーネントを追加する
    上記コードを AfterImage.cs として保存し、Unity に戻るとスクリプトがコンポーネントとして使えるようになります。
    • Player オブジェクトを選択
    • Add ComponentAfterImage を追加

    Reset() により、SpriteRendererRigidbody2D は自動でセットされるはずです。

  3. Inspector でパラメータを調整する
    • After Image Color:残像の色と透明度(例:薄い水色や白)
    • After Image Lifetime:残像が消えるまでの時間(0.2〜0.5秒くらいがおすすめ)
    • Spawn Interval:どれくらいの間隔で残像を出すか(数値を小さくすると密度が増える)
    • Use Velocity Threshold:速度条件を使うかどうか
    • Velocity Threshold:この速度以上で残像が出る(ダッシュ速度に合わせると自然)

    たとえば「通常移動は残像なし、ダッシュ中だけ残像を出したい」場合は、
    ダッシュ時の速度より少し小さい値を Velocity Threshold に設定しておくとよいですね。

  4. ゲームを再生して動きを確認する
    プレイヤーを動かして、
    – 高速移動中だけ残像が出ているか
    – 残像の色やフェード速度が自然かどうか
    を確認しながらパラメータを微調整していきましょう。

同じ手順で、敵キャラの突進攻撃時の演出や、動く床・ワープギミックなどにも簡単に使い回せます。
「残像を付けたいオブジェクト」=「AfterImage コンポーネントをアタッチ」というシンプルな運用にしておくと、レベルデザイン時にかなり楽になります。


メリットと応用

AfterImage をコンポーネントとして分離しておくと、次のようなメリットがあります。

  • プレイヤーの移動ロジックとエフェクトが完全に分離される
    → 移動スクリプトをいじっても残像エフェクトが壊れないし、その逆も同様です。
  • プレハブの再利用性が高い
    → プレイヤー、敵、ギミックなど、スプライトを持っていれば同じコンポーネントをそのまま使い回せます。
  • レベルデザインが楽になる
    → 「この敵は残像付きで強そうに見せたい」「このギミックは残像いらない」といった調整を、シーン上でコンポーネントのON/OFFやパラメータ変更だけで完結できます。
  • エフェクトのチューニングがしやすい
    → 色、寿命、間隔などを Inspector からいじれるので、アーティストやレベルデザイナーも触りやすい構造になります。

さらに、少し改造するだけで応用の幅も広がります。
例えば「ダッシュボタンを押している間だけ残像を出す」といった明示的な制御をしたい場合は、
AfterImage にフラグを追加し、外部から ON/OFF するメソッドを用意しておくと便利です。


// AfterImage 内に追加する例:外部から残像の有効/無効を切り替える
public void SetActive(bool isActive)
{
    // 単にコンポーネントの有効/無効を切り替えるだけでもOK
    // これで Update() 自体が止まるので負荷も下がる
    enabled = isActive;

    if (!isActive)
    {
        // タイマーをリセットしておくと、再有効化時に即発生を防げる
        spawnTimer = 0f;
    }
}

プレイヤーの入力処理側から afterImage.SetActive(true/false) を呼び出せば、
「ダッシュ中だけ」「スキル発動中だけ」といった制御も簡単に追加できます。
このように、小さな責務のコンポーネントに切り出しておくと、後からの拡張がとてもやりやすくなりますね。