Code/Water/WaterManager.Displacement.cs

WaterManager partial class for displacement queries. It finds which water sources (quads, bodies, flows) cover a world position and returns wave height, displacement, velocity and flow data, plus hull containment tests.

NetworkingFile Access
using System;
using Sandbox;

namespace RedSnail.WaterTool;

public partial class WaterManager
{
	// --- Hull test ---

	private static bool IsInsideHull(HullCollider hull, Vector3 position)
	{
		if (!hull.IsValid())
			return false;

		var local = hull.WorldTransform.PointToLocal(position);

		if (hull.Type == HullCollider.PrimitiveType.Box)
		{
			var half = hull.BoxSize * 0.5f;
			return float.Abs(local.x) <= half.x &&
				   float.Abs(local.y) <= half.y &&
				   float.Abs(local.z - hull.Center.z) <= half.z;
		}

		if (hull.Type == HullCollider.PrimitiveType.Cylinder)
		{
			Vector2 flat = new(local.x, local.y);
			return flat.LengthSquared <= hull.Radius * hull.Radius &&
				   float.Abs(local.z - hull.Center.z) <= hull.Height * 0.5f;
		}

		return false;
	}

	// --- Nearest-source lookup ---

	private static WaterQuad FindQuadAtPosition(Vector3 position)
	{
		WaterQuad best = null;
		float bestDist = float.MaxValue;

		foreach (WaterQuad quad in Current?.Quads ?? [])
		{
			if (!quad.HullCollider.IsValid())
				continue;
			
			var local = quad.HullCollider.WorldTransform.PointToLocal(position);
			bool insideXY;
			float floorLocalZ;

			if (quad.HullCollider.Type == HullCollider.PrimitiveType.Cylinder)
			{
				Vector2 flat = new(local.x, local.y);
				insideXY = flat.LengthSquared <= quad.HullCollider.Radius * quad.HullCollider.Radius;
				floorLocalZ = quad.HullCollider.Center.z - quad.HullCollider.Height * 0.5f;
			}
			else
			{
				Vector3 half = quad.HullCollider.BoxSize * 0.5f;
				insideXY = MathF.Abs(local.x) <= half.x && MathF.Abs(local.y) <= half.y;
				floorLocalZ = quad.HullCollider.Center.z - half.z;
			}

			if (!insideXY)
				continue;

			// Water has a finite depth: ignore the quad once we're beneath its floor so the
			// column isn't treated as bottomless (otherwise swim/buoyancy stay "submerged" forever).
			if (local.z < floorLocalZ)
				continue;

			float dist = MathF.Abs(position.z - quad.WorldPosition.z);
			if (dist < bestDist) { bestDist = dist; best = quad; }
		}

		return best;
	}

	private static WaterBody FindBodyAtPosition(Vector3 position)
	{
		WaterBody best = null;
		float bestDist = float.MaxValue;

		foreach (WaterBody body in Current?.Bodies ?? [])
		{
			if (!body.IsValid() || !body.Active || !body.ContainsPointXY(position))
				continue;

			// Finite depth: skip bodies whose floor is above us (we're below the volume).
			if (position.z < body.GetBottomHeight())
				continue;

			float dist = body.GetVerticalDistanceToSurface(position);
			if (dist < bestDist) { bestDist = dist; best = body; }
		}

		return best;
	}

	// Rivers are the most specific water volume, so when a position falls inside a
	// flow's footprint the flow provides the surface — quads/bodies are the fallback.
	private static WaterFlow FindFlowAtPosition(Vector3 position, out float surfaceHeight, out Vector2 flowDirection, out float flowSpeed)
	{
		surfaceHeight = float.MinValue;
		flowDirection = Vector2.Zero;
		flowSpeed = 0.0f;

		foreach (WaterFlow flow in Current?.Flows ?? [])
		{
			if (!flow.IsValid() || !flow.Active)
				continue;

			if (!flow.TryGetSurfaceAt(position, out float height, out Vector2 dir, out float speed))
				continue;

			// Finite depth: ignore the river once we're beneath its bed.
			if (position.z < height - flow.Depth)
				continue;

			surfaceHeight = height;
			flowDirection = dir;
			flowSpeed = speed;

			return flow;
		}

		return null;
	}

	// --- Public static wave queries ---

	public static bool IsPositionInsideAny(Vector3 position)
	{
		if (Current is null)
			return false;

		foreach (WaterQuad quad in Current.Quads)
		{
			if (!quad.IsValid() || !quad.Active)
				continue;

			if (!IsInsideHull(quad.HullCollider, position))
				continue;

			if (position.z <= quad.GetWaveHeightAt(position))
				return true;
		}

		foreach (WaterBody body in Current.Bodies)
		{
			if (!body.IsValid() || !body.Active)
				continue;

			if (!body.ContainsPointInVolume(position))
				continue;

			if (position.z <= body.GetWaveHeightAt(position))
				return true;
		}

		foreach (WaterFlow flow in Current.Flows)
		{
			if (!flow.IsValid() || !flow.Active)
				continue;

			if (flow.ContainsPoint(position))
				return true;
		}

		return false;
	}

	/// <summary>
	/// World-space river current at a position (direction * speed, horizontal).
	/// Zero when the position is not inside any WaterFlow. Buoyancy uses this to
	/// drag floating bodies downstream.
	/// </summary>
	public static Vector3 GetFlowVelocityAt(Vector3 position)
	{
		WaterFlow flowSource = FindFlowAtPosition(position, out _, out Vector2 flowDir, out float flowSpeed);

		return flowSource.IsValid()
			? new Vector3(flowDir.x, flowDir.y, 0.0f) * flowSpeed
			: Vector3.Zero;
	}

	public static Vector3 GetWaveDisplacementAt(Vector3 position)
	{
		// Rivers: no wave orbital displacement — the current is a velocity, applied
		// through Buoyancy's current drag (GetFlowVelocityAt), not wave transport.
		WaterFlow flowSource = FindFlowAtPosition(position, out _, out _, out _);

		if (flowSource.IsValid())
			return new Vector3(0.0f, 0.0f, Current?.ComputeRippleHeight(position) ?? 0.0f);

		WaterQuad quad = FindQuadAtPosition(position);
		WaterBody body = FindBodyAtPosition(position);

		Vector3 displacement;

		if (quad.IsValid() && body.IsValid())
			displacement = quad.GetWaveHeightAt(position) >= body.GetWaveHeightAt(position)
				? quad.GetWaveDisplacementAt(position)
				: body.GetWaveDisplacementAt(position);
		else if (quad.IsValid()) displacement = quad.GetWaveDisplacementAt(position);
		else if (body.IsValid()) displacement = body.GetWaveDisplacementAt(position);
		else return Vector3.Zero;

		// Calm volumes damp the ambient wave displacement, matching the shader
		displacement *= 1.0f - (Current?.ComputeCalm(position) ?? 0.0f);

		// Interactive ripples add purely vertical displacement on top — NOT calmed
		displacement.z += Current?.ComputeRippleHeight((Vector2)position) ?? 0.0f;

		return displacement;
	}

	public static Vector3 GetWaveVelocityAt(Vector3 position)
	{
		// Rivers: the water itself moves — report the current as the wave velocity.
		WaterFlow flowSource = FindFlowAtPosition(position, out _, out Vector2 flowDir, out float flowSpeed);

		if (flowSource.IsValid())
			return new Vector3(flowDir.x, flowDir.y, 0.0f) * flowSpeed;

		WaterQuad quad = FindQuadAtPosition(position);
		WaterBody body = FindBodyAtPosition(position);

		Vector3 velocity;

		if (quad.IsValid() && body.IsValid())
			velocity = quad.GetWaveHeightAt(position) >= body.GetWaveHeightAt(position)
				? quad.GetWaveVelocityAt(position)
				: body.GetWaveVelocityAt(position);
		else if (quad.IsValid()) velocity = quad.GetWaveVelocityAt(position);
		else if (body.IsValid()) velocity = body.GetWaveVelocityAt(position);
		else return Vector3.Zero;

		// Calm volumes damp the wave motion too
		return velocity * (1.0f - (Current?.ComputeCalm(position) ?? 0.0f));
	}

	public static float GetFlatWaterHeightAt(Vector3 position)
	{
		WaterFlow flowSource = FindFlowAtPosition(position, out float flowHeight, out _, out _);

		if (flowSource.IsValid())
			return flowHeight;

		WaterQuad quad = FindQuadAtPosition(position);
		WaterBody body = FindBodyAtPosition(position);

		if (quad.IsValid() && body.IsValid())
			return MathF.Max(quad.WorldPosition.z, body.GetSurfaceHeight());

		if (quad.IsValid()) return quad.WorldPosition.z;
		if (body.IsValid()) return body.GetSurfaceHeight();
		return float.MinValue;
	}

	public static float GetWaterHeightAt(Vector3 position)
	{
		float height;
		float flat;   // the flat (no-wave) surface for this source — calm target

		WaterFlow flowSource = FindFlowAtPosition(position, out float flowHeight, out _, out _);

		if (flowSource.IsValid())
		{
			// River surface: level across the channel, follows the spline downstream.
			// (The shader's travelling waves are small detail — not mirrored on the CPU.)
			// Already flat on the CPU, so a calm volume is a no-op here.
			height = flowHeight;
			flat = flowHeight;
		}
		else
		{
			WaterQuad quad = FindQuadAtPosition(position);
			WaterBody body = FindBodyAtPosition(position);

			if (quad.IsValid() && body.IsValid())
			{
				float qh = quad.GetWaveHeightAt(position);
				float bh = body.GetWaveHeightAt(position);
				if (qh >= bh) { height = qh; flat = quad.WorldPosition.z; }
				else { height = bh; flat = body.GetSurfaceHeight(); }
			}
			else if (quad.IsValid()) { height = quad.GetWaveHeightAt(position); flat = quad.WorldPosition.z; }
			else if (body.IsValid()) { height = body.GetWaveHeightAt(position); flat = body.GetSurfaceHeight(); }
			else return float.MinValue;
		}

		// Calm volumes settle the ambient waves toward the flat surface, matching the
		// shader, so buoyancy sits on the same level the player sees.
		float calm = Current?.ComputeCalm(position) ?? 0.0f;
		if (calm > 0.0f)
			height = float.Lerp(height, flat, calm);

		// Interactive ripples raise/lower the effective surface on top — NOT calmed
		return height + (Current?.ComputeRippleHeight((Vector2)position) ?? 0.0f);
	}
}