🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (48 total matches found)
redsnail.watertool / Water/WaterManager.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;
using RenderStage = Sandbox.Rendering.Stage;

namespace RedSnail.WaterTool;

[Title("Water Manager")]
public partial class WaterManager : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer, IHotloadManaged
{
	private SceneCustomObject m_SceneObject;
	
	[SkipHotload] public static WaterManager Current { get; private set; } = null;
	
	[Property(Title = "Ocean"), Group("Profile"), Order(0)] public WaterDefinition OceanWaveProfile { get; set; }
	[Property(Title = "Lake"), Group("Profile")] public WaterDefinition LakeWaveProfile { get; set; }
	[Property(Title = "River"), Group("Profile")] public WaterDefinition RiverWaveProfile { get; set; }
	[Property(Title = "Pool"), Group("Profile")] public WaterDefinition PoolWaveProfile { get; set; }
	[Property(Title = "Custom"), Group("Profile")] public WaterDefinition CustomWaveProfile { get; set; }

	[Property(Title = "Underwater Volume"), Group("Post Processing")] public PostProcessVolume UnderwaterPostProcessVolume { get; set; }

	// Skips the whole compute + draw for any bounded water surface (pools, rivers) whose
	// bounds fall outside the camera frustum. The single biggest win when a scene has many
	// separate WaterQuads scattered around. Infinite oceans (WaterBodyRenderer) are never culled.
	[Property(Title = "Frustum Culling"), Group("Performance")] public bool EnableFrustumCulling { get; set; } = true;
	// Extra slack (world units) added to each surface's bounds before the frustum test, so
	// surfaces at the screen edge don't pop when the camera turns quickly.
	[Property(Title = "Cull Padding"), Group("Performance")] public float CullPadding { get; set; } = 256.0f;
	// Beyond this distance (world units, measured to the nearest point of a surface's bounds)
	// the surface is skipped entirely. 0 = no distance limit. Independent of frustum culling.
	[Property(Title = "Max Render Distance"), Group("Performance")] public float MaxRenderDistance { get; set; } = 25000.0f;

	// Distance LOD: distant water quads drop tessellation instead of staying at full density.
	// Each level halves the cell count and doubles the cell size, so the surface covers exactly
	// the same area with 4x fewer vertices — coverage, ring layout and texture tiling are all
	// unchanged, only the triangle density falls off with distance.
	[Property(Title = "Distance LOD"), Group("Performance")] public bool EnableDistanceLod { get; set; } = true;
	// Distance at which LOD 1 begins; each level after that doubles (LOD 2 at 2x, LOD 3 at 4x).
	[Property(Title = "LOD Start Distance"), Group("Performance")] public float LodStartDistance { get; set; } = 1000.0f;
	[Property(Title = "Max LOD Level"), Group("Performance"), Range(0, 4)] public int MaxLodLevel { get; set; } = 3;

	private ComputeShader m_ComputeShader;

	private CommandList m_CommandList = new("Water Rendering");

	private CameraComponent m_LastCamera;
	private Vector3 m_CameraPosition;
	private Frustum m_CullFrustum;
	private bool m_HasCullFrustum;
	private WaterDefinition m_DefaultProfile;

	// Rebuilt each RenderAll: the bounded surfaces that survived frustum culling. Reused
	// across the compute / barrier / draw phases so the decision is made exactly once.
	private readonly List<WaterQuad> m_VisibleQuads = [];
	private readonly List<WaterFlow> m_VisibleFlows = [];

	private List<WaterQuad> Quads { get; } = [];
	private List<WaterBodyRenderer> QuadRenderers { get; } = [];
	public List<WaterBody> Bodies { get; } = [];
	public List<WaterFlow> Flows { get; } = [];
	public List<WaterExclusionVolume> ExclusionVolumes { get; } = [];
	public List<HullWaterExclusionVolume> HullExclusionVolumes { get; } = [];
	
	
	
	protected override void OnAwake()
	{
		Current = Scene.Get<WaterManager>();
		
		m_ComputeShader = new ComputeShader("water_clipmap_cs");

		m_DefaultProfile = new WaterDefinition();
	}
	
	
	
	protected override void OnEnabled()
	{
		m_SceneObject = new SceneCustomObject(Scene.SceneWorld)
		{
			RenderOverride = RenderAll,
			Transform = new Transform(Vector3.Zero, Rotation.Identity),
			Flags =
			{
				IsOpaque = false,
				IsTranslucent = true,
				WantsFrameBufferCopy = false,
				WantsPrePass = false
			}
		};
		
		UpdateCommandListRegistration();

		RefreshWaterQuadsList();
		RefreshWaterBodyRenderersList();
		RefreshWaterBodiesList();
		RefreshWaterExclusionVolumesList();
		RefreshWaterHullExclusionVolumesList();
	}
	
	
	
	protected override void OnDisabled()
	{
		m_SceneObject?.Delete();
		m_SceneObject = null;

		m_RippleBuffer?.Dispose();
		m_RippleBuffer = null;
	
		ClearCalmVolumes();

		// Unregister from the camera we actually registered with. Scene.Camera can have changed
		// (or gone) since then, so asking for it again would leave the list attached to a camera
		// we never clean up.
		if (m_LastCamera.IsValid())
			m_LastCamera.RemoveCommandList(m_CommandList);

		m_LastCamera = null;
	}



	/// <summary>
	/// Keeps the compute command list attached to a camera that will actually replay it. This has
	/// to run every frame, not just on enable: a scene starting without a camera would never
	/// register at all, and leaving play mode destroys the play camera without the reference here
	/// turning null, so comparing references alone would leave us bound to a dead camera forever.
	/// </summary>
	private void UpdateCommandListRegistration()
	{
		var renderCamera = GetRenderCamera();

		if (renderCamera == m_LastCamera && m_LastCamera.IsValid())
			return;

		if (m_LastCamera.IsValid())
			m_LastCamera.RemoveCommandList(m_CommandList);

		m_LastCamera = null;

		if (renderCamera.IsValid())
		{
			renderCamera.AddCommandList(m_CommandList, RenderStage.AfterTransparent);
			m_LastCamera = renderCamera;
		}
	}



	/// <summary>
	/// The camera whose command list actually replays. A scene camera does so in the editor
	/// viewport as well as in game, so it wins when one exists; with no camera in the scene the
	/// editor camera is the only thing left that will replay ours.
	/// </summary>
	private CameraComponent GetRenderCamera()
	{
		if (Scene.Camera.IsValid())
			return Scene.Camera;

		if (Scene.IsEditor)
			return Application.Editor?.Camera;

		return null;
	}



	/// <summary>
	/// World position the water should treat as the viewer, for anything that culls or picks
	/// volumes by distance. While editing that has to be the viewport camera rather than the scene
	/// camera, or volumes are gathered around wherever the game camera happens to be parked and the
	/// water you are actually looking at gets the wrong set. Falls back when no camera exists at
	/// all, which is a real case - Scene.Camera excludes the editor camera and can be null.
	/// </summary>
	public static Vector3 GetViewPosition(Scene scene, Vector3 fallback = default)
	{
		if (!scene.IsValid())
			return fallback;

		if (scene.IsEditor)
		{
			var editorCamera = Application.Editor?.Camera;
			if (editorCamera.IsValid())
				return editorCamera.WorldPosition;
		}

		return scene.Camera.IsValid() ? scene.Camera.WorldPosition : fallback;
	}
	
	
	
	void IHotloadManaged.Destroyed(Dictionary<string, object> _State)
	{
		_State["IsActive"] = Current == this;
	}



	void IHotloadManaged.Created(IReadOnlyDictionary<string, object> _State)
	{
		if (_State.GetValueOrDefault("IsActive") is true)
			Current = this;
	}
	
	
	
	/// <summary>
	/// Whether a bounded water surface should render this frame: inside the cull camera's
	/// frustum and within the max render distance. Returns true — render it — when there's
	/// no viewer, or when both culls are disabled.
	/// </summary>
	/// <summary>Distance at which the given LOD level starts (level 1 = LodStartDistance).</summary>
	private float LodThreshold(int lod) => LodStartDistance * MathF.Pow(2.0f, lod - 1);

	/// <summary>
	/// Resolves the tessellation LOD for a surface from how far its bounds are from the viewer.
	/// Takes the surface's current level so the switch can be hysteretic: a level only changes
	/// once the distance is comfortably past the boundary, otherwise a camera hovering right on
	/// a threshold would rebuild that surface's GPU buffers every frame.
	/// </summary>
	public int ComputeLodLevel(BBox worldBounds, int currentLod)
	{
		if (!EnableDistanceLod || !m_HasCullFrustum || MaxLodLevel <= 0 || LodStartDistance <= 0.0f)
			return 0;

		const float hysteresis = 0.15f;

		float distance = worldBounds.ClosestPoint(m_CameraPosition).Distance(m_CameraPosition);

		int lod = Math.Clamp(currentLod, 0, MaxLodLevel);

		// Step out as the surface recedes, in as it approaches — one level at a time
		while (lod < MaxLodLevel && distance > LodThreshold(lod + 1) * (1.0f + hysteresis))
			lod++;

		while (lod > 0 && distance < LodThreshold(lod) * (1.0f - hysteresis))
			lod--;

		return lod;
	}



	private bool IsRenderVisible(BBox worldBounds)
	{
		// Both culls need a viewer; without one, don't cull anything.
		if (!m_HasCullFrustum)
			return true;

		// Distance cull — measured to the nearest point of the bounds, so a large surface
		// whose centre is far but edge is near still renders.
		if (MaxRenderDistance > 0.0f)
		{
			float distSq = worldBounds.ClosestPoint(m_CameraPosition).DistanceSquared(m_CameraPosition);

			if (distSq > MaxRenderDistance * MaxRenderDistance)
				return false;
		}

		// Frustum cull
		if (EnableFrustumCulling && !m_CullFrustum.IsInside(worldBounds.Grow(CullPadding), partially: true))
			return false;

		return true;
	}



	private void RenderAll(SceneObject _)
	{
		if (Graphics.LayerType != SceneLayerType.Translucent)
			return;

		m_CommandList.Reset();

		// Frustum-cull the bounded surfaces once, up front. The compute / barrier / draw
		// phases below all iterate these lists, so a culled surface pays for nothing.
		m_VisibleQuads.Clear();
		foreach (var quad in Quads)
		{
			if (quad.IsValid() && quad.ParticipatesInRendering && IsRenderVisible(quad.GetWorldBounds2D()))
				m_VisibleQuads.Add(quad);
		}

		m_VisibleFlows.Clear();
		foreach (var flow in Flows)
		{
			if (flow.IsValid() && flow.ParticipatesInRendering && IsRenderVisible(flow.GetWorldBounds()))
				m_VisibleFlows.Add(flow);
		}

		bool hasAnythingToRender = false;

		// Renderers are the infinite ocean surfaces — never culled (their bounds are "everywhere")
		foreach (var renderer in QuadRenderers)
		{
			if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
				continue;

			hasAnythingToRender = true;
			renderer.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);
		}

		foreach (var quad in m_VisibleQuads)
		{
			hasAnythingToRender = true;
			quad.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);
		}

		// Flows build their mesh on the CPU (no compute pass or barrier needed)
		if (m_VisibleFlows.Count > 0)
			hasAnythingToRender = true;

		if (hasAnythingToRender)
		{
			foreach (var renderer in QuadRenderers)
			{
				if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
					continue;

				renderer.BarrierTransition(m_CommandList);
			}

			foreach (var quad in m_VisibleQuads)
				quad.BarrierTransition(m_CommandList);

			m_CommandList.Attributes.GrabFrameTexture("FrameBufferCopyTexture");

			foreach (var renderer in QuadRenderers)
			{
				if (!renderer.IsValid() || !renderer.ParticipatesInRendering)
					continue;

				renderer.Draw(m_CommandList);
			}

			foreach (var quad in m_VisibleQuads)
				quad.Draw(m_CommandList);

			foreach (var flow in m_VisibleFlows)
				flow.Draw(m_CommandList);
		}
	}
	
	
	
	protected override void OnUpdate()
	{
		// We've to make sure it's always correct while in the editor
		// (S&box is a complete mess when it comes to managing a singleton properly on a component that execute in the editor, bcs its reference get constantly swapped between
		// gameplay and editor, we've to do this non sense !)
		if (Scene.IsEditor)
			Current = Scene.Get<WaterManager>();

		UpdateCommandListRegistration();

		// The camera we cull and centre the clipmap against: the game camera while playing,
		// otherwise the editor viewport camera so culling follows what you're actually looking at.
		CameraComponent cullCamera = Game.IsPlaying ? Scene.Camera : Application.Editor?.Camera;

		if (cullCamera.IsValid())
		{
			m_CameraPosition = cullCamera.WorldPosition;
			m_CullFrustum = cullCamera.GetFrustum();
			m_HasCullFrustum = true;
		}
		else
		{
			m_CameraPosition = Vector3.Zero;
			m_HasCullFrustum = false;
		}

		if (UnderwaterPostProcessVolume.IsValid())
			UnderwaterPostProcessVolume.Enabled = IsPositionInsideAny(m_CameraPosition);

		UpdateRipples();
		UpdateCalmVolumes();
	}

	/// <summary>
	/// We have to do all this non sense bcs using a Register/Unregister logic with OnEnabled/OnDisabled is a complete
	/// mess to manage when we enter play mode/stop play mode in the editor, the references get duplicated etc... Otherwise we've to check by gameobject id...
	/// It's just way too annoying, refreshing the whole list is safer and we're always sure to have the proper count of components
	/// </summary>
	public void RefreshWaterQuadsList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		Quads.Clear();
		Quads.AddRange(Scene.GetAll<WaterQuad>());
	}

	public void RefreshWaterBodyRenderersList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		QuadRenderers.Clear();
		QuadRenderers.AddRange(Scene.GetAll<WaterBodyRenderer>());
	}

	public void RefreshWaterBodiesList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		Bodies.Clear();
		Bodies.AddRange(Scene.GetAll<WaterBody>());
	}
	
	public void RefreshWaterFlowsList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		Flows.Clear();
		Flows.AddRange(Scene.GetAll<WaterFlow>());
	}

	public void RefreshWaterExclusionVolumesList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		ExclusionVolumes.Clear();
		ExclusionVolumes.AddRange(Scene.GetAll<WaterExclusionVolume>());
	}

	public void RefreshWaterHullExclusionVolumesList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		HullExclusionVolumes.Clear();
		HullExclusionVolumes.AddRange(Scene.GetAll<HullWaterExclusionVolume>());
	}

	private WaterDefinition GetWaveProfileForType(WaterBodyType waterType) => waterType switch
	{
		WaterBodyType.Ocean => OceanWaveProfile,
		WaterBodyType.Lake => LakeWaveProfile,
		WaterBodyType.River => RiverWaveProfile,
		WaterBodyType.Pool => PoolWaveProfile,
		_ => CustomWaveProfile
	};

	public static WaterDefinition GetWaveProfile(WaterBodyType _WaterType)
	{
		if (Current == null)
			return null;

		WaterDefinition profile = Current.GetWaveProfileForType(_WaterType);

		if (profile.IsValid())
			return profile;

		Log.Warning("[WaterTool] No water profile found in the 'Water Manager', please add a water profile for the specified water type ! (Project Settings > Water Manager > 'Assign the profiles')");

		return Current.m_DefaultProfile;
	}
}
redsnail.watertool / Water/WaterCalmVolume.cs
Game library
using Sandbox;
using Sandbox.Volumes;

namespace RedSnail.WaterTool;

/// <summary>
/// Calms the water inside a volume: wave displacement (and the surface normals that
/// come from it) smoothly fade to flat. Affects every water surface — WaterQuad,
/// WaterBodyRenderer and WaterFlow — so it's the clean way to blend two of them
/// together. The classic use is a river mouth meeting an ocean: drop a calm volume
/// over the junction, set both surfaces to the same height there, and the wave
/// mismatch (ocean chop poking above the river, seams) disappears.
///
/// Purely visual — it doesn't touch buoyancy, swimming or the flow current.
/// </summary>
[Title("Water Calm Volume")]
[Category("Volumes")]
[Icon("water")]
public sealed class WaterCalmVolume : VolumeComponent, Component.ExecuteInEditor
{
	// 0 = no effect, 1 = perfectly flat at the core. Lets a volume only partially
	// settle the water if you want some residual motion.
	[Property, Range(0.0f, 1.0f)] public float Strength { get; set; } = 1.0f;

	// Fraction of the volume (from each face inward) over which the calming ramps in.
	// 0 = hard edge (a visible crease), 1 = ramps all the way from the center.
	[Property, Range(0.05f, 1.0f)] public float Falloff { get; set; } = 0.4f;
	
	
	
	protected override void OnEnabled()
	{
		WaterManager.Current?.RefreshWaterCalmVolumesList();
	}
	
	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterCalmVolumesList();
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		if (!Gizmo.IsSelected)
			return;

		// Faint fill so calm volumes read differently from exclusion volumes
		BBox box = SceneVolume.GetBounds();

		Gizmo.Draw.Color = Color.Cyan.WithAlpha(0.06f);
		Gizmo.Draw.SolidBox(box);
	}

	public (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()
	{
		BBox local = SceneVolume.GetBounds();
		Vector3 center = WorldTransform.PointToWorld(local.Center);
		Vector3 halfExtents = local.Size * 0.5f;

		return (center, WorldRotation.Forward, WorldTransform.Up, halfExtents);
	}
}
redsnail.watertool / Water/WaterManager.CalmVolumes.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.WaterTool;

public partial class WaterManager
{
	// Calm volumes are few (river/ocean junctions) and apply to every water surface,
	// so — like ripples — they live in one shared buffer the manager updates once a
	// frame, rather than the per-component distance-sorted exclusion-volume pattern.

	private const int MAX_CALM_VOLUMES = 64;
	private const int CALM_VOLUME_ROWS = 4;

	public List<WaterCalmVolume> CalmVolumes { get; } = [];

	private GpuBuffer<Vector4> m_CalmVolumeBuffer;
	private readonly Vector4[] m_CalmVolumeData = new Vector4[MAX_CALM_VOLUMES * CALM_VOLUME_ROWS];
	private int m_ActiveCalmCount;
	
	
	
	public void RefreshWaterCalmVolumesList()
	{
		if (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)
			return;
		
		CalmVolumes.Clear();
		CalmVolumes.AddRange(Scene.GetAll<WaterCalmVolume>());
	}
	
	
	
	private void UpdateCalmVolumes()
	{
		int count = 0;

		foreach (var volume in CalmVolumes)
		{
			if (!volume.IsValid() || !volume.Active)
				continue;

			if (count >= MAX_CALM_VOLUMES)
				break;

			var (center, forward, up, half) = volume.GetWorldOBB();

			int row = count * CALM_VOLUME_ROWS;
			m_CalmVolumeData[row + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_CalmVolumeData[row + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_CalmVolumeData[row + 2] = new Vector4(center.x, center.y, center.z, half.z);
			m_CalmVolumeData[row + 3] = new Vector4(volume.Falloff, volume.Strength, 0.0f, 0.0f);

			count++;
		}

		m_ActiveCalmCount = count;

		EnsureCalmBuffer();

		m_CalmVolumeBuffer.SetData(m_CalmVolumeData.AsSpan(0, count * CALM_VOLUME_ROWS));
	}

	private void EnsureCalmBuffer()
	{
		if (!m_CalmVolumeBuffer.IsValid())
			m_CalmVolumeBuffer = new GpuBuffer<Vector4>(MAX_CALM_VOLUMES * CALM_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);
	}

	internal void ApplyCalmAttributes(RenderAttributes _Attributes)
	{
		_Attributes.Set("WaterCalmVolumeCount", m_ActiveCalmCount);

		if (m_CalmVolumeBuffer.IsValid())
			_Attributes.Set("WaterCalmVolumeData", m_CalmVolumeBuffer);
	}



	/// <summary>
	/// CPU evaluation of the calm factor at a world position (0 = full waves, 1 = flat).
	/// MUST mirror ComputeWaterCalm() in water_calm_volume.fxc so physics (buoyancy,
	/// height queries) matches the flattened visual surface.
	/// </summary>
	public float ComputeCalm(Vector3 _WorldPosition)
	{
		if (CalmVolumes.Count == 0)
			return 0.0f;

		float calm = 0.0f;

		foreach (var volume in CalmVolumes)
		{
			if (!volume.IsValid() || !volume.Active)
				continue;

			var (center, forward, up, half) = volume.GetWorldOBB();

			Vector3 right = Vector3.Cross(up, forward);
			Vector3 d = _WorldPosition - center;

			float nx = MathF.Abs(Vector3.Dot(d, forward)) / MathF.Max(half.x, 0.001f);
			float ny = MathF.Abs(Vector3.Dot(d, right))   / MathF.Max(half.y, 0.001f);
			float nz = MathF.Abs(Vector3.Dot(d, up))      / MathF.Max(half.z, 0.001f);

			float nmax = MathF.Max(nx, MathF.Max(ny, nz));

			float falloffStart = Math.Clamp(1.0f - volume.Falloff, 0.0f, 1.0f);
			float volumeCalm = (1.0f - SmoothStep(falloffStart, 1.0f, nmax)) * volume.Strength;

			calm = MathF.Max(calm, volumeCalm);
		}

		return Math.Clamp(calm, 0.0f, 1.0f);
	}

	// Matches HLSL smoothstep().
	private static float SmoothStep(float _Edge0, float _Edge1, float _X)
	{
		float t = Math.Clamp((_X - _Edge0) / MathF.Max(_Edge1 - _Edge0, 1e-6f), 0.0f, 1.0f);
		return t * t * (3.0f - 2.0f * t);
	}

	private void ClearCalmVolumes()
	{
		m_CalmVolumeBuffer?.Dispose();
		m_CalmVolumeBuffer = null;
	}
}
redsnail.watertool / Water/WaterWaveUtility.cs
Game library
using System;
using Sandbox;

namespace RedSnail.WaterTool;

public enum WaterBodyType
{
	Ocean,
	Lake,
	River,
	Pool,
	Custom
}

public static class WaterWaveUtility
{
	public static Vector3 ComputeDisplacementAt(Vector2 worldXY, WaterDefinition profile)
	{
		Vector3 detail = ComputeGerstner(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;
		Vector3 swell = ComputeGerstner(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;
		return detail + swell;
	}

	public static Vector3 ComputeVelocityAt(Vector2 worldXY, WaterDefinition profile)
	{
		Vector3 detail = ComputeGerstnerVelocity(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;
		Vector3 swell = ComputeGerstnerVelocity(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;
		return detail + swell;
	}

	private static Vector3 ComputeGerstner(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)
	{
		if (scale <= 0.0f || speed <= 0.0f || octaves <= 0)
			return Vector3.Zero;

		Vector2 waveDirection = direction.Normal;
		float t = Time.Now * speed;

		Vector3 displacement = Vector3.Zero;
		float amp = 1.0f;
		float freq = scale;
		float maxAmp = 0f;

		for (int oct = 0; oct < octaves; oct++)
		{
			float angle = oct * 1.2f;
			Vector2 octDir = new(
				waveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),
				waveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)
			);

			float phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;
			displacement.x += steepness * amp * octDir.x * MathF.Cos(phase);
			displacement.y += steepness * amp * octDir.y * MathF.Cos(phase);
			displacement.z += amp * MathF.Sin(phase);

			maxAmp += amp;
			amp *= persistence;
			freq *= lacunarity;
		}

		return maxAmp > 0.0f ? displacement / maxAmp : Vector3.Zero;
	}

	private static Vector3 ComputeGerstnerVelocity(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)
	{
		if (scale <= 0.0f || speed <= 0.0f || octaves <= 0)
			return Vector3.Zero;

		Vector2 waveDirection = direction.Normal;
		float t = Time.Now * speed;

		Vector3 velocity = Vector3.Zero;
		float amp = 1.0f;
		float freq = scale;
		float maxAmp = 0f;

		for (int oct = 0; oct < octaves; oct++)
		{
			float angle = oct * 1.2f;
			Vector2 octDir = new(
				waveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),
				waveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)
			);

			float phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;
			float angularVelocity = freq * speed * 0.5f;

			velocity.x -= steepness * amp * octDir.x * angularVelocity * MathF.Sin(phase);
			velocity.y -= steepness * amp * octDir.y * angularVelocity * MathF.Sin(phase);
			velocity.z += amp * angularVelocity * MathF.Cos(phase);

			maxAmp += amp;
			amp *= persistence;
			freq *= lacunarity;
		}

		return maxAmp > 0.0f ? velocity / maxAmp : Vector3.Zero;
	}
}
redsnail.watertool / Code/Water/HullWaterExclusionVolume.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace RedSnail.WaterTool;

/// <summary>
/// Excludes the water surface inside a mesh hull rather than an approximated box volume.
/// Place on the same GameObject as the ModelRenderer. The physics collision mesh is extracted
/// once and uploaded to the GPU as a triangle list; only the WorldToLocal matrix is updated
/// each frame as the object moves or rotates.
/// </summary>
[Title("Hull Water Exclusion Volume"), Group("Water"), Icon("sailing")]
public sealed class HullWaterExclusionVolume : Component, Component.ExecuteInEditor
{
	/// <summary>Triangle vertices in model LOCAL space, flat (v0,v1,v2, v0,v1,v2 …).</summary>
	public Vector3[] LocalTriangles { get; private set; } = Array.Empty<Vector3>();

	/// <summary>AABB of all local triangles, used for early GPU rejection.</summary>
	public BBox LocalAABB { get; private set; }
	
	[Property] private Model CustomModel { get; set; }

	private Model _lastModel;

	protected override void OnEnabled()
	{
		RebuildMesh();

		WaterManager.Current?.RefreshWaterHullExclusionVolumesList();
	}

	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterHullExclusionVolumesList();
	}

	protected override void OnUpdate()
	{
		var model = CustomModel.IsValid() ? CustomModel : GetComponent<ModelRenderer>()?.Model;
		
		if (model != _lastModel)
			RebuildMesh();
	}

	private void RebuildMesh()
	{
		var model = CustomModel.IsValid() ? CustomModel : GetComponent<ModelRenderer>()?.Model;

		if (model == null)
		{
			LocalTriangles = Array.Empty<Vector3>();
			LocalAABB = default;
			_lastModel = null;
			Log.Warning($"{nameof(HullWaterExclusionVolume)}: No ModelRenderer or Model found.");
			return;
		}

		_lastModel = model;

		var tris = new List<Vector3>();
		var aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
		var aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);

		// Prefer the physics collision mesh — it's already simplified and watertight.
		var physics = model.Physics;
		if (physics != null)
		{
			foreach (var part in physics.Parts)
			{
				foreach (var meshPart in part.Meshes)
				{
					foreach (var tri in meshPart.GetTriangles())
					{
						tris.Add(tri.A);
						tris.Add(tri.B);
						tris.Add(tri.C);

						aabbMin = Vector3.Min(aabbMin, Vector3.Min(tri.A, Vector3.Min(tri.B, tri.C)));
						aabbMax = Vector3.Max(aabbMax, Vector3.Max(tri.A, Vector3.Max(tri.B, tri.C)));
					}
				}

				// Convex hull shapes have no MeshParts — triangulate each hull instead.
				foreach (var hullPart in part.Hulls)
				{
					var pts = hullPart.GetPoints()?.ToArray();
					if (pts == null || pts.Length < 4) continue;
					TriangulateConvexHull(pts, tris, ref aabbMin, ref aabbMax);
				}
			}
		}

		// Fallback: render mesh (may have more triangles, less ideal for GPU iteration)
		if (tris.Count == 0)
		{
			var vertices = model.GetVertices();
			var indices = model.GetIndices();

			if (vertices != null && indices != null)
			{
				for (int i = 0; i + 2 < indices.Length; i += 3)
				{
					Vector3 v0 = vertices[indices[i + 0]].Position;
					Vector3 v1 = vertices[indices[i + 1]].Position;
					Vector3 v2 = vertices[indices[i + 2]].Position;

					tris.Add(v0);
					tris.Add(v1);
					tris.Add(v2);

					aabbMin = Vector3.Min(aabbMin, Vector3.Min(v0, Vector3.Min(v1, v2)));
					aabbMax = Vector3.Max(aabbMax, Vector3.Max(v0, Vector3.Max(v1, v2)));
				}
			}
		}

		LocalTriangles = tris.ToArray();
		LocalAABB = tris.Count > 0 ? new BBox(aabbMin, aabbMax) : default;
	}

	// N³ convex hull triangulation.
	// Finds each hull face by collecting ALL coplanar vertices, then fan-triangulates once per face.
	// Without this, rectangular faces (4 coplanar verts) emit C(4,3)=4 overlapping triangles,
	// flipping the ray parity and incorrectly marking exterior points as inside.
	private static void TriangulateConvexHull(Vector3[] verts, List<Vector3> result, ref Vector3 aabbMin, ref Vector3 aabbMax)
	{
		int n = verts.Length;
		if (n < 4) return;

		var centroid = Vector3.Zero;
		foreach (var v in verts) centroid += v;
		centroid /= n;

		var processedFaces = new HashSet<string>();

		for (int i = 0; i < n; i++)
			for (int j = i + 1; j < n; j++)
				for (int k = j + 1; k < n; k++)
				{
					Vector3 A = verts[i], B = verts[j], C = verts[k];
					Vector3 rawNormal = Vector3.Cross(B - A, C - A);
					if (rawNormal.LengthSquared < 1e-8f) continue;
					Vector3 normal = rawNormal.Normal; // normalize so d = actual distance in units

					bool pos = false, neg = false;
					var faceIndices = new List<int> { i, j, k };

					for (int m = 0; m < n; m++)
					{
						if (m == i || m == j || m == k) continue;
						float d = Vector3.Dot(normal, verts[m] - A);
						if (MathF.Abs(d) < 0.01f)
							faceIndices.Add(m);   // coplanar — part of this face
						else if (d > 0f) pos = true;
						else neg = true;
					}

					if (pos && neg) continue;     // interior edge, not a hull face
					if (!pos && !neg) continue;   // degenerate — no non-coplanar vertices

					// Canonical key: sorted vertex indices — each face processed exactly once.
					faceIndices.Sort();
					string key = string.Join(",", faceIndices);
					if (!processedFaces.Add(key)) continue;

					// Collect face vertices and sort by angle around the face centroid.
					var faceVerts = faceIndices.Select(idx => verts[idx]).ToList();
					var fc = Vector3.Zero;
					foreach (var fv in faceVerts) fc += fv;
					fc /= faceVerts.Count;

					// Build a 2D frame in the face plane for angle sorting.
					var outward = (Vector3.Dot(normal, centroid - A) < 0f) ? normal : -normal;
					var tan = faceVerts.Select(fv => fv - fc).FirstOrDefault(d => d.LengthSquared > 1e-8f);
					tan = tan.Normal;
					var bitan = Vector3.Cross(outward.Normal, tan);

					faceVerts.Sort((p, q) =>
					{
						float ap = MathF.Atan2(Vector3.Dot(p - fc, bitan), Vector3.Dot(p - fc, tan));
						float aq = MathF.Atan2(Vector3.Dot(q - fc, bitan), Vector3.Dot(q - fc, tan));
						return ap.CompareTo(aq);
					});

					// Fan triangulate the face.
					for (int t = 1; t < faceVerts.Count - 1; t++)
					{
						var ta = faceVerts[0]; var tb = faceVerts[t]; var tc = faceVerts[t + 1];
						result.Add(ta); result.Add(tb); result.Add(tc);
						aabbMin = Vector3.Min(aabbMin, Vector3.Min(ta, Vector3.Min(tb, tc)));
						aabbMax = Vector3.Max(aabbMax, Vector3.Max(ta, Vector3.Max(tb, tc)));
					}
				}
	}

	/// <summary>
	/// Fills the 4 rows of the WorldToLocal matrix (row-major, for mul(M, float4(worldPos,1)) in HLSL).
	/// </summary>
	/// <summary>
	/// Matches WorldTransform.PointToLocal = Rotation.Inverse * (worldPt - Position) / Scale.
	/// In s&box: Forward=(1,0,0)=localX, Left=-Right=(0,1,0)=localY, Up=(0,0,1)=localZ.
	/// </summary>
	public void GetWorldToLocalRows(out Vector4 r0, out Vector4 r1, out Vector4 r2, out Vector4 r3)
	{
		Vector3 fwd = WorldRotation.Forward;        // world-space local X axis
		Vector3 left = -WorldRotation.Right;          // world-space local Y axis  (Right = -Y in s&box)
		Vector3 up = WorldRotation.Up;             // world-space local Z axis
		Vector3 pos = WorldPosition;
		Vector3 scale = WorldScale;

		float isx = MathF.Abs(scale.x) > 1e-6f ? 1f / scale.x : 0f;
		float isy = MathF.Abs(scale.y) > 1e-6f ? 1f / scale.y : 0f;
		float isz = MathF.Abs(scale.z) > 1e-6f ? 1f / scale.z : 0f;

		r0 = new Vector4(fwd.x * isx, fwd.y * isx, fwd.z * isx, -Vector3.Dot(fwd, pos) * isx);
		r1 = new Vector4(left.x * isy, left.y * isy, left.z * isy, -Vector3.Dot(left, pos) * isy);
		r2 = new Vector4(up.x * isz, up.y * isz, up.z * isz, -Vector3.Dot(up, pos) * isz);
		r3 = new Vector4(0f, 0f, 0f, 1f);
	}

	protected override void DrawGizmos()
	{
		if (!Gizmo.IsSelected || LocalTriangles == null || LocalTriangles.Length == 0)
			return;

		Gizmo.Draw.Color = Color.Yellow.WithAlpha(0.5f);
		Gizmo.Draw.LineBBox(LocalAABB);
	}
}
redsnail.watertool / Water/WaterRippleEmitter.cs
Game library
using Sandbox;

namespace RedSnail.WaterTool;

/// <summary>
/// Emits water ripples when this object crosses the water surface, and optionally
/// while it moves across it. A generic, dependency-free alternative to the entry
/// ripple built into <see cref="Buoyancy"/> — drop it on anything that doesn't have
/// a Buoyancy component (players, NPCs, projectiles, debris...).
///
/// Velocity is derived from the object's own position delta, so it works with any
/// movement system (CharacterController, custom controllers, animation, etc.) and
/// needs no Rigidbody.
/// </summary>
[Icon("water"), Group("Water"), Title("Water Ripple Emitter")]
public sealed class WaterRippleEmitter : Component
{
	[Property, Group("Entry")] public bool EmitOnEntry { get; set; } = true;
	[Property, Group("Entry")] public float EntryStrength { get; set; } = 0.2f;
	// Ring spacing for the entry splash — smaller = tighter, more concentric rings.
	[Property, Group("Entry"), Range(20.0f, 400.0f)] public float EntryWavelength { get; set; } = 120.0f;
	// Ring size for the entry splash — larger = a bigger, broader ripple.
	[Property, Group("Entry"), Range(10.0f, 500.0f)] public float EntryRingWidth { get; set; } = 50.0f;
	// Minimum downward speed (units/s) needed to splash. Set to 0 to ripple on any crossing.
	[Property, Group("Entry")] public float MinImpactSpeed { get; set; } = 40.0f;

	[Property, Group("Wake")] public bool EmitWake { get; set; } = false;
	[Property, Group("Wake")] public float WakeStrength { get; set; } = 0.1f;
	// Ring spacing for wake ripples — smaller = tighter, more concentric rings.
	[Property, Group("Wake"), Range(20.0f, 400.0f)] public float WakeWavelength { get; set; } = 120.0f;
	// Ring size for wake ripples — larger = a bigger, broader ripple.
	[Property, Group("Wake"), Range(10.0f, 500.0f)] public float WakeRingWidth { get; set; } = 50.0f;
	// Minimum horizontal speed (units/s) before a moving object leaves a wake.
	[Property, Group("Wake")] public float WakeMinSpeed { get; set; } = 1.0f;
	[Property, Group("Wake")] public float WakeInterval { get; set; } = 0.0333f; // 30 fps

	// Local-space offset of the point tested against the surface (e.g. the feet).
	[Property, Group("General")] public Vector3 SampleOffset { get; set; } = Vector3.Zero;

	private bool m_Initialized;
	private bool m_WasBelowSurface;
	private Vector3 m_LastPosition;
	private float m_WakeTimer;

	private Vector3 SamplePosition => WorldPosition + WorldRotation * SampleOffset;



	protected override void OnEnabled()
	{
		m_LastPosition = SamplePosition;
		m_WasBelowSurface = false;
		m_Initialized = false;
	}



	protected override void OnUpdate()
	{
		// If this gameobject is parented to anything, we don't want to play water ripple effects
		// (e.g. A player inside a boat)
		if (GameObject.Parent != Scene)
			return;
		
		Vector3 samplePos = SamplePosition;

		// Velocity from position delta — no Rigidbody required
		Vector3 velocity = Time.Delta > 0.0f ? (samplePos - m_LastPosition) / Time.Delta : Vector3.Zero;
		m_LastPosition = samplePos;

		float waterHeight = WaterManager.GetWaterHeightAt(samplePos);

		// Not over any water surface
		if (waterHeight <= float.MinValue)
		{
			m_WasBelowSurface = false;
			return;
		}

		bool belowSurface = samplePos.z <= waterHeight;

		// Skip the first valid frame so an object spawned already in water doesn't splash
		if (!m_Initialized)
		{
			m_WasBelowSurface = belowSurface;
			m_Initialized = true;
			return;
		}

		// Entry splash on the above -> below surface crossing
		if (EmitOnEntry && belowSurface && !m_WasBelowSurface)
		{
			float impactSpeed = float.Max(0.0f, -velocity.z);

			if (impactSpeed >= MinImpactSpeed)
			{
				float strength = (impactSpeed / 150.0f).Clamp(0.3f, 2.5f) * EntryStrength;
				
				WaterManager.AddRipple(samplePos.WithZ(waterHeight), strength, EntryWavelength, EntryRingWidth);
			}
		}

		m_WasBelowSurface = belowSurface;

		float horizontalSpeed = velocity.WithZ(0.0f).Length;
		
		// Continuous wake while skimming/swimming through the surface
		if (EmitWake && belowSurface)
		{
			if (horizontalSpeed >= WakeMinSpeed)
			{
				m_WakeTimer -= Time.Delta;

				if (m_WakeTimer <= 0.0f)
				{
					WaterManager.AddRipple(samplePos.WithZ(waterHeight), WakeStrength, WakeWavelength, WakeRingWidth);
					m_WakeTimer = WakeInterval;
				}
			}
		}
	}
}
redsnail.watertool / Editor/WaterFlowTool.cs
Editor library
using Sandbox;
using Editor;

namespace RedSnail.WaterTool.Editor;

/// <summary>
/// Scene editor tool for the WaterFlow component. Activates when a WaterFlow is
/// selected and hosts the spline editor: select points, drag them and their In/Out
/// tangent handles (for curved rivers), click on the river to insert a point, and
/// shift-drag a point to extrude a new one. All edits are undo-aware and rebuild
/// the river mesh live.
/// </summary>
[Title("Water Flow")]
[Icon("waves")]
[Alias("water_flow")]
[Group("1")]
[Order(1)]
public class WaterFlowTool : EditorTool<WaterFlow>
{
	private WaterFlowWindow m_Window;
	private WaterFlow m_Selected;



	public override void OnEnabled()
	{
		m_Window = new WaterFlowWindow();

		AddOverlay(m_Window, TextFlag.RightBottom, 10);

		OnSelectionChanged();
	}



	public override void OnDisabled()
	{
		m_Window?.OnDisabled();
	}



	public override void OnUpdate()
	{
		m_Window?.OnUpdate();
	}



	public override void OnSelectionChanged()
	{
		WaterFlow target = GetSelectedComponent<WaterFlow>();

		if (!target.IsValid())
			return;

		// Only re-target when the component itself changes — otherwise this fires on
		// every property edit and would reset the selected point each time.
		if (target != m_Selected)
		{
			m_Window?.OnSelectionChanged(target);

			m_Selected = target;
		}
	}
}
redsnail.watertool / Editor/WaterFlowWindow.UI.cs
Editor library
using Sandbox;
using Editor;

namespace RedSnail.WaterTool.Editor;

public partial class WaterFlowWindow
{
	private const int HEADER_HEIGHT = 32;



	private void Rebuild()
	{
		Layout.Clear(true);
		Layout.Margin = 0;

		Icon = _isClosed ? "" : "waves";
		UpdateWindowTitle();
		IsGrabbable = !_isClosed;

		if (_isClosed)
		{
			BuildClosedState();
			return;
		}

		MinimumWidth = 360;
		BuildHeader();

		if (_targetComponent.IsValid())
			BuildControlSheet();

		Layout.Margin = 4;
	}



	private void BuildClosedState()
	{
		var closedRow = Layout.AddRow();

		closedRow.Add(new IconButton("waves", () => { _isClosed = false; Rebuild(); })
		{
			ToolTip = "Open Water Flow Spline Editor",
			FixedHeight = HEADER_HEIGHT,
			FixedWidth = HEADER_HEIGHT,
			Background = Color.Transparent
		});

		MinimumWidth = 0;
	}



	private void BuildHeader()
	{
		var headerRow = Layout.AddRow();

		headerRow.AddStretchCell();

		headerRow.Add(new IconButton("info")
		{
			ToolTip = GetInfoTooltip(),
			FixedHeight = HEADER_HEIGHT,
			FixedWidth = HEADER_HEIGHT,
			Background = Color.Transparent
		});

		headerRow.Add(new IconButton("close", CloseWindow)
		{
			ToolTip = "Close Editor",
			FixedHeight = HEADER_HEIGHT,
			FixedWidth = HEADER_HEIGHT,
			Background = Color.Transparent
		});
	}



	private string GetInfoTooltip()
	{
		return "Edit the river's spline.\n\n" +
			   "• Click a point to select it, then drag it or its In/Out tangent handles.\n" +
			   "• Tangent Mode controls the curve: Auto smooths, Linear makes sharp corners,\n" +
			   "  Mirrored/Split let you shape the bend by hand.\n" +
			   "• Click anywhere on the river to insert a point there.\n" +
			   "• Hold Shift while dragging a point to drag out a new one.\n\n" +
			   "The source point is green, the mouth is red.";
	}



	private void BuildControlSheet()
	{
		var serialized = this.GetSerialized();
		var controlSheet = new ControlSheet();

		controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointTangentMode)));
		_positionControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointPosition)));
		_inTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointIn)));
		_outTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointOut)));

		controlSheet.AddLayout(BuildControlButtons());

		Layout.Add(controlSheet);

		ToggleTangentInput();
	}



	private Layout BuildControlButtons()
	{
		var row = Layout.Row();
		row.Spacing = 16;
		row.Margin = 8;

		row.Add(CreateNavigationButton("skip_previous", -1, "Go to previous point"));
		row.Add(CreateNavigationButton("skip_next", 1, "Go to next point"));
		row.Add(CreateDeleteButton());
		row.Add(CreateAddButton());

		return row;
	}



	private IconButton CreateNavigationButton(string _Icon, int _Direction, string _Tooltip)
	{
		return new IconButton(_Icon, () =>
		{
			if (_Direction < 0)
				SelectedPointIndex = int.Max(0, SelectedPointIndex - 1);
			else
				SelectedPointIndex = int.Min(_targetComponent.Spline.PointCount - 1, SelectedPointIndex + 1);

			SelectPoint(SelectedPointIndex);
			Focus();
		})
		{ ToolTip = _Tooltip };
	}



	private IconButton CreateDeleteButton()
	{
		return new IconButton("delete", () =>
		{
			// The source point can't be deleted, and rivers need at least two points
			if (IsSourcePointSelected || _targetComponent.Spline.PointCount <= 2)
				return;

			using (CreateUndoScope("Delete Water Flow Point"))
			{
				_targetComponent.Spline.RemovePoint(SelectedPointIndex);
				SelectedPointIndex = int.Max(0, SelectedPointIndex - 1);
			}

			UpdateWindowTitle();
			Focus();
		})
		{ ToolTip = "Delete the selected point (the source point is locked; minimum 2 points)" };
	}



	private IconButton CreateAddButton()
	{
		return new IconButton("add", () =>
		{
			using (CreateUndoScope("Add Water Flow Point"))
			{
				InsertNewPoint();
				SelectedPointIndex++;
			}

			UpdateWindowTitle();
			Focus();
		})
		{
			ToolTip = "Insert a point after the selected one.\n" +
					  "You can also click on the river, or Shift-drag a point."
		};
	}



	private void InsertNewPoint()
	{
		var spline = _targetComponent.Spline;

		if (SelectedPointIndex == spline.PointCount - 1)
		{
			// Extend past the mouth, following the spline tangent
			float distance = spline.GetDistanceAtPoint(SelectedPointIndex);
			Vector3 tangent = spline.SampleAtDistance(distance).Tangent;
			Vector3 newPosition = _selectedPoint.Position + tangent * 256.0f;

			spline.InsertPoint(SelectedPointIndex + 1, _selectedPoint with { Position = newPosition });
		}
		else
		{
			// Split the segment toward the next point
			float currentDist = spline.GetDistanceAtPoint(SelectedPointIndex);
			float nextDist = spline.GetDistanceAtPoint(SelectedPointIndex + 1);

			spline.AddPointAtDistance((currentDist + nextDist) / 2.0f, true);
		}
	}



	private void UpdateWindowTitle()
	{
		WindowTitle = _isClosed
			? ""
			: $"Water Flow — Point [{SelectedPointIndex}] — {_targetComponent?.GameObject?.Name ?? ""}";
	}



	private void CloseWindow()
	{
		_isClosed = true;
		Rebuild();
		Position = Parent.Size - 32;
	}
}
redsnail.watertool / Code/Miscellaneous/BoatController.cs
Game library
using System;
using Sandbox;
using Sandbox.Movement;

namespace RedSnail.WaterTool;

/// <summary>
/// Minimal demo boat controller.
/// </summary>
[Title( "Demo Boat Controller" ), Group( "Water" ), Icon( "directions_boat" )]
public sealed class BoatController : Component, Component.IPressable, ISitTarget
{
	private TimeSince m_TimeSinceLastUnderWave;
	private float m_LastHitTimer = 1.0f;
	
	[Property, Group( "Seat" )] public GameObject SeatPosition { get; set; }
	[Property, Group( "Seat" )] public GameObject EyePosition  { get; set; }
	[Property, Group( "Seat" )] public GameObject ExitPoint    { get; set; }

	[Property, Group( "Movement" )] public float ThrustForce   { get; set; } = 200_000f;
	[Property, Group( "Movement" )] public float ReverseForce  { get; set; } = 80_000f;
	[Property, Group( "Movement" )] public float TurnForce     { get; set; } = 60_000f;
	[Property, Group( "Movement" )] public float Stability     { get; set; } = 50_000f;
	[Property, Group( "Movement" )] public float TerminalSpeed { get; set; } = 800f;

	[Property, Group( "Interaction" )] public string TooltipTitle { get; set; } = "Drive";
	[Property, Group( "Interaction" )] public string TooltipIcon  { get; set; } = "directions_boat";

	[Property, Group( "Sounds" )] public SoundEvent BoatUnderWaves  { get; set; }
	[Property, Group( "Sounds" )] public SoundPointComponent BoatOnWaterLoop  { get; set; }

	private Rigidbody m_Rigidbody;
	private Buoyancy m_Buoyancy;

	private float m_TargetThrust;
	private float m_TargetTurn;

	public bool IsOccupied => GetComponentInChildren<PlayerController>( false ) != null;



	protected override void OnStart()
	{
		m_Rigidbody = GetComponent<Rigidbody>();
		m_Buoyancy = GetComponent<Buoyancy>();
	}



	protected override void OnFixedUpdate()
	{
		if ( !m_Rigidbody.IsValid() )
			return;

		HandleSounds();
		Stabilize();

		if ( IsOccupied )
			HandleMovement();
		else
		{
			// Smoothly reset forces when unmanned
			m_TargetThrust = 0f;
			m_TargetTurn   = 0f;
		}
	}
	
	
	
	public bool CanPress( IPressable.Event e )
	{
		return e.Source is PlayerController && !IsOccupied;
	}

	public bool Press( IPressable.Event e )
	{
		if ( e.Source is not PlayerController player ) return false;
		if ( IsOccupied ) return false;

		MountPlayer( player );
		return true;
	}

	public IPressable.Tooltip? GetTooltip( IPressable.Event e )
	{
		if ( IsOccupied ) return null;
		
		var tooltip = new IPressable.Tooltip
		{
			Title = TooltipTitle,
			Icon = TooltipIcon
		};

		return tooltip;
	}
	
	
	
	public void AskToLeave( PlayerController player )
	{
		DismountPlayer( player );
	}

	public void UpdatePlayerAnimator( PlayerController controller, SkinnedModelRenderer renderer )
	{
		controller.LocalTransform = global::Transform.Zero;
		renderer.LocalRotation   = Rotation.Identity;
		renderer.Set( "sit",        (int)BaseChair.AnimatorSitPose.ChairForward );
		renderer.Set( "b_grounded", true );
		renderer.Set( "b_climbing", false );
		renderer.Set( "b_swim",     false );
		renderer.Set( "duck",       false );
	}

	public Transform CalculateEyeTransform( PlayerController controller )
	{
		var anchor = EyePosition ?? SeatPosition ?? GameObject;

		// Position follows the seat anchor so the camera rides with the boat.
		// Rotation uses the player's eye angles in pure world space, the boat's
		// pitch and roll are intentionally NOT applied so the view stays level
		// even when the hull bobs or banks.
		return new Transform
		{
			Position = anchor.WorldPosition,
			Rotation = controller.EyeAngles.ToRotation()
		};
	}
	
	
	
	private void MountPlayer( PlayerController player )
	{
		var seat = SeatPosition ?? GameObject;

		// Disable the player's own physics so they don't fight the boat
		if ( player.Body.IsValid() )          player.Body.Enabled = false;
		if ( player.ColliderObject.IsValid() ) player.ColliderObject.Enabled = false;
		
		player.GameObject.SetParent( seat, false );
		player.GameObject.LocalTransform = global::Transform.Zero;
	}

	private void DismountPlayer( PlayerController player )
	{
		player.GameObject.SetParent( null, true );
		
		if ( player.Body.IsValid() )          player.Body.Enabled = true;
		if ( player.ColliderObject.IsValid() ) player.ColliderObject.Enabled = true;
		
		// Move to exit point, or eject to the side if none is set
		player.WorldPosition = ExitPoint != null
			? ExitPoint.WorldPosition
			: WorldPosition + WorldRotation.Right * 100f + Vector3.Up * 30f;

		m_TargetThrust = 0f;
		m_TargetTurn   = 0f;
	}
	
	
	
	private void HandleMovement()
	{
		// Only push when the hull is actually in the water
		if ( m_Buoyancy is { IsTouchingWater: false } )
			return;

		float fwd  = Input.AnalogMove.x; // W = +1  S = -1
		float side = Input.AnalogMove.y; // D = +1  A = -1
		
		// Thrust
		float wantedThrust = fwd > 0.02f  ?  ThrustForce * fwd
		                   : fwd < -0.02f ? ReverseForce * fwd
		                   : 0f;

		m_TargetThrust = float.Lerp( m_TargetThrust, wantedThrust, Time.Delta * 3f );

		float speed   = m_Rigidbody.Velocity.WithZ( 0 ).Length;
		float limiter = MathF.Min( 1f, TerminalSpeed / ( speed + 0.001f ) );

		m_Rigidbody.ApplyForce( WorldRotation.Right * m_TargetThrust * limiter );

		// Turning
		float speedFactor = float.Clamp( speed / 200f, 0.2f, 1f );
		float wantedTurn  = side * TurnForce * speedFactor;
		m_TargetTurn       = float.Lerp( m_TargetTurn, wantedTurn, Time.Delta * 5f );

		Vector3 bow = WorldPosition + WorldRotation.Forward * 60f;
		m_Rigidbody.ApplyForceAt( bow, WorldRotation.Left * m_TargetTurn );

		// Speed dependent damping so the boat decelerates naturally
		float damping = ( TerminalSpeed / ( speed + 0.001f ) ) * 0.5f;
		m_Rigidbody.LinearDamping = float.Clamp( damping, 0.5f, 5f );
	}
	
	
	
	private void HandleSounds()
	{
		if (Scene.Camera is not CameraComponent camera)
			return;

		HandleWavesSound(camera);
		HandleMovementSound(camera);
	}
	
	
	
	private void HandleWavesSound(CameraComponent _Camera)
	{
		if (!BoatUnderWaves.IsValid())
			return;
		
		float distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);
		float MaxDistanceSq = BoatUnderWaves.Distance * BoatUnderWaves.Distance;

		float speed = m_Rigidbody.Velocity.WithZ(0).Length;
		
		if (speed < 10.0f && distance < MaxDistanceSq && m_Buoyancy.IsTouchingWater && m_TimeSinceLastUnderWave > m_LastHitTimer)
		{
			Sound.Play(BoatUnderWaves, WorldPosition);

			m_TimeSinceLastUnderWave = 0;
			m_LastHitTimer = Game.Random.Float(2.0f, 10.0f);
		}
	}
	
	
	
	private void HandleMovementSound(CameraComponent _Camera)
	{
		if (!BoatOnWaterLoop.IsValid())
			return;
		
		float distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);
		float MaxDistanceSq = BoatOnWaterLoop.Distance * BoatOnWaterLoop.Distance;
		
		if (distance > MaxDistanceSq)
		{
			// Disable the sound point if too far away from the camera (Avoid wasting resources)
			BoatOnWaterLoop.Enabled = false;
		}
		else
		{
			BoatOnWaterLoop.SoundOverride = true;
			BoatOnWaterLoop.Volume = m_Rigidbody.Velocity.WithZ(0).Length.Remap(0.0f, 200.0f);
			BoatOnWaterLoop.Enabled = true;
		}
	}
	
	
	
	private void Stabilize()
	{
		Vector3 torque = Vector3.Cross( WorldRotation.Up, Vector3.Up ) * Stability;
		m_Rigidbody.ApplyTorque( torque );
	}
}
redsnail.watertool / Code/Water/WaterBody.cs
Game library
using System;
using Sandbox;
using Sandbox.Volumes;

namespace RedSnail.WaterTool;

/// <summary>
/// Defines a discrete body of water that participates in a renderer-driven water system.
/// Provides volume bounds, a physics hull for buoyancy/swimming, and renderer inclusion in one component.
/// Requires a WaterQuadRenderer present in the scene to produce a visible water surface.
/// </summary>
[Title("Water Body")]
[Category("Water")]
[Icon("water_drop")]
public sealed class WaterBody : VolumeComponent, Component.ExecuteInEditor
{
	private HullCollider m_HullCollider;
	private BBox m_LastLocalBounds;

	[Property, Group("General")] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;

	protected override void OnEnabled()
	{
		WaterManager.Current?.RefreshWaterBodiesList();

		UpdateColliderState();

		m_LastLocalBounds = SceneVolume.GetBounds();
	}

	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterBodiesList();

		m_HullCollider?.Destroy();
		m_HullCollider = null;
	}

	protected override void OnUpdate()
	{
		BBox localBounds = SceneVolume.GetBounds();

		if (localBounds != m_LastLocalBounds)
		{
			UpdateColliderState();

			m_LastLocalBounds = localBounds;
		}
	}

	protected override void DrawGizmos()
	{
		if (!Gizmo.IsSelected || !m_HullCollider.IsValid())
			return;

		Gizmo.Draw.Color = Color.Cyan;
		Gizmo.Draw.LineBBox(m_HullCollider.LocalBounds);
	}

	// Bounds
	public void SetBounds(BBox bounds)
	{
		SceneVolume = SceneVolume with { Box = bounds };
	}

	public float GetSurfaceHeight()
	{
		BBox local = SceneVolume.GetBounds();

		return WorldTransform.PointToWorld(new Vector3(local.Center.x, local.Center.y, local.Maxs.z)).z;
	}

	public float GetBottomHeight()
	{
		BBox local = SceneVolume.GetBounds();

		return WorldTransform.PointToWorld(new Vector3(local.Center.x, local.Center.y, local.Mins.z)).z;
	}

	public bool ContainsPointXY(Vector3 worldPosition)
	{
		BBox local = SceneVolume.GetBounds();
		Vector3 point = WorldTransform.PointToLocal(worldPosition);
		Vector3 half = local.Size * 0.5f;

		return MathF.Abs(point.x - local.Center.x) <= half.x && MathF.Abs(point.y - local.Center.y) <= half.y;
	}

	public bool ContainsPointInVolume(Vector3 worldPosition)
	{
		BBox local = SceneVolume.GetBounds();

		Vector3 point = WorldTransform.PointToLocal(worldPosition);
		Vector3 half = local.Size * 0.5f;

		return MathF.Abs(point.x - local.Center.x) <= half.x &&
			   MathF.Abs(point.y - local.Center.y) <= half.y &&
			   MathF.Abs(point.z - local.Center.z) <= half.z;
	}

	public (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()
	{
		BBox local = SceneVolume.GetBounds();

		return (WorldTransform.PointToWorld(local.Center), WorldRotation.Forward, WorldTransform.Up, local.Size * 0.5f);
	}

	// Wave queries
	public Vector3 GetWaveDisplacementAt(Vector3 _WorldPosition)
	{
		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		return profile.IsValid() ? WaterWaveUtility.ComputeDisplacementAt(_WorldPosition, profile) : Vector3.Zero;
	}

	public Vector3 GetWaveVelocityAt(Vector3 _WorldPosition)
	{
		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		return profile.IsValid() ? WaterWaveUtility.ComputeVelocityAt(_WorldPosition, profile) : Vector3.Zero;
	}

	public float GetWaveHeightAt(Vector3 _WorldPosition) => GetSurfaceHeight() + GetWaveDisplacementAt(_WorldPosition).z;

	internal float GetVerticalDistanceToSurface(Vector3 _WorldPosition) => MathF.Abs(_WorldPosition.z - GetSurfaceHeight());

	private void UpdateColliderState()
	{
		BBox local = SceneVolume.GetBounds();

		m_HullCollider = GetOrAddComponent<HullCollider>();
		m_HullCollider.Flags |= ComponentFlags.Hidden;
		m_HullCollider.Static = true;
		m_HullCollider.Type = HullCollider.PrimitiveType.Box;
		m_HullCollider.Center = local.Center;
		m_HullCollider.BoxSize = local.Size;
		m_HullCollider.IsTrigger = true;

		Tags.Add("water");
	}
}
redsnail.watertool / PostProcessing/SimpleFog.cs
Game library
using Sandbox;
using Sandbox.Rendering;

namespace RedSnail.WaterTool;

[Title("Simple Fog")]
[Category("Post Processing")]
[Icon("foggy")]
public sealed class SimpleFog : BasePostProcess<SimpleFog>
{
	[Property] private Color Color { get; set; } = Color.White;
	[Property, Range(0, 1)] private float Intensity { get; set; } = 0.01f;
	[Property, Range(0, 1)] private float Opacity { get; set; } = 0.5f;



	public override void Render()
	{
		float opacity = GetWeighted(x => x.Opacity);

		if (opacity.AlmostEqual(0.0f))
			return;

		Attributes.Set("Color", GetWeighted(x => x.Color));
		Attributes.Set("Intensity", GetWeighted(x => x.Intensity));
		Attributes.Set("Opacity", opacity);

		Material shader = Material.FromShader("pp_simplefog");
		BlitMode blit = BlitMode.WithBackbuffer(shader, Stage.BeforePostProcess, 60);
		Blit(blit, "Simple Fog");
	}
}
redsnail.watertool / Water/WaterDefinition.cs
Game library
using Sandbox;

namespace RedSnail.WaterTool;

[AssetType(Name = "Water Definition", Extension = "wtdef", Category = "Water")]
public sealed class WaterDefinition : GameResource
{
	[Property, Group("Detail")] public float WavesIntensity { get; set; } = 4.0f;
	[Property, Group("Detail"), Range(0, 5)] public float WavesSpeed { get; set; } = 0.3f;
	[Property, Group("Detail")] public float WavesScale { get; set; } = 0.05f;
	[Property, Group("Detail")] public Vector2 WavesDirection { get; set; } = new Vector2(1, 0.5f);
	[Property, Group("Detail"), Range(1, 5)] public int WavesOctaves { get; set; } = 3;
	[Property, Group("Detail")] public float WavesLacunarity { get; set; } = 2.0f;
	[Property, Group("Detail"), Range(0, 1)] public float WavesPersistence { get; set; } = 0.5f;
	[Property, Group("Detail"), Range(0, 1)] public float WavesSteepness { get; set; } = 0.5f;

	[Property, Group("Swell")] public float SwellIntensity { get; set; } = 15.0f;
	[Property, Group("Swell"), Range(0, 500)] public float SwellSpeed { get; set; } = 100.0f;
	[Property, Group("Swell")] public float SwellScale { get; set; } = 0.002f;
	[Property, Group("Swell")] public Vector2 SwellDirection { get; set; } = new Vector2(0.7f, 0.3f);
	[Property, Group("Swell"), Range(1, 4)] public int SwellOctaves { get; set; } = 2;
	[Property, Group("Swell")] public float SwellLacunarity { get; set; } = 1.8f;
	[Property, Group("Swell"), Range(0, 1)] public float SwellPersistence { get; set; } = 0.6f;
	[Property, Group("Swell"), Range(0, 1)] public float SwellSteepness { get; set; } = 0.3f;

	public void ApplyTo(RenderAttributes attributes)
	{
		attributes.Set("WavesIntensity", WavesIntensity);
		attributes.Set("WavesSpeed", WavesSpeed);
		attributes.Set("WavesScale", WavesScale);
		attributes.Set("WavesDirection", WavesDirection);
		attributes.Set("WavesOctaves", WavesOctaves);
		attributes.Set("WavesLacunarity", WavesLacunarity);
		attributes.Set("WavesPersistence", WavesPersistence);
		attributes.Set("WavesSteepness", WavesSteepness);

		attributes.Set("SwellIntensity", SwellIntensity);
		attributes.Set("SwellSpeed", SwellSpeed);
		attributes.Set("SwellScale", SwellScale);
		attributes.Set("SwellDirection", SwellDirection);
		attributes.Set("SwellOctaves", SwellOctaves);
		attributes.Set("SwellLacunarity", SwellLacunarity);
		attributes.Set("SwellPersistence", SwellPersistence);
		attributes.Set("SwellSteepness", SwellSteepness);
	}

	protected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)
	{
		return CreateSimpleAssetTypeIcon("water", _Width, _Height, "#4287f5", "white");
	}
}
redsnail.watertool / Water/WaterExclusionVolume.cs
Game library
using Sandbox;
using Sandbox.Volumes;

namespace RedSnail.WaterTool;

/// <summary>
/// Suppresses water surface rendering inside a volume. Has no effect on the physical water hull
/// so buoyancy and swimming still work within the excluded area.
/// Intended for enclosed spaces that sit in water, such as the interior of a boat or submarine.
/// </summary>
[Title("Water Exclusion Volume")]
[Category("Volumes")]
[Icon("water")]
public sealed class WaterExclusionVolume : VolumeComponent, Component.ExecuteInEditor
{
	protected override void OnEnabled()
	{
		WaterManager.Current?.RefreshWaterExclusionVolumesList();
	}

	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterExclusionVolumesList();
	}

	protected override void DrawGizmos()
	{
		base.DrawGizmos();

		/*
		SceneVolume sceneVolume = SceneVolume;
		Gizmo.Draw.IgnoreDepth = false;
		Gizmo.Draw.Color = Gizmo.Colors.Blue.WithAlpha(0.8f);
		Gizmo.Draw.SolidBox(sceneVolume.Box);
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = global::Color.White.WithAlpha(0.05f);
		Gizmo.Draw.SolidBox(sceneVolume.Box);
		
		SceneVolume = sceneVolume;
		*/
	}

	protected override void OnUpdate()
	{
		// DebugOverlay.Box(GetWorldBounds(), Color.Cyan, overlay: true);
	}

	public (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()
	{
		BBox local = SceneVolume.GetBounds();
		Vector3 center = WorldTransform.PointToWorld(local.Center);
		Vector3 halfExtents = local.Size * 0.5f;

		return (center, WorldRotation.Forward, WorldTransform.Up, halfExtents);
	}

	public void SetLocalBounds(BBox localBounds)
	{
		var sv = SceneVolume;
		sv.Box = localBounds;
		SceneVolume = sv;
	}
}
redsnail.watertool / Water/WaterManager.Ripples.cs
Game library
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.WaterTool;

public partial class WaterManager
{
	// Interactive ripples — expanding radial wave packets stamped onto the surface
	// when something enters or moves on the water. Each emitter is uploaded as two
	// float4 rows: row0 = (Center.xy, StartTime, Strength), row1 = (Wavelength, Width, _, _).
	// Amplitude/Speed/Damping are global; Strength, Wavelength and Width are per-ripple.
	// The exact same formula runs in advancedwater.shader (VS) and in ComputeRippleHeight
	// (CPU) so buoyancy bobs over the visual ripples.

	private const int MAX_RIPPLES = 64;
	private const int RIPPLE_ROWS = 2;

	[Property(Title = "Amplitude"), Group("Ripples")] public float RippleAmplitude { get; set; } = 8.0f;
	[Property(Title = "Expansion Speed"), Group("Ripples")] public float RippleSpeed { get; set; } = 100.0f;
	// Default ring spacing used when a ripple is spawned without an explicit wavelength.
	// Smaller = tighter, more concentric rings. Larger = fewer, broader rings.
	[Property(Title = "Default Wavelength"), Group("Ripples")] public float RippleWavelength { get; set; } = 120.0f;
	// Default ring size used when a ripple is spawned without an explicit width.
	// Larger = bigger, broader ripple (the wave packet spans a wider radial band).
	[Property(Title = "Default Ring Width"), Group("Ripples")] public float RippleWidth { get; set; } = 50.0f;
	[Property(Title = "Damping"), Group("Ripples")] public float RippleDamping { get; set; } = 1.0f;
	[Property(Title = "Lifetime"), Group("Ripples")] public float RippleLifetime { get; set; } = 3.0f;

	private struct RippleEmitter
	{
		public Vector2 Center;
		public float StartTime;
		public float Strength;
		public float Wavelength;
		public float Width;
	}

	private readonly List<RippleEmitter> m_Ripples = [];
	private GpuBuffer<Vector4> m_RippleBuffer;
	private readonly Vector4[] m_RippleData = new Vector4[MAX_RIPPLES * RIPPLE_ROWS];
	private int m_ActiveRippleCount;



	/// <summary>
	/// Spawn an expanding ripple on the water surface at the given world position.
	/// </summary>
	/// <param name="_WorldPosition">Where the ripple originates (only XY is used).</param>
	/// <param name="_Strength">Scales the height of the ripple (1 = a normal splash).</param>
	/// <param name="_Wavelength">Ring spacing — smaller = more rings. Pass &lt;= 0 to use the manager's Default Wavelength.</param>
	/// <param name="_Width">Ring size — larger = a bigger, broader ripple. Pass &lt;= 0 to use the manager's Default Ring Width.</param>
	public static void AddRipple(Vector3 _WorldPosition, float _Strength = 1.0f, float _Wavelength = -1.0f, float _Width = -1.0f)
	{
		Current?.AddRippleInternal(_WorldPosition, _Strength, _Wavelength, _Width);
	}

	private void AddRippleInternal(Vector3 _WorldPosition, float _Strength, float _Wavelength, float _Width)
	{
		if (_Strength <= 0.0f)
			return;

		// Fall back to the global defaults when no per-ripple value is given
		if (_Wavelength <= 0.0f)
			_Wavelength = RippleWavelength;

		if (_Width <= 0.0f)
			_Width = RippleWidth;

		// Drop the oldest when full so the freshest splashes always survive
		if (m_Ripples.Count >= MAX_RIPPLES)
			m_Ripples.RemoveAt(0);

		m_Ripples.Add(new RippleEmitter
		{
			Center = new Vector2(_WorldPosition.x, _WorldPosition.y),
			StartTime = Time.Now,
			Strength = _Strength,
			Wavelength = _Wavelength,
			Width = _Width
		});
	}



	private void UpdateRipples()
	{
		// Prune expired emitters
		for (int i = m_Ripples.Count - 1; i >= 0; i--)
		{
			if (Time.Now - m_Ripples[i].StartTime > RippleLifetime)
				m_Ripples.RemoveAt(i);
		}

		m_ActiveRippleCount = Math.Min(m_Ripples.Count, MAX_RIPPLES);

		for (int i = 0; i < m_ActiveRippleCount; i++)
		{
			var r = m_Ripples[i];
			int row = i * RIPPLE_ROWS;

			m_RippleData[row + 0] = new Vector4(r.Center.x, r.Center.y, r.StartTime, r.Strength);
			m_RippleData[row + 1] = new Vector4(r.Wavelength, r.Width, 0.0f, 0.0f);
		}

		EnsureRippleBuffer();

		m_RippleBuffer.SetData(m_RippleData.AsSpan(0, m_ActiveRippleCount * RIPPLE_ROWS));
	}

	private void EnsureRippleBuffer()
	{
		if (!m_RippleBuffer.IsValid())
			m_RippleBuffer = new GpuBuffer<Vector4>(MAX_RIPPLES * RIPPLE_ROWS, GpuBuffer.UsageFlags.Structured);
	}



	internal void ApplyRippleAttributes(RenderAttributes _Attributes)
	{
		_Attributes.Set("RippleCount", m_ActiveRippleCount);
		_Attributes.Set("RippleAmplitude", RippleAmplitude);
		_Attributes.Set("RippleSpeed", RippleSpeed);
		_Attributes.Set("RippleDamping", RippleDamping);

		if (m_RippleBuffer.IsValid())
			_Attributes.Set("RippleData", m_RippleBuffer);
	}



	/// <summary>
	/// CPU evaluation of the ripple vertical displacement at a world XY position.
	/// MUST mirror ComputeRipples() in advancedwater.shader so physics matches visuals.
	/// </summary>
	public float ComputeRippleHeight(Vector2 _WorldXY)
	{
		if (m_Ripples.Count == 0)
			return 0.0f;

		float z = 0.0f;

		for (int i = 0; i < m_Ripples.Count; i++)
		{
			var r = m_Ripples[i];

			float age = Time.Now - r.StartTime;
			if (age < 0.0f || age > RippleLifetime)
				continue;

			float freq = r.Wavelength > 0.001f ? (MathF.PI * 2.0f / r.Wavelength) : 0.0f;
			float invWidthSq = r.Width > 0.001f ? 1.0f / (r.Width * r.Width) : 0.0f;

			float d = (_WorldXY - r.Center).Length;
			float ring = age * RippleSpeed;
			float ringDelta = d - ring;

			float spatialEnv = MathF.Exp(-ringDelta * ringDelta * invWidthSq);
			float timeEnv = MathF.Exp(-age * RippleDamping);
			float wave = MathF.Sin(ringDelta * freq);

			z += wave * spatialEnv * timeEnv * RippleAmplitude * r.Strength;
		}

		return z;
	}
}
redsnail.watertool / Code/Water/WaterBodyRenderer.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;

namespace RedSnail.WaterTool;

[Icon("water"), Group("Environment"), Title("Water Body Renderer")]
public sealed class WaterBodyRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
#pragma warning disable CS0649

	private struct WaterVertex
	{
		[VertexLayout.Position] public Vector3 Position;
		[VertexLayout.Normal] public Vector3 Normal;
		[VertexLayout.Tangent] public Vector4 Tangent;
		[VertexLayout.TexCoord] public Vector2 TexCoord;
		[VertexLayout.Color] public Color Color;
	}

#pragma warning restore CS0649

	private const float BASE_TILE_SIZE = 100.0f;

	private const int MAX_RINGS = 8;

	private const int MAX_WATER_INCLUSION_VOLUMES = 1024;
	private const int WATER_INCLUSION_VOLUME_ROWS = 3;

	private const int MAX_WATER_EXCLUSION_VOLUMES = 512;
	private const int WATER_EXCLUSION_VOLUME_ROWS = 3;

	private const int MAX_HULL_EXCLUSION_VOLUMES = 8;
	private const int HULL_EXCLUSION_META_ROWS = 6;
	private const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;
	private const int MAX_HULL_EXCLUSION_TRIS = 16384;

	private GpuBuffer<WaterVertex> m_VertexBuffer;
	private GpuBuffer<uint> m_IndexBuffer;
	private GpuBuffer<Vector4> m_WaterInclusionVolumeBuffer;
	private GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;
	private int m_TotalIndexCount;
	private readonly RenderAttributes m_DrawAttributes = new();
	private int m_LastConfigHash;
	private readonly Vector4[] m_WaterInclusionVolumeData = new Vector4[MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS];
	private readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];
	private GpuBuffer<Vector4> m_HullExclusionBuffer;
	private readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];

	[Property, Group("General"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;
	[Property, Group("General"), Order(0)] public Material Material { get; set; }
	[Property, Group("General"), Order(0)] public float Width { get; set; } = 10000.0f;
	[Property, Group("General"), Order(0)] public float Length { get; set; } = 10000.0f;
	[Property, Group("General"), Order(0)] public float Depth { get; set; } = 300.0f;
	[Property(Title = "Infinite Rendering"), Group("General"), Order(0)] public bool UseHybridInclusionBounds { get; set; } = true;
	[Property, Group("Clipmap"), Order(1)] public float BaseCellSize { get; set; } = 8.0f;
	[Property, Group("Clipmap"), Order(1), Range(16, 512)] public int CellsPerRing { get; set; } = 64;
	[Property(Title = "Use Camera For Clipmap"), Group("Clipmap"), Order(1)] public bool FollowCameraForClipmap { get; set; } = true;
	[Property, Group("Texture"), Order(2), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;

	private int VerticesPerRing => (CellsPerRing + 1) * (CellsPerRing + 1);
	private float OuterExtent => CellsPerRing * BaseCellSize * (1 << (ComputeRingCount() - 1));

	internal bool ParticipatesInRendering => Active && Material.IsValid();
	internal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();

	protected override void OnEnabled()
	{
		if (!ParticipatesInRendering)
			return;

		CreateBuffers();

		m_LastConfigHash = ComputeConfigHash();

		WaterManager.Current?.RefreshWaterBodyRenderersList();
	}

	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterBodyRenderersList();

		m_VertexBuffer = default;
		m_IndexBuffer = default;
		m_WaterInclusionVolumeBuffer?.Dispose();
		m_WaterInclusionVolumeBuffer = null;
		m_WaterExclusionVolumeBuffer?.Dispose();
		m_WaterExclusionVolumeBuffer = null;
		m_HullExclusionBuffer?.Dispose();
		m_HullExclusionBuffer = null;
	}

	protected override void OnUpdate()
	{
		if (!ParticipatesInRendering)
			return;

		int configHash = ComputeConfigHash();
		if (!HasValidBuffers || configHash != m_LastConfigHash)
		{
			CreateBuffers();
			m_LastConfigHash = configHash;
		}

		UpdateShaderAttributes();
	}

	internal BBox GetWorldBounds2D()
	{
		Vector3 right = WorldRotation.Right * (Length / 2.0f);
		Vector3 forward = WorldRotation.Forward * (Width / 2.0f);

		Vector3 c0 = WorldPosition + right + forward;
		Vector3 c1 = WorldPosition - right + forward;
		Vector3 c2 = WorldPosition + right - forward;
		Vector3 c3 = WorldPosition - right - forward;

		float minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));
		float maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));
		float minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));
		float maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));

		return new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));
	}

	// Records the clipmap compute dispatches into the command list as DEFERRED commands.
	// They run later, on the render thread, when the camera executes the list - so the
	// per-ring attributes are set through the command list (which writes Graphics.Attributes
	// at execute time, exactly what CommandList.DispatchCompute reads) rather than on the
	// shared shader instance.
	internal void RecordCompute(CommandList commandList, ComputeShader shader, Vector3 cameraPosition)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;

		int ringCount = ComputeRingCount();
		int verticesPerRing = VerticesPerRing;

		var localBounds = GetWorldBounds2D();

		for (int ring = 0; ring < ringCount; ring++)
		{
			float cellSize = BaseCellSize * (1 << ring);
			Vector3 clipmapAnchor = FollowCameraForClipmap ? cameraPosition : WorldPosition;
			float snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;
			float snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;

			commandList.Attributes.Set("VertexBuffer", m_VertexBuffer);
			commandList.Attributes.Set("VertexOffset", ring * verticesPerRing);
			commandList.Attributes.Set("GridWidth", CellsPerRing);
			commandList.Attributes.Set("CellSize", cellSize);
			commandList.Attributes.Set("SnapPosition", new Vector2(snapX, snapY));
			commandList.Attributes.Set("WaterZ", WorldPosition.z);
			commandList.Attributes.Set("TilingScale", 1.0f / OuterExtent);
			commandList.Attributes.Set("ClampToBounds", false);
			commandList.Attributes.Set("BoundsMin", new Vector2(localBounds.Mins.x, localBounds.Mins.y));
			commandList.Attributes.Set("BoundsMax", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));
			commandList.DispatchCompute(shader, verticesPerRing, 1, 1);
		}
	}

	internal void BarrierTransition(CommandList _CommandList)
	{
		if (m_VertexBuffer.IsValid())
			_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);
	}

	internal void Draw(CommandList _CommandList)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;
		
		_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);
	}

	private void UpdateShaderAttributes()
	{
		BBox localBounds = GetWorldBounds2D();

		m_DrawAttributes.Set("RequireWaterInclusionVolumes", UseHybridInclusionBounds);
		m_DrawAttributes.Set("UseHybridInclusionBounds", UseHybridInclusionBounds);
		m_DrawAttributes.Set("HybridInclusionBoundsMin", new Vector2(localBounds.Mins.x, localBounds.Mins.y));
		m_DrawAttributes.Set("HybridInclusionBoundsMax", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));

		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		if (profile.IsValid())
			profile.ApplyTo(m_DrawAttributes);

		m_DrawAttributes.Set("WaterTime", Time.Now);
		m_DrawAttributes.Set("DepthMax", Depth);

		float tilingScalar = (OuterExtent / BASE_TILE_SIZE) * TextureTilingMultiplier;
		m_DrawAttributes.Set("NormalTiling", new Vector2(tilingScalar, tilingScalar));

		WaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);
		WaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);
		
		// Band-limit the wave normal to the local clipmap vertex spacing (see shader)
		m_DrawAttributes.Set("WaveNormalEpsScale", 3.0f / CellsPerRing);
		m_DrawAttributes.Set("WaveNormalEpsMin", BaseCellSize);

		var viewPosition = WaterManager.GetViewPosition(Scene, WorldPosition);

		SetWaterInclusionVolumes(viewPosition);
		SetWaterExclusionVolumes(viewPosition);
		SetHullExclusionVolumes();
	}

	private void SetWaterInclusionVolumes(Vector3 referencePosition)
	{
		EnsureWaterInclusionVolumeBuffer();

		var volumes = WaterManager.Current.Bodies
			.Where(v => v.IsValid() && v.Active && v.WaterType == WaterType)
			.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))
			.Take(MAX_WATER_INCLUSION_VOLUMES)
			.ToList();

		for (int i = 0; i < volumes.Count; i++)
		{
			var (center, forward, up, half) = volumes[i].GetWorldOBB();

			int rowOffset = i * WATER_INCLUSION_VOLUME_ROWS;

			m_WaterInclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_WaterInclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_WaterInclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
		}

		m_WaterInclusionVolumeBuffer.SetData(m_WaterInclusionVolumeData.AsSpan(0, volumes.Count * WATER_INCLUSION_VOLUME_ROWS));

		m_DrawAttributes.Set("WaterInclusionVolumeCount", volumes.Count);
		m_DrawAttributes.Set("WaterInclusionVolumeRows", m_WaterInclusionVolumeBuffer);
	}

	private void SetWaterExclusionVolumes(Vector3 referencePosition)
	{
		EnsureWaterExclusionVolumeBuffer();

		var volumes = WaterManager.Current.ExclusionVolumes
			.Where(v => v.IsValid() && v.Enabled && v.Active)
			.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))
			.Take(MAX_WATER_EXCLUSION_VOLUMES)
			.ToList();

		for (int i = 0; i < volumes.Count; i++)
		{
			var (center, forward, up, half) = volumes[i].GetWorldOBB();

			int rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;

			m_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
		}

		m_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));

		m_DrawAttributes.Set("WaterExclusionVolumeCount", volumes.Count);
		m_DrawAttributes.Set("WaterExclusionVolumeRows", m_WaterExclusionVolumeBuffer);
	}

	private void EnsureWaterExclusionVolumeBuffer()
	{
		if (m_WaterExclusionVolumeBuffer.IsValid())
			return;

		m_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);
	}



	private void SetHullExclusionVolumes()
	{
		if (WaterManager.Current == null)
			return;

		var hulls = WaterManager.Current.HullExclusionVolumes
			.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)
			.Take(MAX_HULL_EXCLUSION_VOLUMES)
			.ToList();

		if (hulls.Count == 0)
		{
			m_DrawAttributes.Set("WaterHullExclusionCount", 0);
			return;
		}

		EnsureHullExclusionBuffers();

		int triWriteCursor = HULL_EXCLUSION_META_SIZE;

		for (int h = 0; h < hulls.Count; h++)
		{
			var hull = hulls[h];
			var tris = hull.LocalTriangles;
			int triCount = tris.Length / 3;

			if (triWriteCursor + tris.Length > m_HullExclusionData.Length)
				break;

			hull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);

			int meta = h * HULL_EXCLUSION_META_ROWS;
			m_HullExclusionData[meta + 0] = r0;
			m_HullExclusionData[meta + 1] = r1;
			m_HullExclusionData[meta + 2] = r2;
			m_HullExclusionData[meta + 3] = r3;

			var aabb = hull.LocalAABB;
			m_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);
			m_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);

			for (int i = 0; i < tris.Length; i++)
				m_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);

			triWriteCursor += tris.Length;
		}

		m_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));

		m_DrawAttributes.Set("WaterHullExclusionCount", hulls.Count);
		m_DrawAttributes.Set("WaterHullExclusionData", m_HullExclusionBuffer);
	}



	private void EnsureHullExclusionBuffers()
	{
		if (!m_HullExclusionBuffer.IsValid())
			m_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);
	}



	private void EnsureWaterInclusionVolumeBuffer()
	{
		if (m_WaterInclusionVolumeBuffer.IsValid())
			return;

		m_WaterInclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);
	}

	private int ComputeConfigHash()
	{
		return HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing);
	}

	private int ComputeRingCount()
	{
		return ComputeRingCount(Width, Length);
	}

	private int ComputeRingCount(float width, float length)
	{
		float maxDim = MathF.Max(length, width);
		float innerExtent = CellsPerRing * BaseCellSize;
		float requiredExtent = maxDim * 2.0f;

		if (requiredExtent <= innerExtent)
			return 1;

		int rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;
		return Math.Clamp(rings, 1, MAX_RINGS);
	}

	private void CreateBuffers()
	{
		int ringCount = ComputeRingCount();
		int n = CellsPerRing;
		int verticesPerRing = VerticesPerRing;

		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;
		int innerBlockSize = innerEnd - innerStart;
		int filledCells = n * n;
		int hollowCells = filledCells - (innerBlockSize * innerBlockSize);
		int totalIndices = filledCells * 6;
		totalIndices += (ringCount - 1) * hollowCells * 6;

		m_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);
		m_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);
		UploadIndexBuffer(ringCount);
	}

	private void UploadIndexBuffer(int ringCount)
	{
		int n = CellsPerRing;
		int verticesPerRing = VerticesPerRing;
		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;

		var indices = new List<uint>();

		for (int ring = 0; ring < ringCount; ring++)
		{
			uint baseVertex = (uint)(ring * verticesPerRing);

			for (int y = 0; y < n; y++)
			{
				for (int x = 0; x < n; x++)
				{
					if (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)
						continue;

					uint i0 = baseVertex + (uint)(y * (n + 1) + x);
					uint i1 = i0 + 1;
					uint i2 = i0 + (uint)(n + 1);
					uint i3 = i2 + 1;

					indices.Add(i0);
					indices.Add(i1);
					indices.Add(i2);
					indices.Add(i1);
					indices.Add(i3);
					indices.Add(i2);
				}
			}
		}

		m_IndexBuffer.SetData(indices);
		m_TotalIndexCount = indices.Count;
	}
}
redsnail.watertool / Code/Water/WaterQuadBaker.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Audio;

namespace RedSnail.WaterTool;

[Icon("water_drop"), Group("Water"), Title("Water Quad Baker")]
public sealed class WaterQuadBaker : Component, Component.ExecuteInEditor
{
	private const string BakedContainerName = "Water Volumes";
	private const string BakedTag = "water_quad_bake";

	private readonly List<Terrain> _terrains = new();
	private readonly HashSet<Collider> _solidColliders = new();
	private float _insideTraceDistance;
	private int _physicsCreatedCount;
	private int _skippedInsideCount;
	private int _subdividedCount;

	[Property, Group("Water"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;

	[Property, Group("Bake Bounds")] public Vector2 BakeSizeXY { get; set; } = new(10000.0f, 10000.0f);
	[Property, Group("Bake Bounds")] public float WaterSurfaceZ { get; set; } = 0.0f;
	[Property, Group("Bake Bounds")] public float WaterDepth { get; set; } = 1000.0f;

	[Property, Group("Strict Pass"), Range(256.0f, 8192.0f), Order(2)] public float MinCellSize { get; set; } = 4096.0f;
	[Property, Group("Strict Pass"), Range(1, 12)] public int MaxDepth { get; set; } = 6;
	[Property, Group("Strict Pass"), Range(0.0f, 64.0f)] public float QuadInset { get; set; } = 0.0f;
	[Property, Group("Strict Pass"), Range(1.0f, 128.0f)] public float SolidProbeRadius { get; set; } = 8.0f;
	[Property, Group("Strict Pass"), Range(0.0f, 256.0f)] public float TerrainPadding { get; set; } = 16.0f;
	[Property, Group("Strict Pass")] public bool IgnoreTerrainBelowWaterSurface { get; set; } = true;
	[Property, Group("Strict Pass"), Range(0.0f, 5000.0f)] public float TerrainDepthIgnoreDistance { get; set; } = 512.0f;

	[Property, Group("Coastal Fill"), Order(3)] public bool EnableCoastalFill { get; set; } = true;
	[Property, Group("Coastal Fill"), Range(256.0f, 8192.0f)] public float CoastalFillMaxCellSize { get; set; } = 4096.0f;
	[Property, Group("Coastal Fill"), Range(0.0f, 5000.0f)] public float CoastalFillPenetrationDistance { get; set; } = 192.0f;
	[Property, Group("Coastal Fill"), Range(0.1f, 1.0f)] public float CoastalFillInlandThreshold { get; set; } = 1.0f;

	[Property, ToggleGroup("Soundscape"), Order(4)] public bool Soundscape { get; set; } = false;
	[Property, Group("Soundscape"), Range(0.0f, 1000.0f)] public float SoundscapeExtraHeight { get; set; } = 250.0f;
	[Property, Group("Soundscape")] public Soundscape SoundscapeAsset { get; set; }
	[Property, Group("Soundscape")] public MixerHandle SoundscapeTargetMixer { get; set; }
	[Property, Group("Soundscape")] public bool SoundscapeStayActiveOnExit { get; set; } = true;
	[Property, Group("Soundscape"), Range(0.0f, 2.0f)] public float SoundscapeVolume { get; set; } = 1.0f;
	
	[Property, Group("Miscellaneous")] public bool ExcludeMeshGeometry { get; set; } = false;



	[Button]
	private async Task Bake()
	{
		CacheSceneGeometry();
		ClearBaked();

		_physicsCreatedCount = 0;
		_skippedInsideCount = 0;
		_subdividedCount = 0;

		// Traverse the octree synchronously to collect candidate boxes.
		var pending = new List<BBox>();

		CollectPhysicsNodes(GetLocalBakeBox(), 0, pending);

		// Create volumes with an editor progress bar.
		var container = GetOrCreateBakedContainer();

		await Application.Editor.ForEachAsync(pending, "Baking Water Volumes", async (box, ct) =>
		{
			if (CreateWaterBody(container, box))
				_physicsCreatedCount++;

			await Task.Delay(1, ct);
		});

		Log.Info($"{nameof(WaterQuadBaker)}: baked {_physicsCreatedCount} water volume set(s), skipped {_skippedInsideCount} node(s), subdivided {_subdividedCount} node(s).");
	}



	[Button]
	private void ClearBaked()
	{
		FindBakedContainer()?.Destroy();
	}



	protected override void DrawGizmos()
	{
		if (!Gizmo.IsSelected)
			return;

		Gizmo.Draw.Color = Color.Green;
		Gizmo.Draw.LineBBox(GetLocalBakeBox());

		Gizmo.Draw.Color = Color.Blue;

		foreach (var waterBody in GetComponentsInChildren<WaterBody>())
		{
			var (center, forward, up, half) = waterBody.GetWorldOBB();

			Gizmo.Draw.LineBBox(BBox.FromPositionAndSize(center, half * 2));
		}
	}



	private void CacheSceneGeometry()
	{
		_terrains.Clear();
		_solidColliders.Clear();

		foreach (var terrain in Scene.GetAllComponents<Terrain>())
		{
			if (!terrain.IsValid() || !terrain.Enabled || !terrain.Active || !terrain.EnableCollision || terrain.Storage is null)
				continue;

			_terrains.Add(terrain);
			_solidColliders.Add(terrain);
		}

		foreach (var collider in Scene.GetAllComponents<Collider>())
		{
			if (!collider.IsValid() || !collider.Enabled || !collider.Active || collider.IsTrigger)
				continue;

			if (collider.GameObject.Tags.Has(BakedTag))
				continue;
			
			if (ExcludeMeshGeometry && collider is not Terrain)
				continue;

			_solidColliders.Add(collider);
		}

		_insideTraceDistance = Math.Max(BakeSizeXY.Length * 2.0f, 10000.0f);
	}



	private void CollectPhysicsNodes(BBox _LocalBox, int _Depth, List<BBox> _Pending)
	{
		var sample = ClassifyNode(_LocalBox);

		bool terrainRejected = sample.TerrainAllInside || (sample.TerrainMixed && !sample.MeshHasAny);
		bool meshRejected = sample.MeshAllInside;
		bool overlapsNonTerrainSolid = BoxOverlapsNonTerrainSolid(_LocalBox);

		if (meshRejected)
		{
			_skippedInsideCount++;

			return;
		}

		if (terrainRejected)
		{
			if (TryHandleCoastalNode(_LocalBox, _Depth, _Pending, sample))
				return;

			_skippedInsideCount++;

			return;
		}

		bool shouldSubdivide = sample.MeshMixed || sample.TerrainMixed || overlapsNonTerrainSolid;

		if (shouldSubdivide && CanSubdivide(_LocalBox, _Depth))
		{
			_subdividedCount++;

			foreach (var child in Subdivide(_LocalBox))
				CollectPhysicsNodes(child, _Depth + 1, _Pending);

			return;
		}

		if (shouldSubdivide)
		{
			_skippedInsideCount++;

			return;
		}

		_Pending.Add(_LocalBox);
	}



	private SampleSummary ClassifyNode(BBox _LocalBox)
	{
		int total = 0;
		int terrainInside = 0;
		int meshInside = 0;

		foreach (var localPoint in EnumerateSamplePoints(_LocalBox))
		{
			total++;

			var worldPoint = WorldTransform.PointToWorld(localPoint);

			if (IsPointInsideTerrainOnly(worldPoint))
				terrainInside++;

			if (IsPointInsideSolidMeshOnly(worldPoint))
				meshInside++;
		}

		return new SampleSummary
		{
			Total = total,
			TerrainInside = terrainInside,
			MeshInside = meshInside
		};
	}



	private bool TryHandleCoastalNode(BBox _LocalBox, int _Depth, List<BBox> _Pending, SampleSummary _Sample)
	{
		if (!EnableCoastalFill || _Sample.MeshHasAny)
			return false;

		float maxSize = Math.Max(_LocalBox.Size.x, _LocalBox.Size.y);

		if (maxSize > CoastalFillMaxCellSize)
		{
			_subdividedCount++;

			foreach (var child in Subdivide(_LocalBox))
				CollectPhysicsNodes(child, _Depth + 1, _Pending);

			return true;
		}

		if (IsCellTooFarInland(_LocalBox))
			return false;

		_Pending.Add(_LocalBox);

		return true;
	}



	private bool IsCellTooFarInland(BBox _LocalBox)
	{
		int inlandCount = 0;
		int total = 0;

		foreach (var localPoint in EnumerateXYSamplePoints(_LocalBox))
		{
			total++;

			var worldPoint = WorldTransform.PointToWorld(localPoint);

			if (IsInlandAtXY(worldPoint))
				inlandCount++;
		}

		return total > 0 && ((float)inlandCount / total) >= CoastalFillInlandThreshold;
	}



	private bool IsInlandAtXY(Vector3 _WorldPoint)
	{
		if (!IsLandAtXY(_WorldPoint))
			return false;

		if (CoastalFillPenetrationDistance <= 0.0f)
			return true;

		Vector3[] offsets =
		[
			Vector3.Right * CoastalFillPenetrationDistance,
			Vector3.Left * CoastalFillPenetrationDistance,
			Vector3.Forward * CoastalFillPenetrationDistance,
			Vector3.Backward * CoastalFillPenetrationDistance
		];

		foreach (var offset in offsets)
		{
			if (!IsLandAtXY(_WorldPoint + offset))
				return false;
		}

		return true;
	}



	private bool IsLandAtXY(Vector3 _WorldPoint)
	{
		foreach (var terrain in _terrains)
		{
			if (TryGetTerrainSurfaceWorldHeight(terrain, _WorldPoint, out var worldHeight) && IsTerrainHeightBlocking(worldHeight))
				return true;
		}

		return false;
	}



	private bool IsPointInsideTerrainOnly(Vector3 _WorldPoint)
	{
		foreach (var terrain in _terrains)
		{
			if (TryGetTerrainSurfaceWorldHeight(terrain, _WorldPoint, out var worldHeight) && IsTerrainHeightBlocking(worldHeight))
			{
				if (_WorldPoint.z <= worldHeight + TerrainPadding)
					return true;
			}
		}

		return false;
	}



	private bool IsTerrainHeightBlocking(float _SampledWorldHeight)
	{
		if (IgnoreTerrainBelowWaterSurface && _SampledWorldHeight <= WaterSurfaceZ - TerrainDepthIgnoreDistance)
			return false;

		return _SampledWorldHeight >= WaterSurfaceZ + TerrainPadding;
	}



	private bool IsPointInsideSolidMeshOnly(Vector3 _WorldPoint)
	{
		if (ExcludeMeshGeometry)
			return false;
		
		var probe = Scene.Trace
			.Sphere(SolidProbeRadius, _WorldPoint, _WorldPoint)
			.WithoutTags(BakedTag)
			.Run();

		if (probe.StartedSolid && probe.Collider is not Terrain)
			return true;

		int oddAxes = 0;

		if (HasOddHitCount(_WorldPoint, Vector3.Right)) oddAxes++;
		if (HasOddHitCount(_WorldPoint, Vector3.Forward)) oddAxes++;
		if (HasOddHitCount(_WorldPoint, Vector3.Up)) oddAxes++;

		return oddAxes >= 2;
	}



	private bool HasOddHitCount(Vector3 _Start, Vector3 _Direction)
	{
		if (ExcludeMeshGeometry)
			return false;
		
		var end = _Start + _Direction.Normal * _insideTraceDistance;

		var hits = Scene.Trace
			.Ray(_Start, end)
			.WithoutTags(BakedTag)
			.RunAll();

		int hitCount = 0;
		Collider lastCollider = null;
		float lastFraction = -10.0f;

		foreach (var hit in hits)
		{
			if (!hit.Hit || hit.Collider is null)
				continue;

			if (!_solidColliders.Contains(hit.Collider) || hit.Collider is Terrain)
				continue;

			if (hit.Collider == lastCollider && Math.Abs(hit.Fraction - lastFraction) < 0.0001f)
				continue;

			lastCollider = hit.Collider;
			lastFraction = hit.Fraction;
			hitCount++;
		}

		return (hitCount & 1) == 1;
	}



	private bool BoxOverlapsNonTerrainSolid(BBox _LocalBox)
	{
		if (ExcludeMeshGeometry)
			return false;
		
		var center = WorldTransform.PointToWorld(_LocalBox.Center);

		var hits = Scene.Trace
			.Box(_LocalBox.Size, center, center)
			.Rotated(WorldRotation)
			.WithoutTags(BakedTag)
			.RunAll();

		foreach (var hit in hits)
		{
			if (hit.Hit && hit.Collider is not null && hit.Collider is not Terrain)
				return true;
		}

		return false;
	}



	private static bool TryGetTerrainSurfaceWorldHeight(Terrain _Terrain, Vector3 _WorldPoint, out float _SampledWorldHeight)
	{
		_SampledWorldHeight = 0.0f;

		var storage = _Terrain.Storage;

		if (storage is null || storage.HeightMap is null || storage.ControlMap is null || storage.Resolution <= 1)
			return false;

		var localPoint = _Terrain.WorldTransform.PointToLocal(_WorldPoint);

		if (localPoint.x < 0.0f || localPoint.y < 0.0f || localPoint.x > storage.TerrainSize || localPoint.y > storage.TerrainSize)
			return false;

		int resolution = storage.Resolution;
		float gridX = (localPoint.x / storage.TerrainSize) * (resolution - 1);
		float gridY = (localPoint.y / storage.TerrainSize) * (resolution - 1);

		int x0 = (int)MathF.Floor(gridX).Clamp(0, resolution - 1);
		int y0 = (int)MathF.Floor(gridY).Clamp(0, resolution - 1);
		int x1 = (x0 + 1).Clamp(0, resolution - 1);
		int y1 = (y0 + 1).Clamp(0, resolution - 1);

		var control = new CompactTerrainMaterial(storage.ControlMap[x0 + y0 * resolution]);

		if (control.IsHole)
			return false;

		float tx = gridX - x0;
		float ty = gridY - y0;
		float h00 = storage.HeightMap[x0 + y0 * resolution];
		float h10 = storage.HeightMap[x1 + y0 * resolution];
		float h01 = storage.HeightMap[x0 + y1 * resolution];
		float h11 = storage.HeightMap[x1 + y1 * resolution];
		float hx0 = MathX.Lerp(h00, h10, tx);
		float hx1 = MathX.Lerp(h01, h11, tx);
		float sampledLocalHeight = MathX.Lerp(hx0, hx1, ty) * (storage.TerrainHeight / ushort.MaxValue);

		_SampledWorldHeight = _Terrain.WorldTransform.PointToWorld(new Vector3(localPoint.x, localPoint.y, sampledLocalHeight)).z;

		return true;
	}



	private static IEnumerable<Vector3> EnumerateSamplePoints(BBox _LocalBox)
	{
		for (int ix = 0; ix < 3; ix++)
			for (int iy = 0; iy < 3; iy++)
				for (int iz = 0; iz < 3; iz++)
				{
					yield return new Vector3(
						MathX.Lerp(_LocalBox.Mins.x, _LocalBox.Maxs.x, ix * 0.5f),
						MathX.Lerp(_LocalBox.Mins.y, _LocalBox.Maxs.y, iy * 0.5f),
						MathX.Lerp(_LocalBox.Mins.z, _LocalBox.Maxs.z, iz * 0.5f)
					);
				}
	}



	private static IEnumerable<Vector3> EnumerateXYSamplePoints(BBox _LocalBox)
	{
		float z = _LocalBox.Center.z;

		for (int ix = 0; ix < 5; ix++)
			for (int iy = 0; iy < 5; iy++)
			{
				yield return new Vector3(
					MathX.Lerp(_LocalBox.Mins.x, _LocalBox.Maxs.x, ix / 4.0f),
					MathX.Lerp(_LocalBox.Mins.y, _LocalBox.Maxs.y, iy / 4.0f),
					z
				);
			}
	}



	private bool CanSubdivide(BBox _LocalBox, int _Depth)
	{
		if (_Depth >= MaxDepth)
			return false;

		var size = _LocalBox.Size;

		return size.x > MinCellSize || size.y > MinCellSize;
	}



	private static IEnumerable<BBox> Subdivide(BBox _LocalBox)
	{
		var center = _LocalBox.Center;
		var mins = _LocalBox.Mins;
		var maxs = _LocalBox.Maxs;

		for (int ix = 0; ix < 2; ix++)
			for (int iy = 0; iy < 2; iy++)
			{
				yield return new BBox(
					new Vector3(ix == 0 ? mins.x : center.x, iy == 0 ? mins.y : center.y, mins.z),
					new Vector3(ix == 0 ? center.x : maxs.x, iy == 0 ? center.y : maxs.y, maxs.z)
				);
			}
	}



	private bool CreateWaterBody(GameObject _Container, BBox _LocalBox)
	{
		float width = _LocalBox.Size.x - QuadInset * 2.0f;
		float length = _LocalBox.Size.y - QuadInset * 2.0f;

		if (width <= 1.0f || length <= 1.0f)
			return false;

		var go = new GameObject(_Container, true, "Water Volume");
		go.Tags.Add(BakedTag);

		var worldPoint = WorldTransform.PointToWorld(_LocalBox.Center);

		go.WorldPosition = new Vector3(worldPoint.x, worldPoint.y, WaterSurfaceZ - WaterDepth * 0.5f);
		go.WorldRotation = WorldRotation;
		go.WorldScale = 1.0f;

		var bounds = new BBox
		(
			new Vector3(-width * 0.5f, -length * 0.5f, -WaterDepth * 0.5f),
			new Vector3(width * 0.5f, length * 0.5f, WaterDepth * 0.5f)
		);

		var body = go.GetOrAddComponent<WaterBody>();
		body.SetBounds(bounds);
		body.WaterType = WaterType;

		if (Soundscape)
			CreateSoundscapeTrigger(go, width, length);

		return true;
	}



	private void CreateSoundscapeTrigger(GameObject _Parent, float _Width, float _Length)
	{
		var finalExtents = new Vector3(_Width * 0.5f, _Length * 0.5f, (WaterDepth * 0.5f) + SoundscapeExtraHeight);

		if (finalExtents.x <= 1.0f || finalExtents.y <= 1.0f || finalExtents.z <= 1.0f)
			return;

		var go = new GameObject(_Parent, true, "Water Soundscape");
		go.Tags.Add(BakedTag);
		go.LocalPosition = Vector3.Zero.WithZ(SoundscapeExtraHeight);
		go.LocalRotation = Rotation.Identity;
		go.LocalScale = 1.0f;

		var trigger = go.GetOrAddComponent<SoundscapeTrigger>();
		trigger.Type = SoundscapeTrigger.TriggerType.Box;
		trigger.Soundscape = SoundscapeAsset;
		trigger.TargetMixer = SoundscapeTargetMixer;
		trigger.StayActiveOnExit = SoundscapeStayActiveOnExit;
		trigger.Volume = SoundscapeVolume;
		trigger.BoxSize = finalExtents;
	}



	private BBox GetLocalBakeBox()
	{
		float minZ = WaterSurfaceZ - WaterDepth;
		float maxZ = WaterSurfaceZ;

		var mins = new Vector3(-BakeSizeXY.x * 0.5f, -BakeSizeXY.y * 0.5f, minZ);
		var maxs = new Vector3(BakeSizeXY.x * 0.5f, BakeSizeXY.y * 0.5f, maxZ);

		return new BBox(mins, maxs);
	}



	private GameObject GetOrCreateBakedContainer()
	{
		var existing = FindBakedContainer();

		if (existing.IsValid())
			return existing;

		var container = new GameObject(GameObject, true, BakedContainerName);
		container.Tags.Add("container");
		container.Tags.Add(BakedTag);
		container.LocalPosition = Vector3.Zero;
		container.LocalRotation = Rotation.Identity;
		container.LocalScale = 1.0f;

		return container;
	}



	private GameObject FindBakedContainer()
	{
		return GameObject.Children.FirstOrDefault(child => child.IsValid() && child.Tags.Has("container"));
	}



	private struct SampleSummary
	{
		public int Total;
		public int TerrainInside;
		public int MeshInside;

		public bool TerrainAllInside => Total > 0 && TerrainInside == Total;
		public bool TerrainMixed => TerrainInside > 0 && TerrainInside < Total;
		public bool MeshAllInside => Total > 0 && MeshInside == Total;
		public bool MeshHasAny => MeshInside > 0;
		public bool TerrainHasAny => TerrainInside > 0;
		public bool MeshMixed => MeshInside > 0 && MeshInside < Total;
	}
}
redsnail.watertool / Code/Water/WaterRippleEmitter.cs
Game library
using Sandbox;

namespace RedSnail.WaterTool;

/// <summary>
/// Emits water ripples when this object crosses the water surface, and optionally
/// while it moves across it. A generic, dependency-free alternative to the entry
/// ripple built into <see cref="Buoyancy"/> — drop it on anything that doesn't have
/// a Buoyancy component (players, NPCs, projectiles, debris...).
///
/// Velocity is derived from the object's own position delta, so it works with any
/// movement system (CharacterController, custom controllers, animation, etc.) and
/// needs no Rigidbody.
/// </summary>
[Icon("water"), Group("Water"), Title("Water Ripple Emitter")]
public sealed class WaterRippleEmitter : Component
{
	[Property, Group("Entry")] public bool EmitOnEntry { get; set; } = true;
	[Property, Group("Entry")] public float EntryStrength { get; set; } = 0.2f;
	// Ring spacing for the entry splash — smaller = tighter, more concentric rings.
	[Property, Group("Entry"), Range(20.0f, 400.0f)] public float EntryWavelength { get; set; } = 120.0f;
	// Ring size for the entry splash — larger = a bigger, broader ripple.
	[Property, Group("Entry"), Range(10.0f, 500.0f)] public float EntryRingWidth { get; set; } = 50.0f;
	// Minimum downward speed (units/s) needed to splash. Set to 0 to ripple on any crossing.
	[Property, Group("Entry")] public float MinImpactSpeed { get; set; } = 40.0f;

	[Property, Group("Wake")] public bool EmitWake { get; set; } = false;
	[Property, Group("Wake")] public float WakeStrength { get; set; } = 0.1f;
	// Ring spacing for wake ripples — smaller = tighter, more concentric rings.
	[Property, Group("Wake"), Range(20.0f, 400.0f)] public float WakeWavelength { get; set; } = 120.0f;
	// Ring size for wake ripples — larger = a bigger, broader ripple.
	[Property, Group("Wake"), Range(10.0f, 500.0f)] public float WakeRingWidth { get; set; } = 50.0f;
	// Minimum horizontal speed (units/s) before a moving object leaves a wake.
	[Property, Group("Wake")] public float WakeMinSpeed { get; set; } = 1.0f;
	[Property, Group("Wake")] public float WakeInterval { get; set; } = 0.0333f; // 30 fps

	// Local-space offset of the point tested against the surface (e.g. the feet).
	[Property, Group("General")] public Vector3 SampleOffset { get; set; } = Vector3.Zero;

	private bool m_Initialized;
	private bool m_WasBelowSurface;
	private Vector3 m_LastPosition;
	private float m_WakeTimer;

	private Vector3 SamplePosition => WorldPosition + WorldRotation * SampleOffset;



	protected override void OnEnabled()
	{
		m_LastPosition = SamplePosition;
		m_WasBelowSurface = false;
		m_Initialized = false;
	}



	protected override void OnUpdate()
	{
		// If this gameobject is parented to anything, we don't want to play water ripple effects
		// (e.g. A player inside a boat)
		if (GameObject.Parent != Scene)
			return;
		
		Vector3 samplePos = SamplePosition;

		// Velocity from position delta — no Rigidbody required
		Vector3 velocity = Time.Delta > 0.0f ? (samplePos - m_LastPosition) / Time.Delta : Vector3.Zero;
		m_LastPosition = samplePos;

		float waterHeight = WaterManager.GetWaterHeightAt(samplePos);

		// Not over any water surface
		if (waterHeight <= float.MinValue)
		{
			m_WasBelowSurface = false;
			return;
		}

		bool belowSurface = samplePos.z <= waterHeight;

		// Skip the first valid frame so an object spawned already in water doesn't splash
		if (!m_Initialized)
		{
			m_WasBelowSurface = belowSurface;
			m_Initialized = true;
			return;
		}

		// Entry splash on the above -> below surface crossing
		if (EmitOnEntry && belowSurface && !m_WasBelowSurface)
		{
			float impactSpeed = float.Max(0.0f, -velocity.z);

			if (impactSpeed >= MinImpactSpeed)
			{
				float strength = (impactSpeed / 150.0f).Clamp(0.3f, 2.5f) * EntryStrength;
				
				WaterManager.AddRipple(samplePos.WithZ(waterHeight), strength, EntryWavelength, EntryRingWidth);
			}
		}

		m_WasBelowSurface = belowSurface;

		float horizontalSpeed = velocity.WithZ(0.0f).Length;
		
		// Continuous wake while skimming/swimming through the surface
		if (EmitWake && belowSurface)
		{
			if (horizontalSpeed >= WakeMinSpeed)
			{
				m_WakeTimer -= Time.Delta;

				if (m_WakeTimer <= 0.0f)
				{
					WaterManager.AddRipple(samplePos.WithZ(waterHeight), WakeStrength, WakeWavelength, WakeRingWidth);
					m_WakeTimer = WakeInterval;
				}
			}
		}
	}
}
redsnail.watertool / Code/Water/WaterWaveUtility.cs
Game library
using System;
using Sandbox;

namespace RedSnail.WaterTool;

public enum WaterBodyType
{
	Ocean,
	Lake,
	River,
	Pool,
	Custom
}

public static class WaterWaveUtility
{
	public static Vector3 ComputeDisplacementAt(Vector2 worldXY, WaterDefinition profile)
	{
		Vector3 detail = ComputeGerstner(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;
		Vector3 swell = ComputeGerstner(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;
		return detail + swell;
	}

	public static Vector3 ComputeVelocityAt(Vector2 worldXY, WaterDefinition profile)
	{
		Vector3 detail = ComputeGerstnerVelocity(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;
		Vector3 swell = ComputeGerstnerVelocity(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;
		return detail + swell;
	}

	private static Vector3 ComputeGerstner(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)
	{
		if (scale <= 0.0f || speed <= 0.0f || octaves <= 0)
			return Vector3.Zero;

		Vector2 waveDirection = direction.Normal;
		float t = Time.Now * speed;

		Vector3 displacement = Vector3.Zero;
		float amp = 1.0f;
		float freq = scale;
		float maxAmp = 0f;

		for (int oct = 0; oct < octaves; oct++)
		{
			float angle = oct * 1.2f;
			Vector2 octDir = new(
				waveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),
				waveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)
			);

			float phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;
			displacement.x += steepness * amp * octDir.x * MathF.Cos(phase);
			displacement.y += steepness * amp * octDir.y * MathF.Cos(phase);
			displacement.z += amp * MathF.Sin(phase);

			maxAmp += amp;
			amp *= persistence;
			freq *= lacunarity;
		}

		return maxAmp > 0.0f ? displacement / maxAmp : Vector3.Zero;
	}

	private static Vector3 ComputeGerstnerVelocity(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)
	{
		if (scale <= 0.0f || speed <= 0.0f || octaves <= 0)
			return Vector3.Zero;

		Vector2 waveDirection = direction.Normal;
		float t = Time.Now * speed;

		Vector3 velocity = Vector3.Zero;
		float amp = 1.0f;
		float freq = scale;
		float maxAmp = 0f;

		for (int oct = 0; oct < octaves; oct++)
		{
			float angle = oct * 1.2f;
			Vector2 octDir = new(
				waveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),
				waveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)
			);

			float phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;
			float angularVelocity = freq * speed * 0.5f;

			velocity.x -= steepness * amp * octDir.x * angularVelocity * MathF.Sin(phase);
			velocity.y -= steepness * amp * octDir.y * angularVelocity * MathF.Sin(phase);
			velocity.z += amp * angularVelocity * MathF.Cos(phase);

			maxAmp += amp;
			amp *= persistence;
			freq *= lacunarity;
		}

		return maxAmp > 0.0f ? velocity / maxAmp : Vector3.Zero;
	}
}
redsnail.watertool / Water/WaterBodyRenderer.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;

namespace RedSnail.WaterTool;

[Icon("water"), Group("Environment"), Title("Water Body Renderer")]
public sealed class WaterBodyRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
#pragma warning disable CS0649

	private struct WaterVertex
	{
		[VertexLayout.Position] public Vector3 Position;
		[VertexLayout.Normal] public Vector3 Normal;
		[VertexLayout.Tangent] public Vector4 Tangent;
		[VertexLayout.TexCoord] public Vector2 TexCoord;
		[VertexLayout.Color] public Color Color;
	}

#pragma warning restore CS0649

	private const float BASE_TILE_SIZE = 100.0f;

	private const int MAX_RINGS = 8;

	private const int MAX_WATER_INCLUSION_VOLUMES = 1024;
	private const int WATER_INCLUSION_VOLUME_ROWS = 3;

	private const int MAX_WATER_EXCLUSION_VOLUMES = 512;
	private const int WATER_EXCLUSION_VOLUME_ROWS = 3;

	private const int MAX_HULL_EXCLUSION_VOLUMES = 8;
	private const int HULL_EXCLUSION_META_ROWS = 6;
	private const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;
	private const int MAX_HULL_EXCLUSION_TRIS = 16384;

	private GpuBuffer<WaterVertex> m_VertexBuffer;
	private GpuBuffer<uint> m_IndexBuffer;
	private GpuBuffer<Vector4> m_WaterInclusionVolumeBuffer;
	private GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;
	private int m_TotalIndexCount;
	private readonly RenderAttributes m_DrawAttributes = new();
	private int m_LastConfigHash;
	private readonly Vector4[] m_WaterInclusionVolumeData = new Vector4[MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS];
	private readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];
	private GpuBuffer<Vector4> m_HullExclusionBuffer;
	private readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];

	[Property, Group("General"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;
	[Property, Group("General"), Order(0)] public Material Material { get; set; }
	[Property, Group("General"), Order(0)] public float Width { get; set; } = 10000.0f;
	[Property, Group("General"), Order(0)] public float Length { get; set; } = 10000.0f;
	[Property, Group("General"), Order(0)] public float Depth { get; set; } = 300.0f;
	[Property(Title = "Infinite Rendering"), Group("General"), Order(0)] public bool UseHybridInclusionBounds { get; set; } = true;
	[Property, Group("Clipmap"), Order(1)] public float BaseCellSize { get; set; } = 8.0f;
	[Property, Group("Clipmap"), Order(1), Range(16, 512)] public int CellsPerRing { get; set; } = 64;
	[Property(Title = "Use Camera For Clipmap"), Group("Clipmap"), Order(1)] public bool FollowCameraForClipmap { get; set; } = true;
	[Property, Group("Texture"), Order(2), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;

	private int VerticesPerRing => (CellsPerRing + 1) * (CellsPerRing + 1);
	private float OuterExtent => CellsPerRing * BaseCellSize * (1 << (ComputeRingCount() - 1));

	internal bool ParticipatesInRendering => Active && Material.IsValid();
	internal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();

	protected override void OnEnabled()
	{
		if (!ParticipatesInRendering)
			return;

		CreateBuffers();

		m_LastConfigHash = ComputeConfigHash();

		WaterManager.Current?.RefreshWaterBodyRenderersList();
	}

	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterBodyRenderersList();

		m_VertexBuffer = default;
		m_IndexBuffer = default;
		m_WaterInclusionVolumeBuffer?.Dispose();
		m_WaterInclusionVolumeBuffer = null;
		m_WaterExclusionVolumeBuffer?.Dispose();
		m_WaterExclusionVolumeBuffer = null;
		m_HullExclusionBuffer?.Dispose();
		m_HullExclusionBuffer = null;
	}

	protected override void OnUpdate()
	{
		if (!ParticipatesInRendering)
			return;

		int configHash = ComputeConfigHash();
		if (!HasValidBuffers || configHash != m_LastConfigHash)
		{
			CreateBuffers();
			m_LastConfigHash = configHash;
		}

		UpdateShaderAttributes();
	}

	internal BBox GetWorldBounds2D()
	{
		Vector3 right = WorldRotation.Right * (Length / 2.0f);
		Vector3 forward = WorldRotation.Forward * (Width / 2.0f);

		Vector3 c0 = WorldPosition + right + forward;
		Vector3 c1 = WorldPosition - right + forward;
		Vector3 c2 = WorldPosition + right - forward;
		Vector3 c3 = WorldPosition - right - forward;

		float minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));
		float maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));
		float minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));
		float maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));

		return new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));
	}

	// Records the clipmap compute dispatches into the command list as DEFERRED commands.
	// They run later, on the render thread, when the camera executes the list - so the
	// per-ring attributes are set through the command list (which writes Graphics.Attributes
	// at execute time, exactly what CommandList.DispatchCompute reads) rather than on the
	// shared shader instance.
	internal void RecordCompute(CommandList commandList, ComputeShader shader, Vector3 cameraPosition)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;

		int ringCount = ComputeRingCount();
		int verticesPerRing = VerticesPerRing;

		var localBounds = GetWorldBounds2D();

		for (int ring = 0; ring < ringCount; ring++)
		{
			float cellSize = BaseCellSize * (1 << ring);
			Vector3 clipmapAnchor = FollowCameraForClipmap ? cameraPosition : WorldPosition;
			float snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;
			float snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;

			commandList.Attributes.Set("VertexBuffer", m_VertexBuffer);
			commandList.Attributes.Set("VertexOffset", ring * verticesPerRing);
			commandList.Attributes.Set("GridWidth", CellsPerRing);
			commandList.Attributes.Set("CellSize", cellSize);
			commandList.Attributes.Set("SnapPosition", new Vector2(snapX, snapY));
			commandList.Attributes.Set("WaterZ", WorldPosition.z);
			commandList.Attributes.Set("TilingScale", 1.0f / OuterExtent);
			commandList.Attributes.Set("ClampToBounds", false);
			commandList.Attributes.Set("BoundsMin", new Vector2(localBounds.Mins.x, localBounds.Mins.y));
			commandList.Attributes.Set("BoundsMax", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));
			commandList.DispatchCompute(shader, verticesPerRing, 1, 1);
		}
	}

	internal void BarrierTransition(CommandList _CommandList)
	{
		if (m_VertexBuffer.IsValid())
			_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);
	}

	internal void Draw(CommandList _CommandList)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;
		
		_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);
	}

	private void UpdateShaderAttributes()
	{
		BBox localBounds = GetWorldBounds2D();

		m_DrawAttributes.Set("RequireWaterInclusionVolumes", UseHybridInclusionBounds);
		m_DrawAttributes.Set("UseHybridInclusionBounds", UseHybridInclusionBounds);
		m_DrawAttributes.Set("HybridInclusionBoundsMin", new Vector2(localBounds.Mins.x, localBounds.Mins.y));
		m_DrawAttributes.Set("HybridInclusionBoundsMax", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));

		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		if (profile.IsValid())
			profile.ApplyTo(m_DrawAttributes);

		m_DrawAttributes.Set("WaterTime", Time.Now);
		m_DrawAttributes.Set("DepthMax", Depth);

		float tilingScalar = (OuterExtent / BASE_TILE_SIZE) * TextureTilingMultiplier;
		m_DrawAttributes.Set("NormalTiling", new Vector2(tilingScalar, tilingScalar));

		WaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);
		WaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);
		
		// Band-limit the wave normal to the local clipmap vertex spacing (see shader)
		m_DrawAttributes.Set("WaveNormalEpsScale", 3.0f / CellsPerRing);
		m_DrawAttributes.Set("WaveNormalEpsMin", BaseCellSize);

		var viewPosition = WaterManager.GetViewPosition(Scene, WorldPosition);

		SetWaterInclusionVolumes(viewPosition);
		SetWaterExclusionVolumes(viewPosition);
		SetHullExclusionVolumes();
	}

	private void SetWaterInclusionVolumes(Vector3 referencePosition)
	{
		EnsureWaterInclusionVolumeBuffer();

		var volumes = WaterManager.Current.Bodies
			.Where(v => v.IsValid() && v.Active && v.WaterType == WaterType)
			.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))
			.Take(MAX_WATER_INCLUSION_VOLUMES)
			.ToList();

		for (int i = 0; i < volumes.Count; i++)
		{
			var (center, forward, up, half) = volumes[i].GetWorldOBB();

			int rowOffset = i * WATER_INCLUSION_VOLUME_ROWS;

			m_WaterInclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_WaterInclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_WaterInclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
		}

		m_WaterInclusionVolumeBuffer.SetData(m_WaterInclusionVolumeData.AsSpan(0, volumes.Count * WATER_INCLUSION_VOLUME_ROWS));

		m_DrawAttributes.Set("WaterInclusionVolumeCount", volumes.Count);
		m_DrawAttributes.Set("WaterInclusionVolumeRows", m_WaterInclusionVolumeBuffer);
	}

	private void SetWaterExclusionVolumes(Vector3 referencePosition)
	{
		EnsureWaterExclusionVolumeBuffer();

		var volumes = WaterManager.Current.ExclusionVolumes
			.Where(v => v.IsValid() && v.Enabled && v.Active)
			.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))
			.Take(MAX_WATER_EXCLUSION_VOLUMES)
			.ToList();

		for (int i = 0; i < volumes.Count; i++)
		{
			var (center, forward, up, half) = volumes[i].GetWorldOBB();

			int rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;

			m_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
		}

		m_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));

		m_DrawAttributes.Set("WaterExclusionVolumeCount", volumes.Count);
		m_DrawAttributes.Set("WaterExclusionVolumeRows", m_WaterExclusionVolumeBuffer);
	}

	private void EnsureWaterExclusionVolumeBuffer()
	{
		if (m_WaterExclusionVolumeBuffer.IsValid())
			return;

		m_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);
	}



	private void SetHullExclusionVolumes()
	{
		if (WaterManager.Current == null)
			return;

		var hulls = WaterManager.Current.HullExclusionVolumes
			.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)
			.Take(MAX_HULL_EXCLUSION_VOLUMES)
			.ToList();

		if (hulls.Count == 0)
		{
			m_DrawAttributes.Set("WaterHullExclusionCount", 0);
			return;
		}

		EnsureHullExclusionBuffers();

		int triWriteCursor = HULL_EXCLUSION_META_SIZE;

		for (int h = 0; h < hulls.Count; h++)
		{
			var hull = hulls[h];
			var tris = hull.LocalTriangles;
			int triCount = tris.Length / 3;

			if (triWriteCursor + tris.Length > m_HullExclusionData.Length)
				break;

			hull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);

			int meta = h * HULL_EXCLUSION_META_ROWS;
			m_HullExclusionData[meta + 0] = r0;
			m_HullExclusionData[meta + 1] = r1;
			m_HullExclusionData[meta + 2] = r2;
			m_HullExclusionData[meta + 3] = r3;

			var aabb = hull.LocalAABB;
			m_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);
			m_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);

			for (int i = 0; i < tris.Length; i++)
				m_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);

			triWriteCursor += tris.Length;
		}

		m_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));

		m_DrawAttributes.Set("WaterHullExclusionCount", hulls.Count);
		m_DrawAttributes.Set("WaterHullExclusionData", m_HullExclusionBuffer);
	}



	private void EnsureHullExclusionBuffers()
	{
		if (!m_HullExclusionBuffer.IsValid())
			m_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);
	}



	private void EnsureWaterInclusionVolumeBuffer()
	{
		if (m_WaterInclusionVolumeBuffer.IsValid())
			return;

		m_WaterInclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);
	}

	private int ComputeConfigHash()
	{
		return HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing);
	}

	private int ComputeRingCount()
	{
		return ComputeRingCount(Width, Length);
	}

	private int ComputeRingCount(float width, float length)
	{
		float maxDim = MathF.Max(length, width);
		float innerExtent = CellsPerRing * BaseCellSize;
		float requiredExtent = maxDim * 2.0f;

		if (requiredExtent <= innerExtent)
			return 1;

		int rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;
		return Math.Clamp(rings, 1, MAX_RINGS);
	}

	private void CreateBuffers()
	{
		int ringCount = ComputeRingCount();
		int n = CellsPerRing;
		int verticesPerRing = VerticesPerRing;

		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;
		int innerBlockSize = innerEnd - innerStart;
		int filledCells = n * n;
		int hollowCells = filledCells - (innerBlockSize * innerBlockSize);
		int totalIndices = filledCells * 6;
		totalIndices += (ringCount - 1) * hollowCells * 6;

		m_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);
		m_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);
		UploadIndexBuffer(ringCount);
	}

	private void UploadIndexBuffer(int ringCount)
	{
		int n = CellsPerRing;
		int verticesPerRing = VerticesPerRing;
		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;

		var indices = new List<uint>();

		for (int ring = 0; ring < ringCount; ring++)
		{
			uint baseVertex = (uint)(ring * verticesPerRing);

			for (int y = 0; y < n; y++)
			{
				for (int x = 0; x < n; x++)
				{
					if (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)
						continue;

					uint i0 = baseVertex + (uint)(y * (n + 1) + x);
					uint i1 = i0 + 1;
					uint i2 = i0 + (uint)(n + 1);
					uint i3 = i2 + 1;

					indices.Add(i0);
					indices.Add(i1);
					indices.Add(i2);
					indices.Add(i1);
					indices.Add(i3);
					indices.Add(i2);
				}
			}
		}

		m_IndexBuffer.SetData(indices);
		m_TotalIndexCount = indices.Count;
	}
}
redsnail.watertool / Water/WaterQuad.cs
Game library
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.Rendering;

namespace RedSnail.WaterTool;

[Icon("water"), Group("Water"), Title("Water Quad")]
public sealed class WaterQuad : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer
{
	#pragma warning disable CS0649

	private struct WaterVertex
	{
		[VertexLayout.Position] public Vector3 Position;
		[VertexLayout.Normal] public Vector3 Normal;
		[VertexLayout.Tangent] public Vector4 Tangent;
		[VertexLayout.TexCoord] public Vector2 TexCoord;
		[VertexLayout.Color] public Color Color;
	}

	#pragma warning restore CS0649

	// GPU buffers (per-quad, owned here — WaterManager owns the command lists and ComputeShader)
	private GpuBuffer<WaterVertex> m_VertexBuffer;
	private GpuBuffer<uint> m_IndexBuffer;
	private int m_TotalIndexCount;
	private int m_CircleGridWidth = 1;
	private readonly RenderAttributes m_DrawAttributes = new();
	private GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;
	private readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];
	private GpuBuffer<Vector4> m_HullExclusionBuffer;
	private readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];

	private HullCollider m_HullCollider;
	private int m_LastConfigHash;
	private float m_LastWidth;
	private float m_LastLength;
	private float m_LastDepth;
	private bool m_LastIsCircleShape;
	private int m_LastNumCircleSegments;
	private Vector3 m_LastHullCenter;
	private Vector3 m_LastHullBoxSize;
	private Material m_LastMaterial;

	private const float BASE_TILE_SIZE = 100.0f;

	private const int MAX_RINGS = 8;

	private const int MAX_WATER_EXCLUSION_VOLUMES = 512;
	private const int WATER_EXCLUSION_VOLUME_ROWS = 3;

	private const int MAX_HULL_EXCLUSION_VOLUMES = 8;
	private const int HULL_EXCLUSION_META_ROWS = 6;
	private const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;
	private const int MAX_HULL_EXCLUSION_TRIS = 16384;

	[Property, Group("General"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;
	[Property, Group("General"), Order(0)] public Material Material { get; set; }
	[Property, Group("General"), Step(1), Order(0)] public float Width { get; set; } = 5000.0f;
	[Property, Group("General"), Step(1), Order(0)] public float Length { get; set; } = 5000.0f;
	[Property, Group("General"), Step(1), Order(0)] public float Depth { get; set; } = 300.0f;

	[Property, Group("Clipmap"), Order(2)] public float BaseCellSize { get; set { field = value.Clamp(8, 4096); } } = 32.0f;
	[Property, Group("Clipmap"), Order(2), Range(16, 512)] public int CellsPerRing { get; set { field = value.Clamp(16, 512); } } = 256;
	[Property(Title = "Use Camera For Clipmap"), Group("Clipmap"), Order(2)] public bool FollowCameraForClipmap { get; set; } = true;
	
	[Property, Group("Shape"), Order(3)] public bool CircleShape { get; set; } = false;
	[Property, Group("Shape"), Order(3), Range(5, 32), ShowIf(nameof(CircleShape), true)] public int CircleSegments { get; set { field = value.Clamp(5, 32); } } = 16;

	[Property, Group("Texture"), Order(4), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;

	public HullCollider HullCollider => m_HullCollider;

	// Distance LOD level resolved by the WaterManager (0 = full detail). At level L the grid
	// uses half the cells at twice the size per level, so it covers exactly the same area with
	// 4^L fewer vertices. CellsPerRing * BaseCellSize is preserved exactly, which is what keeps
	// the ring count, coverage and texture tiling identical across levels — only the
	// tessellation density changes, so there's no swimming or resizing when a level switches.
	private int m_LodLevel;

	private int EffectiveCellsPerRing => Math.Max(16, CellsPerRing >> m_LodLevel);
	private float EffectiveBaseCellSize => BaseCellSize * ((float)CellsPerRing / EffectiveCellsPerRing);

	private int VerticesPerRing => (EffectiveCellsPerRing + 1) * (EffectiveCellsPerRing + 1);



	protected override void OnEnabled()
	{
		RefreshRenderBuffers();
		UpdateColliderState();

		m_LastWidth = Width;
		m_LastLength = Length;
		m_LastDepth = Depth;
		m_LastIsCircleShape = CircleShape;
		m_LastNumCircleSegments = CircleSegments;
		m_LastMaterial = Material;

		WaterManager.Current?.RefreshWaterQuadsList();
	}



	protected override void OnDisabled()
	{
		WaterManager.Current?.RefreshWaterQuadsList();

		m_HullCollider?.Destroy();

		m_VertexBuffer = default;
		m_IndexBuffer = default;
		m_WaterExclusionVolumeBuffer?.Dispose();
		m_WaterExclusionVolumeBuffer = null;
		m_HullExclusionBuffer?.Dispose();
		m_HullExclusionBuffer = null;
	}



	protected override void OnUpdate()
	{
		if (WaterManager.Current == null)
			return;

		// Material was just assigned after the component was already enabled, register now.
		if (m_LastMaterial == null && Material != null)
			WaterManager.Current?.RefreshWaterQuadsList();

		m_LastMaterial = Material;

		if (Material == null)
			return;

		// Resolve the tessellation level before the buffers are checked — it feeds the config
		// hash, so a level change rebuilds the grid at the new density (rare, thanks to the
		// hysteresis in ComputeLodLevel).
		m_LodLevel = WaterManager.Current.ComputeLodLevel(GetWorldBounds2D(), m_LodLevel);

		UpdateBuffers();

		if (Width != m_LastWidth || Length != m_LastLength || Depth != m_LastDepth || CircleShape != m_LastIsCircleShape || m_LastNumCircleSegments != CircleSegments)
		{
			UpdateColliderState();

			m_LastWidth = Width;
			m_LastLength = Length;
			m_LastDepth = Depth;
			m_LastIsCircleShape = CircleShape;
			m_LastNumCircleSegments = CircleSegments;
		}

		if (m_HullCollider.IsValid())
		{
			if (m_HullCollider.Center != m_LastHullCenter)
			{
				m_HullCollider.Center = m_LastHullCenter;
				
				Log.Warning("[WaterTool] Do not use S&box gizmos to control the size of the water quad, please use the intended: Width, Length & Depth property in the editor!");
			}

			if (m_HullCollider.BoxSize != m_LastHullBoxSize)
			{
				m_HullCollider.BoxSize = m_LastHullBoxSize;
				
				Log.Warning("[WaterTool] Do not use S&box gizmos to control the size of the water quad, please use the intended: Width, Length & Depth property in the editor!");
			}
		}

		UpdateShaderAttributes();
	}



	protected override void DrawGizmos()
	{
		if (!Gizmo.IsSelected)
			return;

		if (!m_HullCollider.IsValid())
			return;

		Gizmo.Draw.Color = Color.Cyan;

		if (CircleShape)
		{
			Vector3 pointA = m_HullCollider.Center;
			pointA.z -= m_HullCollider.Height / 2.0f;

			Vector3 pointB = m_HullCollider.Center;
			pointB.z += m_HullCollider.Height / 2.0f;

			Gizmo.Draw.LineCylinder(pointA, pointB, m_HullCollider.Radius, m_HullCollider.Radius2, CircleSegments);
		}
		else
		{
			Gizmo.Draw.LineBBox(m_HullCollider.LocalBounds);
		}
	}



	private int ComputeConfigHash()
	{
		return HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing, CircleShape, CircleSegments, m_LodLevel);
	}



	private int ComputeRingCount()
	{
		return ComputeRingCount(Width, Length);
	}



	private int ComputeRingCount(float _Width, float _Length)
	{
		float maxDim = MathF.Max(_Length, _Width);

		// Authored product on purpose: LOD preserves CellsPerRing * BaseCellSize exactly, so the
		// ring layout and coverage stay identical across levels — only the density changes.
		float innerExtent = CellsPerRing * BaseCellSize;

		float requiredExtent = maxDim * 2.0f;

		if (requiredExtent <= innerExtent)
			return 1;

		int rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;

		return Math.Clamp(rings, 1, MAX_RINGS);
	}



	private float OuterExtent
	{
		get
		{
			if (CircleShape)
				return MathF.Min(Width, Length) / 2.0f;

			int ringCount = ComputeRingCount();

			// Authored product (LOD-invariant) so texture tiling doesn't shift on a level change
			return CellsPerRing * BaseCellSize * (1 << (ringCount - 1));
		}
	}



	private void UpdateBuffers()
	{
		int configHash = ComputeConfigHash();

		if (configHash != m_LastConfigHash)
		{
			CreateBuffers();

			m_LastConfigHash = configHash;
		}
	}



	private void CreateBuffers()
	{
		if (CircleShape)
		{
			BuildCircleBuffers();

			return;
		}

		int ringCount = ComputeRingCount();
		int n = EffectiveCellsPerRing;
		int verticesPerRing = VerticesPerRing;

		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;
		int innerBlockSize = innerEnd - innerStart;
		int filledCells = n * n;
		int hollowCells = filledCells - (innerBlockSize * innerBlockSize);

		int totalIndices = filledCells * 6;
		totalIndices += (ringCount - 1) * hollowCells * 6;

		m_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);
		m_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);

		UploadIndexBuffer(ringCount);
	}



	private void RefreshRenderBuffers()
	{
		CreateBuffers();

		m_LastConfigHash = ComputeConfigHash();
	}



	private void BuildCircleBuffers()
	{
		float radius = MathF.Min(Width, Length) / 2.0f;
		int M = ComputeCircleGridWidth();
		m_CircleGridWidth = M;

		float cellSize = (radius * 2.0f) / M;   // M cells span the full diameter
		float half = M * cellSize * 0.5f;        // == radius (grid centred on the circle)
		float r2 = radius * radius;

		// "Minecraft circle": a uniform, world-axis-aligned grid of square cells, masked
		// to a circular boundary. Because the vertices live on the same grid as a
		// rectangular quad, wave displacement behaves identically (no polar pinching).
		int verticesPerSide = M + 1;
		int vertexCount = verticesPerSide * verticesPerSide;
		m_VertexBuffer = new GpuBuffer<WaterVertex>(vertexCount, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);

		var indices = new List<uint>();

		// Emit a cell's two triangles only when its centre falls inside the circle
		for (int y = 0; y < M; y++)
		{
			for (int x = 0; x < M; x++)
			{
				float cx = (x + 0.5f) * cellSize - half;
				float cy = (y + 0.5f) * cellSize - half;

				if (cx * cx + cy * cy > r2)
					continue;

				uint i0 = (uint)(y * verticesPerSide + x);
				uint i1 = i0 + 1;
				uint i2 = i0 + (uint)verticesPerSide;
				uint i3 = i2 + 1;

				indices.Add(i0); indices.Add(i1); indices.Add(i2);
				indices.Add(i1); indices.Add(i3); indices.Add(i2);
			}
		}

		m_IndexBuffer = new GpuBuffer<uint>(indices.Count, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);
		m_IndexBuffer.SetData(indices);
		m_TotalIndexCount = indices.Count;
	}



	// Number of grid cells across the circle's diameter, driven by BaseCellSize so the
	// blockiness matches the rest of the water — smaller cells = finer (rounder) edge.
	private int ComputeCircleGridWidth()
	{
		float diameter = MathF.Min(Width, Length);
		int cells = (int)MathF.Ceiling(diameter / EffectiveBaseCellSize);
		return Math.Clamp(cells, 1, 256);
	}



	private void UploadIndexBuffer(int _RingCount)
	{
		int n = EffectiveCellsPerRing;
		int verticesPerRing = VerticesPerRing;

		int innerStart = n / 4 + 1;
		int innerEnd = n * 3 / 4 - 1;

		var indices = new List<uint>();

		for (int ring = 0; ring < _RingCount; ring++)
		{
			uint baseVertex = (uint)(ring * verticesPerRing);

			for (int y = 0; y < n; y++)
			{
				for (int x = 0; x < n; x++)
				{
					if (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)
						continue;

					uint i0 = baseVertex + (uint)(y * (n + 1) + x);
					uint i1 = i0 + 1;
					uint i2 = i0 + (uint)(n + 1);
					uint i3 = i2 + 1;

					indices.Add(i0);
					indices.Add(i1);
					indices.Add(i2);
					indices.Add(i1);
					indices.Add(i3);
					indices.Add(i2);
				}
			}
		}

		m_IndexBuffer.SetData(indices);

		m_TotalIndexCount = indices.Count;
	}



	internal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();

	internal bool ParticipatesInRendering => Material.IsValid();
	
	
	
	internal BBox GetWorldBounds2D()
	{
		Vector3 right = WorldRotation.Right * (Length / 2.0f);
		Vector3 forward = WorldRotation.Forward * (Width / 2.0f);

		Vector3 c0 = WorldPosition + right + forward;
		Vector3 c1 = WorldPosition - right + forward;
		Vector3 c2 = WorldPosition + right - forward;
		Vector3 c3 = WorldPosition - right - forward;

		float minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));
		float maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));
		float minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));
		float maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));

		return new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));
	}



	// Records the clipmap compute dispatches into the command list as DEFERRED commands -
	// see WaterBodyRenderer.RecordCompute for why per-ring attributes go through the list.
	internal void RecordCompute(CommandList _CommandList, ComputeShader _Shader, Vector3 _CameraPosition)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;

		float outerExtent = OuterExtent;

		if (CircleShape)
		{
			int M = m_CircleGridWidth;
			int verticesPerSide = M + 1;
			float cellSize = MathF.Min(Width, Length) / M;   // M cells span the diameter

			_CommandList.Attributes.Set("VertexBuffer", m_VertexBuffer);
			_CommandList.Attributes.Set("VertexOffset", 0);

			_CommandList.Attributes.Set("GridWidth", M);
			_CommandList.Attributes.Set("CellSize", cellSize);

			// Static grid centred on the quad — the circular pool doesn't follow the camera
			_CommandList.Attributes.Set("SnapPosition", (Vector2)WorldPosition);
			_CommandList.Attributes.Set("WaterZ", WorldPosition.z);

			_CommandList.Attributes.Set("TilingScale", 1.0f / outerExtent);
			_CommandList.Attributes.Set("ClampToBounds", false);

			_CommandList.DispatchCompute(_Shader, verticesPerSide * verticesPerSide, 1, 1);

			return;
		}

		int ringCount = ComputeRingCount();
		int verticesPerRing = VerticesPerRing;

		var localBounds = GetWorldBounds2D();
		float boundsMinX = localBounds.Mins.x;
		float boundsMaxX = localBounds.Maxs.x;
		float boundsMinY = localBounds.Mins.y;
		float boundsMaxY = localBounds.Maxs.y;

		for (int ring = 0; ring < ringCount; ring++)
		{
			float cellSize = EffectiveBaseCellSize * (1 << ring);

			Vector3 clipmapAnchor = FollowCameraForClipmap ? _CameraPosition : WorldPosition;

			float snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;
			float snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;

			_CommandList.Attributes.Set("VertexBuffer", m_VertexBuffer);
			_CommandList.Attributes.Set("VertexOffset", ring * verticesPerRing);

			_CommandList.Attributes.Set("GridWidth", EffectiveCellsPerRing);
			_CommandList.Attributes.Set("CellSize", cellSize);

			_CommandList.Attributes.Set("SnapPosition", new Vector2(snapX, snapY));
			_CommandList.Attributes.Set("WaterZ", WorldPosition.z);

			_CommandList.Attributes.Set("TilingScale", 1.0f / outerExtent);
			_CommandList.Attributes.Set("ClampToBounds", true);

			_CommandList.Attributes.Set("BoundsMin", new Vector2(boundsMinX, boundsMinY));
			_CommandList.Attributes.Set("BoundsMax", new Vector2(boundsMaxX, boundsMaxY));

			_CommandList.DispatchCompute(_Shader, verticesPerRing, 1, 1);
		}
	}



	internal void BarrierTransition(CommandList _CommandList)
	{
		if (m_VertexBuffer.IsValid())
			_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);
	}



	internal void Draw(CommandList _CommandList)
	{
		if (!ParticipatesInRendering || !HasValidBuffers)
			return;
		
		_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);
	}



	private void UpdateColliderState()
	{
		m_HullCollider = GetOrAddComponent<HullCollider>();
		m_HullCollider.Flags |= ComponentFlags.Hidden;
		m_HullCollider.Static = true;

		m_HullCollider.Type = CircleShape ? HullCollider.PrimitiveType.Cylinder : HullCollider.PrimitiveType.Box;

		m_HullCollider.Center = new Vector3(0, 0, -Depth / 2.0f);

		if (CircleShape)
		{
			m_HullCollider.Radius = MathF.Min(Width, Length) / 2.0f;
			m_HullCollider.Radius2 = MathF.Min(Width, Length) / 2.0f;
			m_HullCollider.Height = Depth;
			m_HullCollider.Slices = CircleSegments;
		}
		else
		{
			m_HullCollider.BoxSize = new Vector3(Width, Length, Depth);
		}
		
		m_LastHullCenter = m_HullCollider.Center;
		m_LastHullBoxSize = m_HullCollider.BoxSize;

		m_HullCollider.IsTrigger = true;

		Tags.Add("water");
	}



	internal (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()
	{
		return (
			WorldPosition + (WorldTransform.Up * (-Depth * 0.5f)),
			WorldRotation.Forward,
			WorldTransform.Up,
			new Vector3(Width * 0.5f, Length * 0.5f, Depth * 0.5f)
		);
	}



	private void UpdateShaderAttributes()
	{
		m_DrawAttributes.Set("RequireWaterInclusionVolumes", false);

		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		if (profile.IsValid())
			profile.ApplyTo(m_DrawAttributes);

		m_DrawAttributes.Set("WaterTime", Time.Now);
		m_DrawAttributes.Set("DepthMax", Depth);

		float outerExtent = OuterExtent;

		Vector2 tiling = new Vector2((outerExtent / BASE_TILE_SIZE) * TextureTilingMultiplier, (outerExtent / BASE_TILE_SIZE) * TextureTilingMultiplier);

		m_DrawAttributes.Set("NormalTiling", tiling);

		WaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);
		WaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);
		
		// Band-limit the wave normal to the local clipmap vertex spacing (see shader)
		// Uses the EFFECTIVE grid: the normal's finite-difference step has to track the real
		// vertex spacing, which coarsens with the LOD level. Feeding the authored values here
		// would reconstruct detail the LODed mesh can't represent — the static world-locked
		// moiré pattern all over again, worst exactly where LOD kicks in.
		m_DrawAttributes.Set("WaveNormalEpsScale", 3.0f / EffectiveCellsPerRing);
		m_DrawAttributes.Set("WaveNormalEpsMin", EffectiveBaseCellSize);

		SetWaterExclusionVolumes(WaterManager.GetViewPosition(Scene, WorldPosition));
		SetHullExclusionVolumes();
	}



	private void SetWaterExclusionVolumes(Vector3 _ReferencePosition)
	{
		if (WaterManager.Current == null)
			return;

		EnsureWaterExclusionVolumeBuffer();

		var volumes = WaterManager.Current.ExclusionVolumes
			.Where(v => v.IsValid() && v.Active)
			.OrderBy(v => v.WorldPosition.DistanceSquared(_ReferencePosition))
			.Take(MAX_WATER_EXCLUSION_VOLUMES)
			.ToList();

		for (int i = 0; i < volumes.Count; i++)
		{
			var (center, forward, up, half) = volumes[i].GetWorldOBB();

			int rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;

			m_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);
			m_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);
			m_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);
		}

		m_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));

		m_DrawAttributes.Set("WaterExclusionVolumeCount", volumes.Count);
		m_DrawAttributes.Set("WaterExclusionVolumeRows", m_WaterExclusionVolumeBuffer);
	}



	private void EnsureWaterExclusionVolumeBuffer()
	{
		if (m_WaterExclusionVolumeBuffer.IsValid())
			return;

		m_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS);
	}



	private void SetHullExclusionVolumes()
	{
		if (WaterManager.Current == null)
			return;

		var hulls = WaterManager.Current.HullExclusionVolumes
			.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)
			.Take(MAX_HULL_EXCLUSION_VOLUMES)
			.ToList();
		
		if (hulls.Count == 0)
		{
			m_DrawAttributes.Set("WaterHullExclusionCount", 0);
			return;
		}

		EnsureHullExclusionBuffers();

		// Triangles are written after the fixed-size metadata section
		int triWriteCursor = HULL_EXCLUSION_META_SIZE;

		for (int h = 0; h < hulls.Count; h++)
		{
			var hull = hulls[h];
			var tris = hull.LocalTriangles;
			int triCount = tris.Length / 3;

			if (triWriteCursor + tris.Length > m_HullExclusionData.Length)
				break;

			hull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);

			int meta = h * HULL_EXCLUSION_META_ROWS;
			m_HullExclusionData[meta + 0] = r0;
			m_HullExclusionData[meta + 1] = r1;
			m_HullExclusionData[meta + 2] = r2;
			m_HullExclusionData[meta + 3] = r3;

			var aabb = hull.LocalAABB;
			// vertStart is an absolute index into the combined buffer
			m_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);
			m_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);

			for (int i = 0; i < tris.Length; i++)
				m_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);

			triWriteCursor += tris.Length;
		}

		m_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));

		m_DrawAttributes.Set("WaterHullExclusionCount", hulls.Count);
		m_DrawAttributes.Set("WaterHullExclusionData", m_HullExclusionBuffer);
	}



	private void EnsureHullExclusionBuffers()
	{
		if (!m_HullExclusionBuffer.IsValid())
			m_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);
	}



	public Vector3 GetWaveDisplacementAt(Vector3 _WorldPosition)
	{
		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		return WaterWaveUtility.ComputeDisplacementAt(_WorldPosition, profile);
	}



	public Vector3 GetWaveVelocityAt(Vector3 _WorldPosition)
	{
		WaterDefinition profile = WaterManager.GetWaveProfile(WaterType);

		return WaterWaveUtility.ComputeVelocityAt(_WorldPosition, profile);
	}



	public float GetWaveHeightAt(Vector3 _WorldPosition)
	{
		return WorldPosition.z + GetWaveDisplacementAt(_WorldPosition).z;
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 48,
    "Files": [
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterManager.cs",
            "FileName": "WaterManager.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\nusing Sandbox.Rendering;\r\nusing RenderStage = Sandbox.Rendering.Stage;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Title(\"Water Manager\")]\r\npublic partial class WaterManager : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer, IHotloadManaged\r\n{\r\n\tprivate SceneCustomObject m_SceneObject;\r\n\t\r\n\t[SkipHotload] public static WaterManager Current { get; private set; } = null;\r\n\t\r\n\t[Property(Title = \"Ocean\"), Group(\"Profile\"), Order(0)] public WaterDefinition OceanWaveProfile { get; set; }\r\n\t[Property(Title = \"Lake\"), Group(\"Profile\")] public WaterDefinition LakeWaveProfile { get; set; }\r\n\t[Property(Title = \"River\"), Group(\"Profile\")] public WaterDefinition RiverWaveProfile { get; set; }\r\n\t[Property(Title = \"Pool\"), Group(\"Profile\")] public WaterDefinition PoolWaveProfile { get; set; }\r\n\t[Property(Title = \"Custom\"), Group(\"Profile\")] public WaterDefinition CustomWaveProfile { get; set; }\r\n\r\n\t[Property(Title = \"Underwater Volume\"), Group(\"Post Processing\")] public PostProcessVolume UnderwaterPostProcessVolume { get; set; }\r\n\r\n\t// Skips the whole compute + draw for any bounded water surface (pools, rivers) whose\r\n\t// bounds fall outside the camera frustum. The single biggest win when a scene has many\r\n\t// separate WaterQuads scattered around. Infinite oceans (WaterBodyRenderer) are never culled.\r\n\t[Property(Title = \"Frustum Culling\"), Group(\"Performance\")] public bool EnableFrustumCulling { get; set; } = true;\r\n\t// Extra slack (world units) added to each surface's bounds before the frustum test, so\r\n\t// surfaces at the screen edge don't pop when the camera turns quickly.\r\n\t[Property(Title = \"Cull Padding\"), Group(\"Performance\")] public float CullPadding { get; set; } = 256.0f;\r\n\t// Beyond this distance (world units, measured to the nearest point of a surface's bounds)\r\n\t// the surface is skipped entirely. 0 = no distance limit. Independent of frustum culling.\r\n\t[Property(Title = \"Max Render Distance\"), Group(\"Performance\")] public float MaxRenderDistance { get; set; } = 25000.0f;\r\n\r\n\t// Distance LOD: distant water quads drop tessellation instead of staying at full density.\r\n\t// Each level halves the cell count and doubles the cell size, so the surface covers exactly\r\n\t// the same area with 4x fewer vertices \u2014 coverage, ring layout and texture tiling are all\r\n\t// unchanged, only the triangle density falls off with distance.\r\n\t[Property(Title = \"Distance LOD\"), Group(\"Performance\")] public bool EnableDistanceLod { get; set; } = true;\r\n\t// Distance at which LOD 1 begins; each level after that doubles (LOD 2 at 2x, LOD 3 at 4x).\r\n\t[Property(Title = \"LOD Start Distance\"), Group(\"Performance\")] public float LodStartDistance { get; set; } = 1000.0f;\r\n\t[Property(Title = \"Max LOD Level\"), Group(\"Performance\"), Range(0, 4)] public int MaxLodLevel { get; set; } = 3;\r\n\r\n\tprivate ComputeShader m_ComputeShader;\r\n\r\n\tprivate CommandList m_CommandList = new(\"Water Rendering\");\r\n\r\n\tprivate CameraComponent m_LastCamera;\r\n\tprivate Vector3 m_CameraPosition;\r\n\tprivate Frustum m_CullFrustum;\r\n\tprivate bool m_HasCullFrustum;\r\n\tprivate WaterDefinition m_DefaultProfile;\r\n\r\n\t// Rebuilt each RenderAll: the bounded surfaces that survived frustum culling. Reused\r\n\t// across the compute / barrier / draw phases so the decision is made exactly once.\r\n\tprivate readonly List<WaterQuad> m_VisibleQuads = [];\r\n\tprivate readonly List<WaterFlow> m_VisibleFlows = [];\r\n\r\n\tprivate List<WaterQuad> Quads { get; } = [];\r\n\tprivate List<WaterBodyRenderer> QuadRenderers { get; } = [];\r\n\tpublic List<WaterBody> Bodies { get; } = [];\r\n\tpublic List<WaterFlow> Flows { get; } = [];\r\n\tpublic List<WaterExclusionVolume> ExclusionVolumes { get; } = [];\r\n\tpublic List<HullWaterExclusionVolume> HullExclusionVolumes { get; } = [];\r\n\t\r\n\t\r\n\t\r\n\tprotected override void OnAwake()\r\n\t{\r\n\t\tCurrent = Scene.Get<WaterManager>();\r\n\t\t\r\n\t\tm_ComputeShader = new ComputeShader(\"water_clipmap_cs\");\r\n\r\n\t\tm_DefaultProfile = new WaterDefinition();\r\n\t}\r\n\t\r\n\t\r\n\t\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tm_SceneObject = new SceneCustomObject(Scene.SceneWorld)\r\n\t\t{\r\n\t\t\tRenderOverride = RenderAll,\r\n\t\t\tTransform = new Transform(Vector3.Zero, Rotation.Identity),\r\n\t\t\tFlags =\r\n\t\t\t{\r\n\t\t\t\tIsOpaque = false,\r\n\t\t\t\tIsTranslucent = true,\r\n\t\t\t\tWantsFrameBufferCopy = false,\r\n\t\t\t\tWantsPrePass = false\r\n\t\t\t}\r\n\t\t};\r\n\t\t\r\n\t\tUpdateCommandListRegistration();\r\n\r\n\t\tRefreshWaterQuadsList();\r\n\t\tRefreshWaterBodyRenderersList();\r\n\t\tRefreshWaterBodiesList();\r\n\t\tRefreshWaterExclusionVolumesList();\r\n\t\tRefreshWaterHullExclusionVolumesList();\r\n\t}\r\n\t\r\n\t\r\n\t\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tm_SceneObject?.Delete();\r\n\t\tm_SceneObject = null;\r\n\r\n\t\tm_RippleBuffer?.Dispose();\r\n\t\tm_RippleBuffer = null;\r\n\t\r\n\t\tClearCalmVolumes();\r\n\r\n\t\t// Unregister from the camera we actually registered with. Scene.Camera can have changed\r\n\t\t// (or gone) since then, so asking for it again would leave the list attached to a camera\r\n\t\t// we never clean up.\r\n\t\tif (m_LastCamera.IsValid())\r\n\t\t\tm_LastCamera.RemoveCommandList(m_CommandList);\r\n\r\n\t\tm_LastCamera = null;\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// Keeps the compute command list attached to a camera that will actually replay it. This has\r\n\t/// to run every frame, not just on enable: a scene starting without a camera would never\r\n\t/// register at all, and leaving play mode destroys the play camera without the reference here\r\n\t/// turning null, so comparing references alone would leave us bound to a dead camera forever.\r\n\t/// </summary>\r\n\tprivate void UpdateCommandListRegistration()\r\n\t{\r\n\t\tvar renderCamera = GetRenderCamera();\r\n\r\n\t\tif (renderCamera == m_LastCamera && m_LastCamera.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tif (m_LastCamera.IsValid())\r\n\t\t\tm_LastCamera.RemoveCommandList(m_CommandList);\r\n\r\n\t\tm_LastCamera = null;\r\n\r\n\t\tif (renderCamera.IsValid())\r\n\t\t{\r\n\t\t\trenderCamera.AddCommandList(m_CommandList, RenderStage.AfterTransparent);\r\n\t\t\tm_LastCamera = renderCamera;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// The camera whose command list actually replays. A scene camera does so in the editor\r\n\t/// viewport as well as in game, so it wins when one exists; with no camera in the scene the\r\n\t/// editor camera is the only thing left that will replay ours.\r\n\t/// </summary>\r\n\tprivate CameraComponent GetRenderCamera()\r\n\t{\r\n\t\tif (Scene.Camera.IsValid())\r\n\t\t\treturn Scene.Camera;\r\n\r\n\t\tif (Scene.IsEditor)\r\n\t\t\treturn Application.Editor?.Camera;\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// World position the water should treat as the viewer, for anything that culls or picks\r\n\t/// volumes by distance. While editing that has to be the viewport camera rather than the scene\r\n\t/// camera, or volumes are gathered around wherever the game camera happens to be parked and the\r\n\t/// water you are actually looking at gets the wrong set. Falls back when no camera exists at\r\n\t/// all, which is a real case - Scene.Camera excludes the editor camera and can be null.\r\n\t/// </summary>\r\n\tpublic static Vector3 GetViewPosition(Scene scene, Vector3 fallback = default)\r\n\t{\r\n\t\tif (!scene.IsValid())\r\n\t\t\treturn fallback;\r\n\r\n\t\tif (scene.IsEditor)\r\n\t\t{\r\n\t\t\tvar editorCamera = Application.Editor?.Camera;\r\n\t\t\tif (editorCamera.IsValid())\r\n\t\t\t\treturn editorCamera.WorldPosition;\r\n\t\t}\r\n\r\n\t\treturn scene.Camera.IsValid() ? scene.Camera.WorldPosition : fallback;\r\n\t}\r\n\t\r\n\t\r\n\t\r\n\tvoid IHotloadManaged.Destroyed(Dictionary<string, object> _State)\r\n\t{\r\n\t\t_State[\"IsActive\"] = Current == this;\r\n\t}\r\n\r\n\r\n\r\n\tvoid IHotloadManaged.Created(IReadOnlyDictionary<string, object> _State)\r\n\t{\r\n\t\tif (_State.GetValueOrDefault(\"IsActive\") is true)\r\n\t\t\tCurrent = this;\r\n\t}\r\n\t\r\n\t\r\n\t\r\n\t/// <summary>\r\n\t/// Whether a bounded water surface should render this frame: inside the cull camera's\r\n\t/// frustum and within the max render distance. Returns true \u2014 render it \u2014 when there's\r\n\t/// no viewer, or when both culls are disabled.\r\n\t/// </summary>\r\n\t/// <summary>Distance at which the given LOD level starts (level 1 = LodStartDistance).</summary>\r\n\tprivate float LodThreshold(int lod) => LodStartDistance * MathF.Pow(2.0f, lod - 1);\r\n\r\n\t/// <summary>\r\n\t/// Resolves the tessellation LOD for a surface from how far its bounds are from the viewer.\r\n\t/// Takes the surface's current level so the switch can be hysteretic: a level only changes\r\n\t/// once the distance is comfortably past the boundary, otherwise a camera hovering right on\r\n\t/// a threshold would rebuild that surface's GPU buffers every frame.\r\n\t/// </summary>\r\n\tpublic int ComputeLodLevel(BBox worldBounds, int currentLod)\r\n\t{\r\n\t\tif (!EnableDistanceLod || !m_HasCullFrustum || MaxLodLevel <= 0 || LodStartDistance <= 0.0f)\r\n\t\t\treturn 0;\r\n\r\n\t\tconst float hysteresis = 0.15f;\r\n\r\n\t\tfloat distance = worldBounds.ClosestPoint(m_CameraPosition).Distance(m_CameraPosition);\r\n\r\n\t\tint lod = Math.Clamp(currentLod, 0, MaxLodLevel);\r\n\r\n\t\t// Step out as the surface recedes, in as it approaches \u2014 one level at a time\r\n\t\twhile (lod < MaxLodLevel && distance > LodThreshold(lod + 1) * (1.0f + hysteresis))\r\n\t\t\tlod++;\r\n\r\n\t\twhile (lod > 0 && distance < LodThreshold(lod) * (1.0f - hysteresis))\r\n\t\t\tlod--;\r\n\r\n\t\treturn lod;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsRenderVisible(BBox worldBounds)\r\n\t{\r\n\t\t// Both culls need a viewer; without one, don't cull anything.\r\n\t\tif (!m_HasCullFrustum)\r\n\t\t\treturn true;\r\n\r\n\t\t// Distance cull \u2014 measured to the nearest point of the bounds, so a large surface\r\n\t\t// whose centre is far but edge is near still renders.\r\n\t\tif (MaxRenderDistance > 0.0f)\r\n\t\t{\r\n\t\t\tfloat distSq = worldBounds.ClosestPoint(m_CameraPosition).DistanceSquared(m_CameraPosition);\r\n\r\n\t\t\tif (distSq > MaxRenderDistance * MaxRenderDistance)\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t// Frustum cull\r\n\t\tif (EnableFrustumCulling && !m_CullFrustum.IsInside(worldBounds.Grow(CullPadding), partially: true))\r\n\t\t\treturn false;\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RenderAll(SceneObject _)\r\n\t{\r\n\t\tif (Graphics.LayerType != SceneLayerType.Translucent)\r\n\t\t\treturn;\r\n\r\n\t\tm_CommandList.Reset();\r\n\r\n\t\t// Frustum-cull the bounded surfaces once, up front. The compute / barrier / draw\r\n\t\t// phases below all iterate these lists, so a culled surface pays for nothing.\r\n\t\tm_VisibleQuads.Clear();\r\n\t\tforeach (var quad in Quads)\r\n\t\t{\r\n\t\t\tif (quad.IsValid() && quad.ParticipatesInRendering && IsRenderVisible(quad.GetWorldBounds2D()))\r\n\t\t\t\tm_VisibleQuads.Add(quad);\r\n\t\t}\r\n\r\n\t\tm_VisibleFlows.Clear();\r\n\t\tforeach (var flow in Flows)\r\n\t\t{\r\n\t\t\tif (flow.IsValid() && flow.ParticipatesInRendering && IsRenderVisible(flow.GetWorldBounds()))\r\n\t\t\t\tm_VisibleFlows.Add(flow);\r\n\t\t}\r\n\r\n\t\tbool hasAnythingToRender = false;\r\n\r\n\t\t// Renderers are the infinite ocean surfaces \u2014 never culled (their bounds are \"everywhere\")\r\n\t\tforeach (var renderer in QuadRenderers)\r\n\t\t{\r\n\t\t\tif (!renderer.IsValid() || !renderer.ParticipatesInRendering)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\thasAnythingToRender = true;\r\n\t\t\trenderer.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);\r\n\t\t}\r\n\r\n\t\tforeach (var quad in m_VisibleQuads)\r\n\t\t{\r\n\t\t\thasAnythingToRender = true;\r\n\t\t\tquad.RecordCompute(m_CommandList, m_ComputeShader, m_CameraPosition);\r\n\t\t}\r\n\r\n\t\t// Flows build their mesh on the CPU (no compute pass or barrier needed)\r\n\t\tif (m_VisibleFlows.Count > 0)\r\n\t\t\thasAnythingToRender = true;\r\n\r\n\t\tif (hasAnythingToRender)\r\n\t\t{\r\n\t\t\tforeach (var renderer in QuadRenderers)\r\n\t\t\t{\r\n\t\t\t\tif (!renderer.IsValid() || !renderer.ParticipatesInRendering)\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\trenderer.BarrierTransition(m_CommandList);\r\n\t\t\t}\r\n\r\n\t\t\tforeach (var quad in m_VisibleQuads)\r\n\t\t\t\tquad.BarrierTransition(m_CommandList);\r\n\r\n\t\t\tm_CommandList.Attributes.GrabFrameTexture(\"FrameBufferCopyTexture\");\r\n\r\n\t\t\tforeach (var renderer in QuadRenderers)\r\n\t\t\t{\r\n\t\t\t\tif (!renderer.IsValid() || !renderer.ParticipatesInRendering)\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\trenderer.Draw(m_CommandList);\r\n\t\t\t}\r\n\r\n\t\t\tforeach (var quad in m_VisibleQuads)\r\n\t\t\t\tquad.Draw(m_CommandList);\r\n\r\n\t\t\tforeach (var flow in m_VisibleFlows)\r\n\t\t\t\tflow.Draw(m_CommandList);\r\n\t\t}\r\n\t}\r\n\t\r\n\t\r\n\t\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// We've to make sure it's always correct while in the editor\r\n\t\t// (S&box is a complete mess when it comes to managing a singleton properly on a component that execute in the editor, bcs its reference get constantly swapped between\r\n\t\t// gameplay and editor, we've to do this non sense !)\r\n\t\tif (Scene.IsEditor)\r\n\t\t\tCurrent = Scene.Get<WaterManager>();\r\n\r\n\t\tUpdateCommandListRegistration();\r\n\r\n\t\t// The camera we cull and centre the clipmap against: the game camera while playing,\r\n\t\t// otherwise the editor viewport camera so culling follows what you're actually looking at.\r\n\t\tCameraComponent cullCamera = Game.IsPlaying ? Scene.Camera : Application.Editor?.Camera;\r\n\r\n\t\tif (cullCamera.IsValid())\r\n\t\t{\r\n\t\t\tm_CameraPosition = cullCamera.WorldPosition;\r\n\t\t\tm_CullFrustum = cullCamera.GetFrustum();\r\n\t\t\tm_HasCullFrustum = true;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tm_CameraPosition = Vector3.Zero;\r\n\t\t\tm_HasCullFrustum = false;\r\n\t\t}\r\n\r\n\t\tif (UnderwaterPostProcessVolume.IsValid())\r\n\t\t\tUnderwaterPostProcessVolume.Enabled = IsPositionInsideAny(m_CameraPosition);\r\n\r\n\t\tUpdateRipples();\r\n\t\tUpdateCalmVolumes();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// We have to do all this non sense bcs using a Register/Unregister logic with OnEnabled/OnDisabled is a complete\r\n\t/// mess to manage when we enter play mode/stop play mode in the editor, the references get duplicated etc... Otherwise we've to check by gameobject id...\r\n\t/// It's just way too annoying, refreshing the whole list is safer and we're always sure to have the proper count of components\r\n\t/// </summary>\r\n\tpublic void RefreshWaterQuadsList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tQuads.Clear();\r\n\t\tQuads.AddRange(Scene.GetAll<WaterQuad>());\r\n\t}\r\n\r\n\tpublic void RefreshWaterBodyRenderersList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tQuadRenderers.Clear();\r\n\t\tQuadRenderers.AddRange(Scene.GetAll<WaterBodyRenderer>());\r\n\t}\r\n\r\n\tpublic void RefreshWaterBodiesList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tBodies.Clear();\r\n\t\tBodies.AddRange(Scene.GetAll<WaterBody>());\r\n\t}\r\n\t\r\n\tpublic void RefreshWaterFlowsList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tFlows.Clear();\r\n\t\tFlows.AddRange(Scene.GetAll<WaterFlow>());\r\n\t}\r\n\r\n\tpublic void RefreshWaterExclusionVolumesList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tExclusionVolumes.Clear();\r\n\t\tExclusionVolumes.AddRange(Scene.GetAll<WaterExclusionVolume>());\r\n\t}\r\n\r\n\tpublic void RefreshWaterHullExclusionVolumesList()\r\n\t{\r\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\r\n\t\t\treturn;\r\n\t\t\r\n\t\tHullExclusionVolumes.Clear();\r\n\t\tHullExclusionVolumes.AddRange(Scene.GetAll<HullWaterExclusionVolume>());\r\n\t}\r\n\r\n\tprivate WaterDefinition GetWaveProfileForType(WaterBodyType waterType) => waterType switch\r\n\t{\r\n\t\tWaterBodyType.Ocean => OceanWaveProfile,\r\n\t\tWaterBodyType.Lake => LakeWaveProfile,\r\n\t\tWaterBodyType.River => RiverWaveProfile,\r\n\t\tWaterBodyType.Pool => PoolWaveProfile,\r\n\t\t_ => CustomWaveProfile\r\n\t};\r\n\r\n\tpublic static WaterDefinition GetWaveProfile(WaterBodyType _WaterType)\r\n\t{\r\n\t\tif (Current == null)\r\n\t\t\treturn null;\r\n\r\n\t\tWaterDefinition profile = Current.GetWaveProfileForType(_WaterType);\r\n\r\n\t\tif (profile.IsValid())\r\n\t\t\treturn profile;\r\n\r\n\t\tLog.Warning(\"[WaterTool] No water profile found in the 'Water Manager', please add a water profile for the specified water type ! (Project Settings > Water Manager > 'Assign the profiles')\");\r\n\r\n\t\treturn Current.m_DefaultProfile;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterCalmVolume.cs",
            "FileName": "WaterCalmVolume.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\nusing Sandbox.Volumes;\n\nnamespace RedSnail.WaterTool;\n\n/// <summary>\n/// Calms the water inside a volume: wave displacement (and the surface normals that\n/// come from it) smoothly fade to flat. Affects every water surface \u2014 WaterQuad,\n/// WaterBodyRenderer and WaterFlow \u2014 so it's the clean way to blend two of them\n/// together. The classic use is a river mouth meeting an ocean: drop a calm volume\n/// over the junction, set both surfaces to the same height there, and the wave\n/// mismatch (ocean chop poking above the river, seams) disappears.\n///\n/// Purely visual \u2014 it doesn't touch buoyancy, swimming or the flow current.\n/// </summary>\n[Title(\"Water Calm Volume\")]\n[Category(\"Volumes\")]\n[Icon(\"water\")]\npublic sealed class WaterCalmVolume : VolumeComponent, Component.ExecuteInEditor\n{\n\t// 0 = no effect, 1 = perfectly flat at the core. Lets a volume only partially\n\t// settle the water if you want some residual motion.\n\t[Property, Range(0.0f, 1.0f)] public float Strength { get; set; } = 1.0f;\n\n\t// Fraction of the volume (from each face inward) over which the calming ramps in.\n\t// 0 = hard edge (a visible crease), 1 = ramps all the way from the center.\n\t[Property, Range(0.05f, 1.0f)] public float Falloff { get; set; } = 0.4f;\n\t\n\t\n\t\n\tprotected override void OnEnabled()\n\t{\n\t\tWaterManager.Current?.RefreshWaterCalmVolumesList();\n\t}\n\t\n\tprotected override void OnDisabled()\n\t{\n\t\tWaterManager.Current?.RefreshWaterCalmVolumesList();\n\t}\n\n\tprotected override void DrawGizmos()\n\t{\n\t\tbase.DrawGizmos();\n\n\t\tif (!Gizmo.IsSelected)\n\t\t\treturn;\n\n\t\t// Faint fill so calm volumes read differently from exclusion volumes\n\t\tBBox box = SceneVolume.GetBounds();\n\n\t\tGizmo.Draw.Color = Color.Cyan.WithAlpha(0.06f);\n\t\tGizmo.Draw.SolidBox(box);\n\t}\n\n\tpublic (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()\n\t{\n\t\tBBox local = SceneVolume.GetBounds();\n\t\tVector3 center = WorldTransform.PointToWorld(local.Center);\n\t\tVector3 halfExtents = local.Size * 0.5f;\n\n\t\treturn (center, WorldRotation.Forward, WorldTransform.Up, halfExtents);\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterManager.CalmVolumes.cs",
            "FileName": "WaterManager.CalmVolumes.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.WaterTool;\n\npublic partial class WaterManager\n{\n\t// Calm volumes are few (river/ocean junctions) and apply to every water surface,\n\t// so \u2014 like ripples \u2014 they live in one shared buffer the manager updates once a\n\t// frame, rather than the per-component distance-sorted exclusion-volume pattern.\n\n\tprivate const int MAX_CALM_VOLUMES = 64;\n\tprivate const int CALM_VOLUME_ROWS = 4;\n\n\tpublic List<WaterCalmVolume> CalmVolumes { get; } = [];\n\n\tprivate GpuBuffer<Vector4> m_CalmVolumeBuffer;\n\tprivate readonly Vector4[] m_CalmVolumeData = new Vector4[MAX_CALM_VOLUMES * CALM_VOLUME_ROWS];\n\tprivate int m_ActiveCalmCount;\n\t\n\t\n\t\n\tpublic void RefreshWaterCalmVolumesList()\n\t{\n\t\tif (!Scene.IsValid()) // S&box make this null while stopping play mode and entering back the editor mode (We need to guard this)\n\t\t\treturn;\n\t\t\n\t\tCalmVolumes.Clear();\n\t\tCalmVolumes.AddRange(Scene.GetAll<WaterCalmVolume>());\n\t}\n\t\n\t\n\t\n\tprivate void UpdateCalmVolumes()\n\t{\n\t\tint count = 0;\n\n\t\tforeach (var volume in CalmVolumes)\n\t\t{\n\t\t\tif (!volume.IsValid() || !volume.Active)\n\t\t\t\tcontinue;\n\n\t\t\tif (count >= MAX_CALM_VOLUMES)\n\t\t\t\tbreak;\n\n\t\t\tvar (center, forward, up, half) = volume.GetWorldOBB();\n\n\t\t\tint row = count * CALM_VOLUME_ROWS;\n\t\t\tm_CalmVolumeData[row + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\n\t\t\tm_CalmVolumeData[row + 1] = new Vector4(up.x, up.y, up.z, half.y);\n\t\t\tm_CalmVolumeData[row + 2] = new Vector4(center.x, center.y, center.z, half.z);\n\t\t\tm_CalmVolumeData[row + 3] = new Vector4(volume.Falloff, volume.Strength, 0.0f, 0.0f);\n\n\t\t\tcount++;\n\t\t}\n\n\t\tm_ActiveCalmCount = count;\n\n\t\tEnsureCalmBuffer();\n\n\t\tm_CalmVolumeBuffer.SetData(m_CalmVolumeData.AsSpan(0, count * CALM_VOLUME_ROWS));\n\t}\n\n\tprivate void EnsureCalmBuffer()\n\t{\n\t\tif (!m_CalmVolumeBuffer.IsValid())\n\t\t\tm_CalmVolumeBuffer = new GpuBuffer<Vector4>(MAX_CALM_VOLUMES * CALM_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);\n\t}\n\n\tinternal void ApplyCalmAttributes(RenderAttributes _Attributes)\n\t{\n\t\t_Attributes.Set(\"WaterCalmVolumeCount\", m_ActiveCalmCount);\n\n\t\tif (m_CalmVolumeBuffer.IsValid())\n\t\t\t_Attributes.Set(\"WaterCalmVolumeData\", m_CalmVolumeBuffer);\n\t}\n\n\n\n\t/// <summary>\n\t/// CPU evaluation of the calm factor at a world position (0 = full waves, 1 = flat).\n\t/// MUST mirror ComputeWaterCalm() in water_calm_volume.fxc so physics (buoyancy,\n\t/// height queries) matches the flattened visual surface.\n\t/// </summary>\n\tpublic float ComputeCalm(Vector3 _WorldPosition)\n\t{\n\t\tif (CalmVolumes.Count == 0)\n\t\t\treturn 0.0f;\n\n\t\tfloat calm = 0.0f;\n\n\t\tforeach (var volume in CalmVolumes)\n\t\t{\n\t\t\tif (!volume.IsValid() || !volume.Active)\n\t\t\t\tcontinue;\n\n\t\t\tvar (center, forward, up, half) = volume.GetWorldOBB();\n\n\t\t\tVector3 right = Vector3.Cross(up, forward);\n\t\t\tVector3 d = _WorldPosition - center;\n\n\t\t\tfloat nx = MathF.Abs(Vector3.Dot(d, forward)) / MathF.Max(half.x, 0.001f);\n\t\t\tfloat ny = MathF.Abs(Vector3.Dot(d, right))   / MathF.Max(half.y, 0.001f);\n\t\t\tfloat nz = MathF.Abs(Vector3.Dot(d, up))      / MathF.Max(half.z, 0.001f);\n\n\t\t\tfloat nmax = MathF.Max(nx, MathF.Max(ny, nz));\n\n\t\t\tfloat falloffStart = Math.Clamp(1.0f - volume.Falloff, 0.0f, 1.0f);\n\t\t\tfloat volumeCalm = (1.0f - SmoothStep(falloffStart, 1.0f, nmax)) * volume.Strength;\n\n\t\t\tcalm = MathF.Max(calm, volumeCalm);\n\t\t}\n\n\t\treturn Math.Clamp(calm, 0.0f, 1.0f);\n\t}\n\n\t// Matches HLSL smoothstep().\n\tprivate static float SmoothStep(float _Edge0, float _Edge1, float _X)\n\t{\n\t\tfloat t = Math.Clamp((_X - _Edge0) / MathF.Max(_Edge1 - _Edge0, 1e-6f), 0.0f, 1.0f);\n\t\treturn t * t * (3.0f - 2.0f * t);\n\t}\n\n\tprivate void ClearCalmVolumes()\n\t{\n\t\tm_CalmVolumeBuffer?.Dispose();\n\t\tm_CalmVolumeBuffer = null;\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterWaveUtility.cs",
            "FileName": "WaterWaveUtility.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\npublic enum WaterBodyType\r\n{\r\n\tOcean,\r\n\tLake,\r\n\tRiver,\r\n\tPool,\r\n\tCustom\r\n}\r\n\r\npublic static class WaterWaveUtility\r\n{\r\n\tpublic static Vector3 ComputeDisplacementAt(Vector2 worldXY, WaterDefinition profile)\r\n\t{\r\n\t\tVector3 detail = ComputeGerstner(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;\r\n\t\tVector3 swell = ComputeGerstner(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;\r\n\t\treturn detail + swell;\r\n\t}\r\n\r\n\tpublic static Vector3 ComputeVelocityAt(Vector2 worldXY, WaterDefinition profile)\r\n\t{\r\n\t\tVector3 detail = ComputeGerstnerVelocity(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;\r\n\t\tVector3 swell = ComputeGerstnerVelocity(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;\r\n\t\treturn detail + swell;\r\n\t}\r\n\r\n\tprivate static Vector3 ComputeGerstner(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)\r\n\t{\r\n\t\tif (scale <= 0.0f || speed <= 0.0f || octaves <= 0)\r\n\t\t\treturn Vector3.Zero;\r\n\r\n\t\tVector2 waveDirection = direction.Normal;\r\n\t\tfloat t = Time.Now * speed;\r\n\r\n\t\tVector3 displacement = Vector3.Zero;\r\n\t\tfloat amp = 1.0f;\r\n\t\tfloat freq = scale;\r\n\t\tfloat maxAmp = 0f;\r\n\r\n\t\tfor (int oct = 0; oct < octaves; oct++)\r\n\t\t{\r\n\t\t\tfloat angle = oct * 1.2f;\r\n\t\t\tVector2 octDir = new(\r\n\t\t\t\twaveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),\r\n\t\t\t\twaveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)\r\n\t\t\t);\r\n\r\n\t\t\tfloat phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;\r\n\t\t\tdisplacement.x += steepness * amp * octDir.x * MathF.Cos(phase);\r\n\t\t\tdisplacement.y += steepness * amp * octDir.y * MathF.Cos(phase);\r\n\t\t\tdisplacement.z += amp * MathF.Sin(phase);\r\n\r\n\t\t\tmaxAmp += amp;\r\n\t\t\tamp *= persistence;\r\n\t\t\tfreq *= lacunarity;\r\n\t\t}\r\n\r\n\t\treturn maxAmp > 0.0f ? displacement / maxAmp : Vector3.Zero;\r\n\t}\r\n\r\n\tprivate static Vector3 ComputeGerstnerVelocity(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)\r\n\t{\r\n\t\tif (scale <= 0.0f || speed <= 0.0f || octaves <= 0)\r\n\t\t\treturn Vector3.Zero;\r\n\r\n\t\tVector2 waveDirection = direction.Normal;\r\n\t\tfloat t = Time.Now * speed;\r\n\r\n\t\tVector3 velocity = Vector3.Zero;\r\n\t\tfloat amp = 1.0f;\r\n\t\tfloat freq = scale;\r\n\t\tfloat maxAmp = 0f;\r\n\r\n\t\tfor (int oct = 0; oct < octaves; oct++)\r\n\t\t{\r\n\t\t\tfloat angle = oct * 1.2f;\r\n\t\t\tVector2 octDir = new(\r\n\t\t\t\twaveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),\r\n\t\t\t\twaveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)\r\n\t\t\t);\r\n\r\n\t\t\tfloat phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;\r\n\t\t\tfloat angularVelocity = freq * speed * 0.5f;\r\n\r\n\t\t\tvelocity.x -= steepness * amp * octDir.x * angularVelocity * MathF.Sin(phase);\r\n\t\t\tvelocity.y -= steepness * amp * octDir.y * angularVelocity * MathF.Sin(phase);\r\n\t\t\tvelocity.z += amp * angularVelocity * MathF.Cos(phase);\r\n\r\n\t\t\tmaxAmp += amp;\r\n\t\t\tamp *= persistence;\r\n\t\t\tfreq *= lacunarity;\r\n\t\t}\r\n\r\n\t\treturn maxAmp > 0.0f ? velocity / maxAmp : Vector3.Zero;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/HullWaterExclusionVolume.cs",
            "FileName": "HullWaterExclusionVolume.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n/// <summary>\r\n/// Excludes the water surface inside a mesh hull rather than an approximated box volume.\r\n/// Place on the same GameObject as the ModelRenderer. The physics collision mesh is extracted\r\n/// once and uploaded to the GPU as a triangle list; only the WorldToLocal matrix is updated\r\n/// each frame as the object moves or rotates.\r\n/// </summary>\r\n[Title(\"Hull Water Exclusion Volume\"), Group(\"Water\"), Icon(\"sailing\")]\r\npublic sealed class HullWaterExclusionVolume : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>Triangle vertices in model LOCAL space, flat (v0,v1,v2, v0,v1,v2 \u2026).</summary>\r\n\tpublic Vector3[] LocalTriangles { get; private set; } = Array.Empty<Vector3>();\r\n\r\n\t/// <summary>AABB of all local triangles, used for early GPU rejection.</summary>\r\n\tpublic BBox LocalAABB { get; private set; }\r\n\t\r\n\t[Property] private Model CustomModel { get; set; }\r\n\r\n\tprivate Model _lastModel;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tRebuildMesh();\r\n\r\n\t\tWaterManager.Current?.RefreshWaterHullExclusionVolumesList();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterHullExclusionVolumesList();\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tvar model = CustomModel.IsValid() ? CustomModel : GetComponent<ModelRenderer>()?.Model;\r\n\t\t\r\n\t\tif (model != _lastModel)\r\n\t\t\tRebuildMesh();\r\n\t}\r\n\r\n\tprivate void RebuildMesh()\r\n\t{\r\n\t\tvar model = CustomModel.IsValid() ? CustomModel : GetComponent<ModelRenderer>()?.Model;\r\n\r\n\t\tif (model == null)\r\n\t\t{\r\n\t\t\tLocalTriangles = Array.Empty<Vector3>();\r\n\t\t\tLocalAABB = default;\r\n\t\t\t_lastModel = null;\r\n\t\t\tLog.Warning($\"{nameof(HullWaterExclusionVolume)}: No ModelRenderer or Model found.\");\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_lastModel = model;\r\n\r\n\t\tvar tris = new List<Vector3>();\r\n\t\tvar aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);\r\n\t\tvar aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);\r\n\r\n\t\t// Prefer the physics collision mesh \u2014 it's already simplified and watertight.\r\n\t\tvar physics = model.Physics;\r\n\t\tif (physics != null)\r\n\t\t{\r\n\t\t\tforeach (var part in physics.Parts)\r\n\t\t\t{\r\n\t\t\t\tforeach (var meshPart in part.Meshes)\r\n\t\t\t\t{\r\n\t\t\t\t\tforeach (var tri in meshPart.GetTriangles())\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\ttris.Add(tri.A);\r\n\t\t\t\t\t\ttris.Add(tri.B);\r\n\t\t\t\t\t\ttris.Add(tri.C);\r\n\r\n\t\t\t\t\t\taabbMin = Vector3.Min(aabbMin, Vector3.Min(tri.A, Vector3.Min(tri.B, tri.C)));\r\n\t\t\t\t\t\taabbMax = Vector3.Max(aabbMax, Vector3.Max(tri.A, Vector3.Max(tri.B, tri.C)));\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\r\n\t\t\t\t// Convex hull shapes have no MeshParts \u2014 triangulate each hull instead.\r\n\t\t\t\tforeach (var hullPart in part.Hulls)\r\n\t\t\t\t{\r\n\t\t\t\t\tvar pts = hullPart.GetPoints()?.ToArray();\r\n\t\t\t\t\tif (pts == null || pts.Length < 4) continue;\r\n\t\t\t\t\tTriangulateConvexHull(pts, tris, ref aabbMin, ref aabbMax);\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// Fallback: render mesh (may have more triangles, less ideal for GPU iteration)\r\n\t\tif (tris.Count == 0)\r\n\t\t{\r\n\t\t\tvar vertices = model.GetVertices();\r\n\t\t\tvar indices = model.GetIndices();\r\n\r\n\t\t\tif (vertices != null && indices != null)\r\n\t\t\t{\r\n\t\t\t\tfor (int i = 0; i + 2 < indices.Length; i += 3)\r\n\t\t\t\t{\r\n\t\t\t\t\tVector3 v0 = vertices[indices[i + 0]].Position;\r\n\t\t\t\t\tVector3 v1 = vertices[indices[i + 1]].Position;\r\n\t\t\t\t\tVector3 v2 = vertices[indices[i + 2]].Position;\r\n\r\n\t\t\t\t\ttris.Add(v0);\r\n\t\t\t\t\ttris.Add(v1);\r\n\t\t\t\t\ttris.Add(v2);\r\n\r\n\t\t\t\t\taabbMin = Vector3.Min(aabbMin, Vector3.Min(v0, Vector3.Min(v1, v2)));\r\n\t\t\t\t\taabbMax = Vector3.Max(aabbMax, Vector3.Max(v0, Vector3.Max(v1, v2)));\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tLocalTriangles = tris.ToArray();\r\n\t\tLocalAABB = tris.Count > 0 ? new BBox(aabbMin, aabbMax) : default;\r\n\t}\r\n\r\n\t// N\u00b3 convex hull triangulation.\r\n\t// Finds each hull face by collecting ALL coplanar vertices, then fan-triangulates once per face.\r\n\t// Without this, rectangular faces (4 coplanar verts) emit C(4,3)=4 overlapping triangles,\r\n\t// flipping the ray parity and incorrectly marking exterior points as inside.\r\n\tprivate static void TriangulateConvexHull(Vector3[] verts, List<Vector3> result, ref Vector3 aabbMin, ref Vector3 aabbMax)\r\n\t{\r\n\t\tint n = verts.Length;\r\n\t\tif (n < 4) return;\r\n\r\n\t\tvar centroid = Vector3.Zero;\r\n\t\tforeach (var v in verts) centroid += v;\r\n\t\tcentroid /= n;\r\n\r\n\t\tvar processedFaces = new HashSet<string>();\r\n\r\n\t\tfor (int i = 0; i < n; i++)\r\n\t\t\tfor (int j = i + 1; j < n; j++)\r\n\t\t\t\tfor (int k = j + 1; k < n; k++)\r\n\t\t\t\t{\r\n\t\t\t\t\tVector3 A = verts[i], B = verts[j], C = verts[k];\r\n\t\t\t\t\tVector3 rawNormal = Vector3.Cross(B - A, C - A);\r\n\t\t\t\t\tif (rawNormal.LengthSquared < 1e-8f) continue;\r\n\t\t\t\t\tVector3 normal = rawNormal.Normal; // normalize so d = actual distance in units\r\n\r\n\t\t\t\t\tbool pos = false, neg = false;\r\n\t\t\t\t\tvar faceIndices = new List<int> { i, j, k };\r\n\r\n\t\t\t\t\tfor (int m = 0; m < n; m++)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tif (m == i || m == j || m == k) continue;\r\n\t\t\t\t\t\tfloat d = Vector3.Dot(normal, verts[m] - A);\r\n\t\t\t\t\t\tif (MathF.Abs(d) < 0.01f)\r\n\t\t\t\t\t\t\tfaceIndices.Add(m);   // coplanar \u2014 part of this face\r\n\t\t\t\t\t\telse if (d > 0f) pos = true;\r\n\t\t\t\t\t\telse neg = true;\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tif (pos && neg) continue;     // interior edge, not a hull face\r\n\t\t\t\t\tif (!pos && !neg) continue;   // degenerate \u2014 no non-coplanar vertices\r\n\r\n\t\t\t\t\t// Canonical key: sorted vertex indices \u2014 each face processed exactly once.\r\n\t\t\t\t\tfaceIndices.Sort();\r\n\t\t\t\t\tstring key = string.Join(\",\", faceIndices);\r\n\t\t\t\t\tif (!processedFaces.Add(key)) continue;\r\n\r\n\t\t\t\t\t// Collect face vertices and sort by angle around the face centroid.\r\n\t\t\t\t\tvar faceVerts = faceIndices.Select(idx => verts[idx]).ToList();\r\n\t\t\t\t\tvar fc = Vector3.Zero;\r\n\t\t\t\t\tforeach (var fv in faceVerts) fc += fv;\r\n\t\t\t\t\tfc /= faceVerts.Count;\r\n\r\n\t\t\t\t\t// Build a 2D frame in the face plane for angle sorting.\r\n\t\t\t\t\tvar outward = (Vector3.Dot(normal, centroid - A) < 0f) ? normal : -normal;\r\n\t\t\t\t\tvar tan = faceVerts.Select(fv => fv - fc).FirstOrDefault(d => d.LengthSquared > 1e-8f);\r\n\t\t\t\t\ttan = tan.Normal;\r\n\t\t\t\t\tvar bitan = Vector3.Cross(outward.Normal, tan);\r\n\r\n\t\t\t\t\tfaceVerts.Sort((p, q) =>\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tfloat ap = MathF.Atan2(Vector3.Dot(p - fc, bitan), Vector3.Dot(p - fc, tan));\r\n\t\t\t\t\t\tfloat aq = MathF.Atan2(Vector3.Dot(q - fc, bitan), Vector3.Dot(q - fc, tan));\r\n\t\t\t\t\t\treturn ap.CompareTo(aq);\r\n\t\t\t\t\t});\r\n\r\n\t\t\t\t\t// Fan triangulate the face.\r\n\t\t\t\t\tfor (int t = 1; t < faceVerts.Count - 1; t++)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar ta = faceVerts[0]; var tb = faceVerts[t]; var tc = faceVerts[t + 1];\r\n\t\t\t\t\t\tresult.Add(ta); result.Add(tb); result.Add(tc);\r\n\t\t\t\t\t\taabbMin = Vector3.Min(aabbMin, Vector3.Min(ta, Vector3.Min(tb, tc)));\r\n\t\t\t\t\t\taabbMax = Vector3.Max(aabbMax, Vector3.Max(ta, Vector3.Max(tb, tc)));\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Fills the 4 rows of the WorldToLocal matrix (row-major, for mul(M, float4(worldPos,1)) in HLSL).\r\n\t/// </summary>\r\n\t/// <summary>\r\n\t/// Matches WorldTransform.PointToLocal = Rotation.Inverse * (worldPt - Position) / Scale.\r\n\t/// In s&box: Forward=(1,0,0)=localX, Left=-Right=(0,1,0)=localY, Up=(0,0,1)=localZ.\r\n\t/// </summary>\r\n\tpublic void GetWorldToLocalRows(out Vector4 r0, out Vector4 r1, out Vector4 r2, out Vector4 r3)\r\n\t{\r\n\t\tVector3 fwd = WorldRotation.Forward;        // world-space local X axis\r\n\t\tVector3 left = -WorldRotation.Right;          // world-space local Y axis  (Right = -Y in s&box)\r\n\t\tVector3 up = WorldRotation.Up;             // world-space local Z axis\r\n\t\tVector3 pos = WorldPosition;\r\n\t\tVector3 scale = WorldScale;\r\n\r\n\t\tfloat isx = MathF.Abs(scale.x) > 1e-6f ? 1f / scale.x : 0f;\r\n\t\tfloat isy = MathF.Abs(scale.y) > 1e-6f ? 1f / scale.y : 0f;\r\n\t\tfloat isz = MathF.Abs(scale.z) > 1e-6f ? 1f / scale.z : 0f;\r\n\r\n\t\tr0 = new Vector4(fwd.x * isx, fwd.y * isx, fwd.z * isx, -Vector3.Dot(fwd, pos) * isx);\r\n\t\tr1 = new Vector4(left.x * isy, left.y * isy, left.z * isy, -Vector3.Dot(left, pos) * isy);\r\n\t\tr2 = new Vector4(up.x * isz, up.y * isz, up.z * isz, -Vector3.Dot(up, pos) * isz);\r\n\t\tr3 = new Vector4(0f, 0f, 0f, 1f);\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif (!Gizmo.IsSelected || LocalTriangles == null || LocalTriangles.Length == 0)\r\n\t\t\treturn;\r\n\r\n\t\tGizmo.Draw.Color = Color.Yellow.WithAlpha(0.5f);\r\n\t\tGizmo.Draw.LineBBox(LocalAABB);\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterRippleEmitter.cs",
            "FileName": "WaterRippleEmitter.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\n\nnamespace RedSnail.WaterTool;\n\n/// <summary>\n/// Emits water ripples when this object crosses the water surface, and optionally\n/// while it moves across it. A generic, dependency-free alternative to the entry\n/// ripple built into <see cref=\"Buoyancy\"/> \u2014 drop it on anything that doesn't have\n/// a Buoyancy component (players, NPCs, projectiles, debris...).\n///\n/// Velocity is derived from the object's own position delta, so it works with any\n/// movement system (CharacterController, custom controllers, animation, etc.) and\n/// needs no Rigidbody.\n/// </summary>\n[Icon(\"water\"), Group(\"Water\"), Title(\"Water Ripple Emitter\")]\npublic sealed class WaterRippleEmitter : Component\n{\n\t[Property, Group(\"Entry\")] public bool EmitOnEntry { get; set; } = true;\n\t[Property, Group(\"Entry\")] public float EntryStrength { get; set; } = 0.2f;\n\t// Ring spacing for the entry splash \u2014 smaller = tighter, more concentric rings.\n\t[Property, Group(\"Entry\"), Range(20.0f, 400.0f)] public float EntryWavelength { get; set; } = 120.0f;\n\t// Ring size for the entry splash \u2014 larger = a bigger, broader ripple.\n\t[Property, Group(\"Entry\"), Range(10.0f, 500.0f)] public float EntryRingWidth { get; set; } = 50.0f;\n\t// Minimum downward speed (units/s) needed to splash. Set to 0 to ripple on any crossing.\n\t[Property, Group(\"Entry\")] public float MinImpactSpeed { get; set; } = 40.0f;\n\n\t[Property, Group(\"Wake\")] public bool EmitWake { get; set; } = false;\n\t[Property, Group(\"Wake\")] public float WakeStrength { get; set; } = 0.1f;\n\t// Ring spacing for wake ripples \u2014 smaller = tighter, more concentric rings.\n\t[Property, Group(\"Wake\"), Range(20.0f, 400.0f)] public float WakeWavelength { get; set; } = 120.0f;\n\t// Ring size for wake ripples \u2014 larger = a bigger, broader ripple.\n\t[Property, Group(\"Wake\"), Range(10.0f, 500.0f)] public float WakeRingWidth { get; set; } = 50.0f;\n\t// Minimum horizontal speed (units/s) before a moving object leaves a wake.\n\t[Property, Group(\"Wake\")] public float WakeMinSpeed { get; set; } = 1.0f;\n\t[Property, Group(\"Wake\")] public float WakeInterval { get; set; } = 0.0333f; // 30 fps\n\n\t// Local-space offset of the point tested against the surface (e.g. the feet).\n\t[Property, Group(\"General\")] public Vector3 SampleOffset { get; set; } = Vector3.Zero;\n\n\tprivate bool m_Initialized;\n\tprivate bool m_WasBelowSurface;\n\tprivate Vector3 m_LastPosition;\n\tprivate float m_WakeTimer;\n\n\tprivate Vector3 SamplePosition => WorldPosition + WorldRotation * SampleOffset;\n\n\n\n\tprotected override void OnEnabled()\n\t{\n\t\tm_LastPosition = SamplePosition;\n\t\tm_WasBelowSurface = false;\n\t\tm_Initialized = false;\n\t}\n\n\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// If this gameobject is parented to anything, we don't want to play water ripple effects\n\t\t// (e.g. A player inside a boat)\n\t\tif (GameObject.Parent != Scene)\n\t\t\treturn;\n\t\t\n\t\tVector3 samplePos = SamplePosition;\n\n\t\t// Velocity from position delta \u2014 no Rigidbody required\n\t\tVector3 velocity = Time.Delta > 0.0f ? (samplePos - m_LastPosition) / Time.Delta : Vector3.Zero;\n\t\tm_LastPosition = samplePos;\n\n\t\tfloat waterHeight = WaterManager.GetWaterHeightAt(samplePos);\n\n\t\t// Not over any water surface\n\t\tif (waterHeight <= float.MinValue)\n\t\t{\n\t\t\tm_WasBelowSurface = false;\n\t\t\treturn;\n\t\t}\n\n\t\tbool belowSurface = samplePos.z <= waterHeight;\n\n\t\t// Skip the first valid frame so an object spawned already in water doesn't splash\n\t\tif (!m_Initialized)\n\t\t{\n\t\t\tm_WasBelowSurface = belowSurface;\n\t\t\tm_Initialized = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Entry splash on the above -> below surface crossing\n\t\tif (EmitOnEntry && belowSurface && !m_WasBelowSurface)\n\t\t{\n\t\t\tfloat impactSpeed = float.Max(0.0f, -velocity.z);\n\n\t\t\tif (impactSpeed >= MinImpactSpeed)\n\t\t\t{\n\t\t\t\tfloat strength = (impactSpeed / 150.0f).Clamp(0.3f, 2.5f) * EntryStrength;\n\t\t\t\t\n\t\t\t\tWaterManager.AddRipple(samplePos.WithZ(waterHeight), strength, EntryWavelength, EntryRingWidth);\n\t\t\t}\n\t\t}\n\n\t\tm_WasBelowSurface = belowSurface;\n\n\t\tfloat horizontalSpeed = velocity.WithZ(0.0f).Length;\n\t\t\n\t\t// Continuous wake while skimming/swimming through the surface\n\t\tif (EmitWake && belowSurface)\n\t\t{\n\t\t\tif (horizontalSpeed >= WakeMinSpeed)\n\t\t\t{\n\t\t\t\tm_WakeTimer -= Time.Delta;\n\n\t\t\t\tif (m_WakeTimer <= 0.0f)\n\t\t\t\t{\n\t\t\t\t\tWaterManager.AddRipple(samplePos.WithZ(waterHeight), WakeStrength, WakeWavelength, WakeRingWidth);\n\t\t\t\t\tm_WakeTimer = WakeInterval;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Editor/WaterFlowTool.cs",
            "FileName": "WaterFlowTool.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\nusing Editor;\n\nnamespace RedSnail.WaterTool.Editor;\n\n/// <summary>\n/// Scene editor tool for the WaterFlow component. Activates when a WaterFlow is\n/// selected and hosts the spline editor: select points, drag them and their In/Out\n/// tangent handles (for curved rivers), click on the river to insert a point, and\n/// shift-drag a point to extrude a new one. All edits are undo-aware and rebuild\n/// the river mesh live.\n/// </summary>\n[Title(\"Water Flow\")]\n[Icon(\"waves\")]\n[Alias(\"water_flow\")]\n[Group(\"1\")]\n[Order(1)]\npublic class WaterFlowTool : EditorTool<WaterFlow>\n{\n\tprivate WaterFlowWindow m_Window;\n\tprivate WaterFlow m_Selected;\n\n\n\n\tpublic override void OnEnabled()\n\t{\n\t\tm_Window = new WaterFlowWindow();\n\n\t\tAddOverlay(m_Window, TextFlag.RightBottom, 10);\n\n\t\tOnSelectionChanged();\n\t}\n\n\n\n\tpublic override void OnDisabled()\n\t{\n\t\tm_Window?.OnDisabled();\n\t}\n\n\n\n\tpublic override void OnUpdate()\n\t{\n\t\tm_Window?.OnUpdate();\n\t}\n\n\n\n\tpublic override void OnSelectionChanged()\n\t{\n\t\tWaterFlow target = GetSelectedComponent<WaterFlow>();\n\n\t\tif (!target.IsValid())\n\t\t\treturn;\n\n\t\t// Only re-target when the component itself changes \u2014 otherwise this fires on\n\t\t// every property edit and would reset the selected point each time.\n\t\tif (target != m_Selected)\n\t\t{\n\t\t\tm_Window?.OnSelectionChanged(target);\n\n\t\t\tm_Selected = target;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Editor/WaterFlowWindow.UI.cs",
            "FileName": "WaterFlowWindow.UI.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\nusing Editor;\n\nnamespace RedSnail.WaterTool.Editor;\n\npublic partial class WaterFlowWindow\n{\n\tprivate const int HEADER_HEIGHT = 32;\n\n\n\n\tprivate void Rebuild()\n\t{\n\t\tLayout.Clear(true);\n\t\tLayout.Margin = 0;\n\n\t\tIcon = _isClosed ? \"\" : \"waves\";\n\t\tUpdateWindowTitle();\n\t\tIsGrabbable = !_isClosed;\n\n\t\tif (_isClosed)\n\t\t{\n\t\t\tBuildClosedState();\n\t\t\treturn;\n\t\t}\n\n\t\tMinimumWidth = 360;\n\t\tBuildHeader();\n\n\t\tif (_targetComponent.IsValid())\n\t\t\tBuildControlSheet();\n\n\t\tLayout.Margin = 4;\n\t}\n\n\n\n\tprivate void BuildClosedState()\n\t{\n\t\tvar closedRow = Layout.AddRow();\n\n\t\tclosedRow.Add(new IconButton(\"waves\", () => { _isClosed = false; Rebuild(); })\n\t\t{\n\t\t\tToolTip = \"Open Water Flow Spline Editor\",\n\t\t\tFixedHeight = HEADER_HEIGHT,\n\t\t\tFixedWidth = HEADER_HEIGHT,\n\t\t\tBackground = Color.Transparent\n\t\t});\n\n\t\tMinimumWidth = 0;\n\t}\n\n\n\n\tprivate void BuildHeader()\n\t{\n\t\tvar headerRow = Layout.AddRow();\n\n\t\theaderRow.AddStretchCell();\n\n\t\theaderRow.Add(new IconButton(\"info\")\n\t\t{\n\t\t\tToolTip = GetInfoTooltip(),\n\t\t\tFixedHeight = HEADER_HEIGHT,\n\t\t\tFixedWidth = HEADER_HEIGHT,\n\t\t\tBackground = Color.Transparent\n\t\t});\n\n\t\theaderRow.Add(new IconButton(\"close\", CloseWindow)\n\t\t{\n\t\t\tToolTip = \"Close Editor\",\n\t\t\tFixedHeight = HEADER_HEIGHT,\n\t\t\tFixedWidth = HEADER_HEIGHT,\n\t\t\tBackground = Color.Transparent\n\t\t});\n\t}\n\n\n\n\tprivate string GetInfoTooltip()\n\t{\n\t\treturn \"Edit the river's spline.\\n\\n\" +\n\t\t\t   \"\u2022 Click a point to select it, then drag it or its In/Out tangent handles.\\n\" +\n\t\t\t   \"\u2022 Tangent Mode controls the curve: Auto smooths, Linear makes sharp corners,\\n\" +\n\t\t\t   \"  Mirrored/Split let you shape the bend by hand.\\n\" +\n\t\t\t   \"\u2022 Click anywhere on the river to insert a point there.\\n\" +\n\t\t\t   \"\u2022 Hold Shift while dragging a point to drag out a new one.\\n\\n\" +\n\t\t\t   \"The source point is green, the mouth is red.\";\n\t}\n\n\n\n\tprivate void BuildControlSheet()\n\t{\n\t\tvar serialized = this.GetSerialized();\n\t\tvar controlSheet = new ControlSheet();\n\n\t\tcontrolSheet.AddRow(serialized.GetProperty(nameof(_selectedPointTangentMode)));\n\t\t_positionControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointPosition)));\n\t\t_inTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointIn)));\n\t\t_outTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointOut)));\n\n\t\tcontrolSheet.AddLayout(BuildControlButtons());\n\n\t\tLayout.Add(controlSheet);\n\n\t\tToggleTangentInput();\n\t}\n\n\n\n\tprivate Layout BuildControlButtons()\n\t{\n\t\tvar row = Layout.Row();\n\t\trow.Spacing = 16;\n\t\trow.Margin = 8;\n\n\t\trow.Add(CreateNavigationButton(\"skip_previous\", -1, \"Go to previous point\"));\n\t\trow.Add(CreateNavigationButton(\"skip_next\", 1, \"Go to next point\"));\n\t\trow.Add(CreateDeleteButton());\n\t\trow.Add(CreateAddButton());\n\n\t\treturn row;\n\t}\n\n\n\n\tprivate IconButton CreateNavigationButton(string _Icon, int _Direction, string _Tooltip)\n\t{\n\t\treturn new IconButton(_Icon, () =>\n\t\t{\n\t\t\tif (_Direction < 0)\n\t\t\t\tSelectedPointIndex = int.Max(0, SelectedPointIndex - 1);\n\t\t\telse\n\t\t\t\tSelectedPointIndex = int.Min(_targetComponent.Spline.PointCount - 1, SelectedPointIndex + 1);\n\n\t\t\tSelectPoint(SelectedPointIndex);\n\t\t\tFocus();\n\t\t})\n\t\t{ ToolTip = _Tooltip };\n\t}\n\n\n\n\tprivate IconButton CreateDeleteButton()\n\t{\n\t\treturn new IconButton(\"delete\", () =>\n\t\t{\n\t\t\t// The source point can't be deleted, and rivers need at least two points\n\t\t\tif (IsSourcePointSelected || _targetComponent.Spline.PointCount <= 2)\n\t\t\t\treturn;\n\n\t\t\tusing (CreateUndoScope(\"Delete Water Flow Point\"))\n\t\t\t{\n\t\t\t\t_targetComponent.Spline.RemovePoint(SelectedPointIndex);\n\t\t\t\tSelectedPointIndex = int.Max(0, SelectedPointIndex - 1);\n\t\t\t}\n\n\t\t\tUpdateWindowTitle();\n\t\t\tFocus();\n\t\t})\n\t\t{ ToolTip = \"Delete the selected point (the source point is locked; minimum 2 points)\" };\n\t}\n\n\n\n\tprivate IconButton CreateAddButton()\n\t{\n\t\treturn new IconButton(\"add\", () =>\n\t\t{\n\t\t\tusing (CreateUndoScope(\"Add Water Flow Point\"))\n\t\t\t{\n\t\t\t\tInsertNewPoint();\n\t\t\t\tSelectedPointIndex++;\n\t\t\t}\n\n\t\t\tUpdateWindowTitle();\n\t\t\tFocus();\n\t\t})\n\t\t{\n\t\t\tToolTip = \"Insert a point after the selected one.\\n\" +\n\t\t\t\t\t  \"You can also click on the river, or Shift-drag a point.\"\n\t\t};\n\t}\n\n\n\n\tprivate void InsertNewPoint()\n\t{\n\t\tvar spline = _targetComponent.Spline;\n\n\t\tif (SelectedPointIndex == spline.PointCount - 1)\n\t\t{\n\t\t\t// Extend past the mouth, following the spline tangent\n\t\t\tfloat distance = spline.GetDistanceAtPoint(SelectedPointIndex);\n\t\t\tVector3 tangent = spline.SampleAtDistance(distance).Tangent;\n\t\t\tVector3 newPosition = _selectedPoint.Position + tangent * 256.0f;\n\n\t\t\tspline.InsertPoint(SelectedPointIndex + 1, _selectedPoint with { Position = newPosition });\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Split the segment toward the next point\n\t\t\tfloat currentDist = spline.GetDistanceAtPoint(SelectedPointIndex);\n\t\t\tfloat nextDist = spline.GetDistanceAtPoint(SelectedPointIndex + 1);\n\n\t\t\tspline.AddPointAtDistance((currentDist + nextDist) / 2.0f, true);\n\t\t}\n\t}\n\n\n\n\tprivate void UpdateWindowTitle()\n\t{\n\t\tWindowTitle = _isClosed\n\t\t\t? \"\"\n\t\t\t: $\"Water Flow \u2014 Point [{SelectedPointIndex}] \u2014 {_targetComponent?.GameObject?.Name ?? \"\"}\";\n\t}\n\n\n\n\tprivate void CloseWindow()\n\t{\n\t\t_isClosed = true;\n\t\tRebuild();\n\t\tPosition = Parent.Size - 32;\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Miscellaneous/BoatController.cs",
            "FileName": "BoatController.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\nusing Sandbox;\nusing Sandbox.Movement;\n\nnamespace RedSnail.WaterTool;\n\n/// <summary>\n/// Minimal demo boat controller.\n/// </summary>\n[Title( \"Demo Boat Controller\" ), Group( \"Water\" ), Icon( \"directions_boat\" )]\npublic sealed class BoatController : Component, Component.IPressable, ISitTarget\n{\n\tprivate TimeSince m_TimeSinceLastUnderWave;\n\tprivate float m_LastHitTimer = 1.0f;\n\t\n\t[Property, Group( \"Seat\" )] public GameObject SeatPosition { get; set; }\n\t[Property, Group( \"Seat\" )] public GameObject EyePosition  { get; set; }\n\t[Property, Group( \"Seat\" )] public GameObject ExitPoint    { get; set; }\n\n\t[Property, Group( \"Movement\" )] public float ThrustForce   { get; set; } = 200_000f;\n\t[Property, Group( \"Movement\" )] public float ReverseForce  { get; set; } = 80_000f;\n\t[Property, Group( \"Movement\" )] public float TurnForce     { get; set; } = 60_000f;\n\t[Property, Group( \"Movement\" )] public float Stability     { get; set; } = 50_000f;\n\t[Property, Group( \"Movement\" )] public float TerminalSpeed { get; set; } = 800f;\n\n\t[Property, Group( \"Interaction\" )] public string TooltipTitle { get; set; } = \"Drive\";\n\t[Property, Group( \"Interaction\" )] public string TooltipIcon  { get; set; } = \"directions_boat\";\n\n\t[Property, Group( \"Sounds\" )] public SoundEvent BoatUnderWaves  { get; set; }\n\t[Property, Group( \"Sounds\" )] public SoundPointComponent BoatOnWaterLoop  { get; set; }\n\n\tprivate Rigidbody m_Rigidbody;\n\tprivate Buoyancy m_Buoyancy;\n\n\tprivate float m_TargetThrust;\n\tprivate float m_TargetTurn;\n\n\tpublic bool IsOccupied => GetComponentInChildren<PlayerController>( false ) != null;\n\n\n\n\tprotected override void OnStart()\n\t{\n\t\tm_Rigidbody = GetComponent<Rigidbody>();\n\t\tm_Buoyancy = GetComponent<Buoyancy>();\n\t}\n\n\n\n\tprotected override void OnFixedUpdate()\n\t{\n\t\tif ( !m_Rigidbody.IsValid() )\n\t\t\treturn;\n\n\t\tHandleSounds();\n\t\tStabilize();\n\n\t\tif ( IsOccupied )\n\t\t\tHandleMovement();\n\t\telse\n\t\t{\n\t\t\t// Smoothly reset forces when unmanned\n\t\t\tm_TargetThrust = 0f;\n\t\t\tm_TargetTurn   = 0f;\n\t\t}\n\t}\n\t\n\t\n\t\n\tpublic bool CanPress( IPressable.Event e )\n\t{\n\t\treturn e.Source is PlayerController && !IsOccupied;\n\t}\n\n\tpublic bool Press( IPressable.Event e )\n\t{\n\t\tif ( e.Source is not PlayerController player ) return false;\n\t\tif ( IsOccupied ) return false;\n\n\t\tMountPlayer( player );\n\t\treturn true;\n\t}\n\n\tpublic IPressable.Tooltip? GetTooltip( IPressable.Event e )\n\t{\n\t\tif ( IsOccupied ) return null;\n\t\t\n\t\tvar tooltip = new IPressable.Tooltip\n\t\t{\n\t\t\tTitle = TooltipTitle,\n\t\t\tIcon = TooltipIcon\n\t\t};\n\n\t\treturn tooltip;\n\t}\n\t\n\t\n\t\n\tpublic void AskToLeave( PlayerController player )\n\t{\n\t\tDismountPlayer( player );\n\t}\n\n\tpublic void UpdatePlayerAnimator( PlayerController controller, SkinnedModelRenderer renderer )\n\t{\n\t\tcontroller.LocalTransform = global::Transform.Zero;\n\t\trenderer.LocalRotation   = Rotation.Identity;\n\t\trenderer.Set( \"sit\",        (int)BaseChair.AnimatorSitPose.ChairForward );\n\t\trenderer.Set( \"b_grounded\", true );\n\t\trenderer.Set( \"b_climbing\", false );\n\t\trenderer.Set( \"b_swim\",     false );\n\t\trenderer.Set( \"duck\",       false );\n\t}\n\n\tpublic Transform CalculateEyeTransform( PlayerController controller )\n\t{\n\t\tvar anchor = EyePosition ?? SeatPosition ?? GameObject;\n\n\t\t// Position follows the seat anchor so the camera rides with the boat.\n\t\t// Rotation uses the player's eye angles in pure world space, the boat's\n\t\t// pitch and roll are intentionally NOT applied so the view stays level\n\t\t// even when the hull bobs or banks.\n\t\treturn new Transform\n\t\t{\n\t\t\tPosition = anchor.WorldPosition,\n\t\t\tRotation = controller.EyeAngles.ToRotation()\n\t\t};\n\t}\n\t\n\t\n\t\n\tprivate void MountPlayer( PlayerController player )\n\t{\n\t\tvar seat = SeatPosition ?? GameObject;\n\n\t\t// Disable the player's own physics so they don't fight the boat\n\t\tif ( player.Body.IsValid() )          player.Body.Enabled = false;\n\t\tif ( player.ColliderObject.IsValid() ) player.ColliderObject.Enabled = false;\n\t\t\n\t\tplayer.GameObject.SetParent( seat, false );\n\t\tplayer.GameObject.LocalTransform = global::Transform.Zero;\n\t}\n\n\tprivate void DismountPlayer( PlayerController player )\n\t{\n\t\tplayer.GameObject.SetParent( null, true );\n\t\t\n\t\tif ( player.Body.IsValid() )          player.Body.Enabled = true;\n\t\tif ( player.ColliderObject.IsValid() ) player.ColliderObject.Enabled = true;\n\t\t\n\t\t// Move to exit point, or eject to the side if none is set\n\t\tplayer.WorldPosition = ExitPoint != null\n\t\t\t? ExitPoint.WorldPosition\n\t\t\t: WorldPosition + WorldRotation.Right * 100f + Vector3.Up * 30f;\n\n\t\tm_TargetThrust = 0f;\n\t\tm_TargetTurn   = 0f;\n\t}\n\t\n\t\n\t\n\tprivate void HandleMovement()\n\t{\n\t\t// Only push when the hull is actually in the water\n\t\tif ( m_Buoyancy is { IsTouchingWater: false } )\n\t\t\treturn;\n\n\t\tfloat fwd  = Input.AnalogMove.x; // W = +1  S = -1\n\t\tfloat side = Input.AnalogMove.y; // D = +1  A = -1\n\t\t\n\t\t// Thrust\n\t\tfloat wantedThrust = fwd > 0.02f  ?  ThrustForce * fwd\n\t\t                   : fwd < -0.02f ? ReverseForce * fwd\n\t\t                   : 0f;\n\n\t\tm_TargetThrust = float.Lerp( m_TargetThrust, wantedThrust, Time.Delta * 3f );\n\n\t\tfloat speed   = m_Rigidbody.Velocity.WithZ( 0 ).Length;\n\t\tfloat limiter = MathF.Min( 1f, TerminalSpeed / ( speed + 0.001f ) );\n\n\t\tm_Rigidbody.ApplyForce( WorldRotation.Right * m_TargetThrust * limiter );\n\n\t\t// Turning\n\t\tfloat speedFactor = float.Clamp( speed / 200f, 0.2f, 1f );\n\t\tfloat wantedTurn  = side * TurnForce * speedFactor;\n\t\tm_TargetTurn       = float.Lerp( m_TargetTurn, wantedTurn, Time.Delta * 5f );\n\n\t\tVector3 bow = WorldPosition + WorldRotation.Forward * 60f;\n\t\tm_Rigidbody.ApplyForceAt( bow, WorldRotation.Left * m_TargetTurn );\n\n\t\t// Speed dependent damping so the boat decelerates naturally\n\t\tfloat damping = ( TerminalSpeed / ( speed + 0.001f ) ) * 0.5f;\n\t\tm_Rigidbody.LinearDamping = float.Clamp( damping, 0.5f, 5f );\n\t}\n\t\n\t\n\t\n\tprivate void HandleSounds()\n\t{\n\t\tif (Scene.Camera is not CameraComponent camera)\n\t\t\treturn;\n\n\t\tHandleWavesSound(camera);\n\t\tHandleMovementSound(camera);\n\t}\n\t\n\t\n\t\n\tprivate void HandleWavesSound(CameraComponent _Camera)\n\t{\n\t\tif (!BoatUnderWaves.IsValid())\n\t\t\treturn;\n\t\t\n\t\tfloat distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);\n\t\tfloat MaxDistanceSq = BoatUnderWaves.Distance * BoatUnderWaves.Distance;\n\n\t\tfloat speed = m_Rigidbody.Velocity.WithZ(0).Length;\n\t\t\n\t\tif (speed < 10.0f && distance < MaxDistanceSq && m_Buoyancy.IsTouchingWater && m_TimeSinceLastUnderWave > m_LastHitTimer)\n\t\t{\n\t\t\tSound.Play(BoatUnderWaves, WorldPosition);\n\n\t\t\tm_TimeSinceLastUnderWave = 0;\n\t\t\tm_LastHitTimer = Game.Random.Float(2.0f, 10.0f);\n\t\t}\n\t}\n\t\n\t\n\t\n\tprivate void HandleMovementSound(CameraComponent _Camera)\n\t{\n\t\tif (!BoatOnWaterLoop.IsValid())\n\t\t\treturn;\n\t\t\n\t\tfloat distance = _Camera.WorldPosition.DistanceSquared(WorldPosition);\n\t\tfloat MaxDistanceSq = BoatOnWaterLoop.Distance * BoatOnWaterLoop.Distance;\n\t\t\n\t\tif (distance > MaxDistanceSq)\n\t\t{\n\t\t\t// Disable the sound point if too far away from the camera (Avoid wasting resources)\n\t\t\tBoatOnWaterLoop.Enabled = false;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tBoatOnWaterLoop.SoundOverride = true;\n\t\t\tBoatOnWaterLoop.Volume = m_Rigidbody.Velocity.WithZ(0).Length.Remap(0.0f, 200.0f);\n\t\t\tBoatOnWaterLoop.Enabled = true;\n\t\t}\n\t}\n\t\n\t\n\t\n\tprivate void Stabilize()\n\t{\n\t\tVector3 torque = Vector3.Cross( WorldRotation.Up, Vector3.Up ) * Stability;\n\t\tm_Rigidbody.ApplyTorque( torque );\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/WaterBody.cs",
            "FileName": "WaterBody.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing Sandbox;\r\nusing Sandbox.Volumes;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n/// <summary>\r\n/// Defines a discrete body of water that participates in a renderer-driven water system.\r\n/// Provides volume bounds, a physics hull for buoyancy/swimming, and renderer inclusion in one component.\r\n/// Requires a WaterQuadRenderer present in the scene to produce a visible water surface.\r\n/// </summary>\r\n[Title(\"Water Body\")]\r\n[Category(\"Water\")]\r\n[Icon(\"water_drop\")]\r\npublic sealed class WaterBody : VolumeComponent, Component.ExecuteInEditor\r\n{\r\n\tprivate HullCollider m_HullCollider;\r\n\tprivate BBox m_LastLocalBounds;\r\n\r\n\t[Property, Group(\"General\")] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterBodiesList();\r\n\r\n\t\tUpdateColliderState();\r\n\r\n\t\tm_LastLocalBounds = SceneVolume.GetBounds();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterBodiesList();\r\n\r\n\t\tm_HullCollider?.Destroy();\r\n\t\tm_HullCollider = null;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tBBox localBounds = SceneVolume.GetBounds();\r\n\r\n\t\tif (localBounds != m_LastLocalBounds)\r\n\t\t{\r\n\t\t\tUpdateColliderState();\r\n\r\n\t\t\tm_LastLocalBounds = localBounds;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif (!Gizmo.IsSelected || !m_HullCollider.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tGizmo.Draw.Color = Color.Cyan;\r\n\t\tGizmo.Draw.LineBBox(m_HullCollider.LocalBounds);\r\n\t}\r\n\r\n\t// Bounds\r\n\tpublic void SetBounds(BBox bounds)\r\n\t{\r\n\t\tSceneVolume = SceneVolume with { Box = bounds };\r\n\t}\r\n\r\n\tpublic float GetSurfaceHeight()\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\r\n\t\treturn WorldTransform.PointToWorld(new Vector3(local.Center.x, local.Center.y, local.Maxs.z)).z;\r\n\t}\r\n\r\n\tpublic float GetBottomHeight()\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\r\n\t\treturn WorldTransform.PointToWorld(new Vector3(local.Center.x, local.Center.y, local.Mins.z)).z;\r\n\t}\r\n\r\n\tpublic bool ContainsPointXY(Vector3 worldPosition)\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\t\tVector3 point = WorldTransform.PointToLocal(worldPosition);\r\n\t\tVector3 half = local.Size * 0.5f;\r\n\r\n\t\treturn MathF.Abs(point.x - local.Center.x) <= half.x && MathF.Abs(point.y - local.Center.y) <= half.y;\r\n\t}\r\n\r\n\tpublic bool ContainsPointInVolume(Vector3 worldPosition)\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\r\n\t\tVector3 point = WorldTransform.PointToLocal(worldPosition);\r\n\t\tVector3 half = local.Size * 0.5f;\r\n\r\n\t\treturn MathF.Abs(point.x - local.Center.x) <= half.x &&\r\n\t\t\t   MathF.Abs(point.y - local.Center.y) <= half.y &&\r\n\t\t\t   MathF.Abs(point.z - local.Center.z) <= half.z;\r\n\t}\r\n\r\n\tpublic (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\r\n\t\treturn (WorldTransform.PointToWorld(local.Center), WorldRotation.Forward, WorldTransform.Up, local.Size * 0.5f);\r\n\t}\r\n\r\n\t// Wave queries\r\n\tpublic Vector3 GetWaveDisplacementAt(Vector3 _WorldPosition)\r\n\t{\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\treturn profile.IsValid() ? WaterWaveUtility.ComputeDisplacementAt(_WorldPosition, profile) : Vector3.Zero;\r\n\t}\r\n\r\n\tpublic Vector3 GetWaveVelocityAt(Vector3 _WorldPosition)\r\n\t{\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\treturn profile.IsValid() ? WaterWaveUtility.ComputeVelocityAt(_WorldPosition, profile) : Vector3.Zero;\r\n\t}\r\n\r\n\tpublic float GetWaveHeightAt(Vector3 _WorldPosition) => GetSurfaceHeight() + GetWaveDisplacementAt(_WorldPosition).z;\r\n\r\n\tinternal float GetVerticalDistanceToSurface(Vector3 _WorldPosition) => MathF.Abs(_WorldPosition.z - GetSurfaceHeight());\r\n\r\n\tprivate void UpdateColliderState()\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\r\n\t\tm_HullCollider = GetOrAddComponent<HullCollider>();\r\n\t\tm_HullCollider.Flags |= ComponentFlags.Hidden;\r\n\t\tm_HullCollider.Static = true;\r\n\t\tm_HullCollider.Type = HullCollider.PrimitiveType.Box;\r\n\t\tm_HullCollider.Center = local.Center;\r\n\t\tm_HullCollider.BoxSize = local.Size;\r\n\t\tm_HullCollider.IsTrigger = true;\r\n\r\n\t\tTags.Add(\"water\");\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "PostProcessing/SimpleFog.cs",
            "FileName": "SimpleFog.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\r\nusing Sandbox.Rendering;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Title(\"Simple Fog\")]\r\n[Category(\"Post Processing\")]\r\n[Icon(\"foggy\")]\r\npublic sealed class SimpleFog : BasePostProcess<SimpleFog>\r\n{\r\n\t[Property] private Color Color { get; set; } = Color.White;\r\n\t[Property, Range(0, 1)] private float Intensity { get; set; } = 0.01f;\r\n\t[Property, Range(0, 1)] private float Opacity { get; set; } = 0.5f;\r\n\r\n\r\n\r\n\tpublic override void Render()\r\n\t{\r\n\t\tfloat opacity = GetWeighted(x => x.Opacity);\r\n\r\n\t\tif (opacity.AlmostEqual(0.0f))\r\n\t\t\treturn;\r\n\r\n\t\tAttributes.Set(\"Color\", GetWeighted(x => x.Color));\r\n\t\tAttributes.Set(\"Intensity\", GetWeighted(x => x.Intensity));\r\n\t\tAttributes.Set(\"Opacity\", opacity);\r\n\r\n\t\tMaterial shader = Material.FromShader(\"pp_simplefog\");\r\n\t\tBlitMode blit = BlitMode.WithBackbuffer(shader, Stage.BeforePostProcess, 60);\r\n\t\tBlit(blit, \"Simple Fog\");\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterDefinition.cs",
            "FileName": "WaterDefinition.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[AssetType(Name = \"Water Definition\", Extension = \"wtdef\", Category = \"Water\")]\r\npublic sealed class WaterDefinition : GameResource\r\n{\r\n\t[Property, Group(\"Detail\")] public float WavesIntensity { get; set; } = 4.0f;\r\n\t[Property, Group(\"Detail\"), Range(0, 5)] public float WavesSpeed { get; set; } = 0.3f;\r\n\t[Property, Group(\"Detail\")] public float WavesScale { get; set; } = 0.05f;\r\n\t[Property, Group(\"Detail\")] public Vector2 WavesDirection { get; set; } = new Vector2(1, 0.5f);\r\n\t[Property, Group(\"Detail\"), Range(1, 5)] public int WavesOctaves { get; set; } = 3;\r\n\t[Property, Group(\"Detail\")] public float WavesLacunarity { get; set; } = 2.0f;\r\n\t[Property, Group(\"Detail\"), Range(0, 1)] public float WavesPersistence { get; set; } = 0.5f;\r\n\t[Property, Group(\"Detail\"), Range(0, 1)] public float WavesSteepness { get; set; } = 0.5f;\r\n\r\n\t[Property, Group(\"Swell\")] public float SwellIntensity { get; set; } = 15.0f;\r\n\t[Property, Group(\"Swell\"), Range(0, 500)] public float SwellSpeed { get; set; } = 100.0f;\r\n\t[Property, Group(\"Swell\")] public float SwellScale { get; set; } = 0.002f;\r\n\t[Property, Group(\"Swell\")] public Vector2 SwellDirection { get; set; } = new Vector2(0.7f, 0.3f);\r\n\t[Property, Group(\"Swell\"), Range(1, 4)] public int SwellOctaves { get; set; } = 2;\r\n\t[Property, Group(\"Swell\")] public float SwellLacunarity { get; set; } = 1.8f;\r\n\t[Property, Group(\"Swell\"), Range(0, 1)] public float SwellPersistence { get; set; } = 0.6f;\r\n\t[Property, Group(\"Swell\"), Range(0, 1)] public float SwellSteepness { get; set; } = 0.3f;\r\n\r\n\tpublic void ApplyTo(RenderAttributes attributes)\r\n\t{\r\n\t\tattributes.Set(\"WavesIntensity\", WavesIntensity);\r\n\t\tattributes.Set(\"WavesSpeed\", WavesSpeed);\r\n\t\tattributes.Set(\"WavesScale\", WavesScale);\r\n\t\tattributes.Set(\"WavesDirection\", WavesDirection);\r\n\t\tattributes.Set(\"WavesOctaves\", WavesOctaves);\r\n\t\tattributes.Set(\"WavesLacunarity\", WavesLacunarity);\r\n\t\tattributes.Set(\"WavesPersistence\", WavesPersistence);\r\n\t\tattributes.Set(\"WavesSteepness\", WavesSteepness);\r\n\r\n\t\tattributes.Set(\"SwellIntensity\", SwellIntensity);\r\n\t\tattributes.Set(\"SwellSpeed\", SwellSpeed);\r\n\t\tattributes.Set(\"SwellScale\", SwellScale);\r\n\t\tattributes.Set(\"SwellDirection\", SwellDirection);\r\n\t\tattributes.Set(\"SwellOctaves\", SwellOctaves);\r\n\t\tattributes.Set(\"SwellLacunarity\", SwellLacunarity);\r\n\t\tattributes.Set(\"SwellPersistence\", SwellPersistence);\r\n\t\tattributes.Set(\"SwellSteepness\", SwellSteepness);\r\n\t}\r\n\r\n\tprotected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)\r\n\t{\r\n\t\treturn CreateSimpleAssetTypeIcon(\"water\", _Width, _Height, \"#4287f5\", \"white\");\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterExclusionVolume.cs",
            "FileName": "WaterExclusionVolume.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\r\nusing Sandbox.Volumes;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n/// <summary>\r\n/// Suppresses water surface rendering inside a volume. Has no effect on the physical water hull\r\n/// so buoyancy and swimming still work within the excluded area.\r\n/// Intended for enclosed spaces that sit in water, such as the interior of a boat or submarine.\r\n/// </summary>\r\n[Title(\"Water Exclusion Volume\")]\r\n[Category(\"Volumes\")]\r\n[Icon(\"water\")]\r\npublic sealed class WaterExclusionVolume : VolumeComponent, Component.ExecuteInEditor\r\n{\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterExclusionVolumesList();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterExclusionVolumesList();\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tbase.DrawGizmos();\r\n\r\n\t\t/*\r\n\t\tSceneVolume sceneVolume = SceneVolume;\r\n\t\tGizmo.Draw.IgnoreDepth = false;\r\n\t\tGizmo.Draw.Color = Gizmo.Colors.Blue.WithAlpha(0.8f);\r\n\t\tGizmo.Draw.SolidBox(sceneVolume.Box);\r\n\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\tGizmo.Draw.Color = global::Color.White.WithAlpha(0.05f);\r\n\t\tGizmo.Draw.SolidBox(sceneVolume.Box);\r\n\t\t\r\n\t\tSceneVolume = sceneVolume;\r\n\t\t*/\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// DebugOverlay.Box(GetWorldBounds(), Color.Cyan, overlay: true);\r\n\t}\r\n\r\n\tpublic (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()\r\n\t{\r\n\t\tBBox local = SceneVolume.GetBounds();\r\n\t\tVector3 center = WorldTransform.PointToWorld(local.Center);\r\n\t\tVector3 halfExtents = local.Size * 0.5f;\r\n\r\n\t\treturn (center, WorldRotation.Forward, WorldTransform.Up, halfExtents);\r\n\t}\r\n\r\n\tpublic void SetLocalBounds(BBox localBounds)\r\n\t{\r\n\t\tvar sv = SceneVolume;\r\n\t\tsv.Box = localBounds;\r\n\t\tSceneVolume = sv;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterManager.Ripples.cs",
            "FileName": "WaterManager.Ripples.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.WaterTool;\n\npublic partial class WaterManager\n{\n\t// Interactive ripples \u2014 expanding radial wave packets stamped onto the surface\n\t// when something enters or moves on the water. Each emitter is uploaded as two\n\t// float4 rows: row0 = (Center.xy, StartTime, Strength), row1 = (Wavelength, Width, _, _).\n\t// Amplitude/Speed/Damping are global; Strength, Wavelength and Width are per-ripple.\n\t// The exact same formula runs in advancedwater.shader (VS) and in ComputeRippleHeight\n\t// (CPU) so buoyancy bobs over the visual ripples.\n\n\tprivate const int MAX_RIPPLES = 64;\n\tprivate const int RIPPLE_ROWS = 2;\n\n\t[Property(Title = \"Amplitude\"), Group(\"Ripples\")] public float RippleAmplitude { get; set; } = 8.0f;\n\t[Property(Title = \"Expansion Speed\"), Group(\"Ripples\")] public float RippleSpeed { get; set; } = 100.0f;\n\t// Default ring spacing used when a ripple is spawned without an explicit wavelength.\n\t// Smaller = tighter, more concentric rings. Larger = fewer, broader rings.\n\t[Property(Title = \"Default Wavelength\"), Group(\"Ripples\")] public float RippleWavelength { get; set; } = 120.0f;\n\t// Default ring size used when a ripple is spawned without an explicit width.\n\t// Larger = bigger, broader ripple (the wave packet spans a wider radial band).\n\t[Property(Title = \"Default Ring Width\"), Group(\"Ripples\")] public float RippleWidth { get; set; } = 50.0f;\n\t[Property(Title = \"Damping\"), Group(\"Ripples\")] public float RippleDamping { get; set; } = 1.0f;\n\t[Property(Title = \"Lifetime\"), Group(\"Ripples\")] public float RippleLifetime { get; set; } = 3.0f;\n\n\tprivate struct RippleEmitter\n\t{\n\t\tpublic Vector2 Center;\n\t\tpublic float StartTime;\n\t\tpublic float Strength;\n\t\tpublic float Wavelength;\n\t\tpublic float Width;\n\t}\n\n\tprivate readonly List<RippleEmitter> m_Ripples = [];\n\tprivate GpuBuffer<Vector4> m_RippleBuffer;\n\tprivate readonly Vector4[] m_RippleData = new Vector4[MAX_RIPPLES * RIPPLE_ROWS];\n\tprivate int m_ActiveRippleCount;\n\n\n\n\t/// <summary>\n\t/// Spawn an expanding ripple on the water surface at the given world position.\n\t/// </summary>\n\t/// <param name=\"_WorldPosition\">Where the ripple originates (only XY is used).</param>\n\t/// <param name=\"_Strength\">Scales the height of the ripple (1 = a normal splash).</param>\n\t/// <param name=\"_Wavelength\">Ring spacing \u2014 smaller = more rings. Pass &lt;= 0 to use the manager's Default Wavelength.</param>\n\t/// <param name=\"_Width\">Ring size \u2014 larger = a bigger, broader ripple. Pass &lt;= 0 to use the manager's Default Ring Width.</param>\n\tpublic static void AddRipple(Vector3 _WorldPosition, float _Strength = 1.0f, float _Wavelength = -1.0f, float _Width = -1.0f)\n\t{\n\t\tCurrent?.AddRippleInternal(_WorldPosition, _Strength, _Wavelength, _Width);\n\t}\n\n\tprivate void AddRippleInternal(Vector3 _WorldPosition, float _Strength, float _Wavelength, float _Width)\n\t{\n\t\tif (_Strength <= 0.0f)\n\t\t\treturn;\n\n\t\t// Fall back to the global defaults when no per-ripple value is given\n\t\tif (_Wavelength <= 0.0f)\n\t\t\t_Wavelength = RippleWavelength;\n\n\t\tif (_Width <= 0.0f)\n\t\t\t_Width = RippleWidth;\n\n\t\t// Drop the oldest when full so the freshest splashes always survive\n\t\tif (m_Ripples.Count >= MAX_RIPPLES)\n\t\t\tm_Ripples.RemoveAt(0);\n\n\t\tm_Ripples.Add(new RippleEmitter\n\t\t{\n\t\t\tCenter = new Vector2(_WorldPosition.x, _WorldPosition.y),\n\t\t\tStartTime = Time.Now,\n\t\t\tStrength = _Strength,\n\t\t\tWavelength = _Wavelength,\n\t\t\tWidth = _Width\n\t\t});\n\t}\n\n\n\n\tprivate void UpdateRipples()\n\t{\n\t\t// Prune expired emitters\n\t\tfor (int i = m_Ripples.Count - 1; i >= 0; i--)\n\t\t{\n\t\t\tif (Time.Now - m_Ripples[i].StartTime > RippleLifetime)\n\t\t\t\tm_Ripples.RemoveAt(i);\n\t\t}\n\n\t\tm_ActiveRippleCount = Math.Min(m_Ripples.Count, MAX_RIPPLES);\n\n\t\tfor (int i = 0; i < m_ActiveRippleCount; i++)\n\t\t{\n\t\t\tvar r = m_Ripples[i];\n\t\t\tint row = i * RIPPLE_ROWS;\n\n\t\t\tm_RippleData[row + 0] = new Vector4(r.Center.x, r.Center.y, r.StartTime, r.Strength);\n\t\t\tm_RippleData[row + 1] = new Vector4(r.Wavelength, r.Width, 0.0f, 0.0f);\n\t\t}\n\n\t\tEnsureRippleBuffer();\n\n\t\tm_RippleBuffer.SetData(m_RippleData.AsSpan(0, m_ActiveRippleCount * RIPPLE_ROWS));\n\t}\n\n\tprivate void EnsureRippleBuffer()\n\t{\n\t\tif (!m_RippleBuffer.IsValid())\n\t\t\tm_RippleBuffer = new GpuBuffer<Vector4>(MAX_RIPPLES * RIPPLE_ROWS, GpuBuffer.UsageFlags.Structured);\n\t}\n\n\n\n\tinternal void ApplyRippleAttributes(RenderAttributes _Attributes)\n\t{\n\t\t_Attributes.Set(\"RippleCount\", m_ActiveRippleCount);\n\t\t_Attributes.Set(\"RippleAmplitude\", RippleAmplitude);\n\t\t_Attributes.Set(\"RippleSpeed\", RippleSpeed);\n\t\t_Attributes.Set(\"RippleDamping\", RippleDamping);\n\n\t\tif (m_RippleBuffer.IsValid())\n\t\t\t_Attributes.Set(\"RippleData\", m_RippleBuffer);\n\t}\n\n\n\n\t/// <summary>\n\t/// CPU evaluation of the ripple vertical displacement at a world XY position.\n\t/// MUST mirror ComputeRipples() in advancedwater.shader so physics matches visuals.\n\t/// </summary>\n\tpublic float ComputeRippleHeight(Vector2 _WorldXY)\n\t{\n\t\tif (m_Ripples.Count == 0)\n\t\t\treturn 0.0f;\n\n\t\tfloat z = 0.0f;\n\n\t\tfor (int i = 0; i < m_Ripples.Count; i++)\n\t\t{\n\t\t\tvar r = m_Ripples[i];\n\n\t\t\tfloat age = Time.Now - r.StartTime;\n\t\t\tif (age < 0.0f || age > RippleLifetime)\n\t\t\t\tcontinue;\n\n\t\t\tfloat freq = r.Wavelength > 0.001f ? (MathF.PI * 2.0f / r.Wavelength) : 0.0f;\n\t\t\tfloat invWidthSq = r.Width > 0.001f ? 1.0f / (r.Width * r.Width) : 0.0f;\n\n\t\t\tfloat d = (_WorldXY - r.Center).Length;\n\t\t\tfloat ring = age * RippleSpeed;\n\t\t\tfloat ringDelta = d - ring;\n\n\t\t\tfloat spatialEnv = MathF.Exp(-ringDelta * ringDelta * invWidthSq);\n\t\t\tfloat timeEnv = MathF.Exp(-age * RippleDamping);\n\t\t\tfloat wave = MathF.Sin(ringDelta * freq);\n\n\t\t\tz += wave * spatialEnv * timeEnv * RippleAmplitude * r.Strength;\n\t\t}\n\n\t\treturn z;\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/WaterBodyRenderer.cs",
            "FileName": "WaterBodyRenderer.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\nusing Sandbox.Rendering;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Icon(\"water\"), Group(\"Environment\"), Title(\"Water Body Renderer\")]\r\npublic sealed class WaterBodyRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\r\n{\r\n#pragma warning disable CS0649\r\n\r\n\tprivate struct WaterVertex\r\n\t{\r\n\t\t[VertexLayout.Position] public Vector3 Position;\r\n\t\t[VertexLayout.Normal] public Vector3 Normal;\r\n\t\t[VertexLayout.Tangent] public Vector4 Tangent;\r\n\t\t[VertexLayout.TexCoord] public Vector2 TexCoord;\r\n\t\t[VertexLayout.Color] public Color Color;\r\n\t}\r\n\r\n#pragma warning restore CS0649\r\n\r\n\tprivate const float BASE_TILE_SIZE = 100.0f;\r\n\r\n\tprivate const int MAX_RINGS = 8;\r\n\r\n\tprivate const int MAX_WATER_INCLUSION_VOLUMES = 1024;\r\n\tprivate const int WATER_INCLUSION_VOLUME_ROWS = 3;\r\n\r\n\tprivate const int MAX_WATER_EXCLUSION_VOLUMES = 512;\r\n\tprivate const int WATER_EXCLUSION_VOLUME_ROWS = 3;\r\n\r\n\tprivate const int MAX_HULL_EXCLUSION_VOLUMES = 8;\r\n\tprivate const int HULL_EXCLUSION_META_ROWS = 6;\r\n\tprivate const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;\r\n\tprivate const int MAX_HULL_EXCLUSION_TRIS = 16384;\r\n\r\n\tprivate GpuBuffer<WaterVertex> m_VertexBuffer;\r\n\tprivate GpuBuffer<uint> m_IndexBuffer;\r\n\tprivate GpuBuffer<Vector4> m_WaterInclusionVolumeBuffer;\r\n\tprivate GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;\r\n\tprivate int m_TotalIndexCount;\r\n\tprivate readonly RenderAttributes m_DrawAttributes = new();\r\n\tprivate int m_LastConfigHash;\r\n\tprivate readonly Vector4[] m_WaterInclusionVolumeData = new Vector4[MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS];\r\n\tprivate readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];\r\n\tprivate GpuBuffer<Vector4> m_HullExclusionBuffer;\r\n\tprivate readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];\r\n\r\n\t[Property, Group(\"General\"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;\r\n\t[Property, Group(\"General\"), Order(0)] public Material Material { get; set; }\r\n\t[Property, Group(\"General\"), Order(0)] public float Width { get; set; } = 10000.0f;\r\n\t[Property, Group(\"General\"), Order(0)] public float Length { get; set; } = 10000.0f;\r\n\t[Property, Group(\"General\"), Order(0)] public float Depth { get; set; } = 300.0f;\r\n\t[Property(Title = \"Infinite Rendering\"), Group(\"General\"), Order(0)] public bool UseHybridInclusionBounds { get; set; } = true;\r\n\t[Property, Group(\"Clipmap\"), Order(1)] public float BaseCellSize { get; set; } = 8.0f;\r\n\t[Property, Group(\"Clipmap\"), Order(1), Range(16, 512)] public int CellsPerRing { get; set; } = 64;\r\n\t[Property(Title = \"Use Camera For Clipmap\"), Group(\"Clipmap\"), Order(1)] public bool FollowCameraForClipmap { get; set; } = true;\r\n\t[Property, Group(\"Texture\"), Order(2), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;\r\n\r\n\tprivate int VerticesPerRing => (CellsPerRing + 1) * (CellsPerRing + 1);\r\n\tprivate float OuterExtent => CellsPerRing * BaseCellSize * (1 << (ComputeRingCount() - 1));\r\n\r\n\tinternal bool ParticipatesInRendering => Active && Material.IsValid();\r\n\tinternal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tif (!ParticipatesInRendering)\r\n\t\t\treturn;\r\n\r\n\t\tCreateBuffers();\r\n\r\n\t\tm_LastConfigHash = ComputeConfigHash();\r\n\r\n\t\tWaterManager.Current?.RefreshWaterBodyRenderersList();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterBodyRenderersList();\r\n\r\n\t\tm_VertexBuffer = default;\r\n\t\tm_IndexBuffer = default;\r\n\t\tm_WaterInclusionVolumeBuffer?.Dispose();\r\n\t\tm_WaterInclusionVolumeBuffer = null;\r\n\t\tm_WaterExclusionVolumeBuffer?.Dispose();\r\n\t\tm_WaterExclusionVolumeBuffer = null;\r\n\t\tm_HullExclusionBuffer?.Dispose();\r\n\t\tm_HullExclusionBuffer = null;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif (!ParticipatesInRendering)\r\n\t\t\treturn;\r\n\r\n\t\tint configHash = ComputeConfigHash();\r\n\t\tif (!HasValidBuffers || configHash != m_LastConfigHash)\r\n\t\t{\r\n\t\t\tCreateBuffers();\r\n\t\t\tm_LastConfigHash = configHash;\r\n\t\t}\r\n\r\n\t\tUpdateShaderAttributes();\r\n\t}\r\n\r\n\tinternal BBox GetWorldBounds2D()\r\n\t{\r\n\t\tVector3 right = WorldRotation.Right * (Length / 2.0f);\r\n\t\tVector3 forward = WorldRotation.Forward * (Width / 2.0f);\r\n\r\n\t\tVector3 c0 = WorldPosition + right + forward;\r\n\t\tVector3 c1 = WorldPosition - right + forward;\r\n\t\tVector3 c2 = WorldPosition + right - forward;\r\n\t\tVector3 c3 = WorldPosition - right - forward;\r\n\r\n\t\tfloat minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));\r\n\t\tfloat maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));\r\n\t\tfloat minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));\r\n\t\tfloat maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));\r\n\r\n\t\treturn new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));\r\n\t}\r\n\r\n\t// Records the clipmap compute dispatches into the command list as DEFERRED commands.\r\n\t// They run later, on the render thread, when the camera executes the list - so the\r\n\t// per-ring attributes are set through the command list (which writes Graphics.Attributes\r\n\t// at execute time, exactly what CommandList.DispatchCompute reads) rather than on the\r\n\t// shared shader instance.\r\n\tinternal void RecordCompute(CommandList commandList, ComputeShader shader, Vector3 cameraPosition)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tvar localBounds = GetWorldBounds2D();\r\n\r\n\t\tfor (int ring = 0; ring < ringCount; ring++)\r\n\t\t{\r\n\t\t\tfloat cellSize = BaseCellSize * (1 << ring);\r\n\t\t\tVector3 clipmapAnchor = FollowCameraForClipmap ? cameraPosition : WorldPosition;\r\n\t\t\tfloat snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;\r\n\t\t\tfloat snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;\r\n\r\n\t\t\tcommandList.Attributes.Set(\"VertexBuffer\", m_VertexBuffer);\r\n\t\t\tcommandList.Attributes.Set(\"VertexOffset\", ring * verticesPerRing);\r\n\t\t\tcommandList.Attributes.Set(\"GridWidth\", CellsPerRing);\r\n\t\t\tcommandList.Attributes.Set(\"CellSize\", cellSize);\r\n\t\t\tcommandList.Attributes.Set(\"SnapPosition\", new Vector2(snapX, snapY));\r\n\t\t\tcommandList.Attributes.Set(\"WaterZ\", WorldPosition.z);\r\n\t\t\tcommandList.Attributes.Set(\"TilingScale\", 1.0f / OuterExtent);\r\n\t\t\tcommandList.Attributes.Set(\"ClampToBounds\", false);\r\n\t\t\tcommandList.Attributes.Set(\"BoundsMin\", new Vector2(localBounds.Mins.x, localBounds.Mins.y));\r\n\t\t\tcommandList.Attributes.Set(\"BoundsMax\", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));\r\n\t\t\tcommandList.DispatchCompute(shader, verticesPerRing, 1, 1);\r\n\t\t}\r\n\t}\r\n\r\n\tinternal void BarrierTransition(CommandList _CommandList)\r\n\t{\r\n\t\tif (m_VertexBuffer.IsValid())\r\n\t\t\t_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);\r\n\t}\r\n\r\n\tinternal void Draw(CommandList _CommandList)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\t\t\r\n\t\t_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);\r\n\t}\r\n\r\n\tprivate void UpdateShaderAttributes()\r\n\t{\r\n\t\tBBox localBounds = GetWorldBounds2D();\r\n\r\n\t\tm_DrawAttributes.Set(\"RequireWaterInclusionVolumes\", UseHybridInclusionBounds);\r\n\t\tm_DrawAttributes.Set(\"UseHybridInclusionBounds\", UseHybridInclusionBounds);\r\n\t\tm_DrawAttributes.Set(\"HybridInclusionBoundsMin\", new Vector2(localBounds.Mins.x, localBounds.Mins.y));\r\n\t\tm_DrawAttributes.Set(\"HybridInclusionBoundsMax\", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));\r\n\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\tif (profile.IsValid())\r\n\t\t\tprofile.ApplyTo(m_DrawAttributes);\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterTime\", Time.Now);\r\n\t\tm_DrawAttributes.Set(\"DepthMax\", Depth);\r\n\r\n\t\tfloat tilingScalar = (OuterExtent / BASE_TILE_SIZE) * TextureTilingMultiplier;\r\n\t\tm_DrawAttributes.Set(\"NormalTiling\", new Vector2(tilingScalar, tilingScalar));\r\n\r\n\t\tWaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);\r\n\t\tWaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);\r\n\t\t\r\n\t\t// Band-limit the wave normal to the local clipmap vertex spacing (see shader)\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsScale\", 3.0f / CellsPerRing);\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsMin\", BaseCellSize);\r\n\r\n\t\tvar viewPosition = WaterManager.GetViewPosition(Scene, WorldPosition);\r\n\r\n\t\tSetWaterInclusionVolumes(viewPosition);\r\n\t\tSetWaterExclusionVolumes(viewPosition);\r\n\t\tSetHullExclusionVolumes();\r\n\t}\r\n\r\n\tprivate void SetWaterInclusionVolumes(Vector3 referencePosition)\r\n\t{\r\n\t\tEnsureWaterInclusionVolumeBuffer();\r\n\r\n\t\tvar volumes = WaterManager.Current.Bodies\r\n\t\t\t.Where(v => v.IsValid() && v.Active && v.WaterType == WaterType)\r\n\t\t\t.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))\r\n\t\t\t.Take(MAX_WATER_INCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tfor (int i = 0; i < volumes.Count; i++)\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = volumes[i].GetWorldOBB();\r\n\r\n\t\t\tint rowOffset = i * WATER_INCLUSION_VOLUME_ROWS;\r\n\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);\r\n\t\t}\r\n\r\n\t\tm_WaterInclusionVolumeBuffer.SetData(m_WaterInclusionVolumeData.AsSpan(0, volumes.Count * WATER_INCLUSION_VOLUME_ROWS));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterInclusionVolumeCount\", volumes.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterInclusionVolumeRows\", m_WaterInclusionVolumeBuffer);\r\n\t}\r\n\r\n\tprivate void SetWaterExclusionVolumes(Vector3 referencePosition)\r\n\t{\r\n\t\tEnsureWaterExclusionVolumeBuffer();\r\n\r\n\t\tvar volumes = WaterManager.Current.ExclusionVolumes\r\n\t\t\t.Where(v => v.IsValid() && v.Enabled && v.Active)\r\n\t\t\t.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))\r\n\t\t\t.Take(MAX_WATER_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tfor (int i = 0; i < volumes.Count; i++)\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = volumes[i].GetWorldOBB();\r\n\r\n\t\t\tint rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;\r\n\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);\r\n\t\t}\r\n\r\n\t\tm_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeCount\", volumes.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeRows\", m_WaterExclusionVolumeBuffer);\r\n\t}\r\n\r\n\tprivate void EnsureWaterExclusionVolumeBuffer()\r\n\t{\r\n\t\tif (m_WaterExclusionVolumeBuffer.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tm_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void SetHullExclusionVolumes()\r\n\t{\r\n\t\tif (WaterManager.Current == null)\r\n\t\t\treturn;\r\n\r\n\t\tvar hulls = WaterManager.Current.HullExclusionVolumes\r\n\t\t\t.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)\r\n\t\t\t.Take(MAX_HULL_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tif (hulls.Count == 0)\r\n\t\t{\r\n\t\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", 0);\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureHullExclusionBuffers();\r\n\r\n\t\tint triWriteCursor = HULL_EXCLUSION_META_SIZE;\r\n\r\n\t\tfor (int h = 0; h < hulls.Count; h++)\r\n\t\t{\r\n\t\t\tvar hull = hulls[h];\r\n\t\t\tvar tris = hull.LocalTriangles;\r\n\t\t\tint triCount = tris.Length / 3;\r\n\r\n\t\t\tif (triWriteCursor + tris.Length > m_HullExclusionData.Length)\r\n\t\t\t\tbreak;\r\n\r\n\t\t\thull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);\r\n\r\n\t\t\tint meta = h * HULL_EXCLUSION_META_ROWS;\r\n\t\t\tm_HullExclusionData[meta + 0] = r0;\r\n\t\t\tm_HullExclusionData[meta + 1] = r1;\r\n\t\t\tm_HullExclusionData[meta + 2] = r2;\r\n\t\t\tm_HullExclusionData[meta + 3] = r3;\r\n\r\n\t\t\tvar aabb = hull.LocalAABB;\r\n\t\t\tm_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);\r\n\t\t\tm_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);\r\n\r\n\t\t\tfor (int i = 0; i < tris.Length; i++)\r\n\t\t\t\tm_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);\r\n\r\n\t\t\ttriWriteCursor += tris.Length;\r\n\t\t}\r\n\r\n\t\tm_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", hulls.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionData\", m_HullExclusionBuffer);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureHullExclusionBuffers()\r\n\t{\r\n\t\tif (!m_HullExclusionBuffer.IsValid())\r\n\t\t\tm_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureWaterInclusionVolumeBuffer()\r\n\t{\r\n\t\tif (m_WaterInclusionVolumeBuffer.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tm_WaterInclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\tprivate int ComputeConfigHash()\r\n\t{\r\n\t\treturn HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing);\r\n\t}\r\n\r\n\tprivate int ComputeRingCount()\r\n\t{\r\n\t\treturn ComputeRingCount(Width, Length);\r\n\t}\r\n\r\n\tprivate int ComputeRingCount(float width, float length)\r\n\t{\r\n\t\tfloat maxDim = MathF.Max(length, width);\r\n\t\tfloat innerExtent = CellsPerRing * BaseCellSize;\r\n\t\tfloat requiredExtent = maxDim * 2.0f;\r\n\r\n\t\tif (requiredExtent <= innerExtent)\r\n\t\t\treturn 1;\r\n\r\n\t\tint rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;\r\n\t\treturn Math.Clamp(rings, 1, MAX_RINGS);\r\n\t}\r\n\r\n\tprivate void CreateBuffers()\r\n\t{\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint n = CellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\t\tint innerBlockSize = innerEnd - innerStart;\r\n\t\tint filledCells = n * n;\r\n\t\tint hollowCells = filledCells - (innerBlockSize * innerBlockSize);\r\n\t\tint totalIndices = filledCells * 6;\r\n\t\ttotalIndices += (ringCount - 1) * hollowCells * 6;\r\n\r\n\t\tm_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);\r\n\t\tm_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);\r\n\t\tUploadIndexBuffer(ringCount);\r\n\t}\r\n\r\n\tprivate void UploadIndexBuffer(int ringCount)\r\n\t{\r\n\t\tint n = CellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\r\n\t\tvar indices = new List<uint>();\r\n\r\n\t\tfor (int ring = 0; ring < ringCount; ring++)\r\n\t\t{\r\n\t\t\tuint baseVertex = (uint)(ring * verticesPerRing);\r\n\r\n\t\t\tfor (int y = 0; y < n; y++)\r\n\t\t\t{\r\n\t\t\t\tfor (int x = 0; x < n; x++)\r\n\t\t\t\t{\r\n\t\t\t\t\tif (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tuint i0 = baseVertex + (uint)(y * (n + 1) + x);\r\n\t\t\t\t\tuint i1 = i0 + 1;\r\n\t\t\t\t\tuint i2 = i0 + (uint)(n + 1);\r\n\t\t\t\t\tuint i3 = i2 + 1;\r\n\r\n\t\t\t\t\tindices.Add(i0);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i3);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tm_IndexBuffer.SetData(indices);\r\n\t\tm_TotalIndexCount = indices.Count;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/WaterQuadBaker.cs",
            "FileName": "WaterQuadBaker.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\nusing Sandbox.Audio;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Icon(\"water_drop\"), Group(\"Water\"), Title(\"Water Quad Baker\")]\r\npublic sealed class WaterQuadBaker : Component, Component.ExecuteInEditor\r\n{\r\n\tprivate const string BakedContainerName = \"Water Volumes\";\r\n\tprivate const string BakedTag = \"water_quad_bake\";\r\n\r\n\tprivate readonly List<Terrain> _terrains = new();\r\n\tprivate readonly HashSet<Collider> _solidColliders = new();\r\n\tprivate float _insideTraceDistance;\r\n\tprivate int _physicsCreatedCount;\r\n\tprivate int _skippedInsideCount;\r\n\tprivate int _subdividedCount;\r\n\r\n\t[Property, Group(\"Water\"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;\r\n\r\n\t[Property, Group(\"Bake Bounds\")] public Vector2 BakeSizeXY { get; set; } = new(10000.0f, 10000.0f);\r\n\t[Property, Group(\"Bake Bounds\")] public float WaterSurfaceZ { get; set; } = 0.0f;\r\n\t[Property, Group(\"Bake Bounds\")] public float WaterDepth { get; set; } = 1000.0f;\r\n\r\n\t[Property, Group(\"Strict Pass\"), Range(256.0f, 8192.0f), Order(2)] public float MinCellSize { get; set; } = 4096.0f;\r\n\t[Property, Group(\"Strict Pass\"), Range(1, 12)] public int MaxDepth { get; set; } = 6;\r\n\t[Property, Group(\"Strict Pass\"), Range(0.0f, 64.0f)] public float QuadInset { get; set; } = 0.0f;\r\n\t[Property, Group(\"Strict Pass\"), Range(1.0f, 128.0f)] public float SolidProbeRadius { get; set; } = 8.0f;\r\n\t[Property, Group(\"Strict Pass\"), Range(0.0f, 256.0f)] public float TerrainPadding { get; set; } = 16.0f;\r\n\t[Property, Group(\"Strict Pass\")] public bool IgnoreTerrainBelowWaterSurface { get; set; } = true;\r\n\t[Property, Group(\"Strict Pass\"), Range(0.0f, 5000.0f)] public float TerrainDepthIgnoreDistance { get; set; } = 512.0f;\r\n\r\n\t[Property, Group(\"Coastal Fill\"), Order(3)] public bool EnableCoastalFill { get; set; } = true;\r\n\t[Property, Group(\"Coastal Fill\"), Range(256.0f, 8192.0f)] public float CoastalFillMaxCellSize { get; set; } = 4096.0f;\r\n\t[Property, Group(\"Coastal Fill\"), Range(0.0f, 5000.0f)] public float CoastalFillPenetrationDistance { get; set; } = 192.0f;\r\n\t[Property, Group(\"Coastal Fill\"), Range(0.1f, 1.0f)] public float CoastalFillInlandThreshold { get; set; } = 1.0f;\r\n\r\n\t[Property, ToggleGroup(\"Soundscape\"), Order(4)] public bool Soundscape { get; set; } = false;\r\n\t[Property, Group(\"Soundscape\"), Range(0.0f, 1000.0f)] public float SoundscapeExtraHeight { get; set; } = 250.0f;\r\n\t[Property, Group(\"Soundscape\")] public Soundscape SoundscapeAsset { get; set; }\r\n\t[Property, Group(\"Soundscape\")] public MixerHandle SoundscapeTargetMixer { get; set; }\r\n\t[Property, Group(\"Soundscape\")] public bool SoundscapeStayActiveOnExit { get; set; } = true;\r\n\t[Property, Group(\"Soundscape\"), Range(0.0f, 2.0f)] public float SoundscapeVolume { get; set; } = 1.0f;\r\n\t\r\n\t[Property, Group(\"Miscellaneous\")] public bool ExcludeMeshGeometry { get; set; } = false;\r\n\r\n\r\n\r\n\t[Button]\r\n\tprivate async Task Bake()\r\n\t{\r\n\t\tCacheSceneGeometry();\r\n\t\tClearBaked();\r\n\r\n\t\t_physicsCreatedCount = 0;\r\n\t\t_skippedInsideCount = 0;\r\n\t\t_subdividedCount = 0;\r\n\r\n\t\t// Traverse the octree synchronously to collect candidate boxes.\r\n\t\tvar pending = new List<BBox>();\r\n\r\n\t\tCollectPhysicsNodes(GetLocalBakeBox(), 0, pending);\r\n\r\n\t\t// Create volumes with an editor progress bar.\r\n\t\tvar container = GetOrCreateBakedContainer();\r\n\r\n\t\tawait Application.Editor.ForEachAsync(pending, \"Baking Water Volumes\", async (box, ct) =>\r\n\t\t{\r\n\t\t\tif (CreateWaterBody(container, box))\r\n\t\t\t\t_physicsCreatedCount++;\r\n\r\n\t\t\tawait Task.Delay(1, ct);\r\n\t\t});\r\n\r\n\t\tLog.Info($\"{nameof(WaterQuadBaker)}: baked {_physicsCreatedCount} water volume set(s), skipped {_skippedInsideCount} node(s), subdivided {_subdividedCount} node(s).\");\r\n\t}\r\n\r\n\r\n\r\n\t[Button]\r\n\tprivate void ClearBaked()\r\n\t{\r\n\t\tFindBakedContainer()?.Destroy();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif (!Gizmo.IsSelected)\r\n\t\t\treturn;\r\n\r\n\t\tGizmo.Draw.Color = Color.Green;\r\n\t\tGizmo.Draw.LineBBox(GetLocalBakeBox());\r\n\r\n\t\tGizmo.Draw.Color = Color.Blue;\r\n\r\n\t\tforeach (var waterBody in GetComponentsInChildren<WaterBody>())\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = waterBody.GetWorldOBB();\r\n\r\n\t\t\tGizmo.Draw.LineBBox(BBox.FromPositionAndSize(center, half * 2));\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CacheSceneGeometry()\r\n\t{\r\n\t\t_terrains.Clear();\r\n\t\t_solidColliders.Clear();\r\n\r\n\t\tforeach (var terrain in Scene.GetAllComponents<Terrain>())\r\n\t\t{\r\n\t\t\tif (!terrain.IsValid() || !terrain.Enabled || !terrain.Active || !terrain.EnableCollision || terrain.Storage is null)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t_terrains.Add(terrain);\r\n\t\t\t_solidColliders.Add(terrain);\r\n\t\t}\r\n\r\n\t\tforeach (var collider in Scene.GetAllComponents<Collider>())\r\n\t\t{\r\n\t\t\tif (!collider.IsValid() || !collider.Enabled || !collider.Active || collider.IsTrigger)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif (collider.GameObject.Tags.Has(BakedTag))\r\n\t\t\t\tcontinue;\r\n\t\t\t\r\n\t\t\tif (ExcludeMeshGeometry && collider is not Terrain)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t_solidColliders.Add(collider);\r\n\t\t}\r\n\r\n\t\t_insideTraceDistance = Math.Max(BakeSizeXY.Length * 2.0f, 10000.0f);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CollectPhysicsNodes(BBox _LocalBox, int _Depth, List<BBox> _Pending)\r\n\t{\r\n\t\tvar sample = ClassifyNode(_LocalBox);\r\n\r\n\t\tbool terrainRejected = sample.TerrainAllInside || (sample.TerrainMixed && !sample.MeshHasAny);\r\n\t\tbool meshRejected = sample.MeshAllInside;\r\n\t\tbool overlapsNonTerrainSolid = BoxOverlapsNonTerrainSolid(_LocalBox);\r\n\r\n\t\tif (meshRejected)\r\n\t\t{\r\n\t\t\t_skippedInsideCount++;\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif (terrainRejected)\r\n\t\t{\r\n\t\t\tif (TryHandleCoastalNode(_LocalBox, _Depth, _Pending, sample))\r\n\t\t\t\treturn;\r\n\r\n\t\t\t_skippedInsideCount++;\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tbool shouldSubdivide = sample.MeshMixed || sample.TerrainMixed || overlapsNonTerrainSolid;\r\n\r\n\t\tif (shouldSubdivide && CanSubdivide(_LocalBox, _Depth))\r\n\t\t{\r\n\t\t\t_subdividedCount++;\r\n\r\n\t\t\tforeach (var child in Subdivide(_LocalBox))\r\n\t\t\t\tCollectPhysicsNodes(child, _Depth + 1, _Pending);\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif (shouldSubdivide)\r\n\t\t{\r\n\t\t\t_skippedInsideCount++;\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_Pending.Add(_LocalBox);\r\n\t}\r\n\r\n\r\n\r\n\tprivate SampleSummary ClassifyNode(BBox _LocalBox)\r\n\t{\r\n\t\tint total = 0;\r\n\t\tint terrainInside = 0;\r\n\t\tint meshInside = 0;\r\n\r\n\t\tforeach (var localPoint in EnumerateSamplePoints(_LocalBox))\r\n\t\t{\r\n\t\t\ttotal++;\r\n\r\n\t\t\tvar worldPoint = WorldTransform.PointToWorld(localPoint);\r\n\r\n\t\t\tif (IsPointInsideTerrainOnly(worldPoint))\r\n\t\t\t\tterrainInside++;\r\n\r\n\t\t\tif (IsPointInsideSolidMeshOnly(worldPoint))\r\n\t\t\t\tmeshInside++;\r\n\t\t}\r\n\r\n\t\treturn new SampleSummary\r\n\t\t{\r\n\t\t\tTotal = total,\r\n\t\t\tTerrainInside = terrainInside,\r\n\t\t\tMeshInside = meshInside\r\n\t\t};\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool TryHandleCoastalNode(BBox _LocalBox, int _Depth, List<BBox> _Pending, SampleSummary _Sample)\r\n\t{\r\n\t\tif (!EnableCoastalFill || _Sample.MeshHasAny)\r\n\t\t\treturn false;\r\n\r\n\t\tfloat maxSize = Math.Max(_LocalBox.Size.x, _LocalBox.Size.y);\r\n\r\n\t\tif (maxSize > CoastalFillMaxCellSize)\r\n\t\t{\r\n\t\t\t_subdividedCount++;\r\n\r\n\t\t\tforeach (var child in Subdivide(_LocalBox))\r\n\t\t\t\tCollectPhysicsNodes(child, _Depth + 1, _Pending);\r\n\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tif (IsCellTooFarInland(_LocalBox))\r\n\t\t\treturn false;\r\n\r\n\t\t_Pending.Add(_LocalBox);\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsCellTooFarInland(BBox _LocalBox)\r\n\t{\r\n\t\tint inlandCount = 0;\r\n\t\tint total = 0;\r\n\r\n\t\tforeach (var localPoint in EnumerateXYSamplePoints(_LocalBox))\r\n\t\t{\r\n\t\t\ttotal++;\r\n\r\n\t\t\tvar worldPoint = WorldTransform.PointToWorld(localPoint);\r\n\r\n\t\t\tif (IsInlandAtXY(worldPoint))\r\n\t\t\t\tinlandCount++;\r\n\t\t}\r\n\r\n\t\treturn total > 0 && ((float)inlandCount / total) >= CoastalFillInlandThreshold;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsInlandAtXY(Vector3 _WorldPoint)\r\n\t{\r\n\t\tif (!IsLandAtXY(_WorldPoint))\r\n\t\t\treturn false;\r\n\r\n\t\tif (CoastalFillPenetrationDistance <= 0.0f)\r\n\t\t\treturn true;\r\n\r\n\t\tVector3[] offsets =\r\n\t\t[\r\n\t\t\tVector3.Right * CoastalFillPenetrationDistance,\r\n\t\t\tVector3.Left * CoastalFillPenetrationDistance,\r\n\t\t\tVector3.Forward * CoastalFillPenetrationDistance,\r\n\t\t\tVector3.Backward * CoastalFillPenetrationDistance\r\n\t\t];\r\n\r\n\t\tforeach (var offset in offsets)\r\n\t\t{\r\n\t\t\tif (!IsLandAtXY(_WorldPoint + offset))\r\n\t\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsLandAtXY(Vector3 _WorldPoint)\r\n\t{\r\n\t\tforeach (var terrain in _terrains)\r\n\t\t{\r\n\t\t\tif (TryGetTerrainSurfaceWorldHeight(terrain, _WorldPoint, out var worldHeight) && IsTerrainHeightBlocking(worldHeight))\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsPointInsideTerrainOnly(Vector3 _WorldPoint)\r\n\t{\r\n\t\tforeach (var terrain in _terrains)\r\n\t\t{\r\n\t\t\tif (TryGetTerrainSurfaceWorldHeight(terrain, _WorldPoint, out var worldHeight) && IsTerrainHeightBlocking(worldHeight))\r\n\t\t\t{\r\n\t\t\t\tif (_WorldPoint.z <= worldHeight + TerrainPadding)\r\n\t\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsTerrainHeightBlocking(float _SampledWorldHeight)\r\n\t{\r\n\t\tif (IgnoreTerrainBelowWaterSurface && _SampledWorldHeight <= WaterSurfaceZ - TerrainDepthIgnoreDistance)\r\n\t\t\treturn false;\r\n\r\n\t\treturn _SampledWorldHeight >= WaterSurfaceZ + TerrainPadding;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool IsPointInsideSolidMeshOnly(Vector3 _WorldPoint)\r\n\t{\r\n\t\tif (ExcludeMeshGeometry)\r\n\t\t\treturn false;\r\n\t\t\r\n\t\tvar probe = Scene.Trace\r\n\t\t\t.Sphere(SolidProbeRadius, _WorldPoint, _WorldPoint)\r\n\t\t\t.WithoutTags(BakedTag)\r\n\t\t\t.Run();\r\n\r\n\t\tif (probe.StartedSolid && probe.Collider is not Terrain)\r\n\t\t\treturn true;\r\n\r\n\t\tint oddAxes = 0;\r\n\r\n\t\tif (HasOddHitCount(_WorldPoint, Vector3.Right)) oddAxes++;\r\n\t\tif (HasOddHitCount(_WorldPoint, Vector3.Forward)) oddAxes++;\r\n\t\tif (HasOddHitCount(_WorldPoint, Vector3.Up)) oddAxes++;\r\n\r\n\t\treturn oddAxes >= 2;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool HasOddHitCount(Vector3 _Start, Vector3 _Direction)\r\n\t{\r\n\t\tif (ExcludeMeshGeometry)\r\n\t\t\treturn false;\r\n\t\t\r\n\t\tvar end = _Start + _Direction.Normal * _insideTraceDistance;\r\n\r\n\t\tvar hits = Scene.Trace\r\n\t\t\t.Ray(_Start, end)\r\n\t\t\t.WithoutTags(BakedTag)\r\n\t\t\t.RunAll();\r\n\r\n\t\tint hitCount = 0;\r\n\t\tCollider lastCollider = null;\r\n\t\tfloat lastFraction = -10.0f;\r\n\r\n\t\tforeach (var hit in hits)\r\n\t\t{\r\n\t\t\tif (!hit.Hit || hit.Collider is null)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif (!_solidColliders.Contains(hit.Collider) || hit.Collider is Terrain)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif (hit.Collider == lastCollider && Math.Abs(hit.Fraction - lastFraction) < 0.0001f)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tlastCollider = hit.Collider;\r\n\t\t\tlastFraction = hit.Fraction;\r\n\t\t\thitCount++;\r\n\t\t}\r\n\r\n\t\treturn (hitCount & 1) == 1;\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool BoxOverlapsNonTerrainSolid(BBox _LocalBox)\r\n\t{\r\n\t\tif (ExcludeMeshGeometry)\r\n\t\t\treturn false;\r\n\t\t\r\n\t\tvar center = WorldTransform.PointToWorld(_LocalBox.Center);\r\n\r\n\t\tvar hits = Scene.Trace\r\n\t\t\t.Box(_LocalBox.Size, center, center)\r\n\t\t\t.Rotated(WorldRotation)\r\n\t\t\t.WithoutTags(BakedTag)\r\n\t\t\t.RunAll();\r\n\r\n\t\tforeach (var hit in hits)\r\n\t\t{\r\n\t\t\tif (hit.Hit && hit.Collider is not null && hit.Collider is not Terrain)\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\r\n\r\n\tprivate static bool TryGetTerrainSurfaceWorldHeight(Terrain _Terrain, Vector3 _WorldPoint, out float _SampledWorldHeight)\r\n\t{\r\n\t\t_SampledWorldHeight = 0.0f;\r\n\r\n\t\tvar storage = _Terrain.Storage;\r\n\r\n\t\tif (storage is null || storage.HeightMap is null || storage.ControlMap is null || storage.Resolution <= 1)\r\n\t\t\treturn false;\r\n\r\n\t\tvar localPoint = _Terrain.WorldTransform.PointToLocal(_WorldPoint);\r\n\r\n\t\tif (localPoint.x < 0.0f || localPoint.y < 0.0f || localPoint.x > storage.TerrainSize || localPoint.y > storage.TerrainSize)\r\n\t\t\treturn false;\r\n\r\n\t\tint resolution = storage.Resolution;\r\n\t\tfloat gridX = (localPoint.x / storage.TerrainSize) * (resolution - 1);\r\n\t\tfloat gridY = (localPoint.y / storage.TerrainSize) * (resolution - 1);\r\n\r\n\t\tint x0 = (int)MathF.Floor(gridX).Clamp(0, resolution - 1);\r\n\t\tint y0 = (int)MathF.Floor(gridY).Clamp(0, resolution - 1);\r\n\t\tint x1 = (x0 + 1).Clamp(0, resolution - 1);\r\n\t\tint y1 = (y0 + 1).Clamp(0, resolution - 1);\r\n\r\n\t\tvar control = new CompactTerrainMaterial(storage.ControlMap[x0 + y0 * resolution]);\r\n\r\n\t\tif (control.IsHole)\r\n\t\t\treturn false;\r\n\r\n\t\tfloat tx = gridX - x0;\r\n\t\tfloat ty = gridY - y0;\r\n\t\tfloat h00 = storage.HeightMap[x0 + y0 * resolution];\r\n\t\tfloat h10 = storage.HeightMap[x1 + y0 * resolution];\r\n\t\tfloat h01 = storage.HeightMap[x0 + y1 * resolution];\r\n\t\tfloat h11 = storage.HeightMap[x1 + y1 * resolution];\r\n\t\tfloat hx0 = MathX.Lerp(h00, h10, tx);\r\n\t\tfloat hx1 = MathX.Lerp(h01, h11, tx);\r\n\t\tfloat sampledLocalHeight = MathX.Lerp(hx0, hx1, ty) * (storage.TerrainHeight / ushort.MaxValue);\r\n\r\n\t\t_SampledWorldHeight = _Terrain.WorldTransform.PointToWorld(new Vector3(localPoint.x, localPoint.y, sampledLocalHeight)).z;\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate static IEnumerable<Vector3> EnumerateSamplePoints(BBox _LocalBox)\r\n\t{\r\n\t\tfor (int ix = 0; ix < 3; ix++)\r\n\t\t\tfor (int iy = 0; iy < 3; iy++)\r\n\t\t\t\tfor (int iz = 0; iz < 3; iz++)\r\n\t\t\t\t{\r\n\t\t\t\t\tyield return new Vector3(\r\n\t\t\t\t\t\tMathX.Lerp(_LocalBox.Mins.x, _LocalBox.Maxs.x, ix * 0.5f),\r\n\t\t\t\t\t\tMathX.Lerp(_LocalBox.Mins.y, _LocalBox.Maxs.y, iy * 0.5f),\r\n\t\t\t\t\t\tMathX.Lerp(_LocalBox.Mins.z, _LocalBox.Maxs.z, iz * 0.5f)\r\n\t\t\t\t\t);\r\n\t\t\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate static IEnumerable<Vector3> EnumerateXYSamplePoints(BBox _LocalBox)\r\n\t{\r\n\t\tfloat z = _LocalBox.Center.z;\r\n\r\n\t\tfor (int ix = 0; ix < 5; ix++)\r\n\t\t\tfor (int iy = 0; iy < 5; iy++)\r\n\t\t\t{\r\n\t\t\t\tyield return new Vector3(\r\n\t\t\t\t\tMathX.Lerp(_LocalBox.Mins.x, _LocalBox.Maxs.x, ix / 4.0f),\r\n\t\t\t\t\tMathX.Lerp(_LocalBox.Mins.y, _LocalBox.Maxs.y, iy / 4.0f),\r\n\t\t\t\t\tz\r\n\t\t\t\t);\r\n\t\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool CanSubdivide(BBox _LocalBox, int _Depth)\r\n\t{\r\n\t\tif (_Depth >= MaxDepth)\r\n\t\t\treturn false;\r\n\r\n\t\tvar size = _LocalBox.Size;\r\n\r\n\t\treturn size.x > MinCellSize || size.y > MinCellSize;\r\n\t}\r\n\r\n\r\n\r\n\tprivate static IEnumerable<BBox> Subdivide(BBox _LocalBox)\r\n\t{\r\n\t\tvar center = _LocalBox.Center;\r\n\t\tvar mins = _LocalBox.Mins;\r\n\t\tvar maxs = _LocalBox.Maxs;\r\n\r\n\t\tfor (int ix = 0; ix < 2; ix++)\r\n\t\t\tfor (int iy = 0; iy < 2; iy++)\r\n\t\t\t{\r\n\t\t\t\tyield return new BBox(\r\n\t\t\t\t\tnew Vector3(ix == 0 ? mins.x : center.x, iy == 0 ? mins.y : center.y, mins.z),\r\n\t\t\t\t\tnew Vector3(ix == 0 ? center.x : maxs.x, iy == 0 ? center.y : maxs.y, maxs.z)\r\n\t\t\t\t);\r\n\t\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool CreateWaterBody(GameObject _Container, BBox _LocalBox)\r\n\t{\r\n\t\tfloat width = _LocalBox.Size.x - QuadInset * 2.0f;\r\n\t\tfloat length = _LocalBox.Size.y - QuadInset * 2.0f;\r\n\r\n\t\tif (width <= 1.0f || length <= 1.0f)\r\n\t\t\treturn false;\r\n\r\n\t\tvar go = new GameObject(_Container, true, \"Water Volume\");\r\n\t\tgo.Tags.Add(BakedTag);\r\n\r\n\t\tvar worldPoint = WorldTransform.PointToWorld(_LocalBox.Center);\r\n\r\n\t\tgo.WorldPosition = new Vector3(worldPoint.x, worldPoint.y, WaterSurfaceZ - WaterDepth * 0.5f);\r\n\t\tgo.WorldRotation = WorldRotation;\r\n\t\tgo.WorldScale = 1.0f;\r\n\r\n\t\tvar bounds = new BBox\r\n\t\t(\r\n\t\t\tnew Vector3(-width * 0.5f, -length * 0.5f, -WaterDepth * 0.5f),\r\n\t\t\tnew Vector3(width * 0.5f, length * 0.5f, WaterDepth * 0.5f)\r\n\t\t);\r\n\r\n\t\tvar body = go.GetOrAddComponent<WaterBody>();\r\n\t\tbody.SetBounds(bounds);\r\n\t\tbody.WaterType = WaterType;\r\n\r\n\t\tif (Soundscape)\r\n\t\t\tCreateSoundscapeTrigger(go, width, length);\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateSoundscapeTrigger(GameObject _Parent, float _Width, float _Length)\r\n\t{\r\n\t\tvar finalExtents = new Vector3(_Width * 0.5f, _Length * 0.5f, (WaterDepth * 0.5f) + SoundscapeExtraHeight);\r\n\r\n\t\tif (finalExtents.x <= 1.0f || finalExtents.y <= 1.0f || finalExtents.z <= 1.0f)\r\n\t\t\treturn;\r\n\r\n\t\tvar go = new GameObject(_Parent, true, \"Water Soundscape\");\r\n\t\tgo.Tags.Add(BakedTag);\r\n\t\tgo.LocalPosition = Vector3.Zero.WithZ(SoundscapeExtraHeight);\r\n\t\tgo.LocalRotation = Rotation.Identity;\r\n\t\tgo.LocalScale = 1.0f;\r\n\r\n\t\tvar trigger = go.GetOrAddComponent<SoundscapeTrigger>();\r\n\t\ttrigger.Type = SoundscapeTrigger.TriggerType.Box;\r\n\t\ttrigger.Soundscape = SoundscapeAsset;\r\n\t\ttrigger.TargetMixer = SoundscapeTargetMixer;\r\n\t\ttrigger.StayActiveOnExit = SoundscapeStayActiveOnExit;\r\n\t\ttrigger.Volume = SoundscapeVolume;\r\n\t\ttrigger.BoxSize = finalExtents;\r\n\t}\r\n\r\n\r\n\r\n\tprivate BBox GetLocalBakeBox()\r\n\t{\r\n\t\tfloat minZ = WaterSurfaceZ - WaterDepth;\r\n\t\tfloat maxZ = WaterSurfaceZ;\r\n\r\n\t\tvar mins = new Vector3(-BakeSizeXY.x * 0.5f, -BakeSizeXY.y * 0.5f, minZ);\r\n\t\tvar maxs = new Vector3(BakeSizeXY.x * 0.5f, BakeSizeXY.y * 0.5f, maxZ);\r\n\r\n\t\treturn new BBox(mins, maxs);\r\n\t}\r\n\r\n\r\n\r\n\tprivate GameObject GetOrCreateBakedContainer()\r\n\t{\r\n\t\tvar existing = FindBakedContainer();\r\n\r\n\t\tif (existing.IsValid())\r\n\t\t\treturn existing;\r\n\r\n\t\tvar container = new GameObject(GameObject, true, BakedContainerName);\r\n\t\tcontainer.Tags.Add(\"container\");\r\n\t\tcontainer.Tags.Add(BakedTag);\r\n\t\tcontainer.LocalPosition = Vector3.Zero;\r\n\t\tcontainer.LocalRotation = Rotation.Identity;\r\n\t\tcontainer.LocalScale = 1.0f;\r\n\r\n\t\treturn container;\r\n\t}\r\n\r\n\r\n\r\n\tprivate GameObject FindBakedContainer()\r\n\t{\r\n\t\treturn GameObject.Children.FirstOrDefault(child => child.IsValid() && child.Tags.Has(\"container\"));\r\n\t}\r\n\r\n\r\n\r\n\tprivate struct SampleSummary\r\n\t{\r\n\t\tpublic int Total;\r\n\t\tpublic int TerrainInside;\r\n\t\tpublic int MeshInside;\r\n\r\n\t\tpublic bool TerrainAllInside => Total > 0 && TerrainInside == Total;\r\n\t\tpublic bool TerrainMixed => TerrainInside > 0 && TerrainInside < Total;\r\n\t\tpublic bool MeshAllInside => Total > 0 && MeshInside == Total;\r\n\t\tpublic bool MeshHasAny => MeshInside > 0;\r\n\t\tpublic bool TerrainHasAny => TerrainInside > 0;\r\n\t\tpublic bool MeshMixed => MeshInside > 0 && MeshInside < Total;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/WaterRippleEmitter.cs",
            "FileName": "WaterRippleEmitter.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using Sandbox;\n\nnamespace RedSnail.WaterTool;\n\n/// <summary>\n/// Emits water ripples when this object crosses the water surface, and optionally\n/// while it moves across it. A generic, dependency-free alternative to the entry\n/// ripple built into <see cref=\"Buoyancy\"/> \u2014 drop it on anything that doesn't have\n/// a Buoyancy component (players, NPCs, projectiles, debris...).\n///\n/// Velocity is derived from the object's own position delta, so it works with any\n/// movement system (CharacterController, custom controllers, animation, etc.) and\n/// needs no Rigidbody.\n/// </summary>\n[Icon(\"water\"), Group(\"Water\"), Title(\"Water Ripple Emitter\")]\npublic sealed class WaterRippleEmitter : Component\n{\n\t[Property, Group(\"Entry\")] public bool EmitOnEntry { get; set; } = true;\n\t[Property, Group(\"Entry\")] public float EntryStrength { get; set; } = 0.2f;\n\t// Ring spacing for the entry splash \u2014 smaller = tighter, more concentric rings.\n\t[Property, Group(\"Entry\"), Range(20.0f, 400.0f)] public float EntryWavelength { get; set; } = 120.0f;\n\t// Ring size for the entry splash \u2014 larger = a bigger, broader ripple.\n\t[Property, Group(\"Entry\"), Range(10.0f, 500.0f)] public float EntryRingWidth { get; set; } = 50.0f;\n\t// Minimum downward speed (units/s) needed to splash. Set to 0 to ripple on any crossing.\n\t[Property, Group(\"Entry\")] public float MinImpactSpeed { get; set; } = 40.0f;\n\n\t[Property, Group(\"Wake\")] public bool EmitWake { get; set; } = false;\n\t[Property, Group(\"Wake\")] public float WakeStrength { get; set; } = 0.1f;\n\t// Ring spacing for wake ripples \u2014 smaller = tighter, more concentric rings.\n\t[Property, Group(\"Wake\"), Range(20.0f, 400.0f)] public float WakeWavelength { get; set; } = 120.0f;\n\t// Ring size for wake ripples \u2014 larger = a bigger, broader ripple.\n\t[Property, Group(\"Wake\"), Range(10.0f, 500.0f)] public float WakeRingWidth { get; set; } = 50.0f;\n\t// Minimum horizontal speed (units/s) before a moving object leaves a wake.\n\t[Property, Group(\"Wake\")] public float WakeMinSpeed { get; set; } = 1.0f;\n\t[Property, Group(\"Wake\")] public float WakeInterval { get; set; } = 0.0333f; // 30 fps\n\n\t// Local-space offset of the point tested against the surface (e.g. the feet).\n\t[Property, Group(\"General\")] public Vector3 SampleOffset { get; set; } = Vector3.Zero;\n\n\tprivate bool m_Initialized;\n\tprivate bool m_WasBelowSurface;\n\tprivate Vector3 m_LastPosition;\n\tprivate float m_WakeTimer;\n\n\tprivate Vector3 SamplePosition => WorldPosition + WorldRotation * SampleOffset;\n\n\n\n\tprotected override void OnEnabled()\n\t{\n\t\tm_LastPosition = SamplePosition;\n\t\tm_WasBelowSurface = false;\n\t\tm_Initialized = false;\n\t}\n\n\n\n\tprotected override void OnUpdate()\n\t{\n\t\t// If this gameobject is parented to anything, we don't want to play water ripple effects\n\t\t// (e.g. A player inside a boat)\n\t\tif (GameObject.Parent != Scene)\n\t\t\treturn;\n\t\t\n\t\tVector3 samplePos = SamplePosition;\n\n\t\t// Velocity from position delta \u2014 no Rigidbody required\n\t\tVector3 velocity = Time.Delta > 0.0f ? (samplePos - m_LastPosition) / Time.Delta : Vector3.Zero;\n\t\tm_LastPosition = samplePos;\n\n\t\tfloat waterHeight = WaterManager.GetWaterHeightAt(samplePos);\n\n\t\t// Not over any water surface\n\t\tif (waterHeight <= float.MinValue)\n\t\t{\n\t\t\tm_WasBelowSurface = false;\n\t\t\treturn;\n\t\t}\n\n\t\tbool belowSurface = samplePos.z <= waterHeight;\n\n\t\t// Skip the first valid frame so an object spawned already in water doesn't splash\n\t\tif (!m_Initialized)\n\t\t{\n\t\t\tm_WasBelowSurface = belowSurface;\n\t\t\tm_Initialized = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Entry splash on the above -> below surface crossing\n\t\tif (EmitOnEntry && belowSurface && !m_WasBelowSurface)\n\t\t{\n\t\t\tfloat impactSpeed = float.Max(0.0f, -velocity.z);\n\n\t\t\tif (impactSpeed >= MinImpactSpeed)\n\t\t\t{\n\t\t\t\tfloat strength = (impactSpeed / 150.0f).Clamp(0.3f, 2.5f) * EntryStrength;\n\t\t\t\t\n\t\t\t\tWaterManager.AddRipple(samplePos.WithZ(waterHeight), strength, EntryWavelength, EntryRingWidth);\n\t\t\t}\n\t\t}\n\n\t\tm_WasBelowSurface = belowSurface;\n\n\t\tfloat horizontalSpeed = velocity.WithZ(0.0f).Length;\n\t\t\n\t\t// Continuous wake while skimming/swimming through the surface\n\t\tif (EmitWake && belowSurface)\n\t\t{\n\t\t\tif (horizontalSpeed >= WakeMinSpeed)\n\t\t\t{\n\t\t\t\tm_WakeTimer -= Time.Delta;\n\n\t\t\t\tif (m_WakeTimer <= 0.0f)\n\t\t\t\t{\n\t\t\t\t\tWaterManager.AddRipple(samplePos.WithZ(waterHeight), WakeStrength, WakeWavelength, WakeRingWidth);\n\t\t\t\t\tm_WakeTimer = WakeInterval;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Code/Water/WaterWaveUtility.cs",
            "FileName": "WaterWaveUtility.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\npublic enum WaterBodyType\r\n{\r\n\tOcean,\r\n\tLake,\r\n\tRiver,\r\n\tPool,\r\n\tCustom\r\n}\r\n\r\npublic static class WaterWaveUtility\r\n{\r\n\tpublic static Vector3 ComputeDisplacementAt(Vector2 worldXY, WaterDefinition profile)\r\n\t{\r\n\t\tVector3 detail = ComputeGerstner(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;\r\n\t\tVector3 swell = ComputeGerstner(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;\r\n\t\treturn detail + swell;\r\n\t}\r\n\r\n\tpublic static Vector3 ComputeVelocityAt(Vector2 worldXY, WaterDefinition profile)\r\n\t{\r\n\t\tVector3 detail = ComputeGerstnerVelocity(worldXY, profile.WavesScale, profile.WavesSpeed, profile.WavesDirection, profile.WavesOctaves, profile.WavesLacunarity, profile.WavesPersistence, profile.WavesSteepness) * profile.WavesIntensity;\r\n\t\tVector3 swell = ComputeGerstnerVelocity(worldXY, profile.SwellScale, profile.SwellSpeed, profile.SwellDirection, profile.SwellOctaves, profile.SwellLacunarity, profile.SwellPersistence, profile.SwellSteepness) * profile.SwellIntensity;\r\n\t\treturn detail + swell;\r\n\t}\r\n\r\n\tprivate static Vector3 ComputeGerstner(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)\r\n\t{\r\n\t\tif (scale <= 0.0f || speed <= 0.0f || octaves <= 0)\r\n\t\t\treturn Vector3.Zero;\r\n\r\n\t\tVector2 waveDirection = direction.Normal;\r\n\t\tfloat t = Time.Now * speed;\r\n\r\n\t\tVector3 displacement = Vector3.Zero;\r\n\t\tfloat amp = 1.0f;\r\n\t\tfloat freq = scale;\r\n\t\tfloat maxAmp = 0f;\r\n\r\n\t\tfor (int oct = 0; oct < octaves; oct++)\r\n\t\t{\r\n\t\t\tfloat angle = oct * 1.2f;\r\n\t\t\tVector2 octDir = new(\r\n\t\t\t\twaveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),\r\n\t\t\t\twaveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)\r\n\t\t\t);\r\n\r\n\t\t\tfloat phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;\r\n\t\t\tdisplacement.x += steepness * amp * octDir.x * MathF.Cos(phase);\r\n\t\t\tdisplacement.y += steepness * amp * octDir.y * MathF.Cos(phase);\r\n\t\t\tdisplacement.z += amp * MathF.Sin(phase);\r\n\r\n\t\t\tmaxAmp += amp;\r\n\t\t\tamp *= persistence;\r\n\t\t\tfreq *= lacunarity;\r\n\t\t}\r\n\r\n\t\treturn maxAmp > 0.0f ? displacement / maxAmp : Vector3.Zero;\r\n\t}\r\n\r\n\tprivate static Vector3 ComputeGerstnerVelocity(Vector2 worldXY, float scale, float speed, Vector2 direction, int octaves, float lacunarity, float persistence, float steepness)\r\n\t{\r\n\t\tif (scale <= 0.0f || speed <= 0.0f || octaves <= 0)\r\n\t\t\treturn Vector3.Zero;\r\n\r\n\t\tVector2 waveDirection = direction.Normal;\r\n\t\tfloat t = Time.Now * speed;\r\n\r\n\t\tVector3 velocity = Vector3.Zero;\r\n\t\tfloat amp = 1.0f;\r\n\t\tfloat freq = scale;\r\n\t\tfloat maxAmp = 0f;\r\n\r\n\t\tfor (int oct = 0; oct < octaves; oct++)\r\n\t\t{\r\n\t\t\tfloat angle = oct * 1.2f;\r\n\t\t\tVector2 octDir = new(\r\n\t\t\t\twaveDirection.x * MathF.Cos(angle) - waveDirection.y * MathF.Sin(angle),\r\n\t\t\t\twaveDirection.x * MathF.Sin(angle) + waveDirection.y * MathF.Cos(angle)\r\n\t\t\t);\r\n\r\n\t\t\tfloat phase = freq * (octDir.x * worldXY.x + octDir.y * worldXY.y) + t * freq * 0.5f;\r\n\t\t\tfloat angularVelocity = freq * speed * 0.5f;\r\n\r\n\t\t\tvelocity.x -= steepness * amp * octDir.x * angularVelocity * MathF.Sin(phase);\r\n\t\t\tvelocity.y -= steepness * amp * octDir.y * angularVelocity * MathF.Sin(phase);\r\n\t\t\tvelocity.z += amp * angularVelocity * MathF.Cos(phase);\r\n\r\n\t\t\tmaxAmp += amp;\r\n\t\t\tamp *= persistence;\r\n\t\t\tfreq *= lacunarity;\r\n\t\t}\r\n\r\n\t\treturn maxAmp > 0.0f ? velocity / maxAmp : Vector3.Zero;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterBodyRenderer.cs",
            "FileName": "WaterBodyRenderer.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\nusing Sandbox.Rendering;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Icon(\"water\"), Group(\"Environment\"), Title(\"Water Body Renderer\")]\r\npublic sealed class WaterBodyRenderer : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\r\n{\r\n#pragma warning disable CS0649\r\n\r\n\tprivate struct WaterVertex\r\n\t{\r\n\t\t[VertexLayout.Position] public Vector3 Position;\r\n\t\t[VertexLayout.Normal] public Vector3 Normal;\r\n\t\t[VertexLayout.Tangent] public Vector4 Tangent;\r\n\t\t[VertexLayout.TexCoord] public Vector2 TexCoord;\r\n\t\t[VertexLayout.Color] public Color Color;\r\n\t}\r\n\r\n#pragma warning restore CS0649\r\n\r\n\tprivate const float BASE_TILE_SIZE = 100.0f;\r\n\r\n\tprivate const int MAX_RINGS = 8;\r\n\r\n\tprivate const int MAX_WATER_INCLUSION_VOLUMES = 1024;\r\n\tprivate const int WATER_INCLUSION_VOLUME_ROWS = 3;\r\n\r\n\tprivate const int MAX_WATER_EXCLUSION_VOLUMES = 512;\r\n\tprivate const int WATER_EXCLUSION_VOLUME_ROWS = 3;\r\n\r\n\tprivate const int MAX_HULL_EXCLUSION_VOLUMES = 8;\r\n\tprivate const int HULL_EXCLUSION_META_ROWS = 6;\r\n\tprivate const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;\r\n\tprivate const int MAX_HULL_EXCLUSION_TRIS = 16384;\r\n\r\n\tprivate GpuBuffer<WaterVertex> m_VertexBuffer;\r\n\tprivate GpuBuffer<uint> m_IndexBuffer;\r\n\tprivate GpuBuffer<Vector4> m_WaterInclusionVolumeBuffer;\r\n\tprivate GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;\r\n\tprivate int m_TotalIndexCount;\r\n\tprivate readonly RenderAttributes m_DrawAttributes = new();\r\n\tprivate int m_LastConfigHash;\r\n\tprivate readonly Vector4[] m_WaterInclusionVolumeData = new Vector4[MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS];\r\n\tprivate readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];\r\n\tprivate GpuBuffer<Vector4> m_HullExclusionBuffer;\r\n\tprivate readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];\r\n\r\n\t[Property, Group(\"General\"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;\r\n\t[Property, Group(\"General\"), Order(0)] public Material Material { get; set; }\r\n\t[Property, Group(\"General\"), Order(0)] public float Width { get; set; } = 10000.0f;\r\n\t[Property, Group(\"General\"), Order(0)] public float Length { get; set; } = 10000.0f;\r\n\t[Property, Group(\"General\"), Order(0)] public float Depth { get; set; } = 300.0f;\r\n\t[Property(Title = \"Infinite Rendering\"), Group(\"General\"), Order(0)] public bool UseHybridInclusionBounds { get; set; } = true;\r\n\t[Property, Group(\"Clipmap\"), Order(1)] public float BaseCellSize { get; set; } = 8.0f;\r\n\t[Property, Group(\"Clipmap\"), Order(1), Range(16, 512)] public int CellsPerRing { get; set; } = 64;\r\n\t[Property(Title = \"Use Camera For Clipmap\"), Group(\"Clipmap\"), Order(1)] public bool FollowCameraForClipmap { get; set; } = true;\r\n\t[Property, Group(\"Texture\"), Order(2), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;\r\n\r\n\tprivate int VerticesPerRing => (CellsPerRing + 1) * (CellsPerRing + 1);\r\n\tprivate float OuterExtent => CellsPerRing * BaseCellSize * (1 << (ComputeRingCount() - 1));\r\n\r\n\tinternal bool ParticipatesInRendering => Active && Material.IsValid();\r\n\tinternal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tif (!ParticipatesInRendering)\r\n\t\t\treturn;\r\n\r\n\t\tCreateBuffers();\r\n\r\n\t\tm_LastConfigHash = ComputeConfigHash();\r\n\r\n\t\tWaterManager.Current?.RefreshWaterBodyRenderersList();\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterBodyRenderersList();\r\n\r\n\t\tm_VertexBuffer = default;\r\n\t\tm_IndexBuffer = default;\r\n\t\tm_WaterInclusionVolumeBuffer?.Dispose();\r\n\t\tm_WaterInclusionVolumeBuffer = null;\r\n\t\tm_WaterExclusionVolumeBuffer?.Dispose();\r\n\t\tm_WaterExclusionVolumeBuffer = null;\r\n\t\tm_HullExclusionBuffer?.Dispose();\r\n\t\tm_HullExclusionBuffer = null;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif (!ParticipatesInRendering)\r\n\t\t\treturn;\r\n\r\n\t\tint configHash = ComputeConfigHash();\r\n\t\tif (!HasValidBuffers || configHash != m_LastConfigHash)\r\n\t\t{\r\n\t\t\tCreateBuffers();\r\n\t\t\tm_LastConfigHash = configHash;\r\n\t\t}\r\n\r\n\t\tUpdateShaderAttributes();\r\n\t}\r\n\r\n\tinternal BBox GetWorldBounds2D()\r\n\t{\r\n\t\tVector3 right = WorldRotation.Right * (Length / 2.0f);\r\n\t\tVector3 forward = WorldRotation.Forward * (Width / 2.0f);\r\n\r\n\t\tVector3 c0 = WorldPosition + right + forward;\r\n\t\tVector3 c1 = WorldPosition - right + forward;\r\n\t\tVector3 c2 = WorldPosition + right - forward;\r\n\t\tVector3 c3 = WorldPosition - right - forward;\r\n\r\n\t\tfloat minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));\r\n\t\tfloat maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));\r\n\t\tfloat minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));\r\n\t\tfloat maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));\r\n\r\n\t\treturn new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));\r\n\t}\r\n\r\n\t// Records the clipmap compute dispatches into the command list as DEFERRED commands.\r\n\t// They run later, on the render thread, when the camera executes the list - so the\r\n\t// per-ring attributes are set through the command list (which writes Graphics.Attributes\r\n\t// at execute time, exactly what CommandList.DispatchCompute reads) rather than on the\r\n\t// shared shader instance.\r\n\tinternal void RecordCompute(CommandList commandList, ComputeShader shader, Vector3 cameraPosition)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tvar localBounds = GetWorldBounds2D();\r\n\r\n\t\tfor (int ring = 0; ring < ringCount; ring++)\r\n\t\t{\r\n\t\t\tfloat cellSize = BaseCellSize * (1 << ring);\r\n\t\t\tVector3 clipmapAnchor = FollowCameraForClipmap ? cameraPosition : WorldPosition;\r\n\t\t\tfloat snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;\r\n\t\t\tfloat snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;\r\n\r\n\t\t\tcommandList.Attributes.Set(\"VertexBuffer\", m_VertexBuffer);\r\n\t\t\tcommandList.Attributes.Set(\"VertexOffset\", ring * verticesPerRing);\r\n\t\t\tcommandList.Attributes.Set(\"GridWidth\", CellsPerRing);\r\n\t\t\tcommandList.Attributes.Set(\"CellSize\", cellSize);\r\n\t\t\tcommandList.Attributes.Set(\"SnapPosition\", new Vector2(snapX, snapY));\r\n\t\t\tcommandList.Attributes.Set(\"WaterZ\", WorldPosition.z);\r\n\t\t\tcommandList.Attributes.Set(\"TilingScale\", 1.0f / OuterExtent);\r\n\t\t\tcommandList.Attributes.Set(\"ClampToBounds\", false);\r\n\t\t\tcommandList.Attributes.Set(\"BoundsMin\", new Vector2(localBounds.Mins.x, localBounds.Mins.y));\r\n\t\t\tcommandList.Attributes.Set(\"BoundsMax\", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));\r\n\t\t\tcommandList.DispatchCompute(shader, verticesPerRing, 1, 1);\r\n\t\t}\r\n\t}\r\n\r\n\tinternal void BarrierTransition(CommandList _CommandList)\r\n\t{\r\n\t\tif (m_VertexBuffer.IsValid())\r\n\t\t\t_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);\r\n\t}\r\n\r\n\tinternal void Draw(CommandList _CommandList)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\t\t\r\n\t\t_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);\r\n\t}\r\n\r\n\tprivate void UpdateShaderAttributes()\r\n\t{\r\n\t\tBBox localBounds = GetWorldBounds2D();\r\n\r\n\t\tm_DrawAttributes.Set(\"RequireWaterInclusionVolumes\", UseHybridInclusionBounds);\r\n\t\tm_DrawAttributes.Set(\"UseHybridInclusionBounds\", UseHybridInclusionBounds);\r\n\t\tm_DrawAttributes.Set(\"HybridInclusionBoundsMin\", new Vector2(localBounds.Mins.x, localBounds.Mins.y));\r\n\t\tm_DrawAttributes.Set(\"HybridInclusionBoundsMax\", new Vector2(localBounds.Maxs.x, localBounds.Maxs.y));\r\n\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\tif (profile.IsValid())\r\n\t\t\tprofile.ApplyTo(m_DrawAttributes);\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterTime\", Time.Now);\r\n\t\tm_DrawAttributes.Set(\"DepthMax\", Depth);\r\n\r\n\t\tfloat tilingScalar = (OuterExtent / BASE_TILE_SIZE) * TextureTilingMultiplier;\r\n\t\tm_DrawAttributes.Set(\"NormalTiling\", new Vector2(tilingScalar, tilingScalar));\r\n\r\n\t\tWaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);\r\n\t\tWaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);\r\n\t\t\r\n\t\t// Band-limit the wave normal to the local clipmap vertex spacing (see shader)\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsScale\", 3.0f / CellsPerRing);\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsMin\", BaseCellSize);\r\n\r\n\t\tvar viewPosition = WaterManager.GetViewPosition(Scene, WorldPosition);\r\n\r\n\t\tSetWaterInclusionVolumes(viewPosition);\r\n\t\tSetWaterExclusionVolumes(viewPosition);\r\n\t\tSetHullExclusionVolumes();\r\n\t}\r\n\r\n\tprivate void SetWaterInclusionVolumes(Vector3 referencePosition)\r\n\t{\r\n\t\tEnsureWaterInclusionVolumeBuffer();\r\n\r\n\t\tvar volumes = WaterManager.Current.Bodies\r\n\t\t\t.Where(v => v.IsValid() && v.Active && v.WaterType == WaterType)\r\n\t\t\t.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))\r\n\t\t\t.Take(MAX_WATER_INCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tfor (int i = 0; i < volumes.Count; i++)\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = volumes[i].GetWorldOBB();\r\n\r\n\t\t\tint rowOffset = i * WATER_INCLUSION_VOLUME_ROWS;\r\n\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);\r\n\t\t\tm_WaterInclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);\r\n\t\t}\r\n\r\n\t\tm_WaterInclusionVolumeBuffer.SetData(m_WaterInclusionVolumeData.AsSpan(0, volumes.Count * WATER_INCLUSION_VOLUME_ROWS));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterInclusionVolumeCount\", volumes.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterInclusionVolumeRows\", m_WaterInclusionVolumeBuffer);\r\n\t}\r\n\r\n\tprivate void SetWaterExclusionVolumes(Vector3 referencePosition)\r\n\t{\r\n\t\tEnsureWaterExclusionVolumeBuffer();\r\n\r\n\t\tvar volumes = WaterManager.Current.ExclusionVolumes\r\n\t\t\t.Where(v => v.IsValid() && v.Enabled && v.Active)\r\n\t\t\t.OrderBy(v => v.WorldPosition.DistanceSquared(referencePosition))\r\n\t\t\t.Take(MAX_WATER_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tfor (int i = 0; i < volumes.Count; i++)\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = volumes[i].GetWorldOBB();\r\n\r\n\t\t\tint rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;\r\n\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);\r\n\t\t}\r\n\r\n\t\tm_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeCount\", volumes.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeRows\", m_WaterExclusionVolumeBuffer);\r\n\t}\r\n\r\n\tprivate void EnsureWaterExclusionVolumeBuffer()\r\n\t{\r\n\t\tif (m_WaterExclusionVolumeBuffer.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tm_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void SetHullExclusionVolumes()\r\n\t{\r\n\t\tif (WaterManager.Current == null)\r\n\t\t\treturn;\r\n\r\n\t\tvar hulls = WaterManager.Current.HullExclusionVolumes\r\n\t\t\t.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)\r\n\t\t\t.Take(MAX_HULL_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tif (hulls.Count == 0)\r\n\t\t{\r\n\t\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", 0);\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureHullExclusionBuffers();\r\n\r\n\t\tint triWriteCursor = HULL_EXCLUSION_META_SIZE;\r\n\r\n\t\tfor (int h = 0; h < hulls.Count; h++)\r\n\t\t{\r\n\t\t\tvar hull = hulls[h];\r\n\t\t\tvar tris = hull.LocalTriangles;\r\n\t\t\tint triCount = tris.Length / 3;\r\n\r\n\t\t\tif (triWriteCursor + tris.Length > m_HullExclusionData.Length)\r\n\t\t\t\tbreak;\r\n\r\n\t\t\thull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);\r\n\r\n\t\t\tint meta = h * HULL_EXCLUSION_META_ROWS;\r\n\t\t\tm_HullExclusionData[meta + 0] = r0;\r\n\t\t\tm_HullExclusionData[meta + 1] = r1;\r\n\t\t\tm_HullExclusionData[meta + 2] = r2;\r\n\t\t\tm_HullExclusionData[meta + 3] = r3;\r\n\r\n\t\t\tvar aabb = hull.LocalAABB;\r\n\t\t\tm_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);\r\n\t\t\tm_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);\r\n\r\n\t\t\tfor (int i = 0; i < tris.Length; i++)\r\n\t\t\t\tm_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);\r\n\r\n\t\t\ttriWriteCursor += tris.Length;\r\n\t\t}\r\n\r\n\t\tm_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", hulls.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionData\", m_HullExclusionBuffer);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureHullExclusionBuffers()\r\n\t{\r\n\t\tif (!m_HullExclusionBuffer.IsValid())\r\n\t\t\tm_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureWaterInclusionVolumeBuffer()\r\n\t{\r\n\t\tif (m_WaterInclusionVolumeBuffer.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tm_WaterInclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_INCLUSION_VOLUMES * WATER_INCLUSION_VOLUME_ROWS, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\tprivate int ComputeConfigHash()\r\n\t{\r\n\t\treturn HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing);\r\n\t}\r\n\r\n\tprivate int ComputeRingCount()\r\n\t{\r\n\t\treturn ComputeRingCount(Width, Length);\r\n\t}\r\n\r\n\tprivate int ComputeRingCount(float width, float length)\r\n\t{\r\n\t\tfloat maxDim = MathF.Max(length, width);\r\n\t\tfloat innerExtent = CellsPerRing * BaseCellSize;\r\n\t\tfloat requiredExtent = maxDim * 2.0f;\r\n\r\n\t\tif (requiredExtent <= innerExtent)\r\n\t\t\treturn 1;\r\n\r\n\t\tint rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;\r\n\t\treturn Math.Clamp(rings, 1, MAX_RINGS);\r\n\t}\r\n\r\n\tprivate void CreateBuffers()\r\n\t{\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint n = CellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\t\tint innerBlockSize = innerEnd - innerStart;\r\n\t\tint filledCells = n * n;\r\n\t\tint hollowCells = filledCells - (innerBlockSize * innerBlockSize);\r\n\t\tint totalIndices = filledCells * 6;\r\n\t\ttotalIndices += (ringCount - 1) * hollowCells * 6;\r\n\r\n\t\tm_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);\r\n\t\tm_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);\r\n\t\tUploadIndexBuffer(ringCount);\r\n\t}\r\n\r\n\tprivate void UploadIndexBuffer(int ringCount)\r\n\t{\r\n\t\tint n = CellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\r\n\t\tvar indices = new List<uint>();\r\n\r\n\t\tfor (int ring = 0; ring < ringCount; ring++)\r\n\t\t{\r\n\t\t\tuint baseVertex = (uint)(ring * verticesPerRing);\r\n\r\n\t\t\tfor (int y = 0; y < n; y++)\r\n\t\t\t{\r\n\t\t\t\tfor (int x = 0; x < n; x++)\r\n\t\t\t\t{\r\n\t\t\t\t\tif (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tuint i0 = baseVertex + (uint)(y * (n + 1) + x);\r\n\t\t\t\t\tuint i1 = i0 + 1;\r\n\t\t\t\t\tuint i2 = i0 + (uint)(n + 1);\r\n\t\t\t\t\tuint i3 = i2 + 1;\r\n\r\n\t\t\t\t\tindices.Add(i0);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i3);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tm_IndexBuffer.SetData(indices);\r\n\t\tm_TotalIndexCount = indices.Count;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "redsnail.watertool",
            "Path": "Water/WaterQuad.cs",
            "FileName": "WaterQuad.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 342768,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\nusing Sandbox.Rendering;\r\n\r\nnamespace RedSnail.WaterTool;\r\n\r\n[Icon(\"water\"), Group(\"Water\"), Title(\"Water Quad\")]\r\npublic sealed class WaterQuad : Component, Component.ExecuteInEditor, Component.DontExecuteOnServer\r\n{\r\n\t#pragma warning disable CS0649\r\n\r\n\tprivate struct WaterVertex\r\n\t{\r\n\t\t[VertexLayout.Position] public Vector3 Position;\r\n\t\t[VertexLayout.Normal] public Vector3 Normal;\r\n\t\t[VertexLayout.Tangent] public Vector4 Tangent;\r\n\t\t[VertexLayout.TexCoord] public Vector2 TexCoord;\r\n\t\t[VertexLayout.Color] public Color Color;\r\n\t}\r\n\r\n\t#pragma warning restore CS0649\r\n\r\n\t// GPU buffers (per-quad, owned here \u2014 WaterManager owns the command lists and ComputeShader)\r\n\tprivate GpuBuffer<WaterVertex> m_VertexBuffer;\r\n\tprivate GpuBuffer<uint> m_IndexBuffer;\r\n\tprivate int m_TotalIndexCount;\r\n\tprivate int m_CircleGridWidth = 1;\r\n\tprivate readonly RenderAttributes m_DrawAttributes = new();\r\n\tprivate GpuBuffer<Vector4> m_WaterExclusionVolumeBuffer;\r\n\tprivate readonly Vector4[] m_WaterExclusionVolumeData = new Vector4[MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS];\r\n\tprivate GpuBuffer<Vector4> m_HullExclusionBuffer;\r\n\tprivate readonly Vector4[] m_HullExclusionData = new Vector4[HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3];\r\n\r\n\tprivate HullCollider m_HullCollider;\r\n\tprivate int m_LastConfigHash;\r\n\tprivate float m_LastWidth;\r\n\tprivate float m_LastLength;\r\n\tprivate float m_LastDepth;\r\n\tprivate bool m_LastIsCircleShape;\r\n\tprivate int m_LastNumCircleSegments;\r\n\tprivate Vector3 m_LastHullCenter;\r\n\tprivate Vector3 m_LastHullBoxSize;\r\n\tprivate Material m_LastMaterial;\r\n\r\n\tprivate const float BASE_TILE_SIZE = 100.0f;\r\n\r\n\tprivate const int MAX_RINGS = 8;\r\n\r\n\tprivate const int MAX_WATER_EXCLUSION_VOLUMES = 512;\r\n\tprivate const int WATER_EXCLUSION_VOLUME_ROWS = 3;\r\n\r\n\tprivate const int MAX_HULL_EXCLUSION_VOLUMES = 8;\r\n\tprivate const int HULL_EXCLUSION_META_ROWS = 6;\r\n\tprivate const int HULL_EXCLUSION_META_SIZE = MAX_HULL_EXCLUSION_VOLUMES * HULL_EXCLUSION_META_ROWS;\r\n\tprivate const int MAX_HULL_EXCLUSION_TRIS = 16384;\r\n\r\n\t[Property, Group(\"General\"), Order(0)] public WaterBodyType WaterType { get; set; } = WaterBodyType.Ocean;\r\n\t[Property, Group(\"General\"), Order(0)] public Material Material { get; set; }\r\n\t[Property, Group(\"General\"), Step(1), Order(0)] public float Width { get; set; } = 5000.0f;\r\n\t[Property, Group(\"General\"), Step(1), Order(0)] public float Length { get; set; } = 5000.0f;\r\n\t[Property, Group(\"General\"), Step(1), Order(0)] public float Depth { get; set; } = 300.0f;\r\n\r\n\t[Property, Group(\"Clipmap\"), Order(2)] public float BaseCellSize { get; set { field = value.Clamp(8, 4096); } } = 32.0f;\r\n\t[Property, Group(\"Clipmap\"), Order(2), Range(16, 512)] public int CellsPerRing { get; set { field = value.Clamp(16, 512); } } = 256;\r\n\t[Property(Title = \"Use Camera For Clipmap\"), Group(\"Clipmap\"), Order(2)] public bool FollowCameraForClipmap { get; set; } = true;\r\n\t\r\n\t[Property, Group(\"Shape\"), Order(3)] public bool CircleShape { get; set; } = false;\r\n\t[Property, Group(\"Shape\"), Order(3), Range(5, 32), ShowIf(nameof(CircleShape), true)] public int CircleSegments { get; set { field = value.Clamp(5, 32); } } = 16;\r\n\r\n\t[Property, Group(\"Texture\"), Order(4), Range(0.1f, 2.0f)] public float TextureTilingMultiplier { get; set; } = 1.0f;\r\n\r\n\tpublic HullCollider HullCollider => m_HullCollider;\r\n\r\n\t// Distance LOD level resolved by the WaterManager (0 = full detail). At level L the grid\r\n\t// uses half the cells at twice the size per level, so it covers exactly the same area with\r\n\t// 4^L fewer vertices. CellsPerRing * BaseCellSize is preserved exactly, which is what keeps\r\n\t// the ring count, coverage and texture tiling identical across levels \u2014 only the\r\n\t// tessellation density changes, so there's no swimming or resizing when a level switches.\r\n\tprivate int m_LodLevel;\r\n\r\n\tprivate int EffectiveCellsPerRing => Math.Max(16, CellsPerRing >> m_LodLevel);\r\n\tprivate float EffectiveBaseCellSize => BaseCellSize * ((float)CellsPerRing / EffectiveCellsPerRing);\r\n\r\n\tprivate int VerticesPerRing => (EffectiveCellsPerRing + 1) * (EffectiveCellsPerRing + 1);\r\n\r\n\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tRefreshRenderBuffers();\r\n\t\tUpdateColliderState();\r\n\r\n\t\tm_LastWidth = Width;\r\n\t\tm_LastLength = Length;\r\n\t\tm_LastDepth = Depth;\r\n\t\tm_LastIsCircleShape = CircleShape;\r\n\t\tm_LastNumCircleSegments = CircleSegments;\r\n\t\tm_LastMaterial = Material;\r\n\r\n\t\tWaterManager.Current?.RefreshWaterQuadsList();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tWaterManager.Current?.RefreshWaterQuadsList();\r\n\r\n\t\tm_HullCollider?.Destroy();\r\n\r\n\t\tm_VertexBuffer = default;\r\n\t\tm_IndexBuffer = default;\r\n\t\tm_WaterExclusionVolumeBuffer?.Dispose();\r\n\t\tm_WaterExclusionVolumeBuffer = null;\r\n\t\tm_HullExclusionBuffer?.Dispose();\r\n\t\tm_HullExclusionBuffer = null;\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif (WaterManager.Current == null)\r\n\t\t\treturn;\r\n\r\n\t\t// Material was just assigned after the component was already enabled, register now.\r\n\t\tif (m_LastMaterial == null && Material != null)\r\n\t\t\tWaterManager.Current?.RefreshWaterQuadsList();\r\n\r\n\t\tm_LastMaterial = Material;\r\n\r\n\t\tif (Material == null)\r\n\t\t\treturn;\r\n\r\n\t\t// Resolve the tessellation level before the buffers are checked \u2014 it feeds the config\r\n\t\t// hash, so a level change rebuilds the grid at the new density (rare, thanks to the\r\n\t\t// hysteresis in ComputeLodLevel).\r\n\t\tm_LodLevel = WaterManager.Current.ComputeLodLevel(GetWorldBounds2D(), m_LodLevel);\r\n\r\n\t\tUpdateBuffers();\r\n\r\n\t\tif (Width != m_LastWidth || Length != m_LastLength || Depth != m_LastDepth || CircleShape != m_LastIsCircleShape || m_LastNumCircleSegments != CircleSegments)\r\n\t\t{\r\n\t\t\tUpdateColliderState();\r\n\r\n\t\t\tm_LastWidth = Width;\r\n\t\t\tm_LastLength = Length;\r\n\t\t\tm_LastDepth = Depth;\r\n\t\t\tm_LastIsCircleShape = CircleShape;\r\n\t\t\tm_LastNumCircleSegments = CircleSegments;\r\n\t\t}\r\n\r\n\t\tif (m_HullCollider.IsValid())\r\n\t\t{\r\n\t\t\tif (m_HullCollider.Center != m_LastHullCenter)\r\n\t\t\t{\r\n\t\t\t\tm_HullCollider.Center = m_LastHullCenter;\r\n\t\t\t\t\r\n\t\t\t\tLog.Warning(\"[WaterTool] Do not use S&box gizmos to control the size of the water quad, please use the intended: Width, Length & Depth property in the editor!\");\r\n\t\t\t}\r\n\r\n\t\t\tif (m_HullCollider.BoxSize != m_LastHullBoxSize)\r\n\t\t\t{\r\n\t\t\t\tm_HullCollider.BoxSize = m_LastHullBoxSize;\r\n\t\t\t\t\r\n\t\t\t\tLog.Warning(\"[WaterTool] Do not use S&box gizmos to control the size of the water quad, please use the intended: Width, Length & Depth property in the editor!\");\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tUpdateShaderAttributes();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif (!Gizmo.IsSelected)\r\n\t\t\treturn;\r\n\r\n\t\tif (!m_HullCollider.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tGizmo.Draw.Color = Color.Cyan;\r\n\r\n\t\tif (CircleShape)\r\n\t\t{\r\n\t\t\tVector3 pointA = m_HullCollider.Center;\r\n\t\t\tpointA.z -= m_HullCollider.Height / 2.0f;\r\n\r\n\t\t\tVector3 pointB = m_HullCollider.Center;\r\n\t\t\tpointB.z += m_HullCollider.Height / 2.0f;\r\n\r\n\t\t\tGizmo.Draw.LineCylinder(pointA, pointB, m_HullCollider.Radius, m_HullCollider.Radius2, CircleSegments);\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tGizmo.Draw.LineBBox(m_HullCollider.LocalBounds);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate int ComputeConfigHash()\r\n\t{\r\n\t\treturn HashCode.Combine(Width, Length, BaseCellSize, CellsPerRing, CircleShape, CircleSegments, m_LodLevel);\r\n\t}\r\n\r\n\r\n\r\n\tprivate int ComputeRingCount()\r\n\t{\r\n\t\treturn ComputeRingCount(Width, Length);\r\n\t}\r\n\r\n\r\n\r\n\tprivate int ComputeRingCount(float _Width, float _Length)\r\n\t{\r\n\t\tfloat maxDim = MathF.Max(_Length, _Width);\r\n\r\n\t\t// Authored product on purpose: LOD preserves CellsPerRing * BaseCellSize exactly, so the\r\n\t\t// ring layout and coverage stay identical across levels \u2014 only the density changes.\r\n\t\tfloat innerExtent = CellsPerRing * BaseCellSize;\r\n\r\n\t\tfloat requiredExtent = maxDim * 2.0f;\r\n\r\n\t\tif (requiredExtent <= innerExtent)\r\n\t\t\treturn 1;\r\n\r\n\t\tint rings = (int)MathF.Ceiling(MathF.Log2(requiredExtent / innerExtent)) + 1;\r\n\r\n\t\treturn Math.Clamp(rings, 1, MAX_RINGS);\r\n\t}\r\n\r\n\r\n\r\n\tprivate float OuterExtent\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif (CircleShape)\r\n\t\t\t\treturn MathF.Min(Width, Length) / 2.0f;\r\n\r\n\t\t\tint ringCount = ComputeRingCount();\r\n\r\n\t\t\t// Authored product (LOD-invariant) so texture tiling doesn't shift on a level change\r\n\t\t\treturn CellsPerRing * BaseCellSize * (1 << (ringCount - 1));\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateBuffers()\r\n\t{\r\n\t\tint configHash = ComputeConfigHash();\r\n\r\n\t\tif (configHash != m_LastConfigHash)\r\n\t\t{\r\n\t\t\tCreateBuffers();\r\n\r\n\t\t\tm_LastConfigHash = configHash;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateBuffers()\r\n\t{\r\n\t\tif (CircleShape)\r\n\t\t{\r\n\t\t\tBuildCircleBuffers();\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint n = EffectiveCellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\t\tint innerBlockSize = innerEnd - innerStart;\r\n\t\tint filledCells = n * n;\r\n\t\tint hollowCells = filledCells - (innerBlockSize * innerBlockSize);\r\n\r\n\t\tint totalIndices = filledCells * 6;\r\n\t\ttotalIndices += (ringCount - 1) * hollowCells * 6;\r\n\r\n\t\tm_VertexBuffer = new GpuBuffer<WaterVertex>(ringCount * verticesPerRing, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);\r\n\t\tm_IndexBuffer = new GpuBuffer<uint>(totalIndices, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);\r\n\r\n\t\tUploadIndexBuffer(ringCount);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RefreshRenderBuffers()\r\n\t{\r\n\t\tCreateBuffers();\r\n\r\n\t\tm_LastConfigHash = ComputeConfigHash();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildCircleBuffers()\r\n\t{\r\n\t\tfloat radius = MathF.Min(Width, Length) / 2.0f;\r\n\t\tint M = ComputeCircleGridWidth();\r\n\t\tm_CircleGridWidth = M;\r\n\r\n\t\tfloat cellSize = (radius * 2.0f) / M;   // M cells span the full diameter\r\n\t\tfloat half = M * cellSize * 0.5f;        // == radius (grid centred on the circle)\r\n\t\tfloat r2 = radius * radius;\r\n\r\n\t\t// \"Minecraft circle\": a uniform, world-axis-aligned grid of square cells, masked\r\n\t\t// to a circular boundary. Because the vertices live on the same grid as a\r\n\t\t// rectangular quad, wave displacement behaves identically (no polar pinching).\r\n\t\tint verticesPerSide = M + 1;\r\n\t\tint vertexCount = verticesPerSide * verticesPerSide;\r\n\t\tm_VertexBuffer = new GpuBuffer<WaterVertex>(vertexCount, GpuBuffer.UsageFlags.Vertex | GpuBuffer.UsageFlags.Structured);\r\n\r\n\t\tvar indices = new List<uint>();\r\n\r\n\t\t// Emit a cell's two triangles only when its centre falls inside the circle\r\n\t\tfor (int y = 0; y < M; y++)\r\n\t\t{\r\n\t\t\tfor (int x = 0; x < M; x++)\r\n\t\t\t{\r\n\t\t\t\tfloat cx = (x + 0.5f) * cellSize - half;\r\n\t\t\t\tfloat cy = (y + 0.5f) * cellSize - half;\r\n\r\n\t\t\t\tif (cx * cx + cy * cy > r2)\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tuint i0 = (uint)(y * verticesPerSide + x);\r\n\t\t\t\tuint i1 = i0 + 1;\r\n\t\t\t\tuint i2 = i0 + (uint)verticesPerSide;\r\n\t\t\t\tuint i3 = i2 + 1;\r\n\r\n\t\t\t\tindices.Add(i0); indices.Add(i1); indices.Add(i2);\r\n\t\t\t\tindices.Add(i1); indices.Add(i3); indices.Add(i2);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tm_IndexBuffer = new GpuBuffer<uint>(indices.Count, GpuBuffer.UsageFlags.Index | GpuBuffer.UsageFlags.Structured);\r\n\t\tm_IndexBuffer.SetData(indices);\r\n\t\tm_TotalIndexCount = indices.Count;\r\n\t}\r\n\r\n\r\n\r\n\t// Number of grid cells across the circle's diameter, driven by BaseCellSize so the\r\n\t// blockiness matches the rest of the water \u2014 smaller cells = finer (rounder) edge.\r\n\tprivate int ComputeCircleGridWidth()\r\n\t{\r\n\t\tfloat diameter = MathF.Min(Width, Length);\r\n\t\tint cells = (int)MathF.Ceiling(diameter / EffectiveBaseCellSize);\r\n\t\treturn Math.Clamp(cells, 1, 256);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UploadIndexBuffer(int _RingCount)\r\n\t{\r\n\t\tint n = EffectiveCellsPerRing;\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tint innerStart = n / 4 + 1;\r\n\t\tint innerEnd = n * 3 / 4 - 1;\r\n\r\n\t\tvar indices = new List<uint>();\r\n\r\n\t\tfor (int ring = 0; ring < _RingCount; ring++)\r\n\t\t{\r\n\t\t\tuint baseVertex = (uint)(ring * verticesPerRing);\r\n\r\n\t\t\tfor (int y = 0; y < n; y++)\r\n\t\t\t{\r\n\t\t\t\tfor (int x = 0; x < n; x++)\r\n\t\t\t\t{\r\n\t\t\t\t\tif (ring > 0 && x >= innerStart && x < innerEnd && y >= innerStart && y < innerEnd)\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tuint i0 = baseVertex + (uint)(y * (n + 1) + x);\r\n\t\t\t\t\tuint i1 = i0 + 1;\r\n\t\t\t\t\tuint i2 = i0 + (uint)(n + 1);\r\n\t\t\t\t\tuint i3 = i2 + 1;\r\n\r\n\t\t\t\t\tindices.Add(i0);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t\tindices.Add(i1);\r\n\t\t\t\t\tindices.Add(i3);\r\n\t\t\t\t\tindices.Add(i2);\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tm_IndexBuffer.SetData(indices);\r\n\r\n\t\tm_TotalIndexCount = indices.Count;\r\n\t}\r\n\r\n\r\n\r\n\tinternal bool HasValidBuffers => m_VertexBuffer.IsValid() && m_IndexBuffer.IsValid();\r\n\r\n\tinternal bool ParticipatesInRendering => Material.IsValid();\r\n\t\r\n\t\r\n\t\r\n\tinternal BBox GetWorldBounds2D()\r\n\t{\r\n\t\tVector3 right = WorldRotation.Right * (Length / 2.0f);\r\n\t\tVector3 forward = WorldRotation.Forward * (Width / 2.0f);\r\n\r\n\t\tVector3 c0 = WorldPosition + right + forward;\r\n\t\tVector3 c1 = WorldPosition - right + forward;\r\n\t\tVector3 c2 = WorldPosition + right - forward;\r\n\t\tVector3 c3 = WorldPosition - right - forward;\r\n\r\n\t\tfloat minX = MathF.Min(MathF.Min(c0.x, c1.x), MathF.Min(c2.x, c3.x));\r\n\t\tfloat maxX = MathF.Max(MathF.Max(c0.x, c1.x), MathF.Max(c2.x, c3.x));\r\n\t\tfloat minY = MathF.Min(MathF.Min(c0.y, c1.y), MathF.Min(c2.y, c3.y));\r\n\t\tfloat maxY = MathF.Max(MathF.Max(c0.y, c1.y), MathF.Max(c2.y, c3.y));\r\n\r\n\t\treturn new BBox(new Vector3(minX, minY, WorldPosition.z - Depth), new Vector3(maxX, maxY, WorldPosition.z));\r\n\t}\r\n\r\n\r\n\r\n\t// Records the clipmap compute dispatches into the command list as DEFERRED commands -\r\n\t// see WaterBodyRenderer.RecordCompute for why per-ring attributes go through the list.\r\n\tinternal void RecordCompute(CommandList _CommandList, ComputeShader _Shader, Vector3 _CameraPosition)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\r\n\t\tfloat outerExtent = OuterExtent;\r\n\r\n\t\tif (CircleShape)\r\n\t\t{\r\n\t\t\tint M = m_CircleGridWidth;\r\n\t\t\tint verticesPerSide = M + 1;\r\n\t\t\tfloat cellSize = MathF.Min(Width, Length) / M;   // M cells span the diameter\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"VertexBuffer\", m_VertexBuffer);\r\n\t\t\t_CommandList.Attributes.Set(\"VertexOffset\", 0);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"GridWidth\", M);\r\n\t\t\t_CommandList.Attributes.Set(\"CellSize\", cellSize);\r\n\r\n\t\t\t// Static grid centred on the quad \u2014 the circular pool doesn't follow the camera\r\n\t\t\t_CommandList.Attributes.Set(\"SnapPosition\", (Vector2)WorldPosition);\r\n\t\t\t_CommandList.Attributes.Set(\"WaterZ\", WorldPosition.z);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"TilingScale\", 1.0f / outerExtent);\r\n\t\t\t_CommandList.Attributes.Set(\"ClampToBounds\", false);\r\n\r\n\t\t\t_CommandList.DispatchCompute(_Shader, verticesPerSide * verticesPerSide, 1, 1);\r\n\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tint ringCount = ComputeRingCount();\r\n\t\tint verticesPerRing = VerticesPerRing;\r\n\r\n\t\tvar localBounds = GetWorldBounds2D();\r\n\t\tfloat boundsMinX = localBounds.Mins.x;\r\n\t\tfloat boundsMaxX = localBounds.Maxs.x;\r\n\t\tfloat boundsMinY = localBounds.Mins.y;\r\n\t\tfloat boundsMaxY = localBounds.Maxs.y;\r\n\r\n\t\tfor (int ring = 0; ring < ringCount; ring++)\r\n\t\t{\r\n\t\t\tfloat cellSize = EffectiveBaseCellSize * (1 << ring);\r\n\r\n\t\t\tVector3 clipmapAnchor = FollowCameraForClipmap ? _CameraPosition : WorldPosition;\r\n\r\n\t\t\tfloat snapX = MathF.Floor(clipmapAnchor.x / cellSize) * cellSize;\r\n\t\t\tfloat snapY = MathF.Floor(clipmapAnchor.y / cellSize) * cellSize;\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"VertexBuffer\", m_VertexBuffer);\r\n\t\t\t_CommandList.Attributes.Set(\"VertexOffset\", ring * verticesPerRing);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"GridWidth\", EffectiveCellsPerRing);\r\n\t\t\t_CommandList.Attributes.Set(\"CellSize\", cellSize);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"SnapPosition\", new Vector2(snapX, snapY));\r\n\t\t\t_CommandList.Attributes.Set(\"WaterZ\", WorldPosition.z);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"TilingScale\", 1.0f / outerExtent);\r\n\t\t\t_CommandList.Attributes.Set(\"ClampToBounds\", true);\r\n\r\n\t\t\t_CommandList.Attributes.Set(\"BoundsMin\", new Vector2(boundsMinX, boundsMinY));\r\n\t\t\t_CommandList.Attributes.Set(\"BoundsMax\", new Vector2(boundsMaxX, boundsMaxY));\r\n\r\n\t\t\t_CommandList.DispatchCompute(_Shader, verticesPerRing, 1, 1);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tinternal void BarrierTransition(CommandList _CommandList)\r\n\t{\r\n\t\tif (m_VertexBuffer.IsValid())\r\n\t\t\t_CommandList?.ResourceBarrierTransition(m_VertexBuffer, ResourceState.UnorderedAccess, ResourceState.VertexOrIndexBuffer);\r\n\t}\r\n\r\n\r\n\r\n\tinternal void Draw(CommandList _CommandList)\r\n\t{\r\n\t\tif (!ParticipatesInRendering || !HasValidBuffers)\r\n\t\t\treturn;\r\n\t\t\r\n\t\t_CommandList?.DrawIndexed(m_VertexBuffer, m_IndexBuffer, Material, 0, m_TotalIndexCount, m_DrawAttributes);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateColliderState()\r\n\t{\r\n\t\tm_HullCollider = GetOrAddComponent<HullCollider>();\r\n\t\tm_HullCollider.Flags |= ComponentFlags.Hidden;\r\n\t\tm_HullCollider.Static = true;\r\n\r\n\t\tm_HullCollider.Type = CircleShape ? HullCollider.PrimitiveType.Cylinder : HullCollider.PrimitiveType.Box;\r\n\r\n\t\tm_HullCollider.Center = new Vector3(0, 0, -Depth / 2.0f);\r\n\r\n\t\tif (CircleShape)\r\n\t\t{\r\n\t\t\tm_HullCollider.Radius = MathF.Min(Width, Length) / 2.0f;\r\n\t\t\tm_HullCollider.Radius2 = MathF.Min(Width, Length) / 2.0f;\r\n\t\t\tm_HullCollider.Height = Depth;\r\n\t\t\tm_HullCollider.Slices = CircleSegments;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tm_HullCollider.BoxSize = new Vector3(Width, Length, Depth);\r\n\t\t}\r\n\t\t\r\n\t\tm_LastHullCenter = m_HullCollider.Center;\r\n\t\tm_LastHullBoxSize = m_HullCollider.BoxSize;\r\n\r\n\t\tm_HullCollider.IsTrigger = true;\r\n\r\n\t\tTags.Add(\"water\");\r\n\t}\r\n\r\n\r\n\r\n\tinternal (Vector3 Center, Vector3 Forward, Vector3 Up, Vector3 HalfExtents) GetWorldOBB()\r\n\t{\r\n\t\treturn (\r\n\t\t\tWorldPosition + (WorldTransform.Up * (-Depth * 0.5f)),\r\n\t\t\tWorldRotation.Forward,\r\n\t\t\tWorldTransform.Up,\r\n\t\t\tnew Vector3(Width * 0.5f, Length * 0.5f, Depth * 0.5f)\r\n\t\t);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateShaderAttributes()\r\n\t{\r\n\t\tm_DrawAttributes.Set(\"RequireWaterInclusionVolumes\", false);\r\n\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\tif (profile.IsValid())\r\n\t\t\tprofile.ApplyTo(m_DrawAttributes);\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterTime\", Time.Now);\r\n\t\tm_DrawAttributes.Set(\"DepthMax\", Depth);\r\n\r\n\t\tfloat outerExtent = OuterExtent;\r\n\r\n\t\tVector2 tiling = new Vector2((outerExtent / BASE_TILE_SIZE) * TextureTilingMultiplier, (outerExtent / BASE_TILE_SIZE) * TextureTilingMultiplier);\r\n\r\n\t\tm_DrawAttributes.Set(\"NormalTiling\", tiling);\r\n\r\n\t\tWaterManager.Current?.ApplyRippleAttributes(m_DrawAttributes);\r\n\t\tWaterManager.Current?.ApplyCalmAttributes(m_DrawAttributes);\r\n\t\t\r\n\t\t// Band-limit the wave normal to the local clipmap vertex spacing (see shader)\r\n\t\t// Uses the EFFECTIVE grid: the normal's finite-difference step has to track the real\r\n\t\t// vertex spacing, which coarsens with the LOD level. Feeding the authored values here\r\n\t\t// would reconstruct detail the LODed mesh can't represent \u2014 the static world-locked\r\n\t\t// moir\u00e9 pattern all over again, worst exactly where LOD kicks in.\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsScale\", 3.0f / EffectiveCellsPerRing);\r\n\t\tm_DrawAttributes.Set(\"WaveNormalEpsMin\", EffectiveBaseCellSize);\r\n\r\n\t\tSetWaterExclusionVolumes(WaterManager.GetViewPosition(Scene, WorldPosition));\r\n\t\tSetHullExclusionVolumes();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void SetWaterExclusionVolumes(Vector3 _ReferencePosition)\r\n\t{\r\n\t\tif (WaterManager.Current == null)\r\n\t\t\treturn;\r\n\r\n\t\tEnsureWaterExclusionVolumeBuffer();\r\n\r\n\t\tvar volumes = WaterManager.Current.ExclusionVolumes\r\n\t\t\t.Where(v => v.IsValid() && v.Active)\r\n\t\t\t.OrderBy(v => v.WorldPosition.DistanceSquared(_ReferencePosition))\r\n\t\t\t.Take(MAX_WATER_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\r\n\t\tfor (int i = 0; i < volumes.Count; i++)\r\n\t\t{\r\n\t\t\tvar (center, forward, up, half) = volumes[i].GetWorldOBB();\r\n\r\n\t\t\tint rowOffset = i * WATER_EXCLUSION_VOLUME_ROWS;\r\n\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 0] = new Vector4(forward.x, forward.y, forward.z, half.x);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 1] = new Vector4(up.x, up.y, up.z, half.y);\r\n\t\t\tm_WaterExclusionVolumeData[rowOffset + 2] = new Vector4(center.x, center.y, center.z, half.z);\r\n\t\t}\r\n\r\n\t\tm_WaterExclusionVolumeBuffer.SetData(m_WaterExclusionVolumeData.AsSpan(0, volumes.Count * WATER_EXCLUSION_VOLUME_ROWS));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeCount\", volumes.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterExclusionVolumeRows\", m_WaterExclusionVolumeBuffer);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureWaterExclusionVolumeBuffer()\r\n\t{\r\n\t\tif (m_WaterExclusionVolumeBuffer.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tm_WaterExclusionVolumeBuffer = new GpuBuffer<Vector4>(MAX_WATER_EXCLUSION_VOLUMES * WATER_EXCLUSION_VOLUME_ROWS);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void SetHullExclusionVolumes()\r\n\t{\r\n\t\tif (WaterManager.Current == null)\r\n\t\t\treturn;\r\n\r\n\t\tvar hulls = WaterManager.Current.HullExclusionVolumes\r\n\t\t\t.Where(h => h.IsValid() && h.Active && h.LocalTriangles.Length > 0)\r\n\t\t\t.Take(MAX_HULL_EXCLUSION_VOLUMES)\r\n\t\t\t.ToList();\r\n\t\t\r\n\t\tif (hulls.Count == 0)\r\n\t\t{\r\n\t\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", 0);\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureHullExclusionBuffers();\r\n\r\n\t\t// Triangles are written after the fixed-size metadata section\r\n\t\tint triWriteCursor = HULL_EXCLUSION_META_SIZE;\r\n\r\n\t\tfor (int h = 0; h < hulls.Count; h++)\r\n\t\t{\r\n\t\t\tvar hull = hulls[h];\r\n\t\t\tvar tris = hull.LocalTriangles;\r\n\t\t\tint triCount = tris.Length / 3;\r\n\r\n\t\t\tif (triWriteCursor + tris.Length > m_HullExclusionData.Length)\r\n\t\t\t\tbreak;\r\n\r\n\t\t\thull.GetWorldToLocalRows(out var r0, out var r1, out var r2, out var r3);\r\n\r\n\t\t\tint meta = h * HULL_EXCLUSION_META_ROWS;\r\n\t\t\tm_HullExclusionData[meta + 0] = r0;\r\n\t\t\tm_HullExclusionData[meta + 1] = r1;\r\n\t\t\tm_HullExclusionData[meta + 2] = r2;\r\n\t\t\tm_HullExclusionData[meta + 3] = r3;\r\n\r\n\t\t\tvar aabb = hull.LocalAABB;\r\n\t\t\t// vertStart is an absolute index into the combined buffer\r\n\t\t\tm_HullExclusionData[meta + 4] = new Vector4(triWriteCursor, triCount, aabb.Mins.x, aabb.Mins.y);\r\n\t\t\tm_HullExclusionData[meta + 5] = new Vector4(aabb.Mins.z, aabb.Maxs.x, aabb.Maxs.y, aabb.Maxs.z);\r\n\r\n\t\t\tfor (int i = 0; i < tris.Length; i++)\r\n\t\t\t\tm_HullExclusionData[triWriteCursor + i] = new Vector4(tris[i].x, tris[i].y, tris[i].z, 0f);\r\n\r\n\t\t\ttriWriteCursor += tris.Length;\r\n\t\t}\r\n\r\n\t\tm_HullExclusionBuffer.SetData(m_HullExclusionData.AsSpan(0, triWriteCursor));\r\n\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionCount\", hulls.Count);\r\n\t\tm_DrawAttributes.Set(\"WaterHullExclusionData\", m_HullExclusionBuffer);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureHullExclusionBuffers()\r\n\t{\r\n\t\tif (!m_HullExclusionBuffer.IsValid())\r\n\t\t\tm_HullExclusionBuffer = new GpuBuffer<Vector4>(HULL_EXCLUSION_META_SIZE + MAX_HULL_EXCLUSION_TRIS * 3, GpuBuffer.UsageFlags.Structured);\r\n\t}\r\n\r\n\r\n\r\n\tpublic Vector3 GetWaveDisplacementAt(Vector3 _WorldPosition)\r\n\t{\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\treturn WaterWaveUtility.ComputeDisplacementAt(_WorldPosition, profile);\r\n\t}\r\n\r\n\r\n\r\n\tpublic Vector3 GetWaveVelocityAt(Vector3 _WorldPosition)\r\n\t{\r\n\t\tWaterDefinition profile = WaterManager.GetWaveProfile(WaterType);\r\n\r\n\t\treturn WaterWaveUtility.ComputeVelocityAt(_WorldPosition, profile);\r\n\t}\r\n\r\n\r\n\r\n\tpublic float GetWaveHeightAt(Vector3 _WorldPosition)\r\n\t{\r\n\t\treturn WorldPosition.z + GetWaveDisplacementAt(_WorldPosition).z;\r\n\t}\r\n}\r\n"
        }
    ]
}