Search code examples
c#.netmathjoystick

C# - Joystick sensitivity formula


How to calculate joystick sensitivity, taking into account deadzone and the circular nature of the stick?

I'm working on a class that represents a stick of a gamepad. I'm having trouble with the mathematics of it, specifically with the sensitivity part. Sensitivity should make the joystick's distance from center non-linear. I applied sensitivity on a X-Box trigger without problems, but because a joystick has two axis (X and Y), I'm having trouble with the math involved.

I want to apply circular sensitivity to the stick, but I don't really know how to do that, specially taking into account other calculations on the axes (like deadzone, distance from center, etc.). How sould I accomplish that?


Additional details about the problem

Right now, I already have my temporary fix which is not working very well. It seems to be working when the joystick direction is either horizontal or vertical, but when I move it to a diagonal direction, is seems buged. My Joystick class has a Distance property, which retrieves the stick's distance from center (a value from 0 to 1). My Distance property is working well, but when I apply the sensitivity, the retrieved distance is less than 1 on diagonal directions if I move my josytick around, when it should be exactly 1, no matter the direction.

Below, I'm including a simplified version of my Joystick class, where I removed most of the unrelevant code. The calculated X and Y positions of the axes are retrieved by ComputedX and ComputedY properties. Each of this properties should include its axis final position (from -1 to 1) taking into account all the modifiers (deadzone, saturation, sensitivity, etc.).

public class Joystick
{

    // Properties

    // Physical axis positions
    public double X { get; set;}
    public double Y { get; set; }
    // Virtual axis positions, with all modifiers applied (like deadzone, sensitivity, etc.)
    public double ComputedX { get => ComputeX(); }
    public double ComputedY {get => ComputeY(); }
    // Joystick modifiers, which influence the computed axis positions 
    public double DeadZone { get; set; }
    public double Saturation { get; set; }
    public double Sensitivity { get; set; }
    public double Range { get; set; }
    public bool InvertX { get; set; }
    public bool InvertY { get; set; }
    // Other properties
    public double Distance
    {
        get => CoerceValue(Math.Sqrt((ComputedX * ComputedX) + (ComputedY * ComputedY)), 0d, 1d);
    }
    public double Direction { get => ComputeDirection(); }


    // Methods

    private static double CoerceValue(double value, double minValue, double maxValue)
    {
        return (value < minValue) ? minValue : ((value > maxValue) ? maxValue : value);
    }


    protected virtual double ComputeX()
    {
        double value = X;
        value = CalculateDeadZoneAndSaturation(value, DeadZone, Saturation);
        value = CalculateSensitivity(value, Sensitivity);
        value = CalculateRange(value, Range);
        if (InvertX) value = -value;
        return CoerceValue(value, -1d, 1d);
    }


    protected virtual double ComputeY()
    {
        double value = Y;
        value = CalculateDeadZoneAndSaturation(value, DeadZone, Saturation);
        value = CalculateSensitivity(value, Sensitivity);
        value = CalculateRange(value, Range);
        if (InvertY) value = -value;
        return CoerceValue(value, -1d, 1d);
    }


    /// <sumary>Gets the joystick's direction (from 0 to 1).</summary>
    private double ComputeDirection()
    {
        double x = ComputedX;
        double y = ComputedY;
        if (x != 0d && y != 0d)
        {
            double angle = Math.Atan2(x, y) / (Math.PI * 2d);
            if (angle < 0d) angle += 1d;
            return CoerceValue(angle, 0d, 1d);
        }
        return 0d;
    }


    private double CalculateDeadZoneAndSaturation(double value, double deadZone, double saturation)
    {
        deadZone = CoerceValue(deadZone, 0.0d, 1.0d);
        saturation = CoerceValue(saturation, 0.0d, 1.0d);

        if ((deadZone > 0) | (saturation < 1))
        {
            double distance = CoerceValue(Math.Sqrt((X * X) + (Y * Y)), 0.0d, 1.0d);
            double directionalDeadZone = Math.Abs(deadZone * (value / distance));
            double directionalSaturation = 1 - Math.Abs((1 - saturation) * (value / distance));

            double edgeSpace = (1 - directionalSaturation) + directionalDeadZone;
            double multiplier = 1 / (1 - edgeSpace);
            if (multiplier != 0)
            {
                if (value > 0)
                {
                    value = (value - directionalDeadZone) * multiplier;
                    value = CoerceValue(value, 0, 1);
                }
                else
                {
                    value = -((Math.Abs(value) - directionalDeadZone) * multiplier);
                    value = CoerceValue(value, -1, 0);
                }
            }
            else
            {
                if (value > 0)
                    value = CoerceValue(value, directionalDeadZone, directionalSaturation);
                else
                    value = CoerceValue(value, -directionalSaturation, -directionalDeadZone);
            }
            value = CoerceValue(value, -1, 1);
        }

        return value;
    }


    private double CalculateSensitivity(double value, double sensitivity)
    {
        value = CoerceValue(value, -1d, 1d);

        if (sensitivity != 0)
        {
            double axisLevel = value;
            axisLevel = axisLevel + ((axisLevel - Math.Sin(axisLevel * (Math.PI / 2))) * (sensitivity * 2));
            if ((value < 0) & (axisLevel > 0))
                axisLevel = 0;
            if ((value > 0) & (axisLevel < 0))
                axisLevel = 0;
            value = CoerceValue(axisLevel, -1d, 1d);
        }

        return value;
    }


    private double CalculateRange(double value, double range)
    {
        value = CoerceValue(value, -1.0d, 1.0d);
        range = CoerceValue(range, 0.0d, 1.0d);
        if (range < 1)
        {
            double distance = CoerceValue(Math.Sqrt((X * X) + (Y * Y)), 0d, 1d);
            double directionalRange = 1 - Math.Abs((1 - range) * (value / distance));
            value *= CoerceValue(directionalRange, 0d, 1d);
        }
        return value;
    }

}

I tried to make this question as short as possible, but it's hard for me to explain this specific problem without describing some details about it. I know I should keep it short, but I would like to write at least a few more words:

Thank you for having the time to read all this!


Solution

  • After searching a bit for geometry math on the Internet, I finally found out the solution to my problem. I'm really bad at math, but now I know that it is actually very simple.

    Instead of applying deadzone and sensitivity for each axis independently, I should apply them to the joystick radius. So, to do that, I just need to convert my joystick's cartesian coordinates (X and Y) to polar coordinates (Radius and Angle). Then, I apply deadzone sensitivity and all modifiers I want on the radius coordinate and convert it back to cartesian coordianates.

    I'm posting here the code I'm using now. This looks far simpler and cleaner than the code on my question above:

    private void ComputeCoordinates()
    {
        // Convert to polar coordinates.
        double r = CoerceValue(Math.Sqrt((X * X) + (Y * Y)), 0d, 1d);  // Radius;
        double a = Math.Atan2(Y, X);  // Angle (in radians);
    
        // Apply modifiers.
        double value = ComputeModifiers(r);
    
        // Convert to cartesian coordinates.
        double x = value * Math.Cos(a);
        double y = value * Math.Sin(a);
    
        // Apply axis independent modifiers.
        if (InvertX) x = -x;
        if (InvertY) y = -y;
    
        // Set calculated values to property values;
        _computedX = x;
        _computedY = y;
    }
    
    
    private double ComputeModifiers(double value)
    {
        // Apply dead-zone and saturation.
        if (DeadZone > 0d || Saturation < 1d)
        {
            double edgeSpace = (1 - Saturation) + DeadZone;
            if (edgeSpace < 1d)
            {
                double multiplier = 1 / (1 - edgeSpace);
                value = (value - DeadZone) * multiplier;
                value = CoerceValue(value, 0d, 1d);
            }
            else
            {
                value = Math.Round(value);
            }
        }
    
        // Apply sensitivity.
        if (Sensitivity != 0d)
        {
            value = value + ((value - Math.Sin(value * (Math.PI / 2))) * (Sensitivity * 2));
            value = CoerceValue(value, 0d, 1d);
        }
    
        // Apply range.
        if (Range < 1d)
        {
            value = value * Range;
        }
    
        // Return calculated value.
        return CoerceValue(value, 0d, 1d);
    }
    

    Explanation of the code above

    1. Convert the physical joystick's X and Y coordinates to polar coordinates;
    2. Apply deadzone, saturation, sensitivity and range modifiers to the radius coordinate;
    3. Convert back to cartesian coordiantes (X and Y) using the original angle and the modified radius;
    4. Optional: apply axis independent modifiers to each of the new axis (in this case, I'm just inverting each axis if the user wants the axis to be inverted);
    5. Done. Every modifier is now applied in a circular way, no matter the direction I move the joystick;

    Well, this situation had cost me about a day of work, because I didn't found anything related to my problem on Internet and I didn't know very well how to search for the solution, but I hope other people getting to this question may find this useful.

    Here are some references about cartesian and polar coordinate systems:

    https://en.wikipedia.org/wiki/Cartesian_coordinate_system

    https://en.wikipedia.org/wiki/Polar_coordinate_system

    https://social.msdn.microsoft.com/Forums/vstudio/en-US/9f120a35-dcac-42ab-b763-c65f3c39afdc/conversion-between-cartesian-to-polar-coordinates-and-back?forum=vbgeneral