Unityを触り始めた頃、「とりあえず全部 Update に書いてしまう」やり方をしがちですよね。移動処理、エフェクト、入力、アニメーション制御…すべてを1つのスクリプトに押し込んでしまうと、あとから仕様変更が入ったときにどこを触ればいいのか分からない巨大スクリプトが出来上がってしまいます。

たとえば「キャラクターが移動しているときだけ残像エフェクトを出したい」という要件を、プレイヤーの移動スクリプトの Update に直接書き足していくと、あっという間に Godクラス化してしまいます。

そこで今回は、「移動中のオブジェクトに残像を付ける」だけに責務を絞った小さなコンポーネントとして、GhostTrail を用意してみましょう。プレイヤーでも敵でも動く床でも、「残像を付けたいオブジェクト」にポン付けできるコンポーネントにしておくと、演出の調整や再利用がとても楽になります。

【Unity】移動するオブジェクトに簡単残像エフェクト!「GhostTrail」コンポーネント

このコンポーネントは、親オブジェクトが動いている間だけ、一定間隔でスプライトのコピー(残像)を生成し、それを時間経過でフェードアウトさせて自動的に破棄します。

  • 親の位置・向き・スケールをコピー
  • 親の SpriteRenderer の見た目(Sprite / Color / Flip)をコピー
  • 残像は個別のオブジェクトとしてフェードアウトして消える
  • 「どのくらい動いたら残像を出すか」や「フェード時間」などをインスペクタで調整可能

フルコード:GhostTrail.cs


using UnityEngine;

/// <summary>
/// 親オブジェクトが移動している間、スプライトの残像を生成してフェードアウトさせるコンポーネント。
/// 移動ロジックとは分離し、「残像エフェクト専用」として責務を分けるのがポイント。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(SpriteRenderer))]
public class GhostTrail : MonoBehaviour
{
    [Header("残像生成条件")]
    [SerializeField]
    private bool _onlyWhenMoving = true; // 移動している時だけ残像を出すか

    [SerializeField, Tooltip("この距離以上動いたら新しい残像を生成します。")]
    private float _minDistanceToSpawn = 0.1f;

    [SerializeField, Tooltip("この時間以上経過したら距離に関係なく残像を生成します。0以下で時間条件無効。")]
    private float _maxTimeInterval = 0.05f;

    [Header("残像の見た目")]
    [SerializeField, Tooltip("残像の初期カラー。アルファ値はここで調整します。")]
    private Color _ghostColor = new Color(1f, 1f, 1f, 0.6f);

    [SerializeField, Tooltip("残像の描画レイヤー補正(0なら親と同じ)")]
    private int _sortingOrderOffset = -1;

    [Header("フェード設定")]
    [SerializeField, Tooltip("残像が完全に消えるまでの秒数。")]
    private float _fadeDuration = 0.4f;

    [SerializeField, Tooltip("フェードアウト中にスケールを少し小さくするかどうか。")]
    private bool _shrinkWhileFading = true;

    [SerializeField, Tooltip("フェード中のスケール倍率(1が等倍)。")]
    private float _shrinkScaleMultiplier = 0.8f;

    [Header("パフォーマンス・管理")]
    [SerializeField, Tooltip("生成する残像の最大数。超えた場合は古いものから破棄します。0以下で無制限。")]
    private int _maxGhostCount = 50;

    [SerializeField, Tooltip("親オブジェクトが非アクティブのときに残像生成を止めるか。")]
    private bool _stopWhenDisabled = true;

    // 内部状態
    private SpriteRenderer _sourceRenderer; // 親のスプライト
    private Vector3 _lastSpawnPosition;
    private float _timeSinceLastSpawn;

    // 残像の管理用
    private readonly System.Collections.Generic.List<GameObject> _spawnedGhosts
        = new System.Collections.Generic.List<GameObject>();

    private void Awake()
    {
        _sourceRenderer = GetComponent<SpriteRenderer>();
        _lastSpawnPosition = transform.position;
        _timeSinceLastSpawn = 0f;
    }

    private void OnEnable()
    {
        // 再有効化されたときに基準位置をリセット
        _lastSpawnPosition = transform.position;
        _timeSinceLastSpawn = 0f;
    }

    private void Update()
    {
        if (_stopWhenDisabled && !gameObject.activeInHierarchy)
        {
            return;
        }

        // 移動量を計算
        Vector3 currentPosition = transform.position;
        float movedDistance = Vector3.Distance(currentPosition, _lastSpawnPosition);
        _timeSinceLastSpawn += Time.deltaTime;

        bool isMoving = movedDistance > Mathf.Epsilon;

        // 「動いているときだけ」オプションが有効なら、静止中は何もしない
        if (_onlyWhenMoving && !isMoving)
        {
            return;
        }

        bool distanceCondition = movedDistance >= _minDistanceToSpawn;
        bool timeCondition = _maxTimeInterval > 0f && _timeSinceLastSpawn >= _maxTimeInterval;

        // どちらかの条件を満たしたら残像生成
        if (distanceCondition || timeCondition)
        {
            SpawnGhost();
            _lastSpawnPosition = currentPosition;
            _timeSinceLastSpawn = 0f;
        }
    }

    /// <summary>
    /// 残像を1つ生成する。
    /// </summary>
    private void SpawnGhost()
    {
        if (_sourceRenderer == null || _sourceRenderer.sprite == null)
        {
            // スプライトがない場合は何もしない
            return;
        }

        // 残像用の GameObject を生成
        GameObject ghost = new GameObject("GhostTrail_Ghost");

        // 親階層を分かりやすくするため、ルートにそのまま置く
        ghost.transform.position = transform.position;
        ghost.transform.rotation = transform.rotation;
        ghost.transform.localScale = transform.lossyScale; // ワールドスケールをコピー

        // SpriteRenderer を追加して、元のスプライト情報をコピー
        SpriteRenderer ghostRenderer = ghost.AddComponent<SpriteRenderer>();
        CopySpriteSettings(_sourceRenderer, ghostRenderer);

        // 初期カラーを設定(元のカラーに対して上書き)
        Color initialColor = _ghostColor;
        // 元の色のRGBを乗算して、色味を少しだけ引き継ぐ
        initialColor.r *= _sourceRenderer.color.r;
        initialColor.g *= _sourceRenderer.color.g;
        initialColor.b *= _sourceRenderer.color.b;
        ghostRenderer.color = initialColor;

        // フェードアウト用のコンポーネントを追加
        GhostTrailFade fade = ghost.AddComponent<GhostTrailFade>();
        fade.Initialize(_fadeDuration, initialColor, _shrinkWhileFading, _shrinkScaleMultiplier);

        // 残像リストに追加して、上限数を超えたら古いものから破棄
        _spawnedGhosts.Add(ghost);
        TrimGhostsIfNeeded();
    }

    /// <summary>
    /// SpriteRenderer の設定をコピーするヘルパー関数。
    /// </summary>
    private void CopySpriteSettings(SpriteRenderer source, SpriteRenderer target)
    {
        target.sprite = source.sprite;
        target.flipX = source.flipX;
        target.flipY = source.flipY;
        target.sortingLayerID = source.sortingLayerID;
        target.sortingOrder = source.sortingOrder + _sortingOrderOffset;
        target.drawMode = source.drawMode;
        target.maskInteraction = source.maskInteraction;
        target.material = source.sharedMaterial;
    }

    /// <summary>
    /// 残像数が上限を超えた場合に、古いものから破棄する。
    /// </summary>
    private void TrimGhostsIfNeeded()
    {
        if (_maxGhostCount <= 0)
        {
            return; // 無制限
        }

        // 安全のため while でチェック
        while (_spawnedGhosts.Count > _maxGhostCount)
        {
            GameObject oldest = _spawnedGhosts[0];
            _spawnedGhosts.RemoveAt(0);

            if (oldest != null)
            {
                Destroy(oldest);
            }
        }
    }
}

/// <summary>
/// 残像オブジェクトを時間経過でフェードアウトさせ、最後に自分自身を破棄するコンポーネント。
/// GhostTrail からだけ使われる小さな責務のクラス。
/// </summary>
public class GhostTrailFade : MonoBehaviour
{
    private float _duration;
    private Color _initialColor;
    private bool _shrink;
    private float _shrinkMultiplier;

    private float _elapsed;
    private SpriteRenderer _renderer;
    private Vector3 _initialScale;

    /// <summary>
    /// GhostTrail から初期化される。
    /// </summary>
    public void Initialize(float duration, Color initialColor, bool shrink, float shrinkMultiplier)
    {
        _duration = Mathf.Max(0.01f, duration); // 0割り防止
        _initialColor = initialColor;
        _shrink = shrink;
        _shrinkMultiplier = shrinkMultiplier;

        _renderer = GetComponent<SpriteRenderer>();
        _initialScale = transform.localScale;
    }

    private void Update()
    {
        if (_renderer == null)
        {
            Destroy(gameObject);
            return;
        }

        _elapsed += Time.deltaTime;
        float t = Mathf.Clamp01(_elapsed / _duration);

        // アルファを線形に 0 まで落とす
        Color c = _initialColor;
        c.a = Mathf.Lerp(_initialColor.a, 0f, t);
        _renderer.color = c;

        // スケールを縮小させるオプション
        if (_shrink)
        {
            float scaleFactor = Mathf.Lerp(1f, _shrinkMultiplier, t);
            transform.localScale = _initialScale * scaleFactor;
        }

        // フェード完了で自動破棄
        if (_elapsed >= _duration)
        {
            Destroy(gameObject);
        }
    }
}

使い方の手順

  1. スクリプトを用意する
    上記の GhostTrail.cs をプロジェクト内の任意のフォルダ(例: Scripts/Effects/)に保存します。クラス名とファイル名は必ず一致させてください。
  2. 残像を付けたいオブジェクトにアタッチ
    プレイヤーキャラや敵、動く床など、SpriteRenderer を持っている GameObject を選択し、GhostTrail コンポーネントを追加します。
    [RequireComponent(typeof(SpriteRenderer))] を付けているので、SpriteRenderer がなければ自動で追加されます。
  3. パラメータを調整する
    インスペクタで以下を調整します:
    • Only When Moving … 移動している間だけ残像を出すか
    • Min Distance To Spawn … この距離以上動くたびに残像を生成
    • Max Time Interval … 一定時間ごとに強制的に残像を出したい場合に設定(0で無効)
    • Ghost Color … 残像の色と透明度(アルファ)
    • Sorting Order Offset … 元スプライトより前/後ろに描画するオフセット
    • Fade Duration … 残像が消えるまでの時間
    • Shrink While Fading / Shrink Scale Multiplier … フェード中に少し縮ませるかどうか
    • Max Ghost Count … 同時に存在できる残像の上限数
  4. 具体的な使用例
    いくつかの典型的なケースを挙げます。
    • プレイヤーのダッシュ演出
      プレイヤーの移動スクリプトは「移動」だけに責務を絞り、GhostTrail は別コンポーネントとしてプレイヤーにアタッチします。
      ダッシュ中はプレイヤーが素早く動くので、Min Distance To Spawn を小さめ(例: 0.05)に、Fade Duration を短め(例: 0.2)にするとスピード感のある残像になります。
    • 敵の瞬間移動(テレポート)演出
      敵がワープする前後で一瞬だけオブジェクトを高速移動させるとき、GhostTrail を有効にしておくと、「スッ」と残像を残しながら消える演出が簡単に作れます。
      テレポート専用の敵プレハブに GhostTrail を仕込んでおけば、他のシーンでもそのまま使い回せます。
    • 動く床やコンベアの演出
      スクロールステージの背景オブジェクトやコンベアの床に GhostTrail をつけると、SF風の「残像付きの床」などが簡単に作れます。
      Ghost Color を薄めの色にして、Fade Duration を長めにすると、ゆったりした残像になります。

メリットと応用

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

  • プレハブの再利用性が高い
    「残像付きプレイヤー」「残像付き敵」「残像付きギミック」といったプレハブを、GhostTrail を付けるだけで量産できます。
  • レベルデザインが楽になる
    シーン上のオブジェクトに「ここは残像を出したいな」と思ったら GhostTrail をアタッチして色やフェード時間を変えるだけで、視線誘導やスピード感の調整ができます。
  • 責務が分かれている
    移動スクリプトは移動だけ、エフェクトはエフェクトだけ、というふうに分かれているので、後から「残像はいらなくなった」となってもコンポーネントを外すだけで済みます。
  • テストしやすい
    残像の挙動(色、フェード時間、生成間隔など)は GhostTrail 単体で調整できるので、ゲーム本体のロジックに影響を与えずに見た目だけをいじれます。

応用として、たとえば「特定の状況でだけ残像を強くする」といった改造も簡単にできます。
以下は、外部から一時的に残像の発生頻度を上げるための簡単な改造案です。


    /// <summary>
    /// 一定時間だけ、残像の発生頻度を上げるブースト機能の例。
    /// 例:ダッシュ開始時に呼び出して、より激しい残像を出す。
    /// </summary>
    public void BoostTrail(float boostDuration, float distanceMultiplier = 0.5f)
    {
        // コルーチンで一時的に MinDistance を下げる例
        StartCoroutine(BoostTrailRoutine(boostDuration, distanceMultiplier));
    }

    private System.Collections.IEnumerator BoostTrailRoutine(float duration, float distanceMultiplier)
    {
        float originalMinDistance = _minDistanceToSpawn;
        _minDistanceToSpawn *= distanceMultiplier;

        float timer = 0f;
        while (timer < duration)
        {
            timer += Time.deltaTime;
            yield return null;
        }

        _minDistanceToSpawn = originalMinDistance;
    }

このように、GhostTrail 自体も「残像エフェクト」という1つの責務に絞りつつ、必要に応じて小さなメソッドを足していくことで、Godクラス化を避けながら演出をリッチにしていくことができます。ぜひ自分のプロジェクト用にカスタマイズしてみてください。