Effigy/Rig/AnimClip.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// A baked animation: one local-to-parent pose per bone per frame.
///
/// BAKED, NOT KEYED, and that is the whole design rather than a simplification. Every tool that
/// authors animation against this kernel keeps its own curve model — Marionette's `.riganim` has
/// keyframes, five easing modes and IK solves layered over each other — and none of that survives
/// a model compiler anyway: ModelDoc resamples to a fixed rate on import regardless of what the
/// source file thought it was doing. Sampling to a flat frame list at the boundary means the
/// kernel never has to know what a curve is, and the authoring side keeps exactly one job — say
/// where every bone is on frame N.
///
/// POSES ARE LOCAL TO THE PARENT, matching <see cref="Bone.Local"/> and every skeletal format
/// worth naming. A world-space pose list would need the skeleton to mean anything at all, and
/// would come apart the moment a parent moved — which is the normal case, not the edge one.
///
/// A BONE THAT IS NEVER POSED STILL GETS A FRAME. There is no "no value here" entry, because a
/// hole would have to be filled by whoever reads it and every reader would fill it differently.
/// The sampler writes the bind pose for an untouched bone, which is what the animator saw in the
/// viewport and therefore what they meant.
/// </summary>
public sealed class AnimClip
{
/// <summary>What the clip is called inside the compiled model — the name AnimGraph and
/// <c>SetAnimParameter</c> end up seeing, so it is not decorative.</summary>
public string Name = "anim";
/// <summary>Frames per second the frames below are sampled at.</summary>
public float FrameRate = 30f;
public bool Looping;
/// <summary>Frames[frame][bone], the bone index being its position in
/// <see cref="Skeleton.Bones"/>. Every frame is the same length as the skeleton.</summary>
public List<Xform[]> Frames = new();
public int FrameCount => Frames.Count;
/// <summary>
/// Length in seconds.
///
/// N frames span N-1 intervals, not N — a two-frame clip at 30fps lasts a thirtieth of a
/// second, not a fifteenth. Getting this wrong stretches every clip by one frame, which reads
/// as animation that drifts slowly out of sync with anything it was timed against rather than
/// as an obvious bug.
/// </summary>
public float Duration => FrameCount <= 1 ? 0f : (FrameCount - 1) / MathF.Max( FrameRate, 0.0001f );
/// <summary>The time in seconds at which a given frame lands.</summary>
public float TimeOf( int frame ) => frame / MathF.Max( FrameRate, 0.0001f );
public void AddFrame( Xform[] pose )
{
if ( pose is null )
throw new ArgumentNullException( nameof( pose ) );
Frames.Add( pose );
}
/// <summary>
/// Check the clip against the skeleton it claims to animate, and say what is wrong rather than
/// leaving it to be discovered as a model that will not load.
///
/// Returns null when the clip is writable. A ragged frame list is the mistake worth catching
/// here: it comes from a sampler that grew a bone mid-loop, and downstream it is an index walk
/// off the end of an array in the middle of writing a file.
/// </summary>
public string Validate( Skeleton skeleton )
{
if ( skeleton is null || skeleton.Count == 0 )
return "a clip needs a skeleton with at least one bone";
if ( FrameCount == 0 )
return "a clip needs at least one frame";
if ( FrameRate <= 0f )
return $"frame rate must be positive, not {FrameRate}";
for ( var f = 0; f < Frames.Count; f++ )
{
if ( Frames[f] is null )
return $"frame {f} is null";
if ( Frames[f].Length != skeleton.Count )
return $"frame {f} has {Frames[f].Length} pose(s) for a skeleton of {skeleton.Count} bone(s)";
}
return null;
}
}