【Cocos Creator 3.8】TrailRenderer(軌跡)の実装:アタッチするだけで剣や弾に「残像の軌跡」を描画する汎用スクリプト

本記事では、Cocos Creator 3.8.7 + TypeScript で、任意のノードにアタッチするだけで移動経路に滑らかな軌跡を描画する「TrailRenderer」コンポーネントを実装します。

剣の振り、弾丸の軌道、ダッシュ中の残像など、「ノードが動いた線の跡」を簡単に表現したい場面は多いです。ここで実装する TrailRenderer は、Line2D コンポーネントを内部で自動取得して制御し、外部の GameManager やシングルトンに一切依存せず、インスペクタでパラメータを調整するだけで使える汎用スクリプトです。


コンポーネントの設計方針

機能要件の整理

  • アタッチしたノードのワールド座標の移動経路に沿って軌跡を描画する。
  • 軌跡は一定時間でフェードアウト(透明になって消える)する。
  • 動きが止まっているときは、不要に頂点を増やさない(一定以上動いたときだけ頂点を追加)。
  • Line2D コンポーネントを利用して、柔らかくつながった線として描画する。
  • Line2D がノードに存在しない場合は、エラーログを出して動作を停止し、エディタ上の設定ミスに気づけるようにする。
  • TrailRenderer 単体で完結し、他のカスタムスクリプトには一切依存しない。

TrailRenderer の基本動作

  1. 毎フレーム、ノードのワールド座標を取得する。
  2. 前回記録した位置から最小移動距離(minDistance)以上動いていたら、新しい「ポイント」を追加する。
  3. 各ポイントは「位置(Vec3)」と「経過時間(age)」を持つ。
  4. update() で各ポイントの age を加算し、lifeTime を超えたポイントを削除する。
  5. 各ポイントの age に応じて、アルファ値(透明度)を計算し、Line2D の頂点カラーとして反映する。
  6. Line2D の points / colors を更新し、見た目の軌跡を描画する。

インスペクタで設定可能なプロパティ

TrailRenderer でインスペクタから調整できるプロパティと役割を整理します。

  • enabledTrail: boolean
    軌跡の ON/OFF を切り替えます。
    ・true: 軌跡を描画する
    ・false: 軌跡の更新を停止し、既存の軌跡もクリアします
  • lifeTime: number
    1つのポイントが残る時間(秒)。
    例: 0.3 ~ 0.8 くらいが剣・弾の軌跡に向いています。
    この時間を過ぎたポイントは自動的に削除されます。
  • minDistance: number
    新しいポイントを追加するために必要な最小移動距離(ワールド座標系の単位)。
    小さすぎると頂点が増えすぎて重くなりやすく、大きすぎるとガクガクした線になります。
    目安: 0.02 ~ 0.1
  • maxPoints: number
    軌跡として保持する最大ポイント数。
    パフォーマンス保護のため、これを超えたら古いポイントから削除します。
    目安: 32 ~ 128
  • startWidth: number
    軌跡の先頭側の線の太さ。
    Line2D の width に反映されます(ここでは全体幅として使用)。
  • startColor: Color
    軌跡の出始めの色。主に RGB を決める用途。アルファ値は内部で上書きされ、フェードアウトに使われます。
  • useWorldSpace: boolean
    Line2D が扱う座標系の指定(Line2D の useWorldSpace に合わせる)。
    ・true: ワールド座標をそのまま使用(通常はこちら)
    ・false: 親ノードローカル座標として扱う
  • fadeMode: number (enum)
    フェードアウトのカーブを選択します。
    ・0: Linear(線形に消える)
    ・1: EaseOut(最初は濃く、最後にスッと消える)
    ・2: EaseIn(最初にすぐ薄くなり、最後はゆっくり消える)

これらのパラメータをインスペクタから調整することで、剣の鋭い軌跡・魔法の柔らかい残光・弾丸の短いスモークラインなど、さまざまな表現に対応できます。


TypeScriptコードの実装

ここからは、TrailRenderer コンポーネントの完全な TypeScript 実装を示します。


import { _decorator, Component, Node, Vec3, Color, Line2D, math, Enum } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 軌跡のフェードモード
 */
enum TrailFadeMode {
    LINEAR = 0,
    EASE_OUT = 1,
    EASE_IN = 2,
}

Enum(TrailFadeMode);

interface TrailPoint {
    position: Vec3;
    age: number; // 経過時間
}

@ccclass('TrailRenderer')
export class TrailRenderer extends Component {

    @property({
        tooltip: '軌跡の描画を有効にするかどうか。\nfalse にすると軌跡の更新と描画を停止し、既存の頂点もクリアされます。',
    })
    public enabledTrail: boolean = true;

    @property({
        tooltip: '1つのポイントが残る時間(秒)。\nこの時間を過ぎるとポイントは削除され、軌跡が消えていきます。',
        min: 0.01,
    })
    public lifeTime: number = 0.4;

    @property({
        tooltip: '新しいポイントを追加するために必要な最小移動距離(ワールド座標)。\n小さすぎると頂点が増えすぎて重くなる可能性があります。',
        min: 0.001,
    })
    public minDistance: number = 0.05;

    @property({
        tooltip: '軌跡として保持する最大ポイント数。\nこれを超えた場合は最も古いポイントから削除されます。',
        min: 2,
    })
    public maxPoints: number = 64;

    @property({
        tooltip: '軌跡の線の太さ。',
        min: 0.001,
    })
    public startWidth: number = 0.05;

    @property({
        tooltip: '軌跡の開始色(RGB)。\nアルファ値は内部で制御され、時間経過でフェードアウトします。',
    })
    public startColor: Color = new Color(255, 255, 255, 255);

    @property({
        tooltip: 'Line2D の座標系をワールド座標として扱うかどうか。\n通常は true 推奨です。',
    })
    public useWorldSpace: boolean = true;

    @property({
        type: TrailFadeMode,
        tooltip: 'フェードアウトのカーブを選択します。\nLINEAR: 一定速度で消える\nEASE_OUT: 最後にスッと消える\nEASE_IN: 最初にすぐ薄くなる',
    })
    public fadeMode: TrailFadeMode = TrailFadeMode.LINEAR;

    // 内部状態
    private _line2D: Line2D | null = null;
    private _points: TrailPoint[] = [];
    private _lastPosition: Vec3 | null = null;
    private _tempWorldPos: Vec3 = new Vec3();
    private _tempLocalPos: Vec3 = new Vec3();

    onLoad() {
        // Line2D の取得(必須コンポーネント)
        this._line2D = this.getComponent(Line2D);
        if (!this._line2D) {
            console.error(
                '[TrailRenderer] このコンポーネントを使用するには、同じノードに Line2D コンポーネントを追加してください。',
                this.node.name
            );
            return;
        }

        // Line2D の基本設定を初期化
        this._line2D.width = this.startWidth;
        this._line2D.useWorldSpace = this.useWorldSpace;
        this._line2D.points = [];
        this._line2D.colors = [];
    }

    start() {
        if (!this._line2D) {
            return;
        }

        // 開始時点の位置を 1 つだけ登録しておく
        this._updateCurrentNodeWorldPos();
        const startPos = this._getLineSpacePosition(this._tempWorldPos);
        this._points = [{
            position: startPos.clone(),
            age: 0,
        }];
        this._lastPosition = startPos.clone();
        this._applyToLine2D();
    }

    update(deltaTime: number) {
        if (!this._line2D) {
            return;
        }

        if (!this.enabledTrail) {
            // 無効化されている場合は軌跡をクリア
            if (this._points.length > 0) {
                this._points.length = 0;
                this._applyToLine2D();
            }
            return;
        }

        // 現在位置の取得
        this._updateCurrentNodeWorldPos();
        const currentPos = this._getLineSpacePosition(this._tempWorldPos);

        // 前回位置から十分移動していれば新しいポイントを追加
        if (!this._lastPosition || Vec3.distance(this._lastPosition, currentPos) >= this.minDistance) {
            this._addPoint(currentPos);
            this._lastPosition = currentPos.clone();
        }

        // ポイントの寿命更新と削除
        this._updatePoints(deltaTime);

        // Line2D に反映
        this._applyToLine2D();
    }

    /**
     * 現在のノードのワールド座標を _tempWorldPos に格納する。
     */
    private _updateCurrentNodeWorldPos() {
        this.node.getWorldPosition(this._tempWorldPos);
    }

    /**
     * Line2D の座標系に合わせて座標を変換して返す。
     * useWorldSpace = true の場合はそのまま、false の場合は親ローカル座標に変換。
     */
    private _getLineSpacePosition(worldPos: Vec3): Vec3 {
        if (this.useWorldSpace) {
            return worldPos.clone();
        } else {
            const parent = this.node.parent;
            if (parent) {
                parent.inverseTransformPoint(this._tempLocalPos, worldPos);
                return this._tempLocalPos.clone();
            } else {
                // 親がいない場合はワールド座標とローカル座標が同じとみなす
                return worldPos.clone();
            }
        }
    }

    /**
     * 新しいポイントを追加し、maxPoints を超えている場合は古いものから削除する。
     */
    private _addPoint(pos: Vec3) {
        this._points.push({
            position: pos,
            age: 0,
        });

        if (this._points.length > this.maxPoints) {
            const overflow = this._points.length - this.maxPoints;
            this._points.splice(0, overflow);
        }
    }

    /**
     * ポイントの age を更新し、lifeTime を超えたものを削除する。
     */
    private _updatePoints(deltaTime: number) {
        if (this._points.length === 0) {
            return;
        }

        for (let i = 0; i < this._points.length; i++) {
            this._points[i].age += deltaTime;
        }

        // lifeTime を超えたポイントを前から削除
        while (this._points.length > 0 && this._points[0].age > this.lifeTime) {
            this._points.shift();
        }
    }

    /**
     * 現在の _points を Line2D の points / colors に反映する。
     */
    private _applyToLine2D() {
        if (!this._line2D) {
            return;
        }

        const count = this._points.length;
        if (count === 0) {
            this._line2D.points = [];
            this._line2D.colors = [];
            return;
        }

        const points: Vec3[] = new Array(count);
        const colors: Color[] = new Array(count);

        for (let i = 0; i < count; i++) {
            const p = this._points[i];
            points[i] = p.position.clone();

            const t = math.clamp01(p.age / this.lifeTime);
            const alphaFactor = this._evaluateFade(t);
            const c = new Color(
                this.startColor.r,
                this.startColor.g,
                this.startColor.b,
                Math.floor(255 * alphaFactor)
            );
            colors[i] = c;
        }

        this._line2D.points = points;
        this._line2D.colors = colors;
        this._line2D.width = this.startWidth;
        this._line2D.useWorldSpace = this.useWorldSpace;
    }

    /**
     * フェードモードに応じて 0~1 のフェード係数を返す。
     * t: 0(生成直後)~1(寿命末期)
     */
    private _evaluateFade(t: number): number {
        switch (this.fadeMode) {
            case TrailFadeMode.EASE_OUT:
                // 最後にスッと消える
                return 1.0 - t * t;
            case TrailFadeMode.EASE_IN:
                // 最初にすぐ薄くなる
                return 1.0 - math.sqrt(t);
            case TrailFadeMode.LINEAR:
            default:
                return 1.0 - t;
        }
    }

    /**
     * 外部から軌跡を即座にクリアしたい場合に呼び出せるヘルパー。
     */
    public clearTrail() {
        this._points.length = 0;
        this._applyToLine2D();
    }
}

コードの主要部分の解説

  • onLoad()
    • 同じノードにアタッチされた Line2D コンポーネントを getComponent(Line2D) で取得します。
    • 存在しない場合は console.error を出し、以降の処理は何もしません(防御的実装)。
    • Line2D の width / useWorldSpace / points / colors を初期化します。
  • start()
    • 開始時点のノード位置を取得し、最初の TrailPoint として登録します。
    • _applyToLine2D() を呼び出して、Line2D に 1 点だけの線を描画させます。
  • update(deltaTime)
    • enabledTrail が false の場合は、軌跡のリストをクリアして Line2D も空にします。
    • 現在のノードのワールド座標を取得し、useWorldSpace 設定に応じて Line2D 用の座標に変換します。
    • 前回の位置から minDistance 以上動いていたら新しいポイントを追加します。
    • 各ポイントの age を更新し、lifeTime を超えたポイントを削除します。
    • 最後に _applyToLine2D() で Line2D の points / colors を更新します。
  • _applyToLine2D()
    • 内部で保持している _points 配列を Line2D の points / colors に変換します。
    • 各ポイントに対して、経過時間 age から 0〜1 の t を計算し、_evaluateFade(t) でアルファ係数を求めます。
    • このアルファをもとに Color の a 値を決定し、時間とともに透明になっていく軌跡を表現します。
  • _evaluateFade(t)
    • enum TrailFadeMode に応じて、線形・EaseOut・EaseIn のフェードカーブを返します。
    • これにより、同じ lifeTime でも見た目の消え方を簡単に変えられます。
  • clearTrail()
    • 外部から呼び出して、軌跡を即座に消去したいときに使えるユーティリティです。
    • 例: 攻撃終了時に一度軌跡をリセットしたい場合など。

使用手順と動作確認

ここからは、エディタ上で TrailRenderer を実際に使うための具体的な手順を説明します。

1. スクリプトファイルの作成

  1. エディタの Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を TrailRenderer.ts に変更します。
  4. 作成された TrailRenderer.ts をダブルクリックして開き、本文で示したコード全体を貼り付けて保存します。

2. テスト用ノードと Line2D の準備

剣や弾の代わりに、まずは簡単なノードで動作確認をします。

  1. Hierarchy パネルで右クリック → Create → 3D Object → Sprite3D(2Dなら Node でも OK)など、適当なノードを作成します。
    ノード名を TrailTest などにしておくと分かりやすいです。
  2. 作成したノードを選択し、Inspector パネルの Add Component ボタンをクリックします。
  3. 検索欄に Line2D と入力し、Line2D コンポーネントを追加します。
  4. Line2D の設定(最初はデフォルトで OK):
    • Width: 0.05 ~ 0.1 くらい(後で TrailRenderer 側からも設定されます)。
    • Use World Space: とりあえず ON(TrailRenderer の useWorldSpace も ON に合わせます)。
    • Points / Colors: 空のままで問題ありません(TrailRenderer が自動で埋めます)。

3. TrailRenderer コンポーネントをアタッチ

  1. 同じく TrailTest ノードを選択した状態で、Inspector の Add Component ボタンをクリックします。
  2. Custom カテゴリ(または検索欄)から TrailRenderer を選択して追加します。
  3. Inspector に TrailRenderer の各プロパティが表示されることを確認します。

4. TrailRenderer のプロパティ設定例

まずは剣の軌跡風の設定例を示します。

  • Enabled Trail: チェック ON
  • Life Time: 0.35
  • Min Distance: 0.05
  • Max Points: 64
  • Start Width: 0.08
  • Start Color: やや青みがかった白(例: R=200, G=230, B=255)
  • Use World Space: チェック ON
  • Fade Mode: EASE_OUT(最後にスッと消える感じ)

これで、TrailTest ノードが動いた経路に、短くてキレのある軌跡が残るようになります。

5. シーン上での動作確認

動かないノードには軌跡が出ないので、何らかの方法でノードを動かします。簡単な確認方法を二つ紹介します。

方法A: エディタの「プレイ中にマウスでノードをドラッグ」して確認

  1. TrailTest ノードを選択した状態で、シーンビューに表示されていることを確認します。
  2. 上部の Play ボタンでゲームを再生します。
  3. ゲーム再生中に、シーンビューで TrailTest ノードをドラッグして動かしてみます。
    ノードの移動経路に沿って、白い線の軌跡が残り、少し時間が経つとフェードアウトして消えていくのが確認できるはずです。

方法B: 簡単な自動回転スクリプトを付けて確認

もっとはっきりした動きを見たい場合、TrailTest ノードに簡易的な回転スクリプトを付けて、円を描くように動かしてみます。

  1. Assets パネルで右クリック → Create → TypeScriptSimpleRotator.ts を作成します。
  2. 以下のような簡単なスクリプトを書きます(完全に任意です)。

import { _decorator, Component, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('SimpleRotator')
export class SimpleRotator extends Component {
    @property({ tooltip: '回転速度(度/秒)' })
    public speed: number = 180;

    private _angle: number = 0;
    private _radius: number = 1.0;
    private _center: Vec3 = new Vec3();

    start() {
        // 開始位置を中心にする
        this.node.getWorldPosition(this._center);
    }

    update(deltaTime: number) {
        this._angle += this.speed * deltaTime;
        const rad = this._angle * Math.PI / 180;
        const x = this._center.x + Math.cos(rad) * this._radius;
        const z = this._center.z + Math.sin(rad) * this._radius;
        this.node.setWorldPosition(x, this._center.y, z);
    }
}
  1. TrailTest ノードに SimpleRotator をアタッチし、speed = 360 程度に設定します。
  2. ゲームを再生すると、TrailTest ノードが円を描きながら動き、その軌跡として円形の残像ラインが描画されます。

このように、TrailRenderer 自体は位置の更新さえあればよく、動かし方は完全に自由です。剣スイングのアニメーションに追従させたり、弾の移動スクリプトと組み合わせたり、任意のノードにそのまま流用できます。

6. 弾丸への適用例(2D)

2D シューティングなどで弾に短い軌跡を付けたい場合の、典型的な設定例です。

  • Life Time: 0.15
  • Min Distance: 0.02
  • Max Points: 16
  • Start Width: 0.03
  • Start Color: やや黄色(例: R=255, G=240, B=180)
  • Fade Mode: LINEAR

弾のノードに Line2D + TrailRenderer をアタッチし、弾を高速で移動させると、短く鋭い光のラインが後ろに残るようになります。


まとめ

本記事では、Cocos Creator 3.8.7 + TypeScript で、Line2D を内部制御して滑らかな軌跡を描画する汎用 TrailRenderer コンポーネントを実装しました。

  • 外部の GameManager やシングルトンに依存せず、このスクリプト単体 + Line2D コンポーネントだけで完結する設計。
  • インスペクタから lifeTime / minDistance / maxPoints / startWidth / startColor / fadeMode を調整するだけで、剣・弾・ダッシュ・魔法エフェクトなど様々な軌跡表現に対応可能。
  • Line2D の存在チェックや、ポイント数制限など、防御的でパフォーマンスを意識した実装

一度 TrailRenderer をプロジェクトに入れておけば、「軌跡を付けたいノードに Line2D と TrailRenderer をアタッチするだけ」で、視覚的なクオリティを簡単に底上げできます。剣のスイング、ビームの残光、キャラクターのダッシュエフェクトなど、さまざまな場面で再利用してみてください。