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)の用意
- シーンに水面用の GameObject を作成(例:
WaterArea)。 Box ColliderなどのColliderを追加し、水が満たされている範囲を覆うようにサイズを調整します。Is Triggerにチェックを入れて、トリガーとして扱えるようにします。- Inspector 上部の
TagからAdd Tag...を選び、Water というタグを追加します。 WaterAreaに Water タグ を設定します。
これで「Water タグが付いたトリガーコライダー」が水エリアになります。
手順②:浮かせたいオブジェクト(親)を用意
例1:木箱(浮遊オブジェクト)
- シーンに木箱のプレハブ(例:
FloatingBox)を配置します。 Rigidbodyコンポーネントを追加します。Use Gravity:オン(重力で沈みつつ、浮力で持ち上がるイメージ)Mass:1~3 くらいの適当な値
- 見た目用の
MeshRenderer/BoxColliderなどはこの親に付けておいてOKです。
例2:プレイヤー
- プレイヤーのルートオブジェクト(例:
PlayerRoot)にRigidbodyが付いていることを確認します。 - すでにキャラクターコントローラなどがある場合も、Rigidbody に AddForce できる構成になっていればそのまま使えます。
手順③:子オブジェクトに WaterBuoyancy を付ける
浮力判定用の「水センサー」を子として作ります。
- 親オブジェクト(
FloatingBoxやPlayerRoot)の子として空の GameObject を作成し、名前をWaterSensorなどにします。 WaterSensorにSphere ColliderなどのColliderを追加し、Is Trigger にチェックを入れます。- サイズは「水に浸かったとみなしたい範囲」を覆うように調整します。
- 例えばプレイヤーなら足元~腰あたりをカバーすると自然です。
WaterSensorに WaterBuoyancy.cs をアタッチします。- Inspector で以下を確認します:
- Target Rigidbody:親オブジェクトの
Rigidbodyが自動で入っていなければ、ドラッグ&ドロップで設定。 - Buoyancy Force:10~30 くらいから調整(重いものほど大きく)。
- Bobbing Amplitude:0.1~0.3 程度(0 にすると揺れなし)。
- Bobbing Frequency:1~3 程度。
- Water Tag:
WaterのままでOK(タグ名を変えたい場合のみ編集)。
- Target Rigidbody:親オブジェクトの
手順④:プレイして挙動を確認する
- ゲームを再生し、木箱やプレイヤーを水エリアに押し込んでみましょう。
WaterSensorがWaterAreaのトリガーに入ると、上向きの力が加わり、ふわっと浮き上がるはずです。- 沈みすぎる場合:
Buoyancy Forceを上げるか、Rigidbody のMassを下げます。 - 跳ねすぎる・揺れすぎる場合:
Bobbing AmplitudeやBobbing 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つのコンポーネントに閉じ込めておけば、
「深さで浮力を変える」「流れのある川で横方向にも力を加える」などの拡張も、
他のシステムに影響を与えずに安全に実装していけます。
