Unityを触り始めた頃って、ついなんでもかんでも Update() に書いてしまいがちですよね。
「ダッシュの処理」「アニメーションの切り替え」「エフェクトの再生」「入力の取得」…全部1つのスクリプトに押し込んでいくと、気づいたら何百行もある巨大なGodクラスになってしまいます。

そんな状態だと、

  • ちょっとした改修で別の機能が壊れる
  • プレイヤー以外(敵やNPC)に流用しづらい
  • バグの原因箇所が追いにくい

といった問題が出てきます。

この記事では、「ダッシュ中にだけ残像を出す」という機能を、
1つの小さなコンポーネントとして切り出した DashGhost を作ってみましょう。
プレイヤーの「ダッシュ機能」そのものとは分離し、「残像を出す役だけ」を担当させる設計にするのがポイントです。

【Unity】ダッシュの気持ちよさを底上げ!「DashGhost」コンポーネント

DashGhost は、

  • プレイヤーがダッシュ中だけ
  • 一定間隔でプレイヤーの見た目をコピーした半透明オブジェクトを生成し
  • 少しずつフェードアウトさせて自動で消える

という「ダッシュ残像」専用コンポーネントです。

ダッシュの判定自体は別コンポーネント(例:PlayerDashController)に任せ、
DashGhost は「今ダッシュ中かどうか」のフラグだけを受け取って仕事をします。
こうすることで、プレイヤー以外に「ダッシュする敵」や「高速移動するギミック」が出てきても、
同じ DashGhost をポン付けするだけで残像演出を共有できます。


フルコード:DashGhost.cs


using System.Collections;
using UnityEngine;

/// <summary>
/// ダッシュ中に、キャラクターの見た目をコピーした半透明の残像を生成するコンポーネント。
/// ・「ダッシュしているかどうか」のフラグだけを外部から教えてもらう設計
/// ・見た目のコピーには SpriteRenderer / SkinnedMeshRenderer / MeshRenderer などをサポート
/// ・生成間隔、フェード時間、色などをインスペクターから調整可能
/// </summary>
[DisallowMultipleComponent]
public class DashGhost : MonoBehaviour
{
    // ====== 設定項目 ======

    [Header("ダッシュ状態の参照")]
    [Tooltip("外部からダッシュ状態を教えてもらうためのフラグ。\n" +
             "例: PlayerDashController などから IsDashing を設定する。")]
    [SerializeField]
    private bool isDashing = false;

    [Header("残像の生成タイミング")]
    [Tooltip("残像を生成する間隔(秒)。\n値を小さくすると残像が密になり、大きくするとスカスカになります。")]
    [SerializeField]
    private float spawnInterval = 0.05f;

    [Header("残像の見た目")]
    [Tooltip("残像の開始時の色(アルファもここで指定)。\nSpriteRenderer / MeshRenderer / SkinnedMeshRenderer に適用されます。")]
    [SerializeField]
    private Color ghostColor = new Color(1f, 1f, 1f, 0.5f);

    [Tooltip("残像のスケール倍率。1 で等倍。")]
    [SerializeField]
    private float ghostScaleMultiplier = 1f;

    [Header("フェードアウト設定")]
    [Tooltip("残像が完全に消えるまでの時間(秒)。")]
    [SerializeField]
    private float fadeDuration = 0.3f;

    [Tooltip("フェードアウト中に、少しだけ下に沈ませるなどの移動をさせたい場合のオフセット速度。")]
    [SerializeField]
    private Vector3 driftVelocity = Vector3.zero;

    [Header("パフォーマンス設定")]
    [Tooltip("親オブジェクトとして使う Transform。null の場合は生成した残像はルート階層に並びます。")]
    [SerializeField]
    private Transform ghostParent;

    [Tooltip("同時に存在できる残像の最大数。\n0 以下の場合は制限なし。")]
    [SerializeField]
    private int maxGhostCount = 32;

    // ====== 内部状態 ======

    // 次の残像を生成してよい時刻
    private float nextSpawnTime = 0f;

    // 同時に存在している残像の数
    private int currentGhostCount = 0;

    // もともと持っているレンダラー達
    private Renderer[] originalRenderers;

    // キャッシュ用:マテリアルプロパティブロック(色変更をインスタンス単位で行うため)
    private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");

    private void Awake()
    {
        // このオブジェクト配下にある全ての Renderer をキャッシュしておく
        // (SpriteRenderer, MeshRenderer, SkinnedMeshRenderer などを含む)
        originalRenderers = GetComponentsInChildren<Renderer>

            (includeInactive: false);

        if (originalRenderers == null || originalRenderers.Length == 0)
        {
            Debug.LogWarning(
                $"[DashGhost] Renderer が見つかりません。見た目をコピーできないため、残像は生成されません。({name})",
                this
            );
        }

        if (ghostParent == null)
        {
            // 専用の親オブジェクトを自動で作ると階層が整理されて便利
            GameObject parent = new GameObject($"{name}_DashGhosts");
            ghostParent = parent.transform;
        }
    }

    private void Update()
    {
        // ダッシュしていない間は何もしない
        if (!isDashing)
        {
            return;
        }

        // 残像の最大数に達している場合は生成しない(パフォーマンス保護)
        if (maxGhostCount > 0 && currentGhostCount >= maxGhostCount)
        {
            return;
        }

        // 一定時間おきに残像を生成
        if (Time.time >= nextSpawnTime)
        {
            SpawnGhost();
            nextSpawnTime = Time.time + spawnInterval;
        }
    }

    /// <summary>
    /// 外部からダッシュ状態を設定するための公開メソッド。
    /// PlayerDashController などから呼び出してください。
    /// </summary>
    public void SetDashing(bool dashing)
    {
        isDashing = dashing;
    }

    /// <summary>
    /// 残像を1つ生成する。
    /// </summary>
    private void SpawnGhost()
    {
        if (originalRenderers == null || originalRenderers.Length == 0)
        {
            return;
        }

        // 残像用の空オブジェクトを作成
        GameObject ghostRoot = new GameObject("DashGhostInstance");
        Transform ghostTransform = ghostRoot.transform;

        // 親子関係と Transform をコピー
        ghostTransform.SetParent(ghostParent, worldPositionStays: true);
        ghostTransform.position = transform.position;
        ghostTransform.rotation = transform.rotation;
        ghostTransform.localScale = transform.lossyScale * ghostScaleMultiplier;

        // 元オブジェクトのレンダラー構造を再現する
        foreach (Renderer originalRenderer in originalRenderers)
        {
            // 元レンダラーの GameObject をコピー
            GameObject ghostPart = new GameObject(originalRenderer.gameObject.name + "_GhostPart");
            Transform ghostPartTransform = ghostPart.transform;
            ghostPartTransform.SetParent(ghostTransform, worldPositionStays: true);

            // ワールド座標をそのままコピー
            ghostPartTransform.position = originalRenderer.transform.position;
            ghostPartTransform.rotation = originalRenderer.transform.rotation;
            ghostPartTransform.localScale = originalRenderer.transform.lossyScale;

            // レンダラーの種類ごとにコピー
            if (originalRenderer is SpriteRenderer spriteRenderer)
            {
                SpriteRenderer ghostSprite = ghostPart.AddComponent<SpriteRenderer>();
                CopySpriteRenderer(spriteRenderer, ghostSprite);
            }
            else if (originalRenderer is SkinnedMeshRenderer skinnedMeshRenderer)
            {
                MeshRenderer ghostMeshRenderer = ghostPart.AddComponent<MeshRenderer>();
                MeshFilter ghostMeshFilter = ghostPart.AddComponent<MeshFilter>();

                // スキン済みメッシュをベイクして静的メッシュにする
                Mesh bakedMesh = new Mesh();
                skinnedMeshRenderer.BakeMesh(bakedMesh);
                ghostMeshFilter.sharedMesh = bakedMesh;

                CopyRendererMaterial(skinnedMeshRenderer, ghostMeshRenderer);
            }
            else if (originalRenderer is MeshRenderer meshRenderer)
            {
                MeshRenderer ghostMeshRenderer = ghostPart.AddComponent<MeshRenderer>();
                MeshFilter ghostMeshFilter = ghostPart.AddComponent<MeshFilter>();

                // 元の MeshFilter を取得してコピー
                MeshFilter originalFilter = meshRenderer.GetComponent<MeshFilter>();
                if (originalFilter != null)
                {
                    ghostMeshFilter.sharedMesh = originalFilter.sharedMesh;
                }

                CopyRendererMaterial(meshRenderer, ghostMeshRenderer);
            }
        }

        // フェードアウト処理を開始
        StartCoroutine(FadeAndDestroy(ghostRoot));

        currentGhostCount++;
    }

    /// <summary>
    /// SpriteRenderer の設定をコピーしつつ、色を残像用に変更する。
    /// </summary>
    private void CopySpriteRenderer(SpriteRenderer original, SpriteRenderer ghost)
    {
        ghost.sprite = original.sprite;
        ghost.flipX = original.flipX;
        ghost.flipY = original.flipY;
        ghost.sortingLayerID = original.sortingLayerID;
        ghost.sortingOrder = original.sortingOrder - 1; // 元より少し後ろに描画

        // マテリアルは共有しつつ、色だけを変更
        ghost.sharedMaterial = original.sharedMaterial;

        // 色を ghostColor ベースに
        Color baseColor = original.color;
        Color finalColor = ghostColor;
        finalColor.r *= baseColor.r;
        finalColor.g *= baseColor.g;
        finalColor.b *= baseColor.b;

        ghost.color = finalColor;
    }

    /// <summary>
    /// MeshRenderer / SkinnedMeshRenderer のマテリアルをコピーして色を変更。
    /// </summary>
    private void CopyRendererMaterial(Renderer original, Renderer ghost)
    {
        // マテリアルを共有しつつ、MaterialPropertyBlock で色だけを変える
        ghost.sharedMaterials = original.sharedMaterials;

        var block = new MaterialPropertyBlock();
        original.GetPropertyBlock(block);

        // _Color プロパティがある前提(標準シェーダーなど)。ない場合はそのまま。
        if (block.HasProperty(ColorPropertyId))
        {
            Color baseColor = block.GetColor(ColorPropertyId);
            Color finalColor = ghostColor;
            finalColor.r *= baseColor.r;
            finalColor.g *= baseColor.g;
            finalColor.b *= baseColor.b;
            block.SetColor(ColorPropertyId, finalColor);
        }
        else
        {
            block.SetColor(ColorPropertyId, ghostColor);
        }

        ghost.SetPropertyBlock(block);
    }

    /// <summary>
    /// 残像を徐々にフェードアウトさせ、最後に破棄するコルーチン。
    /// </summary>
    private IEnumerator FadeAndDestroy(GameObject ghostRoot)
    {
        float elapsed = 0f;

        // 残像に含まれる全 Renderer を取得
        Renderer[] renderers = ghostRoot.GetComponentsInChildren<Renderer>();

        // 各 Renderer ごとに MaterialPropertyBlock を用意
        MaterialPropertyBlock[] blocks = new MaterialPropertyBlock[renderers.Length];
        Color[] initialColors = new Color[renderers.Length];

        for (int i = 0; i < renderers.Length; i++)
        {
            blocks[i] = new MaterialPropertyBlock();
            renderers[i].GetPropertyBlock(blocks[i]);

            // 現在色を取得(なければ ghostColor)
            Color color = ghostColor;
            if (blocks[i].HasProperty(ColorPropertyId))
            {
                color = blocks[i].GetColor(ColorPropertyId);
            }

            initialColors[i] = color;
        }

        // フェードアウトループ
        while (elapsed < fadeDuration)
        {
            elapsed += Time.deltaTime;
            float t = Mathf.Clamp01(elapsed / fadeDuration);
            float alpha = Mathf.Lerp(1f, 0f, t);

            for (int i = 0; i < renderers.Length; i++)
            {
                Color c = initialColors[i];
                c.a *= alpha;
                blocks[i].SetColor(ColorPropertyId, c);
                renderers[i].SetPropertyBlock(blocks[i]);
            }

            // 残像自体を少しだけ動かしたい場合
            if (driftVelocity != Vector3.zero)
            {
                ghostRoot.transform.position += driftVelocity * Time.deltaTime;
            }

            yield return null;
        }

        Destroy(ghostRoot);
        currentGhostCount--;
    }

#if UNITY_EDITOR
    // エディタ上でパラメータを変更したときに、即座に反映させたい場合の補助
    private void OnValidate()
    {
        spawnInterval = Mathf.Max(0.01f, spawnInterval);
        fadeDuration = Mathf.Max(0.01f, fadeDuration);
        maxGhostCount = Mathf.Max(0, maxGhostCount);
        ghostScaleMultiplier = Mathf.Max(0.01f, ghostScaleMultiplier);
    }
#endif
}

使い方の手順

ここでは「2Dのプレイヤーキャラがダッシュしたときに残像を出す」例で説明します。
3Dキャラでも同じ考え方で使えます。

  1. DashGhost.cs をプロジェクトに追加
    上記コードを DashGhost.cs という名前で Assets フォルダに保存します。
    Unity に戻ると自動でコンパイルされます。
  2. プレイヤーの見た目に Renderer が付いていることを確認
    2Dなら SpriteRenderer、3Dなら SkinnedMeshRendererMeshRenderer
    プレイヤーオブジェクトの子階層に付いていることを確認してください。
    DashGhost は GetComponentsInChildren<Renderer>() でこれらを自動検出します。
  3. プレイヤーに DashGhost コンポーネントをアタッチ
    プレイヤーのルート GameObject(移動やダッシュを管理しているオブジェクト)に
    DashGhost を追加します。
    インスペクターで以下を調整しましょう:
    • Spawn Interval: 0.03〜0.07 くらいが「スピード感のある残像」になりやすいです。
    • Ghost Color: アルファを 0.3〜0.6 にすると自然です。
    • Fade Duration: 0.2〜0.5 秒くらいから試してみてください。
    • Max Ghost Count: 16〜32 くらいにしておくと、カメラ外で暴走しにくくなります。
  4. ダッシュ制御スクリプトから DashGhost にフラグを渡す
    すでにプレイヤーのダッシュ処理を行っているスクリプトがある前提で、
    「今ダッシュ中かどうか」の情報だけを DashGhost に教えます。
    例として、非常にシンプルなダッシュ制御クラスを用意するとこんな感じです:
    
    using UnityEngine;
    
    /// <summary>
    /// 入力に応じてダッシュ状態を切り替えるだけの簡易サンプル。
    /// 実際のプロジェクトでは移動やスタミナ管理などは別コンポーネントに分けましょう。
    /// </summary>
    [RequireComponent(typeof(DashGhost))]
    public class PlayerDashController : MonoBehaviour
    {
        [SerializeField]
        private KeyCode dashKey = KeyCode.LeftShift;
    
        [SerializeField]
        private float dashDuration = 0.3f;
    
        private DashGhost dashGhost;
        private bool isDashing;
        private float dashEndTime;
    
        private void Awake()
        {
            dashGhost = GetComponent<DashGhost>();
        }
    
        private void Update()
        {
            // ダッシュ開始
            if (Input.GetKeyDown(dashKey) && !isDashing)
            {
                StartDash();
            }
    
            // ダッシュ時間の管理
            if (isDashing && Time.time >= dashEndTime)
            {
                EndDash();
            }
        }
    
        private void StartDash()
        {
            isDashing = true;
            dashEndTime = Time.time + dashDuration;
    
            // DashGhost に通知
            dashGhost.SetDashing(true);
    
            // 実際の速度アップなどは別の移動コンポーネントに任せるのがオススメ
        }
    
        private void EndDash()
        {
            isDashing = false;
            dashGhost.SetDashing(false);
        }
    }
    

    このように、ダッシュの判定と挙動PlayerDashController
    残像の生成DashGhost と役割を分けておくと、後からの改造がとても楽になります。

同じ要領で、

  • 高速で突進する敵キャラ(EnemyDasher)
  • 一定間隔で瞬間移動するボス
  • プレイヤーを運ぶ「高速で動く床」

などにも DashGhost をアタッチして、SetDashing(true/false) を呼ぶだけで
簡単に「残像演出」を共有できます。


メリットと応用

DashGhost をコンポーネントとして切り出しておくと、

  • プレハブの再利用性が高い
    「ダッシュするキャラ用プレハブ」を作るとき、
    ダッシュ挙動と残像演出をそれぞれ別コンポーネントとして付け外しできるので、
    用途に応じて組み合わせるだけでバリエーションを増やせます。
  • レベルデザインが楽になる
    ステージ上に高速で動くギミックや敵を配置するとき、
    「この敵は残像あり」「このギミックは残像なし」といった調整を
    インスペクターのチェック一つ・コンポーネント一つで切り替えできます。
  • ビジュアル調整が1か所で完結
    残像の色・フェード時間・生成間隔を DashGhost だけで管理しているので、
    「全体的に残像をもっと薄くしたい」「フェードを早くしたい」といった要望にも
    1つのスクリプトをいじるだけで対応できます。
  • SRP(単一責任の原則)に沿った設計
    ダッシュ機能(入力+移動ロジック)と、残像演出(ビジュアル)は別物として分離されているので、
    片方を修正してももう片方に影響しづらく、バグの混入ポイントが減るのも大きなメリットです。

応用として、例えば「ダッシュ中だけでなく、テレポートの瞬間にも残像を残したい」という場合、
テレポート処理の直前・直後で SetDashing(true/false) を呼ぶだけで、
同じビジュアル演出を再利用できます。

改造案:一定距離ダッシュしたら自動で残像をオフにする

「時間ではなく、ダッシュで移動した距離をもとに残像を止めたい」場合の一例です。
PlayerDashController 側に、こんなメソッドを追加してみましょう。


private Vector3 dashStartPosition;
[SerializeField]
private float maxDashDistance = 5f;

private void StartDash()
{
    isDashing = true;
    dashStartPosition = transform.position;

    dashGhost.SetDashing(true);
}

private void Update()
{
    if (isDashing)
    {
        float distance = Vector3.Distance(dashStartPosition, transform.position);
        if (distance >= maxDashDistance)
        {
            EndDash();
        }
    }

    // 他の入力処理など...
}

このように、ダッシュの「終了条件」だけを変えても、
残像演出側(DashGhost)は一切変更せずに流用できるのが、
コンポーネント分割の強みですね。