Code/AltCurve.cs
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace AltCurves;
/// <summary>
/// AltCurve, Curves with an extended editor and better performance.
/// A valid, initialized AltCurve must have 1 or more keyframes in distinct ascending time order.
/// </summary>
[JsonConverter( typeof( JsonConverter ) )]
public readonly partial record struct AltCurve
{
/// <summary>
/// The bulk of the curve data, each keyframe contains a time/value and interpolates to the next keyframe
/// </summary>
public ImmutableArray<Keyframe> Keyframes { get; init; }
/// <summary>
/// How to handle values before the first keyframe
/// </summary>
public Extrapolation PreInfinity { get; init; }
/// <summary>
/// How to handle values after the last keyframe
/// </summary>
public Extrapolation PostInfinity { get; init; }
/// <summary>
/// The min/max time range of the provided keyframe set
/// </summary>
public (float Min, float Max) TimeRange { get; init; }
/// <summary>
/// The total timespan of the curve, from the first keyframe to the last
/// </summary>
public float TimeSpan { get; init; }
/// <summary>
/// The min/max value observed across all keyframes, including curves that extend above/below their keyframes.
/// Cached at construction time to avoid recalculating each time.
/// </summary>
public (float Min, float Max) ValueRange { get; init; }
/// <summary>
/// The min/max value observed for each keyframe segment, including curves that extend above/below their keyframes.
/// Cached at construction time to avoid recalculating each time. Only valid if we have > 1 keyframe (has n-1 entries, 1 per segment)
/// </summary>
public ImmutableArray<(float Min, float Max)> KeyframeValueRanges { get; init; }
/// <summary>
/// Binary search comparer for keyframes by time
/// </summary>
private readonly IComparer<Keyframe> _keyframeTimeComparer;
public AltCurve( IEnumerable<Keyframe> keyframes, Extrapolation preInfinity, Extrapolation postInfinity )
{
// Don't allow a curve consisting of 0 keyframes, if none exist then add one default keyframe.
Keyframes = keyframes.Any() ? keyframes.ToImmutableArray() : ImmutableArray.Create( new Keyframe() );
PreInfinity = preInfinity;
PostInfinity = postInfinity;
_keyframeTimeComparer = new KeyframeTimeComparer();
// Check for out-of-order or duplicate keyframes
for ( int i = 0; i < Keyframes.Length - 1; i++ )
{
if ( Keyframes[i].Time > Keyframes[i + 1].Time )
{
throw new ArgumentException( $"Keyframes are out of order at index {i}. Keyframe times must be in ascending order." );
}
if ( Keyframes[i].Time == Keyframes[i + 1].Time )
{
throw new ArgumentException( $"Duplicate keyframe time found at index {i} and {i + 1}. Keyframe times must be unique." );
}
}
TimeSpan = Keyframes[^1].Time - Keyframes[0].Time;
TimeRange = (Keyframes[0].Time, Keyframes[^1].Time);
ValueRange = (Keyframes[0].Value, Keyframes[0].Value);
if ( Keyframes.Length > 1 )
{
float totalMin = float.MaxValue;
float totalMax = float.MinValue;
List<(float Min, float Max)> ranges = new( Keyframes.Length );
for ( int i = 0; i < Keyframes.Length - 1; i++ )
{
// Bare minimum this value range should contain the min/max of the values themselves
float min = Math.Min( Keyframes[i].Value, Keyframes[i + 1].Value );
float max = Math.Max( Keyframes[i].Value, Keyframes[i + 1].Value );
// If it's a cubic curve, sample along the curve and find any other larger extents
if ( Keyframes[i].Interpolation == Interpolation.Cubic )
{
const int steps = 10;
float timeDiff = Keyframes[i + 1].Time - Keyframes[i].Time;
for ( float t = 0.0f; t <= 1.0f; t += (1.0f / steps) )
{
float interpolatedTime = Keyframes[i].Time + timeDiff * t;
float value = GetInterpolatedValue( Keyframes[i], Keyframes[i + 1], interpolatedTime );
min = Math.Min( min, value );
max = Math.Max( max, value );
}
}
totalMin = Math.Min( totalMin, min );
totalMax = Math.Max( totalMax, max );
ranges.Add( (min, max) );
}
KeyframeValueRanges = ranges.ToImmutableArray();
ValueRange = (totalMin, totalMax);
}
}
/// <summary>
/// Default constructor contains one single default keyframe
/// </summary>
public AltCurve() : this( ImmutableArray.Create( new Keyframe() ), Extrapolation.Constant, Extrapolation.Constant )
{
}
public AltCurve( AltCurve copy, IEnumerable<Keyframe> replacementKeyframes ) :
this( replacementKeyframes, copy.PreInfinity, copy.PostInfinity )
{
}
/// <summary>
/// Return a sanitized enumerable of the input keyframes, in particular:
/// - No keys may share an identical time
/// - All keys must be in ascending time order
/// </summary>
public static IEnumerable<Keyframe> SanitizeKeyframes( IEnumerable<Keyframe> keyframes )
{
return keyframes.DistinctBy( x => x.Time ).OrderBy( x => x.Time );
}
/// <summary>
/// Create a copy of this curve, replacing the keyframes with a copy of the given input keyframe enumerable
/// </summary>
public readonly AltCurve WithKeyframes( IEnumerable<Keyframe> keyframes ) => new( this, keyframes );
// The push towards more clearly defined immutability with record structs is nice.
// But what's the point when you get shit like ImmutableArrays implementing IEquality but only equating the array REFERENCE and not the array VALUES..?
// https://github.com/dotnet/runtime/issues/77183 Unfortunately it sounds like this will never be a sensible default
// So for now just provide our own equality that will be used instead (even though it's not referenced,
// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/record-structs#equality-members)
public readonly bool Equals( AltCurve other )
{
if ( PreInfinity != other.PreInfinity || PostInfinity != other.PostInfinity )
return false;
// Skip SequenceEqual if the keyframe arrays have not been initialized (ie this is default(AltCurve))
if ( Keyframes.IsDefault || other.Keyframes.IsDefault )
return Keyframes.IsDefault == other.Keyframes.IsDefault;
else
return Keyframes.SequenceEqual( other.Keyframes );
}
public override int GetHashCode() => HashCode.Combine( Keyframes, PreInfinity, PostInfinity );
/// <summary>
/// Evaluate the curve value for a given input time.
/// Time complexity: O(log n)
/// </summary>
[MethodImpl( MethodImplOptions.AggressiveInlining )] // Notable gains from profiling
public readonly float Evaluate( float time )
{
// Evaluations on an empty curve will always evaluate as 0
if ( Keyframes.IsDefault || Keyframes.Length == 0 )
return 0.0f;
if ( Keyframes.Length == 1 )
return Keyframes[0].Value; // Only one keyframe, return its value
// Normalize the time & calculate offset for any times that fall outside our keyframe range
float normalizedTime = time;
float accumulatedOffset = 0.0f;
if ( time < TimeRange.Min )
(normalizedTime, accumulatedOffset) = HandlePreInfinity( time );
else if ( time > TimeRange.Max )
(normalizedTime, accumulatedOffset) = HandlePostInfinity( time );
// Binary search for the given time (or the next index)
int baseIndex = Keyframes.BinarySearch( new() { Time = normalizedTime }, _keyframeTimeComparer );
if ( baseIndex >= 0 )
return Keyframes[baseIndex].Value + accumulatedOffset; // Exact match found
// No exact match, so the bitwise compliment of the index is the next nearest keyframe.
baseIndex = ~baseIndex;
if ( baseIndex == 0 )
return Keyframes[0].Value + accumulatedOffset; // Time is less than the first keyframe's time
if ( baseIndex >= Keyframes.Length )
return Keyframes[^1].Value + accumulatedOffset; // Time is greater than the last keyframe's time
return GetInterpolatedValue( Keyframes[baseIndex - 1], Keyframes[baseIndex], normalizedTime ) + accumulatedOffset;
}
/// <summary>
/// Handle pre-infinity extrapolation and return adjusted time and vertical offset.
/// </summary>
private readonly (float adjustedTime, float accumulatedOffset) HandlePreInfinity( float time )
{
var firstKey = Keyframes[0];
switch ( PreInfinity )
{
case Extrapolation.Constant:
return (firstKey.Time, 0.0f);
case Extrapolation.Linear:
var secondKey = Keyframes[1];
float linearSlope = (secondKey.Value - firstKey.Value) / (secondKey.Time - firstKey.Time);
return (firstKey.Time, linearSlope * (time - firstKey.Time));
case Extrapolation.Cycle:
float cycleOffset = TimeRange.Min - time;
return (TimeRange.Max - (cycleOffset % TimeSpan), 0.0f);
case Extrapolation.CycleOffset:
float cycleOffsetOffset = TimeRange.Min - time;
float cycleVerticalOffset = -(float)((Math.Floor( cycleOffsetOffset / TimeSpan ) + 1.0) * (Keyframes[^1].Value - Keyframes[0].Value));
return (TimeRange.Max - (cycleOffsetOffset % TimeSpan), cycleVerticalOffset);
case Extrapolation.Oscillate:
float oscillateOffset = TimeRange.Min - time;
int oscillateCycles = (int)Math.Floor( oscillateOffset / TimeSpan );
float oscillateAdjustedTime = TimeRange.Min + (oscillateOffset % TimeSpan);
if ( oscillateCycles % 2 == 1 ) oscillateAdjustedTime = TimeRange.Max - (oscillateAdjustedTime - TimeRange.Min);
return (oscillateAdjustedTime, 0.0f);
default:
throw new NotImplementedException( "PreInfinity not implemented" );
}
}
/// <summary>
/// Handle post-infinity extrapolation and return adjusted time and vertical offset.
/// </summary>
private readonly (float adjustedTime, float accumulatedOffset) HandlePostInfinity( float time )
{
var lastKey = Keyframes[^1];
switch ( PostInfinity )
{
case Extrapolation.Constant:
return (lastKey.Time, 0.0f);
case Extrapolation.Linear:
var secondLastKey = Keyframes[^2];
float linearSlope = (lastKey.Value - secondLastKey.Value) / (lastKey.Time - secondLastKey.Time);
return (lastKey.Time, linearSlope * (time - lastKey.Time));
case Extrapolation.Cycle:
float cycleOffset = time - TimeRange.Min;
return (TimeRange.Min + (cycleOffset % TimeSpan), 0.0f);
case Extrapolation.CycleOffset:
float cycleOffsetOffset = time - TimeRange.Min;
float cycleVerticalOffset = (float)(Math.Floor( cycleOffsetOffset / TimeSpan ) * (Keyframes[^1].Value - Keyframes[0].Value));
return (TimeRange.Min + (cycleOffsetOffset % TimeSpan), cycleVerticalOffset);
case Extrapolation.Oscillate:
float oscillateOffset = time - TimeRange.Min;
int oscillateCycles = (int)Math.Floor( oscillateOffset / TimeSpan );
float oscillateAdjustedTime = TimeRange.Min + (oscillateOffset % TimeSpan);
if ( oscillateCycles % 2 == 1 ) oscillateAdjustedTime = TimeRange.Max - (oscillateAdjustedTime - TimeRange.Min);
return (oscillateAdjustedTime, 0.0f);
default:
throw new NotImplementedException( "PostInfinity not implemented" );
}
}
/// <summary>
/// Interpolates the value between two keyframes at a given time.
/// </summary>
[MethodImpl( MethodImplOptions.AggressiveInlining )]
private static float GetInterpolatedValue( in Keyframe keyframeA, in Keyframe keyframeB, float time )
{
switch ( keyframeA.Interpolation )
{
case Interpolation.Constant:
return keyframeA.Value;
case Interpolation.Linear:
{
// The intermediate math operations are done with doubles to help preserve accuracy
float interpTime = (time - keyframeA.Time) / (keyframeB.Time - keyframeA.Time);
return keyframeA.Value + (keyframeB.Value - keyframeA.Value) * interpTime;
}
case Interpolation.Cubic:
{
// Build the cubic Bezier curve where the first/last points are the keyframe values, and the middle points are the tangent offset positions
float interpTime = (time - keyframeA.Time) / (keyframeB.Time - keyframeA.Time);
const float tangentFactor = 0.4f;
return Bezier1D( interpTime,
keyframeA.Value,
keyframeA.Value + (keyframeA.TangentOut * (keyframeB.Time - keyframeA.Time) * tangentFactor),
keyframeB.Value - (keyframeB.TangentIn * (keyframeB.Time - keyframeA.Time) * tangentFactor),
keyframeB.Value );
}
default:
throw new NotImplementedException( "Interpolation type not implemented" );
}
}
/// <summary>
/// De Casteljau's algorithm bezier curve (6 lerps)
/// </summary>
[MethodImpl( MethodImplOptions.AggressiveInlining )]
private static float Bezier1D( float t, float p0, float p1, float p2, float p3 )
{
float line1Frac = p0 + t * (p1 - p0);
float line2Frac = p1 + t * (p2 - p1);
float line3Frac = p2 + t * (p3 - p2);
float subLine1Frac = line1Frac + t * (line2Frac - line1Frac);
float subLine2Frac = line2Frac + t * (line3Frac - line2Frac);
return subLine1Frac + t * (subLine2Frac - subLine1Frac);
}
}