OceanSpray.cs
using System;
using Sandbox.Rendering;
namespace OceanLib;
/// <summary>
/// Spray where a hull cuts into the sea: when a bow slams into a wave face, the water it shoves aside is thrown up and
/// out in torn white sheets, a scatter of droplets and a drifting mist, which arc over, fall back and are gone in a
/// second or two - the wave breaking on the boat. The sea itself doesn't deform; the spray
/// and the foam (<see cref="OceanFoamSource.Splash"/>) sell it. <see cref="OceanBuoyancy"/> calls <see cref="Emit"/>.
///
/// The particles live on the CPU - a few thousand at most, cheap to move - and are drawn as one batch of camera-facing
/// quads with the ocean_spray shader, from a camera command list after the scene's transparent things. They are lit by
/// the scene's sun and fog colours, so they go grey with the storm.
/// </summary>
[Title( "Ocean Spray" ), Category( "Ocean" ), Icon( "water_drop" )]
public sealed class OceanSpray : Component
{
/// <summary>A material on the ocean_spray shader. Empty uses the library's.</summary>
[Property] public Material Material { get; set; }
/// <summary>The most particles alive at once; older ones make way when it's full.</summary>
[Property, Range( 100, 8000 )] public int MaxParticles { get; set; } = 3000;
/// <summary>Scales every burst: more sheets, more droplets.</summary>
[Property, Range( 0, 4 )] public float Amount { get; set; } = 1.0f;
/// <summary>Scales how fast and high the spray is thrown.</summary>
[Property, Range( 0.2f, 3 )] public float Force { get; set; } = 1.0f;
/// <summary>The sun lighting the spray. Empty takes the scene's first.</summary>
[Property] public DirectionalLight Sun { get; set; }
/// <summary>
/// The fog whose colour shades the spray's underside, when it has a constant colour. Empty takes the scene's;
/// without one - or with a fog coloured by the sky - the sun's sky light shades it instead.
/// </summary>
[Property] public Fog Fog { get; set; }
/// <summary>The first active spray in the scene, for anything that wants to throw some.</summary>
public static OceanSpray Current { get; private set; }
/// <summary>Particles alive now.</summary>
public int Count => m_Count;
private enum Kind { Sheet = 0, Droplet = 1, Mist = 2 }
private struct Particle
{
public Vector3 Position, Velocity;
public float Age, Life, Size, Grow, Opacity, Seed, Floor, Drag;
public Kind Kind;
}
private const float UnitsPerMetre = Ocean.UnitsPerMetre;
private const int DrawOrder = 105;
private Particle[] m_Particles = Array.Empty<Particle>();
private int m_Count;
private Vertex[] m_Vertices = Array.Empty<Vertex>();
private Mesh m_Mesh;
private Model m_Model;
private readonly CameraCommands m_Commands = new( "Ocean Spray", Stage.AfterTransparent, DrawOrder );
private readonly RenderAttributes m_Attributes = new();
protected override void OnEnabled()
{
Current ??= this;
Allocate();
}
protected override void OnDisabled()
{
if ( Current == this )
Current = null;
m_Commands.Dispose();
m_Model = null;
}
private void Allocate()
{
int max = Math.Clamp( MaxParticles, 100, 8000 );
m_Particles = new Particle[max];
m_Vertices = new Vertex[max * 4];
m_Count = 0;
var material = Material ?? Material.Load( "ocean/materials/ocean_spray.vmat" );
if ( !material.IsValid() )
return;
var indices = new int[max * 6];
for ( int p = 0; p < max; p++ )
{
int v = p * 4, i = p * 6;
indices[i] = v; indices[i + 1] = v + 1; indices[i + 2] = v + 2;
indices[i + 3] = v; indices[i + 4] = v + 2; indices[i + 5] = v + 3;
}
m_Mesh = new Mesh( material );
m_Mesh.CreateVertexBuffer<Vertex>( m_Vertices.Length, m_Vertices.AsSpan() );
m_Mesh.CreateIndexBuffer( indices.Length, indices.AsSpan() );
m_Mesh.Bounds = new BBox( new Vector3( -1e7f ), new Vector3( 1e7f ) );
m_Model = Model.Builder.AddMesh( m_Mesh ).Create();
}
/// <summary>
/// Throws a burst of spray from a point on the waterline: up, out along <paramref name="outward"/> (away from the
/// hull) and carried on with the hull's own velocity. Strength 1 is a solid slam into a wave; 3 is the biggest.
/// </summary>
public void Emit( Vector3 at, Vector3 hullVelocity, Vector3 outward, float strength )
{
if ( m_Particles.Length == 0 || strength <= 0.0f )
return;
strength = MathF.Min( strength, 3.0f );
outward = outward.WithZ( 0 );
outward = outward.Length > 1e-3f ? outward.Normal : Vector3.Forward;
var along = new Vector3( -outward.y, outward.x, 0 );
float force = Force * UnitsPerMetre * MathF.Sqrt( strength );
var carried = hullVelocity * 0.7f;
int sheets = Spread( 3.0f * strength * Amount );
int droplets = Spread( 14.0f * strength * Amount );
int mists = Spread( 1.5f * strength * Amount );
for ( int i = 0; i < sheets; i++ )
{
var dir = outward * Game.Random.Float( 1.5f, 4.0f ) + along * Game.Random.Float( -1.5f, 1.5f ) + Vector3.Up * Game.Random.Float( 3.0f, 6.5f );
Add( Kind.Sheet, at + along * Game.Random.Float( -40, 40 ), carried + dir * force, Game.Random.Float( 0.7f, 1.2f ),
Game.Random.Float( 1.2f, 2.2f ) * UnitsPerMetre * MathF.Sqrt( strength ), 3.0f * UnitsPerMetre, 1.0f, 0.6f );
}
for ( int i = 0; i < droplets; i++ )
{
var dir = outward * Game.Random.Float( 1.0f, 5.0f ) + along * Game.Random.Float( -2.0f, 2.0f ) + Vector3.Up * Game.Random.Float( 3.0f, 8.5f );
Add( Kind.Droplet, at + along * Game.Random.Float( -50, 50 ), carried + dir * force, Game.Random.Float( 0.9f, 1.8f ),
Game.Random.Float( 0.08f, 0.16f ) * UnitsPerMetre, 0.0f, 1.0f, 0.05f );
}
for ( int i = 0; i < mists; i++ )
{
var dir = outward * Game.Random.Float( 0.5f, 1.5f ) + Vector3.Up * Game.Random.Float( 1.0f, 2.5f );
Add( Kind.Mist, at + Vector3.Up * 40, carried * 0.5f + dir * force * 0.6f, Game.Random.Float( 1.8f, 3.0f ),
Game.Random.Float( 2.0f, 3.5f ) * UnitsPerMetre, 2.5f * UnitsPerMetre, 0.5f, 1.2f );
}
}
/// <summary>A count from an average: the fraction becomes the chance of one more.</summary>
private static int Spread( float average ) => (int)average + (Game.Random.Float() < average - MathF.Floor( average ) ? 1 : 0);
private void Add( Kind kind, Vector3 at, Vector3 velocity, float life, float size, float grow, float opacity, float drag )
{
// Full: the oldest particle makes way.
int slot = m_Count;
if ( m_Count >= m_Particles.Length )
{
slot = 0;
for ( int i = 1; i < m_Count; i++ )
if ( m_Particles[i].Age / m_Particles[i].Life > m_Particles[slot].Age / m_Particles[slot].Life )
slot = i;
}
else
{
m_Count++;
}
m_Particles[slot] = new Particle
{
Kind = kind, Position = at, Velocity = velocity, Life = life, Size = size, Grow = grow, Opacity = opacity,
Drag = drag, Seed = Game.Random.Float(), Floor = at.z - 40.0f, Age = 0.0f
};
}
protected override void OnUpdate()
{
if ( m_Particles.Length != Math.Clamp( MaxParticles, 100, 8000 ) || (m_Model is null && Material.IsValid()) )
Allocate();
float dt = Time.Delta;
var gravity = Scene.PhysicsWorld.Gravity;
for ( int i = 0; i < m_Count; )
{
ref var p = ref m_Particles[i];
p.Age += dt;
p.Velocity += gravity * (p.Kind == Kind.Mist ? 0.05f : 1.0f) * dt;
p.Velocity *= MathF.Max( 0.0f, 1.0f - p.Drag * dt );
p.Position += p.Velocity * dt;
p.Size += p.Grow * dt;
// Gone once its time is up, or once it has fallen back into the sea.
if ( p.Age >= p.Life || (p.Velocity.z < 0 && p.Position.z < p.Floor) )
{
m_Particles[i] = m_Particles[--m_Count];
continue;
}
i++;
}
}
protected override void OnPreRender()
{
m_Commands.List.Reset();
m_Commands.Follow( Scene );
var camera = m_Commands.Camera;
if ( m_Model is null || m_Mesh is null || m_Count == 0 || !camera.IsValid() )
return;
var right = camera.WorldRotation.Right;
var up = camera.WorldRotation.Up;
var forward = camera.WorldRotation.Forward;
for ( int i = 0; i < m_Count; i++ )
{
ref var p = ref m_Particles[i];
float t = p.Age / p.Life;
float half = p.Size * 0.5f;
// Fades in at once and out over its last part; sheets thin out as they spread.
float fade = MathF.Min( t * 12.0f, 1.0f ) * MathF.Min( (1.0f - t) * 3.0f, 1.0f );
float opacity = p.Opacity * fade * (p.Kind == Kind.Sheet ? 1.0f - t * 0.5f : 1.0f);
var axisX = right * half;
var axisY = up * half;
// Droplets stretch along their flight as seen from the camera.
if ( p.Kind == Kind.Droplet )
{
var flight = p.Velocity - forward * Vector3.Dot( p.Velocity, forward );
float speed = flight.Length;
if ( speed > 1.0f )
{
var dir = flight / speed;
axisY = dir * half * (1.0f + speed / UnitsPerMetre * 0.25f);
axisX = Vector3.Cross( dir, forward ).Normal * half;
}
}
var packed = new Vector4( 0, 0, opacity, (int)p.Kind + p.Seed * 0.099f );
int v = i * 4;
m_Vertices[v + 0] = Corner( p.Position - axisX - axisY, -1, -1, opacity, packed.w );
m_Vertices[v + 1] = Corner( p.Position + axisX - axisY, 1, -1, opacity, packed.w );
m_Vertices[v + 2] = Corner( p.Position + axisX + axisY, 1, 1, opacity, packed.w );
m_Vertices[v + 3] = Corner( p.Position - axisX + axisY, -1, 1, opacity, packed.w );
}
m_Mesh.SetVertexBufferData<Vertex>( m_Vertices.AsSpan( 0, m_Count * 4 ), 0 );
m_Mesh.SetIndexRange( 0, m_Count * 6 );
// Lit by the sun on top, the fog's or the sky's grey underneath: white in the sunshine, slate in a storm.
var sun = Sun.IsValid() ? Sun : Scene.GetAllComponents<DirectionalLight>().FirstOrDefault();
var fog = Fog.IsValid() ? Fog : Scene.GetAllComponents<Fog>().FirstOrDefault();
var light = sun.IsValid() ? sun.LightColor : Color.White;
var shade = fog.IsValid() && fog.ColorSource == FogColorSource.Constant ? fog.Color
: sun.IsValid() ? sun.SkyColor
: new Color( 0.55f, 0.62f, 0.68f );
// White water stays the brightest thing on a grey sea: a floor of light keeps it from sinking into the gloom.
var sky = new Vector3( shade.r, shade.g, shade.b );
var lit = new Vector3( light.r, light.g, light.b ) * 0.7f + sky * 0.6f + new Vector3( 0.12f );
m_Attributes.Set( "SprayLight", lit );
m_Attributes.Set( "SprayShadow", sky * 0.9f + new Vector3( 0.05f ) );
m_Commands.List.DrawModel( m_Model, global::Transform.Zero, m_Attributes );
}
private static Vertex Corner( Vector3 position, float u, float v, float opacity, float kind )
{
return new Vertex
{
Position = position,
Normal = Vector3.Up,
Tangent = new Vector4( 1, 0, 0, 1 ),
TexCoord0 = new Vector4( u, v, 0, 0 ),
TexCoord1 = new Vector4( opacity, kind, 0, 0 ),
Color = Color.White,
};
}
}