Code/HumanoidRetargeter/Core/Sprites/SpriteSkin.cs

The file defines a static utility class for drawing humanoid character sprites using signed distance fields. It converts bone positions and shape data into a raw RGBA byte array representing the rendered sprite, including color and outline.

#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;

namespace HumanoidRetargeter.Core.Sprites;

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

/// <summary>
/// Draws a <see cref="SpriteCharacter"/> in a pose, ported from Bonebase's skin.lua: each
/// bone a signed distance field (an uneven capsule, plus bulges), the bones of a part merged
/// with a smooth minimum, and a one-pixel outline around the whole part. Being redrawn, the
/// skin keeps its radii however a bone turns or shortens, with no seam at a joint.
/// </summary>
public static class SpriteSkin
{
    /// <summary>The outline's brightness against the part's colour.</summary>
    private const float LineShade = 0.55f;

    private const double NearZero = 1e-9;

    private static readonly (int Ax, int Ay, int Bx, int By)[] CornerPairs =
    {
        (0, -1, 1, 0), (1, 0, 0, 1), (0, 1, -1, 0), (-1, 0, 0, -1),
    };

    /// <summary>
    /// Draws one frame as RGBA bytes, row by row. <paramref name="heads"/> and
    /// <paramref name="tails"/> place each bone on the canvas, indexed like
    /// <see cref="SpriteCharacter.Bones"/>; <paramref name="stacking"/> lists part indices
    /// bottom first.
    /// </summary>
    public static byte[] Draw(SpriteCharacter character, Vector2[] heads, Vector2[] tails, IReadOnlyList<int> stacking)
    {
        ArgumentNullException.ThrowIfNull(character);
        var pixels = new byte[character.Width * character.Height * 4];
        foreach (var index in stacking)
            DrawPart(character, character.Parts[index], heads, tails, pixels);
        return pixels;
    }

    private static void DrawPart(SpriteCharacter character, SpritePart part, Vector2[] heads, Vector2[] tails, byte[] pixels)
    {
        var bones = new List<int>();
        for (var i = 0; i < character.Bones.Count; i++)
        {
            if (character.Bones[i].Part == part.Id)
                bones.Add(i);
        }
        if (bones.Count == 0)
            return;

        var (x0, y0, x1, y1) = Bounds(character, part, bones, heads, tails);
        if (x1 < x0 || y1 < y0)
            return;
        var width = x1 - x0 + 1;
        var height = y1 - y0 + 1;

        var mask = new bool[width * height];
        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
                mask[y * width + x] = PartDistance(character, part, bones, heads, tails, new Vector2(x0 + x + 0.5f, y0 + y + 0.5f)) <= 0f;
        }

        var line = Outline(mask, width, height);
        var lineRed = (byte)(part.Red * LineShade);
        var lineGreen = (byte)(part.Green * LineShade);
        var lineBlue = (byte)(part.Blue * LineShade);
        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
            {
                var k = y * width + x;
                if (!mask[k])
                    continue;
                var i = ((y0 + y) * character.Width + x0 + x) * 4;
                var isLine = line[k];
                pixels[i] = isLine ? lineRed : part.Red;
                pixels[i + 1] = isLine ? lineGreen : part.Green;
                pixels[i + 2] = isLine ? lineBlue : part.Blue;
                pixels[i + 3] = 255;
            }
        }
    }

    private static (int X0, int Y0, int X1, int Y1) Bounds(
        SpriteCharacter character, SpritePart part, List<int> bones, Vector2[] heads, Vector2[] tails)
    {
        var min = new Vector2(float.MaxValue);
        var max = new Vector2(float.MinValue);
        foreach (var i in bones)
        {
            var bone = character.Bones[i];
            var margin = new Vector2(MathF.Max(bone.HeadRadius, bone.TailRadius) + MaxBulge(bone) + part.Blend + 1f);
            min = Vector2.Min(min, Vector2.Min(heads[i], tails[i]) - margin);
            max = Vector2.Max(max, Vector2.Max(heads[i], tails[i]) + margin);
        }
        return (Math.Max(0, (int)MathF.Floor(min.X)), Math.Max(0, (int)MathF.Floor(min.Y)),
            Math.Min(character.Width - 1, (int)MathF.Ceiling(max.X)),
            Math.Min(character.Height - 1, (int)MathF.Ceiling(max.Y)));
    }

    private static float MaxBulge(SpriteBone bone)
    {
        var most = 0f;
        foreach (var bulge in bone.BulgeLeft)
            most = MathF.Max(most, bulge.Radius);
        foreach (var bulge in bone.BulgeRight)
            most = MathF.Max(most, bulge.Radius);
        return most;
    }

    private static double PartDistance(
        SpriteCharacter character, SpritePart part, List<int> bones, Vector2[] heads, Vector2[] tails, Vector2 point)
    {
        var distance = double.PositiveInfinity;
        foreach (var i in bones)
        {
            var bone = BoneDistance(character.Bones[i], heads[i], tails[i], point);
            distance = double.IsPositiveInfinity(distance) ? bone : SmoothMin(distance, bone, part.Blend);
        }
        return distance;
    }

    private static double SmoothMin(double a, double b, double radius)
    {
        if (radius <= 0)
            return Math.Min(a, b);
        var h = Math.Max(radius - Math.Abs(a - b), 0) / radius;
        return Math.Min(a, b) - h * h * radius * 0.25;
    }

    /// <summary>Signed distance to a bone's skin: Inigo Quilez's uneven capsule. The length is
    /// the posed one, so a shortened bone is a shorter capsule of the same radii.</summary>
    private static double BoneDistance(SpriteBone bone, Vector2 head, Vector2 tail, Vector2 point)
    {
        double dx = tail.X - head.X, dy = tail.Y - head.Y;
        double px = point.X - head.X, py = point.Y - head.Y;
        double headRadius = bone.HeadRadius, tailRadius = bone.TailRadius;
        var length = Math.Sqrt(dx * dx + dy * dy);
        if (!(length > NearZero))
            return Math.Sqrt(px * px + py * py) - Math.Max(headRadius, tailRadius);

        var along = (px * dx + py * dy) / length;
        var side = (-px * dy + py * dx) / length;
        var across = Math.Abs(side);
        var slope = (headRadius - tailRadius) / length;

        double distance;
        if (slope * slope >= 1)
        {
            distance = Math.Min(
                Math.Sqrt(along * along + side * side) - headRadius,
                Math.Sqrt((along - length) * (along - length) + side * side) - tailRadius);
        }
        else
        {
            var a = Math.Sqrt(1 - slope * slope);
            var k = -slope * across + a * along;
            if (k < 0)
                distance = Math.Sqrt(across * across + along * along) - headRadius;
            else if (k > a * length)
                distance = Math.Sqrt(across * across + (along - length) * (along - length)) - tailRadius;
            else
                distance = across * a + along * slope - headRadius;
        }

        var bulges = side < 0 ? bone.BulgeLeft : bone.BulgeRight;
        return bulges.Length > 0 ? distance - BulgeAt(bulges, along / length) : distance;
    }

    private static double BulgeAt(SpriteBulge[] bulges, double t)
    {
        if (t <= 0 || t >= 1)
            return 0;
        double previousAt = 0, previousRadius = 0;
        foreach (var bulge in bulges)
        {
            if (t <= bulge.At)
                return Ease(previousAt, previousRadius, bulge.At, bulge.Radius, t);
            (previousAt, previousRadius) = (bulge.At, bulge.Radius);
        }
        return Ease(previousAt, previousRadius, 1, 0, t);
    }

    private static double Ease(double t0, double d0, double t1, double d1, double t)
    {
        if (t1 <= t0)
            return d1;
        var u = (t - t0) / (t1 - t0);
        return d0 + (d1 - d0) * (1 - Math.Cos(Math.PI * u)) * 0.5;
    }

    /// <summary>Ink with an empty 4-neighbour, without the L corners a pixel artist would cut.</summary>
    private static bool[] Outline(bool[] mask, int width, int height)
    {
        bool Inside(int x, int y) => x >= 0 && y >= 0 && x < width && y < height && mask[y * width + x];

        var line = new bool[mask.Length];
        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
                line[y * width + x] = mask[y * width + x]
                    && !(Inside(x - 1, y) && Inside(x + 1, y) && Inside(x, y - 1) && Inside(x, y + 1));
        }

        bool IsLine(int x, int y) => x >= 0 && y >= 0 && x < width && y < height && line[y * width + x];

        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
            {
                if (!line[y * width + x])
                    continue;
                foreach (var (ax, ay, bx, by) in CornerPairs)
                {
                    if (IsLine(x + ax, y + ay) && IsLine(x + bx, y + by) && !IsLine(x - ax, y - ay)
                        && !IsLine(x - bx, y - by) && !IsLine(x + ax + bx, y + ay + by))
                    {
                        line[y * width + x] = false;
                        break;
                    }
                }
            }
        }
        return line;
    }
}