Unityを触り始めた頃は、つい「とりあえず全部Updateに書くか…」となりがちですよね。
剣の軌跡や弾の残像エフェクトも、Update()の中で座標を配列に貯めて、LineRendererに全部突っ込んで…とやっていると、あっという間に「軌跡だけで100行」みたいなスクリプトになってしまいます。
そうなると、
- プレイヤーの攻撃ロジックと軌跡ロジックがベッタリ結合してテストしづらい
- 敵や弾にも同じ軌跡を使いたいのに、コピペ地獄になる
- ちょっとだけ見た目を変えたいのに、本体コードを触る必要がある
といった「Godクラス」化まっしぐらな構成になりがちです。
この記事では、剣や弾の移動経路に対して、LineRenderer(Line2D)を使って滑らかに消える軌跡を描画するためのコンポーネント
「TrailRenderer(軌跡)」 を、1つの役割に絞ったコンポーネントとして実装していきます。
攻撃ロジックや移動ロジックとはきれいに分離し、軌跡はこのコンポーネントに丸投げできる構成を目指しましょう。
【Unity】剣も弾も「軌跡」は丸投げ!「TrailRenderer(軌跡)」コンポーネント
以下が、Unity 6(C#)で動作するフルコードです。
LineRenderer を使い、一定時間ごとに位置サンプルを蓄積しつつ、古いサンプルを削除していくことで「スッと消えていく軌跡」を実現します。
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 剣や弾などの移動経路に、LineRenderer を使って滑らかに消える軌跡を描画するコンポーネント。
/// - 単一責任: 「軌跡の描画と寿命管理」だけを担当する。
/// - 移動や攻撃ロジックとは分離し、どのオブジェクトにも再利用できるようにする。
///
/// 想定用途:
/// - プレイヤーの剣の振り軌跡
/// - 弾丸やミサイルの残像
/// - 動く床やギミックの通ったルートの可視化 など
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class TrailRenderer2D : MonoBehaviour
{
// --- 基本設定 ---
[Header("基本設定")]
[SerializeField]
[Tooltip("軌跡が完全に消えるまでの時間(秒)。この時間より古いポイントは自動的に削除されます。")]
private float trailLifetime = 0.4f;
[SerializeField]
[Tooltip("新しいポイントを追加する最小移動距離。小さすぎるとポイントが増えすぎて重くなります。")]
private float minDistance = 0.02f;
[SerializeField]
[Tooltip("座標をサンプリングする最小時間間隔。0 にすると毎フレーム判定します。")]
private float minSampleInterval = 0.0f;
[Header("見た目設定")]
[SerializeField]
[Tooltip("軌跡の開始幅(LineRenderer の startWidth)。")]
private float startWidth = 0.12f;
[SerializeField]
[Tooltip("軌跡の終了幅(LineRenderer の endWidth)。")]
private float endWidth = 0.0f;
[SerializeField]
[Tooltip("軌跡の開始色。アルファ値も有効です。")]
private Color startColor = new Color(1f, 1f, 1f, 1f);
[SerializeField]
[Tooltip("軌跡の終了色。アルファ値も有効です。")]
private Color endColor = new Color(1f, 1f, 1f, 0f);
[SerializeField]
[Tooltip("ローカル座標ではなくワールド座標で軌跡を描画するかどうか。弾などには true 推奨。")]
private bool useWorldSpace = true;
[Header("動作制御")]
[SerializeField]
[Tooltip("有効なときだけ軌跡を描画します。剣を振っている間だけ true にする、などの制御に使います。")]
private bool emit = true;
[SerializeField]
[Tooltip("開始時に軌跡ポイントをクリアするかどうか。プレハブ生成時に古い軌跡を消したい場合は true 推奨。")]
private bool clearOnStart = true;
// --- 内部状態 ---
/// <summary> 軌跡の1点を表す構造体(位置と記録時刻)。 </summary>
private struct TrailPoint
{
public Vector3 Position;
public float Time;
}
// 軌跡ポイントのリスト(古い順に並ぶ)
private readonly List<TrailPoint> points = new List<TrailPoint>();
// LineRenderer コンポーネントの参照
private LineRenderer lineRenderer;
// 最後にポイントを追加した時間
private float lastSampleTime;
// 最後にポイントを追加した位置
private Vector3 lastSamplePosition;
// --- プロパティ(外部スクリプトから制御したい用) ---
/// <summary>
/// 軌跡を出すかどうかのフラグ。外部から ON/OFF できます。
/// 例: 攻撃開始時に true、攻撃終了時に false にする。
/// </summary>
public bool Emit
{
get => emit;
set
{
emit = value;
// OFF になった瞬間にポイントをクリアしたい場合はコメントアウトを外す
// if (!emit) ClearTrail();
}
}
private void Awake()
{
lineRenderer = GetComponent<LineRenderer>();
// LineRenderer の基本設定
lineRenderer.useWorldSpace = useWorldSpace;
lineRenderer.positionCount = 0;
lineRenderer.startWidth = startWidth;
lineRenderer.endWidth = endWidth;
// 色のグラデーションを設定(開始色 -> 終了色)
var gradient = new Gradient();
gradient.SetKeys(
new[]
{
new GradientColorKey(startColor, 0f),
new GradientColorKey(endColor, 1f)
},
new[]
{
new GradientAlphaKey(startColor.a, 0f),
new GradientAlphaKey(endColor.a, 1f)
}
);
lineRenderer.colorGradient = gradient;
}
private void Start()
{
if (clearOnStart)
{
ClearTrail();
}
// 初期サンプル位置を現在位置で初期化
lastSamplePosition = GetCurrentPosition();
lastSampleTime = Time.time;
AddPoint(lastSamplePosition);
}
private void Update()
{
float currentTime = Time.time;
// 古くなったポイントを削除
RemoveExpiredPoints(currentTime);
// emit が無効なら新規ポイントは追加しない(既存の軌跡は寿命で消えていく)
if (!emit)
{
UpdateLineRenderer();
return;
}
// サンプリング間隔が設定されていれば、それを満たすまで待つ
if (minSampleInterval > 0f && currentTime - lastSampleTime < minSampleInterval)
{
UpdateLineRenderer();
return;
}
Vector3 currentPos = GetCurrentPosition();
float distance = Vector3.Distance(currentPos, lastSamplePosition);
// 一定距離以上動いたらポイントを追加
if (distance >= minDistance)
{
AddPoint(currentPos);
lastSamplePosition = currentPos;
lastSampleTime = currentTime;
}
// LineRenderer にポイントを反映
UpdateLineRenderer();
}
/// <summary>
/// 現在の Transform 位置を取得する。
/// 2D用途でも Z はそのまま使えるように Vector3 として扱う。
/// </summary>
private Vector3 GetCurrentPosition()
{
return transform.position;
}
/// <summary>
/// 新しいポイントをリストに追加する。
/// </summary>
private void AddPoint(Vector3 position)
{
TrailPoint point = new TrailPoint
{
Position = position,
Time = Time.time
};
points.Add(point);
}
/// <summary>
/// trailLifetime を過ぎた古いポイントをリストから削除する。
/// </summary>
private void RemoveExpiredPoints(float currentTime)
{
if (trailLifetime <= 0f)
{
// 0 以下の場合は寿命無制限だが、メモリ肥大化に注意
return;
}
// 先頭から順にチェックし、古いものを削除
int removeCount = 0;
for (int i = 0; i < points.Count; i++)
{
float age = currentTime - points[i].Time;
if (age > trailLifetime)
{
removeCount++;
}
else
{
// ここから先は全て新しいので break
break;
}
}
if (removeCount > 0)
{
points.RemoveRange(0, removeCount);
}
}
/// <summary>
/// LineRenderer に現在のポイントリストを反映する。
/// </summary>
private void UpdateLineRenderer()
{
// ポイントが 0 or 1 しかない場合は描画しても見えないので消す
if (points.Count <= 1)
{
lineRenderer.positionCount = 0;
return;
}
lineRenderer.positionCount = points.Count;
// LineRenderer に位置をセット
for (int i = 0; i < points.Count; i++)
{
lineRenderer.SetPosition(i, points[i].Position);
}
}
/// <summary>
/// 軌跡を完全にクリアする(ポイントも LineRenderer も空にする)。
/// 外部からも呼べるように public にしておく。
/// </summary>
public void ClearTrail()
{
points.Clear();
lineRenderer.positionCount = 0;
// 現在位置を基準にしておくと、次の emit ON で滑らかに始まる
lastSamplePosition = GetCurrentPosition();
lastSampleTime = Time.time;
}
/// <summary>
/// ランタイム中に見た目のパラメータを変更したいときに呼び出すヘルパー。
/// 例: 武器の強化に応じて色や太さを変えるなど。
/// </summary>
public void ApplyAppearance(float newStartWidth, float newEndWidth, Color newStartColor, Color newEndColor)
{
startWidth = newStartWidth;
endWidth = newEndWidth;
startColor = newStartColor;
endColor = newEndColor;
lineRenderer.startWidth = startWidth;
lineRenderer.endWidth = endWidth;
var gradient = new Gradient();
gradient.SetKeys(
new[]
{
new GradientColorKey(startColor, 0f),
new GradientColorKey(endColor, 1f)
},
new[]
{
new GradientAlphaKey(startColor.a, 0f),
new GradientAlphaKey(endColor.a, 1f)
}
);
lineRenderer.colorGradient = gradient;
}
}
使い方の手順
ここでは、以下の3つの具体例で使い方を説明します。
- プレイヤーの剣の軌跡
- 敵の弾の残像
- 動く床の通ったルートの可視化
手順① プレハブ(またはオブジェクト)にコンポーネントを追加
- シーン上のオブジェクト(例: Player の子オブジェクト「SwordTip」)を選択します。
- Add Component から
LineRendererを追加します。 - 同じく Add Component から、上記の
TrailRenderer2Dスクリプトを追加します。
これで、剣の先端や弾のオブジェクトに「軌跡専用コンポーネント」が載った状態になります。
手順② LineRenderer のマテリアルとレイヤーを設定
- LineRenderer の Material に、適当な
Sprites/DefaultやUnlit系マテリアルを設定します。 - Sorting Layer / Order in Layer を設定して、キャラクターの前後関係を調整します。
- 2Dゲームであれば、Z=0 付近に収まるようにしておくと扱いやすいです。
TrailRenderer2D のインスペクターでは、以下のような値から試してみると分かりやすいです。
- Trail Lifetime: 0.3 ~ 0.5
- Min Distance: 0.02 ~ 0.05
- Start Width: 0.1 ~ 0.2
- End Width: 0.0
- Start Color: 白 or 少し色味をつける
- End Color: アルファ 0 の色(透明)
手順③ 剣の軌跡として使う(攻撃中だけ Emit を ON)
例として、プレイヤーの攻撃アニメーションに合わせて剣の軌跡を出す場合です。
剣の先端に SwordTip という子オブジェクトを作り、そこに TrailRenderer2D を付けておきます。
using UnityEngine;
/// <summary>
/// 非常にシンプルな攻撃制御例。
/// アニメーションイベントやボタン入力に応じて、剣の軌跡の Emit を ON/OFF する。
/// </summary>
public class PlayerAttackExample : MonoBehaviour
{
[SerializeField]
private TrailRenderer2D swordTrail; // SwordTip に付けた TrailRenderer2D をアサイン
// 攻撃開始時に呼ぶ(アニメーションイベントなどから)
public void OnAttackStart()
{
if (swordTrail == null) return;
// 軌跡を一旦クリアしてから Emit を ON にすると、前回の攻撃の残りが混ざらない
swordTrail.ClearTrail();
swordTrail.Emit = true;
}
// 攻撃終了時に呼ぶ
public void OnAttackEnd()
{
if (swordTrail == null) return;
// Emit を OFF にして、残った軌跡は寿命でフェードアウトさせる
swordTrail.Emit = false;
}
}
攻撃ロジック(当たり判定やダメージ計算)は別コンポーネントに任せて、
この例では「攻撃の開始/終了タイミングを TrailRenderer2D に伝えるだけ」にしているのがポイントです。
手順④ 弾や動く床にも簡単に再利用
敵の弾の残像として使う
弾プレハブに LineRenderer + TrailRenderer2D を載せるだけで、移動経路に残像が出ます。
弾は常に動いているので、Emit は常に ON のままで問題ありません。
using UnityEngine;
/// <summary>
/// 単純な弾移動の例。TrailRenderer2D は常時 Emit = true にしておく。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class BulletMoverExample : MonoBehaviour
{
[SerializeField]
private float speed = 10f;
[SerializeField]
private TrailRenderer2D trail; // 同じオブジェクトに付けた TrailRenderer2D
private Rigidbody2D rb;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
}
private void OnEnable()
{
// 発射時に軌跡をクリアして Emit を ON
if (trail != null)
{
trail.ClearTrail();
trail.Emit = true;
}
}
private void FixedUpdate()
{
rb.velocity = transform.right * speed;
}
}
動く床の通ったルートを可視化する
動く床オブジェクトに TrailRenderer2D を付けておけば、プレイヤーに「床がどこを移動してきたか」を示すガイドラインとして使えます。
- Trail Lifetime を長め(2~5秒)にする
- Start/End Color を薄めにして邪魔にならないようにする
- Emit は常に ON
といった設定にすると、ゲーム性のヒントとしてちょうど良い見た目になります。
メリットと応用
TrailRenderer2D のように「軌跡だけ」を担当するコンポーネントに切り出しておくと、次のようなメリットがあります。
- プレハブの再利用性が高い
剣の先端プレハブ、弾プレハブ、敵の武器プレハブなど、どこにでも同じ軌跡ロジックを載せられます。
軌跡の見た目を変えたければ、このコンポーネントのインスペクターをいじるだけでOKです。 - レベルデザインが楽になる
動く床やギミックに付けることで、「プレイヤーにどんな動きを見せたいか」を視覚的に確認できます。
Designer がパラメータだけ触って見た目を調整しやすくなるのも利点ですね。 - 本体ロジックと視覚効果が分離される
攻撃判定・移動・AI などのロジックはロジックでシンプルに保ちつつ、軌跡は軌跡で完結したコードにできます。
バグの切り分けもしやすく、保守コストが下がります。 - LineRenderer の生APIに触れずに済む
「ポイントをどのタイミングで追加/削除するか」「positionCount の管理」など、面倒な部分はコンポーネント内部に隠蔽しています。
さらに、少しの改造でいろいろなバリエーションを作れます。
改造案:速度に応じて軌跡の長さを変える
例えば、オブジェクトの移動速度が速いときだけ長い軌跡を出し、遅いときは短くすることで、スピード感を強調できます。
以下のようなメソッドを TrailRenderer2D に追加し、外部から毎フレーム呼び出す構成にすると良いでしょう。
/// <summary>
/// 現在の速度に応じて trailLifetime を補間する例。
/// 速度が速いほど軌跡を長く、遅いほど短くする。
/// </summary>
public void UpdateLifetimeBySpeed(float speed, float minSpeed, float maxSpeed, float minLifetime, float maxLifetime)
{
// 速度を 0-1 の範囲に正規化
float t = Mathf.InverseLerp(minSpeed, maxSpeed, speed);
trailLifetime = Mathf.Lerp(minLifetime, maxLifetime, t);
}
プレイヤーの移動コンポーネントなどから、
float speed = rb.velocity.magnitude;
swordTrail.UpdateLifetimeBySpeed(speed, 0f, 10f, 0.1f, 0.6f);
のように呼び出してあげれば、「速く振ると長く伸びる剣の軌跡」が簡単に作れます。
軌跡のような「見た目ロジック」は、つい本体のスクリプトに混ぜ込みがちですが、
コンポーネントとして分離しておくと、プロジェクトが大きくなっても破綻しにくくなります。
ぜひ、自分のプロジェクト用にカスタマイズして使い回してみてください。
