Unityを触り始めた頃って、つい「音の処理も全部1つのスクリプトのUpdateに書いてしまう」ことが多いですよね。プレイヤーの移動、UIの更新、カメラ制御、BGMの切り替え、SEの音量変更……全部1つの巨大クラスに押し込んでしまうと、

  • どこで何が起きているのか追えない
  • ちょっとした仕様変更で大量のコードを修正する羽目になる
  • シーンごとに微妙に違う挙動を作りづらい

といった問題が出てきます。

音響まわりも同じで、「水中に入ったら音をこもらせたい」「特定エリアだけローパスをかけたい」といった処理をプレイヤー制御スクリプトに全部書いてしまうと、一気にGodクラス化してしまいます。

そこで今回は、「水中エリアに入ったら自動でローパスフィルタをかけるだけ」という、役割がハッキリした小さなコンポーネント 「LowPassFilter」 を作っていきます。
水中ゾーンのオブジェクトにポンと付けるだけで、AudioMixer(AudioBus)にローパスをかけて、音をこもらせるコンポーネントです。

【Unity】水中に入ったら音がこもる!「LowPassFilter」コンポーネント

ここでは、Unityの AudioMixer(ここでは「AudioBus」と呼ばれることも多い)に設定したローパスフィルタのカットオフ周波数を、トリガーの出入りに応じてスムーズに変化させるコンポーネントを実装します。

  • 水中に入る:カットオフ周波数を下げて音をこもらせる
  • 水中から出る:カットオフ周波数を上げて通常状態に戻す
  • プレイヤー以外には反応しないようにレイヤー・タグでフィルタ可能

というシンプルな責務だけを持たせます。

フルコード


using UnityEngine;
using UnityEngine.Audio;   // AudioMixer を使うため
using System.Collections;

[DisallowMultipleComponent]
[RequireComponent(typeof(Collider))]
public class LowPassFilter : MonoBehaviour
{
    // --- AudioMixer(AudioBus)関連 ---

    [Header("AudioMixer 設定")]
    [SerializeField]
    private AudioMixer audioMixer;

    // AudioMixer 側で exposed したパラメータ名
    // 例: "MasterLowPassCutoff"
    [SerializeField]
    private string cutoffParameterName = "MasterLowPassCutoff";

    [Header("ローパス設定 (Hz)")]
    [Tooltip("通常時のカットオフ周波数 (例: 22000)")]
    [SerializeField]
    private float normalCutoff = 22000f;

    [Tooltip("水中時のカットオフ周波数 (例: 800)")]
    [SerializeField]
    private float underwaterCutoff = 800f;

    [Header("トランジション設定 (秒)")]
    [Tooltip("水中に入った時にローパスをかけるまでの時間")]
    [SerializeField]
    private float enterDuration = 0.5f;

    [Tooltip("水中から出た時に元に戻すまでの時間")]
    [SerializeField]
    private float exitDuration = 0.5f;

    [Header("対象フィルタ")]
    [Tooltip("true の場合、指定したタグのオブジェクトにのみ反応")]
    [SerializeField]
    private bool useTagFilter = true;

    [SerializeField]
    private string targetTag = "Player";

    [Tooltip("true の場合、指定した Layer のオブジェクトにのみ反応")]
    [SerializeField]
    private bool useLayerFilter = false;

    [SerializeField]
    private LayerMask targetLayerMask = ~0; // デフォルトは全レイヤー

    // 現在進行中のトランジション用コルーチン
    private Coroutine transitionCoroutine;

    // 現在のターゲット(トリガー内にいるオブジェクトがあるかどうか)
    private int insideCount = 0;

    private void Reset()
    {
        // Collider をトリガーにしておく
        Collider col = GetComponent<Collider>();
        if (col != null)
        {
            col.isTrigger = true;
        }
    }

    private void Awake()
    {
        // AudioMixer が設定されていなければ警告
        if (audioMixer == null)
        {
            Debug.LogWarning(
                $"[{nameof(LowPassFilter)}] AudioMixer が設定されていません。ローパスは動作しません。",
                this
            );
        }

        // 起動時に通常状態のカットオフにしておく
        ApplyCutoffInstant(normalCutoff);
    }

    private void OnTriggerEnter(Collider other)
    {
        if (!IsTarget(other.gameObject))
        {
            return;
        }

        insideCount++;

        // 初めてターゲットが入ったタイミングでローパスをかけ始める
        if (insideCount == 1)
        {
            StartCutoffTransition(underwaterCutoff, enterDuration);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (!IsTarget(other.gameObject))
        {
            return;
        }

        insideCount = Mathf.Max(insideCount - 1, 0);

        // 全てのターゲットが出たタイミングで元に戻す
        if (insideCount == 0)
        {
            StartCutoffTransition(normalCutoff, exitDuration);
        }
    }

    /// <summary>
    /// 対象オブジェクトかどうかを判定する
    /// </summary>
    private bool IsTarget(GameObject obj)
    {
        if (useTagFilter && !obj.CompareTag(targetTag))
        {
            return false;
        }

        if (useLayerFilter)
        {
            // LayerMask に含まれているかどうか
            int layerMask = 1 << obj.layer;
            if ((targetLayerMask.value & layerMask) == 0)
            {
                return false;
            }
        }

        return true;
    }

    /// <summary>
    /// 指定したカットオフ周波数に向けてトランジションを開始する
    /// </summary>
    private void StartCutoffTransition(float targetCutoff, float duration)
    {
        // AudioMixer が設定されていない場合は何もしない
        if (audioMixer == null || string.IsNullOrEmpty(cutoffParameterName))
        {
            return;
        }

        // すでにトランジション中なら止める
        if (transitionCoroutine != null)
        {
            StopCoroutine(transitionCoroutine);
        }

        transitionCoroutine = StartCoroutine(CutoffTransitionRoutine(targetCutoff, duration));
    }

    /// <summary>
    /// カットオフ周波数を即座に適用する
    /// </summary>
    private void ApplyCutoffInstant(float cutoff)
    {
        if (audioMixer == null || string.IsNullOrEmpty(cutoffParameterName))
        {
            return;
        }

        // AudioMixer のパラメータは基本的に dB だが、
        // ローパスのカットオフは Hz をそのまま渡すことが多い。
        // ここでは「AudioMixer 側で Hz の exposed parameter を使っている」前提で直接セットする。
        audioMixer.SetFloat(cutoffParameterName, cutoff);
    }

    /// <summary>
    /// カットオフ周波数を時間をかけて補間するコルーチン
    /// </summary>
    private IEnumerator CutoffTransitionRoutine(float targetCutoff, float duration)
    {
        // 現在値を取得
        float currentCutoff;
        if (!audioMixer.GetFloat(cutoffParameterName, out currentCutoff))
        {
            // 取得できなかった場合は normalCutoff を基準にする
            currentCutoff = normalCutoff;
        }

        float elapsed = 0f;

        // duration が 0 の場合は即座に反映
        if (duration <= 0f)
        {
            ApplyCutoffInstant(targetCutoff);
            transitionCoroutine = null;
            yield break;
        }

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            float t = Mathf.Clamp01(elapsed / duration);

            // 線形補間でカットオフを変化
            float newCutoff = Mathf.Lerp(currentCutoff, targetCutoff, t);
            audioMixer.SetFloat(cutoffParameterName, newCutoff);

            yield return null;
        }

        // 最終値を保証
        audioMixer.SetFloat(cutoffParameterName, targetCutoff);
        transitionCoroutine = null;
    }
}

使い方の手順

ここでは、プレイヤーが水中エリアに入ると音がこもる という想定で進めます。

① AudioMixer(AudioBus)を用意してローパス用パラメータを作る

  1. Project ウィンドウで Create > Audio Mixer を選択し、MasterMixer など名前をつけます。
  2. ダブルクリックして AudioMixer ウィンドウを開き、Master グループ(または任意のグループ)を選択します。
  3. 右側の Inspector で、「Add Effect」から Lowpass Simple などのローパス系エフェクトを追加します。
  4. そのローパスの Cutoff などのパラメータ右クリック → Expose ‘Cutoff’ to script を選択します。
  5. AudioMixer ウィンドウ右側の Exposed Parameters パネルで、今 expose したパラメータ名を確認し、MasterLowPassCutoff のような分かりやすい名前に変更します。

この Exposed Parameter の名前 を、先ほどのスクリプトの cutoffParameterName に設定します。

② シーンに水中エリア用の GameObject を作る

  1. Hierarchy で Create Empty を選び、名前を WaterArea にします。
  2. WaterAreaBox Collider を追加します(または水面の形状に合わせて Sphere / Capsule でもOK)。
  3. Box Collider の Is Trigger にチェックを入れます。
    (スクリプトの Reset() でも自動でトリガーにしますが、念のため確認しておきましょう)

③ 「LowPassFilter」コンポーネントをアタッチして設定する

  1. WaterArea を選択し、「Add Component」から LowPassFilter を追加します。
  2. Inspector で以下の項目を設定します:
    • Audio Mixer:①で作った MasterMixer をドラッグ&ドロップ
    • Cutoff Parameter Name:①で expose したパラメータ名(例:MasterLowPassCutoff
    • Normal Cutoff:通常時の周波数(例:22000
    • Underwater Cutoff:水中時の周波数(例:8001500 あたり)
    • Enter Duration:水中に入ったときのフェード時間(例:0.5 秒)
    • Exit Duration:水中から出たときのフェード時間(例:0.5 秒)
    • Use Tag Filter:プレイヤーだけに反応させたい場合は true
    • Target TagPlayer(プレイヤーのタグに合わせる)

プレイヤーの GameObject 側には、Collider(CharacterController 含む)+ Rigidbody などの物理コンポーネントが付いていることを確認してください。
水中エリアの Collider.isTrigger と、プレイヤー側の Collider が正しく設定されていれば、OnTriggerEnter/Exit が呼ばれます。

④ 実例:プレイヤー用、水中敵用、動く水中エリア

  • プレイヤーが水中に潜るステージ
    プレイヤーの移動スクリプトには一切手を入れず、水中エリアにだけ LowPassFilter を付けるだけでOKです。
    ステージごとに水中の広さやローパスの強さを変えたい場合も、そのエリアのインスペクタ値を変えるだけで済みます。
  • 水中にいる敵だけ音がこもる演出
    敵キャラの AudioSource が別の AudioMixer グループ(例:Enemy)に送られているなら、敵専用の Mixer グループ+ローパスを作り、
    敵専用の LowPassFilter を敵の周囲に置く、という構成もできます。
    この場合は useTagFiltertrue にして、targetTagEnemy にしておきましょう。
  • 動く水中エリア(例:移動するバブルシールド)
    WaterArea をプレイヤーやオブジェクトの子にして動かすと、「その周囲だけ音がこもる」ような表現も作れます。
    コンポーネント自体は「トリガーに入ったかどうか」しか見ていないので、動いていても問題なく動作します。

メリットと応用

この LowPassFilter コンポーネントを使うメリットは、

  • 水中演出のロジックがプレイヤーや敵のスクリプトから完全に独立する
    → プレイヤー制御スクリプトを肥大化させずに済みます。
  • プレハブ化して量産しやすい
    → 「水中エリア用プレハブ」を1つ作っておけば、シーン上にいくつでも配置してOK。
    → ステージごとにローパスの強さやフェード時間を変えたい場合も、プレハブを複製してパラメータを変えるだけです。
  • レベルデザイン側で音響演出をいじりやすい
    → 「ここは音をちょっとだけこもらせたい」「ここはガッツリこもらせたい」といった要望に対して、
    プログラマがコードを書き換えなくても、レベルデザイナーがインスペクタで調整できます。
  • タグ・レイヤーで対象を切り替えられる
    → 将来的に「特定の仲間キャラだけ水中音にしたい」などの拡張もしやすい構造になっています。

コンポーネントの責務を「トリガー内外に応じて AudioMixer のパラメータを補間するだけ」に限定しているので、
他のコンポーネント(例えば、水中で移動速度を落とす UnderwaterMovementModifier など)とも組み合わせやすくなります。

改造案:フェードカーブをイージングで変える

今の実装は Mathf.Lerp による線形補間ですが、
「水中に入った瞬間だけグッとこもらせて、その後ゆっくり変化させたい」など、イージングカーブを使っても面白いです。

例えば、以下のようなイージング関数を追加して、CutoffTransitionRoutine 内の Mathf.Lerpt に適用するだけで、
フェードの雰囲気をガラッと変えられます。


    /// <summary>
    /// ゆっくり始まって最後にスッと終わるイージング (EaseOutQuad)
    /// </summary>
    private float EaseOutQuad(float t)
    {
        // t: 0〜1 の値を想定
        return 1f - (1f - t) * (1f - t);
    }

この関数を使う場合、CutoffTransitionRoutine の中で


float t = Mathf.Clamp01(elapsed / duration);
t = EaseOutQuad(t); // ここでイージングを適用

のように一行追加するだけで、より自然なローパスのかかり方になります。
このように、小さなコンポーネントにしておくと、こうした改造も局所的に行いやすくなりますね。