Unityを触り始めた頃って、つい何でもかんでも Update() に書いてしまいがちですよね。移動処理、アニメーション制御、エフェクト再生、サウンド再生…すべて1つのスクリプトに押し込んでしまうと、だんだん「どこをいじればいいのか分からない巨大クラス(Godクラス)」になってしまいます。

足音や足元の砂煙・土煙のエフェクトも、プレイヤー制御スクリプトの中に直接書いてしまう代表例です。結果として、

  • エフェクトの調整をしたいだけなのに、プレイヤー制御スクリプトを開く必要がある
  • 敵キャラやNPCにも同じ処理をコピペしてしまう
  • アニメーションの変更に弱く、メンテナンスがつらい

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

そこでこの記事では、「足元のパーティクル発生」だけに責務を絞ったコンポーネント FootstepParticles を作って、キャラクターの見た目エフェクトをきれいに分離していきましょう。

【Unity】足元から土煙!「FootstepParticles」コンポーネント

このコンポーネントは、

  • 親オブジェクトが「地面の上を歩いている(≒一定以上の速度で動いている & 接地している)」とき
  • 一定間隔で足元にパーティクルを再生する

というシンプルな役割だけを担当します。
「移動のロジック」は別のコンポーネント(キャラクターコントローラなど)に任せて、
「歩いているかどうかの判定」+「パーティクルの再生」だけをこのコンポーネントが受け持つイメージですね。

フルコード(FootstepParticles.cs)


using UnityEngine;

/// <summary>
/// 親オブジェクトが床の上を歩いているとき、足元からパーティクルを発生させるコンポーネント。
/// - Rigidbody の速度と接地判定から「歩いているか」をざっくり判定します。
/// - パーティクル再生の間隔や条件はインスペクターから調整できます。
/// </summary>
[RequireComponent(typeof(ParticleSystem))]
public class FootstepParticles : MonoBehaviour
{
    // ====== 参照系 ======
    [Header("参照設定")]
    [Tooltip("足元に配置したパーティクル(このオブジェクト自身でもOK)")]
    [SerializeField] private ParticleSystem footstepParticle;

    [Tooltip("移動速度を取得するための Rigidbody。親オブジェクト側に付けておくことを想定")]
    [SerializeField] private Rigidbody targetRigidbody;

    [Tooltip("接地判定に使うレイヤーマスク(地面レイヤーを指定)")]
    [SerializeField] private LayerMask groundLayerMask = ~0; // デフォルトは全レイヤー

    // ====== 条件設定 ======
    [Header("発生条件")]
    [Tooltip("この速度以上で動いているときに「歩いている」とみなす")]
    [SerializeField] private float minSpeedToEmit = 0.5f;

    [Tooltip("足煙を出す間隔(秒)。短くすると足音のように細かく出る")]
    [SerializeField] private float emitInterval = 0.25f;

    [Tooltip("接地判定に使う SphereCast の半径")]
    [SerializeField] private float groundCheckRadius = 0.1f;

    [Tooltip("接地判定に使う SphereCast の距離(足元からどのくらい下を見るか)")]
    [SerializeField] private float groundCheckDistance = 0.2f;

    [Tooltip("接地していない状態でもパーティクルを出すか(例: 空中ダッシュの演出など)")]
    [SerializeField] private bool allowInAir = false;

    // ====== 位置・向き調整 ======
    [Header("位置・向き調整")]
    [Tooltip("パーティクルを出すオフセット位置(親オブジェクトのローカル座標系)")]
    [SerializeField] private Vector3 localOffset = new Vector3(0f, 0f, 0f);

    [Tooltip("移動方向に合わせてパーティクルを回転させるか")]
    [SerializeField] private bool faceMoveDirection = false;

    [Tooltip("Y軸だけを回転させる(横方向のみ)なら true。false だと完全に移動ベクトルに向ける")]
    [SerializeField] private bool rotateOnlyY = true;

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

    private Transform _targetTransform;

    private void Reset()
    {
        // コンポーネント追加時に、できるだけ自動で参照を埋める
        if (footstepParticle == null)
        {
            footstepParticle = GetComponent<ParticleSystem>();
        }

        if (targetRigidbody == null)
        {
            // 親か自分にある Rigidbody を自動取得
            targetRigidbody = GetComponentInParent<Rigidbody>();
        }
    }

    private void Awake()
    {
        if (footstepParticle == null)
        {
            footstepParticle = GetComponent<ParticleSystem>();
        }

        if (targetRigidbody == null)
        {
            targetRigidbody = GetComponentInParent<Rigidbody>();
        }

        if (targetRigidbody != null)
        {
            _targetTransform = targetRigidbody.transform;
        }
        else
        {
            // Rigidbody が見つからない場合は自分自身を対象にする
            _targetTransform = transform.parent != null ? transform.parent : transform;
            Debug.LogWarning(
                $"[FootstepParticles] Rigidbody が見つかりませんでした。速度判定が正しく動作しない可能性があります。 <{name}>",
                this
            );
        }

        _timer = emitInterval; // 起動直後からすぐ出せるようにタイマーを満タンにしておく
    }

    private void Update()
    {
        if (footstepParticle == null || _targetTransform == null)
        {
            return;
        }

        // 現在の速度を取得
        Vector3 velocity = targetRigidbody != null ? targetRigidbody.velocity : Vector3.zero;
        float horizontalSpeed = new Vector3(velocity.x, 0f, velocity.z).magnitude;

        // 歩いているかどうかの判定
        bool isMovingEnough = horizontalSpeed >= minSpeedToEmit;
        bool isGrounded = allowInAir || CheckGrounded();

        if (!isMovingEnough || !isGrounded)
        {
            // 条件を満たしていない間はタイマーだけ進めておく
            _timer = Mathf.Min(_timer + Time.deltaTime, emitInterval);
            return;
        }

        // タイマー更新
        _timer += Time.deltaTime;

        if (_timer >= emitInterval)
        {
            EmitFootstep(velocity);
            _timer = 0f;
        }
    }

    /// <summary>
    /// 接地判定。SphereCast を使って足元に地面があるかをざっくり確認します。
    /// </summary>
    private bool CheckGrounded()
    {
        Vector3 origin = _targetTransform.position + Vector3.up * 0.1f; // 少し上からキャスト
        float radius = groundCheckRadius;
        float distance = groundCheckDistance + 0.1f;

        // SphereCast で下方向に地面があるかチェック
        bool hit = Physics.SphereCast(
            origin,
            radius,
            Vector3.down,
            out RaycastHit hitInfo,
            distance,
            groundLayerMask,
            QueryTriggerInteraction.Ignore
        );

        return hit;
    }

    /// <summary>
    /// 足元パーティクルを1回発生させる。
    /// </summary>
    private void EmitFootstep(Vector3 velocity)
    {
        // パーティクルの位置を親の足元オフセット位置に合わせる
        if (_targetTransform != null)
        {
            Vector3 worldPos = _targetTransform.TransformPoint(localOffset);
            footstepParticle.transform.position = worldPos;
        }

        // 移動方向に合わせて回転させるオプション
        if (faceMoveDirection)
        {
            Vector3 moveDir = new Vector3(velocity.x, rotateOnlyY ? 0f : velocity.y, velocity.z);
            if (moveDir.sqrMagnitude > 0.0001f)
            {
                Quaternion targetRot = Quaternion.LookRotation(moveDir.normalized, Vector3.up);

                if (rotateOnlyY)
                {
                    // Y軸だけ回転させる(横向きだけ)
                    Vector3 euler = footstepParticle.transform.eulerAngles;
                    euler.y = targetRot.eulerAngles.y;
                    footstepParticle.transform.eulerAngles = euler;
                }
                else
                {
                    footstepParticle.transform.rotation = targetRot;
                }
            }
        }

        // 実際にパーティクルを再生
        footstepParticle.Play();
    }

    // ====== デバッグ用ギズモ描画 ======
    private void OnDrawGizmosSelected()
    {
        // 接地判定の範囲をシーンビューに表示して、調整しやすくする
        Transform t = Application.isPlaying ? _targetTransform : (transform.parent != null ? transform.parent : transform);

        if (t == null) return;

        Vector3 origin = t.position + Vector3.up * 0.1f;
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(origin + Vector3.down * groundCheckDistance, groundCheckRadius);

        // パーティクルのオフセット位置も表示
        Gizmos.color = Color.cyan;
        Vector3 offsetPos = t.TransformPoint(localOffset);
        Gizmos.DrawWireSphere(offsetPos, 0.05f);
    }
}

使い方の手順

ここでは「プレイヤーキャラクターが走ると足元から土煙が出る」例で説明しますが、敵キャラや動く床などにも同じ手順で使えます。

  1. ① 足煙用のパーティクルを用意する
    • Project ウィンドウで右クリック → Create > Particle System を作成
    • 名前を FootstepDust などに変更
    • Scene にドラッグして、キャラクターの足元あたりに配置
    • パーティクルの設定例:
      • Duration: 0.5 ~ 1.0
      • Looping: OFF(ループしない)
      • Start Lifetime: 0.3 ~ 0.6
      • Start Speed: 0.5 ~ 1.5
      • Simulation Space: World(ワールド空間で飛ぶ)
      • Emission の Rate over Time を 0 にして、Play() で単発再生する前提にする
  2. ② プレイヤーに Rigidbody を設定する
    • プレイヤー(または敵)オブジェクトに Rigidbody コンポーネントを追加
    • 既にある場合はそのままでOK(キャラクターコントローラで動かしている前提)
    • Use Gravity はオン、Is Kinematic は移動方式に応じて設定
  3. ③ FootstepParticles コンポーネントを追加する
    • 足煙パーティクルを配置した GameObject に FootstepParticles をアタッチ
    • インスペクターで以下を設定:
      • Footstep Particle: 自分自身の ParticleSystem が自動で入るはずです
      • Target Rigidbody: プレイヤー(親)の Rigidbody をドラッグ&ドロップ
      • Ground Layer Mask: 地面に使っているレイヤー(例: Ground)を指定
      • Min Speed To Emit: 0.5 ~ 1.0 くらいにすると「止まっているときは出ない」感じになります
      • Emit Interval: 0.2 ~ 0.3 くらいで「トトトト…」と足音のような間隔に
      • Local Offset: (0, 0, 0) から始めて、足元に来るように微調整
      • Face Move Direction: 砂煙が移動方向に流れてほしければ ON
  4. ④ 実行して調整する
    • ゲームを再生して、プレイヤーを動かしてみる
    • 足元からパーティクルが出るタイミング・量が合わなければ、
      • Emit Interval(間隔)
      • Min Speed To Emit(どのくらいの速さで歩き扱いにするか)
      • Local Offset(位置)
      • パーティクルの Start Lifetime / Start Speed / Emission Burst など

      を調整して、自分のゲームに合う見た目に仕上げましょう。

同じ手順で、敵キャラや NPC、動く床などにも足煙を追加できます。
Rigidbody と FootstepParticles をセットでプレハブ化しておけば、レベルデザイン時にシーンへポンポン置くだけで足煙付きキャラを量産できますね。

メリットと応用

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

  • プレイヤー制御スクリプトがスリムになる
    「移動ロジック」と「エフェクト演出」が分離されるので、どちらかを修正しても互いに影響しづらくなります。
  • プレハブの再利用性が高い
    FootstepParticles を含んだ「足煙付きキャラ」プレハブを作っておけば、敵やNPCにも流用できます。
    エフェクトの見た目を変えたいときも、このコンポーネントを差し替えるだけでOKです。
  • レベルデザインが楽になる
    例えば「このエリアは砂埃が強いから、足煙を濃くしたい」といったときに、シーン内の FootstepParticles だけを一括調整すれば済みます。
    スクリプト側をいじらずに、インスペクターのパラメータだけで調整できるのが嬉しいところですね。

応用としては、

  • 雪・泥・草むらなど、地形ごとにパーティクルを差し替える
  • 空中ダッシュやスライディングの時だけ別のパーティクルを出す
  • アニメーションイベントと組み合わせて「足が地面に着いた瞬間」にだけ再生する

といった拡張が考えられます。

例えば「スライディング中だけパーティクルを連続再生したい」という改造案として、こんなメソッドを追加しても面白いです。


    /// <summary>
    /// 外部(プレイヤー制御スクリプトなど)から呼び出して、
    /// スライディング中などの特殊アクション用に足煙を連続再生する例。
    /// </summary>
    public void EmitContinuous(float duration, float interval)
    {
        // コルーチンを使って一定時間だけ連続再生する
        StartCoroutine(EmitContinuousRoutine(duration, interval));
    }

    private System.Collections.IEnumerator EmitContinuousRoutine(float duration, float interval)
    {
        float elapsed = 0f;

        while (elapsed < duration)
        {
            // 速度や接地判定に関係なく強制的に足煙を出す
            EmitFootstep(targetRigidbody != null ? targetRigidbody.velocity : Vector3.forward);

            yield return new WaitForSeconds(interval);
            elapsed += interval;
        }
    }

このように、小さなコンポーネント単位で責務を分けておくと、後からの拡張やアニメーション連携がとてもやりやすくなります。
足煙や足音などの「演出系」は、どんどん専用コンポーネントに切り出していきましょう。