【Cocos Creator 3.8】AudioOcclusion の実装:アタッチするだけで「壁越しの音量減衰(遮蔽音)」を実現する汎用スクリプト

このガイドでは、音源とリスナー(プレイヤー)との間に壁などの障害物があるとき、RayCast で検知して自動的に音量を下げる「AudioOcclusion」コンポーネントを実装します。

任意の音源ノード(AudioSource を持つノード)にこのコンポーネントをアタッチし、インスペクタでリスナーや遮蔽時の音量などを設定するだけで、3D/2D問わず「壁越しにこもって聞こえる」効果を簡単に導入できます。


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

機能要件の整理

  • このコンポーネントは「音源側」にアタッチする。
  • 音源ノードは AudioSource コンポーネントを持っていることが前提。
  • リスナー(プレイヤー)ノードをインスペクタから指定できる。
  • 毎フレーム(または一定間隔)で、音源 → リスナーへ RayCast を飛ばし、間にコライダーがあれば「遮蔽されている」と判定。
  • 遮蔽されている場合は音量を 指定した遮蔽音量まで下げる。
  • 遮蔽されていない場合は音量を 元の音量(基準音量)に戻す。
  • 音量の変化は、急激に変わらないように スムージング(補間)できるようにする。
  • 2D/3D両対応とするため、PhysicsSystem / PhysicsSystem2D のどちらを使うかを選択可能にする。
  • 「どのレイヤーのコライダーを遮蔽として扱うか」を ビットマスクで設定可能にする。
  • このスクリプト単体で完結し、他のカスタムスクリプトには依存しない。

使用する主な標準コンポーネント・クラス

  • AudioSource:音量制御の対象。
  • Node, Vec3:位置取得と方向ベクトルの計算。
  • PhysicsSystem(3D)、PhysicsSystem2D(2D)と geometry.Ray(3D)または PhysicsRayResult2D など。

防御的実装として、必要なコンポーネントが存在しない場合は onLoadconsole.error を出力し、以降の処理は安全にスキップします。

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

AudioOcclusion コンポーネントに定義する @property は以下の通りです。

  • listener (Node)
    • 説明: 音を聞く側(プレイヤーなど)のノード。
    • 役割: このノードの位置と音源の位置を結ぶ線上に RayCast を飛ばします。
    • 注意: 未設定の場合は遮蔽判定を行わず、エラーログを出して処理をスキップします。
  • use2DPhysics (boolean)
    • 説明: 2D 物理を使うかどうか。
    • true: PhysicsSystem2D による RayCast。
    • false: PhysicsSystem(3D)による RayCast。
  • occludedVolume (number)
    • 説明: 遮蔽されているときの目標音量(0.0〜1.0)。
    • 例: 0.2 にすると、壁越しのときの音量が 20% になる。
  • normalVolume (number)
    • 説明: 遮蔽されていないときの目標音量(0.0〜1.0)。
    • 通常は 1.0(フルボリューム)を指定。
    • AudioSource の初期 volume と同期しておくと自然。
  • volumeLerpSpeed (number)
    • 説明: 現在の音量を目標音量に近づける速度(1秒あたりの補間係数のイメージ)。
    • 例: 5〜10 くらいにすると、0.1〜0.2秒程度でスムーズに変化。
    • 0 以下の場合は即時切り替え(補間なし)。
  • raycastInterval (number)
    • 説明: RayCast を実行する間隔(秒)。
    • 例: 0.05〜0.2 くらいにするとパフォーマンスと精度のバランスが良い。
    • 0 以下の場合は毎フレーム RayCast を実行。
  • maxRayDistance (number)
    • 説明: RayCast の最大距離。
    • 0 以下の場合は「音源とリスナーの距離」を自動で使用。
  • occlusionLayerMask3D (number)
    • 説明: 3D 物理で遮蔽として扱うレイヤーのビットマスク。
    • 例: デフォルトの 0xffffffff なら全レイヤーが遮蔽対象。
    • 3D のときのみ使用。
  • occlusionLayerMask2D (number)
    • 説明: 2D 物理で遮蔽として扱うレイヤーのビットマスク。
    • 2D のときのみ使用。
  • debugLog (boolean)
    • 説明: 遮蔽状態の変化時にログを出すかどうか。
    • デバッグ用。リリースでは OFF 推奨。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    AudioSource,
    Vec3,
    geometry,
    PhysicsSystem,
    PhysicsSystem2D,
    PhysicsRayResult2D,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * AudioOcclusion
 * 音源とリスナーの間にコライダーがある場合、RayCast で検知して音量を下げるコンポーネント。
 * - 音源ノードに AudioSource を持たせ、このコンポーネントを追加して使用します。
 * - リスナー(プレイヤー)ノードを inspector から指定してください。
 */
@ccclass('AudioOcclusion')
export class AudioOcclusion extends Component {

    @property({
        type: Node,
        tooltip: '音を聞く側(プレイヤーなど)のノード。\nこのノードと音源の間にコライダーがあると遮蔽と判定します。',
    })
    public listener: Node | null = null;

    @property({
        tooltip: 'true: 2D 物理 (PhysicsSystem2D) を使用。\nfalse: 3D 物理 (PhysicsSystem) を使用。',
    })
    public use2DPhysics: boolean = false;

    @property({
        tooltip: '遮蔽されているときの目標音量 (0.0〜1.0)。\n壁越しにどれくらい音が聞こえるかを設定します。',
        min: 0.0,
        max: 1.0,
        step: 0.01,
    })
    public occludedVolume: number = 0.2;

    @property({
        tooltip: '遮蔽されていないときの目標音量 (0.0〜1.0)。\n通常は 1.0 を指定します。',
        min: 0.0,
        max: 1.0,
        step: 0.01,
    })
    public normalVolume: number = 1.0;

    @property({
        tooltip: '音量を目標値へ補間する速度。\n値が大きいほどすばやく変化します。\n0 以下の場合は即時切り替え。',
        min: 0.0,
        step: 0.1,
    })
    public volumeLerpSpeed: number = 8.0;

    @property({
        tooltip: 'RayCast を実行する間隔(秒)。\n0 以下の場合は毎フレーム RayCast を実行します。',
        min: 0.0,
        step: 0.01,
    })
    public raycastInterval: number = 0.05;

    @property({
        tooltip: 'RayCast の最大距離。\n0 以下の場合は音源とリスナーの距離を自動で使用します。',
        min: 0.0,
        step: 0.1,
    })
    public maxRayDistance: number = 0.0;

    @property({
        tooltip: '3D 物理で遮蔽として扱うレイヤーのビットマスク。\n0xffffffff なら全レイヤーが対象です。',
    })
    public occlusionLayerMask3D: number = 0xffffffff;

    @property({
        tooltip: '2D 物理で遮蔽として扱うレイヤーのビットマスク。\n0xffffffff なら全レイヤーが対象です。',
    })
    public occlusionLayerMask2D: number = 0xffffffff;

    @property({
        tooltip: '遮蔽状態が変化したときにログを出力するかどうか(デバッグ用)。',
    })
    public debugLog: boolean = false;

    private _audioSource: AudioSource | null = null;
    private _currentTargetVolume: number = 1.0;
    private _isOccluded: boolean = false;
    private _timeSinceLastRaycast: number = 0.0;

    // 再利用用のベクトル・レイ(GC削減)
    private static _tempPosA: Vec3 = new Vec3();
    private static _tempPosB: Vec3 = new Vec3();
    private static _tempDir: Vec3 = new Vec3();
    private _ray3D: geometry.Ray = new geometry.Ray();

    onLoad() {
        // AudioSource の取得
        this._audioSource = this.getComponent(AudioSource);
        if (!this._audioSource) {
            console.error('[AudioOcclusion] AudioSource コンポーネントが見つかりません。このノードに AudioSource を追加してください。', this.node.name);
        } else {
            // AudioSource の初期音量を normalVolume に反映(初期値が 1.0 以外のケースも考慮)
            this.normalVolume = this._audioSource.volume;
        }

        // 初期状態では遮蔽されていないとみなす
        this._isOccluded = false;
        this._currentTargetVolume = this.normalVolume;
    }

    start() {
        if (!this.listener) {
            console.error('[AudioOcclusion] listener が設定されていません。リスナーとなるノードを Inspector から指定してください。', this.node.name);
        }

        // 初期ボリュームを反映
        if (this._audioSource) {
            this._audioSource.volume = this.normalVolume;
        }
    }

    update(deltaTime: number) {
        if (!this._audioSource || !this.listener) {
            // 必須コンポーネントまたはリスナー未設定時は何もしない
            return;
        }

        // RayCast の実行タイミング管理
        let shouldRaycast = false;
        if (this.raycastInterval = this.raycastInterval) {
                this._timeSinceLastRaycast = 0;
                shouldRaycast = true;
            }
        }

        if (shouldRaycast) {
            this._updateOcclusionState();
        }

        // 音量の補間
        this._updateVolume(deltaTime);
    }

    /**
     * 現在の遮蔽状態を RayCast によって更新する。
     */
    private _updateOcclusionState() {
        const from = AudioOcclusion._tempPosA;
        const to = AudioOcclusion._tempPosB;
        const dir = AudioOcclusion._tempDir;

        this.node.worldPosition.clone().assign(from);
        this.listener!.worldPosition.clone().assign(to);

        // direction = to - from
        Vec3.subtract(dir, to, from);
        const distance = dir.length();

        if (distance <= 0.0001) {
            // リスナーがほぼ同じ位置にいる場合は遮蔽なしとみなす
            this._setOccluded(false);
            return;
        }

        Vec3.normalize(dir, dir);

        const maxDistance = (this.maxRayDistance > 0) ? Math.min(this.maxRayDistance, distance) : distance;

        if (this.use2DPhysics) {
            this._raycast2D(from, dir, maxDistance);
        } else {
            this._raycast3D(from, dir, maxDistance);
        }
    }

    /**
     * 3D 物理で RayCast を行い、遮蔽状態を判定。
     */
    private _raycast3D(origin: Vec3, direction: Vec3, distance: number) {
        const physics3D = PhysicsSystem.instance;
        if (!physics3D) {
            console.error('[AudioOcclusion] 3D PhysicsSystem が有効ではありません。Project Settings で 3D Physics を有効にしてください。');
            return;
        }

        // Ray の設定
        this._ray3D.o.set(origin);
        this._ray3D.d.set(direction);

        // closest = true で最も近いヒットのみ取得
        const hit = physics3D.raycastClosest(this._ray3D, this.occlusionLayerMask3D, distance);
        if (!hit) {
            // 何もヒットしなければ遮蔽なし
            this._setOccluded(false);
            return;
        }

        const result = physics3D.raycastClosestResult;
        if (!result) {
            this._setOccluded(false);
            return;
        }

        // ヒットしたコライダーがリスナー自身であれば遮蔽なし、
        // それ以外なら遮蔽ありとみなす。
        const hitNode = result.collider?.node;
        if (hitNode === this.listener) {
            this._setOccluded(false);
        } else {
            this._setOccluded(true);
        }
    }

    /**
     * 2D 物理で RayCast を行い、遮蔽状態を判定。
     */
    private _raycast2D(origin: Vec3, direction: Vec3, distance: number) {
        const physics2D = PhysicsSystem2D.instance;
        if (!physics2D) {
            console.error('[AudioOcclusion] 2D PhysicsSystem が有効ではありません。Project Settings で 2D Physics を有効にしてください。');
            return;
        }

        // 2D では x, y のみを使用(z は無視)
        const fromX = origin.x;
        const fromY = origin.y;
        const toX = origin.x + direction.x * distance;
        const toY = origin.y + direction.y * distance;

        const results: PhysicsRayResult2D[] = [];
        const hitCount = physics2D.raycast(
            { x: fromX, y: fromY },
            { x: toX, y: toY },
            this.occlusionLayerMask2D,
            true,
            results
        );

        if (hitCount === 0) {
            this._setOccluded(false);
            return;
        }

        // 最も近いヒットのみを見る(results は距離順とは限らないので、自前で最小を探す)
        let closest: PhysicsRayResult2D | null = null;
        let minFraction = Number.MAX_VALUE;

        for (let i = 0; i < hitCount; i++) {
            const r = results[i];
            if (r.fraction < minFraction) {
                minFraction = r.fraction;
                closest = r;
            }
        }

        if (!closest) {
            this._setOccluded(false);
            return;
        }

        const hitNode = closest.collider.node;
        if (hitNode === this.listener) {
            this._setOccluded(false);
        } else {
            this._setOccluded(true);
        }
    }

    /**
     * 遮蔽状態フラグを更新し、目標音量を設定する。
     */
    private _setOccluded(occluded: boolean) {
        if (this._isOccluded === occluded) {
            return;
        }
        this._isOccluded = occluded;
        this._currentTargetVolume = occluded ? this.occludedVolume : this.normalVolume;

        if (this.debugLog) {
            console.log(`[AudioOcclusion] Occlusion changed: ${occluded ? 'OCCLUDED' : 'CLEAR'} on node "${this.node.name}"`);
        }
    }

    /**
     * 現在の音量を目標音量へ近づける。
     */
    private _updateVolume(deltaTime: number) {
        if (!this._audioSource) {
            return;
        }

        const current = this._audioSource.volume;
        const target = this._currentTargetVolume;

        if (this.volumeLerpSpeed <= 0) {
            // 補間なしで即時反映
            if (current !== target) {
                this._audioSource.volume = target;
            }
            return;
        }

        // 線形補間 (exponential に近づけたい場合は別の式も可)
        const t = 1 - Math.exp(-this.volumeLerpSpeed * deltaTime);
        const newVolume = current + (target - current) * t;

        // 数値誤差を抑制
        if (Math.abs(newVolume - target) < 0.001) {
            this._audioSource.volume = target;
        } else {
            this._audioSource.volume = newVolume;
        }
    }
}

コードの主要部分の解説

  • onLoad
    • 同じノードから AudioSource を取得し、存在しない場合は console.error を出力して警告。
    • AudioSource が存在する場合、その volumenormalVolume の初期値として使用。
    • 初期状態では「遮蔽なし」として _isOccluded = false, _currentTargetVolume = normalVolume を設定。
  • start
    • listener が未設定の場合にエラーログを出力。
    • AudioSource の現在の volumenormalVolume に合わせて初期化。
  • update
    • AudioSource または listener がない場合は何もしない。
    • raycastInterval に基づいて RayCast 実行タイミングを制御。
    • RayCast が必要なタイミングになったら _updateOcclusionState を呼び出す。
    • 毎フレーム _updateVolume を呼び出して、現在の音量を目標音量へ補間。
  • _updateOcclusionState
    • 音源ノードとリスナーノードの worldPosition を取得。
    • 方向ベクトルと距離を計算し、距離が極端に短い場合は遮蔽なしと判定。
    • use2DPhysics に応じて _raycast2D または _raycast3D を呼び出す。
  • _raycast3D / _raycast2D
    • それぞれ 3D / 2D の PhysicsSystem を使って RayCast を実行。
    • 最も近いヒットが「リスナー自身」であれば遮蔽なし、それ以外なら遮蔽ありと判定。
    • 「何もヒットしない」場合も遮蔽なしと判定。
  • _setOccluded
    • 内部フラグ _isOccluded を更新し、_currentTargetVolumeoccludedVolume または normalVolume に設定。
    • debugLog が true の場合、遮蔽状態の変化をログ出力。
  • _updateVolume
    • 現在の音量と目標音量の差を、volumeLerpSpeeddeltaTime に基づいて補間。
    • volumeLerpSpeed <= 0 の場合は補間せず即時反映。
    • 指数関数的に近づけることで、フレームレートに依存しにくいスムーズな変化を実現。

使用手順と動作確認

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

  1. エディタの Assets パネルで任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を AudioOcclusion.ts とします。
  3. 作成された AudioOcclusion.ts をダブルクリックして開き、内容をすべて削除して、前章の TypeScript コード全体 を貼り付けて保存します。

2. テスト用シーンの準備(共通)

  1. Hierarchy パネルで右クリック → Create → 2D/3D の任意のノード(例: NodeSprite3D Object)を作成し、名前を Player とします。
    • このノードが「リスナー(プレイヤー)」になります。
  2. 同様に、音源用のノードを作成します(例: SoundSource)。
  3. SoundSource ノードを選択し、InspectorAdd Component → Audio → AudioSource を追加します。
  4. AudioSource の Clip に任意の音声クリップを設定し、Loop にチェックを入れてループ再生にします。

3. 2D での動作確認例

  1. 2D 物理を有効化していない場合は、Project → Project Settings → Physics 2D で有効にします。
  2. Hierarchy で Canvas(または 2D 用のルートノード)配下に以下のノードを作成します。
    • Player(既に作成している場合はそれを使用)
    • SoundSource(AudioSource を持つノード)
    • Wall(遮蔽用の壁)
  3. Wall ノードを選択し、Add Component → Physics 2D → BoxCollider2D を追加します。
    • サイズや位置を調整して、PlayerSoundSource の間に壁が来るように配置します。
    • 必要に応じて RigidBody2D も追加します(静的壁なら Type = Static)。
  4. SoundSource ノードを選択し、Add Component → Custom → AudioOcclusion を追加します。
  5. Inspector の AudioOcclusion セクションで以下のように設定します。
    • Listener: Player ノードをドラッグ&ドロップ。
    • Use 2D Physics: チェックを ON
    • Occluded Volume: 0.2 程度。
    • Normal Volume: 1.0(AudioSource の volume と合わせる)。
    • Volume Lerp Speed: 810
    • Raycast Interval: 0.05
    • Max Ray Distance: 0(自動で音源〜リスナー距離を使用)。
    • Occlusion Layer Mask 2D: デフォルトのままで問題ありません(壁のレイヤーを含んでいることを確認)。
    • Debug Log: 動作確認時は ON にすると状態変化がコンソールに表示されます。
  6. 再生ボタン(▶)でシーンを実行し、以下を確認します。
    • PlayerSoundSource の間に Wall があるとき:音量が 徐々に occludedVolume まで下がる。
    • 壁を動かして線上から外す、または Player を動かして壁を避けると:音量が normalVolume に戻る。
    • Debug Log を ON にしている場合、コンソールに OCCLUDED / CLEAR が表示される。

4. 3D での動作確認例

  1. 3D 物理を有効化していない場合は、Project → Project Settings → Physics 3D で有効にします。
  2. Hierarchy で 3D シーン用のノードを作成します。
    • Player(任意の 3D ノード、例: Empty Node
    • SoundSource(AudioSource を持つノード)
    • Wall(3D の壁オブジェクト、例: Cube)
  3. Wall ノードを選択し、Add Component → Physics → BoxCollider を追加します。
    • 必要に応じて RigidBody を追加し、Type = Static に設定。
  4. SoundSource ノードに AudioSourceAudioOcclusion コンポーネントを追加します。
  5. Inspector の AudioOcclusion セクションで以下のように設定します。
    • Listener: Player ノード。
    • Use 2D Physics: チェックを OFF(3D を使う)。
    • その他のパラメータ(Occluded Volume, Normal Volume, Volume Lerp Speed など)は 2D の例と同様。
    • Occlusion Layer Mask 3D: 壁の Collider が属するレイヤーを含むマスクを設定。
  6. シーンを再生し、Player や Wall を動かして以下を確認します。
    • 音源と Player の間に壁が入るときに音量が下がる。
    • 壁が線上から外れたら音量が戻る。

5. よくあるハマりどころとチェックポイント

  • AudioSource が同じノードに付いていない
    • AudioOcclusion は 同じノード上の AudioSource を探します。別ノードにある場合は動作しません。
  • listener が未設定
    • Inspector の listener プロパティに、必ずプレイヤーノードを割り当ててください。
  • 物理システムが無効
    • 2D / 3D どちらを使うかに応じて、Project Settings で該当の Physics を有効にしてください。
  • レイヤーマスクの設定ミス
    • 壁の Collider が属するレイヤーが、occlusionLayerMask2D/3D に含まれているか確認してください。

まとめ

この「AudioOcclusion」コンポーネントを使えば、

  • 音源ノードに AudioSource + AudioOcclusion をアタッチするだけで
  • プレイヤーとの間に壁があるかどうかを 自動で RayCast 判定
  • 遮蔽時 / 非遮蔽時の音量を スムーズに補間して切り替える

という、実用的な「遮蔽音」表現を簡単に導入できます。

このスクリプトは完全に独立しており、GameManager やシングルトンに依存しないため、

  • 任意のシーン・任意のプロジェクトにそのままコピーして再利用できる
  • 複数の音源(例: ドアの向こうのテレビ、別室の NPC など)に対して簡単に適用できる

といったメリットがあります。

応用例としては、

  • 遮蔽時に音量だけでなく Low-pass フィルタを併用して「こもった音」を再現する
  • 遮蔽レベルを複数段階にして、「薄い壁」「厚い壁」で減衰量を変える
  • Ray を複数本(広がりを持たせて)飛ばし、「部分的に遮蔽されている」状況を表現する

なども考えられます。

まずは本記事の実装をベースに、プロジェクトに合わせた調整や拡張を加えつつ、より臨場感のあるサウンド演出に発展させてみてください。