Fatality/Blood.cs
using System;
using System.Collections.Generic;
using SquishyTerry.Sim;

namespace SquishyTerry;

/// <summary>
/// Blood for the fatality: a small, cheap liquid simulation made for the squishy face.
/// <list type="bullet">
/// <item><b>Drips</b> live on the skin: a position on a triangle (barycentric) that runs downhill
/// along the surface under gravity, walking from triangle to triangle, slowing and thinning as it
/// leaves a trail behind. Because every point is stored on the skin, drips and trails ride along
/// as the face squishes and turns.</item>
/// <item><b>Droplets</b> are thrown from a wound: ballistic particles that splat into new little
/// drips where they land back on the face, or fall away out of view.</item>
/// <item><b>Wounds</b> bleed while open: a growing stain, drips every so often, and spurts when
/// the knife moves.</item>
/// </list>
/// Everything is drawn from one preallocated mesh rewritten each frame (unused slots collapse to
/// nothing), with a glossy material and rounded normals so the trails read as wet beads.
/// </summary>
public sealed class Blood : Component
{
	[Property] public Material Material { get; set; }
	[Property] public Color Color { get; set; } = new( 0.22f, 0.005f, 0.012f );

	/// <summary>World gravity for thrown droplets (units / s²).</summary>
	[Property] public float DropletGravity { get; set; } = 60f;

	/// <summary>How fast a full drip runs down the skin (simulation units / s; the head is ~2 across).</summary>
	[Property] public float DripSpeed { get; set; } = 0.3f;

	/// <summary>Seconds blood stays after it stops moving (or a wound closes) before it fades; 0 = it stays.</summary>
	[Property] public float StainLifetime { get; set; } = 0f;

	/// <summary>Editor testing: tick to open a wound on Terry's cheek (untick to close it).</summary>
	[Property] public bool TestWound { get; set; }

	// Capacity: past these the oldest blood is reused, so they're generous.
	const int MaxDrips = 96;
	const int MaxSpots = 192;
	const int SpotSides = 8;
	const int MaxTrail = 40;
	const int MaxDroplets = 64;
	const int MaxWounds = 16;
	const int BlobSides = 10;
	const int WoundSides = 14;

	const float TrailSpacing = 0.018f;   // simulation units between trail points
	const float DripWidth = 0.5f;        // world units, a full drip
	const float SurfaceLift = 0.4f;      // world units above the skin
	const float Consumption = 1.3f;      // volume used per simulation unit travelled
	const float FadeTime = 2f;
	const int TracesPerFrame = 6;        // droplet-vs-face ray tests allowed per frame

	sealed class Drip
	{
		public bool Alive;
		public int Tri;
		public Vector3 Bary;
		public float Volume, Speed, Phase, Still, Size;
		public Vec3 LastTrail;
		public readonly List<(int Tri, Vector3 Bary, float Width)> Trail = new();
	}

	struct Droplet
	{
		public bool Alive;
		public Vector3 Pos, Vel;
		public float Size, Age;
	}

	sealed class Wound
	{
		public bool Alive, Open;
		public int Tri;
		public Vector3 Bary;
		public float Radius, MaxRadius, NextDrip, Closed, Ooze;
		public int Order;
	}

	/// <summary>A splat where a thrown droplet landed: a small stain on the skin.</summary>
	struct Spot
	{
		public bool Alive;
		public int Tri;
		public Vector3 Bary;
		public float Radius;
	}

	readonly Drip[] _drips = new Drip[MaxDrips];
	readonly Droplet[] _droplets = new Droplet[MaxDroplets];
	readonly Wound[] _wounds = new Wound[MaxWounds];
	readonly Spot[] _spots = new Spot[MaxSpots];
	int _nextSpot, _woundOrder;
	int _nextDrip, _nextDroplet;

	SquishyFace _face;
	int[] _adjacent; // per triangle corner k: the triangle across the edge opposite corner k, or -1

	GameObject _object;
	Mesh _mesh;
	Vertex[] _verts;
	ModelRenderer _renderer;
	int _dripVerts, _woundVerts;
	readonly Random _random = new();

	// --- Public API ---

	/// <summary>Opens a wound at a point on the skin; returns its id.</summary>
	public int OpenWound( int triangle, Vector3 bary )
	{
		// A free slot, else the oldest wound.
		int slot = 0;
		for ( int i = 0; i < MaxWounds; i++ )
		{
			if ( !_wounds[i].Alive ) { slot = i; break; }
			if ( _wounds[i].Order < _wounds[slot].Order ) slot = i;
		}
		var w = _wounds[slot];
		w.Order = ++_woundOrder;
		w.Alive = true;
		w.Open = true;
		w.Tri = triangle;
		w.Bary = bary;
		w.Radius = 0.15f;
		w.MaxRadius = 0.9f + Rand( 0f, 0.4f );
		w.NextDrip = 0.1f;
		w.Closed = 0f;
		w.Ooze = 0f;
		return slot;
	}

	/// <summary>The knife was pulled out: a last gush, then the wound oozes for a few seconds and stops.</summary>
	public void CloseWound( int id, Vector3 outward )
	{
		if ( id < 0 || id >= MaxWounds || !_wounds[id].Alive ) return;
		var w = _wounds[id];
		w.Open = false;
		w.Ooze = 8f;
		Spurt( id, outward, 22, 1.2f );
		for ( int i = 0; i < 3; i++ )
		{
			var (t, b) = Around( w.Tri, w.Bary, w.Radius );
			AddDrip( t, b, Rand( 0.7f, 1f ) );
		}
	}

	/// <summary>Throws droplets out of a wound (along <paramref name="outward"/>, spread in a cone).</summary>
	public void Spurt( int id, Vector3 outward, int count, float force = 1f )
	{
		if ( _face is null || id < 0 || id >= MaxWounds || !_wounds[id].Alive ) return;
		var w = _wounds[id];
		var origin = _face.SurfacePoint( w.Tri, w.Bary );
		var normal = _face.SurfaceNormal( w.Tri, w.Bary );
		var dir = (outward.Normal + normal).Normal;

		for ( int i = 0; i < count; i++ )
		{
			var spread = new Vector3( Rand( -1f, 1f ), Rand( -1f, 1f ), Rand( -1f, 1f ) ) * 0.55f;
			var velocity = (dir + spread).Normal * Rand( 6f, 18f ) * force;
			ref var d = ref _droplets[_nextDroplet];
			_nextDroplet = (_nextDroplet + 1) % MaxDroplets;
			d.Alive = true;
			d.Pos = origin + normal * 0.2f;
			d.Vel = velocity;
			d.Size = Rand( 0.07f, 0.2f );
			d.Age = 0f;
		}

		// Pushing the wound also pumps a fresh drip down the skin.
		var (st, sb) = Around( w.Tri, w.Bary, w.Radius );
		AddDrip( st, sb, Rand( 0.5f, 0.9f ) * MathF.Min( force, 1f ) );
		w.Radius = MathF.Min( w.Radius + 0.1f * force, w.MaxRadius );
	}

	/// <summary>How hard he's being squished: wounds drip up to this many times faster (1 = at rest).</summary>
	public float Pump { get; set; } = 1f;

	/// <summary>Wounds still bleeding (open, or oozing after the knife came out).</summary>
	public int BleedingWounds
	{
		get
		{
			int n = 0;
			foreach ( var w in _wounds ) if ( w is not null && w.Alive && (w.Open || w.Ooze > 0f) ) n++;
			return n;
		}
	}

	/// <summary>A squish landed: every bleeding wound spurts.</summary>
	public void PumpAll( int droplets )
	{
		for ( int i = 0; i < MaxWounds; i++ )
		{
			var w = _wounds[i];
			if ( w is null || !w.Alive || !(w.Open || w.Ooze > 0f) ) continue;
			Spurt( i, _face?.SurfaceNormal( w.Tri, w.Bary ) ?? Vector3.Up, droplets, 0.7f );
		}
	}

	/// <summary>Removes all blood.</summary>
	public void Clear()
	{
		foreach ( var d in _drips ) { d.Alive = false; d.Trail.Clear(); }
		for ( int i = 0; i < MaxDroplets; i++ ) _droplets[i].Alive = false;
		foreach ( var w in _wounds ) w.Alive = false;
		for ( int i = 0; i < MaxSpots; i++ ) _spots[i].Alive = false;
	}

	// --- Lifecycle ---

	protected override void OnEnabled()
	{
		for ( int i = 0; i < MaxDrips; i++ ) _drips[i] = new Drip();
		for ( int i = 0; i < MaxWounds; i++ ) _wounds[i] = new Wound();
		BuildMesh();
	}

	protected override void OnDisabled()
	{
		_object?.Destroy();
		_object = null;
		_mesh = null;
	}

	protected override void OnUpdate()
	{
		_face ??= Components.Get<SquishyFace>();
		if ( _face?.Sim is null || _mesh is null ) return;
		if ( _adjacent is null ) BuildAdjacency( _face.Sim.Indices );

		UpdateTestWound();

		float dt = MathF.Min( RealTime.Delta, 0.05f ) * SlowMo.Scale;
		UpdateWounds( dt );
		UpdateDrips( dt );
		UpdateDroplets( dt );
		Render();
	}

	int _testWound = -1;

	void UpdateTestWound()
	{
		if ( TestWound && _testWound < 0 && Scene.Camera is { } camera )
		{
			// Aim at the right cheek, a little below the eye.
			var ray = camera.ScreenNormalToRay( new Vector3( 0.56f, 0.6f, 0f ) );
			if ( _face.TraceSurface( ray, out int tri, out var bary ) )
			{
				_testWound = OpenWound( tri, bary );
				Spurt( _testWound, -ray.Forward, 14 );
			}
		}
		else if ( !TestWound && _testWound >= 0 )
		{
			CloseWound( _testWound, -(Scene.Camera?.WorldRotation.Forward ?? Vector3.Forward) );
			_testWound = -1;
		}
	}

	// --- Simulation ---

	void UpdateWounds( float dt )
	{
		foreach ( var w in _wounds )
		{
			if ( !w.Alive ) continue;
			bool bleeding = w.Open || w.Ooze > 0f;
			if ( !w.Open ) { w.Ooze -= dt; w.Closed += dt; }

			if ( bleeding )
			{
				w.Radius = MathF.Min( w.Radius + dt * 0.25f, w.MaxRadius );
				w.NextDrip -= dt * Pump;
				if ( w.NextDrip <= 0f )
				{
					w.NextDrip = Rand( 0.35f, 0.9f );
					var (t, b) = Around( w.Tri, w.Bary, w.Radius );
					AddDrip( t, b, Rand( 0.35f, 1f ) );
				}
			}

			if ( StainLifetime > 0f && w.Closed > StainLifetime + FadeTime ) w.Alive = false;
		}
	}

	void AddDrip( int tri, Vector3 bary, float volume )
	{
		// Reuse a free slot, else the oldest.
		Drip drip = null;
		for ( int i = 0; i < MaxDrips && drip is null; i++ )
			if ( !_drips[i].Alive ) drip = _drips[i];
		if ( drip is null )
		{
			drip = _drips[_nextDrip];
			_nextDrip = (_nextDrip + 1) % MaxDrips;
		}

		drip.Alive = true;
		drip.Tri = tri;
		drip.Bary = bary;
		drip.Volume = volume;
		drip.Size = volume;
		drip.Speed = 0f;
		drip.Phase = Rand( 0f, 10f );
		drip.Still = 0f;
		drip.Trail.Clear();
		drip.LastTrail = SimPoint( tri, bary );
		drip.Trail.Add( (tri, bary, Width( volume )) );
	}

	void AddSpot( int tri, Vector3 bary, float radius )
	{
		ref var spot = ref _spots[_nextSpot];
		_nextSpot = (_nextSpot + 1) % MaxSpots;
		spot.Alive = true;
		spot.Tri = tri;
		spot.Bary = bary;
		spot.Radius = radius;
	}

	void UpdateDrips( float dt )
	{
		var sim = _face.Sim;
		var gravity = _face.WorldDirectionToSim( Vector3.Down ).Normalized;

		foreach ( var d in _drips )
		{
			if ( !d.Alive ) continue;

			bool moving = d.Volume > 0.08f && d.Trail.Count < MaxTrail;
			if ( !moving )
			{
				d.Still += dt;
				if ( StainLifetime > 0f && d.Still > StainLifetime + FadeTime ) { d.Alive = false; d.Trail.Clear(); }
				continue;
			}

			Corners( sim, d.Tri, out var a, out var b, out var c );
			var n = Vec3.Cross( b - a, c - a ).Normalized;
			var downhill = gravity - n * Vec3.Dot( gravity, n );
			float slope = downhill.Length;
			if ( slope < 0.05f ) { d.Volume -= dt * 0.5f; continue; } // on a flat top: it just soaks in
			downhill /= slope;

			// Thick drips run faster; a slow wobble keeps the path from being a ruler-straight line.
			d.Phase += dt * 1.5f;
			var side = Vec3.Cross( n, downhill );
			var dir = (downhill + side * (MathF.Sin( d.Phase ) * 0.12f)).Normalized;
			float target = DripSpeed * slope * (0.3f + 0.7f * d.Volume);
			d.Speed += (target - d.Speed) * MathF.Min( dt * 4f, 1f );

			float step = d.Speed * dt;
			var p = SimPoint( d.Tri, d.Bary ) + dir * step;
			Walk( d, p );
			d.Volume -= step * Consumption;

			// Reached an overhang (under the chin, the jaw): the drop lets go and falls.
			if ( d.Volume > 0.15f && Vector3.Dot( _face.SurfaceNormal( d.Tri, d.Bary ), Vector3.Down ) > 0.55f )
			{
				Release( d );
				continue;
			}

			var here = SimPoint( d.Tri, d.Bary );
			if ( (here - d.LastTrail).Length >= TrailSpacing )
			{
				d.LastTrail = here;
				d.Trail.Add( (d.Tri, d.Bary, Width( d.Volume )) );
			}
		}
	}

	/// <summary>Moves a drip to (near) point p on the skin, crossing into neighbouring triangles as needed.</summary>
	void Walk( Drip d, Vec3 p ) => Walk( ref d.Tri, ref d.Bary, p, 4 );

	void Walk( ref int tri, ref Vector3 bary, Vec3 p, int maxHops )
	{
		var sim = _face.Sim;
		for ( int hop = 0; hop < maxHops; hop++ )
		{
			Corners( sim, tri, out var a, out var b, out var c );
			var here = Barycentric( p, a, b, c );
			int k = here.x < here.y ? (here.x < here.z ? 0 : 2) : (here.y < here.z ? 1 : 2);
			float min = k == 0 ? here.x : k == 1 ? here.y : here.z;
			if ( min >= 0f ) { bary = here; return; }

			int next = _adjacent[tri * 3 + k];
			if ( next < 0 ) break;
			tri = next;
		}

		// Couldn't settle cleanly: clamp into the current triangle.
		Corners( sim, tri, out var a2, out var b2, out var c2 );
		var clamped = Barycentric( p, a2, b2, c2 );
		clamped = new Vector3( MathF.Max( clamped.x, 0f ), MathF.Max( clamped.y, 0f ), MathF.Max( clamped.z, 0f ) );
		float sum = clamped.x + clamped.y + clamped.z;
		bary = sum > 1e-5f ? clamped / sum : new Vector3( 1f / 3f );
	}

	/// <summary>A random spot on the skin within <paramref name="worldRadius"/> of a point on it.</summary>
	(int Tri, Vector3 Bary) Around( int tri, Vector3 bary, float worldRadius )
	{
		Corners( _face.Sim, tri, out var a, out var b, out var c );
		var n = Vec3.Cross( b - a, c - a ).Normalized;
		var u = (b - a).Normalized;
		var v = Vec3.Cross( n, u );
		float angle = Rand( 0f, MathF.Tau );
		float r = MathF.Sqrt( Rand( 0f, 1f ) ) * worldRadius / _face.WorldScale.x;
		var p = SimPoint( tri, bary ) + (u * MathF.Cos( angle ) + v * MathF.Sin( angle )) * r;
		Walk( ref tri, ref bary, p, 12 );
		return (tri, bary);
	}

	/// <summary>A drip hanging off an overhang drops: its bead becomes a falling droplet and the trail stays.</summary>
	void Release( Drip d )
	{
		ref var drop = ref _droplets[_nextDroplet];
		_nextDroplet = (_nextDroplet + 1) % MaxDroplets;
		drop.Alive = true;
		drop.Pos = _face.SurfacePoint( d.Tri, d.Bary ) + Vector3.Down * 0.3f;
		drop.Vel = Vector3.Down * 2f;
		drop.Size = 0.12f + 0.12f * d.Volume;
		drop.Age = 0.05f;
		d.Volume = 0.1f; // what's left dries where it hung
		d.Size = 0.5f;
	}

	void UpdateDroplets( float dt )
	{
		var center = _face.WorldPosition;
		float radius = _face.WorldScale.x * 1.15f;
		int traces = 0;

		for ( int i = 0; i < MaxDroplets; i++ )
		{
			ref var d = ref _droplets[i];
			if ( !d.Alive ) continue;

			var from = d.Pos;
			d.Vel += Vector3.Down * DropletGravity * dt;
			d.Vel *= 1f - 0.6f * dt; // air drag
			d.Pos += d.Vel * dt;
			d.Age += dt;
			if ( d.Age > 3f ) { d.Alive = false; continue; }

			// Landing back on the face: only near it, heading inwards, and within this frame's ray budget.
			if ( d.Age < 0.05f || (d.Pos - center).Length > radius ) continue;
			if ( Vector3.Dot( d.Vel, center - d.Pos ) <= 0f ) continue;
			if ( traces >= TracesPerFrame ) continue;
			traces++;

			var travel = d.Pos - from;
			float length = travel.Length;
			if ( length < 1e-4f ) continue;
			if ( !_face.TraceSurface( new Ray( from, travel / length ), out int tri, out var bary ) ) continue;
			if ( (_face.SurfacePoint( tri, bary ) - from).Length > length + 0.3f ) continue;

			// Big drops run; small ones just splat.
			if ( d.Size > 0.16f ) AddDrip( tri, bary, Math.Clamp( d.Size * 1.6f, 0.15f, 0.4f ) );
			else AddSpot( tri, bary, d.Size * 2.2f );
			d.Alive = false;
		}
	}

	// --- Rendering ---

	void BuildMesh()
	{
		_object = new GameObject( true, "Blood" );
		_object.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_object.NetworkMode = NetworkMode.Never;

		int dripVerts = MaxTrail * 2 + BlobSides + 1;
		int woundVerts = WoundSides + 1;
		const int dropletVerts = 6;
		_dripVerts = dripVerts;
		_woundVerts = woundVerts;

		var indices = new List<int>();
		int v = 0;
		for ( int i = 0; i < MaxDrips; i++ )
		{
			for ( int t = 0; t + 1 < MaxTrail; t++ )
			{
				int l0 = v + t * 2, r0 = l0 + 1, l1 = l0 + 2, r1 = l0 + 3;
				indices.AddRange( new[] { l0, l1, r0, r0, l1, r1 } );
			}
			Fan( indices, v + MaxTrail * 2, BlobSides );
			v += dripVerts;
		}
		for ( int i = 0; i < MaxWounds; i++ )
		{
			Fan( indices, v, WoundSides );
			v += woundVerts;
		}
		for ( int i = 0; i < MaxSpots; i++ )
		{
			Fan( indices, v, SpotSides );
			v += SpotSides + 1;
		}
		for ( int i = 0; i < MaxDroplets; i++ )
		{
			// Octahedron: 0/1 = ends, 2..5 = ring.
			for ( int s = 0; s < 4; s++ )
			{
				int r0 = v + 2 + s, r1 = v + 2 + (s + 1) % 4;
				indices.AddRange( new[] { v, r0, r1, v + 1, r1, r0 } );
			}
			v += dropletVerts;
		}

		_verts = new Vertex[v];
		_mesh = new Mesh( Material ?? Material.Load( "materials/squishy_blood.vmat" ) );
		_mesh.CreateVertexBuffer( v, Vertex.Layout, new List<Vertex>( _verts ) );
		_mesh.CreateIndexBuffer( indices.Count, indices );
		_mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, 100000f );

		_renderer = _object.Components.Create<ModelRenderer>();
		_renderer.Model = Model.Builder.AddMesh( _mesh ).Create();
		_renderer.Tint = Color;
		_renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
	}

	/// <summary>Triangle fan: centre vertex first, then the rim.</summary>
	static void Fan( List<int> indices, int start, int sides )
	{
		for ( int s = 0; s < sides; s++ )
			indices.AddRange( new[] { start, start + 1 + (s + 1) % sides, start + 1 + s } );
	}

	void Render()
	{
		Array.Clear( _verts );
		int v = 0;

		foreach ( var d in _drips )
		{
			if ( d.Alive && d.Trail.Count > 0 ) WriteDrip( d, v );
			v += _dripVerts;
		}
		foreach ( var w in _wounds )
		{
			if ( w.Alive )
			{
				float fade = StainLifetime > 0f ? 1f - Math.Clamp( (w.Closed - StainLifetime) / FadeTime, 0f, 1f ) : 1f;
				WriteBlob( v, w.Tri, w.Bary, w.Radius * fade, WoundSides, 0.08f );
			}
			v += _woundVerts;
		}
		for ( int i = 0; i < MaxSpots; i++ )
		{
			if ( _spots[i].Alive ) WriteBlob( v, _spots[i].Tri, _spots[i].Bary, _spots[i].Radius, SpotSides, 0.04f );
			v += SpotSides + 1;
		}
		for ( int i = 0; i < MaxDroplets; i++ )
		{
			if ( _droplets[i].Alive ) WriteDroplet( v, _droplets[i] );
			v += 6;
		}

		_mesh.SetVertexBufferData( _verts.AsSpan(), 0 );
		_renderer.Tint = Color;
		_object.WorldPosition = Vector3.Zero;
		_object.WorldRotation = Rotation.Identity;
	}

	void WriteDrip( Drip d, int start )
	{
		float fade = StainLifetime > 0f ? 1f - Math.Clamp( (d.Still - StainLifetime) / FadeTime, 0f, 1f ) : 1f;
		int count = d.Trail.Count;

		for ( int t = 0; t < count; t++ )
		{
			var (tri, bary, width) = d.Trail[t];
			var n = _face.SurfaceNormal( tri, bary );
			var c = _face.SurfacePoint( tri, bary ) + n * SurfaceLift;

			// Direction along the trail (towards the next point, or from the previous at the end).
			var (ntri, nbary, _) = d.Trail[Math.Min( t + 1, count - 1 )];
			var (ptri, pbary, _) = d.Trail[Math.Max( t - 1, 0 )];
			var along = _face.SurfacePoint( ntri, nbary ) - _face.SurfacePoint( ptri, pbary );
			if ( along.Length < 1e-4f ) along = Vector3.Down;
			var side = Vector3.Cross( n, along ).Normal;

			// The trail tapers to nothing at its start and fattens towards the drop.
			float taper = MathF.Min( t / 3f, 1f );
			float half = width * 0.5f * taper * fade;
			// Normals tilted outwards at the edges read as a rounded, wet bead under the light.
			_verts[start + t * 2] = new Vertex( c - side * half, (n - side * 0.7f).Normal, along.Normal, Vector4.Zero );
			_verts[start + t * 2 + 1] = new Vertex( c + side * half, (n + side * 0.7f).Normal, along.Normal, Vector4.Zero );
		}

		// Collapse the unused trail slots onto the last point so they draw nothing.
		for ( int t = count; t < MaxTrail; t++ )
		{
			_verts[start + t * 2] = _verts[start + (count - 1) * 2];
			_verts[start + t * 2 + 1] = _verts[start + (count - 1) * 2];
		}

		// The drop at the head: a little dome, bigger while it still has blood to give.
		float radius = (d.Volume > 0.08f ? 0.22f + 0.28f * d.Volume : 0.18f) * fade * MathF.Max( d.Size, 0.5f );
		WriteBlob( start + MaxTrail * 2, d.Tri, d.Bary, radius, BlobSides, 0.12f );
	}

	/// <summary>A domed disc on the skin (a drop, or a wound's stain).</summary>
	void WriteBlob( int start, int tri, Vector3 bary, float radius, int sides, float dome )
	{
		if ( radius <= 0.001f ) return;
		var n = _face.SurfaceNormal( tri, bary );
		var c = _face.SurfacePoint( tri, bary ) + n * SurfaceLift;
		var u = Vector3.Cross( n, MathF.Abs( n.z ) < 0.9f ? Vector3.Up : Vector3.Forward ).Normal;
		var w = Vector3.Cross( n, u );

		_verts[start] = new Vertex( c + n * dome, n, u, Vector4.Zero );
		for ( int s = 0; s < sides; s++ )
		{
			float a = s * MathF.Tau / sides;
			// A slightly uneven rim, fixed per spot, so stains aren't perfect circles.
			float wobble = 1f + 0.18f * MathF.Sin( a * 3f + tri ) + 0.1f * MathF.Sin( a * 5f + tri * 0.7f );
			var rim = (u * MathF.Cos( a ) + w * MathF.Sin( a )) * radius * wobble;
			_verts[start + 1 + s] = new Vertex( c + rim, (n + rim.Normal * 0.6f).Normal, u, Vector4.Zero );
		}
	}

	void WriteDroplet( int start, in Droplet d )
	{
		float speed = d.Vel.Length;
		var axis = speed > 1e-3f ? d.Vel / speed : Vector3.Down;
		float half = d.Size + speed * 0.008f; // stretched along its motion
		var u = Vector3.Cross( axis, MathF.Abs( axis.z ) < 0.9f ? Vector3.Up : Vector3.Forward ).Normal;
		var w = Vector3.Cross( axis, u );

		_verts[start] = new Vertex( d.Pos + axis * half, axis, u, Vector4.Zero );
		_verts[start + 1] = new Vertex( d.Pos - axis * half, -axis, u, Vector4.Zero );
		_verts[start + 2] = new Vertex( d.Pos + u * d.Size, u, axis, Vector4.Zero );
		_verts[start + 3] = new Vertex( d.Pos + w * d.Size, w, axis, Vector4.Zero );
		_verts[start + 4] = new Vertex( d.Pos - u * d.Size, -u, axis, Vector4.Zero );
		_verts[start + 5] = new Vertex( d.Pos - w * d.Size, -w, axis, Vector4.Zero );
	}

	// --- Helpers ---

	void BuildAdjacency( int[] indices )
	{
		int tris = indices.Length / 3;
		_adjacent = new int[tris * 3];
		Array.Fill( _adjacent, -1 );
		var edges = new Dictionary<long, int>();
		for ( int t = 0; t < tris; t++ )
		{
			for ( int k = 0; k < 3; k++ )
			{
				int a = indices[t * 3 + (k + 1) % 3], b = indices[t * 3 + (k + 2) % 3];
				long key = (long)Math.Min( a, b ) << 32 | (uint)Math.Max( a, b );
				if ( edges.TryGetValue( key, out int other ) )
				{
					_adjacent[t * 3 + k] = other / 3;
					_adjacent[other] = t;
				}
				else edges[key] = t * 3 + k;
			}
		}
	}

	static void Corners( Face sim, int tri, out Vec3 a, out Vec3 b, out Vec3 c )
	{
		a = sim.Pos[sim.Indices[tri * 3]];
		b = sim.Pos[sim.Indices[tri * 3 + 1]];
		c = sim.Pos[sim.Indices[tri * 3 + 2]];
	}

	Vec3 SimPoint( int tri, Vector3 bary )
	{
		Corners( _face.Sim, tri, out var a, out var b, out var c );
		return a * bary.x + b * bary.y + c * bary.z;
	}

	/// <summary>Barycentric coordinates of p projected onto triangle abc.</summary>
	static Vector3 Barycentric( Vec3 p, Vec3 a, Vec3 b, Vec3 c )
	{
		var v0 = b - a;
		var v1 = c - a;
		var v2 = p - a;
		float d00 = Vec3.Dot( v0, v0 ), d01 = Vec3.Dot( v0, v1 ), d11 = Vec3.Dot( v1, v1 );
		float d20 = Vec3.Dot( v2, v0 ), d21 = Vec3.Dot( v2, v1 );
		float denom = d00 * d11 - d01 * d01;
		if ( MathF.Abs( denom ) < 1e-12f ) return new Vector3( 1f / 3f );
		float y = (d11 * d20 - d01 * d21) / denom;
		float z = (d00 * d21 - d01 * d20) / denom;
		return new Vector3( 1f - y - z, y, z );
	}

	float Width( float volume ) => DripWidth * (0.35f + 0.65f * MathF.Sqrt( Math.Clamp( volume, 0f, 1f ) ));

	float Rand( float min, float max ) => min + (float)_random.NextDouble() * (max - min);
}