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);
}