Code/Water/Buoyancy/Buoyancy.cs
using Sandbox;
namespace RedSnail.WaterTool;
public sealed class Buoyancy : Component
{
private Collider m_Collider;
private const float WATER_DENSITY = 1000.0f; // kg/m3
[Property, Group("Buoyancy")] private float SpringStiffness { get; set; } = 500.0f;
[Property, Group("Buoyancy")] private float Damping { get; set; } = 5.0f;
[Property, Group("Buoyancy"), Range(0.1f, 1.0f)] private float HullSpread { get; set; } = 0.6f;
[Property, Group("Buoyancy")] private float SurfaceOffset { get; set; } = 0.0f;
[Property, Group("Drag")] private float DragCoefficient { get; set; } = 1.0f;
[Property, Group("Drag")] private float AngularDragCoefficient { get; set; } = 2.0f;
// Set to 0 for docked/anchored boats that should only bob vertically
[Property, Group("Wave Transport"), Range(0f, 1f)] private float HorizontalDisplacementStrength { get; set; } = 1.0f;
[Property, Group("Wave Transport")] private float WaveTransportForce { get; set; } = 5.0f;
[Property] private float AirLeakRate { get; set; } = 0.0f;
[Sync] public float AirVolume { get; private set; } = 1.0f;
[Sync] public float WaterHeight { get; private set; } = float.MinValue;
[Sync] public bool IsTouchingWater { get; private set; }
public bool IsUnderwater => IsTouchingWater && WorldPosition.z <= WaterHeight;
protected override void OnAwake()
{
m_Collider = GetComponent<Collider>();
}
protected override void OnFixedUpdate()
{
if (IsProxy)
return;
if (m_Collider.IsTrigger)
return;
if (!m_Collider.Rigidbody.IsValid())
return;
float waveHeight = WaterManager.GetWaterHeightAt(WorldPosition);
bool insideWater = waveHeight > float.MinValue;
if (insideWater)
{
WaterHeight = waveHeight;
IsTouchingWater = true;
float colliderHeight = m_Collider.LocalBounds.Size.z;
bool isNearWater = WorldPosition.z <= WaterHeight + colliderHeight;
if (isNearWater)
{
ApplyWaterResistance();
ApplyAngularDrag();
ApplyBuoyancy();
ApplyWaveTransport();
}
}
else
{
IsTouchingWater = false;
WaterHeight = float.MinValue;
}
// Always run, drains while submerged, recovers while above water or fully out
UpdateAirVolume();
}
private float GetSubmersionAtPoint(Vector3 _WorldPoint, float _WaterHeight)
{
float depth = _WaterHeight - _WorldPoint.z;
// Get the height of the collider for normalization
BBox localBounds = m_Collider.LocalBounds;
float colliderHeight = localBounds.Size.z;
if (colliderHeight <= 0.0f)
return 0.0f;
// Return normalized depth (0 = at surface, 1 = fully submerged)
return (depth / colliderHeight).Clamp(0.0f, 1.0f);
}
private void UpdateAirVolume()
{
if (WorldPosition.z < WaterHeight)
AirVolume -= Time.Delta * AirLeakRate;
else
AirVolume += Time.Delta * AirLeakRate;
AirVolume = AirVolume.Clamp(0.0f, 1.0f);
}
private void ApplyWaterResistance()
{
Vector3 velocity = m_Collider.Rigidbody.Velocity;
float speed = velocity.Length * 0.0254f; // Convert inches to meters
if (speed < 0.01f)
return;
float submersion = GetSubmersionAtPoint(WorldPosition, WaterHeight);
// Approximate frontal area (in m²)
BBox worldBounds = m_Collider.LocalBounds.Transform(WorldTransform);
float area = (worldBounds.Size.z * worldBounds.Size.x) * 0.00064516f; // Convert inches² to meters²
Vector3 velocityDir = velocity.Normal;
// Drag force = -0.5 * ρ * v^2 * C_d * A * dir
Vector3 dragForce = -0.5f * WATER_DENSITY * speed * speed * DragCoefficient * area * velocityDir * submersion;
m_Collider.Rigidbody.ApplyForce(dragForce);
}
private void ApplyAngularDrag()
{
Vector3 angularVelocity = m_Collider.Rigidbody.AngularVelocity;
if (angularVelocity.LengthSquared < 0.0001f)
return;
float submersion = GetSubmersionAtPoint(WorldPosition, WaterHeight);
Vector3 angularDrag = -angularVelocity * AngularDragCoefficient * submersion;
m_Collider.Rigidbody.AngularVelocity += angularDrag * Time.Delta;
}
private void ApplyBuoyancy()
{
BBox localBounds = m_Collider.LocalBounds;
Vector3 center = localBounds.Center;
Vector3 extents = localBounds.Extents;
float sx = extents.x * HullSpread;
float sy = extents.y * HullSpread;
Vector3 p0 = center; // Center
Vector3 p1 = center + new Vector3(sx, 0, 0); // Starboard
Vector3 p2 = center + new Vector3(-sx, 0, 0); // Port
Vector3 p3 = center + new Vector3(0, sy, 0); // Bow
Vector3 p4 = center + new Vector3(0, -sy, 0); // Stern
Vector3 p5 = center + new Vector3(sx, sy, 0); // Bow-Starboard
Vector3 p6 = center + new Vector3(-sx, sy, 0); // Bow-Port
Vector3 p7 = center + new Vector3(sx, -sy, 0); // Stern-Starboard
Vector3 p8 = center + new Vector3(-sx, -sy, 0); // Stern-Port
const int pointCount = 9;
float mass = m_Collider.Rigidbody.Mass;
Vector3 angularVel = m_Collider.Rigidbody.AngularVelocity;
foreach (Vector3 localPoint in new[] { p0, p1, p2, p3, p4, p5, p6, p7, p8 })
{
Vector3 worldPoint = WorldTransform.PointToWorld(localPoint);
float pointWaterHeight = WaterManager.GetWaterHeightAt(worldPoint);
if (pointWaterHeight == float.MinValue)
pointWaterHeight = WaterHeight;
/*
Vector3 test = worldPoint;
test.z = pointWaterHeight;
DebugOverlay.Box(test, Vector3.One, Color.Red, overlay: true);
*/
// How far below the wave surface this point is (positive = submerged)
// SurfaceOffset raises the effective water level so the boat sits higher
float depth = (pointWaterHeight + SurfaceOffset) - worldPoint.z;
if (depth <= 0f)
continue;
// Spring: force proportional to depth below surface, scaled by remaining air
float springForce = depth * SpringStiffness * mass * AirVolume / pointCount;
// Damper: opposes vertical velocity at this point to prevent oscillation
Vector3 pointVelocity = m_Collider.Rigidbody.Velocity + Vector3.Cross(angularVel, worldPoint - WorldPosition);
float damperForce = -pointVelocity.z * Damping * mass / pointCount;
m_Collider.Rigidbody.ApplyForceAt(worldPoint, Vector3.Up * (springForce + damperForce));
}
}
private void ApplyWaveTransport()
{
if (HorizontalDisplacementStrength <= 0f)
return;
Vector3 displacement = WaterManager.GetWaveDisplacementAt(WorldPosition);
Vector3 horizontalDisp = new Vector3(displacement.x, displacement.y, 0) * HorizontalDisplacementStrength;
m_Collider.Rigidbody.ApplyForce(horizontalDisp * WaveTransportForce);
}
}