OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / IFSEngine / Animation / Channel.cs

154 lines · csharp ref:
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;

using IFSEngine.Animation.ChannelDrivers;

namespace IFSEngine.Animation;

public class Channel
{
    public string Name { get; set; } = "Unnamed channel";
    public List<Keyframe> Keyframes { get; init; } = [];
    public AudioChannelDriver? AudioChannelDriver { get; set; } = null;
    //TODO: add other channel drivers: clamp, wrap, repeat

    public Channel() { }
    public Channel(string name, Keyframe keyframe)
    {
        Name = name;
        Keyframes.Add(keyframe);
    }

    public double EvaluateAt(double t)
    {
        //TODO: avoid sorting every time
        var keyframes = Keyframes.OrderBy(k => k.t).ToList();

        double eval = 0;
        if (t >= keyframes.Last().t)
            eval = keyframes.Last().Value;
        else if (t <= keyframes.First().t)
            eval = keyframes.First().Value;
        else
        {//between keyframes
            var previousKeyframe = keyframes.Where(c => c.t <= t).MaxBy(c => c.t)!;
            var nextKeyframe = keyframes.Where(c => c.t > t).MinBy(c => c.t)!;

            var tNorm = (t - previousKeyframe.t) / (nextKeyframe.t - previousKeyframe.t);

            if (previousKeyframe.Type != KeyframeType.Legacy)
                return Drive(EvaluatePreset(keyframes, previousKeyframe, nextKeyframe, tNorm), t);

            //easing
            // Legacy only. A preset carries its own shape, and for the easing presets the
            // direction is applied inside Easing.Apply instead.
            // Every case must satisfy f(0)=0 and f(1)=1, or the segment does not start at the
            // previous keyframe and end at the next one. `Out` was `1 - t^p`, which is 1 at
            // t=0 and 0 at t=1 - it ran the whole segment BACKWARDS, from the next keyframe's
            // value to the previous one, at every power including 1. The mirror of `In` about
            // both axes is `1 - (1-t)^p`.
            double tEasing = previousKeyframe.EasingDirection switch
            {
                EasingDirection.In => Math.Pow(tNorm, previousKeyframe.EasingPower),
                EasingDirection.Out => 1.0 - Math.Pow(1.0 - tNorm, previousKeyframe.EasingPower),
                EasingDirection.InOut => InOutPowerEasing(tNorm, previousKeyframe.EasingPower, nextKeyframe.EasingPower),
                _ => throw new NotImplementedException(),
            };

            //interpolation
            if (previousKeyframe.InterpolationMode == InterpolationMode.Linear)
            {
                eval = (1.0 - tEasing) * previousKeyframe.Value + tEasing * nextKeyframe.Value;
            }
            else if (previousKeyframe.InterpolationMode == InterpolationMode.Constant)
            {
                eval = previousKeyframe.Value;
            }
            else if (previousKeyframe.InterpolationMode == InterpolationMode.CatmullRom)
            {
                var p0 = keyframes.ElementAtOrDefault(keyframes.IndexOf(previousKeyframe) - 1) ?? previousKeyframe;
                var p3 = keyframes.ElementAtOrDefault(keyframes.IndexOf(nextKeyframe) + 1) ?? nextKeyframe;
                eval = CatmullRom(p0.Value, previousKeyframe.Value, nextKeyframe.Value, p3.Value, tEasing);
            }
            else throw new NotImplementedException();
        }

        return Drive(eval, t);
    }

    /// <summary>
    /// Where a keyframe's bezier handles actually sit, deriving the automatic ones.
    /// </summary>
    /// <remarks>
    /// The editor needs this to DRAW handles, and to seed the stored coordinates when a handle
    /// is switched to a user-positioned type - without which the curve would jump the moment
    /// "Free" was picked, because the stored coordinates start at the origin.
    /// </remarks>
    public (Handle Left, Handle Right) ResolveHandles(Keyframe key)
    {
        var sorted = Keyframes.OrderBy(k => k.t).ToList();
        int index = sorted.IndexOf(key);
        if (index < 0)
            return (new Handle(key.t, key.Value), new Handle(key.t, key.Value));

        return (BezierHandles.Resolve(sorted, index, leftSide: true),
                BezierHandles.Resolve(sorted, index, leftSide: false));
    }

    /// <summary>Applies the channel's audio driver, if it has one, on top of an interpolated value.</summary>
    private double Drive(double eval, double t) =>
        AudioChannelDriver is not null ? AudioChannelDriver.Apply(eval, t) : eval;

    /// <summary>
    /// Evaluates a <see cref="KeyframeType"/> preset over a segment, given its NORMALIZED
    /// parameter.
    /// </summary>
    /// <remarks>
    /// <paramref name="tNorm"/> is deliberately the un-eased parameter: the legacy
    /// <see cref="Keyframe.EasingPower"/> ramp is a different mechanism from these presets and
    /// composing the two would shape every segment twice.
    /// </remarks>
    private static double EvaluatePreset(List<Keyframe> keyframes, Keyframe previous, Keyframe next, double tNorm)
    {
        switch (previous.Type)
        {
            case KeyframeType.Constant:
                return previous.Value;

            case KeyframeType.Linear:
                return (1.0 - tNorm) * previous.Value + tNorm * next.Value;

            case KeyframeType.Bezier:
                int index = keyframes.IndexOf(previous);
                (Handle right, Handle left) = BezierHandles.ForSegment(keyframes, index);
                double t = previous.t + tNorm * (next.t - previous.t);
                return BezierHandles.EvaluateSegment(previous, right, left, next, t);

            default:
                double eased = Easing.Apply(previous.Type, previous.EasingDirection, tNorm, previous);
                return (1.0 - eased) * previous.Value + eased * next.Value;
        }
    }

    private static double InOutPowerEasing(double t, double pow1, double pow2)
    {
        if (t < 0.5)
            return Math.Pow(t * 2.0, pow1) / 2.0;
        return 1.0 - Math.Pow((1.0 - t) * 2.0, pow2) / 2.0;
    }

    private static double CatmullRom(double p0, double p1, double p2, double p3, double t)
    {
        var h1 = 2 * t * t * t - 3 * t * t + 1;
        var h2 = -2 * t * t * t + 3 * t * t;
        var h3 = t * t * t - 2 * t * t + t;
        var h4 = t * t * t - t * t;
        var t1 = (p2 - p0) / 2;
        var t2 = (p3 - p1) / 2;
        return h1 * p1 + h2 * p2 + h3 * t1 + h4 * t2;
    }

}