【Cocos Creator 3.8】DopplerEffect の実装:アタッチするだけで「高速で通過する物体のドップラー効果(接近時に高音・離脱時に低音)」を実現する汎用スクリプト

このコンポーネントは、移動しているオブジェクトにアタッチするだけで、速度と進行方向に応じて AudioSource の再生ピッチを自動的に変化させ、ドップラー効果風のサウンド演出を行うための汎用スクリプトです。

車・弾丸・飛行機など、高速で通過するオブジェクトにセットすると、近づいてくるときは高い音、遠ざかるときは低い音になるため、スピード感や臨場感を簡単に追加できます。


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

1. 要件の整理

  • このコンポーネントは移動しているオブジェクト側(車、弾、飛行機など)にアタッチして使う。
  • 音源は AudioSource コンポーネントを利用し、ピッチ(pitch)をフレームごとに変更してドップラー効果を表現する。
  • 外部の「プレイヤー位置」や「カメラ位置」に依存せず、このノード自身の速度ベクトルのみから簡易的なドップラー効果を計算する。
  • 本格的な物理シミュレーションではなく、「速度が速く、進行方向に向かっているときは高音」「逆方向に動いているときは低音」という、ゲーム向けの簡易モデルとする。
  • 速度は Node.worldPosition の変化量から自前で計算し、Rigidbody などの存在は必須としない。
  • 再生する音はループ BGM でも SE でもよいが、基本はループ再生のエンジン音・飛行音などを想定する。

2. ドップラー効果の簡易モデル

本来のドップラー効果は「音源と観測者の相対速度のうち、観測者との距離方向成分」だけが効きますが、ここでは外部の観測者ノードを持たないため、次のような簡略モデルにします。

  • ノードの移動方向(速度ベクトルの正規化)と、「前方方向」(Transform の Z 軸 or X 軸) のなす角度から、「進行方向に向かっているか / 背を向けているか」を判定。
  • 「前方に進んでいる」ときは接近、「後方に進んでいる」ときは離脱とみなす。
  • 速度の大きさと方向から、ピッチ倍率を basePitch ± dopplerAmount の範囲で補正する。

このモデルは厳密な物理シミュレーションではありませんが、「前に突っ込んでくると高音」「通り過ぎて背を向けて走り去ると低音」というゲーム的なドップラー感を簡単に表現できます。

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

以下のようなプロパティを用意し、すべてインスペクタから調整可能にします。

  • audioSource (AudioSource)
    • このノード、または任意の子ノードにアタッチされた AudioSource を指定。
    • 未指定の場合は、onLoadthis.getComponent(AudioSource) を試みる。
  • autoPlayOnStart (boolean)
    • true の場合、start() で AudioSource を自動再生する。
    • すでに再生中なら何もしない。
  • basePitch (number)
    • ドップラー効果が 0 のときの基準ピッチ。
    • 通常は 1.0(原音の高さ)。
  • maxSpeedForEffect (number)
    • この速度以上では、ドップラー効果を最大として扱う。
    • 単位は「ワールド座標単位 / 秒」。
    • 例:20 など。
  • maxPitchShift (number)
    • ドップラー効果によるピッチ変化の最大量。
    • 例:0.4 の場合、basePitch ± 0.4 の範囲に収まる。
    • 接近時は basePitch + maxPitchShift、離脱時は basePitch - maxPitchShift 近くまで変化する。
  • forwardAxis (enum)
    • 「このオブジェクトの前方方向」をどの軸とみなすか。
    • +Z(3D オブジェクト)、+X(2D 横スクロール)などを選択可能。
  • smoothing (number)
    • ピッチ変化のスムージング係数(0〜1)。
    • 0 に近いほど動きがなめらか(遅い追従)、1 に近いほど即時反映。
    • 例:0.15
  • minPitch (number)
    • 安全のためのピッチ下限。
    • 例:0.3
  • maxPitch (number)
    • 安全のためのピッチ上限。
    • 例:3.0
  • debugLog (boolean)
    • 有効にすると、速度やピッチをログ出力して調整しやすくする。

これらにより、車・ジェット機・弾丸など、さまざまな速度感に合わせて調整できる汎用コンポーネントになります。


TypeScriptコードの実装

以下が完成した DopplerEffect.ts の全コードです。


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

/**
 * オブジェクトの移動速度と進行方向に応じて AudioSource.pitch を変化させ、
 * 簡易的なドップラー効果を表現するコンポーネント。
 *
 * このスクリプト単体で完結し、外部の GameManager やプレイヤーノードには依存しません。
 * 移動しているノードにアタッチし、AudioSource を設定するだけで動作します。
 */

enum ForwardAxis {
    POSITIVE_Z = 0,
    POSITIVE_X = 1,
}

Enum(ForwardAxis);

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

    @property({
        type: AudioSource,
        tooltip: 'ドップラー効果を適用する AudioSource。\n未指定の場合、このノードから自動取得を試みます。',
    })
    public audioSource: AudioSource | null = null;

    @property({
        tooltip: 'true の場合、start() 時に AudioSource を自動再生します。\nすでに再生中なら何もしません。',
    })
    public autoPlayOnStart: boolean = true;

    @property({
        tooltip: 'ドップラー効果が 0 のときの基準ピッチ(通常は 1.0)。',
    })
    public basePitch: number = 1.0;

    @property({
        tooltip: 'この速度(ワールド単位/秒)以上では、ドップラー効果を最大として扱います。\n速度が小さいときは効果が弱くなります。',
        min: 0.01,
    })
    public maxSpeedForEffect: number = 20.0;

    @property({
        tooltip: 'ドップラー効果による最大ピッチ変化量。\n例: 0.4 の場合、basePitch ± 0.4 の範囲で変化します。',
        min: 0.0,
    })
    public maxPitchShift: number = 0.4;

    @property({
        type: ForwardAxis,
        tooltip: 'このオブジェクトの「前方方向」とみなす軸。\n3D オブジェクトなら通常 POSITIVE_Z、2D 横スクロールなら POSITIVE_X など。',
    })
    public forwardAxis: ForwardAxis = ForwardAxis.POSITIVE_Z;

    @property({
        tooltip: 'ピッチ変化のスムージング係数(0〜1)。\n0 に近いほどなめらか/1 に近いほど即時反映。',
        min: 0.0,
        max: 1.0,
    })
    public smoothing: number = 0.15;

    @property({
        tooltip: '安全のためのピッチ下限値。',
        min: 0.01,
    })
    public minPitch: number = 0.3;

    @property({
        tooltip: '安全のためのピッチ上限値。',
        min: 0.01,
    })
    public maxPitch: number = 3.0;

    @property({
        tooltip: 'true にすると、速度やピッチの情報をコンソールにログ出力します。',
    })
    public debugLog: boolean = false;

    // 内部状態
    private _lastWorldPos: Vec3 = new Vec3();
    private _hasLastPos: boolean = false;
    private _currentPitch: number = 1.0;

    onLoad() {
        // AudioSource が未設定なら、このノードから取得を試みる
        if (!this.audioSource) {
            const found = this.getComponent(AudioSource);
            if (!found) {
                console.error(
                    '[DopplerEffect] AudioSource が設定されていません。このノード、または子ノードに AudioSource コンポーネントを追加し、インスペクタで割り当ててください。',
                    this.node.name
                );
            } else {
                this.audioSource = found;
            }
        }

        // 初期ピッチを basePitch に合わせる(AudioSource があれば)
        if (this.audioSource) {
            this.audioSource.pitch = this.basePitch;
        }
        this._currentPitch = this.basePitch;

        // 初期位置記録
        this.node.getWorldPosition(this._lastWorldPos);
        this._hasLastPos = true;
    }

    start() {
        if (!this.audioSource) {
            // onLoad でエラーを出しているので、ここでは何もしない
            return;
        }

        if (this.autoPlayOnStart && !this.audioSource.playing) {
            this.audioSource.play();
        }
    }

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

        if (deltaTime <= 0) {
            return;
        }

        // 速度ベクトルを計算(ワールド座標)
        const currentPos = new Vec3();
        this.node.getWorldPosition(currentPos);

        if (!this._hasLastPos) {
            this._lastWorldPos.set(currentPos);
            this._hasLastPos = true;
            return;
        }

        const velocity = new Vec3(
            (currentPos.x - this._lastWorldPos.x) / deltaTime,
            (currentPos.y - this._lastWorldPos.y) / deltaTime,
            (currentPos.z - this._lastWorldPos.z) / deltaTime,
        );

        // 次フレーム用に現在位置を保存
        this._lastWorldPos.set(currentPos);

        const speed = velocity.length();

        // 速度が非常に小さい場合は、ほぼ静止として basePitch に戻していく
        if (speed < 0.001) {
            this._lerpPitchTo(this.basePitch, deltaTime);
            return;
        }

        // 前方ベクトルを取得
        const forward = this._getForwardVector();

        // 速度ベクトルを正規化
        const velNorm = velocity.normalize();

        // 前方方向との内積(-1〜1)
        const dot = Vec3.dot(forward, velNorm);

        // dot > 0: 前方に向かって進んでいる(接近)とみなす
        // dot < 0: 後方に進んでいる(離脱)とみなす
        // 効果量を 0〜1 に正規化
        const dirFactor = math.clamp01(Math.abs(dot));

        // 速度の大きさに基づく効果量(0〜1)
        const speedFactor = math.clamp01(speed / this.maxSpeedForEffect);

        // 総合的な効果の強さ
        const effectStrength = dirFactor * speedFactor;

        // 接近か離脱かで符号を変える
        // dot > 0: 接近 → ピッチを上げる(+)、dot < 0: 離脱 → ピッチを下げる(-)
        const sign = dot >= 0 ? 1 : -1;

        // 目標ピッチを計算
        const pitchOffset = this.maxPitchShift * effectStrength * sign;
        let targetPitch = this.basePitch + pitchOffset;

        // 安全範囲にクランプ
        targetPitch = math.clamp(targetPitch, this.minPitch, this.maxPitch);

        // 現在のピッチからスムージングして追従
        this._lerpPitchTo(targetPitch, deltaTime);

        if (this.debugLog) {
            console.log(
                `[DopplerEffect] node=${this.node.name}, speed=${speed.toFixed(2)}, dot=${dot.toFixed(
                    2
                )}, effect=${effectStrength.toFixed(2)}, targetPitch=${targetPitch.toFixed(
                    2
                )}, currentPitch=${this._currentPitch.toFixed(2)}`
            );
        }
    }

    /**
     * 前方ベクトルを取得する。
     * forwardAxis 設定に応じて、ノードのローカル軸をワールド空間に変換する。
     */
    private _getForwardVector(): Vec3 {
        const q = this.node.worldRotation;
        const result = new Vec3();

        switch (this.forwardAxis) {
            case ForwardAxis.POSITIVE_Z:
                // (0, 0, 1) をワールド空間へ回転
                Vec3.transformQuat(result, Vec3.FORWARD, q);
                break;
            case ForwardAxis.POSITIVE_X:
                // (1, 0, 0) をワールド空間へ回転
                Vec3.transformQuat(result, Vec3.RIGHT, q);
                break;
            default:
                Vec3.transformQuat(result, Vec3.FORWARD, q);
                break;
        }

        result.normalize();
        return result;
    }

    /**
     * 現在のピッチを target へスムージングしながら近づける。
     */
    private _lerpPitchTo(target: number, deltaTime: number) {
        // smoothing は 0〜1 を想定。deltaTime を掛けることでフレームレート差を少し緩和。
        const t = math.clamp01(this.smoothing * (deltaTime * 60)); // 60fps 基準
        this._currentPitch = math.lerp(this._currentPitch, target, t);

        if (this.audioSource) {
            this.audioSource.pitch = this._currentPitch;
        }
    }
}

コードのポイント解説

  • onLoad()
    • audioSource が未指定なら this.getComponent(AudioSource) で自動取得。
    • 見つからない場合は console.error で明示的にエラーを出し、エディタで AudioSource を追加する必要があることを伝える。
    • 初期ピッチを basePitch に設定し、初期位置を記録する。
  • start()
    • autoPlayOnStarttrue かつ audioSource.playingfalse の場合のみ、自動再生。
    • 外部から再生を制御したい場合は autoPlayOnStart = false にすればよい。
  • update(deltaTime)
    • 毎フレーム、ワールド座標の変化量から速度ベクトルを計算。
    • 速度ベクトルの大きさ(speed)と、ノードの「前方ベクトル」との内積(dot)から、接近 or 離脱と効果の強さを決定。
    • maxSpeedForEffect を上限として 0〜1 に正規化し、maxPitchShift と組み合わせて targetPitch を算出。
    • _lerpPitchTo() でスムージングしながら現在のピッチを更新し、audioSource.pitch に反映。
    • debugLogtrue のときは、速度やピッチをログ出力してチューニングしやすくする。
  • _getForwardVector()
    • forwardAxis に応じて Vec3.FORWARD (0,0,1) または Vec3.RIGHT (1,0,0) を、ノードの worldRotation で回転してワールド空間の前方ベクトルを求める。
    • これにより、3D でも 2D でも、「このオブジェクトの前がどっちか」を柔軟に設定できる。
  • _lerpPitchTo()
    • math.lerp で現在ピッチと目標ピッチの間を補間し、急激なピッチ変化を避ける
    • smoothingdeltaTime を掛け合わせて、フレームレートに依存しすぎない追従速度に調整。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を DopplerEffect.ts に変更します。
  3. 作成された DopplerEffect.ts をダブルクリックして開き、中身をすべて削除してから、前述のコードを貼り付けて保存します。

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

ここでは 2D の車オブジェクトを例に説明しますが、3D でも手順はほぼ同じです。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を Car などに変更します。
  2. Car ノードを選択し、Inspector の Add Component ボタンをクリックします。
  3. Audio → AudioSource を選択して追加します。
  4. 追加された AudioSource コンポーネントで、以下を設定します。
    • Clip: エンジン音や風切り音など、ループ再生したい音を指定。
    • Loop: チェックを入れる(ループ再生)。
    • Play On Awake: オフでも可(今回は DopplerEffect の autoPlayOnStart で制御できます)。
    • Volume: 適切な音量に調整。

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

  1. Car ノードを選択した状態で、Inspector の Add Component をクリックします。
  2. Custom → DopplerEffect を選択して追加します。
  3. Inspector に表示される DopplerEffect のプロパティを設定します。
    • Audio Source:
      • 自動で Car の AudioSource が割り当てられていればそのままで OK。
      • 割り当てられていない場合は、ドラッグ&ドロップで Car の AudioSource を指定します。
    • Auto Play On Start: とりあえず true にしておくと動作確認が簡単です。
    • Base Pitch: 1.0 のままで OK(原音)。
    • Max Speed For Effect: 例えば 20 に設定。
    • Max Pitch Shift: 例えば 0.4 に設定(接近時 1.4、離脱時 0.6 付近まで変化)。
    • Forward Axis: 2D 横スクロールなら POSITIVE_X を選択。
    • Smoothing: 0.15 前後(好みで調整)。
    • Min Pitch: 0.3
    • Max Pitch: 3.0
    • Debug Log: 調整時は true にしてログを見てもよいです(普段は false 推奨)。

4. ノードを動かす簡単な方法(確認用)

ドップラー効果は速度に依存するので、ノードが動いていないと何も起きません。ここでは簡単に動かすための方法を 2 つ紹介します。

方法 A: Tween で往復移動させる

  1. Car ノードに、簡単な移動スクリプトを追加します(このスクリプトはあくまでテスト用であり、DopplerEffect 自体は単独で完結しています)。
  2. Assets で Create → TypeScriptCarMover.ts を作成し、以下のようなコードを貼り付けます。
    
    import { _decorator, Component, Node, tween, Vec3 } from 'cc';
    const { ccclass, property } = _decorator;
    
    @ccclass('CarMover')
    export class CarMover extends Component {
        @property
        public distance: number = 30;
    
        @property
        public duration: number = 2;
    
        start() {
            const startPos = this.node.position.clone();
            const endPos = new Vec3(startPos.x + this.distance, startPos.y, startPos.z);
    
            tween(this.node)
                .repeatForever(
                    tween()
                        .to(this.duration, { position: endPos })
                        .to(this.duration, { position: startPos })
                )
                .start();
        }
    }
    
  3. Car ノードを選択し、Add Component → Custom → CarMover を追加します。
  4. Game ビューで再生すると、Car が左右に往復し、それに合わせて音程が高くなったり低くなったりするのを確認できます。

方法 B: キーボード入力で動かす(2D 横移動)

よりゲームに近い動作を見たい場合は、簡単な入力スクリプトで左右移動させても構いません。ここではコードは省略しますが、重要なのは「ノードの位置が変化している」ことだけです。DopplerEffect は位置変化から速度を自動計算します。

5. 調整のコツ

  • 効果が弱いと感じる場合
    • Max Pitch Shift0.6〜0.8 などに上げてみる。
    • Max Speed For Effect を下げる(例:10)と、低速でも大きく変化するようになる。
  • ピッチ変化が急すぎて不自然な場合
    • Smoothing0.05〜0.1 など、より小さな値にする。
  • 音が高すぎる / 低すぎる場合
    • Min PitchMax Pitch の範囲を狭める。
    • あるいは Base Pitch 自体を 0.8 や 1.2 などに調整する。
  • 2D か 3D かで前方方向が逆に感じる場合
    • Forward Axis の設定を確認し、POSITIVE_X / POSITIVE_Z を切り替えてみる。
    • モデルの向きによっては、Transform の回転を 180 度回すなどで調整する必要がある。

まとめ

この DopplerEffect コンポーネントは、

  • AudioSource さえあれば他に何もいらない、完全に独立した汎用スクリプトです。
  • ノードの位置変化から速度と進行方向を自動的に算出し、接近時は高音・離脱時は低音というドップラー効果風の演出を行います。
  • インスペクタから maxSpeedForEffectmaxPitchShiftsmoothing などを調整することで、車・ジェット機・弾丸・ミサイルなど、さまざまなオブジェクトに簡単に使い回せます。

特に、

  • レースゲームのエンジン音
  • シューティングゲームの敵機や弾の通過音
  • 飛行機やミサイルが頭上をかすめる演出

などでは、「とりあえず AudioSource にアタッチするだけ」で臨場感を一段階引き上げることができます。

ゲームの規模が大きくなっても、外部マネージャやシングルトンに依存しないため、別プロジェクトへの持ち込みやチーム内での共有も容易です。まずはシンプルなシーンで動作を確認し、プロジェクトのサウンド演出に組み込んでみてください。