Unityを触り始めた頃、「とりあえず Update() に全部書いて動けばOK!」という実装をしがちですよね。
プレイヤーの入力処理、移動、アニメーション、当たり判定、エフェクト制御…すべて1つの巨大なスクリプトに詰め込んでしまうと、次のような問題が出てきます。

  • 少し仕様を変えたいだけなのに、どこを触ればいいか分からない
  • プレイヤー以外のオブジェクト(敵やギミック)で同じ仕組みを使い回しにくい
  • 物理挙動(Rigidbody)とロジックが混ざってバグを生みやすい

水中の浮力表現も、つい Update() の中で「もし水に入っていたら上に移動」と書きがちですが、
それだと水エリアの変更や、別のオブジェクトへの適用が面倒になります。

そこでこの記事では、「水に入っている間だけ、親オブジェクトに上向きの力を加える」専用コンポーネント
WaterBuoyancy を用意して、浮力という1つの責務に切り分けていきます。

【Unity】ふわっと浮かぶ水中挙動!「WaterBuoyancy」コンポーネント

今回のコンポーネントの役割はとてもシンプルです。

  • このコンポーネントが付いているオブジェクトの「親」に対して浮力を与える
  • 親オブジェクトには Rigidbody が付いていることを前提とする
  • 水エリアのコライダーに入っている間だけ、上向きの力を加え続ける
  • 軽く上下に揺れるような「浮き沈み」表現もできる

例えば、「水面に浮かぶ木箱」「水に落ちたプレイヤー」「ぷかぷか浮く敵」などにそのまま使える構成にします。

フルコード:WaterBuoyancy.cs


using UnityEngine;

/// <summary>
/// 親オブジェクトの Rigidbody に対して浮力を加えるコンポーネント。
/// ・このコンポーネント自体は子オブジェクト(水判定用トリガー)に付ける想定。
/// ・親には Rigidbody が付いている必要がある。
/// ・水エリアのコライダーに入っている間だけ、上向きの力を与える。
/// </summary>
[DisallowMultipleComponent]
public class WaterBuoyancy : MonoBehaviour
{
    // 親に付いている Rigidbody をキャッシュする
    [SerializeField] private Rigidbody targetRigidbody;

    // 浮力の強さ(上向きの力の大きさ)
    [SerializeField] private float buoyancyForce = 10f;

    // 浮力がかかる方向(通常は Vector3.up)
    [SerializeField] private Vector3 buoyancyDirection = Vector3.up;

    // 水中にいる間、上下にプカプカ揺れるための振幅
    [SerializeField] private float bobbingAmplitude = 0.2f;

    // プカプカの速さ(周波数)
    [SerializeField] private float bobbingFrequency = 1.5f;

    // 水と判定するためのタグ名
    [SerializeField] private string waterTag = "Water";

    // 水の中にいるかどうか
    private bool _isInWater = false;

    // プカプカ用の時間カウンタ
    private float _bobbingTime = 0f;

    // 初期ローカル位置(プカプカの基準位置)
    private Vector3 _initialLocalPosition;

    private void Reset()
    {
        // Reset はコンポーネント追加時に自動で呼ばれる
        // ここで親の Rigidbody を自動取得しておく
        if (transform.parent != null)
        {
            targetRigidbody = transform.parent.GetComponent<Rigidbody>();
        }

        // 一般的なデフォルト値をセット
        buoyancyForce = 10f;
        buoyancyDirection = Vector3.up;
        bobbingAmplitude = 0.2f;
        bobbingFrequency = 1.5f;
        waterTag = "Water";
    }

    private void Awake()
    {
        // 初期ローカル位置を保存しておく(プカプカ用)
        _initialLocalPosition = transform.localPosition;

        // targetRigidbody が未設定なら、親から自動取得を試みる
        if (targetRigidbody == null && transform.parent != null)
        {
            targetRigidbody = transform.parent.GetComponent<Rigidbody>();
        }

        if (targetRigidbody == null)
        {
            Debug.LogWarning(
                $"[WaterBuoyancy] 親に Rigidbody が見つかりませんでした。浮力が適用されません。GameObject: {gameObject.name}",
                this
            );
        }

        // 子オブジェクト側には「isTrigger = true」の Collider が必要
        var col = GetComponent<Collider>();
        if (col == null)
        {
            Debug.LogWarning(
                $"[WaterBuoyancy] Collider が見つかりません。水エリアとの接触判定ができません。GameObject: {gameObject.name}",
                this
            );
        }
        else if (!col.isTrigger)
        {
            Debug.LogWarning(
                $"[WaterBuoyancy] この Collider は isTrigger = true を推奨します。水エリアとの判定専用に使うためです。GameObject: {gameObject.name}",
                this
            );
        }
    }

    private void FixedUpdate()
    {
        // Rigidbody への力の適用は FixedUpdate で行う
        if (!_isInWater) return;
        if (targetRigidbody == null) return;

        // 基本の上向きの力を加える
        // ForceMode.Acceleration を使うことで質量に依存しない浮力になる
        Vector3 force = buoyancyDirection.normalized * buoyancyForce;
        targetRigidbody.AddForce(force, ForceMode.Acceleration);

        // プカプカ揺れる表現(任意)
        if (bobbingAmplitude > 0f && bobbingFrequency > 0f)
        {
            _bobbingTime += Time.fixedDeltaTime * bobbingFrequency;

            // sin 波で上下に動かす
            float offsetY = Mathf.Sin(_bobbingTime * Mathf.PI * 2f) * bobbingAmplitude;

            // ローカル位置を少しだけ上下に動かす(見た目だけの揺れ)
            Vector3 localPos = _initialLocalPosition;
            localPos.y += offsetY;
            transform.localPosition = localPos;
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        // 水エリアに入ったかどうかをタグで判定
        if (other.CompareTag(waterTag))
        {
            _isInWater = true;
            _bobbingTime = 0f; // 入水時にプカプカの位相をリセット

            // デバッグ用ログ(不要ならコメントアウトしてOK)
            // Debug.Log($"[WaterBuoyancy] 水エリアに入りました: {other.name}", this);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.CompareTag(waterTag))
        {
            _isInWater = false;

            // 水から出たら位置を元に戻しておく
            transform.localPosition = _initialLocalPosition;

            // Debug.Log($"[WaterBuoyancy] 水エリアから出ました: {other.name}", this);
        }
    }

    private void OnTriggerStay(Collider other)
    {
        // 万が一 Enter を取りこぼしても、Stay 中は水中扱いにしておく保険
        if (other.CompareTag(waterTag))
        {
            _isInWater = true;
        }
    }

    /// <summary>
    /// 外部から浮力のオン/オフを切り替えたい場合に使えるヘルパー。
    /// 例:プレイヤーが死亡したら浮力を無効化する など。
    /// </summary>
    public void SetBuoyancyEnabled(bool enabled)
    {
        _isInWater = enabled;

        if (!enabled)
        {
            // 無効化時は位置をリセットしておく
            transform.localPosition = _initialLocalPosition;
        }
    }
}

使い方の手順

ここでは「水に浮かぶ木箱」と「水に落ちたプレイヤー」を例に、具体的なセットアップ手順を見ていきます。

手順①:水エリア(Water)の用意

  1. シーンに水面用の GameObject を作成(例:WaterArea)。
  2. Box Collider などの Collider を追加し、水が満たされている範囲を覆うようにサイズを調整します。
  3. Is Trigger にチェックを入れて、トリガーとして扱えるようにします。
  4. Inspector 上部の Tag から Add Tag... を選び、Water というタグを追加します。
  5. WaterAreaWater タグ を設定します。

これで「Water タグが付いたトリガーコライダー」が水エリアになります。

手順②:浮かせたいオブジェクト(親)を用意

例1:木箱(浮遊オブジェクト)

  1. シーンに木箱のプレハブ(例:FloatingBox)を配置します。
  2. Rigidbody コンポーネントを追加します。
    • Use Gravity:オン(重力で沈みつつ、浮力で持ち上がるイメージ)
    • Mass:1~3 くらいの適当な値
  3. 見た目用の MeshRenderer / BoxCollider などはこの親に付けておいてOKです。

例2:プレイヤー

  1. プレイヤーのルートオブジェクト(例:PlayerRoot)に Rigidbody が付いていることを確認します。
  2. すでにキャラクターコントローラなどがある場合も、Rigidbody に AddForce できる構成になっていればそのまま使えます。

手順③:子オブジェクトに WaterBuoyancy を付ける

浮力判定用の「水センサー」を子として作ります。

  1. 親オブジェクト(FloatingBoxPlayerRoot)の子として空の GameObject を作成し、名前を WaterSensor などにします。
  2. WaterSensorSphere Collider などの Collider を追加し、Is Trigger にチェックを入れます。
    • サイズは「水に浸かったとみなしたい範囲」を覆うように調整します。
    • 例えばプレイヤーなら足元~腰あたりをカバーすると自然です。
  3. WaterSensorWaterBuoyancy.cs をアタッチします。
  4. Inspector で以下を確認します:
    • Target Rigidbody:親オブジェクトの Rigidbody が自動で入っていなければ、ドラッグ&ドロップで設定。
    • Buoyancy Force:10~30 くらいから調整(重いものほど大きく)。
    • Bobbing Amplitude:0.1~0.3 程度(0 にすると揺れなし)。
    • Bobbing Frequency:1~3 程度。
    • Water TagWater のままでOK(タグ名を変えたい場合のみ編集)。

手順④:プレイして挙動を確認する

  • ゲームを再生し、木箱やプレイヤーを水エリアに押し込んでみましょう。
  • WaterSensorWaterArea のトリガーに入ると、上向きの力が加わり、ふわっと浮き上がるはずです。
  • 沈みすぎる場合:Buoyancy Force を上げるか、Rigidbody の Mass を下げます。
  • 跳ねすぎる・揺れすぎる場合:Bobbing AmplitudeBobbing Frequency を下げます。

この構成にしておくと、「浮かせたいオブジェクトの子に WaterBuoyancy を足すだけ」でどんなオブジェクトにも同じ浮力ロジックを再利用できます。

メリットと応用

WaterBuoyancy を「浮力だけを担当する小さなコンポーネント」として切り出しておくと、次のようなメリットがあります。

  • プレハブ化しやすい
    浮かぶ木箱プレハブ、浮かぶ樽プレハブ、浮かぶ敵プレハブ…など、
    それぞれの見た目やAIは別として、浮力の仕組みは全て同じコンポーネントで共有できます。
  • レベルデザインが楽になる
    ステージデザイナーは「このオブジェクトを水に浮かせたい」と思ったら、
    既存プレハブをポンと配置するだけでOK。
    水位を変えたり、水エリアの形を変えても、WaterBuoyancy 側は一切変更不要です。
  • テストや調整が局所的にできる
    「浮力が強すぎる」「揺れが激しい」などのフィードバックが来ても、
    触るのは WaterBuoyancy のパラメータだけ。
    プレイヤーの入力処理やAIロジックを巻き込まないので、安全に調整できます。
  • 責務の分離(SRP)
    プレイヤーのスクリプトは「移動」「攻撃」「UI更新」などに集中し、
    「水に浮くかどうか」は WaterBuoyancy に委譲できます。
    結果として、どのスクリプトが何をしているかが明確になり、保守性が上がります。

簡単な改造案:水の深さによって浮力を変える

よりリッチな表現として、「水面に近いほど浮力が強く、深いと少し弱くなる」といった調整もできます。
以下は FixedUpdate に組み込める、深さ依存の浮力計算の一例です。


private void ApplyDepthBasedBuoyancy(float waterSurfaceY)
{
    if (targetRigidbody == null) return;

    // 現在のオブジェクトのワールド座標の高さ
    float objectY = targetRigidbody.worldCenterOfMass.y;

    // 水面からの「沈み具合」(水面より上なら 0)
    float depth = Mathf.Max(0f, waterSurfaceY - objectY);

    // 深く沈むほど浮力が増えるようにする(上限付き)
    float depthFactor = Mathf.Clamp01(depth / 2f); // 2m で最大

    Vector3 force = buoyancyDirection.normalized * buoyancyForce * depthFactor;
    targetRigidbody.AddForce(force, ForceMode.Acceleration);
}

この関数を使う場合は、waterSurfaceY(水面の高さ)をどこかから渡してあげる必要があります。
例えば、水面オブジェクトの transform.position.y を参照したり、WaterArea 用の管理コンポーネントを作って共有する形ですね。

このように、浮力ロジックを1つのコンポーネントに閉じ込めておけば、
「深さで浮力を変える」「流れのある川で横方向にも力を加える」などの拡張も、
他のシステムに影響を与えずに安全に実装していけます。