【Cocos Creator 3.8】SpatialAudio の実装:アタッチするだけで 3D 的な距離減衰&左右パンを自動制御する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで音源とリスナーの距離・方向に応じて音量と左右パン(ステレオバランス)を自動調整するSpatialAudio」コンポーネントを実装します。

2D/3Dゲームで「近づくと音が大きく、右から来る音は右から聞こえる」という演出を、他のカスタムスクリプトに一切依存せず、このコンポーネント単体で完結させます。シーン内のオブジェクトにアタッチして、インスペクタでパラメータを調整するだけで運用できる設計です。


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

1. 機能要件の整理

  • このコンポーネントがアタッチされたノードを「音源」とみなす。
  • リスナー」となるノードをインスペクタで指定し、そのノードとの距離・相対方向に応じて下記を自動制御する:
    • 音量(距離減衰)
    • 左右パン(ステレオバランス)
  • 外部ゲームマネージャやシングルトンには依存しない
    • リスナーは @property(Node) で指定。
    • サウンド再生は 同じノードの AudioSource コンポーネントを使用。
  • 防御的実装:
    • AudioSource が見つからない場合は error ログを出し、自動制御を停止。
    • リスナーが未設定の場合は warn ログを出し、自動制御を停止。

2. 3D 風オーディオの簡易モデル

Cocos Creator 3.8 の標準 AudioSource は 3D 空間オーディオの詳細制御を直接は持たないため、ここでは 「擬似 3D」 として以下を行います:

  • 距離減衰:
    • 距離 dminDistance 以下なら volume = 1.0(最大)。
    • 距離 dmaxDistance 以上なら volume = 0.0(無音)。
    • その間は、線形またはべき乗(ロールオフ)で減衰。
  • 左右パン(ステレオバランス):
    • 音源とリスナーの相対位置から、スクリーン右方向を +X として、左右の差を計算。
    • 左右パン値 pan-1.0(完全左)〜 +1.0(完全右) で算出。
    • 標準 AudioSource にパン API がないため、左右用の AudioSource を 1 つのノードに 2 つアタッチして、leftVolume, rightVolume を操作する方式を採用。
      • 左チャンネル: audioSourceLeft.volume
      • 右チャンネル: audioSourceRight.volume

※ 1 つの AudioSource だけでパンを制御する公式 API は現状ないため、「左用・右用の AudioSource を 2 つ用意する」という現実的な設計にしています。

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

以下のプロパティを @property で定義します。

  • listenerNode: Node | null
    • 説明: 音を「聴く側」のノード。通常は「カメラ」や「プレイヤー」など。
    • 未設定の場合は、シーンのメインカメラを自動検出しようと試みる(見つからなければ警告)。
  • minDistance: number
    • 説明: この距離以内では常に最大音量(1.0)。
    • 例: 1.0
  • maxDistance: number
    • 説明: この距離以上では無音(0.0)。
    • 例: 20.0
  • rolloffFactor: number
    • 説明: 距離減衰カーブの「きつさ」を決める係数。
    • 1.0 で線形、2.0 以上でより急激に減衰。
  • maxPanAngle: number
    • 説明: 最大パンを適用する左右方向の角度(度)。
    • 例: 60度なら、リスナーの正面から ±60 度以上で完全左/右になる。
  • panStrength: number
    • 説明: パンの強さ。0〜1。
    • 0 なら常にセンター、1 ならフルパン。
  • use2DAxis: boolean
    • 説明: 2D ゲーム用に、Y 軸を無視して XZ 平面(または XY 平面)だけで計算するかどうか。
    • オンにすると、主に横スクロールなどで扱いやすい。
  • autoPlayOnLoad: boolean
    • 説明: start() 時に、左右の AudioSource を自動再生するかどうか。
  • debugLog: boolean
    • 説明: コンポーネントの初期化状況をログ出力するかどうか。

4. 必要な標準コンポーネント

このコンポーネントが動作するには、同じノードに以下のコンポーネントが必要です:

  • AudioSource(左チャンネル用)
  • AudioSource(右チャンネル用)

防御的実装として、コード内で getComponents(AudioSource) で取得を試み、2 つ未満の場合は error ログを出して動作を停止します。エディタでの追加手順は「使用手順と動作確認」で詳述します。


TypeScriptコードの実装

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


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

/**
 * SpatialAudio
 * - 距離に応じた音量減衰
 * - リスナーとの相対方向に応じた左右パン
 * - 左右用 AudioSource を 2 つ使用してステレオ制御
 */
@ccclass('SpatialAudio')
export class SpatialAudio extends Component {

    @property({
        type: Node,
        tooltip: 'リスナー(聞き手)となるノード。\n通常はカメラやプレイヤーを指定します。\n未設定の場合、シーン内のメインカメラを自動検出しようとします。'
    })
    public listenerNode: Node | null = null;

    @property({
        tooltip: 'この距離以内では常に最大音量(1.0)になります。'
    })
    public minDistance: number = 1.0;

    @property({
        tooltip: 'この距離以上では無音(0.0)になります。'
    })
    public maxDistance: number = 20.0;

    @property({
        tooltip: '距離減衰のきつさを決める係数。\n1.0で線形、2.0以上で急激に減衰します。'
    })
    public rolloffFactor: number = 1.0;

    @property({
        tooltip: '最大パンを適用する左右方向の角度(度)。\nこの角度以上で完全左/右になります。'
    })
    public maxPanAngle: number = 60.0;

    @property({
        tooltip: 'パンの強さ(0〜1)。\n0で常にセンター、1でフルパンになります。'
    })
    public panStrength: number = 1.0;

    @property({
        tooltip: '2Dゲーム向けの簡易モード。\nオンにすると水平方向のみ(X軸)を利用してパンを計算します。'
    })
    public use2DAxis: boolean = true;

    @property({
        tooltip: 'start()時に左右のAudioSourceを自動再生します。'
    })
    public autoPlayOnLoad: boolean = false;

    @property({
        tooltip: '初期化状況などのデバッグログを出力します。'
    })
    public debugLog: boolean = false;

    // 内部参照: 左右のAudioSource
    private _audioLeft: AudioSource | null = null;
    private _audioRight: AudioSource | null = null;

    private _tempVecSource: Vec3 = new Vec3();
    private _tempVecListener: Vec3 = new Vec3();
    private _tempDir: Vec3 = new Vec3();

    onLoad() {
        // AudioSource の取得(同じノードに2つ必要)
        const audios = this.getComponents(AudioSource);
        if (audios.length < 2) {
            console.error(
                '[SpatialAudio] 同じノードに AudioSource コンポーネントが2つ必要です。\n' +
                '例: 左右用として2つ追加し、同じAudioClipを設定してください。'
            );
            return;
        }

        // 左右を決める(インデックス0を左、1を右とする)
        this._audioLeft = audios[0];
        this._audioRight = audios[1];

        if (this.debugLog) {
            console.log('[SpatialAudio] AudioSource を2つ検出しました。左=0, 右=1');
        }

        // リスナーが未設定ならメインカメラを探す
        if (!this.listenerNode) {
            const scene = director.getScene();
            if (scene) {
                const cameras = scene.getComponentsInChildren(Camera);
                if (cameras.length > 0) {
                    this.listenerNode = cameras[0].node;
                    if (this.debugLog) {
                        console.log('[SpatialAudio] listenerNode が未設定だったため、シーン内のカメラを自動設定しました:', this.listenerNode.name);
                    }
                } else {
                    console.warn('[SpatialAudio] listenerNode が未設定で、シーン内に Camera も見つかりませんでした。');
                }
            }
        }

        // パラメータの安全チェック
        if (this.minDistance < 0) {
            this.minDistance = 0;
        }
        if (this.maxDistance <= this.minDistance) {
            this.maxDistance = this.minDistance + 0.01;
        }
        this.panStrength = math.clamp01(this.panStrength);
        if (this.maxPanAngle <= 0) {
            this.maxPanAngle = 1.0;
        }
    }

    start() {
        // AudioSource が正しく取得できていない場合は何もしない
        if (!this._audioLeft || !this._audioRight) {
            return;
        }

        // 自動再生オプション
        if (this.autoPlayOnLoad) {
            if (!this._audioLeft.playing) {
                this._audioLeft.play();
            }
            if (!this._audioRight.playing) {
                this._audioRight.play();
            }
        }
    }

    update(deltaTime: number) {
        // 必要な参照が揃っていなければ処理しない
        if (!this.listenerNode || !this._audioLeft || !this._audioRight) {
            return;
        }

        // 位置の取得(ワールド座標)
        this.node.getWorldPosition(this._tempVecSource);
        this.listenerNode.getWorldPosition(this._tempVecListener);

        // リスナーから見た音源へのベクトル
        Vec3.subtract(this._tempDir, this._tempVecSource, this._tempVecListener);

        // 距離
        const distance = this._tempDir.length();

        // 音量(距離減衰)の計算
        const baseVolume = this._calculateDistanceVolume(distance);

        // パンの計算
        const pan = this._calculatePan(this._tempDir);

        // パン -1〜+1 を左右ボリュームに変換
        // pan = -1 (左) => left=1, right=0
        // pan =  0 (中央) => left=1, right=1
        // pan = +1 (右) => left=0, right=1
        const leftPan = math.clamp01(1.0 - math.clamp01((pan + 1.0) * 0.5) * 2.0);
        const rightPan = math.clamp01(1.0 - math.clamp01((1.0 - pan) * 0.5) * 2.0);

        // 最終的な左右の音量
        const leftVolume = baseVolume * (1.0 - this.panStrength + this.panStrength * leftPan);
        const rightVolume = baseVolume * (1.0 - this.panStrength + this.panStrength * rightPan);

        this._audioLeft.volume = math.clamp01(leftVolume);
        this._audioRight.volume = math.clamp01(rightVolume);
    }

    /**
     * 距離に応じた音量(0〜1)を計算
     */
    private _calculateDistanceVolume(distance: number): number {
        if (distance <= this.minDistance) {
            return 1.0;
        }
        if (distance >= this.maxDistance) {
            return 0.0;
        }

        const t = (distance - this.minDistance) / (this.maxDistance - this.minDistance);
        // rolloffFactor によるカーブ調整
        if (this.rolloffFactor === 1.0) {
            // 線形
            return 1.0 - t;
        } else {
            // t^rolloffFactor で急激な減衰
            const v = Math.pow(1.0 - t, this.rolloffFactor);
            return math.clamp01(v);
        }
    }

    /**
     * 相対方向からパン値(-1〜+1)を計算
     * use2DAxis が true の場合は X 軸だけを利用した簡易パン。
     */
    private _calculatePan(dir: Vec3): number {
        if (this.use2DAxis) {
            // 2D モード: X 軸方向のみからパンを計算
            // dir.x が正: 右, 負: 左
            const maxPanDistance = this.maxDistance > 0 ? this.maxDistance : 1.0;
            const normalizedX = math.clamp(dir.x / maxPanDistance, -1.0, 1.0);
            return normalizedX;
        } else {
            // 3D モード: リスナーの前方向と左右方向から角度ベースでパンを計算
            // ここでは簡易的に、ワールドの +Z を前、+X を右とみなす
            const forward = new Vec3(0, 0, 1);
            const right = new Vec3(1, 0, 0);

            const dirNorm = dir.normalize();
            const angleRight = Vec3.dot(dirNorm, right);   // -1 (左) 〜 +1 (右)
            const angleForward = Vec3.dot(dirNorm, forward); // -1 (後) 〜 +1 (前)

            // angleRight を角度(度)に換算
            const rad = Math.acos(math.clamp(angleRight, -1.0, 1.0));
            const deg = math.toDegree(rad);

            // maxPanAngle 度以上でフルパン
            let pan = math.clamp(angleRight, -1.0, 1.0);

            // 前後方向による影響を少し抑える(後ろ側はパンを弱めるなど)
            const forwardFactor = math.clamp01((angleForward + 1.0) * 0.5); // -1〜+1 => 0〜1
            pan *= forwardFactor;

            // maxPanAngle によるスケーリング(大きな角度だけパンを強くする)
            const angleScale = math.clamp01(deg / this.maxPanAngle);
            pan *= angleScale;

            return math.clamp(pan, -1.0, 1.0);
        }
    }
}

コードのポイント解説

  • onLoad()
    • getComponents(AudioSource) で同じノードにアタッチされた AudioSource をすべて取得し、2 つ以上あるかチェック
    • 2 つ未満なら console.error を出して、その後の処理は事実上無効になる(_audioLeft/_audioRight が null)。
    • listenerNode が未設定なら、シーン内から Camera を検索し、最初に見つかったものをリスナーに設定。
    • minDistance, maxDistance, panStrength, maxPanAngle などの値を安全な範囲にクリップ。
  • start()
    • 左右の AudioSource が揃っていない場合は何もしない。
    • autoPlayOnLoadtrue のとき、再生中でなければ play() を呼ぶ
  • update(deltaTime)
    • listenerNode と左右の AudioSource がそろっていなければ何もしない。
    • 音源ノードとリスナーノードのワールド座標を取得し、距離相対ベクトルを計算。
    • _calculateDistanceVolume() で距離に応じた音量(0〜1)を計算。
    • _calculatePan() でパン値(-1〜+1)を計算。
    • パン値から左右のボリューム係数を算出し、baseVolumepanStrength を掛け合わせて audioLeft.volume, audioRight.volume に反映。
  • _calculateDistanceVolume()
    • minDistance 以内で 1.0、maxDistance 以上で 0.0。
    • その間は rolloffFactor に応じて線形またはべき乗で減衰。
  • _calculatePan()
    • use2DAxis = true の場合:
      • 単純に dir.x(X 軸)からパンを計算する簡易 2D モード。
      • 横スクロールゲームなどでの使用を想定。
    • use2DAxis = false の場合:
      • ワールドの +Z を前、+X を右として、dot を利用した簡易 3D パン。
      • 前後方向の成分 angleForward によってパンを弱めることで、後ろにいる音はパンを抑える。
      • maxPanAngle によって、どのくらいの角度でフルパンにするか調整。

使用手順と動作確認

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

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

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

  1. Hierarchy パネルで右クリックし、Create > 3D Object > Node などでテスト用のノードを作成します。
    • 名前例: TestSpatialAudio
  2. TestSpatialAudio ノードを選択し、Inspector で以下の操作を行います:
    1. Add Component > Audio > AudioSource を選択し、1つ目の AudioSource を追加します。
    2. 同じ手順でもう一度 AudioSource を追加し、合計2つの AudioSource がアタッチされるようにします。
    3. 2つの AudioSource の AudioClip に、同じサウンドファイル(BGM や環境音など)を設定します。
    4. 2つとも Loop をオンにしておくと、テストしやすくなります。

※ AudioSource が 1 つしかないと、onLoad() でエラーログが出てパン制御が動作しません。

3. SpatialAudio コンポーネントのアタッチ

  1. TestSpatialAudio ノードを選択した状態で、Inspector の Add Component ボタンをクリックします。
  2. Custom Component(または Custom カテゴリ)から SpatialAudio を選択して追加します。

4. リスナー(聞き手)ノードの準備

もっとも簡単な方法は、カメラをリスナーとして使うことです。

  1. Hierarchy から Main Camera などのカメラノードを確認します。
  2. もしカメラがない場合は、Create > 3D Object > Camera などでカメラを作成します。
  3. TestSpatialAudio ノードを選択し、SpatialAudio コンポーネントの listenerNode プロパティに、カメラノードをドラッグ&ドロップします。
    • 未設定のままでも、シーン内にカメラが 1 つあれば自動検出されますが、明示的に設定しておくことを推奨します。

5. プロパティの設定例

TestSpatialAudio ノードの SpatialAudio コンポーネントで、以下のように設定してみてください:

  • listenerNode: Main Camera
  • minDistance: 1.0
  • maxDistance: 20.0
  • rolloffFactor: 1.5
  • maxPanAngle: 60.0
  • panStrength: 1.0
  • use2DAxis: チェック(2D横スクロール風のテストをするなら ON)
  • autoPlayOnLoad: チェック(ON)
  • debugLog: 必要に応じてチェック

6. 実際に動かしてみる

  1. エディタ右上の Play ボタンを押してゲームを再生します。
  2. シーンビューまたはゲームビューで、TestSpatialAudio ノードを選択し、移動ツールで X 軸方向に動かしてみます。
    • カメラ(listenerNode)から見て右側へ動かすと、右耳側の音量が大きくなり、左側へ動かすと 左耳側の音量が大きくなります。
  3. カメラから遠ざけたり近づけたりすると、距離に応じて音量が変化するのが確認できます。
  4. use2DAxis を OFF にして 3D モードに切り替えた場合は、Z 軸方向(前後)も含めてパンの変化が異なる挙動になるので試してみてください。

7. よくあるつまずきポイント

  • 音が聞こえない
    • 左右の AudioSourceAudioClip が設定されているか確認。
    • autoPlayOnLoad を OFF にしている場合は、どこかで play() を呼んでいるか確認。
    • maxDistance が極端に小さくないか、minDistance との関係がおかしくないか確認。
  • パンが効いていない気がする
    • panStrength が 0 になっていないか確認。
    • use2DAxis が ON のときは、X 軸方向に動かしているか確認。
    • 距離が maxDistance 近くになると音量自体が小さくなり、パンの違いが分かりづらくなります。
  • コンソールにエラーが出ている
    • [SpatialAudio] 同じノードに AudioSource コンポーネントが2つ必要です。 と出ている場合は、AudioSource が 1 つしかありません。
      • Inspector で AudioSource を2つ追加しているか確認してください。

まとめ

この「SpatialAudio」コンポーネントは、

  • 同じノードに AudioSource を2つアタッチし、
  • リスナーとなるノードを指定するだけで、
  • 距離に応じた音量減衰方向に応じた左右パンを自動的に行う、完全独立・再利用可能なスクリプトです。

ゲーム全体のオーディオ管理クラスやシングルトンに依存せず、必要なノードにだけアタッチして使えるので、

  • 敵キャラクターごとに足音や攻撃音を空間的に鳴らす
  • 環境音(滝、焚き火、街の喧騒など)を配置して距離感を出す
  • 2D アクションで、画面外から近づいてくる敵の方向を音で知らせる

といった演出を、シンプルな設定だけで実現できます。

プロジェクト内で一度 SpatialAudio.ts を用意しておけば、今後は「ノードにアタッチして AudioSource を2つ置く」だけで、どのシーン・どのゲームでも同じ空間オーディオ表現を再利用できます。さらに、距離減衰カーブやパンの強さをインスペクタから調整できるため、ゲームの雰囲気に合わせたチューニングも容易です。

このコンポーネントをベースに、遮蔽物による減衰高度差によるフィルタリングなどを追加していけば、より本格的な 3D サウンドシステムにも発展させられます。まずは本記事の実装をそのまま試し、プロジェクトに合わせてアレンジしてみてください。