Component that renders periodic ripple overlays on a mud water plane and mildly wobbles the plane texture. It spawns a pooled set of quad renderers with a ring material at random positions inside the renderer model bounds, scales and fades them over time, and optionally animates a private copy of the plane material UV offset.
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;
}
}