Code/HumanoidRetargeter/Core/Sprites/SpriteBake.cs

The SpriteBake class generates a list of byte arrays, where each array represents an RGBA row of a baked 2D character sprite. It uses the solved poses of a 3D armature to calculate bone positions and render the sprite skin onto a 2D canvas.

#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;

namespace HumanoidRetargeter.Core.Sprites;

using Vector2 = System.Numerics.Vector2; // s&box compat: shadow engine's global-namespace Vector2 (see Code/HumanoidRetargeter/Assembly.cs)
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>
/// Redraws a solved 2D clip frame by frame: the posed armature gives each
/// <see cref="SpriteCharacter"/> bone its place on the canvas, and <see cref="SpriteSkin"/>
/// draws the skin there, as Bonebase does. The result is pixels, not a model bent into shape.
/// </summary>
public static class SpriteBake
{
    /// <summary>Bonebase's clip rate.</summary>
    public const float FramesPerSecond = 12f;

    /// <summary>Joints this close on the canvas (px) are one joint: the child ends its parent.</summary>
    private const float ConnectedDistance = 1.5f;

    /// <summary>Depth from the hips that puts a part over or under the body, as a share of the
    /// character's height, and how much further it must go to change sides (retarget.lua).</summary>
    private const float DepthMargin = 0.03f, DepthHold = 1.5f;

    private sealed record BoneLink(int RigBone, int TipBone, Vector3 RestTail);

    /// <summary>
    /// Draws the clip at <see cref="FramesPerSecond"/>, each frame RGBA row by row.
    /// <paramref name="solved"/> are the local transforms of a clip solved through
    /// <paramref name="view"/> onto <paramref name="rig"/>, which must be the armature baked
    /// from <paramref name="character"/>.
    /// </summary>
    /// <exception cref="ArgumentException">A character bone has no bone on the rig.</exception>
    public static List<byte[]> Frames(
        SpriteCharacter character, TargetRig rig, IReadOnlyList<XForm[]> solved, float fps, bool isLooping, PlanarView view)
    {
        ArgumentNullException.ThrowIfNull(character);
        ArgumentNullException.ThrowIfNull(rig);
        ArgumentNullException.ThrowIfNull(solved);

        var (up, toward) = PlanarSolver.CameraAxes(rig, view);
        var right = Vector3.Cross(up, toward);
        var rest = rig.Skeleton.RestWorld;
        var bones = character.Bones;
        var rigBones = new int[bones.Count];
        for (var i = 0; i < bones.Count; i++)
            rigBones[i] = FindBone(rig, bones[i].Name)
                ?? throw new ArgumentException($"The rig has no bone for the 2D character's '{bones[i].Name}'.", nameof(rig));

        var origin = bones[0].Head;
        var restOrigin = rest[rigBones[0]].Pos;
        var scale = PixelsPerUnit(character, rest, rigBones, origin, restOrigin, right, up);
        Vector2 ToCanvas(Vector3 position)
        {
            var offset = position - restOrigin;
            return origin + new Vector2(Vector3.Dot(offset, right), -Vector3.Dot(offset, up)) * scale;
        }

        var links = new BoneLink[bones.Count];
        for (var i = 0; i < bones.Count; i++)
        {
            var line = (bones[i].Tail - bones[i].Head) / scale;
            links[i] = new BoneLink(rigBones[i], TipOf(character, rig, rigBones, i), right * line.X - up * line.Y);
        }

        var margin = DepthMargin * character.Height / scale;
        var sides = new int[character.Parts.Count];
        var firstBones = FirstBones(character);
        var heads = new Vector2[bones.Count];
        var tails = new Vector2[bones.Count];
        var frames = new List<byte[]>();
        foreach (var index in SampleFrames(solved.Count, fps, isLooping))
        {
            var world = new Pose(solved[index]).ToWorld(rig.Skeleton);
            for (var i = 0; i < bones.Count; i++)
            {
                var link = links[i];
                var head = world[link.RigBone].Pos;
                heads[i] = ToCanvas(head);
                if (link.TipBone >= 0)
                {
                    tails[i] = ToCanvas(world[link.TipBone].Pos);
                }
                else
                {
                    var turn = MathQ.Normalize(world[link.RigBone].Rot * Quaternion.Conjugate(rest[link.RigBone].Rot));
                    tails[i] = ToCanvas(head + Vector3.Transform(link.RestTail, turn));
                }
            }
            frames.Add(SpriteSkin.Draw(character, heads, tails,
                Stacking(character, firstBones, rigBones, world, toward, margin, sides)));
        }
        return frames;
    }

    /// <summary>The solved frames that land on each output frame.</summary>
    private static IEnumerable<int> SampleFrames(int count, float fps, bool isLooping)
    {
        var duration = Math.Max(0, count - 1) / fps;
        var frames = Math.Max(1, (int)MathF.Round(duration * FramesPerSecond) + (isLooping ? 0 : 1));
        for (var i = 0; i < frames; i++)
            yield return Math.Min(count - 1, (int)MathF.Round(i / FramesPerSecond * fps));
    }

    private static int? FindBone(TargetRig rig, string name)
    {
        var wanted = Plain(name);
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            if (Plain(rig.Skeleton[i].Name) == wanted)
                return i;
        }
        return null;
    }

    private static string Plain(string name) => name.Replace("_", "").Replace(".", "").ToLowerInvariant();

    private static float PixelsPerUnit(
        SpriteCharacter character, IReadOnlyList<XForm> rest, int[] rigBones, Vector2 origin, Vector3 restOrigin, Vector3 right, Vector3 up)
    {
        float canvas = 0f, rig = 0f;
        for (var i = 1; i < rigBones.Length; i++)
        {
            canvas += Vector2.Distance(character.Bones[i].Head, origin);
            var offset = rest[rigBones[i]].Pos - restOrigin;
            rig += new Vector2(Vector3.Dot(offset, right), Vector3.Dot(offset, up)).Length();
        }
        if (rig <= 0f)
            throw new ArgumentException("The rig's 2D character bones are all on one spot.", nameof(rest));
        return canvas / rig;
    }

    /// <summary>The joint a bone's tail follows, so a bone that shortens is drawn shorter: a
    /// connected child, or the toe at the end of a foot. -1 when the tail just turns with it.</summary>
    private static int TipOf(SpriteCharacter character, TargetRig rig, int[] rigBones, int bone)
    {
        var tail = character.Bones[bone].Tail;
        for (var i = 0; i < character.Bones.Count; i++)
        {
            if (i != bone && character.Bones[i].Part == character.Bones[bone].Part
                && Vector2.Distance(character.Bones[i].Head, tail) < ConnectedDistance)
                return rigBones[i];
        }
        var name = character.Bones[bone].Name;
        return name.StartsWith("foot", StringComparison.Ordinal)
            ? FindBone(rig, "toe" + name["foot".Length..]) ?? -1
            : -1;
    }

    private static int[] FirstBones(SpriteCharacter character)
    {
        var first = new int[character.Parts.Count];
        for (var p = 0; p < first.Length; p++)
        {
            first[p] = -1;
            for (var i = 0; i < character.Bones.Count && first[p] < 0; i++)
            {
                if (character.Bones[i].Part == character.Parts[p].Id)
                    first[p] = i;
            }
        }
        return first;
    }

    /// <summary>retarget.lua's depth order: a part clearly in front of the hips goes over what
    /// is level with them, one clearly behind goes under, and inside each side the rig's own
    /// order holds. A part already on a side stays until it is well past.</summary>
    private static List<int> Stacking(
        SpriteCharacter character, int[] firstBones, int[] rigBones, XForm[] world, Vector3 toward, float margin, int[] sides)
    {
        var hips = Vector3.Dot(world[rigBones[0]].Pos, toward);
        var order = new List<int>(sides.Length);
        for (var p = 0; p < sides.Length; p++)
        {
            order.Add(p);
            if (firstBones[p] < 0)
                continue;
            var depth = Vector3.Dot(world[rigBones[firstBones[p]]].Pos, toward) - hips;
            var limit = sides[p] != 0 && depth > 0f == sides[p] > 0 ? margin / DepthHold : margin;
            sides[p] = depth > limit ? 1 : depth < -limit ? -1 : 0;
        }
        var stacking = sides;
        order.Sort((a, b) =>
        {
            if (stacking[a] != stacking[b])
                return stacking[a].CompareTo(stacking[b]);
            var layers = character.Parts[a].Layer.CompareTo(character.Parts[b].Layer);
            return layers != 0 ? layers : a.CompareTo(b);
        });
        return order;
    }
}