DiamondMenuDye.cs
using System;
using Sandbox;
namespace Diamonds;
/// <summary>A menu-sized dye field with moving glyph walls and solid button boundaries.</summary>
public sealed class DiamondMenuDye
{
public const int Columns = 96, Rows = 64;
const int Count = Columns * Rows;
readonly float[] dye = new float[Count * 5], transported = new float[Count * 5];
readonly Vector2[] velocity = new Vector2[Count], nextVelocity = new Vector2[Count];
readonly Vector2[] normals = new Vector2[Count], walls = new Vector2[Count];
readonly float[] distance = new float[Count], divergence = new float[Count];
float[] pressure = new float[Count], pressureNext = new float[Count];
readonly Random random = new();
readonly Vector2[] centers = new Vector2[2], wordVelocity = new Vector2[2];
Rect viewport;
Rect[] buttons = [];
Vector2 cell;
float accumulator;
bool initialized;
public float Sample( int x, int y, int color ) => dye[(y * Columns + x) * 5 + color];
public ReadOnlySpan<float> Samples => dye;
// Rendering revision: velocity-only updates leave the uploaded colors reusable.
public ulong DyeVersion { get; private set; }
public Vector2 Position( int x, int y ) => viewport.Position + new Vector2( (x + 0.5f) * cell.x, (y + 0.5f) * cell.y );
public Vector2 CellSize => cell;
public bool Ready( Rect bounds ) => initialized && viewport == bounds;
public float TotalDye { get { float total = 0; foreach ( float value in dye ) total += value; return total; } }
public float MotionEnergy { get { float total = 0; foreach ( var value in velocity ) total += value.LengthSquared; return total; } }
public void Clear() { initialized = false; Array.Clear( dye ); Array.Clear( velocity ); accumulator = 0; DyeVersion++; }
public void Update( float delta, Rect bounds, ReadOnlySpan<Rect> buttonWalls, Vector2 first, Vector2 second )
{
bool reset = !initialized || viewport != bounds;
bool wallsChanged = reset || !buttonWalls.SequenceEqual( buttons );
viewport = bounds;
if ( wallsChanged ) buttons = buttonWalls.ToArray();
cell = new Vector2( bounds.Width / Columns, bounds.Height / Rows );
for ( int word = 0; word < 2; word++ )
{
var next = word == 0 ? first : second;
var motion = !reset && delta > 0 ? (next - centers[word]) / delta : Vector2.Zero;
motion = motion.Length > 1050 ? motion.Normal * 1050 : motion;
// Include velocity changes: the first stationary update must clear moving walls.
wallsChanged |= centers[word] != next || wordVelocity[word] != motion;
wordVelocity[word] = motion;
centers[word] = next;
}
if ( wallsChanged ) BuildWalls();
if ( reset ) Seed();
DisplaceCoveredDye();
// Moving leading faces transfer momentum to the surrounding fluid, including the wake.
float reach = MathF.Max( cell.x, cell.y ) * 2;
for ( int i = 0; i < Count; i++ )
{
if ( distance[i] < 0 ) { velocity[i] = walls[i]; continue; }
if ( distance[i] > reach ) continue;
float approach = Vector2.Dot( velocity[i] - walls[i], normals[i] );
if ( approach < 0 ) velocity[i] -= normals[i] * approach * (1 - distance[i] / reach) * Math.Clamp( delta * 120, 0, 1 );
}
accumulator += Math.Clamp( delta, 0, 0.1f );
while ( accumulator >= 1f / 30 ) { Step( 1f / 30 ); accumulator -= 1f / 30; }
}
void Seed()
{
DyeVersion++;
Array.Clear( velocity ); Array.Clear( dye );
accumulator = 0;
float a = Phase(), b = Phase(), c = Phase(), d = Phase(), hue = Phase();
float Phase() => (float)random.NextDouble() * MathF.PI * 2;
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
if ( distance[n] < 0 ) continue;
float u = x / (float)Columns, v = y / (float)Rows;
float fold = MathF.Sin( u * 11 + a + MathF.Sin( v * 7 + b ) * 1.3f );
float curl = MathF.Cos( v * 10 - u * 4 + c + MathF.Cos( u * 8 + d ) );
float cloud = Math.Clamp( 0.5f + fold * 0.3f + curl * 0.24f, 0, 1 );
float swirl = MathF.Sin( u * 6 + a ) * 1.7f + MathF.Cos( v * 7 + b ) * 1.4f + u * 5;
for ( int color = 0; color < 5; color++ )
{
float band = MathF.Max( 0, MathF.Cos( swirl - color * MathF.PI * 2 / 5 + hue ) );
dye[n * 5 + color] = cloud * (0.01f + 0.29f * band * band);
}
}
initialized = true;
}
void BuildWalls()
{
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
distance[n] = Obstacle( Position( x, y ), out normals[n], out walls[n] );
}
}
void DisplaceCoveredDye()
{
bool changed = false;
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
if ( distance[n] >= 0 ) continue;
var target = Position( x, y ) + normals[n] * (-distance[n] + MathF.Max( cell.x, cell.y ) * 1.3f);
int tx = Math.Clamp( (int)((target.x - viewport.Left) / cell.x), 0, Columns - 1 );
int ty = Math.Clamp( (int)((target.y - viewport.Top) / cell.y), 0, Rows - 1 );
int receiver = ty * Columns + tx;
for ( int color = 0; color < 5; color++ )
{
changed |= dye[n * 5 + color] != 0;
if ( distance[receiver] >= 0 ) dye[receiver * 5 + color] += dye[n * 5 + color];
dye[n * 5 + color] = 0;
}
}
if ( changed ) DyeVersion++;
}
void Step( float dt )
{
Array.Clear( pressure );
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
if ( distance[n] < 0 ) { nextVelocity[n] = walls[n]; continue; }
var sample = Trace( Position( x, y ), -velocity[n] * dt );
nextVelocity[n] = SampleVelocity( sample ) * 0.983f;
}
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
divergence[n] = -0.5f * ((NeighborVelocity( x + 1, y, n ).x - NeighborVelocity( x - 1, y, n ).x) / cell.x
+ (NeighborVelocity( x, y + 1, n ).y - NeighborVelocity( x, y - 1, n ).y) / cell.y);
}
for ( int iteration = 0; iteration < 10; iteration++ )
{
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
pressureNext[n] = distance[n] < 0 ? 0 : (divergence[n] * cell.x * cell.y
+ NeighborPressure( x - 1, y, n ) + NeighborPressure( x + 1, y, n )
+ NeighborPressure( x, y - 1, n ) + NeighborPressure( x, y + 1, n )) * 0.25f;
}
(pressure, pressureNext) = (pressureNext, pressure);
}
for ( int y = 0; y < Rows; y++ )
for ( int x = 0; x < Columns; x++ )
{
int n = y * Columns + x;
if ( distance[n] < 0 ) { velocity[n] = walls[n]; continue; }
var v = nextVelocity[n] - new Vector2( (NeighborPressure( x + 1, y, n ) - NeighborPressure( x - 1, y, n )) / cell.x,
(NeighborPressure( x, y + 1, n ) - NeighborPressure( x, y - 1, n )) / cell.y ) * 0.5f;
// Reflect incoming flow in the wall's moving frame instead of letting it leak through.
float incoming = Vector2.Dot( v - walls[n], normals[n] );
if ( distance[n] < MathF.Max( cell.x, cell.y ) && incoming < 0 ) v -= normals[n] * incoming * 1.65f;
if ( x == 0 && v.x < 0 || x == Columns - 1 && v.x > 0 ) v.x *= -0.65f;
if ( y == 0 && v.y < 0 || y == Rows - 1 && v.y > 0 ) v.y *= -0.65f;
velocity[n] = v.Length > 1050 ? v.Normal * 1050 : v;
var source = Trace( Position( x, y ), -velocity[n] * dt );
TransportDye( source, n );
}
bool changed = false;
for ( int n = 0; n < Count; n++ )
for ( int color = 0; color < 5; color++ )
{
int index = n * 5 + color;
float value = distance[n] < 0 ? 0 : transported[index];
changed |= dye[index] != value;
dye[index] = value;
}
if ( changed ) DyeVersion++;
}
Vector2 NeighborVelocity( int x, int y, int own ) => x < 0 || x >= Columns || y < 0 || y >= Rows ? -nextVelocity[own] : nextVelocity[y * Columns + x];
float NeighborPressure( int x, int y, int own ) => x < 0 || x >= Columns || y < 0 || y >= Rows || distance[y * Columns + x] < 0 ? pressure[own] : pressure[y * Columns + x];
Vector2 Trace( Vector2 point, Vector2 motion )
{
int steps = Math.Max( 1, (int)MathF.Ceiling( motion.Length / 5 ) );
var result = point;
for ( int i = 1; i <= steps; i++ )
{
var next = point + motion * (i / (float)steps);
if ( !viewport.IsInside( next ) || Obstacle( next, out _, out _ ) < 1 ) break;
result = next;
}
return result;
}
void TransportDye( Vector2 point, int destination )
{
Weights( point, out int x, out int y, out float fx, out float fy );
int a = y * Columns + x, b = a + 1, c = a + Columns, d = c + 1;
// All five colors share the same open neighbors and interpolation weights.
float wa = distance[a] < 0 ? 0 : (1 - fx) * (1 - fy);
float wb = distance[b] < 0 ? 0 : fx * (1 - fy);
float wc = distance[c] < 0 ? 0 : (1 - fx) * fy;
float wd = distance[d] < 0 ? 0 : fx * fy;
float total = wa + wb + wc + wd;
for ( int color = 0; color < 5; color++ )
{
float value = 0;
value += dye[a * 5 + color] * wa;
value += dye[b * 5 + color] * wb;
value += dye[c * 5 + color] * wc;
value += dye[d * 5 + color] * wd;
transported[destination * 5 + color] = (total > 0 ? value / total : 0) * 0.9999f;
}
}
Vector2 SampleVelocity( Vector2 point )
{
Weights( point, out int x, out int y, out float fx, out float fy );
return velocity[y * Columns + x] * ((1 - fx) * (1 - fy)) + velocity[y * Columns + x + 1] * (fx * (1 - fy))
+ velocity[(y + 1) * Columns + x] * ((1 - fx) * fy) + velocity[(y + 1) * Columns + x + 1] * (fx * fy);
}
void Weights( Vector2 point, out int x, out int y, out float fx, out float fy )
{
float gx = Math.Clamp( (point.x - viewport.Left) / cell.x - 0.5f, 0, Columns - 1.001f );
float gy = Math.Clamp( (point.y - viewport.Top) / cell.y - 0.5f, 0, Rows - 1.001f );
x = (int)gx; y = (int)gy; fx = gx - x; fy = gy - y;
}
public float Obstacle( Vector2 point, out Vector2 normal, out Vector2 wall )
{
float nearest = float.PositiveInfinity;
normal = Vector2.Zero;
wall = Vector2.Zero;
foreach ( var button in buttons )
{
float d = BoxDistance( point, button, out var other );
if ( d < nearest ) { nearest = d; normal = other; }
}
for ( int word = 0; word < 2; word++ )
{
var offset = point - centers[word];
var local = new Vector2( offset.x * 0.62932f - offset.y * 0.777146f, offset.x * 0.777146f + offset.y * 0.62932f );
float d = GlyphDistance( word, local, out var n );
if ( d >= nearest ) continue;
nearest = d;
normal = new Vector2( n.x * 0.62932f + n.y * 0.777146f, -n.x * 0.777146f + n.y * 0.62932f );
wall = wordVelocity[word];
}
return nearest;
}
static float BoxDistance( Vector2 point, Rect box, out Vector2 normal )
{
var nearest = new Vector2( Math.Clamp( point.x, box.Left, box.Right ), Math.Clamp( point.y, box.Top, box.Bottom ) );
var offset = point - nearest;
if ( offset.Length > 0 ) { normal = offset.Normal; return offset.Length; }
float left = point.x - box.Left, right = box.Right - point.x, top = point.y - box.Top, bottom = box.Bottom - point.y;
float min = MathF.Min( MathF.Min( left, right ), MathF.Min( top, bottom ) );
normal = min == left ? new Vector2( -1, 0 ) : min == right ? new Vector2( 1, 0 ) : min == top ? new Vector2( 0, -1 ) : new Vector2( 0, 1 );
return -min;
}
const int GlyphWidth = 365, GlyphHeight = 69;
static readonly float[][] glyphDistance = new float[2][];
static readonly Vector2[][] glyphNormal = new Vector2[2][];
static float GlyphDistance( int word, Vector2 point, out Vector2 normal )
{
normal = Vector2.Zero;
if ( point.x < -364 || point.x > 364 || point.y < -68 || point.y > 68 ) return 10000;
if ( glyphDistance[word] is null ) BuildGlyph( word );
int x = Math.Clamp( (int)MathF.Round( (point.x + 364) / 2 ), 0, GlyphWidth - 1 );
int y = Math.Clamp( (int)MathF.Round( (point.y + 68) / 2 ), 0, GlyphHeight - 1 );
int index = y * GlyphWidth + x;
normal = glyphNormal[word][index];
return glyphDistance[word][index];
}
static void BuildGlyph( int word )
{
glyphDistance[word] = new float[GlyphWidth * GlyphHeight];
glyphNormal[word] = new Vector2[GlyphWidth * GlyphHeight];
for ( int y = 0; y < GlyphHeight; y++ )
for ( int x = 0; x < GlyphWidth; x++ )
{
var point = new Vector2( x * 2 - 364, y * 2 - 68 );
bool inside = false;
float best = float.PositiveInfinity;
var direction = Vector2.Zero;
foreach ( var contour in DiamondTitleGlyphs.Words[word] )
for ( int i = 0; i < contour.Length; i++ )
{
var a = contour[i]; var b = contour[(i + 1) % contour.Length];
if ( (a.y > point.y) != (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x ) inside = !inside;
var edge = b - a;
float fraction = Math.Clamp( Vector2.Dot( point - a, edge ) / MathF.Max( edge.LengthSquared, 0.0001f ), 0, 1 );
var offset = point - (a + edge * fraction);
if ( offset.LengthSquared >= best ) continue;
best = offset.LengthSquared; direction = offset.Normal;
}
int n = y * GlyphWidth + x;
glyphDistance[word][n] = MathF.Sqrt( best ) * (inside ? -1 : 1);
glyphNormal[word][n] = direction * (inside ? -1 : 1);
}
}
}