Component that simulates buoyancy for a physics body. It reads water height, waves and flow from WaterNetwork, applies springs at nine hull points, water drag, angular drag, wave transport and current forces, and emits ripples/wake effects while updating an air volume property.
using Sandbox;
namespace RedSnail.WaterTool2;
/// <summary>
/// Floats a rigidbody on the water: springs at nine points of its hull push it up to the surface (waves and ripples
/// included), drag slows it in the water, the waves' motion carries it about, and the current of a river or of the plume
/// where a river enters a lake drags it along. It splashes ripples as it lands and as it moves.
/// </summary>
[Title("Buoyancy")]
[Category("Water Tool 2")]
[Icon("sailing")]
public sealed class Buoyancy : Component
{
private Collider m_Collider;
private const float WaterDensity = 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;
/// <summary>
/// How much the waves and the current carry the body along. 0 for a moored boat that should only bob.
/// </summary>
[Property, Group("Wave Transport"), Range(0.0f, 1.0f)] private float HorizontalDisplacementStrength { get; set; } = 1.0f;
[Property, Group("Wave Transport"), Range(0.1f, 10.0f)] private float Drag { get; set; } = 1.5f;
[Property] private float AirLeakRate { get; set; } = 0.0f;
[Property, Group("Ripples")] private bool EmitEntryRipple { get; set; } = true;
/// <summary>
/// Ring spacing of the entry splash: smaller makes more, tighter rings.
/// </summary>
[Property, Group("Ripples"), Range(20.0f, 400.0f)] private float EntryRippleWavelength { get; set; } = 120.0f;
/// <summary>
/// Ring size of the entry splash: larger makes a bigger, broader ripple.
/// </summary>
[Property, Group("Ripples"), Range(10.0f, 500.0f)] private float EntryRippleWidth { get; set; } = 50.0f;
/// <summary>
/// A steady trail of ripples while the hull moves across the surface (a boat's wake).
/// </summary>
[Property, Group("Ripples")] private bool EmitWakeRipple { get; set; } = true;
[Property, Group("Ripples")] private float WakeRippleStrength { get; set; } = 0.1f;
/// <summary>
/// The horizontal speed (units/s) below which no wake is left.
/// </summary>
[Property, Group("Ripples")] private float WakeRippleMinSpeed { get; set; } = 20.0f;
/// <summary>
/// Seconds between wake ripples: lower makes a denser trail (and uses more of the ripple budget).
/// </summary>
[Property, Group("Ripples")] private float WakeRippleInterval { get; set; } = 0.0333f;
[Property, Group("Ripples"), Range(20.0f, 400.0f)] private float WakeRippleWavelength { get; set; } = 120.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; }
private bool m_WasBelowSurface;
private float m_WakeTimer;
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.IsValid() || m_Collider.IsTrigger || !m_Collider.Rigidbody.IsValid())
return;
float waveHeight = WaterNetwork.GetWaterHeightAt(WorldPosition);
if (waveHeight > float.MinValue)
{
WaterHeight = waveHeight;
HandleEntryRipple();
// Water under us always has a height, even while we fly far above it: only count as touching it when the
// hull is down at the surface (the band the forces use).
float colliderHeight = m_Collider.LocalBounds.Size.z;
bool isNearWater = WorldPosition.z <= WaterHeight + colliderHeight;
IsTouchingWater = isNearWater;
if (isNearWater)
{
ApplyWaterResistance();
ApplyAngularDrag();
ApplyBuoyancy();
ApplyWaveTransport();
ApplyCurrentForce();
HandleWakeRipple();
}
}
else
{
IsTouchingWater = false;
WaterHeight = float.MinValue;
m_WasBelowSurface = false;
}
// Drains while submerged, recovers above water.
UpdateAirVolume();
}
/// <summary>
/// A splash the moment the body drops through the surface; gentle bobbing through the surface line doesn't count.
/// </summary>
private void HandleEntryRipple()
{
bool belowSurface = WorldPosition.z <= WaterHeight;
if (EmitEntryRipple && belowSurface && !m_WasBelowSurface)
{
float impactSpeed = float.Max(0.0f, -m_Collider.Rigidbody.Velocity.z);
if (impactSpeed > 40.0f)
{
float strength = (impactSpeed / 150.0f).Clamp(0.3f, 2.5f);
WaterNetwork.AddRipple(WorldPosition.WithZ(WaterHeight), strength, EntryRippleWavelength, EntryRippleWidth);
}
}
m_WasBelowSurface = belowSurface;
}
private void HandleWakeRipple()
{
if (!EmitWakeRipple)
return;
float horizontalSpeed = m_Collider.Rigidbody.Velocity.WithZ(0.0f).Length;
if (horizontalSpeed < WakeRippleMinSpeed)
return;
m_WakeTimer -= Time.Delta;
if (m_WakeTimer > 0.0f)
return;
float strength = (horizontalSpeed / 1000.0f).Clamp(0.1f, 1.0f) * WakeRippleStrength * 10.0f;
WaterNetwork.AddRipple(WorldPosition.WithZ(WaterHeight), strength, WakeRippleWavelength, m_Collider.LocalBounds.Extents.x);
m_WakeTimer = WakeRippleInterval;
}
/// <summary>
/// How far under the surface a point is, as a fraction of the collider's height (0 at the surface, 1 fully under).
/// </summary>
private float GetSubmersionAtPoint(Vector3 _WorldPoint, float _WaterHeight)
{
float colliderHeight = m_Collider.LocalBounds.Size.z;
if (colliderHeight <= 0.0f)
return 0.0f;
return ((_WaterHeight - _WorldPoint.z) / 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; // inches to metres
if (speed < 0.01f)
return;
float submersion = GetSubmersionAtPoint(WorldPosition, WaterHeight);
// Frontal area, square metres
BBox worldBounds = m_Collider.LocalBounds.Transform(WorldTransform);
float area = (worldBounds.Size.z * worldBounds.Size.x) * 0.00064516f;
// F = -0.5 * rho * v^2 * Cd * A
Vector3 dragForce = -0.5f * WaterDensity * speed * speed * DragCoefficient * area * velocity.Normal * 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);
m_Collider.Rigidbody.AngularVelocity += -angularVelocity * AngularDragCoefficient * submersion * Time.Delta;
}
/// <summary>
/// A spring and a damper at each of nine points over the hull, each pushing up by how far under its own bit of
/// surface the point is, so the body tilts with the waves under it.
/// </summary>
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;
var points = new[]
{
center,
center + new Vector3(sx, 0, 0),
center + new Vector3(-sx, 0, 0),
center + new Vector3(0, sy, 0),
center + new Vector3(0, -sy, 0),
center + new Vector3(sx, sy, 0),
center + new Vector3(-sx, sy, 0),
center + new Vector3(sx, -sy, 0),
center + new Vector3(-sx, -sy, 0)
};
float mass = m_Collider.Rigidbody.Mass;
Vector3 angularVelocity = m_Collider.Rigidbody.AngularVelocity;
foreach (Vector3 localPoint in points)
{
Vector3 worldPoint = WorldTransform.PointToWorld(localPoint);
float pointWaterHeight = WaterNetwork.GetWaterHeightAt(worldPoint);
if (pointWaterHeight == float.MinValue)
pointWaterHeight = WaterHeight;
// How far below the surface this point is (positive under it); the offset raises the effective water level.
float depth = (pointWaterHeight + SurfaceOffset) - worldPoint.z;
if (depth <= 0.0f)
continue;
float springForce = depth * SpringStiffness * mass * AirVolume / points.Length;
Vector3 pointVelocity = m_Collider.Rigidbody.Velocity + Vector3.Cross(angularVelocity, worldPoint - WorldPosition);
float damperForce = -pointVelocity.z * Damping * mass / points.Length;
m_Collider.Rigidbody.ApplyForceAt(worldPoint, Vector3.Up * (springForce + damperForce));
}
}
/// <summary>
/// The waves' orbital motion pulls the body along with it.
/// </summary>
private void ApplyWaveTransport()
{
if (HorizontalDisplacementStrength <= 0.0f)
return;
Vector3 displacement = WaterNetwork.GetWaveDisplacementAt(WorldPosition);
Vector3 horizontal = new Vector3(displacement.x, displacement.y, 0) * HorizontalDisplacementStrength;
Vector3 relative = horizontal - m_Collider.Rigidbody.Velocity.WithZ(0.0f);
m_Collider.Rigidbody.ApplyForce(relative * (m_Collider.Rigidbody.Mass * Drag));
}
/// <summary>
/// The current drags the body towards its own speed: down a river, out across a lake from a river's mouth.
/// </summary>
private void ApplyCurrentForce()
{
if (HorizontalDisplacementStrength <= 0.0f)
return;
Vector3 flow = WaterNetwork.GetFlowVelocityAt(WorldPosition);
if (flow.LengthSquared < 0.01f)
return;
Vector3 relative = flow - m_Collider.Rigidbody.Velocity.WithZ(0.0f);
m_Collider.Rigidbody.ApplyForce(relative * HorizontalDisplacementStrength * (m_Collider.Rigidbody.Mass * Drag));
}
}