Code/MudWaterRipple.cs

A component that adds visual ripples and a subtle wobble to a flat water plane. It spawns a pool of overlay quad renderers that scale and fade to simulate expanding rings, and optionally animates a private copy of the plane material's UV offset to produce a small wobble.

File Access
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Ripples on a mud water plane. Every Interval (a random time between the min and max) a
/// shockwave starts at a random point on the plane: a flat overlay quad carrying a ring texture
/// (three concentric rings in its alpha and normal map, from Tools/make_mud_water.py) is placed on
/// the surface, scaled up at Ring Speed until it reaches Max Radius, and fades to nothing on the
/// way, about two seconds at the defaults. The overlay's fade is the renderer's Tint alpha; the
/// complex shader has no attribute to drive a ripple inside the water material itself, so the
/// overlay is the cheap route in this build. Nothing is networked, every machine ripples on its
/// own; a small pool of overlays, no allocation per ripple. Put it on the object that renders the
/// water plane: the plane's extent is that renderer's model bounds.
///
/// Between ripples the water also sways in place: the plane's material is copied at start and the
/// copy's texture offset is moved round a tiny circle every frame (the complex shader has no
/// wobble or animated normal option, only a straight scroll, so this is done from code). Nothing
/// flows, the texture only drifts back and forth by a fraction of a texel.
/// </summary>
[Title( "Mud Water Ripple" ), Category( "Golf" ), Icon( "water" )]
public sealed class MudWaterRipple : Component
{
	/// <summary>Shortest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Min" )]
	public float IntervalMin { get; set; } = 5f;

	/// <summary>Longest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Max" )]
	public float IntervalMax { get; set; } = 15f;

	/// <summary>How fast the rings spread, units per second.</summary>
	[Property, Range( 10f, 1000f ), Title( "Ring Speed" )]
	public float RingSpeed { get; set; } = 100f;

	/// <summary>How visible the rings are when they start, 0 to 1.</summary>
	[Property, Range( 0f, 1f ), Title( "Ring Strength" )]
	public float RingStrength { get; set; } = 0.8f;

	/// <summary>Radius the rings reach before they are gone, units.</summary>
	[Property, Range( 20f, 2000f ), Title( "Max Radius" )]
	public float MaxRadius { get; set; } = 200f;

	/// <summary>Ripples in flight at once; the pool size.</summary>
	[Property, Range( 1, 8 )]
	public int MaxRipples { get; set; } = 3;

	/// <summary>Sway the water texture in place between ripples.</summary>
	[Property, Group( "Wobble" )]
	public bool Wobble { get; set; } = true;

	/// <summary>How far the texture sways, in UV units; 0.002 is a fraction of a texel.</summary>
	[Property, Group( "Wobble" ), Range( 0f, 0.02f ), Title( "Wobble Radius" )]
	public float WobbleRadius { get; set; } = 0.002f;

	/// <summary>Seconds for one full sway round the circle.</summary>
	[Property, Group( "Wobble" ), Range( 1f, 30f ), Title( "Wobble Period" )]
	public float WobblePeriod { get; set; } = 6f;

	const string QuadModel = "models/mud_water/ripple_quad.vmdl";
	const string RingMaterial = "textures/mud_water/ripple_ring.vmat";
	const float Lift = 0.75f;   // the overlay floats this far above the water so it never z-fights

	ModelRenderer[] _overlays = Array.Empty<ModelRenderer>();
	float[] _born = Array.Empty<float>();
	int _next;
	float _nextAt;
	ModelRenderer _plane;
	Material _planeMaterial;   // what the plane had before the wobble copy, put back on disable
	Material _wobble;

	protected override void OnStart()
	{
		var model = Model.Load( QuadModel );
		var material = Material.Load( RingMaterial );
		if ( model is null || material is null )
		{
			Log.Warning( $"MudWaterRipple: missing {QuadModel} or {RingMaterial}" );
			return;
		}

		var count = Math.Clamp( MaxRipples, 1, 8 );
		_overlays = new ModelRenderer[count];
		_born = new float[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"ripple {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var renderer = go.AddComponent<ModelRenderer>();
			renderer.Model = model;
			renderer.MaterialOverride = material;
			_overlays[i] = renderer;
		}

		_nextAt = Time.Now + Wait();

		// The wobble works on a private copy of the plane's material, so the asset itself is untouched.
		_plane = GetComponent<ModelRenderer>();
		var source = _plane?.MaterialOverride ?? _plane?.Model?.Materials.FirstOrDefault();
		if ( Wobble && source is not null )
		{
			_planeMaterial = _plane.MaterialOverride;
			_wobble = source.CreateCopy( "mud water wobble" );
			_plane.MaterialOverride = _wobble;
		}
	}

	protected override void OnDisabled()
	{
		foreach ( var overlay in _overlays )
			if ( overlay.IsValid() ) overlay.GameObject.Enabled = false;

		if ( _wobble is not null && _plane.IsValid() && _plane.MaterialOverride == _wobble )
			_plane.MaterialOverride = _planeMaterial;
		_wobble = null;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor || _overlays.Length == 0 ) return;

		var now = Time.Now;

		if ( _wobble is not null )
		{
			var angle = now / Math.Max( 0.1f, WobblePeriod ) * MathF.PI * 2f;
			var radius = Wobble ? WobbleRadius : 0f;
			_wobble.Set( "g_vTexCoordOffset", new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );
		}
		var life = MaxRadius / Math.Max( 1f, RingSpeed );

		for ( var i = 0; i < _overlays.Length; i++ )
		{
			var overlay = _overlays[i];
			if ( !overlay.IsValid() || !overlay.GameObject.Enabled ) continue;

			var age = now - _born[i];
			if ( age >= life )
			{
				overlay.GameObject.Enabled = false;
				continue;
			}

			var t = age / life;
			var radius = Math.Max( 1f, RingSpeed * age );
			overlay.GameObject.WorldScale = new Vector3( radius, radius, 1f );
			overlay.Tint = Color.White.WithAlpha( RingStrength * (1f - t) * (1f - t) );
		}

		if ( now < _nextAt ) return;
		_nextAt = now + Wait();
		Spawn();
	}

	float Wait()
	{
		var min = Math.Min( IntervalMin, IntervalMax );
		var max = Math.Max( IntervalMin, IntervalMax );
		return Game.Random.Float( min, max );
	}

	/// <summary>A ripple at a random point on the plane's top surface.</summary>
	void Spawn()
	{
		var i = _next;
		_next = (_next + 1) % _overlays.Length;
		var overlay = _overlays[i];
		if ( !overlay.IsValid() ) return;

		var plane = GetComponent<ModelRenderer>();
		var bounds = plane?.Model?.Bounds ?? new BBox( new Vector3( -100f, -100f, 0f ), new Vector3( 100f, 100f, 0f ) );
		var local = new Vector3(
			Game.Random.Float( bounds.Mins.x, bounds.Maxs.x ),
			Game.Random.Float( bounds.Mins.y, bounds.Maxs.y ),
			bounds.Maxs.z );
		var position = WorldTransform.PointToWorld( local ) + Vector3.Up * Lift;

		overlay.GameObject.WorldPosition = position;
		overlay.GameObject.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
		overlay.GameObject.WorldScale = new Vector3( 1f, 1f, 1f );
		overlay.Tint = Color.White.WithAlpha( RingStrength );
		_born[i] = Time.Now;
		overlay.GameObject.Enabled = true;
	}
}