Gameplay/Buoyancy.cs

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.

NetworkingFile Access
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));
	}
}