Code/HumanoidRetargeter/Core/Formats/Mmd/MmdPlaybackData.cs

Data container and builder for sampled MMD (PMX/PMD) playback. Stores model bytes, per-frame bone transforms, morph curves and soft-body samples, can create a playback payload from a solved retargeting clip, validate/deserialize payloads, and provide per-frame bone transforms.

File Access
using System.Numerics;
using System.Text.Json;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;

namespace HumanoidRetargeter.Core.Formats.Mmd;

using Vector3 = System.Numerics.Vector3;

/// <summary>Versioned, self-contained MMD mesh and sampled animation for native playback.</summary>
public sealed class MmdPlaybackData
{
	/// <summary>Payload schema version.</summary>
	public int Version { get; set; } = 1;
	/// <summary>Original PMX/PMD bytes, preserving all deformation and material definitions.</summary>
	public byte[] Model { get; set; } = Array.Empty<byte>();
	/// <summary>Sample rate.</summary>
	public float Fps { get; set; }
	/// <summary>Number of sampled poses.</summary>
	public int FrameCount { get; set; }
	/// <summary>Whether playback repeats.</summary>
	public bool Looping { get; set; }
	/// <summary>Seven floats per bone per frame: skinning translation and quaternion in MMD coordinates.</summary>
	public float[] Transforms { get; set; } = Array.Empty<float>();
	/// <summary>Morph controls sampled at the same rate as the poses.</summary>
	public Dictionary<string, float[]> Morphs { get; set; } = new();
	/// <summary>Original vertex indices with baked soft-body positions.</summary>
	public int[] SoftVertices { get; set; } = Array.Empty<int>();
	/// <summary>Three floats per soft vertex per frame, in MMD coordinates.</summary>
	public float[] SoftPositions { get; set; } = Array.Empty<float>();

	/// <summary>Builds playback for a PMX/PMD target from the retargeter's final solved clip.</summary>
	public static MmdPlaybackData Create( byte[] modelBytes, RetargetTargetSpec target, ClipResult clip )
	{
		var model = MmdModelReader.Read( modelBytes );
		var frames = clip.SolvedFrames ?? throw new ArgumentException( "The clip's solved frames have been released." );
		var skeleton = target.Rig.Skeleton;
		var bones = model.Bones.Select( bone => MmdBoneNames.Find( skeleton, bone.Name ) ).ToArray();
		if ( bones.Any( index => index < 0 ) )
			throw new ArgumentException( "MMD playback requires the matching PMX/PMD target skeleton." );
		var result = new MmdPlaybackData
		{
			Model = modelBytes, Fps = clip.Fps, FrameCount = frames.Count, Looping = clip.Looping,
			Transforms = new float[ checked(frames.Count * bones.Length * 7) ],
			Morphs = clip.MorphWeights.ToDictionary( entry => entry.Key, entry => entry.Value ),
		};
		// ModelDoc can rotate the entire bind into its forward axis. MMD bind world
		// rotations are identity, so this anchor recovers the original model space.
		var anchor = skeleton.RestWorld[ bones[ 0 ] ];
		var basis = Quaternion.Inverse( anchor.Rot );
		var scale = target.UpAxis == TargetUpAxis.ZUpEngine ? RetargetTargetSpec.SboxSourceScale : 1f;
		var origin = MmdModel.Position( model.Bones[ 0 ].Position ) - Vector3.Transform( anchor.Pos, basis ) / scale;
		for ( var frame = 0; frame < frames.Count; frame++ )
		{
			var world = new Pose( frames[ frame ] ).ToWorld( skeleton );
			for ( var i = 0; i < bones.Length; i++ )
			{
				var current = world[ bones[ i ] ];
				var rest = skeleton.RestWorld[ bones[ i ] ];
				var rotation = Quaternion.Normalize( current.Rot * Quaternion.Inverse( rest.Rot ) );
				var position = current.Pos - Vector3.Transform( rest.Pos, rotation );
				rotation = Quaternion.Normalize( basis * rotation * Quaternion.Inverse( basis ) );
				position = Vector3.Transform( position, basis ) / scale + origin - Vector3.Transform( origin, rotation );
				position = MmdModel.Position( position ) / (MmdModel.UnitScaleCm * MmdModel.UnitScaleCm);
				rotation = MmdModel.Rotation( rotation );
				var offset = (frame * bones.Length + i) * 7;
				result.Transforms[ offset ] = position.X;
				result.Transforms[ offset + 1 ] = position.Y;
				result.Transforms[ offset + 2 ] = position.Z;
				result.Transforms[ offset + 3 ] = rotation.X;
				result.Transforms[ offset + 4 ] = rotation.Y;
				result.Transforms[ offset + 5 ] = rotation.Z;
				result.Transforms[ offset + 6 ] = rotation.W;
			}
		}
		MmdSoftBodyBake.Apply( result, model );
		return result;
	}

	/// <summary>Serializes the payload for an MMD playback resource.</summary>
	public string ToJson() => JsonSerializer.Serialize( this );

	/// <summary>Reads and validates a playback payload.</summary>
	public static MmdPlaybackData FromJson( string json )
	{
		var data = JsonSerializer.Deserialize<MmdPlaybackData>( json ) ?? throw new FormatException( "Empty MMD playback data." );
		var model = MmdModelReader.Read( data.Model );
		if ( data.Version != 1 || data.FrameCount <= 0 || !float.IsFinite( data.Fps ) || data.Fps <= 0 || data.Fps > 240
			|| (long)data.FrameCount * model.Bones.Count * 7 != data.Transforms.Length
			|| (long)data.FrameCount * data.SoftVertices.Length * 3 != data.SoftPositions.Length
			|| data.SoftVertices.Any( i => i < 0 || i >= model.Vertices.Count )
			|| data.Transforms.Any( value => !float.IsFinite( value ) ) || data.SoftPositions.Any( value => !float.IsFinite( value ) )
			|| data.Morphs.Values.Any( values => values.Length != data.FrameCount || values.Any( value => !float.IsFinite( value ) ) ) )
			throw new FormatException( "Invalid MMD playback data." );
		return data;
	}

	internal XForm TransformAt( int frame, int bone, int boneCount )
	{
		var offset = (frame * boneCount + bone) * 7;
		return new XForm( new Vector3( Transforms[ offset ], Transforms[ offset + 1 ], Transforms[ offset + 2 ] ),
			Quaternion.Normalize( new Quaternion( Transforms[ offset + 3 ], Transforms[ offset + 4 ], Transforms[ offset + 5 ], Transforms[ offset + 6 ] ) ) );
	}
}